一、接口基础说明
点击获取key和secret
唯品会关键词商品列表接口(搜索接口)
- 请求方式:通常 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货源比价、供应商监控、选品报表