@web-auto/camo 0.1.24 → 0.1.25

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.
@@ -41,6 +41,7 @@ export async function executeViewportOperation({ profileId, action, params = {}
41
41
  const width = hasTargetViewport ? Math.max(320, rawWidth) : null;
42
42
  const height = hasTargetViewport ? Math.max(240, rawHeight) : null;
43
43
  const followWindow = params.followWindow !== false;
44
+ const fitDisplayWindow = params.fitDisplayWindow !== false;
44
45
  const settleMs = Math.max(0, Number(params.settleMs ?? 180) || 180);
45
46
  const attempts = Math.max(1, Number(params.attempts ?? 3) || 3);
46
47
  const tolerance = Math.max(0, Number(params.tolerancePx ?? 3) || 3);
@@ -56,6 +57,49 @@ export async function executeViewportOperation({ profileId, action, params = {}
56
57
 
57
58
  let measured = await probeWindow();
58
59
  if (followWindow && !hasTargetViewport) {
60
+ let resizedWindow = false;
61
+ let windowTarget = null;
62
+ if (fitDisplayWindow) {
63
+ try {
64
+ const displayPayload = await callWithTimeout('system:display', {}, apiTimeoutMs);
65
+ const display = displayPayload?.metrics || displayPayload || {};
66
+ const reserveFromEnv = Number(process.env.CAMO_DEFAULT_WINDOW_VERTICAL_RESERVE ?? 0);
67
+ const reservePx = Number.isFinite(reserveFromEnv) ? Math.max(0, Math.min(240, Math.floor(reserveFromEnv))) : 0;
68
+ const workWidth = Number(display.workWidth || 0);
69
+ const workHeight = Number(display.workHeight || 0);
70
+ const displayWidth = Number(display.width || 0);
71
+ const displayHeight = Number(display.height || 0);
72
+ const baseW = Math.floor(workWidth > 0 ? workWidth : displayWidth);
73
+ const baseH = Math.floor(workHeight > 0 ? workHeight : displayHeight);
74
+ if (baseW > 0 && baseH > 0) {
75
+ windowTarget = {
76
+ width: Math.max(960, baseW),
77
+ height: Math.max(700, baseH - reservePx),
78
+ };
79
+ const currentOuterWidth = Number(measured.outerWidth || 0);
80
+ const currentOuterHeight = Number(measured.outerHeight || 0);
81
+ const shouldResize = (
82
+ !Number.isFinite(currentOuterWidth)
83
+ || !Number.isFinite(currentOuterHeight)
84
+ || currentOuterWidth < Math.floor(windowTarget.width * 0.92)
85
+ || currentOuterHeight < Math.floor(windowTarget.height * 0.92)
86
+ );
87
+ if (shouldResize) {
88
+ await callWithTimeout('window:resize', {
89
+ profileId,
90
+ width: windowTarget.width,
91
+ height: windowTarget.height,
92
+ }, apiTimeoutMs);
93
+ if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));
94
+ measured = await probeWindow();
95
+ resizedWindow = true;
96
+ }
97
+ }
98
+ } catch {
99
+ // display probing is best-effort and must not block follow-window sync
100
+ }
101
+ }
102
+
59
103
  const innerWidth = Math.max(320, Number(measured.innerWidth || 0) || 1280);
60
104
  const innerHeight = Math.max(240, Number(measured.innerHeight || 0) || 720);
61
105
  const outerWidth = Math.max(320, Number(measured.outerWidth || 0) || innerWidth);
@@ -77,6 +121,8 @@ export async function executeViewportOperation({ profileId, action, params = {}
77
121
  followWindow: true,
78
122
  viewport: { width: followWidth, height: followHeight },
79
123
  frame: { width: frameW, height: frameH },
124
+ resizedWindow,
125
+ windowTarget,
80
126
  measured: synced,
81
127
  },
82
128
  };
@@ -2,6 +2,30 @@ import { getDomSnapshotByProfile } from '../../utils/browser-service.mjs';
2
2
  import { ChangeNotifier } from '../change-notifier.mjs';
3
3
  import { ensureActiveSession, getCurrentUrl, normalizeArray } from './utils.mjs';
4
4
 
5
+ function normalizeElementKeys(elements) {
6
+ return (Array.isArray(elements) ? elements : [])
7
+ .map((node) => String(node?.path || '').trim())
8
+ .filter(Boolean)
9
+ .sort();
10
+ }
11
+
12
+ function joinElementKeys(keys) {
13
+ return Array.isArray(keys) && keys.length > 0 ? keys.join('|') : 'none';
14
+ }
15
+
16
+ function buildEventKey(subscriptionId, type, presenceVersion, keys) {
17
+ return `${subscriptionId}:${type}:p${Math.max(0, Number(presenceVersion) || 0)}:k${joinElementKeys(keys)}`;
18
+ }
19
+
20
+ export function isTransientSubscriptionError(error) {
21
+ const message = String(error?.message || error || '').trim().toLowerCase();
22
+ if (!message) return false;
23
+ return message.includes('execution context was destroyed')
24
+ || message.includes('most likely because of a navigation')
25
+ || message.includes('cannot find context with specified id')
26
+ || message.includes('target closed');
27
+ }
28
+
5
29
  function resolveFilterMode(input) {
6
30
  const text = String(input || process.env.CAMO_FILTER_MODE || 'strict').trim().toLowerCase();
7
31
  if (!text) return 'strict';
@@ -11,9 +35,9 @@ function resolveFilterMode(input) {
11
35
 
12
36
  function urlMatchesFilter(url, item) {
13
37
  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;
38
+ const includes = normalizeArray(item?.pageUrlIncludes || item?.urlIncludes).map((token) => String(token || '').trim()).filter(Boolean);
39
+ const excludes = normalizeArray(item?.pageUrlExcludes || item?.urlExcludes).map((token) => String(token || '').trim()).filter(Boolean);
40
+ if (includes.length > 0 && !includes.some((token) => href.includes(token))) return false;
17
41
  if (excludes.length > 0 && excludes.some((token) => href.includes(token))) return false;
18
42
  return true;
19
43
  }
@@ -51,7 +75,13 @@ export async function watchSubscriptions({
51
75
  })
52
76
  .filter(Boolean);
53
77
 
54
- const state = new Map(items.map((item) => [item.id, { exists: false, stateSig: '', appearCount: 0 }]));
78
+ const state = new Map(items.map((item) => [item.id, {
79
+ exists: false,
80
+ stateSig: '',
81
+ appearCount: 0,
82
+ presenceVersion: 0,
83
+ elementKeys: [],
84
+ }]));
55
85
  const intervalMs = Math.max(100, Number(throttle) || 500);
56
86
  let stopped = false;
57
87
 
@@ -67,21 +97,36 @@ export async function watchSubscriptions({
67
97
  if (stopped) return;
68
98
  try {
69
99
  const snapshot = await getDomSnapshotByProfile(resolvedProfile);
70
- const currentUrl = await getCurrentUrl(resolvedProfile).catch(() => '');
100
+ const currentUrl = String(snapshot?.__url || '') || await getCurrentUrl(resolvedProfile).catch(() => '');
71
101
  const ts = new Date().toISOString();
72
102
  for (const item of items) {
73
- const prev = state.get(item.id) || { exists: false, stateSig: '', appearCount: 0 };
103
+ const prev = state.get(item.id) || {
104
+ exists: false,
105
+ stateSig: '',
106
+ appearCount: 0,
107
+ presenceVersion: 0,
108
+ elementKeys: [],
109
+ };
74
110
  const urlMatched = urlMatchesFilter(currentUrl, item);
75
111
  const elements = urlMatched
76
112
  ? notifier.findElements(snapshot, { css: item.selector, visible: item.visible })
77
113
  : [];
78
114
  const exists = elements.length > 0;
79
- const stateSig = elements.map((node) => node.path).sort().join(',');
115
+ const elementKeys = normalizeElementKeys(elements);
116
+ const prevElementKeys = Array.isArray(prev.elementKeys) ? prev.elementKeys : [];
117
+ const prevElementKeySet = new Set(prevElementKeys);
118
+ const elementKeySet = new Set(elementKeys);
119
+ const appearedKeys = elementKeys.filter((key) => !prevElementKeySet.has(key));
120
+ const disappearedKeys = prevElementKeys.filter((key) => !elementKeySet.has(key));
121
+ const stateSig = elementKeys.join(',');
80
122
  const changed = stateSig !== prev.stateSig;
123
+ const presenceVersion = prev.presenceVersion + (exists && !prev.exists ? 1 : 0);
81
124
  const next = {
82
125
  exists,
83
126
  stateSig,
84
127
  appearCount: prev.appearCount + (exists && !prev.exists ? 1 : 0),
128
+ presenceVersion,
129
+ elementKeys,
85
130
  };
86
131
  state.set(item.id, next);
87
132
 
@@ -96,6 +141,10 @@ export async function watchSubscriptions({
96
141
  elements,
97
142
  pageUrl: currentUrl,
98
143
  filterMode: effectiveFilterMode,
144
+ elementKeys,
145
+ presenceVersion,
146
+ stateKey: stateSig,
147
+ eventKey: buildEventKey(item.id, 'appear', presenceVersion, appearedKeys.length > 0 ? appearedKeys : elementKeys),
99
148
  timestamp: ts,
100
149
  });
101
150
  }
@@ -109,6 +158,11 @@ export async function watchSubscriptions({
109
158
  elements: [],
110
159
  pageUrl: currentUrl,
111
160
  filterMode: effectiveFilterMode,
161
+ elementKeys: [],
162
+ departedElementKeys: disappearedKeys,
163
+ presenceVersion: prev.presenceVersion,
164
+ stateKey: '',
165
+ eventKey: buildEventKey(item.id, 'disappear', prev.presenceVersion, disappearedKeys),
112
166
  timestamp: ts,
113
167
  });
114
168
  }
@@ -122,6 +176,10 @@ export async function watchSubscriptions({
122
176
  elements,
123
177
  pageUrl: currentUrl,
124
178
  filterMode: effectiveFilterMode,
179
+ elementKeys,
180
+ presenceVersion,
181
+ stateKey: stateSig,
182
+ eventKey: buildEventKey(item.id, 'exist', presenceVersion, elementKeys),
125
183
  timestamp: ts,
126
184
  });
127
185
  }
@@ -135,12 +193,19 @@ export async function watchSubscriptions({
135
193
  elements,
136
194
  pageUrl: currentUrl,
137
195
  filterMode: effectiveFilterMode,
196
+ elementKeys,
197
+ appearedElementKeys: appearedKeys,
198
+ departedElementKeys: disappearedKeys,
199
+ presenceVersion,
200
+ stateKey: stateSig,
201
+ eventKey: buildEventKey(item.id, 'change', presenceVersion, elementKeys),
138
202
  timestamp: ts,
139
203
  });
140
204
  }
141
205
  }
142
206
  await emit({ type: 'tick', profileId: resolvedProfile, timestamp: ts });
143
207
  } catch (err) {
208
+ if (isTransientSubscriptionError(err)) return;
144
209
  onError(err);
145
210
  }
146
211
  };
@@ -26,13 +26,21 @@ async function validatePage(profileId, spec = {}, platform = 'generic') {
26
26
  errors.push(`url host mismatch, expected one of: ${hostIncludes.join(',')}`);
27
27
  }
28
28
 
29
+ // 锚点驱动的 checkpoint 检查
29
30
  const checkpoints = normalizeArray(spec.checkpointIn || []);
30
31
  let checkpoint = null;
31
32
  if (checkpoints.length > 0) {
32
- const detected = await detectCheckpoint({ profileId, platform });
33
- checkpoint = detected?.data?.checkpoint || null;
34
- if (!checkpoints.includes(checkpoint)) {
35
- errors.push(`checkpoint mismatch: got ${checkpoint}, expect one of ${checkpoints.join(',')}`);
33
+ // 使用锚点轮询,而非 evaluate
34
+ const checkpointSelectors = getCheckpointSelectors(checkpoints, platform);
35
+ const anchorResult = await validateAnchors(profileId, checkpointSelectors, {
36
+ timeoutMs: 15000, // 15秒最大等待
37
+ intervalMs: 500,
38
+ });
39
+
40
+ if (anchorResult.found) {
41
+ checkpoint = checkpoints[0]; // 锚点存在,使用第一个 checkpoint
42
+ } else {
43
+ errors.push(`anchor not found within ${anchorResult.elapsed}ms, expected one of: ${checkpoints.join(',')}`);
36
44
  }
37
45
  }
38
46
 
@@ -42,8 +50,29 @@ async function validatePage(profileId, spec = {}, platform = 'generic') {
42
50
  checkpoint,
43
51
  errors,
44
52
  };
53
+ };
54
+
55
+ // 添加 getCheckpointSelectors 辅助函数
56
+ function getCheckpointSelectors(checkpoints, platform = 'generic') {
57
+ const XHS_CHECKPOINTS = {
58
+ search_ready: ['#search-input', 'input.search-input', '.search-result-list'],
59
+ home_ready: ['.note-item', '.note-item a', 'a[href*="/explore/"]', '[class*="note-item"]'],
60
+ detail_ready: ['.note-scroller', '.note-content', '.interaction-container'],
61
+ comments_ready: ['.comments-container', '.comment-item'],
62
+ login_guard: ['.login-container', '.login-dialog', '#login-container'],
63
+ risk_control: ['.qrcode-box', '.captcha-container', '[class*="captcha"]'],
64
+ };
65
+
66
+ const selectors = [];
67
+ for (const cp of checkpoints) {
68
+ if (platform === 'xiaohongshu' && XHS_CHECKPOINTS[cp]) {
69
+ selectors.push(...XHS_CHECKPOINTS[cp]);
70
+ }
71
+ }
72
+ return selectors.length > 0 ? selectors : checkpoints;
45
73
  }
46
74
 
75
+
47
76
  async function validateContainer(profileId, spec = {}) {
48
77
  const snapshot = await getDomSnapshotByProfile(profileId);
49
78
  const selector = maybeSelector({
@@ -125,3 +154,31 @@ export async function validateOperation({
125
154
  return asErrorPayload('VALIDATION_FAILED', err?.message || String(err), { phase, context });
126
155
  }
127
156
  }
157
+
158
+ // 锚点驱动的验证:轮询容器 selector,而非 evaluate
159
+ async function validateAnchors(profileId, selectors = [], options = {}) {
160
+ const maxMs = Math.max(1000, Number(options.timeoutMs || 30000));
161
+ const intervalMs = Math.max(200, Number(options.intervalMs || 500));
162
+ const startTime = Date.now();
163
+
164
+ while (Date.now() - startTime < maxMs) {
165
+ try {
166
+ const snapshot = await getDomSnapshotByProfile(profileId, { maxDepth: 5, maxChildren: 50 });
167
+ for (const selector of selectors) {
168
+ const matched = buildSelectorCheck(snapshot, selector);
169
+ if (matched.length > 0) {
170
+ return { ok: true, found: true, selector, count: matched.length, elapsed: Date.now() - startTime };
171
+ }
172
+ }
173
+ } catch (err) {
174
+ // 忽略 snapshot 错误,继续轮询
175
+ }
176
+
177
+ // 等待下次检查
178
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
179
+ }
180
+
181
+ return { ok: false, found: false, elapsed: maxMs };
182
+ }
183
+
184
+ export { validateAnchors };
@@ -48,6 +48,10 @@ export async function withTimeout(promise, ms, message = 'Timeout') {
48
48
  export function ensureUrlScheme(url) {
49
49
  if (!url) return url;
50
50
  if (url.startsWith('http://') || url.startsWith('https://')) return url;
51
+ // Skip special browser URLs that don't need scheme
52
+ if (url.startsWith('about:') || url.startsWith('chrome:') || url.startsWith('file:')) {
53
+ return url;
54
+ }
51
55
  if (url.startsWith('localhost') || url.match(/^\\d+\\.\\d+/)) {
52
56
  return `http://${url}`;
53
57
  }
@@ -7,6 +7,7 @@ import { BrowserWsServer } from './internal/ws-server.js';
7
7
  import { logDebug } from './internal/logging.js';
8
8
  import { installServiceProcessLogger } from './internal/service-process-logger.js';
9
9
  import { startHeartbeatWriter } from './internal/heartbeat.js';
10
+ import { appendCommandLog } from '../../utils/command-log.mjs';
10
11
  const clients = new Set();
11
12
  const autoLoops = new Map();
12
13
  function readNumber(value) {
@@ -215,6 +216,18 @@ export async function startBrowserService(opts = {}) {
215
216
  const t0 = Date.now();
216
217
  const action = String(payload?.action || '');
217
218
  const profileId = String(payload?.args?.profileId || payload?.args?.profile || payload?.args?.sessionId || '');
219
+ appendCommandLog({
220
+ action,
221
+ profileId,
222
+ payload: payload?.args ?? {},
223
+ meta: {
224
+ source: 'browser-service',
225
+ cwd: process.cwd(),
226
+ pid: process.pid,
227
+ ppid: process.ppid,
228
+ sender: payload?.meta?.sender && typeof payload.meta.sender === 'object' ? payload.meta.sender : {},
229
+ },
230
+ });
218
231
  logEvent('browser.command.start', { action, profileId });
219
232
  const result = await handleCommand(payload, sessionManager, wsServer, { onSessionStart: markSessionStarted });
220
233
  logEvent('browser.command.done', { action, profileId, ok: result.ok, ms: Date.now() - t0 });
@@ -292,16 +305,19 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
292
305
  switch (action) {
293
306
  case 'start': {
294
307
  const startViewport = resolveStartViewport(args);
295
- const opts = {
296
- profileId: args.profileId || 'default',
297
- sessionName: args.profileId || 'default',
298
- headless: !!args.headless,
299
- initialUrl: args.url,
300
- engine: args.engine || 'camoufox',
301
- fingerprintPlatform: args.fingerprintPlatform || null,
302
- ...(startViewport ? { viewport: startViewport } : {}),
303
- ...(args.ownerPid ? { ownerPid: args.ownerPid } : {}),
304
- };
308
+ const opts = {
309
+ profileId: args.profileId || 'default',
310
+ sessionName: args.profileId || 'default',
311
+ headless: !!args.headless,
312
+ initialUrl: args.url,
313
+ engine: args.engine || 'camoufox',
314
+ fingerprintPlatform: args.fingerprintPlatform || null,
315
+ ...(startViewport ? { viewport: startViewport } : {}),
316
+ ...(args.ownerPid ? { ownerPid: args.ownerPid } : {}),
317
+ };
318
+ if (Number.isFinite(args.maxTabs) && args.maxTabs >= 1) {
319
+ opts.maxTabs = Math.floor(args.maxTabs);
320
+ }
305
321
  const res = await manager.createSession(opts);
306
322
  const session = manager.getSession(opts.profileId);
307
323
  if (!session) {
@@ -479,6 +495,7 @@ async function handleCommand(payload, manager, wsServer, options = {}) {
479
495
  const activeIndex = pages.find((p) => p.active)?.index ?? 0;
480
496
  return { ok: true, body: { ok: true, pages, activeIndex } };
481
497
  }
498
+ case 'newTab':
482
499
  case 'page:new':
483
500
  case 'newPage': {
484
501
  const profileId = args.profileId || 'default';