单文件高中物理题库 HTML 构建与改造
一句话说明
把高中物理题库转成离线单文件 HTML——公式内联 MathJax 渲染、图片可内嵌/文件夹版、支持四维度筛选(题型/难度/年份/知识点),双击即开,无需联网。
适用 / 不适用
| 需求 | 用它 |
|---|---|
| 从 Obsidian 题卡批量生成单文件题库 | ✅ 本技能(场景 B) |
| 把 data.js 外挂结构(智能题库各章)转成单文件 | ✅ 本技能(场景 C) |
| 按章/专题裁剪、瘦身、修筛选面板 | ✅ 本技能(场景 A) |
| 新增/替换筛选维度,修"下拉为空" | ✅ 本技能 |
| 公式不渲染、图片缺失、选项挤一行 | ✅ 本技能 |
| 打包成桌面双击版 + zip 发给同事 | ✅ 本技能 |
| 从零命制题目、组卷 | ❌ 物理命题与组卷专家 / 物理组卷出题 |
| 维护 Obsidian 题卡本身(入库/去重/补字段) | ❌ obsidian / xiaowu-physics-item-authoring |
| 题库 HTML → Word | ❌ html转Word讲义 |
使用示例
- "把
桌面/智能题库/02…/data.js转成单文件离线题库" - "从
M:\Obsidian Vault\题库\力学\运动学\下的题卡生成单文件题库" - "题库刷题面板知识点下拉是空的,帮我修"
- "把这份 5832 题的交互力题库补上图片,打包给我"
三条链路
题卡 (Obsidian *.md) ──md2data.py──→ JSON
│ classify_scenario.py 补「物理情景」分类
▼
单文件 HTML ←─── 场景 B 到此结束
│ build_bank.py 注入 bank_template.html
▼
待改造题库
│ audit_fields.py 体检 / trim_by_fields.py 裁剪
│ probe_dom.py 真浏览器验证 / package_deliver.py 打包
▼
交付版(bat + zip)
改造已有题库时从中间开始;构建新题库从最上面开始。
核心约定
- 先体检后动手:字段区分度不够,界面怎么改都是空的。
- 数据是唯一事实来源:筛选项一律从
DATA统计生成,禁止手写清单。 - 验证只认浏览器:read-only 读源码看不出渲染结果,必须 headless 跑真页面。
- 双 JSON 提取铁律:
json.JSONDecoder().raw_decode(T, start)直接拿(obj, end),不要手写括号匹配。 - 图片两种交付形态:
-embed(默认):base64 内嵌进IMG_DATA,单文件版(图多时会膨胀到数百 MB~GB)。
-relative:png 复制到images/目录,题目用![[附件/...]]标记,模板回退到同目录相对加载——图多时首选,体积小、浏览器不打不开。
脚本清单
| 脚本 | 用途 |
|---|---|
md2data.py |
Obsidian 题卡 → JSON(支持 --embed-images / --img-root) |
classify_scenario.py |
补「物理情景」分类(导出→模型分→合并→规则兜底) |
datajs2data.py |
data.js → JSON(含 --img-prefix 图片前缀参数化,可跨设备用) |
build_bank.py |
JSON + 模板 → 单文件 HTML |
audit_fields.py |
字段体检(distinct、空值率、自由文本判据) |
trim_by_fields.py |
按字段裁剪 + 图片瘦身 |
probe_dom.py |
真浏览器验证(DOM 节点、下拉选项数、联动交集) |
package_deliver.py |
打包交付(复制 + bat 启动器 + zip + md5 自校验) |
bank_lib.py |
双 JSON 解析工具函数(load / decl_span / img_report) |
适用 / 不适用
| 需求 | 用它 |
|---|---|
| 从 Obsidian 题卡批量生成单文件题库 | ✅ 本技能(场景 B) |
| 从桌面 智能题库 02–22 章(data.js 外挂)转单文件 | ✅ 本技能(场景 C) |
| 按章/专题裁剪、删题、瘦身 | ✅ 本技能(场景 A) |
| 新增/替换筛选维度,修"下拉为空" | ✅ 本技能 |
| 公式不渲染、图片缺失、选项挤一行 | ✅ 本技能 |
| 打包成桌面双击版 + zip 发给同事 | ✅ 本技能 |
| 从零命制题目、组卷 | ❌ 物理命题与组卷专家 / 物理组卷出题 |
| 维护 Obsidian 题卡本身(入库/去重/补字段) | ❌ obsidian / xiaowu-physics-item-authoring |
| 题库 HTML → Word | ❌ html转Word讲义 |
三条链路的关系
题库卡片 (M:\Obsidian Vault\题库\*.md)
│ md2data.py 解析 frontmatter + callout → JSON
│ classify_scenario.py 补「物理情景」分类(导出→模型分→合并)
│ build_bank.py 注入模板 → 单文件 HTML
▼
单文件题库 HTML ←── 场景 B 到此结束
│ audit_fields.py 体检:字段能否当筛选项
│ trim_by_fields.py 裁剪 + 图片瘦身
│ probe_dom.py 真浏览器验证
│ package_deliver.py 打包交付
▼
桌面双击版 + zip
改造已有题库时从中间开始;构建新题库从最上面开始,并且构建完照样要跑改造段的体检与验证。
场景 C:从 data.js 智能题库转换(桌面 02–22 章)
桌面 智能题库/ 下 22 章,每章一种结构:外壳 HTML(38KB,引用 KaTeX CDN)+ data.js(window.QUESTIONS,15MB 级)+ build/test 脚本,外加 高中物理实验库(100+ 微型 HTML,实验演示类,不是题库)。
这些题库是另一次在 home 电脑用 WorkBuddy 生成的,图片全部是 file:///E:/MyVault/... 绝对外链,在当前机器存在率 0%(图源在 home 电脑的 E 盘)。转换把它们变成和场景 B 同构的单文件:
PY="C:/Users/Administrator/.workbuddy/binaries/python/versions/3.13.12/python.exe"
SK="C:/Users/Administrator/.workbuddy/skills/物理题库HTML改造/scripts"
# 1) data.js → JSON(字段映射 + 图片占位〔原图缺失〕 + 缺失图清单)
$PY "$SK/datajs2data.py" --src "C:/Users/Administrator/Desktop/智能题库/02 相互作用力智能题库系统/data.js" \
--out "相互作用力_data.json" --miss "相互作用力_缺失图片清单.json" --topic "相互作用力"
# 2) 注入模板 → 单文件 HTML(公式走模板内联 MathJax 离线渲染,不再依赖 KaTeX CDN)
$PY "$SK/build_bank.py" --data "相互作用力_data.json" --template "bank_template.html" \
--out "相互作用力_单文件.html" --title "相互作用力" --key fh
# 3) 后续走改造段:audit → 按需 trim → probe 验证 → package 打包
图片补全流程(等 home 图拷回后,已用假图实测通过):图源在 home 电脑 E:/MyVault/_库外文件_20261021/图片/。把整个 图片 文件夹拷到当前机器(保持同样路径最省事,或任意位置并记录路径),用 --img-root 重新跑转换即可自动内嵌:
$PY "$SK/datajs2data.py" --src "..../data.js" --out "相互作用力_data.json" \
--img-root "E:/MyVault/_库外文件_20261021/图片" --topic "相互作用力"
$PY "$SK/build_bank.py" --data "相互作用力_data.json" --template "bank_template.html" \
--out "相互作用力_单文件.html" --title "相互作用力"
--img-root 按 URL 里的相对路径(05-附件/.../xxx.png,已解码 %20)在本机找图,base64 内嵌进 IMG_DATA、正文写成 ![[...]] 离线渲染;找不到的仍留 〔原图缺失〕。02 共 34504 张不同图、被引用 43084 次,--img-root 不传时全部占位、传了就内嵌能找到的。
要点:data.js 是 window.QUESTIONS = [...](不是 const DATA = [),datajs2data.py 已封装解析;02–22 章同体系,换 --src 与 --topic 即可复用。
场景 B:从题卡构建新题库
PY="C:/Users/Administrator/.workbuddy/binaries/python/versions/3.13.12/python.exe"
SK="C:/Users/Administrator/.workbuddy/skills/物理题库HTML改造/scripts"
TPL="C:/Users/Administrator/.workbuddy/skills/物理题库HTML改造/assets/bank_template.html"
# 1) 卡片 → JSON(--embed-images 会把图片转 base64,20MB 级正常)
$PY "$SK/md2data.py" --path "题库/力学/运动学/匀变速直线运动" \
--out data.json --embed-images
# 2) 情景分类:导出 → 模型逐批分 → 合并(规则版只是兜底,实测仅 38%)
$PY "$SK/classify_scenario.py" --data data.json --export batches --batch 40
$PY "$SK/classify_scenario.py" --data data.json --merge batches
$PY "$SK/classify_scenario.py" --data data.json --rules # 收尾填空
# 3) 注入模板生成题库
$PY "$SK/build_bank.py" --data data.json --template "bank_template.html" \
--out "题库_输出.html" --title "匀变速直线运动" --key kin
# 4) 模板需要重做时(换了外壳样式/逻辑)
$PY "$SK/build_bank.py" --make-template "某个调好的题库.html" --template "$TPL"
卡片格式约定(本机 Obsidian 题库):
---
type: 题目
module: 力学 / chapter: 运动学 / topic: 匀变速直线运动
qtype: 计算 / level: 2 / score: 10
sub_qtype: 计算推导 ← 卡片自带,10 类(解法/设问类型)
source: 《…》(2021 浙江卷第 20 题)
---
> [!info] 出处
> [!question] 题干(含 ![[附件/xxx.png]])
> [!success]- 答案
> [!note]- 解析(里面常嵌 > > [!warning] 易错点)
卡片没有 qtype_sub(物理情景) —— 全库 1477 篇一篇都没有。这个维度只能后补:
sub_qtype 是现成的(直接可用),qtype_sub 必须靠分类(见上)。
三条原则(先记住,再动手)
- 先体检后动手。字段区分度不够,界面怎么改都是空的。今天「刷题面板知识点下拉是空的」根因在数据层:裁剪后 1358 题的
topic全是同一个值。 - 数据是唯一事实来源。筛选项一律从
DATA统计生成,禁止手写清单(手写DAZHAO_LIST漏了一个'7',10 道题永远不显示)。 - 验证只认浏览器。23MB 单文件 + 内联 JS,读源码看不出"到底渲染出几项"。必须 headless 跑真页面、读 DOM。
流水线
0 摸结构
PY="C:/Users/Administrator/.workbuddy/binaries/python/versions/3.13.12/python.exe"
SK="C:/Users/Administrator/.workbuddy/skills/物理题库HTML改造/scripts"
$PY "$SK/bank_lib.py" "题库.html" # 打印题量与图片数,验证解析成功
结构固定为两段 JSON 字面量 + 外壳:
const IMG_DATA={"附件/xxx.png":"data:image/png;base64,..."}; ← 图片
const DATA = [{"qtype_sub":"追及与相遇","_question":"...", ...}]; ← 题目
提取铁律:json.JSONDecoder().raw_decode(T, start) 直接拿 (obj, end)。
不要手写括号匹配——20MB 单行 JSON 里一个转义就能让计数崩掉。bank_lib.decl_span() 已封装。
1 字段体检(改任何东西之前必须先跑)
$PY "$SK/audit_fields.py" "题库.html" --top 6
看三件事:
| 指标 | 判据 | 用途 |
|---|---|---|
distinct |
3~30 才好当筛选项 | distinct=1 标 [退化],禁止绑到下拉 |
| 空值率 | <60% 才好当筛选项 | 太高说明该字段只有少数题有标签 |
| [自由文本] | 几乎每题都不同 | 只能做关键词搜索,不能做分类 |
报告末尾会直接给"可做筛选项的字段"清单,照着选。
2 定维度,再裁剪
边界字段只能选一个能唯一定界范围的。今天的教训:dazhao='运动学' 只是章,
topic='匀变速直线运动' 才是真边界——只按 dazhao 裁,圆周运动的题会混进来。
裁剪脚本模板见 scripts/trim_by_fields.py(复制到工作区改条件即可),核心是:
keep = [q for q in data if q.get('dazhao')=='运动学' and q.get('topic')=='匀变速直线运动']
imgs = {k: v for k, v in imgs.items() if k in refs_of(keep)} # 图片同步瘦身
3 裁完立刻重体检(最容易漏的一步)
裁剪会改变字段基数。今天的真实事故链:
裁到只剩一个专题 → topic 从 N 个值塌成 1 个 → 刷题面板绑 topic 的下拉变空 → 用户报"没有知识点选项"。
规矩:任何数据变更(裁剪/删题/改字段)之后,重跑步 1。 不重跑就等于闭眼改界面。
4 内容清洗
| 项目 | 判据 | 做法 |
|---|---|---|
| 空题 | _question 去空白后为空 |
删 |
| 越界题 | 字段不在目标集 | 删 |
| 图片键 | 引用 ![[附件/x.png]] 的键必须带 附件/ 前缀 |
统一前缀,否则 0 命中 |
| 缺图 | img_report()['missing'] |
源文件存在的用 GDI+/pywin32 栅格化补入;源本身是占位图的写明 |
[图片] 占位符 |
应为 0 | 删除或改「〔原图未收录〕」 |
| 选项挤一行 | audit_fields 报"选项挤在同一行" |
见 references/pitfalls.md 的正则 |
5 界面重建
三处必须同时改,少一处就出现"看着改了但没生效":
- HTML:
<select id="qSub">/ 筛选容器<div id="fXxx"> - JS 取值:
document.getElementById('qSub').value - 过滤函数:
getFilterPool()/doFilter()里的条件
改完立刻查残留:grep -c "qTopic" 题库.html 必须为 0。
选项生成一律走数据(含空态兜底):
const ORDER=[...]; // 只用于排序,不用于"有哪些"
const cnt={}; DATA.forEach(q=>{const k=q.qtype_sub||'综合与其他';cnt[k]=(cnt[k]||0)+1;});
ORDER.forEach(k=>{ if(!cnt[k])return; /* 有题才建 option,文案带题量 */ });
删除任何筛选容器时:mk() 必须加 if(!el)return;,resetAll() 的字段数组同步删。
否则一次 el.innerHTML 抛错会中断 initChips/renderStats/doFilter,整页空白。
6 真浏览器验证
# 数下拉选项
$PY "$SK/probe_dom.py" "题库.html" --view quiz --select qSub,qDazhao
# 模拟选择,验证联动是交集不是覆盖
$PY "$SK/probe_dom.py" "题库.html" --view quiz \
--js "pick('qSub','追及与相遇');out.push('a='+info());pick('qDazhao','4');out.push('b='+info());"
# 看排版
$PY "$SK/probe_dom.py" "题库.html" --view quiz --screenshot shot.png
判据:选项数 = 数据里 distinct 数 +1("全部");联动计数必须严格递减且等于手算交集。
出题流程要另外验一次:模拟点「开始刷题」,查 #quizArea 有题目节点、题干非空、
公式已渲染(不是 $...$ 原文)、来源行正常。
7 打包交付 + 回验
$PY "$SK/package_deliver.py" "题库.html" \
--outdir "C:/Users/Administrator/Desktop/题库_点此打开" \
--title "高 X 物理题库" --scope "匀变速直线运动"
脚本自动完成:复制 → 生成两个 bat(360 优先 / 强制 Chrome)→ 写使用说明(UTF-8 BOM+CRLF)
→ 打 zip → 自校验(md5 三方一致、testzip、bat 纯 ASCII+CRLF)。
最后一步不能省:从 zip 解压出来再跑一次步 6。 用户拿到的是压缩包,
只有"解压后能用"才算交付成功。
红线清单(踩一次白干一整轮)
python -c "..."里的反引号和$会被 bash 提前吃掉(今天把日志内容吃成空行)。稍复杂的脚本一律写成 .py 文件再执行。.bat必须纯 ASCII + CRLF;中文路径靠%~dp0运行时解析,不要写死;启动器用for %%f in ("%~dp0*.html")自动找文件,别硬编码文件名(zip 内改名就失效)。- 改前必备份:
题库名_before_<变更点>.html。 IMG_DATA与DATA必须从后往前替换,否则前面一改偏移全错。- 断言式修改:
assert T.count(old) == 1,改不到就报错退出,不要静默跳过。
验收清单
构建(场景 B)
- [ ] md2data.py 报告的 no_answer / no_note 比例可接受(本机题库约 5~20%,是源数据问题)
- [ ] 图片:引用数 = 内嵌数,missing 为空
- [ ] 生成后 audit_fields.py 通过;_question / _answer / _note 覆盖率达到预期
- [ ] 浏览器里题目数 = JSON 条数,.qimg 有图,公式不是 $...$ 原文
- [ ] localStorage 键已按题库隔离(LS_KEY_REC 不是共用的 kin_records)
改造(场景 A)
- [ ] audit_fields.py 全绿,退化字段没有一个被绑到界面
- [ ] 筛选项数 = distinct+1,联动计数等于手算交集
- [ ] 旧字段残留 grep = 0
- [ ] 无头浏览器出题正常:题干/公式/图片/答案都在
从 data.js 转换(场景 C)
- [ ] datajs2data.py 报告的题数 = data.js 里 QUESTIONS 长度
- [ ] 生成单文件后用 bank_lib.load 复核题数(避免只写进一部分导致题数骤降)
- [ ] 浏览器四类筛选都有内容:题型 / 难度 / 年份 / 知识点(fSrcType 自动从 qtype_sub 生成)
- [ ] 图片位显示「原图缺失」而非裂图
交付
- [ ] 交付包:md5 三方一致、testzip=None、bat 干跑输出正确路径
- [ ] 从 zip 解压后再验一次通过
本机环境
- Python:
C:/Users/Administrator/.workbuddy/binaries/python/versions/3.13.12/python.exe - Chrome:
C:\Program Files\Google\Chrome\Application\chrome.exe(probe_dom.py自动探测) - 360:
%APPDATA%\secoresdk\360se6\Application\360se.exe(用户日常浏览器,优先) - 题卡仓库:
M:\Obsidian Vault\题库\<模块>\<章节>\<知识点>\*.md(约 27900 篇) - 附件:
M:\Obsidian Vault\附件\(53200 个文件 / 3.5 GB) - 工作区:
M:\物理教研\_work\(脚本与备份都放这里) - 智能题库:
C:\Users\Administrator\Desktop\智能题库\(22 章 data.js 体系;图源在 home 电脑E:/MyVault/_库外文件_20261021/图片/05-附件/,当前机器缺失)
更多踩坑细节与代码片段:references/pitfalls.md