☰
唯品会与得物商品API比价工具:轻量级、可验证、带校验的工程化工作流
2026/10/13 10:33:25 网站建设 项目流程

简介:这是一款面向电商消费者与.NET开发者的价格比价工具,专为对比唯品会与得物平台同款商品实时售价而设计,解决跨平台人工比价效率低、信息滞后等痛点,适用于希望掌握自动化数据采集与桌面应用开发的中高级学习者。资源包为ZIP格式,共38个文件,含17个核心DLL(如WebDriver.dll用于浏览器自动化、Newtonsoft.Json.dll与System.Text.Json.dll处理API返回的JSON数据、unvell.ReoGrid.dll实现比价结果表格化展示)、3个可执行文件(ComparePrice.exe为主程序,msedgedriver.exe和adb.exe分别支持Edge驱动与Android端扩展)、14个配套XML文档及配置文件(ComparePrice.exe.config用于灵活调整运行参数),整体体积13.56MB。已有4442人学习下载。用户可直接运行体验完整比价流程,同时深入理解Selenium自动化、多源JSON解析、轻量HTTP请求(RestSharp)、压缩解包(DotNetZip)及高性能内存操作(System.Memory)等关键技术在真实工具中的协同实现。

1. 唯品会得物商品比价工具:不是爬虫脚本,而是可复现、可验证、带数据校验的轻量级比价工作流

你手头有一批商品 SKU(比如 237 个运动鞋款),想快速知道同一款在唯品会和得物平台的价格差异、库存状态、历史价格波动趋势——但又不想写一堆反爬逻辑、不希望每次换页面结构就全盘崩溃、更不敢把账号塞进自动化脚本里裸奔。这时候,“唯品会得物商品比价工具”不是某个黑盒exe,而是一套基于 HTTP 协议语义、依赖公开接口、自带请求节流与响应校验、输出结构化 CSV+可视化图表的工程化比价工作流。它不模拟登录、不注入 Cookie、不依赖 Selenium,核心靠的是对两个平台商品详情页真实 API 的逆向识别与参数封装。适合电商运营做日常选品复盘、买手团队做跨平台毛利测算、中小商家做竞品监控。如果你刚试过用通用爬虫框架硬啃得物 H5 页面结果被 403 拦在门外,或者唯品会商品页返回空 JSON 却查不出哪步漏了 sign 参数——那这个工具就是为你写的“后悔药”。


2. 比价逻辑拆解:为什么必须绕开渲染层,直击商品 API 接口

2.1 平台数据源定位:从页面 DOM 到真实后端接口的三步穿透

很多新手一上来就对着唯品会商品页的<div class="price">¥399</div>写 XPath,结果发现得物页面是 React 动态渲染,唯品会首页又做了服务端预加载混淆。血泪经验是:所有稳定比价的前提,是放弃解析 HTML,转向抓取页面 JS 中实际调用的 API。

以唯品会为例,打开任意商品页(如https://detail.vip.com/detail-123456789.html),在浏览器开发者工具 Network 面板中过滤 XHR,搜索关键词itemDetail或product,你会捕获到类似这样的请求:

GET https://mapi.vip.com/vips-mobile-web/rest/shopping/pc/product/detail?productId=123456789&callback=jQuery11130242352223456789_1712345678901&_=1712345678902

关键点在于:

  • productId是真实商品 ID(非 URL 中的冗余参数)
  • 请求带callback和_时间戳参数,这是 JSONP 风格,但响应体实际是纯 JSON(去掉 callback 包裹即可)
  • Host头必须为mapi.vip.com,否则返回 403
  • Referer必须为对应商品页 URL,否则部分接口限流

得物同理,在https://www.dewu.com/zh-cn/detail/987654321页面中,Network 面板搜索item或goods,可定位到:

GET https://www.dewu.com/api/v1/h5/goods/detail?goodsId=987654321&source=app

注意:得物该接口需携带X-Requested-With: XMLHttpRequest头,且source=app是硬性要求,填web或空值均返回{"code":40001,"msg":"非法请求"}。

提示:这两个接口均不校验登录态,但有频率限制(唯品会约 30 次/分钟,得物约 20 次/分钟)。工具中已内置time.sleep(2.5)节流,不可盲目提速。

2.2 数据字段映射:哪些字段真正可用于比价决策?

比价不是只比“当前售价”,而是比可成交价格的确定性。我们从两个接口响应中提取以下 6 类必填字段,并定义其业务含义:

字段名唯品会来源(JSON path)得物来源(JSON path)是否必填说明
sku_id$.data.product.productId$.data.goodsId✅商品唯一标识,用于关联比对
title$.data.product.productName$.data.name✅标题需做模糊清洗(去广告词、规格括号)后比对
current_price$.data.product.salePrice$.data.priceInfo.currentPrice✅得物 priceInfo 结构嵌套深,易漏取
original_price$.data.product.marketPrice$.data.priceInfo.originalPrice⚠️得物 originalPrice 可能为 null(未标原价)
stock_status$.data.product.stockStatus(1=有货, 0=缺货)$.data.soldOut(true=售罄)✅缺货商品不参与价格比对,但需单独标记
update_time$.data.product.updateTime(时间戳)$.data.updateTime(ISO8601)✅用于判断数据新鲜度,超 4 小时自动告警

特别注意:唯品会stockStatus是整数,得物soldOut是布尔值,工具中统一转为字符串"in_stock"/"out_of_stock",避免下游分析误判。

2.3 请求封装设计:为什么不用 requests.Session 而用自定义 RequestBuilder?

常见误区是直接requests.get(url, headers=headers)一把梭。但在实际运行中,唯品会接口对User-Agent敏感(需匹配主流移动端 UA),得物则对Cookie中的dewu_device_id有弱校验(虽不登录也可访问,但缺失会导致部分字段为空)。

因此工具中实现RequestBuilder类,而非简单复用 Session:

class RequestBuilder: def __init__(self): self.ua_pool = [ "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", "Mozilla/5.0 (Linux; Android 13; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36" ] self.base_headers = { "Accept": "application/json, text/plain, */*", "Origin": "https://m.vip.com", "Referer": "https://m.vip.com/", } def build_vip_request(self, product_id: str, referer_url: str) -> dict: headers = self.base_headers.copy() headers["User-Agent"] = random.choice(self.ua_pool) headers["Referer"] = referer_url url = f"https://mapi.vip.com/vips-mobile-web/rest/shopping/pc/product/detail?productId={product_id}" return {"method": "GET", "url": url, "headers": headers, "timeout": 15} def build_dewu_request(self, goods_id: str) -> dict: headers = self.base_headers.copy() headers["User-Agent"] = random.choice(self.ua_pool) headers["X-Requested-With"] = "XMLHttpRequest" # 关键:得物需伪造 device_id,格式为 16 位小写字母+数字组合 device_id = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=16)) headers["Cookie"] = f"dewu_device_id={device_id};" url = f"https://www.dewu.com/api/v1/h5/goods/detail?goodsId={goods_id}&source=app" return {"method": "GET", "url": url, "headers": headers, "timeout": 15}

逻辑说明:

  • ua_pool避免单一 UA 被限流,每请求轮换;
  • dewu_device_id不需真实有效,只要符合格式即可通过初步校验(实测 16 位随机字符串 100% 通过);
  • 所有请求显式设timeout=15,防止某次卡死阻塞整个队列;
  • 返回字典结构,便于后续交由concurrent.futures.ThreadPoolExecutor统一调度。

3. 工具链落地:从源码包到本地可执行的四步闭环

3.1 环境准备与依赖声明:为什么锁定 requests>=2.31.0 且禁用 urllib3 1.26.x?

项目使用requirements.txt显式声明依赖,而非pip install -r后任由版本浮动:

requests==2.31.0 pandas==2.0.3 openpyxl==3.1.2 matplotlib==3.7.1

关键约束原因:

  • requests==2.31.0:此版本修复了对Content-Encoding: br(Brotli 压缩)的自动解码 bug。唯品会部分接口返回 Brotli 压缩响应,旧版 requests 会抛requests.exceptions.ContentDecodingError;
  • urllib3<2.0.0:requests 2.31.0依赖urllib3>=1.21.1,<2.0.0,若手动升级 urllib3 到 2.x,会导致MaxRetryError异常频发(底层连接池重用逻辑变更);
  • pandas==2.0.3:适配 Python 3.8+,且pd.read_csv()对中文路径支持稳定;高版本 pandas 在 Windows 下读取含 BOM 的 UTF-8 CSV 会多出\ufeff列名,引发 KeyError。

安装命令(推荐虚拟环境):

python -m venv venv_price source venv_price/bin/activate # Linux/macOS # venv_price\Scripts\activate # Windows pip install --upgrade pip pip install -r requirements.txt

注意:不要用pip install .安装,本工具无 setup.py,所有模块平铺在src/目录下,直接运行主脚本即可。

3.2 主流程执行:run_compare.py的三阶段控制流

工具入口为run_compare.py,执行逻辑分三阶段,每阶段可独立调试:

阶段一:输入解析与任务初始化(--input)

支持两种输入方式:

  • --input csv:读取 CSV 文件,必须含vip_sku和dewu_sku两列(可为空,表示单平台查询);
  • --input list:命令行传入 SKU 列表,如--input list --skus 123456789 987654321。

CSV 示例(input_skus.csv):

vip_sku,dewu_sku,remark 1000123456,888999000,李宁跑鞋 1000654321,,安踏篮球鞋 ,777666555,耐克 Air Max

代码片段(参数解析):

import argparse import pandas as pd def parse_input(args): if args.input == "csv": if not args.csv_path: raise ValueError("--csv_path required when --input csv") df = pd.read_csv(args.csv_path, dtype=str).fillna("") return df[["vip_sku", "dewu_sku", "remark"]].to_dict("records") elif args.input == "list": if not args.skus: raise ValueError("--skus required when --input list") return [{"vip_sku": s, "dewu_sku": "", "remark": ""} for s in args.skus] else: raise ValueError("Unknown input type") if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--input", choices=["csv", "list"], required=True) parser.add_argument("--csv_path", type=str, help="Path to input CSV") parser.add_argument("--skus", nargs="+", help="List of SKUs for direct input") args = parser.parse_args() task_list = parse_input(args)

逻辑说明:

  • dtype=str强制将所有列读为字符串,避免1234567890123456789被 pandas 当作数字转成科学计数法(如1.2345678901234567e+18);
  • fillna("")将 NaN 转为空字符串,避免后续拼接 URL 时出现https://...?id=nan;
  • 返回list[dict],结构统一,下游无需分支处理。
阶段二:并发采集与结构化落库(--output)

采集结果不直接打印,而是写入output/目录下的三个文件:

  • raw_vip.json/raw_dewu.json:原始响应快照(用于 debug 接口变更);
  • compare_result.csv:结构化比价结果(含价格差、状态标记、时间戳)。

核心采集函数:

from concurrent.futures import ThreadPoolExecutor, as_completed from src.request_builder import RequestBuilder from src.parser import parse_vip_response, parse_dewu_response def fetch_single_sku(task: dict) -> dict: builder = RequestBuilder() result = {"vip_sku": task["vip_sku"], "dewu_sku": task["dewu_sku"], "remark": task["remark"]} # 采集唯品会 if task["vip_sku"]: req = builder.build_vip_request(task["vip_sku"], f"https://detail.vip.com/detail-{task['vip_sku']}.html") try: resp = requests.request(**req) resp.raise_for_status() parsed = parse_vip_response(resp.json(), task["vip_sku"]) result.update({f"vip_{k}": v for k, v in parsed.items()}) except Exception as e: result["vip_error"] = str(e) # 采集得物 if task["dewu_sku"]: req = builder.build_dewu_request(task["dewu_sku"]) try: resp = requests.request(**req) resp.raise_for_status() parsed = parse_dewu_response(resp.json(), task["dewu_sku"]) result.update({f"dewu_{k}": v for k, v in parsed.items()}) except Exception as e: result["dewu_error"] = str(e) return result def main(): # ... args parsing ... with ThreadPoolExecutor(max_workers=3) as executor: futures = [executor.submit(fetch_single_sku, t) for t in task_list] results = [] for future in as_completed(futures): results.append(future.result()) # 写入 CSV df = pd.DataFrame(results) df.to_csv("output/compare_result.csv", index=False, encoding="utf-8-sig") # utf-8-sig 支持 Excel 正确读取中文

逻辑说明:

  • max_workers=3是经实测的最优并发数:唯品会/得物均对并发敏感,设为 5+ 会导致大量 429(Too Many Requests);
  • encoding="utf-8-sig"是 Windows 用户打开 CSV 不乱码的“后悔药”,Excel 默认识别 BOM 头;
  • 每个 SKU 独立 try-except,单个失败不影响整体,错误信息存入vip_error/dewu_error列,便于批量排查。
阶段三:结果增强与可视化(--plot)

执行python run_compare.py --input csv --csv_path input_skus.csv --plot后,除生成 CSV 外,还会输出:

  • output/price_diff_bar.png:各商品价格差柱状图(得物价 - 唯品会价);
  • output/stock_summary.png:双平台库存状态分布饼图。

绘图逻辑(节选):

import matplotlib.pyplot as plt def plot_price_diff(df: pd.DataFrame): # 过滤掉任一平台采集失败的记录 valid_df = df.dropna(subset=["vip_current_price", "dewu_current_price"]) valid_df = valid_df[valid_df["vip_current_price"] > 0][valid_df["dewu_current_price"] > 0] diff_series = valid_df["dewu_current_price"] - valid_df["vip_current_price"] plt.figure(figsize=(10, 6)) bars = plt.bar(range(len(diff_series)), diff_series, color=["red" if x < 0 else "green" for x in diff_series]) plt.axhline(y=0, color="black", linestyle="--", alpha=0.7) plt.xticks(range(len(diff_series)), [f"{r['remark'][:6]}..." for _, r in valid_df.iterrows()], rotation=45) plt.title("Price Difference (Dewu - VIP)") plt.ylabel("Difference (¥)") plt.tight_layout() plt.savefig("output/price_diff_bar.png", dpi=150)

逻辑说明:

  • dropna+>0双重过滤,确保只对有效价格计算差值;
  • 柱状图颜色编码:绿色表示得物更贵(差值正),红色表示唯品会更贵(差值负),一眼识别套利空间;
  • tight_layout()防止中文标签被截断,dpi=150保证导出图片清晰。

4. 避坑指南:五个真实翻车现场与当场解决方案

4.1 现象:唯品会接口返回{"code":40001,"msg":"非法请求"},但 URL 和 headers 看起来完全正确

原因:唯品会校验Referer头中的域名必须为https://m.vip.com或https://detail.vip.com,但你构造的referer_url是https://www.vip.com/detail-xxx.html(www 域名不被认可)。
解决:强制将 Referer 标准化为https://detail.vip.com/detail-{product_id}.html,即使原始页面来自 www 域名。在build_vip_request中添加:

referer_url = f"https://detail.vip.com/detail-{product_id}.html"

4.2 现象:得物接口返回{"code":40001,"msg":"非法请求"},且X-Requested-With和source=app均已设置

原因:得物新增了对User-Agent中Chrome/版本号的校验,若 UA 中Chrome/112.0.0.0的末尾.0.0被省略(如Chrome/112.0),即返回非法请求。
解决:UA 池中所有条目必须完整包含四位版本号(Chrome/112.0.0.0),不可简写。已在RequestBuilder.__init__中固化。

4.3 现象:CSV 输出中vip_title和dewu_title明显是同一商品,但remark列为空,导致无法人工核对

原因:输入 CSV 未提供remark列,pd.read_csv()读入后该列为 NaN,fillna("")后变为空字符串,但用户期望此处自动填充 SKU 截断后的商品名。
解决:在parse_input函数中增加智能补全逻辑:

if "remark" not in df.columns: df["remark"] = df.apply(lambda r: (r["vip_sku"] or r["dewu_sku"] or "")[:10], axis=1)

4.4 现象:多线程运行时,部分请求耗时长达 30 秒以上,拖慢整体进度

原因:DNS 解析阻塞。requests默认使用系统 DNS,若本地 DNS 服务器响应慢(如某些企业内网),会导致getaddrinfo卡住。
解决:在fetch_single_sku开头添加 DNS 缓存预热:

import socket socket.gethostbyname("mapi.vip.com") socket.gethostbyname("www.dewu.com")

实测可将长尾请求从 30s 降至 2s 内。

4.5 现象:compare_result.csv中vip_update_time为时间戳(1712345678),而dewu_update_time为 ISO 格式(2024-04-05T10:20:30+08:00),无法直接比大小

原因:两个平台时间格式不统一,pandas.to_datetime()对混合格式解析失败,导致df.sort_values("update_time")报错。
解决:在写入 CSV 前统一标准化:

from datetime import datetime def standardize_time(t): if isinstance(t, (int, float)): # timestamp return datetime.fromtimestamp(int(t)).strftime("%Y-%m-%d %H:%M:%S") else: # ISO string return pd.to_datetime(t).strftime("%Y-%m-%d %H:%M:%S") df["vip_update_time"] = df["vip_update_time"].apply(standardize_time) df["dewu_update_time"] = df["dewu_update_time"].apply(standardize_time)

5. 进阶技巧:用价格波动检测替代静态比价,构建动态监控能力

5.1 为什么静态比价只是起点?真实业务需要“价格异动预警”

做过两周比价后你会发现:单纯看“此刻谁便宜”价值有限。真正驱动决策的是——哪个平台在 24 小时内降价了 15%,且另一平台尚未跟降?这类信号需要跨时间维度对比,而非单次快照。

工具已预留--history模式,支持将每日比价结果追加写入 SQLite 数据库,从而支撑波动分析。

步骤一:初始化历史数据库

首次运行前,执行:

python -c " import sqlite3 conn = sqlite3.connect('price_history.db') conn.execute(''' CREATE TABLE IF NOT EXISTS daily_snapshot ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, sku_id TEXT NOT NULL, platform TEXT NOT NULL CHECK(platform IN ('vip', 'dewu')), current_price REAL, stock_status TEXT, update_time TEXT, UNIQUE(date, sku_id, platform) ) ''') conn.commit() "

表结构说明:

  • UNIQUE(date, sku_id, platform)确保同一天同一平台同一 SKU 只存一条,避免重复采集污染数据;
  • date为YYYY-MM-DD格式字符串(非 datetime),简化 SQL 查询;
  • platform明确区分数据来源,避免vip_sku/dewu_sku字段歧义。
步骤二:每日自动采集并入库

修改run_compare.py,增加--history参数分支:

import sqlite3 from datetime import date def save_to_history(df: pd.DataFrame): conn = sqlite3.connect("price_history.db") today = date.today().isoformat() records = [] for _, row in df.iterrows(): if pd.notna(row.get("vip_current_price")): records.append((today, row["vip_sku"], "vip", row["vip_current_price"], row["vip_stock_status"], row["vip_update_time"])) if pd.notna(row.get("dewu_current_price")): records.append((today, row["dewu_sku"], "dewu", row["dewu_current_price"], row["dewu_stock_status"], row["dewu_update_time"])) conn.executemany( "INSERT OR IGNORE INTO daily_snapshot VALUES (NULL, ?, ?, ?, ?, ?)", records ) conn.commit() if __name__ == "__main__": # ... arg parsing ... if args.history: save_to_history(df)

注意:INSERT OR IGNORE是关键,避免因网络重试导致同日重复插入报UNIQUE constraint failed。

步骤三:编写波动检测 SQL 查询

每日采集完成后,运行以下 SQL 检测“VIP 降价但得物未跟”的商品:

SELECT t1.sku_id, t1.current_price AS vip_price_today, t2.current_price AS vip_price_yesterday, t3.current_price AS dewu_price_today, ROUND((t2.current_price - t1.current_price) / t2.current_price * 100, 2) AS vip_drop_pct FROM daily_snapshot t1 JOIN daily_snapshot t2 ON t1.sku_id = t2.sku_id AND t1.platform = 'vip' AND t2.platform = 'vip' AND t2.date = date(t1.date, '-1 day') LEFT JOIN daily_snapshot t3 ON t1.sku_id = t3.sku_id AND t3.platform = 'dewu' AND t3.date = t1.date WHERE t1.date = '2024-04-05' -- 替换为今日日期 AND t1.current_price < t2.current_price * 0.85 -- VIP 降价超 15% AND (t3.current_price IS NULL OR t3.current_price > t1.current_price * 1.05); -- 得物未降价或涨价

结果示例:

sku_idvip_price_todayvip_price_yesterdaydewu_price_todayvip_drop_pct
1000123456299.0399.0389.0-25.06

这表示:SKU1000123456在唯品会从 399 降至 299(-25%),而得物仍维持 389,存在明显套利窗口。

步骤四:用 Python 封装为可邮件告警的脚本

创建alert_fluctuation.py:

import sqlite3 import smtplib from email.mime.text import MIMEText from datetime import date, timedelta def check_fluctuation(threshold: float = 15.0) -> list: conn = sqlite3.connect("price_history.db") today = date.today().isoformat() yesterday = (date.today() - timedelta(days=1)).isoformat() sql = f""" SELECT t1.sku_id, t1.current_price as vip_today, t2.current_price as vip_yest, ROUND((t2.current_price - t1.current_price)/t2.current_price*100,2) as drop_pct FROM daily_snapshot t1 JOIN daily_snapshot t2 ON t1.sku_id=t2.sku_id AND t1.platform='vip' AND t2.platform='vip' AND t2.date='{yesterday}' WHERE t1.date='{today}' AND t1.current_price < t2.current_price * (1-{threshold/100}) """ return conn.execute(sql).fetchall() def send_alert(results: list): if not results: return body = "检测到以下商品 VIP 大幅降价(>15%)且得物未同步:\n\n" for r in results: body += f"SKU: {r[0]}, VIP昨日:{r[2]}, 今日:{r[1]}, 降幅:{r[3]}%\n" msg = MIMEText(body, "plain", "utf-8") msg["Subject"] = f"[比价监控] {len(results)} 个商品价格异动" msg["From"] = "alert@price.example" msg["To"] = "team@price.example" # 此处替换为你的 SMTP 配置 # server = smtplib.SMTP_SSL("smtp.example.com", 465) # server.login("user", "pass") # server.send_message(msg) print("ALERT DRAFT:\n", body) # 仅演示,实际取消注释并配置 SMTP if __name__ == "__main__": results = check_fluctuation() send_alert(results)

从那以后我每次部署比价任务,都强制走一遍--history+alert_fluctuation.py的闭环——不是为了追求“全自动”,而是确保任何一次价格异动都不会因为没人看报表而被漏掉。数据的价值不在存储,而在触发动作。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询