euparliamentmonitor 0.8.30 → 0.8.31
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/README.md +1 -1
- package/package.json +3 -3
- package/scripts/generators/motions-content.js +6 -2
- package/scripts/generators/news-enhanced.js +4 -3
- package/scripts/generators/pipeline/fetch-stage.js +8 -9
- package/scripts/generators/sitemap.js +4 -4
- package/scripts/generators/strategies/committee-reports-strategy.js +1 -1
- package/scripts/mcp/ep-mcp-client.d.ts +10 -14
- package/scripts/mcp/ep-mcp-client.js +24 -61
- package/scripts/mcp/mcp-connection.js +20 -7
- package/scripts/types/mcp.d.ts +60 -21
- package/scripts/utils/content-validator.js +436 -1
- package/scripts/utils/file-utils.js +11 -7
- package/scripts/utils/political-risk-assessment.js +3 -0
- package/scripts/utils/world-bank-data.js +3 -3
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ import {
|
|
|
124
124
|
|
|
125
125
|
**MCP Server Integration**: The project uses the
|
|
126
126
|
[European-Parliament-MCP-Server](https://github.com/Hack23/European-Parliament-MCP-Server)
|
|
127
|
-
v1.2.
|
|
127
|
+
v1.2.7 for accessing real EU Parliament data via the Model Context Protocol.
|
|
128
128
|
|
|
129
129
|
- **MCP Server Status**: ✅ Fully operational — 60+ EP data tools available
|
|
130
130
|
(feeds, direct lookups, analytical tools, intelligence correlation)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "euparliamentmonitor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.31",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "European Parliament Intelligence Platform - Monitor political activity with systematic transparency",
|
|
6
6
|
"main": "scripts/index.js",
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
"jscpd": "4.0.9",
|
|
160
160
|
"lint-staged": "16.4.0",
|
|
161
161
|
"papaparse": "5.5.3",
|
|
162
|
-
"prettier": "3.8.
|
|
162
|
+
"prettier": "3.8.3",
|
|
163
163
|
"ts-api-utils": "2.5.0",
|
|
164
164
|
"tsx": "4.21.0",
|
|
165
165
|
"typedoc": "0.28.19",
|
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
"node": ">=25"
|
|
171
171
|
},
|
|
172
172
|
"dependencies": {
|
|
173
|
-
"european-parliament-mcp-server": "1.2.
|
|
173
|
+
"european-parliament-mcp-server": "1.2.7"
|
|
174
174
|
},
|
|
175
175
|
"optionalDependencies": {
|
|
176
176
|
"worldbank-mcp": "1.0.1"
|
|
@@ -349,9 +349,13 @@ export function buildAdoptedTextsSection(adoptedTexts, language) {
|
|
|
349
349
|
if (adoptedTexts.length === 0)
|
|
350
350
|
return '';
|
|
351
351
|
const heading = ADOPTED_TEXTS_HEADINGS[language] ?? ADOPTED_TEXTS_HEADINGS['en'] ?? 'Recently Adopted Texts';
|
|
352
|
-
const countFn = ADOPTED_TEXTS_COUNT_STRINGS[language] ??
|
|
352
|
+
const countFn = ADOPTED_TEXTS_COUNT_STRINGS[language] ??
|
|
353
|
+
ADOPTED_TEXTS_COUNT_STRINGS['en'] ??
|
|
354
|
+
((n) => `${n} adopted texts`);
|
|
353
355
|
const countText = countFn(adoptedTexts.length);
|
|
354
|
-
const unknownDate = ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS[language] ??
|
|
356
|
+
const unknownDate = ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS[language] ??
|
|
357
|
+
ADOPTED_TEXTS_DATE_UNKNOWN_STRINGS['en'] ??
|
|
358
|
+
'Unknown date';
|
|
355
359
|
// Group by date, sort most recent first
|
|
356
360
|
const byDate = new Map();
|
|
357
361
|
for (const item of adoptedTexts) {
|
|
@@ -118,8 +118,9 @@ let languagesInput = languagesArg
|
|
|
118
118
|
? (languagesArg.split(ARG_SEPARATOR)[1] ?? '').trim().toLowerCase()
|
|
119
119
|
: 'en';
|
|
120
120
|
// Expand presets
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
const presetLanguages = LANGUAGE_PRESETS[languagesInput];
|
|
122
|
+
if (presetLanguages) {
|
|
123
|
+
languagesInput = presetLanguages.join(',');
|
|
123
124
|
}
|
|
124
125
|
const languages = languagesInput
|
|
125
126
|
.split(',')
|
|
@@ -402,7 +403,7 @@ function wireAIMetadata() {
|
|
|
402
403
|
*/
|
|
403
404
|
export function computeDedupSuffix(articleTypes, analysisDir) {
|
|
404
405
|
const baseSlugNoRun = deriveArticleTypeSlug(articleTypes.filter((t) => VALID_ARTICLE_CATEGORIES.includes(t)));
|
|
405
|
-
const rawSuffix = analysisDir
|
|
406
|
+
const rawSuffix = analysisDir?.startsWith(baseSlugNoRun)
|
|
406
407
|
? analysisDir.slice(baseSlugNoRun.length)
|
|
407
408
|
: '';
|
|
408
409
|
// Suffix validation patterns for dedup suffix extraction.
|
|
@@ -427,16 +427,16 @@ export async function fetchWeekAheadData(client, dateRange) {
|
|
|
427
427
|
const wasHalfOpen = mcpCircuitBreaker.getState() === 'HALF_OPEN';
|
|
428
428
|
console.log(`${MCP_FETCH_PREFIX} Fetching week-ahead data from MCP (parallel)...`);
|
|
429
429
|
const [plenarySessions, committeeInfo, documents, pipeline, questions, epEvents] = await Promise.allSettled([
|
|
430
|
-
client.getPlenarySessions({
|
|
431
|
-
client.getCommitteeInfo({
|
|
432
|
-
client.searchDocuments({
|
|
430
|
+
client.getPlenarySessions({ dateFrom: dateRange.start, dateTo: dateRange.end, limit: 50 }),
|
|
431
|
+
client.getCommitteeInfo({ showCurrent: true }),
|
|
432
|
+
client.searchDocuments({ keyword: 'parliament', limit: 20 }),
|
|
433
433
|
client.monitorLegislativePipeline({
|
|
434
434
|
dateFrom: dateRange.start,
|
|
435
435
|
dateTo: dateRange.end,
|
|
436
436
|
status: 'ACTIVE',
|
|
437
437
|
limit: 20,
|
|
438
438
|
}),
|
|
439
|
-
client.getParliamentaryQuestions({
|
|
439
|
+
client.getParliamentaryQuestions({ dateFrom: dateRange.start, limit: 20 }),
|
|
440
440
|
client.getEvents({ dateFrom: dateRange.start, dateTo: dateRange.end, limit: 20 }),
|
|
441
441
|
]);
|
|
442
442
|
const allFailed = [
|
|
@@ -459,7 +459,7 @@ export async function fetchWeekAheadData(client, dateRange) {
|
|
|
459
459
|
const additionalEvents = parseEPEvents(epEvents, dateRange.start);
|
|
460
460
|
const events = [...plenaryEvents, ...additionalEvents];
|
|
461
461
|
return {
|
|
462
|
-
events: events.length > 0 ? events :
|
|
462
|
+
events: events.length > 0 ? events : PLACEHOLDER_EVENTS.map((e) => ({ ...e, date: dateRange.start })),
|
|
463
463
|
committees: parseCommitteeMeetings(committeeInfo, dateRange.start),
|
|
464
464
|
documents: parseLegislativeDocuments(documents),
|
|
465
465
|
pipeline: parseLegislativePipeline(pipeline),
|
|
@@ -728,7 +728,7 @@ export async function fetchCommitteeData(client, abbreviation) {
|
|
|
728
728
|
return defaultResult;
|
|
729
729
|
try {
|
|
730
730
|
console.log(`${MCP_FETCH_PREFIX} Fetching committee info for ${abbreviation}...`);
|
|
731
|
-
const committeeResult = await callMCP(() => client.getCommitteeInfo({
|
|
731
|
+
const committeeResult = await callMCP(() => client.getCommitteeInfo({ abbreviation }), null, `getCommitteeInfo(${abbreviation})`);
|
|
732
732
|
if (committeeResult)
|
|
733
733
|
applyCommitteeInfo(committeeResult, defaultResult, abbreviation);
|
|
734
734
|
}
|
|
@@ -738,7 +738,7 @@ export async function fetchCommitteeData(client, abbreviation) {
|
|
|
738
738
|
}
|
|
739
739
|
try {
|
|
740
740
|
console.log(`${MCP_FETCH_PREFIX} Fetching documents for ${abbreviation}...`);
|
|
741
|
-
const docsResult = await callMCP(() => client.searchDocuments({
|
|
741
|
+
const docsResult = await callMCP(() => client.searchDocuments({ keyword: abbreviation, limit: 5 }), null, `searchDocuments(${abbreviation})`);
|
|
742
742
|
if (docsResult)
|
|
743
743
|
applyDocuments(docsResult, defaultResult);
|
|
744
744
|
}
|
|
@@ -1062,8 +1062,7 @@ const TIMEFRAME_FALLBACK_CHAIN = new Map([
|
|
|
1062
1062
|
['one-day', 'one-week'],
|
|
1063
1063
|
['one-week', 'one-month'],
|
|
1064
1064
|
['one-month', undefined],
|
|
1065
|
-
['
|
|
1066
|
-
['one-year', undefined],
|
|
1065
|
+
['custom', undefined],
|
|
1067
1066
|
]);
|
|
1068
1067
|
/**
|
|
1069
1068
|
* Get the next wider timeframe for fallback, or `undefined` if no fallback exists.
|
|
@@ -49,7 +49,7 @@ export function collectDocsHtmlFiles(dir, rootDir = PROJECT_ROOT) {
|
|
|
49
49
|
*/
|
|
50
50
|
export function generateSitemap(articles, docsFiles = []) {
|
|
51
51
|
const urls = [];
|
|
52
|
-
const today = new Date().toISOString().
|
|
52
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
53
53
|
// Add home pages for each language
|
|
54
54
|
for (const lang of ALL_LANGUAGES) {
|
|
55
55
|
const filename = lang === 'en' ? 'index.html' : `index-${lang}.html`;
|
|
@@ -301,8 +301,8 @@ export function generateSitemapHTML(lang, articleInfos, hasDocsDir = false) {
|
|
|
301
301
|
const skipLinkText = getLocalizedString(SKIP_LINK_TEXTS, lang);
|
|
302
302
|
const dir = getTextDirection(lang);
|
|
303
303
|
const year = new Date().getFullYear();
|
|
304
|
-
const sections = SITEMAP_SECTIONS[lang] ?? SITEMAP_SECTIONS['en'];
|
|
305
|
-
const docsLabels = DOCS_LABELS[lang] ?? DOCS_LABELS['en'];
|
|
304
|
+
const sections = (SITEMAP_SECTIONS[lang] ?? SITEMAP_SECTIONS['en']);
|
|
305
|
+
const docsLabels = (DOCS_LABELS[lang] ?? DOCS_LABELS['en']);
|
|
306
306
|
const heroTitle = getLocalizedString(PAGE_TITLES, lang).split(' - ')[0] ?? '';
|
|
307
307
|
const headerSubtitle = escapeHTML(getLocalizedString(HEADER_SUBTITLE_LABELS, lang));
|
|
308
308
|
const themeToggleLabel = escapeHTML(getLocalizedString(THEME_TOGGLE_LABELS, lang));
|
|
@@ -539,7 +539,7 @@ function main() {
|
|
|
539
539
|
// Generate sitemap HTML for each language
|
|
540
540
|
let htmlGenerated = 0;
|
|
541
541
|
for (const lang of ALL_LANGUAGES) {
|
|
542
|
-
const langArticles = articlesByLang.get(lang)
|
|
542
|
+
const langArticles = articlesByLang.get(lang) ?? [];
|
|
543
543
|
// Sort newest first
|
|
544
544
|
langArticles.sort((a, b) => b.date.localeCompare(a.date));
|
|
545
545
|
const html = generateSitemapHTML(lang, langArticles, hasDocsDir);
|
|
@@ -249,7 +249,7 @@ function buildAdoptedTextsSection(feedData, lang) {
|
|
|
249
249
|
const sections = displayOrder
|
|
250
250
|
.filter((cat) => grouped[cat]?.length)
|
|
251
251
|
.map((cat) => {
|
|
252
|
-
const items = grouped[cat];
|
|
252
|
+
const items = grouped[cat] ?? [];
|
|
253
253
|
const listItems = items
|
|
254
254
|
.map((item) => `<li class="adopted-text-item"><strong>${escapeHTML(item.title)}</strong> <span class="document-date">(${escapeHTML(item.date)})</span></li>`)
|
|
255
255
|
.join('\n ');
|
|
@@ -70,32 +70,28 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
70
70
|
/**
|
|
71
71
|
* Get plenary sessions
|
|
72
72
|
*
|
|
73
|
-
* @param options - Filter options
|
|
74
|
-
* per the tool schema when the canonical fields are absent.
|
|
73
|
+
* @param options - Filter options including dateFrom, dateTo, eventId, year, location
|
|
75
74
|
* @returns Plenary sessions data
|
|
76
75
|
*/
|
|
77
76
|
getPlenarySessions(options?: GetPlenarySessionsOptions): Promise<MCPToolResult>;
|
|
78
77
|
/**
|
|
79
78
|
* Search legislative documents
|
|
80
79
|
*
|
|
81
|
-
* @param options - Search options
|
|
82
|
-
* since the MCP tool schema requires the `keyword` parameter)
|
|
80
|
+
* @param options - Search options using v1.2.7 parameters: keyword, documentType, docId, etc.
|
|
83
81
|
* @returns Search results
|
|
84
82
|
*/
|
|
85
83
|
searchDocuments(options?: SearchDocumentsOptions): Promise<MCPToolResult>;
|
|
86
84
|
/**
|
|
87
85
|
* Get parliamentary questions
|
|
88
86
|
*
|
|
89
|
-
* @param options - Filter options
|
|
90
|
-
* `dateTo` is intentionally ignored because the `get_parliamentary_questions` tool schema
|
|
91
|
-
* only supports `startDate` as a date filter; passing `dateTo` would have no effect.
|
|
87
|
+
* @param options - Filter options including docId, type, author, topic, status, dateFrom, dateTo
|
|
92
88
|
* @returns Parliamentary questions data
|
|
93
89
|
*/
|
|
94
90
|
getParliamentaryQuestions(options?: GetParliamentaryQuestionsOptions): Promise<MCPToolResult>;
|
|
95
91
|
/**
|
|
96
92
|
* Get committee information
|
|
97
93
|
*
|
|
98
|
-
* @param options - Filter options
|
|
94
|
+
* @param options - Filter options: id, abbreviation, showCurrent
|
|
99
95
|
* @returns Committee info data
|
|
100
96
|
*/
|
|
101
97
|
getCommitteeInfo(options?: GetCommitteeInfoOptions): Promise<MCPToolResult>;
|
|
@@ -123,21 +119,21 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
123
119
|
/**
|
|
124
120
|
* Analyze coalition dynamics and cohesion
|
|
125
121
|
*
|
|
126
|
-
* @param options - Options including optional
|
|
122
|
+
* @param options - Options including optional groupIds and date range
|
|
127
123
|
* @returns Coalition cohesion and stress analysis
|
|
128
124
|
*/
|
|
129
125
|
analyzeCoalitionDynamics(options?: AnalyzeCoalitionDynamicsOptions): Promise<MCPToolResult>;
|
|
130
126
|
/**
|
|
131
127
|
* Detect voting anomalies and party defections
|
|
132
128
|
*
|
|
133
|
-
* @param options - Options including optional MEP id,
|
|
129
|
+
* @param options - Options including optional MEP id, groupId, and date range
|
|
134
130
|
* @returns Anomaly detection results
|
|
135
131
|
*/
|
|
136
132
|
detectVotingAnomalies(options?: DetectVotingAnomaliesOptions): Promise<MCPToolResult>;
|
|
137
133
|
/**
|
|
138
134
|
* Compare political groups across dimensions
|
|
139
135
|
*
|
|
140
|
-
* @param options - Options including required
|
|
136
|
+
* @param options - Options including required groupIds and optional dimensions and date range
|
|
141
137
|
* @returns Cross-group comparative analysis
|
|
142
138
|
*/
|
|
143
139
|
comparePoliticalGroups(options: ComparePoliticalGroupsOptions): Promise<MCPToolResult>;
|
|
@@ -151,7 +147,7 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
151
147
|
/**
|
|
152
148
|
* Retrieve voting records with optional filters
|
|
153
149
|
*
|
|
154
|
-
* @param options - Filter options (mepId,
|
|
150
|
+
* @param options - Filter options (sessionId, mepId, topic, dateFrom, dateTo, limit, offset)
|
|
155
151
|
* @returns Voting records data
|
|
156
152
|
*/
|
|
157
153
|
getVotingRecords(options?: VotingRecordsOptions): Promise<MCPToolResult>;
|
|
@@ -382,10 +378,10 @@ export declare class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
382
378
|
/**
|
|
383
379
|
* Cross-tool OSINT intelligence correlation engine
|
|
384
380
|
*
|
|
385
|
-
* @param options - Options including optional
|
|
381
|
+
* @param options - Options including required mepIds, optional groups, sensitivityLevel, includeNetworkAnalysis
|
|
386
382
|
* @returns Correlated intelligence alerts and insights
|
|
387
383
|
*/
|
|
388
|
-
correlateIntelligence(options
|
|
384
|
+
correlateIntelligence(options: CorrelateIntelligenceOptions): Promise<MCPToolResult>;
|
|
389
385
|
/**
|
|
390
386
|
* Retrieve precomputed European Parliament activity statistics (EP6–EP10, 2004–2025).
|
|
391
387
|
* Includes yearly stats, category rankings, political landscape history, and
|
|
@@ -29,7 +29,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
|
|
|
29
29
|
/**
|
|
30
30
|
* Classify an error message into a diagnostic error category.
|
|
31
31
|
*
|
|
32
|
-
* Maps EP MCP Server v1.2.
|
|
32
|
+
* Maps EP MCP Server v1.2.7 structured error codes and generic HTTP/network
|
|
33
33
|
* errors into one of six broad categories used for logging and retry decisions:
|
|
34
34
|
*
|
|
35
35
|
* Returned categories (priority order):
|
|
@@ -45,7 +45,7 @@ const SERVER_HEALTH_FALLBACK = '{"server": null, "feeds": []}';
|
|
|
45
45
|
*/
|
|
46
46
|
function classifyToolError(message) {
|
|
47
47
|
const lowerMsg = message.toLowerCase();
|
|
48
|
-
// EP MCP Server v1.2.
|
|
48
|
+
// EP MCP Server v1.2.7 structured error codes (matched case-insensitively)
|
|
49
49
|
if (lowerMsg.includes('internal_error')) {
|
|
50
50
|
return 'INTERNAL_ERROR';
|
|
51
51
|
}
|
|
@@ -205,78 +205,38 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
205
205
|
/**
|
|
206
206
|
* Get plenary sessions
|
|
207
207
|
*
|
|
208
|
-
* @param options - Filter options
|
|
209
|
-
* per the tool schema when the canonical fields are absent.
|
|
208
|
+
* @param options - Filter options including dateFrom, dateTo, eventId, year, location
|
|
210
209
|
* @returns Plenary sessions data
|
|
211
210
|
*/
|
|
212
211
|
async getPlenarySessions(options = {}) {
|
|
213
|
-
return this.safeCallTool('get_plenary_sessions',
|
|
214
|
-
const { dateFrom, dateTo, ...rest } = options;
|
|
215
|
-
const normalizedOptions = { ...rest };
|
|
216
|
-
if (normalizedOptions['startDate'] === undefined && dateFrom !== undefined) {
|
|
217
|
-
normalizedOptions['startDate'] = dateFrom;
|
|
218
|
-
}
|
|
219
|
-
if (normalizedOptions['endDate'] === undefined && dateTo !== undefined) {
|
|
220
|
-
normalizedOptions['endDate'] = dateTo;
|
|
221
|
-
}
|
|
222
|
-
return normalizedOptions;
|
|
223
|
-
}, '{"sessions": []}');
|
|
212
|
+
return this.safeCallTool('get_plenary_sessions', options, '{"sessions": []}');
|
|
224
213
|
}
|
|
225
214
|
/**
|
|
226
215
|
* Search legislative documents
|
|
227
216
|
*
|
|
228
|
-
* @param options - Search options
|
|
229
|
-
* since the MCP tool schema requires the `keyword` parameter)
|
|
217
|
+
* @param options - Search options using v1.2.7 parameters: keyword, documentType, docId, etc.
|
|
230
218
|
* @returns Search results
|
|
231
219
|
*/
|
|
232
220
|
async searchDocuments(options = {}) {
|
|
233
|
-
return this.safeCallTool('search_documents',
|
|
234
|
-
const { query, ...rest } = options;
|
|
235
|
-
const normalizedOptions = { ...rest };
|
|
236
|
-
// MCP tool schema expects 'keyword', not 'query'
|
|
237
|
-
if (normalizedOptions['keyword'] === undefined && query !== undefined) {
|
|
238
|
-
const trimmed = String(query).trim();
|
|
239
|
-
if (trimmed.length > 0) {
|
|
240
|
-
normalizedOptions['keyword'] = trimmed;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return normalizedOptions;
|
|
244
|
-
}, DOCUMENTS_FALLBACK);
|
|
221
|
+
return this.safeCallTool('search_documents', options, DOCUMENTS_FALLBACK);
|
|
245
222
|
}
|
|
246
223
|
/**
|
|
247
224
|
* Get parliamentary questions
|
|
248
225
|
*
|
|
249
|
-
* @param options - Filter options
|
|
250
|
-
* `dateTo` is intentionally ignored because the `get_parliamentary_questions` tool schema
|
|
251
|
-
* only supports `startDate` as a date filter; passing `dateTo` would have no effect.
|
|
226
|
+
* @param options - Filter options including docId, type, author, topic, status, dateFrom, dateTo
|
|
252
227
|
* @returns Parliamentary questions data
|
|
253
228
|
*/
|
|
254
229
|
async getParliamentaryQuestions(options = {}) {
|
|
255
|
-
return this.safeCallTool('get_parliamentary_questions',
|
|
256
|
-
const { dateFrom, dateTo: _dateTo, ...rest } = options;
|
|
257
|
-
const toolOptions = { ...rest };
|
|
258
|
-
if (toolOptions['startDate'] === undefined && dateFrom !== undefined) {
|
|
259
|
-
toolOptions['startDate'] = dateFrom;
|
|
260
|
-
}
|
|
261
|
-
return toolOptions;
|
|
262
|
-
}, '{"questions": []}');
|
|
230
|
+
return this.safeCallTool('get_parliamentary_questions', options, '{"questions": []}');
|
|
263
231
|
}
|
|
264
232
|
/**
|
|
265
233
|
* Get committee information
|
|
266
234
|
*
|
|
267
|
-
* @param options - Filter options
|
|
235
|
+
* @param options - Filter options: id, abbreviation, showCurrent
|
|
268
236
|
* @returns Committee info data
|
|
269
237
|
*/
|
|
270
238
|
async getCommitteeInfo(options = {}) {
|
|
271
|
-
return this.safeCallTool('get_committee_info',
|
|
272
|
-
const { committeeId, ...rest } = options;
|
|
273
|
-
const toolOptions = { ...rest };
|
|
274
|
-
// MCP tool schema expects 'abbreviation', not 'committeeId'
|
|
275
|
-
if (toolOptions['abbreviation'] === undefined && committeeId !== undefined) {
|
|
276
|
-
toolOptions['abbreviation'] = committeeId;
|
|
277
|
-
}
|
|
278
|
-
return toolOptions;
|
|
279
|
-
}, '{"committees": []}');
|
|
239
|
+
return this.safeCallTool('get_committee_info', options, '{"committees": []}');
|
|
280
240
|
}
|
|
281
241
|
/**
|
|
282
242
|
* Monitor legislative pipeline
|
|
@@ -319,7 +279,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
319
279
|
/**
|
|
320
280
|
* Analyze coalition dynamics and cohesion
|
|
321
281
|
*
|
|
322
|
-
* @param options - Options including optional
|
|
282
|
+
* @param options - Options including optional groupIds and date range
|
|
323
283
|
* @returns Coalition cohesion and stress analysis
|
|
324
284
|
*/
|
|
325
285
|
async analyzeCoalitionDynamics(options = {}) {
|
|
@@ -328,7 +288,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
328
288
|
/**
|
|
329
289
|
* Detect voting anomalies and party defections
|
|
330
290
|
*
|
|
331
|
-
* @param options - Options including optional MEP id,
|
|
291
|
+
* @param options - Options including optional MEP id, groupId, and date range
|
|
332
292
|
* @returns Anomaly detection results
|
|
333
293
|
*/
|
|
334
294
|
async detectVotingAnomalies(options = {}) {
|
|
@@ -337,19 +297,18 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
337
297
|
/**
|
|
338
298
|
* Compare political groups across dimensions
|
|
339
299
|
*
|
|
340
|
-
* @param options - Options including required
|
|
300
|
+
* @param options - Options including required groupIds and optional dimensions and date range
|
|
341
301
|
* @returns Cross-group comparative analysis
|
|
342
302
|
*/
|
|
343
303
|
async comparePoliticalGroups(options) {
|
|
344
|
-
const
|
|
345
|
-
const groups = rawGroups
|
|
304
|
+
const groupIds = (Array.isArray(options.groupIds) ? options.groupIds : [])
|
|
346
305
|
.map((g) => (typeof g === 'string' ? g.trim() : ''))
|
|
347
306
|
.filter((g) => g.length > 0);
|
|
348
|
-
if (
|
|
349
|
-
console.warn('compare_political_groups called without valid
|
|
307
|
+
if (groupIds.length === 0) {
|
|
308
|
+
console.warn('compare_political_groups called without valid groupIds (non-empty string array required)');
|
|
350
309
|
return { content: [{ type: 'text', text: '{"comparison": {}}' }] };
|
|
351
310
|
}
|
|
352
|
-
return this.safeCallTool('compare_political_groups', { ...options,
|
|
311
|
+
return this.safeCallTool('compare_political_groups', { ...options, groupIds }, '{"comparison": {}}');
|
|
353
312
|
}
|
|
354
313
|
/**
|
|
355
314
|
* Get detailed information about a specific MEP
|
|
@@ -367,7 +326,7 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
367
326
|
/**
|
|
368
327
|
* Retrieve voting records with optional filters
|
|
369
328
|
*
|
|
370
|
-
* @param options - Filter options (mepId,
|
|
329
|
+
* @param options - Filter options (sessionId, mepId, topic, dateFrom, dateTo, limit, offset)
|
|
371
330
|
* @returns Voting records data
|
|
372
331
|
*/
|
|
373
332
|
async getVotingRecords(options = {}) {
|
|
@@ -708,10 +667,14 @@ export class EuropeanParliamentMCPClient extends MCPConnection {
|
|
|
708
667
|
/**
|
|
709
668
|
* Cross-tool OSINT intelligence correlation engine
|
|
710
669
|
*
|
|
711
|
-
* @param options - Options including optional
|
|
670
|
+
* @param options - Options including required mepIds, optional groups, sensitivityLevel, includeNetworkAnalysis
|
|
712
671
|
* @returns Correlated intelligence alerts and insights
|
|
713
672
|
*/
|
|
714
|
-
async correlateIntelligence(options
|
|
673
|
+
async correlateIntelligence(options) {
|
|
674
|
+
if (!Array.isArray(options.mepIds) || options.mepIds.length === 0) {
|
|
675
|
+
console.warn('correlate_intelligence called without valid mepIds (non-empty string array required)');
|
|
676
|
+
return { content: [{ type: 'text', text: INTELLIGENCE_FALLBACK }] };
|
|
677
|
+
}
|
|
715
678
|
return this.safeCallTool('correlate_intelligence', options, INTELLIGENCE_FALLBACK);
|
|
716
679
|
}
|
|
717
680
|
/**
|
|
@@ -419,7 +419,7 @@ export class MCPConnection {
|
|
|
419
419
|
return trimmedKey;
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
|
-
const rawScheme = typeof process !== 'undefined' && process.env
|
|
422
|
+
const rawScheme = typeof process !== 'undefined' && process.env?.['EP_MCP_GATEWAY_AUTH_SCHEME'];
|
|
423
423
|
const scheme = typeof rawScheme === 'string' ? rawScheme.trim() : '';
|
|
424
424
|
if (scheme && tokenRegex.test(scheme)) {
|
|
425
425
|
return `${scheme} ${trimmedKey}`;
|
|
@@ -430,6 +430,9 @@ export class MCPConnection {
|
|
|
430
430
|
* Attempt a single connection via MCP Gateway (HTTP transport)
|
|
431
431
|
*/
|
|
432
432
|
async _attemptGatewayConnection() {
|
|
433
|
+
if (!this.gatewayUrl) {
|
|
434
|
+
throw new Error('Gateway URL not configured. Set the EP_MCP_GATEWAY_URL environment variable or provide the gatewayUrl constructor option.');
|
|
435
|
+
}
|
|
433
436
|
try {
|
|
434
437
|
const headers = {
|
|
435
438
|
'Content-Type': 'application/json',
|
|
@@ -562,17 +565,24 @@ export class MCPConnection {
|
|
|
562
565
|
handleMessage(line) {
|
|
563
566
|
try {
|
|
564
567
|
const message = JSON.parse(line);
|
|
565
|
-
if (message.id && this.pendingRequests.has(message.id)) {
|
|
568
|
+
if (message.id !== null && message.id !== undefined && this.pendingRequests.has(message.id)) {
|
|
566
569
|
const pending = this.pendingRequests.get(message.id);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
+
if (pending) {
|
|
571
|
+
this.pendingRequests.delete(message.id);
|
|
572
|
+
if (message.error) {
|
|
573
|
+
pending.reject(new Error(message.error.message ?? 'MCP server error'));
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
pending.resolve(message.result);
|
|
577
|
+
}
|
|
570
578
|
}
|
|
571
579
|
else {
|
|
572
|
-
|
|
580
|
+
// has() returned true but get() returned undefined — unexpected
|
|
581
|
+
this.pendingRequests.delete(message.id);
|
|
582
|
+
console.error(`MCP pending request ${String(message.id)} vanished before handling`);
|
|
573
583
|
}
|
|
574
584
|
}
|
|
575
|
-
else if (
|
|
585
|
+
else if ((message.id === null || message.id === undefined) && message.method) {
|
|
576
586
|
console.log(`MCP Notification: ${message.method}`);
|
|
577
587
|
}
|
|
578
588
|
}
|
|
@@ -619,6 +629,9 @@ export class MCPConnection {
|
|
|
619
629
|
* @returns Server response
|
|
620
630
|
*/
|
|
621
631
|
async _sendGatewayRequest(method, params = {}) {
|
|
632
|
+
if (!this.gatewayUrl) {
|
|
633
|
+
throw new Error('Gateway URL not configured. Set EP_MCP_GATEWAY_URL or provide gatewayUrl in MCP client options.');
|
|
634
|
+
}
|
|
622
635
|
const id = ++this.requestId;
|
|
623
636
|
const request = {
|
|
624
637
|
jsonrpc: '2.0',
|
package/scripts/types/mcp.d.ts
CHANGED
|
@@ -63,43 +63,58 @@ export interface GetMEPsOptions {
|
|
|
63
63
|
}
|
|
64
64
|
/** Options for getPlenarySessions */
|
|
65
65
|
export interface GetPlenarySessionsOptions {
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
/**
|
|
66
|
+
/** Meeting event ID for single meeting lookup */
|
|
67
|
+
eventId?: string | undefined;
|
|
68
|
+
/** Filter by calendar year (recommended for annual counts) */
|
|
69
|
+
year?: number | undefined;
|
|
70
|
+
/** Start date in YYYY-MM-DD format */
|
|
71
71
|
dateFrom?: string | undefined;
|
|
72
|
-
/**
|
|
72
|
+
/** End date in YYYY-MM-DD format */
|
|
73
73
|
dateTo?: string | undefined;
|
|
74
|
+
/** Session location (e.g., "Strasbourg", "Brussels") */
|
|
74
75
|
location?: string | undefined;
|
|
75
76
|
limit?: number | undefined;
|
|
77
|
+
offset?: number | undefined;
|
|
76
78
|
}
|
|
77
79
|
/** Options for searchDocuments */
|
|
78
80
|
export interface SearchDocumentsOptions {
|
|
79
|
-
|
|
81
|
+
/** Document ID for single document lookup (bypasses keyword search) */
|
|
82
|
+
docId?: string | undefined;
|
|
83
|
+
/** Search keyword or phrase */
|
|
80
84
|
keyword?: string | undefined;
|
|
81
|
-
type
|
|
85
|
+
/** Document type: REPORT, RESOLUTION, DECISION, DIRECTIVE, REGULATION, OPINION, AMENDMENT */
|
|
86
|
+
documentType?: string | undefined;
|
|
82
87
|
committee?: string | undefined;
|
|
83
88
|
dateFrom?: string | undefined;
|
|
84
89
|
dateTo?: string | undefined;
|
|
85
90
|
limit?: number | undefined;
|
|
91
|
+
offset?: number | undefined;
|
|
86
92
|
}
|
|
87
93
|
/** Options for getParliamentaryQuestions */
|
|
88
94
|
export interface GetParliamentaryQuestionsOptions {
|
|
95
|
+
/** Document ID for single question lookup */
|
|
96
|
+
docId?: string | undefined;
|
|
97
|
+
/** Question type: WRITTEN or ORAL */
|
|
89
98
|
type?: string | undefined;
|
|
90
|
-
|
|
99
|
+
/** MEP identifier or name of question author */
|
|
100
|
+
author?: string | undefined;
|
|
101
|
+
/** Question topic or keyword to search */
|
|
102
|
+
topic?: string | undefined;
|
|
103
|
+
/** Question status: PENDING or ANSWERED */
|
|
104
|
+
status?: string | undefined;
|
|
91
105
|
dateFrom?: string | undefined;
|
|
92
106
|
dateTo?: string | undefined;
|
|
93
107
|
limit?: number | undefined;
|
|
108
|
+
offset?: number | undefined;
|
|
94
109
|
}
|
|
95
110
|
/** Options for getCommitteeInfo */
|
|
96
111
|
export interface GetCommitteeInfoOptions {
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
/** Committee identifier */
|
|
113
|
+
id?: string | undefined;
|
|
114
|
+
/** Committee abbreviation (e.g., "ENVI", "AGRI") */
|
|
99
115
|
abbreviation?: string | undefined;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
limit?: number | undefined;
|
|
116
|
+
/** If true, returns all current active corporate bodies */
|
|
117
|
+
showCurrent?: boolean | undefined;
|
|
103
118
|
}
|
|
104
119
|
/** Options for monitorLegislativePipeline */
|
|
105
120
|
export interface MonitorLegislativePipelineOptions {
|
|
@@ -117,21 +132,31 @@ export interface AssessMEPInfluenceOptions {
|
|
|
117
132
|
}
|
|
118
133
|
/** Options for analyzeCoalitionDynamics */
|
|
119
134
|
export interface AnalyzeCoalitionDynamicsOptions {
|
|
120
|
-
|
|
135
|
+
/** Political group identifiers to analyze (omit for all groups) */
|
|
136
|
+
groupIds?: string[] | undefined;
|
|
121
137
|
dateFrom?: string | undefined;
|
|
122
138
|
dateTo?: string | undefined;
|
|
139
|
+
/** Minimum cohesion threshold for alliance detection (0-1) */
|
|
140
|
+
minimumCohesion?: number | undefined;
|
|
123
141
|
}
|
|
124
142
|
/** Options for detectVotingAnomalies */
|
|
125
143
|
export interface DetectVotingAnomaliesOptions {
|
|
126
144
|
mepId?: string | undefined;
|
|
127
|
-
|
|
145
|
+
/** Political group to analyze */
|
|
146
|
+
groupId?: string | undefined;
|
|
128
147
|
dateFrom?: string | undefined;
|
|
148
|
+
dateTo?: string | undefined;
|
|
149
|
+
/** Anomaly sensitivity (0-1, lower = more anomalies detected) */
|
|
150
|
+
sensitivityThreshold?: number | undefined;
|
|
129
151
|
}
|
|
130
152
|
/** Options for comparePoliticalGroups */
|
|
131
153
|
export interface ComparePoliticalGroupsOptions {
|
|
132
|
-
|
|
133
|
-
|
|
154
|
+
/** Political group identifiers to compare (minimum 2, maximum 10) */
|
|
155
|
+
groupIds: string[];
|
|
156
|
+
/** Comparison dimensions: voting_discipline, activity_level, legislative_output, attendance, cohesion */
|
|
157
|
+
dimensions?: string[] | undefined;
|
|
134
158
|
dateFrom?: string | undefined;
|
|
159
|
+
dateTo?: string | undefined;
|
|
135
160
|
}
|
|
136
161
|
/** Options for analyzeLegislativeEffectiveness */
|
|
137
162
|
export interface AnalyzeLegislativeEffectivenessOptions {
|
|
@@ -144,7 +169,11 @@ export interface AnalyzeLegislativeEffectivenessOptions {
|
|
|
144
169
|
export interface VotingRecordsOptions {
|
|
145
170
|
mepId?: string | undefined;
|
|
146
171
|
sessionId?: string | undefined;
|
|
172
|
+
topic?: string | undefined;
|
|
173
|
+
dateFrom?: string | undefined;
|
|
174
|
+
dateTo?: string | undefined;
|
|
147
175
|
limit?: number | undefined;
|
|
176
|
+
offset?: number | undefined;
|
|
148
177
|
}
|
|
149
178
|
/** Options for analyzing voting patterns */
|
|
150
179
|
export interface VotingPatternsOptions {
|
|
@@ -192,6 +221,8 @@ export interface GetCurrentMEPsOptions {
|
|
|
192
221
|
/** Options for getSpeeches */
|
|
193
222
|
export interface GetSpeechesOptions {
|
|
194
223
|
speechId?: string | undefined;
|
|
224
|
+
/** Filter by calendar year (recommended for annual counts) */
|
|
225
|
+
year?: number | undefined;
|
|
195
226
|
dateFrom?: string | undefined;
|
|
196
227
|
dateTo?: string | undefined;
|
|
197
228
|
limit?: number | undefined;
|
|
@@ -214,6 +245,8 @@ export interface GetAdoptedTextsOptions {
|
|
|
214
245
|
/** Options for getEvents */
|
|
215
246
|
export interface GetEventsOptions {
|
|
216
247
|
eventId?: string | undefined;
|
|
248
|
+
/** Filter by calendar year (recommended for annual counts) */
|
|
249
|
+
year?: number | undefined;
|
|
217
250
|
dateFrom?: string | undefined;
|
|
218
251
|
dateTo?: string | undefined;
|
|
219
252
|
limit?: number | undefined;
|
|
@@ -338,8 +371,14 @@ export interface ComparativeIntelligenceOptions {
|
|
|
338
371
|
}
|
|
339
372
|
/** Options for correlateIntelligence */
|
|
340
373
|
export interface CorrelateIntelligenceOptions {
|
|
341
|
-
|
|
342
|
-
|
|
374
|
+
/** MEP identifiers to cross-correlate (1-5 MEPs, required) */
|
|
375
|
+
mepIds: string[];
|
|
376
|
+
/** Political groups for coalition fracture analysis (max 8, omit for all) */
|
|
377
|
+
groups?: string[] | undefined;
|
|
378
|
+
/** Alert sensitivity: HIGH, MEDIUM, or LOW */
|
|
379
|
+
sensitivityLevel?: 'HIGH' | 'MEDIUM' | 'LOW' | undefined;
|
|
380
|
+
/** Run network centrality analysis (increases response time) */
|
|
381
|
+
includeNetworkAnalysis?: boolean | undefined;
|
|
343
382
|
}
|
|
344
383
|
/** Allowed category values for getAllGeneratedStats */
|
|
345
384
|
export type GeneratedStatsCategory = 'all' | 'plenary_sessions' | 'legislative_acts' | 'roll_call_votes' | 'committee_meetings' | 'parliamentary_questions' | 'resolutions' | 'speeches' | 'adopted_texts' | 'political_groups' | 'procedures' | 'events' | 'documents' | 'mep_turnover' | 'declarations';
|
|
@@ -356,7 +395,7 @@ export interface GetAllGeneratedStatsOptions {
|
|
|
356
395
|
* Allowed timeframe values for EP API v2 feed endpoints.
|
|
357
396
|
* Controls how far back the feed looks for recently updated items.
|
|
358
397
|
*/
|
|
359
|
-
export type FeedTimeframe = 'today' | 'one-day' | 'one-week' | 'one-month' | '
|
|
398
|
+
export type FeedTimeframe = 'today' | 'one-day' | 'one-week' | 'one-month' | 'custom';
|
|
360
399
|
/** Common options shared by all EP API v2 feed endpoints */
|
|
361
400
|
export interface FeedBaseOptions {
|
|
362
401
|
/** How far back to look for recently-updated items (default: `'one-day'`) */
|
|
@@ -106,6 +106,89 @@ const ENGLISH_PLACEHOLDER_PHRASES = [
|
|
|
106
106
|
'political group dynamics',
|
|
107
107
|
'committee coordinators',
|
|
108
108
|
];
|
|
109
|
+
// ─── Article Quality Gate Constants ───────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Section headings that MUST NOT appear as article keywords.
|
|
112
|
+
* These leak into meta tags when AI agents copy their section headers
|
|
113
|
+
* into the keywords field instead of using policy terms.
|
|
114
|
+
*
|
|
115
|
+
* @see SHARED_PROMPT_PATTERNS.md § Keywords Quality Rules
|
|
116
|
+
*/
|
|
117
|
+
const BANNED_KEYWORD_PATTERNS = [
|
|
118
|
+
'Deep Political Analysis',
|
|
119
|
+
'What Happened',
|
|
120
|
+
'Key Actors',
|
|
121
|
+
'Timeline',
|
|
122
|
+
'Why It Matters',
|
|
123
|
+
'Why This Matters',
|
|
124
|
+
'Legislative Pipeline Overview',
|
|
125
|
+
'Impact Assessment',
|
|
126
|
+
'Actions → Consequences',
|
|
127
|
+
'Miscalculations & Missed Opportunities',
|
|
128
|
+
'Winners & Losers',
|
|
129
|
+
'Root Causes',
|
|
130
|
+
'Stakeholder Perspectives',
|
|
131
|
+
'Multi-Stakeholder Perspectives',
|
|
132
|
+
'Stakeholder Outcome Matrix',
|
|
133
|
+
'Intelligence Policy Map',
|
|
134
|
+
'Strategic Outlook',
|
|
135
|
+
'SWOT Analysis',
|
|
136
|
+
'Dashboard',
|
|
137
|
+
'Pipeline Health',
|
|
138
|
+
'Analysis Pipeline Insights',
|
|
139
|
+
'Plenary Sessions',
|
|
140
|
+
'Executive Summary',
|
|
141
|
+
'Table of Contents',
|
|
142
|
+
'Political Context',
|
|
143
|
+
];
|
|
144
|
+
/**
|
|
145
|
+
* Minimum number of non-whitespace characters for a `<section>` to be
|
|
146
|
+
* considered non-empty. Below this threshold the section is treated as empty.
|
|
147
|
+
*/
|
|
148
|
+
const MIN_SECTION_CONTENT_LENGTH = 10;
|
|
149
|
+
/**
|
|
150
|
+
* How many characters to look back from a tag position when checking
|
|
151
|
+
* whether the tag is inside a pipeline-health/pipeline-metrics container.
|
|
152
|
+
*/
|
|
153
|
+
const PIPELINE_CONTEXT_LOOKBEHIND_CHARS = 2000;
|
|
154
|
+
/**
|
|
155
|
+
* Pre-computed normalized banned-keyword map for exact-match comparison.
|
|
156
|
+
* Built once at module init from BANNED_KEYWORD_PATTERNS + normalizeKeywordToken.
|
|
157
|
+
*
|
|
158
|
+
* Keys are normalized tokens; values are original patterns.
|
|
159
|
+
*/
|
|
160
|
+
let _bannedNormalizedCache;
|
|
161
|
+
/**
|
|
162
|
+
* Return (and lazily compute once) the normalized banned-keyword map.
|
|
163
|
+
* Lazy initialization avoids a forward-reference to `normalizeKeywordToken`
|
|
164
|
+
* which is defined later in this module.
|
|
165
|
+
*
|
|
166
|
+
* @returns Map from normalized token to original banned pattern
|
|
167
|
+
*/
|
|
168
|
+
function getBannedNormalized() {
|
|
169
|
+
if (!_bannedNormalizedCache) {
|
|
170
|
+
_bannedNormalizedCache = new Map();
|
|
171
|
+
for (const pattern of BANNED_KEYWORD_PATTERNS) {
|
|
172
|
+
_bannedNormalizedCache.set(normalizeKeywordToken(pattern), pattern);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return _bannedNormalizedCache;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* HTML entity → decoded character pairs used by the single-pass decoder.
|
|
179
|
+
* Longest entities are listed first so that `&` doesn't greedily match
|
|
180
|
+
* inside `&lt;` before the full entity `&lt;` is checked.
|
|
181
|
+
*/
|
|
182
|
+
const ENTITY_PAIRS = [
|
|
183
|
+
['—', '—'],
|
|
184
|
+
['–', '–'],
|
|
185
|
+
['→', '→'],
|
|
186
|
+
['"', '"'],
|
|
187
|
+
['&', '&'],
|
|
188
|
+
[''', "'"],
|
|
189
|
+
['<', '<'],
|
|
190
|
+
['>', '>'],
|
|
191
|
+
];
|
|
109
192
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
110
193
|
// stripScriptBlocks is imported from html-sanitize.ts
|
|
111
194
|
/**
|
|
@@ -264,7 +347,357 @@ function checkMetaTagSync(html) {
|
|
|
264
347
|
return false;
|
|
265
348
|
return true;
|
|
266
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Decode common HTML entities that appear in meta keyword values.
|
|
352
|
+
* Only covers the entities actually used by the article template engine.
|
|
353
|
+
*
|
|
354
|
+
* Uses a single-pass scan to avoid double-unescaping (e.g. `&lt;`
|
|
355
|
+
* becomes `<`, NOT `<`). Each `&` in the input is checked once;
|
|
356
|
+
* decoded replacements are never re-scanned.
|
|
357
|
+
*
|
|
358
|
+
* @param s - String potentially containing HTML entities
|
|
359
|
+
* @returns The string with common entities decoded
|
|
360
|
+
*/
|
|
361
|
+
function decodeKeywordEntities(s) {
|
|
362
|
+
const parts = [];
|
|
363
|
+
let i = 0;
|
|
364
|
+
while (i < s.length) {
|
|
365
|
+
const ch = s[i] ?? '';
|
|
366
|
+
if (ch === '&') {
|
|
367
|
+
const rest = s.slice(i).toLowerCase();
|
|
368
|
+
let matched = false;
|
|
369
|
+
for (const [entity, replacement] of ENTITY_PAIRS) {
|
|
370
|
+
if (rest.startsWith(entity)) {
|
|
371
|
+
parts.push(replacement);
|
|
372
|
+
i += entity.length;
|
|
373
|
+
matched = true;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!matched) {
|
|
378
|
+
parts.push(ch);
|
|
379
|
+
i++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
parts.push(ch);
|
|
384
|
+
i++;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return parts.join('');
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Normalize a keyword token for comparison: decode HTML entities,
|
|
391
|
+
* collapse arrow/dash variants, and normalize whitespace.
|
|
392
|
+
*
|
|
393
|
+
* @param s - Raw keyword token to normalize
|
|
394
|
+
* @returns Lowercased, entity-decoded, dash-normalized token
|
|
395
|
+
*/
|
|
396
|
+
function normalizeKeywordToken(s) {
|
|
397
|
+
let decoded = decodeKeywordEntities(s);
|
|
398
|
+
// Normalize arrow/dash variants → single canonical form
|
|
399
|
+
decoded = decoded.replace(/→/gu, '->');
|
|
400
|
+
decoded = decoded.replace(/—/gu, '-');
|
|
401
|
+
decoded = decoded.replace(/–/gu, '-');
|
|
402
|
+
// Collapse whitespace and lowercase
|
|
403
|
+
return decoded.replace(/\s+/gu, ' ').trim().toLowerCase();
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Detect section-heading keywords that leaked into the article's meta keywords.
|
|
407
|
+
* Returns the list of banned keywords found.
|
|
408
|
+
*
|
|
409
|
+
* Decodes HTML entities (e.g. `&` → `&`) and normalizes dash/arrow
|
|
410
|
+
* variants so that exact comma-separated tokens can be matched after
|
|
411
|
+
* normalization, for example "Winners & Losers" matching
|
|
412
|
+
* "Winners & Losers". Combined phrases are not split on dash or arrow
|
|
413
|
+
* separators and therefore only match if the full normalized token is banned.
|
|
414
|
+
*
|
|
415
|
+
* @param html - HTML string to inspect
|
|
416
|
+
* @returns Array of section-heading keywords found in the meta tag
|
|
417
|
+
*/
|
|
418
|
+
function detectBannedKeywords(html) {
|
|
419
|
+
const keywordsMeta = extractMetaContent(html, 'name', 'keywords');
|
|
420
|
+
if (!keywordsMeta)
|
|
421
|
+
return [];
|
|
422
|
+
// Parse comma-separated keywords and normalize each token
|
|
423
|
+
const tokens = keywordsMeta
|
|
424
|
+
.split(',')
|
|
425
|
+
.map((k) => normalizeKeywordToken(k))
|
|
426
|
+
.filter((k) => k.length > 0);
|
|
427
|
+
const bannedNormalized = getBannedNormalized();
|
|
428
|
+
const found = [];
|
|
429
|
+
for (const token of tokens) {
|
|
430
|
+
const original = bannedNormalized.get(token);
|
|
431
|
+
if (original) {
|
|
432
|
+
found.push(original);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return found;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Test whether a character is a boundary before/after the word "class"
|
|
439
|
+
* in an HTML attribute context.
|
|
440
|
+
*
|
|
441
|
+
* @param ch - The character to test (or undefined if at string edge)
|
|
442
|
+
* @param side - Whether to check as a 'before' or 'after' boundary
|
|
443
|
+
* @returns true if the character is a valid boundary
|
|
444
|
+
*/
|
|
445
|
+
function isAttrBoundary(ch, side) {
|
|
446
|
+
if (!ch || ch === '')
|
|
447
|
+
return true;
|
|
448
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r')
|
|
449
|
+
return true;
|
|
450
|
+
if (side === 'before')
|
|
451
|
+
return ch === '"' || ch === "'";
|
|
452
|
+
return ch === '=';
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Extract the quoted value of the `class` attribute starting at a given cursor
|
|
456
|
+
* position (immediately after the word "class"). Returns `null` if the syntax
|
|
457
|
+
* is not `class = "..."` or `class = '...'`.
|
|
458
|
+
*
|
|
459
|
+
* @param tag - The full start-tag string
|
|
460
|
+
* @param cursor - Index right after the word "class" in `tag`
|
|
461
|
+
* @returns `{ value, end }` or `null`
|
|
462
|
+
*/
|
|
463
|
+
function extractClassValue(tag, cursor) {
|
|
464
|
+
let pos = cursor;
|
|
465
|
+
// Skip whitespace before '=' (space, tab, newline, carriage return)
|
|
466
|
+
while (pos < tag.length &&
|
|
467
|
+
(tag[pos] === ' ' || tag[pos] === '\t' || tag[pos] === '\n' || tag[pos] === '\r'))
|
|
468
|
+
pos++;
|
|
469
|
+
if (pos >= tag.length || tag[pos] !== '=')
|
|
470
|
+
return null;
|
|
471
|
+
pos++; // skip '='
|
|
472
|
+
// Skip whitespace before opening quote
|
|
473
|
+
while (pos < tag.length &&
|
|
474
|
+
(tag[pos] === ' ' || tag[pos] === '\t' || tag[pos] === '\n' || tag[pos] === '\r'))
|
|
475
|
+
pos++;
|
|
476
|
+
if (pos >= tag.length)
|
|
477
|
+
return null;
|
|
478
|
+
const quote = tag[pos];
|
|
479
|
+
if (quote !== '"' && quote !== "'")
|
|
480
|
+
return null;
|
|
481
|
+
const valueStart = pos + 1;
|
|
482
|
+
const valueEnd = tag.indexOf(quote, valueStart);
|
|
483
|
+
if (valueEnd === -1)
|
|
484
|
+
return null;
|
|
485
|
+
return { value: tag.slice(valueStart, valueEnd), end: valueEnd + 1 };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Check whether an HTML start tag has a specific class token (whitespace-tokenized).
|
|
489
|
+
* Handles both single-quoted and double-quoted class attributes.
|
|
490
|
+
*
|
|
491
|
+
* @param startTag - An opening HTML tag string (e.g. `<span class="metric-value foo">`)
|
|
492
|
+
* @param token - The class token to look for (e.g. `metric-value`)
|
|
493
|
+
* @returns true if the class attribute contains the exact token
|
|
494
|
+
*/
|
|
495
|
+
function hasClassToken(startTag, token) {
|
|
496
|
+
const lowerTag = startTag.toLowerCase();
|
|
497
|
+
let searchFrom = 0;
|
|
498
|
+
while (searchFrom < lowerTag.length) {
|
|
499
|
+
const classPos = lowerTag.indexOf('class', searchFrom);
|
|
500
|
+
if (classPos === -1)
|
|
501
|
+
return false;
|
|
502
|
+
const before = classPos > 0 ? lowerTag[classPos - 1] : undefined;
|
|
503
|
+
const after = classPos + 5 < lowerTag.length ? lowerTag[classPos + 5] : undefined;
|
|
504
|
+
if (!isAttrBoundary(before, 'before') || !isAttrBoundary(after, 'after')) {
|
|
505
|
+
searchFrom = classPos + 5;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const extracted = extractClassValue(startTag, classPos + 5);
|
|
509
|
+
if (!extracted) {
|
|
510
|
+
searchFrom = classPos + 5;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
const tokens = extracted.value.split(/\s+/u).filter((t) => t.length > 0);
|
|
514
|
+
if (tokens.includes(token))
|
|
515
|
+
return true;
|
|
516
|
+
searchFrom = extracted.end;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Detect metric values showing "0%" in pipeline-health / pipeline-metrics
|
|
522
|
+
* containers, which indicate no-data conditions that should not be rendered
|
|
523
|
+
* as real dashboard metrics.
|
|
524
|
+
*
|
|
525
|
+
* Only flags `0%` inside elements whose surrounding context includes a
|
|
526
|
+
* `pipeline-metrics` or `pipeline-health` class, avoiding false positives
|
|
527
|
+
* on legitimate trend-panel change indicators (e.g. week-over-week 0%).
|
|
528
|
+
*
|
|
529
|
+
* @param html - HTML string to inspect
|
|
530
|
+
* @returns Number of 0% pipeline metric values found
|
|
531
|
+
*/
|
|
532
|
+
function detectZeroPercentMetrics(html) {
|
|
533
|
+
// Use indexOf-based search to avoid regex backtracking (ReDoS-safe)
|
|
534
|
+
let count = 0;
|
|
535
|
+
let searchFrom = 0;
|
|
536
|
+
const zeroValue = '0%';
|
|
537
|
+
const lowerHtml = html.toLowerCase();
|
|
538
|
+
while (searchFrom < html.length) {
|
|
539
|
+
const tagStart = html.indexOf('<', searchFrom);
|
|
540
|
+
if (tagStart === -1)
|
|
541
|
+
break;
|
|
542
|
+
const tagClose = html.indexOf('>', tagStart);
|
|
543
|
+
if (tagClose === -1)
|
|
544
|
+
break;
|
|
545
|
+
const startTag = html.slice(tagStart, tagClose + 1);
|
|
546
|
+
// Only check elements that have the 'metric-value' class token
|
|
547
|
+
if (hasClassToken(startTag, 'metric-value')) {
|
|
548
|
+
const contentStart = tagClose + 1;
|
|
549
|
+
const nextTag = html.indexOf('<', contentStart);
|
|
550
|
+
if (nextTag === -1)
|
|
551
|
+
break;
|
|
552
|
+
const textContent = html.slice(contentStart, nextTag).trim();
|
|
553
|
+
if (textContent === zeroValue && isInPipelineContext(lowerHtml, tagStart)) {
|
|
554
|
+
count++;
|
|
555
|
+
}
|
|
556
|
+
searchFrom = nextTag;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
searchFrom = tagClose + 1;
|
|
560
|
+
}
|
|
561
|
+
return count;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Check whether a position in the HTML is inside a pipeline-health/metrics context.
|
|
565
|
+
* Looks backward up to 2000 chars for pipeline marker class names.
|
|
566
|
+
*
|
|
567
|
+
* If a `trend-panel` marker appears *after* the nearest pipeline marker,
|
|
568
|
+
* the element is inside a trend panel (not pipeline), so return false.
|
|
569
|
+
* This avoids flagging legitimate WoW/MoM 0% deltas rendered by
|
|
570
|
+
* `buildTrendPanel` that happen to fall within the look-behind window.
|
|
571
|
+
*
|
|
572
|
+
* @param lowerHtml - Lowercase HTML string
|
|
573
|
+
* @param position - Current scan position
|
|
574
|
+
* @returns true if inside a pipeline context
|
|
575
|
+
*/
|
|
576
|
+
function isInPipelineContext(lowerHtml, position) {
|
|
577
|
+
const precedingHtml = lowerHtml.slice(Math.max(0, position - PIPELINE_CONTEXT_LOOKBEHIND_CHARS), position);
|
|
578
|
+
const pipelineMetricsPos = precedingHtml.lastIndexOf('pipeline-metrics');
|
|
579
|
+
const pipelineHealthPos = precedingHtml.lastIndexOf('pipeline-health');
|
|
580
|
+
const lastPipelinePos = Math.max(pipelineMetricsPos, pipelineHealthPos);
|
|
581
|
+
if (lastPipelinePos === -1)
|
|
582
|
+
return false;
|
|
583
|
+
// If a trend-panel marker appears after the pipeline marker, the element
|
|
584
|
+
// is inside a trend panel, not the pipeline panel.
|
|
585
|
+
const trendPanelPos = precedingHtml.lastIndexOf('trend-panel');
|
|
586
|
+
if (trendPanelPos !== -1 && trendPanelPos > lastPipelinePos)
|
|
587
|
+
return false;
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Strip HTML tags from a string using a simple character scanner.
|
|
592
|
+
* ReDoS-safe alternative to regex-based tag removal.
|
|
593
|
+
*
|
|
594
|
+
* @param input - HTML string to strip tags from
|
|
595
|
+
* @returns Plain text content with tags removed
|
|
596
|
+
*/
|
|
597
|
+
function stripHtmlTags(input) {
|
|
598
|
+
const parts = [];
|
|
599
|
+
let inTag = false;
|
|
600
|
+
for (let i = 0; i < input.length; i++) {
|
|
601
|
+
const ch = input[i] ?? '';
|
|
602
|
+
if (ch === '<') {
|
|
603
|
+
inTag = true;
|
|
604
|
+
}
|
|
605
|
+
else if (ch === '>') {
|
|
606
|
+
inTag = false;
|
|
607
|
+
}
|
|
608
|
+
else if (!inTag) {
|
|
609
|
+
parts.push(ch);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return parts.join('');
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Evaluate whether a section's inner HTML has enough meaningful content.
|
|
616
|
+
*
|
|
617
|
+
* @param innerHtml - The HTML content between `<section>` and `</section>` tags
|
|
618
|
+
* @returns true if the section is empty or near-empty
|
|
619
|
+
*/
|
|
620
|
+
function isSectionEmpty(innerHtml) {
|
|
621
|
+
const plainText = stripHtmlTags(innerHtml).replace(/\s+/gu, ' ').trim();
|
|
622
|
+
return plainText.length < MIN_SECTION_CONTENT_LENGTH;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Find the next `<section` open or `</section>` close tag from a given cursor.
|
|
626
|
+
* Returns `{ type, pos }` or `null` if no more section tags found.
|
|
627
|
+
*
|
|
628
|
+
* @param lowerHtml - Lowercase HTML string
|
|
629
|
+
* @param cursor - Start position
|
|
630
|
+
* @returns Tag event or null
|
|
631
|
+
*/
|
|
632
|
+
function findNextSectionTag(lowerHtml, cursor) {
|
|
633
|
+
const nextOpen = lowerHtml.indexOf('<section', cursor);
|
|
634
|
+
const nextClose = lowerHtml.indexOf('</section>', cursor);
|
|
635
|
+
if (nextOpen === -1 && nextClose === -1)
|
|
636
|
+
return null;
|
|
637
|
+
const openFirst = nextOpen !== -1 && (nextClose === -1 || nextOpen < nextClose);
|
|
638
|
+
return openFirst ? { type: 'open', pos: nextOpen } : { type: 'close', pos: nextClose };
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Count empty `<section>` elements — those with little or no visible content.
|
|
642
|
+
* An empty section contains only whitespace or very short boilerplate text.
|
|
643
|
+
* Uses a stack-based scanner to correctly handle nested `<section>` elements.
|
|
644
|
+
*
|
|
645
|
+
* @param html - HTML string to inspect
|
|
646
|
+
* @returns Number of empty sections found
|
|
647
|
+
*/
|
|
648
|
+
function countEmptySections(html) {
|
|
649
|
+
const lowerHtml = html.toLowerCase();
|
|
650
|
+
let count = 0;
|
|
651
|
+
const stack = [];
|
|
652
|
+
let cursor = 0;
|
|
653
|
+
let event = findNextSectionTag(lowerHtml, cursor);
|
|
654
|
+
while (event) {
|
|
655
|
+
if (event.type === 'open') {
|
|
656
|
+
const tagEnd = html.indexOf('>', event.pos);
|
|
657
|
+
if (tagEnd === -1)
|
|
658
|
+
break;
|
|
659
|
+
stack.push(tagEnd + 1);
|
|
660
|
+
cursor = tagEnd + 1;
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
if (stack.length > 0) {
|
|
664
|
+
const contentStart = stack[stack.length - 1] ?? 0;
|
|
665
|
+
stack.pop();
|
|
666
|
+
if (isSectionEmpty(html.slice(contentStart, event.pos))) {
|
|
667
|
+
count++;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
cursor = event.pos + '</section>'.length;
|
|
671
|
+
}
|
|
672
|
+
event = findNextSectionTag(lowerHtml, cursor);
|
|
673
|
+
}
|
|
674
|
+
return count;
|
|
675
|
+
}
|
|
267
676
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
677
|
+
/**
|
|
678
|
+
* Collect warnings from machine-enforceable article quality gates.
|
|
679
|
+
* Extracted to keep `validateArticleContent` within cognitive-complexity limits.
|
|
680
|
+
*
|
|
681
|
+
* @param html - Complete HTML string
|
|
682
|
+
* @param warnings - Mutable warnings array to append to
|
|
683
|
+
*/
|
|
684
|
+
function collectQualityGateWarnings(html, warnings) {
|
|
685
|
+
// Keyword quality: detect section-heading keywords leaked into meta tags
|
|
686
|
+
const bannedKeywords = detectBannedKeywords(html);
|
|
687
|
+
if (bannedKeywords.length > 0) {
|
|
688
|
+
warnings.push(`Keywords contain ${bannedKeywords.length} section heading(s) that should not be used as keywords: ${bannedKeywords.join(', ')}`);
|
|
689
|
+
}
|
|
690
|
+
// Dashboard metric quality: detect 0% metrics rendered as real data
|
|
691
|
+
const zeroMetricCount = detectZeroPercentMetrics(html);
|
|
692
|
+
if (zeroMetricCount > 0) {
|
|
693
|
+
warnings.push(`Dashboard renders ${zeroMetricCount} metric(s) showing "0%" — this likely indicates no-data, not a real score. Omit the dashboard when data is unavailable.`);
|
|
694
|
+
}
|
|
695
|
+
// Empty section detection: flag sections with no meaningful content
|
|
696
|
+
const emptySectionCount = countEmptySections(html);
|
|
697
|
+
if (emptySectionCount > 0) {
|
|
698
|
+
warnings.push(`Article contains ${emptySectionCount} empty or near-empty <section> element(s) that should be removed`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
268
701
|
/**
|
|
269
702
|
* Validate the quality of a generated article.
|
|
270
703
|
*
|
|
@@ -287,7 +720,7 @@ export function validateArticleContent(html, language, articleType) {
|
|
|
287
720
|
const errors = [];
|
|
288
721
|
// Word count check
|
|
289
722
|
const wordCount = countWordsInHtml(html);
|
|
290
|
-
const minWords = MIN_WORD_COUNTS[articleType]
|
|
723
|
+
const minWords = MIN_WORD_COUNTS[articleType] ?? DEFAULT_MIN_WORDS;
|
|
291
724
|
if (wordCount < minWords) {
|
|
292
725
|
warnings.push(`Content too short: ${wordCount} words (minimum ${minWords} for "${articleType}")`);
|
|
293
726
|
}
|
|
@@ -336,6 +769,8 @@ export function validateArticleContent(html, language, articleType) {
|
|
|
336
769
|
}
|
|
337
770
|
// Extended validation: cross-reference density, stakeholder balance, temporal coverage
|
|
338
771
|
collectExtendedValidationWarnings(html, warnings);
|
|
772
|
+
// Machine-enforceable article quality gates
|
|
773
|
+
collectQualityGateWarnings(html, warnings);
|
|
339
774
|
return {
|
|
340
775
|
valid: errors.length === 0,
|
|
341
776
|
warnings,
|
|
@@ -59,13 +59,19 @@ export function groupArticlesByLanguage(articles, languages) {
|
|
|
59
59
|
}
|
|
60
60
|
for (const article of articles) {
|
|
61
61
|
const parsed = parseArticleFilename(article);
|
|
62
|
-
if (parsed
|
|
63
|
-
grouped[parsed.lang]
|
|
62
|
+
if (parsed) {
|
|
63
|
+
const bucket = grouped[parsed.lang];
|
|
64
|
+
if (bucket) {
|
|
65
|
+
bucket.push(parsed);
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
// Sort by date (newest first)
|
|
67
70
|
for (const lang in grouped) {
|
|
68
|
-
grouped[lang]
|
|
71
|
+
const bucket = grouped[lang];
|
|
72
|
+
if (bucket) {
|
|
73
|
+
bucket.sort((a, b) => b.date.localeCompare(a.date));
|
|
74
|
+
}
|
|
69
75
|
}
|
|
70
76
|
return grouped;
|
|
71
77
|
}
|
|
@@ -89,8 +95,7 @@ export function formatSlug(slug) {
|
|
|
89
95
|
*/
|
|
90
96
|
export function getModifiedDate(filepath) {
|
|
91
97
|
const stats = fs.statSync(filepath);
|
|
92
|
-
|
|
93
|
-
return stats.mtime.toISOString().split('T')[0];
|
|
98
|
+
return stats.mtime.toISOString().slice(0, 10);
|
|
94
99
|
}
|
|
95
100
|
/**
|
|
96
101
|
* Format date for article slug
|
|
@@ -99,8 +104,7 @@ export function getModifiedDate(filepath) {
|
|
|
99
104
|
* @returns Formatted date string (YYYY-MM-DD)
|
|
100
105
|
*/
|
|
101
106
|
export function formatDateForSlug(date = new Date()) {
|
|
102
|
-
|
|
103
|
-
return date.toISOString().split('T')[0];
|
|
107
|
+
return date.toISOString().slice(0, 10);
|
|
104
108
|
}
|
|
105
109
|
/**
|
|
106
110
|
* Calculate read time estimate from content
|
|
@@ -529,6 +529,9 @@ function synthesiseOverallRisk(risks, assessmentId, date) {
|
|
|
529
529
|
}
|
|
530
530
|
// Safe: risks.length > 0 is guaranteed by the guard above
|
|
531
531
|
const firstRisk = risks[0];
|
|
532
|
+
if (!firstRisk) {
|
|
533
|
+
throw new Error(`Invariant violation: risks[0] was undefined for non-empty risks array in assessment ${assessmentId} on ${date}`);
|
|
534
|
+
}
|
|
532
535
|
const maxRisk = risks.reduce((max, r) => (r.riskScore > max.riskScore ? r : max), firstRisk);
|
|
533
536
|
// Count confidence levels to pick the dominant one
|
|
534
537
|
const confidenceCounts = { high: 0, medium: 0, low: 0 };
|
|
@@ -171,7 +171,7 @@ function splitCSVLine(line) {
|
|
|
171
171
|
let current = '';
|
|
172
172
|
let inQuotes = false;
|
|
173
173
|
for (let i = 0; i < line.length; i++) {
|
|
174
|
-
const ch = line[i];
|
|
174
|
+
const ch = line[i] ?? '';
|
|
175
175
|
if (ch === '"') {
|
|
176
176
|
if (inQuotes && i + 1 < line.length && line[i + 1] === '"') {
|
|
177
177
|
// RFC 4180 escaped quote: "" → literal "
|
|
@@ -209,11 +209,11 @@ export function parseWorldBankCSV(csvText) {
|
|
|
209
209
|
if (lines.length < 2) {
|
|
210
210
|
return [];
|
|
211
211
|
}
|
|
212
|
-
const headers = splitCSVLine(lines[0]).map((h) => h.toLowerCase());
|
|
212
|
+
const headers = splitCSVLine(lines[0] ?? '').map((h) => h.toLowerCase());
|
|
213
213
|
const colMap = Object.fromEntries(Object.entries(HEADER_ALIASES).map(([key, aliases]) => [key, findColumnIndex(headers, aliases)]));
|
|
214
214
|
const results = [];
|
|
215
215
|
for (let i = 1; i < lines.length; i++) {
|
|
216
|
-
const cols = splitCSVLine(lines[i]);
|
|
216
|
+
const cols = splitCSVLine(lines[i] ?? '');
|
|
217
217
|
const rawValue = readCol(cols, colMap['value'] ?? -1);
|
|
218
218
|
const parsedValue = rawValue !== '' ? Number(rawValue) : null;
|
|
219
219
|
const yearStr = readCol(cols, colMap['date'] ?? -1);
|