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.
Files changed (73) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +11 -9
  3. package/package.json +3 -2
  4. package/server.js +175 -26
  5. package/src/cli/commands/stealth.js +7 -1
  6. package/src/constants/config.js +2 -1
  7. package/src/core/ActionExecutor.js +168 -16
  8. package/src/core/AlertNotificationSystem.js +2 -1
  9. package/src/core/AuthManager.js +19 -1
  10. package/src/core/ChangeTracker.js +34 -6
  11. package/src/core/LLMsTxtAnalyzer.js +94 -12
  12. package/src/core/LocalizationManager.js +2 -1
  13. package/src/core/ResearchOrchestrator.js +401 -86
  14. package/src/core/StealthBrowserManager.js +186 -105
  15. package/src/core/WebhookDispatcher.js +3 -4
  16. package/src/core/analysis/ContentAnalyzer.js +41 -15
  17. package/src/core/analysis/sentenceUtils.js +16 -5
  18. package/src/core/crawlers/BFSCrawler.js +44 -21
  19. package/src/core/llm/LLMManager.js +496 -0
  20. package/src/core/llm/OllamaProvider.js +14 -5
  21. package/src/core/processing/BrowserProcessor.js +27 -0
  22. package/src/core/processing/ContentProcessor.js +11 -39
  23. package/src/core/processing/PDFProcessor.js +2 -3
  24. package/src/core/research/claimFilters.js +235 -0
  25. package/src/schemas/toolOutputSchemas.js +5 -1
  26. package/src/security/wave3-security.js +2 -1
  27. package/src/server/requestContext.js +23 -0
  28. package/src/server/withAuth.js +21 -5
  29. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +6 -1
  30. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  31. package/src/tools/advanced/batchScrape/schema.js +4 -0
  32. package/src/tools/advanced/batchScrape/worker.js +19 -10
  33. package/src/tools/basic/_fetch.js +19 -15
  34. package/src/tools/basic/extractLinks.js +8 -3
  35. package/src/tools/basic/extractMetadata.js +7 -3
  36. package/src/tools/basic/extractText.js +8 -3
  37. package/src/tools/basic/fetchUrl.js +7 -3
  38. package/src/tools/basic/scrapeStructured.js +76 -3
  39. package/src/tools/crawl/_sessionContext.js +10 -2
  40. package/src/tools/crawl/crawlDeep.js +29 -12
  41. package/src/tools/crawl/mapSite.js +39 -14
  42. package/src/tools/extract/_fetchAndParse.js +23 -8
  43. package/src/tools/extract/analyzeContent.js +5 -3
  44. package/src/tools/extract/extractContent.js +18 -4
  45. package/src/tools/extract/extractStructured.js +66 -12
  46. package/src/tools/extract/extractWithLlm.js +51 -4
  47. package/src/tools/extract/processDocument.js +45 -78
  48. package/src/tools/extract/summarizeContent.js +35 -1
  49. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  50. package/src/tools/research/deepResearch.js +2 -1
  51. package/src/tools/scrape/_brandingExtractor.js +42 -3
  52. package/src/tools/scrape/_mainContent.js +105 -0
  53. package/src/tools/scrape/unifiedScrape.js +21 -14
  54. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  55. package/src/tools/search/redditSearch.js +6 -3
  56. package/src/tools/search/searchWeb.js +26 -3
  57. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  58. package/src/tools/tracking/trackChanges/differ.js +26 -3
  59. package/src/tools/tracking/trackChanges/index.js +12 -5
  60. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  61. package/src/tools/tracking/trackChanges/schema.js +3 -0
  62. package/src/utils/complianceAudit.js +72 -0
  63. package/src/utils/contentUtils.js +12 -1
  64. package/src/utils/domainFilter.js +38 -19
  65. package/src/utils/fetchIdentity.js +62 -0
  66. package/src/utils/hostBlocklist.js +81 -0
  67. package/src/utils/hostRateLimiter.js +101 -2
  68. package/src/utils/ollamaConfig.js +36 -2
  69. package/src/utils/robotsChecker.js +90 -43
  70. package/src/utils/robotsGate.js +206 -0
  71. package/src/utils/sitemapParser.js +33 -15
  72. package/src/utils/ssrfProtection.js +2 -1
  73. 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 simple readability metrics
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(normalizedUrl)) {
229
+ if (!isSeed && this.cache.has(cacheKey)) {
220
230
  this.cacheHits++;
221
- return this.cache.get(normalizedUrl);
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
- this.addToCache(normalizedUrl, decision);
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
- const hasWhitelist = this.whitelist.size > 0 || this.patterns.include.length > 0;
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
- if (config.rateLimit.perDomain === false) return; // feature disabled
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
- for (const preferred of PREFERRED_MODELS) {
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
+ }