@detiq/crawler 1.1.6 → 1.1.8

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.js CHANGED
@@ -10,7 +10,7 @@ import { z } from "zod";
10
10
  import fs from "node:fs/promises";
11
11
  import path from "node:path";
12
12
  import { AppBrain, recordLLMCall } from "@detiq/app-brain";
13
- import { aiVisualCompare, askTracked, estimateCost, getTaskAgentLLMConfig } from "@detiq/agents";
13
+ import { aiVisualCompare, askTracked, deterministicSeed, estimateCost, getTaskAgentLLMConfig } from "@detiq/agents";
14
14
  import { config } from "@detiq/core";
15
15
  import { pHashVerdict, mergeAiVisualVerdict } from "./visual-diff-verdict.js";
16
16
  import { skyvernFallback, skyvernNavigate } from "./skyvern.js";
@@ -795,6 +795,10 @@ async function crawlProject(opts, onProgress) {
795
795
  await page.waitForLoadState("networkidle", { timeout: 1e4 }).catch(() => {
796
796
  });
797
797
  await waitForStableDOM(page);
798
+ const homeFingerprintTitle = await page.title().catch(() => "");
799
+ const homeFingerprintElemCount = await page.evaluate(
800
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
801
+ ).catch(() => -1);
798
802
  if (!opts.discoveryMode) {
799
803
  try {
800
804
  await runAuthDiscoveryPhase(page, opts, new URL(opts.appUrl).origin, onProgress);
@@ -1275,6 +1279,93 @@ async function crawlProject(opts, onProgress) {
1275
1279
  let totalAttemptsThisSegment = 0;
1276
1280
  const maxAttempts = Math.max(segmentSize * 4, 200);
1277
1281
  let pendingSessionRestore = null;
1282
+ const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? "5", 10));
1283
+ async function runLiteWorker(workerPage) {
1284
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1285
+ if (opts.abortSignal?.aborted) break;
1286
+ if (++totalAttemptsThisSegment > maxAttempts) break;
1287
+ const url = queue.shift();
1288
+ if (!url) break;
1289
+ const canonUrl = canonicalize(url);
1290
+ if (visited.has(canonUrl)) continue;
1291
+ try {
1292
+ const { pathname } = new URL(url);
1293
+ if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1294
+ } catch {
1295
+ continue;
1296
+ }
1297
+ visited.set(canonUrl, "_pending_");
1298
+ try {
1299
+ const navErr = await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1300
+ if (navErr && /timeout/i.test(String(navErr?.message ?? navErr))) {
1301
+ await workerPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch(() => {
1302
+ });
1303
+ }
1304
+ await workerPage.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
1305
+ });
1306
+ if (homeFingerprintElemCount >= 0 && url !== startUrl) {
1307
+ const wTitle = await workerPage.title().catch(() => "");
1308
+ if (wTitle === homeFingerprintTitle && wTitle !== "") {
1309
+ const wElemCount = await workerPage.evaluate(
1310
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
1311
+ ).catch(() => -1);
1312
+ if (wElemCount >= 0 && wElemCount === homeFingerprintElemCount) {
1313
+ console.log(`[lite-worker] skip ${url} \u2014 soft-404 (homepage fingerprint match)`);
1314
+ visited.set(canonUrl, "_soft404_");
1315
+ continue;
1316
+ }
1317
+ }
1318
+ }
1319
+ const pathParts = new URL(url).pathname.split("/").filter(Boolean);
1320
+ const name = pathParts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, " ")).join(" \u2014 ") || "Home";
1321
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1322
+ visited.set(canonUrl, screen.id);
1323
+ const shotPath = path.join(opts.screenshotDir ?? "/tmp", `${screen.id}-desktop-v${screen.version}.png`);
1324
+ const [elements] = await Promise.all([
1325
+ domFallbackElements(workerPage).catch(() => []),
1326
+ workerPage.screenshot({ path: shotPath, fullPage: true }).then(async () => {
1327
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
1328
+ return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, "DESKTOP", cloudUrl);
1329
+ }).catch(() => {
1330
+ })
1331
+ ]);
1332
+ const nextLinks = await workerPage.evaluate((originStr) => {
1333
+ return Array.from(document.querySelectorAll("a[href]")).map((a) => {
1334
+ try {
1335
+ return new URL(a.getAttribute("href"), window.location.href).href;
1336
+ } catch {
1337
+ return "";
1338
+ }
1339
+ }).filter((h) => h && new URL(h).origin === originStr && !h.includes("#")).slice(0, 200);
1340
+ }, origin).catch(() => []);
1341
+ for (const link of nextLinks) {
1342
+ const cLink = canonicalize(link);
1343
+ if (!visited.has(cLink)) {
1344
+ queue.push(link);
1345
+ edges.push({ fromUrl: url, toUrl: link });
1346
+ }
1347
+ }
1348
+ discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
1349
+ } catch (err) {
1350
+ logger.warn({ url, err: err?.message ?? String(err) }, "[lite-worker] URL processing failed");
1351
+ discovered.push({ url, error: err?.message ?? String(err) });
1352
+ }
1353
+ }
1354
+ }
1355
+ const liteWorkerPromises = [];
1356
+ if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
1357
+ const pwCtx = playwrightBrowser.contexts()[0];
1358
+ for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1359
+ const wp = await pwCtx.newPage();
1360
+ await wp.addInitScript(() => {
1361
+ Object.defineProperty(navigator, "webdriver", { get: () => void 0 });
1362
+ }).catch(() => {
1363
+ });
1364
+ wp.setDefaultTimeout(45e3);
1365
+ liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {
1366
+ })));
1367
+ }
1368
+ }
1278
1369
  while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1279
1370
  if (pendingSessionRestore) {
1280
1371
  await pendingSessionRestore();
@@ -1390,9 +1481,16 @@ async function crawlProject(opts, onProgress) {
1390
1481
  logger.info({ url }, "[crawler] temporarily cleared session for public auth route");
1391
1482
  }
1392
1483
  }
1393
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
1394
- logger.warn({ url, err: navErr?.message ?? String(navErr) }, "[crawler] navigation failed \u2014 skipping");
1395
- });
1484
+ const navErr15 = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 }).then(() => null, (e) => e);
1485
+ if (navErr15) {
1486
+ if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1487
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 }).catch((navErr) => {
1488
+ logger.warn({ url, err: navErr?.message ?? String(navErr) }, "[crawler] navigation failed \u2014 skipping");
1489
+ });
1490
+ } else {
1491
+ logger.warn({ url, err: navErr15?.message ?? String(navErr15) }, "[crawler] navigation failed \u2014 skipping");
1492
+ }
1493
+ }
1396
1494
  rawPage.off("response", onRedirectResponse);
1397
1495
  if (redirectHops.length > 0) {
1398
1496
  redirectRegistry.register({
@@ -1661,6 +1759,16 @@ async function crawlProject(opts, onProgress) {
1661
1759
  seenDomHashes.add(preDomHash);
1662
1760
  continue;
1663
1761
  }
1762
+ if (homeFingerprintElemCount >= 0 && url !== startUrl && pageTitle === homeFingerprintTitle && pageTitle !== "") {
1763
+ const pElemCount = await page.evaluate(
1764
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
1765
+ ).catch(() => -1);
1766
+ if (pElemCount >= 0 && pElemCount === homeFingerprintElemCount) {
1767
+ console.log(`[crawler] skip ${url} \u2014 soft-404 (homepage fingerprint match, title="${pageTitle}" elems=${pElemCount})`);
1768
+ seenDomHashes.add(preDomHash);
1769
+ continue;
1770
+ }
1771
+ }
1664
1772
  const rawPerf = await page.evaluate(() => {
1665
1773
  const nav = performance.getEntriesByType("navigation")[0];
1666
1774
  const paints = performance.getEntriesByType("paint");
@@ -1897,7 +2005,8 @@ async function crawlProject(opts, onProgress) {
1897
2005
  }),
1898
2006
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
1899
2007
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
1900
- discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0).then((freshLinks) => {
2008
+ // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2009
+ discoverLinks(stagehand, origin, page, linkLLM ?? void 0, aiSkipCount > 0 || preDomCount < 5).then((freshLinks) => {
1901
2010
  if (aiSkipCount > 0) aiSkipCount--;
1902
2011
  aiErrorStreak = 0;
1903
2012
  const merged = (cachedLinks ? Array.from(/* @__PURE__ */ new Set([...cachedLinks, ...freshLinks])) : freshLinks).filter((href) => isUsefulNavigationUrl(href, origin));
@@ -1987,6 +2096,7 @@ async function crawlProject(opts, onProgress) {
1987
2096
  const llmCfg = await getCachedCrawlLLM(opts.tenantId);
1988
2097
  const snippet = extracted.markdown.slice(0, 1200);
1989
2098
  if (snippet.trim().length < 80) return void 0;
2099
+ const summarySeed = deterministicSeed(`${opts.tenantId}:${opts.projectId ?? ""}:page-summary:${url}`);
1990
2100
  const { text, usage } = await askTracked(
1991
2101
  "You are a QA analyst. Output ONLY a single complete sentence (no preamble, no quotes) describing what this screen does or shows. Be specific. Stop after the first sentence.",
1992
2102
  `Screen: "${name}"
@@ -1995,7 +2105,9 @@ URL: ${url}
1995
2105
  Content:
1996
2106
  ${snippet}`,
1997
2107
  llmCfg,
1998
- 200
2108
+ 200,
2109
+ void 0,
2110
+ { temperature: 0, seed: summarySeed }
1999
2111
  );
2000
2112
  if (usage) {
2001
2113
  recordLLMCall({
@@ -2394,6 +2506,7 @@ ${snippet}`,
2394
2506
  discovered.push({ url, error: err?.message ?? String(err) });
2395
2507
  }
2396
2508
  }
2509
+ if (liteWorkerPromises.length > 0) await Promise.all(liteWorkerPromises);
2397
2510
  if (opts.discoveryMode) {
2398
2511
  return {
2399
2512
  discoveredUrls: Array.from(visited.values()),
@@ -3320,11 +3433,20 @@ async function contentFallbackElements(page) {
3320
3433
  return page.evaluate(() => {
3321
3434
  const out = [];
3322
3435
  const seen = /* @__PURE__ */ new Set();
3323
- const addUniq = (item) => {
3436
+ const captureRect = (el) => {
3437
+ try {
3438
+ const r = el.getBoundingClientRect();
3439
+ if (r.width === 0 && r.height === 0) return void 0;
3440
+ return { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, right: r.right, bottom: r.bottom, left: r.left };
3441
+ } catch {
3442
+ return void 0;
3443
+ }
3444
+ };
3445
+ const addUniq = (item, el) => {
3324
3446
  const key = `${item.role}:${item.meaning.toLowerCase().slice(0, 60)}`;
3325
3447
  if (!seen.has(key) && item.meaning.trim().length > 1) {
3326
3448
  seen.add(key);
3327
- out.push(item);
3449
+ out.push(el ? { ...item, boundingRect: captureRect(el) } : item);
3328
3450
  }
3329
3451
  };
3330
3452
  const isVisible = (el) => {
@@ -3342,7 +3464,7 @@ async function contentFallbackElements(page) {
3342
3464
  document.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((h) => {
3343
3465
  if (!isVisible(h)) return;
3344
3466
  const text = h.textContent?.replace(/\s+/g, " ").trim().slice(0, 100);
3345
- if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) });
3467
+ if (text) addUniq({ meaning: text, role: h.tagName.toLowerCase(), notes: JSON.stringify({ level: h.tagName }) }, h);
3346
3468
  });
3347
3469
  document.querySelectorAll('details, [data-state="closed"], [data-state="open"]').forEach((el) => {
3348
3470
  if (!isVisible(el)) return;
@@ -3373,7 +3495,7 @@ async function contentFallbackElements(page) {
3373
3495
  document.querySelectorAll('[class*="card" i]:not(nav):not(header):not(footer), [class*="chip" i], [class*="badge" i], [class*="tag" i], [class*="pill" i]').forEach((el) => {
3374
3496
  if (!isVisible(el)) return;
3375
3497
  const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3376
- if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: "content-card" });
3498
+ if (text && text.length > 2 && text.length < 200) addUniq({ meaning: text, role: "content-card" }, el);
3377
3499
  });
3378
3500
  const mainContent = document.querySelector('main, [role="main"], article') ?? document.body;
3379
3501
  let listItemCount = 0;
@@ -3382,14 +3504,14 @@ async function contentFallbackElements(page) {
3382
3504
  if (!isVisible(li)) return;
3383
3505
  const text = li.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3384
3506
  if (text && text.length > 3 && text.length < 200) {
3385
- addUniq({ meaning: text, role: "list-item" });
3507
+ addUniq({ meaning: text, role: "list-item" }, li);
3386
3508
  listItemCount++;
3387
3509
  }
3388
3510
  });
3389
3511
  mainContent.querySelectorAll('section > p:first-of-type, article > p:first-of-type, [class*="section" i] > p:first-of-type').forEach((p) => {
3390
3512
  if (!isVisible(p)) return;
3391
3513
  const text = p.textContent?.replace(/\s+/g, " ").trim().slice(0, 250);
3392
- if (text && text.length > 10) addUniq({ meaning: text, role: "section-paragraph" });
3514
+ if (text && text.length > 10) addUniq({ meaning: text, role: "section-paragraph" }, p);
3393
3515
  });
3394
3516
  document.querySelectorAll('[class*="cta" i] p, [class*="call-to-action" i] p, section:not(form) > p').forEach((p) => {
3395
3517
  if (!isVisible(p)) return;
@@ -3774,7 +3896,7 @@ async function contentFallbackElements(page) {
3774
3896
  document.querySelectorAll('[role="alert"], [role="status"], [role="banner"]:not(header), .alert, .notice, .notification, .warning-banner, .error-banner, .success-banner, [class*="alert-" i]:not(button), [class*="toast" i]:not(button), [class*="snackbar" i]').forEach((el) => {
3775
3897
  if (!isVisible(el)) return;
3776
3898
  const text = el.textContent?.replace(/\s+/g, " ").trim().slice(0, 150);
3777
- if (text && text.length > 5) addUniq({ meaning: text, role: "alert-banner", notes: JSON.stringify({ ariaRole: el.getAttribute("role") }) });
3899
+ if (text && text.length > 5) addUniq({ meaning: text, role: "alert-banner", notes: JSON.stringify({ ariaRole: el.getAttribute("role") }) }, el);
3778
3900
  });
3779
3901
  let pricingCount = 0;
3780
3902
  document.querySelectorAll('[class*="pricing" i], [class*="plan" i]:not(main):not(article), [class*="tier" i]:not(nav), [class*="package" i]:not(script)').forEach((el) => {
@@ -4334,7 +4456,7 @@ async function hasMeaningfulAuthOrPublicState(page, requestedUrl) {
4334
4456
  return false;
4335
4457
  }
4336
4458
  })();
4337
- const domLooksRelevant = await page.evaluate(() => {
4459
+ const domState = await page.evaluate(() => {
4338
4460
  const text = (document.body?.innerText ?? "").toLowerCase();
4339
4461
  const inputs = Array.from(document.querySelectorAll("input"));
4340
4462
  const hasPassword = inputs.some((i) => i.type === "password");
@@ -4344,8 +4466,11 @@ async function hasMeaningfulAuthOrPublicState(page, requestedUrl) {
4344
4466
  });
4345
4467
  const hasAuthCopy = /(sign in|log in|login|sign up|create account|register|forgot password|reset password|verify email|one-time|otp|continue with email)/i.test(text);
4346
4468
  const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
4347
- return hasPassword || hasIdentityInput || hasAuthCopy || hasPublicNav;
4348
- }).catch(() => false);
4469
+ const isSoft404 = !hasPassword && !hasIdentityInput && /(page not found|404|this page (could not|doesn'?t) (be found|exist)|we couldn'?t find|no longer exists|been moved|doesn'?t exist)/i.test(text);
4470
+ return { hasPassword, hasIdentityInput, hasAuthCopy, hasPublicNav, isSoft404 };
4471
+ }).catch(() => ({ hasPassword: false, hasIdentityInput: false, hasAuthCopy: false, hasPublicNav: false, isSoft404: false }));
4472
+ if (domState.isSoft404) return false;
4473
+ const domLooksRelevant = domState.hasPassword || domState.hasIdentityInput || domState.hasAuthCopy || domState.hasPublicNav;
4349
4474
  return pathLooksRelevant || domLooksRelevant;
4350
4475
  }
4351
4476
  async function discoverAuthCandidates(page, origin) {
@@ -4490,6 +4615,18 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
4490
4615
  } catch {
4491
4616
  }
4492
4617
  }
4618
+ let homeTitle = "";
4619
+ let homeElemCount = -1;
4620
+ try {
4621
+ await page.goto(opts.appUrl, { waitUntil: "domcontentloaded", timeout: 15e3 }).catch(() => null);
4622
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
4623
+ });
4624
+ homeTitle = await page.title().catch(() => "");
4625
+ homeElemCount = await page.evaluate(
4626
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
4627
+ ).catch(() => -1);
4628
+ } catch {
4629
+ }
4493
4630
  let captured = 0;
4494
4631
  let ordinal = 0;
4495
4632
  await onProgress?.(10, "Discovering public and auth flows");
@@ -4503,6 +4640,13 @@ async function runAuthDiscoveryPhase(page, opts, origin, onProgress) {
4503
4640
  await waitForStableDOM(page);
4504
4641
  await acceptCookieConsent(page, { waitMs: 400 }).catch(() => {
4505
4642
  });
4643
+ if (current.source === "seed_path" && homeElemCount >= 0) {
4644
+ const pageTitle = await page.title().catch(() => "");
4645
+ const elemCount = await page.evaluate(
4646
+ () => document.querySelectorAll('button,a,input,select,textarea,[role="button"]').length
4647
+ ).catch(() => -1);
4648
+ if (pageTitle === homeTitle && elemCount === homeElemCount) continue;
4649
+ }
4506
4650
  const finalUrl = page.url?.() ?? current.url;
4507
4651
  const captureKey = normalizeAuthUrl(finalUrl);
4508
4652
  if (!capturedKeys.has(captureKey)) {