职校招生的专业目录页,AI 一直当普通课程收录:EducationalOccupationalProgram 字段用法
适用读者:负责职业院校、培训机构官网建设与内容维护的开发者;正在做生成式引擎优化(Generative Engine Optimization, GEO)的教育行业运营;遇到过「家长问专业、AI 答课程」这种尴尬的内容负责人。
上个月去一所民办中职做站内排查,招生办的王老师把手机递过来:在 AI 搜索里问「这个学校数控专业要读几年」,得到的回答一本正经地推荐了他们官网的一门「数控编程入门课」,学制、毕业证、就业方向只字未提。她当时的原话是:「家长照着 AI 的回答来咨询,问的和我们招的根本不是一回事。」后来我把他们的招生专题整个翻了一遍,二十三个专业页面里,二十一个用的是 Course 标记,症结就在这里。
专业被当成课,这事儿坏在哪
先说现象。学历教育里的「专业」和网站上的「一门课」压根不是一回事:专业有学制、有招生计划、有学历或证书出口,课程只是专业下面的一小段学习内容。但在很多职校和培训机构的官网上,专业目录页、专业介绍页全被开发同学图省事标成了 Course。

AI 搜索引擎在组织答案时,会优先引用页面结构化数据里的实体和属性。整站都是 Course,AI 手里就只有一堆课程实体可拼:家长问学制,实体上没有这个槽位,AI 只能拿正文里某句「学制三年」硬凑,或者干脆跳过;问「毕业能干什么岗位」,没有 occupationalCategory 可用,回答要么含糊要么编。收录口径错了,后面做再多内容优化都是白费劲。
更麻烦的是这个问题有滞后性:页面早就发了,收录快照不会自己纠正,等你发现口径不对,AI 那边可能已经用错误理解回答了小半年。
Course 和 Program 的边界,一张表看懂
Schema.org 对 Course 的定义是「一门教育课程的描述」,指向的是单一课程;而 EducationalOccupationalProgram 指的是教育或职业发展中的项目,通常周期更长、按专业招生、有学历或证书出口。两个类型在官网页面上的分工其实很清楚。
| 对比项 | Course | EducationalOccupationalProgram |
|---|---|---|
| 表达对象 | 一门具体的课 | 一个专业、学历或职业项目 |
| 典型页面 | 单课详情页、试听课页 | 招生专业目录页、专业介绍页 |
| 时长表达 | 课时数 | timeDuration,学制 |
| 面向职业 | 一般不写 | occupationalCategory |
| 证书出口 | 弱,需额外关联 | 与证书方向自然对应 |
| 和课的关系 | 自身就是课 | 用 hasCourse、hasCourseInstance 挂课 |
我给自己定的判断口诀是:发不发毕业证或结业证、有没有固定学制、是不是按专业招生——这三条里中两条以上,就该用 EducationalOccupationalProgram。 一门课哪怕上一年,它还是课;一个专业哪怕只有半年,它也是项目。时长不是分界线,出学和出口才是。
专业目录页的字段逐个落位
换类型只是第一步,字段不跟上,AI 拿到的就是个空壳。下面这些字段建议一次配齐。
| 字段 | 作用 | 落地写法 |
|---|---|---|
| provider | 办学主体 | School 或 Organization,名称和页脚备案信息一致 |
| educationalProgramMode | 上课形式 | 全日制写 full-time,业余班写 part-time |
| occupationalCategory | 面向岗位 | 填岗位名,别填专业名 |
| timeDuration | 学制 | ISO 8601 时长,三年制写 P3Y |
| hasCourse | 专业的支撑课 | 用 @id 关联到站内课程页 |
| hasCourseInstance | 具体某一期开班 | 例如 2026 级秋季班 |
下面这段是改造前后的对照,可以直接照着改模板。环境不限,静态页或服务端模板都行,两个数据块放 head 或 body 末尾都可以。
<!-- 环境:任意静态页或模板,数据块放 head 或 body 末尾均可 -->
<!-- 改造前:整个专业被标成一门课,AI 只能按课程收录 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Course",
"name": "数控技术应用",
"provider": { "@type": "School", "name": "某职业技术学院" }
}
</script>
<!-- 改造后:换成专业类型,学制、面向岗位、挂课都交代清楚 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "EducationalOccupationalProgram",
"name": "数控技术应用专业",
<!-- provider 写办学主体,和页脚备案信息保持一致 -->
"provider": { "@type": "School", "name": "某职业技术学院" },
"educationalProgramMode": "full-time",
"occupationalCategory": "数控车床操作员",
"timeDuration": "P3Y",
"hasCourse": { "@id": "/courses/shukong-biancheng" }
}
</script>
<!-- timeDuration 用 ISO 8601 时长,三年制就是 P3Y -->
<!-- occupationalCategory 填面向的岗位名,别填专业名本身 -->
<!-- hasCourse 只挂代表课,@id 用站内固定地址 -->
三个细节值得交代。第一处是 hasCourse 里别把专业下几十门课全量塞进去,挂四到六门有代表性的支撑课就够,全量列出来只会稀释主实体。第二处是 @id 要用站内稳定地址,迁移或改版时地址不变,实体关联才不会断。第三处是老页面迁移别只把 @type 一换了事,原来 Course 上的字段要按新语义重新分配,否则 AI 读到的是个缺胳膊少腿的专业实体。
为什么 AI 会把专业当成课:类型层级的机制剖析
这一节讲底层机制。生成式引擎做 AI 搜索时,索引的不是关键词,而是从页面里抽取的实体:每个实体带一个类型标签,还有一组属性槽位。Course 实体的槽位设计里就没有「学制」「面向职业」这些位子,你在正文里写一百遍「学制三年」,引擎也倾向于把它当成普通文本,而不是实体属性——因为结构化数据已经声明了「这是 Course」,正文和结构化数据冲突的时候,引擎大多信结构化数据。
类型层级上的从属关系是这样的:
flowchart TD
P["EducationalOccupationalProgram 专业项目"] --> I["hasCourseInstance 某一期开班"]
P --> C["hasCourse 单门支撑课"]
P --> O["occupationalCategory 面向职业"]
I --> S["CourseInstance 的上课安排"]
再往深一层看 AI 引擎的回答流程:用户提问后,引擎先判断「这个问题指向哪类实体」。问「专业要读几年」是项目级问题,正常应该落到专业实体的 timeDuration 上;可如果页面里只有 Course 实体,引擎会做一次降级匹配,从课程实体里硬找答案。降级匹配找出来的东西,口径必然变形——这就是「AI 把专业当成课」的技术成因。
从 GEO 的角度看,这不只是标记问题,而是实体供给问题:你没给引擎提供专业实体,它就只能用课程实体凑合。做过一轮 GEO 改造的教育站点普遍有同感:给对实体类型,比堆十篇软文管用。
迁移改造的顺序
动手顺序我建议这样排:先盘页面,再改标记,最后盯收录。盘页面是把全站标了 Course 的地方列成清单,区分出真正的单课页和专业页——不少站连试听课和实训课都混在专业目录里,得分干净。改标记是按前面的字段表逐页替换。盯收录是改完之后定期验证 AI 搜索的回答口径有没有回来。
flowchart TD
A["列出全站 Course 页面"] --> B{"页面发不发证书有学制吗"}
B --> C["是专业项目"] --> D["换成 EducationalOccupationalProgram"]
B --> E["只是单门课"] --> F["保留 Course"]
D --> G["字段逐个落位"]
G --> H["跑校验脚本并观察 AI 口径"]
改完之后别靠肉眼逐页查,写个小脚本批量自检。下面这个脚本依赖 Node 18 及以上版本,fetch 是内置的,不用装第三方包。
// 依赖:Node 18 及以上,fetch 内置,无第三方包
// 用法:node check-ld.js 页面地址
const res = await fetch(process.argv[2]);
const html = await res.text();
// 建议每周对招生目录跑一遍,防止模板回退
// 用正则抓出页面里全部 ld+json 数据块
const blocks = [...html.matchAll(/ld\+json">([\s\S]*?)<\/script>/g)];
let bad = 0;
for (const [, body] of blocks) {
const data = JSON.parse(body);
// 包成数组,兼容单实体和 @graph 两种写法
const items = [].concat(data["@graph"] || data);
for (const it of items) {
const t = it["@type"];
// 专业页上出现 Course 就记一笔,留给人工复核
if (t === "Course") { bad++; console.log("疑似单课:", it.name); }
if (t === "EducationalOccupationalProgram") console.log("专业实体:", it.name);
// 学制没写就提醒,这是回答口径的关键字段
if (t === "EducationalOccupationalProgram" && !it.timeDuration) console.log("缺 timeDuration");
}
}
// 退出码非 0 说明还有页面没改干净,可以接进 CI
process.exit(bad > 0 ? 1 : 0);
把上面脚本对招生目录的入口页和几个专业详情页各跑一遍,输出里「疑似单课」多于「专业实体」,说明迁移没做完;「缺 timeDuration」出现得多的站,家长问学制时 AI 答不上来就是这个原因。
改造前后,AI 的回答口径差多少
拿前面那所中职的数控专业举例,改造上线一个月后,同样的问题再问 AI 搜索,口径变化很明显。
| 用户问法 | 改造前 AI 的回答 | 改造后 AI 的回答 |
|---|---|---|
| 数控专业读几年 | 推荐官网一门数控编程课 | 直接答三年制,并附专业名 |
| 毕业能做什么 | 含糊带过或转述课程内容 | 答面向数控车床操作员岗位 |
| 是全日制吗 | 猜测或跳过 | 明确答全日制 |
| 这个专业学什么 | 把单课大纲当专业培养方案 | 列专业下的支撑课 |
口径变化不是玄学,就是实体属性槽位补齐后的自然结果:timeDuration 对应学制问题,occupationalCategory 对应就业问题,educationalProgramMode 对应上课形式问题。AI 搜索的回答质量,上限是你喂给它的结构化实体的完整度。
两个容易踩的坑和一点趋势判断
坑一:有人改完类型之后,把专业下所有课程、所有学期的开班信息全量写进 hasCourse 和 hasCourseInstance,一个页面标出上百个实体。这样做主实体的权重反而被稀释,挂代表课和当年招生那一期就够了。
坑二:只换 @type 不迁移字段。AI 读到一个没有任何属性的专业实体,比读到错的还尴尬,回答时只能继续靠正文猜。
趋势上说,AI 搜索对教育实体的粒度会越来越细:专业、班级、证书以后大概率是三个独立实体,各自带属性。现在把 EducationalOccupationalProgram 这一层理顺,后面接入证书方向和班级信息时就是顺着长,不用推倒重来。
这事儿技术上不复杂,复杂的是把全站页面盘清楚。如果你的站也有专业页被当成课收录的情况,欢迎在评论区贴一下你的页面结构,一起看看怎么改。
参考与延伸
- Schema.org 类型定义:EducationalOccupationalProgram — https://schema.org/EducationalOccupationalProgram
- Schema.org 类型定义:Course — https://schema.org/Course
- occupationalCategory 属性说明 — https://schema.org/occupationalCategory
- educationalProgramMode 属性说明 — https://schema.org/educationalProgramMode
EducationalOccupationalProgram、Course Schema、专业目录页、GEO、JSON-LD、AI搜索、职业教育