@tangle-network/browser-agent-driver 0.10.0 → 0.11.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.
Files changed (74) hide show
  1. package/dist/brain/index.d.ts +2 -0
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +96 -10
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/browser-launch.d.ts +3 -0
  6. package/dist/browser-launch.d.ts.map +1 -1
  7. package/dist/browser-launch.js +12 -2
  8. package/dist/browser-launch.js.map +1 -1
  9. package/dist/cli-design-audit.d.ts.map +1 -1
  10. package/dist/cli-design-audit.js +250 -2
  11. package/dist/cli-design-audit.js.map +1 -1
  12. package/dist/cli-showcase.d.ts +28 -0
  13. package/dist/cli-showcase.d.ts.map +1 -0
  14. package/dist/cli-showcase.js +131 -0
  15. package/dist/cli-showcase.js.map +1 -0
  16. package/dist/cli-ui.d.ts.map +1 -1
  17. package/dist/cli-ui.js +23 -2
  18. package/dist/cli-ui.js.map +1 -1
  19. package/dist/cli.js +371 -211
  20. package/dist/cli.js.map +1 -1
  21. package/dist/config.d.ts +4 -0
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +2 -1
  24. package/dist/config.js.map +1 -1
  25. package/dist/design/compare.d.ts +9 -0
  26. package/dist/design/compare.d.ts.map +1 -0
  27. package/dist/design/compare.js +339 -0
  28. package/dist/design/compare.js.map +1 -0
  29. package/dist/design/index.d.ts +6 -0
  30. package/dist/design/index.d.ts.map +1 -0
  31. package/dist/design/index.js +5 -0
  32. package/dist/design/index.js.map +1 -0
  33. package/dist/design/page-interaction.d.ts +24 -0
  34. package/dist/design/page-interaction.d.ts.map +1 -0
  35. package/dist/design/page-interaction.js +244 -0
  36. package/dist/design/page-interaction.js.map +1 -0
  37. package/dist/design/rip.d.ts +9 -0
  38. package/dist/design/rip.d.ts.map +1 -0
  39. package/dist/design/rip.js +362 -0
  40. package/dist/design/rip.js.map +1 -0
  41. package/dist/design/types.d.ts +126 -0
  42. package/dist/design/types.d.ts.map +1 -0
  43. package/dist/design/types.js +3 -0
  44. package/dist/design/types.js.map +1 -0
  45. package/dist/index.d.ts +5 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +4 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/runner/runner.d.ts.map +1 -1
  50. package/dist/runner/runner.js +24 -0
  51. package/dist/runner/runner.js.map +1 -1
  52. package/dist/showcase/annotate.d.ts +28 -0
  53. package/dist/showcase/annotate.d.ts.map +1 -0
  54. package/dist/showcase/annotate.js +95 -0
  55. package/dist/showcase/annotate.js.map +1 -0
  56. package/dist/showcase/assemble.d.ts +31 -0
  57. package/dist/showcase/assemble.d.ts.map +1 -0
  58. package/dist/showcase/assemble.js +107 -0
  59. package/dist/showcase/assemble.js.map +1 -0
  60. package/dist/showcase/demo-player.d.ts +53 -0
  61. package/dist/showcase/demo-player.d.ts.map +1 -0
  62. package/dist/showcase/demo-player.js +325 -0
  63. package/dist/showcase/demo-player.js.map +1 -0
  64. package/dist/showcase/index.d.ts +21 -0
  65. package/dist/showcase/index.d.ts.map +1 -0
  66. package/dist/showcase/index.js +289 -0
  67. package/dist/showcase/index.js.map +1 -0
  68. package/dist/showcase/types.d.ts +111 -0
  69. package/dist/showcase/types.d.ts.map +1 -0
  70. package/dist/showcase/types.js +3 -0
  71. package/dist/showcase/types.js.map +1 -0
  72. package/dist/types.d.ts +22 -0
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +11 -1
package/dist/cli.js CHANGED
@@ -101,6 +101,18 @@ async function main() {
101
101
  'fork-run': { type: 'string' },
102
102
  pages: { type: 'string' },
103
103
  'extract-tokens': { type: 'boolean' },
104
+ rip: { type: 'boolean' },
105
+ 'design-compare': { type: 'boolean' },
106
+ 'compare-url': { type: 'string' },
107
+ // showcase
108
+ script: { type: 'string' },
109
+ capture: { type: 'string' },
110
+ crop: { type: 'string' },
111
+ highlight: { type: 'string' },
112
+ format: { type: 'string' },
113
+ viewport: { type: 'string' },
114
+ scale: { type: 'string' },
115
+ 'color-scheme': { type: 'string' },
104
116
  'llm-timeout': { type: 'string' },
105
117
  retries: { type: 'string' },
106
118
  'retry-delay-ms': { type: 'string' },
@@ -116,6 +128,8 @@ async function main() {
116
128
  timeout: { type: 'string' },
117
129
  extension: { type: 'string', multiple: true },
118
130
  'user-data-dir': { type: 'string' },
131
+ 'profile-dir': { type: 'string' },
132
+ 'cdp-url': { type: 'string' },
119
133
  wallet: { type: 'boolean' },
120
134
  'wallet-auto-approve': { type: 'boolean' },
121
135
  'wallet-password': { type: 'string' },
@@ -160,6 +174,32 @@ async function main() {
160
174
  cliError('--url is required for design-audit.');
161
175
  process.exit(1);
162
176
  }
177
+ // --design-compare mode
178
+ if (values['design-compare']) {
179
+ if (!values['compare-url']) {
180
+ cliError('--compare-url is required with --design-compare.');
181
+ process.exit(1);
182
+ }
183
+ const { runDesignCompare } = await import('./design/compare.js');
184
+ await runDesignCompare({
185
+ urlA: values.url,
186
+ urlB: values['compare-url'],
187
+ headless: values.headless,
188
+ outputDir: values.sink,
189
+ });
190
+ process.exit(0);
191
+ }
192
+ // --rip mode
193
+ if (values.rip) {
194
+ const { ripSite } = await import('./design/rip.js');
195
+ await ripSite({
196
+ url: values.url,
197
+ pages: values.pages ? parseInt(values.pages) : undefined,
198
+ headless: values.headless,
199
+ outputDir: values.sink,
200
+ });
201
+ process.exit(0);
202
+ }
163
203
  const { runDesignAudit } = await import('./cli-design-audit.js');
164
204
  await runDesignAudit({
165
205
  url: values.url,
@@ -208,8 +248,27 @@ async function main() {
208
248
  }
209
249
  process.exit(0);
210
250
  }
251
+ if (command === 'showcase') {
252
+ const { handleShowcase } = await import('./cli-showcase.js');
253
+ await handleShowcase({
254
+ url: values.url,
255
+ script: values.script,
256
+ capture: values.capture,
257
+ crop: values.crop,
258
+ highlight: values.highlight,
259
+ format: values.format,
260
+ viewport: values.viewport,
261
+ output: values.sink,
262
+ headless: values.headless ?? true,
263
+ colorScheme: values['color-scheme'],
264
+ scale: values.scale ? parseFloat(values.scale) : undefined,
265
+ storageState: values['storage-state'],
266
+ quality: values['quality-threshold'] ? parseInt(values['quality-threshold']) : undefined,
267
+ });
268
+ process.exit(0);
269
+ }
211
270
  if (command !== 'run') {
212
- cliError(`Unknown command: ${command}. Use "run", "runs", or "design-audit".`);
271
+ cliError(`Unknown command: ${command}. Use "run", "runs", "design-audit", or "showcase".`);
213
272
  process.exit(1);
214
273
  }
215
274
  // Validate inputs
@@ -378,6 +437,10 @@ async function main() {
378
437
  }
379
438
  }
380
439
  }
440
+ if (values['profile-dir'])
441
+ cliOverrides.profileDir = values['profile-dir'];
442
+ if (values['cdp-url'])
443
+ cliOverrides.cdpUrl = values['cdp-url'];
381
444
  if (values.memory !== undefined || values['memory-dir']) {
382
445
  cliOverrides.memory = {
383
446
  ...(cliOverrides.memory ?? {}),
@@ -438,12 +501,8 @@ async function main() {
438
501
  cliOverrides.resourceBlocking = {
439
502
  ...(cliOverrides.resourceBlocking ?? {}),
440
503
  blockAnalytics: true,
441
- ...(profile === 'benchmark-webbench'
442
- ? {
443
- blockImages: true,
444
- blockMedia: true,
445
- }
446
- : {}),
504
+ blockImages: true,
505
+ blockMedia: true,
447
506
  };
448
507
  }
449
508
  cliOverrides.microPlan = {
@@ -677,9 +736,9 @@ async function main() {
677
736
  cliError(`storage state file not found: ${storageStatePath}`);
678
737
  process.exit(1);
679
738
  }
680
- if (launchPlan.walletMode && browserName !== 'chromium') {
681
- cliError('wallet mode currently supports Chromium only. Set --browser chromium.');
682
- process.exit(1);
739
+ if (launchPlan.persistentContext && !launchPlan.cdpUrl && browserName !== 'chromium') {
740
+ const feature = launchPlan.walletMode ? 'Wallet mode' : '--profile-dir';
741
+ throw new Error(`${feature} requires Chromium. Set --browser chromium.`);
683
742
  }
684
743
  // Ensure clean exit on interrupt
685
744
  process.on('SIGINT', () => { renderer?.destroy(); process.exit(130); });
@@ -690,145 +749,179 @@ async function main() {
690
749
  let persistentContext;
691
750
  let stopWalletAutoApprover;
692
751
  const launchDiagnostics = {};
693
- if (launchPlan.walletMode) {
752
+ // CDP connection — attach to an existing browser (Atlas, Chrome, Brave, etc.)
753
+ let cdpUrl = launchPlan.cdpUrl || process.env.BROWSER_ENDPOINT;
754
+ let cdpConnected = false;
755
+ if (cdpUrl) {
756
+ // Auto-discover WebSocket URL from HTTP endpoint
757
+ if (cdpUrl.startsWith('http://') || cdpUrl.startsWith('https://')) {
758
+ try {
759
+ const versionUrl = cdpUrl.replace(/\/$/, '') + '/json/version';
760
+ const res = await fetch(versionUrl);
761
+ const info = await res.json();
762
+ if (info.webSocketDebuggerUrl) {
763
+ if (info.Browser && !quiet)
764
+ cliLog('cdp', `connected to ${info.Browser}`);
765
+ cdpUrl = info.webSocketDebuggerUrl;
766
+ }
767
+ }
768
+ catch {
769
+ // Fall through — try the URL as-is
770
+ }
771
+ }
772
+ const cdpStartedAt = Date.now();
773
+ if (cdpUrl.includes('/devtools/') || browserName === 'chromium') {
774
+ browser = await chromium.connectOverCDP(cdpUrl);
775
+ }
776
+ else {
777
+ const browserType = browserName === 'firefox' ? firefox : browserName === 'webkit' ? webkit : chromium;
778
+ browser = await browserType.connect(cdpUrl);
779
+ }
780
+ launchDiagnostics.browserLaunchMs = Date.now() - cdpStartedAt;
781
+ launchDiagnostics.cdpUrl = cdpUrl;
782
+ cdpConnected = true;
783
+ }
784
+ else if (launchPlan.persistentContext) {
694
785
  for (const extensionPath of launchPlan.extensionPaths) {
695
786
  if (!fs.existsSync(extensionPath)) {
696
787
  throw new Error(`Wallet extension path does not exist: ${extensionPath}`);
697
788
  }
698
789
  }
699
- const userDataDir = launchPlan.userDataDir ?? path.resolve('.agent-wallet-profile');
790
+ const userDataDir = launchPlan.userDataDir ?? path.resolve(launchPlan.walletMode ? '.agent-wallet-profile' : '.agent-profile');
700
791
  fs.mkdirSync(userDataDir, { recursive: true });
701
792
  const persistentLaunchStartedAt = Date.now();
702
793
  persistentContext = await chromium.launchPersistentContext(userDataDir, {
703
794
  channel: isStealthProfile ? 'chrome' : 'chromium',
704
- headless: false,
795
+ headless: launchPlan.headless,
705
796
  args: launchPlan.browserArgs,
706
797
  viewport,
707
798
  recordVideo: { dir: videoDir, size: viewport },
708
799
  });
709
800
  launchDiagnostics.browserLaunchMs = Date.now() - persistentLaunchStartedAt;
710
801
  await applyStorageStateToPersistentContext(persistentContext, storageStatePath);
711
- const walletConfig = driverConfig.wallet ?? {};
712
- // Intercept page-level JSON-RPC so dApps see wallet balances from the
713
- // local Anvil fork. Only forward user-specific calls (eth_getBalance
714
- // for the wallet, eth_call with wallet address in calldata). Pool data
715
- // and protocol calls go to real endpoints for reliability.
716
- const walletRpcUrl = walletConfig.preflight?.chain?.rpcUrl;
717
- if (walletRpcUrl) {
718
- // Default to Anvil's first derived address if no wallet address configured
719
- const walletAddrFull = (walletConfig.address ?? '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266').toLowerCase();
720
- const walletAddrHex = walletAddrFull.replace('0x', '');
721
- await persistentContext.route('**/*', async (route) => {
722
- try {
723
- const frame = route.request().frame();
724
- if (frame && frame.url().startsWith('chrome-extension://')) {
802
+ if (launchPlan.walletMode) {
803
+ const walletConfig = driverConfig.wallet ?? {};
804
+ // Intercept page-level JSON-RPC so dApps see wallet balances from the
805
+ // local Anvil fork. Only forward user-specific calls (eth_getBalance
806
+ // for the wallet, eth_call with wallet address in calldata). Pool data
807
+ // and protocol calls go to real endpoints for reliability.
808
+ const walletRpcUrl = walletConfig.preflight?.chain?.rpcUrl;
809
+ if (walletRpcUrl) {
810
+ // Default to Anvil's first derived address if no wallet address configured
811
+ const walletAddrFull = (walletConfig.address ?? '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266').toLowerCase();
812
+ const walletAddrHex = walletAddrFull.replace('0x', '');
813
+ await persistentContext.route('**/*', async (route) => {
814
+ try {
815
+ const frame = route.request().frame();
816
+ if (frame && frame.url().startsWith('chrome-extension://')) {
817
+ await route.continue();
818
+ return;
819
+ }
820
+ }
821
+ catch {
725
822
  await route.continue();
726
823
  return;
727
824
  }
728
- }
729
- catch {
730
- await route.continue();
731
- return;
732
- }
733
- if (route.request().method() !== 'POST') {
734
- await route.continue();
735
- return;
736
- }
737
- const ct = route.request().headers()['content-type'] ?? '';
738
- if (!ct.includes('json')) {
739
- await route.continue();
740
- return;
741
- }
742
- const postData = route.request().postData();
743
- if (!postData) {
744
- await route.continue();
745
- return;
746
- }
747
- try {
748
- const body = JSON.parse(postData);
749
- const items = Array.isArray(body) ? body : [body];
750
- // Check if any item involves the wallet (balance, contract call, simulation)
751
- const isUserQuery = items.some((item) => {
752
- const method = item.method;
753
- if (!method)
754
- return false;
755
- if (method === 'eth_getBalance') {
756
- const params = item.params;
757
- return params?.[0]?.toLowerCase() === walletAddrFull;
758
- }
759
- if (method === 'eth_call' || method === 'eth_estimateGas') {
760
- const params = item.params;
761
- const txObj = params?.[0];
762
- if (!txObj)
825
+ if (route.request().method() !== 'POST') {
826
+ await route.continue();
827
+ return;
828
+ }
829
+ const ct = route.request().headers()['content-type'] ?? '';
830
+ if (!ct.includes('json')) {
831
+ await route.continue();
832
+ return;
833
+ }
834
+ const postData = route.request().postData();
835
+ if (!postData) {
836
+ await route.continue();
837
+ return;
838
+ }
839
+ try {
840
+ const body = JSON.parse(postData);
841
+ const items = Array.isArray(body) ? body : [body];
842
+ // Check if any item involves the wallet (balance, contract call, simulation)
843
+ const isUserQuery = items.some((item) => {
844
+ const method = item.method;
845
+ if (!method)
763
846
  return false;
764
- const from = txObj.from?.toLowerCase() ?? '';
765
- const data = txObj.data?.toLowerCase() ?? '';
766
- return from === walletAddrFull || data.includes(walletAddrHex);
767
- }
768
- if (method === 'eth_getTransactionCount') {
769
- const params = item.params;
770
- return params?.[0]?.toLowerCase() === walletAddrFull;
847
+ if (method === 'eth_getBalance') {
848
+ const params = item.params;
849
+ return params?.[0]?.toLowerCase() === walletAddrFull;
850
+ }
851
+ if (method === 'eth_call' || method === 'eth_estimateGas') {
852
+ const params = item.params;
853
+ const txObj = params?.[0];
854
+ if (!txObj)
855
+ return false;
856
+ const from = txObj.from?.toLowerCase() ?? '';
857
+ const data = txObj.data?.toLowerCase() ?? '';
858
+ return from === walletAddrFull || data.includes(walletAddrHex);
859
+ }
860
+ if (method === 'eth_getTransactionCount') {
861
+ const params = item.params;
862
+ return params?.[0]?.toLowerCase() === walletAddrFull;
863
+ }
864
+ return false;
865
+ });
866
+ if (!isUserQuery) {
867
+ await route.continue();
868
+ return;
771
869
  }
772
- return false;
773
- });
774
- if (!isUserQuery) {
870
+ // Normalize: some dApps (Aave) omit jsonrpc/id — Anvil requires them
871
+ let nextId = 1;
872
+ const normalized = items.map((item) => {
873
+ const out = { ...item, jsonrpc: '2.0', id: item.id ?? nextId++ };
874
+ delete out.chainId;
875
+ return out;
876
+ });
877
+ const payload = Array.isArray(body) ? normalized : normalized[0];
878
+ const res = await fetch(walletRpcUrl, {
879
+ method: 'POST',
880
+ headers: { 'Content-Type': 'application/json' },
881
+ body: JSON.stringify(payload),
882
+ });
883
+ await route.fulfill({
884
+ status: res.status,
885
+ contentType: 'application/json',
886
+ body: await res.text(),
887
+ });
888
+ }
889
+ catch {
775
890
  await route.continue();
776
- return;
777
891
  }
778
- // Normalize: some dApps (Aave) omit jsonrpc/id — Anvil requires them
779
- let nextId = 1;
780
- const normalized = items.map((item) => {
781
- const out = { ...item, jsonrpc: '2.0', id: item.id ?? nextId++ };
782
- delete out.chainId;
783
- return out;
784
- });
785
- const payload = Array.isArray(body) ? normalized : normalized[0];
786
- const res = await fetch(walletRpcUrl, {
787
- method: 'POST',
788
- headers: { 'Content-Type': 'application/json' },
789
- body: JSON.stringify(payload),
790
- });
791
- await route.fulfill({
792
- status: res.status,
793
- contentType: 'application/json',
794
- body: await res.text(),
795
- });
796
- }
797
- catch {
798
- await route.continue();
892
+ });
893
+ }
894
+ const shouldAutoApprove = walletConfig.autoApprove ?? true;
895
+ if (shouldAutoApprove) {
896
+ stopWalletAutoApprover = await startWalletAutoApprover(persistentContext, {
897
+ password: walletConfig.password,
898
+ tickMs: walletConfig.tickMs,
899
+ actionSelectors: walletConfig.actionSelectors,
900
+ });
901
+ }
902
+ const preflightEnabled = walletConfig.preflight?.enabled ?? true;
903
+ const preflightSeedUrls = walletConfig.preflight?.seedUrls && walletConfig.preflight.seedUrls.length > 0
904
+ ? walletConfig.preflight.seedUrls
905
+ : [...new Set(cases.map((testCase) => testCase.startUrl).filter(Boolean))];
906
+ if (preflightEnabled && preflightSeedUrls.length > 0) {
907
+ const preflight = await runWalletPreflight(persistentContext, {
908
+ seedUrls: preflightSeedUrls,
909
+ password: walletConfig.password,
910
+ actionSelectors: walletConfig.actionSelectors,
911
+ promptPaths: walletConfig.promptPaths,
912
+ connectSelectors: walletConfig.connectSelectors,
913
+ connectorSelectors: walletConfig.connectorSelectors,
914
+ requestAccounts: walletConfig.preflight?.requestAccounts,
915
+ accountsTimeoutMs: walletConfig.preflight?.accountsTimeoutMs,
916
+ maxChainSwitchAttempts: walletConfig.preflight?.maxChainSwitchAttempts,
917
+ chain: walletConfig.preflight?.chain,
918
+ log: quiet ? undefined : (message) => cliLog('wallet', message),
919
+ });
920
+ if (!preflight.ok) {
921
+ const failed = preflight.results.find((resultEntry) => !resultEntry.ready);
922
+ const details = failed?.details ?? 'unknown reason';
923
+ throw new Error(`Wallet preflight failed for ${preflight.failedUrl ?? 'unknown origin'} (${details})`);
799
924
  }
800
- });
801
- }
802
- const shouldAutoApprove = walletConfig.autoApprove ?? true;
803
- if (shouldAutoApprove) {
804
- stopWalletAutoApprover = await startWalletAutoApprover(persistentContext, {
805
- password: walletConfig.password,
806
- tickMs: walletConfig.tickMs,
807
- actionSelectors: walletConfig.actionSelectors,
808
- });
809
- }
810
- const preflightEnabled = walletConfig.preflight?.enabled ?? true;
811
- const preflightSeedUrls = walletConfig.preflight?.seedUrls && walletConfig.preflight.seedUrls.length > 0
812
- ? walletConfig.preflight.seedUrls
813
- : [...new Set(cases.map((testCase) => testCase.startUrl).filter(Boolean))];
814
- if (preflightEnabled && preflightSeedUrls.length > 0) {
815
- const preflight = await runWalletPreflight(persistentContext, {
816
- seedUrls: preflightSeedUrls,
817
- password: walletConfig.password,
818
- actionSelectors: walletConfig.actionSelectors,
819
- promptPaths: walletConfig.promptPaths,
820
- connectSelectors: walletConfig.connectSelectors,
821
- connectorSelectors: walletConfig.connectorSelectors,
822
- requestAccounts: walletConfig.preflight?.requestAccounts,
823
- accountsTimeoutMs: walletConfig.preflight?.accountsTimeoutMs,
824
- maxChainSwitchAttempts: walletConfig.preflight?.maxChainSwitchAttempts,
825
- chain: walletConfig.preflight?.chain,
826
- log: quiet ? undefined : (message) => cliLog('wallet', message),
827
- });
828
- if (!preflight.ok) {
829
- const failed = preflight.results.find((resultEntry) => !resultEntry.ready);
830
- const details = failed?.details ?? 'unknown reason';
831
- throw new Error(`Wallet preflight failed for ${preflight.failedUrl ?? 'unknown origin'} (${details})`);
832
925
  }
833
926
  }
834
927
  }
@@ -838,94 +931,160 @@ async function main() {
838
931
  : browserName === 'webkit'
839
932
  ? webkit
840
933
  : chromium;
841
- if (process.env.BROWSER_ENDPOINT) {
842
- const endpoint = process.env.BROWSER_ENDPOINT;
843
- const browserLaunchStartedAt = Date.now();
844
- // CDP endpoint (chrome, android-chrome) vs Playwright endpoint (firefox, webkit)
845
- if (browserName === 'chromium' || endpoint.includes('/devtools/')) {
846
- browser = await chromium.connectOverCDP(endpoint);
847
- }
848
- else {
849
- browser = await browserType.connect(endpoint);
850
- }
851
- launchDiagnostics.browserLaunchMs = Date.now() - browserLaunchStartedAt;
934
+ const browserLaunchStartedAt = Date.now();
935
+ browser = await browserType.launch({
936
+ headless: launchPlan.headless,
937
+ ...(browserName === 'chromium' ? { args: launchPlan.browserArgs } : {}),
938
+ // Use system Chrome for stealth profiles — real TLS/JA3 fingerprint vs bundled Chromium
939
+ ...(isStealthProfile && browserName === 'chromium' ? { channel: 'chrome' } : {}),
940
+ });
941
+ launchDiagnostics.browserLaunchMs = Date.now() - browserLaunchStartedAt;
942
+ }
943
+ // Headless Chromium sends "HeadlessChrome/..." in the default User-Agent.
944
+ // CDNs like Akamai reject this with ERR_HTTP2_PROTOCOL_ERROR before any JS
945
+ // stealth patches can run. Build a clean UA from the browser version.
946
+ const headlessUserAgent = launchPlan.headless && browser
947
+ ? (() => {
948
+ const ver = browser.version();
949
+ const plat = process.platform;
950
+ const platformToken = plat === 'win32'
951
+ ? 'Windows NT 10.0; Win64; x64'
952
+ : plat === 'linux'
953
+ ? 'X11; Linux x86_64'
954
+ : 'Macintosh; Intel Mac OS X 10_15_7';
955
+ return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${ver} Safari/537.36`;
956
+ })()
957
+ : undefined;
958
+ const driverFactory = async () => {
959
+ const contextStartedAt = Date.now();
960
+ // CDP: reuse the browser's default context (preserves user cookies/sessions).
961
+ // Persistent context: use the already-opened persistent context.
962
+ // Default: create a fresh isolated context.
963
+ let context;
964
+ if (cdpConnected) {
965
+ // Reuse the user's existing browser context — cookies, localStorage, extensions intact
966
+ const contexts = browser.contexts();
967
+ context = contexts[0] ?? await browser.newContext({ viewport });
968
+ }
969
+ else if (persistentContext) {
970
+ context = persistentContext;
852
971
  }
853
972
  else {
854
- const browserLaunchStartedAt = Date.now();
855
- browser = await browserType.launch({
856
- headless: launchPlan.headless,
857
- ...(browserName === 'chromium' ? { args: launchPlan.browserArgs } : {}),
858
- // Use system Chrome for stealth profiles — real TLS/JA3 fingerprint vs bundled Chromium
859
- ...(isStealthProfile && browserName === 'chromium' ? { channel: 'chrome' } : {}),
973
+ context = await browser.newContext({
974
+ viewport,
975
+ recordVideo: { dir: videoDir, size: viewport },
976
+ storageState: storageStatePath,
977
+ locale: 'en-US',
978
+ timezoneId: 'America/New_York',
979
+ ...(headlessUserAgent ? { userAgent: headlessUserAgent } : {}),
980
+ extraHTTPHeaders: {
981
+ 'Accept-Language': 'en-US,en;q=0.9',
982
+ },
860
983
  });
861
- launchDiagnostics.browserLaunchMs = Date.now() - browserLaunchStartedAt;
862
984
  }
863
- }
864
- const driverFactory = async () => {
865
- const contextStartedAt = Date.now();
866
- const context = persistentContext ?? await browser.newContext({
867
- viewport,
868
- recordVideo: { dir: videoDir, size: viewport },
869
- storageState: storageStatePath,
870
- locale: 'en-US',
871
- timezoneId: 'America/New_York',
872
- extraHTTPHeaders: {
873
- 'Accept-Language': 'en-US,en;q=0.9',
874
- },
985
+ // Stealth patches: only for Playwright-controlled contexts (not real user browsers)
986
+ if (!cdpConnected) {
987
+ await context.addInitScript(`
988
+ // navigator.webdriver explicit override (backup for --disable-blink-features)
989
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
990
+ // navigator.plugins empty in headless, non-empty in real browsers
991
+ Object.defineProperty(navigator, 'plugins', {
992
+ get: () => [
993
+ { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
994
+ { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
995
+ { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
996
+ ],
875
997
  });
876
- // Stealth: patch navigator/browser properties to reduce automation fingerprint.
877
- // This runs in the browser context (not Node), so we use a string to avoid TS issues.
878
- await context.addInitScript(`
879
- // navigator.webdriver explicit override (backup for --disable-blink-features)
880
- Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
881
- // navigator.pluginsempty in headless, non-empty in real browsers
882
- Object.defineProperty(navigator, 'plugins', {
883
- get: () => [
884
- { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
885
- { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' },
886
- { name: 'Native Client', filename: 'internal-nacl-plugin', description: '' },
887
- ],
888
- });
889
- // navigator.languages must match Accept-Language header
890
- Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
891
- // hardware signals realistic desktop values
892
- Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
893
- Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
894
- // window.chrome — full stub matching real Chrome
895
- if (!window.chrome) window.chrome = {};
896
- if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
897
- if (!window.chrome.app) window.chrome.app = { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } };
898
- if (!window.chrome.csi) window.chrome.csi = function() { return { onloadT: Date.now(), startE: Date.now(), pageT: Date.now() - performance.timing.navigationStart }; };
899
- if (!window.chrome.loadTimes) window.chrome.loadTimes = function() { return { commitLoadTime: Date.now() / 1000, connectionInfo: 'h2', finishDocumentLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: Date.now() / 1000 - 0.16, startLoadTime: Date.now() / 1000 - 0.16, wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true }; };
900
- // WebGL vendor/renderer match real GPU values
901
- try {
902
- const getParameter = WebGLRenderingContext.prototype.getParameter;
903
- WebGLRenderingContext.prototype.getParameter = function(parameter) {
904
- if (parameter === 37445) return 'Intel Inc.';
905
- if (parameter === 37446) return 'Intel Iris OpenGL Engine';
906
- return getParameter.call(this, parameter);
907
- };
908
- } catch (_) {}
909
- try {
910
- const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
911
- WebGL2RenderingContext.prototype.getParameter = function(parameter) {
912
- if (parameter === 37445) return 'Intel Inc.';
913
- if (parameter === 37446) return 'Intel Iris OpenGL Engine';
914
- return getParameter2.call(this, parameter);
915
- };
916
- } catch (_) {}
917
- // window.outerWidth/outerHeight0 in headless, match viewport in real browsers
918
- if (window.outerWidth === 0) Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth });
919
- if (window.outerHeight === 0) Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85 });
920
- // Patch permissions API for notification checks
921
- try {
922
- const origQuery = navigator.permissions.query.bind(navigator.permissions);
923
- navigator.permissions.query = (params) =>
924
- params.name === 'notifications'
925
- ? Promise.resolve({ state: 'denied', onchange: null })
926
- : origQuery(params);
927
- } catch (_) {}
928
- `);
998
+ // navigator.languages must match Accept-Language header
999
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
1000
+ // hardware signals — realistic desktop values
1001
+ Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
1002
+ Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
1003
+ // window.chromefull stub matching real Chrome
1004
+ if (!window.chrome) window.chrome = {};
1005
+ if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
1006
+ if (!window.chrome.app) window.chrome.app = { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } };
1007
+ if (!window.chrome.csi) window.chrome.csi = function() { return { onloadT: Date.now(), startE: Date.now(), pageT: Date.now() - performance.timing.navigationStart }; };
1008
+ if (!window.chrome.loadTimes) window.chrome.loadTimes = function() { return { commitLoadTime: Date.now() / 1000, connectionInfo: 'h2', finishDocumentLoadTime: Date.now() / 1000, finishLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'Other', npnNegotiatedProtocol: 'h2', requestTime: Date.now() / 1000 - 0.16, startLoadTime: Date.now() / 1000 - 0.16, wasAlternateProtocolAvailable: false, wasFetchedViaSpdy: true, wasNpnNegotiated: true }; };
1009
+ // WebGL vendor/renderer — match real GPU values
1010
+ try {
1011
+ const getParameter = WebGLRenderingContext.prototype.getParameter;
1012
+ WebGLRenderingContext.prototype.getParameter = function(parameter) {
1013
+ if (parameter === 37445) return 'Intel Inc.';
1014
+ if (parameter === 37446) return 'Intel Iris OpenGL Engine';
1015
+ return getParameter.call(this, parameter);
1016
+ };
1017
+ } catch (_) {}
1018
+ try {
1019
+ const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
1020
+ WebGL2RenderingContext.prototype.getParameter = function(parameter) {
1021
+ if (parameter === 37445) return 'Intel Inc.';
1022
+ if (parameter === 37446) return 'Intel Iris OpenGL Engine';
1023
+ return getParameter2.call(this, parameter);
1024
+ };
1025
+ } catch (_) {}
1026
+ // window.outerWidth/outerHeight 0 in headless, match viewport in real browsers
1027
+ if (window.outerWidth === 0) Object.defineProperty(window, 'outerWidth', { get: () => window.innerWidth });
1028
+ if (window.outerHeight === 0) Object.defineProperty(window, 'outerHeight', { get: () => window.innerHeight + 85 });
1029
+ // Patch permissions API — cover all permission types bots commonly mis-handle
1030
+ try {
1031
+ const origQuery = navigator.permissions.query.bind(navigator.permissions);
1032
+ navigator.permissions.query = (params) => {
1033
+ const deny = ['notifications', 'geolocation', 'camera', 'microphone', 'payment-handler'];
1034
+ if (deny.includes(params.name))
1035
+ return Promise.resolve({ state: 'denied', onchange: null });
1036
+ return origQuery(params);
1037
+ };
1038
+ } catch (_) {}
1039
+ // Canvas fingerprint noise add imperceptible per-session noise to canvas readback
1040
+ // so each session produces a unique fingerprint (defeats static fingerprint matching)
1041
+ try {
1042
+ const seed = Math.random() * 0xffff | 0;
1043
+ const noisify = (canvas) => {
1044
+ try {
1045
+ const ctx = canvas.getContext('2d');
1046
+ if (!ctx) return;
1047
+ const { width: w, height: h } = canvas;
1048
+ if (w === 0 || h === 0) return;
1049
+ const img = ctx.getImageData(0, 0, w, h);
1050
+ const d = img.data;
1051
+ for (let i = 0; i < d.length; i += 4) {
1052
+ // deterministic per-pixel noise from seed + position
1053
+ d[i] = d[i] ^ ((seed + i) & 1);
1054
+ }
1055
+ ctx.putImageData(img, 0, 0);
1056
+ } catch (_) {}
1057
+ };
1058
+ const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
1059
+ HTMLCanvasElement.prototype.toDataURL = function(...args) {
1060
+ noisify(this);
1061
+ return origToDataURL.apply(this, args);
1062
+ };
1063
+ const origToBlob = HTMLCanvasElement.prototype.toBlob;
1064
+ HTMLCanvasElement.prototype.toBlob = function(...args) {
1065
+ noisify(this);
1066
+ return origToBlob.apply(this, args);
1067
+ };
1068
+ } catch (_) {}
1069
+ // Fix CDP screenX/screenY bug — CDP Input.dispatchMouseEvent sets
1070
+ // screenX=clientX, screenY=clientY which never happens in real browsers.
1071
+ // Cloudflare Turnstile actively checks this. Add a per-session window
1072
+ // offset so screenX/screenY are realistic and internally consistent.
1073
+ try {
1074
+ const winX = Math.floor(Math.random() * 200) + 50;
1075
+ const winY = Math.floor(Math.random() * 100) + 50;
1076
+ const chrome = 85;
1077
+ Object.defineProperty(MouseEvent.prototype, 'screenX', {
1078
+ get() { return this.clientX + winX; },
1079
+ configurable: true,
1080
+ });
1081
+ Object.defineProperty(MouseEvent.prototype, 'screenY', {
1082
+ get() { return this.clientY + winY + chrome; },
1083
+ configurable: true,
1084
+ });
1085
+ } catch (_) {}
1086
+ `);
1087
+ }
929
1088
  const contextCreateMs = Date.now() - contextStartedAt;
930
1089
  const pageStartedAt = Date.now();
931
1090
  const page = await context.newPage();
@@ -957,8 +1116,9 @@ async function main() {
957
1116
  contextCreateMs,
958
1117
  pageCreateMs,
959
1118
  resourceBlockingSetupMs,
960
- storageStateApplied: Boolean(storageStatePath),
1119
+ storageStateApplied: Boolean(storageStatePath) && !cdpConnected,
961
1120
  persistentContext: Boolean(persistentContext),
1121
+ cdpConnected,
962
1122
  };
963
1123
  // Wrap in a Driver that properly tears down context on close
964
1124
  const wrappedDriver = {
@@ -970,7 +1130,7 @@ async function main() {
970
1130
  async close() {
971
1131
  await driver.close().catch(() => { });
972
1132
  await page.close().catch(() => { });
973
- if (!persistentContext) {
1133
+ if (!persistentContext && !cdpConnected) {
974
1134
  await context.close().catch(() => { });
975
1135
  }
976
1136
  },