直播课下播就没流量:BroadcastEvent 与回放页结构化的实战记录
适用读者:负责职业教育、知识付费平台的工程师与内容运营,正在处理直播课在 AI 搜索里“下播即消失”的问题。
我们给一家做编程职业教育的平台做技术支持,六月中旬发现一个规律:每周三晚上的直播课,直播进行中还能被 AI 搜索引擎在答案里点名引用,下播 48 小时后引用量直接归零。页面没下线,回放也没删,但直播页变成了一个只剩播放器外壳和“本场直播已结束”提示的空壳,AI 爬虫进来抓不到任何可提取的语义。六月第三周那期《Python 并发编程》,下播前 7 天累计被 AI 引用 23 次,下播后 7 天是 0 次;AI 渠道带来的试听线索从每周 11 条掉到 1 条。这事儿不解决,每周辛苦一场的直播内容等于只活两天。
后来我们花了三周,用 Event 与 BroadcastEvent 组合标记直播场次,再给回放页补 recordedAs 与 VideoObject,用定时任务在开播、下播两个时间点自动切换页面输出。改造前后各观察了 40 天,AI 引用从 31 次涨到 118 次。这篇文章把整个过程拆开讲。
背景:一节直播课在 AI 引擎眼里的生命周期
传统搜索引擎抓到回放页,哪怕页面上只有一个播放器,也能靠标题和正文收录。AI 引擎不一样,它要把页面内容解析成实体(Entity)放进候选知识库,才能在生成答案时引用。直播课的麻烦在于它的内容形态会突变:开播前是预告页,直播中是实时流页面,下播后理应变成回放页。绝大多数平台只做了一件事——把播放器从“待开播”换成“已结束”,语义上什么都没变。

我们抓取了自己直播页下播后的 DOM:标题、时间、讲师名都在,但全是普通 HTML 文本,没有任何结构化标记,也没有回放视频文件的元信息。AI 爬虫无法判断“这是一场结束了的、有回放的课”,更无法把这个场次和课程体系里的其他 13 期对齐。整个生命周期如下:
flowchart LR
A[预告态<br>EventScheduled] -->|开播前 10 分钟| B[直播中<br>isLiveBroadcast=true]
B -->|下播 15 分钟后| C[回放态<br>recordedAs 指向 VideoObject]
A -.->|临时取消| D[EventCancelled]
C -->|下一期开播| A
改造前我们卡在预告态和“空壳态”之间:页面既没有声明这是一场可被追踪的 Event(事件实体),也没有声明下播后有可观看的回放资产。AI 引擎只能在下播后的短暂窗口里靠页面标题残留的信号引用,48 小时后旧引用过期,链接就彻底从候选池里掉出去了。
原理剖析:Event、BroadcastEvent 与 publication 的语义分工
这一节讲机制,不是讲经验。schema.org 里和直播相关的三个类型各有分工,混用是大多数平台翻车的原因。
Event(事件实体)描述“一场课”:谁讲、什么时候开始结束、在哪参加。它是课程体系里可对齐的最小单元,第 14 期的并发课和第 15 期的并发课是两个独立 Event,靠名称和 startDate 区分。
BroadcastEvent(广播实体)描述“一场直播流”:这个流是不是正在播出(isLiveBroadcast)、播出窗口是几点到几点(publication 字段,类型是 BroadcastTimeSpecification,里面也有 startDate 和 endDate)。Google 在直播流标记的官方文档里用的就是 BroadcastEvent 加 publication 的组合,它解决的是“流本身”的生命周期,和“课”是两层。
recordedAs 是另一个方向的桥:它把一个实体指向其录制产物。下播后,Event 通过 recordedAs 指向一个 VideoObject(视频实体),VideoObject 再带上时长、缩略图、上传日期、transcript 摘要。AI 引擎引用时,直播中引用的是“正在播出的第 14 期”,下播后引用的是“有完整回放的第 14 期”,两者都能挂上试听链接。
下面这条时序图说明了改造前后 AI 爬虫行为的差异:
sequenceDiagram
participant AI as AI 引擎爬虫
participant LP as 直播页
participant RP as 回放页
participant KG as 候选知识库
AI->>LP: 下播 48h 后重新抓取
LP-->>AI: 空壳 DOM,无结构化语义
AI->>KG: 旧引用过期,条目移出候选池
AI->>RP: 改造后抓取回放 URL
RP-->>AI: Event 加 VideoObject 的 JSON-LD
AI->>KG: 实体对齐到课程条目
AI-->>AI: 用户提问时引用回放并给出试听入口
一句话概括机制:Event 管“课”,BroadcastEvent 管“流”,recordedAs 管“回放资产”,三层都齐了,AI 引擎在任何时间点抓取都能拿到可引用的语义。
实操一:给直播页加上 Event + BroadcastEvent 标记
我们的课程数据在 MySQL 8.0 的 course_schedule 表里,模板层是 Django 4.2。JSON-LD 没有用第三方库,直接在 Python 里拼 dict 再 json.dumps,方便模板渲染和单元测试。
# 依赖:Python 3.10+,无第三方依赖
# 环境:Django 4.2 模板层,数据源为 course_schedule 表
import json
from datetime import timedelta, timezone
TZ = timezone(timedelta(hours=8)) # 统一东八区,避免 AI 爬虫按 UTC 解析出跨天场次
# 时区必须全局统一来源,见踩坑一:UTC 写法导致实体对齐失败
# 后续如需服务海外学员,再把 TZ 抽成按场次配置,不要在每个函数里各自处理
def build_live_graph(lesson):
# lesson 是一行课程记录,含 title、starts_at、ends_at、embed_url 等字段
# 函数返回的 dict 会被模板层直接渲染成 <script type="application/ld+json">
start = lesson["starts_at"].astimezone(TZ).isoformat()
end = lesson["ends_at"].astimezone(TZ).isoformat()
window_end = lesson["ends_at"].astimezone(TZ) + timedelta(hours=2)
# 播出窗口比课程结束时间多留 2 小时,覆盖拖堂与答疑环节
# 注意:window_end 只影响 BroadcastEvent 的 publication,不改 Event 的 endDate
return {
"@context": "https://schema.org",
# @graph 里放两个实体:场次(Event)与直播流(BroadcastEvent)
"@graph": [
{
"@type": "Event", # 外层先声明场次实体,是课程体系对齐的锚点
# @id 后缀固定为 #event,回放页必须复用同一个值,见踩坑三
"@id": f"https://example-edu.cn/live/{lesson['id']}#event",
"name": lesson["title"], # 标题带期数,如「Python 并发编程 第 14 期」
"startDate": start,
"endDate": end,
# 线上课必须声明 OnlineEventAttendanceMode,缺省会被当成线下活动
"eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
"eventStatus": "https://schema.org/EventScheduled",
"location": {
"@type": "VirtualLocation", # 线上直播用 VirtualLocation,不要写 Place
"url": lesson["embed_url"],
},
"organizer": {
"@type": "Organization",
"name": lesson["org_name"], # 与站点页脚一致,帮助实体对齐
"url": "https://example-edu.cn/",
},
"isAccessibleForFree": True, # 免费公开课必须显式声明,默认值不是 True
},
{
"@type": "BroadcastEvent", # 直播流实体,与场次实体同页共存
# is_live 由定时任务每分钟刷新,见实操三的三态扫描
"isLiveBroadcast": lesson["is_live"],
# publication 描述播出窗口,类型必须是 BroadcastTimeSpecification
"publication": {
"@type": "BroadcastTimeSpecification",
"startDate": start, # 播出窗口起点与开课时间一致
"endDate": window_end.isoformat(), # 窗口终点留 2 小时缓冲
},
# WatchAction 告诉 AI 引擎这个 URL 可以直接观看
"potentialAction": {
"@type": "WatchAction",
"target": lesson["embed_url"],
},
},
],
}
def render_json_ld(graph):
# 输出时确保 UTF-8 且不转义中文,否则部分 AI 爬虫解析器会读出乱码实体名
# separators 去掉多余空格,减小页面体积
# 模板层输出前不再二次转义,Django 的 |safe 过滤器在这里是必要项
return json.dumps(graph, ensure_ascii=False, separators=(",", ":"))
上线第一天用 Google 的富媒体测试工具验了一把,直播页报 Missing field "publication"——那是我把 publication 简写成了一个时间段对象漏了类型声明,补上 BroadcastTimeSpecification 后通过。这个报错原文我一直留着,后来团队里其他人犯同样错误,直接把报错贴给对方。
实操二:回放页补 recordedAs 与 VideoObject
回放页是整个方案里收益最大的改动。直播页的下播空壳问题,解法不是把直播页修得更漂亮,而是让下播后的同一个 URL 输出“回放语义”。有一个细节要说明:schema.org 的 EventStatusType 里没有“已结束”这个枚举值,表示结束靠两个信号——endDate 已经过去,加上 recordedAs 指向了录制产物。
def build_replay_graph(lesson, video):
# video 是回放资产记录,含 mp4_url、duration_sec、thumb_url、published_at
# 直播页与回放页共用同一个 build 函数族,@graph 结构保持同源
duration = f"PT{video['duration_sec'] // 3600}H" # ISO 8601 时长前缀 PT
duration += f"{video['duration_sec'] % 3600 // 60}M"
# 录制转码完成前 video 可能是 None,模板层此时降级输出预告态标记
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Event", # 场次实体保留,与直播页同一个 @id
# 回放页复用 #event 后缀,保证实体对齐时被认成同一场课
"@id": f"https://example-edu.cn/live/{lesson['id']}#event",
"name": lesson["title"],
"startDate": lesson["starts_at"].astimezone(TZ).isoformat(),
"endDate": lesson["ends_at"].astimezone(TZ).isoformat(),
# 关键字段:场次指向其回放资产,这是回放页语义的核心
"recordedAs": {
"@type": "VideoObject",
"name": f"{lesson['title']} 完整回放",
"contentUrl": video["mp4_url"],
"thumbnailUrl": video["thumb_url"],
# 时长格式必须是 ISO 8601,写错整段失效,见踩坑二
"duration": duration,
"uploadDate": video["published_at"].astimezone(TZ).isoformat(),
# 摘要截 300 字符,用讲师一手摘要,不要复用课程通用简介
"description": video["summary"][:300],
},
},
],
}
VideoObject 的 description 我们没有偷懒填课程简介,而是让讲师下播后提交 200 字左右的“本场讲了什么”,AI 引擎对这种第一手摘要的抓取明显比通用简介积极。第 14 期的摘要里写了“GIL 的三种绕过场景”,40 天观察期内有 6 次 AI 引用直接命中了这句话。
实操三:Python 定时任务在直播前后切换状态
三态切换靠人工改后台是不现实的,运营同学忘过两次,页面在直播开始两小时后还挂着预告态。我们用 APScheduler 3.10 写了一个每分钟扫描的作业,逻辑只有三条规则。
# 依赖:APScheduler 3.10,MySQL 8.0(course_schedule 表新增 page_state 字段)
# 部署:与 Web 服务同机常驻进程,单实例运行,避免重复扫描
# 扫描逻辑刻意做成幂等:任何一轮漏跑,下一轮都能把状态补齐
from apscheduler.schedulers.blocking import BlockingScheduler
PRE_ROLL = 10 * 60 # 开播前 10 分钟切直播中态,给爬虫留预热窗口
COOL_DOWN = 15 * 60 # 下播 15 分钟后切回放态,等录制文件转码完成
# 两个阈值来自实测:预热窗口过短,AI 爬虫开播后 20 分钟才回访
# 冷却时间过短,回放页会指向还在转码中的录制文件
@BlockingScheduler()
def scan_lessons():
# 每分钟扫一次全部未归档场次,按当前时间判断三态
# 判定顺序有讲究:先判直播窗口,再判回放,匹配到即更新,不做多余写库
now = utcnow()
for lesson in db.query("SELECT * FROM course_schedule WHERE archived = 0"):
# delta_start 为正表示还没开播,delta_end 为正表示已经下播
delta_start = (lesson.starts_at - now).total_seconds()
delta_end = (now - lesson.ends_at).total_seconds()
if 0 <= delta_start <= PRE_ROLL:
# 开播前 10 分钟:isLiveBroadcast 置 true,刷新 BroadcastEvent
db.update(lesson, page_state="live", is_live=True)
elif delta_end >= COOL_DOWN and lesson.record_id:
# 下播 15 分钟且录制文件就绪:切回放态,同一 URL 换输出模板
# record_id 为空说明转码未完成,本条件不成立,留给下一轮扫描
# 实测过转码排队 22 分钟的情况,跳过两轮后正常切换,无人工介入
db.update(lesson, page_state="replay", is_live=False)
elif delta_start > PRE_ROLL:
# 开播前维持预告态,eventStatus 保持 EventScheduled
# 这里幂等重写同一状态,避免定时任务重启后出现状态真空
db.update(lesson, page_state="upcoming", is_live=False)
转码是个前置依赖:COOL_DOWN 设 15 分钟就是给录制转码留的,有一次转码排队 22 分钟,任务看到 record_id 为空就跳过了,下一轮扫描才切换——这种“等文件就绪再切”的幂等设计避免了回放页指向 404 的尴尬。三态切换上线后,运营彻底不用管页面状态了。
前后 40 天的数据对比
改造在七月第二周全量上线,覆盖 6 期直播课和对应的回放页。观察口径:用 5 个 AI 搜索入口分别以课程相关长尾词提问,记录答案中是否出现我们的课程条目或回放链接,每次提问记 1 次引用;试听线索按落地来源归因。
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 直播页语义输出 | 播放器外壳,无 JSON-LD | Event 与 BroadcastEvent 组成的 @graph |
| 回放页语义输出 | 无任何结构化标记 | Event 挂 recordedAs 指向 VideoObject |
| 状态切换方式 | 运营手工改,常漏 | 定时任务自动三态切换 |
| 讲师摘要 | 无 | 每期下播后 200 字一手摘要 |
前后各 40 天的核心数据如下表。要说明的是样本只有 6 期课,波动不小,但方向一致。
| 指标 | 前 40 天 | 后 40 天 | 变化 |
|---|---|---|---|
| AI 引用总次数 | 31 | 118 | 约为改造前 3.8 倍 |
| 引用命中的页面 | 直播页占 87% | 回放页占 71% | 引用主体从直播页转移到回放页 |
| AI 渠道试听线索 | 14 条 | 52 条 | 每周均值从 2.5 条到 9.1 条 |
| 回放页被抓取频次 | 0.4 次/天 | 3.1 次/天 | 抓取频次翻了近 8 倍 |
| 引用持续窗口 | 下播 48h 内归零 | 覆盖至下一期开播 | 每期内容从 2 天活到 7 天 |
最出乎预期的是引用主体转移:改造前 AI 只能在直播“还活着”的窗口里引用直播页,改造后 71% 的引用落在回放页上。也就是说,内容资产的生命周期被拉长了,直播从一次性消耗品变成了可反复引用的录播库。
踩过的三个坑
时区问题占一个。早期我们把 startDate 写成了带 Z 后缀的 UTC 时间,国内某个 AI 入口把它解析成了前一天的场次,实体对齐直接失败,那期课在答案里被引用成了“昨天的并发课”。统一东八区后没再出现。
时长格式占一个。VideoObject 的 duration 必须是 ISO 8601 格式(PT1H32M 这种),我们起初写的是 “92分钟”,富媒体测试工具没报错,但抓取频次就是不涨。改成标准格式后一周内抓取频次从 0.9 涨到 2.4。
还有一个坑是 @id 不稳定。前两周直播页和回放页的 @id 后缀写法不一致,一个带 #event 一个不带,AI 引擎把它们当成了两个实体,引用被稀释。同一个场次的 @id 必须在所有页面保持字节级一致,这是实体对齐(Entity Resolution)的前提。
误区澄清与趋势判断
一个常见误区是把 BroadcastEvent 当成 SEO 技巧,只求测试工具通过就算完。结构化数据的真正受众是 AI 引擎的实体抽取管道,它关心的是语义完整和实体稳定,不是标签齐全。如果你只在直播页做标记、回放页裸奔,等于只优化了内容 2 天的生命周期,放弃了后面 5 天。
另一个误区是用“已结束”的文案暗示回放。AI 爬虫不读视觉暗示,它只读语义。页面上写“本场直播已结束,点击观看回放”,如果 recordedAs 不存在,对机器而言这就是一句空话。
趋势上,各家 AI 搜索入口对视频类实体(尤其是带完整元数据的回放内容)的引用权重在提升,因为它能支撑答案里“讲了几小时、讲了什么”这类可验证细节。知识付费团队手里的直播回放库,目前大多是一堆没有语义的 mp4 文件,把它们结构化出来,可能是接下来一年性价比很高的工程投入。欢迎在评论区交流你们平台的直播标记实践,尤其是状态切换的工程方案。
参考与延伸
- schema.org Event:Event 类型及其属性定义,含 eventStatus 枚举说明
- schema.org BroadcastEvent:BroadcastEvent 与 BroadcastTimeSpecification 的字段定义
- Google Search Central:直播流标记指南 :Google 官方对直播与视频结构化数据的要求
- llmstxt.org:面向 AI 引擎的站点内容引导规范,可与结构化数据配合使用
GEO|BroadcastEvent|VideoObject|JSON-LD|直播回放|结构化数据|AI 搜索