@tricoteuses/senat 3.1.24 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/index.d.ts +1 -0
- package/lib/src/other_types/plf.d.ts +21 -0
- package/lib/src/other_types/plf.js +1 -0
- package/lib/src/parsers/plf/credits.d.ts +2 -0
- package/lib/src/parsers/plf/credits.js +171 -0
- package/lib/src/parsers/plf/index.d.ts +1 -0
- package/lib/src/parsers/plf/index.js +1 -0
- package/lib/src/parsers/plf/parser_utils.d.ts +18 -0
- package/lib/src/parsers/plf/parser_utils.js +172 -0
- package/lib/src/parsers/texte.d.ts +1 -3
- package/lib/src/parsers/texte.js +63 -30
- package/lib/src/scripts/extract_plf_fixtures.d.ts +1 -0
- package/lib/src/scripts/extract_plf_fixtures.js +101 -0
- package/lib/src/scripts/retrieve_collaborateurs.js +6 -3
- package/lib/src/scripts/retrieve_documents.js +7 -46
- package/lib/src/server/parse_document.d.ts +5 -0
- package/lib/src/server/parse_document.js +47 -0
- package/lib/tests/parsers/parseDocument.test.d.ts +1 -0
- package/lib/tests/parsers/parseDocument.test.js +73 -0
- package/lib/tests/parsers/plf/credits.integration.test.d.ts +1 -0
- package/lib/tests/parsers/plf/credits.integration.test.js +118 -0
- package/lib/tests/parsers/plf/credits.test.d.ts +1 -0
- package/lib/tests/parsers/plf/credits.test.js +467 -0
- package/lib/tests/parsers/plf/integration.test_utils.d.ts +8 -0
- package/lib/tests/parsers/plf/integration.test_utils.js +73 -0
- package/lib/tests/parsers/plf/test_utils.d.ts +20 -0
- package/lib/tests/parsers/plf/test_utils.js +85 -0
- package/lib/tests/parsers/texte.test.d.ts +1 -0
- package/lib/tests/parsers/texte.test.js +71 -0
- package/lib/tests/parsers/texte.test_utils.d.ts +15 -0
- package/lib/tests/parsers/texte.test_utils.js +73 -0
- package/lib/tests/plf/credits.test.d.ts +1 -0
- package/lib/tests/plf/credits.test.js +458 -0
- package/lib/tests/plf/test_utils.d.ts +20 -0
- package/lib/tests/plf/test_utils.js +85 -0
- package/lib/tests/videoMatching.test.js +1 -1
- package/package.json +3 -1
|
@@ -5,7 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { convertSenatXmlToHtml } from "../server/conversion_textes.js";
|
|
6
6
|
import * as git from "../server/git.js";
|
|
7
7
|
import { DATA_ORIGINAL_FOLDER, DATA_TRANSFORMED_FOLDER, ENRICHED_TEXTE_FOLDER, iterLoadSenatRapportUrls, iterLoadSenatTexteUrls, RAPPORT_FOLDER, TEXTE_FOLDER, } from "../server/loaders.js";
|
|
8
|
-
import {
|
|
8
|
+
import { parseDocument } from "../server/parse_document.js";
|
|
9
9
|
import { getSessionsFromStart, UNDEFINED_SESSION } from "../other_types/sessions.js";
|
|
10
10
|
import { assertExistingDirectory, commonOptions } from "./shared/cli_helpers.js";
|
|
11
11
|
import { ensureAndClearDir, fetchWithRetry, isOptionEmptyOrHasValue } from "./shared/util.js";
|
|
@@ -134,21 +134,13 @@ export async function processTexte(texteMetadata, originalTextesDir, transformed
|
|
|
134
134
|
// Skip re-parsing if the XML was not newly downloaded AND the parsed output already exists
|
|
135
135
|
const needsParsing = !result.skipped || !parsedOutputExists;
|
|
136
136
|
if (needsParsing && (result.buffer !== null || (await fs.pathExists(destPath)))) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const texteXml = result.buffer !== null ? textDecoder.decode(result.buffer) : await fs.readFile(destPath, "utf-8");
|
|
138
|
+
await parseDocument(texteMetadata.session, transformedTextesDir, texteMetadata.name, texteXml, exposeDesMotifsContent ? textDecoder.decode(exposeDesMotifsContent) : null, options);
|
|
139
|
+
try {
|
|
140
|
+
await convertSenatXmlToHtml(texteXml, path.join(enrichedTextesDir, `${texteMetadata.session ?? UNDEFINED_SESSION}`, texteMetadata.name, `${texteMetadata.name}.html`));
|
|
141
141
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
if (texteXmlContent !== null) {
|
|
146
|
-
try {
|
|
147
|
-
await convertSenatXmlToHtml(texteXmlContent, path.join(enrichedTextesDir, `${texteMetadata.session ?? UNDEFINED_SESSION}`, texteMetadata.name, `${texteMetadata.name}.html`));
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
console.error(`Error converting ${texteMetadata.name} to HTML: ${error.message}`);
|
|
151
|
-
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
console.error(`Error converting ${texteMetadata.name} to HTML: ${error.message}`);
|
|
152
144
|
}
|
|
153
145
|
}
|
|
154
146
|
else if (options.verbose) {
|
|
@@ -212,37 +204,6 @@ async function processRapports(dataDir, sessions) {
|
|
|
212
204
|
}
|
|
213
205
|
commitAndPushGit(rapportsDir);
|
|
214
206
|
}
|
|
215
|
-
async function parseDocument(session, transformedTextesDir, textePath, texteName, texteBuffer, exposeDesMotifs = null, options = {}) {
|
|
216
|
-
if (options.verbose) {
|
|
217
|
-
console.log(`Parsing texte ${textePath}…`);
|
|
218
|
-
}
|
|
219
|
-
let parsedTexte;
|
|
220
|
-
if (texteBuffer) {
|
|
221
|
-
const texteXml = textDecoder.decode(texteBuffer);
|
|
222
|
-
parsedTexte = parseTexte(texteXml);
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
if (!(await fs.pathExists(textePath))) {
|
|
226
|
-
if (options.verbose) {
|
|
227
|
-
console.warn(`Skipping parse for missing XML file: ${textePath}`);
|
|
228
|
-
}
|
|
229
|
-
return null;
|
|
230
|
-
}
|
|
231
|
-
parsedTexte = await parseTexteFromFile(textePath);
|
|
232
|
-
}
|
|
233
|
-
if (!parsedTexte)
|
|
234
|
-
return null;
|
|
235
|
-
if (exposeDesMotifs) {
|
|
236
|
-
if (options.verbose) {
|
|
237
|
-
console.log("Parsing exposé des motifs…");
|
|
238
|
-
}
|
|
239
|
-
const exposeDesMotifsHtml = textDecoder.decode(exposeDesMotifs);
|
|
240
|
-
parsedTexte.expose_motifs = parseExposeDesMotifs(exposeDesMotifsHtml);
|
|
241
|
-
}
|
|
242
|
-
const transformedTexteDir = path.join(transformedTextesDir, `${session ?? UNDEFINED_SESSION}`, texteName);
|
|
243
|
-
await fs.outputJSON(path.join(transformedTexteDir, `${texteName}.json`), parsedTexte, { spaces: 2 });
|
|
244
|
-
return parsedTexte;
|
|
245
|
-
}
|
|
246
207
|
async function main() {
|
|
247
208
|
const dataDir = assertExistingDirectory(options["dataDir"], "data directory");
|
|
248
209
|
const sessions = getSessionsFromStart((options.fromSession ?? UNDEFINED_SESSION));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { FlatTexte } from "../other_types/texte.js";
|
|
2
|
+
export type ParseDocumentOptions = {
|
|
3
|
+
verbose?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function parseDocument(session: number | null | undefined, transformedTextesDir: string, texteName: string, texteXml: string, exposeHtml?: string | null, options?: ParseDocumentOptions): Promise<FlatTexte | null>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { JSDOM } from "jsdom";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { UNDEFINED_SESSION } from "../other_types/sessions.js";
|
|
5
|
+
import { parseExposeDesMotifs, parseTexte } from "../parsers/texte.js";
|
|
6
|
+
import { parsePlfCredits } from "../parsers/plf/index.js";
|
|
7
|
+
export async function parseDocument(session, transformedTextesDir, texteName, texteXml, exposeHtml = null, options = {}) {
|
|
8
|
+
if (options.verbose) {
|
|
9
|
+
console.log(`Parsing texte ${texteName}…`);
|
|
10
|
+
}
|
|
11
|
+
let document;
|
|
12
|
+
try {
|
|
13
|
+
document = new JSDOM(texteXml, { contentType: "text/xml" }).window.document;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
console.error(`Could not parse texte with error ${error.message}`);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const parsedTexte = parseTexte(texteXml, document);
|
|
20
|
+
if (!parsedTexte)
|
|
21
|
+
return null;
|
|
22
|
+
if (exposeHtml) {
|
|
23
|
+
if (options.verbose) {
|
|
24
|
+
console.log("Parsing exposé des motifs…");
|
|
25
|
+
}
|
|
26
|
+
parsedTexte.expose_motifs = parseExposeDesMotifs(exposeHtml);
|
|
27
|
+
}
|
|
28
|
+
const transformedTexteDir = path.join(transformedTextesDir, `${session ?? UNDEFINED_SESSION}`, texteName);
|
|
29
|
+
await fs.outputJSON(path.join(transformedTexteDir, `${texteName}.json`), parsedTexte, { spaces: 2 });
|
|
30
|
+
// Parse PLF credits
|
|
31
|
+
try {
|
|
32
|
+
const credits = parsePlfCredits(document);
|
|
33
|
+
if (credits.length > 0) {
|
|
34
|
+
await fs.outputJSON(path.join(transformedTexteDir, `${texteName}_plf_credits.json`), credits, { spaces: 2 });
|
|
35
|
+
if (options.verbose) {
|
|
36
|
+
console.log(` Parsed ${credits.length} PLF credit section(s) for ${texteName}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
await fs.remove(path.join(transformedTexteDir, `${texteName}_plf_credits.json`));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// PLF parsing is best-effort; failures should not block the main text parse
|
|
45
|
+
}
|
|
46
|
+
return parsedTexte;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { parseDocument } from "../../src/server/parse_document.js";
|
|
6
|
+
import { article, articleAlinea, xmlDoc } from "./texte.test_utils.js";
|
|
7
|
+
import { buildArticle, etatTable3Col, missionRow, totalRow } from "./plf/test_utils.js";
|
|
8
|
+
const tmpDirs = [];
|
|
9
|
+
function makeTmpDir(prefix) {
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
11
|
+
tmpDirs.push(dir);
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
16
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
describe("parseDocument", () => {
|
|
20
|
+
const session = 2024;
|
|
21
|
+
const texteName = "pjl-test";
|
|
22
|
+
const body = article("art_1er", articleAlinea("al_1__art_1er", "<p>Un article de test.</p>"));
|
|
23
|
+
it("writes the parsed FlatTexte as JSON in the session/name folder and returns it", async () => {
|
|
24
|
+
const transformedDir = makeTmpDir("senat-parse-doc-");
|
|
25
|
+
const texteXml = xmlDoc(body);
|
|
26
|
+
const result = await parseDocument(session, transformedDir, texteName, texteXml);
|
|
27
|
+
const jsonPath = path.join(transformedDir, `${session}`, texteName, `${texteName}.json`);
|
|
28
|
+
expect(fs.existsSync(jsonPath)).toBe(true);
|
|
29
|
+
const written = fs.readJsonSync(jsonPath);
|
|
30
|
+
expect(written.titre).toBe("Projet de loi de finances pour 2026");
|
|
31
|
+
expect(written.divisions).toHaveLength(1);
|
|
32
|
+
expect(result?.titre).toBe(written.titre);
|
|
33
|
+
});
|
|
34
|
+
it("parses and injects the exposé des motifs when exposeHtml is provided", async () => {
|
|
35
|
+
const transformedDir = makeTmpDir("senat-parse-expose-");
|
|
36
|
+
const exposeHtml = "<section><p>EXPOSÉ DES MOTIFS</p><p>Voici les motifs du texte.</p></section>";
|
|
37
|
+
const result = await parseDocument(session, transformedDir, texteName, xmlDoc(body), exposeHtml);
|
|
38
|
+
expect(result?.expose_motifs?.text).toContain("Voici les motifs du texte.");
|
|
39
|
+
});
|
|
40
|
+
it("does not write a PLF credits file when the texte has no credits", async () => {
|
|
41
|
+
const transformedDir = makeTmpDir("senat-parse-nocredits-");
|
|
42
|
+
await parseDocument(session, transformedDir, texteName, xmlDoc(body));
|
|
43
|
+
expect(fs.existsSync(path.join(transformedDir, `${session}`, texteName, `${texteName}_plf_credits.json`))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
it("writes a PLF credits file when the texte contains an 'etat' article", async () => {
|
|
46
|
+
const transformedDir = makeTmpDir("senat-parse-credits-");
|
|
47
|
+
const plfBody = buildArticle("etat_B", "État B", etatTable3Col("B", missionRow("Action extérieure de l'État", "3 449 452 613", "3 454 425 325") +
|
|
48
|
+
totalRow("620 249 232 626", "593 890 071 649")));
|
|
49
|
+
await parseDocument(session, transformedDir, texteName, xmlDoc(plfBody));
|
|
50
|
+
const creditsPath = path.join(transformedDir, `${session}`, texteName, `${texteName}_plf_credits.json`);
|
|
51
|
+
expect(fs.existsSync(creditsPath)).toBe(true);
|
|
52
|
+
const credits = fs.readJsonSync(creditsPath);
|
|
53
|
+
expect(credits).toHaveLength(1);
|
|
54
|
+
expect(credits[0].libelle).toBe("Budget général");
|
|
55
|
+
});
|
|
56
|
+
it("removes a stale PLF credits file when a re-parse produces no credits", async () => {
|
|
57
|
+
const transformedDir = makeTmpDir("senat-parse-stalecredits-");
|
|
58
|
+
const creditsPath = path.join(transformedDir, `${session}`, texteName, `${texteName}_plf_credits.json`);
|
|
59
|
+
const nbCreditsDir = path.join(transformedDir, `${session}`, texteName);
|
|
60
|
+
const plfBody = buildArticle("etat_B", "État B", etatTable3Col("B", missionRow("Action extérieure de l'État", "3 449 452 613", "3 454 425 325") +
|
|
61
|
+
totalRow("620 249 232 626", "593 890 071 649")));
|
|
62
|
+
await parseDocument(session, transformedDir, texteName, xmlDoc(plfBody));
|
|
63
|
+
expect(fs.existsSync(creditsPath)).toBe(true);
|
|
64
|
+
await parseDocument(session, transformedDir, texteName, xmlDoc(body));
|
|
65
|
+
expect(fs.existsSync(creditsPath)).toBe(false);
|
|
66
|
+
expect(fs.existsSync(nbCreditsDir)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
it("works for a session-less texte using the undefined session", async () => {
|
|
69
|
+
const transformedDir = makeTmpDir("senat-parse-nosession-");
|
|
70
|
+
await parseDocument(null, transformedDir, texteName, xmlDoc(body));
|
|
71
|
+
expect(fs.existsSync(path.join(transformedDir, "0", texteName, `${texteName}.json`))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { parsePlfCredits } from "../../../src/parsers/plf/index.js";
|
|
3
|
+
import { expectValidCreditSections, loadPlfFixture, sectionByLibelle, sectionLibelles, } from "./integration.test_utils.js";
|
|
4
|
+
// Real Sénat AKN XML, extracted from the actual senat-data dumps (only the
|
|
5
|
+
// État B, C and D articles, trimmed to a representative row sample). Regenerate/
|
|
6
|
+
// extend with:
|
|
7
|
+
// npx tsx src/scripts/extract_plf_fixtures.ts --src <senat .xml> --name <name> --only-parsed
|
|
8
|
+
const fixtures = {
|
|
9
|
+
plf2026Depot: { name: "plf2026-depot", label: "PLF 2026 (pjl25-138) — dépôt" },
|
|
10
|
+
plf2026Avance: { name: "plf2026-avance", label: "PLF 2026 (pjl25-308) — stade avancé" },
|
|
11
|
+
plf2025: { name: "plf2025", label: "PLF 2025 (pjl24-143) — dépôt" },
|
|
12
|
+
};
|
|
13
|
+
describe("parsePlfCredits — documents réels (integration)", () => {
|
|
14
|
+
let parsed;
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
parsed = Object.fromEntries(Object.values(fixtures).map(({ name }) => [
|
|
17
|
+
name,
|
|
18
|
+
parsePlfCredits(loadPlfFixture(name)),
|
|
19
|
+
]));
|
|
20
|
+
}, 30_000);
|
|
21
|
+
const loader = (name) => parsed[name];
|
|
22
|
+
describe("invariants", () => {
|
|
23
|
+
it.each(Object.values(fixtures))("$label — produit des sections cohérentes", ({ name }) => {
|
|
24
|
+
const sections = loader(name);
|
|
25
|
+
expect(sections.length).toBeGreaterThan(0);
|
|
26
|
+
expectValidCreditSections(sections);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe("section count et libellés", () => {
|
|
30
|
+
it.each([
|
|
31
|
+
{
|
|
32
|
+
fixture: fixtures.plf2026Depot,
|
|
33
|
+
expected: [
|
|
34
|
+
"Budget général",
|
|
35
|
+
"Budgets annexes",
|
|
36
|
+
"Comptes d'affectation spéciale",
|
|
37
|
+
"Comptes de concours financiers",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
fixture: fixtures.plf2025,
|
|
42
|
+
expected: [
|
|
43
|
+
"Budget général",
|
|
44
|
+
"Budgets annexes",
|
|
45
|
+
"Comptes d'affectation spéciale",
|
|
46
|
+
"Comptes de concours financiers",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
// L'État C est « (Conforme) » à ce stade (aucun tableau de crédits)
|
|
51
|
+
fixture: fixtures.plf2026Avance,
|
|
52
|
+
expected: [
|
|
53
|
+
"Budget général",
|
|
54
|
+
"Comptes d'affectation spéciale",
|
|
55
|
+
"Comptes de concours financiers",
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
])("$fixture.label — expose les sections attendues", ({ fixture, expected }) => {
|
|
59
|
+
expect(sectionLibelles(loader(fixture.name))).toEqual(expected);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("totaux", () => {
|
|
63
|
+
it.each([
|
|
64
|
+
{ fixture: fixtures.plf2026Depot, section: "Budget général", total: 613_009_460_112 },
|
|
65
|
+
{ fixture: fixtures.plf2026Depot, section: "Budgets annexes", total: 2_691_230_585 },
|
|
66
|
+
{
|
|
67
|
+
fixture: fixtures.plf2026Depot,
|
|
68
|
+
section: "Comptes d'affectation spéciale",
|
|
69
|
+
total: 77_423_204_686,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
fixture: fixtures.plf2026Depot,
|
|
73
|
+
section: "Comptes de concours financiers",
|
|
74
|
+
total: 149_661_603_151,
|
|
75
|
+
},
|
|
76
|
+
])("$fixture.label / $section — total AE correct", ({ fixture, section, total }) => {
|
|
77
|
+
expect(sectionByLibelle(loader(fixture.name), section)?.total?.autorisationEngagement).toBe(total);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe("missions", () => {
|
|
81
|
+
it.each([
|
|
82
|
+
{
|
|
83
|
+
fixture: fixtures.plf2026Depot,
|
|
84
|
+
section: "Budget général",
|
|
85
|
+
mission: "Action extérieure de l’État",
|
|
86
|
+
ae: 3_452_452_613,
|
|
87
|
+
programmes: 3,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
fixture: fixtures.plf2026Avance,
|
|
91
|
+
section: "Budget général",
|
|
92
|
+
mission: "Action extérieure de l’État",
|
|
93
|
+
ae: 3_449_452_613,
|
|
94
|
+
programmes: 3,
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
fixture: fixtures.plf2025,
|
|
98
|
+
section: "Budget général",
|
|
99
|
+
mission: "Action extérieure de l’État",
|
|
100
|
+
ae: 3_527_537_701,
|
|
101
|
+
programmes: 3,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
fixture: fixtures.plf2026Depot,
|
|
105
|
+
section: "Comptes d'affectation spéciale",
|
|
106
|
+
mission: "Contrôle de la circulation et du stationnement routiers",
|
|
107
|
+
ae: 1_928_700_107,
|
|
108
|
+
},
|
|
109
|
+
])("$fixture.label / $mission — première mission et son AE", ({ fixture, section, mission, ae, programmes }) => {
|
|
110
|
+
const sections = loader(fixture.name);
|
|
111
|
+
const first = sectionByLibelle(sections, section)?.missions[0];
|
|
112
|
+
expect(first?.libelle).toBe(mission);
|
|
113
|
+
expect(first?.autorisationEngagement).toBe(ae);
|
|
114
|
+
if (programmes !== undefined)
|
|
115
|
+
expect(first?.programmes).toHaveLength(programmes);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|