起初接这个选题时,我以为又是一套“爬虫+图表”的拼凑项目,真正做完才发现这里面可挖的东西比想象中多得多。它表面上是给计算机毕业设计准备的源码,实际上串起了一条非常完整的数据工程链路:采集、清洗、存储、分析、可视化,再到推荐和对话式查询。对想在大数据、数据分析、电商产品这几个方向上找感觉的同学来说,这是一块很合适的练手靶场。
我把这套系统的核心功能拆开讲:用 requests 做数据采集端,把某电商平台上手机品类的商品信息抓下来;用 Flask 构建后端服务;用 pyecharts 生成可视化图表,呈现品牌分布、价格区间、评论热度、促销趋势等维度;再叠加一个基于统计召回和属性相似度的推荐模块;最后用大模型 agent 能力做自然语言交互式查询。以下是我完整做完后的复盘,包含技术选型的理由、每一个环节的坑、以及答辩演示时容易被问倒的细节。
1. 选题动机与系统边界:为什么手机品类这么适合做成毕设
1.1 这个系统到底解决了什么问题
很多同学做毕设喜欢一上来就搞复杂模型,比如训练一个推荐神经网络,最后却发现数据凑不齐、效果说不清、答辩被追问到哑口无言。这套系统的聪明之处在于:它不把重心放在算法上,而是放在“数据从哪来、怎么变成有价值的图表、如何被用户使用”这条完整链路上。
选择手机品类做数据源有几个现实原因:
- 手机商品结构相对统一,品牌、价格、内存、颜色、评价数、店铺名等字段齐全,适合做结构化分析。
- 数据量适中,单页列表加详情页,几千条数据就能支撑起很有说服力的可视化结论。
- 用户认知门槛低,无论答辩老师还是评审专家,都对手机品类有直观感受,不需要额外解释业务背景。
- 推荐模块容易落地,基于品牌偏好、价格区间、内存容量做召回排序,效果好且可解释。
系统解决的直接问题就是:一个普通用户面对海量手机商品时,难以快速判断价格是否合理、口碑是否真实、哪些品牌/配置最值得关注。系统通过可视化看板呈现整体行情,通过推荐模块输出“适合你”的商品列表,通过对话式查询让人直接问出“三千元以内、续航好、拍照强的手机有哪些”。
1.2 核心模块与最终交付物
我梳理出的系统模块清单如下,这直接对应着源码里的目录结构:
| 模块 | 主要职责 | 核心技术点 |
|---|---|---|
| 数据采集模块 | 定时抓取手机商品列表与详情 | requests、多页解析、限速重试 |
| 数据清洗模块 | 字段抽取、单位统一、缺失值处理 | pandas、正则表达式 |
| 存储模块 | 保存结构化数据供分析使用 | SQLite / CSV |
| 分析模块 | 计算品牌、价格、热度等指标 | pandas、聚合统计 |
| 可视化模块 | 生成图表并嵌入 Web 页面 | pyecharts、Flask |
| 推荐模块 | 根据用户偏好输出 TopN 商品 | 属性相似度、加权排序 |
| 智能问答模块 | 自然语言理解与工具调用 | 大模型 API、agent 框架 |
最终交付的是一套能够独立运行的项目,演示时先启动爬虫灌入数据,再启动 Flask 服务,浏览器里就能看到完整看板、推荐结果和对话窗口。
2. 技术选型复盘:Flask、pyecharts、requests 的组合凭什么够用
2.1 每层技术解决的核心矛盾
技术选型不是越新越好,而是正好卡在毕业设计的工作量和展示效果之间。
先说 requests。很多人纠结要不要上 scrapy,我的结论是:这个项目规模完全不需要。手机品类抓取量在几千到几万条之间,requests 配合 Session、重试机制、随机延时完全够用,而且代码直观,答辩时能一句话讲清楚“我发了一个 HTTP 请求拿到 HTML,再用 XPath/正则抽取字段”。用 scrapy 当然更工程化,但对于 300 行代码就能讲明白的采集逻辑,scrapy 的中间件、管道反而变成了解释负担。
再说 Flask。它足够轻,能快速把数据分析结果以 Web 服务形式暴露出来。和 Django 相比,Flask 更契合这类工具型项目——需要的只是几个路由返回 JSON 或渲染页面,不需要后台管理、表单框架、ORM 全家桶。而且 Flask 和 pyecharts 的对接资料非常多,遇到问题很容易查到现成解法。
pyecharts 的作用是把 pandas 算出来的 DataFrame 变成 echarts 可识别的配置。它不是简单的图表库封装,而是自带了一套主题体系和交互事件,生成的图表支持缩放、提示框、动态排序,视觉效果在毕设演示中是加分项。最方便的是它可以直接输出 HTML 片段,也可以生成 JSON 配置交给前端渲染,这为 Flask 集成提供了两种成熟路径。
2.2 整体数据流转与存储选型
我在设计时把系统分成了四层,每一层的输入输出都非常明确:
- 采集层:requests 抓取列表页和数据详情页,解析出原始字段,写入临时文件。
- 清洗层:pandas 读取临时文件,做去重、类型转换、价格单位统一,生成干净的结构化数据表。
- 分析层:对干净数据做分组聚合,计算各维度指标,输出图表数据和推荐候选集。
- 展示层:Flask 读取分析结果,通过接口和页面模板渲染看板。
存储这里我尝试过 MySQL,后来换成了 SQLite。原因很实际:毕设项目需要让老师一键跑起来,SQLite 是单文件数据库,免安装,数据量又不大。如果上了 MySQL,环境配置问题可能比项目本身还耗时。数据量在几万条场景下,SQLite 的查询性能绰绰有余。
提示:如果你后续想扩展成真正可部署的系统,可以把存储层抽象一下,预留 SQLAlchemy 接口,但演示版本用 SQLite 是最稳的选择。
3. 数据采集工程化:requests 爬手机商品数据时真正要注意的细节
3.1 请求策略与反爬应对
采集模块是整套系统的地基,地基不稳后面全是空中楼阁。我在写爬虫时踩过不少坑,核心经验可以浓缩成三条:像真人、留余地、可重试。
像真人,指的是请求头必须伪装到位。只写一个 User-Agent 远远不够,我见过太多新手在这上面栽跟头。需要至少将 User-Agent、Referer、Accept、Accept-Language 头完整带上,并且最好准备一个 User-Agent 池,每次请求随机切换。以下是示例:
import requests import random UA_POOL = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36", ] def make_session(): sess = requests.Session() sess.headers.update({ "User-Agent": random.choice(UA_POOL), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6", }) return sess留余地,指的是请求频率必须保守。手机品类数据量不需要高并发,每抓一页睡上 1 到 3 秒,总耗时也在可接受范围内。很多反爬机制的核心触发条件就是“单位时间请求量超过阈值”,控制节奏比花式绕过更可靠。
可重试,指的是对偶发失败要有兜底。我在代码里封装了带重试机制的请求函数,连续失败超过三次就记录日志并退出,避免死循环或触发更严厉的限制:
def safe_get(url, sess, retries=3): for i in range(retries): try: resp = sess.get(url, timeout=10) if resp.status_code == 200 and resp.text: return resp.text except Exception: pass time.sleep(2 + random.random()) return None3.2 字段清洗与落库结构
原始数据拿回来后,清洗是最容易被低估的环节。我最初直接把页面上的文本塞进数据库,结果后面做可视化时到处出问题:价格带了“元”字、评论数带了“万”字、有的商品没有内存字段、品牌命名单双引号不一致。
我的处理办法是写一个独立的清洗脚本,用 pandas 分阶段处理:
import pandas as pd def clean_data(df): # 统一品牌名称,去掉空格和特殊字符 df["brand"] = df["brand"].str.strip().str.upper() # 价格字段抽取数字并转成浮点数 df["price"] = df["price"].str.replace("[^0-9.]", "", regex=True).astype(float) # 评论数处理:'1.2万' -> 12000 def parse_comment_cnt(s): s = str(s) if "万" in s: return float(s.replace("万", "")) * 10000 return float(s.replace(",", "").replace("条", "")) df["comment_cnt"] = df["comment_cnt"].apply(parse_comment_cnt) return df存储表结构设计成一张主表就够了,核心字段包括:
| 字段名 | 类型 | 说明 |
|---|---|---|
| sku_id | TEXT | 商品唯一标识 |
| title | TEXT | 商品标题 |
| brand | TEXT | 品牌 |
| price | REAL | 当前价格 |
| original_price | REAL | 原价或划线价 |
| comment_cnt | INTEGER | 评论数 |
| shop_name | TEXT | 店铺名 |
| memory | TEXT | 内存版本,如 8GB+256GB |
| category | TEXT | 商品类目 |
清洗后我会再跑一次去重和缺失值检查,确保进入可视化阶段的数据是干净可信的。数据质量直接决定了图表能不能讲出好故事,这一关不能图省事。
3.3 爬虫稳定性的运维手段
采集模块写完只是第一步,真正稳定跑完需要加几个保险。我把采集脚本设计成支持增量更新的模式,每次运行前先读取数据库里已有的 sku_id 集合,遇到重复商品就跳过。这样演示前临时加跑一轮不会把数据搞乱。
同时,采集进度日志要打印清楚,包括当前页码、成功条数、失败累计次数。我建议用标准库 logging 输出到文件,而不是只 print 到控制台。因为采集是长时间任务,中途可能断网或手动中断,有日志才能排查问题。
还有一个容易忽略的点:字段抽取规则要尽量和 HTML 结构解耦。我第一版解析逻辑强依赖某个具体的 class 名称,结果页面结构调整一次就全崩了,后来改成“先定位商品块,再在块内按文本模式匹配”,健壮性好很多。
4. 分析指标与图表映射:把原始 SKU 变成可视化叙事的计算逻辑
4.1 关键指标定义与计算口径
数据到手后,分析模块的目标是把密密麻麻的 SKU 记录提炼成有业务含义的指标。我定义了以下几组核心指标:
- 品牌分布:按品牌分组统计商品数量和评论总数的占比,反映各个品牌的市场声量。
- 价格区间分布:将价格划分成 0-1000、1000-2000、2000-3000、3000-5000、5000 以上五个区间,统计每个区间的商品数和平均评论数。
- 价格与口碑关系:计算每个品牌的平均价格和平均评论数,用散点图观察是否存在“价格越高口碑越好”的直觉误区。
- 高性价比商品:评论数高、价格低于同配置均价的商品列表,这是推荐模块的重要候选集。
- 促销力度排行:用原价减去现价再除以原价,计算折扣深度,观察哪些品牌促销策略更激进。
计算口径定了之后,分析逻辑就变成一段清晰的 pandas 分组聚合代码:
price_disc = pd.cut(df["price"], bins=[0, 1000, 2000, 3000, 5000, float("inf")]) summary = df.groupby(price_disc).agg( product_cnt=("sku_id", "count"), avg_price=("price", "mean"), total_comment=("comment_cnt", "sum") ).reset_index()注意:以“现价”作为价格字段基准,而不是原价或划线价,否则会出现大量商品被错误归入低价区间的失真结论。
4.2 图表选型与业务含义对应
图表不是随便挑一个好看的样式,每种图都有它最适合回答的问题。我最终确定的图表组合如下:
| 分析问题 | 图表类型 | 为什么选它 |
|---|---|---|
| 哪些品牌手机最多 | 饼图 / 环形图 | 直观呈现占比,适合品牌份额对比 |
| 手机价格分布在哪个区间 | 柱状图 | 区间统计对比清晰,差异一目了然 |
| 品牌价格与评论量关系 | 散点图(气泡图) | 展示两个连续变量的相关性,气泡大小映射商品数 |
| 价格与折扣深度关系 | 箱线图 | 反映不同品牌/价格段的价格离散程度,比纯均值更有说服力 |
| 单品热度排行 | 横向条形图 | Top10 名称较长,横向排列阅读更舒服 |
| 内存版本分布 | 堆叠柱状图 | 展示不同内存版本在各价格区间中的构成 |
离线分析结果我在演示前都会人工扫一遍,数据异常往往一眼能看出来。比如某个品牌的平均价格突然低得离谱,大概率是清洗时混入了配件或二手机型,回到上一步做过滤即可。
5. Flask 与 pyecharts 的联动:动态生成图表看板的三种写法
5.1 方案一:后端渲染 JSON 配置文件
pyecharts 支持将图表配置导出为 JSON,Flask 后端读取这个 JSON,传给前端模板,再由前端调用 echarts 渲染。这是我最推荐的方案,因为它让后端只负责数据计算,前端只负责渲染,双方职责清晰。
具体流程是:分析脚本生成图表配置后存入文件或内存,Flask 路由读取并传给模板:
from flask import Flask, render_template, jsonify import json app = Flask(__name__) def load_chart_config(chart_name): with open(f"charts/{chart_name}.json", "r", encoding="utf-8") as f: return json.load(f) @app.route("/") def index(): return render_template("index.html") @app.route("/api/chart/brand") def brand_chart(): return jsonify(load_chart_config("brand_pie"))前端模板里用 echarts 实例化:
<!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="brandChart" style="width:100%;height:400px;"></div> <script> fetch('/api/chart/brand') .then(res => res.json()) .then(option => { const chart = echarts.init(document.getElementById('brandChart')); chart.setOption(option); }); </script> </body> </html>5.2 方案二:前端异步加载增强交互
图表都展示出来后,交互体验是下一个需要解决的问题。我加了两个交互点:点击饼图某个品牌时联动更新右侧价格区间图;切换价格区间时推荐列表跟着刷新。这种联动本质上就是前端监听事件后再次向后端发起接口请求,后端返回对应条件下的图表配置。
联动实现不复杂,但有一个后端接口设计原则:接口入参不要写死,接受过滤条件。
@app.route("/api/chart/price_dist") def price_dist(): brand = request.args.get("brand", "") df = load_clean_data() if brand: df = df[df["brand"] == brand] # 按价格区间聚合 ... return jsonify(chart_option)这种参数化接口让看板从“静态展示”进化成“可交互的数据分析工具”,答辩时的演示效果会明显不一样。
5.3 联动细节与性能优化
联动做多了会遇到一个性能问题:每次切换条件都重新读全量数据,导致接口响应越来越慢。我的优化办法是在 Flask 启动时加载一次数据到内存,后续查询都在内存中使用 DataFrame 过滤,而不是每次都从数据库读。
_DATA_CACHE = None def load_clean_data(): global _DATA_CACHE if _DATA_CACHE is None: _DATA_CACHE = pd.read_csv("data/clean_phone_data.csv") return _DATA_CACHE这个优化对几万条数据效果非常明显,接口响应从几百毫秒降到几十毫秒。演示时鼠标滑来滑去不卡顿,观感完全不同。
另一个细节是 echarts 的 resize 问题。浏览器窗口变化后,如果图表容器尺寸变了,需要调用chart.resize(),否则会出现图表显示不全或空白。我在页面里加了一行监听:
window.addEventListener('resize', () => { chart.resize(); });6. 推荐模块设计:不堆算法的轻量召回排序方案
6.1 用户行为冷启动问题
推荐模块是很多同学想“秀肌肉”的地方,但也是最容易失控的地方。一上来就写协同过滤,结果用户行为数据根本没有,算法成了空中楼阁。我的处理思路是先承认冷启动问题,再通过一条可解释的规则链路输出推荐结果。
系统里没有真实用户历史行为,我把“用户偏好”设计成交互式的三个输入:预算范围、倾向品牌、关键需求(比如续航优先、拍照优先、性能优先)。基于这三个输入,推荐模块在主数据集中做召回和排序。这种交互式推荐的好处是演示起来非常直观:老师问“你推荐我三千块以内买个拍照好的手机”,你在系统里输入条件就能看到结果,当场验证。
6.2 基于商品属性相似度的召回
召回阶段的核心是用商品字段构造检索条件。价格区间是最强约束,先过滤掉预算范围外的商品;然后根据倾向品牌做加权召回,如果没有明确品牌偏好,就按评论数取各价格段的口碑头部商品。
召回之后做属性相似度打分。这里不需要向量化,因为手机的结构化属性足够强。我定义了以下打分因子:
| 因子 | 权重 | 说明 |
|---|---|---|
| 价格接近度 | 0.4 | 价格离当前筛选中心越近,得分越高 |
| 评论数对数 | 0.3 | 评论数是口碑和热度的替代指标 |
| 折扣深度 | 0.2 | 促销力度大的商品更容易吸引用户 |
| 配置完整度 | 0.1 | 内存、颜色等信息缺失则扣分 |
价格接近度计算我用了归一化绝对误差,分越低越好:
def price_score(price, target_price, max_price): diff = abs(price - target_price) / max_price return max(0, 1 - diff)6.3 排序规则与可解释性
推荐结果不能只是一串商品 ID,系统要把“为什么推荐这个”讲清楚。我在结果列表里显示了三条推荐理由的标签:价格在预算内、同价位评论数前 10、促销力度超过 30%。这种可解释性不仅提升用户体验,更重要的是答辩时能清楚说明推荐逻辑,避免“黑箱算法”式的尴尬提问。
排序最终使用加权总分倒序,但我会加一个去重约束:同一品牌在同一价格区间内最多展示两个商品,防止结果页被某个品牌刷屏,影响多样性。
def rank_products(candidates, weights): candidates["score"] = ( weights["price"] * candidates["price_sim"] + weights["comment"] * candidates["comment_score"] + weights["discount"] * candidates["discount_score"] ) final_list = [] seen_brand = {} for row in candidates.sort_values("score", ascending=False).itertuples(): brand = row.brand if seen_brand.get(brand, 0) >= 2: continue final_list.append(row) seen_brand[brand] = seen_brand.get(brand, 0) + 1 return final_list[:10]7. 大模型 agent 与对话式分析:给系统加一个“智能导购”
7.1 为什么用 agent 而不是简单接口
加了推荐模块后,业务逻辑已经能回答预设问题,但用户要的是自然语言问答。比如“1500 到 2500 之间的手机,哪些牌子性价比高”这种问题,如果用传统规则接口,参数该怎么拆?品牌、价格、性价比这三个词需要拆成结构化条件。这时候大模型 agent 就派上了用场。
我想过只调用大模型接口生成 SQL 或过滤条件,但发现不够灵活。更合理的方案是让大模型充当“意图理解+工具调度中枢”:它把用户的话翻译成系统内部可执行的函数调用,然后调用我写好的查询函数,再把结果组织成自然语言回答。
7.2 工具调用与提示词设计
实现上我参考了 agent 工具调用的常见范式。核心思路是:定义几个明确的方法,让大模型根据用户输入决定调用哪个方法,传入什么参数。
TOOLS = { "get_top_products": { "description": "根据价格、品牌、需求获取推荐商品列表", "parameters": ["price_min", "price_max", "brand", "need"] }, "get_brand_summary": { "description": "获取某品牌的均价、平均评论数、促销力度", "parameters": ["brand"] }, "get_price_trend": { "description": "获取某价格区间的商品数量与平均评论数", "parameters": ["price_min", "price_max"] } }提示词里我明确告诉大模型:不要自己编造数据,只能调用工具获取结果,如果工具没有返回合适数据就如实说不知道。这样能很大程度避免大模型“一本正经胡说八道”的问题。
7.3 实际效果与翻车教训
这个模块我调试了很久,最典型的翻车场景有三个:
- 大模型把品牌名理解错,比如把“爱酷”当成“苹果”,导致工具收到错误参数。解决办法是在工具函数里做品牌名归一化,和商品数据中的品牌列表做个模糊匹配。
- 大模型直接输出“根据分析,苹果手机均价最高”之类结论,但实际上它没有调用任何工具,是在瞎编。解决办法是在提示词中强制规定回答路径:必须包含“我调用了 XX 工具,返回结果是……”。
- 连续对话时上下文过长,token 消耗严重。解决办法是只保留最近两轮对话和当前工具结果,不把全量商品数据塞进上下文。
agent 模块让系统的“智能化”感知非常强,演示时话题性很足,但要注意它依赖大模型接口,网络不稳时会出现响应慢甚至超时,最好准备一个降级策略。我的做法是大模型无响应时自动回退到预设的规则问答,保证系统任何时候都能工作。
8. 答辩演示与常见坑位:从爬到可视化全链路的排错经验
8.1 爬虫运行中的典型异常
整个链路里爬虫环节最容易出问题,我把实际遇到的典型异常和解决办法整理成了一份清单:
| 异常现象 | 可能原因 | 排查与修复 |
|---|---|---|
| 返回 403 / 频繁跳验证页 | 请求频率太高或请求头不完整 | 降低频率、轮换 UA、增加延时 |
| 部分商品价格解析为 0 | 页面改版导致字段没抽取到 | 检查当前 selector 或改用品牌搜索页兜底 |
| SQLite 数据库锁死 | 采集和展示进程同时写入 | 采集用独立进程,展示只读;避免长事务 |
| 中文乱码 | 页面编码识别错误 | 用resp.apparent_encoding再手动指定 utf-8 |
| 评论数解析报错 | 数据中存在“暂无评论”文本 | 解析前先判断是否为纯数字文本 |
8.2 图表不显示或白屏的定位路径
Flask 页面白屏,第一反应不要查 echarts,先看浏览器 Console 报错。我遇到的十次白屏里,七次是接口返回的 JSON 格式问题,两次是 echarts CDN 没加载成功,只有一次是图表配置字段写错。
定位方法非常直接:在浏览器里直接访问接口地址,看返回结构是否和前端期望一致。
curl http://127.0.0.1:5000/api/chart/brand如果接口正常,再检查前端是否获取到了数据。在setOption之前打一条console.log(option),确认 option 不是空对象,这一招能过滤掉大半问题。
8.3 演示前的数据量与性能准备
毕设演示最怕现场翻车,我总结了几条演示前必须做的准备:
- 数据量不要太大。我测试过三万条商品数据和三十万条商品数据,看板响应速度有明显差别,演示场景保持一万条以内最流畅。
- 预生成图表配置。与其让用户现场等待爬虫运行,不如提前把图表配置生成好,演示时直接展示,需要增量更新时再现场跑一小段。
- 准备一个“故障预案包”。把常用异常和处理命令写在一个 README 里,万一现场报错,按文档顺序排查,比现场翻代码靠谱得多。
- 所有第三方资源用本地文件。echarts 的 JS 库不要依赖线上 CDN,演示场地网络不可控,提前下载到本地 static 目录更稳妥。
把大模型 agent 的接口 key 写进本地配置文件,不要硬编码到源码里,避免源码打包或上传后被泄露。这个细节虽然小,但在答辩场景下被问到安全问题时反而很加分。
整个项目做完,我最深的体会是:这套系统的价值不在单个模块的技术难度,而在于它把数据全流程串成了一个完整产品,每一层都有产出物,每一层都能应对追问。你如果也想在这个选题上打磨,建议不要停留在把源码跑通,而是亲手把每一个模块的边界、异常和优化点都过一遍,答辩时你会发现自己心里特别有底。