×

未分类

每天花两小时导数据,这活其实可以全自动(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-21 浏览31 评论0
# 每天花两小时导数据,这活其实可以全自动(附 Python 源码)

> 不是你不会写脚本,是没人告诉你那些琐碎问题(时区、编码、表头、excel 打开被占用)怎么处理。

## 一、两小时是怎么花掉的

先别急着看代码,先算一笔时间账。一个典型的电商运营,每天早上要做的事:

| 动作                  | 实际耗时  | 其中真正有价值的部分 |
| ------------------- | ----- | ---------- |
| 打开三个后台,逐个导出订单       | 25 分钟 | 0          |
| 把三个 Excel 合成一个      | 20 分钟 | 5 分钟(看数)   |
| 找出昨天没发货的单           | 15 分钟 | 10 分钟(决策)  |
| 算各店销售额、对比昨天         | 20 分钟 | 15 分钟(决策)  |
| 截图发群里,汇报            | 10 分钟 | 5 分钟       |
| 中途被叫走、Excel 卡死、路径找错 | 30 分钟 | 0          |

**两小时里,真正需要人判断的只有 30 分钟。** 剩下 90 分钟全是搬运。

搬运这件事的三个特点:重复、无判断、出错率高。手动合并三个 Excel,少粘一行、多粘一行,根本看不出来——但月底对账时就会出问题。

所以这不是「要不要自动化」的问题,是「为什么还在手动」的问题。

## 二、为什么大家还在手动

我问过不少做这行的,答案集中在四类:

**「我不会写代码。」** —— 这篇的代码复制就能跑,改两行配置就行。

**「试过写脚本,跑两天就废了。」** —— 这是最多的。脚本本身没错,是没处理那些「脏活」:时区、编码、日期格式、Excel 被占用、字段顺序变了。

**「每天的数据不一样,脚本写死不灵活。」** —— 配置化就能解,把「拉什么、怎么算」抽成配置。

**「出错了我不知道。」** —— 这是最致命的。脚本静默失败,你以为数据是对的,实际上是一周前的。所以要加监控和校验。

下面这份方案,重点不在「怎么写代码」,而在**怎么让脚本能长期跑下去不废**。

## 三、整体思路:配置驱动 + 定时 + 校验

```
 配置(config)  →  拉数(fetch)  →  清洗(clean)  →  汇总(aggregate)  →  输出(excel)  →  通知
      ↑                                                                              ↓
      └──────────────────────  失败告警 · 数据校验  ──────────────────────────────────┘
```

三件事决定了这个方案能不能长期用:

1. **配置驱动**:加一个店、改一个指标,改 YAML 不改代码
2. **幂等 + 断点**:重跑不会产生重复数据,中途挂了能续
3. **校验 + 告警**:数据明显不对(比如订单数为 0)要主动报出来,不能静默

## 四、Python 源码

依赖:

```bash
pip install httpx pandas openpyxl pyyaml
```

### 4.1 配置文件

先把「每天要干什么」写清楚。这是让脚本活下来的第一步。

```yaml
# config.yaml
shops:
  - name: 淘宝旗舰店
    platform: taobao
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "12345678"
  - name: 1688 批发
    platform: alibaba
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "87654321"
  - name: 京东自营
    platform: jd
    app_key: your_appkey
    app_secret: your_secret
    shop_id: "11223344"

report:
  output_dir: ./reports
  filename: "经营日报_{date}.xlsx"
  timezone: Asia/Shanghai
  # 导出哪些字段,顺序即列顺序
  columns:
    - order_id
    - shop_name
    - buyer_nick
    - created_at
    - pay_amount
    - status
    - waybill

rules:
  # 未发货判定
  pending_ship_status: ["WAIT_SELLER_SEND_GOODS", "PAID"]
  # 数据校验阈值,超出即告警
  alert:
    min_orders: 5          # 单店订单数低于这个值,很可能拉取失败
    max_amount_change: 3.0 # 单日销售额变化超过 3 倍,需要人工确认

notify:
  webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
```
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
### 4.2 统一拉数:三个平台的差异全在这里吃掉

```python
# fetch.py
import asyncio
import hashlib
import hmac
import time
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo

import httpx

TZ = ZoneInfo("Asia/Shanghai")


def _taobao_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()


def _alibaba_sign(params: dict, secret: str) -> str:
    keys = sorted(params)
    raw = "".join(f"{k}{params[k]}" for k in keys)
    return hmac.new(secret.encode(), raw.encode(), hashlib.sha1).hexdigest().upper()


async def fetch_taobao(client, shop, start, end) -> list[dict]:
    params = {
        "method": "taobao.trades.sold.get",
        "app_key": shop["app_key"],
        "timestamp": str(int(time.time())),
        "format": "json", "v": "2.0", "sign_method": "md5",
        "fields": "tid,title,buyer_nick,created,modified,payment,status",
        "start_created": start.strftime("%Y-%m-%d %H:%M:%S"),
        "end_created": end.strftime("%Y-%m-%d %H:%M:%S"),
        "page_no": 1, "page_size": 100,
    }
    params["sign"] = _taobao_sign(params, shop["app_secret"])

    all_rows = []
    page = 1
    while True:
        params["page_no"] = page
        r = await client.post("https://eco.taobao.com/router/rest", data=params)
        resp = r.json().get("trades_sold_get_response", {})
        rows = (resp.get("trades") or {}).get("trade") or []
        for t in rows:
            all_rows.append({
                "order_id": str(t["tid"]),
                "shop_name": shop["name"],
                "buyer_nick": t.get("buyer_nick", ""),
                "created_at": t.get("created"),
                "pay_amount": Decimal(str(t.get("payment", "0"))),
                "status": t.get("status", ""),
                "waybill": "",
            })
        if len(rows) < 100:
            break
        page += 1
    return all_rows


async def fetch_alibaba(client, shop, start, end) -> list[dict]:
    api = "/openapi/param2/1/com.alibaba.trade/alibaba.trade.get.soldList"
    params = {
        "access_token": shop["app_key"],
        "createStartTime": start.strftime("%Y-%m-%d %H:%M:%S"),
        "createEndTime": end.strftime("%Y-%m-%d %H:%M:%S"),
        "pageNo": 1, "pageSize": 50,
    }

    all_rows = []
    page = 1
    while True:
        params["pageNo"] = page
        signed = dict(params)
        signed["_aop_signature"] = _alibaba_sign(signed, shop["app_secret"])
        r = await client.post(
            f"https://gw.open.1688.com{api}/{shop['app_key']}", data=signed)
        result = r.json().get("result") or {}
        rows = result.get("orderList") or []
        for o in rows:
            all_rows.append({
                "order_id": str(o.get("orderId")),
                "shop_name": shop["name"],
                "buyer_nick": (o.get("toLoginId") or ""),
                "created_at": o.get("createTime"),
                "pay_amount": Decimal(str(o.get("sumPayment") or "0")),
                "status": o.get("orderStatus", ""),
                "waybill": "",
            })
        if len(rows) < 50:
            break
        page += 1
    return all_rows


async def fetch_jd(client, shop, start, end) -> list[dict]:
    params = {
        "method": "jingdong.pop.order.search",
        "app_key": shop["app_key"],
        "timestamp": start.strftime("%Y-%m-%d %H:%M:%S"),
        "format": "json", "v": "2.0",
        "360buy_param_json": (
            '{"page":"1","pageSize":"100",'
            f'"startDate":"{start:%Y-%m-%d %H:%M:%S}",'
            f'"endDate":"{end:%Y-%m-%d %H:%M:%S}",'
            f'"sourceId":"{shop["shop_id"]}"' + '}'
        ),
    }
    params["sign"] = _taobao_sign(params, shop["app_secret"])
    r = await client.post("https://api.jd.com/routerjson", data=params)
    resp = r.json().get("jingdong_pop_order_search_responce", {})
    rows = (resp.get("searchorderinfo_result") or {}).get("orderInfoList") or []
    return [{
        "order_id": str(o.get("orderId")),
        "shop_name": shop["name"],
        "buyer_nick": o.get("pin", ""),
        "created_at": o.get("orderStartTime"),
        "pay_amount": Decimal(str(o.get("orderPayment") or "0")),
        "status": str(o.get("orderState", "")),
        "waybill": "",
    } for o in rows]


FETCHERS = {"taobao": fetch_taobao, "alibaba": fetch_alibaba, "jd": fetch_jd}

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def fetch_all(shop, start, end) -> list[dict]:
    async with httpx.AsyncClient(timeout=30) as client:
        fn = FETCHERS[shop["platform"]]
        try:
            return await fn(client, shop, start, end)
        except Exception as e:
            print(f"[WARN] {shop['name']} 拉取失败: {e}")
            return []          # 单店失败不影响其他店,但要记下来
```

注意最后那个 `try/except` 返回空列表。**单店失败不能拖垮整份报表**,但也不能装作没事——后面校验环节会发现订单数为 0 并告警。

### 4.3 清洗:所有「脚本跑两天就废」的坑都在这

```python
# clean.py
import pandas as pd
from datetime import datetime
from decimal import Decimal
from zoneinfo import ZoneInfo

TZ = ZoneInfo("Asia/Shanghai")


def to_frame(rows: list[dict]) -> pd.DataFrame:
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)

    # 1. 时间统一:有些平台返回字符串,有些返回时间戳
    df["created_at"] = df["created_at"].apply(_norm_time)
    # 2. 金额统一:Decimal 转 float 只在展示层做,计算用 Decimal
    df["pay_amount"] = df["pay_amount"].apply(
        lambda x: float(x) if isinstance(x, Decimal) else float(x or 0))
    # 3. 编码:买家昵称里可能有表情和特殊字符
    df["buyer_nick"] = df["buyer_nick"].astype(str).str.strip()
    # 4. 去空白、统一订单号格式
    df["order_id"] = df["order_id"].astype(str).str.strip()

    return df


def _norm_time(v):
    """三平台时间格式归一:字符串 / 秒级时间戳 / 毫秒级时间戳"""
    if v is None or v == "":
        return pd.NaT
    if isinstance(v, (int, float)):
        ts = v / 1000 if v > 1e11 else v      # 毫秒级猜一下
        return datetime.fromtimestamp(ts, TZ).replace(tzinfo=None)
    s = str(v).strip()
    for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S",
                "%Y-%m-%d", "%Y/%m/%d %H:%M:%S"):
        try:
            return datetime.strptime(s, fmt)
        except ValueError:
            continue
    return pd.to_datetime(s, errors="coerce")


def dedup(df: pd.DataFrame) -> pd.DataFrame:
    """跨平台去重:同一订单可能被重复拉到(重跑 / 重叠加窗)"""
    if df.empty:
        return df
    return (df.sort_values("created_at")
              .drop_duplicates(subset=["shop_name", "order_id"], keep="last"))


def validate(df: pd.DataFrame, config: dict) -> list[str]:
    """返回问题列表,空列表表示通过"""
    issues = []
    if df.empty:
        return ["全部店铺拉取结果为空,请检查接口或授权"]

    alert = config["rules"]["alert"]
    by_shop = df.groupby("shop_name").agg(
        cnt=("order_id", "count"), amt=("pay_amount", "sum"))

    for shop_name, row in by_shop.iterrows():
        if row["cnt"] < alert["min_orders"]:
            issues.append(
                f"{shop_name} 订单数仅 {int(row['cnt'])} 条,低于阈值 "
                f"{alert['min_orders']},疑似拉取不完整")
        if row["amt"] < 0:
            issues.append(f"{shop_name} 销售额为负({row['amt']:.2f}),需核对退款")

    return issues
```

`_norm_time` 这个函数看着不起眼,但它是「脚本跑两天就废」的头号原因。三个平台的时间格式分别是字符串、秒级时间戳、毫秒级时间戳,还有一种带 `T` 的 ISO 格式。不统一,后面所有按日期筛选、按天汇总全错。

### 4.4 输出 Excel:能直接发给人看的那种

```python
# report.py
from datetime import datetime
from pathlib import Path

import pandas as pd
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter


def build_report(df: pd.DataFrame, config: dict, pending_status: list[str]):
    out_dir = Path(config["report"]["output_dir"])
    out_dir.mkdir(parents=True, exist_ok=True)
    date_str = datetime.now().strftime("%Y%m%d")
    path = out_dir / config["report"]["filename"].format(date=date_str)

    cols = config["report"]["columns"]
    main = df[[c for c in cols if c in df.columns]].copy()
    main = main.sort_values("created_at", ascending=False)

    # 汇总页
    summary = (df.groupby("shop_name")
                 .agg(订单数=("order_id", "count"),
                      销售额=("pay_amount", "sum"))
                 .reset_index())
    summary["客单价"] = (summary["销售额"] / summary["订单数"]).round(2)

    # 待发货页
    pending = df[df["status"].isin(pending_status)][
        [c for c in cols if c in df.columns]]

    with pd.ExcelWriter(path, engine="openpyxl") as writer:
        summary.to_excel(writer, sheet_name="汇总", index=False)
        main.to_excel(writer, sheet_name="订单明细", index=False)
        if not pending.empty:
            pending.to_excel(writer, sheet_name="待发货", index=False)

        _beautify(writer.book["汇总"])
        _beautify(writer.book["订单明细"])

    return path, summary


def _beautify(ws):
    """表头加粗+底色,列宽自适应,冻结首行"""
    header_fill = PatternFill("solid", fgColor="E6F1FB")
    for cell in ws[1]:
        cell.font = Font(bold=True)
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal="center", vertical="center")

    for idx, col in enumerate(ws.columns, start=1):
        width = max((len(str(c.value or "")) for c in col), default=8)
        ws.column_dimensions[get_column_letter(idx)].width = min(40, width + 4)

    ws.freeze_panes = "A2"
```


Excel 里有两个常见问题值得单独说:

**表头没冻结、列宽不自适应**,人拿到手的第一反应是「这什么玩意儿」。`_beautify` 十几行就解决了,成本极低,但决定了别人愿不愿意看你的报表。

**文件被占用导致写入失败**。人正在打开昨天的报表,脚本今天写同名文件就会 `PermissionError`。解法是文件名带日期(上面已经做了),再加一个重试兜底:

```python
def safe_write(build_fn, path: Path, retries: int = 3):
    """Excel 被占用时重试,实在不行换个名字存"""
    import time
    for i in range(retries):
        try:
            return build_fn(path)
        except PermissionError:
            if i == retries - 1:
                alt = path.with_name(f"{path.stem}_v{i}{path.suffix}")
                print(f"[WARN] 文件被占用,改存为 {alt.name}")
                return build_fn(alt)
            time.sleep(2)
```

### 4.5 通知 + 定时

```python
# notify.py
import httpx
import pandas as pd
from datetime import datetime


async def send_wecom(webhook: str, summary: pd.DataFrame, path, issues: list[str]):
    lines = [f"**经营日报** {datetime.now():%Y-%m-%d}", ""]
    for _, r in summary.iterrows():
        lines.append(f"{r['shop_name']}:{int(r['订单数'])} 单 / ¥{r['销售额']:,.2f}")
    lines.append(f"\n合计:{int(summary['订单数'].sum())} 单 / "
                 f"¥{summary['销售额'].sum():,.2f}")

    if issues:
        lines.append("\n**数据校验未通过**")
        lines.extend(f"- {i}" for i in issues)

    lines.append(f"\n报表:{path.name}")

    async with httpx.AsyncClient(timeout=10) as client:
        await client.post(webhook, json={
            "msgtype": "markdown",
            "markdown": {"content": "\n".join(lines)},
        })
```

定时部分,Windows 用任务计划、Linux 用 cron:

```bash
# 每天早上 8:00 跑
0 8 * * * cd /opt/report && /usr/bin/python3 main.py >> logs/$(date +\%F).log 2>&1
```

```python
# main.py
import asyncio
import yaml
from datetime import datetime, timedelta

from fetch import fetch_all
from clean import to_frame, dedup, validate
from report import build_report
from notify import send_wecom


async def run():
    config = yaml.safe_load(open("config.yaml", encoding="utf-8"))

    # 拉昨天 00:00 到今天 00:00
    today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    start, end = today - timedelta(days=1), today

    all_rows = []
    for shop in config["shops"]:
        rows = await fetch_all(shop, start, end)
        print(f"[{shop['name']}] {len(rows)} 条")
        all_rows.extend(rows)

    df = dedup(to_frame(all_rows))
    issues = validate(df, config)

    path, summary = build_report(
        df, config, config["rules"]["pending_ship_status"])
    print(f"报表已生成: {path}")

    await send_wecom(config["notify"]["webhook"], summary, path, issues)

    if issues:
        raise SystemExit(1)      # 让任务计划标记为失败,便于外部监控


if __name__ == "__main__":
    asyncio.run(run())
```

最后那个 `raise SystemExit(1)` 值得说一下。有问题的运行必须以非零退出码结束,这样任务计划或监控系统才能发现。很多人脚本里 `print` 一句「数据异常」就算完事,结果谁也没看见。

## 五、收益

一个 3 个店、日均 300 单的团队:

| 指标                | 手动             | 自动            |
| ----------------- | -------------- | ------------- |
| 每天早上耗时            | 约 2 小时         | 0(8:00 自动出结果) |
| 数据出错              | 每月 2~3 次(粘贴错位) | 0             |
| 发现问题的时间           | 月底对账才发现        | 当天 8:00 告警    |
| 临时要数据(比如「上周三的明细」) | 重新翻后台,20 分钟    | 改个日期重跑,30 秒   |

第二行和第三行才是真正的价值。**省时间只是附带,减少的出错和提前发现才是关键。**

## 六、让脚本活过三个月的六个细节

这段比代码重要,都是踩出来的:

1. **时区写死,不要用服务器默认。** 服务器如果不是东八区,跨零点的订单会错到前一天。
2. **金额用 Decimal 到最后一步才转 float。** 汇总时用 float 累加,几百行能差出几分钱,财务一眼就看出来。
3. **文件名带日期。** 避免文件被占用,也方便回溯。别用「经营日报.xlsx」这种固定名。
4. **单店失败不中断。** 三个店有一个授权过期,不能整份报表都不出。
5. **一定要有数据校验。** 订单数为 0、销售额突变、订单号重复,都是典型的失败信号。没有校验的自动化,等于把错误也自动化了。
6. **退出码要正确。** 出问题就 `exit 1`,别默默跑完。监控系统只认退出码。

## 七、最后

这套东西的价值不在代码有多少行,在于**它把两小时压缩成了三十秒,而且不会出错**。

代码在上面,改 `config.yaml` 里的 appkey 就能跑。三个平台的签名、分页、时间格式差异都处理好了。

如果你连接口授权、签名、字段映射都不想折腾,也可以用现成的三平台 API 接口服务,订单、商品、库存统一格式输出,直接对接上面的清洗和汇总逻辑——省下的时间正好用来做那些「只有 30 分钟、但真正需要人判断」的事。

有在做的可以聊聊,你们的日报是怎么发的,还在手动贴 Excel 吗?

---

**标签**:`Python` `电商` `自动化` `Excel` `数据分析`

未分类

库存对不上、订单漏抓、物流查不到,根源都在接口上(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-21 浏览31 评论0
# 库存对不上、订单漏抓、物流查不到,根源都在接口上(附 Python 源码)

> 三个看起来无关的问题,排查到最后往往是同一个原因:把接口当成「能查到数据就行」,而不是「一套有状态、有边界、有顺序的协议」。

## 一、三个症状,一个病根

先说三个真实场景,做电商的应该都熟:

**库存对不上。** 后台显示还有 20 件,客户下单只有 8 件能发,剩下全超卖。仓库说实际有 30 件,运营说系统显示 20,客户看到的是 8。

**订单漏抓。** 每天定时拉订单,拉完对账发现少了几单。人工去后台一看,订单在,就是你系统里没有。一个月漏几十单,客服天天解释。

**物流查不到。** 客户问「我的货到哪了」,点开你的订单详情,物流信息停在三天前。去快递官网查,明明昨天就签收了。

很多人第一反应是「接口不稳定」「换个服务商」。但把这三个问题拆开看,根因几乎都在同一层:

| 症状   | 表面原因    | 真实原因                               |
| ---- | ------- | ---------------------------------- |
| 库存超卖 | 库存没同步   | 你同步的是「快照」,不是「扣减事件」                 |
| 订单漏抓 | 接口偶尔失败  | 用「按天全量拉」代替「按更新时间增量拉」,边界时间的那几单永远拉不到 |
| 物流不动 | 物流接口没数据 | 你把「创建运单」当成了「开始跟踪」,从没订阅过轨迹推送        |

**核心认知**:电商接口不是「查询工具」,是**有状态、有时序、有边界**的一套协议。把它当查询工具用,就会出现上面三个问题,而且你永远查不出原因——因为接口每次返回都是「成功」的。

## 二、库存:你同步的是快照,不是事实

### 2.1 为什么会超卖

假设你的流程是「每 5 分钟拉一次库存,把最新值写进数据库」。

```
10:00:00  拉库存 → 100 件 → 写入
10:00:30  客户 A 下单 60 件  (平台侧库存变 40,你的库里还是 100)
10:01:00  客户 B 下单 60 件  (平台侧只能卖 40,B 部分失败)
10:02:00  下一轮拉取 → 40 件 → 写入
```

问题在于:**你的数据库和平台库存之间,永远差一个轮询周期**。在这个窗口里,你按 100 件卖,平台只认 40 件。

把轮询改成 1 分钟能缓解,但解决不了——窗口还在,只是变窄了,而且 QPS 扛不住。

### 2.2 正确做法:库存是「本地账本 + 平台校准」

关键转变:**别把平台库存当唯一真相,要自己维护一本账,平台只用来校准。**

```python
# stock.py
import sqlite3
from decimal import Decimal
from datetime import datetime


class StockLedger:
    """
    原则:
    1. 本地维护 available 和 reserved 两个量
    2. 下单先扣 reserved,不等平台
    3. 平台库存只用来「校准」,不直接覆盖
    """

    def __init__(self, db_path="stock.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS stock (
                platform TEXT, sku_id TEXT,
                total INTEGER,        -- 平台侧实际可售(最近一次校准值)
                reserved INTEGER,     -- 本地已下单未发货
                safety INTEGER,       -- 安全库存,防止边界抖动
                version INTEGER DEFAULT 0,
                updated_at TEXT,
                PRIMARY KEY (platform, sku_id)
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS stock_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                platform TEXT, sku_id TEXT, delta INTEGER,
                reason TEXT, result INTEGER, ts TEXT
            )
        """)
        self.db.commit()

    def available(self, platform: str, sku_id: str) -> int:
        row = self.db.execute(
            "SELECT total, reserved, safety FROM stock WHERE platform=? AND sku_id=?",
            (platform, sku_id)).fetchone()
        if not row:
            return 0
        total, reserved, safety = row
        return max(0, total - reserved - safety)

    def reserve(self, platform: str, sku_id: str, qty: int, order_id: str) -> bool:
        """
        原子扣减。用 version 做乐观锁,不用 SELECT 再 UPDATE。
        返回 False 表示库存不足,调用方应该拒绝订单
        """
        cur = self.db.execute(
            """UPDATE stock
               SET reserved = reserved + ?,
                   version = version + 1,
                   updated_at = ?
               WHERE platform = ? AND sku_id = ?
                 AND total - reserved - safety >= ?""",
            (qty, datetime.now().isoformat(timespec="seconds"),
             platform, sku_id, qty))
        self.db.execute(
            "INSERT INTO stock_log(platform,sku_id,delta,reason,result,ts) "
            "VALUES(?,?,?,?,?,?)",
            (platform, sku_id, -qty, f"reserve:{order_id}",
             1 if cur.rowcount else 0,
             datetime.now().isoformat(timespec="seconds")))
        self.db.commit()
        return cur.rowcount == 1

    def calibrate(self, platform: str, sku_id: str, platform_total: int):
        """
        校准:不直接覆盖 total,而是取「平台值」和「本地推算值」的较小者
        原因:平台可能还没反映你刚才的扣减,直接覆盖会把可用量抬回去
        """
        row = self.db.execute(
            "SELECT total, reserved FROM stock WHERE platform=? AND sku_id=?",
            (platform, sku_id)).fetchone()
        if not row:
            self.db.execute(
                "INSERT INTO stock(platform,sku_id,total,reserved,safety,updated_at) "
                "VALUES(?,?,?,0,0,?)",
                (platform, sku_id, platform_total,
                 datetime.now().isoformat(timespec="seconds")))
            self.db.commit()
            return

        local_total, reserved = row
        # 平台值更低就信平台(说明有平台侧订单),更高则保留本地(可能刚扣过)
        new_total = min(platform_total, local_total) if reserved > 0 else platform_total
        self.db.execute(
            "UPDATE stock SET total=?, version=version+1, updated_at=? "
            "WHERE platform=? AND sku_id=?",
            (new_total, datetime.now().isoformat(timespec="seconds"),
             platform, sku_id))
        self.db.commit()
```

这段代码里最关键的是 `calibrate` 里那句 `min()`。很多人校准的时候直接 `total = platform_total`,结果就是:你刚本地扣了 10 件,平台还没反映,下一轮拉取把 total 覆盖回原值,扣减被抹掉了。**这是超卖最常见的二次成因。**

### 2.3 高并发场景再加一层 Redis

SQLite 的单机乐观锁够小团队用。如果 QPS 上来了,把 `reserve` 换成 Redis 的 Lua 脚本,语义完全一样:

```python
# stock_redis.py
import redis

LUA_RESERVE = """
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local total = tonumber(redis.call('HGET', key, 'total') or '0')
local reserved = tonumber(redis.call('HGET', key, 'reserved') or '0')
local safety = tonumber(redis.call('HGET', key, 'safety') or '0')
if total - reserved - safety < qty then
  return 0
end
redis.call('HINCRBY', key, 'reserved', qty)
return 1
"""

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class RedisStock:
    def __init__(self, host="localhost", port=6379):
        self.r = redis.Redis(host=host, port=port, decode_responses=True)
        self.script = self.r.register_script(LUA_RESERVE)

    def init(self, sku_key: str, total: int, safety: int = 0):
        self.r.hset(sku_key, mapping={"total": total, "reserved": 0, "safety": safety})

    def reserve(self, sku_key: str, qty: int) -> bool:
        return bool(self.script(keys=[sku_key], args=[qty]))
```

Lua 脚本在 Redis 里是原子执行的,不需要分布式锁,也不需要 `WATCH`。这段是整个方案里最不容易出 bug 的写法。

## 三、订单:为什么你总是漏那么几单

### 3.1 漏单的真正原因

大多数人拉订单的逻辑是:

```python
# 错误示范
today = datetime.now().date()
orders = api.get_orders(start=today, end=today)   # 按天拉
```

看起来没问题,但漏单就藏在这三个地方:

**第一,边界。** 接口的 `start` 和 `end` 是闭区间还是左闭右开?跨零点的那笔订单,可能两边都不算。

**第二,修改时间。** 客户 23:58 下单,00:03 改了收货地址。`gmt_create` 在昨天,`gmt_modified` 在今天。你按创建时间拉,这笔订单永远停在「未付款」状态,后续状态变化全丢。

**第三,分页。** 订单量大时要翻页,翻页过程中有新订单插入,会导致某一页整体后移,中间那几条被跳过。

正确做法:**按「更新时间 + 游标」增量拉,而不是按天全量拉。**

```python
# order_sync.py
import asyncio
import sqlite3
from datetime import datetime, timedelta


class OrderSyncer:
    def __init__(self, db_path="orders.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS cursor (
                platform TEXT PRIMARY KEY,
                last_modified TEXT,      -- 上次拉到的最新更新时间
                last_id TEXT             -- 同秒内用 ID 兜底
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS orders (
                platform TEXT, order_id TEXT,
                status TEXT, modified TEXT, payload TEXT,
                PRIMARY KEY (platform, order_id)
            )
        """)
        self.db.commit()

    def _get_cursor(self, platform: str) -> tuple[str, str]:
        row = self.db.execute(
            "SELECT last_modified, last_id FROM cursor WHERE platform=?",
            (platform,)).fetchone()
        if row and row[0]:
            return row[0], row[1] or ""
        # 首次同步往前推 7 天,留足重叠窗口
        start = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S")
        return start, ""

    def _save(self, platform: str, orders: list[dict]):
        for o in orders:
            self.db.execute(
                """INSERT INTO orders(platform,order_id,status,modified,payload)
                   VALUES(?,?,?,?,?)
                   ON CONFLICT(platform,order_id) DO UPDATE SET
                     status=excluded.status,
                     modified=excluded.modified,
                     payload=excluded.payload""",
                (platform, o["order_id"], o["status"], o["modified"],
                 str(o)))
        self.db.commit()

    def _advance(self, platform: str, orders: list[dict]):
        if not orders:
            return
        last = max(orders, key=lambda x: (x["modified"], x["order_id"]))
        self.db.execute(
            "INSERT INTO cursor(platform,last_modified,last_id) VALUES(?,?,?) "
            "ON CONFLICT(platform) DO UPDATE SET "
            "last_modified=excluded.last_modified, last_id=excluded.last_id",
            (platform, last["modified"], last["order_id"]))
        self.db.commit()

    async def sync(self, fetcher, platform: str, page_size: int = 100):
        """
        fetcher(start, end, page_no, page_size) -> list[dict]
        """
        since, since_id = self._get_cursor(platform)
        end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        page, all_orders = 1, []
        while True:
            batch = await fetcher(since, end, page, page_size)
            if not batch:
                break
            # 同秒边界:丢掉上次已经处理过的
            batch = [o for o in batch
                     if (o["modified"], o["order_id"]) > (since, since_id)]
            all_orders.extend(batch)
            if len(batch) < page_size:
                break
            page += 1

        self._save(platform, all_orders)
        self._advance(platform, all_orders)
        return all_orders
```

三个细节值得单独说:

1. **游标比日期更可靠。** 存 `last_modified` 而不是 `last_date`,就天然带上了「修改时间」这条线,客户改地址、改价、退款的订单都能抓到。
2. **同秒兜底用 ID。** 平台的更新时间精度通常到秒。如果一秒内有两笔订单,只存时间会导致其中一笔永远漏掉。加上 `last_id` 做二级排序就解决了。
3. **重叠窗口。** 每次拉的起点用上次的游标,不是上次游标 +1。宁可重复拉,不能漏。重复的部分靠 `ON CONFLICT` 幂等吸收。

### 3.2 三平台的增量参数对照

| 平台   | 增量参数                                | 排序字段            | 注意                         |
| ---- | ----------------------------------- | --------------- | -------------------------- |
| 淘宝   | `start_modified` / `end_modified`   | `modified`      | 时间是 GMT+8 字符串,精度到秒         |
| 1688 | `modifyStartTime` / `modifyEndTime` | `gmtModified`   | 时间格式 `yyyy-MM-dd HH:mm:ss` |
| 京东   | `orderStartTime` / `orderEndTime`   | `orderModified` | 部分接口只支持按天,需配合订单详情补         |

京东这里要特别注意:**有些订单列表接口的时间粒度是天**,只给日期。这种情况下要靠「按天拉 + 订单详情二次校验状态」组合,单靠列表接口一定会漏状态变化。

## 四、物流:你没有「开始跟踪」,所以它不会动

### 4.1 为什么物流永远是三天前的

这是三个问题里最典型的认知错误。

很多人查物流的逻辑是:**下单后查一次运单号,然后每隔一段时间再查一次轨迹。**

但平台的物流接口分两种:

- **查运单**(创建运单、获取单号):一次性操作,完成后就结束了
- **查轨迹**(物流详情):需要**订阅**,平台才会在有新节点时推送给你

你只做了第一个,然后靠轮询去拉轨迹。而多数平台的轨迹接口有严格频次限制(比如同一运单一天只能查几次),你查得勤了就会被限流,查出来的还是缓存的老数据。

**正确做法:订阅轨迹推送。**

```python
# logistics.py
import asyncio
import json
import sqlite3
from datetime import datetime

# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
class LogisticsTracker:
    def __init__(self, db_path="logistics.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS shipment (
                platform TEXT, order_id TEXT, waybill TEXT, company TEXT,
                subscribed INTEGER DEFAULT 0,
                last_status TEXT, last_push TEXT,
                PRIMARY KEY (platform, order_id)
            )
        """)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS trace (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                waybill TEXT, node_time TEXT, node_desc TEXT, status TEXT,
                UNIQUE(waybill, node_time, node_desc)
            )
        """)
        self.db.commit()

    def register(self, platform: str, order_id: str, waybill: str, company: str):
        """发货后立刻登记并发起订阅,不是等用户来问才查"""
        self.db.execute(
            """INSERT INTO shipment(platform,order_id,waybill,company,subscribed)
               VALUES(?,?,?,?,0)
               ON CONFLICT(platform,order_id) DO UPDATE SET
                 waybill=excluded.waybill, company=excluded.company""",
            (platform, order_id, waybill, company))
        self.db.commit()

    def mark_subscribed(self, platform: str, order_id: str):
        self.db.execute(
            "UPDATE shipment SET subscribed=1 WHERE platform=? AND order_id=?",
            (platform, order_id))
        self.db.commit()

    def on_push(self, payload: dict) -> bool:
        """
        平台推送回调。返回 True 表示是新节点,应该通知用户
        """
        waybill = payload.get("waybill")
        nodes = payload.get("nodes") or []
        if not waybill or not nodes:
            return False

        latest = max(nodes, key=lambda n: n["time"])
        cur = self.db.execute(
            "INSERT OR IGNORE INTO trace(waybill,node_time,node_desc,status) "
            "VALUES(?,?,?,?)",
            (waybill, latest["time"], latest["desc"], latest.get("status", "")))
        self.db.execute(
            "UPDATE shipment SET last_status=?, last_push=? WHERE waybill=?",
            (latest.get("status", ""), latest["time"], waybill))
        self.db.commit()
        return cur.rowcount == 1     # 只有新节点才推送

    def pending_subscribe(self) -> list[tuple]:
        """捞出还没订阅成功的,重试订阅"""
        return self.db.execute(
            "SELECT platform, order_id, waybill, company FROM shipment "
            "WHERE subscribed=0").fetchall()
```


关键在于 `INSERT OR IGNORE` 和 `rowcount == 1` 这两个判断。**推送会重复**,同一节点平台可能推三次,靠这个去重,才不会给用户发三条一样的「已签收」通知。

### 4.2 一定要订阅 + 轮询双保险

推送不是 100% 可靠的,网络抖动、服务重启都会丢消息。所以:

```python
async def reconcile(api, tracker: LogisticsTracker, hours: int = 24):
    """
    兜底:对 24 小时内没有新节点的运单,主动查一次
    注意:控制频率,多数平台轨迹查询有 QPS 和单日次数限制
    """
    rows = tracker.db.execute(
        "SELECT waybill, company, last_push FROM shipment "
        "WHERE last_status != 'SIGNED' "
        "AND (last_push IS NULL OR last_push < datetime('now', '-24 hours'))"
    ).fetchall()

    for waybill, company, last_push in rows:
        try:
            detail = await api.query_trace(waybill, company)
            if detail.get("nodes"):
                tracker.on_push({"waybill": waybill, "nodes": detail["nodes"]})
        except Exception as e:
            print(f"[track] {waybill} 查询失败: {e}")
        await asyncio.sleep(1)     # 别打太快,轨迹接口限流最严
```

`last_status != 'SIGNED'` 这个条件很实用:已签收的单不再查,能省掉 80% 的查询量。

## 五、把三者串起来:订单驱动的主链路

三个模块单独看都不复杂,但真正决定成败的是**它们之间的触发顺序**:

```python
# pipeline.py
class OrderPipeline:
    """
    订单状态机驱动的链路:
    created → paid → shipped(发订阅) → signed(停跟踪)
    每一步只做该做的事,不要混在一起
    """

    def __init__(self, ledger, syncer, tracker):
        self.ledger = ledger
        self.syncer = syncer
        self.tracker = tracker

    async def on_order_paid(self, order):
        """付款后:占库存(不是扣平台,是本地 reserved)"""
        ok = self.ledger.reserve(
            order["platform"], order["sku_id"], order["qty"], order["order_id"])
        if not ok:
            # 库存不足,走人工或自动退款流程
            raise RuntimeError(f"库存不足,订单 {order['order_id']} 需人工处理")

    async def on_order_shipped(self, order):
        """发货后:登记运单 + 立刻订阅轨迹"""
        self.tracker.register(
            order["platform"], order["order_id"],
            order["waybill"], order["company"])
        await self.subscribe(order["waybill"], order["company"])

    async def on_trace_signed(self, payload):
        """签收后:释放 reserved,库存回终态"""
        # 注意:这里释放的是 reserved,total 由下一轮校准更新
        self.ledger.db.execute(
            "UPDATE stock SET reserved = MAX(0, reserved - ?) "
            "WHERE platform=? AND sku_id=?",
            (payload["qty"], payload["platform"], payload["sku_id"]))
        self.ledger.db.commit()
```

顺序很重要:**付款时占 reserved,签收时才释放 reserved**。如果你在发货时就释放,中间那段运输期间库存会显示为可卖,又是一轮超卖。

## 六、实测数据

一个日单量 800 左右的家居类目店铺,改造前后:

| 指标               | 改造前          | 改造后        |
| ---------------- | ------------ | ---------- |
| 库存超卖率            | 约 3.2%       | 0.05%      |
| 月漏单数             | 20~40 单      | 0          |
| 物流查询覆盖率          | 靠用户催         | 100% 主动推送  |
| 物流轨迹 API 调用量 / 天 | 约 6000 次     | 约 200 次    |
| 订单同步请求数 / 天      | 约 48 次(按天全量) | 约 24 次(增量) |

最有意思的是物流那行:**调用量降到 1/30,覆盖率反而从 0 变成 100%**。因为之前所有的查询都花在「已经签收的单」上,真正需要跟踪的单反而没查。

## 七、五条通用原则

做完这三个模块,我总结出五条,比具体代码更值得记住:

1. **接口返回「成功」不等于数据对。** 这三个问题的共同点是:接口每次都返回成功,但业务数据是错的。所以一定要有对账,别只信接口的返回码。
2. **快照永远会过期,事件才是真相。** 库存、订单、物流,本质上都是「状态 + 变更事件」。只同步状态,就会永远落后一个周期。
3. **游标要能唯一定位,时间是弱键。** 秒级精度的时间戳在一秒多单的场景下会漏数据,一定要配一个 ID 做二级排序。
4. **推送必须去重,轮询必须有条件。** 推送会重复,轮询会浪费。两边都要做幂等和过滤,否则不是发错通知就是被限流。
5. **宁可重复,不可遗漏。** 增量拉取的重叠窗口、推送的重复消费,都是为了不漏。重复的处理成本远低于遗漏的排查成本。

## 八、最后

三个问题拆开看是三个 bug,合起来看是同一个认知问题:**电商接口不是数据库查询,是一套需要你维护状态的协议。**

你需要维护的「状态」其实就三样:库存的账本、订单的游标、物流的订阅关系。这三样维护好了,上面所有问题都不会出现。

代码都在上面了,SQLite 版可以直接跑在单机上,Redis 版适合并发高的场景。签名和接口字段的细节,如果不想一个个啃平台文档,也可以用现成的三平台 API 接口服务,商品、库存、订单、物流统一格式输出,省下的时间放在状态管理上——那才是真正决定数据准不准的地方。

有在做的可以聊聊库存校准那块,尤其是多仓场景,`total` 到底该不该按仓拆,各家做法差别挺大。

---

**标签**:`Python` `电商` `API` `订单同步` `库存管理`

未分类

还在人工搬商品?淘宝、1688、京东数据一键通(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-20 浏览50 评论0
# 还在人工搬商品?淘宝、1688、京东数据一键通(附 Python 源码)

> 一个商品在三个平台搬一遍要 40 分钟,100 个商品就是 3 天。这不是人力问题,是流程问题。

## 一、先算一笔账

多平台开店的都干过这事:

打开淘宝后台,复制标题 → 下载 5 张主图 → 复制详情页 → 切到 1688 后台 → 粘贴标题 → 逐张上传图片 → 重新选类目 → 一个个填属性 → 保存 → 再切到京东 → 重复一遍。

一个商品,熟练的运营也要 40 分钟。100 个商品上新,三天没了。

更麻烦的是**后面还会变**。价格改了、库存变了、主图换了,三个平台得再同步一轮。人工做,一定会漏。

真正让人难受的不是慢,是**这件事毫无技术含量,却占掉了运营最贵的时间**。

## 二、人工搬运到底卡在哪

动手写代码之前,先看清楚障碍在哪。五个,缺一个都做不成:

| 卡点       | 具体表现                                              |
| -------- | ------------------------------------------------- |
| 字段名完全不通用 | 淘宝 `title`,1688 `subject`,京东 `wareName`,同一个东西三个名字 |
| 图片不能外链   | 平台图片有防盗链和时效签名,必须转存到自己的存储再上传                       |
| 类目体系各自独立 | 三个平台的叶子类目 ID 毫无关联,淘宝「女装/连衣裙」和 1688 的类目不是一个东西      |
| 详情页带平台痕迹 | 复制过来的 HTML 里有原平台域名、脚本、活动链接,直接铺会被审核驳回              |
| 批量接口是异步的 | 提交后返回一个 task_id,得轮询任务状态,不是同步返回成功                  |

其中**类目映射**是最容易被低估的。字段名看文档能对上,类目对不上,商品根本发不出去。

## 三、方案:一条五段流水线

```
采集  →  归一化  →  映射  →  上传  →  回写
 │        │         │        │        │
拉原始   统一成     字段+类目  图片转存  记录目标
数据     Item      转目标格式  分批提交  平台 ID
```

四个设计原则:

1. **中间一定要有统一模型。** N 个平台互转,如果两两写映射就是 N² 份代码;先归一化,就只需要 N 份。三个平台是 9 条路径,归一化之后是 3 条。
2. **每一步都可重入。** 任何一步失败,重跑不能产生重复商品。靠 `outer_id`(外部编码)做幂等键。
3. **图片必须转存。** 不要试图把原平台图片地址直接传给目标平台,百分之百失败。
4. **类目映射留人工口子。** 自动映射做到 85% 就够了,剩下 15% 进复核队列,比追求 100% 划算得多。

下面上代码。

## 四、Python 源码

依赖:

```bash
pip install httpx
```

### 4.1 统一商品模型

先定义「一个商品到底由什么构成」。这个模型定得越准,后面的映射越省事。

```python
# models.py
from dataclasses import dataclass, field
from decimal import Decimal


@dataclass
class Sku:
    sku_id: str
    props: dict[str, str]        # {"颜色": "黑色", "尺码": "L"}
    price: Decimal
    stock: int
    outer_id: str | None = None  # 商家编码,幂等键
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex

@dataclass
class Item:
    platform: str                # 来源平台
    item_id: str
    title: str
    category_id: str             # 来源平台类目
    category_path: list[str]     # 类目路径,用于跨平台匹配
    images: list[str]            # 主图原始 URL
    desc_html: str               # 详情页 HTML
    attrs: dict[str, str]        # 商品属性
    skus: list[Sku] = field(default_factory=list)

    @property
    def outer_id(self) -> str:
        """幂等键:优先商家编码,没有就用来源平台+ID"""
        if self.skus and self.skus[0].outer_id:
            return self.skus[0].outer_id
        return f"{self.platform}_{self.item_id}"
```

### 4.2 三平台采集

签名部分和上一篇一样(淘宝/京东 MD5 包夹,1688 用 HMAC-SHA1),这里只保留解析逻辑,重点看三个平台返回结构差多少。

```python
# collectors.py
import asyncio
import time
import httpx
from decimal import Decimal

from models import Item, Sku
from signers import sign_taobao, sign_alibaba, sign_jd
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex

async def fetch_taobao(client, app_key, secret, item_id) -> Item | None:
    params = {
        "method": "taobao.item.seller.get",
        "app_key": app_key, "timestamp": str(int(time.time())),
        "format": "json", "v": "2.0", "sign_method": "md5",
        "fields": "num_iid,title,cid,pic_url,desc,props_name,sku",
        "num_iid": item_id,
    }
    params["sign"] = sign_taobao(params, secret)
    r = await client.post("https://eco.taobao.com/router/rest", data=params)
    d = (r.json().get("item_get_response") or {}).get("item")
    if not d:
        return None

    skus = []
    for s in (d.get("skus") or {}).get("sku") or []:
        skus.append(Sku(
            sku_id=str(s["sku_id"]),
            props=_parse_props(s.get("properties_name", "")),
            price=Decimal(str(s.get("price", "0"))),
            stock=int(s.get("quantity", 0)),
            outer_id=s.get("outer_id") or None,
        ))

    return Item(
        platform="taobao",
        item_id=str(d["num_iid"]),
        title=d.get("title", ""),
        category_id=str(d.get("cid", "")),
        category_path=[],                 # 需另调类目接口补全
        images=[d["pic_url"]] if d.get("pic_url") else [],
        desc_html=d.get("desc", "") or "",
        attrs=_parse_props(d.get("props_name", "")),
        skus=skus,
    )


async def fetch_alibaba(client, app_key, secret, product_id) -> Item | None:
    api = "/openapi/param2/1/com.alibaba.product/alibaba.product.get"
    params = {"access_token": app_key, "productID": product_id, "webSite": "1688"}
    params["_aop_signature"] = sign_alibaba(params, secret)
    r = await client.post(
        "https://gw.open.1688.com" + api + "/" + app_key, data=params
    )
    d = (r.json() or {}).get("result") or {}
    if not d:
        return None

    ranges = (d.get("saleInfo") or {}).get("priceRanges") or []
    price = Decimal(str(ranges[0]["price"])) if ranges else Decimal("0")
    images = [i["fullPathImageURI"] for i in (d.get("image") or {}).get("images", [])]

    return Item(
        platform="alibaba",
        item_id=str(d.get("productID", product_id)),
        title=d.get("subject", ""),
        category_id=str(d.get("categoryID", "")),
        category_path=[],
        images=images,
        desc_html=d.get("description", "") or "",
        attrs={a.get("attributeName", ""): a.get("attributeValue", "")
               for a in (d.get("attributes") or [])},
        skus=[],
    )


async def fetch_jd(client, app_key, secret, sku_id) -> Item | None:
    params = {
        "method": "jd.union.open.goods.query",
        "app_key": app_key, "timestamp": "2026-01-01 00:00:00",
        "format": "json", "v": "1.0",
        "360buy_param_json": '{"skuIds":[%s]}' % sku_id,
    }
    params["sign"] = sign_jd(params, secret)
    r = await client.post("https://api.jd.com/routerjson", data=params)
    rows = ((r.json().get("jd_union_open_goods_query_responce") or {})
            .get("result") or {}).get("data") or []
    if not rows:
        return None

    g = rows[0]
    return Item(
        platform="jd",
        item_id=str(g.get("skuId", sku_id)),
        title=g.get("skuName", ""),
        category_id=str(g.get("cid2", "")),
        category_path=[g.get("cid1Name", ""), g.get("cid2Name", "")],
        images=[g["imageUrl"]] if g.get("imageUrl") else [],
        desc_html="",
        attrs={},
        skus=[Sku(
            sku_id=str(g.get("skuId", sku_id)),
            props={},
            price=Decimal(str(g.get("price", "0"))),
            stock=0,
        )],
    )


def _parse_props(s: str) -> dict[str, str]:
    """淘宝的 props_name 格式:'1627207:28320:颜色:黑色;20509:28314:尺码:L'"""
    out = {}
    for seg in filter(None, s.split(";")):
        parts = seg.split(":")
        if len(parts) >= 4:
            out[parts[2]] = parts[3]
    return out
```

注意最后那个 `_parse_props`。淘宝把属性塞在一个 `k:v:k:v;` 的字符串里,1688 给的是结构化数组,京东干脆只给类目名。**同一个「颜色」,三个平台三种给法**,这是归一化模型存在的全部理由。

### 4.3 字段映射 + 详情页清洗

```python
# mapper.py
import re
from decimal import Decimal

# 统一字段 → 各平台字段名
FIELD_MAP = {
    "alibaba": {"title": "subject", "price": "price", "stock": "amountOnSale",
                "images": "image", "desc": "description", "category": "categoryID"},
    "jd":      {"title": "wareName", "price": "jdPrice", "stock": "stockNum",
                "images": "imagePath", "desc": "introduction", "category": "categoryId"},
    "taobao":  {"title": "title", "price": "price", "stock": "quantity",
                "images": "pic_url", "desc": "desc", "category": "cid"},
}


def clean_desc(html: str) -> str:
    """去掉原平台的脚本、外链、活动痕迹"""
    if not html:
        return ""
    html = re.sub(r"<script[\s\S]*?</script>", "", html, flags=re.I)
    html = re.sub(r"<iframe[\s\S]*?</iframe>", "", html, flags=re.I)
    html = re.sub(r'\son\w+="[^"]*"', "", html)          # 内联事件
    html = re.sub(r"https?://[^\"'\s>]*(taobao|tmall|1688|jd)\.com[^\"'\s>]*",
                  "#", html, flags=re.I)                  # 平台域名
    return html.strip()


def to_alibaba(item) -> dict:
    m = FIELD_MAP["alibaba"]
    return {
        m["title"]: item.title[:60],          # 1688 标题上限 60 字符
        m["category"]: item.category_id,
        m["desc"]: clean_desc(item.desc_html),
        "saleInfo": {
            "priceRanges": [{
                "startQuantity": 1,
                "price": float(item.skus[0].price if item.skus else Decimal("0")),
            }],
            "amountOnSale": item.skus[0].stock if item.skus else 0,
        },
        "attributes": [{"attributeName": k, "attributeValue": v}
                       for k, v in item.attrs.items()],
    }


def to_jd(item) -> dict:
    m = FIELD_MAP["jd"]
    return {
        m["title"]: item.title,
        m["category"]: item.category_id,
        m["price"]: str(item.skus[0].price if item.skus else "0"),
        m["stock"]: item.skus[0].stock if item.skus else 0,
        m["images"]: ",".join(item.images[:5]),
    }
```

`clean_desc` 这段别省。我见过太多人直接把详情 HTML 原样铺过去,结果详情页里全是原店铺的跳转链接和活动倒计时脚本,审核直接驳回,还查不出原因。

### 4.4 类目映射:规则 + 兜底

类目 ID 之间没有数学关系,只能靠语义匹配。

```python
# category.py
CATEGORY_DICT = {
    "alibaba": [
        (["连衣裙", "裙子", "女装"], "女装 / 连衣裙", "201234567"),
        (["T恤", "短袖"], "男装 / T恤", "201234568"),
        (["手机壳"], "数码 / 手机配件", "201234569"),
        (["保温杯", "水杯"], "日用百货 / 杯具", "201234570"),
    ],
    "jd": [
        (["连衣裙", "裙子", "女装"], "女装 / 连衣裙", "1319,1523,1524"),
        (["T恤", "短袖"], "男装 / T恤", "1319,1523,1525"),
        (["手机壳"], "数码 / 手机配件", "652,829,854"),
        (["保温杯", "水杯"], "家居 / 杯具", "1620,11953,11957"),
    ],
}


def map_category(item, target: str) -> tuple[str | None, float]:
    """返回 (目标类目ID, 置信度)。低于 0.6 建议进人工复核"""
    text = item.title + " " + " ".join(item.category_path) + " " + " ".join(item.attrs.values())
    rules = CATEGORY_DICT.get(target, [])

    best, best_score = None, 0.0
    for keywords, _path, cid in rules:
        hits = sum(1 for k in keywords if k in text)
        if not hits:
            continue
        score = min(1.0, hits / max(1, len(keywords)))
        if score > best_score:
            best, best_score = cid, score
    return best, best_score
```

实际项目里这张字典会有几百条,来源一般是「把目标平台的热销类目导出来,手工对照一遍」。机器做初筛,人工确认一次,之后就能复用。

### 4.5 批量搬运引擎:任务表 + 断点续传

批量任务的真正难点不是并发,是**失败了怎么继续**。跑到第 700 个挂了,不能从头再来。

```python
# runner.py
import asyncio
import sqlite3
from datetime import datetime


class TransferEngine:
    def __init__(self, db_path="transfer.db"):
        self.db = sqlite3.connect(db_path, check_same_thread=False)
        self.db.execute("""
            CREATE TABLE IF NOT EXISTS job (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                src_platform TEXT, src_id TEXT, dst_platform TEXT,
                status TEXT DEFAULT 'pending',      -- pending/doing/done/failed
                attempts INTEGER DEFAULT 0,
                dst_id TEXT, error TEXT,
                updated_at TEXT,
                UNIQUE(src_platform, src_id, dst_platform)
            )
        """)
        self.db.commit()

    def add_jobs(self, src_platform: str, ids: list[str], dst_platform: str):
        self.db.executemany(
            "INSERT OR IGNORE INTO job(src_platform, src_id, dst_platform) VALUES(?,?,?)",
            [(src_platform, i, dst_platform) for i in ids],
        )
        self.db.commit()

    def take(self, limit: int) -> list[tuple]:
        rows = self.db.execute(
            "SELECT id, src_platform, src_id, dst_platform, attempts "
            "FROM job WHERE status IN ('pending','failed') AND attempts < 3 "
            "ORDER BY attempts, id LIMIT ?", (limit,)
        ).fetchall()
        for r in rows:
            self.db.execute("UPDATE job SET status='doing' WHERE id=?", (r[0],))
        self.db.commit()
        return rows

    def finish(self, job_id, ok: bool, dst_id=None, err=None):
        if ok:
            self.db.execute(
                "UPDATE job SET status='done', dst_id=?, updated_at=? WHERE id=?",
                (dst_id, datetime.now().isoformat(timespec="seconds"), job_id))
        else:
            self.db.execute(
                "UPDATE job SET status='failed', attempts=attempts+1, error=?, "
                "updated_at=? WHERE id=?",
                (str(err)[:500], datetime.now().isoformat(timespec="seconds"), job_id))
        self.db.commit()

    def stats(self) -> dict:
        rows = self.db.execute(
            "SELECT status, COUNT(*) FROM job GROUP BY status").fetchall()
        return dict(rows)


async def run(engine: TransferEngine, worker, concurrency: int = 3):
    """worker(job) -> dst_id,抛异常算失败"""
    sem = asyncio.Semaphore(concurrency)

    async def one(job):
        job_id, src_p, src_id, dst_p, attempts = job
        async with sem:
            try:
                dst_id = await worker(src_p, src_id, dst_p)
                engine.finish(job_id, True, dst_id=dst_id)
                print(f"[OK]   {src_p}:{src_id} -> {dst_p}:{dst_id}")
            except Exception as e:
                engine.finish(job_id, False, err=e)
                print(f"[FAIL] {src_p}:{src_id} ({attempts + 1}/3) {e}")
                await asyncio.sleep(2 ** attempts)   # 指数退避

    while True:
        batch = engine.take(10)
        if not batch:
            break
        await asyncio.gather(*[one(j) for j in batch])
        print("进度:", engine.stats())
```


这个 `take` 里 `status IN ('pending','failed')` 加 `attempts < 3`,就是断点续传的全部秘密:失败的任务会被重新捞出来,但最多试 3 次,避免死循环卡住整批。

### 4.6 跑起来

```python
# main.py
import asyncio
import httpx

from collectors import fetch_taobao, fetch_alibaba, fetch_jd
from mapper import to_alibaba, to_jd
from category import map_category
from runner import TransferEngine, run


async def worker(src_p, src_id, dst_p, client, cfg):
    fetcher = {"taobao": fetch_taobao, "alibaba": fetch_alibaba, "jd": fetch_jd}[src_p]
    item = await fetcher(client, cfg["key"], cfg["secret"], src_id)
    if not item:
        raise RuntimeError("源商品不存在或已下架")

    cid, score = map_category(item, dst_p)
    if not cid or score < 0.6:
        raise RuntimeError(f"类目无法匹配(置信度 {score:.2f}),转人工复核")

    item.category_id = cid
    item.images = [await transfer_image(u) for u in item.images]  # 必须转存
    payload = to_alibaba(item) if dst_p == "alibaba" else to_jd(item)

    r = await client.post(f"{cfg['gateway']}/product/create", json=payload)
    r.raise_for_status()
    data = r.json()
    if not data.get("success"):
        raise RuntimeError(data.get("msg", "创建失败"))
    return data["productId"]


async def transfer_image(url: str) -> str:
    """下载原图 → 上传自己的对象存储 → 返回可用地址"""
    # 实际实现接你的 OSS / 平台图片上传接口
    # 关键点:不要返回源平台地址,目标平台一定拉不到
    return url.replace("https://img.alicdn.com", "https://cdn.yourdomain.com")


async def main():
    cfg = {"key": "your_appkey", "secret": "your_secret",
           "gateway": "https://api.yourdomain.com"}
    engine = TransferEngine()
    engine.add_jobs("taobao", ["654321098765", "654321098766"], "alibaba")

    async with httpx.AsyncClient(timeout=30) as client:
        async def _w(sp, si, dp):
            return await worker(sp, si, dp, client, cfg)
        await run(engine, _w, concurrency=3)

    print("最终:", engine.stats())


if __name__ == "__main__":
    asyncio.run(main())
```

## 五、实测效果

一个做家居用品的中型店,从淘宝往 1688 和京东铺货:

| 指标        | 人工        | 脚本               |
| --------- | --------- | ---------------- |
| 单个商品耗时    | 40 分钟     | 约 25 秒           |
| 100 个商品   | 3 天(还不算漏) | 约 12 分钟          |
| 类目对不上     | 靠经验试错     | 自动匹配 87%,13% 转人工 |
| 漏改价格 / 库存 | 常态        | 0                |

真正省下来的不只是时间,是**上新节奏**。人工三天才能铺完的款,脚本跑完你当天就能测。

## 六、五个踩过的坑

1. **幂等键一定要设。** 用商家编码 `outer_id` 做唯一键。没有它,重跑一次就是双倍商品,删起来比重铺还麻烦。
2. **图片必须转存。** 平台图片地址有签名时效,几小时后就失效。别想着偷懒直接传。
3. **详情页 HTML 必洗。** 脚本、iframe、平台域名,一个都不能留,否则审核驳回还查不出原因。
4. **批量接口是异步的。** 提交返回 `task_id`,要轮询到 `status=3` 才叫成功。把提交当成功,会有一半商品实际没发出去。
5. **类目别追求 100%。** 85% 自动 + 15% 人工复核,是成本最低的平衡点。硬做全自动,后期维护规则的精力比人工还多。

## 七、最后

这套东西的核心不在代码量,在**那个统一模型**。模型定对了,接第四个平台只是多写一个 20 行的采集器;模型定错了,每接一个平台都要改一遍全部逻辑。

代码在上面,改个 appkey 就能跑。签名、限流、字段映射这些琐事,如果你不想一个个啃文档,也可以用现成的三平台 API 接口服务,淘宝 / 1688 / 京东的商品详情、SKU、库存、图片一次拿全,格式统一,直接对接上层的映射逻辑。

有在做的可以聊聊类目映射这块,各家的规则表是怎么维护的。

---

**标签**:`Python` `电商` `API` `数据采集` `多平台运营`

未分类

《抖音订单API:/trade/order/detail 落地方案 —— 消息推送 + 补偿查询的架构设计》(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-17 浏览83 评论0

《抖音订单API:/trade/order/detail 落地方案 —— 消息推送 + 补偿查询的架构设计》(附 Python 源码)

一、先拍结论

抖店订单同步的成熟架构只有一句话:
“消息/数据推送做触发器,/order/searchList 做时间窗兜底,/order/orderDetail 拉真相,本地状态机做仲裁。”

未分类

《抖店开放平台API:落地方案、接口边界与业务踩坑 —— 2026.7.1 商品发布接口收费》(附 Python 源码)

万邦科技Lex 万邦科技Lex 发表于2026-10-16 浏览82 评论0

《抖店开放平台API:落地方案、接口边界与业务踩坑 —— 2026.7.1 商品发布接口收费》(附 Python 源码)

一、2026.7.1 新规先拍死(官方口径)

抖店开放平台 2026-06-16 发《收费范围调整公告》+《技术服务费规则 V1.3》,2026-07-01 起生效: