Play
online · 30 мин
Туториалы 24 мин чтения 28.09.2026

Парсинг сайтов на PHP: сбор данных, обход антибота и стабильность под нагрузкой

Парсинг сайтов на PHP — это не про модный инструмент, а про реальные задачи e-commerce: сверка цен, наполнение каталога, сбор фидов. Разбираем, как собирать данные, обходить антибот-защиту и не уронить сервер под нагрузкой.

Что такое парсинг сайтов и где он реально нужен в e-commerce

Парсинг сайтов — это автоматизированный сбор данных со страниц: скрипт запрашивает HTML или JSON, разбирает разметку и складывает результат в структуру — таблицу, каталог, файл. В e-commerce к нам с этой задачей приходят постоянно, и почти всегда за ней стоит конкретная боль, а не «хотим модный инструмент». Магазин с каталогом 30 тысяч SKU не может вручную сверять цены у пяти конкурентов каждый день. Поставщик отдаёт прайс в PDF, а нужен нормальный фид для маркетплейса. После миграции с чужой площадки каталог пустой, а карточки нужно чем-то наполнять.

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

В наших проектах парсинг закрывает четыре типовые зоны:

  • Мониторинг цен — регулярный обход карточек конкурентов и обновление своей ценовой матрицы;
  • Наполнение каталога — сбор характеристик и описаний, когда каталог стартует с нуля или после переезда;
  • Обогащение существующих товаров — дописывание полей, которых нет в учётной системе: габариты, состав, совместимость;
  • Подготовка B2B-фидов — приведение разрозненных данных к формату маркетплейса или дистрибьюторской площадки.

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

Пять типовых запросов, которые мы видим чаще всего:

  • Мониторинг цен конкурентов — ежедневный или ежечасный обход карточек, сравнение с собственной ценой, отчёт для категорийного менеджера;
  • Сбор карточек для каталога — когда магазин только запускается и весь контент нужно откуда-то взять легально;
  • Обогащение существующих товаров — добавление полей, которых нет в 1С: характеристики, фото, SEO-описания;
  • Сбор отзывов для аналитики — выгрузка публичных отзывов с площадок, чтобы видеть слабые места товара и конкурента;
  • Подготовка данных для B2B-фида — сведение прайсов поставщиков в один файл под требования маркетплейса.

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

В проектах с каталогом 30k SKU мы сначала ищем официальный фид и только потом пишем парсер. Порядок такой: спросить у поставщика выгрузку в CSV или XML, проверить, нет ли публичного API, и лишь если ничего нет — идти в HTML. Причина простая: поддерживать парсер дороже, чем договориться о фиде. Скрипт нужно мониторить, чинить при смене вёрстки, обходить блокировки — а фид просто приходит по расписанию.

Когда API нет и не будет — парсинг оправдан. Это нормальная практика для мониторинга цен, сбора публичных характеристик и наполнения каталога. Главное — держать в голове правовую рамку.

Парсер на PHP: с чего начать и какие инструменты выбрать

PHP 8.2 в Bitrix-контуре — не компромисс, а осознанный выбор. Причина простая: тот же рантайм, тот же деплой, тот же cron. Скрипт парсера живёт рядом с сайтом, его не нужно поднимать в отдельном контейнере, настраивать pip-окружение и объяснять хостингу, почему на прод-сервере появился Python. У нас в проектах с активным импортом это решающий аргумент — интеграцию проще поддерживать, когда весь код на одном стеке.

Встроить парсер в Bitrix тоже без сюрпризов: агент по расписанию, cron-скрипт в /local/cron/ или CLI-команда через bitrix/bin/php. Результат кладём в инфоблок штатными средствами — об этом ниже, а пока про инструменты.

Минимальный набор для старта — нативный cURL и DOMDocument. Первый тянет страницы, второй разбирает HTML и позволяет ходить по нему через XPath. Этого хватает для 80% задач: собрать карточки товаров, вытащить цены, забрать фиды. Опционально подключаем Guzzle — когда запросов много и нужен пул, ретраи и нормальная обработка ошибок. И Symfony Panther — если на странице данные подгружаются JavaScript'ом и без браузера их не видно.

Инструмент Что умеет Когда брать Чего не умеет
Нативный cURL HTTP-запросы, заголовки, таймауты, куки Разовые задачи, простые страницы Пул запросов, ретраи из коробки
Guzzle Пул, middleware, ретраи, PSR-7 Массовый обход, API-интеграции Исполнение JavaScript
Symfony Panther Реальный браузер через WebDriver SPA, данные после JS-рендера Скорость, лёгкость деплоя

Начнём с базы — запрос на cURL и разбор ответа через DOMDocument с XPath:

PHP
<?php
$ch = curl_init('https://example.com/catalog');
curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; MyParser/1.0)',
        CURLOPT_TIMEOUT        => 15,
        CURLOPT_FOLLOWLOCATION => true,
]);
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr  = curl_errno($ch);
curl_close($ch);

if ($curlErr !== 0 || $httpCode !== 200) {
    throw new RuntimeException("Запрос упал: errno={$curlErr}, HTTP={$httpCode}");
}

libxml_use_internal_errors(true);
$dom = new DOMDocument();
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);
$nodes = $xpath->query('//div[@class="product-card"]//span[@class="price"]');
foreach ($nodes as $node) {
    echo trim($node->textContent), PHP_EOL;
}

Ключевое здесь — проверка curl_errno и HTTP-кода до парсинга. Если запрос вернул 503 или оборвался по таймауту, DOMDocument получит пустую строку и молча отдаст ноль узлов. Скрипт «отработает успешно», а данных не будет. Поэтому сначала валидируем ответ, потом разбираем.

Второй момент — DOMDocument::loadHTML не любит битую разметку. На реальных сайтах незакрытые теги, невалидные атрибуты и кривые комментарии встречаются постоянно, и без подготовки парсер сыплет Warning в лог. Лечится двумя строками: libxml_use_internal_errors(true) перед загрузкой и libxml_clear_errors() после. Ошибки разметки не всплывают в вывод, а документ всё равно разбирается.

Когда одиночных запросов становится много, переходим на Guzzle. Первое, что там стоит настроить — connect_timeout => 2. По умолчанию соединение может висеть десятки секунд, если удалённый хост тормозит, и один зависший запрос блокирует весь цикл. Две секунды на коннект — разумный порог: сервер не ответил, идём дальше. Таймаут на сам ответ ставим отдельно и щедрее.

Переход на Guzzle оправдан, когда нужно параллельно обойти сотни URL или добавить middleware для логирования и ретраев. Для десятка страниц нативный cURL проще и не тянет зависимость.

Как обойти антибот при парсинге: заголовки, таймауты и что реально работает

Антибот-защита на стороне клиента — это не «стена», а набор проверок, которые сервер прогоняет по каждому входящему запросу. Простейший уровень: валидация HTTP-заголовков. User-Agent должен быть непустым и не совпадать с дефолтным curl/8.x, Accept-Language присутствовать, Referer указывать на страницу того же домена. Дальше идут поведенческие проверки: как часто приходят запросы с одного IP, есть ли между ними пауза, повторяются ли они с точностью до миллисекунд. И верхний уровень — JS-челлендж: сервер отдаёт страницу с обфусцированным скриптом, который считает cookie и только потом пускает к контенту.

У нас в практике девяносто процентов «блокировок» снимается корректным поведением клиента, а не какими-то хитрыми трюками. Когда скрипт ходит с осмысленным User-Agent, шлёт Accept-Language: ru-RU,ru;q=0.9, держит паузу между запросами и переиспользует cookie-сессию, защита часто вообще не срабатывает. Проблемы начинаются, когда парсер молотит сотни запросов в секунду с одного IP и одним и тем же «пустым» клиентом. «Обход» — это в первую очередь дисциплина запросов, и только потом технические средства.

Что мы настраиваем в первую очередь, прежде чем трогать что-то ещё:

  • Реалистичный User-Agent — актуальная строка браузера, а не дефолт библиотеки; для больших объёмов — пул из нескольких.
  • Accept-Language и Referer — язык страницы и адрес, с которого «пришёл» клиент; без них запрос выглядит ботом.
  • Пауза между запросами — 300–1000 мс рандомно; ровные интервалы сами по себе сигнал автоматизации.
  • Обработка 429 и 403 с бэкоффом — пришёл лимит, ждём экспоненциально, а не долбим дальше.
  • Cookie-сессия — сохраняем Set-Cookie между запросами и шлём обратно, иначе каждый запрос — «новый» клиент.
PHP
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;

$client = new Client([
        'base_uri'        => 'https://example.com',
        'connect_timeout' => 2,   // не ждём TCP-хендшейк 20+ секунд
        'timeout'         => 10,  // общий лимит на ответ
        'http_errors'     => false, // 4xx/5xx не бросают исключение — разбираем сами
]);

$jar = new CookieJar();

function fetch(Client $client, CookieJar $jar, string $url, int $attempt = 0): ?string
{
    $response = $client->get($url, [
            'cookies' => $jar,
            'headers' => [
                'User-Agent'      => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
                'Accept-Language' => 'ru-RU,ru;q=0.9,en;q=0.8',
                'Referer'         => 'https://example.com/',
                'Accept'          => 'text/html,application/xhtml+xml',
            ],
    ]);

    $code = $response->getStatusCode();

    if ($code === 429 || $code === 403) {
        if ($attempt >= 5) {
            return null;
        }
        // экспоненциальная задержка: 1, 2, 4, 8, 16 сек + джиттер
        $delay = (2 ** $attempt) + random_int(0, 500) / 1000;
        sleep((int) ceil($delay));
        return fetch($client, $jar, $url, $attempt + 1);
    }

    return $code === 200 ? (string) $response->getBody() : null;
}

$html = fetch($client, $jar, '/catalog/phones');

Ключевое здесь — http_errors => false и ручная обработка статусов: Guzzle по умолчанию бросает исключение на 403, и без этой опции рекурсивный бэкофф не напишешь. Таймауты разделены намеренно. connect_timeout в 2 секунды спасает от «зависших» IP, где TCP-хендшейк идёт десятками секунд, а timeout в 10 — от медленных ответов уже после соединения. Под свой проект меняйте потолок попыток и базу экспоненты: для агрессивных сайтов разумнее начинать с 5 секунд и не больше трёх ретраев, для лояльных хватит двух.

Отдельная грабля — отключение проверки SSL. В интернете полно советов ставить CURLOPT_SSL_VERIFYPEER => false и CURLOPT_SSL_VERIFYHOST => 0, чтобы «починить» ошибку сертификата. На проде это открывает дверь для MITM: любой посредник между вами и сайтом может подменить ответ, а парсер этого не заметит. Правильный путь — обновить корневой сертификат cacert.pem, прописать его в CURLOPT_CAINFO или в опции Guzzle verify => '/path/to/cacert.pem'. Если сайт отдаёт самоподписанный сертификат, это повод поговорить с его администратором, а не глушить проверку у себя.

Когда на странице JS-челлендж, обычный HTTP-клиент бессилен: HTML приходит без контента, а данные подгружает скрипт. Тут берём Symfony Panther через WebDriver — он поднимает реальный Chrome или Firefox, исполняет JS, проходит челлендж и отдаёт готовый DOM. Это дороже по ресурсам (нужен запущенный браузер и chromedriver), поэтому мы включаем Panther точечно — только для тех URL, где статический запрос стабильно возвращает пустышку. Для остальных страниц остаётся обычный Guzzle.

Прокси для парсинга: когда они нужны и как не сломать себе отладку

Прокси в парсере решает три разные задачи, и путать их не стоит. Первая — разнести нагрузку по IP-адресам: если вы качаете карточки товаров с одного адреса сотнями запросов в минуту, сайт-источник рано или поздно начнёт отдавать 429 или капчу. Вторая — обойти гео-ограничения: часть магазинов и маркетплейсов отдаёт разный контент в зависимости от страны, и без прокси в нужном регионе вы получите не тот HTML, который видит покупатель. Третья — снизить риск блокировки одного адреса: когда пул из нескольких адресов, отвалившийся IP не останавливает весь сбор, а только снижает пропускную способность.

Но прокси нужны далеко не всегда. Если у вас один источник с адекватным rate limit — например, вы раз в сутки выгружаете прайс с партнёрского API, где лимит прописан в договоре, — прокси только добавят точку отказа. У нас в практике парсеры без прокси живут годами, когда это внутренняя интеграция или собственный фид поставщика. Прокси становятся обязательными, когда источников много, они публичные и защищены антиботом, а объём запросов заметен со стороны. Об этом мы подробно говорили в секции про антибот — там как раз про то, почему один IP быстро перестаёт работать.

PHP
$ch = curl_init('https://api.ipify.org?format=json');

curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_PROXY          => 'http://proxy.example.com:8080',
        CURLOPT_PROXYUSERPWD   => 'user:secret',
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 30,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($response === false) {
    // CURLE_COULDNT_CONNECT (7) или CURLE_OPERATION_TIMEDOUT (28)
    error_log('proxy check failed: ' . curl_error($ch) . ' code=' . curl_errno($ch));
} else {
    $ip = json_decode($response, true)['ip'] ?? 'unknown';
    // IP должен совпадать с адресом прокси, а не с адресом вашего сервера
    error_log('parser request via proxy ip=' . $ip);
}

curl_close($ch);

Ключевое здесь — не сам вызов, а проверка, что запрос действительно идёт через прокси. Мы один раз поймали ситуацию, когда прокси молча игнорировался из-за опечатки в схеме, и парсер месяц ходил с серверного IP — пока источник не начал отдавать 403. Поэтому echo-проверка через внешний сервис вроде api.ipify.org должна быть в первом же тестовом запуске, а не «потом добавим».

Второй момент — таймауты. Прокси добавляет лишний хоп, и CURLOPT_CONNECTTIMEOUT по умолчанию может оказаться слишком оптимистичным: соединение с вашим сервером ставится быстро, а вот до прокси может не успеть. Мы ставим connect-таймаут в 10 секунд, общий CURLOPT_TIMEOUT — по логике задачи. И раздельно обрабатываем CURLE_COULDNT_CONNECT и CURLE_OPERATION_TIMEDOUT: первый чаще означает, что прокси недоступен, второй — что источник не ответил. Смешивать их в одну ошибку нельзя, иначе диагностика превращается в гадание. Поэтому в каждой записи лога парсера мы храним IP прокси, через который шёл запрос — при разборе инцидента это первое, что смотрим.

Публичные бесплатные прокси не используйте для коммерческого парсинга. Они утекают трафик — включая ваши заголовки и токены авторизации, если они попадают в запрос, — и часто подменяют тело ответа, подсовывая чужой HTML или рекламу. Для рабочего парсера берите приватные прокси с договором и понятным SLA: это дешевле, чем разбираться, почему каталог заполнился мусором.

Сбор данных с сайтов: как складывать результат в Bitrix и не терять картинки

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

Вариантов хранения обычно три. Прямо в инфоблок — быстро видно результат, но каждая ошибка парсера сразу попадает в живой каталог: битые цены, пустые описания, дубли. В HL-блок — удобно для справочных данных, но не для товаров со свойствами и торговыми предложениями. В промежуточную таблицу MySQL — и это наш выбор по умолчанию.

Логика простая: парсер пишет сырой результат в staging-таблицу, а отдельный скрипт уже разбирает её и аккуратно переносит в инфоблок. Между двумя шагами можно посмотреть глазами, что пришло, отфильтровать мусор, свести дубли. И только потом трогать каталог.

Если источник отдал 500 товаров с пустой ценой — вы увидите это в таблице, а не в списке товаров на сайте. Дальше разберём два узких места: загрузку картинок и запись в инфоблок без дублей.

Как загрузить картинки с удалённого сервера в /upload/ Битрикса

Первое, с чем сталкиваются все: в поле картинки инфоблока нельзя положить URL. Bitrix хранит файлы физически на диске и в таблице b_file, а не ссылками на чужие серверы. Значит, изображение нужно сначала скачать к себе, и только потом привязать.

На ru.stackoverflow разбирали ровно этот случай: нужно было залить несколько тысяч картинок (от 1 до 5 на товар) с внешнего сервиса в локальную папку /upload/produdct_img/. Рабочее решение там — file_put_contents($NewFullPath, fopen($imagePath, 'r')), где первый аргумент это полный путь к новому файлу, а второй — поток на удалённое изображение. Просто, но требует обвязки: проверка MIME, лимит размера и фиксированная папка назначения.

После того как файл лёг на диск, его превращают в массив для инфоблока через CFile::MakeFileArray() и передают в свойство. Ключевое здесь — не доверять имени и типу файла из ответа источника: расширение берём из белого списка, MIME проверяем по содержимому, а размер ограничиваем. Ниже — рабочий пример с этими проверками.

PHP
<?php
// Скачивание картинки во временную папку и подготовка к привязке
$uploadDir = $_SERVER['DOCUMENT_ROOT'] . '/upload/produdct_img/';
if (!is_dir($uploadDir)) {
    mkdir($uploadDir, 0755, true);
}

$allowedMime = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'];
$maxSize = 5 * 1024 * 1024; // 5 МБ

function downloadImage(string $url, string $uploadDir, array $allowedMime, int $maxSize): ?array
{
    $ctx = stream_context_create([
            'http' => ['timeout' => 15, 'follow_location' => 0],
    ]);
    $stream = @fopen($url, 'r', false, $ctx);
    if (!$stream) {
        return null;
    }

    $tmp = tempnam(sys_get_temp_dir(), 'img_');
    $written = file_put_contents($tmp, $stream, LOCK_EX);
    fclose($stream);

    if ($written === false || $written > $maxSize) {
        @unlink($tmp);
        return null;
    }

    $mime = mime_content_type($tmp);
    if (!isset($allowedMime[$mime])) {
        @unlink($tmp);
        return null;
    }

    $target = $uploadDir . uniqid('imp_', true) . '.' . $allowedMime[$mime];
    if (!rename($tmp, $target)) {
        @unlink($tmp);
        return null;
    }

    // Массив, который принимает свойство типа "Файл"
    return CFile::MakeFileArray($target);
}

$fileArray = downloadImage($imageUrl, $uploadDir, $allowedMime, $maxSize);

Как писать собранные данные в инфоблок через CIBlockElement::Add и не плодить дубли

Вторая грабля — дубли. Если парсер запускается по расписанию, наивный CIBlockElement::Add() на каждом прогоне создаст новый товар, и через неделю в каталоге будет пять копий одной позиции. Спасает XML_ID: в него кладём стабильный идентификатор из источника (артикул, ID карточки на сайте-доноре) и перед добавлением ищем элемент по нему.

Алгоритм такой: ищем по XML_ID через CIBlockElement::GetList(). Нашли — обновляем через CIBlockElement::Update(). Не нашли — добавляем через CIBlockElement::Add(). Свойства в обоих случаях удобнее ставить отдельно — через CIBlockElement::SetPropertyValuesEx(), он не сбрасывает те свойства, которые вы не передали.

Отдельно про XML_ID: он должен быть детерминированным. Не подставляйте туда time() или случайное значение — иначе поиск никогда ничего не найдёт и дубли вернутся. Хороший ключ — артикул или нормализованный URL страницы-источника.

PHP
<?php
// $item — разобранная запись из staging-таблицы
$iblockId = 15; // замените на ваш IBLOCK_ID
$xmlId = (string)$item['external_id'];

$res = CIBlockElement::GetList(
    [],
    ['IBLOCK_ID' => $iblockId, 'XML_ID' => $xmlId],
    false,
    false,
    ['ID']
);

$arFields = [
    'IBLOCK_ID' => $iblockId,
    'NAME'      => $item['name'],
    'XML_ID'    => $xmlId,
    'ACTIVE'    => 'Y',
    'PREVIEW_TEXT' => $item['description'],
];

$el = new CIBlockElement();

if ($row = $res->Fetch()) {
    $elementId = (int)$row['ID'];
    $el->Update($elementId, $arFields);
} else {
    $elementId = (int)$el->Add($arFields);
}

if ($elementId > 0) {
    // Свойства: картинка + цена
    CIBlockElement::SetPropertyValuesEx($elementId, $iblockId, [
            'MORE_PHOTO' => $fileArray,   // массив из CFile::MakeFileArray()
            'PRICE'      => $item['price'],
    ]);
}

Не пишите файлы по произвольному пути из ответа источника. Если имя или путь файла приходит извне и вы сохраняете его «как есть» — рано или поздно туда прилетит shell.php, и вы получите RCE через загрузку PHP-файла. Правила жёсткие: папка назначения фиксированная (/upload/produdct_img/), расширение — только из белого списка (jpg, png, webp), MIME проверяется по содержимому через mime_content_type(), размер ограничен. Имя генерируем сами через uniqid(), а не берём из URL. Никаких ../ и никаких исполняемых расширений в целевом каталоге.

Почему PHP-скрипт парсера съедает память и как это чинить в php-fpm

Картина повторяется: ночью по крону отработал парсер, а к утру в top висят десятки процессов php-fpm в состоянии sleep, RSS у каждого по 80–120 МБ. Суммарно они съели всю оперативку. Сайт тормозит, иногда ловит 502. Первое подозрение — утечка в PHP-коде. Чаще всего утечки нет: процессы просто живут дольше, чем нужно, и держат память.

Логика пула такая: php-fpm держит набор дочерних воркеров, каждый обслуживает запросы по очереди. После ответа воркер не умирает — ждёт следующего запроса. Память, которую PHP занял под парсинг большого HTML, не возвращается операционной системе: сборщик мусора освобождает её внутри процесса, но сам процесс остаётся жирным. Если парсер запускается через веб-эндпоинт, он живёт в том же пуле, что и сайт, — и раздутые воркеры висят рядом с обычными.

По нашему опыту, в 8 из 10 случаев «парсер съел память» лечится не переписыванием кода, а настройками пула. Разберём, что именно держит память.

Что реально держит память: pm.max_requests и request_terminate_timeout

Два параметра в pool.d/*.conf отвечают за то, как долго воркер живёт и сколько памяти успевает накопить. pm.max_requests — сколько запросов воркер обработает, прежде чем php-fpm его принудительно перезапустит. Если значение 0 (по умолчанию), воркер живёт бесконечно и постепенно пухнет от фрагментации памяти. Для пула, где крутится парсер, разумно поставить 200: после этого воркер убивается и стартует чистым.

request_terminate_timeout — жёсткий лимит времени на один запрос. Скрипт завис на медленном внешнем сайте — php-fpm убьёт воркер по таймауту, не дожидаясь, пока PHP сам дойдёт до конца. Без этого зависший парсер держит память часами.

Третий параметр — pm.max_children: максимум одновременных воркеров. Его считают от объёма RAM: на сервере 4 ГБ и один воркер в пике ест ~100 МБ, оставляйте запас и не выставляйте больше 20–25 — иначе пул сам себя выест по памяти.

SHELL
# /etc/php/8.2/fpm/pool.d/www.conf
; Перезапускать воркер после 200 запросов — сбрасывает накопленную память
pm.max_requests = 200

; Убивать запрос, если он висит дольше 60 секунд
request_terminate_timeout = 60s

; Максимум одновременных воркеров — считаем от RAM, а не «на глаз»
; 4 ГБ RAM, ~100 МБ на воркер в пике, оставляем запас под систему и БД
pm.max_children = 20

; После правки — перечитать конфиг без полного рестарта
# systemctl reload php8.2-fpm

Что делать в самом коде парсера, чтобы процесс не разрастался

Настройки пула — это страховка. Но если сам скрипт держит в памяти весь ответ внешнего сайта и дерево DOM целиком, никакой max_requests не спасёт от пикового потребления. Правило простое: обрабатывать данные порциями и освобождать всё, что больше не нужно. DOMDocument на странице каталога в 2 МБ легко занимает в 5–8 раз больше — дерево узлов в памяти в разы тяжелее исходного HTML.

Второй момент — способ получения ответа. Если накапливать тело ответа в строке через CURLOPT_RETURNTRANSFER, весь HTML окажется в памяти до начала разбора. Для больших страниц лучше стримить через CURLOPT_WRITEFUNCTION и разбирать по мере поступления. Обязательно ставить лимит на размер ответа — иначе один тяжёлый чужой сайт положит воркер.

Вот четыре шага, которые мы применяем в парсерах на PHP:

  1. Вызывать unset($dom) и unset($xpath) в конце каждой итерации цикла — иначе объекты копятся до конца скрипта.
  2. Стримить ответ через CURLOPT_WRITEFUNCTION вместо накопления строки в памяти целиком.
  3. Ставить лимит на размер ответа — например, обрывать соединение после N мегабайт, чтобы не тянуть гигантские страницы.
  4. Запускать парсер отдельным CLI-скриптом, а не через веб-запрос, — тогда он не делит пул с сайтом.

Как не упасть под нагрузкой: запуск парсера в CLI, очередь и логирование

Парсер, который дёргают из веб-запроса, падает не потому, что код плохой, а потому, что он живёт в чужом доме. У php-fpm один пул на весь сайт: тот же воркер, что обслуживает карточку товара покупателю, вынужден тянуть десять тысяч внешних запросов. Пока он это делает, остальные посетители стоят в очереди за свободным процессом. Плюс max_execution_time — жёсткий потолок, после которого скрипт прибьют на середине, оставив половину данных в неизвестном состоянии.

И главное: у веб-запроса нет памяти о прогрессе. Он не знает, что уже скачано, и ретрай после таймаута начнёт всё с нуля.

В наших проектах мы разводим эти две роли жёстко: веб-запрос только ставит задачу, а работает её отдельный CLI-процесс. Cron будит скрипт, скрипт читает очередь из таблицы, воркеры разбирают URL по одному и пишут результат в staging. Сайт при этом вообще не замечает, что где-то идёт парсинг.

flowchart LR
    A[Cron] -->|"каждые 5 минут"| B[CLI-скрипт]
    B -->|"читает задания"| C[Очередь задач в MySQL]
    C -->|"выдаёт URL"| D[Воркер]
    D -->|"HTTP-запрос"| E[Парсинг страницы]
    E -->|"данные"| F[Staging-таблица]
    F -->|"импорт"| G[Инфоблок Bitrix]

Вот скелет такого CLI-скрипта. Он не боится долгих прогонов и корректно завершается по сигналу:

PHP
<?php
// /local/cron/parser_worker.php
// запуск: php -f /local/cron/parser_worker.php
require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

set_time_limit(0);
ignore_user_abort(true);

$running = true;
pcntl_async_signals(true);
pcntl_signal(SIGTERM, function () use (&$running) {
        $running = false;
});
pcntl_signal(SIGINT, function () use (&$running) {
        $running = false;
});

global $DB;
$workerId = gethostname() . ':' . getmypid();

while ($running) {
    $task = $DB->Query(
        "SELECT ID, URL FROM parser_queue
        WHERE STATUS = 'new'
        ORDER BY ID ASC LIMIT 1"
    )->Fetch();

    if (!$task) {
        sleep(5);
        continue;
    }

    $DB->Query(
        "UPDATE parser_queue
        SET STATUS = 'in_progress', WORKER = '" . $DB->ForSql($workerId) . "'
        WHERE ID = " . (int)$task['ID']
    );

    $html = file_get_contents($task['URL']);

    $DB->Query(
        "INSERT INTO parser_staging (TASK_ID, URL, HTML, CREATED_AT)
        VALUES (" . (int)$task['ID'] . ", '" . $DB->ForSql($task['URL']) . "',
            '" . $DB->ForSql($html) . "', NOW())"
    );

    $DB->Query("UPDATE parser_queue SET STATUS = 'done' WHERE ID = " . (int)$task['ID']);
}

echo "Worker {$workerId} stopped gracefully\n";

Ключевое здесь не сам цикл, а то, что прогресс живёт в базе, а не в памяти процесса. Если воркер упадёт на середине, следующий подхватит очередь с того же места: задания со статусом in_progress можно вернуть в new по таймауту. Файл для этого не годится. При параллельных воркерах начнётся гонка за записью, а при переезде на другой сервер прогресс просто потеряется.

Ограничить параллелизм удобно мьютексом в Redis через SETNX: перед стартом воркер пытается занять ключ parser:lock:{workerId} с TTL в пару минут и продлевает его в цикле. Не занял — выходит, не нагружая сервер. Так вы держите ровно N одновременных процессов, сколько выдержит целевой сайт и ваш канал.

Ретраи строим на уровне воркера, а не внутри HTTP-запроса. Считаем попытки в поле ATTEMPTS, а задержку берём экспоненциальную: 30 секунд, 2 минуты, 10 минут. После третьей неудачи задание уходит в failed и ждёт ручного разбора — бесконечно долбить чужой сервер нельзя ни технически, ни этически.

Не запускайте парсер через ajax-эндпоинт, доступный анонимно. Любой, кто откроет DevTools или подсмотрит запрос, сможет дёргать его в цикле и положить сервер — вы получите бесплатный DoS своими руками. Запуск парсера — только CLI под cron либо закрытый админ-роут с проверкой прав через $USER->IsAdmin() и check_bitrix_sessid(). Никаких «удобных» публичных кнопок «запустить парсинг».

Самые частые ошибки, которые ломают парсер на PHP

Мы разбирали чужие парсеры на аудитах и заметили закономерность: код падает не на сложной логике разбора HTML, а на четырёх-пяти строчках, которые разработчик добавил «чтобы заработало». Отключённая проверка SSL-сертификата, потому что источник отдавал ошибку. Запрос без проверки HTTP-кода — а в ответе лежала капча. Отсутствие таймаута, из-за которого воркер висел до max_execution_time. Путь для сохранения картинок, взятый прямо из ответа источника.

По отдельности каждая мелочь выглядит безобидно. Именно они превращают рабочий скрипт в источник инцидентов.

Хорошая новость: все эти грабли лечатся на уровне конфигурации cURL и пары проверок перед записью. Мы собрали матрицу — что ломается, почему и как правильно. Дальше разберём, почему в Bitrix-контуре цена каждой ошибки выше, чем в standalone-скрипте.

Грабля Причина Как правильно
CURLOPT_SSL_VERIFYPEER = false «Так заработало» на чужом сертификате Починить CA-бандл, не отключать проверку
Нет connect_timeout Дефолт ждёт соединение десятки секунд Ставить 2–5 секунд на коннект
Парсинг без проверки HTTP-кода Разбираем тело ответа 403 или 503 Проверять curl_getinfo()['http_code']
Запрос без User-Agent Источник отдаёт заглушку или блокирует Задавать реальный UA через CURLOPT_USERAGENT
Запись в произвольную папку Путь взят из ответа источника Фиксированный каталог, белый список имён
Парсер в веб-пуле php-fpm Долгий скрипт занимает воркер сайта Выносить в CLI или очередь
Нет бэкоффа на 429 Ретраи без задержки добивают лимит Экспоненциальная пауза и уважение Retry-After

Теперь — почему в контуре Bitrix каждая из этих строк опаснее, чем в отдельном скрипте. Парсер, запущенный из веб-запроса, живёт в общем пуле php-fpm с сайтом: пока один воркер висит на медленном источнике, покупатель ждёт ответа своей страницы. Отключённая проверка SSL превращает парсер в слепого клиента. При смене сертификата на источнике он молча примет контент от подмены, включая редиректы на фишинговые страницы, и запишет это в базу как «актуальные данные».

Запись файлов по пути из ответа — это риск RCE. Если источник (или MITM) вернёт имя вида ../../upload/shell.php, а скрипт сохранит файл с расширением из ответа, в /upload/ появится исполняемый файл. Права на /upload/ в Bitrix обычно допускают запись, а веб-сервер отдаёт оттуда файлы. Отсюда правило: каталог фиксированный, имя генерируем сами, расширение — из белого списка.

Отдельная история — персональные данные в staging-таблицах. Парсер, который тянет карточки товаров вместе с отзывами или профилями продавцов, легко заносит в промежуточную таблицу ФИО, телефоны, email. Такие данные попадают под ст. 272.1 УК РФ, и «это же просто кэш» не оправдание. Мы в проектах либо вырезаем PII на этапе разбора, либо пишем в таблицу с ограниченным сроком хранения.

Частые вопросы

Можно ли парсить через headless-браузер на PHP, если cURL не проходит Cloudflare?

Да, через Chrome DevTools Protocol из PHP (например, библиотека chrome-php/chrome) или связку Puppeteer + Node как отдельный микросервис. Это в разы тяжелее по RAM и CPU, чем cURL, поэтому запускайте такой парсер только в CLI и с ограничением параллельных вкладок.

Что делать, если сайт отдаёт 200 OK, но в HTML вместо данных заглушка «Проверяем ваш браузер»?

Это JS-челлендж: сервер вернул страницу-заглушку, а реальные данные подгружаются после выполнения скрипта и установки cookie. Проверьте в DevTools, какой запрос уходит после челленджа (обычно XHR к /api/...), и воспроизведите его напрямую через cURL с теми же заголовками и cookie.

Чем отличается ротация прокси по IP от ротации по User-Agent?

IP-ротация меняет источник запроса и обходит лимиты по адресу, а смена User-Agent лишь маскирует клиента и без смены IP от бана не спасает. На практике нужны оба: пул прокси + согласованный с ним набор реальных UA, иначе получается «Chrome на Windows» с IP из дата-центра, что сразу палится.

А если у меня Bitrix и парсер пишет напрямую через CIBlockElement::Add — почему падает по памяти?

CIBlockElement::Add тянет за собой обработчики инфоблока, переиндексацию поиска и сброс кеша, поэтому на 10k+ элементов процесс раздувается. Разбейте импорт на батчи по 200–500 элементов, между батчами вызывайте unset() на объекты и запускайте скрипт в CLI с opcache.enable_cli=1.

Можно ли запускать парсер по cron в php-fpm, а не в CLI?

Технически можно через wget/curl на локальный URL, но вы упрётесь в max_execution_time, memory_limit и займёте воркеры fpm, из-за чего пострадают пользователи сайта. Правильнее — cron вызывает php /path/to/parser.php в CLI, а тяжёлые задачи складываются в очередь (например, через таблицу b_option или Redis).

Что делать, если парсер работает, но картинки в Bitrix битые или не подтягиваются?

Скачивайте изображения отдельным проходом через CFile::MakeFileArray() с проверкой HTTP-кода и Content-Type, а не через file_get_contents по URL из HTML. Учитывайте, что многие CDN отдают картинки только с Referer исходной страницы — передавайте его в заголовке при скачивании.

#PHP #Nginx #Docker #Redis #Cron
автор · Backend / SRE Engineer
Артем Колячек

Backend-разработчик и SRE в студии Paradigma. Занимается тем, что у других проектов обычно обнаруживается за неделю до запуска: переездом Bitrix-проектов между серверами, починкой кешей после миграций, выстраиванием pipeline для контейнеров, мониторингом под нагрузкой.

До Paradigma — 7 лет в backend (PHP + Postgres) и в operations (Linux, Docker, Coolify, restic-бэкапы). Любит когда логи разговаривают полным синтаксисом ошибки, а не «что-то пошло не так».

На блоге пишет ровно про те ситуации с которыми сам разбирался руками: какой Bitrix-апдейт сломал кеш и как откатить, почему Docker-сеть не находит контейнер после рекрейта, что делать когда asyncpg ругается на event loop.