ssulbox/backend/generator/animation_greekroman/scraper.py

288 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 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
GREEK_FIGURES = [
# 올림포스 12신
(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 = GREEK_FIGURES
if args.only:
wanted = set(args.only)
kings = [k for k in GREEK_FIGURES if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 인물 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in GREEK_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"greek_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"greek_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())