从"每天打开后台截图"到"一键生成日报":我如何用 OZON API 重构跨境电商的数据获取流程

做跨境电商的人都懂:每天早上第一件事,就是登录 OZON 后台,一个店铺一个店铺地看昨天的数据——销售额、退款、广告费、有效订单、取消订单……几家店铺切换下来,半小时过去了,Excel 上还没写一个字。

于是我花了一点时间,把这件事彻底自动化了。本文记录整个过程,既是技术复盘,也是对"数据获取"这件事本身的一些思考。


一、问题的起点:后台数据到底有多"散"

在 OZON 卖家后台,看一天的经营数据,要跳转至少 4 个不同的页面:

需要的数据 后台路径 痛点
订单金额、订单量 订单管理 → 分别看 FBO 和 FBS FBO(OZON仓发货)、FBS(自发货)是两套独立列表
取消订单 同上,需筛选状态 要手动勾选"已取消"才能看到
退款金额 财务 → 经营状况 → 退款 和订单是完全独立的口径
广告花费 广告后台(独立系统,独立登录) 账号体系都和卖家后台分开
产品销售明细 分析 → 按 SKU 看 还要自己导出、筛掉销售额为 0 的

关键认知:OZON 的销售数据和广告数据,是两套完全独立的 API 体系,连鉴权方式都不一样。这是整个事情第一个需要跨过的门槛。

如果店铺数量是 5 个,上面的动作要重复 5 次。如果还要汇总"近 7 天"做趋势分析——那基本上一上午就废了。


二、网页后台 vs API:同样的数据,两种获取方式

这是全文的核心对比。我把整个数据获取流程拆成 4 块,逐块对比。

对比 1:销售订单金额

网页后台做法

  1. 登录卖家后台
  2. 进入"订单 → FBO",筛选昨天的下单时间,记录订单金额
  3. 进入"订单 → FBS",同样筛选昨天,记录订单金额
  4. 把状态为"已取消"的单子手动剔除
  5. 把上面两块加起来

API 做法

FBO 和 FBS 是两个独立的接口,但参数结构几乎一样:

# 拉取 FBS 订单,按莫斯科时区过滤,自动翻页
def _fetch_fbs_orders(store, day):
    result, offset = [], 0
    while True:
        resp = requests.post(
            "https://api-seller.ozon.ru/v3/posting/fbs/list",
            headers=seller_headers(store),
            json={
                "dir": "asc",
                "filter": {
                    "since": day + "T00:00:00+03:00",  # 莫斯科时间 +03:00
                    "to":    day + "T23:59:59+03:00"
                },
                "limit": 50, "offset": offset,
            }, timeout=30)
        postings = resp.json().get("result", {}).get("postings", [])
        result += postings
        if len(postings) < 50:   # 不足一页,说明拉完了
            break
        offset += 50
    return result

拿到数据后,用状态码筛掉取消的订单,剩下的累加金额就是"有效订单金额":

CANCELLED = {"cancelled", "cancelled_employer"}  # 两种取消状态

valid = [p for p in all_orders if p.get("status") not in CANCELLED]
result_a = sum(
    float(item["price"]) * int(item["quantity"])
    for p in valid
    for item in p.get("products", [])
)

几个非写不可的注释

  • 时间必须用莫斯科时间(UTC+3),否则跨零点的订单会错位到另一天。OZON 后台显示的就是莫斯科时间。
  • FBS 和 FBO 是两套接口/v3/posting/fbs/list/v2/posting/fbo/list,连 API 版本号都不一样。
  • 分页用 offset 推进,每页最多 50 条,直到返回不足 50 条才算拉完。

对比 2:退款金额

网页后台做法

进入"财务 → 经营状况 → 退款",记录昨天的退款金额。

API 做法

退款这里有个坑:它不在订单接口里,而在财务流水接口里,而且需要按 transaction_type: "returns" 过滤:

def _fetch_returns_amount(store, day):
    total, page = 0.0, 1
    while True:
        resp = requests.post(
            "https://api-seller.ozon.ru/v3/finance/transaction/list",
            headers=seller_headers(store),
            json={
                "filter": {
                    "date": {
                        "from": day + "T00:00:00.000Z",
                        "to":   day + "T23:59:59.000Z"
                    },
                    "operation_type": [],
                    "transaction_type": "returns"  # 关键:只要退款
                },
                "page": page, "page_size": 100
            }, timeout=30)
        result = resp.json().get("result", {})
        for op in result.get("operations", []):
            total += op.get("accruals_for_sale", 0)
        if page >= result.get("page_count", 1):
            break
        page += 1
    return abs(round(total, 2))  # 退款是负数,取绝对值

几个非写不可的注释

  • 退款金额字段是 accruals_for_sale,返回的是负数(账上扣减),所以最后要 abs()
  • 这个接口的时间用的是 UTC 格式.000Z),和订单接口的莫斯科时间格式不同。同一个 API 家族,不同接口的时间格式居然不一致,这是 OZON API 最反直觉的地方之一。
  • 分页用 pagepage_size,和订单接口的 offset/limit 也不一样。

对比 3:广告数据

这块最复杂,因为广告是另一套完全独立的 API

维度 销售 API 广告 API
域名 api-seller.ozon.ru api-performance.ozon.ru
鉴权方式 Client-Id + Api-Key 直接塞 header OAuth2,先拿 token 再请求
数据格式 JSON CSV(分号分隔,逗号做小数点)
账号体系 卖家账号 广告主账号(独立申请)

网页后台做法:登录广告后台(另一套登录态),进入"推广 → 推广分析→选择日期",直接记录当天的广告消费或者导出 CSV,手动整理。

API 做法

# 第一步:拿 access_token
def get_perf_token(store):
    resp = requests.post(
        "https://api-performance.ozon.ru/api/client/token",
        json={
            "client_id":     store["perf_id"],
            "client_secret": store["perf_secret"],
            "grant_type":    "client_credentials"
        }, timeout=30)
    return resp.json()["access_token"]

# 第二步:用 token 拿数据,注意返回的是 CSV
def _fetch_ad_detail(store, date_from, date_to):
    token = get_perf_token(store)
    resp = requests.get(
        "https://api-performance.ozon.ru/api/client/statistics/daily",
        headers={"Authorization": f"Bearer {token}"},
        params={"dateFrom": date_from, "dateTo": date_to}, timeout=30)

    # CSV 用分号分隔,小数点是逗号(俄欧习惯)
    df = pd.read_csv(StringIO(resp.text.strip()),
                     sep=";", decimal=",", encoding="utf-8")
    df.columns = ["活动ID", "活动名称", "日期", "展示量", "点击量",
                  "广告费(RUB)", "广告订单量", "广告销售额(RUB)"]

    # 关键:OZON 会在 CSV 末尾自动附加一行"合计行",活动ID 为空
    # 如果不过滤掉,所有金额都会被重复计算一遍
    df = df[df["活动ID"].notna() & (df["活动ID"].astype(str).str.strip() != "")]
    return df

几个踩过的坑

  • 俄语区 CSV 用分号做分隔符、逗号做小数点,所以 pd.read_csv 必须同时传 sep=";"decimal=",",否则数字全错。
  • CSV 末尾会自动附加一行合计行(活动 ID 为空),第一次没注意,广告费直接翻倍。
  • 归因机制:用户先点击了广告 A,又点击了广告 B,最后下单了——OZON 会把这笔销售额同时计入 A 和 B。所以广告销售额加总可能大于实际订单额,这不是 bug,是平台的归因策略。
  • 通过 API 拿到的广告费,和网页端看到的可能会差几卢布,是尾数精度差异造成的,对经营决策没有影响。

对比 4:产品级销售明细

网页后台做法:进入"分析 → 我的商品销售",选日期,导出 Excel,手动剔除销售额为 0 的行。

API 做法:走 /v1/analytics/data,这是一个很通用的 OLAP 风格接口,可以指定维度(dimension)和指标(metrics):

resp = requests.post(
    "https://api-seller.ozon.ru/v1/analytics/data",
    headers=seller_headers(store),
    json={
        "date_from": day, "date_to": day,
        "metrics":   ["revenue", "ordered_units"],  # 想要什么指标
        "dimension": ["sku"],                        # 按什么维度切
        "limit": 1000
    }, timeout=30)

这个接口设计得最好——一个接口搞定所有维度组合,想按 SKU 切就按 SKU,想按类目切就按类目。


三、最难的一个问题:1 号下单、2 号取消怎么办?

这是整个项目里最容易被忽略、也最容易让数据失真的一个问题。

想象这个场景:

  • 11 月 1 日 客户下单,金额 1000 卢布
  • 11 月 2 日 客户取消订单

如果每天跑日报,只拉"当天下单的订单":

  • 11 月 1 日的日报:1000 卢布订单 ✅ 没问题
  • 11 月 2 日的日报:0 单(这天没人下单)
  • 可是 1 号那条数据永远是"有效订单",永远不会更新成"已取消" ❌

换句话说,按下单日拉数据 + 当天下单当天判定状态 = 永远捕捉不到跨天取消

解决方案:每周一次重新统计 + 用实时状态

我写了两个脚本:

ozon_day.py(日报):每天早上跑一次,生成昨天的日报。这是快报,用来快速感知。

ozon_week.py(周报):拉取最近 7 天的所有订单,但判定状态时用的是API 返回的当前状态

关键代码:

# 一次拉取 7 天窗口内的订单
fbs_orders = _fetch_fbs(store, date_from, date_to)  # date_from=7天前, date_to=昨天
fbo_orders = _fetch_fbo(store, date_from, date_to)

# 按下单日分桶,但用"当前状态"判定是否取消
for p in all_orders:
    d = _order_day(p)  # 下单日期(莫斯科时间)
    if p.get("status") in CANCELLED:  # 这里是实时状态,不是历史快照
        cancel_cnt[d] += 1
    else:
        valid_amt[d] += _posting_amount(p)

这样一来:

  • 11 月 1 日下单、11 月 2 日取消的那条订单,在 11 月 2 日跑周报时,会被归到"11 月 1 日 → 已取消"。
  • 1 号的"有效订单金额"会被正确地向下修正 1000 卢布。

这是网页后台也做不到的事情——后台要么给你看"按下单日的静态数据",要么给你看"按处理日的流水",没有"按下单日 + 最新状态"这个视角。

当然也还是有边界——如果订单是 8 天前下的、今天才取消,周报就覆盖不到了。但对实际经营来说,一周窗口已经足够。


四、两种方式的数据差异:

这是全文最该被收藏的部分。同样的"昨天销售额",后台看到的和 API 拿到的,可能对不上。原因:

差异点 说明 影响
时间口径 FBS 看 in_process_at,FBO 看 created_at;财务流水用 UTC 跨零点的订单可能归属不同日期
广告费尾数 API 返回的数值精度和网页端显示的截断方式不同 差几卢布,不影响决策
广告归因 一单可能被多个广告计入销售额 广告销售额之和 > 实际销售额(正常现象)
退款归属 退款按【财务模块-店铺经营状况】“扣款日"归属,不是按"原订单日” 和订单金额不是严格同一批单子的
取消订单 日报只能看到"当天下单当天取消",周报能看"当天下单 N 天内取消" 日报的取消数会偏低,周报更准
导出明细 OZON 导出的销售明细 CSV 默认包含已取消订单 如果直接求和,会虚高,需要筛选掉已取消订单

总结:API 能拿到比后台更完整、更灵活的数据,但代价是你要自己搞清楚每个字段的口径。后台看似简单,其实平台替你做了很多隐式的口径选择——而这些选择并不总是符合你的经营视角。


五、产出:两个 Excel

日报脚本跑完后,生成一个 Excel 文件,包含:

  • 汇总:每个店铺一行
  • 销售明细:店铺 × 日期× SKU
  • 广告明细:按广告活动拆分,带 ROI 和广告费占比

周报脚本跑完后,生成一个 Excel 文件,包含:

  • 汇总:每个店铺一行,近 7 天合计
  • 每日明细:店铺 × 日期,每天一行
  • 销售明细:按 SKU 拆分
  • 广告明细:按广告活动拆分,带 ROI 和广告费占比

每天早上我只需要双击一下 py 文件,几分钟后打开 Excel 就能看到所有店铺的完整经营情况。

原来需要2小时的手工操作,现在变成了 5-10 分钟。


六、写在最后:为什么这件事值得做

网页后台是平台帮你做好的"一个视角",它足够好,但不一定是你最需要的那个视角。 API 是原材料,你可以按自己的口径重新组装数据。

  • 后台不会帮你自动合并20个店铺的数据,API 会。
  • 后台不会帮你做"按下单日 + 最新状态"的口径,API 会。
  • 后台不会帮你把销售和广告并列对比算 ROI,API 会。

这也是我之后会继续挖的方向——不是把脚本写得多花哨,而是把每一个"重复动作"还原成它背后的数据问题,然后用最直接的方式解决它。


本文涉及的接口包括:OZON 卖家 API(订单、财务、分析)和 OZON 广告 API(统计)。 所有接口的鉴权信息、店铺配置被抽离到独立的 ozon_config.py,主脚本只负责逻辑。

AAA