@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/color-contrast.js
CHANGED
|
@@ -1,78 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
1
|
+
/**
|
|
2
|
+
* WCAG color contrast checker.
|
|
3
|
+
*
|
|
4
|
+
* Evaluates text contrast ratios against backgrounds for all visible text elements.
|
|
5
|
+
* Reports elements that fail WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large text)
|
|
6
|
+
* or AAA (7:1 for normal, 4.5:1 for large text) requirements.
|
|
7
|
+
*
|
|
8
|
+
* Uses computedStyle to get actual rendered colors — accounts for CSS variables
|
|
9
|
+
* and inheritance.
|
|
10
|
+
*/
|
|
11
|
+
export async function auditColorContrast(page, url) {
|
|
12
|
+
const auditedAt = new Date().toISOString();
|
|
13
|
+
const violations = await page.evaluate(() => {
|
|
14
|
+
function parseRgb(color) {
|
|
15
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/);
|
|
16
|
+
return m ? [+m[1], +m[2], +m[3], m[4] !== undefined ? +m[4] : 1] : null;
|
|
17
|
+
}
|
|
18
|
+
function luminance([r, g, b]) {
|
|
19
|
+
const toLinear = (c) => {
|
|
20
|
+
const n = c / 255;
|
|
21
|
+
return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
|
|
22
|
+
};
|
|
23
|
+
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
|
24
|
+
}
|
|
25
|
+
function contrastRatio(fg, bg) {
|
|
26
|
+
const l1 = luminance(fg);
|
|
27
|
+
const l2 = luminance(bg);
|
|
28
|
+
const lighter = Math.max(l1, l2);
|
|
29
|
+
const darker = Math.min(l1, l2);
|
|
30
|
+
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
|
|
31
|
+
}
|
|
32
|
+
const violations = [];
|
|
33
|
+
const elements = Array.from(document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, a, span, li, td, th, label, button'));
|
|
34
|
+
let checked = 0;
|
|
35
|
+
for (const el of elements) {
|
|
36
|
+
if (checked >= 100)
|
|
37
|
+
break; // limit to avoid perf hit
|
|
38
|
+
const text = (el.textContent ?? '').trim().slice(0, 60);
|
|
39
|
+
if (!text)
|
|
40
|
+
continue;
|
|
41
|
+
const style = window.getComputedStyle(el);
|
|
42
|
+
if (style.display === 'none' || style.visibility === 'hidden')
|
|
43
|
+
continue;
|
|
44
|
+
const fg = parseRgb(style.color);
|
|
45
|
+
const bg = parseRgb(style.backgroundColor);
|
|
46
|
+
if (!fg || !bg || bg[3] === 0)
|
|
47
|
+
continue; // transparent bg — can't check
|
|
48
|
+
const fontSize = parseFloat(style.fontSize);
|
|
49
|
+
const fontWeight = style.fontWeight;
|
|
50
|
+
const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || +fontWeight >= 700));
|
|
51
|
+
const ratio = contrastRatio(fg, bg);
|
|
52
|
+
const aaRequired = isLargeText ? 3.0 : 4.5;
|
|
53
|
+
const aaaRequired = isLargeText ? 4.5 : 7.0;
|
|
54
|
+
if (ratio < aaRequired) {
|
|
55
|
+
violations.push({
|
|
56
|
+
selector: el.tagName.toLowerCase(),
|
|
57
|
+
text: text.slice(0, 50),
|
|
58
|
+
foreground: style.color,
|
|
59
|
+
background: style.backgroundColor,
|
|
60
|
+
ratio,
|
|
61
|
+
required: aaRequired,
|
|
62
|
+
level: 'AA',
|
|
63
|
+
isLargeText,
|
|
64
|
+
severity: 'fail',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (ratio < aaaRequired) {
|
|
68
|
+
violations.push({
|
|
69
|
+
selector: el.tagName.toLowerCase(),
|
|
70
|
+
text: text.slice(0, 50),
|
|
71
|
+
foreground: style.color,
|
|
72
|
+
background: style.backgroundColor,
|
|
73
|
+
ratio,
|
|
74
|
+
required: aaaRequired,
|
|
75
|
+
level: 'AAA',
|
|
76
|
+
isLargeText,
|
|
77
|
+
severity: 'warn',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
checked++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return violations;
|
|
85
|
+
}).catch(() => []);
|
|
86
|
+
const passCount = Math.max(0, 100 - violations.length);
|
|
87
|
+
const criticals = violations.filter(v => v.severity === 'fail').length;
|
|
88
|
+
const score = Math.max(0, 100 - criticals * 15 - violations.filter(v => v.severity === 'warn').length * 3);
|
|
89
|
+
return { url, violations, passCount, score, auditedAt };
|
|
74
90
|
}
|
|
75
|
-
export {
|
|
76
|
-
auditColorContrast
|
|
77
|
-
};
|
|
78
|
-
//# sourceMappingURL=color-contrast.js.map
|
package/dist/content-chunker.js
CHANGED
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
function estimateTokens(text) {
|
|
2
|
-
|
|
1
|
+
export function estimateTokens(text) {
|
|
2
|
+
return Math.ceil(text.length / 4);
|
|
3
3
|
}
|
|
4
|
-
function chunkContent(text, opts) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return markdownHeadingChunk(text, charSize);
|
|
20
|
-
case "semantic":
|
|
21
|
-
return semanticChunk(text, charSize);
|
|
22
|
-
case "recursive":
|
|
23
|
-
return recursiveChunk(text, charSize, opts.separators ?? ["\n\n", "\n", ". ", " "]);
|
|
24
|
-
default:
|
|
25
|
-
return fixedChunk(text, charSize);
|
|
26
|
-
}
|
|
4
|
+
export function chunkContent(text, opts) {
|
|
5
|
+
const size = opts.chunkSize ?? 2000;
|
|
6
|
+
const charSize = size * 4;
|
|
7
|
+
const overlap = opts.overlap ?? 200;
|
|
8
|
+
const overlapChars = overlap * 4;
|
|
9
|
+
switch (opts.strategy) {
|
|
10
|
+
case 'fixed': return fixedChunk(text, charSize);
|
|
11
|
+
case 'overlap': return overlapChunk(text, charSize, overlapChars);
|
|
12
|
+
case 'sentence': return sentenceChunk(text, charSize);
|
|
13
|
+
case 'paragraph': return paragraphChunk(text, charSize);
|
|
14
|
+
case 'markdown-heading': return markdownHeadingChunk(text, charSize);
|
|
15
|
+
case 'semantic': return semanticChunk(text, charSize);
|
|
16
|
+
case 'recursive': return recursiveChunk(text, charSize, opts.separators ?? ['\n\n', '\n', '. ', ' ']);
|
|
17
|
+
default: return fixedChunk(text, charSize);
|
|
18
|
+
}
|
|
27
19
|
}
|
|
28
20
|
function makeChunk(text, index, startChar, heading) {
|
|
29
|
-
|
|
21
|
+
return { index, text, tokenEstimate: estimateTokens(text), startChar, endChar: startChar + text.length, heading };
|
|
30
22
|
}
|
|
31
23
|
function fixedChunk(text, charSize) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let i = 0;
|
|
26
|
+
while (i < text.length) {
|
|
27
|
+
const slice = text.slice(i, i + charSize);
|
|
28
|
+
chunks.push(makeChunk(slice, chunks.length, i));
|
|
29
|
+
i += charSize;
|
|
30
|
+
}
|
|
31
|
+
return chunks;
|
|
40
32
|
}
|
|
41
33
|
function overlapChunk(text, charSize, overlapChars) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < text.length) {
|
|
37
|
+
const slice = text.slice(i, i + charSize);
|
|
38
|
+
chunks.push(makeChunk(slice, chunks.length, i));
|
|
39
|
+
i += charSize - overlapChars;
|
|
40
|
+
if (i <= 0)
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
return chunks;
|
|
51
44
|
}
|
|
52
45
|
function sentenceChunk(text, charSize) {
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
const sentences = text.match(/[^.!?]*[.!?]+["']?\s*/g) ?? [text];
|
|
47
|
+
return groupIntoChunks(sentences, charSize);
|
|
55
48
|
}
|
|
56
49
|
function paragraphChunk(text, charSize) {
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const paras = text.split(/\n\n+/).filter(p => p.trim());
|
|
51
|
+
return groupIntoChunks(paras, charSize);
|
|
59
52
|
}
|
|
60
53
|
function markdownHeadingChunk(text, charSize) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
54
|
+
const chunks = [];
|
|
55
|
+
const headingRe = /^(#{1,3}\s.+)$/m;
|
|
56
|
+
const sections = text.split(/(?=^#{1,3}\s)/m).filter(s => s.trim());
|
|
57
|
+
let pos = 0;
|
|
58
|
+
for (const section of sections) {
|
|
59
|
+
const headingMatch = section.match(headingRe);
|
|
60
|
+
const heading = headingMatch ? headingMatch[1].replace(/^#+\s*/, '').trim() : undefined;
|
|
61
|
+
if (section.length <= charSize) {
|
|
62
|
+
chunks.push({ ...makeChunk(section.trim(), chunks.length, pos), heading });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Section too large — sub-chunk by paragraph
|
|
66
|
+
const sub = paragraphChunk(section, charSize);
|
|
67
|
+
for (const s of sub) {
|
|
68
|
+
chunks.push({ ...s, index: chunks.length, startChar: pos + s.startChar, endChar: pos + s.endChar, heading });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
pos += section.length;
|
|
75
72
|
}
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
return chunks.length > 0 ? chunks : fixedChunk(text, charSize);
|
|
73
|
+
return chunks.length > 0 ? chunks : fixedChunk(text, charSize);
|
|
79
74
|
}
|
|
80
75
|
function semanticChunk(text, charSize) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
76
|
+
// Like paragraph but merges short paragraphs with the next
|
|
77
|
+
const paras = text.split(/\n\n+/).filter(p => p.trim());
|
|
78
|
+
const merged = [];
|
|
79
|
+
let current = '';
|
|
80
|
+
for (const para of paras) {
|
|
81
|
+
if (current.length + para.length + 2 <= charSize) {
|
|
82
|
+
current = current ? current + '\n\n' + para : para;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
if (current)
|
|
86
|
+
merged.push(current);
|
|
87
|
+
current = para;
|
|
88
|
+
}
|
|
90
89
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
if (current)
|
|
91
|
+
merged.push(current);
|
|
92
|
+
return groupIntoChunks(merged, charSize);
|
|
94
93
|
}
|
|
95
94
|
function recursiveChunk(text, charSize, separators) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
95
|
+
if (text.length <= charSize)
|
|
96
|
+
return [makeChunk(text, 0, 0)];
|
|
97
|
+
const [sep, ...rest] = separators;
|
|
98
|
+
if (!sep)
|
|
99
|
+
return fixedChunk(text, charSize);
|
|
100
|
+
const parts = text.split(sep).filter(p => p);
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let pos = 0;
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
if (part.length <= charSize) {
|
|
105
|
+
chunks.push(makeChunk(part, chunks.length, pos));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const sub = recursiveChunk(part, charSize, rest);
|
|
109
|
+
for (const s of sub)
|
|
110
|
+
chunks.push({ ...s, index: chunks.length, startChar: pos + s.startChar, endChar: pos + s.endChar });
|
|
111
|
+
}
|
|
112
|
+
pos += part.length + sep.length;
|
|
108
113
|
}
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
return chunks;
|
|
114
|
+
return chunks;
|
|
112
115
|
}
|
|
113
116
|
function groupIntoChunks(parts, charSize) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
117
|
+
const chunks = [];
|
|
118
|
+
let current = '';
|
|
119
|
+
let startChar = 0;
|
|
120
|
+
let pos = 0;
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
if (current.length + part.length + 1 > charSize && current) {
|
|
123
|
+
chunks.push(makeChunk(current.trim(), chunks.length, startChar));
|
|
124
|
+
startChar = pos;
|
|
125
|
+
current = part;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
current = current ? current + ' ' + part : part;
|
|
129
|
+
}
|
|
130
|
+
pos += part.length + 1;
|
|
125
131
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return chunks;
|
|
132
|
+
if (current.trim())
|
|
133
|
+
chunks.push(makeChunk(current.trim(), chunks.length, startChar));
|
|
134
|
+
return chunks;
|
|
130
135
|
}
|
|
131
|
-
function mergeSmallChunks(chunks, minTokens) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
136
|
+
export function mergeSmallChunks(chunks, minTokens) {
|
|
137
|
+
const minChars = minTokens * 4;
|
|
138
|
+
const result = [];
|
|
139
|
+
let i = 0;
|
|
140
|
+
while (i < chunks.length) {
|
|
141
|
+
if (chunks[i].text.length < minChars && i + 1 < chunks.length) {
|
|
142
|
+
const merged = chunks[i].text + '\n\n' + chunks[i + 1].text;
|
|
143
|
+
result.push(makeChunk(merged, result.length, chunks[i].startChar, chunks[i].heading ?? chunks[i + 1].heading));
|
|
144
|
+
i += 2;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
result.push({ ...chunks[i], index: result.length });
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
143
150
|
}
|
|
144
|
-
|
|
145
|
-
return result;
|
|
151
|
+
return result;
|
|
146
152
|
}
|
|
147
|
-
export {
|
|
148
|
-
chunkContent,
|
|
149
|
-
estimateTokens,
|
|
150
|
-
mergeSmallChunks
|
|
151
|
-
};
|
|
152
|
-
//# sourceMappingURL=content-chunker.js.map
|
package/dist/content-dedup.js
CHANGED
|
@@ -1,60 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content deduplication — detect near-duplicate pages during crawl.
|
|
3
|
+
*
|
|
4
|
+
* Uses MinHash-inspired approach: instead of full MinHash (needs many hash functions),
|
|
5
|
+
* we use 8 structural fingerprints extracted from the markdown to detect pages that
|
|
6
|
+
* share the same layout/template even if the data differs.
|
|
7
|
+
*
|
|
8
|
+
* Typical duplicates: /products?page=1 vs /products?page=2 (same template, different data).
|
|
9
|
+
* These waste crawl budget and generate redundant test cases.
|
|
10
|
+
*/
|
|
11
|
+
/** Extract structural fingerprints from markdown — layout-sensitive, data-insensitive */
|
|
1
12
|
function extractFingerprints(markdown) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
const lines = markdown.split('\n').filter(l => l.trim().length > 0);
|
|
14
|
+
const fps = [];
|
|
15
|
+
// FP1: heading structure (H1-H6 count distribution)
|
|
16
|
+
const headings = lines.filter(l => /^#{1,6} /.test(l));
|
|
17
|
+
const hCounts = [0, 0, 0, 0, 0, 0];
|
|
18
|
+
for (const h of headings) {
|
|
19
|
+
const lvl = h.match(/^(#{1,6}) /)?.[1].length ?? 1;
|
|
20
|
+
hCounts[lvl - 1]++;
|
|
21
|
+
}
|
|
22
|
+
fps.push('h:' + hCounts.join(','));
|
|
23
|
+
// FP2: link count (grouped into buckets)
|
|
24
|
+
const linkCount = (markdown.match(/\[.+?\]\(.+?\)/g) ?? []).length;
|
|
25
|
+
fps.push('lk:' + Math.floor(linkCount / 5));
|
|
26
|
+
// FP3: list item count (grouped)
|
|
27
|
+
const listCount = lines.filter(l => /^[\-\*\+] |^\d+\. /.test(l)).length;
|
|
28
|
+
fps.push('li:' + Math.floor(listCount / 3));
|
|
29
|
+
// FP4: code block presence
|
|
30
|
+
fps.push('code:' + (markdown.includes('```') ? '1' : '0'));
|
|
31
|
+
// FP5: table presence
|
|
32
|
+
fps.push('tbl:' + (markdown.includes('|') ? '1' : '0'));
|
|
33
|
+
// FP6: approximate paragraph count
|
|
34
|
+
const paraCount = markdown.split(/\n{2,}/).filter(p => p.trim().length > 30).length;
|
|
35
|
+
fps.push('p:' + Math.floor(paraCount / 2));
|
|
36
|
+
// FP7: first heading text (normalized)
|
|
37
|
+
const firstH = headings[0]?.replace(/^#{1,6} /, '').toLowerCase().replace(/[^a-z0-9]/g, '') ?? '';
|
|
38
|
+
fps.push('fh:' + firstH.slice(0, 20));
|
|
39
|
+
// FP8: word count bucket
|
|
40
|
+
const wordCount = markdown.split(/\s+/).length;
|
|
41
|
+
fps.push('wc:' + Math.floor(wordCount / 50));
|
|
42
|
+
return fps;
|
|
24
43
|
}
|
|
25
44
|
function similarity(a, b) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
if (a.length !== b.length)
|
|
46
|
+
return 0;
|
|
47
|
+
let matches = 0;
|
|
48
|
+
for (let i = 0; i < a.length; i++) {
|
|
49
|
+
if (a[i] === b[i])
|
|
50
|
+
matches++;
|
|
51
|
+
}
|
|
52
|
+
return matches / a.length;
|
|
32
53
|
}
|
|
33
|
-
class ContentDeduplicator {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
54
|
+
export class ContentDeduplicator {
|
|
55
|
+
seen = new Map();
|
|
56
|
+
threshold;
|
|
57
|
+
constructor(threshold = 0.85) {
|
|
58
|
+
this.threshold = threshold;
|
|
59
|
+
}
|
|
60
|
+
check(url, markdown) {
|
|
61
|
+
const fps = extractFingerprints(markdown);
|
|
62
|
+
for (const [key, entry] of this.seen) {
|
|
63
|
+
const score = similarity(fps, entry.fps);
|
|
64
|
+
if (score >= this.threshold) {
|
|
65
|
+
return { isDuplicate: true, similarUrl: entry.url, similarityScore: score };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
this.seen.set(url, { fps, url });
|
|
69
|
+
return { isDuplicate: false, similarityScore: 1.0 };
|
|
70
|
+
}
|
|
71
|
+
size() {
|
|
72
|
+
return this.seen.size;
|
|
73
|
+
}
|
|
74
|
+
reset() {
|
|
75
|
+
this.seen.clear();
|
|
46
76
|
}
|
|
47
|
-
this.seen.set(url, { fps, url });
|
|
48
|
-
return { isDuplicate: false, similarityScore: 1 };
|
|
49
|
-
}
|
|
50
|
-
size() {
|
|
51
|
-
return this.seen.size;
|
|
52
|
-
}
|
|
53
|
-
reset() {
|
|
54
|
-
this.seen.clear();
|
|
55
|
-
}
|
|
56
77
|
}
|
|
57
|
-
export {
|
|
58
|
-
ContentDeduplicator
|
|
59
|
-
};
|
|
60
|
-
//# sourceMappingURL=content-dedup.js.map
|