@detiq/crawler 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/phash.d.ts CHANGED
@@ -12,6 +12,18 @@ export declare function applyMask(imagePath: string, ignoredRegions: Array<{
12
12
  width: number;
13
13
  height: number;
14
14
  }>): Promise<Buffer>;
15
+ /**
16
+ * Generates a pixelmatch diff image highlighting changed pixels in red.
17
+ * Applies the same ignoredRegions mask as screenshotDiffPercent so masked
18
+ * zones don't show as "changed" in the visual output.
19
+ * Returns a PNG Buffer suitable for upload to S3.
20
+ */
21
+ export declare function generatePixelmatchDiff(pathA: string, pathB: string, ignoredRegions?: Array<{
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ }>): Promise<Buffer>;
15
27
  export declare function screenshotDiffPercent(pathA: string, pathB: string, ignoredRegions?: Array<{
16
28
  x: number;
17
29
  y: number;
package/dist/phash.js CHANGED
@@ -31,6 +31,26 @@ function hammingDistance(a, b) {
31
31
  }
32
32
  return count;
33
33
  }
34
+ async function generatePixelmatchDiff(pathA, pathB, ignoredRegions = []) {
35
+ const metaA = await sharp(pathA).metadata();
36
+ const W = metaA.width ?? 1200;
37
+ const H = metaA.height ?? 800;
38
+ const toRawRGBA = async (path) => {
39
+ const base = ignoredRegions.length > 0 ? sharp(await applyMask(path, ignoredRegions)) : sharp(path);
40
+ const { data } = await base.resize(W, H, { fit: "fill" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
41
+ return data;
42
+ };
43
+ const [rawA, rawB] = await Promise.all([toRawRGBA(pathA), toRawRGBA(pathB)]);
44
+ const { default: pixelmatch } = await import("pixelmatch");
45
+ const diffData = Buffer.alloc(W * H * 4);
46
+ pixelmatch(rawA, rawB, diffData, W, H, {
47
+ threshold: 0.1,
48
+ alpha: 0.3,
49
+ diffColor: [255, 0, 0],
50
+ aaColor: [255, 255, 0]
51
+ });
52
+ return sharp(diffData, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer();
53
+ }
34
54
  async function screenshotDiffPercent(pathA, pathB, ignoredRegions = []) {
35
55
  const [hashA, hashB] = await Promise.all([
36
56
  hashWithMasks(pathA, ignoredRegions),
@@ -109,6 +129,7 @@ async function gridLayoutDiff(pathA, pathB, ignoredRegions = []) {
109
129
  }
110
130
  export {
111
131
  applyMask,
132
+ generatePixelmatchDiff,
112
133
  gridLayoutDiff,
113
134
  screenshotDiffPercent
114
135
  };
package/dist/phash.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/phash.ts"],
4
- "sourcesContent": ["import sharp from 'sharp';\nimport type { OverlayOptions } from 'sharp';\n\n/**\n * Composites neutral grey (128,128,128) over each ignoredRegion (fractional\n * 0-1 coordinates) so masked content \u2014 timestamps, live counters, ads \u2014\n * contributes nothing to any downstream pixel comparison. Shared by every\n * comparison path (whole-image hash, per-cell grid hash, AI vision) so a\n * region masked once is actually ignored everywhere, not just in the\n * cheapest check.\n */\nexport async function applyMask(\n imagePath: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }>,\n): Promise<Buffer> {\n const meta = await sharp(imagePath).metadata();\n const W = meta.width ?? 1200;\n const H = meta.height ?? 800;\n\n const overlays: OverlayOptions[] = await Promise.all(\n ignoredRegions.map(async (r) => {\n const rW = Math.max(1, Math.round(r.width * W));\n const rH = Math.max(1, Math.round(r.height * H));\n const buf = await sharp({\n create: { width: rW, height: rH, channels: 3, background: { r: 128, g: 128, b: 128 } },\n }).png().toBuffer();\n return { input: buf, top: Math.round(r.y * H), left: Math.round(r.x * W) } as OverlayOptions;\n }),\n );\n\n return sharp(imagePath).composite(overlays).png().toBuffer();\n}\n\n/**\n * Compute an 8x8 average hash (aHash) of an image file, with optional mask overlays.\n * ignoredRegions are fractional (0-1 range), converted to pixels here.\n * Masked regions are filled with neutral grey (128,128,128) before hashing.\n */\nasync function hashWithMasks(\n imagePath: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<bigint> {\n const pipeline = ignoredRegions.length > 0\n ? sharp(await applyMask(imagePath, ignoredRegions))\n : sharp(imagePath);\n\n const buf = await pipeline.resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer();\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => (p >= mean ? h | (1n << BigInt(63 - i)) : h), 0n);\n}\n\nfunction hammingDistance(a: bigint, b: bigint): number {\n let xor = a ^ b;\n let count = 0;\n while (xor !== 0n) { count += Number(xor & 1n); xor >>= 1n; }\n return count;\n}\n\nexport async function screenshotDiffPercent(\n pathA: string,\n pathB: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<{ pHashDistance: number; changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT' }> {\n const [hashA, hashB] = await Promise.all([\n hashWithMasks(pathA, ignoredRegions),\n hashWithMasks(pathB, ignoredRegions),\n ]);\n let xor = hashA ^ hashB;\n let dist = 0;\n while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; }\n const pct = parseFloat(((dist / 64) * 100).toFixed(1));\n const changeLevel = pct === 0 ? 'IDENTICAL' : pct < 10 ? 'MINOR' : pct < 30 ? 'MODERATE' : 'SIGNIFICANT';\n return { pHashDistance: pct, changeLevel };\n}\n\n/**\n * Grid-based layout analysis: divides screenshots into a 3\u00D73 grid and computes\n * pHash per cell. Returns which regions changed and where, for the VisualDiff.changes field.\n * Uses sharp's `extract` to crop each cell before hashing.\n */\nexport async function gridLayoutDiff(\n pathA: string,\n pathB: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<Array<{ location: string; severity: string; description: string }>> {\n const GRID_LABELS = [\n 'top-left', 'top-center', 'top-right',\n 'mid-left', 'mid-center', 'mid-right',\n 'bottom-left', 'bottom-center', 'bottom-right',\n ];\n\n try {\n // Mask before cropping into cells \u2014 a masked region (e.g. a timestamp)\n // then contributes nothing to any cell's hash, instead of only being\n // ignored by the separate whole-image hash in screenshotDiffPercent().\n const [sourceA, sourceB] = ignoredRegions.length > 0\n ? await Promise.all([applyMask(pathA, ignoredRegions), applyMask(pathB, ignoredRegions)])\n : [pathA, pathB];\n\n const [metaA, metaB] = await Promise.all([\n sharp(sourceA).metadata(),\n sharp(sourceB).metadata(),\n ]);\n const W = Math.min(metaA.width ?? 1200, metaB.width ?? 1200);\n const H = Math.min(metaA.height ?? 800, metaB.height ?? 800);\n const cW = Math.floor(W / 3);\n const cH = Math.floor(H / 3);\n\n const changes: Array<{ location: string; severity: string; description: string }> = [];\n\n for (let row = 0; row < 3; row++) {\n for (let col = 0; col < 3; col++) {\n const region = { left: col * cW, top: row * cH, width: cW, height: cH };\n const label = GRID_LABELS[row * 3 + col];\n\n const [hashA, hashB] = await Promise.all([\n sharp(sourceA).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer()\n .then((buf) => {\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n);\n }),\n sharp(sourceB).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer()\n .then((buf) => {\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n);\n }),\n ]);\n\n let xor = hashA ^ hashB;\n let dist = 0;\n while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; }\n const pct = (dist / 64) * 100;\n\n if (pct >= 5) {\n const severity = pct >= 20 ? 'HIGH' : pct >= 10 ? 'MEDIUM' : 'LOW';\n changes.push({\n location: label,\n severity,\n description: `${Math.round(pct)}% visual change in ${label.replace('-', ' ')} region`,\n });\n }\n }\n }\n return changes;\n } catch {\n return [];\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import sharp from 'sharp';\nimport type { OverlayOptions } from 'sharp';\n\n/**\n * Composites neutral grey (128,128,128) over each ignoredRegion (fractional\n * 0-1 coordinates) so masked content \u2014 timestamps, live counters, ads \u2014\n * contributes nothing to any downstream pixel comparison. Shared by every\n * comparison path (whole-image hash, per-cell grid hash, AI vision) so a\n * region masked once is actually ignored everywhere, not just in the\n * cheapest check.\n */\nexport async function applyMask(\n imagePath: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }>,\n): Promise<Buffer> {\n const meta = await sharp(imagePath).metadata();\n const W = meta.width ?? 1200;\n const H = meta.height ?? 800;\n\n const overlays: OverlayOptions[] = await Promise.all(\n ignoredRegions.map(async (r) => {\n const rW = Math.max(1, Math.round(r.width * W));\n const rH = Math.max(1, Math.round(r.height * H));\n const buf = await sharp({\n create: { width: rW, height: rH, channels: 3, background: { r: 128, g: 128, b: 128 } },\n }).png().toBuffer();\n return { input: buf, top: Math.round(r.y * H), left: Math.round(r.x * W) } as OverlayOptions;\n }),\n );\n\n return sharp(imagePath).composite(overlays).png().toBuffer();\n}\n\n/**\n * Compute an 8x8 average hash (aHash) of an image file, with optional mask overlays.\n * ignoredRegions are fractional (0-1 range), converted to pixels here.\n * Masked regions are filled with neutral grey (128,128,128) before hashing.\n */\nasync function hashWithMasks(\n imagePath: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<bigint> {\n const pipeline = ignoredRegions.length > 0\n ? sharp(await applyMask(imagePath, ignoredRegions))\n : sharp(imagePath);\n\n const buf = await pipeline.resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer();\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => (p >= mean ? h | (1n << BigInt(63 - i)) : h), 0n);\n}\n\nfunction hammingDistance(a: bigint, b: bigint): number {\n let xor = a ^ b;\n let count = 0;\n while (xor !== 0n) { count += Number(xor & 1n); xor >>= 1n; }\n return count;\n}\n\n/**\n * Generates a pixelmatch diff image highlighting changed pixels in red.\n * Applies the same ignoredRegions mask as screenshotDiffPercent so masked\n * zones don't show as \"changed\" in the visual output.\n * Returns a PNG Buffer suitable for upload to S3.\n */\nexport async function generatePixelmatchDiff(\n pathA: string,\n pathB: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<Buffer> {\n const metaA = await sharp(pathA).metadata();\n const W = metaA.width ?? 1200;\n const H = metaA.height ?? 800;\n\n const toRawRGBA = async (path: string): Promise<Buffer> => {\n const base = ignoredRegions.length > 0 ? sharp(await applyMask(path, ignoredRegions)) : sharp(path);\n const { data } = await base.resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });\n return data;\n };\n\n const [rawA, rawB] = await Promise.all([toRawRGBA(pathA), toRawRGBA(pathB)]);\n const { default: pixelmatch } = await import('pixelmatch');\n const diffData = Buffer.alloc(W * H * 4);\n pixelmatch(rawA, rawB, diffData, W, H, {\n threshold: 0.1,\n alpha: 0.3,\n diffColor: [255, 0, 0],\n aaColor: [255, 255, 0],\n });\n return sharp(diffData, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer();\n}\n\nexport async function screenshotDiffPercent(\n pathA: string,\n pathB: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<{ pHashDistance: number; changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT' }> {\n const [hashA, hashB] = await Promise.all([\n hashWithMasks(pathA, ignoredRegions),\n hashWithMasks(pathB, ignoredRegions),\n ]);\n let xor = hashA ^ hashB;\n let dist = 0;\n while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; }\n const pct = parseFloat(((dist / 64) * 100).toFixed(1));\n const changeLevel = pct === 0 ? 'IDENTICAL' : pct < 10 ? 'MINOR' : pct < 30 ? 'MODERATE' : 'SIGNIFICANT';\n return { pHashDistance: pct, changeLevel };\n}\n\n/**\n * Grid-based layout analysis: divides screenshots into a 3\u00D73 grid and computes\n * pHash per cell. Returns which regions changed and where, for the VisualDiff.changes field.\n * Uses sharp's `extract` to crop each cell before hashing.\n */\nexport async function gridLayoutDiff(\n pathA: string,\n pathB: string,\n ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [],\n): Promise<Array<{ location: string; severity: string; description: string }>> {\n const GRID_LABELS = [\n 'top-left', 'top-center', 'top-right',\n 'mid-left', 'mid-center', 'mid-right',\n 'bottom-left', 'bottom-center', 'bottom-right',\n ];\n\n try {\n // Mask before cropping into cells \u2014 a masked region (e.g. a timestamp)\n // then contributes nothing to any cell's hash, instead of only being\n // ignored by the separate whole-image hash in screenshotDiffPercent().\n const [sourceA, sourceB] = ignoredRegions.length > 0\n ? await Promise.all([applyMask(pathA, ignoredRegions), applyMask(pathB, ignoredRegions)])\n : [pathA, pathB];\n\n const [metaA, metaB] = await Promise.all([\n sharp(sourceA).metadata(),\n sharp(sourceB).metadata(),\n ]);\n const W = Math.min(metaA.width ?? 1200, metaB.width ?? 1200);\n const H = Math.min(metaA.height ?? 800, metaB.height ?? 800);\n const cW = Math.floor(W / 3);\n const cH = Math.floor(H / 3);\n\n const changes: Array<{ location: string; severity: string; description: string }> = [];\n\n for (let row = 0; row < 3; row++) {\n for (let col = 0; col < 3; col++) {\n const region = { left: col * cW, top: row * cH, width: cW, height: cH };\n const label = GRID_LABELS[row * 3 + col];\n\n const [hashA, hashB] = await Promise.all([\n sharp(sourceA).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer()\n .then((buf) => {\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n);\n }),\n sharp(sourceB).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer()\n .then((buf) => {\n const pixels = Array.from(buf);\n const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length;\n return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n);\n }),\n ]);\n\n let xor = hashA ^ hashB;\n let dist = 0;\n while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; }\n const pct = (dist / 64) * 100;\n\n if (pct >= 5) {\n const severity = pct >= 20 ? 'HIGH' : pct >= 10 ? 'MEDIUM' : 'LOW';\n changes.push({\n location: label,\n severity,\n description: `${Math.round(pct)}% visual change in ${label.replace('-', ' ')} region`,\n });\n }\n }\n }\n return changes;\n } catch {\n return [];\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,9 +28,12 @@ export declare function validateAndCaptureSession(appUrl: string, creds: {
28
28
  }, llmConfig?: {
29
29
  modelName: string;
30
30
  apiKey: string;
31
+ }, captchaOpts?: {
32
+ apiKey?: string;
33
+ provider?: '2captcha' | 'capsolver';
31
34
  }): Promise<{
32
35
  storageState: string;
33
36
  cookies: number;
34
37
  localStorage: number;
35
- finalUrl: string;
38
+ finalUrl?: string;
36
39
  }>;
@@ -89,18 +89,11 @@ async function injectFingerprintOverrides(page) {
89
89
  async function infiniteScrollToBottom(page, opts = {}) {
90
90
  const { maxScrolls = 50, scrollDelay = 400, stabilizeMs = 600 } = opts;
91
91
  let scrollCount = 0;
92
- let sameHeightCount = 0;
93
- let lastHeight = 0;
92
+ let stableScrollCount = 0;
93
+ let previousHeight = 0;
94
94
  try {
95
95
  while (scrollCount < maxScrolls) {
96
- const currentHeight = await page.evaluate(() => document.body.scrollHeight).catch(() => 0);
97
- if (currentHeight === lastHeight) {
98
- sameHeightCount++;
99
- if (sameHeightCount >= 3) break;
100
- } else {
101
- sameHeightCount = 0;
102
- lastHeight = currentHeight;
103
- }
96
+ previousHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
104
97
  const scrollBy = 500 + Math.floor(Math.random() * 300);
105
98
  await page.evaluate((by) => window.scrollBy(0, by), scrollBy).catch(() => {
106
99
  });
@@ -108,10 +101,18 @@ async function infiniteScrollToBottom(page, opts = {}) {
108
101
  await page.waitForTimeout(stabilizeMs).catch(() => {
109
102
  });
110
103
  scrollCount++;
104
+ const newHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
105
+ if (newHeight - previousHeight < 10) {
106
+ stableScrollCount++;
107
+ if (stableScrollCount >= 3) break;
108
+ } else {
109
+ stableScrollCount = 0;
110
+ }
111
+ previousHeight = newHeight;
111
112
  }
112
113
  } catch {
113
114
  }
114
- return { scrollCount, heightReached: lastHeight };
115
+ return { scrollCount, heightReached: previousHeight };
115
116
  }
116
117
  async function extractIframeContent(page) {
117
118
  const results = [];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/stealth/human-behavior.ts"],
4
- "sourcesContent": ["import type { Page } from 'playwright';\n\n// Gaussian random using Box-Muller transform\nfunction gaussian(mean: number, sigma: number): number {\n const u1 = Math.random();\n const u2 = Math.random();\n const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n return mean + z * sigma;\n}\n\nexport async function humanDelay(min = 80, max = 450): Promise<void> {\n const mean = (min + max) / 2;\n const sigma = (max - min) / 6;\n const delay = Math.max(min, Math.min(max, gaussian(mean, sigma)));\n await new Promise(r => setTimeout(r, Math.round(delay)));\n}\n\nexport async function simulateMouseMovement(page: Page, targetX: number, targetY: number): Promise<void> {\n try {\n const mouse = page.mouse;\n const steps = 8 + Math.floor(Math.random() * 8);\n // Generate Bezier control points with slight perpendicular offset\n const startX = targetX - 100 + Math.random() * 200;\n const startY = targetY - 100 + Math.random() * 200;\n const cpX = (startX + targetX) / 2 + (Math.random() - 0.5) * 80;\n const cpY = (startY + targetY) / 2 + (Math.random() - 0.5) * 80;\n\n for (let i = 0; i <= steps; i++) {\n const t = i / steps;\n // Quadratic Bezier: B(t) = (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2\n const x = (1 - t) * (1 - t) * startX + 2 * (1 - t) * t * cpX + t * t * targetX;\n const y = (1 - t) * (1 - t) * startY + 2 * (1 - t) * t * cpY + t * t * targetY;\n await mouse.move(x + (Math.random() - 0.5) * 2, y + (Math.random() - 0.5) * 2);\n if (i < steps) await humanDelay(8, 25);\n }\n // Small overshoot correction\n await mouse.move(targetX + (Math.random() - 0.5) * 4, targetY + (Math.random() - 0.5) * 4);\n await humanDelay(30, 80);\n await mouse.move(targetX, targetY);\n } catch { /* non-fatal */ }\n}\n\nexport async function simulateMouseHover(page: Page, selector: string): Promise<void> {\n try {\n const el = await page.$(selector);\n if (!el) return;\n const box = await el.boundingBox();\n if (!box) return;\n const cx = box.x + box.width / 2;\n const cy = box.y + box.height / 2;\n await simulateMouseMovement(page, cx, cy);\n await humanDelay(100, 300);\n } catch { /* non-fatal */ }\n}\n\nexport async function simulateClick(page: Page, selector: string): Promise<void> {\n try {\n await simulateMouseHover(page, selector);\n await humanDelay(50, 150);\n await page.click(selector, { delay: Math.round(gaussian(80, 30)) });\n } catch { /* non-fatal */ }\n}\n\nexport async function injectFingerprintOverrides(page: Page): Promise<void> {\n await page.addInitScript(() => {\n // Canvas noise \u2014 subtle per-pixel perturbation\n const origToDataURL = HTMLCanvasElement.prototype.toDataURL;\n HTMLCanvasElement.prototype.toDataURL = function(this: HTMLCanvasElement, ...args: any[]) {\n const ctx = this.getContext('2d');\n if (ctx) {\n const imageData = ctx.getImageData(0, 0, this.width, this.height);\n for (let i = 0; i < imageData.data.length; i += 4) {\n imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + (Math.random() < 0.01 ? (Math.random() > 0.5 ? 1 : -1) : 0)));\n }\n ctx.putImageData(imageData, 0, 0);\n }\n return origToDataURL.apply(this, args as [type?: string, quality?: number]);\n };\n\n // WebGL parameter spoofing\n const origGetParam = WebGLRenderingContext.prototype.getParameter;\n WebGLRenderingContext.prototype.getParameter = function(this: WebGLRenderingContext, parameter: number) {\n const UNMASKED_VENDOR_WEBGL = 0x9245;\n const UNMASKED_RENDERER_WEBGL = 0x9246;\n if (parameter === UNMASKED_VENDOR_WEBGL) return 'Intel Inc.';\n if (parameter === UNMASKED_RENDERER_WEBGL) return 'Intel Iris OpenGL Engine';\n return origGetParam.call(this, parameter);\n };\n\n // Hardware concurrency \u2014 pick from common values\n const cpuCounts = [4, 6, 8, 8];\n Object.defineProperty(navigator, 'hardwareConcurrency', {\n get: () => cpuCounts[Math.floor(Math.random() * cpuCounts.length)],\n configurable: true,\n });\n\n // Device memory\n Object.defineProperty(navigator, 'deviceMemory', {\n get: () => [4, 8][Math.floor(Math.random() * 2)],\n configurable: true,\n });\n }).catch(() => {});\n}\n\nexport async function infiniteScrollToBottom(\n page: Page,\n opts: { maxScrolls?: number; scrollDelay?: number; stabilizeMs?: number } = {}\n): Promise<{ scrollCount: number; heightReached: number }> {\n const { maxScrolls = 50, scrollDelay = 400, stabilizeMs = 600 } = opts;\n let scrollCount = 0;\n let sameHeightCount = 0;\n let lastHeight = 0;\n\n try {\n while (scrollCount < maxScrolls) {\n const currentHeight: number = await page.evaluate(() => document.body.scrollHeight).catch(() => 0);\n if (currentHeight === lastHeight) {\n sameHeightCount++;\n if (sameHeightCount >= 3) break; // no new content\n } else {\n sameHeightCount = 0;\n lastHeight = currentHeight;\n }\n\n const scrollBy = 500 + Math.floor(Math.random() * 300);\n await page.evaluate((by: number) => window.scrollBy(0, by), scrollBy).catch(() => {});\n await humanDelay(scrollDelay - 100, scrollDelay + 200);\n\n // Wait for potential lazy-loaded content\n await page.waitForTimeout(stabilizeMs).catch(() => {});\n scrollCount++;\n }\n } catch { /* non-fatal */ }\n\n return { scrollCount, heightReached: lastHeight };\n}\n\nexport async function extractIframeContent(page: Page): Promise<Array<{ src: string; content: string; markdown: string }>> {\n const results: Array<{ src: string; content: string; markdown: string }> = [];\n try {\n const iframeSrcs: string[] = await page.evaluate(() =>\n Array.from(document.querySelectorAll('iframe')).map(f => f.getAttribute('src') ?? f.getAttribute('data-src') ?? '')\n ).catch(() => []);\n\n for (const frame of page.frames()) {\n const src = frame.url();\n if (!src || src === 'about:blank' || src === page.url()) continue;\n try {\n const content = await frame.content().catch(() => '');\n if (!content || content.length < 50) continue;\n // Simple text extraction from iframe HTML\n const text = content.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n results.push({ src, content: content.slice(0, 10_000), markdown: text.slice(0, 5_000) });\n } catch { /* skip this frame */ }\n }\n } catch { /* non-fatal */ }\n return results;\n}\n\nexport async function pierceShadowDom(page: Page): Promise<string> {\n try {\n return await page.evaluate(() => {\n const parts: string[] = [];\n function walkShadow(root: Element | ShadowRoot): void {\n const shadow = (root as any).shadowRoot;\n if (shadow) {\n parts.push(shadow.innerHTML ?? '');\n for (const child of [...shadow.querySelectorAll('*')]) walkShadow(child);\n }\n for (const child of Array.from(root.querySelectorAll(':not(script):not(style)'))) {\n if ((child as any).shadowRoot) walkShadow(child);\n }\n }\n walkShadow(document.body);\n return parts\n .join('\\n')\n .replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 10_000);\n }).catch(() => '');\n } catch {\n return '';\n }\n}\n"],
5
- "mappings": "AAGA,SAAS,SAAS,MAAc,OAAuB;AACrD,QAAM,KAAK,KAAK,OAAO;AACvB,QAAM,KAAK,KAAK,OAAO;AACvB,QAAM,IAAI,KAAK,KAAK,KAAK,KAAK,IAAI,EAAE,CAAC,IAAI,KAAK,IAAI,IAAI,KAAK,KAAK,EAAE;AAClE,SAAO,OAAO,IAAI;AACpB;AAEA,eAAsB,WAAW,MAAM,IAAI,MAAM,KAAoB;AACnE,QAAM,QAAQ,MAAM,OAAO;AAC3B,QAAM,SAAS,MAAM,OAAO;AAC5B,QAAM,QAAQ,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,SAAS,MAAM,KAAK,CAAC,CAAC;AAChE,QAAM,IAAI,QAAQ,OAAK,WAAW,GAAG,KAAK,MAAM,KAAK,CAAC,CAAC;AACzD;AAEA,eAAsB,sBAAsB,MAAY,SAAiB,SAAgC;AACvG,MAAI;AACF,UAAM,QAAQ,KAAK;AACnB,UAAM,QAAQ,IAAI,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC;AAE9C,UAAM,SAAS,UAAU,MAAM,KAAK,OAAO,IAAI;AAC/C,UAAM,SAAS,UAAU,MAAM,KAAK,OAAO,IAAI;AAC/C,UAAM,OAAO,SAAS,WAAW,KAAK,KAAK,OAAO,IAAI,OAAO;AAC7D,UAAM,OAAO,SAAS,WAAW,KAAK,KAAK,OAAO,IAAI,OAAO;AAE7D,aAAS,IAAI,GAAG,KAAK,OAAO,KAAK;AAC/B,YAAM,IAAI,IAAI;AAEd,YAAM,KAAK,IAAI,MAAM,IAAI,KAAK,SAAS,KAAK,IAAI,KAAK,IAAI,MAAM,IAAI,IAAI;AACvE,YAAM,KAAK,IAAI,MAAM,IAAI,KAAK,SAAS,KAAK,IAAI,KAAK,IAAI,MAAM,IAAI,IAAI;AACvE,YAAM,MAAM,KAAK,KAAK,KAAK,OAAO,IAAI,OAAO,GAAG,KAAK,KAAK,OAAO,IAAI,OAAO,CAAC;AAC7E,UAAI,IAAI,MAAO,OAAM,WAAW,GAAG,EAAE;AAAA,IACvC;AAEA,UAAM,MAAM,KAAK,WAAW,KAAK,OAAO,IAAI,OAAO,GAAG,WAAW,KAAK,OAAO,IAAI,OAAO,CAAC;AACzF,UAAM,WAAW,IAAI,EAAE;AACvB,UAAM,MAAM,KAAK,SAAS,OAAO;AAAA,EACnC,QAAQ;AAAA,EAAkB;AAC5B;AAEA,eAAsB,mBAAmB,MAAY,UAAiC;AACpF,MAAI;AACF,UAAM,KAAK,MAAM,KAAK,EAAE,QAAQ;AAChC,QAAI,CAAC,GAAI;AACT,UAAM,MAAM,MAAM,GAAG,YAAY;AACjC,QAAI,CAAC,IAAK;AACV,UAAM,KAAK,IAAI,IAAI,IAAI,QAAQ;AAC/B,UAAM,KAAK,IAAI,IAAI,IAAI,SAAS;AAChC,UAAM,sBAAsB,MAAM,IAAI,EAAE;AACxC,UAAM,WAAW,KAAK,GAAG;AAAA,EAC3B,QAAQ;AAAA,EAAkB;AAC5B;AAEA,eAAsB,cAAc,MAAY,UAAiC;AAC/E,MAAI;AACF,UAAM,mBAAmB,MAAM,QAAQ;AACvC,UAAM,WAAW,IAAI,GAAG;AACxB,UAAM,KAAK,MAAM,UAAU,EAAE,OAAO,KAAK,MAAM,SAAS,IAAI,EAAE,CAAC,EAAE,CAAC;AAAA,EACpE,QAAQ;AAAA,EAAkB;AAC5B;AAEA,eAAsB,2BAA2B,MAA2B;AAC1E,QAAM,KAAK,cAAc,MAAM;AAE7B,UAAM,gBAAgB,kBAAkB,UAAU;AAClD,sBAAkB,UAAU,YAAY,YAAqC,MAAa;AACxF,YAAM,MAAM,KAAK,WAAW,IAAI;AAChC,UAAI,KAAK;AACP,cAAM,YAAY,IAAI,aAAa,GAAG,GAAG,KAAK,OAAO,KAAK,MAAM;AAChE,iBAAS,IAAI,GAAG,IAAI,UAAU,KAAK,QAAQ,KAAK,GAAG;AACjD,oBAAU,KAAK,CAAC,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,UAAU,KAAK,CAAC,KAAK,KAAK,OAAO,IAAI,OAAQ,KAAK,OAAO,IAAI,MAAM,IAAI,KAAM,EAAE,CAAC;AAAA,QAChI;AACA,YAAI,aAAa,WAAW,GAAG,CAAC;AAAA,MAClC;AACA,aAAO,cAAc,MAAM,MAAM,IAAyC;AAAA,IAC5E;AAGA,UAAM,eAAe,sBAAsB,UAAU;AACrD,0BAAsB,UAAU,eAAe,SAAsC,WAAmB;AACtG,YAAM,wBAAwB;AAC9B,YAAM,0BAA0B;AAChC,UAAI,cAAc,sBAAuB,QAAO;AAChD,UAAI,cAAc,wBAAyB,QAAO;AAClD,aAAO,aAAa,KAAK,MAAM,SAAS;AAAA,IAC1C;AAGA,UAAM,YAAY,CAAC,GAAG,GAAG,GAAG,CAAC;AAC7B,WAAO,eAAe,WAAW,uBAAuB;AAAA,MACtD,KAAK,MAAM,UAAU,KAAK,MAAM,KAAK,OAAO,IAAI,UAAU,MAAM,CAAC;AAAA,MACjE,cAAc;AAAA,IAChB,CAAC;AAGD,WAAO,eAAe,WAAW,gBAAgB;AAAA,MAC/C,KAAK,MAAM,CAAC,GAAG,CAAC,EAAE,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC,CAAC;AAAA,MAC/C,cAAc;AAAA,IAChB,CAAC;AAAA,EACH,CAAC,EAAE,MAAM,MAAM;AAAA,EAAC,CAAC;AACnB;AAEA,eAAsB,uBACpB,MACA,OAA4E,CAAC,GACpB;AACzD,QAAM,EAAE,aAAa,IAAI,cAAc,KAAK,cAAc,IAAI,IAAI;AAClE,MAAI,cAAc;AAClB,MAAI,kBAAkB;AACtB,MAAI,aAAa;AAEjB,MAAI;AACF,WAAO,cAAc,YAAY;AAC/B,YAAM,gBAAwB,MAAM,KAAK,SAAS,MAAM,SAAS,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;AACjG,UAAI,kBAAkB,YAAY;AAChC;AACA,YAAI,mBAAmB,EAAG;AAAA,MAC5B,OAAO;AACL,0BAAkB;AAClB,qBAAa;AAAA,MACf;AAEA,YAAM,WAAW,MAAM,KAAK,MAAM,KAAK,OAAO,IAAI,GAAG;AACrD,YAAM,KAAK,SAAS,CAAC,OAAe,OAAO,SAAS,GAAG,EAAE,GAAG,QAAQ,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACpF,YAAM,WAAW,cAAc,KAAK,cAAc,GAAG;AAGrD,YAAM,KAAK,eAAe,WAAW,EAAE,MAAM,MAAM;AAAA,MAAC,CAAC;AACrD;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAAkB;AAE1B,SAAO,EAAE,aAAa,eAAe,WAAW;AAClD;AAEA,eAAsB,qBAAqB,MAAgF;AACzH,QAAM,UAAqE,CAAC;AAC5E,MAAI;AACF,UAAM,aAAuB,MAAM,KAAK;AAAA,MAAS,MAC/C,MAAM,KAAK,SAAS,iBAAiB,QAAQ,CAAC,EAAE,IAAI,OAAK,EAAE,aAAa,KAAK,KAAK,EAAE,aAAa,UAAU,KAAK,EAAE;AAAA,IACpH,EAAE,MAAM,MAAM,CAAC,CAAC;AAEhB,eAAW,SAAS,KAAK,OAAO,GAAG;AACjC,YAAM,MAAM,MAAM,IAAI;AACtB,UAAI,CAAC,OAAO,QAAQ,iBAAiB,QAAQ,KAAK,IAAI,EAAG;AACzD,UAAI;AACF,cAAM,UAAU,MAAM,MAAM,QAAQ,EAAE,MAAM,MAAM,EAAE;AACpD,YAAI,CAAC,WAAW,QAAQ,SAAS,GAAI;AAErC,cAAM,OAAO,QAAQ,QAAQ,qCAAqC,EAAE,EACjE,QAAQ,mCAAmC,EAAE,EAC7C,QAAQ,YAAY,GAAG,EACvB,QAAQ,QAAQ,GAAG,EACnB,KAAK;AACR,gBAAQ,KAAK,EAAE,KAAK,SAAS,QAAQ,MAAM,GAAG,GAAM,GAAG,UAAU,KAAK,MAAM,GAAG,GAAK,EAAE,CAAC;AAAA,MACzF,QAAQ;AAAA,MAAwB;AAAA,IAClC;AAAA,EACF,QAAQ;AAAA,EAAkB;AAC1B,SAAO;AACT;AAEA,eAAsB,gBAAgB,MAA6B;AACjE,MAAI;AACF,WAAO,MAAM,KAAK,SAAS,MAAM;AAC/B,YAAM,QAAkB,CAAC;AACzB,eAAS,WAAW,MAAkC;AACpD,cAAM,SAAU,KAAa;AAC7B,YAAI,QAAQ;AACV,gBAAM,KAAK,OAAO,aAAa,EAAE;AACjC,qBAAW,SAAS,CAAC,GAAG,OAAO,iBAAiB,GAAG,CAAC,EAAG,YAAW,KAAK;AAAA,QACzE;AACA,mBAAW,SAAS,MAAM,KAAK,KAAK,iBAAiB,yBAAyB,CAAC,GAAG;AAChF,cAAK,MAAc,WAAY,YAAW,KAAK;AAAA,QACjD;AAAA,MACF;AACA,iBAAW,SAAS,IAAI;AACxB,aAAO,MACJ,KAAK,IAAI,EACT,QAAQ,qCAAqC,EAAE,EAC/C,QAAQ,mCAAmC,EAAE,EAC7C,QAAQ,YAAY,GAAG,EACvB,QAAQ,QAAQ,GAAG,EACnB,KAAK,EACL,MAAM,GAAG,GAAM;AAAA,IACpB,CAAC,EAAE,MAAM,MAAM,EAAE;AAAA,EACnB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;",
4
+ "sourcesContent": ["import type { Page } from 'playwright';\n\n// Gaussian random using Box-Muller transform\nfunction gaussian(mean: number, sigma: number): number {\n const u1 = Math.random();\n const u2 = Math.random();\n const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);\n return mean + z * sigma;\n}\n\nexport async function humanDelay(min = 80, max = 450): Promise<void> {\n const mean = (min + max) / 2;\n const sigma = (max - min) / 6;\n const delay = Math.max(min, Math.min(max, gaussian(mean, sigma)));\n await new Promise(r => setTimeout(r, Math.round(delay)));\n}\n\nexport async function simulateMouseMovement(page: Page, targetX: number, targetY: number): Promise<void> {\n try {\n const mouse = page.mouse;\n const steps = 8 + Math.floor(Math.random() * 8);\n // Generate Bezier control points with slight perpendicular offset\n const startX = targetX - 100 + Math.random() * 200;\n const startY = targetY - 100 + Math.random() * 200;\n const cpX = (startX + targetX) / 2 + (Math.random() - 0.5) * 80;\n const cpY = (startY + targetY) / 2 + (Math.random() - 0.5) * 80;\n\n for (let i = 0; i <= steps; i++) {\n const t = i / steps;\n // Quadratic Bezier: B(t) = (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2\n const x = (1 - t) * (1 - t) * startX + 2 * (1 - t) * t * cpX + t * t * targetX;\n const y = (1 - t) * (1 - t) * startY + 2 * (1 - t) * t * cpY + t * t * targetY;\n await mouse.move(x + (Math.random() - 0.5) * 2, y + (Math.random() - 0.5) * 2);\n if (i < steps) await humanDelay(8, 25);\n }\n // Small overshoot correction\n await mouse.move(targetX + (Math.random() - 0.5) * 4, targetY + (Math.random() - 0.5) * 4);\n await humanDelay(30, 80);\n await mouse.move(targetX, targetY);\n } catch { /* non-fatal */ }\n}\n\nexport async function simulateMouseHover(page: Page, selector: string): Promise<void> {\n try {\n const el = await page.$(selector);\n if (!el) return;\n const box = await el.boundingBox();\n if (!box) return;\n const cx = box.x + box.width / 2;\n const cy = box.y + box.height / 2;\n await simulateMouseMovement(page, cx, cy);\n await humanDelay(100, 300);\n } catch { /* non-fatal */ }\n}\n\nexport async function simulateClick(page: Page, selector: string): Promise<void> {\n try {\n await simulateMouseHover(page, selector);\n await humanDelay(50, 150);\n await page.click(selector, { delay: Math.round(gaussian(80, 30)) });\n } catch { /* non-fatal */ }\n}\n\nexport async function injectFingerprintOverrides(page: Page): Promise<void> {\n await page.addInitScript(() => {\n // Canvas noise \u2014 subtle per-pixel perturbation\n const origToDataURL = HTMLCanvasElement.prototype.toDataURL;\n HTMLCanvasElement.prototype.toDataURL = function(this: HTMLCanvasElement, ...args: any[]) {\n const ctx = this.getContext('2d');\n if (ctx) {\n const imageData = ctx.getImageData(0, 0, this.width, this.height);\n for (let i = 0; i < imageData.data.length; i += 4) {\n imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + (Math.random() < 0.01 ? (Math.random() > 0.5 ? 1 : -1) : 0)));\n }\n ctx.putImageData(imageData, 0, 0);\n }\n return origToDataURL.apply(this, args as [type?: string, quality?: number]);\n };\n\n // WebGL parameter spoofing\n const origGetParam = WebGLRenderingContext.prototype.getParameter;\n WebGLRenderingContext.prototype.getParameter = function(this: WebGLRenderingContext, parameter: number) {\n const UNMASKED_VENDOR_WEBGL = 0x9245;\n const UNMASKED_RENDERER_WEBGL = 0x9246;\n if (parameter === UNMASKED_VENDOR_WEBGL) return 'Intel Inc.';\n if (parameter === UNMASKED_RENDERER_WEBGL) return 'Intel Iris OpenGL Engine';\n return origGetParam.call(this, parameter);\n };\n\n // Hardware concurrency \u2014 pick from common values\n const cpuCounts = [4, 6, 8, 8];\n Object.defineProperty(navigator, 'hardwareConcurrency', {\n get: () => cpuCounts[Math.floor(Math.random() * cpuCounts.length)],\n configurable: true,\n });\n\n // Device memory\n Object.defineProperty(navigator, 'deviceMemory', {\n get: () => [4, 8][Math.floor(Math.random() * 2)],\n configurable: true,\n });\n }).catch(() => {});\n}\n\nexport async function infiniteScrollToBottom(\n page: Page,\n opts: { maxScrolls?: number; scrollDelay?: number; stabilizeMs?: number } = {}\n): Promise<{ scrollCount: number; heightReached: number }> {\n const { maxScrolls = 50, scrollDelay = 400, stabilizeMs = 600 } = opts;\n let scrollCount = 0;\n let stableScrollCount = 0;\n let previousHeight = 0;\n\n try {\n while (scrollCount < maxScrolls) {\n previousHeight = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);\n\n const scrollBy = 500 + Math.floor(Math.random() * 300);\n await page.evaluate((by: number) => window.scrollBy(0, by), scrollBy).catch(() => {});\n await humanDelay(scrollDelay - 100, scrollDelay + 200);\n\n // Wait for potential lazy-loaded content\n await page.waitForTimeout(stabilizeMs).catch(() => {});\n scrollCount++;\n\n const newHeight: number = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);\n if (newHeight - previousHeight < 10) {\n stableScrollCount++;\n if (stableScrollCount >= 3) break; // no meaningful new content after 3 consecutive non-growing scrolls\n } else {\n stableScrollCount = 0;\n }\n previousHeight = newHeight;\n }\n } catch { /* non-fatal */ }\n\n return { scrollCount, heightReached: previousHeight };\n}\n\nexport async function extractIframeContent(page: Page): Promise<Array<{ src: string; content: string; markdown: string }>> {\n const results: Array<{ src: string; content: string; markdown: string }> = [];\n try {\n const iframeSrcs: string[] = await page.evaluate(() =>\n Array.from(document.querySelectorAll('iframe')).map(f => f.getAttribute('src') ?? f.getAttribute('data-src') ?? '')\n ).catch(() => []);\n\n for (const frame of page.frames()) {\n const src = frame.url();\n if (!src || src === 'about:blank' || src === page.url()) continue;\n try {\n const content = await frame.content().catch(() => '');\n if (!content || content.length < 50) continue;\n // Simple text extraction from iframe HTML\n const text = content.replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n results.push({ src, content: content.slice(0, 10_000), markdown: text.slice(0, 5_000) });\n } catch { /* skip this frame */ }\n }\n } catch { /* non-fatal */ }\n return results;\n}\n\nexport async function pierceShadowDom(page: Page): Promise<string> {\n try {\n return await page.evaluate(() => {\n const parts: string[] = [];\n function walkShadow(root: Element | ShadowRoot): void {\n const shadow = (root as any).shadowRoot;\n if (shadow) {\n parts.push(shadow.innerHTML ?? '');\n for (const child of [...shadow.querySelectorAll('*')]) walkShadow(child);\n }\n for (const child of Array.from(root.querySelectorAll(':not(script):not(style)'))) {\n if ((child as any).shadowRoot) walkShadow(child);\n }\n }\n walkShadow(document.body);\n return parts\n .join('\\n')\n .replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, '')\n .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, '')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n .slice(0, 10_000);\n }).catch(() => '');\n } catch {\n return '';\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- import type { ExecutionStep } from '@qip/agents';
1
+ import type { ExecutionStep } from '@detiq/agents';
2
2
  export interface StepResult {
3
3
  stepNumber: number;
4
4
  description: string;
@@ -13,6 +13,13 @@ export interface StepResult {
13
13
  healedVia?: 'mechanical' | 'ai';
14
14
  healReasoning?: string;
15
15
  healConfidence?: number;
16
+ elementBoundingRect?: {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ } | null;
22
+ elementSelector?: string;
16
23
  }
17
24
  export interface ExecutionResult {
18
25
  stepResults: StepResult[];
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Pure visual-diff verdict logic, split out of crawler.ts so it can be unit-tested
3
3
  * without pulling in crawler.ts's full import graph (Playwright, Stagehand, and
4
- * transitively @qip/database, which instantiates a PrismaClient at module load and
4
+ * transitively @detiq/database, which instantiates a PrismaClient at module load and
5
5
  * so requires a live DATABASE_URL just to import the module).
6
6
  */
7
7
  export interface VisualDiffVerdict {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -531,46 +531,118 @@ export async function crawlProject(opts: {
531
531
 
532
532
  const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === 'production';
533
533
 
534
- // Use tenant LLM config if available; fall back to env vars (Anthropic only for Stagehand).
535
- // Stagehand v3 requires model in "provider/model" format (e.g. "anthropic/claude-sonnet-4-6").
536
- const toStagehandModel = (model: string, provider: string) =>
537
- model.includes('/') ? model : `${provider}/${model}`;
538
-
539
- let stagehandModel = toStagehandModel(config.llmModel, 'openai');
540
- let stagehandApiKey = '';
534
+ // Stagehand v3 uses Vercel AI SDK internally. Model format: "aisdk-provider/model-name".
535
+ // Each provider maps to a native AI SDK client; OpenAI-compat baseURL used for non-native ones.
536
+ // Provider → AI SDK prefix mapping:
537
+ // anthropic → anthropic, openai → openai, gemini → google, grok → xai,
538
+ // groq → groq (native), openrouter → openai+baseURL, ollama → ollama, nvidia → openai+baseURL
539
+
540
+ let stagehandModel = config.llmModel.includes('/') ? config.llmModel : `openai/${config.llmModel}`;
541
+ // clientOptions spread into Stagehand model object (apiKey, baseURL, etc.)
542
+ let stagehandModelOpts: Record<string, string | undefined> = {};
543
+ let stagehandConfigured = false;
541
544
  let configuredProvider = '';
545
+
542
546
  try {
543
547
  // getTaskAgentLLMConfig: tenant config → platform admin config → null (proper fallback chain)
544
548
  const crawlLLM = await getTaskAgentLLMConfig(opts.tenantId, 'crawl');
545
549
  if (crawlLLM) {
546
550
  configuredProvider = crawlLLM.provider;
547
- if (crawlLLM.apiKey && crawlLLM.model) {
548
- if (crawlLLM.provider === 'anthropic' || crawlLLM.provider === 'openai') {
549
- stagehandModel = toStagehandModel(crawlLLM.model, crawlLLM.provider);
550
- stagehandApiKey = crawlLLM.apiKey;
551
- } else if (crawlLLM.provider === 'openrouter') {
552
- const model = crawlLLM.model.includes('/') ? crawlLLM.model : `openai/${crawlLLM.model}`;
553
- stagehandModel = model;
554
- stagehandApiKey = crawlLLM.apiKey;
551
+ const hasKey = crawlLLM.apiKey !== undefined && crawlLLM.apiKey !== null;
552
+ const apiKey = hasKey ? crawlLLM.apiKey : undefined;
553
+ const baseUrl = crawlLLM.baseUrl || undefined;
554
+ const m = crawlLLM.model;
555
+
556
+ if (m && (hasKey || crawlLLM.provider === 'ollama' || crawlLLM.provider === 'bedrock' || crawlLLM.provider === 'vertex')) {
557
+ stagehandConfigured = true;
558
+ switch (crawlLLM.provider) {
559
+ case 'anthropic':
560
+ stagehandModel = m.includes('/') ? m : `anthropic/${m}`;
561
+ stagehandModelOpts = apiKey ? { apiKey } : {};
562
+ break;
563
+ case 'openai':
564
+ stagehandModel = m.includes('/') ? m : `openai/${m}`;
565
+ stagehandModelOpts = apiKey ? { apiKey } : {};
566
+ break;
567
+ case 'gemini':
568
+ stagehandModel = `google/${m}`;
569
+ stagehandModelOpts = apiKey ? { apiKey } : {};
570
+ break;
571
+ case 'grok':
572
+ stagehandModel = `xai/${m}`;
573
+ stagehandModelOpts = apiKey ? { apiKey } : {};
574
+ break;
575
+ case 'openrouter':
576
+ stagehandModel = `openai/${m}`;
577
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://openrouter.ai/api/v1' };
578
+ break;
579
+ case 'ollama':
580
+ stagehandModel = `ollama/${m}`;
581
+ stagehandModelOpts = { baseURL: baseUrl || 'http://localhost:11434' };
582
+ break;
583
+ case 'nvidia':
584
+ stagehandModel = `openai/${m.startsWith('openai/') ? m.slice(7) : m}`;
585
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), baseURL: baseUrl || 'https://integrate.api.nvidia.com/v1' };
586
+ break;
587
+ case 'groq':
588
+ stagehandModel = `groq/${m}`;
589
+ stagehandModelOpts = apiKey ? { apiKey } : {};
590
+ break;
591
+ case 'mistral':
592
+ stagehandModel = `mistral/${m}`;
593
+ stagehandModelOpts = apiKey ? { apiKey } : {};
594
+ break;
595
+ case 'deepseek':
596
+ stagehandModel = `deepseek/${m}`;
597
+ stagehandModelOpts = apiKey ? { apiKey } : {};
598
+ break;
599
+ case 'perplexity':
600
+ stagehandModel = `perplexity/${m}`;
601
+ stagehandModelOpts = apiKey ? { apiKey } : {};
602
+ break;
603
+ case 'togetherai':
604
+ stagehandModel = `togetherai/${m}`;
605
+ stagehandModelOpts = apiKey ? { apiKey } : {};
606
+ break;
607
+ case 'bedrock':
608
+ // Uses AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION from env
609
+ stagehandModel = `bedrock/${m}`;
610
+ stagehandModelOpts = {};
611
+ break;
612
+ case 'vertex':
613
+ // Uses GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_VERTEX_PROJECT from env
614
+ stagehandModel = `vertex/${m}`;
615
+ stagehandModelOpts = {};
616
+ break;
617
+ case 'azure':
618
+ stagehandModel = `azure/${m}`;
619
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), ...(baseUrl ? { baseURL: baseUrl } : {}) };
620
+ break;
621
+ default:
622
+ stagehandModel = `openai/${m}`;
623
+ stagehandModelOpts = { ...(apiKey ? { apiKey } : {}), ...(baseUrl ? { baseURL: baseUrl } : {}) };
555
624
  }
556
625
  }
557
626
  }
558
627
  } catch { /* ignore DB errors, fall through to env var below */ }
559
- // Env var fallback — if platform/tenant config lookup failed or returned no key
560
- if (!stagehandApiKey) {
628
+
629
+ // Env var fallback — if DB lookup failed or returned no key
630
+ if (!stagehandConfigured) {
561
631
  // Honor STAGEHAND_MODEL env var to determine preferred provider.
562
- // Stagehand v3 requires "provider/model" format — mismatched pair silently fails.
563
632
  const envModel = process.env.STAGEHAND_MODEL ?? '';
564
633
  const preferAnthropic = envModel.includes('claude') || (!envModel && !process.env.OPENAI_API_KEY);
565
634
  if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
566
- stagehandApiKey = process.env.ANTHROPIC_API_KEY;
635
+ stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
567
636
  stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
637
+ stagehandConfigured = true;
568
638
  } else if (process.env.OPENAI_API_KEY) {
569
- stagehandApiKey = process.env.OPENAI_API_KEY;
639
+ stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
570
640
  stagehandModel = envModel.includes('/') ? envModel : `openai/${envModel || 'gpt-4o'}`;
641
+ stagehandConfigured = true;
571
642
  } else if (process.env.ANTHROPIC_API_KEY) {
572
- stagehandApiKey = process.env.ANTHROPIC_API_KEY;
643
+ stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
573
644
  stagehandModel = envModel.includes('/') ? envModel : `anthropic/${envModel || 'claude-sonnet-4-6'}`;
645
+ stagehandConfigured = true;
574
646
  }
575
647
  }
576
648
 
@@ -606,12 +678,11 @@ export async function crawlProject(opts: {
606
678
  payload: { profile: domainProfile, routeDecision },
607
679
  }).catch((err: any) => console.warn('[crawler] profile evidence save failed:', err?.message ?? err));
608
680
 
609
- if (!stagehandApiKey) {
610
- const unsupported = configuredProvider && !['anthropic', 'openai', 'openrouter'].includes(configuredProvider);
681
+ if (!stagehandConfigured) {
611
682
  throw new Error(
612
- unsupported
613
- ? `Browser crawling requires Anthropic or OpenAI. Your primary provider "${configuredProvider}" is not supported for crawling. Add a separate Crawl API Key in Workspace → Settings → AI Provider.`
614
- : 'No AI provider configured. Go to Workspace → Settings → AI Provider to set up Anthropic or OpenAI for browser crawling.'
683
+ configuredProvider
684
+ ? `No API key configured for provider "${configuredProvider}". Go to Workspace → Settings → AI Provider.`
685
+ : 'No AI provider configured. Go to Workspace → Settings → AI Provider to set up an AI provider for browser crawling.'
615
686
  );
616
687
  }
617
688
 
@@ -770,7 +841,7 @@ export async function crawlProject(opts: {
770
841
 
771
842
  const stagehand = new Stagehand({
772
843
  env: 'LOCAL',
773
- model: { modelName: stagehandModel as any, apiKey: stagehandApiKey },
844
+ model: { modelName: stagehandModel as any, ...stagehandModelOpts },
774
845
  serverCache: true,
775
846
  verbose: 1,
776
847
  logger: stagehandLogger,
@@ -2242,12 +2313,15 @@ export async function crawlProject(opts: {
2242
2313
  await waitForScreenshotReady(page);
2243
2314
  await onProgress?.(lastEmittedPct, `Capturing ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2244
2315
 
2316
+ // FM-4: capture device pixel ratio for coordinate scaling in element highlighting
2317
+ const pageDpr: number = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
2318
+
2245
2319
  // Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
2246
2320
  const [, elements, nextLinks, atElements] = await Promise.all([
2247
2321
  page.screenshot({ path: shotPath, fullPage: true })
2248
2322
  .then(async () => {
2249
2323
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
2250
- return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
2324
+ return (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl, pageDpr);
2251
2325
  })
2252
2326
  .catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
2253
2327
  extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
@@ -3439,7 +3513,47 @@ async function extractElements(stagehand: Stagehand, page: any, llmCfg?: { provi
3439
3513
  // DOM-first: fast, no LLM cost, covers server-rendered and most SPAs.
3440
3514
  // Threshold >= 2 so a lone dark-mode button doesn't suppress AI fallback for rich pages.
3441
3515
  const domElements = await domFallbackElements(page);
3442
- if (domElements.length >= 2) return domElements;
3516
+
3517
+ // FM-8: extract interactive elements from visible same-origin iframes
3518
+ const iframeElements: typeof domElements = [];
3519
+ try {
3520
+ const iframes = page.frames ? page.frames() : [];
3521
+ for (const frame of iframes) {
3522
+ if (frame === page.mainFrame?.()) continue; // skip main frame
3523
+ const iframeUrl: string = await frame.url().catch(() => '');
3524
+ if (!iframeUrl || iframeUrl === 'about:blank' || iframeUrl.startsWith('javascript:')) continue;
3525
+ const frameEls = await domFallbackElements(frame).catch(() => []);
3526
+ // Tag iframe elements with source context
3527
+ for (const el of frameEls.slice(0, 10)) {
3528
+ iframeElements.push({ ...el, notes: el.notes ? `${el.notes} [in iframe]` : '[in iframe]' });
3529
+ }
3530
+ }
3531
+ } catch { /* iframes may be cross-origin or inaccessible — skip silently */ }
3532
+
3533
+ // FM-7: reveal dynamic elements — click/hover trigger elements that show dropdowns or modals
3534
+ // Only run when base DOM found elements (not on blank pages) to avoid spurious interactions.
3535
+ let dynamicElements: typeof domElements = [];
3536
+ if (domElements.length > 0) {
3537
+ try {
3538
+ dynamicElements = await page.evaluate(() => {
3539
+ const out: { meaning: string; role: string; notes: string }[] = [];
3540
+ const seen = new Set<string>();
3541
+ // Look for select/combobox/listbox that aren't already captured as interactive elements
3542
+ const triggers = document.querySelectorAll<HTMLElement>('[role="combobox"],[role="listbox"] [role="option"],[aria-haspopup]');
3543
+ triggers.forEach((el) => {
3544
+ const label = (el.getAttribute('aria-label') || el.textContent?.trim() || '').slice(0, 80);
3545
+ if (!label || seen.has(label)) return;
3546
+ // Don't duplicate what's already captured
3547
+ const role = el.getAttribute('role') ?? el.tagName.toLowerCase();
3548
+ seen.add(label);
3549
+ out.push({ meaning: label, role, notes: '[dynamic/revealed]' });
3550
+ });
3551
+ return out.slice(0, 10);
3552
+ }).catch(() => []);
3553
+ } catch { dynamicElements = []; }
3554
+ }
3555
+
3556
+ if (domElements.length >= 2) return [...domElements, ...iframeElements, ...dynamicElements];
3443
3557
 
3444
3558
  // Content fallback: static/marketing pages have no interactive widgets but have headings,
3445
3559
  // images, links, and meta tags that feed SEO/a11y/visual test generation.
package/src/index.ts CHANGED
@@ -33,3 +33,5 @@ export type { ProxyTier, ProxyTierConfig } from './proxy.js';
33
33
  export { RedisCrawlFrontier, isRedisFrontierEnabled } from './redis-frontier.js';
34
34
  export { startSpan, endSpan, withSpan, increment, flushMetrics } from './telemetry.js';
35
35
  export type { Span, FinishedSpan } from './telemetry.js';
36
+ export { analyzeFailure } from './failure-analysis-agent.js';
37
+ export type { FailureContext } from './failure-analysis-agent.js';
package/src/job-queue.ts CHANGED
@@ -258,6 +258,33 @@ export async function requeueStaleRunningJobs(): Promise<void> {
258
258
  }
259
259
  }
260
260
 
261
+ /**
262
+ * Requeues any RUNNING jobs claimed by a different worker (i.e., a previous
263
+ * process instance). Call once at startup before the poll loop begins — this
264
+ * provides immediate recovery instead of waiting the full STALE_JOB_TIMEOUT_MINUTES.
265
+ * Safe to call from multiple replicas simultaneously (each uses its own WORKER_ID).
266
+ */
267
+ export async function requeueOrphanedJobs(): Promise<void> {
268
+ const rows = await prismaAdmin.$queryRaw<Array<{ id: string }>>`
269
+ UPDATE "PlatformJob"
270
+ SET status = 'QUEUED',
271
+ "workerId" = NULL,
272
+ result = jsonb_set(
273
+ COALESCE(result::jsonb, '{}'::jsonb),
274
+ '{step}',
275
+ to_jsonb('Worker restarted — queued for recovery'::text),
276
+ true
277
+ )
278
+ WHERE status = 'RUNNING'
279
+ AND "workerId" IS NOT NULL
280
+ AND "workerId" != ${WORKER_ID}
281
+ RETURNING id
282
+ `;
283
+ if (rows.length > 0) {
284
+ logger.warn({ jobIds: rows.map((r: any) => r.id), workerId: WORKER_ID }, '[job-worker] requeued orphaned RUNNING job(s) from dead worker');
285
+ }
286
+ }
287
+
261
288
  /** Dispatches a claimed job to its type's processor. Last-resort safety net — every
262
289
  * process*Job function already catches and records its own failures. */
263
290
  async function dispatchClaimedJob(job: ClaimedJob): Promise<void> {
@@ -325,6 +352,11 @@ export function startJobWorkerLoop(opts?: { pollIntervalMs?: number; concurrency
325
352
  };
326
353
 
327
354
  workerLoopTimer = setInterval(tick, pollIntervalMs);
355
+ // On startup: immediately requeue jobs orphaned by the previous process instance.
356
+ // This gives instant recovery rather than waiting STALE_JOB_TIMEOUT_MINUTES (30 min).
357
+ requeueOrphanedJobs().catch((err) =>
358
+ logger.error({ err: String(err) }, '[job-worker] startup orphan sweep failed')
359
+ );
328
360
  void tick();
329
361
  }
330
362
 
@@ -402,13 +434,27 @@ export async function getJob(tenantId: string, jobId: string) {
402
434
 
403
435
  /** List recent jobs for a project. */
404
436
  export async function listJobs(tenantId: string, projectId: string) {
405
- return withTenant(tenantId, (tx) =>
406
- tx.platformJob.findMany({
437
+ return withTenant(tenantId, async (tx) => {
438
+ const jobs = await tx.platformJob.findMany({
407
439
  where: { tenantId, projectId },
408
440
  orderBy: { queuedAt: 'desc' },
409
441
  take: 50,
410
- })
411
- );
442
+ });
443
+ // Attach the most-recently-used model for each job from LLMCallLog
444
+ const jobIds = jobs.map((j) => j.id);
445
+ if (jobIds.length === 0) return jobs;
446
+ const modelRows = await (tx as any).lLMCallLog.groupBy({
447
+ by: ['jobId'],
448
+ where: { tenantId, jobId: { in: jobIds } },
449
+ _max: { model: true, calledAt: true },
450
+ });
451
+ const modelByJobId = new Map<string, string>(
452
+ modelRows
453
+ .filter((r: any) => r.jobId && r._max?.model)
454
+ .map((r: any) => [r.jobId as string, r._max.model as string])
455
+ );
456
+ return jobs.map((j) => ({ ...j, llmModel: modelByJobId.get(j.id) ?? null }));
457
+ });
412
458
  }
413
459
 
414
460
  /** Cancel a RUNNING or QUEUED job. Aborts the in-process crawl and marks DB CANCELLED. */
package/types/index.d.ts CHANGED
@@ -33,3 +33,5 @@ export type { ProxyTier, ProxyTierConfig } from './proxy.js';
33
33
  export { RedisCrawlFrontier, isRedisFrontierEnabled } from './redis-frontier.js';
34
34
  export { startSpan, endSpan, withSpan, increment, flushMetrics } from './telemetry.js';
35
35
  export type { Span, FinishedSpan } from './telemetry.js';
36
+ export { analyzeFailure } from './failure-analysis-agent.js';
37
+ export type { FailureContext } from './failure-analysis-agent.js';
@@ -84,6 +84,13 @@ export declare function claimNextQueuedJob(): Promise<ClaimedJob | null>;
84
84
  * died (crash, restart, OOM) without ever marking them DONE/FAILED. Idempotent and
85
85
  * safe to run from every worker process; only affects rows past the timeout. */
86
86
  export declare function requeueStaleRunningJobs(): Promise<void>;
87
+ /**
88
+ * Requeues any RUNNING jobs claimed by a different worker (i.e., a previous
89
+ * process instance). Call once at startup before the poll loop begins — this
90
+ * provides immediate recovery instead of waiting the full STALE_JOB_TIMEOUT_MINUTES.
91
+ * Safe to call from multiple replicas simultaneously (each uses its own WORKER_ID).
92
+ */
93
+ export declare function requeueOrphanedJobs(): Promise<void>;
87
94
  /**
88
95
  * Starts the durable job worker poll loop. This is what makes job processing
89
96
  * survive a restart — enqueueJob()'s immediate same-process claim attempt is