figranium 0.9.1 → 0.9.6

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/headful.js CHANGED
@@ -1,219 +1,583 @@
1
- const { chromium } = require('playwright');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const { getProxySelection } = require('./proxy-rotation');
5
- const { selectUserAgent } = require('./user-agent-settings');
6
- const { validateUrl } = require('./url-utils');
7
- const { parseBooleanFlag } = require('./common-utils');
8
- const { installMouseHelper } = require('./src/agent/dom-utils');
9
- const { Mutex } = require('./src/server/utils');
10
-
11
- const headfulMutex = new Mutex();
12
-
13
- const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
14
- const STORAGE_STATE_FILE = (() => {
15
- try {
16
- if (fs.existsSync(STORAGE_STATE_PATH)) {
17
- const stat = fs.statSync(STORAGE_STATE_PATH);
18
- if (stat.isDirectory()) {
19
- return path.join(STORAGE_STATE_PATH, 'storage_state.json');
20
- }
21
- }
22
- } catch { }
23
- return STORAGE_STATE_PATH;
24
- })();
25
-
26
- let activeSession = null;
27
-
28
- const teardownActiveSession = async () => {
29
- if (!activeSession) return;
30
- try {
31
- if (activeSession.interval) clearInterval(activeSession.interval);
32
- } catch { }
33
- try {
34
- if (activeSession.context && !activeSession.stateless) {
35
- await activeSession.context.storageState({ path: STORAGE_STATE_FILE });
36
- }
37
- } catch { }
38
- try {
39
- if (activeSession.browser) {
40
- await activeSession.browser.close();
41
- }
42
- } catch { }
43
- activeSession = null;
44
- };
45
-
46
- async function runHeadful(data, options = {}) {
47
- const { res } = options;
48
- if (activeSession) {
49
- await teardownActiveSession();
50
- }
51
-
52
- const url = data.url || 'https://www.google.com';
53
-
54
- await validateUrl(url);
55
-
56
- const rotateProxiesRaw = data.rotateProxies;
57
- const rotateProxies = String(rotateProxiesRaw).toLowerCase() === 'true' || rotateProxiesRaw === true;
58
- const statelessExecutionRaw = data.statelessExecution;
59
- const statelessExecution = parseBooleanFlag(statelessExecutionRaw);
60
-
61
- activeSession = { status: 'starting', startedAt: Date.now(), stateless: statelessExecution };
62
-
63
- const selectedUA = await selectUserAgent(false);
64
-
65
- let browser;
66
- try {
67
- const launchOptions = {
68
- headless: false,
69
- args: [
70
- '--no-sandbox',
71
- '--disable-setuid-sandbox',
72
- '--disable-dev-shm-usage',
73
- '--window-size=1920,1080',
74
- '--window-position=0,0'
75
- ]
76
- };
77
- const selection = getProxySelection(rotateProxies);
78
- if (selection.proxy) {
79
- launchOptions.proxy = selection.proxy;
80
- }
81
- browser = await chromium.launch(launchOptions);
82
-
83
- const contextOptions = {
84
- viewport: null,
85
- userAgent: selectedUA,
86
- locale: 'en-US',
87
- timezoneId: 'America/New_York'
88
- };
89
-
90
- if (!statelessExecution && fs.existsSync(STORAGE_STATE_FILE)) {
91
- try {
92
- const rawState = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
93
- const targetHost = new URL(url).hostname;
94
- const targetDomain = targetHost.replace(/^www\./, '');
95
-
96
- if (rawState.cookies) {
97
- rawState.cookies = rawState.cookies.filter(c => {
98
- const cookieDomain = (c.domain || '').replace(/^\./, '');
99
- return cookieDomain === targetDomain ||
100
- cookieDomain.endsWith('.' + targetDomain) ||
101
- targetDomain.endsWith('.' + cookieDomain);
102
- });
103
- }
104
-
105
- if (rawState.origins) {
106
- rawState.origins = rawState.origins.filter(o => {
107
- try {
108
- const originHost = new URL(o.origin).hostname.replace(/^www\./, '');
109
- return originHost === targetDomain || originHost.endsWith('.' + targetDomain);
110
- } catch { return false; }
111
- });
112
- }
113
-
114
- contextOptions.storageState = rawState;
115
- } catch (e) {
116
- contextOptions.storageState = STORAGE_STATE_FILE;
117
- }
118
- }
119
-
120
- const context = await browser.newContext(contextOptions);
121
- await context.addInitScript(() => {
122
- Object.defineProperty(window, 'open', { writable: true, configurable: true, value: () => null });
123
- const handleLinkClick = (event) => {
124
- const path = event.composedPath ? event.composedPath() : [];
125
- const anchor = path.find(el => el.tagName === 'A');
126
- if (anchor && anchor.target === '_blank') {
127
- event.preventDefault();
128
- return;
129
- }
130
- if (event.type === 'auxclick' && event.button === 1 && anchor) {
131
- event.preventDefault();
132
- }
133
- };
134
- document.addEventListener('click', handleLinkClick, true);
135
- document.addEventListener('auxclick', handleLinkClick, true);
136
- });
137
- await context.addInitScript(installMouseHelper);
138
-
139
- const page = await context.newPage();
140
-
141
- const closeIfExtra = async (extraPage) => {
142
- if (!extraPage || extraPage === page) return;
143
- try { await extraPage.close(); } catch { }
144
- };
145
-
146
- context.on('page', closeIfExtra);
147
- page.on('popup', async (popup) => {
148
- try { popup.close().catch(() => { }); } catch { }
149
- await closeIfExtra(popup);
150
- });
151
-
152
- await page.goto(url);
153
-
154
- const saveState = async () => {
155
- if (statelessExecution) return;
156
- try {
157
- await context.storageState({ path: STORAGE_STATE_FILE });
158
- } catch (e) { }
159
- };
160
-
161
- const interval = setInterval(saveState, 10000);
162
- activeSession = { browser, context, interval, status: 'running', startedAt: activeSession.startedAt, stateless: statelessExecution };
163
-
164
- page.on('close', async () => {
165
- clearInterval(interval);
166
- await saveState();
167
- });
168
-
169
- const responseData = {
170
- message: 'Headful session started.',
171
- userAgentUsed: selectedUA,
172
- path: statelessExecution ? null : STORAGE_STATE_FILE
173
- };
174
-
175
- if (res) {
176
- res.json(responseData);
177
- }
178
-
179
- await new Promise((resolve) => browser.on('disconnected', resolve));
180
- clearInterval(interval);
181
- await saveState();
182
- activeSession = null;
183
- return responseData;
184
- } catch (error) {
185
- if (browser) await browser.close();
186
- activeSession = null;
187
- throw error;
188
- }
189
- }
190
-
191
- async function handleHeadful(req, res) {
192
- await headfulMutex.lock();
193
- try {
194
- const data = { ...req.body, ...req.query };
195
- await runHeadful(data, { res });
196
- } catch (error) {
197
- const message = String(error && error.message ? error.message : error);
198
- const displayUnavailable = /missing x server|\$display|platform failed to initialize/i.test(message);
199
- if (!res.headersSent && displayUnavailable) {
200
- return res.status(409).json({ error: 'HEADFUL_DISPLAY_UNAVAILABLE', details: message });
201
- }
202
- if (!res.headersSent) {
203
- res.status(500).json({ error: 'Failed to start headful session', details: message });
204
- }
205
- } finally {
206
- headfulMutex.unlock();
207
- }
208
- }
209
-
210
- async function stopHeadful(req, res) {
211
- if (!activeSession) {
212
- return res.status(200).json({ message: 'No active headful session.' });
213
- }
214
-
215
- await teardownActiveSession();
216
- if (res) res.json({ message: 'Headful session stopped.' });
217
- }
218
-
219
- module.exports = { runHeadful, handleHeadful, stopHeadful };
1
+ const { chromium } = require('playwright');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getProxySelection } = require('./proxy-rotation');
5
+ const { selectUserAgent } = require('./user-agent-settings');
6
+ const { validateUrl } = require('./url-utils');
7
+ const { parseBooleanFlag, cookieMatches } = require('./common-utils');
8
+ const { Mutex } = require('./src/server/utils');
9
+
10
+ const headfulMutex = new Mutex();
11
+
12
+ const EventEmitter = require('events');
13
+ const headfulEventEmitter = new EventEmitter();
14
+
15
+ const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
16
+ const STORAGE_STATE_FILE = (() => {
17
+ try {
18
+ if (fs.existsSync(STORAGE_STATE_PATH)) {
19
+ const stat = fs.statSync(STORAGE_STATE_PATH);
20
+ if (stat.isDirectory()) {
21
+ return path.join(STORAGE_STATE_PATH, 'storage_state.json');
22
+ }
23
+ }
24
+ } catch { }
25
+ return STORAGE_STATE_PATH;
26
+ })();
27
+
28
+ let activeSession = null;
29
+
30
+ const teardownActiveSession = async () => {
31
+ if (!activeSession) return;
32
+ try {
33
+ if (activeSession.interval) clearInterval(activeSession.interval);
34
+ } catch { }
35
+ try {
36
+ if (activeSession.context && !activeSession.stateless) {
37
+ await activeSession.context.storageState({ path: STORAGE_STATE_FILE });
38
+ }
39
+ } catch { }
40
+ try {
41
+ if (activeSession.browser) {
42
+ await activeSession.browser.close();
43
+ }
44
+ } catch { }
45
+ activeSession = null;
46
+ };
47
+
48
+ async function runHeadful(data, options = {}) {
49
+ const { res } = options;
50
+ if (activeSession) {
51
+ await teardownActiveSession();
52
+ }
53
+
54
+ const url = data.url || 'https://www.google.com';
55
+
56
+ await validateUrl(url);
57
+
58
+ const rotateProxiesRaw = data.rotateProxies;
59
+ const rotateProxies = String(rotateProxiesRaw).toLowerCase() === 'true' || rotateProxiesRaw === true;
60
+ const statelessExecutionRaw = data.statelessExecution;
61
+ const statelessExecution = parseBooleanFlag(statelessExecutionRaw);
62
+
63
+ const inspectModeEnabled = true;
64
+
65
+ activeSession = { status: 'starting', startedAt: Date.now(), stateless: statelessExecution, inspectModeEnabled };
66
+
67
+ const selectedUA = await selectUserAgent(false);
68
+
69
+ let browser;
70
+ let context;
71
+ let page;
72
+ let navigated = false;
73
+
74
+ try {
75
+ if (data.targetActionId && data.taskSnapshot) {
76
+ const { runAgent } = require('./src/agent');
77
+ try {
78
+ const reqScope = { ...data.taskSnapshot, variables: data.variables || data.taskVariables || {} };
79
+ if (data.url) reqScope.url = data.url;
80
+
81
+ const result = await runAgent(reqScope, {
82
+ headless: false,
83
+ handoffContext: true,
84
+ stopAtActionId: data.targetActionId
85
+ });
86
+ if (result && result._handoff) {
87
+ browser = result._handoff.browser;
88
+ context = result._handoff.context;
89
+ page = result._handoff.page;
90
+ navigated = true;
91
+ }
92
+ } catch (e) {
93
+ console.error("Agent handoff failed:", e);
94
+ }
95
+ }
96
+
97
+ if (!browser) {
98
+ const launchOptions = {
99
+ headless: false,
100
+ args: [
101
+ '--no-sandbox',
102
+ '--disable-setuid-sandbox',
103
+ '--disable-dev-shm-usage',
104
+ '--disable-gpu',
105
+ '--window-size=1920,1080',
106
+ '--window-position=0,0',
107
+ '--start-maximized'
108
+ ]
109
+ };
110
+ const selection = getProxySelection(rotateProxies);
111
+ if (selection.proxy) {
112
+ launchOptions.proxy = selection.proxy;
113
+ }
114
+ browser = await chromium.launch(launchOptions);
115
+
116
+ const contextOptions = {
117
+ viewport: null,
118
+ userAgent: selectedUA,
119
+ locale: 'en-US',
120
+ timezoneId: 'America/New_York'
121
+ };
122
+
123
+ if (!statelessExecution && fs.existsSync(STORAGE_STATE_FILE)) {
124
+ contextOptions.storageState = STORAGE_STATE_FILE;
125
+ }
126
+
127
+ contextOptions.permissions = ['clipboard-read', 'clipboard-write'];
128
+ context = await browser.newContext(contextOptions);
129
+ }
130
+
131
+ let preloadedCookies = [];
132
+ if (fs.existsSync(STORAGE_STATE_FILE)) {
133
+ try {
134
+ const state = JSON.parse(fs.readFileSync(STORAGE_STATE_FILE, 'utf8'));
135
+ preloadedCookies = state.cookies || [];
136
+ } catch (e) { }
137
+ }
138
+
139
+ await context.route('**/*', async (route) => {
140
+ const request = route.request();
141
+ const requestUrl = request.url();
142
+ const resourceType = request.resourceType();
143
+
144
+ const isDataRequest = ['document', 'script', 'xhr', 'fetch'].includes(resourceType);
145
+ if (isDataRequest && preloadedCookies.length > 0) {
146
+ // ⚡ Bolt: Parse URL once to avoid redundant parsing inside cookieMatches filter loop
147
+ const urlObj = new URL(requestUrl);
148
+ const filteredCookies = preloadedCookies.filter(cookie => cookieMatches(cookie, urlObj));
149
+ if (filteredCookies.length > 0) {
150
+ const fileCookieMap = new Map();
151
+ filteredCookies.forEach(c => fileCookieMap.set(c.name, c.value));
152
+
153
+ const existingCookieHeader = request.headers()['cookie'] || '';
154
+ const existingCookies = existingCookieHeader.split(';').filter(Boolean).map(s => s.trim());
155
+
156
+ existingCookies.forEach(s => {
157
+ const [name, ...valParts] = s.split('=');
158
+ const val = valParts.join('=');
159
+ if (!fileCookieMap.has(name)) {
160
+ fileCookieMap.set(name, val);
161
+ }
162
+ });
163
+
164
+ const cookieHeader = Array.from(fileCookieMap.entries()).map(([n, v]) => `${n}=${v}`).join('; ');
165
+ const headers = { ...request.headers(), 'cookie': cookieHeader };
166
+ return route.continue({ headers });
167
+ }
168
+ }
169
+ route.continue();
170
+ });
171
+
172
+ const inspectInitFn = () => {
173
+ Object.defineProperty(window, 'open', { writable: true, configurable: true, value: () => null });
174
+ const handleLinkClick = (event) => {
175
+ const path = event.composedPath ? event.composedPath() : [];
176
+ const anchor = path.find(el => el.tagName === 'A');
177
+ if (anchor && anchor.target === '_blank') {
178
+ event.preventDefault();
179
+ return;
180
+ }
181
+ if (event.type === 'auxclick' && event.button === 1 && anchor) {
182
+ event.preventDefault();
183
+ }
184
+ };
185
+ document.addEventListener('click', handleLinkClick, true);
186
+ document.addEventListener('auxclick', handleLinkClick, true);
187
+
188
+ window.__figraniumInspectInit = () => {
189
+ if (window._figraniumInspectHandler) return;
190
+
191
+ const overlay = document.createElement('div');
192
+ overlay.id = 'figranium-inspect-overlay';
193
+ overlay.style.position = 'fixed';
194
+ overlay.style.pointerEvents = 'none';
195
+ overlay.style.zIndex = '2147483646';
196
+ overlay.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
197
+ overlay.style.border = '1px solid rgb(96, 165, 250)';
198
+ overlay.style.boxSizing = 'border-box';
199
+ overlay.style.transition = 'all 0.1s ease';
200
+ overlay.style.display = 'none';
201
+ document.body.appendChild(overlay);
202
+
203
+ const tooltip = document.createElement('div');
204
+ tooltip.id = 'figranium-inspect-tooltip';
205
+ tooltip.style.position = 'fixed';
206
+ tooltip.style.pointerEvents = 'none';
207
+ tooltip.style.zIndex = '2147483647';
208
+ tooltip.style.backgroundColor = '#1e293b';
209
+ tooltip.style.color = '#f8fafc';
210
+ tooltip.style.padding = '4px 8px';
211
+ tooltip.style.borderRadius = '4px';
212
+ tooltip.style.fontSize = '12px';
213
+ tooltip.style.fontFamily = 'monospace';
214
+ tooltip.style.boxShadow = '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)';
215
+ tooltip.style.display = 'none';
216
+ tooltip.style.whiteSpace = 'nowrap';
217
+ tooltip.style.lineHeight = '1.4';
218
+ document.body.appendChild(tooltip);
219
+
220
+ window._figraniumGetSelectors = (el) => {
221
+ const isRandomId = (id) => {
222
+ if (!id) return true;
223
+ // Long numbers, UUIDs, explicit long strings
224
+ if (/\d{4,}/.test(id) || /^[0-9a-f]{8}-/i.test(id) || id.length > 30 || /[0-9]{3,}/.test(id)) return true;
225
+ // Google-style obfuscated classes (e.g. gLFyf, APjFqb)
226
+ if (/^[a-zA-Z]{4,8}$/.test(id) && /[A-Z]/.test(id) && /[a-z]/.test(id)) return true;
227
+ // Styled-components or CSS modules with hashes like css-1n7jcv, style_module__1xyz
228
+ if (/^css-[a-zA-Z0-9]+/.test(id) || /^sc-[a-zA-Z0-9]+/.test(id) || /_[a-zA-Z0-9]{5,}$/.test(id) || /-[a-zA-Z0-9]{5,}$/.test(id)) return true;
229
+ // Tailwind arbitrary values or very complex utility classes
230
+ if (id.includes('[') || id.includes(']')) return true;
231
+ return false;
232
+ };
233
+ const tag = el.tagName ? el.tagName.toLowerCase() : '';
234
+ if (!tag || tag === 'html' || tag === 'body') return [tag];
235
+
236
+ const selectors = new Set();
237
+
238
+ const isUnique = (sel) => {
239
+ try {
240
+ const nodes = document.querySelectorAll(sel);
241
+ return nodes.length === 1 && nodes[0] === el;
242
+ } catch (e) { return false; }
243
+ };
244
+
245
+ const addIfUnique = (sel) => {
246
+ if (isUnique(sel)) selectors.add(sel);
247
+ };
248
+
249
+ // 1. Name & placeholder (highest priority — most human-readable)
250
+ const topAttrs = ['name', 'placeholder'];
251
+ for (const attr of topAttrs) {
252
+ const val = el.getAttribute(attr);
253
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
254
+ addIfUnique(`[${attr}="${val}"]`);
255
+ addIfUnique(`${tag}[${attr}="${val}"]`);
256
+ }
257
+ }
258
+
259
+ // 2. Text content (:has-text — very readable)
260
+ if ((tag === 'button' || tag === 'a' || tag === 'span' || tag === 'div' || tag === 'label' || tag === 'li' || tag === 'p' || tag === 'h1' || tag === 'h2' || tag === 'h3') && el.textContent) {
261
+ const text = el.textContent.trim().substring(0, 40);
262
+ if (text && !text.includes('\n') && !text.includes('"') && text.length > 1) {
263
+ const allTags = Array.from(document.querySelectorAll(tag));
264
+ const matches = allTags.filter(t => t.textContent.trim() === text);
265
+ if (matches.length === 1 && matches[0] === el) {
266
+ selectors.add(`${tag}:has-text("${text}")`);
267
+ }
268
+ }
269
+ }
270
+
271
+ // 3. Other semantic attributes
272
+ const semanticAttrs = ['aria-label', 'title', 'alt'];
273
+ for (const attr of semanticAttrs) {
274
+ const val = el.getAttribute(attr);
275
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n')) {
276
+ addIfUnique(`[${attr}="${val}"]`);
277
+ addIfUnique(`${tag}[${attr}="${val}"]`);
278
+ }
279
+ }
280
+
281
+ // 4. Data attributes
282
+ const dataAttrs = ['data-testid', 'data-test-id', 'data-qa', 'data-cy'];
283
+ for (const attr of dataAttrs) {
284
+ const val = el.getAttribute(attr);
285
+ if (val) {
286
+ addIfUnique(`[${attr}="${val}"]`);
287
+ addIfUnique(`${tag}[${attr}="${val}"]`);
288
+ }
289
+ }
290
+
291
+ // 5. IDs
292
+ const id = el.id;
293
+ if (id && !isRandomId(id)) {
294
+ addIfUnique(`#${id}`);
295
+ addIfUnique(`${tag}#${id}`);
296
+ }
297
+
298
+ // 6. Other basic attributes
299
+ const otherAttrs = ['type', 'value', 'href', 'src'];
300
+ for (const attr of otherAttrs) {
301
+ const val = el.getAttribute(attr);
302
+ if (val && val.length < 50 && !val.includes('"') && !val.includes('\n') && !val.startsWith('data:')) {
303
+ addIfUnique(`${tag}[${attr}="${val}"]`);
304
+ }
305
+ }
306
+
307
+ // 7. Classes
308
+ const classes = el.className && typeof el.className === 'string' ?
309
+ el.className.trim().split(/\s+/).filter(c => c && !isRandomId(c)) : [];
310
+ const classStr = classes.length > 0 ? '.' + classes.join('.') : '';
311
+
312
+ if (classStr) {
313
+ addIfUnique(`${tag}${classStr}`);
314
+ if (classes.length === 1) addIfUnique(`${classStr}`);
315
+ if (classes.length > 1) {
316
+ for (let c of classes) addIfUnique(`${tag}.${c}`);
317
+ }
318
+ }
319
+
320
+ addIfUnique(tag);
321
+
322
+ // 7. Structural
323
+ if (el.parentElement) {
324
+ const siblings = Array.from(el.parentElement.children).filter(c => c.tagName === el.tagName);
325
+ const index = siblings.indexOf(el) + 1;
326
+ addIfUnique(`${tag}:nth-of-type(${index})`);
327
+ if (classStr) addIfUnique(`${tag}${classStr}:nth-of-type(${index})`);
328
+ }
329
+
330
+ // 8. Combinations with parents (Basic Path generation fallback)
331
+ if (selectors.size < 3) {
332
+ let path = '';
333
+ let current = el;
334
+ while (current && current !== document.body && current !== document.documentElement) {
335
+ let step = current.tagName.toLowerCase();
336
+
337
+ // add id if good
338
+ if (current.id && !isRandomId(current.id)) {
339
+ step += `#${current.id}`;
340
+ } else {
341
+ // Add nth-of-type if no id and has siblings of same tag
342
+ if (current.parentElement) {
343
+ const sibs = Array.from(current.parentElement.children).filter(c => c.tagName === current.tagName);
344
+ if (sibs.length > 1) step += `:nth-of-type(${sibs.indexOf(current) + 1})`;
345
+ }
346
+ }
347
+
348
+ path = path ? `${step} > ${path}` : step;
349
+ if (isUnique(path)) {
350
+ selectors.add(path);
351
+ break; // Stop as soon as we found a unique path
352
+ }
353
+
354
+ // Try ID anchor
355
+ if (current.id && !isRandomId(current.id) && isUnique(`#${current.id}`)) {
356
+ break; // We anchored on a unique ID
357
+ }
358
+
359
+ current = current.parentElement;
360
+ }
361
+ }
362
+
363
+ return Array.from(selectors).slice(0, 5);
364
+ };
365
+
366
+ window._figraniumInspectHandler = (e) => {
367
+ const element = e.composedPath ? e.composedPath()[0] : e.target;
368
+ if (!element || element === document || element === document.body) {
369
+ overlay.style.display = 'none';
370
+ tooltip.style.display = 'none';
371
+ return;
372
+ }
373
+
374
+ const rect = element.getBoundingClientRect();
375
+ overlay.style.display = 'block';
376
+ overlay.style.top = rect.top + 'px';
377
+ overlay.style.left = rect.left + 'px';
378
+ overlay.style.width = rect.width + 'px';
379
+ overlay.style.height = rect.height + 'px';
380
+
381
+ const selectors = window._figraniumGetSelectors(element);
382
+ tooltip.style.display = 'block';
383
+ tooltip.innerHTML = selectors.map((s, i) => i === 0 ? `<strong>${s}</strong>` : `<span style="opacity:0.7">${s}</span>`).join('<br/>');
384
+
385
+ let tipTop = e.clientY + 15;
386
+ let tipLeft = e.clientX + 15;
387
+
388
+ const tooltipRect = tooltip.getBoundingClientRect();
389
+ if (tipLeft + tooltipRect.width > window.innerWidth) {
390
+ tipLeft = e.clientX - tooltipRect.width - 15;
391
+ }
392
+ if (tipTop + tooltipRect.height > window.innerHeight) {
393
+ tipTop = e.clientY - tooltipRect.height - 15;
394
+ }
395
+
396
+ tooltip.style.top = tipTop + 'px';
397
+ tooltip.style.left = tipLeft + 'px';
398
+ };
399
+
400
+ window._figraniumInspectClickHandler = async (e) => {
401
+ if (!window._figraniumInspectHandler) return;
402
+ e.preventDefault();
403
+ e.stopPropagation();
404
+ e.stopImmediatePropagation();
405
+
406
+ const element = e.composedPath ? e.composedPath()[0] : e.target;
407
+ const selectors = window._figraniumGetSelectors(element);
408
+ const bestSelector = selectors[0] || '';
409
+
410
+ // Push to backend via Playwright binding
411
+ if (window.__figraniumOnElementSelected && selectors.length > 0) {
412
+ try {
413
+ await window.__figraniumOnElementSelected(JSON.stringify(selectors));
414
+ } catch (err) { }
415
+ }
416
+
417
+ try {
418
+ if (bestSelector && navigator.clipboard && navigator.clipboard.writeText) {
419
+ await navigator.clipboard.writeText(bestSelector);
420
+ }
421
+ } catch (err) { }
422
+ };
423
+
424
+ document.addEventListener('mousemove', window._figraniumInspectHandler, true);
425
+ document.addEventListener('click', window._figraniumInspectClickHandler, true);
426
+ };
427
+
428
+ window.__figraniumInspectDestroy = () => {
429
+ const overlay = document.getElementById('figranium-inspect-overlay');
430
+ if (overlay) overlay.remove();
431
+ const tooltip = document.getElementById('figranium-inspect-tooltip');
432
+ if (tooltip) tooltip.remove();
433
+ if (window._figraniumInspectHandler) {
434
+ document.removeEventListener('mousemove', window._figraniumInspectHandler, true);
435
+ delete window._figraniumInspectHandler;
436
+ }
437
+ if (window._figraniumInspectClickHandler) {
438
+ document.removeEventListener('click', window._figraniumInspectClickHandler, true);
439
+ delete window._figraniumInspectClickHandler;
440
+ }
441
+ };
442
+
443
+ window.addEventListener('DOMContentLoaded', async () => {
444
+ if (window.__figraniumIsInspectEnabled) {
445
+ const enabled = await window.__figraniumIsInspectEnabled();
446
+ if (enabled) {
447
+ window.__figraniumInspectInit();
448
+ }
449
+ }
450
+ });
451
+ };
452
+
453
+ await context.addInitScript(inspectInitFn);
454
+
455
+ await context.exposeBinding('__figraniumIsInspectEnabled', () => {
456
+ return activeSession ? !!activeSession.inspectModeEnabled : false;
457
+ });
458
+
459
+ await context.exposeBinding('__figraniumOnElementSelected', (source, selector) => {
460
+ headfulEventEmitter.emit('selectorSelected', selector);
461
+ });
462
+
463
+ if (!page) {
464
+ page = await context.newPage();
465
+ try {
466
+ const cdp = await context.newCDPSession(page);
467
+ const { windowId } = await cdp.send('Browser.getWindowForTarget');
468
+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });
469
+ } catch (e) { }
470
+ } else {
471
+ try { await page.evaluate(inspectInitFn); } catch (e) { }
472
+ try {
473
+ await page.evaluate(() => {
474
+ if (window.__figraniumInspectInit) window.__figraniumInspectInit();
475
+ });
476
+ } catch (e) { }
477
+ }
478
+
479
+ const closeIfExtra = async (extraPage) => {
480
+ if (!extraPage || extraPage === page) return;
481
+ try { await extraPage.close(); } catch { }
482
+ };
483
+
484
+ context.on('page', closeIfExtra);
485
+ page.on('popup', async (popup) => {
486
+ try { popup.close().catch(() => { }); } catch { }
487
+ await closeIfExtra(popup);
488
+ });
489
+
490
+ if (!navigated && url) {
491
+ await page.goto(url).catch(() => { });
492
+ }
493
+
494
+ const saveState = async () => {
495
+ if (statelessExecution) return;
496
+ try {
497
+ await context.storageState({ path: STORAGE_STATE_FILE });
498
+ } catch (e) { }
499
+ };
500
+
501
+ const interval = setInterval(saveState, 10000);
502
+ activeSession = { browser, context, page, interval, status: 'running', startedAt: activeSession.startedAt, stateless: statelessExecution, inspectModeEnabled: activeSession.inspectModeEnabled };
503
+
504
+ page.on('close', async () => {
505
+ clearInterval(interval);
506
+ await saveState();
507
+ });
508
+
509
+ const responseData = {
510
+ message: 'Headful session started.',
511
+ userAgentUsed: selectedUA,
512
+ path: statelessExecution ? null : STORAGE_STATE_FILE
513
+ };
514
+
515
+ if (res) {
516
+ res.json(responseData);
517
+ }
518
+
519
+ await new Promise((resolve) => browser.on('disconnected', resolve));
520
+ clearInterval(interval);
521
+ await saveState();
522
+ activeSession = null;
523
+ return responseData;
524
+ } catch (error) {
525
+ if (browser) await browser.close();
526
+ activeSession = null;
527
+ throw error;
528
+ }
529
+ }
530
+
531
+ async function handleHeadful(req, res) {
532
+ await headfulMutex.lock();
533
+ try {
534
+ const data = { ...req.body, ...req.query };
535
+ await runHeadful(data, { res });
536
+ } catch (error) {
537
+ const message = String(error && error.message ? error.message : error);
538
+ const displayUnavailable = /missing x server|\$display|platform failed to initialize/i.test(message);
539
+ if (!res.headersSent && displayUnavailable) {
540
+ return res.status(409).json({ error: 'HEADFUL_DISPLAY_UNAVAILABLE', details: message });
541
+ }
542
+ if (!res.headersSent) {
543
+ res.status(500).json({ error: 'Failed to start headful session', details: message });
544
+ }
545
+ } finally {
546
+ headfulMutex.unlock();
547
+ }
548
+ }
549
+
550
+ async function stopHeadful(req, res) {
551
+ if (!activeSession) {
552
+ return res.status(200).json({ message: 'No active headful session.' });
553
+ }
554
+
555
+ await teardownActiveSession();
556
+ if (res) res.json({ message: 'Headful session stopped.' });
557
+ }
558
+
559
+ async function toggleInspectMode(req, res) {
560
+ if (!activeSession || !activeSession.context) {
561
+ return res.status(400).json({ error: 'No active headful session.' });
562
+ }
563
+ const enabled = req.body.enabled === true || req.body.enabled === 'true';
564
+ activeSession.inspectModeEnabled = enabled;
565
+
566
+ try {
567
+ const pages = activeSession.context.pages();
568
+ for (const page of pages) {
569
+ await page.evaluate((enabled) => {
570
+ if (enabled) {
571
+ if (window.__figraniumInspectInit) window.__figraniumInspectInit();
572
+ } else {
573
+ if (window.__figraniumInspectDestroy) window.__figraniumInspectDestroy();
574
+ }
575
+ }, enabled);
576
+ }
577
+ res.json({ message: `Inspect mode ${enabled ? 'enabled' : 'disabled'}` });
578
+ } catch (error) {
579
+ res.status(500).json({ error: 'Failed to toggle inspect mode', details: String(error) });
580
+ }
581
+ }
582
+
583
+ module.exports = { runHeadful, handleHeadful, stopHeadful, toggleInspectMode, headfulEventEmitter };