@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/dist/crawler.js +81 -3
- package/dist/crawler.js.map +2 -2
- package/dist/job-queue.js +2 -0
- package/dist/job-queue.js.map +2 -2
- package/package.json +1 -1
- package/src/crawler.ts +88 -3
- package/src/job-queue.ts +2 -0
- package/types/crawler.d.ts +12 -0
package/package.json
CHANGED
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(() =>
|
|
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
|
|
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
|
-
|
|
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
|
},
|
package/types/crawler.d.ts
CHANGED
|
@@ -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;
|