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

发布时间:2026/10/12 20:05:30
豆瓣电影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?start0filterstart 每次递增 25。这种结构用 requests 直接请求就能拿到完整 HTML没必要上 Selenium。Selenium 启动浏览器开销大在毕业设计这种数据量250 条下属于杀鸡用牛刀而且部署到服务器时还要装 Chrome 驱动平白增加环境复杂度。解析层选 lxml 的 XPath 而不是 BeautifulSoup原因是 XPath 对层级结构的表达更精确豆瓣的条目结构嵌套较深用 XPath 定位//div[classitem]再逐层取子元素比 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;q0.9, } def parse_page(url): resp requests.get(url, headersHEADERS, timeout10) resp.encoding utf-8 # 豆瓣返回头有时不带 charset手动指定 html etree.HTML(resp.text) items html.xpath(//div[classitem]) rows [] for item in items: rank item.xpath(.//em/text())[0] title item.xpath(.//span[classtitle]/text())[0] # 评分可能缺失用 join 兜底 rating .join(item.xpath(.//span[classrating_num]/text())) # 评价人数在 div classstar 下的最后一个 span votes .join(item.xpath(.//div[classstar]/span[last()]/text())) quote item.xpath(.//span[classinq]/text()) quote quote[0] if quote else info item.xpath(.//div[classbd]/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 fhttps://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, encodingutf-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-8Excel 会把中文显示成乱码。参数方面timeout10是防止某个请求卡死导致整个脚本挂起生产环境建议设 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 用 INTrating 用 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(人评价, , regexFalse).astype(int) engine create_engine(mysqlpymysql://root:passwordlocalhost:3306/douban?charsetutf8mb4) df.to_sql(movies, engine, if_existsreplace, indexFalse)if_existsreplace表示每次重新导入时覆盖旧表调试阶段很方便但正式环境建议用append配合去重逻辑。charsetutf8mb4不能省否则遇到生僻字或特殊符号会报错。3. pandas 清洗与分析把原始数据变成能画图的指标3.1 数据类型转换与缺失值处理爬下来的数据看着干净实际用起来问题不少。最常见的是 votes 字段带中文后缀、rating 偶尔为空、info 字段混着导演和演员信息需要拆分。pandas 的数据类型转换是这一步的核心astype和pd.to_numeric的区别要搞清楚astype(int)遇到非数字会直接报错pd.to_numeric(errorscoerce)会把无法转换的变成 NaN更适合脏数据。import pandas as pd import numpy as np df pd.read_csv(douban_top250.csv) # 评分转数值无法转换的置为 NaN df[rating] pd.to_numeric(df[rating], errorscoerce) # 评价人数去掉后缀再转 df[votes] df[votes].str.replace(人评价, , regexFalse) df[votes] pd.to_numeric(df[votes], errorscoerce) # 缺失值处理评分缺失用中位数填充人数缺失直接删 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], errorscoerce)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], binsbins) 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 就能拆成两行分别计数这是很多人不知道的技巧。参数上binsnp.arange(8.0, 10.0, 0.5)生成 8.0、8.5、9.0、9.5 四个边界实际分成三档。如果想分得更细把步长改成 0.2 即可。head(8)和head(10)是控制图表条目数量太多会导致饼图标签重叠、柱状图挤成一团。3.3 把分析结果转成 echarts 能用的 JSONFlask 后端和 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.int64jsonify遇到它会抛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, typeint) size request.args.get(size, 20, typeint) 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, typeint)的第三个参数typeint会自动把字符串转成整数如果转换失败就用默认值 1比手动 try-except 简洁。分页用df.iloc[start:startsize]切片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、y20 到 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[classitem]换成实际的结构即可。5.2 pandas 读取 CSV 中文乱码现象pd.read_csv读出来的中文全是乱码或者报UnicodeDecodeError。原因爬虫写文件时用的编码和读取时指定的编码不一致。Windows 下 Excel 默认用 GBK而爬虫脚本通常写 UTF-8。解决写文件时统一用utf-8-sig读取时加encodingutf-8-sig。如果文件已经乱码用encodinggbk试着读一次再转存成 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连接字符串写成mysqlpymysql://user:passhost:3306/douban?charsetutf8mb4。utf8mb4 比 utf8 多支持 emoji 和部分生僻字豆瓣的短评里偶尔会有特殊符号用 utf8mb4 更保险。6. 进阶技巧让这套系统在答辩时更有说服力6.1 用缓存减少重复计算每次请求接口都重新读 CSV、跑一遍 pandas 分析数据量小的时候感觉不出来但答辩演示时如果网络慢或者机器卡页面加载会有明显延迟。加一层简单的内存缓存就能解决from functools import lru_cache lru_cache(maxsize1) def load_data(): df pd.read_csv(douban_top250.csv, encodingutf-8-sig) # ... 清洗逻辑 return dflru_cache(maxsize1)表示只缓存最近一次调用的结果数据不变的情况下后续请求直接返回缓存对象省掉重复的 IO 和计算。注意返回的 DataFrame 不要在原处修改否则缓存会被污染需要修改时先.copy()。6.2 给图表加上交互筛选静态图表只能看加上筛选条件就能体现系统的价值。比如在页面上放一个年份下拉框选择后重新请求接口后端根据参数过滤数据api_bp.route(/decade_trend) def decade_trend(): start_year request.args.get(start, 1930, typeint) 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(debugTrue)生产环境用 gunicorngunicorn -w 4 -b 0.0.0.0:5000 app:app-w 4是 4 个 worker 进程根据 CPU 核数调整。静态文件交给 Nginx 处理Flask 只管接口和模板这样并发能力会好很多。这套系统我从头搭过几遍最大的体会是爬虫和分析的代码网上到处都是真正花时间的是把数据格式在各个环节对齐——爬虫输出的 CSV 编码、pandas 读进来的类型、jsonify 能序列化的格式、echarts 需要的结构每一层都有坑。把这些对齐了系统就跑通了。希望帮到你。本文还有配套的精品资源点击获取