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

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

Кнопка «Показать ещё»: загрузка порциями (PHP)

Собрать каталог, в котором следующая порция появляется после нажатия кнопки. Важно дождаться не только ответа сервера, но и новых карточек на странице.

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

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

{
  "path": "/button-click",
  "mode": "button",
  "expected": 147
}

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

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

  1. Открываем /button-click в браузере. Маска */ajax/products* включает наблюдение за ответами загрузки товаров ещё до первого нажатия.
  2. Ждём заполненное название товара, затем читаем уже показанные карточки. Наличие пустого контейнера само по себе не означает, что товары готовы.
  3. Находим #load-more-btn. Если кнопка исчезла или в её inline-стиле указано display: none, считаем выдачу завершённой.
  4. Перед кликом запоминаем data-offset и число карточек. По offset затем узнаём ответ именно на эту порцию, а не случайный запрос страницы.
  5. После клика ждём соответствующий ответ до 15 секунд. Проверяем HTTP-статус, отсутствие ошибки и признака усечённого тела. Пустое тело завершает выдачу, в том числе при ответе 204.
  6. Если тело непустое, ждём появления карточки с номером count + 1. Только после обновления DOM начинаем следующий проход.
  7. В конце ещё раз читаем страницу, сверяем уникальные товары и закрываем браузер в finally.

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

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

Один проход цикла соответствует одной проверке или загрузке порции. Предел 30 проходов защищает от бесконечных нажатий; seen убирает повторы при повторном чтении всей страницы.

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

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

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

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

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

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

Исходник общий для всей подборки. Для этого примера в нём выполняется ветка button, выбранная параметрами выше. Блок можно целиком скопировать в редактор 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