这个项目教会我最重要的一件事:一个对接类项目的"完成度",不取决于代码写了多少,而取决于有多少事实是被真实验证过的。
一、先说结论:我第一版交付的东西,是不可用的
stdu-cli 的起点很朴素:石家庄铁道大学的教务系统没有好用的移动端,我想让同学能在 AI 对话框里直接问课表、成绩、空教室。
我从河南大学的同类项目 henu-cli 移植架构,按正方教务系统的"典型结构"填好了所有端点、字段名、返回结构。然后我跑了一遍:doctor 自检绿的、单元测试 8 项全绿、help 输出漂亮、仓库推到 GitHub、README 写得很完整。看起来完成了。实际上一条课都查不出来。
因为那些端点是"经验值"——照着别的学校、别的版本猜的。我当时在交付说明里诚实地写了"需要用户本人跑一次 dump 校准",但心里清楚:那等于把最难的部分推给了用户。后来借到一次真实的校园网会话,我用 8 轮迭代才把接口校准完。回头看,这 8 轮踩的坑,几乎每一条都指向同一类错误:我把假设当成了事实。
二、教训一:在对接类项目里,"猜测"必须被消灭,而不是被标注
我早期代码里到处是这样的注释:「端点为经验值,需校准」。我一度觉得这是负责任的做法——我标注了不确定性嘛。但它没有解决问题。真正有用的是把不确定性当成待办事项,排进主线,用真实数据一条条消掉。
我为此加了两个工具,这是整个项目里投入产出比最高的改动:
stdu-cli bootstrap:登录成功后在同一个浏览器上下文里立刻抓真实响应。关键是"同一个上下文"——教务会话几分钟就过期,分成两步跑永远抓不全。
stdu-cli dump:把响应落盘成 JSON,供离线分析。
有了这两个,校准从"求人帮忙"变成了"跑一条命令"。
可复用做法:第一天就要问自己——我有哪些事实是没验证过的?我有没有一条命令能拿到真实验证材料?如果没有,先做这个工具,再做功能。
三、教训二:外壳页 ≠ 数据接口
校准过程中卡了最久的一个坑,我给它起名叫 no-jquery。现象是:用 Playwright 打开「空闲教室」页面,点"查询",毫无反应。按钮存在,选择器正确,点击成功,什么都没发生。
排查了很久才明白:① 正方 V9 的功能页只返回一个 HTML 外壳,数据由页面加载后的驱动脚本另外 POST 获取;② 这些页面不自带 jQuery,只引了 jqGrid / validate 这类插件,它们依赖父框架提供 jQuery;③ 当我直接 goto 打开这个页面时,父框架不存在,驱动脚本在解析期就抛错了,#search_go 的点击事件从来没被绑定过。所以点击必然没反应。
我试过注入 jQuery,仍然失败——页面自己的 init 脚本已经先报错了,注入太晚。最后的解法很朴素:压根别点按钮,直接 POST 真实数据接口,把 #search_go 那套表单校验门控整个绕过去。
这件事的价值不只是"找到了接口",而是改变了我的取数模型:不要模拟人的操作路径,要复现浏览器的网络行为。 后者稳定得多,也快得多。
四、教训三:接口通了不等于语义对了
接口打通之后,我拿到 22 门课,很兴奋。然后用户问:"明天课表给我。" 我一想,简单,筛星期二。结果错了。三个坑叠在一起:
坑 1:课表按教学周次排,不是按星期。 单双周、分周次换教室、调课,都在周次里。只按星期过滤必然错。
坑 2:当前周次不在课表接口里。 我翻遍了首页、课表外壳页、日期安排接口,全都没有。最后在「学生课表查询(按周次)」页 N2154 的隐藏域 dqzc 里找到了(实测值 7),配合下拉框选中文本 7(2026-10-26至2026-11-01) 拿到本周日期区间,这才有了"任意日期 → 周次"的换算能力。
坑 3:我自己的解析器有 bug。 周次字符串长这样:1-3周,5-7周(单),8-10周。我原来的 parseWeeks 把"单"当成作用于整个字符串,解析成 [1,3,5,7,9]。实际上(单) 只作用于它所在的那个逗号分段,正确结果是 [1,2,3,5,7,8,9,10]。这个 bug 有多致命?它会让"明天有课"变成"明天没课"。
可复用做法:正确性分两层——传输层(拿到了 200 和 JSON)和语义层(字段的含义、组合规则、边界条件)。绝大多数 bug 在第二层,而第二层只能靠真实数据 + 人工推理覆盖。写完解析器后,我养成了拿最复杂的那条真实字符串手算一遍的习惯。
五、教训四:体验的瓶颈,往往在系统约束而不在代码
功能跑通后,用户给了两条反馈:「每条命令都要跳转认证,我希望只认证一次」「响应太慢」。这两条一开始被我当成"优化项",后来发现都是根因问题。
「每条命令都要认证」:CAS 下发的是会话级 cookie,而我原来每条命令结束都 context.close()——等于每次用完就把登录态扔掉。不是"认证做得不好",是"我主动销毁了它"。解法是 cookie 落盘 + 常驻守护进程(先 headless 启动,检测到未登录才 upgradeToInteractive() 弹认证页,30 分钟空闲自动退出)。实测验证很关键:我把所有浏览器关掉、把 daemon 杀掉、删掉 daemon.json,然后查课表——0 次认证提示,3.6 秒返回 22 门课。
「太慢」:我先量化,再动手。拆开看 Node 自身启动 719ms、每次新开浏览器 2.2s,剩下的才是网络请求。大头根本不在查询,在启动。
场景 优化前 优化后
缓存命中 — 1–2 ms
守护进程热查询 ~2.5 s 155–335 ms
冷启动(免登录) 需重新认证 3.6 s,0 次认证
CLI 启动 1.4 s 1.1 s(动态 import)
缓存 TTL 按数据变化频率给:课表 6 小时、成绩 30 分钟、空教室 10 分钟、当前周次 6 小时。
可复用做法:收到"太慢"的反馈,第一件事是分解耗时。我原本以为瓶颈在教务服务器响应,实测发现 80% 在进程和浏览器启动上——凭直觉优化会做完全错误的事。
六、教训五:可分发性要当成功能来做,不是文档来做
这个项目的终点不是"我能用",是"同学能用"。这改变了几个决定:
安装必须一条命令:npx -y github:974294341-bit/stdu-cli install,自动复制技能到 AI 目录、装浏览器内核、写配置、自动弹出认证页。
把"怎么用"写进技能文件而不是 README:SKILL.md 里明确写了自然语言到命令的映射,还写了行为约束——「不要动不动就叫用户去登录,只有换绑才用 relogin」「问明天的课一律用 course --tomorrow,不要自己按星期猜」。因为真正读这份文件的是 AI,不是人。
给同学的是一段提示词,不是一份文档:SHARE.md 里准备了可以直接粘进 AI 对话框的引导语。
这里有个反直觉的点:给 AI 写的文档,约束比说明重要。 我最初的 SKILL.md 写满"这个命令做什么",实测 AI 还是会自己按星期猜课表、频繁叫用户重新登录。后来把这些写成明确的禁止条款,行为才稳定下来。
七、教训六:工程细节会吃掉你整天
响应去重键把 POST 当重复丢了:用 url|content-type 去重,结果 GET 外壳页和 POST 数据接口被判成同一个请求,数据全被丢掉。加上 method 才对。
手工 POST 缺字段返回 500:空教室接口必须把表单字段全部提交,缺一个就报「系统升级维护中」,很有误导性。
守护进程在 CLI 退出后消失:因为异常没兜住。加 uncaughtException / unhandledRejection 保护 + stderr 落盘到 ~/.stdu-cli/daemon.log 之后才稳定存活(顺带修好了 daemon stop 失效)。
GitHub Contents API 的路径编码:必须按分段编码(path.split('/').map(encodeURIComponent).join('/')),整体 encodeURIComponent 会把 / 变成 %2F,在仓库根目录建出一堆 src%2Fconfig.js 之类的垃圾文件。我因此清理了 14 个文件。
沙箱里 curl 连 localhost 会被代理拦(502):测本地服务要用 Node 的 http 直连。
八、一份可复用清单
先建验证工具,再写功能。 一条命令能拿到真实响应的那种。
给每个未验证的事实打标记,并规定消灭它的路径。 标注不是终点,是待办。
复现网络行为,而不是模拟人的点击。 直接打真实数据接口。
传输层通了之后,专门做一轮语义层审查。 拿最复杂的真实字符串手算一遍。
收到"慢"的反馈,先分解耗时。 别凭直觉优化。
体验类问题先找系统约束。 "每次都要登录"的根因是会话级 cookie,不是认证流程写得差。
给 AI 的文档,重点是约束不是说明。
端到端实测,且要测最坏情况。 我最后那次验证是"关掉一切再查一次",这才真正证明了"只认证一次"。
九、最后
这个项目的代码量不大,25 个模块约 2800 行。真正花时间的是查清楚「它到底是什么样」,以及搞清楚「我以为的和实际的不一样在哪」。
在工程里,"我不知道"不是需要掩饰的东西,而是需要被管理的东西——管理它的方法,是建立一条从未知到已知的低成本路径。
这条路径在 stdu-cli 里叫 bootstrap 和 dump。在别的项目里它可能是别的名字,但它必须存在。
浙公网安备 33010602011771号