多货币报价在 AI 眼里串了行:priceCurrency 与汇率切换的 60 天引用对照

2026-09-19 09:59:44 10 次浏览
结构化数据SEO外贸独立站GEO多货币

适用读者:外贸独立站、跨境电商的技术负责人与前端工程师,负责结构化数据(Structured Data)维护的 SEO 同学,以及正在做生成式引擎优化(Generative Engine Optimization,GEO)、被 AI 回答误引报价的人。

一条客户对话记录:报的是欧元价,挂的是美元符号

「你们页面上写 1280 美元一套卡钳,AI 助手给客户的答复是 1280 欧元,客户以为我们坐地起价。」某汽配出海企业的外贸经理把这段对话截图甩进群里,后面跟着终端客户的原话。

多货币数据流经世界地图

人工打开页面核对,USD 1280,没问题。AI 回答里 EUR 1280,数字一位不差,货币单位换了。数值没漂,价格单位(price currency)串了行。

同一批 14 个 SKU 抽检里,9 个出现过数值与货币单位不匹配,AI 回答中的报价引用错误率 61%。这不是渲染 bug,浏览器里一切正常,坏在机器读到的那一层。

问题切片:三个层面各自错了什么

把这次排查拆开看,错误不是单点,是三层叠在一起。第一层是路由形态,第二层是结构化数据(Structured Data)的字段一致性,第三层是汇率快照的时点。三层单独看都不致命,合起来就变成 AI 眼里的乱报价。

层面一:货币切换只活在 URL 参数里

站点用 ?currency=EUR 切换货币。参数切换对用户友好,对抓取器(crawler)不友好:查询串(query string)在抓取调度里常被当作同一个 URL 的变体,规范化 URL(Canonical URL)指向美元版,那么欧元版页面大概率不被单独收录,也不被单独抓取。

结果是一个物理页面拖着三种货币状态,抓取器抓到哪一次算哪一次。

层面二:price 变了,priceCurrency 没跟着变

前端模板把价格数字替换成了换算后的值,但 JSON-LD 里的 priceCurrency 是硬编码的 "USD"。机器读到 price: 1280, priceCurrency: "USD",再叠上 DOM 里渲染出的 符号,两条证据互相打架。

层面三:汇率写入时机与抓取时点错位

汇率每天凌晨由定时任务写入缓存,抓取发生在白天。缓存里是 0.92 的换算系数,页面表格里展示的是实时接口拉到的 0.94。同一产品在结构化数据、DOM 文本、AI 回答三处出现三个价格。

层面 表象 机器实际读到 修复动作
路由 ?currency=EUR 参数切换 欧版页面不收录,抓美元版 改为 /eur/ 子路径(Subpath Routing)+ 独立规范化 URL
结构化数据 数字换算了,货币没换 pricepriceCurrency 不匹配 价格与货币同源生成,一次输出
汇率 日更缓存对实时接口 三处价格三个值 汇率快照(FX Snapshot)落库,全站同读一份

原理与机制剖析:AI 引擎怎么把「数字 + 货币」抽出来

抽取路径:结构化数据优先,DOM 兜底

主流引擎在抽取商品价格时,先看页面里的 JSON-LD(JavaScript Object Notation for Linked Data)。Offer 类型的 pricepriceCurrency 是两个平级字段,抽取器按字段名取值,不会去读页面上的货币符号做二次判断。换句话说,priceCurrency 写的是什么,机器就认定是什么,页面上那个 号在结构化数据阶段不参与投票。

生成式回答(AI Overview 一类)在组织答案时,会把结构化数据抽到的数值与 DOM 文本里的展示值做一次弱校验。两者不一致时,常见处理是保留数字、另行补全单位,于是出现「数字来自美元版、单位来自欧元版」的拼接错误。这次事故的完整形状就是这样。

flowchart TD
    A[产品页 HTML] --> B{是否存在 JSON-LD Offer}
    B -->|存在| C[抽取 price 与 priceCurrency]
    B -->|不存在| D[降级到 DOM 文本正则抽取]
    C --> E[得到数值与货币单位]
    D --> F[从符号推断单位]
    E --> G{结构化数值与 DOM 展示值是否一致}
    F --> G
    G -->|一致| H[回答引用正确]
    G -->|不一致| I[保留数字 另行补单位 出现串货币]

URL 参数对抓取视角的影响

抓取调度以 URL 为单位。带查询串的变体在去重阶段容易被折叠进规范化 URL 指向的那一版,欧元版因此长期不被抓取;即便被抓到,缓存里存的也常常是最早那次的结果。

子路径路由(/eur/product/...)在调度眼里是另一个 URL,有独立的抓取预算、独立的规范化声明、独立的缓存条目。这一条改动是后面所有一致性工作的前提。

flowchart LR
    subgraph BEFORE[改造前 参数切换]
        B1["/product/brake-caliper?currency=EUR"] --> B2[规范化 URL 指向美元版]
        B2 --> B3[欧版不收录 AI 只见过 USD]
    end
    subgraph AFTER[改造后 子路径]
        A1["/eur/product/brake-caliper"] --> A2[独立规范化 URL]
        A2 --> A3[JSON-LD 输出 EUR]
        A3 --> A4[AI 按路径取对应货币]
    end

priceSpecification 的一致性契约

priceSpecificationOffer 下的复合字段,用 PriceSpecification 描述「多少钱、什么币、含不含税、有没有区间」。它和简写字段 price / priceCurrency 说的是同一件事,两处必须同源。

三条约束:一是 priceCurrency 用 ISO 4217 三字母代码,不要用符号;二是 valueAddedTaxIncluded 显式声明是否含税,B2B 报价尤其不能省;三是同一产品在任一时刻只允许一套生效的 priceSpecification,历史价走另一个字段或另开节点,不要并列。

改造方案:显式声明 + 子路径路由 + 一致性校验

第一步:Offer 与货币同源生成

价格对象由同一个工厂函数产出,pricepriceCurrencypriceSpecification 从入参一次推导,不允许模板里二次改写。下面是构建脚本。

依赖与环境:Node.js 18+ 或 Next.js 14 App Router,无第三方依赖,输出直接嵌入页面 <script type="application/ld+json">

// 依赖与环境:Node.js 18+ / Next.js 14 App Router,无第三方依赖
// 汇率来自全站共用的 FX 快照,禁止在此处另行请求实时接口
// 目标:price、priceCurrency、priceSpecification 三处同源一次产出

// 基准价统一以最小货币单位(分)存储,避免浮点累加误差
function buildOffer(product, currency, fxSnapshot) {
  // 取该货币在快照中的换算系数与小数位规则
  const rule = fxSnapshot.rules[currency];
  // 基准价换算后按该货币的展示精度取整
  const amountMinor = Math.round(product.priceMinorUSD * rule.rate);
  // 最小单位还原为带小数点的展示值,EUR 与 USD 都是 2 位
  const price = (amountMinor / 100).toFixed(rule.scale);

  // 价格与货币来自同一次计算,不存在模板二次改写的机会
  return {
    "@type": "Offer",
    // 货币用 ISO 4217 三字母代码,不写符号
    priceCurrency: currency,
    // 数值与上面的货币严格配对
    price: price,
    // 库存与履约字段保持与页面一致,避免另一类不一致
    availability: product.inStock
      ? "https://schema.org/InStock"
      : "https://schema.org/OutOfStock",
    // 复合字段复述同一组数值,两处必须同源
    priceSpecification: {
      "@type": "PriceSpecification",
      // 与外层 priceCurrency 取同一个变量
      priceCurrency: currency,
      // 与外层 price 取同一个变量
      price: price,
      // B2B 场景显式声明含税口径,缺省会让机器自行猜测
      valueAddedTaxIncluded: product.taxIncluded,
    },
    // 子路径版 URL,货币信息体现在路径而非查询串
    url: `https://example.com/${currency.toLowerCase()}/product/${product.slug}`,
  };
}

// 汇率快照结构示例:rate 为相对基准货币的系数,scale 为小数位
const fxSnapshot = {
  asOf: "2026-09-19T00:00:00Z",
  rules: { USD: { rate: 1, scale: 2 }, EUR: { rate: 0.92, scale: 2 } },
};

// 同一个产品分别产出两套 Offer,各自携带自己的货币
const offerUSD = buildOffer(brakeCaliper, "USD", fxSnapshot);
const offerEUR = buildOffer(brakeCaliper, "EUR", fxSnapshot);

渲染后的 JSON-LD 片段如下,两个路径各出一份,互不复用:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Brake Caliper 4-Piston Kit",
  "sku": "BC-4P-1280",
  "offers": {
    "@type": "Offer",
    "priceCurrency": "EUR",
    "price": "1177.60",
    "availability": "https://schema.org/InStock",
    "url": "https://example.com/eur/product/brake-caliper-4p",
    "priceSpecification": {
      "@type": "PriceSpecification",
      "priceCurrency": "EUR",
      "price": "1177.60",
      "valueAddedTaxIncluded": false
    }
  }
}

第二步:子路径路由中间件

依赖与环境:Next.js 14 App Router 的 middleware.ts,或任意支持边缘中间件的框架,逻辑可直接移植到 Nginx 的 location 规则。

// 依赖与环境:Next.js 14 App Router middleware.ts,边缘运行时
// 职责:把货币写进路径,并为每个货币路径给出独立的规范化 URL

import { NextRequest, NextResponse } from "next/server";

// 站点支持的货币白名单,未列入的一律回落默认货币
const SUPPORTED = ["usd", "eur", "gbp"];
// 默认货币,用于兜底与重定向
const DEFAULT_CCY = "usd";

export function middleware(req: NextRequest) {
  const url = req.nextUrl;

  // 旧链接携带查询参数,做一次 301 迁到子路径,保留原有路径与查询
  const legacy = url.searchParams.get("currency");
  if (legacy) {
    // 参数值统一小写并对齐白名单
    const ccy = legacy.toLowerCase();
    // 清理查询串里的货币参数,避免两套机制并存
    url.searchParams.delete("currency");
    // 拼出新的子路径,携带其余查询参数
    const target = new URL(`/${SUPPORTED.includes(ccy) ? ccy : DEFAULT_CCY}${url.pathname}`, url.origin);
    target.search = url.searchParams.toString();
    // 301 让抓取器把权重迁到新地址
    return NextResponse.redirect(target, 301);
  }

  // 取出路径首段作为货币标识
  const seg = url.pathname.split("/")[1] || "";
  // 首段不在白名单,说明是裸路径,补上默认货币前缀
  if (!SUPPORTED.includes(seg.toLowerCase())) {
    const target = new URL(`/${DEFAULT_CCY}${url.pathname}`, url.origin);
    target.search = url.searchParams.toString();
    return NextResponse.redirect(target, 302);
  }

  // 命中货币子路径,把货币写进请求头供渲染层读取
  const headers = new Headers(req.headers);
  // 渲染层与 JSON-LD 构建函数都从这里取值,保证只有一个来源
  headers.set("x-currency", seg.toLowerCase());
  return NextResponse.next({ request: { headers } });
}

//  matcher 排除静态资源,减少无谓的边缘计算
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

第三步:汇率快照与一致性校验

依赖与环境:Python 3.10+,requests 可选,脚本挂在每日定时任务后执行,产出快照并做一次全站一致性扫描。

# 依赖与环境:Python 3.10+,requests 可选,挂定时任务执行
# 职责:生成当日汇率快照,并校验线上结构化数据与快照是否一致

import json
import datetime
import urllib.request

# 基准货币与需要产出的目标货币
TARGETS = ["USD", "EUR", "GBP"]
# 每种货币的展示小数位
SCALE = {"USD": 2, "EUR": 2, "GBP": 2}
# 汇率接口换成自己采购的那一家
FX_ENDPOINT = "https://api.example.com/fx/latest?base=USD"
# 一致性阈值:线上值与快照换算值相差超过 2 分即判定为串货币
TOLERANCE = 0.02
# 扫描结果落盘路径,供看板读取趋势
REPORT_PATH = "./reports/offer_consistency.json"


def build_snapshot():
    # 拉取一次接口,全站共用这一份结果
    with urllib.request.urlopen(FX_ENDPOINT, timeout=10) as resp:
        raw = json.load(resp)
    # 快照里记下时间戳,页面可展示“汇率截至”
    snap = {"asOf": datetime.datetime.utcnow().isoformat() + "Z", "rules": {}}
    # 基准货币系数恒为 1
    snap["rules"]["USD"] = {"rate": 1.0, "scale": SCALE["USD"]}
    # 其余货币按接口返回的中间价写入
    for ccy in TARGETS:
        if ccy == "USD":
            continue
        snap["rules"][ccy] = {"rate": float(raw["rates"][ccy]), "scale": SCALE[ccy]}
    return snap


def check_offer(offer, snap, expect_ccy):
    # 校验点一:外层货币必须是路径对应的货币
    if offer.get("priceCurrency") != expect_ccy:
        return f"priceCurrency 不匹配: {offer.get('priceCurrency')} != {expect_ccy}"
    # 校验点二:复合字段的货币必须与外层一致
    spec = offer.get("priceSpecification", {})
    if spec.get("priceCurrency") != expect_ccy:
        return "priceSpecification.priceCurrency 与外层不一致"
    # 校验点三:两个数值必须字符串相等,避免浮点格式化差异
    if str(offer.get("price")) != str(spec.get("price")):
        return f"price 与 priceSpecification.price 不一致: {offer.get('price')} / {spec.get('price')}"
    # 校验点四:数值必须由当前快照换算得出,浮动超过阈值即报警
    # 阈值与上面的 TOLERANCE 共用,避免两处口径不一致
    expected = round_expected(offer, snap, expect_ccy)
    if abs(float(offer["price"]) - expected) > TOLERANCE:
        return f"价格偏离快照换算值: {offer['price']} vs {expected}"
    return None


def round_expected(offer, snap, ccy):
    # 以基准价乘以快照系数,按展示精度取整
    base = float(offer["_baseMinorUSD"]) / 100
    rate = snap["rules"][ccy]["rate"]
    return round(base * rate, SCALE[ccy])

60 天对照:某汽配出海企业的引用正确率

改造分两批上线:第 0 天完成子路径路由与 301 迁移,第 6 天完成 JSON-LD 同源改造与快照校验,之后进入 60 天观测。观测口径是每天固定 20 组提问,覆盖 14 个 SKU,记录 AI 回答里出现的数值与货币是否与对应路径页面的结构化数据一致。

观测指标 改造前(前 30 天均值) 改造后(后 30 天均值) 变化
报价引用正确率 39% 92% +53 个百分点
数值正确但货币错误占比 34% 3% 下降 31 个百分点
数值与货币全错占比 27% 5% 下降 22 个百分点
欧版页面被单独抓取的次数/周 2 41 提升约 20 倍
同一 SKU 出现三个价格的次数 58 4 下降 93%

分错误类型看得更清楚。参数路由时期的主要错误是「数值对、单位错」,因为机器只见过美元版的 priceCurrency,却从欧元路径的文本里补了单位。子路径上线后欧版有了独立抓取条目,这一类错误在两周内基本消失。剩下 5% 里的多数是汇率快照更新当天抓取到的旧缓存,属于缓存失效延迟,靠缩短缓存 TTL(Time To Live)继续压。

错误类型 改造前占比 改造后占比 主要成因
数值对、货币错 34% 3% 参数变体未被抓取,priceCurrency 停在默认货币
数值错、货币对 19% 2% 实时接口与日更缓存并存
数值与货币全错 27% 5% 快照更新当天的旧缓存未失效
未引用任何价格 20% 8% 结构化数据字段缺失导致降级抽取

误区澄清与趋势预判

一个常见误区是以为多货币站点只要在页面上把符号换掉就够了。机器读的是字段不是像素,符号换掉不影响 JSON-LD,也不影响抓取器抓哪一版。另一个误区是把 priceCurrency 写成动态变量却从两个来源取值,页面渲染读一个、结构化数据读另一个,等价于没改。

汇率精度也容易踩。不同货币的小数位规则不同,日元这类零小数位货币按两位输出会出现 1280.00 这种机器能读但明显不对的值,展示精度应当随货币走。

往后看,多货币报价的机器可读性会越来越依赖路径层面的显式信号。查询串驱动的视图层状态,对生成式引擎(Generative Engine)来说是几乎不可见的;把货币、语言、地区这类会影响数值语义的维度写进路径,等于给抓取器一张稳定的地图。结构化数据这一侧的收敛方向也很明确:简写字段与复合字段同源,含税口径显式声明,汇率快照可追溯。

具体到落地,我建议的起手顺序是先改路由,再改结构化数据,最后上校验。路由不改,后面两件事做完了也只覆盖到美元版;校验脚本不上线,改造完一个月就会因为某次汇率接口变动悄悄退化。你们站点在多货币这块踩过哪一类坑,评论区聊聊,尤其是缓存失效和抓取时点那些难复现的。

参考与延伸

多货币报价 · priceCurrency · JSON-LD Offer · 子路径路由 · 结构化数据 · GEO · AI优化AIO

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