退货和运费政策别只藏在帮助中心: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 拼装、页面注入、抓取与解析。
flowchart LR
A[policy_return_rule<br/>policy_shipping_rule] --> B[枚举映射层<br/>内部短码 到 schema.org URL]
B --> C{字段校验}
C -- 通过 --> D[拼装 JSON-LD<br/>Offer 节点]
C -- 不通过 --> E[打告警日志<br/>丢弃该字段]
D --> F[注入商品详情页 head<br/>ld+json]
F --> G[爬虫抓取]
G --> H[结构化字段进事实槽位]
H --> I[回答 能否退 / 谁付运费 / 几天到]
链路里有一步容易被忽略:校验失败的字段不是保留原值,而是直接消失。下游看到的不是错误数据,是空值。
一份可以直接改的 JSON-LD
先把最终产物贴出来,后面再讲怎么批量生成。这是那件羽绒服 SKU 的完整 Product 节点,含一件全国包邮政策与一条新疆西藏偏远地区政策。
{
"@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 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 本身更有价值,它把「运营在后台填错了什么」变成可排班处理的清单。
校验流程:枚举、地区、时效三道闸
上面的脚本里散落着几处校验,实际我们把它收敛成一条固定流水线,任何一条政策记录输出前都要过这三道闸。
flowchart TD
A[读取一条政策记录] --> B{returnPolicyCategory<br/>在枚举白名单内}
B -- 否 --> X1[丢弃整个政策节点<br/>写告警]
B -- 是 --> C{forbidden 与<br/>merchantReturnDays 同时存在}
C -- 是 --> X2[清空天数<br/>保留不支持退货语义]
C -- 否 --> D{returnFees 为<br/>ReturnShippingFees 且无金额}
D -- 是 --> X3[降级为<br/>ReturnFeesCustomerResponsibility]
D -- 否 --> E{addressRegion 是否<br/>为合法细分码}
E -- 否 --> X4[退回全国口径<br/>写告警]
E -- 是 --> F{transit_min 是否<br/>大于 transit_max}
F -- 是 --> X5[跳过该条配送规则]
F -- 否 --> G[输出 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