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.
@@ -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: if the requested engine differs from the running browser, tear it down first.
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
- await this.browser.close().catch(() => {});
296
- this.browser = null;
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
- const browser = this.browser;
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.isCjkText(text)) {
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.isCjkText(text)) {
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. Unlike the ASCII '.' these are
7
- // unambiguous — no abbreviation, decimal or initial uses them — and CJK text
8
- // puts no whitespace after them, so they split on a zero-width boundary.
9
- const CJK_TERMINATORS = '。.!?;';
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+|(?<=[。.!?;])\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(/([.!?。.!?;])\s*$/);
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(/[.!?。.!?;]\s*$/, '');
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 Set();
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
- const gate = await robotsPreflight(normalizedUrl, {
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: normalizedUrl,
202
+ url,
195
203
  depth,
196
- error: new RobotsDisallowedError(normalizedUrl).message,
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.add(normalizedUrl);
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(normalizedUrl);
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, normalizedUrl);
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: normalizedUrl,
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, normalizedUrl);
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: normalizedUrl,
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 = $('title').text().trim() || $('h1').first().text().trim() || '';
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
- return normalizeUrl(link);
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
- return normalizeUrl(resolved.toString());
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 = $('title').text().trim();
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: $('title').text().trim() || null,
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: $('title').text().trim() || null,
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 = $('title').text().trim();
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: $('title').text().trim(),
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: $('title').text().trim(),
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 = $('title').text().trim();
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 = $('title').text().trim();
207
+ m.title = pageTitle($);
207
208
  m.description = $('meta[name="description"]').attr('content') || '';
208
209
  m.og = {};
209
210
  $('meta[property^="og:"]').each((_, el) => {