288 lines
11 KiB
Python
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())
|