@tricoteuses/senat 3.1.24 → 3.2.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.
Files changed (35) hide show
  1. package/lib/src/index.d.ts +1 -0
  2. package/lib/src/other_types/plf.d.ts +21 -0
  3. package/lib/src/other_types/plf.js +1 -0
  4. package/lib/src/parsers/plf/credits.d.ts +2 -0
  5. package/lib/src/parsers/plf/credits.js +171 -0
  6. package/lib/src/parsers/plf/index.d.ts +1 -0
  7. package/lib/src/parsers/plf/index.js +1 -0
  8. package/lib/src/parsers/plf/parser_utils.d.ts +18 -0
  9. package/lib/src/parsers/plf/parser_utils.js +172 -0
  10. package/lib/src/parsers/texte.d.ts +1 -3
  11. package/lib/src/parsers/texte.js +63 -30
  12. package/lib/src/scripts/extract_plf_fixtures.d.ts +1 -0
  13. package/lib/src/scripts/extract_plf_fixtures.js +101 -0
  14. package/lib/src/scripts/retrieve_documents.js +7 -46
  15. package/lib/src/server/parse_document.d.ts +5 -0
  16. package/lib/src/server/parse_document.js +47 -0
  17. package/lib/tests/parsers/parseDocument.test.d.ts +1 -0
  18. package/lib/tests/parsers/parseDocument.test.js +73 -0
  19. package/lib/tests/parsers/plf/credits.integration.test.d.ts +1 -0
  20. package/lib/tests/parsers/plf/credits.integration.test.js +118 -0
  21. package/lib/tests/parsers/plf/credits.test.d.ts +1 -0
  22. package/lib/tests/parsers/plf/credits.test.js +467 -0
  23. package/lib/tests/parsers/plf/integration.test_utils.d.ts +8 -0
  24. package/lib/tests/parsers/plf/integration.test_utils.js +73 -0
  25. package/lib/tests/parsers/plf/test_utils.d.ts +20 -0
  26. package/lib/tests/parsers/plf/test_utils.js +85 -0
  27. package/lib/tests/parsers/texte.test.d.ts +1 -0
  28. package/lib/tests/parsers/texte.test.js +71 -0
  29. package/lib/tests/parsers/texte.test_utils.d.ts +15 -0
  30. package/lib/tests/parsers/texte.test_utils.js +73 -0
  31. package/lib/tests/plf/credits.test.d.ts +1 -0
  32. package/lib/tests/plf/credits.test.js +458 -0
  33. package/lib/tests/plf/test_utils.d.ts +20 -0
  34. package/lib/tests/plf/test_utils.js +85 -0
  35. package/package.json +3 -1
@@ -40,3 +40,4 @@ export { UNDEFINED_SESSION, sessionOptions, sessionOptionsOrAll, getSessionsFrom
40
40
  export { DivisionType } from "./other_types/texte.js";
41
41
  export type { DivisionTag, Version, DocumentMetadata, FlatTexte, Step, Division, Article, DivisionContent, Alinea, ExposeDesMotifs, } from "./other_types/texte.js";
42
42
  export type { CollaborateurSenat } from "./other_types/collaborateurs.js";
43
+ export type { PlfBudgetType, PlfLigne, PlfProgramme, PlfMission, PlfCreditsSection } from "./other_types/plf.js";
@@ -0,0 +1,21 @@
1
+ export type PlfBudgetType = "budgetGeneral" | "budgetsAnnexes" | "comptesSpeciaux";
2
+ export interface PlfLigne {
3
+ libelle: string;
4
+ autorisationEngagement?: number;
5
+ creditPaiement?: number;
6
+ autorisationEngagementAnnule?: number;
7
+ creditPaiementAnnule?: number;
8
+ }
9
+ export interface PlfProgramme extends PlfLigne {
10
+ lignes?: PlfLigne[];
11
+ }
12
+ export interface PlfMission extends PlfLigne {
13
+ budgetType: PlfBudgetType;
14
+ programmes: PlfProgramme[];
15
+ }
16
+ export interface PlfCreditsSection {
17
+ budgetType: PlfBudgetType;
18
+ libelle: string;
19
+ missions: PlfMission[];
20
+ total?: Omit<PlfLigne, "libelle">;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { type PlfCreditsSection } from "../../other_types/plf.js";
2
+ export declare function parsePlfCredits(document: Document): PlfCreditsSection[];
@@ -0,0 +1,171 @@
1
+ import { cleanText, classifyRow, detectEtatDSubSection, etatNumToBudgetType, etatNumToLibelle, isHeaderRow, parseLibelle, rowToMontants, SUBSECTION_DEFINITIONS, } from "./parser_utils.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Table parsing
4
+ // ---------------------------------------------------------------------------
5
+ function parseCreditTable(table) {
6
+ const missions = [];
7
+ let currentMission = null;
8
+ let currentProgramme = null;
9
+ let tableTotal;
10
+ const rows = [...table.querySelectorAll("tr")];
11
+ // Skip header rows: the first row normally has colspan (the "(En euros)" row),
12
+ // and the second row has the column headers (bold, border-bottom).
13
+ let dataStartIndex = 0;
14
+ for (let i = 0; i < rows.length; i++) {
15
+ const tds = rows[i].querySelectorAll("td");
16
+ if (tds.length < 2) {
17
+ dataStartIndex = i + 1;
18
+ continue;
19
+ }
20
+ // Check if this is a header row: border-bottom on first td, or contains header text
21
+ const firstTdStyle = (tds[0]?.getAttribute("style") ?? "").replace(/[\n\t\r]/g, " ");
22
+ if (/border-bottom\s*:\s*1px/.test(firstTdStyle) ||
23
+ isHeaderRow(tds)) {
24
+ dataStartIndex = i + 1;
25
+ continue;
26
+ }
27
+ // If we have enough td cells, assume we're past headers
28
+ if (tds.length >= 3) {
29
+ dataStartIndex = i;
30
+ break;
31
+ }
32
+ dataStartIndex = i + 1;
33
+ }
34
+ for (let i = dataStartIndex; i < rows.length; i++) {
35
+ const tr = rows[i];
36
+ const tds = tr.querySelectorAll("td");
37
+ if (tds.length < 3)
38
+ continue;
39
+ const kind = classifyRow(tr);
40
+ const nameText = cleanText(tds[0].textContent ?? "");
41
+ if (!nameText)
42
+ continue;
43
+ if (kind === "total") {
44
+ tableTotal = rowToMontants(tr);
45
+ continue;
46
+ }
47
+ if (kind === "ligne") {
48
+ if (currentProgramme) {
49
+ const ligneLibelle = parseLibelle(tds[0].textContent ?? "");
50
+ if (ligneLibelle === undefined)
51
+ continue;
52
+ currentProgramme.lignes = currentProgramme.lignes || [];
53
+ currentProgramme.lignes.push({
54
+ libelle: ligneLibelle,
55
+ ...rowToMontants(tr),
56
+ });
57
+ }
58
+ continue;
59
+ }
60
+ if (kind === "mission") {
61
+ if (currentMission) {
62
+ missions.push(currentMission);
63
+ }
64
+ const libelle = parseLibelle(tds[0].textContent ?? "");
65
+ if (libelle === undefined) {
66
+ currentMission = null;
67
+ currentProgramme = null;
68
+ continue;
69
+ }
70
+ currentMission = {
71
+ budgetType: "budgetGeneral",
72
+ libelle,
73
+ ...rowToMontants(tr),
74
+ programmes: [],
75
+ };
76
+ currentProgramme = null;
77
+ continue;
78
+ }
79
+ // programme row
80
+ if (currentMission) {
81
+ const libelle = parseLibelle(tds[0].textContent ?? "");
82
+ if (libelle === undefined) {
83
+ currentProgramme = null;
84
+ continue;
85
+ }
86
+ currentProgramme = {
87
+ libelle,
88
+ ...rowToMontants(tr),
89
+ };
90
+ currentMission.programmes.push(currentProgramme);
91
+ }
92
+ else {
93
+ // If no mission is active, treat this as a mission with one programme
94
+ const libelle = parseLibelle(tds[0].textContent ?? "");
95
+ if (libelle === undefined)
96
+ continue;
97
+ currentMission = {
98
+ budgetType: "budgetGeneral",
99
+ libelle,
100
+ ...rowToMontants(tr),
101
+ programmes: [],
102
+ };
103
+ }
104
+ }
105
+ if (currentMission) {
106
+ missions.push(currentMission);
107
+ }
108
+ if (missions.length === 0)
109
+ return null;
110
+ return { missions, total: tableTotal };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Main parser
114
+ // ---------------------------------------------------------------------------
115
+ export function parsePlfCredits(document) {
116
+ const body = document.querySelector("body");
117
+ if (!body)
118
+ return [];
119
+ const etatArticles = [...body.querySelectorAll('article[class="etat"]')];
120
+ if (etatArticles.length === 0)
121
+ return [];
122
+ const results = [];
123
+ for (const article of etatArticles) {
124
+ const numEl = article.querySelector(":scope > num");
125
+ if (!numEl)
126
+ continue;
127
+ const num = cleanText(numEl.textContent ?? "");
128
+ const budgetType = etatNumToBudgetType(num);
129
+ if (!budgetType)
130
+ continue;
131
+ // Find all tables in this etat article
132
+ const tables = [...article.querySelectorAll("table.alinea-table.etat-tableau")];
133
+ if (tables.length === 0)
134
+ continue;
135
+ // For État D, handle CAS/CCF sub-sections
136
+ if (budgetType === "comptesSpeciaux") {
137
+ let fallbackCasIndex = 0;
138
+ for (const table of tables) {
139
+ const subsection = detectEtatDSubSection(table) ??
140
+ (fallbackCasIndex === 0 ? "cas" : fallbackCasIndex === 1 ? "ccf" : undefined);
141
+ fallbackCasIndex++;
142
+ if (!subsection)
143
+ continue;
144
+ const parsed = parseCreditTable(table);
145
+ if (!parsed)
146
+ continue;
147
+ const def = SUBSECTION_DEFINITIONS[subsection];
148
+ results.push({
149
+ budgetType,
150
+ libelle: def.libelle,
151
+ missions: parsed.missions.map((m) => ({ ...m, budgetType })),
152
+ total: parsed.total,
153
+ });
154
+ }
155
+ }
156
+ else {
157
+ // État B or État C: single table
158
+ const parsed = parseCreditTable(tables[0]);
159
+ if (!parsed)
160
+ continue;
161
+ const libelle = etatNumToLibelle(num) ?? num;
162
+ results.push({
163
+ budgetType,
164
+ libelle,
165
+ missions: parsed.missions.map((m) => ({ ...m, budgetType })),
166
+ total: parsed.total,
167
+ });
168
+ }
169
+ }
170
+ return results;
171
+ }
@@ -0,0 +1 @@
1
+ export { parsePlfCredits } from "./credits.js";
@@ -0,0 +1 @@
1
+ export { parsePlfCredits } from "./credits.js";
@@ -0,0 +1,18 @@
1
+ import { type PlfBudgetType, type PlfLigne } from "../../other_types/plf.js";
2
+ export declare function cleanText(text: string): string;
3
+ export declare function hasBorderTop(style: string): boolean;
4
+ export declare function hasFontWeightBold(style: string): boolean;
5
+ export declare function hasFontStyleItalic(style: string): boolean;
6
+ export declare function normalizeAmount(text: string): number | undefined;
7
+ export declare function parseLibelle(text: string): string | undefined;
8
+ export declare function etatNumToBudgetType(num: string): PlfBudgetType | undefined;
9
+ export declare function etatNumToLibelle(num: string): string | undefined;
10
+ export type RowKind = "mission" | "programme" | "ligne" | "total" | "unknown";
11
+ export declare function classifyRow(tr: Element): RowKind;
12
+ export declare function rowToMontants(tr: Element): Omit<PlfLigne, "libelle">;
13
+ export declare function isHeaderRow(tds: Iterable<Element>): boolean;
14
+ export type EtatDSubSection = "cas" | "ccf";
15
+ export declare const SUBSECTION_DEFINITIONS: Record<EtatDSubSection, {
16
+ libelle: string;
17
+ }>;
18
+ export declare function detectEtatDSubSection(table: Element): EtatDSubSection | undefined;
@@ -0,0 +1,172 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Helpers
3
+ // ---------------------------------------------------------------------------
4
+ export function cleanText(text) {
5
+ return text.replace(/\s+/g, " ").trim();
6
+ }
7
+ export function hasBorderTop(style) {
8
+ return /border-top\s*:\s*1px/.test(style);
9
+ }
10
+ export function hasFontWeightBold(style) {
11
+ return /font-weight\s*:\s*bold/.test(style);
12
+ }
13
+ export function hasFontStyleItalic(style) {
14
+ return /font-style\s*:\s*italic/.test(style);
15
+ }
16
+ export function normalizeAmount(text) {
17
+ let cleaned = cleanText(text)
18
+ .replace(/[\u2212\u2011]/g, "-")
19
+ // Remove literal "&nbsp;" artefact from double-escaped XML entities
20
+ .replace(/&nbsp;/gi, "")
21
+ // Remove actual non-breaking spaces
22
+ .replace(/\u00A0/g, "")
23
+ .replace(/\s/g, "")
24
+ .replace(/[^0-9,.-]/g, "");
25
+ const commaCount = (cleaned.match(/,/g) ?? []).length;
26
+ if (commaCount > 1)
27
+ return undefined;
28
+ const normalized = commaCount === 1 ? cleaned.replace(/\./g, "").replace(",", ".") : cleaned;
29
+ if (!/^-?\d+(?:\.\d+)?$/.test(normalized))
30
+ return undefined;
31
+ const n = Number(normalized);
32
+ return Number.isNaN(n) ? undefined : n;
33
+ }
34
+ export function parseLibelle(text) {
35
+ // Remove any HTML tags (e.g. literal "<br>" from double-escaped XML entities)
36
+ const libelle = cleanText(text)
37
+ .replace(/<[^>]*>/g, " ")
38
+ .replace(/(?:\s*\.){2,}\s*$/g, "")
39
+ .trim();
40
+ if (/\(\s*ligne\s+supprim(?:ée|ee)\s*\)\s*$/i.test(libelle)) {
41
+ return undefined;
42
+ }
43
+ return libelle.replace(/\s*\(\s*ligne\s+nouvelle\s*\)\s*$/i, "").trim();
44
+ }
45
+ export function etatNumToBudgetType(num) {
46
+ switch (num) {
47
+ case "État B":
48
+ return "budgetGeneral";
49
+ case "État C":
50
+ return "budgetsAnnexes";
51
+ case "État D":
52
+ return "comptesSpeciaux";
53
+ default:
54
+ return undefined;
55
+ }
56
+ }
57
+ export function etatNumToLibelle(num) {
58
+ switch (num) {
59
+ case "État B":
60
+ return "Budget général";
61
+ case "État C":
62
+ return "Budgets annexes";
63
+ case "État D":
64
+ return "Comptes spéciaux";
65
+ default:
66
+ return undefined;
67
+ }
68
+ }
69
+ export function classifyRow(tr) {
70
+ const trClass = (tr.getAttribute("class") ?? "").trim().toLowerCase();
71
+ if (trClass === "mission")
72
+ return "mission";
73
+ if (trClass === "programme")
74
+ return "programme";
75
+ if (trClass === "lgncred")
76
+ return "ligne";
77
+ if (trClass === "total")
78
+ return "total";
79
+ // Fallback: style-based heuristics
80
+ const firstTd = tr.querySelector("td");
81
+ if (!firstTd)
82
+ return "unknown";
83
+ const style = (firstTd.getAttribute("style") ?? "").replace(/[\n\t\r]/g, " ");
84
+ const text = cleanText(firstTd.textContent ?? "");
85
+ if (/^total/i.test(text))
86
+ return "total";
87
+ const isBold = hasFontWeightBold(style);
88
+ const isItalic = hasFontStyleItalic(style);
89
+ const hasBorder = hasBorderTop(style);
90
+ if (isItalic && !isBold) {
91
+ // "Dont titre 2" style rows
92
+ if (/dont\s+titre/i.test(text))
93
+ return "ligne";
94
+ }
95
+ if (isBold && hasBorder)
96
+ return "mission";
97
+ return "programme";
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Amount extraction (3 or 5 columns)
101
+ // ---------------------------------------------------------------------------
102
+ export function rowToMontants(tr) {
103
+ const tds = [...tr.querySelectorAll("td")];
104
+ const ae = normalizeAmount(tds[1]?.textContent ?? "");
105
+ const cp = normalizeAmount(tds[2]?.textContent ?? "");
106
+ if (tds.length >= 5) {
107
+ return {
108
+ autorisationEngagement: ae,
109
+ creditPaiement: cp,
110
+ autorisationEngagementAnnule: normalizeAmount(tds[3]?.textContent ?? ""),
111
+ creditPaiementAnnule: normalizeAmount(tds[4]?.textContent ?? ""),
112
+ };
113
+ }
114
+ return { autorisationEngagement: ae, creditPaiement: cp };
115
+ }
116
+ export function isHeaderRow(tds) {
117
+ const fullRowText = [...tds]
118
+ .map((td) => cleanText(td.textContent ?? ""))
119
+ .join(" ");
120
+ if (/mission\s*\/\s*programme/i.test(fullRowText))
121
+ return true;
122
+ if (/cr[ée]dits?\s+de\s+paiement/i.test(fullRowText))
123
+ return true;
124
+ return false;
125
+ }
126
+ export const SUBSECTION_DEFINITIONS = {
127
+ cas: { libelle: "Comptes d'affectation spéciale" },
128
+ ccf: { libelle: "Comptes de concours financiers" },
129
+ };
130
+ export function detectEtatDSubSection(table) {
131
+ let current = table;
132
+ // Navigate up: table → content → alinea → parent alinea
133
+ while (current && current.tagName !== "alinea") {
134
+ current = current.parentElement;
135
+ }
136
+ if (!current)
137
+ return undefined;
138
+ const innerAlinea = current;
139
+ const outerAlinea = innerAlinea.parentElement;
140
+ if (!outerAlinea || outerAlinea.tagName !== "alinea")
141
+ return undefined;
142
+ // Look for <content><p> in outerAlinea AFTER the inner alinea
143
+ let foundInner = false;
144
+ for (const child of [...outerAlinea.children]) {
145
+ if (child === innerAlinea) {
146
+ foundInner = true;
147
+ continue;
148
+ }
149
+ if (!foundInner)
150
+ continue;
151
+ if (child.tagName === "content") {
152
+ const p = child.querySelector(":scope > p");
153
+ if (!p) {
154
+ // Try direct text
155
+ const text = cleanText(child.textContent ?? "");
156
+ if (text.length > 0) {
157
+ if (/COMPTES\s+D['\u2019]AFFECTATION\s+SP[EÉ]CIALE/i.test(text))
158
+ return "cas";
159
+ if (/COMPTES\s+DE\s+CONCOURS\s+FINANCIERS/i.test(text))
160
+ return "ccf";
161
+ }
162
+ continue;
163
+ }
164
+ const pText = cleanText(p.textContent ?? "");
165
+ if (/COMPTES\s+D['\u2019]AFFECTATION\s+SP[EÉ]CIALE/i.test(pText))
166
+ return "cas";
167
+ if (/COMPTES\s+DE\s+CONCOURS\s+FINANCIERS/i.test(pText))
168
+ return "ccf";
169
+ }
170
+ }
171
+ return undefined;
172
+ }
@@ -1,7 +1,5 @@
1
1
  import { ExposeDesMotifs, FlatTexte } from "../other_types/texte.js";
2
2
  export declare function transformTexte(document: Document): FlatTexte | null;
3
3
  export declare function transformExposeDesMotifs(document: Document): ExposeDesMotifs | null;
4
- export declare function parseTexte(texteXml: string): FlatTexte | null;
5
- export declare function parseTexteFromFile(xmlFilePath: string): Promise<FlatTexte | null>;
4
+ export declare function parseTexte(texteXml: string, dom?: Document): FlatTexte | null;
6
5
  export declare function parseExposeDesMotifs(exposeDesMotifsHtml: string): ExposeDesMotifs | null;
7
- export declare function parseExposeDesMotifsFromFile(htmlFilePath: string): Promise<ExposeDesMotifs | null>;
@@ -51,14 +51,67 @@ function buildAlinea(contentNode, alineaNode) {
51
51
  text: alineaNode.querySelector("num")?.textContent ?? null,
52
52
  };
53
53
  const pastille = alineaNode.getAttribute("data:pastille") ?? null;
54
+ const rawHtml = contentNode.innerHTML?.trim() ?? null;
55
+ const rawText = contentNode.textContent?.trim() ?? null;
56
+ const { text, html } = normalizeDoubleEscapedMarkup(rawText, rawHtml);
54
57
  return {
55
58
  eId,
56
59
  heading,
57
- text: contentNode.textContent?.trim() ?? null,
58
- html: contentNode.innerHTML?.trim() ?? null,
60
+ text,
61
+ html,
59
62
  pastille,
60
63
  };
61
64
  }
65
+ // The Sénat XML sometimes double-escapes inner block markup: a cell/paragraph
66
+ // whose text is literally "<p>…</p><p>…</p>" (e.g. the italic footnote block in
67
+ // the article liminaire of the PLF). Unescape it so the blocks render as real
68
+ // paragraphs instead of showing literal "<p>" characters.
69
+ function normalizeDoubleEscapedMarkup(text, html) {
70
+ if (html != null && html.includes("&lt;")) {
71
+ return {
72
+ html: html.replace(/&lt;/g, "<").replace(/&gt;/g, ">"),
73
+ text: html
74
+ .replace(/&lt;/g, "<")
75
+ .replace(/&gt;/g, ">")
76
+ .replace(/<[^>]*>/g, " ")
77
+ .replace(/\s+/g, " ")
78
+ .trim(),
79
+ };
80
+ }
81
+ return { text, html };
82
+ }
83
+ function buildDivisionalAlinea(node, index) {
84
+ const eId = node.getAttribute("eId");
85
+ const numNode = node.querySelector("num");
86
+ const contentNode = Array.from(node.childNodes).find((childNode) => childNode.nodeName === "content");
87
+ const numText = numNode?.textContent?.trim() ?? null;
88
+ const contentText = contentNode?.textContent?.trim() ?? null;
89
+ const contentHtml = contentNode?.innerHTML?.trim() ?? null;
90
+ const headings = [];
91
+ if (numText)
92
+ headings.push({ text: numText, html: numText });
93
+ if (contentText)
94
+ headings.push({ text: contentText, html: contentHtml });
95
+ return {
96
+ index,
97
+ eId,
98
+ tag: "alinea",
99
+ level: DivisionType["alinea"],
100
+ headings,
101
+ };
102
+ }
103
+ function isArticleAlinea(alineaNode) {
104
+ let ancestor = alineaNode.parentElement;
105
+ while (ancestor) {
106
+ const tag = ancestor.nodeName;
107
+ if (tag === "article")
108
+ return true;
109
+ if (DivisionType[tag] != null && tag !== "alinea")
110
+ return false;
111
+ ancestor = ancestor.parentElement;
112
+ }
113
+ return false;
114
+ }
62
115
  function buildEmptyArticle(index) {
63
116
  return {
64
117
  index: index,
@@ -105,6 +158,12 @@ function splitTexte(texteContentRoot) {
105
158
  // https://github.com/jsdom/jsdom/issues/2998
106
159
  .filter((alineaChildNode) => alineaChildNode.nodeName === "content")
107
160
  .forEach((alineaContentNode) => {
161
+ if (!isArticleAlinea(node)) {
162
+ // Divisional alinea (e.g. `aldiv_*` headings inside a title/part):
163
+ // a structural heading of a subdivision, not an article alinea.
164
+ divisions.push(buildDivisionalAlinea(node, divisionIndex++));
165
+ return;
166
+ }
108
167
  // Hypothesis: alineas should always be enclosed in articles
109
168
  let lastArticle = divisions.findLast((division) => division.tag === "article");
110
169
  if (!lastArticle) {
@@ -189,23 +248,9 @@ export function transformExposeDesMotifs(document) {
189
248
  }
190
249
  return null;
191
250
  }
192
- export function parseTexte(texteXml) {
251
+ export function parseTexte(texteXml, dom) {
193
252
  try {
194
- const { document } = new JSDOM(texteXml, {
195
- contentType: "text/xml",
196
- }).window;
197
- return transformTexte(document);
198
- }
199
- catch (error) {
200
- console.error(`Could not parse texte with error ${error.message}`);
201
- }
202
- return null;
203
- }
204
- // Prevent from memory leak
205
- // https://github.com/jsdom/jsdom/issues/2583#issuecomment-559520814
206
- export async function parseTexteFromFile(xmlFilePath) {
207
- try {
208
- const { document } = (await JSDOM.fromFile(xmlFilePath, { contentType: "text/xml" })).window;
253
+ const document = dom ?? new JSDOM(texteXml, { contentType: "text/xml" }).window.document;
209
254
  return transformTexte(document);
210
255
  }
211
256
  catch (error) {
@@ -225,15 +270,3 @@ export function parseExposeDesMotifs(exposeDesMotifsHtml) {
225
270
  }
226
271
  return null;
227
272
  }
228
- // Prevent from memory leak
229
- // https://github.com/jsdom/jsdom/issues/2583#issuecomment-559520814
230
- export async function parseExposeDesMotifsFromFile(htmlFilePath) {
231
- try {
232
- const { document } = (await JSDOM.fromFile(htmlFilePath, { contentType: "text/html" })).window;
233
- return transformExposeDesMotifs(document);
234
- }
235
- catch (error) {
236
- console.error(`Could not parse exposé des motifs with error ${error.message}`);
237
- }
238
- return null;
239
- }
@@ -0,0 +1 @@
1
+ export declare function extractEtats(srcPath: string): Promise<Element[]>;
@@ -0,0 +1,101 @@
1
+ import fs from "fs-extra";
2
+ import { JSDOM } from "jsdom";
3
+ import path from "path";
4
+ import { commonOptions } from "./shared/cli_helpers.js";
5
+ const optionsDefinitions = [
6
+ { name: "src", type: String },
7
+ {
8
+ help: [
9
+ "extract the 'etat' articles of a real Sénat AKN XML into a minimal fixture",
10
+ "named <name>.xml under tests/fixtures/plf/",
11
+ ].join(" "),
12
+ name: "name",
13
+ type: String,
14
+ },
15
+ {
16
+ help: "keep only the parsed budget states (B, C, D); drop the large A/G tables",
17
+ name: "only-parsed",
18
+ type: Boolean,
19
+ },
20
+ ...commonOptions,
21
+ ];
22
+ const KEEP_DATA_ROWS = 6;
23
+ function trimTable(table) {
24
+ const rows = [...table.querySelectorAll("tr")];
25
+ // Les 2 premières lignes sont les en-têtes réels : « (En euros) » puis « Mission / Programme ».
26
+ const headerCount = Math.min(2, rows.length);
27
+ const dataRows = rows.slice(headerCount);
28
+ const dataStartIndex = rows.length - dataRows.length;
29
+ const totalRows = dataRows.filter((row) => {
30
+ const firstTd = row.querySelector("td");
31
+ return /^total/i.test(firstTd?.textContent?.trim() ?? "");
32
+ });
33
+ const totalIndexes = new Set(totalRows.map((row) => rows.indexOf(row)));
34
+ let kept = 0;
35
+ for (const row of rows) {
36
+ const index = rows.indexOf(row);
37
+ if (index < dataStartIndex)
38
+ continue; // en-têtes conservés
39
+ if (totalIndexes.has(index))
40
+ continue; // lignes Total conservées
41
+ if (kept < KEEP_DATA_ROWS) {
42
+ kept++;
43
+ continue;
44
+ }
45
+ row.parentNode?.removeChild(row);
46
+ }
47
+ }
48
+ export async function extractEtats(srcPath) {
49
+ const xml = await fs.readFile(srcPath, "utf-8");
50
+ const { document } = new JSDOM(xml, { contentType: "text/xml" }).window;
51
+ const body = document.querySelector("body");
52
+ if (!body)
53
+ throw new Error(`No <body> found in ${srcPath}`);
54
+ return [...body.querySelectorAll('article[class="etat"]')];
55
+ }
56
+ async function main() {
57
+ const commandLineArgs = (await import("command-line-args")).default;
58
+ const options = commandLineArgs(optionsDefinitions);
59
+ const srcPath = options.src;
60
+ const fixtureName = options.name;
61
+ if (!srcPath)
62
+ throw new Error("Missing <src> path to a Sénat AKN XML");
63
+ if (!fixtureName)
64
+ throw new Error("Missing --name");
65
+ const etatArticles = await extractEtats(srcPath);
66
+ const kept = options["only-parsed"]
67
+ ? etatArticles.filter((article) => {
68
+ const num = article.querySelector(":scope > num")?.textContent?.trim();
69
+ return num === "État B" || num === "État C" || num === "État D";
70
+ })
71
+ : etatArticles;
72
+ if (kept.length === 0)
73
+ throw new Error(`No B/C/D etat articles found in ${srcPath}`);
74
+ for (const article of kept) {
75
+ for (const table of article.querySelectorAll("table.alinea-table.etat-tableau")) {
76
+ trimTable(table);
77
+ }
78
+ }
79
+ const outDir = path.join("tests", "fixtures", "plf");
80
+ await fs.ensureDir(outDir);
81
+ const outPath = path.join(outDir, `${fixtureName}.xml`);
82
+ const fixture = `<?xml version="1.0" encoding="UTF-8"?>
83
+ <akomaNtoso xmlns="http://docs.oasis-open.org/legaldocml/ns/akn/3.0" xmlns:data="http://data.parlement.fr/v1">
84
+ <bill contains="singleVersion" name="plf">
85
+ <body>${kept.map((el) => el.outerHTML).join("\n")}</body>
86
+ </bill>
87
+ </akomaNtoso>
88
+ `;
89
+ await fs.writeFile(outPath, fixture);
90
+ if (!options.silent) {
91
+ const sizeKb = (Buffer.byteLength(fixture) / 1024).toFixed(1);
92
+ console.log(`Wrote ${outPath} (${kept.length} etat articles, ${sizeKb} KB)`);
93
+ }
94
+ }
95
+ if (process.argv[1].endsWith("extract_plf_fixtures.ts")) {
96
+ main()
97
+ .catch((error) => {
98
+ console.error(error);
99
+ process.exit(1);
100
+ });
101
+ }