Архитектура крупномасштабного веб-скрейпинга: Полевое руководство

Несколько сотен страниц — это скрипт. Миллионы — это распределенная система. Вот архитектура, которая поможет вам достичь этого — очереди, асинхронные рабочие, уровни прокси, повторные попытки, дедупликация и проверки качества данных, с работающим кодом.

Скрейпинг нескольких сотен страниц — это скрипт. Скрейпинг миллионов — это распределенная система. Как только количество целей переходит от "запускается за ночь на моем ноутбуке" к "нужно завершить на этой неделе, не перегреваясь", сложность перестает быть в парсинге и становится во всем вокруг — как вы ставите задачи в очередь, распределяете их, избегаете блокировок, повторяете неудачные попытки и сохраняете и проверяете результат. Это крупномасштабный веб-скрейпинг как архитектура, а не фрагмент кода, с работающим кодом на каждом этапе.

Масштаб — это параллелизм, а не больший цикл

Одна цифра делает это конкретным. Возьмите категорию с 20,000 страницами объявлений, по 20 элементов каждая — 400,000 страниц для загрузки. При реалистичных 2.5 секундах на страницу строго последовательный запуск займет около 1,000,000 секунд, примерно 11.5 дней ожидания загрузки страниц до парсинга первого поля. Запустите 200 страниц параллельно, и эти 11.5 дней сокращаются до часа реального времени. Время — это ограничение на масштабе, и параллелизм — это способ его вернуть. Все остальное в архитектуре существует, чтобы сделать этот параллелизм жизнеспособным.

Диаграмма статистики, показывающая, что 400,000 страниц занимают 11.5 дней последовательно против около одного часа при 200 параллельно, с 10,000 сбоями на миллион при 1 проценте
Параллелизм превращает 11.5-дневный запуск в час — но при миллионе запросов даже 1% уровень сбоев — это 10,000 неудачных страниц.

Архитектура с первого взгляда

Скрейпер, который выдерживает миллионы страниц, — это небольшая распределенная система с несколькими именованными частями, каждая из которых решает проблему, которая возникает только при большом объеме:

Сначала очередь: отделите обнаружение от загрузки

Самое важное структурное решение — это поставить очередь между "что скрейпить" и "выполнением скрейпинга". Производитель перечисляет URL-адреса; пул рабочих их обрабатывает. Ни одна из сторон не знает, насколько быстро работает другая, и вы добавляете рабочих, не касаясь производителя. В Python это Celery или RQ на базе Redis; в Node — BullMQ; на большом масштабе — RabbitMQ или Kafka. Шаблон в одном файле:

import asyncio, aiohttp

CONCURRENCY = 50
queue = asyncio.Queue()

async def worker(session):
    while True:
        url = await queue.get()
        try:
            async with session.get(url, timeout=20) as resp:
                await handle(url, await resp.text(), resp.status)
        except Exception as err:
            await on_failure(url, err)
        finally:
            queue.task_done()

async def run(urls):
    for u in urls:
        queue.put_nowait(u)
    async with aiohttp.ClientSession() as session:
        tasks = [asyncio.create_task(worker(session)) for _ in range(CONCURRENCY)]
        await queue.join()
        for t in tasks:
            t.cancel()

Ручка, которая имеет значение, — это CONCURRENCY. Слишком низкое значение тратит впустую параллелизм, который делает масштаб возможным; слишком высокое перегружает как цель, так и ваш собственный исходящий трафик. Вы находите правильное значение, наблюдая за ростом уровня ошибок — именно поэтому мониторинг является важной частью системы, а не последующей мыслью.

Диаграмма потока крупномасштабного конвейера скрейпинга: очередь, асинхронные рабочие, уровень прокси, парсинг и проверки качества, хранение
Восемь именованных частей. Уровень прокси, антиботы и рендеринг — самые сложные для поддержания в здоровом состоянии — и естественное место для покупки.

Уровень прокси ломается первым

При низком объеме вы едва замечаете защиту от ботов; на масштабе они ломают запуск первыми. Отправьте несколько сотен тысяч запросов с одного IP, и вы получите ограничение скорости, затем вызов, затем блокировку. Исправление — это ротация по многим адресам. Уровень: дешевые IP-адреса дата-центра для мягких целей и API, ротация резидентных IP для сложных коммерческих целей, которые ожидают трафик реальных пользователей. Но одной ротации недостаточно — современные защиты также считывают отпечатки TLS и порядок заголовков, поэтому трафик должен выглядеть как браузер, а не просто исходить с нового IP.

Повторные попытки: сбой — это нормальное состояние

При миллионе запросов 1% временный уровень сбоев — это 10,000 неудачных страниц. Сбой не является краевым случаем при таком объеме — это рутина, и конвейер должен рассматривать неудачную загрузку как норму, а не как фатальную ошибку. Повторите с экспоненциальным увеличением интервала и ограничением, затем переместите URL в очередь неудачных сообщений вместо блокировки запуска. Прочтите почему это не удалось: тайм-аут или 503 стоит повторить, жесткий 404 — нет.

import asyncio, random

async def fetch_with_retry(session, url, tries=4):
    for attempt in range(tries):
        try:
            async with session.get(url, timeout=20) as r:
                if r.status == 404:
                    return None                # don't retry a hard 404
                if r.status < 400:
                    return await r.text()
        except Exception:
            pass
        await asyncio.sleep(2 ** attempt + random.random())  # backoff + jitter
    await dead_letter(url)                     # give up after the cap
    return None

Дедупликация: не скрейпите одну и ту же страницу дважды

Обнаружение на масштабе постоянно производит дубликаты — один и тот же продукт доступен с трех путей, параметры отслеживания, которые делают одну страницу похожей на десять. Нормализуйте URL-адреса перед их добавлением в очередь, затем ведите учет уже просмотренных (множество Redis или фильтр Блума, когда множество достигает сотен миллионов):

from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

seen = set()

def normalize(url):
    s = urlsplit(url.lower())
    q = [(k, v) for k, v in parse_qsl(s.query) if not k.startswith("utm_")]
    return urlunsplit((s.scheme, s.netloc, s.path.rstrip("/"), urlencode(sorted(q)), ""))

def enqueue(url):
    key = normalize(url)
    if key not in seen:
        seen.add(key)
        queue.put_nowait(key)

Хранение и качество данных

Две привычки, специфичные для масштаба: записывайте пакетами, чтобы хранение не стало вашим узким местом, и отделяйте сырые данные от обработанных, чтобы вы могли перепарсить без повторного скрейпинга, когда селекторы изменяются. Затем добавьте ту часть мониторинга, которую большинство команд пропускает — проверки качества данных. Запуск может сообщить о 100% успехе HTTP и все равно выдать мусор, если макет изменился и ваши селекторы теперь не соответствуют ничему. Утверждайте, что обязательные поля не пусты, а значения разумны:

def validate(row):
    assert row.get("title"), "empty title — selector may have drifted"
    price = row.get("price")
    assert isinstance(price, (int, float)) and 0 < price < 1_000_000, "bad price"
    return row

# fail loud on page 5,000, not silently after 5,000,000 empty rows

Перенесите прокси, антиботы и рендеринг в один API

Рендерьте только когда необходимо

Безголовый браузер — самая дорогая операция в конвейере — ЦП, память и секунды на страницу, которые доминируют над всем при миллионе страниц. Многие сайты все еще отправляют данные в начальном HTML или на JSON-эндпоинте; простая загрузка плюс парсер на порядок дешевле. Попробуйте сначала дешевый путь, подтвердите, что поля присутствуют, и переходите к рендерингу только для страниц, которые в этом нуждаются. Наш анализ стоимости рендеринга против HTTP дает реальные цифры на этот разрыв.

Строить или покупать сложные уровни

Все вышеперечисленное можно построить, поэтому честный вопрос — какие части заслуживают вашего инженерного времени. Модель данных, логика парсинга, проверки качества и схема хранения специфичны для вашего проекта — только вы можете построить их хорошо. Пул прокси, обработка антиботов, флот безголового рендеринга и очередь повторных попыток и доставки — это общая инфраструктура, которую дорого строить и сложно поддерживать в здоровом состоянии по мере изменения целей. Это линия, на которой стоит управляемый Scraper API: арендуйте части, которые одинаковы для всех. Наш анализ "строить или покупать" подробно рассматривает налог на обслуживание, а запуск скрейперов как производственного программного обеспечения охватывает поддержание всей системы в наблюдаемом состоянии.

Часто задаваемые вопросы

Как скрейпить миллионы страниц?

С помощью параллелизма, а не большего цикла. Поставьте очередь между обнаружением URL и загрузкой, обрабатывайте её пулом асинхронных или распределенных рабочих, меняйте IP, чтобы избежать блокировок, повторяйте временные сбои с увеличением интервала, дедуплицируйте URL и записывайте данные в хранилище пакетами. Последовательный запуск миллиона страниц занимает дни; та же работа в пуле параллельных рабочих завершается за часы.

Какая архитектура лучше всего подходит для крупномасштабного веб-скрейпинга?

Конвейер "очередь и рабочие": производитель перечисляет URL в очередь (Redis, RabbitMQ или Kafka), рабочие загружают параллельно через ротационный уровень прокси, рендерят только страницы, которым нужен JavaScript, повторяют сбои в очередь неудачных сообщений, дедуплицируют с помощью множества просмотренных и хранят сырые и обработанные данные отдельно. Оберните это в мониторинг с проверками качества данных, чтобы отклонения проявлялись рано.

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

Это зависит от цели и вашего исходящего трафика, а не от фиксированного числа. Скрейпинг ограничен вводом-выводом, поэтому скромная машина может обрабатывать многие сотни запросов в процессе. Начните с параллелизма около 50, наблюдайте за уровнем ошибок и увеличивайте его, пока не начнут расти сбои — это ваш потолок. За пределами ограничений одной машины добавляйте распределенных рабочих, а не нагружайте один узел сильнее.

Как обрабатывать сбои на масштабе?

Предполагайте сбои — при миллионе запросов даже 1% уровень ошибок — это 10,000 неудачных страниц. Повторяйте временные ошибки (тайм-ауты, 503) с экспоненциальным увеличением интервала и джиттером, ограничивайте попытки и перемещайте постоянные сбои в очередь неудачных сообщений вместо блокировки запуска. Не повторяйте жесткие 404. Рандомизация порядка сбора также распределяет сбои, чтобы вы не сталкивались с одинаковыми страницами при каждом запуске.

Масштаб — это в основном те части, которые неинтересно строить: ротация IP, антиботы, безголовый рендеринг, очереди и повторные попытки. Владейте частями, специфичными для ваших данных — моделью, парсерами, проверками качества — и арендуйте общую инфраструктуру, которая одинаково сложна для всех. Сначала правильно настройте очередь и параллелизм; все остальное — это обеспечение выживания этого параллелизма при контакте с миллионом реальных страниц.

Запитайте уровень загрузки с помощью ротации резидентных IP