亚马逊 API 字段含义全解:变体漂移 / 评论归属 / 空值规则与验证方法

20 阅读11分钟

amazon-product-data-json-api-cover-zh.png

引言:一个不报错的 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.keyList 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 512GBBlack 512GB风险
strikethroughPrice.keyList PriceTypical price语义不同,不可同比
strikethroughPrice.value$649.00$629.95基准不同
inStockOnly 13 left in stock - order soon.In Stock自由文本
shipper(空字符串)Amazon空值语义未定义
attributes 长度4849键集合漂移
Display Resolution Maximum2556 × 1179 pixels2556x1179 pixels全角乘号 vs 小写 x
product_dims6 x 4 x 2 inches5.77 x 2.78 x 0.33 inches精度口径不同
price$628.95$628.95一致
parentAsinB0GP8D698XB0GP8D698X可用作分组键
rating(5258)(5258)父级共用

亚马逊商品数据 JSON API 同一父商品两个变体的字段 diff:折扣基准、属性数量与分辨率写法均不同

同一父商品(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

亚马逊商品数据 JSON API 评论接口的 asin 字段分布:10 条评论分属 7 个变体,无一等于请求 ASIN

请求 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: ""本次未取到 / 该变体不适用保留原值,标记未知
键在,nullreviews: 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 开发者文档

管道怎么分层、字段契约放哪一层,可以参考亚马逊数据管道要自建哪几层。

契约先行,解析后写。 这四个字是我踩了那次坑之后最想分享的一句话。