刷题平台被 AI 搜索无视了两个月:Quiz 与 QuizQuestion 的错题解析结构化缺位复盘

2026-09-26 01:16:09 1 次浏览
GEOAI搜索Schema.orgJSON-LD在线教育

适用读者:做在线教育、知识付费站点的前后端工程师;负责内容被 AI 搜索收录的技术负责人;正在纠结该给题目页套哪种 Schema.org 类型的 SEO 同学。

一个 1.2 万道题的编程刷题平台,两个月里被 AI 搜索引用的次数是 0。不是内容不行,题目解析写得挺扎实,用户反馈也一直不错。问题出在交付方式上:题干和解析全藏在前端 JS 交互里,用户点「查看解析」按钮才发请求渲染内容,AI 爬虫抓到的页面只有一层题目列表骨架。这篇文章把整个排查过程、结构化改造方案,以及 Quiz 和 FAQPage 之间的取舍掰开讲一遍。

先交代背景。这个站的核心页面结构是列表页加详情页,详情页走 React SPA,数据从接口异步拉。为了页面首屏快,团队把解析部分做成了懒加载,用户不点就不请求。这个决策本身对用户体验没毛病,坏就坏在它把内容对爬虫藏起来了,而且一藏就是两个月没人发现。

问题是怎么从访问日志里翻出来的

发现的契机很偶然。运营同学在某个 AI 助手里搜「JS 数组去重的几种写法」,答案推荐了竞品站,我们站的题目明明排得更靠前。她把截图丢进群里,我顺手查了下访问日志。

题库选项卡与解析答案卡

日志里的情况比想象中难看。用下面这条命令把常见 AI 爬虫的轨迹筛出来:

# 从 Nginx 访问日志里筛出主流 AI 爬虫的请求轨迹
grep -E "(GPTBot|ClaudeBot|PerplexityBot|Google-Extended)" access.log | awk '{print $7}' | sort | uniq -c | sort -rn

# 再看这些请求最终拿到的状态码,确认有没有被误拦
grep -E "(GPTBot|ClaudeBot|PerplexityBot)" access.log | awk '{print $9}' | sort | uniq -c

跑出来的结果:两个月里 AI 爬虫一共只请求过 3 个静态页,两个是关于订阅说明的,一个是首页。1.2 万道题的详情页,一次都没被碰过。状态码全是 200,robots.txt 也没拦,说明不是封禁问题,是爬虫压根不知道这些页面值得来抓,或者抓了也拿不到东西。

这里有个很容易被忽略的点:AI 爬虫和传统搜索引擎爬虫的耐心不一样。Googlebot 会执行 JS 渲染队列排几天也认了,多数 AI 爬虫抓一次拿到什么就是什么,懒得等你异步接口。所以排查不能只看 SEO 工具里的收录数,得直接看日志。

DOM 快照对比:AI 爬虫眼里的页面长什么样

光看日志还不够,我用无头浏览器把 JS 禁掉,模拟了一个「不执行脚本」的抓取,把详情页的 DOM 快照存了下来。前后对比放在一张表里:

对比项 JS 禁用后的快照 真实浏览器渲染后
题干文本 存在,但嵌在初始 state 里 完整可见
答案解析 完全不存在 点击「查看解析」后出现
结构化数据 无任何 JSON-LD 同样没有
可提取的语义信息 仅导航和列表骨架 题目 + 代码示例 + 步骤说明

结论很直白:对一个不执行 JS 的爬虫来说,这道题的解析等于不存在。而解析恰恰是这类站最有引用价值的内容——AI 引擎在回答「怎么实现 XX」时,引用的就是解析里的思路和代码。

抓取与渲染机制剖析

把这件事的机制拆开看。AI 搜索引用内容的链路大致是:抓取 → 抽取正文 → 切块向量化 → 检索命中 → 生成答案时带引用。这条链路里每一步都依赖 HTML 里直接可读的文本。这就是生成式引擎优化(Generative Engine Optimization, GEO)和传统 SEO 分岔的地方:传统 SEO 至少还有渲染队列兜底,GEO 这条链路里 JS 懒加载的内容基本等于黑洞。

另一个机制层面的缺口是结构化数据。页面当时连一份 JSON-LD 都没有,爬虫只能靠启发式规则猜哪块是题目、哪块是解析。Schema.org 里为教育内容准备的类型其实早就有了:Quiz 用来声明整卷或整题集,QuizQuestion 用来声明单道题,单题里的 acceptedAnswer 放标准答案,suggestedAnswer 放可接受的备选答案,解析文本则可以直接放在 acceptedAnswer.text 里。这几个属性组合起来,等于把「这是一道题、答案是什么、解析说了什么」用机器可读的方式拍在桌面上。改造后的内容暴露路径变成了这样:

flowchart LR
    A[AI 爬虫请求详情页] --> B[SSR 输出完整 HTML]
    B --> C[head 内 JSON-LD: Quiz + hasPart]
    B --> D[正文直接包含解析文本]
    C --> E[acceptedAnswer.text 中的解析]
    D --> F[可读正文切块向量化]
    E --> G[进入 AI 检索索引]
    F --> G

改造前的链路则断在第二步——爬虫拿到骨架,后面全部无从谈起。整个排查阶段我们自己走了一遍完整的取证流程,从日志异常到方案落地大约花了三天,节奏是这样的:

flowchart TD
    A[运营反馈 AI 引用为零] --> B[筛访问日志: AI 爬虫轨迹]
    B --> C[仅 3 个静态页被请求]
    C --> D[无头浏览器禁 JS 抓 DOM 快照]
    D --> E[确认解析文本不在 HTML 中]
    E --> F[选型 Quiz + QuizQuestion]
    F --> G[SSR 注入 JSON-LD 与解析正文]
    G --> H[validator 校验后灰度上线]

每一步都留了截图和日志快照,后面写改造复盘报告时直接取用,省了二次取证的时间。

结构化方案:Quiz 与 QuizQuestion 怎么组合

定了方向之后是选型。教育类内容可选的类型不少,我们最终落在 Quiz + QuizQuestion 的组合上:详情页顶层声明一个 Quiz,hasPart 数组里挂这道题的 QuizQuestion,答案和解析塞进 acceptedAnswer。核心的 JSON-LD 长这样:

{
  "@context": "https://schema.org",
  "@type": "Quiz",
  "name": "JS 数组去重实现题集",
  // learningResourceType 明确告诉引擎这是测验类学习资源
  "learningResourceType": "Quiz",
  "educationalLevel": "Beginner",
  // hasPart 把题集拆成单题,每道题一个 QuizQuestion
  "hasPart": {
    "@type": "QuizQuestion",
    "name": "第 1 题:用 Set 实现 ES6 数组去重",
    // educationalUse 标注这是考核性质的交互
    "educationalUse": "assessment",
    // acceptedAnswer 是标准答案,解析文本直接放这里
    "acceptedAnswer": {
      "@type": "Answer",
      "text": "使用 new Set(arr) 去重后,用展开运算符还原为数组。Set 内部使用 SameValueZero 算法判等,时间复杂度为 O(n)。"
    },
    // suggestedAnswer 放可接受的备选写法,比如手写 filter 实现
    "suggestedAnswer": {
      "@type": "Answer",
      "text": "arr.filter((v, i) => arr.indexOf(v) === i) 同样可行,但时间复杂度为 O(n²),大数组不推荐。"
    }
  }
}

配套的 SSR 改造不大,因为数据本来就在服务端接口里,只是原来留给前端渲染,现在提前拼进 HTML。拿 Node 侧举例:

// 服务端渲染详情页时,把题目与解析一并注入 HTML
app.get("/quiz/:id", async (req, res) => {
  // 从题库服务取单题详情,含题干、标准答案、解析与备选答案
  const quiz = await quizService.findById(req.params.id);

  // 组装 JSON-LD,解析文本同时写入 acceptedAnswer.text
  const jsonLd = buildQuizJsonLd(quiz);

  // 正文区也要输出解析文本,不能只塞进 JSON-LD
  // AI 爬虫抽取正文时依赖可见文本,两者要同时具备
  res.render("quiz-detail", { quiz, jsonLd });
});

上线前用 validator.schema.org 把每类页面模板都验了一遍,顺手发现两个坑:QuizQuestion 必须挂在 Quiz 或 LearningResource 上下文里单独放容易被判无效;acceptedAnswer 和 suggestedAnswer 都要求是 Answer 类型,直接塞字符串在部分校验路径下会报错。

和 FAQPage 的取舍:我们为什么没用 FAQ

选型时内部争论过一阵,FAQPage 是更常见的方案,改造模板也现成。但比完之后我们放弃了,对比放在这里:

维度 Quiz + QuizQuestion FAQPage
语义匹配度 天然对应「题目—答案—解析」结构 只表达「问题—回答」
备选答案 suggestedAnswer 原生支持 无对应属性
教育场景标注 learningResourceType / educationalUse 无
富媒体展示 部分引擎支持练习题卡片 FAQ 手风琴结果
AI 引用倾向 答案边界清晰,便于切块引用 整段回答粒度偏粗

对刷题站来说,一道题的价值密度集中在解析上,QuizQuestion 的 acceptedAnswer 恰好给了这个文本一个明确的语义槽位,AI 引擎切块的时候不会把题干和解析搅在一起。FAQPage 不是不能用,而是它表达不了「这道题还有别的可接受解法」这件事,而备选解法恰恰是编程题内容里引用率最高的部分。

改造完成后第 5 周,日志里的数字变成了这样:AI 爬虫日均请求详情页 600 多次,AI 搜索带引用的曝光从 0 涨到日均 40+ 次,引用来源集中在解析文本和备选解法两块。流量占比不大,但都是高意图的长尾问题,注册转化率比搜索来的均值高了一截。

几个踩坑细节,值得单独记一笔

改 HTML 结构的时候踩过一次回马枪。为了让正文更干净,我们把解析区的 DOM 从弹窗挪进了文档流,结果弹窗组件的关闭逻辑还挂在旧节点上,移动端点「查看解析」会白屏。灰度时靠前端监控的错误聚合抓到的,原因很朴素:SSR 改造动了组件挂载点,交互逻辑没跟着走。所以这类改造一定要把「渲染层」和「交互层」当成两个独立改动分开灰度。

另一个坑是解析文本的长度。早期我们把完整解析(含三段代码示例)全塞进 acceptedAnswer.text,部分 AI 引擎抽取时会截断长文本,引用出来的片段恰好断在代码中间,观感很差。后来把代码示例留在正文 HTML 里,text 只放思路概述,两三百字以内,引用质量立刻稳定了。

还有个小细节:hasPart 挂多题时注意 position 属性,题集类页面 AI 引擎偶尔会按顺序引用多题,没有 position 就会乱序。

误区澄清或趋势预判

常见误区有两个。一个是把结构化数据当成万能票,以为塞了 JSON-LD 内容就能被引用——acceptedAnswer.text 和可见正文缺一不可,很多引擎会交叉校验,只写结构化数据的页面反而有被判低质的风险。另一个是等搜索引擎渲染队列,传统搜索或许等得起,GEO 这条链路的抓取器普遍不执行 JS,懒加载内容在它们眼里就是不存在。

往后看,教育类内容在 AI 搜索里的竞争会越来越吃「结构清晰度」的红利。题目、答案、解析、难度、知识点这些字段越是机器可读,越容易被组装进 AI 的回答里。如果你手里也有内容躺在交互组件后面,先去翻一遍访问日志,看 AI 爬虫到底来过没有、拿走了什么——数据会告诉你该不该动手。

参考与延伸

GEO、AI搜索、Schema.org、JSON-LD、Quiz、QuizQuestion、在线教育

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。