ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

豆瓣电影Top250数据分析系统:Python爬虫+pandas+Flask+ECharts全流程实战

豆瓣电影Top250数据分析系统:Python爬虫+pandas+Flask+ECharts全流程实战

简介:本资源为基于Python的豆瓣电影数据分析与可视化系统完整毕业设计包,面向计算机相关专业学生及需要数据分析实战项目的开发者。项目采用Flask搭建后端、ECharts完成图表渲染,结合爬虫采集豆瓣电影数据并用pandas做清洗处理,数据库使用MySQL,覆盖从环境搭建、数据爬取、数据处理到可视化展示的完整链路,可帮助读者理解多角度动态视图与信息识别率提升的实现思路。压缩包共1054个文件,约30.07MB,以688个py源码、157个pyc编译文件、36张jpg图片、15个html页面、11个js脚本及sql脚本、论文文档等为主,源码、数据库脚本与论文一并提供。目前已有255人学习下载,适合作为毕业设计参考或数据分析入门练手项目,便于快速复现系统并在此基础上二次开发。

1. 豆瓣电影数据分析系统:从爬虫到可视化的完整落地路径

豆瓣电影 Top250 是很多人做 Python 毕业设计时第一个想到的数据源,但真正动手后会发现,难点从来不是"把数据爬下来",而是爬完之后怎么存、怎么清洗、怎么让前端图表跑起来、怎么把 pandas 的分析结果变成 echarts 能吃的 JSON。这套基于 Flask + echarts + 爬虫 + pandas 的系统,本质上是一条完整的数据链路:requests 抓取页面 → 解析入库 → pandas 清洗分析 → Flask 提供接口 → echarts 渲染图表。它适合正在做毕业设计、需要一套能跑通且能讲清楚原理的完整方案的人,也适合想系统练一遍 Python 数据处理全流程的入门者。下面按实际搭建顺序,把每个环节的参数、代码和踩坑点讲透。

2. 爬虫层:requests + lxml 抓取豆瓣 Top250 的稳定写法

2.1 为什么选 requests + lxml 而不是 Selenium

豆瓣 Top250 的页面结构是服务端渲染的静态 HTML,每页 25 条,共 10 页,URL 规律是https://movie.douban.com/top250?start=0&filter=,start 每次递增 25。这种结构用 requests 直接请求就能拿到完整 HTML,没必要上 Selenium。Selenium 启动浏览器开销大,在毕业设计这种数据量(250 条)下属于杀鸡用牛刀,而且部署到服务器时还要装 Chrome 驱动,平白增加环境复杂度。

解析层选 lxml 的 XPath 而不是 BeautifulSoup,原因是 XPath 对层级结构的表达更精确,豆瓣的条目结构嵌套较深,用 XPath 定位//div[@class="item"]再逐层取子元素,比 BeautifulSoup 的 find 链式调用更直观,速度也更快。常见做法是用etree.HTML()把响应文本转成可查询的树,然后循环提取。

2.2 完整爬取脚本与请求头配置

import requests from lxml import etree import time import random import csv HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", } def parse_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" # 豆瓣返回头有时不带 charset,手动指定 html = etree.HTML(resp.text) items = html.xpath('//div[@class="item"]') rows = [] for item in items: rank = item.xpath('.//em/text()')[0] title = item.xpath('.//span[@class="title"]/text()')[0] # 评分可能缺失,用 join 兜底 rating = ''.join(item.xpath('.//span[@class="rating_num"]/text()')) # 评价人数在 <div class="star"> 下的最后一个 span votes = ''.join(item.xpath('.//div[@class="star"]/span[last()]/text()')) quote = item.xpath('.//span[@class="inq"]/text()') quote = quote[0] if quote else "" info = item.xpath('.//div[@class="bd"]/p[1]/text()') info_text = ' '.join([t.strip() for t in info if t.strip()]) rows.append([rank, title, rating, votes, quote, info_text]) return rows def crawl_all(): all_rows = [] for start in range(0, 250, 25): url = f"https://movie.douban.com/top250?start={start}&filter=" all_rows.extend(parse_page(url)) time.sleep(random.uniform(1.5, 3.0)) # 随机间隔,降低被封风险 with open("douban_top250.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["rank", "title", "rating", "votes", "quote", "info"]) writer.writerows(all_rows) print(f"共抓取 {len(all_rows)} 条") if __name__ == "__main__": crawl_all()

这段代码的关键点有三个。第一,resp.encoding = "utf-8"必须手动设置,requests 有时会根据响应头猜测编码,猜错就会出现中文乱码,这是新手最常翻车的地方。第二,time.sleep(random.uniform(1.5, 3.0))的随机间隔比固定 sleep 更安全,固定间隔的请求节奏太规律,容易被识别。第三,写 CSV 时用utf-8-sig编码,这样用 Excel 打开不会乱码,如果用纯utf-8,Excel 会把中文显示成乱码。

参数方面,timeout=10是防止某个请求卡死导致整个脚本挂起,生产环境建议设 5 到 15 秒。range(0, 250, 25)生成 0、25、50……225 共 10 个起始位置,正好覆盖 250 条。评价人数那个字段取的是span[last()],因为豆瓣的 star 区域结构是评分 span + 评价人数 span,最后一个才是人数。

2.3 反爬应对与数据落库

豆瓣对高频请求有 IP 限制,连续快速请求会返回 403 或跳转到验证页。除了随机 sleep,还可以准备多个 User-Agent 轮换,但毕业设计的数据量下,控制好频率基本不会触发。如果确实遇到 403,先检查请求头是否完整,再降低频率,不要一上来就想着上代理池,那是分布式爬虫才需要的方案。

数据落库这一步,毕业设计通常用 MySQL。建表时注意字段类型:rank 用 INT,rating 用 DECIMAL(3,1) 因为评分是一位小数,votes 用 INT,其余用 VARCHAR。导入时用 pandas 的to_sql最省事:

import pandas as pd from sqlalchemy import create_engine df = pd.read_csv("douban_top250.csv") # 评价人数带"人评价"后缀,需要清洗成纯数字 df["votes"] = df["votes"].str.replace("人评价", "", regex=False).astype(int) engine = create_engine("mysql+pymysql://root:password@localhost:3306/douban?charset=utf8mb4") df.to_sql("movies", engine, if_exists="replace", index=False)

if_exists="replace"表示每次重新导入时覆盖旧表,调试阶段很方便,但正式环境建议用append配合去重逻辑。charset=utf8mb4不能省,否则遇到生僻字或特殊符号会报错。

3. pandas 清洗与分析:把原始数据变成能画图的指标

3.1 数据类型转换与缺失值处理

爬下来的数据看着干净,实际用起来问题不少。最常见的是 votes 字段带中文后缀、rating 偶尔为空、info 字段混着导演和演员信息需要拆分。pandas 的数据类型转换是这一步的核心,astype和pd.to_numeric的区别要搞清楚:astype(int)遇到非数字会直接报错,pd.to_numeric(errors="coerce")会把无法转换的变成 NaN,更适合脏数据。

import pandas as pd import numpy as np df = pd.read_csv("douban_top250.csv") # 评分转数值,无法转换的置为 NaN df["rating"] = pd.to_numeric(df["rating"], errors="coerce") # 评价人数去掉后缀再转 df["votes"] = df["votes"].str.replace("人评价", "", regex=False) df["votes"] = pd.to_numeric(df["votes"], errors="coerce") # 缺失值处理:评分缺失用中位数填充,人数缺失直接删 df["rating"] = df["rating"].fillna(df["rating"].median()) df = df.dropna(subset=["votes"]) # 从 info 里提取年份和国家 df["year"] = df["info"].str.extract(r"(\d{4})") df["country"] = df["info"].str.extract(r"/\s*([^/]+?)\s*/") df["year"] = pd.to_numeric(df["year"], errors="coerce")

str.extract配合正则表达式是处理半结构化文本的利器。年份的正则(\d{4})抓第一个四位数字,国家的正则/\s*([^/]+?)\s*/抓两个斜杠之间的内容。这里要注意,豆瓣的 info 格式是"导演: xxx 主演: xxx / 年份 / 国家 / 类型",所以年份和国家的位置相对固定,但不同条目可能有细微差异,正则要写得宽容一些。

3.2 分组聚合:评分分布、年代趋势、国家占比

分析维度决定了后面 echarts 画什么图。毕业设计里最常做的三类分析是:评分分布直方图、各年代电影数量折线图、国家占比饼图。用 pandas 的groupby和value_counts几行就能算出来。

# 评分分布:按 0.5 分一档分箱 bins = np.arange(8.0, 10.0, 0.5) df["rating_bin"] = pd.cut(df["rating"], bins=bins) rating_dist = df["rating_bin"].value_counts().sort_index() # 年代趋势:按十年分组 df["decade"] = (df["year"] // 10 * 10).astype("Int64") decade_count = df.groupby("decade").size() # 国家占比:取前 8 个国家 country_count = df["country"].value_counts().head(8) # 类型拆分:一部电影可能有多个类型,需要 explode df["genre"] = df["info"].str.extract(r"/\s*([^/]+)$") genre_df = df["genre"].str.split().explode() genre_count = genre_df.value_counts().head(10)

pd.cut做等宽分箱,value_counts统计频次,groupby().size()统计分组数量,这三个是 pandas 分析的基本功。explode()处理的是"一部电影多个类型"的情况,比如"剧情 爱情",split 后变成列表再 explode 就能拆成两行分别计数,这是很多人不知道的技巧。

参数上,bins=np.arange(8.0, 10.0, 0.5)生成 8.0、8.5、9.0、9.5 四个边界,实际分成三档。如果想分得更细,把步长改成 0.2 即可。head(8)和head(10)是控制图表条目数量,太多会导致饼图标签重叠、柱状图挤成一团。

3.3 把分析结果转成 echarts 能用的 JSON

Flask 后端和 echarts 前端之间的数据格式约定,是整个系统最容易出问题的地方。echarts 的柱状图需要 xAxis 的类目数组和 series 的数值数组,饼图需要[{name, value}]格式的数组。在 Flask 里用jsonify返回时,要注意 numpy 的数据类型不能直接序列化,必须转成 Python 原生类型。

import json def df_to_echarts_bar(series): """把 pandas Series 转成 echarts 柱状图需要的格式""" return { "xAxis": [str(i) for i in series.index.tolist()], "series": [int(v) for v in series.values.tolist()] } def df_to_echarts_pie(series): """转成饼图需要的 [{name, value}] 格式""" return [{"name": str(k), "value": int(v)} for k, v in series.items()]

int(v)这一步不能省,因为series.values返回的是 numpy.int64,jsonify遇到它会抛TypeError: Object of type int64 is not JSON serializable。这是 Flask + pandas 组合里最经典的报错,很多人卡在这里半天找不到原因。转成 Python 原生 int 或 float 就解决了。

4. Flask 后端:接口设计与 echarts 数据对接

4.1 项目结构与路由规划

Flask 项目不要把所有代码堆在一个 app.py 里,按功能拆成蓝图(Blueprint)更清晰,也方便答辩时讲架构。推荐的结构是:

douban_system/ ├── app.py # 入口,注册蓝图 ├── models.py # SQLAlchemy 模型 ├── analysis.py # pandas 分析函数 ├── routes/ │ ├── __init__.py │ ├── api.py # 数据接口 │ └── views.py # 页面路由 ├── static/ │ ├── js/echarts.min.js │ └── css/style.css └── templates/ ├── index.html └── detail.html

路由分两类:页面路由返回 HTML,接口路由返回 JSON。页面路由用render_template,接口路由统一加/api/前缀,返回jsonify的结果。这样前端用 ajax 请求/api/rating_dist就能拿到数据,职责分明。

4.2 核心接口实现与参数说明

from flask import Blueprint, jsonify, request import pandas as pd from analysis import load_data, get_rating_dist, get_decade_trend api_bp = Blueprint("api", __name__, url_prefix="/api") @api_bp.route("/rating_dist") def rating_dist(): df = load_data() series = get_rating_dist(df) return jsonify({ "code": 0, "data": { "xAxis": [str(i) for i in series.index], "series": [int(v) for v in series.values] } }) @api_bp.route("/movies") def movies(): # 分页参数,默认第 1 页每页 20 条 page = request.args.get("page", 1, type=int) size = request.args.get("size", 20, type=int) df = load_data() total = len(df) start = (page - 1) * size subset = df.iloc[start:start + size] return jsonify({ "code": 0, "total": total, "data": subset[["rank", "title", "rating", "votes"]].to_dict("records") })

request.args.get("page", 1, type=int)的第三个参数type=int会自动把字符串转成整数,如果转换失败就用默认值 1,比手动 try-except 简洁。分页用df.iloc[start:start+size]切片,to_dict("records")把 DataFrame 转成字典列表,正好是前端表格需要的格式。

统一返回{"code": 0, "data": ...}的结构是个好习惯,前端可以根据 code 判断请求是否成功,出错时返回code: 1加错误信息。这套约定在答辩时也能体现工程规范。

4.3 前端 echarts 接入与图表配置

前端页面用原生 fetch 请求接口,拿到数据后调用 echarts 的setOption。以评分分布柱状图为例:

fetch('/api/rating_dist') .then(res => res.json()) .then(res => { const chart = echarts.init(document.getElementById('ratingChart')); chart.setOption({ title: { text: '豆瓣 Top250 评分分布' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: res.data.xAxis }, yAxis: { type: 'value', name: '电影数量' }, series: [{ type: 'bar', data: res.data.series, itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: '#83bff6' }, { offset: 1, color: '#188df0' } ]) } }] }); });

echarts.graphic.LinearGradient是给柱状图加渐变色的标准写法,四个参数分别是 x0、y0、x2、y2(0 到 1 的相对坐标)和颜色数组。trigger: 'axis'让 tooltip 在鼠标移到某个类目时显示整列数据,比item模式体验好。图表容器#ratingChart必须有明确的宽高,否则 echarts 初始化时拿不到尺寸会渲染成空白,这是最常见的"图表不显示"原因。

5. 避坑与排查:这套系统最容易翻车的 5 个地方

5.1 爬虫返回 403 或空数据

现象:requests 请求豆瓣返回 403,或者返回的 HTML 里 xpath 取不到任何 item。

原因:请求头缺少 User-Agent 被识别为爬虫,或者请求频率过高被临时限制,也可能是豆瓣页面结构改版导致 xpath 失效。

解决:先补全 User-Agent 和 Accept-Language,加随机 sleep 降低频率。如果还是 403,把请求间隔拉长到 5 秒以上,等一段时间再试。xpath 失效的话,用浏览器开发者工具重新检查页面结构,豆瓣偶尔会调整 class 名,把//div[@class="item"]换成实际的结构即可。

5.2 pandas 读取 CSV 中文乱码

现象:pd.read_csv读出来的中文全是乱码,或者报UnicodeDecodeError。

原因:爬虫写文件时用的编码和读取时指定的编码不一致。Windows 下 Excel 默认用 GBK,而爬虫脚本通常写 UTF-8。

解决:写文件时统一用utf-8-sig,读取时加encoding="utf-8-sig"。如果文件已经乱码,用encoding="gbk"试着读一次再转存成 UTF-8。这个坑的本质是编码不统一,养成"写读同编码"的习惯就不会遇到。

5.3 Flask 接口返回 500 且报 int64 不可序列化

现象:接口在浏览器里返回 500,控制台报TypeError: Object of type int64 is not JSON serializable。

原因:pandas 的value_counts、groupby().size()返回的数值是 numpy.int64 类型,Flask 的 jsonify 只认 Python 原生类型。

解决:在返回前统一转换,[int(v) for v in series.values]或者用series.astype(int).tolist()。更彻底的做法是写一个to_native辅助函数,递归处理 dict 和 list 里的 numpy 类型。

5.4 echarts 图表不显示或显示空白

现象:页面加载后图表区域一片空白,控制台没有报错,或者报Cannot read property 'getWidth' of null。

原因:图表容器没有设置宽高,echarts 初始化时容器尺寸为 0;或者 DOM 还没加载完就调用了echarts.init。

解决:给容器加 CSS 宽高,比如#ratingChart { width: 100%; height: 400px; }。初始化代码放在DOMContentLoaded事件里,或者把 script 标签放在 body 末尾。如果容器是隐藏的(display:none),切换显示后要调用chart.resize()。

5.5 数据库连接报 charset 错误

现象:用 pandas 的to_sql导入数据时报Incorrect string value或中文变成问号。

原因:MySQL 建库时没指定 utf8mb4,或者连接字符串里没加 charset 参数。

解决:建库时用CREATE DATABASE douban DEFAULT CHARACTER SET utf8mb4,连接字符串写成mysql+pymysql://user:pass@host:3306/douban?charset=utf8mb4。utf8mb4 比 utf8 多支持 emoji 和部分生僻字,豆瓣的短评里偶尔会有特殊符号,用 utf8mb4 更保险。

6. 进阶技巧:让这套系统在答辩时更有说服力

6.1 用缓存减少重复计算

每次请求接口都重新读 CSV、跑一遍 pandas 分析,数据量小的时候感觉不出来,但答辩演示时如果网络慢或者机器卡,页面加载会有明显延迟。加一层简单的内存缓存就能解决:

from functools import lru_cache @lru_cache(maxsize=1) def load_data(): df = pd.read_csv("douban_top250.csv", encoding="utf-8-sig") # ... 清洗逻辑 return df

lru_cache(maxsize=1)表示只缓存最近一次调用的结果,数据不变的情况下后续请求直接返回缓存对象,省掉重复的 IO 和计算。注意返回的 DataFrame 不要在原处修改,否则缓存会被污染,需要修改时先.copy()。

6.2 给图表加上交互筛选

静态图表只能看,加上筛选条件就能体现"系统"的价值。比如在页面上放一个年份下拉框,选择后重新请求接口,后端根据参数过滤数据:

@api_bp.route("/decade_trend") def decade_trend(): start_year = request.args.get("start", 1930, type=int) df = load_data() df = df[df["year"] >= start_year] series = df.groupby("decade").size() return jsonify({"xAxis": [str(i) for i in series.index], "series": [int(v) for v in series.values]})

前端监听下拉框的 change 事件,把选中的年份作为参数拼到 URL 里重新 fetch。这个交互不复杂,但答辩时演示"动态筛选"比"静态展示"加分不少,因为它体现了前后端的数据联动。

6.3 验证数据准确性的三个方法

做完系统后,怎么证明数据是对的?我一般用三个方法交叉验证。第一,随机抽 5 条记录,手动打开豆瓣对应页面核对评分和评价人数。第二,用 pandas 算一下df["rating"].mean(),豆瓣 Top250 的平均分大概在 8.7 左右,如果算出来是 5 或者 9.9,说明数据有问题。第三,检查df["rank"].nunique()是否等于 250,如果小于 250 说明有重复或漏抓。

这三个检查花不了几分钟,但能避免答辩时被老师问"你怎么保证数据准确"时答不上来。数据类项目的可信度,一半靠代码,一半靠验证。

6.4 部署时的一个小习惯

本地跑通和部署到服务器是两回事。我习惯在项目根目录放一个requirements.txt,用pip freeze > requirements.txt生成,部署时pip install -r requirements.txt一把装齐。Flask 启动不要用app.run(debug=True),生产环境用 gunicorn:gunicorn -w 4 -b 0.0.0.0:5000 app:app,-w 4是 4 个 worker 进程,根据 CPU 核数调整。静态文件交给 Nginx 处理,Flask 只管接口和模板,这样并发能力会好很多。

这套系统我从头搭过几遍,最大的体会是:爬虫和分析的代码网上到处都是,真正花时间的是把数据格式在各个环节对齐——爬虫输出的 CSV 编码、pandas 读进来的类型、jsonify 能序列化的格式、echarts 需要的结构,每一层都有坑。把这些对齐了,系统就跑通了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进