neoagent 3.0.1-beta.27 → 3.0.1-beta.29

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.
@@ -62,6 +62,23 @@ when no reply is needed.
62
62
  Prefer official integrations or MCP tools over browser automation when both
63
63
  can perform the action. Structured tools are easier to restrict and diagnose.
64
64
 
65
+ ## Bot-protected pages
66
+
67
+ The isolated VM browser uses native anti-detection hardening inspired by
68
+ Botasaurus' public anti-detect browser patterns: stable browser profiles,
69
+ common desktop viewport sizes, less mechanical pointer movement, referrer-aware
70
+ navigation, and challenge detection telemetry. NeoAgent does not embed or
71
+ depend on Botasaurus for this path.
72
+
73
+ `browser_navigate` accepts optional `referrerMode` values of `direct`,
74
+ `google`, or `current`. By default it also retries once with a Google referrer
75
+ when a known bot challenge is detected. Browser results can include
76
+ `botDetection` so runs can report that a page is blocked instead of treating the
77
+ challenge page as ordinary content.
78
+
79
+ Browser automation still must comply with the target site's terms and applicable
80
+ law. URL safety checks and the VM device-access restrictions remain in effect.
81
+
65
82
  ## Runs and delivery
66
83
 
67
84
  Open **Runs** to inspect the trigger, tool calls, approvals, output, and error
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.1-beta.27",
3
+ "version": "3.0.1-beta.29",
4
4
  "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
@@ -1 +1 @@
1
- 802b144119e4d7e88f089829dde8e27b
1
+ 8b097147c5ec8ac3bee082f22bd5d248
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"a10d8ac38de835021c8d2f920dbf50a920ccc0
37
37
 
38
38
  _flutter.loader.load({
39
39
  serviceWorkerSettings: {
40
- serviceWorkerVersion: "3226159482" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
40
+ serviceWorkerVersion: "1039759445" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
41
41
  }
42
42
  });
@@ -138719,7 +138719,7 @@ r===$&&A.b()
138719
138719
  p.push(A.iU(q,A.ju(!1,new A.Y(B.vr,A.cN(new A.cA(B.jU,new A.a94(r,q),q),q,q),q),!1,B.I,!0),q,q,0,0,0,q))}r=!1
138720
138720
  if(!s.ay)if(!s.ch){r=s.e
138721
138721
  r===$&&A.b()
138722
- r=B.b.t("mqv0w1e9-4502c91").length!==0&&r.b}if(r){r=s.d
138722
+ r=B.b.t("mr0924tg-6e8948b").length!==0&&r.b}if(r){r=s.d
138723
138723
  r===$&&A.b()
138724
138724
  r=r.aD&&!r.av?84:0
138725
138725
  s=s.e
@@ -145015,7 +145015,7 @@ $S:0}
145015
145015
  A.a0g.prototype={}
145016
145016
  A.U0.prototype={
145017
145017
  nm(a){var s=this
145018
- if(B.b.t("mqv0w1e9-4502c91").length===0||s.a!=null)return
145018
+ if(B.b.t("mr0924tg-6e8948b").length===0||s.a!=null)return
145019
145019
  s.Bg()
145020
145020
  s.a=A.m4(B.Td,new A.bgn(s))},
145021
145021
  Bg(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f
@@ -145033,7 +145033,7 @@ if(!t.f.b(k)){s=1
145033
145033
  break}i=J.a2(k,"buildId")
145034
145034
  h=i==null?null:B.b.t(J.p(i))
145035
145035
  j=h==null?"":h
145036
- if(J.bd(j)===0||J.f(j,"mqv0w1e9-4502c91")){s=1
145036
+ if(J.bd(j)===0||J.f(j,"mr0924tg-6e8948b")){s=1
145037
145037
  break}n.b=!0
145038
145038
  n.E()
145039
145039
  p=2
@@ -145050,7 +145050,7 @@ case 2:return A.i(o.at(-1),r)}})
145050
145050
  return A.k($async$Bg,r)},
145051
145051
  vV(){var s=0,r=A.l(t.H),q,p=2,o=[],n=this,m,l,k,j,i,h,g,f,e,d,c,b,a,a0,a1
145052
145052
  var $async$vV=A.h(function(a2,a3){if(a2===1){o.push(a3)
145053
- s=p}for(;;)switch(s){case 0:if(B.b.t("mqv0w1e9-4502c91").length===0||n.c){s=1
145053
+ s=p}for(;;)switch(s){case 0:if(B.b.t("mr0924tg-6e8948b").length===0||n.c){s=1
145054
145054
  break}n.c=!0
145055
145055
  n.E()
145056
145056
  p=4
@@ -103,7 +103,14 @@ router.post('/navigate', async (req, res) => {
103
103
  return res.status(403).json({ error: 'This URL is not permitted.' });
104
104
  }
105
105
 
106
- const result = await bc.navigate(url, { waitUntil: waitFor || 'domcontentloaded' });
106
+ const result = await bc.navigate(url, {
107
+ waitUntil: waitFor || req.body?.waitUntil || 'domcontentloaded',
108
+ waitFor,
109
+ screenshot: req.body?.screenshot !== false,
110
+ fullPage: req.body?.fullPage === true,
111
+ referrerMode: req.body?.referrerMode,
112
+ challengeRetry: req.body?.challengeRetry,
113
+ });
107
114
  res.json(result);
108
115
  } catch (err) {
109
116
  res.status(500).json({ error: sanitizeError(err) });
@@ -9,6 +9,13 @@ const _reservations = new Map();
9
9
  const DEFAULT_RATE_LIMIT_4H = 2_500_000;
10
10
  const DEFAULT_RATE_LIMIT_WEEKLY = 10_000_000;
11
11
 
12
+ // Generous upper bound on a single run's token cost. Used to size the in-flight
13
+ // reservation that keeps concurrent run starts from collectively bypassing the
14
+ // per-user budget. It must NOT be the full limit — doing so would reserve the
15
+ // entire budget for one run and reject any second concurrent run for the same
16
+ // user (e.g. a scheduled task firing in the same minute as another).
17
+ const DEFAULT_RUN_RESERVATION_TOKENS = 500_000;
18
+
12
19
  const WINDOWS = {
13
20
  fourHour: {
14
21
  durationMs: 4 * 60 * 60 * 1000,
@@ -144,10 +151,22 @@ function enforceRateLimits(userId) {
144
151
  throw new RateLimitExceededError('weekly', snapshot);
145
152
  }
146
153
  // Reserve a placeholder so concurrent starts see this run as in-flight.
147
- // The reservation is released (or reconciled) when the run completes.
154
+ // The reservation is released (or reconciled) when the run completes. Size it
155
+ // to a realistic per-run estimate — never the full limit — so concurrent runs
156
+ // for the same user are admitted normally and only a genuinely near-budget
157
+ // user gets throttled. Clamp so the reservation can never by itself exceed a
158
+ // (possibly small/custom) limit.
148
159
  const key = String(userId);
149
160
  const limits = snapshot.limits;
150
- const reserve = Math.max(limits.fourHour || 0, limits.weekly || 0, 1);
161
+ const estimate = parsePositiveInteger(
162
+ process.env.NEOAGENT_RUN_RESERVATION_TOKENS,
163
+ DEFAULT_RUN_RESERVATION_TOKENS,
164
+ );
165
+ const cap = Math.min(
166
+ limits.fourHour == null ? Infinity : limits.fourHour,
167
+ limits.weekly == null ? Infinity : limits.weekly,
168
+ );
169
+ const reserve = Math.max(1, Math.min(estimate, Number.isFinite(cap) ? cap : estimate));
151
170
  _reservations.set(key, (_reservations.get(key) || 0) + reserve);
152
171
  return { snapshot, releaseReservation: () => releaseReservation(userId, reserve) };
153
172
  }
@@ -437,7 +437,9 @@ function getAvailableTools(app, options = {}) {
437
437
  url: { type: 'string', description: 'URL to navigate to' },
438
438
  screenshot: { type: 'boolean', description: 'Take a screenshot (default true)' },
439
439
  waitFor: { type: 'string', description: 'CSS selector to wait for' },
440
- fullPage: { type: 'boolean', description: 'Full page screenshot (default false)' }
440
+ fullPage: { type: 'boolean', description: 'Full page screenshot (default false)' },
441
+ referrerMode: { type: 'string', enum: ['direct', 'google', 'current'], description: 'Navigation referrer strategy for the VM browser (default direct). google sends a Google referrer; current navigates from the current page when possible.' },
442
+ challengeRetry: { type: 'boolean', description: 'Retry once with a Google referrer if a known bot challenge is detected (default true).' }
441
443
  },
442
444
  required: ['url']
443
445
  }
@@ -1787,7 +1789,9 @@ async function executeTool(toolName, args, context, engine) {
1787
1789
  return { ...await provider.navigate(args.url, {
1788
1790
  screenshot: args.screenshot !== false,
1789
1791
  waitFor: args.waitFor,
1790
- fullPage: args.fullPage
1792
+ fullPage: args.fullPage,
1793
+ referrerMode: args.referrerMode,
1794
+ challengeRetry: args.challengeRetry
1791
1795
  }), backend };
1792
1796
  }
1793
1797
 
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const USER_AGENTS = Object.freeze([
6
+ {
7
+ ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
8
+ platform: 'Win32',
9
+ webglVendor: 'Google Inc. (NVIDIA)',
10
+ webglRenderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Direct3D11 vs_5_0 ps_5_0, D3D11)',
11
+ },
12
+ {
13
+ ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
14
+ platform: 'Win32',
15
+ webglVendor: 'Google Inc. (Intel)',
16
+ webglRenderer: 'ANGLE (Intel, Intel(R) UHD Graphics 770 Direct3D11 vs_5_0 ps_5_0, D3D11)',
17
+ },
18
+ {
19
+ ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
20
+ platform: 'MacIntel',
21
+ webglVendor: 'Google Inc. (Apple)',
22
+ webglRenderer: 'ANGLE (Apple, Apple M2, OpenGL 4.1)',
23
+ },
24
+ {
25
+ ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
26
+ platform: 'MacIntel',
27
+ webglVendor: 'Google Inc. (Apple)',
28
+ webglRenderer: 'ANGLE (Apple, Apple M1, OpenGL 4.1)',
29
+ },
30
+ {
31
+ ua: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
32
+ platform: 'Linux x86_64',
33
+ webglVendor: 'Google Inc. (Intel)',
34
+ webglRenderer: 'ANGLE (Intel, Mesa Intel(R) UHD Graphics 620, OpenGL 4.6)',
35
+ },
36
+ ]);
37
+
38
+ const VIEWPORTS = Object.freeze([
39
+ { width: 1920, height: 1080, weight: 35 },
40
+ { width: 1366, height: 768, weight: 26 },
41
+ { width: 1536, height: 864, weight: 16 },
42
+ { width: 1280, height: 720, weight: 9 },
43
+ { width: 1440, height: 900, weight: 9 },
44
+ { width: 1600, height: 900, weight: 5 },
45
+ ]);
46
+
47
+ const REFERRER_MODES = new Set(['direct', 'google', 'current']);
48
+
49
+ function hashInt(value) {
50
+ const hash = crypto.createHash('sha256').update(String(value || 'default')).digest();
51
+ return hash.readUInt32BE(0);
52
+ }
53
+
54
+ function chooseWeighted(items, seed) {
55
+ const total = items.reduce((sum, item) => sum + Number(item.weight || 1), 0);
56
+ let cursor = Math.abs(Number(seed) || 0) % total;
57
+ for (const item of items) {
58
+ cursor -= Number(item.weight || 1);
59
+ if (cursor < 0) return item;
60
+ }
61
+ return items[0];
62
+ }
63
+
64
+ function chooseBrowserIdentity(profileKey) {
65
+ const seed = hashInt(profileKey);
66
+ const profile = USER_AGENTS[seed % USER_AGENTS.length];
67
+ const viewport = chooseWeighted(VIEWPORTS, seed >>> 3);
68
+ return {
69
+ userAgent: profile.ua,
70
+ platform: profile.platform,
71
+ viewport: { width: viewport.width, height: viewport.height },
72
+ webglVendor: profile.webglVendor,
73
+ webglRenderer: profile.webglRenderer,
74
+ hardwareConcurrency: [4, 6, 8, 12, 16][seed % 5],
75
+ deviceMemory: [4, 8, 16][(seed >>> 5) % 3],
76
+ };
77
+ }
78
+
79
+ function normalizeReferrerMode(value) {
80
+ const normalized = String(value || '').trim().toLowerCase();
81
+ return REFERRER_MODES.has(normalized) ? normalized : 'direct';
82
+ }
83
+
84
+ function normalizeChallengeRetry(value) {
85
+ return value !== false;
86
+ }
87
+
88
+ function detectBotChallenge(input = {}) {
89
+ const title = String(input.title || '').trim();
90
+ const url = String(input.url || '').trim();
91
+ const html = String(input.html || '');
92
+ const text = String(input.text || input.pageContent || '');
93
+ const haystack = `${title}\n${url}\n${html.slice(0, 200000)}\n${text.slice(0, 50000)}`.toLowerCase();
94
+
95
+ if (title === 'Just a moment...' || haystack.includes('cf-turnstile-response') || haystack.includes('/cdn-cgi/challenge-platform/')) {
96
+ return { detected: true, provider: 'cloudflare' };
97
+ }
98
+ if (haystack.includes('please verify you are a human') || haystack.includes('press & hold to confirm')) {
99
+ return { detected: true, provider: 'perimeterx' };
100
+ }
101
+ if (haystack.includes('datadome') || haystack.includes('geo.captcha-delivery.com')) {
102
+ return { detected: true, provider: 'datadome' };
103
+ }
104
+ if (
105
+ haystack.includes('unusual traffic')
106
+ || haystack.includes('are you a robot')
107
+ || haystack.includes('verify that you are human')
108
+ || haystack.includes('complete the security check')
109
+ ) {
110
+ return { detected: true, provider: 'generic' };
111
+ }
112
+ return { detected: false, provider: null };
113
+ }
114
+
115
+ function rand(min, max) {
116
+ return Math.floor(Math.random() * (max - min + 1)) + min;
117
+ }
118
+
119
+ function easeInOutQuad(t) {
120
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
121
+ }
122
+
123
+ function easeOutQuad(t) {
124
+ return 1 - (1 - t) * (1 - t);
125
+ }
126
+
127
+ function easeInOutSine(t) {
128
+ return -(Math.cos(Math.PI * t) - 1) / 2;
129
+ }
130
+
131
+ function bezierPoint(points, t) {
132
+ let current = points.map((point) => ({ x: point.x, y: point.y }));
133
+ while (current.length > 1) {
134
+ current = current.slice(0, -1).map((point, index) => ({
135
+ x: point.x + (current[index + 1].x - point.x) * t,
136
+ y: point.y + (current[index + 1].y - point.y) * t,
137
+ }));
138
+ }
139
+ return current[0];
140
+ }
141
+
142
+ function generateHumanMousePath(from, to, viewport = {}) {
143
+ const start = {
144
+ x: Number.isFinite(Number(from?.x)) ? Number(from.x) : 0,
145
+ y: Number.isFinite(Number(from?.y)) ? Number(from.y) : 0,
146
+ };
147
+ const end = {
148
+ x: Number.isFinite(Number(to?.x)) ? Number(to.x) : 0,
149
+ y: Number.isFinite(Number(to?.y)) ? Number(to.y) : 0,
150
+ };
151
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
152
+ if (distance < 2) return [start, end];
153
+
154
+ const width = Math.max(1, Number(viewport.width || 1440));
155
+ const height = Math.max(1, Number(viewport.height || 900));
156
+ const steps = Math.max(12, Math.min(90, Math.round(18 + distance / 18 + rand(-5, 8))));
157
+ const boundaryX = Math.min(90, Math.max(10, distance * 0.12));
158
+ const boundaryY = Math.min(90, Math.max(10, distance * 0.12));
159
+ const controlCount = rand(1, 3);
160
+ const controls = [];
161
+ for (let i = 1; i <= controlCount; i += 1) {
162
+ const ratio = i / (controlCount + 1);
163
+ controls.push({
164
+ x: Math.max(0, Math.min(width, start.x + (end.x - start.x) * ratio + rand(-boundaryX, boundaryX))),
165
+ y: Math.max(0, Math.min(height, start.y + (end.y - start.y) * ratio + rand(-boundaryY, boundaryY))),
166
+ });
167
+ }
168
+
169
+ const tweens = [easeInOutQuad, easeOutQuad, easeInOutSine];
170
+ const tween = tweens[rand(0, tweens.length - 1)];
171
+ const curve = [start, ...controls, end];
172
+ const path = [];
173
+ for (let i = 0; i < steps; i += 1) {
174
+ const point = bezierPoint(curve, tween(i / (steps - 1)));
175
+ path.push({
176
+ x: Math.round(Math.max(0, Math.min(width, point.x))),
177
+ y: Math.round(Math.max(0, Math.min(height, point.y + (i > 0 && i < steps - 1 ? rand(-1, 1) : 0)))),
178
+ });
179
+ }
180
+ path[0] = { x: Math.round(start.x), y: Math.round(start.y) };
181
+ path[path.length - 1] = { x: Math.round(end.x), y: Math.round(end.y) };
182
+ return path;
183
+ }
184
+
185
+ module.exports = {
186
+ chooseBrowserIdentity,
187
+ detectBotChallenge,
188
+ generateHumanMousePath,
189
+ normalizeChallengeRetry,
190
+ normalizeReferrerMode,
191
+ rand,
192
+ };
@@ -2,6 +2,14 @@ const path = require('path');
2
2
  const fs = require('fs');
3
3
  const { spawn } = require('child_process');
4
4
  const { DATA_DIR } = require('../../../runtime/paths');
5
+ const {
6
+ chooseBrowserIdentity,
7
+ detectBotChallenge,
8
+ generateHumanMousePath,
9
+ normalizeChallengeRetry,
10
+ normalizeReferrerMode,
11
+ rand,
12
+ } = require('./anti_detection');
5
13
 
6
14
  const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
7
15
  if (!fs.existsSync(SCREENSHOTS_DIR)) fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true });
@@ -9,21 +17,6 @@ const BROWSER_PROFILE_ROOT = path.join(DATA_DIR, 'browser-profiles');
9
17
  if (!fs.existsSync(BROWSER_PROFILE_ROOT)) fs.mkdirSync(BROWSER_PROFILE_ROOT, { recursive: true });
10
18
  const BROWSER_READY_MARKER = '/var/lib/neoagent/browser-runtime-ready';
11
19
 
12
- const USER_AGENTS = [
13
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
14
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
15
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
16
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
17
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
18
- ];
19
-
20
- const VIEWPORTS = [
21
- { width: 1280, height: 800 },
22
- { width: 1366, height: 768 },
23
- { width: 1440, height: 900 },
24
- { width: 1920, height: 1080 },
25
- ];
26
-
27
20
  // Injected into every page in the cloud VM browser context to deny local device access.
28
21
  const DEVICE_DENY_SCRIPT = `(() => {
29
22
  const denied = () => Promise.reject(Object.assign(
@@ -166,10 +159,6 @@ function installPlaywrightBrowserBinary(browserName) {
166
159
  });
167
160
  }
168
161
 
169
- function rand(min, max) {
170
- return Math.floor(Math.random() * (max - min + 1)) + min;
171
- }
172
-
173
162
  function sleep(ms) {
174
163
  return new Promise(r => setTimeout(r, ms));
175
164
  }
@@ -241,10 +230,15 @@ class BrowserController {
241
230
  this.launchPromise = null;
242
231
  this.browserBinaryInstallPromise = null;
243
232
  this.headless = false;
244
- this._viewport = VIEWPORTS[0];
245
- this._userAgent = USER_AGENTS[0];
246
233
  this.profileDir = path.join(BROWSER_PROFILE_ROOT, this.userId || 'default');
247
234
  if (!fs.existsSync(this.profileDir)) fs.mkdirSync(this.profileDir, { recursive: true });
235
+ this._identity = chooseBrowserIdentity(this.userId || this.profileDir);
236
+ this._viewport = this._identity.viewport;
237
+ this._userAgent = this._identity.userAgent;
238
+ this._mousePosition = {
239
+ x: Math.round(this._viewport.width / 2),
240
+ y: Math.round(this._viewport.height / 2),
241
+ };
248
242
  }
249
243
 
250
244
  async setHeadless(val) {
@@ -260,6 +254,7 @@ class BrowserController {
260
254
  async _applyStealthToPage(page) {
261
255
  const ua = this._userAgent;
262
256
  const vp = this._viewport;
257
+ const identity = this._identity || chooseBrowserIdentity(this.userId || this.profileDir);
263
258
 
264
259
  if (typeof page.setUserAgent === 'function') {
265
260
  await page.setUserAgent(ua);
@@ -281,9 +276,9 @@ class BrowserController {
281
276
 
282
277
  // Realistic language/platform
283
278
  Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
284
- Object.defineProperty(navigator, 'platform', { get: () => 'MacIntel' });
285
- Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => ${rand(4, 16)} });
286
- Object.defineProperty(navigator, 'deviceMemory', { get: () => ${[4, 8, 16][rand(0, 2)]} });
279
+ Object.defineProperty(navigator, 'platform', { get: () => ${JSON.stringify(identity.platform)} });
280
+ Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => ${Number(identity.hardwareConcurrency) || 8} });
281
+ Object.defineProperty(navigator, 'deviceMemory', { get: () => ${Number(identity.deviceMemory) || 8} });
287
282
 
288
283
  // Make it look like a real Chrome install
289
284
  window.chrome = {
@@ -323,9 +318,9 @@ class BrowserController {
323
318
  apply: function(target, ctx, args) {
324
319
  const param = args[0];
325
320
  // UNMASKED_VENDOR_WEBGL
326
- if (param === 37445) return 'Google Inc. (Apple)';
321
+ if (param === 37445) return ${JSON.stringify(identity.webglVendor)};
327
322
  // UNMASKED_RENDERER_WEBGL
328
- if (param === 37446) return 'ANGLE (Apple, Apple M2, OpenGL 4.1)';
323
+ if (param === 37446) return ${JSON.stringify(identity.webglRenderer)};
329
324
  return Reflect.apply(target, ctx, args);
330
325
  }
331
326
  };
@@ -388,8 +383,13 @@ class BrowserController {
388
383
  }
389
384
  await this.ensureVirtualDisplay();
390
385
 
391
- this._userAgent = USER_AGENTS[rand(0, USER_AGENTS.length - 1)];
392
- this._viewport = VIEWPORTS[rand(0, VIEWPORTS.length - 1)];
386
+ this._identity = chooseBrowserIdentity(this.userId || this.profileDir);
387
+ this._userAgent = this._identity.userAgent;
388
+ this._viewport = this._identity.viewport;
389
+ this._mousePosition = {
390
+ x: Math.round(this._viewport.width / 2),
391
+ y: Math.round(this._viewport.height / 2),
392
+ };
393
393
 
394
394
  let executablePath = resolveBrowserExecutablePath();
395
395
  if (!executablePath) {
@@ -414,18 +414,28 @@ class BrowserController {
414
414
  };
415
415
 
416
416
  const launchArgs = [
417
+ '--start-maximized',
418
+ '--remote-allow-origins=*',
417
419
  '--no-sandbox',
418
420
  '--disable-setuid-sandbox',
419
421
  '--disable-dev-shm-usage',
422
+ '--no-service-autorun',
420
423
  '--disable-crash-reporter',
424
+ '--disable-breakpad',
421
425
  '--disable-background-networking',
422
426
  '--disable-component-update',
423
427
  '--disable-blink-features=AutomationControlled',
424
428
  '--disable-infobars',
429
+ '--disable-session-crashed-bubble',
430
+ '--disable-search-engine-choice-screen',
425
431
  '--no-first-run',
426
432
  '--no-default-browser-check',
433
+ '--homepage=about:blank',
434
+ '--no-pings',
435
+ '--password-store=basic',
427
436
  '--disable-gpu',
428
437
  '--lang=en-US,en',
438
+ `--user-agent=${this._userAgent}`,
429
439
  `--window-size=${this._viewport.width},${this._viewport.height}`,
430
440
  // Cloud security: disable hardware device APIs at the Chromium level
431
441
  '--disable-features=WebBluetooth,WebUSB,WebSerial,WebOTP,DirectSockets',
@@ -440,6 +450,8 @@ class BrowserController {
440
450
  env: launchEnv,
441
451
  args: launchArgs,
442
452
  viewport: this._viewport,
453
+ userAgent: this._userAgent,
454
+ locale: 'en-US',
443
455
  ignoreHTTPSErrors: false,
444
456
  timeout: 120000,
445
457
  });
@@ -535,13 +547,48 @@ class BrowserController {
535
547
  return page.screenshot(screenshotOptions);
536
548
  }
537
549
 
550
+ async _navigatePage(page, url, options = {}) {
551
+ const referrerMode = normalizeReferrerMode(options.referrerMode);
552
+ const waitUntil = normalizeWaitUntil(options.waitUntil);
553
+ if (referrerMode === 'current' && page.url() && page.url() !== 'about:blank') {
554
+ const previousUrl = page.url();
555
+ await page.evaluate((nextUrl) => { window.location.href = nextUrl; }, url);
556
+ await page.waitForFunction((oldUrl) => window.location.href !== oldUrl, previousUrl, { timeout: 10000 }).catch(() => {});
557
+ await page.waitForLoadState(waitUntil, { timeout: 30000 }).catch(() => {});
558
+ return null;
559
+ }
560
+
561
+ const gotoOptions = {
562
+ waitUntil,
563
+ timeout: 30000,
564
+ };
565
+ if (referrerMode === 'google') {
566
+ gotoOptions.referer = 'https://www.google.com/';
567
+ } else if (referrerMode === 'current' && page.url() && page.url() !== 'about:blank') {
568
+ gotoOptions.referer = page.url();
569
+ }
570
+ return page.goto(url, gotoOptions);
571
+ }
572
+
573
+ async _getBotDetection(page, rawHtml, pageContent) {
574
+ const title = await page.title().catch(() => '');
575
+ return detectBotChallenge({
576
+ title,
577
+ url: page.url(),
578
+ html: rawHtml,
579
+ pageContent,
580
+ });
581
+ }
582
+
538
583
  async navigate(url, options = {}) {
539
584
  const page = await this.ensurePage();
540
585
 
541
586
  try {
542
- const response = await page.goto(url, {
543
- waitUntil: normalizeWaitUntil(options.waitUntil),
544
- timeout: 30000
587
+ const requestedReferrerMode = normalizeReferrerMode(options.referrerMode);
588
+ let activeReferrerMode = requestedReferrerMode;
589
+ let response = await this._navigatePage(page, url, {
590
+ ...options,
591
+ referrerMode: activeReferrerMode,
545
592
  });
546
593
 
547
594
  if (options.waitFor) {
@@ -549,7 +596,34 @@ class BrowserController {
549
596
  }
550
597
 
551
598
  // Simulate human reading delay
552
- await sleep(rand(500, 1500));
599
+ await sleep(rand(700, 1800));
600
+
601
+ let rawHtml = await page.content();
602
+ const { extractForLLM } = require('./contentExtractor');
603
+ let extraction = extractForLLM(rawHtml, { url: page.url() });
604
+ let botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
605
+ let challengeRetried = false;
606
+
607
+ if (
608
+ botDetection.detected
609
+ && normalizeChallengeRetry(options.challengeRetry)
610
+ && requestedReferrerMode === 'direct'
611
+ ) {
612
+ challengeRetried = true;
613
+ activeReferrerMode = 'google';
614
+ await sleep(rand(1200, 2600));
615
+ response = await this._navigatePage(page, url, {
616
+ ...options,
617
+ referrerMode: activeReferrerMode,
618
+ });
619
+ if (options.waitFor) {
620
+ await page.waitForSelector(options.waitFor, { timeout: 10000 }).catch(() => { });
621
+ }
622
+ await sleep(rand(900, 2200));
623
+ rawHtml = await page.content();
624
+ extraction = extractForLLM(rawHtml, { url: page.url() });
625
+ botDetection = await this._getBotDetection(page, rawHtml, extraction.markdown);
626
+ }
553
627
 
554
628
  const title = await page.title();
555
629
  const currentUrl = page.url();
@@ -559,15 +633,14 @@ class BrowserController {
559
633
  screenshot = await this.takeScreenshot({ fullPage: options.fullPage });
560
634
  }
561
635
 
562
- const rawHtml = await page.content();
563
- const { extractForLLM } = require('./contentExtractor');
564
- const extraction = extractForLLM(rawHtml, { url: currentUrl });
565
-
566
636
  return {
567
637
  title,
568
638
  url: currentUrl,
569
639
  status: response?.status() || 0,
570
640
  pageContent: extraction.markdown,
641
+ botDetection,
642
+ referrerMode: activeReferrerMode,
643
+ challengeRetried,
571
644
  screenshotPath: screenshot?.screenshotPath || null,
572
645
  artifactId: screenshot?.artifactId || null,
573
646
  fullPath: screenshot?.fullPath || null
@@ -578,6 +651,7 @@ class BrowserController {
578
651
  return {
579
652
  error: err.message,
580
653
  url,
654
+ botDetection: { detected: false, provider: null },
581
655
  screenshotPath: screenshot?.screenshotPath || null,
582
656
  artifactId: screenshot?.artifactId || null,
583
657
  fullPath: screenshot?.fullPath || null
@@ -585,6 +659,36 @@ class BrowserController {
585
659
  }
586
660
  }
587
661
 
662
+ async _moveMouseTo(page, x, y, options = {}) {
663
+ const target = {
664
+ x: Math.max(0, Math.min(this._viewport.width, Math.round(Number(x) || 0))),
665
+ y: Math.max(0, Math.min(this._viewport.height, Math.round(Number(y) || 0))),
666
+ };
667
+ const path = generateHumanMousePath(this._mousePosition, target, this._viewport);
668
+ for (const point of path) {
669
+ await page.mouse.move(point.x, point.y);
670
+ if (!options.fast) {
671
+ await sleep(rand(2, 10));
672
+ }
673
+ }
674
+ this._mousePosition = target;
675
+ return target;
676
+ }
677
+
678
+ async _pointForElement(element) {
679
+ await element.scrollIntoViewIfNeeded?.().catch(() => {});
680
+ const box = await element.boundingBox();
681
+ if (!box || box.width <= 0 || box.height <= 0) {
682
+ return null;
683
+ }
684
+ const xRatio = rand(25, 75) / 100;
685
+ const yRatio = rand(30, 70) / 100;
686
+ return {
687
+ x: box.x + box.width * xRatio,
688
+ y: box.y + box.height * yRatio,
689
+ };
690
+ }
691
+
588
692
  async click(selector, text, screenshot = true) {
589
693
  const page = await this.ensurePage();
590
694
 
@@ -608,10 +712,14 @@ class BrowserController {
608
712
  return { error: 'Either selector or text required' };
609
713
  }
610
714
 
611
- // Human-like: hover first, then click with a hold delay
612
- await target.hover();
613
- await sleep(rand(80, 250));
614
- await target.click({ delay: rand(50, 150) });
715
+ const point = await this._pointForElement(target);
716
+ if (!point) return { error: 'Element has no visible clickable area' };
717
+
718
+ await this._moveMouseTo(page, point.x, point.y);
719
+ await sleep(rand(170, 320));
720
+ await page.mouse.down();
721
+ await sleep(rand(80, 260));
722
+ await page.mouse.up();
615
723
 
616
724
  await sleep(rand(800, 1800));
617
725
 
@@ -637,10 +745,10 @@ class BrowserController {
637
745
  try {
638
746
  const px = Math.max(0, Math.round(Number(x) || 0));
639
747
  const py = Math.max(0, Math.round(Number(y) || 0));
640
- await page.mouse.move(px, py, { steps: rand(4, 10) });
641
- await sleep(rand(40, 140));
748
+ await this._moveMouseTo(page, px, py);
749
+ await sleep(rand(90, 220));
642
750
  await page.mouse.down();
643
- await sleep(rand(30, 110));
751
+ await sleep(rand(70, 240));
644
752
  await page.mouse.up();
645
753
  await sleep(rand(500, 1200));
646
754
 
@@ -667,7 +775,7 @@ class BrowserController {
667
775
  try {
668
776
  const px = Math.max(0, Math.round(Number(x) || 0));
669
777
  const py = Math.max(0, Math.round(Number(y) || 0));
670
- await page.mouse.move(px, py, { steps: options.steps || 1 });
778
+ await this._moveMouseTo(page, px, py, { fast: Number(options.steps) <= 1 });
671
779
  return {
672
780
  success: true,
673
781
  x: px,
@@ -684,11 +792,19 @@ class BrowserController {
684
792
  const page = await this.ensurePage();
685
793
 
686
794
  try {
687
- await page.mouse.wheel({
688
- deltaX: Math.round(Number(deltaX) || 0),
689
- deltaY: Math.round(Number(deltaY) || 0),
690
- });
691
- await sleep(rand(300, 900));
795
+ const x = Math.max(10, Math.min(this._viewport.width - 10, this._mousePosition.x + rand(-80, 80)));
796
+ const y = Math.max(10, Math.min(this._viewport.height - 10, this._mousePosition.y + rand(-80, 80)));
797
+ await this._moveMouseTo(page, x, y);
798
+ const totalX = Math.round(Number(deltaX) || 0);
799
+ const totalY = Math.round(Number(deltaY) || 0);
800
+ const chunks = Math.max(1, Math.min(6, Math.ceil(Math.max(Math.abs(totalX), Math.abs(totalY)) / 450)));
801
+ for (let i = 0; i < chunks; i += 1) {
802
+ await page.mouse.wheel({
803
+ deltaX: Math.round(totalX / chunks),
804
+ deltaY: Math.round(totalY / chunks),
805
+ });
806
+ await sleep(rand(120, 360));
807
+ }
692
808
 
693
809
  let screenshotResult = null;
694
810
  if (screenshot) screenshotResult = await this.takeScreenshot();
@@ -711,12 +827,20 @@ class BrowserController {
711
827
 
712
828
  try {
713
829
  if (options.clear !== false) {
714
- await page.click(selector, { clickCount: 3 });
830
+ const element = await page.$(selector);
831
+ if (element) {
832
+ const point = await this._pointForElement(element);
833
+ if (point) {
834
+ await this._moveMouseTo(page, point.x, point.y);
835
+ await sleep(rand(120, 260));
836
+ }
837
+ }
838
+ await page.click(selector, { clickCount: 3, delay: rand(40, 120) });
715
839
  await page.keyboard.press('Backspace');
716
840
  }
717
841
 
718
842
  for (const char of text) {
719
- await page.type(selector, char, { delay: rand(30, 150) });
843
+ await page.type(selector, char, { delay: rand(45, 180) });
720
844
  }
721
845
 
722
846
  if (options.pressEnter) {
@@ -744,7 +868,7 @@ class BrowserController {
744
868
 
745
869
  try {
746
870
  for (const char of String(text || '')) {
747
- await page.keyboard.type(char, { delay: rand(25, 110) });
871
+ await page.keyboard.type(char, { delay: rand(45, 160) });
748
872
  }
749
873
 
750
874
  if (options.pressEnter) {
@@ -797,6 +921,8 @@ class BrowserController {
797
921
  const page = await this.ensurePage();
798
922
 
799
923
  try {
924
+ const rawHtml = await page.content().catch(() => '');
925
+ const botDetection = await this._getBotDetection(page, rawHtml, '').catch(() => ({ detected: false, provider: null }));
800
926
  if (all) {
801
927
  const results = await page.$$eval(selector || 'body', (elements, attr) => {
802
928
  return elements.map(el => {
@@ -806,7 +932,7 @@ class BrowserController {
806
932
  return el.innerText || '';
807
933
  });
808
934
  }, attribute);
809
- return { results: results.slice(0, 100) };
935
+ return { results: results.slice(0, 100), botDetection };
810
936
  }
811
937
 
812
938
  const result = await page.$eval(selector || 'body', (el, attr) => {
@@ -816,7 +942,7 @@ class BrowserController {
816
942
  return el.innerText || '';
817
943
  }, attribute);
818
944
 
819
- return { result: typeof result === 'string' ? result.slice(0, 50000) : result };
945
+ return { result: typeof result === 'string' ? result.slice(0, 50000) : result, botDetection };
820
946
  } catch (err) {
821
947
  return { error: err.message };
822
948
  }