培训机构的周末直播没人引用:Event Schema 与直播活动页的结构化实战

2026-09-19 09:59:49 10 次浏览
SEOGEOJson-LD.NET 8结构化数据

适用读者:培训机构与企业大学的站点工程师、维护 .NET 8 与 Razor Pages 的后端开发者、负责活动页被生成式引擎引用归因的 SEO 与 GEO 技术人员。

运营在群里甩了一句话:我们在三个 AI 应用里问了「本周有什么免费直播课」,答的全是别家机构,周六晚八点那堂公开课一次都没出现。后面跟着一张截图:答案里列了四家机构,连课表和报名入口都给出来了,我们的名字不在其中。

那张活动页我看过:一张 1920 乘 640 的海报,标题、讲师、时间全压在图片里,下面挂一个报名表单。页面写得不算差,问题在于所有关键信息都以像素形式存在。

一次抱怨之后:先看引擎拿到了什么

排查顺序比工具重要,我们用了三步。

直播舞台与活动日历

第一步是 curl -sL 把活动页的原始 HTML 落到一个文件里。抓取器拿到的是未执行 JavaScript 的那一版,而这个页面的报名表单走 XHR 拉取,海报是 <img> 标签里的一张 JPEG,详情页正文只有一句「详见海报」。也就是说,时间与价格这两个关键槽位,在返回给引擎的文本里为零。

第二步是 Rich Results Test 与 Schema Markup Validator 各跑一遍,两个工具都报「页面上未检测到结构化数据」。前者只看它支持的富媒体类型,后者做词表与语法校验,两边结论一致时,基本可以排除是某个工具的误判。

第三步是人工复读,把二十组真实提问在六个生成式引擎里各跑一轮。结果是:涉及「本周」「这个周末」「今晚」这类时间词的提问,命中率 7 出 120;不带时间词的「有没有 .NET 公开课」类提问,偶尔能碰到一次,给的是上一期的回放页 URL。时间敏感型提问几乎全军覆没,非时间敏感的偶发命中靠的是页面标题里的关键词,两者机制不同。

到这里病因清楚了:页面缺的不是内容质量,是一份机器可读的时间声明。

Event Schema 的字段骨架与取值约束

结构化数据(Structured Data)用 schema.org 词表描述页面上的实体。活动类实体对应 Event 类型,教培场景也可以用它下面的 EducationEvent 子类,两者字段一致,子类只是多一层语义。

下面这张表是我们最终落地的字段清单,第三列的取值直接复制就能用。

字段 作用 取值示例 常见坑
@id 活动的稳定标识,跨语言、跨渠道归一依据 https://example-edu.cn/live/dotnet-8-weekly#2026-09-19 每期换一个随机 URL,历史活动在索引里分裂成几十个实体
startDate 开始时间,决定能否进入近期事件候选集 2026-09-19T20:00:00+08:00 写成 2026/09/19 20:00 或只写 2026-09-19,解析器按 UTC 猜,偏差一天就掉出窗口
endDate 结束时间,用于判断活动是否已过期 2026-09-19T21:30:00+08:00 省略后部分引擎按默认时长补,补齐的时段与实际不符时会被降权
eventAttendanceMode 参与方式枚举 https://schema.org/OnlineEventAttendanceMode 写短名 OnlineEventAttendanceMode,整条活动的可用性判断失效
eventStatus 活动状态枚举,决定能否被推荐 https://schema.org/EventScheduled 取消后仍留着 EventScheduled,用户按推荐来了吃闭门羹
location 线上用 VirtualLocation,线下用 Place {"@type":"VirtualLocation","url":"..."} 线上活动填了 Place 却不给地址,或被当成「缺少场馆」丢弃
offers 票价与报名入口 {"price":"0","priceCurrency":"CNY","url":"..."} 免费场次不写 offers,回答里只有时间没有报名链接
organizer / performer 主办方与主讲人,参与实体对齐 Organization / Person 节点 每期讲师名写法不同,同一个人在知识库里被拆成多个实体
eventSchedule 周期性活动声明 Schedule 节点,配合 repeatFrequency 描述每周一次 每周一场的公开课却按单次活动发布,错过「每周六」类提问

有一处取舍值得单独讲:公开课每周一期,起初我们想每期一个独立 URL。实践下来改成了固定 slug 加日期锚点的写法,即 /live/dotnet-8-weekly#2026-09-19,页面 URL 始终是同一个,锚点区分期数。这样历史场次的实体靠 @id 归到同一条脉络上,新一期上线时 URL 的抓取权重已经在,重抓延迟明显更短。

原理剖析:近期活动的时序索引与 eventStatus 的语义权重

生成式引擎处理「本周有什么直播课」这类提问时,走的不是常规内容检索那条路。

第一步是意图判定(Intent Classification)。识别到查询里带相对时间词时,引擎把提问标记为时间敏感,检索范围从全量语料收缩到一个按时间分区的索引结构。这个结构可以理解成若干个滑动窗口:以当前日期为原点,向前覆盖十四天,向后覆盖两天左右,各家实现略有差异。只有能解析出明确 startDate 的实体才进得去这个索引;解析不出来的,即使正文里反复提到「周六」,也会被留在常规语料库里,而常规检索对模糊时间词的匹配极不稳定。

第二步是候选生成(Candidate Generation)。落在窗口内的实体进入候选集,按与提问时刻的时间距离粗排,越近权重越高。这里有个容易被忽略的细节——窗口之外的内容不是被删除,只是不参与这一轮排序,所以错过窗口等于本次提问不存在。startDate 缺时区偏移的写法最容易在这一步出事:按 UTC 解析出来的时间点偏离八小时,遇到二十点开播的场次直接跨到第二天,滑出了「本周」这个窗口。

第三步是状态过滤(Status Filtering)。eventStatus 在这里起作用,它是一条硬规则而不是加分项。

eventStatus 取值 语义 引擎侧常见处理 该什么时候改
EventScheduled 按计划进行,默认值 正常进候选集,回答里给出时间与链接 活动通过后立即置上,页面上线前保持这个值
EventRescheduled 已改期,时间待确认或有新时间 有新的 startDate 时按新时间参与;无新时间则多数引擎直接排除 讲师临时调整档期,但新时间还没敲定的那几天
EventMovedOnline 改为线上举行 保留候选资格,location 换成 VirtualLocation 后照常被引用 场地不可用、报名人数超场馆容量时
EventPostponed 延期,新时间未定 失去时间锚点,几乎不再出现在近期推荐里 师资临时有事、时间另行通知的情况
EventCancelled 已取消 从近期候选集中移除,保留历史归属 取消当天改,且不要顺手把页面删掉

EventCancelled 这一行的「不要删页面」是踩出来的经验。第一次取消活动,运维把页面 410 掉了,结果两周后仍然有人从缓存答案里找过来,点进去是空页。后来改成保留页面、返回 200、状态置为 EventCancelled,页面上再放一句取消说明和下期预告,投诉就停了。

stateDiagram-v2
    [*] --> EventScheduled : 后台创建并通过审核
    EventScheduled --> EventRescheduled : 讲师档期变动 新时间未定
    EventScheduled --> EventMovedOnline : 场地不可用或改为纯线上
    EventScheduled --> EventCancelled : 师资或报名人数不足取消
    EventRescheduled --> EventScheduled : 新时间确认 回填 startDate 与 previousStartDate
    EventRescheduled --> EventCancelled : 最终无法开课
    EventMovedOnline --> EventMovedOnline : 更新 VirtualLocation 的直播地址
    EventMovedOnline --> EventCancelled : 线上也无法进行
    EventCancelled --> [*]
    note right of EventRescheduled
        只改状态 不删页面
        状态码保持 200
    end note

.NET 8 Razor Pages 直出 JSON-LD

依赖与环境:.NET 8 SDK 与 ASP.NET Core 8.0 的 Razor Pages 项目,只用框架自带的 System.Text.Json,不引第三方序列化库。视图模型负责把后台活动表的记录映射成 schema.org 字段,序列化之后由页面模板直出到 <head>

// 运行环境:.NET 8 / ASP.NET Core 8.0 Razor Pages,仅依赖框架自带 System.Text.Json
// 用途:把后台活动表的一条记录映射成 Event 节点,并安全地写进 script 标签
using System.Text.Json;                            // JSON 序列化入口
using System.Text.Encodings.Web;                   // 宽松编码器 JavaScriptEncoder 在这里
using System.Text.Json.Serialization;              // JsonPropertyName 特性

// 命名空间按业务域划分,小程序端与后台共用同一份视图模型
namespace TrainingSite.Pages.Live;

// 视图模型:字段名用 JsonPropertyName 钉死,避免被全局命名策略悄悄改写
public sealed class LiveEventVm
{
    // @context 指定词表,写死 https://schema.org
    [JsonPropertyName("@context")]
    public string Context => "https://schema.org";

    // @id 是活动的稳定标识,同一系列建议用同一 slug 加日期锚点
    [JsonPropertyName("@id")]
    public string Id { get; init; } = "";

    // 公开课可以直接用 EducationEvent,语义比 Event 更窄也更准
    // 若营业侧还要求 SEO 富媒体展示,保持 Event 兼容性更好
    [JsonPropertyName("@type")]
    public string Type => "Event";

    // 标题里不要塞期数和日期,这两个值由字段承担
    // 也不要塞营销词,回答里它是被当作名称念出来的
    [JsonPropertyName("name")]
    public string Name { get; init; } = "";

    // 摘要控制在 120 到 180 字,回答里经常整段搬走
    [JsonPropertyName("description")]
    public string Description { get; init; } = "";

    // ISO 8601 必带时区偏移,缺偏移量会被按 UTC 兜底解析
    [JsonPropertyName("startDate")]
    public string StartDate { get; init; } = "";

    // 结束时间同样带偏移,缺它时部分引擎按默认时长补,与实际不符会被降权
    [JsonPropertyName("endDate")]
    public string EndDate { get; init; } = "";

    // 全程线上填 OnlineEventAttendanceMode,线下场馆填 OfflineEventAttendanceMode
    [JsonPropertyName("eventAttendanceMode")]
    public string EventAttendanceMode { get; init; } = "https://schema.org/OnlineEventAttendanceMode";

    // 默认 EventScheduled,改期与取消时由 PageModel 覆盖
    [JsonPropertyName("eventStatus")]
    public string EventStatus { get; init; } = "https://schema.org/EventScheduled";

    // 线上场次给 VirtualLocation 对象,线下给 Place 对象,两者不共存
    [JsonPropertyName("location")]
    public object? Location { get; init; }

    // 免费公开课也必须写 offers,price 给 0,否则回答里出不来报名链接
    [JsonPropertyName("offers")]
    public object? Offers { get; init; }

    // 主办方用 Organization 节点,name 与全站品牌串保持一致
    [JsonPropertyName("organizer")]
    public object? Organizer { get; init; }

    // 主讲人用 Person 节点,姓名走后台统一词表,避免同一个讲师被拆成多个实体
    [JsonPropertyName("performer")]
    public object? Performer { get; init; }

    // 改期场景用,记录原定开始时间,供引擎回溯本次变更
    [JsonPropertyName("previousStartDate")]
    public string? PreviousStartDate { get; init; }

    // 图文素材的海报图,建议给同一张图 16:9 与 1:1 两个尺寸
    [JsonPropertyName("image")]
    public string[]? Image { get; init; }

    // 活动详情页自身,回答里的跳转出口
    [JsonPropertyName("url")]
    public string Url { get; init; } = "";
}

public static class JsonLdWriter
{
    // 默认编码器会把中文转成 \u4E2D、把 +08:00 里的加号转成 \u002B
    // 换成宽松编码器后中文与时区偏移保持原样,diff 和日志排查都省事
    // 注意宽松编码器不等于不安全,尖括号转义在下面单独做
    private static readonly JsonSerializerOptions Opts = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        // 缩进只为了本地调试方便
        WriteIndented = false                              // 线上压成一行,省带宽
    };

    public static string ToScriptBody(object graph)
    {
        // 序列化前不需要再做一次校验,字段合法性在映射层挡掉了
        var json = JsonSerializer.Serialize(graph, Opts);
        // 内容落到 script 标签里时,出现 </script> 会被浏览器提前闭合标签
        // 转义尖括号是服务端数据进 script 标签的标准做法,别省
        // Html.Raw 只保证不二次转义 HTML,不负责这一步,两件事要分开看
        return json.Replace("<", "\\u003C", StringComparison.Ordinal);
    }
}

映射层放在 PageModel 里:依赖与环境同上,下面这段代码是 Pages/Live/Detail.cshtml.cs 的核心部分,ILiveEventRepo 是活动仓储接口,按 slug 取一条记录。

// 运行环境:.NET 8 / ASP.NET Core 8.0 Razor Pages,仓储接口 ILiveEventRepo 由项目自行实现
// 用途:把仓储里的活动记录映射到视图模型,状态枚举在这里收敛成 schema.org 的 URL
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace TrainingSite.Pages.Live;

public sealed class DetailModel(ILiveEventRepo repo) : PageModel      // C# 12 主构造函数注入
{
    public string JsonLd { get; private set; } = "";                  // 供模板直出使用

    public async Task OnGetAsync(string slug, CancellationToken ct)
    {
        // slug 是路径上的固定标识,日期走查询串或锚点,不做进 slug
        var e = await repo.GetBySlugAsync(slug, ct);
        if (e is null)
        {
            Response.StatusCode = 404;                                // 真不存在的活动才给 404
            return;
        }

        // 内部状态到 schema.org 枚举的映射,全站只有这一处,改枚举只动这里
        var statusUrl = e.Status switch
        {
            // 正常排期,是绝大多数活动所处的状态
            LiveStatus.Scheduled   => "https://schema.org/EventScheduled",
            // 改期但新时间已定,此时 startDate 必须已经回填
            LiveStatus.Rescheduled => "https://schema.org/EventRescheduled",
            // 改为线上举行,location 必须同步换成 VirtualLocation
            LiveStatus.MovedOnline => "https://schema.org/EventMovedOnline",
            // 延期且新时间未定,这类活动基本进不了推荐
            LiveStatus.Postponed   => "https://schema.org/EventPostponed",
            // 已取消,页面保留并给出下期预告
            LiveStatus.Cancelled   => "https://schema.org/EventCancelled",
            // 未知兜底值,按排期处理比留空安全
            _                      => "https://schema.org/EventScheduled"
        };

        // 线上场次用 VirtualLocation,线下用 Place 加 PostalAddress,二选一
        object location = e.IsOnline
            ? new { @type = "VirtualLocation", name = e.RoomName, url = e.RoomUrl }
            : new
            {
                @type = "Place",
                name = e.VenueName,                                    // 场馆名,与票面写法一致
                address = new
                {
                    @type = "PostalAddress",
                    streetAddress = e.VenueAddress,                    // 门牌、楼层、教室号
                    addressLocality = e.City,                          // 城市,参与地理类检索
                    addressCountry = "CN"
                }
            };

        var vm = new LiveEventVm
        {
            // slug 固定 + 日期锚点:同一系列的多期活动共享同一 URL
            Id = $"https://example-edu.cn/live/{e.Slug}#{e.StartAt:yyyy-MM-dd}",
            Name = e.Title,
            Description = e.Summary,
            // zzz 输出带冒号的时区偏移,例如 +08:00
            StartDate = e.StartAt.ToString("yyyy-MM-ddTHH:mm:sszzz"),
            // 结束时间不能省,缺值会被按默认时长补齐
            EndDate = e.EndAt.ToString("yyyy-MM-ddTHH:mm:sszzz"),
            // 参与方式与 location 必须成对变更,只改一边会被判为自相矛盾
            EventAttendanceMode = e.IsOnline
                ? "https://schema.org/OnlineEventAttendanceMode"
                : "https://schema.org/OfflineEventAttendanceMode",
            EventStatus = statusUrl,
            Location = location,
            // 海报给两张,宽幅用于摘要卡片,方图用于列表位
            Image = new[] { e.Poster16x9Url, e.Poster1x1Url },
            Url = $"https://example-edu.cn/live/{e.Slug}",
            Offers = new
            {
                @type = "Offer",
                price = e.IsFree ? "0" : e.Price.ToString("0.##"),    // 免费场次必须是字符串 0
                priceCurrency = "CNY",                                // 缺币种的价格会被整段丢弃
                availability = "https://schema.org/InStock",
                url = e.SignUpUrl,                                    // 报名入口,回答里的落地链接
                validFrom = e.SignUpOpenAt.ToString("yyyy-MM-ddTHH:mm:sszzz")   // 报名开放时间
            },
            // 讲师姓名由后台统一词表输出,前台不做裁剪,避免同一个人在知识库里被拆成多个实体
            Performer = new { @type = "Person", name = e.SpeakerName, jobTitle = e.SpeakerTitle },
            Organizer = new
            {
                @type = "Organization",
                name = e.BrandName,                                   // 与全站 Organization.name 同源
                url = "https://example-edu.cn/"
            },
            // 改过期的场次才输出原定时间,没改过留 null 让序列化器跳过
            PreviousStartDate = e.OriginalStartAt?.ToString("yyyy-MM-ddTHH:mm:sszzz")
        };

        // 序列化结果直接给模板,落地安全由 ToScriptBody 负责
        JsonLd = JsonLdWriter.ToScriptBody(vm);
    }
}

映射层里有一处值得留意:讲师姓名统一由后台词表输出,前台不做裁剪,同一个讲师在不同期数里写法不同,会被知识库当成多个人。

Razor 模板:把 JSON-LD 落到 head

依赖与环境:同一个 Razor Pages 项目,模板文件 Pages/Live/Detail.cshtml。要点只有两个——必须服务端直出,且注入位置在 <head> 里。

<!-- Pages/Live/Detail.cshtml:ASP.NET Core 8.0 Razor Pages 模板 -->
<!-- Model 是上面那个 DetailModel,JsonLd 里已是转义过尖括号的 JSON 字符串 -->
@page "{slug}"
@model TrainingSite.Pages.Live.DetailModel
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <!-- title 里保留课程名,不带期数与日期,避免每期改一次造成历史引用串不一致 -->
  <title>@Model.Title</title>
  <!-- 关键一行:ld+json 脚本必须出现在服务端返回的 HTML 里 -->
  <!-- 用 JavaScript 在 DOMContentLoaded 之后 append 的写法,抓取器那一版 HTML 里拿不到 -->
  <!-- 位置放在 head 内部即可,不必挤到最前面 -->
  <script type="application/ld+json">@Html.Raw(Model.JsonLd)</script>
  <!-- 时间同样写一份进 Open Graph,社交与聚合器走这条通道 -->
  <meta property="og:type" content="website" />
  <meta property="og:title" content="@Model.Title" />
</head>
<body>
  <!-- 可见文案里也写一遍时间与报名方式,结构化数据与正文互相印证 -->
  <!-- 只写 schema 不写正文,部分解析器会判定为「页面无可抽取内容」 -->
  <h1>@Model.Title</h1>
  <!-- 时间与参与方式用文本输出,别只压在海报图里 -->
  <p>开播时间:@Model.StartAtText(北京时间,两小时)</p>
  <p>参与方式:@Model.AttendanceText</p>
  <!-- 报名入口放在首屏,减少一次跳转 -->
  <a href="@Model.SignUpUrl">免费报名</a>
  <!-- 海报图保留,但不再作为时间的载体 -->
  <!-- alt 里写清课程名与主讲人,图片通道同样要能读懂 -->
  <img src="@Model.Poster16x9Url" alt="@Model.PosterAltText" width="1280" height="720" />
  <!-- 讲师卡片与报名二维码放在 body 尾部,不与正文争抢抽取权重 -->
</body>
</html>

完整 JSON-LD 输出样例

依赖与环境:上面两段代码在 .NET 8 上跑出来的真实串,为了便于阅读做了缩进与行注释,线上版没有换行也没有注释。

{
  // @context 固定指向 schema.org 词表
  "@context": "https://schema.org",
  // @id 用同一 slug 加日期锚点,跨期归到同一条脉络上
  "@id": "https://example-edu.cn/live/dotnet-8-weekly#2026-09-19",
  "@type": "Event",
  "name": ".NET 8 依赖注入与中间件公开课",
  // 摘要 120 到 180 字,回答里经常被整段引用
  "description": "两小时直播,讲清 .NET 8 里 DI 容器的生命周期边界与中间件管道的执行顺序,课后提供回放与示例代码。",
  // ISO 8601 且带时区偏移,缺偏移会被按 UTC 解析
  "startDate": "2026-09-19T20:00:00+08:00",
  // 结束时间同样带偏移,缺值时引擎自行补齐的时长未必对得上
  "endDate": "2026-09-19T21:30:00+08:00",
  // 纯线上参与,对应 location 里的 VirtualLocation
  "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
  // 正常排期中,若后期取消则改为 EventCancelled
  "eventStatus": "https://schema.org/EventScheduled",
  // 直播间入口,线下场次这里换成 Place 加 PostalAddress
  "location": {
    // VirtualLocation 是专为纯线上场景准备的类型
    "@type": "VirtualLocation",
    // 直播间名称,建议带机构名前缀
    "name": "本周公开课直播间",
    // 直播地址,开播前一段时间可被替换为带时效的临时链接
    "url": "https://example-edu.cn/live/room/20260919"
  },
  // 图片给两张以上,宽幅用于摘要,方图用于列表位
  "image": [
    // CDN 直链,不要跟到防盗链较严的对象存储临时地址
    "https://cdn.example-edu.cn/poster/20260919-16x9.jpg",
    "https://cdn.example-edu.cn/poster/20260919-1x1.jpg"
  ],
  // 免费场次 price 写字符串 0,priceCurrency 不能省,url 指报名页
  "offers": {
    "@type": "Offer",
    // 数字 0 与字符串 0 都能解析,写字符串能避免浮点精度带来的脏值
    "price": "0",
    "priceCurrency": "CNY",
    // InStock 表示仍可报名,报满后换 SoldOut
    "availability": "https://schema.org/InStock",
    "url": "https://example-edu.cn/live/dotnet-8-weekly/signup",
    // 报名开放时间,早于该时间点之前不对外推荐
    "validFrom": "2026-09-12T10:00:00+08:00"
  },
  // 主办方节点与全站 Organization.name 保持一致
  "organizer": {
    "@type": "Organization",
    "name": "示例职业教育",
    "url": "https://example-edu.cn/"
  },
  // 讲师名按后台统一词表输出,避免同名变形
  "performer": {
    "@type": "Person",
    "name": "周维",
    "jobTitle": ".NET 架构讲师"
  },
  "inLanguage": "zh-CN",
  // 活动详情页自身,回答里的跳转出口,不要指向首页
  "url": "https://example-edu.cn/live/dotnet-8-weekly"
}

状态流转的维护细节

状态字段不能没人管。我们把活动状态从一个下拉框改成了带变更记录的状态机,每条变更写进一张 live_event_status_log 表,字段含变更前、变更后、操作人与原因。这么做有两个直接收益:一是 @id 与 URL 全程不变,二是 previousStartDate 有据可查,不用翻聊天记录。

维护过程里跑顺的三条规则。

改的不是状态本身,而是同步一组字段。转线上时必须同时替换 location 的两个属性 —— eventAttendanceMode 换成 OnlineEventAttendanceModelocation 换成 VirtualLocation 对象;只改其中一个,会出现「线上活动配了实体场馆」这种自相矛盾的声明,解析器倾向于整条丢弃。

取消时不走 410。页面保留、状态置 EventCancelled、正文给出下期预告,比直接下架干净。搜索引擎与生成式引擎对「已取消但仍有页面」的处理是一致的:不再进近期候选集,历史归属保留。

活动结束不需要改状态。schema.org 没有「已结束」这个枚举值,startDate 落在过去之后,实体自然滑出近期窗口,这时候把 EventScheduled 改成别的反而是错的。真正要做的是把回放地址挂到页面上,让这条 URL 继续承担内容价值。

从后台表单到被引用的整条链路,掉信息的地方集中在映射与校验两步。

flowchart LR
    A[活动表 live_events] --> B[时间字段与状态字段]
    B --> C[映射层 内部枚举到 schema.org URL]
    C --> D{字段校验}
    D -- startDate 缺时区偏移 --> E[回退本地时区补偏移并打告警]
    D -- 线上场次缺 location --> F[丢弃该字段并通知运营]
    D -- 校验通过 --> G[拼装 JSON-LD 并转义尖括号]
    G --> H[服务端直出到 head 的 ld+json]
    H --> I[AI 爬虫抓取原始 HTML]
    I --> J[写入按时间分区的索引]
    K[提问 本周有什么免费直播课] --> L{意图判定 是否时间敏感}
    L -- 是 --> M[取近十四天窗口内的候选]
    M --> N[按 eventStatus 过滤]
    N --> O[回答给出时间 参与方式与报名链接]
    L -- 否 --> P[常规内容检索]
    J --> M

链路里最容易无声失败的是 EF 两条支路。校验不通过的字段不会带着错值往下走,而是直接消失,下游看到的不是报错而是空槽位,这也是很多人「明明加了字段却没效果」的原因。

改造前后:8 周对照

观测口径在改造前就定死了,避免事后挑数据。二十组提问模板固定不变,每周一轮,六个生成式引擎各跑一次;「命中」定义为回答里出现了本机构名称且给出了可点击的活动链接;时间对不上一律算不命中。转化数据取 GA4 里按来源域名归因的会话与目标完成数。

观测指标 改造前 30 天基线 改造后第 8 周 备注
二十组提问的周命中次数(满分 120) 每周 6 至 9 次 每周 82 至 91 次 第 3 周开始明显抬升,第 6 周后趋于平稳
回答里给出准确开播时间 每周 3 次 每周 71 次 时间写成「周六晚」但缺钟点不计入
回答里给出可直接跳转的报名链接 每周 2 次 每周 66 次 offers.url 上线后才开始出现
活动页来自生成式引擎的会话数 每周 11 至 15 每周 198 至 247 集中在开播前 48 小时
该渠道的报名转化率 1.1% 6.4% 落地即活动页,无中间跳转
因时间信息错误导致的退款与投诉 每周 4 至 6 起 每周 0 至 1 起 取消与改期走状态机之后基本消失

表格第四行的会话数里有一处要说清楚:同期我们还把报名入口从二级页提到活动页首屏,表单字段由六个减到三个。落地体验这块大概贡献了转化率抬升的一半,把它全算到结构化数据头上就失真了。

另一处变量是正文。对照期内海报上的时间与价格被挪进了正文可见文案,这一步和 Schema 上线在同一周完成。它对不带时间词的常规检索贡献更大,时间敏感提问的增长主要来自 Schema。想严格区分的话可以把两个动作拆成两批做,各观察四周,我们当时没空等。

三个被反复问到的误区

第一问通常是:只用微数据(Microdata)写在 HTML 属性上行不行。行,两套标记方式都能被解析,但微数据把属性散落在标签里,海报、表单、讲师卡片各占一处,改期时要同时改四五个地方。集中成一段 JSON-LD 之后,改期只动一个字段。

第二个是时区。有人觉得写 2026-09-19T20:00 就够了,反正中国人都在东八区。缺了偏移量的写法要按 UTC 兜底解析,二十点变成次日凌晨四点,正好滑出「本周」窗口。改起来也不复杂,多加六个字符。

第三个是免费活动要不要写 offers。我们一度跳过这个字段,理由是「免费就不用标价格了」。结果回答里有时间、有标题,就是没有报名链接。补齐 price0priceCurrency 之后,报名链接在第二周开始进入回答,这也是表格里第三行数据变化的来源。

收尾留一个可以自己验证的办法:view-source 打开活动页,搜 application/ld+json,能看见那段字符串就算落地了;看不见就去查是不是被 JavaScript 塞进去的,或者被中间件的输出缓存挡住了。如果你的直播页换过别的落地方式,或者遇到过某类 eventStatus 不生效的情况,评论区把抓取到的原始 HTML 片段贴出来,我们可以一起对着字段排。

参考与延伸

GEO · AI优化AIO · Event Schema · JSON-LD · 直播活动页 · Razor Pages · 结构化数据 · eventStatus

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