Технологии и защита 16 мин чтения

Парсинг динамических сайтов: два подхода

Два способа парсить динамические сайты: реверс XHR-запросов к API и headless-браузеры — плюсы, минусы и критерии выбора.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 2 марта 2025

Современные сайты редко отдают готовый HTML с данными. Каталог товаров, лента, цены, отзывы — всё это чаще всего подгружается уже после загрузки страницы через JavaScript. Для парсера это означает, что простой GET страницы и разбор HTML вернёт пустой каркас без данных.

Есть два принципиально разных способа решить эту проблему:

  1. Перехват API-запросов — найти те запросы, которыми браузер сам забирает данные, и повторить их напрямую, без браузера.
  2. Полноценная эмуляция браузера — запустить настоящий (или headless) браузер, дать ему отрендерить JS и работать со страницей как пользователь: кликать, скроллить, тянуть элементы.

Первый способ быстрее и дешевле по ресурсам, второй — универсальнее и устойчивее к нестандартной логике. На практике их часто комбинируют.


Подход 1. Перехват и эмуляция API-запросов

Идея

Когда страница «дорисовывает» данные, она почти всегда делает фоновые HTTP-запросы (XHR/fetch) к внутреннему API, который возвращает JSON. Если найти этот эндпоинт и воспроизвести запрос с правильными заголовками, куками и токенами — данные можно получать напрямую, минуя рендеринг. Это в десятки раз быстрее и не требует браузера.

Как найти API

  1. Откройте DevTools (F12) → вкладка Network.
  2. Отфильтруйте по Fetch/XHR.
  3. Прокрутите страницу, нажмите «Показать ещё», смените категорию — словом, спровоцируйте подгрузку данных.
  4. Найдите запрос, в ответе которого лежит нужный JSON (товары, цены и т.д.).
  5. Изучите его: URL, метод, query-параметры, заголовки, тело, куки.
  6. Правый клик → Copy → Copy as cURL — отличная отправная точка: можно импортировать в Postman или сразу перевести в код.

Токены: CSRF, сессии, авторизация

Главная сложность подхода — запрос почти никогда не «голый». Сервер ожидает набор подтверждающих данных, и без них вернёт 401, 403 или 419.

CSRF-токен (Cross-Site Request Forgery). Защита от подделки межсайтовых запросов. Сервер выдаёт случайный токен, который клиент обязан вернуть при модифицирующих (а иногда и при читающих) запросах. Где он обычно лежит:

  • в <meta name="csrf-token" content="..."> в HTML страницы;
  • в скрытом поле формы <input type="hidden" name="_token" value="...">;
  • в куке (часто XSRF-TOKEN), которую затем нужно продублировать в заголовок X-CSRF-Token или X-XSRF-TOKEN.

Схема работы: сначала загружаем обычную страницу, вытаскиваем токен и куки сессии, затем подставляем их в API-запрос.

Сессионные куки. После первого захода сервер ставит Set-Cookie (например, sessionid, PHPSESSID, laravel_session). Их нужно сохранять между запросами — для этого используют объект-сессию (requests.Session, httpx.Client), который делает это автоматически.

Авторизация (Bearer / JWT / API-key). Если данные за логином, в заголовке обычно идёт Authorization: Bearer <token>. JWT-токены добываются через эндпоинт логина, после чего прикладываются к каждому запросу.

Прочие защитные поля. X-Requested-With: XMLHttpRequest (часто обязателен для AJAX-эндпоинтов), Referer, Origin, иногда — подписанные параметры (signature, nonce, timestamp), которые генерирует фронтенд-JS.

Когда подход ломается. Если токен или подпись запроса генерируются обфусцированным JS прямо в браузере (или в WASM), воспроизвести его на стороне сервера крайне трудно. Это сигнал, что проще перейти ко второму подходу — эмуляции браузера, где JS отработает сам.

Пример: Python + requests (с CSRF и пагинацией)

python
import re
import requests

session = requests.Session()
BASE = "https://example-shop.com"

# 1. Загружаем страницу, чтобы получить CSRF-токен и куки сессии
resp = session.get(f"{BASE}/catalog", headers={
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
})

# CSRF может лежать в meta-теге...
m = re.search(r'name="csrf-token"\s+content="([^"]+)"', resp.text)
csrf = m.group(1) if m else session.cookies.get("XSRF-TOKEN")

headers = {
    "X-CSRF-Token": csrf,
    "X-Requested-With": "XMLHttpRequest",
    "Referer": f"{BASE}/catalog",
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
    "Accept": "application/json",
}

# 2. Дёргаем внутренний API постранично
all_items = []
page = 1
while True:
    r = session.get(
        f"{BASE}/api/products",
        params={"category": "phones", "page": page, "per_page": 48},
        headers=headers,
    )
    r.raise_for_status()
    payload = r.json()
    items = payload.get("items", [])
    if not items:
        break
    all_items.extend(items)
    page += 1

for it in all_items:
    print(it["title"], it["price"])

Пример: Python + httpx (async, быстрее на больших объёмах)

python
import asyncio
import httpx

async def fetch_page(client, page):
    r = await client.get("/api/products", params={"page": page, "per_page": 48})
    return r.json().get("items", [])

async def main():
    async with httpx.AsyncClient(base_url="https://example-shop.com",
                                 headers={"X-Requested-With": "XMLHttpRequest"}) as client:
        tasks = [fetch_page(client, p) for p in range(1, 11)]
        results = await asyncio.gather(*tasks)
    items = [x for chunk in results for x in chunk]
    print(len(items))

asyncio.run(main())

Пример: Node.js + fetch

javascript
const csrf = "..."; // вытащен из HTML/куки заранее

const res = await fetch("https://example-shop.com/api/products?page=1&per_page=48", {
  headers: {
    "X-CSRF-Token": csrf,
    "X-Requested-With": "XMLHttpRequest",
    "Accept": "application/json",
    "Cookie": "sessionid=abc123; XSRF-TOKEN=" + csrf,
  },
});

const data = await res.json();
data.items.forEach(item => console.log(item.title, item.price));

Подход 2. Полноценная эмуляция браузера

Идея

Запускаем настоящий движок (Chromium, Firefox, WebKit), он скачивает страницу, выполняет весь JS, рендерит DOM. Дальше мы работаем со страницей так же, как человек: ждём появления элементов, кликаем, скроллим, тянем ползунки. Все токены, подписи и анти-бот скрипты отрабатывают сами — нам не нужно их воспроизводить.

Минусы: на порядок медленнее, прожорлив по CPU/RAM, легче детектируется анти-бот системами (но с этим борются специальными «stealth»-режимами).

Чем эмулировать

  • Selenium — старейший стандарт, поддерживает Python, Java, C#, JavaScript, Ruby. Управляет реальными браузерами через WebDriver.
  • Playwright — современный фреймворк от Microsoft. Python, JavaScript/TS, .NET, Java. Chromium, Firefox, WebKit «из коробки», умное автоожидание элементов, удобный перехват сетевых запросов.
  • Puppeteer — Node.js, изначально только Chromium (есть экспериментальный Firefox). Очень быстрый и зрелый для Chrome.

Действия со страницей

Ниже — одни и те же четыре действия (клик, скролл, скролл к элементу, удержание кнопки мыши + движение) на разных стеках. Удержание + движение — это базис для drag-and-drop, ползунков-слайдеров и слайдер-капч.

Playwright (Python)

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example-shop.com/catalog")

    # КЛИК
    page.click("button.load-more")

    # СКРОЛЛ колесом
    page.mouse.wheel(0, 2000)

    # СКРОЛЛ к конкретному элементу
    page.locator("footer").scroll_into_view_if_needed()

    # УДЕРЖАНИЕ кнопки + ДВИЖЕНИЕ (drag / слайдер)
    box = page.locator(".slider-handle").bounding_box()
    start_x = box["x"] + box["width"] / 2
    start_y = box["y"] + box["height"] / 2
    page.mouse.move(start_x, start_y)
    page.mouse.down()                                   # зажали
    page.mouse.move(start_x + 200, start_y, steps=25)   # плавно ведём (25 шагов)
    page.mouse.up()                                     # отпустили

    browser.close()

Playwright (JavaScript/Node)

javascript
const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');

  // КЛИК
  await page.click('button.load-more');

  // СКРОЛЛ
  await page.mouse.wheel(0, 2000);

  // СКРОЛЛ к элементу
  await page.locator('footer').scrollIntoViewIfNeeded();

  // УДЕРЖАНИЕ + ДВИЖЕНИЕ
  const box = await page.locator('.slider-handle').boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.mouse.down();
  await page.mouse.move(box.x + 200, box.y, { steps: 25 });
  await page.mouse.up();

  await browser.close();
})();

Selenium (Python) — через ActionChains

python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

driver = webdriver.Chrome()
driver.get("https://example-shop.com/catalog")
wait = WebDriverWait(driver, 10)

# КЛИК (с ожиданием кликабельности)
btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.load-more")))
btn.click()

# СКРОЛЛ
driver.execute_script("window.scrollBy(0, 2000)")

# СКРОЛЛ к элементу
footer = driver.find_element(By.CSS_SELECTOR, "footer")
driver.execute_script("arguments[0].scrollIntoView({block:'center'})", footer)

# УДЕРЖАНИЕ + ДВИЖЕНИЕ
handle = driver.find_element(By.CSS_SELECTOR, ".slider-handle")
(ActionChains(driver)
    .click_and_hold(handle)     # зажали
    .move_by_offset(200, 0)     # сдвинули на 200px вправо
    .pause(0.3)
    .release()                  # отпустили
    .perform())

driver.quit()

Selenium (Java)

java
WebDriver driver = new ChromeDriver();
driver.get("https://example-shop.com/catalog");

// КЛИК
driver.findElement(By.cssSelector("button.load-more")).click();

// СКРОЛЛ
((JavascriptExecutor) driver).executeScript("window.scrollBy(0, 2000)");

// УДЕРЖАНИЕ + ДВИЖЕНИЕ
WebElement handle = driver.findElement(By.cssSelector(".slider-handle"));
new Actions(driver)
    .clickAndHold(handle)
    .moveByOffset(200, 0)
    .release()
    .perform();

Puppeteer (Node.js)

javascript
const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');

  // КЛИК
  await page.click('button.load-more');

  // СКРОЛЛ
  await page.evaluate(() => window.scrollBy(0, 2000));

  // СКРОЛЛ к элементу
  await page.$eval('footer', el => el.scrollIntoView());

  // УДЕРЖАНИЕ + ДВИЖЕНИЕ
  const handle = await page.$('.slider-handle');
  const box = await handle.boundingBox();
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.mouse.down();
  await page.mouse.move(box.x + 200, box.y, { steps: 25 });
  await page.mouse.up();

  await browser.close();
})();

Stealth-режимы: обход детекта автоматизации

Запущенный «из коробки» headless-браузер легко отличить от настоящего. Анти-бот системы (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai и др.) проверяют десятки сигналов, и если хотя бы часть выдаёт автоматизацию — следует капча, челлендж или блокировка. Stealth-режим — это набор патчей и приёмов, маскирующих эти признаки.

По каким признакам ловят

  • navigator.webdriver === true — самый явный флаг, который браузер под управлением WebDriver/CDP выставляет автоматически.
  • Артефакты headless. Отсутствие window.chrome, пустой список плагинов (navigator.plugins), нестандартные navigator.languages, рендерер WebGL вида SwiftShader/Google Inc. вместо настоящей видеокарты.
  • Фингерпринтинг. Canvas, WebGL, AudioContext и набор шрифтов дают стабильный «отпечаток» окружения; у дефолтного headless он подозрительно типовой.
  • TLS/JA3-фингерпринт. На уровне самого HTTP-соединения «рукопожатие» Python-клиента или Node отличается от Chrome — это ловится ещё до выполнения JS (актуально и для подхода 1).
  • Поведение. Мгновенные клики без движения мыши, идеально ровный тайминг, переход прямо на внутреннюю страницу без навигации — всё это нечеловечно.
  • Репутация IP. Диапазоны дата-центров (AWS, Hetzner и т.п.) помечены; жилые (residential) и мобильные адреса вызывают меньше подозрений.

Готовые инструменты

  • puppeteer-extra + puppeteer-extra-plugin-stealth (Node) — самый известный набор, скрывает navigator.webdriver, чинит WebGL/plugins/languages и десятки других «утечек».
  • playwright-extra с тем же stealth-плагином — аналог для Playwright на Node.
  • undetected-chromedriver (Python, поверх Selenium) — пропатченный ChromeDriver, проходящий многие проверки Cloudflare. Его развитие — nodriver (без webdriver-протокола вообще, чисто через CDP).
  • SeleniumBase в UC-режиме (--uc) — обёртка над Selenium с встроенным антидетектом.
  • rebrowser-patches — низкоуровневые патчи рантайма Puppeteer/Playwright, закрывающие более тонкие утечки CDP.

Важно: ни один stealth-плагин не даёт гарантии. Анти-бот системы постоянно обновляются, и то, что проходило вчера, завтра может ловиться. Это «гонка вооружений», а не разовая настройка.

Примеры

Python — undetected-chromedriver:

python
import undetected_chromedriver as uc

options = uc.ChromeOptions()
options.add_argument("--lang=ru-RU")
# для антидетекта обычно НЕ ставят headless, либо используют новый режим:
# options.add_argument("--headless=new")

driver = uc.Chrome(options=options)
driver.get("https://example-shop.com/catalog")
print(driver.title)
driver.quit()

Node — puppeteer-extra + stealth:

javascript
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');

puppeteer.use(StealthPlugin());

(async () => {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();
  // правдоподобный UA и заголовки в тон
  await page.setUserAgent(
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
    '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
  );
  await page.goto('https://example-shop.com/catalog');
  await browser.close();
})();

Node — playwright-extra + stealth:

javascript
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();

chromium.use(stealth);

(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage();
  await page.goto('https://example-shop.com/catalog');
  await browser.close();
})();

Ручные приёмы (поверх или вместо плагинов)

  • Прятать webdriver и чинить окружение. Через CDP/init script до загрузки страницы:
python
# Playwright (Python): выполняется в каждом новом документе ДО скриптов сайта
page.add_init_script(
    "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
)
  • Новый headless-режим. В свежих версиях Chrome флаг --headless=new ближе к обычному браузеру, чем старый headless; иногда выгоднее запускать вообще не-headless под виртуальным дисплеем (Xvfb).
  • Постоянный профиль. Запуск с user_data_dir сохраняет куки и «прогретость» сессии между запусками — меньше похоже на свежего бота.
  • Человеческое поведение. Случайные паузы, движение мыши по кривой (Безье), скролл рывками, а не одним прыжком. Для генерации траекторий есть библиотеки вроде pyautogui/bezier или встроенный steps у mouse.move.
  • Прокси. Жилые и мобильные прокси с ротацией заметно снижают долю челленджей по сравнению с дата-центровыми IP.

Stealth для подхода 1 (без браузера)

Перехват API тоже детектируется — по TLS-отпечатку HTTP-клиента. Чтобы запрос на уровне «рукопожатия» выглядел как настоящий Chrome, используют клиенты с подделкой TLS-фингерпринта:

python
# curl_cffi умеет имитировать TLS/JA3 конкретного браузера
from curl_cffi import requests

r = requests.get(
    "https://example-shop.com/api/products?page=1",
    impersonate="chrome124",   # подделываем рукопожатие Chrome 124
)
print(r.json())

Аналоги: tls-client (Python/Go), curl-impersonate (системный бинарник). Это часто решает проблему, когда «голый» requests получает 403, а в браузере та же страница открывается.


Какие библиотеки что умеют

Ключевой водораздел: исполняет ли инструмент JavaScript и умеет ли имитировать действия мыши. HTTP-клиенты и парсеры HTML не делают ни того, ни другого — они подходят только для статики или для второго подхода (эмуляция API).

Библиотека Язык JS-рендеринг Действия (клик/скролл/drag) Назначение
requests / httpx Python Нет Нет HTTP-клиент
aiohttp Python Нет Нет Async HTTP
BeautifulSoup / lxml Python Нет Нет Парсинг HTML
Scrapy Python Нет (нужен Splash/Playwright-плагин) Нет Фреймворк-краулер
Selenium Python/Java/C#/JS/Ruby Да Да Управление браузером
Playwright Python/JS/.NET/Java Да Да Управление браузером
Puppeteer Node.js Да (Chromium) Да Управление браузером
Cypress JS Да Да (но заточен под e2e-тесты) Тестирование
axios / fetch / got Node.js Нет Нет HTTP-клиент
cheerio Node.js Нет Нет Парсинг HTML (jQuery-стиль)
Colly Go Нет Нет Фреймворк-краулер
chromedp / rod Go Да Да Управление браузером
HtmlUnit Java Частично/нестабильно Ограниченно Headless-браузер
jsoup Java Нет Нет Парсинг HTML

Краткий вывод:

  • Нужно только повторить API-запросrequests/httpx (Python), fetch/got (Node), Colly (Go).
  • Распарсить уже полученный HTMLBeautifulSoup/lxml, cheerio, jsoup.
  • Нужен рендеринг JS и действия мышьюPlaywright, Selenium, Puppeteer, chromedp/rod.
  • HtmlUnit умеет немного JS, но на современных SPA часто спотыкается — для серьёзного рендеринга берут Playwright/Selenium.

Кейс: мониторинг интернет-магазинов и бесконечный скролл

Это, пожалуй, самая частая практическая задача. В каталогах товары обычно не выводятся все сразу: применяется lazy loading / infinite scroll — новые карточки подгружаются по мере прокрутки (или по нажатию «Показать ещё»). Простой запрос HTML отдаст лишь первую «порцию».

Есть два пути, и оба активно используются в мониторинге цен и ассортимента:

Путь А (предпочтительный): перехват пагинационного API

При скролле магазин почти всегда дёргает что-то вроде /api/catalog?page=2&offset=48. Если это так — забудьте про браузер и собирайте данные постранично напрямую (см. подход 1). Это быстро, стабильно и масштабируется на тысячи товаров. Именно так строят большинство промышленных мониторингов: браузер используют один раз — чтобы разведать структуру API и токены, а сам сбор идёт через HTTP-клиент.

Путь Б: рендеринг + скролл, когда API закрыт

Если эндпоинт защищён нетривиальной подписью или данные генерируются исключительно на клиенте, остаётся скроллить браузером и собирать карточки из DOM. Алгоритм: скроллим вниз → ждём подгрузку → считаем карточки → повторяем, пока количество растёт.

python
from playwright.sync_api import sync_playwright

def scrape_catalog(url):
    products = []
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto(url)

        prev_count = -1
        stable_rounds = 0

        while stable_rounds < 2:  # 2 подряд «пустых» прокрутки = дошли до конца
            # скроллим в самый низ
            page.mouse.wheel(0, 4000)
            page.wait_for_timeout(1500)  # даём время на подгрузку

            cards = page.locator(".product-card")
            count = cards.count()

            if count == prev_count:
                stable_rounds += 1
            else:
                stable_rounds = 0
            prev_count = count

        # собираем все карточки после полной прокрутки
        cards = page.locator(".product-card")
        for i in range(cards.count()):
            card = cards.nth(i)
            products.append({
                "title": card.locator(".title").inner_text(),
                "price": card.locator(".price").inner_text(),
                "url":   card.locator("a").get_attribute("href"),
            })

        browser.close()
    return products

data = scrape_catalog("https://example-shop.com/catalog/phones")
print(f"Собрано товаров: {len(data)}")

Тот же приём для кнопки «Показать ещё» — кликаем, пока кнопка существует:

python
while page.locator("button.load-more").count() > 0:
    page.click("button.load-more")
    page.wait_for_timeout(1200)

Гибрид: лучший из обоих миров

Playwright и Puppeteer умеют слушать сетевой трафик. Можно открыть страницу в браузере (чтобы прошли все токены и анти-бот проверки), но забирать данные не из DOM, а из ответов того самого API, которые браузер дёргает при скролле:

python
def handle_response(response):
    if "/api/products" in response.url:
        data = response.json()
        # сохраняем готовый JSON — парсить HTML не нужно
        save(data["items"])

page.on("response", handle_response)
page.goto("https://example-shop.com/catalog")
# дальше просто скроллим — данные сами «прилетают» в обработчик

Это часто оптимальный вариант для мониторинга: устойчивость браузерной эмуляции + чистый структурированный JSON вместо хрупкого разбора вёрстки.

Практические советы по мониторингу

  • Ждите данные, а не время. Вместо «спать 1.5 секунды» используйте ожидание появления элемента (wait_for_selector) или сетевого затишья (wait_for_load_state("networkidle")) — надёжнее и часто быстрее.
  • Дедупликация. При infinite scroll одни карточки могут считываться повторно — собирайте по уникальному id/url.
  • Ограничивайте частоту. Слишком агрессивные запросы перегружают сайт и быстро приводят к блокировке. Ставьте задержки, используйте пулы прокси аккуратно и в рамках закона.
  • Кешируйте разведку. Структуру API и токены выясняйте один раз; в продакшене гоняйте лёгкий HTTP-сбор, а тяжёлый браузер держите как резерв.

Как выбрать подход

Критерий Перехват API Эмуляция браузера
Скорость Очень высокая Низкая
Потребление ресурсов Минимальное Высокое (CPU/RAM)
Сложность настройки Выше (реверс токенов) Ниже (всё «как у пользователя»)
Устойчивость к смене вёрстки Высокая (зависит от API) Средняя (зависит от селекторов)
Обход клиентских токенов/подписей Сложно Автоматически
Масштабирование на объём Отличное Ограниченное

Практическое правило: всегда сначала проверяйте, нельзя ли обойтись перехватом API — это быстрее, дешевле и стабильнее. Переходите к эмуляции браузера только когда API скрыт за клиентской криптографией, защищён сложной анти-бот логикой или когда нужно воспроизвести нетривиальное взаимодействие (drag-and-drop, слайдеры, поэтапные формы).