crawlforge-mcp-server 5.2.9 → 5.3.0
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/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +473 -0
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -258,7 +258,43 @@ function extractColors(cssText, $, cssVariables) {
|
|
|
258
258
|
.slice(0, 24);
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
// Splits a CSS value on commas that are not inside parentheses, so a
|
|
262
|
+
// `var(--x, sans-serif)` reference survives as one entry instead of being cut in half.
|
|
263
|
+
function splitTopLevel(value) {
|
|
264
|
+
const parts = [];
|
|
265
|
+
let depth = 0;
|
|
266
|
+
let cur = '';
|
|
267
|
+
for (const ch of value) {
|
|
268
|
+
if (ch === '(') depth++;
|
|
269
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
270
|
+
if (ch === ',' && depth === 0) {
|
|
271
|
+
parts.push(cur);
|
|
272
|
+
cur = '';
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
cur += ch;
|
|
276
|
+
}
|
|
277
|
+
parts.push(cur);
|
|
278
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Resolves a `var(--x)` / `var(--x, fallback)` entry against the collected variables,
|
|
282
|
+
// returning the family names it stands for. An unresolvable reference yields nothing
|
|
283
|
+
// rather than leaking `var(` text into the font list.
|
|
284
|
+
function resolveVarRef(entry, cssVariables, seen) {
|
|
285
|
+
const m = entry.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]*))?\)$/);
|
|
286
|
+
if (!m) return [entry];
|
|
287
|
+
const name = m[1];
|
|
288
|
+
const fallback = m[2];
|
|
289
|
+
if (!seen.has(name)) {
|
|
290
|
+
seen.add(name);
|
|
291
|
+
const value = cssVariables[name];
|
|
292
|
+
if (value) return splitTopLevel(value).flatMap((v) => resolveVarRef(v, cssVariables, seen));
|
|
293
|
+
}
|
|
294
|
+
return fallback ? splitTopLevel(fallback).flatMap((v) => resolveVarRef(v, cssVariables, seen)) : [];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function extractFonts(cssText, $, cssVariables) {
|
|
262
298
|
const families = new Map();
|
|
263
299
|
const generics = new Set();
|
|
264
300
|
const fontFaces = [];
|
|
@@ -266,7 +302,10 @@ function extractFonts(cssText, $) {
|
|
|
266
302
|
const ffRe = /font-family\s*:\s*([^;}{]+)/gi;
|
|
267
303
|
let m;
|
|
268
304
|
while ((m = ffRe.exec(cssText)) !== null) {
|
|
269
|
-
const list = m[1]
|
|
305
|
+
const list = splitTopLevel(m[1])
|
|
306
|
+
.flatMap((f) => resolveVarRef(f, cssVariables, new Set()))
|
|
307
|
+
.map((f) => f.trim().replace(/^['"]|['"]$/g, ''))
|
|
308
|
+
.filter((f) => f && !f.includes('var('));
|
|
270
309
|
for (const f of list) {
|
|
271
310
|
const lf = f.toLowerCase();
|
|
272
311
|
if (GENERIC_FAMILIES.has(lf)) { generics.add(lf); continue; }
|
|
@@ -386,7 +425,7 @@ export async function extractBranding($, pageUrl, opts = {}) {
|
|
|
386
425
|
|
|
387
426
|
const cssVariables = safe(() => extractCssVariables(sources.cssText), {}, 'css-variables');
|
|
388
427
|
const colors = safe(() => extractColors(sources.cssText, $, cssVariables), [], 'colors');
|
|
389
|
-
const fontInfo = safe(() => extractFonts(sources.cssText,
|
|
428
|
+
const fontInfo = safe(() => extractFonts(sources.cssText, $, cssVariables), { fonts: [], genericFallbacks: [], webfontProviders: [], fontFaces: [] }, 'fonts');
|
|
390
429
|
const logo = safe(() => extractLogo($, pageUrl), { favicons: [], ogImage: null, candidates: [], inlineHeaderSvg: null }, 'logo');
|
|
391
430
|
const tokens = safe(() => extractTokens(sources.cssText, cssVariables), { radii: [], shadows: [], spacingVariables: {} }, 'tokens');
|
|
392
431
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _mainContent.js — Readability main-content extraction, plus recovery of the
|
|
3
|
+
* data tables Readability drops.
|
|
4
|
+
*
|
|
5
|
+
* Readability keeps one article candidate and discards everything outside it.
|
|
6
|
+
* On a table-led page that silently loses the payload: Wikipedia's *List of
|
|
7
|
+
* S&P 500 companies* came back with zero table rows at scrape's default
|
|
8
|
+
* onlyMainContent:true, and 505 pipe-table lines with it off. No Readability
|
|
9
|
+
* option recovers them (charThreshold:100 and nbTopCandidates:20 were both
|
|
10
|
+
* probed) — the tables are simply not in the candidate — so they are
|
|
11
|
+
* re-attached afterwards.
|
|
12
|
+
*
|
|
13
|
+
* Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { JSDOM } from 'jsdom';
|
|
17
|
+
import { Readability } from '@mozilla/readability';
|
|
18
|
+
|
|
19
|
+
// How much of a table's text is compared against the article to decide whether
|
|
20
|
+
// Readability already kept it. Long enough to be unique to that table.
|
|
21
|
+
const SIGNATURE_LENGTH = 120;
|
|
22
|
+
|
|
23
|
+
function normalizeWhitespace(text) {
|
|
24
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Readability's own data-table test (`_markDataTables`): 10+ rows or more than
|
|
29
|
+
* 4 columns means the table carries data rather than layout, counting
|
|
30
|
+
* rowspan/colspan the way Readability does.
|
|
31
|
+
* @param {HTMLTableElement} table
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isDataTable(table) {
|
|
35
|
+
let rows = 0;
|
|
36
|
+
let columns = 0;
|
|
37
|
+
for (const row of Array.from(table.rows)) {
|
|
38
|
+
rows += parseInt(row.getAttribute('rowspan') || '1', 10) || 1;
|
|
39
|
+
let columnsInRow = 0;
|
|
40
|
+
for (const cell of Array.from(row.cells)) {
|
|
41
|
+
columnsInRow += parseInt(cell.getAttribute('colspan') || '1', 10) || 1;
|
|
42
|
+
}
|
|
43
|
+
columns = Math.max(columns, columnsInRow);
|
|
44
|
+
}
|
|
45
|
+
return rows >= 10 || columns > 4;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract a page's main content, re-attaching data tables Readability dropped.
|
|
50
|
+
*
|
|
51
|
+
* `title` is Readability's article title, which it strips out of `html` — a
|
|
52
|
+
* caller feeding this to an LLM has to put it back or the headline is simply
|
|
53
|
+
* not in the text (the IANA page's main content never says "Example Domains").
|
|
54
|
+
*
|
|
55
|
+
* @param {string} html - the page's HTML
|
|
56
|
+
* @param {string} [url] - document URL, used as the base for relative links
|
|
57
|
+
* @returns {{ html: string|null, title: string, tablesRecovered: number }} `html`
|
|
58
|
+
* is null when Readability found no article; callers decide what to fall back to.
|
|
59
|
+
*/
|
|
60
|
+
export function extractMainContent(html, url) {
|
|
61
|
+
let article;
|
|
62
|
+
try {
|
|
63
|
+
const dom = new JSDOM(html, { url });
|
|
64
|
+
article = new Readability(dom.window.document).parse();
|
|
65
|
+
} catch {
|
|
66
|
+
return { html: null, title: '', tablesRecovered: 0 };
|
|
67
|
+
}
|
|
68
|
+
if (!article || !article.content) return { html: null, title: '', tablesRecovered: 0 };
|
|
69
|
+
|
|
70
|
+
const title = article.title || '';
|
|
71
|
+
|
|
72
|
+
// Nothing to recover, and no reason to pay for a second parse.
|
|
73
|
+
if (!/<table[\s>]/i.test(html)) {
|
|
74
|
+
return { html: article.content, title, tablesRecovered: 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Readability mutates the document it is handed — the dropped tables are
|
|
78
|
+
// already gone from `dom` by the time parse() returns — so recovery has to
|
|
79
|
+
// parse the original HTML again.
|
|
80
|
+
let recovered;
|
|
81
|
+
try {
|
|
82
|
+
const fresh = new JSDOM(html, { url });
|
|
83
|
+
const kept = normalizeWhitespace(article.textContent || '');
|
|
84
|
+
recovered = Array.from(fresh.window.document.querySelectorAll('table'))
|
|
85
|
+
// A nested table travels with its parent; re-attaching it separately
|
|
86
|
+
// would duplicate it.
|
|
87
|
+
.filter((table) => !table.parentElement?.closest('table'))
|
|
88
|
+
.filter(isDataTable)
|
|
89
|
+
.filter((table) => {
|
|
90
|
+
const signature = normalizeWhitespace(table.textContent || '').slice(0, SIGNATURE_LENGTH);
|
|
91
|
+
return signature.length > 0 && !kept.includes(signature);
|
|
92
|
+
})
|
|
93
|
+
.map((table) => table.outerHTML);
|
|
94
|
+
} catch {
|
|
95
|
+
return { html: article.content, title, tablesRecovered: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
html: recovered.length > 0 ? `${article.content}\n${recovered.join('\n')}` : article.content,
|
|
100
|
+
title,
|
|
101
|
+
tablesRecovered: recovered.length
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default extractMainContent;
|
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { z } from 'zod';
|
|
14
|
-
import { JSDOM } from 'jsdom';
|
|
15
|
-
import { Readability } from '@mozilla/readability';
|
|
16
14
|
import { fetchAndParse } from '../extract/_fetchAndParse.js';
|
|
15
|
+
import { extractMainContent } from './_mainContent.js';
|
|
17
16
|
import { htmlToMarkdown } from '../../utils/htmlToMarkdown.js';
|
|
18
17
|
import { stripHiddenFromDom } from '../../utils/hiddenContent.js';
|
|
19
|
-
import { extractBlockText
|
|
18
|
+
import { extractBlockText } from '../basic/extractText.js';
|
|
20
19
|
|
|
21
20
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
|
22
21
|
|
|
@@ -42,6 +41,10 @@ export const UnifiedScrapeSchema = z.object({
|
|
|
42
41
|
resolveHiddenContent: z.enum(['linked', 'inline', 'off']).optional().default('linked'),
|
|
43
42
|
// Pass-through to fetchAndParse
|
|
44
43
|
timeoutMs: z.number().min(1000).max(60000).optional().default(15000),
|
|
44
|
+
// Compliance overrides, per request: identify as yourself for a target you
|
|
45
|
+
// have your own agreement with, and take responsibility for ignoring robots.
|
|
46
|
+
user_agent: z.string().optional(),
|
|
47
|
+
respect_robots: z.boolean().optional(),
|
|
45
48
|
// Optional, additive: only consulted when 'branding' / 'screenshot' is requested.
|
|
46
49
|
brandingOptions: z.object({
|
|
47
50
|
fetchLinkedCss: z.boolean().optional().default(true),
|
|
@@ -201,10 +204,13 @@ export class UnifiedScrapeTool {
|
|
|
201
204
|
const { url, formats, onlyMainContent, timeoutMs, brandingOptions, screenshotOptions, resolveHiddenContent } = validated;
|
|
202
205
|
|
|
203
206
|
// Single fetch
|
|
204
|
-
let html, $, finalUrl;
|
|
207
|
+
let html, $, finalUrl, fetchWarnings;
|
|
205
208
|
try {
|
|
206
|
-
({ html, $, finalUrl } = await fetchAndParse(url, {
|
|
209
|
+
({ html, $, finalUrl, warnings: fetchWarnings } = await fetchAndParse(url, {
|
|
207
210
|
timeoutMs,
|
|
211
|
+
userAgent: validated.user_agent,
|
|
212
|
+
respectRobots: validated.respect_robots,
|
|
213
|
+
tool: 'scrape',
|
|
208
214
|
stripTags: [] // we handle boilerplate ourselves
|
|
209
215
|
}));
|
|
210
216
|
} catch (err) {
|
|
@@ -223,19 +229,20 @@ export class UnifiedScrapeTool {
|
|
|
223
229
|
let mainHtml = null;
|
|
224
230
|
function getMainHtml() {
|
|
225
231
|
if (mainHtml !== null) return mainHtml;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
mainHtml = html;
|
|
232
|
+
const main = extractMainContent(html, finalUrl);
|
|
233
|
+
mainHtml = main.html ?? html;
|
|
234
|
+
if (main.tablesRecovered > 0) {
|
|
235
|
+
warnings.push(
|
|
236
|
+
`mainContent: re-attached ${main.tablesRecovered} data table(s) that main-content extraction had dropped`
|
|
237
|
+
);
|
|
233
238
|
}
|
|
234
239
|
return mainHtml;
|
|
235
240
|
}
|
|
236
241
|
|
|
237
242
|
const content = {};
|
|
238
|
-
|
|
243
|
+
// The gate's warnings (e.g. a respect_robots override) travel with the
|
|
244
|
+
// per-format ones, so the caller sees the decision in the response.
|
|
245
|
+
const warnings = [...fetchWarnings];
|
|
239
246
|
|
|
240
247
|
// Kept for the rawHtml format, which must survive the strip below.
|
|
241
248
|
const pristineHtml = html;
|
|
@@ -325,7 +332,7 @@ export class UnifiedScrapeTool {
|
|
|
325
332
|
case 'markdown':
|
|
326
333
|
try {
|
|
327
334
|
content.markdown = onlyMainContent
|
|
328
|
-
?
|
|
335
|
+
? htmlToMarkdown(getMainHtml())
|
|
329
336
|
: htmlToMarkdown($.html('body') || html);
|
|
330
337
|
} catch (err) {
|
|
331
338
|
content.markdown = '';
|
|
@@ -22,13 +22,11 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { normalizePost, normalizeTreeNodes, stripIdPrefix, stripNamePrefix } from '../redditNormalize.js';
|
|
25
|
+
import { identityHeaders, resolveUserAgent } from '../../../utils/fetchIdentity.js';
|
|
25
26
|
|
|
26
27
|
const TOKEN_URL = 'https://www.reddit.com/api/v1/access_token';
|
|
27
28
|
const API_BASE = 'https://oauth.reddit.com';
|
|
28
29
|
|
|
29
|
-
/** Reddit requires a descriptive, unique User-Agent; generic ones are throttled. */
|
|
30
|
-
const DEFAULT_USER_AGENT = 'CrawlForge-MCP/5.2.1 (+https://www.crawlforge.dev)';
|
|
31
|
-
|
|
32
30
|
/** Our sort is asc/desc by post date; Reddit listings/search only go newest-first. */
|
|
33
31
|
const REDDIT_SORT = 'new';
|
|
34
32
|
|
|
@@ -39,7 +37,10 @@ export class RedditOfficialApiAdapter {
|
|
|
39
37
|
}
|
|
40
38
|
this.clientId = clientId;
|
|
41
39
|
this.clientSecret = clientSecret;
|
|
42
|
-
|
|
40
|
+
// Reddit requires a descriptive, unique User-Agent; generic ones are
|
|
41
|
+
// throttled. The canonical identity qualifies, role-suffixed so Reddit's
|
|
42
|
+
// side can tell this traffic apart from a page crawl.
|
|
43
|
+
this.userAgent = resolveUserAgent(options.userAgent || process.env.REDDIT_USER_AGENT, 'reddit');
|
|
43
44
|
this.tokenUrl = options.tokenUrl || TOKEN_URL;
|
|
44
45
|
this.apiBaseUrl = options.apiBaseUrl || API_BASE;
|
|
45
46
|
this.timeoutMs = options.timeoutMs ?? 30000;
|
|
@@ -60,7 +61,7 @@ export class RedditOfficialApiAdapter {
|
|
|
60
61
|
headers: {
|
|
61
62
|
Authorization: this.authHeader,
|
|
62
63
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
63
|
-
|
|
64
|
+
...identityHeaders({ userAgent: this.userAgent }),
|
|
64
65
|
},
|
|
65
66
|
body: 'grant_type=client_credentials',
|
|
66
67
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
@@ -93,7 +94,7 @@ export class RedditOfficialApiAdapter {
|
|
|
93
94
|
let response;
|
|
94
95
|
try {
|
|
95
96
|
response = await fetch(url, {
|
|
96
|
-
headers: { Authorization: `Bearer ${token}`,
|
|
97
|
+
headers: { Authorization: `Bearer ${token}`, ...identityHeaders({ userAgent: this.userAgent }) },
|
|
97
98
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
98
99
|
});
|
|
99
100
|
} catch (error) {
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from './redditNormalize.js';
|
|
40
40
|
import { RedditOfficialApiAdapter } from './adapters/redditOfficialApi.js';
|
|
41
41
|
import { SearchProviderFactory } from './adapters/searchProviderFactory.js';
|
|
42
|
+
import { identityHeaders } from '../../utils/fetchIdentity.js';
|
|
42
43
|
|
|
43
44
|
const ARCTIC_SHIFT_BASE = 'https://arctic-shift.photon-reddit.com';
|
|
44
45
|
const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
@@ -46,9 +47,11 @@ const PULLPUSH_BASE = 'https://api.pullpush.io';
|
|
|
46
47
|
/**
|
|
47
48
|
* Identify ourselves. Verified live: Arctic Shift throttles UA-less clients
|
|
48
49
|
* into a shared bucket (422 "Timeout. Maybe slow down a bit" while curl got
|
|
49
|
-
* 200 for the same URL); with a descriptive UA it answers instantly.
|
|
50
|
+
* 200 for the same URL); with a descriptive UA it answers instantly. The
|
|
51
|
+
* canonical identity is descriptive, so the archives get the same one
|
|
52
|
+
* everything else sends.
|
|
50
53
|
*/
|
|
51
|
-
const
|
|
54
|
+
const ARCHIVE_IDENTITY = identityHeaders();
|
|
52
55
|
|
|
53
56
|
const RedditSearchSchema = z.object({
|
|
54
57
|
query: z.string().min(1).optional(),
|
|
@@ -389,7 +392,7 @@ export class RedditSearchTool {
|
|
|
389
392
|
let response;
|
|
390
393
|
try {
|
|
391
394
|
response = await fetch(url, {
|
|
392
|
-
headers: { Accept: 'application/json',
|
|
395
|
+
headers: { Accept: 'application/json', ...ARCHIVE_IDENTITY },
|
|
393
396
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
394
397
|
});
|
|
395
398
|
} catch (error) {
|
|
@@ -64,6 +64,14 @@ const SearchWebSchema = z.object({
|
|
|
64
64
|
}).optional()
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
// Deduplication runs after the backend search, so asking the provider for
|
|
68
|
+
// exactly `limit` items returns short whenever that page contains duplicates.
|
|
69
|
+
// Over-fetch a small margin once and trim back to `limit` after dedup. Google
|
|
70
|
+
// returns at most 10 items per request and bills per request, so the margin
|
|
71
|
+
// never costs an extra backend search.
|
|
72
|
+
const DEDUPE_OVERFETCH = 4;
|
|
73
|
+
const GOOGLE_MAX_RESULTS_PER_REQUEST = 10;
|
|
74
|
+
|
|
67
75
|
export class SearchWebTool {
|
|
68
76
|
constructor(options = {}) {
|
|
69
77
|
const {
|
|
@@ -213,7 +221,10 @@ export class SearchWebTool {
|
|
|
213
221
|
// Perform search with localized parameters
|
|
214
222
|
const searchParams = {
|
|
215
223
|
query: searchQuery,
|
|
216
|
-
num:
|
|
224
|
+
num: Math.min(
|
|
225
|
+
GOOGLE_MAX_RESULTS_PER_REQUEST,
|
|
226
|
+
localizedParams.limit + DEDUPE_OVERFETCH
|
|
227
|
+
),
|
|
217
228
|
start: localizedParams.offset + 1, // Google uses 1-based indexing
|
|
218
229
|
lr: localizedParams.lr || `lang_${localizedParams.lang}`,
|
|
219
230
|
safe: localizedParams.safe_search ? 'active' : 'off',
|
|
@@ -275,7 +286,13 @@ export class SearchWebTool {
|
|
|
275
286
|
deduplicationRate: ((originalCount - processedResults.length) / originalCount * 100).toFixed(1) + '%'
|
|
276
287
|
};
|
|
277
288
|
}
|
|
278
|
-
|
|
289
|
+
|
|
290
|
+
// Drop the over-fetched margin. Runs unconditionally because the extra
|
|
291
|
+
// items are requested whether or not deduplication is enabled.
|
|
292
|
+
if (processedResults.length > localizedParams.limit) {
|
|
293
|
+
processedResults = processedResults.slice(0, localizedParams.limit);
|
|
294
|
+
}
|
|
295
|
+
|
|
279
296
|
// Apply ranking if enabled
|
|
280
297
|
let rankingInfo = null;
|
|
281
298
|
if (validated.enable_ranking && processedResults.length > 1) {
|
|
@@ -386,7 +403,8 @@ export class SearchWebTool {
|
|
|
386
403
|
|
|
387
404
|
const adapterResult = await searchViaSearxng({
|
|
388
405
|
query: validated.query,
|
|
389
|
-
|
|
406
|
+
// SearXNG returns a whole page per request, so the dedup margin is free.
|
|
407
|
+
limit: validated.limit + DEDUPE_OVERFETCH,
|
|
390
408
|
page,
|
|
391
409
|
safeSearch: validated.safe_search,
|
|
392
410
|
language: validated.lang
|
|
@@ -414,6 +432,11 @@ export class SearchWebTool {
|
|
|
414
432
|
};
|
|
415
433
|
}
|
|
416
434
|
|
|
435
|
+
// Drop the over-fetched margin (see execute()).
|
|
436
|
+
if (processedResults.length > validated.limit) {
|
|
437
|
+
processedResults = processedResults.slice(0, validated.limit);
|
|
438
|
+
}
|
|
439
|
+
|
|
417
440
|
let rankingInfo = null;
|
|
418
441
|
if (validated.enable_ranking && processedResults.length > 1) {
|
|
419
442
|
const rankingOptions = validated.ranking_weights
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { TemplateRegistry } from 'crawlforge-extractors';
|
|
10
10
|
import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
11
|
+
import { preflightFetch } from '../../utils/robotsGate.js';
|
|
12
|
+
import { noteRetryAfter } from '../../utils/hostRateLimiter.js';
|
|
11
13
|
|
|
12
14
|
export class ScrapeTemplateTool {
|
|
13
15
|
constructor() {
|
|
@@ -16,10 +18,11 @@ export class ScrapeTemplateTool {
|
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Execute the scrape_template tool.
|
|
19
|
-
* @param {{ template: string, url: string, timeout?: number
|
|
21
|
+
* @param {{ template: string, url: string, timeout?: number,
|
|
22
|
+
* user_agent?: string, respect_robots?: boolean }} params
|
|
20
23
|
* @returns {Promise<object>}
|
|
21
24
|
*/
|
|
22
|
-
async execute({ template, url, timeout = 15000 }) {
|
|
25
|
+
async execute({ template, url, timeout = 15000, user_agent, respect_robots }) {
|
|
23
26
|
// list mode — return available templates without scraping
|
|
24
27
|
if (template === 'list' || !url) {
|
|
25
28
|
return {
|
|
@@ -40,6 +43,13 @@ export class ScrapeTemplateTool {
|
|
|
40
43
|
// so the SSRF guard below still applies.
|
|
41
44
|
const fetchUrl = template === 'list' ? url : (tpl.resolveUrl ? tpl.resolveUrl(url) : url);
|
|
42
45
|
|
|
46
|
+
// Robots gate + per-host politeness before any request to the target.
|
|
47
|
+
const gate = await preflightFetch(fetchUrl, {
|
|
48
|
+
respectRobots: respect_robots,
|
|
49
|
+
userAgent: user_agent,
|
|
50
|
+
tool: 'scrape_template'
|
|
51
|
+
});
|
|
52
|
+
|
|
43
53
|
// Fetch the page
|
|
44
54
|
const controller = new AbortController();
|
|
45
55
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
@@ -47,13 +57,14 @@ export class ScrapeTemplateTool {
|
|
|
47
57
|
try {
|
|
48
58
|
const response = await safeFetch(fetchUrl, {
|
|
49
59
|
signal: controller.signal,
|
|
50
|
-
headers: {
|
|
51
|
-
'User-Agent': 'Mozilla/5.0 (compatible; CrawlForge-TemplateScraper/4.0)'
|
|
52
|
-
}
|
|
60
|
+
headers: { ...gate.headers }
|
|
53
61
|
});
|
|
54
62
|
clearTimeout(timeoutId);
|
|
55
63
|
|
|
56
64
|
if (!response.ok) {
|
|
65
|
+
if (response.status === 429 || response.status === 503) {
|
|
66
|
+
noteRetryAfter(fetchUrl, response.headers.get('retry-after'));
|
|
67
|
+
}
|
|
57
68
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
58
69
|
}
|
|
59
70
|
html = await response.text();
|
|
@@ -67,7 +78,7 @@ export class ScrapeTemplateTool {
|
|
|
67
78
|
|
|
68
79
|
// Run the template extractor
|
|
69
80
|
const result = await this.registry.run(template, html, url, fetchUrl);
|
|
70
|
-
return result;
|
|
81
|
+
return gate.warnings.length > 0 ? { ...result, warnings: gate.warnings } : result;
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { safeFetch } from '../../../utils/ssrfGuard.js';
|
|
7
|
+
import { preflightFetch } from '../../../utils/robotsGate.js';
|
|
8
|
+
import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Default Jaccard similarity threshold below which a change is considered
|
|
@@ -39,14 +41,31 @@ export function calculateSimilarity(text1, text2) {
|
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Fetch the HTML/text content of a URL with change-tracking headers.
|
|
44
|
+
*
|
|
45
|
+
* Sends the same identity as every other fetching tool: a baseline captured
|
|
46
|
+
* under one UA and compared under another reports the difference between two
|
|
47
|
+
* server-side renderings as a content change.
|
|
48
|
+
*
|
|
42
49
|
* @param {string} url
|
|
43
|
-
* @
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {string} [options.userAgent] per-request identity override
|
|
52
|
+
* @param {boolean} [options.respectRobots] per-request robots override
|
|
53
|
+
* @param {string} [options.tool] tool name, for the audit row
|
|
54
|
+
* @param {string} [options.apiKey] hashed into the audit row
|
|
55
|
+
* @returns {Promise<{ content: string, metadata: Object, warnings: string[] }>}
|
|
44
56
|
*/
|
|
45
|
-
export async function fetchContent(url) {
|
|
57
|
+
export async function fetchContent(url, options = {}) {
|
|
58
|
+
const gate = await preflightFetch(url, {
|
|
59
|
+
respectRobots: options.respectRobots,
|
|
60
|
+
userAgent: options.userAgent,
|
|
61
|
+
tool: options.tool || 'track_changes',
|
|
62
|
+
apiKey: options.apiKey
|
|
63
|
+
});
|
|
64
|
+
|
|
46
65
|
try {
|
|
47
66
|
const response = await safeFetch(url, {
|
|
48
67
|
headers: {
|
|
49
|
-
|
|
68
|
+
...gate.headers,
|
|
50
69
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
51
70
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
52
71
|
'Accept-Encoding': 'gzip, deflate',
|
|
@@ -56,6 +75,9 @@ export async function fetchContent(url) {
|
|
|
56
75
|
});
|
|
57
76
|
|
|
58
77
|
if (!response.ok) {
|
|
78
|
+
if (response.status === 429 || response.status === 503) {
|
|
79
|
+
noteRetryAfter(url, response.headers.get('retry-after'));
|
|
80
|
+
}
|
|
59
81
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
60
82
|
}
|
|
61
83
|
|
|
@@ -63,6 +85,7 @@ export async function fetchContent(url) {
|
|
|
63
85
|
|
|
64
86
|
return {
|
|
65
87
|
content,
|
|
88
|
+
warnings: gate.warnings,
|
|
66
89
|
metadata: {
|
|
67
90
|
statusCode: response.status,
|
|
68
91
|
contentType: response.headers.get('content-type'),
|
|
@@ -185,15 +185,17 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
async createBaseline(params) {
|
|
188
|
-
const { url, content, html, trackingOptions, storageOptions = {} } = params;
|
|
188
|
+
const { url, content, html, trackingOptions, storageOptions = {}, respect_robots, user_agent } = params;
|
|
189
189
|
const enableSnapshots = storageOptions.enableSnapshots !== false;
|
|
190
190
|
|
|
191
191
|
let sourceContent = content || html;
|
|
192
192
|
let fetchMeta = {};
|
|
193
|
+
let warnings = [];
|
|
193
194
|
if (!sourceContent) {
|
|
194
|
-
const r = await fetchContent(url);
|
|
195
|
+
const r = await fetchContent(url, { respectRobots: respect_robots, userAgent: user_agent });
|
|
195
196
|
sourceContent = r.content;
|
|
196
197
|
fetchMeta = r.metadata;
|
|
198
|
+
warnings = r.warnings;
|
|
197
199
|
}
|
|
198
200
|
if (!sourceContent || typeof sourceContent !== 'string') throw new Error('Invalid content');
|
|
199
201
|
|
|
@@ -217,6 +219,7 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
217
219
|
createdAt: baseline.createdAt,
|
|
218
220
|
options: trackingOptions
|
|
219
221
|
},
|
|
222
|
+
...(warnings.length ? { warnings } : {}),
|
|
220
223
|
snapshot: snapshotInfo, timestamp: Date.now()
|
|
221
224
|
};
|
|
222
225
|
}
|
|
@@ -246,16 +249,18 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
246
249
|
}
|
|
247
250
|
|
|
248
251
|
async compareWithBaseline(params) {
|
|
249
|
-
const { url, content, html, trackingOptions, storageOptions = {}, notificationOptions } = params;
|
|
252
|
+
const { url, content, html, trackingOptions, storageOptions = {}, notificationOptions, respect_robots, user_agent } = params;
|
|
250
253
|
const enableSnapshots = storageOptions.enableSnapshots !== false;
|
|
251
254
|
await this.rehydrateBaseline(url, trackingOptions);
|
|
252
255
|
|
|
253
256
|
let currentContent = content || html;
|
|
254
257
|
let fetchMeta = {};
|
|
258
|
+
let fetchWarnings = [];
|
|
255
259
|
if (!currentContent) {
|
|
256
|
-
const r = await fetchContent(url);
|
|
260
|
+
const r = await fetchContent(url, { respectRobots: respect_robots, userAgent: user_agent });
|
|
257
261
|
currentContent = r.content;
|
|
258
262
|
fetchMeta = r.metadata;
|
|
263
|
+
fetchWarnings = r.warnings;
|
|
259
264
|
}
|
|
260
265
|
if (!currentContent || typeof currentContent !== 'string') throw new Error('Invalid content');
|
|
261
266
|
|
|
@@ -281,7 +286,9 @@ export class TrackChangesTool extends EventEmitter {
|
|
|
281
286
|
details: comparisonResult.details,
|
|
282
287
|
metrics: comparisonResult.metrics,
|
|
283
288
|
recommendations: comparisonResult.recommendations,
|
|
284
|
-
...(comparisonResult.warnings
|
|
289
|
+
...(comparisonResult.warnings || fetchWarnings.length
|
|
290
|
+
? { warnings: [...(comparisonResult.warnings || []), ...fetchWarnings] }
|
|
291
|
+
: {}),
|
|
285
292
|
snapshot: snapshotInfo, timestamp: Date.now()
|
|
286
293
|
};
|
|
287
294
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Used by monitor.js and the main TrackChangesTool class.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { identityHeaders } from '../../../utils/fetchIdentity.js';
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Send all enabled notifications for a detected change.
|
|
9
11
|
* @param {string} url
|
|
@@ -43,7 +45,7 @@ export async function sendWebhookNotification(url, changeResult, webhookConfig,
|
|
|
43
45
|
method: webhookConfig.method || 'POST',
|
|
44
46
|
headers: {
|
|
45
47
|
'Content-Type': 'application/json',
|
|
46
|
-
|
|
48
|
+
...identityHeaders({ role: 'webhook' }),
|
|
47
49
|
...webhookConfig.headers
|
|
48
50
|
},
|
|
49
51
|
body: JSON.stringify(payload)
|
|
@@ -27,6 +27,9 @@ export const TrackChangesSchema = z.object({
|
|
|
27
27
|
content: z.string().optional(),
|
|
28
28
|
html: z.string().optional(),
|
|
29
29
|
|
|
30
|
+
respect_robots: z.boolean().optional(),
|
|
31
|
+
user_agent: z.string().optional(),
|
|
32
|
+
|
|
30
33
|
trackingOptions: z.object({
|
|
31
34
|
granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
|
|
32
35
|
trackText: z.boolean().default(true),
|