《京东商品API:从 item.jd.get 迁移到 jingdong.item.read.get 的字段映射与踩坑》(附 Python 源码)
京东这波迁移不是重构业务,而是“宙斯 JOS → 京东商家开放平台”的命名/入口收敛。网关还是https://api.jd.com/routerjson,签名还是secret + kv(ASCII) + secret,AccessToken 逻辑不变;真正要改的是三件事:
method从item.jd.get / jd.item.get / jingdong.ware.get换成jingdong.item.read.get返回根节点从
item_get_response / jingdong_ware_get_response换成jingdong_item_read_get_response旧接口“单 SKU 平铺”,新接口“SPU + skuList + priceInfo + stockInfo”嵌套化
老代码直接item["title"]的,迁移后必炸。
一、老 vs 新:先对齐接口身份
维度 | 老 item.jd.get / jd.item.get | 新 jingdong.item.read.get |
|---|---|---|
平台 | 宙斯 JOS(已融合到 open.jd.com) | 京东商家开放平台 |
定位 | 商品详情只读(旧命名) | 商品基础信息只读(标准化) |
网关 | api.jd.com/routerjson | 同上 |
签名 | MD5(secret+kv+secret) | 同上(一行不改) |
入参 | sku_id / item_id + fields | sku_id / sku_ids + fields |
批量 | 不支持或各版本不一 | 支持 sku_ids,最多 20 个 |
返回根 | item_get_response | jingdong_item_read_get_response |
结构 | 平铺多 | goodsInfoList / priceInfo / imageInfo / skuList / stockInfo |
新应用申请 | 维护期,不鼓励 | 官方推荐 |
经验口径:网关/签名/鉴权零改动,解析层重写,批量能力顺手用上。
二、旧接口“脑内模型”(为什么会写错)
item.jd.get 很多人写成:{
"item_get_response": {
"item": {
"sku_id": 100012345678,
"title": "XX 笔记本",
"price": 4999.00,
"stock": 120,
"image": "https://img10.360buyimg.com/..."
}
}
}item = resp["item_get_response"]["item"] title = item["title"] price = float(item["price"]) stock = int(item["stock"])
三、新接口真实结构(简化但接近生产)
{
"jingdong_item_read_get_response": {
"code": "0",
"message": "success",
"result": {
"goodsInfoList": [
{
"skuId": "100012345678",
"title": "XX 笔记本",
"shortTitle": "轻薄本",
"saleState": 1,
"priceInfo": {
"marketPrice": "5999.00",
"jdPrice": "4999.00",
"promotionPrice": "4799.00",
"currency": "CNY"
},
"imageInfo": {
"mainImg": "https://img10.360buyimg.com/n1/...",
"imgList": ["https://img10...", "https://img10..."]
},
"brand": { "brandId": 123, "brandName": "Lenovo" },
"category": {
"cid1": 1, "cid1Name": "电脑办公",
"cid2": 12, "cid2Name": "笔记本",
"cid3": 123, "cid3Name": "轻薄本"
},
"stockInfo": {
"stockNum": 120,
"stockStatus": "available"
},
"salesInfo": {
"totalSold": 3200,
"commentCount": 1450,
"goodRate": "97.2"
},
"skuList": [
{
"skuId": "10001234567801",
"specText": "16G+512G",
"price": "4999.00",
"stock": 60
},
{
"skuId": "10001234567802",
"specText": "32G+1T",
"price": "5799.00",
"stock": 60
}
]
}
]
}
}
}有的返回
jdPrice,有的返回salePrice有的返回
stockNum,有的返回stock有的返回
goodsInfoList,联盟接口返回data下架商品
saleState=0,价格/库存可能直接为空
四、字段映射表(ERP 落地用)
老 item.jd.get | 新 jingdong.item.read.get | 说明 |
|---|---|---|
item.sku_id | goodsInfoList[].skuId | 最小销售单元 |
item.item_id / ware_id | SPU 需另查 / 用 sku 反推 | 京东强 SKU 模型 |
item.title | goodsInfoList[].title | |
item.sub_title | shortTitle | 不一定有 |
item.price | priceInfo.jdPrice | 当前京东价 |
item.market_price | priceInfo.marketPrice | 划线价 |
item.promotion_price | priceInfo.promotionPrice | 活动价 |
item.stock | stockInfo.stockNum / skuList[].stock | 区域库存才准 |
item.img_url | imageInfo.mainImg | 360buy 图链有防盗链 |
item.images | imageInfo.imgList | |
item.brand | brand.brandName | |
item.cid | category.cid3 | 三级类目 |
item.sku_attrs | skuList[].specText | “颜色:黑;尺码:XL” |
item.desc | 新接口常不返回 | 详情 HTML 要别的权限/接口 |
item.seller_nick | shopInfo.shopName | 商家接口才有 |
item.outer_id | 商家后台字段,新只读接口未必返 | 自营运配编码要店铺商品接口 |
跨平台统一模型里:京东主键用jd_<skuId>,不是 item_id;1688 是offerId+skuId,淘宝是num_iid+sku_id,京东是skuId + skuList[].skuId。
五、Python:兼容老新的客户端
# jd/client.py
import hashlib
import time
import requests
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class JDClient:
GATEWAY = "https://api.jd.com/routerjson"
def __init__(self, app_key, app_secret, access_token=None, timeout=5):
self.app_key = app_key
self.app_secret = app_secret
self.access_token = access_token
self.timeout = timeout
def _sign(self, params: dict) -> str:
flat = {k: v for k, v in params.items() if v is not None and k != "sign"}
qs = "".join(f"{k}{v}" for k, v in sorted(flat.items()))
raw = f"{self.app_secret}{qs}{self.app_secret}"
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def execute(self, method: str, biz: dict):
params = {
"app_key": self.app_key,
"method": method,
"timestamp": str(int(time.time() * 1000)),
"format": "json",
"v": "2.0",
"sign_method": "md5",
}
if self.access_token:
params["access_token"] = self.access_token
params.update(biz)
params["sign"] = self._sign(params)
resp = requests.post(self.GATEWAY, data=params, timeout=self.timeout)
return resp.json()六、Python:旧→新适配层(重点)
# jd/item_adapter.py
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class JDItem:
sku_id: str
spu_hint: Optional[str] = None
title: Optional[str] = None
short_title: Optional[str] = None
market_price: Optional[float] = None
sale_price: Optional[float] = None
promo_price: Optional[float] = None
main_img: Optional[str] = None
img_list: list[str] = field(default_factory=list)
brand_name: Optional[str] = None
cid3: Optional[str] = None
cid3_name: Optional[str] = None
total_stock: Optional[int] = None
on_sale: bool = True
skus: list[dict] = field(default_factory=list)
raw: dict = field(default_factory=dict)
def _f(x):
try:
return float(x)
except (TypeError, ValueError):
return None
def _i(x):
try:
return int(x)
except (TypeError, ValueError):
return None
def parse_legacy_item(resp: dict) -> JDItem:
"""老 item.jd.get"""
item = (resp.get("item_get_response") or {}).get("item") or {}
return JDItem(
sku_id=str(item.get("sku_id") or item.get("skuId")),
title=item.get("title"),
market_price=_f(item.get("market_price")),
sale_price=_f(item.get("price")),
promo_price=_f(item.get("promotion_price")),
main_img=item.get("img_url") or item.get("image"),
total_stock=_i(item.get("stock")),
skus=[{
"sku_id": str(item.get("sku_id")),
"spec": "",
"price": _f(item.get("price")),
"stock": _i(item.get("stock")),
}],
raw=item,
)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def parse_new_item(resp: dict) -> JDItem:
"""新 jingdong.item.read.get"""
node = resp.get("jingdong_item_read_get_response") or {}
if str(node.get("code")) not in ("0", "", None) and "result" not in node:
raise RuntimeError(node.get("message") or "jd item read error")
goods_list = (
(node.get("result") or {}).get("goodsInfoList") or []
)
if not goods_list:
raise RuntimeError("empty goodsInfoList")
g = goods_list[0]
price_info = g.get("priceInfo") or {}
stock_info = g.get("stockInfo") or {}
image_info = g.get("imageInfo") or {}
category = g.get("category") or {}
return JDItem(
sku_id=str(g.get("skuId")),
title=g.get("title"),
short_title=g.get("shortTitle"),
market_price=_f(price_info.get("marketPrice")),
sale_price=_f(price_info.get("jdPrice") or price_info.get("salePrice")),
promo_price=_f(price_info.get("promotionPrice")),
main_img=image_info.get("mainImg"),
img_list=image_info.get("imgList") or [],
brand_name=(g.get("brand") or {}).get("brandName"),
cid3=str(category.get("cid3") or ""),
cid3_name=category.get("cid3Name"),
total_stock=_i(stock_info.get("stockNum") or stock_info.get("stock")),
on_sale=g.get("saleState", 1) == 1,
skus=[{
"sku_id": str(s.get("skuId")),
"spec": s.get("specText") or s.get("spec") or "",
"price": _f(s.get("price")),
"stock": _i(s.get("stock")),
} for s in (g.get("skuList") or [])],
raw=g,
)client = JDClient(APP_KEY, APP_SECRET, ACCESS_TOKEN)
# 老
legacy = parse_legacy_item(client.execute("item.jd.get", {"sku_id": sku}))
# 新
new = parse_new_item(client.execute("jingdong.item.read.get", {
"sku_id": sku,
"fields": "skuId,title,shortTitle,priceInfo,imageInfo,brand,category,stockInfo,skuList,salesInfo"
}))七、批量迁移红利:别再单 SKU 狂打
def batch_get(client, sku_ids: list[str]):
if len(sku_ids) > 20:
raise ValueError("jingdong.item.read.get 单次最多 20 个")
resp = client.execute("jingdong.item.read.get", {
"sku_ids": ",".join(sku_ids),
"fields": "skuId,title,priceInfo,stockInfo,imageInfo,skuList"
})
node = resp.get("jingdong_item_read_get_response") or {}
return (node.get("result") or {}).get("goodsInfoList") or []基础 2 QPS
企业 5~50 QPS
批量 20 个 ≠ 20 次免费,仍按平台计费/限流模型走
BUCKET = TokenBucket(rate=8) # 企业 10 的话留余量 def safe_batch(skus): for chunk in chunks(skus, 20): BUCKET.acquire() yield batch_get(client, chunk)
八、迁移期双跑校验(很重要)
def migrate_check(sku):
old = safe_parse(parse_legacy_item(client.execute("item.jd.get", {"sku_id": sku})))
new = safe_parse(parse_new_item(client.execute("jingdong.item.read.get", {"sku_id": sku})))
diffs = []
if old.title != new.title:
diffs.append(("title", old.title, new.title))
if abs((old.sale_price or 0) - (new.sale_price or 0)) > 0.01:
diffs.append(("price", old.sale_price, new.sale_price))
if (old.total_stock or 0) != (new.total_stock or 0):
diffs.append(("stock", old.total_stock, new.total_stock))
return diffs标题/价格不一致 → 先查
area区域价、查促销时间窗库存不一致 → 京东库存是区域可售库存,不传
area默认北京老接口有、新接口没有(desc / outer_id / cost_price)→ 不是丢了,是权限域不同
九、区域库存坑(京东特有)
client.execute("jingdong.item.read.get", {
"sku_id": sku,
"area": "19_1607_3157_0", # 省_市_区_镇
"fields": "skuId,stockInfo,priceInfo"
})area:价格用默认区域
库存用平台默认仓配视角
跨境/家电/冷链会明显失真
淘宝/1688 很少强调“区域库存”,京东 POP/自营必须传area。
十、和前几篇收口
1688:
offerId+skuId采购视角,amountOnSale=-1/999999不可信淘宝:
num_iid卖家视角,item.get公开 /item.seller.get私有京东:
skuId是王,jingdong.item.read.get是商家/POP 只读视图;联盟jd.union.open.goods.detail.query是 CPS 视角,别混用迁移本质:京东不是“接口变了”,是“返回从平铺商品变成 SPU/SKU 价格库存对象树”
老京东商品接口教你怎么“取一个商品”;新jingdong.item.read.get教你怎么“理解一个京东 SPU:哪些 SKU、各区多少钱、哪区有货”。迁移别只改 method,要把“商品=一条记录”的脑模型,升级成“商品=SPU 聚合 + SKU 集合 + 区域库存”。