@tricoteuses/senat 3.2.2 → 3.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/lib/src/other_types/agenda.d.ts +25 -0
- package/lib/src/scripts/data-download.js +13 -13
- package/lib/src/scripts/retrieve_agenda.js +46 -56
- package/lib/src/scripts/shared/data_download.d.ts +43 -0
- package/lib/src/scripts/shared/data_download.js +175 -0
- package/lib/src/server/agenda_api.d.ts +9 -0
- package/lib/src/server/agenda_api.js +32 -0
- package/lib/src/server/agenda_api_mapping.d.ts +56 -0
- package/lib/src/server/agenda_api_mapping.js +128 -0
- package/lib/src/utils/reunion_parsing.js +6 -1
- package/lib/tests/agenda/agendaApi.test.d.ts +1 -0
- package/lib/tests/agenda/agendaApi.test.js +83 -0
- package/lib/tests/agenda/agendaApiMapping.test.d.ts +1 -0
- package/lib/tests/agenda/agendaApiMapping.test.js +166 -0
- package/lib/tests/agenda/reunionApiFields.test.d.ts +1 -0
- package/lib/tests/agenda/reunionApiFields.test.js +44 -0
- package/lib/tests/dataDownload.test.d.ts +1 -0
- package/lib/tests/dataDownload.test.js +217 -0
- package/package.json +1 -1
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
export interface AgendaDeadline {
|
|
2
|
+
title: string | null;
|
|
3
|
+
detail: string | null;
|
|
4
|
+
dateOriginal: string | null;
|
|
5
|
+
date: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface AgendaPresidency {
|
|
8
|
+
title: string | null;
|
|
9
|
+
content: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface AgendaLink {
|
|
12
|
+
label: string;
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
1
15
|
export interface AgendaEvent {
|
|
2
16
|
id: string;
|
|
3
17
|
type: string | null;
|
|
@@ -12,6 +26,12 @@ export interface AgendaEvent {
|
|
|
12
26
|
captationVideo: boolean;
|
|
13
27
|
urlDossierSenat: string | null;
|
|
14
28
|
quantieme: string | null;
|
|
29
|
+
public?: boolean;
|
|
30
|
+
forecast?: boolean;
|
|
31
|
+
contentHtml?: string | null;
|
|
32
|
+
presidency?: AgendaPresidency | null;
|
|
33
|
+
links?: AgendaLink[] | null;
|
|
34
|
+
deadlines?: AgendaDeadline[] | null;
|
|
15
35
|
}
|
|
16
36
|
export type TimeSlot = "MATIN" | "APRES-MIDI" | "SOIR" | "UNKNOWN";
|
|
17
37
|
export interface Reunion {
|
|
@@ -34,6 +54,11 @@ export interface Reunion {
|
|
|
34
54
|
timecodeDebutVideo?: number;
|
|
35
55
|
timecodeFinVideo?: number;
|
|
36
56
|
odj?: ReunionOdj;
|
|
57
|
+
deadlines?: AgendaDeadline[] | null;
|
|
58
|
+
presidency?: AgendaPresidency | null;
|
|
59
|
+
links?: AgendaLink[] | null;
|
|
60
|
+
forecast?: boolean;
|
|
61
|
+
public?: boolean;
|
|
37
62
|
}
|
|
38
63
|
export interface ReunionOdjPoint {
|
|
39
64
|
objet: string | null;
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
-
|
|
3
|
-
function
|
|
2
|
+
import { runDataDownload } from "./shared/data_download.js";
|
|
3
|
+
function exec(command) {
|
|
4
4
|
try {
|
|
5
5
|
execSync(command, { stdio: "inherit" });
|
|
6
|
+
return { status: 0 };
|
|
6
7
|
}
|
|
7
8
|
catch (error) {
|
|
8
9
|
const execError = error;
|
|
9
|
-
|
|
10
|
-
console.error(`Error during: ${command}`, error);
|
|
11
|
-
process.exit(execError.status || 1);
|
|
12
|
-
}
|
|
10
|
+
return { status: execError.status ?? null, error };
|
|
13
11
|
}
|
|
14
12
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
const exitCode = runDataDownload({
|
|
14
|
+
exec,
|
|
15
|
+
now: () => new Date(),
|
|
16
|
+
rawArgs: process.argv.slice(2),
|
|
17
|
+
log: console.log,
|
|
18
|
+
warn: console.warn,
|
|
19
|
+
error: (message, error) => error === undefined ? console.error(message) : console.error(message, error),
|
|
20
|
+
});
|
|
21
|
+
process.exit(exitCode);
|
|
@@ -3,12 +3,11 @@ import fs from "fs-extra";
|
|
|
3
3
|
import { DateTime } from "luxon";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import * as git from "../server/git.js";
|
|
6
|
-
import { AGENDA_FOLDER,
|
|
7
|
-
import {
|
|
6
|
+
import { AGENDA_FOLDER, DATA_TRANSFORMED_FOLDER } from "../server/loaders.js";
|
|
7
|
+
import { fetchWeekAgendaEvents } from "../server/agenda_api.js";
|
|
8
8
|
import { getSessionsFromStart, UNDEFINED_SESSION } from "../other_types/sessions.js";
|
|
9
9
|
import { ID_DATE_FORMAT } from "./datautil.js";
|
|
10
10
|
import { assertExistingDirectory, commonOptions } from "./shared/cli_helpers.js";
|
|
11
|
-
import { fetchWithRetry } from "./shared/util.js";
|
|
12
11
|
import { buildReunionsByBucket } from "../utils/reunion_parsing.js";
|
|
13
12
|
import { buildSenatDossierIndex, buildDoslegReunionIndex } from "../utils/reunion_odj_building.js";
|
|
14
13
|
import { loadPreviousManifest, saveManifest, detectChanges } from "../utils/manifest.js";
|
|
@@ -23,13 +22,11 @@ const optionsDefinitions = [
|
|
|
23
22
|
];
|
|
24
23
|
let exitCode = 10; // 0: some data changed, 10: no modification
|
|
25
24
|
const options = commandLineArgs(optionsDefinitions);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
}
|
|
25
|
+
// The agenda.senat.fr API only serves dates after the launch of the new
|
|
26
|
+
// website (~mid-December 2025). For earlier dates, files already downloaded
|
|
27
|
+
// from the old agenda are kept as-is.
|
|
28
|
+
const AGENDA_API_COVERAGE_START = "2025-12-13";
|
|
29
|
+
const FR_TZ = "Europe/Paris";
|
|
33
30
|
function commitAndPushGit(datasetDir, options) {
|
|
34
31
|
if (options.commit) {
|
|
35
32
|
const errorCode = git.commitAndPush(datasetDir, "Nouvelle moisson", options.remote);
|
|
@@ -43,78 +40,71 @@ async function retrieveAgendas(options, sessions) {
|
|
|
43
40
|
const dataDir = assertExistingDirectory(options["dataDir"], "data directory");
|
|
44
41
|
const agendaRootDir = path.join(dataDir, AGENDA_FOLDER);
|
|
45
42
|
fs.ensureDirSync(agendaRootDir);
|
|
46
|
-
const originalAgendaDir = path.join(agendaRootDir, DATA_ORIGINAL_FOLDER);
|
|
47
|
-
fs.ensureDirSync(originalAgendaDir);
|
|
48
43
|
const transformedAgendaDir = path.join(agendaRootDir, DATA_TRANSFORMED_FOLDER);
|
|
49
44
|
if (options["parseAgenda"]) {
|
|
50
45
|
fs.ensureDirSync(transformedAgendaDir);
|
|
51
46
|
}
|
|
52
47
|
const dossierIndex = buildSenatDossierIndex(options);
|
|
53
48
|
const doslegReunionIndex = buildDoslegReunionIndex(options);
|
|
49
|
+
const coverageStart = DateTime.fromISO(AGENDA_API_COVERAGE_START, { zone: FR_TZ });
|
|
50
|
+
const now = DateTime.now().setZone(FR_TZ).startOf("day");
|
|
51
|
+
// Don't download agendas more than 15 days in the future.
|
|
52
|
+
const fifteenDaysFromNow = now.plus({ days: 15 });
|
|
54
53
|
for (const session of sessions) {
|
|
55
|
-
const
|
|
56
|
-
|
|
54
|
+
const sessionStart = DateTime.fromISO(`${session}-10-01`, { zone: FR_TZ });
|
|
55
|
+
const sessionEnd = DateTime.fromISO(`${session + 1}-09-30`, { zone: FR_TZ }).endOf("day");
|
|
56
|
+
if (sessionEnd < coverageStart || sessionStart > fifteenDaysFromNow)
|
|
57
|
+
continue;
|
|
57
58
|
const transformedAgendaSessionDir = path.join(transformedAgendaDir, `${session}`);
|
|
58
59
|
if (options["parseAgenda"]) {
|
|
59
60
|
fs.ensureDirSync(transformedAgendaSessionDir);
|
|
60
61
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (const date = new Date(session, 9, 1); date <= new Date(session + 1, 8, 30) && date <= fifteenDaysFromNow; date.setDate(date.getDate() + 1)) {
|
|
65
|
-
const agendaName = DateTime.fromJSDate(date).toFormat(EVENT_DATE_FORMAT);
|
|
66
|
-
const agendaFileName = DateTime.fromJSDate(date).toFormat(ID_DATE_FORMAT);
|
|
67
|
-
const agendaPath = path.join(originalAgendaSessionDir, `${agendaFileName}.html`);
|
|
62
|
+
const start = DateTime.max(sessionStart, coverageStart).startOf("week");
|
|
63
|
+
for (let date = start; date <= sessionEnd && date <= fifteenDaysFromNow; date = date.plus({ weeks: 1 })) {
|
|
64
|
+
let byDay;
|
|
68
65
|
try {
|
|
69
|
-
await
|
|
70
|
-
if (options["parseAgenda"]) {
|
|
71
|
-
const createdUids = await parseAgenda(transformedAgendaSessionDir, agendaFileName, agendaPath, dossierIndex, doslegReunionIndex);
|
|
72
|
-
await processManifestAndDeletions(transformedAgendaSessionDir, agendaFileName, session, createdUids);
|
|
73
|
-
}
|
|
66
|
+
byDay = await fetchWeekAgendaEvents(date.toISODate());
|
|
74
67
|
}
|
|
75
68
|
catch (error) {
|
|
76
|
-
console.error(error);
|
|
69
|
+
console.error(`Could not fetch agenda week ${date.toISODate()}:`, error);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const [dayISO, events] of byDay) {
|
|
73
|
+
const day = DateTime.fromISO(dayISO, { zone: FR_TZ });
|
|
74
|
+
if (day < sessionStart || day > sessionEnd || day > fifteenDaysFromNow)
|
|
75
|
+
continue;
|
|
76
|
+
const agendaFileName = day.toFormat(ID_DATE_FORMAT);
|
|
77
|
+
try {
|
|
78
|
+
if (options["parseAgenda"]) {
|
|
79
|
+
// Day without any event on the API side: write and delete
|
|
80
|
+
// nothing, to avoid corrupting existing data (partial API
|
|
81
|
+
// coverage or events wrongly removed from the agenda).
|
|
82
|
+
if (events.length === 0)
|
|
83
|
+
continue;
|
|
84
|
+
const createdUids = await processDayEvents(transformedAgendaSessionDir, agendaFileName, events, dossierIndex, doslegReunionIndex);
|
|
85
|
+
await processManifestAndDeletions(transformedAgendaSessionDir, agendaFileName, session, createdUids);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
console.error(error);
|
|
90
|
+
}
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
|
-
async function downloadAgenda(agendaName, agendaPath) {
|
|
82
|
-
const agendaUrl = `${SENAT_GLOBAL_AGENDA_URL_ROOT}/agl${agendaName}.html`;
|
|
83
|
-
if (!options["silent"]) {
|
|
84
|
-
console.log(`Downloading Agenda ${agendaUrl}…`);
|
|
85
|
-
}
|
|
86
|
-
const response = await fetchWithRetry(agendaUrl);
|
|
87
|
-
if (!response.ok) {
|
|
88
|
-
if (response.status === 404) {
|
|
89
|
-
console.warn(`Agenda ${agendaUrl} not found`);
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
throw new AgendaError(String(response.status), agendaName);
|
|
93
|
-
}
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const agendaContent = await response.arrayBuffer();
|
|
97
|
-
if (!agendaContent) {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
fs.writeFileSync(agendaPath, Buffer.from(agendaContent));
|
|
101
|
-
}
|
|
102
95
|
function writeGroupsAsFiles(dir, groups) {
|
|
103
96
|
for (const g of groups) {
|
|
104
97
|
const outPath = path.join(dir, `${g.uid}.json`);
|
|
105
98
|
fs.writeJSONSync(outPath, g, { spaces: 2 });
|
|
106
99
|
}
|
|
107
100
|
}
|
|
108
|
-
async function
|
|
101
|
+
async function processDayEvents(transformedAgendaSessionDir, agendaFileName, events, dossierBySenatUrl, doslegReunionIndex) {
|
|
109
102
|
if (!options["silent"])
|
|
110
|
-
console.log(`
|
|
111
|
-
const parsedAgendaEvents = await parseAgendaFromFile(agendaPath);
|
|
112
|
-
if (!parsedAgendaEvents?.length)
|
|
113
|
-
return [];
|
|
103
|
+
console.log(`Processing agenda ${agendaFileName} (${events.length} event(s))…`);
|
|
114
104
|
const flatPath = path.join(transformedAgendaSessionDir, `${agendaFileName}.json`);
|
|
115
|
-
fs.writeJSONSync(flatPath,
|
|
116
|
-
const byBucket = buildReunionsByBucket(
|
|
117
|
-
//
|
|
105
|
+
fs.writeJSONSync(flatPath, events, { spaces: 2 });
|
|
106
|
+
const byBucket = buildReunionsByBucket(events, dossierBySenatUrl, doslegReunionIndex);
|
|
107
|
+
// Collect all created UIDs
|
|
118
108
|
const createdUids = [];
|
|
119
109
|
// SP
|
|
120
110
|
if (byBucket.IDS.length > 0) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Dataset } from "../../server/datasets.js";
|
|
2
|
+
export declare const STATUS_FILENAME = "dataset_status.json";
|
|
3
|
+
export type DatasetStatusEntry = {
|
|
4
|
+
status: "ok" | "failed";
|
|
5
|
+
lastRunAt: string;
|
|
6
|
+
lastSuccessAt?: string;
|
|
7
|
+
lastFailureAt?: string;
|
|
8
|
+
lastError?: string;
|
|
9
|
+
consecutiveFailures: number;
|
|
10
|
+
firstFailureAt?: string;
|
|
11
|
+
};
|
|
12
|
+
export type DatasetStatusFile = Record<string, DatasetStatusEntry>;
|
|
13
|
+
export type ExecResult = {
|
|
14
|
+
status: number | null;
|
|
15
|
+
error?: unknown;
|
|
16
|
+
};
|
|
17
|
+
export type ExecFn = (command: string) => ExecResult;
|
|
18
|
+
export type DataDownloadLogger = {
|
|
19
|
+
log?: (message: string) => void;
|
|
20
|
+
warn?: (message: string) => void;
|
|
21
|
+
error?: (message: string, error?: unknown) => void;
|
|
22
|
+
};
|
|
23
|
+
export type DataDownloadDeps = DataDownloadLogger & {
|
|
24
|
+
rawArgs: string[];
|
|
25
|
+
exec: ExecFn;
|
|
26
|
+
now: () => Date;
|
|
27
|
+
};
|
|
28
|
+
export declare const CATEGORY_DATASETS: Array<{
|
|
29
|
+
category: string;
|
|
30
|
+
dataset: Dataset;
|
|
31
|
+
}>;
|
|
32
|
+
export type ParsedArgs = {
|
|
33
|
+
categories: string[];
|
|
34
|
+
dataDir?: string;
|
|
35
|
+
rest: string[];
|
|
36
|
+
};
|
|
37
|
+
export declare function parseArgs(rawArgs: string[]): ParsedArgs;
|
|
38
|
+
export declare function forwardArgs(parsedArgs: ParsedArgs, categories: string[]): string;
|
|
39
|
+
export declare function readStatusFile(statusFilePath: string): DatasetStatusFile;
|
|
40
|
+
export declare function writeStatusFile(statusFilePath: string, statuses: DatasetStatusFile): void;
|
|
41
|
+
export declare function recordSuccess(statuses: DatasetStatusFile, datasetName: string, now: Date): void;
|
|
42
|
+
export declare function recordFailure(statuses: DatasetStatusFile, datasetName: string, now: Date, errorMessage: string): void;
|
|
43
|
+
export declare function runDataDownload(deps: DataDownloadDeps): number;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { datasets } from "../../server/datasets.js";
|
|
4
|
+
export const STATUS_FILENAME = "dataset_status.json";
|
|
5
|
+
const MAX_ERROR_LENGTH = 2000;
|
|
6
|
+
export const CATEGORY_DATASETS = [
|
|
7
|
+
{ category: "Ameli", dataset: datasets.ameli },
|
|
8
|
+
{ category: "Debats", dataset: datasets.debats },
|
|
9
|
+
{ category: "DosLeg", dataset: datasets.dosleg },
|
|
10
|
+
{ category: "Questions", dataset: datasets.questions },
|
|
11
|
+
{ category: "Sens", dataset: datasets.sens },
|
|
12
|
+
];
|
|
13
|
+
// Options of retrieve_open_data whose next token is a value. They must not be
|
|
14
|
+
// mistaken for the dataDir positional argument.
|
|
15
|
+
const valueOptions = new Set([
|
|
16
|
+
"categories",
|
|
17
|
+
"k",
|
|
18
|
+
"sudo",
|
|
19
|
+
"clone",
|
|
20
|
+
"C",
|
|
21
|
+
"remote",
|
|
22
|
+
"r",
|
|
23
|
+
"fromSession",
|
|
24
|
+
"only-recent",
|
|
25
|
+
]);
|
|
26
|
+
export function parseArgs(rawArgs) {
|
|
27
|
+
const categories = [];
|
|
28
|
+
let dataDir;
|
|
29
|
+
const rest = [];
|
|
30
|
+
for (let index = 0; index < rawArgs.length; index++) {
|
|
31
|
+
const token = rawArgs[index];
|
|
32
|
+
const name = token.replace(/^-+/, "").split("=")[0];
|
|
33
|
+
if (name === "categories" || name === "k") {
|
|
34
|
+
const rawValues = token.includes("=") ? [token.slice(token.indexOf("=") + 1)] : [rawArgs[++index]];
|
|
35
|
+
for (const rawValue of rawValues) {
|
|
36
|
+
if (rawValue === undefined) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const value of rawValue.split(",")) {
|
|
40
|
+
if (value.length > 0) {
|
|
41
|
+
categories.push(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (token.startsWith("-") && valueOptions.has(name) && !token.includes("=")) {
|
|
48
|
+
rest.push(token);
|
|
49
|
+
const value = rawArgs[++index];
|
|
50
|
+
if (value !== undefined) {
|
|
51
|
+
rest.push(value);
|
|
52
|
+
}
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!token.startsWith("-") && dataDir === undefined) {
|
|
56
|
+
dataDir = token;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
rest.push(token);
|
|
60
|
+
}
|
|
61
|
+
return { categories: categories.length > 0 ? categories : ["All"], dataDir, rest };
|
|
62
|
+
}
|
|
63
|
+
export function forwardArgs(parsedArgs, categories) {
|
|
64
|
+
const parts = [...parsedArgs.rest];
|
|
65
|
+
if (parsedArgs.dataDir !== undefined) {
|
|
66
|
+
parts.push(parsedArgs.dataDir);
|
|
67
|
+
}
|
|
68
|
+
for (const category of categories) {
|
|
69
|
+
parts.push("--categories", category);
|
|
70
|
+
}
|
|
71
|
+
return parts.join(" ");
|
|
72
|
+
}
|
|
73
|
+
export function readStatusFile(statusFilePath) {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readJsonSync(statusFilePath);
|
|
76
|
+
return content && typeof content === "object" ? content : {};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function writeStatusFile(statusFilePath, statuses) {
|
|
83
|
+
fs.writeJsonSync(statusFilePath, statuses, { spaces: 2 });
|
|
84
|
+
}
|
|
85
|
+
export function recordSuccess(statuses, datasetName, now) {
|
|
86
|
+
statuses[datasetName] = {
|
|
87
|
+
consecutiveFailures: 0,
|
|
88
|
+
lastRunAt: now.toISOString(),
|
|
89
|
+
lastSuccessAt: now.toISOString(),
|
|
90
|
+
status: "ok",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function recordFailure(statuses, datasetName, now, errorMessage) {
|
|
94
|
+
const previous = statuses[datasetName];
|
|
95
|
+
statuses[datasetName] = {
|
|
96
|
+
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
|
|
97
|
+
firstFailureAt: previous?.firstFailureAt ?? now.toISOString(),
|
|
98
|
+
lastError: errorMessage.slice(0, MAX_ERROR_LENGTH),
|
|
99
|
+
lastFailureAt: now.toISOString(),
|
|
100
|
+
lastRunAt: now.toISOString(),
|
|
101
|
+
lastSuccessAt: previous?.lastSuccessAt,
|
|
102
|
+
status: "failed",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function execNormalized(deps, command) {
|
|
106
|
+
try {
|
|
107
|
+
return deps.exec(command);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const execError = error;
|
|
111
|
+
return { status: execError.status ?? null, error };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function isSuccessfulStatus(result) {
|
|
115
|
+
return result.status === 0 || result.status === 10;
|
|
116
|
+
}
|
|
117
|
+
function runDownstreamScript(deps, command) {
|
|
118
|
+
const result = execNormalized(deps, command);
|
|
119
|
+
if (isSuccessfulStatus(result)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
deps.error?.(`Error during: ${command}`, result.error);
|
|
123
|
+
return result.status || 1;
|
|
124
|
+
}
|
|
125
|
+
export function runDataDownload(deps) {
|
|
126
|
+
const parsedArgs = parseArgs(deps.rawArgs);
|
|
127
|
+
const chosen = parsedArgs.categories.includes("All")
|
|
128
|
+
? CATEGORY_DATASETS
|
|
129
|
+
: CATEGORY_DATASETS.filter(({ category }) => parsedArgs.categories.includes(category));
|
|
130
|
+
if (chosen.length === 0) {
|
|
131
|
+
deps.error?.(`No known dataset among categories: ${parsedArgs.categories.join(", ")}`);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
const statusFilePath = parsedArgs.dataDir !== undefined ? path.join(parsedArgs.dataDir, STATUS_FILENAME) : null;
|
|
135
|
+
const statuses = statusFilePath !== null ? readStatusFile(statusFilePath) : {};
|
|
136
|
+
const okCategories = [];
|
|
137
|
+
const failedCategories = [];
|
|
138
|
+
for (const { category, dataset } of chosen) {
|
|
139
|
+
// --all: fetch, unzip, repair encoding and import (same as the original script).
|
|
140
|
+
const command = `tsx src/scripts/retrieve_open_data.ts --all ${forwardArgs(parsedArgs, [category])}`.trim();
|
|
141
|
+
deps.log?.(`\n=== Retrieving dataset ${dataset.title} (${category}) ===`);
|
|
142
|
+
const result = execNormalized(deps, command);
|
|
143
|
+
if (!isSuccessfulStatus(result)) {
|
|
144
|
+
const errorMessage = result.error instanceof Error ? result.error.message : String(result.error);
|
|
145
|
+
recordFailure(statuses, dataset.database, deps.now(), errorMessage);
|
|
146
|
+
failedCategories.push(category);
|
|
147
|
+
deps.error?.(`Error during: ${command}`, result.error);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
recordSuccess(statuses, dataset.database, deps.now());
|
|
151
|
+
okCategories.push(category);
|
|
152
|
+
}
|
|
153
|
+
if (statusFilePath !== null) {
|
|
154
|
+
writeStatusFile(statusFilePath, statuses);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (failedCategories.length > 0) {
|
|
158
|
+
deps.warn?.(`\n⚠️ Warning: Some datasets failed to retrieve: ${failedCategories.join(", ")}. ` +
|
|
159
|
+
"Continuing with available datasets...\n");
|
|
160
|
+
}
|
|
161
|
+
if (okCategories.length === 0) {
|
|
162
|
+
deps.error?.("All datasets failed to retrieve; skipping data conversion.");
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
const exitCode = runDownstreamScript(deps, `tsx src/scripts/convert_data.ts ${forwardArgs(parsedArgs, okCategories)}`) ??
|
|
166
|
+
runDownstreamScript(deps, `cross-env TZ='Etc/UTC' tsx src/scripts/retrieve_agenda.ts ${deps.rawArgs.join(" ")} --parseAgenda --silent`) ??
|
|
167
|
+
runDownstreamScript(deps, "cross-env TZ='Etc/UTC' tsx src/scripts/retrieve_cr_seance.ts " +
|
|
168
|
+
`${deps.rawArgs.join(" ")} --parseDebats --silent`) ??
|
|
169
|
+
runDownstreamScript(deps, "cross-env TZ='Etc/UTC' tsx src/scripts/retrieve_cr_commission.ts " +
|
|
170
|
+
`${deps.rawArgs.join(" ")} --parseDebats --silent`) ??
|
|
171
|
+
runDownstreamScript(deps, `tsx src/scripts/retrieve_videos.ts ${deps.rawArgs.join(" ")} --silent`) ??
|
|
172
|
+
runDownstreamScript(deps, `tsx src/scripts/retrieve_collaborateurs.ts ${deps.rawArgs.join(" ")} --silent`) ??
|
|
173
|
+
0;
|
|
174
|
+
return exitCode;
|
|
175
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AgendaEvent } from "../other_types/agenda.js";
|
|
2
|
+
export declare const AGENDA_API_URL_ROOT = "https://www.senat.fr/api/v1/agenda";
|
|
3
|
+
/**
|
|
4
|
+
* Récupère une semaine d'agenda (7 jours à compter de la semaine de dateISO,
|
|
5
|
+
* même comportement que le site agenda.senat.fr) puis le détail de chaque
|
|
6
|
+
* événement. Retourne un Map jour ISO → AgendaEvent[] (vide pour les jours
|
|
7
|
+
* sans événement).
|
|
8
|
+
*/
|
|
9
|
+
export declare function fetchWeekAgendaEvents(dateISO: string): Promise<Map<string, AgendaEvent[]>>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { fetchWithRetry } from "../scripts/shared/util.js";
|
|
2
|
+
import { apiEventToAgendaEvent, computeQuantiemes, } from "./agenda_api_mapping.js";
|
|
3
|
+
export const AGENDA_API_URL_ROOT = "https://www.senat.fr/api/v1/agenda";
|
|
4
|
+
async function fetchJson(url) {
|
|
5
|
+
const response = await fetchWithRetry(url);
|
|
6
|
+
if (!response.ok) {
|
|
7
|
+
throw new Error(`Agenda API request failed (${response.status}): ${url}`);
|
|
8
|
+
}
|
|
9
|
+
return (await response.json());
|
|
10
|
+
}
|
|
11
|
+
async function fetchEventDetail(id) {
|
|
12
|
+
return fetchJson(`${AGENDA_API_URL_ROOT}/events/${id}`);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Récupère une semaine d'agenda (7 jours à compter de la semaine de dateISO,
|
|
16
|
+
* même comportement que le site agenda.senat.fr) puis le détail de chaque
|
|
17
|
+
* événement. Retourne un Map jour ISO → AgendaEvent[] (vide pour les jours
|
|
18
|
+
* sans événement).
|
|
19
|
+
*/
|
|
20
|
+
export async function fetchWeekAgendaEvents(dateISO) {
|
|
21
|
+
const url = `${AGENDA_API_URL_ROOT}/events?date=${dateISO}&week=1`;
|
|
22
|
+
const week = await fetchJson(url);
|
|
23
|
+
const byDay = new Map();
|
|
24
|
+
for (const [dayKey, dayValue] of Object.entries(week)) {
|
|
25
|
+
const day = dayValue?.day ?? dayKey;
|
|
26
|
+
const events = dayValue?.events ?? [];
|
|
27
|
+
const quantiemes = computeQuantiemes(events);
|
|
28
|
+
const details = await Promise.all(events.map((e) => fetchEventDetail(e.id)));
|
|
29
|
+
byDay.set(day, details.map((detail) => apiEventToAgendaEvent(detail, quantiemes[String(detail.id)] ?? null)));
|
|
30
|
+
}
|
|
31
|
+
return byDay;
|
|
32
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AgendaDeadline, AgendaEvent } from "../other_types/agenda.js";
|
|
2
|
+
export type AgendaApiEvent = {
|
|
3
|
+
id: number;
|
|
4
|
+
date: string;
|
|
5
|
+
hour: string | null;
|
|
6
|
+
title: string;
|
|
7
|
+
place: string | null;
|
|
8
|
+
instances: string[] | null;
|
|
9
|
+
forecast: boolean;
|
|
10
|
+
public: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type AgendaApiEventDetail = AgendaApiEvent & {
|
|
13
|
+
content: string | null;
|
|
14
|
+
links: {
|
|
15
|
+
label: string;
|
|
16
|
+
to: string;
|
|
17
|
+
target?: string;
|
|
18
|
+
}[] | null;
|
|
19
|
+
presidency: {
|
|
20
|
+
title: string;
|
|
21
|
+
content: string;
|
|
22
|
+
} | null;
|
|
23
|
+
deadlines: {
|
|
24
|
+
title: string;
|
|
25
|
+
date: string;
|
|
26
|
+
}[] | null;
|
|
27
|
+
addToCalendar: {
|
|
28
|
+
startDate: string;
|
|
29
|
+
startTime: string;
|
|
30
|
+
endDate: string;
|
|
31
|
+
endTime: string;
|
|
32
|
+
description: string;
|
|
33
|
+
} | null;
|
|
34
|
+
};
|
|
35
|
+
export type KnownType = "SP" | "COM" | "MC" | "OD" | "ID";
|
|
36
|
+
/**
|
|
37
|
+
* Classifies an instance from the new API into the 5 generic types of the
|
|
38
|
+
* old agenda (evt-seance, evt-instanz, evt-cemi, evt-deleg, evt-bureau).
|
|
39
|
+
*/
|
|
40
|
+
export declare function classifyInstance(instances: string[] | null | undefined): {
|
|
41
|
+
kind: KnownType;
|
|
42
|
+
type: string;
|
|
43
|
+
};
|
|
44
|
+
export declare function extractDossierUrl(contentHtml: string | null | undefined): string | null;
|
|
45
|
+
export declare function cleanHtmlToText(contentHtml: string | null | undefined): string | null;
|
|
46
|
+
export declare function parseDeadline(raw: {
|
|
47
|
+
title: string;
|
|
48
|
+
date: string;
|
|
49
|
+
}): AgendaDeadline;
|
|
50
|
+
/**
|
|
51
|
+
* Computes the session number ("Unique", "Première", …) of the public
|
|
52
|
+
* sittings of a day, same rule as the old parser (order of appearance
|
|
53
|
+
* in the agenda).
|
|
54
|
+
*/
|
|
55
|
+
export declare function computeQuantiemes(dayEvents: AgendaApiEvent[]): Record<string, string | null>;
|
|
56
|
+
export declare function apiEventToAgendaEvent(detail: AgendaApiEventDetail, quantieme: string | null): AgendaEvent;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
import * as cheerio from "cheerio";
|
|
3
|
+
import { getStartAndEndTimes } from "./agenda.js";
|
|
4
|
+
const FR_TZ = "Europe/Paris";
|
|
5
|
+
const GENERIC_TYPE = {
|
|
6
|
+
SP: "Séance publique",
|
|
7
|
+
COM: "Commissions",
|
|
8
|
+
MC: "Mission de contrôle",
|
|
9
|
+
OD: "Offices et délégations",
|
|
10
|
+
ID: "Instances décisionnelles",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Classifies an instance from the new API into the 5 generic types of the
|
|
14
|
+
* old agenda (evt-seance, evt-instanz, evt-cemi, evt-deleg, evt-bureau).
|
|
15
|
+
*/
|
|
16
|
+
export function classifyInstance(instances) {
|
|
17
|
+
const label = (instances?.[0] ?? "").trim();
|
|
18
|
+
const s = label
|
|
19
|
+
.normalize("NFKD")
|
|
20
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
21
|
+
.toLowerCase();
|
|
22
|
+
if (/\bseance\b.*\bpublique\b/.test(s))
|
|
23
|
+
return { kind: "SP", type: GENERIC_TYPE.SP };
|
|
24
|
+
if (/^commission/.test(s))
|
|
25
|
+
return { kind: "COM", type: GENERIC_TYPE.COM };
|
|
26
|
+
if (/^mi\b/.test(s) || /^mission\b/.test(s) || /^ce\b/.test(s))
|
|
27
|
+
return { kind: "MC", type: GENERIC_TYPE.MC };
|
|
28
|
+
if (/^(delegation|office)/.test(s))
|
|
29
|
+
return { kind: "OD", type: GENERIC_TYPE.OD };
|
|
30
|
+
return { kind: "ID", type: GENERIC_TYPE.ID };
|
|
31
|
+
}
|
|
32
|
+
export function extractDossierUrl(contentHtml) {
|
|
33
|
+
if (!contentHtml)
|
|
34
|
+
return null;
|
|
35
|
+
const $ = cheerio.load(contentHtml);
|
|
36
|
+
const href = $("a[href*='senat.fr/dossier-legislatif']").first().attr("href");
|
|
37
|
+
return href ?? null;
|
|
38
|
+
}
|
|
39
|
+
export function cleanHtmlToText(contentHtml) {
|
|
40
|
+
if (!contentHtml)
|
|
41
|
+
return null;
|
|
42
|
+
const $ = cheerio.load(contentHtml);
|
|
43
|
+
$("br").replaceWith("\n");
|
|
44
|
+
const text = $("body").text() ?? $.root().text();
|
|
45
|
+
const cleaned = text
|
|
46
|
+
.replace(/\u00a0/g, " ")
|
|
47
|
+
.replace(/[ \t]+/g, " ")
|
|
48
|
+
.replace(/\n\s*\n/g, "\n")
|
|
49
|
+
.trim();
|
|
50
|
+
return cleaned ? cleaned.replace(/^- /, "").trim() : null;
|
|
51
|
+
}
|
|
52
|
+
export function parseDeadline(raw) {
|
|
53
|
+
const [titlePart, ...restParts] = (raw.title ?? "").split(/<br\s*\/?>/i);
|
|
54
|
+
const title = cleanHtmlToText(titlePart ?? null) ?? null;
|
|
55
|
+
const detail = restParts.length > 0 ? (cleanHtmlToText(restParts.join("\n")) ?? null) : null;
|
|
56
|
+
const dateOriginal = (raw.date ?? "").replace(/\u00a0/g, " ").trim() || null;
|
|
57
|
+
let date = null;
|
|
58
|
+
if (dateOriginal) {
|
|
59
|
+
const parsed = DateTime.fromFormat(dateOriginal, "EEEE d MMMM yyyy HH'h'mm", {
|
|
60
|
+
locale: "fr",
|
|
61
|
+
zone: FR_TZ,
|
|
62
|
+
});
|
|
63
|
+
if (parsed.isValid)
|
|
64
|
+
date = parsed.toISO();
|
|
65
|
+
}
|
|
66
|
+
return { title, detail, dateOriginal, date };
|
|
67
|
+
}
|
|
68
|
+
function toAgendaLinks(links) {
|
|
69
|
+
if (!links?.length)
|
|
70
|
+
return null;
|
|
71
|
+
return links.map((l) => ({ label: l.label, url: l.to }));
|
|
72
|
+
}
|
|
73
|
+
function toAgendaPresidency(presidency) {
|
|
74
|
+
if (!presidency)
|
|
75
|
+
return null;
|
|
76
|
+
return { title: presidency.title ?? null, content: presidency.content ?? null };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Computes the session number ("Unique", "Première", …) of the public
|
|
80
|
+
* sittings of a day, same rule as the old parser (order of appearance
|
|
81
|
+
* in the agenda).
|
|
82
|
+
*/
|
|
83
|
+
export function computeQuantiemes(dayEvents) {
|
|
84
|
+
const result = {};
|
|
85
|
+
const seances = dayEvents.filter((e) => classifyInstance(e.instances).kind === "SP");
|
|
86
|
+
const labels = ["Unique", "Première", "Deuxième", "Troisième", "Quatrième", "Cinquième"];
|
|
87
|
+
for (const event of dayEvents) {
|
|
88
|
+
if (classifyInstance(event.instances).kind !== "SP") {
|
|
89
|
+
result[String(event.id)] = null;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const index = seances.indexOf(event);
|
|
93
|
+
result[String(event.id)] =
|
|
94
|
+
seances.length === 1 && index === 0 ? labels[0] : (labels[index + 1] ?? "Non défini");
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
export function apiEventToAgendaEvent(detail, quantieme) {
|
|
99
|
+
const { kind, type } = classifyInstance(detail.instances);
|
|
100
|
+
const hour = (detail.hour ?? "").trim();
|
|
101
|
+
const { startTime } = getStartAndEndTimes(hour, detail.date);
|
|
102
|
+
// The API provides no video capture flag; public sittings are always
|
|
103
|
+
// filmed, unlike other instances.
|
|
104
|
+
const captationVideo = kind === "SP";
|
|
105
|
+
return {
|
|
106
|
+
id: String(detail.id),
|
|
107
|
+
type,
|
|
108
|
+
date: detail.date,
|
|
109
|
+
startTime,
|
|
110
|
+
// The end time provided by the API (addToCalendar) is a heuristic
|
|
111
|
+
// (defaults to +2h): null is preferred to guarantee data reliability.
|
|
112
|
+
endTime: null,
|
|
113
|
+
timeOriginal: hour || null,
|
|
114
|
+
titre: (detail.title ?? "").trim(),
|
|
115
|
+
organe: kind === "SP" ? "Séance publique" : (detail.instances?.[0]?.trim() ?? null),
|
|
116
|
+
objet: cleanHtmlToText(detail.content),
|
|
117
|
+
lieu: (detail.place ?? "").trim() || null,
|
|
118
|
+
captationVideo,
|
|
119
|
+
urlDossierSenat: extractDossierUrl(detail.content),
|
|
120
|
+
quantieme,
|
|
121
|
+
public: detail.public,
|
|
122
|
+
forecast: detail.forecast,
|
|
123
|
+
contentHtml: detail.content ?? null,
|
|
124
|
+
presidency: toAgendaPresidency(detail.presidency),
|
|
125
|
+
links: toAgendaLinks(detail.links),
|
|
126
|
+
deadlines: detail.deadlines?.length ? detail.deadlines.map(parseDeadline) : null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -41,6 +41,11 @@ function toReunion(e, dossierBySenatUrl, uid, doslegReunionIndex) {
|
|
|
41
41
|
events: [e], // TODO remove
|
|
42
42
|
odj: buildOdj([e], dossierBySenatUrl, doslegReunionIndex),
|
|
43
43
|
lieu: e.lieu || undefined,
|
|
44
|
+
deadlines: e.deadlines ?? null,
|
|
45
|
+
presidency: e.presidency ?? null,
|
|
46
|
+
links: e.links ?? null,
|
|
47
|
+
forecast: e.forecast,
|
|
48
|
+
public: e.public,
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
export function buildReunionsByBucket(events, dossierBySenatUrl, doslegReunionIndex) {
|
|
@@ -57,7 +62,7 @@ export function buildReunionsByBucket(events, dossierBySenatUrl, doslegReunionIn
|
|
|
57
62
|
const uid = makeReunionUid(e.date, kind, e.id, e.organe ?? null);
|
|
58
63
|
out[bucket].push(toReunion(e, dossierBySenatUrl, uid, doslegReunionIndex));
|
|
59
64
|
}
|
|
60
|
-
//
|
|
65
|
+
// Stable sort by bucket (date + time, unknowns at the end)
|
|
61
66
|
for (const k of Object.keys(out)) {
|
|
62
67
|
out[k].sort((a, b) => {
|
|
63
68
|
const da = DateTime.fromISO(`${a.date}T${a.startTime || "23:59:59.999+02:00"}`, { zone: PARIS }).toMillis();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Tests for the Sénat agenda API client (mocked fetch)
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { AGENDA_API_URL_ROOT, fetchWeekAgendaEvents } from "../../src/server/agenda_api.js";
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
});
|
|
7
|
+
function jsonResponse(body, status = 200) {
|
|
8
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
9
|
+
}
|
|
10
|
+
describe("fetchWeekAgendaEvents", () => {
|
|
11
|
+
it("fetches the week then each event detail, and returns per-day AgendaEvents", async () => {
|
|
12
|
+
const liste = {
|
|
13
|
+
"2026-06-16": {
|
|
14
|
+
day: "2026-06-16",
|
|
15
|
+
events: [
|
|
16
|
+
{
|
|
17
|
+
id: 557355,
|
|
18
|
+
date: "2026-06-16",
|
|
19
|
+
hour: "9h30",
|
|
20
|
+
title: "44 questions orales",
|
|
21
|
+
place: "Hémicycle",
|
|
22
|
+
instances: ["Séance publique"],
|
|
23
|
+
forecast: false,
|
|
24
|
+
public: true,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 557356,
|
|
28
|
+
date: "2026-06-16",
|
|
29
|
+
hour: "14h30",
|
|
30
|
+
title: "Séance 2",
|
|
31
|
+
place: "Hémicycle",
|
|
32
|
+
instances: ["Séance publique"],
|
|
33
|
+
forecast: false,
|
|
34
|
+
public: true,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
"2026-06-17": { day: "2026-06-17", events: [] },
|
|
39
|
+
};
|
|
40
|
+
const detail = (id, title) => ({
|
|
41
|
+
id,
|
|
42
|
+
date: "2026-06-16",
|
|
43
|
+
hour: "14h30",
|
|
44
|
+
title,
|
|
45
|
+
place: "Hémicycle",
|
|
46
|
+
instances: ["Séance publique"],
|
|
47
|
+
forecast: false,
|
|
48
|
+
public: true,
|
|
49
|
+
content: `<p>- ${title}</p>`,
|
|
50
|
+
links: null,
|
|
51
|
+
presidency: null,
|
|
52
|
+
deadlines: null,
|
|
53
|
+
addToCalendar: null,
|
|
54
|
+
});
|
|
55
|
+
const fetchMock = vi.fn(async (url) => {
|
|
56
|
+
const u = String(url);
|
|
57
|
+
if (u === `${AGENDA_API_URL_ROOT}/events?date=2026-06-16&week=1`)
|
|
58
|
+
return jsonResponse(liste);
|
|
59
|
+
if (u === `${AGENDA_API_URL_ROOT}/events/557355`)
|
|
60
|
+
return jsonResponse(detail(557355, "44 questions orales"));
|
|
61
|
+
if (u === `${AGENDA_API_URL_ROOT}/events/557356`)
|
|
62
|
+
return jsonResponse(detail(557356, "Séance 2"));
|
|
63
|
+
throw new Error(`unexpected url ${u}`);
|
|
64
|
+
});
|
|
65
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
66
|
+
const byDay = await fetchWeekAgendaEvents("2026-06-16");
|
|
67
|
+
expect([...byDay.keys()].sort()).toEqual(["2026-06-16", "2026-06-17"]);
|
|
68
|
+
expect(byDay.get("2026-06-17")).toEqual([]);
|
|
69
|
+
const day16 = byDay.get("2026-06-16");
|
|
70
|
+
expect(day16).toHaveLength(2);
|
|
71
|
+
expect(day16[0].id).toBe("557355");
|
|
72
|
+
expect(day16[0].objet).toBe("44 questions orales");
|
|
73
|
+
// 1 week request + 2 detail requests
|
|
74
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
75
|
+
// quantiemes computed from the order of sittings of the day
|
|
76
|
+
expect(day16[0].quantieme).toBe("Première");
|
|
77
|
+
expect(day16[1].quantieme).toBe("Deuxième");
|
|
78
|
+
});
|
|
79
|
+
it("propagates an error when the week request fails", async () => {
|
|
80
|
+
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ error: "boom" }, 500)));
|
|
81
|
+
await expect(fetchWeekAgendaEvents("2026-06-16")).rejects.toThrow();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Tests for the agenda.senat.fr API → AgendaEvent mapping (historical schema kept)
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { apiEventToAgendaEvent, classifyInstance, computeQuantiemes, extractDossierUrl, parseDeadline, } from "../../src/server/agenda_api_mapping.js";
|
|
7
|
+
import { makeReunionUid } from "../../src/utils/reunion_parsing.js";
|
|
8
|
+
const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "agenda_api");
|
|
9
|
+
const loadFixture = (name) => JSON.parse(readFileSync(path.join(fixturesDir, name), "utf-8"));
|
|
10
|
+
describe("classifyInstance", () => {
|
|
11
|
+
it("maps Séance publique to SP", () => {
|
|
12
|
+
expect(classifyInstance(["Séance publique"])).toEqual({ kind: "SP", type: "Séance publique" });
|
|
13
|
+
});
|
|
14
|
+
it("maps commissions (singular or plural) to COM", () => {
|
|
15
|
+
expect(classifyInstance(["Commission des lois"])).toEqual({ kind: "COM", type: "Commissions" });
|
|
16
|
+
expect(classifyInstance(["Commission aménagement du territoire / développement durable"])).toEqual({
|
|
17
|
+
kind: "COM",
|
|
18
|
+
type: "Commissions",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
it("maps missions (MI) to MC", () => {
|
|
22
|
+
expect(classifyInstance(["MI Loi littoral Loi montagne"])).toEqual({ kind: "MC", type: "Mission de contrôle" });
|
|
23
|
+
});
|
|
24
|
+
it("maps commissions d'enquête (CE) to MC", () => {
|
|
25
|
+
expect(classifyInstance(["CE marges grande distribution"])).toEqual({ kind: "MC", type: "Mission de contrôle" });
|
|
26
|
+
// "CE Universités" = commission of inquiry on universities (like
|
|
27
|
+
// "CEMAGRDI", classified IDM by the old agenda)
|
|
28
|
+
expect(classifyInstance(["CE Universités"])).toEqual({ kind: "MC", type: "Mission de contrôle" });
|
|
29
|
+
});
|
|
30
|
+
it("maps délégations and offices to OD", () => {
|
|
31
|
+
expect(classifyInstance(["Délégation aux entreprises"])).toEqual({ kind: "OD", type: "Offices et délégations" });
|
|
32
|
+
expect(classifyInstance(["Office parlementaire d'évaluation des choix scient. tech."])).toEqual({
|
|
33
|
+
kind: "OD",
|
|
34
|
+
type: "Offices et délégations",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it("maps remaining instances (Présidence, instances, null) to ID", () => {
|
|
38
|
+
expect(classifyInstance(["Présidence"])).toEqual({ kind: "ID", type: "Instances décisionnelles" });
|
|
39
|
+
expect(classifyInstance(["Conférence des Présidents"])).toEqual({ kind: "ID", type: "Instances décisionnelles" });
|
|
40
|
+
expect(classifyInstance(null)).toEqual({ kind: "ID", type: "Instances décisionnelles" });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("extractDossierUrl", () => {
|
|
44
|
+
it("extracts the first dossier-legislatif link from content HTML", () => {
|
|
45
|
+
const detail = loadFixture("event_557372.json");
|
|
46
|
+
expect(extractDossierUrl(detail.content)).toBe("https://www.senat.fr/dossier-legislatif/ppl24-172.html");
|
|
47
|
+
});
|
|
48
|
+
it("returns null when content has no dossier link", () => {
|
|
49
|
+
expect(extractDossierUrl("<p>- 44 questions orales</p>")).toBeNull();
|
|
50
|
+
expect(extractDossierUrl(null)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe("parseDeadline", () => {
|
|
54
|
+
it("splits title/detail on <br> and normalizes the date to ISO", () => {
|
|
55
|
+
const detail = loadFixture("event_557372.json");
|
|
56
|
+
const parsed = parseDeadline(detail.deadlines[0]);
|
|
57
|
+
expect(parsed.title).toBe("Délai limite pour le dépôt des amendements de commission");
|
|
58
|
+
expect(parsed.detail).toBe("Proposition de loi portant diverses dispositions d'adaptation du droit des outre-mer");
|
|
59
|
+
expect(parsed.dateOriginal).toBe("Vendredi 29 mai 2026 12h00");
|
|
60
|
+
expect(parsed.date).toBe("2026-05-29T12:00:00.000+02:00");
|
|
61
|
+
});
|
|
62
|
+
it("handles deadline without <br> and unparseable date", () => {
|
|
63
|
+
const parsed = parseDeadline({ title: "Délai limite pour le dépôt des amendements", date: "date invalide" });
|
|
64
|
+
expect(parsed.title).toBe("Délai limite pour le dépôt des amendements");
|
|
65
|
+
expect(parsed.detail).toBeNull();
|
|
66
|
+
expect(parsed.dateOriginal).toBe("date invalide");
|
|
67
|
+
expect(parsed.date).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("computeQuantiemes", () => {
|
|
71
|
+
it("numbers séances publiques of a day in order", () => {
|
|
72
|
+
const week = JSON.parse(readFileSync(path.join(fixturesDir, "week_2026-06-15.json"), "utf-8"));
|
|
73
|
+
const day16 = week["2026-06-16"].events;
|
|
74
|
+
const quant = computeQuantiemes(day16);
|
|
75
|
+
expect(quant["557355"]).toBe("Première");
|
|
76
|
+
expect(quant["557356"]).toBe("Deuxième");
|
|
77
|
+
expect(quant["557358"]).toBe("Troisième");
|
|
78
|
+
expect(quant["557362"]).toBe("Quatrième");
|
|
79
|
+
expect(quant["557366"]).toBe("Cinquième");
|
|
80
|
+
// Non-SP events get null
|
|
81
|
+
expect(quant["557795"]).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe("apiEventToAgendaEvent", () => {
|
|
85
|
+
it("maps a séance publique with deadlines, presidency and dossier link (557372)", () => {
|
|
86
|
+
const detail = loadFixture("event_557372.json");
|
|
87
|
+
const event = apiEventToAgendaEvent(detail, "Deuxième");
|
|
88
|
+
expect(event.id).toBe("557372");
|
|
89
|
+
expect(event.type).toBe("Séance publique");
|
|
90
|
+
expect(event.organe).toBe("Séance publique");
|
|
91
|
+
expect(event.date).toBe("2026-06-17");
|
|
92
|
+
expect(event.timeOriginal).toBe("16h30");
|
|
93
|
+
expect(event.startTime).toBe("14:30:00.000Z");
|
|
94
|
+
expect(event.endTime).toBeNull();
|
|
95
|
+
expect(event.titre).toBe("PPL Adaptation du droit des outre-mer");
|
|
96
|
+
expect(event.lieu).toBe("Hémicycle");
|
|
97
|
+
expect(event.captationVideo).toBe(true);
|
|
98
|
+
expect(event.quantieme).toBe("Deuxième");
|
|
99
|
+
expect(event.public).toBe(true);
|
|
100
|
+
expect(event.forecast).toBe(false);
|
|
101
|
+
expect(event.urlDossierSenat).toBe("https://www.senat.fr/dossier-legislatif/ppl24-172.html");
|
|
102
|
+
expect(event.objet).toBe("Proposition de loi portant diverses dispositions d'adaptation du droit des outre-mer, présentée par Mme Micheline JACQUES et plusieurs de ses collègues (texte de la commission, n° 691, 2025-2026)");
|
|
103
|
+
expect(event.deadlines).toHaveLength(3);
|
|
104
|
+
expect(event.deadlines[0].title).toBe("Délai limite pour le dépôt des amendements de commission");
|
|
105
|
+
expect(event.deadlines[0].date).toBe("2026-05-29T12:00:00.000+02:00");
|
|
106
|
+
expect(event.presidency).toEqual({
|
|
107
|
+
title: "Présidence de séance",
|
|
108
|
+
content: detail.presidency.content,
|
|
109
|
+
});
|
|
110
|
+
expect(event.links).toBeNull();
|
|
111
|
+
expect(event.contentHtml).toBe(detail.content);
|
|
112
|
+
});
|
|
113
|
+
it("maps a commission event (557828)", () => {
|
|
114
|
+
const detail = loadFixture("event_557828.json");
|
|
115
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
116
|
+
expect(event.type).toBe("Commissions");
|
|
117
|
+
expect(event.organe).toBe("Commission de la culture");
|
|
118
|
+
expect(event.lieu).toBe("Salle A245 - 2ème étage Ouest");
|
|
119
|
+
expect(event.startTime).toBe("13:30:00.000Z"); // 15h30 Paris (CEST)
|
|
120
|
+
expect(event.captationVideo).toBe(false);
|
|
121
|
+
expect(event.quantieme).toBeNull();
|
|
122
|
+
expect(makeReunionUid(event.date, "COM", event.id, event.organe)).toBe("RUSN20260616IDCCOCU557828");
|
|
123
|
+
});
|
|
124
|
+
it("maps a mission d'information (557825)", () => {
|
|
125
|
+
const detail = loadFixture("event_557825.json");
|
|
126
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
127
|
+
expect(event.type).toBe("Mission de contrôle");
|
|
128
|
+
expect(event.organe).toBe("MI Loi littoral Loi montagne");
|
|
129
|
+
expect(makeReunionUid(event.date, "MC", event.id, event.organe)).toBe("RUSN20260616IDMMILOLILO557825");
|
|
130
|
+
});
|
|
131
|
+
it("maps a délégation (557712) and an office (557752)", () => {
|
|
132
|
+
const deleg = apiEventToAgendaEvent(loadFixture("event_557712.json"), null);
|
|
133
|
+
expect(deleg.type).toBe("Offices et délégations");
|
|
134
|
+
expect(deleg.organe).toBe("Délégation aux entreprises");
|
|
135
|
+
const office = apiEventToAgendaEvent(loadFixture("event_557752.json"), null);
|
|
136
|
+
expect(office.type).toBe("Offices et délégations");
|
|
137
|
+
});
|
|
138
|
+
it("maps a Présidence event to Instances décisionnelles (557933)", () => {
|
|
139
|
+
const detail = loadFixture("event_557933.json");
|
|
140
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
141
|
+
expect(event.type).toBe("Instances décisionnelles");
|
|
142
|
+
expect(event.organe).toBe("Présidence");
|
|
143
|
+
});
|
|
144
|
+
it("maps textual hour 'Matin' like the old parser (557937)", () => {
|
|
145
|
+
const detail = loadFixture("event_557937.json");
|
|
146
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
147
|
+
expect(event.timeOriginal).toBe("Matin");
|
|
148
|
+
expect(event.startTime).toBe("08:00:00.000Z"); // 10h00 Paris (CEST)
|
|
149
|
+
expect(event.endTime).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
it("maps an event without hour (557939)", () => {
|
|
152
|
+
const detail = loadFixture("event_557939.json");
|
|
153
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
154
|
+
expect(event.timeOriginal).toBeNull();
|
|
155
|
+
expect(event.startTime).toBeNull();
|
|
156
|
+
expect(event.endTime).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
it("maps an event without place (Conférence des Présidents)", () => {
|
|
159
|
+
const detail = loadFixture("event_558859.json");
|
|
160
|
+
const event = apiEventToAgendaEvent(detail, null);
|
|
161
|
+
expect(event.lieu).toBeNull();
|
|
162
|
+
expect(event.organe).toBeNull();
|
|
163
|
+
expect(event.type).toBe("Instances décisionnelles");
|
|
164
|
+
expect(event.public).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Checks that buildReunionsByBucket propagates the new API fields into Reunion
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { buildReunionsByBucket } from "../../src/utils/reunion_parsing.js";
|
|
4
|
+
describe("buildReunionsByBucket with API fields", () => {
|
|
5
|
+
it("copies deadlines, presidency, links, forecast and public into the Reunion", () => {
|
|
6
|
+
const event = {
|
|
7
|
+
id: "557372",
|
|
8
|
+
type: "Séance publique",
|
|
9
|
+
date: "2026-06-17",
|
|
10
|
+
startTime: "14:30:00.000Z",
|
|
11
|
+
endTime: null,
|
|
12
|
+
timeOriginal: "16h30",
|
|
13
|
+
titre: "PPL Adaptation du droit des outre-mer",
|
|
14
|
+
organe: "Séance publique",
|
|
15
|
+
objet: "Proposition de loi",
|
|
16
|
+
lieu: "Hémicycle",
|
|
17
|
+
captationVideo: true,
|
|
18
|
+
urlDossierSenat: "https://www.senat.fr/dossier-legislatif/ppl24-172.html",
|
|
19
|
+
quantieme: "Deuxième",
|
|
20
|
+
public: true,
|
|
21
|
+
forecast: false,
|
|
22
|
+
contentHtml: "<p>- Proposition de loi</p>",
|
|
23
|
+
presidency: { title: "Présidence de séance", content: "<p>M. X</p>" },
|
|
24
|
+
links: null,
|
|
25
|
+
deadlines: [
|
|
26
|
+
{
|
|
27
|
+
title: "Délai limite pour le dépôt des amendements de séance",
|
|
28
|
+
detail: "Proposition de loi portant diverses dispositions d'adaptation du droit des outre-mer",
|
|
29
|
+
dateOriginal: "Vendredi 12 juin 2026 12h00",
|
|
30
|
+
date: "2026-06-12T12:00:00.000+02:00",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
const buckets = buildReunionsByBucket([event], {});
|
|
35
|
+
expect(buckets.IDS).toHaveLength(1);
|
|
36
|
+
const reunion = buckets.IDS[0];
|
|
37
|
+
expect(reunion.uid).toBe("RUSN20260617IDS557372");
|
|
38
|
+
expect(reunion.deadlines).toEqual(event.deadlines);
|
|
39
|
+
expect(reunion.presidency).toEqual(event.presidency);
|
|
40
|
+
expect(reunion.links).toBeNull();
|
|
41
|
+
expect(reunion.forecast).toBe(false);
|
|
42
|
+
expect(reunion.public).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { forwardArgs, parseArgs, readStatusFile, recordFailure, recordSuccess, runDataDownload, STATUS_FILENAME, writeStatusFile, } from "../src/scripts/shared/data_download.js";
|
|
6
|
+
let dataDir;
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (dataDir !== undefined) {
|
|
9
|
+
rmSync(dataDir, { force: true, recursive: true });
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
function createTempDataDir() {
|
|
13
|
+
dataDir = mkdtempSync(path.join(tmpdir(), "senat-data-download-"));
|
|
14
|
+
return dataDir;
|
|
15
|
+
}
|
|
16
|
+
const fixedDate = new Date("2026-09-08T10:00:00Z");
|
|
17
|
+
function makeExec(respond) {
|
|
18
|
+
const commands = [];
|
|
19
|
+
return {
|
|
20
|
+
commands,
|
|
21
|
+
exec: (command) => {
|
|
22
|
+
commands.push(command);
|
|
23
|
+
return respond(command) ?? { status: 0 };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe("parseArgs", () => {
|
|
28
|
+
it("defaults categories to All when none are provided", () => {
|
|
29
|
+
const parsed = parseArgs([]);
|
|
30
|
+
expect(parsed.categories).toEqual(["All"]);
|
|
31
|
+
expect(parsed.dataDir).toBeUndefined();
|
|
32
|
+
expect(parsed.rest).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
it("extracts the dataDir positional argument and forwards the other options", () => {
|
|
35
|
+
const rawArgs = [
|
|
36
|
+
"--all",
|
|
37
|
+
"/app/senat-data",
|
|
38
|
+
"--fromSession",
|
|
39
|
+
"2022",
|
|
40
|
+
"--fetchDocuments",
|
|
41
|
+
"--parseDocuments",
|
|
42
|
+
"--only-recent",
|
|
43
|
+
"30",
|
|
44
|
+
"--commit",
|
|
45
|
+
"--keepDir",
|
|
46
|
+
];
|
|
47
|
+
const parsed = parseArgs(rawArgs);
|
|
48
|
+
expect(parsed.dataDir).toBe("/app/senat-data");
|
|
49
|
+
expect(parsed.rest).toEqual([
|
|
50
|
+
"--all",
|
|
51
|
+
"--fromSession",
|
|
52
|
+
"2022",
|
|
53
|
+
"--fetchDocuments",
|
|
54
|
+
"--parseDocuments",
|
|
55
|
+
"--only-recent",
|
|
56
|
+
"30",
|
|
57
|
+
"--commit",
|
|
58
|
+
"--keepDir",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it("collects --categories values including repeated occurrences", () => {
|
|
62
|
+
const parsed = parseArgs(["/data", "--categories", "Ameli", "--categories", "Sens"]);
|
|
63
|
+
expect(parsed.categories).toEqual(["Ameli", "Sens"]);
|
|
64
|
+
expect(parsed.dataDir).toBe("/data");
|
|
65
|
+
expect(parsed.rest).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
it("supports the -k alias and comma-separated values", () => {
|
|
68
|
+
const parsed = parseArgs(["-k", "Sens,Ameli", "/data"]);
|
|
69
|
+
expect(parsed.categories).toEqual(["Sens", "Ameli"]);
|
|
70
|
+
});
|
|
71
|
+
it("supports the --categories= form", () => {
|
|
72
|
+
const parsed = parseArgs(["--categories=Sens", "/data"]);
|
|
73
|
+
expect(parsed.categories).toEqual(["Sens"]);
|
|
74
|
+
});
|
|
75
|
+
it("does not mistake valued options for the dataDir positional", () => {
|
|
76
|
+
const parsed = parseArgs(["--sudo", "postgres", "--remote", "origin", "true-data", "--verbose"]);
|
|
77
|
+
expect(parsed.dataDir).toBe("true-data");
|
|
78
|
+
expect(parsed.rest).toEqual(["--sudo", "postgres", "--remote", "origin", "--verbose"]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("forwardArgs", () => {
|
|
82
|
+
it("appends the dataDir then the requested categories to the forwarded options", () => {
|
|
83
|
+
const parsed = parseArgs(["--all", "/app/senat-data", "--categories", "All", "--fromSession", "2022"]);
|
|
84
|
+
const forwarded = forwardArgs(parsed, ["Sens"]);
|
|
85
|
+
expect(forwarded).toBe("--all --fromSession 2022 /app/senat-data --categories Sens");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("dataset status file", () => {
|
|
89
|
+
it("increments consecutiveFailures and keeps firstFailureAt across consecutive failures", () => {
|
|
90
|
+
const statuses = {};
|
|
91
|
+
recordFailure(statuses, "sens", new Date("2026-09-01T00:00:00Z"), "dump empty");
|
|
92
|
+
recordFailure(statuses, "sens", new Date("2026-09-02T00:00:00Z"), "dump empty again");
|
|
93
|
+
expect(statuses["sens"]).toMatchObject({
|
|
94
|
+
status: "failed",
|
|
95
|
+
consecutiveFailures: 2,
|
|
96
|
+
firstFailureAt: "2026-09-01T00:00:00.000Z",
|
|
97
|
+
lastFailureAt: "2026-09-02T00:00:00.000Z",
|
|
98
|
+
lastError: "dump empty again",
|
|
99
|
+
lastSuccessAt: undefined,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("resets the failure streak on success while updating lastSuccessAt", () => {
|
|
103
|
+
const statuses = {};
|
|
104
|
+
recordFailure(statuses, "sens", new Date("2026-09-01T00:00:00Z"), "dump empty");
|
|
105
|
+
recordSuccess(statuses, "sens", new Date("2026-09-02T00:00:00Z"));
|
|
106
|
+
expect(statuses["sens"]).toEqual({
|
|
107
|
+
status: "ok",
|
|
108
|
+
consecutiveFailures: 0,
|
|
109
|
+
lastRunAt: "2026-09-02T00:00:00.000Z",
|
|
110
|
+
lastSuccessAt: "2026-09-02T00:00:00.000Z",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
it("returns an empty state for a missing or corrupted status file", () => {
|
|
114
|
+
const dir = createTempDataDir();
|
|
115
|
+
expect(readStatusFile(path.join(dir, STATUS_FILENAME))).toEqual({});
|
|
116
|
+
const corruptedPath = path.join(dir, "corrupted.json");
|
|
117
|
+
writeFileSync(corruptedPath, "{not json", { encoding: "utf-8" });
|
|
118
|
+
expect(readStatusFile(corruptedPath)).toEqual({});
|
|
119
|
+
});
|
|
120
|
+
it("round-trips statuses through the status file", () => {
|
|
121
|
+
const dir = createTempDataDir();
|
|
122
|
+
const filePath = path.join(dir, STATUS_FILENAME);
|
|
123
|
+
const statuses = {};
|
|
124
|
+
recordFailure(statuses, "sens", fixedDate, "dump empty");
|
|
125
|
+
writeStatusFile(filePath, statuses);
|
|
126
|
+
expect(readStatusFile(filePath)).toEqual(statuses);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe("runDataDownload", () => {
|
|
130
|
+
it("continues after a dataset failure, converts only successful categories and exits 0", () => {
|
|
131
|
+
const dir = createTempDataDir();
|
|
132
|
+
const { commands, exec } = makeExec((command) => command.includes("--categories Sens")
|
|
133
|
+
? { status: 1, error: new Error("Staging schema sens_staging is empty") }
|
|
134
|
+
: undefined);
|
|
135
|
+
const exitCode = runDataDownload({
|
|
136
|
+
rawArgs: [dir, "--fromSession", "2022"],
|
|
137
|
+
exec,
|
|
138
|
+
now: () => fixedDate,
|
|
139
|
+
});
|
|
140
|
+
expect(exitCode).toBe(0);
|
|
141
|
+
const retrieveCommands = commands.filter((command) => command.includes("retrieve_open_data"));
|
|
142
|
+
expect(retrieveCommands).toHaveLength(5);
|
|
143
|
+
for (const command of retrieveCommands) {
|
|
144
|
+
expect(command).toContain("retrieve_open_data.ts --all ");
|
|
145
|
+
}
|
|
146
|
+
expect(retrieveCommands[0]).toContain("--categories Ameli");
|
|
147
|
+
expect(retrieveCommands[4]).toContain("--categories Sens");
|
|
148
|
+
const convertCommand = commands.find((command) => command.includes("convert_data"));
|
|
149
|
+
expect(convertCommand).toContain("--categories Ameli");
|
|
150
|
+
expect(convertCommand).toContain("--categories Debats");
|
|
151
|
+
expect(convertCommand).toContain("--categories DosLeg");
|
|
152
|
+
expect(convertCommand).toContain("--categories Questions");
|
|
153
|
+
expect(convertCommand).not.toContain("--categories Sens");
|
|
154
|
+
const agendaCommand = commands.find((command) => command.includes("retrieve_agenda"));
|
|
155
|
+
expect(agendaCommand).toBe(`cross-env TZ='Etc/UTC' tsx src/scripts/retrieve_agenda.ts ${dir} --fromSession 2022 --parseAgenda --silent`);
|
|
156
|
+
const statuses = JSON.parse(readFileSync(path.join(dir, STATUS_FILENAME), { encoding: "utf-8" }));
|
|
157
|
+
expect(statuses.sens).toMatchObject({ status: "failed", consecutiveFailures: 1 });
|
|
158
|
+
expect(statuses.ameli).toMatchObject({ status: "ok", consecutiveFailures: 0 });
|
|
159
|
+
});
|
|
160
|
+
it("exits 1 without conversion when all datasets fail", () => {
|
|
161
|
+
const dir = createTempDataDir();
|
|
162
|
+
const { commands, exec } = makeExec(() => ({ status: 1, error: new Error("boom") }));
|
|
163
|
+
const exitCode = runDataDownload({
|
|
164
|
+
rawArgs: [dir],
|
|
165
|
+
exec,
|
|
166
|
+
now: () => fixedDate,
|
|
167
|
+
});
|
|
168
|
+
expect(exitCode).toBe(1);
|
|
169
|
+
expect(commands.filter((command) => command.includes("retrieve_open_data"))).toHaveLength(5);
|
|
170
|
+
expect(commands.find((command) => command.includes("convert_data"))).toBeUndefined();
|
|
171
|
+
expect(commands.find((command) => command.includes("retrieve_agenda"))).toBeUndefined();
|
|
172
|
+
const statuses = JSON.parse(readFileSync(path.join(dir, STATUS_FILENAME), { encoding: "utf-8" }));
|
|
173
|
+
for (const dataset of ["ameli", "debats", "dosleg", "questions", "sens"]) {
|
|
174
|
+
expect(statuses[dataset]).toMatchObject({ status: "failed", consecutiveFailures: 1 });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
it("runs every step when all datasets succeed", () => {
|
|
178
|
+
const dir = createTempDataDir();
|
|
179
|
+
const { commands, exec } = makeExec(() => undefined);
|
|
180
|
+
const exitCode = runDataDownload({
|
|
181
|
+
rawArgs: [dir, "--categories", "All"],
|
|
182
|
+
exec,
|
|
183
|
+
now: () => fixedDate,
|
|
184
|
+
});
|
|
185
|
+
expect(exitCode).toBe(0);
|
|
186
|
+
const convertCommand = commands.find((command) => command.includes("convert_data"));
|
|
187
|
+
for (const category of ["Ameli", "Debats", "DosLeg", "Questions", "Sens"]) {
|
|
188
|
+
expect(convertCommand).toContain(`--categories ${category}`);
|
|
189
|
+
}
|
|
190
|
+
expect(commands.find((command) => command.includes("retrieve_agenda"))).toBeDefined();
|
|
191
|
+
expect(commands.find((command) => command.includes("retrieve_cr_seance"))).toBeDefined();
|
|
192
|
+
expect(commands.find((command) => command.includes("retrieve_cr_commission"))).toBeDefined();
|
|
193
|
+
expect(commands.find((command) => command.includes("retrieve_videos"))).toBeDefined();
|
|
194
|
+
expect(commands.find((command) => command.includes("retrieve_collaborateurs"))).toBeDefined();
|
|
195
|
+
});
|
|
196
|
+
it("stops after convert_data when it fails and propagates its exit status", () => {
|
|
197
|
+
const dir = createTempDataDir();
|
|
198
|
+
const { commands, exec } = makeExec((command) => command.includes("convert_data") ? { status: 3, error: new Error("parse failure") } : undefined);
|
|
199
|
+
const exitCode = runDataDownload({
|
|
200
|
+
rawArgs: [dir],
|
|
201
|
+
exec,
|
|
202
|
+
now: () => fixedDate,
|
|
203
|
+
});
|
|
204
|
+
expect(exitCode).toBe(3);
|
|
205
|
+
expect(commands.find((command) => command.includes("retrieve_agenda"))).toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
it("exits 1 when no requested category matches a known dataset", () => {
|
|
208
|
+
const { commands, exec } = makeExec(() => undefined);
|
|
209
|
+
const exitCode = runDataDownload({
|
|
210
|
+
rawArgs: ["/data", "--categories", "Unknown"],
|
|
211
|
+
exec,
|
|
212
|
+
now: () => fixedDate,
|
|
213
|
+
});
|
|
214
|
+
expect(exitCode).toBe(1);
|
|
215
|
+
expect(commands).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
});
|