×

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

万邦科技Lex 万邦科技Lex 发表于2026-10-21 14:43:06 浏览33 评论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` `数据分析`


群贤毕至

访客