Files
atomk-hermes-skills/skills/archived/atomk-api-export/SKILL.md
T

29 KiB
Raw Blame History

name, category, description
name category description
atomk-api-export cross-border-ecommerce Extract product and listing data from AtomK via its undocumented REST API (JWT auth), then import into Miaoshou ERP collection box or other platforms.

AtomK API Export & Integration Pipeline

Overview

AtomK (atomlisting.com) exposes an internal REST API that can be called directly with a JWT Bearer token. This is dramatically more efficient than browser-scraping for bulk export of claimed products and AI-generated listings.

Authentication

The API uses JWT Bearer tokens stored in browser localStorage after login. There is a second authentication method specifically for ERP integration, but agent nodes should only use Method 1 (JWT token via login).

Extract Token (DrissionPage)

token = page.run_js('return localStorage.getItem("atomk_token");')
headers = {
    'Authorization': f'Bearer {token}',
    'Content-Type': 'application/json',
    'Accept': 'application/json',
}

Token payload fields: user_id, username, email, role, system_code, exp

Complete API Endpoint List (v1 — 2025-05 Upgrade)

认证 Auth

方法 端点 说明
POST /api/v1/auth/login 登录获取JWT
POST /api/v1/auth/refresh 刷新token
GET /api/v1/auth/verify-token 验证token有效性
GET /api/v1/auth/me 获取当前用户信息

产品 Products

方法 端点 说明
GET /api/v1/products/remote 远程产品池列表
GET /api/v1/products/remote/random 随机获取远程产品
GET /api/v1/products/remote/by-code/:code 按产品编码查询
GET /api/v1/products/remote/categories 远程产品分类
GET /api/v1/products/remote/:id 远程产品详情
POST /api/v1/products/claim 认领产品
GET /api/v1/products/claimed 已认领产品列表
GET /api/v1/products/claimed/count 已认领产品数量
GET /api/v1/products/claimed/categories 已认领产品分类
GET /api/v1/products/claimed/:id 已认领产品详情
PUT /api/v1/products/claimed/:id 更新已认领产品
DELETE /api/v1/products/claimed/:id 删除已认领产品
POST /api/v1/products/images/from-url 从URL上传图片
POST /api/v1/products/images/from-file 从文件上传图片
DELETE /api/v1/products/images/:id 删除图片

Listing 上架

方法 端点 说明
GET /api/v1/listings 列表(分页,max 100/page
GET /api/v1/listings/count 数量统计
POST /api/v1/listings 创建listing
GET /api/v1/listings/:id 详情
PUT /api/v1/listings/:id 更新(含platform_fields
DELETE /api/v1/listings/:id 删除
POST /api/v1/listings/generate AI生成listing
POST /api/v1/listings/:id/publish 发布到平台

社交帖子 Social

方法 端点 说明
GET /api/v1/social/posts 社交帖子列表
POST /api/v1/social/posts 创建帖子
GET /api/v1/social/posts/:id 帖子详情
PUT /api/v1/social/posts/:id 更新帖子
DELETE /api/v1/social/posts/:id 删除帖子
POST /api/v1/social/posts/generate AI生成帖子
POST /api/v1/social/posts/:id/publish 发布到社交平台
GET /api/v1/social/listings 社交关联listing

店铺 Stores (16 平台)

方法 端点 说明
GET /api/v1/stores 所有店铺列表
POST /api/v1/stores 创建店铺
GET /api/v1/stores/:id 店铺详情
PUT /api/v1/stores/:id 更新店铺
DELETE /api/v1/stores/:id 删除店铺
POST /api/v1/stores/:id/test-connection 测试连接
GET /api/v1/stores/:id/categories 店铺分类

设置 Settings

方法 端点 说明
GET /api/v1/settings/accounts 聚合:stores+social+erp+email
GET /api/v1/settings/system-code 公开,无需认证
GET/POST/PUT/DELETE /api/v1/settings/social-accounts 社交账号 CRUD
GET/POST/PUT/DELETE /api/v1/settings/erp-accounts ERP账号 CRUD10系统)
GET/POST/PUT/DELETE /api/v1/settings/email-accounts 邮箱账号 CRUD10服务商)

同步 Sync

方法 端点 说明
POST /api/v1/settings/sync/center-to-local 中心→本地同步(需admin API key
GET /api/v1/settings/sync/status 同步状态查询

Key Data Structures

Claimed Product Fields

Field Description
id Internal AtomK product ID
remote_product_code Product code (e.g., CNVGX8F4)
local_sku Local SKU
product_name English name
product_name_zh Chinese name
category Category (e.g., 家居百货)
source_url Original 1688/source URL
images List of COS image objects
claimed_at ISO timestamp

Image structure:

{
    "id": 8230,
    "image_url": "https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/AK-101/users/4/products/CNVGX8F4/0_0d3a9223.jpeg",
    "cos_key": "AK-101/users/4/products/CNVGX8F4/0_0d3a9223.jpeg",
    "is_primary": True,
    "sort_order": 0
}

Listing Fields

Field Description
id Listing ID
claimed_product_id Links to product id
platform temu, ozon, aliexpress, etc.
title AI-generated title
description AI-generated description
price Price
currency Currency code
keywords Comma-separated keywords
status generated or other
ai_model hunyuan, deepseek, etc.
csv_url Public COS URL to Temu-standard CSV
html_url Public COS URL to formatted HTML product page
platform_fields Nested dict with SKU, UPC, GTIN, EAN, package dims, image URLs, etc.
created_at / updated_at ISO timestamps

Platform fields (Temu example):

{
    "SKU": "RC-MACARON-CANDLES-BOX360",
    "ean": "7477596853977",
    "upc": "747759886757",
    "gtin": "0747759886757",
    "price": 10.28,
    "price_cny": 69.9,
    "quantity": "360",
    "exchange_rate": 6.8,
    "package_width": "10",
    "package_height": "2",
    "package_length": "15",
    "package_weight": "0.1",
    "image_urls": "url1;url2;url3;...",  # semicolon-separated COS URLs
    "category_path": "Home & Garden > Party Supplies"
}

CSV Export (Pre-generated)

The csv_url is a publicly accessible COS link. No auth required to download:

import requests
csv_resp = requests.get(listing['csv_url'], timeout=30)
# Returns UTF-8 BOM CSV with Temu-standard columns

HTML Export (Pre-generated)

The html_url is also public. Self-contained, styled HTML product detail page:

html_resp = requests.get(listing['html_url'], timeout=30)

Integration with Miaoshou ERP 采集箱

Miaoshou Import Collection Box URL

https://erp.91miaoshou.com/common_collect_box/index?fetchType=importCopy

Import Methods

  1. 导入链接采集 (Import Link Collection) — requires xls with source URLs
  2. Excel表格导入 (Excel Import) — for bulk data
  3. 本地素材包导入 (Local Material Package Import)

Downloaded template: 导入产品链接模板.xls

Columns:

Column Field Required
A 链接地址(必填) Yes
B 产品标题 Optional
C 价格(RMB Optional
D 促销价(RMB Optional (Lazada only)

How Miaoshou processes it: For each link, Miaoshou's scraper visits the URL and extracts product info (images, title, description). Optional columns override scraped values.

CRITICAL: Miaoshou's scraper only supports 1688/Taobao/Pinduoduo URLs. AtomK's html_url (COS-hosted HTML) will always fail with "解析失败". You must use the original source_url from /api/v1/products/claimed.

import requests
import xlwt

requests.packages.urllib3.disable_warnings()

# 1. Login AtomK
login_resp = requests.post('https://www.atomlisting.com/api/v1/auth/login',
                          json={'username': 'admincao', 'password': 'Tt123456!'},
                          timeout=10, verify=False)
token = login_resp.json()['token']
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}

# 2. Get claimed products (for source_url) AND listings (for title/price)
products = requests.get('https://www.atomlisting.com/api/v1/products/claimed?page=1&limit=100',
                       headers=headers, timeout=30, verify=False).json()
listings = requests.get('https://www.atomlisting.com/api/v1/listings',
                       headers=headers, timeout=30, verify=False).json()

# 3. Match listing → product via claimed_product_id to get 1688 source_url
matched = []
for listing in listings:
    if not listing.get('html_url'):  # skip incomplete
        continue
    product = next((p for p in products if p['id'] == listing['claimed_product_id']), None)
    if product and product.get('source_url'):
        matched.append({'listing': listing, 'product': product})

# 4. Build Miaoshou import xls using OFFICIAL template format
workbook = xlwt.Workbook()
sheet = workbook.add_sheet('Worksheet')

# Official columns (verified by downloading template from Miaoshou UI):
sheet.write(0, 0, '链接地址(必填)')
sheet.write(0, 1, '产品标题')
sheet.write(0, 2, '价格(RMB')
sheet.write(0, 3, '促销价(RMB')
sheet.write(0, 4, '提示:促销价仅可用于采集到Lazada')

for idx, item in enumerate(matched, start=1):
    listing = item['listing']
    product = item['product']
    pf = listing.get('platform_fields', {})
    sheet.write(idx, 0, product['source_url'])  # ✅ 1688 link, NOT html_url
    sheet.write(idx, 1, listing['title'])
    sheet.write(idx, 2, pf.get('price_cny', ''))
    sheet.write(idx, 3, '')
    sheet.write(idx, 4, '')

workbook.save('/tmp/miaoshou_import.xls')

5. Upload to Miaoshou via browser automation:

# Prerequisites: logged into Miaoshou via DrissionPage (see captcha-auto-login skill)
page.get('https://erp.91miaoshou.com/common_collect_box/index?fetchType=importCopy')
time.sleep(8)

# Step 1: Close notification popups (critical — they block interactions)
for _ in range(5):
    page.run_js('''
        var btns = document.querySelectorAll("button, .jx-dialog__close, .jx-dialog__headerbtn");
        for (var i = 0; i < btns.length; i++) {
            var text = btns[i].textContent.trim();
            var ariaLabel = btns[i].getAttribute('aria-label') || '';
            var cls = btns[i].className || '';
            if (text === "关闭" || text === "我知道了" || ariaLabel.includes("关闭") 
                || cls.includes('close') || cls.includes('headerbtn')) {
                btns[i].click();
            }
        }
    ''')
    time.sleep(2)

# Step 2: Open import dialog
page.run_js('''
    var btns = document.querySelectorAll("button");
    for (var i = 0; i < btns.length; i++) {
        if (btns[i].textContent.trim().includes("导入链接采集")) {
            var evt = new MouseEvent("click", { bubbles: true, cancelable: true, view: window });
            btns[i].dispatchEvent(evt);
            return true;
        }
    }
    return false;
''')
time.sleep(8)

# Step 3: Upload file via hidden input
file_input = page.ele('css:.jx-upload__input', timeout=5)
file_input.input('/tmp/miaoshou_import.xls')
time.sleep(5)

# Step 4: Click confirm
page.run_js('''
    var modals = document.querySelectorAll(".jx-dialog");
    for (var i = 0; i < modals.length; i++) {
        var style = window.getComputedStyle(modals[i]);
        if (style.display !== "none") {
            var btns = modals[i].querySelectorAll("button");
            for (var j = 0; j < btns.length; j++) {
                if (btns[j].textContent.trim() === "确认") {
                    btns[j].click();
                    return true;
                }
            }
        }
    }
    return false;
''')

# Step 5: Wait for parsing (1-5 minutes)
time.sleep(180)
# Status will show: 等待解析 → 解析成功 (X/Y/Z) or 解析失败

Expected result: 解析成功 3/0/3 (all succeeded) — NOT 解析失败.

If it shows "等待解析": Wait 1-5 minutes then refresh. If it shows "解析失败 解析文件数据错误", the template format is wrong. If it shows "导入文件未识别到:【产品主编号】表头", you're using the wrong import method or an outdated template.

Alternative: Full Data Injection (Bypass Scraping)

If link scraping fails, use the complete data from platform_fields to directly create products in Miaoshou or target platforms:

  • Title, description, price, quantity, SKU, UPC, GTIN, EAN
  • Package dimensions & weight
  • Image URLs (semicolon-separated COS links)
  • Keywords

This bypasses Miaoshou's collection box entirely and goes straight to platform-specific CSV generation.

Pitfalls

  • CRITICAL: html_url does NOT work with Miaoshou — Miaoshou's scraper only supports 1688/Taobao/Pinduoduo. You MUST use source_url from /api/v1/products/claimed (original 1688 link). Using html_url produces 解析失败 0/3/3 100% of the time.
  • Wrong template = "产品主编号" error — If you see "导入文件未识别到:【产品主编号】表头", you are either using the wrong import method (e.g., "Excel表格导入" instead of "导入链接采集") or an outdated template. The correct "导入链接采集" template has only: 链接地址(必填), 产品标题, 价格(RMB), 促销价(RMB).
  • Notification popups block UI — Miaoshou shows "我知道了" notification popups that MUST be closed before clicking "导入链接采集". Failure to close them causes the click to do nothing.
  • Sub-account permission limits — The account xiaochaoren2026 is a Miaoshou sub-account. It can access 采集箱/导入采集 but shows "暂无当前模块权限" on product publish/edit pages. Use a main account for full publishing.
  • 店小秖 (Dianxiaomi) requires slider CAPTCHA — Login page has "拖动下方拼图完成验证" which is extremely difficult to bypass automatically. Prefer Miaoshou for automation.
  • API is partially documented — 2025-05 upgrade added many endpoints. Some new endpoints may have undocumented body fields or response shapes. Monitor 404/500 responses.
  • Token expiry — JWT has exp field. Use POST /api/v1/auth/refresh with refresh token if needed.
  • Image format mix — AtomK stores both .jpeg and .webp images. Some platforms prefer .jpeg only. Use POST /api/v1/products/images/from-url or from-file for image management.
  • COS URLs are public — but tied to AtomK's Tencent Cloud account. If AtomK changes storage policy, URLs may break.
  • No batch export API/api/v1/export does not exist. Export is done product-by-product via pre-generated csv_url/html_url.
  • ERP accounts should NOT be stored in Stores — use /api/v1/settings/erp-accounts instead. The old workaround of platform=woocommerce + extra_data.real_platform=miaoshou_erp is deprecated.
  • POST /settings/erp-accounts has 500 bug (2025-05) — all creation attempts fail regardless of platform. Store ERP credentials in local file (~/.hermes/credentials/accounts.md) until backend is fixed.
  • password in extra_data auto-filtered to *** — always use pwd field for any account type (stores, ERP, email).
  • Silent UI Failures for Account LinkingPOST /api/v1/stores may return 502 (telnumber DB constraint), POST /api/v1/settings/social-accounts may return 500 for unsupported platforms. Intercept requests to debug.
  • Social platform whitelist — only facebook, tiktok, twitter, wordpress work. instagram, pinterest return 500.
  • Sync API needs API keyGET /settings/sync/status and POST /settings/sync/center-to-local require x-api-key header, not JWT.
  • status="imported" triggers 500 — always use status="generated" when creating listings.
  • platform_fields is silently ignored in POST — you must use PUT to set it after creation.
  • claimed_product_id must be unique — assigning the same product ID to multiple listings creates duplicates.
  • API returns max 100 listings per page — use page parameter to paginate.
  • 502 Bad Gateway on rapid fire — add 50ms delay between requests, or implement retry with backoff.
  • Consecutive 500s mean a bad product — if you get 10 consecutive 500s, skip that product. The product data itself is likely invalid.
  • Deduplicate before creating — always check existing listings first. Re-running a sync script without dedup creates duplicates.
  • POST /api/v1/settings/sync/center-to-local requires admin API key, not regular JWT.
  • Ozon Seller API keys expire — both Hzqjone(client_id=3098640) and Hzqjtwo(client_id=3103005) keys returned "Invalid Api-Key" as of 2025-05. Must regenerate from seller.ozon.ru (requires Russian IP). Ozon product import uses /v3/product/import (not /v1/).
  • Ozon publish not supportedPOST /listings/:id/publish returns 400 for Ozon. Must use Ozon Seller API directly to create products.

Stores API Details

Working platform values: walmart, temu, ozon, aliexpress, amazon, ebay, woocommerce, lazada, shopee

Store fields:

Field Description
id Store ID
platform E-commerce platform (strict validation)
store_name Human-readable name
store_url Platform URL
extra_data Arbitrary JSON (API keys, credentials, etc.)
is_active Boolean

Pitfalls for Stores API:

  • password in extra_data auto-filtered to *** — use pwd field instead.
  • Platform validation is strictmiaoshou, shopify, other, erp all return 500. Only e-commerce platforms accepted.
  • ERP accounts do NOT belong in Stores — use /api/v1/settings/erp-accounts instead.
  • POST /stores/:id/test-connection — validates store credentials (API key, URL, etc.)
  • GET /stores/:id/categories — fetches platform category tree (not all platforms support this; walmart returns 400)

Settings API Details

GET /api/v1/settings/accounts — 聚合所有账号信息(stores + social + erp + email),一站式获取。

GET /api/v1/settings/system-code — 公开端点,无需认证。返回 {"system_code": "AK-101"}

ERP Accounts CRUD (/api/v1/settings/erp-accounts):

  • 必填字段: erp_name, account_name
  • 可选字段: platform, extra_data, is_active
  • ⚠️ POST currently returns 500 (backend bug, 2025-05) — all platform values fail. Workaround: store ERP credentials in /home/ubuntu/.hermes/credentials/accounts.md until backend is fixed.
  • When fixed, expected usage:
erp = {
    "erp_name": "妙手ERP",
    "platform": "miaoshou",
    "account_name": "17762501033",
    "extra_data": {"phone": "17762501033", "pwd": "Tt123456!"},
    "is_active": True
}
requests.post(f'{BASE}/settings/erp-accounts', json=erp, headers=headers)

Email Accounts CRUD (/api/v1/settings/email-accounts):

  • 必填字段: email_provider, email_address, account_name
  • 可选字段: smtp_host, smtp_port, smtp_username, use_tls, extra_data, is_active, linked_store_id
  • 密码存 extra_data.pwd(不要用 password
email = {
    "email_provider": "gmail",
    "email_address": "admin@9webs.cn",
    "account_name": "9webs Admin",
    "smtp_host": "smtp.gmail.com",
    "smtp_port": 587,
    "smtp_username": "admin@9webs.cn",
    "use_tls": True,
    "extra_data": {"pwd": "xxx"},
    "is_active": True
}

Social Accounts CRUD (/api/v1/settings/social-accounts):

  • 字段: platform, account_name, account_handle, linked_store_id, is_active
  • 可用platform: facebook, tiktok, twitter, wordpress
  • ⚠️ instagram, pinterest 返回 500 (backend bug)

Social Posts API

Social模块管理社交帖子(Facebook、Twitter等),结构类似Listings

  • GET /social/posts — 帖子列表
  • POST /social/posts/generate — AI生成社交帖子内容
  • POST /social/posts/:id/publish — 发布到社交平台
  • GET /social/listings — 获取与社交帖子关联的listing数据

Social Post fields:

Field Description
id Post ID
platform Social platform
content Post content text
images Image URLs
status draft / generated / published
linked_listing_id Optional link to a listing

Sync API Details

POST /api/v1/settings/sync/center-to-local:

  • 从中心数据库同步到本地实例
  • ⚠️ 需要 admin API keyx-api-key header),非JWT token。普通JWT返回401。
  • 用于多实例部署场景

GET /api/v1/settings/sync/status:

  • 查询当前同步状态
  • 同样需要 API key

Listings Generate & Publish

POST /api/v1/listings/generate — AI生成listing数据:

  • 请求体: {"claimed_product_id": 123, "platform": "ozon", "store_id": 12}
  • 自动生成: 目标语言标题/描述/关键词(Ozon=俄语)、EAN/UPC/GTIN条码、定价(汇率6.8换算)、包裹尺寸、分类路径
  • 耗时约30-60秒/个,需设timeout=120+
  • 返回完整listing对象(含id/title/description/platform_fields/csv_url/html_url等)
  • 批量生成时需逐个调用,不能并行。每个generate是同步的(不是async

POST /api/v1/listings/:id/publish — 推送到目标平台:

  • 前提: store已配置且 test-connection 通过
  • ⚠️ Ozon不支持 — 返回400 "Platform ozon publishing not supported yet"
  • Walmart/Temu/AliExpress可能支持(未测试)
  • Ozon上架需走Ozon Seller API (/v3/product/import) 直接推

批量Generate模式 (推荐):

# 逐个generate,带timeout和重试
import requests, time

s = requests.Session()
s.verify = False
# ... login ...

# 获取未使用的claimed products
products = []  # 从 /products/claimed 获取
used = set()   # 从 /listings 获取已用的claimed_product_id
unused = [p for p in products if p['id'] not in used]

for i, prod in enumerate(unused[:N]):
    try:
        r = s.post(f'{BASE}/listings/generate', json={
            'claimed_product_id': prod['id'],
            'platform': 'ozon',
            'store_id': 12,
        }, timeout=120)
        if r.status_code == 200:
            listing = r.json()
            print(f"[{i+1}] OK id={listing['id']} title={listing.get('title','')[:40]}")
        else:
            print(f"[{i+1}] FAIL {r.status_code}: {r.text[:100]}")
    except requests.exceptions.ReadTimeout:
        print(f"[{i+1}] TIMEOUT (product {prod['id']} may be too complex)")
    time.sleep(0.5)

Verification Steps

  1. Confirm api/v1/listings returns expected data shape and claimed_product_id matches api/v1/products/claimed items.
  2. Confirm source_url exists on claimed products and points to detail.1688.com.
  3. Generate a 1-row test XLS with official template format (链接地址(必填), 产品标题, 价格(RMB)) using source_url.
  4. Upload to Miaoshou via "导入链接采集". Wait 1-5 minutes. Expected: 解析成功 1/0/1.
  5. If you see 解析失败, check: (a) URL is 1688 not AtomK HTML, (b) template downloaded from Miaoshou UI is current, (c) popup notifications were closed before upload.

Listings API — Write Side (Create / Update / Delete)

Create Listing

POST https://www.atomlisting.com/api/v1/listings

Required body fields:

{
    "claimed_product_id": 123,   # must be a valid, unassigned claimed product ID
    "platform": "ozon",          # platform name
    "store_id": 12,              # store ID from /api/v1/stores
    "title": "Product Title",
    "description": "Description",
    "price": 9.99,
    "currency": "CNY",
    "status": "generated"        # NOT "imported" — that causes 500!
}

Pitfalls for Create:

  • status="imported" triggers 500 — always use status="generated".
  • platform_fields is silently ignored in POST — you must use PUT to set it after creation.
  • claimed_product_id must be unique — assigning the same product ID to multiple listings creates duplicates. Track which product IDs are already used.
  • API pagination limit is 100GET /listings?limit=100&page=N. limit>100 returns empty items.

Update Listing (Set platform_fields)

PUT https://www.atomlisting.com/api/v1/listings/{id}
{
    "platform_fields": {
        "product_id": "123456789",
        "offer_id": "SKU-001",
        "store_name": "Hzqjone",
        "url": "https://www.ozon.ru/product/123456789"
    }
}

Delete Listing

DELETE https://www.atomlisting.com/api/v1/listings/{id}

Bulk Sync Pattern (Ozon → AtomK)

When syncing external platform listings (Ozon, Temu, etc.) into AtomK, use this pattern:

import requests, json, time
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)

BASE = 'https://www.atomlisting.com/api/v1'
s = requests.Session()
s.verify = False

# 1. Login
r = s.post(f'{BASE}/auth/login', json={'username': 'admincao', 'password': 'Tt123456!'}, timeout=10)
s.headers.update({'Authorization': f'Bearer {r.json()["token"]}'})

# 2. Fetch ALL existing listings with pagination (max 100 per page)
all_listings = []
page = 1
while True:
    r = s.get(f'{BASE}/listings', params={'page': page, 'limit': 100}, timeout=15)
    d = r.json()
    items = d if isinstance(d, list) else d.get('items', [])
    all_listings.extend(items)
    if len(items) < 100:
        break
    page += 1

# 3. Build index: product_id -> [listing_ids] for dedup
ozon_listings = [l for l in all_listings if l.get('platform') == 'ozon']
by_pid = {}
for l in ozon_listings:
    pf = l.get('platform_fields') or {}
    pid = pf.get('product_id')
    if pid:
        by_pid.setdefault(pid, []).append(l)

# 4. Delete duplicates (keep first, delete rest)
for pid, lst in by_pid.items():
    for l in lst[1:]:
        s.delete(f'{BASE}/listings/{l["id"]}', timeout=10)
        time.sleep(0.05)

# 5. Get unused claimed_product_ids
products = []
page = 1
while True:
    r = s.get(f'{BASE}/products/claimed', params={'page': page, 'limit': 100}, timeout=15)
    items = r.json() if isinstance(r.json(), list) else r.json().get('items', [])
    products.extend(items)
    if len(items) < 100: break
    page += 1

used_pids = {l['claimed_product_id'] for l in ozon_listings}
unused = [p['id'] for p in products if p['id'] not in used_pids]

# 6. Create missing listings
existing_pids = set(by_pid.keys())
for i, product_data in enumerate(external_products):
    if product_data['product_id'] in existing_pids:
        continue
    if not unused:
        print("No more unused claimed_product_ids!")
        break

    cp_id = unused.pop(0)
    # POST to create (without platform_fields)
    r = s.post(f'{BASE}/listings', json={
        'claimed_product_id': cp_id,
        'platform': 'ozon',
        'store_id': store_id,
        'title': product_data.get('name', f'Ozon-{product_data["product_id"]}'),
        'description': '',
        'price': product_data.get('price', 0),
        'currency': 'CNY',
        'status': 'generated',
    }, timeout=10)

    if r.status_code == 201:
        listing_id = r.json()['id']
        # PUT to set platform_fields
        s.put(f'{BASE}/listings/{listing_id}', json={
            'platform_fields': {
                'product_id': str(product_data['product_id']),
                'offer_id': product_data.get('offer_id', ''),
                'store_name': store_name,
                'url': f'https://www.ozon.ru/product/{product_data["product_id"]}',
            }
        }, timeout=10)
    time.sleep(0.05)  # rate limit protection

Critical Pitfalls for Bulk Sync:

  • Pagination is mandatory/listings returns max 100 items. Always paginate or you'll create hundreds of duplicates.
  • Two-step creation — POST creates the listing, but platform_fields must be set via a separate PUT. The POST silently ignores platform_fields.
  • status="imported" = 500 — use "generated".
  • 502 Bad Gateway on rapid fire — add 50ms delay between requests, or implement retry with backoff.
  • Consecutive 500s mean a bad product, not rate limiting — if you get 10 consecutive 500s, skip that product. The product data itself is likely invalid (e.g., null fields the backend can't handle).
  • Deduplicate before creating — always check existing listings first. Re-running a sync script without dedup creates 2x, 3x duplicates.

References

  • references/backend-development.md — Full guide for adding new API endpoints: model creation, router setup, Alembic migration, production deployment (no CI/CD, manual SSH), plus a debug pattern for Desktop-backend API mismatch errors.
  • references/billionmail-api.md — BillionMail (SG4) email server API reference: mailbox creation, SMTP relay, and default domain (9webs.site).
  • atomk-temu-batch-listing — browser-based batch claiming and AI generation
  • miaoshou-collect-box — Miaoshou ERP collection box navigation
  • captcha-auto-login — automated ERP login with CAPTCHA solving