@web-auto/camo 0.1.14 → 0.1.16

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.
@@ -1,10 +1,6 @@
1
1
  import { callAPI, getDomSnapshotByProfile } from '../../../utils/browser-service.mjs';
2
+ import { isJsExecutionEnabled } from '../../../utils/js-policy.mjs';
2
3
  import { executeTabPoolOperation } from './tab-pool.mjs';
3
- import {
4
- buildSelectorClickScript,
5
- buildSelectorScrollIntoViewScript,
6
- buildSelectorTypeScript,
7
- } from './selector-scripts.mjs';
8
4
  import { executeViewportOperation } from './viewport.mjs';
9
5
  import {
10
6
  asErrorPayload,
@@ -27,6 +23,23 @@ const VIEWPORT_ACTIONS = new Set([
27
23
  'get_current_url',
28
24
  ]);
29
25
 
26
+ const DEFAULT_MODAL_SELECTORS = [
27
+ '[aria-modal="true"]',
28
+ '[role="dialog"]',
29
+ '.modal',
30
+ '.dialog',
31
+ '.note-detail-mask',
32
+ '.note-detail-page',
33
+ '.note-detail-dialog',
34
+ ];
35
+
36
+ function resolveFilterMode(input) {
37
+ const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
38
+ if (!text) return 'strict';
39
+ if (text === 'legacy') return 'legacy';
40
+ return 'strict';
41
+ }
42
+
30
43
  async function executeExternalOperationIfAny({
31
44
  profileId,
32
45
  action,
@@ -54,6 +67,7 @@ async function executeExternalOperationIfAny({
54
67
  }
55
68
 
56
69
  async function flashOperationViewport(profileId, params = {}) {
70
+ if (!isJsExecutionEnabled()) return;
57
71
  if (params.highlight === false) return;
58
72
  try {
59
73
  await callAPI('evaluate', {
@@ -77,7 +91,272 @@ async function flashOperationViewport(profileId, params = {}) {
77
91
  }
78
92
  }
79
93
 
80
- async function executeSelectorOperation({ profileId, action, operation, params }) {
94
+ function sleep(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
97
+
98
+ function clamp(value, min, max) {
99
+ return Math.min(Math.max(value, min), max);
100
+ }
101
+
102
+ function isTargetFullyInViewport(target, margin = 6) {
103
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
104
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
105
+ if (!rect || !viewport) return true;
106
+ const vw = Number(viewport.width || 0);
107
+ const vh = Number(viewport.height || 0);
108
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return true;
109
+ const left = Number(rect.left || 0);
110
+ const top = Number(rect.top || 0);
111
+ const width = Math.max(0, Number(rect.width || 0));
112
+ const height = Math.max(0, Number(rect.height || 0));
113
+ const right = left + width;
114
+ const bottom = top + height;
115
+ const m = Math.max(0, Number(margin) || 0);
116
+ return left >= m && top >= m && right <= (vw - m) && bottom <= (vh - m);
117
+ }
118
+
119
+ function resolveViewportScrollDelta(target, margin = 6) {
120
+ const rect = target?.rect && typeof target.rect === 'object' ? target.rect : null;
121
+ const viewport = target?.viewport && typeof target.viewport === 'object' ? target.viewport : null;
122
+ if (!rect || !viewport) return { deltaX: 0, deltaY: 0 };
123
+ const vw = Number(viewport.width || 0);
124
+ const vh = Number(viewport.height || 0);
125
+ if (!Number.isFinite(vw) || !Number.isFinite(vh) || vw <= 0 || vh <= 0) return { deltaX: 0, deltaY: 0 };
126
+ const left = Number(rect.left || 0);
127
+ const top = Number(rect.top || 0);
128
+ const width = Math.max(0, Number(rect.width || 0));
129
+ const height = Math.max(0, Number(rect.height || 0));
130
+ const right = left + width;
131
+ const bottom = top + height;
132
+ const m = Math.max(0, Number(margin) || 0);
133
+
134
+ let deltaX = 0;
135
+ let deltaY = 0;
136
+
137
+ if (left < m) {
138
+ deltaX = Math.round(left - m);
139
+ } else if (right > (vw - m)) {
140
+ deltaX = Math.round(right - (vw - m));
141
+ }
142
+
143
+ if (top < m) {
144
+ deltaY = Math.round(top - m);
145
+ } else if (bottom > (vh - m)) {
146
+ deltaY = Math.round(bottom - (vh - m));
147
+ }
148
+
149
+ if (Math.abs(deltaY) < 80 && !isTargetFullyInViewport(target, m)) {
150
+ deltaY = deltaY >= 0 ? 120 : -120;
151
+ }
152
+ if (Math.abs(deltaX) < 40 && (left < m || right > (vw - m))) {
153
+ deltaX = deltaX >= 0 ? 60 : -60;
154
+ }
155
+
156
+ return {
157
+ deltaX: clamp(deltaX, -900, 900),
158
+ deltaY: clamp(deltaY, -900, 900),
159
+ };
160
+ }
161
+
162
+ function normalizeRect(node) {
163
+ const rect = node?.rect && typeof node.rect === 'object' ? node.rect : null;
164
+ if (!rect) return null;
165
+ const left = Number(rect.left ?? rect.x ?? 0);
166
+ const top = Number(rect.top ?? rect.y ?? 0);
167
+ const width = Number(rect.width ?? 0);
168
+ const height = Number(rect.height ?? 0);
169
+ if (!Number.isFinite(left) || !Number.isFinite(top) || !Number.isFinite(width) || !Number.isFinite(height)) {
170
+ return null;
171
+ }
172
+ if (width <= 0 || height <= 0) return null;
173
+ return { left, top, width, height };
174
+ }
175
+
176
+ function nodeArea(node) {
177
+ const rect = normalizeRect(node);
178
+ if (!rect) return 0;
179
+ return Number(rect.width || 0) * Number(rect.height || 0);
180
+ }
181
+
182
+ function nodeCenter(node, viewport = null) {
183
+ const rect = normalizeRect(node);
184
+ const vw = Number(viewport?.width || 0);
185
+ const vh = Number(viewport?.height || 0);
186
+ if (!rect) return null;
187
+ const rawX = rect.left + Math.max(1, rect.width / 2);
188
+ const rawY = rect.top + Math.max(1, rect.height / 2);
189
+ const centerX = vw > 1
190
+ ? clamp(Math.round(rawX), 1, Math.max(1, vw - 1))
191
+ : Math.max(1, Math.round(rawX));
192
+ const centerY = vh > 1
193
+ ? clamp(Math.round(rawY), 1, Math.max(1, vh - 1))
194
+ : Math.max(1, Math.round(rawY));
195
+ return {
196
+ center: { x: centerX, y: centerY },
197
+ rawCenter: { x: rawX, y: rawY },
198
+ rect,
199
+ };
200
+ }
201
+
202
+ function getSnapshotViewport(snapshot) {
203
+ const width = Number(snapshot?.__viewport?.width || 0);
204
+ const height = Number(snapshot?.__viewport?.height || 0);
205
+ return { width, height };
206
+ }
207
+
208
+ function isPathWithin(path, parentPath) {
209
+ const child = String(path || '').trim();
210
+ const parent = String(parentPath || '').trim();
211
+ if (!child || !parent) return false;
212
+ return child === parent || child.startsWith(`${parent}/`);
213
+ }
214
+
215
+ function resolveActiveModal(snapshot) {
216
+ if (!snapshot) return null;
217
+ const rows = [];
218
+ for (const selector of DEFAULT_MODAL_SELECTORS) {
219
+ const matches = buildSelectorCheck(snapshot, { css: selector, visible: true });
220
+ for (const node of matches) {
221
+ if (nodeArea(node) <= 1) continue;
222
+ rows.push({
223
+ selector,
224
+ path: String(node.path || ''),
225
+ node,
226
+ area: nodeArea(node),
227
+ });
228
+ }
229
+ }
230
+ rows.sort((a, b) => b.area - a.area);
231
+ return rows[0] || null;
232
+ }
233
+
234
+ async function resolveSelectorTarget(profileId, selector, options = {}) {
235
+ const filterMode = resolveFilterMode(options.filterMode);
236
+ const strictFilter = filterMode !== 'legacy';
237
+ const normalizedSelector = String(selector || '').trim();
238
+ const snapshot = await getDomSnapshotByProfile(profileId);
239
+ const viewport = getSnapshotViewport(snapshot);
240
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
241
+ const visibleMatches = buildSelectorCheck(snapshot, { css: normalizedSelector, visible: true });
242
+ const allMatches = strictFilter
243
+ ? visibleMatches
244
+ : buildSelectorCheck(snapshot, { css: normalizedSelector, visible: false });
245
+ const scopedVisible = modal
246
+ ? visibleMatches.filter((item) => isPathWithin(item.path, modal.path))
247
+ : visibleMatches;
248
+ const scopedAll = modal
249
+ ? allMatches.filter((item) => isPathWithin(item.path, modal.path))
250
+ : allMatches;
251
+ const candidate = strictFilter
252
+ ? (scopedVisible[0] || null)
253
+ : (scopedVisible[0] || scopedAll[0] || null);
254
+ if (!candidate) {
255
+ if (modal) {
256
+ throw new Error(`Modal focus locked for selector: ${normalizedSelector}`);
257
+ }
258
+ throw new Error(`Element not found: ${normalizedSelector}`);
259
+ }
260
+ const center = nodeCenter(candidate, viewport);
261
+ if (!center) {
262
+ throw new Error(`Element not found: ${normalizedSelector}`);
263
+ }
264
+ return {
265
+ ok: true,
266
+ selector: normalizedSelector,
267
+ matchedIndex: Math.max(0, scopedAll.indexOf(candidate)),
268
+ center: center.center,
269
+ rawCenter: center.rawCenter,
270
+ rect: center.rect,
271
+ viewport,
272
+ modalLocked: Boolean(modal),
273
+ };
274
+ }
275
+
276
+ async function scrollTargetIntoViewport(profileId, selector, initialTarget, params = {}, options = {}) {
277
+ let target = initialTarget;
278
+ const maxSteps = Math.max(0, Math.min(24, Number(params.maxScrollSteps ?? 8) || 8));
279
+ const settleMs = Math.max(0, Number(params.scrollSettleMs ?? 140) || 140);
280
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
281
+ for (let i = 0; i < maxSteps; i += 1) {
282
+ if (isTargetFullyInViewport(target, visibilityMargin)) break;
283
+ const delta = resolveViewportScrollDelta(target, visibilityMargin);
284
+ if (Math.abs(delta.deltaX) < 1 && Math.abs(delta.deltaY) < 1) break;
285
+ const anchorX = clamp(Math.round(Number(target?.center?.x || 0) || 1), 1, Math.max(1, Number(target?.viewport?.width || 1) - 1));
286
+ const anchorY = clamp(Math.round(Number(target?.center?.y || 0) || 1), 1, Math.max(1, Number(target?.viewport?.height || 1) - 1));
287
+ await callAPI('mouse:move', { profileId, x: anchorX, y: anchorY, steps: 1 });
288
+ await callAPI('mouse:wheel', { profileId, deltaX: delta.deltaX, deltaY: delta.deltaY });
289
+ if (settleMs > 0) await sleep(settleMs);
290
+ target = await resolveSelectorTarget(profileId, selector, options);
291
+ }
292
+ return target;
293
+ }
294
+
295
+ async function resolveScrollAnchor(profileId, options = {}) {
296
+ const filterMode = resolveFilterMode(options.filterMode);
297
+ const strictFilter = filterMode !== 'legacy';
298
+ const selector = String(options.selector || '').trim();
299
+ const snapshot = await getDomSnapshotByProfile(profileId);
300
+ const viewport = getSnapshotViewport(snapshot);
301
+ const modal = strictFilter ? resolveActiveModal(snapshot) : null;
302
+
303
+ if (selector) {
304
+ const visibleMatches = buildSelectorCheck(snapshot, { css: selector, visible: true });
305
+ const target = visibleMatches[0] || null;
306
+ if (target) {
307
+ if (modal && !isPathWithin(target.path, modal.path)) {
308
+ const modalCenter = nodeCenter(modal.node, viewport);
309
+ if (modalCenter) {
310
+ return {
311
+ ok: true,
312
+ source: 'modal',
313
+ center: modalCenter.center,
314
+ modalLocked: true,
315
+ modalSelector: modal.selector,
316
+ selectorRejectedByModalLock: true,
317
+ };
318
+ }
319
+ } else {
320
+ const targetCenter = nodeCenter(target, viewport);
321
+ if (targetCenter) {
322
+ return {
323
+ ok: true,
324
+ source: 'selector',
325
+ center: targetCenter.center,
326
+ modalLocked: Boolean(modal),
327
+ };
328
+ }
329
+ }
330
+ }
331
+ }
332
+
333
+ if (modal) {
334
+ const modalCenter = nodeCenter(modal.node, viewport);
335
+ if (modalCenter) {
336
+ return {
337
+ ok: true,
338
+ source: 'modal',
339
+ center: modalCenter.center,
340
+ modalLocked: true,
341
+ modalSelector: modal.selector,
342
+ };
343
+ }
344
+ }
345
+
346
+ const width = Number(viewport.width || 0);
347
+ const height = Number(viewport.height || 0);
348
+ return {
349
+ ok: true,
350
+ source: 'document',
351
+ center: {
352
+ x: width > 1 ? Math.round(width / 2) : 1,
353
+ y: height > 1 ? Math.round(height / 2) : 1,
354
+ },
355
+ modalLocked: false,
356
+ };
357
+ }
358
+
359
+ async function executeSelectorOperation({ profileId, action, operation, params, filterMode }) {
81
360
  const selector = maybeSelector({
82
361
  profileId,
83
362
  containerId: params.containerId || operation?.containerId || null,
@@ -85,22 +364,75 @@ async function executeSelectorOperation({ profileId, action, operation, params }
85
364
  });
86
365
  if (!selector) return asErrorPayload('CONTAINER_NOT_FOUND', `${action} requires selector/containerId`);
87
366
 
88
- const highlight = params.highlight !== false;
367
+ let target = await resolveSelectorTarget(profileId, selector, { filterMode });
368
+ target = await scrollTargetIntoViewport(profileId, selector, target, params, { filterMode });
369
+ const visibilityMargin = Math.max(0, Number(params.visibilityMargin ?? params.viewportMargin ?? 6) || 6);
370
+ const targetFullyVisible = isTargetFullyInViewport(target, visibilityMargin);
371
+ if (action === 'click' && !targetFullyVisible) {
372
+ return asErrorPayload('TARGET_NOT_FULLY_VISIBLE', 'click target is not fully visible after auto scroll', {
373
+ selector,
374
+ target,
375
+ visibilityMargin,
376
+ });
377
+ }
378
+
379
+ await callAPI('mouse:move', { profileId, x: target.center.x, y: target.center.y, steps: 2 });
380
+
89
381
  if (action === 'scroll_into_view') {
90
- const script = buildSelectorScrollIntoViewScript({ selector, highlight });
91
- const result = await callAPI('evaluate', {
382
+ return {
383
+ ok: true,
384
+ code: 'OPERATION_DONE',
385
+ message: 'scroll_into_view done',
386
+ data: { selector, target, targetFullyVisible, visibilityMargin },
387
+ };
388
+ }
389
+
390
+ if (action === 'click') {
391
+ const button = String(params.button || 'left').trim() || 'left';
392
+ const clicks = Math.max(1, Number(params.clicks ?? 1) || 1);
393
+ const delay = Number(params.delay);
394
+ const result = await callAPI('mouse:click', {
92
395
  profileId,
93
- script,
396
+ x: target.center.x,
397
+ y: target.center.y,
398
+ button,
399
+ clicks,
400
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
94
401
  });
95
- return { ok: true, code: 'OPERATION_DONE', message: 'scroll_into_view done', data: result };
402
+ return { ok: true, code: 'OPERATION_DONE', message: 'click done', data: { selector, target, result, targetFullyVisible, visibilityMargin } };
96
403
  }
97
404
 
98
- const typeText = String(params.text ?? params.value ?? '');
99
- const script = action === 'click'
100
- ? buildSelectorClickScript({ selector, highlight })
101
- : buildSelectorTypeScript({ selector, highlight, text: typeText });
102
- const result = await callAPI('evaluate', { profileId, script });
103
- return { ok: true, code: 'OPERATION_DONE', message: `${action} done`, data: result };
405
+ const text = String(params.text ?? params.value ?? '');
406
+ await callAPI('mouse:click', {
407
+ profileId,
408
+ x: target.center.x,
409
+ y: target.center.y,
410
+ button: 'left',
411
+ clicks: 1,
412
+ });
413
+ const clearBeforeType = params.clear !== false;
414
+ if (clearBeforeType) {
415
+ await callAPI('keyboard:press', {
416
+ profileId,
417
+ key: process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
418
+ });
419
+ await callAPI('keyboard:press', { profileId, key: 'Backspace' });
420
+ }
421
+ const delay = Number(params.keyDelayMs ?? params.delay);
422
+ await callAPI('keyboard:type', {
423
+ profileId,
424
+ text,
425
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
426
+ });
427
+ if (params.pressEnter === true) {
428
+ await callAPI('keyboard:press', { profileId, key: 'Enter' });
429
+ }
430
+ return {
431
+ ok: true,
432
+ code: 'OPERATION_DONE',
433
+ message: 'type done',
434
+ data: { selector, target, length: text.length },
435
+ };
104
436
  }
105
437
 
106
438
  async function executeVerifySubscriptions({ profileId, params }) {
@@ -200,6 +532,13 @@ export async function executeOperation({ profileId, operation, context = {} }) {
200
532
  const resolvedProfile = session.profileId || profileId;
201
533
  const action = String(operation?.action || '').trim();
202
534
  const params = operation?.params || operation?.config || {};
535
+ const filterMode = resolveFilterMode(
536
+ params.filterMode
537
+ || operation?.filterMode
538
+ || context?.filterMode
539
+ || context?.runtime?.filterMode
540
+ || null,
541
+ );
203
542
 
204
543
  if (!action) {
205
544
  return asErrorPayload('OPERATION_FAILED', 'operation.action is required');
@@ -279,38 +618,49 @@ export async function executeOperation({ profileId, operation, context = {} }) {
279
618
  deltaX = amount;
280
619
  deltaY = 0;
281
620
  }
621
+ const anchorSelector = maybeSelector({
622
+ profileId: resolvedProfile,
623
+ containerId: params.containerId || operation?.containerId || null,
624
+ selector: params.selector || operation?.selector || null,
625
+ });
626
+ const anchor = await resolveScrollAnchor(resolvedProfile, {
627
+ selector: anchorSelector,
628
+ filterMode,
629
+ });
630
+ if (anchor?.center?.x && anchor?.center?.y) {
631
+ await callAPI('mouse:move', {
632
+ profileId: resolvedProfile,
633
+ x: Math.max(1, Math.round(Number(anchor.center.x) || 1)),
634
+ y: Math.max(1, Math.round(Number(anchor.center.y) || 1)),
635
+ steps: 2,
636
+ });
637
+ }
282
638
  const result = await callAPI('mouse:wheel', { profileId: resolvedProfile, deltaX, deltaY });
283
639
  return {
284
640
  ok: true,
285
641
  code: 'OPERATION_DONE',
286
642
  message: 'scroll done',
287
- data: { direction, amount, deltaX, deltaY, result },
643
+ data: {
644
+ direction,
645
+ amount,
646
+ deltaX,
647
+ deltaY,
648
+ filterMode,
649
+ anchorSource: String(anchor?.source || 'document'),
650
+ modalLocked: anchor?.modalLocked === true,
651
+ result,
652
+ },
288
653
  };
289
654
  }
290
655
 
291
656
  if (action === 'press_key') {
292
657
  const key = String(params.key || params.value || '').trim();
293
658
  if (!key) return asErrorPayload('OPERATION_FAILED', 'press_key requires params.key');
294
- const result = await callAPI('evaluate', {
659
+ const delay = Number(params.delay);
660
+ const result = await callAPI('keyboard:press', {
295
661
  profileId: resolvedProfile,
296
- script: `(async () => {
297
- const target = document.activeElement || document.body || document.documentElement;
298
- const key = ${JSON.stringify(key)};
299
- const code = key.length === 1 ? 'Key' + key.toUpperCase() : key;
300
- const opts = { key, code, bubbles: true, cancelable: true };
301
- target.dispatchEvent(new KeyboardEvent('keydown', opts));
302
- target.dispatchEvent(new KeyboardEvent('keypress', opts));
303
- target.dispatchEvent(new KeyboardEvent('keyup', opts));
304
- if (key === 'Escape') {
305
- const closeButton = document.querySelector('.note-detail-mask .close-box, .note-detail-mask .close-circle');
306
- if (closeButton instanceof HTMLElement) closeButton.click();
307
- }
308
- if (key === 'Enter' && target instanceof HTMLInputElement && target.form) {
309
- if (typeof target.form.requestSubmit === 'function') target.form.requestSubmit();
310
- else target.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
311
- }
312
- return { key, targetTag: target?.tagName || null };
313
- })()`,
662
+ key,
663
+ ...(Number.isFinite(delay) && delay >= 0 ? { delay } : {}),
314
664
  });
315
665
  return { ok: true, code: 'OPERATION_DONE', message: 'press_key done', data: result };
316
666
  }
@@ -320,6 +670,9 @@ export async function executeOperation({ profileId, operation, context = {} }) {
320
670
  }
321
671
 
322
672
  if (action === 'evaluate') {
673
+ if (!isJsExecutionEnabled()) {
674
+ return asErrorPayload('JS_DISABLED', 'evaluate is disabled by default. Re-run camo command with --js.');
675
+ }
323
676
  const script = String(params.script || '').trim();
324
677
  if (!script) return asErrorPayload('OPERATION_FAILED', 'evaluate requires params.script');
325
678
  const result = await callAPI('evaluate', { profileId: resolvedProfile, script });
@@ -327,11 +680,12 @@ export async function executeOperation({ profileId, operation, context = {} }) {
327
680
  }
328
681
 
329
682
  if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
330
- return executeSelectorOperation({
683
+ return await executeSelectorOperation({
331
684
  profileId: resolvedProfile,
332
685
  action,
333
686
  operation,
334
687
  params,
688
+ filterMode,
335
689
  });
336
690
  }
337
691
 
@@ -1,17 +1,36 @@
1
1
  import { getDomSnapshotByProfile } from '../../utils/browser-service.mjs';
2
2
  import { ChangeNotifier } from '../change-notifier.mjs';
3
- import { ensureActiveSession, normalizeArray } from './utils.mjs';
3
+ import { ensureActiveSession, getCurrentUrl, normalizeArray } from './utils.mjs';
4
+
5
+ function resolveFilterMode(input) {
6
+ const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
7
+ if (!text) return 'strict';
8
+ if (text === 'legacy') return 'legacy';
9
+ return 'strict';
10
+ }
11
+
12
+ function urlMatchesFilter(url, item) {
13
+ const href = String(url || '').trim();
14
+ const includes = normalizeArray(item?.pageUrlIncludes).map((token) => String(token || '').trim()).filter(Boolean);
15
+ const excludes = normalizeArray(item?.pageUrlExcludes).map((token) => String(token || '').trim()).filter(Boolean);
16
+ if (includes.length > 0 && !includes.every((token) => href.includes(token))) return false;
17
+ if (excludes.length > 0 && excludes.some((token) => href.includes(token))) return false;
18
+ return true;
19
+ }
4
20
 
5
21
  export async function watchSubscriptions({
6
22
  profileId,
7
23
  subscriptions,
8
24
  throttle = 500,
25
+ filterMode = 'strict',
9
26
  onEvent = () => {},
10
27
  onError = () => {},
11
28
  }) {
12
29
  const session = await ensureActiveSession(profileId);
13
30
  const resolvedProfile = session.profileId || profileId;
14
31
  const notifier = new ChangeNotifier();
32
+ const effectiveFilterMode = resolveFilterMode(filterMode);
33
+ const strictFilter = effectiveFilterMode === 'strict';
15
34
  const items = normalizeArray(subscriptions)
16
35
  .map((item, index) => {
17
36
  if (!item || typeof item !== 'object') return null;
@@ -19,7 +38,16 @@ export async function watchSubscriptions({
19
38
  const selector = String(item.selector || '').trim();
20
39
  if (!selector) return null;
21
40
  const events = normalizeArray(item.events).map((name) => String(name).trim()).filter(Boolean);
22
- return { id, selector, events: events.length > 0 ? new Set(events) : null };
41
+ const pageUrlIncludes = normalizeArray(item.pageUrlIncludes).map((token) => String(token || '').trim()).filter(Boolean);
42
+ const pageUrlExcludes = normalizeArray(item.pageUrlExcludes).map((token) => String(token || '').trim()).filter(Boolean);
43
+ return {
44
+ id,
45
+ selector,
46
+ visible: strictFilter ? true : (item.visible !== false),
47
+ pageUrlIncludes,
48
+ pageUrlExcludes,
49
+ events: events.length > 0 ? new Set(events) : null,
50
+ };
23
51
  })
24
52
  .filter(Boolean);
25
53
 
@@ -39,10 +67,14 @@ export async function watchSubscriptions({
39
67
  if (stopped) return;
40
68
  try {
41
69
  const snapshot = await getDomSnapshotByProfile(resolvedProfile);
70
+ const currentUrl = await getCurrentUrl(resolvedProfile).catch(() => '');
42
71
  const ts = new Date().toISOString();
43
72
  for (const item of items) {
44
73
  const prev = state.get(item.id) || { exists: false, stateSig: '', appearCount: 0 };
45
- const elements = notifier.findElements(snapshot, { css: item.selector });
74
+ const urlMatched = urlMatchesFilter(currentUrl, item);
75
+ const elements = urlMatched
76
+ ? notifier.findElements(snapshot, { css: item.selector, visible: item.visible })
77
+ : [];
46
78
  const exists = elements.length > 0;
47
79
  const stateSig = elements.map((node) => node.path).sort().join(',');
48
80
  const changed = stateSig !== prev.stateSig;
@@ -55,16 +87,56 @@ export async function watchSubscriptions({
55
87
 
56
88
  const shouldEmit = (type) => !item.events || item.events.has(type);
57
89
  if (exists && !prev.exists && shouldEmit('appear')) {
58
- await emit({ type: 'appear', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
90
+ await emit({
91
+ type: 'appear',
92
+ profileId: resolvedProfile,
93
+ subscriptionId: item.id,
94
+ selector: item.selector,
95
+ count: elements.length,
96
+ elements,
97
+ pageUrl: currentUrl,
98
+ filterMode: effectiveFilterMode,
99
+ timestamp: ts,
100
+ });
59
101
  }
60
102
  if (!exists && prev.exists && shouldEmit('disappear')) {
61
- await emit({ type: 'disappear', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: 0, elements: [], timestamp: ts });
103
+ await emit({
104
+ type: 'disappear',
105
+ profileId: resolvedProfile,
106
+ subscriptionId: item.id,
107
+ selector: item.selector,
108
+ count: 0,
109
+ elements: [],
110
+ pageUrl: currentUrl,
111
+ filterMode: effectiveFilterMode,
112
+ timestamp: ts,
113
+ });
62
114
  }
63
115
  if (exists && shouldEmit('exist')) {
64
- await emit({ type: 'exist', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
116
+ await emit({
117
+ type: 'exist',
118
+ profileId: resolvedProfile,
119
+ subscriptionId: item.id,
120
+ selector: item.selector,
121
+ count: elements.length,
122
+ elements,
123
+ pageUrl: currentUrl,
124
+ filterMode: effectiveFilterMode,
125
+ timestamp: ts,
126
+ });
65
127
  }
66
128
  if (changed && shouldEmit('change')) {
67
- await emit({ type: 'change', profileId: resolvedProfile, subscriptionId: item.id, selector: item.selector, count: elements.length, elements, timestamp: ts });
129
+ await emit({
130
+ type: 'change',
131
+ profileId: resolvedProfile,
132
+ subscriptionId: item.id,
133
+ selector: item.selector,
134
+ count: elements.length,
135
+ elements,
136
+ pageUrl: currentUrl,
137
+ filterMode: effectiveFilterMode,
138
+ timestamp: ts,
139
+ });
68
140
  }
69
141
  }
70
142
  await emit({ type: 'tick', profileId: resolvedProfile, timestamp: ts });
@@ -9,7 +9,7 @@ import {
9
9
  normalizeArray,
10
10
  } from './utils.mjs';
11
11
 
12
- async function validatePage(profileId, spec = {}, platform = 'xiaohongshu') {
12
+ async function validatePage(profileId, spec = {}, platform = 'generic') {
13
13
  const url = await getCurrentUrl(profileId);
14
14
  const includes = normalizeArray(spec.urlIncludes || []);
15
15
  const excludes = normalizeArray(spec.urlExcludes || []);
@@ -74,7 +74,7 @@ export async function validateOperation({
74
74
  validationSpec = {},
75
75
  phase = 'pre',
76
76
  context = {},
77
- platform = 'xiaohongshu',
77
+ platform = 'generic',
78
78
  }) {
79
79
  try {
80
80
  const mode = String(validationSpec.mode || 'none').toLowerCase();