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:
@@ -40,4 +40,27 @@ npx tsx scrape.ts --url "https://example.com" --mode main --output page.md
|
|||||||
|
|
||||||
# Authenticate flow
|
# Authenticate flow
|
||||||
npx tsx auth.ts --url "https://example.com/login"
|
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"}]'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ If any check fails, stop and return:
|
|||||||
- Browse page: `npx tsx browse.ts --url "https://example.com"`
|
- 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`
|
- 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"`
|
- 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
209
skills/web-automation/scripts/flow.ts
Normal file
209
skills/web-automation/scripts/flow.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user