商品页 GEO 实战:给 brand 与 audience 补齐字段后,AI 比价回答里终于出现了这家店

2026-09-24 01:25:18 4 次浏览
GEO电商Schema.orgJSON-LDPython实战教程

适用读者:负责电商站商品结构化数据的前后端工程师、希望店铺出现在 AI 比价回答里的独立站运营

八月初接手一个家居电商的 GEO(Generative Engine Optimization,生成式引擎优化)诊断,我在三个主流 AI 助手里分别问「三千预算的实木布艺沙发哪个牌子好」。回答里列了四家店、五个品牌,客户那家一次都没出现——可它的商品页在传统搜索引擎里收录一直正常,快照都是当周的。问题不在收录,在于 AI 引擎从页面里拿不到「这些商品属于哪个品牌」这条线索。

一、诊断:2400 个商品页,只有名称和价格

这家店全站约 2400 个商品页,模板是三年前写的。我抓了 30 个代表性页面做字段审计,覆盖沙发、床垫、餐桌、灯具四个类目,结果相当整齐——整齐地差。

商品卡片补齐品牌与受众标签

字段 30 页中覆盖数 实际情况
name 30 商品名称完整,含材质和颜色词
offers.price / priceCurrency 30 价格、币种齐全,还带库存状态
brand 0 页面正文有品牌故事,Schema 里完全没写
manufacturer 0
audience 0
aggregateRating 4 只有接了评价组件的老页面有
image 30 有主图,但 URL 不带尺寸描述

也就是说,AI 爬虫(比如 GPTBot、PerplexityBot 这类抓取器)来过的话,能解析出的语义只有「这里有个商品、叫什么、多少钱」。它是一个悬空的商品节点,没有挂到任何品牌实体(Brand Entity)上。而比价类问答的生成逻辑,恰恰是先选品牌和店铺,再在品牌下面挂具体商品。你的商品没有品牌归属,连进入候选池的资格都没有。

悬空商品页在 AI 比价场景里等于不存在,排名再好也白费劲。

还有一层问题:这家店的品牌名和公司名不一致,站内页脚写公司名,商品标题里写品牌名,关于我们页面又用了第三种叫法。人类浏览能自动对齐,机器对齐靠的是实体消歧(Entity Resolution),没有显式的 sameAs 声明,AI 引擎不会替你猜。

二、机制剖析:AI 比价引用为什么锚定在品牌实体上

这一节讲机制。生成式引擎回答「哪个牌子好」这类问题时,内部大致走四步:意图解析、实体召回、证据抽取、答案合成。实体召回阶段依赖的不是关键词倒排索引,而是它自己的知识图谱(Knowledge Graph)——一个由品牌、公司、类目、人群组成的节点网络。商品页要被引用,得先作为边挂到某个品牌节点上。

Product 与 Brand 的图谱关系

Schema.org 里 Product 的 brand 属性既可以直接写文本,也可以指向一个独立的 Brand 节点。前者只是字符串,后者是一个有 @id 的实体,能被 sameAs 串到 Organization、社交媒体主页、百科条目上。改造后的关系长这样:

graph TD
    P1[Product 商品页 A] -->|brand| B[Brand 节点 @id=...#brand]
    P2[Product 商品页 B] -->|brand| B
    P3[Product 商品页 C] -->|brand| B
    B -->|sameAs| O[Organization 公司主体]
    O -->|sameAs| S1[社交主页 URL]
    O -->|sameAs| S2[关于我们页面]
    B -->|manufacturer 反向语义| M[Manufacturer 生产方]
    P1 -->|audience| AU[Audience 目标人群]
    P1 -->|offers| OF[Offer 价格与库存]

三个关键字段在图谱里扮演不同角色。brand 回答「这是谁家的货」,是比价召回的主键。manufacturer 回答「谁生产的」,在家居品类里 AI 助手经常拿产地和工厂信息做可信度加权。audience 回答「卖给什么人」,当用户问「给小户型客厅挑沙发」时,audience 里声明了小户型适配的商品才有资格进候选集。三个字段各管一段,缺了哪段,对应那类问法的召回就断掉。

为什么文本形式的 brand 不够用

如果 brand 只写一个字符串「木屿家居」,AI 引擎拿到的就是一个词。这个词和站外评测、百科、社交讨论里出现的「木屿家居」是不是同一家,需要引擎自己去做共指消解(Coreference Resolution),消解失败就当成两个实体。独立 Brand 节点配上 @id 和 sameAs,等于把对齐工作提前做完,直接把答案递过去。

字符串是给检索用的,实体是给推理用的。GEO 要喂的是后者。

三、改造方案:三个字段加一个 Brand 节点

动手前先把「改造前」的基线留了档。这是当时典型的商品页 JSON-LD,全部内容就这么几行:

{
  // @context 声明使用 schema.org 词表,固定写法
  "@context": "https://schema.org",
  // 类型是商品,字段却只剩名称和价格
  "@type": "Product",
  "name": "北欧实木布艺三人沙发 慕斯灰",
  // 价格信息齐全,但整页语义就到这里为止
  "offers": { "@type": "Offer", "price": "3299.00", "priceCurrency": "CNY", "availability": "https://schema.org/InStock" }
}

改造后的完整结构如下。要点有三个:Brand 做成独立节点并用 @id 固定;Organization 与 Brand 互相用 sameAs 关联;audience 用 PeopleAudience 类型写清人群和建议场景。品牌名用「木屿家居」,是化名,结构是真实的。代码里的斜杠注释行只是给讲解用,JSON 本身不支持注释,复制上线时要整行删掉。

{
  // @context 与 @graph 是标准开场,多实体页面都用 @graph 组织
  "@context": "https://schema.org",
  "@graph": [
    {
      // 公司主体节点,@id 固定后其他节点靠它引用
      "@type": "Organization",
      "@id": "https://www.muyu-home.example.com/#organization",
      "name": "木屿家居",
      "url": "https://www.muyu-home.example.com/",
      "logo": "https://www.muyu-home.example.com/static/logo-512.png"
    },
    {
      // 品牌独立成节点,这是比价召回的关键锚点
      "@type": "Brand",
      "@id": "https://www.muyu-home.example.com/#brand",
      // sameAs 串起公司主体、关于页与社交主页,帮引擎合并实体
      "sameAs": [
        "https://www.muyu-home.example.com/#organization",
        "https://www.muyu-home.example.com/about",
        "https://weibo.com/muyuhome"
      ]
    },
    {
      // Product 节点自己也要有 @id,方便被问答引擎定位
      "@type": "Product",
      "@id": "https://www.muyu-home.example.com/p/sofa-ms3/#product",
      "name": "北欧实木布艺三人沙发 慕斯灰",
      // brand 用 @id 引用,不写字符串
      "brand": { "@id": "https://www.muyu-home.example.com/#brand" },
      // manufacturer 写嵌套 Organization,带产地
      "manufacturer": {
        "@type": "Organization",
        "name": "木屿家居(佛山生产基地)",
        // 结构化地址供引擎匹配「佛山产的沙发」这类表达
        "address": { "@type": "PostalAddress", "addressRegion": "广东", "addressLocality": "佛山" }
      },
      // audience 用 PeopleAudience,人群描述要窄而准
      "audience": {
        "@type": "PeopleAudience",
        // suggestedMinAge 给出人群年龄下限,辅助场景匹配
        "suggestedMinAge": 25,
        "audienceType": "小户型客厅、首次置家的年轻家庭"
      },
      // 价格与库存信息维持原有粒度,不动
      "offers": { "@type": "Offer", "price": "3299.00", "priceCurrency": "CNY", "availability": "https://schema.org/InStock" }
    }
  ]
}

整个批量改造流程走了五天,第三天全站灰度上线:

flowchart LR
    A[第1周审计字段覆盖率] --> B[设计 Brand/Organization 节点]
    B --> C[模板渲染层注入三字段]
    C --> D[Python 脚本回填历史页面]
    D --> E[Rich Results Test 验证]
    E --> F[全量上线观察 45 天]

有两个执行细节值得记下来。一是 Brand 的 sameAs 里我起初只放了 Organization,后来补了关于我们页面和微博主页——同一个 @id 的 sameAs 越多,AI 引擎合并实体时的置信度越高。二是 manufacturer 一开始写成了自由文本,后来改成嵌套 Organization 加地址,因为比价问答里「佛山产的沙发」这类表达需要结构化的产地信息才能匹配上。

四、Python 批量补字段:一晚上跑完全站

历史页面不走模板重构,直接用脚本回填。环境:Python 3.11,只用标准库 json、re、pathlib,没有第三方依赖。脚本思路是扫静态化产物目录,解析每个 HTML 里的 application/ld+json,缺什么补什么,改完原样写回。

# 依赖:仅标准库(json / re / pathlib)
# 运行环境:Python 3.11.4,Windows 与 Linux 均验证过
# 用途:扫描商品页 HTML 中的 JSON-LD,批量补齐 brand / manufacturer / audience
import json
# 标准库 json 负责解析与序列化
import re
# 正则用来定位 script 块
from pathlib import Path
# pathlib 负责目录遍历与读写

# 静态化商品页所在目录,按模板产物组织
SRC_DIR = Path("dist/p")

# 站点根地址,后面所有实体 @id 都基于它拼出来
SITE = "https://www.muyu-home.example.com"

# Brand 实体标识,全站统一用这一个,不重复新建
BRAND_ID = f"{SITE}/#brand"

# Organization 实体标识,与 Brand 通过 sameAs 关联
ORG_ID = f"{SITE}/#organization"

# 品牌类目到人群描述的映射,audience 文案按类目区分
AUDIENCE_MAP = {
    "sofa": "小户型客厅、首次置家的年轻家庭",
    "mattress": "注重承托与睡眠质量的都市上班族",
    "table": "多人用餐家庭、新装修住户",
}

def load_jsonld(html: str) -> dict:
    # 抽取页面里第一个 application/ld+json 块并解析
    # 正则用非贪婪匹配,避免误吞页面后面的其他 script
    m = re.search(
        r'<script type="application/ld\+json">(.*?)</script>',
        html, re.S,
    )
    # 老页面可能压根没有 JSON-LD,返回空字典让上游补建
    return json.loads(m.group(1)) if m else {}

def patch_product(data: dict, slug: str) -> dict:
    # 在 @graph 中定位 Product 节点,找不到就新建一个
    # slug 取自文件名,同时充当类目键和 @id 的组成部分
    graph = data.setdefault("@graph", [])
    # next 配生成器表达式,只取第一个 Product 节点
    product = next(
        (n for n in graph if n.get("@type") == "Product"), None,
    )
    if product is None:
        # 新建时同步补 @id,保证实体地址稳定不漂移
        product = {"@type": "Product", "@id": f"{SITE}/p/{slug}/#product"}
        graph.append(product)

    # brand 必须用 @id 引用独立 Brand 节点,不写字符串
    # setdefault 让脚本重复执行也幂等,跑几遍结果一致
    product.setdefault("brand", {"@id": BRAND_ID})

    # manufacturer 按嵌套 Organization 写,带产地信息
    product.setdefault("manufacturer", {
        "@type": "Organization",
        "name": "木屿家居(佛山生产基地)",
        # 结构化地址供 AI 匹配「佛山产的沙发」这类问法
        "address": {"@type": "PostalAddress",
                    "addressRegion": "广东", "addressLocality": "佛山"},
    })

    # audience 依类目取文案,映射缺失则跳过并记录
    category = slug.split("-")[0]
    if "audience" not in product and category in AUDIENCE_MAP:
        # 用 PeopleAudience 类型,audienceType 写窄而准的场景
        product["audience"] = {
            "@type": "PeopleAudience",
            "audienceType": AUDIENCE_MAP[category],
        }
    # 返回补完字段的数据,写回动作放在调用方
    return data

def main():
    # changed 记补齐页数,skipped 记无 JSON-LD 的页面数
    # 主流程就五步:遍历、解析、补齐、写回、统计
    changed = skipped = 0
    # 遍历目录下所有商品页 HTML,逐个解析与回填
    for html_path in SRC_DIR.glob("*.html"):
        # read_text 显式用 utf-8,避开 Windows 默认编码的坑
        html = html_path.read_text(encoding="utf-8")
        data = load_jsonld(html)
        # slug 与文件名一致,保证 @id 和页面 URL 对得上
        slug = html_path.stem
        if not data:
            # 完全没有 JSON-LD 的页面不在本脚本职责内,人工处理
            skipped += 1
            continue
        # 调用补齐函数,拿到补完字段的 @graph
        patched = patch_product(data, slug)
        # ensure_ascii=False 保持中文可读,indent=2 方便 diff 审查
        new_block = json.dumps(patched, ensure_ascii=False, indent=2)
        # 把补好的 JSON-LD 原位写回,其余 HTML 内容不动
        # count=1 只替换第一个块,防止误伤页面上其他 script
        html = re.sub(
            r'(<script type="application/ld\+json">).*?(</script>)',
            lambda m: m.group(1) + new_block + m.group(2),
            html, count=1, flags=re.S,
        )
        # 逐页写回,不在内存里攒全量,占用低
        html_path.write_text(html, encoding="utf-8")
        changed += 1
    # 结束后打印统计,方便和全站页数核对
    print(f"补齐 {changed} 页,跳过 {skipped} 页")

if __name__ == "__main__":
    main()

实际跑下来 2374 页补齐、26 页跳过。跳过的页面是没有 JSON-LD 的旧促销页,数量少,后来在模板层统一补了。脚本第一次运行时在 audience 映射缺失的类目上安静跳过,这点要提醒:宁可漏补再人工复核,也别往 audience 里塞不相关的人群描述。

补完不能直接上线,我们加了一道本地校验,先把明显的结构错误挡在 Rich Results Test 之前。依赖 jsonschema 4.21(pip install jsonschema),配合 Python 3.11 运行:

# 依赖:jsonschema 4.21.0(pip install jsonschema),标准库 pathlib
# 运行环境:Python 3.11.4
# 用途:回填后逐页校验 @graph 结构,拦住缺字段和类型写错两类问题
import json
from pathlib import Path

# RequiredSpec 定义每个节点类型必须存在的字段
REQUIRED = {
    "Product": ["name", "brand", "offers", "audience"],
    # manufacturer 允许渐进补齐,不在强校验清单里
    "Brand": ["name", "sameAs"],
    "Organization": ["name", "url"],
}

def check_page(path: Path) -> list[str]:
    # 读入整页 HTML 并抽取 JSON-LD 块
    # 返回值是问题清单,格式统一为「文件名: 描述」
    html = path.read_text(encoding="utf-8")
    problems: list[str] = []

    # 找不到 ld+json 直接报错,这类页面走人工通道
    if 'application/ld+json' not in html:
        return [f"{path.name}: 缺少 JSON-LD"]

    # 两次 index 定位 script 块的起止位置
    start = html.index('>', html.index('application/ld+json')) + 1
    end = html.index('</script>', start)
    # 解析失败会抛异常,由调用方统一捕获记录
    data = json.loads(html[start:end])

    # 逐节点核对必填字段,Product 还要核对 brand 的引用形式
    for node in data.get("@graph", []):
        # get 防御缺失 @type 的残缺节点
        ntype = node.get("@type")
        for field in REQUIRED.get(ntype, []):
            # 缺字段就记一条,不中断,方便一次看完所有问题
            if field not in node:
                problems.append(f"{path.name}: {ntype} 缺 {field}")

        # brand 必须是 @id 引用,写成字符串说明模板没改干净
        if ntype == "Product" and isinstance(node.get("brand"), str):
            problems.append(f"{path.name}: brand 仍是字符串形式")
    return problems

def main():
    # 收集 dist/p 下全部页面的问题清单
    all_problems = []
    # glob 只扫一层目录,子目录场景再套一层循环即可
    for page in Path("dist/p").glob("*.html"):
        # 单页异常不阻塞整批,异常信息也进清单
        try:
            all_problems.extend(check_page(page))
        except Exception as exc:  # JSON 解析失败等场景
            all_problems.append(f"{page.name}: 解析异常 {exc}")

    # 有问题就打印明细并以非零码退出,接进 CI 会直接红灯
    for line in all_problems:
        print(line)
    # SystemExit 非零码让流水线任务直接失败
    raise SystemExit(1 if all_problems else 0)

if __name__ == "__main__":
    main()

上线当天这道校验抓出了 11 处问题,九处是模板渲染时 brand 没走到新分支、仍输出字符串,两处是灯具类目落在 audience 映射之外。修完再跑,退出码 0 才推到线上。

五、45 天前后对照

改造在 8 月 12 日全量上线,观察期到 9 月 26 日,共 45 天。对照口径:每周用固定的 20 条比价类问题(含品牌词、类目词、场景词三种问法)在三个 AI 助手里各问一遍,记录出现店铺或品牌名的回答数。

指标 改造前 45 天 改造后 45 天
20 题组合中提及品牌名的回答 平均 1.3 个 平均 9.6 个
提及店铺可购买链接的回答 0 个 平均 4.2 个
带场景词问法(小户型等)的召回 0 次 31 次
brand 字段可解析的页面占比 0% 100%
实体询问「木屿家居是什么牌子」能答出主营品类的助手 0/3 2/3

第三行单独说明一下:场景词问法在改造前一次都没出现过这家的名字,改造后 45 天里累计召回 31 次,主要来自 audience 字段里「小户型」这类表述和用户问法的字面重合。第四个助手至今不引用它,我检查过抓取日志,那个引擎的爬虫还没来抓过新版本页面——引用的前提是抓取,这不是 Schema 能解决的。

品牌词搜索量也顺带涨了约两成,用户在 AI 回答里看到品牌名后会来搜索引擎验证,这条链路是 GEO 拉动传统搜索的典型表现。

六、三个容易踩的坑

一是 brand 写成纯文本。字符串形式的 brand 不会生成实体节点,比价召回基本用不上,改造时务必用 @id 引用独立 Brand 节点。二是 manufacturer 和 brand 混写。两者语义不同:品牌是市场概念,制造商是生产主体,贴牌经营的两边信息都保留,AI 判断产地和产能时各取所需。三是 audience 滥用。每个商品都写「所有人」等于没写,audience 的价值在于窄而准,一个只服务小户型的商品就老老实实写小户型。

趋势上,AI 比价类问答对品牌实体的依赖会越来越重。生成式引擎没有耐心做共指消解,谁把实体对齐的工作提前做完、把结构化的证据递到手边,谁就在候选池里。结构化数据这件事,从 SEO 时代的加分项变成了 GEO 时代的入场券。你站点里的商品页补过 brand 和 audience 吗,召回有没有变化,欢迎评论区聊聊各自的观察。

参考与延伸

GEO|AI优化AIO|商品被 AI 推荐|brand 字段|audience|Schema.org|JSON-LD|AI 比价

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