@detiq/crawler 1.1.1 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "1.1.1",
3
+ "version": "1.1.5",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/crawler.ts CHANGED
@@ -1638,6 +1638,92 @@ export async function crawlProject(opts: {
1638
1638
 
1639
1639
  let pendingSessionRestore: (() => Promise<void>) | null = null;
1640
1640
 
1641
+ // F10: Parallel lite-worker browser tabs — pull from shared queue while main loop runs.
1642
+ // Each worker uses a fresh Playwright page from the same browser context so it shares
1643
+ // cookies/session with the main page. DOM-only extraction (no Stagehand AI).
1644
+ // Default 5 = 3-5× crawl throughput on a single browser context. Set CRAWL_PAGE_PARALLELISM=1 to disable.
1645
+ const CRAWL_PAGE_PARALLELISM = Math.max(1, parseInt(process.env.CRAWL_PAGE_PARALLELISM ?? '5', 10));
1646
+
1647
+ async function runLiteWorker(workerPage: any): Promise<void> {
1648
+ while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1649
+ if (opts.abortSignal?.aborted) break;
1650
+ if (++totalAttemptsThisSegment > maxAttempts) break;
1651
+
1652
+ const url = queue.shift();
1653
+ if (!url) break;
1654
+
1655
+ const canonUrl = canonicalize(url);
1656
+ if (visited.has(canonUrl)) continue;
1657
+
1658
+ try {
1659
+ const { pathname } = new URL(url);
1660
+ if (SKIP_PATH.test(pathname) || isGeneratedDynamicPath(pathname)) continue;
1661
+ } catch { continue; }
1662
+
1663
+ // Claim URL before first await — prevents duplicate processing across parallel workers
1664
+ visited.set(canonUrl, '_pending_');
1665
+
1666
+ try {
1667
+ const navErr = await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null as null, (e: any) => e);
1668
+ if (navErr && /timeout/i.test(String((navErr as any)?.message ?? navErr))) {
1669
+ await workerPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
1670
+ }
1671
+
1672
+ const pathParts = new URL(url).pathname.split('/').filter(Boolean);
1673
+ const name = pathParts.map((p: string) => p.charAt(0).toUpperCase() + p.slice(1).replace(/[-_]/g, ' ')).join(' — ') || 'Home';
1674
+
1675
+ const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, url, opts.jobId, discoveryMethodFor(url));
1676
+ visited.set(canonUrl, screen.id);
1677
+
1678
+ const shotPath = path.join(opts.screenshotDir ?? '/tmp', `${screen.id}-desktop-v${screen.version}.png`);
1679
+
1680
+ const [elements] = await Promise.all([
1681
+ domFallbackElements(workerPage).catch(() => [] as Awaited<ReturnType<typeof domFallbackElements>>),
1682
+ workerPage.screenshot({ path: shotPath, fullPage: true })
1683
+ .then(async () => {
1684
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
1685
+ return (AppBrain.saveScreenshot as any)(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
1686
+ })
1687
+ .catch(() => {}),
1688
+ ]);
1689
+
1690
+ const nextLinks: string[] = await (workerPage.evaluate as any)((originStr: string) => {
1691
+ return Array.from(document.querySelectorAll('a[href]'))
1692
+ .map((a: any) => {
1693
+ try { return new URL(a.getAttribute('href')!, window.location.href).href; }
1694
+ catch { return ''; }
1695
+ })
1696
+ .filter((h: string) => h && new URL(h).origin === originStr && !h.includes('#'))
1697
+ .slice(0, 200);
1698
+ }, origin).catch(() => []);
1699
+
1700
+ for (const link of nextLinks) {
1701
+ const cLink = canonicalize(link);
1702
+ if (!visited.has(cLink)) {
1703
+ queue.push(link);
1704
+ edges.push({ fromUrl: url, toUrl: link });
1705
+ }
1706
+ }
1707
+
1708
+ discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
1709
+ } catch (err: any) {
1710
+ logger.warn({ url, err: err?.message ?? String(err) }, '[lite-worker] URL processing failed');
1711
+ discovered.push({ url, error: err?.message ?? String(err) });
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ const liteWorkerPromises: Promise<void>[] = [];
1717
+ if (CRAWL_PAGE_PARALLELISM > 1 && playwrightBrowser) {
1718
+ const pwCtx = playwrightBrowser.contexts()[0];
1719
+ for (let i = 1; i < CRAWL_PAGE_PARALLELISM; i++) {
1720
+ const wp = await pwCtx.newPage();
1721
+ await wp.addInitScript(() => { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); }).catch(() => {});
1722
+ wp.setDefaultTimeout(45_000);
1723
+ liteWorkerPromises.push(runLiteWorker(wp).finally(() => wp.close().catch(() => {})));
1724
+ }
1725
+ }
1726
+
1641
1727
  while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1642
1728
  if (pendingSessionRestore) {
1643
1729
  await pendingSessionRestore();
@@ -1767,9 +1853,17 @@ export async function crawlProject(opts: {
1767
1853
  }
1768
1854
  }
1769
1855
 
1770
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
1771
- logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
1772
- });
1856
+ // Try fast 15 s first; only escalate to 30 s on timeout so happy-path pages don't wait
1857
+ const navErr15 = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 }).then(() => null, (e: any) => e);
1858
+ if (navErr15) {
1859
+ if (/timeout/i.test(String(navErr15?.message ?? navErr15))) {
1860
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
1861
+ logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
1862
+ });
1863
+ } else {
1864
+ logger.warn({ url, err: navErr15?.message ?? String(navErr15) }, '[crawler] navigation failed — skipping');
1865
+ }
1866
+ }
1773
1867
  rawPage.off('response', onRedirectResponse);
1774
1868
  if (redirectHops.length > 0) {
1775
1869
  redirectRegistry.register({
@@ -2350,7 +2444,8 @@ export async function crawlProject(opts: {
2350
2444
  }),
2351
2445
  // Always run AI link discovery (even when cache exists) — cache is merged in, not used as a replacement.
2352
2446
  // Skipping AI when cache exists was a bug: stale cache from broken crawls permanently blocked discovery.
2353
- discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0)
2447
+ // Skip AI on simple pages (< 5 interactive elements) — DOM-only is sufficient and saves ~12s per page.
2448
+ discoverLinks(stagehand, origin, page, linkLLM ?? undefined, aiSkipCount > 0 || preDomCount < 5)
2354
2449
  .then((freshLinks) => {
2355
2450
  if (aiSkipCount > 0) aiSkipCount--;
2356
2451
  aiErrorStreak = 0;
@@ -2909,6 +3004,9 @@ export async function crawlProject(opts: {
2909
3004
  }
2910
3005
  }
2911
3006
 
3007
+ // Wait for any lite workers still processing URLs from the shared queue
3008
+ if (liteWorkerPromises.length > 0) await Promise.all(liteWorkerPromises);
3009
+
2912
3010
  // Discovery mode: return collected URLs and skip all post-crawl analysis phases.
2913
3011
  // Use visited.values() (original URLs) not .keys() (canonical forms with /{id} placeholders).
2914
3012
  if (opts.discoveryMode) {
@@ -5052,7 +5150,7 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
5052
5150
  }
5053
5151
  })();
5054
5152
 
5055
- const domLooksRelevant = await page.evaluate(() => {
5153
+ const domState = await page.evaluate(() => {
5056
5154
  const text = (document.body?.innerText ?? '').toLowerCase();
5057
5155
  const inputs = Array.from(document.querySelectorAll('input')) as HTMLInputElement[];
5058
5156
  const hasPassword = inputs.some((i) => i.type === 'password');
@@ -5062,9 +5160,16 @@ async function hasMeaningfulAuthOrPublicState(page: any, requestedUrl: string):
5062
5160
  });
5063
5161
  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);
5064
5162
  const hasPublicNav = /(pricing|contact|features|documentation|docs|about|privacy|terms)/i.test(text);
5065
- return hasPassword || hasIdentityInput || hasAuthCopy || hasPublicNav;
5066
- }).catch(() => false);
5163
+ // Soft-404: page says "not found" / "404" with no real auth form
5164
+ const isSoft404 = !hasPassword && !hasIdentityInput &&
5165
+ /(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);
5166
+ return { hasPassword, hasIdentityInput, hasAuthCopy, hasPublicNav, isSoft404 };
5167
+ }).catch(() => ({ hasPassword: false, hasIdentityInput: false, hasAuthCopy: false, hasPublicNav: false, isSoft404: false }));
5168
+
5169
+ // Soft-404 with no auth form — path match alone is not enough
5170
+ if (domState.isSoft404) return false;
5067
5171
 
5172
+ const domLooksRelevant = domState.hasPassword || domState.hasIdentityInput || domState.hasAuthCopy || domState.hasPublicNav;
5068
5173
  return pathLooksRelevant || domLooksRelevant;
5069
5174
  }
5070
5175
 
package/src/job-queue.ts CHANGED
@@ -191,7 +191,7 @@ const runningControllers = new Map<string, AbortController>();
191
191
  const WORKER_ID = `${os.hostname()}:${process.pid}`;
192
192
  const STALE_JOB_TIMEOUT_MINUTES = Number(process.env.JOB_STALE_TIMEOUT_MINUTES) || 30;
193
193
  const WORKER_POLL_INTERVAL_MS = Number(process.env.JOB_WORKER_POLL_INTERVAL_MS) || 15_000;
194
- const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 3;
194
+ const WORKER_CONCURRENCY = Number(process.env.JOB_WORKER_CONCURRENCY) || 8;
195
195
  const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS) || 15_000;
196
196
  // How many headless browsers a single RUN_TEST_SUITE job launches at once —
197
197
  // distinct from WORKER_CONCURRENCY (how many *jobs* the poll loop claims at
@@ -199,7 +199,7 @@ const JOB_HEARTBEAT_INTERVAL_MS = Number(process.env.JOB_HEARTBEAT_INTERVAL_MS)
199
199
  // primary scale-out path for running more tests at once is still horizontal
200
200
  // (more worker containers / SQS), not raising this past what one container
201
201
  // can safely hold — see startJobWorkerLoop's doc comment.
202
- const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 3;
202
+ const TEST_EXEC_CONCURRENCY = Number(process.env.TEST_EXEC_CONCURRENCY) || 5;
203
203
 
204
204
  type ClaimedJob = { id: string; tenantId: string; projectId: string; type: string; config: any };
205
205
 
@@ -1689,6 +1689,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1689
1689
  const maxDiscoverUrls: number = config.maxDiscoverUrls ?? 2000;
1690
1690
  const proxyUrl: string | undefined = config.proxyUrl ?? crawlCfg.proxyUrl;
1691
1691
  const screenshotDir = os.tmpdir();
1692
+ const discoverStorageConfig = await getTenantStorageConfig(tenantId).catch(() => null);
1692
1693
 
1693
1694
  await onProgress(3, 'Seeding from sitemap/robots.txt…');
1694
1695
 
@@ -1714,6 +1715,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1714
1715
  maxScreens: maxDiscoverUrls,
1715
1716
  proxyUrl,
1716
1717
  useFirefox: crawlCfg.useFirefox,
1718
+ storageConfig: discoverStorageConfig,
1717
1719
  abortSignal: controller.signal,
1718
1720
  jobId,
1719
1721
  onUrlsDiscovered: async (urls: string[]) => {
@@ -1794,6 +1796,7 @@ async function processDiscoverJob(jobId: string, config: any, opts?: ProcessJobO
1794
1796
  proxyUrl,
1795
1797
  chromeProfilePath: crawlCfg.chromeProfilePath,
1796
1798
  useFirefox: crawlCfg.useFirefox,
1799
+ storageConfig: discoverStorageConfig,
1797
1800
  captchaSolverApiKey: crawlCfg.captchaSolverApiKey,
1798
1801
  captchaSolverProvider: crawlCfg.captchaSolverProvider,
1799
1802
  mailslurpApiKey: crawlCfg.mailslurpApiKey,
package/src/sitemap.ts CHANGED
@@ -111,9 +111,11 @@ export async function fetchSitemapUrls(sitemapUrl: string, origin: string, depth
111
111
  const { pageUrls, subSitemapUrls } = extractUrlsFromParsedSitemap(parsed, origin);
112
112
  const collected = [...pageUrls];
113
113
 
114
- for (const subUrl of subSitemapUrls) {
115
- if (collected.length >= MAX_URLS) break;
116
- collected.push(...(await fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher)));
114
+ if (subSitemapUrls.length > 0 && collected.length < MAX_URLS) {
115
+ const subResults = await Promise.all(
116
+ subSitemapUrls.slice(0, 10).map(subUrl => fetchSitemapUrls(subUrl, origin, depth + 1, dispatcher))
117
+ );
118
+ for (const sub of subResults) collected.push(...sub);
117
119
  }
118
120
 
119
121
  return [...new Set(collected)].slice(0, MAX_URLS);