crawlforge-mcp-server 5.6.0 → 5.6.2

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 CHANGED
@@ -60,9 +60,9 @@ These guidelines are working if: fewer unnecessary changes in diffs, fewer rewri
60
60
 
61
61
  ## Project Overview
62
62
 
63
- CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 28 web scraping, crawling, and content processing tools (5 inline + 23 advanced).
63
+ CrawlForge MCP Server - A professional MCP (Model Context Protocol) server providing 29 web scraping, crawling, and content processing tools (6 inline + 23 advanced).
64
64
 
65
- **Current Version:** 5.3.0
65
+ **Current Version:** 5.6.0
66
66
 
67
67
  ## Development Commands
68
68
 
@@ -75,11 +75,6 @@ npm run setup
75
75
  # Or provide API key via environment:
76
76
  export CRAWLFORGE_API_KEY="your_api_key_here"
77
77
 
78
- # Creator Mode (for package maintainer only)
79
- # Set your creator secret in .env file:
80
- # CRAWLFORGE_CREATOR_SECRET=your-secret-uuid
81
- # This enables unlimited access for development/testing
82
-
83
78
  # Run the server (production)
84
79
  npm start
85
80
 
@@ -172,13 +167,13 @@ Tools are organized in subdirectories by category:
172
167
  - `tracking/` - trackChanges
173
168
  - `llmstxt/` - generateLLMsTxt
174
169
 
175
- ### Available MCP Tools (28 total)
170
+ ### Available MCP Tools (29 total)
176
171
 
177
- **Basic Tools (server.js inline, 5):**
178
- fetch_url, extract_text, extract_links, extract_metadata, scrape_structured
172
+ **Basic Tools (server.js inline, 6):**
173
+ fetch_url, extract_text, extract_links, extract_metadata, extract_embedded_state, scrape_structured
179
174
 
180
175
  **Advanced Tools (23):**
181
- search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
176
+ search_web, serp_rank, reddit_search, crawl_deep, map_site, extract_content, process_document, summarize_content, analyze_content, extract_structured, extract_with_llm, list_ollama_models, batch_scrape, get_batch_results, scrape_with_actions, deep_research, track_changes, generate_llms_txt, stealth_mode, localization, scrape_template, scrape, agent
182
177
 
183
178
  **serp_rank (DataForSEO):**
184
179
  - `serp_rank` — reports where a target domain ranks in Google's REAL organic results for a keyword (the SERP position Google Custom Search / `search_web` cannot give). Backed by the DataForSEO Google Organic SERP API (Live Advanced, `POST /v3/serp/google/organic/live/advanced`, HTTP Basic auth). Credentials via `DATAFORSEO_LOGIN` / `DATAFORSEO_PASSWORD`, billed to the user's own DataForSEO account (~US$0.002 per 10 results of `depth`, so $0.004 at the default `depth:20` and $0.02 at `depth:100`), separate from CrawlForge credits. When unconfigured it returns `{ configured:false }` and charges **0** credits; when configured, **Cost: 5**. Never fabricates a rank.
package/README.md CHANGED
@@ -192,7 +192,7 @@ CrawlForge requires a CrawlForge API key — **every tool is metered and consume
192
192
  | `summarize_content` | 4 | Generate intelligent summaries |
193
193
  | `crawl_deep` | 4 | Deep crawl entire websites |
194
194
  | `search_web` | 5 | Search the web using Google Search API |
195
- | `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this queries the Arctic Shift + PullPush community archives (free, no Reddit credentials). A Reddit-wide search spends a web search to discover posts, so it is priced with `search_web` |
195
+ | `reddit_search` | 5 | Search Reddit posts/comments or read a full thread — reddit.com blocks direct scraping, so this reads the Arctic Shift community archive (free, no Reddit credentials). A Reddit-wide search spends a web search to discover posts, so it is priced with `search_web` |
196
196
  | `serp_rank` | 5 | Check where a domain ranks in Google's **real organic SERP** for a keyword (the position `search_web` can't give). Powered by DataForSEO (`DATAFORSEO_LOGIN`/`DATAFORSEO_PASSWORD`, billed to your own DataForSEO account). Returns `{ configured:false }` and charges **0** credits until configured |
197
197
  | `batch_scrape` | 5 | Process multiple URLs simultaneously |
198
198
  | `scrape_with_actions` | 5 | Browser automation chains |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.6.0",
3
+ "version": "5.6.2",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 29 web scraping, crawling, deep-research, and autonomous-extraction tools. Returns clean Markdown and structured JSON for Claude, Cursor, and any MCP client. Defaults to local Ollama for LLM extraction (no API key needed); OpenAI/Anthropic available as opt-in. Includes a unified multi-format scrape tool, an autonomous agent, pre-built site templates, and Camoufox stealth browsing.",
6
6
  "main": "server.js",
@@ -114,7 +114,7 @@
114
114
  "cheerio": "^1.1.2",
115
115
  "commander": "^14.0.3",
116
116
  "compromise": "^14.14.4",
117
- "crawlforge-extractors": "^1.6.0",
117
+ "crawlforge-extractors": "^1.6.2",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -33,6 +33,7 @@ import { LocalizationManager } from "./src/core/LocalizationManager.js";
33
33
  // Stealth scrape: format conversion + the pre-fetch compliance gate (G5/G6/G7)
34
34
  import * as cheerio from "cheerio";
35
35
  import { htmlToMarkdown } from "./src/utils/htmlToMarkdown.js";
36
+ import { detectChallengePage } from "./src/utils/challengeDetection.js";
36
37
  import { browserPreflight } from "./src/utils/robotsGate.js";
37
38
  import { memoryMonitor } from "./src/utils/MemoryMonitor.js";
38
39
  import { config, validateConfig, getToolConfig } from "./src/constants/config.js";
@@ -105,7 +106,7 @@ const taskStore = createTaskStore({ logger });
105
106
  // Create the server
106
107
  const server = new McpServer({
107
108
  name: "crawlforge",
108
- version: "5.6.0",
109
+ version: "5.6.2",
109
110
  description: "Production-ready MCP server with 29 web scraping, crawling, and content processing tools. Features MCP Resources (crawlforge://), Prompts, Sampling fallback, Elicitation, stealth browsing, deep research, structured extraction, embedded JavaScript state extraction, real Google SERP rank tracking, Reddit search via community archives, change tracking, local-LLM extraction via Ollama, unified multi-format scrape, and autonomous agent tool.",
110
111
  homepage: "https://www.crawlforge.dev",
111
112
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1377,12 +1378,21 @@ registerToolIfEnabled("stealth_mode", {
1377
1378
  stealthConfig
1378
1379
  });
1379
1380
 
1381
+ // A bot-wall interstitial arrives as HTTP 200 with a title and prose
1382
+ // of its own; reported as a successful scrape it hid the block for
1383
+ // three rounds (R10 Q1 → R15). Name it — the content is still returned
1384
+ // so the caller can see what the site served.
1385
+ const challenge = detectChallengePage(scraped);
1380
1386
  result = {
1381
- success: true,
1387
+ success: !challenge,
1382
1388
  url: scraped.url,
1383
1389
  title: scraped.title,
1384
1390
  content: stealthScrapeFormats(formats, scraped)
1385
1391
  };
1392
+ if (challenge) {
1393
+ result.blocked = challenge;
1394
+ result.error = `${challenge.vendor} served a challenge page instead of the content (${challenge.evidence}); the stealth browser did not pass it.`;
1395
+ }
1386
1396
 
1387
1397
  // Screenshots follow the same crawlforge://screenshot/{id} pattern as
1388
1398
  // scrape and scrape_with_actions, so the base64 never bloats the result.
@@ -1452,6 +1462,12 @@ registerToolIfEnabled("stealth_mode", {
1452
1462
  ok: response ? response.ok() : null,
1453
1463
  title: await page.title().catch(() => null)
1454
1464
  };
1465
+ const challenge = detectChallengePage({
1466
+ title: navigation.title || '',
1467
+ html: await page.content().catch(() => ''),
1468
+ text: await page.innerText('body').catch(() => '')
1469
+ });
1470
+ if (challenge) navigation.blocked = challenge;
1455
1471
  }
1456
1472
  } finally {
1457
1473
  // No operation can ever reference this page again — keeping it open
@@ -1574,7 +1590,7 @@ registerToolIfEnabled("localization", {
1574
1590
  break;
1575
1591
  case 'generate_timezone_spoof':
1576
1592
  result = {
1577
- timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode),
1593
+ timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode, params.timezone),
1578
1594
  countryCode: params.countryCode || localizationManager.getCurrentSettings().countryCode
1579
1595
  };
1580
1596
  break;
@@ -203,7 +203,13 @@ export class AgentOrchestrator {
203
203
  const { text } = await this._getSamplingClient().complete(planPrompt, { maxTokens: 200 });
204
204
  const lines = text.split('\n')
205
205
  .map(l => l.replace(/^[-*\d.)\s]+/, '').trim())
206
+ // A model that fences its list ("```\nq1\nq2\n```") or quotes each
207
+ // query leaks the fence and quote characters into the queries; the
208
+ // R14 Julia run spent two of its three searches on the literal "```".
209
+ .map(l => l.replace(/^```\w*\s*|\s*```$/g, '').replace(/^[`"']+|[`"']+$/g, '').trim())
206
210
  .filter(Boolean)
211
+ // A line with no letter or digit left is punctuation, not a query.
212
+ .filter(l => /[\p{L}\p{N}]/u.test(l))
207
213
  // Drop preamble/garbage lines ("Here are 3 concise web search queries:", …)
208
214
  // so they never become search query #1 and poison the URL queue.
209
215
  .filter(l =>
@@ -428,9 +428,13 @@ export class LocalizationManager extends EventEmitter {
428
428
  * @param {string} countryCode - Target country code
429
429
  * @returns {string} - JavaScript injection code
430
430
  */
431
- async generateTimezoneSpoof(countryCode = null) {
431
+ async generateTimezoneSpoof(countryCode = null, timezone = null) {
432
432
  const targetCountry = countryCode || this.currentSettings.countryCode;
433
- const config = await this.getLocalizationConfig(targetCountry);
433
+ let config = await this.getLocalizationConfig(targetCountry);
434
+ // An explicit IANA timezone wins over the country default: the tool
435
+ // accepted `timezone` and silently answered with the configured country's
436
+ // zone (Asia/Tokyo asked, Europe/Berlin returned — R14).
437
+ if (timezone) config = { ...config, timezone };
434
438
 
435
439
  const timezoneOffset = this.getTimezoneOffset(config.timezone);
436
440
 
@@ -140,47 +140,50 @@ export class StealthBrowserManager {
140
140
  }
141
141
  };
142
142
 
143
- // Enhanced User agent pools with realistic patterns
143
+ // User agent pools. Chrome majors track the bundled Chromium (151 at
144
+ // playwright-core 1.62) and Firefox tracks the Camoufox build (135): a
145
+ // UA thirty majors behind the engine — the pool sat at 119–121 while
146
+ // Chrome 152 shipped — is a staleness tell for anything that reads
147
+ // sec-ch-ua (R15, 2026-09-04).
144
148
  this.userAgentPools = {
145
149
  chrome: {
146
150
  windows: [
147
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
148
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
149
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
150
- 'Mozilla/5.0 (Windows NT 11.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
151
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
152
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
153
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36',
154
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36'
151
155
  ],
152
156
  macos: [
153
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
154
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
155
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_6_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
157
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
158
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
159
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
156
160
  ],
157
161
  linux: [
158
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
159
- 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
162
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36',
163
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36',
164
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36'
160
165
  ]
161
166
  },
162
167
  firefox: {
163
168
  windows: [
164
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
165
- 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
169
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0'
166
170
  ],
167
171
  macos: [
168
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:122.0) Gecko/20100101 Firefox/122.0',
169
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0'
172
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0'
170
173
  ],
171
174
  linux: [
172
- 'Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0',
173
- 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0'
175
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0',
176
+ 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0'
174
177
  ]
175
178
  },
176
179
  safari: {
177
180
  macos: [
178
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15',
179
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15'
181
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15',
182
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15'
180
183
  ]
181
184
  }
182
185
  };
183
-
186
+
184
187
  // Operating system distributions for realistic user agent selection
185
188
  this.osDistribution = {
186
189
  windows: 0.75,
@@ -467,8 +470,10 @@ export class StealthBrowserManager {
467
470
  height: fingerprint.screen.height
468
471
  },
469
472
 
470
- // Bypass various detections
471
- bypassCSP: true,
473
+ // bypassCSP is deliberately NOT set: ignoring a page's Content Security
474
+ // Policy is "invalid behavior for a normal browser" and rebrowser's
475
+ // bot detector flags it red (R15, 2026-09-04). Every init script here
476
+ // goes through addInitScript/evaluate, which need no CSP bypass.
472
477
  javaScriptEnabled: true
473
478
  };
474
479
 
@@ -616,6 +621,14 @@ export class StealthBrowserManager {
616
621
 
617
622
  // Select browser based on distribution and OS compatibility
618
623
  let availableBrowsers = { ...this.browserDistribution };
624
+ // The engine decides the pool. Chromium exposes navigator.vendor
625
+ // "Google Inc.", window.chrome and Chrome's PDF viewer plugins, so a
626
+ // Firefox or Safari User-Agent on it contradicts what the page can see —
627
+ // sannysoft printed a Firefox UA beside "Chrome: present" (R14). Camoufox
628
+ // presents its own Firefox identity and never draws from this pool.
629
+ if (config.engine !== 'camoufox') {
630
+ availableBrowsers = { chrome: 1 };
631
+ }
619
632
  if (selectedOS === 'linux' && availableBrowsers.safari) {
620
633
  delete availableBrowsers.safari;
621
634
  // Redistribute safari's weight
@@ -696,10 +709,10 @@ export class StealthBrowserManager {
696
709
  * @param {string} [userAgent] — the selected user agent string
697
710
  */
698
711
  generateSecChUaHeader(userAgent = '') {
699
- // Extract Chrome major version from the UA (e.g. "Chrome/121.0.0.0" → "121").
700
- // Fall back to 121 if the UA is not a Chrome UA.
712
+ // Extract Chrome major version from the UA (e.g. "Chrome/151.0.0.0" → "151").
713
+ // Fall back to the bundled Chromium major if the UA is not a Chrome UA.
701
714
  const match = userAgent.match(/Chrome\/(\d+)/i);
702
- const version = match ? match[1] : '121';
715
+ const version = match ? match[1] : '151';
703
716
 
704
717
  const brands = [
705
718
  { brand: 'Not_A Brand', version: '8' },
@@ -1005,7 +1018,9 @@ export class StealthBrowserManager {
1005
1018
  processor: selectedProcessor.name,
1006
1019
  architecture: 'x86_64',
1007
1020
  memory: Math.floor(Math.random() * 24) + 8, // 8-32 GB
1008
- deviceMemory: Math.pow(2, Math.floor(Math.random() * 3) + 3), // 8, 16, or 32 GB
1021
+ // navigator.deviceMemory is capped at 8 in every Chromium build, so 16
1022
+ // or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
1023
+ deviceMemory: Math.random() < 0.7 ? 8 : 4,
1009
1024
  platform: this.selectRealisticPlatform(selectedOS)
1010
1025
  };
1011
1026
  }
@@ -1215,29 +1230,84 @@ export class StealthBrowserManager {
1215
1230
  })
1216
1231
  });
1217
1232
 
1218
- // Override plugin array
1219
- Object.defineProperty(navigator, 'plugins', {
1220
- get: function() {
1221
- const plugins = [
1222
- {
1223
- 0: {
1224
- type: "application/x-google-chrome-pdf",
1225
- suffixes: "pdf",
1226
- description: "Portable Document Format"
1227
- },
1228
- description: "Portable Document Format",
1229
- filename: "internal-pdf-viewer",
1230
- length: 1,
1231
- name: "Chrome PDF Plugin"
1232
- }
1233
- ];
1234
- plugins.item = function(index) { return this[index] || null; };
1235
- plugins.namedItem = function(name) {
1236
- return this.find(plugin => plugin.name === name) || null;
1237
- };
1238
- return plugins;
1233
+ // Plugin list. New headless Chromium already reports the same five PDF
1234
+ // viewer entries as a real PluginArray, and replacing them with a plain
1235
+ // Array fails the "plugins is of type PluginArray" check every scanner
1236
+ // runs. Only an empty list — the legacy headless tell — is replaced.
1237
+ if (navigator.plugins.length === 0 && typeof PluginArray !== 'undefined') {
1238
+ // Built on the real prototypes so `instanceof PluginArray`, the
1239
+ // toStringTag and the DOM-style accessors all hold — a plain Array here
1240
+ // failed bot.sannysoft.com's "plugins is of type PluginArray" check.
1241
+ // The five entries are what every desktop Chrome reports.
1242
+ const mimeSpecs = [
1243
+ { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
1244
+ { type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' }
1245
+ ];
1246
+ const makePlugin = (name) => {
1247
+ const plugin = Object.create(Plugin.prototype);
1248
+ const mimeTypes = mimeSpecs.map((spec) => {
1249
+ const mimeType = Object.create(MimeType.prototype);
1250
+ Object.defineProperties(mimeType, {
1251
+ type: { value: spec.type },
1252
+ suffixes: { value: spec.suffixes },
1253
+ description: { value: spec.description },
1254
+ enabledPlugin: { get: () => plugin }
1255
+ });
1256
+ return mimeType;
1257
+ });
1258
+ Object.defineProperties(plugin, {
1259
+ name: { value: name },
1260
+ filename: { value: 'internal-pdf-viewer' },
1261
+ description: { value: 'Portable Document Format' },
1262
+ length: { value: mimeTypes.length },
1263
+ item: { value: (index) => mimeTypes[index] || null },
1264
+ namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
1265
+ [Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
1266
+ });
1267
+ mimeTypes.forEach((mimeType, index) => {
1268
+ Object.defineProperty(plugin, index, { value: mimeType, enumerable: true });
1269
+ Object.defineProperty(plugin, mimeType.type, { value: mimeType });
1270
+ });
1271
+ return plugin;
1272
+ };
1273
+ const plugins = [
1274
+ 'PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer',
1275
+ 'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'
1276
+ ].map(makePlugin);
1277
+ const pluginArray = Object.create(PluginArray.prototype);
1278
+ Object.defineProperties(pluginArray, {
1279
+ length: { value: plugins.length },
1280
+ item: { value: (index) => plugins[index] || null },
1281
+ namedItem: { value: (name) => plugins.find((p) => p.name === name) || null },
1282
+ refresh: { value: () => {} },
1283
+ [Symbol.iterator]: { value: function* () { yield* plugins; } }
1284
+ });
1285
+ plugins.forEach((plugin, index) => {
1286
+ Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
1287
+ Object.defineProperty(pluginArray, plugin.name, { value: plugin });
1288
+ });
1289
+ Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
1290
+
1291
+ // Real Chrome pairs those plugins with two navigator.mimeTypes entries
1292
+ // (application/pdf, text/pdf). Five plugins beside an empty mimeTypes
1293
+ // is its own tell — infosimples' detect-headless read "5 plugins /
1294
+ // 0 mime types" (R15, 2026-09-04).
1295
+ if (typeof MimeTypeArray !== 'undefined') {
1296
+ const mimeTypeArray = Object.create(MimeTypeArray.prototype);
1297
+ const mimeTypes = Array.from(plugins[0]);
1298
+ Object.defineProperties(mimeTypeArray, {
1299
+ length: { value: mimeTypes.length },
1300
+ item: { value: (index) => mimeTypes[index] || null },
1301
+ namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
1302
+ [Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
1303
+ });
1304
+ mimeTypes.forEach((mimeType, index) => {
1305
+ Object.defineProperty(mimeTypeArray, index, { value: mimeType, enumerable: true });
1306
+ Object.defineProperty(mimeTypeArray, mimeType.type, { value: mimeType });
1307
+ });
1308
+ Object.defineProperty(navigator, 'mimeTypes', { get: () => mimeTypeArray });
1239
1309
  }
1240
- });
1310
+ }
1241
1311
 
1242
1312
  // Override languages with the fingerprint's own locale — a hardcoded
1243
1313
  // en-US here contradicts navigator.language and Accept-Language whenever
@@ -1367,14 +1437,23 @@ export class StealthBrowserManager {
1367
1437
  HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
1368
1438
  const ctx = getContext.call(this, contextType, contextAttributes);
1369
1439
 
1370
- if (contextType === 'webgl' || contextType === 'experimental-webgl') {
1440
+ // Headless Chromium without a GPU path answers getContext('webgl')
1441
+ // with null; wrapping it threw a TypeError where a real browser
1442
+ // returns null, which is itself a tell.
1443
+ if (ctx && (contextType === 'webgl' || contextType === 'experimental-webgl')) {
1371
1444
  const originalGetParameter = ctx.getParameter;
1445
+ // Real Chrome answers VENDOR/RENDERER with "WebKit"/"WebKit WebGL";
1446
+ // the GPU identity lives behind WEBGL_debug_renderer_info as the
1447
+ // UNMASKED_VENDOR_WEBGL (37445) / UNMASKED_RENDERER_WEBGL (37446)
1448
+ // parameters. Those are what a scanner reads, so those are spoofed.
1449
+ const UNMASKED_VENDOR_WEBGL = 37445;
1450
+ const UNMASKED_RENDERER_WEBGL = 37446;
1372
1451
  ctx.getParameter = function(parameter) {
1373
1452
  // Spoof specific WebGL parameters
1374
- if (parameter === ctx.RENDERER) {
1453
+ if (parameter === UNMASKED_RENDERER_WEBGL) {
1375
1454
  return webglConfig.renderer;
1376
1455
  }
1377
- if (parameter === ctx.VENDOR) {
1456
+ if (parameter === UNMASKED_VENDOR_WEBGL) {
1378
1457
  return webglConfig.vendor;
1379
1458
  }
1380
1459
  if (parameter === ctx.VERSION) {
@@ -1387,13 +1466,10 @@ export class StealthBrowserManager {
1387
1466
  return originalGetParameter.call(this, parameter);
1388
1467
  };
1389
1468
 
1390
- const originalGetExtension = ctx.getExtension;
1391
- ctx.getExtension = function(name) {
1392
- if (webglConfig.extensions.includes(name)) {
1393
- return originalGetExtension.call(this, name) || {};
1394
- }
1395
- return null;
1396
- };
1469
+ // getExtension is left alone: returning null for an extension the
1470
+ // fingerprint's list did not name made WEBGL_debug_renderer_info
1471
+ // vanish, and a page that reads it threw a TypeError where a real
1472
+ // browser reports a GPU (sannysoft "WebGL Vendor: Error", R14).
1397
1473
  }
1398
1474
 
1399
1475
  return ctx;
@@ -1797,47 +1873,34 @@ export class StealthBrowserManager {
1797
1873
  }
1798
1874
  }
1799
1875
 
1876
+ /**
1877
+ * Whether a request is dropped before it leaves the browser. Only the
1878
+ * advanced level sheds assets, never by URL and never the document itself.
1879
+ * @param {string} resourceType - Playwright's request.resourceType()
1880
+ * @param {string} level - stealth level
1881
+ * @returns {boolean}
1882
+ */
1883
+ static shouldAbortRequest(resourceType, level) {
1884
+ if (level !== 'advanced') return false;
1885
+ if (!['image', 'font', 'stylesheet'].includes(resourceType)) return false;
1886
+ // Allow some images/fonts to maintain realism
1887
+ return Math.random() >= 0.3;
1888
+ }
1889
+
1800
1890
  /**
1801
1891
  * Apply page-level stealth measures
1802
1892
  */
1803
1893
  async applyPageStealthMeasures(page, config, fingerprint) {
1804
- // Enhanced resource blocking with stealth considerations
1894
+ // Resource routing. Nothing is aborted by URL any more: the old list
1895
+ // blocked challenges.cloudflare.com and every URL containing "selenium",
1896
+ // "webdriver" or "puppeteer" — the first meant a Cloudflare challenge
1897
+ // could never complete (the interstitial named the blocked host), the
1898
+ // second killed the top-level navigation to www.selenium.dev on every
1899
+ // stealth path (R15, 2026-09-04). A vendor script that never loads is a
1900
+ // louder tell than one that runs.
1805
1901
  await page.route('**/*', route => {
1806
- const resourceType = route.request().resourceType();
1807
- const url = route.request().url();
1808
-
1809
- // Block known bot detection resources
1810
- const blockedDomains = [
1811
- 'botd.fpjs.io',
1812
- 'challenges.cloudflare.com',
1813
- 'datadome.co',
1814
- 'perimeterx.net',
1815
- 'distilnetworks.com'
1816
- ];
1817
-
1818
- if (blockedDomains.some(domain => url.includes(domain))) {
1819
- route.abort();
1820
- return;
1821
- }
1822
-
1823
- // Don't block detection-related resources that might be expected
1824
- if (url.includes('webdriver') || url.includes('selenium') || url.includes('puppeteer')) {
1902
+ if (StealthBrowserManager.shouldAbortRequest(route.request().resourceType(), config.level)) {
1825
1903
  route.abort();
1826
- return;
1827
- }
1828
-
1829
- // Selective resource blocking based on level
1830
- if (config.level === 'advanced') {
1831
- if (['image', 'font', 'stylesheet'].includes(resourceType)) {
1832
- // Allow some images/fonts to maintain realism
1833
- if (Math.random() < 0.3) {
1834
- route.continue();
1835
- } else {
1836
- route.abort();
1837
- }
1838
- } else {
1839
- route.continue();
1840
- }
1841
1904
  } else {
1842
1905
  route.continue();
1843
1906
  }
@@ -6,7 +6,8 @@
6
6
  import {
7
7
  LANGUAGE_NAMES,
8
8
  cjkScriptCounts as sharedCjkScriptCounts,
9
- detectLanguage as sharedDetectLanguage
9
+ detectLanguage as sharedDetectLanguage,
10
+ needsWordSegmentation
10
11
  } from '../../utils/languageDetection.js';
11
12
  import nlp from 'compromise';
12
13
  import { z } from 'zod';
@@ -168,7 +169,9 @@ export class ContentAnalyzer {
168
169
  * @returns {string[]} - Word tokens
169
170
  */
170
171
  tokenizeWords(text) {
171
- return this.isCjkText(text)
172
+ // CJK and the unspaced South-East Asian scripts (Thai, Lao, Khmer,
173
+ // Burmese) have no word spaces to split on.
174
+ return needsWordSegmentation(text)
172
175
  ? this.segmentWords(text)
173
176
  : text.split(/\s+/).filter(w => w.length > 0);
174
177
  }
@@ -1,4 +1,5 @@
1
1
  import { OpenAIProvider } from './OpenAIProvider.js';
2
+ import { extractionFormat } from '../../utils/extractionFormat.js';
2
3
  import { AnthropicProvider } from './AnthropicProvider.js';
3
4
  import { OllamaProvider } from './OllamaProvider.js';
4
5
  import { Logger } from '../../utils/Logger.js';
@@ -919,7 +920,7 @@ Return the list and nothing else.`;
919
920
  const schemaFields = Object.keys(schema.properties || {}).length;
920
921
  const scaledTokens = Math.min(2000, Math.max(maxTokens, schemaFields * 100 + 500));
921
922
 
922
- const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Do not include any explanation or markdown — only the raw JSON object.`;
923
+ const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Use null for any field the content does not state — never guess, infer, or fill a value from memory. Do not include any explanation or markdown — only the raw JSON object.`;
923
924
 
924
925
  const schemaStr = JSON.stringify(schema, null, 2);
925
926
  const guidance = userPrompt ? `\n\nExtraction guidance: ${userPrompt}` : '';
@@ -937,9 +938,11 @@ Extract the data and return valid JSON:`;
937
938
  systemPrompt,
938
939
  maxTokens: scaledTokens,
939
940
  temperature: 0.1,
940
- // Constrain the output to a parseable object. Small local models
941
- // otherwise wrap the JSON in prose and the parse below throws.
942
- format: 'json'
941
+ // Constrain the output to the caller's shape with every field
942
+ // nullable, so a model shown content that does not state a field can
943
+ // answer null instead of being decoded into an invented string. Small
944
+ // local models otherwise wrap the JSON in prose and the parse throws.
945
+ format: extractionFormat(schema)
943
946
  });
944
947
 
945
948
  // Strip markdown code fences if present
@@ -972,7 +975,9 @@ Extract the data and return valid JSON:`;
972
975
  const required = schema.required || [];
973
976
 
974
977
  for (const field of required) {
975
- if (!(field in data)) {
978
+ // The decoder is told to answer null for a field the content never
979
+ // states, so a null here is "not filled in", the same as absent.
980
+ if (!(field in data) || data[field] === null) {
976
981
  errors.push(`Missing required field: ${field}`);
977
982
  }
978
983
  }
@@ -980,6 +985,10 @@ Extract the data and return valid JSON:`;
980
985
  for (const [key, fieldSchema] of Object.entries(properties)) {
981
986
  if (key in data) {
982
987
  const value = data[key];
988
+ // A null in a field the schema does not require is the honest answer
989
+ // for content that never states it, not a type error (typeof null is
990
+ // 'object', which used to read as "expected number, got object").
991
+ if (value === null) continue;
983
992
  const expectedType = fieldSchema.type;
984
993
  if (expectedType) {
985
994
  const actualType = Array.isArray(value) ? 'array' : typeof value;
@@ -53,8 +53,8 @@ Returns titles, URLs, snippets. Supports `lang`, `site` (domain filter),
53
53
 
54
54
  ## reddit_search (cost: 5)
55
55
 
56
- reddit.com 403-blocks direct scraping, so this queries the Arctic Shift and
57
- PullPush community archives instead (free, no Reddit credentials).
56
+ reddit.com 403-blocks direct scraping, so this reads the Arctic Shift community
57
+ archive instead (free, no Reddit credentials).
58
58
 
59
59
  ```json
60
60
  {
@@ -65,8 +65,10 @@ PullPush community archives instead (free, no Reddit credentials).
65
65
 
66
66
  Modes: `posts` (default), `comments`, and `thread` (a post plus its nested
67
67
  comment tree via `link_id`). Scope with `subreddit`/`author` for near-real-time
68
- results (Arctic Shift); an unscoped `query` searches all of Reddit via PullPush
69
- (best-effort — it rate-limits aggressively). `after`/`before` accept ISO dates,
68
+ results (Arctic Shift); an unscoped `query` searches all of Reddit via web
69
+ discovery — a site-restricted web search finds posts, then they are read from the
70
+ archive by ID (max 10 per call, ordered by relevance, and `after`/`before` do not
71
+ apply on that path). `after`/`before` accept ISO dates,
70
72
  epoch seconds, or offsets like `"7d"`. Scores of posts under ~36h old read 0/1
71
73
  until the archive backfills. Space calls out rather than firing them in
72
74
  parallel — the archives shed load under concurrent bursts.
@@ -618,6 +618,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
618
618
  for (const stateData of capturedStates) {
619
619
  try {
620
620
  const $ = load(stateData.html);
621
+ // Never rendered as text — the same strip the batch worker does (R15).
622
+ $('script, style, noscript, template').remove();
621
623
 
622
624
  const state = {
623
625
  capturePoint: stateData.afterActionIndex + 1,
@@ -688,6 +690,8 @@ export class ScrapeWithActionsTool extends EventEmitter {
688
690
  // over the post-action DOM here, the one place every caller reads.
689
691
  if (chainResult?.finalHtml && (params.extractionOptions?.selectors || params.formats?.includes('text'))) {
690
692
  const $ = load(chainResult.finalHtml);
693
+ // Never rendered as text — the same strip the batch worker does (R15).
694
+ $('script, style, noscript, template').remove();
691
695
  extractResult.content = extractResult.content || {};
692
696
  if (params.extractionOptions?.selectors) {
693
697
  extractResult.content.extracted = this.extractWithSelectors($, params.extractionOptions.selectors);
@@ -120,7 +120,12 @@ function extractStructuredData($, selectors) {
120
120
  return extracted;
121
121
  }
122
122
 
123
- function generateFormats($, html, formats) {
123
+ export function generateFormats($, html, formats) {
124
+ // Script, style, noscript and template bodies are never rendered text.
125
+ // Left in, the text format carried an entire theme-toggle script
126
+ // (erlang.org/downloads) and CSS rules (opennet.ru) — R15, 2026-09-04.
127
+ // `html` below is the raw document and stays untouched.
128
+ $('script, style, noscript, template').remove();
124
129
  const content = {};
125
130
  if (formats.includes('html')) content.html = html;
126
131
  if (formats.includes('text')) content.text = $('body').text().replace(/\s+/g, ' ').trim();
@@ -54,6 +54,37 @@ function mainContentText($, html, url) {
54
54
  return [title, flattenBodyText(load(mainHtml))].filter(Boolean).join('\n');
55
55
  }
56
56
 
57
+ /** Characters of page text the model is shown; about 6k tokens. */
58
+ const SHOWN_TEXT_BUDGET = 24000;
59
+
60
+ /**
61
+ * The text the model reads: main content first, then the whole page when both
62
+ * fit the budget.
63
+ *
64
+ * Main content alone is what keeps the Cloudflare blog's headline from being
65
+ * "Skip to content", but Readability also drops the page chrome, and that is
66
+ * where racket-lang.org announces "Racket version 9.3 is available". A model
67
+ * that never sees the answer fills a required field with the nearest heading,
68
+ * which the provenance guard cannot fault because it carries no digits, and
69
+ * the full-text retry below never fires because nothing was nulled. Showing
70
+ * the page text after the main content puts the banner in view while the
71
+ * article still leads. A page too long for both gets main content alone, up
72
+ * to the budget — the old 6000-character cap cut a 20k-character article to
73
+ * its first third.
74
+ *
75
+ * @param {import('cheerio').CheerioAPI} $
76
+ * @param {string} html
77
+ * @param {string} url
78
+ * @param {string} textContent - whole-page text from fetchAndParse
79
+ * @returns {string}
80
+ */
81
+ function shownText($, html, url, textContent) {
82
+ const main = mainContentText($, html, url);
83
+ if (!main) return textContent;
84
+ if (main.length + textContent.length > SHOWN_TEXT_BUDGET) return main;
85
+ return `${main}\n\n${textContent}`;
86
+ }
87
+
57
88
  /**
58
89
  * Missing, null, blank string or empty array — what "the extraction did not
59
90
  * fill this field in" looks like across every extraction method.
@@ -147,6 +178,9 @@ export class ExtractStructuredTool {
147
178
  tool: 'extract_structured'
148
179
  });
149
180
 
181
+ // What the model reads — see shownText().
182
+ const shown = shownText($, html, url, textContent);
183
+
150
184
  // Step 3: Try LLM extraction first
151
185
  let extractionResult = null;
152
186
  let extractionMethod = 'llm';
@@ -160,9 +194,9 @@ export class ExtractStructuredTool {
160
194
  // local Ollama was never used.
161
195
  llmAvailable = await llm.ready();
162
196
  if (llmAvailable) {
163
- const result = await llm.extractStructured(mainContentText($, html, url) || textContent, schema, {
197
+ const result = await llm.extractStructured(shown, schema, {
164
198
  prompt: prompt || '',
165
- maxContentLength: 6000
199
+ maxContentLength: SHOWN_TEXT_BUDGET
166
200
  });
167
201
  // extractStructured swallows LLM failures and returns its keyword
168
202
  // fallback. Only accept the result as an LLM extraction when it
@@ -246,13 +280,12 @@ export class ExtractStructuredTool {
246
280
  // So when a REQUIRED field is nulled, retry once on the full page text,
247
281
  // which is exactly what the guard will accept. Only a strictly better
248
282
  // result is kept, so a retry can never make the answer worse.
249
- const shownText = mainContentText($, html, url) || textContent;
250
- if (guarded.missingRequired.length > 0 && shownText !== textContent && textContent) {
283
+ if (guarded.missingRequired.length > 0 && shown !== textContent && textContent) {
251
284
  try {
252
285
  const llm = this._ensureLLMManager(llmConfig || {});
253
286
  const retry = await llm.extractStructured(textContent, schema, {
254
287
  prompt: prompt || '',
255
- maxContentLength: 6000
288
+ maxContentLength: SHOWN_TEXT_BUDGET
256
289
  });
257
290
  if (retry?.method === 'llm') {
258
291
  const retryGuarded = applyGuard(retry);
@@ -11,6 +11,7 @@ import { z } from 'zod';
11
11
  import { fetchAndParse } from './_fetchAndParse.js';
12
12
  import { ollamaBaseUrl, ollamaHeaders, selectOllamaModel } from '../../utils/ollamaConfig.js';
13
13
  import { verifyNumericProvenance } from '../../utils/provenance.js';
14
+ import { extractionFormat } from '../../utils/extractionFormat.js';
14
15
  import { fenceUntrusted } from '../../utils/untrustedContent.js';
15
16
  // D1.3: SamplingClient for MCP sampling fallback (lazy — only imported if needed)
16
17
  let _SamplingClient = null;
@@ -164,16 +165,12 @@ function extractBalancedJson(str) {
164
165
  * as an object schema.
165
166
  */
166
167
  function buildInputSchema(schema) {
167
- if (schema && (schema.type === 'object' || schema.properties)) {
168
- return { additionalProperties: true, ...schema, type: 'object' };
169
- }
170
- // Flat hint map → object schema with string-typed properties for any
171
- // non-object hint values (Anthropic requires a valid JSON Schema).
172
- const properties = {};
173
- for (const [key, val] of Object.entries(schema || {})) {
174
- properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
175
- }
176
- return { type: 'object', properties, additionalProperties: true };
168
+ // Every field nullable, no `required`: the decoder must be allowed to say
169
+ // "not stated" — a required string it cannot leave null becomes an
170
+ // invention (R14: `scrape` json on racket-lang.org answered "Racket 5.1.0"
171
+ // for a page that says 9.3). The caller's schema still drives validation.
172
+ const format = extractionFormat(schema);
173
+ return format === 'json' ? { type: 'object', properties: {}, additionalProperties: true } : format;
177
174
  }
178
175
 
179
176
  /**
@@ -189,7 +186,7 @@ function jsonSchemaToZod(schema) {
189
186
  if (!isJsonSchema) {
190
187
  const shape = {};
191
188
  for (const [key, val] of Object.entries(schema)) {
192
- shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).optional();
189
+ shape[key] = jsonSchemaToZod(typeof val === 'string' ? { type: val } : val).nullable().optional();
193
190
  }
194
191
  return z.object(shape).passthrough();
195
192
  }
@@ -206,7 +203,11 @@ function jsonSchemaToZod(schema) {
206
203
  const required = Array.isArray(schema.required) ? schema.required : [];
207
204
  for (const [key, val] of Object.entries(schema.properties || {})) {
208
205
  const field = jsonSchemaToZod(val);
209
- shape[key] = required.includes(key) ? field : field.optional();
206
+ // The model is told to answer null for a field the content never
207
+ // states, so null is the honest answer for a field the schema does
208
+ // not require — not a type violation. A required field stays strict:
209
+ // null there is exactly what the caller needs to hear about.
210
+ shape[key] = required.includes(key) ? field : field.nullable().optional();
210
211
  }
211
212
  return z.object(shape).passthrough();
212
213
  }
@@ -561,7 +562,8 @@ export class ExtractWithLlm {
561
562
  }
562
563
 
563
564
  const systemMessage =
564
- 'You extract structured data from web content per the user\'s instructions. Return JSON only.';
565
+ 'You extract structured data from web content per the user\'s instructions. Return JSON only. ' +
566
+ 'Use null for any field the content does not state — never guess, infer, or fill a value from memory.';
565
567
 
566
568
  const { userMessage, truncated: inputTruncated, original_length } = buildUserMessage(prompt, text, schema);
567
569
 
@@ -281,19 +281,21 @@ export class UnifiedScrapeTool {
281
281
  if (fmt && typeof fmt === 'object' && fmt.type === 'json') {
282
282
  try {
283
283
  const extractWithLlm = await this._getExtractWithLlm();
284
- let text;
285
- if (onlyMainContent) {
286
- text = htmlToMarkdown(getMainHtml());
287
- } else {
288
- // Script and template bodies are never rendered, but $('body').text()
289
- // includes them — on a Shopify storefront that was 179KB of
290
- // JavaScript, more than the page's real text, and it carried the
291
- // very "Sold out" strings the strip had just removed from the DOM.
292
- const { load } = await import('cheerio');
293
- const $visible = load(html);
294
- $visible('script, style, noscript, template').remove();
295
- text = $visible('body').text().replace(/\s+/g, ' ').trim();
296
- }
284
+ // Script and template bodies are never rendered, but $('body').text()
285
+ // includes them — on a Shopify storefront that was 179KB of
286
+ // JavaScript, more than the page's real text, and it carried the
287
+ // very "Sold out" strings the strip had just removed from the DOM.
288
+ const { load } = await import('cheerio');
289
+ const $visible = load(html);
290
+ $visible('script, style, noscript, template').remove();
291
+ const pageText = $visible('body').text().replace(/\s+/g, ' ').trim();
292
+ // Main content first, then the whole page: Readability drops the page
293
+ // chrome, and racket-lang.org states its version there — shown the
294
+ // main content alone, the model answered "Racket" for a version the
295
+ // page gives as 9.3 (R14). The article still leads.
296
+ const text = onlyMainContent
297
+ ? `${htmlToMarkdown(getMainHtml())}\n\n${pageText}`
298
+ : pageText;
297
299
  const result = await extractWithLlm.execute({
298
300
  content: text,
299
301
  prompt: fmt.prompt || 'Extract structured data from this page content.',
@@ -133,7 +133,7 @@ export class RedditOfficialApiAdapter {
133
133
  // The official API cannot honor an arbitrary date range (only coarse `t`
134
134
  // buckets). Reject so `auto` mode falls back to the archives, which can.
135
135
  if (v.after || v.before) {
136
- throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift"/"pullpush"');
136
+ throw new Error('official Reddit API cannot filter by date range — unset after/before, or use source:"arctic_shift" (scoped searches only — date filters need an archive)');
137
137
  }
138
138
  const sr = stripNamePrefix(subreddit);
139
139
  const au = stripNamePrefix(author);
@@ -166,12 +166,12 @@ export class RedditSearchTool {
166
166
  throw new Error('source:"reddit_api" needs Reddit app credentials — set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET (create a "script" app at https://www.reddit.com/prefs/apps)');
167
167
  }
168
168
  if (v.mode === 'comments') {
169
- throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift"/"pullpush", or read a whole thread with mode:"thread"');
169
+ throw new Error('the official Reddit API has no comment full-text search — use comments mode with source:"arctic_shift" (scoped) or "web_discovery" (unscoped), or read a whole thread with mode:"thread"');
170
170
  }
171
171
  order = ['reddit_api'];
172
172
  } else if (v.source === 'arctic_shift') {
173
173
  if (!arcticPossible) {
174
- throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or use source:"pullpush"');
174
+ throw new Error('Arctic Shift cannot keyword-search across all of Reddit — add a subreddit or author scope, or drop source so auto routes the query to web_discovery');
175
175
  }
176
176
  order = ['arctic_shift'];
177
177
  } else if (v.source === 'pullpush') {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * challengeDetection.js — recognise a bot-wall interstitial served as a page.
3
+ *
4
+ * Cloudflare, Amazon, DataDome, PerimeterX and Akamai all answer a blocked
5
+ * request with HTTP 200 and a page of their own: a title, some prose and a
6
+ * challenge script. Reported as a successful scrape, that page hides the
7
+ * block — producthunt.com came back "success:true, title: Just a moment..."
8
+ * for three regression rounds (R10 Q1 → R15, 2026-09-04). A title match is
9
+ * definitive; a script or form marker is definitive only on a short page,
10
+ * because a real page can legitimately embed a Turnstile widget.
11
+ */
12
+
13
+ const SHORT_PAGE_CHARS = 4000;
14
+
15
+ const CHALLENGES = [
16
+ {
17
+ vendor: 'cloudflare',
18
+ title: /^just a moment/i,
19
+ markers: /challenges\.cloudflare\.com|cf-chl-|_cf_chl_opt|cf_chl_rc_|window\._cf_chl/i,
20
+ evidence: 'a Cloudflare challenge script'
21
+ },
22
+ {
23
+ vendor: 'amazon',
24
+ // The robot check is the only Amazon page whose form posts to validateCaptcha.
25
+ definitive: /action="[^"]*validateCaptcha/i,
26
+ evidence: 'the validateCaptcha form'
27
+ },
28
+ {
29
+ vendor: 'datadome',
30
+ markers: /captcha-delivery\.com\/captcha|geo\.captcha-delivery\.com|dd\.captcha/i,
31
+ evidence: 'a DataDome captcha frame'
32
+ },
33
+ {
34
+ vendor: 'perimeterx',
35
+ markers: /px-captcha|_pxCaptcha|human-challenge/i,
36
+ evidence: 'a PerimeterX / HUMAN challenge element'
37
+ },
38
+ {
39
+ vendor: 'akamai',
40
+ title: /^access denied/i,
41
+ markers: /errors\.edgesuite\.net/i,
42
+ evidence: 'an Akamai access-denied page'
43
+ }
44
+ ];
45
+
46
+ /**
47
+ * @param {{ title?: string, html?: string, text?: string }} page
48
+ * @returns {{ vendor: string, evidence: string } | null}
49
+ */
50
+ export function detectChallengePage({ title = '', html = '', text = '' } = {}) {
51
+ const cleanTitle = (title || '').trim();
52
+ const visible = (text || '').replace(/\s+/g, ' ').trim();
53
+ const shortPage = visible.length < SHORT_PAGE_CHARS;
54
+ for (const challenge of CHALLENGES) {
55
+ if (challenge.title && challenge.title.test(cleanTitle)) {
56
+ return { vendor: challenge.vendor, evidence: `title "${cleanTitle}"` };
57
+ }
58
+ if (challenge.definitive && challenge.definitive.test(html)) {
59
+ return { vendor: challenge.vendor, evidence: challenge.evidence };
60
+ }
61
+ if (shortPage && challenge.markers && challenge.markers.test(html)) {
62
+ return { vendor: challenge.vendor, evidence: `${challenge.evidence} on a ${visible.length}-character page` };
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ export default detectChallengePage;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The constrained-decoding shape for an LLM extraction.
3
+ *
4
+ * A caller's JSON Schema says which fields are REQUIRED of the result. Handed
5
+ * to the model verbatim as the output format, that word means something else:
6
+ * the decoder cannot emit null for a required string, so a model shown content
7
+ * that never states the field is forced to invent one. R14 caught it twice on
8
+ * the same day — racket-lang.org's version came back as the page title,
9
+ * rust-lang.org's as the word "stable" — while the same model on the same
10
+ * pages answered correctly the moment the fact was in front of it.
11
+ *
12
+ * So the format the model decodes against makes every property nullable and
13
+ * drops `required`. The caller's schema still drives validation and the
14
+ * provenance retry: a required field the model answered null is what those
15
+ * exist to catch, and a null is honest where a guess is not.
16
+ *
17
+ * @param {object} schema - caller's JSON Schema (or a flat hint map)
18
+ * @returns {object|'json'} an object schema for the decoder, or 'json' when the
19
+ * input carries no properties to constrain
20
+ */
21
+ export function extractionFormat(schema) {
22
+ if (!schema || typeof schema !== 'object') return 'json';
23
+ const source = schema.properties && typeof schema.properties === 'object'
24
+ ? schema
25
+ : { type: 'object', properties: flatHintProperties(schema) };
26
+ if (!source.properties || Object.keys(source.properties).length === 0) return 'json';
27
+
28
+ const properties = {};
29
+ for (const [key, prop] of Object.entries(source.properties)) {
30
+ properties[key] = nullable(prop);
31
+ }
32
+ const { required: _required, ...rest } = source;
33
+ return { additionalProperties: true, ...rest, type: 'object', properties };
34
+ }
35
+
36
+ /** A flat `{ field: 'string' }` hint map as JSON-Schema properties. */
37
+ function flatHintProperties(hints) {
38
+ const properties = {};
39
+ for (const [key, val] of Object.entries(hints)) {
40
+ properties[key] = (val && typeof val === 'object') ? val : { type: 'string' };
41
+ }
42
+ return properties;
43
+ }
44
+
45
+ /** The property with `null` admitted to its type, when it names a single type. */
46
+ function nullable(prop) {
47
+ if (!prop || typeof prop !== 'object' || Array.isArray(prop)) return prop;
48
+ if (typeof prop.type !== 'string' || prop.type === 'null' || prop.type === 'object') return prop;
49
+ return { ...prop, type: [prop.type, 'null'] };
50
+ }
51
+
52
+ export default extractionFormat;
@@ -158,6 +158,26 @@ export function isCjkText(text) {
158
158
  return cjkScriptCounts(text).share >= CJK_SCRIPT_THRESHOLD;
159
159
  }
160
160
 
161
+ // Scripts written without spaces between words. Thai, Lao, Khmer and Burmese
162
+ // need the same dictionary segmentation CJK gets; on the whitespace split a
163
+ // 290-character Thai paragraph counted as 4 words (R15, 2026-09-04).
164
+ const UNSPACED_SCRIPT_RE = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/gu;
165
+
166
+ /**
167
+ * True when the text needs Intl.Segmenter to find word boundaries — a CJK
168
+ * script, or one of the South-East Asian scripts that write no word spaces.
169
+ *
170
+ * @param {string} text
171
+ * @returns {boolean}
172
+ */
173
+ export function needsWordSegmentation(text) {
174
+ if (isCjkText(text)) return true;
175
+ const letters = (text.match(/\p{L}/gu) || []).length;
176
+ if (letters === 0) return false;
177
+ const unspaced = (text.match(UNSPACED_SCRIPT_RE) || []).length;
178
+ return unspaced / letters >= CJK_SCRIPT_THRESHOLD;
179
+ }
180
+
161
181
  /**
162
182
  * Detect the language of a piece of text.
163
183
  *
@@ -228,6 +228,28 @@ function literalReadings(value) {
228
228
  return [...spellings];
229
229
  }
230
230
 
231
+ /** A version with two or more dot-separated numeric segments, inside prose. */
232
+ const EMBEDDED_VERSION = /(?<![\w.])v?\d+(?:\.\d+){2,}(?![\w.])/gi;
233
+
234
+ /**
235
+ * The spellings of every version-shaped token inside a prose string, or null
236
+ * when the value is not prose or carries none. "Racket 5.1.0" on a page that
237
+ * says 9.3 is the case that found this: the whole value is prose, so
238
+ * `literalReadings` left it alone, and the fabricated version rode through.
239
+ *
240
+ * @param {*} value
241
+ * @returns {string[][]|null} one spelling list per token
242
+ */
243
+ function embeddedVersionReadings(value) {
244
+ if (typeof value !== 'string' || !/\s/.test(value.trim())) return null;
245
+ const tokens = value.match(EMBEDDED_VERSION);
246
+ if (!tokens) return null;
247
+ return tokens.map((token) => {
248
+ const lower = token.toLowerCase();
249
+ return [...new Set([lower, lower.replace(VERSION_PREFIX, '')])];
250
+ });
251
+ }
252
+
231
253
  /**
232
254
  * Replace every numeric value that is not present in the source with null.
233
255
  *
@@ -286,8 +308,22 @@ export function verifyNumericProvenance(data, source) {
286
308
  // Not a number, but possibly a version/date/SKU-shaped literal, which is
287
309
  // the other thing a model invents when the page does not say.
288
310
  const literals = literalReadings(node);
289
- if (literals === null) return node;
290
- if (inSource(literals)) {
311
+ if (literals !== null) {
312
+ if (inSource(literals)) {
313
+ verified++;
314
+ return node;
315
+ }
316
+ unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
317
+ return null;
318
+ }
319
+
320
+ // Prose that carries a version-shaped token ("Racket 5.1.0"): the words
321
+ // may be the model's own re-wording, but the token has exactly one
322
+ // spelling and is checked like a bare one. Decimals ("1.5 million") and
323
+ // bare integers stay prose — a model may legitimately re-word those.
324
+ const embedded = embeddedVersionReadings(node);
325
+ if (embedded === null) return node;
326
+ if (embedded.every((spellings) => inSource(spellings))) {
291
327
  verified++;
292
328
  return node;
293
329
  }
@@ -135,6 +135,29 @@ export async function robotsPreflight(url, options = {}) {
135
135
  * warnings: string[], overridden: boolean }>}
136
136
  * @throws {BlockedHostError|RobotsDisallowedError}
137
137
  */
138
+ /**
139
+ * The headers an HTTP page fetch carries: identity, the Web Bot Auth signature
140
+ * when one is configured, and an EMPTY Accept-Language.
141
+ *
142
+ * Node's fetch adds `Accept-Language: *` whenever the caller sets none. Amazon
143
+ * answers this identity with a captcha interstitial when ANY Accept-Language
144
+ * rides along — `*`, en-US, de-DE, a full browser list — and serves the page
145
+ * when the header is absent (R14, bisected header by header with curl, then
146
+ * reproduced with Node's fetch). fetch offers no way to leave the header out,
147
+ * but an empty value is sent as-is instead of `*`, and Amazon treats empty as
148
+ * absent. The identity stays honest; no language preference is claimed.
149
+ * @param {string} [userAgent]
150
+ * @param {Record<string,string>} [signature]
151
+ * @returns {Record<string,string>}
152
+ */
153
+ export function outboundHeaders(userAgent, signature = {}) {
154
+ return {
155
+ ...identityHeaders({ userAgent }),
156
+ 'Accept-Language': '',
157
+ ...signature
158
+ };
159
+ }
160
+
138
161
  export async function preflightFetch(url, options = {}) {
139
162
  const decision = await robotsPreflight(url, options);
140
163
  if (!decision.allowed) {
@@ -153,7 +176,7 @@ export async function preflightFetch(url, options = {}) {
153
176
  const signature = signRequestHeaders(url) || {};
154
177
 
155
178
  return {
156
- headers: { ...identityHeaders({ userAgent: decision.userAgent }), ...signature },
179
+ headers: outboundHeaders(decision.userAgent, signature),
157
180
  userAgent: decision.userAgent,
158
181
  warnings: decision.warnings,
159
182
  overridden: decision.overridden