4. 静态网页抓取:requests + BeautifulSoup 实战
静态网页抓取,说白了就是去拿那些「已经写死在 HTML 里」的数据。你打开浏览器,右键查看源代码,能看到的内容,基本都能用 requests 拿下来,再用 BeautifulSoup 解析出来。
我刚开始做采集的时候,总觉得这步太简单,结果踩了不少坑。嗯,今天咱们就把这块彻底捋清楚。
4.1 环境准备与核心库
先装两个库,一个负责「拿」,一个负责「拆」:
pip install requests beautifulsoup4 lxml
我个人习惯把 lxml 也装上。为什么?因为 BeautifulSoup 默认的解析器是 Python 内置的 html.parser,速度慢,容错也一般。lxml 是 C 扩展,快得多,而且对「不规范」的 HTML 容忍度更高。
4.2 requests 基础用法
拿一个页面,核心就三步:
- 构造请求 —— 告诉服务器你要什么
- 发送请求 —— 等服务器回你
- 处理响应 —— 拿到 HTML 字符串
看个最简单的例子:
import requests
url = 'https://example.com/products'
response = requests.get(url)
# 检查状态码
if response.status_code == 200:
html = response.text
print('页面获取成功,长度:', len(html))
else:
print('请求失败,状态码:', response.status_code)
这里有个坑。我曾经遇到一个网站,明明浏览器能打开,requests 却返回 403。为什么?因为服务器检查了 User-Agent。浏览器会带上一串标识,而 requests 默认的 User-Agent 是 python-requests/2.x.x,服务器一看就知道你是爬虫。
解决办法很简单,加个请求头:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(url, headers=headers)
4.3 BeautifulSoup 解析与 CSS 选择器
拿到 HTML 之后,就该 BeautifulSoup 上场了。它能把 HTML 字符串变成一棵「标签树」,然后你用选择器去树上摘果子。
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
# 用 CSS 选择器找元素
titles = soup.select('h2.product-title')
prices = soup.select('span.price')
for title, price in zip(titles, prices):
print(title.get_text(strip=True), price.get_text(strip=True))
CSS 选择器是我最常用的方式。你想想看,前端写页面用的就是 CSS,你用同样的语法去选元素,天然顺手。
常用的 CSS 选择器规则:
| 语法 | 含义 | 示例 |
|---|---|---|
tag |
标签选择器 | soup.select('div') |
.class |
类选择器 | soup.select('.price') |
#id |
ID 选择器 | soup.select('#main') |
parent > child |
直接子元素 | soup.select('ul > li') |
tag[attr] |
属性选择器 | soup.select('a[href]') |
tag[attr="value"] |
属性精确匹配 | soup.select('a[href="/login"]') |
4.4 XPath 定位元素
CSS 选择器虽然好用,但有些场景它搞不定。比如你想选「第三个 li 里的第二个 a 标签」,或者「包含某个文本的 div」。这时候 XPath 就派上用场了。
XPath 是另一种路径语言,比 CSS 选择器更强大,但也更啰嗦。BeautifulSoup 本身不支持 XPath,需要配合 lxml 的 etree 使用:
from lxml import etree
# 把 HTML 转成 lxml 的 Element 对象
tree = etree.HTML(html)
# XPath 定位
titles = tree.xpath('//h2[@class="product-title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')
for title, price in zip(titles, prices):
print(title.strip(), price.strip())
常用的 XPath 表达式:
| 表达式 | 含义 | 示例 |
|---|---|---|
//tag |
任意位置的 tag | //div |
/tag |
直接子元素 | /div |
@attr |
属性 | //a[@href] |
text() |
文本内容 | //h1/text() |
position() |
位置 | //li[position()=3] |
contains() |
包含 | //div[contains(@class, "price")] |
soup.select('a'),但选「第三个表格里的第五行」就用 XPath。两者互补,别死磕一种。
4.5 CSS 选择器 vs XPath:怎么选?
很多新手会纠结这个问题。我直接给你一个对比表:
| 维度 | CSS 选择器 | XPath |
|---|---|---|
| 语法简洁度 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| 表达能力 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 按文本筛选 | 不支持 | 支持 |
| 按位置筛选 | 有限支持 | 完全支持 |
| 性能 | 快 | 稍慢 |
| 学习曲线 | 平缓 | 陡峭 |
我个人建议:80% 的场景用 CSS 选择器,剩下 20% 用 XPath。别为了炫技用 XPath 写一个超长表达式,维护起来你自己都看不懂。
4.6 实战:抓取商品列表
咱们来一个完整的例子。假设要抓取一个图书列表页,提取书名、价格和链接:
import requests
from bs4 import BeautifulSoup
url = 'https://books.toscrape.com/'
headers = {'User-Agent': 'Mozilla/5.0'}
response = requests.get(url, headers=headers)
if response.status_code != 200:
print('页面加载失败')
exit()
soup = BeautifulSoup(response.text, 'lxml')
books = []
for article in soup.select('article.product_pod'):
title = article.h3.a.get('title')
price = article.select_one('p.price_color').get_text(strip=True)
link = article.h3.a.get('href')
books.append({'title': title, 'price': price, 'link': link})
for book in books[:5]:
print(f'{book["title"]} | {book["price"]} | {book["link"]}')
这段代码里有个细节:article.h3.a 这种链式写法,是 BeautifulSoup 的快捷方式。它等价于 article.find('h3').find('a')。但注意,如果中间某个标签不存在,会报 AttributeError。稳妥的做法是用 select_one 加判空。
select_one 返回了 None,然后 .get_text() 直接崩了。后来我加了个判断:price_tag = article.select_one('p.price_color'); price = price_tag.get_text(strip=True) if price_tag else 'N/A'。记住,永远假设数据可能缺失。
4.7 本章知识体系
下面这张图把本章的核心逻辑串起来了:
4.8 常见问题与避坑
- 中文乱码: 有些网站返回的编码不是 UTF-8。可以用
response.encoding = response.apparent_encoding自动检测,或者直接指定response.encoding = 'utf-8'。 - 动态加载的内容: 如果页面里有些数据是 JavaScript 渲染的,requests 拿不到。这时候需要 Selenium 或直接找接口。别在静态抓取上死磕。
- 反爬虫: 除了 User-Agent,有些网站还会检查 Cookie、Referer、请求频率。我建议每次请求间隔 1-3 秒,别把人家服务器打崩了。
- 选择器失效: 网站改版是常事。我一般会在代码里加个日志,每次抓取记录成功提取的数量。如果数量突然变少,大概率是选择器需要更新了。