正在学习

5.5 示例项目:优化网络爬虫

# 依赖:
# pip install requests beautifulsoup4

import time
from typing import List, Dict
import requests
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "MasteringClaudeCode/1.0 (+https://example.org)"}

def fetch(url: str, timeout: float = 10.0) -> str:
    """同步获取一个 URL 并返回文本内容。"""
    resp = requests.get(url, headers=HEADERS, timeout=timeout)
    resp.raise_for_status()
    return resp.text

def parse(html: str) -> Dict[str, str]:
    """提取简单字段:<title> 和第一个 <h1>(如果存在)。"""
    soup = BeautifulSoup(html, "html.parser")
    title = (soup.title.string or "").strip() if soup.title else ""
    h1 = (soup.find("h1").get_text(strip=True)) if soup.find("h1") else ""
    return {"title": title, "h1": h1}

def scrape(urls: List[str]) -> List[Dict[str, str]]:
    results = []
    for url in urls:
        html = fetch(url)
        data = parse(html)
        data["url"] = url
        results.append(data)
        time.sleep(0.2) # 礼貌性延迟
    return results

if __name__ == "__main__":
    URLS = [
        "https://example.com",
        "https://httpbin.org/html",
        "https://www.iana.org/domains/reserved",
    ]
    start = time.time()
    items = scrape(URLS)
    elapsed = time.time() - start
    for item in items:
        print(f"{item['url']}\n title: {item['title']}\n h1: {item['h1']}\n")
    print(f"Baseline elapsed: {elapsed:.3f}s")

这个基础版本简单且正确,但它在每次网络请求时都会阻塞。随着 URL 列表的增长,总耗时与延迟呈线性关系,并且任何瞬时错误都会中断处理。

优化版:并发、连接池化且健壮的爬虫

# optimized_scraper.py
# 依赖:
# pip install aiohttp aiodns beautifulsoup4 cchardet orjson
# (aiodns 为可选;在某些平台上可加速 DNS 解析)
import asyncio
from typing import List, Dict, Optional, Tuple
from contextlib import asynccontextmanager
import random
import time
import aiohttp
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "MasteringClaudeCode/1.0 (+https://example.org)"}

# 有界并发可避免压垮远端服务器和本地机器。
MAX_CONCURRENCY = 10
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=12, connect=5, sock_connect=5, sock_read=10)

@asynccontextmanager
async def make_session() -> aiohttp.ClientSession:
    connector = aiohttp.TCPConnector(limit=MAX_CONCURRENCY, ttl_dns_cache=300)
    async with aiohttp.ClientSession(headers=HEADERS, timeout=REQUEST_TIMEOUT, connector=connector) as session:
        yield session

async def fetch(session: aiohttp.ClientSession, url: str, attempt: int = 1, max_attempts: int = 3) -> str:
    """
    获取一个 URL,在瞬时错误时采用指数退避。
    若所有尝试都失败,则抛出最后一个异常。
    """
    try:
        async with session.get(url) as resp:
            # 对非 2xx 响应快速失败
            if resp.status >= 400:
                raise aiohttp.ClientResponseError(
                    request_info=resp.request_info,
                    history=resp.history,
                    status=resp.status,
                    message=f"HTTP {resp.status}",
                    headers=resp.headers,
                )
            # 高效解码;必要时依赖 aiohttp 的 chardet
            return await resp.text()
    except (aiohttp.ClientError, asyncio.TimeoutError) as e:
        if attempt >= max_attempts:
            raise
        # 带抖动的指数退避
        backoff = (2 ** (attempt - 1)) + random.uniform(0, 0.25)
        await asyncio.sleep(backoff)
        return await fetch(session, url, attempt + 1, max_attempts)

def parse_fast(html: str) -> Dict[str, str]:
    """
    优先使用 BeautifulSoup 的 'lxml' 解析器以提升速度,
    不可用时回退到 'html.parser'。
    """
    try:
        soup = BeautifulSoup(html, "lxml")
    except Exception:
        soup = BeautifulSoup(html, "html.parser")
    title = (soup.title.string or "").strip() if soup.title else ""
    h1_tag = soup.find("h1")
    h1 = h1_tag.get_text(strip=True) if h1_tag else ""
    return {"title": title, "h1": h1}

async def scrape_one(semaphore: asyncio.Semaphore, session: aiohttp.ClientSession, url: str) -> Tuple[str, Optional[Dict[str, str]], Optional[str]]:
    async with semaphore:
        try:
            html = await fetch(session, url)
            data = parse_fast(html)
            data["url"] = url
            return url, data, None
        except Exception as e:
            return url, None, f"{type(e).__name__}: {e}"

async def scrape(urls: List[str]) -> List[Dict[str, str]]:
    semaphore = asyncio.Semaphore(MAX_CONCURRENCY)
    async with make_session() as session:
        tasks = [scrape_one(semaphore, session, url) for url in urls]
        results = []
        for coro in asyncio.as_completed(tasks):
            url, data, err = await coro
            if err:
                results.append({"url": url, "error": err, "title": "", "h1": ""})
            else:
                results.append(data)
        return results

if __name__ == "__main__":
    URLS = [
        "https://example.com",
        "https://httpbin.org/html",
        "https://www.iana.org/domains/reserved",
        # 在此处添加更多 URL 以观察并发效果
    ]
    started = time.time()
    items = asyncio.run(scrape(URLS))
    elapsed = time.time() - started
    for item in items:
        if item.get("error"):
            print(f"{item['url']}\n error: {item['error']}\n")
        else:
            print(f"{item['url']}\n title: {item['title']}\n h1: {item['h1']}\n")
    print(f"Optimized elapsed: {elapsed:.3f}s with concurrency={MAX_CONCURRENCY}")

优化版的关键改进:

  • 通过信号量和连接器限制实现有界并发,既保持礼貌访问,又保护资源。
  • 通过 TCPConnector 实现连接池化,减少大量请求间的握手开销。
  • 超时与退避机制可避免请求卡死,并能从容应对瞬时失败,而不会让整个流程崩溃。
  • 更快的解析会自动优先选用 lxml,同时保持对标准解析器的兼容性。
  • 结构化的结果统一捕获成功与错误,使下游处理更简单。
优化项 变更内容 重要性 对行为的影响
并发 单线程循环 → 使用 aiohttp 和信号量的 asyncio 通过 I/O 重叠隐藏网络延迟 显著降低多个 URL 的墙钟时间
连接复用 每个请求一个 TCP 连接 → 池化 TCPConnector 减少重复的握手和 DNS 查询 降低每个请求的开销
超时与退避 无超时/重试 → 显式超时 + 指数退避 快速失败并从瞬态错误中恢复 提高鲁棒性和吞吐量稳定性
解析器选择 仅使用内置解析器 → 优先使用 lxml 并设置回退 在 CPython 中实现更快的 HTML 解析 减少每页的 CPU 时间
错误处理 异常向上抛出 → 结构化错误行 在部分失败时保持管道运行 提升大规模场景下的可操作性

你从一个正确的同步爬虫出发,通过深入思考时间究竟消耗在哪里,逐步将其演进为一个并发、连接池化且具备容错能力的管道,同时保持了良好的可读性和可修改性。Claude Code 在此工作流中的价值在于:能够解释每次变更为何重要、预测其影响,并在性能提升的同时保持设计的整洁性。在下一节中,你将把这些技术推广为数据采集服务可复用的优化清单:测量瓶颈、选择合适的并发模型,并验证提速不以牺牲正确性或可维护性为代价。

练习题

基线爬虫的一个关键特征是什么?

A. 它使用并发请求以最小化延迟
B. 它会在每次网络请求时阻塞
C. 它会自动重试临时性错误
D. 它使用连接池以提高效率

优化版爬虫不需要哪个包?

A. aiohttp
B. beautifulsoup4
C. requests
D. orjson

优化后的爬虫中,有界并发有哪些优势?(选择所有适用的项)

A. 防止压垮远程服务器
B. 降低网络延迟
C. 保护本地系统资源
D. 消除对超时的需求

优化后的爬虫使用指数退避来处理瞬时错误。

优化后的爬虫将总请求超时设置为___秒。

解释优化爬虫中 parse_fast 函数的目的。

与基线相比,优化后的爬虫的以下哪项是一项关键改进?

A. 更简单的错误处理
B. 使用信号量的有界并发
C. 同步请求处理
D. 请求没有超时

优化后的爬虫中 REQUEST_TIMEOUT 的组成部分是什么?(选择所有适用的)

A. total=12
B. connect=5
C. sock_connect=5
D. sock_read=10
E. retry=3

优化后的爬虫使用带有连接数限制的 TCPConnector 来管理连接池。

优化后的爬虫使用 ___ 在结果处理完成时立即产出,而不是将它们全部存储在内存中。

描述优化后的爬虫在网络请求期间如何处理瞬态错误。

以下哪些是由优化后的爬虫所捕获的结构化结果?(选择所有适用的)

A. 带有数据的成功项
B. 带有描述的错误项
C. 解析时间
D. 已处理的 URL

优化后的爬虫的 parse_fast 函数总是使用 'lxml' 进行解析。

优化后的爬虫引入了一个___来限制并发请求的数量。

为什么优化后的爬虫使用结构化结果,而不是简单地通过抛出异常来处理错误?

在网页爬虫中使用有界并发的最大好处是什么?

A. 它增加了每秒请求数
B. 它防止压垮远程服务器和您自己的机器
C. 它消除了对超时的需求
D. 它使代码更易读

与基线相比,优化后的爬虫在以下哪些方面进行了关键改进?

A. 使用信号量和连接器限制的有界并发
B. 使用列表推导式进行解析
C. 用于处理瞬时错误的超时和退避机制
D. 使用 lxml 进行更快的解析
E. 为简单起见使用同步请求

优化后的爬虫使用指数退避来处理瞬时错误,这有助于防止整个运行崩溃。

优化后的爬虫在可用时优先使用 ___ 解析 HTML,否则回退到 'html.parser',以提高解析速度。

解释为什么有界并发在网页爬取中很重要,以及它与爬取中礼貌性概念的关系。

登录后解锁笔记、知识点解析、AI 问答

立即登录