课程引用率翻倍的 45 天:CourseInstance 与 LearningResource 的数据对照
适用读者:职业教育与职业培训机构的站点负责人、负责课程站模板的前端与后端工程师、正在把结构化数据从「有」推到「能用」的 SEO 与 GEO 从业者。
一次内部数据周会,运营把线索明细表翻到最后几页,指着某一行说:这个月 AI 工具带来的咨询里,来源栏写的是两个第三方聚合站的名字,我们自己的课程详情页一次都没出现。页面上有完整的课程体系、师资介绍和报名入口,看上去什么都不缺。
当天下午我们把站内一门课从头到尾拆了一遍,问题不在内容质量,而在实体粒度:站点只有 Course(课程)名称页,「哪一期开班、线上还是线下、每周投入多少学时」全部写在正文排期表里,机器读不到;课件和样章只是一个指向对象存储路径的下载链接。AI 引擎回答「最近有没有班」这类问题时,宁可转引那些有时间有地点的聚合页。
接下来 45 天只动结构化数据,文案、外链、投放和价格都没变。下面是这 45 天的对照记录。
现场:改造前站点拿得出什么
定位这类问题的工具链不复杂,顺序比工具重要。第一步是 view-source 看原始 HTML,AI 抓取器大多拿的是未执行 JavaScript 的那一版;第二步是 Schema Markup Validator 和 Rich Results Test 各跑一遍;第三步是把页面上「人看得懂但机器读不到」的信息逐条抄下来,每条都问一句「它在 JSON-LD 里有没有位置」。

第三步抄出来的清单是这样的。
- 面包屑齐全,
Course节点存在,但节点里只有name、description、provider,没有hasCourseInstance。 - 开班排期是一张
<table>,单元格里写的是「9 月、10 月、11 月滚动开班」,属于纯自然语言。 - 班型信息在一张图上,图上写着「线下全日制 128 学时」和「线上晚班」,没有任何机器可读标记。
- 课件下载区有 6 个
<a>标签,地址是对象存储路径,锚文本是文件名,既没有类型也没有用途说明。
引擎因此拿不到一个可以被引用的句子。它能确认这家机构有这门课,也能复述课程名,但回答不了三个问题:下一期什么时候开,有没有线上班,零基础能不能直接上。这三类恰好是课程推荐类提问里占比最高的三档。
45 天对照:三个阶段的引用数据
观测方法写在改造之前,避免事后挑时间窗。我们固定了 60 条提问,课程推荐类 28 条、开班时间类 19 条、对比选择类 13 条;每天早上九点各引擎跑一轮,记录回答里的全部引用 URL,只有命中本站域名的才计数。第 16 天上线改造版,第 16 到 21 天走灰度只放 32 个页面,第 22 天全量。
| 观测项 | D1 到 D15 基线期 | D16 到 D30 上线期 | D31 到 D45 稳定期 |
|---|---|---|---|
| 有效提问轮次 | 900 | 900 | 900 |
| 本站被引用次数 | 174 | 213 | 380 |
| 日均被引用次数 | 11.6 | 14.2 | 25.3 |
| 落地在课程名页 | 174 | 96 | 141 |
| 落地在开班期次页 | 0 | 88 | 176 |
| 落地在课件样章页 | 0 | 29 | 63 |
| 回答中出现第三方聚合站的次数 | 561 | 402 | 246 |
上线期的数据低于后来,原因是一次渲染故障:第 18 到 21 天,startDate 的值被模板转义成了 HTML 实体形式,整段 JSON-LD 解析失败,那三天日均引用数掉到 8 上下。修掉之后才进入稳定期。这段插曲后面还会提到。
分引擎看,五个入口的涨幅并不一致,和各自挑候选页面的策略有关。
| 引擎 | D1 到 D15 | D31 到 D45 | 倍数 | 主要落地页类型 |
|---|---|---|---|---|
| Perplexity | 58 | 121 | 2.1 | 开班期次页 |
| ChatGPT 联网浏览 | 47 | 108 | 2.3 | 开班期次页 |
| Google AI Overviews | 31 | 54 | 1.7 | 课程名页与课件页 |
| 豆包 | 22 | 58 | 2.6 | 课件样章页 |
| 必应 Copilot | 16 | 39 | 2.4 | 开班期次页 |
| 合计 | 174 | 380 | 2.2 | 混合 |
Perplexity 和必应 Copilot 偏爱带明确日期的页面,九成以上落到具体期次。Google 侧更多把课程名页和课件页放进候选集,它有自己的实体库做兜底。同期表单线索里,来自 AI 工具的咨询从每周 34 条涨到 71 条,聚合站跳转线索从 55 条降到 21 条。一家机构一个品类,样本偏小,趋势只能当作量级参考。
Course 与 CourseInstance:两层实体的字段分工
Course 描述教学内容和知识脉络,CourseInstance(开班实例)描述某一次具体的交付:courseMode 是交付方式,startDate 与 endDate 是交付时间,location 是交付地点,offers 是这一期的价格。两者由 Course 上的 hasCourseInstance 往下连,反向用 isPartOf 往回指。
下表列出这次改造用到的字段,以及缺字段时引擎端的实际表现。
| 字段 | 归属类型 | 取值约束 | 缺失时的表现 |
|---|---|---|---|
| hasCourseInstance | Course | CourseInstance 数组 | 只知道有这门课,回答给不出开班时间 |
| courseMode | CourseInstance | Online、Onsite、Blended 受控词,也接受 URL | 有没有线上班匹配不到入口 |
| courseWorkload | CourseInstance | ISO 8601 时长,如 PT96H | 在职学员问每周投入得不到答案 |
| courseSchedule | CourseInstance | Schedule 对象,含 repeatFrequency 与 byDay | 每周上课节律完全不可知 |
| startDate 与 endDate | CourseInstance,继承自 Event | ISO 8601 日期,建议带时区 | 最近的班几月开,无句可引 |
| offers | CourseInstance | Offer 对象 | 价格类对比题缺可信来源 |
| educationalLevel | Course、LearningResource | Beginner、Intermediate、Advanced 或 DefinedTerm | 零基础提问匹配不到入门班型 |
| learningResourceType | LearningResource | 自由文本或 DefinedTerm | 样章被当成普通下载文件 |
| teaches | Course、LearningResource | 学习内容或能力点 | 回答只能复述课程名 |
嵌套关系用图看更清楚。一个 Course 节点可以同时挂多个 CourseInstance 和多个 LearningResource,全部靠 @id 锚定。
graph LR
subgraph 课程实体层
C[Course 节点] --> N1[name 与 description]
C --> L1[educationalLevel 定级]
C --> W1[provider 机构节点]
end
C -- hasCourseInstance --> I1[CourseInstance 第 2026 期]
C -- hasCourseInstance --> I2[CourseInstance 第 2027 期]
C -- hasPart --> R1[LearningResource 样章]
C -- hasPart --> R2[LearningResource 课件包]
subgraph 开班实例层
I1 --> M1[courseMode 交付方式]
I1 --> D1[startDate 与 endDate]
I1 --> S1[courseSchedule 每周节律]
I1 --> O1[offers 价格与报名入口]
I1 --> P1[instructor 讲师]
end
subgraph 学习资源层
R1 --> T1[learningResourceType 材料类型]
R1 --> T2[teaches 能力点]
end
I1 -- isPartOf --> C
I2 -- isPartOf --> C
写这组结构时有两条约束要死守。第一,@id 必须全站稳定且彼此不重复,推荐「页面 canonical 地址加井号片段」的写法,课程页用井号 course,期次页用井号 instance。第二,Course 节点在所有出现位置都要带上同一个 @id,否则同一门课会被判成多个互不相干的实体,引用记录被打散。
原理剖析:AI 引擎为什么偏爱有具体开班时间的实体
这一节解释前面那张数据表的成因。回答型引擎处理课程推荐任务时走的是「检索候选、抽取可引用片段、生成带引用的答案」这条链路,中间有三处判断会直接淘汰只有课程名的页面。
可核验三元组决定了页面能不能被引用
生成环节有一条硬约束:答案里每个事实都要能对应到被引用页面上的具体片段。问「最近有没有班」,需要的是一个「课程、开班日期、班型」三元组。课程名页只提供实体名,日期和班型两个槽位填不上,模型也不该替它补。
flowchart TD
A[提问 十一月零基础能不能学 UI 设计] --> B[检索候选页面]
B --> C{页面是否含 JSON-LD}
C -- 无 --> D[回退正文抽取]
C -- 有 --> E{是否含 CourseInstance}
D --> F[只拿到课程名字符串]
E -- 无 --> F
E -- 有 --> G[读到 startDate courseMode educationalLevel]
F --> H{能否拼出可核验三元组}
H -- 不能 --> I[降级转引第三方聚合页]
H -- 能 --> J[抽取可引用片段]
G --> J
J --> K[答案里直接引用本站期次页]
时间属性是最难补偿的一类槽位
缺失字段的补偿难度并不相同。课程概述、能力点这些可以从正文语义里推测,开班日期不行:它是一个必须精确、且会过期的离散值。就算引擎从别处知道这类课程通常按月开班,它也没有理由把推测结果挂到某个具体 URL 上。这也解释了那次渲染故障为什么代价高昂,startDate 解析失败在链路上的效果等同于把整个 CourseInstance 层从图里抹掉。
实体锚点让多层页面合并成一个对象
改造后有四类页面参与回答:课程名页、期次页、课件页、讲师页。没有 @id 时,引擎看到的是四份互不相干的材料,每次只能挑一份引用。有了 @id,四份数据指向同一个实体对象,引用路径变多,可信度也会叠加。表一里期次页引用数超过课程名页之后全站总引用数一起抬起来,就顺理成章了,两者不是此消彼长的关系。
新鲜度信号来自日期本身
startDate 既是答案内容,也是新鲜度信号。45 天里被引用的期次页,平均开班日距取样日 43 天;超过 180 天的远期期次和已经结课的期次,合计引用次数只有 11 次。期次该不该出现在 JSON-LD 里,取决于它离当天多近,而不是取决于它在数据库里是否存在。
课件与样章:LearningResource 的三个字段
学习资源(LearningResource)在 schema.org 里通常作为附加类型使用,和主类型一起出现,写法是 "@type": ["LearningResource", "DigitalDocument"]。它自带的属性不多,这次真正影响引用的是三个:learningResourceType 说明这是哪一类材料,educationalLevel 说明适合什么基础,teaches 说明学完能干什么。第三个字段被大量站点忽略,而它恰好是对比类提问里最常用的判断依据。
取值上我们定了一份内部词典全站统一,避免同一份 PPT 在不同页面被写成课件、讲义、handout 三种样子。
| 页面类型 | learningResourceType | educationalLevel | teaches 写法 |
|---|---|---|---|
| PDF 样章 | Handout | 取课程本身的定级 | 单章对应的一个可验证能力点 |
| 课前预习视频 | Video Recording | Beginner | 预习后应达到的操作水平 |
| 课后习题包 | Practice Problem Set | 与课程一致 | 覆盖的知识点,写数组 |
| 完整课件包 | Presentation | 与课程一致 | 整套能力清单 |
| 项目实战手册 | Project Brief | Advanced | 项目交付物的描述 |
样章页输出下面这段,带中文注释便于对照。依赖与环境:服务端直出即可,任何语言都能拼这段字符串;删掉注释行就是标准 JSON-LD,交付前用 https://validator.schema.org/ 校验一遍。
{
"@context": "https://schema.org",
// LearningResource 作为附加类型,和主类型一起写在数组里
"@type": ["LearningResource", "DigitalDocument"],
// 资源 @id 同样用「页面地址加井号片段」,和课程节点保持同前缀
"@id": "https://example.edu.cn/samples/uid-chapter3#resource",
// name 里带上所属章节,方便在回答里被单独摘出来
"name": "UI 设计就业班 第 3 章 栅格与间距规范 样章",
// url 指向的是给人看的样章介绍页,不是文件地址
"url": "https://example.edu.cn/courses/ui-design-foundation/sample-chapter-3",
// 材料类型取自内部词典,全站同一份材料只用一个词
"learningResourceType": "Handout",
// 定级与所属课程一致,避免同一门课出现两个层次
"educationalLevel": "Beginner",
// teaches 写一个可验证的能力点,不写营销 slogan
"teaches": "栅格系统、间距倍数与组件对齐规范的落地方法",
// 前置要求写明,减少零基础学员误报
"competencyRequired": "掌握设计工具的基础操作",
// educationalUse 标明这是预览用途,不是正式课件
"educationalUse": "preview",
// 学习时长同样用 ISO 8601,四十分钟
"timeRequired": "PT40M",
"inLanguage": "zh-CN",
// 免费样章显式声明,付费资源另有 offer 节点
"isAccessibleForFree": true,
// 反向挂到课程节点,让样章和课程合并进同一张图
"isPartOf": { "@id": "https://example.edu.cn/courses/ui-design-foundation#course" },
"publisher": { "@id": "https://example.edu.cn#org" },
// 实体文件用 associatedMedia 描述,不要把下载链接直接当资源本体
"associatedMedia": {
"@type": "MediaObject",
"contentUrl": "https://cdn.example.edu.cn/samples/uid-chapter3.pdf",
"encodingFormat": "application/pdf"
}
}
落地:Course 加 CourseInstance 的嵌套 JSON-LD
依赖与环境:任意能输出 HTML 的服务端均可;下面这段是带注释的 jsonc 版本,上线时由模板生成不带注释的标准 JSON。交付前除了 https://validator.schema.org/ 的语法校验,还要用 Google Rich Results Test 看谷歌侧实际识别到哪些项。
课程名页与期次页在这一版里输出同一份 @graph,只是期次页把自家 CourseInstance 放在数组第一位。这样任何一页单独被抓到,图都是完整的。
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Course",
// 课程节点 @id 全站不重复:canonical 地址加井号片段
"@id": "https://example.edu.cn/courses/ui-design-foundation#course",
// name 与页面 h1 保持字面一致,差一个别名都会削弱实体一致性
"name": "UI 设计就业班",
// description 写满一句完整的话,太短会被判为信息不足
"description": "面向零基础转行学员的界面设计课程,覆盖设计基础、规范、作品集与项目实战。",
// canonical 地址,不带查询参数和来源标记
"url": "https://example.edu.cn/courses/ui-design-foundation",
// 内部课程编号,跨期次稳定不变
"courseCode": "UID-2026",
// 受控词,不写本地化的字面量
"educationalLevel": "Beginner",
"inLanguage": "zh-CN",
// 能力点写数组,便于逐条抽取
"teaches": ["栅格规范", "组件化思维", "作品集叙事"],
// 机构节点单独放在图里,这里只引用它的 @id
"provider": { "@id": "https://example.edu.cn#org" },
"hasCourseInstance": [
{
"@type": "CourseInstance",
// 期次 @id 与课程同前缀,片段部分换成 instance
"@id": "https://example.edu.cn/courses/ui-design-foundation/2026-11-online#instance",
// 期次页的 canonical 地址,独立 URL 便于被单独引用
"url": "https://example.edu.cn/courses/ui-design-foundation/2026-11-online",
// 期次名称带年份月份,和仅写课程名的页面区分开
"name": "UI 设计就业班 2026 年 11 月线上班",
// 交付方式,Online 表示全程在线
"courseMode": "Online",
// ISO 8601 日期,引擎可以直接比较先后
"startDate": "2026-11-09",
"endDate": "2027-01-29",
// 总学时用 ISO 8601 时长表示
"courseWorkload": "PT96H",
"inLanguage": "zh-CN",
// 反向指回课程,双向连接都成立
"isPartOf": { "@id": "https://example.edu.cn/courses/ui-design-foundation#course" },
"instructor": { "@type": "Person", "name": "林一波", "jobTitle": "设计负责人" },
// 线上班用 VirtualLocation,不要退化成 Place
"location": { "@type": "VirtualLocation", "url": "https://live.example.edu.cn/rooms/uid-202611" },
// 每周节律:每周二周四晚七点半到九点半
"courseSchedule": { "@type": "Schedule", "repeatFrequency": "P1W", "byDay": ["https://schema.org/Tuesday", "https://schema.org/Thursday"], "startTime": "19:30", "endTime": "21:30" },
// 报名入口,价格和可用状态都挂在这一条 Offer 上
"offers": { "@type": "Offer", "price": "6980", "priceCurrency": "CNY", "availability": "https://schema.org/InStock", "url": "https://example.edu.cn/enroll/uid-202611-online" }
},
{
"@type": "CourseInstance",
// 第二个期次,同一门课的另一个交付形态
"@id": "https://example.edu.cn/courses/ui-design-foundation/2026-11-onsite#instance",
"url": "https://example.edu.cn/courses/ui-design-foundation/2026-11-onsite",
// 名称里写清校区与班型,便于被引擎直接摘取
"name": "UI 设计就业班 2026 年 11 月线下全日制班",
// 线下班取 Onsite,混合班取 Blended
"courseMode": "Onsite",
"startDate": "2026-11-16",
"endDate": "2027-02-12",
"courseWorkload": "PT128H",
"isPartOf": { "@id": "https://example.edu.cn/courses/ui-design-foundation#course" },
// 线下必须有 Place,地址至少写到市级
"location": { "@type": "Place", "name": "城东校区 3 号实训楼", "address": { "@type": "PostalAddress", "addressLocality": "杭州" } },
// 余量不足时降级为 LimitedAvailability
"offers": { "@type": "Offer", "price": "12800", "priceCurrency": "CNY", "availability": "https://schema.org/LimitedAvailability" }
}
]
},
{
"@type": "EducationalOrganization",
// 机构节点全站共用一个 @id,避免同名组织被判成多个实体
"@id": "https://example.edu.cn#org",
"name": "示例职业培训学校",
"url": "https://example.edu.cn"
}
]
}
.NET Razor 输出片段
依赖与环境:.NET 8 的 ASP.NET Core Razor Pages,序列化走内置 System.Text.Json,不用装任何包。有一处容易忽略的细节:JsonSerializer 默认把中文转成转义字符,解析没问题,但排查时肉眼没法比对,所以要显式指定放宽模式。
// 依赖与环境:.NET 8 加 ASP.NET Core Razor Pages,System.Text.Json 内置
// 文件位置:Pages/Course/Detail.cshtml.cs
// 用法:模板里用 @Html.Raw(Model.JsonLd) 把这段字符串原样输出
using System.Text.Json;
// 放宽中文转义必须引用这个命名空间
using System.Text.Encodings.Web;
// 课程详情页的页面模型,只负责拼图,不做业务判断
namespace CourseSite.Pages.Course;
public sealed class DetailModel : PageModel
{
// 仓储层负责课程、期次与讲师的联表查询
private readonly ICourseRepository _courses;
// 构造器注入,序列化选项在下面按需创建,不做静态共享
public DetailModel(ICourseRepository courses) => _courses = courses;
// 模板侧只需要这一个字符串
public string JsonLd { get; private set; } = "{}";
public async Task<IActionResult> OnGetAsync(string slug, CancellationToken ct)
{
var course = await _courses.GetBySlugAsync(slug, ct);
if (course is null)
{
// 课程不存在时不输出任何结构化数据,避免只有壳的空节点
return NotFound();
}
// 只渲染窗口内的期次:未来 120 天开班,或结课不到 14 天
var today = DateTime.UtcNow;
var window = await _courses.GetInstancesAsync(
course.Id, today.AddDays(-14), today.AddDays(120), ct);
// 拼图与序列化分两步,方便单测直接断言 BuildCourseGraph 的产物
var graph = BuildCourseGraph(course, window);
JsonLd = JsonSerializer.Serialize(graph, new JsonSerializerOptions
{
// 默认会把中文转成转义字符,这里放宽成可读形式
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = true
});
return Page();
}
private const string SiteRoot = "https://example.edu.cn";
private static Dictionary<string, object> BuildCourseGraph(
CourseDto course, IReadOnlyList<InstanceDto> instances)
{
// 课程 @id:canonical 地址加井号片段,全站不重复
var courseId = $"{SiteRoot}{course.CanonicalPath}#course";
// 课程节点单独构造,结构复杂时不建议内联在属性初始化器里
var courseNode = BuildCourseNode(courseId, course);
var instanceNodes = instances
// 期次按开班日升序,最早的一期排最前,方便引擎取最近一期
.OrderBy(i => i.StartDate)
.Select(i => BuildInstanceNode(courseId, i))
.ToList();
// 窗口内没有期次时不挂这个键,避免输出空数组
if (instanceNodes.Count > 0)
{
courseNode["hasCourseInstance"] = instanceNodes;
}
return new Dictionary<string, object>
{
// @graph 承载多个节点,节点之间靠 @id 互相引用
["@context"] = "https://schema.org",
// 机构节点单独抽出,全站共用一个 @id
["@graph"] = new List<object>
{
courseNode,
new Dictionary<string, object>
{
// 教学机构类型,写成 Person 会让机构画像缺一层
["@type"] = "EducationalOrganization",
["@id"] = $"{SiteRoot}#org",
["name"] = course.ProviderName,
["url"] = SiteRoot
}
}
};
}
private static Dictionary<string, object> BuildCourseNode(string courseId, CourseDto course)
{
return new Dictionary<string, object>
{
// 类型与锚点,锚点已在 BuildCourseGraph 里算好
["@type"] = "Course",
["@id"] = courseId,
// name 与页面 h1 保持字面一致
["name"] = course.Name,
["description"] = course.Description,
// canonical 地址,不带查询参数
["url"] = $"{SiteRoot}{course.CanonicalPath}",
// 课程编号,跨期次稳定不变
["courseCode"] = course.CourseCode,
["inLanguage"] = "zh-CN",
// 只用受控词,数字档位在业务层映射,不让模板写死中文
["educationalLevel"] = course.Level switch
{
0 => "Beginner",
1 => "Intermediate",
_ => "Advanced"
},
// teaches 写可验证的能力点,不写营销语
["teaches"] = course.Competencies.ToArray(),
// 机构只引用 @id,不在这里重复声明一份组织信息
["provider"] = new Dictionary<string, object> { ["@id"] = $"{SiteRoot}#org" }
};
}
private static Dictionary<string, object> BuildInstanceNode(string courseId, InstanceDto ins)
{
// 期次 @id 与课程 @id 同前缀,片段部分换成 instance
return new Dictionary<string, object>
{
// 类型与锚点,锚点用期次页自己的 canonical 地址拼
["@type"] = "CourseInstance",
["@id"] = $"{SiteRoot}{ins.CanonicalPath}#instance",
["url"] = $"{SiteRoot}{ins.CanonicalPath}",
// 标题带年月和班型,和仅写课程名的节点区分开
["name"] = ins.Title,
// 交付方式用受控词,不要自创字典外的词
["courseMode"] = ins.IsOnline ? "Online" : "Onsite",
// ISO 8601 时长,一百二十八学时写成 PT128H
["courseWorkload"] = $"PT{ins.TotalHours}H",
// 日期必须是年月日的 ISO 8601 形式
["startDate"] = ins.StartDate.ToString("yyyy-MM-dd"),
["endDate"] = ins.EndDate.ToString("yyyy-MM-dd"),
["inLanguage"] = "zh-CN",
// 反向指回课程,双向连接成立
["isPartOf"] = new Dictionary<string, object> { ["@id"] = courseId },
// 讲师写成 Person,带职务信息有助于实体画像
["instructor"] = new Dictionary<string, object> { ["@type"] = "Person", ["name"] = ins.InstructorName, ["jobTitle"] = ins.InstructorTitle },
// 线上线下走两个分支,线上用 VirtualLocation 而不是 Place
["location"] = ins.IsOnline
? new Dictionary<string, object> { ["@type"] = "VirtualLocation", ["url"] = ins.LiveRoomUrl }
: new Dictionary<string, object> { ["@type"] = "Place", ["name"] = ins.CampusName, ["address"] = new Dictionary<string, object> { ["@type"] = "PostalAddress", ["addressLocality"] = ins.City } },
// 每周重复一次,星期取值用 schema.org 的 DayOfWeek 枚举地址
["courseSchedule"] = new Dictionary<string, object> { ["@type"] = "Schedule", ["repeatFrequency"] = "P1W", ["byDay"] = ins.WeekDays, ["startTime"] = ins.StartTime, ["endTime"] = ins.EndTime },
["offers"] = new Dictionary<string, object>
{
["@type"] = "Offer",
// 价格保留整数,避免浮点尾数造成反复变更
["price"] = ins.Price.ToString("F0"),
["priceCurrency"] = "CNY",
// 余量不足时降级成 LimitedAvailability,不要继续标现货
["availability"] = ins.SeatsLeft > 3
? "https://schema.org/InStock"
: "https://schema.org/LimitedAvailability",
// 报名落地页,指向这一期而不是课程泛页
["url"] = ins.EnrollUrl
}
};
}
}
模板侧只需要一行 @Html.Raw。还有一条纪律:注释不要写进 script 标签内部,那是数据区,写了会被当成 JSON 的一部分。
@* 依赖与环境:ASP.NET Core Razor Pages 的 .cshtml 模板,运行时 .NET 8 *@
@model CourseSite.Pages.Course.DetailModel
<!-- JSON-LD 放进 head,正文 DOM 调整不会波及它 -->
<!-- 必须走 Html.Raw,否则双引号被转义成实体名称导致整段解析失败 -->
@section Head {
<script type="application/ld+json">
@Html.Raw(Model.JsonLd)
</script>
}
<!-- 正文要让机器读到与 JSON-LD 一致的信息,肉眼看不见的字段也要落进 DOM -->
<article>
<!-- h1 与 Course 的 name 保持字面一致,差一个别名都会削弱实体一致性 -->
<h1>@Model.Course.Name</h1>
<!-- 排期表同时服务于人和机器,两种读法的数据必须同源 -->
<table>
<!-- 排期表每一行的期次,都要能在 JSON-LD 里找到同 startDate 的节点 -->
@foreach (var ins in Model.Instances)
{
<tr>
<!-- 期次名称与 CourseInstance 节点的 name 一致 -->
<td>@ins.Title</td>
<!-- 班型文案与 courseMode 受控词一一对应,一个班型只对一个词 -->
<td>@(ins.IsOnline ? "线上直播" : "线下面授")</td>
<!-- 时间用 time 标签带 datetime 属性,给不读 JSON-LD 的解析器留一条路 -->
<td><time datetime="@ins.StartDate.ToString("yyyy-MM-dd")">@ins.StartDateText</time></td>
<!-- 学时与 courseWorkload 的数值必须同源,避免两处口径不一致 -->
<td>@ins.TotalHours 学时</td>
<!-- 报名链接指向当期自己的报名地址,不要统一跳到报名总页 -->
<td><a href="@ins.EnrollUrl">报名</a></td>
</tr>
}
</table>
</article>
回归观测:把引用变化记成数据
依赖与环境:PostgreSQL 14 以上,采样脚本每天一轮,结果写 ai_citation_log 表,字段包含引擎名、引用排名、落地页类型和距开班日的天数。
-- 依赖与环境:PostgreSQL 14 以上,结果写入 ai_citation_log
-- 统计稳定期各落地页类型的被引用情况
SELECT page_type,
-- 落地页类型:课程名页、期次页、课件页
COUNT(*) AS cite_count,
-- 覆盖了多少个不同引擎,太少说明这条路径不具备普适性
COUNT(DISTINCT engine) AS engine_count,
-- 首条引用率:引擎把本站放在引用列表第一位时的比例
ROUND(100.0 * COUNT(*) FILTER (WHERE rank_position = 1) / COUNT(*), 1) AS first_rate,
-- 平均距开班天数,用来观察新鲜度衰减
ROUND(AVG(days_to_start), 1) AS avg_days_to_start
FROM ai_citation_log
WHERE sampled_at >= '2026-08-14' -- 稳定期起点
AND sampled_at < '2026-08-29' -- 左闭右开,避免边界日重复计数
AND host = 'example.edu.cn' -- 只统计本站域名,排除聚合站
-- 抓取失败或回答为空的轮次在入库时已剔除
-- 按落地页类型聚合,观察哪一类页面真正被引用
GROUP BY page_type
-- 引用次数降序,排最前的就是改造真正带来收益的页面类型
ORDER BY cite_count DESC;
这张表跑出来的结果直接成了第二周周会上那一页材料,它把「感觉变好了」换成了可以复核的行数。
三个容易踩的坑
@Html.Raw 漏写。 Razor 默认做 HTML 转义,输出的 JSON-LD 里双引号变成实体名称,整段解析失效,上线期那三天的数据就是这么丢的。上线清单里应该加一条:curl 拿回源始 HTML,用肉眼确认脚本块里没有转义实体。
历史期次全量输出。 数据库里攒了三年的期次全部塞进 hasCourseInstance,单页 JSON 体积膨胀到几百 KB,还会把已经结课的班推给引擎,用户点进去发现报名入口失效。做法是在仓储层按时间窗口过滤,历史页面保留 URL、不再输出节点。
educationalLevel 中英混写。 同一门课在课程页写 Beginner,在课件页写中文同等词,两个值落成两个字符串,实体合并时会被当成两个层次。内部词典定一种写法,页面上的中文说明照常保留,但结构化数据里必须是同一个值。
这套改动的成本主要在后端组装层,前端只是把已有的排期信息换一遍输出。样本只有一家机构和一个课程品类,别的品类建议先照这套观测方法跑两周基线,拿到自己的基线数再动手。评论区可以交流 courseMode 受控词怎么和站点自身的班型字典对齐。
参考与延伸
- schema.org CourseInstance:https://schema.org/CourseInstance
- schema.org LearningResource:https://schema.org/LearningResource
- Google 搜索中心课程结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/course
- 结构化数据语法校验工具:https://validator.schema.org/
GEO、AI优化AIO、CourseInstance、LearningResource、JSON-LD、嵌套图结构、生成式引擎优化、引用归属