☰
Python爬虫入门:Requests与BeautifulSoup从请求到解析全攻略
2026/9/26 7:10:03 网站建设 项目流程

最近后台收到好几条私信,都是问同一个方向的问题:想入门爬虫,但不知道从哪一步开始,装了一堆工具最后全卡在报错上。这类问题我这些年见过太多次了,其实爬虫入门没那么玄乎,核心就两件事:把网页内容拿回来,再从拿回来的内容里把你要的信息抠出来。对应的两个主力库就是 Requests 和 BeautifulSoup,这俩一前一后配合,能解决大部分简单的数据采集需求。这篇文章就围绕这两个库,把从环境准备、请求发送、内容解析到数据保存的完整链路讲清楚,过程中会把我在真实项目里踩过的坑、总结的排错思路一并放进来,适合刚接触 Python 或者刚想写第一个爬虫的读者,有基础的朋友也可以直接跳到后面看异常处理和解析器的选型思路。

1. 写爬虫前的思路整理:目标、边界与方案选型

1.1 先想清楚你要抓什么、怎么存、多久抓一次

很多人上手爬虫的第一反应是直接打开编辑器写代码,这恰恰是最容易翻车的开头。我建议你在写第一行代码之前,先在纸上或备忘录里回答三个问题。

第一,目标页面是谁?这里要具体到域名、路径、列表页还是详情页。比如你要抓某个公开的图书榜单,是抓第一页还是翻遍全站?不同的目标决定了代码复杂度。第二,数据结构长什么样?每一条记录包括哪些字段?是标题、链接、价格、日期,还是更复杂的嵌套信息?提前把字段名定义好,后面写解析逻辑会顺很多。第三,抓完之后放哪里?CSV 文件够不够,还是要入库?抓取的频率是手动跑一次,还是定时每天跑?

这三个问题看起来基础,但能在后面帮你省掉大量返工时间。我有一次接到一个临时数据需求,对方说“随便抓抓就行”,结果我花半天爬完发现他们要的是结构化入库,之前写好的 CSV 存储整个作废。所以别嫌这一步啰嗦,后续所有代码都是围绕这三个答案展开的。

除了明确目标,还得说一句边界问题:爬虫只是工具,不是拿来作恶的。公开信息的采集要尊重目标网站的 robots.txt 和用户协议,控制频率,不要给对方服务器制造压力。这个意识从入门第一天建立起来,你的技术道路会走得更稳。

1.2 为什么入门首选 Requests + BeautifulSoup 而不是 Selenium 或 Scrapy

市面上 Python 爬虫方案很多,Selenium 能驱动真实浏览器,Scrapy 是完整的爬虫框架,但入门阶段我更推荐手工搭一套 Requests + BeautifulSoup 的组合。原因很简单:这两个库各自只负责一件事,你能清楚地看到“请求”和“解析”这两步之间发生了什么。

Requests 负责把 HTTP 请求发出去,拿回响应文本;BeautifulSoup 负责把 HTML 文本解析成可操作的节点树。整个链路短,中间没有框架帮你隐藏细节,意味着你踩过的每一个坑都会变成经验。反观 Scrapy,它虽然功能强大,但异步调度、中间件、Item Pipeline 这些概念对新手来说是巨大的认知负担,很容易迷失在框架机制里而不是学到爬虫本身。

Selenium 就更不用说了,它适合 JavaScript 动态渲染、需要模拟真实用户操作的场景。如果目标页面的数据是后端渲染进 HTML 的,你根本用不到它。杀鸡用牛刀不是不行,但会让你的入门曲线陡好几倍。我的建议是先把 Requests + BeautifulSoup 这套静态方案跑通,再按需学 Selenium,最后才考虑上框架。

1.3 对反爬机制建立基本认知

很多人第一次爬虫被拦下来,第一反应是“我的代码写错了”,其实不是。现代 Web 站点普遍有基础的反爬手段,常见的有校验 User-Agent、校验 Referer、Cookie 会话检查、请求频率限制等。这些东西不需要你深入研究,但要知道大致的应对思路。

最常见的拦截是服务端检查请求头里的 User-Agent。正常浏览器的 UA 会包含 Mozilla/5.0 和浏览器名称,而 Python Requests 默认的 UA 是 python-requests/2.x,一眼就能认出来。这种情况处理起来很简单,构造一段正常的请求头带上即可。更讲究一点的做法是限制请求频率,不要一次性并发几十个请求,慢一点反而更稳。后面我会具体讲怎么设置请求头和请求间隔。

2. 环境准备:Python 安装与依赖引入

2.1 安装 Python 并确认 pip 可用

如果你是完全新手,第一步是把 Python 装好。Windows 用户去 Python 官网下载安装包,注意勾选“Add Python to PATH”;macOS 用户可以直接用 Homebrew 安装,命令是brew install python3。Linux 各发行版一般自带 Python,不过版本可能偏旧,建议确认一下python3 --version。

装完之后打开终端或命令行,运行python --version和pip --version,都能正常输出版本号就说明环境没问题。如果提示找不到命令,多半是 PATH 没配好,重新看一遍安装步骤里的勾选项。

这里有个小建议:不要用系统自带的 Python 环境直接装包。项目多了之后依赖会互相打架,今天装这个库要 2.x 版本,明天那个库要求 1.x,全局环境根本绷不住。所以下面我强烈建议你从第一步就养成用虚拟环境的习惯。

2.2 创建虚拟环境并安装 requests 和 beautifulsoup4

虚拟环境的概念可以类比成一个独立的工具箱:每个项目一套 Python 解释器和依赖库,互不干扰。操作起来非常简单,在项目目录下执行:

# 创建虚拟环境,venv 是目录名,可以随便取 python -m venv venv # 激活虚拟环境 # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate

激活之后,终端提示符前面会出现(venv),说明你已经进入这个独立环境。接着安装依赖:

pip install requests beautifulsoup4

想用更快的解析器的话,顺手把 lxml 也装上:

pip install lxml

lxml不是必需依赖,但它解析 HTML 的速度比 Python 自带的解析器快不少,而且容错能力更强。后面我会详细对比解析器选型,这里先装上没坏处。

2.3 用最小案例验证环境是否装好

环境装完别急着写复杂逻辑,先跑一个最简单的验证。新建test.py,写入:

import requests from bs4 import BeautifulSoup text = "<p>hello crawler</p>" soup = BeautifulSoup(text, "html.parser") print(soup.find("p").text) resp = requests.get("https://httpbin.org/get") print(resp.status_code)

能正常输出hello crawler和200,就说明两个库都能正常工作。httpbin.org 是一个专门用于测试 HTTP 请求的公开服务,后面调试请求头、参数时也会经常用到它。

3. Requests 库实操:请求发送、响应处理与异常重试

3.1 从一次性 GET 请求开始:把网页文本拿到手

Requests 库的用法非常直白。先看最基础的一次请求:

import requests url = "https://example.com" resp = requests.get(url, timeout=10) print(resp.status_code) # 状态码,200 表示正常 print(resp.text) # 响应文本,HTML 源码

这里有两个必须注意的点。第一,timeout参数一定要加。不加的话,遇到网络波动或目标服务器没有响应时,你的程序会一直挂在那里,表现像“卡死”了一样。设一个 10 秒的峰值,超过就抛异常,至少程序是可控的。第二,打印响应之前先确认状态码。很多新手直接打印resp.text,发现里面是一个错误提示页还以为是网页改版了,其实那只是服务器返回的 403 页面。

更好的写法是把状态码判断结合进来:

import requests url = "https://example.com" resp = requests.get(url, timeout=10) if resp.status_code == 200: html = resp.text # 继续后续解析 else: print(f"请求失败,状态码: {resp.status_code}")

3.2 设置请求头:User-Agent 和常用字段

前面提到,Requests 默认的 User-Agent 是python-requests/2.31.0这种格式,服务器一眼就能判断这不是正常浏览器。处理方式很简单,构建一个headers字典传入请求:

import requests 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": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } url = "https://example.com" resp = requests.get(url, headers=headers, timeout=10)

UA 字符串可以从浏览器的开发者工具里复制。打开 Chrome 的 Network 面板,随便请求一个网页,在请求详情里找到 User-Agent,整段复制出来就是最真实的。不要手打,手打容易漏掉格式细节。

如果目标网站对来源有要求,还要加上Referer字段,表示你是从哪个页面跳转过来的。有些站点会校验这个字段,少了它直接返回 403。这个没有统一写法,通常取目标网站的主页地址就行。

3.3 请求参数:需要翻页或带查询条件时怎么传

很多列表页是通过 URL 参数来控制页码和筛选条件的,比如https://example.com/list?page=2&size=50。Requests 支持用params参数自动拼 URL,避免手工拼接字符串带来的转义问题:

import requests url = "https://example.com/list" params = { "page": 2, "size": 50, } resp = requests.get(url, params=params, headers=headers, timeout=10) print(resp.url) # 自动拼接成 https://example.com/list?page=2&size=50

多页抓取时,把页码变量化,用循环逐页请求,每页之间加个短延时。这个延时非常重要,后面会在第 6 章详细讲限流的问题。

3.4 响应处理:状态码、编码与异常捕获

服务端返回的状态码有很多种,爬虫场景里最常遇到的是这几种:

状态码含义常见原因处理建议
200请求成功正常继续解析
403禁止访问反爬拦截、权限不足换请求头、增加延时
404页面不存在URL 错误、内容已删检查 URL 或跳过该条目
429请求过于频繁频率超出限制停止请求,等待后再继续
500服务器错误对方服务异常间隔后重试,多次失败则放弃

用resp.raise_for_status()可以快速检查状态码,非 200 系的响应会直接抛出异常,省得你自己写一堆条件判断。不过要注意,429 和 403 这类“可恢复”的情况更适合自己处理,不要一股脑抛异常。

编码问题是新手几乎必踩的坑。Requests 从响应头里的charset字段推断编码,但很多网站根本不在响应头里声明编码,或者声明得不对,导致你拿到的文本全是乱码。稳妥的做法是从内容里检测编码:

import requests resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding html = resp.text

resp.apparent_encoding会基于响应内容的编码特征做推断,比默认值准确得多。代价是它会消耗一点额外的内存和计算时间,但对于入门级爬虫完全能接受。如果你的解析结果有中文,发现乱码了,优先检查这一行。

4. BeautifulSoup 解析:从 HTML 中提取结构化数据

4.1 解析器选型:内置解析器和 lxml 怎么选

把 HTML 文本交给 BeautifulSoup 后,第一步是创建解析对象:

from bs4 import BeautifulSoup soup = BeautifulSoup(html, "html.parser")

不同的解析器表现差异很大。Python 自带的html.parser不需要额外安装,但在处理一些不规范的 HTML 时可能解析结果不理想。lxml是 C 语言实现,解析速度快,容错能力也更强,是目前的主流选择。还有一个html5lib,完全遵循 HTML5 标准,容错性极强但速度最慢,一般不建议日常使用。

我的建议是:你已经在本地装了 lxml,就直接用它。写法就是把字符串从"html.parser"换成"lxml",其他地方完全不用改。如果页面结构相对简单、也没有乱码问题,用内置解析器也没什么毛病。做数据解析这种事,解析器之间并没有绝对的对错,重要的是你了解它们在速度和容错上的取舍。

4.2 查找节点:find、find_all 与 CSS 选择器

BeautifulSoup 的核心操作可以分成两类:查找单个节点,查找所有节点。查找单个节点推荐find()或select_one(),查找全部节点推荐find_all()或select()。

先看最基础的用法:

# 找第一个标题标签 title_tag = soup.find("h1") # 找所有链接标签 all_links = soup.find_all("a") # 加类名过滤 items = soup.find_all("div", class_="item")

要注意class_后面的下划线,因为class是 Python 的关键字,不能直接作为参数名。BeautifulSoup 也支持用字典形式传入属性:

items = soup.find_all("div", attrs={"class": "item", "data-id": "1"})

如果你熟悉 CSS 选择器,select()方法会更顺手。它能直接写嵌套选择器,比如#content .item a表示 id 为 content 的节点里,所有 class 包含 item 的节点下的全部 a 标签:

links = soup.select("#content .item a")

这套写法的好处是表达层级关系时非常直观,调试起来也方便,把浏览器里的选择器抄过来就能用。

4.3 提取文本与属性:get_text 与 get

拿到节点之后,接下来要提取节点里的文本或属性。文本用get_text(),属性用get():

# 提取文本,strip 会去掉首尾空白字符 title = title_tag.get_text(strip=True) # 提取链接地址 href = a_tag.get("href") # 提取图片地址 img_src = img_tag.get("src")

get_text()默认会把所有子节点的文本拼在一起。当你只想拿某个节点自身的文本、不要子节点内容时,可以用get_text().strip()配合结构筛选来处理,或者先确定好目标节点的最小范围再提取。常见的问题是find_all里容易把父节点和子节点都抓到,导致文本重复,这个细节我在第 6 章会专门说。

4.4 综合实战:抓取一个简单的列表页面

理论说了一堆,下面用一个完整例子串起来。假设有一个图书列表页,每个图书条目是这样一个结构:

<div class="book-item"> <h2 class="book-title">Python入门到实践</h2> <a class="book-link" href="/books/101">详情</a> <span class="book-price">¥59.00</span> </div>

完整爬虫代码如下:

import requests 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", } url = "https://example.com/books" resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding if resp.status_code == 200: soup = BeautifulSoup(resp.text, "lxml") items = soup.find_all("div", class_="book-item") books = [] for item in items: title = item.find("h2", class_="book-title").get_text(strip=True) link = item.find("a", class_="book-link").get("href") price = item.find("span", class_="book-price").get_text(strip=True) books.append({ "title": title, "link": link, "price": price, }) print(books)

运行之后,控制台会输出一个字典列表,每条记录包含书名、链接和价格。到这里,最核心的“请求 + 解析”流程就已经完整跑通了。剩下的问题就是拿到数据之后放哪里。

5. 数据保存:CSV、JSON 与 SQLAlchemy 入库

5.1 存为 CSV:极简方案与注意事项

对条数不多、结构扁平的数据,CSV 是最直接的存储方式。Python 标准库里的csv模块就能搞定:

import csv with open("books.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "link", "price"]) writer.writeheader() writer.writerows(books)

注意两个细节。第一,打开文件时要加newline="",否则在 Windows 上写出的 CSV 会多出空行。第二,编码用utf-8-sig而不是常见的utf-8,这是为了让生成的 CSV 直接用 Excel 打开时不乱码。utf-8-sig会在文件开头加入 BOM 标记,Excel 能正确识别为 UTF-8 编码。

5.2 存为 JSON:保留嵌套结构的首选

如果你的数据有嵌套结构,比如图书详情里包含多个作者、多个标签,CSV 就不太合适了,JSON 是更好的选择:

import json with open("books.json", "w", encoding="utf-8") as f: json.dump(books, f, ensure_ascii=False, indent=2)

ensure_ascii=False要记得加,否则中文会被转义成\uXXXX,人眼根本没法读。indent=2让输出自动缩进,方便调试时查看结构。

5.3 进阶:为什么推荐用 SQLAlchemy 存爬虫数据

数据量到几百上千条之后,CSV 和 JSON 的劣势会越来越明显。你想按价格排序、按日期筛选,或者做增量更新,每次都要把整个文件读进来处理一遍,效率很低。这时候就该考虑数据库了。

很多入门教程上来就教你写原生 SQL,个人建议可以绕道,直接用 ORM。ORM 全称是对象关系映射,简单说就是让你用 Python 对象操作数据库,而不用手写 SQL 字符串。SQLAlchemy 是 Python 生态里最流行的 ORM 库,它能让你把爬下来的数据自动映射成数据库表,用起来非常顺手。

比如你定义了 Book 这个模型类,定义一个字段title对应数据库里的title列,那你在 Python 代码里存一个新对象,它就会自动执行 INSERT 语句。好处是逻辑清晰,将来改表结构也方便,尤其适合爬虫这种数据结构迭代快的场景。

写一个最小可运行示例:

from sqlalchemy import create_engine, Column, String, Float, Integer from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Book(Base): __tablename__ = "books" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(200)) link = Column(String(200)) price = Column(String(20)) engine = create_engine("sqlite:///books.db") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() for item in books: book = Book(title=item["title"], link=item["link"], price=item["price"]) session.add(book) session.commit()

这里用的是 SQLite,零配置、单文件,非常合适入门。整个库就是一个.db文件,想删除数据直接删文件就行。等以后数据量大了,只需要把引擎的地址换成 MySQL 或 PostgreSQL 的连接串,其他逻辑几乎不用动。这就是把数据层抽离出来的好处。

爬虫接数据库这些内容很多教程都会放在后面讲,但我觉得在入门阶段就了解一次数据落地方案之间的差异,能帮你少走很多“爬完没法用”的弯路。

6. 常见问题与排查技巧实录

6.1 中文乱码:先查编码再查浏览器

中文乱码可能是爬虫圈提问频率最高的问题。我以前也经常碰到,后来总结出一个排查顺序。第一优先检查resp.encoding是不是设置过,没设置就用resp.apparent_encoding再跑一次。第二检查目标页面在浏览器里实际使用的是什么编码,Chrome 的 Network 面板里响应头Content-Type字段会标明,比如text/html; charset=utf-8。第三检查你的输出环节,比如写 CSV 时是不是用了错误的编码。按照这个顺序排查,绝大多数乱码问题都在第一步就解决了。

6.2 请求被拒绝:403、UA、Cookie 与请求频率

收到 403 时,首先确认你的请求头里有没有带完整的 User-Agent。很多站点只做最基础的 UA 校验,补上真实浏览器 UA 后问题就消失了。

如果 UA 正常但还是 403,那就需要更进一步。有的站点需要先访问一次首页拿到会话 Cookie,再带着 Cookie 去访问数据接口。Requests 的requests.Session()对象可以自动保存 Cookie,脚本中途不丢会话状态:

import requests session = requests.Session() session.headers.update({ "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", }) # 先访问首页,让 session 记录 Cookie session.get("https://example.com", timeout=10) # 再访问目标数据页 resp = session.get("https://example.com/books", timeout=10)

要是加了 Cookie 还是被拒绝,那基本就是更高阶的反爬手段了,比如浏览器指纹校验、JS 渲染验证等。这时候我才建议考虑 Selenium 这类浏览器工具。

还有一个经常忽略的原因,就是请求频率。如果你在本地环境、同一 IP 下短时间发了大量请求,触发频率限制后服务器会暂时屏蔽你的 IP。解决方法是放宽请求间隔,5 秒甚至 10 秒一次都算正常。你可以用一个简单的time.sleep(random.uniform(1, 3))在请求之间制造随机延时,模拟人的真实访问节奏。

6.3 429 Too Many Requests:限流状态码的正确打开方式

最近在几个技术社群里频繁看到“429”这个词,很多人问怎么解决。429 全称是Too Many Requests,服务端用这个状态码明说:你太快了,给我停下。

初学者遇到 429 的第一反应通常是增加请求频率,以为多试几次就能“撞开”。这种思路完全反了。429 是服务端的保护机制,你越撞只会被封得越久。正确的处理方式是看到 429 就先停下来,等一段时间再继续。

一个稳妥的重试策略是退避式重试:第一次失败等 10 秒,第二次失败等 30 秒,第三次失败等 60 秒。每多一次失败,等待时间就长一截,让服务端有时间恢复对你的信任。代码里可以用循环配合time.sleep实现:

import time def get_with_retry(url, headers=None, max_retries=3, timeout=10): for attempt in range(max_retries): resp = requests.get(url, headers=headers, timeout=timeout) if resp.status_code == 200: return resp if resp.status_code == 429: wait_time = 10 * (attempt + 1) print(f"收到 429,等待 {wait_time} 秒后重试") time.sleep(wait_time) else: # 其他状态码不等了,直接抛异常让调用方处理 resp.raise_for_status() return None

这个函数用起来很顺手:请求成功就直接返回响应对象;遇到 429 就按 10 秒、20 秒、30 秒的节奏退避重试;遇到 404 这种不可恢复的状态码,直接抛异常,避免无意义地重试。

我不建议把 max_retries 设得太大。一般的个人爬虫项目,三次左右就够了。如果连续三次都触发限流,说明你的整体请求节奏问题更大,该调整的源头是频率本身,而不是重试次数。

6.4 find_all 返回空列表:先检查抓包再检查选择器

新手写 BeautifulSoup 解析时最崩溃的时刻,莫过于find_all返回了一个空列表。代码看着明明没问题,选择器也写了,就是抓不到数据。

我的排查顺序是三步。第一,先打印原始 HTML,确认你拿到的内容里真的包含你要找的代码片段。很多情况是请求被反爬拦截,返回了一个提示页面而不是你想要的列表页,这时候改解析器是没用的,得回头处理请求。第二,确认你要抓的数据是不是通过 JavaScript 动态加载的。如果是,HTML 源码里根本不会出现这些节点,你得换 Selenium 方案或者直接找 Ajax 接口。这一步可以用浏览器右键的“查看网页源代码”来对比,如果源码里没有但页面渲染后有,就是动态加载。第三,检查选择器层级和类名是否和浏览器里一致。有些网站会用空格分隔多个 class,而find_all的class_参数需要精确匹配。如果你只取了其中一个类名,是匹配不到的。这种情况改用select()配合 CSS 选择器会灵活很多。

6.5 爬虫运行时间太长:循环里加进度日志

最后分享一个实战小技巧。爬虫一旦跑起来,很多任务都是几分钟甚至更久的活。如果代码里没有任何输出,你盯着黑屏控制台根本不知道它爬到哪里了、有没有卡住。建议在循环里加简单的进度日志:

for i, page_url in enumerate(url_list): print(f"[{i+1}/{len(url_list)}] 正在抓取: {page_url}") # 具体请求和解析逻辑

不用搞花哨的进度条,一行 print 就能让你随时知道程序状态。我之前有个任务要抓六千多个详情页,没有日志的时候我只能干等,加了日志之后发现中间有个别页面请求超时,我立刻就能看到是哪一段出了问题,针对性修调用次就完事了。

最后补两句真心话

写了这么多年爬虫,我越来越认同一个观点:爬虫技术本身不难,难的是对目标的拆解和对自己程序的掌控。Requests 和 BeautifulSoup 这两个库都不复杂,每一个方法都能在几分钟内学会,但要把它们组合成一个稳定可复用的采集流程,靠的全是在实战中积累细节。我最常用也最推荐的一条铁律是:永远假设目标网页会变,永远假设请求会失败,然后在这个前提下写出从容的代码——加请求头、设超时、处理状态码、留随机延时、打印进度。这些东西全部加起来不过几十行代码,却能让你的爬虫从“能跑”变成“能持续跑”。希望这篇文章能帮你把这条链路走通,下一篇我会写怎么用断点续爬和增量更新来处理更大规模的数据采集场景,到时候见。

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

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

立即咨询