ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

猫眼Top100电影爬虫、数据清洗、分析与可视化实战

猫眼Top100电影爬虫、数据清洗、分析与可视化实战

简介:基于Python构建的猫眼电影数据爬取、分析与可视化综合项目,主要面向Python学习者、数据分析入门者及需要完成期末大作业的高校学生。项目以猫眼电影为素材,完整覆盖数据爬取、清洗存储、统计分析及可视化展示全流程,能够直接运行并产出分析结论。压缩包共42个文件,以Python源码为主,包含py/pyc脚本文件、前端页面html/js、SQL数据脚本、配置文档及readme说明,整体大小仅428KB,结构紧凑且目录层级清晰。截至目前已有1984人学习下载,适合作为课程设计或期末项目的参考模板。项目带有详细的代码注释与开发文档,基础薄弱者也能轻松跟上;同时模块化设计便于二次开发,可替换数据源或调整展示维度。从接口、模型到视图的完整代码组织,也能帮助读者快速理解Web数据项目的基本架构,是一份源自期末大作业且获得97分高分评价的优质参考。

1. 期末大作业选题猫眼电影:爬虫、分析和可视化一次全打通

期末大作业选Python方向,最怕的是技术点很多但凑不成一个完整项目。猫眼电影Top100榜单是一个很适合收口的练手数据源:网页版是静态HTML,不用抓包模拟App接口;字段包含片名、主演、上映时间、评分,信息密度刚好够做分析和可视化;反爬强度对高频请求有反馈,又能用UA和延时绕开,足够向老师展示爬虫基本功。最难的不是把requests请求发出去,而是数据到手后才发现需要清洗、类型转换、处理乱码,这些步骤加起来才算完成度高的作业。

下面按我实际做这类作业的顺序展开:先把猫眼Top100榜单爬下来,再清洗成干净的DataFrame,做几个能讲出故事的分析维度,最后给出一套避坑清单和答辩加分技巧。适合已经学完Python基础、想用requests、pandas和matplotlib交一份能现场演示大作业的同学。不需要IP池,不需要分布式方案,一台电脑、一个IDE、几次sleep就能跑通。

2. 抓取猫眼榜单数据:确定页面结构、写正则、处理反爬

2.1 打开页面先看HTML:爬虫里最不该跳过的步骤

写爬虫最容易翻车的点,是拿到网上代码直接跑,跑不通就说“被反爬了”。实际上很多爬虫跑不通是因为页面结构和你用的选择器不匹配。先说猫眼榜单页的特征:比较好用的Top100榜单页是服务端渲染的页面,打开后在浏览器里右键“查看网页源代码”,能在HTML里直接看到电影条目,不需要去XHR接口里翻JSON。每个电影条目在HTML里是一个

节点,里面包含排名、片名、主演、上映时间、评分。

为什么强调先看源码?因为猫眼页面改过版,不同时间段打开,标签class可能不一样。正确做法是先把榜单页保存成本地HTML,然后用编辑器检查一个电影条目的结构,再决定正则怎么写。常见页面版本里,字段和页面标注特征对应关系如下:

字段页面特征说明
排名board-index榜单序号,从1到100
片名a标签的title属性同时可以取到电影详情页链接
主演class为star的段落多个人名用顿号分隔
上映时间class为releasetime的段落文本以“上映时间:”开头
评分integer与fraction两个标签整数部分和小数部分被拆开

我一般会写一个临时小脚本,把第一页响应存下来,避免在终端里只看到半屏HTML:

import requests headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Safari/537.36' } url = 'https://maoyan.com/board/4?offset=0' resp = requests.get(url, headers=headers, timeout=5) with open('maoyan_page.html', 'w', encoding='utf-8') as f: f.write(resp.text) print(resp.status_code, len(resp.text))

这段代码的逻辑很简单:先请求第一页,把响应保存为HTML。resp.text是requests解码后的文本,写文件时再指定utf-8,后面用编辑器打开中文不会乱。很多同学在这里直接打印HTML,结果终端缓冲区太小,只能看到半屏内容,无法判断结构。把响应存下来再打开,是最稳妥的做法。

参数说明:timeout=5表示超过5秒没有响应就抛异常,避免程序一直挂起;UA是模拟Chrome浏览器的标识,不带这个字段请求部分站点会直接返回403。第一次请求成功后,再打开maoyan_page.html看

结构,确认每个字段在什么标签里。

2.2 用正则抓取Top100榜单:一条正则吃下六个字段

确认页面结构之后,就可以写解析函数。这里用正则而不是BeautifulSoup,不是说BeautifulSoup不好,而是很多课程在爬虫章节刚好教到正则,而且榜单页HTML结构比较规整,一条正则匹配多个字段是可行的。下面是一种经过验证的写法,注意正则里每个括号都要和字段一一对应:

import re import time import requests HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Safari/537.36' } movie_pattern = re.compile( r'<dd>.*?board-index-\d+">(\d+)</i>.*?' r'<a href="/films/\d+" title="(.*?)".*?' r'<p class="star">(?:主演:)?(.*?)</p>.*?' r'<p class="releasetime">(?:上映时间:)?(.*?)</p>.*?' r'<i class="integer">(.*?)</i><i class="fraction">(.*?)</i>', re.S ) def fetch_html(offset): url = f'https://maoyan.com/board/4?offset={offset}' resp = requests.get(url, headers=HEADERS, timeout=5) resp.raise_for_status() return resp.text def parse_movies(html): movies = [] for item in movie_pattern.findall(html): movies.append({ 'rank': int(item[0]), 'title': item[1].strip(), 'actors': item[2].strip(), 'release_date': item[3].strip(), 'score_int': item[4], 'score_frac': item[5] }) return movies

这段代码里有几个关键点。第一,正则里的

和.?之间没有class属性,只要能定位到条目块即可,不关心
有没有额外属性。第二,<a href="/films/\d+"用\d+匹配电影ID,如果页面改版后a标签的属性顺序变了,这段正则就会失效,这也是2.1里强调先存HTML的原因。第三,re.S让.能匹配换行符,因为
内部跨多行,不加上这个标志,.
?到不了下一行。

参数说明:findall返回的是元组列表,item[0]到item[5]分别对应正则里的六个括号;rank用int()转成整数,方便后续排序;strip()把字符串前后的空格清掉。fetch_html里的raise_for_status()让请求遇到4xx、5xx时主动抛异常,而不是默默返回一个错误页面,否则后面的正则匹配到空列表,排查起来很费劲。

循环抓取10页的代码放在主函数里:

if __name__ == '__main__': all_movies = [] for page in range(10): offset = page * 10 html = fetch_html(offset) movies = parse_movies(html) all_movies.extend(movies) print(f'第{page + 1}页完成,累计{len(all_movies)}条') time.sleep(1)

页面逻辑:Top100榜单按offset分页,每页10条,第1页offset=0,第2页offset=10,一直到第9页offset=90。time.sleep(1)是每抓完一页休息1秒,既减少对目标站点的压力,也降低被反爬的概率。如果只抓100条,这个速度大概10秒左右,对期末作业完全够用。

2.3 反爬处理:先从UA和Referer开始

说到反爬,很多同学第一反应是上代理IP池,其实对猫眼榜单这个场景完全没必要。最常见的反爬反馈是:请求返回200,但响应正文里找不到电影条目,或者直接被重定向到安全验证页面。这个时候先把UA补上,再把Referer带上,往往就能解决。Referer表示请求是从哪个页面跳转过来的,爬虫里加上这个头更像浏览器行为:

HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Safari/537.36', 'Referer': 'https://maoyan.com/board' }

如果加了这两项仍然返回空数据,需要确认响应是不是验证页。快速定位的办法是打印响应前200个字符:

html = fetch_html(0) print(html[:200])

看到里面出现“校验”“验证”之类的字眼,就说明被反爬拦住了。此时不要硬着头皮调正则,先降低请求频率,把time.sleep改到2秒,再等一两分钟重新跑。如果还是被拦,检查是否本机IP被临时限流,换个网络环境重试即可。不需要把问题上升到对抗层面,期末作业的关键是把流程走通,而不是证明能爬多少量。

这里还有一个容易被忽略的点:requests默认不带浏览器的Accept-Language头,某些站点会根据语言头返回不同版本页面。稳妥起见可以把浏览器常用头都复制过来,但别在代码里硬编码一堆静态头,看起来反而像自动化痕迹过重。我一般只带UA和Referer,保持克制,这也是一种低姿态抓取。

3. 数据清洗与存储:把抓下来的字符串变成能分析的DataFrame

3.1 先把列表转成DataFrame并检查数据质量

爬下来的all_movies是一个字典列表,直接交给pandas就能变成DataFrame。但很多同学在这一步直接做表,然后马上画图,结果图很怪。必须先摸清数据质量。首选动作是:

import pandas as pd df = pd.DataFrame(all_movies) print(df.head()) print(df.info()) print(df.describe())

df.info()能看到每一列的非空数量和数据类型;df.describe()能看到rank字段的分布。此时还要确认是不是真的有100条记录、有没有重复电影。如果条数不够,先回到爬虫部分检查是否每页都抓到了10条。数据质量检查不是一个额外的好习惯,而是画图前必要的体检。

一个常见的意外是刚抓下来的df里,score_int和score_frac都是字符串,画图时pandas直接把它们当分类变量处理。类型转换放3.2,这里先处理重复值:

df.drop_duplicates(subset='title', inplace=True) print(df.shape)

以片名作为去重键。如果去重后数量少于100,说明爬虫阶段有重复请求或者页面本身重复。此时需要回看2.2的输出日志,确认是解析导致重复还是页面数据本身重复。

3.2 字段清洗:合并评分、转换日期、切分演员

# 合并且转换评分,解析失败的变成NaN score_str = df['score_int'].fillna('') + '.' + df['score_frac'].fillna('') df['score'] = pd.to_numeric(score_str, errors='coerce') # 上映时间转成datetime,非法的变成NaT df['release_date'] = pd.to_datetime(df['release_date'], errors='coerce') df = df.dropna(subset=['score', 'release_date']) # 从日期里提取年份,后面年份分析要用 df['year'] = df['release_date'].dt.year # 演员字段去掉首尾空格,并转成列表 df['actors'] = df['actors'].str.strip() df['actor_list'] = df['actors'].str.split('、')

这段代码有几个需要注意的参数。第一个是pd.to_numeric和errors='coerce',它把“9.”+“5”这种字符串转成9.5,遇到无法解析的值变成NaN,后续再dropna。这样做比直接astype(float)安全,因为astype遇到空字符串会直接报错,而to_numeric不会。第二个是to_datetime里的errors='coerce',即使页面里某个上映时间是空文本,程序也不会中断。

关于演员字段的拆分,我用中文顿号作为分隔符,这是榜单主演字段的默认格式。def split 如果某个条目只有一个演员,split后也能生成含一个元素的列表。这里不要用英文逗号分割,因为逗号分隔后会有多余空格,还得再strip一次。

3.3 存储到CSV和SQLite:让作业在答辩时随时可复现

数据清洗完就要落盘。存储目标有两个:一是生成一份随时可查看的CSV,方便在pandas之外用Excel人工核对;二是存SQLite,答辩时不管爬虫还能不能跑通,数据都还在。代码:

df.to_csv('maoyan_top100.csv', index=False, encoding='utf-8-sig') import sqlite3 conn = sqlite3.connect('maoyan.db') df.to_sql('movie', conn, if_exists='replace', index=False) conn.close()

重点说encode参数。很多同学第一次用encoding='utf-8',然后用Excel打开CSV发现中文乱码,开始怀疑爬虫编码写错了。实际上CSV是UTF-8编码,Excel默认按GBK去解码,所以要用utf-8-sig。utf-8-sig会在文件开头加入BOM标记,Excel看到BOM就知道这是UTF-8编码。这个参数对期末作业很重要,因为老师大概率会直接用Excel打开你的CSV检查。

SQLite存储说明:to_sql要求sqlite3连接,表名为movie;if_exists='replace'表示表已存在就覆盖,适合重复运行。需要把数据读回来时,用pd.read_csv('maoyan_top100.csv')或pd.read_sql('select * from movie', sqlite3.connect('maoyan.db'))。到这里,一个干净的数据集就齐了。

4. 数据分析:从三个维度把Top100榜单讲成一个故事

4.1 评分分布:高分是常态还是少数?

猫眼Top100榜单本身是一个高分榜单,所以评分分布和全量电影不一样。分析时不能只给一个平均值,还要把分布形状讲清楚。用直方图最直观:

import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei'] plt.rcParams['axes.unicode_minus'] = False df['score'].plot(kind='hist', bins=20, edgecolor='white') plt.title('猫眼Top100电影评分分布') plt.xlabel('评分') plt.ylabel('电影数量') plt.show()

bins=20表示评分区间分成20段。如果分数集中在9分上下,直方图会出现尖峰;如果分散,分布就平缓。关键是要结合describe()补充文字说明,比如均值和中位数、最低和最高分。不要只贴图,答辩时要能说出“榜单里的电影大部分落在哪个区间”这句话。

为了让结论更清晰,可以再做一个分档统计:

df['score_bin'] = pd.cut(df['score'], bins=[0, 7, 8, 9, 10], labels=['<7分', '7-8分', '8-9分', '9分以上']) print(df['score_bin'].value_counts())

pd.cut把连续分数切成几档,value_counts()统计每档数量。这个表格放在PPT里比直方图更好解释。参数说明:bins边界左开右闭,0到7分一档,7到8分一档,9分以上一档。如果榜单里大量电影在9分以上,那说明Top100的筛选标准确实高。

4.2 年份维度:榜单里老片和新片各占多少?

分析时用year字段做统计。代码:

year_count = df['year'].value_counts().sort_index() year_count.plot(kind='bar', figsize=(12, 4)) plt.title('榜单电影上映年份分布') plt.xlabel('年份') plt.ylabel('数量') plt.show()

value_counts()统计每个年份出现的次数,sort_index()按年份排序,这样横轴才是连续的时间轴。如果榜单跨度特别大,比如从1990到2024,大多数年份只有一两部,柱状图比较碎。这种情况可以按五年区间聚合:

df['year_bin'] = (df['year'] // 5) * 5 year_bin_count = df['year_bin'].value_counts().sort_index() year_bin_count.plot(kind='bar') print(year_bin_count)

(year // 5) * 5先把年份除以5取整再乘回5,得到所在五年段的起始年份。比如1993年落在1990-1994这一段,结果就是1990。这种分箱方法比手动写if判断简洁,调粒度也方便:想每10年一段,把5改成10即可。

分析时要写清边界:榜单是当前热度排行的经典片单,不代表某一年电影质量突然变高。在报告和PPT里要写清楚,这是榜单样本的分布,不是整体市场趋势。

4.3 演员维度:谁的名字在Top100里出现最多?

主演字段是“主演:A、B、C”格式,清洗时已经拆成了actor_list,现在需要展开成一行一个演员:

actor_series = df['actor_list'].explode() actor_count = actor_series.str.strip().value_counts().head(15) print(actor_count)

explode()是pandas把列表元素拆到多行的函数,拆完后一个包含三个演员的电影会变成三行。value_counts()统计每位演员出现次数,head(15)取前15名。由于不同演员可能同名,这个统计只能做粗粒度展示,不能作为严谨的学术结论,但作为期末作业的可视化维度足够。

画图:

actor_count.sort_values().plot(kind='barh', figsize=(8, 8)) plt.title('演员上榜次数Top15') plt.xlabel('上榜次数') plt.ylabel('演员') plt.show()

用barh画横向条形图,演员名字在纵轴,不会因为名字太长导致横轴标签重叠。sort_values()先把数据升序,最长的条形在下方,视觉上更像一个排行榜。

再补一个评分与年份的关系,作为分析部分的收尾:

print(df[['year', 'score']].corr()) df.plot.scatter(x='year', y='score') plt.title('年份与评分散点图') plt.show()

corr()输出相关系数矩阵,如果相关系数接近0,说明年份和评分没有明显线性关系。这种情况下结论就是“榜单中高分电影不受年份局限,老片和新片都有高分”,这也是一个可以立得住的结论,评分分布和年份分布可以共同支撑它。

5. 避坑指南:爬虫、清洗、可视化里最容易翻车的5个问题

5.1 正则匹配不到数据,页面结构被改版

现象:运行脚本后all_movies一直是空列表,或者抓到的条数远少于100。

原因:榜单页改版后,

内部的class名、标签层级发生变化;另一个常见原因是反爬返回了验证页,解析函数拿到的html里根本没有电影条目。

解决:把响应存成本地文件看实际结构。如果class名变了,修改正则中的关键词;如果html里只剩几行验证码,调整请求头并降低频率。注意正则匹配不到时不要反复调正则里的量词,先打印len(html)看长度,长度只有几千字节多半是验证页。定位代码:

if len(movies) == 0: with open('error.html', 'w', encoding='utf-8') as f: f.write(html) print('匹配为空,请检查error.html')

5.2 评分拆成两段后转float报错

现象:清洗时执行astype(float)直接报错,ValueError提示字符串无法转换。

原因:score_int或score_frac里有空值。正则匹配不到小数部分时,字符串就变成“9.”这种残缺格式。如果某一行两个都为空,拼接出来就是“.”,转float必然失败。

解决:优先用pd.to_numeric(x, errors='coerce')而不是astype(float)。转完后再查看NaN数量:

score_str = df['score_int'].fillna('') + '.' + df['score_frac'].fillna('') df['score'] = pd.to_numeric(score_str, errors='coerce') print(df['score'].isna().sum())

看到NaN之后决定是删行还是填默认值。对Top100榜单评分,我建议直接删除缺失评分的行,因为评分是核心字段,硬填一个值会干扰后续所有图表。

5.3 CSV打开后中文乱码

现象:pandas里数据正常,Excel打开CSV全是乱码。

原因:写入用了utf-8,Excel按GBK解码。这是编码不一致问题,不是数据坏了。

解决:to_csv时用encoding='utf-8-sig'。已经生成的乱码文件不能靠Excel手动切换编码完整挽救,只能重新导出。这条属于血泪经验,建议第一次就写成utf-8-sig。验证读取也没问题:

df_check = pd.read_csv('maoyan_top100.csv', encoding='utf-8-sig') print(df_check.head())

5.4 matplotlib中文显示成方块

现象:图标题、坐标轴标签都是方块,英文正常。

原因:matplotlib默认字体里没有中文字符,需要配置中文字体。Windows上SimHei通常可用,macOS上可以用PingFang SC。

解决:在绘图脚本顶部统一设置:

plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'PingFang SC'] plt.rcParams['axes.unicode_minus'] = False

如果列表里的字体都没装,先用print(matplotlib.font_manager.fontManager.ttflist)查看系统可用字体,挑一个支持中文的填进去。axes.unicode_minus必须设为False,否则负号显示成方块。配置要放在import matplotlib之后,所有绘图之前,否则有的图已经生成了才设置,无效。

5.5 只有100条数据,分析结论被质疑

现象:答辩时老师问“你统计的演员上榜次数能代表猫眼所有电影吗”。

原因:Top100榜单是有偏样本,它是按热度或评分选出的百强,不代表全量。

解决:在报告标题、PPT、结论里都写明“研究对象为猫眼Top100榜单”,而不是“猫眼电影整体”。想扩大样本,可以爬取多个榜单合并,比如“正在热映榜”“人气榜”,字段结构类似,合并后做对比分析。这个动作不复杂,但能让爬虫和分析部分更有说服力。

6. 三个加分技巧:让期末大作业从“能跑”变成“值得看”

6.1 加日志和重试,爬虫中断不再“从头再来”

抓100条数据只要10秒,但如果以后抓多个榜单,中途断掉会很痛苦。给请求函数加一个重试包装:

import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s') def safe_fetch(offset, retry=3): for i in range(retry): try: html = fetch_html(offset) logging.info(f'offset {offset} 请求成功') return html except Exception as e: logging.warning(f'offset {offset} 第{i+1}次失败: {e}') time.sleep(2) return None

每次请求成功都留一条日志,失败时知道是从哪个offset断了。这个习惯比代码本身更重要。

6.2 用pyecharts产出交互式HTML图表

答辩现场用网页图表比静态图片更吸引人。pyecharts基于ECharts,几行就能生成一个带数据提示的条形图:

from pyecharts.charts import Bar from pyecharts import options as opts top10 = df.nlargest(10, 'score') bar = (Bar() .add_xaxis(top10['title'].tolist()) .add_yaxis('评分', top10['score'].tolist()) .set_global_opts(title_opts=opts.TitleOpts(title='Top10高分电影')) .set_series_opts(label_opts=opts.LabelOpts(is_show=True))) bar.render('top10.html')

浏览器打开top10.html,鼠标悬停能看到每部电影评分,这种交互感在答辩演示时很加分。前提是环境里已经pip install pyecharts。

6.3 报告里放一张可读表格

数据分析报告里不建议贴print()输出。可以生成一个排序后的表格,放进PPT或者附录:

summary = df[['rank', 'title', 'score', 'year']].sort_values('score', ascending=False) print(summary.head(20).to_string(index=False))

我的习惯是最后几天不再动爬虫代码,把所有数据和分析结果固定住,只调整结论的表达。做这类项目时吃过亏:数据抓下来没检查质量就去画图,出来的图自己都解释不了。现在无论多小的大作业,我都会先跑df.info()和df.describe(),相当于给数据做个体检。希望帮到你。

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

RELATED READING

延伸阅读

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