
简介本资源是一份面向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?showType1本质是服务 SEO 的静态 HTML 页面不依赖 JavaScript 渲染核心数据——这恰恰是初学者最该练的「干净入口」没有登录态、无复杂加密、DOM 结构稳定、字段语义清晰片名、评分、主演、类型、上映日期、想看人数。它不像豆瓣或淘票票那样频繁插入动态水印或混淆字段也不像招聘网站那样强校验 User-Agent 和 Referer。用 Python 爬猫眼核心价值不在数据本身而在于把 requests BeautifulSoup pandas matplotlib/seaborn 这条链路跑通从发请求时怎么设 headers 避开 403到解析dd里嵌套的div classchannel-lst怎么精准定位再到清洗“12.3万想看”这种带单位文本最后用柱状图对比各类型电影平均分、用词云展示高频主演名——这才是企业面试和实际项目中真正考察的「数据采集-清洗-分析-呈现」四层能力。适合刚学完 requests 基础、正卡在「能发请求但总拿不到数据」阶段的开发者也适合需要快速交付一个可演示的可视化小项目的工程师。2. 用 requests BeautifulSoup 在本地跑通猫眼电影列表页的最小命令2.1 为什么选 requests 而不是 Selenium静态页面不需要浏览器驱动猫眼电影列表页以“正在热映”为例的 HTML 源码中所有电影信息都直接存在于初始 HTML 中无需执行 JavaScript 加载。打开浏览器开发者工具 → Network → 刷新页面 → 查看films?showType1对应的响应确认 Content-Type 是text/html; charsetutf-8且 Response Body 里已包含div classmovie-item等完整结构。这意味着用 requests 发起一次 GET 请求即可获取全部数据无需启动 Chrome 或 Firefox 进程。Selenium 在此场景下纯属杀鸡用牛刀启动慢、内存占用高、调试复杂且容易因 ChromeDriver 版本不匹配导致WebDriverException。requests 的优势在于轻量、可控、易调试——失败时直接打印response.status_code和response.text[:500]就能定位问题。提示不要用requests.get(https://maoyan.com/films?showType1)直接请求。猫眼服务器会检查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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.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?showType1 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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, } response requests.get(url, headersheaders, timeout10) print(fHTTP 状态码: {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且✅ 请求成功即证明环境已就绪。注意timeout10是硬性要求——猫眼部分节点响应较慢不设超时会导致程序卡死。若返回403优先检查User-Agent是否被写错如多了一个空格、Referer是否漏了末尾斜杠。2.3 解析电影列表的 DOM 路径与 BeautifulSoup 定位策略猫眼电影列表的 HTML 结构高度规律每部电影包裹在div classmovie-item内其子元素按固定顺序排列。关键字段对应路径如下字段DOM 路径BeautifulSoup 选择器说明片名div classchannel-lst下第一个a的文本movie.find(div, class_channel-lst).find(a).get_text(stripTrue)注意stripTrue去除换行和空格评分div classmovie-score内i classinteger和i classfraction拼接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 classmovie-ver下div classstar的文本movie.find(div, class_movie-ver).find(div, class_star).get_text(stripTrue).replace(主演, )文本含前缀“主演”需清洗类型div classmovie-ver下div classmovie-en的文本movie.find(div, class_movie-ver).find(div, class_movie-en).get_text(stripTrue).replace(类型, )同样含前缀需替换上映日期div classmovie-ver下div classreleasetime的文本movie.find(div, class_movie-ver).find(div, class_releasetime).get_text(stripTrue).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(stripTrue) 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(stripTrue) if score_int_tag else 0}.{score_frac_tag.get_text(stripTrue) 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(stripTrue).replace(主演, ) if star_tag else genre genre_tag.get_text(stripTrue).replace(类型, ) if genre_tag else release release_tag.get_text(stripTrue).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 classmovie-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(stripTrue) 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(fscore_count 统计:\n{df[score_count].describe()})正则r(\d\.?\d*)[万]?人评分的含义(\d\.?\d*)捕获组匹配整数如123或小数如12.3\.表示字面量小数点?表示前面的.可有可无[万]?匹配零个或一个“万”字人评分字面量匹配。score_count计算逻辑若匹配到“万”则乘以 10000否则直接转 float。这样12.3万→123000.05678→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(namecount) genre_freq.columns [genre, count] print(电影类型频次统计:) print(genre_freq) # 计算各类型的平均评分 genre_score_avg df_exploded.groupby(genre_list)[score].mean().sort_values(ascendingFalse).reset_index(nameavg_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], errorscoerce) df[year] df[release_date].dt.year df[month] df[release_date].dt.month # 统计每年上映数量 yearly_count df.groupby(year)[title].count().reset_index(namefilm_count) print(历年上映数量:) print(yearly_count) # 过滤出2024年电影看当前热度 current_year_films df[df[year] 2024] print(f\n2024年上映电影数: {len(current_year_films)}) print(f2024年平均评分: {current_year_films[score].mean():.2f})errorscoerce参数至关重要当遇到无法解析的日期如待定时自动转为NaTNot 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(datagenre_score_avg, xavg_score, ygenre, paletteBlues_d) plt.title(各类型电影平均评分基于猫眼列表页, fontsize14, fontweightbold) plt.xlabel(平均评分, fontsize12) plt.ylabel(电影类型, fontsize12) 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}, vacenter, fontweightbold) plt.tight_layout() plt.savefig(genre_avg_score.png, dpi300, bbox_inchestight) plt.show()关键点sns.barplot的x和y顺序决定横纵轴方向此处xavg_score数值ygenre类别生成水平柱状图便于长类型名显示paletteBlues_d使用渐变蓝色系专业且符合数据主题plt.text()在每个柱子右侧标注精确分数避免读者估读bbox_inchestight确保保存时不裁剪标签。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_pathsimhei.ttf, # Windows 系统字体路径Mac 用 /System/Library/Fonts/PingFang.ttc width800, height400, background_colorwhite, max_words100, colormapviridis ).generate_from_frequencies(word_freq) plt.figure(figsize(12, 6)) plt.imshow(wc, interpolationbilinear) plt.axis(off) plt.title(猫眼热映电影主演词云字号越大出现越频繁, fontsize14) plt.savefig(star_wordcloud.png, dpi300, bbox_inchestight) 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], cdf_filtered[score], # 颜色映射评分 cmapRdYlBu_r, alpha0.7, s50 ) plt.colorbar(scatter, label电影评分) plt.xlabel(评分人数万人, fontsize12) plt.ylabel(评分, fontsize12) plt.title(评分人数与评分关系散点图\n点越大表示评分人数越多颜色越深表示评分越高, fontsize14) plt.grid(True, alpha0.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--, alpha0.8) plt.tight_layout() plt.savefig(score_vs_count.png, dpi300, bbox_inchestight) plt.show()此图常呈现轻微负相关评分人数多的商业大片如《抓娃娃》评分集中在7.5-8.5而评分9.0的文艺片如《年会不能停》评分人数往往仅1-2万。cmapRdYlBu_r使用红-黄-蓝反向色谱高分蓝与低分红对比鲜明alpha0.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_retries3): for attempt in range(max_retries): try: response requests.get(url, headersheaders, timeout10) if response.status_code 200: return response elif response.status_code 403: print(f第 {attempt1} 次请求被拒等待后重试...) # 指数退避 随机抖动 wait_time min(2 ** attempt random.uniform(0, 1), 10) time.sleep(wait_time) else: print(fHTTP {response.status_code} 错误停止重试) break except requests.RequestException as e: print(f请求异常: {e}{attempt1}/{max_retries}) if attempt max_retries - 1: time.sleep(random.uniform(1, 3)) return None # 使用示例 response safe_request(https://maoyan.com/films?showType1, 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_dircache): os.makedirs(cache_dir, exist_okTrue) # 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, encodingutf-8) as f: return f.read() else: print(f请求新页面: {url}) response requests.get(url, headersheaders, timeout10) if response.status_code 200: with open(filepath, w, encodingutf-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?showType1, headers) soup BeautifulSoup(html_content, html.parser)缓存文件名用url.replace()简单处理生产环境可用hashlib.md5(url.encode()).hexdigest()生成唯一哈希。os.makedirs(..., exist_okTrue)确保目录存在避免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/xhtmlxml,application/xml;q0.9,image/avif,image/webp,image/apng,*/*;q0.8,application/signed-exchange;vb3;q0.7, } # 在 safe_request 中调用 headers get_headers_with_ua() response requests.get(url, headersheaders, timeout10)UA 池至少包含 Windows、Mac、Linux 三类主流系统覆盖不同渲染引擎。每次请求随机选取降低被标记风险。本文还有配套的精品资源点击获取