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