电商比价工具实战:到手价计算与商品匹配的核心逻辑
发布时间:2026/10/2 7:37:09来源:尧图网络
简介面向有跨平台购物比价需求的消费者这款唯品会得物商品比价工具借助自动化技术实时抓取两个电商平台的商品价格并进行对比帮助用户更快做出明智的购买决策。压缩包内共38个文件以exe主程序、dll功能库、xml配置与数据文件为主另含说明文档与pdb调试信息整体体积约13.56MB目录结构便于按模块查阅。已有4438人浏览学习。工具完整展示了自动化比价的实现路径通过浏览器驱动模拟登录与搜索操作抓取商品页面或调用接口获取数据再利用JSON解析和内存高效处理完成信息清洗最终由表格控件直观呈现各平台价格对比。这套工程覆盖了爬虫、Selenium自动化、.NET数据操作及桌面界面开发等关键环节对于想研究电商数据采集与对比工具实现的开发者是一份可上手的实战案例。1. 顺手写了个唯品会得物比价工具先把“到手价”这三个字搞明白唯品会做品牌特卖、得物做潮流球鞋和鉴定交易两边同一个商品标价经常差几百块。但直接比标价没有意义——唯品会有满减券和会员折扣得物在卖家价之外还要收技术服务费和鉴别费真正该比的是各自结算页里的“到手价”。这个工具我做了三件事把两个平台的搜索和商品详情采集下来按货号把同一双鞋/同一件衣服归到一条记录上再用各自的价格口径算出实际开销推给用户。适合两类人一类是自己想买但不想做表格手动对价的朋友另一类是做商品价格监控、需要持续跑数据的从业者。刚跑通第一版时我觉得很稳结果第三天就发现在匹配逻辑上翻了大车后面会细说。2. 商品匹配与比价口径同一双鞋凭什么认定是同一件2.1 标题匹配的陷阱唯品会叫“潮流板鞋”得物叫“AJ1 Mid”比价工具的第一个难点不是采集而是“匹配”。唯品会的商品标题偏营销风比如“耐克男鞋新款复古低帮板鞋 缓震休闲运动鞋”得物的标题则是结构化的“Air Jordan 1 Mid SE Craft 黑白 男子篮球鞋 2024款”。两个标题对不齐用字符串包含、编辑距离都容易错配编辑距离会把“Nike Dunk 黑白”和“Nike Dunk Low 黑白”判成同一个商品实际上这是两个货号的产品。我的处理方式是先做特征提取再做归一化匹配。特征是硬指标品牌名、系列名、官方货号Style Code、配色关键词。唯品会详情页里通常带“货号”字段得物商品详情也有对应的货号信息只是藏在页面 JSON 里。先把两边都解析成统一结构再按货号优先匹配货号缺失时用“品牌系列配色”三元组做模糊匹配。这一步做完匹配准确率才从我开始时的不到 60% 拉到 90% 以上。下面这段是货号提取和归一化的核心代码我在两个平台的详情页 JSON 里都跑过import re # 货号常见格式3~6位字母/数字组合如 CT8532-104、DH3716-100 STYLE_CODE_PATTERN re.compile( r\b([A-Z]{1,3}\d{3,4}[-]?\d{3})\b ) def extract_style_code(raw_text: str) - str | None: 从商品标题或详情JSON字段里提取官方货号。 raw_text: 拼接了标题、子标题、规格字段的纯文本 返回: 统一格式的货号例如 CT8532-104找不到返回 None if not raw_text: return None upper_text raw_text.upper() # 先去掉常见干扰词避免把“NIKE”后的数字误认成货号 upper_text upper_text.replace(NIKE, ).replace(AIR JORDAN, ) match STYLE_CODE_PATTERN.search(upper_text) if not match: return None # 统一去掉横杠方便两边对比CT8532-104 - CT8532104 return match.group(0).replace(-, ) def normalize_color_keywords(text: str) - str: 把配色关键词归一化同名不同写法的色系在这里合并。 例如 Black/White 与 黑白 都映射到 黑白 text_lower text.lower() color_map { black/white: 黑白, 黑白: 黑白, triple white: 纯白, 纯白: 纯白, } for key, val in color_map.items(): if key in text_lower: return val return text_lower.strip()货号提取用正则匹配命中后统一去横杠是因为唯品会可能显示为CT8532-104得物可能显示成CT8532104不去横杠就直接错过。normalize_color_keywords是为了解决两边语言习惯不同的问题得物用英文配色唯品会用中文得先映射到同一套枚举值再参与匹配。这一步的教训是不要一上来就搞机器学习匹配先把手里的规则特征吃干净大部分场景规则就够了。2.2 到手价口径唯品会算完券得物还要加鉴定费两个平台的“到手价”含义不一样这是比价工具的第二道坎。唯品会的到手价逻辑商品标价 - 优惠券金额 - 会员折扣如果有。优惠券是全场券还是品类券金额什么时候抵扣在接口里都有明确字段。缺点是要在登录状态下才能拿到真实会员价未登录看到的通常是“划线价”或原价。得物的到手价逻辑卖家价 技术服务费 鉴别费 运费。技术服务费按卖家价的一定比例收有上限鉴别费按品类固定运费视卖家发货方式而定。得物页面显示的“到手价”是平台帮买家算好的但采集接口里的字段分散在不同层级需要自己把服务费率和基础价格兜齐。下面是我定义的价格模型两边采集后的数据都折算进这个结构from dataclasses import dataclass dataclass class FinalPrice: 统一后的最终到手价模型 platform: str # vip 或 dewu raw_price: float # 页面标注价/卖家价 discount: float # 折扣或优惠金额唯品会 service_fee: float # 技术服务费得物 auth_fee: float # 鉴别费得物 shipping: float # 运费 final_price: float # 最终到手价 def __post_init__(self): if self.platform vip: self.final_price self.raw_price - self.discount elif self.platform dewu: self.final_price self.raw_price self.service_fee self.auth_fee self.shipping def is_lower_than(self, other: FinalPrice, threshold: float 0.0) - bool: 比较两个平台的到手价返回是否比对方便宜超过 threshold 元 return self.final_price threshold other.final_price这个数据类把两边的价差计算统一成同一套接口唯品会价格等于“标价减优惠”得物价格等于“卖家价加一堆费用”。判断是否值得买时我用is_lower_than带一个阈值参数默认 0 表示只要便宜就算实际用的时候我会把阈值设成 30 元——差不到 30 块钱不值得换平台折腾。这个阈值在后面的监控告警里也是同一个参数避免为了一两块钱频繁推送。3. 采集层落地搜索接口与页面解析的完整代码3.1 唯品会搜索页JSON 接口与签名参数的应对思路唯品会的搜索页走的是内部 JSON 接口浏览器地址栏里的 URL 是 SEO 页面直接抓 HTML 效率低、数据杂我从 Network 面板里找到了search.do?keywordxxx这个接口返回的 JSON 里有list字段每一项包含title、price、salePrice、brandName、goodsId这些核心字段。但接口有个 signature 参数是服务端动态生成的直接裸请求会被丢到验证码页。我的处理方式是用 requests.Session 维持 cookie先访问一次搜索首页种下基础 cookie再用同一个 Session 带上一组固定的 Header 集合去请求搜索接口——常见做法是把浏览器里的Accept、User-Agent、Referer按顺序放好Referer 必须指向唯品会搜索页否则部分接口会返回 403。import requests import time import json HEADERS { User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ), Accept: application/json, text/plain, */*, Referer: https://www.vip.com/search.html, Origin: https://www.vip.com, Accept-Language: zh-CN,zh;q0.9, } session requests.Session() session.headers.update(HEADERS) def search_vip(keyword: str, page: int 1) - list[dict]: 搜索唯品会商品返回商品基础信息列表。 keyword: 搜索关键词例如 Air Jordan 1 page: 页码接口从 1 开始 url https://search.vip.com/search.do params { keyword: keyword, page: page, pagesize: 60, needPop: false, source: search, } resp session.get(url, paramsparams, timeout10) # 常见做法先判状态码再判返回结构防止拿到验证码页面的 HTML if resp.status_code ! 200: return [] try: data resp.json() except json.JSONDecodeError: # HTML 响应说明被重定向到验证码页稍后重试 return [] items [] for raw in data.get(list, []): # salePrice 是活动价price 是划线价比价统一用 salePrice items.append({ goods_id: raw.get(goodsId), title: raw.get(title, ).strip(), brand: raw.get(brandName, ), final_price: float(raw.get(salePrice, raw.get(price, 0))), product_url: https://www.vip.com/detail- str(raw.get(goodsId)) .html, }) return items这里有几个参数需要重点说明pagesize我设成 60是唯品会一次返回的最大数量设大了不会多给还会超时needPop固定false是为了省掉弹窗信息salePrice才是用户最终付款价price是划线价取错字段会把比价结果拉偏。如果返回空列表但状态码是 200八成是signature校验失败——这时候加一个time.sleep(2)等冷却再用带 cookie 的 Session 重新请求比换代理更管用。3.2 得物搜索与详情页加密参数靠解析 HTML 里的NEXT_DATA得物的反爬比唯品会严搜索接口带platform、clientTag等请求头校验直接调 JSON 接口容易被风控所以我绕了一步——用常见做法“先请求搜索列表页 HTML再从 HTML 里的__NEXT_DATA__脚本标签解析商品数据”。这个字段是 Next.js 应用在服务端渲染时内嵌的 JSON包含搜索结果的完整信息能拿到商品 id 和标题。拿到商品 id 后再请求详情页详情页同样有__NEXT_DATA__里面的goods对象里有priceInfo和serviceFeeRate。解析时用正则把script id__NEXT_DATA__ typeapplication/json.../script里的内容抠出来再走json.loads。import re import json import time import requests DETAIL_HEADERS { User-Agent: ( Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1 ), Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, } session requests.Session() session.headers.update(DETAIL_HEADERS) def parse_next_data(html: str) - dict: 从 HTML 中提取 __NEXT_DATA__ 并解析为 JSON 对象 m re.search( rscript id__NEXT_DATA__ typeapplication/json(.*?)/script, html, re.DOTALL, ) if not m: return {} try: return json.loads(m.group(1)) except json.JSONDecodeError: return {} def search_dewu(keyword: str) - list[dict]: 搜索得物商品从列表页的 __NEXT_DATA__ 提取商品信息。 keyword: 搜索关键词 url https://www.dewu.com/search params {keyword: keyword} resp session.get(url, paramsparams, timeout10) if resp.status_code ! 200: return [] data parse_next_data(resp.text) # 不同页面里商品列表所在的路径略有差异常见做法是逐层取 props page_props data.get(props, {}).get(pageProps, {}) or {} goods_list page_props.get(searchResult, []) or [] items [] for raw in goods_list: items.append({ spu_id: raw.get(spuId), title: raw.get(title, ), brand: raw.get(brandName, ), ref_price: raw.get(refPrice, 0), product_url: fhttps://www.dewu.com/product/{raw.get(spuId)}.html, }) return items得物搜索结果里的refPrice是参考价不是真实到手价这里只做展示和匹配用真实到手价必须进详情页拿价格模型。移动端 UA 是我反复试出来的——得物对 PC UA 的详情页会注入风险检测脚本移动端反而宽松一些。这个差异没有文档属于血泪经验如果你跑的时候发现 PC 端频繁被校验直接换 iPhone UA 再试能少走半天弯路。4. 数据层与比价计算SQLite 存储与到手价算法4.1 三张表的结构设计商品表、价格表、历史表比价不是一次性请求而是持续跑。我用 SQLite 做持久化三张表products存商品基础信息和货号prices存当前两条平台价格记录price_history存每次采集的价格快照。价格表和历史表分开的原因是当前价格是覆盖写的历史价格是追加写的混在一张表里会让查询越来越慢。建表 SQL 我放在下面字段命名尽量直白后续写统计查询时不用翻文档CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, style_code TEXT, -- 归一化货号如 CT8532104 brand TEXT, -- 品牌名 product_name TEXT, -- 商品名 vip_goods_id TEXT, -- 唯品会商品 ID dewu_spu_id TEXT, -- 得物 SPU ID created_at TEXT DEFAULT (datetime(now, localtime)), UNIQUE(style_code) ); CREATE TABLE IF NOT EXISTS prices ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, platform TEXT NOT NULL, -- vip 或 dewu final_price REAL NOT NULL, -- 最终到手价 raw_data TEXT, -- 保留原始响应字段方便排查 updated_at TEXT DEFAULT (datetime(now, localtime)), UNIQUE(product_id, platform), FOREIGN KEY (product_id) REFERENCES products(id) ); CREATE TABLE IF NOT EXISTS price_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, platform TEXT NOT NULL, final_price REAL NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)), FOREIGN KEY (product_id) REFERENCES products(id) );products表里的vip_goods_id和dewu_spu_id单独存而不是只存一个商品 ID是为了保持双平台映射关系。UNIQUE(style_code)在写入时做幂等保护同一个货号重复采集不会插入新商品行而是更新平台字段和价格。raw_data字段保留接口原始响应排查“这个价格为什么算出来不对”时直接翻这条记录就知道是不是采集阶段取错了字段。4.2 比价主流程采集、匹配、入库三步串起来入库之后就是核心的比价流程。我把它写成一个独立函数每一步都打日志方便定时任务跑挂时快速定位import sqlite3 from datetime import datetime DB_PATH compare.db def run_compare(keyword: str) - dict: 主流程采集唯品会和得物数据 - 匹配 - 入库 - 返回比价结果 keyword: 搜索关键词建议带上品牌和系列名例如 nike dunk conn sqlite3.connect(DB_PATH) conn.execute(PRAGMA journal_modeWAL) # 1. 采集 vip_items search_vip(keyword) dewu_items search_dewu(keyword) if not vip_items or not dewu_items: conn.close() return {status: empty, msg: 单边无数据跳过} # 2. 按货号匹配匹配不到的可选规则再兜底一次 merged [] for vip in vip_items: vip_style extract_style_code(vip[title]) for dewu in dewu_items: dewu_style extract_style_code(dewu[title]) if vip_style and vip_style dewu_style: merged.append({vip: vip, dewu: dewu, style_code: vip_style}) break # 3. 入库并计算比价结果 results [] for pair in merged: product_id upsert_product(conn, pair) vip_price upsert_price(conn, product_id, vip, pair[vip]) dewu_price upsert_price(conn, product_id, dewu, pair[dewu]) diff round(vip_price - dewu_price, 2) cheap_platform vip if diff 0 else dewu results.append({ product_id: product_id, style_code: pair[style_code], vip_price: vip_price, dewu_price: dewu_price, diff: abs(diff), cheap_platform: cheap_platform, }) conn.close() return {status: ok, results: results} def upsert_product(conn: sqlite3.Connection, pair: dict) - int: 按货号插入或更新商品表返回 product_id cur conn.execute( INSERT INTO products (style_code, product_name, vip_goods_id, dewu_spu_id) VALUES (?, ?, ?, ?) ON CONFLICT(style_code) DO UPDATE SET vip_goods_id excluded.vip_goods_id, dewu_spu_id excluded.dewu_spu_id , ( pair[style_code], pair[vip][title], pair[vip][goods_id], pair[dewu][spu_id], ), ) conn.commit() select_cur conn.execute( SELECT id FROM products WHERE style_code ?, (pair[style_code],) ) return select_cur.fetchone()[0] def upsert_price(conn: sqlite3.Connection, product_id: int, platform: str, item: dict) - float: 更新当前价格并追加历史快照返回最终到手价。 item: 采集阶段的商品字典需包含 final_price 字段 price float(item.get(final_price, 0)) if price 0: return 0.0 conn.execute( INSERT INTO prices (product_id, platform, final_price, raw_data, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(product_id, platform) DO UPDATE SET final_price excluded.final_price, raw_data excluded.raw_data, updated_at excluded.updated_at , (product_id, platform, price, json.dumps(item, ensure_asciiFalse), datetime.now().isoformat()), ) conn.execute( INSERT INTO price_history (product_id, platform, final_price) VALUES (?, ?, ?), (product_id, platform, price), ) conn.commit() return price匹配逻辑里有个容易忽略的点唯品会一个goodsId可能对应多个 SKU 规格得物一个spuId下也有多个尺码。我比价时用整个搜索列表取标题里出现的商品进行匹配如果你要精确到尺码需要把尺码维度也加进products表的唯一键否则会出现 42 码得物 900 元、唯品会 45 码 850 元被当成同一双鞋的尴尬。SQLite 开WAL模式是为了让定时任务和手动查询并发运行不锁库数据量上来之后这个设置能明显减少database is locked报错。5. 避坑排查六个翻车点测了一周才稳定5.1 得物登录态过期价格字段集体变 0现象跑了半天的定时任务突然某一次采集后所有得物商品价格全部是 0直达详情页也正常。 原因得物的价格接口依赖登录态的X-Tokentoken 过期后接口不报错只是把价格字段返回为空或 0程序把空值当成了合法价格入库后续比价全部错乱。 解决在search_dewu和详情解析里加一道校验——price 0时直接丢弃返回空列表同时把 Session 里的 cookie 和 token 持久化到本地文件每次请求前检查 token 时间戳超过 6 小时就提示重新登录并暂停采集。从那以后我每次采集前都强制走一遍价格有效性检查不是跑到一半才发现数据废了。5.2 唯品会salePrice不恒定区间价会误导比价现象同一个商品昨天采集到手价 399今天变成 429同一页面点进去却是 379。 原因唯品会的salePrice在搜索列表接口里给的是“区间价”或“多人团价”不同用户、不同登录状态下看到的值不同详情页里的价格带上会员标识才是当前账号的最终支付价。 解决比价时搜索列表的价格只做初筛拿到goodsId后进详情页取salePrice和优惠券信息再参与计算。我用FinalPrice数据类时也把discount独立出来方便按账号维度配置优惠券模板而不是把优惠揉进raw_price里。5.3 匹配错配同系列不同配色被合成了同一个商品现象Air Jordan 1 Low Black和Air Jordan 1 Low White被匹配成同一商品比价结果里出现“同款价格差 400 元”。 原因货号缺失时只用了“品牌 系列”匹配丢掉了配色特征。两个商品货号前几位相同系列名也一样唯一区分是货号后缀或配色字段。 解决匹配算法升级为两级——第一优先级是完整货号第二优先级是“品牌 系列 归一化配色”只有三元组完全一致才确认匹配。同时把两个平台的货号后缀也纳入比较例如CT8532-104与CT8532-107属于不同配色不能合并。这个坑后来被我列成一条校验规则凡是匹配结果里两边标题差异大于 25%直接打回人工审核。5.4 访问频率过快两个平台同时触发验证码现象批次跑到第 100 个关键词时唯品会的响应从 JSON 变成 HTML得物直接返回 403整批数据带回去全是空的。 原因采集用了固定time.sleep(0.5)单线程看似不快但 Session 复用加上无随机抖动被风控按 IP 维度识别成了脚本特征。 解决延迟改成随机区间time.sleep(random.uniform(2, 5))并且每跑 20 个关键词强制停 30 秒。如果还触发验证码就先降到请求频率而不是换代理因为换代理后 cookie 重建的成本更高。这个踩坑经验只写在这网上很多教程不会提——他们给的都是“失败后重试”不告诉你失败往往是因为太快。5.5 得物详情页的__NEXT_DATA__里价格路径在不同品类不一样现象球鞋详情页能解析出priceInfo但潮流服饰详情页解析出来是空字典程序直接抛 KeyError。 原因得物对不同品类的商品结构做了差异化渲染部分品类没有priceInfo字段而是saleInfo字段路径不是统一的。 解决解析时用.get()多级兜底同时记录字段路径日志发现新品类时打印原始 JSON 的 key 树人工补充解析规则。常见做法是把“路径查找器”单独抽成一个函数传入一组候选路径命中哪个用哪个新增品类只需加路径不需要动主流程。5.6 SQLite 并发写入导致database is locked现象定时任务和手动跑脚本同时打开数据库时手动脚本抛出OperationalError: database is locked。 原因SQLite 默认是写锁粒度两个写事务同时提交时会锁库跑定时任务时忘了主动关闭连接连接池占着锁不放。 解决连接时执行PRAGMA journal_modeWAL写操作统一走同一个函数入口并设置timeout30。WAL 模式下读和写可以并发timeout则让写冲突时等待而不是直接报错。从那以后我每次建库都会在连接初始化时强制走一遍这两个 pragma 设置算是一个止血习惯。6. 进阶价格变动的持续监控与告警比价工具的用处不只在于“当下谁便宜”更在于“什么时候值得出手”。我把历史价格表和当前价格表对比每次采集后检测三天内的最低价和跌幅一旦跌幅超过阈值就触发告警。这里我用一个简单 SQL 查询SELECT product_id, platform, MIN(final_price) AS min_price, MAX(final_price) AS max_price, ROUND((MAX(final_price) - MIN(final_price)) / MIN(final_price) * 100, 2) AS drop_pct FROM price_history WHERE created_at datetime(now, -3 days, localtime) GROUP BY product_id, platform HAVING drop_pct 5;这条查询扫描近三天的价格快照按商品和平台分组算出最大跌幅百分比只保留跌幅超过 5% 的记录。落到定时任务里我一般用 APScheduler 每隔两小时跑一次全量比对命中告警就推一条消息内容包含商品名、平台、降价前后价格和直达链接。阈值 5% 是经验值——球鞋这类商品日常波动就有 3% 左右低于 5% 推出来全是噪音时间长了就没有人看告警了。告警函数里我会把同款商品的两端价格并排显示如果唯品会降价比得物更多就自动标记“建议唯品会入手”。这个逻辑不复杂但很实用用户点进来看的不是两个平台的数字而是“现在该去哪买”的直接结论。运行一段时候后可以把命中告警的商品回填到一张专门追踪的表里连续三天都在降价且跌幅扩大的列为重点观察名单。这个项目真正跑通花了接近两周大部分时间耗在字段不确定和频率控制上。每当我想再加一个新平台或新品类时都会先想想任务是要覆盖更宽还是让数据更稳从那以后我每次改完采集逻辑都会强制跑一遍本地全量校验把 0 价格、空响应、匹配异常各标记一类颜色确认三类都清零才放它上线。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网