培训机构的周末直播没人引用:Event Schema 与直播活动页的结构化实战
适用读者:培训机构与企业大学的站点工程师、维护 .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 换成 OnlineEventAttendanceMode,location 换成 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
链路里最容易无声失败的是 E 与 F 两条支路。校验不通过的字段不会带着错值往下走,而是直接消失,下游看到的不是报错而是空槽位,这也是很多人「明明加了字段却没效果」的原因。
改造前后: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。我们一度跳过这个字段,理由是「免费就不用标价格了」。结果回答里有时间、有标题,就是没有报名链接。补齐 price 为 0 和 priceCurrency 之后,报名链接在第二周开始进入回答,这也是表格里第三行数据变化的来源。
收尾留一个可以自己验证的办法:view-source 打开活动页,搜 application/ld+json,能看见那段字符串就算落地了;看不见就去查是不是被 JavaScript 塞进去的,或者被中间件的输出缓存挡住了。如果你的直播页换过别的落地方式,或者遇到过某类 eventStatus 不生效的情况,评论区把抓取到的原始 HTML 片段贴出来,我们可以一起对着字段排。
参考与延伸
- Schema.org Event 类型字段总表
- Google Search Central:Event 结构化数据要求
- Schema.org eventStatus 枚举取值说明
- Schema.org 校验工具
GEO · AI优化AIO · Event Schema · JSON-LD · 直播活动页 · Razor Pages · 结构化数据 · eventStatus