crawlforge-mcp-server 5.6.7 → 5.6.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/package.json +2 -2
- package/server.js +11 -18
- package/src/core/ActionExecutor.js +10 -1
- package/src/core/AgentOrchestrator.js +107 -5
- package/src/core/ChangeTracker.js +2 -1
- package/src/core/LLMsTxtAnalyzer.js +2 -1
- package/src/core/LocalizationManager.js +26 -9
- package/src/core/ResearchOrchestrator.js +47 -4
- package/src/core/StealthBrowserManager.js +179 -7
- package/src/core/analysis/ContentAnalyzer.js +34 -3
- package/src/core/analysis/sentenceUtils.js +10 -7
- package/src/core/crawlers/BFSCrawler.js +29 -17
- package/src/core/integrations/PerformanceIntegration.js +2 -1
- package/src/core/processing/ContentProcessor.js +3 -2
- package/src/core/workers/worker.js +2 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +19 -3
- package/src/tools/advanced/batchScrape/worker.js +4 -3
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +11 -26
- package/src/tools/extract/extractContent.js +5 -1
- package/src/tools/extract/extractStructured.js +13 -3
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/tools/search/redditSearch.js +7 -0
- package/src/tools/templates/ScrapeTemplateTool.js +56 -0
- package/src/tools/templates/shopifyJsonLdFallback.js +160 -0
- package/src/utils/challengeDetection.js +11 -0
- package/src/utils/pageTitle.js +18 -0
- package/src/utils/stealthVerdict.js +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.6.
|
|
3
|
+
"version": "5.6.9",
|
|
4
4
|
"mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
|
|
5
5
|
"description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
|
|
6
6
|
"main": "server.js",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"cheerio": "^1.1.2",
|
|
117
117
|
"commander": "^14.0.3",
|
|
118
118
|
"compromise": "^14.14.4",
|
|
119
|
-
"crawlforge-extractors": "^1.6.
|
|
119
|
+
"crawlforge-extractors": "^1.6.4",
|
|
120
120
|
"diff": "^9.0.0",
|
|
121
121
|
"dotenv": "^17.2.1",
|
|
122
122
|
"franc": "^6.2.0",
|
package/server.js
CHANGED
|
@@ -34,6 +34,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
|
|
|
34
34
|
import * as cheerio from "cheerio";
|
|
35
35
|
import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
|
|
36
36
|
import { detectChallengePage } from "./src/utils/challengeDetection.js";
|
|
37
|
+
import { stealthDocumentVerdict } from "./src/utils/stealthVerdict.js";
|
|
37
38
|
import { browserPreflight } from "./src/utils/robotsGate.js";
|
|
38
39
|
import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
|
|
39
40
|
import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
|
|
@@ -106,7 +107,7 @@ const taskStore = createTaskStore({ logger });
|
|
|
106
107
|
// Create the server
|
|
107
108
|
const server = new McpServer({
|
|
108
109
|
name: "crawlforge",
|
|
109
|
-
version: "5.6.
|
|
110
|
+
version: "5.6.9",
|
|
110
111
|
description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
|
|
111
112
|
homepage: "https://www.crawlforge.dev",
|
|
112
113
|
icon: "https://www.crawlforge.dev/icon.png",
|
|
@@ -1404,28 +1405,20 @@ registerToolIfEnabled("stealth_mode", {
|
|
|
1404
1405
|
|
|
1405
1406
|
// A bot-wall interstitial arrives as HTTP 200 with a title and prose
|
|
1406
1407
|
// of its own; reported as a successful scrape it hid the block for
|
|
1407
|
-
// three rounds (R10 Q1 → R15).
|
|
1408
|
-
//
|
|
1409
|
-
|
|
1408
|
+
// three rounds (R10 Q1 → R15). An HTTP error page and a short
|
|
1409
|
+
// error-titled placeholder did the same in R18. Name each — the
|
|
1410
|
+
// content is still returned so the caller can see what the site served.
|
|
1411
|
+
const verdict = stealthDocumentVerdict(scraped, { waitedMs: (wait_for || 0) + (scraped.gracedMs || 0) });
|
|
1410
1412
|
result = {
|
|
1411
|
-
success:
|
|
1413
|
+
success: verdict.success,
|
|
1412
1414
|
url: scraped.url,
|
|
1413
1415
|
title: scraped.title,
|
|
1416
|
+
status: verdict.status,
|
|
1414
1417
|
content: stealthScrapeFormats(formats, scraped)
|
|
1415
1418
|
};
|
|
1416
|
-
if (
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
} else if (!scraped.title && !String(scraped.text || '').trim()) {
|
|
1420
|
-
// A document with no title and no text is not a scraped page; it
|
|
1421
|
-
// is a render that has not happened yet (or an empty response).
|
|
1422
|
-
// Reported as success it reads like a page with nothing on it.
|
|
1423
|
-
result.success = false;
|
|
1424
|
-
result.error =
|
|
1425
|
-
`The stealth browser reached ${scraped.url} but the document rendered no title and no text` +
|
|
1426
|
-
` after ${wait_for || 0}ms of extra wait (${(scraped.html || '').length} bytes of HTML).` +
|
|
1427
|
-
' A JavaScript-rendered page needs a longer wait_for; an empty document means the server sent nothing to render.';
|
|
1428
|
-
}
|
|
1419
|
+
if (verdict.blocked) result.blocked = verdict.blocked;
|
|
1420
|
+
if (verdict.error) result.error = verdict.error;
|
|
1421
|
+
if (scraped.gracedMs > 0) result.waited_for_render_ms = scraped.gracedMs;
|
|
1429
1422
|
|
|
1430
1423
|
// Screenshots follow the same crawlforge://screenshot/{id} pattern as
|
|
1431
1424
|
// scrape and scrape_with_actions, so the base64 never bloats the result.
|
|
@@ -381,6 +381,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
381
381
|
url,
|
|
382
382
|
finalUrl: executionContext.finalUrl || url,
|
|
383
383
|
finalHtml: executionContext.finalHtml,
|
|
384
|
+
navigationStatus: executionContext.page?.__crawlforgeNavigation?.status ?? null,
|
|
384
385
|
executionTime: Date.now() - startTime,
|
|
385
386
|
results: executionContext.results,
|
|
386
387
|
screenshots: executionContext.screenshots,
|
|
@@ -1171,7 +1172,7 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1171
1172
|
// hostname-based checks alone would miss DNS-rebinding/private-IP targets.
|
|
1172
1173
|
await assertUrlAllowed(url, { resolveDns: true });
|
|
1173
1174
|
|
|
1174
|
-
await page.goto(url, {
|
|
1175
|
+
const response = await page.goto(url, {
|
|
1175
1176
|
waitUntil: options.waitUntil || 'domcontentloaded',
|
|
1176
1177
|
timeout: options.timeout || 30000
|
|
1177
1178
|
});
|
|
@@ -1182,6 +1183,14 @@ export class ActionExecutor extends EventEmitter {
|
|
|
1182
1183
|
if (/^https?:\/\//i.test(landedUrl)) {
|
|
1183
1184
|
await assertUrlAllowed(landedUrl, { resolveDns: true });
|
|
1184
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
// Remembered on the page so the chain result can report the status of
|
|
1188
|
+
// the last navigation: tesla.com's Akamai denial ran a full action chain
|
|
1189
|
+
// and came back success:true (R18, 2026-09-04).
|
|
1190
|
+
let status = null;
|
|
1191
|
+
try { status = response ? response.status() : null; } catch { status = null; }
|
|
1192
|
+
page.__crawlforgeNavigation = { url: landedUrl, status };
|
|
1193
|
+
return { url: landedUrl, status };
|
|
1185
1194
|
}
|
|
1186
1195
|
|
|
1187
1196
|
/**
|
|
@@ -34,7 +34,11 @@ const DEFAULT_MAX_URLS = 10;
|
|
|
34
34
|
function isRelevant(text, query) {
|
|
35
35
|
if (!text || !query) return true; // fail-open
|
|
36
36
|
const lc = text.toLowerCase();
|
|
37
|
-
|
|
37
|
+
// Terms are compared bare: the quoted entity in `npm package "commander"?`
|
|
38
|
+
// arrived as `"commander"?` and matched nothing (R17, 2026-09-04).
|
|
39
|
+
return query.toLowerCase().split(/\s+/)
|
|
40
|
+
.map(term => term.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
41
|
+
.some(term => term.length > 3 && lc.includes(term));
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -45,6 +49,46 @@ function truncate(text, maxChars = 8000) {
|
|
|
45
49
|
return text.slice(0, maxChars) + '\n[...truncated]';
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Literal provenance check: every version, date and large number in the
|
|
54
|
+
* answer must appear somewhere in the fetched source text. Returns the raw
|
|
55
|
+
* strings that do not (deduplicated, in answer order). Comparison is
|
|
56
|
+
* case-insensitive with whitespace and thousands separators removed, so
|
|
57
|
+
* "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
|
|
58
|
+
* ignored: they are citations, not claims.
|
|
59
|
+
*/
|
|
60
|
+
const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
|
|
61
|
+
const VALUE_PATTERNS = [
|
|
62
|
+
/\b\d+\.\d+(?:\.\d+){0,2}\b/g, // versions 1.2 / 1.2.3 / 1.2.3.4
|
|
63
|
+
/\b\d{4}-\d{2}-\d{2}\b/g, // ISO dates
|
|
64
|
+
new RegExp(`\\b${MONTHS}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}\\b`, 'gi'), // January 18, 2024
|
|
65
|
+
new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTHS}\\.?\\s+\\d{4}\\b`, 'gi'), // 18 January 2024
|
|
66
|
+
/\b\d{1,3}(?:,\d{3})+\b|\b\d{4,}\b/g, // 1,234 / 12345
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function normalizeValue(value) {
|
|
70
|
+
return value.toLowerCase().replace(/[\s,]/g, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function unverifiedValues(answer, sourceText) {
|
|
74
|
+
if (!answer) return [];
|
|
75
|
+
const stripped = answer.replace(/https?:\/\/\S+/g, ' ');
|
|
76
|
+
const haystack = normalizeValue(sourceText || '');
|
|
77
|
+
const missing = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
80
|
+
for (const match of stripped.matchAll(pattern)) {
|
|
81
|
+
const raw = match[0];
|
|
82
|
+
const key = normalizeValue(raw);
|
|
83
|
+
if (!key || seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
if (!haystack.includes(key)) missing.push(raw);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// "2024" inside an already-flagged "January 18, 2024" is the same claim.
|
|
89
|
+
return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
/**
|
|
49
93
|
* Current-state task gate: prompts about the live "now" ("right now",
|
|
50
94
|
* "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
@@ -222,6 +266,17 @@ export class AgentOrchestrator {
|
|
|
222
266
|
} catch {
|
|
223
267
|
// Sampling unavailable — use raw prompt
|
|
224
268
|
}
|
|
269
|
+
// A quoted term in the prompt ("commander") is the thing being asked
|
|
270
|
+
// about. A planner told to search the bare entity reduced
|
|
271
|
+
// 'npm package "commander"' to "npm", fetched only npm's own docs and
|
|
272
|
+
// answered from memory (R17, 2026-09-04). Every query carries the quoted
|
|
273
|
+
// terms.
|
|
274
|
+
const quotedTerms = [...prompt.matchAll(/"([^"\n]{2,60})"/g)].map(m => m[1].trim()).filter(Boolean);
|
|
275
|
+
if (quotedTerms.length > 0) {
|
|
276
|
+
searchQueries = searchQueries.map(q =>
|
|
277
|
+
quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
|
|
278
|
+
);
|
|
279
|
+
}
|
|
225
280
|
|
|
226
281
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
227
282
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
@@ -238,6 +293,8 @@ export class AgentOrchestrator {
|
|
|
238
293
|
if (!priorityUrls.includes(url)) priorityUrls.push(url);
|
|
239
294
|
}
|
|
240
295
|
const searchResults = [];
|
|
296
|
+
/** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
|
|
297
|
+
const excerpts = new Map();
|
|
241
298
|
|
|
242
299
|
if (urlQueue.length < capUrls) {
|
|
243
300
|
try {
|
|
@@ -253,6 +310,11 @@ export class AgentOrchestrator {
|
|
|
253
310
|
for (const r of parsed.results) {
|
|
254
311
|
if (r.link && !urlQueue.includes(r.link)) urlQueue.push(r.link);
|
|
255
312
|
searchResults.push({ query: q, title: r.title || '', url: r.link || '', snippet: r.snippet || '' });
|
|
313
|
+
// What the engine saw of the page: the snippet plus the
|
|
314
|
+
// page's own meta description, which for npmjs.com carries
|
|
315
|
+
// "Latest version: 15.0.0" while the snippet does not.
|
|
316
|
+
const excerpt = [r.snippet, r.pagemap?.metatags?.description].filter(Boolean).join(' ');
|
|
317
|
+
if (r.link && excerpt && !excerpts.has(r.link)) excerpts.set(r.link, { title: r.title || '', text: excerpt });
|
|
256
318
|
}
|
|
257
319
|
}
|
|
258
320
|
} catch { /* skip failed search */ }
|
|
@@ -318,7 +380,17 @@ export class AgentOrchestrator {
|
|
|
318
380
|
text: truncate(textContent),
|
|
319
381
|
step
|
|
320
382
|
});
|
|
321
|
-
} catch {
|
|
383
|
+
} catch {
|
|
384
|
+
// The page is out of reach (challenge page, 403, timeout) but the
|
|
385
|
+
// search engine's excerpt of it is not: npmjs.com's "Latest version:
|
|
386
|
+
// 15.0.0" sat in the snippet while every fetch of the package page was
|
|
387
|
+
// challenged, and the run ended with no evidence at all (R17,
|
|
388
|
+
// 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
|
|
389
|
+
const excerpt = excerpts.get(url);
|
|
390
|
+
if (excerpt && isRelevant(excerpt.text, prompt)) {
|
|
391
|
+
evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
322
394
|
}
|
|
323
395
|
|
|
324
396
|
// ── SHAPE ─────────────────────────────────────────────────────────────────
|
|
@@ -340,7 +412,7 @@ export class AgentOrchestrator {
|
|
|
340
412
|
.sort((a, b) => b._score - a._score);
|
|
341
413
|
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
342
414
|
const combinedText = orderedEvidence
|
|
343
|
-
.map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
|
|
415
|
+
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${truncate(e.text, perSourceCap)}`)
|
|
344
416
|
.join('\n\n');
|
|
345
417
|
|
|
346
418
|
if (!combinedText.trim()) {
|
|
@@ -386,6 +458,7 @@ export class AgentOrchestrator {
|
|
|
386
458
|
let answer = null;
|
|
387
459
|
let degraded = false;
|
|
388
460
|
let degradedReason;
|
|
461
|
+
let unverified = [];
|
|
389
462
|
|
|
390
463
|
try {
|
|
391
464
|
// Wording matters for small local models (llama3.2-class): without the
|
|
@@ -414,6 +487,34 @@ export class AgentOrchestrator {
|
|
|
414
487
|
|
|
415
488
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
416
489
|
answer = text;
|
|
490
|
+
|
|
491
|
+
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
492
|
+
// published January 18, 2024" — none of it on any fetched page (R17,
|
|
493
|
+
// 2026-09-04). Versions, dates and counts in the answer must appear in
|
|
494
|
+
// the source text; what does not gets one corrective rewrite, and
|
|
495
|
+
// whatever still remains is named in the answer and in `provenance`.
|
|
496
|
+
unverified = unverifiedValues(answer, combinedText);
|
|
497
|
+
if (unverified.length > 0) {
|
|
498
|
+
const retryPrompt =
|
|
499
|
+
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
500
|
+
`It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
|
|
501
|
+
`Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
|
|
502
|
+
`Where the sources do not state a value, say that it is not stated instead of guessing.`;
|
|
503
|
+
try {
|
|
504
|
+
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
|
|
505
|
+
if (rewritten && rewritten.trim()) {
|
|
506
|
+
answer = rewritten;
|
|
507
|
+
unverified = unverifiedValues(answer, combinedText);
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// Keep the first answer; it is flagged below.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (unverified.length > 0) {
|
|
514
|
+
answer +=
|
|
515
|
+
`\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
|
|
516
|
+
`${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
|
|
517
|
+
}
|
|
417
518
|
} catch (err) {
|
|
418
519
|
degraded = true;
|
|
419
520
|
degradedReason = `LLM synthesis unavailable: ${err.message}`;
|
|
@@ -425,11 +526,12 @@ export class AgentOrchestrator {
|
|
|
425
526
|
success: true,
|
|
426
527
|
answer,
|
|
427
528
|
search_results: searchResults,
|
|
428
|
-
evidence: degraded ? evidence : evidence.map(e => ({ url: e.url })),
|
|
529
|
+
evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
|
|
429
530
|
degraded,
|
|
430
531
|
reason: degradedReason,
|
|
431
532
|
steps: step,
|
|
432
|
-
urls_fetched: urlsFetched
|
|
533
|
+
urls_fetched: urlsFetched,
|
|
534
|
+
provenance: { checked: !degraded, unverified }
|
|
433
535
|
};
|
|
434
536
|
}
|
|
435
537
|
|
|
@@ -13,6 +13,7 @@ import { load } from 'cheerio';
|
|
|
13
13
|
import { diffWords, diffLines, diffChars } from 'diff';
|
|
14
14
|
import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
|
|
15
15
|
import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
|
|
16
|
+
import { pageTitle } from '../utils/pageTitle.js';
|
|
16
17
|
|
|
17
18
|
const ChangeTrackingSchema = z.object({
|
|
18
19
|
url: z.string().url(),
|
|
@@ -892,7 +893,7 @@ export class ChangeTracker extends EventEmitter {
|
|
|
892
893
|
|
|
893
894
|
extractMetadata($, options) {
|
|
894
895
|
const metadata = {
|
|
895
|
-
title: $
|
|
896
|
+
title: pageTitle($) || '',
|
|
896
897
|
headings: [],
|
|
897
898
|
links: [],
|
|
898
899
|
images: [],
|
|
@@ -9,6 +9,7 @@ import { safeFetch } from '../utils/ssrfGuard.js';
|
|
|
9
9
|
import { resolveUserAgent } from '../utils/fetchIdentity.js';
|
|
10
10
|
import { preflightFetch } from '../utils/robotsGate.js';
|
|
11
11
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
12
|
+
import { pageTitle } from '../utils/pageTitle.js';
|
|
12
13
|
|
|
13
14
|
const logger = new Logger('LLMsTxtAnalyzer');
|
|
14
15
|
|
|
@@ -722,7 +723,7 @@ export class LLMsTxtAnalyzer {
|
|
|
722
723
|
type: 'static',
|
|
723
724
|
confidence: 0.7,
|
|
724
725
|
metadata: {
|
|
725
|
-
title: $
|
|
726
|
+
title: pageTitle($),
|
|
726
727
|
contentLength: html.length
|
|
727
728
|
}
|
|
728
729
|
};
|
|
@@ -262,7 +262,7 @@ export class LocalizationManager extends EventEmitter {
|
|
|
262
262
|
timezone: validatedInput.timezone || countryData.timezone,
|
|
263
263
|
currency: validatedInput.currency || countryData.currency,
|
|
264
264
|
searchDomain: countryData.searchDomain,
|
|
265
|
-
acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language),
|
|
265
|
+
acceptLanguage: this.buildAcceptLanguageHeader(validatedInput.language || countryData.language, validatedInput.countryCode),
|
|
266
266
|
customHeaders: validatedInput.customHeaders || {},
|
|
267
267
|
geoLocation: validatedInput.geoLocation,
|
|
268
268
|
proxySettings: validatedInput.proxySettings,
|
|
@@ -601,8 +601,18 @@ export class LocalizationManager extends EventEmitter {
|
|
|
601
601
|
return await this.configureCountry(countryCode);
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
-
|
|
605
|
-
|
|
604
|
+
/**
|
|
605
|
+
* The header a browser in `countryCode` sends for `language`. A bare
|
|
606
|
+
* language takes the country as its region — configure_country GB used to
|
|
607
|
+
* answer en-US (R18, 2026-09-04) because the country never reached here.
|
|
608
|
+
*/
|
|
609
|
+
buildAcceptLanguageHeader(language, countryCode) {
|
|
610
|
+
const [langCode, region] = String(language).split('-');
|
|
611
|
+
const cc = region || (countryCode ? String(countryCode).toUpperCase() : null);
|
|
612
|
+
if (cc) {
|
|
613
|
+
const tag = `${langCode}-${cc}`;
|
|
614
|
+
return langCode === 'en' ? `${tag},en;q=0.9` : `${tag},${langCode};q=0.9,en;q=0.8`;
|
|
615
|
+
}
|
|
606
616
|
return LANGUAGE_MAPPINGS[langCode] || `${language},${langCode};q=0.9,en;q=0.8`;
|
|
607
617
|
}
|
|
608
618
|
|
|
@@ -679,11 +689,15 @@ export class LocalizationManager extends EventEmitter {
|
|
|
679
689
|
return this.timezoneCache.get(timezone);
|
|
680
690
|
}
|
|
681
691
|
|
|
682
|
-
// Calculate dynamically if not cached
|
|
692
|
+
// Calculate dynamically if not cached. Compare the target zone against
|
|
693
|
+
// UTC, never against the host zone: the old form added the host offset
|
|
694
|
+
// once more, so America/Sao_Paulo came out as -420 from a UTC-4 host
|
|
695
|
+
// (R17, 2026-09-04). Both wall-clock strings are parsed the same way, so
|
|
696
|
+
// the difference is the zone's current offset in minutes.
|
|
683
697
|
const now = new Date();
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
const offset = (
|
|
698
|
+
const asTarget = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
|
699
|
+
const asUtc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
|
|
700
|
+
const offset = Math.round((asTarget.getTime() - asUtc.getTime()) / 60000);
|
|
687
701
|
|
|
688
702
|
this.timezoneCache.set(timezone, offset);
|
|
689
703
|
return offset;
|
|
@@ -1344,10 +1358,13 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1344
1358
|
}
|
|
1345
1359
|
|
|
1346
1360
|
// CJK script detection
|
|
1361
|
+
// Kana and Hangul are tested before the unified ideographs: Japanese
|
|
1362
|
+
// text is full of kanji, so a chinese-first order labelled every
|
|
1363
|
+
// Japanese page "chinese" (R17, 2026-09-04).
|
|
1347
1364
|
const cjkPatterns = {
|
|
1348
|
-
chinese: /[\u4E00-\u9FFF]/,
|
|
1349
1365
|
japanese: /[\u3040-\u309F\u30A0-\u30FF]/,
|
|
1350
|
-
korean: /[\uAC00-\uD7AF]
|
|
1366
|
+
korean: /[\uAC00-\uD7AF]/,
|
|
1367
|
+
chinese: /[\u4E00-\u9FFF]/
|
|
1351
1368
|
};
|
|
1352
1369
|
|
|
1353
1370
|
for (const [script, pattern] of Object.entries(cjkPatterns)) {
|
|
@@ -79,6 +79,41 @@ const MAX_CONFLICT_PAIRS = 40;
|
|
|
79
79
|
* - Advanced provenance tracking and activity logging
|
|
80
80
|
* - Smart URL prioritization based on content quality
|
|
81
81
|
*/
|
|
82
|
+
// A research topic is often a paragraph ("Why does X get stuck on Y and what
|
|
83
|
+
// makes Z pass it in 2026: A, B, C, D and known working approaches"). Google
|
|
84
|
+
// answers a 40-word query with nothing: the same run found 1 source from the
|
|
85
|
+
// paragraph and 8 from a six-word restatement (R18, 2026-09-04). The full
|
|
86
|
+
// topic still steers the LLM expansion and the relevance ranking; the search
|
|
87
|
+
// backend gets a keyword-dense form of it.
|
|
88
|
+
export const SEARCH_TOPIC_MAX_WORDS = 12;
|
|
89
|
+
export const SEARCH_QUERY_MAX_WORDS = 16;
|
|
90
|
+
const TOPIC_STOPWORDS = new Set([
|
|
91
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'for', 'with', 'from', 'by', 'as', 'at', 'into',
|
|
92
|
+
'what', 'why', 'how', 'when', 'where', 'which', 'who', 'whom', 'whose', 'that', 'this', 'these', 'those',
|
|
93
|
+
'does', 'do', 'did', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'their', 'there', 'they',
|
|
94
|
+
'can', 'could', 'should', 'would', 'will', 'may', 'might', 'get', 'gets', 'make', 'makes', 'made', 'about',
|
|
95
|
+
'known', 'required', 'versus', 'vs', 'i', 'we', 'you', 'my', 'our', 'your', 'me', 'us', 'them', 'not', 'no', 'so'
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
export function compactSearchTopic(topic) {
|
|
99
|
+
const clean = String(topic || '').replace(/\s+/g, ' ').trim();
|
|
100
|
+
const words = clean.split(' ').filter(Boolean);
|
|
101
|
+
if (words.length <= SEARCH_TOPIC_MAX_WORDS) return clean;
|
|
102
|
+
const bare = clean.replace(/[“”"'`()[\]{}:;,?!.]/g, ' ').split(/\s+/).filter(Boolean);
|
|
103
|
+
const kept = [];
|
|
104
|
+
for (const word of bare) {
|
|
105
|
+
if (TOPIC_STOPWORDS.has(word.toLowerCase())) continue;
|
|
106
|
+
kept.push(word);
|
|
107
|
+
if (kept.length >= SEARCH_TOPIC_MAX_WORDS) break;
|
|
108
|
+
}
|
|
109
|
+
return (kept.length > 0 ? kept : bare.slice(0, SEARCH_TOPIC_MAX_WORDS)).join(' ');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clampSearchQuery(query, maxWords = SEARCH_QUERY_MAX_WORDS) {
|
|
113
|
+
const words = String(query || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
|
|
114
|
+
return (words.length > maxWords ? words.slice(0, maxWords) : words).join(' ');
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
export class ResearchOrchestrator extends EventEmitter {
|
|
83
118
|
constructor(options = {}) {
|
|
84
119
|
super();
|
|
@@ -374,9 +409,17 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
374
409
|
});
|
|
375
410
|
}
|
|
376
411
|
|
|
377
|
-
// Add research-specific query variations
|
|
378
|
-
|
|
379
|
-
|
|
412
|
+
// Add research-specific query variations. The search backend sees the
|
|
413
|
+
// keyword-dense form of a long topic; every query is clamped to what a
|
|
414
|
+
// search engine answers.
|
|
415
|
+
const searchTopic = compactSearchTopic(topic);
|
|
416
|
+
if (searchTopic !== topic) {
|
|
417
|
+
this.logger.info('Long research topic compacted for search', { topic, searchTopic });
|
|
418
|
+
}
|
|
419
|
+
const researchVariations = this.generateResearchVariations(searchTopic);
|
|
420
|
+
const allQueries = [...new Set(
|
|
421
|
+
[searchTopic, ...expandedQueries, ...researchVariations].map((q) => clampSearchQuery(q)).filter(Boolean)
|
|
422
|
+
)];
|
|
380
423
|
|
|
381
424
|
// Rank queries by research relevance with semantic understanding
|
|
382
425
|
const rankedQueries = await this.rankResearchQueriesWithSemantics(allQueries, topic);
|
|
@@ -390,7 +433,7 @@ export class ResearchOrchestrator extends EventEmitter {
|
|
|
390
433
|
} catch (error) {
|
|
391
434
|
this.logger.warn('Topic expansion failed, using original topic', { error: error.message });
|
|
392
435
|
this.metrics.queryExpansionTime += Date.now() - startTime;
|
|
393
|
-
return [topic];
|
|
436
|
+
return [compactSearchTopic(topic)];
|
|
394
437
|
}
|
|
395
438
|
}
|
|
396
439
|
|