Puppeteer — это мощная библиотека автоматизации работы с браузером для веб-скрейпинга и интеграционного тестирования. Однако ее асинхронный реалтайм API оставляет вам достаточно много пространства для неумышленного создания проблем и антипаттернов.

Эта статья является частью цикла, который начинался со статей «О антипаттернах в Puppeteer» и «Puppeteer в Node.js: Распространенные ошибки, которые следует избегать». В этой статье мы добавим к этому списку еще десяток антипаттернов. Этот список не будет пересекаться с предыдущими частями, поэтому я бы рекомендовал вам начать с них.

Хоть эти антипаттерны и не являются полноценными ошибками, исключение их из скриптов (или хотя бы разумное использование) значительно повысит надежность вашего Puppeteer-кода.

Давайте начнем.

Предварительные требования

Эта статья рассчитана на тех, кто уже знаком с синтаксисом ES6 JavaScript, промисами, DOM’ом браузера и Node, а также имеет хотя бы небольшой опыт написания скриптов Puppeteer.

В написании статьи использовалась версия Puppeteer 20.3.0.

Что ж, давайте разбираться с антипаттернами!

Антипаттерны, которых следует избегать в Puppeteer для Node.js

Пренебрежение page.goto

Я часто вижу скрипты, которые автоматизируют поиск следующим образом:

  • Переход на главную страницу сайта.

  • Принятие cookie.

  • Ввод поискового запроса в поле ввода.

  • Нажатие кнопки для отправки запроса.

  • Ожидание завершения второй навигации.

Хоть это и может быть нужно в рамках тестирования, в контексте скрейпинга эти шаги часто можно обойти, добавив такой параметр запроса, как https://www.some-site.com/search?q=search+term, и используя page.goto(searchResultURL) напрямую. Пропуск промежуточной страницы ускоряет работу скрипта, требует меньше кода и, как правило, повышает надежность.

То же самое зачастую справедливо и для автоматизации с использованием iframe. Во многих случаях по URL источника фрейма можно перейти напрямую, минуя возню с родительским документом. Если источник фрейма заранее неизвестен, можно извлечь его и отделить от внешнего документа с помощью команды goto:

const frame = await page.waitForSelector("iframe");
const src = await frame.evaluate((e) => el.src);
await page.goto(src, { waitUntil: "domcontentloaded" });

Это упрощение кода может стоить дополнительной (возможно, кэшированной) загрузки.

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

Использование page.on вместо page.waitForRequest или page.waitForResponse

page.on("request", handler) и page.on("response", handler) — это слушатели событий на основе колбеков. Они подходят для перехвата и обработки сразу всех запросов или ответов, но могут быть не очень удобны в использовании при асинхронном потоке управления.

В случаях, когда необходимо дожидаться получения одного или нескольких ответов определенного типа, вместо того чтобы цеплять зависимый код на коллбеки или самим промисифицировать page.on, следует использовать page.waitForRequest или page.waitForResponse. Эти удобные методы как рази и представляют из себя промисифицированные обработчики page.on().

Для диалогов промисификация неизбежна, поскольку в настоящее время Puppeteer не предоставляет обертку page.waitForDialog. Однако page.once — это удобный способ избежать необходимости удаления слушателя после перехвата диалога:

const dialogDismissed = new Promise((resolve, reject) => {
  page.once("dialog", async (dialog) => {
    await dialog.dismiss();
    resolve(dialog.message());
  });
});

/* выполняем действие для запуска диалога */

const msg = await dialogDismissed;

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

const timeout = 30_000;
const dialogDismissed = new Promise((resolve, reject) => {
  const timeoutId = setTimeout(reject, timeout);
  page.once("dialog", async (dialog) => {
    clearTimeout(timeoutId);
    await dialog.dismiss();
    resolve(dialog.message());
  });
});

/* выполняем действие для запуска диалога */

const msg = await dialogDismissed;

Поскольку запросы и ответы могут происходить в любом порядке и в любое время, page.once не так полезен для этих событий, как для диалогов, которые обычно достаточно предсказуемы.

Следует отметить, что в целом в Puppeteer вам не придется часто использовать new Promise. Промисифицирование API, построенного на промисах, известно как антипаттерн явной конструкции промисов (или deferred anti-pattern), который обычно проникает в код, когда программист не очень хорошо знаком с промисами.

Пренебрежение специальными вариантами методов wait и evaluate

Как и в случае с обработчиками запросов и ответов, многие методы Puppeteer имеют иерархию обобщенности. Ниже (навскидку) приведено семейство вариантов вызова evaluate от общего к более специализированному:

  1. page.evaluate() может делать практически все, что может делать любой другой вызов API Puppeteer в браузере. Это мощная, но не специализированная функция.

  2. page. и page." class="formula inline">$eval() — это shorthand’ы для обычных вызовов page.evaluate(), которые в качестве первого шага своих колбеков немедленно выполняют document.querySelector() или document.querySelectorAll().

  3. page.waitForFunction() — это shorthand для page.evaluate(), который регистрирует цикл MutationObserver или requestAnimationFrame, который многократно проверяет условие и возвращается, когда это условие выполняется.

  4. page.waitForSelector() — это shorthand для page.waitForFunction(), который блокируется до тех пор, пока определенный селектор не совпадет с элементом в DOM.

Антипаттерн здесь — это использование обобщенного метода, когда существует конкретный метод, специально предназначенный для данной задачи. Например:

await page.evaluate(() => {
  const elements = document.querySelectorAll(".foo-bar");
  return [...elements].map((el) => el.textContent.trim());
});

Или:

await page.$$eval(".foo-bar", (elements) => {
  return elements.map((el) => el.textContent.trim());
});

Пренебрежение повторным использованием браузеров

Запуск браузера — задача достаточно затратная. Для поддержания идемпотентности при тестировании и в веб-приложениях, использующих Puppeteer с Express для выполнения задач, полезно обнулять состояние браузера после каждого запуска. Однако во многих случаях браузер можно использовать повторно, инкапсулируя задачи на отдельных страницах.

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

Скрейпинг DOM, а не ответов

Многие веб-приложения используют информацию из JSON-данных, либо встроенных в элемент <script>, либо в виде полезной нагрузки XHR-ответов. Вместо того чтобы придумывать, как извлечь данные из DOM, очень часто бывает легче перехватить ответы или извлечь информацию из <script>. Устранение непостоянного слоя представления данных из процесса может сделать ваш код более надежным.

Хотя необработанные данные, скорее всего, более стабильны, чем DOM, в некоторых случаях структуры полезных нагрузок с JSON могут подвергаться изменениям при получении или же их сложнее идентифицировать и распарсить, чем DOM. Хотя ответы не всегда являются самым удобным способом скрейпинга данных, все-таки стоит открывать вкладку "Network", чтобы попытаться найти ответ, в котором содержатся данные. Иногда находки оправдывают затраченные усилия.

Использование XPath вместо селекторов CSS

Поскольку XPath, как правило, более подробен и более сложен с точки зрения корректного написания, чем селекторы CSS, селекторы CSS следует предпочитать XPath, когда это возможно.

В Puppeteer 19.7.1 появился селектор ::-p-text, который в значительной степени покрывает популярную причину использования XPath в Puppeteer — выбор элементов по тексту.

Использование синтаксиса атрибутов CSS для классов

Селекторы CSS имеют специальный удобный синтаксис для выбора элементов по их атрибутам. Например:

<label for="username"></label>

Вы можете выбрать его с помощью page.$('[for="username"]'), что вполне нормально, но при применении этого синтаксиса к классам возникают проблемы:

<div class="row align-items-center"></div>

Есть целый ряд причин предпочесть .row.align-items-center вместо [class="row align-items-center"]. Синтаксис с точками проще с точки зрения чтения и написания, а также не зависит от порядка расположения и дополнительных атрибутов. Если список классов изменится на:

<div class="align-items-center row"></div>

Или:

<div class="row align-items-center p-2"></div>

Тогда селектор атрибутов не сработает. Чтобы сделать эти два подхода взаимозаменяемыми, можно использовать селектор атрибутов ~: [class~="row"][class~="align-items-center"]. Его длина делает этот антипаттерн очевидным.

Существуют ситуации, в которых можно использовать паттерн [class="..."] — например, выбор элементов, имеющих одинаковое имя префиксного атрибута с генерируемым постфиксом: [class^="p-"].

Добавление преждевременных абстракций

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

Лично я обычно следую советам Grug-Brained Dev:

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

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

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

Не очищать обработчики браузера и страниц с помощью finally

Даже если вы позаботитесь о том, чтобы обеспечить вызов browser.close(), нередко случаются ошибки, которые могут помешать закрытию браузера.

Возьмите в привычку добавлять блок finally, вызывающий browser.close() при каждом вызове browser.launch(), чтобы обеспечить правильную очистку ресурсов.

Не использовать встроенные селекторы

Как уже упоминалось выше, Puppeteer предлагает селектор  ::-p-text, а также ::-p-aria и ::-p-xpath. Предпочтительнее использовать их, а не самопальные альтернативы.

Давайте рассмотрим пример клика по кнопке на основе ее текстового содержимого:

await page.setContent(`<button>Click me</button>`);
const btn = await page.waitForSelector("button::-p-text(Click)");
await btn.click();

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

Комбинаторы >>> и >>>> позволяют обходить корни теневого дерева (shadow roots), причем >>> обходит глубокие теневые корни, а >>>> — глубиной в один корень.

Обычные методы выбора, такие как page.waitForSelector, page." class="formula inline">eval, page.$$eval и page.evaluate, работают со встроенными селекторами. Кроме того, page.waitForXPath и page.$x считаются устаревшими в Puppeteer в угоду унификации API селекторов.

Не использовать userDataDir

Автоматизация входа в систему может оказаться непростой задачей. Двухфакторная авторизация, поля ввода со сложной асинхронной валидацией и масками, редиректы и iframes — все это встречается повсеместно.

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

Даже в тех случаях, когда вы решили автоматизировать вход в систему, сохранение сессии позволяет повысить производительность.

Не использовать Playwright для тестирования пользовательских интерфейсов в Node.js

Новая библиотека Playwright компании Microsoft предлагает иную философию выбора элементов, нежели Puppeteer. Скрипты Puppeteer, как правило, опираются на селекторы CSS и XPath. В отличие от них, в подходе Playwright приоритет отдается атрибутам, ориентированным на пользователя, таким как доступные роли, текст и заголовки.

Сторонние пакеты тестирования, такие как expect-puppeteer и pptr-testing-library, пытаются привнести в Puppeteer философию, ориентированную на пользователя (user-facing). Однако Playwright предлагает этот стиль тестирования прямо из коробки. Playwright строго придерживается своего подхода и не поощряет методы выбора, не ориентированные на пользователя, как и API в стиле Puppeteer, который в основном уже устарел.

Однако для большинства задач веб-скрейпинга вполне естественно использовать CSS-селекторы в стиле Puppeteer. Я не видел никаких серьезных доказательств того, что при веб-скрейпинге следует придерживаться принципов, ориентированных на пользователя. Более простой API Puppeteer в большей степени не перетягивает все внимание на себя. Мне нравится использовать его в качестве тонкой, не требующей вмешательства обертки для уже работающего кода браузера, добавляя управляемые события и промисы, когда это необходимо.

Заключение

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

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

Кроме того, я выступал за то, чтобы рассматривать API Puppeteer для оценки и ожидания как простые обёртки для консоли браузера, оставляя принципы тестирования, ориентированные на пользователя, для Playwright.


В заключение приглашаем всех желающих на открытый урок «Service Workers и прогрессивные веб-приложения», который пройдет 18 сентября. На этом уроке:

  • Научимся делать сайты, которые доступны без интернета.

  • Разберём основные инструменты браузера для эффективного кеширования и обеспечения оффлайн-режима.

Записаться на открытый урок можно на странице курса "JavaScript Developer. Professional".

Комментарии (0)