退货和运费政策别只藏在帮助中心:MerchantReturnPolicy 与 shippingDetails 落地实战

退货和运费政策别只藏在帮助中心:MerchantReturnPolicy 与 shippingDetails 落地实战

适用读者:自营电商与独立站后端工程师、维护商品结构化数据的数据/前端工程师、正在做 GEO 与 AIO 的技术负责人

从一次客服对账说起

去年 12 月第二个周二,客服主管把一张对账表摔进群。37 笔退款申请里,11 笔的理由栏写着同一句话:AI 说 30 天无理由,你们页面只写 7 天。截图也在,客服只能先垫付再走审批。

复盘时我们发现,帮助中心那页其实写得挺全:7 天无理由、退货运费买家承担、新疆西藏 7 到 12 天。问题是这些数据只以自然语言散文的形式存在,机器读不出一个确定值

客户转头去问 AI 引擎「这家能退吗」,引擎抓走的是一段含糊的表述,甚至混进了别家站点的相似段落。我们服务的那家自营电商,SKU 约 4200 个,退货政策起初只有三种,后来被运营拆到 9 种,靠人工维护帮助中心早就跟不上了。

政策数据一直在库里,缺的是可被机器读的那一层

这家站点的政策数据分散在三张表里:policy_return_rule 存退货窗口与费用承担方,policy_shipping_rule 存运费模板和时效区间,product_policy_bind 把 SKU 绑到具体规则上。数据是齐的,缺的是输出层。

我们评估过三条路。把政策塞进商品详情页的可见文案,读起来啰嗦,运营也不愿意逐条 SKU 改。做一个帮助中心 API 让引擎自己调,引擎不会主动调。剩下的路是把政策按 schema.org 的类型结构输出到 JSON-LD(JavaScript Object Notation for Linked Data),也就是 MerchantReturnPolicyOfferShippingDetails 两个类型。

结构化字段的价值不在于给搜索引擎看,而在于给答案抽取器一个可以直接搬走的槽位。 散文要被理解,字段只要被匹配。

六个字段先看清楚:MerchantReturnPolicy 与 OfferShippingDetails

先分清层级关系,这块踩坑最集中。hasMerchantReturnPolicy 可以挂在 Organization(或 OnlineStore)上作为全站标准政策,也可以挂在 Offer 上做单品覆盖;shippingDetails 挂在 Offer 上。两套层级同时存在时,商品级的政策优先于组织级。

字段 所属类型 取值形态 我们的取值示例 常见坑
returnPolicyCategory MerchantReturnPolicy 枚举 URL https://schema.org/MerchantReturnFiniteReturnWindow 写成 FiniteReturnWindow 这种短码,整段政策被丢弃
merchantReturnDays MerchantReturnPolicy 整数 7 与「不支持退货」类目互斥,同时出现即冲突
returnShippingFeesAmount MerchantReturnPolicy MonetaryAmount 对象 {"value":12.00,"currency":"CNY"} 只在 returnFeesReturnShippingFees 时才有意义
shippingRate OfferShippingDetails MonetaryAmount,每实例一个 {"value":0,"currency":"CNY"} 想表达多档运费必须拆成多个 shippingDetails
deliveryTime OfferShippingDetails ShippingDeliveryTime,含处理与运输两段 处理 0-1 天 + 运输 2-4 天 unitCode 只认 DAYd,写 days 无效
shippingDestination OfferShippingDetails DefinedRegion 全国一条 + 偏远地区一条 addressRegion 用中文行政区名,识别不了

returnFees 的取值要特别小心。Google 支持的三个值是 FreeReturnReturnFeesCustomerResponsibilityReturnShippingFees。前两个不能带 returnShippingFeesAmount,第三个必须带非零金额。

我们最初把「买家承担运费」错填成 ReturnShippingFees 却不给金额,结果解析器把整个 returnFees 丢掉,AI 回答退回「政策未说明」。改成 ReturnFeesCustomerResponsibility 之后才稳定。

从政策表到 AI 回答:整条链路长什么样

数据从 MySQL 出来到被 AI 引用,中间有五个环节会掉信息:枚举映射、字段校验、JSON-LD 拼装、页面注入、抓取与解析。
#mermaid-svg-3l9qg2Lorf8Dh8F6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .error-icon{fill:#552222;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .marker.cross{stroke:#333333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 p{margin:0;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster-label text{fill:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster-label span{color:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster-label span p{background-color:transparent;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .label text,#mermaid-svg-3l9qg2Lorf8Dh8F6 span{fill:#333;color:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .node rect,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node circle,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node ellipse,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node polygon,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .rough-node .label text,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node .label text,#mermaid-svg-3l9qg2Lorf8Dh8F6 .image-shape .label,#mermaid-svg-3l9qg2Lorf8Dh8F6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .rough-node .label,#mermaid-svg-3l9qg2Lorf8Dh8F6 .node .label,#mermaid-svg-3l9qg2Lorf8Dh8F6 .image-shape .label,#mermaid-svg-3l9qg2Lorf8Dh8F6 .icon-shape .label{text-align:center;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .node.clickable{cursor:pointer;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .arrowheadPath{fill:#333333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3l9qg2Lorf8Dh8F6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3l9qg2Lorf8Dh8F6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster text{fill:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .cluster span{color:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3l9qg2Lorf8Dh8F6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .icon-shape,#mermaid-svg-3l9qg2Lorf8Dh8F6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .icon-shape p,#mermaid-svg-3l9qg2Lorf8Dh8F6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .icon-shape .label rect,#mermaid-svg-3l9qg2Lorf8Dh8F6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3l9qg2Lorf8Dh8F6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3l9qg2Lorf8Dh8F6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3l9qg2Lorf8Dh8F6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
不通过
policy_return_rule

policy_shipping_rule
枚举映射层

内部短码 到 schema.org URL
字段校验
拼装 JSON-LD

Offer 节点
打告警日志

丢弃该字段
注入商品详情页 head

ld+json
爬虫抓取
结构化字段进事实槽位
回答 能否退 / 谁付运费 / 几天到

链路里有一步容易被忽略:校验失败的字段不是保留原值,而是直接消失。下游看到的不是错误数据,是空值。

一份可以直接改的 JSON-LD

先把最终产物贴出来,后面再讲怎么批量生成。这是那件羽绒服 SKU 的完整 Product 节点,含一件全国包邮政策与一条新疆西藏偏远地区政策。

json 复制代码
{
  "@context": "https://schema.org",
  "@type": "Product",
  "sku": "OXY-DW-800-BK-M",
  "offers": {
    "@type": "Offer",
    "priceCurrency": "CNY",
    "price": "899.00",
    "availability": "https://schema.org/InStock",
    "hasMerchantReturnPolicy": {
      "@type": "MerchantReturnPolicy",
      "applicableCountry": "CN",
      "returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
      "merchantReturnDays": 7,
      "merchantReturnLink": "https://example-shop.com/help/return",
      "returnMethod": "https://schema.org/ReturnByMail",
      "returnFees": "https://schema.org/ReturnShippingFees",
      "returnShippingFeesAmount": { "@type": "MonetaryAmount", "value": 12.00, "currency": "CNY" },
      "refundType": "https://schema.org/FullRefund"
    },
    "shippingDetails": [
      { "@type": "OfferShippingDetails",
        "shippingRate": { "@type": "MonetaryAmount", "value": 0, "currency": "CNY" },
        "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "CN" },
        "deliveryTime": { "@type": "ShippingDeliveryTime",
          "handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1, "unitCode": "DAY" },
          "transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 4, "unitCode": "DAY" } } },
      { "@type": "OfferShippingDetails",
        "shippingRate": { "@type": "MonetaryAmount", "value": 25.00, "currency": "CNY" },
        "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "CN", "addressRegion": ["XJ", "XZ"] },
        "deliveryTime": { "@type": "ShippingDeliveryTime",
          "handlingTime": { "@type": "QuantitativeValue", "minValue": 1, "maxValue": 2, "unitCode": "DAY" },
          "transitTime": { "@type": "QuantitativeValue", "minValue": 7, "maxValue": 12, "unitCode": "DAY" } } }
    ]
  }
}

偏远地区那条的 addressRegion 写的是 XJXZ,不是中文省名。Google 搜索侧只识别少数国家的 ISO 3166-2 细分码,用中文省名会让整条 shippingDestination 判为无效,退回「全国」口径------AI 就会答成「新疆也是 2 到 4 天」。

批量生成:Python 3.10 + pymysql 1.1 从政策表产出 JSON-LD

落地的核心是下面这个脚本。依赖与环境版本:

  • Python 3.10.13
  • pymysql 1.1.3(pip install "pymysql>=1.1,<2.0"
  • MySQL 8.0.36,连接字符集固定 utf8mb4

脚本按 SKU 分批读取,逐条做枚举映射和互斥校验,最后把 JSON-LD 写成静态文件,由发布流程注入详情页 <head>

python 复制代码
# 依赖与环境版本:Python 3.10.13 / pymysql 1.1.3 / MySQL 8.0.36
# 安装:pip install "pymysql>=1.1,<2.0"
# 输出格式是 JSON-LD,只用标准库的 json 就够
import json
# MySQL 驱动用 pymysql,与 MySQL 8 的 utf8mb4 兼容良好
import pymysql

# schema.org 的枚举必须写完整 URL,短码会被判为未知类型
S = "https://schema.org/"

# 后台短码到官方枚举的映射之一:退货窗口类目
RETURN_CATEGORY = {
    # 有限退货窗口,必须同时给 merchantReturnDays
    "finite": S + "MerchantReturnFiniteReturnWindow",
    # 无期限窗口,与天数互斥
    "unlimited": S + "MerchantReturnUnlimitedWindow",
    # 明确不支持退货,同样与天数互斥
    "forbidden": S + "MerchantReturnNotPermitted",
}

# 映射之二:退货运费承担方,三种语义不能混用
RETURN_FEES = {
    # 商家承担退货运费,不允许带金额
    "merchant": S + "FreeReturn",
    # 买家自己寄并自己付,同样不允许带金额
    "buyer": S + "ReturnFeesCustomerResponsibility",
    # 买家把运费付给商家,必须带非零金额
    "buyer_pay_merchant": S + "ReturnShippingFees",
}

# 映射之三:退货方式,只在允许退货时有意义
RETURN_METHOD = {
    # 邮寄退回
    "mail": S + "ReturnByMail",
    # 门店退回
    "store": S + "ReturnInStore",
    # 自提点退回,目前尚未开放
    "kiosk": S + "ReturnAtKiosk",
}

# 偏远地区用 ISO 3166-2 细分码,中文省名在搜索侧不被识别
REMOTE_REGIONS = {"XJ", "XZ", "NM", "QH"}

# 运费币种必须与 Offer 的 priceCurrency 一致,否则整条配送无效
CURRENCY = "CNY"

# 组装 MonetaryAmount,必须是对象形态
def money(value):
    # 裸数字会丢掉币种信息
    return {"@type": "MonetaryAmount", "value": float(value), "currency": CURRENCY}

# 组装时效区间
def span(lo, hi):
    # unitCode 只接受 DAY 或 d,别的写法一律无效
    return {"@type": "QuantitativeValue", "minValue": int(lo), "maxValue": int(hi), "unitCode": "DAY"}

# 生成退货政策节点,枚举映射失败时返回 None
def build_return(row, warn, sku):
    # 固定属性先一次性铺开
    policy = {"@type": "MerchantReturnPolicy", "applicableCountry": "CN"}
    # applicableCountry 固定 CN,站点目前只发大陆
    # merchantReturnLink 指向帮助中心那页,与政策数据同源
    policy["merchantReturnLink"] = row["policy_url"]
    # 类目先过一次白名单映射
    category = RETURN_CATEGORY.get(row["return_window_type"])
    # 映射失败说明运营填了系统不认识的值
    if category is None:
        # 告警带上 sku 与原始值,方便回查后台记录
        warn.append((sku, "return_window_type", row["return_window_type"]))
        # 宁可整段不出,也不要输出半截政策
        return None
    # 通过校验后才写入类目
    policy["returnPolicyCategory"] = category
    # 只有有限窗口才写天数,其余情况宁可不写也不写 0
    if row["return_window_type"] == "finite":
        policy["merchantReturnDays"] = int(row["return_days"])
    # 退货方式映射不到就不输出该字段
    if row["return_method"] in RETURN_METHOD:
        policy["returnMethod"] = RETURN_METHOD[row["return_method"]]
    # 运费承担方同样先过映射表
    fee_code = RETURN_FEES.get(row["return_fee_payer"])
    # 映射失败就不写 returnFees,而不是写一个猜的值
    if fee_code:
        policy["returnFees"] = fee_code
    # 只有买家付给商家这一类才需要金额
    if row["return_fee_payer"] == "buyer_pay_merchant":
        # 金额非零才写,写 0 会被解析成免费退货
        if row["return_fee_amount"]:
            policy["returnShippingFeesAmount"] = money(row["return_fee_amount"])
        # 金额缺失时降级为买家自理,保住整个节点可用
        else:
            warn.append((sku, "return_fee_amount", "missing"))
            policy["returnFees"] = S + "ReturnFeesCustomerResponsibility"
    # 全额退款目前是站点的默认策略
    if row["refund_type"] == "full":
        # refundType 同样只接受枚举 URL
        policy["refundType"] = S + "FullRefund"
    # 返回完整政策节点交给上游拼装
    return policy

# 生成配送节点列表,一个实例只带一个 shippingRate
def build_shipping(rows, warn, sku):
    # 结果先攒在列表里,最后一次性返回
    out = []
    # 同一个 SKU 常有全国与偏远两条模板
    for idx, row in enumerate(rows):
        # 时效字段缺失的规则整条跳过
        if row["transit_min"] is None or row["transit_max"] is None:
            # 记下缺失的那一条模板下标
            warn.append((sku, "transit_time", idx))
            # 不产出这个配送实例
            continue
        # 最小天数大于最大天数属于配置写反,同样跳过
        if row["transit_min"] > row["transit_max"]:
            # 记下倒挂的区间,运营据此改后台
            warn.append((sku, "transit_range", idx))
            # 不产出这个配送实例
            continue
        # 目的地默认覆盖全国
        dest = {"@type": "DefinedRegion", "addressCountry": "CN"}
        # 合法细分码才升级为偏远地区覆盖
        if row["region_code"] in REMOTE_REGIONS:
            dest["addressRegion"] = [row["region_code"]]
        # 其余地区码多为中文省名,退回全国口径并告警
        elif row["region_code"]:
            warn.append((sku, "region_code", row["region_code"]))
        # 一条配送规则包含运费、目的地与时效三块
        out.append({"@type": "OfferShippingDetails",
                    # 运费转成 MonetaryAmount
                    "shippingRate": money(row["shipping_fee"]),
                    # 目的地对象
                    "shippingDestination": dest,
                    # 时效拆成处理时长与运输时长
                    "deliveryTime": {"@type": "ShippingDeliveryTime",
                                     "handlingTime": span(row["handling_min"], row["handling_max"]),
                                     "transitTime": span(row["transit_min"], row["transit_max"])}})
    # 返回列表交给上游写进 offers.shippingDetails
    return out

# 主流程:游标分页扫 SKU,逐个写出 JSON-LD 静态文件
def main():
    # 只读账号,权限收在 policy 相关表的 SELECT
    conn = pymysql.connect(host="127.0.0.1", user="geo_ro", password="***",
                           database="shop", charset="utf8mb4")
    # warn 收集全部异常条目,last_id 是翻页游标
    warn, last_id = [], 0
    try:
        # 每批 500 条,按主键游标翻页,避开深分页扫描
        while True:
            rows = load_rows(conn, last_id, 500)
            # 空页说明数据扫完了
            if not rows:
                break
            for row in rows:
                # 先建政策节点,失败会返回 None
                policy = build_return(row, warn, row["sku"])
                # 政策为空说明枚举不可用,跳过这个 SKU
                if policy is None:
                    continue
                # 拼装最终注入详情页 head 的 Product 节点
                payload = {"@context": "https://schema.org", "@type": "Product",
                           "sku": row["sku"],
                           "offers": {"@type": "Offer", "priceCurrency": CURRENCY,
                                      "hasMerchantReturnPolicy": policy,
                                      "shippingDetails": build_shipping(row["ship_rows"], warn, row["sku"])}}
                # 落地成静态文件,发布流程扫描目录后注入
                with open(f"./out/{row['sku']}.jsonld", "w", encoding="utf-8") as fp:
                    json.dump(payload, fp, ensure_ascii=False, indent=2)
            last_id = rows[-1]["id"]
    # 连接放在 finally 里关,异常时也不漏
    finally:
        conn.close()
    # 告警落盘成清单,第二天按 sku 回补后台配置
    with open("./out/warnings.log", "w", encoding="utf-8") as fp:
        # 每条告警是 sku、字段名、原始值三元组
        for item in warn:
            # 制表符分隔,方便直接导进表格给运营看
            fp.write("\t".join(map(str, item)) + "\n")

# 入口只做一件事,方便挂到定时任务里
if __name__ == "__main__":
    main()

第一版跑完,4200 个 SKU 里有 213 条告警:118 条是 region_code 用了中文省名,57 条是运费模板的 transit_min 大于 transit_max,38 条是 return_fee_amount 为空。告警日志比 JSON-LD 本身更有价值,它把「运营在后台填错了什么」变成可排班处理的清单。

校验流程:枚举、地区、时效三道闸

上面的脚本里散落着几处校验,实际我们把它收敛成一条固定流水线,任何一条政策记录输出前都要过这三道闸。
#mermaid-svg-vgFr0xu4bSCzADAQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vgFr0xu4bSCzADAQ .error-icon{fill:#552222;}#mermaid-svg-vgFr0xu4bSCzADAQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vgFr0xu4bSCzADAQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vgFr0xu4bSCzADAQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vgFr0xu4bSCzADAQ .marker.cross{stroke:#333333;}#mermaid-svg-vgFr0xu4bSCzADAQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vgFr0xu4bSCzADAQ p{margin:0;}#mermaid-svg-vgFr0xu4bSCzADAQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster-label text{fill:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster-label span{color:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster-label span p{background-color:transparent;}#mermaid-svg-vgFr0xu4bSCzADAQ .label text,#mermaid-svg-vgFr0xu4bSCzADAQ span{fill:#333;color:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ .node rect,#mermaid-svg-vgFr0xu4bSCzADAQ .node circle,#mermaid-svg-vgFr0xu4bSCzADAQ .node ellipse,#mermaid-svg-vgFr0xu4bSCzADAQ .node polygon,#mermaid-svg-vgFr0xu4bSCzADAQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vgFr0xu4bSCzADAQ .rough-node .label text,#mermaid-svg-vgFr0xu4bSCzADAQ .node .label text,#mermaid-svg-vgFr0xu4bSCzADAQ .image-shape .label,#mermaid-svg-vgFr0xu4bSCzADAQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-vgFr0xu4bSCzADAQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vgFr0xu4bSCzADAQ .rough-node .label,#mermaid-svg-vgFr0xu4bSCzADAQ .node .label,#mermaid-svg-vgFr0xu4bSCzADAQ .image-shape .label,#mermaid-svg-vgFr0xu4bSCzADAQ .icon-shape .label{text-align:center;}#mermaid-svg-vgFr0xu4bSCzADAQ .node.clickable{cursor:pointer;}#mermaid-svg-vgFr0xu4bSCzADAQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vgFr0xu4bSCzADAQ .arrowheadPath{fill:#333333;}#mermaid-svg-vgFr0xu4bSCzADAQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vgFr0xu4bSCzADAQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vgFr0xu4bSCzADAQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vgFr0xu4bSCzADAQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vgFr0xu4bSCzADAQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vgFr0xu4bSCzADAQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster text{fill:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ .cluster span{color:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vgFr0xu4bSCzADAQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vgFr0xu4bSCzADAQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-vgFr0xu4bSCzADAQ .icon-shape,#mermaid-svg-vgFr0xu4bSCzADAQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vgFr0xu4bSCzADAQ .icon-shape p,#mermaid-svg-vgFr0xu4bSCzADAQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vgFr0xu4bSCzADAQ .icon-shape .label rect,#mermaid-svg-vgFr0xu4bSCzADAQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vgFr0xu4bSCzADAQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vgFr0xu4bSCzADAQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vgFr0xu4bSCzADAQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否









读取一条政策记录
returnPolicyCategory

在枚举白名单内
丢弃整个政策节点

写告警
forbidden 与

merchantReturnDays 同时存在
清空天数

保留不支持退货语义
returnFees 为

ReturnShippingFees 且无金额
降级为

ReturnFeesCustomerResponsibility
addressRegion 是否

为合法细分码
退回全国口径

写告警
transit_min 是否

大于 transit_max
跳过该条配送规则
输出 Offer 节点

三道闸的顺序有讲究。枚举闸在最前,因为枚举不合法会连带整个节点失效;地区闸在时效闸之前,因为地区不合法时退回全国口径仍然可用,而时效区间倒挂只能整条丢弃。

原理剖析:AI 引擎怎么抽取政策类答案

要理解为什么一个字符错了就全盘失效,得看答案抽取器的处理顺序。

引擎拿到商品页后,先切出所有 application/ld+json 脚本块,按 @type 建索引。MerchantReturnPolicyOfferShippingDetails 会被写入一组带命名空间的事实槽位,比如 offer.return.window_daysoffer.shipping.remote.transit_max。这一步是纯语法匹配,没有语义推断。

结构化的政策事实在排序上高于正文散文。 当「退货几天」这个问题既有 JSON-LD 的 merchantReturnDays: 7,又有帮助中心里「7 天无理由」这句话时,引擎优先采用字段值,因为字段有明确的主语和单位,不用做指代消解。散文里的「7 天」经常连起算点都缺------下单后 7 天还是签收后 7 天?引擎只能猜,或者去抓一个说得更清楚的站点。

那枚举值不合法时为什么整段被丢掉?解析器在构建槽位前会做一次类型校验:returnPolicyCategory 的期望类型是 MerchantReturnEnumeration,只接受那三个完整 URL。我们用短码提交时,类型校验判定失败。

失败后解析器有两个选择:保留属性并标记为未知,或者丢弃该属性。多数实现选后者,原因是保留未知值会污染下游规则引擎------一个取值为未知的枚举,在「是否支持退货」这类二值判断上会同时命中和不命中的分支。丢弃更安全,代价是我们以为写对了。

同样的机制解释了 returnShippingFeesAmount 为什么必须带非零金额:value 为 0 时解析器认为这与 FreeReturn 语义等价,于是把 returnFees 和金额一起收敛掉。

45 天对照:改造前后我们看到的

改造分两批上线。第一批只加 hasMerchantReturnPolicy,第二批补 shippingDetails 与偏远地区规则,中间隔了三周。

观测项 改造前 第一批上线后 第二批上线后
AI 渠道政策问答口径正确率 约 6 成,期限常答成 30 天 约 9 成,期限与运费对齐 约 9 成 5,偏远时效可答出
政策相关售前咨询量(日均) 63 次 41 次 28 次
因政策误解发起的退款争议(周均) 11 笔 4 笔 2 笔
结构化数据无效项告警 未统计 213 条 19 条

口径正确率的统计方式是每周抽样 50 条 AI 渠道政策咨询,人工比对回答与后台规则。第二批上线后剩下的 19 条告警全部来自 3 个长尾类目的运费模板配置错误,运营改完就清零了。

有个反直觉的发现:帮助中心页面的跳出率几乎没动。结构化数据解决的是「AI 替你回答」这一段,不是「人来读页面」这一段。指望它同时降低页面跳出率是找错了指标。

两个误区与一个趋势判断

误区一:把政策只挂在 Organization 上就完事。全站标准政策确实可以只写一份,但这家站点的退货政策被运营拆成了 9 种,商品级覆盖绕不开。挂组织级只是为了兜底,不是替代。

误区二:认为 shippingDetails 多写几条就等于多档运费。一个实例只能带一个 shippingRate,多条规则必须拆成多个实例。塞两个进去,解析器取哪个都不好说。

趋势上,政策类字段正从「商品层的可选增强」变成「商家层的必备声明」。Search Console 里的运货与退货设置、组织级政策标记,都指向同一个方向:把履约条款从页面文案里拆出来,变成可被机器直接消费的数据

对技术团队来说,这件事的落点不在 SEO,而在数据治理。政策表本身要能被枚举化、能校验、能告警,JSON-LD 只是它的一个输出格式。

参考与延伸

关键词:MerchantReturnPolicy, shippingDetails, Offer Schema, 政策结构化, 电商SEO, 生成式引擎优化, AI优化AIO

相关推荐
码农学院6 小时前
同一台设备在 AI 眼里有三个身份:GTIN、MPN 与 SKU 标识符写法解读
geo·geo优化·ai优化aio·泉州geo优化
cxk18082727 小时前
2026 GEO 优化实战指南:精采信时代昆明 AI 营销服务商选型方法论
大数据·人工智能·机器学习·geo·昆明geo
码农学院9 小时前
AI 爬虫的抓取预算从哪来:Crawl Budget 机制与 sitemap index 分片实践
网络爬虫·geo优化·ai优化aio
码农学院9 小时前
停产的设备还在被 AI 推荐:软 404、410 状态码与结构化数据清理的排查记录
网络爬虫·geo·geo优化·ai优化aio
2601_950513591 天前
AI 搜索正在分走一部分流量:企业内容到底该往哪投
内容运营·seo·geo·ai搜索·生成式引擎优化
码农学院2 天前
门店信息三方一致性审计:站内、地图平台与 AI 回答口径差异的量化与修复前后数据
geo·geo优化·ai优化aio
东莞市云毅网络有限公司2 天前
用标准库做网页正文抽取:从 HTML 到结构化字段的轻量实现
python·sqlite·自动化运维·geo·数据监测
码农学院3 天前
企业官网GEO实战:用 Organization 与 Person Schema 构建作者实体,让 AI 引擎把内容归到可信来源
人工智能·geo·ai优化aio
狗都不学爬虫_3 天前
AI逆向 - 天御无感点击验证(补+纯)
爬虫·python·网络爬虫