作为课程助教,需要从某个在线做题平台的班级练习排名里,把某学号段的同学每人每道题得分导出来,方便督促作业,该如何操作?
本文记录从"直接猜 API"到"最终 DOM 抓取"的思路转折,以及其中值得聊一聊的相关知识点。
一、需求
平台是一个答题系统的「排名」页,分页展示,每行一个学生,列包括:排名、学号、姓名、总分,以及每道小题的得分(判断、单选、程序填空……)。
目标:
- 只保留学号
2026XXXXXXX之后的同学; - 保留每道小题的得分;
- 导出成 Excel 能直接打开的 CSV。
二、踩坑路径(简述)
整个过程其实就是一次极简版的 Web 逆向,按时间顺序:
| 尝试 | 结果 | 原因 |
|---|---|---|
直接猜 RESTful 路径/api/.../exam/rankings?page=1 | 报错Unexpected token '<' ... is not valid JSON | 服务器返回的是 HTML(404 页 / 登录页),不是 JSON |
F12 → Network 找到真实接口common-rankings?page=0&limit=50 | 自己fetch调用返回404 | 缺了平台自定义的反爬请求头 |
右键「Copy as fetch」补齐x-lollipop、x-marshmallow等头 | 偶发成功,随后又 404 | 这类 token 是一次性/短时效的,会过期 |
| 放弃打 API,改为直接抓页面已经渲染好的表格 DOM | 一次跑通 | 不再和反爬机制对抗 |
这是一个典型结论:当你有权限看页面本身时,DOM 抓取永远比硬刚 API 反爬更便宜。
三、关键知识点(重点)
1.Unexpected token '<'—— 一个被低估的报错信息
控制台报SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON。
看到这个报错不用慌,它在告诉你一件事:
你以为你在请求 JSON,实际上服务器给你回了一个 HTML 文档。
HTML 一定以<!DOCTYPE html>或<html>开头,JSON 不可能以<开头,所以 JSON 解析器在第一个字符就崩了。常见原因:
- 请求路径错了,命中了前端路由,被 fallback 到
index.html; - 未登录,被重定向到登录页;
- 鉴权失败,返回了一个 HTML 错误页。
划重点:做接口测试时,先看Response的 Content-Type,再决定用不用.json()。一个成熟的脚本应该先console.log(resp.status, await resp.text())看一眼真实返回,再决定怎么解析。
2. Cookie / Session:浏览器已经帮你做了什么
在 Network 面板的 Cookie 一栏里能看到:
JSESSIONID:典型的 Java Servlet 会话 ID;- 平台自定义的会话凭证(形如
XXXSession); - 若干
_ga/_uid之类的统计或指纹 Cookie。
这些值意味着:你在浏览器里是登录态。当你在控制台写fetch(url, { credentials: "include" })时,浏览器会自动把这些 Cookie 带上。
fetch(url,{credentials:"include"})credentials: "include":跨域也带 Cookie;credentials: "same-origin"(默认):同源才带;credentials: "omit":完全不带。
小知识:
- 会话固定(Session Fixation)、CSRF 的本质都和"浏览器自动携带 Cookie"有关;
- 服务端为了防 CSRRF,会要求请求带一个同步令牌(即后面说的
x-头),这个令牌通常通过<meta name="csrf-token">或首页接口下发,不能靠猜。
3. 反爬请求头:x-lollipop/x-marshmallow
最关键的一步:用「Copy as fetch」对比成功请求和自己发的请求,发现多了两个奇怪的头:
x-lollipop: <一段随机串> x-marshmallow: <空串或短值>把它们补上就能通,过一段时间又失效。
这是前端反爬里非常经典的做法:
- 页面加载时,JS 根据 UA、Canvas 指纹、时间戳、行为数据算一个签名;
- 后续所有 XHR 都要求带这个签名头;
- 服务端校验签名是否合法、是否过期、是否复用。
为什么是自定义x-头而不是 Cookie?
- Cookie 会被浏览器自动附加,攻击者很难篡改;
- 自定义头需要前端 JS 显式设置,没有 JS 执行环境的纯 HTTP 工具(curl、简单 requests)就拿不到正确值,这就把一部分裸脚本挡在门外。
划重点:
- 这属于「前端行为验证 / 设备指纹」的轻量实现,比滑块、验证码便宜;
- 绕它的正规思路是:不绕,直接复用浏览器环境(Puppeteer / Playwright),或者像我们本次一样——根本不调接口。
4. 同源策略与 CORS
控制台里看到的报错(WebSocket 连接失败等)和我们无关,但值得一提:为什么我们的fetch在控制台里能成功调用?因为我们是从目标平台自己的控制台发的请求,属于同源,不受 CORS 限制。
如果换成在https://evil.com的控制台里发fetch("https://target.com/..."),就会被 CORS 拦住,除非服务端返回Access-Control-Allow-Origin。
5. DOM 抓取 vs API 逆向:成本对比
| 维度 | API 逆向 | DOM 抓取 |
|---|---|---|
| 数据结构化程度 | 高(JSON) | 中(要自己解析) |
| 抗反爬能力 | 差(签名、Token 一变就废) | 强(只要页面长这样就能读) |
| 性能 | 好(一次拿 JSON) | 一般(要翻页、等渲染) |
| 工程复杂度 | 高 | 低 |
| 适合场景 | 高频、大规模、无人值守 | 一次性、小数据量、有登录态 |
比如一共 95 条数据,翻 2 页就完事,DOM 抓取是性价比最高的选择。
6. 两个工程小细节
(a) 分页 page 从 0 开始
URL 里是?page=0&limit=50,不是page=1。第一次循环写成page=1直接少抓一页。这种细节只能靠 Network 面板看真实请求,不能靠想当然。
(b) CSV 加 BOM 头,Excel 才认中文
newBlob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"})\ufeff是 UTF-8 BOM。Excel 在没有 BOM 的情况下会按系统本地编码(中文 Windows 是 GBK)去猜 UTF-8 文件,导致乱码。加这一个字符,中文列名就正常了。
© 表格文本解析的坑
最开始我们用document.querySelector("table").innerText,再按换行切分。结果解析出 800 多行假数据——因为每个单元格内部本身有换行,innerText会把单元格内的<br>也输出成\n,导致一行数据被切成多片。
正确做法是直接遍历 DOM:
document.querySelectorAll("tr").forEach(tr=>{constcells=[...tr.querySelectorAll("td")].map(td=>td.innerText.trim());});DOM 是结构化的,tr就是一行,td就是一列,不会错。
四、脱敏后的脚本
asyncfunctionexportRank(){constsleep=ms=>newPromise(r=>setTimeout(r,ms));functionparseRows(){constrows=[];document.querySelectorAll("tr").forEach(tr=>{consttds=tr.querySelectorAll("td");if(tds.length<4)return;// 跳过表头rows.push([...tds].map(td=>td.innerText.replace(/\n/g,' ').trim()));});returnrows;}letall=parseRows();// 翻到下一页(页码按钮文本为 "2")constnext=[...document.querySelectorAll("button,li,a")].find(el=>el.textContent.trim()==="2");if(next){next.click();awaitsleep(1800);all=all.concat(parseRows());}// 只保留学号 >= 阈值(学号是N位数字字符串,按字典序比较即可)constTHRESHOLD="2026XXXXXXX";consttarget=all.filter(cells=>{constm=cells.join(' ').match(/20\d{10}/);returnm&&m[0].localeCompare(THRESHOLD)>=0;});// 导出 CSV(带 BOM,Excel 中文不乱码)constheader="排名,学号,姓名,总分,...各题得分...,开始时间\n";constcsv="\ufeff"+header+target.map(r=>r.join(",")).join("\n");constblob=newBlob([csv],{type:"text/csv;charset=utf-8"});consta=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="rank_filtered.csv";a.click();}exportRank();五、合规与边界
最后必须提一句:
- 这个脚本跑在你自己已经登录、且有权查看的班级页面里,导出的是合规范围内的数据;
- 没有绕过登录、没有爆破接口、没有对服务端造成额外压力;
- 脚本只在本地浏览器执行,不向第三方发送任何数据。
清楚这条线:“技术上能做到” 和 “该不该做” 是两件事。同样一段代码,用来导出本班级的作业情况是效率工具,用来批量爬别人平台的数据就可能踩红线。
六、一句话总结
遇到反爬,先别急着逆向签名。问自己一句:这个数据我在浏览器里已经看得见了吗?看得见,就直接从 DOM 拿;看不见,再考虑打 API。