场景:教务管理平台(uManage)大规模数据维护
目标:跨年级批量增删学生、班级、教师账号与任课配置
手段:Chrome DevTools Protocol + 前端接口逆向 + Node.js 批量脚本
规模:一次会话内完成 5000+ 学生、70+ 班级、50+ 教师账号的增删改
环境:Windows / Chrome 153 / Node.js 22
教务系统这类内部平台,通常只提供”一个个点”的表格 UI。当需求变成”把整个高二年级的学生清空重建””按名单批量建班导学生””按表格批量设置任课教师”时,纯手工操作是不现实的。这篇文章记录一次完整实战:如何让浏览器被程序驱动、如何把 UI 操作还原成接口、以及踩到的每一个坑。
第一关:让浏览器能被程序驱动
技术路线很直白:用 Chrome 的远程调试协议(CDP)接管一个真实浏览器实例,复用它的登录态,在页面上下文里直接发 fetch。难点不在 CDP 本身,而在于让这个浏览器真的能起来、并且活下来。
Chrome 153 的 App-Bound Encryption
Chrome 从 127 版本起引入了 App-Bound Encryption(ABE),Cookie 等敏感数据的加密密钥会绑定到原始 profile 路径。带来的直接后果是:把默认 profile 复制一份到别的目录再启动,登录态是解不开的。
同时,新版本对默认 profile 开启了保护——直接对日常使用的 profile 加 --remote-debugging-port,端口根本不会监听。
进程被 job object 回收
第二个坑更隐蔽:从命令行启动的 Chrome,会在命令返回后被操作系统连同进程组一起回收——端口刚监听上就没了。
依次排除过的方案:
nohup ... &+disown:无效,Windows 上 detached 语义和 POSIX 不同cmd /c start:进程仍在同一个 job 里- 计划任务 / WMIC:被安全策略直接拉黑
explorer.exe转发启动 bat:本环境不执行 batShell.ApplicationCOM:被安全策略判定为可执行任意代码而拦截
最终稳定可用的方式,是用一个常驻后台任务承载浏览器进程:把 Chrome 的启动放进一个长期运行的后台任务里,进程便不会再随单次调用消失,后续所有脚本都能连上同一个 127.0.0.1:9222。
curl http://127.0.0.1:9222/json/version。返回 JSON 即正常,返回空说明实例已经没了。实例就绪后,注入登录态并打开目标页只需要三步:
// 连上浏览器
const { webSocketDebuggerUrl } = await (await fetch('http://127.0.0.1:9222/json/version')).json();
const ws = new WebSocket(webSocketDebuggerUrl);
// 1) 全域注入 Cookie(绕开 ABE 无法迁移登录态的问题)
await send('Storage.setCookies', { cookies: [{
name: 'SESSION', value: '<登录态>',
domain: '.example.com', path: '/', secure: true, sameSite: 'Lax',
}]});
// 2) 开一个新标签并附着调试会话
const t = await send('Target.createTarget', { url: 'about:blank' });
const { sessionId } = await send('Target.attachToTarget', { targetId: t.result.targetId, flatten: true });
// 3) 导航,之后所有 fetch 都自动带上登录态
await send('Page.navigate', { url: TARGET_URL }, sessionId);
第二关:把 UI 操作还原成接口
浏览器能被驱动之后,剩下的就是”翻译”工作:把所有要批量执行的操作,抓出它背后的 HTTP 请求。
做法是开启 CDP 的 Network 域监听,人工在页面上完整操作一遍(删一个学生、建一个班、加一位教师),把过程中的请求全部落盘,然后分析参数结构。
因为页面本身是前端框架渲染的单页应用,接口规律非常整齐:统一的 POST/GET + 表单或 JSON 体 + CSRF token 放在 URL query 上。
核心接口清单
| 操作 | 方法与路径 | 关键参数 |
|---|---|---|
| 删除学生 | POST /student/delete |
studentListJson(含 studentId / name / classId) |
| 批量新增学生 | POST /student/batch/add |
studentJsonStr、password(RSA 密文) |
| 查询学生列表 | GET /student/list/new |
studentQueryDTOStr,limit 上限 50 |
| 批量删除班级 | POST /class/class-batch-delete |
classIdAndTypes,需 X-Requested-With |
| 班级课程教师 | GET /class/course-teacher-list |
classId / teachingCycleId |
| 批量设置任课 | POST /teaching/batch-org-teaching-set-with-lock-status |
JSON 体 + token 放 query |
| 新增教师 | POST /teacher/add-teacher |
表单字段见下 |
| 删除教师 | POST /teacher/delete-teacher |
ids / schoolId / dimissionReason |
| 教师名册 | GET /teacher/list/new |
userLoginName / page / limit |
| 班级树与人数 | GET /tree/class-tree |
gradeCode / teachingCycleId |
接口逆向的六个坑
1. 密码字段是 RSA 密文
前端在提交前会调用 window.encryptedString(明文) 做 RSA-2048 加密,输出 512 位十六进制。一开始以为每名学生都要单独算一次密文,后来发现这是确定性加密——同样的明文永远得到同样的密文。
2. limit 超过 50 会静默返回 HTML
列表接口传 limit=200 不会报错,而是返回一个 HTML 页面(登录/错误页),解析 JSON 就直接炸。表现为”某个班级复验结果变成 -1″。
修正:limit=50 固定,靠翻页拿全量。
3. 少一个请求头就返回 HTML
/class/class-batch-delete 缺少 X-Requested-With: XMLHttpRequest 时同样返回 HTML。凡是”接口返回 HTML”的情况,先怀疑请求头。
4. token 放错位置
批量设置任课的接口,CSRF token 必须放在 URL query 上,写进表单体会被拒绝。
5. 字段不全导致”假成功”
/teacher/add-teacher 如果只提交必填的姓名、密码,接口会返回“新增成功”,但数据库里根本没有这条记录。
逐个字段二分定位后确认:必须同时提交 idCardNo、teacherStaffNo、mobile、email、departmentListStr 五个空值字段,才会真正落库。
6. 列表缓存延迟与翻页漏记录
教师列表接口有缓存:删除账号后立刻查询,结果还是旧的,大约半分钟后才更新。此外翻页抓取会漏记录(曾漏掉某个教师名下的第三个账号)。
修正:复验时按姓名精确搜索,而不是全量翻页。
第三关:怎么确认数据真的落库了
前端页面上的红底/黄底提示,其实是页面打开那一刻的本地校验快照,不会实时刷新——补完账号再回看,标记不会变,必须重开页面。
真正可靠的验证方式是反向核对:用读取类接口(如班级课程教师列表)把服务端真实数据拉下来,与提交前的表格逐格比对。
这次就靠这个方法抓出一个漏网之鱼:高二 36 个班、254 个教师填报格子,服务端比对显示 247 格完全一致,唯独 38 班整行 7 格没落库——提交时那一行被漏掉了,从页面上完全看不出来。
可复用的”技能包”
执行范式
整套流程沉淀成一个固定的五步范式:
- 读取:用接口拉服务端真实数据,建立基线
- 计划:本地算出保留/删除/新增清单,先跑 dry-run 打印计划
- 执行:逐条调用接口,
status=1才算成功,记录每一条响应 - 复验:独立再读一次服务端数据,逐条核对
- 备份:删除前导出完整清单(含 ID、账号、手机号),随时可追溯
脚本清单
| 脚本 | 职责 |
|---|---|
visit.mjs |
CDP 注入 Cookie + 打开目标页 + 截图 |
capture_net.mjs |
CDP 监听网络,抓取页面操作的真实请求 |
batch_delete.mjs |
批量删除(进班删除学生 / 批量删班) |
batch_add.mjs |
按名单批量导入学生(复用密码密文) |
extract_grid.mjs |
提取页面表格网格与单元格底色标记 |
dump_roster.mjs |
全量/按姓名拉取教师名册 |
delete_dup.mjs |
重名账号去重(保留唯一) |
compare.mjs |
表格计划 vs 服务端实际,输出差异清单 |
重名账号的处理策略
批量设置教师时,最麻烦的是同名教师——系统提示”存在同名用户,请从下拉框中选择”,无法自动匹配。处理策略是让每个姓名在教师库里唯一:
- 优先保留已登记手机号的账号
- 同组都无手机号时,保留创建较早(ID 较小)的那一个
- 删除前后各留一份清单,注明保留依据
写在最后
这类”内部系统自动化”的难度分布很有意思:CDP、接口逆向都是成熟技术,真正的成本几乎全花在环境对抗(浏览器加密策略、进程回收、安全拦截)和数据可靠性验证上。
三条经验值得记住:
- 凡是接口,先抓包;UI 上点一次,胜过猜十次
- 凡是批量写,必独立复验;”接口返回成功”和”数据真的进库”是两件事
- 凡是批量删,先备份清单;可追溯比快更重要






暂无评论内容