直播课下播就没流量:BroadcastEvent 与回放页结构化的实战记录

2026-09-24 01:25:18 3 次浏览
GEO在线教育BroadcastEventVideoObjectJSON-LD实战教程

适用读者:负责职业教育、知识付费平台的工程师与内容运营,正在处理直播课在 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 文件,把它们结构化出来,可能是接下来一年性价比很高的工程投入。欢迎在评论区交流你们平台的直播标记实践,尤其是状态切换的工程方案。

参考与延伸

GEO|BroadcastEvent|VideoObject|JSON-LD|直播回放|结构化数据|AI 搜索

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