网页网址一直没变但内容变了,爬虫抓取动态分类名单的实践
在传统爬虫开发者的肌肉记忆里,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 人- 原点锚定: Playwright 加载初始页面,先提取并保存基线状态的数据;
- 渐进式状态探索: 针对模型建议的每个 Tab,模拟真实用户的物理点击,智能等待网络请求完成与 DOM 变动稳定;
- 独立状态快照: 把每次点击后产生的局部 DOM 封装成独立的状态快照,直接复用既有的文本切块、正文识别与大模型信息提取管道;
- 统一汇聚与多维去重: 几组状态快照提取出的老师,在落地前通过导师姓名、个人主页 Canonical URL 以及工号进行全局去重。
状态隔离的韧性这种设计具备极强的容错性:即使“物理化学”那个 Tab 因为前端后端接口 500 导致点击后加载失败,系统只会跳过该状态快照,绝对不会拖垮前面已经成功抓取的其他 Tab。
从 3 位到 197 位
改完这套逻辑后,我们重新把那个化学学院的网页扔进系统。
终端输出赏心悦目: 大模型一眼识破了顶部的 11 个学科分组按钮,Playwright 快速切换渲染出 11 个状态切片,原本仅有 3 人的名录,最终精准抓取并入库了 197 位导师!
而且,由于我们对“Tab 点击”与“传统分页”做了清晰的行为解耦: 如果某个学科 Tab 点进去后,下面竟然还带有“第 2 页”,分页机制会在该状态快照内部无缝接力,完全不需要写任何特异化的适配逻辑。
总结
Web 技术演进到今天,爬虫的认知模型必须随之升级:
- 从“面向 URL 抓取”到“面向状态空间抓取”: 现代前端的单页应用里,URL 只是一个进入大门的门牌号,真实的数据往往散落在不同的交互状态切片中;
- 永远不要信任静态的 HTML Snapshot: 对于带动态交互的页面,必须在有真实渲染引擎(如 Chromium)的环境下,结合事件触发去探索完整的数据边界;
- 安全交互的核心是“有限词表”: 别让 AI 在网页里无拘无束地“自由冲浪”。将网页交互转化为有限的候选动作集合,让 AI 做选择题,系统才能既聪明又可控。