@tricoteuses/senat 3.1.23 → 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.
- package/lib/src/index.d.ts +2 -0
- package/lib/src/other_types/plf.d.ts +21 -0
- package/lib/src/other_types/plf.js +1 -0
- package/lib/src/parsers/collaborateurs.d.ts +24 -24
- package/lib/src/parsers/collaborateurs.js +76 -76
- 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/rich_types/sens.d.ts +5 -0
- package/lib/src/scripts/data-download.js +1 -0
- 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 +93 -93
- 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/collaborateurs.test.js +52 -52
- 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/package.json +5 -1
package/lib/src/index.d.ts
CHANGED
|
@@ -39,3 +39,5 @@ export type { Session, SessionOrAll } from "./other_types/sessions.js";
|
|
|
39
39
|
export { UNDEFINED_SESSION, sessionOptions, sessionOptionsOrAll, getSessionsFromStart } from "./other_types/sessions.js";
|
|
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
|
+
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 {};
|
|
@@ -1,44 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pure functions for parsing the HR PDF listing senators' collaborators
|
|
3
3
|
* (https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* - "Employeur" (
|
|
7
|
-
* - "Nom Collaborateur"
|
|
8
|
-
*
|
|
5
|
+
* The PDF is a two-column table per page:
|
|
6
|
+
* - "Employeur" (the senator) on the left, e.g. "Mme AESCHLIMANN Marie-Do" ;
|
|
7
|
+
* - "Nom Collaborateur" on the right, e.g. "M. CHAREF Dahmane".
|
|
8
|
+
* A line without an employer extends the previous senator.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* ("Marie-Do"),
|
|
10
|
+
* Edge cases handled here: no matricule in the PDF, truncated senator first names
|
|
11
|
+
* ("Marie-Do"), compound names ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
|
|
12
12
|
*/
|
|
13
|
-
/**
|
|
13
|
+
/** Positioned text element extracted from the PDF (x goes right, y goes up). */
|
|
14
14
|
export type PdfTextItem = {
|
|
15
15
|
str: string;
|
|
16
16
|
x: number;
|
|
17
17
|
y: number;
|
|
18
18
|
page: number;
|
|
19
19
|
};
|
|
20
|
-
export type
|
|
20
|
+
export type PersonName = {
|
|
21
21
|
civilite: string;
|
|
22
22
|
nom: string;
|
|
23
23
|
prenom: string;
|
|
24
24
|
};
|
|
25
|
-
export type
|
|
26
|
-
senateur:
|
|
27
|
-
collaborateur:
|
|
25
|
+
export type CollaboratorRow = {
|
|
26
|
+
senateur: PersonName;
|
|
27
|
+
collaborateur: PersonName;
|
|
28
28
|
};
|
|
29
|
-
/**
|
|
30
|
-
export declare function
|
|
29
|
+
/** Normalizes a string for comparison: uppercase, no accents, compacted spaces. */
|
|
30
|
+
export declare function normalizeForComparison(value: string): string;
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Splits a "Civility LAST_NAME(S) FirstName" cell into {civilite, nom, prenom}.
|
|
33
|
+
* Uppercase head tokens form the name (handles compound names), the rest is the first name.
|
|
34
|
+
* Returns null if the cell is not a usable person name.
|
|
35
35
|
*/
|
|
36
|
-
export declare function
|
|
37
|
-
/**
|
|
38
|
-
export declare function
|
|
36
|
+
export declare function parsePersonCell(cell: string): PersonName | null;
|
|
37
|
+
/** Extracts the edition date printed in the footer ("Edition du JJ/MM/AAAA"). */
|
|
38
|
+
export declare function extractEditionDate(strings: string[]): Date | null;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* Reconstructs the {senator, collaborator} list from positioned text items.
|
|
41
|
+
* Iterates pages then lines top to bottom; tracks the current senator and
|
|
42
|
+
* attaches collaborators from subsequent lines until a new employer appears.
|
|
43
43
|
*/
|
|
44
|
-
export declare function
|
|
44
|
+
export declare function buildCollaboratorRows(items: PdfTextItem[]): CollaboratorRow[];
|
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pure functions for parsing the HR PDF listing senators' collaborators
|
|
3
3
|
* (https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* - "Employeur" (
|
|
7
|
-
* - "Nom Collaborateur"
|
|
8
|
-
*
|
|
5
|
+
* The PDF is a two-column table per page:
|
|
6
|
+
* - "Employeur" (the senator) on the left, e.g. "Mme AESCHLIMANN Marie-Do" ;
|
|
7
|
+
* - "Nom Collaborateur" on the right, e.g. "M. CHAREF Dahmane".
|
|
8
|
+
* A line without an employer extends the previous senator.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* ("Marie-Do"),
|
|
10
|
+
* Edge cases handled here: no matricule in the PDF, truncated senator first names
|
|
11
|
+
* ("Marie-Do"), compound names ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
|
|
12
12
|
*/
|
|
13
|
-
//
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
//
|
|
18
|
-
// (
|
|
19
|
-
const
|
|
13
|
+
// Horizontal bands of the two columns (measured on the PDF: employer ≈ 147, collaborator ≈ 285).
|
|
14
|
+
const EMPLOYER_COLUMN = { min: 130, max: 225 };
|
|
15
|
+
const COLLABORATOR_COLUMN = { min: 255, max: 360 };
|
|
16
|
+
const CIVILITIES = ["Mme", "M.", "Mlle", "M"];
|
|
17
|
+
// Lowercase nobiliary/patronymic particles that are part of the name when they precede it
|
|
18
|
+
// (e.g. "de CIDRAC", "de LA GONTRIE", "de LEGGE"). The Senate reference keeps them in Nom_usuel.
|
|
19
|
+
const PARTICLES = new Set([
|
|
20
20
|
"de",
|
|
21
21
|
"du",
|
|
22
22
|
"des",
|
|
@@ -33,8 +33,8 @@ const PARTICULES = new Set([
|
|
|
33
33
|
"del",
|
|
34
34
|
"dos",
|
|
35
35
|
]);
|
|
36
|
-
//
|
|
37
|
-
const
|
|
36
|
+
// Header / footer lines to ignore.
|
|
37
|
+
const IGNORED_LINES = [
|
|
38
38
|
/liste des collaborateurs/i,
|
|
39
39
|
/^employeur$/i,
|
|
40
40
|
/^nom collaborateur$/i,
|
|
@@ -43,9 +43,9 @@ const LIGNES_IGNOREES = [
|
|
|
43
43
|
/congé non rémunéré/i,
|
|
44
44
|
/^-\s*\d+\s*-$/,
|
|
45
45
|
];
|
|
46
|
-
/**
|
|
47
|
-
export function
|
|
48
|
-
return
|
|
46
|
+
/** Normalizes a string for comparison: uppercase, no accents, compacted spaces. */
|
|
47
|
+
export function normalizeForComparison(value) {
|
|
48
|
+
return value
|
|
49
49
|
.normalize("NFD")
|
|
50
50
|
.replace(/\p{Diacritic}/gu, "")
|
|
51
51
|
.toUpperCase()
|
|
@@ -53,105 +53,105 @@ export function normaliserPourComparaison(valeur) {
|
|
|
53
53
|
.replace(/\s+/g, " ")
|
|
54
54
|
.trim();
|
|
55
55
|
}
|
|
56
|
-
function
|
|
56
|
+
function isIgnored(str) {
|
|
57
57
|
const t = str.trim();
|
|
58
58
|
if (!t)
|
|
59
59
|
return true;
|
|
60
|
-
return
|
|
60
|
+
return IGNORED_LINES.some((re) => re.test(t));
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
63
|
+
* Splits a "Civility LAST_NAME(S) FirstName" cell into {civilite, nom, prenom}.
|
|
64
|
+
* Uppercase head tokens form the name (handles compound names), the rest is the first name.
|
|
65
|
+
* Returns null if the cell is not a usable person name.
|
|
66
66
|
*/
|
|
67
|
-
export function
|
|
68
|
-
const
|
|
67
|
+
export function parsePersonCell(cell) {
|
|
68
|
+
const raw = cell
|
|
69
69
|
.replace(/\(\*\)/g, "")
|
|
70
70
|
.replace(/\s+/g, " ")
|
|
71
71
|
.trim();
|
|
72
|
-
if (!
|
|
72
|
+
if (!raw)
|
|
73
73
|
return null;
|
|
74
74
|
let civilite = "";
|
|
75
|
-
let
|
|
76
|
-
for (const civ of
|
|
77
|
-
if (
|
|
75
|
+
let rest = raw;
|
|
76
|
+
for (const civ of CIVILITIES) {
|
|
77
|
+
if (raw === civ)
|
|
78
78
|
return null;
|
|
79
|
-
if (
|
|
79
|
+
if (raw.startsWith(`${civ} `)) {
|
|
80
80
|
civilite = civ;
|
|
81
|
-
|
|
81
|
+
rest = raw.slice(civ.length + 1).trim();
|
|
82
82
|
break;
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
if (!
|
|
85
|
+
if (!rest)
|
|
86
86
|
return null;
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
return
|
|
87
|
+
const tokens = rest.split(" ");
|
|
88
|
+
const isUppercase = (token) => {
|
|
89
|
+
const letters = token.replace(/[^A-Za-zÀ-ÿ]/g, "");
|
|
90
|
+
return letters.length > 0 && letters === letters.toUpperCase();
|
|
91
91
|
};
|
|
92
|
-
const
|
|
92
|
+
const nameTokens = [];
|
|
93
93
|
let i = 0;
|
|
94
|
-
//
|
|
95
|
-
while (i <
|
|
96
|
-
|
|
94
|
+
// Leading lowercase particles (e.g. "de", "de la") that are part of the name.
|
|
95
|
+
while (i < tokens.length && PARTICLES.has(tokens[i].toLowerCase())) {
|
|
96
|
+
nameTokens.push(tokens[i]);
|
|
97
97
|
i++;
|
|
98
98
|
}
|
|
99
|
-
//
|
|
100
|
-
while (i <
|
|
101
|
-
|
|
99
|
+
// Then uppercase name tokens.
|
|
100
|
+
while (i < tokens.length && isUppercase(tokens[i])) {
|
|
101
|
+
nameTokens.push(tokens[i]);
|
|
102
102
|
i++;
|
|
103
103
|
}
|
|
104
|
-
//
|
|
105
|
-
if (
|
|
106
|
-
|
|
104
|
+
// A lone particle without an uppercase name is not valid: reset.
|
|
105
|
+
if (nameTokens.length > 0 && nameTokens.every((j) => PARTICLES.has(j.toLowerCase()))) {
|
|
106
|
+
nameTokens.length = 0;
|
|
107
107
|
i = 0;
|
|
108
108
|
}
|
|
109
|
-
//
|
|
110
|
-
if (
|
|
111
|
-
|
|
109
|
+
// If no uppercase token (unexpected case), take the first as name.
|
|
110
|
+
if (nameTokens.length === 0 && tokens.length > 0) {
|
|
111
|
+
nameTokens.push(tokens[0]);
|
|
112
112
|
i = 1;
|
|
113
113
|
}
|
|
114
|
-
const nom =
|
|
115
|
-
const prenom =
|
|
114
|
+
const nom = nameTokens.join(" ");
|
|
115
|
+
const prenom = tokens.slice(i).join(" ");
|
|
116
116
|
if (!nom)
|
|
117
117
|
return null;
|
|
118
118
|
return { civilite, nom, prenom };
|
|
119
119
|
}
|
|
120
|
-
/**
|
|
121
|
-
export function
|
|
120
|
+
/** Extracts the edition date printed in the footer ("Edition du JJ/MM/AAAA"). */
|
|
121
|
+
export function extractEditionDate(strings) {
|
|
122
122
|
for (const s of strings) {
|
|
123
123
|
const m = s.match(/Edition du\s+(\d{2})\/(\d{2})\/(\d{4})/i);
|
|
124
124
|
if (m) {
|
|
125
|
-
const [,
|
|
126
|
-
return new Date(Date.UTC(Number(
|
|
125
|
+
const [, dd, mm, yyyy] = m;
|
|
126
|
+
return new Date(Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)));
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
return null;
|
|
130
130
|
}
|
|
131
131
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
132
|
+
* Reconstructs the {senator, collaborator} list from positioned text items.
|
|
133
|
+
* Iterates pages then lines top to bottom; tracks the current senator and
|
|
134
|
+
* attaches collaborators from subsequent lines until a new employer appears.
|
|
135
135
|
*/
|
|
136
|
-
export function
|
|
137
|
-
const
|
|
138
|
-
//
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
let
|
|
142
|
-
for (const it of
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
136
|
+
export function buildCollaboratorRows(items) {
|
|
137
|
+
const useful = items.filter((it) => !isIgnored(it.str));
|
|
138
|
+
// Reading order: increasing page, then decreasing y (top → bottom), then increasing x (left → right).
|
|
139
|
+
const sorted = [...useful].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
|
|
140
|
+
const rows = [];
|
|
141
|
+
let currentSenator = null;
|
|
142
|
+
for (const it of sorted) {
|
|
143
|
+
const inEmployer = it.x >= EMPLOYER_COLUMN.min && it.x <= EMPLOYER_COLUMN.max;
|
|
144
|
+
const inCollaborator = it.x >= COLLABORATOR_COLUMN.min && it.x <= COLLABORATOR_COLUMN.max;
|
|
145
|
+
if (inEmployer) {
|
|
146
|
+
const person = parsePersonCell(it.str);
|
|
147
|
+
if (person)
|
|
148
|
+
currentSenator = person;
|
|
149
149
|
}
|
|
150
|
-
else if (
|
|
151
|
-
const collaborateur =
|
|
150
|
+
else if (inCollaborator && currentSenator) {
|
|
151
|
+
const collaborateur = parsePersonCell(it.str);
|
|
152
152
|
if (collaborateur)
|
|
153
|
-
|
|
153
|
+
rows.push({ senateur: currentSenator, collaborateur });
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
|
-
return
|
|
156
|
+
return rows;
|
|
157
157
|
}
|
|
@@ -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;
|