一个页面一个身份:mainEntityOfPage 与 @id 让产品页成为 AI 引擎里的独立实体

2026-09-30 01:28:16 0 次浏览
GEOAI搜索mainEntityOfPage实体对齐JSON-LD

9 月 12 日早上,我用 Perplexity 查自家一款防腐离心风机的型号参数,回答里把 4kW 的电机功率安到了竞品的同尺寸机型上。追到引用来源才发现:两家站的 Product 结构化数据都没写 @id,页面又被同一个行业目录页同时收录,AI 引擎抽取实体时根本分不清「谁是谁」。当天我把全站 1,842 个产品页按实体图谱重新改造,四周后同类错误归因从抽样里的 11 处降到 2 处。这篇文章把整套做法拆开讲清楚。

一、问题现场:AI 引擎为什么张冠李戴

传统 SEO 时代,页面混一点没关系——搜索引擎把链接和标题存进索引,用户点进来自己看。生成式引擎的链路完全不同:爬取、抽取实体、对齐、生成答案,四个环节里「对齐」是最容易出错的一步。ChatGPT 的浏览检索、Perplexity 的引用归因、Gemini 的 grounding,都要回答同一个问题:这段参数到底属于哪个实体?

一个页面一个身份:mainEntityOfPag

我们站原来的 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% 参考值

样本量不大,别当成统计结论,但方向和机制解释是自洽的:实体身份清晰之后,引擎做归因不再需要猜。

参考与延伸

如果你的独立站也在做 GEO,建议先跑一遍第六节的校验脚本,看看 @id 悬空引用有多少——数字通常比想象中难看。改造中踩到别的坑,欢迎评论区交流。

关键词:GEO、AI优化AIO、独立站AI流量、mainEntityOfPage、@id、实体对齐、Schema.org

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