做1688方向的数据项目,有两件事绕不开:一是把商品类目跑通,二是把商品榜单拉出来。类目决定商品归属,榜单反映供需热度,这两项打通之后,选品、价格监控、供应链分析就都有数据可依了。
这篇文章把一个实际项目的全流程拆开讲——从类目树递归拉取,到按销量和综合维度调取商品榜单,再到签名、限频、数据落库,全程用 Python 演示,中间穿插大量踩坑记录。项目本身不复杂,核心价值在于把类目和榜单连接起来的思路。适合正在做 B2B 选品工具、竞品价格分析、商家运营数据报表的开发者参考。
先交代一下背景:这是一个内部小项目,代号就叫“模拟项目X”。最初的需求只有一个,给一个一级类目,每天自动把该类目下所有子类目的热销商品排名抓回来。听起来简单,真正做起来才发现,类目接口、搜索排序、鉴权签名、限频重试,每一个环节都有细节。下面按项目推进的顺序,把整套方案完整复盘一遍。
1. 整体思路与方案选型
1.1 项目到底要解决什么问题
把标题拆开看,这个项目就两件事:先拿类目,再拿榜单。类目解决的是“去哪找”的问题,榜单解决的是“找什么”的问题。
在真实业务里,采购团队经常会问“陶瓷杯这个类目下哪个子类目热度高”,或者“童装类目里销量增长最快的是哪些商品”。如果只给一堆商品链接,第二天链接过期就废了。一个可靠的做法是:每天定时把叶子类目下的商品列表快照拉取回来,统一按排名和销量字段整理,生成一份可对比的榜单数据。
这里最容易踩的认知坑,是把“榜单”理解成平台直接提供的一个固定接口。实际上,大部分榜单数据本质上就是“带排序参数的商品搜索结果”。项目核心不是去找某个神秘的排行榜接口,而是构造出正确的类目和搜索条件,再把返回结果按业务口径重排一次。
1.2 为什么不用页面爬虫,而是走接口
一开始团队也考虑过直接抓页面。1688商品列表页左侧有完整类目导航,搜索结果也能按销量排序,看起来直接用爬虫一把梭就行。但真正跑起来会发现几个问题:
- 页面是异步渲染的,商品数据藏在多个内部请求里,链接经常带动态签名参数。
- 高频访问会触发环境检测和验证码,封控策略一天比一天严。
- 页面结构经常调整,改一次版爬虫就要重写一次,长期维护成本太高。
反过来,开放接口虽然要申请权限,但返回的是结构化字段,有文档、有错误码,数据稳定度好很多。尤其是类目数据,接口返回的父子关系非常规整,比从页面解析省太多事。
需要注意,开放接口不是所有能力都默认开通。有些商品详情字段、搜索类接口需要单独申请权限,而且对调用量有要求。项目启动前最好先把接口权限清单列出来,逐项和平台审核人员确认,不然开发完才发现某个字段没权限,返工成本很高。
1.3 技术栈选型与项目目录结构
考虑到团队现状,技术栈选的是 Python 3.10 + requests + MySQL + 定时调度。选型逻辑很简单:
- Python 处理 JSON、写脚本最快,团队熟悉。
- requests 足够稳定,不需要上 httpx 之类的异步框架,因为本来就要主动限频,异步反而难控制节奏。
- MySQL 用于存储类目树和榜单快照,单日几十万行数据完全扛得住。
- 定时调度用 APScheduler,方便在同一个进程里跑多个任务,也方便控制每个任务间的间隔。
项目目录结构大致如下:
project/ app/ auth.py # 签名、令牌、密钥管理 category.py # 类目拉取与建树 rank.py # 榜单搜索、分页、排序 storage.py # 数据落地和快照管理 config.py # 全局配置 run.py # 定时任务入口这个结构看着简单,但对项目维护非常关键。把签名逻辑单独拆出来,是因为后面所有接口调用都要用它,而且签名问题是排查率最高的故障点。
2. 商品类目树的抓取与建模
2.1 类目结构的核心逻辑
1688 的类目体系是典型的树形结构,根类目下挂着一级类目,一级下再挂二级,二级下继续挂叶子节点。实际调接口时,不会一次性返回整棵树,通常只能按父类目 ID 逐层查询子类目。
这里有一个非常重要的设计点:类目 ID 是后续所有搜索请求的基础参数。你拿着一个叶子类目 ID,才能在榜单搜索中缩小范围;如果只传一级类目 ID,搜索结果会混入大量跨子类目的商品,榜单的参考价值就大打折扣。
所以项目的第一步,不是急着调榜单接口,而是先把整棵类目树完整拉取下来,存进一张表里,建好父子关系和层级索引。
2.2 递归拉取子类目
类目接口请求参数比较简单,核心就是传入父类目 ID,返回子类目列表。下面是一段简化后的示意代码:
import hashlib import time import requests class CategoryService: def __init__(self, app_key, app_secret): self.app_key = app_key self.app_secret = app_secret def get_children(self, parent_id): params = { "parentId": parent_id, "appKey": self.app_key, "timestamp": int(time.time() * 1000), } params["sign"] = self._sign(params) resp = requests.get( "https://gateway.example.com/category/getChildren", params=params, timeout=10 ) data = resp.json() if data.get("code") != "0": raise RuntimeError(f"类目接口异常: {data}") return data.get("children", []) def _sign(self, params): ordered = "".join(f"{k}{v}" for k, v in sorted(params.items())) raw = f"{self.app_secret}{ordered}{self.app_secret}" return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()有了单个节点的查询能力,递归构建整棵树就很简单:
def walk_category(parent_id, level=0, path=""): children = service.get_children(parent_id) rows = [] for child in children: rows.append({ "category_id": child["id"], "parent_id": parent_id, "level": level + 1, "name": child["name"], "path": f"{path}/{child['name']}", }) if child.get("hasChild"): rows.extend(walk_category(child["id"], level + 1, f"{path}/{child['name']}")) return rows这里要注意几个实际运行时的坑:
- 递归深度可能比预想的大,建议在代码里加最大深度保护,超过六层就强制停止并告警。
- 有些类目节点会返回
hasChild=true,但下一层返回空列表,说明该节点数据不完整或需要额外权限。遇到这种情况要记录日志,不要静默通过。 - 类目接口单次调用量有限制,全量递归可能触发频控,每次调用之间最好加 300 毫秒左右的间隔。
2.3 类目数据落库与常规维护
类目表结构我用的是这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| category_id | bigint | 类目ID |
| parent_id | bigint | 父类目ID,根节点为0 |
| level | int | 层级 |
| name | varchar | 类目名称 |
| path | varchar | 全路径,方便运营阅读 |
| is_leaf | tinyint | 是否叶子类目 |
| updated_at | datetime | 最后更新时间 |
建好之后,类目 ID 就是后续榜单任务的主要遍历对象。
类目数据不是每天都会变,但不能假设它永远不变。我的经验是每三天全量刷新一次类目树,跑完和库里旧数据做 diff,把新增、停用、改名的类目单独导出一张变更表。这个变更信息对运营来说价值很高,因为类目一旦变化,基于旧类目配置的商品榜单任务就要跟着调整。
3. 商品榜单调用的核心实现
3.1 榜单的本质是排序搜索
“商品榜单”这个需求,落到接口调用上,最直接的做法是使用商品搜索接口,并指定排序维度。整个调用链路是这样:
- 从类目表取一个叶子类目 ID。
- 调用搜索接口,设置
categoryId和排序参数。 - 分页拉取返回商品列表。
- 记录商品 ID、标题、价格、销量、店铺等信息。
- 把结果按排名写入榜单快照表。
搜索接口支持的常见排序维度有:
- 综合排序:平台默认权重,包含价格、销量、转化率、店铺评分等多个因子。
- 销量排序:按近一段时间销量聚合,最接近“热销”这个概念。
- 价格排序:适合做低端供应链筛选。
- 新品排序:适合追踪新品趋势。
业务侧最常用的是销量排序和综合排序。如果只保留一个,我会首选销量排序,因为真实采购场景下,卖得动才是硬道理。
3.2 榜单搜索请求的构造
搜索接口的请求参数,重点在于如何传类目 ID。很多开发者会顺手把关键词也填上,但这会让榜单失真。正确做法是:只传类目 ID,不传关键词,让平台返回该类目下的自然商品流。
示意代码如下:
class RankService: def search_offers(self, category_id, sort_type="sale", page=1, page_size=20): params = { "categoryId": category_id, "sort": sort_type, "page": page, "pageSize": page_size, } # 补充公共参数和签名 params.update(self.common_params()) params["sign"] = self.sign(params) resp = requests.post( "https://gateway.example.com/offer/search", json=params, timeout=15 ) json_data = resp.json() return json_data返回结构里通常会包含这些字段:
offerId:商品唯一 ID,榜单任务后续更新、对比都靠它。subject:商品标题,标题本身包含大量类目关键词,可以用来做标签。price:商品主价格,要注意区分区间价和起批量价格。saleQuantity:销量,不同接口可能表示成近 30 天销量或累计销量。shopName:店铺名称,可以用它评估整体销量是不是被某一家垄断。detailUrl:商品链接,方便运营直接跳转核实。
3.3 分页、去重与抓取节奏
搜索接口有分页上限,一般单页最多 20 到 40 条,总页数也可能限制在 50 页以内。所以拿全一个叶子类目的榜单,需要连续翻页。这里建议把每页大小调到一个适中值,比如 20 条,减少单次请求的超时概率。
翻页逻辑里有个容易被忽略的问题:高销量商品在翻页过程中可能被插到前面或后面,导致跨页重复。我的处理方式很简单:
- 以
offerId为主键做全局去重。 - 每一页拉回来先缓存到内存,全部拉完之后再统一排序和去重。
- 如果同一商品在前后页重复出现,保留排序最靠前的那条记录。
节奏方面,给每个叶子类目设置 500 毫秒到 1 秒的间隔,既避免触发频控,又不会让整个任务爬得太慢。假设你有 1000 个叶子类目,每类目 3 页,单页 20 条,加间隔后的总耗时大约在 40 到 60 分钟,完全在可接受范围内。
3.4 榜单快照的表结构设计
榜单数据按日期落库,推荐使用“每天全量快照”的模式。不要试图做实时更新,一是没必要,二是接口限流撑不住。
榜单快照表结构:
| 字段名 | 类型 | 说明 |
|---|---|---|
| run_date | date | 快照日期 |
| category_id | bigint | 类目ID |
| rank | int | 商品排名 |
| offer_id | bigint | 商品ID |
| title | varchar | 商品标题 |
| price | decimal | 商品价格 |
| sale_quantity | int | 销量 |
| shop_name | varchar | 店铺名称 |
| detail_url | varchar | 商品链接 |
把run_date和category_id建成联合索引,查询某一天某个类目的榜单会非常快。每日快照模式还有个额外好处:可以做“榜单变动分析”,直接对比昨天和今天的排名变化,能发现哪些商品冲上来了、哪些在掉榜。
4. 签名鉴权与高频调用限制破解
4.1 应用权限与令牌管理
所有接口调用都绕不开授权。项目初始化时,需要在开放平台创建应用,拿到appKey和appSecret,再根据接口能力范围申请权限。
这里有一个经验:appSecret一定不要写死在代码里,更不要提交到代码仓库。正确做法是放到环境变量或独立配置文件里,并在配置文件中加一个“是否已脱敏”的检查。我们团队有一次在测试环境把密钥打印在日志里,结果排查了半天才发现是日志平台把密钥当成敏感信息自动拦截了,导致后续签名一直对不上。
如果接口需要用户授权 token,还要处理 token 过期的问题。建议加一层缓存机制,token 快过期时提前刷新,不要在调接口时才被动处理。
4.2 签名算法的关键细节
很多接口的签名算法都是通用套路:把所有请求参数按字典序排序,拼接成字符串,再用密钥做哈希。看起来简单,实际操作中有几个地方非常容易出错:
- 排序必须按参数名的字典序,用 Python 的
sorted(params.items())是安全的,不要自己手写排序逻辑。 - 参数名区分大小写,
pageSize和pagesize是两个不同参数,排序顺序也会不同。 - 拼接值是原始值,不要对值做 URL 编码后再参与签名,否则网关验签会失败。
- 时间戳尽量用毫秒级,并保证和网关时间差在允许范围内。
再强调一次,签名报错是接口对接中最常见的问题。排查时不要看代码,先打印出参与签名的原始参数字符串,和服务端要求对比,通常一眼就能找到问题。盲改代码只会越改越乱。
4.3 限流策略与频控规避
接口调用量一大,平台就会开始限流。限流的表现不是立刻报错,而是先故意延迟响应,再随机报几个“系统繁忙”错误。我们项目上线第一周就吃过这个亏,定时任务把并发调到 16,跑了不到半小时就开始大量报错。
后来调整成了一套比较保守的频控策略:
- 全局并发数限制在 4。
- 每次请求之间随机休眠 300 到 800 毫秒。
- 同一类目的连续请求不超过 20 次。
- 遇到限流错误,退避间隔按 2 倍递增,最多退避到 30 秒。
- 每周避免高峰期运行时,比如平台大促前后系统负载高,尽量避开。
这套策略牺牲了一点速度,但换来稳定性。现在每天跑几千个类目,基本不会触发频控。
5. 数据质量治理与问题排查实录
5.1 数据一致性的坑
榜单数据跑通后,还面临业务侧的数据质量问题。第一个坑是价格字段不统一。同一个商品有很多个 SKU,价格接口返回的是区间价,比如 5.5 到 12.8 元,直接存一个字段会导致后续排序和报表失真。我的处理方式是拆成price_min和price_max两个字段,存原始值不做加工。
第二个坑是销量字段的统计口径。不同接口对“销量”的定义可能不同,有的表示近 30 天成交件数,有的表示累计销量,还有的带单位“件”或“批”。所有字段入库前都要统一标准化,否则榜单对比就是鸡同鸭讲。项目里我用了一个字段叫sale_type,明确标记该销量是“近30天”还是“累计”。
第三个坑是商品可能下架。昨天还在榜单里的商品,今天搜索可能就查不到了。处理方式是保留历史快照,不在当天的快照表里删除旧数据。下架商品会在后续变动分析中被自动识别。
5.2 常见异常错误速查表
整理一份实际运行中遇到的错误排查表,按优先级排列:
| 错误特征 | 可能原因 | 处理方式 |
|---|---|---|
| 签名校验失败 | 参数拼接顺序错误、密钥不对、值未编码 | 打印原始签名串逐字对比 |
| 权限不足 | 应用未开通对应接口权限或权限已过期 | 检查开放平台应用权限 |
| 调用频率超限 | 单位时间请求数超出配额 | 降低并发、增大休眠 |
| 商品列表返回为空 | 类目 ID 是父级而非叶子、类目已停用 | 核对类目表,改用叶子类目 |
| 单页超时 | 网络波动、响应体过大 | 减小 pageSize,增加重试 |
| 字段缺失 | 部分商品没有销量或价格数据 | 先用默认值填充,标记异常记录 |
| 远程服务错误 | 平台服务端临时故障 | 指数退避重试,最多三次 |
这里特别提一下“远程服务错误”,它不是你的代码问题,是网关暂时挂了。但这个错误不能无限重试,无脑重试会把限流也带出来。建议重试三次后,把任务标记为失败,留到下一次调度统一补跑。
5.3 定时任务的断点续跑与告警
定时任务最怕跑了一半崩溃,然后整个流程要重来。我的做法是在表里加一个任务状态字段:
todo:待执行running:执行中success:完成failed:执行失败
每个叶子类目的榜单任务都独立记录状态。调度器启动时,先查所有failed和todo状态的任务,重新入队。这样一次崩溃只会影响失败的任务,不会拖累其他类目。
告警方面,我接了一个简单的日志检查:如果连续失败任务超过整体任务数的 5%,就给团队发一条企业微信机器人消息。不用做复杂的监控平台,一个脚本就够,核心是尽早发现问题。
最后再说几点实际体会
项目上线跑了一个多月,整体的效果比预想好。最初以为技术难点在接口调用和签名上,真正运行后才发现,数据口径的统一和每日快照的设计才是这个项目的命脉。类目数据即便不参与榜单计算,单独作为一个基础数据表也有很高的复用价值,比如做商品标签、做目录导航、做运营分析,都会用到它。
我个人最推荐的一个操作是:每天榜单跑完后,把当天的快照和前一天做一次 diff,生成“榜单上升榜”和“榜单下降榜”。这个变动数据比单日榜单本身更值钱,它是采购决策和运营活动策划的直接依据。
如果在座的你正准备做类似的数据项目,建议给自己留出至少两成的开发时间处理限流和异常重试,不要只盯着接口文档写代码。接口文档只能告诉你参数怎么传,不能告诉你线上环境会有多颠簸。把容错和监控做好,这个项目才算真正落地。