退货和运费政策别只藏在帮助中心: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),也就是 MerchantReturnPolicy 与 OfferShippingDetails 两个类型。
结构化字段的价值不在于给搜索引擎看,而在于给答案抽取器一个可以直接搬走的槽位。 散文要被理解,字段只要被匹配。
六个字段先看清楚: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"} |
只在 returnFees 为 ReturnShippingFees 时才有意义 |
shippingRate |
OfferShippingDetails | MonetaryAmount,每实例一个 | {"value":0,"currency":"CNY"} |
想表达多档运费必须拆成多个 shippingDetails |
deliveryTime |
OfferShippingDetails | ShippingDeliveryTime,含处理与运输两段 | 处理 0-1 天 + 运输 2-4 天 | unitCode 只认 DAY 或 d,写 days 无效 |
shippingDestination |
OfferShippingDetails | DefinedRegion | 全国一条 + 偏远地区一条 | addressRegion 用中文行政区名,识别不了 |
returnFees 的取值要特别小心。Google 支持的三个值是 FreeReturn、ReturnFeesCustomerResponsibility、ReturnShippingFees。前两个不能带 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 写的是 XJ、XZ,不是中文省名。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 建索引。MerchantReturnPolicy 与 OfferShippingDetails 会被写入一组带命名空间的事实槽位,比如 offer.return.window_days、offer.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 只是它的一个输出格式。
参考与延伸
- schema.org 的 MerchantReturnPolicy 类型定义:https://schema.org/MerchantReturnPolicy
- schema.org 的 OfferShippingDetails 类型定义:https://schema.org/OfferShippingDetails
- Google Search Central 商家退货政策结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/return-policy
- Google Search Central 商家运货政策结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/shipping-policy
关键词:MerchantReturnPolicy, shippingDetails, Offer Schema, 政策结构化, 电商SEO, 生成式引擎优化, AI优化AIO