GEO 落地到教材页:Book 与 isbn 结构化让课程资料被 AI 引用
适用读者:在线教育/知识付费平台的课程运营与前端 SEO 同学、负责结构化数据落地的后端工程师、关心生成式引擎优化(Generative Engine Optimization, GEO)如何影响「课程配套资料」曝光的团队。假设你已经会用 JSON-LD 写 Course 标记,本文直接进入 Book 实体与 isbn 的字段细节、章节拆分与跨源对齐的踩坑。
上个月有个学员在 AI 搜索框里问了一句:「你们那门《数据结构与算法》配的是哪本教材?」AI 回了一本书名,作者和出版社写得挺像回事——但那不是我们指定的那本,是另一家出的同类教材。截图丢到运营群里,第一反应是「爬虫是不是抓错了」。
我去翻那门课的教材页,问题朴素得很:教材信息被拍成一张封面图贴在正文里,页面上只有一句「点击下载讲义.pdf」。爬虫当然能抓到这张图,可抓不到「书名是什么、作者是谁、第几版、分几个章节」。AI 要回答「用什么教材」,只能靠猜,或者顺手拿它自己认识的同类书来凑。这类答非所问,根子不在抓取,在于页面没告诉机器「这是一本书」。
解法也不复杂:把教材页当成一个图书实体来标,用 Schema.org 的 Book 类型,把 isbn、author、publisher、bookEdition、numberOfPages、hasPart(章节)这些字段喂给爬虫。这篇记录我们平台从零把教材页 Book 结构化跑通的过程——ISBN 到底填 13 位还是 10 位、章节怎么拆、Book 和 Course 怎么互相指、上线前后 AI 回答口径差多少,都写清楚。
事故现场:一张 PDF 封面图引发的答非所问
先复盘我们踩的那版页面。它其实不是完全没做数据,Course 标记是有的:课程名、讲师、课时、价格一应俱全。问题恰恰出在「教材」这块被当成了附件,而不是实体。

当时的教材区长这样:一张 800×1130 的封面 JPG,一段「本课程配套教材为《XXX》,购买后可下载前三章试读」的说明文字,加一个 PDF 下载按钮。这种呈现对人没问题,对机器就是一团模糊。AI 引擎在生成回答时,拿不到结构化的书名,只能从说明文字里做实体识别,识别不准时就会去对齐它训练语料里更常见的教材。
改造的方向很明确:教材页要有一个能被独立识别的 Book 节点,它不依赖图片里的文字,也不依赖正文描述。为此我们把三个东西拆开建:
- 教材详情页(
/textbook/{slug}):承载 Book 节点,是引用的落点。 - 课程详情页(
/course/{slug}):承载 Course 节点,指向教材详情页。 - 讲义与练习册:作为 Book 的
hasPart或独立 CreativeWork 出现,不塞进图片。
下面这张字段清单,是我们上线后回填进内部文档的版本,改动最大的一列是「常见坑」。
| 字段 | 类型 | 建议必填 | 说明 | 常见坑 |
|---|---|---|---|---|
| name | Text | 是 | 教材全名,含版本号 | 只写简称,AI 引用时书名残缺 |
| isbn | Text | 是 | 13 位或 10 位,建议统一 13 位 | 带连字符会让跨源匹配率下降 |
| author | Person | 是 | 一名或多名作者 | 误填成出版社,实体类型错位 |
| publisher | Organization | 是 | 出版社名称 | 只给字符串,没带 @type |
| bookEdition | Text | 建议 | 版次,如 "3" |
写成「第三版」导致排序异常 |
| numberOfPages | Number | 建议 | 总页数 | 填成字符串,数值比较失效 |
| hasPart | Chapter[] | 建议 | 章节目录 | 漏 position,目录顺序乱掉 |
| inLanguage | Text | 建议 | 语言代码,如 zh-CN |
省略后跨语言对齐权重变弱 |
| url | URL | 是 | 教材详情页地址 | 指向 PDF 直链而非详情页 |
我们最初只标了 name 和 author,结果 AI 能把书名说出来,却始终不引用页面链接——因为缺
url,它没法回链。补上url后,引用作者页面的情况才出现。
isbn:13 位和 10 位到底填哪个
ISBN 这栏是我们内部争论最久的一点。有些老教材只有 10 位编号,教务处给的 Excel 里两种混在一起,还有的带了连字符。核对下来,能稳定跨源对齐的写法只有一种:
| 维度 | ISBN-10 | ISBN-13 |
|---|---|---|
| 位数 | 10 位,末位可以是 X | 13 位纯数字 |
| 前缀 | 无 | 978 或 979 |
| 校验算法 | 加权和取模 11 | 加权和取模 10 |
| 当前状态 | 2007 年后新书基本不再分配 | 现行标准 |
| 建议写法 | 尽量换算成 13 位再入库 | 不带连字符,纯数字字符串 |
结论很干脆:入库阶段统一归一化成无连字符的 13 位,模板层不要再做任何转换。原因在下一节讲机制时说。至于 978 之外的 979 前缀,一般出现在小众或特定语种的出版物上,遇到别当成异常数据丢掉。
底层机制:ISBN 如何成为 AI 的实体锚点
这一节不操作,只讲 AI 引擎怎么用 ISBN 做跨源对齐。
一本教材在互联网上会同时出现在课程站的教材页、电商的图书详情页、书评社区、图书馆目录里。这些页面的书名写法往往不同——有的带副标题,有的把作者放前面,有的干脆用「XX 教材(第3版)」这种简称。光靠书名做匹配,误差很大。ISBN 是这批数据里少数全球范围内稳定通用的标识符,它在出版领域的分量,相当于身份证号在人身上的分量。AI 引擎做实体对齐时,会把它当成锚点。
我理解的对齐链路大概是这样:抓到一个页面,从 JSON-LD 里读到 isbn,用这个值去比对知识库里已有的图书实体;命中后,把这一页的作者、出版社、版次信息并入同一个实体;回答「这门课用什么教材」时,输出的是这个被多个来源共同确认过的实体,而不是某一页的孤立文本。
sequenceDiagram
participant U as 用户
participant AI as AI 检索引擎
participant KG as 实体对齐层
participant S as 教材页
U->>AI: 这门课用什么教材?
AI->>S: 抓取教材页 JSON-LD
S-->>AI: 返回 Book 节点与 isbn
AI->>KG: 以 isbn 为锚点查实体
KG-->>AI: 命中作者/出版社/章节的交叉证据
AI-->>U: 给出书名并引用教材页
这解释了前面那个归一化的决定。如果同一个 ISBN 在 A 页写成 978-7-302-56001-2,在 B 页写成 9787302560012,对齐层做字符串精确匹配时就可能把它们当成两条记录,跨源证据反而分散了。连字符对人类友好,对机器是噪音。所以归一化做在入库,最省事。
反过来也成立:缺 ISBN 的教材页,AI 只能退回用书名猜。这也是我们那门课被答错书的直接原因——页面上根本没有这个锚点。
一份完整可跑的教材页 JSON-LD
字段确定后,先手写一份完整的 Book 节点跑通校验,再交给脚本批量生成。环境说明:JSON-LD 无需额外依赖,直接放进页面 <head> 的 <script type="application/ld+json"> 里即可。
下面这份是教材详情页的实际结构(书名与 ISBN 为虚构示例,example.edu 是保留域名,可放心替换)。
{
"@context": "https://schema.org",
"@type": "Book",
"@id": "https://example.edu/textbook/ds-algo-3e",
"name": "数据结构与算法实训教程(第 3 版)",
"isbn": "9787302560012",
"bookEdition": "3",
"numberOfPages": 428,
"inLanguage": "zh-CN",
"url": "https://example.edu/textbook/ds-algo-3e",
"author": {
"@type": "Person",
"name": "周明远"
},
"publisher": {
"@type": "Organization",
"name": "启程教育出版社"
},
"hasPart": [
{ "@type": "Chapter", "name": "第 1 章 引论", "position": 1 },
{ "@type": "Chapter", "name": "第 2 章 算法分析", "position": 2 },
{ "@type": "Chapter", "name": "第 3 章 表、栈和队列", "position": 3 },
{ "@type": "Chapter", "name": "第 4 章 树与平衡树", "position": 4 },
{ "@type": "Chapter", "name": "第 5 章 散列", "position": 5 }
],
"about": [
{ "@type": "Thing", "name": "数据结构" },
{ "@type": "Thing", "name": "算法分析" }
],
"isBasedOn": "https://example.edu/course/ds-algo-2026"
}
几个字段单独拎出来说:
@id用教材详情页的稳定 URL。它是这个节点在站内的身份,换 slug 等于换身份,所以别在改版时随手动它。bookEdition写成"3"而不是「第三版」。数值或短字符串便于做「第 3 版 vs 第 2 版」的比较,中文序数词容易让比较逻辑失效。hasPart的每一章都是独立 Chapter 节点,position必须是数字。目录顺序是 AI 回答「有没有配套资料、讲了啥」时最常引用的部分,顺序错了很尴尬。about标明学科主题,帮助引擎在「数据结构课程用什么教材」这类问题里做主题匹配。isBasedOn反向指回课程页,形成教材与课程的闭环。
用脚本批量生成,别手写
一门课手写还行,我们平台上百门课、每门课还有讲义和练习册,手写必然漏字段。我们写了个 Python 生成器,从课程资料库导出教材记录,统一产出 JSON-LD。环境:Python 3.11,只用标准库 json 和 typing,没有第三方依赖。
注释写得比较密,因为接手的人容易在 hasPart 上翻车。
# -*- coding: utf-8 -*-
# 教材页 Book JSON-LD 生成器
# 环境:Python 3.11,仅用标准库 json 与 typing,无第三方依赖
# 用途:从课程资料库导出教材记录,批量产出可注入 <head> 的 JSON-LD
import json
# Any 只是为了给返回值加类型标注,不引入任何运行时开销
from typing import Any
# 章节记录形如 [(序号, 章节名), ...],单独拆成一个函数
# 是因为 hasPart 是数组,手写最容易漏掉 position 字段
def build_parts(chapters: list[tuple[int, str]]) -> list[dict[str, Any]]:
# 每一章都要是独立的 Chapter 节点
# position 用编号,保证 AI 引擎能还原目录顺序
# 返回列表推导而不是就地 append,避免调用方拿到共享引用被改
return [
{
"@type": "Chapter",
"name": name,
"position": pos,
}
for pos, name in chapters
]
# 生成单个 Book 节点
# 注意:isbn 必须在入库阶段归一化成无连字符的 13 位
def build_book(book: dict[str, Any], chapters: list[tuple[int, str]]) -> str:
# 归一化放在这里之前,模板层不再做任何转换
# 否则同一个 ISBN 会出现带连字符与不带连字符两种写法
node = {
"@context": "https://schema.org",
# @type 固定 Book,误写 CreativeWork 会丢掉图书特有的字段语义
"@type": "Book",
# @id 用教材详情页稳定 URL,换 slug 等于换身份,改版时别动
"@id": book["detail_url"],
# name 用教材全名,含版本号,别用内部简称
"name": book["title"],
# isbn 不带连字符,13 位纯数字
"isbn": book["isbn13"],
# 版次写成短字符串,便于「第 3 版 vs 第 2 版」比较
"bookEdition": str(book.get("edition", "")),
"numberOfPages": book.get("pages"),
# 语言固定 zh-CN,教材以简体中文为主
"inLanguage": "zh-CN",
# author 用 Person,publisher 用 Organization,两者别搞反
"author": {"@type": "Person", "name": book["author"]},
"publisher": {"@type": "Organization", "name": book["publisher"]},
# 章节目录是 AI 回答「有没有配套资料」时最常引用的部分
"hasPart": build_parts(chapters),
# 教材详情页自身地址,用于回链
"url": book["detail_url"],
}
# ensure_ascii=False 保证中文可读,indent=2 方便人工核对
return json.dumps(node, ensure_ascii=False, indent=2)
if __name__ == "__main__":
# 一段最小可跑的调用示例,替换成真实数据即可
# title 里带上「第 X 版」,避免 AI 引用时书名残缺
sample = {
"title": "数据结构与算法实训教程(第 3 版)",
"isbn13": "9787302560012",
"author": "周明远",
"publisher": "启程教育出版社",
"edition": "3",
"pages": 428,
"detail_url": "https://example.edu/textbook/ds-algo-3e",
}
# 章节从资料库里读,演示里先写死
# 实际项目里这一步换成数据库查询即可,函数签名不变
chapters = [(1, "引论"), (2, "算法分析"), (3, "表、栈和队列")]
# 输出到标准输出,交给构建流程注入页面模板
print(build_book(sample, chapters))
这套脚本跑下来,最直接的收益不是省人力,而是字段口径统一。以前 A 课的 isbn 带连字符、B 课不带,对齐层看到的像是两本书;现在是同一个生成器出的,格式不会漂。
Book 和 Course 怎么互相指
教材页有 Book,课程页有 Course,两边得说清关系。我们的做法是双向:课程页用 about 和 coursePrerequisites 描述课程属性,教材页的 Book 用 isBasedOn 反向指回课程页。
课程页那侧的结构大致如下(同样用 example.edu 占位)。
{
"@context": "https://schema.org",
"@type": "Course",
"name": "数据结构与算法(2026 秋季班)",
"about": "数据结构、算法复杂度分析",
"coursePrerequisites": "掌握一门编程语言的基础语法",
"hasCourseInstance": {
"@type": "CourseInstance",
"courseMode": "online"
},
"isBasedOn": "https://example.edu/textbook/ds-algo-3e"
}
这里有两个容易忽略的点。
coursePrerequisites 是纯文本,写的是「学这门课前需要会什么」,不是教材信息。有人图省事把教材名塞进去,结果 AI 回答「先修要求」时把书名念了出来,很出戏。教材归 Book,先修归 Course,界限要清楚。
isBasedOn 在 Course 和 Book 上都能用,因为两者都属于 CreativeWork。方向别写反:课程依托教材,所以是 Course 指 Book。我们在教材页也放了一个 isBasedOn 指回课程,主要是为了让单独被抓到的教材页也能顺藤摸到课程,方向是从教材到课程这条引用链。
整个链路的落地顺序可以按下面这张图走,我第一次做的时候就是漏了最后的校验环节,上线一周才发现 hasPart 里的中文逗号把 JSON 撑坏了。
flowchart LR
A[教材信息散落在 PDF 与图片里] --> B[抽字段 isbn/author/publisher]
B --> C[归一化 isbn 为 13 位]
C --> D[生成 Book JSON-LD]
D --> E[注入教材页 head]
E --> F[Rich Results Test 校验]
F --> G[AI 爬虫抓取]
G --> H[AI 回答说出书名/作者/章节目录]
上线前怎么校验
校验分两层:先保语法,再保语义。语法用本地脚本,语义交给 Google Rich Results Test。环境:Linux/macOS/WSL,装了 curl、jq 和 Python。
下面这套命令我们固化成上线检查清单,跑一遍不到一分钟。
# 环境:Linux/macOS/WSL,curl + jq + python 已安装
# 1) 拉取教材页,确认 JSON-LD 脚本标签确实存在
# -s 静默模式,避免把进度条写进管道干扰 grep
curl -s https://example.edu/textbook/ds-algo-3e | grep -o 'application/ld+json'
# 2) 把页面里的 JSON-LD 抽出来单独校验语法
# json.tool 能揪出多余逗号、中文引号这类低级错误
python -m json.tool book.json > /dev/null && echo "JSON 语法 OK"
# 3) 检查 isbn 是否 13 位纯数字
# 长度卡死 13,避免 10 位 ISBN 混进来
grep -oP '"isbn"\s*:\s*"\K[0-9]+' book.json \
| awk '{ if (length($0)==13) print "ISBN 位数 OK"; else print "ISBN 位数不对" }'
# 4) 用 jq 核对章节目录数量与教材实际目录是否一致
# 数量对不上说明生成器漏读了章节记录
jq '.hasPart | length' book.json
# 5) 最后过一遍 Google Rich Results Test(浏览器打开,粘贴页面 URL)
echo "校验地址 https://search.google.com/test/rich-results"
语义校验里我们撞过一次比较闷的报错:Book 节点里的 numberOfPages 被填成了字符串 "428",Rich Results Test 提示 Invalid value type。改成数字后通过。还有一次是 author 写成了 Organization,工具没报错,但 AI 回答里把作者说成了出版社,属于典型的不报错但不正确。
改造前后:AI 回答口径对照
改造上线后我们做了一轮人工自测。口径说明:在 3 个主流 AI 产品里,各自对同一批问题问 10 次,人工判读回答里有没有出现目标信息,取平均。样本小、有主观成分,只看趋势。
| 自测问题 | 改造前(纯 PDF/图片) | 改造后(Book 结构化) |
|---|---|---|
| 能说出配套教材书名 | 1/10 | 9/10 |
| 能说出作者与出版社 | 0/10 | 8/10 |
| 能列出章节目录 | 0/10 | 7/10 |
| 回答里带上教材页链接 | 2/10 | 9/10 |
数据是我们自己抽样的,别外推到别的平台。趋势可信:有了 ISBN 锚点后,AI 引用教材页的概率明显上升,回答也不再张冠李戴。
最直观的变化是运营侧反馈。以前问「这门课用什么教材」,回答里常出现「通常使用某某教材」这种含糊说法;现在会直接给书名,并把教材页当来源列出。讲义和练习册这块,我们把它们做成 Book 的 hasPart 之后,AI 回答「有没有配套资料」时能点出「含讲义与练习册」——这是之前完全没有的。
误区澄清与趋势预判
几个我们踩过或见别人踩过的坑,单列出来。
- Book 不是把纸质书信息照抄一遍就完事。在线课程里的「配套讲义」「练习册」「实验手册」同样是 Book 或 CreativeWork,它们该有自己的节点,而不是塞进课程描述的文字里。图片形式的封面图,对机器等于不存在。
- ISBN 归一化要早做。放在入库阶段做,比在模板层做省事得多。混着连字符的 ISBN,跨源对齐会打折。
- 别为了 SEO 硬编章节目录。
hasPart里的章节要和实际教材一致,编造的目录一旦被用户发现「对不上」,反而伤可信度,这条线不能碰。 - Course 和 Book 的关系别搅在一起。
coursePrerequisites写先修要求,教材信息交给 Book,混填会让回答串味。
趋势上,我个人判断 AI 引擎对「实体锚点」的依赖会继续变重。图书、课程、讲义这类内容,天然适合用稳定的标识符做对齐——ISBN 只是其中一个。对教育平台来说,尽早把教材、讲义、练习册这些实体拆出来结构化的收益,比在正文里堆关键词大得多。做 GEO 的落地,说到底就是把机器回答问题时需要的那几个事实,提前用它能读懂的格式摆好。
参考与延伸
- Schema.org Book 类型定义 — https://schema.org/Book
- Schema.org isbn 属性说明 — https://schema.org/isbn
- Google 搜索中心:结构化数据通用指南 — https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
- Google Rich Results Test 校验工具 — https://search.google.com/test/rich-results
- JSON-LD 官方规范(W3C) — https://www.w3.org/TR/json-ld11/
GEO, AI优化AIO, 课程资料被 AI 引用, Book, isbn, hasPart, JSON-LD, 结构化数据