Junie's Blog

网页网址一直没变但内容变了,爬虫抓取动态分类名单的实践

全文共 1838预计阅读 7 分钟

在传统爬虫开发者的肌肉记忆里,URL 就是页面的唯一身份证

抓取高校导师名录的经典链路非常线性: 输入学院目录 URL ──> 找到所有分页链接(?page=2?page=3) ──> 提取每个老师的个人主页 URL ──> 逐个下载详情。

只要把相关的 URL 穷举完,名录抓取就算大功告成。

直到某天,有位同学扔来一个某知名大学化学学院的师资页面。

任务一眨眼跑完了,控制台打出:抓取成功,提取导师 3 人

我点开那个网页一看,差点被气乐了: 页面顶部排着一长串漂亮的 Tab 按钮:“无机化学”、“有机化学”、“分析化学”、“物理化学”、“高分子”…… 默认选中的是“学院领导”分类,总共就 3 位老师;而后面随便点开一个教研室,里面都密密麻麻排着几十位教授!

但不管你在这几个 Tab 之间怎么疯狂点击,浏览器的地址栏就像焊死了一样,自始至终连一个字符、一个 hash 锚点都没变过!

为什么现代高校网站喜欢做这种 SPA 交互?

随着 Vue/React 在政企和高校 CMS 中的普及,前端开发习惯用组件化 Tab + Ajax 异步局部刷新来展示部门名单。这种设计对普通用户浏览很友好,不用整页刷新;但对传统的“以 URL 为核心”的爬虫来说,这无异于一堵隐形墙。

爬虫看见了,但它“不会按按钮”

其实系统里的 DOM 解析器和 LLM 早已看到了这排按钮。

为了让大模型看懂页面布局,后端在把 HTML 送给模型前,会先进行一遍无损精简,把页面里的超链接、按钮、Tab 选项卡整整齐齐地编上号(比如 [Button #1: 分析化学])。

问题出在当时的调度规则上:

  • 入口路由阶段: 系统只允许大模型输出“下一步要访问的目标 URL”;
  • 分页遍历阶段: 系统严厉警告大模型“禁止把普通 Tab 按钮当成分页链接提交”。
通俗比喻:只认门牌号的快递员,遇到了旋转货架

为什么传统爬虫对这种单页无能为力? 想象一个只认门牌号(URL)的快递员:

  • 平时的房间(传统网页):每个房间都有独立的门牌(page=1, page=2),快递员推门进去就能取件;
  • 现代前端动态网页:整栋楼只有一个房间,进门后正中央放着一个旋转货架(Tab 按钮)。你必须伸手按一下红色按钮,货架转出“有机化学”的 50 个盒子;按一下蓝色按钮,又转出“分析化学”的 50 个盒子。

如果快递员脑子里只有“去下一个房间”的逻辑,而不知道“伸手按一下按钮”,他就永远只能看到货架正对门口的那 3 个默认盒子。

这导致了一个荒诞的僵局:

  • 爬虫看见了“分析化学”这个按钮;
  • 大模型也知道点进去能看到更多老师;
  • 但整个系统的通讯协议里,根本没有设计“请帮我点击这个控件”的语义字段!

于是爬虫安安心心收下默认选中的 3 个人,理直气壮地交卷收工。

别写死规则,也别让 AI 乱按

怎么破局?

最下意识的想法是“打补丁”:写个正则,只要在页面里看到“化学”、“教研室”、“研究所”就统统让 Playwright 去点一遍。

但这完全是饮鸩止渴: 如果明天抓的是法学院呢?后天抓的是医学院呢?难道我们还要维护一份包含全国所有学科分类的巨型词典?

另一个极端更危险:让大模型自由发挥,给它权限随意写点击选择器。 相信我,大模型一旦脱缰,可能会在页面上把“提交申请”、“下载全站附件”、“退出登录”甚至“切换英文版”全都给你点一遍,爬虫当场就能在网页里迷路跑偏。

我们需要的是一个受控的语义决策闭环

【受约束的交互契约】
1. 后端提取页面所有具备点击行为的控件,赋予确定性的短编号:[Action #12: 有机化学研究所]
2. 提示词严正声明意图:“请挑出能切换到互补人员名单的 Tab 控件编号”
3. LLM 仅负责做单选题:返回 {"target_actions": [12, 13, 14]}
4. 后端进行强安检拦截:
   - 编号必须在预检列表中;
   - 点击动作严格限定在原位 Tab 触发,超时与无效重定向直接丢弃;
   - 单次最大交互次数死死卡住(例如最多允许切 15 个 Tab),防死循环。
语义归模型,执行归沙箱

让模型拥有“做选择的眼睛”,但绝不给它“直接执行任意 JavaScript 的双手”。模型只给建议,程序校验安全后在受控的 Playwright 沙箱里执行确定性动作。

把“同页点击”抽象为“页面状态切片”

另一个核心难题是:点击 Tab 之后拿到的一堆新 DOM,在系统里算什么?

如果强行把它当成一个“新页面”,但它没有自己的 URL,缓存系统和去重系统会彻底紊乱。

最终的架构设计非常优雅:“状态切片(Stateful Snapshot)”

                   ┌──> [状态 0: 默认列表] ──> 抽 3 人

[同一个 URL 入口] ──┼──> 点击 Tab 1 ──> [状态 1: 无机化学] ──> 抽 45 人 ──> 合并去重 (共 197 人)

                   └──> 点击 Tab 2 ──> [状态 2: 分析化学] ──> 抽 38 人
  1. 原点锚定: Playwright 加载初始页面,先提取并保存基线状态的数据;
  2. 渐进式状态探索: 针对模型建议的每个 Tab,模拟真实用户的物理点击,智能等待网络请求完成与 DOM 变动稳定;
  3. 独立状态快照: 把每次点击后产生的局部 DOM 封装成独立的状态快照,直接复用既有的文本切块、正文识别与大模型信息提取管道;
  4. 统一汇聚与多维去重: 几组状态快照提取出的老师,在落地前通过导师姓名、个人主页 Canonical URL 以及工号进行全局去重。
状态隔离的韧性

这种设计具备极强的容错性:即使“物理化学”那个 Tab 因为前端后端接口 500 导致点击后加载失败,系统只会跳过该状态快照,绝对不会拖垮前面已经成功抓取的其他 Tab。

从 3 位到 197 位

改完这套逻辑后,我们重新把那个化学学院的网页扔进系统。

终端输出赏心悦目: 大模型一眼识破了顶部的 11 个学科分组按钮,Playwright 快速切换渲染出 11 个状态切片,原本仅有 3 人的名录,最终精准抓取并入库了 197 位导师

而且,由于我们对“Tab 点击”与“传统分页”做了清晰的行为解耦: 如果某个学科 Tab 点进去后,下面竟然还带有“第 2 页”,分页机制会在该状态快照内部无缝接力,完全不需要写任何特异化的适配逻辑。

总结

Web 技术演进到今天,爬虫的认知模型必须随之升级:

  1. 从“面向 URL 抓取”到“面向状态空间抓取”: 现代前端的单页应用里,URL 只是一个进入大门的门牌号,真实的数据往往散落在不同的交互状态切片中;
  2. 永远不要信任静态的 HTML Snapshot: 对于带动态交互的页面,必须在有真实渲染引擎(如 Chromium)的环境下,结合事件触发去探索完整的数据边界;
  3. 安全交互的核心是“有限词表”: 别让 AI 在网页里无拘无束地“自由冲浪”。将网页交互转化为有限的候选动作集合,让 AI 做选择题,系统才能既聪明又可控。

评论