Асинхронный парсинг на 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.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

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. Сначала настройте слой прокси правильно, и большинство списка блокировок исчезнет до того, как вы его достигнете.