Парсинг на PHP: полное руководство от простого к сложному

Полное руководство по парсингу на PHP: cURL, DOMDocument, Simple HTML DOM, Guzzle и организация регулярного сбора на хостинге.

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

Обзорная статья о том, как писать парсеры (веб-скрейперы) на чистом PHP: от простого скачивания страницы до многопоточности, прокси, TOR и очередей. Все примеры — рабочие, их можно копировать и адаптировать под свою задачу.

Оглавление

  1. Что такое парсинг и когда он нужен
  2. Этика и юридическая сторона (robots.txt, нагрузка)
  3. Как забираем страницу - file_get_contents - cURL - Guzzle
  4. Библиотеки для парсинга содержимого - Почему не регулярки - DOMDocument + DOMXPath - Symfony DomCrawler - Simple HTML DOM / phpQuery - Когда лучше парсить JSON / скрытый API
  5. Решение проблем с парсингом кириллицы
  6. multi_curl и многопоточность
  7. Использование прокси
  8. Парсинг через TOR
  9. Работа с HTTPS / SSL
  10. Работа с cookie
  11. Статус ответа и другие заголовки
  12. Маскировка под браузер, задержки, повторы (дополнение)
  13. JavaScript-страницы и headless-браузеры (дополнение)
  14. Хранение URL и очереди (поверхностно)
  15. Основные плюсы и минусы реализации на PHP
  16. Заключение

1. Что такое парсинг и когда он нужен

Парсинг (web scraping) — это автоматическое получение страниц сайта и извлечение из них структурированных данных: цен, описаний, контактов, новостей. Процесс почти всегда состоит из двух шагов:

  1. Скачать HTML-страницу (HTTP-запрос).
  2. Разобрать её и достать нужные куски (HTML/DOM-парсинг).

Эти два шага лучше держать раздельно: «качалку» и «разборщик». Тогда вы сможете менять способ загрузки (cURL → прокси → TOR) независимо от логики извлечения.

Перед тем как писать парсер, всегда проверьте: нет ли у сайта открытого API или JSON-эндпоинта. Парсить готовый JSON в десятки раз проще и надёжнее, чем вытаскивать данные из вёрстки, которая меняется каждую неделю.


2. Этика и юридическая сторона

Прежде чем нагружать чужой сервер, держите в голове несколько вещей:

  • robots.txt — файл, где сайт указывает, что можно индексировать. Юридически он не запрещает доступ, но это знак вежливости и иногда — часть пользовательского соглашения.
  • Нагрузка. Не отправляйте сотни запросов в секунду — это похоже на DDoS. Ставьте паузы между запросами (см. раздел 12).
  • Авторские права и персональные данные. Сбор и переопубликование контента может нарушать закон. Особенно осторожно — с персональными данными.
  • Условия использования (ToS). Многие сайты прямо запрещают автоматический сбор. Это не уголовка, но может закончиться блокировкой и претензиями.

Простой чтец robots.txt:

php
function isAllowed(string $url, string $userAgent = '*'): bool
{
    $parts = parse_url($url);
    $robotsUrl = $parts['scheme'] . '://' . $parts['host'] . '/robots.txt';
    $robots = @file_get_contents($robotsUrl);
    if ($robots === false) {
        return true; // robots.txt нет — формально не запрещено
    }
    // Упрощённая проверка: ищем Disallow для нашего пути.
    $path = $parts['path'] ?? '/';
    foreach (preg_split('/\R/', $robots) as $line) {
        if (preg_match('/^\s*Disallow:\s*(\S+)/i', $line, $m)) {
            if ($m[1] !== '' && str_starts_with($path, $m[1])) {
                return false;
            }
        }
    }
    return true;
}

Для серьёзных проектов берите готовый парсер robots.txt (например spatie/robots-txt), а не самописный.


3. Как забираем страницу

3.1. file_get_contents — самый простой способ

php
$html = file_get_contents('https://example.com');

Работает, если в php.ini включён allow_url_fopen. Можно передать контекст с заголовками:

php
$context = stream_context_create([
    'http' => [
        'method'  => 'GET',
        'header'  => "User-Agent: Mozilla/5.0\r\n",
        'timeout' => 10,
    ],
]);
$html = file_get_contents('https://example.com', false, $context);

Минусы: нет нормальной работы с cookie, прокси, нет кодов ответа «из коробки», плохо контролируются ошибки. Годится для разовых скриптов, не для боевого парсера.

3.2. cURL — рабочая лошадка

cURL — расширение, которое есть почти везде и даёт полный контроль над запросом. Это основной инструмент для парсинга на PHP.

php
function fetch(string $url): string
{
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,   // вернуть результат строкой, а не печатать
        CURLOPT_FOLLOWLOCATION => true,   // следовать за редиректами
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 30,     // общий таймаут
        CURLOPT_CONNECTTIMEOUT => 10,     // таймаут на установку соединения
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                                . 'AppleWebKit/537.36 (KHTML, like Gecko) '
                                . 'Chrome/124.0 Safari/537.36',
        CURLOPT_ENCODING       => '',     // принимать gzip/deflate и распаковывать
    ]);

    $html = curl_exec($ch);

    if ($html === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL error: $error");
    }

    curl_close($ch);
    return $html;
}

Ключевые опции:

Опция Зачем
CURLOPT_RETURNTRANSFER вернуть ответ как строку
CURLOPT_FOLLOWLOCATION идти за 301/302 редиректами
CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT не висеть вечно
CURLOPT_ENCODING => '' автоматически распаковать gzip
CURLOPT_HTTPHEADER произвольные заголовки (массив строк)
CURLOPT_POSTFIELDS тело POST-запроса

3.3. Guzzle — современный HTTP-клиент

Если проект на Composer, удобнее использовать Guzzle. Это обёртка над cURL с человеческим API, поддержкой асинхронности, middleware, cookie-jar и т.д.

php
use GuzzleHttp\Client;

$client = new Client([
    'timeout' => 30,
    'headers' => ['User-Agent' => 'Mozilla/5.0 ...'],
]);

$response = $client->get('https://example.com');
$html = (string) $response->getBody();
$status = $response->getStatusCode();

В статье дальше примеры на «голом» cURL — чтобы видеть механику, — но в реальном проекте Guzzle часто экономит время.


4. Библиотеки для парсинга содержимого

4.1. Почему не регулярки

Соблазн распарсить HTML регулярным выражением велик, но HTML — не регулярный язык. Любая вложенность, незакрытый тег или перенос строки ломают регулярку. Регэкспы уместны только для очень простых, плоских кусков (например, выдрать число из строки), но не для разбора дерева документа.

4.2. DOMDocument + DOMXPath (встроено в PHP)

Самый надёжный встроенный способ. Загружаем HTML в DOM и ходим по нему XPath'ом.

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);           // глушим ворнинги «битого» HTML
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Все заголовки h2 внутри блока с классом article
$nodes = $xpath->query('//div[@class="article"]//h2');
foreach ($nodes as $node) {
    echo trim($node->textContent), PHP_EOL;
}

// Получить href из ссылок
$links = $xpath->query('//a/@href');
foreach ($links as $link) {
    echo $link->value, PHP_EOL;
}

Полезные XPath-выражения:

XPath Что выбирает
//a все ссылки
//div[@id="main"] div c id="main"
//div[contains(@class,"item")] div, у которого в классе есть item
//table//tr/td[2] вторая ячейка каждой строки таблицы
//meta[@property="og:title"]/@content значение атрибута content

4.3. Symfony DomCrawler (через Composer) — рекомендую

Удобная обёртка над DOM с поддержкой и CSS-селекторов, и XPath.

bash
composer require symfony/dom-crawler symfony/css-selector
php
use Symfony\Component\DomCrawler\Crawler;

$crawler = new Crawler($html);

// CSS-селекторы (нужен css-selector)
$crawler->filter('div.article h2')->each(function (Crawler $node) {
    echo $node->text(), PHP_EOL;
});

// Атрибуты
$title = $crawler->filter('meta[property="og:title"]')->attr('content');

// XPath тоже доступен
$crawler->filterXPath('//a')->each(fn(Crawler $a) => print($a->attr('href') . "\n"));

4.4. Simple HTML DOM и phpQuery

  • Simple HTML DOM (simple_html_dom) — старая, очень простая библиотека с jQuery-подобным синтаксисом. Удобна, но прожорлива по памяти и давно почти не развивается. Для небольших задач сойдёт.
  • phpQuery — порт jQuery на PHP. Тоже устаревшая, но привычный синтаксис pq('div.item')->find('a').

Для новых проектов лучше DomCrawler или DOMXPath — они быстрее и поддерживаются.

4.5. Скрытый JSON / API — самый чистый путь

Откройте DevTools → вкладку Network. Часто данные подгружаются отдельным XHR-запросом, возвращающим готовый JSON. Распарсить его — одна строка:

php
$data = json_decode($jsonString, true);

Это надёжнее любого HTML-парсинга: структура JSON меняется реже, чем вёрстка.


5. Решение проблем с парсингом кириллицы

Самая частая боль: на выходе «кракозябры» вместо русского текста. Причины — несовпадение кодировок (сайт в windows-1251, а вы ждёте UTF-8) и то, что DOMDocument по умолчанию считает вход UTF-8.

5.1. Определяем кодировку страницы

Кодировку сайт сообщает в HTTP-заголовке Content-Type или в <meta charset>.

php
function detectCharset(string $html, ?string $contentTypeHeader = null): string
{
    if ($contentTypeHeader && preg_match('/charset=([\w-]+)/i', $contentTypeHeader, $m)) {
        return strtoupper($m[1]);
    }
    if (preg_match('/<meta[^>]+charset=["\']?([\w-]+)/i', $html, $m)) {
        return strtoupper($m[1]);
    }
    // Эвристика как запасной вариант
    return mb_detect_encoding($html, ['UTF-8', 'Windows-1251', 'KOI8-R'], true) ?: 'UTF-8';
}

5.2. Приводим к UTF-8

php
$charset = detectCharset($html, $contentType);
if ($charset !== 'UTF-8') {
    $html = mb_convert_encoding($html, 'UTF-8', $charset);
    // и подменим объявление в meta, чтобы DOM не запутался
    $html = preg_replace('/charset=[\w-]+/i', 'charset=UTF-8', $html, 1);
}

5.3. Главный лайфхак для DOMDocument

DOMDocument::loadHTML угадывает кодировку по содержимому и часто ошибается. Самый надёжный приём — добавить «подсказку» перед загрузкой:

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
// заставляем парсер считать вход UTF-8
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

или вариант с флагом (PHP 8.1+ ничего лишнего не добавляет):

php
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

Правило: сначала переводим весь HTML в UTF-8 на этапе скачивания, и только потом отдаём в парсер. Тогда кириллица не ломается.


6. multi_curl и многопоточность

PHP по своей природе однопоточный, но cURL умеет вести несколько запросов параллельно через curl_multi_*. Это даёт огромный прирост скорости: пока один сервер «думает», качаются другие.

php
function fetchMany(array $urls, int $concurrency = 10): array
{
    $multi = curl_multi_init();
    $handles = [];
    $results = [];
    $queue = array_values($urls);
    $active = [];

    // функция добавления одного запроса
    $addHandle = function (string $url) use ($multi, &$active) {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_ENCODING       => '',
        ]);
        curl_multi_add_handle($multi, $ch);
        $active[(int) $ch] = $url;
        return $ch;
    };

    // первая порция
    for ($i = 0; $i < $concurrency && $queue; $i++) {
        $addHandle(array_shift($queue));
    }

    do {
        curl_multi_exec($multi, $running);
        curl_multi_select($multi);   // ждём событий, не крутим CPU вхолостую

        // забираем завершённые
        while ($done = curl_multi_info_read($multi)) {
            $ch = $done['handle'];
            $url = $active[(int) $ch];
            $results[$url] = curl_multi_getcontent($ch);

            curl_multi_remove_handle($multi, $ch);
            curl_close($ch);
            unset($active[(int) $ch]);

            // подкидываем следующий из очереди
            if ($queue) {
                $addHandle(array_shift($queue));
            }
        }
    } while ($running || $queue || $active);

    curl_multi_close($multi);
    return $results;
}

$pages = fetchMany([
    'https://example.com/1',
    'https://example.com/2',
    'https://example.com/3',
], concurrency: 5);

Ключевая идея — скользящее окно: держим в работе не больше concurrency запросов одновременно, по мере завершения добавляем новые. Так мы и быстрые, и не открываем тысячу соединений разом.

Альтернативы для «настоящей» параллельности:

  • Guzzle Pool / Promises — асинхронные запросы с ограничением concurrency, более высокоуровнево, чем curl_multi.
  • ReactPHP / Amp / Swoole — асинхронные/корутинные рантаймы, если нужен большой масштаб.
  • pcntl_fork / параллельные воркеры — несколько процессов, каждый берёт свою пачку URL из очереди (см. раздел 14).

7. Использование прокси

Прокси нужны, чтобы:

  • обходить блокировки по IP (сайт банит за частые запросы),
  • собирать данные с разных регионов,
  • распределять нагрузку между адресами.
php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '123.45.67.89:8080',
    CURLOPT_PROXYTYPE => CURLPROXY_HTTP,     // или CURLPROXY_SOCKS5
]);

// прокси с авторизацией
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'login:password');

Типы прокси:

Тип Константа Заметки
HTTP CURLPROXY_HTTP самый частый
HTTPS CURLPROXY_HTTPS прокси по TLS
SOCKS5 CURLPROXY_SOCKS5 TOR работает по SOCKS5
SOCKS5 + DNS на прокси CURLPROXY_SOCKS5_HOSTNAME резолвинг доменов на стороне прокси

Ротация прокси. Держите пул адресов и выдавайте по кругу; «битые» помечайте и временно исключайте.

php
class ProxyPool
{
    private array $proxies;
    private int $i = 0;

    public function __construct(array $proxies)
    {
        $this->proxies = array_values($proxies);
    }

    public function next(): string
    {
        $proxy = $this->proxies[$this->i % count($this->proxies)];
        $this->i++;
        return $proxy;
    }
}

Различают datacenter-прокси (дешёвые, легко детектятся) и residential/mobile (дороже, но выглядят как реальные пользователи). Выбор зависит от того, насколько агрессивно сайт защищается.


8. Парсинг через TOR

TOR — это бесплатная сеть, дающая анонимный SOCKS5-прокси на 127.0.0.1:9050. Удобно для бесплатной ротации IP, но медленно и многие сайты блокируют выходные узлы TOR.

Подключение

php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '127.0.0.1:9050',
    CURLOPT_PROXYTYPE => CURLPROXY_SOCKS5_HOSTNAME, // DNS через TOR — важно для анонимности
]);

Смена IP (новая цепочка)

У TOR есть управляющий порт (9051), через который можно запросить новую цепочку сигналом NEWNYM. Сначала включите его в torrc:

code
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:...   # сгенерировать: tor --hash-password "ваш_пароль"

Затем из PHP:

php
function torNewIdentity(string $password, string $host = '127.0.0.1', int $port = 9051): bool
{
    $fp = @fsockopen($host, $port, $errno, $errstr, 10);
    if (!$fp) {
        return false;
    }
    fwrite($fp, "AUTHENTICATE \"$password\"\r\n");
    $auth = fgets($fp);                 // ждём 250 OK
    fwrite($fp, "SIGNAL NEWNYM\r\n");
    $signal = fgets($fp);               // 250 OK
    fclose($fp);

    sleep(5); // TOR строит новую цепочку не мгновенно
    return str_starts_with($auth, '250') && str_starts_with($signal, '250');
}

Типичный цикл: сделать N запросов → torNewIdentity() → продолжить с новым IP.

Минусы TOR: низкая скорость, часть сайтов сразу отдаёт CAPTCHA или 403, небольшое число выходных узлов. Для серьёзного объёма лучше платные прокси.


9. Работа с HTTPS / SSL

По умолчанию cURL проверяет SSL-сертификат — и это правильно. Проблемы возникают, когда на сервере устаревший набор корневых сертификатов (CA bundle).

php
curl_setopt_array($ch, [
    CURLOPT_SSL_VERIFYPEER => true,   // проверять сертификат (НЕ отключайте без причины)
    CURLOPT_SSL_VERIFYHOST => 2,      // проверять, что хост совпадает с сертификатом
    CURLOPT_CAINFO         => '/path/to/cacert.pem', // свежий CA bundle
]);

Свежий cacert.pem берут с curl.se/docs/caextract.html и прописывают в php.ini:

ini
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"

Не делайте так в продакшене:

php
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // отключает защиту от MITM

Это убирает проверку сертификата. Допустимо лишь временно, для локальной отладки. Правильное решение «ошибки сертификата» — обновить CA bundle, а не отключать проверку.

Можно также форсировать версию TLS:

php
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);

Cookie нужны для сессий, авторизации и обхода «проверок», которые ставят куку и ждут её на следующем запросе. cURL умеет хранить и переотправлять их автоматически через cookie jar — файл.

php
$cookieFile = __DIR__ . '/cookies.txt';

curl_setopt_array($ch, [
    CURLOPT_COOKIEJAR  => $cookieFile,  // куда СОХРАНЯТЬ полученные cookie
    CURLOPT_COOKIEFILE => $cookieFile,  // откуда ЧИТАТЬ при запросе
]);

Если оба запроса (логин и последующий) используют один и тот же $cookieFile, сессия сохраняется между ними.

Пример авторизации

php
$cookieFile = tempnam(sys_get_temp_dir(), 'ck');

// 1) POST с логином/паролем — сервер вернёт session-cookie
$ch = curl_init('https://example.com/login');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['user' => 'me', 'pass' => 'secret']),
    CURLOPT_COOKIEJAR      => $cookieFile,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
curl_close($ch);

// 2) запрос к защищённой странице — cookie подставится автоматически
$ch = curl_init('https://example.com/account');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_COOKIEJAR      => $cookieFile,
]);
$account = curl_exec($ch);
curl_close($ch);

Передать cookie вручную (без файла) можно так:

php
curl_setopt($ch, CURLOPT_COOKIE, 'sessionid=abc123; lang=ru');

В многопоточном парсинге давайте каждому «воркеру»/прокси свой cookie-файл, иначе сессии перемешаются.


11. Статус ответа и другие заголовки

Парсер обязан реагировать на код ответа: 200 — ок, 404 — страницы нет, 403/429 — забанили или просят сбавить темп, 5xx — ошибка сервера.

Код ответа

php
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($httpCode === 200) {
    // обрабатываем
} elseif ($httpCode === 429) {
    // слишком часто — ждём и повторяем
} elseif ($httpCode >= 500) {
    // ошибка сервера — повтор позже
}

Полезная инфа из curl_getinfo

php
$info = curl_getinfo($ch);
// $info['http_code']       — код ответа
// $info['content_type']    — Content-Type (тут же charset!)
// $info['redirect_url']    — куда редиректило
// $info['total_time']      — сколько заняло
// $info['primary_ip']      — IP сервера (полезно при проверке прокси)
// $info['size_download']   — размер ответа

Получить заголовки ответа отдельно

php
curl_setopt($ch, CURLOPT_HEADER, true); // включить заголовки в вывод
$response = curl_exec($ch);

$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$body       = substr($response, $headerSize);

Аккуратнее — через callback, который собирает заголовки в массив:

php
$headers = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$headers) {
    $parts = explode(':', $line, 2);
    if (count($parts) === 2) {
        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
    }
    return strlen($line); // обязательно вернуть длину
});
curl_exec($ch);
// теперь $headers['content-type'], $headers['set-cookie'] и т.д.

Особенно важны: Content-Type (кодировка), Set-Cookie, Location (редирект), Retry-After (сколько ждать при 429), Content-Length.


12. Маскировка под браузер, задержки, повторы

Чтобы парсер не банили на втором запросе, он должен вести себя «по-человечески».

Реалистичные заголовки

php
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
        . '(KHTML, like Gecko) Chrome/124.0 Safari/537.36',
    'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language: ru-RU,ru;q=0.9,en;q=0.8',
    'Referer: https://example.com/',
    'Connection: keep-alive',
]);

Задержки между запросами

php
usleep(random_int(800_000, 2_500_000)); // случайная пауза 0.8–2.5 сек

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

Повторы с экспоненциальной задержкой (retry/backoff)

php
function fetchWithRetry(string $url, int $maxAttempts = 3): ?string
{
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_FOLLOWLOCATION => true,
        ]);
        $html = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($html !== false && $code === 200) {
            return $html;
        }
        if ($code === 404) {
            return null; // нет смысла повторять
        }
        sleep(2 ** $attempt); // 2, 4, 8 секунд...
    }
    return null;
}

Что ещё помогает не нарваться на бан

  • Ротация User-Agent и прокси.
  • Сохранение cookie между запросами (как настоящий браузер).
  • Уважение Retry-After при 429.
  • Параллелить умеренно (не сотни потоков на один домен).

13. JavaScript-страницы и headless-браузеры

cURL получает исходный HTML, но не выполняет JavaScript. Если контент дорисовывается на клиенте (SPA на React/Vue), в HTML его не будет — увидите пустые блоки.

Варианты:

  1. Найти скрытый API (раздел 4.5) — почти всегда лучший выход: SPA берёт данные из JSON-эндпоинта, который можно дёрнуть напрямую.
  2. Headless-браузер — запустить настоящий движок, который выполнит JS: - Symfony Panther — PHP-обёртка над ChromeDriver/Selenium. - php-webdriver + Selenium/Chrome. - Связка с Puppeteer/Playwright (Node.js) — иногда проще вынести рендеринг в отдельный микросервис.
php
// Пример на Symfony Panther
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient();
$crawler = $client->request('GET', 'https://spa-example.com');
$client->waitFor('.product');         // ждём, пока JS отрисует
$titles = $crawler->filter('.product .title')->each(fn($n) => $n->text());

Headless-браузеры тяжёлые и медленные — используйте их только когда без JS никак.


14. Хранение URL и очереди

Когда парсер обходит сотни тысяч страниц, нужна очередь URL и учёт того, что уже обработано. Поверхностно — основные подходы:

Что хранить

  • очередь URL «на обработку» (frontier);
  • множество уже посещённых URL (чтобы не ходить дважды) — для дедупликации удобно хранить хэш URL;
  • статус каждого URL: ожидает / в работе / готово / ошибка / число попыток;
  • сами результаты (распарсенные данные).

Простой вариант — БД

sql
CREATE TABLE crawl_queue (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    url         VARCHAR(2048) NOT NULL,
    url_hash    CHAR(40) NOT NULL,           -- sha1(url), для уникальности
    status      ENUM('pending','processing','done','failed') DEFAULT 'pending',
    attempts    INT DEFAULT 0,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uniq_hash (url_hash),
    KEY idx_status (status)
);

Воркер «забирает» задачу атомарно, чтобы два процесса не взяли один URL:

php
$pdo->beginTransaction();
$row = $pdo->query(
    "SELECT id, url FROM crawl_queue
     WHERE status='pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED"
)->fetch();

if ($row) {
    $pdo->prepare("UPDATE crawl_queue SET status='processing', attempts=attempts+1 WHERE id=?")
        ->execute([$row['id']]);
}
$pdo->commit();

FOR UPDATE SKIP LOCKED (MySQL 8+/PostgreSQL) — ключ к безопасной раздаче задач нескольким воркерам.

Когда нужно масштабнее

  • Redis (списки LPUSH/BRPOP, множества SADD для дедупликации) — очень быстрая очередь, популярный выбор.
  • RabbitMQ / Kafka / Beanstalkd — полноценные брокеры сообщений, если воркеров много и нужна надёжная доставка.
  • Bloom-фильтр — компактная проверка «видели ли мы этот URL» на миллиардах адресов без хранения всех строк.

Архитектурный принцип

Разделяйте роли: producer находит новые ссылки и кладёт в очередь, workers разбирают очередь параллельно и пишут результат. Так система легко горизонтально масштабируется — просто добавляете воркеров.


15. Основные плюсы и минусы реализации на PHP

Плюсы

  • Низкий порог входа — cURL и DOM встроены, окружение есть почти везде.
  • Отличный cURL — гибкая работа с прокси, cookie, SSL, заголовками.
  • Зрелые библиотеки — Guzzle, Symfony DomCrawler/Panther, готовые очереди.
  • Удобно встроить в существующий веб-проект на PHP (CMS, админка) — парсер пишет прямо в ту же БД.
  • Дёшево разворачивать — хостинг под PHP массовый и недорогой.

Минусы

  • Нет настоящей многопоточности из коробки. Параллельность — это curl_multi, несколько процессов, либо асинхронные рантаймы (ReactPHP/Amp/Swoole). Сложнее, чем потоки в Go или async в Python.
  • JS не исполняется — для SPA нужен headless-браузер, что тяжело и медленно.
  • Память. Старые библиотеки (Simple HTML DOM) прожорливы; на больших объёмах важно следить за утечками в долгоживущих воркерах.
  • Скорость. Для экстремального масштаба специализированные стеки (Scrapy на Python, Colly на Go) часто эффективнее и имеют больше готовых инструментов.
  • Хрупкость. Как и любой парсер, ломается при смене вёрстки сайта — это не специфика PHP, но об этом нужно помнить.

Вывод: PHP — отличный выбор для большинства задач парсинга малого и среднего масштаба, особенно когда данные нужно сразу класть в PHP-проект. Для очень больших краулеров и тяжёлого JS-рендеринга стоит присмотреться к специализированным стекам или вынести рендеринг в отдельный сервис.


16. Заключение

Минимальный «боевой» парсер на PHP включает:

  • загрузку через cURL с таймаутами, редиректами и CURLOPT_ENCODING => '';
  • реалистичный User-Agent и заголовки;
  • приведение кодировки к UTF-8 до парсинга (лечение кириллицы);
  • разбор через DOMXPath или Symfony DomCrawler (не регулярками);
  • проверку HTTP-кода и обработку 404/403/429/5xx;
  • паузы между запросами и retry с backoff;
  • cookie-jar, если нужна авторизация/сессии;
  • прокси/ротацию IP, если есть блокировки (или TOR для бесплатного варианта);
  • curl_multi/воркеры для скорости на больших объёмах;
  • очередь URL с дедупликацией для серьёзного краулинга.

Главные правила: отделяйте скачивание от разбора, всегда чините кодировку до парсинга, уважайте чужой сервер (паузы, robots.txt, не DDoS) и сначала ищите готовый JSON/API, прежде чем мучиться с вёрсткой.