Skip to content

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 补充默认列表外的主机

基础脚本模板

ts
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()
}

编写脚本时要注意:

  • defineMetadatadefineParams 必须是顶层静态字面量调用,不能使用变量、展开语法、函数返回值或动态计算。
  • 模块必须导出 main
  • 不要在脚本里手动调用 main,运行器会自动调用。
  • 运行时参数全部通过 main(params) 传入,避免把关键词、URL、数量、概率等每次运行都可能变化的值写死。
  • 即使脚本不需要参数,也要声明 defineParams({ type: 'object', properties: {} })

Sandbox 运行环境

RoxyBrowser 脚本运行在 vm.SourceTextModule sandbox 中。它是一个受控的 Node.js 子集,目标是让脚本可以稳定控制浏览器,同时限制对宿主系统、网络和环境变量的访问。

已注入的全局对象

脚本无需 import 即可使用以下全局能力:

全局对象说明
console日志会被转发到运行日志;console.debug 仅在 debug 模式输出
process受控的 process 子集,只暴露安全方法和白名单环境变量
fetch可用;默认已允许常见平台域名,其他主机需要权限文件放行
定时器setTimeoutclearTimeoutsetIntervalclearInterval
标准内置对象BufferURLURLSearchParamsTextEncoderTextDecoderPromiseJSONDateMapSet

XMLHttpRequest 在 sandbox 中不可用。

process 子集

process.cwd() 返回当前脚本的真实工作目录:

text
workdir/{slug}/

这个目录不是项目根目录,也不是系统根目录。脚本产生的普通文件、TableKV 数据都应优先放在这里。

可用环境变量只有:

环境变量说明
BROWSER_URL浏览器 WebSocket 连接地址,供 chromium.connect()firefox.connect() 使用
BROWSER_TYPE浏览器类型,通常是 chromium,也可能是 firefox
SANDBOX_DEBUGdebug 模式下为 1,release 模式下为 0

其他宿主环境变量不会暴露,读取结果为 undefined。不要枚举或依赖宿主系统的环境变量。

允许 import 的模块

Sandbox 对 import 做白名单限制。允许使用:

模块用途
playwright连接并控制 RoxyBrowser 提供的浏览器
fsnode:fsfs/promisesnode:fs/promises文件读写,受权限模型限制
pathnode:path路径处理
urlnode:urlURL 处理
cryptonode:crypto哈希、随机值等基础能力
buffernode:bufferBuffer 处理
streamnode:stream流处理
@roxybrowser/sandbox脚本自描述宏
@roxybrowser/sandbox/*RoxyBrowser 提供的内置能力包

不允许的模块会在运行时报错:

text
Module not allowed

常见禁止模块包括 child_processworker_threadsnethttphttpsosvmdgram 等。脚本中的页面流量应通过 Playwright 发起;如果确实需要脚本自身发起 HTTP 请求,使用受权限控制的 fetch

Sandbox 内置包

RoxyBrowser 提供了一组 @roxybrowser/sandbox/* 包。建议优先使用这些包,而不是在脚本中重复实现通用能力。

@roxybrowser/sandbox

根包提供脚本自描述宏:

ts
import { defineMetadata, defineParams } from '@roxybrowser/sandbox'
API说明
defineMetadata(metadata)声明脚本名称、描述、版本、slug、默认浏览器窗口等信息
defineParams(schema)声明 main(params) 的 JSON Schema

namedescription 以及参数 description 支持多语言对象:

ts
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)对不稳定操作做带退避的重试

常见用法:

ts
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 包提供显式的人类化鼠标、键盘和滚动操作。

ts
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.movehuman.hoverhuman.clickhuman.typehuman.scroll,避免混用 page.mouse 或 Locator 自带的 click/hover 导致位置状态失真。

@roxybrowser/sandbox/2fa

2fa 包用于处理 TOTP 两步验证码。

ts
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 包通过宿主调用模型,适合在脚本中做文本生成、分类、抽取和结构化判断。它可能更慢,也可能产生模型调用成本,应只在业务确实需要时使用。

ts
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 行存储,适合保存采集结果、执行记录、导出数据。

ts
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 文件:

text
<name>.csv

方法都返回 Promise,需要 await

API说明
append(record | record[])追加一行或多行
all()读取全部行
find(predicate)查找第一条满足条件的记录
clear()清空表

@roxybrowser/sandbox/kv

KV 是 JSON 键值存储,适合保存跨运行状态,例如已处理 ID、上次执行时间、分页游标。

ts
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) 实例对应工作目录中的一个文件:

text
<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 控制。

推荐写法是把脚本产物写入当前工作目录:

ts
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,需要在权限文件中声明可读路径:

json
{
  "version": 1,
  "fs": {
    "read": [
      "/Users/me/input/"
    ]
  }
}

注意:

  • fs.read 中的每个值会作为一个独立的 --allow-fs-read=<value> 传给 Node。
  • 建议声明目录而不是单个临时文件,便于同一目录下的输入文件复用。
  • 外部写入不支持;要写文件请写到 process.cwd() 或使用 TableKV

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.comwww.youtube.com 都可以访问。

如果脚本要访问默认列表之外的主机,可以通过权限文件继续补充。权限文件不会替换默认列表,只会扩展当前脚本允许访问的 fetch 主机:

json
{
  "version": 1,
  "fetch": {
    "allow": [
      "api.example.com",
      "*.googleapis.com"
    ]
  }
}

匹配规则:

  • api.example.com 只允许这个精确主机。
  • *.googleapis.com 允许 sheets.googleapis.comdrive.googleapis.com 等子域名。
  • 通配符只匹配子域名,不匹配根域名本身;*.example.com 不包含 example.com
  • 仅支持 HTTP(S) 请求。
  • 自动重定向被禁用。若接口返回 3xx,需要脚本检查 location,确认目标主机也已授权后再显式请求。

如果数据本来就是页面请求产生的,优先通过 Playwright 监听响应,而不是额外 fetch

ts
page.on('response', async (response) => {
  if (response.url().includes('/api/products')) {
    const data = await response.json()
    console.log('products', data.length)
  }
})

浏览器页面流量由浏览器发起,不需要 fetch.allow

权限拒绝信号

当脚本越过权限边界时,常见错误是:

text
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 allowedimport 了白名单外模块改用允许模块或 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() 命中了错误实例缩小到唯一容器后再定位控件
脚本偶发超时页面还未渲染完成或懒加载未完成等待具体元素或加载稳定信号