学爬虫绕不开豆瓣 Top 250,这话放在今天依然没错。很多人第一次接触 Python 爬虫,第一个完整跑通的项目就是抓豆瓣电影榜单;我第一次完整写爬虫时抓的也是它。原因很简单:页面结构规整、URL 规律明确、数据字段丰富,又能把请求、解析、清洗、存储这条完整的链路走一遍。这篇文章就把整个实战过程拆开讲清楚,从页面分析到最终落库,包含完整代码和我在实抓过程中踩过的坑,适合刚学完 Python 基础、想找个正经项目练手的读者。
我用的是目前最主流的方案:requests负责请求页面,BeautifulSoup+lxml负责解析 HTML,数据分别写入 CSV 和 SQLite。这个组合不算最前沿,但胜在稳定、容易调试,而且能帮你把“抓取→解析→存储”这条主线彻底弄明白。看完这篇文章,换一个目标网站,你也能自己上手拆解。
1. 练手项目为什么要选豆瓣 Top 250
1.1 这个榜单页面恰好覆盖爬虫的完整链路
先说项目目标:把豆瓣电影 Top 250 榜单上的电影数据抓下来,包括排名、电影名、导演和主演、年份、地区、类型、评分、评价人数、一句话简介,以及每部电影的详情页链接,一共 250 条。数据量不大,但很完整,足够用来练习。
选这个页面做实战有几层原因。第一,页面是静态 HTML,数据直接渲染在页面源码里,不用处理 JavaScript 动态加载,对初学者友好。第二,榜单按“每页 25 条、一共 10 页”分页,URL 参数规律极其明显,适合拿来理解分页抓取的原理。第三,每条电影记录有多个不同语义的字段,抓下来之后要做清洗和类型转换,这个过程能让你体会到“从 HTML 字符串到结构化数据”到底要经过哪些步骤。第四,豆瓣的反爬不算特别激进,只要正常控制频率、伪装好请求头,就能顺利跑完整个项目。
对比一下其他练手目标:抓静态新闻列表太简单,抓一线电商平台又容易触发严格的反爬机制,一上来就把新手劝退。Top 250 这个难度阶梯刚好卡在“有点挑战但够得着”的位置。
1.2 环境准备与核心依赖选型
动手之前,先确认环境。我假设你已经装好了 Python 3.8 以上的版本,然后需要安装三个库:
pip install requests beautifulsoup4 lxmlrequests:发起 HTTP 请求,拿网页源码。它比 Python 自带的urllib好用太多,自动处理编码、会话、请求头,是爬虫入门的标配。beautifulsoup4:解析 HTML 文档,提供select()、find()这类易读的 API,定位元素非常方便。lxml:HTML 解析器,解析速度比 Python 标准库的html.parser快得多,配合 BeautifulSoup 使用是经典组合。
不建议一上来就学 Scrapy、Playwright 这类重框架。先把 requests + BeautifulSoup 跑通,把原理吃透,后面升级到 Scrapy 时你才能理解它到底帮你解决了什么问题,而不是照抄配置。
1.3 先说合规边界
爬虫不是“能抓到”就行,动手前必须把红线划清楚。豆瓣的robots.txt限制了爬取路径,Top 250 页面并没有被禁止抓取,但这不等于可以随便暴力访问。实际操作时要注意三点:请求频率控制在合理范围,比如每抓一页间隔 3 到 5 秒;不把数据用于商业用途;不给对方服务器造成压力。公开可见的榜单数据用于个人学习、技术研究没有问题,但抓取之后大规模公开传播、打包售卖,就超出了合理使用的范畴。
我的习惯是:抓练习数据只跑一次,存好结果就不再反复请求。这既是合规要求,也是技术素养。
2. 页面结构与 URL 规律:数据到底藏在哪
2.1 翻页参数 start 的规律
打开豆瓣电影 Top 250 页面,观察地址栏:
https://movie.douban.com/top250?start=0&filter=点第二页,地址变成:
https://movie.douban.com/top250?start=25&filter=规律很清楚:参数start控制起始位置,每页 25 条,所以第 N 页的start就是(N - 1) * 25。第 10 页是start=225。后面那个filter=参数留空即可,不影响结果。
这个分页方式在很多网站上都用,掌握之后遇到同类列表页可以直接套用。
2.2 用开发者工具定位字段
拿到页面源码后,不建议直接肉眼乱翻,我用的是浏览器开发者工具。右键页面选择“检查”,定位到任意一条电影条目,先把结构摸清楚。
Top 250 页面里,每条电影是一个div,外层类是item,内部主要分两个区域:左边div.pic是海报和排名,右边div.info是电影详细信息。下面是简化后的结构:
<div class="item"> <div class="pic"> <em class="">1</em> <a href="https://movie.douban.com/subject/1292052/"> <img src="..." alt="肖申克的救赎"> </a> </div> <div class="info"> <div class="hd"> <a href="https://movie.douban.com/subject/1292052/"> <span class="title">肖申克的救赎</span> </a> </div> <div class="bd"> <p> 导演: 弗兰克·德拉邦特 Frank Darabont 主演: 蒂姆·罗宾斯 Tim Robbins /...<br> 1994 / 美国 / 犯罪 剧情 </p> <div class="star"> <span class="rating_num">9.7</span> <span>2803625人评价</span> </div> <p class="quote"> <span class="inq">希望让人自由。</span> </p> </div> </div> </div>注意几个关键定位点:
- 排名:
div.pic > em - 电影名:
div.info > div.hd > a > span.title(中文名) - 导演、演员、年份、地区、类型:
div.info > div.bd > p,这些字段混在一起,需要用分隔符拆分 - 评分:
div.star > span.rating_num - 评价人数:
div.star下面没有被单独标注类的span - 一句话简介:
p.quote > span.inq - 详情页链接:
div.hd > a的href
理解了这个结构,后面写解析代码就是按图索骥。
2.3 解析库选型:BeautifulSoup、XPath、正则怎么选
解析方式通常有三条路:BeautifulSoup 的select()、lxml 的 XPath、纯正则表达式。我在这类项目里优先用 BeautifulSoup,原因很直接:
BeautifulSoup 选择器写起来和 CSS 选择器几乎一样,可读性好,配合浏览器开发者工具里复制的路径很容易定位。XPath 功能更强、性能更好,但写起来可读性差一些,调试成本高。正则表达式适合处理“不需要解析 HTML 结构、只想从字符串里抠出一小段内容”的场景,比如从混合文本里提取年份和地区。
在 Top 250 项目里,我会这样分工:select()负责定位结构化字段(片名、评分、排名),正则或字符串切割负责清洗p标签里混合的导演、年份、类型信息。这样每种工具都用在其最擅长的位置,代码简洁、逻辑清晰。
3. 请求这一步:为什么一上来就 403
3.1 User-Agent 与 headers 伪装
初学者最容易踩的坑就是:直接用requests.get(url)去抓,结果返回 403。403 的意思是服务器理解请求,但拒绝执行。原因很简单,很多网站的反爬机制首先看请求头里的User-Agent,如果识别出不是常见浏览器,直接拒绝。
我请求网页时固定带这样一组请求头:
HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "keep-alive" }其中User-Agent最重要,最好用一个当前浏览器版本的完整 UA 字符串,不要用太旧的。Accept-Language也很关键,豆瓣会依据这个返回不同语言的页面,设置为zh-CN能确保拿到中文电影名。实测下来,只要这两项正确,不带 Cookie 也能正常抓取。
3.2 状态码背后的反爬信号
请求之后先检查状态码,这是排查问题的第一步。对于这个项目,常见状态码的含义如下:
| 状态码 | 含义 | 怎么办 |
|---|---|---|
| 200 | 请求成功,拿到页面 | 正常解析 |
| 403 | Forbidden,服务器拒绝 | 检查 UA、Referer,降低频率 |
| 418 | 检测到异常请求 | 停一会再试,别硬刚 |
| 429 | 请求太快被限流 | 增加间隔时间 |
我在实测中就遇到过 418。那是我写了个循环没有加time.sleep(),连续快速请求好几页,结果豆瓣直接返回了 418。418 是“我是一个茶壶”的彩蛋状态码,一堆网站拿它当反爬信号,表示“我识别出你是脚本了”。这个状态下继续重试只会加重封禁,正确做法是停下来等两三分钟,再恢复抓取。
3.3 请求频率与重试机制
对于只有 10 页的 Top 250,完全不需要像抓大型站点那样上代理池和分布式。我的建议是:每抓一页time.sleep(3),十页也就半分钟,既稳定又不会触发限流。
另一个实用组件是重试机制。网络请求偶尔会出现超时或 5xx 错误,直接跳过可能导致数据缺失。我习惯用requests配合简单的重试逻辑:
import time import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter)用session而不是直接requests.get还有一个隐藏好处:Session 会保持连接,后续请求复用 TCP 连接,效率更高,多个页面连续抓取时更稳定。
4. 数据解析与清洗:把 HTML 变成结构化记录
4.1 解析提取的完整代码
页面拿到手,下面进入核心环节:解析。我先把完整解析代码写出来,再逐段解释。
from bs4 import BeautifulSoup def parse_page(html): soup = BeautifulSoup(html, "lxml") movies = [] items = soup.select("ol.grid_view > li") for item in items: rank = item.select_one("div.pic > em").get_text(strip=True) title = item.select_one("div.info > div.hd > a > span.title").get_text(strip=True) link = item.select_one("div.info > div.hd > a")["href"] rating = item.select_one("div.star > span.rating_num").get_text(strip=True) rating_people = item.select_one("div.star > span:nth-of-type(4)").get_text(strip=True) quote_node = item.select_one("p.quote > span.inq") quote = quote_node.get_text(strip=True) if quote_node else "" p_text = item.select_one("div.bd > p").get_text(" ", strip=True) director, year, area, genres = parse_p_info(p_text) movies.append({ "rank": int(rank), "title": title, "director": director, "year": year, "area": area, "genres": genres, "rating": float(rating), "people": parse_people(rating_people), "quote": quote, "link": link }) return movies有几个地方要特别说明。第一,select_one("div.star > span:nth-of-type(4)")取评价人数,因为“评价人数”那个span没有独立 class,只能靠位置定位。这个选择器在 BeautifulSoup 里是可用的,但如果你觉得别扭,也可以用item.select_one(".star").get_text()再正则提取数字,两条路都行。
第二,p.quote不是每条电影都有——部分影片没有那句简短简介。代码里先判断quote_node是否为None,否则直接.get_text()会抛异常。这类“有的字段不一定存在”的情况,在真实爬虫里非常常见,写代码时必须考虑。
第三,链接直接取a标签的href属性,它的格式是https://movie.douban.com/subject/1292052/,后面如果要抓详情页可以直接用。
4.2 导演、年份、地区和类型的拆分
p标签里的文本是整段混在一起的,例如:
导演: 弗兰克·德拉邦特 Frank Darabont 主演: 蒂姆·罗宾斯 Tim Robbins / 摩根·弗里曼 Morgan Freeman ... 1994 / 美国 / 犯罪 剧情注意实际文本里导演和主演之间的空格是连续多个空格或 ,年份前的部分到年份这里是个分水岭。我写的parse_p_info用正则把这段文本拆开:
import re def parse_p_info(p_text): parts = re.split(r"\d{4}", p_text, maxsplit=1) director_part = parts[0] if parts else "" director_match = re.search(r"导演:\s*(.*?)(?:主演:|$)", director_part) director = director_match.group(1).strip() if director_match else "" tail = "" if len(parts) > 1: tail = p_text[len(parts[0]):] tail_match = re.match(r"(\d{4})\s*/\s*([^/]+?)\s*/\s*(.+)", tail) year = tail_match.group(1).strip() if tail_match else "" area = tail_match.group(2).strip() if tail_match else "" genres = tail_match.group(3).strip().replace(" ", "/") if tail_match else "" return director, year, area, genres这里re.split(r"\d{4}", p_text, maxsplit=1)用年份数字作为分隔点,把文本切成“导演主演部分”和“年份地区类型部分”。年份以后的部分再用(\d{4})\s*/\s*([^/]+?)\s*/\s*(.+)匹配,分别提取年份、地区、类型。类型字段可能包含多个词,如“犯罪 剧情”,我把它转成以/分隔的字符串,后续存数据库更规范。
这段逻辑是解析中最容易写糙的地方,花点时间打磨是值得的。如果你只抓片名和评分,代码可以短很多,但抓下来的数据可用性会差很多。
4.3 主循环与分页拼接
解析函数写完,主循环就简单了。10 页数据逐页抓取:
import time if __name__ == "__main__": all_movies = [] for page in range(10): start = page * 25 url = f"https://movie.douban.com/top250?start={start}&filter=" resp = session.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" if resp.status_code == 200: page_movies = parse_page(resp.text) all_movies.extend(page_movies) else: print(f"第 {page + 1} 页请求失败: {resp.status_code}") time.sleep(3)这里resp.encoding = "utf-8"是一个小细节。豆瓣页面的编码就是 UTF-8,但requests有时会根据响应头猜测不准确,导致中文乱码。手动指定编码是最保险的做法。实测中加不加这一行,测试结果差别很大。
5. 数据落地:CSV 与 SQLite 双写
5.1 两种存储方式的取舍
数据抓下来之后就要考虑怎么存。CSV 和 SQLite 是我在这个项目里同时用的两种方案,各自的适用场景不同。
CSV 的好处是可直接用 Excel/WPS 打开,便于人工查看,也方便后续导入 Pandas 做数据分析。250 条数据用 CSV 完全没压力。SQLite 的好处是查询灵活,支持条件过滤、排序、聚合,能练习数据库基本功。抓取爬虫数据用 SQLite 比用 MySQL 轻量,数据库就是单个文件,不用安装服务,很适合单机项目。
我的思路是:先写 CSV 留一份可读备份,再写 SQLite 方便后续做分析查询。二者共用同一个data字典列表,代码维护成本很低。
5.2 写入代码实现
CSV 写入用csv.DictWriter,要注意字段顺序用fieldnames控制:
import csv CSV_FILE = "douban_top250.csv" FIELDNAMES = ["rank", "title", "director", "year", "area", "genres", "rating", "people", "quote", "link"] def write_csv(movies): with open(CSV_FILE, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=FIELDNAMES) writer.writeheader() for movie in movies: writer.writerow(movie)这里用utf-8-sig而非utf-8是经验点。前者带 BOM 头,用 Excel 直接打开 CSV 时中文能正常显示,不会乱码。如果之后要程序读取 CSV,建议用utf-8或读取时指定encoding="utf-8-sig",两者兼容。
SQLite 部分先建表,再逐条插入:
import sqlite3 DB_FILE = "douban_top250.db" def init_db(): conn = sqlite3.connect(DB_FILE) conn.execute(""" CREATE TABLE IF NOT EXISTS movies ( id INTEGER PRIMARY KEY AUTOINCREMENT, rank INTEGER UNIQUE, title TEXT, director TEXT, year INTEGER, area TEXT, genres TEXT, rating REAL, people INTEGER, quote TEXT, link TEXT ) """) conn.commit() conn.close() def write_sqlite(movies): conn = sqlite3.connect(DB_FILE) conn.executemany(""" INSERT OR REPLACE INTO movies (rank, title, director, year, area, genres, rating, people, quote, link) VALUES (:rank, :title, :director, :year, :area, :genres, :rating, :people, :quote, :link) """, movies) conn.commit() conn.close()rank INTEGER UNIQUE把排名设成唯一键,后续重复跑脚本时INSERT OR REPLACE会更新已有记录,不会生成重复数据。这个设计在增量更新场景里非常实用。
5.3 入库后的简单校验
数据存完不要急着高兴,先做校验。我的习惯是用 SQL 做几个快速检查:
SELECT COUNT(*) FROM movies; -- 应该等于 250 SELECT year, COUNT(*) FROM movies GROUP BY year ORDER BY COUNT(*) DESC; SELECT rating FROM movies ORDER BY rating DESC LIMIT 5;还要检查缺失值:
SELECT * FROM movies WHERE title IS NULL OR title = '';跑一遍这些查询只要几秒钟,能快速发现解析逻辑的问题。比如我曾经漏掉解析quote字段导致大量空值,就是靠这条缺值查询发现的。如果发现某些年份为 0 或类型为空,回头检查parse_p_info的正则逻辑,几乎百发百中。
6. 踩坑实录:实测中遇到的几个典型问题
6.1 页面结构变化导致的解析失效
爬虫最怕的不是反爬,而是页面改版。某一天我的脚本突然解析不出电影名,items列表为空,一查原因:豆瓣把ol.grid_view这个容器类名改了,所有 CSS 选择器集体失效。
这种问题没有一劳永逸的解法,只能靠监控。我的经验是:脚本里加一个“解析结果为空就告警”的逻辑,比如解析出 0 条记录时打印日志、发送通知,而不是默默存一个空文件。另外,定位元素时尽量选择稳定的结构属性,优先靠em、.title、.rating_num这类关键类名,而不是某一层父容器的类名,这样即使某个包裹层改名,解析代码的容错率也高一些。
6.2 请求频率过高被临时限制
我有一次为了测试删掉了time.sleep(3),让 10 页连续请求,结果第 4 页就返回 418。短暂等了两分钟后重新跑,脚本恢复正常。这个经历让我意识到:对 Top 250 这种小榜单而言,数据量本身很小,提速的收益几乎为零,但触发限流的风险会陡增。所以别贪快,抓完就好,留一点时间间隔是成本最低的稳定保障。
6.3 多线程提速的代价
聊到爬虫难免有人问:要不要用多线程?对于 10 页数据,我的建议是不要用。多线程确实能把抓取时间压缩到几秒,但需要处理线程安全、连接数控制、频率限流,调试复杂度上升,收益却不明显。真要练多线程爬虫,建议换一个更大的目标站点,比如爬几千页的电商分类页,再用ThreadPoolExecutor或asyncio去优化。在 Top 250 这个项目里,顺序请求 + 3 秒间隔就是最优解。
如果你确实想在这个项目里感受一下并发,可以控制线程数为 2 到 4,并且在每次请求之间保留随机延时:
import random import time from concurrent.futures import ThreadPoolExecutor def fetch_one(start): time.sleep(random.uniform(1, 3)) ...但我会坦白告诉你:体验一下可以,正式数据抓取还是建议顺序跑。
最后再分享几个我在实战里的习惯
每次写爬虫脚本,我都会在开头加一个BASE_URL常量、中间加若干print日志、末尾对入库数据做数量校验。看起来不起眼,但实际调试时能省大量时间。尤其是打印日志,初学者容易忽略,等脚本在数据量大的时候莫名其妙少了记录,才知道有日志多好。
Top 250 只是爬虫入门的第一站,这个流程跑通之后,你可以换着花样扩展:加上电影详情页的抓取和分析影评数据,把 CSV 导入 Pandas 做评分分布分析,或者用 Flask 写一个简单接口展示榜单数据。核心的 request 和 parse 模块不动,换数据源也只是改选择器和解析函数的事,这套思维才是爬虫真正值钱的部分。