写学习通自动建立题库脚本的经历!反复被bug鞭打
写学习通自动建立题库脚本的经历!反复被bug鞭打
这脚本是我自己用的小工具,边写边测,每加一个功能就翻一次车。下面按翻车顺序把这些错记下来——技术点基本都藏在这些错里,比正面讲"我设计了个架构"有用得多。
单文件 Tampermonkey 用户脚本,约 4600 行、228 个函数、249 KB。
一、起因
期末复习的时候,我把老师给的练习题做了一遍又一遍。每做一遍,就得手动把做错的题抄到笔记里。这件事大概吃掉了我三分之一的复习时间,而且抄完还是记不住。
所以我想:让脚本把做过的题连答案一起存下来,我只看错题不就完了?
出发点就这么简单。功能是后来越加越多的:先是"存题",存了发现得能"查",查了发现错题得能"重练",重练发现得能"组卷"……典型的滚雪球。
二、第一次翻车:我以为"题目最多的那一组"就是对的
脚本要在学习通页面上找到题目。类名会改版,所以我写了三套策略并行:按已知类名找、按选项控件分组找、按题号文字锚点找。
三套都跑出结果之后,得选一套用。我当时的判断是——取数量最多的那组。看起来特别合理:找到的题越多,说明这组越完整。
这个想法害了我很久。
有一次我打开一个只有 8 道题的作业,面板显示"共 55 题"。我以为是页面有问题,刷新没用,换浏览器也没用。
我没猜出来,最后是在脚本里加了一个按钮:点一下就把解析过程的内部状态全打出来。一按,答案就在眼前:
采用策略:按选项控件分组;题目块数:58
[块0] <div class="xxt-card">
[块1] <label class="xxt-bankrow">
脚本把我自己画的面板当成题目了。
那面板上有几十个复选框(各种开关),"按选项控件分组"把它们的容器全算了进去,58 个块轻松压过真实的 8 道题。而我的选择逻辑只比数量、不比质量。
修法两条:
- 解析时排除自己注入的 DOM——加个
isOwnNode(),往上找祖先,遇到#xxt-panel这类前缀就跳过; - 选择策略时先比质量再比数量。质量怎么算?看这组块里"像题目"的比例——有选项控件、或者有成组的 A/B/C 选项文字,才算像:
function blockQualityScore(list) {
if (!list.length) return 0;
var good = 0;
list.forEach(function (el) {
var inputs = el.querySelectorAll('input[type=radio], input[type=checkbox]').length;
if (inputs >= 2 || findOptionRowsGeneric(el).length >= 2) good++;
});
return good / list.length;
}
像题率不到 60% 的组直接淘汰,同分才比数量。
教训:数量多不等于正确。尤其在解析别人页面的时候,"多"往往意味着"抓脏了"。
三、第二次翻车:切了作业,还在做上一份的题
修好上一条之后我用得挺顺,直到有一次我做完一个 17 题的作业,切到另一个只有 7 题的作业——面板显示还是 17 题。点开始,它真的又去做那 17 题。
这次我很快就想到原因了:学习通是 AJAX 换页,切作业时页面不重新加载(脚本也就不重跑),而旧作业的 DOM 只是被 display:none 藏起来,并没有从文档里移除。
我的解析代码里,一行可见性判断都没有。
修法是两个:
// 自己或任一祖先被隐藏 → 不算当前题目
function isHidden(el) {
var v = el, guard = 0;
while (v && v.nodeType === 1 && guard++ < 30) {
if (v.hidden) return true;
if (v.getAttribute && v.getAttribute('aria-hidden') === 'true') return true;
var st = (typeof getComputedStyle === 'function') ? getComputedStyle(v) : null;
if (st && (st.display === 'none' || st.visibility === 'hidden')) return true;
v = v.parentElement;
}
return false;
}
再补一个:每次解析前比对 URL,变了就作废上一页的解析结果。
教训:跨页自动化里,"页面"和"状态"必须显式绑定。任何"页面没变"的隐含假设,都得有一个失效检查兜着。
四、第三次翻车:宿主页面的 CSS 教我做组件
我把面板注入学习通页面之后,界面惨不忍睹:列表符号没了、复选框看不见了、代码块变成一个巨大的方块。
我一开始以为是自己的 CSS 写错了,查了半天才发现——我不是在写自己的页面,我是在写一个要活在别人 CSS 里的组件。宿主页面里有这类全局重置:
ul, ol { list-style: none; }
li { display: block; }
code { display: block; }
input { appearance: none; }
这些规则的优先级比我想象的高。最后的做法没什么技术含量,就是三条:
- 所有选择器都用 ID 前缀限定(
#xxt-panel .xxx); - 关键属性一律
!important; - 列表符号必须直接写在
li上,不能靠继承:
/* 这样写不够,会被宿主的 li{display:block} 干掉 */
#xxt-panel ul { list-style-type: disc; }
/* 要写在 li 上,并且 !important */
#xxt-panel ul li { list-style-type: disc !important; display: list-item !important; }
第 3 条我卡了最久。原因是:直接设在元素上的值,永远胜过继承来的值。宿主把 display: block 直接设在 li 上,你从父级继承的 list-style 就形同虚设。
后来我写了个小工具:把宿主那套"敌意 CSS"注入到一个预览页里,用无头浏览器截图看。凡是"本地看着好好的、注入之后烂掉"的问题,都能在提交前抓到。
五、第四次翻车:我不信 SheetJS 不写样式,专门测了一遍
导出 Excel 一开始用 SheetJS,写得很顺。然后我自己看着导出的文件别扭:所有选项挤在一个单元格里,一坨,看不清。
我想当然地去设置样式:
ws['A1'].s = { alignment: { wrapText: true, vertical: 'top' } };
一点效果都没有。
我不太信,写了个脚本生成文件后直接解压看 XML:
z = zipfile.ZipFile('out.xlsx')
s = z.read('xl/styles.xml').decode('utf-8')
print('含 wrapText =', 'wrapText' in s)
结果是 False——样式表里根本没有 wrapText。但列宽和行高是写进去的:
| 能力 | SheetJS 社区版 |
|---|---|
列宽 !cols |
会写(customWidth="1") |
行高 !rows |
会写(ht="40" customHeight="1") |
单元格样式 cell.s |
完全忽略(写样式是 Pro 功能) |
而我恰恰需要自动换行。
最后一个有点冒险的决定:自己拼 OOXML,自己写一个极简 ZIP。好处是不加任何依赖、完全可控;代价是要处理 CRC32 和 ZIP 的结构。
ZIP 用 store(不压缩)模式,实现简单也不容易错:
dv.setUint32(0, 0x04034b50, true); // 本地文件头签名
dv.setUint16(8, 0, true); // 0 = store,不压缩
dv.setUint32(14, crc, true); // CRC32
dv.setUint32(18, data.length, true);
样式表里就随便写了:
<alignment vertical="top" wrapText="1"/>
然后是这一步最关键:回读验证。 手写二进制格式最怕"自己觉得对"。我用 openpyxl 把生成的文件读回来逐项核对,还专门做了 300 题压测——行数一多,ZIP 偏移量算错就会立刻暴露:
300 题 → zip 完整性 OK,逐成员 CRC 读取全部 OK(说明偏移量在规模下是对的)
尺寸 A1:G301,冻结 A2,自动筛选 ✓
行高按内容自适应:81 / 51 / 111 / 51
XML 转义:无裸 script 标签,& 已转义,无非法控制字符
顺手还修了一个隐患:题库名里如果带 /、: 这类字符,Excel 的工作表名是非法的,会直接报错。
教训:生成"成功"不等于"格式正确"。凡是二进制或结构化输出,都得回读一遍;而且小样本对不代表大样本对。
六、第五次翻车:我说 0 是有效的,代码根本不认
这一条是我自己设计需求时埋的雷。
停止条件里有"最低轮数"。我的想法是:最低轮数填 0 就等于不设下限,配合"提前停止"就是"题库一命中就停"。
结果代码里是这么写的:
config.work.min_rounds = num(getV('cfg-min-rounds')) || 1;
0 被 || 当成假值吞了,永远变成 1。 我在文档里信誓旦旦写着"可以填 0",实际根本填不了。
改法是显式判空,只把"没填"当默认:
var raw = getV('cfg-min-rounds');
config.work.min_rounds = (raw === '' || raw == null) ? 1 : Math.max(0, num(raw));
教训:x || 默认值 这个写法,在"0 是合法值"的场景里都是定时炸弹。而且——自己描述需求时用的隐含前提,也要去代码里验一遍。
七、第六次翻车:规则互相打架,我自己没看出来
停止条件涉及四个量:最低轮数、最高轮数、提前停止(题库已覆盖)、满分即停。
我第一版写的优先级是"提前停止可以越过最低轮数"——理由是"没有新题了再刷也是浪费"。当时觉得挺有道理。
后来被人问了一句:"这个到底是必须大于最低轮次,还是只要没有新题就行?感觉意义不明确。"
我回头看代码,才发现两条规则是真的矛盾:
// 0 新题:完全无视最低轮数,立刻停
if (config.work.stop_when_covered && st.lastUnknown === 0) { 停止 }
// 满分:却必须熬够最低轮数
if (full && st.rounds >= config.work.min_rounds) { 停止 }
而且还有第二个问题:"某一轮 0 新题"是很弱的证据。学习通是从题库随机抽题的,抽题池 100 题、每轮抽 25 题时,你收到 95 题之后某一轮全是已收录的题,完全可能。
最后我把模型改成:最低/最高轮数是硬约束,提前停止只是"到了下界之后"的一个优化。
① 最高轮数 硬上限,无条件停
② 最低轮数 没到就一定继续 —— 提前停止也要让路
③ 提前停止 已满足最低轮数 且 连续 N 轮 0 道新题 → 停
④ 已满分 已满足最低轮数 且 达到真实满分 → 停
顺带把"连续几轮"做成可调参数(默认 1,担心漏题就调到 2~3),并且把判定用到的每个量都显示在面板上——原来只显示"已完成 N 轮",满分和"连续无新题 N/M 轮"都看不见,当然看不懂规则在算什么。
教训:当两条规则为"谁优先"打架时,通常不是要加例外,而是模型本身错了。改成"硬约束 + 优化"之后,例外规则全消失了。
八、还有一堆小的
| 坑 | 现象 | 最后怎么处理的 |
|---|---|---|
| 静默失败 | AI 返回格式不对,面板显示"AI ✓",实际一个字没填 | 失败必须计数 + 记日志,不许静默降级 |
| 空值相等 | 两个页面都取不到 courseId(都是空串),'' === '' 成立,状态串页 |
回退逻辑里强制校验字段非空 |
| 打印页关不掉 | window.print() 开出来的窗口没有关闭入口 |
不自动弹打印框,页面里给"打印"和"关闭"两个按钮 |
| 测试和线上不是一套逻辑 | 早期自测里复制了一份业务代码 | 改成从主脚本提取真函数来测 |
最后那条值得多说一句。我的自测脚本现在是160 项断言,用模拟 DOM 跑。关键设计是它不复制业务逻辑,而是从主脚本里把函数抽出来测:
const code = 'var lastParse={...};var config={...};\n' +
NAMES.map(extractFn).join('\n\n') +
'\nreturn {decideStop, parseAllQuestions, ...};';
const API = new Function('document', 'window', 'GM_getValue', 'GM_setValue', code)(...);
好处是改主脚本、测试自动跟着变;成本是得手工维护一份函数名清单。靠它抓出来的真问题包括:判断题在 A 对 / B 错 的时候误选了字母 A,以及上面那个 0 被吞的 bug。
九、它现在是什么样
不吹,就列事实:
- 单文件用户脚本,约 4600 行,数据全存在浏览器本地(GM 存储),不传服务器;
- 能自动识别页面上的题目、把正确答案和解析存进本地题库,下次遇到直接命中;
- 题库没命中的题调 AI 作答(任何 OpenAI 兼容接口,密钥只存本地);
- 能按题型 / 题库 / 错题本随机组卷自测,带倒计时和自动判分;
- 题库支持多本、搜索、勾选转移到别的题库、合并、去重;
- 能导出 JSON / Excel / Word / PDF,也能导入纯文本 / JSON / Excel / CSV;
- 提交时间有 ±20% 随机波动,可选的每题随机延时(3~8 秒)。
它做不到什么:填空题不支持(一题多个空的失败方式太隐蔽,我干脆把这类题跳过并写进日志);只对电脑版页面有效,手机上用不了;页面大改版时解析可能失效——那时候点面板上的"诊断页面结构",把报告贴出来才有办法定位。
下载:学习通刷题插件.zip
包里除脚本外还有安装图解、完整说明书(PDF/Word)和一份免责声明。安装就三步:装 Tampermonkey → 新建脚本把 学习通刷题.user.js 整段粘进去 → 在面板「设置」里填 AI 的 Base URL / API Key / 模型名。
建议一定配 AI:不配的话,题库没命中的题只能随机猜。如果作业要求"达到一定分数才能交卷",随机猜很容易凑不够分,于是这一轮白做、题也收不进来,题库永远涨不起来。
十、最后
一句正经话:这脚本是我给自己省抄题时间写的,不是给考试用的。别拿它作弊、别拿去卖、别违反你学校的学术诚信规定——包里的免责声明写得很清楚,用不用、后果如何,都由使用者自己承担。
如果你的作业要求一定分数才能交卷,而里面又有填空题,那很可能凑不够分,就得自己补。这是这个工具的边界,我不打算硬圆过去。
(页面结构分析的思路参考了开源项目 CodFrm/cxmooc-tools(MIT)。)
浙公网安备 33010602011771号