引言:一个不报错的 Bug
先看一段能正常跑完的代码:
def parse_product(payload: dict) -> dict:
return {
"asin": payload["asin"],
"price": float(payload["price"].replace("$", "")),
"original_price": float(payload["strikethroughPrice"]["value"].replace("$", "")),
"in_stock": payload["inStock"].strip(),
"attrs": {a["key"]: a["value"] for a in payload.get("attributes", [])},
}
这段代码没有明显的错误:取字段、转类型、建索引。它对本文样本里的两件商品都能跑通,不抛异常,不产生 null。
但它产出的 original_price 是错的。因为其中一件商品的 strikethroughPrice.key 是 List Price(厂商建议零售价),另一件是 Typical price(90 天成交中位数)。两个不同基准的数字被写进了同一个字段。
这类缺陷的可怕之处在于它不报错。 你的监控看到的是 200 和正常的解析耗时,你的看板按时刷新,只有业务侧的比较结果在某个维度上悄悄失真。
这篇文章把评估亚马逊商品数据 JSON API 的方法收敛成一套可执行的工程实践:对象分组 → 类型契约 → 变体 diff → 空值规则 → 契约测试 → 版本监控。
一、对象分组:把字段按业务语义切开
字段不是平铺的键值集合,它们属于四个业务对象,各自有不同的更新频率和可空策略。这个分组决定了后面所有判断。
| 对象 | 代表字段 | 更新节奏 | 可空策略 |
|---|---|---|---|
| 身份 | asin parentAsin title itemName brand | 几乎不变 | 必填非空 |
| 交易 | price strikethroughPrice inStock shipper seller | 分钟级 | 必填可空,空值须有语义 |
| 评价 | star rating ratingDistribution reviews | 日级 | 聚合与明细分开建模 |
| 规格 | attributes productOverview variantDetails images | 周级 | 存在即用,缺失即降级 |
为什么必须按业务对象而不是按页面模块分组? 因为页面模块是呈现结构,业务对象是数据语义。按页面模块分组(比如「主图区」「五点描述区」「A+ 区」),你的字段词典无法直接映射到数据库表;按业务对象分组,身份字段就是主键表,交易字段就是事实表,规格字段就是稀疏扩展表。
规格字段绝不能设成必填。 亚马逊商品的信息完整度差异极大,把规格设成必填会让管道在遇到信息不全的商品时报错中断——而信息不全是常态。
二、类型契约:三种可空性分开表达
JSON 的键存在性与业务的可空性是独立维度。混为一谈会导致两种相反的建模错误:把「一定存在但可能为空」写成可选类型,或者把「可能不出现」写成必填可空。
type NonEmpty<T> = T; // 一定存在且非空
type Nullable<T> = T | null; // 一定存在,值可能为空
type Optional<T> = T | undefined; // 键本身可能不出现
interface ProductContract {
// 身份:必填非空
asin: NonEmpty<string>;
parentAsin: NonEmpty<string>;
title: NonEmpty<string>;
// 标题拆分:键一定在,值可能为空串(旧版商品 itemHighlights 恒为空串)
itemName: Nullable<string>;
itemHighlights: Nullable<string>;
// 交易:键一定在,值可能为空
price: Nullable<string>;
inStock: Nullable<string>; // 自由文本,非枚举
shipper: Nullable<string>; // 可能为空串,空 != "无配送方"
// 结构化价格:嵌套对象,且 key 不是稳定枚举
strikethroughPrice: Nullable<{
key: string; // 实测出现 "List Price" 与 "Typical price"
value: string;
tip: string;
}>;
// 规格:一律可选
attributes?: Array<{ key: string; value: string }>;
size?: string; // 注意:此为运行内存,非存储容量
}
三个真实的字段陷阱
陷阱一:标题的拆分边界。 亚马逊从 2026-07-27 起把标题拆成 itemName(主体)与 itemHighlights(后缀)。但旧版商品尚未拆分,此时 itemName 是完整标题、itemHighlights 是空串。
假设「拼接等于完整标题」→ 旧版商品丢后半段;假设「itemHighlights 一定有值」→ 旧版商品全部落空。正确做法是 itemName 直接用,空时回退 title。
陷阱二:size 的语义漂移。 手机类目下 size = "8 GB" 是运行内存,而同返回的 Memory Storage Capacity = "512 GB" 才是容量。把 size 当容量入库,存储筛选全错。这个字段的语义还随类目变——服装是尺码。
陷阱三:同名不同义。 顶层 size = "8 GB"(内存),variantDetails[].size = " 512GB "(容量,带首尾空格)。同一个词在同一 JSON 里出现两次,指导两个不同概念。
三、变体 diff:性价比最高的一步
如果只能做一件事,就做这个:取同一父商品的至少两个变体,把全部字段拉平对比。
我们实测的两个变体同属 parentAsin = B0GP8D698X,结果:
| 字段 | White 512GB | Black 512GB | 风险 |
|---|---|---|---|
strikethroughPrice.key | List Price | Typical price | 语义不同,不可同比 |
strikethroughPrice.value | $649.00 | $629.95 | 基准不同 |
inStock | Only 13 left in stock - order soon. | In Stock | 自由文本 |
shipper | (空字符串) | Amazon | 空值语义未定义 |
attributes 长度 | 48 | 49 | 键集合漂移 |
Display Resolution Maximum | 2556 × 1179 pixels | 2556x1179 pixels | 全角乘号 vs 小写 x |
product_dims | 6 x 4 x 2 inches | 5.77 x 2.78 x 0.33 inches | 精度口径不同 |
price | $628.95 | $628.95 | 一致 |
parentAsin | B0GP8D698X | B0GP8D698X | 可用作分组键 |
rating | (5258) | (5258) | 父级共用 |
同一父商品(B0GP8D698X)两个变体的字段级 diff:折扣基准、attributes 长度与分辨率写法三组差异
十行里六行不一致。只测单个 ASIN 永远看不到这些。
其中两处特别值得说。
attributes 是稀疏映射。 48 vs 49,差的键是 Model Series。不能假设「所有变体都有某个属性」,也不能用固定列宽展开。
规格文本写法不统一。 分辨率一个用全角乘号 ×(U+00D7)一个用小写 x。同规格、不同字符串,精确匹配会把它们判成不同商品。
import re
def normalize_resolution(value: str) -> str:
"""2556 × 1179 pixels 与 2556x1179 pixels 归一后必须相等。"""
v = value.replace("\u00d7", "x").replace("\u00d7", "x")
v = re.sub(r"\s+", "", v.lower())
return v.replace("pixels", "")
这不是供应商缺陷——亚马逊页面本身就两种写法,任何采集方都会原样带出。
四、最意外的发现:评论 asin ≠ 请求 asin
请求 ASIN B0CMZFCQ6D 的评论,返回 10 条,逐条看 asin:
B0CMYXFK3R ×2 | B0CMZL2TJ9 ×3 | B0CMZBXYWX ×1 | B0CMZ7L14T ×1
B0CRJRNTNS ×1 | B0CMZ9KS3G ×1 | B0CMZCGQDK ×1
─────────────────────────────
distinct = 7
等于请求 ASIN 的条数 = 0
请求 ASIN B0CMZFCQ6D,返回 10 条评论分属 7 个变体,等于请求 ASIN 的条数为 0
十条评论没有一条属于我们请求的商品。
这不是 bug,是亚马逊的评论归属机制:评论挂在具体变体上,同族变体共享评论池,页面聚合展示。每条评论的 asin 标明它实际来自哪个变体。
两个直接后果:不能用 reviews[].asin 回填主商品记录(会污染数据);需要变体级评论时必须显式过滤。
顺带一处格式分歧:评论接口 star = "1.0 out of 5 stars"(带小数),商品接口 reviews[].star = "5 out of 5 stars"(整数)。同名同义,两种格式,共用解析函数必有一端出错。
五、空值规则:null / 空串 / 缺失是三件事
| 形态 | 实测例子 | 语义 | 下游行为 |
|---|---|---|---|
| 键在,空串 | shipper: ""、itemHighlights: "" | 本次未取到 / 该变体不适用 | 保留原值,标记未知 |
| 键在,null | reviews: null | 该模块当期不存在 | 跳过,不算失败 |
| 键缺失 | 部分商品的规格字段 | 卖家未填写 | 走默认值,计入填充率 |
不要把空串归一成 null。 两者信息量不同,混同会让你既无法统计填充率,也无法在填充率下降时定位原因。
填充率下降能告诉你「字段丢了」,但告诉不了你「值是不是旧的」。我们之前用一套48 小时测试方案验证亚马逊 API 的新鲜度,实测过响应很快但数据是缓存快照的情形——这两类问题要在监控里分开建指标,混在一起排查时会被互相掩盖。
inStock 必须归一,且「未知」不能默认有货
import re
def normalize_stock(raw: str | None) -> tuple:
"""把 inStock 自由文本归一为 (是否有货, 剩余件数)。
(None, None) 表示无法判定,调用方须按「未知」处理,
不得默认当作有货。
"""
if not raw or not raw.strip():
return None, None
text = raw.strip().lower()
if "left in stock" in text:
m = re.search(r"(\d+)\s+left in stock", text)
return True, int(m.group(1)) if m else None
if "in stock" in text:
return True, None
if "unavailable" in text or "out of stock" in text:
return False, 0
return None, None # 未知形态,保留原文待人工确认
最后一行是设计核心。识别不了的库存文案,标记未知而不是默认有货。 缺货误判为有货会让补货告警失效;有货误判为缺货会造成无效紧急调价。
六、schema diff:版本漂移的监控
字段会变。亚马逊拆分标题字段、调整属性键集合;供应商调整返回结构。契约需要版本策略。
import json
def flatten(obj, prefix: str = "") -> dict:
"""把嵌套 JSON 压平成 {路径: 类型} 映射,数组记为 [] 结尾。"""
out = {}
if isinstance(obj, dict):
for k, v in obj.items():
out.update(flatten(v, f"{prefix}.{k}" if prefix else k))
elif isinstance(obj, list):
out[prefix + "[]"] = "array"
for item in obj[:5]:
out.update(flatten(item, prefix + "[]"))
else:
out[prefix] = type(obj).__name__
return out
def schema_diff(baseline: dict, current: dict) -> dict:
added = sorted(set(current) - set(baseline))
removed = sorted(set(baseline) - set(current))
retyped = sorted(p for p in set(baseline) & set(current)
if baseline[p] != current[p])
return {"added": added, "removed": removed, "retyped": retyped}
if __name__ == "__main__":
with open("baseline.json", encoding="utf-8") as f:
base = flatten(json.load(f))
with open("current.json", encoding="utf-8") as f:
cur = flatten(json.load(f))
diff = schema_diff(base, cur)
for label, items in diff.items():
print(f"[{label}] {len(items)}")
for item in items:
print(" ", item)
# 有差异即返回 1,让 CI 标记待审而不是直接失败
raise SystemExit(1 if any(diff.values()) else 0)
退出码设计成 1 是刻意的:契约测试是预警,不是拦截。 字段新增通常无害可自动接受;字段消失或类型变化可能破坏解析,需要人看。
七、契约测试:断言等价关系,而不是常量
import re
import pytest
# ---------- 陷阱一:折扣基准的 key 不稳定 ----------
@pytest.mark.parametrize("key,expected", [
("List Price", "list_price"),
("Typical price", "typical_price"),
])
def test_strikethrough_key_is_classified(key, expected):
assert classify_strikethrough(key) == expected
def classify_strikethrough(key: str) -> str:
k = key.strip().lower()
if "list price" in k:
return "list_price"
if "typical" in k:
return "typical_price"
return "unknown" # 未知形态须显式暴露
# ---------- 陷阱二:规格文本写法不统一 ----------
def test_resolution_variants_are_equal():
assert normalize_resolution("2556 \u00d7 1179 pixels") == \
normalize_resolution("2556x1179 pixels")
# ---------- 陷阱三:评论 asin 不等于请求 asin ----------
def test_reviews_are_not_implicitly_filtered(reviews, requested_asin):
own = [r for r in reviews if r["asin"] == requested_asin]
others = [r for r in reviews if r["asin"] != requested_asin]
assert len(own) + len(others) == len(reviews)
# 关键:不过滤就回填主商品记录,会污染数据
if others:
assert all(r["asin"] != requested_asin for r in others)
# ---------- 填充率监控:上游改版的最早信号 ----------
def test_fill_rate_does_not_regress(products, baseline: dict):
for field, floor in baseline.items():
filled = sum(1 for p in products if p.get(field) not in (None, "", []))
rate = filled / len(products)
assert rate >= floor, f"{field} 填充率 {rate:.2%} 低于基线 {floor:.2%}"
关键设计:断言的是「归一后的等价」和「显式的未知」,而不是「值等于某个常量」。
规格文本、库存文案、折扣基准的上游写法会变。写死常量 → 持续误报 → 团队忽略 → 测试形同虚设。断言归一后的等价关系 → 写法变化时继续有效,语义真变时才失败。
八、成本分级:按字段容忍延迟选频率
| 字段类别 | 变化速度 | 建议频率 |
|---|---|---|
| 交易(价格/库存/Buy Box/优惠券) | 分钟级 | 分钟级轮询 |
| 评价聚合(评分/评分数) | 日级 | 每日 |
| 评论内容、规格 | 周级 | 每周 |
| 身份 | 几乎不变 | 一次采集长期复用 |
单件商品,分钟级轮询价格是每天上千次请求,规格每周一次只有几十次。先定每个字段的容忍延迟,再选采集频率,最后才是比价。
我们在 Pangolinfo 做这类采集用的是 Amazon Scraper API,返回结构化 JSON,覆盖本文四类字段,支持按指定邮区采集。30M+/天的调用量下维持 99% 成功率与约 3 秒中位延迟,字段填充率作为独立指标监控。
评论明细用Pangolinfo Amazon Review API,支持按星级、排序、媒体类型筛选。接口字段定义见 Pangolinfo 开发者文档
管道怎么分层、字段契约放哪一层,可以参考亚马逊数据管道要自建哪几层。
契约先行,解析后写。 这四个字是我踩了那次坑之后最想分享的一句话。