Асинхронный парсинг на Python с использованием httpx, aiohttp и прокси

Асинхронность превращает медленный парсер в быстрый — и быстрый парсер в заблокированный, если вы неправильно настроите прокси, семафоры и тайм-ауты. Вот полный шаблон для httpx и aiohttp с кодом.

Если ваш парсер проводит большую часть времени ожидания в сети, асинхронность — это самое большое ускорение, которое можно получить, а прокси — это то, что предотвращает блокировку. Это руководство охватывает асинхронный парсинг на Python с использованием httpx и aiohttp плюс прокси от начала до конца: как каждая библиотека настраивает прокси, как ограничить параллелизм с помощью семафора, как установить тайм-ауты, которые действительно срабатывают, и как менять 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 через прокси. Создание нового клиента для каждого запроса — самая распространенная ошибка производительности в асинхронности: это выбрасывает повторное использование соединений и состояние куки на каждом вызове.

Настройка прокси в 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, подключение к прокси, настройка туннеля, подключение к цели, ожидание заголовков и чтение тела — все это отдельные задержки. Один общий тайм-аут скрывает, какая фаза зависла. ClientTimeout(total=, connect=, sock_read=) в aiohttp и Timeout() в httpx позволяют ограничивать их по отдельности. Единственное правило без исключений: никогда не отправляйте запрос без тайм-аута, иначе один зависший выход повесит корутину навсегда и тихо истощит ваш цикл событий.

Меняйте прокси, не нарушая сессии

Существуют две стратегии ротации, и неправильный выбор испортит ваши данные. Случайная ротация для каждого запроса идеально подходит для безгосударственных выборок страниц. Но она разрушает любой поток, который зависит от куки, входа в систему или локализации, потому что второй запрос попадает на другой 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 начинает проигрывать независимо от вашего прокси, потому что рукопожатие TLS на Python выглядит не так, как у Chrome. В этой точке Scraper API, который несет реальный отпечаток браузера, меняет IP и рендерит JavaScript по требованию, требует меньше кода и дает более высокий уровень успеха, чем поддержка всего этого вручную. Наш пост о стоимости headless против 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