做跨境电商的人都懂:每天早上第一件事,就是登录 OZON 后台,一个店铺一个店铺地看昨天的数据——销售额、退款、广告费、有效订单、取消订单……几家店铺切换下来,半小时过去了,Excel 上还没写一个字。
于是我花了一点时间,把这件事彻底自动化了。本文记录整个过程,既是技术复盘,也是对"数据获取"这件事本身的一些思考。
一、问题的起点:后台数据到底有多"散"
在 OZON 卖家后台,看一天的经营数据,要跳转至少 4 个不同的页面:
| 需要的数据 | 后台路径 | 痛点 |
|---|---|---|
| 订单金额、订单量 | 订单管理 → 分别看 FBO 和 FBS | FBO(OZON仓发货)、FBS(自发货)是两套独立列表 |
| 取消订单 | 同上,需筛选状态 | 要手动勾选"已取消"才能看到 |
| 退款金额 | 财务 → 经营状况 → 退款 | 和订单是完全独立的口径 |
| 广告花费 | 广告后台(独立系统,独立登录) | 账号体系都和卖家后台分开 |
| 产品销售明细 | 分析 → 按 SKU 看 | 还要自己导出、筛掉销售额为 0 的 |
关键认知:OZON 的销售数据和广告数据,是两套完全独立的 API 体系,连鉴权方式都不一样。这是整个事情第一个需要跨过的门槛。
如果店铺数量是 5 个,上面的动作要重复 5 次。如果还要汇总"近 7 天"做趋势分析——那基本上一上午就废了。
二、网页后台 vs API:同样的数据,两种获取方式
这是全文的核心对比。我把整个数据获取流程拆成 4 块,逐块对比。
对比 1:销售订单金额
网页后台做法
- 登录卖家后台
- 进入"订单 → FBO",筛选昨天的下单时间,记录订单金额
- 进入"订单 → FBS",同样筛选昨天,记录订单金额
- 把状态为"已取消"的单子手动剔除
- 把上面两块加起来
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 最反直觉的地方之一。 - 分页用
page和page_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,主脚本只负责逻辑。