crawlforge-mcp-server 5.2.9 → 5.3.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/CLAUDE.md +13 -1
- package/README.md +11 -9
- package/package.json +3 -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 +401 -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 +496 -0
- package/src/core/llm/OllamaProvider.js +14 -5
- 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 +6 -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/ollamaConfig.js +36 -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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* complianceAudit — durable record of the compliance decisions a customer made.
|
|
3
|
+
*
|
|
4
|
+
* Ground rule G5 allows `respect_robots: false`, but only as *the customer's
|
|
5
|
+
* documented decision*. That means the override has to leave a trace: which key
|
|
6
|
+
* asked, for which URL, from which tool, when. Without the row the override is
|
|
7
|
+
* a silent product default again, which is the thing G5 exists to prevent.
|
|
8
|
+
*
|
|
9
|
+
* Rows go to `logs/compliance-audit.log` as JSONL (one row per line, appended)
|
|
10
|
+
* and to a small in-memory ring the tools and tests can read back. Writing is
|
|
11
|
+
* best-effort: an audit sink that throws must never fail a customer's fetch.
|
|
12
|
+
*
|
|
13
|
+
* The API key is never stored. `apiKeyId` is a truncated SHA-256 of it — stable
|
|
14
|
+
* enough to group a key's overrides, useless as a credential.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
import { appendFile, mkdir } from 'fs/promises';
|
|
19
|
+
import { dirname, join } from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const DEFAULT_LOG_PATH = join(__dirname, '../../logs/compliance-audit.log');
|
|
24
|
+
|
|
25
|
+
const RING_SIZE = 200;
|
|
26
|
+
const ring = [];
|
|
27
|
+
|
|
28
|
+
let sink = null; // null → the default JSONL file sink
|
|
29
|
+
|
|
30
|
+
/** Truncated, non-reversible identifier for an API key. */
|
|
31
|
+
export function apiKeyId(apiKey) {
|
|
32
|
+
if (typeof apiKey !== 'string' || !apiKey) return 'anonymous';
|
|
33
|
+
return createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function defaultSink(row) {
|
|
37
|
+
await mkdir(dirname(DEFAULT_LOG_PATH), { recursive: true });
|
|
38
|
+
await appendFile(DEFAULT_LOG_PATH, `${JSON.stringify(row)}\n`, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Record a compliance event. Never throws and never rejects.
|
|
43
|
+
* @param {{ event: string, url?: string, tool?: string, apiKeyId?: string, [k: string]: unknown }} event
|
|
44
|
+
* @returns {{ event: string, timestamp: string }} the row as written
|
|
45
|
+
*/
|
|
46
|
+
export function recordComplianceEvent(event = {}) {
|
|
47
|
+
const row = { timestamp: new Date().toISOString(), ...event };
|
|
48
|
+
ring.push(row);
|
|
49
|
+
if (ring.length > RING_SIZE) ring.shift();
|
|
50
|
+
|
|
51
|
+
Promise.resolve()
|
|
52
|
+
.then(() => (sink || defaultSink)(row))
|
|
53
|
+
.catch(() => { /* an audit sink must never break a fetch */ });
|
|
54
|
+
|
|
55
|
+
return row;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Most recent audit rows, newest last. Test/diagnostic hook. */
|
|
59
|
+
export function getComplianceAuditRows() {
|
|
60
|
+
return ring.slice();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Replace the persistence sink (tests, or a hosted deployment). */
|
|
64
|
+
export function setComplianceAuditSink(fn) {
|
|
65
|
+
sink = typeof fn === 'function' ? fn : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Test hook. */
|
|
69
|
+
export function _resetComplianceAudit() {
|
|
70
|
+
ring.length = 0;
|
|
71
|
+
sink = null;
|
|
72
|
+
}
|
|
@@ -312,7 +312,18 @@ export class ContentQualityAssessor {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
/**
|
|
315
|
-
* Calculate
|
|
315
|
+
* Calculate Flesch Reading-Ease metrics.
|
|
316
|
+
*
|
|
317
|
+
* This is the single Flesch implementation behind process_document and
|
|
318
|
+
* extract_content: both the `readabilityScore` field and
|
|
319
|
+
* `qualityAssessment.metrics.readability` are derived from here, so the two
|
|
320
|
+
* can never report different scores for the same text.
|
|
321
|
+
*
|
|
322
|
+
* The score is deliberately NOT clamped to [0, 100] — Flesch is unbounded
|
|
323
|
+
* (very simple text exceeds 100, dense text goes negative), and
|
|
324
|
+
* assessContentQuality() treats `score > 100` as a quality signal, which a
|
|
325
|
+
* clamp would make unreachable.
|
|
326
|
+
*
|
|
316
327
|
* @param {string} text - Text to analyze
|
|
317
328
|
* @returns {Object} - Readability metrics
|
|
318
329
|
*/
|
|
@@ -209,24 +209,36 @@ export class DomainFilter {
|
|
|
209
209
|
/**
|
|
210
210
|
* Check if URL is allowed based on all filtering rules
|
|
211
211
|
* @param {string} url - URL to check
|
|
212
|
+
* @param {Object} [options] - Evaluation options
|
|
213
|
+
* @param {boolean} [options.isSeed=false] - Treat url as a crawl's start URL, which is
|
|
214
|
+
* exempt from the include-pattern gate (the caller asked for it explicitly). Blacklist,
|
|
215
|
+
* exclude patterns and whitelist checks are unchanged.
|
|
212
216
|
* @returns {Object} Decision object with allowed status and metadata
|
|
213
217
|
*/
|
|
214
|
-
isAllowed(url) {
|
|
218
|
+
isAllowed(url, options = {}) {
|
|
219
|
+
const { isSeed = false } = options;
|
|
215
220
|
try {
|
|
216
221
|
const normalizedUrl = normalizeUrl(url);
|
|
217
|
-
|
|
222
|
+
// Patterns are tested against the raw URL as well, so a pattern written with a
|
|
223
|
+
// trailing slash ('/docs/') still matches a URL normalizeUrl has stripped it from.
|
|
224
|
+
// The two forms can therefore reach different decisions and must not share a cache
|
|
225
|
+
// entry; seed checks skip the cache entirely since they use a different gate.
|
|
226
|
+
const cacheKey = normalizedUrl === url ? normalizedUrl : `${normalizedUrl}|${url}`;
|
|
227
|
+
|
|
218
228
|
// Check cache first
|
|
219
|
-
if (this.cache.has(
|
|
229
|
+
if (!isSeed && this.cache.has(cacheKey)) {
|
|
220
230
|
this.cacheHits++;
|
|
221
|
-
return this.cache.get(
|
|
231
|
+
return this.cache.get(cacheKey);
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
this.cacheMisses++;
|
|
225
|
-
const decision = this.evaluateUrl(normalizedUrl);
|
|
226
|
-
|
|
235
|
+
const decision = this.evaluateUrl(normalizedUrl, url, isSeed);
|
|
236
|
+
|
|
227
237
|
// Cache the decision
|
|
228
|
-
|
|
229
|
-
|
|
238
|
+
if (!isSeed) {
|
|
239
|
+
this.addToCache(cacheKey, decision);
|
|
240
|
+
}
|
|
241
|
+
|
|
230
242
|
return decision;
|
|
231
243
|
} catch (error) {
|
|
232
244
|
return {
|
|
@@ -241,9 +253,11 @@ export class DomainFilter {
|
|
|
241
253
|
/**
|
|
242
254
|
* Internal URL evaluation logic
|
|
243
255
|
* @param {string} url - Normalized URL to evaluate
|
|
256
|
+
* @param {string} [rawUrl=url] - URL as the caller supplied it, before normalization
|
|
257
|
+
* @param {boolean} [isSeed=false] - Skip the include-pattern gate for a crawl's start URL
|
|
244
258
|
* @returns {Object} Decision object
|
|
245
259
|
*/
|
|
246
|
-
evaluateUrl(url) {
|
|
260
|
+
evaluateUrl(url, rawUrl = url, isSeed = false) {
|
|
247
261
|
const urlObj = new URL(url);
|
|
248
262
|
const domain = urlObj.hostname;
|
|
249
263
|
const path = urlObj.pathname;
|
|
@@ -255,7 +269,7 @@ export class DomainFilter {
|
|
|
255
269
|
}
|
|
256
270
|
|
|
257
271
|
// 2. Check exclude patterns
|
|
258
|
-
const excludePatternResult = this.checkExcludePatterns(url);
|
|
272
|
+
const excludePatternResult = this.checkExcludePatterns(url, rawUrl);
|
|
259
273
|
if (!excludePatternResult.allowed) {
|
|
260
274
|
return excludePatternResult;
|
|
261
275
|
}
|
|
@@ -267,13 +281,16 @@ export class DomainFilter {
|
|
|
267
281
|
}
|
|
268
282
|
|
|
269
283
|
// 4. Check include patterns
|
|
270
|
-
const includePatternResult = this.checkIncludePatterns(url);
|
|
284
|
+
const includePatternResult = this.checkIncludePatterns(url, rawUrl);
|
|
271
285
|
if (includePatternResult.allowed) {
|
|
272
286
|
return includePatternResult;
|
|
273
287
|
}
|
|
274
288
|
|
|
275
|
-
// 5. Default behavior - if no whitelist exists, allow; if whitelist exists, deny
|
|
276
|
-
|
|
289
|
+
// 5. Default behavior - if no whitelist exists, allow; if whitelist exists, deny.
|
|
290
|
+
// A seed URL is not subject to the include-pattern gate: include patterns scope where a
|
|
291
|
+
// crawl may go, not whether the URL the caller named may be fetched at all.
|
|
292
|
+
const hasWhitelist = this.whitelist.size > 0 ||
|
|
293
|
+
(!isSeed && this.patterns.include.length > 0);
|
|
277
294
|
|
|
278
295
|
return {
|
|
279
296
|
allowed: !hasWhitelist,
|
|
@@ -366,12 +383,13 @@ export class DomainFilter {
|
|
|
366
383
|
|
|
367
384
|
/**
|
|
368
385
|
* Check exclude patterns
|
|
369
|
-
* @param {string} url - URL to check
|
|
386
|
+
* @param {string} url - Normalized URL to check
|
|
387
|
+
* @param {string} [rawUrl=url] - URL before normalization; tested as well
|
|
370
388
|
* @returns {Object} Decision object
|
|
371
389
|
*/
|
|
372
|
-
checkExcludePatterns(url) {
|
|
390
|
+
checkExcludePatterns(url, rawUrl = url) {
|
|
373
391
|
for (const patternConfig of this.patterns.exclude) {
|
|
374
|
-
if (patternConfig.pattern.test(url)) {
|
|
392
|
+
if (patternConfig.pattern.test(url) || patternConfig.pattern.test(rawUrl)) {
|
|
375
393
|
return {
|
|
376
394
|
allowed: false,
|
|
377
395
|
reason: `Matches exclude pattern: ${patternConfig.rawPattern}`,
|
|
@@ -390,12 +408,13 @@ export class DomainFilter {
|
|
|
390
408
|
|
|
391
409
|
/**
|
|
392
410
|
* Check include patterns
|
|
393
|
-
* @param {string} url - URL to check
|
|
411
|
+
* @param {string} url - Normalized URL to check
|
|
412
|
+
* @param {string} [rawUrl=url] - URL before normalization; tested as well
|
|
394
413
|
* @returns {Object} Decision object
|
|
395
414
|
*/
|
|
396
|
-
checkIncludePatterns(url) {
|
|
415
|
+
checkIncludePatterns(url, rawUrl = url) {
|
|
397
416
|
for (const patternConfig of this.patterns.include) {
|
|
398
|
-
if (patternConfig.pattern.test(url)) {
|
|
417
|
+
if (patternConfig.pattern.test(url) || patternConfig.pattern.test(rawUrl)) {
|
|
399
418
|
return {
|
|
400
419
|
allowed: true,
|
|
401
420
|
reason: `Matches include pattern: ${patternConfig.rawPattern}`,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fetchIdentity — the single outbound identity for every CrawlForge fetch.
|
|
3
|
+
*
|
|
4
|
+
* Nine tools used to hardcode nine different User-Agents, so nine tools saw nine
|
|
5
|
+
* different versions of the same page (a Zillow listing served 41 `address`
|
|
6
|
+
* elements to one tool and 9 to another). That is a correctness bug, not a
|
|
7
|
+
* disguise problem, and the fix is one honest identity everywhere:
|
|
8
|
+
*
|
|
9
|
+
* CrawlForge/<version> (+https://crawlforge.dev)
|
|
10
|
+
*
|
|
11
|
+
* Ground rule G4: identify honestly by default — real product name, real
|
|
12
|
+
* contact URL — so a site that wants to block us can. Callers that have their
|
|
13
|
+
* own agreement with a target can pass a per-request `userAgent` override
|
|
14
|
+
* (G4's escape hatch); the override wins, the canonical UA applies otherwise.
|
|
15
|
+
*
|
|
16
|
+
* The literal 'User-Agent' header name lives here and nowhere else outside the
|
|
17
|
+
* browser paths, so `identityHeaders()` is the only way to spell it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'module';
|
|
21
|
+
|
|
22
|
+
const _require = createRequire(import.meta.url);
|
|
23
|
+
const _pkg = _require('../../package.json');
|
|
24
|
+
|
|
25
|
+
/** The canonical, honest identity every page fetch sends. */
|
|
26
|
+
export const CRAWLFORGE_USER_AGENT = `CrawlForge/${_pkg.version} (+https://crawlforge.dev)`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Identity for non-page traffic (webhook delivery, health checks, alerting).
|
|
30
|
+
* Same product name and contact URL, with the role appended so a receiving
|
|
31
|
+
* server can tell a webhook POST from a crawl. Falls back to the canonical UA.
|
|
32
|
+
* @param {string} [role]
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function serviceUserAgent(role) {
|
|
36
|
+
const trimmed = typeof role === 'string' ? role.trim() : '';
|
|
37
|
+
return trimmed
|
|
38
|
+
? `CrawlForge/${_pkg.version} (+https://crawlforge.dev; ${trimmed})`
|
|
39
|
+
: CRAWLFORGE_USER_AGENT;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the User-Agent for a request: a non-empty override wins, otherwise
|
|
44
|
+
* the canonical identity (optionally role-suffixed).
|
|
45
|
+
* @param {string} [override]
|
|
46
|
+
* @param {string} [role]
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function resolveUserAgent(override, role) {
|
|
50
|
+
const trimmed = typeof override === 'string' ? override.trim() : '';
|
|
51
|
+
return trimmed || serviceUserAgent(role);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The outbound identity headers for a fetch. Spread this into a headers object
|
|
56
|
+
* rather than writing the header name at the call site.
|
|
57
|
+
* @param {{ userAgent?: string, role?: string }} [options]
|
|
58
|
+
* @returns {{ 'User-Agent': string }}
|
|
59
|
+
*/
|
|
60
|
+
export function identityHeaders(options = {}) {
|
|
61
|
+
return { 'User-Agent': resolveUserAgent(options.userAgent, options.role) };
|
|
62
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hostBlocklist — the platform's permanent refusals (ground rule G7).
|
|
3
|
+
*
|
|
4
|
+
* When a site owner opts out or sends a takedown, the block has to hold at the
|
|
5
|
+
* platform layer. A blocklist any customer can switch off is not a blocklist,
|
|
6
|
+
* so nothing here reads a per-request flag: `respect_robots: false` does not
|
|
7
|
+
* reach it, and neither does a `userAgent` override.
|
|
8
|
+
*
|
|
9
|
+
* To add an entry, append to BLOCKED_HOSTS with a dated one-line reason. Blocks
|
|
10
|
+
* cover the host and all of its subdomains. `CRAWLFORGE_BLOCKED_HOSTS` (comma
|
|
11
|
+
* separated) adds more at runtime; it can only ever extend the list.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {string[]} host → blocked, with the date and reason it was added. */
|
|
15
|
+
const BLOCKED_HOSTS = [
|
|
16
|
+
// e.g. 'example.com', // 2026-08-27 owner opt-out, ref #123
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export class BlockedHostError extends Error {
|
|
20
|
+
constructor(host) {
|
|
21
|
+
super(
|
|
22
|
+
`${host} is on CrawlForge's permanent blocklist (site-owner opt-out or takedown) ` +
|
|
23
|
+
`and cannot be fetched. This block is not overridable. ` +
|
|
24
|
+
`If you believe it is in error, contact support@crawlforge.dev.`
|
|
25
|
+
);
|
|
26
|
+
this.name = 'BlockedHostError';
|
|
27
|
+
this.code = 'HOST_BLOCKED';
|
|
28
|
+
this.host = host;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let overrideList = null; // tests only
|
|
33
|
+
|
|
34
|
+
function blockedSet() {
|
|
35
|
+
if (overrideList) return overrideList;
|
|
36
|
+
const fromEnv = (process.env.CRAWLFORGE_BLOCKED_HOSTS || '')
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((h) => h.trim().toLowerCase())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
return new Set([...BLOCKED_HOSTS.map((h) => h.toLowerCase()), ...fromEnv]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The hostname of a URL, lowercased, or null if it will not parse. */
|
|
44
|
+
function hostOf(url) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(url).hostname.toLowerCase().replace(/\.$/, '');
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* True when the URL's host, or any parent domain of it, is blocked.
|
|
54
|
+
* @param {string} url
|
|
55
|
+
*/
|
|
56
|
+
export function isBlockedHost(url) {
|
|
57
|
+
const host = hostOf(url);
|
|
58
|
+
if (!host) return false;
|
|
59
|
+
const blocked = blockedSet();
|
|
60
|
+
if (blocked.size === 0) return false;
|
|
61
|
+
|
|
62
|
+
const labels = host.split('.');
|
|
63
|
+
for (let i = 0; i < labels.length - 1; i++) {
|
|
64
|
+
if (blocked.has(labels.slice(i).join('.'))) return true;
|
|
65
|
+
}
|
|
66
|
+
return blocked.has(host);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Throw BlockedHostError if the URL's host is blocked. Call before any network
|
|
71
|
+
* work — the point is that a blocked host never gets a request.
|
|
72
|
+
* @param {string} url
|
|
73
|
+
*/
|
|
74
|
+
export function assertHostAllowed(url) {
|
|
75
|
+
if (isBlockedHost(url)) throw new BlockedHostError(hostOf(url));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test hook: replace the effective list. Pass null to restore. */
|
|
79
|
+
export function _setBlockedHostsForTests(hosts) {
|
|
80
|
+
overrideList = hosts ? new Set(hosts.map((h) => h.toLowerCase())) : null;
|
|
81
|
+
}
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* effective behaviour), enabled by RATE_LIMIT_PER_DOMAIN (default true). Setting
|
|
11
11
|
* RATE_LIMIT_PER_DOMAIN=false disables the throttle entirely — there is no global
|
|
12
12
|
* cross-host cap, so broad multi-host crawls are never slowed by this.
|
|
13
|
+
*
|
|
14
|
+
* Two politeness signals the host itself sends are honoured on top of that
|
|
15
|
+
* (ground rule G6 — load we impose is load someone pays for):
|
|
16
|
+
* - robots.txt `Crawl-delay`, passed in per request by the robots gate;
|
|
17
|
+
* - `Retry-After` on a 429/503, recorded by the fetch helpers so the *next*
|
|
18
|
+
* request to that host waits instead of retrying straight into the wall.
|
|
19
|
+
* Both are host-scoped and survive RATE_LIMIT_PER_DOMAIN=false: an operator
|
|
20
|
+
* turning off our own throttle is not a licence to ignore the site's.
|
|
13
21
|
*/
|
|
14
22
|
import { RateLimiter } from './rateLimiter.js';
|
|
15
23
|
import { config } from '../constants/config.js';
|
|
@@ -26,13 +34,103 @@ function limiter() {
|
|
|
26
34
|
return _limiter;
|
|
27
35
|
}
|
|
28
36
|
|
|
37
|
+
/** host → { lastRequestAt, notBefore } */
|
|
38
|
+
const hostState = new Map();
|
|
39
|
+
|
|
40
|
+
function hostOf(url) {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(url).hostname.toLowerCase();
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateFor(host) {
|
|
49
|
+
let state = hostState.get(host);
|
|
50
|
+
if (!state) {
|
|
51
|
+
state = { lastRequestAt: 0, notBefore: 0 };
|
|
52
|
+
hostState.set(host, state);
|
|
53
|
+
}
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a `Retry-After` header into milliseconds.
|
|
61
|
+
* Accepts delta-seconds ("2") and an HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
|
|
62
|
+
* Returns 0 for anything unparseable, negative, or absent.
|
|
63
|
+
* @param {string|null|undefined} value
|
|
64
|
+
* @param {number} [now] epoch ms, for deterministic tests
|
|
65
|
+
* @returns {number}
|
|
66
|
+
*/
|
|
67
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
68
|
+
if (value === null || value === undefined) return 0;
|
|
69
|
+
const raw = String(value).trim();
|
|
70
|
+
if (!raw) return 0;
|
|
71
|
+
|
|
72
|
+
if (/^\d+$/.test(raw)) return parseInt(raw, 10) * 1000;
|
|
73
|
+
|
|
74
|
+
const asDate = Date.parse(raw);
|
|
75
|
+
if (!Number.isNaN(asDate)) return Math.max(0, asDate - now);
|
|
76
|
+
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record a `Retry-After` the host asked for. Subsequent requests to that host
|
|
82
|
+
* wait it out. Capped at 5 minutes so a hostile or mistaken header cannot pin a
|
|
83
|
+
* worker indefinitely.
|
|
84
|
+
* @param {string} url
|
|
85
|
+
* @param {string|null|undefined} retryAfterHeader
|
|
86
|
+
* @returns {number} the backoff applied, in ms (0 if none)
|
|
87
|
+
*/
|
|
88
|
+
export function noteRetryAfter(url, retryAfterHeader) {
|
|
89
|
+
const host = hostOf(url);
|
|
90
|
+
if (!host) return 0;
|
|
91
|
+
|
|
92
|
+
const delayMs = Math.min(parseRetryAfter(retryAfterHeader), 5 * 60 * 1000);
|
|
93
|
+
if (delayMs <= 0) return 0;
|
|
94
|
+
|
|
95
|
+
const state = stateFor(host);
|
|
96
|
+
state.notBefore = Math.max(state.notBefore, Date.now() + delayMs);
|
|
97
|
+
return delayMs;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Remaining backoff for a host in ms (0 if none). Diagnostic/test hook. */
|
|
101
|
+
export function getHostBackoffMs(url) {
|
|
102
|
+
const host = hostOf(url);
|
|
103
|
+
if (!host) return 0;
|
|
104
|
+
const state = hostState.get(host);
|
|
105
|
+
return state ? Math.max(0, state.notBefore - Date.now()) : 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
29
108
|
/**
|
|
30
109
|
* Wait (if necessary) until another request to this URL's host is allowed.
|
|
31
110
|
* Never throws — a limiter failure must not block a legitimate fetch.
|
|
32
111
|
* @param {string} url
|
|
112
|
+
* @param {{ crawlDelayMs?: number }} [options] `crawlDelayMs` from robots.txt
|
|
33
113
|
*/
|
|
34
|
-
export async function throttleHost(url) {
|
|
35
|
-
|
|
114
|
+
export async function throttleHost(url, options = {}) {
|
|
115
|
+
const host = hostOf(url);
|
|
116
|
+
const crawlDelayMs = Number(options.crawlDelayMs) > 0 ? Number(options.crawlDelayMs) : 0;
|
|
117
|
+
|
|
118
|
+
if (host) {
|
|
119
|
+
const state = stateFor(host);
|
|
120
|
+
|
|
121
|
+
// The host's own signals, honoured whether or not our throttle is enabled.
|
|
122
|
+
const waits = [];
|
|
123
|
+
if (state.notBefore > Date.now()) waits.push(state.notBefore - Date.now());
|
|
124
|
+
if (crawlDelayMs > 0 && state.lastRequestAt > 0) {
|
|
125
|
+
waits.push(state.lastRequestAt + crawlDelayMs - Date.now());
|
|
126
|
+
}
|
|
127
|
+
const wait = Math.max(0, ...waits);
|
|
128
|
+
if (wait > 0) await sleep(wait);
|
|
129
|
+
|
|
130
|
+
state.lastRequestAt = Date.now();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (config.rateLimit.perDomain === false) return; // our own throttle disabled
|
|
36
134
|
try {
|
|
37
135
|
await limiter().checkLimit(url);
|
|
38
136
|
} catch {
|
|
@@ -43,4 +141,5 @@ export async function throttleHost(url) {
|
|
|
43
141
|
/** Test/diagnostic hook. */
|
|
44
142
|
export function _resetHostRateLimiter() {
|
|
45
143
|
_limiter = null;
|
|
144
|
+
hostState.clear();
|
|
46
145
|
}
|
|
@@ -55,6 +55,32 @@ const PREFERRED_MODELS = [
|
|
|
55
55
|
'qwen2.5:3b'
|
|
56
56
|
];
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Models measured fit to JUDGE claims — relevance to a topic, same-meaning
|
|
60
|
+
* grouping, and contradiction — as opposed to extracting fields. Measured
|
|
61
|
+
* 2026-08-28 by replaying a live deep_research run's own 136 claims through
|
|
62
|
+
* each installed model, three runs each:
|
|
63
|
+
*
|
|
64
|
+
* gemma3:12b 0 false contradictions on 27 real pairs, 3/3 planted caught,
|
|
65
|
+
* 7-9 cross-source groups (the 4B model: 1-2 false, 0-1/3
|
|
66
|
+
* caught, 1 group)
|
|
67
|
+
* gemma3:4b the extraction winner, but it scored "Playwright vs Selenium"
|
|
68
|
+
* marketing 0.9 relevant to an anti-bot topic and put it in the
|
|
69
|
+
* research summary
|
|
70
|
+
* gemma4:31b judged as cleanly as gemma3:12b but only with thinking turned
|
|
71
|
+
* off — under the default it spends the whole token budget on
|
|
72
|
+
* hidden reasoning and returns empty content — and it grouped so
|
|
73
|
+
* strictly that consensus vanished. Not ranked.
|
|
74
|
+
* gpt-oss:20b empty content at these token budgets for the same reason,
|
|
75
|
+
* and `think: false` makes it emit nothing at all. Not ranked.
|
|
76
|
+
*
|
|
77
|
+
* Membership here is what turns conflict detection on: a model that invents
|
|
78
|
+
* disagreement between sources that agree is worse than one that reports none,
|
|
79
|
+
* so a model absent from this list is never asked. When none is installed the
|
|
80
|
+
* judgement role falls through to the extraction ranking above.
|
|
81
|
+
*/
|
|
82
|
+
export const JUDGEMENT_MODELS = ['gemma3:12b'];
|
|
83
|
+
|
|
58
84
|
/** Used only when Ollama cannot be reached, so the error names a real model. */
|
|
59
85
|
export const FALLBACK_OLLAMA_MODEL = 'llama3.2';
|
|
60
86
|
|
|
@@ -102,9 +128,11 @@ export async function installedOllamaModels() {
|
|
|
102
128
|
* instead would break anyone who has not pulled it, so the best *installed*
|
|
103
129
|
* model is chosen, and an explicit OLLAMA_DEFAULT_MODEL always wins.
|
|
104
130
|
*
|
|
131
|
+
* @param {'default'|'judgement'} [role] 'judgement' tries JUDGEMENT_MODELS
|
|
132
|
+
* first and falls through to the extraction ranking when none is installed.
|
|
105
133
|
* @returns {Promise<string>}
|
|
106
134
|
*/
|
|
107
|
-
export async function selectOllamaModel() {
|
|
135
|
+
export async function selectOllamaModel(role = 'default') {
|
|
108
136
|
const explicit = process.env.OLLAMA_DEFAULT_MODEL;
|
|
109
137
|
if (explicit) return explicit;
|
|
110
138
|
|
|
@@ -112,10 +140,16 @@ export async function selectOllamaModel() {
|
|
|
112
140
|
if (installed.length === 0) return FALLBACK_OLLAMA_MODEL;
|
|
113
141
|
|
|
114
142
|
const byBase = new Map(installed.map((name) => [baseName(name), name]));
|
|
115
|
-
|
|
143
|
+
const ranking = role === 'judgement' ? [...JUDGEMENT_MODELS, ...PREFERRED_MODELS] : PREFERRED_MODELS;
|
|
144
|
+
for (const preferred of ranking) {
|
|
116
145
|
const match = byBase.get(baseName(preferred));
|
|
117
146
|
if (match) return match;
|
|
118
147
|
}
|
|
119
148
|
// Nothing recognised — use whatever is there rather than failing.
|
|
120
149
|
return installed[0];
|
|
121
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Whether a model name is one measured fit to judge contradictions. */
|
|
153
|
+
export function isJudgementModel(name) {
|
|
154
|
+
return typeof name === 'string' && JUDGEMENT_MODELS.some((m) => baseName(m) === baseName(name));
|
|
155
|
+
}
|