这个项目的名字里虽然带着“机器学习”四个字,但真正做完你会发现,它骨子里是一个标准的数据分析全流程作品:Python 爬虫负责采集当当网的图书数据,清洗之后用 Django 框架搭后端接口,再通过 ECharts 做可视化大屏展示,最后补一个图书推荐模块把整条链路串起来。整体覆盖“采集—清洗—存储—分析—展示—推荐”六个环节,放在毕业设计或者大数据课程实践里都属于性价比很高的选题。
这套系统能解决什么问题?说白了就是三件事:第一,用真实业务数据验证完整的采集和分析方法论;第二,把枯燥的表格数据变成答辩评委或业务方一眼就能看懂的大屏图表;第三,让访问者能从成千上万本图书中快速找到自己可能感兴趣的同类书。如果你正准备做类似的图书数据分析系统,或者想用一份“可运行、可演示、可讲清楚”的全栈项目作为简历亮点,这篇文章会从架构设计一路拆到细节实现。
1. 项目全貌与需求拆解
1.1 核心链路解析:从采集到推荐的六个环节
我第一版做这个项目时,踩过最大的坑就是低估了“链路整合”的难度。很多人以为爬虫最难,其实当你把六块功能放在一个系统里时,真正费时间的反而是数据格式不统一、接口定义不一致、前端图表和后端字段对不上这类“黏合层”问题。
所以先看整体设计。这套图书数据分析可视化系统,我把中文完整链路拆成六个环节。
| 环节 | 核心任务 | 产出物 | 关键工具 |
|---|---|---|---|
| 数据采集 | 爬取当当网图书列表页与详情页 | 原始 JSON 数据文件 | requests + BeautifulSoup |
| 数据清洗 | 去重、缺失值处理、字段类型规整 | 干净的 DataFrame 或 CSV | pandas |
| 数据存储 | 设计表结构并落地 | MySQL 数据表 + Redis 缓存 | Django ORM / SQLAlchemy |
| 统计分析 | 围绕核心维度做聚合统计 | 统计结果 JSON | Django ORM + pandas |
| 可视化大屏 | 将统计结果渲染为图表大屏 | 可交互的大屏页面 | ECharts + HTML/CSS |
| 图书推荐 | 基于内容相似度推荐图书 | 相似图书列表 | 余弦相似度 + numpy |
这个顺序不能乱,因为下游环节严重依赖上游输出的结构。比如清洗环节如果没把价格字段从“45.00元”这种字符串转成 float,后面统计价格分布时就会直接报错或者画出一堆异常值。推荐模块如果拿不到干净的分类和出版社数据,特征向量就是一堆空值。
我当时定的数据规模是抓取 8 到 10 万条图书记录。这个量级没有大数据平台也能轻松扛住,MySQL 单表完全没问题,又比几千条数据更有说服力。如果你只有一两万条,图表会显得单薄;但如果去爬几百万条,单机 requests 又太慢,还得上 Scrapy 分布式,反而偏离了毕业设计的重心。
1.2 技术选型背后的原因:Django 不是唯一答案,但最稳
关于后端框架,我先做了一版 Flask,后来重构时换成了 Django。原因很实际。
Flask 确实轻量,一张 main.py 就能跑起服务,适合做课程作业。但这类项目一旦涉及模型层、数据库迁移、后台管理、接口路由分离,Flask 就暴露短板了——你需要自己集成 Flask-SQLAlchemy、Flask-Migrate、Flask-Admin,中间还要处理各种配置冲突。Django 自带 ORM、Admin 后台、Migrate 迁移机制,甚至自带分页和缓存框架,你只需要规规矩矩把 app 划分好,代码结构天然清晰。
我记得有个细节特别能说明问题:Django 的 ORM 在聚合查询上比 Flask 的 SQLAlchemy 更顺手,像.values('category').annotate(count=Count('id'))一行就能求出每个分类的图书数量。大屏后端需要十几类统计接口,写起来非常快。
爬虫部分我坚持用 requests + BeautifulSoup,而不是一上来就上 Scrapy。这个选择很多教程会骂我,但我的理由很简单:几十万条数据、单机跑六到八个小时就能搞定,requests 配合线程池完全够用。Scrapy 的异步下载器和中间件机制确实强大,但项目复杂度也会同步上升,对于以“全流程展示”为核心诉求的系统来说,有点杀鸡用牛刀。
存储上选用 MySQL + Redis 双组合。MySQL 存全量明细数据,保证可靠性和分析能力;Redis 存大屏高频访问的统计结果和推荐结果的热缓存。为什么要 Redis?因为大屏页面每隔十几秒就会拉一次统计数据,如果每次都实时查 MySQL,数据库压力会随着演示次数上升,而且大屏打开时如果全部重新聚合计算,首屏能卡三秒以上。把最热门的几个聚合接口做成五分钟过期缓存,体感会好非常多。
1.3 系统部署架构总览
整套系统的部署架构我也顺手给出来,方便你后面答辩画图。生产环境实际用的是 Nginx + Gunicorn 跑 Django,静态文件交给 Nginx 处理,动态接口走 Gunicorn 转发。MySQL 负责持久化,Redis 负责缓存。
客户端浏览器访问大屏页面时,Nginx 直接返回 HTML、CSS、JS 文件,页面加载完成后通过 Ajax 请求 Django 的/api/statistics/系列接口。Django 先查 Redis 缓存,若命中则直接把缓存 JSON 返回给页面;若未命中则执行 ORM 聚合查询,将结果写回缓存并设置过期时间,再把 JSON 返回给前端。
这个架构的好处是每层都能独立替换。学生版不用 Nginx,直接python manage.py runserver也行;不想用 Redis,把缓存函数改成内存字典也能跑,只是没那么优雅。但架构图完整画出来,体现的是你对“分层”这个概念有认知,这在答辩环节是加分项。
2. 当当网爬虫与数据清洗实战
2.1 爬虫方案设计:先搞清楚页面结构和字段来源
写爬虫之前,第一件事是打开当当网的页面,按 F12 看清页面结构。别急着写代码,你可以先手动翻几页,把列表页的 URL 规律找出来。
当当图书列表页的地址格式比较有规律,大致是图书分类路径加页码参数。例如某个分类第一页是:https://category.dangdang.com/cp01.01.02.00.00.00.html,往下翻页会看到?page_index=2这样的参数。我的建议是选定一个主分类,把列表页 URL 模板确定下来,再遍历页数即可。
列表页能拿到的核心字段包括:图书标题、作者、出版社、价格、原价、评论数量、详情页 URL。但评分这个字段在列表页是不完整的,很多书没有评论就没有评分,这时需要进入详情页补抓评分数据。
由于十万条数据全部走详情页会翻五倍请求量,我做了策略区分:列表页大批量抓取,详情页只对评分缺失或者需要推荐特征的图书做补抓。这样整体请求量可控,单个分类下几千本书,requests 串行加线程池后大约十几分钟能跑完一轮。
字段设计上我会多留一个raw_json字段,把原始 JSON 原样落盘。这一步很多人会忽略,但实际价值很大——清洗方案改来改去时,你不需要重新爬一遍,直接从原始数据重新清洗就行。
2.2 反爬应对与采集节奏控制
当当的反爬不像电商巨头那么严格,但如果不加控制地高频请求,照样会被限流。我踩过最明显的迹象就是请求返回 403 页面,或者响应体里出现一个验证跳转页。
我采用了一套组合策略,按优先级排序:
- 随机 User-Agent,把常见的 Chrome、Firefox、Safari UA 放到列表里随机挑选
- 每次请求之间加随机延时,1 到 3 秒之间随机
- 请求失败时最多重试三次,每次重试延长时间翻倍
- 爬取一段时间后主动暂停 10 到 15 秒
- 设置单 IP 下最大并发数,我用线程池时控制为 4 个线程
这里给一段请求核心代码作为参考:
import time import random import requests from fake_useragent import UserAgent ua = UserAgent() def fetch_with_retry(url, max_retry=3): for attempt in range(max_retry): try: headers = { "User-Agent": ua.random, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200 and "验证" not in resp.text: return resp except Exception as e: print(f"请求异常: {e}, 第 {attempt + 1} 次重试") time.sleep(2 * (attempt + 1)) return None需要注意一个合规性问题:爬虫要在合理频率下采集公开信息,不能对目标站点造成访问压力,更不要绕过登录或验证码机制去获取非公开数据。我的建议是:单次任务总量控制在几万条以内,频率限制在每秒一两个请求。这个节奏既能保证演示数据量,又不会把目标网站拖垮。
2.3 数据清洗的三大关键操作
清洗是我愿意花最多篇幅讲的部分,因为数据库里每条脏数据都会在最终可视化大屏上原形毕露。清洗操作我归纳为三大类。
第一类:去重。图书数据重复主要来自两个原因:同一本书在不同列表页出现多次(比如新书榜和好评榜同时收录),以及爬虫中断后重新爬取导致的重复记录。去重逻辑我用的是“书名 + 作者”联合判断,比单纯用书名可靠得多,因为不同出版社可能出同名书。pandas 里一行drop_duplicates(subset=['title', 'author'])就能处理。
第二类:缺失值处理。图书数据中图片、简介、出版社这些字段可能有缺失。对于展示型项目,不要一删了之。评分缺失的图书,用该分类下的平均评分填充即可;出版社缺失的,填充“未知出版社”;图片缺失的,用一张本地默认封面代替。数据量只有几万条时,直接删行会影响统计分布的真实性。
第三类:字段类型规整。这是最容易出幺蛾子的地方。“价格”字段爬下来经常是¥45.00或者45.00元,需要正则提取数字再转 float。“评论数”有时是1万+这种格式,要转成10000。出版时间也需要统一成YYYY-MM-DD格式。这类转换逻辑汇总成一个函数处理,不要散落在各处。
def clean_price(price_str): # 提取纯数字部分,保留小数点 import re match = re.search(r"(\d+\.?\d*)", str(price_str)) return float(match.group(1)) if match else None def clean_comment_count(count_str): # 处理 '1万+' -> 10000 的情况 import re text = str(count_str) match = re.search(r"(\d+\.?\d*)", text) if not match: return 0 value = float(match.group(1)) if "万" in text: value *= 10000 return int(value)清洗完成后,我会输出一版clean_books.csv做质量抽检,随机抽 200 条人工看一遍字段是否正常。这个动作花不了十分钟,但能把后面所有的分析环节都保护起来。
2.4 存储设计:MySQL 表和 Redis 缓存的配合
清洗干净的数据最终要落库。我设计的 book 表结构大概长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | BIGINT 自增主键 | 主键 |
| title | VARCHAR(255) | 书名 |
| author | VARCHAR(255) | 作者 |
| publisher | VARCHAR(255) | 出版社 |
| publish_date | DATE | 出版日期 |
| price | DECIMAL(10,2) | 现价 |
| original_price | DECIMAL(10,2) | 定价 |
| rating | DECIMAL(3,1) | 评分,0 表示暂无 |
| rating_count | INT | 评论数 |
| category | VARCHAR(100) | 分类 |
| detail_url | VARCHAR(500) | 详情页 URL |
| create_time | DATETIME | 数据入库时间 |
索引方面我建了三个:category、publisher、rating。这三个字段分别对应大屏的分类占比图、出版社榜单和评分分布图。别给所有字段都加索引,索引过多会影响写入速度,而且在这个数据量下毫无必要。
Redis 缓存设计更简单。我在 Django 里封装了一个自定义缓存装饰器,为每个大屏统计接口设置 300 秒过期时间。这样即使大屏每隔 15 秒拉一次数据,后端也只有第一次请求会真正查询数据库。具体的缓存键我用接口名和参数拼接,比如dashboard:category_count:all。
3. 数据分析与可视化大屏的实现
3.1 数据分析维度怎么定:先问业务问题
大屏好看的前提是分析维度有逻辑,不是为了堆图表而堆图表。我设计大屏之前,先列了四个业务问题:
- 图书定价集中分布在什么区间,大众图书的合理价格带在哪里?
- 评分和评论数之间有没有关系,高评分图书是否一定高关注度?
- 哪些出版社上榜数量最多,出版市场上哪些出版社最活跃?
- 不同分类的图书占比如何,哪几个分类是绝对主力?
这四个问题对应四类核心图表:价格区间分布图(直方图)、评分与评论数散点图、出版社 TOP10 榜单(横向条形图)、分类占比图(饼图或环形图)。
额外我还加了一个“出版年份与数量趋势”的折线图,能看出近几年图书出版数量的变化趋势。这五个维度就足够撑起一个 1920x1080 的大屏布局了,再多就显得杂乱。
数据结果也很有意思。我爬的那批数据里,价格在 30 到 60 元之间的图书数量最多,评分在 8 到 9 分之间是集中区域,评论数和评分并没有强正相关——有些评分 9 分以上的书评论数反而很少,说明“叫好”和“叫座”是两回事。这类洞察都可以写进论文的结论部分,比单纯贴几张图有说服力得多。
3.2 Django 数据接口设计
大屏前端不直接连数据库,所有数据通过 Django 的 JSON 接口获取。我按功能拆分了五个接口,每个接口返回结构固定:
/api/statistics/price_distribution/返回价格区间与数量列表/api/statistics/score_comment/返回评分、评论数散点数据/api/statistics/publisher_top/返回出版社 TOP10/api/statistics/category_ratio/返回分类占比/api/statistics/year_trend/返回出版年份趋势
以价格分布为例,后端用 ORM 查询很轻松完成:
def price_distribution(request): # 先查缓存 cache_key = "dashboard:price_distribution" result = cache.get(cache_key) if result: return JsonResponse({"data": json.loads(result)}) # 定义价格区间 bins = [0, 20, 40, 60, 80, 100, 200] labels = ["0-20", "20-40", "40-60", "60-80", "80-100", "100-200"] data = [] for i in range(len(bins) - 1): count = Book.objects.filter(price__gte=bins[i], price__lt=bins[i+1]).count() data.append({"range": labels[i], "count": count}) result = {"data": data} cache.set(cache_key, json.dumps(result), timeout=300) return JsonResponse(result)所有接口都遵循“先查缓存、再查数据库、再回写缓存”的模式,代码结构统一,后期维护就轻松。
3.3 可视化大屏布局与 ECharts 交互
大屏页面我按经典的数据驾驶舱样式来布局:顶部是系统标题和更新时间,中间主体为价格分布主图和分类占比环形图,左右两侧分别是出版社 TOP10 条形图、评分与评论数散点图、年度趋势折线图。底部再放置一个滚动更新的“图书榜单”区域,从热门图书中取 10 本轮播展示。
布局比例上,我用的是 1920x1080 为基准的栅格。特别强调一个经验:大屏不要用纯 px 写死尺寸,否则在答辩教室不同的投影分辨率下会出现错位。更好的方案是用 rem 作为单位,配合一段自适应脚本动态设置根字体大小。我实测下来,这段脚本对 1366x768 和 2560x1440 的适配效果都令人满意。
function setRem() { const baseWidth = 1920; const scale = document.documentElement.clientWidth / baseWidth; document.documentElement.style.fontSize = (100 * scale) + 'px'; } window.addEventListener('resize', setRem); setRem();ECharts 部分我封装了一个公共的初始化函数,传入图表容器 id、option 配置和接口地址。这样五张图表共用同一套“拉取数据 + 渲染 + 监听 window resize”逻辑,代码量减少一半以上。
还有一个容易被忽略的体验细节:大屏要加一个每 15 秒刷新数据的定时器。刷新逻辑很简单,重新请求接口然后调用chart.setOption(option),但在刷新时不能使用notMerge参数清空旧数据,否则会有明显的闪烁感。另外在数据刷新过程中要避免重复创建图表实例,正确做法是在图表初始化时先chart.dispose()销毁旧实例。
4. 图书推荐模块:轻量但完整的推荐链路
4.1 为什么不做协同过滤
这个项目的推荐模块,我最终选择的是基于内容的推荐,而不是很多人第一反应会选的协同过滤。原因是这个场景有几个现实约束。
协同过滤需要有真实的用户行为数据——用户点击、购买、评分记录。我们这个系统里根本没有登录注册流程,更别说行为日志了。就算硬凑出一张模拟用户评分表,数据也非常稀疏,每个用户都只评过几本书,算出来的相似度矩阵毫无参考价值。这就是协同过滤最典型的“冷启动”问题:新用户没有行为记录,推荐系统完全失效。
基于内容的推荐就没有这个困扰。它只依赖图书自身的特征,只要每本书的分类、出版社、作者、价格、评分这些字段是完整的,就能算出书与书之间的相似度,然后给任意一个入口返回“和这本书相似的图书列表”。这非常适合我们的系统:图书详情页里挂一个“猜你喜欢”的横条,交互自然,算法也能讲清楚。
4.2 特征工程与相似度计算
基于内容的推荐,核心就是两步:构造特征向量,计算相似度。
我提取了五个特征维度:图书分类(文本)、出版社(文本)、作者(文本)、价格区间(数值分箱)、评分区间(数值分箱)。文本特征用 one-hot 编码,数值特征先分箱再 one-hot。比如价格落入 40-60 元区间,则该维度向量为 1,否则为 0。
这里我把分类做再细化处理。当当网的分类有多级结构,如“文学 > 小说 > 中国当代小说”,不能直接整个字符串拿去 one-hot,否则维度爆炸且没有区分度。我取到二级分类为止,并把父类和子类分开编码,比如“文学”一个维度、“小说”一个维度,这样同一个大类下的书即使子类不同,也有父类的相似度基础。
相似度计算用的是余弦相似度。sklearn 里有现成的cosine_similarity,但我不建议在 Django 请求链路里直接跑全量计算,十万本书两两计算会生成十亿量级的相似矩阵,单机完全跑不动。我的策略很简单:离线用 Python 脚本预先算好每本书的 top-N 相似书列表,结果存到 Redis 里,线上接口只做查询。
from sklearn.feature_extraction.text import CountVectorizer from sklearn.metrics.pairwise import cosine_similarity # 特征文本拼接 books["feature_text"] = ( books["category"] + " " + books["publisher"] + " " + books["author"] + " " + books["price_level"] + " " + books["rating_level"] ) vectorizer = CountVectorizer() feature_matrix = vectorizer.fit_transform(books["feature_text"]) similarity_matrix = cosine_similarity(feature_matrix) # 取每本书最相似的5本书 for idx, row in books.iterrows(): sim_scores = list(enumerate(similarity_matrix[idx])) sim_scores = sorted(sim_scores, key=lambda x: x[1], reverse=True) top_books = [books.iloc[t[0]]["id"] for t in sim_scores[1:6]] rds.hset("recommend:bookid:" + str(row["id"]), mapping={"suggest": top_books})注意这里feature_text里加的四个文本字段都确保是 string,如果某个字段为空要用占位符填充,否则 CountVectorizer 会把空值报错。这也是清洗环节做缺失值填充的原因之一。
4.3 推荐接口与前端呈现
推荐接口设计为/api/recommend/?book_id=123,返回该书的相似图书列表,每个结果包含书名、作者、价格、评分和封面地址。前端在图书详情页右侧放一个“相似好书推荐”的卡片区域,点击书名可以跳转到对应详情页。
另外我还在大屏底部做了一个“热门图书推荐”模块,它展示的是评论数最多的十本书,从另一个角度给大屏增加了“推荐”的功能呈现。
整体推荐模块在答辩讲解时,我建议抓住三个要点:其一,推荐数据是离线预计算的,不是实时全量匹配,这样保证了接口速度;其二,相似度计算采用的是余弦相似度,衡量的是特征向量方向的一致性,它不受向量长度影响,所以不会因为某本书特征文本长就天然相似;其三,推荐效果在人工抽查时是有可解释性的——一本讲 Python 爬虫的书,推荐列表大概率是 Python 开发和数据分析类图书。
5. 常见问题与避坑指南
5.1 爬虫请求被限制怎么办
这个现象我在开发时几乎每天都会遇到。一旦请求频率过高,当当会返回 403 或者跳转到一个安全页面,此时解析出来的数据全是乱码或空列表。
我的排查顺序是:先确认是否触发反爬,再看频率,再看数据是否能被重新解析。如果是请求频率过高,就把随机延时拉长到 5 秒以上,并暂停一段时间。不要为了追求速度把单次任务跑得太激进,展示项目稳定比速度重要得多。另一个经验是,爬虫不要用固定 IP 连续作战,有条件的话可以隔一段时间换个网络环境再跑,但这种操作要注意合规性,尽量只在合法采集的范围内进行。
5.2 中文乱码与编码问题
requests 返回的页面编码判断错了,中文直接显示为乱码,这个坑十个人有九个踩过。当当的页面编码在不同页面可能不同,不能写死resp.encoding = 'utf-8'。
正确做法是用resp.apparent_encoding让 requests 自动判断编码,或者直接从响应头里拿 char set。但apparent_encoding偶尔会误判,所以我实际用的方式是先用resp.content.decode('utf-8', errors='ignore')尝试,抓取失败再用 gb18030 解码兜底。最终清洗出来的数据入库时全部统一转成 UTF-8,MySQL 连接参数里也要带上charset='utf8mb4',否则存入 emoji 或生僻字时会报错。
5.3 大屏在不同分辨率下错乱
原生 px 写死宽度是自坑第一来源。我前一个版本在 1920 笔记本上完美,换到 1366 的笔记本演示就直接横向溢出,图表的 tooltip 跑到屏幕外,非常掉价。
解决方案就是前面提到的 rem 自适应方案,但还需要注意图表容器的高度也要用 rem 指定。ECharts 的chart.resize()方法需要在容器 size 变化后调用,我在自适应脚本里加了一个 debounce 函数,保证窗口连续变化时不会频繁触发 resize 导致卡顿。
5.4 数据库查询过慢与 N+1 问题
大屏第一次加载时,如果所有统计接口都实时跑 SQL,你会看到页面空白好几秒。除了加 Redis 缓存外,还要注意 ORM 的 N+1 查询问题。
最典型的场景是详情页展示图书列表时,需要同时显示每本书的出版社名称和封面地址,如果循环取出每条记录再逐条查询关联表,几百本书就会产生几百条 SQL。解决办法是用select_related或prefetch_related提前把关联数据加载到内存,或者干脆把需要展示的字段直接冗余在 book 表里。展示型项目我不反对适度冗余,一张宽表解决 90% 的查询场景。
5.5 容易被忽视的更新节奏设计
数据不是只爬一次就完事的。我当时反复修改清洗规则,导致库里的数据和原始 CSV 对不上,非常头疼。
我的建议是给数据加版本标记,每次重爬或重清洗时记录一个 batch_id。大屏展示时可以只查最新 batch 的数据,出问题时也可以快速回退到上一批数据。增量更新方面,我写了一个定时脚本,每周跑一次,检查已有图书的价格和评论数是否有变化,有变化则更新记录,新增图书则插入。这样既保证了大屏演示时数据是新鲜的,又不会因为全量重爬消耗太多请求配额。
做完整套系统之后,我最大的感受是:不要被“机器学习”这四个字吓住,这类项目真正的难点从来不是算法多高深,而是你能不能把每个环节都稳稳地串起来。如果你想让这个项目更进一步,可以考虑把推荐模块加上用户行为日志做成协同过滤,或者把大屏的数据刷新改成 WebSocket 全双工推送,甚至把离线计算脚本换成 Spark 处理百万级数据。但前提永远是先把当下这条链路跑通跑顺,否则换什么技术栈都一样抓瞎。我的经验是:留出至少两天时间专门做数据质量抽检和页面细节打磨,这笔时间投入的回报远远高于多写两个用不上的功能。