1. 项目概述:为什么要采集猫眼电影的年份数据?
做数据分析或者市场研究的朋友,可能都动过抓取猫眼电影数据的念头。猫眼作为国内主流的电影信息与票务平台,沉淀了海量的电影条目、评分、票房(部分)以及用户评论数据。这些数据就像一座金矿,无论是想分析某个导演的风格变迁,研究特定类型片的市场表现,还是想做个票房预测模型,都离不开对历史数据的系统性梳理。
“各年份数据采集”这个需求,听起来简单,但背后涉及的东西可不少。它不是一个简单的“一锅端”式爬虫,而是要求我们按年份维度,结构化地获取电影的核心信息。这意味着我们需要找到猫眼电影列表页的规律,模拟翻页,解析每一部电影的详情页,并把散落在各处的信息——比如电影名称、主演、类型、上映日期、评分,甚至是那句吸引人的宣传语——都规整地提取出来,按年份归档。
这活儿我干过不止一次,为团队做过竞品分析,也帮朋友做过学术研究的数据支持。过程中踩过的坑、总结的技巧,远比最后那几行代码值钱。今天,我就把自己这套经过实战检验的方案拆开揉碎了讲给你听,从思路设计到工具选型,从核心代码到反爬应对,最后再附上我私藏的“数据清洗”心得。目标就一个:让你拿到手的不只是能跑的脚本,更是一套遇到问题能自己解决的思路和一份干净、可直接分析的数据集。
2. 整体思路与爬虫策略设计
面对猫眼这样的大型站点,直接蛮干很容易撞上南墙。我的核心思路是“化整为零,层层递进”。整个采集任务可以分解为三个清晰的层次,这样不仅逻辑清晰,也便于调试和维护。
2.1 核心策略:列表页遍历 + 详情页抓取
这是最经典也最有效的策略。猫眼电影有按年份筛选的列表页(例如maoyan.com/films?year=2023),这个页面就是我们任务的起点。
- 确定年份范围:首先,你需要想清楚要抓取哪几年的数据。是近十年?还是自有记录以来?确定起止年份,比如2010-2024。
- 遍历年份列表页:针对每一个目标年份,访问其对应的列表页URL。这里的关键在于,列表页通常是分页的,你需要解析出总页数,或者通过观察“下一页”按钮的规律,来模拟翻页,遍历该年份下的所有电影条目。
- 提取详情页链接:在每一个列表页中,我们的核心目标是提取出每一部电影的详情页独立URL。通常,这个链接藏在电影海报或标题的
<a>标签里。 - 深入详情页抓取:拿到详情页链接后,再发起请求,进入单个电影的“主场”。这里的信息最为全面,是我们数据采集的主要战场。
这个策略的好处是目标明确,每一层的任务单一。即使某个详情页抓取出错,也不会影响其他电影或其他年份的抓取进程,容错性较好。
2.2 技术栈选型:为什么是 Requests + BeautifulSoup?
Python生态里爬虫库很多,Scrapy框架强大,Selenium能处理复杂JS。但对于猫眼电影这个场景,我的选择是Requests + BeautifulSoup这对经典组合。
- Requests:负责HTTP通信。它的API简洁优雅,性能足够,且易于设置请求头(Headers)、代理(Proxy)等来应对基础反爬。猫眼电影的大部分页面内容(尤其是列表页和详情页的核心信息)仍然是服务端渲染的,直接通过Requests获取HTML是可行的。
- BeautifulSoup:负责HTML解析。它就像一把瑞士军刀,能根据标签、CSS类名(class)、ID等快速定位并提取我们需要的文本内容。对于结构相对规整的猫眼页面,用起来非常顺手。
不选Scrapy是因为本项目结构并不复杂,无需调度、去重、管道等重型框架的特性,用轻量级库更灵活。不首选Selenium是因为它效率较低(需要启动浏览器),资源消耗大,除非页面数据完全由JavaScript动态加载且没有隐藏的接口,否则没必要上来就用“大炮”。
当然,如果后续发现猫眼某些数据通过Ajax接口加载,我们可以轻松地在Requests的基础上,配合浏览器的开发者工具(F12 -> Network -> XHR)找到那个返回JSON数据的接口,用Requests直接调用,效率更高。这是组合方案的灵活之处。
2.3 关键挑战与应对思路预设
在动手写代码前,必须预见到可能遇到的麻烦,并想好对策。
反爬虫机制:这是最大的拦路虎。猫眼肯定有。
- User-Agent检测:这是最基本的。必须为Requests设置一个常见的浏览器UA。
- 请求频率限制:如果请求太快,IP可能会被暂时封禁。解决方案是在请求间加入随机延时(例如
time.sleep(random.uniform(1, 3))),模拟真人操作。 - Cookie/Session:某些页面可能需要维持会话。使用
requests.Session()对象可以自动管理Cookie,让多次请求看起来像同一个用户在操作。 - IP代理池:对于大规模、高频采集,这是终极方案。需要准备一批高质量的代理IP,在请求时随机切换。但对于按年份的“中低速”采集,通过控制频率和设置合理延时,通常可以避免触发IP封锁。
页面结构变动:网站前端可能会改版。今天能用的CSS选择器,明天可能就失效了。因此,我们的代码不能写死,提取信息的逻辑要清晰,并且最好将关键的CSS选择器(如
.name,.actor)作为配置变量放在代码开头,方便日后修改。数据完整性:不是每部电影都有评分或票房数据。我们的代码要能处理这些缺失值,用
None或空字符串填充,避免程序因某个字段缺失而崩溃。
3. 实操步骤详解:从环境搭建到数据落地
理论说再多,不如一行代码。我们一步步来,我会把每个环节的意图和注意事项讲清楚。
3.1 环境准备与依赖安装
首先,确保你的Python环境(建议3.7以上)已经就绪。然后,通过pip安装我们需要的库。
pip install requests beautifulsoup4 pandasrequests: 用于网络请求。beautifulsoup4: 用于解析HTML。pandas: 这不是爬虫必选,但强烈建议安装。它用于将爬取的数据整理成结构化的表格(DataFrame),并轻松导出为CSV或Excel文件,是数据清洗和分析的神器。
3.2 核心代码模块拆解
我不会一次性扔给你几百行代码。我们按功能模块来构建,这样你更容易理解。
模块一:请求头与会话管理
创建一个Python文件,比如maoyan_crawler.py。开头我们先配置好请求头和会话。
import requests import time import random 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/91.0.4472.124 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', } # 创建一个会话对象,可以自动保存cookies session = requests.Session() session.headers.update(headers) # 基础URL BASE_URL = 'https://maoyan.com'注意:
User-Agent是关键。你可以从自己浏览器的开发者工具(F12 -> Network,刷新页面,点击任意请求,查看Headers)里复制一个最新的来用。这里我写了一个常见的Chrome UA作为示例。
模块二:获取单个年份的电影列表页链接
这个函数的目标是,输入一个年份(如2023),输出该年份下所有电影详情页的链接列表。
def get_film_links_by_year(year, max_pages=10): """ 获取指定年份的所有电影详情页链接 :param year: 年份,整数 :param max_pages: 最大抓取页数,防止意外死循环 :return: 电影详情页链接列表 """ film_links = [] page = 0 # 猫眼列表页页码通常从0开始 while page < max_pages: # 构建列表页URL,猫眼的规律通常是 films?showType=1&year={year}&offset={page*30} # 具体参数需要观察实际页面 list_url = f'{BASE_URL}/films?year={year}&offset={page*30}' print(f'正在抓取 {year} 年,第 {page+1} 页: {list_url}') try: resp = session.get(list_url, timeout=10) resp.raise_for_status() # 如果状态码不是200,抛出异常 soup = BeautifulSoup(resp.text, 'html.parser') # 核心:定位电影条目。打开猫眼列表页,检查元素,找到每个电影块的容器。 # 例如,可能是 <div class="movie-item"> 里面的 <a> 标签 # 这里的选择器需要你根据实际页面结构调整!!! movie_items = soup.find_all('div', class_='movie-item') # 示例,可能不准确 if not movie_items: print(f'未在第 {page+1} 页找到电影条目,可能已到末页或页面结构已变。') break for item in movie_items: link_tag = item.find('a', href=True) if link_tag and link_tag['href'].startswith('/films/'): full_link = BASE_URL + link_tag['href'] if full_link not in film_links: # 简单去重 film_links.append(full_link) # 判断是否还有下一页(同样需要根据页面实际结构调整) # 例如,查找是否存在带有“下一页”文本或特定class的链接 next_button = soup.find('a', string='下一页') if not next_button or 'disabled' in next_button.get('class', []): break # 没有下一页了,退出循环 page += 1 # 重要:随机延时,避免请求过快 time.sleep(random.uniform(2, 5)) except requests.exceptions.RequestException as e: print(f'请求列表页出错: {e}') break except Exception as e: print(f'解析列表页出错: {e}') break print(f'年份 {year} 共找到 {len(film_links)} 部电影的链接。') return film_links实操心得:
find_all和find里用的CSS选择器(如class_='movie-item')是整个爬虫最脆弱的部分。你必须亲自打开猫眼电影的列表页(比如maoyan.com/films?year=2023),按F12打开开发者工具,使用元素选择器(箭头图标)去点击电影条目,查看它外层容器的HTML结构和类名。把这个类名填到代码里。网站改版,首先就要改这里。
模块三:解析单个电影详情页信息
现在我们有了详情页链接,需要从中提取出结构化的数据。
def parse_film_detail(detail_url): """ 解析电影详情页,提取核心信息 :param detail_url: 电影详情页URL :return: 包含电影信息的字典 """ film_info = { '电影名称': '', '上映年份': '', '类型': '', '主演': '', '评分': '', '宣传语': '', '详情页链接': detail_url } try: resp = session.get(detail_url, timeout=10) resp.raise_for_status() # 注意编码,有时需要指定 resp.encoding = 'utf-8' soup = BeautifulSoup(resp.content, 'html.parser') # 1. 电影名称 (通常在 <h1> 或某个特定class的<span>里) name_tag = soup.find('h1', class_='name') or soup.find('h1') if name_tag: film_info['电影名称'] = name_tag.get_text(strip=True) # 2. 上映年份 (可以从名称后的括号里提取,或者从特定的上映日期元素中提取年份) # 例如,找到包含“上映日期”的标签 info_tags = soup.find_all('li', class_='ellipsis') for tag in info_tags: text = tag.get_text(strip=True) if '上映时间' in text or '上映日期' in text: date_str = text.split(':')[-1] if ':' in text else text # 尝试从日期字符串中提取年份,如 “2023-08-01” -> 2023 import re year_match = re.search(r'(\d{4})', date_str) if year_match: film_info['上映年份'] = year_match.group(1) break # 3. 类型 (通常在一组标签里,用 / 分隔) genre_tag = soup.find('li', class_='ellipsis', text=re.compile(r'类型')) if genre_tag: # 可能需要找到相邻的兄弟节点获取具体类型文本 genre_text = genre_tag.find_next_sibling('li', class_='ellipsis') if genre_text: film_info['类型'] = genre_text.get_text(strip=True).replace('/', ',') # 4. 主演 (同样,找到“主演”标签,然后取后面的内容) actor_tag = soup.find('li', class_='ellipsis', text=re.compile(r'主演')) if actor_tag: actor_text = actor_tag.find_next_sibling('li', class_='ellipsis') if actor_text: film_info['主演'] = actor_text.get_text(strip=True).replace('/', ',') # 5. 评分 (可能在 <div class="score"> 里) score_tag = soup.find('div', class_='score') or soup.find('span', class_='score-num') if score_tag: film_info['评分'] = score_tag.get_text(strip=True) # 6. 宣传语 (可能在 <div class="dra"> 里) dra_tag = soup.find('div', class_='dra') if dra_tag: film_info['宣传语'] = dra_tag.get_text(strip=True) # 再次检查年份:如果从上映日期没提取到,尝试从电影名称或URL中推断 if not film_info['上映年份'] and film_info['电影名称']: # 有些电影名包含年份,如“流浪地球2 (2023)” year_match = re.search(r'\((\d{4})\)', film_info['电影名称']) if year_match: film_info['上映年份'] = year_match.group(1) print(f'已解析: {film_info["电影名称"]} ({film_info.get("上映年份", "N/A")})') time.sleep(random.uniform(1, 3)) # 详情页请求也加延时 except requests.exceptions.RequestException as e: print(f'请求详情页 {detail_url} 出错: {e}') except Exception as e: print(f'解析详情页 {detail_url} 出错: {e}') return film_info注意事项:详情页的解析逻辑比列表页更复杂,因为要定位多个字段。没有一成不变的选择器。你必须打开一个具体的电影详情页(如《流浪地球2》的页面),用开发者工具逐个找到“上映日期”、“类型”、“主演”等文本对应的HTML标签和类名。我上面代码中的
class_='ellipsis'只是示例,实际类名可能完全不同。这个过程需要耐心和细心。
模块四:主控流程与数据存储
最后,我们把所有模块串联起来,并利用pandas保存数据。
def main(start_year=2010, end_year=2024): all_films_data = [] for year in range(start_year, end_year + 1): print(f'\n========== 开始采集 {year} 年电影数据 ==========') film_links = get_film_links_by_year(year, max_pages=20) # 可根据年份调整最大页数 for idx, link in enumerate(film_links): print(f'[{year}] 处理第 {idx+1}/{len(film_links)} 部电影...') film_data = parse_film_detail(link) if film_data['电影名称']: # 只保存成功解析到名称的数据 all_films_data.append(film_data) # 每个年份抓取完后,可以稍作长时间休息,降低风险 if year != end_year: wait_time = random.uniform(10, 20) print(f'{year}年采集完成,等待{wait_time:.1f}秒后继续下一年...') time.sleep(wait_time) # 使用pandas保存数据 if all_films_data: df = pd.DataFrame(all_films_data) # 重新排序列顺序,让关键信息在前 column_order = ['电影名称', '上映年份', '类型', '主演', '评分', '宣传语', '详情页链接'] df = df[column_order] # 保存为CSV文件,编码用utf-8-sig解决Excel打开中文乱码问题 filename = f'maoyan_films_{start_year}_to_{end_year}.csv' df.to_csv(filename, index=False, encoding='utf-8-sig') print(f'\n所有数据已保存至 {filename}, 共 {len(df)} 条记录。') print(df.head()) # 预览前几行数据 else: print('未抓取到任何数据。') if __name__ == '__main__': main(2022, 2023) # 示例:抓取2022和2023年的数据,正式运行可扩大范围4. 进阶技巧与反爬虫实战
如果你的爬虫跑起来不顺利,或者想更稳健、更高效,下面这些技巧会帮到你。
4.1 动态加载数据的应对
猫眼的部分数据(比如“想看人数”、部分评论)可能是通过JavaScript异步加载的。你在HTML源码里找不到,但在浏览器的“Network”面板里能看到XHR或Fetch请求。
- 解决方法:
- 打开开发者工具(F12),进入“Network”选项卡,勾选“Preserve log”(保留日志)。
- 刷新或操作页面,观察“XHR”或“Fetch”类型的请求。
- 找到包含你所需数据(如JSON格式)的请求,查看其“Headers”获取请求URL和参数,查看“Preview”或“Response”确认数据。
- 在你的Python代码中,直接用
requests.get()或requests.post()模拟这个请求。这通常比解析HTML更直接、更稳定,因为接口返回的是结构化的JSON。
例如,电影的评分详情可能来自一个类似https://maoyan.com/ajax/film/${filmId}?__t=时间戳的接口。你需要先从详情页HTML里提取出filmId,然后构造这个接口URL去请求。
4.2 请求头(Headers)的精细化伪装
除了User-Agent,有时网站还会检查其他Header。
- 关键Headers:
Referer:表示你从哪个页面跳转过来的。对于详情页,可以设置为列表页的URL。Accept-Encoding:通常设为'gzip, deflate, br',表示接受压缩数据以节省带宽。Connection:'keep-alive'Upgrade-Insecure-Requests:'1'Sec-Fetch-*系列头:现代浏览器会发送这些头,但用Requests模拟比较麻烦。如果遇到高级反爬,可以考虑使用curl_cffi或undetected-chromedriver等能更好模拟浏览器指纹的库。
4.3 使用IP代理池
当单IP请求频率过高被禁时,就需要切换IP。
# 一个简单的代理使用示例(需自行准备代理IP列表) proxies_list = [ 'http://user:pass@ip1:port', 'http://user:pass@ip2:port', # ... ] def get_with_proxy(url, session): proxy = random.choice(proxies_list) try: resp = session.get(url, proxies={'http': proxy, 'https': proxy}, timeout=8) return resp except: # 如果代理失败,可以重试或标记该代理失效 return None重要提醒:免费代理大多不稳定、速度慢。商业项目或大规模采集,建议使用付费代理服务。在小规模、低速采集下,通过
time.sleep控制频率通常足以应对。
4.4 异常处理与日志记录
健壮的爬虫必须能处理各种异常,并记录下发生了什么。
- Try-Except:像示例代码中那样,用
try-except包裹可能出错的网络请求和解析代码。 - 日志模块:使用Python内置的
logging模块替代print,可以输出不同级别(DEBUG, INFO, WARNING, ERROR)的日志到文件,方便事后排查。 - 数据持久化:不要等所有数据抓完再保存。可以每抓取10部或1个年份,就将
all_films_data列表保存一次到CSV,防止程序中途崩溃导致全部数据丢失。
5. 数据清洗与后处理经验谈
爬下来的原始数据往往是“脏”的,直接分析会出问题。以下是我常用的清洗步骤:
- 去重:尽管我们在收集链接时做了简单去重,但可能因为不同年份列表页出现同一部电影(如跨年上映),导致数据重复。使用pandas的
df.drop_duplicates(subset=['电影名称', '上映年份'], keep='first')可以根据电影名和年份去重。 - 处理缺失值:
评分:没有评分的电影,可能是未上映或太老。可以填充为NaN,在分析时排除。主演/类型:可能为空。根据分析需求,决定是保留空值还是填充为“未知”。
- 字段格式化:
上映年份:确保是整数类型。从字符串中提取后,用pd.to_numeric(df['上映年份'], errors='coerce')转换。评分:如果是字符串如“9.5”,转换为浮点数。注意处理“暂无评分”这样的文本。类型/主演:我们之前用逗号分隔了。检查是否有多余的空格,可以用df['类型'] = df['类型'].str.replace(',', ',').str.split(',').apply(lambda x: [i.strip() for i in x])将其转换为列表,便于后续做“类型统计”。
- 异常值检查:查看年份范围是否合理(比如有1970年或2099年这样的异常值),评分是否在合理区间(如0-10分)。
清洗后的数据,才是真正可用的“资产”。你可以用pandas进行各种分析,比如:
- 各年份电影数量趋势。
- 不同类型电影的占比和年度变化。
- 评分分布情况。
- 高频出现的演员等。
最后,也是最重要的心得:爬虫是与网站维护者的一场动态博弈。你的代码今天能跑,不代表明天还能跑。保持对目标网站变化的关注,定期测试,并理解反爬措施背后的原理(是为了保护服务器负载,还是保护核心数据),才能让你的数据管道长久稳定。这套方法和思路,不仅适用于猫眼,稍作调整,也能应用到其他许多类似结构的网站上去。