
简介基于Python Flask与Echarts插件构建的豆瓣电影TOP250数据分析可视化网站完整源码适合有一定Python基础、希望系统学习Flask Web开发与数据可视化应用的开发者。项目综合运用爬虫采集豆瓣电影数据并通过Echarts生成交互式图表辅以WordCloud词云、地图文件等增强展示效果可作为课程设计或毕业设计的参考蓝本。整个压缩包共92个文件主要包含4个Python脚本、5个HTML模板、16个CSS样式、16个JavaScript脚本及11个地图文件另有图片、字体、数据库与配置文件等整体大小9.98MB目录结构清晰前端静态资源与后端逻辑划分明确。目前已有462人学习下载。通过学习这份源码可掌握基于Flask搭建动态网站、使用Echarts绘制评分与评论分布图表、利用WordCloud分析文本关键词以及爬虫数据入库的完整实现思路便于后续迁移到其他数据分析场景。1. 基于Python Flask与Echarts插件的豆瓣电影TOP250数据分析可视化网站从小工具到完整项目你是不是也有过这种经历想研究豆瓣TOP250到底被哪些年份、哪些地区的电影霸榜手动一页页翻网页、复制表格到Excel折腾一晚上只得到一份乱七八糟的数据。这正是我做这个项目的起点——与其人工扒数据不如写一个把抓取、存储、分析和可视化串起来的小网站。基于Python Flask与Echarts插件的豆瓣电影TOP250数据分析可视化网站本质上就是一条完整的数据流水线requests抓页面BeautifulSoup解析SQLite存数据Flask提供后端接口和页面渲染Echarts在浏览器里画出柱状图、饼图和排行表。这套方案不挑机器、不挑系统Windows和Mac都能跑适合刚学完Python基础想做一个能拿得出手的“数据类项目”的人也适合已经在做爬虫但想补上可视化这一环的开发者。2. 先把数据抓到本地豆瓣TOP250的页面解析与字段清洗2.1 目标数据长什么样列表页结构、翻页规律与字段清单豆瓣TOP250的列表页是出了名的简单规整每页固定25条电影一共10页。它的翻页参数就是URL里的start从0开始步长25也就是说第1页是start0第2页是start25一直到最后start225。每个电影条目被包裹在ol.grid_view列表的li.item节点里你要的信息基本都在这个节点内部。我一般会先打开页面源码确认字段节点而不是凭经验写死选择器。每个li.item里能拿到的字段包括排名em标签、片名span.title、导演和主演p里的第一行文本以换行分隔、年份和地区同一段p里的第二行、评分span.rating_num、评价人数span.pl、一句话短评span.inq。这些字段的结构经年累月基本稳定但文字排版偶尔会变化所以清洗逻辑一定要写得抗造。字段解析节点清洗后格式备注排名li.item div.pic em整数全局唯一可作主键片名span.title第一个字符串中文名别名不要导演/主演p第一行文本按/拆分字符串取第一位可得到导演年份p第二行文本正则提取四位整数如“1994”地区p第二行文本按/拆分字符串可能多地区取第一个评分span.rating_num浮点数保留一位小数评价人数span.pl去括号整数常见写法“1513423人评价”短评span.inq字符串有的片子没有需判空2.2 抓取脚本requests加BeautifulSoup的最小实现爬虫这部分我习惯用requests做HTTP请求BeautifulSoup做页面解析而不是直接用Scrapy。原因很简单总共只有10个页面不需要调度器、中间件那一套重武器单文件脚本足够清爽出了问题也好定位。下面这个脚本就是整个项目的“数据入口”。import requests import time from bs4 import BeautifulSoup 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 } def fetch_page(start): 抓取单个列表页返回解析后的电影字典列表 url fhttps://movie.douban.com/top250?start{start}filter resp requests.get(url, headersHEADERS, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) movies [] for item in soup.select(ol.grid_view li.item): # 节点缺失时用 try 兜底避免整页解析中断 rank int(item.select_one(div.pic em).get_text()) title item.select_one(span.title).get_text() rating float(item.select_one(span.rating_num).get_text()) quote_node item.select_one(span.inq) quote quote_node.get_text() if quote_node else info_lines item.select_one(div.bd p).get_text(\n, stripTrue).split(\n) # info_lines[0] 是导演/主演info_lines[1] 是年份/地区/类型 year_line info_lines[1] if len(info_lines) 1 else movies.append({ rank: rank, title: title, rating: rating, quote: quote, year_raw: year_line, }) return movies def main(): all_movies [] for start in range(0, 250, 25): all_movies.extend(fetch_page(start)) time.sleep(1) # 控制请求频率降低被封概率 # 后续把 all_movies 写入数据文件见下一节 print(f共抓到 {len(all_movies)} 条)我特意用select_one而不是find因为select_one配合CSS选择器写起来更像写选择器而且天然返回单个节点或None适合判空。resp.raise_for_status()的作用是状态码非200时直接抛异常避免拿到错误页面还继续解析造成假数据。time.sleep(1)在本地抓250条时不算慢但对豆瓣这种有反爬机制的站点来说这个间隔是保命的。2.3 数据清洗与落地年份、地区、评分区间怎么处理抓下来的year_raw是类似“1994 / 美国 / 犯罪 剧情”这种混合文本不能直接用。我通常用正则提取年份再按分隔符拆分地区和类型最后把结果规整成一行一条的结构化数据。清洗逻辑放在解析函数之后、入库之前单独写一个函数方便后续换了数据源还能复用。import re def clean_movie(movie): 从原始字段里提取年份和地区补全结构化字段 raw movie.pop(year_raw, ) parts [p.strip() for p in raw.split(/) if p.strip()] year None region 未知 for part in parts: # 年份优先匹配四位数字 m re.search(r(\d{4}), part) if m: year int(m.group(1)) continue # 非年份且非类型词视为地区 if part not in (剧情, 喜剧, 动作, 爱情, 科幻, 动画, 悬疑, 犯罪, 冒险, 奇幻, 惊悚, 恐怖, 纪录片, 音乐, 歌舞, 战争, 历史, 传记, 运动): region part movie[year] year movie[region] region return movie注意这里的类型词表是我按TOP250常出现的类型整理出来的实际拿到的页面数据如果有新的类型词就会误判成地区。这是小型爬虫项目里典型的“硬编码字典风险”代码够用但不完美遇到漏网之鱼时只需要往集合里补词。清洗后的数据我一般存成两路一是movies.json方便调试时直接看二是SQLite数据库供Flask后端查询下一章会用到。写入JSON时别忘了一个关键参数with open(movies.json, w, encodingutf-8) as f: json.dump(all_movies, f, ensure_asciiFalse, indent2)ensure_asciiFalse必须加否则中文片名和地区全部会变成\u4f60这种转义序列打开文件根本没法看。这一步我在早期项目里翻过车当时还以为是爬虫解析出了问题排查了半天才发现是序列化参数没设对。3. Flask后端与API设计动态页面渲染和数据接口两条路线3.1 为什么选Flask而不选FastAPI轻量、模板生态和上手成本做这种规模的个人项目Flask和FastAPI其实都能胜任但它们的气质完全不同。FastAPI的异步支持、自动OpenAPI文档和Pydantic校验都很现代适合前后端分离、接口文档需求明确的生产级应用。但我的场景是单机自用、带页面模板、需要快速跑起来Flask的render_template配合Jinja2模板直接一个函数出页面省掉了“前端单独部署”这一步。更重要的一点是Flask的资料和踩坑记录遍地都是新手遇到问题很容易搜到同款报错。如果有朝一日项目要加实时推送、要做高并发接口那时候再迁移到FastAPI也不迟那是“数据量真大了”的问题不是“还没跑起来”的问题。做这个TOP250项目我不建议在框架选型上过度纠结先跑通再优化比什么都强。3.2 源码工程的目录结构一个Flask应用的标准打开方式拿到这类“设计源码”时最常见的目录组织方式是这样的不管源码作者怎么命名核心就这几个部分project_root/ ├── app.py # Flask主程序路由和API都在这 ├── spider.py # 爬虫脚本独立运行 ├── data/ │ ├── movies.json # 爬虫输出的原始数据 │ └── movies.db # SQLite数据库 ├── templates/ │ └── index.html # Echarts图表容器页面 └── static/ ├── echarts.min.js # Echarts库文件本地引入 └── style.css # 页面样式app.py和spider.py分开写是因为爬虫部分和网站服务部分是两条生命周期爬虫是“偶尔跑一次”的采集任务而Flask应用是“持续挂着”的服务。如果把两者写进同一个文件每次启动网站都会执行一遍爬虫逻辑既慢又容易触发反爬。templates和static是Flask约定的目录名不要改动否则框架找不到文件。3.3 两条渲染路线render_template服务端渲染与/api接口前端取数Flask写页面有两条路。一条是服务端渲染在app.py里读数据库把数据整理成列表或字典直接传给模板模板里的Jinja2语法把它嵌入页面。另一条是API取数后端只提供JSON接口前端页面加载后用JavaScript的fetch去拿数据再由Echarts把数据画成图表。我在这类项目里的经验是静态页面上做模板渲染没问题但图表数据一定要走API接口。原因是Echarts的setOption需要接收一个JavaScript对象服务端渲染出来的数据要先塞进tojson过滤器再交给前端形态别扭而API天然返回JSON前端拿过来直接用代码心智负担小得多。from flask import Flask, render_template, jsonify, g import sqlite3 app Flask(__name__) DB_PATH data/movies.db def query_db(sql, args()): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row cur conn.execute(sql, args) rows cur.fetchall() conn.close() return rows app.route(/) def index(): # 页面首次访问时渲染空壳图表由前端JS拉取数据后绘制 return render_template(index.html) app.route(/api/year-distribution) def year_distribution(): 按年份统计电影数量返回柱状图数据 rows query_db( SELECT year, COUNT(*) AS cnt FROM movies WHERE year IS NOT NULL GROUP BY year ORDER BY year ) return jsonify([{year: r[year], count: r[cnt]} for r in rows]) app.route(/api/rating-distribution) def rating_distribution(): 按评分区间统计电影数量返回饼图数据 rows query_db(SELECT rating FROM movies) buckets {9分以上: 0, 8.5-9分: 0, 8-8.5分: 0, 8分以下: 0} for r in rows: rating r[rating] if rating 9: buckets[9分以上] 1 elif rating 8.5: buckets[8.5-9分] 1 elif rating 8: buckets[8-8.5分] 1 else: buckets[8分以下] 1 return jsonify([{name: k, value: v} for k, v in buckets.items()])要注意jsonify在Flask里会自动把响应Content-Type设成application/json前端fetch拿到的res.json()可以直接解析不需要额外处理编码。sqlite3.Row的作用是让查出来的每一行能通过r[year]这种字典式访问拿值比默认的元组形式可读性强很多。我在query_db里每次查询都新建连接项目规模小所以无所谓如果数据量大到上百MB你再考虑连接池也不迟。3.4 中文编码、JSON序列化与排序的边界问题Flask的jsonify在返回中文时不需要担心乱码框架默认会处理UTF-8编码。真正会翻车的点是如果你不用jsonify而是手动json.dumps一定要带上ensure_asciiFalse否则你辛辛苦苦抓来的“肖申克的救赎”就变成了\u8096浏览器里展示出来的就是一串乱码。另外还有一个排序问题。jsonify默认按字典的Key排序输出也就是count会跑到year前面。这也就算了关键是某些Echarts配置对数据顺序敏感比如年份柱状图横轴期望按年份升序排如果后端没加ORDER BY year图表上的柱子顺序就会是乱的。我一般会在SQL层就把排序做掉别指望前端再去排序——减少一个出错环节就少一次排查。4. Echarts图表怎么接从后端JSON到柱状图、饼图与排行的完整配置4.1 引入方式本地静态文件优先CDN不是不可以但要有备选Echarts的引入方式直接决定项目在无外网环境还能不能跑。用bootcdn或jsdelivr的CDN地址确实省事但这类个人项目经常要拿到别人的电脑上演示对方一旦没联网或者CDN被解析异常页面就是一片空白。我的惯例是把echarts.min.js下载后放进static/目录用本地路径引用。代价是工程目录里多一个约1MB的静态文件换来的却是绝对的可移植性。4.2 页面骨架与基础配置容器高度、宽度与初始化时序Echarts图表不渲染十次有九次是容器高度为0。div默认高度由内容撑开而Echarts的init方法要求容器必须有明确的宽高。所以页面里每个图表容器都要显式指定高度哪怕是一行styleheight:400px也比不给强。!doctype html html langzh-cn head meta charsetutf-8 title豆瓣电影TOP250数据分析/title script src{{ url_for(static, filenameecharts.min.js) }}/script style .chart { width: 90%; height: 420px; margin: 20px auto; } .chart-row { display: flex; gap: 16px; } .chart-row .chart { flex: 1; } /style /head body h2豆瓣电影TOP250 · 可视化看板/h2 div idyearChart classchart/div div classchart-row div idratingChart classchart/div div idregionChart classchart/div /div script src{{ url_for(static, filenamemain.js) }}/script /body /html这里有个值得注意的细节main.js必须放在/body之前而不是head里。因为图表初始化需要DOM节点已经存在脚本放前面会找不到yearChart这个id。如果你非要放头部那就得包一层window.onload或DOMContentLoaded没必要。模板里的url_for(static, filename...)是Flask的标准静态资源路径生成方式换成写死的/static/...也能用但前者在项目换目录部署时不用改代码。4.3 年份分布柱状图坐标轴、标签旋转与渐变色柱状图是这个项目里信息量最大的图表横轴是年份1980到2024纵轴是该年份入围的电影数量。年份跨度大横轴标签如果不做处理每根柱子下面挤满数字根本看不清。我一般会把axisLabel的rotate设为45并开启interval自动抽稀让Echarts自己决定每几个年份显示一个标签。另外可以做一个隐藏的细节优化用itemStyle.color配合linearGradient做渐变色柱体视觉上会更有“数据看板”的气质。// 这个文件是 static/main.js fetch(/api/year-distribution) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(yearChart)); chart.setOption({ title: { text: 豆瓣TOP250电影年份分布, left: center }, tooltip: { trigger: axis }, grid: { left: 50, right: 20, bottom: 60 }, xAxis: { type: category, data: data.map(item item.year), axisLabel: { rotate: 45, interval: auto } }, yAxis: { type: value, name: 影片数量 }, series: [{ type: bar, data: data.map(item item.count), itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: #ff6b35 }, { offset: 1, color: #ffd166 } ]) }, barMaxWidth: 30 }] }); }) .catch(err console.error(年份数据加载失败, err));这段配置里grid的bottom留了60像素不是随手写的——因为rotate:45后的年份标签会向下探出40到50像素底部空间不够的话标签会被裁掉。barMaxWidth:30是为了防止某个年份数量特别大时单根柱子过宽把其他年份挤得显示不下。这些都是实践里一点一点试出来的参数先抄下来用后面再按自己的审美微调。4.4 评分分布环形图饼图中间的文字和颜色列表评分分布的饼图适合用环形图展示视觉上比实心饼图轻巧。Echarts里只需要在series里加一个radius: [40%, 70%]内径外径一设饼图就变成了环。环中间默认是空的很多人想在那里放一个总数这就涉及一个常见问题——饼图中间的字怎么加。做法是在series平级的位置添一个graphic组件用text元素定位到画布中心。fetch(/api/rating-distribution) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(ratingChart)); const total data.reduce((sum, item) sum item.value, 0); chart.setOption({ title: { text: 评分区间分布, left: center }, tooltip: { trigger: item }, legend: { orient: vertical, right: 10, top: middle }, series: [{ type: pie, radius: [40%, 70%], data: data, label: { show: false }, color: [#5470c6, #fac858, #ee6666, #73c0de] }], graphic: [{ type: text, left: center, top: 45%, style: { text: 总计\n${total} 部, textAlign: center, fontSize: 18, fontWeight: bold } }] }); });graphic组件是Echarts里容易被忽略但很好用的能力它能在canvas上叠加任意图形或文字而且不参与图表的数据体系。设置left:center和top:45%时Echarts会自动换算成百分比坐标效果大致是文字垂直居中于环形区域。color数组的顺序对应data数组的顺序注意饼图扇区的默认排序会把数据从大到小排颜色可能和你预期的分组顺序对不上如果希望颜色严格对应可以在series里加sort: (a, b) a.value - b.value控制。4.5 地区分布横向条形图解决国家名过长的问题地区分布我习惯用横向条形图而不是纵向柱状图。原因是有些地区名称很长“中国大陆 / 中国香港”这种组合在纵向柱状图的横轴下面会被压缩成斜着的省略号根本读不出来。横向条形图的Y轴天然适合放长文本。实现上只需要把xAxis和yAxis对调series类型仍然是bar。这个图表的接口/api/region-distribution在上一章没写你可以照着year-distribution的写法自己补一个核心就是GROUP BY region ORDER BY cnt DESC LIMIT 10重点在前十名地区后面的地区数量少画出来没意义。图的渲染顺序也值得注意页面上一共有三个图表三个fetch是并行发出的互不等待。如果某个接口偶发超时对应图表会保持空白而其他图表正常渲染。这不是框架问题是异步加载的正常表现。如果你希望“要么全出要么给个错误提示”可以用Promise.all统一加载但那通常不是必要优化。5. 常见坑与排查编码、反爬、图表白屏的四个血泪现场5.1 豆瓣返回418或403不是代码写错了是请求太像爬虫现象爬虫第一次运行能抓到几十条多跑几次后resp.status_code变成403或者418页面内容变成了“请求过于频繁”的验证页面。原因豆瓣的反爬机制识别到了高频请求特征封禁了当前IP的访问权限。你做了UA伪装但没做频率控制或者前几次请求间隔太短被封是必然的。解决把请求间隔从time.sleep(0.5)拉长到time.sleep(2)到time.sleep(3)并给请求头里的Accept-Language和Referer都补上。如果你想稳妥一点可以加一个简单的重试逻辑状态码非200时等5秒重试一次最多重试3次。爬虫这东西不是越快越好慢就是快。5.2 JSON文件里全是\uXXXX转义看着像乱码其实是没设ensure_ascii现象打开movies.json中文电影名全是\u8096\u514b\u7684\u6551\u8d4e但程序能正常读取页面也能输出正常中文。原因json.dump没有指定ensure_asciiFalse默认把所有非ASCII字符转义成了Unicode编码。这个现象只在json.dump和json.dumps里出现写到SQLite里的数据不受影响。解决写入时统一加ensure_asciiFalse, encodingutf-8再检查一遍打开文件是否正常。这个坑我在不止一个项目里见过包括有些网上的源码包本身就没处理你拿到手觉得“怎么全是乱码”其实不是数据坏了。5.3 图表白屏但接口正常容器高度为0或初始化时序不对现象浏览器控制台没有任何报错fetch能拿到数据console.log(data)也能打印出数组但页面上图表区域就是一片空白。原因两种可能一是容器的CSS高度没有设置div的默认高度是0二是echarts.init执行时DOM节点还没渲染完成。前者最常见后者发生在你把main.js放在了head里。解决给每个图表容器加styleheight:420px这种显式高度把main.js的script标签移到body底部。如果改完还白屏打开开发者工具在Elements面板里看容器元素的计算样式height是不是有值——这一步能定位90%的白屏问题。5.4 分析数据比豆瓣页面少了十几条页面结构微调导致解析漏掉节点现象网页上明明250条程序抓完只有235条少的主要集中在某些特定年份。原因个别列表项里的span.inq不存在没有一句话短评或者p文本里的换行符数量不一致导致info_lines[1]索引越界整条被continue跳过了。这是典型的“解析逻辑容错性差”。解决把每个字段的解析都独立判空解析失败时该字段置为None而不是跳过整条记录。另外可以在清洗前打印一下len(movies)对比页面总数抓少了就检查是不是某类节点结构变了。这种问题没有什么一劳永逸的解法因为豆瓣前端偶尔改版你的解析代码需要具备“一个字段拿不到不影响其他字段”的弹性。5.5 Flask应用启动后页面能开但样式全乱静态文件路径被硬编码现象本地开发时一切正常把整个目录放到服务器或者别人的电脑上页面HTML出来了但图表和CSS都加载不出来。原因模板里写了绝对路径/static/echarts.min.js而不是用url_for(static, filename...)生成。Flask的static路径在开发服务器、gunicorn、nginx反代三种场景下前缀可能会有差异硬编码路径是最脆弱的写法。解决模板里统一用{{ url_for(static, filenameecharts.min.js) }}。这个函数会根据Flask的配置自动生成正确路径项目挪位置、加前缀都不需要改页面代码。同理API请求地址也尽量用相对路径/api/year-distribution不要在JavaScript里写死http://localhost:5000。6. 再往前走一步从单页图表到部署上线与自动更新项目跑通只是起点到这个阶段你应该已经有一份完整可运行的代码但我建议你再往前走三步把项目从“能跑”变成“有用”。第一步是部署。Flask自带的开发服务器只适合调试真正对外提供服务时我习惯用gunicorn做WSGI容器一条命令搞定gunicorn -w 4 -b 0.0.0.0:8000 app:app。注意-w的进程数和服务器CPU核数相关不是越多越好4核机器开4个worker是常见起步配置。部署后如果遇到静态文件404优先检查app.secret_key和static_folder这些配置项多数问题都出在这。第二步是数据更新自动化。TOP250榜单不是一成不变的新片入围、老片退出都是常态。我给爬虫加了一个“幂等更新”逻辑按rank字段作为唯一键重复抓取时先删后插保证数据库里永远是当前最新榜单。配合系统的cron或Windows的任务计划程序每月自动跑一次spider.py数据就永远是新鲜的。第三步是图表交互深度。现在的三个图表是彼此孤立的进阶一点的做法是加一个click事件——点击年份柱状图里的某根柱子右侧的排行榜表格就只显示该年份的电影。Echarts的chart.on(click, params ...)可以拿到柱子对应的params.name然后带着这个参数重新请求一个/api/movies?year1994接口就能实现真正的联动。这一步做完这个项目就不再是一个“展示页”而是一个可交互的分析工具。我在自己折腾这类项目的过程中最大的教训是别急着写代码。先把数据字段确认清楚、把目录结构想明白、把接口路径定下来后面每一步都会很顺。反过来一上来就写页面、写图表最后经常因为数据格式对不上反复重构那种返工才是最费时间的。这套方案我前后整理过好几个版本从最早的纯表格展示到后来的多图表联动再到现在的自动更新部署每一步都是被真实需求推着走的。希望这套基于Python Flask与Echarts插件的豆瓣电影TOP250数据分析可视化网站设计能帮你少走几步弯路早点把自己想看的数画出来。本文还有配套的精品资源点击获取