@detiq/crawler 1.0.4 → 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/crawler.d.ts CHANGED
@@ -35,10 +35,24 @@ export declare function crawlProject(opts: {
35
35
  tenantId: string;
36
36
  projectId: string;
37
37
  appUrl: string;
38
+ /** The viewport used for this crawl pass */
39
+ viewport?: 'DESKTOP' | 'MOBILE_PORTRAIT' | 'TABLET';
38
40
  credentials?: Credentials;
39
41
  /** Playwright storageState JSON string — if present, session is restored and AI login is skipped. */
40
42
  storageState?: string;
41
43
  screenshotDir?: string;
44
+ /** Cloud storage config for immediate screenshot upload. If set, screenshots are uploaded to cloud immediately after capture and cloudUrl is persisted. */
45
+ storageConfig?: {
46
+ provider: string;
47
+ bucket: string;
48
+ region?: string;
49
+ accountId?: string;
50
+ endpointUrl?: string;
51
+ accessKeyId?: string;
52
+ secretAccessKey?: string;
53
+ prefix?: string;
54
+ publicUrl?: string;
55
+ } | null;
42
56
  /** When true, run a fast URL-only discovery crawl — skips screenshots, DB saves, and AI element extraction. Returns discoveredUrls in the result object. */
43
57
  discoveryMode?: boolean;
44
58
  maxScreens?: number;
package/dist/crawler.js CHANGED
@@ -49,6 +49,13 @@ import { screensToSitemapUrls, writeSitemap } from "./sitemap-writer.js";
49
49
  import { generateOpenApiSpec, specToYaml } from "./openapi-generator.js";
50
50
  import { WebhookNotifier } from "./webhook-notifier.js";
51
51
  import { generatePlaywrightTest } from "./playwright-recorder.js";
52
+ const _crawlLLMCache = /* @__PURE__ */ new Map();
53
+ async function getCachedCrawlLLM(tenantId) {
54
+ if (_crawlLLMCache.has(tenantId)) return _crawlLLMCache.get(tenantId);
55
+ const cfg = await getTaskAgentLLMConfig(tenantId, "crawl").catch(() => null);
56
+ _crawlLLMCache.set(tenantId, cfg);
57
+ return cfg;
58
+ }
52
59
  function getGitInfo() {
53
60
  try {
54
61
  const branch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf8", timeout: 3e3 }).trim();
@@ -325,39 +332,104 @@ async function crawlProject(opts, onProgress) {
325
332
  }
326
333
  };
327
334
  const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === "production";
328
- const toStagehandModel = (model, provider) => model.includes("/") ? model : `${provider}/${model}`;
329
- let stagehandModel = toStagehandModel(config.llmModel, "openai");
330
- let stagehandApiKey = "";
335
+ let stagehandModel = config.llmModel.includes("/") ? config.llmModel : `openai/${config.llmModel}`;
336
+ let stagehandModelOpts = {};
337
+ let stagehandConfigured = false;
331
338
  let configuredProvider = "";
332
339
  try {
333
340
  const crawlLLM = await getTaskAgentLLMConfig(opts.tenantId, "crawl");
334
341
  if (crawlLLM) {
335
342
  configuredProvider = crawlLLM.provider;
336
- if (crawlLLM.apiKey && crawlLLM.model) {
337
- if (crawlLLM.provider === "anthropic" || crawlLLM.provider === "openai") {
338
- stagehandModel = toStagehandModel(crawlLLM.model, crawlLLM.provider);
339
- stagehandApiKey = crawlLLM.apiKey;
340
- } else if (crawlLLM.provider === "openrouter") {
341
- const model = crawlLLM.model.includes("/") ? crawlLLM.model : `openai/${crawlLLM.model}`;
342
- stagehandModel = model;
343
- stagehandApiKey = crawlLLM.apiKey;
343
+ const hasKey = crawlLLM.apiKey !== void 0 && crawlLLM.apiKey !== null;
344
+ const apiKey = hasKey ? crawlLLM.apiKey : void 0;
345
+ const baseUrl = crawlLLM.baseUrl || void 0;
346
+ const m = crawlLLM.model;
347
+ if (m && (hasKey || crawlLLM.provider === "ollama" || crawlLLM.provider === "bedrock" || crawlLLM.provider === "vertex")) {
348
+ stagehandConfigured = true;
349
+ switch (crawlLLM.provider) {
350
+ case "anthropic":
351
+ stagehandModel = m.includes("/") ? m : `anthropic/${m}`;
352
+ stagehandModelOpts = apiKey ? { apiKey } : {};
353
+ break;
354
+ case "openai":
355
+ stagehandModel = m.includes("/") ? m : `openai/${m}`;
356
+ stagehandModelOpts = apiKey ? { apiKey } : {};
357
+ break;
358
+ case "gemini":
359
+ stagehandModel = `google/${m}`;
360
+ stagehandModelOpts = apiKey ? { apiKey } : {};
361
+ break;
362
+ case "grok":
363
+ stagehandModel = `xai/${m}`;
364
+ stagehandModelOpts = apiKey ? { apiKey } : {};
365
+ break;
366
+ case "openrouter":
367
+ stagehandModel = `openai/${m}`;
368
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://openrouter.ai/api/v1" };
369
+ break;
370
+ case "ollama":
371
+ stagehandModel = `ollama/${m}`;
372
+ stagehandModelOpts = { baseURL: baseUrl || "http://localhost:11434" };
373
+ break;
374
+ case "nvidia":
375
+ stagehandModel = `openai/${m.startsWith("openai/") ? m.slice(7) : m}`;
376
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, baseURL: baseUrl || "https://integrate.api.nvidia.com/v1" };
377
+ break;
378
+ case "groq":
379
+ stagehandModel = `groq/${m}`;
380
+ stagehandModelOpts = apiKey ? { apiKey } : {};
381
+ break;
382
+ case "mistral":
383
+ stagehandModel = `mistral/${m}`;
384
+ stagehandModelOpts = apiKey ? { apiKey } : {};
385
+ break;
386
+ case "deepseek":
387
+ stagehandModel = `deepseek/${m}`;
388
+ stagehandModelOpts = apiKey ? { apiKey } : {};
389
+ break;
390
+ case "perplexity":
391
+ stagehandModel = `perplexity/${m}`;
392
+ stagehandModelOpts = apiKey ? { apiKey } : {};
393
+ break;
394
+ case "togetherai":
395
+ stagehandModel = `togetherai/${m}`;
396
+ stagehandModelOpts = apiKey ? { apiKey } : {};
397
+ break;
398
+ case "bedrock":
399
+ stagehandModel = `bedrock/${m}`;
400
+ stagehandModelOpts = {};
401
+ break;
402
+ case "vertex":
403
+ stagehandModel = `vertex/${m}`;
404
+ stagehandModelOpts = {};
405
+ break;
406
+ case "azure":
407
+ stagehandModel = `azure/${m}`;
408
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, ...baseUrl ? { baseURL: baseUrl } : {} };
409
+ break;
410
+ default:
411
+ stagehandModel = `openai/${m}`;
412
+ stagehandModelOpts = { ...apiKey ? { apiKey } : {}, ...baseUrl ? { baseURL: baseUrl } : {} };
344
413
  }
345
414
  }
346
415
  }
347
416
  } catch {
348
417
  }
349
- if (!stagehandApiKey) {
418
+ if (!stagehandConfigured) {
350
419
  const envModel = process.env.STAGEHAND_MODEL ?? "";
351
420
  const preferAnthropic = envModel.includes("claude") || !envModel && !process.env.OPENAI_API_KEY;
352
421
  if (preferAnthropic && process.env.ANTHROPIC_API_KEY) {
353
- stagehandApiKey = process.env.ANTHROPIC_API_KEY;
422
+ stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
354
423
  stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
424
+ stagehandConfigured = true;
355
425
  } else if (process.env.OPENAI_API_KEY) {
356
- stagehandApiKey = process.env.OPENAI_API_KEY;
426
+ stagehandModelOpts = { apiKey: process.env.OPENAI_API_KEY };
357
427
  stagehandModel = envModel.includes("/") ? envModel : `openai/${envModel || "gpt-4o"}`;
428
+ stagehandConfigured = true;
358
429
  } else if (process.env.ANTHROPIC_API_KEY) {
359
- stagehandApiKey = process.env.ANTHROPIC_API_KEY;
430
+ stagehandModelOpts = { apiKey: process.env.ANTHROPIC_API_KEY };
360
431
  stagehandModel = envModel.includes("/") ? envModel : `anthropic/${envModel || "claude-sonnet-4-6"}`;
432
+ stagehandConfigured = true;
361
433
  }
362
434
  }
363
435
  const elementLLM = await AppBrain.getTaskLLMConfig(opts.tenantId, "elementExtraction").catch(() => null);
@@ -390,10 +462,9 @@ async function crawlProject(opts, onProgress) {
390
462
  type: "CRAWL_PROFILE",
391
463
  payload: { profile: domainProfile, routeDecision }
392
464
  }).catch((err) => console.warn("[crawler] profile evidence save failed:", err?.message ?? err));
393
- if (!stagehandApiKey) {
394
- const unsupported = configuredProvider && !["anthropic", "openai", "openrouter"].includes(configuredProvider);
465
+ if (!stagehandConfigured) {
395
466
  throw new Error(
396
- unsupported ? `Browser crawling requires Anthropic or OpenAI. Your primary provider "${configuredProvider}" is not supported for crawling. Add a separate Crawl API Key in Workspace \u2192 Settings \u2192 AI Provider.` : "No AI provider configured. Go to Workspace \u2192 Settings \u2192 AI Provider to set up Anthropic or OpenAI for browser crawling."
467
+ configuredProvider ? `No API key configured for provider "${configuredProvider}". Go to Workspace \u2192 Settings \u2192 AI Provider.` : "No AI provider configured. Go to Workspace \u2192 Settings \u2192 AI Provider to set up an AI provider for browser crawling."
397
468
  );
398
469
  }
399
470
  const stealthArgs = [
@@ -529,7 +600,7 @@ async function crawlProject(opts, onProgress) {
529
600
  };
530
601
  const stagehand = new Stagehand({
531
602
  env: "LOCAL",
532
- model: { modelName: stagehandModel, apiKey: stagehandApiKey },
603
+ model: { modelName: stagehandModel, ...stagehandModelOpts },
533
604
  serverCache: true,
534
605
  verbose: 1,
535
606
  logger: stagehandLogger,
@@ -1339,7 +1410,7 @@ async function crawlProject(opts, onProgress) {
1339
1410
  console.warn(`[crawler] connection failed for ${url} \u2014 skipping (chrome-error page)`);
1340
1411
  continue;
1341
1412
  }
1342
- await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1413
+ await page.waitForLoadState("load", { timeout: 8e3 }).catch(() => {
1343
1414
  });
1344
1415
  await waitForStableDOM(page);
1345
1416
  if (!opts.discoveryMode) {
@@ -1798,10 +1869,11 @@ async function crawlProject(opts, onProgress) {
1798
1869
  return "/";
1799
1870
  }
1800
1871
  })()}`);
1872
+ const pageDpr = await page.evaluate(() => window.devicePixelRatio ?? 1).catch(() => 1);
1801
1873
  const [, elements, nextLinks, atElements] = await Promise.all([
1802
1874
  page.screenshot({ path: shotPath, fullPage: true }).then(async () => {
1803
1875
  const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
1804
- return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
1876
+ return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl, pageDpr);
1805
1877
  }).catch((err) => console.error("[crawler] page.screenshot failed:", err)),
1806
1878
  extractElements(stagehand, page, elementLLM ?? void 0).catch((err) => {
1807
1879
  console.error("[crawler] extractElements failed:", err);
@@ -1883,7 +1955,7 @@ async function crawlProject(opts, onProgress) {
1883
1955
  await fs.writeFile(mdPath, scrubbed, "utf8");
1884
1956
  const summaryPromise = (async () => {
1885
1957
  try {
1886
- const llmCfg = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => void 0);
1958
+ const llmCfg = await getCachedCrawlLLM(opts.tenantId);
1887
1959
  const snippet = extracted.markdown.slice(0, 1200);
1888
1960
  const { text, usage } = await askTracked(
1889
1961
  "You are a QA analyst. Given page content, write ONE crisp sentence describing what this screen does or shows. Be specific. No preamble.",
@@ -2020,15 +2092,38 @@ ${snippet}`,
2020
2092
  logger.warn({ screenId: screen.id, err: String(aiErr) }, "[crawler] AI visual compare failed, keeping pHash-only verdict");
2021
2093
  }
2022
2094
  }
2023
- const { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2095
+ let { changeType, recommendation, changes: allChanges, bugReportDraft, aiSummary } = verdict;
2096
+ if (changeType !== "NONE") {
2097
+ try {
2098
+ const project = await AppBrain.getProject(opts.tenantId, opts.projectId);
2099
+ const threshold = project?.visualDiffThreshold ?? null;
2100
+ if (threshold !== null && pHashDistance < threshold) {
2101
+ changeType = "NONE";
2102
+ }
2103
+ } catch {
2104
+ }
2105
+ }
2024
2106
  if (changeType !== "NONE") {
2025
2107
  let newScreenshotCloudUrl;
2108
+ let diffImageCloudUrl;
2026
2109
  try {
2027
2110
  const { getTenantStorageConfig, uploadScreenshotToTenantStorage, buildArtifactKey } = await import("@detiq/app-brain");
2028
2111
  const storageCfg = await getTenantStorageConfig(opts.tenantId).catch(() => null);
2029
2112
  if (storageCfg) {
2030
2113
  const key = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}.png`, storageCfg.prefix);
2031
2114
  newScreenshotCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, shotPath, key);
2115
+ try {
2116
+ const { generatePixelmatchDiff } = await import("./phash.js");
2117
+ const diffBuffer = await generatePixelmatchDiff(baseline.storagePath, shotPath, baseline.ignoredRegions ?? []);
2118
+ const diffTmpPath = `${shotPath}.diff.png`;
2119
+ await fs.writeFile(diffTmpPath, diffBuffer);
2120
+ const diffKey = buildArtifactKey(opts.tenantId, opts.projectId, "visual-diffs", `${screen.id}-v${screen.version}-diff.png`, storageCfg.prefix);
2121
+ diffImageCloudUrl = await uploadScreenshotToTenantStorage(storageCfg, diffTmpPath, diffKey);
2122
+ fs.unlink(diffTmpPath).catch(() => {
2123
+ });
2124
+ } catch (diffErr) {
2125
+ logger.warn({ screenId: screen.id, err: String(diffErr) }, "[crawler] pixelmatch diff generation failed, skipping");
2126
+ }
2032
2127
  }
2033
2128
  } catch {
2034
2129
  }
@@ -2036,6 +2131,7 @@ ${snippet}`,
2036
2131
  baselineId: baseline.id,
2037
2132
  newScreenshotPath: shotPath,
2038
2133
  newScreenshotCloudUrl,
2134
+ diffImageCloudUrl,
2039
2135
  diffScore: pHashDistance,
2040
2136
  changeType,
2041
2137
  changes: allChanges,
@@ -2056,17 +2152,20 @@ ${snippet}`,
2056
2152
  })();
2057
2153
  }
2058
2154
  const linkMappings = await extractLinkMappings(page, origin);
2059
- for (const el of elements) {
2060
- const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
2061
- const label = el.meaning;
2062
- const role = el.role ?? "unknown";
2063
- const selectorHint = atByRole.get(role) ?? role;
2064
- const selectors = [
2065
- { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
2066
- { type: "aria-label", value: label, confidence: 0.85 },
2067
- { type: "tag-hint", value: selectorHint, confidence: 0.6 }
2068
- ];
2069
- AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
2155
+ for (let _ei = 0; _ei < elements.length; _ei += 4) {
2156
+ const _batch = elements.slice(_ei, _ei + 4);
2157
+ await Promise.all(_batch.map(async (el) => {
2158
+ const saved = await AppBrain.saveElement(opts.tenantId, opts.projectId, screen.id, el.meaning, el.role, el.expectedData, el.notes, void 0, true, el.boundingRect, el.ariaState, el.parentLandmark);
2159
+ const label = el.meaning;
2160
+ const role = el.role ?? "unknown";
2161
+ const selectorHint = atByRole.get(role) ?? role;
2162
+ const selectors = [
2163
+ { type: "role-label", value: `${role}:${label}`, confidence: 0.9 },
2164
+ { type: "aria-label", value: label, confidence: 0.85 },
2165
+ { type: "tag-hint", value: selectorHint, confidence: 0.6 }
2166
+ ];
2167
+ AppBrain.saveElementFingerprint(opts.tenantId, opts.projectId, saved.id, selectors).catch(console.warn);
2168
+ }));
2070
2169
  }
2071
2170
  for (const lm of linkMappings) {
2072
2171
  const safety = assessActionSafety(lm.label, domainProfile);
@@ -2366,7 +2465,7 @@ ${snippet}`,
2366
2465
  }
2367
2466
  try {
2368
2467
  const { runUrlPatternCompletion } = await import("./url-pattern-completer.js");
2369
- const llmCfgForPatterns = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => null);
2468
+ const llmCfgForPatterns = await getCachedCrawlLLM(opts.tenantId);
2370
2469
  const urlPatternCandidates = await runUrlPatternCompletion(
2371
2470
  Array.from(visited.keys()),
2372
2471
  opts.appUrl,
@@ -2427,7 +2526,7 @@ ${snippet}`,
2427
2526
  if (visited.size >= 3) {
2428
2527
  try {
2429
2528
  await onProgress?.(80, "Running gap analysis\u2026");
2430
- const llmCfgForGap = await getTaskAgentLLMConfig(opts.tenantId, "crawl").catch(() => null);
2529
+ const llmCfgForGap = await getCachedCrawlLLM(opts.tenantId);
2431
2530
  const discoveredForGap = Array.from(visited.entries()).map(([url, screenId]) => {
2432
2531
  const d = discovered.find((dd) => dd.screenId === screenId);
2433
2532
  return { url, name: d ? new URL(url).pathname.split("/").filter(Boolean).pop() ?? "screen" : void 0, elements: d?.elements };
@@ -2876,7 +2975,41 @@ ${snippet}`,
2876
2975
  const ELEMENT_NOISE = /^(cookie|consent|privacy|accept|decline|dismiss|close|copy|©|v\d|switch to (dark|light)|dark mode|light mode|toggle (dark|light|theme)|theme (toggle|switch)|share|tweet|follow|subscribe to newsletter|skip to|back to top|\s*$)/i;
2877
2976
  async function extractElements(stagehand, page, llmCfg) {
2878
2977
  const domElements = await domFallbackElements(page);
2879
- if (domElements.length >= 2) return domElements;
2978
+ const iframeElements = [];
2979
+ try {
2980
+ const iframes = page.frames ? page.frames() : [];
2981
+ for (const frame of iframes) {
2982
+ if (frame === page.mainFrame?.()) continue;
2983
+ const iframeUrl = await frame.url().catch(() => "");
2984
+ if (!iframeUrl || iframeUrl === "about:blank" || iframeUrl.startsWith("javascript:")) continue;
2985
+ const frameEls = await domFallbackElements(frame).catch(() => []);
2986
+ for (const el of frameEls.slice(0, 10)) {
2987
+ iframeElements.push({ ...el, notes: el.notes ? `${el.notes} [in iframe]` : "[in iframe]" });
2988
+ }
2989
+ }
2990
+ } catch {
2991
+ }
2992
+ let dynamicElements = [];
2993
+ if (domElements.length > 0) {
2994
+ try {
2995
+ dynamicElements = await page.evaluate(() => {
2996
+ const out = [];
2997
+ const seen = /* @__PURE__ */ new Set();
2998
+ const triggers = document.querySelectorAll('[role="combobox"],[role="listbox"] [role="option"],[aria-haspopup]');
2999
+ triggers.forEach((el) => {
3000
+ const label = (el.getAttribute("aria-label") || el.textContent?.trim() || "").slice(0, 80);
3001
+ if (!label || seen.has(label)) return;
3002
+ const role = el.getAttribute("role") ?? el.tagName.toLowerCase();
3003
+ seen.add(label);
3004
+ out.push({ meaning: label, role, notes: "[dynamic/revealed]" });
3005
+ });
3006
+ return out.slice(0, 10);
3007
+ }).catch(() => []);
3008
+ } catch {
3009
+ dynamicElements = [];
3010
+ }
3011
+ }
3012
+ if (domElements.length >= 2) return [...domElements, ...iframeElements, ...dynamicElements];
2880
3013
  const contentElements = await contentFallbackElements(page);
2881
3014
  if (contentElements.length > 0) return [...domElements, ...contentElements];
2882
3015
  try {
@@ -3540,8 +3673,6 @@ async function waitForScreenshotReady(page) {
3540
3673
  }).catch(() => {
3541
3674
  }));
3542
3675
  await withHardCap(waitForStableDOM(page, 2e3, 500));
3543
- await withHardCap(page.waitForTimeout(250).catch(() => {
3544
- }));
3545
3676
  } catch {
3546
3677
  }
3547
3678
  }