@web-auto/camo 0.1.24 → 0.1.26

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.
Files changed (27) hide show
  1. package/README.md +46 -0
  2. package/package.json +1 -1
  3. package/src/cli.mjs +9 -0
  4. package/src/commands/browser.mjs +9 -7
  5. package/src/container/change-notifier.mjs +90 -39
  6. package/src/container/runtime-core/operations/index.mjs +108 -48
  7. package/src/container/runtime-core/operations/tab-pool.mjs +301 -99
  8. package/src/container/runtime-core/operations/tab-pool.mjs.bak +762 -0
  9. package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +762 -0
  10. package/src/container/runtime-core/operations/viewport.mjs +46 -0
  11. package/src/container/runtime-core/subscription.mjs +72 -7
  12. package/src/container/runtime-core/validation.mjs +61 -4
  13. package/src/core/utils.mjs +4 -0
  14. package/src/services/browser-service/index.js +27 -10
  15. package/src/services/browser-service/index.js.bak +671 -0
  16. package/src/services/browser-service/internal/BrowserSession.js +34 -2
  17. package/src/services/browser-service/internal/browser-session/input-ops.js +60 -0
  18. package/src/services/browser-service/internal/browser-session/input-pipeline.js +3 -1
  19. package/src/services/browser-service/internal/browser-session/page-management.js +120 -58
  20. package/src/services/browser-service/internal/browser-session/page-management.test.js +43 -0
  21. package/src/services/browser-service/internal/browser-session/utils.js +6 -0
  22. package/src/services/controller/controller.js +1 -1
  23. package/src/services/controller/transport.js +8 -1
  24. package/src/utils/args.mjs +1 -0
  25. package/src/utils/browser-service.mjs +13 -1
  26. package/src/utils/command-log.mjs +64 -0
  27. package/src/utils/help.mjs +3 -3
package/README.md CHANGED
@@ -473,6 +473,7 @@ Condition types:
473
473
 
474
474
  ### Environment Variables
475
475
 
476
+ - `CAMO_INPUT_MODE` - Input mode: `playwright` (default) or `cdp`. CDP mode uses `Input.dispatchMouseEvent` via Chrome DevTools Protocol, bypassing OS-level input system. Does not require window foreground. See [CDP Input Mode](#cdp-input-mode) below.
476
477
  - `CAMO_BROWSER_URL` - Browser service URL (default: `http://127.0.0.1:7704`)
477
478
  - `CAMO_INSTALL_DIR` - `@web-auto/camo` 安装目录(可选,首次安装兜底)
478
479
  - `CAMO_REPO_ROOT` - Camo repository root (optional, dev mode)
@@ -484,6 +485,51 @@ Condition types:
484
485
  - `CAMO_PROGRESS_WS_HOST` / `CAMO_PROGRESS_WS_PORT` - Progress websocket daemon bind address (default: `127.0.0.1:7788`)
485
486
  - `CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE` - Reserved vertical pixels for default headful auto-size
486
487
 
488
+ ### CDP Input Mode
489
+
490
+ By default, Camo uses Playwright's high-level input API (`page.mouse.click`), which goes through the OS input system and requires the browser window to be in the foreground. This can cause hangs (up to 30s timeout) on Windows when the window loses focus.
491
+
492
+ CDP mode sends mouse events directly via the Chrome DevTools Protocol (`Input.dispatchMouseEvent`), which:
493
+
494
+ - **Does not require window foreground** — works with minimized, background, or headless windows
495
+ - **Does not depend on OS input system** — no `bringToFront`, no `ensureInputReady`
496
+ - **Bypasses input pipeline checks** — no 30s timeout risk from `ensureInputReady` hanging
497
+
498
+ #### How to enable
499
+
500
+ ```bash
501
+ # Environment variable (recommended)
502
+ CAMO_INPUT_MODE=cdp camo start xhs-qa-1 --url https://www.xiaohongshu.com
503
+
504
+ # Or set in shell profile
505
+ export CAMO_INPUT_MODE=cdp
506
+ ```
507
+
508
+ #### Behavior differences
509
+
510
+ | Feature | Playwright (default) | CDP mode |
511
+ |---------|---------------------|----------|
512
+ | Window foreground required | Yes | No |
513
+ | OS input system | Yes | No |
514
+ | Auto-scroll to element | Yes (via Playwright) | No (caller must ensure element in viewport) |
515
+ | `ensureInputReady` check | Yes (can hang 30s) | Skipped |
516
+ | `bringToFront` | Yes (default) | Skipped |
517
+ | Nudge/recovery on timeout | Yes | No (fast fail) |
518
+ | Input coordinate system | Viewport-relative | Viewport-relative (same) |
519
+
520
+ #### Limitations
521
+
522
+ - **Element must be in viewport**: CDP clicks at coordinates only. If the target element is scrolled out of view, the click will miss. Callers (like webauto's `clickPoint`) already resolve viewport-relative coordinates via `getBoundingClientRect`.
523
+ - **No auto-scroll**: Unlike Playwright's `page.click(selector)`, CDP mode does not scroll to bring elements into view.
524
+ - **keyboard operations still use Playwright**: `keyboard:press` and `keyboard:type` are not affected by CDP mode (they already work reliably in background via Playwright's keyboard API).
525
+
526
+ #### Related environment variables
527
+
528
+ - `CAMO_INPUT_ACTION_TIMEOUT_MS` — Max wait for input action (default: 30000)
529
+ - `CAMO_INPUT_ACTION_MAX_ATTEMPTS` — Retry count on failure (default: 2)
530
+ - `CAMO_INPUT_READY_SETTLE_MS` — Settle time after input ready (default: 80)
531
+ - `CAMO_BRING_TO_FRONT_MODE` — `never` (skip) or `auto` (default, bring window to front)
532
+
487
533
  ## Session Persistence
488
534
 
489
535
  Camo CLI persists session information locally:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  import { handleSessionWatchdogCommand } from './lifecycle/session-watchdog.mjs';
34
34
  import { safeAppendProgressEvent } from './events/progress-log.mjs';
35
35
  import { ensureProgressEventDaemon } from './events/daemon.mjs';
36
+ import { appendCommandLog, buildCommandSenderMeta } from './utils/command-log.mjs';
36
37
 
37
38
  const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
38
39
  const PACKAGE_JSON_PATH = path.resolve(CURRENT_DIR, '..', 'package.json');
@@ -119,6 +120,14 @@ function inferProfileId(cmd, args) {
119
120
  async function runTrackedCommand(cmd, args, fn) {
120
121
  const startedAt = Date.now();
121
122
  const profileId = inferProfileId(cmd, args);
123
+ appendCommandLog({
124
+ action: cmd,
125
+ command: cmd,
126
+ profileId,
127
+ args: args.slice(1),
128
+ payload: { mode: 'cli' },
129
+ meta: buildCommandSenderMeta({ source: 'cli', cwd: process.cwd(), argv: process.argv.slice() }),
130
+ });
122
131
  safeAppendProgressEvent({
123
132
  source: 'cli.command',
124
133
  mode: cmd === 'autoscript' ? 'autoscript' : 'normal',
@@ -496,6 +496,7 @@ export async function handleStartCommand(args) {
496
496
  const alias = validateAlias(readFlagValue(args, ['--alias']));
497
497
  const idleTimeoutRaw = readFlagValue(args, ['--idle-timeout']);
498
498
  const parsedIdleTimeoutMs = parseDurationMs(idleTimeoutRaw, DEFAULT_HEADLESS_IDLE_TIMEOUT_MS);
499
+ const maxTabs = Math.max(1, Math.floor(Number(readFlagValue(args, ['--max-tabs']) || 1) || 1));
499
500
  const wantsDevtools = args.includes('--devtools');
500
501
  const wantsRecord = args.includes('--record');
501
502
  const recordName = readFlagValue(args, ['--record-name']);
@@ -506,7 +507,7 @@ export async function handleStartCommand(args) {
506
507
  ? true
507
508
  : null;
508
509
  if (hasExplicitWidth !== hasExplicitHeight) {
509
- 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>]');
510
+ throw new Error('Usage: camo start [profileId] [--url <url>] [--no-headless|--visible] [--devtools] [--record] [--record-name <name>] [--record-output <path>] [--record-overlay|--no-record-overlay] [--alias <name>] [--idle-timeout <duration>] [--width <w> --height <h> --max-tabs <n>]');
510
511
  }
511
512
  if ((hasExplicitWidth && explicitWidth < START_WINDOW_MIN_WIDTH) || (hasExplicitHeight && explicitHeight < START_WINDOW_MIN_HEIGHT)) {
512
513
  throw new Error(`Window size too small. Minimum is ${START_WINDOW_MIN_WIDTH}x${START_WINDOW_MIN_HEIGHT}`);
@@ -527,8 +528,8 @@ export async function handleStartCommand(args) {
527
528
  const arg = args[i];
528
529
  if (arg === '--url') { i++; continue; }
529
530
  if (arg === '--width' || arg === '--height') { i++; continue; }
530
- if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output') { i++; continue; }
531
- if (arg === '--headless') continue;
531
+ if (arg === '--alias' || arg === '--idle-timeout' || arg === '--record-name' || arg === '--record-output' || arg === '--max-tabs') { i++; continue; }
532
+ if (arg === '--headless' || arg === '--no-headless' || arg === '--visible') continue;
532
533
  if (arg === '--record' || arg === '--record-overlay' || arg === '--no-record-overlay') continue;
533
534
  if (arg.startsWith('--')) continue;
534
535
 
@@ -615,9 +616,9 @@ export async function handleStartCommand(args) {
615
616
  releaseLock(profileId);
616
617
  }
617
618
 
618
- const headless = args.includes('--headless');
619
+ const headless = !args.includes('--no-headless') && !args.includes('--visible');
619
620
  if (wantsDevtools && headless) {
620
- throw new Error('--devtools is not supported with --headless');
621
+ throw new Error('--devtools requires --no-headless or --visible mode');
621
622
  }
622
623
  const idleTimeoutMs = headless ? parsedIdleTimeoutMs : 0;
623
624
  const targetUrl = explicitUrl || implicitUrl;
@@ -627,6 +628,7 @@ export async function handleStartCommand(args) {
627
628
  headless,
628
629
  devtools: wantsDevtools,
629
630
  ...(wantsRecord ? { record: true } : {}),
631
+ ...(Number.isFinite(maxTabs) ? { maxTabs } : {}),
630
632
  ...(recordName ? { recordName } : {}),
631
633
  ...(recordOutput ? { recordOutput } : {}),
632
634
  ...(recordOverlay !== null ? { recordOverlay } : {}),
@@ -655,8 +657,8 @@ export async function handleStartCommand(args) {
655
657
  all: 'camo close all',
656
658
  };
657
659
  result.message = headless
658
- ? `Started headless session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
659
- : 'Started session. Remember to stop it when finished.';
660
+ ? `Started session. Idle timeout: ${formatDurationMs(idleTimeoutMs)}`
661
+ : 'Started visible session. Remember to stop it when finished.';
660
662
 
661
663
  if (!headless) {
662
664
  let windowTarget = null;
@@ -26,30 +26,43 @@ function parseCssSelector(css) {
26
26
  const raw = typeof css === 'string' ? css.trim() : '';
27
27
  if (!raw) return [];
28
28
  const attrRegex = /\[\s*([^\s~|^$*=\]]+)\s*(\*=|\^=|\$=|=)?\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))?\s*\]/g;
29
+ const parseSegment = (item) => {
30
+ const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
31
+ const idMatch = item.match(/#([\w-]+)/);
32
+ const classMatches = item.match(/\.([\w-]+)/g) || [];
33
+ const attrs = [];
34
+ let attrMatch = attrRegex.exec(item);
35
+ while (attrMatch) {
36
+ attrs.push({
37
+ name: String(attrMatch[1] || '').toLowerCase(),
38
+ op: attrMatch[2] || 'exists',
39
+ value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
40
+ });
41
+ attrMatch = attrRegex.exec(item);
42
+ }
43
+ attrRegex.lastIndex = 0;
44
+ return {
45
+ raw: item,
46
+ tag: tagMatch ? tagMatch[0].toLowerCase() : null,
47
+ id: idMatch ? idMatch[1] : null,
48
+ classes: classMatches.map((token) => token.slice(1)),
49
+ attrs,
50
+ };
51
+ };
29
52
  return raw
30
53
  .split(',')
31
54
  .map((item) => item.trim())
32
55
  .filter(Boolean)
33
56
  .map((item) => {
34
- const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
35
- const idMatch = item.match(/#([\w-]+)/);
36
- const classMatches = item.match(/\.([\w-]+)/g) || [];
37
- const attrs = [];
38
- let attrMatch = attrRegex.exec(item);
39
- while (attrMatch) {
40
- attrs.push({
41
- name: String(attrMatch[1] || '').toLowerCase(),
42
- op: attrMatch[2] || 'exists',
43
- value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
44
- });
45
- attrMatch = attrRegex.exec(item);
46
- }
47
- attrRegex.lastIndex = 0;
57
+ const segments = item
58
+ .split(/\s+/)
59
+ .map((segment) => segment.trim())
60
+ .filter(Boolean)
61
+ .map((segment) => parseSegment(segment));
48
62
  return {
49
- tag: tagMatch ? tagMatch[0].toLowerCase() : null,
50
- id: idMatch ? idMatch[1] : null,
51
- classes: classMatches.map((token) => token.slice(1)),
52
- attrs,
63
+ raw: item,
64
+ segments,
65
+ ...parseSegment(item),
53
66
  };
54
67
  });
55
68
  }
@@ -82,6 +95,50 @@ function matchAttribute(node, attrSpec, nodeId, nodeClasses) {
82
95
  return false;
83
96
  }
84
97
 
98
+ function nodeMatchesCssSegment(node, cssSegment) {
99
+ const nodeTag = typeof node?.tag === 'string' ? node.tag.toLowerCase() : null;
100
+ const nodeId = typeof node?.id === 'string' ? node.id : null;
101
+ const nodeClasses = new Set(Array.isArray(node?.classes) ? node.classes : []);
102
+
103
+ const hasConstraints = Boolean(
104
+ cssSegment?.tag
105
+ || cssSegment?.id
106
+ || (cssSegment?.classes && cssSegment.classes.length > 0)
107
+ || (cssSegment?.attrs && cssSegment.attrs.length > 0),
108
+ );
109
+ if (!hasConstraints) return false;
110
+
111
+ let matched = true;
112
+ if (cssSegment.tag && nodeTag !== cssSegment.tag) matched = false;
113
+ if (cssSegment.id && nodeId !== cssSegment.id) matched = false;
114
+ if (matched && cssSegment.classes.length > 0) {
115
+ matched = cssSegment.classes.every((className) => nodeClasses.has(className));
116
+ }
117
+ if (matched && cssSegment.attrs.length > 0) {
118
+ matched = cssSegment.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
119
+ }
120
+ return matched;
121
+ }
122
+
123
+ function matchesAncestorChain(ancestors, segments) {
124
+ if (!Array.isArray(segments) || segments.length === 0) return true;
125
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return false;
126
+ let ancestorIndex = ancestors.length - 1;
127
+ for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
128
+ let found = false;
129
+ while (ancestorIndex >= 0) {
130
+ if (nodeMatchesCssSegment(ancestors[ancestorIndex], segments[segmentIndex])) {
131
+ found = true;
132
+ ancestorIndex -= 1;
133
+ break;
134
+ }
135
+ ancestorIndex -= 1;
136
+ }
137
+ if (!found) return false;
138
+ }
139
+ return true;
140
+ }
141
+
85
142
  export class ChangeNotifier {
86
143
  constructor() {
87
144
  this.subscriptions = new Map(); // topic -> Set<callback>
@@ -214,17 +271,22 @@ export class ChangeNotifier {
214
271
  const normalized = normalizeSelector(selector);
215
272
  const runtimeContext = context || {
216
273
  viewport: node?.__viewport || null,
274
+ ancestors: [],
217
275
  };
218
276
 
219
277
  // Check if current node matches
220
- if (this.nodeMatchesSelector(node, normalized) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
278
+ if (this.nodeMatchesSelector(node, normalized, runtimeContext.ancestors) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
221
279
  results.push({ ...node, path });
222
280
  }
223
281
 
224
282
  // Recurse into children
225
283
  if (node.children) {
284
+ const childContext = {
285
+ ...runtimeContext,
286
+ ancestors: [...runtimeContext.ancestors, node],
287
+ };
226
288
  for (let i = 0; i < node.children.length; i++) {
227
- const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, runtimeContext);
289
+ const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, childContext);
228
290
  results.push(...childResults);
229
291
  }
230
292
  }
@@ -233,7 +295,7 @@ export class ChangeNotifier {
233
295
  }
234
296
 
235
297
  // Check if node matches selector
236
- nodeMatchesSelector(node, selector) {
298
+ nodeMatchesSelector(node, selector, ancestors = []) {
237
299
  if (!node) return false;
238
300
  const normalized = normalizeSelector(selector);
239
301
  if (!normalized || typeof normalized !== 'object') return false;
@@ -248,24 +310,13 @@ export class ChangeNotifier {
248
310
  const cssVariants = parseCssSelector(normalized.css);
249
311
  if (cssVariants.length > 0) {
250
312
  for (const cssVariant of cssVariants) {
251
- const hasConstraints = Boolean(
252
- cssVariant.tag
253
- || cssVariant.id
254
- || (cssVariant.classes && cssVariant.classes.length > 0)
255
- || (cssVariant.attrs && cssVariant.attrs.length > 0),
256
- );
257
- if (!hasConstraints) continue;
258
-
259
- let matched = true;
260
- if (cssVariant.tag && nodeTag !== cssVariant.tag) matched = false;
261
- if (cssVariant.id && nodeId !== cssVariant.id) matched = false;
262
- if (matched && cssVariant.classes.length > 0) {
263
- matched = cssVariant.classes.every((className) => nodeClasses.has(className));
264
- }
265
- if (matched && cssVariant.attrs.length > 0) {
266
- matched = cssVariant.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
267
- }
268
- if (matched) return true;
313
+ const segments = Array.isArray(cssVariant.segments) && cssVariant.segments.length > 0
314
+ ? cssVariant.segments
315
+ : [cssVariant];
316
+ const targetSegment = segments[segments.length - 1];
317
+ if (!nodeMatchesCssSegment(node, targetSegment)) continue;
318
+ if (segments.length === 1) return true;
319
+ if (matchesAncestorChain(ancestors, segments.slice(0, -1))) return true;
269
320
  }
270
321
  }
271
322
 
@@ -94,6 +94,17 @@ function sleep(ms) {
94
94
  return new Promise((resolve) => setTimeout(resolve, ms));
95
95
  }
96
96
 
97
+ async function pageScroll(profileId, deltaY, delayMs = 80) {
98
+ const raw = Number(deltaY) || 0;
99
+ if (!Number.isFinite(raw) || raw === 0) return;
100
+ const key = raw >= 0 ? 'PageDown' : 'PageUp';
101
+ const steps = Math.max(1, Math.min(8, Math.round(Math.abs(raw) / 420) || 1));
102
+ for (let step = 0; step < steps; step += 1) {
103
+ await callAPI('keyboard:press', { profileId, key });
104
+ if (delayMs > 0) await sleep(delayMs);
105
+ }
106
+ }
107
+
97
108
  function clamp(value, min, max) {
98
109
  return Math.min(Math.max(value, min), max);
99
110
  }
@@ -281,7 +292,8 @@ async function scrollTargetIntoViewport(profileId, selector, initialTarget, para
281
292
  if (isTargetFullyInViewport(target, visibilityMargin)) break;
282
293
  const delta = resolveViewportScrollDelta(target, visibilityMargin);
283
294
  if (Math.abs(delta.deltaX) < 1 && Math.abs(delta.deltaY) < 1) break;
284
- await callAPI('mouse:wheel', { profileId, deltaX: delta.deltaX, deltaY: delta.deltaY });
295
+ const deltaY = delta.deltaY !== 0 ? delta.deltaY : (delta.deltaX !== 0 ? delta.deltaX : 0);
296
+ await pageScroll(profileId, deltaY);
285
297
  if (settleMs > 0) await sleep(settleMs);
286
298
  target = await resolveSelectorTarget(profileId, selector, options);
287
299
  }
@@ -468,6 +480,56 @@ async function executeVerifySubscriptions({ profileId, params }) {
468
480
 
469
481
  const acrossPages = params.acrossPages === true;
470
482
  const settleMs = Math.max(0, Number(params.settleMs ?? 280) || 280);
483
+ const pageUrlIncludes = normalizeArray(params.pageUrlIncludes)
484
+ .map((item) => String(item || '').trim())
485
+ .filter(Boolean);
486
+ const pageUrlExcludes = normalizeArray(params.pageUrlExcludes)
487
+ .map((item) => String(item || '').trim())
488
+ .filter(Boolean);
489
+ const pageUrlRegex = String(params.pageUrlRegex || '').trim();
490
+ const pageUrlNotRegex = String(params.pageUrlNotRegex || '').trim();
491
+ const requireMatchedPages = params.requireMatchedPages !== false;
492
+
493
+ let includeRegex = null;
494
+ if (pageUrlRegex) {
495
+ try {
496
+ includeRegex = new RegExp(pageUrlRegex);
497
+ } catch {
498
+ return asErrorPayload('OPERATION_FAILED', `invalid pageUrlRegex: ${pageUrlRegex}`);
499
+ }
500
+ }
501
+ let excludeRegex = null;
502
+ if (pageUrlNotRegex) {
503
+ try {
504
+ excludeRegex = new RegExp(pageUrlNotRegex);
505
+ } catch {
506
+ return asErrorPayload('OPERATION_FAILED', `invalid pageUrlNotRegex: ${pageUrlNotRegex}`);
507
+ }
508
+ }
509
+
510
+ const hasPageFilter = (
511
+ pageUrlIncludes.length > 0
512
+ || pageUrlExcludes.length > 0
513
+ || Boolean(includeRegex)
514
+ || Boolean(excludeRegex)
515
+ );
516
+
517
+ const shouldVerifyPage = (rawUrl) => {
518
+ const url = String(rawUrl || '').trim();
519
+ if (pageUrlIncludes.length > 0 && !pageUrlIncludes.some((part) => url.includes(part))) {
520
+ return false;
521
+ }
522
+ if (pageUrlExcludes.length > 0 && pageUrlExcludes.some((part) => url.includes(part))) {
523
+ return false;
524
+ }
525
+ if (includeRegex && !includeRegex.test(url)) {
526
+ return false;
527
+ }
528
+ if (excludeRegex && excludeRegex.test(url)) {
529
+ return false;
530
+ }
531
+ return true;
532
+ };
471
533
 
472
534
  const collectForCurrentPage = async () => {
473
535
  const snapshot = await getDomSnapshotByProfile(profileId);
@@ -487,6 +549,8 @@ async function executeVerifySubscriptions({ profileId, params }) {
487
549
 
488
550
  let pagesResult = [];
489
551
  let overallOk = true;
552
+ let matchedPageCount = 0;
553
+ let activePageIndex = null;
490
554
  if (!acrossPages) {
491
555
  const current = await collectForCurrentPage();
492
556
  overallOk = current.matches.every((item) => item.count >= item.minCount);
@@ -494,8 +558,19 @@ async function executeVerifySubscriptions({ profileId, params }) {
494
558
  } else {
495
559
  const listed = await callAPI('page:list', { profileId });
496
560
  const { pages, activeIndex } = extractPageList(listed);
561
+ activePageIndex = Number.isFinite(activeIndex) ? activeIndex : null;
497
562
  for (const page of pages) {
498
563
  const pageIndex = Number(page.index);
564
+ const listedUrl = String(page.url || '');
565
+ if (!shouldVerifyPage(listedUrl)) {
566
+ pagesResult.push({
567
+ index: pageIndex,
568
+ url: listedUrl,
569
+ skipped: true,
570
+ ok: true,
571
+ });
572
+ continue;
573
+ }
499
574
  if (Number.isFinite(activeIndex) && activeIndex !== pageIndex) {
500
575
  await callAPI('page:switch', { profileId, index: pageIndex });
501
576
  if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
@@ -504,15 +579,43 @@ async function executeVerifySubscriptions({ profileId, params }) {
504
579
  const pageOk = current.matches.every((item) => item.count >= item.minCount);
505
580
  overallOk = overallOk && pageOk;
506
581
  pagesResult.push({ index: pageIndex, ...current, ok: pageOk });
582
+ matchedPageCount += 1;
507
583
  }
508
584
  if (Number.isFinite(activeIndex)) {
509
585
  await callAPI('page:switch', { profileId, index: activeIndex });
510
586
  }
511
587
  }
512
588
 
589
+ if (acrossPages && hasPageFilter && requireMatchedPages && matchedPageCount === 0) {
590
+ const fallback = await collectForCurrentPage();
591
+ const fallbackOk = fallback.matches.every((item) => item.count >= item.minCount);
592
+ if (fallbackOk) {
593
+ matchedPageCount = 1;
594
+ overallOk = true;
595
+ pagesResult.push({
596
+ index: Number.isFinite(activePageIndex) ? activePageIndex : null,
597
+ urlMatched: false,
598
+ fallback: 'dom_match',
599
+ ok: true,
600
+ ...fallback,
601
+ });
602
+ } else {
603
+ return asErrorPayload('SUBSCRIPTION_MISMATCH', 'no page matched verify_subscriptions pageUrl filter', {
604
+ acrossPages,
605
+ pageUrlIncludes,
606
+ pageUrlExcludes,
607
+ pageUrlRegex: pageUrlRegex || null,
608
+ pageUrlNotRegex: pageUrlNotRegex || null,
609
+ pages: pagesResult,
610
+ fallback,
611
+ });
612
+ }
613
+ }
614
+
513
615
  if (!overallOk) {
514
616
  return asErrorPayload('SUBSCRIPTION_MISMATCH', 'subscription selectors missing on one or more pages', {
515
617
  acrossPages,
618
+ matchedPageCount,
516
619
  pages: pagesResult,
517
620
  });
518
621
  }
@@ -521,7 +624,7 @@ async function executeVerifySubscriptions({ profileId, params }) {
521
624
  ok: true,
522
625
  code: 'OPERATION_DONE',
523
626
  message: 'verify_subscriptions done',
524
- data: { acrossPages, pages: pagesResult },
627
+ data: { acrossPages, matchedPageCount, pages: pagesResult },
525
628
  };
526
629
  }
527
630
 
@@ -617,48 +720,12 @@ export async function executeOperation({ profileId, operation, context = {} }) {
617
720
  deltaX = amount;
618
721
  deltaY = 0;
619
722
  }
620
- const anchorSelector = maybeSelector({
621
- profileId: resolvedProfile,
622
- containerId: params.containerId || operation?.containerId || null,
623
- selector: params.selector || operation?.selector || null,
624
- });
625
- const anchor = await resolveScrollAnchor(resolvedProfile, {
626
- selector: anchorSelector,
627
- filterMode,
628
- });
629
- if (!anchor?.ok || !anchor?.center) {
630
- return asErrorPayload('OPERATION_FAILED', 'visible scroll container not found');
631
- }
632
- await callAPI('mouse:click', {
633
- profileId: resolvedProfile,
634
- x: anchor.center.x,
635
- y: anchor.center.y,
636
- button: 'left',
637
- clicks: 1,
638
- delay: 30,
639
- });
640
- const result = await callAPI('mouse:wheel', {
641
- profileId: resolvedProfile,
642
- deltaX,
643
- deltaY,
644
- anchorX: anchor.center.x,
645
- anchorY: anchor.center.y,
646
- });
723
+ const result = await pageScroll(resolvedProfile, deltaY);
647
724
  return {
648
725
  ok: true,
649
726
  code: 'OPERATION_DONE',
650
727
  message: 'scroll done',
651
- data: {
652
- direction,
653
- amount,
654
- deltaX,
655
- deltaY,
656
- filterMode,
657
- anchorSource: String(anchor?.source || 'document'),
658
- anchorCenter: anchor?.center || null,
659
- modalLocked: anchor?.modalLocked === true,
660
- result,
661
- },
728
+ data: { direction, amount, deltaX, deltaY, result },
662
729
  };
663
730
  }
664
731
 
@@ -679,13 +746,7 @@ export async function executeOperation({ profileId, operation, context = {} }) {
679
746
  }
680
747
 
681
748
  if (action === 'evaluate') {
682
- if (!isJsExecutionEnabled()) {
683
- return asErrorPayload('JS_DISABLED', 'evaluate is disabled by default. Re-run camo command with --js.');
684
- }
685
- const script = String(params.script || '').trim();
686
- if (!script) return asErrorPayload('OPERATION_FAILED', 'evaluate requires params.script');
687
- const result = await callAPI('evaluate', { profileId: resolvedProfile, script });
688
- return { ok: true, code: 'OPERATION_DONE', message: 'evaluate done', data: result };
749
+ return asErrorPayload('JS_DISABLED', 'evaluate is disabled in camo runtime');
689
750
  }
690
751
 
691
752
  if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
@@ -694,7 +755,6 @@ export async function executeOperation({ profileId, operation, context = {} }) {
694
755
  action,
695
756
  operation,
696
757
  params,
697
- filterMode,
698
758
  });
699
759
  }
700
760