简介:这是一套面向计算机专业本科生的毕业设计与课程大作业实战项目,基于Python+Flask构建豆瓣电影数据采集、存储、分析与可视化全流程系统,适用于期末大作业、毕业设计选题及爬虫与Web开发综合实践。资源包含110个文件,涵盖5个核心Python脚本(含爬虫主程序、数据清洗与Flask后端逻辑)、6个HTML前端页面、22个JS交互脚本、16个CSS样式文件及21张JPG图表图片,辅以SQLite数据库文件、Bootstrap/AOS/Boxicons等主流前端库资源,整体压缩包仅6.25MB,轻量易部署。已有241人学习下载,项目经助教审定、本地实测可运行,评审得分高达98分,难度适中但结构完整——提供清晰的MVC目录划分、带注释的爬虫反爬绕过逻辑、MovieLens风格的数据分析模块(评分分布、类型热度、年代趋势)及ECharts动态可视化看板,是少有的兼顾工程规范性与教学适用性的高分参考源码。
1. 豆瓣电影数据采集与分析可视化系统:为什么毕业设计选它,真能跑通、能讲清、还能拿高分?
很多同学在开题时盯着“爬虫+可视化”四个字发愁:怕反爬翻车、怕数据杂乱、怕图表假大空、更怕答辩被问“你这数据真实吗?清洗逻辑在哪?”。而豆瓣电影恰恰是个极佳的平衡点——它结构清晰(电影页有固定字段:评分、导演、主演、类型、年份、短评)、反爬强度适中(无验证码、无强JS渲染、User-Agent+Referer即可稳定获取)、社区数据丰富(千万级影评、万级标签、千级分类),且天然具备分析纵深:从单片热度→类型分布→年代趋势→口碑演化→导演风格聚类,每一步都能对应课程知识点(HTTP协议、正则/BS4/XPath解析、Pandas数据清洗、Matplotlib/Seaborn/ECharts可视化、甚至可拓展到情感分析或协同过滤)。这不是一个“能爬就行”的玩具项目,而是一个可闭环验证、可逐层拆解、可展示工程思维的完整数据链路。我带过的几届学生里,凡把豆瓣电影项目做到“本地一键启动→自动采集2000+影片→生成5类动态图表→导出PDF报告”的,答辩通过率接近100%,高分关键不在炫技,而在每行代码都经得起追问。
2. 用Flask搭起服务骨架:从零初始化一个可调试、可扩展的Web后端
2.1 初始化项目结构与依赖管理:为什么必须用虚拟环境+requirements.txt
毕业设计不是写完就扔的脚本,而是要让导师、答辩组、甚至后续维护者能3分钟内复现。第一步必须隔离环境。常见错误是直接pip install flask requests beautifulsoup4 pandas matplotlib全局安装——一旦版本冲突(比如某库要求pandas<2.0,另一库要>2.1),整个项目就成黑匣子。
# 创建独立虚拟环境(Python 3.8+推荐) python -m venv venv_douban source venv_douban/bin/activate # Linux/macOS # venv_douban\Scripts\activate.bat # Windows # 安装核心依赖(注意:requests>=2.28.0, beautifulsoup4>=4.11.0, pandas>=1.5.0) pip install flask requests beautifulsoup4 pandas matplotlib seaborn jieba wordcloud openpyxl # 冻结当前环境精确版本(关键!答辩时导师会核对) pip freeze > requirements.txt提示:
requirements.txt不是摆设。答辩前务必用全新虚拟环境pip install -r requirements.txt重装一次,确认无报错。曾有学生因本地装了matplotlib=3.7.0,而导师机上只有3.6.3,导致plt.style.use('seaborn-v0_8')报错,当场卡壳。
2.2 Flask最小可运行服务:路由设计与模板渲染逻辑
Flask的核心是“请求-响应”映射。本系统需支撑三类入口:首页(展示分析总览)、采集控制页(手动触发/查看进度)、图表页(按维度筛选渲染)。不建议用单文件app.py硬编码所有逻辑,采用模块化结构:
douban_analyzer/ ├── app.py # Flask应用工厂,初始化app、注册蓝图 ├── config.py # 配置类(DEBUG=True, SECRET_KEY等) ├── models/ # 数据模型(Movie、Review类,含字段定义) ├── crawler/ # 爬虫模块(douban_spider.py, utils.py) ├── analyzer/ # 分析模块(trend_analysis.py, genre_stats.py) ├── static/ # 静态资源(CSS/JS/生成的图表图片) └── templates/ # Jinja2模板(index.html, charts.html, crawl.html)app.py核心代码(精简版):
from flask import Flask, render_template, request, jsonify from crawler.douban_spider import start_crawl, get_crawl_status from analyzer.trend_analysis import generate_yearly_trend, generate_genre_pie def create_app(): app = Flask(__name__) app.config.from_object('config.Config') @app.route('/') def index(): # 首页加载基础统计:总影片数、平均分、热门类型TOP3 stats = { 'total_movies': 0, 'avg_rating': 0.0, 'top_genres': [] } # 实际中从数据库或缓存读取,此处简化为占位 return render_template('index.html', stats=stats) @app.route('/crawl', methods=['GET', 'POST']) def crawl_control(): if request.method == 'POST': # 接收前端POST的采集参数(起始页、页数、是否更新) start_page = int(request.form.get('start_page', 1)) pages = int(request.form.get('pages', 5)) update_mode = request.form.get('update_mode') == 'true' # 启动异步采集(生产环境用Celery,毕设用线程足够) from threading import Thread t = Thread(target=start_crawl, args=(start_page, pages, update_mode)) t.daemon = True t.start() return jsonify({'status': 'started', 'message': f'开始采集第{start_page}至{start_page+pages-1}页'}) return render_template('crawl.html') @app.route('/charts/<chart_type>') def render_chart(chart_type): # chart_type: yearly_trend, genre_pie, director_top10, rating_dist if chart_type == 'yearly_trend': fig_path = generate_yearly_trend() elif chart_type == 'genre_pie': fig_path = generate_genre_pie() else: fig_path = 'static/images/placeholder.png' return render_template('charts.html', chart_path=fig_path, chart_type=chart_type) return app if __name__ == '__main__': app = create_app() app.run(debug=True, host='0.0.0.0', port=5000)逻辑说明:
create_app()是Flask应用工厂模式,便于测试和部署;/crawl路由支持GET(显示控制页)和POST(接收采集指令),避免页面刷新中断采集;/charts/<chart_type>用URL变量动态路由,避免为每个图表写独立路由,降低维护成本;generate_yearly_trend()等函数返回的是静态图片路径(如static/images/trend_20240510.png),而非直接返回图像二进制——这是Web开发常识,但新手常误用send_file()导致浏览器缓存混乱。
3. 豆瓣电影数据采集:绕过基础反爬、处理动态加载、保证数据一致性
3.1 目标页面分析与请求构造:为什么不能只靠Requests+BeautifulSoup
豆瓣电影列表页(如https://movie.douban.com/top250?start=0&filter=)看似简单,但存在两个隐藏陷阱:
- AJAX分页加载:滚动到底部时,实际发起的是
GET /j/chart/top_list?type=25&interval_id=100:90&action=&start=0&limit=20请求,返回JSON数据,而非HTML; - 详情页结构变异:部分老电影(如1990年前)缺失“编剧”字段,部分新片(如2023年院线片)有“制片国家/地区”多值字段,若用固定XPath硬解析,必然丢数据。
因此,采集策略必须分层:
- 列表页:优先用AJAX接口(JSON格式,结构稳定,字段全);
- 详情页:对关键字段(导演、主演、类型、年份、评分)用容错XPath,对非关键字段(编剧、语言、IMDb链接)做
try-except兜底。
import requests from lxml import etree import time import random def fetch_movie_list(start, limit=20): """调用豆瓣AJAX接口获取电影列表(推荐)""" url = f"https://movie.douban.com/j/chart/top_list" params = { 'type': '25', # type=25 对应"电影Top250" 'interval_id': '100:90', 'action': '', 'start': start, 'limit': limit } headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://movie.douban.com/top250' } try: resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() return resp.json() # 返回标准JSON,含id、title、url、score等 except Exception as e: print(f"列表页请求失败: {e}") return [] def parse_movie_detail(movie_url): """解析单部电影详情页,容错处理字段缺失""" headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://movie.douban.com/' } try: resp = requests.get(movie_url, headers=headers, timeout=15) resp.raise_for_status() tree = etree.HTML(resp.text) # 导演:可能有多个,取第一个(XPath匹配所有a标签,取[1]) directors = tree.xpath('//div[@id="info"]//span[text()="导演"]/following-sibling::span[1]//a/text()') director = directors[0].strip() if directors else "未知" # 主演:同理,但取前3个(避免名单过长) actors = tree.xpath('//div[@id="info"]//span[text()="主演"]/following-sibling::span[1]//a/text()') actor_str = "/".join([a.strip() for a in actors[:3]]) if actors else "未知" # 类型:位于info区块末尾,用正则提取"类型:"后内容 info_text = tree.xpath('//div[@id="info"]/text()') genre = "未知" for text in info_text: if "类型:" in text: genre = text.split("类型:")[1].strip().split("/")[0].strip() # 取第一个类型 break # 年份:从标题中提取(如《阿凡达:水之道 (2022)》) title = tree.xpath('//span[@property="v:itemreviewed"]/text()') year = "未知" if title: import re year_match = re.search(r'\((\d{4})\)', title[0]) year = year_match.group(1) if year_match else "未知" return { 'director': director, 'actors': actor_str, 'genre': genre, 'year': year, 'rating': tree.xpath('//strong[@property="v:average"]/text()')[0].strip() if tree.xpath('//strong[@property="v:average"]/text()') else "0.0" } except Exception as e: print(f"详情页解析失败 {movie_url}: {e}") return {'director': '解析失败', 'actors': '解析失败', 'genre': '解析失败', 'year': '未知', 'rating': '0.0'}参数说明:
timeout=10/15:防止单次请求卡死,影响整体采集;headers['Referer']:豆瓣校验Referer,缺失则返回403;tree.xpath(...)[0]:加索引前必须判断列表非空,否则IndexError;- 年份提取用正则而非XPath,因HTML结构不稳定,而标题文本格式高度一致。
3.2 数据存储设计:SQLite轻量够用,但字段定义必须严谨
毕设不必上MySQL,SQLite单文件数据库完全胜任。但表结构设计决定后续分析难易度。关键原则:宁可冗余,不可缺失;宁可字符串,不可NULL。
-- movies.db 表结构(使用DB Browser for SQLite建表) CREATE TABLE movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, douban_id TEXT UNIQUE NOT NULL, -- 豆瓣唯一ID,如"1292052" title TEXT NOT NULL, -- 电影中文名 original_title TEXT, -- 原名(可能为空) year TEXT DEFAULT '未知', -- 字符串,避免INT类型无法存"未知" director TEXT DEFAULT '未知', actors TEXT DEFAULT '未知', -- 多主演用"/"分隔,方便后续split genre TEXT DEFAULT '未知', rating REAL DEFAULT 0.0, -- 浮点型,便于排序计算 rating_count INTEGER DEFAULT 0, -- 评价人数,用于可信度加权 crawl_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- reviews表(如需分析短评,可选) CREATE TABLE reviews ( id INTEGER PRIMARY KEY AUTOINCREMENT, movie_id TEXT NOT NULL, rating INTEGER, -- 1~5星 content TEXT, -- 短评正文 author TEXT, review_time TEXT, FOREIGN KEY (movie_id) REFERENCES movies(douban_id) );为什么用TEXT存year?
因豆瓣存在《未命名电影 (2024?)》《XXX (TBD)》等非标准年份,用INTEGER会强制转0,丢失信息。分析时用WHERE year GLOB '[0-9][0-9][0-9][0-9]'过滤即可。
4. 数据清洗与分析:从原始字段到可解释图表的必经之路
4.1 Pandas清洗实战:处理豆瓣数据的5类典型脏数据
豆瓣原始数据绝非干净表格,清洗是分析前提。以下代码基于movies.csv(由爬虫导出)进行,覆盖最常见问题:
import pandas as pd import numpy as np import re # 读取原始CSV(假设字段:douban_id,title,year,director,actors,genre,rating) df = pd.read_csv('data/raw_movies.csv', encoding='utf-8') # 1. 去重:同一电影因不同榜单重复采集(如Top250+冷门佳片榜) df = df.drop_duplicates(subset=['douban_id'], keep='first') # 2. 年份标准化:清理"2022年"、"2022?"、"TBD"等异常值 def clean_year(x): if pd.isna(x) or x == '未知': return '未知' # 提取4位数字,如"2022年"→"2022","2022?"→"2022" match = re.search(r'(\d{4})', str(x)) return match.group(1) if match else '未知' df['year'] = df['year'].apply(clean_year) # 3. 导演/主演去重与截断:解决"张艺谋 / 张艺谋 / 陈凯歌"、"主演1/主演2/主演3/主演4/..."问题 def clean_names(x): if pd.isna(x) or x == '未知': return '未知' # 按"/"分割,去重,取前3个 names = [n.strip() for n in str(x).split('/') if n.strip()] unique_names = list(dict.fromkeys(names)) # 保持顺序去重 return '/'.join(unique_names[:3]) if unique_names else '未知' df['director'] = df['director'].apply(clean_names) df['actors'] = df['actors'].apply(clean_names) # 4. 类型归一化:豆瓣类型有"剧情 / 爱情"、"剧情 / 爱情 / 同性",统一为首个主类型 df['main_genre'] = df['genre'].apply( lambda x: x.split('/')[0].strip() if isinstance(x, str) and '/' in x else x.strip() if isinstance(x, str) else '未知' ) # 5. 评分可信度过滤:剔除评价人数<1000的影片(避免小众片拉低均值) # 先补全rating_count字段(需爬虫时同步采集) # 此处假设已有rating_count列 df = df[df['rating_count'] >= 1000].copy() # 保存清洗后数据 df.to_csv('data/cleaned_movies.csv', index=False, encoding='utf-8-sig') # utf-8-sig兼容Excel中文血泪经验:
drop_duplicates必须用subset=['douban_id'],而非默认全部列,因标题可能有括号差异(《肖申克的救赎》vs《肖申克的救赎 (1994)》);re.search(r'(\d{4})', str(x))比str(x)[:4]可靠,因存在"199"、"202"等非4位年份;dict.fromkeys(names)是Python 3.7+保持插入顺序的去重法,比list(set(names))更可控。
4.2 生成核心分析图表:用Matplotlib/Seaborn画出答辩级图表
毕业设计图表不求酷炫,但求信息密度高、标注清晰、结论可读。以下3个图表覆盖答辩高频问题:
图表1:年度影片数量与平均分趋势(双Y轴)
import matplotlib.pyplot as plt import seaborn as sns import pandas as pd df = pd.read_csv('data/cleaned_movies.csv') # 过滤掉"未知"年份 df_year = df[df['year'] != '未知'].copy() df_year['year'] = df_year['year'].astype(int) # 转为整数便于排序 # 按年份分组统计 year_stats = df_year.groupby('year').agg({ 'douban_id': 'count', # 影片数量 'rating': 'mean' # 平均分 }).rename(columns={'douban_id': 'count', 'rating': 'avg_rating'}).reset_index() # 绘图 fig, ax1 = plt.subplots(figsize=(12, 6)) color1 = '#1f77b4' ax1.set_xlabel('年份', fontsize=12) ax1.set_ylabel('影片数量', color=color1, fontsize=12) line1 = ax1.plot(year_stats['year'], year_stats['count'], color=color1, marker='o', label='影片数量') ax1.tick_params(axis='y', labelcolor=color1) ax1.grid(True, alpha=0.3) ax2 = ax1.twinx() color2 = '#ff7f0e' ax2.set_ylabel('平均评分', color=color2, fontsize=12) line2 = ax2.plot(year_stats['year'], year_stats['avg_rating'], color=color2, marker='s', label='平均评分') ax2.tick_params(axis='y', labelcolor=color2) # 合并图例 lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') plt.title('豆瓣Top250影片:年度数量与平均分趋势(1920-2023)', fontsize=14, pad=20) plt.tight_layout() plt.savefig('static/images/yearly_trend.png', dpi=300, bbox_inches='tight') plt.close()为什么用双Y轴?
数量级差异大(数量千级,评分10分制),单Y轴无法同时看清两趋势。答辩时可指着图说:“可见2000年后影片数量激增,但平均分稳定在8.5-8.8,说明大众审美趋于成熟”。
图表2:类型分布饼图(突出TOP5,其余归为"其他")
from collections import Counter # 统计主类型频次 genre_counts = Counter(df_year['main_genre']) # 取TOP5,其余归"其他" top5_genres = genre_counts.most_common(5) other_count = sum(count for genre, count in genre_counts.items()) - sum(count for _, count in top5_genres) top5_genres.append(('其他', other_count)) labels, sizes = zip(*top5_genres) colors = plt.cm.Set3(range(len(labels))) plt.figure(figsize=(10, 8)) plt.pie(sizes, labels=labels, autopct='%1.1f%%', colors=colors, startangle=90) plt.title('豆瓣Top250影片类型分布(TOP5+其他)', fontsize=14, pad=20) plt.axis('equal') plt.savefig('static/images/genre_pie.png', dpi=300, bbox_inches='tight') plt.close()避坑点:
plt.axis('equal')确保饼图是圆的,否则答辩PPT里显示为椭圆,显得不专业。
图表3:导演作品数量TOP10(水平条形图,降序排列)
director_counts = df_year['director'].value_counts().head(10) # 降序排列(value_counts默认降序,但显式sort确保) director_counts = director_counts.sort_values(ascending=True) plt.figure(figsize=(10, 6)) bars = plt.barh(director_counts.index, director_counts.values, color='#2ca02c') plt.xlabel('作品数量', fontsize=12) plt.title('导演作品数量TOP10(豆瓣Top250)', fontsize=14, pad=20) # 在条形右侧添加数值标签 for i, (v, name) in enumerate(zip(director_counts.values, director_counts.index)): plt.text(v + 0.1, i, str(v), va='center', fontsize=10) plt.tight_layout() plt.savefig('static/images/director_top10.png', dpi=300, bbox_inches='tight') plt.close()为什么水平条形图?导演姓名较长(如“克里斯托弗·诺兰”),垂直柱状图文字会重叠,水平图天然适配。
5. 常见问题排查:答辩前必须扫清的5个致命坑
5.1 现象:采集到的影片数量远少于预期(如只爬到50部,目标250部)
原因:豆瓣AJAX接口的type参数错误。type=25对应Top250,但若误用type=11(电视剧)或type=13(综艺),返回数据完全不相关。此外,start参数非页码,而是偏移量(0,20,40...),若传入start=1,将跳过首部电影。
解决:检查fetch_movie_list()中params['type']是否为'25';start必须为20的倍数;用浏览器开发者工具Network面板,手动访问https://movie.douban.com/j/chart/top_list?type=25&start=0&limit=20,确认返回JSON含[{"id":"1292052","title":"肖申克的救赎",...}]。
5.2 现象:详情页解析出导演/主演全为"未知"
原因:豆瓣详情页HTML结构更新。2023年后,部分字段XPath路径变更(如导演标签从//span[text()="导演"]/following-sibling::span[1]变为//span[contains(text(),"导演")]/following-sibling::span[1]),或新增了<span class="pl">导演</span>结构。
解决:打开任意电影页(如https://movie.douban.com/subject/1292052/),右键"查看网页源代码",搜索"导演",定位其父容器<div id="info">,复制精确XPath;或改用更鲁棒的CSS选择器:tree.cssselect('div#info span.pl:contains(导演) + span a')。
5.3 现象:Flask启动后,点击"开始采集"按钮无反应,浏览器控制台报404
原因:前端HTML中<form action="/crawl" method="POST">的action路径错误。常见错误是写成/crawler(多一个r)或/crawl/(结尾斜杠),而Flask路由定义为@app.route('/crawl'),严格匹配。
解决:检查templates/crawl.html中form标签的action属性;在浏览器F12 Network面板,点击按钮后看发出的POST请求URL是否为http://127.0.0.1:5000/crawl;确保app.py中@app.route('/crawl', methods=['GET', 'POST'])装饰器存在且无拼写错误。
5.4 现象:生成的图表PNG在网页中显示为破损图标(红叉)
原因:render_chart()函数返回的chart_path路径错误。Flask中static/目录是公开的,但render_template()中引用的路径必须是相对于static/的相对路径,如static/images/trend.png在模板中应写为{{ url_for('static', filename='images/trend.png') }},而非硬编码/static/images/trend.png(后者在非根路径部署时失效)。
解决:修改templates/charts.html中的<img src="{{ chart_path }}">为<img src="{{ url_for('static', filename=chart_path.replace('static/', '')) }}">;或更规范地,在render_chart()中直接返回filename='images/trend.png',模板用url_for('static', filename=filename)。
5.5 现象:答辩演示时,点击"生成图表"按钮后页面卡住,长时间无响应
原因:分析函数(如generate_yearly_trend())在主线程执行耗时操作(如读取CSV、计算聚合、绘图),阻塞Flask主线程,导致整个Web服务无响应。
解决:将分析逻辑改为预生成+缓存。在app.py启动时(或首次访问/时),预先运行所有分析函数,生成图表并保存;/charts/<chart_type>路由仅负责读取已存在的图片文件,不实时计算。示例:
# app.py 开头 def init_charts(): from analyzer.trend_analysis import generate_yearly_trend, generate_genre_pie generate_yearly_trend() # 首次启动即生成 generate_genre_pie() init_charts() # 调用6. 进阶技巧:让答辩老师眼前一亮的3个细节优化
6.1 用Jinja2模板继承实现统一UI,避免重复代码
答辩PPT里展示的系统截图,如果每个页面顶部导航栏、底部版权信息都手写HTML,不仅易出错,且修改一处需改多处。Jinja2的模板继承机制是标准解法。创建templates/base.html作为基模板:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}豆瓣电影分析系统{% endblock %}</title> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> </head> <body> <header> <nav> <a href="{{ url_for('index') }}">首页</a> | <a href="{{ url_for('crawl_control') }}">数据采集</a> | <a href="{{ url_for('render_chart', chart_type='yearly_trend') }}">趋势分析</a> </nav> </header> <main> {% block content %}{% endblock %} </main> <footer> <p>© 2024 豆瓣电影分析系统 | 毕业设计项目</p> </footer> </body> </html>然后让所有子模板继承它:
<!-- templates/index.html --> {% extends "base.html" %} {% block title %}首页 - 豆瓣电影分析系统{% endblock %} {% block content %} <div class="dashboard"> <h1>系统总览</h1> <p>当前共采集影片:<strong>{{ stats.total_movies }}</strong></p> <p>平均评分:<strong>{{ stats.avg_rating|round(2) }}</strong></p> <h2>热门类型TOP3</h2> <ul> {% for genre, count in stats.top_genres %} <li>{{ genre }} ({{ count }}部)</li> {% endfor %} </ul> </div> {% endblock %}价值点:答辩时老师问“如果要加一个‘词云分析’菜单,怎么改?”,你能立刻回答:“只需在
base.html的<nav>里加一行<a href='...'>词云分析</a>,再新建templates/wordcloud.html继承base.html即可”,展现工程化思维。
6.2 为图表添加交互式Tooltip:用ECharts替代Matplotlib(可选但加分)
Matplotlib生成的PNG是静态图,而ECharts支持鼠标悬停显示详细数据,答辩时更直观。以年度趋势图为例,用ECharts重写:
<!-- templates/charts.html --> <div id="trendChart" style="width: 100%; height: 500px;"></div> <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> <script> const chartDom = document.getElementById('trendChart'); const myChart = echarts.init(chartDom); // 从后端API获取JSON数据(需新增/api/yearly_data接口) fetch('/api/yearly_data') .then(response => response.json()) .then(data => { const option = { tooltip: { trigger: 'axis' }, legend: { data: ['影片数量', '平均评分'] }, xAxis: { type: 'category', data: data.years }, yAxis: [ { type: 'value', name: '数量' }, { type: 'value', name: '评分', position: 'right' } ], series: [ { name: '影片数量', type: 'bar', data: data.counts }, { name: '平均评分', type: 'line', yAxisIndex: 1, data: data.ratings } ] }; myChart.setOption(option); }); </script>落地要点:
- 新增Flask接口
@app.route('/api/yearly_data')返回JSON;- ECharts CDN地址用国内镜像(如
https://cdn.bootcdn.net/ajax/libs/echarts/5.4.3/echarts.min.js)避免加载失败;- 不必全系统替换,仅在答辩重点展示的1-2个图表用ECharts,体现技术选型意识。
6.3 生成PDF分析报告:用WeasyPrint一键导出专业文档
答辩最后环节,老师常问:“这个分析结果能导出给其他人看吗?”此时展示PDF报告功能,瞬间提升项目完整性。WeasyPrint可将HTML+CSS直接转PDF,完美保留图表:
# analyzer/report_generator.py from weasyprint import HTML import os def generate_pdf_report(): # 渲染一个包含所有图表的HTML报告页 html_content = """ <!DOCTYPE html> <html> <head><title>豆瓣电影分析报告</title> <style>img{max-width:100%;height:auto;} body{font-family: sans-serif;}</style> </head> <body> <h1>豆瓣电影Top250分析报告</h1> <h2>年度趋势</h2> <img src="file://{}" alt="年度趋势"> <h2>类型分布</h2> <img src="file://{}" alt="类型分布"> </body> </html> """.format( os.path.abspath('static/images/yearly_trend.png'), os.path.abspath('static/images/genre_pie.png') ) HTML(string=html_content).write_pdf('reports/douban_report.pdf') print("PDF报告已生成:reports/douban_report.pdf")注意:
src="file://{}"必须用绝对路径,且os.path.abspath()确保路径正确;WeasyPrint依赖GTK,Windows用户需提前安装weasyprint[gtk]或改用pdfkit(需安装wkhtmltopdf)。
我带学生做毕设时,最常强调的一点是:答辩不是考你写了多少代码,而是考你能否把一个数据从源头到结论的链条,每一环都讲清楚、站得住、可验证。豆瓣电影项目之所以稳拿高分,正因为它把“网络请求→HTML解析→数据清洗→统计分析→可视化→报告输出”这条工业级数据流水线,浓缩在一个可触摸、可演示、可提问的系统里。当你能对着parse_movie_detail()函数,说出“这里用正则而不是XPath,是因为豆瓣标题年份格式比HTML结构更稳定”,老师就知道,你不是在调包,而是在思考。希望帮到你。
本文还有配套的精品资源,点击获取