crawlforge-mcp-server 5.6.6 → 5.6.8
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 +3 -3
- package/package.json +5 -3
- package/server.js +86 -62
- package/src/core/AgentOrchestrator.js +107 -5
- package/src/core/ChangeTracker.js +2 -1
- package/src/core/LLMsTxtAnalyzer.js +2 -1
- package/src/core/LocalizationManager.js +13 -6
- package/src/core/StealthBrowserManager.js +132 -5
- package/src/core/analysis/ContentAnalyzer.js +34 -3
- package/src/core/analysis/sentenceUtils.js +10 -7
- package/src/core/crawlers/BFSCrawler.js +2 -1
- package/src/core/integrations/PerformanceIntegration.js +2 -1
- package/src/core/processing/ContentProcessor.js +3 -2
- package/src/core/workers/worker.js +2 -1
- package/src/server/fallbackHints.js +66 -0
- package/src/server/toolFilter.js +1 -1
- package/src/server/withAuth.js +8 -0
- package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +2 -2
- package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
- package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +7 -5
- package/src/skills/installer.js +19 -0
- package/src/tools/advanced/ScrapeWithActionsTool.js +2 -1
- package/src/tools/advanced/batchScrape/worker.js +4 -3
- package/src/tools/crawl/mapSite.js +2 -1
- package/src/tools/extract/_fetchAndParse.js +11 -26
- package/src/tools/extract/extractContent.js +5 -1
- package/src/tools/extract/extractStructured.js +13 -3
- package/src/tools/scrape/unifiedScrape.js +2 -1
- package/src/utils/challengeDetection.js +11 -0
- package/src/utils/pageTitle.js +18 -0
|
@@ -34,7 +34,11 @@ const DEFAULT_MAX_URLS = 10;
|
|
|
34
34
|
function isRelevant(text, query) {
|
|
35
35
|
if (!text || !query) return true; // fail-open
|
|
36
36
|
const lc = text.toLowerCase();
|
|
37
|
-
|
|
37
|
+
// Terms are compared bare: the quoted entity in `npm package "commander"?`
|
|
38
|
+
// arrived as `"commander"?` and matched nothing (R17, 2026-09-04).
|
|
39
|
+
return query.toLowerCase().split(/\s+/)
|
|
40
|
+
.map(term => term.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
41
|
+
.some(term => term.length > 3 && lc.includes(term));
|
|
38
42
|
}
|
|
39
43
|
|
|
40
44
|
/**
|
|
@@ -45,6 +49,46 @@ function truncate(text, maxChars = 8000) {
|
|
|
45
49
|
return text.slice(0, maxChars) + '\n[...truncated]';
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Literal provenance check: every version, date and large number in the
|
|
54
|
+
* answer must appear somewhere in the fetched source text. Returns the raw
|
|
55
|
+
* strings that do not (deduplicated, in answer order). Comparison is
|
|
56
|
+
* case-insensitive with whitespace and thousands separators removed, so
|
|
57
|
+
* "6.3.3" matches "6.3.3" and "1,234" matches "1 234". URLs in the answer are
|
|
58
|
+
* ignored: they are citations, not claims.
|
|
59
|
+
*/
|
|
60
|
+
const MONTHS = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*';
|
|
61
|
+
const VALUE_PATTERNS = [
|
|
62
|
+
/\b\d+\.\d+(?:\.\d+){0,2}\b/g, // versions 1.2 / 1.2.3 / 1.2.3.4
|
|
63
|
+
/\b\d{4}-\d{2}-\d{2}\b/g, // ISO dates
|
|
64
|
+
new RegExp(`\\b${MONTHS}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?,?\\s+\\d{4}\\b`, 'gi'), // January 18, 2024
|
|
65
|
+
new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTHS}\\.?\\s+\\d{4}\\b`, 'gi'), // 18 January 2024
|
|
66
|
+
/\b\d{1,3}(?:,\d{3})+\b|\b\d{4,}\b/g, // 1,234 / 12345
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function normalizeValue(value) {
|
|
70
|
+
return value.toLowerCase().replace(/[\s,]/g, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function unverifiedValues(answer, sourceText) {
|
|
74
|
+
if (!answer) return [];
|
|
75
|
+
const stripped = answer.replace(/https?:\/\/\S+/g, ' ');
|
|
76
|
+
const haystack = normalizeValue(sourceText || '');
|
|
77
|
+
const missing = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
80
|
+
for (const match of stripped.matchAll(pattern)) {
|
|
81
|
+
const raw = match[0];
|
|
82
|
+
const key = normalizeValue(raw);
|
|
83
|
+
if (!key || seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
if (!haystack.includes(key)) missing.push(raw);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// "2024" inside an already-flagged "January 18, 2024" is the same claim.
|
|
89
|
+
return missing.filter((v, i) => !missing.some((o, j) => j !== i && o.length > v.length && o.includes(v)));
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
/**
|
|
49
93
|
* Current-state task gate: prompts about the live "now" ("right now",
|
|
50
94
|
* "currently", "today", "latest", "#1 … now") go stale through search alone —
|
|
@@ -222,6 +266,17 @@ export class AgentOrchestrator {
|
|
|
222
266
|
} catch {
|
|
223
267
|
// Sampling unavailable — use raw prompt
|
|
224
268
|
}
|
|
269
|
+
// A quoted term in the prompt ("commander") is the thing being asked
|
|
270
|
+
// about. A planner told to search the bare entity reduced
|
|
271
|
+
// 'npm package "commander"' to "npm", fetched only npm's own docs and
|
|
272
|
+
// answered from memory (R17, 2026-09-04). Every query carries the quoted
|
|
273
|
+
// terms.
|
|
274
|
+
const quotedTerms = [...prompt.matchAll(/"([^"\n]{2,60})"/g)].map(m => m[1].trim()).filter(Boolean);
|
|
275
|
+
if (quotedTerms.length > 0) {
|
|
276
|
+
searchQueries = searchQueries.map(q =>
|
|
277
|
+
quotedTerms.every(t => q.toLowerCase().includes(t.toLowerCase())) ? q : `${q} ${quotedTerms.join(' ')}`.trim()
|
|
278
|
+
);
|
|
279
|
+
}
|
|
225
280
|
|
|
226
281
|
// ── GATHER (search) ───────────────────────────────────────────────────────
|
|
227
282
|
const urlQueue = [...seedUrls]; // start with any user-provided seeds
|
|
@@ -238,6 +293,8 @@ export class AgentOrchestrator {
|
|
|
238
293
|
if (!priorityUrls.includes(url)) priorityUrls.push(url);
|
|
239
294
|
}
|
|
240
295
|
const searchResults = [];
|
|
296
|
+
/** url -> { title, text }: the engine's excerpt, evidence of last resort for a page that cannot be fetched. */
|
|
297
|
+
const excerpts = new Map();
|
|
241
298
|
|
|
242
299
|
if (urlQueue.length < capUrls) {
|
|
243
300
|
try {
|
|
@@ -253,6 +310,11 @@ export class AgentOrchestrator {
|
|
|
253
310
|
for (const r of parsed.results) {
|
|
254
311
|
if (r.link && !urlQueue.includes(r.link)) urlQueue.push(r.link);
|
|
255
312
|
searchResults.push({ query: q, title: r.title || '', url: r.link || '', snippet: r.snippet || '' });
|
|
313
|
+
// What the engine saw of the page: the snippet plus the
|
|
314
|
+
// page's own meta description, which for npmjs.com carries
|
|
315
|
+
// "Latest version: 15.0.0" while the snippet does not.
|
|
316
|
+
const excerpt = [r.snippet, r.pagemap?.metatags?.description].filter(Boolean).join(' ');
|
|
317
|
+
if (r.link && excerpt && !excerpts.has(r.link)) excerpts.set(r.link, { title: r.title || '', text: excerpt });
|
|
256
318
|
}
|
|
257
319
|
}
|
|
258
320
|
} catch { /* skip failed search */ }
|
|
@@ -318,7 +380,17 @@ export class AgentOrchestrator {
|
|
|
318
380
|
text: truncate(textContent),
|
|
319
381
|
step
|
|
320
382
|
});
|
|
321
|
-
} catch {
|
|
383
|
+
} catch {
|
|
384
|
+
// The page is out of reach (challenge page, 403, timeout) but the
|
|
385
|
+
// search engine's excerpt of it is not: npmjs.com's "Latest version:
|
|
386
|
+
// 15.0.0" sat in the snippet while every fetch of the package page was
|
|
387
|
+
// challenged, and the run ended with no evidence at all (R17,
|
|
388
|
+
// 2026-09-04). Keep a relevant snippet as evidence, labelled as one.
|
|
389
|
+
const excerpt = excerpts.get(url);
|
|
390
|
+
if (excerpt && isRelevant(excerpt.text, prompt)) {
|
|
391
|
+
evidence.push({ url, title: excerpt.title, text: excerpt.text, snippet: true, step });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
322
394
|
}
|
|
323
395
|
|
|
324
396
|
// ── SHAPE ─────────────────────────────────────────────────────────────────
|
|
@@ -340,7 +412,7 @@ export class AgentOrchestrator {
|
|
|
340
412
|
.sort((a, b) => b._score - a._score);
|
|
341
413
|
const perSourceCap = Math.max(1500, Math.floor(12000 / Math.max(evidence.length, 1)));
|
|
342
414
|
const combinedText = orderedEvidence
|
|
343
|
-
.map(e => `--- Source: ${e.url} ---\n${truncate(e.text, perSourceCap)}`)
|
|
415
|
+
.map(e => `--- Source: ${e.url}${e.snippet ? ' (search-result snippet; the page itself could not be fetched)' : ''} ---\n${truncate(e.text, perSourceCap)}`)
|
|
344
416
|
.join('\n\n');
|
|
345
417
|
|
|
346
418
|
if (!combinedText.trim()) {
|
|
@@ -386,6 +458,7 @@ export class AgentOrchestrator {
|
|
|
386
458
|
let answer = null;
|
|
387
459
|
let degraded = false;
|
|
388
460
|
let degradedReason;
|
|
461
|
+
let unverified = [];
|
|
389
462
|
|
|
390
463
|
try {
|
|
391
464
|
// Wording matters for small local models (llama3.2-class): without the
|
|
@@ -414,6 +487,34 @@ export class AgentOrchestrator {
|
|
|
414
487
|
|
|
415
488
|
const { text } = await this._getSamplingClient().complete(synthesisPrompt, { maxTokens: 1024 });
|
|
416
489
|
answer = text;
|
|
490
|
+
|
|
491
|
+
// A small model fills gaps from memory: "Swift 5.9.3", "commander 8.6.2
|
|
492
|
+
// published January 18, 2024" — none of it on any fetched page (R17,
|
|
493
|
+
// 2026-09-04). Versions, dates and counts in the answer must appear in
|
|
494
|
+
// the source text; what does not gets one corrective rewrite, and
|
|
495
|
+
// whatever still remains is named in the answer and in `provenance`.
|
|
496
|
+
unverified = unverifiedValues(answer, combinedText);
|
|
497
|
+
if (unverified.length > 0) {
|
|
498
|
+
const retryPrompt =
|
|
499
|
+
`${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
|
|
500
|
+
`It contains values that appear nowhere in the source text: ${unverified.map(v => `"${v}"`).join(', ')}. ` +
|
|
501
|
+
`Rewrite the answer. Keep only values that the sources state, spelled as the sources spell them. ` +
|
|
502
|
+
`Where the sources do not state a value, say that it is not stated instead of guessing.`;
|
|
503
|
+
try {
|
|
504
|
+
const { text: rewritten } = await this._getSamplingClient().complete(retryPrompt, { maxTokens: 1024 });
|
|
505
|
+
if (rewritten && rewritten.trim()) {
|
|
506
|
+
answer = rewritten;
|
|
507
|
+
unverified = unverifiedValues(answer, combinedText);
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
// Keep the first answer; it is flagged below.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (unverified.length > 0) {
|
|
514
|
+
answer +=
|
|
515
|
+
`\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
|
|
516
|
+
`${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
|
|
517
|
+
}
|
|
417
518
|
} catch (err) {
|
|
418
519
|
degraded = true;
|
|
419
520
|
degradedReason = `LLM synthesis unavailable: ${err.message}`;
|
|
@@ -425,11 +526,12 @@ export class AgentOrchestrator {
|
|
|
425
526
|
success: true,
|
|
426
527
|
answer,
|
|
427
528
|
search_results: searchResults,
|
|
428
|
-
evidence: degraded ? evidence : evidence.map(e => ({ url: e.url })),
|
|
529
|
+
evidence: degraded ? evidence : evidence.map(e => (e.snippet ? { url: e.url, snippet: true } : { url: e.url })),
|
|
429
530
|
degraded,
|
|
430
531
|
reason: degradedReason,
|
|
431
532
|
steps: step,
|
|
432
|
-
urls_fetched: urlsFetched
|
|
533
|
+
urls_fetched: urlsFetched,
|
|
534
|
+
provenance: { checked: !degraded, unverified }
|
|
433
535
|
};
|
|
434
536
|
}
|
|
435
537
|
|
|
@@ -13,6 +13,7 @@ import { load } from 'cheerio';
|
|
|
13
13
|
import { diffWords, diffLines, diffChars } from 'diff';
|
|
14
14
|
import { structureSignature, structuralSimilarity } from 'crawlforge-extractors';
|
|
15
15
|
import { calculateSimilarity as calculateContentSimilarity } from '../tools/tracking/trackChanges/differ.js';
|
|
16
|
+
import { pageTitle } from '../utils/pageTitle.js';
|
|
16
17
|
|
|
17
18
|
const ChangeTrackingSchema = z.object({
|
|
18
19
|
url: z.string().url(),
|
|
@@ -892,7 +893,7 @@ export class ChangeTracker extends EventEmitter {
|
|
|
892
893
|
|
|
893
894
|
extractMetadata($, options) {
|
|
894
895
|
const metadata = {
|
|
895
|
-
title: $
|
|
896
|
+
title: pageTitle($) || '',
|
|
896
897
|
headings: [],
|
|
897
898
|
links: [],
|
|
898
899
|
images: [],
|
|
@@ -9,6 +9,7 @@ import { safeFetch } from '../utils/ssrfGuard.js';
|
|
|
9
9
|
import { resolveUserAgent } from '../utils/fetchIdentity.js';
|
|
10
10
|
import { preflightFetch } from '../utils/robotsGate.js';
|
|
11
11
|
import { noteRetryAfter } from '../utils/hostRateLimiter.js';
|
|
12
|
+
import { pageTitle } from '../utils/pageTitle.js';
|
|
12
13
|
|
|
13
14
|
const logger = new Logger('LLMsTxtAnalyzer');
|
|
14
15
|
|
|
@@ -722,7 +723,7 @@ export class LLMsTxtAnalyzer {
|
|
|
722
723
|
type: 'static',
|
|
723
724
|
confidence: 0.7,
|
|
724
725
|
metadata: {
|
|
725
|
-
title: $
|
|
726
|
+
title: pageTitle($),
|
|
726
727
|
contentLength: html.length
|
|
727
728
|
}
|
|
728
729
|
};
|
|
@@ -679,11 +679,15 @@ export class LocalizationManager extends EventEmitter {
|
|
|
679
679
|
return this.timezoneCache.get(timezone);
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
-
// Calculate dynamically if not cached
|
|
682
|
+
// Calculate dynamically if not cached. Compare the target zone against
|
|
683
|
+
// UTC, never against the host zone: the old form added the host offset
|
|
684
|
+
// once more, so America/Sao_Paulo came out as -420 from a UTC-4 host
|
|
685
|
+
// (R17, 2026-09-04). Both wall-clock strings are parsed the same way, so
|
|
686
|
+
// the difference is the zone's current offset in minutes.
|
|
683
687
|
const now = new Date();
|
|
684
|
-
const
|
|
685
|
-
const
|
|
686
|
-
const offset = (
|
|
688
|
+
const asTarget = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
|
689
|
+
const asUtc = new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }));
|
|
690
|
+
const offset = Math.round((asTarget.getTime() - asUtc.getTime()) / 60000);
|
|
687
691
|
|
|
688
692
|
this.timezoneCache.set(timezone, offset);
|
|
689
693
|
return offset;
|
|
@@ -1344,10 +1348,13 @@ export class LocalizationManager extends EventEmitter {
|
|
|
1344
1348
|
}
|
|
1345
1349
|
|
|
1346
1350
|
// CJK script detection
|
|
1351
|
+
// Kana and Hangul are tested before the unified ideographs: Japanese
|
|
1352
|
+
// text is full of kanji, so a chinese-first order labelled every
|
|
1353
|
+
// Japanese page "chinese" (R17, 2026-09-04).
|
|
1347
1354
|
const cjkPatterns = {
|
|
1348
|
-
chinese: /[\u4E00-\u9FFF]/,
|
|
1349
1355
|
japanese: /[\u3040-\u309F\u30A0-\u30FF]/,
|
|
1350
|
-
korean: /[\uAC00-\uD7AF]
|
|
1356
|
+
korean: /[\uAC00-\uD7AF]/,
|
|
1357
|
+
chinese: /[\u4E00-\u9FFF]/
|
|
1351
1358
|
};
|
|
1352
1359
|
|
|
1353
1360
|
for (const [script, pattern] of Object.entries(cjkPatterns)) {
|
|
@@ -14,6 +14,7 @@ import crypto from 'crypto';
|
|
|
14
14
|
import HumanBehaviorSimulator from '../utils/HumanBehaviorSimulator.js';
|
|
15
15
|
import { BrowserContextPool } from './BrowserContextPool.js';
|
|
16
16
|
import { safeGoto } from '../utils/ssrfGuard.js';
|
|
17
|
+
import { detectChallengePage } from '../utils/challengeDetection.js';
|
|
17
18
|
|
|
18
19
|
const StealthConfigSchema = z.object({
|
|
19
20
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
@@ -290,10 +291,19 @@ export class StealthBrowserManager {
|
|
|
290
291
|
this.browser = null;
|
|
291
292
|
}
|
|
292
293
|
|
|
293
|
-
// C2:
|
|
294
|
+
// C2: the requested engine differs from the running browser. Park the
|
|
295
|
+
// running one instead of closing it: closing took every live context in
|
|
296
|
+
// it along, so a create_context on Chromium followed by any camoufox
|
|
297
|
+
// scrape left the caller's contextId pointing at a closed browser
|
|
298
|
+
// ("Target page, context or browser has been closed", R17 2026-09-04).
|
|
299
|
+
// Each engine keeps its own browser; cleanup() closes the parked ones.
|
|
294
300
|
if (this.browser && this._launchedEngine && this._launchedEngine !== validatedConfig.engine) {
|
|
295
|
-
|
|
296
|
-
this.browser
|
|
301
|
+
this._parkedBrowsers ??= new Map();
|
|
302
|
+
this._parkedBrowsers.set(this._launchedEngine, this.browser);
|
|
303
|
+
const parked = this._parkedBrowsers.get(validatedConfig.engine);
|
|
304
|
+
this._parkedBrowsers.delete(validatedConfig.engine);
|
|
305
|
+
this.browser = parked && parked.isConnected() ? parked : null;
|
|
306
|
+
this._launchedEngine = this.browser ? validatedConfig.engine : null;
|
|
297
307
|
}
|
|
298
308
|
|
|
299
309
|
if (this.browser) {
|
|
@@ -484,7 +494,14 @@ export class StealthBrowserManager {
|
|
|
484
494
|
// Policy is "invalid behavior for a normal browser" and rebrowser's
|
|
485
495
|
// bot detector flags it red (R15, 2026-09-04). Every init script here
|
|
486
496
|
// goes through addInitScript/evaluate, which need no CSP bypass.
|
|
487
|
-
javaScriptEnabled: true
|
|
497
|
+
javaScriptEnabled: true,
|
|
498
|
+
|
|
499
|
+
// Init scripts never reach a service worker, whose navigator therefore
|
|
500
|
+
// reported the real "HeadlessChrome/151 (Macintosh)" beside a spoofed
|
|
501
|
+
// Windows Chrome main thread (bot.incolumitas.com, R17 2026-09-04).
|
|
502
|
+
// Blocking registration removes that vantage point; a page's content
|
|
503
|
+
// does not depend on its service worker on a first, uncached visit.
|
|
504
|
+
serviceWorkers: 'block'
|
|
488
505
|
};
|
|
489
506
|
|
|
490
507
|
// camoufox's Firefox build predates the Browser.setDefaultViewport fields
|
|
@@ -499,6 +516,7 @@ export class StealthBrowserManager {
|
|
|
499
516
|
delete contextOptions.isMobile;
|
|
500
517
|
delete contextOptions.hasTouch;
|
|
501
518
|
delete contextOptions.screen;
|
|
519
|
+
delete contextOptions.serviceWorkers;
|
|
502
520
|
}
|
|
503
521
|
|
|
504
522
|
const context = await this.browser.newContext(contextOptions);
|
|
@@ -1605,6 +1623,79 @@ export class StealthBrowserManager {
|
|
|
1605
1623
|
});
|
|
1606
1624
|
}
|
|
1607
1625
|
}, fingerprint.hardware);
|
|
1626
|
+
|
|
1627
|
+
// Init scripts never run inside a dedicated Worker, so a detector that
|
|
1628
|
+
// compares navigator in a worker with the main thread saw the real
|
|
1629
|
+
// platform, deviceMemory and hardwareConcurrency beside the spoofed
|
|
1630
|
+
// ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
|
|
1631
|
+
// R17 2026-09-04). At the advanced level, classic workers start from a
|
|
1632
|
+
// blob that patches WorkerNavigator and then importScripts() the real
|
|
1633
|
+
// script; relative importScripts/fetch inside such a worker resolve
|
|
1634
|
+
// against the original script URL. Module workers, data:/blob: worker
|
|
1635
|
+
// URLs and a CSP that refuses blob workers fall through to the native
|
|
1636
|
+
// constructor untouched. camoufox spoofs workers itself.
|
|
1637
|
+
if (config.level === 'advanced' && this._launchedEngine !== 'camoufox') {
|
|
1638
|
+
await context.addInitScript(({ hardware, locale }) => {
|
|
1639
|
+
const NativeWorker = window.Worker;
|
|
1640
|
+
if (typeof NativeWorker !== 'function') return;
|
|
1641
|
+
const primary = locale.split('-')[0];
|
|
1642
|
+
const languages = primary === locale ? [locale] : [locale, primary];
|
|
1643
|
+
const spoof = JSON.stringify({
|
|
1644
|
+
hardwareConcurrency: hardware.hardwareConcurrency,
|
|
1645
|
+
platform: hardware.platform,
|
|
1646
|
+
deviceMemory: hardware.deviceMemory,
|
|
1647
|
+
languages,
|
|
1648
|
+
language: locale
|
|
1649
|
+
});
|
|
1650
|
+
const prelude = (scriptUrl) =>
|
|
1651
|
+
`(() => {
|
|
1652
|
+
const spoof = ${spoof};
|
|
1653
|
+
const proto = self.WorkerNavigator && self.WorkerNavigator.prototype;
|
|
1654
|
+
const define = (key, value) => {
|
|
1655
|
+
try { Object.defineProperty(proto, key, { get: () => value, configurable: true }); } catch (e) {}
|
|
1656
|
+
};
|
|
1657
|
+
if (proto) {
|
|
1658
|
+
define('hardwareConcurrency', spoof.hardwareConcurrency);
|
|
1659
|
+
define('platform', spoof.platform);
|
|
1660
|
+
if ('deviceMemory' in self.navigator) define('deviceMemory', spoof.deviceMemory);
|
|
1661
|
+
define('languages', spoof.languages);
|
|
1662
|
+
define('language', spoof.language);
|
|
1663
|
+
}
|
|
1664
|
+
const base = ${JSON.stringify(scriptUrl)};
|
|
1665
|
+
const resolve = (u) => { try { return new URL(String(u), base).href; } catch (e) { return u; } };
|
|
1666
|
+
const nativeImport = self.importScripts;
|
|
1667
|
+
self.importScripts = (...urls) => nativeImport.apply(self, urls.map(resolve));
|
|
1668
|
+
const nativeFetch = self.fetch;
|
|
1669
|
+
if (typeof nativeFetch === 'function') {
|
|
1670
|
+
self.fetch = (input, init) => nativeFetch.call(self, typeof input === 'string' ? resolve(input) : input, init);
|
|
1671
|
+
}
|
|
1672
|
+
if (self.XMLHttpRequest) {
|
|
1673
|
+
const nativeOpen = self.XMLHttpRequest.prototype.open;
|
|
1674
|
+
self.XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
1675
|
+
return nativeOpen.call(this, method, resolve(url), ...rest);
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
})();
|
|
1679
|
+
importScripts(${JSON.stringify(scriptUrl)});`;
|
|
1680
|
+
const Wrapped = function Worker(scriptURL, options) {
|
|
1681
|
+
try {
|
|
1682
|
+
const isModule = options && options.type === 'module';
|
|
1683
|
+
const absolute = new URL(String(scriptURL), location.href).href;
|
|
1684
|
+
if (!isModule && /^https?:/.test(absolute)) {
|
|
1685
|
+
const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
|
|
1686
|
+
return new NativeWorker(URL.createObjectURL(blob), options);
|
|
1687
|
+
}
|
|
1688
|
+
} catch (e) {
|
|
1689
|
+
// CSP refused the blob URL or the URL failed to parse: native worker.
|
|
1690
|
+
}
|
|
1691
|
+
return new NativeWorker(scriptURL, options);
|
|
1692
|
+
};
|
|
1693
|
+
Wrapped.prototype = NativeWorker.prototype;
|
|
1694
|
+
Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
|
|
1695
|
+
Wrapped.toString = () => 'function Worker() { [native code] }';
|
|
1696
|
+
window.Worker = Wrapped;
|
|
1697
|
+
}, { hardware: fingerprint.hardware, locale: config.locale });
|
|
1698
|
+
}
|
|
1608
1699
|
}
|
|
1609
1700
|
|
|
1610
1701
|
// Font spoofing
|
|
@@ -1926,6 +2017,27 @@ export class StealthBrowserManager {
|
|
|
1926
2017
|
* @param {Object} [params.stealthConfig] — stealth configuration overrides
|
|
1927
2018
|
* @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string}>}
|
|
1928
2019
|
*/
|
|
2020
|
+
/**
|
|
2021
|
+
* If the document is a self-solving bot-wall interstitial (its title is a
|
|
2022
|
+
* known challenge title), wait up to timeoutMs for the title to change —
|
|
2023
|
+
* the challenge script navigates to the real page when it passes — and for
|
|
2024
|
+
* that navigation to reach domcontentloaded. A challenge that never passes
|
|
2025
|
+
* is left to challengeDetection to report as blocked.
|
|
2026
|
+
*/
|
|
2027
|
+
async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
|
|
2028
|
+
let title;
|
|
2029
|
+
try {
|
|
2030
|
+
title = await page.title();
|
|
2031
|
+
} catch {
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (!detectChallengePage({ title })) return;
|
|
2035
|
+
await page
|
|
2036
|
+
.waitForFunction((t) => document.title !== t, title, { timeout: timeoutMs })
|
|
2037
|
+
.catch(() => {});
|
|
2038
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1929
2041
|
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
|
|
1930
2042
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
1931
2043
|
|
|
@@ -1938,6 +2050,12 @@ export class StealthBrowserManager {
|
|
|
1938
2050
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
1939
2051
|
await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
1940
2052
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2053
|
+
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
2054
|
+
// real browser and then reload the page. camoufox on lesswrong.com was
|
|
2055
|
+
// read while the "Vercel Security Checkpoint" interstitial was still
|
|
2056
|
+
// up, so the interstitial came back as success:true (R17, 2026-09-04).
|
|
2057
|
+
// An auto-solving challenge gets one bounded wait to finish first.
|
|
2058
|
+
await this._waitOutChallenge(page);
|
|
1941
2059
|
|
|
1942
2060
|
// A failure to read the document is a failure. With every read wrapped
|
|
1943
2061
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -2195,9 +2313,18 @@ export class StealthBrowserManager {
|
|
|
2195
2313
|
}
|
|
2196
2314
|
|
|
2197
2315
|
// Close browser; if close hangs, kill the process so the OS reclaims it.
|
|
2316
|
+
// Browsers parked by an engine switch are closed the same way.
|
|
2317
|
+
const browsers = [];
|
|
2198
2318
|
if (this.browser) {
|
|
2199
|
-
|
|
2319
|
+
browsers.push(this.browser);
|
|
2200
2320
|
this.browser = null;
|
|
2321
|
+
}
|
|
2322
|
+
if (this._parkedBrowsers) {
|
|
2323
|
+
browsers.push(...this._parkedBrowsers.values());
|
|
2324
|
+
this._parkedBrowsers.clear();
|
|
2325
|
+
}
|
|
2326
|
+
this._launchedEngine = null;
|
|
2327
|
+
for (const browser of browsers) {
|
|
2201
2328
|
const closed = await withDeadline(browser.close(), 5000);
|
|
2202
2329
|
if (!closed) {
|
|
2203
2330
|
try {
|
|
@@ -204,6 +204,9 @@ export class ContentAnalyzer {
|
|
|
204
204
|
if (analysisOptions.detectLanguage) {
|
|
205
205
|
result.language = await this.detectLanguage(text, analysisOptions);
|
|
206
206
|
}
|
|
207
|
+
// Topic and keyword extraction pick their tokenizer by language: the
|
|
208
|
+
// English noun-phrase matcher returns whole clauses for anything else.
|
|
209
|
+
analysisOptions.detectedLanguage = result.language?.code || null;
|
|
207
210
|
|
|
208
211
|
// Text summarization
|
|
209
212
|
if (analysisOptions.summarize) {
|
|
@@ -440,12 +443,34 @@ export class ContentAnalyzer {
|
|
|
440
443
|
* @param {Object} options - Extraction options
|
|
441
444
|
* @returns {Promise<Array>} - Array of topics
|
|
442
445
|
*/
|
|
446
|
+
/**
|
|
447
|
+
* Whether topic/keyword extraction must rank segmented content words
|
|
448
|
+
* instead of running the English noun-phrase matcher. compromise knows no
|
|
449
|
+
* grammar but English: on CJK text it emits whole multi-sentence runs as
|
|
450
|
+
* one "phrase", and on Hindi or Finnish it returned clause-length
|
|
451
|
+
* "keywords" such as "kaupungissa asuu noin 680 000 ihmistä" (R17,
|
|
452
|
+
* 2026-09-04). Any detected non-English language, or a text whose letters
|
|
453
|
+
* are mostly non-Latin, takes the word-frequency path.
|
|
454
|
+
* @param {string} text
|
|
455
|
+
* @param {{ detectedLanguage?: string|null }} options
|
|
456
|
+
* @returns {boolean}
|
|
457
|
+
*/
|
|
458
|
+
needsSegmentedTerms(text, options = {}) {
|
|
459
|
+
if (this.isCjkText(text)) return true;
|
|
460
|
+
const lang = options.detectedLanguage;
|
|
461
|
+
if (lang && lang !== 'und' && lang !== 'eng' && lang !== 'sco') return true;
|
|
462
|
+
const letters = text.match(/\p{L}/gu);
|
|
463
|
+
if (!letters || letters.length === 0) return false;
|
|
464
|
+
const latin = text.match(/\p{Script=Latin}/gu) || [];
|
|
465
|
+
return latin.length / letters.length < 0.5;
|
|
466
|
+
}
|
|
467
|
+
|
|
443
468
|
async extractTopics(text, options = {}) {
|
|
444
469
|
try {
|
|
445
470
|
// compromise noun-phrase matching is English-only: on CJK text it emits
|
|
446
471
|
// whole multi-sentence runs as one "phrase". Use dictionary-segmented
|
|
447
472
|
// content words, ranked by RAKE-style relative salience like below.
|
|
448
|
-
if (this.
|
|
473
|
+
if (this.needsSegmentedTerms(text, options)) {
|
|
449
474
|
const termFreq = this.cjkContentWordFrequencies(text);
|
|
450
475
|
const maxFreq = Math.max(1, ...Object.values(termFreq));
|
|
451
476
|
return Object.entries(termFreq)
|
|
@@ -632,7 +657,7 @@ export class ContentAnalyzer {
|
|
|
632
657
|
try {
|
|
633
658
|
// compromise is English-only: on CJK text it returns whole multi-sentence
|
|
634
659
|
// runs as single "terms". Rank dictionary-segmented content words instead.
|
|
635
|
-
if (this.
|
|
660
|
+
if (this.needsSegmentedTerms(text, options)) {
|
|
636
661
|
const termFreq = this.cjkContentWordFrequencies(text);
|
|
637
662
|
const totalTerms = Object.values(termFreq).reduce((sum, freq) => sum + freq, 0);
|
|
638
663
|
return Object.entries(termFreq)
|
|
@@ -1027,7 +1052,13 @@ export class ContentAnalyzer {
|
|
|
1027
1052
|
'也是', '就是', '我们', '你们', '他们', '她们', '它们', '这个', '那个',
|
|
1028
1053
|
'这些', '那些', '一个', '一些', '以及', '或者', '但是', '因为', '所以',
|
|
1029
1054
|
'如果', '没有', '可以', '已经', '通过', '由于', '对于', '其中', '并且',
|
|
1030
|
-
'而且', '虽然', '什么', '自己', '这里', '那里'
|
|
1055
|
+
'而且', '虽然', '什么', '自己', '这里', '那里',
|
|
1056
|
+
// Japanese particles, auxiliaries and formal nouns the segmenter emits
|
|
1057
|
+
// as tokens ("ます", "です" led kakaku.com keywords in R17, 2026-09-04)
|
|
1058
|
+
'ます', 'です', 'てい', 'って', 'ので', 'から', 'まで', 'など', 'また',
|
|
1059
|
+
'する', 'いる', 'ある', 'こと', 'これ', 'それ', 'ため', 'よう', 'として',
|
|
1060
|
+
'について', 'という', 'られ', 'れる', 'でき', 'ました', 'ません', 'した',
|
|
1061
|
+
'その', 'この', 'あの', 'もの', 'ところ', 'ここ', 'そこ'
|
|
1031
1062
|
];
|
|
1032
1063
|
|
|
1033
1064
|
return cjkStopWords.includes(word);
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* domain names, and other common patterns that contain periods.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// CJK / fullwidth sentence terminators
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
6
|
+
// CJK / fullwidth sentence terminators, plus the Devanagari danda (।) and
|
|
7
|
+
// double danda (॥) that end Hindi, Marathi, Nepali and Sanskrit sentences —
|
|
8
|
+
// a Hindi paragraph of five sentences counted as one (R17, 2026-09-04).
|
|
9
|
+
// Unlike the ASCII '.' these are unambiguous — no abbreviation, decimal or
|
|
10
|
+
// initial uses them — and CJK text puts no whitespace after them, so they
|
|
11
|
+
// split on a zero-width boundary.
|
|
12
|
+
const CJK_TERMINATORS = '。.!?;।॥';
|
|
10
13
|
|
|
11
14
|
// Common abbreviations that should not trigger sentence splits
|
|
12
15
|
const ABBREVIATIONS = new Set([
|
|
@@ -33,16 +36,16 @@ export function splitSentences(text) {
|
|
|
33
36
|
|
|
34
37
|
// Split by potential sentence boundaries: . ! ? and the CJK terminators
|
|
35
38
|
// But be smart about abbreviations, numbers, and domain-like patterns
|
|
36
|
-
const tokens = text.split(/(?<=[.!?])\s+|(?<=[
|
|
39
|
+
const tokens = text.split(/(?<=[.!?])\s+|(?<=[。.!?;।॥])\s*/);
|
|
37
40
|
|
|
38
41
|
for (const token of tokens) {
|
|
39
42
|
const combined = current ? current + ' ' + token : token;
|
|
40
43
|
|
|
41
44
|
// Check if the current chunk ends with something that looks like a sentence end
|
|
42
|
-
const endMatch = combined.match(/([
|
|
45
|
+
const endMatch = combined.match(/([.!?。.!?;।॥])\s*$/);
|
|
43
46
|
if (endMatch) {
|
|
44
47
|
// Check if the period is likely NOT a sentence boundary
|
|
45
|
-
const beforePeriod = combined.replace(/[
|
|
48
|
+
const beforePeriod = combined.replace(/[.!?。.!?;।॥]\s*$/, '');
|
|
46
49
|
const lastWord = beforePeriod.split(/\s+/).pop() || '';
|
|
47
50
|
const lastWordLower = lastWord.toLowerCase().replace(/[^a-z]/g, '');
|
|
48
51
|
|
|
@@ -11,6 +11,7 @@ import { safeFetch } from '../../utils/ssrfGuard.js';
|
|
|
11
11
|
import { robotsPreflight, RobotsDisallowedError } from '../../utils/robotsGate.js';
|
|
12
12
|
import { throttleHost } from '../../utils/hostRateLimiter.js';
|
|
13
13
|
import { CRAWLFORGE_USER_AGENT, identityHeaders } from '../../utils/fetchIdentity.js';
|
|
14
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
14
15
|
|
|
15
16
|
const logger = new Logger('BFSCrawler');
|
|
16
17
|
|
|
@@ -381,7 +382,7 @@ export class BFSCrawler {
|
|
|
381
382
|
const $ = load(html);
|
|
382
383
|
|
|
383
384
|
// Extract title
|
|
384
|
-
const title = $
|
|
385
|
+
const title = pageTitle($) || $('h1').first().text().trim() || '';
|
|
385
386
|
|
|
386
387
|
// Extract main content
|
|
387
388
|
$('script, style, noscript').remove();
|
|
@@ -8,6 +8,7 @@ import WorkerPool from '../workers/WorkerPool.js';
|
|
|
8
8
|
import ConnectionPool from '../connections/ConnectionPool.js';
|
|
9
9
|
import StreamProcessor from '../processing/StreamProcessor.js';
|
|
10
10
|
import { config } from '../../constants/config.js';
|
|
11
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
11
12
|
|
|
12
13
|
let performanceManagerInstance = null;
|
|
13
14
|
let workerPoolInstance = null;
|
|
@@ -279,7 +280,7 @@ async function parseHtmlSync(html, options = {}) {
|
|
|
279
280
|
|
|
280
281
|
if (options.extractText !== false) {
|
|
281
282
|
result.text = $('body').text().trim();
|
|
282
|
-
result.title = $
|
|
283
|
+
result.title = pageTitle($);
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
if (options.extractLinks) {
|
|
@@ -9,6 +9,7 @@ import * as cheerio from 'cheerio';
|
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { ContentQualityAssessor } from '../../utils/contentUtils.js';
|
|
11
11
|
import { recoverDroppedTables } from '../../tools/scrape/_mainContent.js';
|
|
12
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
12
13
|
|
|
13
14
|
const ContentProcessorSchema = z.object({
|
|
14
15
|
html: z.string(),
|
|
@@ -303,7 +304,7 @@ export class ContentProcessor {
|
|
|
303
304
|
extractMetadata(html) {
|
|
304
305
|
const $ = cheerio.load(html);
|
|
305
306
|
const metadata = {
|
|
306
|
-
title: $
|
|
307
|
+
title: pageTitle($) || null,
|
|
307
308
|
description: null,
|
|
308
309
|
keywords: null,
|
|
309
310
|
author: null,
|
|
@@ -474,7 +475,7 @@ export class ContentProcessor {
|
|
|
474
475
|
|
|
475
476
|
return {
|
|
476
477
|
content: mainContent,
|
|
477
|
-
title: $
|
|
478
|
+
title: pageTitle($) || null,
|
|
478
479
|
headings: this.extractHeadings($),
|
|
479
480
|
length: mainContent ? mainContent.length : 0
|
|
480
481
|
};
|
|
@@ -9,6 +9,7 @@ import { JSDOM } from 'jsdom';
|
|
|
9
9
|
import { Readability } from '@mozilla/readability';
|
|
10
10
|
import compromise from 'compromise';
|
|
11
11
|
import { franc } from 'franc';
|
|
12
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
12
13
|
|
|
13
14
|
// Task handlers
|
|
14
15
|
const taskHandlers = {
|
|
@@ -93,7 +94,7 @@ async function handleParseHtml(data) {
|
|
|
93
94
|
// Extract text content
|
|
94
95
|
if (extractText) {
|
|
95
96
|
result.text = $('body').text().trim();
|
|
96
|
-
result.title = $
|
|
97
|
+
result.title = pageTitle($);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
// Extract links
|