Парсинг бесконечного списка с пагинацией
Table Of Content
Парсинг страниц, использующих бесконечную прокрутку, может быть сложной задачей. В этом руководстве показан один подход к решению этой проблемы с использованием 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-запросы. Для целей этой статьи я предположу, что запросы невозможно воспроизвести на серверной стороне.
Стратегия
Части
Вспомогательные функции
Подготовьте путь для пользовательских функций регистрации. Это полезно при автоматизации с помощью безголового браузера, потому что мы можем переопределить эти функции регистрации с помощью пользовательских функций в контексте скрипта (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 код, без дополнительного текста