webcrawl 1.2.4 → 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.
- package/dist/cli.js +3 -2
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- 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
|
+
}
|