Перейти к основному содержимому

Руководство по параллельному скрапингу

Экспериментальные функции

На момент написания этого руководства (декабрь 2023 г.) блокировка запросов все еще является экспериментальной функцией. Подробнее об этом эксперименте можно прочитать на странице эксперимента с блокировкой запросов.

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

А, вы уже вернулись! Давайте приступим к распараллеливанию скрапера!

Что нужно учесть перед распараллеливанием

Прежде чем приступать к изменению скрапера для поддержки параллельной работы, подумайте о следующем:

  • Планируете ли вы обрабатывать столько страниц, что действительно нужно распараллеливание?
    • Если ваш скрапер обрабатывает всего несколько страниц, параллелизация вряд ли нужна
    • Но если вы обрабатываете много страниц или страницы долго загружаются, стоит рассмотреть параллельную обработку
  • Можно ли распараллелить скрапер, не перегружая целевой сайт?
    • Если сайт и так сильно нагружен, не стоит усугублять ситуацию параллельными запросами
  • Есть ли у вас ресурсы для параллельного запуска нескольких скраперов?
    • При локальном запуске достаточно ли у вас CPU и RAM для поддержки параллельной работы
    • При работе в облаке оправдает ли прирост скорости дополнительные затраты на параллельные скраперы?

Предположим, на все вопросы вы ответили "да". Отлично! Перед тем как продолжить, рекомендуем также прочитать статью об этичном веб-скрапинге от Apify.

Хотите увидеть конечный результат?

Посмотрите репозиторий Crawlee Parallel Scraping Example! Это тот же скрапер, что мы создали во вводном руководстве, но на TypeScript и с поддержкой параллельной работы!

Чем отличается параллелизм от многопоточности в Crawlee?

Подождите! В Crawlee же есть опция maxConcurrency! Зачем нужно что-то еще?

Верно, Crawlee уже поддерживает "параллельную" (точнее, конкурентную) обработку. Это позволяет одному процессу выполнять несколько задач одновременно. Однако при масштабировании вы столкнетесь с ограничениями - будь то невозможность среды выполнения обрабатывать больше параллельных запросов или нехватка RAM и CPU. Вертикальное масштабирование (увеличение ресурсов) имеет свои пределы.

Именно поэтому различают вертикальное и горизонтальное масштабирование. Вертикальное - это увеличение ресурсов одного процесса/машины, а горизонтальное - увеличение количества процессов/машин. В этом руководстве мы рассмотрим именно горизонтальное масштабирование (параллелизацию)!

Подготовка скрапера к параллельной работе

Одно из преимуществ Crawlee в том, что для параллелизации требуется минимум изменений! Нужно создать очередь с поддержкой блокировки, добавить в нее ссылки из начального скрапера, а затем создать параллельные скраперы, использующие эту очередь!

Создание очереди запросов с поддержкой блокировки

Первый шаг - создание общего файла (назовем его requestQueue.mjs) с очередью запросов, поддерживающей блокировку.

src/requestQueue.mjs
import { RequestQueueV2 } from 'crawlee';

// Создаем очередь запросов, которая также поддерживает параллелизацию
let queue;

/**
* @param {boolean} makeFresh Следует ли очистить очередь перед возвратом
* @returns Очередь
*/
export async function getOrInitQueue(makeFresh = false) {
if (queue) {
return queue;
}

queue = await RequestQueueV2.open('shop-urls');

if (makeFresh) {
await queue.drop();
queue = await RequestQueueV2.open('shop-urls');
}

return queue;
}

Экспортируемая функция getOrInitQueue может показаться сложной, но по сути она просто инициализирует очередь запросов и при необходимости очищает ее.

Адаптация существующего скрапера для добавления URL в новую очередь

В файле src/routes.mjs нашего предыдущего скрапера есть обработчик для метки CATEGORY. Давайте адаптируем этот обработчик для добавления URL продуктов в новую очередь, которую мы создали.

Сначала импортируем функцию getOrInitQueue из созданного ранее файла requestQueue.mjs. Добавьте следующую строку в начало файла:

src/routes.mjs
import { getOrInitQueue } from './requestQueue.mjs';

Затем заменим обработчик CATEGORY следующим кодом:

src/routes.mjs
router.addHandler('CATEGORY', async ({ page, enqueueLinks, request, log }) => {
log.debug(`Enqueueing pagination for: ${request.url}`);
// Мы теперь на странице категории. Мы можем использовать это для пролистывания и добавления в очередь всех продуктов,
// а также любых последующих страниц, которые мы найдем

await page.waitForSelector('.product-item > a');
await enqueueLinks({
selector: '.product-item > a',
label: 'DETAIL', // <= обратите внимание на другую метку,
requestQueue: await getOrInitQueue(), // <= обратите внимание на другую очередь запросов
});

// Теперь нам нужно найти кнопку "Следующая" и добавить в очередь следующую страницу результатов (если она существует)
const nextButton = await page.$('a.pagination__next');
if (nextButton) {
await enqueueLinks({
selector: 'a.pagination__next',
label: 'CATEGORY', // <= обратите внимание на ту же метку
});
}
});

Теперь переименуем наш входной файл с src/main.mjs на src/initial-scraper.mjs и запустим его. Вы увидите, что краулер не будет обрабатывать страницы с деталями, но URL будут добавляться в очередь с поддержкой блокировки!

Перед завершением добавим следующую строку перед crawler.run():

src/initial-scraper.mjs
import { getOrInitQueue } from './requestQueue.mjs';

// Предварительно инициализируем пустую очередь, которая будет заполнена краулером
await getOrInitQueue(true);

Это нужно для того, чтобы очередь всегда начиналась с чистого листа при запуске скрапера. Но в вашем случае это может быть необязательно - всегда экспериментируйте и выбирайте то, что работает лучше всего!

Вот и все по подготовке нашего начального скрапера к сохранению всех URL, которые мы хотим обработать, в очередь с поддержкой блокировки!

Создание параллельных скраперов

Теперь создадим скрапер, который будет параллельно обрабатывать URL из очереди! Мы будем использовать дочерние процессы Node.js, но вы можете использовать любой другой метод параллельного запуска.

src/parallel-scraper.mjs
import { fork } from 'node:child_process';

import { Configuration, Dataset, PlaywrightCrawler, log } from 'crawlee';

import { router } from './routes.mjs';
import { getOrInitQueue } from './shared.mjs';

// Для этого примера мы будем запускать 2 отдельных процесса, которые будут скрапить магазин параллельно.

if (!process.env.IN_WORKER_THREAD) {
// Это основной процесс. Мы будем использовать его для запуска рабочих потоков.
log.info('Setting up worker threads.');

const currentFile = new URL(import.meta.url).pathname;

// Сохраняем промис для каждого воркера, чтобы дождаться завершения всех перед выходом из основного процесса
const promises = [];

// Вы можете решить, сколько воркеров вы хотите запустить, но помните, что вы можете запустить только столько, сколько позволяет ваша машина
for (let i = 0; i < 2; i++) {
const proc = fork(currentFile, {
env: {
// Поделитесь переменными окружения текущего процесса с новым процессом
...process.env,
// ...но также сообщите процессу, что он является воркерским процессом
IN_WORKER_THREAD: 'true',
// ...а также какой воркер он является
WORKER_INDEX: String(i),
},
});

proc.on('online', () => {
log.info(`Process ${i} is online.`);

// Выведите, что делают краулеры
// Обратите внимание: мы хотим использовать console.log вместо log.info, потому что мы уже получаем отформатированный вывод от краулеров
proc.stdout.on('data', (data) => {
// eslint-disable-next-line no-console
console.log(data.toString());
});

proc.stderr.on('data', (data) => {
// eslint-disable-next-line no-console
console.error(data.toString());
});
});

proc.on('message', async (data) => {
log.debug(`Process ${i} sent data.`, data);
await Dataset.pushData(data);
});

promises.push(
new Promise((resolve) => {
proc.once('exit', (code, signal) => {
log.info(`Process ${i} exited with code ${code} and signal ${signal}`);
resolve();
});
}),
);
}

await Promise.all(promises);

log.info('Crawling complete!');
} else {
// Это воркерский процесс. Мы будем использовать его для скрапинга магазина.

// Давайте построим логгер, который будет добавлять префикс к сообщениям журнала с индексом воркера
const workerLogger = log.child({ prefix: `[Worker ${process.env.WORKER_INDEX}]` });

// Это лучше установить с помощью переменной окружения CRAWLEE_LOG_LEVEL
// или параметра конфигурации. Это просто для демонстрации 😈
workerLogger.setLevel(log.LEVELS.DEBUG);

// Отключите автоматическое очищение при запуске
// Это необходимо при локальном запуске, так как в противном случае несколько процессов попытаются очистить стандартное хранилище (и это вызовет конфликты)
Configuration.set('purgeOnStart', false);

// Получите очередь запросов
const requestQueue = await getOrInitQueue(false);

// Настройте краулер для хранения данных воркера в отдельной директории (необходимо сделать после инициализации очереди при локальном запуске)
const config = new Configuration({
storageClientOptions: {
localDataDirectory: `./storage/worker-${process.env.WORKER_INDEX}`,
},
});

workerLogger.debug('Setting up crawler.');
const crawler = new PlaywrightCrawler(
{
log: workerLogger,
// Вместо длинного requestHandler с условными операторами мы предоставляем экземпляр роутера.
requestHandler: router,
// Включите эксперимент по блокировке запросов, чтобы мы могли фактически использовать очередь.
experiments: {
requestLocking: true,
},
// Предоставьте очередь запросов, которую мы предварительно заполнили в предыдущих шагах
requestQueue,
// Давайте также ограничим параллельность краулера, мы не хотим перегружать один процесс 🐌
maxConcurrency: 5,
},
config,
);

await crawler.run();
}

Почему мы ограничили maxConcurrency значением 5?

Для этого есть две причины:

  • Мы не хотим перегружать целевой сайт запросами, поэтому ограничиваем количество одновременных запросов на каждый рабочий процесс
  • Мы не хотим перегружать машину, на которой работает скрапер

Это возвращает нас к начальному вопросу о целесообразности параллелизации вашего скрапера.

Часто задаваемые вопросы

Можно ли объединить initial-scraper и parallel-scraper?

Технически - да! Ничто не мешает сначала добавить все URL в очередь в родительском процессе, а затем запустить рабочие процессы для их обработки. Мы разделили их для большей наглядности, но вы можете объединить их по своему усмотрению.

Будет ли параллелизация полезна для моего скрапера / сайта?

Однозначного ответа нет! 🤷

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