@doppelgangerdev/doppelganger 0.4.3 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +162 -162
  2. package/README.md +39 -37
  3. package/agent.js +342 -130
  4. package/dist/assets/index-BXRKDZ1_.css +1 -0
  5. package/dist/assets/index-Deb2QMGx.js +19 -0
  6. package/dist/captures/run_1769042882403_589_agent_1769042884446_initial.png +0 -0
  7. package/dist/captures/run_1769042882403_589_agent_1769042887058.png +0 -0
  8. package/dist/captures/run_1769042882403_589_agent_1769042888468.webm +0 -0
  9. package/dist/captures/run_1769043202318_943_agent_1769043206237.png +0 -0
  10. package/dist/captures/run_1769043202318_943_agent_1769043207415.webm +0 -0
  11. package/dist/captures/run_1769043449517_97_agent_1769043451350_initial.png +0 -0
  12. package/dist/captures/run_1769043449517_97_agent_1769043455038.png +0 -0
  13. package/dist/captures/run_1769043449517_97_agent_1769043456476.webm +0 -0
  14. package/dist/captures/run_1769043471164_239_agent_1769043472720_initial.png +0 -0
  15. package/dist/captures/run_1769043471164_239_agent_1769043474022.png +0 -0
  16. package/dist/captures/run_1769043471164_239_agent_1769043476419.png +0 -0
  17. package/dist/captures/run_1769043471164_239_agent_1769043477795.webm +0 -0
  18. package/dist/captures/run_1769080585290_151_agent_1769080595110.png +0 -0
  19. package/dist/captures/run_1769080585290_151_agent_1769080596335.webm +0 -0
  20. package/dist/index.html +2 -2
  21. package/dist/screenshots/agent_1769037343598.png +0 -0
  22. package/dist/screenshots/agent_1769037357541.png +0 -0
  23. package/dist/screenshots/scrape_1769037382254.png +0 -0
  24. package/dist/screenshots/scrape_1769037413189.png +0 -0
  25. package/dist/screenshots/scrape_1769037449707.png +0 -0
  26. package/dist/screenshots/scrape_1769037461756.png +0 -0
  27. package/dist/screenshots/scrape_1769037490581.png +0 -0
  28. package/dist/screenshots/scrape_1769038242368.png +0 -0
  29. package/headful.js +76 -21
  30. package/package.json +3 -1
  31. package/proxy-rotation.js +133 -90
  32. package/public/captures/run_1769042882403_589_agent_1769042884446_initial.png +0 -0
  33. package/public/captures/run_1769042882403_589_agent_1769042887058.png +0 -0
  34. package/public/captures/run_1769042882403_589_agent_1769042888468.webm +0 -0
  35. package/public/captures/run_1769043202318_943_agent_1769043206237.png +0 -0
  36. package/public/captures/run_1769043202318_943_agent_1769043207415.webm +0 -0
  37. package/public/captures/run_1769043449517_97_agent_1769043451350_initial.png +0 -0
  38. package/public/captures/run_1769043449517_97_agent_1769043455038.png +0 -0
  39. package/public/captures/run_1769043449517_97_agent_1769043456476.webm +0 -0
  40. package/public/captures/run_1769043471164_239_agent_1769043472720_initial.png +0 -0
  41. package/public/captures/run_1769043471164_239_agent_1769043474022.png +0 -0
  42. package/public/captures/run_1769043471164_239_agent_1769043476419.png +0 -0
  43. package/public/captures/run_1769043471164_239_agent_1769043477795.webm +0 -0
  44. package/public/captures/run_1769080585290_151_agent_1769080595110.png +0 -0
  45. package/public/captures/run_1769080585290_151_agent_1769080596335.webm +0 -0
  46. package/public/screenshots/agent_1769037343598.png +0 -0
  47. package/public/screenshots/agent_1769037357541.png +0 -0
  48. package/public/screenshots/scrape_1769037382254.png +0 -0
  49. package/public/screenshots/scrape_1769037413189.png +0 -0
  50. package/public/screenshots/scrape_1769037449707.png +0 -0
  51. package/public/screenshots/scrape_1769037461756.png +0 -0
  52. package/public/screenshots/scrape_1769037490581.png +0 -0
  53. package/public/screenshots/scrape_1769038242368.png +0 -0
  54. package/scrape.js +163 -66
  55. package/server.js +127 -72
  56. package/dist/assets/index-D68YZVOp.js +0 -19
  57. package/dist/assets/index-WbwoTnJa.css +0 -1
package/agent.js CHANGED
@@ -3,6 +3,7 @@ const { JSDOM } = require('jsdom');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
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
 
@@ -146,7 +141,7 @@ async function humanType(page, selector, text, options = {}) {
146
141
  const typo = keys[Math.floor(Math.random() * keys.length)];
147
142
  await page.keyboard.press(typo, { delay: 40 + Math.random() * 120 });
148
143
  if (Math.random() < 0.5) {
149
- await page.waitForTimeout(120 + Math.random() * 200);
144
+ await page.waitForTimeout(60 + Math.random() * 120);
150
145
  }
151
146
  await page.keyboard.press('Backspace', { delay: 40 + Math.random() * 120 });
152
147
  if (Math.random() < 0.3) {
@@ -155,13 +150,13 @@ async function humanType(page, selector, text, options = {}) {
155
150
  }
156
151
  }
157
152
 
158
- const extra = punctuationPause.test(char) ? randomBetween(140, 320) : randomBetween(0, 90);
159
- const fatiguePause = fatigue && Math.random() < 0.06 ? randomBetween(180, 420) : 0;
153
+ const extra = punctuationPause.test(char) ? randomBetween(60, 150) : randomBetween(0, 40);
154
+ const fatiguePause = fatigue && Math.random() < 0.06 ? randomBetween(90, 200) : 0;
160
155
  await typeChar(char, baseDelay + extra + fatiguePause);
161
156
  burstCounter += 1;
162
157
 
163
158
  if (naturalTyping && char === ' ') {
164
- await page.waitForTimeout(randomBetween(40, 180));
159
+ await page.waitForTimeout(randomBetween(20, 80));
165
160
  }
166
161
  }
167
162
  }
@@ -170,6 +165,7 @@ async function handleAgent(req, res) {
170
165
  const data = (req.method === 'POST') ? req.body : req.query;
171
166
  let { url, actions, wait: globalWait, rotateUserAgents, rotateProxies, humanTyping, stealth = {} } = data;
172
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
@@ -198,9 +194,9 @@ async function handleAgent(req, res) {
198
194
  });
199
195
  }
200
196
 
201
- const localPort = req.socket && req.socket.localPort;
202
- const localHost = localPort ? `127.0.0.1:${localPort}` : req.get('host');
203
- const baseUrl = `${req.protocol || 'http'}://${localHost}`;
197
+ const localPort = req.socket && req.socket.localPort;
198
+ const localHost = localPort ? `127.0.0.1:${localPort}` : req.get('host');
199
+ const baseUrl = `${req.protocol || 'http'}://${localHost}`;
204
200
  const runtimeVars = { ...(data.taskVariables || data.variables || {}) };
205
201
  let lastBlockOutput = null;
206
202
  runtimeVars['block.output'] = lastBlockOutput;
@@ -232,6 +228,16 @@ async function handleAgent(req, res) {
232
228
  return resolveTemplate(value);
233
229
  };
234
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
+ };
240
+
235
241
  const parseValue = (value) => {
236
242
  if (typeof value !== 'string') return value;
237
243
  const trimmed = value.trim();
@@ -391,12 +397,11 @@ 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
402
  let browser;
403
+ let context;
404
+ let page;
400
405
  try {
401
406
  const launchOptions = {
402
407
  headless: true,
@@ -404,6 +409,7 @@ async function handleAgent(req, res) {
404
409
  args: [
405
410
  '--no-sandbox',
406
411
  '--disable-setuid-sandbox',
412
+ '--disable-dev-shm-usage',
407
413
  '--disable-blink-features=AutomationControlled',
408
414
  '--hide-scrollbars',
409
415
  '--mute-audio'
@@ -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
 
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
+
420
436
  const contextOptions = {
421
437
  userAgent: selectedUA,
422
- viewport: { width: 1280 + Math.floor(Math.random() * 640), height: 720 + Math.floor(Math.random() * 360) },
438
+ viewport,
423
439
  deviceScaleFactor: 1,
424
440
  locale: 'en-US',
425
441
  timezoneId: 'America/New_York',
426
442
  colorScheme: 'dark',
427
- permissions: ['geolocation']
443
+ permissions: ['geolocation'],
444
+ recordVideo: { dir: recordingsDir, size: viewport }
428
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);
451
+ context = await browser.newContext(contextOptions);
435
452
 
436
453
  await context.addInitScript(() => {
437
454
  Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
438
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,89 +726,150 @@ async function handleAgent(req, res) {
648
726
  return merged;
649
727
  };
650
728
 
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
+
651
747
  const executeAction = async (act) => {
652
748
  const { type, timeout } = act;
653
749
  const actionTimeout = timeout || 10000;
654
750
  let result = null;
655
751
 
656
752
  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();
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;
662
766
  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
- }
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
+ }
677
780
 
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
- }
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));
691
793
  }
794
+ }
692
795
 
693
- await page.waitForTimeout(baseDelay(50));
694
- await page.click(resolveMaybe(act.selector), {
695
- delay: baseDelay(50)
696
- });
697
- result = true;
698
- break;
796
+ await page.waitForTimeout(baseDelay(50));
797
+ await page.click(selectorValue, {
798
+ delay: baseDelay(50)
799
+ });
800
+ result = true;
801
+ break;
802
+ }
699
803
  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 });
804
+ case 'fill': {
805
+ const selectorValue = act.selector ? resolveMaybe(act.selector) : null;
806
+ const valueText = resolveMaybe(act.value) || '';
807
+ const typeMode = act.typeMode === 'append' ? 'append' : 'replace';
808
+ const humanOptions = { allowTypos, naturalTyping, fatigue };
809
+
810
+ const typeIntoSelector = async () => {
811
+ if (!selectorValue) return;
812
+ if (typeMode === 'replace') {
813
+ if (humanTyping) {
814
+ await page.fill(selectorValue, '');
815
+ await humanType(page, selectorValue, valueText, humanOptions);
816
+ } else {
817
+ await page.fill(selectorValue, valueText);
818
+ }
819
+ return;
820
+ }
704
821
  if (humanTyping) {
705
- await humanType(page, resolveMaybe(act.selector), resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
822
+ await humanType(page, selectorValue, valueText, humanOptions);
706
823
  } else {
707
- await page.fill(resolveMaybe(act.selector), resolveMaybe(act.value));
824
+ await page.type(selectorValue, valueText, { delay: baseDelay(50) });
708
825
  }
826
+ };
827
+
828
+ if (selectorValue) {
829
+ const coords = parseCoords(String(selectorValue));
830
+ logs.push(`Typing into ${selectorValue}: ${valueText}`);
831
+ if (coords) {
832
+ await page.mouse.click(coords.x, coords.y, { delay: baseDelay(50) });
833
+ await typeIntoSelector();
834
+ result = valueText;
835
+ break;
836
+ }
837
+ await page.waitForSelector(selectorValue, { timeout: actionTimeout });
838
+ await typeIntoSelector();
709
839
  } else {
710
- logs.push(`Typing (global): ${resolveMaybe(act.value)}`);
840
+ logs.push(`Typing (global): ${valueText}`);
711
841
  if (humanTyping) {
712
- await humanType(page, null, resolveMaybe(act.value), { allowTypos, naturalTyping, fatigue });
842
+ await humanType(page, null, valueText, humanOptions);
713
843
  } else {
714
- await page.keyboard.type(resolveMaybe(act.value), { delay: baseDelay(50) });
844
+ await page.keyboard.type(valueText, { delay: baseDelay(50) });
715
845
  }
716
846
  }
717
- result = resolveMaybe(act.value);
847
+ result = valueText;
718
848
  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));
849
+ }
850
+ case 'hover': {
851
+ const selectorValue = resolveMaybe(act.selector);
852
+ const coords = parseCoords(String(selectorValue || ''));
853
+ logs.push(`Hovering: ${selectorValue}`);
854
+ if (coords) {
855
+ await moveMouseHumanlike(page, coords.x, coords.y);
732
856
  result = true;
733
857
  break;
858
+ }
859
+ await page.waitForSelector(selectorValue, { timeout: actionTimeout });
860
+ {
861
+ const handle = await page.$(selectorValue);
862
+ const box = handle && await handle.boundingBox();
863
+ if (box) {
864
+ const centerX = box.x + box.width / 2 + (Math.random() - 0.5) * 5;
865
+ const centerY = box.y + box.height / 2 + (Math.random() - 0.5) * 5;
866
+ await moveMouseHumanlike(page, centerX, centerY);
867
+ }
868
+ }
869
+ await page.waitForTimeout(baseDelay(150));
870
+ result = true;
871
+ break;
872
+ }
734
873
  case 'press':
735
874
  logs.push(`Pressing key: ${resolveMaybe(act.key)}`);
736
875
  await page.keyboard.press(resolveMaybe(act.key), { delay: baseDelay(50) });
@@ -757,22 +896,62 @@ async function handleAgent(req, res) {
757
896
  await page.selectOption(resolveMaybe(act.selector), resolveMaybe(act.value));
758
897
  result = resolveMaybe(act.value);
759
898
  break;
760
- case 'scroll':
899
+ case 'scroll': {
761
900
  const amount = act.value ? parseInt(resolveMaybe(act.value), 10) : (400 + Math.random() * 400);
762
- logs.push(`Scrolling page: ${amount}px...`);
901
+ const speedMs = act.key ? parseInt(resolveMaybe(act.key), 10) : 500;
902
+ const durationMs = Number.isFinite(speedMs) && speedMs > 0 ? speedMs : 500;
903
+ logs.push(`Scrolling page: ${amount}px over ${durationMs}ms...`);
763
904
  if (overscroll) {
764
905
  await overshootScroll(page, amount);
906
+ await page.waitForTimeout(baseDelay(200));
765
907
  } else if (act.selector) {
766
- await page.evaluate(({ selector, y }) => {
908
+ await page.evaluate(({ selector, y, duration }) => {
767
909
  const el = document.querySelector(selector);
768
- if (el) el.scrollBy({ top: y, behavior: 'smooth' });
769
- }, { selector: resolveMaybe(act.selector), y: amount });
910
+ if (!el) return;
911
+ const start = el.scrollTop;
912
+ const target = start + y;
913
+ const startTime = performance.now();
914
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
915
+ const step = (now) => {
916
+ const elapsed = now - startTime;
917
+ const t = Math.min(1, elapsed / duration);
918
+ const next = start + (target - start) * easeOut(t);
919
+ el.scrollTop = next;
920
+ if (t < 1) requestAnimationFrame(step);
921
+ };
922
+ requestAnimationFrame(step);
923
+ }, { selector: resolveMaybe(act.selector), y: amount, duration: durationMs });
924
+ await page.waitForTimeout(baseDelay(durationMs));
770
925
  } else {
771
- await page.evaluate((y) => window.scrollBy({ top: y, behavior: 'smooth' }), amount);
926
+ await page.evaluate(({ y, duration }) => {
927
+ const start = window.scrollY || 0;
928
+ const target = start + y;
929
+ const startTime = performance.now();
930
+ const easeOut = (t) => 1 - Math.pow(1 - t, 3);
931
+ const step = (now) => {
932
+ const elapsed = now - startTime;
933
+ const t = Math.min(1, elapsed / duration);
934
+ const next = start + (target - start) * easeOut(t);
935
+ window.scrollTo(0, next);
936
+ if (t < 1) requestAnimationFrame(step);
937
+ };
938
+ requestAnimationFrame(step);
939
+ }, { y: amount, duration: durationMs });
940
+ await page.waitForTimeout(baseDelay(durationMs));
772
941
  }
773
- await page.waitForTimeout(baseDelay(500));
774
942
  result = amount;
775
943
  break;
944
+ }
945
+ case 'screenshot':
946
+ logs.push('Capturing screenshot...');
947
+ try {
948
+ const shotUrl = await captureScreenshot(act.label || act.value || '');
949
+ result = shotUrl;
950
+ logs.push(`Screenshot saved: ${shotUrl}`);
951
+ } catch (e) {
952
+ logs.push(`Screenshot failed: ${e.message}`);
953
+ }
954
+ break;
776
955
  case 'javascript':
777
956
  logs.push('Running custom JavaScript...');
778
957
  if (act.value) {
@@ -829,24 +1008,24 @@ async function handleAgent(req, res) {
829
1008
  case 'start': {
830
1009
  const taskId = resolveMaybe(act.value);
831
1010
  if (!taskId) throw new Error('Missing task id.');
832
- const apiKey = loadApiKey() || data.apiKey || data.key;
833
- if (!apiKey) {
834
- logs.push('No API key available; attempting internal start.');
835
- }
836
- logs.push(`Starting task: ${taskId}`);
837
- const headers = {
838
- 'Content-Type': 'application/json',
839
- 'x-internal-run': '1'
840
- };
841
- if (apiKey) {
842
- headers['x-api-key'] = apiKey;
843
- }
844
- const response = await fetch(`${baseUrl}/tasks/${taskId}/api`, {
845
- method: 'POST',
846
- headers,
847
- body: JSON.stringify({
848
- variables: runtimeVars,
849
- taskVariables: runtimeVars,
1011
+ const apiKey = loadApiKey() || data.apiKey || data.key;
1012
+ if (!apiKey) {
1013
+ logs.push('No API key available; attempting internal start.');
1014
+ }
1015
+ logs.push(`Starting task: ${taskId}`);
1016
+ const headers = {
1017
+ 'Content-Type': 'application/json',
1018
+ 'x-internal-run': '1'
1019
+ };
1020
+ if (apiKey) {
1021
+ headers['x-api-key'] = apiKey;
1022
+ }
1023
+ const response = await fetch(`${baseUrl}/tasks/${taskId}/api`, {
1024
+ method: 'POST',
1025
+ headers,
1026
+ body: JSON.stringify({
1027
+ variables: runtimeVars,
1028
+ taskVariables: runtimeVars,
850
1029
  runSource: 'agent_block',
851
1030
  taskId
852
1031
  })
@@ -856,10 +1035,10 @@ async function handleAgent(req, res) {
856
1035
  const detail = payload?.error || payload?.message || response.statusText;
857
1036
  throw new Error(`Start task failed: ${detail}`);
858
1037
  }
859
- result = payload?.data ?? payload?.html ?? payload;
860
- setBlockOutput(result);
861
- break;
862
- }
1038
+ result = payload?.data ?? payload?.html ?? payload;
1039
+ setBlockOutput(result);
1040
+ break;
1041
+ }
863
1042
  }
864
1043
  return result;
865
1044
  };
@@ -939,7 +1118,8 @@ async function handleAgent(req, res) {
939
1118
  if (act.type === 'while') {
940
1119
  try {
941
1120
  reportProgress(runId, { actionId: act.id, status: 'running' });
942
- const condition = await evalCondition(act.value);
1121
+ const hasStructured = act.conditionVarType || act.conditionOp || act.conditionVar || act.conditionValue;
1122
+ const condition = hasStructured ? evalStructuredCondition(act) : await evalCondition(act.value);
943
1123
  setBlockOutput(condition);
944
1124
  logs.push(`While condition: ${condition ? 'true' : 'false'}`);
945
1125
  reportProgress(runId, { actionId: act.id, status: 'success' });
@@ -964,17 +1144,19 @@ async function handleAgent(req, res) {
964
1144
  reportProgress(runId, { actionId: act.id, status: 'running' });
965
1145
  const rawCount = parseInt(resolveMaybe(act.value) || '0', 10);
966
1146
  const count = Number.isFinite(rawCount) ? rawCount : 0;
967
- const state = repeatState.get(index) || { remaining: count };
968
- repeatState.set(index, state);
1147
+ let state = repeatState.get(index);
1148
+ if (!state) {
1149
+ state = { remaining: count };
1150
+ repeatState.set(index, state);
1151
+ }
969
1152
  if (state.remaining <= 0) {
970
1153
  repeatState.delete(index);
971
1154
  reportProgress(runId, { actionId: act.id, status: 'success' });
972
1155
  index = (startToEnd[index] ?? index) + 1;
973
1156
  continue;
974
1157
  }
975
- state.remaining -= 1;
976
- logs.push(`Repeat block: ${state.remaining + 1} remaining`);
977
- setBlockOutput(state.remaining + 1);
1158
+ logs.push(`Repeat block: ${state.remaining} remaining`);
1159
+ setBlockOutput(state.remaining);
978
1160
  reportProgress(runId, { actionId: act.id, status: 'success' });
979
1161
  index += 1;
980
1162
  continue;
@@ -1014,11 +1196,15 @@ async function handleAgent(req, res) {
1014
1196
  }
1015
1197
  if (startAction.type === 'repeat') {
1016
1198
  const state = repeatState.get(startIndex);
1017
- if (state && state.remaining > 0) {
1018
- index = startIndex + 1;
1019
- continue;
1199
+ if (state) {
1200
+ state.remaining -= 1;
1201
+ if (state.remaining > 0) {
1202
+ setBlockOutput(state.remaining);
1203
+ index = startIndex + 1;
1204
+ continue;
1205
+ }
1206
+ repeatState.delete(startIndex);
1020
1207
  }
1021
- repeatState.delete(startIndex);
1022
1208
  }
1023
1209
  if (startAction.type === 'foreach') {
1024
1210
  const state = foreachState.get(startIndex);
@@ -1216,13 +1402,13 @@ async function handleAgent(req, res) {
1216
1402
  };
1217
1403
 
1218
1404
  // 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 });
1405
+ const capturesDir = path.join(__dirname, 'public', 'captures');
1406
+ if (!fs.existsSync(capturesDir)) {
1407
+ fs.mkdirSync(capturesDir, { recursive: true });
1222
1408
  }
1223
1409
 
1224
- const screenshotName = `agent_${Date.now()}.png`;
1225
- const screenshotPath = path.join(screenshotsDir, screenshotName);
1410
+ const screenshotName = `${captureRunId}_agent_${Date.now()}.png`;
1411
+ const screenshotPath = path.join(capturesDir, screenshotName);
1226
1412
  try {
1227
1413
  await page.screenshot({ path: screenshotPath, fullPage: false });
1228
1414
  } catch (e) {
@@ -1241,14 +1427,40 @@ async function handleAgent(req, res) {
1241
1427
  logs: logs || [],
1242
1428
  html: typeof cleanedHtml === 'string' ? safeFormatHTML(cleanedHtml) : '',
1243
1429
  data: formattedExtraction,
1244
- screenshot_url: fs.existsSync(screenshotPath) ? `/screenshots/${screenshotName}` : null
1430
+ screenshot_url: fs.existsSync(screenshotPath) ? `/captures/${screenshotName}` : null
1245
1431
  };
1246
1432
 
1433
+ const video = page.video();
1247
1434
  try { await context.storageState({ path: STORAGE_STATE_FILE }); } catch {}
1435
+ try { await context.close(); } catch {}
1436
+ if (video) {
1437
+ try {
1438
+ const videoPath = await video.path();
1439
+ if (videoPath && fs.existsSync(videoPath)) {
1440
+ const recordingName = `${captureRunId}_agent_${Date.now()}.webm`;
1441
+ const recordingPath = path.join(capturesDir, recordingName);
1442
+ try {
1443
+ fs.renameSync(videoPath, recordingPath);
1444
+ } catch (err) {
1445
+ if (err && err.code === 'EXDEV') {
1446
+ fs.copyFileSync(videoPath, recordingPath);
1447
+ fs.unlinkSync(videoPath);
1448
+ } else {
1449
+ throw err;
1450
+ }
1451
+ }
1452
+ }
1453
+ } catch (e) {
1454
+ console.error('Recording save failed:', e.message);
1455
+ }
1456
+ }
1248
1457
  try { await browser.close(); } catch {}
1249
1458
  res.json(outputData);
1250
1459
  } catch (error) {
1251
1460
  console.error('Agent Error:', error);
1461
+ try {
1462
+ if (context) await context.close();
1463
+ } catch {}
1252
1464
  if (browser) await browser.close();
1253
1465
  res.status(500).json({ error: 'Agent failed', details: error.message });
1254
1466
  }