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:
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user