crawlforge-mcp-server 5.6.7 → 5.6.9
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/package.json +2 -2
- package/server.js +11 -18
- package/src/core/ActionExecutor.js +10 -1
- 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 +26 -9
- package/src/core/ResearchOrchestrator.js +47 -4
- package/src/core/StealthBrowserManager.js +179 -7
- package/src/core/analysis/ContentAnalyzer.js +34 -3
- package/src/core/analysis/sentenceUtils.js +10 -7
- package/src/core/crawlers/BFSCrawler.js +29 -17
- 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/tools/advanced/ScrapeWithActionsTool.js +19 -3
- 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/tools/search/redditSearch.js +7 -0
- package/src/tools/templates/ScrapeTemplateTool.js +56 -0
- package/src/tools/templates/shopifyJsonLdFallback.js +160 -0
- package/src/utils/challengeDetection.js +11 -0
- package/src/utils/pageTitle.js +18 -0
- package/src/utils/stealthVerdict.js +82 -0
|
@@ -14,6 +14,10 @@ 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';
|
|
18
|
+
|
|
19
|
+
// Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
|
|
20
|
+
export const EMPTY_DOCUMENT_GRACE_MS = 8000;
|
|
17
21
|
|
|
18
22
|
const StealthConfigSchema = z.object({
|
|
19
23
|
level: z.enum(['basic', 'medium', 'advanced']).default('medium'),
|
|
@@ -290,10 +294,19 @@ export class StealthBrowserManager {
|
|
|
290
294
|
this.browser = null;
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
// C2:
|
|
297
|
+
// C2: the requested engine differs from the running browser. Park the
|
|
298
|
+
// running one instead of closing it: closing took every live context in
|
|
299
|
+
// it along, so a create_context on Chromium followed by any camoufox
|
|
300
|
+
// scrape left the caller's contextId pointing at a closed browser
|
|
301
|
+
// ("Target page, context or browser has been closed", R17 2026-09-04).
|
|
302
|
+
// Each engine keeps its own browser; cleanup() closes the parked ones.
|
|
294
303
|
if (this.browser && this._launchedEngine && this._launchedEngine !== validatedConfig.engine) {
|
|
295
|
-
|
|
296
|
-
this.browser
|
|
304
|
+
this._parkedBrowsers ??= new Map();
|
|
305
|
+
this._parkedBrowsers.set(this._launchedEngine, this.browser);
|
|
306
|
+
const parked = this._parkedBrowsers.get(validatedConfig.engine);
|
|
307
|
+
this._parkedBrowsers.delete(validatedConfig.engine);
|
|
308
|
+
this.browser = parked && parked.isConnected() ? parked : null;
|
|
309
|
+
this._launchedEngine = this.browser ? validatedConfig.engine : null;
|
|
297
310
|
}
|
|
298
311
|
|
|
299
312
|
if (this.browser) {
|
|
@@ -484,7 +497,14 @@ export class StealthBrowserManager {
|
|
|
484
497
|
// Policy is "invalid behavior for a normal browser" and rebrowser's
|
|
485
498
|
// bot detector flags it red (R15, 2026-09-04). Every init script here
|
|
486
499
|
// goes through addInitScript/evaluate, which need no CSP bypass.
|
|
487
|
-
javaScriptEnabled: true
|
|
500
|
+
javaScriptEnabled: true,
|
|
501
|
+
|
|
502
|
+
// Init scripts never reach a service worker, whose navigator therefore
|
|
503
|
+
// reported the real "HeadlessChrome/151 (Macintosh)" beside a spoofed
|
|
504
|
+
// Windows Chrome main thread (bot.incolumitas.com, R17 2026-09-04).
|
|
505
|
+
// Blocking registration removes that vantage point; a page's content
|
|
506
|
+
// does not depend on its service worker on a first, uncached visit.
|
|
507
|
+
serviceWorkers: 'block'
|
|
488
508
|
};
|
|
489
509
|
|
|
490
510
|
// camoufox's Firefox build predates the Browser.setDefaultViewport fields
|
|
@@ -499,6 +519,7 @@ export class StealthBrowserManager {
|
|
|
499
519
|
delete contextOptions.isMobile;
|
|
500
520
|
delete contextOptions.hasTouch;
|
|
501
521
|
delete contextOptions.screen;
|
|
522
|
+
delete contextOptions.serviceWorkers;
|
|
502
523
|
}
|
|
503
524
|
|
|
504
525
|
const context = await this.browser.newContext(contextOptions);
|
|
@@ -1605,6 +1626,79 @@ export class StealthBrowserManager {
|
|
|
1605
1626
|
});
|
|
1606
1627
|
}
|
|
1607
1628
|
}, fingerprint.hardware);
|
|
1629
|
+
|
|
1630
|
+
// Init scripts never run inside a dedicated Worker, so a detector that
|
|
1631
|
+
// compares navigator in a worker with the main thread saw the real
|
|
1632
|
+
// platform, deviceMemory and hardwareConcurrency beside the spoofed
|
|
1633
|
+
// ones (bot.incolumitas.com "inconsistentWebWorkerNavigatorPropery",
|
|
1634
|
+
// R17 2026-09-04). At the advanced level, classic workers start from a
|
|
1635
|
+
// blob that patches WorkerNavigator and then importScripts() the real
|
|
1636
|
+
// script; relative importScripts/fetch inside such a worker resolve
|
|
1637
|
+
// against the original script URL. Module workers, data:/blob: worker
|
|
1638
|
+
// URLs and a CSP that refuses blob workers fall through to the native
|
|
1639
|
+
// constructor untouched. camoufox spoofs workers itself.
|
|
1640
|
+
if (config.level === 'advanced' && this._launchedEngine !== 'camoufox') {
|
|
1641
|
+
await context.addInitScript(({ hardware, locale }) => {
|
|
1642
|
+
const NativeWorker = window.Worker;
|
|
1643
|
+
if (typeof NativeWorker !== 'function') return;
|
|
1644
|
+
const primary = locale.split('-')[0];
|
|
1645
|
+
const languages = primary === locale ? [locale] : [locale, primary];
|
|
1646
|
+
const spoof = JSON.stringify({
|
|
1647
|
+
hardwareConcurrency: hardware.hardwareConcurrency,
|
|
1648
|
+
platform: hardware.platform,
|
|
1649
|
+
deviceMemory: hardware.deviceMemory,
|
|
1650
|
+
languages,
|
|
1651
|
+
language: locale
|
|
1652
|
+
});
|
|
1653
|
+
const prelude = (scriptUrl) =>
|
|
1654
|
+
`(() => {
|
|
1655
|
+
const spoof = ${spoof};
|
|
1656
|
+
const proto = self.WorkerNavigator && self.WorkerNavigator.prototype;
|
|
1657
|
+
const define = (key, value) => {
|
|
1658
|
+
try { Object.defineProperty(proto, key, { get: () => value, configurable: true }); } catch (e) {}
|
|
1659
|
+
};
|
|
1660
|
+
if (proto) {
|
|
1661
|
+
define('hardwareConcurrency', spoof.hardwareConcurrency);
|
|
1662
|
+
define('platform', spoof.platform);
|
|
1663
|
+
if ('deviceMemory' in self.navigator) define('deviceMemory', spoof.deviceMemory);
|
|
1664
|
+
define('languages', spoof.languages);
|
|
1665
|
+
define('language', spoof.language);
|
|
1666
|
+
}
|
|
1667
|
+
const base = ${JSON.stringify(scriptUrl)};
|
|
1668
|
+
const resolve = (u) => { try { return new URL(String(u), base).href; } catch (e) { return u; } };
|
|
1669
|
+
const nativeImport = self.importScripts;
|
|
1670
|
+
self.importScripts = (...urls) => nativeImport.apply(self, urls.map(resolve));
|
|
1671
|
+
const nativeFetch = self.fetch;
|
|
1672
|
+
if (typeof nativeFetch === 'function') {
|
|
1673
|
+
self.fetch = (input, init) => nativeFetch.call(self, typeof input === 'string' ? resolve(input) : input, init);
|
|
1674
|
+
}
|
|
1675
|
+
if (self.XMLHttpRequest) {
|
|
1676
|
+
const nativeOpen = self.XMLHttpRequest.prototype.open;
|
|
1677
|
+
self.XMLHttpRequest.prototype.open = function(method, url, ...rest) {
|
|
1678
|
+
return nativeOpen.call(this, method, resolve(url), ...rest);
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
})();
|
|
1682
|
+
importScripts(${JSON.stringify(scriptUrl)});`;
|
|
1683
|
+
const Wrapped = function Worker(scriptURL, options) {
|
|
1684
|
+
try {
|
|
1685
|
+
const isModule = options && options.type === 'module';
|
|
1686
|
+
const absolute = new URL(String(scriptURL), location.href).href;
|
|
1687
|
+
if (!isModule && /^https?:/.test(absolute)) {
|
|
1688
|
+
const blob = new Blob([prelude(absolute)], { type: 'text/javascript' });
|
|
1689
|
+
return new NativeWorker(URL.createObjectURL(blob), options);
|
|
1690
|
+
}
|
|
1691
|
+
} catch (e) {
|
|
1692
|
+
// CSP refused the blob URL or the URL failed to parse: native worker.
|
|
1693
|
+
}
|
|
1694
|
+
return new NativeWorker(scriptURL, options);
|
|
1695
|
+
};
|
|
1696
|
+
Wrapped.prototype = NativeWorker.prototype;
|
|
1697
|
+
Object.defineProperty(Wrapped, 'name', { value: 'Worker' });
|
|
1698
|
+
Wrapped.toString = () => 'function Worker() { [native code] }';
|
|
1699
|
+
window.Worker = Wrapped;
|
|
1700
|
+
}, { hardware: fingerprint.hardware, locale: config.locale });
|
|
1701
|
+
}
|
|
1608
1702
|
}
|
|
1609
1703
|
|
|
1610
1704
|
// Font spoofing
|
|
@@ -1926,6 +2020,56 @@ export class StealthBrowserManager {
|
|
|
1926
2020
|
* @param {Object} [params.stealthConfig] — stealth configuration overrides
|
|
1927
2021
|
* @returns {Promise<{success:boolean, url:string, title:string, text:string, html:string, screenshot:?string}>}
|
|
1928
2022
|
*/
|
|
2023
|
+
/**
|
|
2024
|
+
* If the document is a self-solving bot-wall interstitial (its title is a
|
|
2025
|
+
* known challenge title), wait up to timeoutMs for the title to change —
|
|
2026
|
+
* the challenge script navigates to the real page when it passes — and for
|
|
2027
|
+
* that navigation to reach domcontentloaded. A challenge that never passes
|
|
2028
|
+
* is left to challengeDetection to report as blocked.
|
|
2029
|
+
*/
|
|
2030
|
+
/**
|
|
2031
|
+
* A document with no title and no text right after domcontentloaded is
|
|
2032
|
+
* usually a page that has not painted yet: a self-solving bot wall the
|
|
2033
|
+
* vendor list does not name (booking.com's chal_t redirect), or a client-
|
|
2034
|
+
* rendered shell (carvana.com, chewy.com). Round 18 (2026-09-04) reported
|
|
2035
|
+
* every one of them as "rendered no title and no text after 0ms" while a
|
|
2036
|
+
* 6-second wait in scrape_with_actions returned the full page. Give an
|
|
2037
|
+
* empty document one bounded grace period to fill in — or to navigate on —
|
|
2038
|
+
* before it is read. Returns the milliseconds waited (0 when the document
|
|
2039
|
+
* already had content).
|
|
2040
|
+
*/
|
|
2041
|
+
async _waitOutEmptyDocument(page, { timeoutMs = EMPTY_DOCUMENT_GRACE_MS } = {}) {
|
|
2042
|
+
const hasContent = () =>
|
|
2043
|
+
Boolean((document.title && document.title.trim()) || (document.body && document.body.innerText.trim()));
|
|
2044
|
+
let empty;
|
|
2045
|
+
try {
|
|
2046
|
+
empty = !(await page.evaluate(hasContent));
|
|
2047
|
+
} catch {
|
|
2048
|
+
return 0;
|
|
2049
|
+
}
|
|
2050
|
+
if (!empty) return 0;
|
|
2051
|
+
const started = Date.now();
|
|
2052
|
+
await page.waitForFunction(hasContent, null, { timeout: timeoutMs }).catch(() => {});
|
|
2053
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
2054
|
+
// What filled the document may itself be a challenge that navigates on.
|
|
2055
|
+
await this._waitOutChallenge(page);
|
|
2056
|
+
return Date.now() - started;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
async _waitOutChallenge(page, { timeoutMs = 8000 } = {}) {
|
|
2060
|
+
let title;
|
|
2061
|
+
try {
|
|
2062
|
+
title = await page.title();
|
|
2063
|
+
} catch {
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (!detectChallengePage({ title })) return;
|
|
2067
|
+
await page
|
|
2068
|
+
.waitForFunction((t) => document.title !== t, title, { timeout: timeoutMs })
|
|
2069
|
+
.catch(() => {});
|
|
2070
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 5000 }).catch(() => {});
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1929
2073
|
async scrapeWithStealth({ url, engine, wait_for = 0, screenshot = false, stealthConfig = {} } = {}) {
|
|
1930
2074
|
if (!url) throw new Error('scrapeWithStealth requires a url');
|
|
1931
2075
|
|
|
@@ -1934,10 +2078,29 @@ export class StealthBrowserManager {
|
|
|
1934
2078
|
const page = await this.createStealthPage(contextId);
|
|
1935
2079
|
let crashed = false;
|
|
1936
2080
|
page.on('crash', () => { crashed = true; });
|
|
2081
|
+
// The HTTP status of the document finally read: the navigation's own
|
|
2082
|
+
// response, then whatever a challenge or redirect navigated on to.
|
|
2083
|
+
// Edmunds' "403 - Access Denied" and Lufthansa's 404 came back as
|
|
2084
|
+
// success:true because nothing looked at the status (R18, 2026-09-04).
|
|
2085
|
+
let status = null;
|
|
2086
|
+
page.on('response', (response) => {
|
|
2087
|
+
try {
|
|
2088
|
+
const request = response.request();
|
|
2089
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) status = response.status();
|
|
2090
|
+
} catch { /* a detached frame; keep the last status */ }
|
|
2091
|
+
});
|
|
1937
2092
|
// SSRF guard at the navigation boundary: the stealth engine resolves DNS
|
|
1938
2093
|
// itself, so a URL/host-only check would miss rebinding to private/metadata IPs.
|
|
1939
|
-
await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2094
|
+
const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded' });
|
|
2095
|
+
if (response && status === null) status = response.status();
|
|
1940
2096
|
if (wait_for > 0) await page.waitForTimeout(wait_for);
|
|
2097
|
+
// Cloudflare's and Vercel's JavaScript challenges solve themselves in a
|
|
2098
|
+
// real browser and then reload the page. camoufox on lesswrong.com was
|
|
2099
|
+
// read while the "Vercel Security Checkpoint" interstitial was still
|
|
2100
|
+
// up, so the interstitial came back as success:true (R17, 2026-09-04).
|
|
2101
|
+
// An auto-solving challenge gets one bounded wait to finish first.
|
|
2102
|
+
await this._waitOutChallenge(page);
|
|
2103
|
+
const gracedMs = await this._waitOutEmptyDocument(page);
|
|
1941
2104
|
|
|
1942
2105
|
// A failure to read the document is a failure. With every read wrapped
|
|
1943
2106
|
// in .catch(() => ''), a renderer that crashed or a page closed during
|
|
@@ -1964,7 +2127,7 @@ export class StealthBrowserManager {
|
|
|
1964
2127
|
? await page.screenshot({ encoding: 'base64', fullPage: false }).catch(() => null)
|
|
1965
2128
|
: null;
|
|
1966
2129
|
|
|
1967
|
-
return { success: true, url, title, text, html, screenshot: shot };
|
|
2130
|
+
return { success: true, url, title, text, html, screenshot: shot, status, gracedMs };
|
|
1968
2131
|
} finally {
|
|
1969
2132
|
await this.closeContext(contextId).catch(() => {});
|
|
1970
2133
|
}
|
|
@@ -2195,9 +2358,18 @@ export class StealthBrowserManager {
|
|
|
2195
2358
|
}
|
|
2196
2359
|
|
|
2197
2360
|
// Close browser; if close hangs, kill the process so the OS reclaims it.
|
|
2361
|
+
// Browsers parked by an engine switch are closed the same way.
|
|
2362
|
+
const browsers = [];
|
|
2198
2363
|
if (this.browser) {
|
|
2199
|
-
|
|
2364
|
+
browsers.push(this.browser);
|
|
2200
2365
|
this.browser = null;
|
|
2366
|
+
}
|
|
2367
|
+
if (this._parkedBrowsers) {
|
|
2368
|
+
browsers.push(...this._parkedBrowsers.values());
|
|
2369
|
+
this._parkedBrowsers.clear();
|
|
2370
|
+
}
|
|
2371
|
+
this._launchedEngine = null;
|
|
2372
|
+
for (const browser of browsers) {
|
|
2201
2373
|
const closed = await withDeadline(browser.close(), 5000);
|
|
2202
2374
|
if (!closed) {
|
|
2203
2375
|
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
|
|
|
@@ -40,7 +41,7 @@ export class BFSCrawler {
|
|
|
40
41
|
// Session context for cookie jar + persistent headers (null = stateless)
|
|
41
42
|
this.sessionContext = sessionContext;
|
|
42
43
|
|
|
43
|
-
this.visited = new
|
|
44
|
+
this.visited = new Map(); // normalized key -> the URL as fetched
|
|
44
45
|
this.results = [];
|
|
45
46
|
this.errors = [];
|
|
46
47
|
this.filterDecisions = []; // Track filtering decisions for analysis
|
|
@@ -134,7 +135,7 @@ export class BFSCrawler {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
return {
|
|
137
|
-
urls: Array.from(this.visited),
|
|
138
|
+
urls: Array.from(this.visited.values()),
|
|
138
139
|
results: this.results,
|
|
139
140
|
errors: this.errors,
|
|
140
141
|
stats: this.getStats(),
|
|
@@ -153,6 +154,9 @@ export class BFSCrawler {
|
|
|
153
154
|
if (this.visited.has(normalizedUrl)) {
|
|
154
155
|
return;
|
|
155
156
|
}
|
|
157
|
+
// The form that goes on the wire: a bare origin gains its root "/",
|
|
158
|
+
// everything else (a trailing slash included) stays as written.
|
|
159
|
+
try { url = new URL(url).href; } catch { /* normalizeUrl accepted it; leave it */ }
|
|
156
160
|
|
|
157
161
|
// Only the seed is queued at depth 0; children are always depth >= 1.
|
|
158
162
|
const isSeed = depth === 0;
|
|
@@ -179,7 +183,11 @@ export class BFSCrawler {
|
|
|
179
183
|
// Check robots.txt through the shared gate, so this crawl reuses the same
|
|
180
184
|
// cached robots.txt every other tool fetched (and honours the platform
|
|
181
185
|
// blocklist, which no per-request flag can switch off).
|
|
182
|
-
|
|
186
|
+
// Fetch, resolve links and ask robots about the URL as written: the
|
|
187
|
+
// normalized form drops a trailing slash, and globalpetrolprices.com
|
|
188
|
+
// answers /gasoline_prices/ with 200 and /gasoline_prices with 404
|
|
189
|
+
// (R18, 2026-09-04). The normalized form is the dedupe and cache key only.
|
|
190
|
+
const gate = await robotsPreflight(url, {
|
|
183
191
|
respectRobots: this.respectRobots,
|
|
184
192
|
userAgent: this.userAgent,
|
|
185
193
|
tool: 'crawl_deep'
|
|
@@ -191,9 +199,9 @@ export class BFSCrawler {
|
|
|
191
199
|
// stay silent: skipping disallowed links is the normal case.
|
|
192
200
|
if (isSeed) {
|
|
193
201
|
this.errors.push({
|
|
194
|
-
url
|
|
202
|
+
url,
|
|
195
203
|
depth,
|
|
196
|
-
error: new RobotsDisallowedError(
|
|
204
|
+
error: new RobotsDisallowedError(url).message,
|
|
197
205
|
code: 'ROBOTS_DISALLOWED',
|
|
198
206
|
timestamp: new Date().toISOString()
|
|
199
207
|
});
|
|
@@ -208,7 +216,7 @@ export class BFSCrawler {
|
|
|
208
216
|
if (this.visited.size >= this.maxPages || this.visited.has(normalizedUrl)) {
|
|
209
217
|
return;
|
|
210
218
|
}
|
|
211
|
-
this.visited.
|
|
219
|
+
this.visited.set(normalizedUrl, url);
|
|
212
220
|
|
|
213
221
|
try {
|
|
214
222
|
// Check cache first
|
|
@@ -241,7 +249,7 @@ export class BFSCrawler {
|
|
|
241
249
|
}
|
|
242
250
|
|
|
243
251
|
// Fetch the page
|
|
244
|
-
pageData = await this.fetchPage(
|
|
252
|
+
pageData = await this.fetchPage(url);
|
|
245
253
|
|
|
246
254
|
// Cache the result
|
|
247
255
|
if (cacheKey) await this.cache.set(cacheKey, pageData);
|
|
@@ -254,18 +262,18 @@ export class BFSCrawler {
|
|
|
254
262
|
const $page = pageData.originalHtml ? load(pageData.originalHtml) : null;
|
|
255
263
|
const pageBodyText = $page ? $page('body').text() : '';
|
|
256
264
|
for (const link of pageData.links) {
|
|
257
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
265
|
+
const absoluteUrl = this.resolveUrl(link, url);
|
|
258
266
|
if (absoluteUrl) {
|
|
259
267
|
// Extract anchor text and context from link
|
|
260
268
|
const linkMetadata = this.extractLinkMetadata(link, $page, pageBodyText);
|
|
261
|
-
this.linkAnalyzer.addLink(normalizedUrl, absoluteUrl, linkMetadata);
|
|
269
|
+
this.linkAnalyzer.addLink(normalizedUrl, normalizeUrl(absoluteUrl), linkMetadata);
|
|
262
270
|
}
|
|
263
271
|
}
|
|
264
272
|
}
|
|
265
273
|
|
|
266
274
|
// Process the page
|
|
267
275
|
const result = {
|
|
268
|
-
url
|
|
276
|
+
url,
|
|
269
277
|
depth,
|
|
270
278
|
title: pageData.title,
|
|
271
279
|
contentLength: pageData.content?.length || 0,
|
|
@@ -285,8 +293,8 @@ export class BFSCrawler {
|
|
|
285
293
|
for (const link of pageData.links) {
|
|
286
294
|
if (this.visited.size >= this.maxPages) break;
|
|
287
295
|
|
|
288
|
-
const absoluteUrl = this.resolveUrl(link,
|
|
289
|
-
if (absoluteUrl && !this.visited.has(absoluteUrl)) {
|
|
296
|
+
const absoluteUrl = this.resolveUrl(link, url);
|
|
297
|
+
if (absoluteUrl && !this.visited.has(normalizeUrl(absoluteUrl))) {
|
|
290
298
|
// Not awaited: this task already holds a queue slot, so awaiting a
|
|
291
299
|
// child would keep that slot pinned for the rest of the recursive
|
|
292
300
|
// crawl (starving other tasks when concurrency <= depth, and making
|
|
@@ -305,7 +313,7 @@ export class BFSCrawler {
|
|
|
305
313
|
}
|
|
306
314
|
} catch (error) {
|
|
307
315
|
this.errors.push({
|
|
308
|
-
url
|
|
316
|
+
url,
|
|
309
317
|
depth,
|
|
310
318
|
error: error.message,
|
|
311
319
|
timestamp: new Date().toISOString()
|
|
@@ -381,7 +389,7 @@ export class BFSCrawler {
|
|
|
381
389
|
const $ = load(html);
|
|
382
390
|
|
|
383
391
|
// Extract title
|
|
384
|
-
const title = $
|
|
392
|
+
const title = pageTitle($) || $('h1').first().text().trim() || '';
|
|
385
393
|
|
|
386
394
|
// Extract main content
|
|
387
395
|
$('script, style, noscript').remove();
|
|
@@ -424,8 +432,11 @@ export class BFSCrawler {
|
|
|
424
432
|
if (!this.followExternal && linkUrl.origin !== this.baseUrl.origin) {
|
|
425
433
|
return null;
|
|
426
434
|
}
|
|
427
|
-
|
|
428
|
-
|
|
435
|
+
|
|
436
|
+
// Keep the URL as the site wrote it (trailing slash included); only
|
|
437
|
+
// the fragment goes. Dedupe happens on normalizeUrl() at the caller.
|
|
438
|
+
linkUrl.hash = '';
|
|
439
|
+
return linkUrl.toString();
|
|
429
440
|
}
|
|
430
441
|
|
|
431
442
|
// Handle relative URLs
|
|
@@ -436,7 +447,8 @@ export class BFSCrawler {
|
|
|
436
447
|
return null;
|
|
437
448
|
}
|
|
438
449
|
|
|
439
|
-
|
|
450
|
+
resolved.hash = '';
|
|
451
|
+
return resolved.toString();
|
|
440
452
|
} catch {
|
|
441
453
|
return null;
|
|
442
454
|
}
|
|
@@ -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
|
|
@@ -10,7 +10,9 @@ import { load } from 'cheerio';
|
|
|
10
10
|
|
|
11
11
|
// Import existing tool for content extraction
|
|
12
12
|
import ExtractContentTool from '../extract/extractContent.js';
|
|
13
|
+
import { stealthDocumentVerdict } from '../../utils/stealthVerdict.js';
|
|
13
14
|
import { elementText } from '../../utils/elementText.js';
|
|
15
|
+
import { pageTitle } from '../../utils/pageTitle.js';
|
|
14
16
|
|
|
15
17
|
// Recording / replay helpers
|
|
16
18
|
import {
|
|
@@ -463,14 +465,28 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
463
465
|
|
|
464
466
|
const executionTime = Date.now() - sessionContext.startTime;
|
|
465
467
|
|
|
468
|
+
// The chain ran; whether the document it ended on is the page is a
|
|
469
|
+
// separate question. A bot wall, an HTTP error page or an error-titled
|
|
470
|
+
// placeholder is named as such (R18, 2026-09-04: tesla.com's Akamai
|
|
471
|
+
// denial completed every action and reported success).
|
|
472
|
+
const verdict = stealthDocumentVerdict({
|
|
473
|
+
url: chainResult?.finalUrl || params.url,
|
|
474
|
+
title: finalContent?.title || finalContent?.metadata?.title || '',
|
|
475
|
+
text: finalContent?.content?.text || '',
|
|
476
|
+
html: chainResult?.finalHtml || '',
|
|
477
|
+
status: chainResult?.navigationStatus ?? null
|
|
478
|
+
}, { allowEmpty: true });
|
|
479
|
+
|
|
466
480
|
return {
|
|
467
|
-
success: chainResult.success,
|
|
481
|
+
success: chainResult.success && verdict.success,
|
|
468
482
|
sessionId: sessionContext.id,
|
|
469
483
|
url: params.url,
|
|
470
484
|
executionTime,
|
|
471
485
|
|
|
472
486
|
// Include error message if action chain failed
|
|
473
|
-
error: chainResult.error || undefined,
|
|
487
|
+
error: chainResult.error || verdict.error || undefined,
|
|
488
|
+
...(verdict.blocked ? { blocked: verdict.blocked } : {}),
|
|
489
|
+
...(verdict.status !== null ? { httpStatus: verdict.status } : {}),
|
|
474
490
|
|
|
475
491
|
actionResults,
|
|
476
492
|
totalActions: params.actions.length,
|
|
@@ -625,7 +641,7 @@ export class ScrapeWithActionsTool extends EventEmitter {
|
|
|
625
641
|
const state = {
|
|
626
642
|
capturePoint: stateData.afterActionIndex + 1,
|
|
627
643
|
url: stateData.url,
|
|
628
|
-
title: $
|
|
644
|
+
title: pageTitle($),
|
|
629
645
|
timestamp: stateData.timestamp,
|
|
630
646
|
content: {}
|
|
631
647
|
};
|
|
@@ -12,6 +12,7 @@ import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
|
|
|
12
12
|
import { preflightFetch } from '../../../utils/robotsGate.js';
|
|
13
13
|
import { htmlToMarkdown } from '../../../utils/htmlToMarkdown.js';
|
|
14
14
|
import { elementText } from '../../../utils/elementText.js';
|
|
15
|
+
import { pageTitle } from '../../../utils/pageTitle.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Fetch a URL with AbortController timeout (SSRF-guarded + per-host throttled).
|
|
@@ -133,7 +134,7 @@ export function generateFormats($, html, formats) {
|
|
|
133
134
|
if (formats.includes('markdown')) content.markdown = buildMarkdown($);
|
|
134
135
|
if (formats.includes('json')) {
|
|
135
136
|
content.json = {
|
|
136
|
-
title: $
|
|
137
|
+
title: pageTitle($),
|
|
137
138
|
headings: extractHeadings($),
|
|
138
139
|
links: extractLinks($),
|
|
139
140
|
images: extractImages($),
|
|
@@ -144,7 +145,7 @@ export function generateFormats($, html, formats) {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
function buildMarkdown($) {
|
|
147
|
-
const title = $
|
|
148
|
+
const title = pageTitle($);
|
|
148
149
|
|
|
149
150
|
const selectors = ['article', 'main', '.content', '#content', '.post-content', '.entry-content'];
|
|
150
151
|
let $body = null;
|
|
@@ -203,7 +204,7 @@ function extractImages($) {
|
|
|
203
204
|
|
|
204
205
|
function extractMetadata($) {
|
|
205
206
|
const m = {};
|
|
206
|
-
m.title = $
|
|
207
|
+
m.title = pageTitle($);
|
|
207
208
|
m.description = $('meta[name="description"]').attr('content') || '';
|
|
208
209
|
m.og = {};
|
|
209
210
|
$('meta[property^="og:"]').each((_, el) => {
|