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 +2 -2
- package/server.js +1 -1
- package/src/core/LocalizationManager.js +20 -28
- package/src/core/StealthBrowserManager.js +44 -9
- package/src/core/analysis/ContentAnalyzer.js +12 -166
- package/src/tools/extract/extractStructured.js +60 -17
- package/src/tools/scrape/_mainContent.js +132 -8
- package/src/utils/languageDetection.js +249 -0
- package/src/utils/provenance.js +110 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crawlforge-mcp-server",
|
|
3
|
-
"version": "5.5.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
1376
|
-
//
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
|
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
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
*
|
|
3
|
+
* content Readability drops.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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;
|
package/src/utils/provenance.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* provenance --
|
|
2
|
+
* provenance -- provenance guard for LLM-extracted data.
|
|
3
3
|
*
|
|
4
|
-
* A model handed page text that does not contain the
|
|
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
|
|
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
|
|
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
|
|
186
|
-
|
|
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
|
}
|