657 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* browser-human.js — Human Browser for AI Agents v4.0.0
*
* Stealth browser with residential proxies from 10+ countries.
* Appears as iPhone 15 Pro or Desktop Chrome to every website.
* Bypasses Cloudflare, DataDome, PerimeterX out of the box.
*
* Get credentials: https://humanbrowser.dev
* Support: https://t.me/virixlabs
*
* Usage:
* const { launchHuman, getTrial } = require('./browser-human');
* const { browser, page } = await launchHuman({ country: 'us' });
*
* Proxy config via env vars:
* HB_PROXY_PROVIDER — decodo | brightdata | iproyal | nodemaven (default: decodo)
* HB_PROXY_USER — proxy username
* HB_PROXY_PASS — proxy password
* HB_PROXY_SERVER — full override: http://host:port
* HB_PROXY_COUNTRY — country code: ro, us, de, gb, fr, nl, sg... (default: ro)
* HB_PROXY_SESSION — Decodo sticky port 10001-49999 (unique IP per user)
* HB_NO_PROXY — set to "1" to disable proxy entirely
*/
// ─── PLAYWRIGHT RESOLVER ──────────────────────────────────────────────────────
// Works in any context: clawhub install, workspace, Clawster containers
function _requirePlaywright() {
const tries = [
() => require('playwright'),
() => require(`${__dirname}/../node_modules/playwright`),
() => require(`${__dirname}/../../node_modules/playwright`),
() => require(`${process.env.HOME || '/root'}/.openclaw/workspace/node_modules/playwright`),
() => require('./node_modules/playwright'),
];
for (const fn of tries) {
try { return fn(); } catch (_) {}
}
throw new Error(
'[human-browser] playwright not found.\n' +
'Run: npm install playwright && npx playwright install chromium'
);
}
const { chromium } = _requirePlaywright();
// ─── COUNTRY CONFIGS ──────────────────────────────────────────────────────────
const COUNTRY_META = {
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' },
us: { locale: 'en-US', tz: 'America/New_York', lat: 40.7128, lon: -74.006, lang: 'en-US,en;q=0.9' },
uk: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
gb: { locale: 'en-GB', tz: 'Europe/London', lat: 51.5074, lon: -0.1278, lang: 'en-GB,en;q=0.9' },
de: { locale: 'de-DE', tz: 'Europe/Berlin', lat: 52.5200, lon: 13.4050, lang: 'de-DE,de;q=0.9,en;q=0.8' },
nl: { locale: 'nl-NL', tz: 'Europe/Amsterdam', lat: 52.3676, lon: 4.9041, lang: 'nl-NL,nl;q=0.9,en;q=0.8' },
jp: { locale: 'ja-JP', tz: 'Asia/Tokyo', lat: 35.6762, lon: 139.6503, lang: 'ja-JP,ja;q=0.9,en;q=0.8' },
fr: { locale: 'fr-FR', tz: 'Europe/Paris', lat: 48.8566, lon: 2.3522, lang: 'fr-FR,fr;q=0.9,en;q=0.8' },
ca: { locale: 'en-CA', tz: 'America/Toronto', lat: 43.6532, lon: -79.3832, lang: 'en-CA,en;q=0.9' },
au: { locale: 'en-AU', tz: 'Australia/Sydney', lat: -33.8688, lon: 151.2093, lang: 'en-AU,en;q=0.9' },
sg: { locale: 'en-SG', tz: 'Asia/Singapore', lat: 1.3521, lon: 103.8198, lang: 'en-SG,en;q=0.9' },
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' },
in: { locale: 'en-IN', tz: 'Asia/Kolkata', lat: 28.6139, lon: 77.2090, lang: 'en-IN,en;q=0.9,hi;q=0.8' },
};
// ─── DEVICE PROFILES ─────────────────────────────────────────────────────────
function buildDevice(mobile, country = 'ro') {
const meta = COUNTRY_META[country.toLowerCase()] || COUNTRY_META.ro;
if (mobile) {
return {
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',
viewport: { width: 393, height: 852 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
locale: meta.locale,
timezoneId: meta.tz,
geolocation: { latitude: meta.lat, longitude: meta.lon, accuracy: 50 },
colorScheme: 'light',
extraHTTPHeaders: {
'Accept-Language': meta.lang,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'sec-fetch-dest': 'document',
'sec-fetch-mode': 'navigate',
'sec-fetch-site': 'none',
},
};
}
return {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
viewport: { width: 1440, height: 900 },
locale: meta.locale,
timezoneId: meta.tz,
geolocation: { latitude: meta.lat, longitude: meta.lon, accuracy: 50 },
colorScheme: 'light',
extraHTTPHeaders: {
'Accept-Language': meta.lang,
'sec-ch-ua': '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
},
};
}
// ─── PROXY PRESETS ────────────────────────────────────────────────────────────
// ⚠️ defaultUser/defaultPass are ALWAYS null — credentials come from env vars
// or getTrial(). NEVER hardcode credentials here.
const PROXY_PRESETS = {
decodo: {
// Sticky session via port number: each unique port = unique sticky IP
serverTemplate: (country, port) => `http://${country}.decodo.com:${port}`,
usernameTemplate: (user) => user,
defaultUser: null,
defaultPass: null,
defaultCountry: 'ro',
stickyPortMin: 10001,
stickyPortMax: 49999,
},
brightdata: {
server: 'http://brd.superproxy.io:33335',
usernameTemplate: (user, country, session) =>
`${user}-country-${country}-session-${session}`,
defaultUser: null,
defaultPass: null,
defaultCountry: 'ro',
},
iproyal: {
server: 'http://geo.iproyal.com:12321',
usernameTemplate: (user) => user,
passwordTemplate: (pass, country, session) =>
`${pass}_country-${country}_session-${session}_lifetime-30m`,
defaultUser: null,
defaultPass: null,
defaultCountry: 'ro',
},
nodemaven: {
server: 'http://rp.nodemavenio.com:10001',
usernameTemplate: (user, country, session) =>
`${user}-country-${country}-session-${session}`,
defaultUser: null,
defaultPass: null,
defaultCountry: 'ro',
},
};
function makeProxy(sessionId = null, country = null) {
if (process.env.HB_NO_PROXY === '1') return null;
const providerName = process.env.HB_PROXY_PROVIDER || 'decodo';
const preset = PROXY_PRESETS[providerName] || PROXY_PRESETS.decodo;
const cty = (country || process.env.HB_PROXY_COUNTRY || preset.defaultCountry || 'ro').toLowerCase();
// Full manual override
if (process.env.HB_PROXY_SERVER && process.env.HB_PROXY_USER) {
return {
server: process.env.HB_PROXY_SERVER,
username: process.env.HB_PROXY_USER,
password: process.env.HB_PROXY_PASS || '',
};
}
// Legacy env var support
const user = process.env.HB_PROXY_USER || process.env.PROXY_USER || process.env.PROXY_USERNAME || preset.defaultUser;
const pass = process.env.HB_PROXY_PASS || process.env.PROXY_PASS || process.env.PROXY_PASSWORD || preset.defaultPass;
if (!user || !pass) {
console.warn(`[browser-human] No proxy credentials for "${providerName}". Call getTrial() or set HB_PROXY_USER/HB_PROXY_PASS.`);
return null;
}
// Decodo: port-based sticky sessions
if (preset.serverTemplate) {
const portMin = preset.stickyPortMin || 10001;
const portMax = preset.stickyPortMax || 49999;
const port = sessionId
? parseInt(sessionId)
: (process.env.HB_PROXY_SESSION
? parseInt(process.env.HB_PROXY_SESSION)
: Math.floor(Math.random() * (portMax - portMin + 1)) + portMin);
const server = preset.serverTemplate(cty, port);
const username = preset.usernameTemplate(user, cty, port);
const password = preset.passwordTemplate
? preset.passwordTemplate(pass, cty, port)
: pass;
return { server, username, password };
}
// Other providers: session-string based
const sid = sessionId || process.env.HB_PROXY_SESSION || Math.random().toString(36).slice(2, 10);
const server = preset.server;
const username = preset.usernameTemplate(user, cty, sid);
const password = preset.passwordTemplate ? preset.passwordTemplate(pass, cty, sid) : pass;
return { server, username, password };
}
// ─── TRIAL CREDENTIALS ───────────────────────────────────────────────────────
/**
* Get free trial credentials from humanbrowser.dev
* Sets HB_PROXY_USER, HB_PROXY_PASS, HB_PROXY_SESSION, HB_PROXY_PROVIDER
* No signup needed — Romania residential proxy
*
* @example
* await getTrial();
* const { page } = await launchHuman(); // now uses trial proxy
*/
async function getTrial() {
if (process.env.HB_PROXY_USER || process.env.PROXY_USER) {
console.log('[human-browser] Credentials already set, skipping trial fetch.');
return { ok: true, cached: true };
}
try {
const https = require('https');
const data = await new Promise((resolve, reject) => {
const req = https.get('https://humanbrowser.dev/api/trial', res => {
let body = '';
res.on('data', d => body += d);
res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
});
req.on('error', reject);
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Trial request timed out')); });
});
if (data.proxy_user || data.PROXY_USER) {
const user = data.proxy_user || data.PROXY_USER;
const pass = data.proxy_pass || data.PROXY_PASS;
const session = data.session || data.PROXY_SESSION || String(Math.floor(Math.random() * 39999) + 10001);
const provider = data.provider || 'decodo';
const country = data.country || 'ro';
process.env.HB_PROXY_PROVIDER = provider;
process.env.HB_PROXY_USER = user;
process.env.HB_PROXY_PASS = pass;
process.env.HB_PROXY_SESSION = session;
if (!process.env.HB_PROXY_COUNTRY) process.env.HB_PROXY_COUNTRY = country;
console.log(`🎉 Human Browser trial activated! (~100MB Romania residential IP)`);
console.log(` Upgrade at: https://humanbrowser.dev\n`);
return { ok: true, provider, country, session };
}
throw new Error(data.error || 'No credentials in trial response');
} catch (err) {
const e = new Error(err.message);
e.code = 'TRIAL_UNAVAILABLE';
e.cta_url = 'https://humanbrowser.dev';
console.warn('[human-browser] Trial fetch failed:', err.message);
console.warn(' → Get credentials at: https://humanbrowser.dev');
throw e;
}
}
// ─── HUMAN BEHAVIOR ───────────────────────────────────────────────────────────
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
/**
* Move mouse along a natural cubic Bezier curve path
*/
async function humanMouseMove(page, toX, toY, fromX = null, fromY = null) {
const startX = fromX ?? rand(100, 300);
const startY = fromY ?? rand(200, 600);
const cp1x = startX + rand(-80, 80), cp1y = startY + rand(-60, 60);
const cp2x = toX + rand(-50, 50), cp2y = toY + rand(-40, 40);
const steps = rand(12, 25);
for (let i = 0; i <= steps; i++) {
const t = i / steps;
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);
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);
await page.mouse.move(x, y);
await sleep(t < 0.2 || t > 0.8 ? rand(8, 20) : rand(2, 8));
}
}
/**
* Human-like click with curved mouse path
*/
async function humanClick(page, x, y) {
await humanMouseMove(page, x, y);
await sleep(rand(50, 180));
await page.mouse.down();
await sleep(rand(40, 100));
await page.mouse.up();
await sleep(rand(100, 300));
}
/**
* Human-like typing: variable speed (60220ms/char), occasional micro-pauses
*/
async function humanType(page, selector, text) {
const el = await page.$(selector);
if (!el) throw new Error(`Element not found: ${selector}`);
const box = await el.boundingBox();
if (box) await humanClick(page, box.x + box.width / 2, box.y + box.height / 2);
await sleep(rand(200, 500));
for (const char of text) {
await page.keyboard.type(char);
await sleep(rand(60, 220));
if (Math.random() < 0.08) await sleep(rand(400, 900));
}
await sleep(rand(200, 400));
}
/**
* Human-like scroll: smooth, multi-step, with jitter
*/
async function humanScroll(page, direction = 'down', amount = null) {
const scrollAmount = amount || rand(200, 600);
const delta = direction === 'down' ? scrollAmount : -scrollAmount;
const vp = page.viewportSize();
await humanMouseMove(page, rand(100, vp.width - 100), rand(200, vp.height - 200));
const steps = rand(4, 10);
for (let i = 0; i < steps; i++) {
await page.mouse.wheel(0, delta / steps + rand(-5, 5));
await sleep(rand(30, 80));
}
await sleep(rand(200, 800));
}
/**
* Read pause — wait as if reading the page, occasional scroll
*/
async function humanRead(page, minMs = 1500, maxMs = 4000) {
await sleep(rand(minMs, maxMs));
if (Math.random() < 0.3) await humanScroll(page, 'down', rand(50, 150));
}
// ─── 2CAPTCHA SOLVER ──────────────────────────────────────────────────────────
/**
* Auto-detect and solve reCAPTCHA v2/v3, hCaptcha, Cloudflare Turnstile via 2captcha.com
* Token is auto-injected into the page — just submit the form after calling this.
*
* @param {Page} page
* @param {Object} opts
* @param {string} opts.apiKey — 2captcha API key (default: env TWOCAPTCHA_KEY)
* @param {string} opts.action — reCAPTCHA v3 action (default: 'verify')
* @param {number} opts.minScore — reCAPTCHA v3 min score (default: 0.7)
* @param {number} opts.timeout — max wait ms (default: 120000)
* @param {boolean} opts.verbose — log progress (default: false)
*
* @example
* const { token, type } = await solveCaptcha(page, { verbose: true });
* await page.click('button[type=submit]');
*/
async function solveCaptcha(page, opts = {}) {
const {
apiKey = process.env.TWOCAPTCHA_KEY,
action = 'verify',
minScore = 0.7,
timeout = 120000,
verbose = false,
} = opts;
if (!apiKey) throw new Error('[2captcha] No API key. Set TWOCAPTCHA_KEY env or pass opts.apiKey');
const log = verbose ? (...a) => console.log('[2captcha]', ...a) : () => {};
const pageUrl = page.url();
// Auto-detect captcha type
const detected = await page.evaluate(() => {
const rc = document.querySelector('.g-recaptcha, [data-sitekey]');
if (rc) {
const sitekey = rc.getAttribute('data-sitekey') || rc.getAttribute('data-key');
const version = rc.getAttribute('data-version') || (typeof window.grecaptcha !== 'undefined' && 'v2');
return { type: 'recaptcha', sitekey, version: version === 'v3' ? 'v3' : 'v2' };
}
const hc = document.querySelector('.h-captcha, [data-hcaptcha-sitekey]');
if (hc) return { type: 'hcaptcha', sitekey: hc.getAttribute('data-sitekey') || hc.getAttribute('data-hcaptcha-sitekey') };
const ts = document.querySelector('.cf-turnstile, [data-cf-turnstile-sitekey]');
if (ts) return { type: 'turnstile', sitekey: ts.getAttribute('data-sitekey') || ts.getAttribute('data-cf-turnstile-sitekey') };
const scripts = [...document.scripts].map(s => s.src + s.textContent).join(' ');
const rcMatch = scripts.match(/(?:sitekey|data-sitekey)['":\s]+([A-Za-z0-9_-]{40,})/);
if (rcMatch) return { type: 'recaptcha', sitekey: rcMatch[1], version: 'v2' };
return null;
});
if (!detected || !detected.sitekey) throw new Error('[2captcha] No captcha detected on page.');
log(`Detected ${detected.type} v${detected.version || ''}`, detected.sitekey.slice(0, 20) + '...');
// Submit to 2captcha
let submitUrl = `https://2captcha.com/in.php?key=${apiKey}&json=1&pageurl=${encodeURIComponent(pageUrl)}&googlekey=${encodeURIComponent(detected.sitekey)}`;
if (detected.type === 'recaptcha') {
submitUrl += `&method=userrecaptcha`;
if (detected.version === 'v3') submitUrl += `&version=v3&action=${action}&min_score=${minScore}`;
} else if (detected.type === 'hcaptcha') {
submitUrl += `&method=hcaptcha&sitekey=${encodeURIComponent(detected.sitekey)}`;
} else if (detected.type === 'turnstile') {
submitUrl += `&method=turnstile&sitekey=${encodeURIComponent(detected.sitekey)}`;
}
const submitResp = await fetch(submitUrl);
const submitData = await submitResp.json();
if (!submitData.status || submitData.status !== 1) throw new Error(`[2captcha] Submit failed: ${JSON.stringify(submitData)}`);
const taskId = submitData.request;
log(`Task submitted: ${taskId} — waiting for workers...`);
let token = null;
const maxAttempts = Math.floor(timeout / 5000);
for (let i = 0; i < maxAttempts; i++) {
await sleep(i === 0 ? 15000 : 5000);
const pollResp = await fetch(`https://2captcha.com/res.php?key=${apiKey}&action=get&id=${taskId}&json=1`);
const pollData = await pollResp.json();
if (pollData.status === 1) { token = pollData.request; log('✅ Solved!'); break; }
if (pollData.request !== 'CAPCHA_NOT_READY') throw new Error(`[2captcha] Poll error: ${JSON.stringify(pollData)}`);
log(`⏳ Attempt ${i + 1}/${maxAttempts}...`);
}
if (!token) throw new Error('[2captcha] Timeout waiting for captcha solution');
// Inject token into page
await page.evaluate(({ type, token }) => {
if (type === 'recaptcha' || type === 'turnstile') {
const ta = document.querySelector('#g-recaptcha-response, [name="g-recaptcha-response"]');
if (ta) { ta.style.display = 'block'; ta.value = token; ta.dispatchEvent(new Event('change', { bubbles: true })); }
try {
const clients = window.___grecaptcha_cfg && window.___grecaptcha_cfg.clients;
if (clients) Object.values(clients).forEach(c => Object.values(c).forEach(w => { if (w && typeof w.callback === 'function') w.callback(token); }));
} catch (_) {}
}
if (type === 'hcaptcha') {
const ta = document.querySelector('[name="h-captcha-response"], #h-captcha-response');
if (ta) { ta.style.display = 'block'; ta.value = token; ta.dispatchEvent(new Event('change', { bubbles: true })); }
}
if (type === 'turnstile') {
const inp = document.querySelector('[name="cf-turnstile-response"]');
if (inp) { inp.value = token; inp.dispatchEvent(new Event('change', { bubbles: true })); }
}
}, { type: detected.type, token });
log('✅ Token injected');
return { token, type: detected.type, sitekey: detected.sitekey };
}
// ─── LAUNCH ───────────────────────────────────────────────────────────────────
/**
* Launch a human-like browser with residential proxy and device fingerprint
*
* @param {Object} opts
* @param {string} opts.country — 'ro'|'us'|'gb'|'de'|'nl'|'jp'|'fr'|'ca'|'au'|'sg' (default: 'ro')
* @param {boolean} opts.mobile — iPhone 15 Pro (true) or Desktop Chrome (false). Default: true
* @param {boolean} opts.useProxy — Enable residential proxy. Default: true
* @param {boolean} opts.headless — Headless mode. Default: true
* @param {string} opts.session — Sticky session ID / Decodo port (unique IP per value)
*
* @returns {{ browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand }}
*/
async function launchHuman(opts = {}) {
const {
country = null,
mobile = true,
useProxy = true,
headless = true,
session = null,
} = opts;
const cty = country || process.env.HB_PROXY_COUNTRY || 'ro';
// Auto-fetch trial credentials if no proxy is configured
if (useProxy && !process.env.HB_PROXY_USER && !process.env.PROXY_USER && !process.env.HB_PROXY_SERVER) {
try {
await getTrial();
} catch (e) {
console.warn('⚠️ Could not fetch trial credentials:', e.message);
console.warn(' Get credentials at: https://humanbrowser.dev');
}
}
const device = buildDevice(mobile, cty);
const meta = COUNTRY_META[cty.toLowerCase()] || COUNTRY_META.ro;
const proxy = useProxy ? makeProxy(session, cty) : null;
const browser = await chromium.launch({
headless,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--ignore-certificate-errors',
'--disable-blink-features=AutomationControlled',
'--disable-features=IsolateOrigins,site-per-process',
'--disable-web-security',
],
});
const ctxOpts = {
...device,
ignoreHTTPSErrors: true,
permissions: ['geolocation', 'notifications'],
};
if (proxy) ctxOpts.proxy = proxy;
const ctx = await browser.newContext(ctxOpts);
// Anti-detection: override navigator properties
await ctx.addInitScript((m) => {
Object.defineProperty(navigator, 'webdriver', { get: () => false });
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => m.mobile ? 5 : 0 });
Object.defineProperty(navigator, 'platform', { get: () => m.mobile ? 'iPhone' : 'Win32' });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => m.mobile ? 6 : 8 });
Object.defineProperty(navigator, 'language', { get: () => m.locale });
Object.defineProperty(navigator, 'languages', { get: () => [m.locale, 'en'] });
if (m.mobile) {
Object.defineProperty(screen, 'width', { get: () => 393 });
Object.defineProperty(screen, 'height', { get: () => 852 });
Object.defineProperty(screen, 'availWidth', { get: () => 393 });
Object.defineProperty(screen, 'availHeight', { get: () => 852 });
}
if (navigator.connection) {
try {
Object.defineProperty(navigator.connection, 'effectiveType', { get: () => '4g' });
} catch (_) {}
}
}, { mobile, locale: meta.locale });
const page = await ctx.newPage();
return { browser, ctx, page, humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand };
}
// ─── SHADOW DOM UTILITIES ─────────────────────────────────────────────────────
/**
* Query an element inside shadow DOM (any depth).
* Use when page.$() returns null but element is visible on screen.
*/
async function shadowQuery(page, selector) {
return page.evaluate((sel) => {
function q(root, s) {
const el = root.querySelector(s); if (el) return el;
for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = q(n.shadowRoot, s); if (f) return f; }
}
return q(document, sel);
}, selector);
}
/**
* Fill an input inside shadow DOM.
* Uses native input setter to trigger React/Angular onChange properly.
*/
async function shadowFill(page, selector, value) {
await page.evaluate(({ sel, val }) => {
function q(root, s) {
const el = root.querySelector(s); if (el) return el;
for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = q(n.shadowRoot, s); if (f) return f; }
}
const el = q(document, sel);
if (!el) throw new Error('shadowFill: not found: ' + sel);
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, val);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, { sel: selector, val: value });
}
/**
* Click a button by text label, searching through shadow DOM.
*/
async function shadowClickButton(page, buttonText) {
await page.evaluate((text) => {
function find(root) {
for (const b of root.querySelectorAll('button')) if (b.textContent.trim() === text) return b;
for (const n of root.querySelectorAll('*')) if (n.shadowRoot) { const f = find(n.shadowRoot); if (f) return f; }
}
const btn = find(document);
if (!btn) throw new Error('shadowClickButton: not found: ' + text);
btn.click();
}, buttonText);
}
/**
* Dump all interactive elements including inside shadow roots.
* Use for debugging when form elements aren't found by standard selectors.
*/
async function dumpInteractiveElements(page) {
return page.evaluate(() => {
const res = [];
function collect(root) {
for (const el of root.querySelectorAll('input,textarea,button,select,[contenteditable]')) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0)
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) || '' });
}
for (const n of root.querySelectorAll('*')) if (n.shadowRoot) collect(n.shadowRoot);
}
collect(document);
return res;
});
}
// ─── RICH TEXT EDITOR UTILITIES ───────────────────────────────────────────────
/**
* Paste text into a Lexical/ProseMirror/Quill/Draft.js rich text editor.
* Uses clipboard API — works where keyboard.type() and fill() fail.
*
* Common selectors:
* '[data-lexical-editor]' — Reddit, Meta apps
* '.public-DraftEditor-content' — Draft.js (Twitter, Quora)
* '.ql-editor' — Quill
* '.ProseMirror' — Linear, Confluence
* '[contenteditable="true"]' — generic
*/
async function pasteIntoEditor(page, editorSelector, text) {
const el = await page.$(editorSelector);
if (!el) throw new Error('pasteIntoEditor: editor not found: ' + editorSelector);
await el.click();
await sleep(300);
await page.evaluate((t) => {
const ta = document.createElement('textarea');
ta.value = t;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}, text);
await page.keyboard.press('Control+a');
await sleep(100);
await page.keyboard.press('Control+v');
await sleep(500);
}
// ─── EXPORTS ──────────────────────────────────────────────────────────────────
module.exports = {
launchHuman, getTrial,
humanClick, humanMouseMove, humanType, humanScroll, humanRead,
solveCaptcha,
shadowQuery, shadowFill, shadowClickButton, dumpInteractiveElements,
pasteIntoEditor,
makeProxy, buildDevice,
sleep, rand, COUNTRY_META,
};
// ─── QUICK TEST ───────────────────────────────────────────────────────────────
if (require.main === module) {
const country = process.argv[2] || 'ro';
console.log(`🧪 Testing Human Browser v4.0.0 — country: ${country.toUpperCase()}\n`);
(async () => {
const { browser, page } = await launchHuman({ country, mobile: true });
await page.goto('https://ipinfo.io/json', { waitUntil: 'domcontentloaded', timeout: 30000 });
const info = JSON.parse(await page.textContent('body'));
console.log(`✅ IP: ${info.ip}`);
console.log(`✅ Country: ${info.country} (${info.city})`);
console.log(`✅ Org: ${info.org}`);
console.log(`✅ TZ: ${info.timezone}`);
const ua = await page.evaluate(() => navigator.userAgent);
console.log(`✅ UA: ${ua.slice(0, 80)}...`);
await browser.close();
console.log('\n🎉 Human Browser v4.0.0 is ready.');
})().catch(console.error);
}