Когда говорят о парсинге на Python, в один список обычно складывают BeautifulSoup, Selenium, Scrapy и Playwright. Выглядит как выбор из четырёх аналогов, но это инструменты разных уровней. BeautifulSoup не умеет ходить в сеть. Selenium вообще не парсер. Scrapy — фреймворк, внутри которого можно использовать любой парсер. Сравнивать их в лоб — примерно как сравнивать двигатель, коробку передач и такси.

На практике эта путаница обходится дорого. Для сбора каталога из пары тысяч страниц поднимают Selenium с Chrome, хотя данные лежат в обычном HTML. Или наоборот: полдня пытаются вытащить requests’ом то, что рисует JavaScript, и не понимают, откуда в ответе пустой <div id="root">.

Поэтому вместо очередного топа библиотек я собрала локальный стенд с четырьмя типичными ситуациями: статический HTML, страница на JavaScript, массовый обход сотен страниц и интерфейс, где надо нажимать кнопки и ждать подгрузки. В каждой прогнала несколько подходящих инструментов и посмотрела на время, память, объём кода и на то, что ломается.

Спойлер: главный вопрос не «что лучше», а «где лежат данные». Когда ответ на него есть, инструмент обычно выбирается сам.

Что вообще сравниваем

Инструменты делятся на слои, и сравнивать их имеет смысл внутри слоя.

HTTP-клиент (requests, httpx) отправляет запрос и отдаёт ответ как есть: байты, текст, JSON. Про HTML он ничего не знает, JavaScript не выполняет.

HTML-парсер (BeautifulSoup, lxml, selectolax) превращает строку HTML в дерево, по которому можно искать CSS-селекторами или XPath. Откуда взялась строка, ему всё равно.

HTTP CLIENT (requests / httpx)
      ↓
   HTML / JSON
      ↓
PARSER (BeautifulSoup / lxml / selectolax)

Автоматизация браузера (Selenium, Playwright) запускает настоящий браузер и управляет им из Python. Страницу скачивает, скрипты выполняет и DOM строит сам браузер, а Python читает готовый результат.

BROWSER AUTOMATION (Selenium / Playwright)
      ↓
браузер: сеть → HTML → CSS → JavaScript
      ↓
   DOM (то, что видит пользователь)

Фреймворк (Scrapy) — готовая инфраструктура вокруг HTTP: очередь запросов, конкурентность, повторы, фильтр дублей, экспорт. Внутри у него parsel поверх lxml, но можно подключить и selectolax.

SCRAPY
      ↓
HTTP + scheduler + retries + middlewares + pipelines + spiders

Что из этого живо

Перед тестами я посмотрела свежие релизы на PyPI и активность репозиториев (данные на начало октября 2026).

Библиотека

Слой

Версия

Последний релиз

Заметка

requests

HTTP-клиент

2.34

май 2026

только sync

httpx

HTTP-клиент

0.28

декабрь 2024

sync и async, HTTP/2; новых релизов давно нет

beautifulsoup4

парсер (обёртка)

4.15

июнь 2026

поверх html.parser, lxml или html5lib

lxml

парсер

6.1

сентябрь 2026

libxml2, XPath

selectolax

парсер

0.4

сентябрь 2026

движки Lexbor и Modest, только CSS

Scrapy

фреймворк

2.19

сентябрь 2026

Twisted + asyncio

selenium

браузер

4.50

сентябрь 2026

драйвер подтягивает Selenium Manager

playwright

браузер

1.63

сентябрь 2026

Chromium, Firefox, WebKit; sync и async

MechanicalSoup

HTTP + формы

1.4

май 2025

requests + BeautifulSoup, без JS

pyppeteer

браузер

2.0

февраль 2024

в README: «This repo is unmaintained»

cloudscraper

HTTP-клиент

1.2

апрель 2023

в тестах не участвует

Три последние строки в основные тесты не попали. Pyppeteer когда-то был единственным способом управлять headless Chrome из asyncio, но сейчас авторы сами пишут, что проект не поддерживается, и советуют Playwright. MechanicalSoup — это requests и BeautifulSoup с удобной работой с HTML-формами; JavaScript он не выполняет, так что на статике ничем не отличается от связки requests + BS4, а на динамике не конкурирует с браузерами. Своя ниша у него есть: старые серверные сайты с формой входа или поиска. cloudscraper прямо позиционируется как средство обхода защиты Cloudflare, а это уже не про извлечение данных. Если сайт показывает вам challenge-страницу, это повод искать официальный API или договариваться с владельцем.

Про httpx стоит помнить, что новых релизов нет с конца 2024 года, а версия всё ещё 0.x. Библиотека стабильная и распространённая, но ниже будет пример, где это аукнулось.

Тестовый стенд

Все тесты идут против локального сервера на Starlette. Живой сайт меняется между прогонами, кэшируется CDN и отвечает с разной скоростью, так что цифры не воспроизвести. К тому же тысяча запросов с конкурентностью 64 в чужой продакшен — это уже не бенчмарк, а нагрузка на чужой сервер.

У localhost обратная беда: сеть идеальная, и параллелизм выглядит бесполезным. Поэтому сервер перед каждым ответом ждёт 50 мс через asyncio.sleep, другим запросам это не мешает. Грубая, но честная имитация сайта, который отвечает не мгновенно.

Маршрут

Что там

Сценарий

/catalog?page=N

серверный HTML: 20 карточек + меню и промо-блоки, около 75 КБ, 1000 страниц

1, 3

/flaky/catalog?page=N

то же, но каждая 10-я страница в первый раз отвечает 503

3

/spa

пустой <div id="app"> и app.js с fetch

2

/api/products?page=N

JSON, который читает app.js

2

/ui

форма поиска и кнопка «Показать ещё»

4

Машина — ноутбук на Intel Core Ultra 5 125H с 16 ГБ памяти, Windows 11, Python 3.14. Selenium работал с установленным Chrome 154, Playwright — со своим Chromium, оба в headless-режиме. Сервер стенда крутился на той же машине.

Как мерила:

  • каждый вариант — отдельный скрипт в новом процессе, чтобы в замер попало всё, что платит реальный запуск: импорты, старт браузера, старт реактора Twisted;

  • память — пик суммарного RSS процесса и всех потомков (chromedriver, драйвер Playwright, процессы Chrome) через psutil. Chrome делит часть памяти между процессами, так что у браузеров цифры немного завышены;

  • один прогревочный запуск, потом 5–7 зачётных, в тексте медиана;

  • прогон засчитывается, только если скрипт вернул ожидаемое число товаров.

Код стенда и всех вариантов: [ссылка на репозиторий].

Тест №1. Обычный HTML

Скачать одну страницу каталога и достать 20 названий и цен. Данные уже лежат в HTML.

requests + BeautifulSoup

resp = requests.get(URL, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")
items = [
    {"title": card.select_one(".title").get_text(strip=True),
     "price": float(card.select_one(".price").get_text())}
    for card in soup.select("article.product")
]

timeout здесь не для красоты: без него requests будет ждать зависший сервер вечно. raise_for_status() превращает 404 и 503 в исключение — иначе парсер спокойно разберёт страницу ошибки и вернёт пустой список. Второй аргумент BeautifulSoup выбирает парсер под капотом, к этому ещё вернёмся. Ограничение очевидное: код видит только то, что прислал сервер.

httpx + selectolax

resp = httpx.get(URL, timeout=10)
resp.raise_for_status()
tree = LexborHTMLParser(resp.text)
items = [
    {"title": node.css_first(".title").text(strip=True),
     "price": float(node.css_first(".price").text())}
    for node in tree.css("article.product")
]

Почти то же самое: для одной страницы httpx — просто другой клиент с похожим API. css_first возвращает None, если ничего не нашёл, так что на карточке без цены код упадёт с AttributeError. XPath в selectolax нет.

Scrapy

class OnePage(scrapy.Spider):
    name = "one"
    start_urls = [URL]

    def parse(self, response):
        for card in response.css("article.product"):
            yield {"title": card.css(".title::text").get(),
                   "price": float(card.css(".price::text").get())}

Паук сам ничего не скачивает. Он описывает, откуда начать и что делать с ответом, а загрузкой, повторами и экспортом занимается движок. yield отдаёт результат в pipeline, а не в вызывающий код, поэтому для запуска из скрипта пришлось написать ещё строк пятнадцать обвязки. Обычно их заменяет scrapy crawl one -o items.json.

Браузеры я прогнала на той же задаче двумя способами: данные собирались одним JS-вызовом и наивно, по одной команде на элемент.

Результаты

Вариант

Время, с

Память, МБ

requests + BeautifulSoup

0,3

~40

httpx + selectolax / lxml

0,6

~50

Playwright

~1,0

~380

Scrapy

~1,0

~90

Selenium

~3,3

~570

С памятью всё ожидаемо: браузерам нужно примерно в десять раз больше. А со временем три вещи меня удивили.

httpx медленнее requests примерно на четверть секунды. Разложила по шагам: сам импорт быстрый, а вот создание httpx.Client() занимает около 0,3 с даже для обычного HTTP — клиент сразу собирает SSL-контекст и грузит сертификаты. httpx.get() создаёт такой клиент на каждый вызов. В сервисе с одним долгоживущим Client это разовая цена, в цикле из httpx.get() — постоянная.

Scrapy на одной странице не быстрее Playwright. Сначала я подумала, что перепутала строки, но результат повторялся. Сам запрос с парсингом занимает сотые доли секунды, всё остальное — импорт фреймворка, старт реактора, инициализация middlewares и расширений. Для краулера на час работы это ничто. Для скрипта, который cron дёргает раз в минуту ради одной страницы, — заметно.

Selenium втрое медленнее Playwright, и браузер тут ни при чём. Запуск Chrome и загрузка страницы заняли меньше секунды, а driver.quit() стабильно занимал около двух. Ответ нашёлся в исходниках Selenium (webdriver/common/service.py): после команды завершения он проверяет, закрылся ли драйвер, в цикле с sleep(1). chromedriver не успевает закрыться к первой проверке — и выход растягивается до двух секунд. Без этого разница с Playwright почти исчезает.

Наивное чтение по одному элементу добавило десятые доли секунды: у Selenium каждый find_element и каждый .text — отдельный HTTP-запрос к chromedriver. На двадцати карточках это мелочь, на таблице в тысячу строк уже нет.

Вывод простой: если данные есть в HTML, браузер вернёт то же самое в 3–10 раз медленнее и с десятикратным расходом памяти. На одной странице этого можно не заметить. На тысяче — это уже деньги и серверы.

Тест №2. JavaScript

Те же 20 товаров, но /spa отдаёт почти пустой HTML, а карточки рисует app.js после запроса к JSON API. Так устроено большинство приложений на React и Vue без серверного рендеринга.

Что видит requests

resp = requests.get("http://127.0.0.1:8000/spa", timeout=10)
soup = BeautifulSoup(resp.text, "lxml")
soup.select("article.product")   # []

Пустой список, каждый раз. Весь ответ сервера:

<body><div id='app'>Загрузка…</div><script src='/static/app.js'></script></body>

Для HTTP-клиента <script> — просто текст. Никто не скачивает app.js и не выполняет его, поэтому DOM, который он строит, не появляется. Это не баг requests, он просто так устроен.

Сначала проверьте Network, а потом запускайте браузер

Первая мысль при виде пустого <div id="app"> — «нужен Selenium». Но скрипт на странице откуда-то берёт данные, и чаще всего обычным HTTP-запросом. Проверить это можно за минуту:

  1. Открыть DevTools (F12), вкладка Network.

  2. Включить фильтр Fetch/XHR и Preserve log.

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

  4. Посмотреть Response или Preview у запросов. Ctrl+F в панели Network ищет и по телам ответов, так что можно просто вбить текст со страницы.

  5. Найдя нужный запрос, сделать Copy → Copy as cURL и повторить его в терминале. Ответ совпал — браузер для этих данных не нужен.

На стенде там обнаруживается GET /api/products?page=1, и весь код сводится к трём строкам:

resp = httpx.get("http://127.0.0.1:8000/api/products", params={"page": 1}, timeout=10)
resp.raise_for_status()
items = resp.json()["items"]   # id, title, price, in_stock

Парсер не нужен вовсе: данные уже структурированы, цена — число, наличие — булево значение, а не строка «нет в наличии». Вёрстка тоже перестаёт быть вашей проблемой: при редизайне меняются классы, а формат API трогают куда реже, на нём держится сам фронтенд.

С GraphQL то же самое: в Network виден POST /graphql с телом {"query": ..., "variables": ...}, и его можно повторить через httpx.post(url, json=payload). Иногда данные лежат прямо в HTML, но не в разметке, а в <script type="application/json"> или application/ld+json — тогда достаточно найти тег и сделать json.loads.

Границы у подхода тоже есть. Если запрос подписан токеном, который считает обфусцированный JavaScript, или данные идут по WebSocket в бинарном виде, воспроизводить это руками может оказаться дороже, чем запустить браузер. И непубличный API никто не обещает сохранять, а пользоваться им можно только в рамках условий сайта.

Selenium

driver.get(SPA_URL)
WebDriverWait(driver, 10).until(
    EC.presence_of_all_elements_located((By.CSS_SELECTOR, "article.product"))
)
cards = driver.find_elements(By.CSS_SELECTOR, "article.product")

driver.get() возвращает управление после события load, когда fetch ещё идёт. Я проверяла: сразу после get карточек на странице ноль. Отсюда явное ожидание: WebDriverWait перепроверяет условие и через 10 секунд сдаётся с TimeoutException. По умолчанию он проверяет раз в полсекунды, и это видно: данные приходят почти сразу, а ожидание заканчивается только на второй проверке. Интервал настраивается параметром poll_frequency.

Playwright

page.goto(SPA_URL)
cards = page.locator("article.product")
cards.first.wait_for()
titles = cards.locator(".title").all_inner_texts()

Ключевая вещь — locator. page.locator(...) ничего не ищет в момент вызова, это описание, которое заново выполняется при каждом действии. click() или inner_text() сами дождутся элемента, а клик — ещё и того, чтобы элемент стал видимым и доступным. Но списочные методы — all(), count(), all_inner_texts() — не ждут, о чём прямо предупреждает документация. Без строки с wait_for() код возвращал пустой список во всех попытках, ровно как Selenium без WebDriverWait.

Есть и промежуточный вариант: открыть страницу браузером, но данные взять не из DOM, а из ответа API, который браузер получил сам:

with page.expect_response(lambda r: "/api/products" in r.url) as resp_info:
    page.goto(SPA_URL)
items = resp_info.value.json()["items"]

Токены, cookies и заголовки формирует фронтенд, а вам достаётся чистый JSON без селекторов.

Результаты

Вариант

Нашёл товаров

Время, с

Память, МБ

requests, HTML страницы

0

0,3

~40

httpx, запрос к API

20

~0,6

~45

Playwright, перехват ответа API

20

~0,8

~330

Playwright, чтение DOM

20

~0,9

~340

Selenium, чтение DOM

20

~3,5

~540

Прямой запрос к API — самый быстрый из рабочих вариантов и примерно в семь раз экономнее по памяти. При этом половина его времени — всё те же 0,3 с на создание клиента. Но главное даже не скорость, а то, что для этого варианта хватило один раз заглянуть в DevTools. Динамический сайт не обязательно означает, что нужен Selenium или Playwright.

Тест №3. Массовый сбор страниц

Обойти 500 страниц каталога и собрать 10 000 названий. Тут важен уже не старт, а то, как инструмент распоряжается ожиданием сети. Парсер везде selectolax, кроме Scrapy со своим parsel.

Синхронный requests

retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])
with requests.Session() as s:
    s.mount("http://", HTTPAdapter(max_retries=retry))
    for url in URLS:
        resp = s.get(url, timeout=10)
        if resp.ok:
            items += parse(resp.text)

Session держит соединения открытыми, и следующий запрос идёт по уже установленному TCP. Retry из urllib3 повторяет запросы с перечисленными кодами и делает экспоненциальную паузу. Деталь, которую я увидела только в исходниках: первый повтор идёт сразу, пауза появляется со второй ошибки подряд. Главное ограничение синхронного цикла: пока один запрос ждёт ответа, ничего не происходит.

Тот же requests легко распараллелить пулом потоков, отдельная Session на каждый поток:

local = threading.local()

def fetch(url):
    if not hasattr(local, "s"):
        local.s = requests.Session()
    resp = local.s.get(url, timeout=10)
    return parse(resp.text) if resp.ok else []

with ThreadPoolExecutor(max_workers=16) as pool:
    chunks = list(pool.map(fetch, URLS))

Для ожидания сети потоки в Python подходят отлично: на время системного вызова GIL отпускается. Десятки потоков — нормально, тысячи — уже повод смотреть в сторону asyncio.

Async httpx

sem = asyncio.Semaphore(CONCURRENCY)
limits = httpx.Limits(max_connections=CONCURRENCY, max_keepalive_connections=CONCURRENCY)
async with httpx.AsyncClient(timeout=10, limits=limits) as client:

    async def fetch(url):
        async with sem:
            resp = await client.get(url)
        return parse(resp.text) if resp.is_success else []

    chunks = await asyncio.gather(*(fetch(u) for u in URLS))

Ограничителей два: семафор держит число одновременных запросов, Limits — размер пула соединений. Без семафора все корутины разом встанут в очередь пула, где у них свой таймаут ожидания. Вторая ловушка — parse() внутри корутины: это синхронный CPU-код, и пока он работает, event loop стоит. С selectolax это доли миллисекунды на страницу, с BeautifulSoup — в двадцать раз больше. Повторы с паузами здесь придётся писать самостоятельно, ещё десяток строк.

Scrapy

Код паука почти такой же, как в первом тесте, меняются только настройки:

settings = {
    "CONCURRENT_REQUESTS": 16,
    "CONCURRENT_REQUESTS_PER_DOMAIN": 16,
    "DOWNLOAD_DELAY": 0,
    "RETRY_TIMES": 3,
}

Всё остальное делает движок:

Spider (start_urls, parse)
   ↓ Request
Scheduler — очередь с приоритетами и фильтром дублей
   ↓
Downloader middlewares — User-Agent, cookies, retry, redirect, сжатие, robots.txt
   ↓
Downloader — лимиты конкурентности, в том числе по домену, задержки, AutoThrottle
   ↓ Response
Spider.parse → Item / новые Request
   ↓
Item pipelines — валидация, дедупликация, запись в БД
   ↓
Feed exports — JSON, JSON Lines, CSV, XML

Из коробки приходит то, что в самописном asyncio-коде пришлось бы писать руками: повторы на типичные коды ошибок, обход по ссылкам с фильтром уже посещённых, лимиты на каждый домен, AutoThrottle, экспорт одной командой и статистика по кодам ответов. Ради одного URL это перебор. Для краулера, который неделями ходит по десятку сайтов, — ровно то, что нужно.

Одна оговорка: RetryMiddleware просто ставит неудачный запрос обратно в очередь, без экспоненциальной паузы. Замедлением при перегрузке сервера в Scrapy занимается AutoThrottle, а не ретраи.

Результаты: 500 страниц, 16 одновременно

При задержке 50 мс и 16 параллельных запросах теоретический минимум — около полутора секунд. Всё сверху — работа клиента, парсинг и сам сервер стенда.

Вариант

Время, с

Страниц/с

Память, МБ

requests без Session, последовательно

~34

~15

~40

requests + Session, последовательно

~31

~16

~40

requests, 16 потоков

~2,7

~190

~50

async httpx, 16 задач

~2,7

~180

~70

Scrapy

~4,7

~105

~350

Последовательный код проигрывает конкурентному больше чем в десять раз, и это целиком ожидание сети. А потоки и asyncio на 16 одновременных запросах дали одно и то же. Session в последовательном цикле сэкономила около 10% даже на localhost; с TLS и реальной сетью выигрыш будет больше.

Scrapy оказался почти вдвое медленнее самописного кода и съел в несколько раз больше памяти. Часть — секунда на старт, часть — middlewares на каждом запросе и parsel вместо selectolax. Откуда столько памяти, я разбираться не стала.

Результаты: 1000 страниц, 64 одновременно

А вот здесь всё пошло совсем не так, как я ожидала.

Вариант

Время, с

Страниц/с

requests, 64 потока

~1,8

~570

Scrapy

7–9

~110

async httpx, 64 задачи

~11,5

~90

Потоки ускорились втрое, а async httpx стал медленнее, чем на 16. Первой мыслью было, что виноват парсинг в event loop или особенности asyncio на Windows. Проверила тот же обход без парсинга и с разными event loop’ами:

Клиент, 1000 страниц без парсинга

16 одновременно

64 одновременно

requests, потоки

~4 с

~1 с

aiohttp

~4 с

~1,3 с

httpx

~5 с

~8–9 с

aiohttp на том же asyncio масштабируется нормально, значит, дело в httpx. Под cProfile картина стала ясной: больше 80% времени уходило в одну функцию пула соединений httpcore, _assign_requests_to_connections, а проверка is_idle() вызывалась миллионы раз на тысячу запросов. Для этой функции есть открытый PR #1035, где автор описывает её квадратичную сложность. В версии, которая ставится с httpx 0.28, исправления нет.

Вывод узкий, но полезный: с текущим httpx пул на несколько десятков соединений сам становится узким местом. До 16 этого почти не видно. Если нужна высокая конкурентность к одному хосту, попробуйте aiohttp или обычные потоки — и не верьте, что async автоматически значит «быстрее».

Scrapy на 64 тоже не ускорился и заметно «гулял» от прогона к прогону. Подозреваю, что он упёрся в собственный CPU в одном потоке, но не профилировала, так что это гипотеза. Впрочем, 110 страниц в секунду — и так намного больше, чем разумно направлять на чужой сайт.

Повторы: каждая 10-я страница отвечает 503

Самый неприятный результат исследования оказался не про скорость. На /flaky/catalog каждая десятая страница в первый раз отвечает 503, а со второго — нормально.

Вариант

Собрано из 10 000

Ошибка в выводе

requests + Session

9 000

нет

requests + Session + Retry

10 000

—

async httpx

9 000

нет

async httpx с ретраями

10 000

—

Scrapy, ретраи по умолчанию

10 000

—

Варианты без повторов молча потеряли 50 страниц из 500. if resp.ok: выглядит аккуратно, скрипт завершается с кодом 0, а данных на 10% меньше. У Scrapy повторы включены изначально и видны в статистике прогона. Именно такие вещи, а не скорость, на мой взгляд и оправдывают фреймворк на большом краулере.

Тест №4. Взаимодействие с UI

Сценарий на /ui: ввести запрос, нажать «Найти», дождаться первых 20 карточек и жать «Показать ещё», пока кнопка не исчезнет — пять подгрузок, сто карточек. Каждая подгрузка — запрос к API плюс 300 мс имитации тяжёлого рендеринга. Пока идёт загрузка, кнопка заблокирована, а потом пересоздаётся новым элементом, как это часто делают фронтенд-фреймворки.

Сами данные и тут приходят из API. Тест про задачи, где взаимодействие и есть цель: e2e-тесты, проверка форм, скриншоты.

Selenium

wait = WebDriverWait(driver, 10)
driver.get(UI_URL)
driver.find_element(By.ID, "q").send_keys("товар")
driver.find_element(By.ID, "search").click()
wait.until(EC.presence_of_element_located(cards))

while driver.find_elements(By.ID, "load-more"):
    before = len(driver.find_elements(*cards))
    wait.until(EC.element_to_be_clickable((By.ID, "load-more"))).click()
    wait.until(lambda d: len(d.find_elements(*cards)) > before)

find_element находит элемент один раз и возвращает WebElement — ссылку на конкретный узел DOM. find_elements при отсутствии элементов возвращает пустой список, поэтому удобен для проверки «кнопка ещё на месте?». Все ожидания приходится прописывать явно, зато сразу видно, чего ждёт код.

А вот что будет, если сохранить кнопку в переменную и кликать по ней в цикле:

button = wait.until(EC.element_to_be_clickable((By.ID, "load-more")))
for _ in range(4):
    button.click()
    wait.until(EC.element_to_be_clickable((By.ID, "load-more")))
selenium.common.exceptions.StaleElementReferenceException: Message: stale element reference:
stale element not found in the current frame

Первый клик проходит, второй падает: приложение удалило старую кнопку и создало новую с тем же id. WebElement смотрит на удалённый узел, и то, что рядом есть точно такая же кнопка, ему не помогает. Это не баг, а следствие модели «элемент = ссылка на узел», но в динамичном интерфейсе именно она даёт большую часть нестабильных тестов.

Playwright

page.goto(UI_URL)
page.locator("#q").fill("товар")
page.locator("#search").click()

cards = page.locator("article.product")
more = page.locator("#load-more")
expect(cards).to_have_count(20)
while more.count():
    before = cards.count()
    more.click()
    expect(cards).not_to_have_count(before)

more — не найденная кнопка, а правило «элемент с id=load-more». Перед каждым кликом Playwright ищет его заново и ждёт, пока он станет видимым, стабильным, доступным и ничем не перекрытым. expect(...) — отдельный механизм проверок, который перепроверяет условие до таймаута. Приём, на котором сломался Selenium, — один объект кнопки на все клики — здесь просто работает.

Результаты

«4 сессии» — тот же сценарий четыре раза параллельно, с изолированными cookies и storage.

Вариант

Время, с

Память, МБ

Успешных прогонов

Selenium, одна сессия

~5,8

~600

все

Selenium, сохранённый WebElement

—

—

ни одного

Playwright, одна сессия

~5,0

~360

все

Selenium, 4 драйвера

~8

~2200

все

Playwright, 4 контекста в одном браузере

~5,0

~640

все

На одной сессии Playwright выигрывает меньше секунды — меньше, чем Selenium тратит на quit(). То есть сам сценарий Selenium проходил даже быстрее. Я разложила шаги Playwright по времени: каждая подгрузка через expect занимала около 0,85 с, хотя страница справлялась примерно за 0,35. В драйвере Playwright зашита лестница интервалов перепроверки — 100, 250, 500 и 1000 мс, и время точно в неё укладывается: условие ловится на третьей проверке. У Selenium шаг постоянный, полсекунды, и здесь он оказался удачнее. С page.wait_for_function() вместо expect поиск проходил вдвое быстрее. В тестах это неважно, в сценарии из сотен шагов — вполне. Автоматические ожидания дают стабильность, а не скорость.

Настоящая разница видна на параллельных сессиях. Selenium для четырёх сессий поднимает четыре chromedriver и четыре браузера: больше двух гигабайт памяти и заметный разброс времени. Playwright создаёт четыре browser.new_context() в одном браузере — изолированные профили со своими cookies и localStorage, как окна инкогнито. Время осталось тем же, а памяти ушло меньше чем вдвое больше, чем на одну сессию.

Тут есть оговорка, которую я чуть не упустила. В headless-режиме Playwright запускает не полный Chromium, а облегчённую сборку chromium_headless_shell, тогда как Selenium работал с обычным Chrome. Когда я запустила Playwright на том же системном Chrome (launch(channel="chrome")), памяти он съел даже больше, чем Selenium. Так что выигрыш Playwright по памяти в моих таблицах — во многом заслуга сборки браузера, а не библиотеки. Преимущество контекстов при этом никуда не девается: оно про архитектуру.

Selenium и Playwright по пунктам

Скорость — не главное, чем они отличаются. Вот с чем реально сталкиваешься на практике (selenium 4.50, playwright 1.63):

Selenium

Playwright

Ожидания

явные (WebDriverWait) или неявные (implicitly_wait), смешивать не рекомендуется

действия ждут сами, expect перепроверяет; all() и count() не ждут

Элемент

WebElement — ссылка на узел, может устареть

Locator — запрос, выполняется при каждом действии

Изоляция сессий

один драйвер — один профиль

много new_context() в одном браузере

Перехват сети

WebDriver BiDi (enable_bidi), для Chromium ещё CDP

page.route(), expect_response()

Async API

нет, параллелизм через потоки или Grid

есть, наряду с sync

Браузеры

системные Chrome, Edge, Firefox, Safari

свои Chromium, Firefox, WebKit плюс Chrome/Edge; Safari нет

iframe

switch_to.frame(), потом не забыть вернуться

frame_locator() без переключений

Файлы

send_keys(path); скачивание через настройки профиля

set_input_files(), expect_download()

Cookies и storage

get_cookies() / add_cookie()

storage_state() в файл и обратно

Отладка

логи драйвера

Trace Viewer, playwright codegen

Инфраструктура

Grid, облачные фермы, стандарт W3C, много языков

один вендор, удалённый запуск через connect()

У Selenium есть сильные стороны, которые в моих тестах не проявились: настоящий Safari, стандарт W3C и Grid, который во многих компаниях уже развёрнут. И это уже не Selenium из сравнений пятилетней давности: драйвер больше не надо качать руками, а перехват сети через BiDi есть и в Python. Но для нового проекта с динамичным интерфейсом локаторы и контексты Playwright снимают целый класс проблем, и это важнее разницы в доли секунды.

BeautifulSoup, lxml и selectolax — это отдельное сравнение

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

Парсер

Время на документ, мс

Селекторы

BeautifulSoup + html.parser

~22

CSS, методы find

BeautifulSoup + lxml

~16

то же

parsel

~2

CSS и XPath

lxml

~2

XPath, CSS через cssselect

selectolax

~1

CSS

BeautifulSoup медленнее lxml примерно на порядок и раз в двадцать медленнее selectolax. Причём замена html.parser на lxml внутри него помогает слабо: сам lxml разбирает документ быстро, но потом BeautifulSoup строит из результата собственное дерево Python-объектов, и это самое дорогое. lxml и selectolax держат дерево в C и создают Python-объекты только для узлов, к которым вы обратились.

Абсолютные цифры сильно зависят от машины — об этом ниже, в разделе про поломки. Соотношениям доверять можно больше.

На практике это значит:

  • десятки и сотни страниц — берите то, что удобнее. Разница теряется на фоне сети, а у BeautifulSoup лучшая терпимость к кривому HTML и подробная документация;

  • тысячи страниц с конкурентностью — парсер становится заметной статьёй расходов, а в asyncio он ещё и блокирует event loop;

  • нужен XPath — lxml или parsel, в selectolax его нет.

И ещё: сравнивать «BeautifulSoup против Selenium» нет смысла. HTML из браузера (page.content() или driver.page_source) можно отдать любому из этих парсеров.

Почему браузер почти всегда дороже

Проще всего это увидеть, если расписать путь данных. У HTTP-клиента он короткий:

Python-процесс
  → TCP/TLS-соединение (из пула, если есть Session/Client)
  → один HTTP-ответ (HTML или JSON)
  → парсер

У браузера — длинный, и процессов в нём несколько:

Python-процесс
  → протокол автоматизации
      Selenium:   HTTP → chromedriver → Chrome
      Playwright: пайп → драйвер → CDP → Chromium
  → процессы браузера: основной, сеть, GPU, рендереры
  → HTML и все подресурсы: CSS, JS, шрифты, картинки
  → выполнение JavaScript
  → DOM → стили → layout → отрисовка
  → ответ на каждую команду обратно через протокол

Отсюда три статьи расходов: старт браузера при каждом запуске, лишняя для нас работа со страницей (layout, картинки, шрифты) и межпроцессный вызов на каждую команду из Python.

Браузер от этого не становится плохим инструментом. Всё перечисленное — ровно то, ради чего его запускают, когда данные появляются только после чужого JavaScript. Вопрос лишь в том, нужно ли это в вашей задаче. А если нужно, часть расходов можно срезать: держать один браузер на много страниц, блокировать картинки и шрифты через page.route() и забирать данные одним JS-вызовом вместо сотни мелких.

О чём подумать до первого запроса

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

robots.txt и условия сайта. robots.txt — не закон и не защита, а явно высказанное пожелание владельца (RFC 9309). В Scrapy по умолчанию ROBOTSTXT_OBEY = False, но шаблон scrapy startproject включает его — это хорошая подсказка. Условия сайта и законы о персональных данных важнее выбора библиотеки, а при наличии официального API разумнее взять его.

Лимиты и задержки. Шаблон проекта Scrapy ставит один запрос в секунду на домен. Это на порядки медленнее цифр из третьего теста, и так и должно быть: высокая конкурентность уместна против своего сервера или API с известными лимитами. Ответ 429 — сигнал притормозить, а заголовок Retry-After стоит уважать; urllib3 Retry делает это сам.

Таймауты. requests без timeout ждёт вечно. У httpx по умолчанию 5 секунд, у Scrapy — три минуты. Один зависший запрос без таймаута может остановить весь ночной сбор.

User-Agent. По умолчанию requests и Scrapy честно представляются своими именами. Хорошая практика — UA с названием бота и контактом, чтобы администратор мог написать вам, а не сразу банить подсеть.

Авторизация и токены. Если данные доступны после входа, удобно залогиниться один раз в браузере (в Playwright — сохранить storage_state), а дальше ходить HTTP-клиентом с теми же cookies. CSRF-токены обычно читаются из самой страницы; если их вычисляет обфусцированный JS, это реальный довод в пользу браузера.

Прокси — инфраструктурная деталь: корпоративная сеть, региональная версия сайта, фиксированный IP для выхода из облака. Поддерживают их все инструменты из статьи.

CAPTCHA и антибот-системы. Это не задача для парсера, а прямой ответ сайта: автоматический доступ сюда не нужен. Я считаю это границей применимости любого инструмента и обход не рассматриваю. Нормальные варианты — официальный API, выгрузка по договорённости с владельцем или отказ от источника.

Что сломалось во время тестов

Всё ниже действительно случилось, пока я готовила статью.

Первая серия замеров оказалась мусором. Повторив несколько тестов для проверки, я получила цифры в 3–4 раза лучше. В журнале событий Windows нашлась смена источника питания ровно между сериями: первая шла от батареи. Пришлось перепрогнать всё от сети, в статье только эти цифры. Любопытно, что соотношения между инструментами в «батарейной» серии почти не изменились. Абсолютным цифрам с ноутбука стоит верить меньше, чем отношениям.

Git Bash переписал URL. Аргумент /flaky/catalog в Git Bash на Windows превратился в C:\Program Files\Git\flaky\catalog — MSYS конвертирует всё, что похоже на POSIX-путь. requests и httpx честно упали с InvalidURL. А Scrapy завершился с кодом 0, собрав ноль товаров, и без единой ошибки при LOG_LEVEL=WARNING. Мораль: проверять надо не код возврата краулера, а количество собранных данных.

lambda в сигнале Scrapy молча не работает. crawler.signals.connect(lambda item, **kw: items.append(item), ...) дал ноль товаров без ошибок. Scrapy хранит обработчики сигналов по слабой ссылке, и анонимную функцию, на которую больше никто не ссылается, сразу забирает сборщик мусора. Помогает именованная функция в области видимости или обычный feed export.

Ещё несколько вещей я сначала приняла за проблемы своего стенда: две секунды в driver.quit(), медленное создание httpx.Client(), деградацию пула httpcore, лестницу интервалов в expect и облегчённую сборку Chromium. Каждый раз разбор по шагам или профайлер показывал, что это поведение самих библиотек.

Итоговая таблица

Инструмент

Тип

Рендерит JS

Async

Скорость (по моим замерам)

Память

Масштабирование

Порог входа

Когда брать

requests + BeautifulSoup

HTTP-клиент + парсер

нет

нет, но есть потоки

самый быстрый старт; в потоках сотни стр./с

низкая

среднее: потоки, ретраи через urllib3

низкий

серверный HTML, до сотен страниц

httpx + selectolax

HTTP-клиент + парсер

нет

да

быстрый парсинг; пул проседает на десятках соединений

низкая

среднее, всё руками

низкий для sync, средний для async

JSON API, async-приложения, HTTP/2

Scrapy

фреймворк

нет (только через сторонние интеграции)

да

около секунды на старт, ~100 стр./с

средняя

высокое: очередь, лимиты по доменам, ретраи, AutoThrottle

средний

большие регулярные краулеры

Selenium

автоматизация браузера

да

нет

секунды на страницу, ~2 с на quit()

высокая, браузер на сессию

потоки или Grid

средний

готовый Grid, настоящий Safari, многоязычные команды

Playwright

автоматизация браузера

да

да

около секунды на страницу

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

контексты, async

средний

JS-сайты без удобного API, динамичный UI, параллельные сессии

Как выбрать инструмент

По итогам тестов дерево начинается не с вопроса «есть ли на сайте JavaScript», а с вопроса «где данные».

Есть официальный API или выгрузка?
│
├─ Да → берите его, парсинг не нужен
│
└─ Нет
    │
    Данные есть в исходном HTML (View Source, а не Elements)?
    │
    ├─ Да
    │   ├─ десятки–сотни страниц          → requests + BeautifulSoup/lxml
    │   ├─ тысячи страниц, один сайт, разово → потоки + selectolax/lxml
    │   └─ много сайтов, по расписанию,
    │      нужны ретраи и лимиты по домену  → Scrapy
    │
    └─ Нет, данные рисует JavaScript
        │
        В Network виден XHR/fetch/GraphQL с данными?
        │
        ├─ Да, и запрос повторяется через cURL
        │     → HTTP-клиент и JSON
        │
        ├─ Да, но нужен токен, который считает JS
        │     → Playwright + expect_response,
        │       или взять cookies из браузера и дальше HTTP-клиентом
        │
        └─ Нет, или нужно именно взаимодействие (формы, скриншоты, e2e)
              ├─ новый проект, параллельные сессии → Playwright
              └─ уже есть Selenium Grid, нужен Safari → Selenium

Ограничения теста

Это замеры на одном ноутбуке с Windows, и переносить их на свою задачу нужно с осторожностью.

  • Сеть. Сервер и клиент на одной машине, задержка искусственная и постоянная, без TLS и без ограничений со стороны сайта. В реальной сети и цена соединений, и выигрыш от параллелизма будут другими.

  • Сервер стенда работает на той же машине и делит с клиентами CPU.

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

  • Версии. Две секунды в quit() или проблемы пула httpcore могут исчезнуть в любом релизе.

  • ОС. На Linux-сервере процессы и браузеры обычно стартуют быстрее, но это я не проверяла.

  • Не проверял Firefox и WebKit, режим с окном, HTTP/2, Selenium Grid и scrapy-playwright.

Что я в итоге использовала бы

Для небольших HTML-страниц — requests + BeautifulSoup: быстрый старт и самый простой код. Если страниц станет много, первым делом поменяю парсер, а не HTTP-клиент.

Для разового обхода тысяч страниц одного сайта — requests в пуле потоков с Retry. На моём стенде это оказалось быстрее async httpx, а код короче. В уже асинхронном приложении — httpx при умеренной конкурентности или aiohttp, если соединений нужно много.

Для большого регулярного краулера — Scrapy, хотя быстрым он не был. Ретраи, лимиты по доменам, AutoThrottle и статистика стоят и секунды на старте, и лишней памяти.

Для данных из SPA — сначала Network, потом HTTP-клиент и JSON. Во втором тесте это было в разы быстрее браузеров и на порядок экономнее по памяти.

Для JavaScript-интерфейсов и сложной автоматизации — Playwright. Selenium — там, где уже есть Grid, нужен настоящий Safari или один инструмент на несколько языков.

Заключение

Главная ошибка — начинать выбор со слов «мне нужен парсер». Сначала нужно понять, где лежат данные: в HTML, в ответе API или только в состоянии страницы после действий пользователя. От этого зависит разница в ресурсах в десятки раз, а разница между библиотеками одного слоя уже вторична.

Вторая мысль — про цифры. Почти каждый неожиданный результат объяснялся не тем, что «библиотека X медленная», а конкретной строчкой кода: sleep(1) при остановке, интервалом опроса, квадратичным циклом в пуле. Поэтому чужие бенчмарки, включая этот, стоит перепроверять на своих версиях и своём сайте. Стенд для этого выложен целиком.

И третья — про надёжность. Опаснее всего оказались не медленные варианты, а те, что тихо вернули меньше данных с кодом 0: без ретраев, без ожидания, с lambda в сигнале. Проверка «собрано столько, сколько ожидалось» полезнее любого выбора библиотеки.

А какие случаи заставили вас перейти с HTTP-парсинга на полноценный браузер — или наоборот?

Источники

Комментарии (0)