Files
wordloop/backend/scripts/fetch_hujiao_books.py
john e76fa586f1 Add word-book practice, iOS app shell, and fix embedded WebView blank screen.
Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:09:27 +08:00

250 lines
8.5 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.
"""从 GitHub (lilinji/English) 下载沪版牛津与商务英语 xlsx,生成词书 JSON。
用法:
python -m scripts.fetch_hujiao_books
python -m scripts.fetch_hujiao_books --import # 生成后直接写入数据库
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import urllib.parse
import urllib.request
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
OUT_DIR = BACKEND_DIR / "data" / "books"
GITHUB_RAW = "https://raw.githubusercontent.com/lilinji/English/main"
OXFORD_DIR = (
"1.全国各大教材版本中小学同步/牛津版"
)
BUSINESS_DIR = "8.商务英语"
PRIMARY_GRADES = [
(1, 11, "一年级", ["牛津上海版一年级上册.xlsx", "牛津上海版一年级下册.xlsx"]),
(2, 12, "二年级", ["牛津上海版二年级上册.xlsx", "牛津上海版二年级下册.xlsx"]),
(3, 13, "三年级", ["牛津上海版三年级上册.xlsx", "牛津上海版三年级下册.xlsx"]),
(4, 14, "四年级", ["牛津上海版四年级上册.xlsx", "牛津上海版四年级下册.xlsx"]),
(5, 15, "五年级", ["牛津上海版五年级上册.xlsx", "牛津上海版五年级下册.xlsx"]),
]
JUNIOR_GRADES = [
(6, 21, "六年级", ["牛津上海版六年级上册.xlsx", "牛津上海版六年级下册.xlsx"]),
(7, 22, "七年级", ["牛津上海版七年级上册.xlsx", "牛津上海版七年级下册.xlsx"]),
(8, 23, "八年级", ["牛津上海版八年级上册.xlsx", "牛津上海版八年级下册.xlsx"]),
(9, 24, "九年级", ["牛津上海版九年级上册.xlsx", "牛津上海版九年级下册.xlsx"]),
]
SENIOR_GRADES = [
(1, 31, "高一", ["牛津上海版高一上.xlsx", "牛津上海版高一下.xlsx"]),
(2, 32, "高二", ["牛津上海版高二上.xlsx", "牛津上海版高二下.xlsx"]),
(3, 33, "高三", ["牛津上海版高三上.xlsx", "牛津上海版高三下.xlsx"]),
]
BOOK_SPECS = [
*[
{
"slug": f"hujiao-primary-{grade}",
"title": f"沪版小学英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "primary",
"sort_order": order,
"daily_target": 10 + grade,
"master_required_count": 3,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in PRIMARY_GRADES
],
*[
{
"slug": f"hujiao-junior-{grade}",
"title": f"沪版初中英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "junior",
"sort_order": order,
"daily_target": 11 + grade - 6,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in JUNIOR_GRADES
],
*[
{
"slug": f"hujiao-senior-{grade}",
"title": f"沪版高中英语 · {label}",
"description": f"牛津上海版{label}词汇(上下册)",
"level": "senior",
"sort_order": order,
"daily_target": 15 + grade,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": files,
}
for grade, order, label, files in SENIOR_GRADES
],
{
"slug": "business-daily",
"title": "日常商务英语",
"description": "BEC 初/中级核心商务词汇",
"level": "business",
"sort_order": 40,
"daily_target": 15,
"master_required_count": 4,
"weak_wrong_threshold": 2,
"files": [
"BEC初级词汇精选.xlsx",
"BEC中级词汇精选.xlsx",
],
"base_dir": BUSINESS_DIR,
},
]
def download_file(rel_path: str, cache_dir: Path) -> Path:
cache_dir.mkdir(parents=True, exist_ok=True)
local = cache_dir / Path(rel_path).name
if local.exists() and local.stat().st_size > 0:
return local
url = f"{GITHUB_RAW}/{urllib.parse.quote(rel_path)}"
print(f" 下载 {Path(rel_path).name} ...")
urllib.request.urlretrieve(url, local)
return local
def parse_xlsx(path: Path) -> list[dict]:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
header = [str(c).strip() if c else "" for c in rows[0]]
col_word = next((i for i, h in enumerate(header) if h in ("单词", "word", "英文")), 0)
col_zh = next((i for i, h in enumerate(header) if h in ("释义", "中文", "意思")), -1)
col_phonetic = next((i for i, h in enumerate(header) if "英音" in h or h == "音标"), -1)
out: list[dict] = []
seen: set[str] = set()
for row in rows[1:]:
if not row or not row[col_word]:
continue
lemma = str(row[col_word]).strip().lower()
if not lemma or lemma in seen:
continue
seen.add(lemma)
zh_raw = str(row[col_zh]).strip() if col_zh >= 0 and row[col_zh] else lemma
zh = _simplify_zh(zh_raw)
phonetic = None
if col_phonetic >= 0 and row[col_phonetic]:
phonetic = str(row[col_phonetic]).strip()[:128] or None
out.append({"lemma_en": lemma[:128], "zh": zh[:256], "phonetic": phonetic})
wb.close()
return out
def _simplify_zh(raw: str) -> str:
text = raw.strip()
first = text.split("\n")[0].strip()
first = re.sub(r"^[a-z]+\.\s*", "", first, flags=re.I)
for sep in ("", ";", "", ","):
if sep in first:
first = first.split(sep)[0].strip()
return first or raw[:64]
def unit_title_from_filename(name: str) -> str:
base = name.replace(".xlsx", "").replace("牛津上海版", "").replace("BEC", "BEC ")
return base.strip()
def build_book(spec: dict, cache_dir: Path) -> dict:
base_dir = spec.get("base_dir", OXFORD_DIR)
units = []
global_seen: set[str] = set()
sort_index = 0
for unit_no, fname in enumerate(spec["files"], start=1):
rel = f"{base_dir}/{fname}"
path = download_file(rel, cache_dir)
words = parse_xlsx(path)
unit_words = []
for w in words:
key = w["lemma_en"]
if key in global_seen:
continue
global_seen.add(key)
sort_index += 1
unit_words.append({**w, "sort_index": sort_index})
if unit_words:
units.append(
{
"unit": unit_no,
"title": unit_title_from_filename(fname),
"words": unit_words,
}
)
return {
"slug": spec["slug"],
"title": spec["title"],
"description": spec["description"],
"level": spec["level"],
"sort_order": spec["sort_order"],
"daily_target": spec["daily_target"],
"master_required_count": spec["master_required_count"],
"weak_wrong_threshold": spec["weak_wrong_threshold"],
"learn_mode": "sequential",
"units": units,
}
def main() -> None:
parser = argparse.ArgumentParser(description="下载沪版词书并生成 JSON")
parser.add_argument("--import-db", action="store_true", help="生成后导入数据库")
args = parser.parse_args()
try:
import openpyxl # noqa: F401
except ImportError:
print("请先安装: pip install openpyxl", file=sys.stderr)
sys.exit(1)
OUT_DIR.mkdir(parents=True, exist_ok=True)
cache_dir = BACKEND_DIR / "data" / "cache" / "word_books"
books = []
for spec in BOOK_SPECS:
print(f"处理 {spec['title']} ...")
book = build_book(spec, cache_dir)
word_count = sum(len(u["words"]) for u in book["units"])
book["word_count"] = word_count
book["unit_count"] = len(book["units"])
out_path = OUT_DIR / f"{spec['slug']}.json"
out_path.write_text(json.dumps(book, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" -> {out_path.name} ({word_count} 词, {book['unit_count']} 单元)")
books.append(book)
manifest = OUT_DIR / "manifest.json"
manifest.write_text(
json.dumps([{"slug": b["slug"], "title": b["title"], "word_count": b["word_count"]} for b in books],
ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"已生成 {len(books)} 本词书 -> {OUT_DIR}")
if args.import_db:
from scripts.import_word_books import import_all
import_all(OUT_DIR)
if __name__ == "__main__":
main()