使用 httpx、aiohttp 和代理进行异步 Python 抓取

异步可以将慢速抓取器变成快速抓取器——而快速抓取器会被阻止,除非你正确设置代理、信号量和超时。以下是使用 httpx 和 aiohttp 的完整模式,包括代码。

如果你的抓取器大部分时间都在等待网络响应,异步是提升速度的最大法宝——而代理是防止这种速度导致你被封禁的关键。本指南涵盖使用 httpxaiohttp 的异步 Python 抓取以及代理的全流程:每个库如何设置代理,如何用信号量限制并发,如何设置真正生效的超时,以及如何在不破坏会话的情况下轮换 IP。所有这些都有可运行的代码和你可以替换的占位符凭据。

为什么选择异步,以及代理的作用

标准的 requests 是阻塞的:每次调用都要等响应后才能开始下一个。抓取 500 个页面,你就要连续支付 500 次往返。异步在一个事件循环中并发抓取它们,因此总时间趋向于最慢的单个请求而不是总和。问题是:从一个 IP 发出大量并发请求正是反爬虫系统监控的特征。解决方法不是放慢速度,而是通过一个轮换代理池分散流量,并用信号量进行有意的节流。

在 httpx 中设置代理(异步)

httpx 是务实的默认选择,因为一个客户端模型同时支持同步和异步,并且支持 HTTP/2。注意现代 API:它是客户端上的一个单一 proxy= 参数,而不是旧的 proxies= 字典——这是升级后常见的“为什么我的代理被忽略”困惑的来源。凭据直接放入代理 URL 中。

import asyncio, httpx

PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"

async def fetch(client, url):
    r = await client.get(url, timeout=httpx.Timeout(20.0))
    return url, r.status_code, r.text

async def main(urls):
    async with httpx.AsyncClient(proxy=PROXY, http2=True) as client:
        tasks = [fetch(client, u) for u in urls]
        return await asyncio.gather(*tasks)

urls = ["https://httpbin.org/ip"] * 5
print(asyncio.run(main(urls)))

一个客户端,跨每个请求重复使用,这是关键——它保持连接池的温暖,因此你可以跳过通过代理的重复 TLS 握手。为每个请求创建一个新的客户端是最常见的异步性能错误:它在每次调用时抛弃了连接重用和 cookie 状态。

在 aiohttp 中设置代理(每个请求)

aiohttp 是 asyncio 原生的,提供最精细的并发控制,但其代理约定不同:代理是在 session.get() 上按请求传递的,而不是在会话上。这实际上对轮换很方便。这里有两点需要注意——默认的 User-Agent 字面上是 Python/3.x aiohttp/3.x,这是一个明显的信号你必须覆盖,并且你应该明确设置连接池大小。

import aiohttp, asyncio

PROXY = "http://USER:PASS@gate.quantumproxies.io:8000"
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
           "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"}

async def fetch(session, url):
    timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=20)
    async with session.get(url, proxy=PROXY, timeout=timeout) as resp:
        return url, resp.status, await resp.text()

async def main(urls):
    conn = aiohttp.TCPConnector(limit=20, limit_per_host=8)
    async with aiohttp.ClientSession(connector=conn, headers=HEADERS) as session:
        return await asyncio.gather(*(fetch(session, u) for u in urls))

print(asyncio.run(main(["https://httpbin.org/ip"] * 5)))

TCPConnector(limit=20, limit_per_host=8) 限制总的打开连接数和对任何单个主机的连接数——这是礼貌的第一道防线,防止一个目标吸收你的整个池。

异步抓取循环的图示:asyncio.gather 供给信号量,然后是轮换代理网关,然后是并行目标抓取
信号量节流;轮换网关伪装。你需要两者——没有它们的异步速度就是一个阻塞波。

用信号量限制并发

无限制地发射并发请求是烧毁代理池和触发速率限制的最快方式。asyncio.Semaphore 是节流器:它限制一次在飞行中的请求数量,无论你排队了多少任务。设置一个全局限制,对于激进的任务也设置一个每个域的限制。

sem = asyncio.Semaphore(15)  # never more than 15 requests in flight

async def guarded_fetch(client, url):
    async with sem:
        return await fetch(client, url)

async def main(urls):
    async with httpx.AsyncClient(proxy=PROXY) as client:
        return await asyncio.gather(*(guarded_fetch(client, u) for u in urls))

正确的数量取决于目标的容忍度和你的池大小,而不是你的机器能跑多快。保守地开始(10–20),观察你的阻塞率,并且只有在成功率保持高时才提高。在一个粘性 IP 上,保持在个位数。

超时:为每个阶段建模

代理请求比直接请求失败的地方更多——DNS 解析、连接到代理、隧道设置、连接到目标、等待头部和读取主体都是不同的停顿。单一的整体超时掩盖了哪个阶段挂起。aiohttpClientTimeout(total=, connect=, sock_read=)httpxTimeout() 允许你分别限制它们。唯一没有例外的规则:永远不要在没有超时的情况下发出请求,否则一个死锁将永远挂起一个协程并悄悄饿死你的事件循环。

在不破坏会话的情况下轮换代理

有两种轮换策略,选择错误会破坏你的数据。随机每请求轮换非常适合无状态页面抓取。但它会破坏任何依赖于 cookie、登录或本地化的流程,因为请求二落在与请求一不同的 IP 上。清晰的分割:在逻辑单元边界轮换——每个抓取段或每个账户一个 IP——并使用一个轮换网关,它会自动给你一个新的出口,这样你的代码永远不需要管理一个列表。

# rotating gateway: one endpoint, new exit IP per request
ROT = "http://USER:PASS@rotating.quantumproxies.io:8000"

# sticky session: same IP for a multi-step flow, tag the session id
STICKY = "http://USER-session-a1b2:PASS@gate.quantumproxies.io:8000"

async def crawl_segment(urls):
    async with httpx.AsyncClient(proxy=ROT) as client:  # rotates per call
        return await asyncio.gather(*(fetch(client, u) for u in urls))

轮换住宅网关是高并发抓取的务实默认选择:每请求轮换跨越 90M+ 个 IP 在 200+ 个国家中,当购物车或登录需要相同的出口几分钟时提供粘性会话。如果你在权衡住宅与 ISP 或数据中心的工作,我们的代理类型选择指南列出了权衡。

获取一个轮换住宅网关

重试、退避和抖动

死锁和瞬时阻塞在规模上是正常的,而不是例外。但天真的重试会使情况更糟:当 50 个异步任务在同一瞬间失败并立即重试时,你会发射一个同步爆发,比原始运行更猛烈地打击目标。添加指数退避加上随机抖动以便重试分散,限制尝试次数,并在失败时轮换 IP 而不是重用烧毁的那个。

import random
from httpx import HTTPError

async def robust_fetch(client, url, tries=3):
    for attempt in range(tries):
        try:
            r = await client.get(url, timeout=httpx.Timeout(20.0))
            if r.status_code < 400 and looks_real(r.text):
                return r
        except HTTPError:
            pass
        # exponential backoff + jitter before the next attempt
        await asyncio.sleep((2 ** attempt) + random.uniform(0, 1))
    return None
httpx 和 aiohttp 代理约定、超时和并发控制的并排比较
两个库,两种代理约定:httpx 在客户端上设置代理,aiohttp 每请求设置代理。

200 并不代表成功

最微妙的异步抓取错误是将 HTTP 200 视为完成。反爬虫系统返回 200 带有 CAPTCHA 页面、访问拒绝通知、空结果集或 JS 挑战——所以仅凭状态码判断“成功”的代理实际上在悄悄地给你提供被阻止的页面。验证内容:在信任响应之前检查已知元素、最小长度或挑战标记的缺失。这就是上面重试循环中的 looks_real() 门。

何时停止手动构建堆栈

上述模式——异步客户端、信号量、超时、轮换、内容验证——可以干净地处理大多数目标。但一旦网站层叠了 Cloudflare、TLS 指纹或重度客户端渲染,原始异步 HTTP 就开始失效,无论你的代理如何,因为 Python 的 TLS 握手看起来与 Chrome 的完全不同。在那条线上,一个Scraper API,携带真实的浏览器指纹,按需轮换 IP 和渲染 JavaScript,比手动维护所有这些代码更少,成功率更高。我们关于无头浏览器与 HTTP 成本的帖子涵盖了这种升级何时值得。

常见问题解答

如何在 aiohttp 中使用代理?

每请求传递代理 URL:session.get(url, proxy="http://user:pass@host:port")。与 requests 不同,aiohttp 不在会话上接受代理字典。始终覆盖默认的 User-Agent(Python/3.x aiohttp/3.x 是一个明显的机器人信号)并设置 ClientTimeout,以便死锁不能挂起协程。

httpx 或 aiohttp 哪个更适合异步抓取?

默认选择 httpx:一个客户端同时支持同步和异步,内置 HTTP/2,代理是一个单一参数。当你想要最大并发控制时选择 aiohttp——显式连接池限制和每请求代理适合大型快速抓取。两者都很好;代理策略比库更重要。

我应该运行多少个并发请求?

不是你的机器允许的数量——而是目标和你的池能容忍的数量。以 10–20 的信号量开始,观察阻塞和错误率,并且只有在成功率保持高时才提高。在一个粘性 IP 上,保持在个位数。更广泛的 IP 轮换让你可以安全地运行更高的总并发。

为什么我的异步抓取器被阻止,而同步的没有?

因为并发集中信号:来自一个 IP 的许多同时请求是经典的机器人模式。通过轮换池分散负载,用信号量节流,添加抖动退避在重试时,并验证响应内容——200 仍然可能是一个挑战页面。没有轮换的速度就是导致你被阻止的原因。

这就是完整的异步模式:选择一个客户端,为其正确设置代理,用信号量限制并发,限制每个超时阶段,在逻辑边界轮换,并且永远不要信任裸 200。首先正确设置代理层,大多数阻止列表在你遇到之前就会消失。

试用 QuantumProxies Scraper API