SPA 爬虫 Selector 每周静默失效,让 LLM 凭空编造内容
我们搭了一条 Playwright 爬取某社交平台博主主页、再交给 LLM 做分析的流水线。第一天跑得很顺,LLM 输出的画像也准确。一周后用户反馈:明明是美妆护肤博主,LLM 却给出了"自律生活家"的人设标签。
去查爬虫日志,name、fans、tags 全是 null。但 Playwright 没有抛任何错误。
现象
page.$('.user-name')返回null,上周同一个 selector 还正常工作- 爬取结果对象结构完整,只是大部分字段值为
null或空数组 - LLM 下游收到满是
null的输入,却没有报错,反而输出了一段"听起来合理"的虚构画像 - 没有任何报警触发,整条流水线表面上运行正常
根因
现代 SPA(React/Vue/Vite 全家桶)在构建时会给 CSS Module 类名加 hash 后缀,例如 .user-name 在下一个构建产物里会变成 .user-name__a8f3。平台每次发版上线,前端 bundle hash 随之刷新,你写死的 selector 就静默失效了。
问题在于 document.querySelector('.user-name') 找不到元素时只是返回 null,不会抛异常。爬虫代码照常执行,返回一个"看起来合格"的空对象,流水线日志全绿。
LLM 收到 {name: null, fans: null, tags: []} 时,它不知道这是爬虫故障,而是把"没有信息"当成"信息缺失"来补全——直接从训练数据里检索相关领域常见人设,填进输出里。这是典型的 LLM 幻觉场景,而且很难被发现,因为输出依然像模像样。
精准 selector 在 SPA 上有保质期。平台发版后 class hash 变化,querySelector 安静地返回 null,没有异常,没有告警。下游 LLM 接到空字段不会拒绝执行,而是用训练集先验知识"补全"内容——输出结果听起来合理,但完全是编的。
正解
不要押注单一 selector,而是为每个字段设计级联兜底链,最末尾兜一份 rawText(页面可见文本的原始切片),让 LLM 在结构化字段全部失败时仍能从纯文本里恢复真实信号。
// 级联兜底:稳定属性 → 语义类名模糊匹配 → 标题推断 → 文本正则
let name = pickText(document.querySelector('[data-user-name]'))
// [class*="user-name"] 可以命中 .user-name__a8f3 这类带 hash 的变体
if (!name) name = pickText(document.querySelector('[class*="user-name"]'))
if (!name) name = pickText(document.querySelector('h1, h2'))
if (!name) {
// 某些平台会把用户ID以固定文案格式渲染在页面上
const m = document.body.textContent?.match(/号[::]\s*(\w+)/)
if (m) name = m[1]
}
// 最终兜底:原始文本切片,给 LLM 留真实信号
const rawText = (document.body.textContent || '')
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.slice(0, 200)
.join('\n')
return { name, fans, tags, desc, rawText }在向 LLM 组装 prompt 时,把 rawText 作为最后一段附上:
function buildPrompt(profile) {
const lines = [
`名称: ${profile.name || '(未识别)'}`,
profile.fans && `粉丝: ${profile.fans}`,
profile.tags?.length && `标签: ${profile.tags.join(' · ')}`,
profile.desc && `简介:\n${profile.desc}`,
profile.rawText && `\n# 页面原文片段(结构化提取失败时的兜底)\n${profile.rawText.slice(0, 1500)}`,
].filter(Boolean)
return lines.join('\n')
}即使所有结构化字段明天全部失效,rawText 里仍然会包含博主自己写的简介、内容标签等文本——LLM 从这段原文推断的画像会准确得多。
用 [class*="keyword"] 替代精准类名匹配,可以免疫 CSS Module hash 后缀。同时在末尾追加 document.body.textContent 的前 200 行切片作为 rawText,即使结构化提取全部归零,LLM 仍能从原文里读取真实信号,而不是凭空捏造。
排查
验证方式很简单:在 DevTools 里把目标元素的 class 手动改成 .foo,然后跑一遍爬虫——name 应该变成 null,但 rawText 里应该还能找到那个名字的字符串。再把 rawText 单独喂给 LLM,确认输出画像是否仍然准确。
另一个反模式要注意:不要用 DOM selector 判断登录态(例如检测用户卡片是否存在)。SPA 在未登录状态下也会渲染推荐用户卡片,a[href*="/user/profile/"] 会产生假阳性。登录检测应该查 cookie(web_session 之类),而不是查 DOM 结构。
一句话外卖
SPA 的 class 名有保质期;给每个字段做级联兜底,最末一层永远是原始文本,让 LLM 从真实内容里恢复,而不是从空值里幻觉。