Современные сайты редко отдают готовый HTML с данными. Каталог товаров, лента, цены, отзывы — всё это чаще всего подгружается уже после загрузки страницы через JavaScript. Для парсера это означает, что простой GET страницы и разбор HTML вернёт пустой каркас без данных.
Есть два принципиально разных способа решить эту проблему:
- Перехват API-запросов — найти те запросы, которыми браузер сам забирает данные, и повторить их напрямую, без браузера.
- Полноценная эмуляция браузера — запустить настоящий (или headless) браузер, дать ему отрендерить JS и работать со страницей как пользователь: кликать, скроллить, тянуть элементы.
Первый способ быстрее и дешевле по ресурсам, второй — универсальнее и устойчивее к нестандартной логике. На практике их часто комбинируют.
Подход 1. Перехват и эмуляция API-запросов
Идея
Когда страница «дорисовывает» данные, она почти всегда делает фоновые HTTP-запросы (XHR/fetch) к внутреннему API, который возвращает JSON. Если найти этот эндпоинт и воспроизвести запрос с правильными заголовками, куками и токенами — данные можно получать напрямую, минуя рендеринг. Это в десятки раз быстрее и не требует браузера.
Как найти API
- Откройте DevTools (
F12) → вкладка Network. - Отфильтруйте по Fetch/XHR.
- Прокрутите страницу, нажмите «Показать ещё», смените категорию — словом, спровоцируйте подгрузку данных.
- Найдите запрос, в ответе которого лежит нужный JSON (товары, цены и т.д.).
- Изучите его: URL, метод, query-параметры, заголовки, тело, куки.
- Правый клик → 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 и пагинацией)
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, быстрее на больших объёмах)
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
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)
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)
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
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)
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)
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:
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:
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:
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до загрузки страницы:
# 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-фингерпринта:
# 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). - Распарсить уже полученный HTML →
BeautifulSoup/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. Алгоритм: скроллим вниз → ждём подгрузку → считаем карточки → повторяем, пока количество растёт.
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)}")
Тот же приём для кнопки «Показать ещё» — кликаем, пока кнопка существует:
while page.locator("button.load-more").count() > 0:
page.click("button.load-more")
page.wait_for_timeout(1200)
Гибрид: лучший из обоих миров
Playwright и Puppeteer умеют слушать сетевой трафик. Можно открыть страницу в браузере (чтобы прошли все токены и анти-бот проверки), но забирать данные не из DOM, а из ответов того самого API, которые браузер дёргает при скролле:
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, слайдеры, поэтапные формы).