1. 项目整体架构与选型思考为什么是Django加ECharts每年毕业设计季总有一批同学绕不开“XX数据分析可视化系统”这个题目而图书方向更是热门中的热门。大多人上来就是爬当当网的书目数据、存数据库、画几个图表、加个推荐功能拼成一个能演示的系统。听起来很简单实际做下来才发现从爬虫到可视化之间隔着好多个坑——光是数据清洗就能耗掉你三分之一的时间更别提推荐模块怎么自圆其说。我做完这个项目最大的体会是这个题目的核心难点不在某一个环节而在“全链路”的打通。任何一个环节断掉整个系统都跑不起来。所以先把架构想清楚比急着写爬虫重要得多。1.1 技术栈选型我为什么把宝压在Django上先看骨架。整个项目的主干是爬虫层requests BeautifulSoup采集当当网图书的分类页和详情页数据存储层MySQL存结构化书籍数据Redis做缓存和热点数据加速后端层Django框架对外提供可视化大屏的数据接口和推荐接口可视化层ECharts渲染大屏直接写在前端模板里不额外引Vue这类重框架推荐模块基于规则的热度推荐 基于物品的简单协同过滤Django不是唯一选择但它在这个场景下有天然优势自带ORM省去手写SQL的麻烦自带Admin后台可以快速查看和维护爬回来的数据自带模板系统大屏页面可以直接用Django模板渲染不需要单独建前端项目。Flask虽然轻量但数据库迁移、表单校验、后台管理这些都要自己搭做完整个项目你会发现工作量翻了一倍。FastAPI异步性能虽好但毕设场景里根本用不到那么高的并发而且招架不住答辩老师追问“你为什么不用Django”。1.2 项目目录布局爬虫与Web如何共存很多人习惯把爬虫代码和Django项目放在同一个根目录下结果迁移环境、部署上线时一团乱麻。我用的目录布局是这样的你可以直接参考book_analysis/ ├── manage.py ├── book_analysis/ # Django主配置 │ ├── settings.py │ ├── urls.py ├── apps/ │ ├── books/ # 图书数据模块模型、接口 │ ├── recommend/ # 推荐模块 ├── crawler/ # 独立爬虫模块 │ ├── spiders/ │ │ ├── dandang_category.py │ │ ├── dandang_detail.py │ ├── middlewares.py │ ├── pipeline.py │ ├── run.py ├── scripts/ │ ├── clean_data.py │ ├── load_to_mysql.py ├── templates/ │ ├── dashboard.html ├── static/ │ ├── echarts/ │ ├── css/ │ └── js/为什么爬虫要独立成一个包因为爬虫和Django是两个生命周期完全不同的东西。Django是常驻服务而爬虫是周期性任务——你不可能让爬虫跑在Django的进程里万一爬虫卡住了整个Web服务也跟着卡死。独立出来之后爬虫挂掉了不影响Web端反过来Web崩了也不影响你重新跑爬虫。还有个细节Django的模型和爬虫之间不要直接互相导入。我当时的做法是爬虫先把数据存成JSON或直接写进MySQLDjango这边用ORM去读现有的表。这样两边解耦如果将来想换爬虫框架比如ScrapyWeb端一行代码都不用改。1.3 这个项目到底算不算“大数据”说句实话爬个当当网几万条数据离真正的大数据还差得很远。但毕设题目里带了“大数据”三个字你就得在架构设计上给它一个交代。我的处理方式是数据处理流程上体现大数据的思想爬取、清洗、存储、分析、可视化的全链路每一步都有明确的产品逻辑数据规模上尽量扩充不只要爬一个分类而是把计算机、文学、经济、历史、少儿、生活等主要分类都爬一遍数据量做到几万条足够撑起可视化大屏的效果技术亮点上结合Redis缓存和MySQL索引优化体现对数据读性能的考虑这其实是毕设答辩的策略问题。你要让老师看到你理解数据处理的每一个环节而不是真的在大规模集群上跑过分布式计算。2. 当当网爬虫的设计与反爬应对爬虫是整个项目的数据源头这一层垮了后面全是空中楼阁。我选择当当网而不是京东、淘宝原因很简单当当网的页面结构相对规整书籍分类清晰而且没有淘宝那种变态的登录验证。但这不代表没有坑。2.1 爬虫目标与字段设计先想清楚要爬什么字段。可视化大屏需要展示的数据和推荐系统需要的数据决定了字段设计。我当时定的核心字段如下字段说明用途title书名展示、推荐author作者展示publisher出版社分析出版社分布publish_date出版时间年份趋势分析isbnISBN号唯一标识、去重price_now当前售价价格区间分布price_original原价计算折扣力度discount折扣价格策略分析comment_count评论数热度计算good_rate好评率口碑分析category所属分类分类对比detail_url详情页链接去重、二次爬取description商品简介文本挖掘可选项注意价格和评论数必须以字符串形式爬回来然后统一在清洗阶段转成数值。因为网页上的“45.00”、“1.2万条评论”这些格式不能直接入库这一步千万别省。2.2 分类页爬取解析思路与频率控制当当网的图书分类页URL格式相对固定http://category.dangdang.com/cp01.xx.xx.xx.00.00.html每个分类下的商品列表页翻页通过URL中的页码参数控制。我用的策略是“广度优先”——先拿分类列表再逐页遍历每个分类下的小说列表每页抓取图书的基础信息。这一层的核心就是避免请求频率过高被封IP。我第一次写的时候没做节流一口气发了2000个请求爬到第500多页的时候IP直接被封了整整一天访问当当都是验证码页面。后来老老实实加了随机延时每次请求后sleep(1~3秒)随机取值并且用了一个UA池随机切换浏览器标识。实测下来单机千页以内的爬取量这个频率完全够用。关于Robots协议和学习用途我在这里多说一句爬虫项目做毕设和学习完全没问题但别拿它做商用更不要用暴力爬取的方式给目标站点造成压力。论文和答辩时也尽量弱化“绕过反爬”的细节重点讲数据采集的架构设计。2.3 详情页爬取这个环节才是最容易被忽略的分类页拿到的只是列表信息很多有价值的字段——比如ISBN、出版时间、商品简介、好评率——都在详情页里。所以爬虫要分两步走先爬列表页拿到基础信息和详情页URL再写一个详情页爬虫去补全信息。这里有一个性能瓶颈详情页并发请求多了必封IP。我当时用的是单线程循环加延时的方式爬5000本详情页大概花了4个多小时。如果你想提速可以引入concurrent.futures的ThreadPoolExecutor设置8~16个线程每个线程之间共享请求延时逻辑。但记住线程数千万不要开太猛否则就是主动吸引反爬的注意。2.4 爬虫的容错与断点续爬半夜断掉是常态爬了几千条数据网络抖动、请求超时、页面结构微调、编码乱码——什么情况都可能遇到。如果没有容错机制睡一觉起来发现爬虫在中途挂掉了所有数据都要重来心态直接爆炸。我的做法是三层容错数据库去重以detail_url作为唯一键插入数据前先查数据库是否已有该URL有就跳过。本地日志记录每成功爬取一个详情页就往日志文件里写一行URL。爬虫重启时先读日志跳过已经爬过的URL。异常捕获和重试对requests.get做三层重试超时时间设为15秒连续失败3次就跳过不要在一个异常页面上死磕。用了这三层保障之后即使爬虫半路挂掉重新运行就能接着爬不用从头再来。3. 数据清洗与存储从杂乱的HTML到结构化数据爬虫跑完只是第一步从网页上拿下来的数据是“脏”的。我自己统计过原始数据里大约有15%~20%的脏数据价格带单位、评论数带“万”字、作者带空格和换行、ISBN缺失……如果不清洗后面的可视化图表和推荐模块全都会失真。3.1 清洗时最常见的五类脏数据我把清洗过程拆成五个常规处理你自己做的时候可以对照检查去重通过detail_url或者isbn去掉重复记录。这两者稍有不同——ISBN如果缺失就不适合做唯一键所以我主用detail_url有ISBN再补个索引。字段剥离把“45.00”里的“”去掉价格字段统一转成float如果原价和现价都是“暂无”整条记录标记为异常后续做价格分析时排除。数值单位化评论数“1.2万条评论”要转成12000这种整数。这里的转换逻辑要写对——带“万”字的乘以10000四舍五入到整数。千万记住先判断字符串里有没有“万”再决定怎么转。正则提取ISBN和出版日期用正则从长文本里抠出来。ISBN的标准格式是13位数字或中间带连字符出版日期形如“2019-01-01”正则表达式分别是\d{13}|\d{10}和\d{4}-\d{2}-\d{2}。缺失值填充缺失字段量少就做删除量多就做默认值处理。评论数为空填0好评率为空填98出版日期为空填“未知”。但填充逻辑要跟着分析需求走——如果你要做年份趋势图“未知”年份的记录就不应该占据图表的面积。3.2 清洗代码示例一条生产可用的清洗管线清洗我不能只是空谈概念直接给你看一段核心代码。我用的是逐字段清洗的思路把每条数据都做成标准字典最后再批量入库import re def clean_book_record(raw_data): cleaned {} # 书名去除首尾空格和换行 cleaned[title] raw_data.get(title, ).strip() # 作者多作者用 “/” 分隔末尾可能有空格 author raw_data.get(author, ).strip() cleaned[author] author if author else 未知 # 出版社同样处理 publisher raw_data.get(publisher, ).strip() cleaned[publisher] publisher if publisher else 未知 # 价格处理去掉货币符号转float price_now raw_data.get(price_now, 0) cleaned[price_now] float(re.sub(r[^\d.], , price_now)) price_original raw_data.get(price_original, 0) cleaned[price_original] float(re.sub(r[^\d.], , price_original)) # 折扣如果原价不为0则计算折扣否则保留默认值1.0 if cleaned[price_original] 0: cleaned[discount] round(cleaned[price_now] / cleaned[price_original], 2) else: cleaned[discount] 1.0 # 评论数转换处理1.2万条评论这类值 comment raw_data.get(comment_count, 0) if 万 in comment: cleaned[comment_count] int(float(comment.replace(万, )) * 10000) else: cleaned[comment_count] int(re.search(r\d, comment).group()) if re.search(r\d, comment) else 0 # 好评率字符串百分比转小数 good_rate raw_data.get(good_rate, 98%) cleaned[good_rate] float(re.sub(r[^\d.], , good_rate)) if good_rate else 98.0 # 出版日期 date_match re.search(r(\d{4})-(\d{2})-(\d{2}), raw_data.get(publish_date, )) cleaned[publish_date] date_match.group() if date_match else 未知 # ISBN isbn_match re.search(r\d{13}|\d{10}, raw_data.get(isbn, )) cleaned[isbn] isbn_match.group() if isbn_match else return cleaned这段代码没什么高深语法但每条清洗规则都对应真实场景。你写完清洗脚本以后建议挑50条原始数据手动跑一遍——我当初就是靠这种方式排除掉了好几个正则写错的坑。3.3 SQLAlchemy和Django ORM数据入库的两种姿势这个项目里有个小尴尬爬虫模块没接Django的ORM所以我用了SQLAlchemy做数据的ORM映射而Django的Web侧用自带的ORM读MySQL。两个ORM操作同一个数据库完全没有问题只要你保证表结构一致。SQLAlchemy入库的核心点是批量插入。一条一条add()然后commit()5000条数据能给你跑小半天。用session.bulk_insert_mappings()或者pandas.to_sql()把数据组装成批量事务速度提升几十倍。而且批量操作之前千万别忘了先清掉表中已有的重复URL不然报错的时候你根本分不清是哪条数据触发的唯一约束冲突。MySQL建表时的几个关键设计title字段加全文索引或普通索引方便搜索和去重category字段加普通索引因为可视化大屏经常按分类做group bycomment_count字段建普通索引因为热度推荐排序高频用到表字符集用utf8mb4不然生僻字、特殊符号全会变成乱码3.4 Redis进来干什么可视化接口和推荐的加速层很多做毕设的同学不太理解Redis在这个项目里的作用总觉得是硬凑技术栈。其实不然。大屏页面打开时会同时请求好几个聚合接口——分类销量、价格分布、出版社排名、评论趋势、热门推荐。每个接口都实时查MySQL页面的响应时间能到3秒以上演示起来非常卡。我的解法是在Django的接口层加一个Redis缓存。每个接口的查询结果设置5分钟的过期时间缓存命中的时候直接返回JSON只有缓存失效了才去查MySQL。大屏演示期间Redis命中率能到90%以上页面响应时间从3秒降到300毫秒以下。这个优化在答辩时提出来老师都会觉得你有性能意识。可以配一个简单的cache.py工具函数封装get和set逻辑再给接口视图加个装饰器复用性很高。4. 可视化大屏从SQL到ECharts的实现思路大屏是整个项目的门面也是答辩时老师第一眼看到的东西。很多人把大屏做成了“一堆图表的堆砌”看起来很炫但没有逻辑。我的做法是先想清楚大屏要给谁讲故事、用什么顺序讲。4.1 数据接口设计一个请求拿到大屏所有数据大屏页面需要的数据通常包括总图书数、总评论数、平均价格、平均折扣、各分类图书数量、出版社Top10、图书价格区间分布、评论数Top20图书列表、年度出版趋势。如果每个指标都单独请求一次接口网络开销大前端逻辑也绕。我把这些数据整合到一个基于Django视图函数的JSON接口里def dashboard_data(request): cache_key dashboard_all data cache.get(cache_key) if data: return JsonResponse(data) # 用ORM做聚合统计 total_books Book.objects.count() total_comments Book.objects.aggregate(Sum(comment_count)) # 分类分布 category_data list(Book.objects.values(category) .annotate(countCount(id)) .order_by(-count)[:10]) # 出版社Top10 publisher_data list(Book.objects.values(publisher) .annotate(countCount(id)) .order_by(-count)[:10]) # 这里再组装其他聚合结果…… result {total_books: total_books, category_data: category_data, publisher_data: publisher_data} cache.set(cache_key, result, 300) return JsonResponse(result)有两点说下用Django的aggregate和annotate替代逐条循环统计数据量大时性能差异是数量级的。Redis缓存这层必须加。大屏的定时刷新和大屏打开瞬间的高频请求如果没有缓存MySQL的连接池会被瞬间打满。4.2 ECharts选型我用的是原生ECharts而不是PyechartsPyecharts确实方便Python直接生成HTML但它的定制性和页面融合度不如原生ECharts。我建议把ECharts的JS文件下载到static/echarts目录下用Django模板渲染大屏页面然后在script里写ECharts初始化代码。这样你可以完全控制图表的布局、颜色、动画甚至实现图表之间的联动。大屏布局我参考了常见的运营大屏结构做了上下左右分区顶部中间总数据指标总图书数、总评论数、平均价格用动态数字展示左侧分类Top10横向柱状图、价格区间分布饼图中间主体热门图书榜单和图书封面词云用书名关键词生成词云球体效果右侧出版社Top10横向柱状图、年度出版趋势折线图一个比较出效果的小技巧分类柱状图初始化时加上动画排序效果——ECharts的barSort配合timeline可以做动态排序图图书分类每年排名变化这种动效答辩展示时全员抬头。4.3 图表与接口数据格式的对应关系这里最容易踩坑ECharts的数据格式要求和后端直接返回的序列化结果经常对不上。比如饼图需要的是[{ name: 计算机, value: 123 }, ...]柱状图需要的是{ categories: [计算机, 文学], values: [123, 456] }。Django返回的数据默认是QuerySet序列化后的列表你不能指望前端直接拿来用必须在接口里做一次“视图模型组装”。我建议写一个serializers.py专门做数据格式转换每种图表配置一个函数比如to_pie_data(queryset)、to_bar_data(queryset)。这样后端返回的JSON天然就是前端图表能直接消费的格式前端不需要做任何二次加工。这个设计是很多参考项目里缺失的但却是保证大屏开发效率的关键。5. 图书推荐不引入重型算法库的实用方案推荐系统是这个项目里最有“机器学习”含金量的部分。但我要说实话——毕设量级的图书推荐根本不需要跑深度学习模型也不需要引入scikit-surprise这种专业推荐库。老师要看到的是你理解推荐的基本逻辑并且能根据场景选择合适的方法。5.1 两种简单有效的推荐模式我做了两种推荐第一种是热度推荐也叫“大家都在看”。核心是一个加权热度公式hot_score 0.6 * comment_count / max(comment_count) 0.3 * good_rate / 100 0.1 * (1 - discount)评论数反映了大众关注度好评率反映了质量认可折扣反映了价格吸引力。把三者归一化到0~1区间再加权得到每本书的hot_score取Top10推给新用户。这个公式看着简单但答辩时很好讲——你能解释清楚每个权重的业务含义。第二种是相似图书推荐基于“同分类评论数相近价格区间相近”的规则。用户点了一本《活着》系统就去找同分类文学下评论数和价格区间相近的其他书。这个不用算向量余弦相似度只需要在Django ORM里用filter(category当前书的分类).exclude(id当前书.id).order_by(-comment_count)取前几本。效果不算精密但放在毕设里完全够用。5.2 要不要做协同过滤如果时间充裕可以做一个基于物品的协同过滤ItemCF——计算所有图书两两之间的“喜欢关联度”。用户在当前页面停留超过30秒或者按下“感兴趣”按钮就记为一次隐性行为然后去数据库里查找哪些书“同时被喜欢”的频率最高。配合Redis做用户行为的实时记录协同过滤的推荐结果能做到秒级更新。但这里有一个隐藏问题数据稀疏性。当当网的图书评论数据分布不均匀畅销书的评论数可能是普通书的几千倍计算出来的相似度很容易被“头部效应”带偏。所以做协同过滤时我会先过滤掉评论数小于50的书再计算相似度得到的推荐结果更符合直觉。5.3 推荐接口和前端展示推荐接口独立成一个视图用GET参数接收用户当前浏览的图书ID返回推荐列表。新用户没有浏览记录时走热度推荐返回{recommend_type: hot, books: [...]}老用户带行为数据时走协同过滤返回{recommend_type: similar, books: [...]}。前端大屏可以加一个“为你推荐”的区域展示推荐结果点击后弹出书籍详情。这里可以加一个非常讨巧的设计推荐结果理由栏。比如“因为你看了《活着》所以我们推荐余华的《许三观卖血记》”。这种“基于规则的推荐”解释逻辑天然适合答辩展示比一个黑盒模型更能说明你理解了推荐机制。6. 部署上线、性能优化与毕业答辩的常见追问项目做完了真正让它“落地”的是部署环节。很多同学只会在本地python manage.py runserver答辩演示时局域网一卡就露怯。我把部署和性能优化的经验写在这里都是踩过坑换来的。6.1 生产部署不要再用runserver了runserver是纯单线程的开发服务器只适合调试。生产环境我建议用Gunicorn Nginx的组合。Gunicorn作为Django的WSGI服务器负责跑Python进程Nginx负责静态文件、反向代理和负载分流。具体配置不展开细说Django官方文档都有。这里提醒三个容易出错的地方settings.py里DEBUG False后静态文件必须交给Nginx处理否则样式和ECharts全部丢失数据库连接配置建议放到环境变量里不要写死在代码里MySQL的max_connections调大一些默认151在并发大屏请求时经常不够用6.2 Django ORM性能N1问题是重灾区大屏的图书列表、排行榜经常要联表查询。最典型的坑是你在模板里循环显示每本书的分类名循环内每次都查一次分类表500本书就是5001次查询页面活活卡死。解决办法是当你在视图中取图书列表时books Book.objects.select_related(category).only(id, title, price_now, category__name)这样一条SQL用JOIN把关联数据一次性取回来。如果遇到多对多关系用prefetch_related代替。这个优化做完列表接口的响应时间能降一个数量级。6.3 答辩时老师可能会问的问题根据我和同学的实际答辩经历老师对这类项目的追问套路集中在这几类上你的爬虫有没有违法风险回答方向仅用于学习和研究控制请求频率不采集个人隐私信息不做商用。数据量多大为什么用MySQL够用回答方向做这个项目用了2万条图书数据MySQL配合索引和Redis缓存完全可以支撑真正的海量数据才需要引入Hadoop等分布式方案。推荐算法为什么不用深度学习回答方向当用户行为数据不足时深度学习容易过拟合基于规则的推荐可解释性更强适合这个场景。如果数据量翻到百万级系统怎么改造回答方向爬虫层引入Scrapy分布式爬虫存储层引入分库分表或ClickHouse缓存层用Redis集群。这些问题都不难关键是你要对每个环节都能说清楚“为什么这么选”和“瓶颈在哪”。提前做一次预答辩演练基本就不会冷场。整个项目从爬虫到可视化再到推荐核心价值不在某一个炫技点上而在全链路的工程化思维。数据量不大技术栈也算不上最新但你能把每一个环节的取舍理由讲明白就已经赢过大多数只跑通Demo的同届同学了。