human-browser 3.9.2 → 4.0.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.
@@ -1,44 +1,30 @@
1
1
  /**
2
- * browser-human.js
2
+ * browser-human.js — Human Browser for AI Agents v4.0.0
3
3
  *
4
- * Stealth browser iPhone 15 Pro, Romania residential proxy, human-like behavior.
5
- * Bypasses Cloudflare, DataDome, PerimeterX, bot detection.
4
+ * Stealth browser with residential proxies from 10+ countries.
5
+ * Appears as iPhone 15 Pro or Desktop Chrome to every website.
6
+ * Bypasses Cloudflare, DataDome, PerimeterX out of the box.
6
7
  *
7
- * Usage:
8
- * const { launchHuman } = require('./browser-human');
9
- * const { browser, page } = await launchHuman(); // mobile (iPhone)
10
- * const { browser, page } = await launchHuman({ mobile: false }); // desktop
11
- *
12
- * Proxy config via env vars (override defaults):
13
- * HB_PROXY_SERVER — e.g. http://ro.decodo.com:13001 (full override)
14
- * HB_PROXY_USER — your proxy username (or call getTrial() for free trial)
15
- * HB_PROXY_PASS — password
16
- * HB_PROXY_COUNTRY — country code: ro, us, de, gb, fr, nl, sg... (default: ro)
17
- * HB_PROXY_SESSION — Decodo: sticky port 10001-49999 (unique IP per user)
18
- * HB_NO_PROXY — set to "1" to disable proxy entirely
8
+ * Get credentials: https://humanbrowser.dev
9
+ * Support: https://t.me/virixlabs
19
10
  *
20
- * Unique IP per user (Decodo sticky sessions):
21
- * Each port in 10001-49999 = different sticky IP.
22
- * Set HB_PROXY_SESSION=<random_port> at deploy time for per-user unique IP.
23
- * Country via HB_PROXY_COUNTRY or launchHuman({ country: 'us' }).
24
- *
25
- * Supported providers (built-in presets):
26
- * brightdata — brd.superproxy.io:33335 (residential_proxy1_roma)
27
- * decodo — gate.decodo.com:10001 (Decodo/Smartproxy)
28
- * iproyal — geo.iproyal.com:12321 (IPRoyal)
29
- * nodemaven — rp.nodemavenio.com:10001 (NodeMaven)
11
+ * Usage:
12
+ * const { launchHuman, getTrial } = require('./browser-human');
13
+ * const { browser, page } = await launchHuman({ country: 'us' });
30
14
  *
31
- * ⚠️ Bright Data KYC note:
32
- * GET requests work without KYC. POST requests require KYC verification
33
- * at https://brightdata.com/cp/kyc takes ~5 min.
34
- * For full functionality (form submissions, APIs), complete KYC or use
35
- * Decodo/IPRoyal which allow POST without extra verification.
15
+ * Proxy config via env vars:
16
+ * HB_PROXY_PROVIDER — decodo | brightdata | iproyal | nodemaven (default: decodo)
17
+ * HB_PROXY_USER proxy username
18
+ * HB_PROXY_PASS — proxy password
19
+ * HB_PROXY_SERVER — full override: http://host:port
20
+ * HB_PROXY_COUNTRY — country code: ro, us, de, gb, fr, nl, sg... (default: ro)
21
+ * HB_PROXY_SESSION — Decodo sticky port 10001-49999 (unique IP per user)
22
+ * HB_NO_PROXY — set to "1" to disable proxy entirely
36
23
  */
37
24
 
38
- // Playwright path resolution — works in multiple contexts:
39
- // 1. clawhub install ~/.agents/skills/human-browser/
40
- // 2. workspace usage → /root/.openclaw/workspace/
41
- // 3. Clawster containers → /root/.openclaw/workspace/
25
+ // ─── PLAYWRIGHT RESOLVER ──────────────────────────────────────────────────────
26
+ // Works in any context: clawhub install, workspace, Clawster containers
27
+
42
28
  function _requirePlaywright() {
43
29
  const tries = [
44
30
  () => require('playwright'),
@@ -50,36 +36,100 @@ function _requirePlaywright() {
50
36
  for (const fn of tries) {
51
37
  try { return fn(); } catch (_) {}
52
38
  }
53
- throw new Error('[human-browser] playwright not found.\nRun: npm install playwright && npx playwright install chromium');
39
+ throw new Error(
40
+ '[human-browser] playwright not found.\n' +
41
+ 'Run: npm install playwright && npx playwright install chromium'
42
+ );
54
43
  }
44
+
55
45
  const { chromium } = _requirePlaywright();
56
46
 
57
- // ─── PROXY CONFIG ─────────────────────────────────────────────────────────────
58
- // Built-in provider presets
47
+ // ─── COUNTRY CONFIGS ──────────────────────────────────────────────────────────
48
+
49
+ const COUNTRY_META = {
50
+ ro: { locale: 'ro-RO', tz: 'Europe/Bucharest', lat: 44.4268, lon: 26.1025, lang: 'ro-RO,ro;q=0.9,en-US;q=0.8,en;q=0.7' },
51
+ us: { locale: 'en-US', tz: 'America/New_York', lat: 40.7128, lon: -74.006, lang: 'en-US,en;q=0.9' },
52
+ uk: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
53
+ gb: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
54
+ de: { locale: 'de-DE', tz: 'Europe/Berlin', lat: 52.5200, lon: 13.4050, lang: 'de-DE,de;q=0.9,en;q=0.8' },
55
+ nl: { locale: 'nl-NL', tz: 'Europe/Amsterdam', lat: 52.3676, lon: 4.9041, lang: 'nl-NL,nl;q=0.9,en;q=0.8' },
56
+ jp: { locale: 'ja-JP', tz: 'Asia/Tokyo', lat: 35.6762, lon: 139.6503, lang: 'ja-JP,ja;q=0.9,en;q=0.8' },
57
+ fr: { locale: 'fr-FR', tz: 'Europe/Paris', lat: 48.8566, lon: 2.3522, lang: 'fr-FR,fr;q=0.9,en;q=0.8' },
58
+ ca: { locale: 'en-CA', tz: 'America/Toronto', lat: 43.6532, lon: -79.3832, lang: 'en-CA,en;q=0.9' },
59
+ au: { locale: 'en-AU', tz: 'Australia/Sydney', lat: -33.8688, lon: 151.2093, lang: 'en-AU,en;q=0.9' },
60
+ sg: { locale: 'en-SG', tz: 'Asia/Singapore', lat: 1.3521, lon: 103.8198, lang: 'en-SG,en;q=0.9' },
61
+ br: { locale: 'pt-BR', tz: 'America/Sao_Paulo', lat: -23.5505, lon: -46.6333, lang: 'pt-BR,pt;q=0.9,en;q=0.8' },
62
+ in: { locale: 'en-IN', tz: 'Asia/Kolkata', lat: 28.6139, lon: 77.2090, lang: 'en-IN,en;q=0.9,hi;q=0.8' },
63
+ };
64
+
65
+ // ─── DEVICE PROFILES ─────────────────────────────────────────────────────────
66
+
67
+ function buildDevice(mobile, country = 'ro') {
68
+ const meta = COUNTRY_META[country.toLowerCase()] || COUNTRY_META.ro;
69
+
70
+ if (mobile) {
71
+ return {
72
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1',
73
+ viewport: { width: 393, height: 852 },
74
+ deviceScaleFactor: 3,
75
+ isMobile: true,
76
+ hasTouch: true,
77
+ locale: meta.locale,
78
+ timezoneId: meta.tz,
79
+ geolocation: { latitude: meta.lat, longitude: meta.lon, accuracy: 50 },
80
+ colorScheme: 'light',
81
+ extraHTTPHeaders: {
82
+ 'Accept-Language': meta.lang,
83
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
84
+ 'Accept-Encoding': 'gzip, deflate, br',
85
+ 'sec-fetch-dest': 'document',
86
+ 'sec-fetch-mode': 'navigate',
87
+ 'sec-fetch-site': 'none',
88
+ },
89
+ };
90
+ }
91
+
92
+ return {
93
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
94
+ viewport: { width: 1440, height: 900 },
95
+ locale: meta.locale,
96
+ timezoneId: meta.tz,
97
+ geolocation: { latitude: meta.lat, longitude: meta.lon, accuracy: 50 },
98
+ colorScheme: 'light',
99
+ extraHTTPHeaders: {
100
+ 'Accept-Language': meta.lang,
101
+ 'sec-ch-ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
102
+ 'sec-ch-ua-mobile': '?0',
103
+ 'sec-ch-ua-platform': '"Windows"',
104
+ },
105
+ };
106
+ }
107
+
108
+ // ─── PROXY PRESETS ────────────────────────────────────────────────────────────
109
+ // ⚠️ defaultUser/defaultPass are ALWAYS null — credentials come from env vars
110
+ // or getTrial(). NEVER hardcode credentials here.
111
+
59
112
  const PROXY_PRESETS = {
60
- brightdata: {
61
- server: 'http://brd.superproxy.io:33335',
62
- usernameTemplate: (user, country, session) =>
63
- `${user}-country-${country}-session-${session}`,
64
- defaultUser: null, // set via HB_PROXY_USER or call getTrial()
65
- defaultPass: null, // set via HB_PROXY_PASS or call getTrial()
66
- defaultCountry: 'ro',
67
- },
68
113
  decodo: {
69
- // Country-specific hostname: {country}.decodo.com
70
- // Sticky session = port number (10001-49999), each port = unique IP
114
+ // Sticky session via port number: each unique port = unique sticky IP
71
115
  serverTemplate: (country, port) => `http://${country}.decodo.com:${port}`,
72
116
  usernameTemplate: (user) => user,
73
- defaultUser: null, // set via HB_PROXY_USER or call getTrial()
74
- defaultPass: null, // set via HB_PROXY_PASS or call getTrial()
117
+ defaultUser: null,
118
+ defaultPass: null,
75
119
  defaultCountry: 'ro',
76
- // Port range for sticky sessions
77
120
  stickyPortMin: 10001,
78
121
  stickyPortMax: 49999,
79
122
  },
123
+ brightdata: {
124
+ server: 'http://brd.superproxy.io:33335',
125
+ usernameTemplate: (user, country, session) =>
126
+ `${user}-country-${country}-session-${session}`,
127
+ defaultUser: null,
128
+ defaultPass: null,
129
+ defaultCountry: 'ro',
130
+ },
80
131
  iproyal: {
81
132
  server: 'http://geo.iproyal.com:12321',
82
- // IPRoyal uses password suffix for options
83
133
  usernameTemplate: (user) => user,
84
134
  passwordTemplate: (pass, country, session) =>
85
135
  `${pass}_country-${country}_session-${session}_lifetime-30m`,
@@ -97,16 +147,14 @@ const PROXY_PRESETS = {
97
147
  },
98
148
  };
99
149
 
100
- // Active provider: env var HB_PROXY_PROVIDER or 'decodo'
101
- const ACTIVE_PROVIDER = process.env.HB_PROXY_PROVIDER || 'decodo';
102
- const preset = PROXY_PRESETS[ACTIVE_PROVIDER] || PROXY_PRESETS.brightdata;
103
-
104
150
  function makeProxy(sessionId = null, country = null) {
105
151
  if (process.env.HB_NO_PROXY === '1') return null;
106
152
 
107
- const cty = (country || process.env.HB_PROXY_COUNTRY || preset.defaultCountry).toLowerCase();
153
+ const providerName = process.env.HB_PROXY_PROVIDER || 'decodo';
154
+ const preset = PROXY_PRESETS[providerName] || PROXY_PRESETS.decodo;
155
+ const cty = (country || process.env.HB_PROXY_COUNTRY || preset.defaultCountry || 'ro').toLowerCase();
108
156
 
109
- // Allow full override via env vars
157
+ // Full manual override
110
158
  if (process.env.HB_PROXY_SERVER && process.env.HB_PROXY_USER) {
111
159
  return {
112
160
  server: process.env.HB_PROXY_SERVER,
@@ -115,227 +163,157 @@ function makeProxy(sessionId = null, country = null) {
115
163
  };
116
164
  }
117
165
 
118
- const user = process.env.HB_PROXY_USER || preset.defaultUser;
119
- const pass = process.env.HB_PROXY_PASS || preset.defaultPass;
166
+ // Legacy env var support
167
+ const user = process.env.HB_PROXY_USER || process.env.PROXY_USER || process.env.PROXY_USERNAME || preset.defaultUser;
168
+ const pass = process.env.HB_PROXY_PASS || process.env.PROXY_PASS || process.env.PROXY_PASSWORD || preset.defaultPass;
169
+
120
170
  if (!user || !pass) {
121
- console.warn(`[browser-human] No proxy credentials for provider "${ACTIVE_PROVIDER}". Set HB_PROXY_USER/HB_PROXY_PASS.`);
171
+ console.warn(`[browser-human] No proxy credentials for "${providerName}". Call getTrial() or set HB_PROXY_USER/HB_PROXY_PASS.`);
122
172
  return null;
123
173
  }
124
174
 
125
- // Decodo: sticky session via port number (10001-49999 range)
126
- // Each unique port = unique sticky IP. HB_PROXY_SESSION stores the port.
127
- let server;
175
+ // Decodo: port-based sticky sessions
128
176
  if (preset.serverTemplate) {
129
177
  const portMin = preset.stickyPortMin || 10001;
130
178
  const portMax = preset.stickyPortMax || 49999;
131
- const sessionPort = sessionId
179
+ const port = sessionId
132
180
  ? parseInt(sessionId)
133
181
  : (process.env.HB_PROXY_SESSION
134
182
  ? parseInt(process.env.HB_PROXY_SESSION)
135
183
  : Math.floor(Math.random() * (portMax - portMin + 1)) + portMin);
136
- server = preset.serverTemplate(cty, sessionPort);
137
- } else {
138
- const sid = sessionId || process.env.HB_PROXY_SESSION || Math.random().toString(36).slice(2, 10);
139
- server = preset.server;
140
- const username = preset.usernameTemplate(user, cty, sid);
141
- const password = preset.passwordTemplate ? preset.passwordTemplate(pass, cty, sid) : pass;
184
+ const server = preset.serverTemplate(cty, port);
185
+ const username = preset.usernameTemplate(user, cty, port);
186
+ const password = preset.passwordTemplate
187
+ ? preset.passwordTemplate(pass, cty, port)
188
+ : pass;
142
189
  return { server, username, password };
143
190
  }
144
191
 
145
- const username = preset.usernameTemplate(user, cty);
146
- const password = preset.passwordTemplate
147
- ? preset.passwordTemplate(pass, cty)
148
- : pass;
149
-
192
+ // Other providers: session-string based
193
+ const sid = sessionId || process.env.HB_PROXY_SESSION || Math.random().toString(36).slice(2, 10);
194
+ const server = preset.server;
195
+ const username = preset.usernameTemplate(user, cty, sid);
196
+ const password = preset.passwordTemplate ? preset.passwordTemplate(pass, cty, sid) : pass;
150
197
  return { server, username, password };
151
198
  }
152
199
 
153
- // Default PROXY (random session per launch)
154
- const PROXY = makeProxy();
155
-
156
200
  // ─── TRIAL CREDENTIALS ───────────────────────────────────────────────────────
157
201
 
158
202
  /**
159
203
  * Get free trial credentials from humanbrowser.dev
160
204
  * Sets HB_PROXY_USER, HB_PROXY_PASS, HB_PROXY_SESSION, HB_PROXY_PROVIDER
161
- * No signup needed — ~1GB Romania residential + 10 captcha solves
205
+ * No signup needed — Romania residential proxy
162
206
  *
163
207
  * @example
164
- * const { launchHuman, getTrial } = require('./browser-human');
165
208
  * await getTrial();
166
209
  * const { page } = await launchHuman(); // now uses trial proxy
167
210
  */
168
211
  async function getTrial() {
169
- if (process.env.HB_PROXY_USER) {
212
+ if (process.env.HB_PROXY_USER || process.env.PROXY_USER) {
170
213
  console.log('[human-browser] Credentials already set, skipping trial fetch.');
171
214
  return { ok: true, cached: true };
172
215
  }
173
216
  try {
174
217
  const https = require('https');
175
218
  const data = await new Promise((resolve, reject) => {
176
- https.get('https://humanbrowser.dev/api/trial', res => {
219
+ const req = https.get('https://humanbrowser.dev/api/trial', res => {
177
220
  let body = '';
178
221
  res.on('data', d => body += d);
179
- res.on('end', () => {
180
- try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
181
- });
182
- }).on('error', reject);
222
+ res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
223
+ });
224
+ req.on('error', reject);
225
+ req.setTimeout(10000, () => { req.destroy(); reject(new Error('Trial request timed out')); });
183
226
  });
227
+
184
228
  if (data.proxy_user || data.PROXY_USER) {
185
- const user = data.proxy_user || data.PROXY_USER;
186
- const pass = data.proxy_pass || data.PROXY_PASS;
187
- const session = data.session || data.PROXY_SESSION || String(Math.floor(Math.random() * 39999) + 10001);
229
+ const user = data.proxy_user || data.PROXY_USER;
230
+ const pass = data.proxy_pass || data.PROXY_PASS;
231
+ const session = data.session || data.PROXY_SESSION || String(Math.floor(Math.random() * 39999) + 10001);
188
232
  const provider = data.provider || 'decodo';
189
- const country = data.country || 'ro';
233
+ const country = data.country || 'ro';
234
+
190
235
  process.env.HB_PROXY_PROVIDER = provider;
191
- process.env.HB_PROXY_USER = user;
192
- process.env.HB_PROXY_PASS = pass;
193
- process.env.HB_PROXY_SESSION = session;
194
- process.env.HB_PROXY_COUNTRY = process.env.HB_PROXY_COUNTRY || country;
195
- console.log(`[human-browser] Trial ready: ${provider} ${country.toUpperCase()} proxy`);
236
+ process.env.HB_PROXY_USER = user;
237
+ process.env.HB_PROXY_PASS = pass;
238
+ process.env.HB_PROXY_SESSION = session;
239
+ if (!process.env.HB_PROXY_COUNTRY) process.env.HB_PROXY_COUNTRY = country;
240
+
241
+ console.log(`🎉 Human Browser trial activated! (~100MB Romania residential IP)`);
242
+ console.log(` Upgrade at: https://humanbrowser.dev\n`);
196
243
  return { ok: true, provider, country, session };
197
244
  }
245
+
198
246
  throw new Error(data.error || 'No credentials in trial response');
199
247
  } catch (err) {
248
+ const e = new Error(err.message);
249
+ e.code = 'TRIAL_UNAVAILABLE';
250
+ e.cta_url = 'https://humanbrowser.dev';
200
251
  console.warn('[human-browser] Trial fetch failed:', err.message);
201
- console.warn(' → Get credentials at: https://humanbrowser.dev');
202
- return { ok: false, error: err.message };
252
+ console.warn(' → Get credentials at: https://humanbrowser.dev');
253
+ throw e;
203
254
  }
204
255
  }
205
256
 
206
- // iPhone 15 Pro — самый популярный iOS девайс 2024
207
- const IPHONE15 = {
208
- userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1',
209
- viewport: { width: 393, height: 852 },
210
- deviceScaleFactor: 3,
211
- isMobile: true,
212
- hasTouch: true,
213
- locale: 'ro-RO',
214
- timezoneId: 'Europe/Bucharest',
215
- geolocation: { latitude: 44.4268, longitude: 26.1025, accuracy: 50 }, // Bucharest
216
- colorScheme: 'light',
217
- // HTTP headers that iOS Safari sends
218
- extraHTTPHeaders: {
219
- 'Accept-Language': 'ro-RO,ro;q=0.9,en-US;q=0.8,en;q=0.7',
220
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
221
- 'Accept-Encoding': 'gzip, deflate, br',
222
- 'sec-fetch-dest': 'document',
223
- 'sec-fetch-mode': 'navigate',
224
- 'sec-fetch-site': 'none',
225
- }
226
- };
227
-
228
- // Desktop Chrome (Windows) — для сайтов которые не работают на мобиле
229
- const DESKTOP_RO = {
230
- userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
231
- viewport: { width: 1440, height: 900 },
232
- locale: 'ro-RO',
233
- timezoneId: 'Europe/Bucharest',
234
- geolocation: { latitude: 44.4268, longitude: 26.1025, accuracy: 50 },
235
- colorScheme: 'light',
236
- extraHTTPHeaders: {
237
- 'Accept-Language': 'ro-RO,ro;q=0.9,en-US;q=0.8',
238
- 'sec-ch-ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
239
- 'sec-ch-ua-mobile': '?0',
240
- 'sec-ch-ua-platform': '"Windows"',
241
- }
242
- };
243
-
244
257
  // ─── HUMAN BEHAVIOR ───────────────────────────────────────────────────────────
245
258
 
246
- /** Random delay between min and max ms */
247
259
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
248
- const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
260
+ const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
249
261
 
250
262
  /**
251
- * Move mouse along a natural curved path (Bezier-like)
252
- * Not a straight line — humans never move in straight lines
263
+ * Move mouse along a natural cubic Bezier curve path
253
264
  */
254
265
  async function humanMouseMove(page, toX, toY, fromX = null, fromY = null) {
255
- const pos = await page.evaluate(() => ({ x: window.mouseX || 400, y: window.mouseY || 400 }));
256
- const startX = fromX ?? pos.x;
257
- const startY = fromY ?? pos.y;
258
-
259
- // Generate control points for bezier curve
260
- const cp1x = startX + rand(-80, 80);
261
- const cp1y = startY + rand(-60, 60);
262
- const cp2x = toX + rand(-50, 50);
263
- const cp2y = toY + rand(-40, 40);
264
-
266
+ const startX = fromX ?? rand(100, 300);
267
+ const startY = fromY ?? rand(200, 600);
268
+ const cp1x = startX + rand(-80, 80), cp1y = startY + rand(-60, 60);
269
+ const cp2x = toX + rand(-50, 50), cp2y = toY + rand(-40, 40);
265
270
  const steps = rand(12, 25);
266
-
267
271
  for (let i = 0; i <= steps; i++) {
268
272
  const t = i / steps;
269
- // Cubic bezier
270
- const x = Math.round(
271
- Math.pow(1-t, 3) * startX +
272
- 3 * Math.pow(1-t, 2) * t * cp1x +
273
- 3 * (1-t) * Math.pow(t, 2) * cp2x +
274
- Math.pow(t, 3) * toX
275
- );
276
- const y = Math.round(
277
- Math.pow(1-t, 3) * startY +
278
- 3 * Math.pow(1-t, 2) * t * cp1y +
279
- 3 * (1-t) * Math.pow(t, 2) * cp2y +
280
- Math.pow(t, 3) * toY
281
- );
273
+ const x = Math.round(Math.pow(1-t,3)*startX + 3*Math.pow(1-t,2)*t*cp1x + 3*(1-t)*t*t*cp2x + t*t*t*toX);
274
+ const y = Math.round(Math.pow(1-t,3)*startY + 3*Math.pow(1-t,2)*t*cp1y + 3*(1-t)*t*t*cp2y + t*t*t*toY);
282
275
  await page.mouse.move(x, y);
283
- // Variable speed faster in middle, slower at start/end
284
- const delay = t < 0.2 || t > 0.8 ? rand(8, 20) : rand(2, 8);
285
- await sleep(delay);
276
+ await sleep(t < 0.2 || t > 0.8 ? rand(8, 20) : rand(2, 8));
286
277
  }
287
278
  }
288
279
 
289
280
  /**
290
- * Human-like click with natural mouse movement
281
+ * Human-like click with curved mouse path
291
282
  */
292
- async function humanClick(page, x, y, opts = {}) {
283
+ async function humanClick(page, x, y) {
293
284
  await humanMouseMove(page, x, y);
294
- await sleep(rand(50, 180)); // Brief pause before click
285
+ await sleep(rand(50, 180));
295
286
  await page.mouse.down();
296
- await sleep(rand(40, 100)); // Hold duration
287
+ await sleep(rand(40, 100));
297
288
  await page.mouse.up();
298
- await sleep(rand(100, 300)); // Post-click pause
289
+ await sleep(rand(100, 300));
299
290
  }
300
291
 
301
292
  /**
302
- * Human-like type variable speed, occasional micro-pause
293
+ * Human-like typing: variable speed (60–220ms/char), occasional micro-pauses
303
294
  */
304
- async function humanType(page, selector, text, opts = {}) {
295
+ async function humanType(page, selector, text) {
305
296
  const el = await page.$(selector);
306
297
  if (!el) throw new Error(`Element not found: ${selector}`);
307
-
308
- // Click to focus
309
298
  const box = await el.boundingBox();
310
- if (box) await humanClick(page, box.x + box.width/2, box.y + box.height/2);
299
+ if (box) await humanClick(page, box.x + box.width / 2, box.y + box.height / 2);
311
300
  await sleep(rand(200, 500));
312
-
313
- // Type character by character
314
301
  for (const char of text) {
315
302
  await page.keyboard.type(char);
316
- // Variable typing speed: 80-250ms per char (average human is ~100-150ms)
317
- const delay = rand(60, 220);
318
- await sleep(delay);
319
-
320
- // Occasional longer pause (thinking)
303
+ await sleep(rand(60, 220));
321
304
  if (Math.random() < 0.08) await sleep(rand(400, 900));
322
305
  }
323
-
324
306
  await sleep(rand(200, 400));
325
307
  }
326
308
 
327
309
  /**
328
- * Human-like scroll smooth, variable speed, realistic
310
+ * Human-like scroll: smooth, multi-step, with jitter
329
311
  */
330
312
  async function humanScroll(page, direction = 'down', amount = null) {
331
313
  const scrollAmount = amount || rand(200, 600);
332
314
  const delta = direction === 'down' ? scrollAmount : -scrollAmount;
333
-
334
- // Move to random position first
335
315
  const vp = page.viewportSize();
336
316
  await humanMouseMove(page, rand(100, vp.width - 100), rand(200, vp.height - 200));
337
-
338
- // Scroll in small increments
339
317
  const steps = rand(4, 10);
340
318
  for (let i = 0; i < steps; i++) {
341
319
  await page.mouse.wheel(0, delta / steps + rand(-5, 5));
@@ -345,41 +323,38 @@ async function humanScroll(page, direction = 'down', amount = null) {
345
323
  }
346
324
 
347
325
  /**
348
- * Human-like page read pause (look around the page)
326
+ * Read pause wait as if reading the page, occasional scroll
349
327
  */
350
328
  async function humanRead(page, minMs = 1500, maxMs = 4000) {
351
329
  await sleep(rand(minMs, maxMs));
352
- // Occasional small scroll while reading
353
- if (Math.random() < 0.3) {
354
- await humanScroll(page, 'down', rand(50, 150));
355
- }
330
+ if (Math.random() < 0.3) await humanScroll(page, 'down', rand(50, 150));
356
331
  }
357
332
 
358
333
  // ─── 2CAPTCHA SOLVER ──────────────────────────────────────────────────────────
359
334
 
360
335
  /**
361
- * Auto-detect and solve any captcha on the current page via 2captcha.com
362
- *
363
- * Supported: reCAPTCHA v2, reCAPTCHA v3, hCaptcha, Cloudflare Turnstile
336
+ * Auto-detect and solve reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile via 2captcha.com
337
+ * Token is auto-injected into the page — just submit the form after calling this.
364
338
  *
365
- * Usage:
366
- * const { token, type } = await solveCaptcha(page);
367
- * // Token is auto-injected into the page. You just submit the form.
339
+ * @param {Page} page
340
+ * @param {Object} opts
341
+ * @param {string} opts.apiKey — 2captcha API key (default: env TWOCAPTCHA_KEY)
342
+ * @param {string} opts.action — reCAPTCHA v3 action (default: 'verify')
343
+ * @param {number} opts.minScore — reCAPTCHA v3 min score (default: 0.7)
344
+ * @param {number} opts.timeout — max wait ms (default: 120000)
345
+ * @param {boolean} opts.verbose — log progress (default: false)
368
346
  *
369
- * Options:
370
- * apiKey — 2captcha API key (default: env TWOCAPTCHA_KEY)
371
- * action — reCAPTCHA v3 action (default: 'verify')
372
- * minScore — reCAPTCHA v3 min score (default: 0.7)
373
- * timeout — max wait ms (default: 120000)
374
- * verbose — log progress (default: false)
347
+ * @example
348
+ * const { token, type } = await solveCaptcha(page, { verbose: true });
349
+ * await page.click('button[type=submit]');
375
350
  */
376
351
  async function solveCaptcha(page, opts = {}) {
377
352
  const {
378
- apiKey = process.env.TWOCAPTCHA_KEY || '14cbfeed64fea439d5c055111d6760e5',
379
- action = 'verify',
353
+ apiKey = process.env.TWOCAPTCHA_KEY,
354
+ action = 'verify',
380
355
  minScore = 0.7,
381
- timeout = 120000,
382
- verbose = false,
356
+ timeout = 120000,
357
+ verbose = false,
383
358
  } = opts;
384
359
 
385
360
  if (!apiKey) throw new Error('[2captcha] No API key. Set TWOCAPTCHA_KEY env or pass opts.apiKey');
@@ -387,167 +362,118 @@ async function solveCaptcha(page, opts = {}) {
387
362
  const log = verbose ? (...a) => console.log('[2captcha]', ...a) : () => {};
388
363
  const pageUrl = page.url();
389
364
 
390
- // ─── Auto-detect captcha type ───────────────────────────────────────────────
365
+ // Auto-detect captcha type
391
366
  const detected = await page.evaluate(() => {
392
- // reCAPTCHA v2/v3
393
367
  const rc = document.querySelector('.g-recaptcha, [data-sitekey]');
394
368
  if (rc) {
395
369
  const sitekey = rc.getAttribute('data-sitekey') || rc.getAttribute('data-key');
396
370
  const version = rc.getAttribute('data-version') || (typeof window.grecaptcha !== 'undefined' && 'v2');
397
371
  return { type: 'recaptcha', sitekey, version: version === 'v3' ? 'v3' : 'v2' };
398
372
  }
399
- // hCaptcha
400
373
  const hc = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
401
- if (hc) {
402
- const sitekey = hc.getAttribute('data-sitekey') || hc.getAttribute('data-hcaptcha-sitekey');
403
- return { type: 'hcaptcha', sitekey };
404
- }
405
- // Cloudflare Turnstile
374
+ if (hc) return { type: 'hcaptcha', sitekey: hc.getAttribute('data-sitekey') || hc.getAttribute('data-hcaptcha-sitekey') };
406
375
  const ts = document.querySelector('.cf-turnstile, [data-cf-turnstile-sitekey]');
407
- if (ts) {
408
- const sitekey = ts.getAttribute('data-sitekey') || ts.getAttribute('data-cf-turnstile-sitekey');
409
- return { type: 'turnstile', sitekey };
410
- }
411
- // Also check script tags for sitekeys
412
- const scripts = [...document.scripts].map(s => s.src + s.textContent);
413
- const combined = scripts.join(' ');
414
- const rcMatch = combined.match(/(?:sitekey|data-sitekey)['":\s]+([A-Za-z0-9_-]{40,})/);
376
+ if (ts) return { type: 'turnstile', sitekey: ts.getAttribute('data-sitekey') || ts.getAttribute('data-cf-turnstile-sitekey') };
377
+ const scripts = [...document.scripts].map(s => s.src + s.textContent).join(' ');
378
+ const rcMatch = scripts.match(/(?:sitekey|data-sitekey)['":\s]+([A-Za-z0-9_-]{40,})/);
415
379
  if (rcMatch) return { type: 'recaptcha', sitekey: rcMatch[1], version: 'v2' };
416
-
417
380
  return null;
418
381
  });
419
382
 
420
- if (!detected || !detected.sitekey) {
421
- throw new Error('[2captcha] No captcha detected on page. Check manually.');
383
+ if (!detected || !detected.sitekey) throw new Error('[2captcha] No captcha detected on page.');
384
+ log(`Detected ${detected.type} v${detected.version || ''}`, detected.sitekey.slice(0, 20) + '...');
385
+
386
+ // Submit to 2captcha
387
+ let submitUrl = `https://2captcha.com/in.php?key=${apiKey}&json=1&pageurl=${encodeURIComponent(pageUrl)}&googlekey=${encodeURIComponent(detected.sitekey)}`;
388
+ if (detected.type === 'recaptcha') {
389
+ submitUrl += `&method=userrecaptcha`;
390
+ if (detected.version === 'v3') submitUrl += `&version=v3&action=${action}&min_score=${minScore}`;
391
+ } else if (detected.type === 'hcaptcha') {
392
+ submitUrl += `&method=hcaptcha&sitekey=${encodeURIComponent(detected.sitekey)}`;
393
+ } else if (detected.type === 'turnstile') {
394
+ submitUrl += `&method=turnstile&sitekey=${encodeURIComponent(detected.sitekey)}`;
422
395
  }
423
396
 
424
- log(`Detected ${detected.type} v${detected.version || ''}`, detected.sitekey.slice(0, 20) + '...');
425
- log(`Page: ${pageUrl}`);
397
+ const submitResp = await fetch(submitUrl);
398
+ const submitData = await submitResp.json();
399
+ if (!submitData.status || submitData.status !== 1) throw new Error(`[2captcha] Submit failed: ${JSON.stringify(submitData)}`);
400
+ const taskId = submitData.request;
401
+ log(`Task submitted: ${taskId} — waiting for workers...`);
426
402
 
427
- // ─── Route: trial proxy OR direct 2captcha ─────────────────────────────────
428
- const captchaProxyUrl = opts.captchaUrl || process.env.CAPTCHA_URL;
429
- const captchaToken = opts.captchaToken || process.env.CAPTCHA_TOKEN;
430
403
  let token = null;
431
-
432
- if (captchaProxyUrl && captchaToken) {
433
- // Trial mode: VPS proxy handles 2captcha + tracks usage
434
- log(`Using trial captcha proxy: ${captchaProxyUrl}`);
435
- const methodMap = { recaptcha: detected.version === 'v3' ? 'recaptcha_v3' : 'recaptcha_v2', hcaptcha: 'hcaptcha', turnstile: 'turnstile' };
436
- const resp = await fetch(captchaProxyUrl, {
437
- method: 'POST',
438
- headers: { 'Content-Type': 'application/json' },
439
- body: JSON.stringify({ trial_token: captchaToken, sitekey: detected.sitekey, method: methodMap[detected.type] || 'recaptcha_v2', pageurl: pageUrl, action, min_score: minScore }),
440
- signal: AbortSignal.timeout(timeout),
441
- });
442
- const data = await resp.json();
443
- if (!data.ok) {
444
- const err = new Error(data.error || 'Captcha proxy failed');
445
- err.upgrade_url = data.upgrade_url || 'https://humanbrowser.dev';
446
- err.solves_remaining = data.solves_remaining ?? 0;
447
- throw err;
448
- }
449
- token = data.token;
450
- log(`✅ Solved via proxy! Solves remaining: ${data.solves_remaining}`);
451
- } else {
452
- // Direct 2captcha mode
453
- if (!apiKey) throw new Error('[2captcha] No API key. Get a trial at humanbrowser.dev');
454
- let submitUrl = `https://2captcha.com/in.php?key=${apiKey}&json=1&pageurl=${encodeURIComponent(pageUrl)}&googlekey=${encodeURIComponent(detected.sitekey)}`;
455
- if (detected.type === 'recaptcha') {
456
- submitUrl += `&method=userrecaptcha`;
457
- if (detected.version === 'v3') submitUrl += `&version=v3&action=${action}&min_score=${minScore}`;
458
- } else if (detected.type === 'hcaptcha') {
459
- submitUrl += `&method=hcaptcha&sitekey=${encodeURIComponent(detected.sitekey)}`;
460
- } else if (detected.type === 'turnstile') {
461
- submitUrl += `&method=turnstile&sitekey=${encodeURIComponent(detected.sitekey)}`;
462
- }
463
- const submitResp = await fetch(submitUrl);
464
- const submitData = await submitResp.json();
465
- if (!submitData.status || submitData.status !== 1) throw new Error(`[2captcha] Submit failed: ${JSON.stringify(submitData)}`);
466
- const taskId = submitData.request;
467
- log(`Task submitted: ${taskId} — waiting for workers...`);
468
-
469
- const maxAttempts = Math.floor(timeout / 5000);
470
- for (let i = 0; i < maxAttempts; i++) {
471
- await sleep(i === 0 ? 15000 : 5000);
472
- const pollResp = await fetch(`https://2captcha.com/res.php?key=${apiKey}&action=get&id=${taskId}&json=1`);
473
- const pollData = await pollResp.json();
474
- if (pollData.status === 1) { token = pollData.request; log(`✅ Solved!`); break; }
475
- if (pollData.request !== 'CAPCHA_NOT_READY') throw new Error(`[2captcha] Poll error: ${JSON.stringify(pollData)}`);
476
- log(`⏳ Attempt ${i + 1}/${maxAttempts} — not ready yet...`);
477
- }
478
- if (!token) throw new Error('[2captcha] Timeout waiting for captcha solution');
404
+ const maxAttempts = Math.floor(timeout / 5000);
405
+ for (let i = 0; i < maxAttempts; i++) {
406
+ await sleep(i === 0 ? 15000 : 5000);
407
+ const pollResp = await fetch(`https://2captcha.com/res.php?key=${apiKey}&action=get&id=${taskId}&json=1`);
408
+ const pollData = await pollResp.json();
409
+ if (pollData.status === 1) { token = pollData.request; log('✅ Solved!'); break; }
410
+ if (pollData.request !== 'CAPCHA_NOT_READY') throw new Error(`[2captcha] Poll error: ${JSON.stringify(pollData)}`);
411
+ log(`⏳ Attempt ${i + 1}/${maxAttempts}...`);
479
412
  }
413
+ if (!token) throw new Error('[2captcha] Timeout waiting for captcha solution');
480
414
 
481
- // ─── Inject token into page ─────────────────────────────────────────────────
415
+ // Inject token into page
482
416
  await page.evaluate(({ type, token }) => {
483
- // reCAPTCHA
484
417
  if (type === 'recaptcha' || type === 'turnstile') {
485
- const textarea = document.querySelector('#g-recaptcha-response, [name="g-recaptcha-response"]');
486
- if (textarea) {
487
- textarea.style.display = 'block';
488
- textarea.value = token;
489
- textarea.dispatchEvent(new Event('change', { bubbles: true }));
490
- }
491
- // Also try callback
492
- if (typeof window.___grecaptcha_cfg !== 'undefined') {
493
- try {
494
- const clients = window.___grecaptcha_cfg.clients;
495
- if (clients) {
496
- Object.values(clients).forEach(client => {
497
- Object.values(client).forEach(widget => {
498
- if (widget && typeof widget.callback === 'function') {
499
- widget.callback(token);
500
- }
501
- });
502
- });
503
- }
504
- } catch (_) {}
505
- }
418
+ const ta = document.querySelector('#g-recaptcha-response, [name="g-recaptcha-response"]');
419
+ if (ta) { ta.style.display = 'block'; ta.value = token; ta.dispatchEvent(new Event('change', { bubbles: true })); }
420
+ try {
421
+ const clients = window.___grecaptcha_cfg && window.___grecaptcha_cfg.clients;
422
+ if (clients) Object.values(clients).forEach(c => Object.values(c).forEach(w => { if (w && typeof w.callback === 'function') w.callback(token); }));
423
+ } catch (_) {}
506
424
  }
507
- // hCaptcha
508
425
  if (type === 'hcaptcha') {
509
- const textarea = document.querySelector('[name="h-captcha-response"], #h-captcha-response');
510
- if (textarea) {
511
- textarea.style.display = 'block';
512
- textarea.value = token;
513
- textarea.dispatchEvent(new Event('change', { bubbles: true }));
514
- }
426
+ const ta = document.querySelector('[name="h-captcha-response"], #h-captcha-response');
427
+ if (ta) { ta.style.display = 'block'; ta.value = token; ta.dispatchEvent(new Event('change', { bubbles: true })); }
515
428
  }
516
- // Turnstile
517
429
  if (type === 'turnstile') {
518
- const input = document.querySelector('[name="cf-turnstile-response"]');
519
- if (input) {
520
- input.value = token;
521
- input.dispatchEvent(new Event('change', { bubbles: true }));
522
- }
430
+ const inp = document.querySelector('[name="cf-turnstile-response"]');
431
+ if (inp) { inp.value = token; inp.dispatchEvent(new Event('change', { bubbles: true })); }
523
432
  }
524
433
  }, { type: detected.type, token });
525
434
 
526
- log('✅ Token injected into page');
435
+ log('✅ Token injected');
527
436
  return { token, type: detected.type, sitekey: detected.sitekey };
528
437
  }
529
438
 
530
439
  // ─── LAUNCH ───────────────────────────────────────────────────────────────────
531
440
 
532
441
  /**
533
- * Launch a human-like browser session
534
- * @param {Object} opts
535
- * @param {boolean} opts.mobile - Use iPhone 15 (default: true)
536
- * @param {boolean} opts.useProxy - Use residential proxy (default: true)
537
- * @param {boolean} opts.headless - Headless mode (default: true)
538
- * @param {string} opts.country - Proxy country code: 'ro','us','de','gb','fr'... (default: env HB_PROXY_COUNTRY or 'ro')
539
- * @param {string} opts.session - Sticky session ID / Decodo port (default: random unique)
442
+ * Launch a human-like browser with residential proxy and device fingerprint
443
+ *
444
+ * @param {Object} opts
445
+ * @param {string} opts.country — 'ro'|'us'|'gb'|'de'|'nl'|'jp'|'fr'|'ca'|'au'|'sg' (default: 'ro')
446
+ * @param {boolean} opts.mobile — iPhone 15 Pro (true) or Desktop Chrome (false). Default: true
447
+ * @param {boolean} opts.useProxy — Enable residential proxy. Default: true
448
+ * @param {boolean} opts.headless — Headless mode. Default: true
449
+ * @param {string} opts.session — Sticky session ID / Decodo port (unique IP per value)
450
+ *
451
+ * @returns {{ browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand }}
540
452
  */
541
453
  async function launchHuman(opts = {}) {
542
454
  const {
543
- mobile = true,
455
+ country = null,
456
+ mobile = true,
544
457
  useProxy = true,
545
458
  headless = true,
546
- country = null,
547
- session = null,
459
+ session = null,
548
460
  } = opts;
549
461
 
550
- const device = mobile ? IPHONE15 : DESKTOP_RO;
462
+ const cty = country || process.env.HB_PROXY_COUNTRY || 'ro';
463
+
464
+ // Auto-fetch trial credentials if no proxy is configured
465
+ if (useProxy && !process.env.HB_PROXY_USER && !process.env.PROXY_USER && !process.env.HB_PROXY_SERVER) {
466
+ try {
467
+ await getTrial();
468
+ } catch (e) {
469
+ console.warn('⚠️ Could not fetch trial credentials:', e.message);
470
+ console.warn(' Get credentials at: https://humanbrowser.dev');
471
+ }
472
+ }
473
+
474
+ const device = buildDevice(mobile, cty);
475
+ const meta = COUNTRY_META[cty.toLowerCase()] || COUNTRY_META.ro;
476
+ const proxy = useProxy ? makeProxy(session, cty) : null;
551
477
 
552
478
  const browser = await chromium.launch({
553
479
  headless,
@@ -555,7 +481,7 @@ async function launchHuman(opts = {}) {
555
481
  '--no-sandbox',
556
482
  '--disable-setuid-sandbox',
557
483
  '--ignore-certificate-errors',
558
- '--disable-blink-features=AutomationControlled', // Hide webdriver flag!
484
+ '--disable-blink-features=AutomationControlled',
559
485
  '--disable-features=IsolateOrigins,site-per-process',
560
486
  '--disable-web-security',
561
487
  ],
@@ -566,109 +492,165 @@ async function launchHuman(opts = {}) {
566
492
  ignoreHTTPSErrors: true,
567
493
  permissions: ['geolocation', 'notifications'],
568
494
  };
569
-
570
- if (useProxy) {
571
- // Each unique session = unique sticky IP. Same session = same IP.
572
- ctxOpts.proxy = makeProxy(session, country);
573
- }
495
+ if (proxy) ctxOpts.proxy = proxy;
574
496
 
575
497
  const ctx = await browser.newContext(ctxOpts);
576
498
 
577
499
  // Anti-detection: override navigator properties
578
- await ctx.addInitScript(() => {
579
- // Hide webdriver
580
- Object.defineProperty(navigator, 'webdriver', { get: () => false });
581
-
582
- // Fix plugins (mobile has none, that's normal for Safari)
583
- if (!navigator.plugins.length) {
584
- // Leave as-is for mobile
500
+ await ctx.addInitScript((m) => {
501
+ Object.defineProperty(navigator, 'webdriver', { get: () => false });
502
+ Object.defineProperty(navigator, 'maxTouchPoints', { get: () => m.mobile ? 5 : 0 });
503
+ Object.defineProperty(navigator, 'platform', { get: () => m.mobile ? 'iPhone' : 'Win32' });
504
+ Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => m.mobile ? 6 : 8 });
505
+ Object.defineProperty(navigator, 'language', { get: () => m.locale });
506
+ Object.defineProperty(navigator, 'languages', { get: () => [m.locale, 'en'] });
507
+ if (m.mobile) {
508
+ Object.defineProperty(screen, 'width', { get: () => 393 });
509
+ Object.defineProperty(screen, 'height', { get: () => 852 });
510
+ Object.defineProperty(screen, 'availWidth', { get: () => 393 });
511
+ Object.defineProperty(screen, 'availHeight', { get: () => 852 });
585
512
  }
586
-
587
- // Override chrome object (not present in Safari)
588
- // delete window.chrome; // Not needed for iPhone UA
589
-
590
- // Realistic touch events for iOS
591
- Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 5 });
592
-
593
- // Platform
594
- Object.defineProperty(navigator, 'platform', { get: () => 'iPhone' });
595
-
596
- // Language
597
- Object.defineProperty(navigator, 'language', { get: () => 'ro-RO' });
598
- Object.defineProperty(navigator, 'languages', { get: () => ['ro-RO', 'ro', 'en-US', 'en'] });
599
-
600
- // Screen (iPhone 15 Pro)
601
- Object.defineProperty(screen, 'width', { get: () => 393 });
602
- Object.defineProperty(screen, 'height', { get: () => 852 });
603
- Object.defineProperty(screen, 'availWidth', { get: () => 393 });
604
- Object.defineProperty(screen, 'availHeight', { get: () => 852 });
605
-
606
- // Hardware concurrency (iPhone has 6 cores)
607
- Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 6 });
608
-
609
- // Memory (4GB iPhone)
610
- // Object.defineProperty(navigator, 'deviceMemory', { get: () => 4 }); // Safari doesn't expose this
611
-
612
- // Connection (LTE/5G)
613
513
  if (navigator.connection) {
614
- Object.defineProperty(navigator.connection, 'effectiveType', { get: () => '4g' });
615
- Object.defineProperty(navigator.connection, 'rtt', { get: () => rand(30, 80) });
514
+ try {
515
+ Object.defineProperty(navigator.connection, 'effectiveType', { get: () => '4g' });
516
+ } catch (_) {}
616
517
  }
617
-
618
- function rand(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
619
- });
518
+ }, { mobile, locale: meta.locale });
620
519
 
621
520
  const page = await ctx.newPage();
622
521
 
623
- // Add realistic touch simulation for mobile
624
- if (mobile) {
625
- await page.addInitScript(() => {
626
- // Simulate touch
627
- window.ontouchstart = null;
628
- window.ontouchmove = null;
629
- window.ontouchend = null;
630
- });
631
- }
632
-
633
522
  return { browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand };
634
523
  }
635
524
 
636
- // ─── EXPORT ───────────────────────────────────────────────────────────────────
637
- module.exports = {
638
- launchHuman,
639
- getTrial,
525
+ // ─── SHADOW DOM UTILITIES ─────────────────────────────────────────────────────
526
+
527
+ /**
528
+ * Query an element inside shadow DOM (any depth).
529
+ * Use when page.$() returns null but element is visible on screen.
530
+ */
531
+ async function shadowQuery(page, selector) {
532
+ return page.evaluate((sel) => {
533
+ function q(root, s) {
534
+ const el = root.querySelector(s); if (el) return el;
535
+ for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = q(n.shadowRoot, s); if (f) return f; }
536
+ }
537
+ return q(document, sel);
538
+ }, selector);
539
+ }
540
+
541
+ /**
542
+ * Fill an input inside shadow DOM.
543
+ * Uses native input setter to trigger React/Angular onChange properly.
544
+ */
545
+ async function shadowFill(page, selector, value) {
546
+ await page.evaluate(({ sel, val }) => {
547
+ function q(root, s) {
548
+ const el = root.querySelector(s); if (el) return el;
549
+ for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = q(n.shadowRoot, s); if (f) return f; }
550
+ }
551
+ const el = q(document, sel);
552
+ if (!el) throw new Error('shadowFill: not found: ' + sel);
553
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
554
+ setter.call(el, val);
555
+ el.dispatchEvent(new Event('input', { bubbles: true }));
556
+ el.dispatchEvent(new Event('change', { bubbles: true }));
557
+ }, { sel: selector, val: value });
558
+ }
559
+
560
+ /**
561
+ * Click a button by text label, searching through shadow DOM.
562
+ */
563
+ async function shadowClickButton(page, buttonText) {
564
+ await page.evaluate((text) => {
565
+ function find(root) {
566
+ for (const b of root.querySelectorAll('button')) if (b.textContent.trim() === text) return b;
567
+ for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = find(n.shadowRoot); if (f) return f; }
568
+ }
569
+ const btn = find(document);
570
+ if (!btn) throw new Error('shadowClickButton: not found: ' + text);
571
+ btn.click();
572
+ }, buttonText);
573
+ }
574
+
575
+ /**
576
+ * Dump all interactive elements including inside shadow roots.
577
+ * Use for debugging when form elements aren't found by standard selectors.
578
+ */
579
+ async function dumpInteractiveElements(page) {
580
+ return page.evaluate(() => {
581
+ const res = [];
582
+ function collect(root) {
583
+ for (const el of root.querySelectorAll('input,textarea,button,select,[contenteditable]')) {
584
+ const rect = el.getBoundingClientRect();
585
+ if (rect.width > 0 && rect.height > 0)
586
+ res.push({ tag: el.tagName, name: el.name || '', id: el.id || '', type: el.type || '', text: el.textContent?.trim().slice(0, 25) || '', placeholder: el.placeholder?.slice(0, 25) || '' });
587
+ }
588
+ for (const n of root.querySelectorAll('*')) if (n.shadowRoot) collect(n.shadowRoot);
589
+ }
590
+ collect(document);
591
+ return res;
592
+ });
593
+ }
594
+
595
+ // ─── RICH TEXT EDITOR UTILITIES ───────────────────────────────────────────────
596
+
597
+ /**
598
+ * Paste text into a Lexical/ProseMirror/Quill/Draft.js rich text editor.
599
+ * Uses clipboard API — works where keyboard.type() and fill() fail.
600
+ *
601
+ * Common selectors:
602
+ * '[data-lexical-editor]' — Reddit, Meta apps
603
+ * '.public-DraftEditor-content' — Draft.js (Twitter, Quora)
604
+ * '.ql-editor' — Quill
605
+ * '.ProseMirror' — Linear, Confluence
606
+ * '[contenteditable="true"]' — generic
607
+ */
608
+ async function pasteIntoEditor(page, editorSelector, text) {
609
+ const el = await page.$(editorSelector);
610
+ if (!el) throw new Error('pasteIntoEditor: editor not found: ' + editorSelector);
611
+ await el.click();
612
+ await sleep(300);
613
+ await page.evaluate((t) => {
614
+ const ta = document.createElement('textarea');
615
+ ta.value = t;
616
+ document.body.appendChild(ta);
617
+ ta.select();
618
+ document.execCommand('copy');
619
+ document.body.removeChild(ta);
620
+ }, text);
621
+ await page.keyboard.press('Control+a');
622
+ await sleep(100);
623
+ await page.keyboard.press('Control+v');
624
+ await sleep(500);
625
+ }
626
+
627
+ // ─── EXPORTS ──────────────────────────────────────────────────────────────────
628
+
629
+ module.exports = {
630
+ launchHuman, getTrial,
640
631
  humanClick, humanMouseMove, humanType, humanScroll, humanRead,
641
632
  solveCaptcha,
642
- sleep, rand,
643
- PROXY, makeProxy, IPHONE15, DESKTOP_RO
633
+ shadowQuery, shadowFill, shadowClickButton, dumpInteractiveElements,
634
+ pasteIntoEditor,
635
+ makeProxy, buildDevice,
636
+ sleep, rand, COUNTRY_META,
644
637
  };
645
638
 
646
639
  // ─── QUICK TEST ───────────────────────────────────────────────────────────────
647
640
  if (require.main === module) {
641
+ const country = process.argv[2] || 'ro';
642
+ console.log(`🧪 Testing Human Browser v4.0.0 — country: ${country.toUpperCase()}\n`);
648
643
  (async () => {
649
- console.log('🧪 Testing human browser (iPhone 15, Romania)...\n');
650
-
651
- const { browser, page, humanScroll, humanRead } = await launchHuman({ mobile: true });
652
-
644
+ const { browser, page } = await launchHuman({ country, mobile: true });
653
645
  await page.goto('https://ipinfo.io/json', { waitUntil: 'domcontentloaded', timeout: 30000 });
654
646
  const info = JSON.parse(await page.textContent('body'));
655
- console.log(`✅ IP: ${info.ip}`);
647
+ console.log(`✅ IP: ${info.ip}`);
656
648
  console.log(`✅ Country: ${info.country} (${info.city})`);
657
- console.log(`✅ Org: ${info.org}`);
658
- console.log(`✅ Timezone: ${info.timezone}`);
659
-
660
- // Test UA
649
+ console.log(`✅ Org: ${info.org}`);
650
+ console.log(`✅ TZ: ${info.timezone}`);
661
651
  const ua = await page.evaluate(() => navigator.userAgent);
662
- console.log(`\n✅ User-Agent: ${ua.slice(0, 80)}...`);
663
-
664
- const platform = await page.evaluate(() => navigator.platform);
665
- const lang = await page.evaluate(() => navigator.language);
666
- const touch = await page.evaluate(() => navigator.maxTouchPoints);
667
- console.log(`✅ Platform: ${platform}`);
668
- console.log(`✅ Language: ${lang}`);
669
- console.log(`✅ Touch points: ${touch}`);
670
-
652
+ console.log(`✅ UA: ${ua.slice(0, 80)}...`);
671
653
  await browser.close();
672
- console.log('\n🎉 All good! Browser is fully configured.');
654
+ console.log('\n🎉 Human Browser v4.0.0 is ready.');
673
655
  })().catch(console.error);
674
656
  }