豆瓣电影 TOP250 几乎是所有做数据采集的人绕不开的一张“入门试卷”。我自己的第一份像样的采集脚本,就是从抓这个榜单开始的。它不涉及登录、不需要处理复杂的动态渲染,页面结构也足够规整,字段丰富程度刚刚好——电影排名、片名、年份、地区、类型、评分、评价人数、经典台词都有。正因如此,它既能让你把 Python 请求、解析、清洗、存储这条链路完整跑通,又能顺手把数据分析的活接了,是一个性价比极高的个人练手项目。
这篇博文主要围绕“豆瓣电影 TOP250 采集”这件事展开,适合刚开始接触网络数据采集的 Python 学习者,也适合想要复习 requests + BeautifulSoup 这套经典组合的开发者。我会把方案选型、页面分析、代码实现、常见踩坑、后续分析扩展一条线讲透。你跟着操作完,手里的产出不是一段能跑的脚本,而是一份可直接拿去做分析的 250 条结构化数据。
1. 项目概述与核心思路
1.1 为什么选这个榜单作为入门切入点
很多人一开始学爬虫,总想着去抓那些有难度、有挑战的目标,比如需要登录的社区、动态加载的信息流、甚至带反爬验证的平台。但我的建议一直是:入门阶段先打简单牌。榜单页面恰好是一个“难易度刚刚好”的样本。
先看它的特点:静态 HTML 页面,数据直接渲染在源码里,不需要分析异步接口;不要求登录;没有复杂的数据加密;分页规律清晰,每页 25 条、共 10 页。这意味着你只需要掌握三件事:用 requests 发起请求、用 BeautifulSoup 从 HTML 中提取字段、用一个循环把 10 页数据串起来。这套过程覆盖了绝大多数采集场景的基础环节,同时又不会因为反爬策略太狠而劝退新手。
更重要的是,这个榜单的字段质量很高。排名连续从 1 到 250,年份、评分、评价人数都是数值型数据,地区、导演、演员、类型又是分类文本。抓下来之后,既能做“评分分布”“年代分布”这类统计,也能做一些简单的可视化,刚好衔接到数据分析那一环。所以我一直觉得它不只是一个“爬虫练习”,而是一个从采集到分析的完整闭环。
1.2 技术方案选型:requests + BeautifulSoup 为什么够用
项目一开始,就要面对工具选型的问题。我见过不少人上来就选 Scrapy,甚至 Selenium,结果光配置环境就花了一晚上。这里有必要说清楚:不是框架越重越好,而是要根据目标特征匹配工具。
requests 是最基础的 HTTP 请求库,它做的事情很简单——把 HTTP 请求发出去,拿到响应内容。你可以把它理解成快递员,负责把“我要这个页面”的包裹送到服务器,再把服务器返回的包裹带回来。BeautifulSoup 则是一个 HTML 解析器,负责把快递包裹里的 HTML 文档拆开,找到你要的那件“商品”。对榜单这种静态页面,这两个库的组合完全够用。
那为什么不推荐 Selenium?因为 Selenium 会启动一个浏览器实例,逐条模拟点击和滚动,效率低、资源占用高。对于不需要 JavaScript 渲染的页面,属于杀鸡用牛刀。为什么不推荐 Scrapy?Scrapy 是专业级框架,自带并发调度、中间件、Pipeline,功能强大,但学习成本也高。对于只有 250 条数据的小项目,手写 requests 循环反而更直观,你能看清每一步发生了什么,而不是被框架的黑盒逻辑带跑。
| 工具 | 适用场景 | 学习成本 | 本项目是否需要 |
|---|---|---|---|
| requests | 普通 HTTP 请求 | 低 | 需要 |
| BeautifulSoup | 静态 HTML 解析 | 低 | 需要 |
| Selenium | 动态页面、需要模拟操作 | 高 | 不需要 |
| Scrapy | 大规模并发采集 | 中高 | 不需要 |
1.3 一次完整的数据链路
整个项目从零到产出,逻辑链路是这样的:
- 构造榜单页请求,带上合理的请求头。
- 接收服务器返回的 HTML 文档。
- 用 BeautifulSoup 解析文档,定位每条电影数据所在的节点。
- 从节点中提取排名、片名、年份、评分、评价人数、经典台词等字段。
- 对字段做清洗,比如把“年份(地区)”拆出四位年份,把评价人数从文本转成整数。
- 存入 CSV 文件,供后续分析使用。
- 做数据校验,确认 250 条数据完整、字段无异常。
这套链路听起来简单,实际运行时每一环都有值得注意的细节。比如请求头不带全,页面可能直接返回异常;解析选择器写错一个 class,整页数据可能抓不全;CSV 编码没处理好,用 Excel 打开就是乱码。这些坑我会在第 4 节专门展开讲。
1.4 关于合规使用的一点提醒
既然聊到了采集,我先把最要紧的话放在前面:这个项目只针对公开榜单的展示数据进行学习研究,不涉及任何个人隐私数据,也不建议把抓下来的数据用于商业用途。实操时注意控制请求频率,不要给目标站点造成压力。技术本身是中性的,但使用方式要有边界。
2. 环境准备与请求前置细节
2.1 依赖安装与虚拟环境
开始写代码之前,先把环境准备好。我的建议是每一个 Python 项目都用虚拟环境,避免不同项目的第三方库互相污染。在项目目录下执行:
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate激活环境后,安装本次需要用到的库:
pip install requests beautifulsoup4 lxml pandas这里特别说一下为什么用 lxml 而不是 BeautifulSoup 默认的 html.parser。lxml 是一个 C 语言实现的解析器,解析速度更快,容错性也更好。HTML 文档如果有一些不规范的地方,lxml 也能尽量帮你兜住。对于这个项目,数据量不大,速度差异其实感知不明显,但养成用 lxml 的习惯没坏处,以后遇到大数据量的解析任务会更从容。
pandas 在这里用来处理和存储数据。其实只用 csv 模块也可以完成任务,但 pandas 的数据清洗接口更顺手,后面做分析扩展时也能直接复用。
2.2 请求头伪装与反爬初体验
用 requests 发请求,最基础的代码就是两行:
import requests url = "https://movie.douban.com/top250?start=0" resp = requests.get(url) print(resp.status_code)如果你的网络环境正常,这段代码大概率能拿到 200 状态码,但页面内容可能不是你想要的。很多站点会校验请求头里的 User-Agent 字段。如果发现 User-Agent 是 Python 的默认值,比如python-requests/2.31.0,服务器就能一眼识别出这不是浏览器访问,从而拒绝返回正常内容,或者把你丢到一个安全验证页面。
正确的做法是在请求时带上一个看起来像真实浏览器的 User-Agent:
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" } resp = requests.get(url, headers=headers)另外,响应内容的编码也要注意。榜单页面是 UTF-8 编码,但有些情况下 requests 会因为响应头没有明确 charset 而猜错编码,导致中文乱码。稳妥的做法是拿到响应后手动指定:
resp.encoding = "utf-8"在真正开始解析之前,可以先打印resp.status_code、resp.url和resp.text[:500],确认我们拿到的是期望的页面,再往下走。这一步相当于“验货”,能省掉后面很多排查时间。
2.3 页面结构分析:确认数据在哪里
写解析代码之前,必须先搞清楚目标数据在 HTML 里长什么样。这一步最直接的方式是用浏览器打开榜单页,按 F12 打开开发者工具,用元素选择器定位一条电影记录。
榜单页中,每一部电影都包裹在一个li标签里,整个列表由一个ol容器组织。进一步看,每条电影数据的关键位置大致是这样:
- 排名:在
.pic区块里的em标签内。 - 片名:在
.info区块里的.hd下第一个.title标签内。 - 导演、主演、年份、地区、类型:集中在
.bd > p的文本里,用换行和/分隔。 - 评分:在
.bd > .star > .rating_num内。 - 评价人数:在
.bd > .star > .people内。 - 经典台词:在
.bd > .quote > .inq内,但并不是每条都有。
这里强调一点:页面结构可能会随站点改版而变化,所以不要长期照抄别人的选择器。你自己打开控制台,确认了 class 名再去写解析代码,才是最可靠的。这个习惯比直接复制代码重要得多。
3. 采集代码实现与核心环节拆解
3.1 单页解析:从列表节点到结构化字段
先写一个函数,专门负责请求某一页并解析其中的电影条目:
import requests from bs4 import BeautifulSoup def fetch_page(start): url = "https://movie.douban.com/top250" params = {"start": start, "filter": ""} 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" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.encoding = "utf-8" if resp.status_code != 200: print(f"请求失败,状态码: {resp.status_code}") return [] soup = BeautifulSoup(resp.text, "lxml") items = soup.select("ol.grid_view li") return parse_items(items)parse_items函数负责提取单条数据。注意字段缺失的处理,尤其是经典台词,很多电影没有这一项:
def parse_items(items): data = [] for item in items: rank = item.select_one(".pic em").text.strip() title = item.select_one(".hd .title").text.strip() info_text = item.select_one(".bd p").text.strip() # 年份等细节在 info 文本的第二行,比如 "1994 / 导演: 某某 / 主演: 某某 / 类型: 剧情" year_line = info_text.split("\n")[1].strip() if len(info_text.split("\n")) > 1 else info_text year = extract_year(year_line) rating = item.select_one(".rating_num").text.strip() people_text = item.select_one(".star .people").text.replace("人评价", "").strip() quote_el = item.select_one(".quote .inq") quote = quote_el.text.strip() if quote_el else "" data.append({ "排名": int(rank), "片名": title, "上映年份": year, "评分": float(rating), "评价人数": int(people_text), "经典台词": quote }) return data关于年份提取,我单独写了一个小函数。榜单里年份往往和导演、地区、类型混在一行文本里,用正则提取最方便:
import re def extract_year(text): match = re.search(r"(\d{4})", text) return int(match.group(1)) if match else None正则在这里的作用是“锚定四位数字”,哪怕前后有其他中文或符号,也能精准取出年份。这是处理混合文本时的常用手法。
3.2 循环翻页与请求节奏控制
榜单共 10 页,每页 25 条,翻页参数是start,从 0 开始,每页增加 25,分别是 0、25、50……225。所以只需要一个range就能生成所有页码:
all_movies = [] for start in range(0, 250, 25): page_data = fetch_page(start) all_movies.extend(page_data) print(f"已采集 start={start},当前累计 {len(all_movies)} 条")这里必须强调节奏控制。不要一口气把 10 个请求在几秒内全部发出去,那样很容易被服务端识别为异常访问,轻则触发限流,重则暂时封禁 IP。稳妥的做法是在每次请求之间加一个随机延时:
import time import random time.sleep(random.uniform(1, 3))随机延时的意思是让请求间隔看起来更像人工操作,而不是固定间隔的机器行为。数值不需要很大,1 到 3 秒就足够表达“我是个人在手动翻页”的节奏。
3.3 数据清洗与 CSV 落地
数据解析出来后,直接存 CSV 是没问题的,但我建议先做一轮清洗。解析阶段虽然已经做了类型转换,但还不够严谨。比如“评价人数”字段,原文是“1281719人评价”,如果直接用.replace("人评价", ""),有些页面可能写法稍有不同,替换不干净,转int时就会报错。更稳妥的做法是只提取数字:
people_int = int(re.search(r"\d+", people_text).group())清洗完所有字段后,用 pandas 写入 CSV:
import pandas as pd df = pd.DataFrame(all_movies) df = df.sort_values("排名").reset_index(drop=True) df.to_csv("douban_top250.csv", index=False, encoding="utf-8-sig")用utf-8-sig编码而不是utf-8,是为了兼容 Excel。Excel 默认用 GBK 系列编码读取 CSV,如果直接写 UTF-8 无 BOM,中文列名和内容在 Excel 里会乱码。加上utf-8-sig之后,Excel 会通过文件开头的 BOM 自动识别编码,打开就是正常中文。
3.4 完整代码整合
把流程整合成一份完整脚本,方便你直接对照跑通:
import re import time import random import requests import pandas as pd 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" } BASE_URL = "https://movie.douban.com/top250" def fetch_page(start): params = {"start": start, "filter": ""} resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=10) resp.encoding = "utf-8" if resp.status_code != 200: print(f"请求失败,状态码: {resp.status_code}") return [] soup = BeautifulSoup(resp.text, "lxml") items = soup.select("ol.grid_view li") return parse_items(items) def extract_year(text): match = re.search(r"(\d{4})", text) return int(match.group(1)) if match else None def parse_items(items): data = [] for item in items: rank_el = item.select_one(".pic em") title_el = item.select_one(".hd .title") info_el = item.select_one(".bd p") rating_el = item.select_one(".rating_num") people_el = item.select_one(".star .people") quote_el = item.select_one(".quote .inq") if not (rank_el and title_el and info_el and rating_el and people_el): continue detail = info_el.text.strip() people_text = people_el.text.strip() people_match = re.search(r"\d+", people_text) data.append({ "排名": int(rank_el.text.strip()), "片名": title_el.text.strip(), "上映年份": extract_year(detail), "评分": float(rating_el.text.strip()), "评价人数": int(people_match.group()) if people_match else 0, "经典台词": quote_el.text.strip() if quote_el else "" }) return data def main(): all_movies = [] for start in range(0, 250, 25): page_data = fetch_page(start) all_movies.extend(page_data) print(f"已采集 start={start},当前累计 {len(all_movies)} 条") time.sleep(random.uniform(1, 3)) df = pd.DataFrame(all_movies) df = df.sort_values("排名").reset_index(drop=True) df.to_csv("douban_top250.csv", index=False, encoding="utf-8-sig") print(f"采集完成,共保存 {len(df)} 条数据到 douban_top250.csv") if __name__ == "__main__": main()这份代码的核心设计是“函数拆分 + 主入口调度”。fetch_page只负责请求和解析,parse_items只负责字段提取,main负责循环和存储。每个函数各司其职,出了问题可以单独调试,不需要翻完整段代码找原因。这个习惯适用于所有采集脚本。
4. 常见问题与排查技巧实录
4.1 请求成功但解析不到数据
这是新手遇到最多的问题:resp.status_code明明是 200,但soup.select("ol.grid_view li")返回空列表,或者soup里的内容和浏览器打开看到的不一致。
原因多数是请求被拦截后返回了一个安全验证页或异常提示页,状态码虽然还是 200,但页面内容和预期完全不一样。排查方法很简单:先把源代码局部打印出来看。
print(resp.text[:500])如果看到的内容里有“检测”“异常”“验证”之类的字眼,说明请求头伪装还不够。把 User-Agent 换成更完整的浏览器身份,再补上常见的 Accept、Accept-Language 请求头,通常就能解决。
另一个容易忽略的问题是参数传法。如果你把?start=25&filter=整个拼在 URL 里,偶尔会因为特殊字符编码问题导致请求异常。用params字典传参,让 requests 自己处理 URL 拼接,更规范。
4.2 字段提取不完整或错位
解析阶段常见的第二个坑是选择器匹配范围过宽。比如.title可能匹配到多个元素,除了中文片名,还有英文片名和副标题。如果你只用.select_one(".title"),拿到的很有可能是第一个匹配项,表面上没问题,但一旦页面某条数据结构排列不同,取到的字段就会串位。
稳妥的做法是缩小范围,用.hd .title先定位到标题容器再取内部节点。另外,经典台词这种可空字段,一定要先判断元素是否存在再做.text操作,不然遇到没有台词的电影会直接抛AttributeError。
再提醒一个细节:people_el.text里除了数字可能还有空格、换行,所以re.search(r"\d+")这种正则方式比直接字符串替换更稳定。把“清洗字段”的思路贯彻到底,你的代码可迁移性会强很多。
4.3 请求到后面几页突然被限流
前 3 页采集正常,到第 4、5 页时偶尔出现超时或状态码 403,这是典型的访问频率过快。
不要头铁继续试,先把延时调大。如果当前用 1 秒固定延时,改成random.uniform(1, 3)还不够,就调到 2 到 4 秒。250 条数据总共就 10 页请求,即使每页等 3 秒,总耗时也就 30 秒左右,完全在可接受范围内。为了省这几秒把 IP 弄进小黑屋,得不偿失。
另外,代码里可以加一个简单的失败重试逻辑。单页请求失败后,不要直接跳过,而是等待几秒重试一次:
def request_with_retry(url, params, headers, retries=3): for i in range(retries): try: resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code == 200: return resp except requests.RequestException: pass time.sleep(random.uniform(2, 4)) return None重试机制是采集脚本的“安全气囊”,它不能保证 100% 成功,但能显著降低临时性网络波动带来的失败率。
4.4 Windows 控制台打印中文乱码
脚本跑起来后,print输出的中文如果出现乱码,通常是 Windows 终端默认编码不是 UTF-8。这个问题不影响数据写入文件,但影响调试体验。
解决方法是在脚本入口处给标准输出重新指定编码:
import sys sys.stdout.reconfigure(encoding="utf-8")如果 Python 版本较老,没有reconfigure方法,也可以在命令行执行时设置环境变量:
set PYTHONIOENCODING=utf-8下面是常见问题速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 状态码 200 但数据为空 | 返回了验证页 | 完善请求头,检查打印内容 |
| 提取字段为空 | 选择器过宽、结构变了 | 用更精确的选择器,F12 重新定位 |
| 某条数据导致脚本中断 | 空字段直接 .text | 先用 if 判断元素是否存在 |
| 后几页请求失败 | 访问频率过高 | 加随机延时,加重试逻辑 |
| CSV 在 Excel 打开乱码 | 编码不对 | 用utf-8-sig保存 |
5. 采集完成后的数据分析与扩展思路
5.1 数据质量校验
采集完成不等于工作结束。拿到 CSV 之后,第一步应该做数据质量校验,而不是直接开始做图。用 pandas 读入文件,检查几条基本指标:
import pandas as pd df = pd.read_csv("douban_top250.csv") print(df.info()) print(df["排名"].is_monotonic_increasing) print(df["排名"].nunique())is_monotonic_increasing检查排名是否连续递增,nunique()检查是否有重复。如果总数不足 250 或排名有缺口,说明采集过程有页面丢失,需要回头补抓。这步做踏实了,后续分析的可信度才有保障。
5.2 三个快速上手的分析方向
数据校验通过后,可以做几个简单又直观的分析:
- 评分分布。看一下 250 部电影的评分集中在什么区间。用
df["评分"].value_counts().sort_index().plot.bar()就能快速出图,大概率能看到高分片集中在 8.5 到 9.3 之间的规律。 - 上映年代分布。从年份字段分出十年区间,统计每个年代的入选数量。可以看到榜单里哪个年代的作品最多,通常上个世纪九十年代和本世纪初的比例会比较高,这也从侧面反映经典电影的沉淀周期。
- 地区分布。榜单中不同国家和地区的电影数量对比,能看出评分体系和选片眼光里的地域倾向性。
这些分析都不复杂,但组合在一起就能形成一份“TOP250 榜单画像”,用来练手数据可视化和业务解读绰绰有余。
5.3 从采集到分析的链路延伸
项目跑通之后,如果还想往深处走,方向其实很多。存储层面,可以把 CSV 换成 SQLite 或 PostgreSQL,让数据支持更灵活的查询;采集层面,可以把 requests 循环改成异步并发,或者引入 Scrapy 框架,提高采集效率;分析层面,可以引入分词技术对经典台词做词频统计,也可以结合多榜单数据做对比分析。
但我个人的经验是,不要一上来就追求“大而全”。先把这个 250 条数据的小项目做扎实,理解请求、解析、清洗、存储、分析这条链路里每一个环节为什么这么做,比盲目堆技术栈更重要。小项目恰恰是暴露问题成本最低的地方,等这套逻辑内化之后,再去碰分布式采集、大规模数据清洗,你会发现自己已经有了很清楚的问题拆解思路。
最后再说一点:自动化采集这件事,真正考验一个人的往往不是技术,而是“控制欲望”。控制请求频率,控制采集边界,控制自己对数据的合理使用。把这些想清楚了,技术上迈开的每一步才走得稳。