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

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

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

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

Выберите язык TypeScript, вставьте полный исходник из этой статьи и задайте параметры запуска ниже. Источник для всей подборки: 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.

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

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

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

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

В TypeScript ctx: Akmelo.Context даёт подсказки методов SDK, Promise<void> описывает результат run, а Set<string> хранит строковые ключи. Эти типы не меняют порядок действий: сетевые и браузерные вызовы по-прежнему требуют await. collect(html: string): number возвращает число карточек текущей страницы.

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

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

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

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

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

async function run(ctx: Akmelo.Context): Promise<void> {
  const origin = 'https://www.scrapingcourse.com';
  const path = String(ctx.param('path', '/pagination'));
  const mode = String(ctx.param('mode', 'http'));
  const auto = ctx.param<boolean>('auto_captcha', true) !== false;
  const seen = new Set<string>();
  const collect = (html: string): number => {
    const dom = ctx.dom(html);
    for (const card of dom.qa('.product-item, li.product')) {
      const name = card.q('.product-name, .woocommerce-loop-product__title')?.text().trim() ?? '';
      const key = card.q('a')?.attr('href') ?? name;
      if (!name || seen.has(key)) continue;
      const price = ctx.text().money(card.q('.product-price, .price')?.text() ?? '');
      const image = card.q('img')?.attr('src') ?? '';
      seen.add(key);
      ctx.emit({name, price, image}, key);
    }
    return dom.qa('.product-item, li.product').length;
  };
  if (mode === 'http') {
    let url: string | null = origin + path;
    const visited = new Set<string>();
    while (url && visited.size < 50) {
      if (visited.has(url)) throw new Error('Пагинация вернула уже обработанную страницу');
      visited.add(url);
      const response = await ctx.http().withoutCache().get(url, auto);
      if (!response.ok()) throw new Error('Источник не открылся: ' + response.status);
      const html = response.body;
      collect(html);
      const next = ctx.dom(html).attr('a[rel="next"], a.next.page-numbers', 'href');
      url = next ? ctx.url().absolutize(url, next) : null;
    }
    if (url) throw new Error('Превышен защитный предел пагинации');
  } else {
    const page = await ctx.browser().open(origin + path, ['*/ajax/products*'], {}, auto);
    try {
      if (mode === 'login') {
        const dom = ctx.dom(await page.content());
        const hint = dom.qa('p').map(node => node.text()).find(text => text.includes('Demo Credentials:')) ?? '';
        const password = hint.match(/Password:\s*([^\s|]+)/i)?.[1];
        let email = hint.match(/Email:\s*([^\s|]+@[^\s|]+)/i)?.[1];
        const encoded = dom.attr('[data-cfemail]', 'data-cfemail');
        if (!email && encoded && encoded.length > 2) {
          const key = parseInt(encoded.slice(0, 2), 16);
          email = '';
          for (let i = 2; i < encoded.length; i += 2) email += String.fromCharCode(parseInt(encoded.slice(i, i + 2), 16) ^ key);
        }
        if (!email || !password) throw new Error('Страница не содержит учебных реквизитов');
        await page.type('#email', email);
        await page.type('#password', password);
        await page.click('button[type="submit"]');
      }
      if (mode === 'challenge') {
        const text = (ctx.dom(await page.content()).text('body') ?? '').replace(/\s+/g, ' ');
        if (!/You (?:successfully )?bypassed|successfully bypassed|Congratulations/i.test(text)) throw new Error('Защищённое содержимое не получено');
        ctx.emit({name: path, passed: true}, path);
      } else {
        await page.waitFor('.product-name:not(:empty)', 15000);
        if (mode === 'button') {
          let complete = false;
          for (let i = 0; i < 30; i++) {
            const before = ctx.dom(await page.content());
            collect(await page.content());
            const button = before.q('#load-more-btn');
            if (!button || /display:\s*none/.test(button.attr('style') ?? '')) { complete = true; break; }
            const offset = button.attr('data-offset');
            const count = before.qa('.product-item').length;
            await page.click('#load-more-btn');
            const response = await page.waitForResponse('*offset=' + offset, 15000) as Akmelo.Row | null;
            if (!response || (Number(response.status) < 200 || Number(response.status) >= 300) || response.truncated || response.error) throw new Error('Не удалось загрузить следующую порцию');
            if (String(response.body ?? '').trim() === '') { complete = true; break; }
            await page.waitFor('.product-item:nth-child(' + (count + 1) + ')');
          }
          if (!complete) throw new Error('Превышен защитный предел загрузки');
        }
        if (mode === 'scroll') {
          let stable = 0;
          let previous = 0;
          for (let i = 0; i < 40 && stable < 3; i++) {
            await page.scroll(3, 400);
            const count = collect(await page.content());
            stable = count === previous ? stable + 1 : 0;
            previous = count;
          }
          if (stable < 3) throw new Error('Лента продолжает загружаться: увеличьте предел прокрутки');
        }
        collect(await page.content());
      }
    } finally { await page.close(); }
  }
  const expected = Number(ctx.param('expected', 1));
  if (mode !== 'challenge' && seen.size !== expected) throw new Error('Неполный результат: ' + seen.size + ' вместо ' + expected);
}

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

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