Python爬虫实战:Requests+BeautifulSoup抓取豆瓣电影TOP250数据
1. 项目缘起为什么需要自己动手爬取豆瓣TOP250作为一名影迷兼数据爱好者我经常遇到一个尴尬想找一部高分电影打开豆瓣TOP250榜单翻了几页发现信息太零散想整理成自己的片单或者做个简单的数据分析手动复制粘贴简直是一场噩梦。排名、评分、导演、上映年份……这些信息如果能规整地躺在表格里那该多方便。市面上虽然有一些现成的数据集但要么数据陈旧要么字段不全最关键的是你无法控制数据的获取过程和格式遇到网站改版或者反爬策略调整就只能干瞪眼。所以自己动手写一个爬虫从豆瓣电影TOP250页面把这些核心信息“拿”下来就成了一件既有学习价值又有实用价值的事情。这不仅仅是获取数据更是一个理解网页结构、处理网络请求、解析HTML文档、应对反爬机制的完整实战。通过这个项目你不仅能得到一份干净、实时、可定制的电影数据更能掌握一套通用的数据采集思路以后遇到任何需要从网页抓取信息的场景都能从容应对。2. 核心目标拆解与工具选型我们的目标非常明确访问豆瓣电影TOP250的列表页遍历每一部电影进入其详情页然后提取标题、排名、评分、链接、导演、编剧、主演、类型、上映日期、片长、评分人数和剧情简介这12个核心字段。要实现这个目标我们需要几个关键工具选择它们的原因如下1. 网络请求库Requests这是Python生态中公认最简洁、最人性化的HTTP库。相比于更底层的urllibRequests的API设计让发送GET、POST请求变得像说话一样简单。对于豆瓣这种主要以静态页面呈现的网站Requests完全够用。我们用它来获取网页的HTML源代码。2. 网页解析库BeautifulSoup从一堆HTML标签中精准地找到我们需要的数据就像大海捞针。BeautifulSoup就是那根“磁铁”。它能够将复杂的HTML文档转换成一个复杂的树形结构解析树然后让我们通过标签名、CSS类名、ID等属性像在文件系统中导航一样轻松地找到目标数据。它的学习曲线平缓对于本项目这种中等复杂度的解析任务再合适不过。3. 为什么不是ScrapyScrapy是一个强大的、异步的爬虫框架适合构建大型、复杂的爬虫项目。但对于我们这次“单页列表多详情页”的、目标明确且规模固定的任务来说使用Scrapy有点“杀鸡用牛刀”的感觉其学习成本和项目结构复杂度会显著增加。RequestsBeautifulSoup的组合更加轻量、直接也更利于初学者理解和掌握每一个步骤。4. 辅助工具Time Random这两个Python内置库至关重要。time.sleep()用于在请求之间插入随机间隔模拟人类浏览行为这是对目标网站最基本的尊重也是避免IP被封锁的最简单有效的方法。random库用来生成随机的等待时间让爬虫行为更“人性化”。5. 数据存储CSV文件最终提取的数据我们将保存为CSV逗号分隔值格式。CSV文件可以用Excel、Numbers直接打开也可以用Pandas进行进一步分析通用性极强。Python内置的csv模块就能很好地完成写入任务。整个工具的选型思路就是在满足需求的前提下选择最简单、最直接、最易维护的方案。3. 实战第一步分析页面结构与制定抓取策略在写任何代码之前我们必须像侦探一样仔细勘察“现场”——豆瓣TOP250的页面。3.1 列表页分析打开豆瓣电影TOP250的页面通过浏览器的开发者工具F12查看其HTML结构。列表容器所有电影条目都包裹在一个classgrid_view的ol标签内。单个电影条目每个li标签代表一部电影。这里面已经包含了我们需要的部分信息排名在classpic的div里em标签内的数字就是排名。中文片名在classhd的div里第一个span classtitle里的文本就是中文名注意有些外国电影可能还有英文名在第二个span里。详情页链接在classhd的div里a标签的href属性就是这部电影详情页的URL。评分在classstar的div里span classrating_num中的文本。评分人数在classstar的div里span标签内的文本通常格式是“XXX人评价”需要用正则表达式或字符串处理提取数字。3.2 详情页分析点击任意一部电影的链接进入详情页这里信息更丰富。导演、编剧、主演这些信息通常在span classattrs的标签内但我们需要通过其前面的文本如“导演”、“编剧”来定位。一个可靠的方法是找到ID为info的div然后在这个区块内进行文本搜索和分割。类型、上映日期、片长同样位于ID为info的div内格式规整可以通过冒号“:”或斜杠“/”进行分割提取。剧情简介简介可能位于span propertyv: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, headersHEADERS) 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, headersHEADERS) 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(‘|‘, stripTrue) # 用‘|‘替换换行符得到连续文本 # 使用更健壮的方法按行分割后处理 lines info_div.get_text(‘\n‘, stripTrue).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(‘ ‘, stripTrue) # 处理简介中的空白字符和换行 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, fieldnamesfieldnames) 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但这还不够。一个真实的浏览器请求会携带很多头部信息。我们可以进一步完善HEADERSHEADERS { ‘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/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8‘, ‘Accept-Language‘: ‘zh-CN,zh;q0.9,en;q0.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, headersHEADERS, proxiesPROXIES)5.3 异常处理与重试机制网络请求充满不确定性。我们必须假设请求可能会失败并做好应对。def safe_request(url, max_retries3): 带重试机制的请求函数 for i in range(max_retries): try: time.sleep(random.uniform(1, 3)) resp requests.get(url, headersHEADERS, timeout10) # 设置超时 resp.raise_for_status() resp.encoding ‘utf-8‘ return resp except (requests.RequestException, requests.Timeout) as e: print(f‘请求失败 (尝试 {i1}/{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页面的限制相对宽松但查看并尊重这个协议是基本的网络礼仪。控制访问频率这是我们反复强调的。你的爬虫不应该对目标网站的正常运营造成负担。我们的延迟设置就是基于这个原则。仅用于个人学习与研究爬取的数据应限于个人分析、学习使用。切勿用于商业用途、大量公开传播或对网站进行恶意攻击。识别并尊重反爬如果网站采取了明显的反爬措施如要求登录、验证码、返回假数据你应该停止爬取。强行突破可能违反网站的服务条款甚至相关法律法规。数据版权电影剧情简介、评分等数据本身可能具有版权。你整理的数据集在分享时需格外谨慎最好只分享你分析后的结论和聚合统计结果而非原始数据本身。写完爬虫成功运行并拿到数据的那一刻很有成就感。但更重要的收获是这套分析方法从目标分析、工具选型、策略制定到代码实现、异常处理、伦理考量。掌握了这套流程你就拥有了从互联网上安全、高效、合规地获取结构化数据的能力这无论是在学术研究、市场分析还是个人兴趣项目中都是一项极具价值的技能。

相关新闻

最新新闻

日新闻

周新闻

月新闻