USAJOBS 招聘板的 JobPosting,被 Search Console 教了两课
一句话结论:一个自建 WordPress 招聘板把 JobPosting 从岗位真实数据生成,靠 Search Console 修掉地址与薪资两处,并拒绝编造两个建议字段。
背景:职位页的 JSON-LD 不是手写块,是插件按岗位 meta 渲染
站点是一个围绕美国联邦岗位(USAJOBS 数据源)自建的 WordPress 招聘板。每个职位详情页都会打印一块 JobPosting 结构化数据——不是编辑器里手贴的代码块,而是插件在模板渲染时,从该岗位自己的 meta 字段拼出来的。这个区别很重要:手写块错一次只错一页,模板生成的错一次就是全站所有职位一起错,Search Console 的"示例网址"可能只列出十几条,背后却是几千个页面。
岗位的本地字段来自 USAJOBS 的联邦岗位数据:职称、部门、地点字符串 City, ST、开放日期与关闭日期、雇佣类型、薪资与期码(PA / PH / PD / PW / BW / PM 这一组,代表源数据里薪资的计发频率)。模板的责任是把这些已经有值的东西,翻译成 Google 认识的形状。
落地清单一:模板里那块 PHP,与它输出的 JSON-LD
插件里生成标记的核心大致如下(作者原文结构,字段名未改动):
$loc = jab_split_location(get_post_meta($id, '_jab_location', true)); // 'City, ST'
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'JobPosting',
'title' => get_the_title($id),
'datePosted' => get_the_date('c', $id),
'validThrough' => get_post_meta($id, '_jab_close_date', true),
'employmentType' => get_post_meta($id, '_jab_jobtype', true),
'hiringOrganization' => array(
'@type' => 'Organization',
'name' => get_post_meta($id, '_jab_department', true),
),
'jobLocation' => array(
'@type' => 'Place',
'address' => array(
'@type' => 'PostalAddress',
'addressLocality' => $loc['city'],
'addressRegion' => $loc['state'],
'addressCountry' => 'US',
),
),
'baseSalary' => jab_salary_schema($id), // value + unitText 来自真实薪资数据
);三个设计点值得抄:
datePosted用get_the_date('c'),直接出 ISO 8601,不经过任何本地化格式。这一条避免了后面"日期不可解析"那一类错误。hiringOrganization是对象不是字符串,里面只有name,值取自岗位的部门字段——是真实雇主,不是招聘板自己的名字。baseSalary交给jab_salary_schema($id)单独处理,因为薪资是这个模板里唯一需要做单位换算的部分。
输出的成品(修复后的形状)如下,可以直接对照自己的页面看差在哪:
{
"@context": "https://schema.org",
"@type": "JobPosting",
"title": "IT Specialist (INFOSEC)",
"description": "<p>Federal civilian position, duties per announcement.</p>",
"datePosted": "2026-10-28",
"validThrough": "2026-11-11",
"employmentType": "FULL_TIME",
"hiringOrganization": {
"@type": "Organization",
"name": "Department of the Navy"
},
"jobLocation": {
"@type": "Place",
"address": {
"@type": "PostalAddress",
"addressLocality": "San Diego",
"addressRegion": "CA",
"addressCountry": "US"
}
},
"baseSalary": {
"@type": "MonetaryAmount",
"currency": "USD",
"value": { "@type": "QuantitativeValue", "value": 32.15, "unitText": "HOUR" }
}
}两处修复:地址拆字段、薪资按期码映射频率
第一课是地址。 第一版把整个 "City, ST" 字符串塞进了 addressLocality。Search Console 报了位置字段警告,因为它要的是分开的属性:城市归 addressLocality,州归 addressRegion。修复就是那个 jab_split_location() 辅助函数——按逗号切一刀,城市和州各自落到自己的字段里。改动很小,效果是从 warning 变 valid。
第二课是薪资。 源数据的薪资不是"一个数字",是"数字 + 期码"。PA、PH、PD、PW、BW、PM 这组期码代表不同的计发频率,直接把它扔进 baseSalary.value.value 而不写 unitText,搜索引擎只能当你没写单位——时薪会被当年薪读。所以在 jab_salary_schema() 里,期码要先映射成 unitText 的真实频率,再随 value 一起输出。这也是为什么这块单独抽成函数:单位换算是变量,字段名是常量,混在一起以后一定会漏。
落地清单二:可复制的本机校验脚本
Search Console 慢且只看它抓过的页面,本地先自检一次能省几轮。下面这段只用 Python 标准库:抽 JSON-LD、查五项必填、检测 addressLocality 复合串、检测 employmentType 枚举、检测 baseSalary.value.unitText。
import json, re, sys
REQUIRED = ["title", "description", "datePosted", "hiringOrganization", "jobLocation"]
ENUMS = {"FULL_TIME", "PART_TIME", "CONTRACTOR", "TEMPORARY", "INTERN",
"VOLUNTEER", "PER_DIEM", "OTHER"}
UNIT = {"HOUR", "DAY", "WEEK", "MONTH", "YEAR"}
def blocks(html):
out = []
for m in re.finditer(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', html, re.S):
try:
out.append(json.loads(m.group(1)))
except Exception as e:
print(" ERROR JSON 解析失败:", e)
return out
def check(obj):
errs, warns = [], []
if not isinstance(obj, dict) or obj.get("@type") != "JobPosting":
return errs, warns
for f in REQUIRED:
if not obj.get(f):
errs.append("Missing %s" % f)
et = obj.get("employmentType")
if et:
vals = et if isinstance(et, list) else [et]
for v in vals:
if v not in ENUMS:
errs.append("employmentType 非枚举值: '%s'" % v)
loc = obj.get("jobLocation")
if isinstance(loc, dict):
addr = loc.get("address") or {}
city = addr.get("addressLocality")
if isinstance(city, str) and "," in city and not addr.get("addressRegion"):
warns.append("addressLocality 是复合串: '%s' -> 拆成 addressLocality + addressRegion" % city)
sal = obj.get("baseSalary")
if isinstance(sal, dict):
val = sal.get("value") or {}
if isinstance(val, dict) and not val.get("unitText"):
errs.append("baseSalary.value 缺 unitText(时薪会被读成年薪)")
elif isinstance(val, dict) and val.get("unitText") not in UNIT:
errs.append("baseSalary.value.unitText 非标准值: '%s'" % val.get("unitText"))
return errs, warns拿三个样例页(修复前 / 修复后 / 纯远程)跑一遍,2026-11-10 本机实际输出:
[修复前] parse=ok ERROR employmentType 非枚举值: 'Full Time' ERROR baseSalary.value 缺 unitText(时薪会被读成年薪) WARN addressLocality 是复合串: 'San Diego, CA' -> 拆成 addressLocality + addressRegion [修复后] parse=ok [纯远程] parse=ok ERROR Missing jobLocation
三段结果正好对应三类问题:修复前的两条 ERROR 是"值对了词不对"(Full Time 是人话,不是枚举)与"值缺单位";一条 WARN 是复合串;修复后干净;纯远程那条则演示了最常见的一个阻塞错误——全远程岗位给了 jobLocationType: TELECOMMUTE 和 applicantLocationRequirements,但没有 jobLocation,脚本照样报 Missing jobLocation。Google 的逻辑也一样:远程不等于没有地点,只是换了一种表达。
验证与排查:error 与 warning 不是一回事
Job Postings 报告最容易浪费时间的地方,是把两类消息混着处理。Google 在 JobPosting 上只要求五项:
| 属性 | 缺了什么 | 后果 |
|---|---|---|
title | error | 基本不会出现在 Google for Jobs |
description | error | 同上 |
datePosted | error | 同上 |
hiringOrganization | error | 同上 |
jobLocation | error | 同上(远程岗位也不例外,需换用 jobLocationType + applicantLocationRequirements) |
其余全部是 recommended:validThrough、baseSalary、employmentType、identifier、jobLocationType、applicantLocationRequirements、addressLocality / addressRegion / postalCode / streetAddress……缺了只是 warning,岗位仍然有资格。所以一页显示 11 条 warning、0 条 error,不叫 schema 有问题,第一轮排查应该按严重程度排序,直接略过 warning。
几个读消息的技术点:消息里的括号指的是父对象,例如 Missing field 'name' (in 'hiringOrganization') 表示要打开的是雇佣组织那个对象,不是全块搜 name;一个父对象写坏通常会连带出好几条子消息,修好父对象,子消息自动消失。
高频 error 的处理口径:
description为空——最典型的成因是正文由 JavaScript 在标记写入之后才加载,服务端输出时这个字段是空的。datePosted不是 ISO 8601——常见于把06/08/2026这种本地化格式直接写进标记。hiringOrganization写成裸字符串——必须补成Organization对象。纯远程岗位缺
applicantLocationRequirements——只留jobLocationType不够,还要说明申请人可以在哪个国家或地区工作。addressCountry缺失——联邦岗位的源数据常常只带城市和州,导入时把国家丢了。employmentType是自由文本(Permanent、Full Time)——必须映射到枚举:FULL_TIME、PART_TIME、CONTRACTOR、TEMPORARY、INTERN、VOLUNTEER、PER_DIEM、OTHER。
验证与排查:标记和页面不一致,是最安静的一类失败
校验器只检查 JSON 能不能解析、属性在不在,它没法知道页面是否支持这些声明。而 Google 的文档写得很明确:标记里的信息必须在页面上可见。以下五种情况一条错误都不会报:
| 标记里写的 | 页面上是什么 | 为什么被处理 |
|---|---|---|
| 一段薪资区间 | 页面上哪里都没有薪资 | 不可见的薪资属于政策问题,不是格式问题 |
| 具名雇主 | 招聘板自己的名字,或者 "Confidential" | 归属错误,重复出现会让整个域的职位失去信任 |
| 某个具体城市 | 一个泛化的国家页或筛选结果页 | URL 不是单个职位详情页,地点无从核对 |
| 完整描述 | 只有摘要,其余在登录后或展开按钮后 | 交互之后才出现的内容不算可见 |
datePosted 是今天 | 这个岗位明显挂了几个月 | 刷新日期正是过期岗位政策要抓的模式 |
自查办法很简单:打开一个职位页,装作看不见标记,只用屏幕上能看到的内容去填每个属性。填不出来的,Google 也验证不了。
验证与排查:过期岗位的三种合规退役
这块值得单独写,因为它最贵。Google 的职位文档写的是,对过期岗位未及时处理可能导致人工处置(manual action)——是人来审,不是算法掉几名,而且可能影响整个域的职位列表,不只是那些僵尸页。
Google 承认的退役方式只有三种:把 validThrough 设成一个已经过去的日期;让该 URL 返回 404 或 410;从页面移除 JobPosting 标记但页面本身留着。三种都合规,适用场景不同:知道截止日期就用第一种,页面之后没有存在必要就用第二种,还想保留链接和存档就只摘标记。不可接受的是第四种——什么都没做,岗位关了标记还在,因为系统里没有任何东西触发清理。
复盘:3 条配置动作
金额和单位必须同源输出。 薪资不要只存一个数字,把期码一起带到
baseSalary.value.unitText,单位换算在函数里做。这一条修的是"看起来对、读起来错"的隐性错误。地址在入口就拆。 别让
City, ST这种复合串进入addressLocality,在导入或模板层就切成addressLocality+addressRegion,同时补上addressCountry。本地先跑一遍五项必填 + 枚举 + 单位三项自检,再去 Search Console。 Search Console 滞后、只抽样,本机脚本能立刻告诉你哪个页面哪一块坏了。
复盘:2 条红线
不要为了消警告编造数据。 Search Console 建议补
streetAddress和postalCode,但 USAJOBS 只到城市级,联邦岗位不提供街道与邮编。作者明确不编:required才叫要求,建议只是可选富化;诚实留空优于编造填满。给一个编出来的邮编,等于就"某人的潜在工作地点"对机器说了一句假话,而且一旦被核对出来,损害的是整站的标记可信度。不要让标记跟着"重新发布"走。
datePosted必须是真实首次发布日期,不是最后一次刷新日期;同理,关闭的岗位不能留着旧标记不管。这两件事都不会触发校验错误,但都会触发政策。
标注
@type:
JobPosting(含Organization/Place/PostalAddress/MonetaryAmount/QuantitativeValue)。Schema.org 版本:现行版,查询于 2026-11-10。本机访问
schema.org/version/latest连接超时(WinError 10060),docs/releases.html返回 200 但页面未解析出版本行,故不写具体版本号;已知上一版为 v30.1,2026-09-16 发布。验证工具:Google Rich Results Test、Search Console「Job Postings」报告、本机 Python 标准库脚本(运行于 2026-11-10)。
数据口径:两处修复与 Search Console 的报错过程均为作者自述,非第三方复现;本机脚本的三段输出为 2026-11-10 实际运行结果,样例为按原文结构构造的职位页。全文不含 CTR、曝光量、排名或 AI 引用变化数据,效果部分仅为结构性观察。