☰
Python猫眼电影数据分析与可视化:从爬虫到ECharts大屏实战
2026/9/29 5:29:13 网站建设 项目流程

简介:这是一份基于Python的猫眼电影数据分析可视化系统的完整设计文档,适合影视行业从业者、数据分析学习者以及需要毕业设计参考的高校学生。系统以requests库自动抓取猫眼公开电影数据,并借助Pandas完成去重、缺失值与异常值清洗,再通过Matplotlib和Echarts生成评分分布、票房趋势、电影类型等多维可视化图表,同时利用Flask框架搭建Web操作界面,让非技术用户也能直观掌握电影市场动态。文档正文从绪论、国内外研究现状、需求分析,到系统架构、核心模块实现、测试与扩展性设计均有详细阐述,既展示完整技术路线,也体现了代码的可维护性与未来功能扩展空间,封面、目录、正文和图表配套完整。压缩包内共1个docx文件,大小约3.31MB,结构清晰,读者可直接参照搭建同类数据分析可视化项目,也可为制定电影宣传策略和发行计划提供数据依据。目前已有382人学习下载,适合作为课程设计、毕业设计或行业数据分析实践的重要参考资料。

1. 先搞清楚:这套「猫眼电影数据分析可视化系统」到底在解决什么问题

很多人第一次看到这个标题,第一反应是「不就是爬个猫眼榜单再画几张图吗」。但实际动手你会发现,真正劝退你的不是爬虫,而是后面一整条链路:数据抓下来了怎么存、字段脏了怎么洗、评分和票房放在一起怎么分析、最后怎么在可视化大屏上把结论讲清楚。这个标题背后是一套完整的「数据采集 → 存储 → 清洗 → 分析 → 可视化」闭环,适合正在做 Python 数据分析入门项目、毕业设计,或者想给自己攒一个能写进简历的数据分析作品集的人。它不涉及高深的机器学习,但非常磨手:你会遇到反爬、字段缺失、编码混乱、图表选型失当等一系列真实问题。把这套链路走通,你对 pandas、requests、ECharts 的掌握程度会明显上一个台阶,这也是它值得做的核心原因。

2. 用 Python 把猫眼数据抓下来:先从 Top100 榜单入手

2.1 为什么选 Top100 作为数据源,而不是全站爬取

猫眼电影的数据接口大体分两类:一类是面向网页的 HTML 页面,一类是后端异步加载的 JSON 接口。全站爬取意味着你要处理登录态、签名参数、大量的反爬验证,对入门项目来说性价比极低。相比之下,Top100 榜单页的结构非常规整,每一部电影的标题、主演、上映时间、评分都集中在同一个列表页里,只需要几十行代码就能拿到干净的结构化数据。

你的系统核心目标是「数据分析与可视化」,不是「爬虫对抗」。所以数据源的选择标准应该是:数据足够覆盖分析场景,同时采集难度可控。Top100 榜单每页 10 部电影,一共 10 页,能拿到片名、主演、上映日期、评分、票房等字段,支撑评分分布、上映年份趋势、票房区间分析这些常见可视化主题绰绰有余。后面想扩展,再横向加「正在热映」或「口碑榜」也不迟。

实际采集时,最常见的实现方式是requests加BeautifulSoup,把页面里的<dd>节点逐一解析。这个方案在 2024 年前后依然可行,但前提是你必须伪装好请求头,并且控制采集频率。下面给出一个可直接跑通的最小实现。

2.2 爬取脚本的最小实现与逐行说明

import time import random import requests from bs4 import BeautifulSoup import pandas as pd 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", "Referer": "https://www.maoyan.com/board/1", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_one_page(offset: int) -> list: """抓取 Top100 榜单单页数据,offset 为 0、10、20 ... 90""" url = f"https://www.maoyan.com/board/1?offset={offset}" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") items = [] for dd in soup.select("dl.board-wrapper dd"): title_tag = dd.select_one(".name a") star_tag = dd.select_one(".star") time_tag = dd.select_one(".releasetime") score_tag = dd.select_one(".score") if not all([title_tag, star_tag, time_tag, score_tag]): continue items.append({ "title": title_tag.get_text(strip=True), "actors": star_tag.get_text(strip=True).replace("主演:", ""), "release_time": time_tag.get_text(strip=True).replace("上映时间:", ""), "score": score_tag.get_text(strip=True), }) return items def crawl_top100(): all_rows = [] for offset in range(0, 100, 10): rows = fetch_one_page(offset) all_rows.extend(rows) time.sleep(random.uniform(1.5, 3.5)) # 随机延时,避免请求过快 print(f"已抓取 offset={offset}, 累计 {len(all_rows)} 条") return pd.DataFrame(all_rows) if __name__ == "__main__": df = crawl_top100() df.to_csv("maoyan_top100_raw.csv", index=False, encoding="utf-8-sig") print(df.head())

这段代码有几个地方要重点说明。HEADERS里的Referer和User-Agent缺一不可,猫眼对裸的requests请求识别非常敏感,缺少这两个字段很容易被重定向到验证页。select选择器依赖的是榜单页的 DOM 结构,如果你在复现时发现抓不到数据,先打开浏览器开发者工具确认.board-wrapper dd这个节点路径是否还成立。解析后立即用pandas落盘,避免内存数据因程序异常丢失。

time.sleep(random.uniform(1.5, 3.5))是防止被封的关键。把固定延时改成随机区间,行为上更接近真实用户,也避免多个请求之间出现固定节律被识别。如果你只想验证代码流程,可以把范围改成0.5 - 1.0,但完整采集时建议不要低于 1 秒。utf-8-sig编码是为了让 Excel 直接打开 CSV 不乱码,这是处理中文数据的一个常规细节。

2.3 采集时的反爬边界与失败信号

采集过程中最常见的失败信号不是请求被拒,而是返回了「验证页面」。当你发现解析出来的items列表为空,或者title字段全部是乱码/空值,第一件事不是改解析逻辑,而是把resp.url和resp.status_code打印出来看看。

如果状态码是 200 但页面内容里找不到榜单节点,说明你拿到的可能是一个反爬中间页。此时requests.get的allow_redirects参数默认为 True,你看到的是跳转后的结果。解决办法是检查resp.text的前 200 个字符里有没有「验证」之类的关键词,有就说明需要加强请求头伪装或降低频率。

另一个容易翻车的地方是字段缺失。榜上的老电影偶尔会缺「上映时间」或「主演」,直接用get_text(strip=True)不会报错,但会留下空字符串。这一步先原样保留,等进入清洗阶段再统一处理。采集阶段追求「尽量不丢」,清洗阶段再做「去空和规范化」,两个阶段混在一起会让代码变得非常难调试。

3. 把数据交给 pandas 前,先想清楚存在哪、怎么存

3.1 选 SQLite 还是 CSV:根据你的可视化链路决定

爬下来的数据最终要喂给可视化层,存储方案的选择直接影响后续开发的体验。如果你的可视化是 Flask + ECharts 这类 Web 方案,数据需要被后端反复查询和聚合,那么 SQLite 是比 CSV 更合理的选择。它不需要额外安装数据库服务端,Python 标准库自带sqlite3,一张表存 100 条电影数据,读写效率绰绰有余。

CSV 的优势是简单直观,适合单机分析、用 pandas 一把梭。但缺点也很明显:字段类型需要你手动确认,中文编码在不同工具间容易出问题,而且每次可视化前的数据聚合都要重新读文件。我一般建议的折中方案是:采集完先存 CSV 做备份,同时写入 SQLite 作为主数据源。这样既留了后悔药,又不影响后续查询效率。

还有一点要注意:如果你的可视化大屏要用到 Redis 做缓存或热数据存储,那 SQLite 负责持久化、Redis 负责缓存查询结果,是一个很常见的数据分析项目架构。不过对 Top100 这个量级的数据来说,Redis 属于可选优化,不是必需品,先用 SQLite 把链路跑通,再加缓存不迟。

3.2 建表与写入:字段类型和索引一开始就要定好

CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, actors TEXT, release_time TEXT, release_year INTEGER, score REAL, box_office REAL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

建表时最容易忽略的是release_year和box_office这两个字段。原始数据里的release_time是「2018-07-05 中国大陆上映」这种混合字符串,直接存 TEXT 会导致后面做年份趋势分析时要反复字符串截取;box_office在 Top100 页面里往往不是显式字段,需要你在清洗阶段从其他信息里提取或者留空,所以先把表结构设计成可扩展的样子,比后面ALTER TABLE改结构要省心得多。

写入时建议用INSERT OR REPLACE配合唯一索引,防止重复采集时产生脏数据。实现上可以先把数据转成DataFrame,再用pandas.to_sql一次性写入,但注意to_sql默认不会创建主键和索引,你得在写入后手动执行建索引的语句。

import sqlite3 conn = sqlite3.connect("maoyan.db") df = pd.read_csv("maoyan_top100_raw.csv", encoding="utf-8-sig") # 将清洗后的 DataFrame 写入 SQLite df.to_sql("movies", conn, if_exists="append", index=False) # 手动创建索引,加速后续按年份、评分的聚合查询 conn.execute("CREATE INDEX IF NOT EXISTS idx_year ON movies(release_year)") conn.execute("CREATE INDEX IF NOT EXISTS idx_score ON movies(score)") conn.commit() conn.close()

if_exists="append"和"replace"之间的选择需要你根据业务决定。append适合增量采集后追加数据,但如果你改了清洗逻辑要重新生成数据,务必先手动DROP TABLE或改用replace,否则会出现同一部电影多个版本的记录。索引的命名规范建议带上前缀idx_,后续维护时一眼就能看出哪些列有索引。

3.3 清洗阶段的四个标准动作

# 1. 去重 df = df.drop_duplicates(subset=["title"], keep="last") # 2. 从 release_time 中提取年份 df["release_year"] = df["release_time"].str.extract(r"(\d{4})").astype("Int64") # 3. 评分统一转 float,非法值置为 NaN df["score"] = pd.to_numeric(df["score"], errors="coerce") # 4. 演员字段拆成列表,去掉空值 df["actors"] = df["actors"].fillna("").str.split(", ") df["actors"] = df["actors"].apply(lambda x: [a for a in x if a])

这四步处理顺序有讲究。先去重再提取年份,是为了避免重复记录干扰后续的聚合统计;评分转float时errors="coerce"会把无法转换的值变成NaN,比直接报错中断程序要好得多;演员字段从字符串拆成列表,是给后期「哪些演员出现频率最高」这类分析预留结构。release_year用Int64而不是int,是因为astype("int")无法处理NaN,这一点很容易让新手翻车。

清洗完成后,建议立刻做一个简单的质量检查:打印数据的形状、每列的缺失值数量、评分字段的描述性统计。如果 100 条数据里缺失值超过 10%,那问题大概率出在采集阶段而不是清洗阶段,回头去补抓数据比在这里硬填要靠谱。

4. 分析什么、怎么算:三个能立刻用上的分析维度

4.1 评分分布:用 pandas 直接出结论

数据分析部分的核心不是把数据画出来,而是先想清楚「要回答什么问题」。对于猫眼 Top100 这个数据集,有三类问题最容易出成果:高分电影的年份分布、不同年份段的评分差异、演员的霸榜频次。这三个方向既不需要复杂建模,又能展示你在数据理解上的基本盘。

import pandas as pd df = pd.read_sql("SELECT * FROM movies", sqlite3.connect("maoyan.db")) score_desc = df["score"].describe() print(score_desc) # 按年份分组,计算每年的平均分与电影数量 yearly_stats = ( df.groupby("release_year") .agg(avg_score=("score", "mean"), movie_count=("score", "count")) .reset_index() ) print(yearly_stats.sort_values("movie_count", ascending=False).head(10))

groupby聚合是这里最核心的操作。agg里对不同列应用了不同的聚合函数,mean和count同时算出来,避免写两遍groupby。输出结果后你大概率会发现一个规律:Top100 里近五年的电影数量明显偏多,但平均分不一定比老片高。这个现象本身就可以成为可视化大屏上最值得展示的洞察。

同时建议做一次评分区间分布分析,把 9.0 分以上、8.0-8.9、7.0-7.9、7.0 以下分成四个桶,用pd.cut实现。这个分布结果直接对应后面的柱状图或饼图,几乎没有额外工作量。

4.2 演员/导演维度:字符串拆分的下一步

演员字段在清洗阶段已经转成了列表,分析时真正落地为可量化指标的方法是「拆行」。最常见的做法是把一条包含多个演员的记录拆成多条,每人一行,然后再做频次统计。

# 将演员列表拆成多行,方便统计单人频次 actors_exploded = df.explode("actors") actor_counts = ( actors_exploded["actors"] .value_counts() .reset_index() ) actor_counts.columns = ["actor", "count"] print(actor_counts.head(20))

explode是 pandas 里处理列表字段的标准方法,它把一行里的多个演员展开成多行,其他字段自动复制。这里要注意的是,拆完之后数据的行数会变成几百行,如果后续还要和原表做关联,需要保留title字段作为关联键。统计结果里你大概率会看到几个熟面孔高频出现,这说明这批演员的票房号召力确实集中在头部。

这一步还有一个加分操作:把「演员出现次数」和「对应电影的平均评分」结合起来,挑选出演了至少 3 部电影且平均分大于 8.5 的演员,作为「口碑担当」榜单。这个交叉分析能体现你对多表数据关系的理解,比单纯数数高一个档次。

4.3 分析结果导出:为可视化准备 JSON 结构

可视化层面主要靠 ECharts,而 ECharts 的图表数据格式是 JSON。所以分析阶段的最后一步,是把 pandas 的DataFrame转换成前端可以直接消费的JSON数组。

import json # 生成评分区间分布数据 bins = [0, 7, 8, 9, 10] labels = ["7分以下", "7.0-7.9", "8.0-8.9", "9.0以上"] df["score_bin"] = pd.cut(df["score"], bins=bins, labels=labels, right=False) bin_counts = df["score_bin"].value_counts().reindex(labels).fillna(0) # 组装成 ECharts 友好的键值对结构 chart_data = [ {"name": str(bin), "value": int(count)} for bin, count in bin_counts.items() ] with open("score_distribution.json", "w", encoding="utf-8") as f: json.dump(chart_data, f, ensure_ascii=False, indent=2)

reindex在这里很关键,value_counts的结果会自动按频次倒序排列,而不是按你定义的区间顺序,直接转列表会导致图表上区间顺序紊乱。用reindex强制对齐到你期望的顺序,再fillna(0)处理空区间。ensure_ascii=False保证中文不乱码,这是所有导出 JSON 都要注意的细节。

到这一步,你的输出物已经从前端的「画图需求」反推到了「数据到底要长什么样」。整个链路不再是爬完数据就结束,而是每一层都在为下一层做准备。这也是数据分析项目区别于单纯爬虫项目的分水岭。

5. 避坑指南:这套系统最常见的五个翻车现场

5.1 爬虫被重定向到验证页:不是 IP 被封,是请求头不够像浏览器

现象:requests.get返回 200,但解析不出任何电影节点,打印resp.url发现地址已经不是原来的榜单页。

原因:服务器识别出这是一个非浏览器发起的请求,返回了反爬中间页。缺少User-Agent或Referer是最常见原因,其次是请求频率过于规律,被判定为脚本行为。

解决:先补全请求头,把User-Agent换成真实 Chrome 的完整字符串,加上Referer指向榜单页本身。如果问题依旧,把time.sleep的固定延时改成随机延时。仍不行就考虑用session保持连接,先访问首页再访问榜单页,模拟真实浏览路径。

5.2 评分字段解析成「8.9」字符串后无法做数值比较

现象:df[df["score"] > 9.0]报错或结果为空,明明数据里有很多 9 分以上的电影。

原因:爬虫解析出来的score是字符串,且可能是「9.2」和「暂无评分」混合在一起。字符串比较「9.10」和「9.2」时逐位比较,结果完全不符合数值逻辑。

解决:用pd.to_numeric(df["score"], errors="coerce")统一转数值,无法转换的置为NaN。后续所有统计都要基于转换后的数值列,不要在原字符串列上做比较运算。分析前用df["score"].dtype确认类型已经是float64。

5.3 写入 SQLite 后中文变成乱码

现象:从 SQLite 里read_sql出来的中文正常,但用第三方工具(如 Navicat 或 DBeaver)打开表看到的是乱码或问号。

原因:连接 SQLite 时没有指定text_factory或数据库本身的编码声明不对。SQLite 默认以 UTF-8 存储文本,但某些客户端工具在创建连接时用了本地编码解析。

解决:Python 连接时显式设置conn.text_factory = str。同时避免在 Windows 下用默认编码gbk去读 UTF-8 内容。更稳妥的做法是,数据入库前统一转成 UTF-8,CSV 备份文件用utf-8-sig写入,保证两个存储实体的编码口径一致。

5.4pd.cut报错「bins must increase monotonically」

现象:按评分区间分组时,pd.cut直接抛异常,提示分箱边界必须单调递增。

原因:代码里写bins = [0, 7, 8, 9, 10],看起来是递增的,但数据里有NaN值,pd.cut默认无法处理缺失值,或者边界传入了None。也可能是你写了bins=[10, 0, 7, 8, 9]没有注意到顺序。

解决:分箱前先df = df.dropna(subset=["score"])或df["score"].fillna(df["score"].median())。检查bins列表确实是严格递增的数值序列。然后用pd.cut的right=False参数明确边界归属,避免边界值 7.0 落在两个区间的歧义。

5.5 可视化大屏的图表加载不出数据:JSON 结构对不上

现象:ECharts 页面打开后图表空白,浏览器控制台报错提示data不是数组或属性不存在。

原因:后端传过去的 JSON 是DataFrame.to_json()输出的对象格式,形如{"0": {...}, "1": {...}},而 ECharts 的series.data需要的是[{name: ..., value: ...}, ...]这样的数组格式。

解决:用json.loads(df.to_json(orient="records"))转成数组,或者像前面清洗阶段那样,用json.dump手动构造列表结构。前后端联调时先固定一个 mock JSON 文件,把前端调通后再接后端真实数据,这样问题边界清晰,不会两边互相甩锅。

6. 验证与进阶:从「数据能跑」到「系统能演示」

整套系统做完后,验证逻辑应该从两个方向走:一是数据正确性验证,二是可视化响应验证。数据正确性最简单的检验方式,是把你的统计结果和猫眼榜单页面上肉眼可见的数字做对照。比如 Top100 里最高分电影是那部、9 分以上有多少部,这些都能在榜单页上直接数出来。对不上就先查清洗逻辑,再查爬虫解析,不要急着怀疑数据库。

可视化响应验证则要关注大屏的实际使用体验。比较常见的做法是启动 Flask 服务后,强制刷新页面 20 次,观察图表是否稳定加载、接口响应是否在几百毫秒内。如果图表加载明显卡顿,优先检查是不是每次请求都重新读取 SQLite 做全量查询。一个立竿见影的优化是把分析结果缓存到内存里,只有数据更新时才重新计算,这也是我在真实项目里必做的一步。

进阶方向上,最值得投入的是时间维度的动态效果:用 ECharts 的timeline组件做年份滑块,让观众拖动滑块看每一年上榜电影的数量和平均分变化。这个功能在原版 Top100 静态数据上做不了,你得扩展采集范围到「近十年每年年度榜单」,然后把采集、清洗、分析的逻辑封装成按月可调的脚本。这一套做下来,系统就从「一个页面」变成了「一个真正的数据分析工具」。

另一个进阶点是给 Flask 后端加接口参数。比如/api/charts/score_distribution?year=2018,让前端能按年份筛选数据。这需要你给 SQLite 表加一个「榜单归属年份」字段,重构查询逻辑。改完之后,你可以声称这套系统支持多维度下钻分析,简历上的分量和只有一个静态大屏完全不同。

最后提醒一点,整个项目里我最深刻的教训是:不要急着写前端代码。先把数据清洗到「一眼能看到结论」的程度,再动手做图表。数据是脏的,任何可视化都是空中楼阁。先花 70% 时间把数据链路磨顺,剩下 30% 时间你会发现做可视化快得惊人。希望这篇笔记能帮你在自己的数据分析项目里少走几步弯路。

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

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

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

立即咨询