crawlforge-mcp-server 6.1.0 → 6.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crawlforge-mcp-server",
3
- "version": "6.1.0",
3
+ "version": "6.3.0",
4
4
  "mcpName": "io.github.mysleekdesigns/crawlforge-mcp-server",
5
5
  "description": "CrawlForge MCP Server - Professional Model Context Protocol server with 30 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",
package/server.js CHANGED
@@ -23,7 +23,7 @@ import { ListOllamaModelsTool } from "./src/tools/extract/listOllamaModels.js";
23
23
  import { BatchScrapeTool } from "./src/tools/advanced/BatchScrapeTool.js";
24
24
  import { ScrapeWithActionsTool } from "./src/tools/advanced/ScrapeWithActionsTool.js";
25
25
  import { DeepResearchTool } from "./src/tools/research/deepResearch.js";
26
- import { TrackChangesTool } from "./src/tools/tracking/trackChanges/index.js";
26
+ import { TrackChangesTool, TRACK_CHANGES_INPUT_SHAPE } from "./src/tools/tracking/trackChanges/index.js";
27
27
  import { GenerateLLMsTxtTool } from "./src/tools/llmstxt/generateLLMsTxt.js";
28
28
  import { ScrapeTemplateTool } from "./src/tools/templates/ScrapeTemplateTool.js"; // D3.3
29
29
  import { UnifiedScrapeTool, SCRAPE_INPUT_SHAPE } from "./src/tools/scrape/unifiedScrape.js"; // D4 D1
@@ -107,7 +107,7 @@ if (configErrors.length > 0 && config.server.nodeEnv === 'production') {
107
107
  // Create the server
108
108
  const server = new McpServer({
109
109
  name: "crawlforge",
110
- version: "6.1.0",
110
+ version: "6.3.0",
111
111
  description: "Production-ready MCP server with 30 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.",
112
112
  homepage: "https://www.crawlforge.dev",
113
113
  icon: "https://www.crawlforge.dev/icon.png",
@@ -1138,103 +1138,11 @@ registerToolIfEnabled("agent", {
1138
1138
 
1139
1139
  // Tool: track_changes
1140
1140
  registerToolIfEnabled("track_changes", {
1141
- description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1141
+ description: "Use this to monitor a URL for content changes over time - competitor pricing, regulation updates, product availability. Start with operation:\"create_baseline\", then periodically use operation:\"compare\" to diff; repeated compare calls on the same URL are expected. Supports webhooks and scheduled monitoring, and scheduledMonitorOptions.hosted:true runs the monitor on CrawlForge's servers with email and signed webhooks. Not for a one-off read (scrape). Cost: 3 credits. Example: track_changes({url: \"https://example.com/pricing\", operation: \"create_baseline\"})",
1142
1142
  annotations: { title: "Track Changes", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1143
+ // The tool module owns the schema (G5); this is the same shape it validates with.
1143
1144
  inputSchema: {
1144
- url: z.string().url().optional().describe("The URL to track changes for (optional for list_scheduled_monitors)"),
1145
- operation: z.enum([
1146
- 'create_baseline', 'compare', 'monitor', 'get_history', 'get_stats',
1147
- 'create_scheduled_monitor', 'stop_scheduled_monitor', 'list_scheduled_monitors', 'get_dashboard',
1148
- 'export_history', 'create_alert_rule', 'generate_trend_report', 'get_monitoring_templates'
1149
- ]).default('compare').describe("Tracking operation to perform"),
1150
- content: z.string().optional().describe("Content to compare against baseline"),
1151
- html: z.string().optional().describe("HTML content to compare against baseline"),
1152
- trackingOptions: z.object({
1153
- granularity: z.enum(['page', 'section', 'element', 'text']).default('section'),
1154
- trackText: z.boolean().default(true),
1155
- trackStructure: z.boolean().default(true),
1156
- trackAttributes: z.boolean().default(false),
1157
- trackImages: z.boolean().default(false),
1158
- trackLinks: z.boolean().default(true),
1159
- ignoreWhitespace: z.boolean().default(true),
1160
- ignoreCase: z.boolean().default(false),
1161
- customSelectors: z.array(z.string()).optional(),
1162
- excludeSelectors: z.array(z.string()).optional(),
1163
- significanceThresholds: z.object({
1164
- minor: z.number().min(0).max(1).default(0.1),
1165
- moderate: z.number().min(0).max(1).default(0.3),
1166
- major: z.number().min(0).max(1).default(0.7)
1167
- }).optional()
1168
- }).optional().describe("Options for how changes are tracked and compared"),
1169
- monitoringOptions: z.object({
1170
- enabled: z.boolean().default(false),
1171
- interval: z.number().min(60000).max(24 * 60 * 60 * 1000).default(300000),
1172
- maxRetries: z.number().min(0).max(5).default(3),
1173
- retryDelay: z.number().min(1000).max(60000).default(5000),
1174
- notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).default('moderate'),
1175
- enableWebhook: z.boolean().default(false),
1176
- webhookUrl: z.string().url().optional(),
1177
- webhookSecret: z.string().optional()
1178
- }).optional().describe("Monitoring schedule and notification settings"),
1179
- storageOptions: z.object({
1180
- enableSnapshots: z.boolean().default(true),
1181
- retainHistory: z.boolean().default(true),
1182
- maxHistoryEntries: z.number().min(1).max(1000).default(100),
1183
- compressionEnabled: z.boolean().default(true),
1184
- deltaStorageEnabled: z.boolean().default(true)
1185
- }).optional().describe("Storage and history retention settings"),
1186
- queryOptions: z.object({
1187
- limit: z.number().min(1).max(500).default(50),
1188
- offset: z.number().min(0).default(0),
1189
- startTime: z.number().optional(),
1190
- endTime: z.number().optional(),
1191
- includeContent: z.boolean().default(false),
1192
- significanceFilter: z.enum(['all', 'minor', 'moderate', 'major', 'critical']).optional()
1193
- }).optional().describe("Query options for history and stats retrieval"),
1194
- notificationOptions: z.object({
1195
- webhook: z.object({
1196
- enabled: z.boolean().default(false),
1197
- url: z.string().url().optional(),
1198
- method: z.enum(['POST', 'PUT']).default('POST'),
1199
- headers: z.record(z.string()).optional(),
1200
- signingSecret: z.string().optional(),
1201
- includeContent: z.boolean().default(false)
1202
- }).optional(),
1203
- slack: z.object({
1204
- enabled: z.boolean().default(false),
1205
- webhookUrl: z.string().url().optional(),
1206
- channel: z.string().optional(),
1207
- username: z.string().optional()
1208
- }).optional()
1209
- }).optional().describe("Notification configuration for webhooks and Slack"),
1210
- scheduledMonitorOptions: z.object({
1211
- schedule: z.string().optional().describe("Optional cron expression (power users)"),
1212
- templateId: z.string().optional(),
1213
- enabled: z.boolean().default(true),
1214
- interval: z.number().min(60000).optional().describe("Polling interval in ms (default 1h)"),
1215
- goal: z.string().optional().describe("Plain-English alert goal; an LLM judges whether a change matches (degrades to threshold if no LLM)"),
1216
- monitorId: z.string().optional().describe("Monitor id for stop_scheduled_monitor"),
1217
- notificationThreshold: z.enum(['minor', 'moderate', 'major', 'critical']).optional()
1218
- }).optional().describe("Scheduled monitoring: recurring compare + notify, optional plain-English goal"),
1219
- alertRuleOptions: z.object({
1220
- ruleId: z.string().optional(),
1221
- condition: z.string().optional(),
1222
- actions: z.array(z.enum(['webhook', 'email', 'slack'])).optional(),
1223
- throttle: z.number().min(0).optional(),
1224
- priority: z.enum(['low', 'medium', 'high']).optional()
1225
- }).optional().describe("Alert rule configuration for change notifications"),
1226
- exportOptions: z.object({
1227
- format: z.enum(['json', 'csv']).default('json'),
1228
- startTime: z.number().optional(),
1229
- endTime: z.number().optional(),
1230
- includeContent: z.boolean().default(false),
1231
- includeSnapshots: z.boolean().default(false)
1232
- }).optional().describe("Export options for change history data"),
1233
- dashboardOptions: z.object({
1234
- includeRecentAlerts: z.boolean().default(true),
1235
- includeTrends: z.boolean().default(true),
1236
- includeMonitorStatus: z.boolean().default(true)
1237
- }).optional().describe("Dashboard display options"),
1145
+ ...TRACK_CHANGES_INPUT_SHAPE,
1238
1146
  ...COMPLIANCE_PARAMS
1239
1147
  }
1240
1148
  }, withAuth("track_changes", async (params) => {
@@ -61,19 +61,33 @@ export function register(program) {
61
61
  .option('--threshold <level>', 'Notification threshold: minor|moderate|major|critical', 'moderate')
62
62
  .option('--cron <expr>', 'Optional cron expression (advanced)')
63
63
  .option('--selector <css>', 'CSS selector to scope monitoring')
64
+ .option('--hosted', "Run the monitor on CrawlForge's servers (fires without this process; email + signed webhooks; 3 credits per compared target per check)")
65
+ .option('--email <addresses>', 'Comma-separated notification emails (sent by hosted monitors only)')
66
+ .option('--name <text>', 'Display name for a hosted monitor (default: the URL host)')
64
67
  .action(async (url, opts) => {
65
68
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
69
+ const notificationOptions = {
70
+ ...(opts.webhook ? { webhook: { enabled: true, url: opts.webhook } } : {}),
71
+ ...(opts.email ? { email: { enabled: true, recipients: opts.email.split(',').map((s) => s.trim()).filter(Boolean) } } : {})
72
+ };
73
+ if (opts.email && !opts.hosted) {
74
+ process.stderr.write('Warning: local monitors do not send email; add --hosted for --email to take effect.\n');
75
+ }
66
76
  try {
67
77
  const res = await tool.execute({
68
78
  url,
69
79
  operation: 'create_scheduled_monitor',
70
80
  ...(opts.selector ? { trackingOptions: { customSelectors: [opts.selector] } } : {}),
71
- ...(opts.webhook ? { notificationOptions: { webhook: { enabled: true, url: opts.webhook } } } : {}),
81
+ ...(Object.keys(notificationOptions).length ? { notificationOptions } : {}),
72
82
  scheduledMonitorOptions: {
73
83
  interval: Math.max(parseInt(opts.every, 10), 60) * 1000,
74
84
  ...(opts.goal ? { goal: opts.goal } : {}),
75
85
  ...(opts.cron ? { schedule: opts.cron } : {}),
76
- notificationThreshold: opts.threshold
86
+ ...(opts.hosted ? { hosted: true } : {}),
87
+ ...(opts.name ? { name: opts.name } : {}),
88
+ // Local only: a hosted check has no significance threshold, and
89
+ // the option's default would otherwise warn on every hosted create.
90
+ ...(opts.hosted ? {} : { notificationThreshold: opts.threshold })
77
91
  }
78
92
  });
79
93
  emit(res);
@@ -86,7 +100,7 @@ export function register(program) {
86
100
 
87
101
  program
88
102
  .command('monitor:list')
89
- .description('List persisted scheduled monitors')
103
+ .description('List scheduled monitors (local and hosted)')
90
104
  .action(async () => {
91
105
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
92
106
  try {
@@ -100,7 +114,7 @@ export function register(program) {
100
114
 
101
115
  program
102
116
  .command('monitor:stop <id>')
103
- .description('Stop and remove a scheduled monitor by id')
117
+ .description('Stop and remove a scheduled monitor by id (local or hosted)')
104
118
  .action(async (id) => {
105
119
  const tool = new TrackChangesTool(getToolConfig('track_changes'));
106
120
  try {
@@ -100,6 +100,62 @@ export function unverifiedValues(answer, sourceText) {
100
100
  */
101
101
  const CURRENT_STATE_RE = /\b(right now|currently|today|tonight|at the moment|as of (now|today)|latest|this (week|month|morning))\b|#\d+[^.?!]*\bnow\b/i;
102
102
 
103
+ /** Version numbers as they appear in an answer, e.g. "2.11.4". */
104
+ const VERSION_IN_TEXT = /\b\d+\.\d+(?:\.\d+){0,2}\b/g;
105
+
106
+ /** Hosts that publish user discussion rather than a project's own statements. */
107
+ const DISCUSSION_HOSTS = new Set([
108
+ 'news.ycombinator.com', 'reddit.com', 'www.reddit.com', 'quora.com', 'www.quora.com',
109
+ 'stackoverflow.com', 'serverfault.com', 'superuser.com', 'askubuntu.com',
110
+ 'facebook.com', 'www.facebook.com', 'twitter.com', 'x.com'
111
+ ]);
112
+
113
+ /**
114
+ * True when a URL is a discussion page — a forum, Q&A site or social post.
115
+ * Covers the hosted-forum conventions too: caddy.community, community.acme.io,
116
+ * forum.example.org, discuss.example.org, anything.stackexchange.com.
117
+ */
118
+ export function isDiscussionSource(url) {
119
+ try {
120
+ const host = new URL(url).hostname.toLowerCase();
121
+ return DISCUSSION_HOSTS.has(host)
122
+ || /(^|\.)(community|forum|forums|discuss|discourse)\./.test(host)
123
+ || /\.(community|forum)$/.test(host)
124
+ || /\.stackexchange\.com$/.test(host);
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** True when the task asks for a version or release number. */
131
+ export function isVersionQuestion(prompt) {
132
+ return /\b(version|versions|release|releases|released|changelog)\b/i.test(prompt || '');
133
+ }
134
+
135
+ /**
136
+ * Version numbers the answer states that no non-discussion source supports.
137
+ *
138
+ * A forum thread carries real version strings, so the literal provenance check
139
+ * passes them: asked for the current Caddy release, the agent answered "2.4.3"
140
+ * off a caddy.community thread about a different project, while caddyserver.com
141
+ * and the GitHub repo — both fetched — stated no version at all (R19). A
142
+ * version is a claim about the project, and a discussion page is not the
143
+ * project speaking.
144
+ *
145
+ * Returns [] when nothing but discussion pages was fetched: there is then no
146
+ * better source to have preferred, and the provenance check already applies.
147
+ */
148
+ export function unsupportedVersionClaims(answer, evidence) {
149
+ if (!answer) return [];
150
+ // URLs are citations, not claims — a version inside one is not an assertion.
151
+ const claims = [...new Set(answer.replace(/https?:\/\/\S+/g, ' ').match(VERSION_IN_TEXT) || [])];
152
+ if (claims.length === 0) return [];
153
+ const authoritative = (evidence || []).filter(e => !isDiscussionSource(e.url));
154
+ if (authoritative.length === 0) return [];
155
+ const supported = authoritative.map(e => e.text || '').join('\n');
156
+ return claims.filter(version => !supported.includes(version));
157
+ }
158
+
103
159
  export function isCurrentStateTask(prompt) {
104
160
  return CURRENT_STATE_RE.test(prompt || '');
105
161
  }
@@ -459,6 +515,10 @@ export class AgentOrchestrator {
459
515
  let degraded = false;
460
516
  let degradedReason;
461
517
  let unverified = [];
518
+ let forumOnlyVersions = [];
519
+ // Distinct from `degraded`: a run can be degraded because its version
520
+ // claims are unsupported and still have had its provenance checked.
521
+ let synthesisFailed = false;
462
522
 
463
523
  try {
464
524
  // Wording matters for small local models (llama3.2-class): without the
@@ -515,8 +575,49 @@ export class AgentOrchestrator {
515
575
  `\n\nProvenance warning: ${unverified.map(v => `"${v}"`).join(', ')} ` +
516
576
  `${unverified.length === 1 ? 'does' : 'do'} not appear in the fetched sources and may be invented.`;
517
577
  }
578
+
579
+ // Asked for a current version, the agent answered "2.4.3" from a forum
580
+ // thread about a different project while the project's own site and its
581
+ // GitHub repo — both fetched — stated no version at all (R19). The
582
+ // literal provenance check cannot catch this: the string really is on a
583
+ // fetched page. A version only a discussion page supports gets the same
584
+ // one corrective rewrite, and what survives it is flagged rather than
585
+ // presented as the answer.
586
+ if (currentState && isVersionQuestion(prompt)) {
587
+ forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
588
+ if (forumOnlyVersions.length > 0) {
589
+ const versionRetryPrompt =
590
+ `${synthesisPrompt}\n\nYour previous answer was:\n${fenceUntrusted(answer, 'previous answer')}\n` +
591
+ `It states ${forumOnlyVersions.map(v => `"${v}"`).join(', ')}, which ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
592
+ `only in forum or discussion pages, not in the project's own pages among the sources. ` +
593
+ `A forum post about a different project is not a release announcement. ` +
594
+ `Rewrite the answer using only versions stated by the project's own sources. ` +
595
+ `If none of them states a current version, say plainly that the fetched sources do not state one.`;
596
+ try {
597
+ const { text: rewritten } = await this._getSamplingClient().complete(versionRetryPrompt, { maxTokens: 1024 });
598
+ if (rewritten && rewritten.trim()) {
599
+ answer = rewritten;
600
+ forumOnlyVersions = unsupportedVersionClaims(answer, evidence);
601
+ }
602
+ } catch {
603
+ // Keep the previous answer; it is flagged below.
604
+ }
605
+ }
606
+ if (forumOnlyVersions.length > 0) {
607
+ answer +=
608
+ `\n\nSource warning: ${forumOnlyVersions.map(v => `"${v}"`).join(', ')} ` +
609
+ `${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} only in discussion pages among the fetched sources, ` +
610
+ `not in the project's own. Treat ${forumOnlyVersions.length === 1 ? 'it' : 'them'} as unconfirmed.`;
611
+ degraded = true;
612
+ degradedReason =
613
+ `Version claim unsupported by the project's own sources: ` +
614
+ `${forumOnlyVersions.map(v => `"${v}"`).join(', ')} ${forumOnlyVersions.length === 1 ? 'appears' : 'appear'} ` +
615
+ `only in discussion pages. The authoritative pages fetched state no version.`;
616
+ }
617
+ }
518
618
  } catch (err) {
519
619
  degraded = true;
620
+ synthesisFailed = true;
520
621
  degradedReason = `LLM synthesis unavailable: ${err.message}`;
521
622
  // Return raw evidence so the host LLM can synthesize
522
623
  answer = null;
@@ -531,7 +632,11 @@ export class AgentOrchestrator {
531
632
  reason: degradedReason,
532
633
  steps: step,
533
634
  urls_fetched: urlsFetched,
534
- provenance: { checked: !degraded, unverified }
635
+ provenance: {
636
+ checked: !synthesisFailed,
637
+ unverified,
638
+ ...(forumOnlyVersions.length > 0 ? { unsupported_versions: forumOnlyVersions } : {})
639
+ }
535
640
  };
536
641
  }
537
642
 
@@ -2000,7 +2000,13 @@ export class ResearchOrchestrator extends EventEmitter {
2000
2000
  sessionId: this.researchState.sessionId,
2001
2001
  topic,
2002
2002
  synthesisMode: 'raw_evidence',
2003
- note: "This response contains raw research evidence with no AI synthesis. The calling LLM (you) should synthesize these sources to answer the user's question. To enable internal LLM synthesis instead, set OPENAI_API_KEY or ANTHROPIC_API_KEY in the MCP server environment.",
2003
+ // Raw evidence has two quite different causes and the note used to
2004
+ // report only one of them: a run that synthesized fine until its
2005
+ // token budget ran out was still told to set an API key it already
2006
+ // had (R19). Say which one actually happened.
2007
+ note: this.researchState.tokenBudgetExceeded
2008
+ ? `This response contains raw research evidence with no AI synthesis. Synthesis ran until the research token budget (${this.researchState.tokenBudgetChars.toLocaleString()} chars of source content) was exhausted, then stopped for the rest of the session. The calling LLM (you) should synthesize these sources. To get internal synthesis, narrow the topic or lower maxUrls, or raise RESEARCH_TOKEN_BUDGET_CHARS in the MCP server environment.`
2009
+ : "This response contains raw research evidence with no AI synthesis. The calling LLM (you) should synthesize these sources to answer the user's question. To enable internal LLM synthesis instead, set OPENAI_API_KEY or ANTHROPIC_API_KEY in the MCP server environment.",
2004
2010
  sources,
2005
2011
  findings: [],
2006
2012
  researchSummary: {
@@ -143,6 +143,26 @@ export class ContentAnalyzer {
143
143
  return this.cjkScriptCounts(text).share >= 0.1;
144
144
  }
145
145
 
146
+ /**
147
+ * True when the text is written mostly in the Latin alphabet.
148
+ *
149
+ * The analyser's entity extraction (compromise) and its syllable counter are
150
+ * both English/Latin-only. Run over Russian they do not degrade, they invent:
151
+ * "дождь и порывистый" ("rain and gusty") came back as an organization, and
152
+ * every Cyrillic word counted as one syllable, which put a weather report at
153
+ * Flesch 100, "Very Easy" (R19). Text with too few Latin letters to work on
154
+ * is reported as not-applicable rather than analysed anyway.
155
+ *
156
+ * @param {string} text - Text to analyze
157
+ * @returns {boolean}
158
+ */
159
+ isLatinScriptText(text) {
160
+ const letters = (text || '').match(/\p{L}/gu);
161
+ if (!letters || letters.length === 0) return true; // nothing to misread
162
+ const latin = (text || '').match(/\p{Script=Latin}/gu);
163
+ return (latin ? latin.length : 0) / letters.length >= 0.5;
164
+ }
165
+
146
166
  /**
147
167
  * Tokenize text into words with Intl.Segmenter (dictionary-based for CJK
148
168
  * scripts, whitespace/boundary-based otherwise). Only word-like segments
@@ -534,6 +554,17 @@ export class ContentAnalyzer {
534
554
  */
535
555
  async extractEntities(text, options = {}) {
536
556
  try {
557
+ // compromise is an English model. On Russian it does not find fewer
558
+ // entities, it finds wrong ones — "дождь и порывистый" as an
559
+ // organization (R19). Say so rather than fill the categories with noise.
560
+ if (!this.isLatinScriptText(text)) {
561
+ return {
562
+ people: [], places: [], organizations: [], dates: [], money: [], other: [],
563
+ notApplicable: 'entity-extraction-requires-latin-script',
564
+ summary: { totalEntities: 0, uniqueEntities: 0, entityDensity: 0 }
565
+ };
566
+ }
567
+
537
568
  const doc = nlp(text);
538
569
 
539
570
  // compromise's out('array') keeps adjoining punctuation ("Craigslist.",
@@ -754,10 +785,13 @@ export class ContentAnalyzer {
754
785
  syllables: totalSyllables
755
786
  };
756
787
 
757
- // Flesch is syllable-based and says nothing about CJK scripts. Report the
758
- // metrics with an explicit reason instead of a fabricated score a null
759
- // return above already means "failed", so the two stay distinguishable.
760
- if (isCjk) {
788
+ // Flesch is syllable-based, and countSyllables() only knows the Latin
789
+ // vowels every Cyrillic, Greek or Arabic word scores one syllable, so
790
+ // the formula returns ~100 ("Very Easy") for any of them (R19). Report
791
+ // the metrics with an explicit reason instead of a fabricated score — a
792
+ // null return above already means "failed", so the two stay
793
+ // distinguishable.
794
+ if (isCjk || !this.isLatinScriptText(text)) {
761
795
  return {
762
796
  notApplicable: 'flesch-requires-syllable-based-language',
763
797
  metrics
@@ -1070,16 +1104,38 @@ export class ContentAnalyzer {
1070
1104
  * @returns {boolean} - True if stop word
1071
1105
  */
1072
1106
  isStopWord(word) {
1073
- const stopWords = [
1074
- 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
1075
- 'from', 'as', 'is', 'was', 'are', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
1076
- 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can',
1077
- 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me',
1078
- 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their'
1079
- ];
1080
-
1081
- return stopWords.includes(word.toLowerCase());
1107
+ return STOP_WORDS.has(word.toLowerCase());
1082
1108
  }
1083
1109
  }
1084
1110
 
1111
+ /**
1112
+ * Stop words, by script. Russian was added after a weather report analysed with
1113
+ * `analyze_content` returned bare prepositions and conjunctions among its
1114
+ * topics — "над" ("over") ranked as one (R19). R17 had added Japanese the same
1115
+ * way; every language whose function words are not on this list gets them back
1116
+ * as topics.
1117
+ */
1118
+ const STOP_WORDS = new Set([
1119
+ // English
1120
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by',
1121
+ 'from', 'as', 'is', 'was', 'are', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
1122
+ 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can',
1123
+ 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me',
1124
+ 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'its', 'our', 'their',
1125
+ // Russian — prepositions, conjunctions, particles and pronouns
1126
+ 'и', 'в', 'во', 'не', 'что', 'он', 'на', 'я', 'с', 'со', 'как', 'а', 'то', 'все', 'она',
1127
+ 'так', 'его', 'но', 'да', 'ты', 'к', 'у', 'же', 'вы', 'за', 'бы', 'по', 'только', 'ее',
1128
+ 'мне', 'было', 'вот', 'от', 'меня', 'еще', 'нет', 'о', 'из', 'ему', 'теперь', 'когда',
1129
+ 'даже', 'ну', 'вдруг', 'ли', 'если', 'уже', 'или', 'ни', 'быть', 'был', 'него', 'до',
1130
+ 'вас', 'нибудь', 'опять', 'уж', 'вам', 'ведь', 'там', 'потом', 'себя', 'ничего', 'ей',
1131
+ 'может', 'они', 'тут', 'где', 'есть', 'надо', 'ней', 'для', 'мы', 'тебя', 'их', 'чем',
1132
+ 'была', 'сам', 'чтоб', 'без', 'будто', 'чего', 'раз', 'тоже', 'себе', 'под', 'будет',
1133
+ 'ж', 'тогда', 'кто', 'этот', 'того', 'потому', 'этого', 'какой', 'совсем', 'ним',
1134
+ 'здесь', 'этом', 'один', 'почти', 'мой', 'тем', 'чтобы', 'нее', 'были', 'куда', 'зачем',
1135
+ 'всех', 'никогда', 'можно', 'при', 'наконец', 'два', 'об', 'другой', 'хоть', 'после',
1136
+ 'над', 'больше', 'тот', 'через', 'эти', 'нас', 'про', 'всего', 'них', 'какая', 'много',
1137
+ 'разве', 'три', 'эту', 'моя', 'впрочем', 'свою', 'этой', 'перед', 'иногда', 'лучше',
1138
+ 'чуть', 'том', 'нельзя', 'такой', 'им', 'более', 'всегда', 'конечно', 'всю', 'между'
1139
+ ]);
1140
+
1085
1141
  export default ContentAnalyzer;
@@ -1,10 +1,32 @@
1
1
  import { OpenAIProvider } from './OpenAIProvider.js';
2
2
  import { extractionFormat } from '../../utils/extractionFormat.js';
3
+ import { validateFieldsAgainstSchema } from '../../utils/schemaValidate.js';
3
4
  import { AnthropicProvider } from './AnthropicProvider.js';
4
5
  import { OllamaProvider } from './OllamaProvider.js';
5
6
  import { Logger } from '../../utils/Logger.js';
6
7
  import { isJudgementModel } from '../../utils/ollamaConfig.js';
7
8
 
9
+ // Output-token ceilings for structured extraction. A schema of scalar fields
10
+ // answers in a few hundred tokens; one asking for an array is writing rows,
11
+ // and the same ceiling truncates it mid-object.
12
+ const SCALAR_OUTPUT_CEILING = 2000;
13
+ const ROW_OUTPUT_CEILING = 4000;
14
+ const ROW_FIELD_ALLOWANCE = 1200;
15
+
16
+ /**
17
+ * True when a JSON string that failed to parse simply stops partway — the
18
+ * signature of a response that hit its output-token limit, as opposed to one
19
+ * that is malformed from the start.
20
+ */
21
+ function endsMidJson(text) {
22
+ const trimmed = text.trim();
23
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return false;
24
+ // A complete document ends on its closing brace or bracket. Anything else
25
+ // — a dangling comma, an unterminated string, a half-written key — means
26
+ // the writer stopped rather than finished.
27
+ return !trimmed.endsWith('}') && !trimmed.endsWith(']');
28
+ }
29
+
8
30
  /**
9
31
  * LLM Manager
10
32
  * Manages multiple LLM providers and provides unified interface
@@ -916,9 +938,17 @@ Return the list and nothing else.`;
916
938
  ? content.substring(0, maxContentLength) + '...'
917
939
  : content;
918
940
 
919
- // Scale maxTokens with schema complexity
920
- const schemaFields = Object.keys(schema.properties || {}).length;
921
- const scaledTokens = Math.min(2000, Math.max(maxTokens, schemaFields * 100 + 500));
941
+ // Scale maxTokens with schema complexity. An array-valued property is one
942
+ // key but many rows of output, so counting keys alone budgets a 250-row
943
+ // table exactly like a single string: the response stops mid-object and
944
+ // the whole extraction is thrown away as unparseable (R19).
945
+ const fields = Object.values(schema.properties || {});
946
+ const arrayFields = fields.filter((field) => field?.type === 'array').length;
947
+ const ceiling = arrayFields > 0 ? ROW_OUTPUT_CEILING : SCALAR_OUTPUT_CEILING;
948
+ const scaledTokens = Math.min(
949
+ ceiling,
950
+ Math.max(maxTokens, fields.length * 100 + arrayFields * ROW_FIELD_ALLOWANCE + 500)
951
+ );
922
952
 
923
953
  const systemPrompt = `You are a structured data extraction expert. Extract data from the provided content and return ONLY valid JSON that conforms to the given JSON Schema. Use null for any field the content does not state — never guess, infer, or fill a value from memory. Do not include any explanation or markdown — only the raw JSON object.`;
924
954
 
@@ -933,76 +963,74 @@ ${truncatedContent}
933
963
 
934
964
  Extract the data and return valid JSON:`;
935
965
 
936
- try {
937
- const response = await this.generateCompletion(extractionPrompt, {
938
- systemPrompt,
939
- maxTokens: scaledTokens,
940
- temperature: 0.1,
941
- // Constrain the output to the caller's shape with every field
942
- // nullable, so a model shown content that does not state a field can
943
- // answer null instead of being decoded into an invented string. Small
944
- // local models otherwise wrap the JSON in prose and the parse throws.
945
- format: extractionFormat(schema)
946
- });
966
+ // Two attempts, the second with twice the budget: the failure this guards
967
+ // against is a response cut off mid-object, which re-asking at the same
968
+ // size reproduces exactly. Mirrors the retry in synthesizeFindings().
969
+ let lastError;
970
+ for (let attempt = 0; attempt < 2; attempt++) {
971
+ const budget = attempt === 0 ? scaledTokens : Math.min(ceiling * 2, scaledTokens * 2);
972
+ try {
973
+ const response = await this.generateCompletion(extractionPrompt, {
974
+ systemPrompt,
975
+ maxTokens: budget,
976
+ temperature: 0.1,
977
+ // Constrain the output to the caller's shape with every field
978
+ // nullable, so a model shown content that does not state a field can
979
+ // answer null instead of being decoded into an invented string. Small
980
+ // local models otherwise wrap the JSON in prose and the parse throws.
981
+ format: extractionFormat(schema)
982
+ });
983
+
984
+ // Strip markdown code fences if present
985
+ const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
986
+ let parsed;
987
+ try {
988
+ parsed = JSON.parse(cleaned);
989
+ } catch (parseError) {
990
+ // Name the cause when the JSON simply stops: "unexpected end of
991
+ // input at position 2608" tells a caller nothing they can act on,
992
+ // whereas "cut off at the 1000-token limit" points at the schema.
993
+ throw endsMidJson(cleaned)
994
+ ? new Error(`model response was cut off at the ${budget}-token output limit (${cleaned.length} chars) — the schema asks for more rows than fit`)
995
+ : parseError;
996
+ }
947
997
 
948
- // Strip markdown code fences if present
949
- const cleaned = response.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim();
950
- const parsed = JSON.parse(cleaned);
951
-
952
- // Lightweight validation
953
- const validation = this.validateAgainstSchema(parsed, schema);
954
- return {
955
- data: parsed,
956
- method: 'llm',
957
- valid: validation.valid,
958
- validationErrors: validation.errors
959
- };
960
- } catch (error) {
961
- this.logger.warn('LLM structured extraction failed, using fallback', { error: error.message });
962
- // Report which path produced the data. Callers previously labelled this
963
- // result "llm", so a failed LLM call was returned as a high-confidence
964
- // LLM extraction.
965
- return { ...this.fallbackStructuredExtraction(content, schema), error: error.message };
998
+ const validation = this.validateAgainstSchema(parsed, schema);
999
+ return {
1000
+ data: parsed,
1001
+ method: 'llm',
1002
+ valid: validation.valid,
1003
+ validationErrors: validation.errors
1004
+ };
1005
+ } catch (error) {
1006
+ lastError = error;
1007
+ this.logger.warn('LLM structured extraction attempt failed', {
1008
+ attempt: attempt + 1,
1009
+ budget,
1010
+ error: error.message
1011
+ });
1012
+ }
966
1013
  }
1014
+
1015
+ this.logger.warn('LLM structured extraction failed, using fallback', { error: lastError.message });
1016
+ // Report which path produced the data. Callers previously labelled this
1017
+ // result "llm", so a failed LLM call was returned as a high-confidence
1018
+ // LLM extraction.
1019
+ return { ...this.fallbackStructuredExtraction(content, schema), error: lastError.message };
967
1020
  }
968
1021
 
969
1022
  /**
970
- * Validate a parsed object against a simple JSON Schema
1023
+ * Validate a parsed object against a JSON Schema.
1024
+ *
1025
+ * Delegates to the shared validator so that it descends into `items` and
1026
+ * nested `properties`. This used to be a hand-rolled one-level check, which
1027
+ * passed `{countries: ["a string"]}` against
1028
+ * `{countries: {type: 'array', items: {type: 'object'}}}` as valid — the
1029
+ * top-level value really was an array, and nothing looked inside it, so an
1030
+ * extraction full of stray page text was returned as `valid: true` (R19).
971
1031
  */
972
1032
  validateAgainstSchema(data, schema) {
973
- const errors = [];
974
- const properties = schema.properties || {};
975
- const required = schema.required || [];
976
-
977
- for (const field of required) {
978
- // The decoder is told to answer null for a field the content never
979
- // states, so a null here is "not filled in", the same as absent.
980
- if (!(field in data) || data[field] === null) {
981
- errors.push(`Missing required field: ${field}`);
982
- }
983
- }
984
-
985
- for (const [key, fieldSchema] of Object.entries(properties)) {
986
- if (key in data) {
987
- const value = data[key];
988
- // A null in a field the schema does not require is the honest answer
989
- // for content that never states it, not a type error (typeof null is
990
- // 'object', which used to read as "expected number, got object").
991
- if (value === null) continue;
992
- const expectedType = fieldSchema.type;
993
- if (expectedType) {
994
- const actualType = Array.isArray(value) ? 'array' : typeof value;
995
- if (actualType !== expectedType) {
996
- errors.push(`Field "${key}": expected ${expectedType}, got ${actualType}`);
997
- }
998
- }
999
- if (fieldSchema.enum && !fieldSchema.enum.includes(value)) {
1000
- errors.push(`Field "${key}": value "${value}" not in enum ${JSON.stringify(fieldSchema.enum)}`);
1001
- }
1002
- }
1003
- }
1004
-
1005
- return { valid: errors.length === 0, errors };
1033
+ return validateFieldsAgainstSchema(data, schema);
1006
1034
  }
1007
1035
 
1008
1036
  /**