Головна / Статті

Перетворення сценарію драматурга на інструмент: зберігання, повторні спроби, CLI та правила безпеки за допомогою ШІ

Як одноразовий скрипт Playwright перетворюється на інструмент Node.js, який можна використовувати багаторазово, з підтримкою історії даних у SQLite, видаленням дублікатів, повторними спробами, інтерфейсом Commander CLI, налаштуваннями та перевіреними завданнями, створеними штучним інтелектом.

Видавець: REACTAPP.TOP9 хв читання1963 слів

Джерела: 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 та центральна конфігурація дозволяють іншим запускати його без змін у коді.
  • Інтерфейс на основі ШІ є безпечним лише за умови суворої перевірки кожного створеного завдання.

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