@askjo/camofox-browser 1.3.1 → 1.4.0
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/README.md +4 -0
- package/lib/config.js +5 -4
- package/lib/cookies.js +3 -3
- package/lib/downloads.js +240 -0
- package/lib/launcher.js +3 -3
- package/lib/macros.js +1 -1
- package/lib/snapshot.js +1 -1
- package/lib/youtube.js +160 -51
- package/openclaw.plugin.json +1 -1
- package/package.json +9 -5
- package/plugin.ts +23 -0
- package/scripts/sync-version.js +25 -0
- package/server.js +857 -151
package/server.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
import { Camoufox, launchOptions } from 'camoufox-js';
|
|
2
|
+
import { firefox } from 'playwright-core';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { expandMacro } from './lib/macros.js';
|
|
7
|
+
import { loadConfig } from './lib/config.js';
|
|
8
|
+
import { windowSnapshot } from './lib/snapshot.js';
|
|
9
|
+
import {
|
|
10
|
+
MAX_DOWNLOAD_INLINE_BYTES,
|
|
11
|
+
clearTabDownloads,
|
|
12
|
+
clearSessionDownloads,
|
|
13
|
+
attachDownloadListener,
|
|
14
|
+
getDownloadsList,
|
|
15
|
+
extractPageImages,
|
|
16
|
+
} from './lib/downloads.js';
|
|
17
|
+
import { detectYtDlp, hasYtDlp, ytDlpTranscript, parseJson3, parseVtt, parseXml } from './lib/youtube.js';
|
|
10
18
|
|
|
11
19
|
const CONFIG = loadConfig();
|
|
12
20
|
|
|
@@ -72,6 +80,16 @@ function timingSafeCompare(a, b) {
|
|
|
72
80
|
return crypto.timingSafeEqual(bufA, bufB);
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
// Custom error for stale/unknown element refs — returned as 422 instead of 500
|
|
84
|
+
class StaleRefsError extends Error {
|
|
85
|
+
constructor(ref, maxRef, totalRefs) {
|
|
86
|
+
super(`Unknown ref: ${ref} (valid refs: e1-${maxRef}, ${totalRefs} total). Refs reset after navigation - call snapshot first.`);
|
|
87
|
+
this.name = 'StaleRefsError';
|
|
88
|
+
this.code = 'stale_refs';
|
|
89
|
+
this.ref = ref;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
function safeError(err) {
|
|
76
94
|
if (CONFIG.nodeEnv === 'production') {
|
|
77
95
|
log('error', 'internal error', { error: err.message, stack: err.stack });
|
|
@@ -80,6 +98,17 @@ function safeError(err) {
|
|
|
80
98
|
return err.message;
|
|
81
99
|
}
|
|
82
100
|
|
|
101
|
+
// Send error response with appropriate status code (422 for stale refs, 500 otherwise)
|
|
102
|
+
function sendError(res, err, extraFields = {}) {
|
|
103
|
+
const status = err instanceof StaleRefsError ? 422 : (err.statusCode || 500);
|
|
104
|
+
const body = { error: safeError(err), ...extraFields };
|
|
105
|
+
if (err instanceof StaleRefsError) {
|
|
106
|
+
body.code = 'stale_refs';
|
|
107
|
+
body.ref = err.ref;
|
|
108
|
+
}
|
|
109
|
+
res.status(status).json(body);
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
function validateUrl(url) {
|
|
84
113
|
try {
|
|
85
114
|
const parsed = new URL(url);
|
|
@@ -92,26 +121,38 @@ function validateUrl(url) {
|
|
|
92
121
|
}
|
|
93
122
|
}
|
|
94
123
|
|
|
124
|
+
function isLoopbackAddress(address) {
|
|
125
|
+
if (!address) return false;
|
|
126
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
127
|
+
}
|
|
128
|
+
|
|
95
129
|
// Import cookies into a user's browser context (Playwright cookies format)
|
|
96
130
|
// POST /sessions/:userId/cookies { cookies: Cookie[] }
|
|
97
131
|
//
|
|
98
132
|
// SECURITY:
|
|
99
133
|
// Cookie injection moves this from "anonymous browsing" to "authenticated browsing".
|
|
100
|
-
//
|
|
101
|
-
//
|
|
134
|
+
// By default, this endpoint is protected by CAMOFOX_API_KEY.
|
|
135
|
+
// For local development convenience, when CAMOFOX_API_KEY is NOT set, we allow
|
|
136
|
+
// unauthenticated cookie import ONLY from loopback (127.0.0.1 / ::1) and ONLY
|
|
137
|
+
// when NODE_ENV != production.
|
|
102
138
|
app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (req, res) => {
|
|
103
139
|
try {
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
if (CONFIG.apiKey) {
|
|
141
|
+
const apiKey = CONFIG.apiKey;
|
|
142
|
+
const auth = String(req.headers['authorization'] || '');
|
|
143
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
144
|
+
if (!match || !timingSafeCompare(match[1], apiKey)) {
|
|
145
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
const remoteAddress = req.socket?.remoteAddress || '';
|
|
149
|
+
const allowUnauthedLocal = CONFIG.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress);
|
|
150
|
+
if (!allowUnauthedLocal) {
|
|
151
|
+
return res.status(403).json({
|
|
152
|
+
error:
|
|
153
|
+
'Cookie import is disabled without CAMOFOX_API_KEY except for loopback requests in non-production environments.',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
115
156
|
}
|
|
116
157
|
|
|
117
158
|
const userId = req.params.userId;
|
|
@@ -169,12 +210,13 @@ app.post('/sessions/:userId/cookies', express.json({ limit: '512kb' }), async (r
|
|
|
169
210
|
|
|
170
211
|
let browser = null;
|
|
171
212
|
// userId -> { context, tabGroups: Map<sessionKey, Map<tabId, TabState>>, lastAccess }
|
|
172
|
-
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, toolCalls: number }
|
|
213
|
+
// TabState = { page, refs: Map<refId, {role, name, nth}>, visitedUrls: Set, downloads: Array, toolCalls: number }
|
|
173
214
|
// Note: sessionKey was previously called listItemId - both are accepted for backward compatibility
|
|
174
215
|
const sessions = new Map();
|
|
175
216
|
|
|
176
217
|
const SESSION_TIMEOUT_MS = CONFIG.sessionTimeoutMs;
|
|
177
218
|
const MAX_SNAPSHOT_NODES = 500;
|
|
219
|
+
const TAB_INACTIVITY_MS = CONFIG.tabInactivityMs;
|
|
178
220
|
const MAX_SESSIONS = CONFIG.maxSessions;
|
|
179
221
|
const MAX_TABS_PER_SESSION = CONFIG.maxTabsPerSession;
|
|
180
222
|
const MAX_TABS_GLOBAL = CONFIG.maxTabsGlobal;
|
|
@@ -184,39 +226,70 @@ const PAGE_CLOSE_TIMEOUT_MS = 5000;
|
|
|
184
226
|
const NAVIGATE_TIMEOUT_MS = CONFIG.navigateTimeoutMs;
|
|
185
227
|
const BUILDREFS_TIMEOUT_MS = CONFIG.buildrefsTimeoutMs;
|
|
186
228
|
const FAILURE_THRESHOLD = 3;
|
|
187
|
-
const
|
|
229
|
+
const MAX_CONSECUTIVE_TIMEOUTS = 3;
|
|
230
|
+
const TAB_LOCK_TIMEOUT_MS = 35000; // Must be > HANDLER_TIMEOUT_MS so active op times out first
|
|
231
|
+
|
|
232
|
+
// Proper mutex for tab serialization. The old Promise-chain lock on timeout proceeded
|
|
233
|
+
// WITHOUT the lock, allowing concurrent Playwright operations that corrupt CDP state.
|
|
234
|
+
class TabLock {
|
|
235
|
+
constructor() {
|
|
236
|
+
this.queue = [];
|
|
237
|
+
this.active = false;
|
|
238
|
+
}
|
|
188
239
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
240
|
+
acquire(timeoutMs) {
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const entry = { resolve, reject, timer: null };
|
|
243
|
+
entry.timer = setTimeout(() => {
|
|
244
|
+
const idx = this.queue.indexOf(entry);
|
|
245
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
246
|
+
reject(new Error('Tab lock queue timeout'));
|
|
247
|
+
}, timeoutMs);
|
|
248
|
+
this.queue.push(entry);
|
|
249
|
+
this._tryNext();
|
|
250
|
+
});
|
|
251
|
+
}
|
|
192
252
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
253
|
+
release() {
|
|
254
|
+
this.active = false;
|
|
255
|
+
this._tryNext();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_tryNext() {
|
|
259
|
+
if (this.active || this.queue.length === 0) return;
|
|
260
|
+
this.active = true;
|
|
261
|
+
const entry = this.queue.shift();
|
|
262
|
+
clearTimeout(entry.timer);
|
|
263
|
+
entry.resolve();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
drain() {
|
|
267
|
+
this.active = true;
|
|
268
|
+
for (const entry of this.queue) {
|
|
269
|
+
clearTimeout(entry.timer);
|
|
270
|
+
entry.reject(new Error('Tab destroyed'));
|
|
206
271
|
}
|
|
272
|
+
this.queue = [];
|
|
207
273
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Per-tab locks to serialize operations on the same tab
|
|
277
|
+
const tabLocks = new Map(); // tabId -> TabLock
|
|
278
|
+
|
|
279
|
+
function getTabLock(tabId) {
|
|
280
|
+
if (!tabLocks.has(tabId)) tabLocks.set(tabId, new TabLock());
|
|
281
|
+
return tabLocks.get(tabId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Timeout is INSIDE the lock so each operation gets its full budget
|
|
285
|
+
// regardless of how long it waited in the queue.
|
|
286
|
+
async function withTabLock(tabId, operation, timeoutMs = HANDLER_TIMEOUT_MS) {
|
|
287
|
+
const lock = getTabLock(tabId);
|
|
288
|
+
await lock.acquire(TAB_LOCK_TIMEOUT_MS);
|
|
213
289
|
try {
|
|
214
|
-
return await
|
|
290
|
+
return await withTimeout(operation(), timeoutMs, 'action');
|
|
215
291
|
} finally {
|
|
216
|
-
|
|
217
|
-
if (tabLocks.get(tabId) === promise) {
|
|
218
|
-
tabLocks.delete(tabId);
|
|
219
|
-
}
|
|
292
|
+
lock.release();
|
|
220
293
|
}
|
|
221
294
|
}
|
|
222
295
|
|
|
@@ -425,6 +498,20 @@ function normalizeUserId(userId) {
|
|
|
425
498
|
async function getSession(userId) {
|
|
426
499
|
const key = normalizeUserId(userId);
|
|
427
500
|
let session = sessions.get(key);
|
|
501
|
+
|
|
502
|
+
// Check if existing session's context is still alive
|
|
503
|
+
if (session) {
|
|
504
|
+
try {
|
|
505
|
+
// Lightweight probe: pages() is synchronous-ish and throws if context is dead
|
|
506
|
+
session.context.pages();
|
|
507
|
+
} catch (err) {
|
|
508
|
+
log('warn', 'session context dead, recreating', { userId: key, error: err.message });
|
|
509
|
+
session.context.close().catch(() => {});
|
|
510
|
+
sessions.delete(key);
|
|
511
|
+
session = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
428
515
|
if (!session) {
|
|
429
516
|
if (sessions.size >= MAX_SESSIONS) {
|
|
430
517
|
throw new Error('Maximum concurrent sessions reached');
|
|
@@ -460,6 +547,94 @@ function getTabGroup(session, listItemId) {
|
|
|
460
547
|
return group;
|
|
461
548
|
}
|
|
462
549
|
|
|
550
|
+
function isDeadContextError(err) {
|
|
551
|
+
const msg = err && err.message || '';
|
|
552
|
+
return msg.includes('Target page, context or browser has been closed') ||
|
|
553
|
+
msg.includes('browser has been closed') ||
|
|
554
|
+
msg.includes('Context closed') ||
|
|
555
|
+
msg.includes('Browser closed');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function isTimeoutError(err) {
|
|
559
|
+
const msg = err && err.message || '';
|
|
560
|
+
return msg.includes('timed out after') ||
|
|
561
|
+
(msg.includes('Timeout') && msg.includes('exceeded'));
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function isTabLockQueueTimeout(err) {
|
|
565
|
+
return err && err.message === 'Tab lock queue timeout';
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isTabDestroyedError(err) {
|
|
569
|
+
return err && err.message === 'Tab destroyed';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Centralized error handler for route catch blocks.
|
|
573
|
+
// Auto-destroys dead browser sessions and returns appropriate status codes.
|
|
574
|
+
function handleRouteError(err, req, res, extraFields = {}) {
|
|
575
|
+
const userId = req.body?.userId || req.query?.userId;
|
|
576
|
+
if (userId && isDeadContextError(err)) {
|
|
577
|
+
destroySession(userId);
|
|
578
|
+
}
|
|
579
|
+
// Track consecutive timeouts per tab and auto-destroy stuck tabs
|
|
580
|
+
if (userId && isTimeoutError(err)) {
|
|
581
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
582
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
583
|
+
if (session && tabId) {
|
|
584
|
+
const found = findTab(session, tabId);
|
|
585
|
+
if (found) {
|
|
586
|
+
found.tabState.consecutiveTimeouts++;
|
|
587
|
+
if (found.tabState.consecutiveTimeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
|
|
588
|
+
log('warn', 'auto-destroying tab after consecutive timeouts', { tabId, count: found.tabState.consecutiveTimeouts });
|
|
589
|
+
destroyTab(session, tabId);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// Lock queue timeout = tab is stuck. Destroy immediately.
|
|
595
|
+
if (userId && isTabLockQueueTimeout(err)) {
|
|
596
|
+
const tabId = req.body?.tabId || req.query?.tabId || req.params?.tabId;
|
|
597
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
598
|
+
if (session && tabId) {
|
|
599
|
+
destroyTab(session, tabId);
|
|
600
|
+
}
|
|
601
|
+
return res.status(503).json({ error: 'Tab unresponsive and has been destroyed. Open a new tab.', ...extraFields });
|
|
602
|
+
}
|
|
603
|
+
// Tab was destroyed while this request was queued in the lock
|
|
604
|
+
if (isTabDestroyedError(err)) {
|
|
605
|
+
return res.status(410).json({ error: 'Tab was destroyed. Open a new tab.', ...extraFields });
|
|
606
|
+
}
|
|
607
|
+
sendError(res, err, extraFields);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function destroyTab(session, tabId) {
|
|
611
|
+
const lock = tabLocks.get(tabId);
|
|
612
|
+
if (lock) {
|
|
613
|
+
lock.drain();
|
|
614
|
+
tabLocks.delete(tabId);
|
|
615
|
+
}
|
|
616
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
617
|
+
if (group.has(tabId)) {
|
|
618
|
+
const tabState = group.get(tabId);
|
|
619
|
+
log('warn', 'destroying stuck tab', { tabId, listItemId, toolCalls: tabState.toolCalls });
|
|
620
|
+
safePageClose(tabState.page);
|
|
621
|
+
group.delete(tabId);
|
|
622
|
+
if (group.size === 0) session.tabGroups.delete(listItemId);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function destroySession(userId) {
|
|
630
|
+
const key = normalizeUserId(userId);
|
|
631
|
+
const session = sessions.get(key);
|
|
632
|
+
if (!session) return;
|
|
633
|
+
log('warn', 'destroying dead session', { userId: key });
|
|
634
|
+
session.context.close().catch(() => {});
|
|
635
|
+
sessions.delete(key);
|
|
636
|
+
}
|
|
637
|
+
|
|
463
638
|
function findTab(session, tabId) {
|
|
464
639
|
for (const [listItemId, group] of session.tabGroups) {
|
|
465
640
|
if (group.has(tabId)) {
|
|
@@ -475,11 +650,15 @@ function createTabState(page) {
|
|
|
475
650
|
page,
|
|
476
651
|
refs: new Map(),
|
|
477
652
|
visitedUrls: new Set(),
|
|
653
|
+
downloads: [],
|
|
478
654
|
toolCalls: 0,
|
|
655
|
+
consecutiveTimeouts: 0,
|
|
479
656
|
lastSnapshot: null,
|
|
480
657
|
};
|
|
481
658
|
}
|
|
482
659
|
|
|
660
|
+
|
|
661
|
+
|
|
483
662
|
async function waitForPageReady(page, options = {}) {
|
|
484
663
|
const { timeout = 10000, waitForNetwork = true } = options;
|
|
485
664
|
|
|
@@ -569,6 +748,156 @@ async function dismissConsentDialogs(page) {
|
|
|
569
748
|
}
|
|
570
749
|
}
|
|
571
750
|
|
|
751
|
+
// --- Google SERP detection ---
|
|
752
|
+
function isGoogleSerp(url) {
|
|
753
|
+
try {
|
|
754
|
+
const parsed = new URL(url);
|
|
755
|
+
return parsed.hostname.includes('google.') && parsed.pathname === '/search';
|
|
756
|
+
} catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// --- Google SERP: combined extraction (refs + snapshot in one DOM pass) ---
|
|
762
|
+
// Returns { refs: Map, snapshot: string }
|
|
763
|
+
async function extractGoogleSerp(page) {
|
|
764
|
+
const refs = new Map();
|
|
765
|
+
if (!page || page.isClosed()) return { refs, snapshot: '' };
|
|
766
|
+
|
|
767
|
+
const start = Date.now();
|
|
768
|
+
|
|
769
|
+
const alreadyRendered = await page.evaluate(() => !!document.querySelector('#rso h3, #search h3, #rso [data-snhf]')).catch(() => false);
|
|
770
|
+
if (!alreadyRendered) {
|
|
771
|
+
try {
|
|
772
|
+
await page.waitForSelector('#rso h3, #search h3, #rso [data-snhf]', { timeout: 5000 });
|
|
773
|
+
} catch {
|
|
774
|
+
try {
|
|
775
|
+
await page.waitForSelector('#rso a[href]:not([href^="/search"]), #search a[href]:not([href^="/search"])', { timeout: 2000 });
|
|
776
|
+
} catch {}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const extracted = await page.evaluate(() => {
|
|
781
|
+
const snapshot = [];
|
|
782
|
+
const elements = [];
|
|
783
|
+
let refCounter = 1;
|
|
784
|
+
|
|
785
|
+
function addRef(role, name) {
|
|
786
|
+
const id = 'e' + refCounter++;
|
|
787
|
+
elements.push({ id, role, name });
|
|
788
|
+
return id;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
snapshot.push('- heading "' + document.title.replace(/"/g, '\\"') + '"');
|
|
792
|
+
|
|
793
|
+
const searchInput = document.querySelector('input[name="q"], textarea[name="q"]');
|
|
794
|
+
if (searchInput) {
|
|
795
|
+
const name = 'Search';
|
|
796
|
+
const refId = addRef('searchbox', name);
|
|
797
|
+
snapshot.push('- searchbox "' + name + '" [' + refId + ']: ' + (searchInput.value || ''));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const navContainer = document.querySelector('div[role="navigation"], div[role="list"]');
|
|
801
|
+
if (navContainer) {
|
|
802
|
+
const navLinks = navContainer.querySelectorAll('a');
|
|
803
|
+
if (navLinks.length > 0) {
|
|
804
|
+
snapshot.push('- navigation:');
|
|
805
|
+
navLinks.forEach(a => {
|
|
806
|
+
const text = (a.textContent || '').trim();
|
|
807
|
+
if (!text || text.length < 1) return;
|
|
808
|
+
if (/^\d+$/.test(text) && parseInt(text) < 50) return;
|
|
809
|
+
const refId = addRef('link', text);
|
|
810
|
+
snapshot.push(' - link "' + text + '" [' + refId + ']');
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const resultContainer = document.querySelector('#rso') || document.querySelector('#search');
|
|
816
|
+
if (resultContainer) {
|
|
817
|
+
const resultBlocks = resultContainer.querySelectorAll(':scope > div');
|
|
818
|
+
for (const block of resultBlocks) {
|
|
819
|
+
const h3 = block.querySelector('h3');
|
|
820
|
+
const mainLink = h3 ? h3.closest('a') : null;
|
|
821
|
+
|
|
822
|
+
if (h3 && mainLink) {
|
|
823
|
+
const title = h3.textContent.trim().replace(/"/g, '\\"');
|
|
824
|
+
const href = mainLink.href;
|
|
825
|
+
const cite = block.querySelector('cite');
|
|
826
|
+
const displayUrl = cite ? cite.textContent.trim() : '';
|
|
827
|
+
|
|
828
|
+
let snippet = '';
|
|
829
|
+
for (const sel of ['[data-sncf]', '[data-content-feature="1"]', '.VwiC3b', 'div[style*="-webkit-line-clamp"]', 'span.aCOpRe']) {
|
|
830
|
+
const el = block.querySelector(sel);
|
|
831
|
+
if (el) { snippet = el.textContent.trim().slice(0, 300); break; }
|
|
832
|
+
}
|
|
833
|
+
if (!snippet) {
|
|
834
|
+
const allText = block.textContent.trim().replace(/\s+/g, ' ');
|
|
835
|
+
const titleLen = title.length + (displayUrl ? displayUrl.length : 0);
|
|
836
|
+
if (allText.length > titleLen + 20) {
|
|
837
|
+
snippet = allText.slice(titleLen).trim().slice(0, 300);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const refId = addRef('link', title);
|
|
842
|
+
snapshot.push('- link "' + title + '" [' + refId + ']:');
|
|
843
|
+
snapshot.push(' - /url: ' + href);
|
|
844
|
+
if (displayUrl) snapshot.push(' - cite: ' + displayUrl);
|
|
845
|
+
if (snippet) snapshot.push(' - text: ' + snippet);
|
|
846
|
+
} else {
|
|
847
|
+
const blockLinks = block.querySelectorAll('a[href^="http"]:not([href*="google.com/search"])');
|
|
848
|
+
if (blockLinks.length > 0) {
|
|
849
|
+
const blockText = block.textContent.trim().replace(/\s+/g, ' ').slice(0, 200);
|
|
850
|
+
if (blockText.length > 10) {
|
|
851
|
+
snapshot.push('- group:');
|
|
852
|
+
snapshot.push(' - text: ' + blockText);
|
|
853
|
+
blockLinks.forEach(a => {
|
|
854
|
+
const linkText = (a.textContent || '').trim().replace(/"/g, '\\"').slice(0, 100);
|
|
855
|
+
if (linkText.length > 2) {
|
|
856
|
+
const refId = addRef('link', linkText);
|
|
857
|
+
snapshot.push(' - link "' + linkText + '" [' + refId + ']:');
|
|
858
|
+
snapshot.push(' - /url: ' + a.href);
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const paaItems = document.querySelectorAll('[jsname="Cpkphb"], div.related-question-pair');
|
|
868
|
+
if (paaItems.length > 0) {
|
|
869
|
+
snapshot.push('- heading "People also ask"');
|
|
870
|
+
paaItems.forEach(q => {
|
|
871
|
+
const text = (q.textContent || '').trim().replace(/"/g, '\\"').slice(0, 150);
|
|
872
|
+
if (text) {
|
|
873
|
+
const refId = addRef('button', text);
|
|
874
|
+
snapshot.push(' - button "' + text + '" [' + refId + ']');
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const nextLink = document.querySelector('#botstuff a[aria-label="Next page"], td.d6cvqb a, a#pnnext');
|
|
880
|
+
if (nextLink) {
|
|
881
|
+
const refId = addRef('link', 'Next');
|
|
882
|
+
snapshot.push('- navigation "pagination":');
|
|
883
|
+
snapshot.push(' - link "Next" [' + refId + ']');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return { snapshot: snapshot.join('\n'), elements };
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
const seenCounts = new Map();
|
|
890
|
+
for (const el of extracted.elements) {
|
|
891
|
+
const key = `${el.role}:${el.name}`;
|
|
892
|
+
const nth = seenCounts.get(key) || 0;
|
|
893
|
+
seenCounts.set(key, nth + 1);
|
|
894
|
+
refs.set(el.id, { role: el.role, name: el.name, nth });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
log('info', 'extractGoogleSerp', { elapsed: Date.now() - start, refs: refs.size });
|
|
898
|
+
return { refs, snapshot: extracted.snapshot };
|
|
899
|
+
}
|
|
900
|
+
|
|
572
901
|
async function buildRefs(page) {
|
|
573
902
|
const refs = new Map();
|
|
574
903
|
|
|
@@ -577,6 +906,13 @@ async function buildRefs(page) {
|
|
|
577
906
|
return refs;
|
|
578
907
|
}
|
|
579
908
|
|
|
909
|
+
// Google SERP fast path — skip ariaSnapshot entirely
|
|
910
|
+
const url = page.url();
|
|
911
|
+
if (isGoogleSerp(url)) {
|
|
912
|
+
const { refs: googleRefs } = await extractGoogleSerp(page);
|
|
913
|
+
return googleRefs;
|
|
914
|
+
}
|
|
915
|
+
|
|
580
916
|
const start = Date.now();
|
|
581
917
|
|
|
582
918
|
// Hard total timeout on the entire buildRefs operation
|
|
@@ -719,7 +1055,12 @@ app.post('/youtube/transcript', async (req, res) => {
|
|
|
719
1055
|
|
|
720
1056
|
let result;
|
|
721
1057
|
if (hasYtDlp()) {
|
|
722
|
-
|
|
1058
|
+
try {
|
|
1059
|
+
result = await ytDlpTranscript(reqId, url, videoId, lang);
|
|
1060
|
+
} catch (ytErr) {
|
|
1061
|
+
log('warn', 'yt-dlp failed, falling back to browser', { reqId, error: ytErr.message });
|
|
1062
|
+
result = await browserTranscript(reqId, url, videoId, lang);
|
|
1063
|
+
}
|
|
723
1064
|
} else {
|
|
724
1065
|
result = await browserTranscript(reqId, url, videoId, lang);
|
|
725
1066
|
}
|
|
@@ -759,16 +1100,52 @@ async function browserTranscript(reqId, url, videoId, lang) {
|
|
|
759
1100
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAVIGATE_TIMEOUT_MS });
|
|
760
1101
|
await page.waitForTimeout(2000);
|
|
761
1102
|
|
|
1103
|
+
// Extract caption track URLs and metadata from ytInitialPlayerResponse
|
|
762
1104
|
const meta = await page.evaluate(() => {
|
|
763
1105
|
const r = window.ytInitialPlayerResponse || (typeof ytInitialPlayerResponse !== 'undefined' ? ytInitialPlayerResponse : null);
|
|
764
|
-
if (!r) return { title: '' };
|
|
1106
|
+
if (!r) return { title: '', tracks: [] };
|
|
765
1107
|
const tracks = r?.captions?.playerCaptionsTracklistRenderer?.captionTracks || [];
|
|
766
1108
|
return {
|
|
767
1109
|
title: r?.videoDetails?.title || '',
|
|
768
|
-
|
|
1110
|
+
tracks: tracks.map(t => ({ code: t.languageCode, name: t.name?.simpleText || t.languageCode, kind: t.kind || 'manual', url: t.baseUrl })),
|
|
769
1111
|
};
|
|
770
1112
|
});
|
|
771
1113
|
|
|
1114
|
+
log('info', 'youtube transcript: extracted caption tracks', { reqId, title: meta.title, trackCount: meta.tracks.length, tracks: meta.tracks.map(t => t.code) });
|
|
1115
|
+
|
|
1116
|
+
// Strategy A: Fetch caption track URL directly from ytInitialPlayerResponse
|
|
1117
|
+
// These URLs are freshly signed by YouTube and work immediately
|
|
1118
|
+
if (meta.tracks && meta.tracks.length > 0) {
|
|
1119
|
+
const track = meta.tracks.find(t => t.code === lang) || meta.tracks[0];
|
|
1120
|
+
if (track && track.url) {
|
|
1121
|
+
const captionUrl = track.url + (track.url.includes('?') ? '&' : '?') + 'fmt=json3';
|
|
1122
|
+
log('info', 'youtube transcript: fetching caption track', { reqId, lang: track.code, url: captionUrl.substring(0, 100) });
|
|
1123
|
+
try {
|
|
1124
|
+
const captionResp = await page.evaluate(async (fetchUrl) => {
|
|
1125
|
+
const resp = await fetch(fetchUrl);
|
|
1126
|
+
return resp.ok ? await resp.text() : null;
|
|
1127
|
+
}, captionUrl);
|
|
1128
|
+
if (captionResp && captionResp.length > 0) {
|
|
1129
|
+
let transcriptText = null;
|
|
1130
|
+
if (captionResp.trimStart().startsWith('{')) transcriptText = parseJson3(captionResp);
|
|
1131
|
+
else if (captionResp.includes('WEBVTT')) transcriptText = parseVtt(captionResp);
|
|
1132
|
+
else if (captionResp.includes('<text')) transcriptText = parseXml(captionResp);
|
|
1133
|
+
if (transcriptText && transcriptText.trim()) {
|
|
1134
|
+
return {
|
|
1135
|
+
status: 'ok', transcript: transcriptText,
|
|
1136
|
+
video_url: url, video_id: videoId, video_title: meta.title,
|
|
1137
|
+
language: track.code, total_words: transcriptText.split(/\s+/).length,
|
|
1138
|
+
available_languages: meta.tracks.map(t => ({ code: t.code, name: t.name, kind: t.kind })),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
} catch (fetchErr) {
|
|
1143
|
+
log('warn', 'youtube transcript: caption track fetch failed', { reqId, error: fetchErr.message });
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Strategy B: Play video and intercept timedtext network response
|
|
772
1149
|
await page.evaluate(() => {
|
|
773
1150
|
const v = document.querySelector('video');
|
|
774
1151
|
if (v) { v.muted = true; v.play().catch(() => {}); }
|
|
@@ -781,7 +1158,7 @@ async function browserTranscript(reqId, url, videoId, lang) {
|
|
|
781
1158
|
if (!interceptedCaptions) {
|
|
782
1159
|
return {
|
|
783
1160
|
status: 'error', code: 404,
|
|
784
|
-
message: 'No captions
|
|
1161
|
+
message: 'No captions available for this video',
|
|
785
1162
|
video_url: url, video_id: videoId, title: meta.title,
|
|
786
1163
|
};
|
|
787
1164
|
}
|
|
@@ -838,33 +1215,42 @@ app.post('/tabs', async (req, res) => {
|
|
|
838
1215
|
return res.status(400).json({ error: 'userId and sessionKey required' });
|
|
839
1216
|
}
|
|
840
1217
|
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
1218
|
+
const result = await withTimeout((async () => {
|
|
1219
|
+
const session = await getSession(userId);
|
|
1220
|
+
|
|
1221
|
+
let totalTabs = 0;
|
|
1222
|
+
for (const group of session.tabGroups.values()) totalTabs += group.size;
|
|
1223
|
+
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
1224
|
+
throw Object.assign(new Error('Maximum tabs per session reached'), { statusCode: 429 });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
1228
|
+
throw Object.assign(new Error('Maximum global tabs reached'), { statusCode: 429 });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const group = getTabGroup(session, resolvedSessionKey);
|
|
1232
|
+
|
|
1233
|
+
const page = await session.context.newPage();
|
|
1234
|
+
const tabId = crypto.randomUUID();
|
|
1235
|
+
const tabState = createTabState(page);
|
|
1236
|
+
attachDownloadListener(tabState, tabId);
|
|
1237
|
+
group.set(tabId, tabState);
|
|
1238
|
+
|
|
1239
|
+
if (url) {
|
|
1240
|
+
const urlErr = validateUrl(url);
|
|
1241
|
+
if (urlErr) throw Object.assign(new Error(urlErr), { statusCode: 400 });
|
|
1242
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1243
|
+
tabState.visitedUrls.add(url);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
log('info', 'tab created', { reqId: req.reqId, tabId, userId, sessionKey: resolvedSessionKey, url: page.url() });
|
|
1247
|
+
return { tabId, url: page.url() };
|
|
1248
|
+
})(), HANDLER_TIMEOUT_MS, 'tab create');
|
|
1249
|
+
|
|
1250
|
+
res.json(result);
|
|
865
1251
|
} catch (err) {
|
|
866
1252
|
log('error', 'tab create failed', { reqId: req.reqId, error: err.message });
|
|
867
|
-
|
|
1253
|
+
handleRouteError(err, req, res);
|
|
868
1254
|
}
|
|
869
1255
|
});
|
|
870
1256
|
|
|
@@ -906,7 +1292,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
906
1292
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
907
1293
|
if (oldestGroup) oldestGroup.delete(oldestTabId);
|
|
908
1294
|
group.set(tabId, tabState);
|
|
909
|
-
tabLocks.delete(oldestTabId);
|
|
1295
|
+
{ const _l = tabLocks.get(oldestTabId); if (_l) _l.drain(); tabLocks.delete(oldestTabId); }
|
|
910
1296
|
log('info', 'tab recycled (limit reached)', { reqId: req.reqId, tabId, recycledFrom: oldestTabId, userId });
|
|
911
1297
|
} else {
|
|
912
1298
|
throw new Error('Maximum tabs per session reached');
|
|
@@ -914,6 +1300,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
914
1300
|
} else {
|
|
915
1301
|
const page = await session.context.newPage();
|
|
916
1302
|
tabState = createTabState(page);
|
|
1303
|
+
attachDownloadListener(tabState, tabId, log);
|
|
917
1304
|
const group = getTabGroup(session, resolvedSessionKey);
|
|
918
1305
|
group.set(tabId, tabState);
|
|
919
1306
|
log('info', 'tab auto-created on navigate', { reqId: req.reqId, tabId, userId });
|
|
@@ -921,7 +1308,7 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
921
1308
|
} else {
|
|
922
1309
|
tabState = found.tabState;
|
|
923
1310
|
}
|
|
924
|
-
tabState.toolCalls++;
|
|
1311
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
925
1312
|
|
|
926
1313
|
let targetUrl = url;
|
|
927
1314
|
if (macro) {
|
|
@@ -937,6 +1324,15 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
937
1324
|
await tabState.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
938
1325
|
tabState.visitedUrls.add(targetUrl);
|
|
939
1326
|
tabState.lastSnapshot = null;
|
|
1327
|
+
|
|
1328
|
+
// For Google SERP: skip eager ref building during navigate.
|
|
1329
|
+
// Results render asynchronously after DOMContentLoaded — the snapshot
|
|
1330
|
+
// call will wait for and extract them.
|
|
1331
|
+
if (isGoogleSerp(tabState.page.url())) {
|
|
1332
|
+
tabState.refs = new Map();
|
|
1333
|
+
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: false, googleSerp: true };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
940
1336
|
tabState.refs = await buildRefs(tabState.page);
|
|
941
1337
|
return { ok: true, tabId, url: tabState.page.url(), refsAvailable: tabState.refs.size > 0 };
|
|
942
1338
|
});
|
|
@@ -947,7 +1343,10 @@ app.post('/tabs/:tabId/navigate', async (req, res) => {
|
|
|
947
1343
|
} catch (err) {
|
|
948
1344
|
log('error', 'navigate failed', { reqId: req.reqId, tabId, error: err.message });
|
|
949
1345
|
const status = err.message && err.message.startsWith('Blocked URL scheme') ? 400 : 500;
|
|
950
|
-
|
|
1346
|
+
if (status === 400) {
|
|
1347
|
+
return res.status(400).json({ error: safeError(err) });
|
|
1348
|
+
}
|
|
1349
|
+
handleRouteError(err, req, res);
|
|
951
1350
|
}
|
|
952
1351
|
});
|
|
953
1352
|
|
|
@@ -963,7 +1362,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
963
1362
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
964
1363
|
|
|
965
1364
|
const { tabState } = found;
|
|
966
|
-
tabState.toolCalls++;
|
|
1365
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
967
1366
|
|
|
968
1367
|
// Cached chunk retrieval for offset>0 requests
|
|
969
1368
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -978,6 +1377,31 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
978
1377
|
}
|
|
979
1378
|
|
|
980
1379
|
const result = await withUserLimit(userId, () => withTimeout((async () => {
|
|
1380
|
+
const pageUrl = tabState.page.url();
|
|
1381
|
+
|
|
1382
|
+
// Google SERP fast path — DOM extraction instead of ariaSnapshot
|
|
1383
|
+
if (isGoogleSerp(pageUrl)) {
|
|
1384
|
+
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
1385
|
+
tabState.refs = googleRefs;
|
|
1386
|
+
tabState.lastSnapshot = googleSnapshot;
|
|
1387
|
+
const annotatedYaml = googleSnapshot;
|
|
1388
|
+
const win = windowSnapshot(annotatedYaml, 0);
|
|
1389
|
+
const response = {
|
|
1390
|
+
url: pageUrl,
|
|
1391
|
+
snapshot: win.text,
|
|
1392
|
+
refsCount: tabState.refs.size,
|
|
1393
|
+
truncated: win.truncated,
|
|
1394
|
+
totalChars: win.totalChars,
|
|
1395
|
+
hasMore: win.hasMore,
|
|
1396
|
+
nextOffset: win.nextOffset,
|
|
1397
|
+
};
|
|
1398
|
+
if (req.query.includeScreenshot === 'true') {
|
|
1399
|
+
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
1400
|
+
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
1401
|
+
}
|
|
1402
|
+
return response;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
981
1405
|
tabState.refs = await buildRefs(tabState.page);
|
|
982
1406
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
983
1407
|
|
|
@@ -1040,7 +1464,7 @@ app.get('/tabs/:tabId/snapshot', async (req, res) => {
|
|
|
1040
1464
|
res.json(result);
|
|
1041
1465
|
} catch (err) {
|
|
1042
1466
|
log('error', 'snapshot failed', { reqId: req.reqId, tabId: req.params.tabId, error: err.message });
|
|
1043
|
-
|
|
1467
|
+
handleRouteError(err, req, res);
|
|
1044
1468
|
}
|
|
1045
1469
|
});
|
|
1046
1470
|
|
|
@@ -1058,7 +1482,7 @@ app.post('/tabs/:tabId/wait', async (req, res) => {
|
|
|
1058
1482
|
res.json({ ok: true, ready });
|
|
1059
1483
|
} catch (err) {
|
|
1060
1484
|
log('error', 'wait failed', { reqId: req.reqId, error: err.message });
|
|
1061
|
-
|
|
1485
|
+
handleRouteError(err, req, res);
|
|
1062
1486
|
}
|
|
1063
1487
|
});
|
|
1064
1488
|
|
|
@@ -1074,13 +1498,15 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1074
1498
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1075
1499
|
|
|
1076
1500
|
const { tabState } = found;
|
|
1077
|
-
tabState.toolCalls++;
|
|
1501
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1078
1502
|
|
|
1079
1503
|
if (!ref && !selector) {
|
|
1080
1504
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
1081
1505
|
}
|
|
1082
1506
|
|
|
1083
|
-
const result = await withUserLimit(userId, () =>
|
|
1507
|
+
const result = await withUserLimit(userId, () => withTabLock(tabId, async () => {
|
|
1508
|
+
const clickStart = Date.now();
|
|
1509
|
+
const remainingBudget = () => Math.max(0, HANDLER_TIMEOUT_MS - 2000 - (Date.now() - clickStart));
|
|
1084
1510
|
// Full mouse event sequence for stubborn JS click handlers (mirrors Swift WebView.swift)
|
|
1085
1511
|
// Dispatches: mouseover → mouseenter → mousedown → mouseup → click
|
|
1086
1512
|
const dispatchMouseSequence = async (locator) => {
|
|
@@ -1102,18 +1528,32 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1102
1528
|
log('info', 'mouse sequence dispatched', { x: x.toFixed(0), y: y.toFixed(0) });
|
|
1103
1529
|
};
|
|
1104
1530
|
|
|
1531
|
+
// On Google SERPs, skip the normal click attempt (always intercepted by overlays)
|
|
1532
|
+
// and go directly to force click — saves 5s timeout per click
|
|
1533
|
+
const onGoogleSerp = isGoogleSerp(tabState.page.url());
|
|
1534
|
+
|
|
1105
1535
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
1106
1536
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
1107
1537
|
|
|
1538
|
+
if (onGoogleSerp) {
|
|
1539
|
+
try {
|
|
1540
|
+
await locator.click({ timeout: 3000, force: true });
|
|
1541
|
+
} catch (forceErr) {
|
|
1542
|
+
log('warn', 'google force click failed, trying mouse sequence');
|
|
1543
|
+
await dispatchMouseSequence(locator);
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1108
1548
|
try {
|
|
1109
1549
|
// First try normal click (respects visibility, enabled, not-obscured)
|
|
1110
|
-
await locator.click({ timeout:
|
|
1550
|
+
await locator.click({ timeout: 3000 });
|
|
1111
1551
|
} catch (err) {
|
|
1112
1552
|
// Fallback 1: If intercepted by overlay, retry with force
|
|
1113
1553
|
if (err.message.includes('intercepts pointer events')) {
|
|
1114
1554
|
log('warn', 'click intercepted, retrying with force');
|
|
1115
1555
|
try {
|
|
1116
|
-
await locator.click({ timeout:
|
|
1556
|
+
await locator.click({ timeout: 3000, force: true });
|
|
1117
1557
|
} catch (forceErr) {
|
|
1118
1558
|
// Fallback 2: Full mouse event sequence for stubborn JS handlers
|
|
1119
1559
|
log('warn', 'force click failed, trying mouse sequence');
|
|
@@ -1131,35 +1571,93 @@ app.post('/tabs/:tabId/click', async (req, res) => {
|
|
|
1131
1571
|
|
|
1132
1572
|
if (ref) {
|
|
1133
1573
|
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1134
|
-
if (!locator
|
|
1135
|
-
//
|
|
1136
|
-
log('info', 'auto-refreshing
|
|
1137
|
-
|
|
1574
|
+
if (!locator) {
|
|
1575
|
+
// Use tight timeout (4s max) to leave budget for click + post-click buildRefs
|
|
1576
|
+
log('info', 'auto-refreshing refs before click', { ref, hadRefs: tabState.refs.size });
|
|
1577
|
+
try {
|
|
1578
|
+
const preClickBudget = Math.min(4000, remainingBudget());
|
|
1579
|
+
const refreshPromise = buildRefs(tabState.page);
|
|
1580
|
+
const refreshBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('pre_click_refs_timeout')), preClickBudget));
|
|
1581
|
+
tabState.refs = await Promise.race([refreshPromise, refreshBudget]);
|
|
1582
|
+
} catch (e) {
|
|
1583
|
+
if (e.message === 'pre_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
1584
|
+
log('warn', 'pre-click buildRefs timed out, proceeding without refresh');
|
|
1585
|
+
} else {
|
|
1586
|
+
throw e;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1138
1589
|
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1139
1590
|
}
|
|
1140
1591
|
if (!locator) {
|
|
1141
1592
|
const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none';
|
|
1142
|
-
throw new
|
|
1593
|
+
throw new StaleRefsError(ref, maxRef, tabState.refs.size);
|
|
1143
1594
|
}
|
|
1144
1595
|
await doClick(locator, true);
|
|
1145
1596
|
} else {
|
|
1146
1597
|
await doClick(selector, false);
|
|
1147
1598
|
}
|
|
1148
1599
|
|
|
1149
|
-
|
|
1600
|
+
// If clicking on a Google SERP, wait for potential navigation to complete
|
|
1601
|
+
if (onGoogleSerp) {
|
|
1602
|
+
try {
|
|
1603
|
+
await tabState.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
|
|
1604
|
+
} catch {}
|
|
1605
|
+
await tabState.page.waitForTimeout(200);
|
|
1606
|
+
// Skip buildRefs here — SERP clicks typically navigate to a new page,
|
|
1607
|
+
// and the caller always requests /snapshot next which rebuilds refs.
|
|
1608
|
+
tabState.lastSnapshot = null;
|
|
1609
|
+
tabState.refs = new Map();
|
|
1610
|
+
const newUrl = tabState.page.url();
|
|
1611
|
+
tabState.visitedUrls.add(newUrl);
|
|
1612
|
+
return { ok: true, url: newUrl, refsAvailable: false };
|
|
1613
|
+
} else {
|
|
1614
|
+
await tabState.page.waitForTimeout(500);
|
|
1615
|
+
}
|
|
1150
1616
|
tabState.lastSnapshot = null;
|
|
1151
|
-
|
|
1617
|
+
// buildRefs after click — use remaining budget (min 2s) so we don't blow the handler timeout.
|
|
1618
|
+
// If it times out, return without refs (caller's next /snapshot will rebuild them).
|
|
1619
|
+
const postClickBudget = Math.max(2000, remainingBudget());
|
|
1620
|
+
try {
|
|
1621
|
+
const refsPromise = buildRefs(tabState.page);
|
|
1622
|
+
const refsBudget = new Promise((_, reject) => setTimeout(() => reject(new Error('post_click_refs_timeout')), postClickBudget));
|
|
1623
|
+
tabState.refs = await Promise.race([refsPromise, refsBudget]);
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
if (e.message === 'post_click_refs_timeout' || e.message === 'buildRefs_timeout') {
|
|
1626
|
+
log('warn', 'post-click buildRefs timed out, returning without refs', { budget: postClickBudget, elapsed: Date.now() - clickStart });
|
|
1627
|
+
tabState.refs = new Map();
|
|
1628
|
+
} else {
|
|
1629
|
+
throw e;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1152
1632
|
|
|
1153
1633
|
const newUrl = tabState.page.url();
|
|
1154
1634
|
tabState.visitedUrls.add(newUrl);
|
|
1155
1635
|
return { ok: true, url: newUrl, refsAvailable: tabState.refs.size > 0 };
|
|
1156
|
-
})
|
|
1636
|
+
}));
|
|
1157
1637
|
|
|
1158
1638
|
log('info', 'clicked', { reqId: req.reqId, tabId, url: result.url });
|
|
1159
1639
|
res.json(result);
|
|
1160
1640
|
} catch (err) {
|
|
1161
1641
|
log('error', 'click failed', { reqId: req.reqId, tabId, error: err.message });
|
|
1162
|
-
|
|
1642
|
+
if (err.message?.includes('timed out')) {
|
|
1643
|
+
try {
|
|
1644
|
+
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
1645
|
+
const found = session && findTab(session, tabId);
|
|
1646
|
+
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
1647
|
+
found.tabState.refs = await buildRefs(found.tabState.page);
|
|
1648
|
+
found.tabState.lastSnapshot = null;
|
|
1649
|
+
return res.status(500).json({
|
|
1650
|
+
error: safeError(err),
|
|
1651
|
+
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
1652
|
+
url: found.tabState.page.url(),
|
|
1653
|
+
refsCount: found.tabState.refs.size,
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
} catch (refreshErr) {
|
|
1657
|
+
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
handleRouteError(err, req, res);
|
|
1163
1661
|
}
|
|
1164
1662
|
});
|
|
1165
1663
|
|
|
@@ -1174,7 +1672,7 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1174
1672
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1175
1673
|
|
|
1176
1674
|
const { tabState } = found;
|
|
1177
|
-
tabState.toolCalls++;
|
|
1675
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1178
1676
|
|
|
1179
1677
|
if (!ref && !selector) {
|
|
1180
1678
|
return res.status(400).json({ error: 'ref or selector required' });
|
|
@@ -1182,8 +1680,13 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1182
1680
|
|
|
1183
1681
|
await withTabLock(tabId, async () => {
|
|
1184
1682
|
if (ref) {
|
|
1185
|
-
|
|
1186
|
-
if (!locator)
|
|
1683
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1684
|
+
if (!locator) {
|
|
1685
|
+
log('info', 'auto-refreshing refs before fill', { ref, hadRefs: tabState.refs.size });
|
|
1686
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
1687
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
1688
|
+
}
|
|
1689
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1187
1690
|
await locator.fill(text, { timeout: 10000 });
|
|
1188
1691
|
} else {
|
|
1189
1692
|
await tabState.page.fill(selector, text, { timeout: 10000 });
|
|
@@ -1193,7 +1696,25 @@ app.post('/tabs/:tabId/type', async (req, res) => {
|
|
|
1193
1696
|
res.json({ ok: true });
|
|
1194
1697
|
} catch (err) {
|
|
1195
1698
|
log('error', 'type failed', { reqId: req.reqId, error: err.message });
|
|
1196
|
-
|
|
1699
|
+
if (err.message?.includes('timed out') || err.message?.includes('not an <input>')) {
|
|
1700
|
+
try {
|
|
1701
|
+
const session = sessions.get(normalizeUserId(req.body.userId));
|
|
1702
|
+
const found = session && findTab(session, tabId);
|
|
1703
|
+
if (found?.tabState?.page && !found.tabState.page.isClosed()) {
|
|
1704
|
+
found.tabState.refs = await buildRefs(found.tabState.page);
|
|
1705
|
+
found.tabState.lastSnapshot = null;
|
|
1706
|
+
return res.status(500).json({
|
|
1707
|
+
error: safeError(err),
|
|
1708
|
+
hint: 'The page may have changed. Call snapshot to see the current state and retry.',
|
|
1709
|
+
url: found.tabState.page.url(),
|
|
1710
|
+
refsCount: found.tabState.refs.size,
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
} catch (refreshErr) {
|
|
1714
|
+
log('warn', 'post-timeout refresh failed', { error: refreshErr.message });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
handleRouteError(err, req, res);
|
|
1197
1718
|
}
|
|
1198
1719
|
});
|
|
1199
1720
|
|
|
@@ -1208,7 +1729,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
1208
1729
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1209
1730
|
|
|
1210
1731
|
const { tabState } = found;
|
|
1211
|
-
tabState.toolCalls++;
|
|
1732
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1212
1733
|
|
|
1213
1734
|
await withTabLock(tabId, async () => {
|
|
1214
1735
|
await tabState.page.keyboard.press(key);
|
|
@@ -1217,7 +1738,7 @@ app.post('/tabs/:tabId/press', async (req, res) => {
|
|
|
1217
1738
|
res.json({ ok: true });
|
|
1218
1739
|
} catch (err) {
|
|
1219
1740
|
log('error', 'press failed', { reqId: req.reqId, error: err.message });
|
|
1220
|
-
|
|
1741
|
+
handleRouteError(err, req, res);
|
|
1221
1742
|
}
|
|
1222
1743
|
});
|
|
1223
1744
|
|
|
@@ -1230,7 +1751,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
1230
1751
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1231
1752
|
|
|
1232
1753
|
const { tabState } = found;
|
|
1233
|
-
tabState.toolCalls++;
|
|
1754
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1234
1755
|
|
|
1235
1756
|
const delta = direction === 'up' ? -amount : amount;
|
|
1236
1757
|
await tabState.page.mouse.wheel(0, delta);
|
|
@@ -1239,7 +1760,7 @@ app.post('/tabs/:tabId/scroll', async (req, res) => {
|
|
|
1239
1760
|
res.json({ ok: true });
|
|
1240
1761
|
} catch (err) {
|
|
1241
1762
|
log('error', 'scroll failed', { reqId: req.reqId, error: err.message });
|
|
1242
|
-
|
|
1763
|
+
handleRouteError(err, req, res);
|
|
1243
1764
|
}
|
|
1244
1765
|
});
|
|
1245
1766
|
|
|
@@ -1254,18 +1775,18 @@ app.post('/tabs/:tabId/back', async (req, res) => {
|
|
|
1254
1775
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1255
1776
|
|
|
1256
1777
|
const { tabState } = found;
|
|
1257
|
-
tabState.toolCalls++;
|
|
1778
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1258
1779
|
|
|
1259
|
-
const result = await
|
|
1780
|
+
const result = await withTabLock(tabId, async () => {
|
|
1260
1781
|
await tabState.page.goBack({ timeout: 10000 });
|
|
1261
1782
|
tabState.refs = await buildRefs(tabState.page);
|
|
1262
1783
|
return { ok: true, url: tabState.page.url() };
|
|
1263
|
-
})
|
|
1784
|
+
});
|
|
1264
1785
|
|
|
1265
1786
|
res.json(result);
|
|
1266
1787
|
} catch (err) {
|
|
1267
1788
|
log('error', 'back failed', { reqId: req.reqId, error: err.message });
|
|
1268
|
-
|
|
1789
|
+
handleRouteError(err, req, res);
|
|
1269
1790
|
}
|
|
1270
1791
|
});
|
|
1271
1792
|
|
|
@@ -1280,18 +1801,18 @@ app.post('/tabs/:tabId/forward', async (req, res) => {
|
|
|
1280
1801
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1281
1802
|
|
|
1282
1803
|
const { tabState } = found;
|
|
1283
|
-
tabState.toolCalls++;
|
|
1804
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1284
1805
|
|
|
1285
|
-
const result = await
|
|
1806
|
+
const result = await withTabLock(tabId, async () => {
|
|
1286
1807
|
await tabState.page.goForward({ timeout: 10000 });
|
|
1287
1808
|
tabState.refs = await buildRefs(tabState.page);
|
|
1288
1809
|
return { ok: true, url: tabState.page.url() };
|
|
1289
|
-
})
|
|
1810
|
+
});
|
|
1290
1811
|
|
|
1291
1812
|
res.json(result);
|
|
1292
1813
|
} catch (err) {
|
|
1293
1814
|
log('error', 'forward failed', { reqId: req.reqId, error: err.message });
|
|
1294
|
-
|
|
1815
|
+
handleRouteError(err, req, res);
|
|
1295
1816
|
}
|
|
1296
1817
|
});
|
|
1297
1818
|
|
|
@@ -1306,18 +1827,18 @@ app.post('/tabs/:tabId/refresh', async (req, res) => {
|
|
|
1306
1827
|
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1307
1828
|
|
|
1308
1829
|
const { tabState } = found;
|
|
1309
|
-
tabState.toolCalls++;
|
|
1830
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1310
1831
|
|
|
1311
|
-
const result = await
|
|
1832
|
+
const result = await withTabLock(tabId, async () => {
|
|
1312
1833
|
await tabState.page.reload({ timeout: 30000 });
|
|
1313
1834
|
tabState.refs = await buildRefs(tabState.page);
|
|
1314
1835
|
return { ok: true, url: tabState.page.url() };
|
|
1315
|
-
})
|
|
1836
|
+
});
|
|
1316
1837
|
|
|
1317
1838
|
res.json(result);
|
|
1318
1839
|
} catch (err) {
|
|
1319
1840
|
log('error', 'refresh failed', { reqId: req.reqId, error: err.message });
|
|
1320
|
-
|
|
1841
|
+
handleRouteError(err, req, res);
|
|
1321
1842
|
}
|
|
1322
1843
|
});
|
|
1323
1844
|
|
|
@@ -1335,7 +1856,7 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
1335
1856
|
}
|
|
1336
1857
|
|
|
1337
1858
|
const { tabState } = found;
|
|
1338
|
-
tabState.toolCalls++;
|
|
1859
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1339
1860
|
|
|
1340
1861
|
const allLinks = await tabState.page.evaluate(() => {
|
|
1341
1862
|
const links = [];
|
|
@@ -1358,6 +1879,59 @@ app.get('/tabs/:tabId/links', async (req, res) => {
|
|
|
1358
1879
|
});
|
|
1359
1880
|
} catch (err) {
|
|
1360
1881
|
log('error', 'links failed', { reqId: req.reqId, error: err.message });
|
|
1882
|
+
handleRouteError(err, req, res);
|
|
1883
|
+
}
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
// Get captured downloads
|
|
1887
|
+
app.get('/tabs/:tabId/downloads', async (req, res) => {
|
|
1888
|
+
try {
|
|
1889
|
+
const userId = req.query.userId;
|
|
1890
|
+
const includeData = req.query.includeData === 'true';
|
|
1891
|
+
const consume = req.query.consume === 'true';
|
|
1892
|
+
const maxBytesRaw = Number(req.query.maxBytes);
|
|
1893
|
+
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
1894
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1895
|
+
const found = session && findTab(session, req.params.tabId);
|
|
1896
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1897
|
+
|
|
1898
|
+
const { tabState } = found;
|
|
1899
|
+
tabState.toolCalls++;
|
|
1900
|
+
|
|
1901
|
+
const downloads = await getDownloadsList(tabState, { includeData, maxBytes });
|
|
1902
|
+
|
|
1903
|
+
if (consume) {
|
|
1904
|
+
await clearTabDownloads(tabState);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
res.json({ tabId: req.params.tabId, downloads });
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
log('error', 'downloads failed', { reqId: req.reqId, error: err.message });
|
|
1910
|
+
res.status(500).json({ error: safeError(err) });
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
// Get image elements from current page
|
|
1915
|
+
app.get('/tabs/:tabId/images', async (req, res) => {
|
|
1916
|
+
try {
|
|
1917
|
+
const userId = req.query.userId;
|
|
1918
|
+
const includeData = req.query.includeData === 'true';
|
|
1919
|
+
const maxBytesRaw = Number(req.query.maxBytes);
|
|
1920
|
+
const limitRaw = Number(req.query.limit);
|
|
1921
|
+
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? maxBytesRaw : MAX_DOWNLOAD_INLINE_BYTES;
|
|
1922
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 20) : 8;
|
|
1923
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1924
|
+
const found = session && findTab(session, req.params.tabId);
|
|
1925
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1926
|
+
|
|
1927
|
+
const { tabState } = found;
|
|
1928
|
+
tabState.toolCalls++;
|
|
1929
|
+
|
|
1930
|
+
const images = await extractPageImages(tabState.page, { includeData, maxBytes, limit });
|
|
1931
|
+
|
|
1932
|
+
res.json({ tabId: req.params.tabId, images });
|
|
1933
|
+
} catch (err) {
|
|
1934
|
+
log('error', 'images failed', { reqId: req.reqId, error: err.message });
|
|
1361
1935
|
res.status(500).json({ error: safeError(err) });
|
|
1362
1936
|
}
|
|
1363
1937
|
});
|
|
@@ -1377,7 +1951,7 @@ app.get('/tabs/:tabId/screenshot', async (req, res) => {
|
|
|
1377
1951
|
res.send(buffer);
|
|
1378
1952
|
} catch (err) {
|
|
1379
1953
|
log('error', 'screenshot failed', { reqId: req.reqId, error: err.message });
|
|
1380
|
-
|
|
1954
|
+
handleRouteError(err, req, res);
|
|
1381
1955
|
}
|
|
1382
1956
|
});
|
|
1383
1957
|
|
|
@@ -1396,11 +1970,36 @@ app.get('/tabs/:tabId/stats', async (req, res) => {
|
|
|
1396
1970
|
listItemId, // Legacy compatibility
|
|
1397
1971
|
url: tabState.page.url(),
|
|
1398
1972
|
visitedUrls: Array.from(tabState.visitedUrls),
|
|
1973
|
+
downloadsCount: Array.isArray(tabState.downloads) ? tabState.downloads.length : 0,
|
|
1399
1974
|
toolCalls: tabState.toolCalls,
|
|
1400
1975
|
refsCount: tabState.refs.size
|
|
1401
1976
|
});
|
|
1402
1977
|
} catch (err) {
|
|
1403
1978
|
log('error', 'stats failed', { reqId: req.reqId, error: err.message });
|
|
1979
|
+
handleRouteError(err, req, res);
|
|
1980
|
+
}
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
// Evaluate JavaScript in page context
|
|
1984
|
+
app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => {
|
|
1985
|
+
try {
|
|
1986
|
+
const { userId, expression } = req.body;
|
|
1987
|
+
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
|
1988
|
+
if (!expression) return res.status(400).json({ error: 'expression is required' });
|
|
1989
|
+
|
|
1990
|
+
const session = sessions.get(normalizeUserId(userId));
|
|
1991
|
+
const found = session && findTab(session, req.params.tabId);
|
|
1992
|
+
if (!found) return res.status(404).json({ error: 'Tab not found' });
|
|
1993
|
+
|
|
1994
|
+
session.lastAccess = Date.now();
|
|
1995
|
+
const { tabState } = found;
|
|
1996
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1997
|
+
|
|
1998
|
+
const result = await tabState.page.evaluate(expression);
|
|
1999
|
+
log('info', 'evaluate', { reqId: req.reqId, tabId: req.params.tabId, userId, resultType: typeof result });
|
|
2000
|
+
res.json({ ok: true, result });
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
log('error', 'evaluate failed', { reqId: req.reqId, error: err.message });
|
|
1404
2003
|
res.status(500).json({ error: safeError(err) });
|
|
1405
2004
|
}
|
|
1406
2005
|
});
|
|
@@ -1412,9 +2011,10 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
1412
2011
|
const session = sessions.get(normalizeUserId(userId));
|
|
1413
2012
|
const found = session && findTab(session, req.params.tabId);
|
|
1414
2013
|
if (found) {
|
|
2014
|
+
await clearTabDownloads(found.tabState);
|
|
1415
2015
|
await safePageClose(found.tabState.page);
|
|
1416
2016
|
found.group.delete(req.params.tabId);
|
|
1417
|
-
tabLocks.delete(req.params.tabId);
|
|
2017
|
+
{ const _l = tabLocks.get(req.params.tabId); if (_l) _l.drain(); tabLocks.delete(req.params.tabId); }
|
|
1418
2018
|
if (found.group.size === 0) {
|
|
1419
2019
|
session.tabGroups.delete(found.listItemId);
|
|
1420
2020
|
}
|
|
@@ -1423,7 +2023,7 @@ app.delete('/tabs/:tabId', async (req, res) => {
|
|
|
1423
2023
|
res.json({ ok: true });
|
|
1424
2024
|
} catch (err) {
|
|
1425
2025
|
log('error', 'tab close failed', { reqId: req.reqId, error: err.message });
|
|
1426
|
-
|
|
2026
|
+
handleRouteError(err, req, res);
|
|
1427
2027
|
}
|
|
1428
2028
|
});
|
|
1429
2029
|
|
|
@@ -1435,6 +2035,7 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
1435
2035
|
const group = session?.tabGroups.get(req.params.listItemId);
|
|
1436
2036
|
if (group) {
|
|
1437
2037
|
for (const [tabId, tabState] of group) {
|
|
2038
|
+
await clearTabDownloads(tabState);
|
|
1438
2039
|
await safePageClose(tabState.page);
|
|
1439
2040
|
tabLocks.delete(tabId);
|
|
1440
2041
|
}
|
|
@@ -1444,7 +2045,7 @@ app.delete('/tabs/group/:listItemId', async (req, res) => {
|
|
|
1444
2045
|
res.json({ ok: true });
|
|
1445
2046
|
} catch (err) {
|
|
1446
2047
|
log('error', 'tab group close failed', { reqId: req.reqId, error: err.message });
|
|
1447
|
-
|
|
2048
|
+
handleRouteError(err, req, res);
|
|
1448
2049
|
}
|
|
1449
2050
|
});
|
|
1450
2051
|
|
|
@@ -1454,6 +2055,7 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
1454
2055
|
const userId = normalizeUserId(req.params.userId);
|
|
1455
2056
|
const session = sessions.get(userId);
|
|
1456
2057
|
if (session) {
|
|
2058
|
+
await clearSessionDownloads(session);
|
|
1457
2059
|
await session.context.close();
|
|
1458
2060
|
sessions.delete(userId);
|
|
1459
2061
|
log('info', 'session closed', { userId });
|
|
@@ -1462,7 +2064,7 @@ app.delete('/sessions/:userId', async (req, res) => {
|
|
|
1462
2064
|
res.json({ ok: true });
|
|
1463
2065
|
} catch (err) {
|
|
1464
2066
|
log('error', 'session close failed', { error: err.message });
|
|
1465
|
-
|
|
2067
|
+
handleRouteError(err, req, res);
|
|
1466
2068
|
}
|
|
1467
2069
|
});
|
|
1468
2070
|
|
|
@@ -1471,6 +2073,7 @@ setInterval(() => {
|
|
|
1471
2073
|
const now = Date.now();
|
|
1472
2074
|
for (const [userId, session] of sessions) {
|
|
1473
2075
|
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
|
|
2076
|
+
clearSessionDownloads(session).catch(() => {});
|
|
1474
2077
|
session.context.close().catch(() => {});
|
|
1475
2078
|
sessions.delete(userId);
|
|
1476
2079
|
log('info', 'session expired', { userId });
|
|
@@ -1482,6 +2085,37 @@ setInterval(() => {
|
|
|
1482
2085
|
}
|
|
1483
2086
|
}, 60_000);
|
|
1484
2087
|
|
|
2088
|
+
// Per-tab inactivity reaper — close tabs idle for TAB_INACTIVITY_MS
|
|
2089
|
+
setInterval(() => {
|
|
2090
|
+
const now = Date.now();
|
|
2091
|
+
for (const [userId, session] of sessions) {
|
|
2092
|
+
for (const [listItemId, group] of session.tabGroups) {
|
|
2093
|
+
for (const [tabId, tabState] of group) {
|
|
2094
|
+
if (!tabState._lastReaperCheck) {
|
|
2095
|
+
tabState._lastReaperCheck = now;
|
|
2096
|
+
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
if (tabState.toolCalls === tabState._lastReaperToolCalls) {
|
|
2100
|
+
const idleMs = now - tabState._lastReaperCheck;
|
|
2101
|
+
if (idleMs >= TAB_INACTIVITY_MS) {
|
|
2102
|
+
log('info', 'tab reaped (inactive)', { userId, tabId, listItemId, idleMs, toolCalls: tabState.toolCalls });
|
|
2103
|
+
safePageClose(tabState.page);
|
|
2104
|
+
group.delete(tabId);
|
|
2105
|
+
{ const _l = tabLocks.get(tabId); if (_l) _l.drain(); tabLocks.delete(tabId); }
|
|
2106
|
+
}
|
|
2107
|
+
} else {
|
|
2108
|
+
tabState._lastReaperCheck = now;
|
|
2109
|
+
tabState._lastReaperToolCalls = tabState.toolCalls;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
if (group.size === 0) {
|
|
2113
|
+
session.tabGroups.delete(listItemId);
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}, 60_000);
|
|
2118
|
+
|
|
1485
2119
|
// =============================================================================
|
|
1486
2120
|
// OpenClaw-compatible endpoint aliases
|
|
1487
2121
|
// These allow camoufox to be used as a profile backend for OpenClaw's browser tool
|
|
@@ -1526,7 +2160,7 @@ app.get('/tabs', async (req, res) => {
|
|
|
1526
2160
|
res.json({ running: true, tabs });
|
|
1527
2161
|
} catch (err) {
|
|
1528
2162
|
log('error', 'list tabs failed', { reqId: req.reqId, error: err.message });
|
|
1529
|
-
|
|
2163
|
+
handleRouteError(err, req, res);
|
|
1530
2164
|
}
|
|
1531
2165
|
});
|
|
1532
2166
|
|
|
@@ -1546,6 +2180,11 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1546
2180
|
|
|
1547
2181
|
const session = await getSession(userId);
|
|
1548
2182
|
|
|
2183
|
+
// Check global tab limit first
|
|
2184
|
+
if (getTotalTabCount() >= MAX_TABS_GLOBAL) {
|
|
2185
|
+
return res.status(429).json({ error: 'Maximum global tabs reached' });
|
|
2186
|
+
}
|
|
2187
|
+
|
|
1549
2188
|
let totalTabs = 0;
|
|
1550
2189
|
for (const g of session.tabGroups.values()) totalTabs += g.size;
|
|
1551
2190
|
if (totalTabs >= MAX_TABS_PER_SESSION) {
|
|
@@ -1557,6 +2196,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1557
2196
|
const page = await session.context.newPage();
|
|
1558
2197
|
const tabId = crypto.randomUUID();
|
|
1559
2198
|
const tabState = createTabState(page);
|
|
2199
|
+
attachDownloadListener(tabState, tabId, log);
|
|
1560
2200
|
group.set(tabId, tabState);
|
|
1561
2201
|
|
|
1562
2202
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
@@ -1572,7 +2212,7 @@ app.post('/tabs/open', async (req, res) => {
|
|
|
1572
2212
|
});
|
|
1573
2213
|
} catch (err) {
|
|
1574
2214
|
log('error', 'openclaw tab open failed', { reqId: req.reqId, error: err.message });
|
|
1575
|
-
|
|
2215
|
+
handleRouteError(err, req, res);
|
|
1576
2216
|
}
|
|
1577
2217
|
});
|
|
1578
2218
|
|
|
@@ -1597,6 +2237,11 @@ app.post('/stop', async (req, res) => {
|
|
|
1597
2237
|
await browser.close().catch(() => {});
|
|
1598
2238
|
browser = null;
|
|
1599
2239
|
}
|
|
2240
|
+
const cleanupTasks = [];
|
|
2241
|
+
for (const session of sessions.values()) {
|
|
2242
|
+
cleanupTasks.push(clearSessionDownloads(session));
|
|
2243
|
+
}
|
|
2244
|
+
await Promise.all(cleanupTasks);
|
|
1600
2245
|
sessions.clear();
|
|
1601
2246
|
res.json({ ok: true, stopped: true, profile: 'camoufox' });
|
|
1602
2247
|
} catch (err) {
|
|
@@ -1625,19 +2270,27 @@ app.post('/navigate', async (req, res) => {
|
|
|
1625
2270
|
}
|
|
1626
2271
|
|
|
1627
2272
|
const { tabState } = found;
|
|
1628
|
-
tabState.toolCalls++;
|
|
2273
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1629
2274
|
|
|
1630
|
-
const result = await
|
|
2275
|
+
const result = await withTabLock(targetId, async () => {
|
|
1631
2276
|
await tabState.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1632
2277
|
tabState.visitedUrls.add(url);
|
|
2278
|
+
tabState.lastSnapshot = null;
|
|
2279
|
+
|
|
2280
|
+
// Google SERP: defer extraction to snapshot call
|
|
2281
|
+
if (isGoogleSerp(tabState.page.url())) {
|
|
2282
|
+
tabState.refs = new Map();
|
|
2283
|
+
return { ok: true, targetId, url: tabState.page.url(), googleSerp: true };
|
|
2284
|
+
}
|
|
2285
|
+
|
|
1633
2286
|
tabState.refs = await buildRefs(tabState.page);
|
|
1634
2287
|
return { ok: true, targetId, url: tabState.page.url() };
|
|
1635
|
-
})
|
|
2288
|
+
});
|
|
1636
2289
|
|
|
1637
2290
|
res.json(result);
|
|
1638
2291
|
} catch (err) {
|
|
1639
2292
|
log('error', 'openclaw navigate failed', { reqId: req.reqId, error: err.message });
|
|
1640
|
-
|
|
2293
|
+
handleRouteError(err, req, res);
|
|
1641
2294
|
}
|
|
1642
2295
|
});
|
|
1643
2296
|
|
|
@@ -1657,7 +2310,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1657
2310
|
}
|
|
1658
2311
|
|
|
1659
2312
|
const { tabState } = found;
|
|
1660
|
-
tabState.toolCalls++;
|
|
2313
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1661
2314
|
|
|
1662
2315
|
// Cached chunk retrieval
|
|
1663
2316
|
if (offset > 0 && tabState.lastSnapshot) {
|
|
@@ -1670,6 +2323,28 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1670
2323
|
return res.json(response);
|
|
1671
2324
|
}
|
|
1672
2325
|
|
|
2326
|
+
const pageUrl = tabState.page.url();
|
|
2327
|
+
|
|
2328
|
+
// Google SERP fast path
|
|
2329
|
+
if (isGoogleSerp(pageUrl)) {
|
|
2330
|
+
const { refs: googleRefs, snapshot: googleSnapshot } = await extractGoogleSerp(tabState.page);
|
|
2331
|
+
tabState.refs = googleRefs;
|
|
2332
|
+
tabState.lastSnapshot = googleSnapshot;
|
|
2333
|
+
const annotatedYaml = googleSnapshot;
|
|
2334
|
+
const win = windowSnapshot(annotatedYaml, 0);
|
|
2335
|
+
const response = {
|
|
2336
|
+
ok: true, format: 'aria', targetId, url: pageUrl,
|
|
2337
|
+
snapshot: win.text, refsCount: tabState.refs.size,
|
|
2338
|
+
truncated: win.truncated, totalChars: win.totalChars,
|
|
2339
|
+
hasMore: win.hasMore, nextOffset: win.nextOffset,
|
|
2340
|
+
};
|
|
2341
|
+
if (req.query.includeScreenshot === 'true') {
|
|
2342
|
+
const pngBuffer = await tabState.page.screenshot({ type: 'png' });
|
|
2343
|
+
response.screenshot = { data: pngBuffer.toString('base64'), mimeType: 'image/png' };
|
|
2344
|
+
}
|
|
2345
|
+
return res.json(response);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
1673
2348
|
tabState.refs = await buildRefs(tabState.page);
|
|
1674
2349
|
|
|
1675
2350
|
const ariaYaml = await getAriaSnapshot(tabState.page);
|
|
@@ -1722,7 +2397,7 @@ app.get('/snapshot', async (req, res) => {
|
|
|
1722
2397
|
res.json(response);
|
|
1723
2398
|
} catch (err) {
|
|
1724
2399
|
log('error', 'openclaw snapshot failed', { reqId: req.reqId, error: err.message });
|
|
1725
|
-
|
|
2400
|
+
handleRouteError(err, req, res);
|
|
1726
2401
|
}
|
|
1727
2402
|
});
|
|
1728
2403
|
|
|
@@ -1746,9 +2421,9 @@ app.post('/act', async (req, res) => {
|
|
|
1746
2421
|
}
|
|
1747
2422
|
|
|
1748
2423
|
const { tabState } = found;
|
|
1749
|
-
tabState.toolCalls++;
|
|
2424
|
+
tabState.toolCalls++; tabState.consecutiveTimeouts = 0;
|
|
1750
2425
|
|
|
1751
|
-
const result = await
|
|
2426
|
+
const result = await withTabLock(targetId, async () => {
|
|
1752
2427
|
switch (kind) {
|
|
1753
2428
|
case 'click': {
|
|
1754
2429
|
const { ref, selector, doubleClick } = params;
|
|
@@ -1758,7 +2433,7 @@ app.post('/act', async (req, res) => {
|
|
|
1758
2433
|
|
|
1759
2434
|
const doClick = async (locatorOrSelector, isLocator) => {
|
|
1760
2435
|
const locator = isLocator ? locatorOrSelector : tabState.page.locator(locatorOrSelector);
|
|
1761
|
-
const clickOpts = { timeout:
|
|
2436
|
+
const clickOpts = { timeout: 3000 };
|
|
1762
2437
|
if (doubleClick) clickOpts.clickCount = 2;
|
|
1763
2438
|
|
|
1764
2439
|
try {
|
|
@@ -1773,8 +2448,13 @@ app.post('/act', async (req, res) => {
|
|
|
1773
2448
|
};
|
|
1774
2449
|
|
|
1775
2450
|
if (ref) {
|
|
1776
|
-
|
|
1777
|
-
if (!locator)
|
|
2451
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2452
|
+
if (!locator) {
|
|
2453
|
+
log('info', 'auto-refreshing refs before click (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
2454
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2455
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2456
|
+
}
|
|
2457
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1778
2458
|
await doClick(locator, true);
|
|
1779
2459
|
} else {
|
|
1780
2460
|
await doClick(selector, false);
|
|
@@ -1795,8 +2475,13 @@ app.post('/act', async (req, res) => {
|
|
|
1795
2475
|
}
|
|
1796
2476
|
|
|
1797
2477
|
if (ref) {
|
|
1798
|
-
|
|
1799
|
-
if (!locator)
|
|
2478
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2479
|
+
if (!locator) {
|
|
2480
|
+
log('info', 'auto-refreshing refs before type (openclaw)', { ref, hadRefs: tabState.refs.size });
|
|
2481
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2482
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2483
|
+
}
|
|
2484
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1800
2485
|
await locator.fill(text, { timeout: 10000 });
|
|
1801
2486
|
if (submit) await tabState.page.keyboard.press('Enter');
|
|
1802
2487
|
} else {
|
|
@@ -1817,8 +2502,12 @@ app.post('/act', async (req, res) => {
|
|
|
1817
2502
|
case 'scrollIntoView': {
|
|
1818
2503
|
const { ref, direction = 'down', amount = 500 } = params;
|
|
1819
2504
|
if (ref) {
|
|
1820
|
-
|
|
1821
|
-
if (!locator)
|
|
2505
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2506
|
+
if (!locator) {
|
|
2507
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2508
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2509
|
+
}
|
|
2510
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1822
2511
|
await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
|
1823
2512
|
} else {
|
|
1824
2513
|
const delta = direction === 'up' ? -amount : amount;
|
|
@@ -1833,8 +2522,12 @@ app.post('/act', async (req, res) => {
|
|
|
1833
2522
|
if (!ref && !selector) throw new Error('ref or selector required');
|
|
1834
2523
|
|
|
1835
2524
|
if (ref) {
|
|
1836
|
-
|
|
1837
|
-
if (!locator)
|
|
2525
|
+
let locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2526
|
+
if (!locator) {
|
|
2527
|
+
tabState.refs = await buildRefs(tabState.page);
|
|
2528
|
+
locator = refToLocator(tabState.page, ref, tabState.refs);
|
|
2529
|
+
}
|
|
2530
|
+
if (!locator) { const maxRef = tabState.refs.size > 0 ? `e${tabState.refs.size}` : 'none'; throw new StaleRefsError(ref, maxRef, tabState.refs.size); }
|
|
1838
2531
|
await locator.hover({ timeout: 5000 });
|
|
1839
2532
|
} else {
|
|
1840
2533
|
await tabState.page.locator(selector).hover({ timeout: 5000 });
|
|
@@ -1857,19 +2550,19 @@ app.post('/act', async (req, res) => {
|
|
|
1857
2550
|
case 'close': {
|
|
1858
2551
|
await safePageClose(tabState.page);
|
|
1859
2552
|
found.group.delete(targetId);
|
|
1860
|
-
tabLocks.delete(targetId);
|
|
2553
|
+
{ const _l = tabLocks.get(targetId); if (_l) _l.drain(); tabLocks.delete(targetId); }
|
|
1861
2554
|
return { ok: true, targetId };
|
|
1862
2555
|
}
|
|
1863
2556
|
|
|
1864
2557
|
default:
|
|
1865
2558
|
throw new Error(`Unsupported action kind: ${kind}`);
|
|
1866
2559
|
}
|
|
1867
|
-
})
|
|
2560
|
+
});
|
|
1868
2561
|
|
|
1869
2562
|
res.json(result);
|
|
1870
2563
|
} catch (err) {
|
|
1871
2564
|
log('error', 'act failed', { reqId: req.reqId, kind: req.body?.kind, error: err.message });
|
|
1872
|
-
|
|
2565
|
+
handleRouteError(err, req, res);
|
|
1873
2566
|
}
|
|
1874
2567
|
});
|
|
1875
2568
|
|
|
@@ -1895,14 +2588,20 @@ setInterval(() => {
|
|
|
1895
2588
|
// Active health probe — detect hung browser even when isConnected() lies
|
|
1896
2589
|
setInterval(async () => {
|
|
1897
2590
|
if (!browser || healthState.isRecovering) return;
|
|
1898
|
-
|
|
1899
|
-
if
|
|
2591
|
+
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
2592
|
+
// Skip probe if operations are in flight AND last success was recent.
|
|
2593
|
+
// If it's been >120s since any successful operation, probe anyway —
|
|
2594
|
+
// active ops are likely stuck on a frozen browser and will time out eventually.
|
|
2595
|
+
if (healthState.activeOps > 0 && timeSinceSuccess < 120000) {
|
|
1900
2596
|
log('info', 'health probe skipped, operations active', { activeOps: healthState.activeOps });
|
|
1901
2597
|
return;
|
|
1902
2598
|
}
|
|
1903
|
-
const timeSinceSuccess = Date.now() - healthState.lastSuccessfulNav;
|
|
1904
2599
|
if (timeSinceSuccess < 120000) return;
|
|
1905
2600
|
|
|
2601
|
+
if (healthState.activeOps > 0) {
|
|
2602
|
+
log('warn', 'health probe forced despite active ops', { activeOps: healthState.activeOps, timeSinceSuccessMs: timeSinceSuccess });
|
|
2603
|
+
}
|
|
2604
|
+
|
|
1906
2605
|
let testContext;
|
|
1907
2606
|
try {
|
|
1908
2607
|
testContext = await browser.newContext();
|
|
@@ -1954,9 +2653,16 @@ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
|
1954
2653
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
1955
2654
|
|
|
1956
2655
|
const PORT = CONFIG.port;
|
|
1957
|
-
const server = app.listen(PORT, () => {
|
|
2656
|
+
const server = app.listen(PORT, async () => {
|
|
1958
2657
|
log('info', 'server started', { port: PORT, pid: process.pid, nodeVersion: process.version });
|
|
1959
|
-
//
|
|
2658
|
+
// Pre-warm browser so first request doesn't eat a 6-7s cold start
|
|
2659
|
+
try {
|
|
2660
|
+
const start = Date.now();
|
|
2661
|
+
await ensureBrowser();
|
|
2662
|
+
log('info', 'browser pre-warmed', { ms: Date.now() - start });
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
log('error', 'browser pre-warm failed (will retry on first request)', { error: err.message });
|
|
2665
|
+
}
|
|
1960
2666
|
});
|
|
1961
2667
|
|
|
1962
2668
|
server.on('error', (err) => {
|