@detiq/crawler 0.2.0 → 0.2.2
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 +1 -1
- package/src/crawler.ts +69 -51
package/package.json
CHANGED
package/src/crawler.ts
CHANGED
|
@@ -853,24 +853,6 @@ export async function crawlProject(opts: {
|
|
|
853
853
|
} catch { /* older engines without these entry types — leave stashed values at defaults */ }
|
|
854
854
|
});
|
|
855
855
|
|
|
856
|
-
// ── HTTP Basic Auth: inject Authorization header for same-origin requests ───
|
|
857
|
-
// CDP-connected contexts cannot use browser.newContext({ httpCredentials }),
|
|
858
|
-
// so we intercept via page.route() instead.
|
|
859
|
-
if (opts.httpBasicUsername && opts.httpBasicPassword) {
|
|
860
|
-
const encoded = Buffer.from(`${opts.httpBasicUsername}:${opts.httpBasicPassword}`).toString('base64');
|
|
861
|
-
const appOrigin = new URL(opts.appUrl).origin;
|
|
862
|
-
await page.route('**/*', (route: any) => {
|
|
863
|
-
const reqUrl = route.request().url();
|
|
864
|
-
if (reqUrl.startsWith(appOrigin)) {
|
|
865
|
-
route.continue({
|
|
866
|
-
headers: { ...route.request().headers(), 'Authorization': `Basic ${encoded}` },
|
|
867
|
-
});
|
|
868
|
-
} else {
|
|
869
|
-
route.continue();
|
|
870
|
-
}
|
|
871
|
-
});
|
|
872
|
-
}
|
|
873
|
-
|
|
874
856
|
// ── API/Bearer token injection ────────────────────────────────────────────
|
|
875
857
|
// If an authToken is provided, inject it as an HTTP header on every request
|
|
876
858
|
// and store it in localStorage so client-side code can pick it up.
|
|
@@ -885,20 +867,6 @@ export async function crawlProject(opts: {
|
|
|
885
867
|
}, opts.authToken);
|
|
886
868
|
}
|
|
887
869
|
|
|
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
|
-
|
|
902
870
|
// ── Web Vitals capture: CLS via buffered PerformanceObserver ─────────────────
|
|
903
871
|
// CLS entries are only delivered to a PerformanceObserver; this init script
|
|
904
872
|
// re-runs on every new document. LCP is captured per-page in the crawl loop.
|
|
@@ -1012,6 +980,37 @@ export async function crawlProject(opts: {
|
|
|
1012
980
|
// Final fallback: if rawPage is still null, use the Stagehand page (events may not work)
|
|
1013
981
|
if (!rawPage) rawPage = page;
|
|
1014
982
|
|
|
983
|
+
// ── HTTP Basic Auth: inject Authorization header for same-origin requests ───
|
|
984
|
+
// Must use rawPage (real Playwright page) — Stagehand V3Page proxy does not implement route().
|
|
985
|
+
if (opts.httpBasicUsername && opts.httpBasicPassword) {
|
|
986
|
+
const encoded = Buffer.from(`${opts.httpBasicUsername}:${opts.httpBasicPassword}`).toString('base64');
|
|
987
|
+
const appOrigin = new URL(opts.appUrl).origin;
|
|
988
|
+
await rawPage.route('**/*', (route: any) => {
|
|
989
|
+
const reqUrl = route.request().url();
|
|
990
|
+
if (reqUrl.startsWith(appOrigin)) {
|
|
991
|
+
route.continue({
|
|
992
|
+
headers: { ...route.request().headers(), 'Authorization': `Basic ${encoded}` },
|
|
993
|
+
});
|
|
994
|
+
} else {
|
|
995
|
+
route.continue();
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ── Crawler login bypass: inject internal secret header on login requests ────
|
|
1001
|
+
// Prevents the per-email rate limiter from blocking re-auth attempts made by
|
|
1002
|
+
// the crawler during long crawl sessions. The API verifies the secret before
|
|
1003
|
+
// skipping the limiter.
|
|
1004
|
+
const crawlerSecret = process.env.CRAWLER_INTERNAL_SECRET;
|
|
1005
|
+
if (crawlerSecret) {
|
|
1006
|
+
const loginPattern = `${new URL(opts.appUrl).origin}**/auth/login*`;
|
|
1007
|
+
await rawPage.route(loginPattern, (route: any) => {
|
|
1008
|
+
route.continue({
|
|
1009
|
+
headers: { ...route.request().headers(), 'x-zeta-crawler': crawlerSecret },
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1015
1014
|
try {
|
|
1016
1015
|
// Step 1: Navigate and authenticate
|
|
1017
1016
|
await onProgress?.(8, 'Loading app');
|
|
@@ -3110,9 +3109,19 @@ export async function crawlProject(opts: {
|
|
|
3110
3109
|
await onProgress?.(90, `App map saved · ${visited.size} screens, ${edges.length} flows wired`);
|
|
3111
3110
|
}
|
|
3112
3111
|
|
|
3112
|
+
// P5: Export session cookies BEFORE session health check so we can reuse them even if
|
|
3113
|
+
// the main browser session is stale. The cookie jar stays valid for fresh browser contexts
|
|
3114
|
+
// even when the CDP-connected Stagehand page shows a redirect to /login.
|
|
3115
|
+
let mainStorageState: any;
|
|
3116
|
+
try {
|
|
3117
|
+
mainStorageState = await playwrightBrowser!.contexts()[0]?.storageState();
|
|
3118
|
+
} catch { /* non-fatal */ }
|
|
3119
|
+
|
|
3113
3120
|
// P5: Orphan page auth re-verify — confirm session is still alive before tail-end viewport pass.
|
|
3114
3121
|
// The main BFS can span many minutes; the session may have expired by the time we reach here.
|
|
3115
|
-
// If expired, attempt re-auth via Stagehand before proceeding.
|
|
3122
|
+
// If expired, attempt re-auth via Stagehand before proceeding.
|
|
3123
|
+
// Creative resilience: even if the main browser is dead, we still run the viewport pass using
|
|
3124
|
+
// the exported cookie jar — fresh browsers are often accepted by servers even after CDP session loss.
|
|
3116
3125
|
let sessionAliveForViewports = true;
|
|
3117
3126
|
try {
|
|
3118
3127
|
await page.goto(opts.appUrl, { waitUntil: 'domcontentloaded', timeout: 15_000 });
|
|
@@ -3131,19 +3140,24 @@ export async function crawlProject(opts: {
|
|
|
3131
3140
|
).catch(() => false);
|
|
3132
3141
|
}
|
|
3133
3142
|
if (reauthed) {
|
|
3143
|
+
// Refresh cookie jar with newly authenticated session
|
|
3144
|
+
try { mainStorageState = await playwrightBrowser!.contexts()[0]?.storageState(); } catch { /* keep prior */ }
|
|
3134
3145
|
logger.info('[P5] Re-auth succeeded — proceeding with viewport pass');
|
|
3135
3146
|
} else {
|
|
3136
3147
|
sessionAliveForViewports = false;
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3148
|
+
const hasCookies = (mainStorageState?.cookies?.length ?? 0) > 0;
|
|
3149
|
+
logger.warn({ landed: sessionCheckUrl, hasCookies }, '[P5] Re-auth failed — will attempt viewport pass with exported cookies');
|
|
3150
|
+
if (!hasCookies) {
|
|
3151
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3152
|
+
jobId: opts.jobId,
|
|
3153
|
+
type: 'AUTH_FAILURE',
|
|
3154
|
+
payload: {
|
|
3155
|
+
url: opts.appUrl,
|
|
3156
|
+
landed: sessionCheckUrl,
|
|
3157
|
+
reason: 'Session expired before mobile/tablet viewport pass and re-auth failed. Re-capture the session in project settings.',
|
|
3158
|
+
},
|
|
3159
|
+
}).catch(() => { });
|
|
3160
|
+
}
|
|
3147
3161
|
}
|
|
3148
3162
|
} else {
|
|
3149
3163
|
logger.info({ url: sessionCheckUrl }, '[P5] Session alive — proceeding with viewport pass');
|
|
@@ -3163,7 +3177,10 @@ export async function crawlProject(opts: {
|
|
|
3163
3177
|
// (isMobile, hasTouch, viewport, deviceScaleFactor) on new contexts — the flags are
|
|
3164
3178
|
// silently ignored, so screenshots come out at desktop dimensions regardless of profile.
|
|
3165
3179
|
const extraProfiles = (opts.deviceProfiles ?? []).filter((p) => p !== 'DESKTOP');
|
|
3166
|
-
|
|
3180
|
+
// Run the viewport pass even when the main session is dead, as long as we exported cookies —
|
|
3181
|
+
// fresh browsers often authenticate successfully with the saved cookie jar.
|
|
3182
|
+
const hasExportedAuth = (mainStorageState?.cookies?.length ?? 0) > 0;
|
|
3183
|
+
if ((sessionAliveForViewports || hasExportedAuth) && extraProfiles.length > 0 && discovered.length > 0) {
|
|
3167
3184
|
const { devices, chromium: pwChromium } = await import('playwright');
|
|
3168
3185
|
const DEVICE_MAP: Record<string, any> = {
|
|
3169
3186
|
// Override viewport heights: Playwright's device descriptors use viewport-minus-browser-chrome
|
|
@@ -3172,11 +3189,6 @@ export async function crawlProject(opts: {
|
|
|
3172
3189
|
MOBILE_PORTRAIT: { ...devices['iPhone 15'], viewport: { width: 393, height: 852 } },
|
|
3173
3190
|
TABLET: { ...devices['iPad Pro 11'], viewport: { width: 834, height: 1194 } },
|
|
3174
3191
|
};
|
|
3175
|
-
// Export session cookies from the main crawl so mobile browser is already authenticated
|
|
3176
|
-
let mainStorageState: any;
|
|
3177
|
-
try {
|
|
3178
|
-
mainStorageState = await playwrightBrowser!.contexts()[0]?.storageState();
|
|
3179
|
-
} catch { /* non-fatal — mobile pass will proceed without auth */ }
|
|
3180
3192
|
|
|
3181
3193
|
for (const profile of extraProfiles) {
|
|
3182
3194
|
const deviceConfig = DEVICE_MAP[profile];
|
|
@@ -3225,13 +3237,19 @@ export async function crawlProject(opts: {
|
|
|
3225
3237
|
).catch(() => { });
|
|
3226
3238
|
}
|
|
3227
3239
|
} catch (err) {
|
|
3228
|
-
|
|
3240
|
+
logger.warn({ profile, url: disc.url, err: String(err) }, `[P5] ${profile} screenshot failed — skipping screen`);
|
|
3241
|
+
AppBrain.saveCrawlEvidence(opts.tenantId, opts.projectId, {
|
|
3242
|
+
jobId: opts.jobId,
|
|
3243
|
+
screenId: disc.screenId,
|
|
3244
|
+
type: 'VIEWPORT_SCREENSHOT_FAILURE',
|
|
3245
|
+
payload: { profile, url: disc.url, reason: String(err) },
|
|
3246
|
+
}).catch(() => { });
|
|
3229
3247
|
}
|
|
3230
3248
|
}
|
|
3231
3249
|
await mobilePage.close().catch(() => { });
|
|
3232
3250
|
await mobileCtx.close().catch(() => { });
|
|
3233
3251
|
} catch (profileErr) {
|
|
3234
|
-
|
|
3252
|
+
logger.warn({ profile, err: String(profileErr) }, `[P5] ${profile} browser launch failed`);
|
|
3235
3253
|
} finally {
|
|
3236
3254
|
await mobileBrowser?.close().catch(() => { });
|
|
3237
3255
|
}
|