返回博客

SPA 爬虫 Selector 每周静默失效,让 LLM 凭空编造内容

我们搭了一条 Playwright 爬取某社交平台博主主页、再交给 LLM 做分析的流水线。第一天跑得很顺,LLM 输出的画像也准确。一周后用户反馈:明明是美妆护肤博主,LLM 却给出了"自律生活家"的人设标签。

去查爬虫日志,namefanstags 全是 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 静默返回 null,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 从这段原文推断的画像会准确得多。

正解:级联 selector + rawText 兜底,接受半结构化

[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 从真实内容里恢复,而不是从空值里幻觉。