简介:本资源是一份面向Python初学者与数据分析爱好者的实战项目包,聚焦生活娱乐领域中的电影数据采集与可视化分析。通过requests发起网络请求,结合xpath与正则表达式解析猫眼电影榜单,将年份、月份、国家、主演等多维度数据清洗后存入CSV,并利用matplotlib与pyecharts完成交互式与静态图表双呈现,涵盖分布图、环形图、统计图等多种可视化形式。资源共12个文件,含2个核心爬虫与分析脚本(maoyanpaqu.py、fenxi.py)、5个HTML可视化报告页、4张JPG图表结果图及1个原始数据CSV文件,压缩包仅296KB,轻量易上手。已有1506人学习下载,提供从数据获取、清洗、存储到多角度可视化的完整闭环方案,附带可直接运行的代码与即开即用的图表输出,适合练手爬虫工程化流程与数据可视化综合能力提升。
1. 为什么猫眼电影数据值得爬?不是为了刷票房,而是练透「静态HTML解析 + 反爬识别 + 多维度可视化」这一套闭环能力
很多人一看到“爬猫眼”就默认是抓实时票房或抢票,其实完全跑偏了。猫眼网页端的电影列表页(如maoyan.com/films?showType=1)本质是服务 SEO 的静态 HTML 页面,不依赖 JavaScript 渲染核心数据——这恰恰是初学者最该练的「干净入口」:没有登录态、无复杂加密、DOM 结构稳定、字段语义清晰(片名、评分、主演、类型、上映日期、想看人数)。它不像豆瓣或淘票票那样频繁插入动态水印或混淆字段,也不像招聘网站那样强校验 User-Agent 和 Referer。用 Python 爬猫眼,核心价值不在数据本身,而在于把 requests + BeautifulSoup + pandas + matplotlib/seaborn 这条链路跑通:从发请求时怎么设 headers 避开 403,到解析<dd>里嵌套的<div class="channel-lst">怎么精准定位,再到清洗“12.3万想看”这种带单位文本,最后用柱状图对比各类型电影平均分、用词云展示高频主演名——这才是企业面试和实际项目中真正考察的「数据采集-清洗-分析-呈现」四层能力。适合刚学完 requests 基础、正卡在「能发请求但总拿不到数据」阶段的开发者,也适合需要快速交付一个可演示的可视化小项目的工程师。
2. 用 requests + BeautifulSoup 在本地跑通猫眼电影列表页的最小命令
2.1 为什么选 requests 而不是 Selenium?静态页面不需要浏览器驱动
猫眼电影列表页(以“正在热映”为例)的 HTML 源码中,所有电影信息都直接存在于初始 HTML 中,无需执行 JavaScript 加载。打开浏览器开发者工具 → Network → 刷新页面 → 查看films?showType=1对应的响应,确认 Content-Type 是text/html; charset=utf-8,且 Response Body 里已包含<div class="movie-item">等完整结构。这意味着用 requests 发起一次 GET 请求即可获取全部数据,无需启动 Chrome 或 Firefox 进程。Selenium 在此场景下纯属杀鸡用牛刀:启动慢、内存占用高、调试复杂,且容易因 ChromeDriver 版本不匹配导致WebDriverException。requests 的优势在于轻量、可控、易调试——失败时直接打印response.status_code和response.text[:500]就能定位问题。
提示:不要用
requests.get("https://maoyan.com/films?showType=1")直接请求。猫眼服务器会检查User-Agent和Referer,缺一即返回 403。必须构造合法请求头。
2.2 构造合法请求头的三个必填字段及实测参数
以下是最小可行请求头配置,经实测(2024年7月)仍有效:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Referer": "https://maoyan.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", }User-Agent:必须匹配主流桌面浏览器最新版本。这里用 Chrome 126 是因为猫眼近期对过老 UA(如 Chrome/90)会降权返回空数据。实测若改成curl/7.68.0或python-requests/2.28,直接返回 403。Referer:必须为https://maoyan.com/。若设为空或https://www.baidu.com,服务器返回 403。这是猫眼反爬的初级校验,验证请求是否来自其官网跳转。Accept:非必需但强烈建议带上。缺失时部分 CDN 节点可能返回压缩格式(gzip),而 requests 默认不自动解压,导致response.text乱码。带上后确保返回明文 HTML。
2.2.1 完整请求与状态校验代码
import requests from bs4 import BeautifulSoup url = "https://maoyan.com/films?showType=1" headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Referer": "https://maoyan.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", } response = requests.get(url, headers=headers, timeout=10) print(f"HTTP 状态码: {response.status_code}") print(f"响应长度: {len(response.text)} 字符") print(f"前200字符: {response.text[:200]}") # 关键校验:状态码必须为200,且响应体包含电影标题特征 if response.status_code == 200 and "movie-item" in response.text: print("✅ 请求成功,HTML 结构完整") soup = BeautifulSoup(response.text, 'html.parser') else: print("❌ 请求失败,请检查网络或 headers") # 此处可添加重试逻辑或日志记录这段代码输出HTTP 状态码: 200且✅ 请求成功,即证明环境已就绪。注意timeout=10是硬性要求——猫眼部分节点响应较慢,不设超时会导致程序卡死。若返回403,优先检查User-Agent是否被写错(如多了一个空格)、Referer是否漏了末尾斜杠。
2.3 解析电影列表的 DOM 路径与 BeautifulSoup 定位策略
猫眼电影列表的 HTML 结构高度规律:每部电影包裹在<div class="movie-item">内,其子元素按固定顺序排列。关键字段对应路径如下:
| 字段 | DOM 路径 | BeautifulSoup 选择器 | 说明 |
|---|---|---|---|
| 片名 | <div class="channel-lst">下第一个<a>的文本 | movie.find('div', class_='channel-lst').find('a').get_text(strip=True) | 注意strip=True去除换行和空格 |
| 评分 | <div class="movie-score">内<i class="integer">和<i class="fraction">拼接 | score_int = movie.find('i', class_='integer').get_text() if movie.find('i', class_='integer') else '0'; score_frac = movie.find('i', class_='fraction').get_text() if movie.find('i', class_='fraction') else '0'; f"{score_int}.{score_frac}" | 评分可能为空,需判空 |
| 主演 | <div class="movie-ver">下<div class="star">的文本 | movie.find('div', class_='movie-ver').find('div', class_='star').get_text(strip=True).replace('主演:', '') | 文本含前缀“主演:”,需清洗 |
| 类型 | <div class="movie-ver">下<div class="movie-en">的文本 | movie.find('div', class_='movie-ver').find('div', class_='movie-en').get_text(strip=True).replace('类型:', '') | 同样含前缀,需替换 |
| 上映日期 | <div class="movie-ver">下<div class="releasetime">的文本 | movie.find('div', class_='movie-ver').find('div', class_='releasetime').get_text(strip=True).replace('上映时间:', '') | 注意日期格式为“2024-07-12” |
2.3.1 完整解析函数与异常防护
def parse_movie_item(movie_div): """解析单个电影 div,返回字典,所有字段做空值防护""" try: # 片名 title_tag = movie_div.find('div', class_='channel-lst').find('a') title = title_tag.get_text(strip=True) if title_tag else "未知片名" # 评分(兼容无评分情况) score_int_tag = movie_div.find('i', class_='integer') score_frac_tag = movie_div.find('i', class_='fraction') score = f"{score_int_tag.get_text(strip=True) if score_int_tag else '0'}.{score_frac_tag.get_text(strip=True) if score_frac_tag else '0'}" # 主演、类型、上映日期均在 movie-ver 下 ver_div = movie_div.find('div', class_='movie-ver') if not ver_div: return None # 跳过结构异常的项 star_tag = ver_div.find('div', class_='star') genre_tag = ver_div.find('div', class_='movie-en') release_tag = ver_div.find('div', class_='releasetime') star = star_tag.get_text(strip=True).replace('主演:', '') if star_tag else "" genre = genre_tag.get_text(strip=True).replace('类型:', '') if genre_tag else "" release = release_tag.get_text(strip=True).replace('上映时间:', '') if release_tag else "" return { "title": title, "score": float(score) if '.' in score and score.replace('.', '').isdigit() else 0.0, "star": star, "genre": genre, "release_date": release } except Exception as e: print(f"解析异常: {e}, 跳过该电影") return None # 主解析流程 movies = [] movie_items = soup.find_all('div', class_='movie-item') print(f"共找到 {len(movie_items)} 部电影") for item in movie_items[:10]: # 先取前10部测试 data = parse_movie_item(item) if data: movies.append(data) print(f"成功解析 {len(movies)} 部电影数据")这段代码的关键在于try...except包裹整个解析逻辑,并对每个.find()结果做if xxx else判空。猫眼页面偶尔存在个别电影卡片 DOM 缺失(如临时下架影片),不加防护会导致AttributeError中断整个流程。score字段强制转float,为后续数值计算铺路;title和star保留字符串,供词云分析使用。
3. 用 pandas 清洗“想看人数”“类型字符串”等典型脏数据
3.1 为什么必须清洗?原始 HTML 中的“12.3万想看”不能直接参与计算
猫眼页面中,“想看人数”字段并不在初始列表页 DOM 中——它位于每部电影的详情页(如maoyan.com/film/123456)。但列表页有另一个关键指标:“评分人数”,显示为“12.3万人评分”。这个数字在<div class="movie-score">的<span>标签内,文本为"12.3万人评分"。若不做清洗,pandas会将其作为字符串存入 DataFrame,无法用于排序、求均值或画趋势图。同理,“类型”字段常为“剧情 / 喜剧 / 爱情”,斜杠分隔多个类型,直接存入会丢失分类维度。
注意:本节清洗的是列表页可获取的字段。若需“想看人数”,必须额外请求详情页,属于进阶任务,此处暂不展开。
3.2 清洗“评分人数”的正则提取与数值标准化
import re import pandas as pd # 假设 movies 列表已包含原始数据,现在扩展字段 for movie in movies: # 从 HTML 中提取评分人数(需先在 parse_movie_item 中获取原始文本) # 此处模拟:假设 movie['score_count_raw'] = "12.3万人评分" # 实际需在 parse_movie_item 中增加解析逻辑 pass # 更优做法:在 parse_movie_item 中直接解析 def parse_movie_item_enhanced(movie_div): # ... 前面的解析逻辑 ... # 新增:评分人数 score_count_tag = movie_div.find('div', class_='movie-score').find('span') score_count_raw = score_count_tag.get_text(strip=True) if score_count_tag else "" # 正则提取数字:匹配 "X.X万" 或 "X万" 或 "XXX" 格式 match = re.search(r'(\d+\.?\d*)[万]?人评分', score_count_raw) score_count = float(match.group(1)) * 10000 if match and '万' in score_count_raw else float(match.group(1)) if match else 0 return { # ... 其他字段 ... "score_count": score_count # 单位:人 } # 批量清洗后构建 DataFrame df = pd.DataFrame(movies) print("原始 DataFrame:") print(df[['title', 'score', 'score_count']].head()) # 查看 score_count 数据类型 print(f"\nscore_count 列类型: {df['score_count'].dtype}") print(f"score_count 统计:\n{df['score_count'].describe()}")正则r'(\d+\.?\d*)[万]?人评分'的含义:
(\d+\.?\d*):捕获组,匹配整数(如123)或小数(如12.3),\.表示字面量小数点,?表示前面的.可有可无;[万]?:匹配零个或一个“万”字;人评分:字面量匹配。
score_count计算逻辑:若匹配到“万”,则乘以 10000;否则直接转 float。这样12.3万→123000.0,5678→5678.0,统一为数值型,支持df['score_count'].mean()等运算。
3.3 拆分“类型”字段为多列并统计频次
猫眼的“类型”字段如"剧情 / 喜剧 / 爱情",需拆分为独立类型以便分析“哪类电影平均分最高”。pandas 的str.split()配合explode()是标准解法:
# 假设 df 已有 'genre' 列,内容为 "剧情 / 喜剧 / 爱情" df['genre_list'] = df['genre'].str.split(' / ') df_exploded = df.explode('genre_list').dropna(subset=['genre_list']) df_exploded['genre_list'] = df_exploded['genre_list'].str.strip() # 去除空格 # 统计各类型出现频次 genre_freq = df_exploded['genre_list'].value_counts().reset_index(name='count') genre_freq.columns = ['genre', 'count'] print("电影类型频次统计:") print(genre_freq) # 计算各类型的平均评分 genre_score_avg = df_exploded.groupby('genre_list')['score'].mean().sort_values(ascending=False).reset_index(name='avg_score') print("\n各类型平均评分:") print(genre_score_avg.round(2))explode()将列表列展开为多行,一行一个类型。例如原行"剧情 / 喜剧"→ 两行:"剧情"、"喜剧"。value_counts()直接给出频次,groupby().mean()计算均值。结果可用于后续柱状图:x 轴为类型,y 轴为avg_score。
3.4 处理日期字段并提取“上映年份”用于时间分析
release_date字段格式为"2024-07-12",可直接转为datetime类型,进而提取年份、月份:
# 转 datetime 并提取年份 df['release_date'] = pd.to_datetime(df['release_date'], errors='coerce') df['year'] = df['release_date'].dt.year df['month'] = df['release_date'].dt.month # 统计每年上映数量 yearly_count = df.groupby('year')['title'].count().reset_index(name='film_count') print("历年上映数量:") print(yearly_count) # 过滤出2024年电影,看当前热度 current_year_films = df[df['year'] == 2024] print(f"\n2024年上映电影数: {len(current_year_films)}") print(f"2024年平均评分: {current_year_films['score'].mean():.2f}")errors='coerce'参数至关重要:当遇到无法解析的日期(如"待定")时,自动转为NaT(Not a Time),避免ValueError。后续df['year']会是NaN,可用dropna()过滤。
4. 用 matplotlib + wordcloud 实现三类核心可视化图表
4.1 用水平柱状图对比各类型电影平均分(突出“剧情片稳居第一”)
import matplotlib.pyplot as plt import seaborn as sns # 设置中文字体(防止中文乱码) plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS', 'DejaVu Sans'] plt.rcParams['axes.unicode_minus'] = False # 使用 genre_score_avg 数据(3.3节生成) plt.figure(figsize=(10, 6)) sns.barplot(data=genre_score_avg, x='avg_score', y='genre', palette='Blues_d') plt.title('各类型电影平均评分(基于猫眼列表页)', fontsize=14, fontweight='bold') plt.xlabel('平均评分', fontsize=12) plt.ylabel('电影类型', fontsize=12) plt.xlim(0, 10) # 评分范围0-10 # 在柱子末端标注数值 for i, v in enumerate(genre_score_avg['avg_score']): plt.text(v + 0.05, i, f'{v:.2f}', va='center', fontweight='bold') plt.tight_layout() plt.savefig('genre_avg_score.png', dpi=300, bbox_inches='tight') plt.show()关键点:
sns.barplot的x和y顺序决定横纵轴方向,此处x='avg_score'(数值)y='genre'(类别),生成水平柱状图,便于长类型名显示;palette='Blues_d'使用渐变蓝色系,专业且符合数据主题;plt.text()在每个柱子右侧标注精确分数,避免读者估读;bbox_inches='tight'确保保存时不裁剪标签。
4.2 用词云展示高频主演(揭示“沈腾、马丽”组合霸榜现象)
from wordcloud import WordCloud import jieba # 合并所有主演字符串,用空格连接(jieba 分词需要) all_stars = " ".join(df['star'].dropna().tolist()) # jieba 精确分词(主演名通常为2-3字,无需自定义词典) words = jieba.lcut(all_stars) # 过滤掉空字符串和单字(如“的”、“了”) words_clean = [w for w in words if len(w) >= 2] # 统计词频 from collections import Counter word_freq = Counter(words_clean) print("主演词频 Top 10:", word_freq.most_common(10)) # 生成词云 wc = WordCloud( font_path='simhei.ttf', # Windows 系统字体路径,Mac 用 '/System/Library/Fonts/PingFang.ttc' width=800, height=400, background_color='white', max_words=100, colormap='viridis' ).generate_from_frequencies(word_freq) plt.figure(figsize=(12, 6)) plt.imshow(wc, interpolation='bilinear') plt.axis('off') plt.title('猫眼热映电影主演词云(字号越大出现越频繁)', fontsize=14) plt.savefig('star_wordcloud.png', dpi=300, bbox_inches='tight') plt.show()jieba.lcut()是关键:它将"沈腾 马丽 艾伦"拆为['沈腾', '马丽', '艾伦'],而非['沈', '腾', '马', '丽']。word_freq.most_common(10)输出前10高频主演,验证数据质量——若出现“主演”“导演”等无效词,说明parse_movie_item中的replace()不彻底,需回溯修正。
4.3 用散点图揭示“评分 vs 评分人数”的负相关关系(小众佳作更受认可)
# 过滤掉评分人数为0或评分<1的异常值 df_filtered = df[(df['score_count'] > 0) & (df['score'] >= 1)] plt.figure(figsize=(10, 6)) scatter = plt.scatter( df_filtered['score_count'] / 10000, # 转为“万”为单位,便于阅读 df_filtered['score'], c=df_filtered['score'], # 颜色映射评分 cmap='RdYlBu_r', alpha=0.7, s=50 ) plt.colorbar(scatter, label='电影评分') plt.xlabel('评分人数(万人)', fontsize=12) plt.ylabel('评分', fontsize=12) plt.title('评分人数与评分关系散点图\n(点越大表示评分人数越多,颜色越深表示评分越高)', fontsize=14) plt.grid(True, alpha=0.3) # 添加趋势线(线性拟合) z = np.polyfit(df_filtered['score_count'] / 10000, df_filtered['score'], 1) p = np.poly1d(z) plt.plot(df_filtered['score_count'] / 10000, p(df_filtered['score_count'] / 10000), "r--", alpha=0.8) plt.tight_layout() plt.savefig('score_vs_count.png', dpi=300, bbox_inches='tight') plt.show()此图常呈现轻微负相关:评分人数多的商业大片(如《抓娃娃》)评分集中在7.5-8.5,而评分9.0+的文艺片(如《年会不能停!》)评分人数往往仅1-2万。cmap='RdYlBu_r'使用红-黄-蓝反向色谱,高分(蓝)与低分(红)对比鲜明;alpha=0.7解决点重叠;趋势线用np.polyfit计算,直观展示整体走向。
5. 防止被封IP的三个实战技巧与本地缓存策略
5.1 控制请求频率:time.sleep() 不是万能解,要配合随机化
猫眼对同一 IP 的高频请求(如1秒内连续10次)会触发风控,返回 403 或空白页。简单time.sleep(1)虽有效,但过于规律,易被识别为脚本。更稳妥的做法是引入随机延迟:
import time import random def safe_request(url, headers, max_retries=3): for attempt in range(max_retries): try: response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: return response elif response.status_code == 403: print(f"第 {attempt+1} 次请求被拒,等待后重试...") # 指数退避 + 随机抖动 wait_time = min(2 ** attempt + random.uniform(0, 1), 10) time.sleep(wait_time) else: print(f"HTTP {response.status_code} 错误,停止重试") break except requests.RequestException as e: print(f"请求异常: {e},{attempt+1}/{max_retries}") if attempt < max_retries - 1: time.sleep(random.uniform(1, 3)) return None # 使用示例 response = safe_request("https://maoyan.com/films?showType=1", headers)min(2 ** attempt + random.uniform(0, 1), 10)实现指数退避:首次失败等1-2秒,第二次等2-3秒,第三次等4-5秒,上限10秒。random.uniform(0, 1)加入抖动,打破规律性。
5.2 本地 HTML 缓存:避免重复请求,加速开发迭代
每次运行都重新请求猫眼,既慢又增加服务器压力。将 HTML 保存为本地文件,开发时优先读取缓存:
import os def get_html_cached(url, headers, cache_dir="cache"): os.makedirs(cache_dir, exist_ok=True) # URL 转为文件名(避免特殊字符) filename = url.replace("https://", "").replace("/", "_").replace("?", "_") + ".html" filepath = os.path.join(cache_dir, filename) if os.path.exists(filepath): print(f"从缓存加载: {filepath}") with open(filepath, 'r', encoding='utf-8') as f: return f.read() else: print(f"请求新页面: {url}") response = requests.get(url, headers=headers, timeout=10) if response.status_code == 200: with open(filepath, 'w', encoding='utf-8') as f: f.write(response.text) return response.text else: raise Exception(f"请求失败: {response.status_code}") # 使用 html_content = get_html_cached("https://maoyan.com/films?showType=1", headers) soup = BeautifulSoup(html_content, 'html.parser')缓存文件名用url.replace()简单处理,生产环境可用hashlib.md5(url.encode()).hexdigest()生成唯一哈希。os.makedirs(..., exist_ok=True)确保目录存在,避免FileNotFoundError。
5.3 备用 User-Agent 池:当单一 UA 失效时自动切换
猫眼可能针对特定 UA 进行封禁。维护一个 UA 列表,请求失败时轮换:
USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", ] def get_headers_with_ua(): return { "User-Agent": random.choice(USER_AGENTS), "Referer": "https://maoyan.com/", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", } # 在 safe_request 中调用 headers = get_headers_with_ua() response = requests.get(url, headers=headers, timeout=10)UA 池至少包含 Windows、Mac、Linux 三类主流系统,覆盖不同渲染引擎。每次请求随机选取,降低被标记风险。
本文还有配套的精品资源,点击获取