课程引用率翻倍的 45 天:CourseInstance 与 LearningResource 的数据对照

2026-09-19 09:59:23 10 次浏览
结构化数据JSON-LD生成式引擎优化课程SchemaAI优化AIOGEO

适用读者:职业教育与职业培训机构的站点负责人、负责课程站模板的前端与后端工程师、正在把结构化数据从「有」推到「能用」的 SEO 与 GEO 从业者。

一次内部数据周会,运营把线索明细表翻到最后几页,指着某一行说:这个月 AI 工具带来的咨询里,来源栏写的是两个第三方聚合站的名字,我们自己的课程详情页一次都没出现。页面上有完整的课程体系、师资介绍和报名入口,看上去什么都不缺。

当天下午我们把站内一门课从头到尾拆了一遍,问题不在内容质量,而在实体粒度:站点只有 Course(课程)名称页,「哪一期开班、线上还是线下、每周投入多少学时」全部写在正文排期表里,机器读不到;课件和样章只是一个指向对象存储路径的下载链接。AI 引擎回答「最近有没有班」这类问题时,宁可转引那些有时间有地点的聚合页。

接下来 45 天只动结构化数据,文案、外链、投放和价格都没变。下面是这 45 天的对照记录。

现场:改造前站点拿得出什么

定位这类问题的工具链不复杂,顺序比工具重要。第一步是 view-source 看原始 HTML,AI 抓取器大多拿的是未执行 JavaScript 的那一版;第二步是 Schema Markup Validator 和 Rich Results Test 各跑一遍;第三步是把页面上「人看得懂但机器读不到」的信息逐条抄下来,每条都问一句「它在 JSON-LD 里有没有位置」。

在线课程播放器与开班日历

第三步抄出来的清单是这样的。

  • 面包屑齐全,Course 节点存在,但节点里只有 namedescriptionprovider,没有 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 是交付方式,startDateendDate 是交付时间,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、嵌套图结构、生成式引擎优化、引用归属

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