Парсинг сайтов на 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
<!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
[
{"name": "Python для начинающих", "price": 1500},
{"name": "Selenium WebDriver", "price": 2000},
{"name": "Playwright Automation", "price": 2500}
]site/script.js
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
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 -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
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, поэтому можно запросить его напрямую:
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
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
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
Конфигурация отдельно:
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:
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)Загрузчик отвечает за получение уже отрисованной страницы и гарантированно закрывает браузер:
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-запроса:
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. Название товара делаем уникальным, чтобы повторный запуск не плодил дубликаты:
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,
)Точка входа остается короткой:
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 или изменили вложенность карточки. Если парсер молча возвратил пустой список, ошибка может остаться незамеченной. Поэтому полезно считать ожидаемый минимум записей и поднять ошибку, если страница внезапно опустела.
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. После этого уже думаем о пагинации, сессиях, ограничениях скорости, хранении и мониторинге.
На маленьком стенде разница между подходами становится заметна буквально за несколько минут. И это полезнее, чем начинать с магического набора флагов «от антибота»: сначала нужно понять архитектуру сайта, а потом выбирать инструмент.

