4. 静态网页抓取实战:Requests + BeautifulSoup
静态网页抓取,说白了就是去拿那些「服务器已经把 HTML 给你组装好了」的页面。你发个请求,它返回完整的 HTML,剩下的就是怎么从这堆标签里把数据抠出来。
我刚开始做采集的时候,也以为这很简单。直到有一次,我对着一个看起来人畜无害的页面抓了三天,才发现问题出在分页参数上。嗯,咱们一步步来。
4.1 环境准备:Requests 与 BeautifulSoup
这两个库是 Python 数据采集的「左右手」。一个负责发请求拿 HTML,一个负责解析 HTML 提取数据。
| 库名 | 职责 | 安装命令 |
|---|---|---|
| Requests | 发送 HTTP 请求,获取响应内容 | pip install requests |
| BeautifulSoup4 | 解析 HTML/XML,提取结构化数据 | pip install beautifulsoup4 |
| lxml(推荐) | 作为 BeautifulSoup 的解析引擎,速度更快 | pip install lxml |
lxml。Python 自带的 html.parser 在遇到不规范标签时容易翻车,lxml 容错性好得多。
先看一个最基础的请求示例:
import requests
from bs4 import BeautifulSoup
# 发请求,拿 HTML
url = 'https://example.com/books'
response = requests.get(url)
response.encoding = 'utf-8' # 手动指定编码,避免乱码
# 解析 HTML
soup = BeautifulSoup(response.text, 'lxml')
# 提取标题
title = soup.title.text
print(f'页面标题:{title}')
这里有个坑——编码问题。我曾经在抓一个台湾网站时,返回的页面声明是 big5,但实际内容是 utf-8。如果不手动指定 response.encoding,拿到的就是一堆乱码。我的做法是:先看 response.apparent_encoding,再决定要不要覆盖。
4.2 CSS 选择器与 XPath:定位元素的两种武器
拿到 HTML 之后,怎么找到你要的数据?两种主流方式:CSS 选择器 和 XPath。
4.2.1 CSS 选择器
BeautifulSoup 原生支持 CSS 选择器,通过 soup.select() 调用。我个人更偏爱这种方式,因为写起来直观,跟前端写样式差不多。
| 选择器语法 | 含义 | 示例 |
|---|---|---|
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"]') |
实战例子:抓取一个图书列表的标题和价格。
html = '''
<div class="book-list">
<div class="book-item">
<h2 class="title">Python 入门</h2>
<span class="price">¥39.00</span>
</div>
<div class="book-item">
<h2 class="title">数据科学实战</h2>
<span class="price">¥59.00</span>
</div>
</div>
'''
soup = BeautifulSoup(html, 'lxml')
items = soup.select('.book-item')
for item in items:
title = item.select_one('.title').text
price = item.select_one('.price').text
print(f'{title}: {price}')
select() 返回列表,select_one() 返回第一个匹配元素。如果没找到,返回 None,记得做空值判断。
4.2.2 XPath
XPath 是另一种定位方式,功能更强大,但语法稍复杂。BeautifulSoup 本身不支持 XPath,需要配合 lxml 的 etree 模块使用。
from lxml import etree
html = '''...''' # 同上
tree = etree.HTML(html)
# XPath 定位
titles = tree.xpath('//div[@class="book-item"]/h2/text()')
prices = tree.xpath('//div[@class="book-item"]/span/text()')
for t, p in zip(titles, prices):
print(f'{t}: {p}')
| XPath 语法 | 含义 | 示例 |
|---|---|---|
//tag |
任意位置的 tag | //div |
/tag |
直接子元素 | /div |
@attr |
属性 | //a[@href] |
text() |
文本内容 | //h2/text() |
contains() |
包含匹配 | //div[contains(@class, "book")] |
//div[1] 取第一个 div,不是第零个。这个跟 Python 的习惯完全不同,第一次用的时候我找了半天 bug。
4.3 处理分页与翻页逻辑
大部分网站不会把数据一次性全给你。分页是常态。翻页逻辑常见的有三种:
- URL 参数翻页:
?page=1、?page=2…… - 路径翻页:
/page/1/、/page/2/…… - 点击「下一页」按钮:需要模拟点击或找
next链接
最规范的是第一种。我遇到的大部分网站都用这个模式。
import time
base_url = 'https://example.com/books?page={}'
page = 1
all_data = []
while True:
url = base_url.format(page)
response = requests.get(url)
soup = BeautifulSoup(response.text, 'lxml')
# 提取当前页数据
items = soup.select('.book-item')
if not items:
break # 没有数据了,退出循环
for item in items:
title = item.select_one('.title').text
price = item.select_one('.price').text
all_data.append({'title': title, 'price': price})
print(f'第 {page} 页完成,共 {len(items)} 条')
page += 1
time.sleep(1) # 礼貌性延迟,别把人家服务器打崩了
对于「下一页」按钮的情况,需要从页面中提取 next 链接:
# 找「下一页」的链接
next_link = soup.select_one('a.next-page')
if next_link and next_link.get('href'):
next_url = next_link['href']
# 继续请求 next_url
else:
break # 没有下一页了
4.4 本章知识体系
下面这张图把本章的核心流程串起来了:
4.5 综合实战:抓取图书列表
把上面所有知识点串起来,写一个完整的采集脚本。目标:抓取一个分页图书列表的标题、价格和链接。
import requests
from bs4 import BeautifulSoup
import time
import csv
def fetch_page(url):
"""抓取单页数据"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
try:
response = requests.get(url, headers=headers, timeout=10)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f'请求失败:{response.status_code}')
return None
return response.text
except Exception as e:
print(f'请求异常:{e}')
return None
def parse_page(html):
"""解析页面,提取数据"""
soup = BeautifulSoup(html, 'lxml')
items = soup.select('.book-item')
data = []
for item in items:
title = item.select_one('.title')
price = item.select_one('.price')
link = item.select_one('a')
if title and price:
data.append({
'title': title.text.strip(),
'price': price.text.strip(),
'link': link.get('href') if link else ''
})
return data
def main():
base_url = 'https://example.com/books?page={}'
all_books = []
page = 1
while True:
print(f'正在抓取第 {page} 页...')
html = fetch_page(base_url.format(page))
if not html:
break
books = parse_page(html)
if not books:
print('没有更多数据,停止抓取')
break
all_books.extend(books)
print(f'第 {page} 页抓取完成,当前累计 {len(all_books)} 条')
page += 1
time.sleep(1.5) # 礼貌性延迟
# 保存到 CSV
with open('books.csv', 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['title', 'price', 'link'])
writer.writeheader()
writer.writerows(all_books)
print(f'全部完成,共抓取 {len(all_books)} 条数据,已保存到 books.csv')
if __name__ == '__main__':
main()
- 把请求和解析拆成独立函数,方便复用和调试
- 加
try-except处理网络异常,别让一个请求失败搞崩整个脚本 - 用
time.sleep()控制请求频率,这是基本的「网络礼仪」 - 数据落地到 CSV,方便后续分析
strip(),结果标题里全是换行和空格,清洗数据花了我半小时。另外,select_one() 返回 None 时直接调 .text 会报 AttributeError,一定要先判空。
静态网页抓取,说白了就是「请求 + 解析 + 提取」三步走。Requests 负责第一步,BeautifulSoup 负责后两步。CSS 选择器和 XPath 是定位数据的工具,选哪个看个人习惯——我偏爱 CSS 选择器,因为它更接近前端思维。分页处理的关键是找到翻页规律,然后循环请求。
嗯,这一章的内容就到这里。代码都经过实战检验,你直接拿去用就行。记住:加延迟、加异常处理、加空值判断——这三个习惯能帮你省掉 80% 的 debug 时间。