一、接口基础说明
唯品会关键词商品列表接口(搜索接口)
- 请求方式:通常 POST/GET,关键词、页码、每页数量、排序、筛选参数
- 天然限制:
- 最大分页上限:很多唯品会搜索接口不能无限翻页,页码到一定值后直接返回空列表,不是报错,是无数据;
- 动态字段:促销、预售、组合商品、盲盒类商品返回字段不一样;部分字段时而返回时而缺失;
- 分页不稳定:同一 page 多次请求,商品条目数量可能不一致,存在商品重复、商品丢失;
- 限流风控:短时间连续翻页会返回 429、验证码、空 data,无明确错误码。
注意:接口权限、签名、cookie/access_token 鉴权不在本文方案内,本文聚焦分页容错逻辑 + 字段归一化。
二、分页容错方案(重点)
2.1 常规分页逻辑(不推荐直接使用)
page = 1
while True:
resp = request(keyword, page)
items = resp['data']['itemList']
if len(items)==0: break
save(items)
page +=1
问题:
- 遇到临时返回空数组直接终止,误判为采集完成;
- 没有去重,同一商品跨页重复入库;
- 不处理限流、超时、接口抖动;
- 到达平台最大页码阈值,直接退出无法区分 "真无数据" 和 "接口截断"。
2.2 增强分页容错设计
1)三重重试策略(分级重试)
表格
| 异常类型 | 重试次数 | 间隔 | 策略 |
|---|---|---|---|
| 网络超时、连接失败 | 3 次 | 1s,2s,4s | 指数退避 |
| 200 但 data 为空 / 无 itemList | 2 次 | 3s | 重试后仍空,标记为疑似结束,不直接退出 |
| 429 限流 | 3 次 | 5s,10s,20s | 长间隔,达到上限暂停当前关键词任务 |
规则:重试只针对临时异常;业务空数据(商品真的没有)不重试。
2)分页结束判定(核心改进)
不再依靠 itemList.length == 0 作为唯一终止条件,采用双条件判定:
- 连续 N 页(推荐 2 页)返回商品数量 = 0 → 判定搜索结果结束;
- 或者 page 到达预设最大页码阈值(如 max_page=50,根据唯品会搜索限制配置)强制停止,防止死循环。
原因:唯品会接口偶尔单页抖动返回空,下一页又恢复数据;单页空不能代表全部商品抓取完毕。
3)商品去重,解决跨页重复
- 唯一主键:
itemId(唯品会商品 ID)作为唯一标识; - 本地内存 /redis 临时集合,保存本轮关键词已采集 itemId;
- 新拉取到商品:如果 itemId 已存在,跳过入库,日志标记【重复商品】;
- 任务结束清空该关键词的去重集合。
4)页码跳变保护
增加校验:返回列表里的商品数量,远小于 pageSize 时,记录告警; 例如每页请求 30 条,返回 < 5 条,记录日志warning: page{page} item count low,便于后续排查是否触发平台结果截断。
5)任务断点续采
持久化当前任务状态:keyword、current_page、已采集itemId集合、状态,存入数据库 /redis; 任务中断(程序崩溃、网络断),重启时读取状态,从 current_page 继续采集,不用从头跑。
2.3 分页伪代码示例(PHP)
$keyword = "xxx";
$page = $task['current_page'];
$maxPage = 50;
$emptyPageCount = 0;
$pageSize = 30;
$redisKey = "vip:search:dedup:".$keyword;
while($page <= $maxPage){
$ret = vipApiRequest($keyword, $page, $pageSize);
if($ret['code'] !== 0){
// 异常重试逻辑
$retryOk = retryApi(function() use ($keyword,$page,$pageSize){
return vipApiRequest($keyword, $page, $pageSize);
},3);
if(!$retryOk){
log_error("page {$page} 请求失败");
break;
}
$ret = $retryOk;
}
$itemList = $ret['data']['itemList'] ?? [];
if(empty($itemList)){
$emptyPageCount++;
if($emptyPageCount >=2){
log_info("连续2页空,搜索任务结束");
break;
}
$page++;
continue;
}
$emptyPageCount = 0;
foreach($itemList as $rawItem){
$itemId = $rawItem['itemId'];
if(redis()->sIsMember($redisKey,$itemId)){
log_info("重复商品 {$itemId},跳过");
continue;
}
redis()->sAdd($redisKey,$itemId);
// 字段归一化处理
$stdItem = normalizeItem($rawItem);
saveToDB($stdItem);
}
$task['current_page'] = $page + 1;
saveTaskStatus($task);
$page++;
usleep(800*1000); // 节流,控制请求频率
}
redis()->del($redisKey);
三、字段归一化方案
3.1 痛点
- 字段名不统一:有的叫
salePrice,有的promotionPrice;原价字段有的marketPrice、origPrice; - 部分商品无 SKU 数组,直接单商品价格;多规格商品 SKU 列表嵌套层级不一致;
- 图片字段:有的返回主图 url,有的返回图片数组,缩略图 / 原图混合;
- 空值:不存在字段返回
null/""/ 直接不返回 key; - 促销标签、库存字段:普通商品、预售、清仓结构差异大。
3.2 归一化设计思路
输入:接口原始异构 JSON 对象 rawItem 输出:统一结构体 stdItem,上层业务固定字段读取,不需要判断原始字段 策略:多层级取值 + 默认兜底值 + 类型强制转换 + SKU 归一化
3.3 标准输出结构体设计
{
"item_id": "",
"title": "",
"sub_title": "",
"brand_name": "",
"price": 0,
"original_price": 0,
"discount": "",
"stock_status": "",
"sales": 0,
"main_image": "",
"image_list": [],
"category_name": "",
"sku_list": [
{
"sku_id": "",
"spec_name": "",
"price": 0,
"stock_num": 0
}
],
"promotion_tags": [],
"source_raw": {},
"crawl_time": ""
}
3.4 字段映射 & 安全取值函数
核心:封装安全取值函数 getSafeVal($arr, $path, $default),支持多层 key 读取,key 不存在返回默认值,避免Undefined index报错。
示例映射规则:
表格
| 标准字段 | 原始候选字段顺序 | 兜底默认值 | 处理逻辑 |
|---|---|---|---|
| item_id | itemId | "" | 字符串 |
| title | title | "" | 去除首尾空格 |
| price | salePrice / promotionPrice / currentPrice | 0 | float,空转为 0 |
| original_price | marketPrice / origPrice | 0 | float |
| main_image | imgUrl / mainPic | "" | 优先取第一张图 |
| image_list | picList / imageArray | \[\] | 数组,过滤空 url |
| sales | saleCount / salesVolume | 0 | 转为数字 |
SKU 归一化(难点)
两种情况:
- 商品返回
skuInfo数组:遍历每个 SKU 映射到标准 sku 结构; - 商品无 SKU 列表(单品):自动构造一个虚拟 SKU,spec_name="默认规格",sku_id=itemId,价格取商品主价。
这样上层业务统一读取 sku_list,不用区分单品 / 多规格商品。
3.5 PHP 归一化函数示例
function normalizeItem($raw){
$std = [];
$std['item_id'] = getSafeVal($raw,'itemId','');
$std['title'] = trim(getSafeVal($raw,'title',''));
$std['brand_name'] = getSafeVal($raw,'brandName','');
$std['price'] = floatval(getSafeVal($raw,'salePrice',0));
$std['original_price'] = floatval(getSafeVal($raw,'marketPrice',0));
$std['main_image'] = getSafeVal($raw,'imgUrl','');
$std['image_list'] = getSafeVal($raw,'picList',[]);
$std['sales'] = intval(getSafeVal($raw,'saleCount',0));
$std['promotion_tags'] = getSafeVal($raw,'tagList',[]);
$std['crawl_time'] = date('Y-m-d H:i:s');
// SKU归一化
$rawSkuList = getSafeVal($raw,'skuInfo',[]);
$std['sku_list'] = [];
if(!empty($rawSkuList)){
foreach($rawSkuList as $sku){
$std['sku_list'][] = [
'sku_id' => getSafeVal($sku,'skuId',''),
'spec_name' => getSafeVal($sku,'spec',''),
'price' => floatval(getSafeVal($sku,'skuPrice',0)),
'stock_num' => intval(getSafeVal($sku,'stock',0)),
];
}
}else{
// 单品构造虚拟SKU
$std['sku_list'][] = [
'sku_id' => $std['item_id'],
'spec_name' => '默认规格',
'price' => $std['price'],
'stock_num' => 0,
];
}
$std['source_raw'] = $raw; // 保留原始数据用于排查
return $std;
}
// 安全多层取值
function getSafeVal($data, $key, $default=null){
if(!is_array($data)) return $default;
return isset($data[$key]) ? $data[$key] : $default;
}
四、监控与告警(保障长期稳定)
- 分页指标监控:每页返回商品数量、空页次数、重试次数、重复商品数量;写入监控表;
- 字段缺失告警:统计商品缺失
itemId、价格、主图的比例,超过阈值触发告警; - 限流监控:429 计数,关键词任务自动降速;
- 数据校验:入库后校验 price>original_price 这类异常数据,标记脏数据。
五、风险与优化方向
- 唯品会接口会不定期调整返回 JSON 结构,source_raw 原始字段持久化非常重要,方便更新映射规则;
- 不要高频并发拉取,单关键词串行分页,多关键词任务做并发池控制;
- 价格字段区分:券前价、券后价、活动价,如果业务需要,归一化结构体可扩展字段存储多种价格;
- 增量更新:关键词搜索是全量快照,如果需要实时监控,可基于 itemId 做增量对比,只更新变化商品。
六、整体架构简图
【任务调度】
↓
【请求层:签名、http客户端、分级重试、节流】
↓
【分页容错层:空页计数、去重、断点续采、结束判定】
↓
【字段归一化层:安全取值、SKU统一、脏数据过滤】
↓
【存储层:标准商品表 + 原始JSON备份表】
↓
【上层业务:比价、选品、报表】
如果你需要,我可以继续输出:
- 可直接复制的MySQL 建表语句(标准商品表 + 任务表);
- Java 版本实现分页 + 归一化代码;
- 改成适合文档发布的 SEO 优化完整版技术文章。
今天 09:22
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
搜好货平台关键词商品搜索接口:分页容错与商品数据轻量化提取方案
业务场景:B2B 工业货源关键词检索、供应商监控、批发比价、货源选品;搜好货属于 B2B 工业电商,和唯品会零售接口差异很大:多规格报价区间、起订量、厂家信息、地区字段、接口返回大 JSON 冗余字段极多 。 核心目标:稳定分页采集,只提取业务需要字段,裁剪冗余大块数据,降低内存、存储、带宽开销;同时解决 B2B 接口分页抖动、总数不准、跨页重复、报价字段异构问题。
一、搜好货关键词搜索接口基础特性
搜好货(912688)商品搜索接口,B2B 货源搜索,GET/POST,带签名 + token 鉴权,入参:关键词、page、pageSize、筛选(地区、起订量、类目)、排序。
平台接口独有坑点
- total 总数不准:接口返回 total 仅为估算值,翻到后面会出现 total 大于实际可返回商品,不能用 total 循环计算最大页码;
- B2B 报价不是单一价格:大多是区间价(1-10 件 XX 元,10-100 件 XX 元),单品固定价、阶梯报价两种结构混杂;
- 商品字段冗余巨大:返回完整详情 HTML、长富文本介绍、多个附件、企业资质、历史询盘等大体积字段,轻量化必须直接丢弃,不解析;
- 分页不稳定:工业词搜索结果缓存强,同一 page 多次请求条目不一致;末尾页码容易返回空数组,属于缓存截断,不是真正无商品;
- 风控特点:相比 C 端平台,慢,超时多,401/403 token 失效频繁,不是简单 429;token 过期属于业务异常,不是重试能解决;
- 商品唯一 ID:
goodsId,供应商 ID:companyId,一个厂家多条同款货源(跨页重复高发)。
说明:签名、token 获取、鉴权逻辑不在本文,聚焦分页容错 + 轻量化提取。
二、分页容错方案(适配 B2B 搜好货搜索接口)
2.1 简单分页(不推荐)
page=1
while page*pageSize <= total:
resp = request(keyword,page)
save(resp['data']['list'])
page +=1
致命问题:total 虚高,会无限请求无效页码;B2B 缓存导致单页空,误判任务结束;同款货源跨页重复入库;token 失效没有中断保护。
2.2 增强分页容错设计
1)分级重试策略
表格
| 异常类型 | 重试次数 | 退避间隔 | 处理规则 |
|---|---|---|---|
| 网络超时、连接重置 | 3 次 | 1s,2s,4s | 指数退避重试 |
| HTTP 200,但 list 为空 | 2 次 | 3s | 重试后仍然空,空页计数 + 1,不直接退出 |
| 401/403 token 失效 | 0 次 | - | 不重试,标记任务暂停,触发 token 刷新告警 |
| 5xx 服务端错误 | 2 次 | 5s | 重试 |
规则:token 过期属于身份失效,重试无效,直接暂停任务,避免锁账号。
2)分页结束判定(重点,B2B 接口核心)
放弃依靠 total 计算循环上限,采用双条件判定
- 连续 2 页返回商品列表为空,判定关键词搜索任务正常结束;
- 配置硬上限 max_page(建议 30~50,搜好货后端搜索结果深度有限),page 超过 max_page 强制终止,防止死循环。
原因:搜好货搜索后端对工业词做结果截断,即便 total 显示几千条,实际最多只能拉几十页。
3)商品去重(B2B 特有:同厂家同款多条货源)
- 主键:
goodsId作为商品唯一 key; - Redis 临时集合:
search:sh:dedup:{keyword},保存本轮关键词已采集 goodsId; - 规则:新拉取商品,goodsId 已存在则跳过入库,日志标记【重复货源】;
业务扩展:如果需要同款合并比价 ,可以增加
companyId+产品标题md5做同款分组。
4)断点续采
任务持久化字段存入 Redis/MySQL:keyword、current_page、max_page、已采集goodsId集合、任务状态、token版本。 程序崩溃、网络中断、token 失效暂停后,重启从 current_page 继续采集,不需要从头抓取。
5)伪代码 PHP 分页主逻辑
$keyword = "工业不锈钢阀门";
$page = $task['current_page'];
$maxPage = 40;
$emptyPageCount = 0;
$pageSize = 20;
$redisDedupKey = "search:sh:dedup:".md5($keyword);
while($page <= $maxPage){
$apiResult = shApiRequest($keyword,$page,$pageSize,$token);
// 鉴权失效
if($apiResult['code'] == 401){
log_error("token失效,任务暂停 keyword:{$keyword}");
$task['status'] = 'pause';
saveTask($task);
break;
}
$rawList = $apiResult['data']['list'] ?? [];
if(empty($rawList)){
$emptyPageCount++;
if($emptyPageCount >=2){
log_info("连续2页空,关键词任务结束");
break;
}
$page++;
continue;
}
$emptyPageCount = 0;
foreach($rawList as $rawGoods){
$goodsId = $rawGoods['goodsId'];
if(redis()->sIsMember($redisDedupKey,$goodsId)){
log_info("重复商品 {$goodsId},跳过");
continue;
}
redis()->sAdd($redisDedupKey,$goodsId);
// ===== 轻量化提取核心函数 =====
$lightItem = extractLightGoods($rawGoods);
saveToDB($lightItem);
}
// 更新任务状态
$task['current_page'] = $page + 1;
saveTask($task);
$page++;
usleep(1200*1000); // B2B接口放慢请求速率,降低风控
}
redis()->del($redisDedupKey);
三、商品数据轻量化提取方案(本篇重点)
思路:不完整解析原始大 JSON,按需提取,直接丢弃大体积冗余字段。B2B 搜好货原始返回包含大量大字段:详情富文本、图片数组几十张、企业资质、视频、询盘记录、参数详情块,全部跳过不读取,减少内存占用。
3.1 轻量化提取原则
- 只提取业务必要字段,原始大字段直接丢弃,不做解析;
- 安全取值,key 不存在返回默认值,禁止直接访问数组下标;
- B2B 阶梯报价归一化:支持单一口价 / 多档阶梯报价两种结构统一输出;
- 供应商信息轻量化:只保留 companyId、厂家名称、地区,不读企业详情长文本;
- 可选:原始 JSON 按需异步落盘备份(可选,不阻塞主采集链路)。
3.2 轻量化输出结构体(精简版)
{
"goods_id": "",
"title": "",
"brand": "",
"company_id": "",
"company_name": "",
"province": "",
"city": "",
"price_type": "", // fixed固定价 / ladder阶梯价
"price": 0, // 最低报价
"price_range": [], // 阶梯报价数组 [{min_num:1,price:50}]
"min_order": 0, // 最小起订量
"main_image": "",
"category_name": "",
"sales": 0,
"stock_status": "",
"crawl_time": ""
}
3.3 字段映射与轻量化规则
表格
| 标准字段 | 原始候选字段 | 默认值 | 处理说明 |
|---|---|---|---|
| goods_id | goodsId | "" | 商品唯一 ID |
| title | title | "" | trim 去除空格,清理换行 |
| company_id | companyId | "" | 供应商 ID |
| company_name | companyName | "" | 厂家名称 |
| province | province | "" | 省份 |
| city | city | "" | 城市 |
| min_order | minOrder | 0 | 最小起订量 B2B 核心字段 |
| main_image | coverImg | "" | 只取封面图,丢弃全部图片数组 |
| sales | saleNum | 0 | 成交量 |
轻量化核心优化:放弃 imageList、detailHtml、attributeHtml、video、certificate 资质等超大字段,不去读取,减少 JSON 解析开销。
B2B 阶梯报价提取逻辑(搜好货特有)
两种结构:
price单一固定价:price_type="fixed",price_range 空数组;ladderPrice数组,多档起订报价:price_type="ladder",price 取数组内最低价,price_range 存储阶梯列表。
3.4 PHP 轻量化提取函数 extractLightGoods
/**
* 搜好货商品轻量化提取,丢弃冗余大字段
*/
function extractLightGoods($raw){
$std = [];
$std['goods_id'] = getSafe($raw,'goodsId','');
$std['title'] = trim(getSafe($raw,'title',''));
$std['company_id'] = getSafe($raw,'companyId','');
$std['company_name'] = getSafe($raw,'companyName','');
$std['province'] = getSafe($raw,'province','');
$std['city'] = getSafe($raw,'city','');
$std['min_order'] = intval(getSafe($raw,'minOrder',0));
$std['main_image'] = getSafe($raw,'coverImg','');
$std['category_name'] = getSafe($raw,'categoryName','');
$std['sales'] = intval(getSafe($raw,'saleNum',0));
$std['crawl_time'] = date('Y-m-d H:i:s');
// B2B阶梯报价处理
$ladderList = getSafe($raw,'ladderPrice',[]);
if(!empty($ladderList)){
$std['price_type'] = 'ladder';
$std['price_range'] = [];
$minPrice = 9999999;
foreach($ladderList as $lad){
$num = intval(getSafe($lad,'minNum',0));
$p = floatval(getSafe($lad,'price',0));
$std['price_range'][] = ["min_num"=>$num,"price"=>$p];
if($p < $minPrice) $minPrice = $p;
}
$std['price'] = $minPrice;
}else{
$std['price_type'] = 'fixed';
$std['price'] = floatval(getSafe($raw,'price',0));
$std['price_range'] = [];
}
return $std;
}
// 安全取值,不存在返回默认值
function getSafe($arr,$key,$default=''){
if(!is_array($arr)) return $default;
return isset($arr[$key]) ? $arr[$key] : $default;
}
四、性能优化(轻量化带来的收益)
- JSON 解析开销下降:跳过详情 HTML、多张图片、资质文本,单商品 JSON 体积从几 KB~ 十几 KB 压缩到几百字节;
- 数据库存储压力:入库表字段精简,不存大文本,索引只建 goods_id、company_id、keyword;
- 内存占用:批量循环时,解析完提取字段后,原始大对象及时释放,避免内存持续上涨;
- 带宽减少:如果可以,请求层可和接口协商,尽量不返回详情富文本字段(部分接口支持参数控制返回字段)。
五、监控、脏数据校验与告警
- 分页指标监控:空页计数、重试次数、goodsId 重复率、token 失效次数;
- B2B 业务脏数据校验:
- min_order <0 标记脏数据;
- 阶梯报价价格异常(价格 <=0);
- 标题为空、goodsId 为空;
- 告警:token 失效、连续 3 次接口 5xx、脏数据比例超过阈值;
- 日志分级:info 正常采集,warning 低返回条目,error 鉴权 / 接口异常。
六、风险与扩展优化
- 搜好货接口结构变更:轻量化只取少量核心字段,相比全量解析,接口改动影响面更小;建议异步单独备份原始 JSON(非主链路),方便字段映射修复;
- 并发策略:单个关键词串行分页,多关键词使用协程 / 线程池控制并发数,B2B 平台对并发敏感;
- 增量监控:关键词搜索是快照采集;如需监控价格变动,以 goodsId 为 key,对比新旧 price/min_order;
- 同款合并:业务如需供应商同款比价,在轻量化结构体基础上,增加标题 md5+companyId 做同款分组。
七、整体架构
任务调度层(关键词任务队列、状态持久化)
↓
HTTP请求层(签名、token、分级重试、节流、鉴权判断)
↓
分页容错层(空页计数、goodsId去重、断点续采、结束判定)
↓
轻量化提取层(只读取必要字段,丢弃富文本/大图等冗余数据,B2B阶梯报价归一)
↓
存储层:轻量化商品表(主业务)|原始JSON备份表(异步可选)
↓
上层业务:B2B货源比价、供应商监控、选品报表