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")]
我曾经踩过的坑:XPath 的索引从 1 开始,不是 0。//div[1] 取第一个 div,不是第零个。这个跟 Python 的习惯完全不同,第一次用的时候我找了半天 bug。

4.3 处理分页与翻页逻辑

大部分网站不会把数据一次性全给你。分页是常态。翻页逻辑常见的有三种:

  1. URL 参数翻页:?page=1、?page=2……
  2. 路径翻页:/page/1/、/page/2/……
  3. 点击「下一页」按钮:需要模拟点击或找 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)  # 礼貌性延迟,别把人家服务器打崩了
避坑指南:我曾经不加延迟地抓一个网站,结果 IP 被封了 24 小时。后来我养成了两个习惯:一是每次请求间隔至少 1 秒,二是给每个项目加一个随机的 User-Agent。

对于「下一页」按钮的情况,需要从页面中提取 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 本章知识体系

下面这张图把本章的核心流程串起来了:

静态网页抓取核心流程 1. 发送 HTTP 请求 2. 解析 HTML 文档 3. 定位目标元素 CSS 选择器 XPath 4. 提取结构化数据 关键工具 • Requests:发请求 • BeautifulSoup:解析 HTML • lxml:解析引擎 翻页策略 • URL 参数翻页 • 路径翻页 • 点击「下一页」

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 时间。