webcrawl 1.2.3 → 1.2.5

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