crawlforge-mcp-server 5.5.0 → 5.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "5.5.0",
3
+ "version": "5.5.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.5.0",
117
+ "crawlforge-extractors": "^1.5.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.0",
108
+ version: "5.5.2",
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",
@@ -7,6 +7,7 @@
7
7
  import { z } from 'zod';
8
8
  import { EventEmitter } from 'events';
9
9
  import { identityHeaders } from '../utils/fetchIdentity.js';
10
+ import { detectLanguage, toIso6391 } from '../utils/languageDetection.js';
10
11
 
11
12
  // ISO 3166-1 alpha-2 country codes with associated settings (Expanded to 15+ countries)
12
13
  const SUPPORTED_COUNTRIES = {
@@ -1372,35 +1373,26 @@ export class LocalizationManager extends EventEmitter {
1372
1373
  * Analyze text for language detection
1373
1374
  */
1374
1375
  async analyzeTextLanguage(text) {
1375
- // Simple heuristic-based language detection
1376
- // In a real implementation, this could use a proper language detection library
1377
- const patterns = {
1378
- 'en': /\b(the|and|is|in|to|of|a|that|it|with|for|as|was|on|are|you)\b/gi,
1379
- 'es': /\b(el|la|de|que|y|en|un|es|se|no|te|lo|le|da|su|por|son)\b/gi,
1380
- 'fr': /\b(le|de|et|à|un|il|être|et|en|avoir|que|pour|dans|ce|son|une)\b/gi,
1381
- 'de': /\b(der|die|und|in|den|von|zu|das|mit|sich|des|auf|für|ist|im)\b/gi,
1382
- 'it': /\b(il|di|che|e|la|per|in|un|è|da|sono|con|non|si|una|su)\b/gi
1383
- };
1384
-
1385
- let bestMatch = null;
1386
- let maxMatches = 0;
1387
- const wordCount = text.split(/\s+/).filter(Boolean).length;
1388
-
1389
- for (const [lang, pattern] of Object.entries(patterns)) {
1390
- const matches = (text.match(pattern) || []).length;
1391
- if (matches > maxMatches) {
1392
- maxMatches = matches;
1393
- // Confidence from stop-word DENSITY (matches per word), not the
1394
- // absolute match count — absolute counts made confidence depend on
1395
- // sample length (a 500-char English sample scored only ~35%).
1396
- // Stop-words are ~25-40% of natural text in the matching language,
1397
- // so density * 250 puts an unambiguous match in the 60-95 range.
1398
- const density = matches / Math.max(1, wordCount);
1399
- bestMatch = { language: lang, confidence: Math.min(95, Math.round(density * 250)) };
1400
- }
1401
- }
1376
+ // Delegates to the server's single detector (src/utils/languageDetection.js).
1377
+ //
1378
+ // This used to be a stop-word regex stub covering five languages, and it
1379
+ // was wrong in both directions: Japanese matched no pattern and returned
1380
+ // null, and French returned 'es' because the `fr` and `es` lists share
1381
+ // "de", "la", "en", "un" and "le" and whichever matched more tokens won.
1382
+ // analyze_content answered the same strings correctly the whole time,
1383
+ // because it used franc — so the bug was really that two detectors existed
1384
+ // and only one got fixed. Anything language-shaped goes through the shared
1385
+ // module now.
1386
+ //
1387
+ // The shared detector speaks ISO 639-3; this surface reports 639-1 (what
1388
+ // html@lang and Accept-Language use), so codes are converted on the way out.
1389
+ const detected = detectLanguage(text);
1390
+ if (!detected) return null;
1402
1391
 
1403
- return bestMatch;
1392
+ return {
1393
+ language: toIso6391(detected.code),
1394
+ confidence: Math.round(detected.confidence * 100)
1395
+ };
1404
1396
  }
1405
1397
 
1406
1398
  /**
@@ -60,6 +60,25 @@ const StealthConfigSchema = z.object({
60
60
  engine: z.enum(['chromium', 'camoufox']).optional().default('chromium')
61
61
  });
62
62
 
63
+ /**
64
+ * True when a page is driven by Chromium, the only engine that speaks CDP.
65
+ *
66
+ * Playwright exposes the engine as `browser.browserType().name()`. A context
67
+ * created over a persistent/connected browser can report a null browser, so an
68
+ * unknown engine is treated as not-Chromium: skipping an optional emulation is
69
+ * cheap, and calling CDP on Firefox throws.
70
+ *
71
+ * @param {import('playwright').Page} page
72
+ * @returns {boolean}
73
+ */
74
+ function isChromium(page) {
75
+ try {
76
+ return page.context().browser()?.browserType().name() === 'chromium';
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
63
82
  export class StealthBrowserManager {
64
83
  constructor(options = {}) {
65
84
  this.browser = null;
@@ -1822,15 +1841,31 @@ export class StealthBrowserManager {
1822
1841
  // Add request headers
1823
1842
  await page.setExtraHTTPHeaders(fingerprint.headers);
1824
1843
 
1825
- // Emulate realistic network conditions
1826
- if (config.level === 'advanced') {
1827
- const client = await page.context().newCDPSession(page);
1828
- await client.send('Network.emulateNetworkConditions', {
1829
- offline: false,
1830
- downloadThroughput: (1.5 + Math.random() * 2) * 1024 * 1024 / 8, // 1.5-3.5 Mbps
1831
- uploadThroughput: (0.75 + Math.random() * 1.25) * 1024 * 1024 / 8, // 0.75-2 Mbps
1832
- latency: 40 + Math.random() * 60 // 40-100ms
1833
- });
1844
+ // Emulate realistic network conditions.
1845
+ //
1846
+ // Network.emulateNetworkConditions is a Chrome DevTools Protocol command,
1847
+ // and CDP is Chromium-only — Playwright throws "CDP session is only
1848
+ // available in Chromium" on Firefox and WebKit, and there is no
1849
+ // cross-browser equivalent. Unguarded, this made `engine: "camoufox"` fail
1850
+ // 100% of the time, because Camoufox is Firefox-based: every advanced-level
1851
+ // camoufox scrape died here before it ever reached the page.
1852
+ //
1853
+ // The emulation is cosmetic realism, not a stealth requirement, so on a
1854
+ // non-Chromium engine it is skipped rather than fatal.
1855
+ if (config.level === 'advanced' && isChromium(page)) {
1856
+ try {
1857
+ const client = await page.context().newCDPSession(page);
1858
+ await client.send('Network.emulateNetworkConditions', {
1859
+ offline: false,
1860
+ downloadThroughput: (1.5 + Math.random() * 2) * 1024 * 1024 / 8, // 1.5-3.5 Mbps
1861
+ uploadThroughput: (0.75 + Math.random() * 1.25) * 1024 * 1024 / 8, // 0.75-2 Mbps
1862
+ latency: 40 + Math.random() * 60 // 40-100ms
1863
+ });
1864
+ } catch (error) {
1865
+ // A browser build that reports chromium but refuses CDP must not take
1866
+ // the scrape down with it.
1867
+ console.warn(`Network condition emulation skipped: ${error.message}`);
1868
+ }
1834
1869
  }
1835
1870
 
1836
1871
  // Set up human behavior if enabled
@@ -3,7 +3,11 @@
3
3
  * Uses multiple NLP libraries for comprehensive content analysis
4
4
  */
5
5
 
6
- import { franc, francAll } from 'franc';
6
+ import {
7
+ LANGUAGE_NAMES,
8
+ cjkScriptCounts as sharedCjkScriptCounts,
9
+ detectLanguage as sharedDetectLanguage
10
+ } from '../../utils/languageDetection.js';
7
11
  import nlp from 'compromise';
8
12
  import { z } from 'zod';
9
13
  import { splitSentences } from './sentenceUtils.js';
@@ -98,86 +102,6 @@ const AnalysisResult = z.object({
98
102
  });
99
103
 
100
104
  // Language code to name mapping
101
- const LANGUAGE_NAMES = {
102
- 'eng': 'English',
103
- 'spa': 'Spanish',
104
- 'fra': 'French',
105
- 'deu': 'German',
106
- 'ita': 'Italian',
107
- 'por': 'Portuguese',
108
- 'rus': 'Russian',
109
- 'jpn': 'Japanese',
110
- 'kor': 'Korean',
111
- 'cmn': 'Chinese',
112
- 'arb': 'Arabic',
113
- 'hin': 'Hindi',
114
- 'nld': 'Dutch',
115
- 'swe': 'Swedish',
116
- 'nob': 'Norwegian',
117
- 'dan': 'Danish',
118
- 'fin': 'Finnish',
119
- 'pol': 'Polish',
120
- 'ces': 'Czech',
121
- 'hun': 'Hungarian',
122
- 'tur': 'Turkish',
123
- 'ell': 'Greek',
124
- 'heb': 'Hebrew',
125
- 'tha': 'Thai',
126
- 'vie': 'Vietnamese',
127
- 'ind': 'Indonesian',
128
- 'zlm': 'Malay',
129
- 'zsm': 'Malay',
130
- 'tgl': 'Tagalog',
131
- 'ukr': 'Ukrainian',
132
- 'bul': 'Bulgarian',
133
- 'hrv': 'Croatian',
134
- 'slv': 'Slovenian',
135
- 'ron': 'Romanian',
136
- 'lit': 'Lithuanian',
137
- 'lav': 'Latvian',
138
- 'est': 'Estonian',
139
- 'slk': 'Slovak',
140
- 'cat': 'Catalan',
141
- 'eus': 'Basque',
142
- 'glg': 'Galician',
143
- 'gle': 'Irish',
144
- 'cym': 'Welsh',
145
- 'isl': 'Icelandic',
146
- 'mlt': 'Maltese',
147
- 'sqi': 'Albanian',
148
- 'mkd': 'Macedonian',
149
- 'srp': 'Serbian',
150
- 'bos': 'Bosnian',
151
- 'mon': 'Mongolian',
152
- 'uzb': 'Uzbek',
153
- 'kaz': 'Kazakh',
154
- 'aze': 'Azerbaijani',
155
- 'geo': 'Georgian',
156
- 'arm': 'Armenian',
157
- 'fas': 'Persian',
158
- 'urd': 'Urdu',
159
- 'ben': 'Bengali',
160
- 'tam': 'Tamil',
161
- 'tel': 'Telugu',
162
- 'kan': 'Kannada',
163
- 'mal': 'Malayalam',
164
- 'guj': 'Gujarati',
165
- 'pan': 'Punjabi',
166
- 'ori': 'Odia',
167
- 'mar': 'Marathi',
168
- 'nep': 'Nepali',
169
- 'sin': 'Sinhala',
170
- 'mya': 'Burmese',
171
- 'khm': 'Khmer',
172
- 'lao': 'Lao',
173
- 'amh': 'Amharic',
174
- 'som': 'Somali',
175
- 'swa': 'Swahili',
176
- 'hau': 'Hausa',
177
- 'yor': 'Yoruba',
178
- 'ibo': 'Igbo',
179
- 'afr': 'Afrikaans'
180
- };
181
105
 
182
106
  export class ContentAnalyzer {
183
107
  constructor() {
@@ -205,17 +129,7 @@ export class ContentAnalyzer {
205
129
  * @returns {Object} - { letters, han, kana, hangul, share }
206
130
  */
207
131
  cjkScriptCounts(text) {
208
- const letters = (text.match(/\p{L}/gu) || []).length;
209
- const han = (text.match(/\p{Script=Han}/gu) || []).length;
210
- const kana = (text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
211
- const hangul = (text.match(/\p{Script=Hangul}/gu) || []).length;
212
- return {
213
- letters,
214
- han,
215
- kana,
216
- hangul,
217
- share: letters > 0 ? (han + kana + hangul) / letters : 0
218
- };
132
+ return sharedCjkScriptCounts(text);
219
133
  }
220
134
 
221
135
  /**
@@ -346,80 +260,12 @@ export class ContentAnalyzer {
346
260
  * @returns {Promise<Object>} - Language detection result
347
261
  */
348
262
  async detectLanguage(text, options = {}) {
349
- try {
350
- // franc scores the single most common script, so a Chinese, Japanese or
351
- // Korean page carrying the usual run of English product names and code
352
- // samples is detected as English. Those scripts never appear in
353
- // Latin-script prose, so a meaningful share of them settles the question
354
- // before trigram scoring gets a say.
355
- const { letters, han, kana, hangul, share } = this.cjkScriptCounts(text);
356
- if (letters > 0) {
357
- if (share >= 0.1) {
358
- const code = kana > 0 ? 'jpn' : hangul > han ? 'kor' : 'cmn';
359
- return {
360
- code,
361
- name: LANGUAGE_NAMES[code],
362
- confidence: 0.9,
363
- alternative: [],
364
- detectionMethod: 'script'
365
- };
366
- }
367
- }
368
-
369
- // Use franc for language detection
370
- const detected = franc(text, {
371
- minLength: 10,
372
- whitelist: Object.keys(LANGUAGE_NAMES)
373
- });
374
-
375
- if (detected === 'und') {
376
- // Fallback: check if text is predominantly ASCII Latin characters (likely English)
377
- const latinChars = (text.match(/[a-zA-Z]/g) || []).length;
378
- const totalChars = text.replace(/\s/g, '').length;
379
- if (totalChars > 0 && latinChars / totalChars > 0.7) {
380
- // Check for common English words as a heuristic
381
- const lower = text.toLowerCase();
382
- const englishMarkers = ['the ', 'is ', 'are ', 'was ', 'and ', 'for ', 'that ', 'with ', 'this ', 'from '];
383
- const matchCount = englishMarkers.filter(w => lower.includes(w)).length;
384
- if (matchCount >= 2) {
385
- return {
386
- code: 'eng',
387
- name: 'English',
388
- confidence: 0.6,
389
- alternative: [],
390
- detectionMethod: 'heuristic'
391
- };
392
- }
393
- }
394
- return null; // Truly undetermined language
395
- }
396
-
397
- // Get confidence score based on text length and detection certainty
398
- const confidence = Math.min(1, 0.5 + (text.length / 500) * 0.5);
399
-
400
- // Get alternative languages using franc.all
401
- const alternatives = francAll(text, {
402
- minLength: 10,
403
- whitelist: Object.keys(LANGUAGE_NAMES)
404
- })
405
- .slice(1, 4) // Top 3 alternatives
406
- .map(([code, score]) => ({
407
- code,
408
- name: LANGUAGE_NAMES[code] || code,
409
- confidence: Math.round(score * 100) / 100
410
- }));
411
-
412
- return {
413
- code: detected,
414
- name: LANGUAGE_NAMES[detected] || detected,
415
- confidence: Math.round(confidence * 100) / 100,
416
- alternative: alternatives
417
- };
418
-
419
- } catch (error) {
420
- console.warn('Language detection failed:', error.message);
421
- return null;
422
- }
263
+ // The detector itself lives in src/utils/languageDetection.js so that this
264
+ // class and LocalizationManager cannot drift apart again — they did, and
265
+ // the localization surface spent that time reporting Japanese as null and
266
+ // French as Spanish. Behaviour here is unchanged: same franc call, same CJK
267
+ // script pre-check, same result shape.
268
+ return sharedDetectLanguage(text, options);
423
269
  }
424
270
 
425
271
  /**
@@ -190,33 +190,76 @@ export class ExtractStructuredTool {
190
190
  // the Apple MacBook Air page Readability keeps the FAQ block and every
191
191
  // price is left behind in an embedded JSON blob, so checking against what
192
192
  // the model was shown would null every correct price.
193
- let provenance = { enabled: false };
194
- if (extractionResult && extractionMethod === 'llm' && verify_numbers) {
195
- const checked = verifyNumericProvenance(extractionResult.data || {}, `${html}\n${textContent}`);
193
+ const fullSource = `${html}\n${textContent}`;
194
+
195
+ /** Run the guard over one extraction and describe what it did. */
196
+ const applyGuard = (result) => {
197
+ const checked = verifyNumericProvenance(result.data || {}, fullSource);
196
198
  // The model's own `valid` flag described the data before the guard ran.
197
199
  // A required field the guard nulled is not filled in any more, so that
198
200
  // flag cannot stand or the response reports a fabrication as valid.
199
201
  const nulledRequired = checked.unverified
200
202
  .map((entry) => entry.path)
201
203
  .filter((path) => (schema.required || []).includes(path));
202
- extractionResult = {
203
- ...extractionResult,
204
- data: checked.data,
205
- ...(nulledRequired.length > 0 ? {
206
- valid: false,
207
- validationErrors: [
208
- ...(extractionResult.validationErrors || []),
209
- ...nulledRequired.map((field) => `Field "${field}" was not found in the page source`)
210
- ]
211
- } : {})
204
+ return {
205
+ checked,
206
+ nulledRequired,
207
+ result: {
208
+ ...result,
209
+ data: checked.data,
210
+ ...(nulledRequired.length > 0 ? {
211
+ valid: false,
212
+ validationErrors: [
213
+ ...(result.validationErrors || []),
214
+ ...nulledRequired.map((field) => `Field "${field}" was not found in the page source`)
215
+ ]
216
+ } : {})
217
+ }
212
218
  };
219
+ };
220
+
221
+ let provenance = { enabled: false };
222
+ if (extractionResult && extractionMethod === 'llm' && verify_numbers) {
223
+ let guarded = applyGuard(extractionResult);
224
+
225
+ // Step 3c: the model is shown main content, but the guard checks the
226
+ // FULL source — so a value can be nulled for being absent from what the
227
+ // model saw while sitting in the page all along. sqlite.org is the case
228
+ // that found this: Readability drops the "Version 3.53.4" line, the
229
+ // model was handed no version at all and answered "3.34.0" from memory,
230
+ // and the guard correctly nulled it — leaving a caller with nothing on a
231
+ // page that plainly states the answer.
232
+ //
233
+ // So when a REQUIRED field is nulled, retry once on the full page text,
234
+ // which is exactly what the guard will accept. Only a strictly better
235
+ // result is kept, so a retry can never make the answer worse.
236
+ const shownText = mainContentText($, html, url) || textContent;
237
+ if (guarded.nulledRequired.length > 0 && shownText !== textContent && textContent) {
238
+ try {
239
+ const llm = this._ensureLLMManager(llmConfig || {});
240
+ const retry = await llm.extractStructured(textContent, schema, {
241
+ prompt: prompt || '',
242
+ maxContentLength: 6000
243
+ });
244
+ if (retry?.method === 'llm') {
245
+ const retryGuarded = applyGuard(retry);
246
+ if (retryGuarded.nulledRequired.length < guarded.nulledRequired.length) {
247
+ guarded = retryGuarded;
248
+ }
249
+ }
250
+ } catch {
251
+ // The first answer already stands; a failed retry changes nothing.
252
+ }
253
+ }
254
+
255
+ extractionResult = guarded.result;
213
256
  provenance = {
214
257
  enabled: true,
215
- verified: checked.verified,
216
- nulled: checked.nulled,
217
- unverified: checked.unverified
258
+ verified: guarded.checked.verified,
259
+ nulled: guarded.checked.nulled,
260
+ unverified: guarded.checked.unverified
218
261
  };
219
- if (checked.skipped) provenance.skipped = checked.skipped;
262
+ if (guarded.checked.skipped) provenance.skipped = guarded.checked.skipped;
220
263
  }
221
264
 
222
265
  // Step 4: CSS selector fallback if LLM unavailable or failed
@@ -1,14 +1,39 @@
1
1
  /**
2
2
  * _mainContent.js — Readability main-content extraction, plus recovery of the
3
- * data tables Readability drops.
3
+ * content Readability drops.
4
4
  *
5
- * Readability keeps one article candidate and discards everything outside it.
6
- * On a table-led page that silently loses the payload: Wikipedia's *List of
7
- * S&P 500 companies* came back with zero table rows at scrape's default
8
- * onlyMainContent:true, and 505 pipe-table lines with it off. No Readability
9
- * option recovers them (charThreshold:100 and nbTopCandidates:20 were both
10
- * probed) — the tables are simply not in the candidate — so they are
11
- * re-attached afterwards.
5
+ * Two independent losses are repaired here.
6
+ *
7
+ * 1. Readability keeps one article candidate and discards everything outside
8
+ * it. On a table-led page that silently loses the payload: Wikipedia's
9
+ * *List of S&P 500 companies* came back with zero table rows at scrape's
10
+ * default onlyMainContent:true, and 505 pipe-table lines with it off. No
11
+ * Readability option recovers them (charThreshold:100 and nbTopCandidates:20
12
+ * were both probed) — the tables are simply not in the candidate — so they
13
+ * are re-attached afterwards.
14
+ *
15
+ * 2. Readability's class regexes are unanchored, so they fire on words their
16
+ * author never meant. Two collisions between them deleted every code
17
+ * example on next-intl.dev/docs/routing/setup:
18
+ *
19
+ * - `unlikelyCandidates` contains `extra`, which matches inside `nextra-*`
20
+ * — the class prefix on every element Nextra emits, and Nextra builds a
21
+ * large share of the JS ecosystem's documentation. None of those classes
22
+ * match `okMaybeItsACandidate`, so the elements are deleted before
23
+ * scoring runs: all 96 inline `<code>` spans and the "setRequestLocale
24
+ * is legacy" callout went with them.
25
+ * - `negative` contains `hidden`, which matches Tailwind's
26
+ * `overflow-hidden`. That is only -25 class weight, but it is enough for
27
+ * `_cleanConditionally` to drop the wrapper around all 12 `<pre>` blocks.
28
+ *
29
+ * Both losses were silent, and the second is the dangerous one: the prose
30
+ * survived, so the page read as complete while every code example and the
31
+ * warning callout were gone, and sentences came back with holes where their
32
+ * inline code had been ("In order to use unique pathnames …, can be used to
33
+ * handle"). `sanitizeAccidentalClasses` strips just those class tokens
34
+ * before Readability sees the document, restoring 11 of the 12 blocks — the
35
+ * 12th sits inside a collapsed `<details>`, which Readability drops whole
36
+ * and which this deliberately does not try to rescue.
12
37
  *
13
38
  * Shared by unifiedScrape (wants HTML) and extractStructured (wants text).
14
39
  */
@@ -20,10 +45,108 @@ import { Readability } from '@mozilla/readability';
20
45
  // Readability already kept it. Long enough to be unique to that table.
21
46
  const SIGNATURE_LENGTH = 120;
22
47
 
48
+ // Copied verbatim from @mozilla/readability's REGEXPS. Two of them delete
49
+ // content, and both are unanchored: `unlikelyCandidates` removes an element
50
+ // outright, `negative` docks 25 from its class weight, which is enough for
51
+ // `_cleanConditionally` to drop it. The unit test asserts the real library
52
+ // still behaves this way, so a Readability upgrade that fixes it upstream
53
+ // fails loudly here rather than leaving a no-op pass behind.
54
+ const UNLIKELY_SOURCE =
55
+ '-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote';
56
+ const NEGATIVE_SOURCE =
57
+ '-ad-|hidden|^hid$| hid$| hid |^hidden|banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget';
58
+ const REMOVAL_SOURCES = [UNLIKELY_SOURCE, NEGATIVE_SOURCE];
59
+ // Readability's `positive` regex. A token matching it carries a signal worth
60
+ // keeping, so it is never stripped even if it also matches mid-word.
61
+ const POSITIVE_SIGNAL =
62
+ /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i;
63
+
64
+ // Utility classes that collide with the `negative` regex on a whole word while
65
+ // meaning something else entirely. Tailwind's `overflow-hidden` clips overflow;
66
+ // it does not mark the element hidden. The mid-word rule below cannot catch
67
+ // these — `hidden` really is a whole segment of `overflow-hidden` — and the
68
+ // cost is real: -25 class weight is enough for `_cleanConditionally` to delete
69
+ // the element, which is what removed every code block on next-intl.dev after
70
+ // the `nextra-` collision had already been handled. Listed explicitly rather
71
+ // than inferred, because `page-footer` and `main-sidebar` are the same shape
72
+ // and *are* genuine.
73
+ const UTILITY_FALSE_POSITIVES = new Set([
74
+ 'overflow-hidden',
75
+ 'overflow-x-hidden',
76
+ 'overflow-y-hidden',
77
+ 'overflow-scroll',
78
+ 'overflow-x-scroll',
79
+ 'overflow-y-scroll',
80
+ 'scroll-smooth',
81
+ 'scroll-auto'
82
+ ]);
83
+
23
84
  function normalizeWhitespace(text) {
24
85
  return text.replace(/\s+/g, ' ').trim();
25
86
  }
26
87
 
88
+ /**
89
+ * True when `token` only ever trips Readability's removal regexes inside a
90
+ * longer word — `extra` within `nextra`, `hidden` within Tailwind's
91
+ * `overflow-hidden`.
92
+ *
93
+ * Only a *preceding* letter or digit counts as mid-word. A trailing one does
94
+ * not, because that is how English suffixes attach: rescuing `comments`,
95
+ * `banners` or `footers` would defeat the regexes on exactly the clutter they
96
+ * were written to catch, whereas a letter run in front of the match means a
97
+ * different word entirely.
98
+ *
99
+ * @param {string} token - one class name
100
+ * @returns {boolean}
101
+ */
102
+ function isAccidentalToken(token) {
103
+ if (UTILITY_FALSE_POSITIVES.has(token.toLowerCase())) return true;
104
+ if (POSITIVE_SIGNAL.test(token)) return false;
105
+ let sawMatch = false;
106
+ for (const source of REMOVAL_SOURCES) {
107
+ const scan = new RegExp(source, 'gi');
108
+ let match;
109
+ while ((match = scan.exec(token)) !== null) {
110
+ if (match[0].length === 0) {
111
+ scan.lastIndex += 1;
112
+ continue;
113
+ }
114
+ sawMatch = true;
115
+ const before = token[match.index - 1];
116
+ if (before === undefined || !/[a-z0-9]/i.test(before)) return false;
117
+ }
118
+ }
119
+ return sawMatch;
120
+ }
121
+
122
+ /**
123
+ * Strip the class tokens that would make Readability discard an element over a
124
+ * word the class does not actually contain.
125
+ *
126
+ * Only whole tokens that are accidental on every match are removed, so a class
127
+ * carrying a real signal survives: an element still genuinely marked `sidebar`
128
+ * or `hidden` is still dropped, as it should be. Ids are left alone — they
129
+ * anchor in-page links, and the class attribute is what these frameworks put
130
+ * their names on.
131
+ *
132
+ * @param {Document} document - mutated in place
133
+ * @returns {number} how many elements were sanitized
134
+ */
135
+ export function sanitizeAccidentalClasses(document) {
136
+ let sanitized = 0;
137
+ for (const el of Array.from(document.querySelectorAll('[class]'))) {
138
+ const className = el.getAttribute('class') || '';
139
+ const tokens = className.split(/\s+/).filter(Boolean);
140
+ const kept = tokens.filter((token) => !isAccidentalToken(token));
141
+ if (kept.length === tokens.length) continue;
142
+
143
+ if (kept.length > 0) el.setAttribute('class', kept.join(' '));
144
+ else el.removeAttribute('class');
145
+ sanitized += 1;
146
+ }
147
+ return sanitized;
148
+ }
149
+
27
150
  /**
28
151
  * Readability's own data-table test (`_markDataTables`): 10+ rows or more than
29
152
  * 4 columns means the table carries data rather than layout, counting
@@ -61,6 +184,7 @@ export function extractMainContent(html, url) {
61
184
  let article;
62
185
  try {
63
186
  const dom = new JSDOM(html, { url });
187
+ sanitizeAccidentalClasses(dom.window.document);
64
188
  article = new Readability(dom.window.document).parse();
65
189
  } catch {
66
190
  return { html: null, title: '', tablesRecovered: 0 };
@@ -0,0 +1,249 @@
1
+ /**
2
+ * languageDetection -- the server's single language detector.
3
+ *
4
+ * There used to be two. `ContentAnalyzer` detected with franc plus a CJK script
5
+ * pre-check; `LocalizationManager.analyzeTextLanguage` was a stub whose own
6
+ * comment said "In a real implementation, this could use a proper language
7
+ * detection library" and which matched stop-word regexes for exactly five
8
+ * languages. It reported Japanese as `null` (no pattern covers the script) and
9
+ * French as `es` (both the `fr` and `es` lists match "de", "la", "en", "un",
10
+ * "le", so whichever happened to match more tokens won), while analyze_content
11
+ * answered the same three strings correctly.
12
+ *
13
+ * That split is the actual bug: the 2026-08-26 franc + CJK fix was applied to
14
+ * ContentAnalyzer and never reached the localization path, so the same class of
15
+ * failure shipped twice. Both callers now come through this module, so a fix
16
+ * here reaches every surface and there is no second detector to drift.
17
+ *
18
+ * Codes are ISO 639-3 (what franc emits). `toIso6391()` converts for callers
19
+ * that speak 639-1, which is what Accept-Language and html@lang use.
20
+ */
21
+
22
+ import { franc, francAll } from 'franc';
23
+
24
+ /** Languages the detector will consider, ISO 639-3 -> English name. */
25
+ export const LANGUAGE_NAMES = {
26
+ 'eng': 'English',
27
+ 'spa': 'Spanish',
28
+ 'fra': 'French',
29
+ 'deu': 'German',
30
+ 'ita': 'Italian',
31
+ 'por': 'Portuguese',
32
+ 'rus': 'Russian',
33
+ 'jpn': 'Japanese',
34
+ 'kor': 'Korean',
35
+ 'cmn': 'Chinese',
36
+ 'arb': 'Arabic',
37
+ 'hin': 'Hindi',
38
+ 'nld': 'Dutch',
39
+ 'swe': 'Swedish',
40
+ 'nob': 'Norwegian',
41
+ 'dan': 'Danish',
42
+ 'fin': 'Finnish',
43
+ 'pol': 'Polish',
44
+ 'ces': 'Czech',
45
+ 'hun': 'Hungarian',
46
+ 'tur': 'Turkish',
47
+ 'ell': 'Greek',
48
+ 'heb': 'Hebrew',
49
+ 'tha': 'Thai',
50
+ 'vie': 'Vietnamese',
51
+ 'ind': 'Indonesian',
52
+ 'zlm': 'Malay',
53
+ 'zsm': 'Malay',
54
+ 'tgl': 'Tagalog',
55
+ 'ukr': 'Ukrainian',
56
+ 'bul': 'Bulgarian',
57
+ 'hrv': 'Croatian',
58
+ 'slv': 'Slovenian',
59
+ 'ron': 'Romanian',
60
+ 'lit': 'Lithuanian',
61
+ 'lav': 'Latvian',
62
+ 'est': 'Estonian',
63
+ 'slk': 'Slovak',
64
+ 'cat': 'Catalan',
65
+ 'eus': 'Basque',
66
+ 'glg': 'Galician',
67
+ 'gle': 'Irish',
68
+ 'cym': 'Welsh',
69
+ 'isl': 'Icelandic',
70
+ 'mlt': 'Maltese',
71
+ 'sqi': 'Albanian',
72
+ 'mkd': 'Macedonian',
73
+ 'srp': 'Serbian',
74
+ 'bos': 'Bosnian',
75
+ 'mon': 'Mongolian',
76
+ 'uzb': 'Uzbek',
77
+ 'kaz': 'Kazakh',
78
+ 'aze': 'Azerbaijani',
79
+ 'geo': 'Georgian',
80
+ 'arm': 'Armenian',
81
+ 'fas': 'Persian',
82
+ 'urd': 'Urdu',
83
+ 'ben': 'Bengali',
84
+ 'tam': 'Tamil',
85
+ 'tel': 'Telugu',
86
+ 'kan': 'Kannada',
87
+ 'mal': 'Malayalam',
88
+ 'guj': 'Gujarati',
89
+ 'pan': 'Punjabi',
90
+ 'ori': 'Odia',
91
+ 'mar': 'Marathi',
92
+ 'nep': 'Nepali',
93
+ 'sin': 'Sinhala',
94
+ 'mya': 'Burmese',
95
+ 'khm': 'Khmer',
96
+ 'lao': 'Lao',
97
+ 'amh': 'Amharic',
98
+ 'som': 'Somali',
99
+ 'swa': 'Swahili',
100
+ 'hau': 'Hausa',
101
+ 'yor': 'Yoruba',
102
+ 'ibo': 'Igbo',
103
+ 'afr': 'Afrikaans'
104
+ };
105
+
106
+ /**
107
+ * ISO 639-3 -> ISO 639-1. Only codes with a 639-1 equivalent appear; the rest
108
+ * have none, and callers keep the 639-3 code rather than invent one.
109
+ */
110
+ export const ISO_639_3_TO_1 = {
111
+ eng: 'en', spa: 'es', fra: 'fr', deu: 'de', ita: 'it', por: 'pt', rus: 'ru',
112
+ jpn: 'ja', kor: 'ko', cmn: 'zh', arb: 'ar', hin: 'hi', nld: 'nl', swe: 'sv',
113
+ nob: 'nb', dan: 'da', fin: 'fi', pol: 'pl', ces: 'cs', hun: 'hu', tur: 'tr',
114
+ ell: 'el', heb: 'he', tha: 'th', vie: 'vi', ind: 'id', zlm: 'ms', zsm: 'ms',
115
+ tgl: 'tl', ukr: 'uk', bul: 'bg', hrv: 'hr', slv: 'sl', ron: 'ro', lit: 'lt',
116
+ lav: 'lv', est: 'et', slk: 'sk', cat: 'ca', eus: 'eu', glg: 'gl', gle: 'ga',
117
+ cym: 'cy', isl: 'is', mlt: 'mt', sqi: 'sq', mkd: 'mk', srp: 'sr', bos: 'bs',
118
+ mon: 'mn', uzb: 'uz', kaz: 'kk', aze: 'az', geo: 'ka', arm: 'hy', fas: 'fa',
119
+ urd: 'ur', ben: 'bn', tam: 'ta', tel: 'te', kan: 'kn', mal: 'ml', guj: 'gu',
120
+ pan: 'pa', ori: 'or', mar: 'mr', nep: 'ne', sin: 'si', mya: 'my', khm: 'km',
121
+ lao: 'lo', amh: 'am', som: 'so', swa: 'sw', hau: 'ha', yor: 'yo', ibo: 'ig',
122
+ afr: 'af'
123
+ };
124
+
125
+ /** The CJK share at or above which script alone settles the language. */
126
+ const CJK_SCRIPT_THRESHOLD = 0.1;
127
+
128
+ /**
129
+ * Count CJK-script letters and their share of all letters. This is the script
130
+ * signal detection uses; the ContentAnalyzer tokenizer reuses it to decide when
131
+ * whitespace splitting cannot work.
132
+ *
133
+ * @param {string} text
134
+ * @returns {{letters: number, han: number, kana: number, hangul: number, share: number}}
135
+ */
136
+ export function cjkScriptCounts(text) {
137
+ const letters = (text.match(/\p{L}/gu) || []).length;
138
+ const han = (text.match(/\p{Script=Han}/gu) || []).length;
139
+ const kana = (text.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu) || []).length;
140
+ const hangul = (text.match(/\p{Script=Hangul}/gu) || []).length;
141
+ return {
142
+ letters,
143
+ han,
144
+ kana,
145
+ hangul,
146
+ share: letters > 0 ? (han + kana + hangul) / letters : 0
147
+ };
148
+ }
149
+
150
+ /**
151
+ * True when a meaningful share of the text is in a CJK script, i.e. words are
152
+ * not whitespace-delimited and must be segmented by dictionary.
153
+ *
154
+ * @param {string} text
155
+ * @returns {boolean}
156
+ */
157
+ export function isCjkText(text) {
158
+ return cjkScriptCounts(text).share >= CJK_SCRIPT_THRESHOLD;
159
+ }
160
+
161
+ /**
162
+ * Detect the language of a piece of text.
163
+ *
164
+ * @param {string} text
165
+ * @param {{minLength?: number}} [options]
166
+ * @returns {{code: string, name: string, confidence: number,
167
+ * alternative: Array<{code: string, name: string, confidence: number}>,
168
+ * detectionMethod?: string} | null} null when undetermined
169
+ */
170
+ export function detectLanguage(text, options = {}) {
171
+ if (typeof text !== 'string' || text.trim() === '') return null;
172
+ const minLength = options.minLength ?? 10;
173
+
174
+ try {
175
+ // franc scores the single most common script, so a Chinese, Japanese or
176
+ // Korean page carrying the usual run of English product names and code
177
+ // samples is detected as English. Those scripts never appear in
178
+ // Latin-script prose, so a meaningful share of them settles the question
179
+ // before trigram scoring gets a say.
180
+ const { letters, han, kana, hangul, share } = cjkScriptCounts(text);
181
+ if (letters > 0 && share >= CJK_SCRIPT_THRESHOLD) {
182
+ const code = kana > 0 ? 'jpn' : hangul > han ? 'kor' : 'cmn';
183
+ return {
184
+ code,
185
+ name: LANGUAGE_NAMES[code],
186
+ confidence: 0.9,
187
+ alternative: [],
188
+ detectionMethod: 'script'
189
+ };
190
+ }
191
+
192
+ const detected = franc(text, { minLength, whitelist: Object.keys(LANGUAGE_NAMES) });
193
+
194
+ if (detected === 'und') {
195
+ // Latin-script text franc could not place. Two English markers is a weak
196
+ // signal, but it beats returning nothing for short English strings.
197
+ const latinChars = (text.match(/[a-zA-Z]/g) || []).length;
198
+ const totalChars = text.replace(/\s/g, '').length;
199
+ if (totalChars > 0 && latinChars / totalChars > 0.7) {
200
+ const lower = text.toLowerCase();
201
+ const englishMarkers = ['the ', 'is ', 'are ', 'was ', 'and ', 'for ', 'that ', 'with ', 'this ', 'from '];
202
+ if (englishMarkers.filter(w => lower.includes(w)).length >= 2) {
203
+ return {
204
+ code: 'eng',
205
+ name: 'English',
206
+ confidence: 0.6,
207
+ alternative: [],
208
+ detectionMethod: 'heuristic'
209
+ };
210
+ }
211
+ }
212
+ return null; // Truly undetermined language
213
+ }
214
+
215
+ const confidence = Math.min(1, 0.5 + (text.length / 500) * 0.5);
216
+
217
+ const alternative = francAll(text, { minLength, whitelist: Object.keys(LANGUAGE_NAMES) })
218
+ .slice(1, 4)
219
+ .map(([code, score]) => ({
220
+ code,
221
+ name: LANGUAGE_NAMES[code] || code,
222
+ confidence: Math.round(score * 100) / 100
223
+ }));
224
+
225
+ return {
226
+ code: detected,
227
+ name: LANGUAGE_NAMES[detected] || detected,
228
+ confidence: Math.round(confidence * 100) / 100,
229
+ alternative
230
+ };
231
+ } catch (error) {
232
+ console.warn('Language detection failed:', error.message);
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * The ISO 639-1 code for a 639-3 code, or the input unchanged when the language
239
+ * has no 639-1 equivalent.
240
+ *
241
+ * @param {string|null|undefined} code
242
+ * @returns {string|null}
243
+ */
244
+ export function toIso6391(code) {
245
+ if (!code) return null;
246
+ return ISO_639_3_TO_1[code] || code;
247
+ }
248
+
249
+ export default detectLanguage;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * provenance -- numeric provenance guard for LLM-extracted data.
2
+ * provenance -- provenance guard for LLM-extracted data.
3
3
  *
4
- * A model handed page text that does not contain the number it was asked for
4
+ * A model handed page text that does not contain the value it was asked for
5
5
  * does not say so: it writes a plausible one. On
6
6
  * https://www.apple.com/shop/buy-mac/macbook-air every MacBook Air price lives
7
7
  * only inside the page's embedded PRODUCT_SELECTION_BOOTSTRAP JSON — the
@@ -25,6 +25,22 @@
25
25
  * "1.299,00", "1 299", "1299.00" and in a value split across markup. Every
26
26
  * ambiguous reading of a source number is admitted, because an extra reading
27
27
  * can only make the guard more permissive, never null a real value.
28
+ *
29
+ * Two kinds of value are checked, both of which have one correct spelling:
30
+ *
31
+ * - numbers, matched on normalised numeric readings (see `valueReadings`);
32
+ * - digit-bearing literals — versions, dates, ISBNs, SKUs, model numbers —
33
+ * matched by literal substring (see `literalReadings`).
34
+ *
35
+ * The literal class was added after `extract_structured` returned SQLite
36
+ * "3.34.0" on sqlite.org for three runs running. Readability drops the
37
+ * "Version 3.53.4" line, so the model saw no version and answered from memory
38
+ * with a real-but-wrong release; the guard reported `verified: 0` because a
39
+ * three-segment dotted string is not a number and nothing else looked at it.
40
+ *
41
+ * Prose is never checked. A value containing whitespace, or no digit at all,
42
+ * is left alone: a model may legitimately re-word a description, and nulling
43
+ * good text is the one failure this guard must not introduce.
28
44
  */
29
45
 
30
46
  /** Spaces (incl. NBSP / narrow NBSP) and the Swiss apostrophe group digits. */
@@ -46,6 +62,31 @@ const CURRENCY_SYMBOLS = /\p{Sc}/gu;
46
62
  */
47
63
  const NUMERIC_STRING = /^[+-]?\d+(?:[.,]\d{3})*(?:[.,]\d+)?$/;
48
64
 
65
+ /**
66
+ * A digit-bearing literal that is not a plain number: a version ("3.53.4"), a
67
+ * date ("2026-07-24"), an ISBN, a SKU, a model number ("A2338"), an id.
68
+ *
69
+ * These are the other thing a model fabricates, and NUMERIC_STRING cannot see
70
+ * them — "3.34.0" has three dotted segments, so it fails that pattern, and
71
+ * `valueReadings` returned null, and the walker skipped the field. On
72
+ * sqlite.org that is exactly what happened: Readability drops the "Version
73
+ * 3.53.4" line, so the model was shown no version at all and answered "3.34.0"
74
+ * from memory — a real SQLite release, absent from the page, reported as
75
+ * verified because the guard never looked at it.
76
+ *
77
+ * The shape is deliberately tight, and the rule is "no whitespace": a value
78
+ * with a space in it is prose, which a model may legitimately re-word, and
79
+ * comparing it literally would null good extractions. What is left is only
80
+ * tokens that have one correct spelling, so a literal lookup is a fair test.
81
+ */
82
+ const LITERAL_SEPARATED = /^[A-Za-z0-9]+(?:[.\-_/:+][A-Za-z0-9]+)+$/;
83
+
84
+ /** A mixed alphanumeric run with no separator: "A2338", "RTX4090". */
85
+ const LITERAL_ALNUM = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9]+$/;
86
+
87
+ /** A leading "v" is a way of writing a version, not part of it. */
88
+ const VERSION_PREFIX = /^v(?=\d)/i;
89
+
49
90
  /** Chained markup between digits (`<span>1</span><span>299</span>`) is welded. */
50
91
  const MARKUP_BETWEEN_DIGITS = /(\d)(?:(?:\s*<[^>]{0,120}>)+\s*)([\d.,])/g;
51
92
  const MAX_WELD_PASSES = 3;
@@ -96,7 +137,7 @@ function readings(token) {
96
137
  * @param {string} source - raw html and/or page text
97
138
  * @returns {Set<string>}
98
139
  */
99
- function numbersInSource(source) {
140
+ function sourceVariants(source) {
100
141
  const variants = [source];
101
142
  if (source.includes('<')) {
102
143
  let welded = source;
@@ -107,9 +148,12 @@ function numbersInSource(source) {
107
148
  }
108
149
  if (welded !== source) variants.push(welded);
109
150
  }
151
+ return variants;
152
+ }
110
153
 
154
+ function numbersInSource(source) {
111
155
  const found = new Set();
112
- for (const variant of variants) {
156
+ for (const variant of sourceVariants(source)) {
113
157
  for (const [token] of variant.matchAll(GROUPED_TOKEN)) {
114
158
  for (const reading of readings(token)) found.add(reading);
115
159
  }
@@ -120,6 +164,19 @@ function numbersInSource(source) {
120
164
  return found;
121
165
  }
122
166
 
167
+ /**
168
+ * Lowercased source variants for literal lookup. A literal is checked by
169
+ * substring, so "3.53.4" is found inside "Version 3.53.4 (2026-07-24)" and
170
+ * inside "v3.53.4", and the welded variant covers a token the page splits
171
+ * across markup.
172
+ *
173
+ * @param {string} source
174
+ * @returns {string[]}
175
+ */
176
+ function literalHaystacks(source) {
177
+ return sourceVariants(source).map((variant) => variant.toLowerCase());
178
+ }
179
+
123
180
  /**
124
181
  * The numeric readings of an extracted value, or null when the value is not a
125
182
  * numeric field at all.
@@ -143,6 +200,34 @@ function valueReadings(value) {
143
200
  return readings(stripped.replace(/^\+/, ''));
144
201
  }
145
202
 
203
+ /**
204
+ * The literal spellings of a digit-bearing identifier, or null when the value
205
+ * is not one. Checked only after `valueReadings` declines, so a plain number
206
+ * never reaches here.
207
+ *
208
+ * Two shapes qualify, both of which have exactly one correct spelling:
209
+ * separated ("3.53.4", "2026-07-24", "978-0-596-51774-8") and mixed
210
+ * alphanumeric ("A2338"). Everything else — anything with whitespace, and any
211
+ * string with no digit in it — is prose and is left alone, because a model may
212
+ * legitimately re-word prose and a literal comparison would null good data.
213
+ *
214
+ * @param {*} value
215
+ * @returns {string[]|null} lowercased spellings to look for
216
+ */
217
+ function literalReadings(value) {
218
+ if (typeof value !== 'string') return null;
219
+ const token = value.trim();
220
+ if (!token || /\s/.test(token)) return null;
221
+ if (!/\d/.test(token)) return null;
222
+ if (!LITERAL_SEPARATED.test(token) && !LITERAL_ALNUM.test(token)) return null;
223
+
224
+ const lower = token.toLowerCase();
225
+ const spellings = new Set([lower]);
226
+ // "v3.53.4" on a page that writes "3.53.4", and the reverse.
227
+ spellings.add(lower.replace(VERSION_PREFIX, ''));
228
+ return [...spellings];
229
+ }
230
+
146
231
  /**
147
232
  * Replace every numeric value that is not present in the source with null.
148
233
  *
@@ -168,6 +253,13 @@ export function verifyNumericProvenance(data, source) {
168
253
  const found = numbersInSource(source);
169
254
  const unverified = [];
170
255
  let verified = 0;
256
+ // Built lazily: most extractions are all numbers and never need it, and
257
+ // lowercasing a multi-megabyte page is not free.
258
+ let haystacks = null;
259
+ const inSource = (spellings) => {
260
+ if (haystacks === null) haystacks = literalHaystacks(source);
261
+ return spellings.some((s) => haystacks.some((h) => h.includes(s)));
262
+ };
171
263
 
172
264
  const walk = (node, path) => {
173
265
  if (Array.isArray(node)) {
@@ -182,8 +274,20 @@ export function verifyNumericProvenance(data, source) {
182
274
  }
183
275
 
184
276
  const candidates = valueReadings(node);
185
- if (candidates === null) return node;
186
- if (candidates.some((c) => found.has(c))) {
277
+ if (candidates !== null) {
278
+ if (candidates.some((c) => found.has(c))) {
279
+ verified++;
280
+ return node;
281
+ }
282
+ unverified.push({ path: path || '(root)', value: node, reason: 'not_found_in_source' });
283
+ return null;
284
+ }
285
+
286
+ // Not a number, but possibly a version/date/SKU-shaped literal, which is
287
+ // the other thing a model invents when the page does not say.
288
+ const literals = literalReadings(node);
289
+ if (literals === null) return node;
290
+ if (inSource(literals)) {
187
291
  verified++;
188
292
  return node;
189
293
  }