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.
- 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 +473 -0
- 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
|
@@ -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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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(
|
|
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
|