拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Python的猫眼电影数据分析可视化系统全链路实战

基于Python的猫眼电影数据分析可视化系统全链路实战

简介:基于Python的猫眼电影数据分析可视化系统毕业设计论文,面向计算机相关专业学生及从事数据分析、可视化开发的开发者。系统以requests库获取猫眼电影接口数据,通过Pandas完成去重、缺失值及异常值处理,再借助Matplotlib与Echarts实现电影评分、票房趋势、类型分布等多维度分析,并利用Flask搭建Web系统供直观浏览。文档对系统研究背景、国内外现状、需求分析、系统设计及测试等均有完整论述,能够帮助读者梳理毕业设计或项目开发的整体脉络。资源为1个docx文档,大小仅3.31MB,内容包含中英文摘要、关键词、目录及正文。目前已有382人学习下载,对于需要完成同类型电影数据分析课题或快速上手爬虫+可视化项目的人员,是一份结构清晰、可借鉴性强的参考资料,能有效降低系统搭建与论文撰写的前期探索成本。

1. 基于 Python 的猫眼电影数据分析可视化系统:一条能跑通的全链路项目

如果你正在找一份能把「爬虫 → 数据清洗 → 存储 → 分析 → 可视化 → 推荐」串起来的实战项目,又不想自己从零摸爬滚打踩三个月的坑,那这个基于 Python 的猫眼电影数据分析可视化系统值得好好拆一遍。它不是一个只会画两张图的 Demo,而是一套完整的毕业设计级工程:用 requests 把猫眼电影数据抓回来,Pandas 做清洗和处理,Flask 搭出 Web 界面,ECharts 和 Matplotlib 负责把评分、票房、类型分布等维度画成直观图表,最后还塞了一个基于协同过滤的电影推荐模块。对正在做毕设、想系统入门数据分析、或者打算拿真实电影数据练手的从业者来说,这套东西的价值在于它把每个环节都走通了一遍——你能看到数据从网站到页面图表之间的完整流向,而不是只拿到一堆零碎的技术点。

2. 数据抓回来只是第一步:爬虫模块与存储设计

2.1 选型思考:为什么用 requests 而不是 Scrapy

这套系统原文里明确写了用 requests 库发 HTTP 请求,这个选择放在毕设场景里其实很合理。requests 是同步请求库,代码直观、调试方便,配合 BeautifulSoup 解析 HTML 就能完成整个采集流程。Scrapy 虽然支持异步并发、自带调度器和中间件,性能上限高,但对单个数据源、数据量在万级左右的毕设项目来说,它的学习成本反而成了包袱——你得理解 Spider、Item Pipeline、Downloader Middleware 一堆概念,才能把最简单的抓取跑起来。

我一般会建议:如果目标是快速验证数据分析和可视化链路,requests + BeautifulSoup 完全够用;如果要长期维护大规模采集任务或者应对反爬升级频繁的场景,再切换 Scrapy 也不迟。这套系统的核心价值不在爬虫本身,而是把数据转化成业务洞察,爬虫只要能稳定拿到数据,工具越简单越好。

2.2 爬虫核心代码:请求头、会话保持与容错

猫眼电影的榜单页面和详情页结构相对稳定,直接用 requests 发送 GET 请求就能拿到服务端渲染的 HTML。下面是爬虫模块的核心逻辑,采集猫眼 Top100 榜单的电影基础信息。

import requests from bs4 import BeautifulSoup import time import random def fetch_maoyan_top100(): """ 抓取猫眼 Top100 榜单电影信息 返回: [{name, score, release_date, actors, ...}, ...] """ base_url = "https://maoyan.com/board/4" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/119.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://maoyan.com/" } movies = [] for page in range(0, 10): # Top100 共 10 页,每页 10 条 params = {"offset": page * 10} try: resp = requests.get( base_url, params=params, headers=headers, timeout=(3, 10) # (连接超时, 读取超时) ) resp.raise_for_status() resp.encoding = resp.apparent_encoding # 自动识别页面编码 except requests.RequestException as e: print(f"[ERROR] 第 {page+1} 页请求失败: {e}") continue soup = BeautifulSoup(resp.text, "html.parser") items = soup.select(".board-wrapper .movie-item") for item in items: name_tag = item.select_one(".name a") score_tag = item.select_one(".score") star_tag = item.select_one(".star") release_tag = item.select_one(".releasetime") if not name_tag: continue movie = { "name": name_tag.get_text(strip=True), "score": score_tag.get_text(strip=True) if score_tag else "", "actors": star_tag.get_text(strip=True).replace("主演:", "") if star_tag else "", "release_time": release_tag.get_text(strip=True).replace("上映时间:", "") if release_tag else "", "page": page + 1 } movies.append(movie) # 控制请求频率:随机睡眠 1~3 秒,避免触发反爬 time.sleep(random.uniform(1, 3)) return movies

这段代码有几个参数值得展开说。timeout=(3, 10)表示连接超时 3 秒、读取超时 10 秒,如果网络抖动或者目标服务器响应慢,请求不会无限挂起。resp.encoding = resp.apparent_encoding这行很关键——猫眼页面虽然标记了 charset,但实际内容可能混有特殊字符,用apparent_encoding让 requests 自动从内容里检测编码,能避免中文乱码。random.uniform(1, 3)是对反爬的基础尊重,固定频率的请求反而更容易被识别为机器行为。

另外,resp.raise_for_status()会把 4xx、5xx 响应直接抛成异常,配合 try/except 可以保证某页失败时不会终止整个采集流程。这套容错设计是爬虫落地的基本功:单页失败要跳过、请求要限速、编码要兜底。

2.3 数据落地:MySQL 表结构与导入

数据抓回来后不能一直躺在内存里,需要设计合理的存储结构。原文技术选型部分明确提到了 MySQL,这里给出电影信息表的核心设计。

字段名类型约束说明
idINTPRIMARY KEY AUTO_INCREMENT自增主键
movie_nameVARCHAR(255)NOT NULL电影名称
ratingDECIMAL(3,1)NULL猫眼评分(如 9.5)
actorsVARCHAR(1000)NULL主演列表
release_dateDATENULL上映日期
genreVARCHAR(255)NULL电影类型(逗号分隔)
box_officeDECIMAL(12,2)NULL累计票房(元)
created_atDATETIMEDEFAULT CURRENT_TIMESTAMP抓取时间

把 DataFrame 直接写入 MySQL 用to_sql()配合 SQLAlchemy 就能实现,但要注意if_exists参数设为"append"而不是"replace",否则每次跑爬虫都会把全表清空重写。数据量小的时候这种方案没有问题,如果后续要支持增量更新,建议在movie_name上加唯一索引,用INSERT ... ON DUPLICATE KEY UPDATE做幂等写入。

from sqlalchemy import create_engine engine = create_engine("mysql+pymysql://root:password@localhost:3306/movie_db?charset=utf8mb4") df = pd.DataFrame(movies) df.to_sql("movie_info", con=engine, if_exists="append", index=False)

这里charset=utf8mb4必须加上,否则遇到 emoji 或者生僻字会报 Incorrect string value 错误。这个坑非常常见,后面避坑章节会详细展开。

3. 脏数据不过夜:Pandas 清洗与预处理实战

3.1 清洗流程设计的核心逻辑

爬虫拿到的原始数据一定带脏:字段为空、格式不统一、存在重复抓取、评分字段带着意外字符。原文描述的清洗思路是「去除重复值、处理缺失值、异常值」,实际操作时要按固定顺序处理,顺序错了会导致清洗结果不可控。我的固定流程是:先读数据并预览 → 去重 → 处理缺失值 → 处理异常值 → 统一数据类型 → 派生新字段。

这个顺序是有讲究的。去重要在处理缺失值之前做,因为重复记录里可能一条有值一条为空;异常值处理要在类型转换之前做,否则字符串和数值混在一起,pd.to_numeric()会直接报错或者把整列变成 object 类型。

3.2 清洗代码:去重、缺失值、异常值的具体处理

import pandas as pd import numpy as np # 1. 读取原始采集数据 df = pd.read_csv("maoyan_movies.csv", encoding="utf-8-sig") print(f"原始数据量: {df.shape}") # 2. 去重:电影名是天然业务主键 df = df.drop_duplicates(subset=["movie_name"], keep="first") print(f"去重后数据量: {df.shape[0]}") # 3. 缺失值处理:评分是分析核心字段,缺失则删除 df = df.dropna(subset=["rating"]) # 演员、类型字段可能为空,用占位符填充,不删除记录 df["actors"] = df["actors"].fillna("未知") df["genre"] = df["genre"].fillna("未分类") # 4. 异常值处理:评分范围必须在 0~10 df = df[(df["rating"] >= 0) & (df["rating"] <= 10)] # 票房不为负,且去除明显异常的大数(比如合计超过同类均值 10 倍的) df = df[df["box_office"] >= 0] df = df[df["box_office"] < df["box_office"].quantile(0.99) * 10] # 5. 类型转换:release_date 转成 datetime,方便后续按时间维度聚合 df["release_date"] = pd.to_datetime(df["release_date"], errors="coerce") # 清洗后再删一次:to_datetime 转换失败的值会变成 NaT df = df.dropna(subset=["release_date"])

代码里值得展开说明的是drop_duplicates(subset=["movie_name"], keep="first")——这里subset指定判断重复的依据列,keep="first"意味着保留第一条而丢弃后续重复记录。如果原始数据里有同一部电影被爬虫重复抓到但没有更新的需求,keep="first"足够;如果重复记录中后抓的更全,可以用keep="last"。

异常值过滤用的是「分位数倍数法」而不是固定阈值。固定阈值在数据分布变化时容易误杀或者漏杀,分位数倍数对长尾数据更鲁棒。这段是毕业设计级数据清洗里比较实用的处理逻辑,写进论文里也站得住脚。

3.3 字段加工与派生特征

清洗完基础字段后,还需要根据分析需求生成派生字段。评分、票房、类型这几个维度虽然原始数据有,但直接拿来做分析比较粗糙,需要加工出更结构化的信息。

# 把 "9.5" 这种字符串评分转成 float df["rating"] = df["rating"].astype(float) # 票房单位统一为「亿元」,方便可视化展示 df["box_office_yi"] = df["box_office"] / 100000000 # 类型字段是逗号分隔的,拆分成列表以便后续按类型聚合 df["genre_list"] = df["genre"].str.split(",") # 从上映日期提取年份和月份,用于时间趋势分析 df["release_year"] = df["release_date"].dt.year df["release_month"] = df["release_date"].dt.month # 评分分箱:将连续评分映射为 0~6、6~8、8~9、9~10 四个档位 bins = [0, 6, 8, 9, 10] labels = ["低分", "中低分", "高分", "神作"] df["rating_level"] = pd.cut(df["rating"], bins=bins, labels=labels, right=False)

这里pd.cut()的right=False参数表示区间是左闭右开,即[6, 8)落入「中低分」而不是(6, 8]。这个细节很容易被忽略,但它直接影响分箱结果的边界归属,在论文里写上「左闭右开区间」会让评审觉得你对边界有清晰认知。

派生字段做完后,数据分析模块就可以直接基于这套加工过的 DataFrame 做聚合计算了,不需要每次分析都重新清洗一遍。

4. 把数据变成会说话的图表:Flask + ECharts 可视化系统搭建

4.1 Flask 应用骨架与路由设计

整个系统用 Flask 搭建 Web 层,把清洗分析好的数据通过接口吐给前端 ECharts 渲染。原文给出的功能模块包括首页可视化、时间数据分析、评分数据分析、票房数据分析、类型数据分析和词云图可视化,对应的路由设计如下。

from flask import Flask, render_template, jsonify import pandas as pd app = Flask(__name__) # 项目启动时加载一次清洗后的数据,避免每次请求都读 CSV df = pd.read_csv("cleaned_movies.csv", encoding="utf-8-sig") df["release_date"] = pd.to_datetime(df["release_date"]) @app.route("/") def index(): """首页:展示全量数据的概要指标卡片""" total_movies = len(df) avg_rating = round(df["rating"].mean(), 2) total_box_office = round(df["box_office_yi"].sum(), 2) return render_template( "index.html", total_movies=total_movies, avg_rating=avg_rating, total_box_office=total_box_office, ) @app.route("/api/rating_distribution") def rating_distribution(): """评分分布接口:按评分档位聚合统计""" dist = df["rating_level"].value_counts().reindex(["低分", "中低分", "高分", "神作"]) return jsonify({"levels": dist.index.tolist(), "counts": dist.values.tolist()}) @app.route("/api/box_office_trend") def box_office_trend(): """票房趋势接口:按年份聚合累计票房""" trend = df.groupby("release_year")["box_office_yi"].sum().reset_index() return jsonify({ "years": trend["release_year"].astype(str).tolist(), "amounts": trend["box_office_yi"].round(2).tolist() }) @app.route("/api/genre_distribution") def genre_distribution(): """类型分布接口:拆分类型字段后统计占比""" genre_series = df["genre_list"].explode().value_counts() return jsonify({"genres": genre_series.index.tolist(), "counts": genre_series.values.tolist()})

这段代码里值得注意的设计是「启动时加载一次数据」。如果每次接口请求都pd.read_csv()一次,接口响应时间会被 IO 拖慢,在毕业论文答辩演示时尤其尴尬。数据量在万级以内时,内存常驻完全可行;如果以后数据量涨到百万级,再考虑换 SQL 查询或者加 Redis 缓存。

df["genre_list"].explode()是一个省事的操作——它能把列表类型的单元格拆成多行。比如一部电影类型是「剧情,爱情」,用 explode 后变成两行参与聚合,value_counts()就能直接算出各类型出现的频次,不用手动循环拼接。这个函数在可视化场景里使用频率非常高。

4.2 ECharts 前端对接:接口数据与图表配置

前端页面用 ECharts 接收接口数据并渲染图表。以评分分布柱状图为例,前端 JavaScript 的核心逻辑如下。

// 从后端拉取评分分布数据 fetch('/api/rating_distribution') .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById('ratingChart')); chart.setOption({ title: { text: '电影评分分布(按档位)' }, tooltip: {}, xAxis: { type: 'category', data: data.levels // 四个评分档位 }, yAxis: { type: 'value', name: '电影数量' }, series: [{ type: 'bar', data: data.counts, // 各档位电影数量 itemStyle: { color: '#ff4d4f' // 用红色系贴合猫眼品牌色调 } }] }); });

接口返回的 JSON 结构是{"levels": [...], "counts": [...]},前端直接消费这两个数组。这里刻意把数据聚合放在后端做,前端只负责渲染——这个取舍对毕设很重要:如果让前端拿全量数据自己聚合,页面会卡顿,而且 JavaScript 里的聚合逻辑不如 Pandas 好写、好解释。答辩时你可以明确说「聚合计算统一在后端 Pandas 完成,前端 ECharts 只做呈现,职责分离」,这是一个很好的评述点。

ECharts 的setOption配置项里,xAxis.data和series.data必须一一对应,否则图表会出现错位。如果后端返回的数据顺序不稳定,建议在前端做一次排序再传入。

4.3 多维度分析页面:票房趋势与类型占比

除了评分分布,票房趋势和类型分布是另外两个核心分析页面。票房趋势用折线图呈现逐年变化,类型分布用饼图体现结构占比。

@app.route("/api/yearly_box_office") def yearly_box_office(): """年度票房趋势:需要合并评分和票房数据""" yearly = df.groupby("release_year").agg( total_box=("box_office_yi", "sum"), avg_rating=("rating", "mean"), movie_count=("movie_name", "count") ).reset_index() return jsonify({ "years": yearly["release_year"].tolist(), "total_box": yearly["total_box"].round(2).tolist(), "avg_rating": yearly["avg_rating"].round(2).tolist(), "movie_count": yearly["movie_count"].tolist() })

groupby之后的agg允许你对不同列指定不同聚合方式——对票房求和、对评分求均值、对电影名计数,一次分组完成多个指标的计算。这种写法在论文里解释起来简单,而且实际分析场景里用的频率极高。前端拿到这个接口的返回值后,可以做一个双 Y 轴折线图:左轴票房、右轴平均评分,一眼看出票房和口碑的相关走势。

词云图模块的实现在这套系统里也不算复杂,用wordcloud库对电影类型和主演名字生成词云,渲染到页面上作为辅助信息展示。词云图对中文显示有特殊要求,必须指定中文字体路径,否则会出现满屏乱码方块,这个细节放到避坑章节细说。

5. 部署路上的血泪经验:四个高频坑与排查建议

5.1 中文乱码:爬虫和可视化两层都中招

现象:爬虫抓回来的电影名和演员名显示乱码,或者 ECharts 图表的标题、坐标轴中文显示成方块。原因:两层原因叠加导致。第一层是 requests 没有正确识别页面编码,resp.text默认使用响应头里声明的编码解码,猫眼页面实际编码和声明不一致时中文就炸了。第二层是前端渲染时 ECharts 本身的容器或页面没有声明 UTF-8,浏览器用了错误的编码解析 JavaScript 文件。解决:爬虫侧统一在请求后执行resp.encoding = resp.apparent_encoding;前端 HTML 的<head>里必须写上<meta charset="UTF-8">。如果 wordcloud 生成词云图乱码,那是字体问题——WordCloud(font_path="/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc")指定一个系统自带的中文字体路径即可。每次抓数据前先打印一条样本记录确认编码,这个习惯能省掉大量返工。

5.2 请求被反爬拦截,返回错误页面

现象:爬虫稳定跑一段时间后,突然拿不到数据,解析结果为空,或者响应内容变成验证码弹窗页。原因:请求频率超过猫眼反爬策略的阈值,IP 被临时封锁;也可能是缺少Referer或Accept-Language头,导致请求特征和正常浏览器差异太大,服务器判定为爬虫。解决:第一个办法是控制频率,每页之间time.sleep(random.uniform(1, 3)),遇到失败时指数退避重试。第二是把请求头补全,参考上面代码里的 headers 配置。如果数据量要求不高,可以只在凌晨低峰时段跑采集任务,这个实战技巧能明显降低被封概率。

5.3 MySQL 写入报错 Incorrect string value

现象:df.to_sql()执行时报错Incorrect string value: '\xF0\x9F...' for column,写入直接失败。原因:MySQL 连接字符串里没有指定charset=utf8mb4。MySQL 的 utf8 字符集实际只支持部分 UTF-8 字符,遇到 emoji 或生僻字(比如某些带特殊符号的片名)就会报错。解决:建库和连接都强制使用 utf8mb4——建库语句CREATE DATABASE movie_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,连接串里加?charset=utf8mb4。这个坑不踩一次很难注意到,踩过之后所有 MySQL 连接我默认都带 utf8mb4。

5.4 前端图表数据错位或比例失衡

现象:柱状图的柱子顺序和预期不符,或者饼图占比显示异常。原因:value_counts()返回的默认顺序是按计数降序排列,不是按业务逻辑排序。比如评分档位的顺序变成了「高分、低分、神作、中低分」,在前端直接渲染就错位;饼图占比异常则可能是数据里有未清洗掉的重复项或者 NaN 值参与计算。解决:后端聚合后强制重排索引,比如前面代码里的reindex(["低分", "中低分", "高分", "神作"])。饼图问题回到清洗环节排查,用df[df["genre"].notna()]先过滤空值再统计。

6. 从会看到会用:协同过滤推荐模块的轻量落地与上线技巧

6.1 物品协同过滤实现

推荐模块是这套系统里最贴近「面向用户」的一层。原文技术选型部分提到了协同过滤算法,落地时我用的是基于物品的协同过滤(Item-Based CF),核心思路是:如果电影 A 和电影 B 被同一批用户看过且评分相近,那么看过 A 的用户大概率也喜欢 B。实现时数据来源不能再依赖票房榜单,需要额外采集用户的评分行为数据,构建「用户-电影评分矩阵」。

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 构建用户-电影评分矩阵 # 行是用户ID,列是电影ID,值是评分;未评分的位填 0(稀疏场景下常见处理) rating_matrix = df.pivot_table( index="user_id", columns="movie_id", values="rating", fill_value=0 # 未评分填充 0 便于余弦相似度计算 ) # 计算电影之间的相似度矩阵(基于所有用户的评分向量) movie_sim = cosine_similarity(rating_matrix.T) # 转置后每一行是一部电影的评分向量 np.fill_diagonal(movie_sim, 0) # 自己与自己的相似度置 0,推荐时排除自身 def recommend_movies(movie_id, top_n=10): """给定电影 ID,返回相似度最高的 top_n 部电影""" sim_scores = list(enumerate(movie_sim[movie_id])) sim_scores.sort(key=lambda x: x[1], reverse=True) top_movies = sim_scores[:top_n] # 把相似度换算成百分比,方便前端展示「相似度 87%」之类的信息 result = [] for idx, score in top_movies: result.append({ "movie_id": int(idx), "score": round(float(score) * 100, 1) }) return result

这段代码里fill_value=0是实际使用里比较常见的简化策略。原始协方差矩阵里缺失的评分用 0 填充会让相似度计算偏保守,但好处是代码简单、不需要处理稀疏矩阵的数据结构。如果数据量不大(千级电影、万级评分),这个方案性能完全扛得住。真正的冷启动问题——新用户没有评分历史、新电影没有用户行为——需要补充基于内容的推荐策略,比如按类型匹配推荐同类型高分电影,在毕设场景里作为兜底方案展示很加分。

6.2 上线技巧:定时刷新与容错降级

推荐模块上线后最大的问题是数据是静态的——猫眼榜单每天在变,用户评分行为也在积累,如果数据不更新,推荐结果会越来越偏离实际情况。常见做法是写一个定时任务,以 Cron 表达式或 Python 的schedule库在每天凌晨 2 点触发全量采集和清洗流程。采集后数据先落临时表,校验通过后再替换线上正式表——这个「先写临时再原子切换」的思路能保证任何时刻页面访问到的都是完整数据,不会出现读到一半的脏数据。

另外要提一个很多人忽略的降级策略:如果推荐模块因为数据量不足或者算法异常导致结果为空,前端应该优雅降级为「同类类型高分推荐」而不是报错白屏。在 Flask 后端加一个try/except包住推荐函数,失败时调genre_based_fallback()返回同类型电影列表,这样系统在演示时永远不会出现难看的技术故障。

从那以后我每次做类似的数据分析项目,都会强制把「数据采集 → 清洗 → 存储 → 分析 → 可视化 → 推荐」这条链路完整走一遍再开始写论文。很多时候不是技术多玄乎,而是链条上某一环没打通就卡住了整个流程——比如编码没兜底、清洗顺序反了、MySQL 字符集不对,这些都是看似小却足以影响全局的细节。这套系统把每一个环节都给了可运行、可解释的参考实现,按着它跑一遍,比自己闭门造车省下至少两周时间。希望帮到你。

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

返回列表