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.
Files changed (71) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +9 -9
  3. package/package.json +2 -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 +407 -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 +473 -0
  20. package/src/core/processing/BrowserProcessor.js +27 -0
  21. package/src/core/processing/ContentProcessor.js +11 -39
  22. package/src/core/processing/PDFProcessor.js +2 -3
  23. package/src/core/research/claimFilters.js +235 -0
  24. package/src/schemas/toolOutputSchemas.js +5 -1
  25. package/src/security/wave3-security.js +2 -1
  26. package/src/server/requestContext.js +23 -0
  27. package/src/server/withAuth.js +21 -5
  28. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  29. package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
  30. package/src/tools/advanced/batchScrape/schema.js +4 -0
  31. package/src/tools/advanced/batchScrape/worker.js +19 -10
  32. package/src/tools/basic/_fetch.js +19 -15
  33. package/src/tools/basic/extractLinks.js +8 -3
  34. package/src/tools/basic/extractMetadata.js +7 -3
  35. package/src/tools/basic/extractText.js +8 -3
  36. package/src/tools/basic/fetchUrl.js +7 -3
  37. package/src/tools/basic/scrapeStructured.js +76 -3
  38. package/src/tools/crawl/_sessionContext.js +10 -2
  39. package/src/tools/crawl/crawlDeep.js +29 -12
  40. package/src/tools/crawl/mapSite.js +39 -14
  41. package/src/tools/extract/_fetchAndParse.js +23 -8
  42. package/src/tools/extract/analyzeContent.js +5 -3
  43. package/src/tools/extract/extractContent.js +18 -4
  44. package/src/tools/extract/extractStructured.js +66 -12
  45. package/src/tools/extract/extractWithLlm.js +51 -4
  46. package/src/tools/extract/processDocument.js +45 -78
  47. package/src/tools/extract/summarizeContent.js +35 -1
  48. package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
  49. package/src/tools/research/deepResearch.js +2 -1
  50. package/src/tools/scrape/_brandingExtractor.js +42 -3
  51. package/src/tools/scrape/_mainContent.js +105 -0
  52. package/src/tools/scrape/unifiedScrape.js +21 -14
  53. package/src/tools/search/adapters/redditOfficialApi.js +7 -6
  54. package/src/tools/search/redditSearch.js +6 -3
  55. package/src/tools/search/searchWeb.js +26 -3
  56. package/src/tools/templates/ScrapeTemplateTool.js +17 -6
  57. package/src/tools/tracking/trackChanges/differ.js +26 -3
  58. package/src/tools/tracking/trackChanges/index.js +12 -5
  59. package/src/tools/tracking/trackChanges/notifier.js +3 -1
  60. package/src/tools/tracking/trackChanges/schema.js +3 -0
  61. package/src/utils/complianceAudit.js +72 -0
  62. package/src/utils/contentUtils.js +12 -1
  63. package/src/utils/domainFilter.js +38 -19
  64. package/src/utils/fetchIdentity.js +62 -0
  65. package/src/utils/hostBlocklist.js +81 -0
  66. package/src/utils/hostRateLimiter.js +101 -2
  67. package/src/utils/robotsChecker.js +90 -43
  68. package/src/utils/robotsGate.js +206 -0
  69. package/src/utils/sitemapParser.js +33 -15
  70. package/src/utils/ssrfProtection.js +2 -1
  71. package/src/utils/webBotAuth.js +193 -0
@@ -2,12 +2,14 @@ import { load } from 'cheerio';
2
2
  import { QueueManager } from '../queue/QueueManager.js';
3
3
  import { CacheManager } from '../cache/CacheManager.js';
4
4
  import { RateLimiter } from '../../utils/rateLimiter.js';
5
- import { RobotsChecker } from '../../utils/robotsChecker.js';
6
5
  import { DomainFilter } from '../../utils/domainFilter.js';
7
6
  import { LinkAnalyzer } from '../analysis/LinkAnalyzer.js';
8
7
  import { normalizeUrl, extractLinks, isValidUrl } from '../../utils/urlNormalizer.js';
9
8
  import { Logger } from '../../utils/Logger.js';
10
9
  import { safeFetch } from '../../utils/ssrfGuard.js';
10
+ import { robotsPreflight } from '../../utils/robotsGate.js';
11
+ import { throttleHost } from '../../utils/hostRateLimiter.js';
12
+ import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
11
13
 
12
14
  const logger = new Logger('BFSCrawler');
13
15
 
@@ -18,7 +20,7 @@ export class BFSCrawler {
18
20
  maxPages = 100,
19
21
  followExternal = false,
20
22
  respectRobots = true,
21
- userAgent = 'MCP-WebScraper/1.0',
23
+ userAgent = CRAWLFORGE_USER_AGENT,
22
24
  timeout = 30000,
23
25
  concurrency = 10,
24
26
  domainFilter = null,
@@ -59,7 +61,6 @@ export class BFSCrawler {
59
61
  // effectiveRateLimit hasn't changed, rather than recreating it on every URL.
60
62
  this.rateLimiter = new RateLimiter({ requestsPerSecond: 10 });
61
63
  this._domainRateLimiters = new Map();
62
- this.robotsChecker = respectRobots ? new RobotsChecker(userAgent) : null;
63
64
 
64
65
  // Initialize domain filter (create new if not provided)
65
66
  this.domainFilter = domainFilter || new DomainFilter({
@@ -109,14 +110,18 @@ export class BFSCrawler {
109
110
  const normalizedStart = normalizeUrl(startUrl);
110
111
  this.baseUrl = new URL(normalizedStart);
111
112
 
112
- // Check if start URL is allowed
113
- const startUrlDecision = this.domainFilter.isAllowed(normalizedStart);
113
+ // Check if start URL is allowed. isSeed exempts it from the include-pattern gate:
114
+ // include patterns scope where the crawl may go next, not whether the URL the caller
115
+ // explicitly asked for may be fetched. Blacklist and exclude patterns still apply.
116
+ // The un-normalized startUrl is passed so a pattern written with a trailing slash
117
+ // ('/docs/') is tested against the form the caller wrote.
118
+ const startUrlDecision = this.domainFilter.isAllowed(startUrl, { isSeed: true });
114
119
  if (!startUrlDecision.allowed) {
115
120
  throw new Error(`Start URL blocked by domain filter: ${startUrlDecision.reason}`);
116
121
  }
117
122
 
118
123
  // Initialize queue with starting URL
119
- await this.queue.add(() => this.processUrl(normalizedStart, 0));
124
+ await this.queue.add(() => this.processUrl(startUrl, 0));
120
125
 
121
126
  // Wait for crawling to complete
122
127
  await this.queue.onIdle();
@@ -148,8 +153,11 @@ export class BFSCrawler {
148
153
  return;
149
154
  }
150
155
 
156
+ // Only the seed is queued at depth 0; children are always depth >= 1.
157
+ const isSeed = depth === 0;
158
+
151
159
  // Check domain filter (replaces old pattern checking)
152
- const filterDecision = this.domainFilter.isAllowed(normalizedUrl);
160
+ const filterDecision = this.domainFilter.isAllowed(url, { isSeed });
153
161
  this.filterDecisions.push({
154
162
  url: normalizedUrl,
155
163
  decision: filterDecision,
@@ -162,18 +170,22 @@ export class BFSCrawler {
162
170
  }
163
171
 
164
172
  // Backward compatibility: also check legacy patterns
165
- if (!this.shouldCrawlUrl(normalizedUrl)) {
173
+ if (!this.shouldCrawlUrl(normalizedUrl, url, isSeed)) {
166
174
  logger.debug(`Legacy pattern blocks: ${normalizedUrl}`);
167
175
  return;
168
176
  }
169
177
 
170
- // Check robots.txt
171
- if (this.respectRobots && this.robotsChecker) {
172
- const canFetch = await this.robotsChecker.canFetch(normalizedUrl);
173
- if (!canFetch) {
174
- logger.debug(`Robots.txt blocks: ${normalizedUrl}`);
175
- return;
176
- }
178
+ // Check robots.txt through the shared gate, so this crawl reuses the same
179
+ // cached robots.txt every other tool fetched (and honours the platform
180
+ // blocklist, which no per-request flag can switch off).
181
+ const gate = await robotsPreflight(normalizedUrl, {
182
+ respectRobots: this.respectRobots,
183
+ userAgent: this.userAgent,
184
+ tool: 'crawl_deep'
185
+ });
186
+ if (!gate.allowed) {
187
+ logger.debug(`Robots.txt blocks: ${normalizedUrl}`);
188
+ return;
177
189
  }
178
190
 
179
191
  // Mark as visited. Re-check the cap and dedupe first: the checks at the
@@ -209,6 +221,12 @@ export class BFSCrawler {
209
221
 
210
222
  await this._domainRateLimiters.get(domain).checkLimit(normalizedUrl);
211
223
 
224
+ // The host's own Crawl-delay, on top of our per-domain limit. Only
225
+ // when it asked for one — otherwise the domain limiter above stands.
226
+ if (gate.crawlDelayMs > 0) {
227
+ await throttleHost(normalizedUrl, { crawlDelayMs: gate.crawlDelayMs });
228
+ }
229
+
212
230
  // Fetch the page
213
231
  pageData = await this.fetchPage(normalizedUrl);
214
232
 
@@ -292,7 +310,7 @@ export class BFSCrawler {
292
310
  const domainRules = this.domainFilter.getDomainRules(urlObj.hostname);
293
311
 
294
312
  const defaultHeaders = {
295
- 'User-Agent': this.userAgent,
313
+ ...identityHeaders({ userAgent: this.userAgent }),
296
314
  'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
297
315
  'Accept-Language': 'en-US,en;q=0.5',
298
316
  'Accept-Encoding': 'gzip, deflate',
@@ -409,16 +427,21 @@ export class BFSCrawler {
409
427
  }
410
428
  }
411
429
 
412
- shouldCrawlUrl(url) {
413
- // Check include patterns
414
- if (this.includePatterns.length > 0) {
415
- const matches = this.includePatterns.some(pattern => pattern.test(url));
430
+ shouldCrawlUrl(url, rawUrl = url, isSeed = false) {
431
+ // Check include patterns. The seed is exempt for the same reason as in crawl():
432
+ // it is the URL the caller named, not a link the crawl chose to follow.
433
+ if (!isSeed && this.includePatterns.length > 0) {
434
+ const matches = this.includePatterns.some(
435
+ pattern => pattern.test(url) || pattern.test(rawUrl)
436
+ );
416
437
  if (!matches) return false;
417
438
  }
418
439
 
419
440
  // Check exclude patterns
420
441
  if (this.excludePatterns.length > 0) {
421
- const excluded = this.excludePatterns.some(pattern => pattern.test(url));
442
+ const excluded = this.excludePatterns.some(
443
+ pattern => pattern.test(url) || pattern.test(rawUrl)
444
+ );
422
445
  if (excluded) return false;
423
446
  }
424
447
 
@@ -408,6 +408,479 @@ Synthesize these findings into a comprehensive analysis:`;
408
408
  }
409
409
  }
410
410
 
411
+ /**
412
+ * Score how much each claim sentence states something about the research topic.
413
+ *
414
+ * Exists because sentence-shape and genre heuristics cannot answer the
415
+ * question that matters here. Two of them were built for deep_research and
416
+ * reverted: both had to read the publisher rather than the sentence, and one
417
+ * source still produced five distinct registers of the same off-topic
418
+ * sentence — pitch, ranking, offering, feature list, comparison — each of
419
+ * which was synthesized into a research conclusion. What separates
420
+ * "automated browsers are detected by TLS fingerprinting" from "our platform
421
+ * handles fingerprinting for you" is not vocabulary, which they share; it is
422
+ * whether the sentence asserts something about the subject or describes a
423
+ * thing built around it. That is a semantic judgement, so it is made here.
424
+ *
425
+ * The response is index-keyed rather than positional because a positional
426
+ * one made the whole gate inert. Requiring exactly N scores in order looked
427
+ * safe and was not: a small local model asked for 35 scores returned 39,
428
+ * both attempts failed the length check, the method returned [], and no
429
+ * claim was ever scored in production. Carrying the index with each score
430
+ * removes the failure mode — a miscount now costs the extra entries, not
431
+ * the run.
432
+ *
433
+ * @param {string[]} claims - Claim sentences.
434
+ * @param {string} topic - The research topic.
435
+ * @returns {Promise<number[]|Array<number|null>>} An array of exactly
436
+ * `claims.length` entries in input order: a score in [0,1] where the model
437
+ * scored the claim, `null` where it did not. Callers treat a non-number as
438
+ * "unscored, never filter", so a partial result is worth more than none.
439
+ * Empty array only on hard failure — unparseable output, no scores array,
440
+ * or nothing usable anywhere in the run.
441
+ */
442
+ async scoreClaimRelevance(claims, topic, options = {}) {
443
+ // Small batches deliberately: a 4B model tracks ten-odd sentences far more
444
+ // reliably than forty, and several small calls degrade better than one
445
+ // large one — a batch that fails now costs its own claims, not all of them.
446
+ const { maxClaimLength = 240, batchSize = 12 } = options;
447
+
448
+ if (!Array.isArray(claims) || claims.length === 0) {
449
+ return [];
450
+ }
451
+
452
+ const systemPrompt = `You rate how strongly each numbered sentence states something about a research topic.
453
+
454
+ Return a JSON object:
455
+ {"scores": [{"i": 0, "score": 0.8}, {"i": 1, "score": 0.2}]}
456
+
457
+ "i" is the sentence number exactly as shown; the first sentence is 0. Include one entry for every sentence.
458
+
459
+ Rate high when the sentence asserts something about the topic itself — how it works, what it does, a mechanism, a measurement, a cause or an effect.
460
+ Rate low when the sentence describes a commercial offering rather than the subject — its features, plans, pricing, coverage or why to choose it — even when it uses the topic's vocabulary. A product built around a subject is not a statement about that subject.
461
+ Rate low for navigation text, boilerplate, author or publication metadata.
462
+
463
+ Return the entries and nothing else.`;
464
+
465
+ const scoreSchema = {
466
+ type: 'object',
467
+ properties: {
468
+ scores: {
469
+ type: 'array',
470
+ items: {
471
+ type: 'object',
472
+ properties: { i: { type: 'integer' }, score: { type: 'number' } },
473
+ required: ['i', 'score']
474
+ }
475
+ }
476
+ },
477
+ required: ['scores']
478
+ };
479
+
480
+ const scores = new Array(claims.length).fill(null);
481
+ let anyScored = false;
482
+
483
+ for (let start = 0; start < claims.length; start += batchSize) {
484
+ const batch = claims.slice(start, start + batchSize).map(claim => {
485
+ const text = String(claim ?? '');
486
+ return text.length > maxClaimLength ? text.slice(0, maxClaimLength) + '…' : text;
487
+ });
488
+
489
+ const prompt = `Research topic: "${topic}"
490
+
491
+ Sentences (numbered from 0):
492
+ ${batch.map((text, index) => `${index}. ${text}`).join('\n')}
493
+
494
+ Rate these ${batch.length} sentences:`;
495
+
496
+ // Same discipline as analyzeRelevance: constrain the output shape,
497
+ // strip fences, validate the load-bearing field, retry once.
498
+ let batchScores = null;
499
+ let lastError;
500
+ for (let attempt = 0; attempt < 2 && !batchScores; attempt++) {
501
+ try {
502
+ const response = await this.generateCompletion(prompt, {
503
+ systemPrompt,
504
+ maxTokens: 100 + batch.length * 20,
505
+ temperature: 0.1,
506
+ format: scoreSchema
507
+ });
508
+
509
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
510
+ const parsed = JSON.parse(cleaned);
511
+ if (!Array.isArray(parsed?.scores)) {
512
+ throw new Error('Relevance response missing scores');
513
+ }
514
+
515
+ const mapped = new Array(batch.length).fill(null);
516
+ let usable = 0;
517
+ for (const entry of parsed.scores) {
518
+ if (!entry || typeof entry !== 'object') continue;
519
+ // Strictly a real integer: Number(null) is 0 and Number(true) is 1,
520
+ // either of which would land a score on a claim it does not belong
521
+ // to. An unusable entry is skipped, never realigned.
522
+ const index = entry.i;
523
+ if (typeof index !== 'number' || !Number.isInteger(index)) continue;
524
+ if (index < 0 || index >= batch.length || mapped[index] !== null) continue;
525
+
526
+ const value = entry.score;
527
+ const score = typeof value === 'number' ? value
528
+ : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
529
+ if (!Number.isFinite(score)) continue;
530
+
531
+ mapped[index] = Math.max(0, Math.min(1, score));
532
+ usable++;
533
+ }
534
+ if (usable === 0) {
535
+ throw new Error('No usable scores in response');
536
+ }
537
+ batchScores = mapped;
538
+ } catch (error) {
539
+ lastError = error;
540
+ }
541
+ }
542
+
543
+ if (batchScores) {
544
+ for (let i = 0; i < batch.length; i++) scores[start + i] = batchScores[i];
545
+ anyScored = true;
546
+ } else {
547
+ // This batch stays null and the run continues. Unscored claims are
548
+ // never filtered, so losing one batch costs less than losing the gate.
549
+ this.logger.warn('LLM claim relevance batch unscored', { error: lastError.message });
550
+ }
551
+ }
552
+
553
+ if (!anyScored) {
554
+ this.logger.warn('LLM claim relevance scoring failed; gate skipped');
555
+ return [];
556
+ }
557
+
558
+ return scores;
559
+ }
560
+
561
+ /**
562
+ * Group claim sentences that assert the same thing about the topic.
563
+ *
564
+ * Exists because the lexical key it replaces (a claim's own first three
565
+ * sorted keywords) splits paraphrases: "an edge network uses TLS
566
+ * fingerprinting to detect automated browsers" and "automated browsers are
567
+ * detected by an edge network through TLS fingerprinting" keyed
568
+ * differently, so 27 real claims produced 27 groups and conflict/consensus
569
+ * detection — which needs
570
+ * a group of 2+ from 2+ sources — was structurally unreachable. A
571
+ * keyword-overlap threshold sweep did not fix it: at every setting it found
572
+ * at most one cross-source merge, and that merge was spurious (two unrelated
573
+ * sentences sharing {best, scrapers, 2026}). Same-meaning is semantic, so it
574
+ * is judged here.
575
+ *
576
+ * @param {string[]} claims - Claim sentences.
577
+ * @param {string} topic - The research topic.
578
+ * @returns {Promise<number[][]>} Groups of indices into `claims`. Every index
579
+ * in 0..claims.length-1 appears exactly once — the caller treats this as a
580
+ * partition and does not re-check it. Empty array on any failure, which is
581
+ * the caller's signal to fall back to keyword grouping.
582
+ */
583
+ async groupClaimsBySimilarity(claims, topic, options = {}) {
584
+ const { maxClaimLength = 240, maxClaims = 60 } = options;
585
+
586
+ // Nothing to group below two claims, and the caller's keyword fallback
587
+ // reaches the same answer without a round trip.
588
+ if (!Array.isArray(claims) || claims.length < 2) {
589
+ return [];
590
+ }
591
+
592
+ // One call, always: claims past the cap are left out of the prompt and the
593
+ // normalization below appends them as singletons. Chunking would be worse
594
+ // than useless here — a paraphrase pair split across two calls can never
595
+ // be found.
596
+ const batch = claims.slice(0, maxClaims).map(claim => {
597
+ const text = String(claim ?? '');
598
+ return text.length > maxClaimLength ? text.slice(0, maxClaimLength) + '…' : text;
599
+ });
600
+
601
+ const systemPrompt = `You group sentences that assert the same thing about a research topic.
602
+
603
+ Return a JSON object:
604
+ {"groups": [[0, 3], [1], [2, 4]]}
605
+
606
+ Each number is a sentence number. Every sentence number appears exactly once across all groups.
607
+ Group two sentences together only when they assert the same fact however differently worded — a restatement, a reversed subject and object, or a paraphrase sharing no words still belongs with its original.
608
+ Keep sentences apart when they describe different mechanisms, different subjects or different measurements. A shared word is not a shared claim.
609
+ Most sentences belong in a group of their own.
610
+
611
+ Return the groups and nothing else.`;
612
+
613
+ const groupSchema = {
614
+ type: 'object',
615
+ properties: {
616
+ groups: { type: 'array', items: { type: 'array', items: { type: 'integer' } } }
617
+ },
618
+ required: ['groups']
619
+ };
620
+
621
+ const prompt = `Research topic: "${topic}"
622
+
623
+ Sentences (numbered from 0):
624
+ ${batch.map((text, index) => `${index}. ${text}`).join('\n')}
625
+
626
+ Group these ${batch.length} sentences:`;
627
+
628
+ try {
629
+ let lastError;
630
+ for (let attempt = 0; attempt < 2; attempt++) {
631
+ try {
632
+ const response = await this.generateCompletion(prompt, {
633
+ systemPrompt,
634
+ maxTokens: 200 + batch.length * 10,
635
+ temperature: 0.1,
636
+ format: groupSchema
637
+ });
638
+
639
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
640
+ const parsed = JSON.parse(cleaned);
641
+ if (!Array.isArray(parsed?.groups) || parsed.groups.length === 0) {
642
+ throw new Error('Grouping response missing groups');
643
+ }
644
+ return this.partitionClaimIndices(parsed.groups, claims.length);
645
+ } catch (error) {
646
+ lastError = error;
647
+ }
648
+ }
649
+ throw lastError;
650
+ } catch (error) {
651
+ this.logger.warn('LLM claim grouping failed, using fallback', { error: error.message });
652
+ return [];
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Force a model's group list into a true partition of 0..count-1.
658
+ *
659
+ * The caller consumes these indices directly, so a duplicated index would
660
+ * double-count a claim's support and a missing one would drop a finding
661
+ * outright. A model that renumbers from 1, repeats an index or invents one
662
+ * is normal and must not corrupt the result, so the guarantee is enforced
663
+ * here rather than trusted from the response.
664
+ */
665
+ partitionClaimIndices(groups, count) {
666
+ const seen = new Set();
667
+ const partition = [];
668
+
669
+ for (const group of groups) {
670
+ if (!Array.isArray(group)) continue;
671
+ const cleaned = [];
672
+ for (const value of group) {
673
+ // Strictly a real integer: Number(null) is 0 and Number(true) is 1, so
674
+ // coercing would attach claim 0 or 1 to a group the model never put it
675
+ // in, inventing corroboration that consensus then counts.
676
+ if (typeof value !== 'number' || !Number.isInteger(value)) continue;
677
+ if (value < 0 || value >= count || seen.has(value)) continue;
678
+ seen.add(value);
679
+ cleaned.push(value);
680
+ }
681
+ if (cleaned.length > 0) partition.push(cleaned);
682
+ }
683
+
684
+ for (let index = 0; index < count; index++) {
685
+ if (!seen.has(index)) partition.push([index]);
686
+ }
687
+
688
+ return partition;
689
+ }
690
+
691
+ /**
692
+ * Decide which claim pairs genuinely contradict each other.
693
+ *
694
+ * Exists because the lexical detector it replaces reported 42 conflicts on a
695
+ * live run and none of the sampled six were real. Its premise — one claim
696
+ * carries a negative word and the other a positive one, therefore they
697
+ * disagree — cannot be made to work: real claims are long multi-sentence
698
+ * blobs, so nearly every pair contains both. It paired "modern anti-bot
699
+ * systems do not just block IP addresses, they fingerprint the TLS
700
+ * handshake" against a claim that such systems match known signatures — two
701
+ * sentences that agree, split only by the token "not" — and paired an
702
+ * article's own table of contents against its prose. Contradiction is a
703
+ * relation between propositions, not between words, so it is judged here.
704
+ *
705
+ * @param {Array<{a: string, b: string}>} pairs - Claim pairs already judged
706
+ * to be about the same assertion.
707
+ * @param {string} topic - The research topic.
708
+ * @returns {Promise<number[]>} Indices into `pairs` that contradict, ascending
709
+ * and deduplicated. An empty array means either "none contradict" or "the
710
+ * check could not run" — deliberately the same value, because the caller
711
+ * fails closed and reports no conflicts either way. Nothing downstream
712
+ * needs to tell the two apart, and reporting a conflict that was never
713
+ * established is the failure mode this method exists to remove.
714
+ */
715
+ async findContradictions(pairs, topic, options = {}) {
716
+ const { maxClaimLength = 240, maxPairs = 30, batchSize = 8 } = options;
717
+
718
+ if (!Array.isArray(pairs) || pairs.length === 0) {
719
+ return [];
720
+ }
721
+
722
+ // Batched in small chunks, and deliberately not in one call. Measured
723
+ // 2026-08-28: this same prompt judged 7 pairs with zero false positives on
724
+ // three consecutive runs, but judging a live run's ~30 pairs in one call
725
+ // returned 29 "contradictions" of which none were real. A 4B local model
726
+ // loses the thread across a long pair list exactly as it loses count on a
727
+ // long score list. Pairs past the cap go unexamined rather than costing
728
+ // more round trips — under-reporting a conflict is the same direction as
729
+ // every other failure here, and the caller already fails closed.
730
+ const truncate = value => {
731
+ const text = String(value ?? '');
732
+ return text.length > maxClaimLength ? text.slice(0, maxClaimLength) + '…' : text;
733
+ };
734
+ const capped = pairs.slice(0, maxPairs).map(pair => ({
735
+ a: truncate(pair?.a),
736
+ b: truncate(pair?.b)
737
+ }));
738
+
739
+ const systemPrompt = `You decide which numbered pairs of sentences genuinely contradict each other.
740
+
741
+ Return a JSON object:
742
+ {"contradictions": [0, 4]}
743
+
744
+ List a pair only when both sentences cannot be true at the same time — the same proposition asserted with opposite polarity, or incompatible values for the same quantity.
745
+
746
+ These are not contradictions:
747
+ - two sentences about the same subject that emphasise different aspects
748
+ - one sentence adding scope, detail or an example the other leaves out
749
+ - a heading or table-of-contents line beside prose from the same document
750
+ - a negative word in one sentence and a positive word in the other; wording is not polarity
751
+
752
+ Most pairs contradict nothing, and an empty list is the correct answer for most inputs.
753
+
754
+ Return the list and nothing else.`;
755
+
756
+ const contradictionSchema = {
757
+ type: 'object',
758
+ properties: {
759
+ contradictions: { type: 'array', items: { type: 'integer' } }
760
+ },
761
+ required: ['contradictions']
762
+ };
763
+
764
+ // Ask in BOTH polarities and keep only what survives both.
765
+ //
766
+ // Asking "which pairs contradict?" alone does not work at any batch size,
767
+ // measured 2026-08-28 against a live run's claims: 30 pairs in one call
768
+ // gave 29 false positives, chunks of 8 gave 13, and one pair per call gave
769
+ // 28 — worst of all, because with nothing to compare against the model
770
+ // affirms whatever it is shown. That is acquiescence ("yes") bias, a
771
+ // documented and general LLM failure mode, not a defect of this prompt.
772
+ //
773
+ // The fix is the standard control for it: put the question the other way
774
+ // round as well. A pair is reported only when the model calls it
775
+ // contradictory AND does not also call it consistent. Because the bias
776
+ // pushes toward "yes" in both passes, a pair named in both is one the
777
+ // model is not actually discriminating, and it is dropped. This is the
778
+ // same bidirectional-agreement idea that semantic-entropy work uses for
779
+ // equivalence, applied to opposition.
780
+ const judgeChunks = async (systemPrompt, question, key) => {
781
+ const named = new Set();
782
+
783
+ for (let offset = 0; offset < capped.length; offset += batchSize) {
784
+ const batch = capped.slice(offset, offset + batchSize);
785
+
786
+ const schema = {
787
+ type: 'object',
788
+ properties: { [key]: { type: 'array', items: { type: 'integer' } } },
789
+ required: [key]
790
+ };
791
+
792
+ const prompt = `Research topic: "${topic}"
793
+
794
+ Sentence pairs (numbered from 0):
795
+ ${batch.map((pair, index) => `${index}.\nA: ${pair.a}\nB: ${pair.b}`).join('\n\n')}
796
+
797
+ ${question.replace('${n}', String(batch.length))}`;
798
+
799
+ try {
800
+ let judged = null;
801
+ let lastError;
802
+ for (let attempt = 0; attempt < 2 && !judged; attempt++) {
803
+ try {
804
+ const response = await this.generateCompletion(prompt, {
805
+ systemPrompt,
806
+ maxTokens: 100 + batch.length * 6,
807
+ temperature: 0.1,
808
+ format: schema
809
+ });
810
+
811
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
812
+ const parsed = JSON.parse(cleaned);
813
+ // An empty array is a real answer and the expected one here — it
814
+ // must not be retried as though it were malformed.
815
+ if (!Array.isArray(parsed?.[key])) {
816
+ throw new Error(`Response missing ${key}`);
817
+ }
818
+ judged = parsed[key];
819
+ } catch (error) {
820
+ lastError = error;
821
+ }
822
+ }
823
+ if (!judged) throw lastError;
824
+
825
+ for (const value of judged) {
826
+ // Strictly a real integer: Number(null) is 0 and Number(true) is 1,
827
+ // so coercing here would name pair 0 or pair 1 the model never did.
828
+ if (typeof value !== 'number' || !Number.isInteger(value)) continue;
829
+ if (value < 0 || value >= batch.length) continue;
830
+ named.add(offset + value);
831
+ }
832
+ } catch (error) {
833
+ // Fail closed for this chunk only. On the contradiction pass that
834
+ // means no conflicts from it; on the consistency pass it means no
835
+ // vetoes, so a chunk that fails there cannot manufacture one.
836
+ this.logger.warn('LLM pairwise judgement failed for a batch', {
837
+ pass: key,
838
+ error: error.message
839
+ });
840
+ }
841
+ }
842
+
843
+ return named;
844
+ };
845
+
846
+ const contradicts = await judgeChunks(
847
+ systemPrompt,
848
+ 'Which of these ${n} pairs contradict?',
849
+ 'contradictions'
850
+ );
851
+
852
+ // Nothing survived the first pass, so the veto pass cannot change the
853
+ // answer — skip its calls entirely.
854
+ if (contradicts.size === 0) return [];
855
+
856
+ const consistentSystemPrompt = `You decide which numbered pairs of sentences are consistent with each other.
857
+
858
+ Return a JSON object:
859
+ {"consistent": [0, 4]}
860
+
861
+ List a pair when both sentences could be true at the same time.
862
+
863
+ These ARE consistent:
864
+ - two sentences about the same subject that emphasise different aspects
865
+ - one sentence adding scope, detail or an example the other leaves out
866
+ - a heading or table-of-contents line beside prose from the same document
867
+ - two sentences about entirely different subjects
868
+
869
+ Only a pair asserting the same thing with opposite polarity, or incompatible
870
+ values for the same quantity, is inconsistent.
871
+
872
+ Return the list and nothing else.`;
873
+
874
+ const consistent = await judgeChunks(
875
+ consistentSystemPrompt,
876
+ 'Which of these ${n} pairs are consistent?',
877
+ 'consistent'
878
+ );
879
+
880
+ const found = [...contradicts].filter(index => !consistent.has(index));
881
+ return found.sort((a, b) => a - b);
882
+ }
883
+
411
884
  /**
412
885
  * Extract structured data from content using LLM and a JSON Schema
413
886
  * Follows the same pattern as analyzeRelevance()
@@ -385,6 +385,33 @@ export class BrowserProcessor {
385
385
  return page;
386
386
  }
387
387
 
388
+ /**
389
+ * Release a stealth page and hand its context slot back to the pool.
390
+ *
391
+ * createStealthPage() registers every context in activeContexts and in
392
+ * StealthBrowserManager's capped pool (MAX_BROWSER_CONTEXTS, default 10).
393
+ * Nothing reuses a context after the call that created it, so a caller that
394
+ * only closes the page keeps the slot: after 10 stealth runs the next
395
+ * createStealthContext() waits for a free slot and then throws. Going
396
+ * through the manager's closeContext() returns the slot and the renderer.
397
+ * @param {Page} page - Stealth page returned by initializePage()
398
+ * @returns {Promise<void>}
399
+ */
400
+ async releaseStealthPage(page) {
401
+ try { await page.close(); } catch (_) { /* ignore close errors */ }
402
+
403
+ for (const [contextId, contextData] of this.activeContexts.entries()) {
404
+ if (contextData.page !== page) continue;
405
+ this.activeContexts.delete(contextId);
406
+ try {
407
+ await this.stealthManager?.closeContext(contextId);
408
+ } catch (error) {
409
+ console.warn(`Failed to close stealth context ${contextId}:`, error.message);
410
+ }
411
+ return;
412
+ }
413
+ }
414
+
388
415
  /**
389
416
  * Apply additional stealth middleware to page
390
417
  * @param {Page} page - Playwright page