В пиларной статье о парсинге — база: requests, BeautifulSoup, первые прокси. Эта — следующая ступень, когда одного IP на всю ночь уже не хватает: как построить сбор на 100К+ страниц в сутки и не попасть в бан на середине списка URL.
Код проверен на Python 3.12, requests 2.32. Для asyncio-варианта архитектура та же — меняется только транспорт.
Что реально банит: три слоя защиты
Прежде чем покупать прокси, поймите, что именно вас блокирует. Защита работает в три эшелона:
- По IP и частоте — считают запросы с адреса, банят при превышении лимита. Лечится паузами и ротацией IP.
- По TLS-фингерпринту — смотрят, чем сделан запрос: браузер или библиотека. Обычный
requestsдетектится мгновенно, независимо от прокси. Лечитсяcurl_cffi(имитация Chrome) или Playwright. - По поведению — паттерны: идеально равные интервалы, одинаковый порядок страниц, отсутствие кук между запросами. Лечится рандомизацией и липкими сессиями.
Классическая ошибка — покупать дорогие residential-прокси, чтобы пробить слой 1, когда вас ловит слой 2. Диагностируйте в этом порядке.
Residential vs datacenter: честная математика
| Datacenter | Residential | |
|---|---|---|
| Цена | ~$1–2 за IP/мес | ~$10–20 за ГБ трафика |
| Кто видит | ASN хостера | Домашний провайдер |
| Скорость | Высокая, стабильная | Средняя, зависит от пула |
| Когда хватает | Большинство сайтов | Cloudflare-захардкоженные, маркетплейсы |
Правило из практики: начинайте с дешёвого datacenter-пула и апгрейдьтесь по факту блокировок. Residential трафик дорогой, и выжигать его на сайтах, которые сдались бы и с датацентром — прямой путь переплатить втрое.
Менеджер прокси: ротация по ошибкам, а не по запросу
Главный принцип: IP меняем, когда текущий показал, что не работает. Ротация «каждый запрос новый IP» сжигает пул и выглядит для защиты хуже, чем стабильный IP.
import random
import time
from itertools import cycle
import requests
USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
]
class ProxyRotator:
"""Выдаёт рабочий прокси; неработающие выбывают из ротации."""
def __init__(self, proxies: list[str]):
self._pool = list(proxies)
self._dead: set[str] = set()
self._current = None
def get(self) -> dict | None:
alive = [p for p in self._pool if p not in self._dead]
if not alive:
raise RuntimeError("Все прокси мертвы — проверьте пул или лимиты")
if self._current is None or self._current in self._dead:
self._current = random.choice(alive)
return {"http": self._current, "https": self._current}
def mark_dead(self) -> None:
if self._current:
self._dead.add(self._current)
self._current = None
rotator = ProxyRotator([
"http://user:pass@gw1.provider.com:8000",
"http://user:pass@gw2.provider.com:8000",
"http://user:pass@gw3.provider.com:8000",
])
def fetch(url: str) -> str:
for attempt in range(4):
proxy = rotator.get()
try:
response = requests.get(
url,
headers={"User-Agent": random.choice(USER_AGENTS)},
proxies=proxy,
timeout=(5, 20),
)
except requests.RequestException:
rotator.mark_dead() # прокси не отвечает — вылетает из пула
continue
if response.status_code == 200:
return response.text
if response.status_code in (403, 429, 503):
rotator.mark_dead() # IP забанен — меняем и пробуем другой
time.sleep(min(2 ** attempt, 8))
continue
response.raise_for_status()
raise RuntimeError(f"Не собрать {url}: закончились живые прокси")
Три детали, на которых ломаются самописные ротаторы:
- Баним прокси, только получив от сайта вердикт (403/429) или таймаут. Не от банальной ошибки сети — иначе пул схлопнется от шума.
mark_deadбезусловно при 403 — но при 429 добавляйте паузу, это rate limit, а не бан: IP оживёт.- Проверяйте пул при старте. 10–30% выданных провайдером IP мёртвые. Дёшёвая проверка — GET к
https://api.ipify.orgчерез каждый прокси перед началом сбора.
Липкие сессии: ведите себя как пользователь, а не как шардинг
Защита смотрит на цепочку: зашёл на каталог → открыл карточку → положил в корзину. У каждого шага — одинаковые куки и один IP. Значит, сбор одной «истории» должен идти через один прокси и одну сессию:
session = requests.Session()
session.proxies = rotator.get()
session.headers["User-Agent"] = random.choice(USER_AGENTS)
for url in user_journey: # все страницы одного «пользователя»
html = session.get(url, timeout=(5, 20)).text
time.sleep(random.uniform(2, 7)) # человек не кликает по 300 мс
Практическое правило: один worker = один прокси = один «пользователь». Разделяйте URL-план между воркерами, а не мечите запросы в общий пул.
Контроль бан-рейта: метрика, которая спасает сбор
Банят всегда постепенно: сначала 1% ответов 403, потом 5%, потом весь пул мёртв. Если заметить на 1% — остановиться и снизить темп — сбор спасён. Заметить на 40% — ночь сборки потеряна.
import logging
stats = {"ok": 0, "banned": 0}
def ban_rate() -> float:
total = stats["ok"] + stats["banned"]
return stats["banned"] / total if total else 0.0
# после каждого запроса:
# stats["ok"] += 1 или stats["banned"] += 1
# и периодически:
if ban_rate() > 0.05:
logging.warning("Бан-рейт %.1f%% — снижаю темп", ban_rate() * 100)
time.sleep(60) # пауза + снижение concurrency
Пороги из практики: до 2% — работаем; 2–5% — снижаем частоту вдвое; больше 5% — стоп, разбираемся, что именно банит (см. три слоя защиты выше).
TLS-фингерпринт: прокси не спасёт от curl-запаха
Если смена IP не помогает, а первый же запрос получает 403 — вас ловит по TLS-стеку. Замена — две строчки:
# pip install curl_cffi
from curl_cffi import requests as cffi_requests
response = cffi_requests.get(url, impersonate="chrome", proxies=rotator.get(), timeout=20)
curl_cffi с impersonate="chrome" отдаёт точно такой же TLS-фингерпринт, как настоящий Chrome. В связке с residential-прокси это проходит большинство Cloudflare-проверок без капчи. Это не «хак», а промышленный стандарт — так работают все серьёзные сборщики.
Чек-лист перед запуском большой сборки
- Проверить пул прокси на живость, мёртвые — сразу в
dead. - Прогнать 100 URL на малой скорости, посмотреть бан-рейт.
- Если банит с первого запроса — curl_cffi, если после N — ротация и паузы.
- Настроить логирование: URL, код ответа, прокси, время — без этого не разберёте падение.
- Хранить прогресс (обработанные URL) в SQLite с unique-индексом — перезапуск не должен начинать с нуля.
Итог
Масштабирование парсинга — это не «купить 1000 прокси», а управление тремя слоями: частота запросов, фингерпринт, поведение. Дешёвый datacenter-пул + ротация по ошибкам + контроль бан-рейта закрывают 90% задач. Оставшиеся 10% — curl_cffi и residential.
Собранные данные надо куда-то девать — удобнее всего через Telegram-бота: собрал → прислал себе в чат → лёг спать.