Парсинг на PHP с использованием браузера

Управление настоящим браузером из PHP: Symfony Panther, chrome-php и Selenium — парсинг динамики без ухода с PHP-стека.

КP
Команда Parsing.agency
Сбор данных под задачи бизнеса
Опубликовано: 23 апреля 2025

Классический парсинг на PHP — это cURL + DOMDocument или связка вроде Symfony DomCrawler. Он быстрый, дешёвый и отлично работает ровно до того момента, пока сайт отдаёт готовый HTML. Современный фронтенд этого почти не делает: контент подгружается через AJAX, рисуется React/Vue/Angular уже в браузере, цены и каталоги прячутся за бесконечным скроллом, а на входе встречает антибот, проверяющий, умеет ли клиент исполнять JavaScript.

В этот момент HTTP-клиент перестаёт справляться, и приходится поднимать настоящий браузер — то есть управлять headless Chrome или Firefox прямо из PHP. Это решает проблему JS-рендеринга, но взамен приносит целый класс новых: ресурсы, утечки памяти, зомби-процессы, отвалившиеся вкладки и сложную отладку. Об этом и поговорим.

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

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

  • контент появляется в DOM только после исполнения JavaScript (SPA, ленивая подгрузка);
  • нужны действия пользователя: клики, скролл, заполнение форм, прохождение многошаговых сценариев;
  • сайт активно защищается от ботов и проверяет исполнение JS, отпечаток браузера, поведение мыши;
  • требуется рендеринг в скриншот или PDF, снятие метрик производительности.

Если же страница отдаёт нужные данные в исходном HTML или в виде JSON через внутренний API — браузер не нужен, и честный HTTP-запрос будет в десятки раз быстрее и стабильнее.

Популярные библиотеки

Symfony Panther

Panther — официальная библиотека от команды Symfony. Под капотом она использует протокол WebDriver и реальные драйверы (ChromeDriver, GeckoDriver), а API совместим с DomCrawler и BrowserKit. Это её главное преимущество: вы пишете привычный код, а вместо HTTP-клиента под ним работает живой Chrome.

php
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient(null, [
    '--headless=new',
    '--no-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
]);

$crawler = $client->request('GET', 'https://example.com');
$client->waitFor('.product-card'); // ждём появления элемента

$titles = $crawler->filter('.product-card h2')->each(
    fn ($node) => $node->text()
);

$client->quit(); // ОБЯЗАТЕЛЬНО — иначе процесс браузера останется висеть

Panther хорош для интеграционных тестов и умеренного парсинга. Главная боль — она не управляет жизненным циклом браузера идеально: при исключениях, фатальных ошибках или забытом quit() процессы накапливаются.

php-webdriver/webdriver + Selenium

php-webdriver (бывший Facebook WebDriver) — низкоуровневый, но самый гибкий клиент протокола WebDriver. Обычно работает в паре с Selenium Grid или Standalone, что удобно масштабируется в кластер.

php
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\WebDriverBy;

$driver = RemoteWebDriver::create(
    'http://selenium-hub:4444/wd/hub',
    DesiredCapabilities::chrome()
);

try {
    $driver->get('https://example.com');
    $element = $driver->findElement(WebDriverBy::cssSelector('.price'));
    $price = $element->getText();
} finally {
    $driver->quit(); // в finally — чтобы закрыть сессию даже при ошибке
}

Связка PHP + Selenium особенно удобна, когда инфраструктуру браузеров надо вынести в отдельный сервис (Docker Compose, Kubernetes), а PHP оставить тонким клиентом.

chrome-php/chrome (headless Chrome через DevTools Protocol)

Эта библиотека общается с Chrome напрямую по Chrome DevTools Protocol (CDP), минуя WebDriver. Она быстрее, ближе к «железу» и даёт тонкий контроль над страницами, сетью и вкладками.

php
use HeadlessChromium\BrowserFactory;

$factory = new BrowserFactory('chromium-browser');
$browser = $factory->createBrowser([
    'headless'        => true,
    'noSandbox'       => true,
    'windowSize'      => [1920, 1080],
    'keepAlive'       => false,
]);

try {
    $page = $browser->createPage();
    $page->navigate('https://example.com')->waitForNavigation();
    $html = $page->getHtml();
} finally {
    $browser->close(); // закрываем браузер и все его страницы
}

CDP-подход даёт самый прямой доступ к управлению вкладками (targets) и перехвату сетевых запросов — это пригодится дальше.

Goutte / DomCrawler — для сравнения

Стоит помнить: Goutte (ныне это просто BrowserKit поверх HttpClient) и чистый DomCrawler браузером не являются — они не исполняют JavaScript. Их часто путают с браузерными решениями, но это HTTP-клиенты. Если на странице нужен JS, они бесполезны, зато на статике несравнимо легче.

Проблемы браузерного парсинга

Ресурсы и производительность

Каждый экземпляр Chrome — это десятки и сотни мегабайт оперативной памяти, отдельные процессы рендерера, GPU, сети. Десяток параллельных браузеров легко съедают несколько гигабайт. Поэтому браузерный парсинг почти всегда строят как пул с жёстким лимитом параллелизма, очередью задач и таймаутами на каждую операцию.

Типичный набор флагов запуска для серверного окружения, экономящий ресурсы и убирающий частые падения:

code
--headless=new
--no-sandbox
--disable-dev-shm-usage      # критично в Docker: иначе /dev/shm переполняется и Chrome падает
--disable-gpu
--disable-extensions
--blink-settings=imagesEnabled=false   # не грузить картинки, если они не нужны
--js-flags=--max-old-space-size=512    # ограничить аппетит V8

Утечки памяти

Это, пожалуй, главная скрытая боль долгоживущих парсеров. Утечки приходят с двух сторон.

Со стороны самого браузера. Chrome не рассчитан на то, чтобы открывать тысячи страниц в одной сессии. Потребление памяти растёт от страницы к странице: накапливается кэш, не освобождаются DOM-деревья, копятся слушатели событий на стороне сайта. Один процесс браузера, обрабатывающий долгую очередь, через несколько часов может занять в разы больше памяти, чем в начале.

Со стороны PHP. Сам по себе PHP-процесс воркера тоже течёт: объекты-обёртки страниц, ответы, накопленные в памяти DOM-деревья, не очищенные переменные. Особенно опасны long-running воркеры (Symfony Messenger, RoadRunner, Swoole, обычный демон в цикле while (true)), где процесс живёт сутками.

Практические меры:

  • Перезапускать воркер по счётчику задач. Самый надёжный приём: воркер обрабатывает N задач (например, 50–200) и завершается, супервизор поднимает свежий. Накопленная память освобождается полностью операционной системой, а не сборщиком мусора PHP.
  • Перезапускать сам браузер периодически. Не держать один Chrome на тысячи страниц — закрывать и создавать заново каждые M страниц.
  • Закрывать страницы (вкладки) сразу после использования, а не только в конце всей пачки.
  • Следить за метриками. Логировать memory_get_usage(true) воркера и RSS процессов браузера, строить графики. Линейный рост памяти во времени — однозначный признак течи.
  • Аккуратно работать с gc_collect_cycles(). Принудительный сбор циклических ссылок иногда помогает, но это не замена перезапуску.

Незакрытые окна и зомби-процессы

Это специфическая для браузеров проблема, которую легко проглядеть. Если PHP-скрипт упал с исключением, был убит по таймауту или просто забыл вызвать quit()/close(), процесс браузера не умирает вместе с ним. Он остаётся висеть в системе — со всеми открытыми вкладками и занятой памятью. На активном парсере такие «осиротевшие» процессы накапливаются десятками, и через сутки сервер забит зомби-Chrome, хотя ни одной активной задачи нет.

Это нужно обязательно мониторить и чистить. Несколько уровней защиты:

1. Закрытие в finally. Любой код, открывший браузер, должен закрывать его в finally, чтобы исключение не оставило процесс висеть:

php
$browser = $factory->createBrowser([...]);
try {
    // ... работа
} finally {
    $browser->close();
}

2. Регистрация shutdown-функции. На случай фатальных ошибок:

php
register_shutdown_function(function () use ($browser) {
    try { $browser->close(); } catch (\Throwable $e) {}
});

3. Внешний мониторинг процессов. Регулярно (по cron или watchdog-скриптом) проверять, сколько процессов браузера живёт в системе и нет ли среди них «старых», переживших своего родителя:

bash
# Сколько процессов chrome/chromium висит прямо сейчас
pgrep -c -f 'chrome|chromium'

# Найти процессы старше 30 минут — кандидаты на убийство
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}'

# Жёсткая зачистка зомби-браузеров старше получаса
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}' | xargs -r kill -9

Этот watchdog стоит ставить отдельным cron-заданием раз в несколько минут. Он не лечит причину, но не даёт серверу лечь от накопившихся окон, пока вы ищете утечку. Параллельно полезно завести алерт: «процессов браузера больше X» или «есть процесс браузера старше Y минут» — это первый сигнал, что где-то теряется quit().

4. Запуск в одноразовом контейнере. Радикальный, но чистый вариант: каждый парсинг-таск стартует в свежем Docker-контейнере с браузером, который гарантированно уничтожается после задачи. Зомби физически негде накапливаться.

Управление вкладками

Открытие нового браузера на каждую страницу — дорого. Часто выгоднее держать один браузер и работать вкладками (в терминах CDP — targets, в терминах WebDriver — windows). Но здесь свои подводные камни.

Главное правило: вкладки надо считать и закрывать так же дисциплинированно, как и сами браузеры. Открытая и забытая вкладка — это та же утечка, только внутри живого процесса. Сайт может сам открывать новые вкладки (target="_blank", popup, реклама), и если их не отслеживать, они тихо копятся.

php
// chrome-php: явный контроль вкладок
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$data = $page->getHtml();
$page->close();   // закрыли вкладку сразу, не дожидаясь конца пачки
php
// php-webdriver: следим за окнами и закрываем лишние
$handles = $driver->getWindowHandles();
foreach ($handles as $handle) {
    if ($handle !== $mainWindow) {
        $driver->switchTo()->window($handle)->close();
    }
}
$driver->switchTo()->window($mainWindow);

Полезные практики:

  • ограничивать число одновременно открытых вкладок (например, не больше 5–10 на браузер);
  • после обработки страницы сразу закрывать вкладку, а не накапливать «на потом»;
  • периодически сверять реальное число вкладок с ожидаемым — расхождение сигнализирует, что сайт открыл что-то лишнее или код где-то не закрывает;
  • при переиспользовании вкладки чистить состояние (cookies, localStorage), иначе сессии перетекают между задачами.

PHP как оркестратор: когда он на самом деле запускает Python или Node

Отдельно стоит честный архитектурный сценарий, который встречается чаще, чем принято признавать. PHP в проекте играет роль оркестратора: он принимает задачи, ставит их в очередь, управляет логикой бизнес-процесса, пишет результаты в БД. Но сам браузерный парсинг он не делает — потому что экосистема браузерной автоматизации сильно богаче в Node.js (Puppeteer, Playwright) и Python (Playwright, Selenium, undetected-chromedriver, антидетект-обвязки).

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

php
use Symfony\Component\Process\Process;

$process = new Process([
    'python3',
    '/app/scrapers/playwright_scraper.py',
    '--url', $url,
    '--timeout', '30',
]);
$process->setTimeout(60); // таймаут на весь процесс — обязательно

try {
    $process->mustRun();
    $payload = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
    // важно: если PHP убил процесс по таймауту, дочерний браузер
    // тоже надо добить — иначе снова зомби
    $process->stop(5, SIGKILL);
    throw $e;
}

Почему так делают:

  • Зрелость инструментов. Playwright и Puppeteer заметно опережают PHP-аналоги по возможностям перехвата сети, эмуляции, антидетекта и стабильности.
  • Изоляция. Браузер живёт в дочернем процессе. Упал он или потёк — это не роняет и не раздувает PHP-воркер. PHP остаётся лёгким и стабильным.
  • Масштабирование по ролям. PHP отвечает за бизнес-логику, очереди и хранилище — то, что он умеет хорошо. Браузер — за рендеринг.

Главный нюанс этой схемы — граница ответственности за процессы. Когда PHP убивает дочерний процесс по таймауту, важно убедиться, что вместе с ним умер и запущенный им браузер. Иначе зомби-Chrome переезжают на уровень глубже и их ещё труднее заметить: PHP-то чист, а память течёт «непонятно откуда». Поэтому дочерний скрипт должен сам корректно закрывать браузер в своём finally/atexit, а PHP — слать SIGKILL всей группе процессов, а не одному PID. Запуск дочернего процесса в отдельной process group и убийство всей группы — надёжный способ не оставить хвостов.

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

VNC для первичной отладки

Headless-режим удобен в проде, но мучителен в отладке: вы не видите, что происходит на странице. Парсер «не находит элемент», а почему — непонятно: то ли появился баннер cookie, то ли капча, то ли вёрстка другая для бота, то ли просто не дождались загрузки. Скриншоты помогают, но это разрозненные кадры, а не живая картина.

Решение для первичной отладки — поднять браузер не в headless, а в обычном режиме внутри виртуального дисплея и смотреть на него по VNC. Вы буквально видите экран браузера в реальном времени: как он кликает, что подгружается, где застревает.

Схема в Docker простая:

  • внутри контейнера запускается виртуальный X-сервер (Xvfb) — браузеру нужно «куда-то» рисовать;
  • поверх него — VNC-сервер (x11vnc), который транслирует этот дисплей наружу;
  • браузер стартует без флага --headless, чтобы реально рисовать на экран;
  • вы подключаетесь любым VNC-клиентом (или через noVNC прямо в браузере) и наблюдаете.
dockerfile
# Фрагмент: набор для отладки по VNC
RUN apt-get update && apt-get install -y \
    chromium xvfb x11vnc fluxbox

ENV DISPLAY=:99

# запуск (упрощённо, в реальности — через supervisor/entrypoint):
# Xvfb :99 -screen 0 1920x1080x24 &
# fluxbox &                       # лёгкий оконный менеджер
# x11vnc -display :99 -forever -nopw &   # VNC наружу, порт 5900
# затем PHP/Python запускает Chrome БЕЗ --headless

Готовые образы вроде selenium/standalone-chrome-debug уже содержат VNC «из коробки» — для связки PHP + Selenium это самый быстрый старт: подключился к порту 5900, ввёл задачу из PHP и смотришь, что творит браузер.

Когда VNC особенно выручает:

  • ловите плавающую капчу или антибот, который срабатывает не всегда;
  • селектор «иногда не находится» — глазами видно, что мешает (модалка, попап, другая локаль);
  • разбираетесь с многошаговым сценарием (логин, корзина, оплата), где важна последовательность;
  • проверяете, как сайт реагирует именно на ваш браузер с вашими флагами и fingerprint.

Важно: VNC — инструмент именно первичной отладки и разработки сценария. В проде его держать не нужно: открытый порт — это поверхность атаки, а headed-браузер тратит лишние ресурсы. Отладили сценарий вживую, убедились, что всё работает — переключайтесь обратно в headless.

Итоговый чек-лист

Если вы строите браузерный парсер на PHP, держите в голове минимум:

  • Выбор инструмента. Panther — для простоты и совместимости с Symfony; php-webdriver + Selenium — для масштабируемой инфраструктуры; chrome-php/chrome — для тонкого контроля через CDP. Для серьёзной браузерной работы — не стесняйтесь делегировать в Playwright/Puppeteer через дочерний процесс.
  • Жизненный цикл. Всегда закрывайте браузер и вкладки в finally; вешайте register_shutdown_function; перезапускайте воркеры и сами браузеры по счётчику.
  • Память. Логируйте потребление воркера и RSS браузеров, ищите линейный рост, лечите перезапуском.
  • Зомби-окна. Обязательно мониторьте число и возраст процессов браузера, ставьте watchdog, который добивает осиротевшие процессы, и алерты на их накопление.
  • Вкладки. Считайте и закрывайте их так же строго, как браузеры; следите за тем, что сайт не наоткрывал лишних.
  • Оркестрация. Если PHP запускает Python/Node — убивайте всю группу процессов, а не один PID, и пусть дочерний скрипт сам закрывает свой браузер.
  • Отладка. Поднимайте VNC + Xvfb для живого просмотра браузера на этапе разработки; в прод его не тащите.

Браузерный парсинг на PHP — это не про «найти волшебную библиотеку», а про дисциплину управления ресурсами. Сам парсинг данных из готового DOM — простая часть. Сложное — сделать так, чтобы через неделю непрерывной работы сервер не оказался забит зомби-вкладками и распухшими воркерами.