Превращение сценария драматурга в инструмент: хранение, повторные попытки, интерфейс командной строки и ограничения от ИИ
Как одноразовый скрипт Playwright превращается в универсальный инструмент для Node.js с поддержкой хранилища SQLite, удалением дубликатов, повторными попытками выполнения задач, интерфейсом командной строки Commander, настройками и проверенными задачами, сгенерированными ИИ.
Источники: The JavaScript Automation Project I Built to Stop Repeating the Same Browser Tasks
Многие повторяющиеся задачи в браузере следуют одному и тому же циклу: открыть сайт, навигировать по нему, ввести что-то, прочитать результат, сохранить его и повторить. Каждый шаг кажется простым, но ежедневное выполнение их вручную суммируется. В этом руководстве этот цикл превращается из десятистрочного скрипта Playwright в небольшой инструмент на Node.js с постоянным хранилищем истории, функцией обнаружения дубликатов, возможностью повторных попыток, интерфейсом командной строки, централизованной настройкой и защищенным интерфейсом на естественном языке.
Основной поток работы выглядит следующим образом: поступает запрос, его обрабатывает JavaScript-работающий процесс, работу выполняет настоящий браузер, результаты в структурированном виде сохраняются в базе данных или отчете. Связанный материал посвящен созданию модульного набора инструментов для автоматизации на JavaScript с нуля; здесь акцент делается на решениях, которые обеспечивают надежность такого инструмента по мере его развития.
Шаг первый: запуск настоящего браузера
Playwright позволяет JavaScript управлять Chromium, Firefox или WebKit. Исходный скрипт запускает видимый браузер, загружает страницу, ждёт готовности DOM, выводит заголовок и закрывает браузер.
const { chromium } = require("playwright");
async function openWebsite(url) {
const browser = await chromium.launch({
headless: false
});
const page = await browser.newPage();
await page.goto(url, {
waitUntil: "domcontentloaded"
});
console.log("Title:", await page.title());
await browser.close();
}
openWebsite("https://example.com");
headless: false позволяет наблюдать за процессом автоматизации во время разработки, а waitUntil: „domcontentloaded“ приводит к завершению операции после обработки HTML, вместо ожидания загрузки всех изображений и скриптов. Вся последующая структура строится на этих возможностях.
Оборачивание примитивных действий в функции с именами
Размещение кода-селекторов по всему проекту делает изменение страницы ресурсоёмким. Вместо этого небольшие функции отвечают за каждое отдельное действие, а более высокоуровневая функция объединяет их.
async function fillSearchBox(page, query) {
const searchBox = page.getByRole("textbox");
await searchBox.fill(query);
}
async function clickSearch(page) {
await page.getByRole("button", {
name: "Search"
}).click();
}
async function performSearch(page, query) {
await fillSearchBox(page, query);
await clickSearch(page);
}
Локаторы используют getByRole, который находит элементы по их доступной роли и имени. Он обычно лучше переносит изменения в структуре маркировки, чем классовые селекторы. Пользователи теперь могут выражать свои намерения, не зная деталей реализации:
await performSearch(
page,
"JavaScript automation"
);
Если форма поиска изменится, необходимо обновить только fillSearchBox и clickSearch.
Преобразование страниц в структурированные данные
В работе с необработанным HTML возникают сложности. Следующая функция выбирает все элементы .article и в контексте страницы преобразует каждый из них в обычный объект с заголовком и автором.
async function extractArticles(page) {
return await page
.locator(".article")
.evaluateAll(elements => {
return elements.map(element => {
const title = element
.querySelector(".title")
?.textContent
?.trim();
const author = element
.querySelector(".author")
?.textContent
?.trim();
return {
title,
author
};
});
});
}
evaluateAll выполняет функцию-обратный вызов в браузере для всех совпавших элементов и возвращает сериализованный результат в Node. Опциональное цепочковое обращение (?.) предотвращает возникновение ошибок при отсутствии поля .title или .author; в таком случае значение этого поля просто становится undefined. Поэтому для его использования достаточно одного вызова:
const articles = await extractArticles(page);
console.log(articles);
Как только страница преобразуется в массив объектов, её можно фильтровать, сравнивать, хранить или передавать дальше как любые другие данные.
Предоставьте инструменту память с использованием SQLite
Результаты, исчезающие после завершения процесса, имеют ограниченную пользу. Пакет sqlite3 обеспечивает базу данных на основе файлов, и приведённая ниже настройка создаёт таблицу results при первом запуске, в которой по умолчанию задаются автоинкрементирующийся ID и время создания записи.
const sqlite3 = require("sqlite3").verbose();
const db = new sqlite3.Database(
"automation.db"
);
db.run(`
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
author TEXT,
source TEXT,
created_at DATETIME
DEFAULT CURRENT_TIMESTAMP
)
`);
Сохранение строки происходит через вспомогательную функцию, которая оборачивает метод db.run, основанный на обратном вызове, в обещание, чтобы он работал с оператором await. Местоимения ? безопасно связывают значения, поэтому текст, полученный в результате скрапинга и содержащий кавычки, не может нарушить структуру SQL-запроса или попасть в него.
function saveResult(result, source) {
return new Promise((resolve, reject) => {
db.run(
`
INSERT INTO results
(title, author, source)
VALUES (?, ?, ?)
`,
[
result.title,
result.author,
source
],
error => {
if (error) {
reject(error);
return;
}
resolve();
}
);
});
}
История позволяет отслеживать изменения, выполнять сравнения, генерировать отчеты и вести журнал действий.
Избегайте хранения одинакового результата дважды
При многократных запусках часто получаются одни и те же данные. Вспомогательная функция проверки определяет, существует ли уже результат с таким же названием для данного источника.
function resultExists(result, source) {
return new Promise((resolve, reject) => {
db.get(
`
SELECT id
FROM results
WHERE title = ?
AND source = ?
LIMIT 1
`,
[
result.title,
source
],
(error, row) => {
if (error) {
reject(error);
return;
}
resolve(Boolean(row));
}
);
});
}
Затем в рабочем процессе вставляются только новые результаты:
for (const result of articles) {
const exists = await resultExists(
result,
"example.com"
);
if (!exists) {
await saveResult(
result,
"example.com"
);
}
}
Такой подход «проверить, затем вставить» подходит для одиночного последовательного процесса. Если несколько запусков могут выполнять операции одновременно, два из них могут обнаружить сообщение «не найдено» и вставить дубликаты. Более надежным решением является добавление ограничения UNIQUE для пары (title, source) и использование команды INSERT OR IGNORE, которая позволяет базе данных обеспечить атомарное соблюдение правила.
Сделать нестабильные шаги перепробовываемыми
Настоящая автоматизация менее предсказуема, чем демонстрация. Сети обрываются, страницы загружаются медленно, сервера отвечают по-разному, и элементы не всегда существуют сразу. Универсальный помощник retry выполняет асинхронную операцию определенное количество раз, ждет между попытками и снова выбрасывает последнюю ошибку, если все они провалились.
async function retry(
operation,
attempts = 3,
delay = 1500
) {
let lastError;
for (
let attempt = 1;
attempt <= attempts;
attempt++
) {
try {
return await operation();
} catch (error) {
lastError = error;
console.log(
`Attempt ${attempt} failed`
);
if (attempt < attempts) {
await new Promise(resolve => {
setTimeout(
resolve,
delay
);
});
}
}
}
throw lastError;
}
Обертка для навигации выглядит так:
await retry(
async () => {
await page.goto(
"https://example.com",
{
waitUntil:
"domcontentloaded"
}
);
},
3
);
Стоит обратить внимание на два момента. Задержка фиксирована; для сервисов, которые могут быть перегружены, обычно более значительная задержка (экспоненциальное откладывание попыток) более целесообразна. Повторные попытки имеют смысл только для операций, которые можно безопасно выполнять несколько раз: перезагрузка страницы — это нормально, в то время как повторная отправка формы заказа — нет.
Сгруппируйте рабочий процесс в класс
Благодаря нескольким вспомогательным функциям один объект может управлять страницей и предоставлять доступ к её шагам. Класс WebAutomation получает браузер, открывает страницу в методе start(), перемещается по ней с помощью метода retry, выполняет поиск и закрывает страницу.
class WebAutomation {
constructor(browser) {
this.browser = browser;
this.page = null;
}
async start() {
this.page =
await this.browser.newPage();
}
async open(url) {
await retry(async () => {
await this.page.goto(url, {
waitUntil:
"domcontentloaded"
});
});
}
async search(query) {
await this.page
.getByRole("textbox")
.fill(query);
await this.page
.getByRole("button", {
name: "Search"
})
.click();
}
async close() {
if (this.page) {
await this.page.close();
}
}
}
Код приложения тогда выглядит как короткий скрипт с описанием намерений, при этом жизненный цикл браузера остается вне его, что позволяет одному браузеру обслуживать несколько объектов автоматизации.
const browser =
await chromium.launch({
headless: true
});
const automation =
new WebAutomation(browser);
await automation.start();
await automation.open(
"https://example.com"
);
await automation.search(
"JavaScript"
);
await automation.close();
await browser.close();
В производственной среде вызовы close() следует разместить в блоке finally.
Использование в качестве инструмента командной строки
Редактирование кода для выполнения других задач неэффективно в масштабе. Библиотека Commander превращает проект в CLI с именем, описанием, версией и подкомандами. Команда visit, приведённая ниже, принимает аргумент в виде URL, открывает его без графического интерфейса и выводит заголовок страницы.
const { Command } = require("commander");
const program = new Command();
program
.name("webtool")
.description(
"Browser automation utility"
)
.version("1.0.0");
program
.command("visit")
.description(
"Visit a webpage"
)
.argument("<url>")
.action(async url => {
const browser =
await chromium.launch({
headless: true
});
const page =
await browser.newPage();
await page.goto(url);
console.log(
await page.title()
);
await browser.close();
});
program.parseAsync();
Вместо parse() используется parseAsync(), поскольку обработчик действий является асинхронным. Затем инструмент запускается из терминала:
node webtool.js visit https://example.com
На этом этапе скрипт превращается в инструмент, который могут вызывать другие пользователи или задачи cron.
Централизованная настройка
Значения, закодированные вручную, быстро распространяются. Один объект настроек объединяет параметры браузера, базы данных и результата вывода.
const config = {
browser: {
headless: true,
timeout: 30000
},
database: {
file: "automation.db"
},
output: {
directory: "results"
}
};
Код запуска затем считывает настройки из этого объекта, включая стандартный таймаут, применяемый ко всем действиям Playwright на странице:
const browser =
await chromium.launch({
headless:
config.browser.headless
});
const page =
await browser.newPage();
page.setDefaultTimeout(
config.browser.timeout
);
Хранение настроек в одном месте облегчает переход от экспериментов к приложениям, которые легко обслуживать.
Разместите слой ИИ с проверкой в начало
Традиционные инструменты предоставляют фиксированные команды:
search
open
extract
save
report
Вместо этого языковая модель может интерпретировать запрос, например, о поиске и сохранении последних статей на JavaScript с определенного сайта, и преобразовать его в структурированный объект задачи:
const task = {
action: "search",
query: "JavaScript",
saveResults: true
};
Модели никогда не следует разрешать выполнять произвольный код. Каждая создаваемая ею задача проверяется по списку разрешенных действий перед тем, как что-либо будет выполнено.
const allowedActions = new Set([
"visit",
"search",
"extract",
"save"
]);
function validateTask(task) {
if (!allowedActions.has(task.action)) {
throw new Error(
"Unsupported action"
);
}
return true;
}
Результатом такой структуры является разделение процессов интерпретации и выполнения:
Natural Language
↓
AI
↓
Structured Task
↓
Validation
↓
JavaScript Workflow
↓
Browser / Database
Модель определяет, что, по мнению пользователя, он хочет; приложение определяет, что ему на самом деле разрешено делать. На практике проверка должна выходить за рамки названия действия и также включать проверку параметров, например, того, что URL принадлежат одобренным доменам.
Для чего на самом деле предназначен этот инструмент
Пользователей волнует исчезновение утомительных задач, а не участие Playwright. Тот же движок подходит для законных целей, таких как тестирование качества и автоматизированные тесты, мониторинг сайтов, внутренние рабочие процессы, сбор данных из авторизованных источников, регулярные отчеты, управление контентом и автоматизация бизнес-процессов. С коммерческой точки зрения это может быть представлено в виде установки, настройки и постоянного обслуживания, или в виде подписки с ограничениями на использование и дополнительными функциями. Код — это движок; рабочий процесс — это продукт.
Результативная архитектура
В совокупности слои выглядят следующим образом. Cheerio появляется рядом с Playwright как более легкая альтернатива для парсинга статического HTML, когда полноценный браузер не требуется.
USER
│
▼
CLI / AI REQUEST
│
▼
TASK VALIDATOR
│
▼
WORKFLOW ENGINE
│ │
▼ ▼
PLAYWRIGHT CHEERIO
│ │
└────┬─────┘
▼
PROCESSED
DATA
│
▼
SQLITE
│
▼
REPORT / EXPORT
Заключение
Этот инструмент с самого начала не был спроектирован заранее. Он развивался постепенно, добавляя слой за слоем, каждый из которых решал конкретную проблему:
- Функции с именами, связанные с элементами интерфейса, несут в себе риски из-за изменений на сайте.
- Структурированный извлечение данных в сочетании с SQLite превращает одноразовые операции в историю действий.
- Удаление дубликатов и повторные попытки делают автоматизированные операции надежными; предпочтительнее использование ограничений базы данных и идемпотентных операций.
- CLI и централизованная настройка позволяют другим людям запускать инструмент без изменения кода.
- Интерфейс на основе ИИ безопасен только при строгой проверке каждой генерируемой задачи.
Вместо того чтобы сначала решать, какой фреймворк изучать, начните с повторяющейся проблемы, которую стоит устранить, и пусть архитектура возникнет сама собой.