ssulbox/backend/generator/animation_samgukji/scraper.py

289 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""
나무위키 삼국지 인물 '썰 키워드' 스크래퍼
========================================
삼국지(후한 말~삼국시대) 주요 인물 28명의 나무위키 문서에서 목차(섹션 제목)를 긁어와
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
설치:
pip install playwright beautifulsoup4
playwright install chromium # (이미 설치돼 있으면 생략)
실행:
python scraper.py
옵션:
python scraper.py --headful # 브라우저 창 보이게
python scraper.py --only 조조 여포 # 특정 인물만
"""
import argparse
import asyncio
import csv
import json
import re
import sys
from datetime import datetime
from pathlib import Path
from playwright.async_api import async_playwright
# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
BASE = "https://namu.wiki/w/"
OUT_DIR = Path(__file__).parent / "data" # 삼국지 인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
# 삼국지 주요 인물 28명: (번호, 이름, 나무위키 문서 제목)
# 동음이의어가 있는 인물(장비/마초/원소 등)은 "(삼국지)" 를 붙인 문서 제목을 쓴다.
# 제목이 틀려도 worker 가 "이름" ↔ "이름(삼국지)" 를 자동으로 재시도한다.
SAMGUK_HEROES = [
# 위(魏)
(1, "조조", "조조"),
(2, "사마의", "사마의"),
(3, "하후돈", "하후돈"),
(4, "장료", "장료"),
(5, "허저", "허저"),
(6, "곽가", "곽가"),
(7, "순욱", "순욱"),
(8, "가후", "가후"),
# 촉(蜀)
(9, "유비", "유비"),
(10, "관우", "관우"),
(11, "장비", "장비(삼국지)"),
(12, "제갈량", "제갈량"),
(13, "조운", "조운(삼국지)"),
(14, "마초", "마초(삼국지)"),
(15, "황충", "황충(삼국지)"),
(16, "강유", "강유(삼국지)"),
(17, "방통", "방통(삼국지)"),
# 오(吳)
(18, "손권", "손권"),
(19, "손책", "손책"),
(20, "주유", "주유(삼국지)"),
(21, "육손", "육손(삼국지)"),
(22, "노숙", "노숙(삼국지)"),
(23, "감녕", "감녕"),
(24, "태사자", "태사자"),
# 군웅(群雄)
(25, "여포", "여포"),
(26, "동탁", "동탁"),
(27, "원소", "원소(삼국지)"),
(28, "원술", "원술"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
ANECDOTE_HINTS = [
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
"독살", "암살", "치세", "업적", "가족", "이름", "별명",
"연의", "정사", "전투", "최후", "민간", "창작물",
]
def clean_heading(text: str) -> str:
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
t = text.replace("[편집]", "").strip()
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
t = re.sub(r"^\s*[\d.]+\s*", "", t)
return t.strip()
def is_anecdote(keyword: str) -> bool:
return any(h in keyword for h in ANECDOTE_HINTS)
async def scrape_king(page, title: str):
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
url = BASE + title
result = {"title": title, "url": url, "page_title": None,
"keywords": [], "anecdote_keywords": [], "error": None}
try:
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
try:
await page.wait_for_selector(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
timeout=8000,
)
except Exception:
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
await page.wait_for_timeout(800)
# 문서 실제 제목
try:
result["page_title"] = (await page.title()).split(" - ")[0].strip()
except Exception:
pass
if resp and resp.status >= 400:
result["error"] = f"HTTP {resp.status}"
return result
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
keywords = await page.evaluate(
"""
() => {
const clean = s => s.replace('[편집]', '').trim();
let items = [];
// 1순위: 목차(table of contents)
const tocLinks = document.querySelectorAll(
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
);
if (tocLinks.length) {
items = Array.from(tocLinks).map(a => clean(a.textContent));
}
// 2순위(목차가 없으면): 본문 헤딩 태그
if (items.length === 0) {
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
items = Array.from(hs).map(h => clean(h.textContent));
}
return items.filter(Boolean);
}
"""
)
# 중복 제거 + 번호 정리
seen, cleaned = set(), []
for raw in keywords:
kw = clean_heading(raw)
if kw and kw not in seen and len(kw) <= 60:
seen.add(kw)
cleaned.append(kw)
result["keywords"] = cleaned
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
except Exception as e:
result["error"] = f"{type(e).__name__}: {e}"
return result
async def main():
parser = argparse.ArgumentParser()
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
parser.add_argument("--concurrency", type=int, default=4,
help="동시에 열 페이지 수 (기본 4)")
args = parser.parse_args()
kings = SAMGUK_HEROES
if args.only:
wanted = set(args.only)
kings = [k for k in SAMGUK_HEROES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in SAMGUK_HEROES))
return
OUT_DIR.mkdir(parents=True, exist_ok=True)
all_results = []
total = len(kings)
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not args.headful)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
locale="ko-KR",
)
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
async def _block(route):
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
await route.abort()
else:
await route.continue_()
await context.route("**/*", _block)
sem = asyncio.Semaphore(max(1, args.concurrency))
done = 0
async def worker(idx, name, title):
nonlocal done
# 문서 제목 후보: 지정 제목 → "이름" → "이름(삼국지)" 순으로 자동 재시도
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
titles = [title]
for alt in (name, f"{name}(삼국지)"):
if alt not in titles:
titles.append(alt)
async with sem:
page = await context.new_page()
try:
r = None
for t in titles:
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
# 실패하면 백오프하며 최대 3회까지 재시도
r = await scrape_king(page, t)
for attempt in range(3):
if not r["error"]:
break
await page.wait_for_timeout(1500 * (attempt + 1))
r = await scrape_king(page, t)
# 성공했고 목차도 나왔으면 이 제목으로 확정
if not r["error"] and r["keywords"]:
break
finally:
await page.close()
r["order"] = idx
r["name"] = name
done += 1
if r["error"]:
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
else:
print(f"[{done:2d}/{total}] {name} ({title}) "
f"키워드 {len(r['keywords'])}"
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
return r
all_results = await asyncio.gather(
*(worker(idx, name, title) for idx, name, title in kings)
)
all_results = sorted(all_results, key=lambda r: r["order"])
await browser.close()
# 저장 ---------------------------------------------------------------
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
json_path = OUT_DIR / f"samguk_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"samguk_keywords_{ts}.csv"
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
for r in all_results:
anec = set(r["anecdote_keywords"])
for kw in r["keywords"]:
w.writerow([r["order"], r["name"], r["title"],
"Y" if kw in anec else "", kw, r["url"]])
total_kw = sum(len(r["keywords"]) for r in all_results)
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
fails = [r["name"] for r in all_results if r["error"]]
print("\n" + "=" * 50)
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}")
if fails:
print(f"수집 실패: {', '.join(fails)}")
print(f"JSON -> {json_path}")
print(f"CSV -> {csv_path}")
if __name__ == "__main__":
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
asyncio.run(main())