1. 项目缘起:为什么需要自己动手爬取豆瓣TOP250?
作为一名影迷兼数据爱好者,我经常遇到一个尴尬:想找一部高分电影,打开豆瓣TOP250榜单,翻了几页,发现信息太零散,想整理成自己的片单或者做个简单的数据分析,手动复制粘贴简直是一场噩梦。排名、评分、导演、上映年份……这些信息如果能规整地躺在表格里,那该多方便。市面上虽然有一些现成的数据集,但要么数据陈旧,要么字段不全,最关键的是,你无法控制数据的获取过程和格式,遇到网站改版或者反爬策略调整,就只能干瞪眼。
所以,自己动手写一个爬虫,从豆瓣电影TOP250页面把这些核心信息“拿”下来,就成了一件既有学习价值又有实用价值的事情。这不仅仅是获取数据,更是一个理解网页结构、处理网络请求、解析HTML文档、应对反爬机制的完整实战。通过这个项目,你不仅能得到一份干净、实时、可定制的电影数据,更能掌握一套通用的数据采集思路,以后遇到任何需要从网页抓取信息的场景,都能从容应对。
2. 核心目标拆解与工具选型
我们的目标非常明确:访问豆瓣电影TOP250的列表页,遍历每一部电影,进入其详情页,然后提取标题、排名、评分、链接、导演、编剧、主演、类型、上映日期、片长、评分人数和剧情简介这12个核心字段。
要实现这个目标,我们需要几个关键工具,选择它们的原因如下:
1. 网络请求库:Requests这是Python生态中公认最简洁、最人性化的HTTP库。相比于更底层的urllib,Requests的API设计让发送GET、POST请求变得像说话一样简单。对于豆瓣这种主要以静态页面呈现的网站,Requests完全够用。我们用它来获取网页的HTML源代码。
2. 网页解析库:BeautifulSoup从一堆HTML标签中精准地找到我们需要的数据,就像大海捞针。BeautifulSoup就是那根“磁铁”。它能够将复杂的HTML文档转换成一个复杂的树形结构(解析树),然后让我们通过标签名、CSS类名、ID等属性,像在文件系统中导航一样轻松地找到目标数据。它的学习曲线平缓,对于本项目这种中等复杂度的解析任务再合适不过。
3. 为什么不是Scrapy?Scrapy是一个强大的、异步的爬虫框架,适合构建大型、复杂的爬虫项目。但对于我们这次“单页列表+多详情页”的、目标明确且规模固定的任务来说,使用Scrapy有点“杀鸡用牛刀”的感觉,其学习成本和项目结构复杂度会显著增加。Requests+BeautifulSoup的组合更加轻量、直接,也更利于初学者理解和掌握每一个步骤。
4. 辅助工具:Time & Random这两个Python内置库至关重要。time.sleep()用于在请求之间插入随机间隔,模拟人类浏览行为,这是对目标网站最基本的尊重,也是避免IP被封锁的最简单有效的方法。random库用来生成随机的等待时间,让爬虫行为更“人性化”。
5. 数据存储:CSV文件最终提取的数据,我们将保存为CSV(逗号分隔值)格式。CSV文件可以用Excel、Numbers直接打开,也可以用Pandas进行进一步分析,通用性极强。Python内置的csv模块就能很好地完成写入任务。
整个工具的选型思路就是:在满足需求的前提下,选择最简单、最直接、最易维护的方案。
3. 实战第一步:分析页面结构与制定抓取策略
在写任何代码之前,我们必须像侦探一样,仔细勘察“现场”——豆瓣TOP250的页面。
3.1 列表页分析
打开豆瓣电影TOP250的页面,通过浏览器的开发者工具(F12)查看其HTML结构。
- 列表容器:所有电影条目都包裹在一个
class="grid_view"的<ol>标签内。 - 单个电影条目:每个
<li>标签代表一部电影。这里面已经包含了我们需要的部分信息:- 排名:在
class="pic"的<div>里,<em>标签内的数字就是排名。 - 中文片名:在
class="hd"的<div>里,第一个<span class="title">里的文本就是中文名(注意,有些外国电影可能还有英文名在第二个<span>里)。 - 详情页链接:在
class="hd"的<div>里,<a>标签的href属性就是这部电影详情页的URL。 - 评分:在
class="star"的<div>里,<span class="rating_num">中的文本。 - 评分人数:在
class="star"的<div>里,<span>标签内的文本,通常格式是“XXX人评价”,需要用正则表达式或字符串处理提取数字。
- 排名:在
3.2 详情页分析
点击任意一部电影的链接进入详情页,这里信息更丰富。
- 导演、编剧、主演:这些信息通常在
<span class="attrs">的标签内,但我们需要通过其前面的文本(如“导演”、“编剧”)来定位。一个可靠的方法是找到ID为info的<div>,然后在这个区块内进行文本搜索和分割。 - 类型、上映日期、片长:同样位于ID为
info的<div>内,格式规整,可以通过冒号“:”或斜杠“/”进行分割提取。 - 剧情简介:简介可能位于
<span property="v:summary">这个标签内,或者其父级<div>内。需要注意,简介有时会有“展开全部”的隐藏部分,我们首次获取的可能是折叠后的简短版本。
3.3 抓取策略制定
基于以上分析,我们的抓取流程可以确定为:
- 抓取列表页:获取所有25部电影(一页)的基本信息和详情页链接。
- 遍历详情页:针对上一步获取的每一个链接,发起新的请求,进入详情页。
- 解析详情页:在详情页中解析出导演、编剧等更丰富的信息。
- 数据存储:将每一部电影解析出的所有字段,保存到CSV文件的一行中。
- 翻页处理:TOP250共有10页,我们需要找到“后页”的链接,循环执行步骤1-4,直到处理完所有页面。
这个策略清晰地将任务分成了“列表抓取”和“详情抓取”两个阶段,逻辑分明。
4. 代码实现:从零构建爬虫核心
接下来,我们一步步用代码实现上述策略。我会先给出关键代码片段,然后解释其背后的逻辑和注意事项。
4.1 环境准备与基础请求
首先,导入必要的库,并设置一些全局变量,比如请求头。设置请求头是为了让我们的请求看起来更像一个普通的浏览器访问,这是绕过基础反爬的第一步。
import requests from bs4 import BeautifulSoup import time import random import csv import re # 基础URL BASE_URL = ‘https://movie.douban.com/top250‘ # 请求头,模拟浏览器 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‘ }4.2 解析列表页函数
这个函数负责处理单个列表页(例如第一页https://movie.douban.com/top250),从中提取出每部电影在列表页中可见的信息,以及最重要的——详情页链接。
def parse_list_page(url): """解析列表页,返回电影基本信息列表和下一页链接""" print(f‘正在抓取列表页: {url}‘) time.sleep(random.uniform(1, 3)) # 随机等待1-3秒 try: response = requests.get(url, headers=HEADERS) response.raise_for_status() # 如果状态码不是200,抛出异常 response.encoding = ‘utf-8‘ except requests.RequestException as e: print(f‘请求列表页失败: {url}, 错误: {e}‘) return [], None soup = BeautifulSoup(response.text, ‘html.parser‘) movie_list = [] grid_view = soup.find(‘ol‘, class_=‘grid_view‘) if not grid_view: print(‘未找到电影列表容器‘) return [], None for item in grid_view.find_all(‘li‘): movie_info = {} # 排名 rank_tag = item.find(‘em‘) movie_info[‘rank‘] = rank_tag.text if rank_tag else ‘N/A‘ # 中文片名和链接 hd_div = item.find(‘div‘, class_=‘hd‘) if hd_div: title_tag = hd_div.find(‘span‘, class_=‘title‘) movie_info[‘title‘] = title_tag.text.strip() if title_tag else ‘N/A‘ link_tag = hd_div.find(‘a‘) movie_info[‘detail_url‘] = link_tag[‘href‘] if link_tag else ‘N/A‘ else: movie_info[‘title‘] = ‘N/A‘ movie_info[‘detail_url‘] = ‘N/A‘ # 评分 star_div = item.find(‘div‘, class_=‘star‘) if star_div: rating_tag = star_div.find(‘span‘, class_=‘rating_num‘) movie_info[‘rating‘] = rating_tag.text if rating_tag else ‘N/A‘ # 评分人数 votes_tag = star_div.find_all(‘span‘)[-1] # 最后一个span是评价人数 votes_text = votes_tag.text if votes_tag else ‘‘ # 使用正则表达式提取数字 votes_num = re.search(r‘\d+‘, votes_text.replace(‘,‘, ‘‘)) movie_info[‘votes‘] = votes_num.group() if votes_num else ‘0‘ else: movie_info[‘rating‘] = ‘N/A‘ movie_info[‘votes‘] = ‘0‘ movie_list.append(movie_info) # 查找下一页链接 next_link = soup.find(‘span‘, class_=‘next‘).find(‘a‘) if soup.find(‘span‘, class_=‘next‘) else None next_url = BASE_URL + next_link[‘href‘] if next_link else None return movie_list, next_url注意:这里对评分人数的处理用了点小技巧。豆瓣的评分人数文本是“1500000人评价”,我们使用
re.search(r‘\d+‘, ...)来提取其中的数字部分,并提前用replace(‘,‘, ‘‘)去掉数字中的千分位逗号,确保提取的是纯数字字符串,方便后续作为数值处理。
4.3 解析详情页函数
这个函数接收一个详情页URL,从中挖掘导演、编剧等深度信息。
def parse_detail_page(url): """解析电影详情页,补充详细信息""" print(f‘ 正在抓取详情页: {url}‘) time.sleep(random.uniform(2, 4)) # 详情页访问间隔稍长 try: resp = requests.get(url, headers=HEADERS) resp.raise_for_status() resp.encoding = ‘utf-8‘ except requests.RequestException as e: print(f‘ 请求详情页失败: {url}, 错误: {e}‘) # 返回一个包含默认值的字典,避免程序中断 return { ‘directors‘: ‘N/A‘, ‘writers‘: ‘N/A‘, ‘actors‘: ‘N/A‘, ‘genres‘: ‘N/A‘, ‘release_date‘: ‘N/A‘, ‘runtime‘: ‘N/A‘, ‘summary‘: ‘N/A‘ } detail_soup = BeautifulSoup(resp.text, ‘html.parser‘) info = {} # 核心信息区域 info_div = detail_soup.find(‘div‘, id=‘info‘) if info_div: info_text = info_div.get_text(‘|‘, strip=True) # 用‘|‘替换换行符,得到连续文本 # 使用更健壮的方法:按行分割后处理 lines = info_div.get_text(‘\n‘, strip=True).split(‘\n‘) info_dict = {} for line in lines: if ‘:‘ in line: key, value = line.split(‘:‘, 1) info_dict[key.strip()] = value.strip() # 从info_dict中提取信息 info[‘directors‘] = info_dict.get(‘导演‘, ‘N/A‘) info[‘writers‘] = info_dict.get(‘编剧‘, ‘N/A‘) info[‘actors‘] = info_dict.get(‘主演‘, ‘N/A‘) # 类型可能有多项,用‘/‘连接 info[‘genres‘] = info_dict.get(‘类型‘, ‘N/A‘) info[‘release_date‘] = info_dict.get(‘上映日期‘, ‘N/A‘).split(‘(‘)[0].strip() # 取第一部分,去掉国家 info[‘runtime‘] = info_dict.get(‘片长‘, ‘N/A‘).split(‘(‘)[0].strip() # 同上,处理多国片长 else: # 如果找不到info区域,全部赋默认值 info = {key: ‘N/A‘ for key in [‘directors‘, ‘writers‘, ‘actors‘, ‘genres‘, ‘release_date‘, ‘runtime‘]} # 剧情简介 summary_tag = detail_soup.find(‘span‘, property=‘v:summary‘) if summary_tag: summary = summary_tag.get_text(‘ ‘, strip=True) # 处理简介中的空白字符和换行 summary = ‘ ‘.join(summary.split()) info[‘summary‘] = summary[:200] + ‘...‘ if len(summary) > 200 else summary # 截断过长的简介 else: info[‘summary‘] = ‘N/A‘ return info踩坑点:详情页的
info区域解析是难点。直接通过特定标签定位导演、编剧等字段可能因为页面微调而失效。这里我采用了更稳健的策略:先将整个info区域的文本按行分割,然后对每一行用冒号:进行拆分,形成一个临时的字典。这样,无论这些字段的HTML标签如何变化,只要其在页面上以“导演: 某某某”的文本形式出现,我们就能正确提取。这是一种“以不变应万变”的文本解析思路。
4.4 主循环与数据存储
现在,我们把所有部分串联起来,并加入翻页逻辑和数据保存功能。
def main(): all_movies_data = [] current_list_url = BASE_URL page_count = 0 # CSV文件表头 fieldnames = [‘排名‘, ‘中文片名‘, ‘评分‘, ‘评分人数‘, ‘详情页链接‘, ‘导演‘, ‘编剧‘, ‘主演‘, ‘类型‘, ‘上映日期‘, ‘片长‘, ‘剧情简介‘] # 打开文件准备写入 with open(‘douban_top250_movies.csv‘, ‘w‘, newline=‘‘, encoding=‘utf-8-sig‘) as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() while current_list_url and page_count < 10: # 最多10页 page_count += 1 print(f‘\n=== 开始处理第 {page_count} 页 ===‘) # 1. 解析当前列表页 movies_basic_info, next_page_url = parse_list_page(current_list_url) if not movies_basic_info: print(‘当前页无电影数据,可能遇到反爬或页面结构变化。‘) break # 2. 遍历当前页的每部电影,获取详情信息 for movie in movies_basic_info: detail_url = movie.get(‘detail_url‘) if detail_url and detail_url != ‘N/A‘: detail_info = parse_detail_page(detail_url) # 合并基础信息和详情信息 full_movie_info = { ‘排名‘: movie.get(‘rank‘, ‘N/A‘), ‘中文片名‘: movie.get(‘title‘, ‘N/A‘), ‘评分‘: movie.get(‘rating‘, ‘N/A‘), ‘评分人数‘: movie.get(‘votes‘, ‘0‘), ‘详情页链接‘: detail_url, ‘导演‘: detail_info.get(‘directors‘, ‘N/A‘), ‘编剧‘: detail_info.get(‘writers‘, ‘N/A‘), ‘主演‘: detail_info.get(‘actors‘, ‘N/A‘), ‘类型‘: detail_info.get(‘genres‘, ‘N/A‘), ‘上映日期‘: detail_info.get(‘release_date‘, ‘N/A‘), ‘片长‘: detail_info.get(‘runtime‘, ‘N/A‘), ‘剧情简介‘: detail_info.get(‘summary‘, ‘N/A‘) } all_movies_data.append(full_movie_info) # 写入一行数据到CSV writer.writerow(full_movie_info) print(f‘ 已保存: {full_movie_info[“中文片名”]} (排名: {full_movie_info[“排名”]})‘) else: print(f‘ 跳过电影 {movie.get(“title“)},无有效详情页链接。‘) # 3. 准备抓取下一页 current_list_url = next_page_url print(f‘\n=== 抓取完成!共获取 {len(all_movies_data)} 部电影数据。数据已保存至 douban_top250_movies.csv ===‘) if __name__ == ‘__main__‘: main()关键设计:这里采用了“解析一页,存储一页”的流式写入方式。在
while循环内,每解析完一部电影的完整信息,就立即调用writer.writerow()将其写入CSV文件。这样做的好处是,即使程序在运行中途因为网络问题或异常中断,已经抓取到的数据也不会丢失,它们已经安全地保存在了硬盘上。这是一种非常实用的工程化思维。
5. 高级话题:反爬应对与代码健壮性
如果只是运行上面的基础代码,很可能在抓取到几十条数据后就会收到403错误,或者发现数据开始大量缺失。这是因为豆瓣(以及大多数现代网站)都有反爬虫机制。我们需要让爬虫行为更“像人”。
5.1 请求头伪装与随机延迟
我们已经设置了User-Agent,但这还不够。一个真实的浏览器请求会携带很多头部信息。我们可以进一步完善HEADERS:
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‘, ‘Accept-Encoding‘: ‘gzip, deflate, br‘, ‘Connection‘: ‘keep-alive‘, ‘Upgrade-Insecure-Requests‘: ‘1‘, }time.sleep(random.uniform(a, b))是我们已经使用的延迟方法。对于豆瓣,列表页间隔1-3秒,详情页间隔2-4秒是一个比较安全的范围。过于频繁的请求是触发反爬的最主要原因。
5.2 使用IP代理池
如果单个IP发送请求过快或过于频繁,很容易被暂时封禁。对于个人小规模爬取,可以尝试降低请求频率。但对于更稳定、长期的需求,使用IP代理池是更专业的方案。你可以使用一些免费的代理IP网站,或者购买稳定的代理服务。代码修改如下:
PROXIES = { ‘http‘: ‘http://your_proxy_ip:port‘, ‘https‘: ‘https://your_proxy_ip:port‘, } # 在requests.get()中增加proxies参数 response = requests.get(url, headers=HEADERS, proxies=PROXIES)5.3 异常处理与重试机制
网络请求充满不确定性。我们必须假设请求可能会失败,并做好应对。
def safe_request(url, max_retries=3): """带重试机制的请求函数""" for i in range(max_retries): try: time.sleep(random.uniform(1, 3)) resp = requests.get(url, headers=HEADERS, timeout=10) # 设置超时 resp.raise_for_status() resp.encoding = ‘utf-8‘ return resp except (requests.RequestException, requests.Timeout) as e: print(f‘请求失败 (尝试 {i+1}/{max_retries}): {url}, 错误: {e}‘) if i < max_retries - 1: wait_time = (i + 1) * 5 # 重试等待时间递增 print(f‘等待 {wait_time} 秒后重试...‘) time.sleep(wait_time) else: print(f‘已达到最大重试次数,放弃请求: {url}‘) return None return None # 在 parse_list_page 和 parse_detail_page 中,用 safe_request 替换 requests.get response = safe_request(url) if not response: return [], None # 或返回空字典这个safe_request函数实现了简单的指数退避重试,在请求失败后等待更长的时间再试,既增加了成功率,又进一步降低了请求频率。
5.4 解析容错
网页结构可能微调,我们的解析逻辑不能太脆弱。代码中已经大量使用了.get()方法并提供默认值(‘N/A‘),以及先检查标签是否存在再操作。这是防止程序因某部电影信息缺失而崩溃的关键。
6. 数据清洗与后处理建议
爬取下来的原始数据往往需要清洗才能使用。我们的代码已经做了一些基础清洗(如提取评分人数的数字、处理上映日期和片长中的括号内容),但你可能还需要:
- 导演/编剧/主演字段拆分:这些字段目前是一个用
/分隔的字符串。如果你想分析某个导演的作品,可能需要将其拆分成列表。可以用directors.split(‘/‘)。 - 上映日期标准化:原始数据可能是“1994-09-10(加拿大)”或“1994-09-10(多伦多电影节)”。我们已经去掉了括号部分,但你可能希望将其统一转换为Python的
datetime.date对象,方便进行时间序列分析。 - 片长单位统一:片长可能是“142分钟”或“2小时22分钟”。需要编写一个函数,将所有表示法统一转换为“分钟”为单位的整数。
- 剧情简介去重:有些电影的简介在详情页有多个版本(短版和长版),需要判断并去重。
- 缺失值处理:对于标记为‘N/A‘的字段,在数据分析前需要决定是删除整行、填充默认值还是采用其他插值方法。
这些清洗步骤通常使用Pandas库来完成会非常高效。你可以将CSV读入Pandas DataFrame,然后利用其强大的字符串处理和向量化运算功能进行批量清洗。
7. 伦理、法律与最佳实践
最后,也是最重要的一部分:负责任地爬取。
- 遵守Robots协议:访问
https://movie.douban.com/robots.txt。这个文件规定了网站允许和禁止爬虫访问的路径。虽然豆瓣对TOP250页面的限制相对宽松,但查看并尊重这个协议是基本的网络礼仪。 - 控制访问频率:这是我们反复强调的。你的爬虫不应该对目标网站的正常运营造成负担。我们的延迟设置就是基于这个原则。
- 仅用于个人学习与研究:爬取的数据应限于个人分析、学习使用。切勿用于商业用途、大量公开传播或对网站进行恶意攻击。
- 识别并尊重反爬:如果网站采取了明显的反爬措施(如要求登录、验证码、返回假数据),你应该停止爬取。强行突破可能违反网站的服务条款,甚至相关法律法规。
- 数据版权:电影剧情简介、评分等数据本身可能具有版权。你整理的数据集在分享时需格外谨慎,最好只分享你分析后的结论和聚合统计结果,而非原始数据本身。
写完爬虫,成功运行并拿到数据的那一刻很有成就感。但更重要的收获是这套分析方法:从目标分析、工具选型、策略制定,到代码实现、异常处理、伦理考量。掌握了这套流程,你就拥有了从互联网上安全、高效、合规地获取结构化数据的能力,这无论是在学术研究、市场分析还是个人兴趣项目中,都是一项极具价值的技能。