Логотип CyberYozh Academy

Парсинг сайтов на Python: Selenium, Puppeteer и защита от ботов

5 октября 2026 г.13 минМария Даровская
Категория:Разработка
Парсинг сайтов на Python: Selenium, Puppeteer и защита от ботов

Причина часто не в Python и не в CSS-селекторах. Современный сайт может отдавать в первом HTML только каркас, загружать данные отдельным запросом и собирать страницу уже в браузере. Иногда достаточно найти этот запрос и так избежать браузерной автоматизации. Но порой без Selenium или Playwright действительно не обойтись.

В этой статье соберем небольшой стенд, на котором можно вручную проверить все варианты: обычный HTTP-запрос, прямой запрос к JSON, Selenium, Playwright и Puppeteer. А потом посмотрим на то, что обычно ломается в реальном парсере.

Сначала разберемся, откуда вообще берутся данные

Для эксперимента сделаем локальный магазин. Это удобнее: мы точно знаем, какой HTML отдает сервер, какой JavaScript выполняется в браузере и откуда появляются товары.

Структура будет такой:

parser-demo/

├── server.py

├── requests_parser.py

├── selenium_parser.py

├── playwright_parser.py

├── parser.mjs

└── site/

    ├── index.html

    ├── script.js

    └── products.json

Страница не содержит карточек товаров. В HTML есть только контейнер, а JavaScript запрашивает products.json и добавляет элементы в DOM.

site/index.html

html
<!doctype html>

<html lang="ru">

<head>

    <meta charset="utf-8">

    <title>Demo Shop</title>

    <script src="/script.js" defer></script>

</head>

<body>

    <h1>Интернет-магазин</h1>

    <div id="products">Загрузка товаров...</div>

</body>

</html>

site/products.json

json
[

  {"name": "Python для начинающих", "price": 1500},

  {"name": "Selenium WebDriver", "price": 2000},

  {"name": "Playwright Automation", "price": 2500}

]

site/script.js

javascript
fetch("/products.json")

  .then(response => {

    if (!response.ok) {

      throw new ErrorHTTP ${response.status});

    }

    return response.json();

  })

  .then(products => {

    const container = document.querySelector("#products");

    container.innerHTML = "";

    for (const product of products) {

      const item = document.createElement("div");

      item.className = "product";

      item.innerHTML = `

        <h2>${product.name}</h2>

        <p class="price">${product.price} ₽</p>

      `;

      container.appendChild(item);

    }

  })

  .catch(error => {

    document.querySelector("#products").textContent =

      "Не удалось загрузить товары";

    console.error(error);

  });

Для раздачи файлов хватит небольшого Flask-сервера. Путь к каталогу задаем от расположения server.py. Если сделать это от текущей директории запуска — пример легко сломать одним cd.

server.py

python
from pathlib import Path

from flask import Flask, send_from_directory

BASE_DIR = Path(__file__).resolve().parent

SITE_DIR = BASE_DIR / "site"

app = Flask(__name__)

@app.get("/")

def index():

    return send_from_directory(SITE_DIR, "index.html")

@app.get("/<path:path>")

def static_files(path: str):

    return send_from_directory(SITE_DIR, path)

if name == "__main__":

    app.run(host="127.0.0.1", port=8000, debug=False)

Для примеров нужен Python 3.10+. Устанавливаем зависимости и запускаем сервер:

python
python -m venv .venv

source .venv/bin/activate        # macOS/Linux

.\.venv\Scripts\Activate.ps1   # Windows PowerShell

python -m pip install flask requests beautifulsoup4 selenium playwright

python -m playwright install chromium

python server.py

После этого http://127.0.0.1:8000 открывается как обычная страница, а браузер спустя доли секунды показывает три карточки.

requests видит HTML, но не выполняет JavaScript

Начнем с самого экономичного, дешевого варианта. Если данные уже лежат в серверном HTML, связки requests + BeautifulSoup, скорее всего, будет достаточно. Откройте второй терминал в той же папке и выполните команду:

source .venv/bin/activate # macOS/Linux

.\.venv\Scripts\Activate.ps1 # Windows PowerShell

В первом терминале нужно просто оставить сервер запущенным, а во втором — начать работу над файлом requests_parser.py.

requests_parser.py

python
import requests

from bs4 import BeautifulSoup

URL = "http://127.0.0.1:8000"

response = requests.get(URL, timeout=10)

response.raise_for_status()

soup = BeautifulSoup(response.text, "html.parser")

products = soup.select(".product")

print(f"Товаров найдено: {len(products)}")

Результат нашего стенда:

Товаров найдено: 0

Парсер ничего не потерял. В тексте ответа действительно нет div.product: эти элементы создает script.js уже после загрузки документа.

Прежде чем запускать Selenium, смотрим Network

На этом в реальном продакшн-проекте экономится больше всего времени. Если браузер получает данные отдельным JSON-запросом, нет смысла поднимать Chrome только ради того, чтобы потом снова вытащить те же данные из DOM.

В нашем стенде источник лежит по адресу /products.json, поэтому можно запросить его напрямую:

python
import requests

response = requests.get(

    "http://127.0.0.1:8000/products.json",

    timeout=10,

)

response.raise_for_status()

for product in response.json():

    print(product["name"], product["price"])

Получим все три записи без JavaScript, WebDriver и разбора HTML. На реальном сайте я обычно начинаю именно с DevTools → Network: смотрю XHR/fetch-запросы, параметры, пагинацию и формат ответа.

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

Когда действительно нужен Selenium

Браузер становится оправданным, если данные появляются только после сложного клиентского сценария, нужны клики и формы, состояние хранится в браузере или удобного сетевого запроса нет.

В актуальном Selenium отдельный webdriver-manager для обычного локального запуска обычно не нужен: Selenium Manager умеет подобрать и настроить драйвер при создании WebDriver. Об этом сказано в официальной документации Selenium.

selenium_parser.py

python
from bs4 import BeautifulSoup

from selenium import webdriver

from selenium.webdriver.chrome.options import Options

from selenium.webdriver.common.by import By

from selenium.webdriver.support import expected_conditions as EC

from selenium.webdriver.support.ui import WebDriverWait

URL = "http://127.0.0.1:8000"

options = Options()

options.add_argument("--headless")

driver = webdriver.Chrome(options=options)

try:

    driver.get(URL)

    WebDriverWait(driver, 10).until(

        EC.presence_of_all_elements_located(

            (By.CSS_SELECTOR, ".product")

        )

    )

    html = driver.page_source

finally:

    driver.quit()

soup = BeautifulSoup(html, "html.parser")

for product in soup.select(".product"):

    name = product.select_one("h2").get_text(strip=True)

    price = product.select_one(".price").get_text(strip=True)

    print(name, price)

Здесь важен не сам Selenium, а способ ожидания. Фиксированное time.sleep(3) работает до тех пор пока страница стабильно укладывается в три секунды. WebDriverWait ждет конкретное состояние — появление карточек — и заканчивает раньше, если они загрузились быстро.

На нашем стенде Selenium увидит три товара

Документация Selenium: https://www.selenium.dev/documentation/webdriver/

То же самое на Playwright

Playwright для Python решает ту же задачу, но модель ожиданий у него заметно завязана на локаторы и автоматические проверки состояния элементов. Для действий вроде click() он сам ждет, пока элемент станет пригоден для взаимодействия. При этом не стоит считать, что методы, которые просто читают список, какие-то магическим образом ожидают весь динамический контент. По этой причине в примере явно ожидаем первую карточку.

playwright_parser.py

python
from playwright.sync_api import sync_playwright

URL = "http://127.0.0.1:8000"

with sync_playwright() as p:

    browser = p.chromium.launch(headless=True)

    try:

        page = browser.new_page()

        page.goto(URL)

        products = page.locator(".product")

        products.first.wait_for(state="attached")

        for index in range(products.count()):

            print(products.nth(index).inner_text())

    finally:

        browser.close()

Playwright умеет управлять Chromium, Firefox и WebKit. После установки пакета браузерные бинарники ставятся отдельной командой playwright install. Для небольшого парсера это чуть больше первоначальной настройки, зато дальше не приходится вручную строить ожидание вокруг каждого стандартного действия.

Документация Playwright для Python: https://playwright.dev/python/docs/library

А где здесь Puppeteer

Puppeteer часто попадает в один список с Selenium и Playwright, но это прежде всего библиотека экосистемы Node.js. Если основной проект написан на JavaScript или TypeScript, использовать Puppeteer вполне логично. Но переносить Python-парсер на Node.js смысла обычно нет.

Нужно установить npm, если он еще не установлен. Для этого стоит сделать следующее.

Для Windows: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm

Для Linux:

sudo apt update

sudo apt install npm

На момент публикации актуальный Puppeteer 25.12 требует Node.js 22.12+. Минимальный пример выглядит так. Сначала ставим пакет:

npm install puppeteer

В случае Linux, скорее всего, придется использовать такую команду:

npx puppeteer browsers install chrome 

parser.mjs

import puppeteer from "puppeteer";

const browser = await puppeteer.launch({headless: true});

try {

  const page = await browser.newPage();

  await page.goto("http://127.0.0.1:8000");

  await page.waitForSelector(".product");

  const products = await page.$$eval(".product", items =>

    items.map(item => item.innerText)

  );

  console.log(products);

} finally {

  await browser.close();

}

Запуск:

node parser.mjs

Текущая документация Puppeteer описывает работу с Chrome for Testing и Firefox. В Python-проекте стоит сравнивать прежде всего requests, Selenium и Playwright, а Puppeteer — это скорее альтернативный вариант для Node.js.

Документация Puppeteer: https://pptr.dev/

Как сайт понимает, что перед ним автоматизация

В реальном продукте задача обычно не заканчивается на получении DOM. Сервер и клиентская часть могут учитывать заголовки HTTP, cookies, частоту запросов, IP-репутацию, свойства браузерного окружения, последовательность действий, сетевой отпечаток и результаты JavaScript-проверок. Отдельный сигнал сам по себе обычно мало что доказывает, но их совокупность позволяет антибот-системе отличать обычный трафик от автоматизированного.

Но это совсем не означает, что следующим шагом нужно маскировать webdriver и начинать перебирать способы обхода CAPTCHA. Для рабочего сборщика полезнее сначала убрать собственные аномалии: не отправлять сотни запросов в секунду, уважать 429 Too Many Requests и Retry-After, кэшировать уже полученные страницы, не ходить повторно за одним и тем же URL и использовать официальный API, если он есть.

import time

import requests

session = requests.Session()

for url in urls:

    response = session.get(url, timeout=15)

    if response.status_code == 429:

        retry_after = response.headers.get("Retry-After")

        delay = int(retry_after) if retry_after and retry_after.isdigit() else 10

        time.sleep(delay)

        continue

    response.raise_for_status()

    parse(response.text)

    time.sleep(1)

Если ресурс отвечает CAPTCHA, явным запретом или стабильно блокирует автоматизацию, это сигнал остановиться и проверить допустимый способ доступа. Бесконечно усложнять маскировку вместо этого — плохая практика.

robots.txt полезен, но это не кнопка «можно/нельзя»

Перед сбором данных рекомендуем открыть /robots.txt и условия использования сайта. Но robots.txt — прежде всего инструкция для веб-краулеров; это не универсальное юридическое разрешение или запрет. Отдельно стоит учесть правила сервиса, авторизацию, персональные данные, лицензии на контент и нагрузку.

Для собственного стенда этот вопрос не возникает. Но вот в случае внешнего ресурса лучше решить его до того, как парсер запуститься и согласно расписанию начнет обходить тысячи страниц.

Собираем парсер, который не стыдно оставить работать

Учебный файл на 30 строк хорош, пока у нас одна страница. Дальше с масштабированием появляются сложности. Нужна правильная конфигурация, заданные правила для повторных попыток, настроенное логирование, подключенная база, тестовые HTML-снимки и отдельный слой загрузки.

Минимальная структура может выглядеть так:

web-parser/

├── config.py

├── browser.py

├── scraper.py

├── parser.py

├── database.py

├── main.py

└── data/

    └── products.db

Для работы программы требуется sqlite3. В Windows придется настроить sqlite3 самостоятельно, используя переменные окружения — https://www.sqlite.org/download.html

Для Linux используйте команды:

sudo apt update

sudo apt install sqlite3

Конфигурация отдельно:

config.py

python
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent

URL = "http://127.0.0.1:8000"

TIMEOUT = 10

DATABASE = BASE_DIR / "data" / "products.db"

Браузерный слой знает только о Selenium:

browser.py

python
from selenium import webdriver

from selenium.webdriver.chrome.options import Options

def create_browser() -> webdriver.Chrome:

    options = Options()

    options.add_argument("--headless")

    return webdriver.Chrome(options=options)

Загрузчик отвечает за получение уже отрисованной страницы и гарантированно закрывает браузер:

scraper.py

python
from selenium.webdriver.common.by import By

from selenium.webdriver.support import expected_conditions as EC

from selenium.webdriver.support.ui import WebDriverWait

from browser import create_browser

from config import TIMEOUT

def get_rendered_html(url: str) -> str:

    browser = create_browser()

    try:

        browser.get(url)

        WebDriverWait(browser, TIMEOUT).until(

            EC.presence_of_all_elements_located(

                (By.CSS_SELECTOR, ".product")

            )

        )

        return browser.page_source

    finally:

        browser.quit()

Парсер HTML не должен знать, откуда пришла страница — из Selenium, файла с тестовым снимком или обычного HTTP-запроса:

parser.py

python
from bs4 import BeautifulSoup

def parse_products(html: str) -> list[dict]:

    soup = BeautifulSoup(html, "html.parser")

    result = []

    for item in soup.select(".product"):

        name_node = item.select_one("h2")

        price_node = item.select_one(".price")

        if not name_node or not price_node:

            continue

        price = int(

            price_node.get_text(strip=True)

            .replace("₽", "")

            .replace(" ", "")

        )

        result.append({

            "name": name_node.get_text(strip=True),

            "price": price,

        })

    return result

Для демо сохраним результат в SQLite. Название товара делаем уникальным, чтобы повторный запуск не плодил дубликаты:

database.py

python
import sqlite3

from config import DATABASE

def init_db() -> None:

    DATABASE.parent.mkdir(parents=True, exist_ok=True)

    with sqlite3.connect(DATABASE) as connection:

        connection.execute("""

            CREATE TABLE IF NOT EXISTS products (

                name TEXT PRIMARY KEY,

                price INTEGER NOT NULL

            )

        """)

def save_products(products: list[dict]) -> None:

    with sqlite3.connect(DATABASE) as connection:

        connection.executemany(

            """

            INSERT INTO products (name, price)

            VALUES (:name, :price)

            ON CONFLICT(name) DO UPDATE SET

                price = excluded.price

            """,

            products,

        )

Точка входа остается короткой:

main.py

python
from config import URL

from database import init_db, save_products

from parser import parse_products

from scraper import get_rendered_html

def main() -> None:

    init_db()

    html = get_rendered_html(URL)

    products = parse_products(html)

    save_products(products)

    print(f"Сохранено товаров: {len(products)}")

if name == "__main__":

    main()

Такой проект уже можно протестировать: parser.py проверять на сохраненных HTML-файлах, database.py — отдельно на временной базе, а браузерный слой, наоборот, не таскать во все тесты подряд.

Что сломается первым на реальном сайте

Верстка

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

html
products = parse_products(html)

if not products:

    raise RuntimeError(

        "Парсер не нашел ни одного товара: "

        "проверьте селекторы и ответ сайта"

    )

Пагинация и бесконечная лента

На обычной пагинации можно идти по page=1, page=2 и останавливаться, когда следующей страницы нет. С бесконечной лентой лучше сначала выяснить, какой запрос выполняется при прокрутке. Очень часто вместо десятков scroll() находится понятный JSON endpoint с cursor или offset.

Авторизация

Если данные доступны только после входа, хранить логин и пароль прямо в исходниках — плохая идея. Секреты выносят в переменные окружения или менеджер секретов. Для браузерной автоматизации можно сохранять состояние сессии, если это допускается правилами сервиса, а не логиниться заново на каждой странице.

Повторные запуски

Парсер почти никогда не запускается один раз. Значит, заранее нужно решить, что считать уникальной записью, как обновлять изменившиеся поля и как не собирать одну страницу заново без необходимости. Иначе первая же ежедневная задача превращает CSV в склад из дубликатов — такое не нужно никому.

Наблюдаемость

В продакшн-запуске важнее всего возможность возможность понять, что парсер сегодня собрал 12 000 карточек, вчера 11 800, а завтра внезапно 17. Логи, метрики, число ошибок и проверка результата спасают чаще и раньше очередного try/except.

Что выбрать: requests, Selenium, Playwright или Puppeteer

Не стоит выбирать инструмент заранее. Ориентируетесь на потребности и особенности проекта. Если нужные поля уже есть в HTML, requests + BeautifulSoup обычно проще, быстрее и дешевле по ресурсам. Если браузер получает удобный JSON, лучше работать с ним. Selenium нужен там, где важна настоящая браузерная сессия или уже есть Selenium-инфраструктура. Playwright совместим с новыми Python-проектами с динамическими интерфейсами, предоставляет удобные локаторы и ожидания. Puppeteer решает похожие задачи в Node.js.

Главная ошибка — начинать с самого тяжелого инструмента. В нашем тестовом магазине Selenium успешно находит три товара, но лучший парсер для этих данных — вообще один requests.get('/products.json').

Рассмотрим веб со стороны безопасности

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

Если после такого разбора хочется копнуть именно эту сторону, в CyberYozh Academy сейчас есть несколько программ: «Специалист по OSINT Кибердетектив 2.0» про поиск и анализ открытых данных, «Этичный хакинг: Эпоха AI» про пентест и исследование уязвимостей, «Анонимность и безопасность 3.0» про приватность и защиту данных, а «Специалист по информационной безопасности» дает более широкий маршрут по ИБ.

Каталог CyberYozh Academy: https://academy.cyberyozh.com/courses

Вместо вывода

Парсер, который видит пустую страницу, не обязательно сломан. Возможно, мы просто смотрим не на тот слой.

Сначала проверяем исходный HTML и Network. Если есть нормальный JSON — забираем его. Если данные действительно появляются только внутри браузерного сценария — подключаем Selenium или Playwright. После этого уже думаем о пагинации, сессиях, ограничениях скорости, хранении и мониторинге.

На маленьком стенде разница между подходами становится заметна буквально за несколько минут. И это полезнее, чем начинать с магического набора флагов «от антибота»: сначала нужно понять архитектуру сайта, а потом выбирать инструмент.