☰
Flask电影评分数据爬取与可视化系统实战:从采集到展示
2026/10/19 3:43:18 网站建设 项目流程

前阵子想分析某电影评分平台近几年的高分趋势,手动打开网页一条条记录,数据零散、字段口径还不统一,查上两小时脑子就发懵。与其受这个罪,我干脆撸了一套“Flask电影评分数据爬取与可视化系统”:爬虫负责自动采集,Flask负责后端服务,前端用ECharts做图表展示,一整套流程跑下来,后面再想查任何维度的评分分布,填个筛选条件就能出图。这套东西的技术含量不算高,但胜在链路完整,从数据采集到展示都有落地代码,非常适合刚接触Python爬虫和数据可视化、想做一个能真正跑起来的综合项目的开发者拿来参考或直接改。

先说清楚它能做什么:定时抓取电影基础信息、评分、评价人数、上映年份等;清洗后写入数据库;Flask提供查询接口;网页端通过交互式图表展示年度趋势、评分分布、类型对比。我后续把它拆过很多次,最终选型是Flask + Requests + BeautifulSoup + SQLite + ECharts,兼顾轻量和易部署。整篇博客会把设计思路、爬虫细节、后端接口、前端联动、常见坑全部展开,包含不少我实际调试时踩过的教训。

1. 系统整体设计与技术选型思路

1.1 这个系统解决的核心痛点

很多人做数据分析时,最耗时间的不是分析本身,而是“收集数据”。比如想比较不同年份电影的平均评分,或者看哪个类型更容易出高分片,手动复制粘贴进Excel,格式混乱、去重麻烦,一旦数据量超过几百条,基本没法维护。

这套系统把流程抽象成四个环节:采集、清洗、存储、呈现。采集解决“从无到有”,清洗解决“从脏到净”,存储解决“可回溯和可查询”,呈现解决“直观理解”。核心价值不是某个单一功能,而是把四个环节串成一个可重复运行的闭环。我最初只写了个爬虫脚本,跑完生成CSV,后面发现根本不够用:数据更新了没法增量管理,想按年份、类型、评分区间筛选得反复重跑脚本。接上Flask和可视化之后,所有查询都变成网页上的点选操作,整个工具才算真正顺手。

1.2 为什么选Flask而不是Django或FastAPI

选型前我列过三个候选框架:Django、Flask、FastAPI。Django自带Admin后台和ORM,功能全,但相对重,杀鸡用牛刀;FastAPI异步能力强,适合高并发接口,但配套资料偏新,对刚接触全栈的开发者不够友好;Flask足够轻,路由、模板、请求对象都简单直接,又有庞大的插件生态,适合快速开发和教学演示。

另外,这个系统的并发量并不高,单人使用或小组内网使用,每分钟几十个请求撑死了,Flask同步阻塞模型完全扛得住。与其上一套复杂异步架构,不如用最直白的办法把功能跑通。真要上生产、集群并发几十万,那是另一个话题,但作为数据采集与可视化系统,Flask的简单反而是优势:代码量少、排查容易、部署方便。如果哪天接口压力上来了,还可以用Gunicorn这类WSGI服务器横向扩充。

1.3 数据存储选择:SQLite够用就不要勉强上MySQL

存储层我在SQLite和MySQL之间犹豫过。MySQL是大家熟悉的关系型数据库,功能强,但需要单独安装服务端、配置账号权限、处理连接池,在一个个人项目里显得繁琐。SQLite是Python内置支持的嵌入式数据库,单文件就能落地,无需额外进程,完全满足这个量级的数据读写。

按这个系统日均新增几百条到几千条电影记录来看,SQLite支撑几万、几十万条数据没有任何问题。查询速度也够快,配合索引,百万行级别以内都不至于卡顿。更要紧的是,我后期用Flask的SQLAlchemy操作数据库,切换到MySQL只需改连接串和少量方言配置,迁移成本很低。所以我的建议是:个人项目初期用SQLite快速搞定,后面真要团队协作、多人并发写入,再平滑迁到MySQL,不折腾。

2. 数据采集:爬虫设计核心细节

2.1 目标站点分析与字段定义

动手写爬虫前,第一件事不是敲代码,而是打开目标站点,把页面结构和要抓的字段梳理清楚。我一般会先人工浏览几个列表页和详情页,用浏览器的开发者工具看网络请求,确认数据是直接渲染在HTML里,还是通过异步接口返回。这个决定直接影响解析方案。

这个系统要采集的字段相对固定:电影名称、导演、主演、类型、上映年份、地区、评分、评价人数、剧情简介。其中评分和评价人数是分析重点,必须严格统一格式,比如评分保留两位小数,评价人数统一转成整数。有些站点列表页自带评分,但评价人数需要进详情页,这就涉及列表页和详情页两级抓取,会增加请求量。我当时的取舍是:列表页先拿基础字段,详情页再补全导演、简介等长文本字段。如果目标是快速验证,可以先只抓列表页已展示的字段,等稳定后再追加详情页采集。

2.2 请求头、频率与重试机制

爬虫不是越快越好,而是越稳越好。我见过太多新手用单线程for循环,连着不歇地请求,结果半分钟后IP被限制,后续请求全部失败。我的做法是:

  • 设置随机User-Agent,伪造不同浏览器的请求头。
  • 每次请求之间随机延时0.5到1.5秒,避免固定节奏被识别。
  • 使用requests.Session保持连接,减少TCP握手开销。
  • 配置超时时间和重试次数,网络抖动时不至于直接崩掉。
  • 捕获HTTP状态码,遇到403或429主动拉长间隔,退避重试。
import requests import time import random HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" } session = requests.Session() session.headers.update(HEADERS) def safe_get(url, retries=3): for i in range(retries): try: resp = session.get(url, timeout=8) if resp.status_code == 200: return resp elif resp.status_code in (403, 429): wait = 10 * (i + 1) + random.uniform(0, 2) time.sleep(wait) except requests.RequestException as e: if i == retries - 1: raise time.sleep(2) return None

这里有个重要细节:重试不等于反复用同一个User-Agent硬怼,重试前最好换一次请求头。如果连续重试三次还失败,应该记录日志并跳过,而不是无限循环,否则会把反爬压力越拉越高。

2.3 页面解析与字段清洗策略

解析我优先用BeautifulSoup加lxml解析器。BeautifulSoup的API直觉,定位标签方便;lxml解析速度快。组合使用代码看起来清爽,性能也够用。

from bs4 import BeautifulSoup def parse_listing_html(html): soup = BeautifulSoup(html, "lxml") results = [] for item in soup.select(".movie-item"): title_tag = item.select_one(".title a") score_tag = item.select_one(".score") if not title_tag: continue name = title_tag.get_text(strip=True) score = float(score_tag.get_text(strip=True)) if score_tag else None results.append({ "name": name, "score": score }) return results

字段清洗是爬虫环节最容易翻车的地方。原始HTML里的评分常带“分”“/10”之类的前缀后缀,评价人数则有“万”“人评价”等中文字符,不处理干净存进数据库,后面可视化口径全乱。我专门写了一个clean_field函数,把所有字符串统一修剪,数字类型转成float或int,缺失字段填默认值。清洗规则尽量写在采集阶段,不要留到查询阶段再处理,否则每次查询都要重算,性能和逻辑都会变差。

2.4 增量更新与去重设计

如果爬虫每次全量重跑,不仅浪费请求次数,还会产生大量重复数据。我为数据库表加了唯一索引,以电影名加上映年份为去重键。写入前先查询是否已存在,存在则更新评分等最新值,不存在才插入。这样每次扫描只需要处理新出现或变更的数据。

实际的增量方案我用的是“按列表页分页抓取+详情页按需补全”。比如第一轮已经抓过前10页,后续更新时只需重新抓前2页和用户重点关注的最近年份,详情页再根据数据库里缺失字段的记录补抓。这也是为什么数据库里要单独加一个data_complete标志位,避免同一部电影的详情页反复抓。

3. Flask后端与API接口实现

3.1 接口设计与路由规划

Flask部分我没有做太复杂的蓝图,按功能拆成三个路由模块就能保持清晰:

路由方法功能
/GET渲染可视化主页面
/api/summaryGET返回总电影数、平均分、最高分等概览
/api/trendGET按年份返回平均评分、数量趋势
/api/distributionGET返回评分区间分布
/api/type_compareGET按类型返回数量与平均分

设计API时,我刻意让接口参数化。比如/api/trend支持year_from、year_to、genre参数,前端所有筛选都通过URL参数传给后端,后端返回统一格式的JSON。这样前端只负责收集筛选条件并发请求,后端专注处理查询逻辑,职责分离,调试时直接用浏览器访问URL就能看到返回值。

3.2 数据库查询与缓存加速

SQLAlchemy用起来虽然有一定上手成本,但胜在查询表达式直观,而且后期换数据库不用改业务代码。比如查询按年份分组的数据:

from sqlalchemy import func from models import Movie def get_year_trend(year_from=None, year_to=None): query = db.session.query( Movie.year, func.avg(Movie.rating).label("avg_rating"), func.count(Movie.id).label("total_count") ).group_by(Movie.year) if year_from: query = query.filter(Movie.year >= year_from) if year_to: query = query.filter(Movie.year <= year_to) return [{"year": r.year, "avg": round(r.avg_rating, 2), "count": r.total_count} for r in query.all()]

每个聚合接口都涉及全表扫描,如果数据量上来,查询时间会明显增加。我还加了一层简单缓存:用functools.lru_cache缓存最近请求结果,或者把热门查询结果写入一个cache_table。在实际项目里,我只缓存了5分钟内不变的数据,爬虫更新后会主动清除相关缓存,这样既保证新鲜度又降低数据库压力。

3.3 前后端数据传递规范

前后端的数据格式我统一用JSON。后端返回格式固定为:

{ "code": 0, "message": "success", "data": [...] }

code为0表示成功,非0表示异常。前端通过fetch拿到数据后先判断code,再渲染图表。这个约定虽然简单,但排查问题时非常顺手——网络请求是否成功、业务是否正常、数据是否为空,光靠状态码和返回值就能定位。

我踩过的坑是:直接把列表返回出去,没有包一层data字段,后面想加个全局提示信息都没有位置。所以建议从一开始就不要偷懒,统一响应结构,后期扩展成本极低。

4. 可视化展示与前端交互实现

4.1 ECharts图表选型与适用场景

可视化库我选了ECharts,原因不外乎三点:文档全、图表类型多、对中文场景支持好。传统的HTML表格展示数据虽然严谨,但没法快速看出趋势。ECharts里我主要用了四个图:

  • 折线图:年份评分趋势,能明显看出高分年份和低分年份。
  • 柱状图:年份电影数量,适合看产量波动。
  • 饼图:类型占比,适合看整体结构。
  • 散点图:评分与评价人数关系,能快速发现“叫好不叫座”还是“票房口碑双丰收”。

图表不是越多越好,每个图表都要能回答一个具体问题。如果只是堆满五颜六色的图表,读者根本不知道重点在哪。我做页面时先列问题:高分电影集中在哪些年份?哪些类型平均分更高?评分和评价人数有没有相关关系?然后才决定用什么图表。

4.2 筛选面板与异步联动

可视化页面的核心体验在于筛选联动。页面上放了年份区间、类型、评分区间三个主要筛选器,任一筛选变化都会触发所有图表重新请求数据。这里有一个关键设计:不同图表接口可以选择共用参数,但响应速度敏感度不同。比如顶部概览卡希望秒级响应,而趋势图可以容忍几百毫秒延迟。

前端我用原生JavaScript加fetch实现,不用Vue或React这类重框架。因为页面本身不复杂,直接用事件监听绑定change事件,统一调用一个reloadAll()函数,分别请求四个接口再更新对应图表。好处是代码量短,问题定位快;坏处是如果以后图表数量上涨,代码会开始冗余。我预留了一个chartBinder对象,把所有图表的更新函数挂载在一个Map里,方便后续扩展。

async function reloadAll() { const params = buildQueryString(); const [summary, trend, dist, typeData] = await Promise.all([ fetch(`/api/summary?${params}`).then(r => r.json()), fetch(`/api/trend?${params}`).then(r => r.json()), fetch(`/api/distribution?${params}`).then(r => r.json()), fetch(`/api/type_compare?${params}`).then(r => r.json()) ]); updateSummary(summary.data); updateTrend(trend.data); updateDistribution(dist.data); updateTypeBar(typeData.data); }

这里用Promise.all并行请求四个接口,避免串行等待。如果某个接口数据量特别大,ECharts渲染可能卡顿,我会在响应式图表上使用animation: false或分批次setOption,保证交互流畅。

4.3 加载与异常状态处理

数据可视化页面最怕两种情况:接口超时和接口报错。我在前端写了统一的错误提示:请求过程中显示加载动画,请求失败后不更新图表,而是在页面顶部展示一条错误横幅,并保留上一次成功渲染的图表。这个“保留上一次数据”的策略很实用——爬虫偶尔更新失败时,页面不至于变成一个空白残废。

另外,ECharts的容器高度必须显式设置,否则图表会渲染成0像素。这是一个特别低级但特别常见的错误。我早期做图表时,忘了设div高度,查了半天找不到原因,最后发现只是CSS里少了height: 500px。

5. 部署上线与平滑运行经验

5.1 本地运行与服务器部署

本地调试直接跑python app.py最方便,默认5000端口。但生产环境不能裸用Flask自带的开发服务器,它没有做并发优化,单进程处理请求时容易阻塞。我的部署方案是:Gunicorn + Nginx。Gunicorn作为WSGI容器,开多个worker进程;Nginx负责静态文件服务和反向代理,同时可以拦截一部分异常流量。

gunicorn -w 4 -b 127.0.0.1:8000 app:app

-w 4代表4个worker进程,app:app表示模块名和Flask实例名。Nginx配置里把/static/直接映射到本地静态目录,同时将/api/代理到Gunicorn地址,这样前端静态请求不占用Python进程,业务接口才有充足资源。

5.2 爬虫调度与日志体系

爬虫脚本不能只在开发时手动跑一次就完事。我希望数据每周自动更新一次,最简单的方式是使用系统自带的计划任务。Linux环境写一个cron表达式,每天凌晨2点执行采集脚本;Windows下可以用任务计划程序。

爬虫执行时,日志非常关键。我写的日志里至少包含以下字段:抓取页码、请求URL、状态码、耗时、抓取条数、异常信息。不要只打印“success”或“failed”,一定要带上能定位的数据。比如某条详情页解析失败,我得知道是哪一个URL才能回查。

为了不让日志文件无限膨胀,我用RotatingFileHandler按大小轮转,单文件超过5MB就自动归档。这个细节很多人会忽略,等服务跑了几个月后磁盘被日志写满才后悔。

5.3 反爬边界与合规提醒

爬虫能跑起来不难,难的是不给自己找麻烦。我在整个项目里反复提醒自己:只抓公开信息,控制请求频率,尊重网站声明,数据仅用于个人学习研究,绝不用于商业用途。系统没有集成任何破解验证码或绕开访问限制的能力,也不应该去碰这些技术。做爬虫项目的正确姿势是“够用就好”:目标网站有API就优先用API,没有API就适度采集,不要一次性把所有数据连夜拉完。

如果你要公开分享或部署这套系统,一定把数据来源和用途说清楚。我做这个项目时,每次采集间隔至少0.5秒,单日总量控制在几千条以内。这类系统更适合小规模分析,不是大规模商业数据仓库。

6. 常见问题与排查技巧实录

6.1 高频问题速查表

问题表现可能原因处理方法
爬虫请求403请求头被识别更换User-Agent,增加随机延时
解析结果为空页面结构或选择器变化打开浏览器开发者工具,重新确认标签
数据库“no such table”表尚未创建先执行建表语句,确认数据库文件路径
Flask接口返回500查询字段类型错误查看日志堆栈,检查查询条件类型
图表不显示容器高度未设置给div设置明确高度
数据总数对不上有重复记录或漏抓检查去重键和分页遍历边界
异步请求跨域报错前后端端口不一致用Nginx同源部署或配置CORS

这张表里的大部分问题我都实际遇到过。比如选择器失效,根本原因不是代码写错,而是网站改版替换了标签类名。遇到这种情况,优先看开发者工具里的元素路径,再改解析逻辑。

6.2 两个经典故障复盘

第一个故障:爬虫跑到一半,数据库里的电影表莫名出现几千条重复数据。查了半天发现我在详情页更新逻辑里没有检查“是否已存在”,每次执行都直接插入新记录。去重键虽然加了,但因为表创建时的旧数据没有清理,唯一索引被历史脏数据顶住,后续插入一直报错。解决方法是彻底重建表结构,并确保后续每次写入都执行“先查后插”,而不是“插入前联动唯一索引”。

第二个故障:可视化页面评分区间分布全挤在“9分以上”这一栏。原因是前端把后端返回的评分字段当成了字符串拼接,比如“9”加“.”加“5”变成了字符串“9.5.0.1”,数值比较全走错分支。排查时我看接口返回的JSON完全正常,问题出在前端数据转换环节把浮点数错误格式化了。最后统一在前端入口做一层Number()转换,所有图表数据才恢复正常。这个案例再次说明:前后端之间约定好类型,传输时保持JSON原生类型,不要随意手工拼字符串。

6.3 保留一点自己的排错习惯

我现在排查这个系统问题都是按固定顺序:先看爬虫日志,确定数据层是否正常;再请求API,确定查询逻辑是否正常;最后刷新页面,确定渲染是否正常。任何时候都先确认数据,再怀疑代码,不要一上来就翻前端代码。数据有了,前端怎么画都有据可依;数据不对,前端改得再花哨也是错上加错。

我个人实际操作中还有个习惯:每天跑完增量爬虫后,自动往一个monitor表写入一条统计数据汇总,比如“今日新增20条,更新15条,评分均值8.1”。这样做既方便判断爬虫有没有顺利工作,也能为后续的数据质量分析留底。

最后再分享一个小技巧:这个系统后续扩展很顺手。如果你想加导演分析,只需在爬虫端补充导演字段,可视化端增加一个接口和图表,其他模块几乎不用动。如果不想跑爬虫,也可以把系统改成纯手动导入CSV的模式,后端查询和展示部分完全复用。任何数据分析项目,最核心的不是工具多高级,而是你对手里的数据有没有清晰的理解,这个系统帮我把这个过程自动化了,数据分析的乐趣也回来了。

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

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

立即咨询