Files

456 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: ozon-seller-api
description: Ozon Seller API 操作手册 — 产品导入、审核、价格、分类、属性。含v3/import实测避坑(分类用desc_cat_id+type_id、属性用id不用attribute_id、字典属性需dict_id、价格必须CNY、审核检查图片匹配分类)。
version: 0.2
tags: [ozon, seller-api, api, listings, cross-border-ecommerce]
---
# Ozon Seller API 操作手册
## 认证
Ozon Seller API 使用两个 Header 认证:
- `Client-Id`: 店铺的 client_id(数字字符串)
- `Api-Key`: 店铺的 API KeyUUID 格式,36字符)
- `Content-Type: application/json`
无需 Bearer Token,不需要 JWT,直接 Header 传参即可。
## 店铺凭据(admincao 下)
| Store | client_id | 说明 |
|-------|-----------|------|
| Ozon-Hzqjone | 3098640 | 手机 15718853624 |
| Ozon-Hzqjtwo | 3103005 | 手机 13011158945 |
API Key 存储在 AtomK 后台 store 的 `extra_data.api_key` 字段,或本地 `/home/ubuntu/.hermes/credentials/accounts.md`
## API 端点(已验证可用 vs 废弃)
### ✅ 可用端点
| 端点 | 用途 | 备注 |
|------|------|------|
| `POST /v3/product/list` | 获取产品ID列表 | ⚠️ last_id 必须是空字符串 `""` |
| `POST /v3/product/info/list` | 批量获取产品详情 | ⚠️ 响应结构陷阱见下方 |
### ❌ 废弃/404 端点
| 端点 | 状态 | 说明 |
|------|------|------|
| `POST /v2/product/list` | 404 | 已废弃 |
| `POST /v2/product/info` | 404 | 已废弃 |
| `POST /v2/product/info/list` | 404 | 已废弃 |
| `POST /v1/product/info` | 404 | 不存在 |
| `POST /v4/product/info` | 404 | 不存在 |
| `POST /v5/product/info` | 404 | 不存在 |
| `POST /v3/product/info` | 404 | 单个产品查询不存在 |
Base URL: `https://api-seller.ozon.ru`
## 核心陷阱与避坑
### 陷阱1v3/product/list 的 last_id 参数
```python
# ❌ 错误 — 整数 0
{"last_id": 0}
# 报错:invalid value for string field last_id: 0
# ❌ 错误 — 字符串 "0"
{"last_id": "0"}
# 报错:invalid base64 data at input byte 0
# ✅ 正确 — 空字符串(首页)
{"last_id": ""}
```
`last_id` 是 base64 编码的分页游标,首页必须传空字符串 `""`。后续页使用上一次返回的 `last_id` 值。
### 陷阱2v3/product/info/list 响应结构
```python
# ❌ 错误 — 以为在 result.items 下
products = result.get("result", {}).get("items", [])
# 返回空列表,因为 result 键不存在
# ✅ 正确 — items 在顶层
products = result.get("items", [])
```
`v3/product/list` 返回 `{"result": {"items": [...]}}` 不同,`v3/product/info/list` 返回的是 `{"items": [...]}`,没有 `result` 包裹层!
### 陷阱3v3/product/list 返回结构
```json
{
"result": {
"items": [
{
"product_id": 2184055159,
"offer_id": "XRZBQ_HK",
"has_fbo_stocks": false,
"has_fbs_stocks": true,
"archived": false,
"is_discounted": false,
"quants": []
}
],
"total": 145,
"last_id": "WzM5OTI4Mjk4MTAsMzk5MjgyOTgxMF0="
}
}
```
注意:这个接口的响应是在 `result` 下的。
## 完整代码模板:获取全部 Active Listing
```python
import urllib.request, json, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
CLIENT_ID = "3098640"
API_KEY = "your-api-key-here"
HEADERS = {
"Content-Type": "application/json",
"Client-Id": CLIENT_ID,
"Api-Key": API_KEY
}
def ozon_request(endpoint, body_dict):
url = f"https://api-seller.ozon.ru{endpoint}"
data = json.dumps(body_dict).encode()
req = urllib.request.Request(url, data=data, headers=HEADERS)
resp = urllib.request.urlopen(req, timeout=20, context=ctx)
return json.loads(resp.read())
# Step 1: 获取全部产品ID(分页)
all_product_ids = []
last_id = ""
while True:
result = ozon_request("/v3/product/list", {
"filter": {"visibility": "ALL"},
"limit": 100,
"last_id": last_id
})
items = result["result"]["items"]
all_product_ids.extend([i["product_id"] for i in items])
last_id = result["result"].get("last_id", "")
if not last_id or len(items) < 100:
break
# Step 2: 批量获取产品详情(每批50)
all_products = []
for offset in range(0, len(all_product_ids), 50):
batch = all_product_ids[offset:offset+50]
detail = ozon_request("/v3/product/info/list", {
"product_id": batch
})
# ⚠️ 注意:items 在顶层,不在 result 下!
all_products.extend(detail.get("items", []))
# Step 3: 筛选 Active
active = [p for p in all_products if not p.get("is_archived", False)]
```
## 产品详情关键字段
| 字段 | 说明 | 示例 |
|------|------|------|
| `id` | 产品ID,用于构建前台URL | 2184055159 |
| `name` | 产品名称(俄语) | "закладки" |
| `offer_id` | 卖家自定义SKU | "XRZBQ_HK" |
| `price` | 当前售价 | "7.28" |
| `old_price` | 划线价 | "15.61" |
| `currency_code` | 货币 | "CNY" |
| `is_archived` | 是否归档 | false |
| `sources[].sku` | Ozon SKU | 2449791222 |
| `sources[].source` | 发货方式 | "sds" |
| `sources[].shipment_type` | 配送类型 | "SHIPMENT_TYPE_GENERAL" |
| `stocks.has_stock` | 是否有库存 | true |
| `stocks.stocks[].source` | 库存来源 | "fbs" / "fbo" |
| `commissions` | 佣金信息 | 含 FBO/FBS/RFBS/FBP |
## Ozon 前台 URL
```
https://www.ozon.ru/product/{product_id}
```
注意:从中国直接访问 ozon.ru 会被地域封锁(307重定向),需要俄罗斯代理或VPN。
## filter.visibility 选项
| 值 | 说明 |
|----|------|
| `ALL` | 全部产品 |
| `VISIBLE` | 仅可见(有库存+有价格) |
| `IN_SALE` | 在售 |
| `NOT_SALE` | 未在售 |
## 依赖
无额外依赖,使用 Python 标准库 `urllib` + `json` + `ssl` 即可。不需要 requests 或其他第三方库。
## 产品导入 (Product Import) — 已实测
### ✅ 可用的导入相关端点
| 端点 | 用途 | 备注 |
|------|------|------|
| `POST /v3/product/import` | 创建产品 | ✅ 唯一可用的import端点 |
| `POST /v1/product/import/info` | 查询导入状态+审核结果 | 返回validation和moderation状态 |
| `POST /v1/product/import/prices` | 设置产品价格 | ⚠️ 必须指定 `currency_code: "CNY"` |
| `POST /v1/product/archive` | 归档产品 | 传 `{"product_id": [123]}` |
| `POST /v3/product/info/list` | 批量获取产品详情 | ✅ 含分类和属性信息 |
### ❌ 废弃/404 的导入相关端点
| 端点 | 状态 |
|------|------|
| `POST /v1/product/import` | 404 |
| `POST /v2/product/import` | 404 |
| `POST /v2/products/stocks` | 404stock无法通过API设置) |
| `POST /v1/product/update/stocks` | 404 |
| 所有 `/v*/category/tree` | 404 |
| 所有 `/v*/category/search` | 404 |
| 所有 `/v*/category/attribute` | 404 |
| 所有 `/v*/product/classify` | 404 |
| 所有 `/v*/category/suggest` | 404 |
### 陷阱4:分类必须用 description_category_id + type_id
```python
# ❌ 错误 — 使用 category_id
{"category_id": 12345}
# ✅ 正确 — 使用 description_category_id + type_id
{"description_category_id": 17028733, "type_id": 95421}
```
`category_id` 字段会报 "TypeId must be greater than 0"。必须用 `description_category_id` + `type_id` 组合。
**获取分类的方法**:从现有已审核通过的产品中提取(`/v3/product/info/list` 返回 `description_category_id``type_id`)。
### 陷阱5:属性格式必须用 id(不是 attribute_id
```python
# ❌ 错误 — 使用 attribute_id
{"complex_id": 0, "attribute_id": 9048, "values": [{"value": "text"}]}
# ✅ 正确 — 使用 id
{"complex_id": 0, "id": 9048, "values": [{"value": "text"}]}
```
### 陷阱6:字典属性需要 dict_id
某些属性(如 attr 9163 "Пол"/性别)是字典类型,需要传 `dict_id``value`
```python
# ❌ 错误 — 只传 value 字符串
{"id": 9163, "values": [{"value": "унисекс"}]}
# ✅ 正确 — 传 dict_id + value
{"id": 9163, "values": [{"dict_id": 1, "value": "унисекс"}]}
```
但问题是:**无法通过API查询字典值列表**(所有attribute端点都404)。只能从已有产品的属性中反推,或试错。
### 陷阱7:价格API必须指定货币
```python
# ❌ 错误 — 不指定货币或用 RUB
{"prices": [{"offer_id": "SKU1", "price": "5.99"}]}
# 报错:"Неверно указана валюта"(货币指定错误)
# ✅ 正确 — 指定 CNY
{"prices": [{"offer_id": "SKU1", "price": "5.99", "old_price": "9.99", "currency_code": "CNY"}]}
```
### 陷阱8:审核(Moderation)会检查图片与分类匹配
**最重要的坑!** 即使 validation: successOzon 的人工审核仍可能 declined 产品。
常见拒因:
- "Фото товара не соответствует его типу" — 产品图片与分类类型不匹配
- "Необходимо изменить тип" — 需要更改类型
**这意味着**:选错分类的产品100%会被审核拒绝,即使所有属性都填对了。必须找到与产品图片内容匹配的分类。
### 陷阱9Stock 无法通过 API 设置
`/v2/products/stocks` 和所有 stock 相关端点均返回 404。库存可能需要在 Ozon Seller 后台手动设置,或通过 FBS/FBO 仓库管理。
### 完整产品导入代码模板
```python
import requests
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
OZON_H = {
"Client-Id": "3103005",
"Api-Key": "your-api-key",
"Content-Type": "application/json"
}
# Step 1: Import product
import_payload = {
"items": [{
"name": "Солнцезащитные рукава для спорта",
"offer_id": "SUNSLV668",
"description": "Описание товара",
"description_category_id": 17028733, # ⚠️ 不是 category_id
"type_id": 95421,
"images": ["https://example.com/image.jpg"],
"attributes": [
{"complex_id": 0, "id": 9048, "values": [{"value": "текст"}]}, # ⚠️ 用 id 不是 attribute_id
],
"vat": "0"
}]
}
r = requests.post("https://api-seller.ozon.ru/v3/product/import",
headers=OZON_H, json=import_payload, timeout=15, verify=False)
result = r.json()
# result.result[0].product_id — 新产品ID
# result.result[0].errors — 导入错误列表
# Step 2: Check import status (repeat until status != "pending")
import time
task_id = result["result"][0]["task_id"]
for _ in range(20):
r = requests.post("https://api-seller.ozon.ru/v1/product/import/info",
headers=OZON_H, json={"task_id": task_id}, timeout=15, verify=False)
status = r.json()["result"]["items"][0]["status"]
if status != "pending":
break
time.sleep(3)
# Step 3: Set price
r = requests.post("https://api-seller.ozon.ru/v1/product/import/prices",
headers=OZON_H,
json={"prices": [{
"offer_id": "SUNSLV668",
"price": "5.99",
"old_price": "9.99",
"currency_code": "CNY" # ⚠️ 必须指定!
}]},
timeout=15, verify=False)
# Step 4: Check moderation result
product_id = result["result"][0]["product_id"]
r = requests.post("https://api-seller.ozon.ru/v3/product/info/list",
headers=OZON_H,
json={"product_id": [product_id]},
timeout=15, verify=False)
product = r.json()["items"][0]
# product.status — "Не продается" = not for sale (declined)
# moderation — "declined" = 审核被拒
```
### 已知分类统计(从现有产品提取)
| description_category_id | type_id | 名称 | approved数 | 备注 |
|-------------------------|---------|------|-----------|------|
| 17028733 | 95421 | Декор/装饰品 | 35 | 通过率高但分类要匹配 |
| 17027904 | 93338 | 运动配件A | 19 | 需性别属性(dict_id) |
| 17027904 | 970575517 | 运动配件B | 12 | 需性别属性(dict_id) |
| 17027929 | 592744673 | Ледоступы/冰爪 | - | ❌ 防晒袖被declined |
| 17027904 | 93866 | Солнцезащитные очки/太阳眼镜 | - | 运动防护类,可能适合防晒用品 |
| 41777465 | 93258 | Солнцезащитная шляпа/太阳帽 | - | ❌ 4个必填字典属性,无法通过API填写 |
### 找正确分类的方法
1. **从Ozon前台搜索**:用Jina Reader抓取 `ozon.ru/search/?text=关键词`,找竞品的分类
2. **从现有产品统计**:用 `/v3/product/info/list` 获取所有产品,按 `description_category_id` 分组统计approved率
3. **AtomK前端编辑器**:通过 `atomlisting.com/editor/:id` 选择Ozon平台,AI可能推荐正确分类
4. **多分类并行测试**:用同一产品数据同时提交到3-4个候选分类,看哪个validation先通过且无缺失属性
### 审核(Moderation)被拒的实战经验
**即使 validation: success,审核仍可能 declined** Validation 只检查格式,审核检查内容。
实测被拒记录:
- 分类 17028733/95421(装饰品)→ "Необходимо изменить тип"(改类型)
- 分类 17027929/592744673(冰爪)→ "Фото товара не соответствует его типу"(照片与类型不匹配)
- 分类 41777465/93258(太阳帽)→ 4个必填字典属性无法填写,validation都无法通过
**结论:分类名称必须与产品图片内容语义匹配。** Ozon审核员是真人,会看图片判断分类是否正确。"防晒袖"放在"冰爪"分类下100%被拒。
### 字典属性(dict_id)暴力破解法
当无法通过API查询字典值时(所有 /v*/category/attribute 端点404),可以暴力枚举 dict_id
```python
# 试错法找 dict_id — 从1开始递增
for dict_id in range(1, 200):
test_attrs = [{"complex_id": 0, "id": 9163, "values": [{"dict_id": dict_id, "value": "унисекс"}]}]
# 提交到 /v3/product/import,检查返回错误
# 如果错误从 "attribute 9163 is missing" 变成别的,说明 dict_id 正确
```
实测结果(分类 17027904/970575517):
- dict_id=1 → 通过import检查,但后续验证报错(不适用于此分类)
- dict_id=49 → 通过import检查,但验证仍报错
- **不同分类的字典值不同**,一个分类下有效的dict_id在另一个分类下可能无效
### offer_id 长度限制
```python
# ❌ 错误 — offer_id 超过50字符
{"offer_id": "SUNSLV668G929X_17027904_970575517"}
# 报错:offer_id_invalid
# ✅ 正确 — offer_id 保持在50字符以内
{"offer_id": "SUNSLV668"}
```
### 产品生命周期状态流转
```
import → pending → validation:success/error → moderate:in_process → moderate:approved/declined
如果approved → 需要stock+price → 在售
如果declined → 必须改分类重新import(无法修改已declined产品的分类)
```
**已declined的产品只能归档后重新导入到新分类。** 无法修改已提交产品的分类。每次重新导入会生成新的 product_id。
### 归档大量测试产品的快捷方法
```python
# 批量归档
requests.post("https://api-seller.ozon.ru/v1/product/archive",
headers=OZON_H,
json={"product_id": [4575426716, 4575556247, 4575793963, ...]},
timeout=15)
```
### v3/product/import/info 返回结构
```json
{
"result": {
"items": [{
"product_id": 4576116662,
"status": "imported", // pending / imported / failed
"errors": [],
"offer_id": "SUNSLV668"
}],
"task_id": 12345678
}
}
```
注意:`task_id` 在 import 返回,用于查询状态。但也可以用 `product_id` 通过 `/v3/product/info/list` 查询最终状态。
## 依赖
无额外依赖,使用 Python `requests` 即可(标准库 `urllib` 也可)。