@tricoteuses/senat 3.1.23 → 3.1.24
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/parsers/collaborateurs.d.ts +24 -24
- package/lib/src/parsers/collaborateurs.js +76 -76
- package/lib/src/rich_types/sens.d.ts +5 -0
- package/lib/src/scripts/data-download.js +1 -0
- package/lib/src/scripts/retrieve_collaborateurs.js +93 -93
- package/lib/tests/collaborateurs.test.js +52 -52
- package/package.json +3 -1
package/lib/src/index.d.ts
CHANGED
|
@@ -39,3 +39,4 @@ 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";
|
|
@@ -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
|
}
|
|
@@ -79,6 +79,11 @@ export interface SenateurResult {
|
|
|
79
79
|
siege: string | null;
|
|
80
80
|
url_hatvp: string | null;
|
|
81
81
|
urls: UrlRow[];
|
|
82
|
+
collaborateurs?: Array<{
|
|
83
|
+
civilite: string;
|
|
84
|
+
nom: string;
|
|
85
|
+
prenom: string;
|
|
86
|
+
}>;
|
|
82
87
|
}
|
|
83
88
|
export interface CirconscriptionResult {
|
|
84
89
|
article: string | null;
|
|
@@ -18,3 +18,4 @@ runScript(`cross-env TZ='Etc/UTC' tsx src/scripts/retrieve_agenda.ts ${args} --p
|
|
|
18
18
|
runScript(`tsx src/scripts/retrieve_cr_seance.ts ${args} --parseDebats --silent`);
|
|
19
19
|
runScript(`tsx src/scripts/retrieve_cr_commission.ts ${args} --parseDebats --silent`);
|
|
20
20
|
runScript(`tsx src/scripts/retrieve_videos.ts ${args} --silent`);
|
|
21
|
+
runScript(`tsx src/scripts/retrieve_collaborateurs.ts ${args} --silent`);
|
|
@@ -1,51 +1,51 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { getDocumentProxy } from "unpdf";
|
|
4
|
-
import {
|
|
4
|
+
import { buildCollaboratorRows, extractEditionDate, normalizeForComparison, } from "../parsers/collaborateurs.js";
|
|
5
5
|
import { assertExistingDirectory } from "./shared/cli_helpers.js";
|
|
6
6
|
import { iterFilePaths } from "../server/loaders.js";
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_PDF_URL = "https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf";
|
|
8
8
|
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
|
|
9
|
-
async function
|
|
9
|
+
async function downloadPdf(url) {
|
|
10
10
|
const cookies = new Map();
|
|
11
|
-
let
|
|
11
|
+
let current = url;
|
|
12
12
|
for (let i = 0; i < 8; i += 1) {
|
|
13
13
|
const headers = { "User-Agent": USER_AGENT };
|
|
14
14
|
if (cookies.size > 0) {
|
|
15
15
|
headers["Cookie"] = [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
16
16
|
}
|
|
17
|
-
const
|
|
18
|
-
for (const
|
|
19
|
-
const [
|
|
20
|
-
const idx =
|
|
17
|
+
const response = await fetch(current, { redirect: "manual", headers });
|
|
18
|
+
for (const raw of response.headers.getSetCookie?.() ?? []) {
|
|
19
|
+
const [pair] = raw.split(";");
|
|
20
|
+
const idx = pair.indexOf("=");
|
|
21
21
|
if (idx > 0)
|
|
22
|
-
cookies.set(
|
|
22
|
+
cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
|
|
23
23
|
}
|
|
24
|
-
if (
|
|
25
|
-
const location =
|
|
24
|
+
if (response.status >= 300 && response.status < 400) {
|
|
25
|
+
const location = response.headers.get("location");
|
|
26
26
|
if (!location)
|
|
27
|
-
throw new Error(`
|
|
28
|
-
|
|
27
|
+
throw new Error(`Redirect without Location header (HTTP ${response.status})`);
|
|
28
|
+
current = new URL(location, current).toString();
|
|
29
29
|
continue;
|
|
30
30
|
}
|
|
31
|
-
if (!
|
|
32
|
-
throw new Error(`
|
|
33
|
-
const buffer = Buffer.from(await
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new Error(`PDF download failed: HTTP ${response.status}`);
|
|
33
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
34
34
|
if (buffer.subarray(0, 5).toString("latin1") !== "%PDF-") {
|
|
35
|
-
throw new Error("
|
|
35
|
+
throw new Error("Downloaded content is not a PDF");
|
|
36
36
|
}
|
|
37
37
|
return buffer;
|
|
38
38
|
}
|
|
39
|
-
throw new Error("
|
|
39
|
+
throw new Error("Too many redirects during HR PDF download");
|
|
40
40
|
}
|
|
41
|
-
async function
|
|
41
|
+
async function extractPdfItems(buffer) {
|
|
42
42
|
const pdf = await getDocumentProxy(new Uint8Array(buffer));
|
|
43
43
|
const items = [];
|
|
44
44
|
const strings = [];
|
|
45
45
|
for (let p = 1; p <= pdf.numPages; p += 1) {
|
|
46
46
|
const page = await pdf.getPage(p);
|
|
47
|
-
const
|
|
48
|
-
for (const item of
|
|
47
|
+
const content = await page.getTextContent();
|
|
48
|
+
for (const item of content.items) {
|
|
49
49
|
if (typeof item.str !== "string" || !item.str.trim() || !item.transform)
|
|
50
50
|
continue;
|
|
51
51
|
items.push({ str: item.str, x: item.transform[4], y: item.transform[5], page: p });
|
|
@@ -54,110 +54,110 @@ async function extraireItemsPdf(buffer) {
|
|
|
54
54
|
}
|
|
55
55
|
return { items, strings };
|
|
56
56
|
}
|
|
57
|
-
function
|
|
58
|
-
const
|
|
57
|
+
function loadSenators(dataDir) {
|
|
58
|
+
const senatorsDir = path.join(dataDir, "sens", "senateurs");
|
|
59
59
|
const files = new Map();
|
|
60
|
-
const
|
|
61
|
-
if (!fs.existsSync(
|
|
62
|
-
return { files,
|
|
63
|
-
for (const filePath of iterFilePaths(
|
|
60
|
+
const indexByName = new Map();
|
|
61
|
+
if (!fs.existsSync(senatorsDir))
|
|
62
|
+
return { files, indexByName };
|
|
63
|
+
for (const filePath of iterFilePaths(senatorsDir)) {
|
|
64
64
|
const senator = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
65
65
|
files.set(senator.matricule, senator);
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
66
|
+
const key = normalizeForComparison(senator.nom_usuel);
|
|
67
|
+
const list = indexByName.get(key);
|
|
68
|
+
if (list)
|
|
69
|
+
list.push(senator);
|
|
70
70
|
else
|
|
71
|
-
|
|
71
|
+
indexByName.set(key, [senator]);
|
|
72
72
|
}
|
|
73
|
-
return { files,
|
|
73
|
+
return { files, indexByName };
|
|
74
74
|
}
|
|
75
75
|
async function main() {
|
|
76
76
|
const dataDir = process.argv[2];
|
|
77
77
|
assertExistingDirectory(dataDir, "dataDir");
|
|
78
|
-
// 1.
|
|
79
|
-
console.log(`
|
|
80
|
-
const buffer = await
|
|
81
|
-
const { items, strings } = await
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
console.log(`PDF
|
|
85
|
-
if (
|
|
86
|
-
throw new Error("
|
|
78
|
+
// 1. Download + parse PDF
|
|
79
|
+
console.log(`Downloading HR PDF: ${DEFAULT_PDF_URL}`);
|
|
80
|
+
const buffer = await downloadPdf(DEFAULT_PDF_URL);
|
|
81
|
+
const { items, strings } = await extractPdfItems(buffer);
|
|
82
|
+
const rows = buildCollaboratorRows(items);
|
|
83
|
+
const editionDate = extractEditionDate(strings);
|
|
84
|
+
console.log(`PDF parsed: ${rows.length} collaborator rows, edition ${editionDate?.toISOString().slice(0, 10) ?? "unknown"}`);
|
|
85
|
+
if (rows.length === 0) {
|
|
86
|
+
throw new Error("No collaborator rows extracted from PDF: unexpected format, aborting without changes.");
|
|
87
87
|
}
|
|
88
|
-
// 2.
|
|
89
|
-
const {
|
|
90
|
-
if (
|
|
91
|
-
throw new Error("
|
|
88
|
+
// 2. Load existing senator files
|
|
89
|
+
const { indexByName } = loadSenators(dataDir);
|
|
90
|
+
if (indexByName.size === 0) {
|
|
91
|
+
throw new Error("No senator files found. Run data:download first to generate the files.");
|
|
92
92
|
}
|
|
93
|
-
// 3.
|
|
94
|
-
let
|
|
95
|
-
let
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
for (const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
103
|
-
|
|
104
|
-
|
|
93
|
+
// 3. Resolve PDF senators → files
|
|
94
|
+
let nbEnriched = 0;
|
|
95
|
+
let nbUnresolved = 0;
|
|
96
|
+
// Group collaborators by senator matricule
|
|
97
|
+
const collabsByMatricule = new Map();
|
|
98
|
+
const unresolvedNames = new Set();
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const nameKey = normalizeForComparison(row.senateur.nom);
|
|
101
|
+
const candidates = indexByName.get(nameKey);
|
|
102
|
+
if (!candidates || candidates.length === 0) {
|
|
103
|
+
unresolvedNames.add(`${row.senateur.civilite} ${row.senateur.nom} ${row.senateur.prenom}`.trim());
|
|
104
|
+
nbUnresolved += 1;
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
-
//
|
|
108
|
-
const
|
|
109
|
-
const
|
|
107
|
+
// Disambiguation by first name prefix
|
|
108
|
+
const targetFirstName = normalizeForComparison(row.senateur.prenom);
|
|
109
|
+
const byFirstName = candidates.filter((c) => normalizeForComparison(c.prenom_usuel).startsWith(targetFirstName));
|
|
110
110
|
let senator = null;
|
|
111
|
-
if (
|
|
112
|
-
senator =
|
|
111
|
+
if (candidates.length === 1) {
|
|
112
|
+
senator = candidates[0];
|
|
113
113
|
}
|
|
114
|
-
else if (
|
|
115
|
-
senator =
|
|
114
|
+
else if (byFirstName.length === 1) {
|
|
115
|
+
senator = byFirstName[0];
|
|
116
116
|
}
|
|
117
117
|
else {
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
unresolvedNames.add(`${row.senateur.civilite} ${row.senateur.nom} ${row.senateur.prenom}`.trim());
|
|
119
|
+
nbUnresolved += 1;
|
|
120
120
|
continue;
|
|
121
121
|
}
|
|
122
|
-
let collabs =
|
|
122
|
+
let collabs = collabsByMatricule.get(senator.matricule);
|
|
123
123
|
if (!collabs) {
|
|
124
124
|
collabs = [];
|
|
125
|
-
|
|
125
|
+
collabsByMatricule.set(senator.matricule, collabs);
|
|
126
126
|
}
|
|
127
|
-
//
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
if (!collabs.some((c) =>
|
|
127
|
+
// Deduplication
|
|
128
|
+
const nName = normalizeForComparison(row.collaborateur.nom);
|
|
129
|
+
const nFirstName = normalizeForComparison(row.collaborateur.prenom);
|
|
130
|
+
if (!collabs.some((c) => normalizeForComparison(c.nom) === nName && normalizeForComparison(c.prenom) === nFirstName)) {
|
|
131
131
|
collabs.push({
|
|
132
|
-
civilite:
|
|
133
|
-
nom:
|
|
134
|
-
prenom:
|
|
132
|
+
civilite: row.collaborateur.civilite,
|
|
133
|
+
nom: row.collaborateur.nom,
|
|
134
|
+
prenom: row.collaborateur.prenom,
|
|
135
135
|
});
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
-
// 4.
|
|
139
|
-
for (const [matricule, collaborateurs] of
|
|
140
|
-
const
|
|
141
|
-
if (!fs.existsSync(
|
|
138
|
+
// 4. Write collaborators into senator files
|
|
139
|
+
for (const [matricule, collaborateurs] of collabsByMatricule) {
|
|
140
|
+
const filePath = path.join(dataDir, "sens", "senateurs", `${matricule}.json`);
|
|
141
|
+
if (!fs.existsSync(filePath))
|
|
142
142
|
continue;
|
|
143
|
-
const senator = JSON.parse(fs.readFileSync(
|
|
143
|
+
const senator = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
144
144
|
senator.collaborateurs = collaborateurs;
|
|
145
|
-
fs.writeFileSync(
|
|
146
|
-
|
|
145
|
+
fs.writeFileSync(filePath, JSON.stringify(senator, null, 2) + "\n");
|
|
146
|
+
nbEnriched += 1;
|
|
147
147
|
}
|
|
148
|
-
// 5.
|
|
149
|
-
const
|
|
150
|
-
const { files:
|
|
151
|
-
for (const [matricule, senator] of
|
|
152
|
-
if (!
|
|
148
|
+
// 5. Clean collaborators from senators absent from the PDF
|
|
149
|
+
const enrichedMatricules = new Set(collabsByMatricule.keys());
|
|
150
|
+
const { files: allFiles } = loadSenators(dataDir);
|
|
151
|
+
for (const [matricule, senator] of allFiles) {
|
|
152
|
+
if (!enrichedMatricules.has(matricule) && senator.collaborateurs) {
|
|
153
153
|
delete senator.collaborateurs;
|
|
154
|
-
const
|
|
155
|
-
fs.writeFileSync(
|
|
154
|
+
const filePath = path.join(dataDir, "sens", "senateurs", `${matricule}.json`);
|
|
155
|
+
fs.writeFileSync(filePath, JSON.stringify(senator, null, 2) + "\n");
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
|
-
console.log(`${
|
|
159
|
-
if (
|
|
160
|
-
console.warn(`${
|
|
158
|
+
console.log(`${nbEnriched} senator(s) enriched with their collaborators`);
|
|
159
|
+
if (unresolvedNames.size > 0) {
|
|
160
|
+
console.warn(`${nbUnresolved} PDF senator(s) could not be matched: ${[...unresolvedNames].slice(0, 10).join(", ")}${unresolvedNames.size > 10 ? "…" : ""}`);
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
main().catch((error) => {
|
|
@@ -1,84 +1,84 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
//
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
/**
|
|
7
|
-
function pageItems(
|
|
2
|
+
import { buildCollaboratorRows, extractEditionDate, normalizeForComparison, parsePersonCell, } from "../src/parsers/collaborateurs.js";
|
|
3
|
+
// X bands measured on the real PDF: employer ≈ 147, collaborator ≈ 285.
|
|
4
|
+
const X_EMPLOYER = 147;
|
|
5
|
+
const X_COLLABORATOR = 285;
|
|
6
|
+
/** Builds a synthetic page: for each row, [employer?, collaborator]. */
|
|
7
|
+
function pageItems(rows, page = 1) {
|
|
8
8
|
const items = [];
|
|
9
9
|
let y = 700;
|
|
10
|
-
for (const [
|
|
11
|
-
if (
|
|
12
|
-
items.push({ str:
|
|
13
|
-
items.push({ str:
|
|
10
|
+
for (const [employer, collaborator] of rows) {
|
|
11
|
+
if (employer)
|
|
12
|
+
items.push({ str: employer, x: X_EMPLOYER, y, page });
|
|
13
|
+
items.push({ str: collaborator, x: X_COLLABORATOR, y, page });
|
|
14
14
|
y -= 15;
|
|
15
15
|
}
|
|
16
16
|
return items;
|
|
17
17
|
}
|
|
18
|
-
describe("
|
|
19
|
-
it("
|
|
20
|
-
expect(
|
|
21
|
-
expect(
|
|
18
|
+
describe("parsePersonCell", () => {
|
|
19
|
+
it("splits civility, last name and first name", () => {
|
|
20
|
+
expect(parsePersonCell("M. CHAREF Dahmane")).toEqual({ civilite: "M.", nom: "CHAREF", prenom: "Dahmane" });
|
|
21
|
+
expect(parsePersonCell("Mme OLIVIER Béatrice")).toEqual({
|
|
22
22
|
civilite: "Mme",
|
|
23
23
|
nom: "OLIVIER",
|
|
24
24
|
prenom: "Béatrice",
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
|
-
it("
|
|
28
|
-
expect(
|
|
27
|
+
it("handles compound names (multiple uppercase tokens)", () => {
|
|
28
|
+
expect(parsePersonCell("Mme MAZENOT CHAPPUY Sophie")).toEqual({
|
|
29
29
|
civilite: "Mme",
|
|
30
30
|
nom: "MAZENOT CHAPPUY",
|
|
31
31
|
prenom: "Sophie",
|
|
32
32
|
});
|
|
33
|
-
expect(
|
|
33
|
+
expect(parsePersonCell("M. DI FOLCO Camille")).toEqual({ civilite: "M.", nom: "DI FOLCO", prenom: "Camille" });
|
|
34
34
|
});
|
|
35
|
-
it("
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
35
|
+
it("handles nobiliary particles at the start of the name (de, de la)", () => {
|
|
36
|
+
expect(parsePersonCell("Mme de CIDRAC Marta")).toEqual({ civilite: "Mme", nom: "de CIDRAC", prenom: "Marta" });
|
|
37
|
+
expect(parsePersonCell("Mme de LA GONTRIE Marie-Pierre")).toEqual({
|
|
38
38
|
civilite: "Mme",
|
|
39
39
|
nom: "de LA GONTRIE",
|
|
40
40
|
prenom: "Marie-Pierre",
|
|
41
41
|
});
|
|
42
|
-
expect(
|
|
42
|
+
expect(parsePersonCell("M. de LEGGE Dominique")).toEqual({
|
|
43
43
|
civilite: "M.",
|
|
44
44
|
nom: "de LEGGE",
|
|
45
45
|
prenom: "Dominique",
|
|
46
46
|
});
|
|
47
|
-
expect(
|
|
47
|
+
expect(parsePersonCell("Mme APOURCEAU-POLY Cathy")).toEqual({
|
|
48
48
|
civilite: "Mme",
|
|
49
49
|
nom: "APOURCEAU-POLY",
|
|
50
50
|
prenom: "Cathy",
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
|
-
it("
|
|
54
|
-
expect(
|
|
53
|
+
it("keeps a truncated first name as-is", () => {
|
|
54
|
+
expect(parsePersonCell("Mme AESCHLIMANN Marie-Do")).toEqual({
|
|
55
55
|
civilite: "Mme",
|
|
56
56
|
nom: "AESCHLIMANN",
|
|
57
57
|
prenom: "Marie-Do",
|
|
58
58
|
});
|
|
59
59
|
});
|
|
60
|
-
it("
|
|
61
|
-
expect(
|
|
60
|
+
it("ignores the (*) unpaid leave marker", () => {
|
|
61
|
+
expect(parsePersonCell("M. DURAND (*) Paul")).toEqual({ civilite: "M.", nom: "DURAND", prenom: "Paul" });
|
|
62
62
|
});
|
|
63
|
-
it("
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
63
|
+
it("returns null for an empty cell or civility-only", () => {
|
|
64
|
+
expect(parsePersonCell("")).toBeNull();
|
|
65
|
+
expect(parsePersonCell("M.")).toBeNull();
|
|
66
66
|
});
|
|
67
67
|
});
|
|
68
|
-
describe("
|
|
69
|
-
it("
|
|
68
|
+
describe("buildCollaboratorRows", () => {
|
|
69
|
+
it("attaches multiple collaborators to the current senator (carry-forward when employer is empty)", () => {
|
|
70
70
|
const items = pageItems([
|
|
71
71
|
["Mme AESCHLIMANN Marie-Do", "M. CHAREF Dahmane"],
|
|
72
72
|
[null, "M. POURQUERY Alexandre"],
|
|
73
73
|
[null, "M. SEBRIER Valentin"],
|
|
74
74
|
["M. ALLIZARD Pascal", "Mme OLIVIER Béatrice"],
|
|
75
75
|
]);
|
|
76
|
-
const
|
|
77
|
-
expect(
|
|
78
|
-
expect(
|
|
79
|
-
expect(
|
|
76
|
+
const rows = buildCollaboratorRows(items);
|
|
77
|
+
expect(rows).toHaveLength(4);
|
|
78
|
+
expect(rows.slice(0, 3).every((r) => r.senateur.nom === "AESCHLIMANN")).toBe(true);
|
|
79
|
+
expect(rows[3].senateur.nom === "ALLIZARD" && rows[3].collaborateur.nom === "OLIVIER").toBe(true);
|
|
80
80
|
});
|
|
81
|
-
it("
|
|
81
|
+
it("ignores headers, footers and page numbers, and handles multiple pages", () => {
|
|
82
82
|
const items = [
|
|
83
83
|
{ str: "Liste des collaborateurs par Sénateur", x: 208, y: 760, page: 1 },
|
|
84
84
|
{ str: "Employeur", x: 193, y: 740, page: 1 },
|
|
@@ -88,28 +88,28 @@ describe("construireLignesCollaborateurs", () => {
|
|
|
88
88
|
{ str: "- 1 -", x: 296, y: 30, page: 1 },
|
|
89
89
|
...pageItems([["Mme BÉLIM Audrey", "M. BOUCHER Thibault"]], 2),
|
|
90
90
|
];
|
|
91
|
-
const
|
|
92
|
-
expect(
|
|
93
|
-
expect(
|
|
94
|
-
expect(
|
|
91
|
+
const rows = buildCollaboratorRows(items);
|
|
92
|
+
expect(rows).toHaveLength(2);
|
|
93
|
+
expect(rows[0].senateur.nom).toBe("BACCI");
|
|
94
|
+
expect(rows[1].senateur.nom).toBe("BÉLIM");
|
|
95
95
|
});
|
|
96
|
-
it("
|
|
97
|
-
const
|
|
98
|
-
expect(
|
|
96
|
+
it("emits nothing until an employer has been encountered", () => {
|
|
97
|
+
const orphan = [{ str: "M. ORPHELIN Jean", x: X_COLLABORATOR, y: 700, page: 1 }];
|
|
98
|
+
expect(buildCollaboratorRows(orphan)).toHaveLength(0);
|
|
99
99
|
});
|
|
100
100
|
});
|
|
101
|
-
describe("
|
|
102
|
-
it("
|
|
103
|
-
const date =
|
|
101
|
+
describe("extractEditionDate", () => {
|
|
102
|
+
it("extracts the edition date from the footer", () => {
|
|
103
|
+
const date = extractEditionDate(["…", "Edition du 24/06/2026", "(*) Congé non rémunéré"]);
|
|
104
104
|
expect(date?.toISOString().slice(0, 10)).toBe("2026-06-24");
|
|
105
105
|
});
|
|
106
|
-
it("
|
|
107
|
-
expect(
|
|
106
|
+
it("returns null if absent", () => {
|
|
107
|
+
expect(extractEditionDate(["nothing here"])).toBeNull();
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
|
-
describe("
|
|
111
|
-
it("
|
|
112
|
-
expect(
|
|
113
|
-
expect(
|
|
110
|
+
describe("normalizeForComparison", () => {
|
|
111
|
+
it("uppercases, removes accents and compacts spaces", () => {
|
|
112
|
+
expect(normalizeForComparison(" Béatrice ")).toBe("BEATRICE");
|
|
113
|
+
expect(normalizeForComparison("Bélim")).toBe("BELIM");
|
|
114
114
|
});
|
|
115
115
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tricoteuses/senat",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.24",
|
|
4
4
|
"description": "Handle French Sénat's open data",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"France",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"data:retrieve_cr_commission": "tsx src/scripts/retrieve_cr_commission.ts",
|
|
61
61
|
"data:retrieve_documents": "tsx src/scripts/retrieve_documents.ts",
|
|
62
62
|
"data:retrieve_open_data": "tsx src/scripts/retrieve_open_data.ts --all",
|
|
63
|
+
"data:retrieve_collaborateurs": "tsx src/scripts/retrieve_collaborateurs.ts",
|
|
63
64
|
"data:retrieve_senateurs_photos": "tsx src/scripts/retrieve_senateurs_photos.ts --fetch",
|
|
64
65
|
"data:retrieve_videos": "tsx src/scripts/retrieve_videos.ts",
|
|
65
66
|
"data:validate_prefixed_tables": "tsx src/scripts/validate_prefixed_tables.ts",
|
|
@@ -88,6 +89,7 @@
|
|
|
88
89
|
"pg": "^8.22.0",
|
|
89
90
|
"pg-cursor": "^2.21.0",
|
|
90
91
|
"slug": "^11.0.1",
|
|
92
|
+
"unpdf": "^1.8.0",
|
|
91
93
|
"windows-1252": "^3.0.4",
|
|
92
94
|
"zod": "^4.4.3"
|
|
93
95
|
},
|