human-browser 4.3.4 → 4.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "human-browser",
3
- "version": "4.3.4",
3
+ "version": "4.4.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",
@@ -25,20 +25,60 @@
25
25
  // ─── PLAYWRIGHT RESOLVER ──────────────────────────────────────────────────────
26
26
  // Works in any context: clawhub install, workspace, Clawster containers
27
27
 
28
+ // Try patchright first (drop-in Playwright fork with CDP-leak patches:
29
+ // no Runtime.enable, no HeadlessChrome UA, navigator.webdriver removed at
30
+ // source level). Fall back to vanilla playwright if patchright is not
31
+ // installed — keeps the npm package usable without the optional dep.
28
32
  function _requirePlaywright() {
33
+ const fs = require('fs');
29
34
  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
+ ['patchright', () => require('patchright')],
36
+ ['patchright (server/node_modules)', () => require(`${__dirname}/../node_modules/patchright`)],
37
+ ['patchright (workspace)', () => require(`${__dirname}/../../node_modules/patchright`)],
38
+ ['patchright (server/prototype/node_modules)', () => require(`${__dirname}/../prototype/node_modules/patchright`)],
39
+ ['patchright (/app/prototype)', () => require('/app/prototype/node_modules/patchright')],
40
+ ['playwright', () => require('playwright')],
41
+ ['playwright (server/node_modules)', () => require(`${__dirname}/../node_modules/playwright`)],
42
+ ['playwright (workspace)', () => require(`${__dirname}/../../node_modules/playwright`)],
43
+ ['playwright (HOME workspace)', () => require(`${process.env.HOME || '/root'}/.openclaw/workspace/node_modules/playwright`)],
44
+ ['playwright (./node_modules)', () => require('./node_modules/playwright')],
35
45
  ];
36
- for (const fn of tries) {
37
- try { return fn(); } catch (_) {}
46
+ // Verify the chromium executable is actually present before accepting a
47
+ // module patchright pins to a different chromium revision than playwright,
48
+ // and v48 production was broken because patchright was loadable but its
49
+ // chromium-1217 binary wasn't installed in the image.
50
+ function _executableExists(mod) {
51
+ try {
52
+ const exe = mod.chromium.executablePath();
53
+ return exe && fs.existsSync(exe);
54
+ } catch (_) {
55
+ return false;
56
+ }
57
+ }
58
+ let lastValid = null;
59
+ let lastValidLabel = null;
60
+ for (const [label, fn] of tries) {
61
+ try {
62
+ const mod = fn();
63
+ if (_executableExists(mod)) {
64
+ try { console.log(`[human-browser] launcher using ${label}`); } catch (_) {}
65
+ return mod;
66
+ }
67
+ // Module loadable but executable missing — keep looking for a usable backend.
68
+ if (!lastValid) { lastValid = mod; lastValidLabel = label; }
69
+ try { console.warn(`[human-browser] ${label} loaded but chromium binary missing — trying next`); } catch (_) {}
70
+ } catch (_) {}
71
+ }
72
+ // No backend has its chromium installed. Return the first loadable module
73
+ // anyway; the eventual launch() will throw a clear "Executable doesn't exist"
74
+ // message that surfaces in session-server logs.
75
+ if (lastValid) {
76
+ try { console.warn(`[human-browser] falling back to ${lastValidLabel} (chromium binary missing — launch will fail)`); } catch (_) {}
77
+ return lastValid;
38
78
  }
39
79
  throw new Error(
40
- '[human-browser] playwright not found.\n' +
41
- 'Run: npm install playwright && npx playwright install chromium'
80
+ '[human-browser] neither patchright nor playwright found.\n' +
81
+ 'Run: npm install patchright playwright && npx playwright install chromium && npx patchright install chromium'
42
82
  );
43
83
  }
44
84
 
@@ -111,9 +151,16 @@ function buildDevice(mobile, country = 'ro') {
111
151
 
112
152
  const PROXY_PRESETS = {
113
153
  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,
154
+ // Canonical Decodo entrypoint: gate.decodo.com:7000 (rotating endpoint)
155
+ // with sticky session + country encoded in username (`user-` prefix +
156
+ // `-country-XX-session-Y-sessionduration-30`). Verified: country IS
157
+ // enforced this way; the port-range form (gate.decodo.com:10001..49999)
158
+ // pins sticky IP but IGNORES country in username — that's why us-zone was
159
+ // leaking UK Sky Broadband IPs. The country-subdomain form
160
+ // (us.decodo.com:port) is the same legacy soft-routing path.
161
+ serverTemplate: (country, port) => `http://gate.decodo.com:7000`,
162
+ usernameTemplate: (user, country, port) =>
163
+ `user-${user}-country-${country}-session-${port}-sessionduration-30`,
117
164
  defaultUser: null,
118
165
  defaultPass: null,
119
166
  defaultCountry: 'ro',
@@ -490,9 +537,35 @@ async function launchHuman(opts = {}) {
490
537
  '--disable-setuid-sandbox',
491
538
  '--ignore-certificate-errors',
492
539
  '--disable-blink-features=AutomationControlled',
493
- '--disable-features=IsolateOrigins,site-per-process',
540
+ // QUIC over UDP can't traverse an HTTP CONNECT proxy; without this
541
+ // Chromium spends 30s+ retrying QUIC against google.com before TCP
542
+ // fallback, blowing the bubus NavigateToUrlEvent budget.
543
+ '--disable-quic',
544
+ // Kill startup chatter that races Playwright's CDP-based proxy auth
545
+ // interceptor (Chromium asks for Proxy-Authorization only after a 407;
546
+ // dozens of concurrent startup fetches all hit 407 simultaneously and
547
+ // some land on a tunnel the proxy already dropped → "duplicate response"
548
+ // CDP warnings + 60s+ NavigateToUrlEvent timeouts).
549
+ '--disable-features=IsolateOrigins,site-per-process,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,AsyncDns,OptimizationHints,OptimizationGuideModelDownloading,OptimizationTargetPrediction,InterestFeedContentSuggestions,Translate,MediaRouter',
550
+ '--disable-background-networking',
551
+ '--disable-component-update',
552
+ '--disable-sync',
553
+ '--disable-domain-reliability',
554
+ '--no-default-browser-check',
555
+ '--no-first-run',
556
+ '--no-pings',
557
+ // Belt-and-braces: tell Chrome to never bypass the proxy locally.
558
+ '--proxy-bypass-list=<-loopback>',
494
559
  ];
495
- if (cdpPort) launchArgs.push(`--remote-debugging-port=${cdpPort}`);
560
+ if (cdpPort) {
561
+ launchArgs.push(`--remote-debugging-port=${cdpPort}`);
562
+ // Chrome 111+ already blocks page-origin DevTools WS by default, but make
563
+ // the policy explicit and future-proof: only allow connections from
564
+ // server-side WS clients (no Origin header) — anything claiming a real
565
+ // page origin is rejected. Setting an unreachable HTTPS sentinel keeps
566
+ // the spec-required allowlist non-empty without granting any real origin.
567
+ launchArgs.push('--remote-allow-origins=https://disabled.invalid');
568
+ }
496
569
 
497
570
  const effectiveHeadless = headed ? false : headless;
498
571
 
@@ -550,6 +623,84 @@ async function launchHuman(opts = {}) {
550
623
  }
551
624
  }, { mobile, locale: meta.locale });
552
625
 
626
+ // Bot-friendly URL rewrites: top-level navigations only, applied via
627
+ // ctx.route(). Reddit's www/new front-end is heavily Cloudflare-bot-blocked
628
+ // for fresh residential IPs; old.reddit.com is on the same auth/cookie space
629
+ // but ships a much lighter (and far less protected) HTML page. Net effect:
630
+ // higher pass-through rate without changing any user-visible profile state.
631
+ await ctx.route(/^https?:\/\/(www\.|new\.|m\.)?reddit\.com\//i, (route) => {
632
+ try {
633
+ const orig = route.request().url();
634
+ // Only rewrite navigation/document loads — leave XHR/static asset paths
635
+ // alone so login flows and OAuth don't break.
636
+ if (route.request().resourceType() !== 'document') return route.continue();
637
+ const rewritten = orig.replace(
638
+ /^(https?:\/\/)(?:www\.|new\.|m\.)?reddit\.com\//i,
639
+ '$1old.reddit.com/'
640
+ );
641
+ if (rewritten === orig) return route.continue();
642
+ console.warn(`[human-browser] rewrite reddit ${orig} -> ${rewritten}`);
643
+ return route.continue({ url: rewritten });
644
+ } catch (_) { try { route.continue(); } catch (_) {} }
645
+ });
646
+
647
+ // Anti-bot response logger. Catches main-frame responses with status that
648
+ // indicates a block/challenge (403/429/451/503) or known CF/Akamai/PerimeterX
649
+ // signatures, and emits a single-line log so the operator can see at a glance
650
+ // when sites are pushing back. Body sniffing is best-effort and bounded
651
+ // (first 4KB) to avoid memory issues on huge pages.
652
+ ctx.on('response', async (resp) => {
653
+ try {
654
+ const req = resp.request();
655
+ // Only main document loads — not images/fonts/scripts.
656
+ if (req.resourceType() !== 'document') return;
657
+ const url = req.url();
658
+ const status = resp.status();
659
+ let host = '';
660
+ try { host = new URL(url).host; } catch (_) {}
661
+
662
+ // Status-based ban signals.
663
+ const banStatus = status === 403 || status === 429 || status === 451 ||
664
+ (status === 503 && host !== '127.0.0.1');
665
+
666
+ // Header signatures (Cloudflare's challenge / hCaptcha / Akamai BMP).
667
+ let banReason = null;
668
+ const hdrs = resp.headers();
669
+ const cfChlg = hdrs['cf-mitigated'] || hdrs['cf-chl-bypass'] || '';
670
+ const server = (hdrs['server'] || '').toLowerCase();
671
+ const xrh = (hdrs['x-robots-tag'] || '').toLowerCase();
672
+ if (cfChlg) banReason = `cf-mitigated:${cfChlg}`;
673
+ else if (server.includes('akamaighost') && status >= 400) banReason = 'akamai-block';
674
+ else if (banStatus) banReason = `status-${status}`;
675
+
676
+ if (!banReason) return;
677
+
678
+ // Best-effort body sniff for inline reason. Kept tiny.
679
+ let bodyHint = '';
680
+ try {
681
+ const buf = await resp.body();
682
+ const txt = buf.slice(0, 4096).toString('utf8').replace(/\s+/g, ' ');
683
+ // Pull a few telltale phrases.
684
+ const matchers = [
685
+ /just a moment/i, /attention required/i, /access denied/i,
686
+ /blocked.{0,40}network security/i, /verifying you are human/i,
687
+ /your request has been blocked/i, /unusual traffic/i,
688
+ /captcha/i, /perimeterx/i, /datadome/i, /forbidden/i,
689
+ ];
690
+ for (const re of matchers) {
691
+ const m = txt.match(re);
692
+ if (m) { bodyHint = m[0].slice(0, 80); break; }
693
+ }
694
+ } catch (_) {}
695
+
696
+ console.warn(
697
+ `[human-browser] BLOCKED host=${host} status=${status} reason=${banReason}` +
698
+ (bodyHint ? ` hint="${bodyHint}"` : '') +
699
+ ` url=${url.slice(0, 200)}`
700
+ );
701
+ } catch (_) { /* listener must never throw */ }
702
+ });
703
+
553
704
  // Persistent context launches with a default page; reuse it instead of
554
705
  // opening a second tab (ephemeral context starts with no pages).
555
706
  const existing = ctx.pages();
@@ -573,6 +724,12 @@ async function launchHuman(opts = {}) {
573
724
  return {
574
725
  browser, ctx, page,
575
726
  cdpHttpUrl, cdpWsUrl,
727
+ proxy, // resolved {server, username, password} or null — needed so callers
728
+ // (session-server.js → browser-use-runner.py) can hand the same creds
729
+ // to browser-use's root-CDP proxy auth handler. Without this, browser-use
730
+ // creates its own targets via raw CDP and Chromium returns 407 because
731
+ // patchright's Fetch.authRequired interceptor is bound per-CRPage, not
732
+ // browser-wide.
576
733
  humanClick, humanMouseMove, humanType, humanScroll, humanRead, sleep, rand,
577
734
  };
578
735
  }