1. 这个项目要解决的现实痛点:手动比价的无力感
先说一个我自己的真实体验。前两年想换一台笔记本,在某东、某宝、某多多之间来回切了十几个页面,把型号、配置、价格抄在备忘录里对比。同一款机器,不同平台差价能拉到五百块以上,加上优惠券、满减、分期免息这些规则叠加,最后根本分不清哪个平台才是最划算的。折腾了一下午,最后凭感觉下了单——结果第二天就看到另一家平台降价。
这种体验我相信绝大多数网购的人都经历过。而作为计算机专业的学生,我当时的第一反应是:这种重复、机械、耗时的事情,就该交给程序去做。于是就有了这个毕设项目——基于爬虫的全网自动比价系统。
这个系统的核心目标很简单:定时去各个电商平台抓取指定商品的名称、价格、店铺、销量、评价数等信息,清洗后统一存入本地数据库,然后通过一个Web页面展示同一款商品在不同平台的实时价格对比,按价格从低到高排序,让用户一眼就能看出该去哪儿买。
对于一个毕业设计来说,这个选题有几个很实在的好处:
- 技术覆盖面广:网络爬虫、HTML解析、反爬应对、数据清洗、数据库设计、Web前后端开发,几乎把大学四年学的核心课程都串起来了。
- 需求真实存在:答辩时评委问"你这个项目有什么用",你可以直接拿出真实使用场景来回答,而不是说"为了完成作业"。
- 可扩展性强:比价只是表象,底层是"结构化采集电商数据"的能力。做完比价系统,换个前端展示就能变成舆情监控、价格监测、选品分析工具。
这篇内容我会从一个实际做完这个项目的角度,把整个系统从架构设计到具体实现、从踩坑记录到答辩经验完整过一遍。不管是打算直接用这套思路做毕设,还是单纯想学爬虫和全栈开发的实战技能,应该都能从中拿到点东西。
2. 系统架构与技术选型:先想清楚再动手
很多同学做毕设的通病是拿到题目就急着写代码,结果写了一半发现数据库表结构不合理,推翻重来;或者爬虫被封了不知道怎么处理,项目卡死。我建议第一步先花一两天做架构设计和技术选型,把这些关键决策想清楚。
2.1 模块划分与数据流转
整个系统我拆成了四个核心模块:
| 模块 | 职责 | 关键技术点 |
|---|---|---|
| 采集层 | 定时抓取目标平台的商品列表页或详情页 | Requests / Scrapy,XPath / BeautifulSoup,动态渲染处理 |
| 数据处理层 | 清洗、去重、结构化,提取有效字段 | Pandas / 正则,标题匹配算法,商品ID归一化 |
| 存储层 | 商品信息、价格历史、采集日志持久化 | MySQL(主存储)+ Redis(缓存) |
| 展示层 | 比价查询、价格排序、历史价格曲线 | Flask / Spring Boot,ECharts,BootStrap 或 Vue |
数据流向是:采集层拿到HTML或JSON → 解析出结构化字段 → 清洗去重 → 写入MySQL → 展示层读取MySQL → 用户在页面上看到比价结果。中间用Redis缓存热点商品的查询结果,避免频繁读写MySQL造成响应变慢。
2.2 语言和框架怎么选
这个项目我是用Python实现的,理由是:
- Python写爬虫几乎是生态最成熟的,Requests、Scrapy、BeautifulSoup、lxml、Playwright这些库开箱即用,遇到问题搜索引擎随便一搜就是解决方案。
- 数据处理方便,Pandas对DataFrame的操作比Java手写一堆集合转换逻辑省事得多。
- 毕业设计场景下,开发效率比运行性能更重要。比价系统对实时性要求不高,采集频率控制在分钟级完全够用,Python的性能瓶颈根本感知不到。
当然,如果你对Java更熟,用Spring Boot + HttpClient + Jsoup + MyBatis也能实现同样的功能,架构思路完全一致,只是底层库换一下。我不建议在毕设里同时用两套语言——前后端都用Python(Flask + Jinja2模板)反而最简单,少一层跨语言联调的麻烦。
2.3 数据源选型:不要贪多,先跑通两个平台
很多人在"全网"两个字上纠结,觉得非得抓十几个平台才叫全网。我的建议是:选1-2个数据源跑通全流程,剩下的作为扩展点写进论文和答辩PPT里。选数据源的标准有三条:
- 页面结构相对规整:列表页和详情页有清晰的HTML结构,便于XPath或CSS选择器定位。
- 公开可访问:不需要登录(或者只做简单的Cookie模拟)就能看到价格。
- 商品覆盖重合度高:同一个品牌型号的商品在多个平台都有售,这样才能有比价的意义。
我当时选了三个平台:某东、某宝和某宁。某宝的反爬相对严格,后面会专门讲踩坑经历。某东和某宁的搜索页结构比较规整,商品信息直接嵌在HTML里,解析难度低,适合作为第一版跑通的数据源。
3. 爬虫采集层:核心代码与关键决策
采集层是整个系统的基础,这一层出问题,后面的数据就是无源之水。我会把采集时的关键代码逻辑拆开讲,重点是解析策略和调度策略。
3.1 列表页解析:XPath与CSS选择器的取舍
我用的是Requests + lxml的XPath方案。以某东的搜索页为例,搜索"iPhone 15 128G"返回的HTML里,每个商品项都包在li.gl-item这个节点里,关键字段的提取逻辑大致是这样:
import requests from lxml import html def fetch_search_page(keyword): url = "https://search.jd.com/Search" params = {"keyword": keyword, "enc": "utf-8"} headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.encoding = "utf-8" return resp.text def parse_jd_items(html_text): tree = html.fromstring(html_text) items = [] # 商品列表节点 for node in tree.xpath("//li[contains(@class, 'gl-item')]"): title = node.xpath(".//div[contains(@class, 'p-name')]//em/text()") price = node.xpath(".//div[contains(@class, 'p-price')]//i/text()") shop = node.xpath(".//div[contains(@class, 'p-shop')]//a/text()") item = { "title": "".join(title).strip() if title else "", "price": float(price[0]) if price else None, "shop": shop[0].strip() if shop else "", "url": "https:" + node.xpath(".//div[contains(@class, 'p-img')]//a/@href")[0] if node.xpath(".//div[contains(@class, 'p-img')]//a/@href") else "" } items.append(item) return items有几个细节值得说明:
第一,XPath的//em/text()为什么用em标签?因为电商页面的搜索词在标题里会被加粗,加粗实现往往用的是<em>标签包裹关键词。直接取em的子文本,拿到的就是用户搜索的那部分,当然实际开发中还是要拼接整个标题的完整文本,我这里是示意。
第二,价格字段要处理"暂无报价"的情况。很多商品在无货时会显示"暂无报价",解析出来的i标签文本不是数字,float()转换直接抛异常。所以必须加异常处理,或者用正则re.search(r"\d+\.?\d*", text)先提取数字部分再转换。
第三,编码问题。Requests的resp.text会用header里声明的编码去解码,但有些页面返回的header没有charset,默认就变成了ISO-8859-1,中文直接乱码。保险做法是resp.encoding = resp.apparent_encoding,或者直接指定"utf-8"。
3.2 动态渲染页面的处理:从Selenium到Playwright
某宁的搜索页一开始我用Requests去抓,结果返回的HTML里商品列表是空的——因为它的数据是Ajax动态加载的。这时候有两条路:
- 直接从浏览器DevTools的Network面板里找到XHR接口,看返回的JSON结构,用Requests直接请求接口拿数据。
- 用Selenium或Playwright模拟浏览器操作,等页面渲染完再取HTML。
接口直连这条路更高效,但难点在于接口URL的参数加密和签名。某宁的搜索接口有个_st参数,是用当前时间加盐做MD5生成的,初始token藏在首页的某个JS文件里。我当时逆向了一圈,发现这个签名算法会定期变化,维护成本太高,就放弃了。
最后还是用Playwright解决了。选Playwright而不是Selenium的原因很简单:Selenium需要额外下载对应版本浏览器驱动,而且WebDriver会被部分站点的JS检测(通过navigator.webdriver属性判断);Playwright自带浏览器内核管理,API设计也更现代化,反检测能力相对好一些。
from playwright.sync_api import sync_playwright def fetch_suning_page(keyword): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64)...", locale="zh-CN" ) page = context.new_page() url = f"https://search.suning.com/{keyword}/" page.goto(url, timeout=30000) page.wait_for_selector(".product-box", timeout=15000) html_text = page.content() browser.close() return html_text这里强调两个经验:
headless=True虽然开销小,但很多站点会识别无头浏览器特征,导致返回验证页。如果遇到这种情况,先切成headless=False看能不能正常访问,再考虑加启动参数--disable-blink-features=AutomationControlled。- 等选择器比固定sleep要可靠。
page.wait_for_selector(".product-box")是等到商品卡片节点出现才继续,比time.sleep(5)硬等更稳,也不会因为网速波动白白浪费等待时间。
3.3 采集频率与调度策略:别把自己玩进去
爬虫写得再漂亮,频率控制不好一样会被封。我第一版图省事,用了一个while True循环每分钟抓一次,结果跑了两个小时IP就被某东临时限制了,所有请求都返回滑块验证页。
后来我改成了随机延迟 + 定时任务的组合策略:
- 每次请求前随机sleep 2到5秒,让请求间隔看起来像人工操作。
- 同一个会话里,每次请求从预置的User-Agent池里随机取一个,避免固定UA被标记。
- 使用
APSchedule库做分钟级定时采集,而不是无限循环。比如每30分钟采集一次比价列表,每次采集完正常退出进程。
from apscheduler.schedulers.blocking import BlockingScheduler import random, time def crawl_job(): for keyword in TARGET_KEYWORDS: html_text = fetch_search_page(keyword) items = parse_jd_items(html_text) save_items_to_db(items) time.sleep(random.uniform(2, 5)) scheduler = BlockingScheduler() scheduler.add_job(crawl_job, 'interval', minutes=30) scheduler.start()注意:不管是毕设还是日常项目,爬虫采集都要尊重目标网站的robots协议和服务条款,控制合理频率,只在公开数据范围内做采集,不要绕过登录鉴权去拿非公开数据。这个系统里的采集逻辑仅用于技术学习和原型演示,上线商用前必须评估合规风险。
4. 数据处理与存储设计:比价系统真正的含金量
爬虫把数据抓回来只是第一步。原始HTML里的数据是脏的、重复的、格式不统一的——这边标题是"Apple iPhone 15 (A3092) 128GB 蓝色",那边可能就变成"苹果15手机128G 蓝色 全新正品"。如果直接拿这些原始字符串去做比价,连"同一款商品"都匹配不上,更别说比价了。
4.1 数据清洗:正则、分词、归一化逐层处理
我的清洗管线分了三层:
第一层是基础清理:去掉HTML实体(&)、空白字符、特殊符号,把全角字符转半角,统一大小写。这些用Python的html.unescape和re.sub一次搞定。
第二层是型号提取:从标题里抽出核心型号标识。思路是先定义一个品牌和型号的正则模式库,比如iPhone的型号规律是"iPhone 15|iPhone 15 Pro|...",笔记本类是"拯救者|ThinkPad X1 Carbon等",然后逐个匹配。匹配不上就退化到编辑距离模糊匹配。
第三层是别名归一化:建立同义词映射表,把"苹果"→"Apple","15"→"15","128G"→"128GB","天蓝色"→"蓝色"。这个映射表一开始是手工维护的,后面我找了个取巧办法——直接用品牌官网的型号命名作为标准名,再把抓来的标题往标准名上映射,省了不少功夫。
4.2 数据库表设计:商品表与价格表分离
比价系统最核心的两张表是商品信息表和价格记录表。价格一定要单独建表,而不能只在商品表里存一个当前价格字段。原因很直接:——比价系统必然要支持"历史价格趋势"功能,如果没有独立的价格流水表,历史上某天卖多少钱这个信息就永久丢失了。
CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_key VARCHAR(64) NOT NULL COMMENT '统一商品标识,平台+型号MD5', brand VARCHAR(32), model VARCHAR(64), title VARCHAR(255), category VARCHAR(64), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_product_key (product_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE price_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, platform VARCHAR(32) NOT NULL, price DECIMAL(10,2) NOT NULL, shop_name VARCHAR(128), item_url VARCHAR(512), crawl_time DATETIME NOT NULL, KEY idx_product_time (product_id, crawl_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个product_key字段是整个表设计的灵魂。它是"统一商品标识",由平台代码+标准化后的品牌型号拼接后做MD5得到。同一个iPhone 15,不管从哪个平台抓来的,清洗后得到的model都是"iPhone 15 128GB",拼上Apple变成APPLE_iPhone 15 128GB,MD5完全一致,所以能关联到同一行product_id。比价功能本质上就是查同一product_id下所有price_record表记录。
4.3 增量更新与定时任务
为了避免每次采集都全量重抓、全量写入,我把采集逻辑设计成增量模式:
- 每次抓完先查数据库,看
product_key是否存在,存在则只往price_record表插价格,不动商品表。 product_key不存在说明是新商品,先插商品表再插价格表。- Redis里存一个"最近采集的商品Key列表",展示层查比价时优先读Redis,命中失败再查MySQL。
增量模式的好处是数据库不会越积越臃肿,价格历史表可以保留长期记录,而商品表的行数基本稳定。
5. 比价核心逻辑:从"价格排序"到"同款识别"
数据存进去了,比价怎么做?很多人的第一反应是SQL查一下价格排序就完了。但实际业务里最大的坑在于:不同平台抓回来的商品标题五花八门,怎么判断它们是同一款商品?
5.1 同款商品匹配:三步递进策略
我的匹配策略分三级,按优先级逐级推进:
第一级:标准型号精确匹配。清洗阶段如果能从标题里提取出标准型号,比如"iPhone 15 128GB",那直接按型号匹配,准确率最高。问题是有些商家标题里不写型号,只写"全网通 5G 双卡双待",这一级就会漏掉。
第二级:标题相似度匹配。用编辑距离算法(Levenshtein Distance)算两个标题的相似度,超过阈值(我实验下来阈值为0.75比较合理)就认为是同款商品。这个算法对"iPhone 15 (A3092) 128GB 蓝色"和"苹果iPhone15 A3092 128G蓝"这类不同措辞但有相同核心词的标题效果很好。
第三级:价格区间辅助验证。如果标题相似度不够,但两个商品的价格差在5%以内,而且品牌、类目字段一致,也标记为"疑似同款",在界面上提示用户自行确认。
def calc_similarity(title_a, title_b): from difflib import SequenceMatcher return SequenceMatcher(None, title_a, title_b).ratio() def match_same_product(item_a, item_b): model_a = normalize_model(item_a["title"]) model_b = normalize_model(item_b["title"]) if model_a and model_a == model_b: return True similarity = calc_similarity(item_a["title"], item_b["title"]) if similarity >= 0.75: return True return False5.2 比价结果的用户体验设计
页面端我用的是简单的Bootstrap表格 + ECharts价格折线图组合。比价结果按价格升序排列,每行展示平台名称、店铺、价格、销量、跳转链接,顶部的筛选按钮可以按品牌、价格区间、平台过滤。
比较实用的是历史价格趋势这个小功能。点击某个商品后,ECharts会展示它最近30天的价格曲线,用不同颜色区分平台,鼠标悬浮显示具体日期和价格。这个功能对用户决策帮助很大——有些平台日常价虚高但经常搞大促,曲线图能看出真实价格水位,这也是我在答辩时重点展示的一个亮点。
5.3 查询接口设计与性能优化
展示层用Flask写了一个简单的RESTful接口,核心查询是:
@app.route("/api/compare") def compare(): model = request.args.get("model") products = query_products_by_model(model) records = query_latest_price_records(products) records.sort(key=lambda x: x["price"]) return jsonify({"items": records})这里有个性能小陷阱:如果不加缓存,每次用户点击查询都会去MySQL里做一次全表关联查询,入库数据量大了之后响应时间会飙升到好几秒。我的处理方式是把"最近一次采集结果"缓存在Redis里,key为compare:商品Key,TTL设置15分钟。这样同一个商品15分钟内的比价查询直接走Redis,实测响应时间从3秒降到200毫秒以内。
6. 从实战中踩出来的坑与经验
做这个项目踩过的坑不少,下面这些教训对后续没有相关经验的人来说尤为值钱。
6.1 某宝商品列表页的"HTML隐藏字段"陷阱
某宝的搜索页和移动端页面渲染出来的HTML是经过JS动态改写的,直接抓取返回的内容,看起来字段齐全,但"价格"和"销量"两个字段经常是不真实的占位符。
我当时调试了整整一天,最后在DevTools里逐段对比发现:淘宝的搜索页HTML里藏着一份window.__INITIAL_STATE__的JSON数据,里面是完整的商品信息。解析这个JSON比解析HTML更可靠,而且数据是结构化的,不用再做HTML标签清洗。
这个经验也适用于其他平台:先打开浏览器DevTools看Network里的真实数据接口,再考虑HTML解析;能用JSON接口就拿JSON,拿不到再退而求其次解析HTML。
6.2 Cookie与登录态失效问题
某宁的搜索接口虽然不需要登录,但每次访问都会下发一个新的Cookie,间断性的让服务端生成一个临时的sn_check参数,这个参数与Cookie绑定。我自己手写的Requests代码里没有正确携带Cookie,导致经常被重定向到验证页。
解决方案是:用Playwright有头模式启动一次浏览器,手动完成搜索拿到完整的CookieJar,导出后用Requests的requests.utils.cookiejar_from_dict加载。这样既保留了解析的便利性,又解决了Cookie不完整的问题。
类似的问题在爬虫项目中非常普遍,所以这块经验我建议所有做毕设的同学都提前了解,不要等到项目中期才发现"哎,怎么刚才还能抓,现在就抓不了了"。
6.3 反爬应对策略的前端视角
关于防爬虫,我还研究了前端侧的一些防御手段,这对写论文和应付答辩都很有帮助。比如:
- 前端加载时注入
navigator.webdriver检测,用JS判断浏览器是否被自动化控制。 - 高频请求的IP限流,配合封禁策略。
- 对关键数据做字体反爬——页面上的数字用自定义字体渲染,直接抓HTML拿到的不是真实数字。某团的评价数就是这么处理的。
理解这些反爬策略的底层逻辑后,你在设计自己的爬虫时就会更有针对性。比如检测到字体反爬时,要下载页面上的WOFF字体文件,解析字符映射关系,把渲染后的字形还原成数字。
6.4 合规边界:毕设展示和论文撰写怎么处理
我最后想提醒大家的是合规问题。爬虫技术本身是中性工具,但采集行为的边界必须清楚。在这个毕设项目里,我坚持了三条底线:
- 只采集公开页面数据,不登录、不绕过权限验证。
- 遵守robots协议,robots.txt里明确禁止抓取的路径直接跳过。
- 控制请求频率,不给目标服务器造成压力,数据仅用于学习展示,不做商业用途。
这三条原则在论文里明确写了出来,答辩时也如实向评审老师说明。技术方案是完整的,但落地使用前要接受法律和商业层面的审视——这样去表达,比单纯强调"我用爬虫抓了某平台数据"要专业得多。
7. 系统演示与答辩准备:从技术方案到能过审的设计
代码写完不代表毕设完成了,还有两个环节很关键:系统的可演示性和答辩时的表述逻辑。我在预答辩阶段吃过亏——评委问"这个系统有什么创新点",我支支吾吾说不出来。
7.1 演示脚本:准备好一个"故事线"
提前准备一条5分钟以内的演示路径:
- 开场:展示系统首页,说明这是一个全网比价系统,数据来自哪些平台。
- 触发一次爬虫:手动触发一次关键词采集,让评委看到抓取过程实时写入数据库的日志。
- 展示比价结果:搜索一个具体型号,展示不同平台价格排序列表。
- 亮点功能:点开历史价格曲线,解释这个数据是从哪来的、怎么算的。
- 技术陈述:简要说明采集层、清洗层、存储层和展示层的分工。
演示时最怕的是爬虫临时被封或者页面加载失败。我的做法是准备了两套数据:一套是真的从线上跑出来的数据,另一套是从本地JSON备份恢复的静态快照。真跑失败的时候,切换静态数据兜底,保证流程不断。
7.2 答辩高频问题与回答思路
我把评委最可能问的几个问题梳理一遍,并附上了我的回答思路:
- "你这个系统为什么不用官方公开API?"回答逻辑是:开放API覆盖的商品范围和字段粒度有限,而且不同平台API规则不一,统一适配成本远高于爬虫方案;爬虫方案可以对页面结构做归一化处理,扩展新平台时只需要写一个页面解析器。
- "如何保证数据的准确性?"回答逻辑是:多平台重复采集交叉验证,加上价格异常检测——如果某次采集价格波动超过历史均值的20%,系统自动标记到"待复核"列表,不直接展示给用户。
- "和市面上的比价App有什么区别?"回答逻辑是:市面产品多数只用官方接口或自行采价,覆盖范围有限;本项目通过模块化爬虫架构可以快速扩展数据源,同时开源所有的采集、清洗、存储和展示逻辑,便于二次开发。
7.3 后续扩展思路:做完毕设之后还能怎么玩
这个项目做完以后,我给它加了几项扩展功能,都很好用:
- 降价提醒:在模块里增加一个订阅表,用户订阅某商品,系统定时检查价格,价格跌破目标值的时发送邮件通知。
- 价格监测报表:按周/月汇总价格波动趋势,输出降价榜和涨幅榜,类似一个"值得买"的雏形。
- 多语言支持:标题翻译后用同样的清洗管线,可以扩展到跨境电商比价场景。
这些扩展点不一定要全做完,但每一件都可以作为毕设论文里单独的一章"未来展望"来写,比写空话强太多。
最后再分享一个自己做完整个项目最大的感受:真正耗时间的不是爬虫代码,而是数据清洗和匹配策略的打磨。“爬”只是体力活,“清洗”和“匹配”才是决定系统可用性的智力活。全网比价系统听起来是个爬虫项目,做下来你会发现,其实是个"脏数据治理项目"。能把这一层想透,这个毕设就算真正吃透了。