human-browser 3.9.2 → 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.
- package/README.md +11 -4
- package/SKILL.md +520 -158
- package/package.json +7 -4
- package/scripts/browser-human.js +300 -556
package/scripts/browser-human.js
CHANGED
|
@@ -1,341 +1,157 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* browser-human.js
|
|
2
|
+
* browser-human.js — Human Browser for AI Agents
|
|
3
3
|
*
|
|
4
|
-
* Stealth browser
|
|
5
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const { chromium } = _requirePlaywright();
|
|
56
|
-
|
|
57
|
-
// ─── PROXY CONFIG ─────────────────────────────────────────────────────────────
|
|
58
|
-
// Built-in provider presets
|
|
59
|
-
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
|
-
decodo: {
|
|
69
|
-
// Country-specific hostname: {country}.decodo.com
|
|
70
|
-
// Sticky session = port number (10001-49999), each port = unique IP
|
|
71
|
-
serverTemplate: (country, port) => `http://${country}.decodo.com:${port}`,
|
|
72
|
-
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()
|
|
75
|
-
defaultCountry: 'ro',
|
|
76
|
-
// Port range for sticky sessions
|
|
77
|
-
stickyPortMin: 10001,
|
|
78
|
-
stickyPortMax: 49999,
|
|
79
|
-
},
|
|
80
|
-
iproyal: {
|
|
81
|
-
server: 'http://geo.iproyal.com:12321',
|
|
82
|
-
// IPRoyal uses password suffix for options
|
|
83
|
-
usernameTemplate: (user) => user,
|
|
84
|
-
passwordTemplate: (pass, country, session) =>
|
|
85
|
-
`${pass}_country-${country}_session-${session}_lifetime-30m`,
|
|
86
|
-
defaultUser: null,
|
|
87
|
-
defaultPass: null,
|
|
88
|
-
defaultCountry: 'ro',
|
|
89
|
-
},
|
|
90
|
-
nodemaven: {
|
|
91
|
-
server: 'http://rp.nodemavenio.com:10001',
|
|
92
|
-
usernameTemplate: (user, country, session) =>
|
|
93
|
-
`${user}-country-${country}-session-${session}`,
|
|
94
|
-
defaultUser: null,
|
|
95
|
-
defaultPass: null,
|
|
96
|
-
defaultCountry: 'ro',
|
|
97
|
-
},
|
|
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' },
|
|
98
33
|
};
|
|
99
34
|
|
|
100
|
-
//
|
|
101
|
-
const ACTIVE_PROVIDER = process.env.HB_PROXY_PROVIDER || 'decodo';
|
|
102
|
-
const preset = PROXY_PRESETS[ACTIVE_PROVIDER] || PROXY_PRESETS.brightdata;
|
|
35
|
+
// ─── PROXY CONFIG ─────────────────────────────────────────────────────────────
|
|
103
36
|
|
|
104
|
-
function
|
|
105
|
-
|
|
37
|
+
function buildProxy(country = 'ro') {
|
|
38
|
+
const c = country.toLowerCase();
|
|
106
39
|
|
|
107
|
-
|
|
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 || '';
|
|
108
45
|
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
username: process.env.HB_PROXY_USER,
|
|
114
|
-
password: process.env.HB_PROXY_PASS || '',
|
|
115
|
-
};
|
|
116
|
-
}
|
|
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;
|
|
117
50
|
|
|
118
|
-
|
|
119
|
-
const pass = process.env.HB_PROXY_PASS || preset.defaultPass;
|
|
120
|
-
if (!user || !pass) {
|
|
121
|
-
console.warn(`[browser-human] No proxy credentials for provider "${ACTIVE_PROVIDER}". Set HB_PROXY_USER/HB_PROXY_PASS.`);
|
|
51
|
+
if (!username || !password) {
|
|
122
52
|
return null;
|
|
123
53
|
}
|
|
124
54
|
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const sessionPort = sessionId
|
|
132
|
-
? parseInt(sessionId)
|
|
133
|
-
: (process.env.HB_PROXY_SESSION
|
|
134
|
-
? parseInt(process.env.HB_PROXY_SESSION)
|
|
135
|
-
: 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;
|
|
142
|
-
return { server, username, password };
|
|
143
|
-
}
|
|
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;
|
|
144
61
|
|
|
145
|
-
|
|
146
|
-
const password = preset.passwordTemplate
|
|
147
|
-
? preset.passwordTemplate(pass, cty)
|
|
148
|
-
: pass;
|
|
149
|
-
|
|
150
|
-
return { server, username, password };
|
|
62
|
+
return { server, username: finalUser, password };
|
|
151
63
|
}
|
|
152
64
|
|
|
153
|
-
//
|
|
154
|
-
const PROXY = makeProxy();
|
|
65
|
+
// ─── DEVICE PROFILES ─────────────────────────────────────────────────────────
|
|
155
66
|
|
|
156
|
-
|
|
67
|
+
function buildDevice(mobile, country = 'ro') {
|
|
68
|
+
const meta = COUNTRY_META[country.toLowerCase()] || COUNTRY_META.ro;
|
|
157
69
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
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);
|
|
183
|
-
});
|
|
184
|
-
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);
|
|
188
|
-
const provider = data.provider || 'decodo';
|
|
189
|
-
const country = data.country || 'ro';
|
|
190
|
-
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`);
|
|
196
|
-
return { ok: true, provider, country, session };
|
|
197
|
-
}
|
|
198
|
-
throw new Error(data.error || 'No credentials in trial response');
|
|
199
|
-
} catch (err) {
|
|
200
|
-
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 };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
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',
|
|
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
|
+
};
|
|
225
90
|
}
|
|
226
|
-
};
|
|
227
91
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
242
|
-
}
|
|
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
|
+
}
|
|
243
107
|
|
|
244
108
|
// ─── HUMAN BEHAVIOR ───────────────────────────────────────────────────────────
|
|
245
109
|
|
|
246
|
-
/** Random delay between min and max ms */
|
|
247
110
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
248
|
-
const rand
|
|
111
|
+
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
|
249
112
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
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
|
-
|
|
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);
|
|
265
117
|
const steps = rand(12, 25);
|
|
266
|
-
|
|
267
118
|
for (let i = 0; i <= steps; i++) {
|
|
268
119
|
const t = i / steps;
|
|
269
|
-
|
|
270
|
-
const
|
|
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
|
-
);
|
|
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);
|
|
282
122
|
await page.mouse.move(x, y);
|
|
283
|
-
|
|
284
|
-
const delay = t < 0.2 || t > 0.8 ? rand(8, 20) : rand(2, 8);
|
|
285
|
-
await sleep(delay);
|
|
123
|
+
await sleep(t < 0.2 || t > 0.8 ? rand(8, 20) : rand(2, 8));
|
|
286
124
|
}
|
|
287
125
|
}
|
|
288
126
|
|
|
289
|
-
|
|
290
|
-
* Human-like click with natural mouse movement
|
|
291
|
-
*/
|
|
292
|
-
async function humanClick(page, x, y, opts = {}) {
|
|
127
|
+
async function humanClick(page, x, y) {
|
|
293
128
|
await humanMouseMove(page, x, y);
|
|
294
|
-
await sleep(rand(50, 180));
|
|
129
|
+
await sleep(rand(50, 180));
|
|
295
130
|
await page.mouse.down();
|
|
296
|
-
await sleep(rand(40, 100));
|
|
131
|
+
await sleep(rand(40, 100));
|
|
297
132
|
await page.mouse.up();
|
|
298
|
-
await sleep(rand(100, 300));
|
|
133
|
+
await sleep(rand(100, 300));
|
|
299
134
|
}
|
|
300
135
|
|
|
301
|
-
|
|
302
|
-
* Human-like type — variable speed, occasional micro-pause
|
|
303
|
-
*/
|
|
304
|
-
async function humanType(page, selector, text, opts = {}) {
|
|
136
|
+
async function humanType(page, selector, text) {
|
|
305
137
|
const el = await page.$(selector);
|
|
306
138
|
if (!el) throw new Error(`Element not found: ${selector}`);
|
|
307
|
-
|
|
308
|
-
// Click to focus
|
|
309
139
|
const box = await el.boundingBox();
|
|
310
|
-
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);
|
|
311
141
|
await sleep(rand(200, 500));
|
|
312
|
-
|
|
313
|
-
// Type character by character
|
|
314
142
|
for (const char of text) {
|
|
315
143
|
await page.keyboard.type(char);
|
|
316
|
-
|
|
317
|
-
const delay = rand(60, 220);
|
|
318
|
-
await sleep(delay);
|
|
319
|
-
|
|
320
|
-
// Occasional longer pause (thinking)
|
|
144
|
+
await sleep(rand(60, 220));
|
|
321
145
|
if (Math.random() < 0.08) await sleep(rand(400, 900));
|
|
322
146
|
}
|
|
323
|
-
|
|
324
147
|
await sleep(rand(200, 400));
|
|
325
148
|
}
|
|
326
149
|
|
|
327
|
-
/**
|
|
328
|
-
* Human-like scroll — smooth, variable speed, realistic
|
|
329
|
-
*/
|
|
330
150
|
async function humanScroll(page, direction = 'down', amount = null) {
|
|
331
151
|
const scrollAmount = amount || rand(200, 600);
|
|
332
152
|
const delta = direction === 'down' ? scrollAmount : -scrollAmount;
|
|
333
|
-
|
|
334
|
-
// Move to random position first
|
|
335
153
|
const vp = page.viewportSize();
|
|
336
154
|
await humanMouseMove(page, rand(100, vp.width - 100), rand(200, vp.height - 200));
|
|
337
|
-
|
|
338
|
-
// Scroll in small increments
|
|
339
155
|
const steps = rand(4, 10);
|
|
340
156
|
for (let i = 0; i < steps; i++) {
|
|
341
157
|
await page.mouse.wheel(0, delta / steps + rand(-5, 5));
|
|
@@ -344,210 +160,45 @@ async function humanScroll(page, direction = 'down', amount = null) {
|
|
|
344
160
|
await sleep(rand(200, 800));
|
|
345
161
|
}
|
|
346
162
|
|
|
347
|
-
/**
|
|
348
|
-
* Human-like page read pause (look around the page)
|
|
349
|
-
*/
|
|
350
163
|
async function humanRead(page, minMs = 1500, maxMs = 4000) {
|
|
351
164
|
await sleep(rand(minMs, maxMs));
|
|
352
|
-
|
|
353
|
-
if (Math.random() < 0.3) {
|
|
354
|
-
await humanScroll(page, 'down', rand(50, 150));
|
|
355
|
-
}
|
|
165
|
+
if (Math.random() < 0.3) await humanScroll(page, 'down', rand(50, 150));
|
|
356
166
|
}
|
|
357
167
|
|
|
358
|
-
// ───
|
|
168
|
+
// ─── LAUNCH ───────────────────────────────────────────────────────────────────
|
|
359
169
|
|
|
360
170
|
/**
|
|
361
|
-
*
|
|
171
|
+
* Launch a human-like browser with residential proxy
|
|
362
172
|
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
|
368
178
|
*
|
|
369
|
-
*
|
|
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)
|
|
375
|
-
*/
|
|
376
|
-
async function solveCaptcha(page, opts = {}) {
|
|
377
|
-
const {
|
|
378
|
-
apiKey = process.env.TWOCAPTCHA_KEY || '14cbfeed64fea439d5c055111d6760e5',
|
|
379
|
-
action = 'verify',
|
|
380
|
-
minScore = 0.7,
|
|
381
|
-
timeout = 120000,
|
|
382
|
-
verbose = false,
|
|
383
|
-
} = opts;
|
|
384
|
-
|
|
385
|
-
if (!apiKey) throw new Error('[2captcha] No API key. Set TWOCAPTCHA_KEY env or pass opts.apiKey');
|
|
386
|
-
|
|
387
|
-
const log = verbose ? (...a) => console.log('[2captcha]', ...a) : () => {};
|
|
388
|
-
const pageUrl = page.url();
|
|
389
|
-
|
|
390
|
-
// ─── Auto-detect captcha type ───────────────────────────────────────────────
|
|
391
|
-
const detected = await page.evaluate(() => {
|
|
392
|
-
// reCAPTCHA v2/v3
|
|
393
|
-
const rc = document.querySelector('.g-recaptcha, [data-sitekey]');
|
|
394
|
-
if (rc) {
|
|
395
|
-
const sitekey = rc.getAttribute('data-sitekey') || rc.getAttribute('data-key');
|
|
396
|
-
const version = rc.getAttribute('data-version') || (typeof window.grecaptcha !== 'undefined' && 'v2');
|
|
397
|
-
return { type: 'recaptcha', sitekey, version: version === 'v3' ? 'v3' : 'v2' };
|
|
398
|
-
}
|
|
399
|
-
// hCaptcha
|
|
400
|
-
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
|
|
406
|
-
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,})/);
|
|
415
|
-
if (rcMatch) return { type: 'recaptcha', sitekey: rcMatch[1], version: 'v2' };
|
|
416
|
-
|
|
417
|
-
return null;
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
if (!detected || !detected.sitekey) {
|
|
421
|
-
throw new Error('[2captcha] No captcha detected on page. Check manually.');
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
log(`Detected ${detected.type} v${detected.version || ''}`, detected.sitekey.slice(0, 20) + '...');
|
|
425
|
-
log(`Page: ${pageUrl}`);
|
|
426
|
-
|
|
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
|
-
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');
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// ─── Inject token into page ─────────────────────────────────────────────────
|
|
482
|
-
await page.evaluate(({ type, token }) => {
|
|
483
|
-
// reCAPTCHA
|
|
484
|
-
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
|
-
}
|
|
506
|
-
}
|
|
507
|
-
// hCaptcha
|
|
508
|
-
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
|
-
}
|
|
515
|
-
}
|
|
516
|
-
// Turnstile
|
|
517
|
-
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
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}, { type: detected.type, token });
|
|
525
|
-
|
|
526
|
-
log('✅ Token injected into page');
|
|
527
|
-
return { token, type: detected.type, sitekey: detected.sitekey };
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// ─── LAUNCH ───────────────────────────────────────────────────────────────────
|
|
531
|
-
|
|
532
|
-
/**
|
|
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)
|
|
179
|
+
* @returns {{ browser, ctx, page, humanClick, humanType, humanScroll, humanRead, sleep, rand }}
|
|
540
180
|
*/
|
|
541
181
|
async function launchHuman(opts = {}) {
|
|
542
182
|
const {
|
|
543
|
-
|
|
183
|
+
country = 'ro',
|
|
184
|
+
mobile = true,
|
|
544
185
|
useProxy = true,
|
|
545
186
|
headless = true,
|
|
546
|
-
country = null,
|
|
547
|
-
session = null,
|
|
548
187
|
} = opts;
|
|
549
188
|
|
|
550
|
-
|
|
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;
|
|
551
202
|
|
|
552
203
|
const browser = await chromium.launch({
|
|
553
204
|
headless,
|
|
@@ -555,7 +206,7 @@ async function launchHuman(opts = {}) {
|
|
|
555
206
|
'--no-sandbox',
|
|
556
207
|
'--disable-setuid-sandbox',
|
|
557
208
|
'--ignore-certificate-errors',
|
|
558
|
-
'--disable-blink-features=AutomationControlled',
|
|
209
|
+
'--disable-blink-features=AutomationControlled',
|
|
559
210
|
'--disable-features=IsolateOrigins,site-per-process',
|
|
560
211
|
'--disable-web-security',
|
|
561
212
|
],
|
|
@@ -566,109 +217,202 @@ async function launchHuman(opts = {}) {
|
|
|
566
217
|
ignoreHTTPSErrors: true,
|
|
567
218
|
permissions: ['geolocation', 'notifications'],
|
|
568
219
|
};
|
|
569
|
-
|
|
570
|
-
if (useProxy) {
|
|
571
|
-
// Each unique session = unique sticky IP. Same session = same IP.
|
|
572
|
-
ctxOpts.proxy = makeProxy(session, country);
|
|
573
|
-
}
|
|
220
|
+
if (proxy) ctxOpts.proxy = proxy;
|
|
574
221
|
|
|
575
222
|
const ctx = await browser.newContext(ctxOpts);
|
|
576
223
|
|
|
577
|
-
// Anti-detection
|
|
578
|
-
await ctx.addInitScript(() => {
|
|
579
|
-
|
|
580
|
-
Object.defineProperty(navigator, '
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
if (navigator.connection) {
|
|
614
|
-
Object.defineProperty(navigator.connection, 'effectiveType', { get: () => '4g' });
|
|
615
|
-
Object.defineProperty(navigator.connection, 'rtt', { get: () => rand(30, 80) });
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function rand(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
|
|
619
|
-
});
|
|
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 });
|
|
620
233
|
|
|
621
234
|
const page = await ctx.newPage();
|
|
622
235
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
});
|
|
630
282
|
});
|
|
631
|
-
|
|
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
|
+
}
|
|
632
291
|
|
|
633
|
-
|
|
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));
|
|
634
393
|
}
|
|
635
394
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
launchHuman,
|
|
639
|
-
getTrial,
|
|
395
|
+
module.exports = {
|
|
396
|
+
launchHuman, getTrial,
|
|
640
397
|
humanClick, humanMouseMove, humanType, humanScroll, humanRead,
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
398
|
+
shadowQuery, shadowFill, shadowClickButton, dumpInteractiveElements,
|
|
399
|
+
pasteIntoEditor,
|
|
400
|
+
sleep, rand, COUNTRY_META,
|
|
644
401
|
};
|
|
645
402
|
|
|
646
403
|
// ─── QUICK TEST ───────────────────────────────────────────────────────────────
|
|
647
404
|
if (require.main === module) {
|
|
405
|
+
const country = process.argv[2] || 'ro';
|
|
406
|
+
console.log(`🧪 Testing Human Browser — country: ${country.toUpperCase()}\n`);
|
|
648
407
|
(async () => {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
const { browser, page, humanScroll, humanRead } = await launchHuman({ mobile: true });
|
|
652
|
-
|
|
408
|
+
const { browser, page } = await launchHuman({ country, mobile: true });
|
|
653
409
|
await page.goto('https://ipinfo.io/json', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
654
410
|
const info = JSON.parse(await page.textContent('body'));
|
|
655
|
-
console.log(`✅ IP:
|
|
411
|
+
console.log(`✅ IP: ${info.ip}`);
|
|
656
412
|
console.log(`✅ Country: ${info.country} (${info.city})`);
|
|
657
|
-
console.log(`✅ Org:
|
|
658
|
-
console.log(`✅
|
|
659
|
-
|
|
660
|
-
// Test UA
|
|
661
|
-
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
|
-
|
|
413
|
+
console.log(`✅ Org: ${info.org}`);
|
|
414
|
+
console.log(`✅ TZ: ${info.timezone}`);
|
|
671
415
|
await browser.close();
|
|
672
|
-
console.log('\n🎉
|
|
416
|
+
console.log('\n🎉 Human Browser is ready.');
|
|
673
417
|
})().catch(console.error);
|
|
674
418
|
}
|