一个页面一个身份:mainEntityOfPage 与 @id 让产品页成为 AI 引擎里的独立实体
9 月 12 日早上,我用 Perplexity 查自家一款防腐离心风机的型号参数,回答里把 4kW 的电机功率安到了竞品的同尺寸机型上。追到引用来源才发现:两家站的 Product 结构化数据都没写 @id,页面又被同一个行业目录页同时收录,AI 引擎抽取实体时根本分不清「谁是谁」。当天我把全站 1,842 个产品页按实体图谱重新改造,四周后同类错误归因从抽样里的 11 处降到 2 处。这篇文章把整套做法拆开讲清楚。
一、问题现场:AI 引擎为什么张冠李戴
传统 SEO 时代,页面混一点没关系——搜索引擎把链接和标题存进索引,用户点进来自己看。生成式引擎的链路完全不同:爬取、抽取实体、对齐、生成答案,四个环节里「对齐」是最容易出错的一步。ChatGPT 的浏览检索、Perplexity 的引用归因、Gemini 的 grounding,都要回答同一个问题:这段参数到底属于哪个实体?

我们站原来的 JSON-LD 是典型的模板产物:{"@type":"Product","name":"AFX-450 防腐离心风机"},一行完事。
三处硬伤:没有 @id,实体无法被外部引用;没有 mainEntityOfPage,页面和主实体的关系靠猜;url 字段半年前改版后没跟着更新,指向一个 301 之后的旧路径。Perplexity 的 agent 抓到旧 url 返回 301,落到分类页,于是把分类页下的另一款产品当成了参数主人。
关键结论:在生成式引擎的抽取链路里,没有稳定标识符的实体就是无名氏,无名氏的属性谁都能认领。
二、原理/机制剖析:@id 与 mainEntityOfPage 在解析链路里各自做什么
JSON-LD 本质是把数据映射成 RDF 图,每个节点要么有显式标识符(@id),要么被解析器分配一个临时空白节点标识。空白节点的问题是:它只在本页文档内有意义。A 页面和 B 页面各自声明了一个「AFX-450」,没有 @id 时它们是两个互不相识的空白节点,AI 引擎只能靠 name 字符串模糊匹配——同名不同厂、同厂不同版本,全是坑。
@id 把节点升级成全局可引用的实体。规范上 @id 可以是任何 IRI,不要求真的能访问,但生成式引擎的实践里有一个隐含约定:@id 应该是一个可解析的 HTTPS URL。原因很直白——引擎会顺着 @id 去拉取页面做交叉验证,解析失败的 @id 等于自断证据链。
mainEntityOfPage 解决的是另一个方向的问题:一个页面可能内嵌多个实体(产品、评价、面包屑、FAQ),它显式声明「这个页面的主角是谁」。Google 的结构化数据文档明确把它列为推荐的写法(用 @type: WebPage 反向指回),Perplexity 这类引擎在做页面级归因时同样依赖它来锁定主实体。
两者的配合关系一句话说清:@id 让别的页面能引用你,mainEntityOfPage 让引擎知道这一页在说谁。
三、实体图谱设计:@graph 里 Product/Organization/Offer 互相引用
单页孤立的 JSON-LD 升级成带 @graph 的实体图谱,核心思路是:Organization 收敛为全站共享的单节点,Product 每款一个节点,Offer 挂在 Product 下,全部用 @id 互相关联,不再重复内联完整对象。 节点间的引用关系如下图:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization", "@id": "https://www.example.com/en/#organization",
"name": "Example Industrial Co., Ltd.", "url": "https://www.example.com/en/",
"logo": "https://www.example.com/static/logo-512.png"
},
{
"@type": "WebPage", "@id": "https://www.example.com/en/products/afx-450",
"url": "https://www.example.com/en/products/afx-450",
"isPartOf": { "@id": "https://www.example.com/en/#website" },
"mainEntity": { "@id": "https://www.example.com/en/products/afx-450#product" }
},
{
"@type": "Product", "@id": "https://www.example.com/en/products/afx-450#product",
"name": "AFX-450 防腐离心风机", "sku": "AFX-450",
"url": "https://www.example.com/en/products/afx-450",
"manufacturer": { "@id": "https://www.example.com/en/#organization" },
"offers": {
"@type": "Offer", "@id": "https://www.example.com/en/products/afx-450#offer",
"price": "1280.00", "priceCurrency": "USD",
"availability": "https://schema.org/InStock"
}
}
]
}
三个要点:Product 通过 manufacturer: {@id} 指回 Organization,不再整段复制公司信息;mainEntity 和 Product 节点共用同一个 @id 命名空间,页面身份和产品身份绑定;Offer 单独成节点,将来做多报价(FOB/CIF)时扩展不动主结构。这套结构在 Google Rich Results Test 里验证通过,Product 与 Merchant listing 富结果均正常识别。
节点之间的解析关系可以从引擎视角再画一张:
sequenceDiagram
participant E as AI 引擎抽取器
participant P as 产品页 (WebPage)
participant PR as Product 节点
participant O as Organization 节点
E->>P: 抓取页面并解析 JSON-LD
P-->>E: mainEntity 指向 PR 的 @id
E->>PR: 读取产品属性与 offers
PR-->>E: manufacturer 引用 O 的 @id
E->>O: 拉取主体信息做交叉验证
O-->>E: 返回品牌/法人/官网
E->>E: 实体对齐完成,归因到 PR
四、多语言站点的 @id 命名规范
我们是 en/de/es 三语言站,早期踩过的坑是三个语言版本的产品页用了同一个 @id——引擎抓取后认为三个页面是同一实体,inLanguage 全部丢失,德语站生成的回答里混进了英文参数。改版时定死了命名规范:
| 版本类型 | @id 形态 | url 字段指向 | 说明 |
|---|---|---|---|
| Organization | /{lang}/#organization |
当前语言首页 | 每语言一个法人主体节点,互为 sameAs 关联 |
| Product 实体 | /{lang}/products/{slug}#product |
当前语言产品页 | 语言是实体身份的一部分,跨语言用 sameAs 互指 |
| Offer | /{lang}/products/{slug}#offer |
同页锚点 | 价格随币种走,@id 必须带语言段 |
| 分类页引用 | /{lang}/category/{slug}#webpage |
分类页自身 | 分类页只做 @id 引用,不内联 Product 全量属性 |
规范背后是两条铁律:@id 含语言段,同款产品在不同语言下是不同节点,用 sameAs 表达「同一事物」;@id 一旦发布就是对外承诺,路径规则与站内路由共用一份配置,禁止 @id 一个格式、页面真实 URL 另一个格式。
五、集中式 @id 生成器架构(.NET 8)
1,842 个产品页散在 6 个 Razor 模板里,靠模板各自拼 @id 必然漂移。我们抽了一个集中式生成器,所有模板只调用它,不自己拼字符串。
flowchart LR
A[(产品主数据库<br/>sku/slug/语言版本)] --> B[@id 生成器服务<br/>ASP.NET Core 8 Minimal API]
C[(站点路由表<br/>sitemap 配置)] --> B
B --> D{路径规则引擎}
D -->|实体节点| E["/{lang}/products/{slug}#product"]
D -->|组织节点| F["/{lang}/#organization"]
B --> G[JSON-LD 渲染中间件<br/>注入 Razor 页面]
B --> H[(@id 登记表<br/>PostgreSQL)]
H --> I[每日一致性巡检任务]
生成器核心就一个静态工厂,关键在登记与校验:
// 依赖:.NET 8 SDK,ASP.NET Core Minimal API,Npgsql 8.0
// 环境:Windows/Linux 均可,作为类库被 6 个 Razor 模板共同引用
public static class EntityIdFactory
{
// 任何新节点类型接入,先在本类登记锚点,再同步更新校验脚本
// 语言白名单,防止路由段注入意外的多级路径
private static readonly string[] Langs = { "en", "de", "es" };
// 实体命名规范里的域名段,与站点 canonical 域名保持严格一致
// 该常量同时驱动 sitemap 的 hreflang 生成,保持单一事实来源
private const string Host = "https://www.example.com";
// 币种映射表,Offer 价格随语言站对应的结算币种走
private static readonly Dictionary<string, string> Currency = new()
{
// 三个语言站各自的默认结算币种,调整只改这一处
["en"] = "USD", ["de"] = "EUR", ["es"] = "USD"
};
/// <summary>生成产品实体 @id,六个 Razor 模板统一经由本工厂</summary>
/// <remarks>锚点段清单与 Python 校验脚本的 ID_PATTERN 联动维护</remarks>
public static string Product(string lang, string sku)
{
// 语言段必须命中白名单,否则直接抛异常而非静默降级
if (!Langs.Contains(lang))
throw new ArgumentException($"unsupported lang: {lang}");
// sku 非空校验放这里,模板层不再重复写防御代码
if (string.IsNullOrWhiteSpace(sku))
throw new ArgumentException("sku is required");
// slug 与站点路由表共用同一配置源,禁止模板层另行拼装
// 历史数据里还有全角空格,规范化时一并替换
var slug = sku.Trim().ToLowerInvariant().Replace(' ', '-');
// @id 末段用锚点区分节点类型,与页面真实 URL 保持可解析
return $"{Host}/{lang}/products/{slug}#product";
}
public static string Organization(string lang)
{
// 组织节点锚点固定为 #organization,全站统一命名空间
// 每个语言站一个组织节点,跨语言关联交给 sameAs 表达
return $"{Host}/{lang}/#organization";
}
public static string Offer(string lang, string sku)
{
// Offer 节点复用 Product 的 slug 规则,只换锚点段
var product = Product(lang, sku);
// 直接在 Product 的 @id 上替换锚点,保证两段路径永远一致
return product.Replace("#product", "#offer");
}
// 暴露币种查询,渲染 Offer 时避免模板各自硬编码货币代码
public static string CurrencyOf(string lang)
{
// 未登记的语言直接抛错,防止生成 priceCurrency 空值
return Currency.TryGetValue(lang, out var c)
? c
: throw new ArgumentException($"no currency for lang: {lang}");
}
// 发布前自检:校验 @id 域名段与 Host 常量一致,防止硬编码漂移
// 锚点段白名单集中维护,新增节点类型时两处同步
public static void Validate(string id)
{
// 前缀不符说明模板层绕过了本工厂直接拼字符串
if (!id.StartsWith(Host, StringComparison.Ordinal))
throw new ArgumentException($"id host mismatch: {id}");
// Offer 与 Product 的路径段一致,是校验脚本第三项的前提条件
// 校验失败的 @id 不允许进页面,宁可渲染空白也不带病上线
// 生产环境还会把这里的异常上报到登记表,便于定位漂移模板
// 该方法在 Razor 布局页渲染 JSON-LD 之前调用
}
}
每次生成同时写入 @id 登记表(sku、语言、@id、首次发布时间、当前 HTTP 状态),巡检任务每天对全量 @id 发 HEAD 请求,非 200 的记录直接进告警群。上线两个月,登记表里积累了 5,526 条 @id,断链拦截了 37 次——多数是运营改了产品 slug 却没走生成器,巡检当天就能发现。
六、实体一致性校验脚本(Python)
发布流水线里加了一道校验门:构建产物全量抓取 JSON-LD,检查 @id 格式、引用闭环、@id 与页面真实 URL 的一致性。脚本很短,直接给出来:
# 依赖:Python 3.11+,beautifulsoup4 4.12,requests 2.31
# 用法:python check_entities.py --sitemap https://www.example.com/sitemap.xml
import json, re, sys, argparse, requests
from bs4 import BeautifulSoup
# @id 必须是 https、含语言段、以 #product/#offer/#organization 锚点结尾
ID_PATTERN = re.compile(
r"^https://www\.example\.com/(en|de|es)/.+#(product|offer|organization)$"
)
def extract_jsonld(html: str) -> list:
# 一个页面可能有多段 script[type=application/ld+json],全部收集
soup = BeautifulSoup(html, "html.parser")
blocks = soup.find_all("script", type="application/ld+json")
# b.string 为空的块直接跳过,避免 json.loads 抛 TypeError
return [json.loads(b.string) for b in blocks if b.string]
def check_org_shared(all_ids: list) -> list:
# Organization 节点全站应收敛到同一组 @id,逐语言一个
org_ids = {i for i in all_ids if i.endswith("#organization")}
# 合法语言段白名单,与 .NET 侧 Langs 保持同步
langs = {"en", "de", "es"}
# 出现白名单之外的形态说明有模板绕过了生成器
unexpected = [i for i in org_ids if not any(f"/{lg}/" in i for lg in langs)]
# 返回异常组织节点清单,主流程并入报告
return unexpected
def collect_ids(data: dict, out: set) -> None:
# 递归收集 @graph 与嵌套对象里的全部 @id,供交叉校验
if isinstance(data, dict):
# 命中 @id 字段就登记,其余键继续下钻
if "@id" in data:
out.add(data["@id"])
for v in data.values():
collect_ids(v, out)
elif isinstance(data, list):
# 数组节点(如 @graph、sameAs)逐项递归
for item in data:
collect_ids(item, out)
def main() -> int:
# 命令行入口,sitemap 地址从参数读,方便本地与 CI 复用
ap = argparse.ArgumentParser()
ap.add_argument("--sitemap", required=True)
args = ap.parse_args()
# 拉取 sitemap 索引,从中抽出全部待检页面地址
urls = requests.get(args.sitemap, timeout=30).text
pages = re.findall(r"<loc>(.*?)</loc>", urls)
# 问题清单:所有校验不通过的行都汇到这里,最终决定退出码
bad = []
# 全站 @id 池,供循环结束后的组织节点收敛性检查
all_ids = set()
for page in pages:
# 单页失败不应中断全量巡检,只记录后继续
html = requests.get(page, timeout=30).text
for block in extract_jsonld(html):
ids = set()
collect_ids(block, ids)
# 并入全站 @id 池,同时供格式校验遍历
all_ids |= ids
for eid in ids:
# 校验一:@id 必须命中命名规范,格式漂移当场报错
if not ID_PATTERN.match(eid):
bad.append(f"{page} -> 非法 @id 格式: {eid}") # 校验二:mainEntity 引用的 @id 必须在本页 @graph 中可解析
graph_ids = set()
collect_ids(block.get("@graph", []), graph_ids)
for node in block.get("@graph", []):
ref = (node.get("mainEntity") or {}).get("@id")
# 悬空引用意味着页面身份声明失效,属于高危项
if ref and ref not in graph_ids:
bad.append(f"{page} -> mainEntity 悬空引用: {ref}")
# 校验三:Product 节点的 url 必须与自身 @id 去锚点后完全一致
if node.get("@type") == "Product" and node.get("url"):
expected = node["@id"].split("#")[0]
# 不一致说明模板层绕过了生成器,直接进问题清单
if node["url"] != expected:
bad.append(f"{page} -> url 与 @id 不一致: {node['url']}")
# 循环结束后补一轮组织节点检查,异常形态并入报告
bad += [f"组织节点 @id 形态异常: {i}" for i in check_org_shared(all_ids)]
for line in bad:
# 逐行打印问题清单,供流水线日志直接摘取
print(line)
# 非零退出码阻断发布,CI 流水线据此拦截
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
这套脚本上线首跑就抓出 214 个问题:163 个是旧模板残留的无 @id 页面,41 个是 mainEntity 指向的锚点在 @graph 里不存在,10 个是 @id 用了 http 而页面已全站 HTTPS。全部修完后纳入 CI,每次发布强制过门。
七、url 断链治理:别让 @id 指向坟墓
@id 的可信度取决于可解析性。治理动作有三步:改版时旧 URL 全部 301 到新路径并在 sitemap 里移除旧地址;@id 登记表与站点路由表共用一份 slug 配置,模板层不存在「第二套拼法」;巡检发现 @id 返回 404/301 时,先回滚路由再改登记表,顺序不能反——实体身份的稳定性优先于路径整洁。Perplexity 的爬虫对 301 的容忍度明显高于 404,我们在日志里观察到 301 跳转后引用归因仍能落在正确页面,而 404 之后基本就是张冠李戴的开端。
八、改造前后:引用归因数据对比
改造于 8 月 20 日全量上线,观测窗口取改造前 4 周与改造后 4 周,数据来自我们站的自建引用日志(解析 Perplexity/ChatGPT 引用来源的 UA 与落地页)加每周人工抽样 60 条 AI 回答:
| 观测指标 | 改造前(7/23-8/19) | 改造后(8/20-9/16) | 变化 |
|---|---|---|---|
| AI 引擎引用产品页且归因正确 | 38 / 60 | 55 / 60 | +45% |
| 参数张冠李戴(抽样判定) | 11 处 | 2 处 | -82% |
| Perplexity 引用落地 404/301 | 9 次 | 1 次 | -89% |
| 产品页 AI 渠道会话(月环比) | — | +27% | 参考值 |
样本量不大,别当成统计结论,但方向和机制解释是自洽的:实体身份清晰之后,引擎做归因不再需要猜。
参考与延伸
- mainEntityOfPage - Schema.org:属性定义与预期类型
- JSON-LD 1.1 语法中 @id 的语义 - Schema.org 社区组文档:节点标识与图引用机制
- 结构化数据工作原理 - Google Search Central:官方对实体与页面关系的说明
- Product 结构化数据 - Google Search Central:商品实体的推荐字段与验证方式
如果你的独立站也在做 GEO,建议先跑一遍第六节的校验脚本,看看 @id 悬空引用有多少——数字通常比想象中难看。改造中踩到别的坑,欢迎评论区交流。
关键词:GEO、AI优化AIO、独立站AI流量、mainEntityOfPage、@id、实体对齐、Schema.org