o2o-castad-backend/generator/animation_odyssey/scraper.py

277 lines
11 KiB
Python

# -*- coding: utf-8 -*-
"""
나무위키 오디세이아 등장인물 '썰 키워드' 스크래퍼
====================================================
오디세이아(오디세우스의 귀향 대모험)에 등장하는 주요 인물·신 20여 명의 나무위키 문서에서
목차(섹션 제목)를 긁어와 숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 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만.
# 오디세이아 서사에 '반드시 출현'하는 인물·괴물만: (번호, 이름, 나무위키 문서 제목)
# ★ 제우스/포세이돈/아테나/헤르메스 같은 범용 신 문서는 넣지 않는다 —
# 문서 전체 목차가 크롤되므로 오디세이아와 무관한 일반 신화 키워드(티타노마키아 등)가
# 섞여 들어와 엉뚱한 인물이 주인공인 영상이 만들어진다(2026-07 제우스 썰 사고).
# 신들의 개입 에피소드는 나레이션 프롬프트가 알아서 살을 붙인다.
# 동음이의어 문서는 worker 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
ODYSSEY_FIGURES = [
# 주인공·가족
(1, "오디세우스", "오디세우스"),
(2, "페넬로페", "페넬로페"),
(3, "텔레마코스", "텔레마코스"),
(4, "라에르테스", "라에르테스"),
# 귀향길 사건의 등장인물·괴물 (오디세이아 서사 전용)
(5, "폴리페모스", "폴리페모스"),
(6, "키르케", "키르케"),
(7, "칼립소", "칼립소"),
(8, "세이렌", "세이렌"),
(9, "스킬라", "스킬라"),
(10, "카립디스", "카립디스"),
(11, "아이올로스", "아이올로스"),
(12, "나우시카", "나우시카(그리스 로마 신화)"),
(13, "로토파고이", "로토파고이"),
# 이타카의 구혼자
(14, "안티노오스", "안티노오스"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
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 = ODYSSEY_FIGURES
if args.only:
wanted = set(args.only)
kings = [k for k in ODYSSEY_FIGURES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in ODYSSEY_FIGURES))
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}(그리스 로마 신화)", 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"odyssey_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"odyssey_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())