Files
Jeffery (jiantw83)andClaude Sonnet 5.5 9cf8196a3e feat: wiki 讀取與對白抽取腳本 (#4)
fetch_wiki.py 用 tea 讀取 wiki;extract_dialogues.py 抽出「」對白與前後文,
並用規則判定明確的說話者。原文與輸出放在 repo 之外。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 17:12:20 +00:00

172 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()