招聘页也能进 AI 的答案:JobPosting 结构化改造的 45 天引用对照
适用读者:制造业、连锁零售、本地服务这类靠官网自己招人的企业里做站的前端或后端;招聘页现在还是一张长图或者一段纯文字,想让 AI 搜索在回答「某某厂招什么岗」「某某岗位待遇多少」时能提到你家的。
客户是做汽配冲压件的一家中型厂,HR 每月手工维护一次岗位,Word 排好版交给外包转成一张 JPG 贴到官网。七月中旬我们把招聘页换成了结构化模板,岗位内容一个字没改,只是加了 JobPosting 标注。45 天后拿 30 组问题去问几个 AI 搜索入口,90 次提问里回答提及官网的次数从 2 次变成 14 次。
一张 JPG 在引擎眼里等于没有内容
先说清一件事:那张招聘长图不是「内容少」,是根本没进抽取环节。图片 alt 是空的,文件名叫 recruit202607.jpg,就算 OCR 认出了字,也拿不到字段归属——「五险一金」这四个字旁边没有任何东西告诉引擎这是待遇还是企业文化。

生成式引擎优化(Generative Engine Optimization, GEO)的语境下,AI 搜索做的是属性级对齐(property-level alignment):问「待遇多少」去找 baseSalary 槽位,问「要不要倒班」去找 employmentType 槽位。槽位空着,引擎退回去抓正文段落,抓不到就整页跳过。
改之前我们实测过一次,问「这家厂招不招焊工」,回答是「未找到相关招聘信息,建议前往招聘平台查询」。岗位明明在官网上挂了半年。
七个字段各自接住哪类提问
招聘信息(JobPosting)这个类型的属性不少,但真正决定能不能被引用的就那几个。下面这张表是改造时的字段配置,左边是字段,右边是它对应的提问意图。
| 字段 | 落地写法 | 接住哪类提问 | 常见错法 |
|---|---|---|---|
| title | 岗位名 + 括号限定词,如「数控车工(两班倒)」 | 招什么岗位 | 写「急招!多名」 |
| hiringOrganization | Organization 实体,带 sameAs 指回官网 | 哪家企业在招 | 只写公司简称字符串 |
| jobLocation | Place 包 PostalAddress,精确到区 | 某地有什么岗位 | 只写城市名 |
| baseSalary | MonetaryAmount 含 unitText 计薪方式 | 待遇多少、月薪多少 | 写「面议」 |
| employmentType | 受控值 FULL_TIME / PART_TIME | 是不是临时工、要不要倒班 | 写「全职兼职皆可」 |
| datePosted | ISO 8601 日期 | 最近有没有新岗位 | 留空或写「长期有效」 |
| validThrough | ISO 8601 日期,过期自动失效 | 这个岗位还招吗 | 不填,导致长年挂着 |
title 写岗位名,别写招聘话术
HR 原始表格里岗位列写的是「急招:冲压工数名(待遇从优)」。这种写法对引擎是噪音,「急招」和「从优」都不构成可对齐的属性值。
我们定的规则是岗位名加一个括号限定词,限定词只允许是班次、工种方向或者厂区位置。「数控车工(两班倒)」「质检员(三坐标)」这类写法和主流招聘平台上的岗位库高度重叠,词汇重叠度(lexical overlap)上去了,引擎做实体对齐时更容易认定是同一个东西。
baseSalary 写区间,别写面议
「面议」是遇到最多的填法,HR 的理由是怕写死了不好谈。但槽位空着,AI 回答「待遇多少」时就只能复述一句「薪资面议」,等于没答。
改成区间之后的规则是:下界写实发下限,上界写实发上限,unitText 显式写 MONTH 或 HOUR,别指望引擎猜。计薪方式这块尤其要紧,厂里小时工和月薪制是混着招的,不写清楚会被直接误读成月薪。
employmentType 用受控值
这个字段 schema.org 给了枚举:FULL_TIME、PART_TIME、CONTRACTOR、TEMPORARY、INTERN。页面上的「全职」「临时工」「实习生」要映射过去,不能直接把中文填进去。
过期岗位比没有岗位更麻烦
招聘页最容易积累脏数据的地方就在这里。HR 一个月更新一次,招满的岗位经常忘了下,页面上常年挂着十几个已经不招的岗。
AI 引擎对内容新鲜度(freshness)的敏感程度超出我们预期。一个 validThrough 已经过期半年的岗位,不只是自己不被引用,还会把整页可信度往下拖——引擎会认为这个站点的内容维护是断的。
我们的处理是把它做成自动的:
- 岗位记录的 validThrough 由发布日期加默认有效期算出,默认 60 天;
- 过期后页面不删除,JSON-LD 里整段 JobPosting 直接不输出,正文保留一句「该岗位已结束招聘」;
- 每晚跑一遍巡检,把 7 天内即将过期的岗位推给 HR 确认延期还是下架。
最后一条是关键。HR 每月手工维护这件事改变不了,那就让提醒去找人,别指望人去记日期。
从 HR 的月度表格到结构化页面
这里说下数据流转。HR 交的是 Excel,我们没让她改表格结构,只在模板里加了几列,剩下的交给转换环节。环境:.NET 8 / C# 12,依赖 ClosedXML 读 xlsx、Newtonsoft.Json 拼 JSON-LD。
flowchart LR
A[HR 月度 Excel] --> B[导入器读取并做列校验]
B --> C{必填列是否齐全}
C -->|缺列| D[整行跳过并写告警日志]
C -->|齐全| E[映射为 JobPosting 草稿实体]
E --> F[计算 validThrough 默认有效期]
F --> G[发布到岗位页 JSON-LD]
G --> H[夜间巡检: 过期则停止输出]
H --> I[HR 收到延期或下架提醒]
下面是导入器里算有效期和拼实体的部分,注释写得比代码多,因为这块是后来改得最频繁的地方。
// env: .NET 8 / C# 12
// 依赖: ClosedXML 0.102 (读 xlsx), Newtonsoft.Json 13.0.3 (拼 JSON-LD)
using Newtonsoft.Json.Linq;
// 默认有效期天数,岗位招满的典型周期是 30 到 60 天
// 这个值别设太长,脏数据比少数据更伤
const int DefaultValidDays = 60;
// 计算失效时间:有手动截止日就以手动为准,否则发布日期顺延
static DateTime CalcValidThrough(DateTime posted, DateTime? manualEnd)
{
// HR 明确填了截止日就听她的
if (manualEnd.HasValue) return manualEnd.Value;
// 否则按发布日期顺延,不做取整,精确到当天
return posted.AddDays(DefaultValidDays);
}
// 把一行 Excel 转成 JobPosting 实体,缺关键字段直接返回 null
static JObject? BuildJobPosting(JobRow row)
{
// 岗位名是必填,空了整个实体没法用
if (string.IsNullOrWhiteSpace(row.Title)) return null;
// 工作地点要精确到区,只有城市名会被判为地域模糊
if (string.IsNullOrWhiteSpace(row.District)) return null;
// 发布日期为空时用导入当天,datePosted 不能缺
var posted = row.Posted ?? DateTime.Today;
// 失效时间由上面的函数算,不在这里写死
var valid = CalcValidThrough(posted, row.ManualEnd);
// 已过期的岗位直接不产出,避免脏数据进页面
if (valid < DateTime.Today) return null;
var obj = new JObject
{
// @context 与 @type 是结构化数据的两个硬性入口
["@context"] = "https://schema.org",
["@type"] = "JobPosting",
// title 用岗位名加括号限定词,不带招聘话术
["title"] = row.Title,
// description 只写职责与要求,待遇细节交给 baseSalary
["description"] = row.Description
};
// identifier 带上站内岗位编码,跨期发布同一岗位时便于去重
obj["identifier"] = new JObject
{
["@type"] = "PropertyValue",
// name 与 value 成对出现,value 取 HR 表格里的岗位编码列
["name"] = "岗位编码",
["value"] = row.JobCode
};
// hiringOrganization 必须是 Organization 实体,不能是裸字符串
// sameAs 指回官网首页,这是和组织实体互链的关键
obj["hiringOrganization"] = new JObject
{
["@type"] = "Organization",
["name"] = row.OrgName,
["sameAs"] = row.OrgHomepage
};
// jobLocation 用 Place 包 PostalAddress,精确到区一级
obj["jobLocation"] = new JObject
{
["@type"] = "Place",
["address"] = new JObject
{
["@type"] = "PostalAddress",
// addressRegion 填区,addressLocality 填市
["addressRegion"] = row.District,
["addressLocality"] = row.City,
// addressCountry 用 ISO 两位码,中文国家名引擎认不出
["addressCountry"] = "CN"
}
};
// 薪资写成 MonetaryAmount,unitText 显式声明计薪周期
if (row.SalaryMin > 0)
{
obj["baseSalary"] = new JObject
{
["@type"] = "MonetaryAmount",
// currency 用 ISO 4217 三位码
["currency"] = "CNY",
["value"] = new JObject
{
["@type"] = "QuantitativeValue",
// 区间用 minValue 与 maxValue,别写死一个数字
["minValue"] = row.SalaryMin,
["maxValue"] = row.SalaryMax,
// unitText 写 MONTH 或 HOUR,不写引擎会猜错计薪周期
["unitText"] = row.PayUnit
}
};
}
// employmentType 用受控枚举值,页面中文要映射过来实现映射的函数单独放一张表,改文案时不用动代码
obj["employmentType"] = MapEmploymentType(row.EmploymentTypeRaw);
// 两个日期字段用 ISO 8601,yyyy-MM-dd 是引擎认的格式
// 顺序不能反,validThrough 早于 datePosted 会被判为无效实体
obj["datePosted"] = posted.ToString("yyyy-MM-dd");
obj["validThrough"] = valid.ToString("yyyy-MM-dd");
return obj;
}
这段上线后改过三次,都是 HR 那边提的需求。一次是加手动截止日,有些岗位确实有硬期限;一次是把失效判定前置到导入阶段,之前放在页面渲染时判,列表页照样能扫到过期岗位;还有一次是把 unitText 从固定值改成取表格列,因为小时工和月薪工混着招的问题暴露了。
原理与机制剖析:招聘字段为什么容易被当高置信事实
这一节拆一下引擎侧的处理链路,理解了这些,字段该填什么就不用死记。
sequenceDiagram
participant HR as HR 月度表格
participant Imp as 导入器
participant Page as 岗位页 JSON-LD
participant Crawler as 引擎抓取
participant Index as 事实索引
HR->>Imp: 提交岗位行
Imp->>Page: 输出 JobPosting 实体
Note over Imp,Page: validThrough 过期则不再输出
Crawler->>Page: 抓取并解析结构化数据
Page->>Index: 写入带时间戳的事实
Index->>Index: 新鲜度校验 validThrough
Index-->>Crawler: 高置信事实可用
第一是字段的客观属性。招聘实体里的 title、baseSalary、jobLocation 都是可核验的离散值,不像「团队氛围好」这种描述性文字需要语义判断。引擎组装回答时,对离散值的引用意愿明显高于对段落的改写,我们在别的项目上也见过同样的现象。
第二是新鲜度信号。datePosted 和 validThrough 给了引擎一个明确的时间窗口,窗口内的岗位被判定为当前有效。反过来说,一个没有日期标注的岗位页,引擎没法判断它是上周的还是三年前的,策略上就保守——宁可不引用。过期岗位必须主动失效,而不是等 HR 想起来删。
第三是图谱联动。hiringOrganization 指向 Organization 实体之后,招聘信息就不再是一个孤立页面,它成了组织实体的一条边。这条边会往回传信号:持续发布岗位的企业,在引擎的知识图谱(knowledge graph)里会被推定处于运营状态,规模判断也多了一个依据。
45 天前后,引用率到底变了什么
验收没用第三方工具,就是人工问。30 组问题分三类:岗位类 12 组、待遇类 10 组、企业类 8 组。每组在 3 个 AI 搜索入口各问一次,共 90 次,记录回答里是否出现官网域名或页面标题。这是内部抽查口径,不是行业统计,只看趋势。
| 指标 | 改造前(第 0 天) | 第 15 天 | 第 45 天 | 变化说明 |
|---|---|---|---|---|
| 90 次提问中提及官网的次数 | 2 | 6 | 14 | 岗位类提问贡献了大部分增量 |
| 岗位类提问命中率 | 0/36 | 8/36 | 21/36 | title 与 jobLocation 对齐后起量 |
| 待遇类提问命中率 | 2/30 | 6/30 | 13/30 | baseSalary 区间写法是关键 |
| 回答中出现薪资数字的次数 | 1 | 7 | 18 | 数字直接来自 baseSalary 槽位 |
| 企业类提问命中率 | 4/24 | 7/24 | 11/24 | 与岗位改造无直接关系,但同步上升 |
| 引用了已过期岗位的次数 | 0 | 3 | 0 | 第 15 天有脏数据,失效机制上线后归零 |
第 15 天那 3 次引用过期岗位是真实发生的事。当时失效机制还没上线,引擎把三个月前招满的岗位当成在招,回答里还带了旧薪资。这事比引用率低更麻烦,因为它给出的是错的答案。脏数据不是没有引用,是错误引用。
还有个细节:岗位类命中率涨得比待遇类快。title 与 jobLocation 对齐难度低,引擎很快吃进去;baseSalary 涉及数值单位,要多轮抓取才稳定。
意外收获:招聘实体让企业规模判断更准
企业类提问本来只是当对照组测的,问题都是「这家厂规模多大」「主要做什么产品」这类和岗位无关的事。
结果它也涨了,从 4 次到 11 次。我们的解释是:岗位数量、工种分布、薪资区间这三样东西,本身是企业规模很强的侧面证据。引擎回答「规模多大」时,手里多了一批可引用的实体,回答里就更容易带上官网。
这个发现后来被用到别的项目上:组织(Organization)实体单独写再多字段,都不如给它挂几条真实发生的业务实体来得有效。招聘是一类,产品、资质、案例也都是同类。GEO 语境下,实体之间的关系比实体自身的描述更值钱,这句话在这个项目上算是被验证了一次。
完整实体长什么样
下面是改完之后单个岗位的输出,删掉了面包屑和站内导航那些无关部分。环境:任意支持 JSON-LD 的模板,注释行仅供讲解,上线前用压缩环节去掉。
{
// @context 固定指向 schema.org,漏了整段不解析
"@context": "https://schema.org",
// @type 必须是 JobPosting,写成 Article 岗位类提问全部落空
"@type": "JobPosting",
// title 写岗位名加限定词,不带招聘话术
"title": "数控车工(两班倒)",
// description 写职责与要求,待遇交给 baseSalary
"description": "负责汽车零部件的数控车削加工,能独立读图并按工艺卡操作,两班倒。",
// identifier 用站内岗位编号,便于跨期去重
"identifier": {
"@type": "PropertyValue",
// name 与 value 成对,value 取 HR 表格里的岗位编码
"name": "岗位编码",
"value": "QC-Lathe-0417"
},
// hiringOrganization 必须是实体,sameAs 指回官网首页完成互链
"hiringOrganization": {
"@type": "Organization",
"name": "某汽配制造企业",
"sameAs": "https://example.com"
},
// jobLocation 精确到区,只写城市会被判地域模糊
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
// addressRegion 是区一级,addressLocality 是市
"addressRegion": "某区",
"addressLocality": "某市",
// addressCountry 用 ISO 两位码,中文国家名引擎认不出
"addressCountry": "CN"
}
},
// baseSalary 用 MonetaryAmount 包一层 QuantitativeValue
"baseSalary": {
"@type": "MonetaryAmount",
// currency 用 ISO 4217 三位码
"currency": "CNY",
"value": {
"@type": "QuantitativeValue",
// 区间写法,不写面议
"minValue": 6500,
"maxValue": 9000,
// unitText 声明计薪周期,MONTH 或 HOUR
"unitText": "MONTH"
}
},
// employmentType 用受控枚举值,中文要映射过来
"employmentType": "FULL_TIME",
// datePosted 是发布当天,ISO 8601 格式
"datePosted": "2026-07-18",
// validThrough 是失效日,过期后整段不再输出
"validThrough": "2026-09-16"
}
这些坑我们踩过
误区一,把整页当成一个 JobPosting。一页挂五个岗位时必须输出五个实体,用一个实体带五个 title 会让引擎只认第一个。
误区二,validThrough 填一个很远的值。填到几年后短期看引用率好看,长期是脏数据源头,我们没这么干。
误区三,hiringOrganization 只写字符串名字。少了 sameAs,招聘实体和组织实体之间那条边就断了,企业类提问的连带提升拿不到。
误区四,忽略 HR 的工作方式。我们最初想让她维护一份结构化表单,两周就放弃了,改成她继续填 Excel、导入器负责校验和告警,这才是能持续下去的形态。
参考与延伸
- JobPosting 类型官方定义:https://schema.org/JobPosting
- Organization 类型官方定义:https://schema.org/Organization
- validThrough 属性说明:https://schema.org/validThrough
- Google 招聘信息结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/job-posting
GEO, AI搜索, JobPosting, 招聘结构化数据, JSON-LD, Schema.org, Organization, validThrough