@detiq/crawler 0.1.8 → 0.2.0

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": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
package/src/crawler.ts CHANGED
@@ -141,6 +141,7 @@ type AuthDiscoveryOptions = {
141
141
  projectId: string;
142
142
  jobId?: string;
143
143
  screenshotDir?: string;
144
+ storageConfig?: any;
144
145
  };
145
146
 
146
147
  const AUTH_DISCOVERY_MAX_SCREENS = 36;
@@ -403,6 +404,8 @@ export async function crawlProject(opts: {
403
404
  /** Playwright storageState JSON string — if present, session is restored and AI login is skipped. */
404
405
  storageState?: string;
405
406
  screenshotDir?: string;
407
+ /** Cloud storage config for immediate screenshot upload. If set, screenshots are uploaded to cloud immediately after capture and cloudUrl is persisted. */
408
+ storageConfig?: { provider: string; bucket: string; region?: string; accountId?: string; endpointUrl?: string; accessKeyId?: string; secretAccessKey?: string; prefix?: string; publicUrl?: string } | null;
406
409
  /** When true, run a fast URL-only discovery crawl — skips screenshots, DB saves, and AI element extraction. Returns discoveredUrls in the result object. */
407
410
  discoveryMode?: boolean;
408
411
  maxScreens?: number;
@@ -504,6 +507,17 @@ export async function crawlProject(opts: {
504
507
  }, onProgress?: (pct: number, step: string) => Promise<void>) {
505
508
  if (opts.screenshotDir) await fs.mkdir(opts.screenshotDir, { recursive: true });
506
509
 
510
+ // Upload screenshot to cloud storage if configured, returns cloudUrl or undefined
511
+ const uploadToCloud = async (localPath: string, key: string): Promise<string | undefined> => {
512
+ if (!opts.storageConfig) return undefined;
513
+ try {
514
+ const { uploadScreenshotToTenantStorage } = await import('@detiq/app-brain');
515
+ return await uploadScreenshotToTenantStorage(opts.storageConfig as any, localPath, key);
516
+ } catch {
517
+ return undefined;
518
+ }
519
+ };
520
+
507
521
  const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.env.NODE_ENV === 'production';
508
522
 
509
523
  // Use tenant LLM config if available; fall back to env vars (Anthropic only for Stagehand).
@@ -871,6 +885,20 @@ export async function crawlProject(opts: {
871
885
  }, opts.authToken);
872
886
  }
873
887
 
888
+ // ── Crawler login bypass: inject internal secret header on login requests ────
889
+ // Prevents the per-email rate limiter from blocking re-auth attempts made by
890
+ // the crawler during long crawl sessions. The API verifies the secret before
891
+ // skipping the limiter.
892
+ const crawlerSecret = process.env.CRAWLER_INTERNAL_SECRET;
893
+ if (crawlerSecret) {
894
+ const loginPattern = `${new URL(opts.appUrl).origin}**/auth/login*`;
895
+ await page.route(loginPattern, (route: any) => {
896
+ route.continue({
897
+ headers: { ...route.request().headers(), 'x-zeta-crawler': crawlerSecret },
898
+ });
899
+ });
900
+ }
901
+
874
902
  // ── Web Vitals capture: CLS via buffered PerformanceObserver ─────────────────
875
903
  // CLS entries are only delivered to a PerformanceObserver; this init script
876
904
  // re-runs on every new document. LCP is captured per-page in the crawl loop.
@@ -1520,7 +1548,13 @@ export async function crawlProject(opts: {
1520
1548
  let totalAttemptsThisSegment = 0;
1521
1549
  const maxAttempts = Math.max(segmentSize * 4, 200);
1522
1550
 
1551
+ let pendingSessionRestore: (() => Promise<void>) | null = null;
1552
+
1523
1553
  while (queue.length > 0 && visited.size < maxScreens && visited.size < segmentSize) {
1554
+ if (pendingSessionRestore) {
1555
+ await pendingSessionRestore();
1556
+ pendingSessionRestore = null;
1557
+ }
1524
1558
  if (++totalAttemptsThisSegment > maxAttempts) {
1525
1559
  logger.warn({ maxAttempts, visited: visited.size, queued: queue.length }, '[crawler] attempt cap reached — stopping to prevent URL explosion');
1526
1560
  break;
@@ -1610,6 +1644,41 @@ export async function crawlProject(opts: {
1610
1644
  }
1611
1645
  };
1612
1646
  rawPage.on('response', onRedirectResponse);
1647
+
1648
+ // Session Isolation: Clear session before crawling public auth routes
1649
+ const AUTH_PATH_REGEX = /\/(login|register|signup|sign-in|sign-up|forgot-password|reset-password)([-/]|$)/i;
1650
+ const isAuthRoute = AUTH_PATH_REGEX.test(new URL(url).pathname);
1651
+ const browserCtx = playwrightBrowser?.contexts()[0];
1652
+
1653
+ if (isAuthRoute && browserCtx) {
1654
+ const currentState = await browserCtx.storageState();
1655
+ const hasSession = (currentState.cookies?.length ?? 0) > 0 ||
1656
+ currentState.origins?.some((o: any) => (o.localStorage?.length ?? 0) > 0);
1657
+ if (hasSession) {
1658
+ pendingSessionRestore = async () => {
1659
+ const ctx = playwrightBrowser?.contexts()[0];
1660
+ if (ctx) {
1661
+ await ctx.addCookies(currentState.cookies);
1662
+ await rawPage.evaluate((origins: any) => {
1663
+ for (const entry of (origins ?? [])) {
1664
+ if (window.location.origin === entry.origin) {
1665
+ for (const item of entry.localStorage) {
1666
+ try { localStorage.setItem(item.name, item.value); } catch {}
1667
+ }
1668
+ }
1669
+ }
1670
+ }, currentState.origins).catch(() => {});
1671
+ logger.info('[crawler] restored session state after public auth route');
1672
+ }
1673
+ };
1674
+ await browserCtx.clearCookies();
1675
+ await rawPage.evaluate(() => {
1676
+ try { localStorage.clear(); sessionStorage.clear(); } catch {}
1677
+ }).catch(() => {});
1678
+ logger.info({ url }, '[crawler] temporarily cleared session for public auth route');
1679
+ }
1680
+ }
1681
+
1613
1682
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch((navErr: any) => {
1614
1683
  logger.warn({ url, err: navErr?.message ?? String(navErr) }, '[crawler] navigation failed — skipping');
1615
1684
  });
@@ -2163,7 +2232,10 @@ export async function crawlProject(opts: {
2163
2232
  // Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
2164
2233
  const [, elements, nextLinks, atElements] = await Promise.all([
2165
2234
  page.screenshot({ path: shotPath, fullPage: true })
2166
- .then(() => AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP'))
2235
+ .then(async () => {
2236
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
2237
+ return AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', cloudUrl);
2238
+ })
2167
2239
  .catch((err: any) => console.error('[crawler] page.screenshot failed:', err)),
2168
2240
  extractElements(stagehand, page, elementLLM ?? undefined).catch((err: any) => {
2169
2241
  console.error('[crawler] extractElements failed:', err);
@@ -2791,6 +2863,11 @@ export async function crawlProject(opts: {
2791
2863
  }
2792
2864
  }
2793
2865
 
2866
+ if (pendingSessionRestore) {
2867
+ await pendingSessionRestore();
2868
+ pendingSessionRestore = null;
2869
+ }
2870
+
2794
2871
  // P1: URL Pattern LLM Completion — post-crawl LLM prediction of undiscovered URLs.
2795
2872
  // Single LLM call predicts missing routes from discovered URL patterns, HEAD-validates each.
2796
2873
  // Saves URL_CANDIDATES evidence so the ActivityDrawer shows what was found.
@@ -3135,7 +3212,8 @@ export async function crawlProject(opts: {
3135
3212
  mobilePage.screenshot({ path: shotPath, fullPage: true }),
3136
3213
  domFallbackElements(mobilePage),
3137
3214
  ]);
3138
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile);
3215
+ const cloudUrl = await uploadToCloud(shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
3216
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, disc.screenId, shotPath, disc.version!, profile, cloudUrl);
3139
3217
  AppBrain.autoCreateVisualBaseline(opts.tenantId, opts.projectId, disc.screenId, profile, shotPath, gitInfo.branch, gitInfo.commit || undefined).catch(() => { });
3140
3218
  // Save viewport-specific element records (bounding rects differ per viewport)
3141
3219
  for (const el of vpElements) {
@@ -4213,7 +4291,14 @@ async function captureAuthDiscoveryScreen(
4213
4291
  await page.screenshot({ path: shotPath, fullPage: true }).catch(() => { });
4214
4292
  const screen = await AppBrain.saveScreen(opts.tenantId, opts.projectId, name, screenUrl, opts.jobId, source === 'entry' ? 'ENTRY_POINT' : 'SEED_URL').catch(() => null);
4215
4293
  if (!screen) return { captured: false, finalUrl, flowType };
4216
- await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP').catch(() => { });
4294
+ let authCloudUrl: string | undefined;
4295
+ if (opts.storageConfig) {
4296
+ try {
4297
+ const { uploadScreenshotToTenantStorage } = await import('@detiq/app-brain');
4298
+ authCloudUrl = await uploadScreenshotToTenantStorage(opts.storageConfig as any, shotPath, `${opts.tenantId}/${opts.projectId}/screenshots/${path.basename(shotPath)}`);
4299
+ } catch { /* non-fatal */ }
4300
+ }
4301
+ await AppBrain.saveScreenshot(opts.tenantId, opts.projectId, screen.id, shotPath, screen.version, 'DESKTOP', authCloudUrl).catch(() => { });
4217
4302
  await AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
4218
4303
  screenId: screen.id,
4219
4304
  jobId: opts.jobId,
package/src/job-queue.ts CHANGED
@@ -621,8 +621,10 @@ export async function processJob(jobId: string, config: any, opts?: ProcessJobOp
621
621
  }
622
622
  : undefined;
623
623
 
624
+ const storageConfigForCrawl = await getTenantStorageConfig(tenantId).catch(() => null);
624
625
  const result = await crawlProject({
625
626
  ...config, credentials: runtimeCreds, abortSignal: controller.signal, jobId,
627
+ storageConfig: storageConfigForCrawl,
626
628
  onCheckpoint: async (visitedUrls, pendingQueue) => {
627
629
  jobCheckpoints.set(jobId, { visitedUrls, pendingQueue });
628
630
  },
@@ -41,6 +41,18 @@ export declare function crawlProject(opts: {
41
41
  /** Playwright storageState JSON string — if present, session is restored and AI login is skipped. */
42
42
  storageState?: string;
43
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;
44
56
  /** When true, run a fast URL-only discovery crawl — skips screenshots, DB saves, and AI element extraction. Returns discoveredUrls in the result object. */
45
57
  discoveryMode?: boolean;
46
58
  maxScreens?: number;