crawlforge-mcp-server 6.21.1 → 6.22.1
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 +3 -2
- package/server.js +12 -12
- package/src/core/ActionExecutor.js +23 -4
- package/src/core/AgentOrchestrator.js +228 -21
- package/src/core/AuthManager.js +4 -1
- package/src/core/LLMsTxtAnalyzer.js +6 -7
- package/src/core/LocalizationManager.js +29 -4
- package/src/core/ResearchOrchestrator.js +76 -13
- package/src/core/SamplingClient.js +38 -8
- package/src/core/SnapshotManager.js +32 -13
- package/src/core/analysis/ContentAnalyzer.js +26 -25
- package/src/core/crawlers/BFSCrawler.js +48 -10
- package/src/core/processing/ContentProcessor.js +4 -2
- package/src/core/queue/QueueManager.js +16 -2
- package/src/resources/ResourceRegistry.js +19 -5
- package/src/schemas/toolOutputSchemas.js +22 -14
- package/src/server/fallbackHints.js +37 -7
- package/src/server/withAuth.js +27 -44
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/references/credits.md +1 -1
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +11 -14
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
- package/src/tools/advanced/BrowserSessionTool.js +11 -2
- package/src/tools/advanced/batchScrape/queue.js +4 -2
- package/src/tools/advanced/batchScrape/worker.js +50 -11
- package/src/tools/agent/agent.js +1 -1
- package/src/tools/basic/_fetch.js +2 -1
- package/src/tools/basic/extractMetadata.js +2 -1
- package/src/tools/basic/extractText.js +12 -8
- package/src/tools/crawl/mapSite.js +8 -0
- package/src/tools/extract/extractContent.js +2 -1
- package/src/tools/extract/extractStructured.js +4 -2
- package/src/tools/extract/extractWithLlm.js +8 -4
- package/src/tools/extract/processDocument.js +29 -1
- package/src/tools/extract/summarizeContent.js +5 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +6 -2
- package/src/tools/scrape/_mainContent.js +54 -2
- package/src/tools/scrape/unifiedScrape.js +94 -6
- package/src/tools/search/providers/searxng.js +20 -3
- package/src/tools/search/ranking/ResultDeduplicator.js +51 -65
- package/src/tools/tracking/trackChanges/differ.js +2 -1
- package/src/tools/tracking/trackChanges/index.js +6 -1
- package/src/tools/tracking/trackChanges/schema.js +15 -1
- package/src/utils/fetchError.js +47 -0
- package/src/utils/fetchLadder.js +5 -5
- package/src/utils/htmlToMarkdown.js +36 -2
- package/src/utils/ssrfGuard.js +2 -1
|
@@ -27,7 +27,8 @@ import { toPublicUnit, parseChosenIndexes, groundingCheck, headingUnits, NOT_IN_
|
|
|
27
27
|
import { setActualCost } from '../../server/requestContext.js';
|
|
28
28
|
import { fenceUntrusted } from '../../utils/untrustedContent.js';
|
|
29
29
|
import { fetchAndParse } from '../extract/_fetchAndParse.js';
|
|
30
|
-
import {
|
|
30
|
+
import { fetchErrorMessage } from '../../utils/fetchError.js';
|
|
31
|
+
import { extractMainContent, isThinMainContent, keepPageHeading, MAX_READABILITY_ELEMENTS } from './_mainContent.js';
|
|
31
32
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
32
33
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
33
34
|
import { extractBlockText } from '../basic/extractText.js';
|
|
@@ -53,6 +54,85 @@ export const JSON_TIMEOUT_MS = 60000;
|
|
|
53
54
|
|
|
54
55
|
const isQueryFormat = (fmt) => Boolean(fmt) && typeof fmt === 'object' && (fmt.type === 'highlights' || fmt.type === 'question');
|
|
55
56
|
|
|
57
|
+
// ── Question evidence support (R25 1.4) ──────────────────────────────────────
|
|
58
|
+
// A ranked unit is not support for an answer by being ranked: before
|
|
59
|
+
// crawlforge-extractors 1.18.0 the ranker scored link URLs, and Wikipedia's
|
|
60
|
+
// reference list answered "Who created Python…?" with citation titles — as
|
|
61
|
+
// grounded:true. These checks read only the units this call returns, so they
|
|
62
|
+
// hold with any extractors version.
|
|
63
|
+
const EVIDENCE_LINK = /(!?)\[((?:\\.|[^[\]\\])*)\]\((?:\\.|[^()\\\s]|\([^()\s]*\))*(?:\s+"(?:\\.|[^"\\])*")?\)/g;
|
|
64
|
+
const REFERENCE_HEADING = /^(?:(?:references?|notes?|citations?|sources?|footnotes?|bibliography|works cited)(?:\s*(?:,|&|and)\s*|\s*$))+$/i;
|
|
65
|
+
// Words any question shares with any page; they show no unit answers it.
|
|
66
|
+
const QUESTION_STOPWORDS = new Set([
|
|
67
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'from', 'as', 'into', 'about',
|
|
68
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'do', 'does', 'did', 'has', 'have', 'had', 'can', 'could',
|
|
69
|
+
'will', 'would', 'should', 'may', 'might', 'it', 'its', 'this', 'that', 'these', 'those', 'there', 'their', 'they',
|
|
70
|
+
'he', 'she', 'his', 'her', 'i', 'you', 'we', 'my', 'your', 'our', 'me', 'us', 'them', 'any', 'some', 'not', 'no',
|
|
71
|
+
'who', 'whom', 'whose', 'what', 'which', 'when', 'where', 'why', 'how', 'many', 'much'
|
|
72
|
+
]);
|
|
73
|
+
// crawlforge-extractors' word runs: a CJK run and any other run never merge.
|
|
74
|
+
const CJK_CHAR = '[\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}]';
|
|
75
|
+
const QUESTION_TOKEN = new RegExp(`(?:(?=${CJK_CHAR})[\\p{L}\\p{N}])+|(?:(?!${CJK_CHAR})[\\p{L}\\p{N}\\p{M}])+`, 'gu');
|
|
76
|
+
const CJK_RUN = new RegExp(`^${CJK_CHAR}`, 'u');
|
|
77
|
+
|
|
78
|
+
/** A unit's text as a reader sees it: link labels without URLs, no citation markers. */
|
|
79
|
+
function visibleEvidenceText(text) {
|
|
80
|
+
let out = String(text ?? '');
|
|
81
|
+
for (let pass = 0; pass < 2 && out.includes(']('); pass++) {
|
|
82
|
+
out = out.replace(EVIDENCE_LINK, (match, _bang, label) => (/\]\(#cite_(?:note|ref)/.test(match) ? ' ' : label));
|
|
83
|
+
}
|
|
84
|
+
return out.toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** An entry of a references-type list: under such a heading, or carrying a Wikipedia back-link. */
|
|
88
|
+
function isReferenceUnit(unit) {
|
|
89
|
+
if (/\]\(#cite_ref/.test(String(unit.text ?? ''))) return true;
|
|
90
|
+
return typeof unit.heading === 'string' && REFERENCE_HEADING.test(unit.heading.trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The question's content terms, stemmed lightly ("released" → "releas") so a
|
|
95
|
+
* substring test finds their inflections; a CJK run becomes bigrams.
|
|
96
|
+
*/
|
|
97
|
+
function questionTerms(question) {
|
|
98
|
+
const terms = new Set();
|
|
99
|
+
for (const [run] of String(question ?? '').toLowerCase().matchAll(QUESTION_TOKEN)) {
|
|
100
|
+
if (CJK_RUN.test(run)) {
|
|
101
|
+
const chars = Array.from(run);
|
|
102
|
+
if (chars.length === 1) terms.add(run);
|
|
103
|
+
for (let i = 0; i + 1 < chars.length; i++) terms.add(chars[i] + chars[i + 1]);
|
|
104
|
+
} else if (!QUESTION_STOPWORDS.has(run)) {
|
|
105
|
+
terms.add(run.length > 4 ? run.replace(/(?:ing|ed|es|s)$/, '').replace(/(.{4,})e$/, '$1') : run);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...terms];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the ranked evidence can ground an answer: some unit outside a
|
|
113
|
+
* reference list whose visible text (or heading) holds a content term of the
|
|
114
|
+
* question. Returns the reason when it cannot.
|
|
115
|
+
* @param {Array<{ text: string, heading?: string | null }>} evidence
|
|
116
|
+
* @param {string} question
|
|
117
|
+
* @returns {{ supported: boolean, reason?: string }}
|
|
118
|
+
*/
|
|
119
|
+
export function evidenceSupport(evidence, question) {
|
|
120
|
+
if (evidence.length === 0) return { supported: false };
|
|
121
|
+
const body = evidence.filter((unit) => !isReferenceUnit(unit));
|
|
122
|
+
if (body.length === 0) {
|
|
123
|
+
return { supported: false, reason: 'every evidence unit is a reference-list entry (References, Notes, Citations or Sources)' };
|
|
124
|
+
}
|
|
125
|
+
const terms = questionTerms(question);
|
|
126
|
+
if (terms.length === 0) return { supported: true };
|
|
127
|
+
const holds = body.some((unit) => {
|
|
128
|
+
const visible = `${visibleEvidenceText(unit.text)}\n${visibleEvidenceText(unit.heading)}`;
|
|
129
|
+
return terms.some((term) => visible.includes(term));
|
|
130
|
+
});
|
|
131
|
+
return holds
|
|
132
|
+
? { supported: true }
|
|
133
|
+
: { supported: false, reason: 'no evidence unit outside a reference list contains a term of the question in its visible text' };
|
|
134
|
+
}
|
|
135
|
+
|
|
56
136
|
// The six public fields in tools/list order, each carrying the description
|
|
57
137
|
// the client sees. server.js spreads this with COMPLIANCE_PARAMS instead of
|
|
58
138
|
// keeping its own copy (Phase 0, 0.3).
|
|
@@ -359,7 +439,7 @@ export class UnifiedScrapeTool {
|
|
|
359
439
|
errorDocuments: true
|
|
360
440
|
}));
|
|
361
441
|
} catch (err) {
|
|
362
|
-
throw new Error(`scrape: fetch failed for ${url}: ${err
|
|
442
|
+
throw new Error(`scrape: fetch failed for ${url}: ${fetchErrorMessage(err, url)}`);
|
|
363
443
|
}
|
|
364
444
|
warnings.push(...fetchWarnings);
|
|
365
445
|
|
|
@@ -504,6 +584,11 @@ export class UnifiedScrapeTool {
|
|
|
504
584
|
if (mainHtml !== null) return mainHtml;
|
|
505
585
|
const main = extractMainContent(html, finalUrl);
|
|
506
586
|
mainHtml = main.html ?? html;
|
|
587
|
+
if (main.tooLarge) {
|
|
588
|
+
warnings.push(
|
|
589
|
+
`mainContent: the page has more than ${MAX_READABILITY_ELEMENTS.toLocaleString('en-US')} elements, too many for main-content extraction; the whole page is used instead`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
507
592
|
if (main.tablesRecovered > 0) {
|
|
508
593
|
warnings.push(
|
|
509
594
|
`mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
|
|
@@ -638,7 +723,7 @@ export class UnifiedScrapeTool {
|
|
|
638
723
|
`json: output did not match the requested schema — ${(result.validationErrors || []).join('; ')}`
|
|
639
724
|
);
|
|
640
725
|
}
|
|
641
|
-
if (result.
|
|
726
|
+
if (result.input_truncated) {
|
|
642
727
|
warnings.push(
|
|
643
728
|
`json: page text was truncated from ${result.original_length} chars before extraction; ` +
|
|
644
729
|
'fields appearing late in the page may be missing'
|
|
@@ -686,8 +771,11 @@ export class UnifiedScrapeTool {
|
|
|
686
771
|
const evidence = rankUnits(candidates, fmt.question, { maxUnits: QUESTION_EVIDENCE_UNITS });
|
|
687
772
|
const evidenceText = evidence.map((unit) => unit.text).join('\n');
|
|
688
773
|
let text = evidenceText;
|
|
689
|
-
// An empty answer rests on nothing, so it is never grounded
|
|
690
|
-
|
|
774
|
+
// An empty answer rests on nothing, so it is never grounded; nor
|
|
775
|
+
// does one resting on reference-list entries or link URLs alone.
|
|
776
|
+
const support = evidenceSupport(evidence, fmt.question);
|
|
777
|
+
if (support.reason) warnings.push(`question: ${support.reason}; grounded: false`);
|
|
778
|
+
let grounded = support.supported;
|
|
691
779
|
if (fmt.mode === 'model' && evidence.length > 0) {
|
|
692
780
|
try {
|
|
693
781
|
text = await this._answerQuestion(evidence, fmt.question);
|
|
@@ -698,7 +786,7 @@ export class UnifiedScrapeTool {
|
|
|
698
786
|
warnings.push('question: the model found no answer in the evidence; grounded: false');
|
|
699
787
|
} else {
|
|
700
788
|
const check = groundingCheck(text, evidenceText, fmt.question);
|
|
701
|
-
grounded = check.grounded;
|
|
789
|
+
grounded = check.grounded && support.supported;
|
|
702
790
|
if (!grounded) {
|
|
703
791
|
warnings.push(`question: the model's answer has ${check.unbacked.length} token(s) not found in the evidence or the question (${check.unbacked.join(', ')}); grounded: false`);
|
|
704
792
|
}
|
|
@@ -94,6 +94,7 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
94
94
|
url.searchParams.set('safesearch', String(safesearch));
|
|
95
95
|
url.searchParams.set('language', language);
|
|
96
96
|
|
|
97
|
+
const startedAt = Date.now();
|
|
97
98
|
let response;
|
|
98
99
|
try {
|
|
99
100
|
response = await fetch(url.toString(), {
|
|
@@ -104,7 +105,10 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
104
105
|
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
|
105
106
|
throw new Error('SearXNG request failed: timed out after 15000ms');
|
|
106
107
|
}
|
|
107
|
-
throw new Error(
|
|
108
|
+
throw new Error(
|
|
109
|
+
`SearXNG is not reachable at ${instanceUrl}: ${err.cause?.code || err.message}.\n` +
|
|
110
|
+
'Next step: start the instance (for the Docker container: "docker start searxng"), or call search_web without provider:"searxng".'
|
|
111
|
+
);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
if (!response.ok) {
|
|
@@ -121,13 +125,26 @@ export async function searchViaSearxng(opts = {}) {
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
const rawResults = Array.isArray(data.results) ? data.results : [];
|
|
128
|
+
|
|
129
|
+
// Every engine failing still comes back HTTP 200 with no results; that is
|
|
130
|
+
// not an empty search (R25 3.3). Entries are [engine, reason] pairs.
|
|
131
|
+
const unresponsive = Array.isArray(data.unresponsive_engines) ? data.unresponsive_engines : [];
|
|
132
|
+
if (rawResults.length === 0 && unresponsive.length > 0) {
|
|
133
|
+
const engines = unresponsive.map((e) => (Array.isArray(e) ? `${e[0]} (${e[1]})` : String(e))).join(', ');
|
|
134
|
+
throw new Error(
|
|
135
|
+
`SearXNG returned no results because its engines failed: ${engines}.\n` +
|
|
136
|
+
'Next step: retry in a minute, or call search_web without provider:"searxng".'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
const items = rawResults.slice(0, limit).map(normalizeSearxngResult);
|
|
125
141
|
|
|
126
142
|
return {
|
|
127
143
|
items,
|
|
128
144
|
searchInformation: {
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
// SearXNG reports 0 when no engine gave a count; that is unknown, not zero.
|
|
146
|
+
totalResults: data.number_of_results || null,
|
|
147
|
+
searchTime: (Date.now() - startedAt) / 1000
|
|
131
148
|
},
|
|
132
149
|
queries: {},
|
|
133
150
|
context: {}
|
|
@@ -119,7 +119,6 @@ export class ResultDeduplicator {
|
|
|
119
119
|
...result,
|
|
120
120
|
originalIndex: index,
|
|
121
121
|
normalizedUrl: this.normalizeUrl(result.link || result.url, dedupeOptions.urlNormalization),
|
|
122
|
-
contentHash: this.computeContentHash(result, dedupeOptions.contentSimilarity),
|
|
123
122
|
titleTokens: this.tokenizeTitle(result.title || ''),
|
|
124
123
|
deduplicationInfo: {
|
|
125
124
|
originalUrl: result.link || result.url,
|
|
@@ -188,11 +187,13 @@ export class ResultDeduplicator {
|
|
|
188
187
|
const currentGroup = [results[i]];
|
|
189
188
|
processed.add(i);
|
|
190
189
|
|
|
191
|
-
// Find all duplicates of current result
|
|
190
|
+
// Find all duplicates of current result. A result joins the group only
|
|
191
|
+
// when it duplicates EVERY member, so a hub page (e.g. MDN `Array`)
|
|
192
|
+
// cannot pull in its children one by one.
|
|
192
193
|
for (let j = i + 1; j < results.length; j++) {
|
|
193
194
|
if (processed.has(j)) continue;
|
|
194
195
|
|
|
195
|
-
if (this.areDuplicates(
|
|
196
|
+
if (currentGroup.every(member => this.areDuplicates(member, results[j], options))) {
|
|
196
197
|
currentGroup.push(results[j]);
|
|
197
198
|
processed.add(j);
|
|
198
199
|
|
|
@@ -216,21 +217,24 @@ export class ResultDeduplicator {
|
|
|
216
217
|
areDuplicates(result1, result2, options) {
|
|
217
218
|
const similarities = this.computeSimilarities(result1, result2, options);
|
|
218
219
|
|
|
219
|
-
//
|
|
220
|
-
|
|
220
|
+
// The same page written differently (protocol, www, trailing slash,
|
|
221
|
+
// index.html, tracking parameters) normalizes to the same URL.
|
|
222
|
+
if (result1.normalizedUrl && result1.normalizedUrl === result2.normalizedUrl) {
|
|
221
223
|
this.stats.urlDuplicates++;
|
|
222
224
|
return true;
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (similarities.
|
|
229
|
-
|
|
227
|
+
// Different URLs merge only on similar content. Sibling pages share a
|
|
228
|
+
// URL prefix and a site title template (" - JavaScript | MDN"), so URL
|
|
229
|
+
// and title similarity alone never prove two pages are the same.
|
|
230
|
+
if (similarities.content < options.thresholds.content) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
230
233
|
|
|
231
|
-
|
|
234
|
+
// Content plus one more high-similarity signal
|
|
235
|
+
if (similarities.url >= options.thresholds.url || similarities.title >= options.thresholds.title) {
|
|
232
236
|
if (similarities.title >= options.thresholds.title) this.stats.titleDuplicates++;
|
|
233
|
-
|
|
237
|
+
this.stats.contentDuplicates++;
|
|
234
238
|
return true;
|
|
235
239
|
}
|
|
236
240
|
|
|
@@ -294,28 +298,23 @@ export class ResultDeduplicator {
|
|
|
294
298
|
}
|
|
295
299
|
|
|
296
300
|
/**
|
|
297
|
-
* Compute content similarity
|
|
301
|
+
* Compute content similarity: Jaccard over the snippets' words. A 64-bit
|
|
302
|
+
* SimHash of a ~30-word snippet is too noisy here: unrelated MDN sibling
|
|
303
|
+
* snippets scored up to 0.86 against the 0.85 threshold (R25 1.3).
|
|
298
304
|
*/
|
|
299
305
|
computeContentSimilarity(result1, result2, contentOptions) {
|
|
300
306
|
const content1 = this.extractContent(result1);
|
|
301
307
|
const content2 = this.extractContent(result2);
|
|
302
|
-
|
|
308
|
+
|
|
303
309
|
if (content1.length < contentOptions.minLength || content2.length < contentOptions.minLength) {
|
|
304
310
|
return 0;
|
|
305
311
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const hammingDistance = this.hammingDistance(hash1, hash2);
|
|
313
|
-
|
|
314
|
-
// Convert to similarity score
|
|
315
|
-
const maxDistance = contentOptions.simhashBits;
|
|
316
|
-
const similarity = 1 - (hammingDistance / maxDistance);
|
|
317
|
-
|
|
318
|
-
return Math.max(0, similarity);
|
|
312
|
+
|
|
313
|
+
const words1 = new Set(content1.split(' '));
|
|
314
|
+
const words2 = new Set(content2.split(' '));
|
|
315
|
+
const intersection = [...words1].filter(word => words2.has(word)).length;
|
|
316
|
+
|
|
317
|
+
return intersection / new Set([...words1, ...words2]).size;
|
|
319
318
|
}
|
|
320
319
|
|
|
321
320
|
/**
|
|
@@ -378,7 +377,7 @@ export class ResultDeduplicator {
|
|
|
378
377
|
// Update deduplication info
|
|
379
378
|
primaryResult.deduplicationInfo.merged = true;
|
|
380
379
|
primaryResult.deduplicationInfo.mergedDuplicates = duplicates.length;
|
|
381
|
-
primaryResult.deduplicationInfo.duplicateUrls = duplicates.map(d => d.originalUrl);
|
|
380
|
+
primaryResult.deduplicationInfo.duplicateUrls = duplicates.map(d => d.deduplicationInfo.originalUrl);
|
|
382
381
|
|
|
383
382
|
this.stats.merged += duplicates.length;
|
|
384
383
|
|
|
@@ -456,16 +455,12 @@ export class ResultDeduplicator {
|
|
|
456
455
|
if (!url) return '';
|
|
457
456
|
|
|
458
457
|
try {
|
|
459
|
-
let normalized = url;
|
|
460
|
-
|
|
461
458
|
// Parse URL
|
|
462
459
|
const urlObj = new URL(url);
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
460
|
+
|
|
461
|
+
// A fragment points into the same page
|
|
462
|
+
urlObj.hash = '';
|
|
463
|
+
|
|
469
464
|
// Remove www prefix
|
|
470
465
|
if (normalizationOptions.removeWww) {
|
|
471
466
|
urlObj.hostname = urlObj.hostname.replace(/^www\./, '');
|
|
@@ -484,19 +479,24 @@ export class ResultDeduplicator {
|
|
|
484
479
|
}
|
|
485
480
|
}
|
|
486
481
|
|
|
482
|
+
// A directory index is the directory itself
|
|
483
|
+
urlObj.pathname = urlObj.pathname.replace(/\/index\.(html?|php)$/i, '/');
|
|
484
|
+
|
|
487
485
|
// Remove trailing slash
|
|
488
486
|
if (normalizationOptions.removeTrailingSlash) {
|
|
489
487
|
urlObj.pathname = urlObj.pathname.replace(/\/$/, '') || '/';
|
|
490
488
|
}
|
|
491
|
-
|
|
489
|
+
|
|
492
490
|
// Sort and clean query parameters
|
|
493
491
|
if (normalizationOptions.sortQueryParams || normalizationOptions.removeEmptyParams) {
|
|
494
492
|
const params = new URLSearchParams(urlObj.search);
|
|
495
493
|
const sortedParams = new URLSearchParams();
|
|
496
|
-
|
|
497
|
-
// Get sorted parameter names
|
|
498
|
-
const paramNames = Array.from(params.keys())
|
|
499
|
-
|
|
494
|
+
|
|
495
|
+
// Get sorted parameter names, without tracking parameters
|
|
496
|
+
const paramNames = Array.from(params.keys())
|
|
497
|
+
.filter(name => !/^(utm_.*|gclid|fbclid|msclkid|mc_cid|mc_eid)$/i.test(name))
|
|
498
|
+
.sort();
|
|
499
|
+
|
|
500
500
|
for (const name of paramNames) {
|
|
501
501
|
const value = params.get(name);
|
|
502
502
|
if (!normalizationOptions.removeEmptyParams || (value && value.trim())) {
|
|
@@ -506,8 +506,12 @@ export class ResultDeduplicator {
|
|
|
506
506
|
|
|
507
507
|
urlObj.search = sortedParams.toString();
|
|
508
508
|
}
|
|
509
|
-
|
|
510
|
-
|
|
509
|
+
|
|
510
|
+
// Remove protocol if specified (http vs https of the same page)
|
|
511
|
+
const normalized = urlObj.toString();
|
|
512
|
+
return normalizationOptions.removeProtocol
|
|
513
|
+
? normalized.replace(/^https?:\/\//, '')
|
|
514
|
+
: normalized;
|
|
511
515
|
} catch (error) {
|
|
512
516
|
// If URL parsing fails, return cleaned original
|
|
513
517
|
return url.toLowerCase().replace(/^https?:\/\/(www\.)?/, '');
|
|
@@ -515,30 +519,12 @@ export class ResultDeduplicator {
|
|
|
515
519
|
}
|
|
516
520
|
|
|
517
521
|
/**
|
|
518
|
-
*
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const content = this.extractContent(result);
|
|
522
|
-
|
|
523
|
-
if (content.length < contentOptions.minLength) {
|
|
524
|
-
return '0'; // Default hash for short content
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return this.simHash(content, contentOptions.simhashBits);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Extract content from result for comparison
|
|
522
|
+
* Extract content from result for comparison. The snippet only: the title
|
|
523
|
+
* and domain are shared by a site's pages (a title template), so they made
|
|
524
|
+
* sibling pages look alike; the title is compared on its own anyway.
|
|
532
525
|
*/
|
|
533
526
|
extractContent(result) {
|
|
534
|
-
|
|
535
|
-
result.title || '',
|
|
536
|
-
result.snippet || '',
|
|
537
|
-
result.htmlSnippet || '',
|
|
538
|
-
result.displayLink || ''
|
|
539
|
-
];
|
|
540
|
-
|
|
541
|
-
return parts.join(' ').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
527
|
+
return (result.snippet || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
542
528
|
}
|
|
543
529
|
|
|
544
530
|
/**
|
|
@@ -104,7 +104,8 @@ export async function fetchContent(url, options = {}) {
|
|
|
104
104
|
// undici reports every network failure as bare "fetch failed" and puts
|
|
105
105
|
// the real reason (ENOTFOUND, ECONNREFUSED, timeout) in error.cause —
|
|
106
106
|
// without it, a dead host and a typoed domain read identically.
|
|
107
|
-
|
|
107
|
+
// safeFetch now names it itself (withFetchCause); don't say it twice.
|
|
108
|
+
const cause = error.cause?.message && !error.message.includes(error.cause.message) ? ` (${error.cause.message})` : '';
|
|
108
109
|
throw new Error(`Failed to fetch content: ${error.message}${cause}`);
|
|
109
110
|
}
|
|
110
111
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { EventEmitter } from 'events';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
import os from 'os';
|
|
18
19
|
import path from 'path';
|
|
19
20
|
import ChangeTracker from '../../../core/ChangeTracker.js';
|
|
@@ -191,7 +192,11 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
191
192
|
default: throw new Error(`Unknown operation: ${operation}`);
|
|
192
193
|
}
|
|
193
194
|
} catch (error) {
|
|
194
|
-
|
|
195
|
+
// A ZodError's message is its issue array as JSON; one line per issue reads better.
|
|
196
|
+
const message = error instanceof z.ZodError
|
|
197
|
+
? `Invalid input - ${error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')}`
|
|
198
|
+
: error.message;
|
|
199
|
+
return { success: false, error: message, timestamp: Date.now() };
|
|
195
200
|
}
|
|
196
201
|
}
|
|
197
202
|
|
|
@@ -16,7 +16,7 @@ import { z } from 'zod';
|
|
|
16
16
|
* `.default({})` would not.
|
|
17
17
|
*/
|
|
18
18
|
export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
19
|
-
url: z.string().url().optional().describe("The URL to track changes for
|
|
19
|
+
url: z.string().url().optional().describe("The URL to track changes for. Required for create_baseline, compare, monitor, get_history and create_scheduled_monitor; stop_scheduled_monitor takes it or scheduledMonitorOptions.monitorId; the other operations run without one"),
|
|
20
20
|
operation: z.enum([
|
|
21
21
|
'create_baseline',
|
|
22
22
|
'compare',
|
|
@@ -146,8 +146,22 @@ export const TRACK_CHANGES_INPUT_SHAPE = {
|
|
|
146
146
|
}).optional().describe("Dashboard display options")
|
|
147
147
|
};
|
|
148
148
|
|
|
149
|
+
// The operations that act on one page. The rest are global (list, dashboard,
|
|
150
|
+
// templates) or fall back to every tracked URL when url is omitted (get_stats,
|
|
151
|
+
// export_history, create_alert_rule, generate_trend_report);
|
|
152
|
+
// stop_scheduled_monitor also accepts a monitorId and checks that itself.
|
|
153
|
+
const URL_REQUIRED_OPERATIONS = new Set(['create_baseline', 'compare', 'monitor', 'get_history', 'create_scheduled_monitor']);
|
|
154
|
+
|
|
155
|
+
// The refinement lives here, not in the registered shape: server.js registers
|
|
156
|
+
// TRACK_CHANGES_INPUT_SHAPE as a raw shape, which cannot carry one, so
|
|
157
|
+
// execute() enforces it. compare without a url used to fail with "Failed to
|
|
158
|
+
// parse URL from undefined" plus a create_baseline hint (R25 3.3).
|
|
149
159
|
export const TrackChangesSchema = z.object({
|
|
150
160
|
...TRACK_CHANGES_INPUT_SHAPE,
|
|
151
161
|
respect_robots: z.boolean().optional(),
|
|
152
162
|
user_agent: z.string().optional()
|
|
163
|
+
}).superRefine((params, ctx) => {
|
|
164
|
+
if (!params.url && URL_REQUIRED_OPERATIONS.has(params.operation)) {
|
|
165
|
+
ctx.addIssue({ code: 'custom', path: ['url'], message: `required for operation "${params.operation}"` });
|
|
166
|
+
}
|
|
153
167
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* undici rejects a DNS, refused or reset connection with a bare TypeError
|
|
3
|
+
* "fetch failed" and keeps the reason (`getaddrinfo ENOTFOUND host`,
|
|
4
|
+
* `ECONNREFUSED`) in `cause`. A tool that reports only `error.message` makes a
|
|
5
|
+
* typoed domain and a dead host read the same, and the not_found hint in
|
|
6
|
+
* fallbackHints.js, which keys on ENOTFOUND, never matches (R25 3.1).
|
|
7
|
+
*
|
|
8
|
+
* @param {Error} error
|
|
9
|
+
* @param {string} url
|
|
10
|
+
* @returns {string} "Could not reach <url> (<reason>)" for that error, as the
|
|
11
|
+
* REST route's FETCH_FAILED says it; any other error's own message
|
|
12
|
+
*/
|
|
13
|
+
export function fetchErrorMessage(error, url) {
|
|
14
|
+
if (!(error instanceof TypeError) || error.message !== 'fetch failed') return error.message;
|
|
15
|
+
const reason = error.cause?.message || error.cause?.code;
|
|
16
|
+
return `Could not reach ${url}${reason ? ` (${reason})` : ''}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The error to rethrow: undici's bare "fetch failed" becomes a TypeError
|
|
21
|
+
* with fetchErrorMessage's text, keeping undici's cause and its code, so
|
|
22
|
+
* `err.cause?.code` checks and RetryManager's code list still see
|
|
23
|
+
* ENOTFOUND / ECONNREFUSED. Any other error comes back unchanged. Applied in
|
|
24
|
+
* safeFetch and fetchWithTimeout, so every tool that prints only
|
|
25
|
+
* `err.message` names the cause.
|
|
26
|
+
*
|
|
27
|
+
* The URL is named without its query or credentials: these two also fetch
|
|
28
|
+
* key-based connector URLs (`?key=...`), and the message reaches the caller.
|
|
29
|
+
*
|
|
30
|
+
* @param {Error} error
|
|
31
|
+
* @param {string|URL} url
|
|
32
|
+
* @returns {Error}
|
|
33
|
+
*/
|
|
34
|
+
export function withFetchCause(error, url) {
|
|
35
|
+
let shown;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = new URL(url);
|
|
38
|
+
shown = `${parsed.origin}${parsed.pathname}`;
|
|
39
|
+
} catch {
|
|
40
|
+
shown = 'the URL';
|
|
41
|
+
}
|
|
42
|
+
const message = fetchErrorMessage(error, shown);
|
|
43
|
+
if (message === error.message) return error;
|
|
44
|
+
const wrapped = new TypeError(message, { cause: error.cause });
|
|
45
|
+
if (error.cause?.code) wrapped.code = error.cause.code;
|
|
46
|
+
return wrapped;
|
|
47
|
+
}
|
package/src/utils/fetchLadder.js
CHANGED
|
@@ -24,6 +24,7 @@ import { fetchWithTimeout } from '../tools/basic/_fetch.js';
|
|
|
24
24
|
import { plainVerdict, targetFailure } from '../tools/basic/_targetFailure.js';
|
|
25
25
|
import { stealthDocumentVerdict } from './stealthVerdict.js';
|
|
26
26
|
import { parseRetryAfter } from './hostRateLimiter.js';
|
|
27
|
+
import { fetchErrorMessage } from './fetchError.js';
|
|
27
28
|
import { setActualCost } from '../server/requestContext.js';
|
|
28
29
|
import { SCRAPE_ESCALATION_SHAPE, SCRAPE_ESCALATION_CREDITS, aBrowserMightPass } from '../tools/scrape/escalation.js';
|
|
29
30
|
|
|
@@ -93,16 +94,15 @@ function retryAfterMs(response) {
|
|
|
93
94
|
|
|
94
95
|
/**
|
|
95
96
|
* fetchWithTimeout, with undici's bare "fetch failed" (DNS, refused, reset)
|
|
96
|
-
* given the URL and the reason it keeps in `cause
|
|
97
|
-
* FETCH_FAILED "Could not reach <url>" does.
|
|
97
|
+
* given the URL and the reason it keeps in `cause` (fetchErrorMessage).
|
|
98
98
|
*/
|
|
99
99
|
async function fetchOnce(url, options) {
|
|
100
100
|
try {
|
|
101
101
|
return await fetchWithTimeout(url, options);
|
|
102
102
|
} catch (error) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
throw new Error(
|
|
103
|
+
const message = fetchErrorMessage(error, url);
|
|
104
|
+
if (message === error.message) throw error;
|
|
105
|
+
throw new Error(message, { cause: error });
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -52,6 +52,39 @@ export function promoteCornerHeaderCells(html) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const ROW_CONTAINER = /^(table|thead|tbody|tfoot)$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Readability serializes a layout table it half-kept as `<tbody>`/`<tr>` with
|
|
59
|
+
* no `<table>` around them. An HTML parser drops row and cell tags outside a
|
|
60
|
+
* table, so on Hacker News every story's cells ran together into one line
|
|
61
|
+
* ("…(github.com/x)62 points by … 39 comments2.") and the layout-table rules
|
|
62
|
+
* never saw a row (R25, 2026-10-19). htmlparser2 keeps the orphans, so they
|
|
63
|
+
* are found there and each run of adjacent ones is wrapped in a `<table>`.
|
|
64
|
+
* @param {string} html
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function wrapOrphanRows(html) {
|
|
68
|
+
if (!/<(tr|tbody|thead|tfoot)[\s>]/i.test(html)) return html;
|
|
69
|
+
try {
|
|
70
|
+
const $ = load(html, { xml: { xmlMode: false, decodeEntities: false } }, false);
|
|
71
|
+
const created = new Set();
|
|
72
|
+
$('thead, tbody, tfoot, tr').each((_, el) => {
|
|
73
|
+
if (el.parent && ROW_CONTAINER.test(el.parent.name)) return;
|
|
74
|
+
const prev = $(el).prev();
|
|
75
|
+
if (prev.length && created.has(prev[0])) {
|
|
76
|
+
prev.append(el);
|
|
77
|
+
} else {
|
|
78
|
+
$(el).wrap('<table></table>');
|
|
79
|
+
created.add(el.parent);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return created.size > 0 ? $.html() : html;
|
|
83
|
+
} catch {
|
|
84
|
+
return html;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
// Mirrors turndown-plugin-gfm's own heading-row test, which is what decides
|
|
56
89
|
// whether it converts a table or keeps it as raw HTML.
|
|
57
90
|
function isHeadingRow(tr) {
|
|
@@ -141,9 +174,10 @@ export function htmlToMarkdown(html, options = {}) {
|
|
|
141
174
|
// Drop content a browser would not paint before converting. Turndown keeps
|
|
142
175
|
// the text of screen-reader-only labels and state-gated badges, which then
|
|
143
176
|
// reads as live page content once the CSS is gone.
|
|
177
|
+
const rows = wrapOrphanRows(html);
|
|
144
178
|
const visible = options.keepHiddenContent
|
|
145
|
-
?
|
|
146
|
-
: stripHiddenHtml(
|
|
179
|
+
? rows
|
|
180
|
+
: stripHiddenHtml(rows, { css: options.css });
|
|
147
181
|
return getTurndown().turndown(promoteCornerHeaderCells(visible)).trim();
|
|
148
182
|
} catch {
|
|
149
183
|
// Fallback: strip tags, return plain text
|
package/src/utils/ssrfGuard.js
CHANGED
|
@@ -36,6 +36,7 @@ import { Agent, buildConnector } from 'undici';
|
|
|
36
36
|
import { config } from '../constants/config.js';
|
|
37
37
|
import { SSRFProtection } from './ssrfProtection.js';
|
|
38
38
|
import { fetchResigned } from './resignedFetch.js';
|
|
39
|
+
import { withFetchCause } from './fetchError.js';
|
|
39
40
|
|
|
40
41
|
// Reused only for its (well-tested) CIDR range math — no network state.
|
|
41
42
|
const _ssrf = new SSRFProtection();
|
|
@@ -342,7 +343,7 @@ export async function safeFetch(url, options = {}) {
|
|
|
342
343
|
if (isSsrfError(err)) {
|
|
343
344
|
throw new Error(err.cause?.message || err.message);
|
|
344
345
|
}
|
|
345
|
-
throw err;
|
|
346
|
+
throw withFetchCause(err, url);
|
|
346
347
|
}
|
|
347
348
|
}
|
|
348
349
|
|