"""从 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()