diff --git a/skills/archived/ozon-seller-api/SKILL.md b/skills/archived/ozon-seller-api/SKILL.md new file mode 100644 index 0000000..d201343 --- /dev/null +++ b/skills/archived/ozon-seller-api/SKILL.md @@ -0,0 +1,455 @@ +--- +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 Key(UUID 格式,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` + +## 核心陷阱与避坑 + +### 陷阱1:v3/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` 值。 + +### 陷阱2:v3/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` 包裹层! + +### 陷阱3:v3/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` | 404(stock无法通过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: success,Ozon 的人工审核仍可能 declined 产品。 + +常见拒因: +- "Фото товара не соответствует его типу" — 产品图片与分类类型不匹配 +- "Необходимо изменить тип" — 需要更改类型 + +**这意味着**:选错分类的产品100%会被审核拒绝,即使所有属性都填对了。必须找到与产品图片内容匹配的分类。 + +### 陷阱9:Stock 无法通过 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` 也可)。