@web-auto/camo 0.1.26 → 0.2.1
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/LICENSE +21 -21
- package/README.md +586 -586
- package/bin/browser-service.mjs +11 -11
- package/bin/camo.mjs +22 -22
- package/package.json +48 -48
- package/scripts/build.mjs +19 -19
- package/scripts/bump-version.mjs +34 -34
- package/scripts/check-file-size.mjs +80 -80
- package/scripts/file-size-policy.json +12 -2
- package/scripts/install.mjs +76 -76
- package/scripts/release.sh +54 -54
- package/src/autoscript/action-providers/index.mjs +6 -6
- package/src/autoscript/impact-engine.mjs +78 -78
- package/src/autoscript/runtime.mjs +1017 -1017
- package/src/autoscript/schema.mjs +376 -376
- package/src/cli.mjs +405 -405
- package/src/commands/attach.mjs +141 -141
- package/src/commands/autoscript.mjs +1011 -1011
- package/src/commands/browser.mjs +1255 -1257
- package/src/commands/container.mjs +401 -401
- package/src/commands/cookies.mjs +69 -69
- package/src/commands/create.mjs +98 -98
- package/src/commands/devtools.mjs +349 -349
- package/src/commands/events.mjs +152 -152
- package/src/commands/highlight-mode.mjs +24 -24
- package/src/commands/init.mjs +68 -68
- package/src/commands/lifecycle.mjs +275 -275
- package/src/commands/mouse.mjs +45 -45
- package/src/commands/profile.mjs +46 -46
- package/src/commands/record.mjs +115 -115
- package/src/commands/system.mjs +14 -14
- package/src/commands/window.mjs +123 -123
- package/src/container/change-notifier.mjs +362 -362
- package/src/container/element-filter.mjs +143 -143
- package/src/container/index.mjs +3 -3
- package/src/container/runtime-core/checkpoint.mjs +209 -209
- package/src/container/runtime-core/index.mjs +21 -21
- package/src/container/runtime-core/operations/index.mjs +774 -774
- package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
- package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
- package/src/container/runtime-core/operations/viewport.mjs +189 -189
- package/src/container/runtime-core/search.mjs +190 -190
- package/src/container/runtime-core/subscription.mjs +224 -224
- package/src/container/runtime-core/utils.mjs +94 -94
- package/src/container/runtime-core/validation.mjs +127 -184
- package/src/container/runtime-core.mjs +1 -1
- package/src/container/subscription-registry.mjs +459 -459
- package/src/core/actions.mjs +561 -561
- package/src/core/browser.mjs +266 -266
- package/src/core/index.mjs +52 -52
- package/src/core/utils.mjs +91 -91
- package/src/events/daemon-entry.mjs +33 -33
- package/src/events/daemon.mjs +80 -80
- package/src/events/progress-log.mjs +109 -109
- package/src/events/ws-server.mjs +239 -239
- package/src/lib/client.mjs +200 -200
- package/src/lifecycle/cleanup.mjs +83 -83
- package/src/lifecycle/lock.mjs +126 -126
- package/src/lifecycle/session-registry.mjs +279 -279
- package/src/lifecycle/session-view.mjs +76 -76
- package/src/lifecycle/session-watchdog.mjs +281 -281
- package/src/services/browser-service/index.js +671 -674
- package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
- package/src/services/browser-service/internal/BrowserSession.js +325 -336
- package/src/services/browser-service/internal/ElementRegistry.js +60 -60
- package/src/services/browser-service/internal/ProfileLock.js +84 -84
- package/src/services/browser-service/internal/SessionManager.js +184 -184
- package/src/services/browser-service/internal/SessionManager.test.js +39 -39
- package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
- package/src/services/browser-service/internal/browser-session/input-ops.js +222 -219
- package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
- package/src/services/browser-service/internal/browser-session/logging.js +46 -46
- package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
- package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
- package/src/services/browser-service/internal/browser-session/page-management.js +302 -336
- package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
- package/src/services/browser-service/internal/browser-session/recording.js +198 -198
- package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
- package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
- package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
- package/src/services/browser-service/internal/browser-session/types.js +14 -14
- package/src/services/browser-service/internal/browser-session/utils.js +95 -95
- package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
- package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
- package/src/services/browser-service/internal/container-matcher.js +851 -851
- package/src/services/browser-service/internal/container-registry.js +182 -182
- package/src/services/browser-service/internal/engine-manager.js +259 -259
- package/src/services/browser-service/internal/fingerprint.js +203 -203
- package/src/services/browser-service/internal/heartbeat.js +137 -137
- package/src/services/browser-service/internal/logging.js +46 -46
- package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
- package/src/services/browser-service/internal/pageRuntime.js +28 -28
- package/src/services/browser-service/internal/runtimeInjector.js +31 -31
- package/src/services/browser-service/internal/service-process-logger.js +140 -140
- package/src/services/browser-service/internal/state-bus.js +45 -45
- package/src/services/browser-service/internal/storage-paths.js +42 -42
- package/src/services/browser-service/internal/ws-server.js +1194 -1194
- package/src/services/browser-service/internal/ws-server.test.js +58 -58
- package/src/services/browser-service/server.mjs +6 -6
- package/src/services/controller/cli-bridge.js +93 -93
- package/src/services/controller/container-index.js +50 -50
- package/src/services/controller/container-storage.js +36 -36
- package/src/services/controller/controller-actions.js +207 -207
- package/src/services/controller/controller.js +1138 -1138
- package/src/services/controller/selectors.js +54 -54
- package/src/services/controller/transport.js +125 -125
- package/src/utils/args.mjs +26 -26
- package/src/utils/browser-service.mjs +544 -544
- package/src/utils/command-log.mjs +64 -64
- package/src/utils/config.mjs +214 -214
- package/src/utils/fingerprint.mjs +181 -181
- package/src/utils/help.mjs +216 -216
- package/src/utils/js-policy.mjs +13 -13
- package/src/utils/ws-client.mjs +30 -30
- package/src/container/runtime-core/operations/tab-pool.mjs.bak +0 -762
- package/src/container/runtime-core/operations/tab-pool.mjs.syntax-error +0 -762
- package/src/services/browser-service/index.js.bak +0 -671
|
@@ -1,544 +1,544 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execSync, spawn } from 'node:child_process';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import { createRequire } from 'node:module';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
9
|
-
import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
|
|
10
|
-
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
11
|
-
import { buildCommandSenderMeta } from './command-log.mjs';
|
|
12
|
-
|
|
13
|
-
const require = createRequire(import.meta.url);
|
|
14
|
-
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
15
|
-
|
|
16
|
-
function resolveApiTimeoutMs(options = {}) {
|
|
17
|
-
const optionValue = Number(options?.timeoutMs);
|
|
18
|
-
if (Number.isFinite(optionValue) && optionValue > 0) {
|
|
19
|
-
return Math.max(1000, Math.floor(optionValue));
|
|
20
|
-
}
|
|
21
|
-
const envValue = Number(process.env.CAMO_API_TIMEOUT_MS);
|
|
22
|
-
if (Number.isFinite(envValue) && envValue > 0) {
|
|
23
|
-
return Math.max(1000, Math.floor(envValue));
|
|
24
|
-
}
|
|
25
|
-
return DEFAULT_API_TIMEOUT_MS;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function resolveWsUrl() {
|
|
29
|
-
const cfg = loadConfig();
|
|
30
|
-
const explicit = String(process.env.CAMO_WS_URL || '').trim();
|
|
31
|
-
if (explicit) return explicit;
|
|
32
|
-
const host = String(process.env.CAMO_WS_HOST || '127.0.0.1').trim() || '127.0.0.1';
|
|
33
|
-
const port = Number(process.env.CAMO_WS_PORT || 8765) || 8765;
|
|
34
|
-
return `ws://${host}:${port}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function openWs() {
|
|
38
|
-
if (typeof WebSocket !== 'function') {
|
|
39
|
-
throw new Error('Global WebSocket is unavailable in this Node runtime');
|
|
40
|
-
}
|
|
41
|
-
const wsUrl = resolveWsUrl();
|
|
42
|
-
return new Promise((resolve, reject) => {
|
|
43
|
-
const socket = new WebSocket(wsUrl);
|
|
44
|
-
const timer = setTimeout(() => {
|
|
45
|
-
try { socket.close(); } catch {}
|
|
46
|
-
reject(new Error(`WebSocket connect timeout: ${wsUrl}`));
|
|
47
|
-
}, 8000);
|
|
48
|
-
socket.addEventListener('open', () => {
|
|
49
|
-
clearTimeout(timer);
|
|
50
|
-
resolve(socket);
|
|
51
|
-
});
|
|
52
|
-
socket.addEventListener('error', (err) => {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
reject(new Error(`WebSocket connect failed: ${err?.message || String(err)}`));
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function callWS(action, payload = {}, options = {}) {
|
|
60
|
-
const timeoutMs = resolveApiTimeoutMs(options);
|
|
61
|
-
const socket = await openWs();
|
|
62
|
-
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
63
|
-
const sessionId = String(payload?.profileId || payload?.sessionId || payload?.profile || '').trim();
|
|
64
|
-
const message = {
|
|
65
|
-
type: 'command',
|
|
66
|
-
request_id: requestId,
|
|
67
|
-
session_id: sessionId,
|
|
68
|
-
data: { command_type: 'dev_command', action, parameters: payload },
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
return new Promise((resolve, reject) => {
|
|
72
|
-
const timer = setTimeout(() => {
|
|
73
|
-
try { socket.close(); } catch {}
|
|
74
|
-
reject(new Error(`browser-service ws timeout after ${timeoutMs}ms: ${action}`));
|
|
75
|
-
}, timeoutMs);
|
|
76
|
-
|
|
77
|
-
socket.addEventListener('message', (event) => {
|
|
78
|
-
try {
|
|
79
|
-
const data = typeof event.data === 'string' ? JSON.parse(event.data) : JSON.parse(String(event.data));
|
|
80
|
-
if (data?.type === 'response' && data.request_id === requestId) {
|
|
81
|
-
clearTimeout(timer);
|
|
82
|
-
try { socket.close(); } catch {}
|
|
83
|
-
resolve(data?.data ?? data);
|
|
84
|
-
}
|
|
85
|
-
} catch (err) {
|
|
86
|
-
clearTimeout(timer);
|
|
87
|
-
try { socket.close(); } catch {}
|
|
88
|
-
reject(err);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
socket.send(JSON.stringify(message));
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function findRepoRootCandidate() {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function isTimeoutError(error) {
|
|
101
|
-
const name = String(error?.name || '').toLowerCase();
|
|
102
|
-
const message = String(error?.message || '').toLowerCase();
|
|
103
|
-
return (
|
|
104
|
-
name.includes('timeout')
|
|
105
|
-
|| name.includes('abort')
|
|
106
|
-
|| message.includes('timeout')
|
|
107
|
-
|| message.includes('timed out')
|
|
108
|
-
|| message.includes('aborted')
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function shouldTrackSessionActivity(action, payload) {
|
|
113
|
-
const profileId = String(payload?.profileId || '').trim();
|
|
114
|
-
if (!profileId) return false;
|
|
115
|
-
if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export async function callAPI(action, payload = {}, options = {}) {
|
|
120
|
-
const timeoutMs = resolveApiTimeoutMs(options);
|
|
121
|
-
const senderMeta = buildCommandSenderMeta({
|
|
122
|
-
source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
|
|
123
|
-
cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
|
|
124
|
-
pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
|
|
125
|
-
ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
|
|
126
|
-
argv: Array.isArray(options?.argv)
|
|
127
|
-
? options.argv
|
|
128
|
-
: Array.isArray(payload?.__commandArgv)
|
|
129
|
-
? payload.__commandArgv
|
|
130
|
-
: process.argv.slice(),
|
|
131
|
-
});
|
|
132
|
-
let r;
|
|
133
|
-
try {
|
|
134
|
-
r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
135
|
-
method: 'POST',
|
|
136
|
-
headers: { 'Content-Type': 'application/json' },
|
|
137
|
-
body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
|
|
138
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
139
|
-
});
|
|
140
|
-
} catch (error) {
|
|
141
|
-
if (isTimeoutError(error)) {
|
|
142
|
-
throw new Error(`browser-service timeout after ${timeoutMs}ms: ${action}`);
|
|
143
|
-
}
|
|
144
|
-
throw error;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
let body;
|
|
148
|
-
try {
|
|
149
|
-
body = await r.json();
|
|
150
|
-
} catch {
|
|
151
|
-
const text = await r.text();
|
|
152
|
-
throw new Error(`HTTP ${r.status}: ${text}`);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
|
156
|
-
if (shouldTrackSessionActivity(action, payload)) {
|
|
157
|
-
touchSessionActivity(payload.profileId, {
|
|
158
|
-
lastAction: String(action || '').trim() || null,
|
|
159
|
-
lastActionAt: Date.now(),
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
return body;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
export async function getSessionByProfile(profileId) {
|
|
166
|
-
const status = await callAPI('getStatus', {});
|
|
167
|
-
if (!profileId) {
|
|
168
|
-
return null;
|
|
169
|
-
}
|
|
170
|
-
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
171
|
-
const resolved = resolveSessionViewByProfile(profileId, liveSessions);
|
|
172
|
-
if (resolved?.live) {
|
|
173
|
-
const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
|
|
174
|
-
if (activeSession) return activeSession;
|
|
175
|
-
}
|
|
176
|
-
if (!resolved?.live) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Some browser-service builds do not populate current_url reliably.
|
|
181
|
-
// Fallback to page:list only to enrich an already-live profile.
|
|
182
|
-
try {
|
|
183
|
-
const pagePayload = await callAPI('page:list', { profileId });
|
|
184
|
-
const pages = Array.isArray(pagePayload?.pages)
|
|
185
|
-
? pagePayload.pages
|
|
186
|
-
: Array.isArray(pagePayload?.data?.pages)
|
|
187
|
-
? pagePayload.data.pages
|
|
188
|
-
: [];
|
|
189
|
-
if (!pages.length) return null;
|
|
190
|
-
const activeIndex = Number(pagePayload?.activeIndex ?? pagePayload?.data?.activeIndex);
|
|
191
|
-
const activePage = Number.isFinite(activeIndex)
|
|
192
|
-
? pages.find((page) => Number(page?.index) === activeIndex)
|
|
193
|
-
: (pages.find((page) => page?.active) || pages[0]);
|
|
194
|
-
return {
|
|
195
|
-
profileId,
|
|
196
|
-
session_id: profileId,
|
|
197
|
-
sessionId: profileId,
|
|
198
|
-
current_url: activePage?.url || null,
|
|
199
|
-
recoveredFromPages: true,
|
|
200
|
-
};
|
|
201
|
-
} catch {
|
|
202
|
-
return null;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export async function getResolvedSessions() {
|
|
207
|
-
const status = await callAPI('getStatus', {});
|
|
208
|
-
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
209
|
-
return buildResolvedSessionView(liveSessions);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function buildDomSnapshotScript(maxDepth, maxChildren) {
|
|
213
|
-
return `(() => {
|
|
214
|
-
const MAX_DEPTH = ${maxDepth};
|
|
215
|
-
const MAX_CHILDREN = ${maxChildren};
|
|
216
|
-
const viewportWidth = Number(window.innerWidth || 0);
|
|
217
|
-
const viewportHeight = Number(window.innerHeight || 0);
|
|
218
|
-
|
|
219
|
-
const normalizeRect = (rect) => {
|
|
220
|
-
if (!rect) return null;
|
|
221
|
-
const left = Number(rect.left ?? rect.x ?? 0);
|
|
222
|
-
const top = Number(rect.top ?? rect.y ?? 0);
|
|
223
|
-
const width = Number(rect.width ?? 0);
|
|
224
|
-
const height = Number(rect.height ?? 0);
|
|
225
|
-
return {
|
|
226
|
-
left,
|
|
227
|
-
top,
|
|
228
|
-
right: left + width,
|
|
229
|
-
bottom: top + height,
|
|
230
|
-
x: left,
|
|
231
|
-
y: top,
|
|
232
|
-
width,
|
|
233
|
-
height,
|
|
234
|
-
};
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
const sanitizeClasses = (el) => {
|
|
238
|
-
const classAttr = typeof el.className === 'string'
|
|
239
|
-
? el.className
|
|
240
|
-
: (el.getAttribute && el.getAttribute('class')) || '';
|
|
241
|
-
return classAttr.split(/\\s+/).filter(Boolean).slice(0, 24);
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
const collectAttrs = (el) => {
|
|
245
|
-
if (!el || !el.getAttribute) return null;
|
|
246
|
-
const keys = [
|
|
247
|
-
'href',
|
|
248
|
-
'src',
|
|
249
|
-
'name',
|
|
250
|
-
'type',
|
|
251
|
-
'value',
|
|
252
|
-
'placeholder',
|
|
253
|
-
'role',
|
|
254
|
-
'aria-label',
|
|
255
|
-
'aria-hidden',
|
|
256
|
-
'title',
|
|
257
|
-
];
|
|
258
|
-
const attrs = {};
|
|
259
|
-
for (const key of keys) {
|
|
260
|
-
const value = el.getAttribute(key);
|
|
261
|
-
if (value === null || value === undefined || value === '') continue;
|
|
262
|
-
attrs[key] = String(value).slice(0, 400);
|
|
263
|
-
}
|
|
264
|
-
return Object.keys(attrs).length > 0 ? attrs : null;
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
const inViewport = (rect) => {
|
|
268
|
-
if (!rect) return false;
|
|
269
|
-
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
270
|
-
return (
|
|
271
|
-
rect.right > 0
|
|
272
|
-
&& rect.bottom > 0
|
|
273
|
-
&& rect.left < viewportWidth
|
|
274
|
-
&& rect.top < viewportHeight
|
|
275
|
-
);
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const isRendered = (el) => {
|
|
279
|
-
try {
|
|
280
|
-
const style = window.getComputedStyle(el);
|
|
281
|
-
if (!style) return false;
|
|
282
|
-
if (style.display === 'none') return false;
|
|
283
|
-
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
284
|
-
const opacity = Number.parseFloat(String(style.opacity || '1'));
|
|
285
|
-
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
286
|
-
return true;
|
|
287
|
-
} catch {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const clampPoint = (value, max) => {
|
|
293
|
-
if (!Number.isFinite(value)) return 0;
|
|
294
|
-
if (max <= 1) return 0;
|
|
295
|
-
return Math.max(0, Math.min(max - 1, value));
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
const hitTestVisible = (el, rect) => {
|
|
299
|
-
if (!rect || viewportWidth <= 0 || viewportHeight <= 0) return false;
|
|
300
|
-
const samplePoints = [
|
|
301
|
-
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
|
|
302
|
-
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
|
|
303
|
-
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
|
|
304
|
-
];
|
|
305
|
-
for (const [rawX, rawY] of samplePoints) {
|
|
306
|
-
const x = clampPoint(rawX, viewportWidth);
|
|
307
|
-
const y = clampPoint(rawY, viewportHeight);
|
|
308
|
-
const topEl = document.elementFromPoint(x, y);
|
|
309
|
-
if (!topEl) continue;
|
|
310
|
-
if (topEl === el) return true;
|
|
311
|
-
if (el.contains && el.contains(topEl)) return true;
|
|
312
|
-
if (topEl.contains && topEl.contains(el)) return true;
|
|
313
|
-
}
|
|
314
|
-
return false;
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
const collect = (el, depth = 0, path = 'root') => {
|
|
318
|
-
if (!el || depth > MAX_DEPTH) return null;
|
|
319
|
-
const classes = sanitizeClasses(el);
|
|
320
|
-
const rect = normalizeRect(el.getBoundingClientRect ? el.getBoundingClientRect() : null);
|
|
321
|
-
const tag = String(el.tagName || el.nodeName || '').toLowerCase();
|
|
322
|
-
const id = el.id || null;
|
|
323
|
-
const text = typeof el.textContent === 'string'
|
|
324
|
-
? el.textContent.replace(/\\s+/g, ' ').trim()
|
|
325
|
-
: '';
|
|
326
|
-
const selector = tag
|
|
327
|
-
? \`\${tag}\${id ? '#' + id : ''}\${classes.length ? '.' + classes.slice(0, 3).join('.') : ''}\`
|
|
328
|
-
: null;
|
|
329
|
-
|
|
330
|
-
const node = {
|
|
331
|
-
tag,
|
|
332
|
-
id,
|
|
333
|
-
classes,
|
|
334
|
-
selector,
|
|
335
|
-
path,
|
|
336
|
-
};
|
|
337
|
-
const attrs = collectAttrs(el);
|
|
338
|
-
if (attrs) node.attrs = attrs;
|
|
339
|
-
if (attrs && attrs.href) node.href = attrs.href;
|
|
340
|
-
if (rect) node.rect = rect;
|
|
341
|
-
if (text) node.textSnippet = text.slice(0, 120);
|
|
342
|
-
if (rect) {
|
|
343
|
-
const rendered = isRendered(el);
|
|
344
|
-
const withinViewport = inViewport(rect);
|
|
345
|
-
const visible = rendered && withinViewport && hitTestVisible(el, rect);
|
|
346
|
-
node.visible = visible;
|
|
347
|
-
} else {
|
|
348
|
-
node.visible = false;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const children = Array.from(el.children || []);
|
|
352
|
-
if (children.length > 0 && depth < MAX_DEPTH) {
|
|
353
|
-
node.children = [];
|
|
354
|
-
const limit = Math.min(children.length, MAX_CHILDREN);
|
|
355
|
-
for (let i = 0; i < limit; i += 1) {
|
|
356
|
-
const child = collect(children[i], depth + 1, \`\${path}/\${i}\`);
|
|
357
|
-
if (child) node.children.push(child);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return node;
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
const root = collect(document.body || document.documentElement, 0, 'root');
|
|
365
|
-
return {
|
|
366
|
-
dom_tree: root,
|
|
367
|
-
viewport: {
|
|
368
|
-
width: viewportWidth,
|
|
369
|
-
height: viewportHeight,
|
|
370
|
-
},
|
|
371
|
-
};
|
|
372
|
-
})()`;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
export async function getDomSnapshotByProfile(profileId, options = {}) {
|
|
376
|
-
const maxDepth = Math.max(1, Math.min(20, Number(options.maxDepth) || 10));
|
|
377
|
-
const maxChildren = Math.max(1, Math.min(500, Number(options.maxChildren) || 120));
|
|
378
|
-
const response = await callAPI('evaluate', {
|
|
379
|
-
profileId,
|
|
380
|
-
script: buildDomSnapshotScript(maxDepth, maxChildren),
|
|
381
|
-
});
|
|
382
|
-
const payload = response?.result || response || {};
|
|
383
|
-
const tree = payload.dom_tree || null;
|
|
384
|
-
if (tree && payload.viewport && typeof payload.viewport === 'object') {
|
|
385
|
-
tree.__viewport = {
|
|
386
|
-
width: Number(payload.viewport.width) || 0,
|
|
387
|
-
height: Number(payload.viewport.height) || 0,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
return tree;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
export async function getViewportByProfile(profileId) {
|
|
394
|
-
const response = await callAPI('evaluate', {
|
|
395
|
-
profileId,
|
|
396
|
-
script: `(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()`,
|
|
397
|
-
});
|
|
398
|
-
const viewport = response?.result || response?.viewport || {};
|
|
399
|
-
const width = Number(viewport?.width) || 1280;
|
|
400
|
-
const height = Number(viewport?.height) || 720;
|
|
401
|
-
return { width, height };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export async function checkBrowserService() {
|
|
405
|
-
try {
|
|
406
|
-
const r = await fetch(`${BROWSER_SERVICE_URL}/health`, { signal: AbortSignal.timeout(2000) });
|
|
407
|
-
return r.ok;
|
|
408
|
-
} catch {
|
|
409
|
-
return false;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export function detectCamoufoxPath() {
|
|
414
|
-
try {
|
|
415
|
-
const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
|
|
416
|
-
const out = execSync(cmd, {
|
|
417
|
-
encoding: 'utf8',
|
|
418
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
419
|
-
});
|
|
420
|
-
const lines = out.trim().split(/\r?\n/);
|
|
421
|
-
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
422
|
-
const line = lines[i].trim();
|
|
423
|
-
if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
|
|
424
|
-
}
|
|
425
|
-
} catch {
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
return null;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export function ensureCamoufox() {
|
|
432
|
-
if (detectCamoufoxPath()) return;
|
|
433
|
-
console.log('Camoufox is not found. Installing...');
|
|
434
|
-
execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
|
|
435
|
-
if (!detectCamoufoxPath()) {
|
|
436
|
-
throw new Error('Camoufox install finished but executable was not detected');
|
|
437
|
-
}
|
|
438
|
-
console.log('Camoufox installed.');
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
const CONTROLLER_SERVER_REL = path.join('src', 'services', 'browser-service', 'index.js');
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
function hasControllerServer(root) {
|
|
445
|
-
if (!root) return false;
|
|
446
|
-
return fs.existsSync(path.join(root, CONTROLLER_SERVER_REL));
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
function scanCommonInstallRoots() {
|
|
454
|
-
const home = os.homedir();
|
|
455
|
-
const appData = String(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'));
|
|
456
|
-
const npmPrefix = String(process.env.npm_config_prefix || '').trim();
|
|
457
|
-
const nodeModuleRoots = [
|
|
458
|
-
path.join(appData, 'npm', 'node_modules'),
|
|
459
|
-
path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
|
|
460
|
-
npmPrefix ? path.join(npmPrefix, 'node_modules') : '',
|
|
461
|
-
npmPrefix ? path.join(npmPrefix, 'lib', 'node_modules') : '',
|
|
462
|
-
'/usr/local/lib/node_modules',
|
|
463
|
-
'/usr/lib/node_modules',
|
|
464
|
-
path.join(home, '.npm-global', 'lib', 'node_modules'),
|
|
465
|
-
].filter(Boolean);
|
|
466
|
-
|
|
467
|
-
for (const root of nodeModuleRoots) {
|
|
468
|
-
const candidate = path.join(root, '@web-auto', 'camo');
|
|
469
|
-
if (hasControllerServer(candidate)) return candidate;
|
|
470
|
-
}
|
|
471
|
-
return null;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
export function findInstallRootCandidate() {
|
|
480
|
-
const cfg = loadConfig();
|
|
481
|
-
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
482
|
-
const siblingInScopedNodeModules = path.resolve(currentDir, '..', '..', '..', 'camo');
|
|
483
|
-
const candidates = [
|
|
484
|
-
process.env.CAMO_INSTALL_DIR,
|
|
485
|
-
process.env.CAMO_PACKAGE_ROOT,
|
|
486
|
-
process.env.CAMO_REPO_ROOT,
|
|
487
|
-
cfg.repoRoot,
|
|
488
|
-
siblingInScopedNodeModules,
|
|
489
|
-
process.cwd(),
|
|
490
|
-
].filter(Boolean);
|
|
491
|
-
|
|
492
|
-
try {
|
|
493
|
-
const pkgPath = require.resolve('@web-auto/camo/package.json');
|
|
494
|
-
candidates.push(path.dirname(pkgPath));
|
|
495
|
-
} catch {
|
|
496
|
-
// ignore resolution failures in npx-only environments
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const seen = new Set();
|
|
500
|
-
for (const raw of candidates) {
|
|
501
|
-
const resolved = path.resolve(String(raw));
|
|
502
|
-
if (seen.has(resolved)) continue;
|
|
503
|
-
seen.add(resolved);
|
|
504
|
-
if (hasControllerServer(resolved)) return resolved;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return scanCommonInstallRoots();
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
export async function ensureBrowserService() {
|
|
511
|
-
if (await checkBrowserService()) return;
|
|
512
|
-
|
|
513
|
-
const installRoot = findInstallRootCandidate();
|
|
514
|
-
if (!installRoot) {
|
|
515
|
-
throw new Error(
|
|
516
|
-
`Cannot locate browser-service launcher (${CONTROLLER_SERVER_REL}). ` +
|
|
517
|
-
'Ensure @web-auto/camo is installed or set CAMO_INSTALL_DIR.',
|
|
518
|
-
);
|
|
519
|
-
}
|
|
520
|
-
const scriptPath = path.join(installRoot, CONTROLLER_SERVER_REL);
|
|
521
|
-
const env = {
|
|
522
|
-
...process.env,
|
|
523
|
-
CAMO_REPO_ROOT: String(process.env.CAMO_REPO_ROOT || '').trim() || installRoot,
|
|
524
|
-
};
|
|
525
|
-
const child = spawn(process.execPath, [scriptPath], {
|
|
526
|
-
cwd: installRoot,
|
|
527
|
-
detached: true,
|
|
528
|
-
stdio: 'ignore',
|
|
529
|
-
windowsHide: true,
|
|
530
|
-
env,
|
|
531
|
-
});
|
|
532
|
-
child.unref();
|
|
533
|
-
console.log(`Starting browser-service daemon (pid=${child.pid || 'unknown'})...`);
|
|
534
|
-
|
|
535
|
-
for (let i = 0; i < 20; i += 1) {
|
|
536
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
537
|
-
if (await checkBrowserService()) {
|
|
538
|
-
console.log('Browser-service is ready.');
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
throw new Error('Browser-service failed to become healthy within timeout');
|
|
544
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync, spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { BROWSER_SERVICE_URL, loadConfig, setRepoRoot } from './config.mjs';
|
|
9
|
+
import { touchSessionActivity } from '../lifecycle/session-registry.mjs';
|
|
10
|
+
import { buildResolvedSessionView, resolveSessionViewByProfile } from '../lifecycle/session-view.mjs';
|
|
11
|
+
import { buildCommandSenderMeta } from './command-log.mjs';
|
|
12
|
+
|
|
13
|
+
const require = createRequire(import.meta.url);
|
|
14
|
+
const DEFAULT_API_TIMEOUT_MS = 90000;
|
|
15
|
+
|
|
16
|
+
function resolveApiTimeoutMs(options = {}) {
|
|
17
|
+
const optionValue = Number(options?.timeoutMs);
|
|
18
|
+
if (Number.isFinite(optionValue) && optionValue > 0) {
|
|
19
|
+
return Math.max(1000, Math.floor(optionValue));
|
|
20
|
+
}
|
|
21
|
+
const envValue = Number(process.env.CAMO_API_TIMEOUT_MS);
|
|
22
|
+
if (Number.isFinite(envValue) && envValue > 0) {
|
|
23
|
+
return Math.max(1000, Math.floor(envValue));
|
|
24
|
+
}
|
|
25
|
+
return DEFAULT_API_TIMEOUT_MS;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveWsUrl() {
|
|
29
|
+
const cfg = loadConfig();
|
|
30
|
+
const explicit = String(process.env.CAMO_WS_URL || '').trim();
|
|
31
|
+
if (explicit) return explicit;
|
|
32
|
+
const host = String(process.env.CAMO_WS_HOST || '127.0.0.1').trim() || '127.0.0.1';
|
|
33
|
+
const port = Number(process.env.CAMO_WS_PORT || 8765) || 8765;
|
|
34
|
+
return `ws://${host}:${port}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function openWs() {
|
|
38
|
+
if (typeof WebSocket !== 'function') {
|
|
39
|
+
throw new Error('Global WebSocket is unavailable in this Node runtime');
|
|
40
|
+
}
|
|
41
|
+
const wsUrl = resolveWsUrl();
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const socket = new WebSocket(wsUrl);
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
try { socket.close(); } catch {}
|
|
46
|
+
reject(new Error(`WebSocket connect timeout: ${wsUrl}`));
|
|
47
|
+
}, 8000);
|
|
48
|
+
socket.addEventListener('open', () => {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve(socket);
|
|
51
|
+
});
|
|
52
|
+
socket.addEventListener('error', (err) => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
reject(new Error(`WebSocket connect failed: ${err?.message || String(err)}`));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function callWS(action, payload = {}, options = {}) {
|
|
60
|
+
const timeoutMs = resolveApiTimeoutMs(options);
|
|
61
|
+
const socket = await openWs();
|
|
62
|
+
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
63
|
+
const sessionId = String(payload?.profileId || payload?.sessionId || payload?.profile || '').trim();
|
|
64
|
+
const message = {
|
|
65
|
+
type: 'command',
|
|
66
|
+
request_id: requestId,
|
|
67
|
+
session_id: sessionId,
|
|
68
|
+
data: { command_type: 'dev_command', action, parameters: payload },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
try { socket.close(); } catch {}
|
|
74
|
+
reject(new Error(`browser-service ws timeout after ${timeoutMs}ms: ${action}`));
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
|
|
77
|
+
socket.addEventListener('message', (event) => {
|
|
78
|
+
try {
|
|
79
|
+
const data = typeof event.data === 'string' ? JSON.parse(event.data) : JSON.parse(String(event.data));
|
|
80
|
+
if (data?.type === 'response' && data.request_id === requestId) {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
try { socket.close(); } catch {}
|
|
83
|
+
resolve(data?.data ?? data);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
try { socket.close(); } catch {}
|
|
88
|
+
reject(err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
socket.send(JSON.stringify(message));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findRepoRootCandidate() {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isTimeoutError(error) {
|
|
101
|
+
const name = String(error?.name || '').toLowerCase();
|
|
102
|
+
const message = String(error?.message || '').toLowerCase();
|
|
103
|
+
return (
|
|
104
|
+
name.includes('timeout')
|
|
105
|
+
|| name.includes('abort')
|
|
106
|
+
|| message.includes('timeout')
|
|
107
|
+
|| message.includes('timed out')
|
|
108
|
+
|| message.includes('aborted')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shouldTrackSessionActivity(action, payload) {
|
|
113
|
+
const profileId = String(payload?.profileId || '').trim();
|
|
114
|
+
if (!profileId) return false;
|
|
115
|
+
if (action === 'getStatus' || action === 'service:shutdown' || action === 'stop') return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function callAPI(action, payload = {}, options = {}) {
|
|
120
|
+
const timeoutMs = resolveApiTimeoutMs(options);
|
|
121
|
+
const senderMeta = buildCommandSenderMeta({
|
|
122
|
+
source: String(options?.source || payload?.__commandSource || 'browser-service-client').trim() || 'browser-service-client',
|
|
123
|
+
cwd: String(options?.cwd || payload?.__commandCwd || process.cwd()).trim() || process.cwd(),
|
|
124
|
+
pid: Number(options?.pid || payload?.__commandPid || process.pid) || process.pid,
|
|
125
|
+
ppid: Number(options?.ppid || payload?.__commandPpid || process.ppid) || process.ppid,
|
|
126
|
+
argv: Array.isArray(options?.argv)
|
|
127
|
+
? options.argv
|
|
128
|
+
: Array.isArray(payload?.__commandArgv)
|
|
129
|
+
? payload.__commandArgv
|
|
130
|
+
: process.argv.slice(),
|
|
131
|
+
});
|
|
132
|
+
let r;
|
|
133
|
+
try {
|
|
134
|
+
r = await fetch(`${BROWSER_SERVICE_URL}/command`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ action, args: payload, meta: { sender: senderMeta } }),
|
|
138
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isTimeoutError(error)) {
|
|
142
|
+
throw new Error(`browser-service timeout after ${timeoutMs}ms: ${action}`);
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let body;
|
|
148
|
+
try {
|
|
149
|
+
body = await r.json();
|
|
150
|
+
} catch {
|
|
151
|
+
const text = await r.text();
|
|
152
|
+
throw new Error(`HTTP ${r.status}: ${text}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!r.ok) throw new Error(body?.error || `HTTP ${r.status}`);
|
|
156
|
+
if (shouldTrackSessionActivity(action, payload)) {
|
|
157
|
+
touchSessionActivity(payload.profileId, {
|
|
158
|
+
lastAction: String(action || '').trim() || null,
|
|
159
|
+
lastActionAt: Date.now(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return body;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function getSessionByProfile(profileId) {
|
|
166
|
+
const status = await callAPI('getStatus', {});
|
|
167
|
+
if (!profileId) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
171
|
+
const resolved = resolveSessionViewByProfile(profileId, liveSessions);
|
|
172
|
+
if (resolved?.live) {
|
|
173
|
+
const activeSession = liveSessions.find((session) => String(session?.profileId || '').trim() === resolved.profileId) || null;
|
|
174
|
+
if (activeSession) return activeSession;
|
|
175
|
+
}
|
|
176
|
+
if (!resolved?.live) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Some browser-service builds do not populate current_url reliably.
|
|
181
|
+
// Fallback to page:list only to enrich an already-live profile.
|
|
182
|
+
try {
|
|
183
|
+
const pagePayload = await callAPI('page:list', { profileId });
|
|
184
|
+
const pages = Array.isArray(pagePayload?.pages)
|
|
185
|
+
? pagePayload.pages
|
|
186
|
+
: Array.isArray(pagePayload?.data?.pages)
|
|
187
|
+
? pagePayload.data.pages
|
|
188
|
+
: [];
|
|
189
|
+
if (!pages.length) return null;
|
|
190
|
+
const activeIndex = Number(pagePayload?.activeIndex ?? pagePayload?.data?.activeIndex);
|
|
191
|
+
const activePage = Number.isFinite(activeIndex)
|
|
192
|
+
? pages.find((page) => Number(page?.index) === activeIndex)
|
|
193
|
+
: (pages.find((page) => page?.active) || pages[0]);
|
|
194
|
+
return {
|
|
195
|
+
profileId,
|
|
196
|
+
session_id: profileId,
|
|
197
|
+
sessionId: profileId,
|
|
198
|
+
current_url: activePage?.url || null,
|
|
199
|
+
recoveredFromPages: true,
|
|
200
|
+
};
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function getResolvedSessions() {
|
|
207
|
+
const status = await callAPI('getStatus', {});
|
|
208
|
+
const liveSessions = Array.isArray(status?.sessions) ? status.sessions : [];
|
|
209
|
+
return buildResolvedSessionView(liveSessions);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildDomSnapshotScript(maxDepth, maxChildren) {
|
|
213
|
+
return `(() => {
|
|
214
|
+
const MAX_DEPTH = ${maxDepth};
|
|
215
|
+
const MAX_CHILDREN = ${maxChildren};
|
|
216
|
+
const viewportWidth = Number(window.innerWidth || 0);
|
|
217
|
+
const viewportHeight = Number(window.innerHeight || 0);
|
|
218
|
+
|
|
219
|
+
const normalizeRect = (rect) => {
|
|
220
|
+
if (!rect) return null;
|
|
221
|
+
const left = Number(rect.left ?? rect.x ?? 0);
|
|
222
|
+
const top = Number(rect.top ?? rect.y ?? 0);
|
|
223
|
+
const width = Number(rect.width ?? 0);
|
|
224
|
+
const height = Number(rect.height ?? 0);
|
|
225
|
+
return {
|
|
226
|
+
left,
|
|
227
|
+
top,
|
|
228
|
+
right: left + width,
|
|
229
|
+
bottom: top + height,
|
|
230
|
+
x: left,
|
|
231
|
+
y: top,
|
|
232
|
+
width,
|
|
233
|
+
height,
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sanitizeClasses = (el) => {
|
|
238
|
+
const classAttr = typeof el.className === 'string'
|
|
239
|
+
? el.className
|
|
240
|
+
: (el.getAttribute && el.getAttribute('class')) || '';
|
|
241
|
+
return classAttr.split(/\\s+/).filter(Boolean).slice(0, 24);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const collectAttrs = (el) => {
|
|
245
|
+
if (!el || !el.getAttribute) return null;
|
|
246
|
+
const keys = [
|
|
247
|
+
'href',
|
|
248
|
+
'src',
|
|
249
|
+
'name',
|
|
250
|
+
'type',
|
|
251
|
+
'value',
|
|
252
|
+
'placeholder',
|
|
253
|
+
'role',
|
|
254
|
+
'aria-label',
|
|
255
|
+
'aria-hidden',
|
|
256
|
+
'title',
|
|
257
|
+
];
|
|
258
|
+
const attrs = {};
|
|
259
|
+
for (const key of keys) {
|
|
260
|
+
const value = el.getAttribute(key);
|
|
261
|
+
if (value === null || value === undefined || value === '') continue;
|
|
262
|
+
attrs[key] = String(value).slice(0, 400);
|
|
263
|
+
}
|
|
264
|
+
return Object.keys(attrs).length > 0 ? attrs : null;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const inViewport = (rect) => {
|
|
268
|
+
if (!rect) return false;
|
|
269
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
270
|
+
return (
|
|
271
|
+
rect.right > 0
|
|
272
|
+
&& rect.bottom > 0
|
|
273
|
+
&& rect.left < viewportWidth
|
|
274
|
+
&& rect.top < viewportHeight
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const isRendered = (el) => {
|
|
279
|
+
try {
|
|
280
|
+
const style = window.getComputedStyle(el);
|
|
281
|
+
if (!style) return false;
|
|
282
|
+
if (style.display === 'none') return false;
|
|
283
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
284
|
+
const opacity = Number.parseFloat(String(style.opacity || '1'));
|
|
285
|
+
if (Number.isFinite(opacity) && opacity <= 0.01) return false;
|
|
286
|
+
return true;
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const clampPoint = (value, max) => {
|
|
293
|
+
if (!Number.isFinite(value)) return 0;
|
|
294
|
+
if (max <= 1) return 0;
|
|
295
|
+
return Math.max(0, Math.min(max - 1, value));
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const hitTestVisible = (el, rect) => {
|
|
299
|
+
if (!rect || viewportWidth <= 0 || viewportHeight <= 0) return false;
|
|
300
|
+
const samplePoints = [
|
|
301
|
+
[rect.left + rect.width * 0.5, rect.top + rect.height * 0.5],
|
|
302
|
+
[rect.left + rect.width * 0.2, rect.top + rect.height * 0.2],
|
|
303
|
+
[rect.left + rect.width * 0.8, rect.top + rect.height * 0.8],
|
|
304
|
+
];
|
|
305
|
+
for (const [rawX, rawY] of samplePoints) {
|
|
306
|
+
const x = clampPoint(rawX, viewportWidth);
|
|
307
|
+
const y = clampPoint(rawY, viewportHeight);
|
|
308
|
+
const topEl = document.elementFromPoint(x, y);
|
|
309
|
+
if (!topEl) continue;
|
|
310
|
+
if (topEl === el) return true;
|
|
311
|
+
if (el.contains && el.contains(topEl)) return true;
|
|
312
|
+
if (topEl.contains && topEl.contains(el)) return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const collect = (el, depth = 0, path = 'root') => {
|
|
318
|
+
if (!el || depth > MAX_DEPTH) return null;
|
|
319
|
+
const classes = sanitizeClasses(el);
|
|
320
|
+
const rect = normalizeRect(el.getBoundingClientRect ? el.getBoundingClientRect() : null);
|
|
321
|
+
const tag = String(el.tagName || el.nodeName || '').toLowerCase();
|
|
322
|
+
const id = el.id || null;
|
|
323
|
+
const text = typeof el.textContent === 'string'
|
|
324
|
+
? el.textContent.replace(/\\s+/g, ' ').trim()
|
|
325
|
+
: '';
|
|
326
|
+
const selector = tag
|
|
327
|
+
? \`\${tag}\${id ? '#' + id : ''}\${classes.length ? '.' + classes.slice(0, 3).join('.') : ''}\`
|
|
328
|
+
: null;
|
|
329
|
+
|
|
330
|
+
const node = {
|
|
331
|
+
tag,
|
|
332
|
+
id,
|
|
333
|
+
classes,
|
|
334
|
+
selector,
|
|
335
|
+
path,
|
|
336
|
+
};
|
|
337
|
+
const attrs = collectAttrs(el);
|
|
338
|
+
if (attrs) node.attrs = attrs;
|
|
339
|
+
if (attrs && attrs.href) node.href = attrs.href;
|
|
340
|
+
if (rect) node.rect = rect;
|
|
341
|
+
if (text) node.textSnippet = text.slice(0, 120);
|
|
342
|
+
if (rect) {
|
|
343
|
+
const rendered = isRendered(el);
|
|
344
|
+
const withinViewport = inViewport(rect);
|
|
345
|
+
const visible = rendered && withinViewport && hitTestVisible(el, rect);
|
|
346
|
+
node.visible = visible;
|
|
347
|
+
} else {
|
|
348
|
+
node.visible = false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const children = Array.from(el.children || []);
|
|
352
|
+
if (children.length > 0 && depth < MAX_DEPTH) {
|
|
353
|
+
node.children = [];
|
|
354
|
+
const limit = Math.min(children.length, MAX_CHILDREN);
|
|
355
|
+
for (let i = 0; i < limit; i += 1) {
|
|
356
|
+
const child = collect(children[i], depth + 1, \`\${path}/\${i}\`);
|
|
357
|
+
if (child) node.children.push(child);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return node;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const root = collect(document.body || document.documentElement, 0, 'root');
|
|
365
|
+
return {
|
|
366
|
+
dom_tree: root,
|
|
367
|
+
viewport: {
|
|
368
|
+
width: viewportWidth,
|
|
369
|
+
height: viewportHeight,
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
})()`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function getDomSnapshotByProfile(profileId, options = {}) {
|
|
376
|
+
const maxDepth = Math.max(1, Math.min(20, Number(options.maxDepth) || 10));
|
|
377
|
+
const maxChildren = Math.max(1, Math.min(500, Number(options.maxChildren) || 120));
|
|
378
|
+
const response = await callAPI('evaluate', {
|
|
379
|
+
profileId,
|
|
380
|
+
script: buildDomSnapshotScript(maxDepth, maxChildren),
|
|
381
|
+
});
|
|
382
|
+
const payload = response?.result || response || {};
|
|
383
|
+
const tree = payload.dom_tree || null;
|
|
384
|
+
if (tree && payload.viewport && typeof payload.viewport === 'object') {
|
|
385
|
+
tree.__viewport = {
|
|
386
|
+
width: Number(payload.viewport.width) || 0,
|
|
387
|
+
height: Number(payload.viewport.height) || 0,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
return tree;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function getViewportByProfile(profileId) {
|
|
394
|
+
const response = await callAPI('evaluate', {
|
|
395
|
+
profileId,
|
|
396
|
+
script: `(() => ({ width: Number(window.innerWidth || 0), height: Number(window.innerHeight || 0) }))()`,
|
|
397
|
+
});
|
|
398
|
+
const viewport = response?.result || response?.viewport || {};
|
|
399
|
+
const width = Number(viewport?.width) || 1280;
|
|
400
|
+
const height = Number(viewport?.height) || 720;
|
|
401
|
+
return { width, height };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function checkBrowserService() {
|
|
405
|
+
try {
|
|
406
|
+
const r = await fetch(`${BROWSER_SERVICE_URL}/health`, { signal: AbortSignal.timeout(2000) });
|
|
407
|
+
return r.ok;
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function detectCamoufoxPath() {
|
|
414
|
+
try {
|
|
415
|
+
const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
|
|
416
|
+
const out = execSync(cmd, {
|
|
417
|
+
encoding: 'utf8',
|
|
418
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
419
|
+
});
|
|
420
|
+
const lines = out.trim().split(/\r?\n/);
|
|
421
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
422
|
+
const line = lines[i].trim();
|
|
423
|
+
if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
|
|
424
|
+
}
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function ensureCamoufox() {
|
|
432
|
+
if (detectCamoufoxPath()) return;
|
|
433
|
+
console.log('Camoufox is not found. Installing...');
|
|
434
|
+
execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
|
|
435
|
+
if (!detectCamoufoxPath()) {
|
|
436
|
+
throw new Error('Camoufox install finished but executable was not detected');
|
|
437
|
+
}
|
|
438
|
+
console.log('Camoufox installed.');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const CONTROLLER_SERVER_REL = path.join('src', 'services', 'browser-service', 'index.js');
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
function hasControllerServer(root) {
|
|
445
|
+
if (!root) return false;
|
|
446
|
+
return fs.existsSync(path.join(root, CONTROLLER_SERVER_REL));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
function scanCommonInstallRoots() {
|
|
454
|
+
const home = os.homedir();
|
|
455
|
+
const appData = String(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'));
|
|
456
|
+
const npmPrefix = String(process.env.npm_config_prefix || '').trim();
|
|
457
|
+
const nodeModuleRoots = [
|
|
458
|
+
path.join(appData, 'npm', 'node_modules'),
|
|
459
|
+
path.join(home, 'AppData', 'Roaming', 'npm', 'node_modules'),
|
|
460
|
+
npmPrefix ? path.join(npmPrefix, 'node_modules') : '',
|
|
461
|
+
npmPrefix ? path.join(npmPrefix, 'lib', 'node_modules') : '',
|
|
462
|
+
'/usr/local/lib/node_modules',
|
|
463
|
+
'/usr/lib/node_modules',
|
|
464
|
+
path.join(home, '.npm-global', 'lib', 'node_modules'),
|
|
465
|
+
].filter(Boolean);
|
|
466
|
+
|
|
467
|
+
for (const root of nodeModuleRoots) {
|
|
468
|
+
const candidate = path.join(root, '@web-auto', 'camo');
|
|
469
|
+
if (hasControllerServer(candidate)) return candidate;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
export function findInstallRootCandidate() {
|
|
480
|
+
const cfg = loadConfig();
|
|
481
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
482
|
+
const siblingInScopedNodeModules = path.resolve(currentDir, '..', '..', '..', 'camo');
|
|
483
|
+
const candidates = [
|
|
484
|
+
process.env.CAMO_INSTALL_DIR,
|
|
485
|
+
process.env.CAMO_PACKAGE_ROOT,
|
|
486
|
+
process.env.CAMO_REPO_ROOT,
|
|
487
|
+
cfg.repoRoot,
|
|
488
|
+
siblingInScopedNodeModules,
|
|
489
|
+
process.cwd(),
|
|
490
|
+
].filter(Boolean);
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
const pkgPath = require.resolve('@web-auto/camo/package.json');
|
|
494
|
+
candidates.push(path.dirname(pkgPath));
|
|
495
|
+
} catch {
|
|
496
|
+
// ignore resolution failures in npx-only environments
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const seen = new Set();
|
|
500
|
+
for (const raw of candidates) {
|
|
501
|
+
const resolved = path.resolve(String(raw));
|
|
502
|
+
if (seen.has(resolved)) continue;
|
|
503
|
+
seen.add(resolved);
|
|
504
|
+
if (hasControllerServer(resolved)) return resolved;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return scanCommonInstallRoots();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export async function ensureBrowserService() {
|
|
511
|
+
if (await checkBrowserService()) return;
|
|
512
|
+
|
|
513
|
+
const installRoot = findInstallRootCandidate();
|
|
514
|
+
if (!installRoot) {
|
|
515
|
+
throw new Error(
|
|
516
|
+
`Cannot locate browser-service launcher (${CONTROLLER_SERVER_REL}). ` +
|
|
517
|
+
'Ensure @web-auto/camo is installed or set CAMO_INSTALL_DIR.',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const scriptPath = path.join(installRoot, CONTROLLER_SERVER_REL);
|
|
521
|
+
const env = {
|
|
522
|
+
...process.env,
|
|
523
|
+
CAMO_REPO_ROOT: String(process.env.CAMO_REPO_ROOT || '').trim() || installRoot,
|
|
524
|
+
};
|
|
525
|
+
const child = spawn(process.execPath, [scriptPath], {
|
|
526
|
+
cwd: installRoot,
|
|
527
|
+
detached: true,
|
|
528
|
+
stdio: 'ignore',
|
|
529
|
+
windowsHide: true,
|
|
530
|
+
env,
|
|
531
|
+
});
|
|
532
|
+
child.unref();
|
|
533
|
+
console.log(`Starting browser-service daemon (pid=${child.pid || 'unknown'})...`);
|
|
534
|
+
|
|
535
|
+
for (let i = 0; i < 20; i += 1) {
|
|
536
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
537
|
+
if (await checkBrowserService()) {
|
|
538
|
+
console.log('Browser-service is ready.');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
throw new Error('Browser-service failed to become healthy within timeout');
|
|
544
|
+
}
|