☰
Python爬虫+Flask+ECharts:豆瓣Top250数据可视化大屏全链路实战
2026/10/12 17:10:33 网站建设 项目流程

简介:本资源是面向高校计算机相关专业毕业设计场景的完整项目包,主题为基于Python的豆瓣电影数据分析与可视化系统,采用Flask搭建后端、ECharts完成图表呈现,并结合爬虫与pandas实现数据采集与清洗,数据库使用MySQL。项目从Pycharm环境搭建、目标网站数据爬取、数据预处理到多角度动态可视化形成完整链路,适合需要数据分析类毕设选题、想练习爬虫与可视化整合的本科生或初中级开发者参考。压缩包共1054个文件,约30.07MB,以688个py源码文件为核心,辅以pyc编译文件、html与js前端页面、jpg与png图表素材、sql脚本、csv数据样例及docx论文文档,另含虚拟环境与依赖安装相关文件,目录结构清晰便于按模块查阅。目前已有255人学习下载。读者可获得可直接运行的源码、数据库脚本与配套论文,理解爬虫采集、pandas处理到ECharts展示的完整实现思路,并借鉴其图表设计与排错经验。

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

豆瓣电影 Top250 是很多人做数据分析入门的第一个数据集,但真正把它做成一套能跑、能看、能写进毕业设计的技术系统,中间要跨过的坑远比想象中多。这套基于 Python 的方案,核心链路是 requests + XPath 抓取电影数据,pandas 做清洗和聚合,存进 MySQL,Flask 提供接口,前端用 ECharts 渲染可视化大屏。它解决的不是"能不能爬到数据"这种入门问题,而是"爬下来的脏数据怎么变成可查询、可展示、可复现的完整系统"。适合正在做 Python 毕业设计、需要一套前后端打通的数据可视化项目的人,也适合想系统练一遍爬虫到展示全链路的开发者。下面按实际搭建顺序拆开讲。

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

2.1 为什么选 requests + XPath 而不是 Scrapy

豆瓣 Top250 的页面结构非常规整,每页 25 条,共 10 页,翻页参数就是start=0,25,50...。这种规模用 Scrapy 属于杀鸡用牛刀,requests 配合 lxml 的 XPath 解析足够,而且代码量小、调试直观,毕业设计里也更容易讲清楚每一步在做什么。Scrapy 的优势在分布式和去重调度,但 Top250 总共就 250 条数据,用不上这些能力,反而增加了理解成本。

真正需要注意的是请求头伪装和访问频率。豆瓣对无 UA 的请求会直接返回 418,对高频访问会临时封 IP。常见做法是每次请求间隔 1 到 2 秒,带上完整的 User-Agent,必要时加 Referer。

2.2 抓取脚本与字段解析

import requests from lxml import etree 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", "Referer": "https://movie.douban.com/top250" } def parse_page(url): resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code != 200: print(f"请求失败: {resp.status_code} -> {url}") return [] html = etree.HTML(resp.text) items = html.xpath('//ol[@class="grid_view"]/li') movies = [] for item in items: rank = item.xpath('.//em/text()')[0] title = item.xpath('.//span[@class="title"][1]/text()')[0] # 部分电影有多个标题,取第一个中文名 rating = item.xpath('.//span[@class="rating_num"]/text()')[0] # 评价人数在 <div class="star"> 下的最后一个 span votes = item.xpath('.//div[@class="star"]/span[last()]/text()')[0] quote = item.xpath('.//span[@class="inq"]/text()') quote = quote[0] if quote else "" info = item.xpath('.//div[@class="bd"]/p[1]/text()') # info 通常有两行:导演/主演 + 年份/国家/类型 year = "" country = "" genre = "" if len(info) >= 2: meta = info[1].strip().split("/") if len(meta) >= 3: year = meta[0].strip() country = meta[1].strip() genre = meta[2].strip() movies.append({ "rank": int(rank), "title": title, "rating": float(rating), "votes": int(votes.replace("人评价", "").strip()), "quote": quote, "year": year, "country": country, "genre": genre }) return movies def crawl_all(): all_movies = [] for start in range(0, 250, 25): url = f"https://movie.douban.com/top250?start={start}&filter=" print(f"抓取: {url}") all_movies.extend(parse_page(url)) time.sleep(random.uniform(1.0, 2.0)) # 随机间隔,降低被封风险 return all_movies if __name__ == "__main__": data = crawl_all() print(f"共抓取 {len(data)} 条")

这段代码的关键点有三个。第一,xpath里的[1]和[last()]是精确取值的核心,豆瓣页面里span.title可能有多个(中文名、英文名、别名),不加索引会拿到列表而不是字符串。第二,评价人数那个字段的文本是"1234567人评价",必须去掉中文后缀再转 int,否则 pandas 读进来是 object 类型。第三,time.sleep用random.uniform而不是固定值,固定间隔反而更容易被识别为脚本。

2.3 参数与反爬边界

参数建议值说明
timeout10 秒超时重试,避免单页卡死
sleep 间隔1.0~2.0 秒随机固定间隔易被识别
User-Agent完整浏览器 UA缺失直接 418
重试次数2~3 次配合指数退避

如果连续几次返回 418,不要硬刚,停几分钟再跑。毕业设计演示时建议先把数据落成 CSV 备份,避免答辩现场网络波动导致爬虫跑不动。

3. pandas 清洗与 MySQL 入库:把脏数据变成可查询的表

3.1 数据类型转换的四个必做动作

爬下来的原始数据直接入库会出问题:rating 是字符串、votes 带中文、year 可能混入"(中国大陆)"这类内容、genre 是斜杠分隔的多值字段。pandas 在这里的作用不是"读一下 CSV",而是把每个字段转成数据库能高效查询的类型。

import pandas as pd import pymysql from sqlalchemy import create_engine df = pd.DataFrame(crawl_all()) # 1. 评分转 float,异常值置空 df["rating"] = pd.to_numeric(df["rating"], errors="coerce") # 2. 年份提取纯数字,处理 "2004(中国大陆)" 这类 df["year"] = df["year"].str.extract(r"(\d{4})") df["year"] = pd.to_numeric(df["year"], errors="coerce") # 3. 国家字段可能有多个,取第一个作为主国家 df["country"] = df["country"].str.split(" ").str[0] # 4. 类型字段保留原始多值,同时拆出主类型 df["main_genre"] = df["genre"].str.split("/").str[0].str.strip() # 去重:按 title 去重,保留第一条 df = df.drop_duplicates(subset=["title"], keep="first") # 填充缺失 df["quote"] = df["quote"].fillna("") df["country"] = df["country"].fillna("未知") print(df.dtypes) print(df.head())

pd.to_numeric的errors="coerce"参数是关键,遇到无法转换的值不会报错,而是置为 NaN,后续可以统一填充或丢弃。str.extract配合正则(\d{4})能从混杂文本里精准抠出年份。这两步做完,rating 和 year 才是真正的数值类型,后面做分组聚合、排序才不会出玄学结果。

3.2 建表与批量写入

CREATE TABLE movies ( id INT AUTO_INCREMENT PRIMARY KEY, `rank` INT NOT NULL, title VARCHAR(200) NOT NULL, rating DECIMAL(3,1), votes INT, quote VARCHAR(500), year INT, country VARCHAR(50), genre VARCHAR(100), main_genre VARCHAR(50), INDEX idx_rating (rating), INDEX idx_year (year), INDEX idx_genre (main_genre) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
engine = create_engine( "mysql+pymysql://root:password@localhost:3306/douban?charset=utf8mb4" ) df.to_sql("movies", engine, if_exists="replace", index=False, chunksize=100) print(f"入库完成,共 {len(df)} 条")

建表时给 rating、year、main_genre 加索引,是因为前端大屏的筛选和排序都围绕这三个字段。to_sql的chunksize=100避免一次性插入过大导致连接超时。if_exists="replace"在开发阶段方便反复跑,但正式演示前建议改成append并先清表,避免数据重复。

注意:MySQL 8.0 默认字符集是 utf8mb4,但如果你用的是 5.7,建库时一定要显式指定CHARACTER SET utf8mb4,否则电影标题里的特殊字符会变成问号。

4. Flask 接口层:给 ECharts 提供干净的数据出口

4.1 接口设计:少而准

前端大屏通常需要四类数据:评分分布、年份趋势、类型占比、国家 Top10。与其做一个万能接口传各种参数,不如拆成四个语义清晰的端点,每个端点返回 ECharts 直接能用的格式。这样前端不用做二次加工,调试时也一眼能看出哪个接口出了问题。

from flask import Flask, jsonify from sqlalchemy import create_engine import pandas as pd app = Flask(__name__) engine = create_engine( "mysql+pymysql://root:password@localhost:3306/douban?charset=utf8mb4" ) @app.route("/api/rating_dist") def rating_dist(): sql = """ SELECT FLOOR(rating) AS score, COUNT(*) AS cnt FROM movies GROUP BY FLOOR(rating) ORDER BY score """ df = pd.read_sql(sql, engine) return jsonify({ "x": df["score"].astype(str).tolist(), "y": df["cnt"].tolist() }) @app.route("/api/year_trend") def year_trend(): sql = """ SELECT year, COUNT(*) AS cnt FROM movies WHERE year IS NOT NULL GROUP BY year ORDER BY year """ df = pd.read_sql(sql, engine) return jsonify({ "x": df["year"].astype(int).tolist(), "y": df["cnt"].tolist() }) @app.route("/api/genre_pie") def genre_pie(): sql = """ SELECT main_genre AS name, COUNT(*) AS value FROM movies GROUP BY main_genre ORDER BY value DESC """ df = pd.read_sql(sql, engine) return jsonify(df.to_dict(orient="records")) @app.route("/api/country_top") def country_top(): sql = """ SELECT country AS name, COUNT(*) AS value FROM movies GROUP BY country ORDER BY value DESC LIMIT 10 """ df = pd.read_sql(sql, engine) return jsonify(df.to_dict(orient="records")) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True)

每个接口返回的 JSON 结构都刻意贴合 ECharts 的series.data格式。饼图接口直接返回[{name, value}],柱状图返回{x: [], y: []},前端拿到就能塞进 option,不需要在 JavaScript 里再做 map 转换。host="0.0.0.0"是为了让同一局域网内的其他设备也能访问,答辩时如果老师用手机看,这个配置就派上用场了。

4.2 跨域与部署注意

开发阶段前端如果用 Live Server 跑在 5500 端口,Flask 在 5000,浏览器会拦跨域请求。最省事的做法是装flask-cors:

from flask_cors import CORS CORS(app)

生产部署时不要用app.run,用 gunicorn 起多进程:

gunicorn -w 4 -b 0.0.0.0:5000 app:app

-w 4是 worker 数量,一般设为 CPU 核数的 2 倍加 1。毕业设计演示用 2 个 worker 就够,太多反而占内存。

5. ECharts 可视化:四个图表类型的具体配置与踩坑

5.1 评分分布柱状图与渐变色

评分分布用柱状图最直观,但默认的纯色柱子在大屏上显得单薄。ECharts 支持线性渐变,配置在itemStyle.color里:

option = { xAxis: { type: 'category', data: res.x }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: res.y, itemStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [ { offset: 0, color: '#5470c6' }, { offset: 1, color: '#91cc75' } ] }, borderRadius: [6, 6, 0, 0] }, label: { show: true, position: 'top' } }] };

colorStops的offset从 0 到 1 表示从上到下的渐变方向,x2:0, y2:1控制垂直方向。borderRadius让柱子顶部圆角,视觉上更柔和。label.position: 'top'把数值显示在柱子顶部,这是大屏场景的常见需求,但要注意如果柱子太密,标签会重叠,此时可以设interval: 0配合rotate倾斜。

5.2 年份趋势折线图与横轴刻度

年份趋势用折线图,坑主要在横轴。如果年份跨度大,默认会每隔几个显示一个标签,看起来稀疏。解决办法是axisLabel.interval设为 0 强制全显示,但年份多的时候又会挤在一起。折中方案是旋转 45 度:

xAxis: { type: 'category', data: res.x, axisLabel: { interval: 0, rotate: 45, fontSize: 11 } }

如果年份确实太多,更好的做法是在 SQL 层按年代分组,比如FLOOR(year/10)*10聚合成十年一个区间,前端展示就清爽了。

5.3 类型占比饼图与中间文字

饼图用main_genre字段,ECharts 的饼图默认中间是空的。如果想在圆心显示总数,需要用title组件定位到中心:

option = { title: { text: '250', subtext: '总影片', left: 'center', top: 'center', textStyle: { fontSize: 24, color: '#333' }, subtextStyle: { fontSize: 12, color: '#999' } }, series: [{ type: 'pie', radius: ['40%', '65%'], data: res, label: { formatter: '{b}: {d}%' } }] };

radius: ['40%', '65%']做成环形图,中间留白给 title 显示总数。{d}是 ECharts 内置的百分比占位符,不用自己算。

5.4 国家 Top10 横向柱状图

国家分布用横向柱状图更合适,因为国家名是中文,纵向排列会挤。关键配置是yAxis.type: 'category'和xAxis.type: 'value'对调:

option = { yAxis: { type: 'category', data: res.map(item => item.name).reverse(), axisLabel: { fontSize: 12 } }, xAxis: { type: 'value' }, series: [{ type: 'bar', data: res.map(item => item.value).reverse(), itemStyle: { color: '#73c0de' } }] };

.reverse()是因为 ECharts 的 category 轴默认从下往上排,不反转的话第一名会跑到最下面,不符合阅读习惯。

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

6.1 爬虫返回 418 或空列表

现象:脚本跑起来后parse_page返回空列表,或者直接打印 418。原因:请求头缺少 User-Agent,或者短时间内请求太频繁被临时限制。解决:补全 UA 和 Referer,把time.sleep改成 1.5 到 2.5 秒的随机值。如果已经被限制,换网络环境或等 10 分钟再试。开发阶段建议先把爬到的数据存成 CSV,后续调试直接读 CSV,不要每次都重新爬。

6.2 pandas 读进来 rating 是 object 类型

现象:df.dtypes显示 rating 是 object,做df["rating"].mean()报错或结果不对。原因:爬虫阶段 rating 是字符串,直接pd.DataFrame不会自动转数值。解决:入库前统一用pd.to_numeric(df["rating"], errors="coerce")转换。转换后检查df["rating"].isna().sum(),如果 NaN 数量异常,说明 XPath 取错了字段。

6.3 ECharts 图表不显示但控制台无报错

现象:页面空白,F12 看 Network 里接口返回了数据,但图表就是不出来。原因:最常见的是容器 div 没有设置高度。ECharts 初始化时需要容器有明确的宽高,如果 div 只有宽度没有高度,canvas 高度为 0。解决:给每个图表容器设height: 400px或类似固定值,不要用height: auto。另一个可能是echarts.init在 DOM 渲染完成前执行了,把初始化代码放到window.onload或DOMContentLoaded回调里。

6.4 Flask 接口返回中文乱码

现象:浏览器里看到的是\u7535\u5f71这种 Unicode 转义,或者直接问号。原因:Flask 的jsonify默认会转义非 ASCII 字符,MySQL 连接字符集不对也会导致问号。解决:Flask 2.3 之后可以设app.json.ensure_ascii = False关闭转义。数据库连接串里必须带?charset=utf8mb4,建表也用 utf8mb4。两个地方都对了,中文才能正常显示。

6.5 部署后接口 500 但本地正常

现象:本地python app.py跑得好好的,用 gunicorn 起就报 500。原因:gunicorn 多 worker 模式下,如果代码里有全局状态(比如在模块顶层创建了数据库连接),多个 worker 会竞争同一个连接。解决:把数据库连接放在函数内部创建,或者用连接池。SQLAlchemy 的create_engine本身带连接池,但要注意pool_size和max_overflow参数,默认值在低并发下够用,不要手动设成 1。

7. 让这套系统在答辩时真正站住脚的两个技巧

第一个技巧是给爬虫加一个"离线模式"。在crawl_all外面包一层判断:如果本地存在movies.csv且文件修改时间在 1 小时内,直接读 CSV 返回,否则才走网络请求。这样答辩现场即使网络抽风,系统照样能跑起来。实现上就是几行os.path.exists和os.path.getmtime的判断,但能救你一命。

import os import time def get_data(force_crawl=False): cache = "movies.csv" if not force_crawl and os.path.exists(cache): if time.time() - os.path.getmtime(cache) < 3600: print("使用缓存数据") return pd.read_csv(cache) df = pd.DataFrame(crawl_all()) df.to_csv(cache, index=False, encoding="utf-8-sig") return df

第二个技巧是给 ECharts 加resize监听。大屏在不同分辨率显示器上打开时,图表不会自动适应窗口变化,需要手动监听:

window.addEventListener('resize', function() { myChart.resize(); });

如果页面有多个图表,把每个实例都存进数组,resize 时遍历调用。这个细节在答辩演示切换投影仪分辨率时特别明显,不加的话图表会错位。

我自己做这类项目最大的教训是:不要等到所有功能都写完才第一次跑通全链路。爬虫写完就先存 CSV,pandas 清洗完就先入库,Flask 接口写完就先用浏览器访问看 JSON,ECharts 每配一个图就先在页面上确认能渲染。每一步都验证过,最后组装时才不会出现"数据有了但图表不出来、图表出来了但数据不对"这种连锁翻车。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询