Python: от базы до продвинутого

Парсинг с прокси и ротацией: как собирать 100 000+ страниц в сутки без бана

В пиларной статье о парсинге — база: requests, BeautifulSoup, первые прокси. Эта — следующая ступень, когда одного IP на всю ночь уже не хватает: как построить сбор на 100К+ страниц в сутки и не попасть в бан на середине списка URL.

Код проверен на Python 3.12, requests 2.32. Для asyncio-варианта архитектура та же — меняется только транспорт.

Что реально банит: три слоя защиты

Прежде чем покупать прокси, поймите, что именно вас блокирует. Защита работает в три эшелона:

  1. По IP и частоте — считают запросы с адреса, банят при превышении лимита. Лечится паузами и ротацией IP.
  2. По TLS-фингерпринту — смотрят, чем сделан запрос: браузер или библиотека. Обычный requests детектится мгновенно, независимо от прокси. Лечится curl_cffi (имитация Chrome) или Playwright.
  3. По поведению — паттерны: идеально равные интервалы, одинаковый порядок страниц, отсутствие кук между запросами. Лечится рандомизацией и липкими сессиями.

Классическая ошибка — покупать дорогие 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-проверок без капчи. Это не «хак», а промышленный стандарт — так работают все серьёзные сборщики.

Чек-лист перед запуском большой сборки

  1. Проверить пул прокси на живость, мёртвые — сразу в dead.
  2. Прогнать 100 URL на малой скорости, посмотреть бан-рейт.
  3. Если банит с первого запроса — curl_cffi, если после N — ротация и паузы.
  4. Настроить логирование: URL, код ответа, прокси, время — без этого не разберёте падение.
  5. Хранить прогресс (обработанные URL) в SQLite с unique-индексом — перезапуск не должен начинать с нуля.

Итог

Масштабирование парсинга — это не «купить 1000 прокси», а управление тремя слоями: частота запросов, фингерпринт, поведение. Дешёвый datacenter-пул + ротация по ошибкам + контроль бан-рейта закрывают 90% задач. Оставшиеся 10% — curl_cffi и residential.

Собранные данные надо куда-то девать — удобнее всего через Telegram-бота: собрал → прислал себе в чат → лёг спать.

FAQ

Какие прокси выбрать для парсинга: datacenter или residential?

Datacenter (дешёвые, ~$1–2/IP) — для сайтов с простой защитой. Residential (дороже в 10–20 раз) — когда банят по ASN датацентров: Cloudflare, крупные маркетплейсы. Начинайте с datacenter и апгрейдьтесь только по факту блокировок.

Сколько запросов можно делать с одного IP?

Универсальной цифры нет — зависит от сайта. Рабочая методика: стартуйте с 1 запроса в 3–5 секунд, поднимайте частоту, пока не появятся 429/403 — и отступайте на 30%. Ведите счёт бан-рейта в логах.

Помогает ли смена TLS-фингерпринта против Cloudflare?

Да, это часто важнее прокси. Обычный requests «светит» библиотечный TLS-стек, и Cloudflare видит бота мгновенно. curl_cffi имитирует Chrome — в связке с residential-прокси проходит большинство проверок.

Что делать, если прокси-провайдер выдаёт мёртвые IP?

Норма отрасли: 10–30% мёртвых IP в выдаче. Проверяйте пул при старте (быстрый GET к надёжному хосту) и выбрасывайте мёртвые, не считая их попытками. Хорошие провайдеры возвращают деньги за нерабочие IP — сохраняйте логи проверки.

Один разбор в неделю на YouTube

Каждой статье здесь — пара-видео на канале @PythonSimple: та же задача, живой код, коротко.

Подписаться на @PythonSimple