crawlforge-mcp-server 5.2.8 → 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 +517 -13
  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
@@ -259,7 +259,9 @@ Return a JSON object with:
259
259
  "keyPoints": ["point1", "point2", ...],
260
260
  "topicAlignment": "description of alignment",
261
261
  "credibilityIndicators": ["indicator1", "indicator2", ...]
262
- }`;
262
+ }
263
+
264
+ Be brief: at most 5 items per array, one short sentence each.`;
263
265
 
264
266
  const prompt = `Research Topic: "${topic}"
265
267
 
@@ -269,19 +271,48 @@ ${truncatedContent}
269
271
  Analyze the relevance of this content to the research topic:`;
270
272
 
271
273
  try {
272
- const response = await this.generateCompletion(prompt, {
273
- systemPrompt,
274
- maxTokens: 500,
275
- temperature: 0.3
276
- });
277
-
278
- const analysis = JSON.parse(response);
279
- return {
280
- relevanceScore: Math.max(0, Math.min(1, analysis.relevanceScore || 0.5)),
281
- keyPoints: analysis.keyPoints || [],
282
- topicAlignment: analysis.topicAlignment || '',
283
- credibilityIndicators: analysis.credibilityIndicators || []
274
+ // Same discipline as synthesizeFindings: constrain the output shape
275
+ // (small local models otherwise wrap the JSON in markdown fences —
276
+ // the raw JSON.parse here failed on every Ollama run), strip fences,
277
+ // validate the load-bearing field, and retry a truncated response
278
+ // once before falling back.
279
+ const relevanceSchema = {
280
+ type: 'object',
281
+ properties: {
282
+ relevanceScore: { type: 'number' },
283
+ keyPoints: { type: 'array', items: { type: 'string' } },
284
+ topicAlignment: { type: 'string' },
285
+ credibilityIndicators: { type: 'array', items: { type: 'string' } }
286
+ },
287
+ required: ['relevanceScore']
284
288
  };
289
+
290
+ let lastError;
291
+ for (let attempt = 0; attempt < 2; attempt++) {
292
+ try {
293
+ const response = await this.generateCompletion(prompt, {
294
+ systemPrompt,
295
+ maxTokens: 800,
296
+ temperature: 0.3,
297
+ format: relevanceSchema
298
+ });
299
+
300
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
301
+ const analysis = JSON.parse(cleaned);
302
+ if (!analysis || typeof analysis.relevanceScore !== 'number') {
303
+ throw new Error('Relevance response missing relevanceScore');
304
+ }
305
+ return {
306
+ relevanceScore: Math.max(0, Math.min(1, analysis.relevanceScore)),
307
+ keyPoints: analysis.keyPoints || [],
308
+ topicAlignment: analysis.topicAlignment || '',
309
+ credibilityIndicators: analysis.credibilityIndicators || []
310
+ };
311
+ } catch (error) {
312
+ lastError = error;
313
+ }
314
+ }
315
+ throw lastError;
285
316
  } catch (error) {
286
317
  this.logger.warn('LLM relevance analysis failed, using fallback', { error: error.message });
287
318
  return this.fallbackRelevanceAnalysis(content, topic);
@@ -377,6 +408,479 @@ Synthesize these findings into a comprehensive analysis:`;
377
408
  }
378
409
  }
379
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
+
380
884
  /**
381
885
  * Extract structured data from content using LLM and a JSON Schema
382
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
@@ -7,6 +7,7 @@ import { Readability } from '@mozilla/readability';
7
7
  import { JSDOM } from 'jsdom';
8
8
  import * as cheerio from 'cheerio';
9
9
  import { z } from 'zod';
10
+ import { ContentQualityAssessor } from '../../utils/contentUtils.js';
10
11
 
11
12
  const ContentProcessorSchema = z.object({
12
13
  html: z.string(),
@@ -381,7 +382,11 @@ export class ContentProcessor {
381
382
  }
382
383
 
383
384
  /**
384
- * Calculate readability score using simple metrics
385
+ * Calculate readability metrics. The Flesch score, level and syllable
386
+ * counting come from ContentQualityAssessor.calculateSimpleReadability —
387
+ * the single Flesch implementation — so this never disagrees with a
388
+ * qualityAssessment computed over the same text. Score is unclamped; see
389
+ * that method for why.
385
390
  * @param {string} text - Text content
386
391
  * @returns {Object} - Readability metrics
387
392
  */
@@ -399,55 +404,22 @@ export class ContentProcessor {
399
404
  return null;
400
405
  }
401
406
 
402
- const avgWordsPerSentence = words.length / sentences.length;
407
+ const readability = ContentQualityAssessor.calculateSimpleReadability(text);
403
408
  const avgCharsPerWord = charactersNoSpaces / words.length;
404
- const avgSyllablesPerWord = words.reduce((sum, w) => sum + this._countSyllables(w), 0) / words.length;
405
-
406
- // Flesch Reading-Ease: higher score = easier to read
407
- const readabilityScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
408
409
 
409
410
  return {
410
411
  sentences: sentences.length,
411
412
  words: words.length,
412
413
  characters,
413
414
  charactersNoSpaces,
414
- avgWordsPerSentence: Math.round(avgWordsPerSentence * 100) / 100,
415
+ avgWordsPerSentence: readability.avgWordsPerSentence,
415
416
  avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100,
416
- avgSyllablesPerWord: Math.round(avgSyllablesPerWord * 100) / 100,
417
- readabilityScore: Math.round(readabilityScore * 100) / 100,
418
- readabilityLevel: this.getReadabilityLevel(readabilityScore)
417
+ avgSyllablesPerWord: readability.avgSyllablesPerWord,
418
+ readabilityScore: readability.score,
419
+ readabilityLevel: readability.level
419
420
  };
420
421
  }
421
422
 
422
- /**
423
- * Get readability level based on score
424
- * @param {number} score - Readability score
425
- * @returns {string} - Readability level
426
- */
427
- getReadabilityLevel(score) {
428
- if (score >= 90) return 'Very Easy';
429
- if (score >= 80) return 'Easy';
430
- if (score >= 70) return 'Fairly Easy';
431
- if (score >= 60) return 'Standard';
432
- if (score >= 50) return 'Fairly Difficult';
433
- if (score >= 30) return 'Difficult';
434
- return 'Very Difficult';
435
- }
436
-
437
- /**
438
- * Count syllables in a word (heuristic)
439
- * @param {string} word
440
- * @returns {number}
441
- */
442
- _countSyllables(word) {
443
- const w = word.toLowerCase().replace(/[^a-z]/g, '');
444
- if (w.length <= 3) return 1;
445
- // Remove trailing silent e
446
- const stripped = w.replace(/e$/, '');
447
- const matches = stripped.match(/[aeiouy]+/g);
448
- return Math.max(1, matches ? matches.length : 1);
449
- }
450
-
451
423
  /**
452
424
  * Extract fallback content when Readability fails
453
425
  * @param {string} html - HTML content
@@ -9,6 +9,7 @@ import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { safeFetch } from '../../utils/ssrfGuard.js';
11
11
  import { config } from '../../constants/config.js';
12
+ import { identityHeaders } from '../../utils/fetchIdentity.js';
12
13
 
13
14
  const PDFProcessorSchema = z.object({
14
15
  source: z.string().min(1),
@@ -237,9 +238,7 @@ export class PDFProcessor {
237
238
  // ignores unknown properties, so only `signal` actually enforces a
238
239
  // deadline here.
239
240
  const response = await safeFetch(url, {
240
- headers: {
241
- 'User-Agent': 'Mozilla/5.0 (compatible; MCP-WebScraper/2.0; PDF-Processor)'
242
- },
241
+ headers: identityHeaders(),
243
242
  signal: AbortSignal.timeout(30000)
244
243
  });
245
244