Увеличение скорости парсинга веб-страниц с помощью puppeteer

Я пытаюсь создать Node.js API, который очищает веб-сайт (я начал только с Goodreads как веб-сайта, который нужно очистить, и буду расширять его дальше, когда я впервые оптимизирую подход) и предоставляю очищенные данные конечному пользователю, что будет использовать мой API.

Мой первоначальный подход заключался в планировании структуры API, решении использовать puppeteer, а затем начать создавать. При успешном создании первой конечной точки я заметил кое-что: в Postman требуется около 2-3 секунд для завершения запроса, что очень медленно.

Вот мой код:

scraper-handler.ts

import { NextFunction, Request, Response } from "express";
import { MOST_POPULAR_LISTS } from "../utils/api/urls-endpoints.js";
import { listScraper } from "./spec-scrapers/list-scraper.js";
import { lists } from "../utils/api/full-urls.js";
import puppeteer from "puppeteer";
import { GOODREADS_POPULAR_LISTS_URL } from "../utils/goodreads/urls.js";

export const scraperHandler = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  const browser = await puppeteer.launch({
    // headless: false,
    // defaultViewport: null,
  });

  const pages = await browser.pages();

  await pages[0].setUserAgent(
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36"
  );

  switch (req.url) {
    case `/${MOST_POPULAR_LISTS}`: {
      const result = await listScraper(
        browser,
        pages[0],
        GOODREADS_POPULAR_LISTS_URL,
        1,
        ".cell",
        ".listTitle",
        ".listTitle"
      );

      res.status(200).json({
        status: "success",
        data: result,
      });
      break;
    }
    default: {
      next();
      break;
    }
  }
};

А вот case /${MOST_POPULAR_LISTS}:

list-scraper.ts

import puppeteer, { Page } from "puppeteer";
import { Browser } from "puppeteer";

export const listScraper = async (
  browser: Browser,
  page: Page,
  url: string,
  pageI = 1,
  main: string,
  title = "",
  ref = ""
)  => {
  // const page = await browser.newPage();

  await page.goto(url, {
    waitUntil: "domcontentloaded",
  });
  
  const books = await page.evaluate(
    (mainSelector, titleSelector, refSelector) => {
      // const nextLink = document.querySelector('a[rel = "next"]');

      // console.info(nextLink);
      const elements = document.querySelectorAll(mainSelector);
      
      return Array.from(elements)
        .slice(0, 3)
        .map((element) => {
          const title =
            titleSelector.length > 0 &&
            (element.querySelector(titleSelector) as HTMLElement | null)
              ?.innerText;
          const ref =
            refSelector.length > 0 &&
            (element.querySelector(refSelector) as HTMLAnchorElement | null)
              ?.href;

          return { title, ref };
        });
    },
    main,
    title,
    ref
  );
  // await page.click(".pagination > a");

  await browser.close();

  return books;
};

Вот URL-адрес Goodreads, с которого я начал:

export const GOODREADS_POPULAR_LISTS_URL = "https://www.goodreads.com/list/popular_lists";

Итак, мой вопрос: как я могу оптимизировать свой подход и какие методы я могу использовать, чтобы ускорить очистку и, таким образом, радикально улучшить производительность моего API?

Я искал разные посты и многие предлагали какие-то манипуляции с процессором, но я не понял, как это можно использовать в моем случае. Также довольно много раз предлагался дочерний процесс в Node.js.

Заранее спасибо!

Какой URL-адрес? Возможно, вы сможете отключить JS или использовать fetch вместо Puppeteer, или, по крайней мере, заблокировать ресурсы, такие как таблицы стилей, изображения, шрифты, сценарии отслеживания и т. д., которые не имеют отношения к извлекаемым вами данным. Вы также можете обойти сайт и напрямую обратиться к API или, по крайней мере, перехватить ответ сети. Все это зависит от сайта.

ggorlen 24.04.2024 07:07

Вот оно: Export const GOODREADS_POPULAR_LISTS_URL = "goodreads.com/list/popular_lists ";

Ilia Popov 24.04.2024 07:11

Спасибо. Я думаю, что данные доступны статически, поэтому попробуйте просто использовать fetch и Cheerio вместо Puppeteer, или хотя бы отключите JS и заблокируйте все возможные запросы. Совет по кэшированию в ответе тоже кажется хорошим - я сомневаюсь, что эти данные меняются так часто, вероятно, кеширование в течение часа или хотя бы получаса в большинстве случаев сэкономит массу работы или даже ежечасное выполнение фонового задания, поэтому вы может всегда предоставлять данные мгновенно.

ggorlen 24.04.2024 07:13

Я полностью согласен, я видел, что он статичен, и, вероятно, cherio может быть ответом на этот вопрос, но puppeteer кажется более мощным и подходит для динамических веб-сайтов, таких как SPA. Целесообразен ли гибридный подход?

Ilia Popov 24.04.2024 07:20

Если он статический, то добавление накладных расходов на кукловода является спорным. Если это не спа, зачем использовать инструмент, предназначенный для спа? Зачем гибридизироваться, если это не спа?

Phix 24.04.2024 07:30

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

Ilia Popov 24.04.2024 07:31

Я также обсуждаю это в своем блоге. Я почти не вижу вариантов использования для объединения Puppeteer и fetch/Cheerio - если данные находятся там статически и сервер пропускает вас, используйте более простой вариант выборки. Если данные невозможно получить с помощью более простого варианта, вам придется использовать более сложный подход, такой как Puppeteer. Прочтите ее — я думаю, она стоит вашего времени и, по сути, ответит на все ваши вопросы.

ggorlen 24.04.2024 07:32

Если потенциально этот API в какой-то момент превратится в API парсинга, каким будет подход — для статических веб-сайтов это будет «cheerio + fetch», а для динамических — «кукловод»?

Ilia Popov 24.04.2024 07:35

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

ggorlen 24.04.2024 07:37

Я думаю, очевидно, что я новичок в мире парсинга, так что я просто пытаюсь понять, как это сделать, а затем перейду к более сложным задачам, таким как тот более общий API, о котором мы говорим. Но, например, если я хочу очистить данные книг с нескольких веб-сайтов, и есть как статические, так и SPA, как будет выглядеть этот «общий API» в этом примере. И что вы имеете в виду, говоря «например, ограниченный набор сайтов, собирающих только простые ответы или допускающих довольно высокий уровень отказов».

Ilia Popov 24.04.2024 07:46
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
1
10
235
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Есть несколько вещей, на которые можно обратить внимание, чтобы улучшить скорость.

  1. Первая серьезная проблема, которую я вижу, — это создание нового экземпляра puppeteer при каждом запросе. Мы можем создать модуль по шаблону Singleton.
// browser_instance.ts
import puppeteer from 'puppeteer';

let browserInstance = null;

export async function getBrowser() {
    if (!browserInstance) {
        browserInstance = await puppeteer.launch({ ... });
    }
    return browserInstance;
}

Затем в своем коде попытайтесь получить экземпляр

import { getBrowser } from './browser-instance.js';
....
export const scraperHandler = async (req: Request, res: Response, next: NextFunction) => {
    const browser = await getBrowser();
    // ... use the browser instance for scraping
};
  1. Рассмотрите возможность использования Promise.all, чтобы дождаться одновременного завершения всех запросов.
const results = await Promise.all(listUrls.map(async (url) => {
    const page = await browser.newPage();
    // ... scraping logic for each page ...
}));
  1. Второй подход — кэшировать существующие веб-сайты, на которые вы уже заходили, если данные не сильно меняются.

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

Где мне следует использовать Promise.all? :)

Ilia Popov 24.04.2024 07:07
Ответ принят как подходящий

Мне ваш подход кажется излишним. Необходимые данные находятся в статическом HTML-коде, поэтому одного запроса к странице, похоже, достаточно, чтобы получить данные с помощью встроенного в Node API-интерфейса выборки и простого статического анализатора HTML Cheerio:

const cheerio = require("cheerio"); // ^1.0.0-rc.12

const url = "<Your URL>";

fetch(url)
  .then(res => {
    if (!res.ok) {
      throw Error(res.statusText);
    }

    return res.text();
  })
  .then(html => {
    const $ = cheerio.load(html);
    const data = [...$(".listTitle")].map(e => ({
      title: $(e).text(),
      ref: $(e).attr("href"),
    }));
    console.info(data);
  })
  .catch(err => console.error(err));

Для сравнения вот оптимизированная версия Puppeteer:

const puppeteer = require("puppeteer"); // ^22.6.0

const url = "<Your URL>";

let browser;
(async () => {
  browser = await puppeteer.launch();
  const [page] = await browser.pages();
  await page.setJavaScriptEnabled(false);
  const ua =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36";
  await page.setUserAgent(ua);
  await page.setRequestInterception(true);
  page.on("request", req => {
    if (req.url() === url) {
      req.continue();
    } else {
      req.abort();
    }
  });
  await page.goto(url, {waitUntil: "domcontentloaded"});
  const data = await page.$$eval(".listTitle", els => els.map(el => ({
    title: el.textContent,
    ref: el.href,
  })));
  console.info(data);
})()
  .catch(err => console.error(err))
  .finally(() => browser?.close());

Вот (по сути) ваша версия — неоптимизированная, кроме "domcontentloaded", что является хорошим началом:

let browser;
(async () => {
  browser = await puppeteer.launch();
  const [page] = await browser.pages();
  const ua =
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36";
  await page.setUserAgent(ua);
  await page.goto(url, {waitUntil: "domcontentloaded"});
  const data = await page.$$eval(".listTitle", els => els.map(el => ({
    title: el.textContent,
    ref: el.href,
  })));
  console.info(data);
})()
  .catch(err => console.error(err))
  .finally(() => browser?.close());

Неоптимизированный Кукловод:

real 0m1.806s
user 0m0.801s
sys  0m0.204s

Оптимизированный Кукловод:

real 0m1.251s
user 0m0.651s
sys  0m0.107s

Получить + Приветствие:

real 0m0.836s
user 0m0.251s
sys  0m0.035s

Использование fetch и Cheerio обеспечивает более чем двукратное ускорение, более простой код и отсутствие зависимости от такой сложной библиотеки, как Puppeteer. Основные причины использования Puppeteer для парсинга — это когда вам необходимо выполнять сложные взаимодействия, такие как клики, перехватывать запросы, работать с файлами cookie, избегать блокировок, ждать загрузки SPA и т. д. (некоторые из этих случаев могут применяться здесь, но сначала добавьте пользователя агент на запрос на получение, если вас заблокировали).

Независимо от того, какой подход к очистке вы используете, кэширование ответа — хорошая идея. Я сомневаюсь, что данные так сильно изменяются, поэтому вы можете повторно получать их только раз в час или около того, возможно, в фоновом задании, и мгновенно передавать их всем из кеша через свой прокси.

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

Другие вопросы по теме