☰
Python爬虫实战:从豆瓣Top250学会数据抓取、解析与存储
2026/9/26 14:06:19 网站建设 项目流程

学爬虫绕不开豆瓣 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 lxml
  • requests:发起 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&nbsp;&nbsp;&nbsp;主演: 蒂姆·罗宾斯 Tim Robbins /...<br> 1994&nbsp;/&nbsp;美国&nbsp;/&nbsp;犯罪 剧情 </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请求成功,拿到页面正常解析
403Forbidden,服务器拒绝检查 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 / 美国 / 犯罪 剧情

注意实际文本里导演和主演之间的空格是连续多个空格或&nbsp;,年份前的部分到年份这里是个分水岭。我写的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 模块不动,换数据源也只是改选择器和解析函数的事,这套思维才是爬虫真正值钱的部分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询