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.
- package/CLAUDE.md +13 -1
- package/README.md +9 -9
- package/package.json +2 -2
- package/server.js +175 -26
- package/src/cli/commands/stealth.js +7 -1
- package/src/constants/config.js +2 -1
- package/src/core/ActionExecutor.js +168 -16
- package/src/core/AlertNotificationSystem.js +2 -1
- package/src/core/AuthManager.js +19 -1
- package/src/core/ChangeTracker.js +34 -6
- package/src/core/LLMsTxtAnalyzer.js +94 -12
- package/src/core/LocalizationManager.js +2 -1
- package/src/core/ResearchOrchestrator.js +407 -86
- package/src/core/StealthBrowserManager.js +186 -105
- package/src/core/WebhookDispatcher.js +3 -4
- package/src/core/analysis/ContentAnalyzer.js +41 -15
- package/src/core/analysis/sentenceUtils.js +16 -5
- package/src/core/crawlers/BFSCrawler.js +44 -21
- package/src/core/llm/LLMManager.js +517 -13
- package/src/core/processing/BrowserProcessor.js +27 -0
- package/src/core/processing/ContentProcessor.js +11 -39
- package/src/core/processing/PDFProcessor.js +2 -3
- package/src/core/research/claimFilters.js +235 -0
- package/src/schemas/toolOutputSchemas.js +5 -1
- package/src/security/wave3-security.js +2 -1
- package/src/server/requestContext.js +23 -0
- package/src/server/withAuth.js +21 -5
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/tools/advanced/ScrapeWithActionsTool.js +49 -1
- package/src/tools/advanced/batchScrape/schema.js +4 -0
- package/src/tools/advanced/batchScrape/worker.js +19 -10
- package/src/tools/basic/_fetch.js +19 -15
- package/src/tools/basic/extractLinks.js +8 -3
- package/src/tools/basic/extractMetadata.js +7 -3
- package/src/tools/basic/extractText.js +8 -3
- package/src/tools/basic/fetchUrl.js +7 -3
- package/src/tools/basic/scrapeStructured.js +76 -3
- package/src/tools/crawl/_sessionContext.js +10 -2
- package/src/tools/crawl/crawlDeep.js +29 -12
- package/src/tools/crawl/mapSite.js +39 -14
- package/src/tools/extract/_fetchAndParse.js +23 -8
- package/src/tools/extract/analyzeContent.js +5 -3
- package/src/tools/extract/extractContent.js +18 -4
- package/src/tools/extract/extractStructured.js +66 -12
- package/src/tools/extract/extractWithLlm.js +51 -4
- package/src/tools/extract/processDocument.js +45 -78
- package/src/tools/extract/summarizeContent.js +35 -1
- package/src/tools/llmstxt/generateLLMsTxt.js +19 -4
- package/src/tools/research/deepResearch.js +2 -1
- package/src/tools/scrape/_brandingExtractor.js +42 -3
- package/src/tools/scrape/_mainContent.js +105 -0
- package/src/tools/scrape/unifiedScrape.js +21 -14
- package/src/tools/search/adapters/redditOfficialApi.js +7 -6
- package/src/tools/search/redditSearch.js +6 -3
- package/src/tools/search/searchWeb.js +26 -3
- package/src/tools/templates/ScrapeTemplateTool.js +17 -6
- package/src/tools/tracking/trackChanges/differ.js +26 -3
- package/src/tools/tracking/trackChanges/index.js +12 -5
- package/src/tools/tracking/trackChanges/notifier.js +3 -1
- package/src/tools/tracking/trackChanges/schema.js +3 -0
- package/src/utils/complianceAudit.js +72 -0
- package/src/utils/contentUtils.js +12 -1
- package/src/utils/domainFilter.js +38 -19
- package/src/utils/fetchIdentity.js +62 -0
- package/src/utils/hostBlocklist.js +81 -0
- package/src/utils/hostRateLimiter.js +101 -2
- package/src/utils/robotsChecker.js +90 -43
- package/src/utils/robotsGate.js +206 -0
- package/src/utils/sitemapParser.js +33 -15
- package/src/utils/ssrfProtection.js +2 -1
- package/src/utils/webBotAuth.js +193 -0
|
@@ -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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
|
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:
|
|
415
|
+
avgWordsPerSentence: readability.avgWordsPerSentence,
|
|
415
416
|
avgCharsPerWord: Math.round(avgCharsPerWord * 100) / 100,
|
|
416
|
-
avgSyllablesPerWord:
|
|
417
|
-
readabilityScore:
|
|
418
|
-
readabilityLevel:
|
|
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
|
|