@detiq/crawler 1.1.8 → 1.1.9

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.
Files changed (82) hide show
  1. package/dist/accessibility-auditor.js +74 -54
  2. package/dist/ai-login.js +249 -227
  3. package/dist/auth-token-rotator.js +67 -57
  4. package/dist/auth.js +336 -299
  5. package/dist/browser-extension.js +142 -104
  6. package/dist/captcha-solver.js +125 -98
  7. package/dist/color-contrast.js +89 -77
  8. package/dist/content-chunker.js +126 -126
  9. package/dist/content-dedup.js +71 -54
  10. package/dist/cookie-consent.js +83 -68
  11. package/dist/cookie-jar.js +60 -53
  12. package/dist/crawler.js +5345 -4486
  13. package/dist/custom-js-injector.js +61 -45
  14. package/dist/dark-mode-capture.js +81 -67
  15. package/dist/dead-link-detector.js +55 -47
  16. package/dist/element-hash.js +12 -8
  17. package/dist/email-otp.js +195 -154
  18. package/dist/engine-router.js +98 -87
  19. package/dist/export/bulk-export.js +55 -70
  20. package/dist/export/vector-export.js +105 -77
  21. package/dist/extractors/advanced.js +266 -249
  22. package/dist/extractors/artifact-envelope.js +58 -59
  23. package/dist/failure-analysis-agent.js +33 -37
  24. package/dist/feature-flag-detector.js +66 -51
  25. package/dist/form-detector.js +75 -55
  26. package/dist/frontier.js +101 -76
  27. package/dist/gap-pass.js +89 -68
  28. package/dist/graphql-detector.js +72 -62
  29. package/dist/http-engine.js +330 -282
  30. package/dist/incremental-diff.js +87 -54
  31. package/dist/index.js +28 -67
  32. package/dist/input-fuzzer.js +97 -93
  33. package/dist/interactive-probe.js +827 -715
  34. package/dist/job-queue.js +2169 -2151
  35. package/dist/js-coverage.js +56 -52
  36. package/dist/js-router-detector.js +117 -86
  37. package/dist/language-detector.js +63 -51
  38. package/dist/link-classifier.js +50 -32
  39. package/dist/live-view.js +108 -91
  40. package/dist/locator-extractor.js +187 -151
  41. package/dist/logger.js +7 -9
  42. package/dist/markdown-extract.js +61 -48
  43. package/dist/native-crawler.js +946 -780
  44. package/dist/network-observer.js +156 -135
  45. package/dist/network-throttle.js +40 -29
  46. package/dist/openapi-generator.js +92 -77
  47. package/dist/pdf-extractor.js +90 -72
  48. package/dist/performance-metrics.js +102 -80
  49. package/dist/phash.js +144 -125
  50. package/dist/pii-scrubber.js +68 -65
  51. package/dist/playwright-recorder.js +58 -70
  52. package/dist/policies/crawl-safety.js +34 -31
  53. package/dist/profiles/domain-profile.js +125 -130
  54. package/dist/proxy.js +172 -146
  55. package/dist/rate-limiter.js +65 -58
  56. package/dist/redirect-tracker.js +64 -60
  57. package/dist/redis-frontier.js +136 -116
  58. package/dist/redis-queue.js +144 -157
  59. package/dist/response-cache.js +140 -127
  60. package/dist/responsive-detector.js +46 -42
  61. package/dist/retry-policy.js +50 -46
  62. package/dist/robots-txt.js +83 -69
  63. package/dist/screen-reader.js +70 -45
  64. package/dist/screenshot-masker.js +77 -59
  65. package/dist/seo-checker.js +107 -88
  66. package/dist/session-capture.js +212 -192
  67. package/dist/session-export.js +133 -107
  68. package/dist/sitemap-writer.js +41 -38
  69. package/dist/sitemap.js +202 -141
  70. package/dist/skyvern.js +148 -123
  71. package/dist/sqs-queue.js +42 -42
  72. package/dist/sso-token.js +90 -74
  73. package/dist/stealth/human-behavior.js +173 -156
  74. package/dist/telemetry.js +82 -68
  75. package/dist/test-executor.js +335 -309
  76. package/dist/tls-fingerprint.js +88 -77
  77. package/dist/url-pattern-completer.js +90 -71
  78. package/dist/visual-diff-verdict.js +38 -23
  79. package/dist/webhook-notifier.js +69 -71
  80. package/dist/websocket-detector.js +68 -56
  81. package/dist/worker.js +38 -29
  82. package/package.json +1 -1
package/dist/email-otp.js CHANGED
@@ -1,177 +1,218 @@
1
- const POLL_INTERVAL_MS = 2e3;
2
- const MAX_WAIT_MS = 6e4;
1
+ /**
2
+ * Email OTP retrieval — supports 3 providers:
3
+ * mailslurp — managed SaaS (existing, no self-hosting)
4
+ * imap — any real IMAP mailbox (Gmail, Outlook, custom domain catch-all)
5
+ * mailpit — self-hosted SMTP trap (dev/staging only)
6
+ *
7
+ * Use mailslurp or imap for production apps.
8
+ * Use mailpit only when you can point the app's SMTP at the trap server.
9
+ */
10
+ const POLL_INTERVAL_MS = 2_000;
11
+ const MAX_WAIT_MS = 60_000;
12
+ /** Extract OTP code from email body text. Prefers 6-digit, falls back to 4-8 digit. */
3
13
  function extractOTP(text) {
4
- const m6 = text.match(/\b(\d{6})\b/);
5
- if (m6) return m6[1];
6
- const m48 = text.match(/\b(\d{4,8})\b/);
7
- if (m48) return m48[1];
8
- const magic = text.match(/\b([A-Z0-9]{6,10})\b/);
9
- if (magic) return magic[1];
10
- return null;
14
+ const m6 = text.match(/\b(\d{6})\b/);
15
+ if (m6)
16
+ return m6[1];
17
+ const m48 = text.match(/\b(\d{4,8})\b/);
18
+ if (m48)
19
+ return m48[1];
20
+ const magic = text.match(/\b([A-Z0-9]{6,10})\b/);
21
+ if (magic)
22
+ return magic[1];
23
+ return null;
11
24
  }
25
+ /** Extract a magic-link URL from email body text. Matches /verify, /magic, /auth/callback, ?token= etc. */
12
26
  function extractMagicLink(text) {
13
- const magicPath = /\/(verify|magic|login|auth|confirm|sign-in)[/?#]/i;
14
- const magicQuery = /[?&](token|magic_token|login_token|auth_token|key)=/i;
15
- const urlMatches = text.match(/https:\/\/[^\s<>"')\]]+/g);
16
- if (!urlMatches) return null;
17
- for (const raw of urlMatches) {
18
- try {
19
- const u = new URL(raw.replace(/[>'")\].,]+$/, ""));
20
- if (magicPath.test(u.pathname) || magicQuery.test(u.search)) return u.toString();
21
- } catch {
27
+ const magicPath = /\/(verify|magic|login|auth|confirm|sign-in)[/?#]/i;
28
+ const magicQuery = /[?&](token|magic_token|login_token|auth_token|key)=/i;
29
+ const urlMatches = text.match(/https:\/\/[^\s<>"')\]]+/g);
30
+ if (!urlMatches)
31
+ return null;
32
+ for (const raw of urlMatches) {
33
+ try {
34
+ const u = new URL(raw.replace(/[>'")\].,]+$/, ''));
35
+ if (magicPath.test(u.pathname) || magicQuery.test(u.search))
36
+ return u.toString();
37
+ }
38
+ catch { /* skip malformed URLs */ }
22
39
  }
23
- }
24
- return null;
40
+ return null;
25
41
  }
26
42
  function parseEmailContent(text) {
27
- const magicLink = extractMagicLink(text);
28
- if (magicLink) return { magicLink };
29
- const otp = extractOTP(text);
30
- if (otp) return { otp };
31
- return {};
43
+ const magicLink = extractMagicLink(text);
44
+ if (magicLink)
45
+ return { magicLink };
46
+ const otp = extractOTP(text);
47
+ if (otp)
48
+ return { otp };
49
+ return {};
32
50
  }
33
- const MAILSLURP_BASE = "https://api.mailslurp.com";
51
+ // ── MailSlurp ────────────────────────────────────────────────────────────────
52
+ const MAILSLURP_BASE = 'https://api.mailslurp.com';
34
53
  async function waitForContentMailSlurp(apiKey, inboxId, since) {
35
- const deadline = Date.now() + MAX_WAIT_MS;
36
- while (Date.now() < deadline) {
37
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
38
- try {
39
- const listRes = await fetch(
40
- `${MAILSLURP_BASE}/inboxes/${inboxId}/emails?sort=DESC&limit=5&since=${encodeURIComponent(since)}`,
41
- { headers: { "x-api-key": apiKey } }
42
- );
43
- if (!listRes.ok) continue;
44
- const emails = await listRes.json();
45
- if (!emails.length) continue;
46
- const bodyRes = await fetch(`${MAILSLURP_BASE}/emails/${emails[0].id}`, { headers: { "x-api-key": apiKey } });
47
- if (!bodyRes.ok) continue;
48
- const email = await bodyRes.json();
49
- const content = parseEmailContent((email.body ?? "") + " " + (email.subject ?? ""));
50
- if (content.otp || content.magicLink) return content;
51
- } catch {
54
+ const deadline = Date.now() + MAX_WAIT_MS;
55
+ while (Date.now() < deadline) {
56
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
57
+ try {
58
+ const listRes = await fetch(`${MAILSLURP_BASE}/inboxes/${inboxId}/emails?sort=DESC&limit=5&since=${encodeURIComponent(since)}`, { headers: { 'x-api-key': apiKey } });
59
+ if (!listRes.ok)
60
+ continue;
61
+ const emails = await listRes.json();
62
+ if (!emails.length)
63
+ continue;
64
+ const bodyRes = await fetch(`${MAILSLURP_BASE}/emails/${emails[0].id}`, { headers: { 'x-api-key': apiKey } });
65
+ if (!bodyRes.ok)
66
+ continue;
67
+ const email = await bodyRes.json();
68
+ const content = parseEmailContent((email.body ?? '') + ' ' + (email.subject ?? ''));
69
+ if (content.otp || content.magicLink)
70
+ return content;
71
+ }
72
+ catch { /* retry */ }
52
73
  }
53
- }
54
- return null;
74
+ return null;
55
75
  }
56
76
  async function getInboxEmailMailSlurp(apiKey, inboxId) {
57
- try {
58
- const res = await fetch(`${MAILSLURP_BASE}/inboxes/${inboxId}`, { headers: { "x-api-key": apiKey } });
59
- if (!res.ok) return null;
60
- const data = await res.json();
61
- return data.emailAddress ?? null;
62
- } catch {
63
- return null;
64
- }
77
+ try {
78
+ const res = await fetch(`${MAILSLURP_BASE}/inboxes/${inboxId}`, { headers: { 'x-api-key': apiKey } });
79
+ if (!res.ok)
80
+ return null;
81
+ const data = await res.json();
82
+ return data.emailAddress ?? null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
65
87
  }
88
+ // ── Mailpit (SMTP trap REST API) ──────────────────────────────────────────────
66
89
  async function waitForContentMailpit(baseUrl, since) {
67
- const deadline = Date.now() + MAX_WAIT_MS;
68
- const sinceMs = since.getTime();
69
- while (Date.now() < deadline) {
70
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
71
- try {
72
- const res = await fetch(`${baseUrl}/api/v1/messages?limit=10`);
73
- if (!res.ok) continue;
74
- const data = await res.json();
75
- const recent = (data.messages ?? []).filter((m) => new Date(m.Created).getTime() > sinceMs);
76
- for (const msg of recent) {
77
- const snippet = parseEmailContent(msg.Snippet + " " + msg.Subject);
78
- if (snippet.otp || snippet.magicLink) return snippet;
79
- const bodyRes = await fetch(`${baseUrl}/api/v1/message/${msg.ID}`);
80
- if (!bodyRes.ok) continue;
81
- const body = await bodyRes.json();
82
- const full = parseEmailContent((body.Text?.Body ?? "") + " " + (body.Subject ?? ""));
83
- if (full.otp || full.magicLink) return full;
84
- }
85
- } catch {
90
+ const deadline = Date.now() + MAX_WAIT_MS;
91
+ const sinceMs = since.getTime();
92
+ while (Date.now() < deadline) {
93
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
94
+ try {
95
+ const res = await fetch(`${baseUrl}/api/v1/messages?limit=10`);
96
+ if (!res.ok)
97
+ continue;
98
+ const data = await res.json();
99
+ const recent = (data.messages ?? []).filter(m => new Date(m.Created).getTime() > sinceMs);
100
+ for (const msg of recent) {
101
+ const snippet = parseEmailContent(msg.Snippet + ' ' + msg.Subject);
102
+ if (snippet.otp || snippet.magicLink)
103
+ return snippet;
104
+ // Fetch full body — snippet may truncate the magic link
105
+ const bodyRes = await fetch(`${baseUrl}/api/v1/message/${msg.ID}`);
106
+ if (!bodyRes.ok)
107
+ continue;
108
+ const body = await bodyRes.json();
109
+ const full = parseEmailContent((body.Text?.Body ?? '') + ' ' + (body.Subject ?? ''));
110
+ if (full.otp || full.magicLink)
111
+ return full;
112
+ }
113
+ }
114
+ catch { /* retry */ }
86
115
  }
87
- }
88
- return null;
116
+ return null;
89
117
  }
118
+ // ── IMAP (any mailbox) ────────────────────────────────────────────────────────
90
119
  async function waitForContentImap(cfg, since) {
91
- let ImapFlow;
92
- try {
93
- const mod = await import("imapflow");
94
- ImapFlow = mod.ImapFlow;
95
- } catch {
96
- throw new Error("IMAP provider requires imapflow package. Add it to dependencies.");
97
- }
98
- const client = new ImapFlow({
99
- host: cfg.host,
100
- port: cfg.port,
101
- secure: cfg.tls,
102
- auth: { user: cfg.user, pass: cfg.pass },
103
- logger: false
104
- });
105
- try {
106
- await client.connect();
107
- const lock = await client.getMailboxLock("INBOX");
120
+ let ImapFlow;
108
121
  try {
109
- const deadline = Date.now() + MAX_WAIT_MS;
110
- while (Date.now() < deadline) {
111
- const uids = await client.search({ seen: false, since });
112
- if (uids.length) {
113
- for await (const msg of client.fetch(uids.slice(-5), { source: true })) {
114
- const { simpleParser } = await import("mailparser");
115
- const parsed = await simpleParser(msg.source);
116
- const text = (parsed.text ?? "") + " " + (parsed.subject ?? "");
117
- const content = parseEmailContent(text);
118
- if (content.otp || content.magicLink) return content;
119
- }
120
- }
121
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
122
- }
123
- return null;
124
- } finally {
125
- lock.release();
122
+ const mod = await import('imapflow');
123
+ ImapFlow = mod.ImapFlow;
126
124
  }
127
- } finally {
128
- await client.logout().catch(() => {
125
+ catch {
126
+ throw new Error('IMAP provider requires imapflow package. Add it to dependencies.');
127
+ }
128
+ const client = new ImapFlow({
129
+ host: cfg.host,
130
+ port: cfg.port,
131
+ secure: cfg.tls,
132
+ auth: { user: cfg.user, pass: cfg.pass },
133
+ logger: false,
129
134
  });
130
- }
135
+ try {
136
+ await client.connect();
137
+ const lock = await client.getMailboxLock('INBOX');
138
+ try {
139
+ const deadline = Date.now() + MAX_WAIT_MS;
140
+ while (Date.now() < deadline) {
141
+ const uids = await client.search({ seen: false, since });
142
+ if (uids.length) {
143
+ for await (const msg of client.fetch(uids.slice(-5), { source: true })) {
144
+ const { simpleParser } = await import('mailparser');
145
+ const parsed = await simpleParser(msg.source);
146
+ const text = (parsed.text ?? '') + ' ' + (parsed.subject ?? '');
147
+ const content = parseEmailContent(text);
148
+ if (content.otp || content.magicLink)
149
+ return content;
150
+ }
151
+ }
152
+ await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
153
+ }
154
+ return null;
155
+ }
156
+ finally {
157
+ lock.release();
158
+ }
159
+ }
160
+ finally {
161
+ await client.logout().catch(() => { });
162
+ }
131
163
  }
132
- async function waitForEmailContent(config, since) {
133
- const sinceStr = typeof since === "string" ? since : since.toISOString();
134
- const sinceDate = typeof since === "string" ? new Date(since) : since;
135
- switch (config.provider) {
136
- case "mailslurp":
137
- if (!config.apiKey || !config.inboxId) throw new Error("mailslurp requires apiKey + inboxId");
138
- return waitForContentMailSlurp(config.apiKey, config.inboxId, sinceStr);
139
- case "mailpit":
140
- return waitForContentMailpit(config.mailpitBaseUrl ?? "http://mailpit:8025", sinceDate);
141
- case "imap":
142
- if (!config.imapHost || !config.imapUser || !config.imapPass) {
143
- throw new Error("imap requires imapHost, imapUser, imapPass");
144
- }
145
- return waitForContentImap({
146
- host: config.imapHost,
147
- port: config.imapPort ?? 993,
148
- user: config.imapUser,
149
- pass: config.imapPass,
150
- tls: config.imapTls !== false
151
- }, sinceDate);
152
- default:
153
- return null;
154
- }
164
+ // ── Public API ────────────────────────────────────────────────────────────────
165
+ /**
166
+ * Wait for an email and return its OTP code or magic link URL.
167
+ * Prefer this over waitForEmailOTP when the app may send passwordless magic links.
168
+ */
169
+ export async function waitForEmailContent(config, since) {
170
+ const sinceStr = typeof since === 'string' ? since : since.toISOString();
171
+ const sinceDate = typeof since === 'string' ? new Date(since) : since;
172
+ switch (config.provider) {
173
+ case 'mailslurp':
174
+ if (!config.apiKey || !config.inboxId)
175
+ throw new Error('mailslurp requires apiKey + inboxId');
176
+ return waitForContentMailSlurp(config.apiKey, config.inboxId, sinceStr);
177
+ case 'mailpit':
178
+ return waitForContentMailpit(config.mailpitBaseUrl ?? 'http://mailpit:8025', sinceDate);
179
+ case 'imap':
180
+ if (!config.imapHost || !config.imapUser || !config.imapPass) {
181
+ throw new Error('imap requires imapHost, imapUser, imapPass');
182
+ }
183
+ return waitForContentImap({
184
+ host: config.imapHost,
185
+ port: config.imapPort ?? 993,
186
+ user: config.imapUser,
187
+ pass: config.imapPass,
188
+ tls: config.imapTls !== false,
189
+ }, sinceDate);
190
+ default:
191
+ return null;
192
+ }
155
193
  }
156
- async function waitForEmailOTP(config, since) {
157
- const content = await waitForEmailContent(config, since);
158
- return content?.otp ?? null;
194
+ /**
195
+ * Wait for an OTP email and extract the code.
196
+ * @deprecated Use waitForEmailContent which also handles magic links.
197
+ */
198
+ export async function waitForEmailOTP(config, since) {
199
+ const content = await waitForEmailContent(config, since);
200
+ return content?.otp ?? null;
159
201
  }
160
- async function getInboxEmail(configOrApiKey, inboxId) {
161
- if (typeof configOrApiKey === "string") {
162
- return getInboxEmailMailSlurp(configOrApiKey, inboxId ?? "");
163
- }
164
- if (configOrApiKey.provider === "mailslurp" && configOrApiKey.apiKey && configOrApiKey.inboxId) {
165
- return getInboxEmailMailSlurp(configOrApiKey.apiKey, configOrApiKey.inboxId);
166
- }
167
- if (configOrApiKey.provider === "imap") {
168
- return configOrApiKey.imapUser ?? null;
169
- }
170
- return null;
202
+ /**
203
+ * Get the email address for a MailSlurp inbox (used to display in UI).
204
+ * Returns null for IMAP/Mailpit providers — use imapUser directly.
205
+ */
206
+ export async function getInboxEmail(configOrApiKey, inboxId) {
207
+ // Legacy call: getInboxEmail(apiKey, inboxId)
208
+ if (typeof configOrApiKey === 'string') {
209
+ return getInboxEmailMailSlurp(configOrApiKey, inboxId ?? '');
210
+ }
211
+ if (configOrApiKey.provider === 'mailslurp' && configOrApiKey.apiKey && configOrApiKey.inboxId) {
212
+ return getInboxEmailMailSlurp(configOrApiKey.apiKey, configOrApiKey.inboxId);
213
+ }
214
+ if (configOrApiKey.provider === 'imap') {
215
+ return configOrApiKey.imapUser ?? null;
216
+ }
217
+ return null;
171
218
  }
172
- export {
173
- getInboxEmail,
174
- waitForEmailContent,
175
- waitForEmailOTP
176
- };
177
- //# sourceMappingURL=email-otp.js.map
@@ -1,94 +1,105 @@
1
- class RenderingTypePredictor {
2
- cache = /* @__PURE__ */ new Map();
3
- /** Return cached decision if available, undefined if unknown. */
4
- getCached(url) {
5
- try {
6
- return this.cache.get(new URL(url).hostname);
7
- } catch {
8
- return void 0;
9
- }
10
- }
11
- /**
12
- * Analyze an HTTP response body to predict if JS rendering is required.
13
- * Returns 'browser' if JS-heavy signals detected, 'http' otherwise.
14
- */
15
- predict(url, htmlBody, statusCode) {
16
- let decision = "http";
17
- try {
18
- if (statusCode >= 400) {
19
- decision = "browser";
20
- } else {
21
- const bodyLen = htmlBody.length;
22
- const textOnly = htmlBody.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
23
- const wordCount = textOnly.split(/\s+/).filter((w) => w.length > 2).length;
24
- const hasSpaRoot = /<div[^>]*\s+id=["'](?:root|app|__nuxt|__next|app-root)["'][^>]*>\s*<\/div>/i.test(htmlBody) || /<app-root[^>]*>\s*<\/app-root>/i.test(htmlBody);
25
- const hasNoscriptFallback = /<noscript[^>]*>[\s\S]{100,}/i.test(htmlBody);
26
- const tinyBody = bodyLen < 3e3 && wordCount < 50;
27
- if (hasSpaRoot && wordCount < 100 || tinyBody && !hasNoscriptFallback) {
28
- decision = "browser";
1
+ /**
2
+ * Per-domain rendering predictor — learns whether a domain requires a full browser
3
+ * or can be served by the fast HTTP engine. Implements adaptive crawlee-python pattern.
4
+ *
5
+ * Strategy:
6
+ * 1. On first encounter of a domain, classify the HTTP response body.
7
+ * 2. Store the decision in memory (Map). All future URLs on same domain skip re-testing.
8
+ * 3. "Requires browser" signals: React/Vue/Angular root with no content, tiny body (<500 chars meaningful text).
9
+ */
10
+ export class RenderingTypePredictor {
11
+ cache = new Map();
12
+ /** Return cached decision if available, undefined if unknown. */
13
+ getCached(url) {
14
+ try {
15
+ return this.cache.get(new URL(url).hostname);
16
+ }
17
+ catch {
18
+ return undefined;
29
19
  }
30
- }
31
- } catch {
32
20
  }
33
- try {
34
- this.cache.set(new URL(url).hostname, decision);
35
- } catch {
21
+ /**
22
+ * Analyze an HTTP response body to predict if JS rendering is required.
23
+ * Returns 'browser' if JS-heavy signals detected, 'http' otherwise.
24
+ */
25
+ predict(url, htmlBody, statusCode) {
26
+ let decision = 'http';
27
+ try {
28
+ if (statusCode >= 400) {
29
+ decision = 'browser'; // let browser handle error pages
30
+ }
31
+ else {
32
+ const bodyLen = htmlBody.length;
33
+ // Strip tags to get raw text
34
+ const textOnly = htmlBody.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
35
+ const wordCount = textOnly.split(/\s+/).filter(w => w.length > 2).length;
36
+ // SPA signals: very short text, presence of React/Vue/Angular root markers
37
+ const hasSpaRoot = /<div[^>]*\s+id=["'](?:root|app|__nuxt|__next|app-root)["'][^>]*>\s*<\/div>/i.test(htmlBody)
38
+ || /<app-root[^>]*>\s*<\/app-root>/i.test(htmlBody);
39
+ const hasNoscriptFallback = /<noscript[^>]*>[\s\S]{100,}/i.test(htmlBody);
40
+ const tinyBody = bodyLen < 3000 && wordCount < 50;
41
+ if ((hasSpaRoot && wordCount < 100) || (tinyBody && !hasNoscriptFallback)) {
42
+ decision = 'browser';
43
+ }
44
+ }
45
+ }
46
+ catch { /* default http */ }
47
+ try {
48
+ this.cache.set(new URL(url).hostname, decision);
49
+ }
50
+ catch { /* ignore */ }
51
+ return decision;
36
52
  }
37
- return decision;
38
- }
39
- /** Force a domain to use a specific engine (e.g. after observing crawl errors). */
40
- set(url, engine) {
41
- try {
42
- this.cache.set(new URL(url).hostname, engine);
43
- } catch {
53
+ /** Force a domain to use a specific engine (e.g. after observing crawl errors). */
54
+ set(url, engine) {
55
+ try {
56
+ this.cache.set(new URL(url).hostname, engine);
57
+ }
58
+ catch { /* ignore */ }
44
59
  }
45
- }
46
- stats() {
47
- const browserDomains = [];
48
- const httpDomains = [];
49
- for (const [domain, engine] of this.cache) {
50
- if (engine === "browser") browserDomains.push(domain);
51
- else httpDomains.push(domain);
60
+ stats() {
61
+ const browserDomains = [];
62
+ const httpDomains = [];
63
+ for (const [domain, engine] of this.cache) {
64
+ if (engine === 'browser')
65
+ browserDomains.push(domain);
66
+ else
67
+ httpDomains.push(domain);
68
+ }
69
+ return { cached: this.cache.size, browserDomains, httpDomains };
52
70
  }
53
- return { cached: this.cache.size, browserDomains, httpDomains };
54
- }
55
71
  }
56
- const renderingPredictor = new RenderingTypePredictor();
57
- function chooseCrawlEngines(input) {
58
- const engines = [];
59
- const reasons = [];
60
- const appType = input.appType?.toLowerCase();
61
- if (appType === "android" || appType === "ios" || appType === "windows" || appType === "macos") {
62
- return { engines: ["native"], reasons: [`appType=${input.appType}`], confidence: 0.95 };
63
- }
64
- if (input.hasStorageState || input.hasAuthToken) {
65
- engines.push("recorded-session");
66
- reasons.push("session/token auth available");
67
- }
68
- engines.push(input.hasProxy ? "browser-stealth" : "browser");
69
- reasons.push(input.hasProxy ? "proxy configured; prefer stealth browser path" : "default browser path");
70
- if (input.hasCredentials || input.profile.industry !== "unknown") {
71
- engines.push("stagehand");
72
- reasons.push(input.hasCredentials ? "credentials available for AI login/navigation" : `domain profile=${input.profile.industry}`);
73
- }
74
- const hardUi = input.profile.industry === "legacy-enterprise" || /captcha|blocked|canvas|iframe|bot/i.test(input.previousFailureReason ?? "");
75
- if (input.hasSkyvern && hardUi) {
76
- engines.push("vision");
77
- reasons.push("hard UI or previous block detected; vision fallback enabled");
78
- }
79
- if (engines.length === 1) {
80
- engines.unshift("http");
81
- reasons.push("HTTP/static extraction can be attempted before browser escalation");
82
- }
83
- return {
84
- engines: Array.from(new Set(engines)),
85
- reasons,
86
- confidence: input.profile.confidence
87
- };
72
+ /** Singleton predictor — shared across crawl jobs in the same process. */
73
+ export const renderingPredictor = new RenderingTypePredictor();
74
+ export function chooseCrawlEngines(input) {
75
+ const engines = [];
76
+ const reasons = [];
77
+ const appType = input.appType?.toLowerCase();
78
+ if (appType === 'android' || appType === 'ios' || appType === 'windows' || appType === 'macos') {
79
+ return { engines: ['native'], reasons: [`appType=${input.appType}`], confidence: 0.95 };
80
+ }
81
+ if (input.hasStorageState || input.hasAuthToken) {
82
+ engines.push('recorded-session');
83
+ reasons.push('session/token auth available');
84
+ }
85
+ engines.push(input.hasProxy ? 'browser-stealth' : 'browser');
86
+ reasons.push(input.hasProxy ? 'proxy configured; prefer stealth browser path' : 'default browser path');
87
+ if (input.hasCredentials || input.profile.industry !== 'unknown') {
88
+ engines.push('stagehand');
89
+ reasons.push(input.hasCredentials ? 'credentials available for AI login/navigation' : `domain profile=${input.profile.industry}`);
90
+ }
91
+ const hardUi = input.profile.industry === 'legacy-enterprise' || /captcha|blocked|canvas|iframe|bot/i.test(input.previousFailureReason ?? '');
92
+ if (input.hasSkyvern && hardUi) {
93
+ engines.push('vision');
94
+ reasons.push('hard UI or previous block detected; vision fallback enabled');
95
+ }
96
+ if (engines.length === 1) {
97
+ engines.unshift('http');
98
+ reasons.push('HTTP/static extraction can be attempted before browser escalation');
99
+ }
100
+ return {
101
+ engines: Array.from(new Set(engines)),
102
+ reasons,
103
+ confidence: input.profile.confidence,
104
+ };
88
105
  }
89
- export {
90
- RenderingTypePredictor,
91
- chooseCrawlEngines,
92
- renderingPredictor
93
- };
94
- //# sourceMappingURL=engine-router.js.map