@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
|
@@ -1,41 +1,37 @@
|
|
|
1
|
-
import { readFileSync } from
|
|
2
|
-
import { askTrackedWithImages, askTracked, deterministicSeed } from
|
|
3
|
-
import { AppBrain } from
|
|
4
|
-
async function analyzeFailure(ctx) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { askTrackedWithImages, askTracked, deterministicSeed } from '@detiq/agents';
|
|
3
|
+
import { AppBrain } from '@detiq/app-brain';
|
|
4
|
+
export async function analyzeFailure(ctx) {
|
|
5
|
+
try {
|
|
6
|
+
let llmCfg;
|
|
7
|
+
if (ctx.tenantId) {
|
|
8
|
+
llmCfg = await AppBrain.getTaskLLMConfig(ctx.tenantId, 'testRunner').catch(() => undefined) ?? undefined;
|
|
9
|
+
}
|
|
10
|
+
if (!llmCfg)
|
|
11
|
+
return null;
|
|
12
|
+
const consoleSection = ctx.consoleLogs?.length
|
|
13
|
+
? `\n\nBrowser console (last ${ctx.consoleLogs.length} entries):\n${ctx.consoleLogs.map(l => `[${l.type}] ${l.text}`).join('\n')}`
|
|
14
|
+
: '';
|
|
15
|
+
const networkSection = ctx.networkErrors?.length
|
|
16
|
+
? `\n\nFailed network requests:\n${ctx.networkErrors.map(n => `${n.method} ${n.url} → ${n.status}`).join('\n')}`
|
|
17
|
+
: '';
|
|
18
|
+
const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific — name the failing element, API endpoint, or error type. Do not suggest fixes.
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
${ctx.networkErrors.map((n) => `${n.method} ${n.url} \u2192 ${n.status}`).join("\n")}` : "";
|
|
19
|
-
const userText = `A Playwright automated test failed. Identify the root cause in 2-3 sentences. Be specific \u2014 name the failing element, API endpoint, or error type. Do not suggest fixes.
|
|
20
|
-
|
|
21
|
-
Test step: ${ctx.stepDescription ?? "unknown"}
|
|
20
|
+
Test step: ${ctx.stepDescription ?? 'unknown'}
|
|
22
21
|
Error: ${ctx.error}${consoleSection}${networkSection}`;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
if (ctx.screenshotPath) {
|
|
23
|
+
try {
|
|
24
|
+
const img = readFileSync(ctx.screenshotPath);
|
|
25
|
+
const result = await askTrackedWithImages('', userText, [{ base64: img.toString('base64'), mediaType: 'image/png' }], llmCfg);
|
|
26
|
+
return result.text || null;
|
|
27
|
+
}
|
|
28
|
+
catch { /* fall through to text-only */ }
|
|
29
|
+
}
|
|
30
|
+
const failureSeed = deterministicSeed(`${ctx.tenantId ?? ''}:failure:${(ctx.stepDescription ?? '').slice(0, 100)}:${ctx.error.slice(0, 200)}`);
|
|
31
|
+
const result = await askTracked('', userText, llmCfg, 256, undefined, { temperature: 0, seed: failureSeed });
|
|
32
|
+
return result.text || null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
30
36
|
}
|
|
31
|
-
const failureSeed = deterministicSeed(`${ctx.tenantId ?? ""}:failure:${(ctx.stepDescription ?? "").slice(0, 100)}:${ctx.error.slice(0, 200)}`);
|
|
32
|
-
const result = await askTracked("", userText, llmCfg, 256, void 0, { temperature: 0, seed: failureSeed });
|
|
33
|
-
return result.text || null;
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
37
|
}
|
|
38
|
-
export {
|
|
39
|
-
analyzeFailure
|
|
40
|
-
};
|
|
41
|
-
//# sourceMappingURL=failure-analysis-agent.js.map
|
|
@@ -1,55 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flag + A/B test detector.
|
|
3
|
+
*
|
|
4
|
+
* Detects popular feature flag / experiment SDKs loaded on the page:
|
|
5
|
+
* LaunchDarkly, Optimizely, Split.io, Unleash, GrowthBook, Statsig, Flagsmith, Amplitude Experiment.
|
|
6
|
+
*
|
|
7
|
+
* Also detects common A/B test patterns: data-testid attributes, gtm.js experiment layers,
|
|
8
|
+
* Optimizely variation IDs, VWO, etc.
|
|
9
|
+
*
|
|
10
|
+
* Why: ZeTa needs to know when a page shows different content to different users
|
|
11
|
+
* so test generation can account for variants and not generate flaky tests.
|
|
12
|
+
*/
|
|
13
|
+
// SDK detectors — checked against window object + script URLs
|
|
1
14
|
const FLAG_SDK_CHECKS = [
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
{ sdk: 'LaunchDarkly', windowKeys: ['LDClient', 'LaunchDarkly', 'ldClient'], scriptPattern: /launchdarkly/i },
|
|
16
|
+
{ sdk: 'Optimizely', windowKeys: ['optimizely', 'optimizelySdk'], scriptPattern: /optimizely/i },
|
|
17
|
+
{ sdk: 'Split.io', windowKeys: ['splitio', 'SplitFactory'], scriptPattern: /split\.io/i },
|
|
18
|
+
{ sdk: 'GrowthBook', windowKeys: ['growthbook', 'GrowthBook'], scriptPattern: /growthbook/i },
|
|
19
|
+
{ sdk: 'Statsig', windowKeys: ['statsig', 'StatsigClient'], scriptPattern: /statsig/i },
|
|
20
|
+
{ sdk: 'Flagsmith', windowKeys: ['flagsmith'], scriptPattern: /flagsmith/i },
|
|
21
|
+
{ sdk: 'Unleash', windowKeys: ['unleash', 'UnleashClient'], scriptPattern: /unleash/i },
|
|
22
|
+
{ sdk: 'VWO', windowKeys: ['vwo', '_vwo'], scriptPattern: /vwo\.com/i },
|
|
23
|
+
{ sdk: 'Google Optimize', windowKeys: ['gaData', 'google_optimize'], scriptPattern: /googleoptimize/i },
|
|
24
|
+
{ sdk: 'AB Tasty', windowKeys: ['ABTasty'], scriptPattern: /abtasty/i },
|
|
12
25
|
];
|
|
13
|
-
async function detectFeatureFlags(page, url) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
export async function detectFeatureFlags(page, url) {
|
|
27
|
+
const detectedAt = new Date().toISOString();
|
|
28
|
+
const result = await page.evaluate((checks) => {
|
|
29
|
+
const flagSdks = [];
|
|
30
|
+
const scripts = Array.from(document.querySelectorAll('script[src]')).map((s) => s.src ?? '');
|
|
31
|
+
for (const check of checks) {
|
|
32
|
+
let found = false;
|
|
33
|
+
let confidence = 'low';
|
|
34
|
+
// Check window keys
|
|
35
|
+
if (check.windowKeys?.some((k) => k in window)) {
|
|
36
|
+
found = true;
|
|
37
|
+
confidence = 'high';
|
|
38
|
+
}
|
|
39
|
+
// Check script URLs
|
|
40
|
+
if (!found && check.scriptPattern) {
|
|
41
|
+
const re = new RegExp(check.scriptPattern.source, 'i');
|
|
42
|
+
const matchingSrc = scripts.find(s => re.test(s));
|
|
43
|
+
if (matchingSrc) {
|
|
44
|
+
found = true;
|
|
45
|
+
confidence = 'medium';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (found)
|
|
49
|
+
flagSdks.push({ sdk: check.sdk, confidence, details: undefined });
|
|
31
50
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
// A/B tools
|
|
52
|
+
const abTools = [];
|
|
53
|
+
if ('_vwo_code' in window || '_vis_opt_queue' in window)
|
|
54
|
+
abTools.push({ tool: 'VWO', experiments: [] });
|
|
55
|
+
if ('dataLayer' in window) {
|
|
56
|
+
const dl = window.dataLayer ?? [];
|
|
57
|
+
const expEvents = dl.filter((e) => e?.event?.includes('experiment') || e?.experimentId);
|
|
58
|
+
if (expEvents.length > 0)
|
|
59
|
+
abTools.push({ tool: 'Google Tag Manager Experiments', experiments: expEvents.slice(0, 5).map((e) => e.experimentId ?? '') });
|
|
60
|
+
}
|
|
61
|
+
return { flagSdks, abTools };
|
|
62
|
+
}, FLAG_SDK_CHECKS).catch(() => ({ flagSdks: [], abTools: [] }));
|
|
63
|
+
return {
|
|
64
|
+
url,
|
|
65
|
+
flagSdks: result.flagSdks,
|
|
66
|
+
abTools: result.abTools,
|
|
67
|
+
hasAnyFlags: result.flagSdks.length > 0 || result.abTools.length > 0,
|
|
68
|
+
detectedAt,
|
|
69
|
+
};
|
|
51
70
|
}
|
|
52
|
-
export {
|
|
53
|
-
detectFeatureFlags
|
|
54
|
-
};
|
|
55
|
-
//# sourceMappingURL=feature-flag-detector.js.map
|
package/dist/form-detector.js
CHANGED
|
@@ -1,58 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form detector — extract form metadata from crawled pages.
|
|
3
|
+
*
|
|
4
|
+
* For each form found on a page, captures: action URL, method, all input fields
|
|
5
|
+
* with their types/names/required/validation constraints, and submit button text.
|
|
6
|
+
*
|
|
7
|
+
* This data feeds ZeTa's test generator to create form validation test cases
|
|
8
|
+
* automatically (happy path + boundary/error cases per field type).
|
|
9
|
+
*/
|
|
1
10
|
function guessPurpose(fields, action) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const names = fields.map(f => f.name?.toLowerCase() + (f.label ?? '').toLowerCase()).join(' ');
|
|
12
|
+
if (/password/.test(names) && /email|username|user/.test(names) && fields.length <= 4)
|
|
13
|
+
return 'login';
|
|
14
|
+
if (/password/.test(names) && /confirm|repeat/.test(names))
|
|
15
|
+
return 'registration';
|
|
16
|
+
if (/search|query|q\b/.test(names) && fields.length <= 3)
|
|
17
|
+
return 'search';
|
|
18
|
+
if (/card|payment|cvv|expir/.test(names))
|
|
19
|
+
return 'checkout';
|
|
20
|
+
if (/message|subject|contact/.test(names))
|
|
21
|
+
return 'contact';
|
|
22
|
+
if (/setting|preference|profile/.test(names))
|
|
23
|
+
return 'settings';
|
|
24
|
+
return 'unknown';
|
|
10
25
|
}
|
|
11
|
-
async function detectForms(page) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
26
|
+
export async function detectForms(page) {
|
|
27
|
+
try {
|
|
28
|
+
return await page.$$eval('form', (forms) => forms.map((form, index) => {
|
|
29
|
+
const inputs = Array.from(form.querySelectorAll('input,select,textarea,button[type="submit"]'));
|
|
30
|
+
const fields = [];
|
|
31
|
+
for (const input of inputs) {
|
|
32
|
+
const tag = input.tagName.toLowerCase();
|
|
33
|
+
const type = tag === 'button' ? 'submit' : (input.type || tag);
|
|
34
|
+
if (['submit', 'reset', 'button', 'hidden'].includes(type))
|
|
35
|
+
continue;
|
|
36
|
+
// Find associated label
|
|
37
|
+
let label = '';
|
|
38
|
+
if (input.id) {
|
|
39
|
+
const lbl = form.querySelector(`label[for="${input.id}"]`);
|
|
40
|
+
if (lbl)
|
|
41
|
+
label = (lbl.textContent ?? '').trim();
|
|
42
|
+
}
|
|
43
|
+
if (!label) {
|
|
44
|
+
const parent = input.closest('label');
|
|
45
|
+
if (parent)
|
|
46
|
+
label = (parent.textContent ?? '').replace(input.value ?? '', '').trim().slice(0, 80);
|
|
47
|
+
}
|
|
48
|
+
const options = tag === 'select'
|
|
49
|
+
? Array.from(input.options ?? []).map((o) => o.text?.trim()).filter(Boolean).slice(0, 20)
|
|
50
|
+
: tag === 'input' && type === 'radio'
|
|
51
|
+
? [input.value]
|
|
52
|
+
: undefined;
|
|
53
|
+
fields.push({
|
|
54
|
+
name: input.name || input.id || '',
|
|
55
|
+
type,
|
|
56
|
+
label: label || undefined,
|
|
57
|
+
required: !!input.required,
|
|
58
|
+
placeholder: input.placeholder || undefined,
|
|
59
|
+
minLength: input.minLength > 0 ? input.minLength : undefined,
|
|
60
|
+
maxLength: input.maxLength > 0 && input.maxLength < 524288 ? input.maxLength : undefined,
|
|
61
|
+
min: input.min || undefined,
|
|
62
|
+
max: input.max || undefined,
|
|
63
|
+
pattern: input.pattern || undefined,
|
|
64
|
+
options,
|
|
65
|
+
autocomplete: input.autocomplete || undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const submitBtn = form.querySelector('button[type="submit"],input[type="submit"]');
|
|
69
|
+
const hasFileUpload = !!form.querySelector('input[type="file"]');
|
|
70
|
+
const action = form.action || undefined;
|
|
71
|
+
const method = (form.method || 'get').toUpperCase();
|
|
72
|
+
return { index, action, method, fields, submitText: submitBtn?.textContent?.trim() || submitBtn?.value || undefined, hasFileUpload };
|
|
73
|
+
})).then(forms => forms.map((f) => ({ ...f, purpose: guessPurpose(f.fields, f.action) })));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
54
78
|
}
|
|
55
|
-
export {
|
|
56
|
-
detectForms
|
|
57
|
-
};
|
|
58
|
-
//# sourceMappingURL=form-detector.js.map
|
package/dist/frontier.js
CHANGED
|
@@ -1,83 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable crawl traversal strategy — matches crawl4AI's BFS/DFS/
|
|
3
|
+
* Best-First deep-crawl strategies (ZeTa previously had a single hardcoded
|
|
4
|
+
* FIFO queue, i.e. always BFS).
|
|
5
|
+
*
|
|
6
|
+
* Array-like on purpose (`length`, `shift()`, `push()`, `filter()`) so it
|
|
7
|
+
* drops into crawler.ts's existing queue call sites with a rename, not a
|
|
8
|
+
* rewrite — BFS's `shift()` behavior is byte-for-byte the same as the plain
|
|
9
|
+
* array it replaces, so BFS (the default) has zero behavior change.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Best-First scoring heuristic: shallower paths score higher (breadth-like
|
|
13
|
+
* bias toward top-level sections before deep sub-pages), boosted further by
|
|
14
|
+
* any user-supplied keyword appearing in the URL. Simple and transparent by
|
|
15
|
+
* design — crawl4AI's own default scorer is a similarly simple keyword-
|
|
16
|
+
* relevance heuristic, not a learned model.
|
|
17
|
+
*/
|
|
1
18
|
function scoreUrl(url, keywords) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
19
|
+
try {
|
|
20
|
+
const u = new URL(url);
|
|
21
|
+
const segments = u.pathname.split('/').filter(Boolean);
|
|
22
|
+
let score = -segments.length;
|
|
23
|
+
if (keywords.length > 0) {
|
|
24
|
+
const lower = url.toLowerCase();
|
|
25
|
+
for (const kw of keywords) {
|
|
26
|
+
if (kw && lower.includes(kw))
|
|
27
|
+
score += 10;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return score;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 0;
|
|
11
34
|
}
|
|
12
|
-
return score;
|
|
13
|
-
} catch {
|
|
14
|
-
return 0;
|
|
15
|
-
}
|
|
16
35
|
}
|
|
17
|
-
class CrawlFrontier {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
36
|
+
export class CrawlFrontier {
|
|
37
|
+
items = [];
|
|
38
|
+
strategy;
|
|
39
|
+
keywords;
|
|
40
|
+
urlFilter;
|
|
41
|
+
constructor(strategy, seedUrls, keywords = [], urlFilter) {
|
|
42
|
+
this.strategy = strategy;
|
|
43
|
+
this.keywords = keywords.map((k) => k.toLowerCase()).filter(Boolean);
|
|
44
|
+
this.urlFilter = urlFilter
|
|
45
|
+
? (() => { try {
|
|
46
|
+
return new RegExp(urlFilter, 'i');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
} })()
|
|
51
|
+
: null;
|
|
52
|
+
for (const url of seedUrls)
|
|
53
|
+
this.push(url);
|
|
54
|
+
}
|
|
55
|
+
get length() {
|
|
56
|
+
return this.items.length;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* P3b: sparse-parent boost — if the parent page had fewer than 5 interactive
|
|
60
|
+
* elements (thin/canvas page), its child URLs score +1 in BEST_FIRST mode so
|
|
61
|
+
* the frontier revisits sparse branches before deep-linked content.
|
|
62
|
+
*/
|
|
63
|
+
push(url, pushOpts) {
|
|
64
|
+
if (this.urlFilter && !this.urlFilter.test(url))
|
|
65
|
+
return;
|
|
66
|
+
const sparseBoost = this.strategy === 'BEST_FIRST' &&
|
|
67
|
+
pushOpts?.parentElements !== undefined &&
|
|
68
|
+
pushOpts.parentElements < 5
|
|
69
|
+
? 1
|
|
70
|
+
: 0;
|
|
71
|
+
this.items.push({ url, score: this.strategy === 'BEST_FIRST' ? scoreUrl(url, this.keywords) + sparseBoost : 0 });
|
|
72
|
+
}
|
|
73
|
+
/** Dequeues the next URL per this frontier's strategy. undefined if empty. */
|
|
74
|
+
shift() {
|
|
75
|
+
if (this.items.length === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
switch (this.strategy) {
|
|
78
|
+
case 'DFS':
|
|
79
|
+
return this.items.pop().url;
|
|
80
|
+
case 'BEST_FIRST': {
|
|
81
|
+
let bestIdx = 0;
|
|
82
|
+
for (let i = 1; i < this.items.length; i++) {
|
|
83
|
+
if (this.items[i].score > this.items[bestIdx].score)
|
|
84
|
+
bestIdx = i;
|
|
85
|
+
}
|
|
86
|
+
return this.items.splice(bestIdx, 1)[0].url;
|
|
87
|
+
}
|
|
88
|
+
case 'BFS':
|
|
89
|
+
default:
|
|
90
|
+
return this.items.shift().url;
|
|
57
91
|
}
|
|
58
|
-
return this.items.splice(bestIdx, 1)[0].url;
|
|
59
|
-
}
|
|
60
|
-
case "BFS":
|
|
61
|
-
default:
|
|
62
|
-
return this.items.shift().url;
|
|
63
92
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
filter(predicate) {
|
|
94
|
+
return this.items.filter((e) => predicate(e.url)).map((e) => e.url);
|
|
95
|
+
}
|
|
96
|
+
/** Remove queued URLs not matching the regex pattern. Returns count removed. */
|
|
97
|
+
filterByPattern(pattern) {
|
|
98
|
+
try {
|
|
99
|
+
const re = new RegExp(pattern, 'i');
|
|
100
|
+
const before = this.items.length;
|
|
101
|
+
this.items = this.items.filter((e) => re.test(e.url));
|
|
102
|
+
return before - this.items.length;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
77
107
|
}
|
|
78
|
-
}
|
|
79
108
|
}
|
|
80
|
-
export {
|
|
81
|
-
CrawlFrontier
|
|
82
|
-
};
|
|
83
|
-
//# sourceMappingURL=frontier.js.map
|