Puppeteer – это популярная библиотека автоматизации браузера для NodeJS, обычно используемая для веб-скрейпинга и сквозного тестирования. Поскольку Puppeteer предлагает богатый API, выполняющий сложные взаимодействия с браузером в реальном времени, в ваши скрипты может закрасться множество недоразумений и антипаттернов.
В этом посте мы расскажем о 9 антипаттернах Puppeteer, которые я использовал или видел в коде Puppeteer за последние несколько лет. Хотя список не является исчерпывающим, мы надеемся, что он поможет вам лучше понять и оценить этот инструмент.
Я предполагал, что читатели уже знакомы с Puppeteer и написали хотя бы несколько сценариев с его помощью, достаточно, чтобы столкнуться с некоторыми причудами и подводными камнями автоматизации.
- Чрезмерное использование waitForTimeout
- Предполагать, что API Puppeteer работает как родной API браузера
- Никогда не использовать "domcontentloaded"
- Не блокировать изображения и ресурсы
- Избегание page.evaluate, когда доверенные события не нужны
- Неправильное использование селекторов, генерируемых инструментами разработчика
- Неиспользование возвращаемого значения .waitForSelector и .waitForXPath
- Использование отдельного парсера HTML в Puppeteer
- Использование Puppeteer, когда другие инструменты более уместны
- Подведение итогов
Чрезмерное использование waitForTimeout
В readme Puppeteer об этом сказано лучше всего:
Puppeteer имеет событийно-ориентированную архитектуру, которая устраняет многие потенциальные недостатки. Нет необходимости в злобных вызовах
sleep(1000)
в сценариях puppeteer.
Проблема в том, что в Puppeteer есть page.waitForTimeout(milliseconds)
, идентичный по семантике злому вызову sleep(1000)
! Это может быть удобно, когда в API есть такой аварийный люк, но им также легко злоупотреблять.
Sleeping является злом, потому что он вводит состояние гонки, которое включает два возможных исхода.
Спящий режим вызывает состояние гонки между состоянием браузера и драйвером Puppeteer.
Один исход – это когда продолжительность сна слишком оптимистична, и драйвер просыпается до того, как браузер перейдет в нужное состояние. В этом случае проблема заключается в корректности: драйвер может пропустить данные из ответов, которые еще не пришли, или выдать ошибку при попытке манипулировать несуществующими элементами.
Другой исход – когда продолжительность сна слишком пессимистична и браузер достигает желаемого состояния до того, как драйвер проснется. В этом случае речь идет об эффективности: время, прошедшее с момента появления желаемого состояния до пробуждения драйвера, тратится впустую. Даже небольшая дополнительная задержка становится болезненной при многократном повторении, например, во время автоматизированного тестирования.
Кроме того, сон, который кажется работающим, может создать иллюзию надежности. Продолжительность, которая сегодня достаточно велика, завтра может оказаться слишком короткой. Сон вводит чрезмерно подогнанное, специфическое для среды значение, которое может работать на одной машине, но легко может не сработать на другой. Эта другая машина часто является производственным развертыванием в облаке, где отладка расхождений с рабочей локальной версией может быть затруднена.
Альтернативы waitForTimeout
включают waitForSelector
, который блокирует до появления селектора, или более общий waitForFunction
, который блокирует до тех пор, пока условие предиката не станет истинным. Puppeteer проверяет условие в узком цикле requestAnimationFrame
или при изменении DOM с помощью MutationObserver
. И waitForSelector
, и waitForFunction
придерживаются событийно-управляемой модели и устраняют условия гонки.
Однако существует множество случаев, когда page.waitForTimeout
будет уместен. Это может помочь при отладке, дросселировании взаимодействия для имитации человеческого поведения, ограничении скорости опроса ресурса и в качестве последнего средства для разрешения досадных ситуаций, которые не поддаются другим подходам. Но спящий режим в качестве опции по умолчанию, когда вполне возможно блокировать по явному предикату, является антипаттерном.
Предполагать, что API Puppeteer работает как родной API браузера
API Puppeteer имеет семантику, отличную от семантики родного API браузера. Например, page.click()
в 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);
…где ваш селектор
не будет внедрен в страницу, пока не придут нужные данные.
Не блокировать изображения и ресурсы
Если цель – соскрести простой фрагмент данных, нет смысла тратить время и полосу пропускания на запрос всех изображений, таблиц стилей и/или скриптов на странице. По возможности отключите 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
, чтобы результаты можно было ожидать позже. Хотя большинство событий доступны в функции, основанной на обещании, другие, такие как "dialog"
, не доступны.
Вы можете использовать методы .once
и .off
, чтобы гарантировать удаление обработчика, когда он больше не нужен.
Избегание page.evaluate
, когда доверенные события не нужны
При рефакторинге кода консоли браузера с использованием jQuery или vanilla JS в доверенный интерфейс Puppeteer можно легко разочароваться: page.$
, page.$eval
, page.type
, page.click
и так далее. Это обычная ситуация, поскольку эксперименты с DOM в консоли браузера – типичный первый шаг в создании сценария Puppeteer. Эти рефакторы часто сходят на нет при попытке передать сложные структуры, такие как ElementHandles, между контекстами браузера и Node, или при столкновении с поведенческими различиями в методах, таких как page.click()
, о которых говорилось выше.
Puppeteer предоставляет page.evaluate()
как обобщенную функцию для выполнения кода в браузере. Она поддерживает передачу сериализуемых данных и возврат десериализованных результатов. В тех случаях, когда доверенные события не нужны, поместив кусок рабочего кода браузера в page.evaluate
и позволив ему выполнять тяжелую работу, вы сократите время на переписывание и уменьшите вероятность тонких регрессий.
Неправильное использование селекторов, генерируемых инструментами разработчика
Инструменты разработчика в современных браузерах позволяют легко копировать селекторы CSS и XPaths в буфер обмена:
Копирование CSS-селектора элемента в Chrome
Это очень удобно для разработчиков, которые не знакомы с селекторами CSS или XPath, и может сэкономить время тем, кто знаком.
Проблема в том, что эти селекторы и пути могут быть слишком жесткими и приводить к хрупким сценариям, которые ломаются, если обертку добавляют в качестве родительского элемента дальше по дереву, или сиблинговый элемент неожиданно появляется после взаимодействия на странице.
Например, Chrome дает очень строгий CSS-селектор #answer-60796572 > div > div.answercell.post-layout--right > div.s-prose.js-post-body > pre
, когда #answer-60796572 pre
мог бы быть более устойчивым к динамическому поведению.
Выбор способа выделения элементов – это искусство, а не наука. Требуется время, чтобы понять, какая специфика подходит для конкретного случая использования. Генерируемые браузером селекторы и пути удобны, но ими можно злоупотреблять. При использовании этих селекторов стоит сделать шаг назад и рассмотреть более широкий контекст, например, поведение сайта и цели сценария.
В статье SerpAPI Web Scraping with CSS Selectors using 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 обратно в браузер см. в моем ответе на Stack Overflow.
Использование отдельного парсера HTML в Puppeteer
Puppeteer уже имеет доступ ко всей мощи браузерного JS и работает на странице в режиме реального времени, поэтому привлечение вторичного HTML-парсера, такого как Cheerio, без веских на то причин является антипаттерном. Использование Cheerio в Puppeteer подразумевает получение сериализованных снимков всего DOM (т.е. использование page.content()
), затем обращение к Cheerio с просьбой повторно разобрать HTML, прежде чем можно будет сделать выбор. Это может быть медленным, добавляет потенциально запутанный уровень косвенности между живой страницей и отдельным анализатором HTML и создает дополнительную возможность для неправильного понимания состояния приложения.
Такой подход может быть полезен для отладки и в тех случаях, когда необходимы снимки HTML или определенные возможности Cheerio (например, селекторы sizzle), но чаще всего Puppeteer используется сам по себе.
Использование Puppeteer, когда другие инструменты более уместны
Автоматизация веб-браузера – тяжелое и медленное занятие. Она включает в себя запуск процесса браузера, затем выполнение сетевых вызовов для перехода браузера на страницу, а затем манипулирование страницей для достижения цели. Иногда эта цель может быть достигнута напрямую с помощью простого HTTP-запроса к публичному API или получения данных, встроенных в статический HTML страницы. Использование библиотеки запросов типа fetch
и парсера HTML типа Cheerio, когда это возможно, может быть более быстрым и простым методом, чем Puppeteer.
При сборе требований для задачи скраппинга потратьте время на то, чтобы убедиться, что данные не спрятаны на виду. Обычно это включает просмотр исходного текста страницы, чтобы увидеть статический HTML, и анализ сетевого трафика с помощью инструментов разработчика браузера, чтобы определить, откуда поступают искомые данные. Чтение FAQ и документации сайта часто указывает на наличие публичного API. Иногда другие сайты предлагают ту же информацию в более доступном формате.
Для одноразового анализа я часто выбираю нужные нам данные с помощью JS браузера и копирую их в буфер обмена из консоли вручную, вместо того чтобы доставать Puppeteer. Юзерскрипты, расширения и букмарклеты – другие легкие способы манипулирования страницей без Puppeteer или Node.
Хотя эти альтернативы могут не покрыть многие ваши потребности в Puppeteer, полезно иметь в своем арсенале несколько инструментов.
Подведение итогов
В этой статье я представил несколько антипаттернов Puppeteer, на которые следует обратить внимание. Мы надеемся, что их критическое рассмотрение поможет сохранить ваш код Puppeteer чистым, быстрым, надежным и простым в сопровождении. По мере развития инструмента мне будет интересно посмотреть, как эти шаблоны будут развиваться вместе с ним.
Имейте в виду, что эти рекомендации являются правилами, и время от времени их придется нарушать. Главное – осознавать компромиссы при их использовании.
Удачной автоматизации!
Эта статья актуальна на момент выхода версии Puppeteer 13.5.1.