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>
This commit is contained in:
john
2026-06-06 21:09:27 +08:00
parent 2fae90b597
commit e76fa586f1
76 changed files with 47488 additions and 445 deletions
+249
View File
@@ -0,0 +1,249 @@
"""从 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()
+106
View File
@@ -0,0 +1,106 @@
"""将 backend/data/books/*.json 导入 word_books / word_book_entries 表。"""
from __future__ import annotations
import json
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from database import SessionLocal # noqa: E402
from models import WordBook, WordBookEntry # noqa: E402
from services.book_service import DEPRECATED_SLUGS # noqa: E402
BOOKS_DIR = BACKEND_DIR / "data" / "books"
def upsert_book(session, data: dict) -> WordBook:
book = session.query(WordBook).filter(WordBook.slug == data["slug"]).first()
if not book:
book = WordBook(slug=data["slug"])
session.add(book)
book.title = data["title"]
book.description = data.get("description")
book.level = data.get("level", "general")
book.word_count = data.get("word_count", 0)
book.unit_count = data.get("unit_count", 0)
book.sort_order = data.get("sort_order", 0)
book.daily_target = data.get("daily_target", 15)
book.master_required_count = data.get("master_required_count", 4)
book.weak_wrong_threshold = data.get("weak_wrong_threshold", 2)
book.learn_mode = data.get("learn_mode", "sequential")
book.is_published = 1
session.flush()
return book
def import_book_file(session, path: Path) -> tuple[str, int]:
data = json.loads(path.read_text(encoding="utf-8"))
book = upsert_book(session, data)
session.query(WordBookEntry).filter(WordBookEntry.book_id == book.id).delete(
synchronize_session=False
)
count = 0
for unit in data.get("units", []):
unit_no = unit.get("unit", 1)
unit_title = unit.get("title")
for w in unit.get("words", []):
entry = WordBookEntry(
book_id=book.id,
unit=unit_no,
unit_title=unit_title,
sort_index=w["sort_index"],
lemma_en=w["lemma_en"].strip().lower()[:128],
zh=w["zh"].strip()[:256],
phonetic=(w.get("phonetic") or "")[:128] or None,
example_en=w.get("example_en"),
example_cn=w.get("example_cn"),
)
session.add(entry)
count += 1
book.word_count = count
book.unit_count = len(data.get("units", []))
session.commit()
return book.slug, count
def import_all(books_dir: Path = BOOKS_DIR) -> None:
files = sorted(books_dir.glob("*.json"))
files = [
f
for f in files
if f.name != "manifest.json"
and f.stem not in DEPRECATED_SLUGS
]
if not files:
print(f"未找到词书 JSON: {books_dir}")
return
session = SessionLocal()
try:
for path in files:
slug, count = import_book_file(session, path)
print(f" 导入 {slug}: {count} 词条")
for slug in DEPRECATED_SLUGS:
book = session.query(WordBook).filter(WordBook.slug == slug).first()
if book and book.is_published:
book.is_published = 0
session.commit()
print(f" 下架旧词书 {slug}")
finally:
session.close()
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="导入词书 JSON 到数据库")
parser.add_argument("--dir", type=Path, default=BOOKS_DIR)
args = parser.parse_args()
import_all(args.dir)
if __name__ == "__main__":
main()