关闭

Tryaivo™技术

USAJOBS 招聘板的 JobPosting,被 Search Console 教了两课

2026-11-10 03:22:15 浏览:

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 来自真实薪资数据
);

三个设计点值得抄:

  1. datePosted 用 get_the_date('c'),直接出 ISO 8601,不经过任何本地化格式。这一条避免了后面"日期不可解析"那一类错误。

  2. hiringOrganization 是对象不是字符串,里面只有 name,值取自岗位的部门字段——是真实雇主,不是招聘板自己的名字。

  3. 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 上只要求五项:

属性缺了什么后果
titleerror基本不会出现在 Google for Jobs
descriptionerror同上
datePostederror同上
hiringOrganizationerror同上
jobLocationerror同上(远程岗位也不例外,需换用 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 条配置动作

  1. 金额和单位必须同源输出。 薪资不要只存一个数字,把期码一起带到 baseSalary.value.unitText,单位换算在函数里做。这一条修的是"看起来对、读起来错"的隐性错误。

  2. 地址在入口就拆。 别让 City, ST 这种复合串进入 addressLocality,在导入或模板层就切成 addressLocality + addressRegion,同时补上 addressCountry。

  3. 本地先跑一遍五项必填 + 枚举 + 单位三项自检,再去 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 引用变化数据,效果部分仅为结构性观察。