human-browser 3.9.1 → 3.9.3

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