音频课在 AI 引擎里没有目录:PodcastEpisode 与 transcript 的规范解读

2026-09-21 01:35:42 0 次浏览
GEOAI搜索PodcastEpisodetranscriptSchema.org知识付费

适用读者:知识付费、在线教育平台做后端和搜索增长的工程师;课程页已挂 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 时效性判断 误用更新时间,旧内容被当新内容

partOfSeriesepisodeNumber 是结构层的地基,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 写成 168028:00 都无效,必须是 ISO 8601 的 PT28M。有一批数据因此整段被判无效。

音频直链带签名参数。CDN 防盗链参数过期后抓取侧拿到 403,连带拉低整集可信度,后来改成抓取侧放行的独立域名,签名有效期放到 7 天。

折叠区用 JS 动态渲染。初版把 transcript 放前端异步拉取,抓下来的 HTML 里一个字都没有。转写文本必须在服务端渲染进首屏 HTML,异步加载对引擎等于不存在。

参考与延伸

关键词:GEO、AI 搜索、PodcastEpisode、transcript、AudioObject、语音转写、Schema.org

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