唯品会商品数据API调用教程:签名、分页、去重一篇文章讲透
唯品会的数据有三处和淘宝、1688 不一样,踩过一次就忘不掉:
- 商品 ID 是复合键:
1710613157-6918711233889249157,前半是店铺/品牌 ID,后半才是商品 ID,少一段就查不到; - 搜索单页最多 120 条,翻页期间排序还会抖,同一件商品会在第 2 页、第 3 页反复出现;
- 同一个款会有多个链接(不同店铺、不同活动),按标题判重几乎必错。
所以这篇文章不铺概念,就讲三件事:签名怎么拼、分页怎么翻、去重怎么做。每段都给可运行的 Python。
一、先选路径:官方 VOP、聚合接口、还是页面接口
| 路径 | 地址 | 鉴权 | 适合谁 |
|---|---|---|---|
| 官方开放平台 VOP | https://vop.vipapis.com |
appKey + appSecret + hmac-md5 签名 + 授权 | 商家/服务商,要订单、库存、发货 |
| 聚合接口(本文主线) | https://api-gw.onebound.cn/vip/ |
key + secret(免签名) | 选品、比价、价格监控、竞品分析 |
| 页面接口(mapi) | mapi.vip.com/... |
页面端 sign(JS 生成) | 不建议:随时变更,且合规风险高 |
两条路线并不冲突:读数据用聚合接口最省事,一旦要接自己的商家后台,就得回到 VOP 走签名。所以下面两套都给你——签名那节讲官方,分页和去重两节讲聚合接口。
二、签名:官方 hmac-md5,90% 的教程都拼错
2.1 聚合接口为什么“免签名”
万邦的 vip.item_get / vip.item_search 只需要把 key、secret 拼在 URL 上,服务端据此鉴权,不需要额外算 sign:
https://api-gw.onebound.cn/vip/item_get/?key=xxx&secret=yyy&num_iid=1710613157-6918711233889249157
省事,但也意味着别把 key 写进前端代码——它相当于账号密码。
2.2 官方 VOP 的签名规则
VOP 的系统级参数一共 7 个,除 appSecret 外全部参与签名:
| 参数 | 说明 |
|---|---|
service |
服务名,如 vipapis.address.AddressService |
method |
方法名,如 getFullAddress |
version |
版本号,如 1.0.0 |
timestamp |
10 位秒级时间戳,与服务器偏差不能超过 10 分钟 |
format |
json / xml |
appKey |
应用标识 |
sign |
算出来的签名,32 位大写十六进制 |
算法三步,注意第二步的拼接方式:
- 把除
appSecret、sign外的系统参数按参数名 ASCII 升序排列; - 按「参数名 + 参数值」直接相连(不是
k=v&k=v),末尾再拼上业务参数的 JSON 串; - 以
appSecret为密钥做 hmac-md5,结果转大写。
官方文档给的样例,拼出来是这样一串(注意中间没有任何分隔符):
appKeyyourappKeyformatjsonmethodgetFullAddressservicevipapis.address.AddressServicetimestamp1406851200version1.0.0{"area_code":"0","is_show_gat":"SHOW_GAT","is_bind":false}
2.3 可运行实现(附官方样例自检)
import hashlib
import hmac
import json
import time
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def vop_sign(app_key, app_secret, service, method, version="1.0.0",
timestamp=None, fmt="json", biz=None):
"""返回 (sign, 待签名原文)。sign 为 32 位大写十六进制。"""
ts = str(timestamp if timestamp is not None else int(time.time()))
sys_params = {
"appKey": app_key, "format": fmt, "method": method,
"service": service, "timestamp": ts, "version": version}
# ① 按参数名排序,② 名字和值直接拼,无分隔符
raw = "".join(k + str(v) for k, v in sorted(sys_params.items()))
# ③ 业务参数用紧凑 JSON 追加在最后(不参与排序)
if biz:
raw += json.dumps(biz, separators=(",", ":"), ensure_ascii=False)
sign = hmac.new(app_secret.encode(), raw.encode(), hashlib.md5).hexdigest().upper()
return sign, raw
if __name__ == "__main__":
sign, raw = vop_sign(
"yourappKey", "yourappSecret",
"vipapis.address.AddressService", "getFullAddress", "1.0.0", 1406851200,
biz={
"area_code": "0", "is_show_gat": "SHOW_GAT", "is_bind": False},
)
print(raw)
print(sign)
跑出来的结果:
2880112276AB2FB2187DABA140B4DACC
与官方文档样例完全一致——建议你接 VOP 前先用这段自检,签名对了再谈业务,能省掉一半“401 无权限”的排查时间。
2.4 四个必踩的坑
| 坑 | 现象 | 正解 |
|---|---|---|
拼成 k=v&k=v |
签名校验失败 | 名字和值直接相连 |
| 把业务参数也排序了 | 校验失败 | 业务参数 JSON 整体拼在最后,内部顺序保持原样 |
JSON 带空格 / False |
校验失败 | separators=(",", ":"),布尔用 false |
| 16 位时间戳 / 小写结果 | 校验失败 | 10 位秒级;hexdigest().upper() |
此外,appSecret 只出现在签名计算里,永远不要放进请求参数。
三、商品详情:num_iid 是复合键
请求
GET https://api-gw.onebound.cn/vip/item_get/?key=&secret=&num_iid=1710613157-6918711233889249157
| 参数 | 必填 | 说明 |
|---|---|---|
num_iid |
是 | 店铺ID-商品ID,中间用 - 连接 |
cache |
否 | yes 默认走缓存;对账/比价传 no |
lang |
否 | cn / en / ru |
result_type |
否 | json / jsonu(中文不转义,调试好用) |
num_iid 怎么来
唯品会详情页地址是 https://detail.vip.com/detail-{brandId}-{productId}.html,detail- 后面整段就是 num_iid:
import re
def parse_num_iid(text: str) -> str:
m = re.search(r"detail-(\d+)-(\d+)", text) # 链接
if m:
return f"{m.group(1)}-{m.group(2)}"
m = re.search(r"(\d+)-(\d+)", text) # 已经是 num_iid
return f"{m.group(1)}-{m.group(2)}" if m else text.strip()
返回里值得重点取的字段
| 字段 | 含义 | 备注 |
|---|---|---|
title |
标题 | 常带「【2件起售】」这类前缀,入库前建议清洗 |
price / orginal_price |
特卖价 / 专柜价 | 注意官方字段名把 original 拼成了 orginal,别自己“纠正” |
num |
总库存 | 为 0 时回落到 SKU 库存求和 |
delist_time |
下架时间(毫秒时间戳) | 判断是否在售,比 status 靠谱 |
skus.sku[] |
规格列表 | 含 sku_id、properties_name、price、quantity |
props_list |
属性键值对 | {"134:0": "颜色:黑色9000"} |
item_imgs[].url / desc_img[] |
主图 / 详情长图 | 两处都要取,详情图不在 item_imgs 里 |
seller_info.shopname / shop_type |
店铺名 / 店铺类型 | 区分「唯品自营」和第三方 |
brand / brandId |
品牌名 / 品牌 ID | 做品牌维度聚合的天然分组键 |
一个很实用的细节:delist_time 是未来时间戳 = 还在卖,是过去时间 = 已下架。做价格监控时先过这一层,能砍掉大量无效详情请求。
四、分页:page 从 1 开始,但别信返回值
搜索请求
GET https://api-gw.onebound.cn/vip/item_search/?key=&secret=&q=运动鞋&page=1&page_size=120&sort=_sale
| 参数 | 说明 |
|---|---|
q |
关键词 |
cat |
类目 ID,做垂直品类采集时比关键词干净 |
start_price / end_price |
价格区间,深翻突破的关键 |
sort |
bid / _bid(总价)、bid2 / _bid2(商品价)、_sale(销量)、_credit(信用),_ 前缀表示从大到小 |
page |
页码,从 1 开始 |
page_size |
单页条数,上限 120 |
返回结构
{
"items": {
"page": 1,
"page_size": 120,
"total_results": "1761",
"real_total_results": "1761",
"item": [
{
"num_iid": "3693373-2170802633",
"title": "【2件起售】踩屎感凉拖鞋女夏季居家...",
"price": "22.9",
"promotion_price": "8.9",
"pic_url": "http://h2.appsimg.com/...jpg",
"detail_url": "https://detail.vip.com/detail-3693373-2170802633.html",
"sales": 0
}
]
}
}
4.1 三个终止条件,缺一不可
if not items: return # ① 空页:到头了(或翻过深翻上限)
if total and len(seen) >= total: return # ② 累计去重数已达 total
if len(items) < page_size: return # ③ 不满一页:最后一页
为什么不能信返回的 page:它是上游透传值,缺省时会返回 0(文档示例就是 "page": 0),你写 if resp.page >= pages: break 会第一页就退出。
total_results 和 real_total_results 的区别:前者是当前可翻页的结果数(可能被截断),后者是真实命中数。分页终止用 total_results,做数据量预估用 real_total_results。
4.2 深翻:唯品会的翻页有天花板
任何电商搜索都只放前 N 页(几十页量级),翻过头就是空页。想要更多数据,正确做法是把查询拆小,而不是硬翻:
# 按价格区间切片,每段 100 元,规避深翻上限
for lo in range(0, 1000, 100):
items = search(q="运动鞋", start_price=lo, end_price=lo + 100, page_size=120)
同理还可以叠加 cat(类目)、sort(换排序会拿到不同的结果集)。切片维度越多,重复率越高——这就必须靠正文第五节去重。
4.3 翻页为什么会重复
- 排序权重实时变化:
_sale按销量排,翻页那几秒销量变了,商品就跳到另一页; - 缓存不一致:不同页命中不同时间的快照;
- 同一
q下商品有多个活动链接。
对策:固定排序 + 记录已见 + 记录 total 快照。用变化小的排序(如 bid2 价格排序)比 _sale 稳定得多。
五、去重:三层,少一层数据就脏
5.1 第一层:商品级(num_iid)
最有效、也最容易被做错的一层。必须保留完整的复合键:
# ❌ 只取后半段,会把不同店铺的同名商品误合并
pid = num_iid.split("-")[-1]
# ✅ 原样保留
key = num_iid
跨页/跨关键词去重只需要一个集合:
seen = set()
for it in page_items:
if it["num_iid"] in seen:
continue
seen.add(it["num_iid"])
yield it
5.2 第二层:SKU 级(sku_id)
详情接口偶尔会返回重复的规格组合(同一 sku_id 出现两次),直接入库会触发唯一键冲突,或者让库存翻倍:
skus, seen_sku = [], set()
for s in raw_skus:
sid = str(s.get("sku_id") or "")
if not sid or sid in seen_sku:
continue
seen_sku.add(sid)
skus.append(s)
5.3 第三层:内容级(同款不同链接)
不同店铺卖同一个款、或者同一商品挂着不同活动链接,num_iid 完全不同,集合去重无能为力。用「品牌分桶 + 标题 n-gram 相似度」兜底:
import re
PROMO = re.compile(r"[【】\[\]()()]|包邮|正品|新款|特卖|专柜|限时|秒杀|清仓|直播|[0-9]+折")
def norm_title(t: str) -> str:
t = PROMO.sub("", t or "")
t = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", t)
return t.lower()
def ngrams(s: str, n: int = 3) -> set:
return {
s[i:i + n] for i in range(max(len(s) - n + 1, 1))}
def similar(a: str, b: str, threshold: float = 0.75) -> bool:
ga, gb = ngrams(norm_title(a)), ngrams(norm_title(b))
if not ga or not gb:
return False
return len(ga & gb) / len(ga | gb) >= threshold
用法:先按 brand + 类目 分桶,桶内两两比较(桶控制小了,复杂度就下来了)。数据量再大就换 SimHash 或 MinHash,但别只对标题做哈希——差一个字就漏,比不去重还糟。
5.4 落库必须幂等
去重的最后一道关在数据库。两个唯一键 + upsert,重复跑任务也不会产生脏数据:
CREATE TABLE IF NOT EXISTS vip_item (
num_iid TEXT PRIMARY KEY, -- 商品级去重
title TEXT,
price REAL,
original_price REAL,
stock INTEGER,
sales INTEGER,
brand TEXT,
shop_name TEXT,
updated_at INTEGER
);
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
CREATE TABLE IF NOT EXISTS vip_sku (
sku_id TEXT PRIMARY KEY, -- SKU 级去重
num_iid TEXT NOT NULL,
spec TEXT,
price REAL,
stock INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sku_item ON vip_sku(num_iid);
INSERT INTO vip_item (num_iid, title, price, stock, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(num_iid) DO UPDATE SET
title=excluded.title, price=excluded.price,
stock=excluded.stock, updated_at=excluded.updated_at;
配一句统计,你就知道去重到底有没有效果:
new, updated = store.new_items, store.seen_items
total = new + updated
print(f"新增 {new} 条,更新 {updated} 条,重复率 {updated / total:.1%}")
正常情况:首次全量新增率高(>90%),二次增量应该几乎全是更新。如果二次同步还大量“新增”,说明键选错了——大概率是把 num_iid 拆开用了。
六、完整源码与运行方式
文末的 vip_api.py 把上面三件事串成了一个可直接用的命令行工具,纯标准库、无第三方依赖:
export ONEBOUND_KEY=你的key
export ONEBOUND_SECRET=你的secret
# 1) 自检签名实现(输出与官方样例一致)
python vip_api.py sign
# 2) 搜索 + 分页 + 去重 + 落库 + 导出
python vip_api.py search "运动鞋" --pages 5 --sort _sale --min-price 100 --max-price 300 \
--db vip.db --csv items.csv
# 3) 查详情(含 SKU 去重)
python vip_api.py detail 1710613157-6918711233889249157 --db vip.db
search 命令的中间输出会把每一页的账算清楚:
[page 1] 返回 120 条,新 118 条,页内重复 2 条,累计 118/1761
[page 2] 返回 120 条,新 111 条,页内重复 9 条,累计 229/1761
...
共 1180 条(已按 num_iid 去重)
入库:新增 1103,更新 77;库内合计 1103 商品
(数字为示意。真实运行时,页内重复条数就是你判断“排序抖不抖”的温度计。)
脚本能力清单:
| 能力 | 实现 |
|---|---|
| 官方签名 | vop_sign(),附官方样例自检 sign 子命令 |
| 分页 | iter_search(),三重终止条件 + 固定间隔 --delay |
| 商品去重 | 内存 seen 集合(单次任务内) |
| SKU 去重 | 按 sku_id 过滤重复规格 |
| 持久去重 | SQLite 双唯一键 + ON CONFLICT DO UPDATE |
| 容错 | 只对 4001/4002/4017/4000/5000 重试,鉴权类直接退出 |
| 导出 | CSV(UTF-8 BOM,Excel 不乱码) |
| 数据新鲜度 | 默认 cache=no,对账/比价不会被旧缓存坑 |
七、上线前的八条清单
num_iid全程保留店铺ID-商品ID的复合格式,不要 split;- 签名先跑官方样例自检,再排查业务权限;
- 时间戳用 10 位秒级,机器上装 NTP,别让服务器慢 10 分钟;
page从 1 开始,终止条件用「空页 / 累计数 / 不满页」三条,不看返回的page;- 深翻超限就按价格区间、类目切片,别硬翻;
- 去重三层全上:
num_iid→sku_id→ 标题相似度; - 落库用唯一键 + upsert,任务可重复执行才是好任务;
- 监控两个指标:去重率和新增率,异常波动先查键,再查上游。
结语
签名的难点在“拼接没有分隔符”,分页的难点在“别信返回值、深翻要切片”,去重的难点在“商品 ID 是复合键、同款不同链接”。
这三件事做对之后,唯品会的数据管道就稳了。剩下的价格监控、竞品分析、选品打分,都只是在干净数据上加一层业务逻辑——而干净,永远是第一层。