Add general flow runner and document natural-language usage

- Add flow.ts for go/click/type/press/wait/screenshot flows
- Update web-automation docs with natural-language examples
- Update SKILL.md quick reference for flow.ts
- Remove temp script files
This commit is contained in:
Luke
2026-02-11 22:01:38 +00:00
parent 2d0614e020
commit a6dffe0091
4 changed files with 243 additions and 78 deletions

View File

@@ -40,4 +40,27 @@ npx tsx scrape.ts --url "https://example.com" --mode main --output page.md
# Authenticate flow
npx tsx auth.ts --url "https://example.com/login"
# General natural-language browser flow
npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s'
```
## Natural-language flow runner (`flow.ts`)
Use `flow.ts` when you want a general command style like:
- "go to this site"
- "find this button and click it"
- "type this and press enter"
### Example
```bash
npx tsx flow.ts --instruction 'go to https://example.com then click on "Sign in" then type "stef@example.com" in #email then press enter'
```
You can also use JSON steps for deterministic runs:
```bash
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"},{"action":"click","text":"Sign in"}]'
```

View File

@@ -40,6 +40,17 @@ If any check fails, stop and return:
- Browse page: `npx tsx browse.ts --url "https://example.com"`
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
## General flow runner
Use `flow.ts` for multi-step commands in plain language (go/click/type/press/wait/screenshot).
Example:
```bash
npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s'
```
## Notes

View File

@@ -0,0 +1,209 @@
#!/usr/bin/env npx tsx
import parseArgs from 'minimist';
import type { Page } from 'playwright-core';
import { launchBrowser } from './browse';
type Step =
| { action: 'goto'; url: string }
| { action: 'click'; selector?: string; text?: string }
| { action: 'type'; selector?: string; text: string }
| { action: 'press'; key: string; selector?: string }
| { action: 'wait'; ms: number }
| { action: 'screenshot'; path: string }
| { action: 'extract'; selector: string; count?: number };
function normalizeKey(k: string): string {
if (!k) return 'Enter';
const lower = k.toLowerCase();
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'tab') return 'Tab';
if (lower === 'escape' || lower === 'esc') return 'Escape';
return k;
}
function splitInstructions(instruction: string): string[] {
return instruction
.split(/\bthen\b|;/gi)
.map((s) => s.trim())
.filter(Boolean);
}
function parseInstruction(instruction: string): Step[] {
const parts = splitInstructions(instruction);
const steps: Step[] = [];
for (const p of parts) {
// go to https://...
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
if (goto) {
steps.push({ action: 'goto', url: goto[1] });
continue;
}
// click on "text" or click #selector
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
if (clickText) {
steps.push({ action: 'click', text: clickText[1] });
continue;
}
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
if (clickSelector) {
steps.push({ action: 'click', selector: clickSelector[1] });
continue;
}
// type "text" [in selector]
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
if (typeInto) {
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
continue;
}
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
if (typeOnly) {
steps.push({ action: 'type', text: typeOnly[1] });
continue;
}
// press enter [in selector]
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
if (pressIn) {
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
continue;
}
const pressOnly = p.match(/^press\s+(\w+)$/i);
if (pressOnly) {
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
continue;
}
// wait 2s / wait 500ms
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
if (waitS) {
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
continue;
}
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
if (waitMs) {
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
continue;
}
// screenshot path
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
if (shot) {
steps.push({ action: 'screenshot', path: shot[1].trim() });
continue;
}
throw new Error(`Could not parse step: "${p}"`);
}
return steps;
}
async function clickByText(page: Page, text: string) {
const loc = page.getByRole('button', { name: text }).or(page.getByRole('link', { name: text })).or(page.getByText(text));
await loc.first().click({ timeout: 15000 });
}
async function typeInBestTarget(page: Page, text: string, selector?: string) {
if (selector) {
await page.locator(selector).first().click({ timeout: 10000 });
await page.locator(selector).first().fill(text);
return;
}
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
await loc.click({ timeout: 10000 });
await loc.fill(text);
}
async function pressOnTarget(page: Page, key: string, selector?: string) {
if (selector) {
await page.locator(selector).first().press(key);
return;
}
await page.keyboard.press(key);
}
async function runSteps(page: Page, steps: Step[]) {
for (const step of steps) {
switch (step.action) {
case 'goto':
await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout: 60000 });
break;
case 'click':
if (step.selector) await page.locator(step.selector).first().click({ timeout: 15000 });
else if (step.text) await clickByText(page, step.text);
else throw new Error('click step missing selector/text');
break;
case 'type':
await typeInBestTarget(page, step.text, step.selector);
break;
case 'press':
await pressOnTarget(page, step.key, step.selector);
break;
case 'wait':
await page.waitForTimeout(step.ms);
break;
case 'screenshot':
await page.screenshot({ path: step.path, fullPage: true });
break;
case 'extract': {
const items = await page.locator(step.selector).allTextContents();
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
console.log(JSON.stringify(out, null, 2));
break;
}
default:
throw new Error('Unknown step');
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['instruction', 'steps'],
boolean: ['headless', 'help'],
default: { headless: true },
alias: { i: 'instruction', s: 'steps', h: 'help' },
});
if (args.help || (!args.instruction && !args.steps)) {
console.log(`
General Web Flow Runner (Camoufox)
Usage:
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
Supported natural steps:
- go to/open/navigate to <url>
- click on "Text"
- click <css-selector>
- type "text"
- type "text" in <css-selector>
- press <key>
- press <key> in <css-selector>
- wait <N>s | wait <N>ms
- screenshot <path>
`);
process.exit(args.help ? 0 : 1);
}
const browser = await launchBrowser({ headless: args.headless });
const page = await browser.newPage();
try {
const steps: Step[] = args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction);
await runSteps(page, steps);
console.log('Flow complete. Final URL:', page.url());
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error('Error:', e instanceof Error ? e.message : e);
process.exit(1);
});

View File

@@ -1,78 +0,0 @@
import { getPage } from './browse.js';
type Extracted = {
title: string;
url: string;
colorVars: Array<[string, string]>;
samples: Record<string, null | { background: string; color: string; border: string }>;
};
function isColorValue(value: string) {
return /#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})\b/i.test(value) || /\brgb\(|\bhsl\(/i.test(value);
}
async function main() {
const url = process.argv[2] ?? 'https://www.firsthorizon.com';
const { page, browser } = await getPage({ headless: true });
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(5000);
const data = await page.evaluate(`(() => {
const rootStyles = getComputedStyle(document.documentElement);
const vars = {};
for (let i = 0; i < rootStyles.length; i++) {
const prop = rootStyles[i];
if (prop && prop.startsWith('--')) {
vars[prop] = rootStyles.getPropertyValue(prop).trim();
}
}
const pick = (selector) => {
const el = document.querySelector(selector);
if (!el) return null;
const cs = getComputedStyle(el);
return {
background: cs.backgroundColor,
color: cs.color,
border: cs.borderColor,
};
};
return {
title: document.title,
url: location.href,
vars,
samples: {
body: pick('body'),
header: pick('header'),
nav: pick('nav'),
primaryButton: pick('button, [role="button"], a[role="button"], a.button, .button'),
link: pick('a'),
},
};
})()`);
const entries = Object.entries(data.vars) as Array<[string, string]>;
const colorVars = entries
.filter(([, v]) => v && isColorValue(v))
.sort((a, b) => a[0].localeCompare(b[0]));
const out: Extracted = {
title: data.title,
url: data.url,
colorVars,
samples: data.samples,
};
process.stdout.write(JSON.stringify(out, null, 2));
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});