@detiq/crawler 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",
package/src/ai-login.ts CHANGED
@@ -141,7 +141,20 @@ export async function loginWithStagehand(
141
141
  await logStep('fill_password', async () => {
142
142
  const observations = await stagehand.observe('Find the password input field');
143
143
  const selector = observations[0]?.selector || 'input[type="password"]';
144
- await pg.locator(selector).fill(creds.password);
144
+ try {
145
+ await pg.locator(selector).fill(creds.password);
146
+ } catch {
147
+ // Stagehand locator failed (e.g. form re-rendered, proxy can't locate element).
148
+ // Fall back to direct DOM fill via evaluate so the password never touches the LLM.
149
+ await pg.evaluate((pwd: string) => {
150
+ const input = document.querySelector('input[type="password"]') as HTMLInputElement | null;
151
+ if (!input) throw new Error('No password field found on page');
152
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
153
+ setter?.call(input, pwd);
154
+ input.dispatchEvent(new Event('input', { bubbles: true }));
155
+ input.dispatchEvent(new Event('change', { bubbles: true }));
156
+ }, creds.password);
157
+ }
145
158
  });
146
159
  await logStep('click_submit', () => actWithMouseSim(stagehand, 'Click the sign in, log in, login, or continue button to submit credentials'));
147
160
  await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(2000));
package/src/crawler.ts CHANGED
@@ -1059,9 +1059,13 @@ export async function crawlProject(opts: {
1059
1059
  }, state.origins);
1060
1060
  }
1061
1061
 
1062
- // Reload to apply cookies + trigger localStorage restore initScript
1062
+ // Navigate to appUrl with cookies now set.
1063
+ // We MUST navigate to appUrl (not reload current page) because the initial
1064
+ // goto ran without cookies and was redirected to /login. Reloading /login stays
1065
+ // at /login even with a valid cookie (public path, no auth redirect). Navigating
1066
+ // to appUrl lets server middleware see the cookie and route to the authenticated app.
1063
1067
  await onProgress?.(12, 'Restoring session');
1064
- await rawPage.reload({ waitUntil: 'domcontentloaded' });
1068
+ await rawPage.goto(opts.appUrl, { waitUntil: 'domcontentloaded' }).catch(() => { });
1065
1069
  await waitForStableDOM(page);
1066
1070
  // SPA root pages redirect client-side (useEffect → router.replace). Wait for it.
1067
1071
  if (page.url() === opts.appUrl || page.url() === opts.appUrl.replace(/\/$/, '')) {
@@ -1303,7 +1307,8 @@ export async function crawlProject(opts: {
1303
1307
  }
1304
1308
  let checkpointSuccessCount = 0;
1305
1309
  let maxSeenTotal = 0; // track high-water mark so progress % never goes backwards
1306
- let lastEmittedPct = 20; // clamp so progress never decreases even when queue grows
1310
+ let lastEmittedPct = 5; // clamp so progress never decreases even when queue grows
1311
+ const crawlLoopStart = Date.now();
1307
1312
  const startUrl = page.url();
1308
1313
  // Resume from previous segment's pending queue if provided
1309
1314
  const resumeUrls = opts.resumeUrls ?? [];
@@ -2135,7 +2140,13 @@ export async function crawlProject(opts: {
2135
2140
 
2136
2141
  // Emit progress now — navigation is done and the page is stable.
2137
2142
  // This fires before the 30-90s parallel screenshot+AI phase, preventing a silent gap in the UI.
2138
- await onProgress?.(20, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2143
+ // Phase 1 (link discovery): 5–20% over first 60s of wall-clock time so users see movement
2144
+ // before visited.size grows enough to drive the 20–80% phase.
2145
+ const elapsedDiscoverySec = (Date.now() - crawlLoopStart) / 1000;
2146
+ const discoveryPct = Math.round(5 + Math.min(15, (elapsedDiscoverySec / 60) * 15));
2147
+ const preCapturePct = Math.max(lastEmittedPct, discoveryPct);
2148
+ lastEmittedPct = preCapturePct;
2149
+ await onProgress?.(preCapturePct, `Crawling ${visited.size} of ~${visited.size + queue.length} · ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2139
2150
 
2140
2151
  // P3a: Async content detection — get raw DOM element count before AI extraction.
2141
2152
  // The AI extraction takes 30-90s; any async-loaded content will be visible after it completes.
@@ -2145,6 +2156,7 @@ export async function crawlProject(opts: {
2145
2156
  ).catch(() => 0);
2146
2157
 
2147
2158
  await waitForScreenshotReady(page);
2159
+ await onProgress?.(lastEmittedPct, `Capturing ${(() => { try { return new URL(url).pathname || '/'; } catch { return '/'; } })()}`);
2148
2160
 
2149
2161
  // Parallelize: screenshot + element extraction + link discovery + AT capture (all work on current page state)
2150
2162
  const [, elements, nextLinks, atElements] = await Promise.all([
@@ -2644,9 +2656,10 @@ export async function crawlProject(opts: {
2644
2656
 
2645
2657
  discovered.push({ url, screenId: screen.id, elements: elements.length, elementFacts: elements, screenshotPath: shotPath, version: screen.version });
2646
2658
  // Progress: 20% baseline + up to 60% across discovered screens.
2647
- // maxSeenTotal is the denominator high-water mark; lastEmittedPct prevents
2648
- // the displayed % from going backwards when new link discoveries grow the queue.
2649
- maxSeenTotal = Math.max(maxSeenTotal, visited.size + queue.length);
2659
+ // Cap maxSeenTotal at maxScreens * 3 so unbounded link discovery does not
2660
+ // collapse the denominator and freeze progress in the 20-22% band.
2661
+ const effectiveMax = isFinite(maxScreens) ? Math.min(visited.size + queue.length, maxScreens * 3) : visited.size + queue.length;
2662
+ maxSeenTotal = Math.max(maxSeenTotal, effectiveMax);
2650
2663
  const rawPct = 20 + Math.round(Math.min(60, (visited.size / Math.max(1, maxSeenTotal)) * 60));
2651
2664
  const clampedPct = Math.max(lastEmittedPct, rawPct);
2652
2665
  lastEmittedPct = clampedPct;
@@ -4018,24 +4031,27 @@ async function waitForStableDOM(page: any, maxWait = 8_000, stabilityMs = 400):
4018
4031
  }
4019
4032
 
4020
4033
  async function waitForScreenshotReady(page: any): Promise<void> {
4034
+ const hardDeadline = Date.now() + 20_000;
4035
+ const withHardCap = <T>(p: Promise<T>): Promise<T | void> =>
4036
+ Promise.race([p, new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, hardDeadline - Date.now())))]);
4021
4037
  try {
4022
- await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { });
4023
- await page.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { });
4024
- await page.evaluate(() => {
4038
+ await withHardCap(page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => { }));
4039
+ await withHardCap(page.waitForLoadState('networkidle', { timeout: 12_000 }).catch(() => { }));
4040
+ await withHardCap(page.evaluate(() => {
4025
4041
  if (document.readyState === 'complete') return true;
4026
4042
  return new Promise<boolean>((resolve) => {
4027
4043
  const done = () => resolve(true);
4028
4044
  window.addEventListener('load', done, { once: true });
4029
4045
  setTimeout(done, 4_000);
4030
4046
  });
4031
- }).catch(() => { });
4032
- await page.evaluate(async () => {
4047
+ }).catch(() => { }));
4048
+ await withHardCap(page.evaluate(async () => {
4033
4049
  const fonts = (document as any).fonts;
4034
4050
  if (fonts?.ready) await fonts.ready;
4035
- }).catch(() => { });
4036
- await scrollToTriggerLazyLoad(page);
4037
- await waitForStableDOM(page, 10_000, 700);
4038
- await page.evaluate(async () => {
4051
+ }).catch(() => { }));
4052
+ await withHardCap(scrollToTriggerLazyLoad(page));
4053
+ await withHardCap(waitForStableDOM(page, 4_000, 700));
4054
+ await withHardCap(page.evaluate(async () => {
4039
4055
  const pending = Array.from(document.images).filter((img) => !img.complete);
4040
4056
  if (pending.length === 0) return;
4041
4057
  await Promise.race([
@@ -4045,9 +4061,9 @@ async function waitForScreenshotReady(page: any): Promise<void> {
4045
4061
  }))),
4046
4062
  new Promise<void>((resolve) => setTimeout(resolve, 4_000)),
4047
4063
  ]);
4048
- }).catch(() => { });
4049
- await waitForStableDOM(page, 4_000, 500);
4050
- await page.waitForTimeout(250).catch(() => { });
4064
+ }).catch(() => { }));
4065
+ await withHardCap(waitForStableDOM(page, 2_000, 500));
4066
+ await withHardCap(page.waitForTimeout(250).catch(() => { }));
4051
4067
  } catch { /* readiness is best-effort; never fail the crawl for a continuously mutating page */ }
4052
4068
  }
4053
4069
 
package/src/skyvern.ts CHANGED
@@ -67,9 +67,6 @@ export async function skyvernNavigate(
67
67
  return { screenshotUrls: [], visitedUrls: [], status: 'failed' };
68
68
  }
69
69
 
70
- const { assertResolvesToPublicAddress } = await import('@detiq/core');
71
- await assertResolvesToPublicAddress(apiUrl);
72
-
73
70
  const visitedUrls: string[] = [];
74
71
  const screenshotUrls: string[] = [];
75
72
  const skyvernSteps: SkyvernStep[] = [];
@@ -164,9 +161,6 @@ export async function skyvernFallback(task: SkyvernTask, tenantConfig?: SkyvernC
164
161
  return 'failed';
165
162
  }
166
163
 
167
- const { assertResolvesToPublicAddress } = await import('@detiq/core');
168
- await assertResolvesToPublicAddress(apiUrl);
169
-
170
164
  try {
171
165
  const res = await fetch(`${apiUrl}/api/v1/tasks`, {
172
166
  method: 'POST',