@web-auto/camo 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,17 @@
5
5
 
6
6
  A cross-platform command-line interface for Camoufox browser automation.
7
7
 
8
+ ## What Camo Provides
9
+
10
+ - Browser lifecycle management: start/stop/list sessions, idle cleanup, lock cleanup.
11
+ - Profile-first automation: persistent profile dirs, fingerprint support, remembered window size.
12
+ - Browser control primitives: navigation, tabs, viewport/window, mouse and keyboard actions.
13
+ - Devtools debugging helpers: open devtools, evaluate JS quickly, collect browser console logs.
14
+ - Session recorder: JSONL interaction capture (click/input/scroll/keyboard + page visits) with runtime toggle.
15
+ - Container subscription layer: selector registration, filter/list/watch in viewport.
16
+ - Autoscript runtime: validate/explain/run/resume/mock-run with snapshot and replay.
17
+ - Progress stream: local websocket daemon (`/events`) with tail/recent/emit commands.
18
+
8
19
  ## Installation
9
20
 
10
21
  ### npm (Recommended)
@@ -48,13 +59,66 @@ camo devtools eval worker-1 "document.title"
48
59
  # Read captured console entries
49
60
  camo devtools logs worker-1 --levels error,warn --limit 50
50
61
 
62
+ # Start recording into JSONL (with in-page toggle)
63
+ camo record start worker-1 --name run-a --output ./logs/run-a.jsonl --overlay
64
+
51
65
  # Navigate
52
66
  camo goto https://www.xiaohongshu.com
53
67
 
54
68
  # Interact
69
+ camo highlight-mode on
70
+ camo click "#search-input" --highlight
71
+ camo type "#search-input" "hello world" --highlight
72
+ camo scroll --down --amount 500 --selector ".feed-list"
73
+ ```
74
+
75
+ ## Core Workflows
76
+
77
+ ### 1) Interactive browser session
78
+
79
+ ```bash
80
+ camo init
81
+ camo profile create myprofile
82
+ camo profile default myprofile
83
+ camo start --url https://example.com --alias main
55
84
  camo click "#search-input"
56
85
  camo type "#search-input" "hello world"
57
- camo scroll --down --amount 500
86
+ ```
87
+
88
+ ### 2) Headless worker with idle auto-stop
89
+
90
+ ```bash
91
+ camo start worker-1 --headless --alias shard1 --idle-timeout 30m
92
+ camo instances
93
+ camo stop idle
94
+ ```
95
+
96
+ ### 3) Devtools-style debugging
97
+
98
+ ```bash
99
+ camo start myprofile --devtools
100
+ camo devtools eval myprofile "document.title"
101
+ camo devtools eval myprofile "(console.error('check-error'), location.href)"
102
+ camo devtools logs myprofile --levels error,warn --limit 50
103
+ camo devtools clear myprofile
104
+ ```
105
+
106
+ ### 4) Run autoscript with live progress
107
+
108
+ ```bash
109
+ camo autoscript validate ./autoscripts/xhs.autoscript.json
110
+ camo autoscript run ./autoscripts/xhs.autoscript.json --profile myprofile \
111
+ --jsonl-file ./runs/xhs/run.jsonl \
112
+ --summary-file ./runs/xhs/summary.json
113
+ camo events tail --profile myprofile --mode autoscript
114
+ ```
115
+
116
+ ### 5) Record manual interactions as JSONL
117
+
118
+ ```bash
119
+ camo start myprofile --record --record-name xhs-debug --record-output ./logs/xhs-debug.jsonl --record-overlay
120
+ camo record status myprofile
121
+ camo record stop myprofile
58
122
  ```
59
123
 
60
124
  ## Commands
@@ -77,10 +141,17 @@ camo init list # List available OS and regions
77
141
  camo create fingerprint --os <os> --region <region>
78
142
  ```
79
143
 
144
+ ### Config
145
+
146
+ ```bash
147
+ camo config repo-root [path] # Get/set persisted webauto repo root
148
+ camo highlight-mode [status|on|off] # Global highlight mode for click/type/scroll
149
+ ```
150
+
80
151
  ### Browser Control
81
152
 
82
153
  ```bash
83
- camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
154
+ camo start [profileId] [--url <url>] [--headless] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
84
155
  camo stop [profileId]
85
156
  camo stop --id <instanceId>
86
157
  camo stop --alias <alias>
@@ -95,6 +166,7 @@ If no saved size exists, it defaults to near-fullscreen (full width, slight vert
95
166
  Use `--width/--height` to override and update the saved profile size.
96
167
  For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
97
168
  Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
169
+ Use `--record` to auto-enable JSONL recording at startup; `--record-name`, `--record-output`, and `--record-overlay` customize file naming/output and floating toggle UI.
98
170
 
99
171
  ### Lifecycle & Cleanup
100
172
 
@@ -106,7 +178,6 @@ camo cleanup all # Cleanup all active sessions
106
178
  camo cleanup locks # Cleanup stale lock files
107
179
  camo force-stop [profileId] # Force stop session (for stuck sessions)
108
180
  camo lock list # List active session locks
109
- camo recover [profileId] # Recover orphaned session
110
181
  ```
111
182
 
112
183
  ### Navigation
@@ -120,9 +191,9 @@ camo screenshot [profileId] [--output <file>] [--full]
120
191
  ### Interaction
121
192
 
122
193
  ```bash
123
- camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>]
124
- camo click [profileId] <selector> # Click element by CSS selector
125
- camo type [profileId] <selector> <text> # Type text into element
194
+ camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]
195
+ camo click [profileId] <selector> [--highlight|--no-highlight] # Click visible element by CSS selector
196
+ camo type [profileId] <selector> <text> [--highlight|--no-highlight] # Type into visible input element
126
197
  camo highlight [profileId] <selector> # Highlight element (red border, 2s)
127
198
  camo clear-highlight [profileId] # Clear all highlights
128
199
  camo viewport [profileId] --width <w> --height <h>
@@ -136,6 +207,26 @@ camo devtools eval [profileId] <expression> [--profile <id>]
136
207
  camo devtools clear [profileId]
137
208
  ```
138
209
 
210
+ `devtools logs` reads entries from an injected in-page console collector.
211
+ Supported levels: `log`, `info`, `warn`, `error`, `debug`.
212
+
213
+ ### Recording
214
+
215
+ ```bash
216
+ camo record start [profileId] [--name <name>] [--output <file>] [--overlay|--no-overlay]
217
+ camo record stop [profileId] [--reason <text>]
218
+ camo record status [profileId]
219
+ ```
220
+
221
+ Recorder JSONL events include:
222
+ - `page.visit`
223
+ - `interaction.click`
224
+ - `interaction.keydown`
225
+ - `interaction.input`
226
+ - `interaction.wheel`
227
+ - `interaction.scroll`
228
+ - `recording.start|stop|toggled|runtime_ready`
229
+
139
230
  ### Pages
140
231
 
141
232
  ```bash
@@ -353,6 +444,7 @@ Condition types:
353
444
  - `WEBAUTO_CONTAINER_ROOT` - User container root override (default: `~/.webauto/container-lib`)
354
445
  - `CAMO_PROGRESS_EVENTS_FILE` - Optional progress event JSONL path override
355
446
  - `CAMO_PROGRESS_WS_HOST` / `CAMO_PROGRESS_WS_PORT` - Progress websocket daemon bind address (default: `127.0.0.1:7788`)
447
+ - `CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE` - Reserved vertical pixels for default headful auto-size
356
448
 
357
449
  ## Session Persistence
358
450
 
@@ -360,7 +452,6 @@ Camo CLI persists session information locally:
360
452
 
361
453
  - Sessions are registered in `~/.webauto/sessions/`
362
454
  - On restart, `camo sessions` / `camo instances` shows live + orphaned sessions
363
- - Use `camo recover <profileId>` to reconnect or cleanup orphaned sessions
364
455
  - Stale sessions (>7 days) are automatically cleaned up
365
456
 
366
457
  ## Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "check:file-size": "node scripts/check-file-size.mjs",
19
19
  "test": "node --test 'tests/**/*.test.mjs'",
20
20
  "test:coverage": "c8 --reporter=text --reporter=lcov node --test 'tests/**/*.test.mjs'",
21
+ "test:coverage:modes": "c8 --all --check-coverage --lines 90 --functions 90 --branches 90 --statements 90 --include src/commands/record.mjs --include src/commands/highlight-mode.mjs --include src/container/runtime-core/operations/selector-scripts.mjs node --test tests/unit/commands/record.test.mjs tests/unit/commands/highlight-mode.test.mjs tests/unit/container/selector-scripts.test.mjs tests/unit/commands/browser.test.mjs",
21
22
  "version:bump": "node scripts/bump-version.mjs",
22
23
  "install:global": "npm run build && npm install -g .",
23
24
  "uninstall:global": "npm uninstall -g @web-auto/camo",
@@ -86,43 +86,6 @@ export function buildCommentsHarvestScript(params = {}) {
86
86
  }
87
87
  return null;
88
88
  };
89
- const normalizeInlineText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
90
- const sanitizeAuthorText = (raw, commentText = '') => {
91
- const text = normalizeInlineText(raw);
92
- if (!text) return '';
93
- if (commentText && text === commentText) return '';
94
- if (text.length > 40) return '';
95
- if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
96
- return text;
97
- };
98
- const readAuthor = (item, commentText = '') => {
99
- const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
100
- for (const attr of attrNames) {
101
- const value = sanitizeAuthorText(item.getAttribute?.(attr), commentText);
102
- if (value) return value;
103
- }
104
- const selectors = [
105
- '.comment-user .name',
106
- '.comment-user .username',
107
- '.comment-user .user-name',
108
- '.author .name',
109
- '.author',
110
- '.user-name',
111
- '.username',
112
- '.name',
113
- 'a[href*="/user/profile/"]',
114
- 'a[href*="/user/"]',
115
- ];
116
- for (const selector of selectors) {
117
- const node = item.querySelector(selector);
118
- if (!node) continue;
119
- const title = sanitizeAuthorText(node.getAttribute?.('title'), commentText);
120
- if (title) return title;
121
- const text = sanitizeAuthorText(node.textContent, commentText);
122
- if (text) return text;
123
- }
124
- return '';
125
- };
126
89
 
127
90
  const scroller = document.querySelector('.note-scroller')
128
91
  || document.querySelector('.comments-el')
@@ -142,8 +105,9 @@ export function buildCommentsHarvestScript(params = {}) {
142
105
  const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
143
106
  for (const item of nodes) {
144
107
  const textNode = item.querySelector('.content, .comment-content, p');
108
+ const authorNode = item.querySelector('.name, .author, .user-name, [class*="author"], [class*="name"]');
145
109
  const text = String((textNode && textNode.textContent) || '').trim();
146
- const author = readAuthor(item, text);
110
+ const author = String((authorNode && authorNode.textContent) || '').trim();
147
111
  if (!text) continue;
148
112
  const key = author + '::' + text;
149
113
  if (commentMap.has(key)) continue;
@@ -74,42 +74,6 @@ function buildCollectLikeTargetsScript() {
74
74
  }
75
75
  return '';
76
76
  };
77
- const sanitizeUserName = (raw, commentText = '') => {
78
- const text = String(raw || '').replace(/\\s+/g, ' ').trim();
79
- if (!text) return '';
80
- if (commentText && text === commentText) return '';
81
- if (text.length > 40) return '';
82
- if (/^(回复|展开|收起|查看更多|评论|赞|分享|发送)$/.test(text)) return '';
83
- return text;
84
- };
85
- const readUserName = (item, commentText = '') => {
86
- const attrNames = ['data-user-name', 'data-username', 'data-user_nickname', 'data-nickname'];
87
- for (const attr of attrNames) {
88
- const value = sanitizeUserName(item.getAttribute?.(attr), commentText);
89
- if (value) return value;
90
- }
91
- const selectors = [
92
- '.comment-user .name',
93
- '.comment-user .username',
94
- '.comment-user .user-name',
95
- '.author .name',
96
- '.author',
97
- '.user-name',
98
- '.username',
99
- '.name',
100
- 'a[href*="/user/profile/"]',
101
- 'a[href*="/user/"]',
102
- ];
103
- for (const selector of selectors) {
104
- const node = item.querySelector(selector);
105
- if (!node) continue;
106
- const title = sanitizeUserName(node.getAttribute?.('title'), commentText);
107
- if (title) return title;
108
- const text = sanitizeUserName(node.textContent, commentText);
109
- if (text) return text;
110
- }
111
- return '';
112
- };
113
77
  const readAttr = (item, attrNames) => {
114
78
  for (const attr of attrNames) {
115
79
  const value = String(item.getAttribute?.(attr) || '').trim();
@@ -117,15 +81,6 @@ function buildCollectLikeTargetsScript() {
117
81
  }
118
82
  return '';
119
83
  };
120
- const readUserId = (item) => {
121
- const value = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
122
- if (value) return value;
123
- const anchor = item.querySelector('a[href*="/user/profile/"], a[href*="/user/"]');
124
- const href = String(anchor?.getAttribute?.('href') || '').trim();
125
- if (!href) return '';
126
- const matched = href.match(/\\/user\\/(?:profile\\/)?([a-zA-Z0-9_-]+)/);
127
- return matched && matched[1] ? matched[1] : '';
128
- };
129
84
 
130
85
  const matchedSet = new Set(
131
86
  Array.isArray(state.matchedComments)
@@ -137,8 +92,8 @@ function buildCollectLikeTargetsScript() {
137
92
  const item = items[index];
138
93
  const text = readText(item, ['.content', '.comment-content', 'p']);
139
94
  if (!text) continue;
140
- const userName = readUserName(item, text);
141
- const userId = readUserId(item);
95
+ const userName = readText(item, ['.name', '.author', '.user-name', '.username', '[class*="author"]', '[class*="name"]']);
96
+ const userId = readAttr(item, ['data-user-id', 'data-userid', 'data-user_id']);
142
97
  const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
143
98
  const likeControl = findLikeControl(item);
144
99
  rows.push({
package/src/cli.mjs CHANGED
@@ -14,6 +14,8 @@ import { handleContainerCommand } from './commands/container.mjs';
14
14
  import { handleAutoscriptCommand } from './commands/autoscript.mjs';
15
15
  import { handleEventsCommand } from './commands/events.mjs';
16
16
  import { handleDevtoolsCommand } from './commands/devtools.mjs';
17
+ import { handleRecordCommand } from './commands/record.mjs';
18
+ import { handleHighlightModeCommand } from './commands/highlight-mode.mjs';
17
19
  import {
18
20
  handleStartCommand, handleStopCommand, handleStatusCommand,
19
21
  handleGotoCommand, handleBackCommand, handleScreenshotCommand,
@@ -72,6 +74,23 @@ function inferProfileId(cmd, args) {
72
74
  }
73
75
  }
74
76
 
77
+ if (cmd === 'record') {
78
+ const explicit = readFlagValue(args, ['--profile', '-p']);
79
+ if (explicit) return explicit;
80
+ const sub = positionals[0] || null;
81
+ const values = [];
82
+ for (let i = 2; i < args.length; i += 1) {
83
+ const token = args[i];
84
+ if (!token || String(token).startsWith('-')) continue;
85
+ const prev = args[i - 1];
86
+ if (prev && ['--name', '--output', '--reason'].includes(prev)) continue;
87
+ values.push(String(token));
88
+ }
89
+ if (sub === 'start' || sub === 'stop' || sub === 'status') {
90
+ return values[0] || null;
91
+ }
92
+ }
93
+
75
94
  if (cmd === 'autoscript' && positionals[0] === 'run') {
76
95
  return explicitProfile || null;
77
96
  }
@@ -205,6 +224,16 @@ async function main() {
205
224
  return;
206
225
  }
207
226
 
227
+ if (cmd === 'record') {
228
+ await runTrackedCommand(cmd, args, () => handleRecordCommand(args));
229
+ return;
230
+ }
231
+
232
+ if (cmd === 'highlight-mode') {
233
+ await runTrackedCommand(cmd, args, () => handleHighlightModeCommand(args));
234
+ return;
235
+ }
236
+
208
237
  // Lifecycle commands
209
238
  if (cmd === 'cleanup') {
210
239
  await runTrackedCommand(cmd, args, () => handleCleanupCommand(args));
@@ -250,7 +279,7 @@ async function main() {
250
279
  'start', 'stop', 'close', 'status', 'list', 'goto', 'navigate', 'back', 'screenshot',
251
280
  'new-page', 'close-page', 'switch-page', 'list-pages', 'shutdown',
252
281
  'scroll', 'click', 'type', 'highlight', 'clear-highlight', 'viewport',
253
- 'cookies', 'window', 'mouse', 'system', 'container', 'autoscript', 'events', 'devtools',
282
+ 'cookies', 'window', 'mouse', 'system', 'container', 'autoscript', 'events', 'devtools', 'record', 'highlight-mode',
254
283
  ]);
255
284
 
256
285
  if (!serviceCommands.has(cmd)) {
@@ -325,6 +354,12 @@ async function main() {
325
354
  case 'system':
326
355
  await handleSystemCommand(args);
327
356
  break;
357
+ case 'record':
358
+ await handleRecordCommand(args);
359
+ break;
360
+ case 'highlight-mode':
361
+ await handleHighlightModeCommand(args);
362
+ break;
328
363
  }
329
364
  });
330
365
  }
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import {
3
4
  listProfiles,
4
5
  getDefaultProfile,
6
+ getHighlightMode,
5
7
  getProfileWindowSize,
6
8
  setProfileWindowSize,
7
9
  } from '../utils/config.mjs';
@@ -10,6 +12,7 @@ import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals }
10
12
  import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
11
13
  import {
12
14
  buildSelectorClickScript,
15
+ buildScrollTargetScript,
13
16
  buildSelectorTypeScript,
14
17
  } from '../container/runtime-core/operations/selector-scripts.mjs';
15
18
  import {
@@ -83,6 +86,12 @@ function validateAlias(alias) {
83
86
  return text.slice(0, 64);
84
87
  }
85
88
 
89
+ function resolveHighlightEnabled(args) {
90
+ if (args.includes('--highlight')) return true;
91
+ if (args.includes('--no-highlight')) return false;
92
+ return getHighlightMode();
93
+ }
94
+
86
95
  function formatDurationMs(ms) {
87
96
  const value = Number(ms);
88
97
  if (!Number.isFinite(value) || value <= 0) return 'disabled';
@@ -319,12 +328,27 @@ export async function handleStartCommand(args) {
319
328
  const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
320
329
  const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
321
330
  const wantsDevtools = args.includes('--devtools');
331
+ const wantsRecord = args.includes('--record');
332
+ const recordName = readFlagValue(args, ['--record-name']);
333
+ const recordOutputRaw = readFlagValue(args, ['--record-output']);
334
+ const recordOverlay = args.includes('--no-record-overlay')
335
+ ? false
336
+ : args.includes('--record-overlay')
337
+ ? true
338
+ : null;
322
339
  if (hasExplicitWidth !== hasExplicitHeight) {
323
- throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
340
+ throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
324
341
  }
325
342
  if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
326
343
  throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
327
344
  }
345
+ if (args.includes('--record-name') && !recordName) {
346
+ throw new Error('Usage: camo start [profileId] --record-name <name>');
347
+ }
348
+ if (args.includes('--record-output') && !recordOutputRaw) {
349
+ throw new Error('Usage: camo start [profileId] --record-output <path>');
350
+ }
351
+ const recordOutput = recordOutputRaw ? path.resolve(recordOutputRaw) : null;
328
352
  const hasExplicitWindowSize = hasExplicitWidth && hasExplicitHeight;
329
353
  const profileSet = new Set(listProfiles());
330
354
  let implicitUrl;
@@ -334,8 +358,9 @@ export async function handleStartCommand(args) {
334
358
  const arg = args[i];
335
359
  if (arg === '--url') { i++; continue; }
336
360
  if (arg === '--width' || arg === '--height') { i++; continue; }
337
- if (arg === '--alias' || arg === '--idle-timeout') { i++; continue; }
361
+ if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
338
362
  if (arg === '--headless') continue;
363
+ if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
339
364
  if (arg.startsWith('--')) continue;
340
365
 
341
366
  if (looksLikeUrlToken(arg) && !profileSet.has(arg)) {
@@ -398,6 +423,14 @@ export async function handleStartCommand(args) {
398
423
  if (!existingHeadless && wantsDevtools) {
399
424
  payload.devtools = await requestDevtoolsOpen(profileId);
400
425
  }
426
+ if (wantsRecord) {
427
+ payload.recording = await callAPI('record:start', {
428
+ profileId,
429
+ ...(recordName ? { name: recordName } : {}),
430
+ ...(recordOutput ? { outputPath: recordOutput } : {}),
431
+ ...(recordOverlay !== null ? { overlay: recordOverlay } : {}),
432
+ });
433
+ }
401
434
  console.log(JSON.stringify(payload, null, 2));
402
435
  startSessionWatchdog(profileId);
403
436
  return;
@@ -423,6 +456,10 @@ export async function handleStartCommand(args) {
423
456
  url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
424
457
  headless,
425
458
  devtools: wantsDevtools,
459
+ ...(wantsRecord ? { record: true } : {}),
460
+ ...(recordName ? { recordName } : {}),
461
+ ...(recordOutput ? { recordOutput } : {}),
462
+ ...(recordOverlay !== null ? { recordOverlay } : {}),
426
463
  });
427
464
 
428
465
  if (result?.ok) {
@@ -729,32 +766,55 @@ export async function handleScrollCommand(args) {
729
766
  await ensureBrowserService();
730
767
  const directionFlags = new Set(['--up', '--down', '--left', '--right']);
731
768
  const isFlag = (arg) => arg?.startsWith('--');
769
+ const selectorIdx = args.indexOf('--selector');
770
+ const selector = selectorIdx >= 0 ? String(args[selectorIdx + 1] || '').trim() : null;
771
+ const highlight = resolveHighlightEnabled(args);
732
772
 
733
773
  let profileId = null;
734
774
  for (let i = 1; i < args.length; i++) {
735
775
  const arg = args[i];
736
776
  if (directionFlags.has(arg)) continue;
737
777
  if (arg === '--amount') { i++; continue; }
778
+ if (arg === '--selector') { i++; continue; }
779
+ if (arg === '--highlight' || arg === '--no-highlight') continue;
738
780
  if (isFlag(arg)) continue;
739
781
  profileId = arg;
740
782
  break;
741
783
  }
742
784
  if (!profileId) profileId = getDefaultProfile();
743
- if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>]');
785
+ if (!profileId) throw new Error('Usage: camo scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]');
786
+ if (selectorIdx >= 0 && !selector) {
787
+ throw new Error('Usage: camo scroll [profileId] --selector <css>');
788
+ }
744
789
 
745
790
  const direction = args.includes('--up') ? 'up' : args.includes('--left') ? 'left' : args.includes('--right') ? 'right' : 'down';
746
791
  const amountIdx = args.indexOf('--amount');
747
792
  const amount = amountIdx >= 0 ? Number(args[amountIdx + 1]) || 300 : 300;
748
793
 
794
+ const target = await callAPI('evaluate', {
795
+ profileId,
796
+ script: buildScrollTargetScript({ selector, highlight }),
797
+ });
798
+ const centerX = Number(target?.result?.center?.x);
799
+ const centerY = Number(target?.result?.center?.y);
800
+ if (Number.isFinite(centerX) && Number.isFinite(centerY)) {
801
+ await callAPI('mouse:move', { profileId, x: centerX, y: centerY, steps: 2 });
802
+ }
803
+
749
804
  const deltaX = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
750
805
  const deltaY = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
751
806
  const result = await callAPI('mouse:wheel', { profileId, deltaX, deltaY });
752
- console.log(JSON.stringify(result, null, 2));
807
+ console.log(JSON.stringify({
808
+ ...result,
809
+ scrollTarget: target?.result || null,
810
+ highlight,
811
+ }, null, 2));
753
812
  }
754
813
 
755
814
  export async function handleClickCommand(args) {
756
815
  await ensureBrowserService();
757
816
  const positionals = getPositionals(args);
817
+ const highlight = resolveHighlightEnabled(args);
758
818
  let profileId;
759
819
  let selector;
760
820
 
@@ -766,12 +826,12 @@ export async function handleClickCommand(args) {
766
826
  selector = positionals[1];
767
827
  }
768
828
 
769
- if (!profileId) throw new Error('Usage: camo click [profileId] <selector>');
770
- if (!selector) throw new Error('Usage: camo click [profileId] <selector>');
829
+ if (!profileId) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
830
+ if (!selector) throw new Error('Usage: camo click [profileId] <selector> [--highlight|--no-highlight]');
771
831
 
772
832
  const result = await callAPI('evaluate', {
773
833
  profileId,
774
- script: buildSelectorClickScript({ selector, highlight: false }),
834
+ script: buildSelectorClickScript({ selector, highlight }),
775
835
  });
776
836
  console.log(JSON.stringify(result, null, 2));
777
837
  }
@@ -779,6 +839,7 @@ export async function handleClickCommand(args) {
779
839
  export async function handleTypeCommand(args) {
780
840
  await ensureBrowserService();
781
841
  const positionals = getPositionals(args);
842
+ const highlight = resolveHighlightEnabled(args);
782
843
  let profileId;
783
844
  let selector;
784
845
  let text;
@@ -793,12 +854,12 @@ export async function handleTypeCommand(args) {
793
854
  text = positionals[2];
794
855
  }
795
856
 
796
- if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text>');
797
- if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text>');
857
+ if (!profileId) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
858
+ if (!selector || text === undefined) throw new Error('Usage: camo type [profileId] <selector> <text> [--highlight|--no-highlight]');
798
859
 
799
860
  const result = await callAPI('evaluate', {
800
861
  profileId,
801
- script: buildSelectorTypeScript({ selector, highlight: false, text }),
862
+ script: buildSelectorTypeScript({ selector, highlight, text }),
802
863
  });
803
864
  console.log(JSON.stringify(result, null, 2));
804
865
  }
@@ -0,0 +1,24 @@
1
+ import { getHighlightMode, setHighlightMode } from '../utils/config.mjs';
2
+
3
+ export async function handleHighlightModeCommand(args) {
4
+ const sub = String(args[1] || 'status').trim().toLowerCase();
5
+
6
+ if (sub === 'status') {
7
+ console.log(JSON.stringify({ ok: true, highlightMode: getHighlightMode() }, null, 2));
8
+ return;
9
+ }
10
+
11
+ if (sub === 'on' || sub === 'enable') {
12
+ const next = setHighlightMode(true);
13
+ console.log(JSON.stringify({ ok: true, highlightMode: next }, null, 2));
14
+ return;
15
+ }
16
+
17
+ if (sub === 'off' || sub === 'disable') {
18
+ const next = setHighlightMode(false);
19
+ console.log(JSON.stringify({ ok: true, highlightMode: next }, null, 2));
20
+ return;
21
+ }
22
+
23
+ throw new Error('Usage: camo highlight-mode [status|on|off]');
24
+ }
@@ -0,0 +1,115 @@
1
+ import path from 'node:path';
2
+ import { getDefaultProfile } from '../utils/config.mjs';
3
+ import { callAPI, ensureBrowserService } from '../utils/browser-service.mjs';
4
+
5
+ function readFlagValue(args, names) {
6
+ for (let i = 0; i < args.length; i += 1) {
7
+ if (!names.includes(args[i])) continue;
8
+ const value = args[i + 1];
9
+ if (!value || String(value).startsWith('-')) return null;
10
+ return value;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ function collectPositionals(args, startIndex = 2) {
16
+ const values = [];
17
+ for (let i = startIndex; i < args.length; i += 1) {
18
+ const token = args[i];
19
+ if (!token || String(token).startsWith('-')) continue;
20
+ const prev = args[i - 1];
21
+ if (prev && ['--profile', '-p', '--name', '--output', '--reason'].includes(prev)) {
22
+ continue;
23
+ }
24
+ values.push(String(token));
25
+ }
26
+ return values;
27
+ }
28
+
29
+ function resolveProfileId(args, startIndex = 2) {
30
+ const explicit = readFlagValue(args, ['--profile', '-p']);
31
+ if (explicit) return explicit;
32
+ const positionals = collectPositionals(args, startIndex);
33
+ if (positionals.length > 0) return positionals[0];
34
+ return getDefaultProfile();
35
+ }
36
+
37
+ function parseOverlayFlag(args) {
38
+ if (args.includes('--no-overlay')) return false;
39
+ if (args.includes('--overlay')) return true;
40
+ return null;
41
+ }
42
+
43
+ async function handleRecordStart(args) {
44
+ await ensureBrowserService();
45
+ const profileId = resolveProfileId(args, 2);
46
+ if (!profileId) {
47
+ throw new Error('Usage: camo record start [profileId] [--name <name>] [--output <file>] [--overlay|--no-overlay]');
48
+ }
49
+ if (args.includes('--name') && !readFlagValue(args, ['--name'])) {
50
+ throw new Error('Usage: camo record start [profileId] --name <name>');
51
+ }
52
+ if (args.includes('--output') && !readFlagValue(args, ['--output'])) {
53
+ throw new Error('Usage: camo record start [profileId] --output <file>');
54
+ }
55
+ const name = readFlagValue(args, ['--name']);
56
+ const output = readFlagValue(args, ['--output']);
57
+ const overlay = parseOverlayFlag(args);
58
+ const result = await callAPI('record:start', {
59
+ profileId,
60
+ ...(name ? { name } : {}),
61
+ ...(output ? { outputPath: path.resolve(output) } : {}),
62
+ ...(overlay !== null ? { overlay } : {}),
63
+ });
64
+ console.log(JSON.stringify(result, null, 2));
65
+ }
66
+
67
+ async function handleRecordStop(args) {
68
+ await ensureBrowserService();
69
+ const profileId = resolveProfileId(args, 2);
70
+ if (!profileId) {
71
+ throw new Error('Usage: camo record stop [profileId]');
72
+ }
73
+ const reason = readFlagValue(args, ['--reason']) || 'manual';
74
+ const result = await callAPI('record:stop', { profileId, reason });
75
+ console.log(JSON.stringify(result, null, 2));
76
+ }
77
+
78
+ async function handleRecordStatus(args) {
79
+ await ensureBrowserService();
80
+ const profileId = resolveProfileId(args, 2);
81
+ if (!profileId) {
82
+ throw new Error('Usage: camo record status [profileId]');
83
+ }
84
+ const result = await callAPI('record:status', { profileId });
85
+ console.log(JSON.stringify(result, null, 2));
86
+ }
87
+
88
+ export async function handleRecordCommand(args) {
89
+ const sub = String(args[1] || '').trim().toLowerCase();
90
+ if (!sub) {
91
+ console.log(`Usage: camo record <start|stop|status> [options]
92
+ camo record start [profileId] [--name <name>] [--output <file>] [--overlay|--no-overlay]
93
+ camo record stop [profileId] [--reason <text>]
94
+ camo record status [profileId]
95
+ `);
96
+ return;
97
+ }
98
+ switch (sub) {
99
+ case 'start':
100
+ await handleRecordStart(args);
101
+ return;
102
+ case 'stop':
103
+ await handleRecordStop(args);
104
+ return;
105
+ case 'status':
106
+ await handleRecordStatus(args);
107
+ return;
108
+ default:
109
+ console.log(`Usage: camo record <start|stop|status> [options]
110
+ camo record start [profileId] [--name <name>] [--output <file>] [--overlay|--no-overlay]
111
+ camo record stop [profileId] [--reason <text>]
112
+ camo record status [profileId]
113
+ `);
114
+ }
115
+ }
@@ -2,12 +2,54 @@ function asBoolLiteral(value) {
2
2
  return value ? 'true' : 'false';
3
3
  }
4
4
 
5
+ function buildVisibleElementResolverLiteral(selector) {
6
+ return `
7
+ const selector = ${JSON.stringify(selector)};
8
+ const candidates = Array.from(document.querySelectorAll(selector));
9
+ const isVisible = (node) => {
10
+ if (!(node instanceof Element)) return false;
11
+ const rect = node.getBoundingClientRect?.();
12
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
13
+ try {
14
+ const style = window.getComputedStyle(node);
15
+ if (!style) return false;
16
+ if (style.display === 'none') return false;
17
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
18
+ const opacity = Number.parseFloat(String(style.opacity || '1'));
19
+ if (Number.isFinite(opacity) && opacity <= 0.01) return false;
20
+ } catch {
21
+ return false;
22
+ }
23
+ return true;
24
+ };
25
+ const hitVisible = (node) => {
26
+ if (!(node instanceof Element)) return false;
27
+ const rect = node.getBoundingClientRect?.();
28
+ if (!rect) return false;
29
+ const x = Math.max(0, Math.min((window.innerWidth || 1) - 1, rect.left + rect.width / 2));
30
+ const y = Math.max(0, Math.min((window.innerHeight || 1) - 1, rect.top + rect.height / 2));
31
+ const top = document.elementFromPoint(x, y);
32
+ if (!top) return false;
33
+ return top === node || node.contains(top) || top.contains(node);
34
+ };
35
+ const pick = () => {
36
+ if (!candidates.length) return null;
37
+ const strict = candidates.find((node) => isVisible(node) && hitVisible(node));
38
+ if (strict) return strict;
39
+ const visible = candidates.find((node) => isVisible(node));
40
+ if (visible) return visible;
41
+ return candidates[0] || null;
42
+ };
43
+ const el = pick();
44
+ if (!el) throw new Error('Element not found: ' + selector);
45
+ const matchedIndex = Math.max(0, candidates.indexOf(el));
46
+ `;
47
+ }
48
+
5
49
  export function buildSelectorScrollIntoViewScript({ selector, highlight }) {
6
- const selectorLiteral = JSON.stringify(selector);
7
50
  const highlightLiteral = asBoolLiteral(highlight);
8
51
  return `(async () => {
9
- const el = document.querySelector(${selectorLiteral});
10
- if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
52
+ ${buildVisibleElementResolverLiteral(selector)}
11
53
  const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
12
54
  if (${highlightLiteral} && el instanceof HTMLElement) {
13
55
  el.style.outline = '2px solid #ff4d4f';
@@ -17,119 +59,190 @@ export function buildSelectorScrollIntoViewScript({ selector, highlight }) {
17
59
  if (${highlightLiteral} && el instanceof HTMLElement) {
18
60
  el.style.outline = restoreOutline;
19
61
  }
20
- return { ok: true, selector: ${selectorLiteral} };
62
+ return {
63
+ ok: true,
64
+ selector,
65
+ matchedIndex,
66
+ action: 'scrollIntoView',
67
+ highlight: ${highlightLiteral},
68
+ target: { tag: String(el.tagName || '').toLowerCase(), id: el.id || null }
69
+ };
21
70
  })()`;
22
71
  }
23
72
 
24
73
  export function buildSelectorClickScript({ selector, highlight }) {
25
- const selectorLiteral = JSON.stringify(selector);
26
74
  const highlightLiteral = asBoolLiteral(highlight);
27
75
  return `(async () => {
28
- const el = document.querySelector(${selectorLiteral});
29
- if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
76
+ ${buildVisibleElementResolverLiteral(selector)}
30
77
  const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
31
78
  if (${highlightLiteral} && el instanceof HTMLElement) {
32
79
  el.style.outline = '2px solid #ff4d4f';
33
80
  }
34
81
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
35
82
  await new Promise((r) => setTimeout(r, 150));
36
- if (el instanceof HTMLElement) {
37
- try { el.focus({ preventScroll: true }); } catch {}
38
- const common = { bubbles: true, cancelable: true, view: window };
39
- try {
40
- if (typeof PointerEvent === 'function') {
41
- el.dispatchEvent(new PointerEvent('pointerdown', { ...common, pointerType: 'mouse', button: 0 }));
42
- el.dispatchEvent(new PointerEvent('pointerup', { ...common, pointerType: 'mouse', button: 0 }));
43
- }
44
- } catch {}
45
- try { el.dispatchEvent(new MouseEvent('mousedown', { ...common, button: 0 })); } catch {}
46
- try { el.dispatchEvent(new MouseEvent('mouseup', { ...common, button: 0 })); } catch {}
47
- }
48
- if (typeof el.click === 'function') el.click();
49
- else el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, button: 0 }));
83
+ if (el instanceof HTMLElement && typeof el.click === 'function') {
84
+ el.click();
85
+ } else {
86
+ throw new Error('Target element is not clickable');
87
+ }
50
88
  if (${highlightLiteral} && el instanceof HTMLElement) {
51
89
  setTimeout(() => { el.style.outline = restoreOutline; }, 260);
52
90
  }
53
- return { ok: true, selector: ${selectorLiteral}, action: 'click', highlight: ${highlightLiteral} };
91
+ return {
92
+ ok: true,
93
+ selector,
94
+ matchedIndex,
95
+ action: 'click',
96
+ highlight: ${highlightLiteral},
97
+ target: { tag: String(el.tagName || '').toLowerCase(), id: el.id || null }
98
+ };
54
99
  })()`;
55
100
  }
56
101
 
57
102
  export function buildSelectorTypeScript({ selector, highlight, text }) {
58
- const selectorLiteral = JSON.stringify(selector);
59
103
  const highlightLiteral = asBoolLiteral(highlight);
60
104
  const textLiteral = JSON.stringify(String(text || ''));
61
105
  const textLength = String(text || '').length;
62
106
 
63
107
  return `(async () => {
64
- const el = document.querySelector(${selectorLiteral});
65
- if (!el) throw new Error('Element not found: ' + ${selectorLiteral});
108
+ ${buildVisibleElementResolverLiteral(selector)}
109
+ const isTypeable = (node) => {
110
+ if (!node) return false;
111
+ if (node instanceof HTMLInputElement) return !node.disabled && !node.readOnly;
112
+ if (node instanceof HTMLTextAreaElement) return !node.disabled && !node.readOnly;
113
+ return node instanceof HTMLElement && node.isContentEditable;
114
+ };
115
+ if (!isTypeable(el)) {
116
+ throw new Error('Target element is not typeable: ' + selector);
117
+ }
66
118
  const restoreOutline = el instanceof HTMLElement ? el.style.outline : '';
67
119
  if (${highlightLiteral} && el instanceof HTMLElement) {
68
120
  el.style.outline = '2px solid #ff4d4f';
69
121
  }
70
122
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
71
123
  await new Promise((r) => setTimeout(r, 150));
72
- if (el instanceof HTMLElement) {
73
- try { el.focus({ preventScroll: true }); } catch {}
74
- if (typeof el.click === 'function') el.click();
124
+ if (el instanceof HTMLElement && typeof el.focus === 'function') {
125
+ el.focus();
75
126
  }
76
- const value = ${textLiteral};
77
- const fireInputEvent = (target, name, init) => {
78
- try {
79
- if (typeof InputEvent === 'function') {
80
- target.dispatchEvent(new InputEvent(name, init));
81
- return;
82
- }
83
- } catch {}
84
- target.dispatchEvent(new Event(name, { bubbles: true, cancelable: init?.cancelable === true }));
127
+
128
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
129
+ el.value = ${textLiteral};
130
+ } else if (el instanceof HTMLElement && el.isContentEditable) {
131
+ el.textContent = ${textLiteral};
132
+ }
133
+
134
+ el.dispatchEvent(new Event('input', { bubbles: true }));
135
+ el.dispatchEvent(new Event('change', { bubbles: true }));
136
+ if (${highlightLiteral} && el instanceof HTMLElement) {
137
+ setTimeout(() => { el.style.outline = restoreOutline; }, 260);
138
+ }
139
+ return {
140
+ ok: true,
141
+ selector,
142
+ matchedIndex,
143
+ action: 'type',
144
+ length: ${textLength},
145
+ highlight: ${highlightLiteral},
146
+ target: { tag: String(el.tagName || '').toLowerCase(), id: el.id || null }
85
147
  };
86
- const assignControlValue = (target, next) => {
87
- if (target instanceof HTMLInputElement) {
88
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
89
- if (setter) setter.call(target, next);
90
- else target.value = next;
91
- if (typeof target.setSelectionRange === 'function') {
92
- const cursor = String(next).length;
93
- try { target.setSelectionRange(cursor, cursor); } catch {}
94
- }
95
- return true;
96
- }
97
- if (target instanceof HTMLTextAreaElement) {
98
- const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
99
- if (setter) setter.call(target, next);
100
- else target.value = next;
101
- if (typeof target.setSelectionRange === 'function') {
102
- const cursor = String(next).length;
103
- try { target.setSelectionRange(cursor, cursor); } catch {}
104
- }
105
- return true;
148
+ })()`;
149
+ }
150
+
151
+ export function buildScrollTargetScript({ selector, highlight }) {
152
+ const selectorLiteral = JSON.stringify(String(selector || '').trim() || null);
153
+ const highlightLiteral = asBoolLiteral(highlight);
154
+ return `(() => {
155
+ const selector = ${selectorLiteral};
156
+ const isVisible = (node) => {
157
+ if (!(node instanceof Element)) return false;
158
+ const rect = node.getBoundingClientRect?.();
159
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
160
+ try {
161
+ const style = window.getComputedStyle(node);
162
+ if (!style) return false;
163
+ if (style.display === 'none') return false;
164
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
165
+ } catch {
166
+ return false;
106
167
  }
107
- return false;
168
+ return true;
169
+ };
170
+ const isScrollable = (node) => {
171
+ if (!(node instanceof Element)) return false;
172
+ const style = window.getComputedStyle(node);
173
+ const overflowY = String(style.overflowY || '');
174
+ const overflowX = String(style.overflowX || '');
175
+ const yScrollable = (overflowY.includes('auto') || overflowY.includes('scroll') || overflowY.includes('overlay'))
176
+ && (node.scrollHeight - node.clientHeight > 2);
177
+ const xScrollable = (overflowX.includes('auto') || overflowX.includes('scroll') || overflowX.includes('overlay'))
178
+ && (node.scrollWidth - node.clientWidth > 2);
179
+ return yScrollable || xScrollable;
108
180
  };
109
- const editableAssigned = assignControlValue(el, value);
110
- if (!editableAssigned) {
111
- if (el instanceof HTMLElement && el.isContentEditable) {
112
- el.textContent = value;
113
- } else {
114
- throw new Error('Element not editable: ' + ${selectorLiteral});
181
+ const findScrollableAncestor = (node) => {
182
+ let cursor = node instanceof Element ? node : null;
183
+ while (cursor) {
184
+ if (isVisible(cursor) && isScrollable(cursor)) return cursor;
185
+ cursor = cursor.parentElement;
115
186
  }
187
+ return null;
188
+ };
189
+
190
+ let target = null;
191
+ let source = 'document';
192
+ if (selector) {
193
+ const list = Array.from(document.querySelectorAll(selector));
194
+ target = list.find((node) => isVisible(node) && isScrollable(node))
195
+ || list.find((node) => isVisible(node))
196
+ || null;
197
+ if (target) source = 'selector';
116
198
  }
117
- fireInputEvent(el, 'beforeinput', {
118
- bubbles: true,
119
- cancelable: true,
120
- data: value,
121
- inputType: 'insertText',
122
- });
123
- fireInputEvent(el, 'input', {
124
- bubbles: true,
125
- cancelable: false,
126
- data: value,
127
- inputType: 'insertText',
128
- });
129
- el.dispatchEvent(new Event('change', { bubbles: true }));
130
- if (${highlightLiteral} && el instanceof HTMLElement) {
131
- setTimeout(() => { el.style.outline = restoreOutline; }, 260);
199
+ if (!target) {
200
+ const active = document.activeElement instanceof Element ? document.activeElement : null;
201
+ target = findScrollableAncestor(active);
202
+ if (target) source = 'active';
132
203
  }
133
- return { ok: true, selector: ${selectorLiteral}, action: 'type', length: ${textLength}, highlight: ${highlightLiteral} };
204
+ if (!target) {
205
+ const cx = Math.max(1, Math.floor((window.innerWidth || 1) / 2));
206
+ const cy = Math.max(1, Math.floor((window.innerHeight || 1) / 2));
207
+ const point = document.elementFromPoint(cx, cy);
208
+ target = findScrollableAncestor(point);
209
+ if (target) source = 'center';
210
+ }
211
+ if (!target) {
212
+ target = document.scrollingElement || document.documentElement || document.body;
213
+ source = 'document';
214
+ }
215
+ if (!target) {
216
+ throw new Error('No scroll target available');
217
+ }
218
+
219
+ const rect = target.getBoundingClientRect?.() || {
220
+ left: 0,
221
+ top: 0,
222
+ width: window.innerWidth || 1,
223
+ height: window.innerHeight || 1,
224
+ };
225
+ const centerX = Math.max(1, Math.min((window.innerWidth || 1) - 1, Math.round(rect.left + Math.max(1, rect.width / 2))));
226
+ const centerY = Math.max(1, Math.min((window.innerHeight || 1) - 1, Math.round(rect.top + Math.max(1, rect.height / 2))));
227
+
228
+ const restoreOutline = target instanceof HTMLElement ? target.style.outline : '';
229
+ if (${highlightLiteral} && target instanceof HTMLElement) {
230
+ target.style.outline = '2px solid #ff4d4f';
231
+ setTimeout(() => {
232
+ target.style.outline = restoreOutline;
233
+ }, 320);
234
+ }
235
+
236
+ return {
237
+ ok: true,
238
+ selector,
239
+ source,
240
+ highlight: ${highlightLiteral},
241
+ center: { x: centerX, y: centerY },
242
+ target: {
243
+ tag: String(target.tagName || '').toLowerCase(),
244
+ id: target.id || null
245
+ }
246
+ };
134
247
  })()`;
135
248
  }
@@ -32,6 +32,7 @@ export function loadConfig() {
32
32
  return {
33
33
  defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null,
34
34
  repoRoot: typeof raw.repoRoot === 'string' ? raw.repoRoot : null,
35
+ highlightMode: typeof raw.highlightMode === 'boolean' ? raw.highlightMode : true,
35
36
  };
36
37
  }
37
38
 
@@ -83,6 +84,17 @@ export function getDefaultProfile() {
83
84
  return loadConfig().defaultProfile;
84
85
  }
85
86
 
87
+ export function getHighlightMode() {
88
+ return loadConfig().highlightMode !== false;
89
+ }
90
+
91
+ export function setHighlightMode(enabled) {
92
+ const cfg = loadConfig();
93
+ cfg.highlightMode = enabled !== false;
94
+ saveConfig(cfg);
95
+ return cfg.highlightMode;
96
+ }
97
+
86
98
  export function getProfileDir(profileId) {
87
99
  return path.join(PROFILES_DIR, String(profileId || '').trim());
88
100
  }
@@ -21,10 +21,11 @@ INITIALIZATION:
21
21
 
22
22
  CONFIG:
23
23
  config repo-root [path] Get or set persisted webauto repo root
24
+ highlight-mode [status|on|off] Global highlight mode for click/type/scroll (default: on)
24
25
 
25
26
  BROWSER CONTROL:
26
27
  init Ensure camoufox + ensure browser-service daemon
27
- start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
28
+ start [profileId] [--url <url>] [--headless] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
28
29
  stop [profileId]
29
30
  stop --id <instanceId> Stop by instance id
30
31
  stop --alias <alias> Stop by alias
@@ -50,9 +51,9 @@ NAVIGATION:
50
51
  screenshot [profileId] [--output <file>] [--full]
51
52
 
52
53
  INTERACTION:
53
- scroll [profileId] [--down|--up|--left|--right] [--amount <px>]
54
- click [profileId] <selector> Click element by CSS selector
55
- type [profileId] <selector> <text> Type text into element
54
+ scroll [profileId] [--down|--up|--left|--right] [--amount <px>] [--selector <css>] [--highlight|--no-highlight]
55
+ click [profileId] <selector> [--highlight|--no-highlight] Click visible element by CSS selector
56
+ type [profileId] <selector> <text> [--highlight|--no-highlight] Type into visible input element
56
57
  highlight [profileId] <selector> Highlight element (red border, 2s)
57
58
  clear-highlight [profileId] Clear all highlights
58
59
  viewport [profileId] --width <w> --height <h>
@@ -68,6 +69,11 @@ DEVTOOLS:
68
69
  devtools eval [profileId] <expression> [--profile <id>]
69
70
  devtools clear [profileId]
70
71
 
72
+ RECORDING:
73
+ record start [profileId] [--name <name>] [--output <file>] [--overlay|--no-overlay]
74
+ record stop [profileId] [--reason <text>]
75
+ record status [profileId]
76
+
71
77
  COOKIES:
72
78
  cookies get [profileId] Get all cookies for profile
73
79
  cookies save [profileId] --path <file> Save cookies to file
@@ -103,17 +109,22 @@ EXAMPLES:
103
109
  camo start --url https://example.com --alias main
104
110
  camo start worker-1 --headless --alias shard1 --idle-timeout 45m
105
111
  camo start worker-1 --devtools
112
+ camo start worker-1 --record --record-name xhs-debug --record-output ./logs/xhs-debug.jsonl --record-overlay
106
113
  camo start myprofile --width 1920 --height 1020
114
+ camo highlight-mode on
107
115
  camo devtools eval myprofile "document.title"
108
116
  camo devtools logs myprofile --levels error,warn --limit 50
117
+ camo record start myprofile --name session-a --output ./logs/session-a.jsonl
118
+ camo record status myprofile
119
+ camo record stop myprofile
109
120
  camo stop --id inst_xxxxxxxx
110
121
  camo stop --alias shard1
111
122
  camo stop idle
112
123
  camo close all
113
124
  camo goto https://www.xiaohongshu.com
114
- camo scroll --down --amount 500
115
- camo click "#search-input"
116
- camo type "#search-input" "hello world"
125
+ camo scroll --down --amount 500 --selector ".feed-list"
126
+ camo click "#search-input" --highlight
127
+ camo type "#search-input" "hello world" --highlight
117
128
  camo highlight ".post-card"
118
129
  camo viewport --width 1920 --height 1080
119
130
  camo cookies get