
简介这是基于Python与定向爬虫实现的商品比价系统毕业设计源码包面向计算机相关专业学生和爬虫入门开发者适合用于课程设计、毕业设计以及电商数据采集实战项目。压缩包共16个文件以Python脚本为主涵盖爬虫抓取、GUI交互界面和数据库操作等核心模块另含SQLite数据文件与说明文档整包约25KB结构紧凑便于快速部署和二次开发。资源已有37人学习下载对需要完整项目参考的人群有较高借鉴价值。读者可从源码中梳理定向爬虫采集、数据清洗与存储、比价结果展示的完整链路理解目标电商平台的页面解析与反爬应对思路同时借助清晰的模块划分和异常处理方式能够快速搭建自己的比价系统原型并进一步体会项目从需求分析到系统测试的落地过程。1. 比价系统的成败不在爬虫抓不到而在同款匹配和价格归一化提到“基于 Python 和定向爬虫的商品比价系统”很多人第一反应是“爬得越多、比得越准”。我在做第一版时就这么干过结果不是更准而是更乱同一个手机一个平台标题写“Apple iPhone 15 (A3092) 128GB 蓝色 双卡双待”另一个平台写“苹果 iPhone 15 128GB 蓝色 5G 手机A3092”直接按标题比较匹配率有 30% 就算不错。这套系统真正的技术重心不在爬虫而在“同款匹配”和“价格归一化”把不同平台对同一商品的各种写法归一成同一件商品再把原价、优惠、运费折算成同一个到手价。落地之后能解决的问题很具体给电商运营做价格监控、给学生项目做数据支撑、给选品团队看行情趋势。下面这条路径按“建模 → 抓取 → 匹配 → 存储 → 排错”的顺序讲重点放在能复现的代码和参数上。2. 先建模再写爬虫比价系统的数据来源与最小可运行原型2.1 先定义商品字段比价数据模型里每一列都在解决一个问题定向爬虫有的资料里叫聚焦爬虫和通用爬虫最大的区别不是用了什么框架而是“提前知道要什么”。通用爬虫会顺着链接一层层往外扩散爬大量无关页面定向爬虫拿到的是种子 URL 列表只抓固定的目标站点、固定的页面类型并且抽取字段时直接落到一张设计好的表结构上。这样做的优势是可控、低负载、解析规则可以按平台单独维护代价是每个平台都要单独适配页面一改版就得跟一次。我一般先列一张字段清单再写代码这张清单也是数据库表结构的雏形字段示例值在比价系统里解决什么问题product_fingerprintiphone15-128gb-blue跨平台匹配主键同款商品共用一个值platform_id1标识来源平台product_titleApple iPhone 15 128GB 蓝色原始标题保留排查依据spec_key128gb/blue规格签名同款匹配的辅助输入origin_price5999.00页面原价coupon_amount400.00优惠金额freight0.00运费包邮也要单独记录final_price5599.00最终到手价计算公式见第 3 章page_urlhttps://...详情页链接用于人工复核crawled_at2024-01-15 10:23:00数据新鲜度判断这里容易犯的第一个错是“把最终提取结果和原始数据混在一起”。页面上的“券后价”是促销模块的结果不是独立字段如果只存一个券后价一旦计算口径变了所有历史数据都得重算。我的做法是每一列都既是业务数据也是排查证据。final_price算错了我可以拿origin_price、coupon_amount、freight反推如果只存一个最终价就只能对着一个可疑数字猜。字段定了之后爬虫代码才会好写。所谓“定向”就是每个目标平台配一套自己的解析规则去哪个列表页找种子 URL、详情页的哪个节点放标题、价格在哪个元素里、优惠信息在哪个模块里。这些规则通常写成配置而不是散落在抓取代码里因为规则一定会变配置化之后每次只改配置不动主流程。2.2 定向爬虫的最小原型requests 加 BeautifulSoup 抓取详情页字段以单个商品详情页为例最小实现如下。这段代码假设平台把价格放在span.price节点里真实站点可能不同需要先打开开发者工具确认一次。import requests from bs4 import BeautifulSoup def fetch_product_detail(url: str) - dict: headers { # 常规做法是写一个通用的桌面浏览器 UA不要带“spider”字样 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36, } # timeout 必须显式设置防止某个慢页面把整个任务拖住 resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() # 有些站点是 gbk 编码不要依赖自动检测显式指定最稳 resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) title_node soup.select_one(div.sku-name) price_node soup.select_one(span.price) return { product_title: title_node.get_text(stripTrue) if title_node else , final_price: float(price_node.get_text(stripTrue)) if price_node else None, page_url: url, } if __name__ __main__: print(fetch_product_detail(https://example.com/item/10001.html))逻辑说明requests.get发起请求BeautifulSoup按 CSS 选择器抽取节点。select_one拿不到节点时返回 None所以每次都要判空不能直接调用get_text()否则一条页面改版就会让整个抓取任务中断。价格文本里常带“¥”“促销价”之类的前缀我在生产代码里会先做一次re.sub把非数字和点号去掉再转float否则float(¥59.9)会直接抛异常。参数说明timeout10是请求超时上限慢接口建议调到 1520 秒但不要超过 30因为一次重试会拖慢整批任务超时设太宽反而让故障不容易暴露。headers里的 User-Agent 按真实浏览器写目的是让服务端把你当成正常访客。resp.encoding在页面声明乱掉时一定要显式指定不然中文标题抓出来是一串乱码后面做同款匹配时全是无效输入。这段代码是单个页面的抓取还没到“系统”。真正落地时每个平台都会有一个类似的parse_detail(html) - dict函数主流程只负责调度和写库不关心某个平台的价格到底在哪个节点里。2.3 请求节流与失败重试三个必调参数让爬虫活得更久爬虫写好后比价系统真正在线上跑的时候出问题最多的反而是最简单的“请求间隔、重试、超时”。我把它们做成三个必调参数放在配置里全局生效# config.py 或 config.json两个文件选一个json 更适合不改代码就调整 CRAWL_SETTINGS { request_interval: 2.5, # 每次请求之间至少间隔 2.5 秒 retry_times: 3, # 失败后最多重试 3 次 retry_backoff: 1.5, # 重试间隔按 1.5 倍递增 batch_size: 20, # 每批只处理 20 个商品处理完先写库 max_error_count: 10, # 单批连续失败 10 次就停止该批任务 }在请求函数里这三个参数这样生效time.sleep(request_interval)放在下一次请求之前遇到网络错误或非 200 状态时按retry_backoff递增等待时间第一次等 1.5 秒第二次等 2.25 秒第三次等 3.375 秒一批商品抓完后先写库、做一次简短停顿再进入下一批。request_interval是保护自己的关键参数。单机、单 IP、单线程情况下23 秒的间隔足够大多数公开商品页接受如果稳定出现 4xx 或验证类响应不是把间隔调大到 10 秒就能解决的而是先检查是不是请求了不该碰的接口。batch_size决定失败影响的粒度每批 20 个商品处理完就写库任何一批出错都不会推倒重来。max_error_count是我后来加的因为发生过“某个页面反复超时整个 while 循环卡在重试里日志刷了几万行”的情况。定向爬虫的意义就在这宁可慢不能乱。3. 商品同款匹配与价格归一化把“看起来像同一件”变成“确实是同一件”3.1 标题完全相同为什么是伪命题比价系统的第一关是抓取第二关是同款匹配。我见过不少项目里直接用“标题完全一致”做匹配结果匹配率低到怀疑人生。因为平台标题不是数据是文案。同一个商品在不同平台几乎不会出现两个一模一样的标题有加品牌自营标识的有把容量和颜色顺序换掉的有全角括号、半角括号混用的还有把“5G 手机”这种通用词放在前还是放在后的区别。平台 AApple iPhone 15 (A3092) 128GB 蓝色 移动联通电信5G手机 双卡双待 平台 B苹果 iPhone 15 128GB 蓝色 5G 手机A3092这两行文本里真正能用来判定同一件商品的是“品牌 型号 容量 颜色 网络制式”这些关键规格。其余部分是平台加的营销词和通用描述。所以匹配的第一步不是直接比较整条标题而是从标题里抽规格、做归一化再比较归一化后的结果。3.2 型号归一化与相似度匹配一段可直接复制的 Python 实现归一化的目标是把不稳定的写法变成稳定写法全角转半角、括号统一、英文品牌保持小写、规格词排序、去掉“正品、官方、旗舰店、包邮、全新”这类噪音词。一个够用的实现如下import re STOP_WORDS {官方, 旗舰, 正品, 全新, 包邮, 自营, 移动联通电信, 双卡双待, 5g手机} def normalize_title(title: str) - str: # 全角括号转半角统一大小写再去掉所有空格 text title.lower().replace(, ().replace(, )) text text.replace( , ) # 去掉噪音词 for w in STOP_WORDS: text text.replace(w, ) # 只保留中文、英文、数字其余标点全部去掉 text re.sub(r[^\w\u4e00-\u9fa5], , text) return text def match_same_product(t1: str, t2: str) - bool: n1, n2 normalize_title(t1), normalize_title(t2) if not n1 or not n2: return False # 优先做包含判定一方完全包含另一方且长度差较小 longer, shorter (n1, n2) if len(n1) len(n2) else (n2, n1) if shorter in longer and len(longer) - len(shorter) 6: return True # difflib 是标准库不需要额外安装 from difflib import SequenceMatcher return SequenceMatcher(None, n1, n2).ratio() 0.88 # 跑一下上面两个标题 t1 Apple iPhone 15 (A3092) 128GB 蓝色 移动联通电信5G手机 双卡双待 t2 苹果 iPhone 15 128GB 蓝色 5G 手机A3092 print(normalize_title(t1)) print(normalize_title(t2)) print(match_same_product(t1, t2)) # 期望输出 True逻辑说明normalize_title先统一括号和大小写再剔除噪音词。match_same_product先做包含判定再做相似度兜底。包含判定解决的是“规格顺序不同但文本几乎相同”的情况相似度阈值 0.88 解决的是“个别营销词不同”的情况。参数说明阈值 0.88 不是拍脑袋定的是拿两个平台的实际标题跑了一批样本之后取的值同一商品的相似度集中在 0.95 以上不同商品集中在 0.6 以下中间留出了 0.850.95 的模糊区间。如果你匹配的是标准化程度高的 3C 数码阈值可以放到 0.85 并加一道规格抽检如果是服装这种规格词模糊的类目0.88 会产生不少误判建议先做规格抽取把“颜色 尺码”拼成spec_key再让相似度只作用在标题主干上。任何时候都不要省略“先判空”页面偶尔抓到空标题空字符串和任何字符串算相似度都是 0结果就是那一条商品永远匹配不上看起来像没抓到其实是匹配模块在空数据上翻车了。这段代码的局限也很明显它判断不出“型号不同、文字相似”的商品比如iPhone 15和iPhone 15 Pro归一化后文本高度重合相似度可能超过阈值。解决这个问题需要在归一化阶段就把型号抽出来比较通常的做法是维护一份型号正则表例如re.search(riphone\s?\d\s?pro?, n1)再把型号加入spec_key参与匹配。比价系统里最怕的不是“匹配不上”而是“匹配错”宁可漏配不要错配。3.3 价格归一化原价、券后价、运费分开存比价才不会失真同款匹配解决的是“是不是同一件”价格归一化解决的是“多少钱才是真的到手价”。我见过最多的问题是拿页面上的“原价”当最终价格结果比价结果里全是虚高价用户一打开详情页就发现不对。把手价统一折算成一条公式final_price origin_price - coupon_amount freight。定向爬虫抓页面时把价格链路拆开存origin_price原始价、coupon_amount优惠金额、freight运费、final_price到手价是否包邮单独标记。这里有几个容易忽略的点。一是“满减”通常需要在促销模块里单独解析不能只取一个券后价因为有的优惠对特定规格不生效。二是“包邮”不等于运费为 0很多店铺对偏远地区包邮条件不一样但页面默认展示的运费是 0这个要结合收货地址参数去看如果拿不到就按商家展示为准同时打一个shipping_unknown标记。三是有的平台把“预约价”“拼团价”放在活动模块而不是详情模块爬虫如果只抓详情模块拿到的就是错误的原价。我的习惯是宁可一个商品到手价显示“待确认”也不要拿一个错误价格去比价。比价系统的口碑建立在“我报的价格基本准确”上数据有缺失可以更新算错则会让用户彻底失去信任。所以字段设计里final_price允许为 NULL不做默认值展示层遇到 NULL 就显示“待确认”而不是用origin_price偷偷顶上。4. 数据落地与查询一套 SQLite 表结构让比价系统先跑起来4.1 三张表的字段设计与索引数据量没到百万级之前用 SQLite 做落地就够了。它的优势不是性能而是“一个文件、零部署、备份就是复制一个文件”。线上跑比价系统最怕的不是慢而是改表字段要迁库SQLite 在这方面反而让我少踩很多坑。表结构按功能拆三张-- 商品主体表一个 fingerprint 对应一个跨平台商品 CREATE TABLE product ( id INTEGER PRIMARY KEY AUTOINCREMENT, fingerprint TEXT, -- 同款匹配后回填允许为空 title TEXT NOT NULL, spec_key TEXT, category TEXT, UNIQUE(fingerprint, spec_key) ); -- 平台在售信息表同一商品在每一个平台的快照一商品一平台一行 CREATE TABLE sku_offer ( id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, platform_id INTEGER NOT NULL, origin_price REAL, final_price REAL, -- 允许 NULL表示待确认 freight REAL, coupon_amount REAL, page_url TEXT, crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(product_id, platform_id), FOREIGN KEY (product_id) REFERENCES product(id) ); -- 历史价格表每次抓取都追加一行是画价格曲线的数据源 CREATE TABLE price_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, sku_offer_id INTEGER NOT NULL, final_price REAL NOT NULL, crawled_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (sku_offer_id) REFERENCES sku_offer(id) );逻辑说明product表管“商品是什么”sku_offer表管“在哪卖、卖多少”price_log表管“价格怎么变”。sku_offer上的唯一索引UNIQUE(product_id, platform_id)确保同一个平台对同一个商品只保留最新一条在售快照价格变化写入price_log。price_log只追加、不更新、不删除方便做趋势分析和误判回查。调参说明fingerprint在第一次匹配前是空值空值和空值之间不参与唯一约束这意味着“多个不同商品都为空指纹”时UNIQUE(fingerprint, spec_key)拦不住重复。我的做法是先让fingerprint允许 NULL抓取结束跑一轮“指纹回填”回填前清理空指纹把空指纹的记录单独列出来人工看。SQLite 默认外键检查是关闭的建表后执行一次PRAGMA foreign_keys ON;否则误删父记录时子表会出现孤儿数据。为什么不直接用 MySQL单机比价系统的主要吞吐在“定时抓取 查询”SQLite 在几百 GB 内都够MySQL 需要服务进程、账号权限、备份策略部署成本一下子变高。等数据量真的涨到需要并发写的时候再把sku_offer和price_log两张表迁到 MySQLproduct表留在 SQLite 也问题不大因为它是低频读写的参照表。4.2 用一条 SQL 算出每个商品的“全网最低价”比价查询的核心是“找出每个 fingerprint 下final_price最低的那条平台记录”。直接用GROUP BY加MIN()拿不到完整的平台信息我习惯用窗口函数WITH ranked AS ( SELECT p.fingerprint, p.title, s.platform_id, s.final_price, s.page_url, ROW_NUMBER() OVER ( PARTITION BY p.fingerprint ORDER BY s.final_price ASC -- 同一个商品按到手价排序 ) AS rn FROM product p JOIN sku_offer s ON p.id s.product_id ) SELECT fingerprint, title, platform_id, final_price, page_url FROM ranked WHERE rn 1 -- 只保留每个商品的最低价 ORDER BY final_price ASC;逻辑说明窗口函数ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)对每个 fingerprint 内的记录按价格升序编号rn 1就是该商品的最低价格行之后再按全网最低价升序输出。这个查询一次完成“取最低 带上最低价对应的平台和链接”不用子查询回表两遍SQLite 3.25 及以上版本支持窗口函数。如果没有窗口函数可用退化成GROUP BY fingerprint配合MIN(final_price)也能出结果但要额外自连接才能拿到平台链接查询会慢一些。我的建议是直接用窗口函数它在排错时也更好读rn 1的记录就是非最低价可以单独拿出来看价差。4.3 定时增量更新只抓需要更新的商品第一个能跑的版本不需要用调度框架一个while True加time.sleep的入口就足够。系统抓取的主循环代码如下import json import time from crawler import fetch_product_detail from storage import upsert_price, get_stale_products def main(): cfg json.load(open(config.json)) while True: # 只取出超过配置时间没有更新过的商品 stale_list get_stale_products(max_agecfg[crawl_interval]) for item in stale_list: record fetch_product_detail(item[page_url]) upsert_price(item[id], record) time.sleep(cfg[request_interval]) time.sleep(60) # 每轮之间等待一分钟防止空轮高频访问 if __name__ __main__: main()逻辑说明get_stale_products负责从数据库里找出crawled_at距今超过crawl_interval的商品这样不会每次都重抓所有商品而是按需增量更新。upsert_price写入当前价格并在price_log里追加一条历史。这个主循环把定时系统拆成了“查过期数据 → 单条抓取 → 单条写库”三个可以单独验证的环节比在一个大循环里抓完再批量写库更容易排错。config.json最小结构长这样{ crawl_interval: 21600, request_interval: 2.5, batch_size: 20 }crawl_interval单位是秒21600 表示 6 小时更新一次。价格敏感的商品可以缩短到 3600非敏感商品可以放到 43200这个值直接影响目标站点的请求压力不要为了“数据新鲜”把间隔设得太短。增量更新能正常工作的前提是所有抓取都带crawled_at时间戳并且中断重启后从数据库状态继续而不是从头再来。我吃过一次亏最初把stale_list设计成“按关键词搜索返回的新商品列表”导致每天抓的是新商品而不是已跟踪商品价格历史完全断档。所以这个循环的核心逻辑应该是“先读库拿更新列表再请求新链接”而不是“先请求再判断要不要入库”。5. 避坑专项比价系统真实运行中常见的 5 个翻车点5.1 页面改版导致解析全空现象昨天还正常抓取的站点今天跑完所有商品都是空标题、空价格数据库里全是空值日志没有报错。原因目标平台前端改版把价格节点从span.price挪到了某个div里或者给节点加了动态class。定向爬虫的优势是解析稳定劣势是“一改全废”。解决解析规则配置化选择器放配置而不是写在代码里每批抓取后做一次“字段完整率”校验标题完整率低于 90% 就自动告警并停止覆盖该站点的数据。页面改版不可预测能预测的是“一定会改”。我自己的习惯是每次改版时保留上一版解析配置出问题时一键回退这个习惯帮我省掉了不止一次手工返工的麻烦。5.2 请求返回 200 但价格字段为空现象HTTP 状态码正常页面也抓到了唯独价格节点是空的。浏览器里打开明明有价格。原因价格被放在页内 JavaScript 动态加载的接口里初始 HTML 只有商品主图、标题、描述价格是页面脚本再异步请求一个价格接口后渲染出来的。用requests拿到的原始 HTML 里没有这个价格数据。解决打开浏览器的开发者工具翻 XHR 请求列表找到价格接口直接请求这个公开接口替代解析 HTML如果接口带签名参数看返回的 HTML 里是否内嵌了一段window.__INITIAL_STATE__之类的 JSON里面有完整的初始数据。两个方案都拿不到时才把价格标记为“待二次确认”而不是凭空猜测。无头浏览器能兜底但不优先它带来的 CPU、内存开销和等待时间会在批量抓取时被无限放大这也是很多人从这个坑跳进下一个坑的原因。5.3 响应里出现验证页程序“假装成功”现象请求返回 200解析出的内容是一段验证脚本或安全提示文案字段完整率骤降但程序没有报错。原因目标站点把该访问行为识别为高频访问返回给客户端的是一个验证页而不是商品页。验证页的状态码也可能是 200所以“没报错”不等于“抓到了”。解决第一优先级是降低请求频率和同轮内对同域名的并发数定向爬虫单机单线程通常足够第二优先级是检查是不是把必要的公开访问参数少带了比如某些站点不携带基础 Cookie 时不展示价格。页面返回时加一个“校验断言”如果解析结果里没有标题也没有价格节点直接记日志并暂停该站点的任务不要继续往下写空数据。这里不建议为了“通过验证”去走各类绕过手段合规、低频、按公开规则访问才是能长期跑的策略。5.4 原价当成到手价比价结果没有参考价值现象比价结果里价格普遍偏高用户去实际页面看一眼发现实际到手价比系统报的低不少。原因优惠券、满减、会员价在详情页的不同模块有的甚至要用户手动领券后才展示。“价格”不是一个值而是一套促销规则。解决在解析层就拆多个字段原价、促销价、优惠金额、运费。促销价能取到就取促销价取不到就保留原价并标记promotion_unknown。比价展示时把“最终价”和“原始价”两列都显示出来让用户看到系统没有乱算。页面如果同一 SKU 有多个促销券后价、满减价按“到手价最低”原则计算并把计算公式对应的文案原样存下来方便日后排查。5.5 同款商品重复入库比价列表里出现两条“最低价”现象同一个商品在结果页出现两次一个指纹是空、一个指纹是乱码价格可能还不一致。原因关键词搜索返回了同一商品的多个链接或者同一链接被不同关键词各抓了一遍但初次入库时 fingerprint 还没回填唯一索引没有拦住。解决入库前先做一步“链接去重”同一page_url只保留一条记录抓取结束后再跑一次“指纹回填”任务将空指纹按照第 3 章的匹配结果更新。sku_offer表建唯一索引回填指纹时如果碰到UNIQUE(product_id, platform_id)冲突做UPDATE而不是INSERT。如果这段逻辑没写对会出现一种很隐蔽的现场比价结果显示这个商品的最低价是昨天的但数据库里明明是今天抓的——因为新记录没有更新旧记录而查询时取到了旧数据。这个现象排查起来非常费时间我后来在抓取入库函数里加了“先按page_url查再决定 INSERT 还是 UPDATE”的固定逻辑才算根治。6. 可信比价的验证方法从“能跑”到“敢用”的三步收尾一套比价系统写完只有“能跑”是不够的。线上跑两周后确定它准不准我通常做三个验证动作。第一个动作是“抽检脚本”从价格表里随机取 20 个商品人工打开详情页核对“最终到手价”是否与系统一致。这个动作不要只在开发期做一次建议每两周做一次重点看相似度阈值有没有因为页面改版而悄悄退化。import random import sqlite3 conn sqlite3.connect(price.db) rows conn.execute( SELECT id, page_url, final_price FROM sku_offer ORDER BY RANDOM() LIMIT 20 ).fetchall() for row in rows: print(row) # 逐个打开 page_url人工核对 final_price第二个动作是“价格曲线”从price_log里按商品拉出历史价格用 Python 简单的列表绘图就能画出一条趋势线并做一个“比最新价低 5% 就提醒”的最小逻辑。这一步不需要引入消息队列一个sqlite3查询加一段if price threshold的判断就够了。它能验证的不只是“数据有没有抓对”还有“这套比价系统有没有持续提供价值”的真实使用场景。第三个动作是把“字段完整率”纳入每次抓取的日志输出标题、价格、运费三个关键字段任何一个为空整条记录标黄。线上跑三周后统计标黄率超过 5% 说明解析规则或请求频率需要调整而不是去改匹配阈值。做这套系统两年我最大的教训是早期把最终价格直接存在product表里。后来想画历史价格曲线时才发现过去的数据已经被覆盖只能从头回填price_log。这个坑让我养成了一个习惯先想清字段会不会被覆盖再写 create table——宁可多一张日志表也不让历史消失在覆盖里。这个系统值不值得做我的答案是值得但一定要先做匹配和后做爬虫一样重要数据可靠了比价结果才有人敢用。希望这套“先建模、再做同款匹配、最后用日志表验证”的思路帮到你。本文还有配套的精品资源点击获取