
如果你想找一个入门级但又能完整体验“分析网页 → 写代码 → 存数据 → 出结果”全流程的爬虫项目豆瓣电影 Top 250 几乎是绕不开的一个经典目标。这个榜单只有 10 页、每页 25 条数据结构规整不需要登录态最关键的是拿它练手速度极快——从零到导出 CSV熟练的话半小时之内就能跑完。我第一次完整写爬虫用的就是这个页面到现在还留着当时导出的 Excel 表格。这篇内容我会把整个项目的完整思路、代码实现、常见报错和合规边界一次性讲清楚。适合已经会 Python 基础语法、想通过一个真实项目理解 requests BeautifulSoup 工作流程的读者也适合想快速给简历加一个“爬虫项目”的初学者。代码不多但每个细节我都会解释为什么这么写。1. 开工前的目标拆解Top 250 到底在爬什么1.1 榜单规模和 URL 规律豆瓣电影 Top 250 的网址是https://movie.douban.com/top250。榜单总共有 250 部电影分 10 页展示每页 25 条。翻页的规律非常规整第一页 URL 不需要额外参数第二页开始加?start25第三页是?start50以此类推最后一页是?start225。也就是说URL 里的start参数代表“从第几条开始展示”。这个规律是所有分页抓取里最简单的一种——通过递增的 start 值直接拼接 URL 即可完全不需要处理 POST 请求或者动态加载。对于刚上手爬虫的读者来说这就是最好的练习素材。如果你在浏览器里访问第二页会看到地址栏自动变成了https://movie.douban.com/top250?start25filter多出来的filter参数可以忽略它不影响抓取结果。1.2 每条电影数据有哪些可用字段在动手写代码之前最好先明确最终要导出哪些字段。根据豆瓣页面的展示结构每条电影条目通常包含以下信息字段说明是否每条都有rank排名直接显示在序号位是title电影中文名是original_title原始片名外文名是director_actor导演和主演信息合并显示是year_region_type年份 / 地区 / 类型斜杠分隔是rating_num豆瓣评分是rating_count评价人数是quote一句话短评部分条目没有字段层面最需要注意的是quote。Top 250 里绝大多数电影都有那句经典短评但确实存在少数条目没有如果解析时不做空值处理程序会直接在提取阶段报错中断。1.3 工具选型为什么不用 Scrapy 而用 requests BeautifulSoup很多初学者一提到爬虫就想到 Scrapy但对这个项目来说Scrapy 属于杀鸡用牛刀。原因有三点项目规模小Top 250 总共只有 10 个页面需要请求Scrapy 的并发调度、中间件、Pipeline 等机制在这里派不上用场。理解底层更重要用 requests 手动发请求、用 BeautifulSoup 手动解析能帮你把“HTTP 请求 → 响应 → HTML 解析 → 数据提取”这条主干理解透。直接上框架反而容易变成只会调包的“框架选手”。调试成本低requests BeautifulSoup 是纯同步脚本写错了直接看报错就能定位问题。Scrapy 的异步框架对新手来说排错成本明显更高。所以我最终的选型是requests负责网络请求BeautifulSoup4负责 HTML 解析csv和codecs负责数据落盘。2. 页面结构与数据定位先看懂 HTML 再写代码2.1 用浏览器开发者工具摸清结构写爬虫最忌讳的事情就是“不看页面直接猜”。我建议你先打开目标页面按 F12 进入开发者工具用左上角的箭头图标点一下任意一部电影标题定位到对应的 HTML 区域。豆瓣 Top 250 的页面结构层级是这样的整个列表放在ol.grid_view下面每部电影是一个li标签li内部包含了电影的所有信息。一个典型的电影条目 HTML 结构可以简化理解为li div classpic em class1/em /div div classinfo div classhd a href... span classtitle肖申克的救赎/span span classtitlenbsp;/nbsp;The Shawshank Redemption/span /a /div div classbd p导演: 弗兰克·德拉邦特 ...br1994nbsp;/nbsp;美国nbsp;/nbsp;犯罪 剧情/p div classstar span classrating_num9.7/span span1234567人评价/span /div p classquote希望让人自由。/p /div /div /li这里比较关键的是豆瓣把中文名和原始片名都放在.title这个 class 下面中文名是第 1 个.title原始片名是第 2 个.title。所以提取时要用select(.title)拿到一个列表再按索引取值而不是直接用find。2.2 字段提取的 CSS 选择器方案根据上面的 HTML 结构我使用的选择器如下排名em位于div.pic内中文名.hd .title第一个原始片名.hd .title第二个注意包含nbsp;/nbsp;前缀需要处理导演和演员信息.bd p第一个取strip()后的文本换行符替换为空格年份 / 地区 / 类型.bd p第一个取br后的部分评分.rating_num评价人数.star span最后一个一句话短评.quote .inq实际解析中我会先定位到每个li节点再在节点内做二次查找避免跨条目错位。2.3 请求头里的身份伪装为什么重要豆瓣对爬虫的检测不算特别严格但如果你直接用默认的User-Agent比如 Python 的python-requests/x.x.x去请求大概率第一次就收到 418。这里的 418 是“Im a teapot”豆瓣用它来标识“你是机器我不给你数据”。所以请求头必须手动设置。最少要带上User-Agent和Accept-Language前者让服务器认你为普通浏览器后者保证返回的页面是中文避免后续解析匹配不到对应的 class 名。3. 代码实现从单页解析到循环翻页3.1 写一个带异常处理和重试的请求函数请求是整个爬虫流程里最不稳定的环节网络抖动、服务器限流、连接超时都可能发生。所以我单独封装了一个fetch_page函数核心逻辑是每次请求设置超时时间避免永久阻塞捕获连接异常和超时异常打印状态码后重试重试次数固定为 3 次如果 3 次都失败就跳出循环或者跳过当前页示例代码如下我用time.sleep在两次请求之间强制加入延迟避免请求频率过高import requests import time HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8 } def fetch_page(url, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersHEADERS, timeout5) if resp.status_code 200: return resp.text else: print(f请求 {url} 返回状态码 {resp.status_code}) except requests.RequestException as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(2) return None这里timeout5是我实测比较稳妥的值豆瓣在国内访问速度较快5 秒足够如果网络条件差可以改成 10但没必要更长否则会拖慢整体采集速度。3.2 单页解析函数逐条提取九个字段页面解析我使用BeautifulSoup定位到ol.grid_view下所有的li元素然后对每一个li单独提取字段。下面给出解析函数的核心代码注意original_title和quote这两个字段的空值处理from bs4 import BeautifulSoup def parse_page(html): soup BeautifulSoup(html, html.parser) movie_list soup.select(ol.grid_view li) result [] for item in movie_list: rank item.select_one(div.pic em).text.strip() title_tags item.select(.hd .title) title title_tags[0].text.strip() if len(title_tags) 0 else original_title title_tags[1].text.strip().replace(\xa0/\xa0, ) if len(title_tags) 1 else bd_p item.select_one(.bd p) director_actor year_region_type if bd_p: p_text bd_p.get_text(\n).strip().split(\n) director_actor p_text[0].strip().replace(\xa0, ) if len(p_text) 0 else year_region_type p_text[1].strip().replace(\xa0, ) if len(p_text) 1 else rating_num item.select_one(.rating_num).text.strip() if item.select_one(.rating_num) else star_spans item.select(.star span) rating_count star_spans[-1].text.strip() if star_spans else quote_elem item.select_one(p.quote span) quote quote_elem.text.strip() if quote_elem else result.append([ rank, title, original_title, director_actor, year_region_type, rating_num, rating_count, quote ]) return result这里之所以用select_one(.bd p)再手动处理换行是因为豆瓣把导演信息和年份信息放在同一个p标签里中间用br隔开。get_text(\n)会把br转成换行符这样就能切成一个列表分别取。3.3 翻页采集和进度输出所有页面 URL 可以通过列表推导式直接生成def crawl_top250(): all_data [] for i in range(10): start i * 25 url fhttps://movie.douban.com/top250?start{start} html fetch_page(url) if html: page_data parse_page(html) all_data.extend(page_data) print(f第 {i 1} 页采集完成累计 {len(all_data)} 条) else: print(f第 {i 1} 页采集失败跳过) time.sleep(1.5) return all_datatime.sleep(1.5)是给服务器的“礼貌间隔”。豆瓣对单一 IP 的请求频率比较敏感1.5 秒已经足够安全不需要刻意加长。3.4 CSV 导出编码和换行两个坑导出数据我用的是 Python 标准库csv但在写文件时有三个细节必须处理否则踩坑踩到你怀疑人生第一个是编码问题。直接写 csv 的时候用utf-8编码你用记事本打开没问题但用 Excel 打开就会出现乱码。解决方案是改用utf-8-sig编码这种编码会在文件开头加一个 BOM 头Excel 能正确识别为 UTF-8 编码。第二个是换行问题。csv.writer写文件时如果按默认方式打开会在每一行后面多出一个空行。原因是 Python 的文件读写默认开启了换行转换而csv模块本身也会处理换行两者叠加就产生了空行。解决办法是打开文件时加一个newline参数。第三个是表头顺序。建议表头顺序和页面展示顺序保持一致便于人工核验。示例代码import csv def save_to_csv(data, filenamedouban_top250.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([排名, 电影名, 原始片名, 导演/主演, 年份/地区/类型, 评分, 评价人数, 一句话短评]) writer.writerows(data) print(f数据已保存到 {filename})4. 反爬机制与合规采集实操中必须跨过的坎4.1 常见的反爬信号状态码和页面内容缺失即使你按照前面的方法设置了请求头依然可能遇到各种反爬问题。最直观的表现是两点第一点是状态码异常。如果你收到403说明服务器识别到你不是正常访问如果收到418说明触发了反爬策略需要检查User-Agent是不是被识别为 Python 客户端。第二点是页面返回了但结构不完整。这种情况最隐蔽——状态码是 200但解析结果为空或者条目数量不是 25 条。遇到这种问题先把返回的 HTML 保存到本地用浏览器打开看看大概率是一段验证码页面或者“请求过于频繁”的提示页。我实测下来豆瓣 Top 250 在正常请求频率下每页间隔 1.5 秒以上基本不会触发验证码。但如果一次性连续请求 10 页不间隔或者之前已经高频访问过就容易被临时限流。4.2 限流应对策略随机等待和固定间隔的选择进阶一点的做法是把固定的time.sleep(1.5)改成随机间隔。比如在 1.2 到 2.8 秒之间随机取值模拟人类浏览的节奏。这一步不是必需的但如果你需要频繁采集豆瓣数据建议加上import random time.sleep(random.uniform(1.2, 2.8))随机间隔的主要作用是让请求时间序列不呈现均匀分布从而降低被算法识别为机器的概率。对于 10 个页面的小任务固定间隔和随机间隔差别不大但养成分散请求的习惯对后续做更大规模的采集有好处。4.3 合规边界爬虫可以学但数据不能乱用这一点必须多说几句。爬虫本身是一门技术但技术的使用有边界。在这个项目中你需要知道几件事robots 协议豆瓣的 robots.txt 对部分路径设置了访问限制Top 250 这类公开榜单页面属于公开数据学习用途的爬取在行业内普遍被视为合理行为但仍建议控制频率。个人学习 vs. 商业使用如果只是学习、练习、做数据分析采集公开榜单数据问题不大但如果要用于商业产品、批量分发、二次加工盈利就必须评估版权和数据合规风险。不越权采集不要试图采集需要登录才能看到的用户私有数据不要绕过登录态更不要使用高并发去压垮目标服务器。代码实践与法律意识并重写爬虫的前提是尊重目标网站的规则如果目标网站明确禁止爬取或者有条款限制就要谨慎评估自己的行为。关于使用本文代码具体的合规判断请自行确认毕竟技术本身是中立的关键在用途。5. 一键运行完整代码与常见报错排查5.1 完整脚本把上面所有模块合并成一个完整脚本就能实现“一键采集并导出 CSV”。完整代码如下import requests import time import csv import random from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9,en;q0.8 } def fetch_page(url, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersHEADERS, timeout5) if resp.status_code 200: return resp.text else: print(f请求 {url} 返回状态码 {resp.status_code}) except requests.RequestException as e: print(f第 {attempt 1} 次请求失败: {e}) time.sleep(2) return None def parse_page(html): soup BeautifulSoup(html, html.parser) movie_list soup.select(ol.grid_view li) result [] for item in movie_list: rank item.select_one(div.pic em).text.strip() title_tags item.select(.hd .title) title title_tags[0].text.strip() if len(title_tags) 0 else original_title title_tags[1].text.strip().replace(\xa0/\xa0, ) if len(title_tags) 1 else bd_p item.select_one(.bd p) director_actor year_region_type if bd_p: p_text bd_p.get_text(\n).strip().split(\n) director_actor p_text[0].strip().replace(\xa0, ) if len(p_text) 0 else year_region_type p_text[1].strip().replace(\xa0, ) if len(p_text) 1 else rating_num item.select_one(.rating_num).text.strip() if item.select_one(.rating_num) else star_spans item.select(.star span) rating_count star_spans[-1].text.strip() if star_spans else quote_elem item.select_one(p.quote span) quote quote_elem.text.strip() if quote_elem else result.append([rank, title, original_title, director_actor, year_region_type, rating_num, rating_count, quote]) return result def crawl_top250(): all_data [] for i in range(10): start i * 25 url fhttps://movie.douban.com/top250?start{start} html fetch_page(url) if html: page_data parse_page(html) all_data.extend(page_data) print(f第 {i 1} 页采集完成累计 {len(all_data)} 条) else: print(f第 {i 1} 页采集失败跳过) time.sleep(random.uniform(1.2, 2.8)) return all_data def save_to_csv(data, filenamedouban_top250.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([排名, 电影名, 原始片名, 导演/主演, 年份/地区/类型, 评分, 评价人数, 一句话短评]) writer.writerows(data) print(f数据已保存到 {filename},共 {len(data)} 条) if __name__ __main__: movies crawl_top250() save_to_csv(movies)5.2 运行结果验证运行脚本后你会在同级目录下看到douban_top250.csv。用 Excel 打开表头正常显示中文250 行数据完整。如果数据不是 250 条可能是某个页面请求失败或解析出错此时优先检查输出日志里有没有“请求失败”提示。5.3 常见异常与解决方案速查表异常表现可能原因解决方案ConnectionError或超时网络不稳定 / 目标服务暂时不可达检查网络调大 timeout稍后重试HTTP 403请求头缺失或 User-Agent 被识别补充完整请求头使用浏览器 UAHTTP 418请求过于频繁触发反爬增加 sleep 间隔降低请求频率解析结果为空返回的是验证码页而非目标页保存 HTML 到本地检查内容CSV 用 Excel 打开乱码编码用的是 utf-8 而非 utf-8-sig改用encodingutf-8-sigCSV 每行之间有空行打开文件时没有加newline加上newline参数写在最后的一点实操体会这个项目我前前后后写过好几个版本早期用的是正则表达式提取代码非常脆弱页面结构一变动就要大批量重写后来换了 BeautifulSoup健壮性好了很多结构变化时只需要改选择器即可。如果你的目标是学会爬虫而不是把豆瓣数据拿到手建议你拿到代码后不要只跑一遍就结束而是动手改一改比如把数据也存一份 JSON或者在解析逻辑里加入断点调试看看每一步提取出的中间结果是什么。顺着这个项目继续拓展比较自然的下一步是爬取每部电影详情页的更多信息比如演职人员列表、剧情简介或者爬一个分类排行榜对比不同榜单的数据差异。无论往哪个方向走认真把这个项目跑透你对爬虫的理解都会比单纯看教程要深入得多。