简介:这份资源是面向计算机专业学生与Python初学者的毕业设计完整方案,围绕豆瓣电影数据爬取、分析与可视化展开,可解决选题落地、代码实现与论文撰写的一站式需求。包内共1054个文件,以688个py源码、157个pyc编译文件为主体,辅以html、js、css等前端页面与ECharts图表资源,另有sql数据库脚本、docx论文文档及csv数据样例,压缩包约30.07MB,目录结构完整便于按模块查阅。项目基于Flask搭建后端、pandas完成数据清洗处理、爬虫采集豆瓣电影信息,并通过ECharts实现多角度动态图表展示,帮助用户直观识别影片数据规律。已有257人学习下载,适合需要参考完整赛题实现、复用爬虫与可视化代码、对照论文框架查漏补缺的读者。
1. 从一份能跑起来的毕设说起:豆瓣电影数据从爬到看的一条龙
每年到了毕设季,后台被问得最多的一类问题就是「有没有能直接跑起来的 Python 数据分析项目」。这套基于 Flask + ECharts + 爬虫 + pandas 的豆瓣电影数据分析与可视化系统,正好踩在这个需求点上:它把「爬数据 → 清洗入库 → 后端接口 → 前端图表」整条链路都做完了,还附带 SQL 脚本和论文,属于拿到手就能跑、跑完还能照着改的那一类。适合谁?一是赶毕设需要完整闭环的同学,二是想练手 Flask 全栈 + pandas 分析的中级开发者。它不追求高并发、不上微服务,但胜在链路完整、代码直白,你能清楚看到一条数据从网页到图表的全过程。
2. 技术栈与目录结构:先看清这套系统由哪几块拼起来
2.1 四层技术选型,各管一段
这套系统的技术栈没有花活,全是毕设场景下最稳的组合,拆开看是四层:
| 层次 | 技术 | 承担职责 |
|---|---|---|
| 数据采集 | requests + BeautifulSoup / lxml | 抓取电影列表、评分、评论等页面数据 |
| 数据处理 | pandas + numpy | 清洗、去重、类型转换、分组聚合 |
| 数据存储 | MySQL / SQLite + SQL 脚本 | 建表、落库、供后端查询 |
| 展示层 | Flask + ECharts + Jinja2 | 提供接口与页面,前端渲染图表 |
选 Flask 而不是 Django,是因为这个项目接口数量有限、不需要自带 admin 和 ORM 全家桶,Flask 轻、路由清晰,新手改起来不容易迷路。ECharts 负责可视化,柱状图、饼图、词云、折线趋势都能覆盖,配置项写 JSON 就行,比手撸 canvas 省事太多。pandas 在这里是绝对主力,清洗和聚合基本靠它,SQL 只做存储和简单查询,复杂统计尽量在 pandas 里完成,这样逻辑集中、调试方便。
2.2 目录结构长什么样
一个典型的工程目录大致是这样组织的(不同版本可能略有出入,但主干一致):
douban_movie_analysis/ ├── app.py # Flask 入口,注册路由与蓝图 ├── config.py # 数据库连接、密钥等配置 ├── requirements.txt # 依赖清单 ├── spider/ │ ├── movie_spider.py # 电影列表与详情爬取 │ └── comment_spider.py # 短评爬取 ├── analysis/ │ ├── clean.py # 数据清洗脚本 │ └── stats.py # 统计分析函数 ├── models/ │ └── movie.py # 数据表映射与查询封装 ├── static/ │ ├── js/ # ECharts 及页面脚本 │ └── css/ ├── templates/ │ ├── index.html # 首页概览 │ └── detail.html # 单部电影详情 ├── sql/ │ └── init.sql # 建表与初始数据脚本 └── data/ └── movies.csv # 爬取后的中间数据这个结构的好处是职责边界清楚:spider只管抓,analysis只管算,models只管查,app.py只做路由分发。二次开发时你想加一个「按年份统计票房」的图,基本只需要动analysis/stats.py加一个函数、app.py加一条路由、前端加一个图表容器,三处改动互不干扰。
2.3 数据表设计:三张表撑起整个分析
SQL 脚本里核心就三张表,字段设计直接决定了后面能做什么分析:
-- 电影主表 CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, -- 电影名 year INT, -- 上映年份 country VARCHAR(64), -- 国家/地区 genre VARCHAR(128), -- 类型,多个用 / 分隔 rating FLOAT, -- 评分 votes INT, -- 评价人数 director VARCHAR(64) -- 导演 ); -- 短评表 CREATE TABLE comment ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT, content TEXT, star INT, -- 星级 1-5 FOREIGN KEY (movie_id) REFERENCES movie(id) ); -- 类型维度表(可选,用于多类型拆分统计) CREATE TABLE genre ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT, genre_name VARCHAR(32) );genre字段用/分隔多个类型,是为了爬取时省事,但做「各类型电影数量」统计时就得先拆开,这也是后面清洗环节要处理的重点。votes存评价人数而不是字符串「12万人看过」,是为了能直接做数值排序和分桶,这类「存数值不存展示文本」的习惯,在数据分析项目里能省掉大量后期麻烦。
3. 爬虫与数据清洗:一条数据从网页到 DataFrame 的完整链路
3.1 爬虫主流程:请求、解析、翻页、落盘
爬虫部分的核心逻辑不复杂,但几个细节决定了它能不能稳定跑完。下面这段是电影列表爬取的骨架:
import requests from bs4 import BeautifulSoup import pandas as pd import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" } def fetch_page(url, retry=3): """带重试的页面请求,失败返回 None""" for i in range(retry): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 200: return resp.text except requests.RequestException: pass time.sleep(random.uniform(1, 3)) # 随机间隔,降低被封风险 return None def parse_movies(html): """解析单页电影列表,返回字典列表""" soup = BeautifulSoup(html, "lxml") rows = [] for item in soup.select("div.item"): title_tag = item.select_one("span.title") rating_tag = item.select_one("span.rating_num") votes_tag = item.select_one("span.star span:last-child") if not title_tag or not rating_tag: continue rows.append({ "title": title_tag.get_text(strip=True), "rating": float(rating_tag.get_text(strip=True)), "votes": int(votes_tag.get_text(strip=True).replace("人评价", "")) if votes_tag else 0, }) return rows def crawl(total_pages=10): all_rows = [] for page in range(total_pages): url = f"https://example.com/top250?start={page * 25}" html = fetch_page(url) if not html: print(f"第 {page} 页抓取失败,跳过") continue all_rows.extend(parse_movies(html)) time.sleep(random.uniform(2, 5)) df = pd.DataFrame(all_rows) df.to_csv("data/movies.csv", index=False, encoding="utf-8-sig") return df逐段说清楚:fetch_page里加了重试和随机 sleep,这是爬虫能不能跑完的关键,很多新手直接requests.get一把梭,跑到一半被限流就前功尽弃。parse_movies用 CSS 选择器定位,select_one找不到返回 None,所以每个字段都要判空,否则一条脏数据就能让整个循环崩掉。votes字段从「12345人评价」里剥出数字,用replace去掉中文后缀再转 int,这一步不做,后面 pandas 里这列就是 object 类型,排序和分桶全废。crawl里翻页参数start每次加 25,是列表页的固定步长,total_pages按你需要的数据量调,抓太多既慢又容易触发风控。
提示:爬取频率和总量要克制,
time.sleep的随机区间别省,这是血泪经验——省掉它省下的时间,最后都会花在重跑上。
3.2 清洗:把「能看」的数据变成「能算」的数据
爬下来的原始数据通常有一堆问题:类型字段带斜杠、年份缺失、评分是字符串、重复条目。清洗脚本要逐项处理:
import pandas as pd import numpy as np def clean_movies(path="data/movies.csv"): df = pd.read_csv(path) # 1. 去重:同名同年的视为重复 df = df.drop_duplicates(subset=["title", "year"], keep="first") # 2. 数值列强制转换,非法值变 NaN 后剔除 df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df["votes"] = pd.to_numeric(df["votes"], errors="coerce").fillna(0).astype(int) df = df.dropna(subset=["rating"]) # 3. 年份缺失用中位数填充,并转 int df["year"] = df["year"].fillna(df["year"].median()).astype(int) # 4. 类型字段拆分:'剧情/爱情' -> ['剧情', '爱情'] df["genre_list"] = df["genre"].fillna("未知").str.split("/") # 5. 派生列:评分等级分桶,方便做分布图 bins = [0, 6, 7, 8, 9, 10] labels = ["6分以下", "6-7分", "7-8分", "8-9分", "9分以上"] df["rating_level"] = pd.cut(df["rating"], bins=bins, labels=labels, right=False) return df几个参数值得展开:drop_duplicates的subset选title + year而不是只选title,是因为同名翻拍电影很常见,只按标题去重会误删。pd.to_numeric(errors="coerce")把无法转换的值变成 NaN 而不是抛异常,这是批量清洗的标准姿势。pd.cut的right=False表示左闭右开区间,[6,7)归到「6-7分」,边界归属要提前想清楚,否则统计结果会和预期差一截。genre_list这列是给后面「类型统计」用的,explode一下就能把一部多类型电影拆成多行,这是 pandas 做维度展开的常用手法。
3.3 统计分析函数:把聚合逻辑收在一处
分析层不直接写在路由里,而是抽成独立函数,返回前端能直接用的结构:
def genre_count(df): """各类型电影数量 Top10""" exploded = df.explode("genre_list") counts = exploded["genre_list"].value_counts().head(10) return {"genres": counts.index.tolist(), "counts": counts.values.tolist()} def year_rating_trend(df): """按年份统计平均评分趋势""" trend = df.groupby("year")["rating"].mean().round(2) trend = trend[trend.index >= 1990] # 过滤过早年份,样本太少 return {"years": trend.index.tolist(), "ratings": trend.values.tolist()} def rating_distribution(df): """评分等级分布,用于饼图""" dist = df["rating_level"].value_counts().sort_index() return [{"name": k, "value": int(v)} for k, v in dist.items()]explode是这里的关键,它把genre_list里的列表展开成多行,一部「剧情/爱情」的电影会变成两行,这样value_counts统计出来的才是「类型出现次数」而不是「电影数」。year_rating_trend里过滤 1990 年以前的数据,是因为早期样本量太小,平均分波动大,画到折线图上就是一根乱跳的线,反而干扰阅读。返回结构统一成{维度: [...], 数值: [...]}或 ECharts 直接能吃的[{name, value}],前端拿到就能setOption,不用再做二次转换。
4. Flask 接口与 ECharts 渲染:图表是怎么被喂出来的
4.1 路由设计:一个接口对应一张图
后端路由保持「一图一接口」的粒度,简单直接:
from flask import Flask, jsonify, render_template from analysis.clean import clean_movies from analysis import stats app = Flask(__name__) DF = clean_movies() # 启动时加载一次,避免每次请求重复清洗 @app.route("/") def index(): return render_template("index.html") @app.route("/api/genre") def api_genre(): return jsonify(stats.genre_count(DF)) @app.route("/api/trend") def api_trend(): return jsonify(stats.year_rating_trend(DF)) @app.route("/api/distribution") def api_distribution(): return jsonify(stats.rating_distribution(DF)) if __name__ == "__main__": app.run(debug=True, port=5000)DF在模块加载时算一次并常驻内存,是这个数据量级下的合理选择——几万条电影数据放内存毫无压力,每次请求重新读 CSV 再清洗纯属浪费。jsonify会自动设置Content-Type: application/json并序列化,注意 numpy 的int64、float64默认不能被 json 序列化,所以stats里返回前都做了int()、round()或.tolist()转换,这一步漏了就会报TypeError: Object of type int64 is not JSON serializable,是 Flask + pandas 组合里最常见的翻车点之一。
4.2 前端渲染:ECharts 初始化与数据对接
页面里每个图表容器对应一段初始化脚本,以类型柱状图为例:
// 类型分布柱状图 fetch('/api/genre') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('genreChart')); chart.setOption({ title: { text: '各类型电影数量 Top10' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: data.genres, axisLabel: { rotate: 30 } // 类型名较长,倾斜避免重叠 }, yAxis: { type: 'value', name: '数量' }, series: [{ type: 'bar', data: data.counts, itemStyle: { color: '#5470c6' } }] }); });echarts.init绑定 DOM 容器,setOption一次性把配置灌进去。axisLabel.rotate是处理长标签的实用参数,类型名像「剧情」「喜剧」还好,遇到「动画/奇幻/冒险」这种不倾斜就会糊成一片。tooltip.trigger: 'axis'让鼠标悬停时按整列显示,比单点触发更适合柱状图。如果图表在容器隐藏(比如 tab 切换)时初始化,会出现宽度为 0 的空白图,常见做法是切 tab 后调一次chart.resize(),这个坑几乎每个用 ECharts 的人都踩过。
4.3 页面组织:Jinja2 模板与静态资源
templates/index.html用 Jinja2 语法引入静态资源,结构上就是「容器 + 脚本」:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>电影数据分析看板</title> <script src="{{ url_for('static', filename='js/echarts.min.js') }}"></script> </head> <body> <div id="genreChart" style="width: 100%; height: 400px;"></div> <div id="trendChart" style="width: 100%; height: 400px;"></div> <script src="{{ url_for('static', filename='js/charts.js') }}"></script> </body> </html>url_for('static', filename=...)是 Flask 生成静态资源路径的标准写法,别硬编码/static/js/...,否则部署到子路径下就全 404。图表容器必须给明确高度,ECharts 靠容器尺寸计算画布,高度为 0 时图直接不显示,这是新手最常问的「为什么页面一片空白」的头号原因。
5. 跑起来与避坑:从零启动这套系统的实操记录
5.1 环境准备与启动步骤
按顺序走,基本不会出岔子:
# 1. 建虚拟环境(Python 3.8+ 均可) python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 装依赖 pip install -r requirements.txt # 3. 初始化数据库(MySQL 示例) mysql -u root -p your_db < sql/init.sql # 4. 改配置 # 编辑 config.py,填数据库地址、账号、密码 # 5. 先跑爬虫拿数据(也可直接用附带的 csv) python spider/movie_spider.py # 6. 启动服务 python app.py启动后访问http://127.0.0.1:5000就能看到看板。如果只想看效果不想等爬虫,直接用data/movies.csv里的现成数据,把clean_movies的路径指过去即可。requirements.txt里通常包含 flask、pandas、numpy、requests、beautifulsoup4、lxml、pymysql 这几项,lxml在部分环境需要编译,装不上就换html.parser作为 BeautifulSoup 的解析器,功能一样只是慢一点。
5.2 常见问题排查:五个真实踩过的坑
现象一:启动报ModuleNotFoundError: No module named 'analysis'。原因是从项目根目录以外的地方启动,Python 找不到包路径。解决:确保在项目根目录执行python app.py,或在入口文件顶部加sys.path.append(os.path.dirname(__file__))。
现象二:接口返回 500,日志里是Object of type int64 is not JSON serializable。原因是 pandas 聚合结果里的 numpy 类型没转成原生 Python 类型。解决:在stats函数返回前统一.tolist()或int(),别指望 Flask 帮你兜底。
现象三:图表容器有,但页面空白无图。原因多半是容器高度为 0,或 ECharts 在容器隐藏时初始化。解决:给容器写死height,tab 切换场景在显示后调chart.resize()。
现象四:爬虫跑几十条就中断,报连接超时或 403。原因是请求频率过高被限流。解决:加大time.sleep随机区间、补全User-Agent、失败重试,必要时减少total_pages,分批跑。
现象五:中文在图表或 CSV 里乱码。原因是编码不统一。解决:to_csv用encoding="utf-8-sig",HTML 里声明<meta charset="UTF-8">,数据库连接串加charset=utf8mb4,三处对齐就不会乱。
注意:改完
config.py记得重启服务,Flask 的debug=True会自动重载代码,但配置模块的改动有时不触发重载,别对着旧配置调试半天。
6. 二次开发:加一张图、换一个数据源的改造思路
拿到这套代码,最有价值的改造方向是「加维度」和「换数据源」,而不是推倒重来。加一张图的标准动作是三处联动:analysis/stats.py里写一个返回{维度, 数值}的聚合函数,app.py里挂一条/api/xxx路由,前端加一个容器和一段setOption。比如想加「导演作品数 Top10」,函数体就是df["director"].value_counts().head(10),路由和图表照抄现有模式,十分钟能跑通。这种「函数 → 接口 → 图表」的固定套路,是这个项目结构最大的红利。
换数据源时,关键是把清洗层当成适配器。不管你换成哪个电影数据来源,只要最终产出和clean_movies返回的 DataFrame 列名一致(title、year、rating、votes、genre_list、rating_level),上层分析和接口一行都不用改。我一般会先写一个to_standard(df)函数做列名映射和类型对齐,再让所有下游只认标准列,这样换源就是换一个适配函数的事。
验证改造是否成功,别只看页面出没出图,要拿几个已知数据点对一遍:比如某类型电影总数、某年份平均分,用 SQL 单独查一次和图表数字比对,对得上才算真跑通。下面这张表是我常用的自检清单:
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 数据完整性 | df.shape与源数据条数比对 | 差异在去重合理范围内 |
| 聚合正确性 | SQL 手查 vs 接口返回 | 数值一致 |
| 接口可用性 | 浏览器直接访问/api/xxx | 返回合法 JSON |
| 图表渲染 | 页面实际显示 | 有图、有数据、可交互 |
最后说个习惯:每次改完分析函数,我都会先用python -c在命令行里单独调一次,确认返回结构对了再接前端。直接改完就刷页面,出了问题你分不清是数据错、接口错还是图表配置错,排查成本翻倍。这套项目不大,但链路完整,把这条链路走顺一遍,比看十篇教程都实在。希望帮到你。
本文还有配套的精品资源,点击获取