human-browser 3.9.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "human-browser",
3
- "version": "3.9.3",
3
+ "version": "4.0.0",
4
4
  "description": "Stealth browser for AI agents. Bypasses Cloudflare, DataDome, PerimeterX. Residential IPs from 10+ countries. iPhone 15 Pro fingerprint. Drop-in Playwright replacement — launchHuman() just works.",
5
5
  "keywords": [
6
6
  "browser-automation",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * browser-human.js — Human Browser for AI Agents
2
+ * browser-human.js — Human Browser for AI Agents v4.0.0
3
3
  *
4
4
  * Stealth browser with residential proxies from 10+ countries.
5
5
  * Appears as iPhone 15 Pro or Desktop Chrome to every website.
@@ -9,59 +9,59 @@
9
9
  * Support: https://t.me/virixlabs
10
10
  *
11
11
  * Usage:
12
- * const { launchHuman } = require('./browser-human');
12
+ * const { launchHuman, getTrial } = require('./browser-human');
13
13
  * const { browser, page } = await launchHuman({ country: 'us' });
14
+ *
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
14
23
  */
15
24
 
16
- const { chromium } = require('playwright');
17
- require('dotenv').config();
25
+ // ─── PLAYWRIGHT RESOLVER ──────────────────────────────────────────────────────
26
+ // Works in any context: clawhub install, workspace, Clawster containers
27
+
28
+ function _requirePlaywright() {
29
+ const tries = [
30
+ () => require('playwright'),
31
+ () => require(`${__dirname}/../node_modules/playwright`),
32
+ () => require(`${__dirname}/../../node_modules/playwright`),
33
+ () => require(`${process.env.HOME || '/root'}/.openclaw/workspace/node_modules/playwright`),
34
+ () => require('./node_modules/playwright'),
35
+ ];
36
+ for (const fn of tries) {
37
+ try { return fn(); } catch (_) {}
38
+ }
39
+ throw new Error(
40
+ '[human-browser] playwright not found.\n' +
41
+ 'Run: npm install playwright && npx playwright install chromium'
42
+ );
43
+ }
44
+
45
+ const { chromium } = _requirePlaywright();
18
46
 
19
47
  // ─── COUNTRY CONFIGS ──────────────────────────────────────────────────────────
20
48
 
21
49
  const COUNTRY_META = {
22
- 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' },
23
- us: { locale: 'en-US', tz: 'America/New_York', lat: 40.7128, lon: -74.006, lang: 'en-US,en;q=0.9' },
24
- uk: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
25
- gb: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
26
- de: { locale: 'de-DE', tz: 'Europe/Berlin', lat: 52.5200, lon: 13.4050, lang: 'de-DE,de;q=0.9,en;q=0.8' },
27
- nl: { locale: 'nl-NL', tz: 'Europe/Amsterdam', lat: 52.3676, lon: 4.9041, lang: 'nl-NL,nl;q=0.9,en;q=0.8' },
28
- jp: { locale: 'ja-JP', tz: 'Asia/Tokyo', lat: 35.6762, lon: 139.6503, lang: 'ja-JP,ja;q=0.9,en;q=0.8' },
29
- fr: { locale: 'fr-FR', tz: 'Europe/Paris', lat: 48.8566, lon: 2.3522, lang: 'fr-FR,fr;q=0.9,en;q=0.8' },
30
- ca: { locale: 'en-CA', tz: 'America/Toronto', lat: 43.6532, lon: -79.3832, lang: 'en-CA,en;q=0.9' },
31
- au: { locale: 'en-AU', tz: 'Australia/Sydney', lat: -33.8688, lon: 151.2093,lang: 'en-AU,en;q=0.9' },
32
- sg: { locale: 'en-SG', tz: 'Asia/Singapore', lat: 1.3521, lon: 103.8198, lang: 'en-SG,en;q=0.9' },
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' },
33
63
  };
34
64
 
35
- // ─── PROXY CONFIG ─────────────────────────────────────────────────────────────
36
-
37
- function buildProxy(country = 'ro') {
38
- const c = country.toLowerCase();
39
-
40
- // Proxy config — use env vars (set manually or auto-fetched via getTrial())
41
- const PROXY_HOST = process.env.PROXY_HOST || '';
42
- const PROXY_PORT = process.env.PROXY_PORT || '13001';
43
- const PROXY_USER = process.env.PROXY_USER || '';
44
- const PROXY_PASS = process.env.PROXY_PASS || '';
45
-
46
- // Also support legacy env var names for backward compatibility
47
- const server = process.env.PROXY_SERVER || (PROXY_HOST ? `http://${PROXY_HOST}:${PROXY_PORT}` : '');
48
- const username = process.env.PROXY_USERNAME || PROXY_USER;
49
- const password = process.env.PROXY_PASSWORD || PROXY_PASS;
50
-
51
- if (!username || !password) {
52
- return null;
53
- }
54
-
55
- // Inject country code into username if needed
56
- // e.g. brd-customer-XXX-zone-YYY → brd-customer-XXX-zone-YYY-country-ro
57
- const hasCountry = username.includes('-country-');
58
- const finalUser = hasCountry
59
- ? username.replace(/-country-\w+/, `-country-${c}`)
60
- : username.includes('zone-') ? `${username}-country-${c}` : username;
61
-
62
- return { server, username: finalUser, password };
63
- }
64
-
65
65
  // ─── DEVICE PROFILES ─────────────────────────────────────────────────────────
66
66
 
67
67
  function buildDevice(mobile, country = 'ro') {
@@ -105,15 +105,168 @@ function buildDevice(mobile, country = 'ro') {
105
105
  };
106
106
  }
107
107
 
108
+ // ─── PROXY PRESETS ────────────────────────────────────────────────────────────
109
+ // ⚠️ defaultUser/defaultPass are ALWAYS null — credentials come from env vars
110
+ // or getTrial(). NEVER hardcode credentials here.
111
+
112
+ const PROXY_PRESETS = {
113
+ decodo: {
114
+ // Sticky session via port number: each unique port = unique sticky IP
115
+ serverTemplate: (country, port) => `http://${country}.decodo.com:${port}`,
116
+ usernameTemplate: (user) => user,
117
+ defaultUser: null,
118
+ defaultPass: null,
119
+ defaultCountry: 'ro',
120
+ stickyPortMin: 10001,
121
+ stickyPortMax: 49999,
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
+ },
131
+ iproyal: {
132
+ server: 'http://geo.iproyal.com:12321',
133
+ usernameTemplate: (user) => user,
134
+ passwordTemplate: (pass, country, session) =>
135
+ `${pass}_country-${country}_session-${session}_lifetime-30m`,
136
+ defaultUser: null,
137
+ defaultPass: null,
138
+ defaultCountry: 'ro',
139
+ },
140
+ nodemaven: {
141
+ server: 'http://rp.nodemavenio.com:10001',
142
+ usernameTemplate: (user, country, session) =>
143
+ `${user}-country-${country}-session-${session}`,
144
+ defaultUser: null,
145
+ defaultPass: null,
146
+ defaultCountry: 'ro',
147
+ },
148
+ };
149
+
150
+ function makeProxy(sessionId = null, country = null) {
151
+ if (process.env.HB_NO_PROXY === '1') return null;
152
+
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();
156
+
157
+ // Full manual override
158
+ if (process.env.HB_PROXY_SERVER && process.env.HB_PROXY_USER) {
159
+ return {
160
+ server: process.env.HB_PROXY_SERVER,
161
+ username: process.env.HB_PROXY_USER,
162
+ password: process.env.HB_PROXY_PASS || '',
163
+ };
164
+ }
165
+
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
+
170
+ if (!user || !pass) {
171
+ console.warn(`[browser-human] No proxy credentials for "${providerName}". Call getTrial() or set HB_PROXY_USER/HB_PROXY_PASS.`);
172
+ return null;
173
+ }
174
+
175
+ // Decodo: port-based sticky sessions
176
+ if (preset.serverTemplate) {
177
+ const portMin = preset.stickyPortMin || 10001;
178
+ const portMax = preset.stickyPortMax || 49999;
179
+ const port = sessionId
180
+ ? parseInt(sessionId)
181
+ : (process.env.HB_PROXY_SESSION
182
+ ? parseInt(process.env.HB_PROXY_SESSION)
183
+ : Math.floor(Math.random() * (portMax - portMin + 1)) + portMin);
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;
189
+ return { server, username, password };
190
+ }
191
+
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;
197
+ return { server, username, password };
198
+ }
199
+
200
+ // ─── TRIAL CREDENTIALS ───────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Get free trial credentials from humanbrowser.dev
204
+ * Sets HB_PROXY_USER, HB_PROXY_PASS, HB_PROXY_SESSION, HB_PROXY_PROVIDER
205
+ * No signup needed — Romania residential proxy
206
+ *
207
+ * @example
208
+ * await getTrial();
209
+ * const { page } = await launchHuman(); // now uses trial proxy
210
+ */
211
+ async function getTrial() {
212
+ if (process.env.HB_PROXY_USER || process.env.PROXY_USER) {
213
+ console.log('[human-browser] Credentials already set, skipping trial fetch.');
214
+ return { ok: true, cached: true };
215
+ }
216
+ try {
217
+ const https = require('https');
218
+ const data = await new Promise((resolve, reject) => {
219
+ const req = https.get('https://humanbrowser.dev/api/trial', res => {
220
+ let body = '';
221
+ res.on('data', d => body += d);
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')); });
226
+ });
227
+
228
+ if (data.proxy_user || data.PROXY_USER) {
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);
232
+ const provider = data.provider || 'decodo';
233
+ const country = data.country || 'ro';
234
+
235
+ process.env.HB_PROXY_PROVIDER = provider;
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`);
243
+ return { ok: true, provider, country, session };
244
+ }
245
+
246
+ throw new Error(data.error || 'No credentials in trial response');
247
+ } catch (err) {
248
+ const e = new Error(err.message);
249
+ e.code = 'TRIAL_UNAVAILABLE';
250
+ e.cta_url = 'https://humanbrowser.dev';
251
+ console.warn('[human-browser] Trial fetch failed:', err.message);
252
+ console.warn(' → Get credentials at: https://humanbrowser.dev');
253
+ throw e;
254
+ }
255
+ }
256
+
108
257
  // ─── HUMAN BEHAVIOR ───────────────────────────────────────────────────────────
109
258
 
110
259
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
111
260
  const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
112
261
 
113
- async function humanMouseMove(page, toX, toY) {
114
- const cp1x = toX + rand(-80, 80), cp1y = toY + rand(-60, 60);
115
- const cp2x = toX + rand(-50, 50), cp2y = toY + rand(-40, 40);
116
- const startX = rand(100, 300), startY = rand(200, 600);
262
+ /**
263
+ * Move mouse along a natural cubic Bezier curve path
264
+ */
265
+ async function humanMouseMove(page, toX, toY, fromX = null, fromY = null) {
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);
117
270
  const steps = rand(12, 25);
118
271
  for (let i = 0; i <= steps; i++) {
119
272
  const t = i / steps;
@@ -124,6 +277,9 @@ async function humanMouseMove(page, toX, toY) {
124
277
  }
125
278
  }
126
279
 
280
+ /**
281
+ * Human-like click with curved mouse path
282
+ */
127
283
  async function humanClick(page, x, y) {
128
284
  await humanMouseMove(page, x, y);
129
285
  await sleep(rand(50, 180));
@@ -133,6 +289,9 @@ async function humanClick(page, x, y) {
133
289
  await sleep(rand(100, 300));
134
290
  }
135
291
 
292
+ /**
293
+ * Human-like typing: variable speed (60–220ms/char), occasional micro-pauses
294
+ */
136
295
  async function humanType(page, selector, text) {
137
296
  const el = await page.$(selector);
138
297
  if (!el) throw new Error(`Element not found: ${selector}`);
@@ -147,6 +306,9 @@ async function humanType(page, selector, text) {
147
306
  await sleep(rand(200, 400));
148
307
  }
149
308
 
309
+ /**
310
+ * Human-like scroll: smooth, multi-step, with jitter
311
+ */
150
312
  async function humanScroll(page, direction = 'down', amount = null) {
151
313
  const scrollAmount = amount || rand(200, 600);
152
314
  const delta = direction === 'down' ? scrollAmount : -scrollAmount;
@@ -160,34 +322,147 @@ async function humanScroll(page, direction = 'down', amount = null) {
160
322
  await sleep(rand(200, 800));
161
323
  }
162
324
 
325
+ /**
326
+ * Read pause — wait as if reading the page, occasional scroll
327
+ */
163
328
  async function humanRead(page, minMs = 1500, maxMs = 4000) {
164
329
  await sleep(rand(minMs, maxMs));
165
330
  if (Math.random() < 0.3) await humanScroll(page, 'down', rand(50, 150));
166
331
  }
167
332
 
333
+ // ─── 2CAPTCHA SOLVER ──────────────────────────────────────────────────────────
334
+
335
+ /**
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.
338
+ *
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)
346
+ *
347
+ * @example
348
+ * const { token, type } = await solveCaptcha(page, { verbose: true });
349
+ * await page.click('button[type=submit]');
350
+ */
351
+ async function solveCaptcha(page, opts = {}) {
352
+ const {
353
+ apiKey = process.env.TWOCAPTCHA_KEY,
354
+ action = 'verify',
355
+ minScore = 0.7,
356
+ timeout = 120000,
357
+ verbose = false,
358
+ } = opts;
359
+
360
+ if (!apiKey) throw new Error('[2captcha] No API key. Set TWOCAPTCHA_KEY env or pass opts.apiKey');
361
+
362
+ const log = verbose ? (...a) => console.log('[2captcha]', ...a) : () => {};
363
+ const pageUrl = page.url();
364
+
365
+ // Auto-detect captcha type
366
+ const detected = await page.evaluate(() => {
367
+ const rc = document.querySelector('.g-recaptcha, [data-sitekey]');
368
+ if (rc) {
369
+ const sitekey = rc.getAttribute('data-sitekey') || rc.getAttribute('data-key');
370
+ const version = rc.getAttribute('data-version') || (typeof window.grecaptcha !== 'undefined' && 'v2');
371
+ return { type: 'recaptcha', sitekey, version: version === 'v3' ? 'v3' : 'v2' };
372
+ }
373
+ const hc = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
374
+ if (hc) return { type: 'hcaptcha', sitekey: hc.getAttribute('data-sitekey') || hc.getAttribute('data-hcaptcha-sitekey') };
375
+ const ts = document.querySelector('.cf-turnstile, [data-cf-turnstile-sitekey]');
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,})/);
379
+ if (rcMatch) return { type: 'recaptcha', sitekey: rcMatch[1], version: 'v2' };
380
+ return null;
381
+ });
382
+
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)}`;
395
+ }
396
+
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...`);
402
+
403
+ let token = null;
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}...`);
412
+ }
413
+ if (!token) throw new Error('[2captcha] Timeout waiting for captcha solution');
414
+
415
+ // Inject token into page
416
+ await page.evaluate(({ type, token }) => {
417
+ if (type === 'recaptcha' || type === 'turnstile') {
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 (_) {}
424
+ }
425
+ if (type === 'hcaptcha') {
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 })); }
428
+ }
429
+ if (type === 'turnstile') {
430
+ const inp = document.querySelector('[name="cf-turnstile-response"]');
431
+ if (inp) { inp.value = token; inp.dispatchEvent(new Event('change', { bubbles: true })); }
432
+ }
433
+ }, { type: detected.type, token });
434
+
435
+ log('✅ Token injected');
436
+ return { token, type: detected.type, sitekey: detected.sitekey };
437
+ }
438
+
168
439
  // ─── LAUNCH ───────────────────────────────────────────────────────────────────
169
440
 
170
441
  /**
171
- * Launch a human-like browser with residential proxy
442
+ * Launch a human-like browser with residential proxy and device fingerprint
172
443
  *
173
444
  * @param {Object} opts
174
- * @param {string} opts.country - 'ro'|'us'|'uk'|'de'|'nl'|'jp'|'fr'|'ca'|'au'|'sg' (default: 'ro')
175
- * @param {boolean} opts.mobile - iPhone 15 Pro (true) or Desktop Chrome (false). Default: true
176
- * @param {boolean} opts.useProxy - Enable residential proxy. Default: true
177
- * @param {boolean} opts.headless - Headless mode. Default: true
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)
178
450
  *
179
- * @returns {{ browser, ctx, page, humanClick, humanType, humanScroll, humanRead, sleep, rand }}
451
+ * @returns {{ browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand }}
180
452
  */
181
453
  async function launchHuman(opts = {}) {
182
454
  const {
183
- country = 'ro',
455
+ country = null,
184
456
  mobile = true,
185
457
  useProxy = true,
186
458
  headless = true,
459
+ session = null,
187
460
  } = opts;
188
461
 
462
+ const cty = country || process.env.HB_PROXY_COUNTRY || 'ro';
463
+
189
464
  // Auto-fetch trial credentials if no proxy is configured
190
- if (useProxy && !process.env.PROXY_USER && !process.env.PROXY_SERVER && !process.env.PROXY_USERNAME) {
465
+ if (useProxy && !process.env.HB_PROXY_USER && !process.env.PROXY_USER && !process.env.HB_PROXY_SERVER) {
191
466
  try {
192
467
  await getTrial();
193
468
  } catch (e) {
@@ -196,9 +471,9 @@ async function launchHuman(opts = {}) {
196
471
  }
197
472
  }
198
473
 
199
- const meta = COUNTRY_META[country.toLowerCase()] || COUNTRY_META.ro;
200
- const device = buildDevice(mobile, country);
201
- const proxy = useProxy ? buildProxy(country) : null;
474
+ const device = buildDevice(mobile, cty);
475
+ const meta = COUNTRY_META[cty.toLowerCase()] || COUNTRY_META.ro;
476
+ const proxy = useProxy ? makeProxy(session, cty) : null;
202
477
 
203
478
  const browser = await chromium.launch({
204
479
  headless,
@@ -221,14 +496,25 @@ async function launchHuman(opts = {}) {
221
496
 
222
497
  const ctx = await browser.newContext(ctxOpts);
223
498
 
224
- // Anti-detection overrides
499
+ // Anti-detection: override navigator properties
225
500
  await ctx.addInitScript((m) => {
226
- Object.defineProperty(navigator, 'webdriver', { get: () => false });
227
- Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 5 });
228
- Object.defineProperty(navigator, 'platform', { get: () => m.mobile ? 'iPhone' : 'Win32' });
229
- Object.defineProperty(navigator, 'hardwareConcurrency',{ get: () => m.mobile ? 6 : 8 });
230
- Object.defineProperty(navigator, 'language', { get: () => m.locale });
231
- Object.defineProperty(navigator, 'languages', { get: () => [m.locale, 'en'] });
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 });
512
+ }
513
+ if (navigator.connection) {
514
+ try {
515
+ Object.defineProperty(navigator.connection, 'effectiveType', { get: () => '4g' });
516
+ } catch (_) {}
517
+ }
232
518
  }, { mobile, locale: meta.locale });
233
519
 
234
520
  const page = await ctx.newPage();
@@ -236,59 +522,6 @@ async function launchHuman(opts = {}) {
236
522
  return { browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand };
237
523
  }
238
524
 
239
- // ─── TRIAL ────────────────────────────────────────────────────────────────────
240
-
241
- /**
242
- * Get free trial credentials from humanbrowser.dev
243
- * Fetches shared trial proxy (~100MB, Romania). Sets env vars automatically.
244
- *
245
- * Usage:
246
- * const { launchHuman, getTrial } = require('./browser-human');
247
- * await getTrial(); // sets PROXY_USER/PASS in process.env
248
- * const { page } = await launchHuman(); // now uses trial credentials
249
- *
250
- * When trial runs out → throws { code: 'TRIAL_EXHAUSTED', cta_url: '...' }
251
- */
252
- async function getTrial() {
253
- let https;
254
- try { https = require('https'); } catch { https = require('http'); }
255
-
256
- return new Promise((resolve, reject) => {
257
- const req = https.get('https://humanbrowser.dev/api/trial', (res) => {
258
- let body = '';
259
- res.on('data', chunk => body += chunk);
260
- res.on('end', () => {
261
- try {
262
- const data = JSON.parse(body);
263
- if (data.error || res.statusCode !== 200) {
264
- const err = new Error(data.error || 'Trial unavailable');
265
- err.code = 'TRIAL_UNAVAILABLE';
266
- err.cta_url = 'https://humanbrowser.dev';
267
- return reject(err);
268
- }
269
- // Auto-set env vars so launchHuman() picks them up
270
- process.env.PROXY_HOST = data.proxy_host;
271
- process.env.PROXY_PORT = data.proxy_port;
272
- process.env.PROXY_USER = data.proxy_user;
273
- process.env.PROXY_PASS = data.proxy_pass;
274
-
275
- console.log('🎉 Human Browser trial activated! (~100MB Romania residential IP)');
276
- console.log(' Upgrade at: https://humanbrowser.dev\n');
277
- resolve(data);
278
- } catch (e) {
279
- reject(e);
280
- }
281
- });
282
- });
283
- req.on('error', (e) => {
284
- const err = new Error('Could not reach humanbrowser.dev: ' + e.message);
285
- err.code = 'TRIAL_NETWORK_ERROR';
286
- reject(err);
287
- });
288
- req.setTimeout(10000, () => { req.destroy(); reject(new Error('Trial request timed out')); });
289
- });
290
- }
291
-
292
525
  // ─── SHADOW DOM UTILITIES ─────────────────────────────────────────────────────
293
526
 
294
527
  /**
@@ -319,13 +552,13 @@ async function shadowFill(page, selector, value) {
319
552
  if (!el) throw new Error('shadowFill: not found: ' + sel);
320
553
  const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
321
554
  setter.call(el, val);
322
- el.dispatchEvent(new Event('input', { bubbles: true }));
555
+ el.dispatchEvent(new Event('input', { bubbles: true }));
323
556
  el.dispatchEvent(new Event('change', { bubbles: true }));
324
557
  }, { sel: selector, val: value });
325
558
  }
326
559
 
327
560
  /**
328
- * Click a button by its text label, searching through shadow DOM.
561
+ * Click a button by text label, searching through shadow DOM.
329
562
  */
330
563
  async function shadowClickButton(page, buttonText) {
331
564
  await page.evaluate((text) => {
@@ -340,8 +573,8 @@ async function shadowClickButton(page, buttonText) {
340
573
  }
341
574
 
342
575
  /**
343
- * Dump all inputs/buttons visible on page, including inside shadow roots.
344
- * Use for debugging when form elements aren't found.
576
+ * Dump all interactive elements including inside shadow roots.
577
+ * Use for debugging when form elements aren't found by standard selectors.
345
578
  */
346
579
  async function dumpInteractiveElements(page) {
347
580
  return page.evaluate(() => {
@@ -370,14 +603,13 @@ async function dumpInteractiveElements(page) {
370
603
  * '.public-DraftEditor-content' — Draft.js (Twitter, Quora)
371
604
  * '.ql-editor' — Quill
372
605
  * '.ProseMirror' — Linear, Confluence
373
- * '[contenteditable="true"]' — generic
606
+ * '[contenteditable="true"]' — generic
374
607
  */
375
608
  async function pasteIntoEditor(page, editorSelector, text) {
376
609
  const el = await page.$(editorSelector);
377
610
  if (!el) throw new Error('pasteIntoEditor: editor not found: ' + editorSelector);
378
611
  await el.click();
379
- await new Promise(r => setTimeout(r, 300));
380
- // Write to clipboard via execCommand (works in Playwright context)
612
+ await sleep(300);
381
613
  await page.evaluate((t) => {
382
614
  const ta = document.createElement('textarea');
383
615
  ta.value = t;
@@ -387,23 +619,27 @@ async function pasteIntoEditor(page, editorSelector, text) {
387
619
  document.body.removeChild(ta);
388
620
  }, text);
389
621
  await page.keyboard.press('Control+a');
390
- await new Promise(r => setTimeout(r, 100));
622
+ await sleep(100);
391
623
  await page.keyboard.press('Control+v');
392
- await new Promise(r => setTimeout(r, 500));
624
+ await sleep(500);
393
625
  }
394
626
 
627
+ // ─── EXPORTS ──────────────────────────────────────────────────────────────────
628
+
395
629
  module.exports = {
396
630
  launchHuman, getTrial,
397
631
  humanClick, humanMouseMove, humanType, humanScroll, humanRead,
632
+ solveCaptcha,
398
633
  shadowQuery, shadowFill, shadowClickButton, dumpInteractiveElements,
399
634
  pasteIntoEditor,
635
+ makeProxy, buildDevice,
400
636
  sleep, rand, COUNTRY_META,
401
637
  };
402
638
 
403
639
  // ─── QUICK TEST ───────────────────────────────────────────────────────────────
404
640
  if (require.main === module) {
405
641
  const country = process.argv[2] || 'ro';
406
- console.log(`🧪 Testing Human Browser — country: ${country.toUpperCase()}\n`);
642
+ console.log(`🧪 Testing Human Browser v4.0.0 — country: ${country.toUpperCase()}\n`);
407
643
  (async () => {
408
644
  const { browser, page } = await launchHuman({ country, mobile: true });
409
645
  await page.goto('https://ipinfo.io/json', { waitUntil: 'domcontentloaded', timeout: 30000 });
@@ -412,7 +648,9 @@ if (require.main === module) {
412
648
  console.log(`✅ Country: ${info.country} (${info.city})`);
413
649
  console.log(`✅ Org: ${info.org}`);
414
650
  console.log(`✅ TZ: ${info.timezone}`);
651
+ const ua = await page.evaluate(() => navigator.userAgent);
652
+ console.log(`✅ UA: ${ua.slice(0, 80)}...`);
415
653
  await browser.close();
416
- console.log('\n🎉 Human Browser is ready.');
654
+ console.log('\n🎉 Human Browser v4.0.0 is ready.');
417
655
  })().catch(console.error);
418
656
  }