RoxyBrowser 脚本开发
提示
RoxyBrowser 自动化脚本是可复用的 Playwright TypeScript 脚本。脚本运行在 RoxyBrowser 提供的受控 sandbox 中,通过连接指定浏览器窗口完成页面操作、数据采集、内容发布、账号维护等任务。
脚本是什么
RoxyBrowser 脚本不是普通的本地 Node.js 程序。每个脚本都是一个自描述 TypeScript 文件,在运行前会被系统解析出脚本信息、参数结构和运行入口,再放入受控 VM sandbox 中执行。
一个标准脚本包含以下部分:
| 部分 | 作用 |
|---|---|
defineMetadata({...}) | 声明脚本名称、描述、版本、运行目录名等元信息 |
defineParams({...}) | 声明运行参数的 JSON Schema |
export async function main(params) | 脚本入口,sandbox 会自动调用 |
| Playwright 连接逻辑 | 通过 process.env.BROWSER_URL 连接 RoxyBrowser 提供的浏览器 |
| 可选权限文件 | 允许读取外部文件,或为 fetch 补充默认列表外的主机 |
基础脚本模板
import { chromium, firefox } from 'playwright'
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'
import { sleep } from '@roxybrowser/sandbox/utils'
interface ScriptParams {
keyword: string
}
defineMetadata({
name: '搜索关键词',
description: '打开当前浏览器窗口并搜索指定关键词',
version: '1.0.0',
slug: 'search-keyword',
})
defineParams({
type: 'object',
properties: {
keyword: {
type: 'string',
description: '要搜索的关键词',
},
},
required: ['keyword'],
})
export async function main(params: ScriptParams): Promise<void> {
const browserUrl = process.env.BROWSER_URL ?? ''
const browserType = process.env.BROWSER_TYPE ?? 'chromium'
const browser = browserType === 'firefox'
? await firefox.connect(browserUrl)
: await chromium.connect(browserUrl)
const context = browser.contexts()[0] ?? await browser.newContext()
const page = context.pages()[0] ?? await context.newPage()
await page.goto('https://www.google.com/search?q=' + encodeURIComponent(params.keyword))
await sleep(1000, 2000)
await browser.close()
}编写脚本时要注意:
defineMetadata和defineParams必须是顶层静态字面量调用,不能使用变量、展开语法、函数返回值或动态计算。- 模块必须导出
main。 - 不要在脚本里手动调用
main,运行器会自动调用。 - 运行时参数全部通过
main(params)传入,避免把关键词、URL、数量、概率等每次运行都可能变化的值写死。 - 即使脚本不需要参数,也要声明
defineParams({ type: 'object', properties: {} })。
Sandbox 运行环境
RoxyBrowser 脚本运行在 vm.SourceTextModule sandbox 中。它是一个受控的 Node.js 子集,目标是让脚本可以稳定控制浏览器,同时限制对宿主系统、网络和环境变量的访问。
已注入的全局对象
脚本无需 import 即可使用以下全局能力:
| 全局对象 | 说明 |
|---|---|
console | 日志会被转发到运行日志;console.debug 仅在 debug 模式输出 |
process | 受控的 process 子集,只暴露安全方法和白名单环境变量 |
fetch | 可用;默认已允许常见平台域名,其他主机需要权限文件放行 |
| 定时器 | setTimeout、clearTimeout、setInterval、clearInterval |
| 标准内置对象 | Buffer、URL、URLSearchParams、TextEncoder、TextDecoder、Promise、JSON、Date、Map、Set 等 |
XMLHttpRequest 在 sandbox 中不可用。
process 子集
process.cwd() 返回当前脚本的真实工作目录:
workdir/{slug}/这个目录不是项目根目录,也不是系统根目录。脚本产生的普通文件、Table、KV 数据都应优先放在这里。
可用环境变量只有:
| 环境变量 | 说明 |
|---|---|
BROWSER_URL | 浏览器 WebSocket 连接地址,供 chromium.connect() 或 firefox.connect() 使用 |
BROWSER_TYPE | 浏览器类型,通常是 chromium,也可能是 firefox |
SANDBOX_DEBUG | debug 模式下为 1,release 模式下为 0 |
其他宿主环境变量不会暴露,读取结果为 undefined。不要枚举或依赖宿主系统的环境变量。
允许 import 的模块
Sandbox 对 import 做白名单限制。允许使用:
| 模块 | 用途 |
|---|---|
playwright | 连接并控制 RoxyBrowser 提供的浏览器 |
fs、node:fs、fs/promises、node:fs/promises | 文件读写,受权限模型限制 |
path、node:path | 路径处理 |
url、node:url | URL 处理 |
crypto、node:crypto | 哈希、随机值等基础能力 |
buffer、node:buffer | Buffer 处理 |
stream、node:stream | 流处理 |
@roxybrowser/sandbox | 脚本自描述宏 |
@roxybrowser/sandbox/* | RoxyBrowser 提供的内置能力包 |
不允许的模块会在运行时报错:
Module not allowed常见禁止模块包括 child_process、worker_threads、net、http、https、os、vm、dgram 等。脚本中的页面流量应通过 Playwright 发起;如果确实需要脚本自身发起 HTTP 请求,使用受权限控制的 fetch。
Sandbox 内置包
RoxyBrowser 提供了一组 @roxybrowser/sandbox/* 包。建议优先使用这些包,而不是在脚本中重复实现通用能力。
@roxybrowser/sandbox
根包提供脚本自描述宏:
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'| API | 说明 |
|---|---|
defineMetadata(metadata) | 声明脚本名称、描述、版本、slug、默认浏览器窗口等信息 |
defineParams(schema) | 声明 main(params) 的 JSON Schema |
name、description 以及参数 description 支持多语言对象:
defineMetadata({
name: {
zh: '采集商品价格',
us: 'Scrape product prices',
ru: 'Сбор цен товаров',
},
description: {
zh: '从商品列表页采集标题和价格',
us: 'Scrape titles and prices from a product listing page',
ru: 'Собирает названия и цены со страницы списка товаров',
},
})@roxybrowser/sandbox/utils
工具函数包是纯函数能力,不访问网络、不依赖密钥、不会额外落盘。
| API | 说明 |
|---|---|
sleep(min, max) | 随机等待 [min, max] 毫秒,比固定等待更适合模拟真实节奏 |
randomBetween(min, max) | 返回指定范围内的随机整数 |
pick(arr) | 从数组中随机选择一项 |
maybe(probability) | 按概率返回 true,如 maybe(0.7) 表示 70% 概率 |
chunk(arr, size) | 将数组切成固定大小的批次 |
retry(fn, opts) | 对不稳定操作做带退避的重试 |
常见用法:
import { maybe, retry, sleep } from '@roxybrowser/sandbox/utils'
if (maybe(0.7)) {
await page.getByRole('button', { name: 'Like' }).click()
}
await retry(
() => page.locator('[data-testid="result"]').waitFor({ state: 'visible' }),
{ attempts: 3, baseDelay: 500 },
)
await sleep(1000, 3000)@roxybrowser/sandbox/human
human 包提供显式的人类化鼠标、键盘和滚动操作。
import { createHuman } from '@roxybrowser/sandbox/human'
const human = createHuman(page)
await human.click({
target: page.getByRole('button', { name: 'Submit' }),
motion: {
duration: 600,
curve: 'ease-in-out',
path: { type: 'bezier', curvature: 40 },
},
settleDuration: 300,
holdDuration: 80,
})createHuman(page) 会维护自己的鼠标位置状态。创建后,建议同一段流程都使用 human.move、human.hover、human.click、human.type、human.scroll,避免混用 page.mouse 或 Locator 自带的 click/hover 导致位置状态失真。
@roxybrowser/sandbox/2fa
2fa 包用于处理 TOTP 两步验证码。
import { parse2FA, totp } from '@roxybrowser/sandbox/2fa'
const code = totp(params.twoFactorSecret)
const detail = parse2FA(params.twoFactorSecret)
console.log(`2FA code expires in ${detail.secondsRemaining}s`)| API | 说明 |
|---|---|
totp(input, options?) | 直接返回当前验证码 |
parse2FA(input, options?) | 返回验证码、剩余秒数、过期时间、位数、算法等完整信息 |
@roxybrowser/sandbox/llm
llm 包通过宿主调用模型,适合在脚本中做文本生成、分类、抽取和结构化判断。它可能更慢,也可能产生模型调用成本,应只在业务确实需要时使用。
import { json } from '@roxybrowser/sandbox/llm'
const result = await json<{ price: number }>(
`从下面文本中提取商品价格:${text}`,
{
type: 'object',
properties: {
price: { type: 'number' },
},
required: ['price'],
},
{ temperature: 0, maxTokens: 200 },
)| API | 说明 |
|---|---|
ask(prompt, opts?) | 返回模型生成的纯文本 |
json(prompt, schema, opts?) | 按 JSON Schema 返回结构化对象 |
@roxybrowser/sandbox/table
Table 是 CSV 行存储,适合保存采集结果、执行记录、导出数据。
import { Table } from '@roxybrowser/sandbox/table'
const records = new Table('product-records')
await records.append({
title,
price,
url: page.url(),
capturedAt: Date.now(),
})
const rows = await records.all()一个 new Table(name) 实例对应工作目录中的一个 CSV 文件:
<name>.csv方法都返回 Promise,需要 await:
| API | 说明 |
|---|---|
append(record | record[]) | 追加一行或多行 |
all() | 读取全部行 |
find(predicate) | 查找第一条满足条件的记录 |
clear() | 清空表 |
@roxybrowser/sandbox/kv
KV 是 JSON 键值存储,适合保存跨运行状态,例如已处理 ID、上次执行时间、分页游标。
import { KV } from '@roxybrowser/sandbox/kv'
const state = new KV('crawler-state')
if (await state.has(productId)) {
console.log('skip processed product')
return
}
await state.set(productId, {
processedAt: Date.now(),
url: page.url(),
})一个 new KV(name) 实例对应工作目录中的一个文件:
<name>.kv.json| API | 说明 |
|---|---|
get(key) | 读取一个键,不存在返回 undefined |
set(key, value) | 写入 JSON 可序列化的值 |
has(key) | 判断键是否存在 |
delete(key) | 删除键 |
keys() | 读取所有键 |
all() | 读取完整快照 |
clear() | 清空命名空间 |
权限模型
Sandbox 的权限模型遵循最小权限原则:
- 工作目录
workdir/{slug}/默认可读写。 - 外部文件默认不可读,必须在权限文件的
fs.read中声明。 - 外部路径不可写,即使写入权限文件也不会放行。
fetch默认允许常见平台域名;访问默认列表外的主机时,必须在权限文件的fetch.allow中声明。- import、环境变量和底层网络模块都有白名单限制。
文件系统权限
脚本可以 import 原生 fs / node:fs / fs/promises / node:fs/promises。这些模块的方法不会被 RoxyBrowser 重写,实际访问边界由 Node permission flags 控制。
推荐写法是把脚本产物写入当前工作目录:
import { writeFile } from 'node:fs/promises'
import path from 'node:path'
const outputPath = path.join(process.cwd(), 'result.json')
await writeFile(outputPath, JSON.stringify({ ok: true }, null, 2), 'utf8')如果需要读取外部输入文件,例如 /Users/me/input/accounts.csv,需要在权限文件中声明可读路径:
{
"version": 1,
"fs": {
"read": [
"/Users/me/input/"
]
}
}注意:
fs.read中的每个值会作为一个独立的--allow-fs-read=<value>传给 Node。- 建议声明目录而不是单个临时文件,便于同一目录下的输入文件复用。
- 外部写入不支持;要写文件请写到
process.cwd()或使用Table、KV。
fetch 权限
脚本内置 fetch,并默认放行一批常见平台域名。默认列表覆盖常见跨境电商、社交媒体和内容平台,例如 Alibaba、AliExpress、Amazon、eBay、Etsy、Lazada、Mercado Libre、Rakuten、Shopee、Shopify、Walmart、Temu、TikTok Shop、Meta、TikTok、YouTube、X/Twitter、Reddit、LinkedIn、Pinterest、Telegram、Discord 等。
默认列表中的域名会同时匹配根域名和子域名。例如默认允许 youtube.com 时,youtube.com 和 www.youtube.com 都可以访问。
如果脚本要访问默认列表之外的主机,可以通过权限文件继续补充。权限文件不会替换默认列表,只会扩展当前脚本允许访问的 fetch 主机:
{
"version": 1,
"fetch": {
"allow": [
"api.example.com",
"*.googleapis.com"
]
}
}匹配规则:
api.example.com只允许这个精确主机。*.googleapis.com允许sheets.googleapis.com、drive.googleapis.com等子域名。- 通配符只匹配子域名,不匹配根域名本身;
*.example.com不包含example.com。 - 仅支持 HTTP(S) 请求。
- 自动重定向被禁用。若接口返回 3xx,需要脚本检查
location,确认目标主机也已授权后再显式请求。
如果数据本来就是页面请求产生的,优先通过 Playwright 监听响应,而不是额外 fetch:
page.on('response', async (response) => {
if (response.url().includes('/api/products')) {
const data = await response.json()
console.log('products', data.length)
}
})浏览器页面流量由浏览器发起,不需要 fetch.allow。
权限拒绝信号
当脚本越过权限边界时,常见错误是:
Access to this API has been restricted.遇到这个错误时,先按失败操作判断是哪类权限问题:
| 失败操作 | 排查方向 |
|---|---|
| 读取文件失败 | 确认路径是否在 process.cwd() 下,或是否被 fs.read 覆盖 |
| 写入文件失败 | 确认是否写到了外部目录;外部写入不能授权 |
fetch 失败 | 确认请求主机和每一次重定向目标主机是否在默认允许列表或 fetch.allow 中 |
不要把权限拒绝当作页面逻辑错误。先修正路径或权限文件,再重新运行。
输出、持久化和调试
不同类型的输出建议放到不同位置:
| 类型 | 推荐方式 |
|---|---|
| 运行进度 | console.log / console.info |
| 调试信息 | console.debug |
| 失败原因 | console.error,并包含足够定位问题的上下文 |
| 采集结果 | Table |
| 跨运行状态 | KV |
| 任意文件产物 | 写入 process.cwd() |
| 页面截图 | page.screenshot({ path: 'name.png' }),路径保持相对 |
page.screenshot({ path: 'result.png' }) 的路径由 Playwright 交给宿主处理,会保存到脚本工作目录下的浏览器 artifact 区域。截图路径建议保持相对路径,不要手动拼接宿主侧的 Playwright 目录。
常见错误
| 错误 | 原因 | 处理方式 |
|---|---|---|
Module not allowed | import 了白名单外模块 | 改用允许模块或 sandbox 内置包 |
Access to this API has been restricted. | 文件或 fetch 权限不足 | 检查 fs.read、默认 fetch 允许列表、fetch.allow 或写入路径 |
defineMetadata 无法解析 | 元信息不是顶层静态字面量 | 把动态变量、展开语法和函数调用移出宏参数 |
defineParams 无法解析 | 参数 schema 不是顶层静态字面量 | 使用完整静态 JSON Schema |
找不到 main | 脚本没有导出入口 | 使用 export async function main(...) |
| 点击了错误元素 | 定位器过宽或 .first() 命中了错误实例 | 缩小到唯一容器后再定位控件 |
| 脚本偶发超时 | 页面还未渲染完成或懒加载未完成 | 等待具体元素或加载稳定信号 |
