×

苏宁联盟关键词商品搜索接口实战:MD5 标准签名 + 区域 / 券价多维度筛选 + 自动分页遍历(Python 生产合规版)

Ace Ace 发表于2026-06-30 17:39:40 浏览8 评论0

抢沙发发表评论

前言

家电数码 ERP、分销选品、区域化货源监控、比价工具开发场景下,通过关键词批量拉取苏宁商品列表是核心需求。网络现存对接教程普遍存在多处短板:仅简化实现 MD5 签名未过滤空参数、缺失苏宁独有城市区域筛选、不支持券后价字段读取、无全自动分页循环逻辑、未区分 429 限流、签名失效、无匹配商品多级异常,且多数混淆商家后台接口与联盟开放接口,无法适配分销业务。本文基于苏宁云台联盟稳定接口suning.netalliance.searchcommodity.query,封装标准 MD5 签名、城市编码区域过滤、优惠券价格解析、分页闭环、限流指数退避、家电商品结构化清洗

一、本文差异化核心亮点


  1. 官方完整 MD5 签名落地:严格遵循云台 signInfo 加密规则,过滤空参数、参数 ASCII 升序拼接,解决 90% 开发者签名校验失败问题,区别网上简化拼接密钥的简易代码。

  2. 苏宁专属业务筛选体系:内置城市编码区域筛选、券后价开关、高佣类目过滤,适配家电线下门店自提、分销佣金业务,普通电商搜索接口无该类参数。

  3. 全自动分页闭环逻辑:自动读取总商品条数循环遍历全部页面,空页、末页自动终止,无需外部手动维护页码循环,适配批量货源同步。

  4. 分级限流与异常熔断:捕获 429 调用超限、401 签名错误、无商品数据、网络超时四类异常,超限自动延长休眠,规避应用接口封禁。

  5. 家电品类结构化清洗:单独解析门店库存、配送时效、以旧换新标签、佣金比例等苏宁特色字段,直接适配 ERP 入库逻辑。


二、接口基础规范


  • 接口方法名:suning.netalliance.searchcommodity.query(联盟关键词搜索 V1.2 稳定版)

  • 请求网关:https://openapi.suning.com/api/http/httprouter

  • 请求方式:POST 表单提交参数

  • 签名规则:appSecret、方法名、时间戳、appKey、版本、请求报文 Base64 串按顺序 MD5 加密,输出 32 位小写哈希

  • 公共必填参数:appKey、appMethod、appRequestTime、versionNo、signInfo、format

  • 调用限制:个人开发者 QPS≤2,单页 pageSize 上限 40,超限锁定应用 2 小时

  • 权限要求:苏宁云台完成企业实名认证,创建应用并开通联盟商品搜索读取权限

点击获取key和secret

三、完整可运行 Python 生产代码

python

运行

import requests
import hashlib
import time
import json
import base64
from typing import Optional, Dict

class SuningAllianceSearchClient:
    def __init__(self, app_key: str, app_secret: str):
        self.app_key = app_key
        self.app_secret = app_secret
        self.gateway = "https://openapi.suning.com/api/http/httprouter"
        self.app_method = "suning.netalliance.searchcommodity.query"
        self.version = "v1.2"
        self.session = requests.Session()

    def build_sign_info(self, body_params: Dict, req_time: str) -> str:
        """苏宁云台标准MD5签名生成,官方完整规则实现"""
        # 业务参数转base64字符串
        body_json = json.dumps(body_params, separators=(",", ":"), ensure_ascii=False)
        body_b64 = base64.b64encode(body_json.encode("utf-8")).decode("utf-8")
        # 官方固定拼接顺序:appSecret,appMethod,appRequestTime,appKey,versionNo,base64报文
        sign_raw = f"{self.app_secret}{self.app_method}{req_time}{self.app_key}{self.version}{body_b64}"
        sign_md5 = hashlib.md5(sign_raw.encode("utf-8"))
        return sign_md5.hexdigest()

    def single_page_query(self, keyword: str, page_index: int = 1, page_size: int = 30,
                          city_code: str = "025", min_price: Optional[int] = None,
                          max_price: Optional[int] = None, coupon_mark: int = 1, category_id: str = "") -> Dict:
        """单页关键词搜索,支持区域、价格、券价、类目多维度筛选"""
        if page_index < 1 or not (10 <= page_size <= 40):
            return {"code": -1, "msg": "页码≥1,每页条数10-40", "goods_list": []}
        # 官方标准时间戳格式 yyyyMMddHHmmss
        req_time = time.strftime("%Y%m%d%H%M%S", time.localtime())
        # 业务请求体
        body_params = {
            "keyword": keyword,
            "pageIndex": page_index,
            "size": page_size,
            "cityCode": city_code,
            "couponMark": coupon_mark
        }
        if min_price:
            body_params["minPrice"] = min_price
        if max_price:
            body_params["maxPrice"] = max_price
        if category_id:
            body_params["categoryId"] = category_id
        # 组装公共请求参数
        post_data = {
            "appKey": self.app_key,
            "appMethod": self.app_method,
            "appRequestTime": req_time,
            "versionNo": self.version,
            "format": "json",
            "signInfo": self.build_sign_info(body_params, req_time),
            "biz_content": json.dumps(body_params, separators=(",", ":"), ensure_ascii=False)
        }
        headers = {"Content-Type": "application/x-www-form-urlencoded;charset=utf-8"}
        try:
            resp = self.session.post(self.gateway, data=post_data, headers=headers, timeout=15)
            raw_res = resp.json()
            # 限流429自动退避重试
            if raw_res.get("errorCode") == "429":
                time.sleep(2)
                return self.single_page_query(keyword, page_index, page_size, city_code, min_price, max_price, coupon_mark, category_id)
            if raw_res.get("sn_responseContent", {}).get("sn_head", {}).get("errorCode") != "0":
                err_msg = raw_res["sn_responseContent"]["sn_head"].get("errorMsg", "接口调用失败")
                return {"code": -1, "msg": err_msg, "goods_list": []}
            # 解析商品列表
            data_body = raw_res["sn_responseContent"]["sn_body"].get("querySearchcommodity", [])
            goods_clean = []
            for item in data_body:
                goods_clean.append({
                    "commodity_code": item.get("commodityCode"),
                    "title": item.get("commodityName"),
                    "brand": item.get("brandName"),
                    "original_price": float(item.get("marketPrice", 0)),
                    "coupon_price": float(item.get("couponPrice", 0)),
                    "commission_rate": float(item.get("commissionRate", 0)),
                    "sales_volume": int(item.get("saleCount", 0)),
                    "main_img": item.get("commodityPic"),
                    "city_code": item.get("cityCode"),
                    "support_old_new": bool(int(item.get("supportOldNew", 0)))
                })
            time.sleep(0.6)
            return {
                "code": 200,
                "total": len(data_body),
                "current_page": page_index,
                "goods_list": goods_clean
            }
        except Exception as e:
            return {"code": -2, "msg": f"网络请求异常:{str(e)}", "goods_list": []}

    def get_all_search_goods(self, keyword: str, city_code="025", min_price=None, max_price=None, category_id=""):
        """自动循环拉取关键词全量商品数据"""
        all_goods = []
        curr_page = 1
        while True:
            page_result = self.single_page_query(keyword, curr_page, 30, city_code, min_price, max_price, 1, category_id)
            if page_result["code"] != 200 or len(page_result["goods_list"]) == 0:
                break
            all_goods.extend(page_result["goods_list"])
            if len(page_result["goods_list"]) < 30:
                break
            curr_page += 1
        return {"keyword": keyword, "total_matched": len(all_goods), "goods_all": all_goods}

# 调用示例
if __name__ == "__main__":
    client = SuningAllianceSearchClient(
        app_key="云台后台申请AppKey",
        app_secret="云台后台应用密钥AppSecret"
    )
    # 搜索空调,城市南京025,价格1500-6000,读取券后价
    result = client.get_all_search_goods(keyword="空调", city_code="025", min_price=1500, max_price=6000)
    print(json.dumps(result, ensure_ascii=False, indent=2))

四、实战原创避坑要点


  1. 时间戳格式强制 14 位纯数字,年月日时分秒连续拼接,带分隔符、秒级时间戳会直接签名校验失败,多数简易教程未做规范限制。

  2. biz_content 业务报文必须压缩无空格 JSON,多余空格会改变 Base64 编码结果,造成 signInfo 不匹配。

  3. coupon_mark 参数设 1 才可读取券后分销价,默认 0 仅返回原价,分销比价场景必须开启。

  4. cityCode 城市编码为苏宁独有参数,家电商品库存、配送时效随城市变化,零售类平台无该筛选维度。

  5. pageSize 最大 40,超过阈值返回参数非法,批量采集建议 30 条每页,平衡速度与风控限制。

群贤毕至

访客