@web-auto/camo 0.1.6 → 0.1.8

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
@@ -39,6 +39,15 @@ camo start --url https://example.com --alias main
39
39
  # Start headless worker (auto-kill after idle timeout)
40
40
  camo start worker-1 --headless --alias shard1 --idle-timeout 30m
41
41
 
42
+ # Start with devtools (headful only)
43
+ camo start worker-1 --devtools
44
+
45
+ # Evaluate JS (devtools-style input in page context)
46
+ camo devtools eval worker-1 "document.title"
47
+
48
+ # Read captured console entries
49
+ camo devtools logs worker-1 --levels error,warn --limit 50
50
+
42
51
  # Navigate
43
52
  camo goto https://www.xiaohongshu.com
44
53
 
@@ -71,7 +80,7 @@ camo create fingerprint --os <os> --region <region>
71
80
  ### Browser Control
72
81
 
73
82
  ```bash
74
- camo start [profileId] [--url <url>] [--headless] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
83
+ camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
75
84
  camo stop [profileId]
76
85
  camo stop --id <instanceId>
77
86
  camo stop --alias <alias>
@@ -85,6 +94,7 @@ camo shutdown # Shutdown browser-service (all sessions)
85
94
  If no saved size exists, it defaults to near-fullscreen (full width, slight vertical reserve).
86
95
  Use `--width/--height` to override and update the saved profile size.
87
96
  For headless sessions, default idle timeout is `30m` (auto-stop on inactivity). Use `--idle-timeout` (e.g. `45m`, `1800s`, `0`) to customize.
97
+ Use `--devtools` to open browser developer tools in headed mode (cannot be combined with `--headless`).
88
98
 
89
99
  ### Lifecycle & Cleanup
90
100
 
@@ -118,6 +128,14 @@ camo clear-highlight [profileId] # Clear all highlights
118
128
  camo viewport [profileId] --width <w> --height <h>
119
129
  ```
120
130
 
131
+ ### Devtools
132
+
133
+ ```bash
134
+ camo devtools logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
135
+ camo devtools eval [profileId] <expression> [--profile <id>]
136
+ camo devtools clear [profileId]
137
+ ```
138
+
121
139
  ### Pages
122
140
 
123
141
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,43 @@ 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
+ };
89
126
 
90
127
  const scroller = document.querySelector('.note-scroller')
91
128
  || document.querySelector('.comments-el')
@@ -105,9 +142,8 @@ export function buildCommentsHarvestScript(params = {}) {
105
142
  const nodes = Array.from(document.querySelectorAll('.comment-item, [class*="comment-item"]'));
106
143
  for (const item of nodes) {
107
144
  const textNode = item.querySelector('.content, .comment-content, p');
108
- const authorNode = item.querySelector('.name, .author, .user-name, [class*="author"], [class*="name"]');
109
145
  const text = String((textNode && textNode.textContent) || '').trim();
110
- const author = String((authorNode && authorNode.textContent) || '').trim();
146
+ const author = readAuthor(item, text);
111
147
  if (!text) continue;
112
148
  const key = author + '::' + text;
113
149
  if (commentMap.has(key)) continue;
@@ -74,6 +74,42 @@ 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
+ };
77
113
  const readAttr = (item, attrNames) => {
78
114
  for (const attr of attrNames) {
79
115
  const value = String(item.getAttribute?.(attr) || '').trim();
@@ -81,6 +117,15 @@ function buildCollectLikeTargetsScript() {
81
117
  }
82
118
  return '';
83
119
  };
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
+ };
84
129
 
85
130
  const matchedSet = new Set(
86
131
  Array.isArray(state.matchedComments)
@@ -92,8 +137,8 @@ function buildCollectLikeTargetsScript() {
92
137
  const item = items[index];
93
138
  const text = readText(item, ['.content', '.comment-content', 'p']);
94
139
  if (!text) continue;
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']);
140
+ const userName = readUserName(item, text);
141
+ const userId = readUserId(item);
97
142
  const timestamp = readText(item, ['.date', '.time', '.timestamp', '[class*="time"]']);
98
143
  const likeControl = findLikeControl(item);
99
144
  rows.push({
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { handleSystemCommand } from './commands/system.mjs';
13
13
  import { handleContainerCommand } from './commands/container.mjs';
14
14
  import { handleAutoscriptCommand } from './commands/autoscript.mjs';
15
15
  import { handleEventsCommand } from './commands/events.mjs';
16
+ import { handleDevtoolsCommand } from './commands/devtools.mjs';
16
17
  import {
17
18
  handleStartCommand, handleStopCommand, handleStatusCommand,
18
19
  handleGotoCommand, handleBackCommand, handleScreenshotCommand,
@@ -64,6 +65,13 @@ function inferProfileId(cmd, args) {
64
65
  return positionals[0] || null;
65
66
  }
66
67
 
68
+ if (cmd === 'devtools') {
69
+ const sub = positionals[0] || null;
70
+ if (sub === 'eval' || sub === 'logs' || sub === 'clear') {
71
+ return positionals[1] || null;
72
+ }
73
+ }
74
+
67
75
  if (cmd === 'autoscript' && positionals[0] === 'run') {
68
76
  return explicitProfile || null;
69
77
  }
@@ -192,6 +200,11 @@ async function main() {
192
200
  return;
193
201
  }
194
202
 
203
+ if (cmd === 'devtools') {
204
+ await runTrackedCommand(cmd, args, () => handleDevtoolsCommand(args));
205
+ return;
206
+ }
207
+
195
208
  // Lifecycle commands
196
209
  if (cmd === 'cleanup') {
197
210
  await runTrackedCommand(cmd, args, () => handleCleanupCommand(args));
@@ -237,7 +250,7 @@ async function main() {
237
250
  'start', 'stop', 'close', 'status', 'list', 'goto', 'navigate', 'back', 'screenshot',
238
251
  'new-page', 'close-page', 'switch-page', 'list-pages', 'shutdown',
239
252
  'scroll', 'click', 'type', 'highlight', 'clear-highlight', 'viewport',
240
- 'cookies', 'window', 'mouse', 'system', 'container', 'autoscript', 'events',
253
+ 'cookies', 'window', 'mouse', 'system', 'container', 'autoscript', 'events', 'devtools',
241
254
  ]);
242
255
 
243
256
  if (!serviceCommands.has(cmd)) {
@@ -8,6 +8,10 @@ import {
8
8
  import { callAPI, ensureCamoufox, ensureBrowserService, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
9
9
  import { resolveProfileId, ensureUrlScheme, looksLikeUrlToken, getPositionals } from '../utils/args.mjs';
10
10
  import { acquireLock, releaseLock, cleanupStaleLocks } from '../lifecycle/lock.mjs';
11
+ import {
12
+ buildSelectorClickScript,
13
+ buildSelectorTypeScript,
14
+ } from '../container/runtime-core/operations/selector-scripts.mjs';
11
15
  import {
12
16
  registerSession,
13
17
  updateSession,
@@ -26,6 +30,9 @@ const START_WINDOW_MIN_HEIGHT = 700;
26
30
  const START_WINDOW_MAX_RESERVE = 240;
27
31
  const START_WINDOW_DEFAULT_RESERVE = 72;
28
32
  const DEFAULT_HEADLESS_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
33
+ const DEVTOOLS_SHORTCUTS = process.platform === 'darwin'
34
+ ? ['Meta+Alt+I', 'F12']
35
+ : ['F12', 'Control+Shift+I'];
29
36
 
30
37
  function sleep(ms) {
31
38
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -122,6 +129,67 @@ async function stopAndCleanupProfile(profileId, options = {}) {
122
129
  };
123
130
  }
124
131
 
132
+ async function probeViewportSize(profileId) {
133
+ try {
134
+ const payload = await callAPI('evaluate', {
135
+ profileId,
136
+ script: '(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()',
137
+ });
138
+ const size = payload?.result || payload?.data || payload || {};
139
+ const width = Number(size?.width || 0);
140
+ const height = Number(size?.height || 0);
141
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
142
+ return { width, height };
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ export async function requestDevtoolsOpen(profileId, options = {}) {
149
+ const id = String(profileId || '').trim();
150
+ if (!id) {
151
+ return { ok: false, requested: false, reason: 'profile_required', shortcuts: [] };
152
+ }
153
+
154
+ const shortcuts = Array.isArray(options.shortcuts) && options.shortcuts.length
155
+ ? options.shortcuts.map((item) => String(item || '').trim()).filter(Boolean)
156
+ : DEVTOOLS_SHORTCUTS;
157
+ const settleMs = Math.max(0, parseNumber(options.settleMs, 180));
158
+ const before = await probeViewportSize(id);
159
+ const attempts = [];
160
+
161
+ for (const key of shortcuts) {
162
+ try {
163
+ await callAPI('keyboard:press', { profileId: id, key });
164
+ attempts.push({ key, ok: true });
165
+ if (settleMs > 0) {
166
+ // Allow browser UI animation to settle after shortcut.
167
+ // eslint-disable-next-line no-await-in-loop
168
+ await sleep(settleMs);
169
+ }
170
+ } catch (err) {
171
+ attempts.push({ key, ok: false, error: err?.message || String(err) });
172
+ }
173
+ }
174
+
175
+ const after = await probeViewportSize(id);
176
+ const beforeHeight = Number(before?.height || 0);
177
+ const afterHeight = Number(after?.height || 0);
178
+ const viewportReduced = beforeHeight > 0 && afterHeight > 0 && afterHeight < (beforeHeight - 100);
179
+ const successCount = attempts.filter((item) => item.ok).length;
180
+
181
+ return {
182
+ ok: successCount > 0,
183
+ requested: true,
184
+ shortcuts,
185
+ attempts,
186
+ before,
187
+ after,
188
+ verified: viewportReduced,
189
+ verification: viewportReduced ? 'viewport_height_reduced' : 'shortcut_dispatched',
190
+ };
191
+ }
192
+
125
193
  export function computeTargetViewportFromWindowMetrics(measured) {
126
194
  const innerWidth = Math.max(320, parseNumber(measured?.innerWidth, 0));
127
195
  const innerHeight = Math.max(240, parseNumber(measured?.innerHeight, 0));
@@ -250,8 +318,9 @@ export async function handleStartCommand(args) {
250
318
  const alias = validateAlias(readFlagValue(args, ['--alias']));
251
319
  const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
252
320
  const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
321
+ const wantsDevtools = args.includes('--devtools');
253
322
  if (hasExplicitWidth !== hasExplicitHeight) {
254
- throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
323
+ throw new Error('Usage: camo start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]');
255
324
  }
256
325
  if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
257
326
  throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
@@ -308,7 +377,7 @@ export async function handleStartCommand(args) {
308
377
  alias: alias || null,
309
378
  });
310
379
  const idleState = computeIdleState(record);
311
- console.log(JSON.stringify({
380
+ const payload = {
312
381
  ok: true,
313
382
  sessionId: existing.session_id || existing.profileId,
314
383
  instanceId: record.instanceId,
@@ -323,7 +392,13 @@ export async function handleStartCommand(args) {
323
392
  byId: `camo stop --id ${record.instanceId}`,
324
393
  byAlias: record.alias ? `camo stop --alias ${record.alias}` : null,
325
394
  },
326
- }, null, 2));
395
+ };
396
+ const existingMode = String(existing?.mode || record?.mode || '').toLowerCase();
397
+ const existingHeadless = existing?.headless === true || existingMode.includes('headless');
398
+ if (!existingHeadless && wantsDevtools) {
399
+ payload.devtools = await requestDevtoolsOpen(profileId);
400
+ }
401
+ console.log(JSON.stringify(payload, null, 2));
327
402
  startSessionWatchdog(profileId);
328
403
  return;
329
404
  }
@@ -338,12 +413,16 @@ export async function handleStartCommand(args) {
338
413
  }
339
414
 
340
415
  const headless = args.includes('--headless');
416
+ if (wantsDevtools && headless) {
417
+ throw new Error('--devtools is not supported with --headless');
418
+ }
341
419
  const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
342
420
  const targetUrl = explicitUrl || implicitUrl;
343
421
  const result = await callAPI('start', {
344
422
  profileId,
345
423
  url: targetUrl ? ensureUrlScheme(targetUrl) : undefined,
346
424
  headless,
425
+ devtools: wantsDevtools,
347
426
  });
348
427
 
349
428
  if (result?.ok) {
@@ -416,6 +495,9 @@ export async function handleStartCommand(args) {
416
495
  Number.isFinite(measuredOuterHeight) ? measuredOuterHeight : windowTarget.height,
417
496
  );
418
497
  result.profileWindow = savedWindow?.window || null;
498
+ if (wantsDevtools) {
499
+ result.devtools = await requestDevtoolsOpen(profileId);
500
+ }
419
501
  }
420
502
  }
421
503
  console.log(JSON.stringify(result, null, 2));
@@ -426,6 +508,12 @@ export async function handleStopCommand(args) {
426
508
  const target = rawTarget.toLowerCase();
427
509
  const idTarget = readFlagValue(args, ['--id']);
428
510
  const aliasTarget = readFlagValue(args, ['--alias']);
511
+ if (args.includes('--id') && !idTarget) {
512
+ throw new Error('Usage: camo stop --id <instanceId>');
513
+ }
514
+ if (args.includes('--alias') && !aliasTarget) {
515
+ throw new Error('Usage: camo stop --alias <alias>');
516
+ }
429
517
  const stopIdle = target === 'idle' || args.includes('--idle');
430
518
  const stopAll = target === 'all';
431
519
  const serviceUp = await checkBrowserService();
@@ -465,11 +553,41 @@ export async function handleStopCommand(args) {
465
553
 
466
554
  if (stopIdle) {
467
555
  const now = Date.now();
468
- const idleTargets = listRegisteredSessions()
556
+ const registeredSessions = listRegisteredSessions();
557
+ let liveSessions = [];
558
+ if (serviceUp) {
559
+ try {
560
+ const status = await callAPI('getStatus', {});
561
+ liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
562
+ } catch {
563
+ // Ignore and fallback to local registry.
564
+ }
565
+ }
566
+ const regMap = new Map(
567
+ registeredSessions
568
+ .filter((item) => item && String(item?.status || '').trim() === 'active')
569
+ .map((item) => [String(item.profileId || '').trim(), item]),
570
+ );
571
+ const idleTargets = new Set(
572
+ registeredSessions
469
573
  .filter((item) => String(item?.status || '').trim() === 'active')
470
574
  .map((item) => ({ session: item, idle: computeIdleState(item, now) }))
471
575
  .filter((item) => item.idle.idle)
472
- .map((item) => item.session.profileId);
576
+ .map((item) => item.session.profileId),
577
+ );
578
+ let orphanLiveHeadlessCount = 0;
579
+ for (const live of liveSessions) {
580
+ const liveProfileId = String(live?.profileId || '').trim();
581
+ if (!liveProfileId) continue;
582
+ if (regMap.has(liveProfileId) || idleTargets.has(liveProfileId)) continue;
583
+ const mode = String(live?.mode || '').toLowerCase();
584
+ const liveHeadless = live?.headless === true || mode.includes('headless');
585
+ // Live but unregistered headless sessions are treated as idle-orphan targets.
586
+ if (liveHeadless) {
587
+ idleTargets.add(liveProfileId);
588
+ orphanLiveHeadlessCount += 1;
589
+ }
590
+ }
473
591
  const results = [];
474
592
  for (const profileId of idleTargets) {
475
593
  // eslint-disable-next-line no-await-in-loop
@@ -479,7 +597,8 @@ export async function handleStopCommand(args) {
479
597
  ok: true,
480
598
  mode: 'idle',
481
599
  serviceUp,
482
- targetCount: idleTargets.length,
600
+ targetCount: idleTargets.size,
601
+ orphanLiveHeadlessCount,
483
602
  closed: results.filter((item) => item.ok).length,
484
603
  failed: results.filter((item) => !item.ok).length,
485
604
  results,
@@ -652,14 +771,7 @@ export async function handleClickCommand(args) {
652
771
 
653
772
  const result = await callAPI('evaluate', {
654
773
  profileId,
655
- script: `(async () => {
656
- const el = document.querySelector(${JSON.stringify(selector)});
657
- if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
658
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
659
- await new Promise(r => setTimeout(r, 200));
660
- el.click();
661
- return { clicked: true, selector: ${JSON.stringify(selector)} };
662
- })()`
774
+ script: buildSelectorClickScript({ selector, highlight: false }),
663
775
  });
664
776
  console.log(JSON.stringify(result, null, 2));
665
777
  }
@@ -686,18 +798,7 @@ export async function handleTypeCommand(args) {
686
798
 
687
799
  const result = await callAPI('evaluate', {
688
800
  profileId,
689
- script: `(async () => {
690
- const el = document.querySelector(${JSON.stringify(selector)});
691
- if (!el) throw new Error('Element not found: ' + ${JSON.stringify(selector)});
692
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
693
- await new Promise(r => setTimeout(r, 200));
694
- el.focus();
695
- el.value = '';
696
- el.value = ${JSON.stringify(text)};
697
- el.dispatchEvent(new Event('input', { bubbles: true }));
698
- el.dispatchEvent(new Event('change', { bubbles: true }));
699
- return { typed: true, selector: ${JSON.stringify(selector)}, length: ${text.length} };
700
- })()`
801
+ script: buildSelectorTypeScript({ selector, highlight: false, text }),
701
802
  });
702
803
  console.log(JSON.stringify(result, null, 2));
703
804
  }
@@ -0,0 +1,349 @@
1
+ import { getDefaultProfile, listProfiles } from '../utils/config.mjs';
2
+ import { callAPI } from '../utils/browser-service.mjs';
3
+
4
+ const DEFAULT_LIMIT = 120;
5
+ const MAX_LIMIT = 1000;
6
+
7
+ function parseNumber(value, fallback) {
8
+ const num = Number(value);
9
+ return Number.isFinite(num) ? num : fallback;
10
+ }
11
+
12
+ function clamp(value, min, max) {
13
+ return Math.min(Math.max(value, min), max);
14
+ }
15
+
16
+ function readFlagValue(args, names) {
17
+ for (let i = 0; i < args.length; i += 1) {
18
+ if (!names.includes(args[i])) continue;
19
+ const value = args[i + 1];
20
+ if (!value || String(value).startsWith('-')) return null;
21
+ return value;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function collectPositionals(args, startIndex = 2) {
27
+ const values = [];
28
+ for (let i = startIndex; i < args.length; i += 1) {
29
+ const token = args[i];
30
+ if (!token || String(token).startsWith('--')) {
31
+ continue;
32
+ }
33
+ const prev = args[i - 1];
34
+ if (prev && ['--profile', '-p', '--limit', '-n', '--levels', '--since'].includes(prev)) {
35
+ continue;
36
+ }
37
+ values.push(String(token));
38
+ }
39
+ return values;
40
+ }
41
+
42
+ function pickProfileAndExpression(args, subcommand) {
43
+ const explicitProfile = readFlagValue(args, ['--profile', '-p']);
44
+ const profileSet = new Set(listProfiles());
45
+ const positionals = collectPositionals(args, 2);
46
+
47
+ let profileId = explicitProfile || null;
48
+ let expression = null;
49
+
50
+ if (subcommand === 'eval') {
51
+ if (positionals.length === 0) {
52
+ return { profileId: profileId || getDefaultProfile(), expression: null };
53
+ }
54
+ if (!profileId && positionals.length >= 2) {
55
+ profileId = positionals[0];
56
+ expression = positionals.slice(1).join(' ').trim();
57
+ } else if (!profileId && profileSet.has(positionals[0])) {
58
+ profileId = positionals[0];
59
+ expression = positionals.slice(1).join(' ').trim();
60
+ } else {
61
+ expression = positionals.join(' ').trim();
62
+ }
63
+ return { profileId: profileId || getDefaultProfile(), expression };
64
+ }
65
+
66
+ if (positionals.length > 0) {
67
+ if (!profileId && (profileSet.has(positionals[0]) || subcommand === 'logs' || subcommand === 'clear')) {
68
+ profileId = positionals[0];
69
+ }
70
+ }
71
+ return { profileId: profileId || getDefaultProfile(), expression: null };
72
+ }
73
+
74
+ function buildConsoleInstallScript(maxEntries) {
75
+ return `(function installCamoDevtoolsConsoleCollector() {
76
+ const KEY = '__camo_console_collector_v1__';
77
+ const BUFFER_KEY = '__camo_console_buffer_v1__';
78
+ const MAX = ${Math.max(100, Math.floor(maxEntries || MAX_LIMIT))};
79
+ const now = () => Date.now();
80
+
81
+ const stringify = (value) => {
82
+ if (typeof value === 'string') return value;
83
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
84
+ if (value === null) return 'null';
85
+ if (typeof value === 'undefined') return 'undefined';
86
+ if (typeof value === 'function') return '[function]';
87
+ if (typeof value === 'symbol') return String(value);
88
+ if (value instanceof Error) return value.stack || value.message || String(value);
89
+ try {
90
+ return JSON.stringify(value);
91
+ } catch {
92
+ return Object.prototype.toString.call(value);
93
+ }
94
+ };
95
+
96
+ const pushEntry = (level, args) => {
97
+ const target = window[BUFFER_KEY];
98
+ if (!Array.isArray(target)) return;
99
+ const text = Array.from(args || []).map(stringify).join(' ');
100
+ target.push({
101
+ ts: now(),
102
+ level,
103
+ text,
104
+ href: String(window.location?.href || ''),
105
+ });
106
+ if (target.length > MAX) {
107
+ target.splice(0, target.length - MAX);
108
+ }
109
+ };
110
+
111
+ if (!Array.isArray(window[BUFFER_KEY])) {
112
+ window[BUFFER_KEY] = [];
113
+ }
114
+
115
+ if (!window[KEY]) {
116
+ const levels = ['log', 'info', 'warn', 'error', 'debug'];
117
+ const originals = {};
118
+ for (const level of levels) {
119
+ const raw = typeof console[level] === 'function' ? console[level] : console.log;
120
+ originals[level] = raw.bind(console);
121
+ console[level] = (...args) => {
122
+ try {
123
+ pushEntry(level, args);
124
+ } catch {}
125
+ return originals[level](...args);
126
+ };
127
+ }
128
+
129
+ window.addEventListener('error', (event) => {
130
+ try {
131
+ const message = event?.message || 'window.error';
132
+ pushEntry('error', [message]);
133
+ } catch {}
134
+ });
135
+ window.addEventListener('unhandledrejection', (event) => {
136
+ try {
137
+ const reason = event?.reason instanceof Error
138
+ ? (event.reason.stack || event.reason.message)
139
+ : stringify(event?.reason);
140
+ pushEntry('error', ['unhandledrejection', reason]);
141
+ } catch {}
142
+ });
143
+
144
+ window[KEY] = { installedAt: now(), max: MAX };
145
+ }
146
+
147
+ return {
148
+ ok: true,
149
+ installed: true,
150
+ entries: Array.isArray(window[BUFFER_KEY]) ? window[BUFFER_KEY].length : 0,
151
+ max: MAX,
152
+ };
153
+ })();`;
154
+ }
155
+
156
+ function buildConsoleReadScript(options = {}) {
157
+ const limit = clamp(parseNumber(options.limit, DEFAULT_LIMIT), 1, MAX_LIMIT);
158
+ const sinceTs = Math.max(0, parseNumber(options.sinceTs, 0) || 0);
159
+ const clear = options.clear === true;
160
+ const levels = Array.isArray(options.levels)
161
+ ? options.levels.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)
162
+ : [];
163
+ const levelsLiteral = JSON.stringify(levels);
164
+
165
+ return `(function readCamoDevtoolsConsole() {
166
+ const BUFFER_KEY = '__camo_console_buffer_v1__';
167
+ const raw = Array.isArray(window[BUFFER_KEY]) ? window[BUFFER_KEY] : [];
168
+ const levelSet = new Set(${levelsLiteral});
169
+ const list = raw.filter((entry) => {
170
+ if (!entry || typeof entry !== 'object') return false;
171
+ const ts = Number(entry.ts || 0);
172
+ if (ts < ${sinceTs}) return false;
173
+ if (levelSet.size === 0) return true;
174
+ return levelSet.has(String(entry.level || '').toLowerCase());
175
+ });
176
+ const entries = list.slice(Math.max(0, list.length - ${limit}));
177
+ if (${clear ? 'true' : 'false'}) {
178
+ window[BUFFER_KEY] = [];
179
+ }
180
+ return {
181
+ ok: true,
182
+ total: raw.length,
183
+ returned: entries.length,
184
+ sinceTs: ${sinceTs},
185
+ levels: Array.from(levelSet),
186
+ cleared: ${clear ? 'true' : 'false'},
187
+ entries,
188
+ };
189
+ })();`;
190
+ }
191
+
192
+ function buildEvalScript(expression) {
193
+ return `(async function runCamoDevtoolsEval() {
194
+ const expr = ${JSON.stringify(expression || '')};
195
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
196
+ const resultPayload = { ok: true, mode: 'expression', value: null, valueType: null };
197
+
198
+ const toSerializable = (value, depth = 0, seen = new WeakSet()) => {
199
+ if (value === null) return null;
200
+ if (typeof value === 'undefined') return '[undefined]';
201
+ if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value;
202
+ if (typeof value === 'bigint') return value.toString();
203
+ if (typeof value === 'function') return '[function]';
204
+ if (typeof value === 'symbol') return value.toString();
205
+ if (value instanceof Error) return { name: value.name, message: value.message, stack: value.stack || null };
206
+ if (depth >= 3) return '[max-depth]';
207
+ if (Array.isArray(value)) return value.slice(0, 30).map((item) => toSerializable(item, depth + 1, seen));
208
+ if (typeof value === 'object') {
209
+ if (seen.has(value)) return '[circular]';
210
+ seen.add(value);
211
+ const out = {};
212
+ const keys = Object.keys(value).slice(0, 30);
213
+ for (const key of keys) {
214
+ out[key] = toSerializable(value[key], depth + 1, seen);
215
+ }
216
+ return out;
217
+ }
218
+ try {
219
+ return JSON.parse(JSON.stringify(value));
220
+ } catch {
221
+ return String(value);
222
+ }
223
+ };
224
+
225
+ try {
226
+ const fn = new AsyncFunction('return (' + expr + ')');
227
+ const value = await fn();
228
+ resultPayload.value = toSerializable(value);
229
+ resultPayload.valueType = typeof value;
230
+ return resultPayload;
231
+ } catch (exprError) {
232
+ try {
233
+ const fn = new AsyncFunction(expr);
234
+ const value = await fn();
235
+ resultPayload.mode = 'statement';
236
+ resultPayload.value = toSerializable(value);
237
+ resultPayload.valueType = typeof value;
238
+ return resultPayload;
239
+ } catch (statementError) {
240
+ return {
241
+ ok: false,
242
+ mode: 'statement',
243
+ error: {
244
+ message: statementError?.message || String(statementError),
245
+ stack: statementError?.stack || null,
246
+ expressionError: exprError?.message || String(exprError),
247
+ },
248
+ };
249
+ }
250
+ }
251
+ })();`;
252
+ }
253
+
254
+ async function ensureConsoleCollector(profileId, maxEntries = MAX_LIMIT) {
255
+ return callAPI('evaluate', {
256
+ profileId,
257
+ script: buildConsoleInstallScript(maxEntries),
258
+ });
259
+ }
260
+
261
+ async function handleLogs(args) {
262
+ const { profileId } = pickProfileAndExpression(args, 'logs');
263
+ if (!profileId) {
264
+ throw new Error('No default profile set. Run: camo profile default <profileId>');
265
+ }
266
+ const limit = clamp(parseNumber(readFlagValue(args, ['--limit', '-n']), DEFAULT_LIMIT), 1, MAX_LIMIT);
267
+ const sinceTs = Math.max(0, parseNumber(readFlagValue(args, ['--since']), 0) || 0);
268
+ const levelsRaw = readFlagValue(args, ['--levels', '--level']) || '';
269
+ const levels = levelsRaw
270
+ .split(',')
271
+ .map((item) => String(item || '').trim().toLowerCase())
272
+ .filter(Boolean);
273
+ const clear = args.includes('--clear');
274
+
275
+ const install = await ensureConsoleCollector(profileId, MAX_LIMIT);
276
+ const result = await callAPI('evaluate', {
277
+ profileId,
278
+ script: buildConsoleReadScript({ limit, sinceTs, levels, clear }),
279
+ });
280
+
281
+ console.log(JSON.stringify({
282
+ ok: true,
283
+ command: 'devtools.logs',
284
+ profileId,
285
+ collector: install?.result || install?.data || install || null,
286
+ result: result?.result || result?.data || result || null,
287
+ }, null, 2));
288
+ }
289
+
290
+ async function handleClear(args) {
291
+ const { profileId } = pickProfileAndExpression(args, 'clear');
292
+ if (!profileId) {
293
+ throw new Error('No default profile set. Run: camo profile default <profileId>');
294
+ }
295
+ await ensureConsoleCollector(profileId, MAX_LIMIT);
296
+ const result = await callAPI('evaluate', {
297
+ profileId,
298
+ script: buildConsoleReadScript({ limit: MAX_LIMIT, sinceTs: 0, clear: true }),
299
+ });
300
+ console.log(JSON.stringify({
301
+ ok: true,
302
+ command: 'devtools.clear',
303
+ profileId,
304
+ result: result?.result || result?.data || result || null,
305
+ }, null, 2));
306
+ }
307
+
308
+ async function handleEval(args) {
309
+ const { profileId, expression } = pickProfileAndExpression(args, 'eval');
310
+ if (!profileId) {
311
+ throw new Error('No default profile set. Run: camo profile default <profileId>');
312
+ }
313
+ if (!expression) {
314
+ throw new Error('Usage: camo devtools eval [profileId] <expression> [--profile <id>]');
315
+ }
316
+
317
+ await ensureConsoleCollector(profileId, MAX_LIMIT);
318
+ const result = await callAPI('evaluate', {
319
+ profileId,
320
+ script: buildEvalScript(expression),
321
+ });
322
+ console.log(JSON.stringify({
323
+ ok: true,
324
+ command: 'devtools.eval',
325
+ profileId,
326
+ expression,
327
+ result: result?.result || result?.data || result || null,
328
+ }, null, 2));
329
+ }
330
+
331
+ export async function handleDevtoolsCommand(args) {
332
+ const sub = String(args[1] || '').trim().toLowerCase();
333
+ switch (sub) {
334
+ case 'logs':
335
+ return handleLogs(args);
336
+ case 'clear':
337
+ return handleClear(args);
338
+ case 'eval':
339
+ return handleEval(args);
340
+ default:
341
+ console.log(`Usage: camo devtools <logs|eval|clear> [options]
342
+
343
+ Commands:
344
+ logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
345
+ eval [profileId] <expression> [--profile <id>]
346
+ clear [profileId]
347
+ `);
348
+ }
349
+ }
@@ -33,7 +33,20 @@ export function buildSelectorClickScript({ selector, highlight }) {
33
33
  }
34
34
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
35
35
  await new Promise((r) => setTimeout(r, 150));
36
- el.click();
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 }));
37
50
  if (${highlightLiteral} && el instanceof HTMLElement) {
38
51
  setTimeout(() => { el.style.outline = restoreOutline; }, 260);
39
52
  }
@@ -56,9 +69,63 @@ export function buildSelectorTypeScript({ selector, highlight, text }) {
56
69
  }
57
70
  el.scrollIntoView({ behavior: 'smooth', block: 'center' });
58
71
  await new Promise((r) => setTimeout(r, 150));
59
- el.focus();
60
- el.value = ${textLiteral};
61
- el.dispatchEvent(new Event('input', { bubbles: true }));
72
+ if (el instanceof HTMLElement) {
73
+ try { el.focus({ preventScroll: true }); } catch {}
74
+ if (typeof el.click === 'function') el.click();
75
+ }
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 }));
85
+ };
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;
106
+ }
107
+ return false;
108
+ };
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});
115
+ }
116
+ }
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
+ });
62
129
  el.dispatchEvent(new Event('change', { bubbles: true }));
63
130
  if (${highlightLiteral} && el instanceof HTMLElement) {
64
131
  setTimeout(() => { el.style.outline = restoreOutline; }, 260);
@@ -24,7 +24,7 @@ CONFIG:
24
24
 
25
25
  BROWSER CONTROL:
26
26
  init Ensure camoufox + ensure browser-service daemon
27
- start [profileId] [--url <url>] [--headless] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
27
+ start [profileId] [--url <url>] [--headless] [--devtools] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h>]
28
28
  stop [profileId]
29
29
  stop --id <instanceId> Stop by instance id
30
30
  stop --alias <alias> Stop by alias
@@ -63,6 +63,11 @@ PAGES:
63
63
  switch-page [profileId] <index>
64
64
  list-pages [profileId]
65
65
 
66
+ DEVTOOLS:
67
+ devtools logs [profileId] [--limit 120] [--since <unix_ms>] [--levels error,warn] [--clear]
68
+ devtools eval [profileId] <expression> [--profile <id>]
69
+ devtools clear [profileId]
70
+
66
71
  COOKIES:
67
72
  cookies get [profileId] Get all cookies for profile
68
73
  cookies save [profileId] --path <file> Save cookies to file
@@ -97,7 +102,10 @@ EXAMPLES:
97
102
  camo profile default myprofile
98
103
  camo start --url https://example.com --alias main
99
104
  camo start worker-1 --headless --alias shard1 --idle-timeout 45m
105
+ camo start worker-1 --devtools
100
106
  camo start myprofile --width 1920 --height 1020
107
+ camo devtools eval myprofile "document.title"
108
+ camo devtools logs myprofile --levels error,warn --limit 50
101
109
  camo stop --id inst_xxxxxxxx
102
110
  camo stop --alias shard1
103
111
  camo stop idle