@webcrawl/mcp 1.4.2 → 1.4.4

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 (135) hide show
  1. package/README.md +1 -1
  2. package/dist/bin.js +5 -7
  3. package/dist/core/engine/awswaf.d.ts +59 -0
  4. package/dist/core/engine/awswaf.js +164 -0
  5. package/dist/core/engine/cache.d.ts +70 -0
  6. package/dist/core/engine/cache.js +297 -0
  7. package/dist/core/engine/chrome-cdp.d.ts +22 -0
  8. package/dist/core/engine/chrome-cdp.js +318 -0
  9. package/dist/core/engine/chrome-launch.d.ts +38 -0
  10. package/dist/core/engine/chrome-launch.js +214 -0
  11. package/dist/core/engine/chrome.d.ts +28 -0
  12. package/dist/core/engine/chrome.js +208 -0
  13. package/dist/core/engine/crawl-policy.d.ts +36 -0
  14. package/dist/core/engine/crawl-policy.js +134 -0
  15. package/dist/core/engine/crawler.d.ts +152 -0
  16. package/dist/core/engine/crawler.js +918 -0
  17. package/dist/core/engine/fetcher.d.ts +92 -0
  18. package/dist/core/engine/fetcher.js +494 -0
  19. package/dist/core/engine/index.d.ts +7 -0
  20. package/dist/core/engine/index.js +7 -0
  21. package/dist/core/engine/markdown.d.ts +33 -0
  22. package/dist/core/engine/markdown.js +334 -0
  23. package/dist/core/engine/path-scope.d.ts +21 -0
  24. package/dist/core/engine/path-scope.js +88 -0
  25. package/dist/core/engine/render.d.ts +41 -0
  26. package/dist/core/engine/render.js +138 -0
  27. package/dist/core/engine/safe-url.d.ts +29 -0
  28. package/dist/core/engine/safe-url.js +109 -0
  29. package/dist/core/engine/session.d.ts +93 -0
  30. package/dist/core/engine/session.js +250 -0
  31. package/dist/core/engine/stealth.d.ts +83 -0
  32. package/dist/core/engine/stealth.js +222 -0
  33. package/dist/core/engine.d.ts +1 -0
  34. package/dist/core/engine.js +1 -0
  35. package/dist/core/extract/business.d.ts +28 -0
  36. package/dist/core/extract/business.js +344 -0
  37. package/dist/core/extract/currency.d.ts +15 -0
  38. package/dist/core/extract/currency.js +259 -0
  39. package/dist/core/extract/cve-cache.d.ts +62 -0
  40. package/dist/core/extract/cve-cache.js +277 -0
  41. package/dist/core/extract/cve.d.ts +57 -0
  42. package/dist/core/extract/cve.js +397 -0
  43. package/dist/core/extract/dedupe-key.d.ts +12 -0
  44. package/dist/core/extract/dedupe-key.js +36 -0
  45. package/dist/core/extract/flights.d.ts +37 -0
  46. package/dist/core/extract/flights.js +373 -0
  47. package/dist/core/extract/hotels.d.ts +35 -0
  48. package/dist/core/extract/hotels.js +473 -0
  49. package/dist/core/extract/index.d.ts +9 -0
  50. package/dist/core/extract/index.js +9 -0
  51. package/dist/core/extract/real-estate.d.ts +53 -0
  52. package/dist/core/extract/real-estate.js +835 -0
  53. package/dist/core/extract/scaledown.d.ts +14 -0
  54. package/dist/core/extract/scaledown.js +484 -0
  55. package/dist/core/extract/shopping.d.ts +44 -0
  56. package/dist/core/extract/shopping.js +393 -0
  57. package/dist/core/extract/structured.d.ts +33 -0
  58. package/dist/core/extract/structured.js +316 -0
  59. package/dist/core/image-url.d.ts +5 -0
  60. package/dist/core/image-url.js +13 -0
  61. package/dist/core/interact/driver.d.ts +72 -0
  62. package/dist/core/interact/driver.js +384 -0
  63. package/dist/core/interact/remote.d.ts +41 -0
  64. package/dist/core/interact/remote.js +133 -0
  65. package/dist/core/interact/spans.d.ts +46 -0
  66. package/dist/core/interact/spans.js +32 -0
  67. package/dist/core/interact/validate.d.ts +34 -0
  68. package/dist/core/interact/validate.js +135 -0
  69. package/dist/core/interact/vm.d.ts +43 -0
  70. package/dist/core/interact/vm.js +338 -0
  71. package/dist/core/local-engine.d.ts +62 -0
  72. package/dist/core/local-engine.js +726 -0
  73. package/dist/core/parse/detect.d.ts +29 -0
  74. package/dist/core/parse/detect.js +152 -0
  75. package/dist/core/parse/fetch.d.ts +20 -0
  76. package/dist/core/parse/fetch.js +39 -0
  77. package/dist/core/parse/image.d.ts +13 -0
  78. package/dist/core/parse/image.js +100 -0
  79. package/dist/core/parse/index.d.ts +53 -0
  80. package/dist/core/parse/index.js +153 -0
  81. package/dist/core/parse/limits.d.ts +12 -0
  82. package/dist/core/parse/limits.js +29 -0
  83. package/dist/core/parse/media.d.ts +42 -0
  84. package/dist/core/parse/media.js +238 -0
  85. package/dist/core/parse/merge.d.ts +19 -0
  86. package/dist/core/parse/merge.js +48 -0
  87. package/dist/core/parse/ocr.d.ts +25 -0
  88. package/dist/core/parse/ocr.js +121 -0
  89. package/dist/core/parse/office.d.ts +28 -0
  90. package/dist/core/parse/office.js +83 -0
  91. package/dist/core/parse/pdf.d.ts +27 -0
  92. package/dist/core/parse/pdf.js +156 -0
  93. package/dist/core/parse/types.d.ts +38 -0
  94. package/dist/core/parse/types.js +1 -0
  95. package/dist/core/parse/youtube.d.ts +30 -0
  96. package/dist/core/parse/youtube.js +264 -0
  97. package/dist/core/ranker/decision.d.ts +28 -0
  98. package/dist/core/ranker/decision.js +151 -0
  99. package/dist/core/ranker/index.d.ts +5 -0
  100. package/dist/core/ranker/index.js +361 -0
  101. package/dist/core/ranker/lexical.d.ts +82 -0
  102. package/dist/core/ranker/lexical.js +643 -0
  103. package/dist/core/ranker/types.d.ts +90 -0
  104. package/dist/core/ranker/types.js +1 -0
  105. package/dist/core/ranker.d.ts +1 -0
  106. package/dist/core/ranker.js +1 -0
  107. package/dist/core/sandbox.d.ts +29 -0
  108. package/dist/core/sandbox.js +222 -0
  109. package/dist/core/search/catalog.d.ts +176 -0
  110. package/dist/core/search/catalog.js +488 -0
  111. package/dist/core/search/index.d.ts +3 -0
  112. package/dist/core/search/index.js +3 -0
  113. package/dist/core/search/pipeline.d.ts +7 -0
  114. package/dist/core/search/pipeline.js +721 -0
  115. package/dist/core/search/providers.d.ts +84 -0
  116. package/dist/core/search/providers.js +1408 -0
  117. package/dist/core/search.d.ts +1 -0
  118. package/dist/core/search.js +1 -0
  119. package/dist/core/targets/developer.d.ts +44 -0
  120. package/dist/core/targets/developer.js +338 -0
  121. package/dist/core/targets/index.d.ts +3 -0
  122. package/dist/core/targets/index.js +3 -0
  123. package/dist/core/targets/registry.d.ts +32 -0
  124. package/dist/core/targets/registry.js +411 -0
  125. package/dist/core/targets/types.d.ts +72 -0
  126. package/dist/core/targets/types.js +8 -0
  127. package/dist/core/targets.d.ts +1 -0
  128. package/dist/core/targets.js +1 -0
  129. package/dist/format.js +5 -1
  130. package/dist/index.d.ts +1 -0
  131. package/dist/index.js +1 -0
  132. package/dist/load-local-engine.d.ts +7 -0
  133. package/dist/load-local-engine.js +22 -0
  134. package/dist/server.js +6 -0
  135. package/package.json +11 -3
@@ -0,0 +1,643 @@
1
+ const ENGINE_WEIGHTS = {
2
+ "google-shopping": 1.38,
3
+ "bing-shopping": 1.35,
4
+ serper: 1.35,
5
+ "brave-api": 1.3,
6
+ jina: 1.3,
7
+ "brave-native": 1.25,
8
+ "ddg-lite": 1.15,
9
+ "bing-rss": 1.12,
10
+ "google-news-rss": 1.05,
11
+ wikipedia: 1.1,
12
+ "hn-algolia": 1.0,
13
+ arxiv: 1.05,
14
+ // Authoritative vulnerability databases: rank their record pages above write-ups.
15
+ nvd: 1.4,
16
+ osv: 1.3,
17
+ };
18
+ const STOPWORDS = new Set([
19
+ "a",
20
+ "an",
21
+ "and",
22
+ "are",
23
+ "as",
24
+ "at",
25
+ "be",
26
+ "by",
27
+ "for",
28
+ "from",
29
+ "has",
30
+ "he",
31
+ "in",
32
+ "is",
33
+ "it",
34
+ "its",
35
+ "of",
36
+ "on",
37
+ "or",
38
+ "that",
39
+ "the",
40
+ "to",
41
+ "was",
42
+ "were",
43
+ "will",
44
+ "with",
45
+ "what",
46
+ "when",
47
+ "where",
48
+ "who",
49
+ "why",
50
+ "how",
51
+ "can",
52
+ "we",
53
+ "you",
54
+ "your",
55
+ "this",
56
+ "these",
57
+ "those",
58
+ "into",
59
+ "about",
60
+ "over",
61
+ "after",
62
+ "before",
63
+ ]);
64
+ const TRACKING_PARAMS = new Set([
65
+ "utm_source",
66
+ "utm_medium",
67
+ "utm_campaign",
68
+ "utm_term",
69
+ "utm_content",
70
+ "fbclid",
71
+ "gclid",
72
+ "msclkid",
73
+ "mc_cid",
74
+ "mc_eid",
75
+ "ref",
76
+ "ref_src",
77
+ ]);
78
+ /**
79
+ * Canonicalize a URL for cross-engine deduplication and RRF fusion.
80
+ */
81
+ export function canonicalizeUrl(rawUrl) {
82
+ try {
83
+ const u = new URL(rawUrl.trim());
84
+ u.hash = "";
85
+ u.hostname = u.hostname.toLowerCase().replace(/^www\./, "");
86
+ for (const key of Array.from(u.searchParams.keys())) {
87
+ if (TRACKING_PARAMS.has(key.toLowerCase()) ||
88
+ key.toLowerCase().startsWith("utm_")) {
89
+ u.searchParams.delete(key);
90
+ }
91
+ }
92
+ if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
93
+ u.pathname = u.pathname.slice(0, -1);
94
+ }
95
+ return u.toString();
96
+ }
97
+ catch {
98
+ return rawUrl.trim();
99
+ }
100
+ }
101
+ /**
102
+ * Fast 32-bit FNV-1a integer hash for zero-allocation token/trigram comparison.
103
+ */
104
+ export function fnv1a32(str) {
105
+ let hash = 0x811c9dc5;
106
+ for (let i = 0; i < str.length; i++) {
107
+ hash ^= str.charCodeAt(i);
108
+ hash = Math.imul(hash, 0x01000193);
109
+ }
110
+ return hash >>> 0;
111
+ }
112
+ /**
113
+ * Ultra-fast Porter-style suffix stripper for English IR normalization.
114
+ */
115
+ export function stemToken(word) {
116
+ if (word.length <= 3) {
117
+ return word;
118
+ }
119
+ let w = word;
120
+ if (w.endsWith("ies") && w.length > 4) {
121
+ return w.slice(0, -3) + "y";
122
+ }
123
+ if (w.endsWith("es") && w.length > 4 && /(ches|shes|xes|zes|ses)$/.test(w)) {
124
+ return w.slice(0, -2);
125
+ }
126
+ if (w.endsWith("s") &&
127
+ !w.endsWith("ss") &&
128
+ !w.endsWith("us") &&
129
+ !w.endsWith("is")) {
130
+ w = w.slice(0, -1);
131
+ }
132
+ if (w.endsWith("ing") && w.length > 5) {
133
+ w = w.slice(0, -3);
134
+ }
135
+ else if (w.endsWith("ed") && w.length > 4) {
136
+ w = w.slice(0, -2);
137
+ }
138
+ else if (w.endsWith("ational") && w.length > 8) {
139
+ w = w.slice(0, -5) + "e";
140
+ }
141
+ else if (w.endsWith("tion") && w.length > 6) {
142
+ w = w.slice(0, -3);
143
+ }
144
+ else if (w.endsWith("ment") && w.length > 6) {
145
+ w = w.slice(0, -4);
146
+ }
147
+ else if (w.endsWith("ness") && w.length > 6) {
148
+ w = w.slice(0, -4);
149
+ }
150
+ else if (w.endsWith("ly") && w.length > 5) {
151
+ w = w.slice(0, -2);
152
+ }
153
+ return w;
154
+ }
155
+ export function tokenizeAndHash(text, includeTrigrams = false) {
156
+ const normalized = text
157
+ .toLowerCase()
158
+ .replace(/[^a-z0-9\s-]/g, " ")
159
+ .split(/[\s-]+/)
160
+ .filter((t) => t.length >= 2 && !STOPWORDS.has(t));
161
+ const tokens = normalized.map(stemToken);
162
+ const hashes = new Uint32Array(tokens.length);
163
+ const freqMap = new Map();
164
+ const trigramSet = new Set();
165
+ for (let i = 0; i < tokens.length; i++) {
166
+ const tok = tokens[i];
167
+ const h = fnv1a32(tok);
168
+ hashes[i] = h;
169
+ freqMap.set(h, (freqMap.get(h) ?? 0) + 1);
170
+ if (includeTrigrams) {
171
+ const padded = `^${tok}$`;
172
+ for (let j = 0; j <= padded.length - 3; j++) {
173
+ trigramSet.add(fnv1a32(padded.slice(j, j + 3)));
174
+ }
175
+ }
176
+ }
177
+ return { tokens, hashes, freqMap, trigramSet };
178
+ }
179
+ /**
180
+ * Stage 2: Weighted Reciprocal Rank Fusion (W-RRF) across heterogeneous engines.
181
+ * Formula: RRF(d) = sum_{e in Engines} w_e / (k + rank_e(d))
182
+ */
183
+ export function fuseWithWeightedRrf(rawCandidates, rrfK = 60) {
184
+ const byUrl = new Map();
185
+ const engineSet = new Set();
186
+ rawCandidates.forEach((cand, idx) => {
187
+ const canonical = canonicalizeUrl(cand.url || `doc:${idx}`);
188
+ const engine = cand.engine || "ddg-lite";
189
+ const rank = cand.engineRank ?? idx + 1;
190
+ const weight = ENGINE_WEIGHTS[engine] ?? 1.0;
191
+ const rrfContribution = weight / (rrfK + rank);
192
+ engineSet.add(engine);
193
+ const existing = byUrl.get(canonical);
194
+ if (!existing) {
195
+ const sources = cand.sources
196
+ ? Array.from(new Set([...cand.sources, engine]))
197
+ : [engine];
198
+ byUrl.set(canonical, {
199
+ doc: { ...cand },
200
+ canonicalUrl: canonical,
201
+ sources,
202
+ engineRanks: { ...(cand.engineRanks || {}), [engine]: rank },
203
+ rrfRaw: rrfContribution,
204
+ });
205
+ }
206
+ else {
207
+ if (!existing.sources.includes(engine)) {
208
+ existing.sources.push(engine);
209
+ }
210
+ const prevRank = existing.engineRanks[engine];
211
+ if (prevRank === undefined || rank < prevRank) {
212
+ existing.engineRanks[engine] = rank;
213
+ existing.rrfRaw += rrfContribution;
214
+ }
215
+ // Keep richest title/description/markdown
216
+ if ((cand.title?.length || 0) > (existing.doc.title?.length || 0)) {
217
+ existing.doc.title = cand.title;
218
+ }
219
+ if ((cand.description?.length || 0) >
220
+ (existing.doc.description?.length || 0)) {
221
+ existing.doc.description = cand.description;
222
+ }
223
+ if (cand.markdown &&
224
+ (!existing.doc.markdown ||
225
+ cand.markdown.length > existing.doc.markdown.length)) {
226
+ existing.doc.markdown = cand.markdown;
227
+ }
228
+ if (cand.publishedDate && !existing.doc.publishedDate) {
229
+ existing.doc.publishedDate = cand.publishedDate;
230
+ }
231
+ }
232
+ });
233
+ // Apply multi-engine consensus bonus (+12% per additional independent search engine)
234
+ const merged = Array.from(byUrl.values()).map((entry) => {
235
+ const consensusMultiplier = 1 + Math.min(entry.sources.length - 1, 4) * 0.12;
236
+ return {
237
+ ...entry,
238
+ rrfRaw: entry.rrfRaw * consensusMultiplier,
239
+ };
240
+ });
241
+ return {
242
+ merged,
243
+ enginesContributing: Array.from(engineSet),
244
+ };
245
+ }
246
+ /**
247
+ * Extract headings (H1-H3) from Markdown text for fielded BM25F scoring.
248
+ */
249
+ export function extractMarkdownHeadings(markdown) {
250
+ if (!markdown) {
251
+ return "";
252
+ }
253
+ const matches = markdown.match(/^#{1,3}\s+.+$/gm);
254
+ return matches ? matches.join(" ") : "";
255
+ }
256
+ /**
257
+ * Stage 4a: ColBERT-inspired Subword + Token MaxSim Passage Scorer & Highlight Extractor.
258
+ * Splits document into semantic blocks and computes maximum late-interaction similarity
259
+ * per query token + minimum window proximity.
260
+ */
261
+ export function computePassageMaxSimAndHighlights(queryTok, rawQueryLower, doc, maxHighlights = 2) {
262
+ const sourceText = [doc.description || "", doc.markdown || ""]
263
+ .filter(Boolean)
264
+ .join("\n\n");
265
+ if (!sourceText.trim() || queryTok.hashes.length === 0) {
266
+ return {
267
+ maxSimScore: 0,
268
+ highlights: doc.description ? [doc.description.slice(0, 320)] : [],
269
+ };
270
+ }
271
+ // Segment into semantic blocks (~150-500 chars) without ES2018 lookbehind
272
+ const rawBlocks = sourceText
273
+ .replace(/([.!?])\s+(?=[A-Z0-9#*\-[])/g, "$1\n\n")
274
+ .split(/\n{2,}/g)
275
+ .map((blk) => blk.replace(/\s+/g, " ").trim())
276
+ .filter((blk) => blk.length >= 25);
277
+ const blocks = rawBlocks.length > 0 ? rawBlocks.slice(0, 32) : [sourceText.slice(0, 500)];
278
+ // Pre-build per-query-token trigram sets for subword matching
279
+ const queryTokenTrigrams = queryTok.tokens.map((tok) => {
280
+ const set = new Set();
281
+ const padded = `^${tok}$`;
282
+ for (let j = 0; j <= padded.length - 3; j++) {
283
+ set.add(fnv1a32(padded.slice(j, j + 3)));
284
+ }
285
+ return set;
286
+ });
287
+ const scoredBlocks = [];
288
+ for (const block of blocks) {
289
+ const bTok = tokenizeAndHash(block, true);
290
+ if (bTok.hashes.length === 0) {
291
+ continue;
292
+ }
293
+ // ColBERT MaxSim: for each query term, find maximum similarity across all tokens in block
294
+ let sumMaxSim = 0;
295
+ for (let qi = 0; qi < queryTok.hashes.length; qi++) {
296
+ const qHash = queryTok.hashes[qi];
297
+ if (bTok.freqMap.has(qHash)) {
298
+ // Exact stemmed token match = 1.0
299
+ sumMaxSim += 1.0;
300
+ }
301
+ else {
302
+ // Subword character 3-gram Dice overlap
303
+ const qTris = queryTokenTrigrams[qi];
304
+ if (qTris.size > 0) {
305
+ let overlap = 0;
306
+ for (const tri of qTris) {
307
+ if (bTok.trigramSet.has(tri)) {
308
+ overlap++;
309
+ }
310
+ }
311
+ const subwordSim = overlap / qTris.size;
312
+ if (subwordSim > 0.35) {
313
+ sumMaxSim += subwordSim * 0.75;
314
+ }
315
+ }
316
+ }
317
+ }
318
+ let blockScore = sumMaxSim / queryTok.hashes.length;
319
+ if (rawQueryLower.length >= 4 &&
320
+ block.toLowerCase().includes(rawQueryLower)) {
321
+ blockScore = Math.min(1, blockScore + 0.25);
322
+ }
323
+ scoredBlocks.push({ text: block, score: blockScore });
324
+ }
325
+ if (scoredBlocks.length === 0) {
326
+ return {
327
+ maxSimScore: 0,
328
+ highlights: doc.description ? [doc.description.slice(0, 320)] : [],
329
+ };
330
+ }
331
+ scoredBlocks.sort((a, b) => b.score - a.score);
332
+ const top1 = scoredBlocks[0].score;
333
+ const top2 = scoredBlocks[1]?.score ?? top1;
334
+ // KeyB2+ style Top-1 + Top-2 block aggregation
335
+ const maxSimScore = Math.min(1, top1 * 0.7 + top2 * 0.3);
336
+ const highlights = [];
337
+ for (const b of scoredBlocks) {
338
+ if (highlights.length >= maxHighlights) {
339
+ break;
340
+ }
341
+ const clipped = b.text.length > 360 ? `${b.text.slice(0, 357)}...` : b.text;
342
+ if (!highlights.includes(clipped)) {
343
+ highlights.push(clipped);
344
+ }
345
+ }
346
+ return { maxSimScore, highlights };
347
+ }
348
+ /**
349
+ * Stage 4b: Exact phrase, ordered bigram, and minimum window proximity score.
350
+ */
351
+ export function computeProximityAndExactScore(queryTok, rawQueryLower, titleTok, descTok, rawTitleLower, rawDescLower) {
352
+ if (queryTok.hashes.length === 0) {
353
+ return 0;
354
+ }
355
+ let score = 0;
356
+ // 1. Exact phrase match in title or description
357
+ if (rawQueryLower.length >= 3) {
358
+ if (rawTitleLower.includes(rawQueryLower)) {
359
+ score += 0.55;
360
+ }
361
+ if (rawDescLower.includes(rawQueryLower)) {
362
+ score += 0.3;
363
+ }
364
+ }
365
+ // 2. Ordered query bi-grams in title + description
366
+ if (queryTok.hashes.length >= 2) {
367
+ const combined = new Uint32Array(titleTok.hashes.length + descTok.hashes.length);
368
+ combined.set(titleTok.hashes, 0);
369
+ combined.set(descTok.hashes, titleTok.hashes.length);
370
+ let matchedBigrams = 0;
371
+ const totalBigrams = queryTok.hashes.length - 1;
372
+ for (let qi = 0; qi < totalBigrams; qi++) {
373
+ const h1 = queryTok.hashes[qi];
374
+ const h2 = queryTok.hashes[qi + 1];
375
+ for (let ci = 0; ci < combined.length - 1; ci++) {
376
+ if (combined[ci] === h1 &&
377
+ (combined[ci + 1] === h2 || combined[ci + 2] === h2)) {
378
+ matchedBigrams++;
379
+ break;
380
+ }
381
+ }
382
+ }
383
+ score += 0.35 * (matchedBigrams / totalBigrams);
384
+ }
385
+ else {
386
+ // Single-term query: check title hit
387
+ if (titleTok.freqMap.has(queryTok.hashes[0])) {
388
+ score += 0.5;
389
+ }
390
+ }
391
+ return Math.min(1, score);
392
+ }
393
+ /**
394
+ * Extract effective publisher/domain key for domain diversity.
395
+ * For Google News RSS redirect URLs (`news.google.com/rss/articles/...`), extracts the
396
+ * actual publisher (`[Publisher]` or `- Publisher` suffix) so distinct news outlets
397
+ * aren't collapsed under `news.google.com`, while true same-publisher pages share a key.
398
+ */
399
+ export function extractPublisherDomainKey(canonicalUrl, title, description) {
400
+ try {
401
+ const u = new URL(canonicalUrl);
402
+ const host = u.hostname.toLowerCase().replace(/^www\./, "");
403
+ if (host === "news.google.com" && u.pathname.includes("/rss/articles/")) {
404
+ const bracketMatch = description.match(/^\[([^\]]+)\]/);
405
+ if (bracketMatch?.[1]) {
406
+ return `publisher:${bracketMatch[1].trim().toLowerCase()}`;
407
+ }
408
+ const dashMatch = title.match(/\s[-|]\s([^-|]+)$/);
409
+ if (dashMatch?.[1]) {
410
+ return `publisher:${dashMatch[1].trim().toLowerCase()}`;
411
+ }
412
+ }
413
+ return host;
414
+ }
415
+ catch {
416
+ return canonicalUrl;
417
+ }
418
+ }
419
+ /**
420
+ * Compute token-set Jaccard + containment similarity in [0, 1] between two candidates
421
+ * to detect near-duplicate headlines or syndicated wire stories.
422
+ */
423
+ export function computeTokenSimilarity(setA, setB) {
424
+ if (setA.size === 0 || setB.size === 0) {
425
+ return 0;
426
+ }
427
+ let intersection = 0;
428
+ for (const h of setA) {
429
+ if (setB.has(h)) {
430
+ intersection++;
431
+ }
432
+ }
433
+ if (intersection === 0) {
434
+ return 0;
435
+ }
436
+ const union = setA.size + setB.size - intersection;
437
+ const jaccard = intersection / union;
438
+ const minContainment = intersection / Math.min(setA.size, setB.size);
439
+ return jaccard * 0.6 + minContainment * 0.4;
440
+ }
441
+ const regionDisplayNames = typeof Intl !== "undefined" && typeof Intl.DisplayNames === "function"
442
+ ? new Intl.DisplayNames(["en"], { type: "region" })
443
+ : null;
444
+ /**
445
+ * Dynamically resolve an ISO 3166-1 alpha-2 country code (e.g. "AU", "GB", "JP", "BR")
446
+ * into its full English name ("Australia", "United Kingdom", "Japan", "Brazil") using
447
+ * standard runtime Intl.DisplayNames: zero hardcoded country tables.
448
+ */
449
+ export function resolveCountryDisplayName(countryCode) {
450
+ if (!countryCode) {
451
+ return undefined;
452
+ }
453
+ const clean = countryCode.trim().toUpperCase();
454
+ if (clean.length !== 2) {
455
+ return countryCode.trim();
456
+ }
457
+ if (clean === "EU") {
458
+ return "Europe";
459
+ }
460
+ try {
461
+ return regionDisplayNames?.of(clean) || clean;
462
+ }
463
+ catch {
464
+ return clean;
465
+ }
466
+ }
467
+ const EU_CCTLDS = new Set([
468
+ "eu",
469
+ "de",
470
+ "fr",
471
+ "it",
472
+ "es",
473
+ "nl",
474
+ "be",
475
+ "at",
476
+ "ie",
477
+ "pt",
478
+ "fi",
479
+ "se",
480
+ "dk",
481
+ "pl",
482
+ "cz",
483
+ "hu",
484
+ "ro",
485
+ "gr",
486
+ "ch",
487
+ "no",
488
+ "uk",
489
+ "gb",
490
+ ]);
491
+ /**
492
+ * Stage 4d: Dynamic Geolocation & Filter Affinity score in [0, 1].
493
+ * Zero hardcoded retailer/city/country lists:
494
+ * - Resolves country display name dynamically via `Intl.DisplayNames`
495
+ * - Matches any 2-letter ccTLD algorithmically (`.${cc}`, `.com.${cc}`, `.co.${cc}`, `.org.${cc}`, `.net.${cc}`, `.gov.${cc}`)
496
+ * - Penalizes foreign 2-letter ccTLDs when `preferLocalDomains` or user country is set
497
+ * - Evaluates dynamic `userLocation` (`city`, `region`, `countryName`) and agent `filterContext` (`boostTerms`, `mustIncludeTerms`, `excludeTerms`)
498
+ */
499
+ export function computeGeoAffinityScore(doc, canonicalUrl, userLocation, filterContext) {
500
+ const hasGeo = Boolean(userLocation &&
501
+ (userLocation.country || userLocation.city || userLocation.region));
502
+ const hasFilterTerms = Boolean(filterContext?.boostTerms?.length ||
503
+ filterContext?.mustIncludeTerms?.length ||
504
+ filterContext?.excludeTerms?.length ||
505
+ filterContext?.subcategory);
506
+ if (!hasGeo && !hasFilterTerms) {
507
+ return 0.5;
508
+ }
509
+ let score = 0.4;
510
+ const countryCode = (userLocation?.country || "").trim().toUpperCase();
511
+ const countryLower = countryCode.toLowerCase();
512
+ const countryNameLower = (userLocation?.countryName ||
513
+ resolveCountryDisplayName(countryCode) ||
514
+ "")
515
+ .trim()
516
+ .toLowerCase();
517
+ const cityLower = (userLocation?.city || "").trim().toLowerCase();
518
+ const regionLower = (userLocation?.region || "").trim().toLowerCase();
519
+ const combinedText = `${doc.title || ""} ${doc.description || ""} ${canonicalUrl}`.toLowerCase();
520
+ // 1. Algorithmic ccTLD check from ISO-3166 alpha-2 country code (no hardcoded TLD list)
521
+ if (countryLower.length === 2) {
522
+ try {
523
+ const host = new URL(canonicalUrl).hostname
524
+ .toLowerCase()
525
+ .replace(/^www\./, "");
526
+ const topLabel = host.split(".").pop() || "";
527
+ // Note: 'uk' vs 'gb' is standard ISO-3166 / IANA ccTLD alias
528
+ const matchesLocalCcTld = topLabel === countryLower ||
529
+ (countryLower === "gb" && topLabel === "uk") ||
530
+ (countryLower === "uk" && topLabel === "gb") ||
531
+ (countryLower === "eu" && EU_CCTLDS.has(topLabel));
532
+ if (matchesLocalCcTld) {
533
+ score += 0.42;
534
+ }
535
+ else if (topLabel.length === 2 &&
536
+ topLabel !== "io" &&
537
+ topLabel !== "ai" &&
538
+ topLabel !== "co" &&
539
+ topLabel !== "tv" &&
540
+ topLabel !== "me" &&
541
+ topLabel !== "app") {
542
+ // Foreign country-code TLD penalty when user has a different country
543
+ score -= filterContext?.preferLocalDomains ? 0.28 : 0.16;
544
+ }
545
+ }
546
+ catch {
547
+ // ignore
548
+ }
549
+ }
550
+ // 2. Dynamic city / region / country name match in document text or URL
551
+ if (cityLower && cityLower.length >= 2 && combinedText.includes(cityLower)) {
552
+ score += 0.32;
553
+ }
554
+ if (regionLower &&
555
+ regionLower.length >= 2 &&
556
+ combinedText.includes(regionLower)) {
557
+ score += 0.15;
558
+ }
559
+ if (countryNameLower &&
560
+ countryNameLower.length >= 2 &&
561
+ combinedText.includes(countryNameLower)) {
562
+ score += 0.22;
563
+ }
564
+ // 3. Dynamic subcategory / boostTerms / excludeTerms affinity
565
+ const subcats = Array.isArray(filterContext?.subcategory)
566
+ ? filterContext?.subcategory
567
+ : filterContext?.subcategory
568
+ ? [filterContext.subcategory]
569
+ : [];
570
+ for (const sub of subcats) {
571
+ const cleanSub = sub.replace(/[-_]/g, " ").trim().toLowerCase();
572
+ if (cleanSub && combinedText.includes(cleanSub)) {
573
+ score += 0.14;
574
+ }
575
+ }
576
+ if (filterContext?.boostTerms?.length) {
577
+ for (const term of filterContext.boostTerms) {
578
+ const t = term.trim().toLowerCase();
579
+ if (t && combinedText.includes(t)) {
580
+ score += 0.12;
581
+ }
582
+ }
583
+ }
584
+ return Math.max(0, Math.min(1, score));
585
+ }
586
+ /**
587
+ * Stage 4c: Freshness & Domain Authority score.
588
+ */
589
+ export function computeFreshnessAndAuthorityScore(doc, canonicalUrl) {
590
+ let score = 0.5;
591
+ // Clean URL structure & shallow depth bonus
592
+ try {
593
+ const u = new URL(canonicalUrl);
594
+ if (u.protocol === "https:") {
595
+ score += 0.08;
596
+ }
597
+ const segments = u.pathname.split("/").filter(Boolean);
598
+ if (segments.length <= 2) {
599
+ score += 0.1;
600
+ }
601
+ else if (segments.length >= 5) {
602
+ score -= 0.08;
603
+ }
604
+ // High-signal documentation / technical / primary domains
605
+ if (/\.(edu|gov|org)$/.test(u.hostname) ||
606
+ /(github\.com|arxiv\.org|wikipedia\.org|developer\.mozilla\.org|vercel\.com|docs\.)/.test(u.hostname)) {
607
+ score += 0.14;
608
+ }
609
+ // Primary vulnerability sources (CVE records, advisories, vendor security bulletins)
610
+ if (/(^|\.)(nvd\.nist\.gov|cve\.org|cve\.mitre\.org|osv\.dev|cisa\.gov)$/.test(u.hostname) ||
611
+ (u.hostname === "github.com" && /^\/advisories\//.test(u.pathname)) ||
612
+ /\/(security|advisories|security-advisories|vulnerabilities)(\/|$)/i.test(u.pathname)) {
613
+ score += 0.12;
614
+ }
615
+ }
616
+ catch {
617
+ // ignore
618
+ }
619
+ // Temporal freshness signal (2026 / 2025 or recent pubDate)
620
+ const combinedMeta = `${doc.publishedDate || ""} ${doc.title || ""} ${doc.description || ""}`;
621
+ if (/\b2026\b/.test(combinedMeta)) {
622
+ score += 0.18;
623
+ }
624
+ else if (/\b2025\b/.test(combinedMeta)) {
625
+ score += 0.1;
626
+ }
627
+ if (doc.publishedDate) {
628
+ const parsedMs = Date.parse(doc.publishedDate);
629
+ if (!Number.isNaN(parsedMs)) {
630
+ const ageDays = Math.max(0, (Date.now() - parsedMs) / (1000 * 60 * 60 * 24));
631
+ if (ageDays <= 7) {
632
+ score += 0.18;
633
+ }
634
+ else if (ageDays <= 30) {
635
+ score += 0.12;
636
+ }
637
+ else if (ageDays <= 180) {
638
+ score += 0.06;
639
+ }
640
+ }
641
+ }
642
+ return Math.max(0, Math.min(1, score));
643
+ }