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