Backend

URL 不变但内容会变:爬虫抓取动态分类名单的实践

1619 字约 6 分钟

摘要某化学学院的师资页面有两百多位老师,爬虫却只抓到 3 位。页面地址始终不变,名单内容通过 Tab 点击异步切换。本文介绍如何让大模型和无头浏览器配合,抓取同一 URL 下的全部名单。

在传统爬虫的开发习惯中,URL 就是页面的唯一标识。

抓取高校导师名录的常见流程很线性:输入学院目录 URL,找到所有分页链接(?page=2、?page=3),提取每位老师的个人主页 URL,然后逐个下载详情页。

只要把所有 URL 都找全,名录抓取就算完成了。

直到有位同学给我发来某知名大学化学学院的师资页面。

任务很快跑完,控制台输出:抓取成功,提取导师 3 人。

我打开页面看了一下:顶部有一排 Tab 按钮,“无机化学”“有机化学”“分析化学”“物理化学”“高分子”……默认选中的是“学院领导”,只有 3 位老师。而点开任意一个教研室,里面都有几十位教授。

但无论在这几个 Tab 之间怎么切换,浏览器地址栏始终没有变化,连 hash 锚点都没有。

为什么现代高校网站会这样设计?

随着 Vue、React 在政企和高校 CMS 中普及,前端常用组件化 Tab 加 Ajax 局部刷新来展示部门名单。这种设计对普通用户很友好,不需要整页刷新;但对以 URL 为中心的传统爬虫来说,却是一堵隐形墙。

爬虫看见了按钮,却不会点

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

为了让模型理解页面结构,后端在把 HTML 交给模型之前,会先做一次无损精简,给页面中的超链接、按钮和 Tab 选项逐个编号,例如 [Button #1: 分析化学]。

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

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

为什么传统爬虫对这种页面没有办法? 想象一个只认门牌号(URL)的快递员:

  • 普通网页像一排房间,每个房间有独立门牌(page=1、page=2),快递员进门就能取件;
  • 动态前端页面则是整栋楼只有一个房间,正中间放着一个旋转货架(Tab 按钮)。按一下红色按钮,货架转出“有机化学”的 50 个盒子;按一下蓝色按钮,转出“分析化学”的 50 个盒子。

如果快递员只知道“去下一个房间”,却不知道“按一下按钮”,那么他只能看到货架正对门口的 3 个默认盒子。

这就形成了一个矛盾:

  • 爬虫看见了“分析化学”这个按钮;
  • 模型也知道点开后能看到更多老师;
  • 但系统的通信协议中,根本没有“请点击这个控件”的字段。

于是爬虫只收下了默认选中的 3 位老师,然后结束了任务。

不写死规则,也不让模型随意点击

该怎么解决?

最直接的想法是打补丁:写一个正则,只要页面中出现“化学”“教研室”“研究所”之类的词,就用 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 页”,分页逻辑会在该状态快照内部继续处理,不需要为此编写额外的适配代码。

总结

随着网页技术的发展,爬虫的思路也需要相应调整:

  1. 从“按 URL 抓取”转向“按状态空间抓取”: 在现代单页应用中,URL 只是入口,真正的数据往往分布在不同的交互状态中;
  2. 不要只依赖静态 HTML 快照: 对于有动态交互的页面,应在真实的渲染环境(如 Chromium)中,通过触发事件来探索完整的数据范围;
  3. 安全交互的关键是限定选项: 不要让 AI 在网页中自由操作。把页面交互限定为有限的候选动作,让 AI 做选择题,系统才能既有效又可控。

评论