ssulbox/backend/generator/animation/scraper.py

271 lines
10 KiB
Python

# -*- coding: utf-8 -*-
"""
나무위키 조선 왕 '썰 키워드' 스크래퍼
====================================
조선 27대 왕의 나무위키 문서에서 목차(섹션 제목)를 긁어와
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
설치:
pip install playwright beautifulsoup4
playwright install chromium # (이미 설치돼 있으면 생략)
실행:
python namu_joseon_scraper.py
옵션:
python namu_joseon_scraper.py --headful # 브라우저 창 보이게
python namu_joseon_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만.
# 조선 27대 왕: (대수, 묘호/군호, 나무위키 문서 제목)
# 군주는 보통 "묘호(조선)" 형태, 폐위된 임금(연산군/광해군)은 군호 단독.
JOSEON_KINGS = [
(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, "순종", "순종(대한제국)"),
]
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
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 = JOSEON_KINGS
if args.only:
wanted = set(args.only)
kings = [k for k in JOSEON_KINGS if k[1] in wanted]
if not kings:
print(f"[!] 매칭되는 왕 없음: {args.only}")
print(" 가능한 값:", ", ".join(k[1] for k in JOSEON_KINGS))
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
async with sem:
page = await context.new_page()
try:
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
# 실패하면 백오프하며 최대 3회까지 재시도
r = await scrape_king(page, title)
for attempt in range(3):
if not r["error"]:
break
await page.wait_for_timeout(1500 * (attempt + 1))
r = await scrape_king(page, title)
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"joseon_keywords_{ts}.json"
json_path.write_text(
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
)
csv_path = OUT_DIR / f"joseon_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())