三种结构化数据写法只留一种:JSON-LD、Microdata 与 RDFa 的迁移方案与取舍

2026-09-20 01:19:04 4 次浏览
GEO独立站JSON-LDSchema.org结构化数据SEO

适用读者:外贸独立站 / 跨境电商的后端与前端负责人,手里有一套跑了多年的模板,正在做生成式引擎优化(Generative Engine Optimization, GEO),却被同一份商品信息被三套结构化数据反复声明这件事拖住的人。

去年接手的一个外贸站,大促第二天,客户在 AI 搜索里问某款型号的价格,回答是 89 美元;那天活动价已经是 79 美元了。同一张页面上,价格被写了两遍:一遍在 HTML 标签的 itemprop 里没跟着改,一遍在 JSON-LD 里改了。改了的一半不生效,根源不在缓存,在实体冲突。

我们后来花了六周,把 微数据(Microdata)、RDFa 属性(RDFa Lite)全部迁到 JSON-LD(JavaScript Object Notation for Linked Data)。这篇把当时的判断依据、阶段划分和脚本原样复盘一遍。

一个站里同时跑三套写法是什么样

接手时站点 2000 多个 SKU,模板分了三代:最早那代是 Microdata,把语义塞进 HTML 属性;中间一代有人试过 RDFa,只落了三百来个页面;最近一年新开的落地页直接写 JSON-LD。三线并行,谁也没删谁的。

结构化数据格式收敛主题图:三套标记并入一套 JSON-LD

维度 JSON-LD 微数据(Microdata) RDFa Lite
写法位置 <script type="application/ld+json"> 独立块 散落在 HTML 标签的 itemscope/itemprop 属性 标签上的 vocab/typeof/property 属性
与 DOM 耦合 无耦合,改版不触碰 强耦合,改一次 DOM 就要跟着改属性 强耦合,同左
模板管线生成 一段字典序列化即可,可复用 要在每个标签上插属性,模板里东一处西一处 同左,且属性嵌套更深
一个页面写多个实体 天然支持数组与 @graph 嵌套容易写错,一个 itemprop 落错标签就断链 前缀与 CURIE 写错更隐蔽
解析器成本 一次 JSON 解析 需要按 DOM 树重建实体 需要三元组(Triple)重组
维护方 Google 明确推荐 W3C 已归为历史推荐 W3C 推荐,但搜索引擎侧支持弱

真正促使我们下决心的是耦合。Microdata 的价格写在 <span itemprop="price"> 里,前端做了一次价格区间的改版,把价格拆成「会员价 / 零售价」两个节点,属性没有跟着拆。那次改版之后,Microdata 抽出来的是空字符串,JSON-LD 里是 79 美元。老陈(化名,负责模板)当时一句话我记得很清楚:「两套数据打架的时候,AI 只信它先读到的那个,我们改哪边都不一定生效。」

解析器怎么读这三种格式:原理剖析

要理解冲突,得先看解析器干了什么。搜索引擎与 AI 侧的抽取器拿到 DOM 之后,走的是三条并行的路子。

graph TD
    A["HTML 文档"] --> B["DOM 解析"]
    B --> C["JSON-LD 分支: script[type=application/ld+json]"]
    B --> D["Microdata 分支: itemscope + itemprop"]
    B --> E["RDFa 分支: vocab + typeof + property"]
    C --> F["JSON 解析 → 实体对象"]
    D --> G["按 DOM 树重建实体: 最近祖先 itemscope 为宿主"]
    E --> H["三元组重组: subject-predicate-object"]
    F --> I["合并到实体图 Entity Graph"]
    G --> I
    H --> I
    I --> J{"同类型实体是否可对齐"}
    J -->|"有 @id / 主键相同"| K["合并属性 → 后写覆盖先写"]
    J -->|"无标识或标识不同"| L["当成两个独立实体 → 冲突态"]

Microdata 的重建规则是「就近归属」:一个 itemprop 归属于 DOM 树上离它最近的祖先 itemscope。标签一挪、结构一拆,归属就变了。RDFa 更麻烦,property 的值可能来自 content 属性、hrefsrc 或者节点文本,取值优先级本身就有坑。

重复实体是怎么冲突的

三条支路抽出的实体进入同一张实体图,对齐靠标识。JSON-LD 里我们可以手写 @id,Microdata 只能靠 itemid 属性,而我们那批老模板压根没写 itemid。于是同一个商品被拆成两个没有主键的 Product 节点,一个 price 89,一个 price 79。

对齐失败之后,消费方(无论是富媒体结果还是 AI 回答生成)通常按文档顺序取第一个,或者取置信度评分最高的那个。Microdata 在 <body> 里,位置普遍早于页面末尾的 JSON-LD 脚本块,旧价就这么被选中了。

graph LR
    P["Product A<br/>source: Microdata<br/>price: 89<br/>无 @id"] --> X{"实体对齐"}
    Q["Product A<br/>source: JSON-LD<br/>price: 79<br/>无 @id"] --> X
    X -->|"无主键, 无法判定同一实体"| R["保留两个节点"]
    R --> S["消费方按 DOM 顺序取 Microdata"]
    S --> T["AI 回答报出 89 的旧价"]

实体对齐为什么失败

对齐的钥匙是稳定标识。JSON-LD 里我们给每个商品写死一个 @id,形如 https://站点/product/sku-123#product,同时用 sku 做业务主键,跨页面引用同一个 @id,解析器才会把「列表页的 Product」和「详情页的 Product」认成同一个东西。

Microdata 即便补上 itemid,也只是给了一个 URI,语义上的「同款不同颜色」「同款不同批次」仍要靠 isVariantOfsku 这些属性表达,而这些属性在旧模板里一个都没有。没有稳定 @id,重复声明就不是冗余,而是噪音。

为什么收敛到 JSON-LD

收敛的理由不是「新就是好」,是三条很实在的工程理由。

  • 与 HTML 解耦。改版改的是 DOM,语义数据不用跟着动,这是那次价格改版事故的直接教训。
  • 模板管线统一生成。商品数据本来就在一个字典里,序列化一次塞进 <head>,列表页、详情页、促销页共用同一个函数。
  • 官方口径明确。Google 的结构化数据文档把 JSON-LD 列为推荐格式,Microdata 与 RDFa 属于「也支持」的遗留项,出了问题排查资料也少。

代价也得说:JSON-LD 与页面可见内容脱节,容易出现「脚本里写 79、页面上显示 89」的新形态不一致。所以迁移时我们加了一条硬约束——JSON-LD 的数值必须由渲染模板的同一份数据源产出,不允许手写第二份。

迁移分三阶段:并行期、校验期、下线期

我们没敢一次性删属性。六周拆成三个阶段,每个阶段都有可回滚的开关。

阶段 时间 主要动作 出口条件 回滚方式
并行期 第 1–3 周 全站页面补 JSON-LD,Microdata/RDFa 原样保留;两边数值由同一数据源产出 抽样 200 个 URL,两种写法抽出的 price/name/sku 完全一致 关掉生成开关,页面回退到旧状态
校验期 第 4–5 周 跑批量校验脚本 + 富媒体测试工具;修 @idavailabilitypriceCurrency 等缺失字段 连续 7 天零冲突告警,Search Console 富媒体报错清零 保留脚本输出日志,逐页回补
下线期 第 6 周 按模板分 3 批删除 itempropitemscopetypeofproperty 属性 删除后结构化数据条目数不下降 Git 按模板批次 revert

并行期是关键。两边同时声明、数值一致,实体冲突依然存在但结果一致,风险被压到最低;等校验期确认 JSON-LD 侧字段合规,再统一摘掉旧属性。

迁移脚本怎么写

第一段脚本负责把老页面里已有的 Microdata / RDFa 值抽出来,当成 JSON-LD 的初始数据源,避免人工录一遍。

环境:Python 3.11,依赖 extruct>=0.16lxml>=5.2

# -*- coding: utf-8 -*-
# 环境:Python 3.11,依赖 extruct>=0.16、lxml>=5.2
# 作用:抽出旧页面的 Microdata / RDFa 商品字段,产出统一 JSON-LD
# 输入:HTML 文件路径列表;输出:标准输出的 JSON 数组
import json
import pathlib
import sys
import extruct

# 白名单字段:键是 Schema.org 属性名,值是缺失时的兜底
FIELDS = {
    "name": "",
    "sku": "",
    "brand": "",
    "price": "",
    # 外贸站多数以美元标价,兜底币种写死
    "priceCurrency": "USD",
    # 库存状态默认在售,缺货模板另有覆盖逻辑
    "availability": "https://schema.org/InStock",
}

# 站点域名,用于拼接稳定的 @id
BASE = "https://example.com"


def pick_product(items):
    # 一个页面常被抽出多个 Product,需要挑字段最全的那个
    cands = [i for i in items if isinstance(i, dict)]
    # 没有 sku 的记录直接丢弃,主键缺失就无法生成 @id
    cands = [i for i in cands if i.get("sku")]
    if not cands:
        return None
    # 按已填字段数量降序排序,取信息量最大的一条
    cands.sort(key=lambda d: sum(1 for k in FIELDS if d.get(k)), reverse=True)
    return cands[0]


def to_jsonld(raw, url):
    # 只搬运白名单字段,历史脏属性不进新结构
    p = pick_product(raw)
    if p is None:
        return None
    # 固定上下文与类型,Product 是本次收敛的核心类型
    node = {"@context": "https://schema.org", "@type": "Product"}
    # 稳定标识:详情页 URL 加锚点,跨页面引用同一实体
    node["@id"] = f"{BASE}/product/{p['sku']}#product"
    for k, default in FIELDS.items():
        # 取真实值,取不到就落兜底
        val = p.get(k) or default
        if val:
            node[k] = val
    # brand 在 Microdata 里常被抽成 dict,统一成 Brand 对象
    if isinstance(node.get("brand"), dict):
        node["brand"] = {"@type": "Brand", "name": node["brand"].get("name", "")}
    # url 指向详情页,方便解析器把页面与实体关联
    node["url"] = url
    return node


def main():
    out = []
    for path in sys.argv[1:]:
        # 逐个文件读取,编码统一 utf-8
        html = pathlib.Path(path).read_text(encoding="utf-8")
        # 同时抽取两种旧语法,base_url 影响相对路径解析
        data = extruct.extract(html, base_url=BASE, syntaxes=["microdata", "rdfa"])
        # 两种语法的抽取结果合并处理
        items = data.get("microdata", []) + data.get("rdfa", [])
        # 只保留 Product,其余类型交给后续脚本处理
        items = [i for i in items if i.get("@type") in ("Product", "product")]
        # 用文件名当 slug 拼出详情页地址
        node = to_jsonld(items, f"{BASE}/product/{pathlib.Path(path).stem}")
        if node:
            out.append(node)
    # 结果交给模板管线直接消费
    print(json.dumps(out, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

第二段是 CI 里的守卫脚本,跑在预发布环境,专门抓「JSON-LD 与页面可见价格不一致」这类新形态事故。

环境:Node.js 20(内置 fetch),正则解析,无第三方依赖。

// 环境:Node.js 20(内置 fetch),无第三方依赖,文件存为 check_ld.mjs
// 作用:预发布环境批量校验,价格不一致或实体冲突则非零退出
// 用法:node check_ld.mjs https://staging.example.com
// 预发布环境地址,从命令行第二个参数取,缺省走 staging
const BASE = process.argv[2] || "https://staging.example.com";
// 待检查的 URL 列表,正式项目里从 sitemap.xml 抽样
const URLS = ["/product/sku-123", "/product/sku-456", "/product/sku-789"];

// 取出页面里所有 ld+json 脚本块的文本内容
function extractBlocks(html) {
  // 类型声明前后可能有空格,正则里用 [^>]+ 兜住
  const re = /<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;
  const out = [];
  let m;
  // 循环收集,一个页面常声明多个块
  while ((m = re.exec(html)) !== null) out.push(m[1]);
  return out;
}

// 从块文本里找出所有 Product 节点,兼容 @graph 与数组两种写法
function findProducts(blocks) {
  const list = [];
  for (const b of blocks) {
    let data;
    try {
      // 去掉首尾空白后再解析
      data = JSON.parse(b.trim());
    } catch (e) {
      // JSON 语法错误必须拦住,记为一条问题记录
      list.push({ __error: "invalid json" });
      continue;
    }
    // 递归下钻:数组逐项处理,对象继续遍历属性
    const walk = (n) => {
      if (Array.isArray(n)) return n.forEach(walk);
      if (n && typeof n === "object") {
        // 命中 Product 就收进结果列表
        if (n["@type"] === "Product") list.push(n);
        Object.values(n).forEach(walk);
      }
    };
    walk(data);
  }
  return list;
}

// 抓页面上肉眼可见的价格,用来与 JSON-LD 交叉比对
function visiblePrice(html) {
  // 匹配 class 含 price 的节点里的数字
  const m = html.match(/class="[^"]*price[^"]*"[^>]*>\s*\$?([\d,.]+)/i);
  // 去掉千分位逗号,只留纯数字串便于比较
  return m ? m[1].replace(/,/g, "") : null;
}

// 失败计数,最后决定进程退出码
let failed = 0;
for (const u of URLS) {
  // 请求预发布环境的页面 HTML
  const res = await fetch(BASE + u);
  const html = await res.text();
  const products = findProducts(extractBlocks(html));
  // 收集所有 @id,用于判断是否为真重复
  const ids = new Set(products.map((p) => p["@id"] || ""));
  // 多个 Product 且标识不同,就是典型的实体冲突
  if (products.length > 1 && ids.size > 1) {
    console.error(`[conflict] ${u} 存在 ${products.length} 个 Product 且标识不一致`);
    failed++;
  }
  const shown = visiblePrice(html);
  for (const p of products) {
    // offers 可能是单个对象也可能是数组,统一成数组处理
    const offers = [].concat(p.offers || []);
    const jsonPrice = String(offers[0]?.price ?? "");
    // 页面可见价与结构化数据价不一致,直接判失败
    if (shown && jsonPrice && shown !== jsonPrice) {
      console.error(`[mismatch] ${u} 页面价 ${shown} ≠ JSON-LD 价 ${jsonPrice}`);
      failed++;
    }
    // 缺少 @id 的实体无法跨页对齐,视为硬错误
    if (!p["@id"]) {
      console.error(`[missing] ${u} Product 缺少 @id`);
      failed++;
    }
  }
}
// 非零退出码把发布流程卡住
process.exit(failed ? 1 : 0);

脚本上线第一周就抓出 27 个页面的 priceCurrency 缺失,还有 4 个促销页的 offers 写成了对象而不是数组。这些问题靠肉眼巡检基本发现不了。

几个容易踩的坑

  • 别用 itemid 去凑对齐。老模板补 itemid 看着省事,但属性缺失没解决,冲突照旧,还多一份维护成本。
  • offers 里的 priceValidUntil 在促销页一定要填,否则过期的活动价会被当成长期价格反复引用。
  • 删属性要按模板批次走,别按 URL 走。同一个模板改一处,几十个页面同时生效,回滚也只需要 revert 一个文件。

趋势上判断,AI 搜索语境里结构化数据的价值正在从「拿富媒体样式」转向「让实体被正确引用」。GEO 的活儿本质上是把实体讲清楚,重复且冲突的声明等于给引擎喂了矛盾证据。我们的做法是:类型收敛到 Product / Offer / Organization / FAQPage 这几类,每个实体一个稳定 @id,跨站引用用 sameAs 指向社媒与行业目录。

这套收敛做完之后,大促价格改一次,AI 侧引用很快就跟上了。如果你手上也是三套写法混着跑,建议先跑一遍抽取脚本看看冲突数量,再决定删哪边——评论区可以聊聊你抽出来的冲突率。

参考与延伸

GEO, AI优化AIO, 独立站 AI 流量, JSON-LD, Schema.org, 结构化数据迁移, Microdata

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