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

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

Пагинация: переход по ссылке «Далее» (JavaScript)

Пройти каталог от первой страницы до последней, не задавая заранее количество страниц. Сценарий ориентируется на ссылки самого источника.

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

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

{
  "path": "/pagination",
  "mode": "http",
  "expected": 147
}

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

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

  1. Открываем /pagination с параметром mode: http. Переменная url хранит адрес страницы, которую нужно обработать сейчас.
  2. Проверяем, нет ли адреса в visited, и отмечаем его как посещённый. Это отдельная проверка страниц, а не товаров.
  3. Получаем HTML, затем передаём его функции сбора. Она добавляет только ещё не встречавшиеся товары.
  4. Ищем a[rel="next"] или a.next.page-numbers. Отсутствие такой ссылки завершает цикл; номер страницы не приходится угадывать.
  5. Относительную ссылку приводим к полному URL. Поэтому переход работает и с полным адресом, и с короткой ссылкой источника.
  6. После завершения сравниваем число уникальных товаров с expected. Проверка помогает заметить пропущенную страницу.

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

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

У алгоритма две разные памяти: visited не даёт ходить по кругу, а seen не даёт сохранять один товар повторно.

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

В JavaScript функция run асинхронная. Сетевые и браузерные действия требуют await, а разбор уже полученного HTML выполняется синхронно. collect замыкает общий Set с ключами товаров. Его возвращаемое число означает количество карточек в текущем HTML, а не число новых записей.

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

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

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

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

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

async function run(ctx) {
  const origin = 'https://www.scrapingcourse.com';
  const path = String(ctx.param('path', '/pagination'));
  const mode = String(ctx.param('mode', 'http'));
  const auto = ctx.param('auto_captcha', true) !== false;
  const seen = new Set();
  const collect = (html) => {
    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 = origin + path;
    const visited = new Set();
    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);
            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