crawlforge-mcp-server 5.5.9 → 5.6.1

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.5.9",
3
+ "version": "5.6.1",
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.5.3",
117
+ "crawlforge-extractors": "^1.6.1",
118
118
  "diff": "^9.0.0",
119
119
  "dotenv": "^17.2.1",
120
120
  "franc": "^6.2.0",
package/server.js CHANGED
@@ -105,7 +105,7 @@ const taskStore = createTaskStore({ logger });
105
105
  // Create the server
106
106
  const server = new McpServer({
107
107
  name: "crawlforge",
108
- version: "5.5.9",
108
+ version: "5.6.1",
109
109
  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
110
  homepage: "https://www.crawlforge.dev",
111
111
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1574,7 +1574,7 @@ registerToolIfEnabled("localization", {
1574
1574
  break;
1575
1575
  case 'generate_timezone_spoof':
1576
1576
  result = {
1577
- timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode),
1577
+ timezoneScript: await localizationManager.generateTimezoneSpoof(params.countryCode, params.timezone),
1578
1578
  countryCode: params.countryCode || localizationManager.getCurrentSettings().countryCode
1579
1579
  };
1580
1580
  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 =>
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { load } from 'cheerio';
3
+ import { readBody } from 'crawlforge-extractors';
3
4
  import { MapSiteTool } from '../tools/crawl/mapSite.js';
4
5
  import { CrawlDeepTool } from '../tools/crawl/crawlDeep.js';
5
6
  import { normalizeUrl, getBaseUrl } from '../utils/urlNormalizer.js';
@@ -240,7 +241,7 @@ export class LLMsTxtAnalyzer {
240
241
  // Look for API documentation references
241
242
  const mainPageResponse = await this.fetchWithTimeout(baseUrl);
242
243
  if (mainPageResponse.ok) {
243
- const html = await mainPageResponse.text();
244
+ const html = await readBody(mainPageResponse);
244
245
  const $ = load(html);
245
246
 
246
247
  // Find API documentation links
@@ -490,7 +491,7 @@ export class LLMsTxtAnalyzer {
490
491
  const robotsUrl = `${baseUrl}/robots.txt`;
491
492
  const response = await this.fetchWithTimeout(robotsUrl);
492
493
  if (response.ok) {
493
- return await response.text();
494
+ return await readBody(response);
494
495
  }
495
496
  } catch {
496
497
  // No robots.txt found
@@ -677,7 +678,9 @@ export class LLMsTxtAnalyzer {
677
678
  }
678
679
 
679
680
  const contentType = response.headers.get('content-type') || '';
680
- const html = await response.text();
681
+ // Charset-aware read; .text() decodes UTF-8 only and mangled
682
+ // ISO-8859-1 page titles in the generated llms.txt.
683
+ const html = await readBody(response);
681
684
  const $ = load(html);
682
685
 
683
686
  // Check for forms
@@ -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
 
@@ -616,6 +616,14 @@ export class StealthBrowserManager {
616
616
 
617
617
  // Select browser based on distribution and OS compatibility
618
618
  let availableBrowsers = { ...this.browserDistribution };
619
+ // The engine decides the pool. Chromium exposes navigator.vendor
620
+ // "Google Inc.", window.chrome and Chrome's PDF viewer plugins, so a
621
+ // Firefox or Safari User-Agent on it contradicts what the page can see —
622
+ // sannysoft printed a Firefox UA beside "Chrome: present" (R14). Camoufox
623
+ // presents its own Firefox identity and never draws from this pool.
624
+ if (config.engine !== 'camoufox') {
625
+ availableBrowsers = { chrome: 1 };
626
+ }
619
627
  if (selectedOS === 'linux' && availableBrowsers.safari) {
620
628
  delete availableBrowsers.safari;
621
629
  // Redistribute safari's weight
@@ -1005,7 +1013,9 @@ export class StealthBrowserManager {
1005
1013
  processor: selectedProcessor.name,
1006
1014
  architecture: 'x86_64',
1007
1015
  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
1016
+ // navigator.deviceMemory is capped at 8 in every Chromium build, so 16
1017
+ // or 32 is a value no real browser reports (sannysoft CHR_MEMORY: FAIL).
1018
+ deviceMemory: Math.random() < 0.7 ? 8 : 4,
1009
1019
  platform: this.selectRealisticPlatform(selectedOS)
1010
1020
  };
1011
1021
  }
@@ -1215,29 +1225,64 @@ export class StealthBrowserManager {
1215
1225
  })
1216
1226
  });
1217
1227
 
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;
1239
- }
1240
- });
1228
+ // Plugin list. New headless Chromium already reports the same five PDF
1229
+ // viewer entries as a real PluginArray, and replacing them with a plain
1230
+ // Array fails the "plugins is of type PluginArray" check every scanner
1231
+ // runs. Only an empty list — the legacy headless tell — is replaced.
1232
+ if (navigator.plugins.length === 0 && typeof PluginArray !== 'undefined') {
1233
+ // Built on the real prototypes so `instanceof PluginArray`, the
1234
+ // toStringTag and the DOM-style accessors all hold — a plain Array here
1235
+ // failed bot.sannysoft.com's "plugins is of type PluginArray" check.
1236
+ // The five entries are what every desktop Chrome reports.
1237
+ const mimeSpecs = [
1238
+ { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' },
1239
+ { type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' }
1240
+ ];
1241
+ const makePlugin = (name) => {
1242
+ const plugin = Object.create(Plugin.prototype);
1243
+ const mimeTypes = mimeSpecs.map((spec) => {
1244
+ const mimeType = Object.create(MimeType.prototype);
1245
+ Object.defineProperties(mimeType, {
1246
+ type: { value: spec.type },
1247
+ suffixes: { value: spec.suffixes },
1248
+ description: { value: spec.description },
1249
+ enabledPlugin: { get: () => plugin }
1250
+ });
1251
+ return mimeType;
1252
+ });
1253
+ Object.defineProperties(plugin, {
1254
+ name: { value: name },
1255
+ filename: { value: 'internal-pdf-viewer' },
1256
+ description: { value: 'Portable Document Format' },
1257
+ length: { value: mimeTypes.length },
1258
+ item: { value: (index) => mimeTypes[index] || null },
1259
+ namedItem: { value: (type) => mimeTypes.find((m) => m.type === type) || null },
1260
+ [Symbol.iterator]: { value: function* () { yield* mimeTypes; } }
1261
+ });
1262
+ mimeTypes.forEach((mimeType, index) => {
1263
+ Object.defineProperty(plugin, index, { value: mimeType, enumerable: true });
1264
+ Object.defineProperty(plugin, mimeType.type, { value: mimeType });
1265
+ });
1266
+ return plugin;
1267
+ };
1268
+ const plugins = [
1269
+ 'PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer',
1270
+ 'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'
1271
+ ].map(makePlugin);
1272
+ const pluginArray = Object.create(PluginArray.prototype);
1273
+ Object.defineProperties(pluginArray, {
1274
+ length: { value: plugins.length },
1275
+ item: { value: (index) => plugins[index] || null },
1276
+ namedItem: { value: (name) => plugins.find((p) => p.name === name) || null },
1277
+ refresh: { value: () => {} },
1278
+ [Symbol.iterator]: { value: function* () { yield* plugins; } }
1279
+ });
1280
+ plugins.forEach((plugin, index) => {
1281
+ Object.defineProperty(pluginArray, index, { value: plugin, enumerable: true });
1282
+ Object.defineProperty(pluginArray, plugin.name, { value: plugin });
1283
+ });
1284
+ Object.defineProperty(navigator, 'plugins', { get: () => pluginArray });
1285
+ }
1241
1286
 
1242
1287
  // Override languages with the fingerprint's own locale — a hardcoded
1243
1288
  // en-US here contradicts navigator.language and Accept-Language whenever
@@ -1367,14 +1412,23 @@ export class StealthBrowserManager {
1367
1412
  HTMLCanvasElement.prototype.getContext = function(contextType, contextAttributes) {
1368
1413
  const ctx = getContext.call(this, contextType, contextAttributes);
1369
1414
 
1370
- if (contextType === 'webgl' || contextType === 'experimental-webgl') {
1415
+ // Headless Chromium without a GPU path answers getContext('webgl')
1416
+ // with null; wrapping it threw a TypeError where a real browser
1417
+ // returns null, which is itself a tell.
1418
+ if (ctx && (contextType === 'webgl' || contextType === 'experimental-webgl')) {
1371
1419
  const originalGetParameter = ctx.getParameter;
1420
+ // Real Chrome answers VENDOR/RENDERER with "WebKit"/"WebKit WebGL";
1421
+ // the GPU identity lives behind WEBGL_debug_renderer_info as the
1422
+ // UNMASKED_VENDOR_WEBGL (37445) / UNMASKED_RENDERER_WEBGL (37446)
1423
+ // parameters. Those are what a scanner reads, so those are spoofed.
1424
+ const UNMASKED_VENDOR_WEBGL = 37445;
1425
+ const UNMASKED_RENDERER_WEBGL = 37446;
1372
1426
  ctx.getParameter = function(parameter) {
1373
1427
  // Spoof specific WebGL parameters
1374
- if (parameter === ctx.RENDERER) {
1428
+ if (parameter === UNMASKED_RENDERER_WEBGL) {
1375
1429
  return webglConfig.renderer;
1376
1430
  }
1377
- if (parameter === ctx.VENDOR) {
1431
+ if (parameter === UNMASKED_VENDOR_WEBGL) {
1378
1432
  return webglConfig.vendor;
1379
1433
  }
1380
1434
  if (parameter === ctx.VERSION) {
@@ -1387,13 +1441,10 @@ export class StealthBrowserManager {
1387
1441
  return originalGetParameter.call(this, parameter);
1388
1442
  };
1389
1443
 
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
- };
1444
+ // getExtension is left alone: returning null for an extension the
1445
+ // fingerprint's list did not name made WEBGL_debug_renderer_info
1446
+ // vanish, and a page that reads it threw a TypeError where a real
1447
+ // browser reports a GPU (sannysoft "WebGL Vendor: Error", R14).
1397
1448
  }
1398
1449
 
1399
1450
  return ctx;
@@ -1,4 +1,5 @@
1
1
  import { load } from 'cheerio';
2
+ import { readBody } from 'crawlforge-extractors';
2
3
  import { QueueManager } from '../queue/QueueManager.js';
3
4
  import { CacheManager } from '../cache/CacheManager.js';
4
5
  import { RateLimiter } from '../../utils/rateLimiter.js';
@@ -366,7 +367,9 @@ export class BFSCrawler {
366
367
  throw new Error(`Non-HTML content type: ${contentType}`);
367
368
  }
368
369
 
369
- const html = await response.text();
370
+ // readBody decodes with the body's real charset — response.text() is
371
+ // UTF-8-only and mangled ISO-8859-1 pages ("portugu�s" on lua.org).
372
+ const html = await readBody(response);
370
373
  return this.parsePage(html, url);
371
374
  } catch (error) {
372
375
  clearTimeout(timeoutId);
@@ -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.
@@ -12,7 +12,7 @@ to enumerate at runtime. Cost: 1 credit per call.
12
12
  | `youtube-video` | YouTube watch page | title, channel, views, likes, published date, description |
13
13
  | `reddit-thread` | Reddit post (read from the Arctic Shift archive, not reddit.com, which blocks plain fetchers) | id, title, subreddit, author, score, upvote_ratio, num_comments, posted, body, link_url, flair, removed. Pass `id` to `reddit_search` with `mode:"thread"` for the comment tree |
14
14
  | `hacker-news-front-page` | HN front page | ranked stories: title, URL, points, author, comment count |
15
- | `producthunt-launch` | Product Hunt launch | name, tagline, upvotes, maker, description |
15
+ | `producthunt-launch` | Product Hunt product page (`/products/...`; old `/posts/...` links redirect there) | name, tagline, description, topics, website, followers, reviews_count, reviews_rating. The /products layout has no product-level vote count — followers and reviews are its engagement numbers |
16
16
  | `stackoverflow-question` | Stack Overflow question (read from the keyless Stack Exchange API, not the page) | title, body, votes, views, tags, author, asked, answered, accepted_answer_id, answer_count, top 5 answers (accepted first: votes, author, posted, body) |
17
17
  | `npm-package` | npm package page | name, version, description, weekly downloads, license, repo link |
18
18
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { load } from 'cheerio';
8
+ import { readBody } from 'crawlforge-extractors';
8
9
  import { config as appConfig } from '../../../constants/config.js';
9
10
  import { ssrfGuard, isSsrfError } from '../../../utils/ssrfGuard.js';
10
11
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
@@ -36,7 +37,10 @@ export async function fetchUrl(url, options = {}) {
36
37
  if (response.status === 429 || response.status === 503) {
37
38
  noteRetryAfter(url, response.headers?.get?.('retry-after'));
38
39
  }
39
- const html = await readBodyCapped(response, maxBodySize);
40
+ // crawlforge-extractors' readBody: same size cap, but decoded with the
41
+ // body's real charset — the local reader this replaces decoded everything
42
+ // as UTF-8, which turned lua.org's ISO-8859-1 "português" into "portugu�s".
43
+ const html = await readBody(response, { maxBytes: maxBodySize });
40
44
  return { response, html, warnings: gate.warnings };
41
45
  } catch (error) {
42
46
  if (isSsrfError(error)) throw new Error(error.cause?.message || error.message);
@@ -50,47 +54,6 @@ export async function fetchUrl(url, options = {}) {
50
54
  }
51
55
  }
52
56
 
53
- /**
54
- * Read a Response body as text, enforcing maxBodySize (Content-Length
55
- * pre-check, then a running byte count while streaming). Falls back to plain
56
- * .text() for responses without a streamable body (e.g. test mocks).
57
- */
58
- async function readBodyCapped(response, maxBodySize) {
59
- const contentLengthHeader = response.headers?.get?.('content-length') ?? null;
60
- if (contentLengthHeader !== null) {
61
- const declared = parseInt(contentLengthHeader, 10);
62
- if (!isNaN(declared) && declared > maxBodySize) {
63
- throw new Error(`Response body too large: Content-Length ${declared} exceeds limit of ${maxBodySize} bytes`);
64
- }
65
- }
66
-
67
- if (!response.body || typeof response.body.getReader !== 'function') {
68
- return response.text();
69
- }
70
-
71
- const reader = response.body.getReader();
72
- const chunks = [];
73
- let totalBytes = 0;
74
- while (true) {
75
- const { done, value } = await reader.read();
76
- if (done) break;
77
- totalBytes += value.byteLength;
78
- if (totalBytes > maxBodySize) {
79
- reader.cancel();
80
- throw new Error(`Response body too large: exceeded limit of ${maxBodySize} bytes`);
81
- }
82
- chunks.push(value);
83
- }
84
-
85
- const merged = new Uint8Array(totalBytes);
86
- let offset = 0;
87
- for (const chunk of chunks) {
88
- merged.set(chunk, offset);
89
- offset += chunk.byteLength;
90
- }
91
- return new TextDecoder().decode(merged);
92
- }
93
-
94
57
  /**
95
58
  * Scrape a single URL and return a result object.
96
59
  */
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { load } from 'cheerio';
3
+ import { readBody } from 'crawlforge-extractors';
3
4
  import { LLMsTxtAnalyzer } from '../../core/LLMsTxtAnalyzer.js';
4
5
  import { Logger } from '../../utils/Logger.js';
5
6
  import { getBaseUrl } from '../../utils/urlNormalizer.js';
@@ -382,7 +383,8 @@ export class GenerateLLMsTxtTool {
382
383
  headers: { ...gate.headers }
383
384
  });
384
385
  if (!response.ok) return null;
385
- return this.extractHomePageMetadata(await response.text());
386
+ // Charset-aware read; .text() decodes UTF-8 only.
387
+ return this.extractHomePageMetadata(await readBody(response));
386
388
  } catch {
387
389
  return null;
388
390
  } finally {
@@ -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') {
@@ -364,12 +364,15 @@ export class RedditSearchTool {
364
364
  try {
365
365
  data = await this.#get(`${this.arcticBaseUrl}${path}`, query);
366
366
  } catch (error) {
367
- // Arctic Shift sheds a scoped comment keyword search over its whole
368
- // history with the same 422 "Timeout. Maybe slow down a bit" it uses for
369
- // throttling (observed live 2026-08-30: r/webdev body=react failed
370
- // unbounded and at 7d, answered at 3d). When the caller set no window,
371
- // narrow it step by step rather than hand the caller the 422.
372
- const narrowable = v.mode === 'comments' && Boolean(v.query) && !v.after;
367
+ // Arctic Shift sheds a scoped keyword search over its whole history
368
+ // with the same 422 "Timeout. Maybe slow down a bit" it uses for
369
+ // throttling (observed live 2026-08-30: r/webdev comments body=react
370
+ // failed unbounded and at 7d, answered at 3d; 2026-09-01: an
371
+ // r/selfhosted posts search failed unbounded and answered at 7d). When
372
+ // the caller set no window, narrow it step by step rather than hand the
373
+ // caller the 422. Thread mode returned above, so both remaining modes
374
+ // (posts, comments) narrow.
375
+ const narrowable = Boolean(v.query) && !v.after;
373
376
  if (!error.retryable || !narrowable) throw error;
374
377
  let lastError = error;
375
378
  for (const window of ['7d', '3d', '1d']) {
@@ -386,7 +389,7 @@ export class RedditSearchTool {
386
389
  if (!data) {
387
390
  throw new Error(
388
391
  `${lastError.message} — Arctic Shift timed out at every window (full history, 7d, 3d, 1d), ` +
389
- 'so its comment search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
392
+ 'so its search is under load right now. Retry later, pass a narrower after/before, or scope to one post with link_id.'
390
393
  );
391
394
  }
392
395
  notes.push(`Arctic Shift timed out searching the full history; results are limited to the last ${windowApplied}. Pass after/before to choose the window.`);
@@ -3,6 +3,7 @@
3
3
  * URL content fetching and history/stat helper functions.
4
4
  */
5
5
 
6
+ import { readBody } from 'crawlforge-extractors';
6
7
  import { safeFetch } from '../../../utils/ssrfGuard.js';
7
8
  import { preflightFetch } from '../../../utils/robotsGate.js';
8
9
  import { noteRetryAfter } from '../../../utils/hostRateLimiter.js';
@@ -81,7 +82,10 @@ export async function fetchContent(url, options = {}) {
81
82
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
82
83
  }
83
84
 
84
- const content = await response.text();
85
+ // Charset-aware read; .text() decodes UTF-8 only, and mojibake here
86
+ // would both corrupt the reported diff and fake changes on a page whose
87
+ // bytes never moved.
88
+ const content = await readBody(response);
85
89
 
86
90
  return {
87
91
  content,
@@ -96,7 +100,11 @@ export async function fetchContent(url, options = {}) {
96
100
  }
97
101
  };
98
102
  } catch (error) {
99
- throw new Error(`Failed to fetch content: ${error.message}`);
103
+ // undici reports every network failure as bare "fetch failed" and puts
104
+ // the real reason (ENOTFOUND, ECONNREFUSED, timeout) in error.cause —
105
+ // without it, a dead host and a typoed domain read identically.
106
+ const cause = error.cause?.message ? ` (${error.cause.message})` : '';
107
+ throw new Error(`Failed to fetch content: ${error.message}${cause}`);
100
108
  }
101
109
  }
102
110
 
@@ -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;
@@ -112,6 +112,61 @@ function specificity(selector) {
112
112
  return ids * 10000 + classes * 100 + elements;
113
113
  }
114
114
 
115
+ /**
116
+ * Iterate a stylesheet's top-level rules as {selector, body}, skipping any
117
+ * block nested inside a rule's body.
118
+ *
119
+ * The flat regex this replaces — ([^{}]+)\{([^{}]*)\} — had no concept of
120
+ * native CSS nesting, so it read a nested rule as a top-level one under its
121
+ * inner selector. legalblogs.wolterskluwer.com ships
122
+ * .cg-splitpane-aside-container.cg-is-closed .cg-splitpane-aside-header-cstm{
123
+ * ... h6{display:none;} a{display:none;} }
124
+ * — links hidden only in a closed sidebar pane — which the stripper read as a
125
+ * bare a{display:none} and deleted 56 of an article's 68 anchors. Nested rules
126
+ * are skipped rather than resolved: expanding them into full descendant
127
+ * selectors needs a real CSS parser, and skipping errs on keeping content.
128
+ * A rule left unclosed at end of input (stylesheets are size-capped upstream,
129
+ * so truncation mid-rule is real) is dropped for the same reason.
130
+ */
131
+ function* topLevelRules(css) {
132
+ const n = css.length;
133
+ let i = 0;
134
+ let selStart = 0;
135
+ while (i < n) {
136
+ const ch = css[i];
137
+ if (ch === '{') {
138
+ const selector = css.slice(selStart, i);
139
+ let depth = 1;
140
+ let j = i + 1;
141
+ let segStart = j;
142
+ let body = '';
143
+ while (j < n && depth > 0) {
144
+ const c = css[j];
145
+ if (c === '{') {
146
+ if (depth === 1) {
147
+ // The text since the last ';' is the nested rule's selector, not a
148
+ // declaration of this rule — keep only the completed declarations.
149
+ const seg = css.slice(segStart, j);
150
+ body += seg.slice(0, seg.lastIndexOf(';') + 1);
151
+ }
152
+ depth++;
153
+ } else if (c === '}') {
154
+ depth--;
155
+ if (depth === 1) segStart = j + 1;
156
+ else if (depth === 0) body += css.slice(segStart, j);
157
+ }
158
+ j++;
159
+ }
160
+ if (depth === 0) yield { selector, body };
161
+ i = j;
162
+ selStart = j;
163
+ } else {
164
+ if (ch === '}') selStart = i + 1; // stray close — resync
165
+ i++;
166
+ }
167
+ }
168
+ }
169
+
115
170
  /**
116
171
  * Parse CSS into rules that hide content and rules that re-show it, each
117
172
  * carrying the specificity and source order a browser would use to resolve
@@ -125,13 +180,9 @@ export function collectVisibilitySelectors(css) {
125
180
  if (!css) return { hide, show };
126
181
 
127
182
  const flat = stripConditionalBlocks(css);
128
- const rule = /([^{}]+)\{([^{}]*)\}/g;
129
- let m;
130
183
  let order = 0;
131
184
 
132
- while ((m = rule.exec(flat)) !== null) {
133
- const selectorList = m[1];
134
- const body = m[2];
185
+ for (const { selector: selectorList, body } of topLevelRules(flat)) {
135
186
  order++;
136
187
 
137
188
  const display = /(?:^|[;{\s])display\s*:\s*([a-z-]+)/i.exec(body);
@@ -214,12 +214,17 @@ export function detectLanguage(text, options = {}) {
214
214
 
215
215
  const confidence = Math.min(1, 0.5 + (text.length / 500) * 0.5);
216
216
 
217
+ // francAll scores are similarity relative to the winner (whose own score
218
+ // is 1), while `confidence` above is length-based — two different scales.
219
+ // Printed raw, a runner-up could read 0.82 against a winner shown at 0.8.
220
+ // Scaling by the winner's confidence keeps the ranking and the invariant
221
+ // that no alternative outranks the pick.
217
222
  const alternative = francAll(text, { minLength, whitelist: Object.keys(LANGUAGE_NAMES) })
218
223
  .slice(1, 4)
219
224
  .map(([code, score]) => ({
220
225
  code,
221
226
  name: LANGUAGE_NAMES[code] || code,
222
- confidence: Math.round(score * 100) / 100
227
+ confidence: Math.round(score * confidence * 100) / 100
223
228
  }));
224
229
 
225
230
  return {
@@ -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