Puppeteer — это популярная библиотека автоматизации браузера для NodeJS, обычно используемая для парсинга веб-страниц и сквозного тестирования. Поскольку Puppeteer предлагает богатый API, который выполняет сложные взаимодействия с браузером в режиме реального времени, в ваши скрипты может закрасться множество недоразумений и антипаттернов.

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

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

Чрезмерное использование waitForTimeout

Ридми Кукловода говорит об этом лучше всего:

Puppeteer имеет событийно-ориентированную архитектуру, которая устраняет многие потенциальные ненадежности. В сценариях кукловодов нет необходимости в злых sleep(1000) вызовах.

Беда в том, что у Кукловода есть page.waitForTimeout(milliseconds), семантика идентичная злобному sleep(1000) зову! Может быть удобно иметь этот аварийный люк в API, но им также легко злоупотреблять.

Сон — это зло, потому что он вводит состояние гонки, которое включает два возможных исхода.

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

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

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

Альтернативы waitForTimeout включают waitForSelector, который блокируется до тех пор, пока не появится селектор, или более общий waitForFunction, который блокируется до тех пор, пока условие предиката не станет истинным. Puppeteer проверяет условие в узком цикле requestAnimationFrame или при изменении DOM с помощью MutationObserver. И waitForSelector, и waitForFunction придерживаются модели, управляемой событиями, и исключают условия гонки.

Однако во многих случаях подходит page.waitForTimeout. Это может помочь при отладке, регулировании взаимодействия для имитации человеческого поведения, опросе ресурса с ограничением скорости и в крайнем случае для обработки надоедливых ситуаций, которые плохо реагируют на другие подходы. Но спать как вариант по умолчанию, когда вполне возможно заблокировать явный предикат, — это антипаттерн.

Предполагая, что API Puppeteer работает как собственный API браузера

API Puppeteer имеет семантику, отличную от родного API браузера. Например, Puppeteer кажется простой оболочкой нативного HTMLElement.click() браузера, но на самом деле он работает совсем по-другому и скрывает под капотом изрядную долю сложности.

Вместо того, чтобы вызывать обработчик события щелчка непосредственно на элементе, как это делает родной .click(), щелчок в Puppeteer прокручивает элемент в поле зрения, перемещает мышь на элемент, нажимает одну из нескольких кнопок мыши, при необходимости вызывает задержку, а затем отпускает мышь. кнопка. Вы также можете активировать несколько кликов. Другими словами, Puppeteer выполняет щелчок, как человек.

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

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

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

await page.evaluate(() => {
  document.querySelector('button[type=submit]').click();
});

Разница в поведении может быть наиболее заметна для page.click(), но стоит помнить об этом различии при работе с остальным API Puppeteer.

Никогда не используйте 'domcontentloaded"

Событием по умолчанию для навигационного API Puppeteer, например page.goto(url, {waitUntil: "some event"}), является {waitUntil: "load"}. Я виновен в том, что слепо полагаюсь на это значение по умолчанию, не подвергая сомнению его последствия, но стоит немного разобраться с другими вариантами: "domcontentloaded", "networkidle2" и "networkidle0".

MDN говорит о событии load:

Событие load запускается после загрузки всей страницы, включая все зависимые ресурсы, такие как таблицы стилей и изображения. Это отличается от DOMContentLoaded, который запускается сразу после загрузки DOM страницы, не дожидаясь окончания загрузки ресурсов.

Ожидание load полезно в сценариях, когда важно видеть страницу такой, какой ее увидел бы пользователь. Примеры использования включают создание снимков экрана или создание PDF-файлов. "networkidle0" и "networkidle2" разрешают обещание навигации, когда за последние 500 миллисекунд активно не более 0 или 2 сетевых запросов. Эти параметры аналогичны вариантам использования, что и "load", и предлагают больший контроль над обработкой страниц, которые могут поддерживать одно или два соединения для опроса активными после загрузки или когда драйверу необходимо сбросить запросы перед продолжением.

С другой стороны, если цель состоит в том, чтобы очистить таблицу текстовой статистики, которая поступает из сетевого запроса, нет смысла ждать чего-то большего, чем "domcontentloaded" и одиночный запрос данных, который может выглядеть так:

await page.goto(url, {waitUntil: "domcontentloaded"});
const el = await page.waitForSelector(yourSelector, {visible: true});
const data = await el.evaluate(el => el.textContent);

…где yourSelector не будет введено на страницу, пока не поступят нужные данные.

Не блокировать изображения и ресурсы

Если цель состоит в том, чтобы собрать простой фрагмент данных, нет смысла тратить время и трафик на запрос всех изображений, таблиц стилей и/или скриптов на странице. По возможности рассмотрите возможность отключения JS с помощью page.setJavaScriptEnabled(false), остановки браузера с помощью page.evaluate(() => window.stop()) или включения перехвата запросов для блокировки изображений следующим образом:

await page.setRequestInterception(true);
page.on("request", req => {
  if (req.resourceType() === "image") {
    req.abort();
  }
  else {
    req.continue();
  }
});

Это помогает сохранить ваши сценарии быстрыми и сократить количество отходов.

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

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

Избегайте page.evaluate, когда доверенные события не нужны

Можно легко разочароваться при рефакторинге работающего кода консоли браузера с помощью jQuery или vanilla JS в доверенный интерфейс Puppeteer: page.$, page.$eval, page.type, page.click и так далее. Это обычная ситуация, поскольку эксперименты с DOM в консоли браузера — типичный первый шаг в создании скрипта Puppeteer. Эти рефакторинги часто дают сбои при попытке передать сложные структуры, такие как ElementHandles, между контекстами браузера и узла или при обнаружении поведенческих различий в таких методах, как page.click(), как обсуждалось выше.

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

Неправильное использование селекторов, созданных инструментами разработчика

Инструменты разработчика в современных браузерах позволяют легко копировать селекторы CSS и XPath в буфер обмена:

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

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

Например, Chrome предоставляет очень строгий селектор CSS #answer-60796572 > div > div.answercell.post-layout--right > div.s-prose.js-post-body > pre, тогда как #answer-60796572 pre может быть более устойчивым к динамическому поведению.

Выбор того, как выбирать элементы, — это искусство, а не наука. Чтобы понять, какая специфика подходит для конкретного варианта использования, требуется время. Генерируемые браузером селекторы и пути удобны, но созрели для неправильного использования. При использовании этих селекторов разумно сделать шаг назад и рассмотреть более широкий контекст, такой как поведение конкретного сайта и цели скрипта.

Сообщение SerpAPI Скрапинг веб-страниц с помощью селекторов CSS с использованием Python представляет собой хорошее введение в селекторы CSS в контексте парсинга веб-страниц. (Python не важен для информации селектора.)

Не использовать возвращаемое значение .waitForSelector и .waitForXPath

Обычный, хотя и второстепенный, антипаттерн заключается в том, чтобы сделать дополнительный вызов для извлечения элемента после его ожидания:

await page.waitForSelector(selector);
const elem = await page.$(selector);
// or:
await page.waitForXPath(xpath);
const [elem] = await page.$x(xpath);

Более чистым и точным является использование возвращаемого значения вызовов waitFor:

const elem = await page.waitForSelector(selector);
// or:
const elem = await page.waitForXPath(xpath);

Получив ElementHandle, вы можете использовать elem.evaluate(el => el.textContent) вместо page.evaluate(el => el.textContent, elem). См. Мой ответ на переполнение стека для получения подробной информации о передаче вложенных ElementHandles обратно в браузер.

Использование отдельного парсера HTML с Puppeteer

Puppeteer уже имеет доступ ко всей мощи браузерного JS и работает со страницей в режиме реального времени, поэтому использование дополнительного парсера HTML, такого как Cheerio, без веской причины является антипаттерном. Использование Cheerio с Puppeteer включает создание сериализованных снимков всего DOM (т. е. с использованием page.content()), а затем просьбу Cheerio повторно проанализировать HTML, прежде чем он сможет сделать выбор. Это может быть медленным, добавляет потенциально запутанный уровень косвенности между активной страницей и отдельным парсером HTML и создает дополнительную возможность неправильного понимания состояния приложения.

Этот подход может быть полезен для отладки, когда необходимы снимки HTML или определенные функции Cheerio (например, шипящие селекторы), но обычно используется Puppeteer отдельно.

Использование Puppeteer, когда другие инструменты более уместны

Автоматизация веб-браузера — тяжелое и медленное решение. Он включает в себя запуск процесса браузера, затем выполнение сетевых вызовов для перехода браузера на страницу, а затем манипулирование страницей для достижения цели. Иногда эта цель может быть достигнута напрямую с помощью простого HTTP-запроса к общедоступному API или извлечения данных, встроенных в статический HTML-код страницы. Использование библиотеки запросов, такой как fetch, и анализатора HTML, такого как Cheerio, когда это возможно, может быть более быстрым и простым методом, чем Puppeteer.

Собирая требования для задания парсинга, найдите время, чтобы убедиться, что данные не скрыты на виду. Обычно это включает в себя просмотр исходного кода страницы для просмотра статического HTML и анализ сетевого трафика с помощью инструментов разработчика браузера, чтобы определить, откуда поступают нужные вам данные. Чтение часто задаваемых вопросов и документации веб-сайта часто указывает на общедоступный API. Иногда другие сайты предлагают ту же информацию в более доступном формате.

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

Хотя эти альтернативы могут не покрывать многие из ваших потребностей Puppeteer, хорошо иметь более одного инструмента на поясе.

Подведение итогов

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

Имейте в виду, что эти рекомендации являются эмпирическими правилами, и, возможно, их придется время от времени нарушать. Ключ должен знать о компромиссах при их использовании.

Удачной автоматизации!

Эта статья актуальна для Puppeteer версии 13.5.1.

Первоначально опубликовано на https://serpapi.com 1 апреля 2022 г. автором Greg Gorlen.