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

В этом руководстве показано, как построить рабочий краулер, обходящий сайт с JavaScript-рендерингом от начала до конца. Вы будете рендерить каждую страницу, чтобы ссылки и контент появились, парсить эти ссылки с помощью BeautifulSoup, поддерживать очередь фронтира и множество посещённых URL для корректного завершения обхода и вежливо регулировать запросы. Получение страниц выполняется через Crawling API Crawlbase с JavaScript-токеном, который рендерит каждую страницу за доверенным IP и возвращает готовый HTML. Для больших заданий мы также рассматриваем асинхронный Crawler, чтобы вы не блокировались на каждом рендере.

Почему краулинг JS-сайта, это две задачи, а не одна

Традиционный краулер, это плотный цикл: получить URL, извлечь якоря, добавить новые в очередь, повторить. Этот цикл предполагает, что получаемый HTML уже содержит и контент, и ссылки. На сайтах с серверным рендерингом так и есть. На сайтах с клиентским рендерингом, нет.

Первая задача, рендеринг. Когда вы запрашиваете маршрут React или Vue с помощью простого HTTP-клиента, сервер возвращает оболочку: корневой <div>, набор тегов скриптов и почти ничего больше. Текст статьи, сетка продуктов, нужная таблица, всё это внедряется после того, как браузер загружает и выполняет JavaScript. Нет браузера, нет контента.

Вторая задача, обнаружение ссылок, и именно она тихо ломает наивные краулеры. Навигация, пагинация и «похожие» ссылки на сайте тоже часто рендерятся на стороне клиента. Поэтому даже если вам нужны только ссылки, а не контент, простой запрос всё равно не даёт ничего для обхода. Краулинг умирает на первой странице, потому что фронтир так никогда и не растёт за её пределы. Чтобы краулить JavaScript-сайт, нужно рендерить каждую страницу, не потому, что вам всегда нужно тело, а потому что рендеринг делает ссылки существующими.

Сначала рендерите, потом парсите

Единственное правило, которое делает JS-краулинг рабочим: рендерите каждую страницу до поиска ссылок. Контент и навигация появляются в одном проходе рендеринга, поэтому после получения готового HTML вы можете извлечь как нужные данные, так и URL для следующих шагов, используя один и тот же парсер.

Что вы создадите

Краулер с обходом в ширину на Python, который начинается с начального URL на сайте с JavaScript-рендерингом и распространяется наружу, оставаясь в рамках одного домена. Конкретно он будет:

  • Рендерить каждую страницу через Crawling API с JS-токеном, чтобы контент и ссылки были присутствовали.
  • Извлекать ссылки из отрендеренного HTML с помощью BeautifulSoup и нормализовать их до абсолютных URL того же домена.
  • Управлять фронтиром из URL для посещения и множеством посещённых, чтобы ничто не загружалось дважды и обход завершался.
  • Вежливо регулировать запросы с задержкой между запросами и ограничением числа посещаемых страниц.

Предварительные требования

Нужно подготовить несколько вещей до написания кода. Ни одна не займёт много времени.

Базовые знания Python. Вы должны уметь запускать скрипты и устанавливать пакеты с помощью pip. Если очереди и множества знакомы, вы готовы.

Python 3.8 или выше. Проверьте командой python --version. Установите с python.org, если его нет.

Аккаунт Crawlbase и JS-токен. Зарегистрируйтесь, откройте панель управления и скопируйте JavaScript (JS) токен со страницы документации аккаунта. JS-токен рендерит страницы в настоящем браузере; обычный токен только получает статический HTML и вернёт ту же пустую оболочку, что и простой запрос. Не храните токен в системе контроля версий.

Настройка проекта

Создайте виртуальное окружение, чтобы зависимости были изолированы, затем установите две библиотеки, необходимые краулеру.

bash
python --version

python -m venv crawler_env
source crawler_env/bin/activate

pip install crawlbase beautifulsoup4

В Windows активируйте командой crawler_env\Scripts\activate вместо строки с source. Пакет crawlbase, официальный клиент для Crawling API, а beautifulsoup4 парсит возвращаемый HTML, чтобы вы могли извлекать как якоря, так и контент.

Шаг 1: Рендеринг одной страницы и подтверждение наличия ссылок

До построения цикла докажите, что сложная часть работает: что рендеринг клиентской страницы выявляет ссылки, которые простой запрос бы упустил. Инициализируйте клиент с JS-токеном и запросите один URL, попросив API подождать асинхронный контент.

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def render(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    html = render("https://example.com/")
    print(len(html) if html else "No HTML returned")

Два параметра ожидания важны для клиентских целей. ajax_wait говорит API подождать завершения загрузки асинхронного контента, а page_wait удерживает фиксированное количество миллисекунд после загрузки, чтобы элементы с поздним рендерингом появились перед захватом. Пять секунд, разумный старт; увеличьте, если ссылки страницы возвращаются пустыми. Сравните длину этого отрендеренного тела с результатом простого requests.get на том же URL, и вы обычно увидите, что отрендеренная версия значительно больше, потому что навигация и контент теперь присутствуют.

Шаг 2: Извлечение и нормализация ссылок

Имея готовый HTML, вытащите якоря и превратите их в чистые абсолютные URL для сравнения и постановки в очередь. Две детали поддерживают краулинг в разумных рамках: разрешайте относительные href к странице, с которой они получены, и убирайте фрагменты URL, чтобы /page и /page#section не считались двумя страницами.

python
from urllib.parse import urljoin, urldefrag, urlparse
from bs4 import BeautifulSoup

def extract_links(html, base_url, domain):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href = urljoin(base_url, a["href"])
        href, _ = urldefrag(href)
        parsed = urlparse(href)
        if parsed.scheme in ("http", "https") and parsed.netloc == domain:
            links.add(href)
    return links

Проверка на один домен (parsed.netloc == domain) не даёт краулеру уходить на внешние сайты, что является разницей между краулингом одного сайта и случайной попыткой краулить весь веб. Возврат set дедуплицирует ссылки, найденные несколько раз на одной странице. Поскольку вы извлекли их из отрендеренного HTML, они включают ссылки, отрисованные JavaScript, что именно и упустил бы краулер с простым запросом.

Crawlbase Crawling API

Краулинг JS-сайта означает рендеринг каждой страницы за доверенным IP снова и снова без блокировок. Crawling API принимает JS-токен, запускает каждую страницу в настоящем браузере, ротирует жилые IP на стороне сервера и возвращает готовый HTML, чтобы и контент, и ссылки присутствовали. Вы избавляетесь от необходимости самостоятельно запускать парк безголовых браузеров и пул прокси. Начните на бесплатном тарифе и укажите на начальный URL.

Шаг 3: Управление фронтиром и множеством посещённых

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

python
import time
from collections import deque

def crawl_site(seed_url, max_pages=50, delay=2.0):
    domain = urlparse(seed_url).netloc
    frontier = deque([seed_url])
    visited = set()
    pages = []

    while frontier and len(visited) < max_pages:
        url = frontier.popleft()
        if url in visited:
            continue
        visited.add(url)

        html = render(url)
        if not html:
            continue

        pages.append({"url": url, "html": html})
        print(f"[{len(visited)}] crawled {url}")

        for link in extract_links(html, url, domain):
            if link not in visited:
                frontier.append(link)

        time.sleep(delay)

    return pages

deque с popleft даёт обход в ширину, чтобы краулер расширялся по сайту, а не нырял глубоко по одной ветке. Пометка URL как посещённого в момент извлечения (а не после успешного получения) означает, что страница, рендеринг которой не удался, всё равно считается просмотренной, и нестабильный URL не ловит цикл в ловушку. Ограничение max_pages и задержка delay между запросами, два ваших рычага вежливости; настраивайте их под сайт и собственный бюджет.

Соблюдайте robots.txt

Перед краулингом в любом объёме прочитайте robots.txt цели и соблюдайте её правила disallow и crawl-delay. Стандартная библиотека Python urllib.robotparser может проверить URL по правилам в несколько строк кода. Вежливое регулирование и пребывание за пределами запрещённых путей, вот что сохраняет краулер желанным, а не заблокированным.

Шаг 4: Собираем всё вместе

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

python
import json
import time
from collections import deque
from urllib.parse import urljoin, urldefrag, urlparse
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_JS_TOKEN"})

def render(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

def extract_links(html, base_url, domain):
    soup = BeautifulSoup(html, "html.parser")
    links = set()
    for a in soup.select("a[href]"):
        href, _ = urldefrag(urljoin(base_url, a["href"]))
        parsed = urlparse(href)
        if parsed.scheme in ("http", "https") and parsed.netloc == domain:
            links.add(href)
    return links

def title_of(html):
    soup = BeautifulSoup(html, "html.parser")
    return soup.title.get_text(strip=True) if soup.title else None

def crawl_site(seed_url, max_pages=50, delay=2.0):
    domain = urlparse(seed_url).netloc
    frontier = deque([seed_url])
    visited = set()
    results = []

    while frontier and len(visited) < max_pages:
        url = frontier.popleft()
        if url in visited:
            continue
        visited.add(url)

        html = render(url)
        if not html:
            continue

        results.append({"url": url, "title": title_of(html)})
        print(f"[{len(visited)}] {url}")

        for link in extract_links(html, url, domain):
            if link not in visited:
                frontier.append(link)

        time.sleep(delay)

    return results

def main():
    pages = crawl_site("https://example.com/", max_pages=25)
    with open("crawl.json", "w") as f:
        json.dump(pages, f, indent=2)
    print(f"Crawled {len(pages)} pages")

if __name__ == "__main__":
    main()

Запустите командой python crawler.py, и вы увидите, как фронтир растёт по мере того, как каждая отрендеренная страница привносит новые ссылки, а затем сокращается по мере достижения ограничения страниц. Вывод, JSON-файл с каждым URL, который посетил краулер, и его заголовком. Замените title_of на настоящую функцию извлечения, и у вас будет полноценный контентный краулер. Для более глубокого разбора парсинга тела одной отрендеренной страницы смотрите материал о как скрапить JavaScript-страницы на Python.

Масштабирование с помощью асинхронного Crawler

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

Для больших заданий переключитесь на асинхронный Crawler. Вместо получения одной страницы и ожидания вы помещаете URL в Crawler, и Crawlbase рендерит их на своей инфраструктуре и доставляет готовый HTML на webhook-коллбэк под вашим контролем. Ваш код перестаёт быть циклом рендеринга и ожидания и становится двумя несвязанными половинами: отправитель, подающий URL по мере их обнаружения, и получатель, принимающий отрендеренные страницы, извлекающий ссылки и снова отправляющий новые. Вы краулите со скоростью пропускной способности парка Crawler, а не задержки одного рендера.

Логика краулинга, которую вы уже написали, переносится напрямую. Фронтир, множество посещённых, проверка на один домен и извлечение ссылок идентичны; меняется только транспорт: от блокирующего вызова api.get к потоку отправки и коллбэка. Для полного паттерна смотрите материал об извлечении данных с помощью Crawlbase Crawler. Если ваш стек на JVM, а не на Python, тот же дизайн с фронтиром и посещёнными URL хорошо переносится на построение веб-краулера на Java.

Типичные подводные камни при краулинге JS-сайтов

Несколько видов отказов встречаются снова и снова. Знать их заранее, значит сэкономить много отладки.

  • Пустые наборы ссылок. Если extract_links возвращает ничего на странице, у которой явно есть навигация, страница, вероятно, не завершила рендеринг. Увеличьте page_wait и держите ajax_wait включённым, чтобы якоря с поздним внедрением присутствовали при парсинге.
  • Бесконечные фронтиры. Календари, фасетированные фильтры и строки запросов с идентификаторами сессий генерируют бесконечное количество уникальных URL. Нормализуйте параметры отслеживания и рассмотрите пропуск URL за пределами ограничения глубины, чтобы краулинг действительно завершался.
  • Уход с сайта. Без проверки на один домен одна внешняя ссылка превращает ваш краулинг сайта в нечто бесконтрольное. Всегда фильтруйте по netloc.
  • Перегрузка сервера. Отсутствие задержки означает серию запросов, выглядящую как атака и заслуживающую блокировки. Держите разумную delay и соблюдайте любой crawl-delay из robots.txt.

Если вы предпочитаете маршрутизировать трафик собственного безголового браузера через ротирующий жилой пул вместо использования управляемого API, Smart AI Proxy даёт ту же ротацию IP как конечную точку drop-in прокси, а рендеринг вы обрабатываете самостоятельно.

Итоги

Ключевые выводы

  • Краулинг JS-сайта, это две задачи. Каждая страница требует рендеринга для показа контента, и переходы, по которым вы следуете, тоже построены на JS, поэтому нужно рендерить каждую страницу для обнаружения следующих.
  • Сначала рендерите, потом парсите. Crawling API с JS-токеном плюс ajax_wait и page_wait возвращает готовый HTML, чтобы контент и ссылки поступали вместе.
  • Фронтир и множество посещённых обязательны. Очередь с обходом в ширину, множество просмотренных URL, фильтр по одному домену и ограничение страниц, вот что делает обход завершаемым.
  • Будьте вежливы. Задерживайтесь между запросами, соблюдайте robots.txt и нормализуйте URL, чтобы краулер не зацикливался на параметрах отслеживания.
  • Масштабируйтесь с асинхронным Crawler. Для больших заданий отправляйте URL и получайте отрендеренные страницы через коллбэк, чтобы краулить со скоростью парка, а не блокироваться на каждом рендере.

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

Почему обычный краулер останавливается после первой страницы на JavaScript-сайте?

Потому что навигационные ссылки рендерятся на стороне клиента. Простой HTTP-запрос возвращает оболочку со скриптами, но без якорей, которые фреймворк отрисовывает после их выполнения, поэтому ваш извлекатель ссылок ничего не находит для постановки в очередь и фронтир так и не растёт. Рендеринг каждой страницы, вот что делает эти ссылки существующими, поэтому краулинг JS-сайта требует рендеринга даже тогда, когда вам важно только обнаружение URL.

Нужен ли мне обычный токен или JS-токен для краулинга JavaScript-сайта?

JS-токен. Обычный токен получает статический HTML, который на клиентском сайте является пустой оболочкой без контента и отрендеренных ссылок. JS-токен запускает страницу в настоящем браузере перед возвратом HTML, поэтому и данные, и навигация присутствуют для вашего парсера и фронтира.

Как не допустить бесконечного цикла краулера?

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

Чем краулинг отличается от скрапинга одной JS-страницы?

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

Когда следует использовать асинхронный Crawler вместо синхронного цикла?

Переходите на асинхронный Crawler, когда блокировка на каждом рендере становится узким местом, обычно при краулинге более нескольких сотен страниц. Вместо того чтобы последовательно ждать каждого рендера, вы отправляете URL и получаете готовые страницы через webhook-коллбэки, то есть краулите со скоростью парка Crawlbase, а не задержкой одного рендера за раз.

Как краулить вежливо без блокировок?

Добавьте задержку между запросами, ограничьте число страниц за запуск и прочитайте robots.txt сайта для соблюдения правил disallow и crawl-delay. Маршрутизируйте запросы через ротирующие жилые IP, с чем Crawling API справляется за вас, чтобы ни один адрес не превысил ограничение частоты. Следите за кодами статуса и снижайте нагрузку, когда начинают появляться проверки.

Начать создавать

Обходите любой сайт в масштабе, без борьбы с инфраструктурой.

Crawlbase берёт на себя прокси, отпечатки и CAPTCHA, чтобы ваша команда выпускала конвейеры данных вместо поддержки обвязки краулинга. 1 000 запросов бесплатно, без карты.

Самообслуживание · Звонок отдела продаж не требуется · Доступны корпоративные объёмы краулинга