想抓网页数据,绕不开两个库:Requests 负责把网页“搬”下来,BeautifulSoup 负责把 HTML 里的信息“捞”出来。这两个是 Python 爬虫入门最经典的组合,你只需要会一点 Python 基础语法——变量、循环、函数——就能开始写自己的第一个爬虫。这篇文章我会从环境准备讲到实战案例,再把自己踩过的坑和排查思路一并写出来,目标是让你读完就能动手,而不是只停留在“看懂代码”的层面。
我做过不少爬虫相关的折腾,从抓新闻列表到抓公开的数据报表,用的基本都是这套组合。它不像 Scrapy 那样需要提前理解框架的调度机制,也不像 Selenium 那样要起一个浏览器进程,轻量、直接、可控,非常适合作为爬虫技术的起点。适合的人群很明确:已经学完 Python 基础、想通过项目巩固的人,或者是工作中偶尔需要批量获取公开网页数据、不想沉迷于复制粘贴的人。这篇文章不会涉及任何“绕过限制”的黑科技,所有内容都基于正常访问公开网页的范畴。
1. 环境准备与工具选型
1.1 为什么是 Requests + BeautifulSoup,而不是别的
初学者最容易犯的错误,是一上来就选重型框架,结果被各种概念淹没。Scrapy 功能强大,但它的下载器中间件、Item Pipeline、Spider 基类这些抽象,对没写过几个爬虫的人来说太沉重。Selenium 能处理动态页面,但每次运行都要驱动一个完整浏览器,速度和资源消耗都很高。正则表达式很快,可是写出来可读性极差,维护成本也高。
Requests + BeautifulSoup 的组合优势在于“分工明确”:Requests 只管网络通信,包一层又细又稳的 HTTP 客户端;BeautifulSoup 只做 HTML 解析,把网页结构变成一棵方便遍历的对象树。二者不需要复杂的配置,十几行代码就能跑通一个完整流程。而且它们都有非常庞大的用户群,遇到问题搜一下基本都有答案,这对新手极为友好。
从原理角度说一下。爬虫的本质是“模拟浏览器向服务器发起 HTTP 请求,拿到响应后从 HTML 中提取信息”。这两个库刚好覆盖了这条链路的两端:Requests 完成“请求-响应”这一半,BeautifulSoup 完成“解析-提取”这一半。理解了这一点,你会发现自己学的不只是两个库,而是一整套抓取静态网页的通用思路。
1.2 安装与初始化配置
安装没什么难度,直接用 pip:
pip install requests beautifulsoup4如果你要用更快的解析器,可以一并装 lxml:
pip install lxml建议在虚拟环境里操作。我用虚拟环境的原因很简单:不同项目依赖的库版本可能冲突,隔离之后省心很多。创建虚拟环境的方式有很多,习惯用哪个都行,但一定要有这个习惯。
安装完之后,在 Python 交互环境里验证一下:
import requests from bs4 import BeautifulSoup print(requests.__version__) print(BeautifulSoup)只要能正常输出版本号和类信息,就说明环境没问题。常见的一个坑是代码里写from bs4 import BeautifulSoup,但忘记安装beautifulsoup4,结果报ModuleNotFoundError,这个属于低级失误,但确实经常发生。
1.3 入门必须先补的三个基础概念
写爬虫之前,有四个概念最好先搞明白,否则只能照着抄代码,遇到问题就懵。
第一是 HTTP 协议的基本请求流程。你访问一个网页,本质是浏览器向服务器发了一个 GET 请求,服务器返回一个 HTML 文档。Requests 库替你把这次通信封装好,你只需要关心 URL、请求头和响应内容。
第二是 HTML 的 DOM 结构。HTML 不是一串无意义的尖括号文本,它是一棵树。html是根节点,下面有head和body,body里面又有各类标签,标签内部可以嵌套标签,也可以携带属性和文本。BeautifulSoup 的核心能力就是把这段文本解析成树,让你可以用“找父节点、找子节点、找符合条件的标签”这种自然的方式遍历。
第三是响应状态码的常识。200 表示正常,403 一般是服务器拒绝访问,404 是页面不存在,500 是服务器内部错误。初学者看到 403 就以为是账号问题,其实大概率是缺少必要的请求头,比如User-Agent,我后面会细说。
第四是编码问题。网页可能是 UTF-8,也可能是 GBK,如果直接按默认编码解析,中文就会变成乱码。这不复杂,但影响体验,后面专门有一节讲。
2. Requests 核心用法与请求配置
2.1 从发一个 GET 请求开始
用 Requests 发请求很简单,核心就一行代码:
import requests url = "https://example.org" response = requests.get(url) print(response.status_code) print(response.text)requests.get()这个方法会做这几件事:根据 URL 构造请求、发起连接、接收响应、把响应内容包装成一个Response对象。response.status_code告诉你服务器的返回状态,response.text就是网页的 HTML 源码。
见过不少新手在这步踩坑:请求发出去了,状态码是 200,但response.text里没有看到期待的内容,反而是一堆类似 “请开启 JavaScript” 的提示。这说明目标页面是动态渲染的,服务器返回的 HTML 里原本就没有数据。这种情况不是 Requests 没学好,而是目标不适合用这个组合,后面我也会给判断方法。
2.2 Response 对象的几个高频属性与方法
Response对象是服务器给我们的“回信”,里面的信息很丰富,入门阶段最常用的是这几个:
response.status_code:HTTP 状态码,先判断这个再往下走。response.text:以字符串形式返回网页源码,适合交给 BeautifulSoup 解析。response.content:以字节形式返回内容,下载图片、文件时用这个。response.encoding:获取或设置编码。如果发现乱码,可以试着手动设置成 UTF-8。response.url:最终的 URL。有些网站会做重定向,这个属性可以看到跳到了哪里。response.headers:响应头,里面能看服务器类型、content-type 等信息。response.json():如果接口直接返回 JSON,解析成字典或列表。
每个属性都要自己动手打一遍,因为实际写爬虫时你会反复用到它们。特别是response.content和response.text的区别,我用过多次,下载图片必须用content再用二进制写入,用text会损坏文件。
2.3 参数、Headers、超时与编码设置的实战配置
很多网址后面的参数是动态变化的,比如翻页时的page=2。最规范的做法是不手动拼 URL,而是用 params 参数:
params = { "page": 1, "keyword": "python" } response = requests.get(url, params=params)Requests 会帮你把参数编码后拼到 URL 上,比手工拼接更安全,也不会漏掉特殊字符。
再往下一个层面就是 Headers。服务器不认识你的程序,它看到的是一个“陌生的访客”,为了避免被识别成异常的机器人,需要伪装成一个正常浏览器的请求头。其中最关键的是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" } response = requests.get(url, headers=headers, timeout=10)timeout=10表示 10 秒内没有得到响应就放弃,这个必须设置。默认情况下如果服务器不响应,程序会一直挂着,你最后只能 Ctrl+C 中断。设置超时之后,程序会在规定时间抛异常,我们可以捕获并进入重试逻辑。
再就是编码。最省事的方法是用response.apparent_encoding,它是 Requests 根据页面内容自动推断出的编码:
response.encoding = response.apparent_encoding这个是我处理中文乱码最常用的两行代码。需要注意的是,apparent_encoding的推断不是绝对准确,个别特殊页面还是会出问题。遇到这种情况,手动设置成utf-8或者gbk试一下。
2.4 Session 与请求重试的入门用法
有些网站的页面需要先访问一次才能拿到 Cookie,后续请求带着 Cookie 才能正常返回内容。用 Session 可以帮你自动维持 Cookie,让多次请求在同一个“会话”里完成:
session = requests.Session() session.headers.update(headers) first = session.get("https://example.org") second = session.get("https://example.org/detail/1")在第一次请求后,服务器可能设置了 Cookie,Session 会自动保存,并在第二次请求时带上,这是模拟正常浏览行为的关键一步。
关于超时重试,我自己常用的模式是循环加捕获异常:
for attempt in range(3): try: response = requests.get(url, headers=headers, timeout=10) response.raise_for_status() break except requests.RequestException: if attempt == 2: raise time.sleep(2)raise_for_status()表示如果状态码不是 200-399,直接抛异常,省得你每次都要手动判断。很多教程不讲这个,但我实际用下来非常顺手。
3. BeautifulSoup 解析核心与选择器技巧
3.1 解析器怎么选
拿到 HTML 字符串之后,就轮到了 BeautifulSoup 登场。先创建一个 Soup 对象:
from bs4 import BeautifulSoup soup = BeautifulSoup(html_text, "html.parser")第二参数是解析器。Python 内置的html.parser不用额外安装,能用但速度一般。lxml解析速度更快,功能也更强,需要额外安装。html5lib对不规范 HTML 的容错性最好,但慢。我一般默认用lxml,如果遇到解析结果和浏览器看到的不一致,会换回html.parser或查看源码结构再判断。
有些新手会遇到一个典型报错:No module named 'lxml',这是加载后才发现缺包,提前装好就行。另外,如果你直接把某个动态网页的渲染后 HTML 拿给 BeautifulSoup,解析没有任何问题,只是里面没有你要的数据——这属于抓取层面没解决,解析器是无辜的。
3.2 看懂 HTML 结构是高效解析的前提
不夸张地说,会看结构的人比会写代码的人解析得更快。拿一个最常见的新闻列表来说:
<div class="news-list"> <div class="item"> <h2><a href="/news/1">第一条新闻标题</a></h2> <span class="date">2024-01-01</span> </div> <div class="item"> <h2><a href="/news/2">第二条新闻标题</a></h2> <span class="date">2024-01-02</span> </div> </div>目标很明确:把div.news-list下面每一个div.item里的标题和链接拿出来。不要看到一个大结构就想着一次性搞定,正确思路是“先定位列表容器,再遍历每一个条目,最后从条目中提取字段”。这种“容器-条目-字段”的三层结构是我写解析代码的通用套路,适合绝大多数列表类页面。
3.3 BeautifulSoup 的四大对象与核心方法
BeautifulSoup 有四种基本对象,入门阶段能分清Tag和NavigableString就够了:
Tag:一个 HTML 标签节点,比如<a href="...">链接</a>整体。NavigableString:标签内部的文本,比如“链接”两个字。BeautifulSoup:整个文档的解析对象。Comment:注释节点,一般不主动处理。
对于Tag,常用到的操作是取属性、取文本、嵌套查找:
a_tag = soup.find("a") print(a_tag["href"]) # 取链接 print(a_tag.text) # 取文本find()返回第一个匹配的标签,find_all()返回所有匹配的标签,这是最常用的两个。也可以用.select()传 CSS 选择器,比如soup.select("div.item a"),一次定位到位,写起来非常舒服。我个人的倾向是:结构简单用find_all,结构复杂用select,两个都要熟练。
3.4 提取文本与属性时容易忽略的细节
提取文本有个细节容易被忽略:tag.text会拿到该标签下所有子标签的文本,包括隐藏的注释和脚本,需要确认是否干净。如果只想取直属于当前节点的文本,用tag.get_text(strip=True)并搭配必要的过滤会更稳妥。
get_text(strip=True)会去掉首尾空白,但不会自动去掉中间多余空格。比如一段文本是"Python 爬虫 入门",中间的空格依然保留,你需要自行清理。这种小问题不会报错,但会污染数据,写代码时要注意。
我建议在解析阶段养成“先打印后写代码”的习惯:对一个不熟悉的结构,先用find_all试探性地打印几条数据,确认提取路径对了,再去写正式的提取逻辑。千万不要一口气写完,跑出来发现全是 None,又得回头逐行排查。
4. 完整实操案例:抓取一个公开示例页面的新闻列表
4.1 动手前的目标分析与页面观察
理论知识说再多,都不如实际跑一个案例。我带大家抓一个我常用来演示的公开示例页面,它就是一个简单的静态页面,里面有两组可提取的数据:新闻标题列表和每篇新闻的链接。
第一步,打开浏览器,右键页面的空白处,选择“查看网页源代码”。这一步的意义是搞清楚数据是“静态写在 HTML 里”还是“动态通过 JS 加载的”。我这次用的示例页面,标题和链接都在 HTML 里,可以直接用。
第二步,定位数据的“容器”。在源码里搜索关键词news,找到我上面那种结构。你需要记下三个信息:条目的外层 class 名、标题所在标签、链接所在标签。这个观察过程决定了后面解析代码怎么写。
4.2 完整代码实现与运行结果
下面给出完整的爬虫代码,我会逐段解释用意:
import requests from bs4 import BeautifulSoup # 1. 准备请求 url = "https://example.org/news" 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" } response = requests.get(url, headers=headers, timeout=10) response.encoding = response.apparent_encoding # 2. 解析页面 soup = BeautifulSoup(response.text, "lxml") news_items = soup.select("div.item") # 3. 提取数据 results = [] for item in news_items: title_tag = item.find("h2").find("a") title = title_tag.text.strip() link = title_tag["href"] results.append({"title": title, "link": link}) # 4. 打印结果 for item in results: print(item["title"], item["link"]) print(f"共提取 {len(results)} 条数据")运行之后,你会看到类似这样的输出:
第一条新闻标题 https://example.org/news/1 第二条新闻标题 https://example.org/news/2 共提取 2 条数据4.3 逐段解析为什么这么写
headers这段是必须的,没有User-Agent,部分服务器会直接拒绝请求。接下来的response.encoding = response.apparent_encoding解决中文乱码问题。
selector = soup.select("div.item")这行用了 CSS 选择器,直接把所有条目定位出来。这里要说明一个细节:select拿到的是一个列表,里面每个元素都是Tag对象,可以继续调用find和select,这种嵌套查询是 BeautifulSoup 最方便的一点。
循环里我写了item.find("h2").find("a"),这是两层嵌套查找。先找当前条目下的h2,再在h2下找a。为什么不直接item.find("a")?因为如果条目里还有其他链接,比如“”,直接find("a")就可能抓错。用层级定位可以把范围缩到最小,这也是我在前面反复强调“先看结构”的原因。
link = title_tag["href"]是取标签的属性值。这里要注意,如果find返回None,那么title_tag["href"]会直接报错。具体的防御方法,我在常见问题里会细讲。
4.4 把数据保存到 CSV 文件
提取数据只是第一步,真正有实用价值的是保存。存 CSV 是最通用的格式,用 Python 标准库的csv模块就能搞定:
import csv with open("news.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "link"]) writer.writeheader() writer.writerows(results)这里编码我特意用了utf-8-sig,而不是单纯的utf-8。原因是 Excel 默认用系统编码打开 CSV,直接写utf-8会出现中文乱码,而utf-8-sig会带一个 BOM 头,Excel 可以正常识别。这是我踩过坑之后总结出来的,现在写 CSV 都习惯用utf-8-sig。
如果你保存的数据里有特殊字符,比如英文引号、换行符,csv模块会自动处理转义,不会破坏表格结构。这也是我推荐用标准库csv而不是手动拼接字符串的原因。
5. 常见问题排查与避坑实录
5.1 页面返回 403、418 怎么办
403 是最常见的反爬信号,别慌,八成是请求头暴露了。先检查有没有带User-Agent,再看看要不要补全Referer——有些服务器会校验“你是从哪个页面跳到这里的”。Referer 可以从浏览器开发者工具的 Network 面板里,选中那次请求,找到 Request Headers 里的 Referer,复制过来加上。
418 这个状态码比较有意思,它表示“我是茶壶”,实际含义是服务器识别到你是机器人后给出的调侃性拒绝。出现这种状态码,说明你不仅缺 UA,可能连 Cookie 都不太正常。这种情况优先尝试用 Session 配合浏览器里复制出来的完整 Header 信息访问,往往能解决。
如果还是不行,检查自己的访问频率是不是太快了。连续几秒内频繁请求同一个站点,很容易被临时封 IP,最直接的策略是每次请求之间加time.sleep(1)或更长的间隔。
5.2 明明有数据却解析为空的最常见原因
拿到的 HTML 里没有你要的内容,先确认目标网页是不是动态渲染的。方法很简单:浏览器右键查看源代码,搜索目标字段,如果搜索不到,而页面上却有显示,那就说明数据是通过 JavaScript 异步加载的。这种情况 Requests 帮不了你,需要直接去找数据接口的 URL,或者换用其他浏览器自动化工具。判断清楚目标类型再动手,能避免浪费大量时间。
还有一种情况:你能看到内容,但 BeautifulSoup 查不到。比较常见的原因是页面有多层嵌套,比如目标文本在一个<template>标签里,或者在一个<script>标签的 JSON 字符串中。这种结构看起来是 HTML,但实际上是藏在转义的 JSON 里,简单解析拿不到。遇到这种就不要硬用 BeautifulSoup 了,先看清数据藏在哪儿再说。
另外,代码里find返回None会导致后续访问属性直接报AttributeError。我的习惯是打印中间结果,定位是哪一层抓取失败了。比如:
print(len(news_items)) for item in news_items[:3]: print(item.prettify())prettify()方法会把单个标签的嵌套结构格式化打印出来,特别适合看层级。这个调试方式帮我解决了不知道多少次问题。
5.3 编码乱码与请求超时排查
乱码问题先尝试:
response.encoding = response.apparent_encoding无效再尝试:
response.encoding = "utf-8"还是乱码就试"gbk"或"gb2312"。国内很多老站点用的是 GBK 编码,手动设置之后中文就正常了。
超时错误requests.exceptions.ConnectTimeout多数是网络问题或服务器太慢,不一定是你的代码问题。处理方式就是设置timeout并配合重试循环。我比较常写的模式是:请求失败后等 2 秒重试,最多重试 3 次,依然失败就跳过当前目标并记录日志,不影响整体任务运行。
5.4 访问频率与站点规则
最后一个必须讲的内容是合规。爬虫是工具,用得好帮你省时间,用不好既会增加服务器负担,也可能给自己带来麻烦。我自己的准则是:严格遵守目标网站的 robots 协议,控制访问频率,只抓取公开数据,绝不通过爬虫获取需要登录才能访问的敏感信息。爬虫是学习技术的好途径,但一定要在合法合规的前提下使用。
6. 最后的经验总结与提升方向
如果让我给初学者一句建议,我会说:先用 Requests + BeautifulSoup 把静态网页的抓取流程走通,亲手写一个 50 行以内的小爬虫,存成 CSV,再用浏览器开发者工具分析一次动态请求是怎么发的,你对爬虫的理解会上一个台阶。这个组合是基础,但绝不意味着“过时”,大量数据采集任务仍然在用这套方式完成。
后续你可以逐步延伸:学习如何处理动态接口返回的 JSON 数据,用response.json()直接解析;再学习数据清洗,去掉重复和无效条目;再往后可以了解如何把抓取任务做成定时执行,让数据每天自动更新。技术进阶路径其实是线性的,先把这篇文章里的每一步亲手做一遍,再去碰更复杂的东西,会轻松很多。
按照我个人的习惯,每次写完一个爬虫,都会主动记录三件事:目标页面结构、请求时需要的特殊 Headers、解析过程中踩过的坑。这些笔记在下次写类似爬虫时能省下大量时间。爬虫是一个实践性极强的领域,你踩过的每个坑都是其他人的“避坑指南”,多写、多调试、多总结,进步自然就出现了。