# -*- 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())