音频课在 AI 引擎里没有目录:PodcastEpisode 与 transcript 的规范解读
适用读者:知识付费、在线教育平台做后端和搜索增长的工程师;课程页已挂 Schema.org 标记,音频详情页却在生成式引擎优化(Generative Engine Optimization, GEO)里始终拿不到引用位。做存量音频批量转写的同学可直接用上后半段的工程部分。
一档 120 集的职场沟通课,每集 28 分钟,详情页主体是一个 <audio> 播放器加三行简介。
有人在 AI 里问"坏消息怎么向上汇报",答案引了三篇图文博客;我们第 47 集整集都在讲这个,一次都没出现。
后台停留时长 22 分钟,看着不差。AI 引擎那一侧,这一集等于没上线。
播放器是个黑盒,AI 引擎不进去听
抓下来的是 HTML,不是声波

抓取侧拿到的是 DOM 树和那几行 JSON-LD。音频文件在 CDN 上是一段几十 MB 的二进制流,抓取链路不会下载它,更不会跑一遍语音识别。你在播放器交互、波形图上花的心思,AI 引擎全感知不到,它看到的只有 <audio src> 这个标签和旁边的文字。
接入 GEO 之前我们做过一次自查:把 1286 个音频详情页用无渲染方式抓一遍,去掉脚本和样式,正文纯文本中位数 68 个字,还包括"课程介绍""点击播放"这类噪音。音频页在 AI 搜索语境下的信息量,等于它的简介字数,这个换算很残酷但很准。
没有目录层,单集之间互相不认识
比文本缺失更麻烦的是结构缺失。当时的课程页只挂了 Course,几百集音频是它下面一串 URL,页面之间没有任何"这是同一系列第 N 集"的声明。对引擎来说,第 47 集和第 48 集是两个孤立文档,它没法把"向上汇报"这个主题的多集内容聚成可枚举的集合。而 AI 回答倾向于引用有明确边界、可逐条列举的资源,散落单页引了之后用户追问"还有吗"它答不上来。没有目录,就没有可引用的条目粒度。
核心字段清单:把一集讲清楚要哪些字段
PodcastEpisode 这套类型原本给播客设计,音频课可以直接借用。下表按"缺了会不会出问题"排序。
| 字段 | 位置 | 作用 | 我们踩的坑 |
|---|---|---|---|
partOfSeries |
Episode → PodcastSeries | 声明归属,把散页聚成集合 | 早期只写 isPartOf 指向 Course,引擎不认 |
episodeNumber |
PodcastEpisode | 集序号,让条目可枚举 | 用自增 ID 当集号,出现跳号和重复 |
associatedMedia |
Episode → AudioObject | 绑定音频本体 | 只写 audio 未展开,duration 丢了 |
duration |
AudioObject | ISO 8601 时长,引擎据此判断信息密度 | 秒数没转格式,整段被判无效 |
transcript |
PodcastEpisode | 转写全文,引用单元的主要来源 | 一开始塞进 description,被截断到 300 字 |
datePublished |
PodcastEpisode | 时效性判断 | 误用更新时间,旧内容被当新内容 |
partOfSeries 和 episodeNumber 是结构层的地基,associatedMedia 负责把"这集有个音频"和"这集讲了什么"绑进同一条数据。集号后来改成运营显式填写,不再用自增 ID——课程做过内容重排,自增 ID 表达不了这种变化,而集号是给用户看的"第几集"。
Schema 嵌套结构长什么样
字段不是平铺的,是有层级的。用一张结构图说明三者的关系,以及 transcript 挂在哪个节点上。
graph TD
A["PodcastSeries 课程系列"] --> B["PodcastEpisode 第 N 集"]
A --> C["PodcastEpisode 第 N+1 集"]
B --> B1["name 单集标题"]
B --> B2["episodeNumber 集序号"]
B --> B3["datePublished 发布日期"]
B --> B4["associatedMedia 关联媒体"]
B --> B5["transcript 转写全文"]
B4 --> D["AudioObject 音频对象"]
D --> D1["contentUrl 音频直链"]
D --> D2["duration ISO8601 时长"]
D --> D3["encodingFormat 编码格式"]
B5 --> E["页面可见折叠区 正文锚点"]
B -.->|partOfSeries 反向指回| A
transcript 必须挂在单集上,不是系列,也不是 AudioObject。挂错位置的后果是引擎拿到几万字转写却定位不到属于哪一集,最后整段丢弃。同理 duration 挂在 AudioObject 上,挂在 Episode 上无效。
批量转写这条工程线怎么搭
120 集存量音频人工转写不现实。我们用 faster-whisper,一张消费级显卡跑,28 分钟音频约 90 秒转完,实时率 18 倍左右。
flowchart LR
S["音频源文件 mp3/m4a"] --> V["VAD 静音切分"]
V --> W["faster-whisper 转写"]
W --> P["标点与口语词清洗"]
P --> T["术语表校正"]
T --> X["按语义切小节"]
X --> Y["写回数据库 transcript 字段"]
Y --> Z["渲染到页面折叠区"]
Y --> J["生成 JSON-LD 结构化数据"]
Z --> K["带 id 的锚点小节"]
J --> L["PodcastEpisode transcript"]
切分比转写本身更影响最终结果。我们试过按固定 30 秒一刀切,大量小节被拦腰截断。改成按标点加停顿做语义切分后,小节落在 220 到 480 字,citation 命中率明显高出一截。
# -*- coding: utf-8 -*-
# 依赖:faster-whisper==1.0.3(底层 CTranslate2==4.4.0)
# 依赖:ffmpeg 6.1 需在 PATH 中,用于解码与重采样
# 用途:批量转写存量音频课,输出带小节划分的 transcript
import json
import re
from pathlib import Path
from faster_whisper import WhisperModel
# model_size 用 large-v3;显存吃紧可退到 medium,准确率约降 3 个点
# compute_type 用 int8 量化,显存占用减半,实测对中文影响很小
model = WhisperModel("large-v3", device="cuda", compute_type="int8")
# 术语表:职场课里高频且易转错的词,转写后统一替换回标准写法
# 不做这一步,实体识别会把"非暴力沟通"切成一堆无意义碎片
GLOSSARY = {
"非暴力沟通": ["非暴力沟通", "非爆力沟通", "非暴力勾通"],
"STAR 法则": ["STAR 法则", "star 法则", "四大法则"],
"向上管理": ["向上管理", "向上关理"],
}
# 口语填充词,清洗阶段直接去掉
FILLERS = r"(嗯+|啊+|那个|就是说|然后呢|对吧)"
def transcribe(audio_path):
# vad_filter 打开后自动跳过静音段,长音频能省下约 15% 的时间
# word_timestamps 保留词级时间,用于后续生成页面锚点
# beam_size 从默认 5 起,调到 8 收益不明显但耗时翻倍
segments, info = model.transcribe(
str(audio_path),
language="zh",
vad_filter=True,
beam_size=5,
word_timestamps=True,
)
raw = []
for seg in segments:
# seg.start / seg.end 为秒级浮点,页面锚点用整数秒即可
raw.append({"start": int(seg.start), "end": int(seg.end), "text": seg.text})
return raw, info.duration
def clean(raw):
out = []
for item in raw:
text = item["text"].strip()
# 去掉口语填充词
text = re.sub(FILLERS, "", text)
# 术语表校正:把 ASR 错字映射回标准词
for standard, variants in GLOSSARY.items():
for v in variants:
text = text.replace(v, standard)
# 去句首残留标点,这类残留会让小节边界判断失准
text = re.sub(r"^[,,。、\s]+", "", text)
if len(text) >= 2:
out.append({**item, "text": text})
return out
def to_sections(items, max_chars=420):
# 按标点做语义切分,目标单节 220-480 字
# max_chars=420 是实测下来的甜点值,再大摘引精度会掉
# 超过 max_chars 时在最近的句末断开,避免拦腰截断
sections, buf, start = [], "", None
for it in items:
if start is None:
start = it["start"]
buf += it["text"]
if len(buf) >= max_chars and re.search(r"[。!?]$", it["text"]):
sections.append({"start": start, "end": it["end"], "text": buf})
buf, start = "", None
if buf:
sections.append({"start": start, "end": items[-1]["end"], "text": buf})
return sections
if __name__ == "__main__":
# 遍历目录下所有音频,逐个转写并落盘
# 输出结构同时服务两处:页面折叠区与 JSON-LD
# 输出目录统一放 out/,文件名沿用音频主文件名便于回溯
Path("out").mkdir(exist_ok=True)
for p in sorted(Path("audio").glob("*.m4a")):
# 先转写,再清洗,最后切分,顺序不能颠倒
raw, duration = transcribe(p)
cleaned = clean(raw)
sections = to_sections(cleaned)
payload = {
"duration": duration,
"sections": sections,
# full 是拼接后的整篇文本,供 JSON-LD 的 transcript 字段使用
"full": "".join(s["text"] for s in sections),
}
# ensure_ascii=False 很关键,否则中文全转成 \uXXXX
(Path("out") / f"{p.stem}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
页面里的双轨落地:折叠区加结构化数据
转写文本要同时喂给两类读者:人需要能折叠、能跳转、不撑爆页面;引擎需要结构化、可定位、不重复。
做法是按小节渲染进 <details> 折叠区,每节带稳定 id,形如 #t-12-034,即第 12 集第 34 小节。折叠区默认收起,实测 LCP 只涨了 40 毫秒,展开后每节右侧有"跳到此处播放"按钮,用 currentTime 定位到该节起始秒数。结构化数据这一侧把小节纯文本拼成完整字符串塞进 transcript,别把 HTML 标签一起拼进去。下面是示意,落地时删掉注释。
{
"@context": "https://schema.org",
// 类型是 PodcastEpisode,不是 Course,也不是 AudioObject
"@type": "PodcastEpisode",
// 指向系列,这里用嵌套写法,也可用 @id 引用页面上的另一个节点
"partOfSeries": {
"@type": "PodcastSeries",
// 系列名要和页面面包屑一致,不一致会让引擎怀疑是两个实体
"name": "职场沟通实战课",
"url": "https://example.com/series/communication"
},
// 集号由运营显式填写,不用数据库自增 ID
// 分季课程另配 seasonNumber,单季课程不要写
"episodeNumber": 47,
// 标题里带上集号,方便引擎与用户对齐同一集
"name": "第 47 集:坏消息怎么向上汇报",
// ISO 8601 日期,用发布日期而非更新时间
"datePublished": "2026-03-11",
// 音频本体展开成 AudioObject,duration 必须挂在这里
"associatedMedia": {
"@type": "AudioObject",
// 直链不要带会过期的签名参数
"contentUrl": "https://cdn.example.com/ep/47.m4a",
// ISO 8601 时长,1680 秒 = 28 分钟
"duration": "PT28M",
// m4a 容器在部分解析器里会被判为未知,统一声明为 audio/mp4
"encodingFormat": "audio/mp4"
},
// transcript 挂单集,值是纯文本,不要带 HTML 标签
"transcript": "这一集聊一个很多人会卡住的场景:项目进度出了问题,需要主动向上汇报。先说结论,坏消息汇报的核心不是措辞技巧,而是信息结构。领导真正想知道的是三件事:发生了什么、影响范围多大、你准备怎么处理。很多人一上来就铺垫原因,讲五分钟背景,听的人已经失去耐心。可以用 STAR 法则组织这段表达,先讲 Situation 交代背景,再讲 Task 说明你负责的部分,然后是 Action 你已经做了什么,最后是 Result 当前结果和后续方案。这里有个常见的坑,就是把坏消息包装成好消息,比如说进度稍有延迟,听的人完全判断不出严重程度。数字要说清楚,延迟三天就是三天,不要说稍微、一点点。"
}
折叠区和 JSON-LD 用同一份数据源,都从 transcript_sections 表渲染。早期两套模板各写各的,两周后就开始漂移。
原理剖析:为什么 transcript 是引用单元的主要来源
AI 引擎对音频内容的解析边界
先把边界说清楚。主流引擎的抓取与索引链路,输入是文本和结构化标记,音频视频这类二进制载荷基本不进处理流程。少数产品有多模态能力,但那发生在用户主动上传文件的会话里,不是公开网页抓取的默认行为,所以音质做得更好也不会影响被引概率。
引擎对音频页能拿到的只有三样:URL、页面文本、结构化数据。前者决定它能不能把这一页归类成"某系列第 47 集",后者决定它能不能找到可摘引的句子。播放量、完播率都不参与这个判断。
引用单元这个概念
引用单元(Citation Unit)指引擎生成答案时实际摘出并附链接的那段文本,不是整页也不是站点,通常是段落、列表项或表格单元格,粒度 50 到 500 字。
音频页 68 字的简介理论上也能成为引用单元,但竞争力很弱,简介只含标题级信息,没有可摘引的方法、步骤、例子。转写文本完全不同:一集 28 分钟约 5200 字,天然分布着几十个"讲清楚了某一个子问题"的段落,每一个都有机会成为引用单元。引用单元的数量大致正比于页面可用正文文本量,transcript 是音频页少数能低成本放大这个量的手段。实测有转写的单集平均产生 6.7 个引用单元,没转写的平均 0.3 个。
转写噪声怎么污染实体识别
转写不是无损的。ASR 出错的地方会直接改变下游实体识别结果,进而影响这一集能匹配到哪些查询。
| 噪声类型 | 典型样例 | 对实体的影响 | 处理办法 |
|---|---|---|---|
| 同音错字 | 非暴力沟通 → 非爆力沟通 | 术语被切碎,无法识别为概念 | 术语表校正 |
| 数字误识 | 三个工作日 → 三天 | 数量实体丢失,步骤类问答失配 | 后处理正则归一 |
| 中英混排 | STAR 法则 → star 法则 | 大小写漂移,匹配召回下降 | 术语表大小写归一 |
| 标点缺失 | 长句无句号 | 小节边界错位,引用单元破碎 | 标点恢复模型 |
| 口语填充 | 嗯、那个、就是说 | 稀释实体密度,文本质量分下降 | 填充词清洗 |
处理完这些噪声后抽查 200 集,实体识别准确率从 71% 提到 93%,术语表贡献最大,48 条术语修掉了近六成错误。标点缺失最难缠,后来接了轻量标点恢复模型才压下去,管线因此多花 8% 时间。
试点 30 天:转写章节被引用了多少
试点选了职场沟通课全部 320 集。口径提前定死:以引擎答案中出现的本域链接为准,且摘引文本必须能在 transcript 里找到字面匹配,才算有效引用。
| 指标 | 试点前 30 天 | 试点后 30 天 | 变化 |
|---|---|---|---|
| 覆盖集数 | 0 | 320 | 新增 320 集 |
| transcript 总字数 | 约 1.1 万字 | 约 214 万字 | 约 195 倍 |
| 出现本域链接的会话数 | 37 | 268 | +624% |
| 被引用过的单集占比 | 2.5% | 40.0% | +37.5pp |
| 转写小节被引用比例 | 0.0% | 12.7% | 新增口径 |
| 引用落点指向小节锚点的比例 | 0.0% | 63.8% | 新增口径 |
| 单集平均引用单元数 | 0.3 | 6.7 | +2133% |
12.7% 要拆开看:320 集共切出 2143 个小节,30 天里有 272 个至少被摘引过一次。分布很不均匀,约七成引用集中在不到两成的小节上,共同特征是开头就有明确结论句,且含数字或步骤。含"三件事"这类显式结构的小节,被引概率是普通小节的 3.8 倍。落点指向锚点的 63.8% 说明引擎确实在用锚点定位,不是甩一个详情页链接了事,用户点进来能直接跳到对应位置,二次跳出比整集页低 21%。
第 12 集讲"汇报时机"的那段被引次数异常高,"越早越好,越晚越被动"这句短、有结论、有对比,几乎成了标准答案句式。也有几集被引的是运营口播稿里的闲聊段,跟课程主题无关。
踩坑清单
transcript 塞错字段。我们图省事把文本截断到 300 字塞进 description,既撑爆了 description 的语义,又没拿到 transcript 的效果。
时长格式。duration 写成 1680 或 28:00 都无效,必须是 ISO 8601 的 PT28M。有一批数据因此整段被判无效。
音频直链带签名参数。CDN 防盗链参数过期后抓取侧拿到 403,连带拉低整集可信度,后来改成抓取侧放行的独立域名,签名有效期放到 7 天。
折叠区用 JS 动态渲染。初版把 transcript 放前端异步拉取,抓下来的 HTML 里一个字都没有。转写文本必须在服务端渲染进首屏 HTML,异步加载对引擎等于不存在。
参考与延伸
- Schema.org 官方类型定义:PodcastEpisode
- Schema.org 官方属性定义:transcript
- Schema.org 官方类型定义:AudioObject
- Google 搜索中心:播客结构化数据文档
关键词:GEO、AI 搜索、PodcastEpisode、transcript、AudioObject、语音转写、Schema.org