Playwright实战:打造唯品会得物商品比价工具
简介唯品会得物商品比价工具是一款面向普通消费者的跨平台价格对比应用通过自动化采集与解析商品信息帮助用户在购买前快速判断同款商品在两个平台上的定价差异。整包包含38个文件以dll动态库、xml配置文件、exe程序文件为主另有少量pdb调试符号与txt说明文档压缩包大小约13.56MB。其中WebDriver.dll支撑浏览器自动化操作RestSharp与Newtonsoft.Json用于发送请求和解析JSON数据unvell.ReoGrid负责表格化展示比价结果整体工具链完整适合对电商数据采集、Selenium自动化或C#开发感兴趣的初学者及中级开发者参考。目前已有4438人学习下载。资源附有使用说明与程序配置示例读者可快速上手运行并通过阅读关键组件清单理解软件结构为二次开发或学习爬虫、数据处理提供真实可用的项目样例。1. 唯品会得物商品比价工具不是抓价格而是对齐到手价唯品会得物商品比价工具如果只做成“把两边的标价抓下来对比”结果大概率翻车。得物上同一双鞋每个尺码是一个独立行情价格十几分钟就会跳动唯品会的“特卖价”背后叠着满减、券和运费分摊你看到的数字和最终付款经常对不上。这个工具真正要解决的是把两个平台的“付费口径”拉平让你能回答一个问题同一件商品今天在哪个平台买到手更划算。适合三类人做代发和搬货的小卖家、给店铺定售价的运营、以及经常在两个平台之间犹豫的个人买手。后面所有步骤都围绕这一件事展开。2. 数据获取先拆解两边页面再用 Playwright 拿到真实价格2.1 得物H5 页面 拦截响应比直接调接口更稳得物没有公开的商品搜索 APIApp 端接口带着签名和时间戳校验直接用 requests 去碰基本是玄学。我一般走 H5 页面用 Playwright 启动一个真实浏览器环境搜索关键词之后监听网络响应把后端返回的 JSON 截下来解析。这样做的好处是签名和风控参数由页面自己处理你只需要关心提取哪个字段。from playwright.sync_api import sync_playwright def dewu_search(keyword): results [] with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page( user_agentMozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 ) def on_response(resp): # 只处理 JSON 响应避免把 HTML 也拿进来解析 if json not in (resp.headers.get(content-type) or ): return try: body resp.json() except Exception: return data body.get(data) if not isinstance(data, dict): return # 搜索接口的返回结构里商品列表可能在 items 或 list 下 items data.get(items) or data.get(list) if isinstance(items, list): for it in items: results.append({ title: it.get(title), pid: it.get(pid) or it.get(spuId), price: it.get(price), }) page.on(response, on_response) page.goto( fhttps://m.dewu.com/search?keyword{keyword}, wait_untilnetworkidle, timeout60000 ) # 多等几秒给搜索接口留出返回时间 page.wait_for_timeout(3000) browser.close() return results这段代码的要点是page.on(response)在浏览器每次收到响应时触发on_response只吃掉 JSON 内容避免把 HTML 响应体也塞进解析流程。data.get(items) or data.get(list)是为了兼容两个不同版本的数据结构搜索接口偶尔会把列表字段从 items 改成 list这种写法能少改代码。参数上headlessTrue表示无头模式日常抓数够用wait_untilnetworkidle是等所有网络请求结束代价是慢一点但初次跑通不建议改成domcontentloaded后面调稳定了再提速。如果跑出来results是空的优先检查两种情况第一H5 页面布局改版搜索接口的路径和返回字段变了这需要打开浏览器开发者工具看实际响应第二触发了滑块验证把headless临时改成False人工滑一次确认能正常访问再继续。2.2 唯品会解析页面里的全局变量绕开接口动态拼参数唯品会的搜索页反而简单一些。它的 Web 端页面会往window.__INITIAL_STATE__这个全局变量里塞一整个商品列表的数据里面包含标题、商品 ID、销售价等关键字段。你不必去猜后端接口的参数直接把这个全局变量取出来递归遍历就行。import json from playwright.sync_api import sync_playwright def vip_search(keyword): results [] def walk(obj): # 递归找包含 salePrice 和 title 的商品对象 if isinstance(obj, dict): if salePrice in obj and title in obj: results.append({ title: obj[title], pid: obj.get(commodityId) or obj.get(goodsId), price: obj[salePrice], }) for v in obj.values(): walk(v) elif isinstance(obj, list): for v in obj: walk(v) with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 ) page.goto( fhttps://www.vip.com/search?keyword{keyword}, wait_untildomcontentloaded, timeout60000 ) page.wait_for_timeout(1500) state page.evaluate(window.__INITIAL_STATE__ || {}) data json.loads(state) if isinstance(state, str) else state walk(data) browser.close() return resultswalk函数是全字段递归不管唯品会怎么调整数据层级只要某个对象同时包含salePrice和title就会被当成一个商品条目收进来。这种写法牺牲了一点性能但换来了对页面结构调整的容忍度日常维护成本低很多。wait_untildomcontentloaded是考虑到唯品会搜索页的首屏数据就在初始 JS 里不需要等所有图片加载完如果发现有的商品没抓到把 1500 毫秒的等待拉长到 3000 再试。2.3 最小跑通脚本把两边价格并进一张表拿到两个函数之后先用一个最小脚本验证数据能并到一起再考虑匹配问题。import pandas as pd def build_dataset(keyword): dewu_items dewu_search(keyword) vip_items vip_search(keyword) dewu_df pd.DataFrame([ {**item, platform: dewu} for item in dewu_items ]) vip_df pd.DataFrame([ {**item, platform: vip} for item in vip_items ]) if dewu_df.empty and vip_df.empty: return None all_df pd.concat([dewu_df, vip_df], ignore_indexTrue) return all_df if __name__ __main__: df build_dataset(耐克 AF1) print(df.head(20))这里的**item是把字典里的字段展开再加上platform标记来源方便后面分组比价。跑通之后你大概率会遇到两个问题价格字段有时是“分”为单位有时是“元”需要统一除以 100两边字段名不一致得物用pid唯品会用commodityId后续匹配前先做一层字段映射。这个阶段不急着做匹配先把数据落成 CSV人工翻开看一遍确认抓到的价格真的对应前台页面展示的商品。3. 到手价计算为什么标价对比会翻车3.1 得物的价格不是“商品价”是“商品价 服务费 运费”很多人第一次抓得物数据时会很兴奋哇这双鞋比唯品会便宜一百多。然后下单时发现结账金额比预期高了好几十。得物的展示价只包含商品本身结算时还要叠加鉴定服务费和运费。鉴定费是按价格档位收的商品越贵鉴定费越高运费则跟鞋盒体积有关买一双鞋和一个配件运费完全不是一档。项目字段常见叫法说明商品价price / tradePrice列表页展示的价格鉴定服务费serviceFee按商品价格分档收取运费shippingFee满额包邮未满按件收所以你在做“得物到手价”时光抓price字段是远远不够的还要进商品详情页找到serviceFee和shippingFee。如果抓不到这两个字段可以先用一个固定估算值去核对订单页等能抓到真实值再替换。3.2 唯品会的“券后价”是优惠分摊后的单件价唯品会的页面里同时存在好几个价格划线价、特卖价、券后价。你真正要关心的是券后价但券后价背后有一套分摊逻辑。平台发的满减券是整单优惠结算时按每件商品的原价比例分摊到单品头上所以单件显示“券后价”往往是凑单凑出来的。如果你只买这一件实际可能享受不到那个价格。这就是“标价对比翻车”的典型场景你在得物看到 599在唯品会看到 499觉得唯品会便宜结果唯品会这件不包邮运费 12 元而得物那单刚好满 299 包邮两边到手价就非常接近了。比价工具的核心差别就在于有没有把运费和优惠分摊算进去。3.3 用同一个“到手价口径”算两边最终付多少钱我处理这个问题的做法是建立一个统一的归一化函数让两边输出的字段一致后面比价才有意义。def normalize_total_price(platform, item): if platform dewu: # 得物商品价 鉴定服务费 运费 price float(item.get(price, 0)) service_fee float(item.get(service_fee, 0)) shipping_fee float(item.get(shipping_fee, 0)) return round(price service_fee shipping_fee, 2) if platform vip: # 唯品会券后价 运费 - 单件优惠分摊 price float(item.get(coupon_price, item.get(price, 0))) shipping_fee float(item.get(shipping_fee, 0)) coupon_share float(item.get(coupon_share, 0)) return round(price shipping_fee - coupon_share, 2) return None逻辑说明得物的service_fee和shipping_fee要从详情页或结算接口取搜索接口通常没有唯品会的关键是把coupon_share分摊额填对如果你只比单价目可以默认填 0但要在结果表里单独标记“未含券分摊”否则容易误判。参数说明coupon_price是唯品会商品对象里的券后价字段有些接口也叫promotionPrice抓取时做一层别名映射更保险。这样统一之后你再看两个平台的数据必须用一个标准要么两边都含运费要么两边都不含运费。我一般选择“含运费实付口径”因为这最接近用户真正付款的金额。4. 同款商品怎么匹配品牌 货号而不是标题相似度4.1 标题匹配为什么不准两边文案风格完全不一样拿到两个平台的商品列表后下一个问题是怎么确定“这一条是同一双鞋”。很多人第一时间想到用标题相似度比如用 difflib 或者 fuzzywuzzy 算一个分数但实际效果非常差。得物的标题写的是“Nike Air Force 1 07 男款运动鞋 CT1234-100 白色”唯品会的标题是“耐克 AF1 休闲鞋 男 白 CT1234-100”两个标题汉字都对不上但指的就是同一款。反过来两个平台可能同时上架“AJ1 低帮”和“AJ1 高帮”标题相似度很高价格差却很远。所以标题相似度只适合做“人工辅助核对”不适合做主键。4.2 用“标准化品牌 货号”做连接键球鞋和服装类目里最稳的关联标识是货号。得物的详情页会单独列出“货号”字段唯品会的标题或详情里也会写“货号: CT1234-100”。只要两边都能抽出货号匹配就变成了一次简单的等值关联。import re BRAND_ALIAS { nike: [nike, 耐克, 耐 克, air jordan, aj], adidas: [adidas, 阿迪达斯, 阿迪, 三叶草], nb: [new balance, newbalance, new balance, 新百伦], converse: [converse, 匡威], } STYLE_PATTERN re.compile(r\b([A-Z0-9]{6,12}(?:-[0-9]{1,4})?)\b) def normalize_brand(text): 从标题或详情文本中识别品牌并统一为标准名 lower_text text.lower() for brand, aliases in BRAND_ALIAS.items(): for alias in aliases: if alias in lower_text: return brand return None def extract_style_code(text): 提取类目货号如 CT1234-100 / DD1391-100 / M9168 upper_text text.upper() match STYLE_PATTERN.search(upper_text) return match.group(1) if match else None def build_item_key(brand, style_code): 标准化品牌 货号拼成关联主键 if not brand or not style_code: return None return f{brand}:{style_code}逻辑说明normalize_brand的核心是别名归一化唯品会爱写中文品牌名得物偏英文不归一直接连不上。STYLE_PATTERN这个正则匹配 6 到 12 位大写字母数字组合中间可以带横杠再跟 1 到 4 位数字覆盖 Nike、Adidas、New Balance 的常规货号格式。参数说明如果你比价的类目只做奢侈品这个正则需要调整因为 LV 的货号往往是“M51182”这种纯字母加数字无横杠结构可以直接放宽成[A-Z0-9]{6,10}但要接受更多误匹配。4.3 匹配验证先看匹配率和误配率再决定要不要上线匹配逻辑写完不要急着全量跑。先用一个样品词比如“耐克 AF1”把两边数据拉下来然后用主键做 merge人工抽查二十条确认确实匹配对了。def match_items(dewu_items, vip_items): dewu_map {} for item in dewu_items: brand normalize_brand(item[title]) style extract_style_code(item[title]) key build_item_key(brand, style) if key: dewu_map[key] item matches [] for item in vip_items: brand normalize_brand(item[title]) style extract_style_code(item[title]) key build_item_key(brand, style) if key and key in dewu_map: dewu_item dewu_map[key] matches.append({ item_key: key, dewu_pid: dewu_item[pid], dewu_title: dewu_item[title], dewu_price: dewu_item[price], vip_pid: item[pid], vip_title: item[title], vip_price: item[price], }) return matches这段代码先把得物的数据按主键建成字典再遍历唯品会的条目做等值查找时间复杂度是 O(n)几万条数据也能跑。匹配完成之后把matches导成表格人工随机抽 20 条点开两个平台的商品页核对标题和货号。只要这 20 条里没有“匹配错”的就可以扩大关键词范围继续测一旦出现“货号相同但商品确实不是同一件”的案例优先检查是不是品牌归一化错了比如 New Balance 和 NB 在唯品会上都叫“新百伦”但“NB”也可能被某些代购写进标题里。5. 避坑比价工具最常见的五个翻车现场5.1 得物同一链接下尺码不同价把 SPU 价当成了整链接价现象搜索接口抓到一个商品价格 499比唯品会便宜 80你觉得性价比很高点进链接发现 42 码卖 620你要的 44 码已经挂到 710。原因得物是 C2C 撮合模式每个尺码对应一个独立市场价格完全不同。搜索接口返回的price字段只是该 SPU 下的一个参考价不能代表整条链接。解决做匹配之前先进入商品的 SKU 列表把目标尺码的价格单独取出来。如果你的比价目标是“某个具体尺码”那么在采集阶段就要带上尺码维度不要只存 SPU 维度。5.2 得物的“求购价”不是能直接拍下的成交价现象工具抓到的得物价格比唯品会低 200点进去却发现只有“求购”按钮没有“立即购买”想要这个价格必须挂单等别人卖。原因得物页面同时存在买家求购价和卖家出售价求购价是“我愿意出多少钱买”不代表当前有货可卖。部分采集脚本把第一个price字段当成成交价实际抓到了求购价。解决在解析得物商品数据时优先取可立即成交的卖家卖价别取bid或buyPrice这类求购字段。如果你拿不准就人工点开商品页确认“立即购买”旁边的价格和工具抓到的数字是否一致。5.3 唯品会的“特卖价”是优惠分摊后的单件价直接比价会误判现象用唯品会特卖价对比得物发现唯品会低很多想着怎么搬货都有利润实际下单后发现要凑满减才是那个价单件买根本不成立。原因唯品会的特卖价基于满减、店铺券、免邮券叠加后的分摊结果单件购买时拿不到这一档。解决采集时把商品参与的活动信息一并抓回来至少要知道“满 499 减 80”还是“单件直降”。如果活动是满减那就是整单券比价时要按“单件无券”计算或者明确标注“含券分摊价”。5.4 请求频率一高就被风控返回空白列表而不是报错现象跑前几个关键词还很正常跑第十个时results突然变成空列表程序没有抛异常你差点以为是数据本身有问题。原因两个平台都对高频请求有风控触发后接口直接返回空数据或者跳验证页不会给你一个明确的 403 状态码。无头浏览器的指纹特征太明显也比较容易被识别。解决每轮请求之间加随机等待间隔 2 到 5 秒同一个浏览器实例不要开太多并发 Tab如果发现空列表先手动打开页面看是否出现滑块验证。另外可以把user_agent设置成真实设备的 UA减少被风控误伤的概率。5.5 只有最新快照没有历史数据比价结论不可复现现象你今天看到得物 599、唯品会 639判定唯品会贵三天后唯品会降到 569你回头看昨天的记录发现当时自己只存了结果没有存明细完全不知道价格是怎么变化的。原因工具只存储最终对比结果没有保留中间抓取的商品价格快照。一旦页面刷新、接口改版原始数据就没了你无法解释“为什么结果是这个价”。解决每次采集把原始商品数据原样落库加上采集时间戳。宁可多存字段也不要只存处理后的价格。后面做历史低价监控时这些原始快照就是最可靠的底料。6. 进阶把比价工具做成每天跑一次的历史低价监控当你把两边的单次比价跑顺之后下一步就是加时间维度。我自己会把这套逻辑挂到定时任务里每天上午十点和晚上九点各跑一次把每次抓到的原始数据落进 SQLite。一旦坚持两周以上你就能看到一个比“今天哪里便宜”更有用的东西某件商品的最低到手价区间。建表时最基础的结构长这样CREATE TABLE price_snapshot ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_key TEXT, platform TEXT, product_id TEXT, title TEXT, price REAL, shipping_fee REAL, service_fee REAL, total_price REAL, created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX idx_item_time ON price_snapshot(item_key, created_at);item_key就是上一章做的“品牌:货号”主键total_price是归一化之后的到手价。每天跑任务时新快照追加写入不覆盖旧记录。查询最近三十天最低价就是用一条简单的分组聚合SELECT item_key, MIN(total_price) AS lowest_price FROM price_snapshot WHERE created_at datetime(now, -30 days) GROUP BY item_key;拿到这个结果后再把“当天采集价”和“三十天最低价”放在一起对比如果当天价就是历史最低说明是个不错的买入窗口如果当天价比最低价高出 50 以上就放进“等降价”观察列表。还有一个验证技巧我每次都会做人工抽二十条记录打开两个平台的前台页面对比我存的total_price和实际下单页的实付金额误差超过 5 元的就回查字段映射。这一步是笨办法但能拦住绝大多数“接口字段理解错了”的潜在问题。我自己最开始跑的时候就是因为没算得物的鉴定费导致一堆便宜结论是假的后来靠这个抽检流程把公式纠正过来。比价工具这东西数据口径对了才谈得上判断口径错了不如不比。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →