ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建电影评分数据爬取与可视化系统:Flask+ECharts实践指南

从零构建电影评分数据爬取与可视化系统:Flask+ECharts实践指南

最近整理电影榜单数据的时候,我又一次掉进了熟悉的坑里:打开评分网站,一页一页翻,把片名、年份、评分、评论数一个个敲进表格。几十部片子还好,上百部就开始眼花,遇到评分小数点和评论数里的千分位符,誊错两三行是常有的事。这种活儿干过一次就不想再干第二次。于是我用 Python 搭了一套基于 Flask 的电影评分数据爬取与可视化系统,让采集、存库、出图这条链路全部自动化。这篇文章就把整个系统的设计思路、核心代码、踩过的坑一次性讲清楚,适合想练手 Python 爬虫和 Web 可视化、又不想只做个命令行小脚本的开发者参考。

这套系统本质上解决的是三类问题:数据从哪来、数据怎么存、数据怎么展示。对应到技术上就是爬虫模块、存储模块、Web 接口和前端图表四块。整体结构不复杂,但每一块都有值得细说的细节,尤其是反爬应对、数据清洗和前后端联调时容易翻车的地方。我尽量把实际操作中遇到的真实情况和处理办法都写出来,而不是只给一个"能跑就行"的 Demo。

1. 为什么做这个系统:从手动整理数据到一键出图

1.1 需求从哪来:手工整理的效率瓶颈

我最初的需求特别朴素:想统计近十年电影评分的变化趋势,顺便看看不同类型影片的评分分布。听起来很简单,但数据收集这一步就能把人劝退。评分网站的分页列表一次显示几十条,每条包含片名、年份、评分、评论数、类型等字段,翻十页就是几百条,纯手抄至少两个小时起步,而且抄完还得自己算平均值、做透视表,Excel 公式写多了还容易出错。

更麻烦的是,这种整理是一次性的。过两周想更新数据,又得重新翻一遍。如果改成写死的爬虫脚本,虽然省了手工,但每次改页面结构、改字段都要动代码,维护成本也不低。所以我的想法很明确:与其写一个用完就扔的脚本,不如做成一个常驻的小系统,爬虫定时跑、数据自动入库、前端随时查,以后想加图表、加筛选条件,改起来也方便。

1.2 方案对比:为什么不是纯脚本,也不是完整后台系统

做之前我其实对比过几种方案。第一种是纯 Python 脚本加 matplotlib 出静态图,优点是代码量少,缺点是每次看数据都要重新跑一遍脚本,而且图是死的,没法交互,想看某个年份区间的分布还得改代码。第二种是直接用现成的商业数据分析工具,但数据源不开放,导入导出也麻烦。第三种就是我最终选择的:Flask 提供 Web 接口和页面,前端用 ECharts 画交互图表,爬虫模块独立在后台运行。

这个组合的核心好处是"数据流是活的"。爬虫把数据写进数据库,Flask 把数据库内容通过 JSON 接口暴露出去,前端页面用 fetch 拉接口再渲染图表。以后想换图表类型、加筛选器,只需要改前端;想改数据源,只需要动爬虫模块;数据库结构如果变了,最多改改接口层。三层解耦,每一层都能单独迭代,对一个小型数据系统来说,这个架构的性价比非常高。

2. 技术栈取舍:Flask、requests 与轻量存储的组合逻辑

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

选 Flask 不是因为别的框架不好,而是这个项目的体量摆在那里,杀鸡不用牛刀。Django 自带 Admin 后台、ORM、迁移工具,适合大项目,但对我这种只需要三个接口加一个页面的场景来说,光初始化项目结构就要多写不少文件,学习成本也不低。FastAPI 性能好、支持异步,但它的异步特性和爬虫这边的 requests 同步模型搭在一起,反而增加理解负担。

Flask 的优势在于"刚刚好":路由、模板、静态文件这些基础能力都有,项目结构可以自己随意组织,想简单就单文件,想规范就拆蓝图。而且 Flask 的开发模式自带调试页面,接口报错时能在浏览器里直接看到堆栈信息,调试爬虫返回的数据格式、测试 JSON 结构变化都特别方便。对这种中小型数据展示项目,Flask 的轻量反而是最大的优点。

2.2 存储选型:SQLite 在中小型项目中的优势

数据量不大的时候,很多人第一反应是装个 MySQL,但这里我选了 SQLite。原因很简单:这个系统预计存几千到几万条电影数据,SQLite 单文件搞定,不需要单独装数据库服务,不需要管账号权限,备份就是把 .db 文件拷走。Python 标准库自带 sqlite3 模块,连驱动都不用额外装。

有人会担心 SQLite 并发问题,但其实 Flask 这边只是读多写少,爬虫写入的时候加个事务,查询走索引,完全够用。真要上了十万级数据、多个进程同时写,那时候再迁移到 MySQL 也不迟,因为 SQLAlchemy 的 ORM 层已经屏蔽了底层差异。这个教训我是后来才体会到的:前期纠结数据库选型,不如先跑起来,数据量到了再迁移才是成本最低的路径。

2.3 可视化方案:ECharts 与后端出图的对比

可视化我也对比过两条路。一条是 Python 侧用 pyecharts 生成 HTML,好处是能复用 Python 的数据处理逻辑,但每次改图表配置都要重新生成整个文件,交互灵活性差。另一条是前端直接用 ECharts,数据从接口拿,页面加载时动态渲染,筛选条件变了重新请求数据或者前端过滤,图表实时更新。

我最终选了后者。ECharts 对常见图表类型覆盖很全,柱状图、折线图、饼图、散点图都有成熟配置,而且图表自带的 tooltip、图例、数据缩放都是开箱即用,不用自己写交互逻辑。前端只负责"把 JSON 填进 option 里",核心的聚合计算放在后端接口里做,SQL 一条 GROUP BY 就能出结果,比在大循环里算平均值靠谱得多。

3. 爬虫模块设计:评分数据采集链路与原理解析

3.1 采集目标与数据字段定义

动手写爬虫之前,先要把"要什么字段"列清楚。我最终定了七个字段:片名、年份、评分、评论数、类型、地区、入库时间。片名和年份是唯一标识,用来去重;评分和评论数是核心分析指标;类型和地区是辅助维度,后续可以做交叉分析。

# 字段示例 movie_item = { "title": "星河彼岸", "year": 2023, "rating": 8.4, "review_count": 12867, "genre": "科幻", "region": "北美", "crawled_at": "2025-01-12 10:30:00" }

这里有个容易忽略的点:年份和片名一定要组合起来当唯一键。因为不同年份可能有同名翻拍片,只看片名会出现误判,把两部电影当成一条数据合并掉。这个唯一键在后续去重逻辑里非常关键。

3.2 请求策略:Headers、重试与限速

爬虫的第一步是发请求,但裸请求很容易被对方服务器识别为非浏览器访问。我的做法是构造一个完整的浏览器 Headers,包括 User-Agent、Accept、Accept-Language、Referer,并让这些字段尽量贴近真实浏览器的取值。

import requests 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.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://example-movie-site.com/" } def fetch_page(url): for attempt in range(3): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp elif resp.status_code in (403, 429): print(f"请求被拒: {resp.status_code}, 等待重试 {attempt + 1}/3") time.sleep(5 * (attempt + 1)) except requests.exceptions.RequestException as e: print(f"网络异常: {e}") time.sleep(3) return None

重试次数我设为三次,每次失败等待时间递增,这就是简单的指数退避。限速方面,每请求一页后随机休眠 1 到 3 秒,避免高频请求触发风控。这里想特别提醒一点:用time.sleep(random.uniform(1, 3))而不是固定time.sleep(2),因为固定的请求间隔反而容易被规律性检测识别出来。

3.3 HTML 解析与数据清洗的细节

拿到页面 HTML 之后,我用 BeautifulSoup 加 lxml 解析器提取目标数据。选择器尽量定位到稳定的容器节点,再在容器内部找子元素,避免写成一大串从根节点开始的长选择器——长选择器一旦页面某个层级变化就容易整个失效。

from bs4 import BeautifulSoup def parse_movies(html): soup = BeautifulSoup(html, "lxml") movie_list = [] items = soup.select(".movie-item") for item in items: title_tag = item.select_one(".title a") rating_tag = item.select_one(".rating strong") year_tag = item.select_one(".year") genre_tag = item.select_one(".genre") region_tag = item.select_one(".region") title = title_tag.get_text(strip=True) if title_tag else None rating_text = rating_tag.get_text(strip=True) if rating_tag else "0" year_text = year_tag.get_text(strip=True) if year_tag else "0" genre = genre_tag.get_text(strip=True) if genre_tag else "未知" region = region_tag.get_text(strip=True) if region_tag else "未知" movie_list.append({ "title": title, "year": int(year_text), "rating": float(rating_text) if rating_text.replace(".", "").isdigit() else 0.0, "genre": genre, "region": region }) return movie_list

解析这一步最容易翻车的不是选择器,而是数据格式转换。页面上展示的评分可能是"8.4",也可能是"暂无评分";评论数可能是"12,867"或者"1.2万",直接 int() 肯定报错。我的处理方式是先做字符串清洗,把逗号去掉、把"万"换成数字再乘 10000,转换失败就赋默认值并打日志,而不是让整个爬虫中断。

3.4 数据落库与去重逻辑

清洗完的数据进入数据库前,要做去重判断。我用"片名+年份"构造唯一键:查一次表,存在就跳过,不存在就插入。这一步不能省,因为爬虫是分批跑的,第二次跑的时候如果不去重,库里会堆满重复数据,前端图表统计出来全是错的。

def save_movies(conn, movies): cur = conn.cursor() inserted = 0 for m in movies: cur.execute( "SELECT id FROM movies WHERE title=? AND year=?", (m["title"], m["year"]) ) if cur.fetchone() is None: cur.execute( "INSERT INTO movies (title, year, rating, review_count, genre, region, crawled_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (m["title"], m["year"], m["rating"], m["review_count"], m["genre"], m["region"], time.strftime("%Y-%m-%d %H:%M:%S")) ) inserted += 1 conn.commit() print(f"本次新增 {inserted} 条,跳过重复 {len(movies) - inserted} 条") return inserted

我建议用 INSERT 前先 SELECT 的方式,而不是直接 INSERT OR IGNORE。虽然后者代码更短,但前者能知道到底跳过了多少重复数据,方便观察爬虫运行情况。如果你有日志系统,把"新增/跳过"数量打出来,比看爬了多少页更能反映采集质量。

4. 数据层与 API 设计:从 SQLite 到 JSON 接口的细节

4.1 数据库表结构与索引设计

库表结构我设计得很简单,就一张 movies 表,字段跟爬虫的 item 对应。但有一件事不能偷懒,就是索引。系统最常用的查询是"按年份聚合""按评分区间统计""按类型分组",这些查询都会用到 year、rating、genre 字段,所以索引要建在这三列上。

CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, year INTEGER NOT NULL, rating REAL DEFAULT 0, review_count INTEGER DEFAULT 0, genre TEXT DEFAULT '未知', region TEXT DEFAULT '未知', crawled_at TEXT, UNIQUE(title, year) ); CREATE INDEX IF NOT EXISTS idx_movies_year ON movies(year); CREATE INDEX IF NOT EXISTS idx_movies_rating ON movies(rating); CREATE INDEX IF NOT EXISTS idx_movies_genre ON movies(genre);

可能有人觉得几万条数据建不建索引无所谓,但如果聚合查询是GROUP BY year,全表扫描确实也跑得完,只是当数据到 5 万条以上时,响应时间能从几十毫秒涨到一二百毫秒。前端如果加了年份筛选联动,每次切换都要查询,累积起来体感就会很明显。所以索引这种低成本优化,越早做越好。

4.2 Flask 蓝图与 JSON 接口规范

API 层我用 Flask 蓝图把接口拆开,方便以后扩展。三个核心接口分别是:电影列表接口、年度评分趋势接口、评分分布统计接口。每个接口都返回统一的 JSON 结构,前端解析起来不用判断各种特殊情况。

from flask import Flask, jsonify, request from blueprint import stats_bp app = Flask(__name__) app.register_blueprint(stats_bp, url_prefix="/api/stats") @app.route("/api/movies") def list_movies(): year = request.args.get("year", type=int) genre = request.args.get("genre", default="全部") # 拼 SQL 查询... return jsonify({"code": 0, "msg": "ok", "data": movie_list})

统一响应结构这件事,看着是小事,实际帮了我大忙。前端 fetch 接口后,先判断code是否为 0,再取data,异常处理集中在一个地方。后续加"分类接口"“搜索接口”的时候,前端代码几乎不用改动,只要照着同一套结构写数据解析就行。

4.3 参数校验与异常返回的统一处理

接口开发过程中最烦人的是前端传来的参数不干净。比如年份参数传了字符串 "abc",type=int会解析成 None,如果不处理,SQL 里就会拼出WHERE year = None这种错误语句,返回 500。我的做法是在接口入口做参数兜底:无效参数直接给默认值,并在响应里带上提示信息,而不是抛异常。

@app.route("/api/stats/year-trend") def year_trend(): start = request.args.get("start", default=2000, type=int) end = request.args.get("end", default=2030, type=int) if start < 1950 or end > 2050: return jsonify({"code": 1, "msg": "参数超出范围", "data": []}) # 查询语句 ...

异常处理还有一个细节:不要在路由函数里裸 try/except 然后吞掉错误。日志里必须能看到堆栈,否则生产环境出问题连排查方向都没有。我习惯在 app 上挂一个全局错误处理器,把未捕获异常记入日志文件,返回给前端一个固定的 500 JSON 结构,这样至少不会出现前端页面一片空白还不知道为什么的情况。

5. 可视化展示层:用 ECharts 把评分变成能看的图表

5.1 页面结构与接口对接方式

前端我做了单页面,一个 HTML 文件里放了三个图表容器,顶部一个筛选栏,页面加载时依次请求接口、填充图表。Flask 的静态文件目录 static 放 ECharts 的本地 JS 文件,页面通过/static/echarts.min.js引入,不依赖外网 CDN,本地演示的时候不会因为没网而白屏。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>电影评分数据看板</title> <script src="/static/echarts.min.js"></script> </head> <body> <div id="chart-year" style="height:400px"></div> <div id="chart-rating" style="height:400px"></div> <div id="chart-genre" style="height:400px"></div> <script src="/static/dashboard.js"></script> </body> </html>

fetch 请求我封装成一个getJSON函数,统一处理错误提示。第一次联调时最容易翻车的点是:本地用浏览器直接打开 HTML 文件,请求/api/...会 404,因为文件协议没有后端。一定要通过 Flask 启动服务后访问http://127.0.0.1:5000/,这个问题新手经常卡半天。

5.2 评分分布、年份趋势与类型占比的图表设计

三个图表我分别用了柱状图、折线图和饼图。评分分布柱状图本质是一个频率分布直方图,我在后端把评分按区间分组统计,比如 0 到 2、2 到 4、4 到 6、6 到 8、8 到 10 五个区间,前端拿到数组后直接映射到 xAxis。

fetch('/api/stats/rating-distribution') .then(r => r.json()) .then(data => { if (data.code !== 0) return; const chart = echarts.init(document.getElementById('chart-rating')); chart.setOption({ xAxis: { type: 'category', data: data.data.labels }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: data.data.values, itemStyle: { color: '#5B8FF9' } }] }); });

年份趋势折线图我关注的是"平均评分随年份的变化",所以后端 SQL 是SELECT year, AVG(rating) FROM movies GROUP BY year ORDER BY year。类型占比饼图则是SELECT genre, COUNT(*) FROM movies GROUP BY genre。这三个查询都非常简单,真正的复杂度在于把结果整理成 ECharts 需要的格式,而不是在 SQL 里搞花活。

5.3 前后端联调中的常见数据格式误区

联调时我踩过几个很典型的坑,写出来给大家提个醒。第一个是数据类型:SQLite 的 COUNT 结果默认是整数,但如果前端 JS 拿到的是字符串,图表 y 轴会显示异常。我统一在后端把数值字段转成 int 或 float,前端只做展示。第二个是空数据:某个年份区间没有电影,SQL 聚合结果为空,后端直接返回空数组,前端图表会渲染成空白,这种情况我一般会加一个"当前条件下暂无数据"的占位文案。

第三个坑比较隐蔽:ECharts 的 tooltip 默认展示的数据格式和后端返回的不一致。比如后端返回的是value字段,但自定义 tooltip 里写死了params.name和params.value,两者对不上标签显示就乱了。我的经验是,前端封装一个formatTooltip函数统一处理,别在图表配置里到处写死字段名,不然改后端字段名的时候前端得全局搜索替换。

6. 实战中的坑与优化:反爬、性能与部署经验

6.1 反爬的层级与应对思路

写爬虫最大的不确定性就是目标网站的风控策略。我在这个项目里遇到的典型情况分几个等级:最低级的是直接返回错误页面;再高级一点是对异常频率的 IP 临时限流;再往上就是验证码和登录墙。应对思路也是递进的:先优化请求头、提升请求间隔,确实不行再考虑更换出口网络,最不济的才是上验证码识别那一套,而那一套对小项目来说成本太高,性价比很低。

我实际使用的策略组合是:随机 User-Agent 池、每次请求间隔 1 到 3 秒、重试时指数退避、失败超过三次就停止本轮采集并记录日志。这套组合对付大多数普通网站已经够了。这里想强调一种更实用的思路——把采集时间安排在目标网站访问低峰期,比如凌晨三四点,而且每次只采集增量数据,不要一上来就全量重新爬。采全量数据对服务器压力大,自己也容易触发风控,增量更新才是更健康的方式。

6.2 采集效率与服务器负载的平衡

爬虫效率不是越快越好。我发现很多教程喜欢吹"并发 20 个线程爬取",但真实场景里,并发越大越容易被封。我这个系统数据量不大,单线程加限速反而最稳。用并发的前提是目标网站允许,或者你做了完善的退避策略,否则就是给自己找麻烦。

# 单线程限速采集示例 for page in range(1, total_pages + 1): url = f"https://example-movie-site.com/list?page={page}" html = fetch_page(url) if html is None: print(f"第 {page} 页采集失败,终止本轮任务") break movies = parse_movies(html.text) save_movies(conn, movies) time.sleep(random.uniform(1, 3))

实测下来,单线程采集 100 页数据大约需要五六分钟,这个速度对个人项目完全够用。更关键的是稳定,跑一晚上都不会被封。如果一定要提效,建议退而求其次:把限速间隔从 1 到 3 秒改成 0.5 到 1 秒,而不是直接上并发。

6.3 本地运行与生产部署的差异处理

系统在本地跑通之后,部署到服务器上又是一轮新的坑。最明显的是 Flask 自带的开发服务器不能直接用于生产环境,它的性能太差,而且调试模式会暴露敏感信息。我的建议是本地开发用app.run(debug=True)方便调试,生产环境则用 WSGI 服务器跑应用,前面再挂一层反向代理处理静态资源和并发请求。

部署时还有一个容易忽略的问题:SQLite 数据库文件的路径。代码里如果写的是相对路径,在服务器上工作目录一变就找不到库文件。我后来统一改成基于项目根目录的绝对路径,启动时自动检查目录是否存在,不存在就创建。这个细节看起来不起眼,但能让部署过程省掉很多定位问题的时间。

另一个部署差异是跨域。如果前端页面和服务是不同端口,浏览器会拦截跨域请求,需要在 Flask 侧配置 CORS 或者把前端页面直接放到 Flask 的静态目录下。我选择了后者,把 dashboard 页面放进 static 文件夹,绕开跨域,逻辑最简单。

写在最后:这类小系统后续可以怎么扩展

这套系统从爬虫到可视化完整跑通之后,最大的收获其实是"数据链路"的思维方式。数据采集、存储、接口、展示,每一层都可以独立替换,这个思路完全可以迁移到其他领域,比如商品价格监控、招聘信息分析、天气数据历史统计,结构都是一样的。我在实际使用中发现,最值得优化的不是爬虫速度,而是数据质量和维护便利性——把解析逻辑和选择器配置抽离出来,把日志做完整,比一味提升采集速度有用得多。

最后再分享一个小技巧:如果你也想做类似的系统,建议先手动抓一页 HTML 存成本地文件,解析逻辑对着本地文件调试,跑通了再去连网络请求。这样不会因为目标网站临时改版或者网络波动,干扰你对解析代码本身的判断。爬虫这种东西,运行环境越可控,排查问题越省心。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进