@detiq/crawler 0.2.7 → 0.2.10

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,8 +1,11 @@
1
1
  {
2
2
  "name": "@detiq/crawler",
3
- "version": "0.2.7",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
6
9
  "type": "module",
7
10
  "main": "dist/index.js",
8
11
  "types": "types/index.d.ts",
package/src/ai-login.ts CHANGED
@@ -9,6 +9,14 @@ import { simulateMouseMovement } from './stealth/human-behavior.js';
9
9
 
10
10
  const CLICK_INTENT = /\b(click|tap|press|submit|select|choose|toggle)\b/i;
11
11
 
12
+ /** Stagehand V3 enforces elementId as /^\d+-\d+$/ — LLM sometimes returns bare integers.
13
+ * Catch the resulting AI_NoObjectGeneratedError so callers can fall back to DOM. */
14
+ function isNoObjectGeneratedError(err: any): boolean {
15
+ const name = String(err?.name ?? '');
16
+ const msg = String(err?.message ?? '');
17
+ return name.includes('NoObjectGenerated') || msg.includes('NoObjectGenerated') || msg.includes('No object generated');
18
+ }
19
+
12
20
  /** Simulate human mouse movement before click-type actions, then call stagehand.act(). */
13
21
  async function actWithMouseSim(stagehand: Stagehand, instruction: string): Promise<any> {
14
22
  if (CLICK_INTENT.test(instruction)) {
@@ -131,11 +139,29 @@ export async function loginWithStagehand(
131
139
  throw new Error(`loginInstructions exceeds ${MAX_LOGIN_INSTRUCTIONS} character limit`);
132
140
  }
133
141
  if (loginInstructions) {
134
- await logStep('custom_recipe', () => actWithMouseSim(stagehand, loginInstructions));
142
+ await logStep('custom_recipe', async () => {
143
+ try {
144
+ return await actWithMouseSim(stagehand, loginInstructions);
145
+ } catch (err: any) {
146
+ if (!isNoObjectGeneratedError(err)) throw err;
147
+ logger.warn('[crawler] custom_recipe act() failed — Stagehand V3 elementId schema error, skipping to standard fill');
148
+ return { description: 'custom_recipe skipped (schema error)' };
149
+ }
150
+ });
135
151
  await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(1500));
136
152
  }
137
153
 
138
- await logStep('fill_username', () => actWithMouseSim(stagehand, `Type "${creds.username}" into the username or email input field`));
154
+ await logStep('fill_username', async () => {
155
+ try {
156
+ return await actWithMouseSim(stagehand, `Type "${creds.username}" into the username or email input field`);
157
+ } catch (err: any) {
158
+ if (!isNoObjectGeneratedError(err)) throw err;
159
+ logger.warn('[crawler] fill_username act() failed — falling back to DOM');
160
+ const sel = 'input[type="email"], input[autocomplete="email"], input[autocomplete="username"], input[name*="email" i], input[name*="user" i], input[placeholder*="email" i], input[placeholder*="user" i], input[id*="email" i], input[id*="user" i]';
161
+ await pg.locator(sel).first().fill(creds.username);
162
+ return { description: 'fill_username via DOM fallback' };
163
+ }
164
+ });
139
165
  // Don't send password to LLM — use direct Playwright fill to avoid
140
166
  // leaking credentials into the Stagehand/LLM provider's request logs.
141
167
  await logStep('fill_password', async () => {
@@ -156,7 +182,18 @@ export async function loginWithStagehand(
156
182
  }, creds.password);
157
183
  }
158
184
  });
159
- await logStep('click_submit', () => actWithMouseSim(stagehand, 'Click the sign in, log in, login, or continue button to submit credentials'));
185
+ await logStep('click_submit', async () => {
186
+ try {
187
+ return await actWithMouseSim(stagehand, 'Click the sign in, log in, login, or continue button to submit credentials');
188
+ } catch (err: any) {
189
+ if (!isNoObjectGeneratedError(err)) throw err;
190
+ logger.warn('[crawler] click_submit act() failed — falling back to DOM');
191
+ const sel = 'button[type="submit"], input[type="submit"], button:has-text("Sign in"), button:has-text("Log in"), button:has-text("Login"), button:has-text("Continue"), button:has-text("Next"), button:has-text("Sign In"), [role="button"]:has-text("Sign in"), [role="button"]:has-text("Login")';
192
+ await pg.locator(sel).first().click();
193
+ await pg.waitForTimeout(500);
194
+ return { description: 'click_submit via DOM fallback' };
195
+ }
196
+ });
160
197
  await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(2000));
161
198
 
162
199
  const twoFA = await stagehand.extract(
@@ -200,8 +237,28 @@ export async function loginWithStagehand(
200
237
  console.warn('[crawler] 2FA detected but no oneTimeCode provided');
201
238
  return false;
202
239
  }
203
- await logStep('2fa_inject', () => actWithMouseSim(stagehand, `Enter "${code}" into the ${twoFA.fieldLabel ?? 'verification code'} field`));
204
- await logStep('2fa_submit', () => actWithMouseSim(stagehand, 'Click verify or confirm to submit the verification code'));
240
+ await logStep('2fa_inject', async () => {
241
+ try {
242
+ return await actWithMouseSim(stagehand, `Enter "${code}" into the ${twoFA.fieldLabel ?? 'verification code'} field`);
243
+ } catch (err: any) {
244
+ if (!isNoObjectGeneratedError(err)) throw err;
245
+ logger.warn('[crawler] 2fa_inject act() failed — falling back to DOM');
246
+ const sel = 'input[autocomplete="one-time-code"], input[name*="otp" i], input[name*="code" i], input[name*="token" i], input[inputmode="numeric"], input[type="number"]';
247
+ await pg.locator(sel).first().fill(code as string);
248
+ return { description: '2fa_inject via DOM fallback' };
249
+ }
250
+ });
251
+ await logStep('2fa_submit', async () => {
252
+ try {
253
+ return await actWithMouseSim(stagehand, 'Click verify or confirm to submit the verification code');
254
+ } catch (err: any) {
255
+ if (!isNoObjectGeneratedError(err)) throw err;
256
+ logger.warn('[crawler] 2fa_submit act() failed — falling back to DOM');
257
+ const sel = 'button[type="submit"], button:has-text("Verify"), button:has-text("Confirm"), button:has-text("Submit"), button:has-text("Continue")';
258
+ await pg.locator(sel).first().click();
259
+ return { description: '2fa_submit via DOM fallback' };
260
+ }
261
+ });
205
262
  await pg.waitForLoadState('networkidle').catch(() => pg.waitForTimeout(2000));
206
263
  }
207
264
 
package/src/crawler.ts CHANGED
@@ -1802,7 +1802,7 @@ export async function crawlProject(opts: {
1802
1802
  type: 'AUTH_FAILURE',
1803
1803
  payload: { url, landed: landedUrl, reason: 'Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.' },
1804
1804
  }).catch(() => { });
1805
- break;
1805
+ throw new Error('[AUTH_FAILURE] Re-authentication failed. Session is unrecoverable. Re-capture the session in project settings and retry the crawl.');
1806
1806
  }
1807
1807
  } else {
1808
1808
  logger.warn('[crawler] Session expired mid-crawl, no credentials to recover — stopping BFS');
@@ -1811,7 +1811,7 @@ export async function crawlProject(opts: {
1811
1811
  type: 'AUTH_FAILURE',
1812
1812
  payload: { url, landed: landedUrl, reason: 'Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.' },
1813
1813
  }).catch(() => { });
1814
- break;
1814
+ throw new Error('[AUTH_FAILURE] Session expired and no credentials are configured. Add credentials in project settings and re-capture the session.');
1815
1815
  }
1816
1816
  }
1817
1817
  // If weWantedThisPage (intentional /login, /session-expired, /404 navigation) —
package/src/job-queue.ts CHANGED
@@ -383,7 +383,10 @@ export async function enqueueJob(type: string, config: any): Promise<string> {
383
383
  if (type === 'CRAWL' && process.env.JOB_QUEUE_BACKEND === 'sqs') {
384
384
  const { sendJobToSqs } = await import('./sqs-queue.js');
385
385
  await sendJobToSqs(job.id, tenantId, config);
386
- } else {
386
+ } else if (process.env.DISABLE_WORKER !== 'true') {
387
+ // Skip inline dispatch when DISABLE_WORKER=true — the dedicated crawler process
388
+ // will pick this up via its poll loop. Inline dispatch in the API container
389
+ // would launch Playwright browsers and crash the API.
387
390
  setImmediate(() => claimSpecificJob(job.id).then((claimed) => claimed && dispatchClaimedJob(claimed)));
388
391
  }
389
392