@doppelgangerdev/doppelganger 0.4.3 → 0.5.2

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 (55) hide show
  1. package/agent.js +415 -218
  2. package/dist/assets/index-BXRKDZ1_.css +1 -0
  3. package/dist/assets/index-Deb2QMGx.js +19 -0
  4. package/dist/captures/run_1769042882403_589_agent_1769042884446_initial.png +0 -0
  5. package/dist/captures/run_1769042882403_589_agent_1769042887058.png +0 -0
  6. package/dist/captures/run_1769042882403_589_agent_1769042888468.webm +0 -0
  7. package/dist/captures/run_1769043202318_943_agent_1769043206237.png +0 -0
  8. package/dist/captures/run_1769043202318_943_agent_1769043207415.webm +0 -0
  9. package/dist/captures/run_1769043449517_97_agent_1769043451350_initial.png +0 -0
  10. package/dist/captures/run_1769043449517_97_agent_1769043455038.png +0 -0
  11. package/dist/captures/run_1769043449517_97_agent_1769043456476.webm +0 -0
  12. package/dist/captures/run_1769043471164_239_agent_1769043472720_initial.png +0 -0
  13. package/dist/captures/run_1769043471164_239_agent_1769043474022.png +0 -0
  14. package/dist/captures/run_1769043471164_239_agent_1769043476419.png +0 -0
  15. package/dist/captures/run_1769043471164_239_agent_1769043477795.webm +0 -0
  16. package/dist/captures/run_1769080585290_151_agent_1769080595110.png +0 -0
  17. package/dist/captures/run_1769080585290_151_agent_1769080596335.webm +0 -0
  18. package/dist/index.html +2 -2
  19. package/dist/screenshots/agent_1769037343598.png +0 -0
  20. package/dist/screenshots/agent_1769037357541.png +0 -0
  21. package/dist/screenshots/scrape_1769037382254.png +0 -0
  22. package/dist/screenshots/scrape_1769037413189.png +0 -0
  23. package/dist/screenshots/scrape_1769037449707.png +0 -0
  24. package/dist/screenshots/scrape_1769037461756.png +0 -0
  25. package/dist/screenshots/scrape_1769037490581.png +0 -0
  26. package/dist/screenshots/scrape_1769038242368.png +0 -0
  27. package/headful.js +76 -21
  28. package/package.json +3 -1
  29. package/proxy-rotation.js +133 -90
  30. package/public/captures/run_1769042882403_589_agent_1769042884446_initial.png +0 -0
  31. package/public/captures/run_1769042882403_589_agent_1769042887058.png +0 -0
  32. package/public/captures/run_1769042882403_589_agent_1769042888468.webm +0 -0
  33. package/public/captures/run_1769043202318_943_agent_1769043206237.png +0 -0
  34. package/public/captures/run_1769043202318_943_agent_1769043207415.webm +0 -0
  35. package/public/captures/run_1769043449517_97_agent_1769043451350_initial.png +0 -0
  36. package/public/captures/run_1769043449517_97_agent_1769043455038.png +0 -0
  37. package/public/captures/run_1769043449517_97_agent_1769043456476.webm +0 -0
  38. package/public/captures/run_1769043471164_239_agent_1769043472720_initial.png +0 -0
  39. package/public/captures/run_1769043471164_239_agent_1769043474022.png +0 -0
  40. package/public/captures/run_1769043471164_239_agent_1769043476419.png +0 -0
  41. package/public/captures/run_1769043471164_239_agent_1769043477795.webm +0 -0
  42. package/public/captures/run_1769080585290_151_agent_1769080595110.png +0 -0
  43. package/public/captures/run_1769080585290_151_agent_1769080596335.webm +0 -0
  44. package/public/screenshots/agent_1769037343598.png +0 -0
  45. package/public/screenshots/agent_1769037357541.png +0 -0
  46. package/public/screenshots/scrape_1769037382254.png +0 -0
  47. package/public/screenshots/scrape_1769037413189.png +0 -0
  48. package/public/screenshots/scrape_1769037449707.png +0 -0
  49. package/public/screenshots/scrape_1769037461756.png +0 -0
  50. package/public/screenshots/scrape_1769037490581.png +0 -0
  51. package/public/screenshots/scrape_1769038242368.png +0 -0
  52. package/scrape.js +163 -66
  53. package/server.js +127 -72
  54. package/dist/assets/index-D68YZVOp.js +0 -19
  55. package/dist/assets/index-WbwoTnJa.css +0 -1
package/agent.js CHANGED
@@ -2,7 +2,8 @@ const { chromium } = require('playwright');
2
2
  const { JSDOM } = require('jsdom');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { getProxySelection } = require('./proxy-rotation');
5
+ const { getProxySelection } = require('./proxy-rotation');
6
+ const { selectUserAgent } = require('./user-agent-settings');
6
7
 
7
8
  const STORAGE_STATE_PATH = path.join(__dirname, 'storage_state.json');
8
9
  const STORAGE_STATE_FILE = (() => {
@@ -17,12 +18,6 @@ const STORAGE_STATE_FILE = (() => {
17
18
  return STORAGE_STATE_PATH;
18
19
  })();
19
20
 
20
- const userAgents = [
21
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
22
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
23
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
24
- ];
25
-
26
21
  const API_KEY_FILE = path.join(__dirname, 'data', 'api_key.json');
27
22
 
28
23
  const loadApiKey = () => {
@@ -124,8 +119,8 @@ async function humanType(page, selector, text, options = {}) {
124
119
  if (selector) await page.focus(selector);
125
120
  const chars = text.split('');
126
121
  let burstCounter = 0;
127
- const burstLimit = naturalTyping ? Math.floor(randomBetween(4, 12)) : 999;
128
- const baseDelay = naturalTyping ? randomBetween(30, 140) : randomBetween(25, 80);
122
+ const burstLimit = naturalTyping ? Math.floor(randomBetween(6, 16)) : 999;
123
+ const baseDelay = naturalTyping ? randomBetween(12, 55) : randomBetween(25, 80);
129
124
  const typeChar = async (char, delay) => {
130
125
  try {
131
126
  await page.keyboard.press(char, { delay });
@@ -137,7 +132,7 @@ async function humanType(page, selector, text, options = {}) {
137
132
 
138
133
  for (const char of chars) {
139
134
  if (naturalTyping && burstCounter >= burstLimit) {
140
- await page.waitForTimeout(randomBetween(120, 420));
135
+ await page.waitForTimeout(randomBetween(60, 180));
141
136
  burstCounter = 0;
142
137
  }
143
138
 
@@ -145,31 +140,32 @@ async function humanType(page, selector, text, options = {}) {
145
140
  const keys = 'qwertyuiopasdfghjklzxcvbnm';
146
141
  const typo = keys[Math.floor(Math.random() * keys.length)];
147
142
  await page.keyboard.press(typo, { delay: 40 + Math.random() * 120 });
148
- if (Math.random() < 0.5) {
149
- await page.waitForTimeout(120 + Math.random() * 200);
150
- }
151
- await page.keyboard.press('Backspace', { delay: 40 + Math.random() * 120 });
152
- if (Math.random() < 0.3) {
153
- await page.keyboard.press(typo, { delay: 40 + Math.random() * 120 });
154
- await page.keyboard.press('Backspace', { delay: 40 + Math.random() * 120 });
155
- }
156
- }
157
-
158
- const extra = punctuationPause.test(char) ? randomBetween(140, 320) : randomBetween(0, 90);
159
- const fatiguePause = fatigue && Math.random() < 0.06 ? randomBetween(180, 420) : 0;
160
- await typeChar(char, baseDelay + extra + fatiguePause);
161
- burstCounter += 1;
162
-
163
- if (naturalTyping && char === ' ') {
164
- await page.waitForTimeout(randomBetween(40, 180));
165
- }
143
+ if (Math.random() < 0.5) {
144
+ await page.waitForTimeout(60 + Math.random() * 120);
145
+ }
146
+ await page.keyboard.press('Backspace', { delay: 40 + Math.random() * 120 });
147
+ if (Math.random() < 0.3) {
148
+ await page.keyboard.press(typo, { delay: 40 + Math.random() * 120 });
149
+ await page.keyboard.press('Backspace', { delay: 40 + Math.random() * 120 });
150
+ }
151
+ }
152
+
153
+ const extra = punctuationPause.test(char) ? randomBetween(60, 150) : randomBetween(0, 40);
154
+ const fatiguePause = fatigue && Math.random() < 0.06 ? randomBetween(90, 200) : 0;
155
+ await typeChar(char, baseDelay + extra + fatiguePause);
156
+ burstCounter += 1;
157
+
158
+ if (naturalTyping && char === ' ') {
159
+ await page.waitForTimeout(randomBetween(20, 80));
160
+ }
166
161
  }
167
162
  }
168
163
 
169
- async function handleAgent(req, res) {
170
- const data = (req.method === 'POST') ? req.body : req.query;
171
- let { url, actions, wait: globalWait, rotateUserAgents, rotateProxies, humanTyping, stealth = {} } = data;
172
- const runId = data.runId ? String(data.runId) : null;
164
+ async function handleAgent(req, res) {
165
+ const data = (req.method === 'POST') ? req.body : req.query;
166
+ let { url, actions, wait: globalWait, rotateUserAgents, rotateProxies, humanTyping, stealth = {} } = data;
167
+ const runId = data.runId ? String(data.runId) : null;
168
+ const captureRunId = runId || `run_${Date.now()}_unknown`;
173
169
  const includeShadowDomRaw = data.includeShadowDom ?? req.query.includeShadowDom;
174
170
  const includeShadowDom = includeShadowDomRaw === undefined
175
171
  ? true
@@ -227,10 +223,20 @@ async function handleAgent(req, res) {
227
223
  });
228
224
  };
229
225
 
230
- const resolveMaybe = (value) => {
231
- if (typeof value !== 'string') return value;
232
- return resolveTemplate(value);
233
- };
226
+ const resolveMaybe = (value) => {
227
+ if (typeof value !== 'string') return value;
228
+ return resolveTemplate(value);
229
+ };
230
+
231
+ const parseCoords = (input) => {
232
+ if (!input || typeof input !== 'string') return null;
233
+ const match = input.trim().match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
234
+ if (!match) return null;
235
+ const x = Number(match[1]);
236
+ const y = Number(match[2]);
237
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
238
+ return { x, y };
239
+ };
234
240
 
235
241
  const parseValue = (value) => {
236
242
  if (typeof value !== 'string') return value;
@@ -391,23 +397,23 @@ async function handleAgent(req, res) {
391
397
  return { startToEnd, startToElse, elseToEnd, endToStart };
392
398
  };
393
399
 
394
- // Pick a random UA if rotation is enabled, otherwise use the first one
395
- const selectedUA = rotateUserAgents
396
- ? userAgents[Math.floor(Math.random() * userAgents.length)]
397
- : userAgents[0];
400
+ const selectedUA = selectUserAgent(rotateUserAgents);
398
401
 
399
- let browser;
400
- try {
402
+ let browser;
403
+ let context;
404
+ let page;
405
+ try {
401
406
  const launchOptions = {
402
407
  headless: true,
403
408
  channel: 'chrome',
404
- args: [
405
- '--no-sandbox',
406
- '--disable-setuid-sandbox',
407
- '--disable-blink-features=AutomationControlled',
408
- '--hide-scrollbars',
409
- '--mute-audio'
410
- ]
409
+ args: [
410
+ '--no-sandbox',
411
+ '--disable-setuid-sandbox',
412
+ '--disable-dev-shm-usage',
413
+ '--disable-blink-features=AutomationControlled',
414
+ '--hide-scrollbars',
415
+ '--mute-audio'
416
+ ]
411
417
  };
412
418
  const useRotateProxies = String(rotateProxies).toLowerCase() === 'true' || rotateProxies === true;
413
419
  const selection = getProxySelection(useRotateProxies);
@@ -417,25 +423,97 @@ async function handleAgent(req, res) {
417
423
  console.log(`[PROXY] Mode: ${selection.mode}; Target: ${selection.proxy ? selection.proxy.server : 'host_ip'}`);
418
424
  browser = await chromium.launch(launchOptions);
419
425
 
420
- const contextOptions = {
421
- userAgent: selectedUA,
422
- viewport: { width: 1280 + Math.floor(Math.random() * 640), height: 720 + Math.floor(Math.random() * 360) },
423
- deviceScaleFactor: 1,
424
- locale: 'en-US',
425
- timezoneId: 'America/New_York',
426
- colorScheme: 'dark',
427
- permissions: ['geolocation']
428
- };
426
+ const recordingsDir = path.join(__dirname, 'data', 'recordings');
427
+ if (!fs.existsSync(recordingsDir)) {
428
+ fs.mkdirSync(recordingsDir, { recursive: true });
429
+ }
430
+
431
+ const rotateViewport = String(data.rotateViewport).toLowerCase() === 'true' || data.rotateViewport === true;
432
+ const viewport = rotateViewport
433
+ ? { width: 1280 + Math.floor(Math.random() * 640), height: 720 + Math.floor(Math.random() * 360) }
434
+ : { width: 1366, height: 768 };
435
+
436
+ const contextOptions = {
437
+ userAgent: selectedUA,
438
+ viewport,
439
+ deviceScaleFactor: 1,
440
+ locale: 'en-US',
441
+ timezoneId: 'America/New_York',
442
+ colorScheme: 'dark',
443
+ permissions: ['geolocation'],
444
+ recordVideo: { dir: recordingsDir, size: viewport }
445
+ };
429
446
 
430
447
  if (fs.existsSync(STORAGE_STATE_FILE)) {
431
448
  contextOptions.storageState = STORAGE_STATE_FILE;
432
449
  }
433
450
 
434
- const context = await browser.newContext(contextOptions);
435
-
436
- await context.addInitScript(() => {
437
- Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
438
- });
451
+ context = await browser.newContext(contextOptions);
452
+
453
+ await context.addInitScript(() => {
454
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
455
+ });
456
+ await context.addInitScript(() => {
457
+ const cursorId = 'dg-cursor-overlay';
458
+ const dotId = 'dg-click-dot';
459
+ if (document.getElementById(cursorId)) return;
460
+ const cursor = document.createElement('div');
461
+ cursor.id = cursorId;
462
+ cursor.style.cssText = [
463
+ 'position:fixed',
464
+ 'top:0',
465
+ 'left:0',
466
+ 'width:18px',
467
+ 'height:18px',
468
+ 'margin-left:-9px',
469
+ 'margin-top:-9px',
470
+ 'border:2px solid rgba(56,189,248,0.7)',
471
+ 'background:rgba(56,189,248,0.25)',
472
+ 'border-radius:50%',
473
+ 'box-shadow:0 0 10px rgba(56,189,248,0.6)',
474
+ 'pointer-events:none',
475
+ 'z-index:2147483647',
476
+ 'transform:translate3d(0,0,0)',
477
+ 'transition:transform 60ms ease-out'
478
+ ].join(';');
479
+ const dot = document.createElement('div');
480
+ dot.id = dotId;
481
+ dot.style.cssText = [
482
+ 'position:fixed',
483
+ 'top:0',
484
+ 'left:0',
485
+ 'width:10px',
486
+ 'height:10px',
487
+ 'margin-left:-5px',
488
+ 'margin-top:-5px',
489
+ 'background:rgba(239,68,68,0.9)',
490
+ 'border-radius:50%',
491
+ 'box-shadow:0 0 12px rgba(239,68,68,0.8)',
492
+ 'pointer-events:none',
493
+ 'z-index:2147483647',
494
+ 'opacity:0',
495
+ 'transform:translate3d(0,0,0) scale(0.6)',
496
+ 'transition:opacity 120ms ease, transform 120ms ease'
497
+ ].join(';');
498
+ document.documentElement.appendChild(cursor);
499
+ document.documentElement.appendChild(dot);
500
+ const move = (x, y) => {
501
+ cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
502
+ };
503
+ window.addEventListener('mousemove', (e) => move(e.clientX, e.clientY), { passive: true });
504
+ window.addEventListener('click', (e) => {
505
+ dot.style.left = `${e.clientX}px`;
506
+ dot.style.top = `${e.clientY}px`;
507
+ dot.style.opacity = '1';
508
+ dot.style.transform = 'translate3d(0,0,0) scale(1)';
509
+ cursor.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0) scale(0.65)`;
510
+ setTimeout(() => {
511
+ dot.style.opacity = '0';
512
+ dot.style.transform = 'translate3d(0,0,0) scale(0.6)';
513
+ cursor.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0) scale(1)`;
514
+ }, 180);
515
+ }, true);
516
+ });
439
517
  if (includeShadowDom) {
440
518
  await context.addInitScript(() => {
441
519
  if (!Element.prototype.attachShadow) return;
@@ -447,7 +525,7 @@ async function handleAgent(req, res) {
447
525
  });
448
526
  }
449
527
 
450
- const page = await context.newPage();
528
+ page = await context.newPage();
451
529
 
452
530
  if (url) {
453
531
  await page.goto(resolveTemplate(url), { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -648,67 +726,105 @@ async function handleAgent(req, res) {
648
726
  return merged;
649
727
  };
650
728
 
651
- const executeAction = async (act) => {
652
- const { type, timeout } = act;
653
- const actionTimeout = timeout || 10000;
654
- let result = null;
655
-
656
- switch (type) {
657
- case 'navigate':
658
- case 'goto':
659
- logs.push(`Navigating to: ${resolveMaybe(act.value)}`);
660
- await page.goto(resolveMaybe(act.value), { waitUntil: 'domcontentloaded' });
661
- result = page.url();
662
- break;
663
- case 'click':
664
- logs.push(`Clicking: ${resolveMaybe(act.selector)}`);
665
- await page.waitForSelector(resolveMaybe(act.selector), { timeout: actionTimeout });
666
-
667
- // Neutral Dead Click
668
- if (deadClicks && Math.random() < 0.4) {
669
- logs.push('Performing neutral dead-click...');
670
- const viewport = page.viewportSize() || { width: 1280, height: 720 };
671
- await page.mouse.click(
672
- 10 + Math.random() * (viewport.width * 0.2),
673
- 10 + Math.random() * (viewport.height * 0.2)
674
- );
675
- await page.waitForTimeout(baseDelay(200));
676
- }
677
-
678
- // Get element point for human-like movement
679
- const handle = await page.$(resolveMaybe(act.selector));
680
- const box = await handle.boundingBox();
681
- if (box) {
682
- const centerX = box.x + box.width / 2 + (Math.random() - 0.5) * 5;
683
- const centerY = box.y + box.height / 2 + (Math.random() - 0.5) * 5;
684
- await moveMouseHumanlike(page, centerX, centerY);
685
- if (deadClicks && Math.random() < 0.25) {
686
- const offsetX = (Math.random() - 0.5) * Math.min(20, box.width / 3);
687
- const offsetY = (Math.random() - 0.5) * Math.min(20, box.height / 3);
688
- await page.mouse.click(centerX + offsetX, centerY + offsetY, { delay: baseDelay(30) });
689
- await page.waitForTimeout(baseDelay(120));
690
- }
691
- }
692
-
693
- await page.waitForTimeout(baseDelay(50));
694
- await page.click(resolveMaybe(act.selector), {
695
- delay: baseDelay(50)
696
- });
697
- result = true;
698
- break;
699
- case 'type':
700
- case 'fill':
701
- if (act.selector) {
702
- logs.push(`Typing into ${resolveMaybe(act.selector)}: ${resolveMaybe(act.value)}`);
703
- await page.waitForSelector(resolveMaybe(act.selector), { timeout: actionTimeout });
704
- if (humanTyping) {
705
- await humanType(page, resolveMaybe(act.selector), resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
706
- } else {
707
- await page.fill(resolveMaybe(act.selector), resolveMaybe(act.value));
708
- }
709
- } else {
710
- logs.push(`Typing (global): ${resolveMaybe(act.value)}`);
711
- if (humanTyping) {
729
+ const ensureCapturesDir = () => {
730
+ const capturesDir = path.join(__dirname, 'public', 'captures');
731
+ if (!fs.existsSync(capturesDir)) {
732
+ fs.mkdirSync(capturesDir, { recursive: true });
733
+ }
734
+ return capturesDir;
735
+ };
736
+
737
+ const captureScreenshot = async (label) => {
738
+ const capturesDir = ensureCapturesDir();
739
+ const safeLabel = label ? String(label).replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 24) : '';
740
+ const nameSuffix = safeLabel ? `_${safeLabel}` : '';
741
+ const screenshotName = `${captureRunId}_agent_${Date.now()}${nameSuffix}.png`;
742
+ const screenshotPath = path.join(capturesDir, screenshotName);
743
+ await page.screenshot({ path: screenshotPath, fullPage: false });
744
+ return `/captures/${screenshotName}`;
745
+ };
746
+
747
+ const executeAction = async (act) => {
748
+ const { type, timeout } = act;
749
+ const actionTimeout = timeout || 10000;
750
+ let result = null;
751
+
752
+ switch (type) {
753
+ case 'navigate':
754
+ case 'goto':
755
+ logs.push(`Navigating to: ${resolveMaybe(act.value)}`);
756
+ await page.goto(resolveMaybe(act.value), { waitUntil: 'domcontentloaded' });
757
+ result = page.url();
758
+ break;
759
+ case 'click': {
760
+ const selectorValue = resolveMaybe(act.selector);
761
+ const coords = parseCoords(String(selectorValue || ''));
762
+ logs.push(`Clicking: ${selectorValue}`);
763
+ if (coords) {
764
+ await page.mouse.click(coords.x, coords.y, { delay: baseDelay(50) });
765
+ result = true;
766
+ break;
767
+ }
768
+ await page.waitForSelector(selectorValue, { timeout: actionTimeout });
769
+
770
+ // Neutral Dead Click
771
+ if (deadClicks && Math.random() < 0.4) {
772
+ logs.push('Performing neutral dead-click...');
773
+ const viewport = page.viewportSize() || { width: 1280, height: 720 };
774
+ await page.mouse.click(
775
+ 10 + Math.random() * (viewport.width * 0.2),
776
+ 10 + Math.random() * (viewport.height * 0.2)
777
+ );
778
+ await page.waitForTimeout(baseDelay(200));
779
+ }
780
+
781
+ // Get element point for human-like movement
782
+ const handle = await page.$(selectorValue);
783
+ const box = await handle.boundingBox();
784
+ if (box) {
785
+ const centerX = box.x + box.width / 2 + (Math.random() - 0.5) * 5;
786
+ const centerY = box.y + box.height / 2 + (Math.random() - 0.5) * 5;
787
+ await moveMouseHumanlike(page, centerX, centerY);
788
+ if (deadClicks && Math.random() < 0.25) {
789
+ const offsetX = (Math.random() - 0.5) * Math.min(20, box.width / 3);
790
+ const offsetY = (Math.random() - 0.5) * Math.min(20, box.height / 3);
791
+ await page.mouse.click(centerX + offsetX, centerY + offsetY, { delay: baseDelay(30) });
792
+ await page.waitForTimeout(baseDelay(120));
793
+ }
794
+ }
795
+
796
+ await page.waitForTimeout(baseDelay(50));
797
+ await page.click(selectorValue, {
798
+ delay: baseDelay(50)
799
+ });
800
+ result = true;
801
+ break;
802
+ }
803
+ case 'type':
804
+ case 'fill':
805
+ if (act.selector) {
806
+ const selectorValue = resolveMaybe(act.selector);
807
+ const coords = parseCoords(String(selectorValue || ''));
808
+ logs.push(`Typing into ${selectorValue}: ${resolveMaybe(act.value)}`);
809
+ if (coords) {
810
+ await page.mouse.click(coords.x, coords.y, { delay: baseDelay(50) });
811
+ if (humanTyping) {
812
+ await humanType(page, null, resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
813
+ } else {
814
+ await page.keyboard.type(resolveMaybe(act.value), { delay: baseDelay(50) });
815
+ }
816
+ result = resolveMaybe(act.value);
817
+ break;
818
+ }
819
+ await page.waitForSelector(selectorValue, { timeout: actionTimeout });
820
+ if (humanTyping) {
821
+ await humanType(page, selectorValue, resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
822
+ } else {
823
+ await page.fill(selectorValue, resolveMaybe(act.value));
824
+ }
825
+ } else {
826
+ logs.push(`Typing (global): ${resolveMaybe(act.value)}`);
827
+ if (humanTyping) {
712
828
  await humanType(page, null, resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
713
829
  } else {
714
830
  await page.keyboard.type(resolveMaybe(act.value), { delay: baseDelay(50) });
@@ -716,21 +832,29 @@ async function handleAgent(req, res) {
716
832
  }
717
833
  result = resolveMaybe(act.value);
718
834
  break;
719
- case 'hover':
720
- logs.push(`Hovering: ${resolveMaybe(act.selector)}`);
721
- await page.waitForSelector(resolveMaybe(act.selector), { timeout: actionTimeout });
722
- {
723
- const handle = await page.$(resolveMaybe(act.selector));
724
- const box = handle && await handle.boundingBox();
725
- if (box) {
726
- const centerX = box.x + box.width / 2 + (Math.random() - 0.5) * 5;
727
- const centerY = box.y + box.height / 2 + (Math.random() - 0.5) * 5;
728
- await moveMouseHumanlike(page, centerX, centerY);
729
- }
730
- }
731
- await page.waitForTimeout(baseDelay(150));
732
- result = true;
733
- break;
835
+ case 'hover': {
836
+ const selectorValue = resolveMaybe(act.selector);
837
+ const coords = parseCoords(String(selectorValue || ''));
838
+ logs.push(`Hovering: ${selectorValue}`);
839
+ if (coords) {
840
+ await moveMouseHumanlike(page, coords.x, coords.y);
841
+ result = true;
842
+ break;
843
+ }
844
+ await page.waitForSelector(selectorValue, { timeout: actionTimeout });
845
+ {
846
+ const handle = await page.$(selectorValue);
847
+ const box = handle && await handle.boundingBox();
848
+ if (box) {
849
+ const centerX = box.x + box.width / 2 + (Math.random() - 0.5) * 5;
850
+ const centerY = box.y + box.height / 2 + (Math.random() - 0.5) * 5;
851
+ await moveMouseHumanlike(page, centerX, centerY);
852
+ }
853
+ }
854
+ await page.waitForTimeout(baseDelay(150));
855
+ result = true;
856
+ break;
857
+ }
734
858
  case 'press':
735
859
  logs.push(`Pressing key: ${resolveMaybe(act.key)}`);
736
860
  await page.keyboard.press(resolveMaybe(act.key), { delay: baseDelay(50) });
@@ -757,25 +881,65 @@ async function handleAgent(req, res) {
757
881
  await page.selectOption(resolveMaybe(act.selector), resolveMaybe(act.value));
758
882
  result = resolveMaybe(act.value);
759
883
  break;
760
- case 'scroll':
761
- const amount = act.value ? parseInt(resolveMaybe(act.value), 10) : (400 + Math.random() * 400);
762
- logs.push(`Scrolling page: ${amount}px...`);
763
- if (overscroll) {
764
- await overshootScroll(page, amount);
765
- } else if (act.selector) {
766
- await page.evaluate(({ selector, y }) => {
767
- const el = document.querySelector(selector);
768
- if (el) el.scrollBy({ top: y, behavior: 'smooth' });
769
- }, { selector: resolveMaybe(act.selector), y: amount });
770
- } else {
771
- await page.evaluate((y) => window.scrollBy({ top: y, behavior: 'smooth' }), amount);
772
- }
773
- await page.waitForTimeout(baseDelay(500));
774
- result = amount;
775
- break;
776
- case 'javascript':
777
- logs.push('Running custom JavaScript...');
778
- if (act.value) {
884
+ case 'scroll': {
885
+ const amount = act.value ? parseInt(resolveMaybe(act.value), 10) : (400 + Math.random() * 400);
886
+ const speedMs = act.key ? parseInt(resolveMaybe(act.key), 10) : 500;
887
+ const durationMs = Number.isFinite(speedMs) && speedMs > 0 ? speedMs : 500;
888
+ logs.push(`Scrolling page: ${amount}px over ${durationMs}ms...`);
889
+ if (overscroll) {
890
+ await overshootScroll(page, amount);
891
+ await page.waitForTimeout(baseDelay(200));
892
+ } else if (act.selector) {
893
+ await page.evaluate(({ selector, y, duration }) => {
894
+ const el = document.querySelector(selector);
895
+ if (!el) return;
896
+ const start = el.scrollTop;
897
+ const target = start + y;
898
+ const startTime = performance.now();
899
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
900
+ const step = (now) => {
901
+ const elapsed = now - startTime;
902
+ const t = Math.min(1, elapsed / duration);
903
+ const next = start + (target - start) * easeOut(t);
904
+ el.scrollTop = next;
905
+ if (t < 1) requestAnimationFrame(step);
906
+ };
907
+ requestAnimationFrame(step);
908
+ }, { selector: resolveMaybe(act.selector), y: amount, duration: durationMs });
909
+ await page.waitForTimeout(baseDelay(durationMs));
910
+ } else {
911
+ await page.evaluate(({ y, duration }) => {
912
+ const start = window.scrollY || 0;
913
+ const target = start + y;
914
+ const startTime = performance.now();
915
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
916
+ const step = (now) => {
917
+ const elapsed = now - startTime;
918
+ const t = Math.min(1, elapsed / duration);
919
+ const next = start + (target - start) * easeOut(t);
920
+ window.scrollTo(0, next);
921
+ if (t < 1) requestAnimationFrame(step);
922
+ };
923
+ requestAnimationFrame(step);
924
+ }, { y: amount, duration: durationMs });
925
+ await page.waitForTimeout(baseDelay(durationMs));
926
+ }
927
+ result = amount;
928
+ break;
929
+ }
930
+ case 'screenshot':
931
+ logs.push('Capturing screenshot...');
932
+ try {
933
+ const shotUrl = await captureScreenshot(act.label || act.value || '');
934
+ result = shotUrl;
935
+ logs.push(`Screenshot saved: ${shotUrl}`);
936
+ } catch (e) {
937
+ logs.push(`Screenshot failed: ${e.message}`);
938
+ }
939
+ break;
940
+ case 'javascript':
941
+ logs.push('Running custom JavaScript...');
942
+ if (act.value) {
779
943
  result = await page.evaluate((code) => {
780
944
  // eslint-disable-next-line no-eval
781
945
  return eval(code);
@@ -936,14 +1100,15 @@ async function handleAgent(req, res) {
936
1100
  continue;
937
1101
  }
938
1102
 
939
- if (act.type === 'while') {
940
- try {
941
- reportProgress(runId, { actionId: act.id, status: 'running' });
942
- const condition = await evalCondition(act.value);
943
- setBlockOutput(condition);
944
- logs.push(`While condition: ${condition ? 'true' : 'false'}`);
945
- reportProgress(runId, { actionId: act.id, status: 'success' });
946
- if (!condition) {
1103
+ if (act.type === 'while') {
1104
+ try {
1105
+ reportProgress(runId, { actionId: act.id, status: 'running' });
1106
+ const hasStructured = act.conditionVarType || act.conditionOp || act.conditionVar || act.conditionValue;
1107
+ const condition = hasStructured ? evalStructuredCondition(act) : await evalCondition(act.value);
1108
+ setBlockOutput(condition);
1109
+ logs.push(`While condition: ${condition ? 'true' : 'false'}`);
1110
+ reportProgress(runId, { actionId: act.id, status: 'success' });
1111
+ if (!condition) {
947
1112
  index = (startToEnd[index] ?? index) + 1;
948
1113
  continue;
949
1114
  }
@@ -960,25 +1125,27 @@ async function handleAgent(req, res) {
960
1125
  continue;
961
1126
  }
962
1127
 
963
- if (act.type === 'repeat') {
964
- reportProgress(runId, { actionId: act.id, status: 'running' });
965
- const rawCount = parseInt(resolveMaybe(act.value) || '0', 10);
966
- const count = Number.isFinite(rawCount) ? rawCount : 0;
967
- const state = repeatState.get(index) || { remaining: count };
968
- repeatState.set(index, state);
969
- if (state.remaining <= 0) {
970
- repeatState.delete(index);
971
- reportProgress(runId, { actionId: act.id, status: 'success' });
972
- index = (startToEnd[index] ?? index) + 1;
973
- continue;
974
- }
975
- state.remaining -= 1;
976
- logs.push(`Repeat block: ${state.remaining + 1} remaining`);
977
- setBlockOutput(state.remaining + 1);
978
- reportProgress(runId, { actionId: act.id, status: 'success' });
979
- index += 1;
980
- continue;
981
- }
1128
+ if (act.type === 'repeat') {
1129
+ reportProgress(runId, { actionId: act.id, status: 'running' });
1130
+ const rawCount = parseInt(resolveMaybe(act.value) || '0', 10);
1131
+ const count = Number.isFinite(rawCount) ? rawCount : 0;
1132
+ let state = repeatState.get(index);
1133
+ if (!state) {
1134
+ state = { remaining: count };
1135
+ repeatState.set(index, state);
1136
+ }
1137
+ if (state.remaining <= 0) {
1138
+ repeatState.delete(index);
1139
+ reportProgress(runId, { actionId: act.id, status: 'success' });
1140
+ index = (startToEnd[index] ?? index) + 1;
1141
+ continue;
1142
+ }
1143
+ logs.push(`Repeat block: ${state.remaining} remaining`);
1144
+ setBlockOutput(state.remaining);
1145
+ reportProgress(runId, { actionId: act.id, status: 'success' });
1146
+ index += 1;
1147
+ continue;
1148
+ }
982
1149
 
983
1150
  if (act.type === 'foreach') {
984
1151
  reportProgress(runId, { actionId: act.id, status: 'running' });
@@ -1012,14 +1179,18 @@ async function handleAgent(req, res) {
1012
1179
  index = startIndex;
1013
1180
  continue;
1014
1181
  }
1015
- if (startAction.type === 'repeat') {
1016
- const state = repeatState.get(startIndex);
1017
- if (state && state.remaining > 0) {
1018
- index = startIndex + 1;
1019
- continue;
1020
- }
1021
- repeatState.delete(startIndex);
1022
- }
1182
+ if (startAction.type === 'repeat') {
1183
+ const state = repeatState.get(startIndex);
1184
+ if (state) {
1185
+ state.remaining -= 1;
1186
+ if (state.remaining > 0) {
1187
+ setBlockOutput(state.remaining);
1188
+ index = startIndex + 1;
1189
+ continue;
1190
+ }
1191
+ repeatState.delete(startIndex);
1192
+ }
1193
+ }
1023
1194
  if (startAction.type === 'foreach') {
1024
1195
  const state = foreachState.get(startIndex);
1025
1196
  if (state) {
@@ -1216,18 +1387,18 @@ async function handleAgent(req, res) {
1216
1387
  };
1217
1388
 
1218
1389
  // Ensure the public/screenshots directory exists
1219
- const screenshotsDir = path.join(__dirname, 'public', 'screenshots');
1220
- if (!fs.existsSync(screenshotsDir)) {
1221
- fs.mkdirSync(screenshotsDir, { recursive: true });
1222
- }
1223
-
1224
- const screenshotName = `agent_${Date.now()}.png`;
1225
- const screenshotPath = path.join(screenshotsDir, screenshotName);
1226
- try {
1227
- await page.screenshot({ path: screenshotPath, fullPage: false });
1228
- } catch (e) {
1229
- console.error('Agent Screenshot failed:', e.message);
1230
- }
1390
+ const capturesDir = path.join(__dirname, 'public', 'captures');
1391
+ if (!fs.existsSync(capturesDir)) {
1392
+ fs.mkdirSync(capturesDir, { recursive: true });
1393
+ }
1394
+
1395
+ const screenshotName = `${captureRunId}_agent_${Date.now()}.png`;
1396
+ const screenshotPath = path.join(capturesDir, screenshotName);
1397
+ try {
1398
+ await page.screenshot({ path: screenshotPath, fullPage: false });
1399
+ } catch (e) {
1400
+ console.error('Agent Screenshot failed:', e.message);
1401
+ }
1231
1402
 
1232
1403
  const extractionFormat = String(data.extractionFormat || (data.taskSnapshot && data.taskSnapshot.extractionFormat) || '').toLowerCase() === 'csv'
1233
1404
  ? 'csv'
@@ -1241,17 +1412,43 @@ async function handleAgent(req, res) {
1241
1412
  logs: logs || [],
1242
1413
  html: typeof cleanedHtml === 'string' ? safeFormatHTML(cleanedHtml) : '',
1243
1414
  data: formattedExtraction,
1244
- screenshot_url: fs.existsSync(screenshotPath) ? `/screenshots/${screenshotName}` : null
1245
- };
1246
-
1247
- try { await context.storageState({ path: STORAGE_STATE_FILE }); } catch {}
1248
- try { await browser.close(); } catch {}
1249
- res.json(outputData);
1250
- } catch (error) {
1251
- console.error('Agent Error:', error);
1252
- if (browser) await browser.close();
1253
- res.status(500).json({ error: 'Agent failed', details: error.message });
1254
- }
1255
- }
1415
+ screenshot_url: fs.existsSync(screenshotPath) ? `/captures/${screenshotName}` : null
1416
+ };
1417
+
1418
+ const video = page.video();
1419
+ try { await context.storageState({ path: STORAGE_STATE_FILE }); } catch {}
1420
+ try { await context.close(); } catch {}
1421
+ if (video) {
1422
+ try {
1423
+ const videoPath = await video.path();
1424
+ if (videoPath && fs.existsSync(videoPath)) {
1425
+ const recordingName = `${captureRunId}_agent_${Date.now()}.webm`;
1426
+ const recordingPath = path.join(capturesDir, recordingName);
1427
+ try {
1428
+ fs.renameSync(videoPath, recordingPath);
1429
+ } catch (err) {
1430
+ if (err && err.code === 'EXDEV') {
1431
+ fs.copyFileSync(videoPath, recordingPath);
1432
+ fs.unlinkSync(videoPath);
1433
+ } else {
1434
+ throw err;
1435
+ }
1436
+ }
1437
+ }
1438
+ } catch (e) {
1439
+ console.error('Recording save failed:', e.message);
1440
+ }
1441
+ }
1442
+ try { await browser.close(); } catch {}
1443
+ res.json(outputData);
1444
+ } catch (error) {
1445
+ console.error('Agent Error:', error);
1446
+ try {
1447
+ if (context) await context.close();
1448
+ } catch {}
1449
+ if (browser) await browser.close();
1450
+ res.status(500).json({ error: 'Agent failed', details: error.message });
1451
+ }
1452
+ }
1256
1453
 
1257
1454
  module.exports = { handleAgent, setProgressReporter, setStopChecker };