2026/10/12 17:11:09

Python爬虫+Flask+ECharts:豆瓣Top250数据可视化大屏全链路实战

Python爬虫+Flask+ECharts:豆瓣Top250数据可视化大屏全链路实战 简介本资源是面向高校计算机相关专业毕业设计场景的完整项目包主题为基于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 做清洗和聚合存进 MySQLFlask 提供接口前端用 ECharts 渲染可视化大屏。它解决的不是能不能爬到数据这种入门问题而是爬下来的脏数据怎么变成可查询、可展示、可复现的完整系统。适合正在做 Python 毕业设计、需要一套前后端打通的数据可视化项目的人也适合想系统练一遍爬虫到展示全链路的开发者。下面按实际搭建顺序拆开讲。2. 爬虫层requests XPath 抓豆瓣 Top250 的稳定写法2.1 为什么选 requests XPath 而不是 Scrapy豆瓣 Top250 的页面结构非常规整每页 25 条共 10 页翻页参数就是start0,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, headersHEADERS, timeout10) if resp.status_code ! 200: print(f请求失败: {resp.status_code} - {url}) return [] html etree.HTML(resp.text) items html.xpath(//ol[classgrid_view]/li) movies [] for item in items: rank item.xpath(.//em/text())[0] title item.xpath(.//span[classtitle][1]/text())[0] # 部分电影有多个标题取第一个中文名 rating item.xpath(.//span[classrating_num]/text())[0] # 评价人数在 div classstar 下的最后一个 span votes item.xpath(.//div[classstar]/span[last()]/text())[0] quote item.xpath(.//span[classinq]/text()) quote quote[0] if quote else info item.xpath(.//div[classbd]/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 fhttps://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], errorscoerce) # 2. 年份提取纯数字处理 2004(中国大陆) 这类 df[year] df[year].str.extract(r(\d{4})) df[year] pd.to_numeric(df[year], errorscoerce) # 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], keepfirst) # 填充缺失 df[quote] df[quote].fillna() df[country] df[country].fillna(未知) print(df.dtypes) print(df.head())pd.to_numeric的errorscoerce参数是关键遇到无法转换的值不会报错而是置为 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;engine create_engine( mysqlpymysql://root:passwordlocalhost:3306/douban?charsetutf8mb4 ) df.to_sql(movies, engine, if_existsreplace, indexFalse, chunksize100) print(f入库完成共 {len(df)} 条)建表时给 rating、year、main_genre 加索引是因为前端大屏的筛选和排序都围绕这三个字段。to_sql的chunksize100避免一次性插入过大导致连接超时。if_existsreplace在开发阶段方便反复跑但正式演示前建议改成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( mysqlpymysql://root:passwordlocalhost:3306/douban?charsetutf8mb4 ) 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(orientrecords)) 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(orientrecords)) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)每个接口返回的 JSON 结构都刻意贴合 ECharts 的series.data格式。饼图接口直接返回[{name, value}]柱状图返回{x: [], y: []}前端拿到就能塞进 option不需要在 JavaScript 里再做 map 转换。host0.0.0.0是为了让同一局域网内的其他设备也能访问答辩时如果老师用手机看这个配置就派上用场了。4.2 跨域与部署注意开发阶段前端如果用 Live Server 跑在 5500 端口Flask 在 5000浏览器会拦跨域请求。最省事的做法是装flask-corsfrom 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], errorscoerce)转换。转换后检查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关闭转义。数据库连接串里必须带?charsetutf8mb4建表也用 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_crawlFalse): 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, indexFalse, encodingutf-8-sig) return df第二个技巧是给 ECharts 加resize监听。大屏在不同分辨率显示器上打开时图表不会自动适应窗口变化需要手动监听window.addEventListener(resize, function() { myChart.resize(); });如果页面有多个图表把每个实例都存进数组resize 时遍历调用。这个细节在答辩演示切换投影仪分辨率时特别明显不加的话图表会错位。我自己做这类项目最大的教训是不要等到所有功能都写完才第一次跑通全链路。爬虫写完就先存 CSVpandas 清洗完就先入库Flask 接口写完就先用浏览器访问看 JSONECharts 每配一个图就先在页面上确认能渲染。每一步都验证过最后组装时才不会出现数据有了但图表不出来、图表出来了但数据不对这种连锁翻车。希望帮到你。本文还有配套的精品资源点击获取