Skip to content

Commit 6e2bdee

Browse files
Copilotpethers
andauthored
fix: apply review-4180180259 – single-quote HTML attrs, entity decoding, injectable persist dir, persist tests
Agent-Logs-Url: https://github.com/Hack23/riksdagsmonitor/sessions/e2c0e6bd-7c8e-45cd-be30-579cad326206 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com>
1 parent 2153bb6 commit 6e2bdee

2 files changed

Lines changed: 119 additions & 19 deletions

File tree

scripts/fetch-calendar.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import fs from 'node:fs';
2929
import path from 'node:path';
3030
import { fileURLToPath } from 'node:url';
3131

32+
import { decodeHtmlEntities } from './html-utils.js';
33+
3234
// ---------------------------------------------------------------------------
3335
// Types
3436
// ---------------------------------------------------------------------------
@@ -387,10 +389,10 @@ export function parseRiksdagKalendariumHtml(html: string): CalendarEvent[] {
387389

388390
// If no articles found, try <li> blocks (Pattern B).
389391
if (events.length === 0) {
390-
const liRe = /<li\b([^>]*class="[^"]*calendar[^"]*"[^>]*)>([\s\S]*?)<\/li>/gi;
392+
const liRe = /<li\b([^>]*class=(["'])[^"']*calendar[^"']*\2[^>]*)>([\s\S]*?)<\/li>/gi;
391393
for (const liMatch of html.matchAll(liRe)) {
392394
const attrs = liMatch[1] ?? '';
393-
const body = liMatch[2] ?? '';
395+
const body = liMatch[3] ?? '';
394396
const event = parseCalendarListItem(attrs, body);
395397
if (event) events.push(event);
396398
}
@@ -426,9 +428,9 @@ export function parseCalendarArticle(attrs: string, body: string): CalendarEvent
426428

427429
return {
428430
dtstart,
429-
org: normalizeOrgCode(org),
430-
akt: normalizeAkt(akt),
431-
summary: stripTags(summary).trim(),
431+
org: normalizeOrgCode(decodeHtmlEntities(org)),
432+
akt: normalizeAkt(decodeHtmlEntities(akt)),
433+
summary: decodeHtmlEntities(stripTags(summary).trim()),
432434
doc_refs: docRefs,
433435
source: 'web-fallback',
434436
};
@@ -456,9 +458,9 @@ export function parseCalendarListItem(attrs: string, body: string): CalendarEven
456458

457459
return {
458460
dtstart,
459-
org: normalizeOrgCode(org),
460-
akt: normalizeAkt(akt),
461-
summary: stripTags(summary).trim(),
461+
org: normalizeOrgCode(decodeHtmlEntities(org)),
462+
akt: normalizeAkt(decodeHtmlEntities(akt)),
463+
summary: decodeHtmlEntities(stripTags(summary).trim()),
462464
doc_refs: docRefs,
463465
source: 'web-fallback',
464466
};
@@ -470,15 +472,15 @@ export function parseCalendarListItem(attrs: string, body: string): CalendarEven
470472

471473
/** Extract the `datetime` attribute from a `<time>` element. */
472474
function extractDatetime(html: string): string | null {
473-
const m = html.match(/<time\b[^>]*\bdatetime="([^"]+)"/i);
474-
return m ? (m[1] ?? null) : null;
475+
const m = html.match(/<time\b[^>]*\bdatetime=(["'])(.*?)\1/i);
476+
return m ? (m[2] ?? null) : null;
475477
}
476478

477479
/** Extract a `data-{attr}` attribute value from a tag's attribute string. */
478480
function extractDataAttr(attrs: string, name: string): string | null {
479-
const re = new RegExp(`\\bdata-${name}="([^"]*)"`, 'i');
481+
const re = new RegExp(`\\bdata-${name}\\s*=\\s*(["'])(.*?)\\1`, 'i');
480482
const m = attrs.match(re);
481-
return m && m[1]?.trim() ? m[1].trim() : null;
483+
return m && m[2]?.trim() ? m[2].trim() : null;
482484
}
483485

484486
/** True when an element attribute string contains a `calendar-item` class token. */
@@ -492,9 +494,12 @@ function hasCalendarItemClass(attrs: string): boolean {
492494
* Uses a simple, non-greedy regex that covers the common markup pattern.
493495
*/
494496
function extractSpanText(html: string, name: string): string | null {
495-
const re = new RegExp(`<span\\b[^>]*class="[^"]*${name}[^"]*"[^>]*>([\\s\\S]*?)<\\/span>`, 'i');
497+
const re = new RegExp(
498+
`<span\\b[^>]*\\bclass\\s*=\\s*(["'])[^"']*${name}[^"']*\\1[^>]*>([\\s\\S]*?)<\\/span>`,
499+
'i',
500+
);
496501
const m = html.match(re);
497-
return m ? stripTags(m[1] ?? '').trim() || null : null;
502+
return m ? stripTags(m[2] ?? '').trim() || null : null;
498503
}
499504

500505
/**
@@ -509,9 +514,9 @@ function extractHeadingAndLinks(html: string): { summary: string; docRefs: strin
509514

510515
// Collect document reference links (/sv/dokument-och-lagar/… or /dokument/…).
511516
const docRefs: string[] = [];
512-
const hrefRe = /<a\b[^>]*\bhref="([^"]+)"[^>]*>/gi;
517+
const hrefRe = /<a\b[^>]*\bhref=(["'])([^"']+)\1[^>]*>/gi;
513518
for (const m of html.matchAll(hrefRe)) {
514-
const href = (m[1] ?? '').trim();
519+
const href = (m[2] ?? '').trim();
515520
if (isRiksdagDocumentHref(href)) {
516521
docRefs.push(href);
517522
}
@@ -731,9 +736,13 @@ const CALENDAR_DIR = path.join(REPO_ROOT, 'data', 'calendar');
731736
* The file is an object with `{ manifest, events }` so that consumers can
732737
* load a single file and get both the data and the provenance record.
733738
*/
734-
export function persistCalendarJson(from: string, result: CalendarFetchResult): string {
735-
fs.mkdirSync(CALENDAR_DIR, { recursive: true });
736-
const outputPath = path.join(CALENDAR_DIR, `${from}.json`);
739+
export function persistCalendarJson(
740+
from: string,
741+
result: CalendarFetchResult,
742+
outputDir: string = CALENDAR_DIR,
743+
): string {
744+
fs.mkdirSync(outputDir, { recursive: true });
745+
const outputPath = path.join(outputDir, `${from}.json`);
737746
const payload = {
738747
schema: 'riksdagsmonitor-calendar/1.0',
739748
manifest: result.manifest,

tests/fetch-calendar.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
*/
2020

2121
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22+
import fs from 'node:fs';
23+
import os from 'node:os';
24+
import path from 'node:path';
2225
import {
2326
isHtmlErrorResponse,
2427
callMcpCalendarEvents,
@@ -31,6 +34,7 @@ import {
3134
fetchWebCalendar,
3235
formatManifestMarkdown,
3336
parseCalendarArgs,
37+
persistCalendarJson,
3438
type CalendarFetchConfig,
3539
type CalendarEvent,
3640
} from '../scripts/fetch-calendar.js';
@@ -855,3 +859,90 @@ describe('CalendarEvent shape', () => {
855859
expect(event.source).toBe('web-fallback');
856860
});
857861
});
862+
863+
// ---------------------------------------------------------------------------
864+
// persistCalendarJson – filesystem persistence
865+
// ---------------------------------------------------------------------------
866+
867+
describe('persistCalendarJson', () => {
868+
let tmpDir: string;
869+
870+
beforeEach(() => {
871+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-calendar-test-'));
872+
});
873+
874+
afterEach(() => {
875+
fs.rmSync(tmpDir, { recursive: true, force: true });
876+
});
877+
878+
it('creates the output directory and writes a JSON file', () => {
879+
const outputDir = path.join(tmpDir, 'calendar');
880+
const result = {
881+
manifest: {
882+
path: 'mcp-primary' as const,
883+
date: '2026-04-28',
884+
dateTo: '2026-04-28',
885+
eventCount: 1,
886+
fetchedAt: '2026-04-28T00:00:00.000Z',
887+
},
888+
events: [
889+
{
890+
dtstart: '2026-04-28T10:00:00',
891+
org: 'FiU',
892+
akt: 'debatt',
893+
summary: 'Test event',
894+
doc_refs: [],
895+
source: 'mcp-primary' as const,
896+
},
897+
],
898+
};
899+
900+
const outPath = persistCalendarJson('2026-04-28', result, outputDir);
901+
902+
expect(fs.existsSync(outPath)).toBe(true);
903+
expect(outPath).toBe(path.join(outputDir, '2026-04-28.json'));
904+
});
905+
906+
it('written file contains correct schema, manifest, and events', () => {
907+
const outputDir = path.join(tmpDir, 'calendar');
908+
const event: CalendarEvent = {
909+
dtstart: '2026-04-28T10:00:00',
910+
org: 'KU',
911+
akt: 'votering',
912+
summary: 'Omröstning',
913+
doc_refs: ['/sv/dokument-och-lagar/betankanden/KU10/'],
914+
source: 'web-fallback',
915+
};
916+
const result = {
917+
manifest: {
918+
path: 'web-fallback' as const,
919+
date: '2026-04-28',
920+
dateTo: '2026-04-28',
921+
eventCount: 1,
922+
fetchedAt: '2026-04-28T01:00:00.000Z',
923+
primaryError: 'HTML error page',
924+
},
925+
events: [event],
926+
};
927+
928+
persistCalendarJson('2026-04-28', result, outputDir);
929+
930+
const content = JSON.parse(
931+
fs.readFileSync(path.join(outputDir, '2026-04-28.json'), 'utf8'),
932+
) as Record<string, unknown>;
933+
expect(content['schema']).toBe('riksdagsmonitor-calendar/1.0');
934+
expect(content['manifest']).toEqual(result.manifest);
935+
expect(content['events']).toEqual([event]);
936+
});
937+
938+
it('returns the output file path', () => {
939+
const outputDir = path.join(tmpDir, 'calendar');
940+
const result = {
941+
manifest: { path: 'none' as const, date: '2026-05-01', dateTo: '2026-05-01', eventCount: 0, fetchedAt: '2026-04-28T00:00:00.000Z' },
942+
events: [],
943+
};
944+
945+
const outPath = persistCalendarJson('2026-05-01', result, outputDir);
946+
expect(outPath).toBe(path.join(outputDir, '2026-05-01.json'));
947+
});
948+
});

0 commit comments

Comments
 (0)