Files
ObsidianHome/思睿/vocab_audio_tools/extract_vocab_data.py
T
2026-11-05 15:16:24 +08:00

306 lines
8.4 KiB
Python
Raw 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.
import argparse
import csv
import html
import json
import re
from pathlib import Path
import pdfplumber
IPA_CHARS = "ˈˌəɪʊɔθðʃʒæɑɒɜŋʌɡ"
POS_RE = r"(?:n|v|vt|vi|adj|adv|pron|prep|conj|art|num|modal|interj)\.?"
ENTRY_RE = re.compile(r"^\s*(?P<num>\d{1,3})\.\s*(?P<body>.+?)\s*$")
DAY_LABELS = [
"first",
"second",
"third",
"fourth",
"fifth",
"sixth",
"seventh",
"eighth",
"ninth",
"tenth",
"eleventh",
"twelfth",
"thirteenth",
"fourteenth",
"fifteenth",
"sixteenth",
"seventeenth",
"eighteenth",
"nineteenth",
"twentieth",
"twenty-first",
"twenty-second",
]
def tail_starts_meaning(tail: str) -> bool:
tail = tail.lstrip()
return bool(re.match(rf"(?:\(|()?{POS_RE}", tail, re.I) or re.match(r"[\u4e00-\u9fff]", tail))
def find_phonetic_start(body: str) -> int | None:
for index, char in enumerate(body):
if char != "/":
continue
end = body.find("/", index + 1)
if end == -1:
continue
segment = body[index + 1 : end].strip()
tail = body[end + 1 :]
if any(ch in segment for ch in IPA_CHARS) or ":" in segment or tail_starts_meaning(tail):
return index
return None
def extract_term(body: str) -> str:
body = " ".join(body.split())
phonetic_start = find_phonetic_start(body)
if phonetic_start is not None:
term = body[:phonetic_start]
else:
term = re.split(
r"\s+(?:n|v|vt|vi|adj|adv|pron|prep|conj|art|num|modal|interj)\.|[\u4e00-\u9fff]",
body,
maxsplit=1,
)[0]
term = term.strip(" /")
term = re.sub(r"\s+", " ", term)
return term
def spoken_term(term: str) -> str:
spoken = term
spoken = spoken.replace("(", "(").replace(")", ")")
spoken = re.sub(r"\(\s*(?:pl\.|复|单数)[^)]+\)", "", spoken, flags=re.I)
spoken = re.sub(r"\(\s*(?:AmE|美)\s*([^)]+)\)", r", \1", spoken, flags=re.I)
spoken = re.sub(r"\(=([^)]+)\)", r", \1", spoken)
spoken = re.sub(r"\(([A-Za-z][A-Za-z .'-]*)\)", r", \1", spoken)
spoken = spoken.replace("=", ", ")
spoken = spoken.replace("/", ", ")
spoken = re.sub(r"\bTV\b", "T V", spoken)
spoken = re.sub(r"\bAI\b", "A I", spoken)
spoken = re.sub(r"\bUK\b", "U K", spoken)
spoken = re.sub(r"\bUSA\b", "U S A", spoken)
spoken = re.sub(r"\s+", " ", spoken)
return spoken.strip(" ,")
def extract_page_entries(page, day_number: int):
entries = []
# The source PDF is two-column. Reading each column separately keeps the
# spoken order close to the visual order on the page.
columns = [
(0, 0, page.width * 0.505, page.height),
(page.width * 0.505, 0, page.width, page.height),
]
for column_index, bbox in enumerate(columns, start=1):
text = page.crop(bbox).extract_text(x_tolerance=1, y_tolerance=3) or ""
for line in text.splitlines():
match = ENTRY_RE.match(line)
if not match:
continue
term = extract_term(match.group("body"))
if not term or any(ch.isdigit() for ch in term[:2]):
continue
entries.append(
{
"day": day_number,
"column": column_index,
"source_number": int(match.group("num")),
"term": term,
"spoken": spoken_term(term),
}
)
for index, entry in enumerate(entries, start=1):
entry["position"] = index
return entries
def write_player(out_dir: Path, days: list[dict]) -> None:
audio_dir = "audio"
rows = []
for day in days:
terms = "\n".join(
f"<li><span>{html.escape(entry['term'])}</span></li>" for entry in day["entries"]
)
rows.append(
f"""
<section class="day">
<div class="day-head">
<div>
<h2>Day {day['day']:02d}</h2>
<p>{day['count']} words</p>
</div>
<audio controls preload="metadata" src="{audio_dir}/Day{day['day']:02d}.wav"></audio>
</div>
<ol>{terms}</ol>
</section>
"""
)
document = f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>1600 中考词汇音频播放</title>
<style>
:root {{
color-scheme: light;
font-family: "Microsoft YaHei", Arial, sans-serif;
background: #f6f7f9;
color: #1e2329;
}}
body {{
margin: 0;
padding: 28px;
}}
main {{
max-width: 1120px;
margin: 0 auto;
}}
h1 {{
margin: 0 0 8px;
font-size: 28px;
font-weight: 700;
}}
.meta {{
margin: 0 0 24px;
color: #5a6472;
line-height: 1.7;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}}
.day {{
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 16px;
}}
.day-head {{
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 12px;
align-items: center;
margin-bottom: 12px;
}}
h2 {{
margin: 0;
font-size: 18px;
}}
.day-head p {{
margin: 4px 0 0;
color: #6b7280;
font-size: 13px;
}}
audio {{
width: 100%;
}}
ol {{
columns: 2;
column-gap: 28px;
margin: 0;
padding-left: 24px;
line-height: 1.75;
font-family: Georgia, "Times New Roman", serif;
}}
li {{
break-inside: avoid;
padding-right: 6px;
}}
@media (max-width: 620px) {{
body {{
padding: 16px;
}}
.day-head {{
grid-template-columns: 1fr;
}}
ol {{
columns: 1;
}}
}}
</style>
</head>
<body>
<main>
<h1>1600 中考词汇音频播放</h1>
<p class="meta">每个单词读两遍,按 PDF 的 22 天分组。音频文件在 <code>audio</code> 文件夹内。</p>
<div class="grid">
{''.join(rows)}
</div>
</main>
</body>
</html>
"""
(out_dir / "index.html").write_text(document, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--pdf", required=True, help="Source PDF path")
parser.add_argument("--out", default="vocab_audio_output", help="Output directory")
parser.add_argument("--days", type=int, default=22, help="Number of day pages to extract")
args = parser.parse_args()
pdf_path = Path(args.pdf)
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
days = []
with pdfplumber.open(str(pdf_path)) as pdf:
for index in range(args.days):
entries = extract_page_entries(pdf.pages[index], index + 1)
days.append(
{
"day": index + 1,
"label": DAY_LABELS[index] if index < len(DAY_LABELS) else str(index + 1),
"count": len(entries),
"entries": entries,
}
)
data = {"source_pdf": str(pdf_path), "days": days}
(out_dir / "vocab_days.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
with (out_dir / "vocab_terms.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(
file, fieldnames=["day", "position", "source_number", "term", "spoken"]
)
writer.writeheader()
for day in days:
for entry in day["entries"]:
writer.writerow(
{
"day": entry["day"],
"position": entry["position"],
"source_number": entry["source_number"],
"term": entry["term"],
"spoken": entry["spoken"],
}
)
summary = "\n".join(
f"Day {day['day']:02d}: {day['count']} words" for day in days
)
(out_dir / "summary.txt").write_text(summary + "\n", encoding="utf-8")
write_player(out_dir, days)
print(summary)
print(f"Total: {sum(day['count'] for day in days)} words")
print(f"Wrote: {out_dir.resolve()}")
if __name__ == "__main__":
main()