Разработка скриптов RoxyBrowser
Подсказка
Автоматизационные скрипты RoxyBrowser — это переиспользуемые TypeScript-скрипты на Playwright. Они выполняются в контролируемой sandbox-среде RoxyBrowser и подключаются к назначенному браузерному профилю, чтобы выполнять действия на странице, собирать данные, публиковать контент, обслуживать аккаунты и решать другие задачи автоматизации.
Что такое скрипт
Скрипт RoxyBrowser — это не обычная локальная Node.js-программа. Каждый скрипт является самоописывающимся TypeScript-файлом. Перед запуском RoxyBrowser извлекает из него метаданные, схему параметров и точку входа, а затем выполняет код внутри контролируемой VM sandbox.
Стандартный скрипт состоит из следующих частей:
| Часть | Назначение |
|---|---|
defineMetadata({...}) | Объявляет имя, описание, версию, имя рабочей директории и другие метаданные скрипта |
defineParams({...}) | Объявляет JSON Schema для параметров запуска |
export async function main(params) | Точка входа скрипта, которую вызывает sandbox |
| Логика подключения Playwright | Подключается к браузеру RoxyBrowser через process.env.BROWSER_URL |
| Необязательный файл разрешений | Разрешает чтение внешних файлов или добавляет нестандартные хосты для 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должны быть статическими вызовами на верхнем уровне файла. Нельзя использовать внутри них переменные, spread-синтаксис, вызовы вспомогательных функций или вычисляемые значения.- Модуль должен экспортировать
main. - Не вызывайте
mainвручную. Runner вызывает его автоматически. - Все значения, которые меняются от запуска к запуску, передавайте через
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 | 1 в debug-режиме, 0 в release-режиме |
Другие переменные окружения хоста не передаются и возвращают undefined. Не перебирайте и не используйте переменные окружения хоста.
Разрешенные import
Sandbox применяет allowlist для import. Разрешены следующие модули:
| Модуль | Назначение |
|---|---|
playwright | Подключение к браузеру RoxyBrowser и управление им |
fs, node:fs, fs/promises, node:fs/promises | Работа с файлами с учетом модели разрешений |
path, node:path | Работа с путями |
url, node:url | Работа с URL |
crypto, node:crypto | Хеширование, случайные значения и базовые crypto-утилиты |
buffer, node:buffer | Работа с Buffer |
stream, node:stream | Работа с потоками |
@roxybrowser/sandbox | Макросы самоописания скрипта |
@roxybrowser/sandbox/* | Встроенные пакеты возможностей sandbox RoxyBrowser |
Import за пределами allowlist завершится ошибкой во время выполнения:
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) | Объявляет JSON Schema для main(params) |
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
Пакет utilities предоставляет чистые вспомогательные функции. Он не обращается к сети, не использует секреты и сам по себе не сохраняет данные.
| API | Описание |
|---|---|
sleep(min, max) | Ждет случайное время в диапазоне [min, max] миллисекунд, удобно для естественного темпа |
randomBetween(min, max) | Возвращает случайное целое число в указанном диапазоне |
pick(arr) | Случайно выбирает один элемент массива |
maybe(probability) | Возвращает true с заданной вероятностью, например maybe(0.7) означает 70% |
chunk(arr, size) | Делит массив на пакеты фиксированного размера |
retry(fn, opts) | Повторяет нестабильные операции с backoff |
Пример:
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-актора по возможности используйте в одном сценарии 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 }>(
`Extract the product price from this text: ${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 key-value хранилище. Оно подходит для состояния между запусками: обработанные 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() | Очищает namespace |
Модель разрешений
Модель разрешений sandbox следует принципу минимальных привилегий:
- Рабочая директория
workdir/{slug}/доступна для чтения и записи по умолчанию. - Внешние файлы недоступны для чтения по умолчанию и должны быть объявлены в
fs.read. - Внешние пути недоступны для записи, даже если они указаны в файле разрешений.
fetchпо умолчанию разрешает распространенные платформенные домены. Хосты вне этого списка нужно объявлять вfetch.allow.- Import, переменные окружения и низкоуровневые сетевые модули ограничены allowlist.
Разрешения файловой системы
Скрипты могут импортировать нативные 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передается Node как отдельный аргумент--allow-fs-read=<value>. - Обычно лучше объявлять директорию, а не один временный файл.
- Запись во внешние пути не поддерживается. Записывайте файлы в
process.cwd()или используйтеTableиKV.
Разрешения fetch
Runtime предоставляет 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.
Если скрипту нужен хост вне стандартного списка, добавьте его через файл разрешений. Файл разрешений расширяет стандартный список, а не заменяет его:
{
"version": 1,
"fetch": {
"allow": [
"api.example.com",
"*.googleapis.com"
]
}
}Правила сопоставления:
api.example.comразрешает только этот точный хост.*.googleapis.comразрешает поддомены вродеsheets.googleapis.comиdrive.googleapis.com.- Wildcard совпадает только с поддоменами, но не с apex-доменом.
*.example.comне включаетexample.com. - Поддерживаются только HTTP(S)-запросы.
- Автоматические редиректы отключены. Если endpoint возвращает 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 | Проверьте, есть ли хост запроса и каждый хост редиректа в стандартном allowlist или 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 через хост и сохраняется в browser artifact зоне рабочей директории скрипта. Оставляйте пути скриншотов относительными. Не собирайте host-only директории Playwright вручную.
Распространенные ошибки
| Ошибка | Причина | Как исправить |
|---|---|---|
Module not allowed | Импортирован модуль вне allowlist | Используйте разрешенный модуль или sandbox-пакет |
Access to this API has been restricted. | Не хватает разрешений для файла или fetch | Проверьте fs.read, стандартный fetch allowlist, fetch.allow или путь записи |
defineMetadata не разбирается | Метаданные не являются статическим объектом на верхнем уровне | Уберите динамические переменные, spread-синтаксис и helper-вызовы из аргументов макроса |
defineParams не разбирается | Схема параметров не является статическим объектом на верхнем уровне | Используйте полную статическую JSON Schema |
main не найден | Скрипт не экспортирует точку входа | Используйте export async function main(...) |
| Клик по неправильному элементу | Locator слишком широкий или .first() выбрал не тот экземпляр | Сначала сузьте поиск до уникального контейнера, затем найдите control внутри него |
| Скрипт периодически уходит в timeout | Страница еще не отрендерилась или lazy loading не завершился | Ждите конкретный элемент или сигнал стабильной загрузки |
