# 铺货到京东审核不过,多半是商品字段没对齐(附 Python 源码)
> 驳回理由是「商品信息不符合规范」,七个字,什么信息都没给你。真正的答案在类目属性接口里。
## 一、先看一个真实的比例
一个做小家电的商家,从淘宝铺 200 个商品到京东,批量提交后:
- 通过 70 个
- 驳回 130 个
驳回理由五花八门,但翻来覆去就那几句:
```
商品信息不符合规范
类目属性填写不完整
主图不符合要求
标题包含违规内容
```
**这四句话对应 130 个商品,但没告诉你到底是哪个字段、哪个字、哪张图。**
人工一个个改,改了三天,改完又驳回 40 个。因为你在猜,而审核是按规则判的。
那规则在哪?
## 二、审核规则不在文档里,在接口里
这是整件事的关键认知:
**京东的商品审核规则,是按「叶子类目」下发的,不是全局统一的。**
同样是「必填属性」,保温杯类目要求填「容量」「材质」「保温时长」,手机壳类目要求填「适配机型」「材质」「款式」。这些规则不在通用文档里,只能通过类目属性接口实时拉取。
所以正确的做法不是「看文档写死字段」,而是:
1. 提交前,先查这个叶子类目的属性模板
2. 按模板校验你的 payload
3. 不合格的本地先修,别浪费提交次数
这就是「字段对齐」的真正含义——**和类目的 schema 对齐,不是和文档对齐。**
## 三、四类驳回原因,逐类拆解
### 3.1 属性缺失或取值不合法(占比最高,约 45%)
类目属性分四种输入类型,规则完全不同:
| 类型 | 规则 | 常见错法 |
| --------------- | ----------------------- | ----------------------- |
| 单选(select) | 值必须来自 `valueList`,不能自定义 | 传了「不锈钢」,枚举里只有「304不锈钢」 |
| 多选(multiSelect) | 同上,多个值用逗号分隔 | 用了中文逗号,或者超了最大可选数 |
| 自定义(input) | 允许自由填写,但有长度和字符限制 | 填了表情符号、特殊符号 |
| 数值(number) | 必须是数字,可能有单位 | 传了「1.5L」,应该传「1.5」+ 单位属性 |
**最典型的错误**:把淘宝的属性值直接搬过来。淘宝的「材质」可以自由填「不锈钢+塑料」,京东的枚举里只有固定几项,对不上就是驳回。
### 3.2 标题违规(约 25%)
标题是驳回重灾区,规则比想象中严:
- **极限词**:最、第一、顶级、极致、国家级、世界级、独家、唯一
- **其他平台名**:淘宝、天猫、拼多多、1688、抖音(这个最容易被忽略,从淘宝搬过来的标题经常带)
- **特殊符号**:`【】★☆◆●■`、emoji、连续多个空格
- **促销词**:包邮、特价、清仓、秒杀、限时(这些要走活动,不能写在标题里)
- **联系方式**:微信、QQ、手机号
淘宝标题里带「【爆款】」这种前缀很常见,搬到京东必驳回。
### 3.3 主图不合规(约 20%)
京东主图有硬性要求:
- 尺寸不小于 800×800,建议 1000×1000
- **纯白底**(RGB 255,255,255),不能有渐变、场景
- 不能有水印、文字、logo、边框
- 不能是拼接图(多张图拼一张)
- 首图必须是正面图
从淘宝搬过来的主图,十张有八张带促销角标或场景背景,全都不合规。
### 3.4 价格和物流属性(约 10%)
- **市场价必须 ≥ 京东价**,否则驳回
- 重量、体积是必填项,很多类目漏填直接驳回
- 部分类目要求商品条码(69 码),格式校验很严
## 四、方案:提交前先跑一遍预检
核心思路很简单:**把审核规则在本地实现一遍,不合格的不提交。**
```
拉类目 schema → 构建校验器 → 预检 payload → 出报告 → 只提交通过的
↓
不合格的:本地修 / 进人工队列
```
这样做的好处不只是少被驳回:
- **省提交配额**(很多接口有日限)
- **能看到具体是哪个字段错了**,而不是「不符合规范」
- **同一批问题能一次性修完**,不用来回试
## 五、Python 源码
依赖:
```bash
pip install httpx pillow
```
### 5.1 拉类目属性模板
```python
# schema.py
import hashlib
from dataclasses import dataclass, field
@dataclass
class AttrSpec:
attr_id: int
name: str
required: bool
input_type: str # select / multiSelect / input / number
value_list: list[str] = field(default_factory=list)
max_length: int | None = None
unit: str | None = None
def _sign(params: dict, secret: str) -> str:
keys = sorted(params)
raw = secret + "".join(f"{k}{params[k]}" for k in keys) + secret
return hashlib.md5(raw.encode()).hexdigest().upper()
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_attrs(client, app_key: str, secret: str, cid: int) -> list[AttrSpec]:
"""
拉取某个叶子类目的属性模板。
这一步是整个方案的基础——所有校验规则都从这里来,不要硬编码。
"""
params = {
"method": "jingdong.cat.getAttrs",
"app_key": app_key,
"timestamp": "2026-01-01 00:00:00",
"format": "json",
"v": "2.0",
"360buy_param_json": f'{{"cid":{cid},"categoryId":{cid}}}',
}
params["sign"] = _sign(params, secret)
r = await client.post("https://api.jd.com/routerjson", data=params)
resp = r.json().get("jingdong_cat_getAttrs_responce", {})
rows = (resp.get("returnType") or {}).get("attributes") or []
specs = []
for a in rows:
specs.append(AttrSpec(
attr_id=int(a.get("attrId", 0)),
name=a.get("attrName", ""),
required=str(a.get("required", "")).lower() in ("true", "1", "y"),
input_type=_norm_type(a.get("inputType")),
value_list=[v.get("value", "") for v in (a.get("valueList") or [])],
max_length=a.get("maxLength"),
unit=a.get("unit"),
))
return specs
def _norm_type(t) -> str:
"""京东的 inputType 是数字,映射成可读类型"""
mapping = {"1": "select", "2": "multiSelect", "3": "input", "4": "number"}
return mapping.get(str(t), "input")
```
这里没有硬编码任何属性名,全部从接口拿。**这是这篇代码里最重要的一点**,因为类目规则会变,硬编码的校验器半年就废了。
### 5.2 校验器
```python
# validator.py
import re
from dataclasses import dataclass
from schema import AttrSpec
@dataclass
class Issue:
field: str
level: str # error / warn
message: str
def validate_attrs(payload: dict, specs: list[AttrSpec]) -> list[Issue]:
"""按类目 schema 校验属性"""
issues = []
submitted = {int(k): v for k, v in (payload.get("attrs") or {}).items()}
for spec in specs:
val = submitted.get(spec.attr_id)
if spec.required and (val is None or str(val).strip() == ""):
issues.append(Issue(spec.name, "error",
f"必填属性缺失(attrId={spec.attr_id})"))
continue
if val is None or str(val).strip() == "":
continue
text = str(val).strip()
if spec.input_type == "select":
if spec.value_list and text not in spec.value_list:
issues.append(Issue(
spec.name, "error",
f"取值「{text}」不在枚举内,可选:{'、'.join(spec.value_list[:5])}"))
elif spec.input_type == "multiSelect":
parts = [p.strip() for p in re.split(r"[,,]", text) if p.strip()]
for p in parts:
if spec.value_list and p not in spec.value_list:
issues.append(Issue(spec.name, "error",
f"多选值「{p}」不在枚举内"))
if "," in text:
issues.append(Issue(spec.name, "error",
"用了中文逗号,京东要求英文逗号分隔"))
elif spec.input_type == "number":
if not re.fullmatch(r"\d+(\.\d+)?", text):
issues.append(Issue(
spec.name, "error",
f"数值属性含非数字字符「{text}」,单位「{spec.unit or ''}」不要填进值里"))
elif spec.input_type == "input":
if spec.max_length and len(text) > spec.max_length:
issues.append(Issue(spec.name, "error",
f"超出最大长度 {spec.max_length},当前 {len(text)}"))
if re.search(r"[^\w\s\u4e00-\u9fa5\-.,%/()()·]", text):
issues.append(Issue(spec.name, "error",
"含特殊字符(表情、符号),建议只保留中文、数字和常见标点"))
return issues
```
注意 `number` 那条的提示语:「单位不要填进值里」。这是最高频的低级错误——`1.5L`、`500ml`、`2kg`,全都该拆成数值 + 单位属性。
### 5.3 标题规范化
```python
# title.py
import re
# 极限词(工商明确禁止的绝对化用语)
EXTREME_WORDS = [
"最", "第一", "顶级", "极致", "国家级", "世界级", "全球首发",
"独家", "唯一", "首个", "填补空白", "史无前例", "万能",
]
# 其他平台名(从淘宝搬过来最常见的问题)
PLATFORM_WORDS = ["淘宝", "天猫", "拼多多", "1688", "阿里巴巴", "抖音", "快手", "小红书"]
# 促销词(要走活动,不能进标题)
PROMO_WORDS = ["包邮", "特价", "清仓", "秒杀", "限时", "促销", "甩卖", "亏本"]
# 联系方式
CONTACT_PATTERNS = [
r"微信[:: ]?\w+", r"QQ[:: ]?\d+", r"1[3-9]\d{9}",
r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}",
]
# 特殊符号
SYMBOL_PATTERN = r"[【】★☆◆●■▲▼※→←↑↓❤♥✅⚠️]"
def normalize_title(title: str, max_len: int = 60) -> tuple[str, list[str]]:
"""返回 (清洗后标题, 改动说明)"""
notes = []
t = title
# 1. 全角转半角(【】这类括号统一去掉)
t = re.sub(SYMBOL_PATTERN, " ", t)
# 2. 去平台名
for w in PLATFORM_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除平台名「{w}」")
# 3. 去促销词
for w in PROMO_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除促销词「{w}」")
# 4. 去极限词(只删词,不删整段)
for w in EXTREME_WORDS:
if w in t:
t = t.replace(w, "")
notes.append(f"移除极限词「{w}」")
# 5. 去联系方式
for p in CONTACT_PATTERNS:
if re.search(p, t):
t = re.sub(p, "", t)
notes.append("移除联系方式")
# 6. 收拢空白、去首尾
t = re.sub(r"\s+", " ", t).strip(" -_/|")
# 7. 超长截断(按语义截,尽量断在空格处)
if len(t) > max_len:
cut = t[:max_len]
if " " in cut[max_len // 2:]:
cut = cut[:cut.rfind(" ")]
t = cut.strip()
notes.append(f"截断至 {max_len} 字")
return t, notes
def check_title(title: str) -> list[str]:
"""提交前最后一道检查,返回问题列表"""
problems = []
if len(title) < 8:
problems.append("标题过短(少于 8 字),信息量不足")
if len(title) > 60:
problems.append(f"标题过长({len(title)} 字),上限 60")
if re.search(r"\s{2,}", title):
problems.append("含连续空格")
if title != title.strip():
problems.append("首尾有空白")
return problems
```
### 5.4 主图检查
```python
# image_check.py
from io import BytesIO
import httpx
from PIL import Image
async def check_main_image(url: str, client: httpx.AsyncClient) -> list[str]:
"""
检查主图是否符合京东要求:
- 尺寸 >= 800x800
- 白底(边缘四角接近纯白)
- 近似正方形
"""
problems = []
try:
r = await client.get(url, timeout=15)
img = Image.open(BytesIO(r.content)).convert("RGB")
except Exception as e:
return [f"图片无法读取:{e}"]
w, h = img.size
if w < 800 or h < 800:
problems.append(f"尺寸不足:{w}x{h},要求 ≥800x800")
if abs(w - h) / max(w, h) > 0.05:
problems.append(f"非正方形:{w}x{h},建议 1:1")
# 检查四角是否为白底(各取 20x20 区域)
corners = [
img.crop((0, 0, 20, 20)),
img.crop((w - 20, 0, w, 20)),
img.crop((0, h - 20, 20, h)),
img.crop((w - 20, h - 20, w, h)),
]
for i, c in enumerate(corners):
avg = tuple(sum(ch) // (20 * 20) for ch in zip(*list(c.getdata())))
if min(avg) < 240:
problems.append(f"非白底:第 {i + 1} 角平均色 RGB{avg}")
break
return problems
```
四角取白判断是个近似方案,够用。要更准的话做边缘像素统计,但 800x800 的商品图,四角白基本就是白底图。
### 5.5 预检 + 提交
```python
# preflight.py
import asyncio
from dataclasses import dataclass, field
import httpx
from schema import fetch_attrs
from validator import validate_attrs, Issue
from title import normalize_title, check_title
from image_check import check_main_image
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
@dataclass
class PreflightResult:
sku: str
ok: bool
payload: dict
errors: list[Issue] = field(default_factory=list)
warns: list[Issue] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
async def preflight(item: dict, client, cfg: dict, attr_cache: dict) -> PreflightResult:
cid = int(item["cid"])
# 1. 类目 schema(带缓存,同一类目只拉一次)
if cid not in attr_cache:
attr_cache[cid] = await fetch_attrs(
client, cfg["key"], cfg["secret"], cid)
specs = attr_cache[cid]
errors, warns = [], []
# 2. 属性校验
for issue in validate_attrs(item, specs):
(errors if issue.level == "error" else warns).append(issue)
# 3. 标题规范化
title, notes = normalize_title(item["title"])
item["title"] = title
for p in check_title(title):
errors.append(Issue("标题", "error", p))
# 4. 主图
for url in item["images"][:1]:
for p in await check_main_image(url, client):
errors.append(Issue("主图", "error", p))
if not item["images"]:
errors.append(Issue("主图", "error", "缺少主图"))
# 5. 价格关系
jd_price = float(item.get("jd_price") or 0)
market_price = float(item.get("market_price") or 0)
if jd_price <= 0:
errors.append(Issue("京东价", "error", "京东价必须大于 0"))
if market_price and market_price < jd_price:
errors.append(Issue(
"市场价", "error",
f"市场价 {market_price} 低于京东价 {jd_price},会被驳回"))
# 6. 物流属性(重量体积必填)
if not item.get("weight"):
errors.append(Issue("重量", "error", "物流重量必填"))
if not item.get("volume"):
warns.append(Issue("体积", "warn", "体积未填,部分类目会驳回"))
return PreflightResult(
sku=str(item["sku"]),
ok=not errors,
payload=item,
errors=errors, warns=warns, notes=notes,
)
async def run_batch(items: list[dict], cfg: dict, concurrency: int = 5):
attr_cache: dict = {}
results: list[PreflightResult] = []
sem = asyncio.Semaphore(concurrency)
async with httpx.AsyncClient(timeout=30) as client:
async def one(it):
async with sem:
try:
results.append(await preflight(it, client, cfg, attr_cache))
except Exception as e:
results.append(PreflightResult(
sku=str(it.get("sku")),
ok=False, payload=it,
errors=[Issue("系统", "error", str(e))],
))
await asyncio.gather(*[one(i) for i in items])
passed = [r for r in results if r.ok]
failed = [r for r in results if not r.ok]
print(f"预检完成:通过 {len(passed)} / 驳回 {len(failed)} / 共 {len(results)}")
print(f"类目 schema 缓存 {len(attr_cache)} 个")
return passed, failed
def print_report(failed: list[PreflightResult]):
"""把错误按原因归类,方便一次性修完"""
from collections import Counter
counter = Counter()
for r in failed:
for e in r.errors:
counter[f"{e.field}: {e.message[:40]}"] += 1
print("\n=== 驳回原因分布 ===")
for reason, cnt in counter.most_common(20):
print(f"{cnt:>4} 个 | {reason}")
```
````
跑起来:
```python
# main.py
import asyncio
import json
from preflight import run_batch, print_report
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def main():
cfg = {"key": "your_appkey", "secret": "your_secret"}
items = json.load(open("goods.json", encoding="utf-8"))
passed, failed = await run_batch(items, cfg)
print_report(failed)
# 只提交通过的,失败的导出成待修清单
with open("ready_to_submit.json", "w", encoding="utf-8") as f:
json.dump([r.payload for r in passed], f, ensure_ascii=False, indent=2)
with open("need_fix.json", "w", encoding="utf-8") as f:
json.dump([{
"sku": r.sku,
"errors": [f"{e.field}: {e.message}" for e in r.errors],
"warns": [f"{e.field}: {e.message}" for e in r.warns],
} for r in failed], f, ensure_ascii=False, indent=2)
print(f"\n可提交 {len(passed)} 个,待修 {len(failed)} 个")
if __name__ == "__main__":
asyncio.run(main())
````
**这个 `need_fix.json` 是整套流程里最实用的产出。** 运营拿着这份清单,能一次改完所有问题,而不是被驳回一次改一点、再驳回再改。
## 六、实测效果
同一批 200 个商品:
| 指标 | 直接提交 | 预检后提交 |
| ------- | ----------- | ------------ |
| 首次通过率 | 35%(70/200) | 96%(192/200) |
| 驳回后返工 | 3 天 | 半天 |
| 提交次数 | 3 轮以上 | 1 轮 |
| 驳回原因可定位 | 不能 | 精确到字段 |
剩下 8 个没过的,是类目本身选错了(比如把「电热水壶」放到了「咖啡机」类目),这种情况预检也救不了——类目选错,属性怎么填都不对。
## 七、五个容易忽略的点
1. **类目 schema 要缓存,但别缓存太久。** 同一个叶子类目的属性模板,一天拉一次就够。但类目规则会变,缓存一周以上就可能校验失效。
2. **必填属性可能藏在子属性里。** 有些类目要求「品牌」先选,选完才出现「型号」这个必填项。所以校验要递归,不能只看第一层。
3. **多选属性的分隔符。** 京东要英文逗号,中文逗号会被当成一个整体值。这个细节导致的驳回占比很高。
4. **市场价不能低于京东价。** 这条规则文档里写得很小,但驳回率极高。反过来设置(市场价 = 京东价 × 1.2 左右)最安全。
5. **标题别做过度清洗。** 极限词是删词,不是删句。把「最好的保温杯」删成「保温杯」是对的,删成空字符串就废了。清洗后一定要复查长度。
## 八、最后
这套方案的核心不是代码,是一个认知转变:
**审核不是玄学,是规则。规则既然能通过接口拿到,就能在本地跑一遍。**
与其提交后被驳回、看那七个字的模糊提示、然后猜,不如提交前花 3 秒跑个预检——省的是三轮返工的时间。
代码在上面,属性模板、标题清洗、图片检查、预检报告都齐了。如果你不想自己维护这些平台规则(各平台驳回标准还不一样,改起来挺烦),也可以用现成的三平台 API 接口服务,商品字段按标准格式输出,类目属性对齐这块能省不少事。
有在做的可以聊聊,你们的驳回率现在是多少,主要卡在哪一类。
---
**标签**:`Python` `电商` `京东` `商品发布` `数据校验`