Парсер на Scrapy падает по таймауту, прокси-пул расходуется в разы быстрее ожидаемого, а логи забиты 407 и 403 ответами — знакомая картина? В 90% случаев проблема не в самих прокси, а в том, как написан DownloaderMiddleware. Разбираем пять самых частых ошибок в middleware, которые превращают дорогой трафик в мусорные запросы, и показываем, как их исправить с кодом.
Ошибка 1: примитивная ротация без учёта состояния прокси
Самая частая конструкция, которую можно найти в туториалах — это random.choice(PROXY_LIST) внутри process_request. Проблема в том, что такая ротация не знает, какой прокси только что получил бан, а какой всё ещё жив. В итоге парсер продолжает слать запросы через уже заблокированный IP, получает 403/429, делает retry — и снова выбирает тот же адрес, потому что выбор случайный и не исключает «плохие» узлы.
Правильный подход — вести состояние каждого прокси: количество успешных запросов, количество ошибок, время последнего использования. Вот минимальный рабочий вариант:
import random
import time
class ProxyPool:
def __init__(self, proxies):
self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}
def get_proxy(self):
now = time.time()
available = [
p for p, state in self.proxies.items()
if state["banned_until"] < now
]
if not available:
# если все забанены, сбрасываем самый "старый" бан
available = list(self.proxies.keys())
return random.choice(available)
def mark_fail(self, proxy, cooldown=300):
self.proxies[proxy]["fails"] += 1
self.proxies[proxy]["banned_until"] = time.time() + cooldown
def mark_success(self, proxy):
self.proxies[proxy]["fails"] = 0
self.proxies[proxy]["last_used"] = time.time()
Такой пул исключает забаненные IP на время «охлаждения» (cooldown) и возвращает их в оборот позже. Это уже снижает расход трафика в разы, потому что вы не долбите один и тот же заблокированный узел по кругу.
Ошибка 2: неправильная обработка retry и статус-кодов
Вторая типичная ошибка — использовать стандартный RetryMiddleware из Scrapy без модификации. По умолчанию он ретраит запрос на том же прокси, который его провалил, если вы явно не подмешали смену прокси в process_exception. В результате получаем классическую картину: 3 retry, 3 бана, запрос всё равно падает, а трафик уже потрачен.
Второй момент — не все статус-коды нужно ретраить одинаково. 429 (Too Many Requests) требует паузы и смены IP, 403 обычно означает бан конкретного прокси (нужна немедленная замена), а 5xx — это часто временная проблема на стороне сервера, ретраить можно на том же прокси. Если валить всё в одну кучу, middleware либо слишком агрессивно жжёт прокси, либо слишком долго ждёт там, где нужно было сразу сменить IP.
class SmartRetryMiddleware:
def __init__(self, pool):
self.pool = pool
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy")
if response.status in (403, 407):
if proxy:
self.pool.mark_fail(proxy, cooldown=600)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if response.status == 429:
if proxy:
self.pool.mark_fail(proxy, cooldown=120)
new_request = request.copy()
new_request.meta["proxy"] = self.pool.get_proxy()
new_request.dont_filter = True
return new_request
if proxy:
self.pool.mark_success(proxy)
return response
Здесь важно разделять cooldown по типу ошибки: жёсткий бан (403/407) — долгая пауза, лимит частоты (429) — короткая. Это экономит десятки процентов трафика на длинных прогонах.
Ошибка 3: отсутствие sticky-сессий для сайтов с авторизацией
Если парсер работает с сайтом, где есть логин, корзина, пагинация с сохранением состояния или капча с проверкой по IP — смена прокси на каждый запрос ломает сессию. Сайт видит, что запрос №1 пришёл с одного IP, а запрос №2 (в рамках той же cookie-сессии) — с другого, и это триггерит защиту от бота мгновенно, даже если оба IP «чистые».
Решение — привязывать один прокси к одной логической сессии (например, к конкретному аккаунту или к цепочке запросов внутри одного домена) на фиксированное время, а не менять его на каждый запрос. Это называется sticky-сессия.
class StickySessionMiddleware:
def __init__(self, pool, ttl=600):
self.pool = pool
self.ttl = ttl
self.sessions = {} # session_id -> (proxy, expires_at)
def process_request(self, request, spider):
session_id = request.meta.get("session_id")
if not session_id:
return
now = time.time()
session = self.sessions.get(session_id)
if session and session[1] > now:
request.meta["proxy"] = session[0]
else:
proxy = self.pool.get_proxy()
self.sessions[session_id] = (proxy, now + self.ttl)
request.meta["proxy"] = proxy
Для задач, где нужна стабильность IP на протяжении всей сессии — авторизация, работа с личным кабинетом, многошаговые формы — лучше всего подходят резидентные прокси с поддержкой сессий: они позволяют держать один и тот же исходящий IP несколько минут или часов, а затем менять его контролируемо, а не случайным образом на каждый запрос.
Ошибка 4: неверная передача авторизации прокси
Четвёртая ошибка — техническая, но встречается почти в каждом втором проекте. Разработчики передают логин и пароль прокси прямо в URL вида http://user:pass@ip:port через request.meta["proxy"]. Это работает в большинстве случаев, но при использовании прокси через HTTPS-туннель или при работе с некоторыми провайдерами такой способ авторизации некорректно обрабатывается стандартным HttpProxyMiddleware, и запросы падают с 407 Proxy Authentication Required, хотя учётные данные верные.
Более надёжный способ — передавать заголовок Proxy-Authorization явно, закодированный в base64:
import base64
class ProxyAuthMiddleware:
def process_request(self, request, spider):
proxy = request.meta.get("proxy")
if not proxy:
return
# proxy без учётных данных в URL
request.meta["proxy"] = proxy
user = request.meta.get("proxy_user")
password = request.meta.get("proxy_pass")
if user and password:
credentials = f"{user}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
request.headers["Proxy-Authorization"] = f"Basic {encoded}"
Такой подход стабильнее работает при масштабировании до сотен параллельных запросов и не зависит от того, как конкретная версия библиотеки парсит URL с embedded credentials. Особенно это критично при работе с мобильными прокси, где аутентификация часто завязана на IP whitelist или на строгую проверку заголовков.
Ошибка 5: нет мониторинга и логирования банов
Последняя и, возможно, самая дорогая по последствиям ошибка — отсутствие логирования того, какие прокси банятся, как часто и на каких доменах. Без этих данных невозможно понять, что именно жрёт трафик: то ли пул прокси исчерпал себя на конкретном сайте, то ли проблема в самом парсере (слишком частые запросы, отсутствие задержек, подозрительные заголовки).
Минимальный набор метрик, который стоит логировать в middleware:
- Количество запросов на каждый прокси за сессию парсинга
- Количество и коды ошибок (403, 407, 429, 5xx) по каждому прокси
- Время жизни прокси до первого бана
- Домены, на которых баны случаются чаще всего
import logging
import json
logger = logging.getLogger("proxy_stats")
class ProxyStatsMiddleware:
def __init__(self):
self.stats = {}
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy", "unknown")
domain = request.url.split("/")[2]
key = f"{proxy}|{domain}"
entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
entry["requests"] += 1
if response.status in (403, 407, 429):
entry["errors"] += 1
if entry["requests"] % 50 == 0:
logger.info(json.dumps(self.stats))
return response
Без такой статистики любые попытки «оптимизировать» middleware сводятся к гаданию. С ней вы точно видите: если, например, конкретный домен банит 80% прокси-пула за первые 10 запросов — проблема не в прокси, а в паттерне запросов (нет ротации User-Agent, слишком высокая частота, отсутствие задержек между запросами).
Рабочий пример middleware целиком
Собираем всё вместе в settings.py — порядок middleware критичен, потому что от него зависит, в каком порядке применяются проверки:
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyAuthMiddleware": 350,
"myproject.middlewares.StickySessionMiddleware": 400,
"myproject.middlewares.SmartRetryMiddleware": 550,
"myproject.middlewares.ProxyStatsMiddleware": 900,
}
RETRY_ENABLED = False # отключаем стандартный retry, используем свой
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8
Обратите внимание на RETRY_ENABLED = False — это принципиально, иначе встроенный retry-механизм Scrapy будет конфликтовать с вашей логикой смены прокси, и запросы начнут дублироваться или ретраиться дважды. Также важно ограничивать CONCURRENT_REQUESTS_PER_DOMAIN — слишком высокая параллельность на один домен даже с ротацией прокси выглядит подозрительно для антибот-систем.
Какой тип прокси выбрать для Scrapy
Middleware решает половину проблемы, вторая половина — правильный выбор самого прокси-пула под задачу. Ниже сравнение по типичным сценариям парсинга.
| Тип прокси | Когда использовать | Плюсы | Минусы |
|---|---|---|---|
| Прокси дата-центров | Массовый парсинг открытых страниц без строгой антибот-защиты | Высокая скорость, низкая стоимость трафика | Легко детектируются, часто в blacklist |
| Резидентные прокси | Парсинг маркетплейсов, сайтов с JS-защитой, авторизация | Реальные IP, низкий процент банов, поддержка sticky-сессий | Ниже скорость по сравнению с DC |
| Мобильные прокси | Парсинг мобильных версий сайтов и API с жёсткой антибот-защитой | Максимальное доверие со стороны целевых сайтов | Самая высокая стоимость трафика |
Практическое правило: для парсинга статичных страниц без сильной защиты подойдут дешёвые датацентровые прокси в связке с грамотным middleware из этой статьи. Если сайт использует Cloudflare, PerimeterX, DataDome или похожие системы — датацентровые IP будут банится почти сразу, и тут уже выгоднее сразу переходить на резидентный пул, экономя время на дебаге middleware.
Чек-лист перед запуском парсера в продакшн
- Middleware исключает забаненные прокси на cooldown, а не выбирает их случайно
- Retry-логика различает типы ошибок (403/407 vs 429 vs 5xx) с разным cooldown
- Для сессионных задач используется sticky-привязка прокси, а не смена на каждый запрос
- Авторизация прокси передаётся через заголовок Proxy-Authorization, а не только через URL
- Ведётся статистика банов по доменам и прокси для диагностики проблем
- Стандартный RetryMiddleware Scrapy отключён, чтобы не конфликтовать с кастомной логикой
- Concurrency ограничена разумными значениями, а не выставлена на максимум
Заключение
Большинство проблем с расходом прокси-трафика в Scrapy решаются не покупкой большего пула IP, а исправлением логики middleware: разделением ошибок по типам, sticky-сессиями для сложных сценариев, корректной авторизацией и постоянным мониторингом банов. Код из этой статьи можно взять как основу и адаптировать под конкретный проект — структура остаётся рабочей и для небольших парсеров, и для распределённых Scrapy-Cluster-инсталляций.
Если middleware уже настроен правильно, а баны всё равно происходят слишком часто — вероятно, дело в качестве самого IP-пула. Для парсинга сайтов с продвинутой антибот-защитой стоит попробовать резидентные прокси с поддержкой сессий — они заметно снижают процент срабатывания защиты по сравнению с датацентровыми адресами при той же логике middleware.