@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.
- package/dist/accessibility-auditor.js +74 -54
- package/dist/ai-login.js +249 -227
- package/dist/auth-token-rotator.js +67 -57
- package/dist/auth.js +336 -299
- package/dist/browser-extension.js +142 -104
- package/dist/captcha-solver.js +125 -98
- package/dist/color-contrast.js +89 -77
- package/dist/content-chunker.js +126 -126
- package/dist/content-dedup.js +71 -54
- package/dist/cookie-consent.js +83 -68
- package/dist/cookie-jar.js +60 -53
- package/dist/crawler.js +5345 -4486
- package/dist/custom-js-injector.js +61 -45
- package/dist/dark-mode-capture.js +81 -67
- package/dist/dead-link-detector.js +55 -47
- package/dist/element-hash.js +12 -8
- package/dist/email-otp.js +195 -154
- package/dist/engine-router.js +98 -87
- package/dist/export/bulk-export.js +55 -70
- package/dist/export/vector-export.js +105 -77
- package/dist/extractors/advanced.js +266 -249
- package/dist/extractors/artifact-envelope.js +58 -59
- package/dist/failure-analysis-agent.js +33 -37
- package/dist/feature-flag-detector.js +66 -51
- package/dist/form-detector.js +75 -55
- package/dist/frontier.js +101 -76
- package/dist/gap-pass.js +89 -68
- package/dist/graphql-detector.js +72 -62
- package/dist/http-engine.js +330 -282
- package/dist/incremental-diff.js +87 -54
- package/dist/index.js +28 -67
- package/dist/input-fuzzer.js +97 -93
- package/dist/interactive-probe.js +827 -715
- package/dist/job-queue.js +2169 -2151
- package/dist/js-coverage.js +56 -52
- package/dist/js-router-detector.js +117 -86
- package/dist/language-detector.js +63 -51
- package/dist/link-classifier.js +50 -32
- package/dist/live-view.js +108 -91
- package/dist/locator-extractor.js +187 -151
- package/dist/logger.js +7 -9
- package/dist/markdown-extract.js +61 -48
- package/dist/native-crawler.js +946 -780
- package/dist/network-observer.js +156 -135
- package/dist/network-throttle.js +40 -29
- package/dist/openapi-generator.js +92 -77
- package/dist/pdf-extractor.js +90 -72
- package/dist/performance-metrics.js +102 -80
- package/dist/phash.js +144 -125
- package/dist/pii-scrubber.js +68 -65
- package/dist/playwright-recorder.js +58 -70
- package/dist/policies/crawl-safety.js +34 -31
- package/dist/profiles/domain-profile.js +125 -130
- package/dist/proxy.js +172 -146
- package/dist/rate-limiter.js +65 -58
- package/dist/redirect-tracker.js +64 -60
- package/dist/redis-frontier.js +136 -116
- package/dist/redis-queue.js +144 -157
- package/dist/response-cache.js +140 -127
- package/dist/responsive-detector.js +46 -42
- package/dist/retry-policy.js +50 -46
- package/dist/robots-txt.js +83 -69
- package/dist/screen-reader.js +70 -45
- package/dist/screenshot-masker.js +77 -59
- package/dist/seo-checker.js +107 -88
- package/dist/session-capture.js +212 -192
- package/dist/session-export.js +133 -107
- package/dist/sitemap-writer.js +41 -38
- package/dist/sitemap.js +202 -141
- package/dist/skyvern.js +148 -123
- package/dist/sqs-queue.js +42 -42
- package/dist/sso-token.js +90 -74
- package/dist/stealth/human-behavior.js +173 -156
- package/dist/telemetry.js +82 -68
- package/dist/test-executor.js +335 -309
- package/dist/tls-fingerprint.js +88 -77
- package/dist/url-pattern-completer.js +90 -71
- package/dist/visual-diff-verdict.js +38 -23
- package/dist/webhook-notifier.js +69 -71
- package/dist/websocket-detector.js +68 -56
- package/dist/worker.js +38 -29
- package/package.json +1 -1
package/dist/email-otp.js
CHANGED
|
@@ -1,177 +1,218 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
51
|
+
// ── MailSlurp ────────────────────────────────────────────────────────────────
|
|
52
|
+
const MAILSLURP_BASE = 'https://api.mailslurp.com';
|
|
34
53
|
async function waitForContentMailSlurp(apiKey, inboxId, since) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
package/dist/engine-router.js
CHANGED
|
@@ -1,94 +1,105 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
engines.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|