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