Парсинг на Python: HTTP, Scrapy или браузер — практическое сравнение

Когда говорят о парсинге на 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).
Библиотека | Слой | Версия | Последний релиз | Заметка |
|---|---|---|---|---|
HTTP-клиент | 2.34 | май 2026 | только sync | |
HTTP-клиент | 0.28 | декабрь 2024 | sync и async, HTTP/2; новых релизов давно нет | |
парсер (обёртка) | 4.15 | июнь 2026 | поверх html.parser, lxml или html5lib | |
парсер | 6.1 | сентябрь 2026 | libxml2, XPath | |
парсер | 0.4 | сентябрь 2026 | движки Lexbor и Modest, только CSS | |
фреймворк | 2.19 | сентябрь 2026 | Twisted + asyncio | |
браузер | 4.50 | сентябрь 2026 | драйвер подтягивает Selenium Manager | |
браузер | 1.63 | сентябрь 2026 | Chromium, Firefox, WebKit; sync и async | |
HTTP + формы | 1.4 | май 2025 | requests + BeautifulSoup, без JS | |
браузер | 2.0 | февраль 2024 | в README: «This repo is unmaintained» | |
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, другим запросам это не мешает. Грубая, но честная имитация сайта, который отвечает не мгновенно.
Маршрут | Что там | Сценарий |
|---|---|---|
| серверный HTML: 20 карточек + меню и промо-блоки, около 75 КБ, 1000 страниц | 1, 3 |
| то же, но каждая 10-я страница в первый раз отвечает 503 | 3 |
| пустой | 2 |
| JSON, который читает | 2 |
| форма поиска и кнопка «Показать ещё» | 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-запросом. Проверить это можно за минуту:
Открыть DevTools (F12), вкладка Network.
Включить фильтр Fetch/XHR и Preserve log.
Перезагрузить страницу или нажать кнопку, после которой появляются данные.
Посмотреть Response или Preview у запросов. Ctrl+F в панели Network ищет и по телам ответов, так что можно просто вбить текст со страницы.
Найдя нужный запрос, сделать 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, сохранённый | — | — | ни одного |
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 | |
|---|---|---|
Ожидания | явные ( | действия ждут сами, |
Элемент |
|
|
Изоляция сессий | один драйвер — один профиль | много |
Перехват сети | WebDriver BiDi ( |
|
Async API | нет, параллелизм через потоки или Grid | есть, наряду с sync |
Браузеры | системные Chrome, Edge, Firefox, Safari | свои Chromium, Firefox, WebKit плюс Chrome/Edge; Safari нет |
iframe |
|
|
Файлы |
|
|
Cookies и storage |
|
|
Отладка | логи драйвера | Trace Viewer, |
Инфраструктура | Grid, облачные фермы, стандарт W3C, много языков | один вендор, удалённый запуск через |
У Selenium есть сильные стороны, которые в моих тестах не проявились: настоящий Safari, стандарт W3C и Grid, который во многих компаниях уже развёрнут. И это уже не Selenium из сравнений пятилетней давности: драйвер больше не надо качать руками, а перехват сети через BiDi есть и в Python. Но для нового проекта с динамичным интерфейсом локаторы и контексты Playwright снимают целый класс проблем, и это важнее разницы в доли секунды.
BeautifulSoup, lxml и selectolax — это отдельное сравнение
На одной странице разница между парсерами тонет в сети и старте интерпретатора. Чтобы её увидеть, я убрала сеть вовсе: один и тот же HTML каталога разбирается в памяти, задача прежняя.
Парсер | Время на документ, мс | Селекторы |
|---|---|---|
BeautifulSoup + | ~22 | CSS, методы |
BeautifulSoup + | ~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 с на | высокая, браузер на сессию | потоки или 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-парсинга на полноценный браузер — или наоборот?
Источники
PyPI: requests, httpx, beautifulsoup4, lxml, selectolax, parsel, Scrapy, selenium, playwright, MechanicalSoup, pyppeteer, cloudscraper
Scrapy: Architecture overview, Settings, AutoThrottle, шаблон settings.py
Playwright: Auto-waiting, Locators, Browser contexts, Network, Browsers
Selenium: Waiting strategies, Selenium Manager, WebDriver BiDi
RFC 9309 — Robots Exclusion Protocol, Chrome DevTools: Network
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.