三种结构化数据写法只留一种:JSON-LD、Microdata 与 RDFa 的迁移方案与取舍
适用读者:外贸独立站 / 跨境电商的后端与前端负责人,手里有一套跑了多年的模板,正在做生成式引擎优化(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 | 微数据(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 属性、href、src 或者节点文本,取值优先级本身就有坑。
重复实体是怎么冲突的
三条支路抽出的实体进入同一张实体图,对齐靠标识。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,语义上的「同款不同颜色」「同款不同批次」仍要靠 isVariantOf、sku 这些属性表达,而这些属性在旧模板里一个都没有。没有稳定 @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 周 | 跑批量校验脚本 + 富媒体测试工具;修 @id、availability、priceCurrency 等缺失字段 |
连续 7 天零冲突告警,Search Console 富媒体报错清零 | 保留脚本输出日志,逐页回补 |
| 下线期 | 第 6 周 | 按模板分 3 批删除 itemprop、itemscope、typeof、property 属性 |
删除后结构化数据条目数不下降 | Git 按模板批次 revert |
并行期是关键。两边同时声明、数值一致,实体冲突依然存在但结果一致,风险被压到最低;等校验期确认 JSON-LD 侧字段合规,再统一摘掉旧属性。
迁移脚本怎么写
第一段脚本负责把老页面里已有的 Microdata / RDFa 值抽出来,当成 JSON-LD 的初始数据源,避免人工录一遍。
环境:Python 3.11,依赖 extruct>=0.16、lxml>=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 侧引用很快就跟上了。如果你手上也是三套写法混着跑,建议先跑一遍抽取脚本看看冲突数量,再决定删哪边——评论区可以聊聊你抽出来的冲突率。
参考与延伸
- Schema.org 官方词汇表:https://schema.org
- Google 结构化数据入门:https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data
- W3C Microdata 规范:https://www.w3.org/TR/microdata/
- W3C RDFa Lite 规范:https://www.w3.org/TR/rdfa-lite/
GEO, AI优化AIO, 独立站 AI 流量, JSON-LD, Schema.org, 结构化数据迁移, Microdata