CoderCastrov logo
CoderCastrov
JavaScript

Парсинг бесконечного списка с пагинацией

Парсинг бесконечного списка с пагинацией
просмотров
8 мин чтение
#JavaScript

Парсинг страниц, использующих бесконечную прокрутку, может быть сложной задачей. В этом руководстве показан один подход к решению этой проблемы с использованием Puppeteer.

Введение

Парсинг веб-сайтов является популярным (иногда спорным) способом получения структурированных данных с веб-сайтов, которые не предлагают общедоступного API.

В случае традиционных веб-приложений HTML, созданный на стороне сервера, может быть получен с использованием HTTP-клиентов (например, cURL, Wget или HTTP-библиотек) и разобран с помощью парсера DOM. Пагинация обычно обрабатывается путем следования ссылкам или увеличения GET-параметров, и логика может быть прослежена в масштабе. Благодаря низкому потреблению ЦП и легкому весу (начальный HTML-код) таких парсеров, эти приложения могут быть спарсены с высокой производительностью и низкой стоимостью.

Современные веб-приложения, которые динамически получают данные в клиентской среде, обычно делают пагинированные AJAX-запросы к общедоступному API-эндпоинту. В таких сценариях эмуляция HTTP-вызовов (например, DevTools) может сделать задачу очень простой. В большинстве случаев это предпочтительный подход.

Однако некоторые веб-приложения требуют аутентифицированных сеансов, используют альтернативные протоколы (WebSockets) или nonced API-вызовы, которые могут быть сложными для воспроизведения. В этих случаях вы можете запустить фактический браузер (Selenium, PhantomJS, Chrome Headless) и спарсить DOM в консоли, чтобы получить нужные результаты. Возможно автоматизировать сложные пользовательские потоки с хорошей надежностью (поддержка веб-стандартов, низкий риск обнаружения) с помощью автоматизации пользовательского поведения.


Пример

Для этого примера я буду использовать страницу результатов поиска Quora для Что такое смысл жизни?_ _. Она должна дать достаточное количество результатов для наших целей. В конечном итоге получится JSON-массив с следующими данными для каждой записи.

  • Заголовок
  • Отрывок
  • Ссылка

Примечание: Это строго для образовательных целей, пожалуйста, уважайте условия использования Quora в отношении парсеров (https://www.quora.com/about/tos)

Вот как выглядит страница:

Похоже, что она делает фрагментированные AJAX-запросы. Для целей этой статьи я предположу, что запросы невозможно воспроизвести на серверной стороне.

Новые AJAX-запросы добавляются по мере прокрутки

Стратегия


Части

Вспомогательные функции

Подготовьте путь для пользовательских функций регистрации. Это полезно при автоматизации с помощью безголового браузера, потому что мы можем переопределить эти функции регистрации с помощью пользовательских функций в контексте скрипта (NodeJS, Python, Lua и т. д.), а не в консоли браузера.

const _log = console.info,
    _warn = console.warn,
    _error = console.error,
    _time = console.time,
    _timeEnd = console.timeEnd;const page = 1;// Глобальный набор для хранения всех записей
let threads = new Set(); // Предотвращает дублирование// Пауза между пагинацией, настраивается в соответствии с временем загрузки
const PAUSE = 4000;

Часть 1. Определение селекторов и извлечение сущностей

На момент написания этого кода, я смог вывести следующие селекторы из данного URL: https://www.quora.com/search?q=meaning%20of%20life%3F&type=answer. Поскольку большинство лент / списков с ленивой загрузкой следуют похожей структуре DOM, вы можете использовать этот скрипт, просто модифицировав селекторы.

// Класс для отдельной ветки
const C_THREAD = '.pagedlist_item:not(.pagedlist_hidden)';// Класс для веток, помеченных для удаления на последующей итерации
const C_THREAD_TO_REMOVE = '.pagedlist_item:not(.pagedlist_hidden) .TO_REMOVE';// Класс для заголовка
const C_THREAD_TITLE = '.title';// Класс для описания
const C_THREAD_DESCRIPTION = '.search_result_snippet .search_result_snippet .rendered_qtext ';// Класс для идентификатора
const C_THREAD_ID = '.question_link';// DOM-атрибут для ссылки
const A_THREAD_URL = 'href';// DOM-атрибут для идентификатора
const A_THREAD_ID = 'id';

Парсинг отдельной записи

// Принимает родительский элемент DOM и извлекает заголовок и URL
function scrapeSingleThread(elThread) {
    try {
        const elTitle = elThread.querySelector(C_THREAD_TITLE),
            elLink = elThread.querySelector(C_THREAD_ID),
            elDescription = elThread.querySelector(C_THREAD_DESCRIPTION);
        if (elTitle) {
            const title = elTitle.innerText.trim(),
                description = elDescription.innerText.trim(),
                id = elLink.getAttribute(A_THREAD_ID),
                url = elLink.getAttribute(A_THREAD_URL);
                
            threads.add({
                title,
                description,
                url,
                id
            });
        }
    } catch (e) {
        _error("Ошибка при получении отдельной ветки", e);
    }
}

Парсинг всех видимых веток. Проходит по каждой ветке и анализирует детали. Возвращает количество веток.

// Получает все ветки в видимом контексте
function scrapeThreads() {
    _log("Парсинг страницы %d", page);
    const visibleThreads = document.querySelectorAll(C_THREAD);if (visibleThreads.length > 0) {
        _log("Парсинг страницы %d... найдено %d веток", page, visibleThreads.length);
        Array.from(visibleThreads).forEach(scrapeSingleThread);
    } else {
        _warn("Парсинг страницы %d... ветки не найдены", page);
    }// Возвращает основной список веток;
    return visibleThreads.length;
}

Выполните вышеуказанные два скрипта в консоли браузера, чтобы получить:

Если вы выполните scrapeThreads() в консоли на этом этапе, вы должны получить число, и глобальный набор должен заполниться.

Часть 2. Эмуляция прокрутки и ленивая загрузка списка

Мы можем использовать JS для прокрутки до конца экрана. Эта функция выполняется после каждого успешного парсинга scrapeThreads

// Прокручивает до конца видимой области
function loadMore() {
    _log("Загрузка еще... страница %d", page);
    window.scrollTo(0, document.body.scrollHeight);
}

Очищаем DOM от элементов, которые уже были обработаны:

// Очищает список между пагинацией для экономии памяти
// В противном случае, браузер начинает тормозить после примерно 1000 тем
function clearList() {
    _log("Очистка списка страницы %d", page);
    const toRemove = `${C_THREAD_TO_REMOVE}_${(page-1)}`,
        toMark = `${C_THREAD_TO_REMOVE}_${(page)}`;
    try {
        // Удаляем ранее помеченные для удаления темы
        document.querySelectorAll(toRemove)
            .forEach(e => e.parentNode.removeChild(e));// Помечаем видимые темы для удаления на следующей итерации
        document.querySelectorAll(C_THREAD)
            .forEach(e => e.className = toMark.replace(/\./g, ''));} catch (e) {
        _error("Не удалось удалить элементы", e.message)
    }
}

clearList() вызывается перед каждым loadMore(). Это помогает нам контролировать использование памяти DOM (в случае с тысячами страниц) и также устраняет необходимость в хранении курсора.

Часть 3. Цикл до тех пор, пока не будут получены все записи и возврат JSON

Поток выполнения скрипта связан здесь. loop() вызывает сам себя, пока видимые потоки не будут исчерпаны.

// Рекурсивный цикл, который завершается, когда нет больше потоков
function loop() {
    _log("Циклическое выполнение... добавлено %d записей", threads.size);
    if (scrapeThreads()) {
        try {
            clearList();
            loadMore();
            page++;
            setTimeout(loop, PAUSE)
        } catch (e) {
            reject(e);
        }
    } else {
        _timeEnd("Парсинг");
        resolve(Array.from(threads));
    }
}

Часть 4. Полный скрипт

Вы можете запустить и настроить этот скрипт в консоли вашего браузера. Он должен вернуть промис, который разрешается массивом JS-объектов записей.

(function() {
    return new Promise((resolve, reject) => {// Класс для отдельного потока
        const C_THREAD = '.pagedlist_item:not(.pagedlist_hidden)';
        // Класс для потоков, помеченных для удаления на следующей итерации
        const C_THREAD_TO_REMOVE = '.pagedlist_item:not(.pagedlist_hidden) .TO_REMOVE';
        // Класс для заголовка
        const C_THREAD_TITLE = '.title';
        // Класс для описания
        const C_THREAD_DESCRIPTION = '.search_result_snippet .search_result_snippet .rendered_qtext ';
        // Класс для ID
        const C_THREAD_ID = '.question_link';
        // DOM-атрибут для ссылки
        const A_THREAD_URL = 'href';
        // DOM-атрибут для ID
        const A_THREAD_ID = 'id';const _log = console.info,
            _warn = console.warn,
            _error = console.error,
            _time = console.time,
            _timeEnd = console.timeEnd;_time("Парсинг");let page = 1;// Глобальный набор для хранения всех записей
        let threads = new Set(); // Исключает дубликаты// Пауза между пагинацией
        const PAUSE = 4000;// Принимает родительский DOM-элемент и извлекает заголовок и URL
        function scrapeSingleThread(elThread) {
            try {
                const elTitle = elThread.querySelector(C_THREAD_TITLE),
                    elLink = elThread.querySelector(C_THREAD_ID),
                    elDescription = elThread.querySelector(C_THREAD_DESCRIPTION);
                if (elTitle) {
                    const title = elTitle.innerText.trim(),
                        description = elDescription.innerText.trim(),
                        id = elLink.getAttribute(A_THREAD_ID),
                        url = elLink.getAttribute(A_THREAD_URL);threads.add({
                        title,
                        description,
                        url,
                        id
                    });
                }
            } catch (e) {
                _error("Ошибка при получении отдельного потока", e);
            }
        }// Получает все потоки в видимом контексте
        function scrapeThreads() {
            _log("Парсинг страницы %d", page);
            const visibleThreads = document.querySelectorAll(C_THREAD);if (visibleThreads.length > 0) {
                _log("Парсинг страницы %d... найдено %d потоков", page, visibleThreads.length);
                Array.from(visibleThreads).forEach(scrapeSingleThread);
            } else {
                _warn("Парсинг страницы %d... потоки не найдены", page);
            }// Возвращает основной список потоков;
            return visibleThreads.length;
        }// Очищает список между пагинацией для сохранения памяти
        // В противном случае браузер начинает тормозить после примерно 1000 потоков
        function clearList() {
            _log("Очистка списка страницы %d", page);
            const toRemove = `${C_THREAD_TO_REMOVE}_${(page-1)}`,
                toMark = `${C_THREAD_TO_REMOVE}_${(page)}`;
            try {
                // Удалить потоки, ранее помеченные для удаления
                document.querySelectorAll(toRemove)
                    .forEach(e => e.parentNode.removeChild(e));// // Пометить видимые потоки для удаления на следующей итерации
                document.querySelectorAll(C_THREAD)
                    .forEach(e => e.className = toMark.replace(/\./g, ''));} catch (e) {
                _error("Не удалось удалить элементы", e.message)
            }
        }// Прокручивает до конца видимой области
        function loadMore() {
            _log("Загрузка дополнительных данных... страница %d", page);
            window.scrollTo(0, document.body.scrollHeight);
        }// Рекурсивный цикл, который завершается, когда нет больше потоков
        function loop() {
            _log("Циклическое выполнение... добавлено %d записей", threads.size);
            if (scrapeThreads()) {
                try {
                    clearList();
                    loadMore();
                    page++;
                    setTimeout(loop, PAUSE)
                } catch (e) {
                    reject(e);
                }
            } else {
                _timeEnd("Парсинг");
                resolve(Array.from(threads));
            }
        }
        loop();
    });
})().then(console.log)

Часть 5. Безголовая автоматизация

Поскольку скрипт выполняется в контексте браузера, он должен работать с любой современной системой автоматизации браузера, которая позволяет выполнять пользовательский JS код. В этом примере я буду использовать Chrome Puppeteer с использованием Node.JS 8.

Сохраните скрипт в виде модуля Node.js с именем script.js в формате CommonJS:

module.exports = function() {
//...скрипт
}

Установите Puppeteer с помощью npm install puppeteer и:

const puppeteer = require('puppeteer')
const script = require('./script');
const { writeFileSync } = require("fs");function save(raw) {
    writeFileSync('results.json', JSON.stringify(raw));
}const URL = '[https://www.quora.com/search?q=meaning%20of%20life&type=answer'](https://www.quora.com/search?q=meaning+of+life&type=answer%27);(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  page.on('console', msg => console.log(msg.text()));
  await page.goto(URL);
  const threads = await page.evaluate(script);
  save(threads);
  await browser.close();
})();

Скрипт должен выдать результат, похожий на следующий:

[  
   {  
      "title":"Есть ли цель в жизни или нет? Если нет, то дает ли это нам возможность выбрать любую цель, которую мы хотим?",
      "description":"Отец к сыну: \"Сынок, ты знаешь, что я думаю о смысле жизни с тех пор, как я был маленьким ребенком твоего возраста\". Сын продолжает облизывать мороженое. ... \"И ты знаешь...",
      "url":"/Does-life-have-a-purpose-or-not-If-not-does-that-give-us-the-chance-to-make-up-any-purpose-we-choose",
      "id":"__w2_JaoJDz0_link"
   },
   {  
      "title":"Каков смысл жизни?",
      "description":"Мы не знаем. Мы не можем знать. Но... ... Каждая религия и каждая философия строит себя вокруг попытки ответить на этот вопрос. И они делают это на вере, потому что жизнь д...",
      "url":"/What-is-the-meaning-of-life-66",
      "id":"__w2_Qov8B7u_link"
   },...
]

# Код:

---
Переведи введенный markdown код с английского на русский. Сохрани оригинальную разметку markdown и изображения на своих местах. Вместо слов скрэпинг/скрапинг/скрапер/скрэпер используй парсинг/парсер/парсить. Возвращай только markdown код, без дополнительного текста