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