招聘页也能进 AI 的答案:JobPosting 结构化改造的 45 天引用对照

2026-09-21 01:35:43 0 次浏览
GEOAI搜索JobPostingSchema.orgJSON-LD企业官网

适用读者:制造业、连锁零售、本地服务这类靠官网自己招人的企业里做站的前端或后端;招聘页现在还是一张长图或者一段纯文字,想让 AI 搜索在回答「某某厂招什么岗」「某某岗位待遇多少」时能提到你家的。

客户是做汽配冲压件的一家中型厂,HR 每月手工维护一次岗位,Word 排好版交给外包转成一张 JPG 贴到官网。七月中旬我们把招聘页换成了结构化模板,岗位内容一个字没改,只是加了 JobPosting 标注。45 天后拿 30 组问题去问几个 AI 搜索入口,90 次提问里回答提及官网的次数从 2 次变成 14 次。

一张 JPG 在引擎眼里等于没有内容

先说清一件事:那张招聘长图不是「内容少」,是根本没进抽取环节。图片 alt 是空的,文件名叫 recruit202607.jpg,就算 OCR 认出了字,也拿不到字段归属——「五险一金」这四个字旁边没有任何东西告诉引擎这是待遇还是企业文化。 招聘看板上的岗位卡片被 AI 选中

生成式引擎优化(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

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