crawlforge-mcp-server 6.22.2 → 6.23.0

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.
Files changed (54) hide show
  1. package/README.md +10 -1
  2. package/package.json +1 -1
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +133 -23
  6. package/src/core/AuthManager.js +7 -1
  7. package/src/core/ChangeTracker.js +52 -4
  8. package/src/core/LLMsTxtAnalyzer.js +21 -9
  9. package/src/core/LocalizationManager.js +20 -4
  10. package/src/core/ResearchOrchestrator.js +29 -3
  11. package/src/core/StealthBrowserManager.js +10 -5
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +286 -0
  14. package/src/core/browser/snapshot.js +22 -0
  15. package/src/core/crawlers/BFSCrawler.js +10 -4
  16. package/src/core/processing/BrowserProcessor.js +5 -3
  17. package/src/core/processing/ContentProcessor.js +2 -1
  18. package/src/core/research/claimFilters.js +20 -1
  19. package/src/core/workers/worker.js +2 -0
  20. package/src/schemas/toolOutputSchemas.js +3 -2
  21. package/src/server/fallbackHints.js +5 -1
  22. package/src/server/inlineThreshold.js +19 -11
  23. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  25. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  26. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  31. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  32. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  33. package/src/tools/agent/sourceChecks.js +103 -0
  34. package/src/tools/basic/extractMetadata.js +9 -4
  35. package/src/tools/basic/extractText.js +6 -4
  36. package/src/tools/basic/fetchUrl.js +3 -0
  37. package/src/tools/basic/scrapeStructured.js +66 -21
  38. package/src/tools/crawl/mapSite.js +18 -3
  39. package/src/tools/extract/extractStructured.js +134 -1
  40. package/src/tools/extract/processDocument.js +48 -5
  41. package/src/tools/extract/summarizeContent.js +123 -5
  42. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  43. package/src/tools/result/readResult.js +79 -10
  44. package/src/tools/scrape/_mainContent.js +97 -6
  45. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  46. package/src/tools/search/searchWeb.js +46 -15
  47. package/src/tools/search/serpRank.js +1 -1
  48. package/src/tools/tracking/trackChanges/index.js +1 -0
  49. package/src/tools/tracking/trackChanges/schema.js +2 -1
  50. package/src/utils/htmlToMarkdown.js +77 -1
  51. package/src/utils/localFilePolicy.js +65 -0
  52. package/src/utils/provenance.js +102 -1
  53. package/src/utils/ssrfGuard.js +4 -3
  54. package/src/utils/ssrfProtection.js +3 -0
@@ -28,6 +28,7 @@ const ChangeTrackingSchema = z.object({
28
28
  trackLinks: z.boolean().default(true),
29
29
  ignoreWhitespace: z.boolean().default(true),
30
30
  ignoreCase: z.boolean().default(false),
31
+ includeMarkupChanges: z.boolean().default(false),
31
32
  customSelectors: z.array(z.string()).optional(),
32
33
  excludeSelectors: z.array(z.string()).optional().default([
33
34
  'script', 'style', 'noscript', '.advertisement', '.ad'
@@ -344,10 +345,12 @@ export class ChangeTracker extends EventEmitter {
344
345
  const currentAnalysis = await this.analyzeContent(currentContent, baseline.options);
345
346
 
346
347
  // Perform comprehensive change detection
348
+ // includeMarkupChanges only shapes the response, so unlike the analysis
349
+ // options it may be asked for at compare time.
347
350
  const changeAnalysis = await this.detectChanges(
348
351
  baseline.analysis,
349
352
  currentAnalysis,
350
- baseline.options
353
+ { ...baseline.options, includeMarkupChanges: options.includeMarkupChanges ?? baseline.options.includeMarkupChanges }
351
354
  );
352
355
 
353
356
  // Calculate change significance
@@ -410,7 +413,16 @@ export class ChangeTracker extends EventEmitter {
410
413
  metrics: changeRecord.metrics,
411
414
  recommendations: this.generateChangeRecommendations(changeRecord),
412
415
  ...(alerts.length ? { alerts } : {}),
416
+ // ignoredOptions is the structured form of the warning: each entry
417
+ // names what the caller asked for and what the baseline applied, so
418
+ // a compare at granularity "text" against a "section" baseline says
419
+ // so in a field, not only in prose (6.12).
413
420
  ...(ignoredOptions.length ? {
421
+ ignoredOptions: ignoredOptions.map(option => ({
422
+ option,
423
+ requested: options[option],
424
+ applied: baseline.options[option] ?? null
425
+ })),
414
426
  warnings: [
415
427
  `${ignoredOptions.join(', ')} passed to this compare ${ignoredOptions.length === 1 ? 'was' : 'were'} ignored — ` +
416
428
  `the baseline's options are applied to both sides of the diff. Recreate the baseline to change them.`
@@ -615,13 +627,23 @@ export class ChangeTracker extends EventEmitter {
615
627
  changes.modifiedElements.push(...elementChanges.modified);
616
628
  }
617
629
 
618
- // Detect text changes
630
+ // Detect text changes. Diffed over the visible text, not the raw HTML,
631
+ // so a class rename or icon markup no longer reads as a text change
632
+ // (live sweep 4.1). The raw markup diff is kept apart in markupChanges
633
+ // and only computed when the caller asks for it.
619
634
  if (options.trackText) {
620
635
  changes.textChanges = await this.detectTextChanges(
621
- baseline.originalContent,
622
- current.originalContent,
636
+ this.visibleText(baseline.originalContent),
637
+ this.visibleText(current.originalContent),
623
638
  options
624
639
  );
640
+ if (options.includeMarkupChanges) {
641
+ changes.markupChanges = await this.detectTextChanges(
642
+ baseline.originalContent,
643
+ current.originalContent,
644
+ options
645
+ );
646
+ }
625
647
  }
626
648
 
627
649
  // Detect monetary value changes. Scored by magnitude rather than by how
@@ -1038,6 +1060,32 @@ export class ChangeTracker extends EventEmitter {
1038
1060
  return changes;
1039
1061
  }
1040
1062
 
1063
+ /**
1064
+ * The text a reader sees: text nodes in document order, one per line, with
1065
+ * script, style, noscript and template content dropped. Input with no
1066
+ * markup (a plain-text content param) is returned unchanged.
1067
+ * @param {string} content - HTML or plain text
1068
+ * @returns {string} - Visible text
1069
+ */
1070
+ visibleText(content = '') {
1071
+ if (!/<[a-z!/]/i.test(content)) return content;
1072
+ const $ = load(content);
1073
+ $('script, style, noscript, template').remove();
1074
+ const parts = [];
1075
+ const walk = nodes => {
1076
+ for (const node of nodes) {
1077
+ if (node.type === 'text') {
1078
+ const text = node.data.trim();
1079
+ if (text) parts.push(text);
1080
+ } else if (node.children) {
1081
+ walk(node.children);
1082
+ }
1083
+ }
1084
+ };
1085
+ walk($.root().contents().toArray());
1086
+ return parts.join('\n');
1087
+ }
1088
+
1041
1089
  async detectTextChanges(baselineContent, currentContent, options = {}) {
1042
1090
  const textChanges = [];
1043
1091
 
@@ -240,8 +240,10 @@ export class LLMsTxtAnalyzer {
240
240
  // "https://site.com/" + "/json" was listed as "https://site.com//json" (R25 1.8).
241
241
  const apiUrl = new URL(path, baseUrl).toString();
242
242
  const response = await this.fetchWithTimeout(apiUrl, { timeout: 5000 });
243
- if (response.ok) {
244
- const contentType = response.headers.get('content-type') || '';
243
+ const contentType = response.headers.get('content-type') || '';
244
+ // A 200 alone is not an API: gov.uk's /api redirects to an HTML
245
+ // help page (live sweep 4.4). Only machine-readable answers count.
246
+ if (response.ok && /json|xml/i.test(contentType)) {
245
247
  return {
246
248
  url: apiUrl,
247
249
  type: this.determineAPIType(apiUrl, contentType),
@@ -739,23 +741,33 @@ export class LLMsTxtAnalyzer {
739
741
  const html = await readBody(response);
740
742
  const $ = load(html);
741
743
 
742
- // Check for forms
743
- if ($('form').length > 0) {
744
+ // Check for forms. A site-wide search box or a hidden feedback form
745
+ // is page chrome, not the page's purpose: counting them made every
746
+ // docs.python.org and gov.uk page a "forms" page (live sweep 4.4).
747
+ const forms = $('form').filter((_, el) => {
748
+ const form = $(el);
749
+ return form.attr('hidden') === undefined
750
+ && form.closest('[role="search"]').length === 0
751
+ && form.find('input[type="search"]').length === 0;
752
+ });
753
+ if (forms.length > 0) {
744
754
  return {
745
755
  category: 'forms',
746
756
  type: 'interactive',
747
757
  confidence: 0.9,
748
- metadata: { formCount: $('form').length }
758
+ metadata: { formCount: forms.length, title: pageTitle($) }
749
759
  };
750
760
  }
751
761
 
752
- // Check for login/auth indicators
753
- if (html.includes('login') || html.includes('password') || $('input[type="password"]').length > 0) {
762
+ // Check for login/auth indicators. Substring checks for "login" and
763
+ // "password" matched link hrefs (gov.uk's /student-finance-register-login).
764
+ if ($('input[type="password"]').length > 0) {
754
765
  return { category: 'restricted', type: 'authentication', confidence: 0.8 };
755
766
  }
756
767
 
757
- // Check for dynamic content indicators
758
- if (html.includes('application/json') || contentType.includes('json')) {
768
+ // Check for dynamic content indicators (the response, not a
769
+ // <script type="application/json"> inside an HTML page)
770
+ if (contentType.includes('json')) {
759
771
  return { category: 'dynamic', type: 'api', confidence: 0.9 };
760
772
  }
761
773
 
@@ -45,6 +45,20 @@ const SUPPORTED_COUNTRIES = {
45
45
  // "zh-Hans-CN"). "klingon" used to come back as "klingon-DE" (R24).
46
46
  const LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?$/i;
47
47
 
48
+ // A BCP 47 tag that always carries a region, in canonical case: a bare "de"
49
+ // for DE comes back "de-DE" (it used to stay "de" while JP's default answered
50
+ // "ja-JP"), "zh-Hans" for CN becomes "zh-Hans-CN", "EN-gb" becomes "en-GB".
51
+ function withRegion(language, countryCode) {
52
+ const [lang, ...rest] = String(language).split('-');
53
+ const script = rest.find(part => part.length === 4);
54
+ const region = rest.find(part => part.length !== 4) || countryCode;
55
+ return [
56
+ lang.toLowerCase(),
57
+ script && script[0].toUpperCase() + script.slice(1).toLowerCase(),
58
+ region.toUpperCase()
59
+ ].filter(Boolean).join('-');
60
+ }
61
+
48
62
  // Common language mappings for Accept-Language headers with cultural preferences
49
63
  const LANGUAGE_MAPPINGS = {
50
64
  'en': 'en-US,en;q=0.9',
@@ -261,16 +275,17 @@ export class LocalizationManager extends EventEmitter {
261
275
  }
262
276
 
263
277
  const countryData = SUPPORTED_COUNTRIES[validatedInput.countryCode];
278
+ const language = withRegion(validatedInput.language || countryData.language, validatedInput.countryCode);
264
279
 
265
280
  // Merge country defaults with custom options
266
281
  const localizationConfig = {
267
282
  countryCode: validatedInput.countryCode,
268
- language: validatedInput.language || countryData.language,
283
+ language,
269
284
  timezone: validatedInput.timezone || countryData.timezone,
270
285
  currency: validatedInput.currency || countryData.currency,
271
286
  searchDomain: countryData.searchDomain,
272
287
  acceptLanguage: validatedInput.acceptLanguage
273
- || this.buildAcceptLanguageHeader(validatedInput.language || countryData.language, validatedInput.countryCode),
288
+ || this.buildAcceptLanguageHeader(language, validatedInput.countryCode),
274
289
  customHeaders: validatedInput.customHeaders || {},
275
290
  geoLocation: validatedInput.geoLocation,
276
291
  proxySettings: validatedInput.proxySettings,
@@ -1291,10 +1306,11 @@ export class LocalizationManager extends EventEmitter {
1291
1306
  return domainMappings[domain];
1292
1307
  }
1293
1308
 
1294
- // TLD-based suggestions
1309
+ // TLD-based suggestions. ".uk" is the one ccTLD that is not its ISO
1310
+ // code: a .co.uk site used to fall through to the US-first defaults.
1295
1311
  const tldMatch = domain.match(/\.([a-z]{2})$/);
1296
1312
  if (tldMatch) {
1297
- const tld = tldMatch[1].toUpperCase();
1313
+ const tld = tldMatch[1] === 'uk' ? 'GB' : tldMatch[1].toUpperCase();
1298
1314
  if (SUPPORTED_COUNTRIES[tld]) {
1299
1315
  return [tld];
1300
1316
  }
@@ -12,6 +12,7 @@ import { safeFetch, safeGoto, assertNavigationAllowed } from '../utils/ssrfGuard
12
12
  import { serverStealthProxies } from '../constants/config.js';
13
13
  import { preflightFetch, browserPreflight, redirectGate } from '../utils/robotsGate.js';
14
14
  import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
15
+ import { guardedProxyFor } from './browser/guardedProxy.js';
15
16
  import { noteRetryAfter } from '../utils/hostRateLimiter.js';
16
17
  import { load } from 'cheerio';
17
18
  import { flattenText } from 'crawlforge-extractors';
@@ -120,6 +121,25 @@ export function claimStatements(point) {
120
121
  .filter(Boolean);
121
122
  }
122
123
 
124
+ /**
125
+ * The first `max` characters of a page's text, ended at the last sentence or
126
+ * line end inside them. A plain cut leaves the last sentence half written, and
127
+ * the summarizer took it as a key point: "…the same hostname can range from no
128
+ * bot pro" was a finding in the R25 re-check (2026-10-19). Cut as before when
129
+ * no end falls in the second half.
130
+ *
131
+ * @param {string} text
132
+ * @param {number} max
133
+ * @returns {string}
134
+ */
135
+ export function cutAtSentence(text, max) {
136
+ if (text.length <= max) return text;
137
+ const cut = text.slice(0, max);
138
+ const ends = [...cut.matchAll(/[.!?]["'\u201D\u2019)]*(?=\s)|\n/g)];
139
+ const end = ends.length > 0 ? ends[ends.length - 1].index + ends[ends.length - 1][0].length : 0;
140
+ return end > max / 2 ? cut.slice(0, end).trimEnd() : cut;
141
+ }
142
+
123
143
  /**
124
144
  * Page text for the plain-fetch fallback, one line per block element. The
125
145
  * regex tag-strip it replaces ran every block together on one line.
@@ -896,7 +916,7 @@ export class ResearchOrchestrator extends EventEmitter {
896
916
  const textContent = fallbackPageText(html);
897
917
  if (textContent.length > 50) {
898
918
  contentData = {
899
- content: textContent.slice(0, 5000),
919
+ content: cutAtSentence(textContent, 5000),
900
920
  metadata: { title: source.title || '' },
901
921
  structuredData: {},
902
922
  fallback: true
@@ -1078,6 +1098,9 @@ export class ResearchOrchestrator extends EventEmitter {
1078
1098
  launchOptions.geoip = true;
1079
1099
  }
1080
1100
  this._stealthBrowser = await camoufox.Camoufox(launchOptions);
1101
+ // Each context goes out through the SSRF-guarded proxy, chained to
1102
+ // this one (src/core/browser/guardedProxy.js).
1103
+ this._stealthUpstream = proxy ? { server: proxy.url } : null;
1081
1104
  this._stealthEngineActive = 'camoufox';
1082
1105
  this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
1083
1106
  proxy: proxy ? proxy.redacted : null
@@ -1188,7 +1211,10 @@ export class ResearchOrchestrator extends EventEmitter {
1188
1211
  if (this._stealthEngineActive === 'camoufox') {
1189
1212
  // viewport:null skips Browser.setDefaultViewport, whose playwright-core
1190
1213
  // 1.62 payload (isMobile etc.) camoufox's older Firefox build rejects.
1191
- camoufoxContext = await this._stealthBrowser.newContext({ viewport: null });
1214
+ camoufoxContext = await this._stealthBrowser.newContext({
1215
+ viewport: null,
1216
+ proxy: await guardedProxyFor(this._stealthUpstream)
1217
+ });
1192
1218
  page = await camoufoxContext.newPage();
1193
1219
  } else {
1194
1220
  const { contextId } = await this._stealthManager.createStealthContext({ level: this.stealthLevel });
@@ -1425,7 +1451,7 @@ export class ResearchOrchestrator extends EventEmitter {
1425
1451
  try {
1426
1452
  if (!source.extractedContent) continue;
1427
1453
 
1428
- const content = source.extractedContent.substring(0, 5000); // Limit content length
1454
+ const content = cutAtSentence(source.extractedContent, 5000); // Limit content length
1429
1455
 
1430
1456
  // Use summarization to extract key points
1431
1457
  const summary = await this.summarizeTool.execute({
@@ -23,6 +23,7 @@ import { guardFirefoxPageErrors } from '../utils/firefoxPageErrorGuard.js';
23
23
  import { serverStealthProxies } from '../constants/config.js';
24
24
  import { ClearanceJar, sharedClearanceJar } from './ClearanceJar.js';
25
25
  import { readWindowState } from './browser/windowState.js';
26
+ import { guardedProxyFor } from './browser/guardedProxy.js';
26
27
 
27
28
  // Grace given to a document that rendered no title and no text (see _waitOutEmptyDocument).
28
29
  export const EMPTY_DOCUMENT_GRACE_MS = 8000;
@@ -873,9 +874,6 @@ export class StealthBrowserManager {
873
874
  const proxy = this._launchedEngine === 'camoufox'
874
875
  ? (this.browser[CAMOUFOX_PROXY] || null)
875
876
  : this.resolveProxy(validatedConfig);
876
- if (proxy) {
877
- contextOptions.proxy = proxy;
878
- }
879
877
 
880
878
  // The identity a clearance is bound to: engine, the User-Agent this context
881
879
  // presents, and the exit it goes out through. Read before the camoufox
@@ -953,6 +951,12 @@ export class StealthBrowserManager {
953
951
  }
954
952
  }
955
953
 
954
+ // What goes on the context is the SSRF-guarded local proxy, chained to
955
+ // that one: it checks every connection the page opens — redirect hops,
956
+ // iframes, WebSockets — where route() never sees them, and keeps the
957
+ // upstream's credentials itself (src/core/browser/guardedProxy.js).
958
+ contextOptions.proxy = await guardedProxyFor(proxy);
959
+
956
960
  const context = await this.browser.newContext(contextOptions);
957
961
  const contextId = this.generateContextId();
958
962
 
@@ -3028,7 +3032,8 @@ export class StealthBrowserManager {
3028
3032
  const response = await safeGoto(page, url, { waitUntil: 'domcontentloaded', onRedirect });
3029
3033
  const landedUrl = page.url();
3030
3034
  if (response && status === null) status = response.status();
3031
- if (wait_for > 0) await page.waitForTimeout(wait_for);
3035
+ const waitedMs = wait_for > 0 ? wait_for : 0;
3036
+ if (waitedMs > 0) await page.waitForTimeout(waitedMs);
3032
3037
  // Cloudflare's and Vercel's JavaScript challenges solve themselves in a
3033
3038
  // real browser and then reload the page. camoufox on lesswrong.com was
3034
3039
  // read while the "Vercel Security Checkpoint" interstitial was still
@@ -3087,7 +3092,7 @@ export class StealthBrowserManager {
3087
3092
  this._markBlocked(contextId, [url, page.url()]);
3088
3093
  }
3089
3094
 
3090
- return { success: true, url, title, text, html, screenshot: shot, status, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
3095
+ return { success: true, url, title, text, html, screenshot: shot, status, waitedMs, gracedMs, engine: engineUsed, warnings, ...(windowState ? { windowState } : {}) };
3091
3096
  } finally {
3092
3097
  await this.closeContext(contextId).catch(() => {});
3093
3098
  }
@@ -255,7 +255,7 @@ export class ContentAnalyzer {
255
255
 
256
256
  // Readability metrics
257
257
  if (analysisOptions.includeReadabilityMetrics) {
258
- result.readability = await this.calculateReadability(text);
258
+ result.readability = await this.calculateReadability(text, analysisOptions);
259
259
  }
260
260
 
261
261
  // Sentiment analysis
@@ -512,6 +512,48 @@ export class ContentAnalyzer {
512
512
  return latin.length / letters.length < 0.5;
513
513
  }
514
514
 
515
+ /**
516
+ * Whether the text is confidently not English, so the English-only sentiment
517
+ * lexicon and Flesch formula must not be applied. A Spanish complaint scored
518
+ * -0.08 and Spanish and German got English Flesch scores (live sweep
519
+ * 2026-10-20). franc misfiles short English — "I am so happy today" is
520
+ * Somali — so a detected language counts only when it has a stop-word list,
521
+ * the script is not Latin, or the text is long enough to detect reliably.
522
+ * @param {string} text
523
+ * @param {{ detectedLanguage?: string|null }} options
524
+ * @returns {boolean}
525
+ */
526
+ isNonEnglishText(text, options = {}) {
527
+ if (!this.needsSegmentedTerms(text, options)) return false;
528
+ if (this.isCjkText(text) || !this.isLatinScriptText(text)) return true;
529
+ return Boolean(LANGUAGE_STOP_WORDS[options.detectedLanguage])
530
+ || text.split(/\s+/).filter(Boolean).length >= 20;
531
+ }
532
+
533
+ /**
534
+ * The noun run of a compromise noun phrase: leading determiners, numbers
535
+ * and adjectives and trailing non-nouns dropped. compromise tags "feel" in
536
+ * "The switches feel crisp" as a noun, and the whole clause came back as a
537
+ * topic (sweep 2026-10-20); a plural noun ends an English compound
538
+ * ("battery life", not "batteries life"), so a noun after it is cut off.
539
+ * A phrase of only time words ("two weeks", "Monday") is not a topic.
540
+ * @param {Object} phrase - One compromise noun-phrase match
541
+ * @returns {string} - The head, or '' when there is none
542
+ */
543
+ nounPhraseHead(phrase) {
544
+ const terms = phrase.terms().json().map(t => t.terms[0]);
545
+ const isNoun = t => t.tags.includes('Noun') && !t.tags.includes('Pronoun');
546
+ const first = terms.findIndex(isNoun);
547
+ if (first === -1) return '';
548
+ let last = terms.length - 1;
549
+ while (!isNoun(terms[last])) last--;
550
+ const head = terms.slice(first, last + 1);
551
+ const plural = head.findIndex(t => t.tags.includes('Plural'));
552
+ if (plural !== -1 && plural < head.length - 1 && isNoun(head[plural + 1])) head.length = plural + 1;
553
+ if (head.every(t => t.tags.includes('Date'))) return '';
554
+ return head.map(t => t.text).join(' ');
555
+ }
556
+
515
557
  async extractTopics(text, options = {}) {
516
558
  try {
517
559
  // compromise noun-phrase matching is English-only: on CJK text it emits
@@ -533,8 +575,8 @@ export class ContentAnalyzer {
533
575
 
534
576
  const doc = nlp(text);
535
577
 
536
- // Extract noun phrases as potential topics
537
- const nounPhrases = doc.nouns().out('array');
578
+ // Extract noun-phrase heads as potential topics
579
+ const nounPhrases = doc.nouns().map(phrase => this.nounPhraseHead(phrase)).filter(Boolean);
538
580
  const adjNounPhrases = doc.match('#Adjective+ #Noun+').out('array');
539
581
 
540
582
  // Combine and count frequency
@@ -597,8 +639,12 @@ export class ContentAnalyzer {
597
639
  // compromise's out('array') keeps adjoining punctuation ("Craigslist.",
598
640
  // "United States,") — clean edges, drop bare stopword/pronoun tokens,
599
641
  // and dedupe case-insensitively keeping the first casing seen.
642
+ // On other languages compromise's capital-letter heuristics also pick
643
+ // up function words and titles: "über" as an organisation, "Berlin den",
644
+ // "Präsidenten Emmanuel Macron" (sweep 2026-10-20).
645
+ const language = options.detectedLanguage;
600
646
  const clean = (arr) => this.dedupeEntities(
601
- arr.map(e => this.cleanEntityText(e))
647
+ arr.map(e => this.trimForeignEntity(this.cleanEntityText(e), language))
602
648
  .filter(e => e.length > 1
603
649
  && !this.isBareStopword(e)
604
650
  && !/^(?:inc|corp|ltd|co|llc)\.?$/i.test(e)) // bare corporate-suffix fragment
@@ -609,7 +655,10 @@ export class ContentAnalyzer {
609
655
  let organizations = clean(doc.organizations().out('array'));
610
656
  // .dates() needs the compromise-dates plugin (not installed) and threw,
611
657
  // aborting ALL entity extraction; #Date+ tag matching is core compromise.
612
- const dates = clean(doc.match('#Date+').out('array'));
658
+ // A duration ("weeks", "an hour") is not a date; a weekday is, in
659
+ // every language with a list — compromise knows only the English ones.
660
+ const dates = clean([...doc.match('#Date+').out('array'), ...this.findWeekdays(text, language)])
661
+ .filter(e => !DURATION_ONLY.test(e));
613
662
  const money = clean(doc.money().out('array'));
614
663
  let other = clean(doc.topics().out('array').slice(0, 10))
615
664
  .filter(e => !this.isSentenceInitialArtifact(e, text));
@@ -651,12 +700,13 @@ export class ContentAnalyzer {
651
700
  // Unicode classes, not [A-Z]: the ASCII version cut "Universität" to
652
701
  // "Universit" and "Wisłą" to "Wis" and dropped "Środkowej" outright (R16).
653
702
  // \b stays ASCII-only even under the u flag, hence the lookbehind.
654
- const properNouns = (text.match(/(?<![\p{L}\p{N}])\p{Lu}[\p{L}.]+(?:\s+\p{Lu}[\p{L}.]+)*/gu) || [])
703
+ // German capitalises every noun, so a capital is no evidence there.
704
+ const properNouns = language === 'deu' ? [] : (text.match(/(?<![\p{L}\p{N}])\p{Lu}[\p{L}.]+(?:\s+\p{Lu}[\p{L}.]+)*/gu) || [])
655
705
  // Split matches that crossed a sentence boundary ("XPath. In") — a
656
706
  // period after a lowercase letter followed by a capital is a sentence
657
707
  // end, while abbreviations ("U.S. Holdings") stay intact.
658
708
  .flatMap(n => n.split(/(?<=\p{Ll}\.)\s+(?=\p{Lu})/u));
659
- const supplemental = this.dedupeEntities(properNouns.map(n => this.cleanEntityText(n)))
709
+ const supplemental = this.dedupeEntities(properNouns.map(n => this.trimForeignEntity(this.cleanEntityText(n), language)))
660
710
  .filter(n => n.length > 1
661
711
  && !existingEntities.has(n.toLowerCase())
662
712
  && !this.isSentenceInitialArtifact(n, text))
@@ -778,9 +828,10 @@ export class ContentAnalyzer {
778
828
  /**
779
829
  * Calculate readability metrics
780
830
  * @param {string} text - Text to analyze
831
+ * @param {{ detectedLanguage?: string|null }} [options]
781
832
  * @returns {Promise<Object>} - Readability metrics
782
833
  */
783
- async calculateReadability(text) {
834
+ async calculateReadability(text, options = {}) {
784
835
  try {
785
836
  const sentences = splitSentences(text);
786
837
  const isCjk = this.isCjkText(text);
@@ -824,6 +875,14 @@ export class ContentAnalyzer {
824
875
  metrics
825
876
  };
826
877
  }
878
+ // The constants are fitted to English; Spanish and German got English
879
+ // scores (sweep 2026-10-20). No other-language formula is implemented.
880
+ if (this.isNonEnglishText(text, options)) {
881
+ return {
882
+ notApplicable: 'flesch-reading-ease-is-english-only',
883
+ metrics
884
+ };
885
+ }
827
886
 
828
887
  // Flesch Reading Ease Score
829
888
  const fleschScore = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
@@ -852,31 +911,27 @@ export class ContentAnalyzer {
852
911
  // negation ("not happy" scores negative). A 25-word list used to leave
853
912
  // clearly positive text "neutral" (R25).
854
913
  const { calculation, tokens } = sentimentAnalyzer.analyze(text);
855
- // Drop hits that are function words of the detected language: German
856
- // "die" is -3 in the English lexicon.
857
- const foreignStopWords = LANGUAGE_STOP_WORDS[options.detectedLanguage];
858
- const scores = calculation
859
- .map(entry => Object.entries(entry)[0])
860
- .filter(([word]) => !foreignStopWords?.has(word))
861
- .map(([, score]) => score);
914
+ const scores = calculation.map(entry => Object.values(entry)[0]);
862
915
 
863
916
  const totalSentimentWords = scores.length;
864
917
 
918
+ // The lexicon is English. Its hits in Spanish or German text are
919
+ // accidents ("die" is -3), and finding none is not evidence of
920
+ // neutrality, so say the measure does not apply (R24, sweep 2026-10-20).
921
+ // Text franc may have misfiled — "I am so happy today" as Somali — is
922
+ // still scored when the lexicon matched.
923
+ if (this.isNonEnglishText(text, options)
924
+ || (totalSentimentWords === 0 && this.needsSegmentedTerms(text, options))) {
925
+ return {
926
+ polarity: 0,
927
+ subjectivity: 0,
928
+ label: 'not_applicable',
929
+ confidence: 0,
930
+ notApplicable: 'sentiment-lexicon-is-english-only'
931
+ };
932
+ }
933
+
865
934
  if (totalSentimentWords === 0) {
866
- // The lexicon is English. Finding none of its words in German or
867
- // Japanese text is not evidence of neutrality, so say the measure does
868
- // not apply instead of reporting "neutral" (R24). Only when nothing
869
- // matched: franc puts short English such as "I am so happy today" in
870
- // Somali, and those strings must still be scored.
871
- if (this.needsSegmentedTerms(text, options)) {
872
- return {
873
- polarity: 0,
874
- subjectivity: 0,
875
- label: 'not_applicable',
876
- confidence: 0,
877
- notApplicable: 'sentiment-lexicon-is-english-only'
878
- };
879
- }
880
935
  // No opinion words at all: neutral, but with no evidence behind it.
881
936
  return {
882
937
  polarity: 0,
@@ -1005,6 +1060,37 @@ export class ContentAnalyzer {
1005
1060
  return entity;
1006
1061
  }
1007
1062
 
1063
+ /**
1064
+ * Trim an entity found in non-English text: leading and trailing function
1065
+ * words of the detected language ("Berlin den" → "Berlin", "über" → "")
1066
+ * and leading titles ("Präsidenten Emmanuel Macron" → "Emmanuel Macron").
1067
+ * Text with no stop-word list for its language is returned unchanged.
1068
+ * @param {string} entity - Cleaned entity string
1069
+ * @param {string|null} [language] - Detected ISO 639-3 code
1070
+ * @returns {string}
1071
+ */
1072
+ trimForeignEntity(entity, language) {
1073
+ const stopWords = LANGUAGE_STOP_WORDS[language];
1074
+ if (!stopWords) return entity;
1075
+ const tokens = entity.split(/\s+/);
1076
+ while (tokens.length > 0 && (stopWords.has(tokens[0].toLowerCase()) || ENTITY_TITLES.has(tokens[0].toLowerCase()))) tokens.shift();
1077
+ while (tokens.length > 0 && stopWords.has(tokens[tokens.length - 1].toLowerCase())) tokens.pop();
1078
+ return tokens.join(' ');
1079
+ }
1080
+
1081
+ /**
1082
+ * Weekday names of the detected language, as written in the text.
1083
+ * @param {string} text - Full source text
1084
+ * @param {string|null} [language] - Detected ISO 639-3 code
1085
+ * @returns {string[]}
1086
+ */
1087
+ findWeekdays(text, language) {
1088
+ const names = WEEKDAYS[language];
1089
+ if (!names) return [];
1090
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${names.join('|')})(?![\\p{L}\\p{N}])`, 'giu');
1091
+ return text.match(pattern) || [];
1092
+ }
1093
+
1008
1094
  /**
1009
1095
  * Deduplicate entities case-insensitively, keeping the first casing seen
1010
1096
  * @param {string[]} entities - Entity strings
@@ -1156,6 +1242,48 @@ export class ContentAnalyzer {
1156
1242
  * way; every language whose function words are not on this list gets them back
1157
1243
  * as topics.
1158
1244
  */
1245
+ /**
1246
+ * A bare duration, optionally counted: "weeks", "an hour", "two years". It
1247
+ * measures time but names no date; compromise tags it #Date (sweep 2026-10-20).
1248
+ */
1249
+ const DURATION_ONLY = /^(?:(?:a|an|one|two|three|four|five|six|seven|eight|nine|ten|few|several|many|\d+)\s+)?(?:seconds?|minutes?|hours?|days?|weeks?|fortnights?|months?|years?|decades?|centuries|century)$/i;
1250
+
1251
+ /**
1252
+ * Weekday names by ISO 639-3 code, for the languages with a stop-word list.
1253
+ * English is left to compromise. Indonesian "minggu" (Sunday) is left out: it
1254
+ * also means "week", and Portuguese bare "segunda" also means "second".
1255
+ */
1256
+ const WEEKDAYS = {
1257
+ deu: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonnabend', 'sonntag'],
1258
+ spa: ['lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado', 'domingo'],
1259
+ fra: ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'],
1260
+ ita: ['lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato', 'domenica'],
1261
+ por: ['segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado', 'domingo'],
1262
+ nld: ['maandag', 'dinsdag', 'woensdag', 'donderdag', 'vrijdag', 'zaterdag', 'zondag'],
1263
+ swe: ['måndag', 'tisdag', 'onsdag', 'torsdag', 'fredag', 'lördag', 'söndag'],
1264
+ dan: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag'],
1265
+ nob: ['mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag', 'søndag'],
1266
+ pol: ['poniedziałek', 'wtorek', 'środa', 'środę', 'czwartek', 'piątek', 'sobota', 'sobotę', 'niedziela', 'niedzielę'],
1267
+ ind: ['senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu'],
1268
+ zlm: ['isnin', 'selasa', 'rabu', 'khamis', 'jumaat', 'sabtu', 'ahad'],
1269
+ zsm: ['isnin', 'selasa', 'rabu', 'khamis', 'jumaat', 'sabtu', 'ahad']
1270
+ };
1271
+
1272
+ /**
1273
+ * Titles that compromise folds into a following name in non-English text
1274
+ * ("Präsidenten Emmanuel Macron"), lowercased. Stripped only from the front.
1275
+ */
1276
+ const ENTITY_TITLES = new Set([
1277
+ 'präsident', 'präsidenten', 'präsidentin', 'bundespräsident', 'bundespräsidenten',
1278
+ 'bundeskanzler', 'bundeskanzlerin', 'kanzler', 'kanzlerin', 'minister', 'ministerin',
1279
+ 'ministerpräsident', 'ministerpräsidentin', 'herr', 'herrn', 'frau', 'könig', 'königin',
1280
+ 'presidente', 'presidenta', 'señor', 'señora', 'don', 'doña', 'ministro', 'ministra', 'rey', 'reina',
1281
+ 'président', 'présidente', 'monsieur', 'madame', 'ministre', 'roi', 'reine',
1282
+ 'signor', 'signore', 'signora', 'senhor', 'senhora',
1283
+ 'president', 'premier', 'meneer', 'mevrouw', 'koning', 'koningin',
1284
+ 'dr', 'dr.', 'prof', 'prof.', 'professor'
1285
+ ]);
1286
+
1159
1287
  const STOP_WORDS = new Set([
1160
1288
  // English
1161
1289
  'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',