☰
唯品会得物比价工具开发实战:字段对齐、同款匹配与自动盯价
2026/10/2 1:02:21 网站建设 项目流程

简介:面向需要跨平台比价消费决策的用户及C#开发者,这款工具自动抓取唯品会与得物平台商品价格,通过模拟浏览器操作与接口请求完成数据采集、解析和对比,省去手动切换比价的麻烦。压缩包共38个文件,以dll、xml为主,另有exe、pdb、config等,整体13.56MB。其中WebDriver.dll与msedgedriver.exe负责浏览器自动化,Newtonsoft.Json.dll、RestSharp.dll用于JSON解析与HTTP请求,unvell.ReoGrid.dll可呈现表格化比价结果,ComparePrice.exe及配置文件则支持直接运行与参数调整。资源目前已有4438人学习下载。除可直接使用的程序外,还包含使用说明txt与项目调试文件,能够帮助读者理解电商数据采集、Selenium自动化、.NET组件协作及常见排障思路,适合想动手实践比价工具或学习爬虫与桌面端整合开发的开发者参考。

1. 唯品会得物商品比价工具:两块屏幕之间的差价,值得用代码去盯

同样是那双 Nike Dunk,唯品会可能挂出折后六百多,得物上同一个尺码的报价却冲上九百;反过来也有唯品会没货、得物反而便宜的时候。手动比价的人只能两个App来回切,搜同一个款号看两边颜色对不对、尺码全不全,一天看下来眼睛花,还经常记混价格。自己做一套唯品会得物商品比价工具,本质就是两件事:把两边同款商品认出来,再把这些价差变成每天自动刷新的数据表。这篇文会按真实落地顺序讲——商品字段怎么拆解、同款怎么匹配、抓取任务怎么定时跑、哪些坑会让比价结果彻底失真。适合想稳定盯价的人,也适合做买手、代购或者二手交易的人拿来当参考。

2. 先把两边的商品数据结构吃透:统一字段才是比价的地基

比价工具的错配率,在动手写代码那天就决定了。如果一开始没把两边字段对齐,后面所有相似度计算都是在给错误数据打补丁。先花半天时间,把唯品会和得物各自呈现商品信息的方式摸清楚。

2.1 唯品会的商品字段:专柜价、折扣价与“专供款”陷阱

唯品会走的是品牌特卖逻辑,商品详情页里最核心的几个字段大概是这些:spuId、skuId、title、brandName、seriesName、goodsNo、color、size、salePrice、vipPrice、marketPrice。goodsNo就是品牌货号,比如 Nike 的DJ0956-100,这是后期匹配同款时最硬的依据。

唯品会有一个字段我需要单独提出来强调:isExclusive,也就是“唯品会专供款”。这类商品本质上是品牌给唯品会单独开的款式或者老款换壳,在得物上大概率找不到对应商品。比价工具第一道过滤器就应该是它,抓取时直接跳过专供款,否则这些数据会永远挂在匹配队列里,消耗人工复核时间。

价格字段上要注意salePrice和vipPrice的差别。vipPrice(会员价)通常更低,但得物是没有“会员价”概念的,比价时如果一侧用会员价、另一侧用普通价,差价里就会掺进去一个平台政策变量,不干净。我一般定死规则:唯品会统一取vipPrice,并在记录里打一个isVipPrice标记。

2.2 得物的报价逻辑:卖家挂单价与历史成交价是两套体系

得物的商品信息结构长得跟传统电商不一样,我抓到的字段一般是itemId、title、detail、colorway、size、price、tradePrice、newStatus、sales。其中detail这个字段里通常会带货号,但由于得物是卖家上架模式,标题和货号经常被卖家自己改过,不能直接拿来当准。

得物的price是当前最低挂单价——卖家挂多少钱卖就是多少,不代表这个尺码真实成交水平。真正有参考价值的是tradePrice,也就是近期历史成交均价。举个例子:一双鞋在得物挂单价标 3699,但近 30 天成交均价只有 2800,你要是拿 3699 去跟唯品会的 2500 比,会得出“得物贵了 1200”的结论,实际上成交价只差 300。所以我的得物抓取字段里,tradePrice是必取项,price只作为辅助参考。

另外得物还有个newStatus字段,区分全新、微瑕、二手。比价工具只认“全新”这一类,否则同一双鞋一边是全新价、一边是“开箱微瑕”价,价差再大也没有说服力。

2.3 字段映射:一张表对齐两个平台

两边字段名称完全不一样,建表之前先做一次手工映射。我通常会把这张映射表直接写进设计文档,后续所有代码都围绕它来写:

语义唯品会字段得物字段匹配用途
商品主IDspuIditemId存储主键
商品标题titletitle文本相似度
品牌货号goodsNodetail(需提取)强匹配
品牌名brandNamebrandName过滤门槛
配色colorcolorway二次校验
尺码sizesize价格锁尺码
当前价vipPriceprice展示参考
成交参考价marketPricetradePrice差价计算

这张表的另一个作用,是给爬虫字段解析做对照。两边反爬程度不一样,但先统一好语义模型,后面不管换哪个采集端,落地数据结构都不会乱。

3. 同款匹配是比价工具的生死线:货号优先、文本相似度兜底

字段对齐之后,紧跟着就是全工具最核心的环节——怎么判断唯品会的“Air Jordan 1 Retro High OG”和得物的“AJ1 高帮 芝加哥”是同一双鞋。这个环节做不好,后面差价再准都是白搭。我的策略是两通道并行:货号通道为主,文本相似度通道兜底。

3.1 商品名清洗:把款号、配色、尺码从标题里拆出来

第一步先把商品标题里的关键信息拆解出来。电商标题格式再乱,基本都包含品牌、系列、货号、配色这几个元素。货号是最强的匹配键,先写一段提取函数:

import re def extract_style_code(text: str) -> str | None: # 先转大写,兼容 nike/ADIDAS 这类大小写混写 text = text.upper() # 常见球鞋/服饰货号模式:2-4位字母 + 4-6位数字 + 可选横杠数字 patterns = [ r"[A-Z]{2,3}\d{4,6}-\d{1,3}", # DJ0956-100 r"[A-Z]{2,3}\d{4,6}", # 无后缀样式 r"\d{4,6}-\d{1,3}", # 部分品牌纯数字 ] for p in patterns: m = re.search(p, text) if m: return m.group(0) return None

这段代码的思路是优先匹配带横杠的完整货号,匹配不到再退而求其次。原因很简单:Nike、Adidas 的货号规范是“字母+数字+横杠+色号”,横杠后面的三位数字往往代表同一个款的不同配色,保留它能提升区分度。逻辑说完之后要说参数:[A-Z]{2,3}限定了品牌缩写长度,如果你对比的品牌里有单字母缩写,要把这里改成{1,3},否则会漏掉。

3.2 文本相似度匹配:用 TF-IDF 跑候选对

不是所有商品都有货号,或者有些卖家把货号藏在图片里不写进标题。这时候只能靠文本相似度匹配。我试过直接用difflib,效果很差,因为“Air Jordan”和“AJ”这种写法差异在字符级别上相似度极低。后来换成 TF-IDF 配合 n-gram,才算稳定下来。

import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity def match_by_text(titles_a: list, titles_b: list, threshold: float = 0.82): # 用 char_wb 的 2-4 gram 做特征,能抓到 "AJ"/"AIR JORDAN" 的局部相似 vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4), max_features=5000) matrix = vec.fit_transform(titles_a + titles_b) sim = cosine_similarity(matrix[:len(titles_a)], matrix[len(titles_a):]) results = [] for i in range(sim.shape[0]): for j in range(sim.shape[1]): if sim[i, j] >= threshold: results.append({ "a_idx": i, "b_idx": j, "score": round(float(sim[i, j]), 4) }) return results

这里的核心参数是ngram_range=(2,4)。为什么要从 2 开始?因为单个字符 e 在英文标题里没有区分能力,而aj、ir这样的两字符组合能表达大量信息;到 4-gram 基本能覆盖常见单词词根。max_features=5000是控制特征维度,防止语料一多内存先撑不住。threshold 设 0.82 是我跑了几千对商品后的经验值,下面细说。

3.3 阈值 0.82 怎么定的

很多人喜欢把相似度阈值写到 0.9,觉得越高越准。实际结果是对半翻车——两边对同一款的命名习惯差异太大了。唯品会偏向官方完整名,得物偏向圈内简称,常规 0.9 的阈值会把大量真实同款漏掉。

反过来,低于 0.75 就会混进大量噪音:同品牌不同系列、同系列不同配色都挤进来。我最后锚定在 0.82,配合货号通道做双重确认。这个数不是调参调出来的玄学,而是按“人工复核 200 对样本,把 false positive 压到 5% 以下”这个标准慢慢试出来的。每个人语料不同,建议保留这个参数为配置项,不要写死在代码里。

3.4 不能完全自动确认:留一个人工复核队列

纯自动匹配必然出错,不要抱有侥幸心理。两条通道都跑完后,把结果分成三个 bucket:货号完全一致、文本相似度高于阈值、两者皆无命中。前两类直接进库,最后一类进人工复核队列。我一般把复核队列输出成 CSV 让人过一遍:

import csv def export_pending(mismatches: list, path: str = "pending_review.csv"): # 每个元素是 {"vip_title": "...", "dewu_title": "...", "score": 0.0} with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["vip_title", "dewu_title", "score"]) writer.writeheader() writer.writerows(mismatches)

第一轮不用贪多,能确认 200 个种子商品就够了,这些种子映射会作为后续匹配的缓存字典。人工复核每天花十几分钟,总比错配数据污染整个数据库强。

4. 把比价做成每天自动跑的定时任务:表结构、请求参数与调度策略

匹配逻辑稳定后,比价工具就从“一次性脚本”变成“日常服务”。这一章讲落地细节:数据库怎么建、请求怎么发、差价怎么算、任务怎么调度。

4.1 数据库表结构:商品主表、价格快照表、映射表分离

建表有一个原则:商品静态信息和每日价格绝不能混在一张表里。商品信息是缓慢变化的,价格是每天变的,混在一起会导致每次更新都要处理“是新增还是覆盖”的问题。三张表分离是我常用的方案:

CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, platform TEXT NOT NULL, -- 平台:vip / dewu item_id TEXT NOT NULL UNIQUE, -- 平台侧原始ID title TEXT NOT NULL, style_code TEXT, -- 提取后的货号 color TEXT, size TEXT, url TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE price_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, -- 关联 products.id price REAL NOT NULL, -- 实际用于比价的价 reference_price REAL, -- 划线价或成交参考价 snapshot_date TEXT NOT NULL, -- 格式 YYYY-MM-DD UNIQUE(product_id, snapshot_date) ); CREATE TABLE product_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, vip_product_id INTEGER NOT NULL, dewu_product_id INTEGER NOT NULL, match_method TEXT NOT NULL, -- style_code / text_sim / manual score REAL, confirmed INTEGER DEFAULT 0, UNIQUE(vip_product_id, dewu_product_id) );

UNIQUE(product_id, snapshot_date)这个约束非常关键。它保证同一商品一天只存一条价格记录,重复跑任务时用INSERT OR IGNORE不会打爆数据。我在 pricelist 表里加了reference_price,这不是用来算差价的,只是留作看“折扣力度”的参考,避免以后想分析历史水分时没有字段可用。

4.2 抓取频率与请求参数:单 IP 低频比多 IP 高频安全得多

这一节是血泪经验。很多人在爬取这步翻车,不是被拒绝了,而是把自己 IP 搞到限流,导致后续数据断档。我的建议是把“低频、随机、不贪多”写进代码。

import random import time import requests from fake_useragent import UserAgent def fetch_with_backoff(url: str, cookie: str, retry: int = 3): ua = UserAgent() headers = { "User-Agent": ua.random, "Accept-Language": "zh-CN,zh;q=0.9", "Cookie": cookie.strip(), "Referer": "https://www.naifeihuatao.example/" # 替换为实际商品列表页 } for attempt in range(retry): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp if resp.status_code in (429, 503): wait = (attempt + 1) * 15 + random.uniform(1, 5) time.sleep(wait) except requests.RequestException: time.sleep(5) return None

参数说明:retry=3是上限,超过就必须停下来检查是不是被限流了。请求间隔要放在任务循环里,两个请求之间至少间隔random.uniform(3, 8)秒,单 IP 采集频率控制在每秒 0.2 次以下,也就是 5 秒一请求。这个频率看起来很低,但比价工具根本不需要实时,每天跑 500 个商品大约耗时 40 分钟,完全可接受。

4.3 差价计算:一条 SQL 把今天的差价率和最低价入口算出来

数据入库后,查差价就是最基本的 SQL 操作。需要注意的一点是:先按映射表把两边商品对上,再找当天的价格快照,两边缺一方的数据直接丢弃。

SELECT p_vip.title AS vip_title, p_dewu.title AS dewu_title, s_vip.price AS vip_price, s_dewu.price AS dewu_price, (s_vip.price - s_dewu.price) AS diff, ROUND((s_vip.price - s_dewu.price) / s_vip.price * 100, 2) AS diff_pct, CASE WHEN s_vip.price < s_dewu.price THEN 'vip' ELSE 'dewu' END AS cheaper FROM product_mappings pm JOIN products p_vip ON pm.vip_product_id = p_vip.id JOIN products p_dewu ON pm.dewu_product_id = p_dewu.id JOIN price_snapshots s_vip ON s_vip.product_id = p_vip.id JOIN price_snapshots s_dewu ON s_dewu.product_id = p_dewu.id WHERE s_vip.snapshot_date = '2025-01-15' AND s_dewu.snapshot_date = '2025-01-15' ORDER BY ABS(diff) DESC LIMIT 50;

这段 SQL 的关键在snapshot_date必须两边相等,否则会把不同日期的价格混在一起比。如果你发现某天查询结果明显变少,先怀疑两边抓取时间差太大,比如唯品会凌晨抓完了,得物还没跑。最后加LIMIT 50是为了让结果先给到最有看点的高价差商品,人工判断这块的匹配质量。

4.4 调度策略:固定时段增量抓取,避开秒杀与活动页

比价任务我推荐用系统 crontab,不要写死在自己脚本里。每天跑两次足够:早上 8 点一次,晚上 22 点一次。这两个时段避开整点秒杀,也避开了平台活动页高频更新的时间窗口,请求成功率更高。

# crontab 示例:每天 8:10 和 22:10 各跑一次 10 8 * * * cd /opt/price-compare && /usr/bin/python3 run_daily.py >> logs/daily.log 2>&1 10 22 * * * cd /opt/price-compare && /usr/bin/python3 run_daily.py >> logs/evening.log 2>&1

增量抓取的意思是:只有映射表里确认过的商品才维护每日价格,新增商品只在发现阶段全量抓取。这样每天的请求量基本恒定,不会因为商品多了就无限膨胀。

5. 比价工具避坑指南:反爬、错配和“低价不低”的真实原因

这个工具能不能长期用,取决于你踩坑之后能不能快速恢复。下面五条是我自己在迭代过程中实际遇到过的,按影响程度排列。

5.1 请求一多就被限流:现象、原因、解决

现象:脚本跑前两周正常,第三周开始大面积超时,甚至出现403 Forbidden,但浏览器里手动打开页面又完全正常。

原因:平台侧对“短时间高频规律请求”做了特征识别,不是因为你换了 User-Agent 就认不出来,而是请求时间间隔过于均匀暴露了机器特征。

解决:把固定time.sleep(5)改成random.uniform(3, 8),并给每个商品请求加一个基于商品ID的随机偏移。更彻底的做法是把整个采集任务打散成四个时间段运行,比如早上只采集唯品会,傍晚只采集得物,让请求模式更贴近正常浏览。另外,尽量不要用共享机房 IP 段,住宅 IP 的稳定性高很多。

5.2 同款不同名:货号缺失导致的错配

现象:某双 New Balance 在唯品会标题里清清楚楚写着M2002RDD,得物标题写成“NB 2002R 深灰 元祖灰”,两边货号提取都失败,文本相似度只有 0.65,被丢进人工复核队列。复核时发现是同款,但系统已经判定不匹配。

原因:不是所有标题都带规整货号,得物卖家经常只写系列名和配色。

解决:给匹配逻辑加一层“系列名别名表”。把常见系列的手写别名整理出来,比如AJ1=Air Jordan 1、Dunk=Dunk Low/High、2002R=M2002RD。在计算相似度之前先把标题里的别名替换成官方名,这一步能让匹配召回率提升至少 20%。

5.3 得物的标价不等于成交价:低价可能是没人买的挂单

现象:得物标价明显低于唯品会,用户点进去发现这个尺码根本没人买,标价是有价无市。工具给出“得物便宜 500”的提示,实际按这个价根本成交不了。

原因:得物没有官方自营定价,所有价格都是卖家挂上去的,挂单价高低完全看卖家心情。有人挂 999 是真心想卖,有人挂 999 只是占个坑不急着出手。

解决:取数时把price和tradePrice分开存,默认用tradePrice作为比价依据。如果某商品当天没有成交数据,宁可不给差价结果,也不能拿挂单价充数。在输出界面里把这两个价都展示出来,让用户自己判断。

5.4 尺码配色没锁定:34 码和 39 码混在一起比

现象:唯品会同一款鞋有多个尺码价格不一样,得物按每码一个报价,映射表只映射到商品级别,于是系统拿唯品会 39 码的价格去对比得物 34 码的价格,差价率算出来 15%,实际同码差价只有 3%。

原因:商品映射粒度不够细。唯品会的商品页可以按尺码拆 SKU,得物商品详情里尺码价格也是独立的。

解决:把size作为映射表的第三把钥匙。也就是product_mappings表里要加sku_key字段,格式是style_code + color + size,只有三件套完全一致才允许匹配。这会让映射表数据量大增,但价格准确度的提升是质变。

5.5 快照表无限膨胀:数据保留策略

现象:跑了一年,price_snapshots表三百万行,查询一天的差价 SQL 从 50 毫秒变成了 1.8 秒,备份文件也越来越大。

原因:每商品每天一行,1000 个商品一年就是 36 万行,这还只是单平台。如果跑了三年,数据量会涨到百万级。

解决:给snapshot_date加索引,这是第一步,但治标不治本。我在调度任务里加了一个归档轮子:只保留最近 90 天的明细快照,超过 90 天的数据按月聚合到price_monthly_agg表,只存当月的最高、最低、平均价。这样明细表数据量可控,历史趋势分析也不受影响。

6. 用 Flask + SQLite 把比价结果变成一张能看的表

最后一步,把计算结果暴露给使用方。没必要上重型前端框架,一个 Flask 应用加上前面建的 SQLite 库,二十行代码就能输出一张可筛选的比价表。

from flask import Flask, render_template_string import sqlite3 app = Flask(__name__) DB_PATH = "/opt/price-compare/data.db" PAGE = """ <table border="1" cellspacing="0" cellpadding="8"> <tr><th>唯品会</th><th>得物</th><th>差价</th><th>差价率</th><th>哪边便宜</th></tr> {% for row in rows %} <tr> <td>{{ row['vip_title'][:30] }}</td> <td>{{ row['dewu_title'][:30] }}</td> <td>{{ row['diff'] }}</td> <td>{{ row['diff_pct'] }}%</td> <td>{{ row['cheaper'] }}</td> </tr> {% endfor %} </table> """ @app.route("/") def index(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.execute(""" SELECT * FROM ( SELECT p_vip.title AS vip_title, p_dewu.title AS dewu_title, (s_vip.price - s_dewu.price) AS diff, ROUND((s_vip.price - s_dewu.price) / s_vip.price * 100, 2) AS diff_pct, CASE WHEN s_vip.price < s_dewu.price THEN 'vip' ELSE 'dewu' END AS cheaper, ROW_NUMBER() OVER (ORDER BY ABS(s_vip.price - s_dewu.price) DESC) AS rn FROM product_mappings pm JOIN products p_vip ON pm.vip_product_id = p_vip.id JOIN products p_dewu ON pm.dewu_product_id = p_dewu.id JOIN price_snapshots s_vip ON s_vip.product_id = p_vip.id JOIN price_snapshots s_dewu ON s_dewu.product_id = p_dewu.id WHERE s_vip.snapshot_date = date('now') AND s_dewu.snapshot_date = date('now') ) WHERE rn <= 50 """).fetchall() conn.close() return render_template_string(PAGE, rows=cur) if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=False)

这个展示页的唯一目的是快速验证结果质量,不需要复杂图表。真正常用的反而是里面的ROW_NUMBER() OVER (ORDER BY ABS(...) DESC),它保证永远只展示差价最大的前 50 条——这正适合每天打开扫一眼,看今天有哪些商品价差出现了异常波动。我一般每周还会抽查 20 条映射,看有没有新的同名不同款混进来。

做比价工具,前期建表和数据清洗花的时间往往比写爬虫还多。每当你觉得匹配逻辑已经完美了,总会有新写法冒出来打脸。保持映射表里的“人工确认”字段常态化,每天留十来分钟扫一遍结果,这比再牛的算法都让人安心。希望这篇整理能把你的坑扫平不少。

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

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

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

立即咨询