mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Add browser automation skill with REPL exploration and flow saving
- Interactive REPL mode for exploring pages via Playwright code chunks - Chunk recording system for capturing successful interactions - Flow saving with curation (select which chunks to keep) - Flow chaining (run saved flows as chunks in exploration) - Session folder structure for organizing screenshots - One-shot page inspection command Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Flow: browse-to-model
|
||||
* Generated: 2026-01-16T06:05:59.839Z
|
||||
* Start URL: https://civitai.com
|
||||
*/
|
||||
|
||||
// --- Click models link ---
|
||||
await page.click('a[href="/models"]'); await page.waitForSelector('a[href^="/models/"]');
|
||||
|
||||
// --- Click first model ---
|
||||
await page.click('a[href^="/models/"]'); await page.waitForSelector('h1');
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
name: browser-automation
|
||||
description: Run saved browser automation flows or explore pages interactively. Use for UI testing, navigation discovery, or automating browser tasks. Flows are reusable Playwright scripts that you build through exploration.
|
||||
---
|
||||
|
||||
# Browser Automation Skill
|
||||
|
||||
Explore pages interactively and save successful paths as reusable flows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Explore a Page (Interactive REPL)
|
||||
```bash
|
||||
node .claude/skills/browser-automation/runner.mjs --explore https://civitai.com
|
||||
```
|
||||
Browser opens. Send JSON commands via stdin to interact:
|
||||
```json
|
||||
{"cmd": "inspect"}
|
||||
{"cmd": "chunk", "label": "Click models", "code": "await page.click('a[href=\"/models\"]');"}
|
||||
{"cmd": "review"}
|
||||
{"cmd": "save", "name": "my-flow", "keep": [1, 2]}
|
||||
{"cmd": "exit"}
|
||||
```
|
||||
|
||||
### Run a Saved Flow
|
||||
```bash
|
||||
node .claude/skills/browser-automation/runner.mjs --run-flow my-flow
|
||||
```
|
||||
|
||||
### List Saved Flows
|
||||
```bash
|
||||
node .claude/skills/browser-automation/runner.mjs --list-flows
|
||||
```
|
||||
|
||||
## Exploration Workflow
|
||||
|
||||
The core workflow is: **Explore → Record → Curate → Save → Replay**
|
||||
|
||||
### 1. Start Exploration
|
||||
```bash
|
||||
node runner.mjs --explore https://example.com
|
||||
```
|
||||
- Browser opens at the URL
|
||||
- Returns `session_started` with page inspection (buttons, links, inputs, screenshot)
|
||||
|
||||
### 2. Execute Code Chunks
|
||||
```json
|
||||
{"cmd": "chunk", "label": "Click login button", "code": "await page.click('button.login');"}
|
||||
```
|
||||
- Executes the Playwright code against the page
|
||||
- Records the chunk with its label
|
||||
- Returns `chunk_executed` with new page inspection
|
||||
|
||||
### 3. Run Existing Flows (Chaining)
|
||||
```json
|
||||
{"cmd": "list-flows"}
|
||||
{"cmd": "flow", "name": "browse-to-model"}
|
||||
```
|
||||
- Runs a saved flow as a chunk
|
||||
- Flow's code gets inlined into the recording
|
||||
- Enables building on top of existing flows
|
||||
|
||||
### 4. Review & Curate
|
||||
```json
|
||||
{"cmd": "review"}
|
||||
```
|
||||
Returns all recorded chunks:
|
||||
```json
|
||||
{
|
||||
"type": "review",
|
||||
"chunks": [
|
||||
{"index": 1, "label": "[flow: browse-to-model]", "code": "await page.click(...)..."},
|
||||
{"index": 2, "label": "Click download", "code": "await page.click('button.download');"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Save Selected Chunks
|
||||
```json
|
||||
{"cmd": "save", "name": "download-model", "keep": [1, 2]}
|
||||
```
|
||||
- Concatenates selected chunks into a `.js` file
|
||||
- Saves to `.browser/flows/download-model.js`
|
||||
- The saved flow is self-contained (no dependencies)
|
||||
|
||||
### 6. Exit
|
||||
```json
|
||||
{"cmd": "exit"}
|
||||
```
|
||||
|
||||
## REPL Commands Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `{"cmd": "inspect"}` | Get current page state (buttons, links, inputs, screenshot) |
|
||||
| `{"cmd": "chunk", "label": "...", "code": "..."}` | Execute Playwright code and record it |
|
||||
| `{"cmd": "list-flows"}` | List available saved flows |
|
||||
| `{"cmd": "flow", "name": "..."}` | Run a saved flow as a chunk |
|
||||
| `{"cmd": "review"}` | Show all recorded chunks |
|
||||
| `{"cmd": "save", "name": "...", "keep": [1,2,3]}` | Save selected chunks as a flow |
|
||||
| `{"cmd": "exit"}` | Close browser and end session |
|
||||
|
||||
## Writing Playwright Code
|
||||
|
||||
Chunks execute in an async context with `page` available:
|
||||
|
||||
```javascript
|
||||
// Click elements
|
||||
await page.click('button.submit');
|
||||
await page.click('a[href="/models"]');
|
||||
|
||||
// Type text
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
|
||||
// Wait for elements
|
||||
await page.waitForSelector('h1');
|
||||
await page.waitForSelector('.loading', { state: 'hidden' });
|
||||
|
||||
// Extract data
|
||||
const title = await page.textContent('h1');
|
||||
console.log('Title:', title);
|
||||
|
||||
// Take screenshots
|
||||
await page.screenshot({ path: '/tmp/screenshot.png' });
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com');
|
||||
```
|
||||
|
||||
## Flow Chaining Example
|
||||
|
||||
Build complex flows by composing simpler ones:
|
||||
|
||||
```bash
|
||||
# Session 1: Create browse-to-model flow
|
||||
node runner.mjs --explore https://civitai.com
|
||||
```
|
||||
```json
|
||||
{"cmd": "chunk", "label": "Click models", "code": "await page.click('a[href=\"/models\"]'); await page.waitForSelector('a[href^=\"/models/\"]');"}
|
||||
{"cmd": "chunk", "label": "Click first model", "code": "await page.click('a[href^=\"/models/\"]'); await page.waitForSelector('h1');"}
|
||||
{"cmd": "save", "name": "browse-to-model", "keep": [1, 2]}
|
||||
{"cmd": "exit"}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Session 2: Build on browse-to-model to create download-model flow
|
||||
node runner.mjs --explore https://civitai.com
|
||||
```
|
||||
```json
|
||||
{"cmd": "flow", "name": "browse-to-model"}
|
||||
{"cmd": "chunk", "label": "Click download", "code": "await page.click('button:has-text(\"Download\")');"}
|
||||
{"cmd": "save", "name": "download-model", "keep": [1, 2]}
|
||||
{"cmd": "exit"}
|
||||
```
|
||||
|
||||
Now `download-model` is self-contained with all the code inlined.
|
||||
|
||||
## When Flows Fail
|
||||
|
||||
If a chunk or flow fails during exploration, you get:
|
||||
- Error message
|
||||
- Current page inspection (screenshot, buttons, links, inputs)
|
||||
|
||||
This lets you see what's actually on the page and adjust your code.
|
||||
|
||||
## One-Shot Inspection (No Session)
|
||||
|
||||
For quick page inspection without a full session:
|
||||
```bash
|
||||
node runner.mjs --inspect https://example.com
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```bash
|
||||
# Exploration (interactive REPL)
|
||||
node runner.mjs --explore <url>
|
||||
|
||||
# Run saved flow
|
||||
node runner.mjs --run-flow <name>
|
||||
|
||||
# List flows
|
||||
node runner.mjs --list-flows
|
||||
|
||||
# One-shot inspect
|
||||
node runner.mjs --inspect <url>
|
||||
|
||||
# Options
|
||||
--headless Run browser without visible window
|
||||
--timeout <ms> Default timeout (default: 30000)
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
- **Saved flows**: `.browser/flows/*.js`
|
||||
- **Session folders**: `.browser/sessions/{session-id}/`
|
||||
- `session.json` - Session metadata
|
||||
- `screenshots/` - All screenshots from the session
|
||||
- `001-session-start.png`
|
||||
- `002-chunk-click-models.png`
|
||||
- `003-flow-browse-to-model.png`
|
||||
- etc.
|
||||
|
||||
## Advanced Playwright Code
|
||||
|
||||
Since chunks execute arbitrary Playwright code, you can do anything Playwright supports:
|
||||
|
||||
```javascript
|
||||
// Resize viewport
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
|
||||
// Listen to console
|
||||
page.on('console', msg => console.log('CONSOLE:', msg.text()));
|
||||
|
||||
// Get page HTML
|
||||
const html = await page.content();
|
||||
|
||||
// Execute JavaScript in the page
|
||||
const result = await page.evaluate(() => document.title);
|
||||
|
||||
// Wait for network idle
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Handle dialogs
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
```
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Page Inspector
|
||||
*
|
||||
* Provides page inspection capabilities for AI agents to explore
|
||||
* and build recipes interactively.
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { createContextCollector } from './context.mjs';
|
||||
|
||||
/**
|
||||
* Inspect a page and return structured data about what's visible
|
||||
* @param {Page} page - Playwright page
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.screenshotPath - Path to save screenshot
|
||||
* @returns {Object} Page inspection data
|
||||
*/
|
||||
export async function inspectPage(page, options = {}) {
|
||||
const screenshot = options.screenshotPath || `/tmp/inspect-${Date.now()}.png`;
|
||||
await page.screenshot({ path: screenshot, fullPage: false }); // Viewport only for speed
|
||||
|
||||
const inspection = await page.evaluate(() => {
|
||||
// Helper to generate a usable selector
|
||||
function getSelector(el) {
|
||||
if (el.id) return `#${el.id}`;
|
||||
if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
|
||||
if (el.name) return `[name="${el.name}"]`;
|
||||
|
||||
// For links, prefer href-based selector
|
||||
if (el.tagName === 'A' && el.getAttribute('href')) {
|
||||
const href = el.getAttribute('href');
|
||||
if (href && !href.startsWith('javascript:') && href !== '#') {
|
||||
return `a[href='${href}']`;
|
||||
}
|
||||
}
|
||||
|
||||
// For buttons with text
|
||||
if (el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') {
|
||||
const text = el.textContent?.trim();
|
||||
if (text && text.length < 30) {
|
||||
return `button:has-text('${text.replace(/'/g, "\\'")}')`;
|
||||
}
|
||||
}
|
||||
|
||||
if (el.className && typeof el.className === 'string') {
|
||||
const classes = el.className.trim().split(/\s+/).slice(0, 2).join('.');
|
||||
if (classes) return `${el.tagName.toLowerCase()}.${classes}`;
|
||||
}
|
||||
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
if (!el.offsetParent && el.tagName !== 'BODY') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return false;
|
||||
const style = window.getComputedStyle(el);
|
||||
return style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0';
|
||||
}
|
||||
|
||||
function isInViewport(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.top < window.innerHeight && rect.bottom > 0 &&
|
||||
rect.left < window.innerWidth && rect.right > 0;
|
||||
}
|
||||
|
||||
// Get clickable elements (buttons, links)
|
||||
const clickable = [];
|
||||
const seen = new Set();
|
||||
|
||||
// Buttons
|
||||
document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach(el => {
|
||||
if (!isVisible(el)) return;
|
||||
const text = el.textContent?.trim() || el.value || '';
|
||||
const selector = getSelector(el);
|
||||
const key = `${selector}-${text}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
clickable.push({
|
||||
type: 'button',
|
||||
text: text.substring(0, 50),
|
||||
selector,
|
||||
inViewport: isInViewport(el),
|
||||
enabled: !el.disabled,
|
||||
});
|
||||
});
|
||||
|
||||
// Links (separate from buttons to avoid dedup issues)
|
||||
const links = [];
|
||||
document.querySelectorAll('a[href]').forEach(el => {
|
||||
if (!isVisible(el)) return;
|
||||
const href = el.getAttribute('href');
|
||||
if (!href || href === '#' || href.startsWith('javascript:')) return;
|
||||
|
||||
const text = el.textContent?.trim() || '';
|
||||
// Skip if no meaningful text or href
|
||||
if (!text && href.startsWith('/')) return;
|
||||
|
||||
const selector = getSelector(el);
|
||||
|
||||
links.push({
|
||||
text: text.substring(0, 50),
|
||||
href: href.substring(0, 100),
|
||||
selector,
|
||||
inViewport: isInViewport(el),
|
||||
});
|
||||
});
|
||||
|
||||
// Form inputs
|
||||
const inputs = [];
|
||||
document.querySelectorAll('input, textarea, select').forEach(el => {
|
||||
if (!isVisible(el) || el.type === 'hidden') return;
|
||||
|
||||
inputs.push({
|
||||
type: el.type || el.tagName.toLowerCase(),
|
||||
name: el.name || el.id || '',
|
||||
selector: getSelector(el),
|
||||
placeholder: el.placeholder || '',
|
||||
value: el.type === 'password' ? '(password)' : (el.value?.substring(0, 50) || ''),
|
||||
required: el.required,
|
||||
inViewport: isInViewport(el),
|
||||
});
|
||||
});
|
||||
|
||||
// Text content for context
|
||||
const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
|
||||
.filter(isVisible)
|
||||
.slice(0, 5)
|
||||
.map(el => el.textContent?.trim().substring(0, 100));
|
||||
|
||||
// Error/alert messages
|
||||
const alerts = Array.from(document.querySelectorAll('[role="alert"], .error, .alert, .error-message'))
|
||||
.filter(isVisible)
|
||||
.filter(el => el.textContent?.trim())
|
||||
.slice(0, 3)
|
||||
.map(el => el.textContent?.trim().substring(0, 200));
|
||||
|
||||
return {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
headings,
|
||||
alerts,
|
||||
buttons: clickable.slice(0, 20), // Limit for readability
|
||||
links: links.slice(0, 20),
|
||||
inputs: inputs.slice(0, 15),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...inspection,
|
||||
screenshot,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive browser session for exploration
|
||||
*/
|
||||
export class BrowserSession {
|
||||
constructor(options = {}) {
|
||||
this.browser = null;
|
||||
this.context = null;
|
||||
this.page = null;
|
||||
this.collector = null;
|
||||
this.headless = options.headless ?? false;
|
||||
this.slowMo = options.slowMo ?? 0;
|
||||
this.actions = []; // Record actions for recipe building
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.browser = await chromium.launch({
|
||||
headless: this.headless,
|
||||
slowMo: this.slowMo,
|
||||
});
|
||||
this.context = await this.browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
this.page = await this.context.newPage();
|
||||
this.page.setDefaultTimeout(30000);
|
||||
this.collector = createContextCollector(this.page);
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
async navigate(url) {
|
||||
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
// Wait a bit for JS to render
|
||||
await this.page.waitForTimeout(2000);
|
||||
this.actions.push({ action: 'navigate', url });
|
||||
return this.inspect();
|
||||
}
|
||||
|
||||
async click(selector) {
|
||||
try {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
|
||||
|
||||
// Check if link and wait for navigation
|
||||
const isLink = await this.page.evaluate(sel => {
|
||||
const el = document.querySelector(sel);
|
||||
return el?.tagName === 'A' && el?.href;
|
||||
}, selector);
|
||||
|
||||
if (isLink) {
|
||||
await Promise.all([
|
||||
this.page.waitForNavigation({ waitUntil: 'networkidle' }).catch(() => {}),
|
||||
this.page.click(selector)
|
||||
]);
|
||||
} else {
|
||||
await this.page.click(selector);
|
||||
await this.page.waitForTimeout(500); // Wait for any JS
|
||||
}
|
||||
|
||||
this.actions.push({ action: 'click', selector });
|
||||
return { success: true, inspection: await this.inspect() };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message, inspection: await this.inspect() };
|
||||
}
|
||||
}
|
||||
|
||||
async type(selector, text) {
|
||||
try {
|
||||
await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
|
||||
await this.page.fill(selector, '');
|
||||
await this.page.fill(selector, text);
|
||||
this.actions.push({ action: 'type', selector, text });
|
||||
return { success: true, inspection: await this.inspect() };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message, inspection: await this.inspect() };
|
||||
}
|
||||
}
|
||||
|
||||
async inspect() {
|
||||
return inspectPage(this.page);
|
||||
}
|
||||
|
||||
async screenshot(name) {
|
||||
const path = `/tmp/${name || 'screenshot'}-${Date.now()}.png`;
|
||||
await this.page.screenshot({ path, fullPage: true });
|
||||
return path;
|
||||
}
|
||||
|
||||
getRecordedActions() {
|
||||
return this.actions;
|
||||
}
|
||||
|
||||
generateRecipe(name, description) {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
steps: this.actions.map(action => {
|
||||
switch (action.action) {
|
||||
case 'navigate':
|
||||
return { navigate: action.url };
|
||||
case 'click':
|
||||
return { click: action.selector };
|
||||
case 'type':
|
||||
return { type: { selector: action.selector, text: action.text } };
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot page inspection (opens browser, inspects, closes)
|
||||
*/
|
||||
export async function inspectUrl(url, options = {}) {
|
||||
const session = new BrowserSession(options);
|
||||
try {
|
||||
await session.start();
|
||||
const result = await session.navigate(url);
|
||||
return result;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* Session Manager
|
||||
*
|
||||
* Manages browser sessions for exploration.
|
||||
* For persistent sessions, use the REPL mode (--explore-repl).
|
||||
*/
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { createInterface } from 'readline';
|
||||
import { inspectPage } from './inspector.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '../../../..');
|
||||
const sessionsDir = resolve(projectRoot, '.browser/sessions');
|
||||
const flowsDir = resolve(projectRoot, '.browser/flows');
|
||||
|
||||
// Ensure directories exist
|
||||
function ensureDirs() {
|
||||
if (!existsSync(sessionsDir)) mkdirSync(sessionsDir, { recursive: true });
|
||||
if (!existsSync(flowsDir)) mkdirSync(flowsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short session ID
|
||||
*/
|
||||
function generateSessionId() {
|
||||
return randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session folder path
|
||||
*/
|
||||
function getSessionDir(sessionId) {
|
||||
return resolve(sessionsDir, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session JSON file path
|
||||
*/
|
||||
function getSessionPath(sessionId) {
|
||||
return resolve(getSessionDir(sessionId), 'session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get screenshots folder path
|
||||
*/
|
||||
function getScreenshotsDir(sessionId) {
|
||||
return resolve(getSessionDir(sessionId), 'screenshots');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure session directories exist
|
||||
*/
|
||||
function ensureSessionDirs(sessionId) {
|
||||
const sessionDir = getSessionDir(sessionId);
|
||||
const screenshotsDir = getScreenshotsDir(sessionId);
|
||||
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
|
||||
if (!existsSync(screenshotsDir)) mkdirSync(screenshotsDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate screenshot path for a session
|
||||
*/
|
||||
function getScreenshotPath(sessionId, name, index) {
|
||||
const screenshotsDir = getScreenshotsDir(sessionId);
|
||||
const paddedIndex = String(index).padStart(3, '0');
|
||||
// Sanitize name for filesystem
|
||||
const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase().substring(0, 50);
|
||||
return resolve(screenshotsDir, `${paddedIndex}-${safeName}.png`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load session data from file
|
||||
*/
|
||||
function loadSession(sessionId) {
|
||||
const path = getSessionPath(sessionId);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`Session not found: ${sessionId}`);
|
||||
}
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data to file
|
||||
*/
|
||||
function saveSession(sessionId, data) {
|
||||
ensureDirs();
|
||||
ensureSessionDirs(sessionId);
|
||||
const path = getSessionPath(sessionId);
|
||||
writeFileSync(path, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session folder
|
||||
*/
|
||||
function deleteSession(sessionId) {
|
||||
const sessionDir = getSessionDir(sessionId);
|
||||
if (existsSync(sessionDir)) {
|
||||
// Remove all files in screenshots
|
||||
const screenshotsDir = getScreenshotsDir(sessionId);
|
||||
if (existsSync(screenshotsDir)) {
|
||||
for (const file of readdirSync(screenshotsDir)) {
|
||||
unlinkSync(resolve(screenshotsDir, file));
|
||||
}
|
||||
rmdirSync(screenshotsDir);
|
||||
}
|
||||
// Remove session.json
|
||||
const sessionPath = getSessionPath(sessionId);
|
||||
if (existsSync(sessionPath)) {
|
||||
unlinkSync(sessionPath);
|
||||
}
|
||||
// Remove session folder
|
||||
rmdirSync(sessionDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive REPL exploration session
|
||||
* Browser stays open and accepts commands via stdin
|
||||
*/
|
||||
export async function startExploreRepl(url, options = {}) {
|
||||
ensureDirs();
|
||||
|
||||
const sessionId = generateSessionId();
|
||||
const headless = options.headless ?? false;
|
||||
|
||||
// Create session directories
|
||||
ensureSessionDirs(sessionId);
|
||||
|
||||
console.error(`Starting exploration session: ${sessionId}`);
|
||||
console.error(`Session folder: ${getSessionDir(sessionId)}`);
|
||||
console.error(`Opening: ${url}`);
|
||||
|
||||
// Launch browser
|
||||
const browser = await chromium.launch({ headless });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
// Navigate to starting URL
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Session state
|
||||
const chunks = [];
|
||||
let currentUrl = page.url();
|
||||
let screenshotIndex = 0;
|
||||
|
||||
// Helper to get next screenshot path
|
||||
const nextScreenshot = (name) => {
|
||||
screenshotIndex++;
|
||||
return getScreenshotPath(sessionId, name, screenshotIndex);
|
||||
};
|
||||
|
||||
// Get and output initial inspection
|
||||
const screenshotPath = nextScreenshot('session-start');
|
||||
const inspection = await inspectPage(page, { screenshotPath });
|
||||
outputJson({
|
||||
type: 'session_started',
|
||||
sessionId,
|
||||
sessionDir: getSessionDir(sessionId),
|
||||
inspection,
|
||||
});
|
||||
|
||||
// Set up readline for commands
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr, // Don't interfere with JSON output
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
console.error('\nSession ready. Commands: chunk, inspect, review, save, exit');
|
||||
console.error('Format: {"cmd": "chunk", "label": "...", "code": "..."}');
|
||||
|
||||
// Process commands
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let cmd;
|
||||
try {
|
||||
cmd = JSON.parse(line);
|
||||
} catch (e) {
|
||||
outputJson({ type: 'error', message: 'Invalid JSON command' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (cmd.cmd) {
|
||||
case 'chunk': {
|
||||
// Execute a code chunk
|
||||
const label = cmd.label || `Chunk ${chunks.length + 1}`;
|
||||
const code = cmd.code;
|
||||
|
||||
if (!code) {
|
||||
outputJson({ type: 'error', message: 'No code provided' });
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const asyncFn = new Function('page', `return (async () => { ${code} })();`);
|
||||
await asyncFn(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Record chunk
|
||||
const chunkIndex = chunks.length + 1;
|
||||
chunks.push({
|
||||
index: chunkIndex,
|
||||
label,
|
||||
code,
|
||||
timestamp: new Date().toISOString(),
|
||||
urlAfter: page.url(),
|
||||
});
|
||||
currentUrl = page.url();
|
||||
|
||||
// Get inspection with session screenshot
|
||||
const screenshotPath = nextScreenshot(`chunk-${label}`);
|
||||
const inspection = await inspectPage(page, { screenshotPath });
|
||||
outputJson({
|
||||
type: 'chunk_executed',
|
||||
chunkIndex,
|
||||
label,
|
||||
inspection,
|
||||
});
|
||||
} catch (execError) {
|
||||
let inspection = null;
|
||||
try {
|
||||
const screenshotPath = nextScreenshot(`chunk-failed-${cmd.label || 'unknown'}`);
|
||||
inspection = await inspectPage(page, { screenshotPath });
|
||||
} catch (e) {}
|
||||
outputJson({
|
||||
type: 'chunk_failed',
|
||||
error: execError.message,
|
||||
inspection,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'inspect': {
|
||||
const screenshotPath = nextScreenshot('inspect');
|
||||
const inspection = await inspectPage(page, { screenshotPath });
|
||||
outputJson({ type: 'inspection', inspection });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'review': {
|
||||
outputJson({
|
||||
type: 'review',
|
||||
sessionId,
|
||||
startUrl: url,
|
||||
currentUrl,
|
||||
chunks: chunks.map(c => ({
|
||||
index: c.index,
|
||||
label: c.label,
|
||||
code: c.code,
|
||||
})),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-flows': {
|
||||
// List available flows
|
||||
const flows = listFlowsInternal();
|
||||
outputJson({
|
||||
type: 'flows_list',
|
||||
flows: flows.map(f => ({ name: f.name, startUrl: f.startUrl })),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'flow': {
|
||||
// Run a saved flow as a chunk
|
||||
const flowName = cmd.name;
|
||||
if (!flowName) {
|
||||
outputJson({ type: 'error', message: 'No flow name provided' });
|
||||
break;
|
||||
}
|
||||
|
||||
const flowPath = resolve(flowsDir, `${flowName}.js`);
|
||||
if (!existsSync(flowPath)) {
|
||||
outputJson({ type: 'error', message: `Flow not found: ${flowName}` });
|
||||
break;
|
||||
}
|
||||
|
||||
const flowScript = readFileSync(flowPath, 'utf-8');
|
||||
|
||||
// Extract just the code (skip the header comments)
|
||||
const codeLines = flowScript.split('\n').filter(line => {
|
||||
return !line.startsWith('/**') &&
|
||||
!line.startsWith(' *') &&
|
||||
!line.startsWith('*/') &&
|
||||
line.trim() !== '';
|
||||
});
|
||||
const flowCode = codeLines.join('\n');
|
||||
|
||||
try {
|
||||
const asyncFn = new Function('page', `return (async () => { ${flowCode} })();`);
|
||||
await asyncFn(page);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Record as a chunk
|
||||
const chunkIndex = chunks.length + 1;
|
||||
chunks.push({
|
||||
index: chunkIndex,
|
||||
label: `[flow: ${flowName}]`,
|
||||
code: flowCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
urlAfter: page.url(),
|
||||
isFlow: true,
|
||||
flowName,
|
||||
});
|
||||
currentUrl = page.url();
|
||||
|
||||
const screenshotPath = nextScreenshot(`flow-${flowName}`);
|
||||
const inspection = await inspectPage(page, { screenshotPath });
|
||||
outputJson({
|
||||
type: 'flow_executed',
|
||||
chunkIndex,
|
||||
flowName,
|
||||
inspection,
|
||||
});
|
||||
} catch (execError) {
|
||||
let inspection = null;
|
||||
try {
|
||||
const screenshotPath = nextScreenshot(`flow-failed-${flowName}`);
|
||||
inspection = await inspectPage(page, { screenshotPath });
|
||||
} catch (e) {}
|
||||
outputJson({
|
||||
type: 'flow_failed',
|
||||
flowName,
|
||||
error: execError.message,
|
||||
inspection,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'save': {
|
||||
const flowName = cmd.name;
|
||||
const keepIndexes = cmd.keep;
|
||||
|
||||
if (!flowName) {
|
||||
outputJson({ type: 'error', message: 'No flow name provided' });
|
||||
break;
|
||||
}
|
||||
|
||||
if (!keepIndexes || keepIndexes.length === 0) {
|
||||
outputJson({ type: 'error', message: 'No chunks selected (use "keep": [1,2,3])' });
|
||||
break;
|
||||
}
|
||||
|
||||
const selectedChunks = chunks.filter(c => keepIndexes.includes(c.index));
|
||||
if (selectedChunks.length === 0) {
|
||||
outputJson({ type: 'error', message: 'No matching chunks found' });
|
||||
break;
|
||||
}
|
||||
|
||||
// Build script
|
||||
const scriptLines = [
|
||||
`/**`,
|
||||
` * Flow: ${flowName}`,
|
||||
` * Generated: ${new Date().toISOString()}`,
|
||||
` * Start URL: ${url}`,
|
||||
` */`,
|
||||
``,
|
||||
];
|
||||
|
||||
for (const chunk of selectedChunks) {
|
||||
scriptLines.push(`// --- ${chunk.label} ---`);
|
||||
scriptLines.push(chunk.code);
|
||||
scriptLines.push(``);
|
||||
}
|
||||
|
||||
const script = scriptLines.join('\n');
|
||||
|
||||
// Save
|
||||
ensureDirs();
|
||||
const flowPath = resolve(flowsDir, `${flowName}.js`);
|
||||
writeFileSync(flowPath, script);
|
||||
|
||||
outputJson({
|
||||
type: 'flow_saved',
|
||||
flowName,
|
||||
flowPath,
|
||||
chunksIncluded: selectedChunks.map(c => c.index),
|
||||
script,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'exit': {
|
||||
outputJson({ type: 'session_ended', sessionId, totalChunks: chunks.length });
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
default:
|
||||
outputJson({ type: 'error', message: `Unknown command: ${cmd.cmd}` });
|
||||
}
|
||||
} catch (cmdError) {
|
||||
outputJson({ type: 'error', message: cmdError.message });
|
||||
}
|
||||
}
|
||||
|
||||
// EOF - cleanup
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON to stdout (for agent consumption)
|
||||
*/
|
||||
function outputJson(obj) {
|
||||
console.log(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a saved flow
|
||||
*/
|
||||
export async function runFlow(flowName, options = {}) {
|
||||
const flowPath = resolve(flowsDir, `${flowName}.js`);
|
||||
|
||||
if (!existsSync(flowPath)) {
|
||||
throw new Error(`Flow not found: ${flowName}`);
|
||||
}
|
||||
|
||||
const script = readFileSync(flowPath, 'utf-8');
|
||||
|
||||
// Extract start URL from script comments
|
||||
const urlMatch = script.match(/Start URL: (.+)/);
|
||||
const startUrl = options.startUrl || (urlMatch ? urlMatch[1] : null);
|
||||
|
||||
if (!startUrl) {
|
||||
throw new Error('No start URL specified and could not extract from flow');
|
||||
}
|
||||
|
||||
// Launch browser
|
||||
const browser = await chromium.launch({
|
||||
headless: options.headless ?? false,
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(options.timeout || 30000);
|
||||
|
||||
try {
|
||||
// Navigate to start URL
|
||||
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Execute the flow script
|
||||
const asyncFn = new Function('page', `return (async () => { ${script} })();`);
|
||||
await asyncFn(page);
|
||||
|
||||
// Get final inspection
|
||||
const inspection = await inspectPage(page);
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
status: 'passed',
|
||||
flowName,
|
||||
inspection,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
let inspection = null;
|
||||
try {
|
||||
inspection = await inspectPage(page);
|
||||
} catch (e) {}
|
||||
|
||||
await browser.close();
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
flowName,
|
||||
error: error.message,
|
||||
inspection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved flows (internal)
|
||||
*/
|
||||
function listFlowsInternal() {
|
||||
ensureDirs();
|
||||
const files = readdirSync(flowsDir).filter(f => f.endsWith('.js'));
|
||||
|
||||
return files.map(f => {
|
||||
const name = f.replace('.js', '');
|
||||
const content = readFileSync(resolve(flowsDir, f), 'utf-8');
|
||||
const urlMatch = content.match(/Start URL: (.+)/);
|
||||
return {
|
||||
name,
|
||||
startUrl: urlMatch ? urlMatch[1] : 'unknown',
|
||||
path: resolve(flowsDir, f),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved flows (exported)
|
||||
*/
|
||||
export function listFlows() {
|
||||
return listFlowsInternal();
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "browser-automation-skill",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "browser-automation-skill",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "browser-automation-skill",
|
||||
"version": "1.0.0",
|
||||
"description": "Browser automation skill with REPL exploration and reusable flows",
|
||||
"type": "module",
|
||||
"main": "runner.mjs",
|
||||
"scripts": {
|
||||
"start": "node runner.mjs",
|
||||
"setup": "npx playwright install chromium",
|
||||
"test": "node runner.mjs --list-flows"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Browser Automation Runner
|
||||
*
|
||||
* Supports two modes:
|
||||
* 1. REPL exploration (interactive session for discovering and recording flows)
|
||||
* 2. Flow execution (run saved flows directly)
|
||||
*
|
||||
* REPL Mode:
|
||||
* node runner.mjs --explore <url>
|
||||
* Then send JSON commands via stdin:
|
||||
* {"cmd": "chunk", "label": "Click button", "code": "await page.click('button');"}
|
||||
* {"cmd": "inspect"}
|
||||
* {"cmd": "review"}
|
||||
* {"cmd": "list-flows"}
|
||||
* {"cmd": "flow", "name": "my-flow"}
|
||||
* {"cmd": "save", "name": "my-flow", "keep": [1, 3, 4]}
|
||||
* {"cmd": "exit"}
|
||||
*
|
||||
* Flow Commands:
|
||||
* --run-flow <name> Run a saved flow
|
||||
* --list-flows List all saved flows
|
||||
*
|
||||
* One-shot Commands:
|
||||
* --inspect <url> One-shot page inspection
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { inspectUrl } from './lib/inspector.mjs';
|
||||
import { startExploreRepl, runFlow, listFlows } from './lib/session.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const config = {
|
||||
// REPL exploration
|
||||
explore: null,
|
||||
|
||||
// Flow commands
|
||||
runFlow: null,
|
||||
listFlows: false,
|
||||
|
||||
// One-shot commands
|
||||
inspectUrl: null,
|
||||
|
||||
// Options
|
||||
headless: process.env.BROWSER_HEADLESS === 'true',
|
||||
timeout: parseInt(process.env.BROWSER_TIMEOUT || '30000', 10),
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
switch (arg) {
|
||||
// REPL exploration
|
||||
case '--explore':
|
||||
config.explore = args[++i];
|
||||
break;
|
||||
|
||||
// Flow commands
|
||||
case '--run-flow':
|
||||
config.runFlow = args[++i];
|
||||
break;
|
||||
case '--list-flows':
|
||||
config.listFlows = true;
|
||||
break;
|
||||
|
||||
// One-shot commands
|
||||
case '--inspect':
|
||||
config.inspectUrl = args[++i];
|
||||
break;
|
||||
|
||||
// Options
|
||||
case '--headless':
|
||||
config.headless = true;
|
||||
break;
|
||||
case '--timeout':
|
||||
config.timeout = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
Browser Automation Runner
|
||||
|
||||
EXPLORATION MODE (Interactive REPL):
|
||||
|
||||
Start exploring:
|
||||
node runner.mjs --explore <url>
|
||||
|
||||
The browser opens and you send JSON commands via stdin:
|
||||
|
||||
Execute code chunk (recorded):
|
||||
{"cmd": "chunk", "label": "Navigate to models", "code": "await page.click('a[href=\\"/models\\"]');"}
|
||||
|
||||
Inspect current page:
|
||||
{"cmd": "inspect"}
|
||||
|
||||
Review all recorded chunks:
|
||||
{"cmd": "review"}
|
||||
|
||||
List available flows:
|
||||
{"cmd": "list-flows"}
|
||||
|
||||
Run a flow as a chunk:
|
||||
{"cmd": "flow", "name": "browse-to-model"}
|
||||
|
||||
Save selected chunks as flow:
|
||||
{"cmd": "save", "name": "my-flow", "keep": [1, 3, 4]}
|
||||
|
||||
Exit session:
|
||||
{"cmd": "exit"}
|
||||
|
||||
Output is JSON on stdout, logs on stderr.
|
||||
|
||||
FLOW MODE (Run Saved Flows):
|
||||
|
||||
Run a flow:
|
||||
node runner.mjs --run-flow <name>
|
||||
|
||||
List flows:
|
||||
node runner.mjs --list-flows
|
||||
|
||||
ONE-SHOT COMMANDS:
|
||||
|
||||
Inspect a page:
|
||||
node runner.mjs --inspect <url>
|
||||
|
||||
OPTIONS:
|
||||
|
||||
--headless Run browser without visible window
|
||||
--timeout <ms> Default timeout for actions (default: 30000)
|
||||
--help, -h Show this help
|
||||
|
||||
EXAMPLE WORKFLOW:
|
||||
|
||||
1. Start exploration:
|
||||
node runner.mjs --explore https://example.com
|
||||
|
||||
2. Execute chunks (agent sends these via stdin):
|
||||
{"cmd": "chunk", "label": "Click login", "code": "await page.click('button.login');"}
|
||||
{"cmd": "chunk", "label": "Fill form", "code": "await page.fill('#email', 'test@example.com');"}
|
||||
|
||||
3. Review what was recorded:
|
||||
{"cmd": "review"}
|
||||
|
||||
4. Save the good chunks as a flow:
|
||||
{"cmd": "save", "name": "login-flow", "keep": [1, 2]}
|
||||
|
||||
5. Later, replay the flow:
|
||||
node runner.mjs --run-flow login-flow
|
||||
`);
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const config = parseArgs();
|
||||
|
||||
// === REPL EXPLORATION ===
|
||||
if (config.explore) {
|
||||
await startExploreRepl(config.explore, { headless: config.headless });
|
||||
return;
|
||||
}
|
||||
|
||||
// === FLOW COMMANDS ===
|
||||
|
||||
if (config.runFlow) {
|
||||
console.log(`Running flow: ${config.runFlow}`);
|
||||
try {
|
||||
const result = await runFlow(config.runFlow, { headless: config.headless, timeout: config.timeout });
|
||||
console.log('\n--- FLOW RESULT ---');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.status === 'passed' ? 0 : 1);
|
||||
} catch (e) {
|
||||
console.error('Failed to run flow:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.listFlows) {
|
||||
const flows = listFlows();
|
||||
console.log('\n=== SAVED FLOWS ===\n');
|
||||
if (flows.length === 0) {
|
||||
console.log(' No flows saved yet.');
|
||||
console.log(' Use --explore to start a session and save flows.');
|
||||
} else {
|
||||
for (const flow of flows) {
|
||||
console.log(` ${flow.name}`);
|
||||
console.log(` Start URL: ${flow.startUrl}`);
|
||||
console.log(` Path: ${flow.path}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === ONE-SHOT COMMANDS ===
|
||||
|
||||
if (config.inspectUrl) {
|
||||
console.log(`\nInspecting: ${config.inspectUrl}\n`);
|
||||
const result = await inspectUrl(config.inspectUrl, { headless: config.headless });
|
||||
console.log('--- PAGE INSPECTION ---');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// No command specified
|
||||
console.error('No command specified. Use --help for usage information.');
|
||||
console.error('\nQuick start:');
|
||||
console.error(' node runner.mjs --explore https://example.com # Start exploring');
|
||||
console.error(' node runner.mjs --list-flows # See saved flows');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+4
-1
@@ -1,7 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
@@ -48,6 +48,9 @@ yarn-error.log*
|
||||
/postgres*
|
||||
/redis
|
||||
/minio
|
||||
|
||||
# browser automation sessions (local exploration data)
|
||||
.browser/sessions
|
||||
/clickhouse*
|
||||
/undefined
|
||||
/.docker
|
||||
|
||||
Reference in New Issue
Block a user