fetch_wiki.py 用 tea 讀取 wiki;extract_dialogues.py 抽出「」對白與前後文, 並用規則判定明確的說話者。原文與輸出放在 repo 之外。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
172 lines
6.6 KiB
Python
172 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
"""從 wiki 的小說頁抽出所有「」對白,並用規則判定明確的說話者。
|
||
|
||
用法:extract_dialogues.py <wiki 目錄> <輸出 jsonl>
|
||
|
||
每個對白一筆:冊、章、前後文、說話者(規則無法判定則為 null)與規則依據。
|
||
規則只處理明確的句式;其餘交給 W4 的標註。輸出與原文放在 repo 之外。
|
||
"""
|
||
import json
|
||
import re
|
||
import sys
|
||
from collections import Counter
|
||
from pathlib import Path
|
||
|
||
ALIASES = json.loads((Path(__file__).with_name("characters.json")).read_text(encoding="utf-8"))
|
||
NAME_RE = "|".join(sorted(map(re.escape, ALIASES), key=len, reverse=True))
|
||
TOKEN_RE = re.compile(f"({NAME_RE})|我(?!們)")
|
||
QUOTE_RE = re.compile(r"「([^」]*)」")
|
||
SENT_END = "。!?!?"
|
||
CUE_RE = re.compile(r"說|問|叫|喊|答|道|聲音|低語|呢喃|嘀咕|開口|回|笑|哭|吼|嘟噥|喃喃")
|
||
# 「對我」「在我背後的」中的「我」是受詞,不是說話者。
|
||
OBJECT_RE = re.compile(
|
||
r"(?:在我(?:的)?(?:背後|身後|旁邊|面前|耳邊|身邊)的?|(?:對著?|向|朝|跟|和|問|告訴|看著|瞪著|望著|回|給|讓|把|被)我(?:說|問)?)"
|
||
)
|
||
CJK_DIGITS = {"零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
|
||
FILE_RE = re.compile(r"Sword Art Online (\d) .*? - 第(.+?)章(?: \((\d+)_(\d+)\))?\.md$")
|
||
|
||
|
||
def cn_number(s):
|
||
if "十" in s:
|
||
head, _, tail = s.partition("十")
|
||
return (CJK_DIGITS[head] if head else 1) * 10 + (CJK_DIGITS[tail] if tail else 0)
|
||
return CJK_DIGITS[s]
|
||
|
||
|
||
def strip_quotes(text):
|
||
return QUOTE_RE.sub("", text)
|
||
|
||
|
||
def last_sentence(text):
|
||
text = text.strip()
|
||
parts = re.split(f"[{SENT_END}]", text)
|
||
parts = [p for p in parts if p.strip()]
|
||
return parts[-1].strip() if parts else ""
|
||
|
||
|
||
def first_sentence(text):
|
||
return re.split(f"[{SENT_END}]", text.strip(), maxsplit=1)[0]
|
||
|
||
|
||
FRAME_RE = re.compile(r"^(?:面對|聽見|聽到|看見|看到|見到|對於|當|由於|因為|雖然|在|到了|一邊|就在)")
|
||
|
||
|
||
def pick_subject(clause):
|
||
"""回傳 (說話者別名, 全部候選, 是否高信心)。
|
||
|
||
動詞前的主詞通常是句首第一個名字(跳過「面對…」「聽見…」之類的開頭子句);
|
||
只有一個候選且句中有說話動詞才算高信心,多個候選取第一個並降為中信心。
|
||
"""
|
||
clause = OBJECT_RE.sub("", strip_quotes(clause))
|
||
parts = [p for p in re.split("[,、,]", clause) if p.strip()]
|
||
while len(parts) > 1 and FRAME_RE.match(parts[0]):
|
||
parts = parts[1:]
|
||
tokens = []
|
||
for m in TOKEN_RE.finditer(",".join(parts)):
|
||
alias = m.group(1) or "我"
|
||
if alias not in tokens:
|
||
tokens.append(alias)
|
||
if not tokens:
|
||
return None, [], False
|
||
return tokens[0], tokens, len(tokens) == 1 and bool(CUE_RE.search(clause))
|
||
|
||
|
||
def resolve(alias):
|
||
if alias == "我":
|
||
return "kirito", "ingame"
|
||
info = ALIASES[alias]
|
||
return info["id"], info["mode"]
|
||
|
||
|
||
def attribute(before, after, prev_par):
|
||
"""回傳 (speaker, alias, confidence, rule, candidates)。"""
|
||
b = before.strip()
|
||
if b.endswith((":", ":")):
|
||
who, cands, cue = pick_subject(last_sentence(b))
|
||
if who:
|
||
return (*resolve(who)[:1], who, "high" if cue else "medium", "lead_in", cands)
|
||
return None, None, None, None, cands
|
||
if not b and prev_par and prev_par.rstrip().endswith((":", ":")):
|
||
who, cands, cue = pick_subject(last_sentence(prev_par))
|
||
if who:
|
||
return (*resolve(who)[:1], who, "high" if cue else "medium", "lead_in_prev", cands)
|
||
return None, None, None, None, cands
|
||
a = after.lstrip(",、 ")
|
||
m = re.match(f"({NAME_RE}|我)", a)
|
||
if m and not a.startswith("我們"):
|
||
clause = first_sentence(a)
|
||
if CUE_RE.search(clause):
|
||
who, cands, _ = pick_subject(clause)
|
||
if who == m.group(1):
|
||
return (*resolve(who)[:1], who, "high", "trail", cands)
|
||
return None, None, None, None, []
|
||
|
||
|
||
def trim(s, n=120):
|
||
s = s.strip()
|
||
return s if len(s) <= n else s[:n] + "…"
|
||
|
||
|
||
def load_chapters(wiki):
|
||
pages = {}
|
||
for f in sorted(Path(wiki).glob("*.md")):
|
||
m = FILE_RE.search(f.name)
|
||
if not m:
|
||
print(f"略過:{f.name}", file=sys.stderr)
|
||
continue
|
||
vol, ch, part = int(m.group(1)), cn_number(m.group(2)), int(m.group(3) or 1)
|
||
pages.setdefault((vol, ch), []).append((part, f))
|
||
for key in sorted(pages):
|
||
paras = []
|
||
for part, f in sorted(pages[key]):
|
||
paras += [(part, ln.strip()) for ln in f.read_text(encoding="utf-8").split("\n") if ln.strip()]
|
||
yield key, paras
|
||
|
||
|
||
def extract(wiki):
|
||
for (vol, ch), paras in load_chapters(wiki):
|
||
seen = {}
|
||
n = 0
|
||
for pi, (part, par) in enumerate(paras):
|
||
quotes = list(QUOTE_RE.finditer(par))
|
||
prev_par = paras[pi - 1][1] if pi else ""
|
||
next_par = paras[pi + 1][1] if pi + 1 < len(paras) else ""
|
||
for qi, q in enumerate(quotes):
|
||
lo = quotes[qi - 1].end() if qi else 0
|
||
hi = quotes[qi + 1].start() if qi + 1 < len(quotes) else len(par)
|
||
before, after = par[lo:q.start()], par[q.end():hi]
|
||
speaker, alias, conf, rule, cands = attribute(before, after, prev_par if qi == 0 else "")
|
||
text = q.group(1)
|
||
n += 1
|
||
dup = len(text) >= 8 and pi - seen.get(text, -99) <= 6
|
||
seen[text] = pi
|
||
rec = {
|
||
"id": f"v{vol}c{ch:02d}-{n:04d}", "volume": vol, "chapter": ch, "page": part, "para": pi,
|
||
"text": text, "before": trim(before), "after": trim(after),
|
||
"prev_par": trim(prev_par), "next_par": trim(next_par),
|
||
"speaker": speaker, "alias": alias, "confidence": conf, "rule": rule,
|
||
"mode": resolve(alias)[1] if alias else None,
|
||
"candidates": cands, "suspect_duplicate": dup,
|
||
}
|
||
yield rec
|
||
|
||
|
||
def main():
|
||
wiki, out = sys.argv[1], Path(sys.argv[2])
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
stats, rules = Counter(), Counter()
|
||
total = 0
|
||
with out.open("w", encoding="utf-8") as fh:
|
||
for rec in extract(wiki):
|
||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
total += 1
|
||
stats[rec["speaker"] or "(未判定)"] += 1
|
||
rules[rec["rule"] or "(無)"] += 1
|
||
print(f"{total} 句 -> {out}")
|
||
print("說話者:", dict(stats.most_common()))
|
||
print("規則:", dict(rules.most_common()))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|