К содержимому

Документация

JavaScript-страница: дождаться заполненных карточек (PHP)

Получить товары, которые появляются после выполнения JavaScript. Здесь браузер нужен для отрисовки данных, а разбор полей остаётся обычным чтением HTML.

Выберите язык PHP, вставьте полный исходник из этой статьи и задайте параметры запуска ниже. Источник для всей подборки: ScrapingCourse. Разбор относится к указанной демонстрационной странице.

Настройки примера

{
  "path": "/javascript-rendering",
  "mode": "browser",
  "expected": 12
}

path выбирает страницу, mode выбирает ветку алгоритма, expected задаёт контрольное число записей для этого примера. Значение expected не является лимитом тарифа. Если набор данных источника меняется, сверяйте ожидаемое количество с ним.

Как работает алгоритм

  1. Открываем /javascript-rendering с режимом browser. Этот режим не отправляет форму и не запускает цикл загрузки порций.
  2. Ждём .product-name:not(:empty) до 15 секунд. Условие проверяет заполненное название, а не только наличие контейнера.
  3. После ожидания читаем page.content(). В нём уже доступна разметка, которую добавили скрипты страницы.
  4. Передаём HTML общей функции сбора. Она извлекает поля и убирает повторы точно так же, как в HTTP-примерах.
  5. Закрываем страницу и проверяем число уникальных товаров по expected.

Карточки обрабатывает courseCollect($ctx, $html, $seen). Сначала читается название. Пустое название пропускается, ссылка карточки становится ключом, а при её отсутствии используется имя. seen не даёт повторно сохранить один и тот же ключ. Это важно, когда очередное чтение HTML включает уже собранные товары.

На что обратить внимание

Браузерный SDK выполняет сетевые и браузерные действия, а DOM-инструменты читают полученную строку HTML. Разделение позволяет использовать один сборщик для разных способов загрузки.

Особенности PHP

В PHP сетевые и браузерные методы вызываются последовательно через ->. Функция courseCollect получает массив $seen и возвращает его обновлённую копию, поэтому результат каждого вызова присваивается обратно. Объект страницы закрывается в finally. Для остановки с понятным сообщением используется $ctx->fail().

Что получится

Для настроенного демонстрационного набора ожидаются 12 уникальных записей. Общая проверка в конце сравнивает фактическое число ключей с expected.

Поле Что содержит
name Название товара после удаления пробелов по краям.
price Цена, преобразованная из текста в число.
image Адрес изображения из атрибута src карточки.

Полный код сценария

Исходник общий для всей подборки. Для этого примера в нём выполняется ветка browser, выбранная параметрами выше. Блок можно целиком скопировать в редактор PHP; реквизиты доступа и ключи сервисов в него добавлять не требуется.

<?php

function run(\Sdk\Context $ctx): void
{
    $origin = 'https://www.scrapingcourse.com';
    $path = (string) $ctx->param('path', '/pagination');
    $mode = (string) $ctx->param('mode', 'http');
    $auto = $ctx->param('auto_captcha', true) !== false;
    $seen = [];
    if ($mode === 'http') {
        $url = $origin.$path;
        $visited = [];
        while ($url !== null && count($visited) < 50) {
            if (isset($visited[$url])) {
                $ctx->fail('Пагинация вернула уже обработанную страницу');
            }
            $visited[$url] = true;
            $response = $ctx->http()->withoutCache()->get($url, $auto);
            if (! $response->ok()) {
                $ctx->fail('Источник не открылся: '.$response->status);
            }
            $html = $response->body;
            $seen = courseCollect($ctx, $html, $seen);
            $next = $ctx->dom($html)->attr('a[rel="next"], a.next.page-numbers', 'href');
            $url = $next ? $ctx->url()->absolutize($url, $next) : null;
        }
        if ($url !== null) {
            $ctx->fail('Превышен защитный предел пагинации');
        }
    } else {
        $page = $ctx->browser()->open($origin.$path, ['*/ajax/products*'], [], $auto);
        try {
            if ($mode === 'login') {
                $dom = $ctx->dom($page->content());
                $hint = '';
                foreach ($dom->qa('p') as $p) {
                    if (str_contains($p->text(), 'Demo Credentials:')) {
                        $hint = $p->text();
                    }
                }
                preg_match('/Password:\s*([^\s|]+)/i', $hint, $password);
                preg_match('/Email:\s*([^\s|]+@[^\s|]+)/i', $hint, $emailMatch);
                $email = $emailMatch[1] ?? '';
                if ($email === '' || ! isset($password[1])) {
                    $ctx->fail('Страница не содержит учебных реквизитов');
                }
                $page->type('#email', $email)->type('#password', $password[1])->click('button[type="submit"]');
            }
            if ($mode === 'challenge') {
                $text = preg_replace('/\s+/', ' ', $ctx->dom($page->content())->text('body') ?? '');
                if (! preg_match('/You (?:successfully )?bypassed|successfully bypassed|Congratulations/i', $text)) {
                    $ctx->fail('Защищённое содержимое не получено');
                }
                $ctx->emit(['name' => $path, 'passed' => true], $path);
            } else {
                $page->waitFor('.product-name:not(:empty)', 15000);
                if ($mode === 'button') {
                    $complete = false;
                    for ($i = 0; $i < 30; $i++) {
                        $html = $page->content();
                        $before = $ctx->dom($html);
                        $seen = courseCollect($ctx, $html, $seen);
                        $button = $before->q('#load-more-btn');
                        if ($button === null || preg_match('/display:\s*none/', $button->attr('style') ?? '')) {
                            $complete = true;
                            break;
                        }
                        $offset = $button->attr('data-offset');
                        $count = count($before->qa('.product-item'));
                        $page->click('#load-more-btn');
                        $response = $page->waitForResponse('*offset='.$offset, 15000);
                        if ($response === null || ($response['status'] < 200 || $response['status'] >= 300) || $response['truncated'] || $response['error']) {
                            $ctx->fail('Не удалось загрузить следующую порцию');
                        }
                        if (trim($response['body'] ?? '') === '') {
                            $complete = true;
                            break;
                        }
                        $page->waitFor('.product-item:nth-child('.($count + 1).')');
                    }
                    if (! $complete) {
                        $ctx->fail('Превышен защитный предел загрузки');
                    }
                }
                if ($mode === 'scroll') {
                    $stable = 0;
                    $previous = 0;
                    for ($i = 0; $i < 40 && $stable < 3; $i++) {
                        $page->scroll(3, 400);
                        $html = $page->content();
                        $seen = courseCollect($ctx, $html, $seen);
                        $count = count($ctx->dom($html)->qa('.product-item'));
                        $stable = $count === $previous ? $stable + 1 : 0;
                        $previous = $count;
                    }
                    if ($stable < 3) {
                        $ctx->fail('Лента продолжает загружаться: увеличьте предел прокрутки');
                    }
                }
                $seen = courseCollect($ctx, $page->content(), $seen);
            }
        } finally {
            $page->close();
        }
    }
    $expected = (int) $ctx->param('expected', 1);
    if ($mode !== 'challenge' && count($seen) !== $expected) {
        $ctx->fail('Неполный результат: '.count($seen).' вместо '.$expected);
    }
}

function courseCollect(\Sdk\Context $ctx, string $html, array $seen): array
{
    $dom = $ctx->dom($html);
    foreach ($dom->qa('.product-item, li.product') as $card) {
        $name = trim($card->q('.product-name, .woocommerce-loop-product__title')?->text() ?? '');
        $key = $card->q('a')?->attr('href') ?? $name;
        if ($name === '' || isset($seen[$key])) {
            continue;
        }
        $seen[$key] = true;
        $ctx->emit(['name' => $name, 'price' => $ctx->text()->money($card->q('.product-price, .price')?->text() ?? ''),
            'image' => $card->q('img')?->attr('src') ?? ''], $key);
    }

    return $seen;
}

Куда посмотреть дальше

Общий алгоритм и параметры · HTTP-запросы · Действия браузера · Поиск элементов HTML