首页 / 文章

将剧作家脚本发展成实用工具:存储、重试机制、命令行界面以及人工智能约束机制

一个一次性的 Playwright 脚本如何演变为具备 SQLite 历史记录、去重功能、重试机制、Commander CLI 接口、配置选项以及经过验证的 AI 生成任务的可用 Node.js 工具。

发布者:REACTAPP.TOP阅读约 9 分钟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封装在Promise中,以便与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"
        );
    }
}

这种先检查再插入的方式适用于单一的顺序处理流程。但如果有多个进程可能同时写入,其中两个进程都可能会看到“未找到”的结果并插入重复数据。更稳妥的做法是在(title, source)上添加UNIQUE约束,并使用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();

由于操作处理是异步的,因此使用了 parseAsync() 而非 parse()。之后可通过终端运行该工具:

node webtool.js visit https://example.com

至此,该脚本已变成可供他人或定时任务调用的工具。

集中管理配置

硬编码的值很容易扩散。通过单个配置对象即可统一管理浏览器、数据库及输出相关设置。

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
);

将设置集中存储在一处,有助于更轻松地从实验阶段过渡到可维护的应用程序。

在前面添加经过验证的AI层

传统工具会提供固定的命令:

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这一工具。同一引擎也可用于各种合法场景,如质量检测与自动化测试、网站监控、内部工作流程处理、从授权来源收集数据、生成定期报告、内容管理以及业务流程自动化。在商业层面,它可以被打包为包含安装、定制及持续维护服务的方案,或是带有使用限制和高级功能的订阅服务。代码是引擎,而工作流程则是最终产品。

最终的架构

将这些层次组合起来后,其结构如下。在不需要完整浏览器的情况下解析静态 HTML 时,Cheerio 作为 Playwright 的轻量级替代方案出现。

USER
                      │
                      ▼
              CLI / AI REQUEST
                      │
                      ▼
                TASK VALIDATOR
                      │
                      ▼
               WORKFLOW ENGINE
                 │          │
                 ▼          ▼
             PLAYWRIGHT   CHEERIO
                 │          │
                 └────┬─────┘
                      ▼
                  PROCESSED
                    DATA
                      │
                      ▼
                   SQLITE
                      │
                      ▼
              REPORT / EXPORT

总结

该工具并非从一开始就设计好的,而是逐层发展起来的,每一层都是为了解决特定的问题:

  • 围绕定位器设计的命名函数会带来网站变更时的维护成本。
  • 结构化的数据提取与 SQLite 的结合使得一次性运行能够被记录下来。
  • 去重与重试机制让无人值守的重复运行更加可靠;应优先使用数据库约束和可重放操作。
  • 命令行界面与集中式配置让他人无需修改代码即可使用该工具。
  • 只有在对每个生成的任务进行严格验证之后,AI 前端才能保证安全。

不必先考虑从哪个框架开始学习,而应从一个值得解决的重复性问题入手,让架构随之形成。