crawlforge-mcp-server 6.22.3 → 6.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +10 -1
  2. package/package.json +6 -6
  3. package/server.js +14 -7
  4. package/src/core/ActionExecutor.js +117 -12
  5. package/src/core/AgentOrchestrator.js +71 -14
  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 +8 -1
  11. package/src/core/StealthBrowserManager.js +21 -8
  12. package/src/core/analysis/ContentAnalyzer.js +157 -29
  13. package/src/core/browser/guardedProxy.js +291 -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/workers/worker.js +2 -0
  19. package/src/schemas/toolOutputSchemas.js +3 -2
  20. package/src/server/fallbackHints.js +5 -1
  21. package/src/server/inlineThreshold.js +15 -9
  22. package/src/skills/agent-skills/crawlforge-batch-automation/SKILL.md +1 -1
  23. package/src/skills/agent-skills/crawlforge-browser-sessions/SKILL.md +1 -1
  24. package/src/skills/agent-skills/crawlforge-change-tracking/SKILL.md +9 -2
  25. package/src/skills/agent-skills/crawlforge-deep-research/SKILL.md +1 -1
  26. package/src/skills/agent-skills/crawlforge-getting-started/SKILL.md +1 -1
  27. package/src/skills/agent-skills/crawlforge-stealth-browsing/SKILL.md +1 -1
  28. package/src/skills/agent-skills/crawlforge-structured-extraction/SKILL.md +1 -1
  29. package/src/skills/agent-skills/crawlforge-web-scraping/SKILL.md +1 -1
  30. package/src/skills/agent-skills/crawlforge-web-scraping/references/tool-reference.md +1 -1
  31. package/src/tools/advanced/ScrapeWithActionsTool.js +35 -9
  32. package/src/tools/agent/sourceChecks.js +103 -0
  33. package/src/tools/basic/extractMetadata.js +9 -4
  34. package/src/tools/basic/extractText.js +6 -4
  35. package/src/tools/basic/fetchUrl.js +3 -0
  36. package/src/tools/basic/scrapeStructured.js +66 -21
  37. package/src/tools/crawl/mapSite.js +18 -3
  38. package/src/tools/extract/extractStructured.js +134 -1
  39. package/src/tools/extract/processDocument.js +48 -5
  40. package/src/tools/extract/summarizeContent.js +123 -5
  41. package/src/tools/llmstxt/generateLLMsTxt.js +6 -5
  42. package/src/tools/result/readResult.js +79 -10
  43. package/src/tools/scrape/_mainContent.js +97 -6
  44. package/src/tools/search/adapters/dataforseoSearch.js +4 -1
  45. package/src/tools/search/searchWeb.js +46 -15
  46. package/src/tools/search/serpRank.js +1 -1
  47. package/src/tools/tracking/trackChanges/index.js +1 -0
  48. package/src/tools/tracking/trackChanges/schema.js +2 -1
  49. package/src/utils/htmlToMarkdown.js +77 -1
  50. package/src/utils/localFilePolicy.js +65 -0
  51. package/src/utils/provenance.js +102 -1
  52. package/src/utils/ssrfGuard.js +4 -3
  53. package/src/utils/ssrfProtection.js +3 -0
@@ -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';
@@ -1097,6 +1098,9 @@ export class ResearchOrchestrator extends EventEmitter {
1097
1098
  launchOptions.geoip = true;
1098
1099
  }
1099
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;
1100
1104
  this._stealthEngineActive = 'camoufox';
1101
1105
  this.logger.info('Stealth fallback using Camoufox (Firefox) engine', {
1102
1106
  proxy: proxy ? proxy.redacted : null
@@ -1207,7 +1211,10 @@ export class ResearchOrchestrator extends EventEmitter {
1207
1211
  if (this._stealthEngineActive === 'camoufox') {
1208
1212
  // viewport:null skips Browser.setDefaultViewport, whose playwright-core
1209
1213
  // 1.62 payload (isMobile etc.) camoufox's older Firefox build rejects.
1210
- camoufoxContext = await this._stealthBrowser.newContext({ viewport: null });
1214
+ camoufoxContext = await this._stealthBrowser.newContext({
1215
+ viewport: null,
1216
+ proxy: await guardedProxyFor(this._stealthUpstream)
1217
+ });
1211
1218
  page = await camoufoxContext.newPage();
1212
1219
  } else {
1213
1220
  const { contextId } = await this._stealthManager.createStealthContext({ level: this.stealthLevel });
@@ -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;
@@ -278,7 +279,7 @@ export async function resolveStealthEngine(requested) {
278
279
  return { engine: 'chromium', fallbackWarning: null };
279
280
  }
280
281
 
281
- let reason = 'camoufox is not installed (npm install camoufox)';
282
+ let reason = 'camoufox is not installed (npm install camoufox, then npx camoufox fetch)';
282
283
  try {
283
284
  if (await new CamoufoxAdapter().isAvailable()) {
284
285
  return { engine: 'camoufox', fallbackWarning: null };
@@ -605,7 +606,7 @@ export class StealthBrowserManager {
605
606
  const available = await adapter.isAvailable();
606
607
  if (!available) {
607
608
  throw new Error(
608
- 'camoufox is not installed. Run: npm install camoufox to use the Firefox-based stealth engine.'
609
+ 'camoufox is not installed. Run: npm install camoufox, then npx camoufox fetch, to use the Firefox-based stealth engine.'
609
610
  );
610
611
  }
611
612
  // camoufox fixes its fingerprint — and, with geoip, its geolocation,
@@ -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
  }
@@ -3607,7 +3612,15 @@ export class CamoufoxAdapter extends BrowserEngine {
3607
3612
 
3608
3613
  async isAvailable() {
3609
3614
  try {
3610
- await this._load();
3615
+ const camoufox = await this._load();
3616
+ // The npm package ships without the Firefox binary, which comes from a
3617
+ // separate `npx camoufox fetch`. getLaunchPath() throws when it is
3618
+ // missing, and "auto" must then pick Chromium, not fail at launch.
3619
+ try {
3620
+ camoufox.getLaunchPath();
3621
+ } catch {
3622
+ return false;
3623
+ }
3611
3624
  return true;
3612
3625
  } catch (err) {
3613
3626
  if (this._isNotInstalled(err)) {
@@ -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',