@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.
@@ -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
- * Fonctions pures de parsing du PDF DRH des collaborateurs de sénateurs
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
- * Le PDF est un tableau à deux colonnes par page :
6
- * - "Employeur" (le sénateur) à gauche, ex. "Mme AESCHLIMANN Marie-Do" ;
7
- * - "Nom Collaborateur" à droite, ex. "M. CHAREF Dahmane".
8
- * Une ligne sans employeur prolonge le sénateur précédent.
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
- * Pièges traités ici : aucun matricule dans le PDF, prénoms de sénateurs tronqués
11
- * ("Marie-Do"), noms composés ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
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
- /** Élément de texte positionné extrait du PDF (x croissant vers la droite, y croissant vers le haut). */
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 PersonneNom = {
20
+ export type PersonName = {
21
21
  civilite: string;
22
22
  nom: string;
23
23
  prenom: string;
24
24
  };
25
- export type LigneCollaborateur = {
26
- senateur: PersonneNom;
27
- collaborateur: PersonneNom;
25
+ export type CollaboratorRow = {
26
+ senateur: PersonName;
27
+ collaborateur: PersonName;
28
28
  };
29
- /** Normalise un texte pour la comparaison : majuscules, sans accents, espaces compactés. */
30
- export declare function normaliserPourComparaison(valeur: string): string;
29
+ /** Normalizes a string for comparison: uppercase, no accents, compacted spaces. */
30
+ export declare function normalizeForComparison(value: string): string;
31
31
  /**
32
- * Sépare une cellule "Civilité NOM(S) Prénom" en {civilite, nom, prenom}.
33
- * Les jetons en majuscules de tête forment le nom (gère les noms composés), le reste le prénom.
34
- * Renvoie null si la cellule n'est pas un nom de personne exploitable.
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 parseCellulePersonne(cellule: string): PersonneNom | null;
37
- /** Extrait la date d'édition imprimée en pied de page ("Edition du JJ/MM/AAAA"). */
38
- export declare function extraireDateEdition(strings: string[]): Date | null;
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
- * Reconstruit la liste {sénateur, collaborateur} à partir des éléments de texte positionnés.
41
- * Parcourt les pages puis les lignes de haut en bas ; mémorise le sénateur courant et lui
42
- * rattache les collaborateurs des lignes suivantes tant qu'aucun nouvel employeur n'apparaît.
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 construireLignesCollaborateurs(items: PdfTextItem[]): LigneCollaborateur[];
44
+ export declare function buildCollaboratorRows(items: PdfTextItem[]): CollaboratorRow[];
@@ -1,22 +1,22 @@
1
1
  /**
2
- * Fonctions pures de parsing du PDF DRH des collaborateurs de sénateurs
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
- * Le PDF est un tableau à deux colonnes par page :
6
- * - "Employeur" (le sénateur) à gauche, ex. "Mme AESCHLIMANN Marie-Do" ;
7
- * - "Nom Collaborateur" à droite, ex. "M. CHAREF Dahmane".
8
- * Une ligne sans employeur prolonge le sénateur précédent.
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
- * Pièges traités ici : aucun matricule dans le PDF, prénoms de sénateurs tronqués
11
- * ("Marie-Do"), noms composés ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
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
- // Bandes horizontales des deux colonnes (mesurées sur le PDF : employeur ≈ 147, collaborateur ≈ 285).
14
- const COLONNE_EMPLOYEUR = { min: 130, max: 225 };
15
- const COLONNE_COLLABORATEUR = { min: 255, max: 360 };
16
- const CIVILITES = ["Mme", "M.", "Mlle", "M"];
17
- // Particules nobiliaires/patronymiques en minuscules, qui font partie du nom quand elles le précèdent
18
- // (ex. "de CIDRAC", "de LA GONTRIE", "de LEGGE"). Le référentiel Sénat les conserve dans Nom_usuel.
19
- const PARTICULES = new Set([
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
- // Lignes d'en-tête / pied de page à ignorer.
37
- const LIGNES_IGNOREES = [
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
- /** Normalise un texte pour la comparaison : majuscules, sans accents, espaces compactés. */
47
- export function normaliserPourComparaison(valeur) {
48
- return valeur
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 estIgnoree(str) {
56
+ function isIgnored(str) {
57
57
  const t = str.trim();
58
58
  if (!t)
59
59
  return true;
60
- return LIGNES_IGNOREES.some((re) => re.test(t));
60
+ return IGNORED_LINES.some((re) => re.test(t));
61
61
  }
62
62
  /**
63
- * Sépare une cellule "Civilité NOM(S) Prénom" en {civilite, nom, prenom}.
64
- * Les jetons en majuscules de tête forment le nom (gère les noms composés), le reste le prénom.
65
- * Renvoie null si la cellule n'est pas un nom de personne exploitable.
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 parseCellulePersonne(cellule) {
68
- const brut = cellule
67
+ export function parsePersonCell(cell) {
68
+ const raw = cell
69
69
  .replace(/\(\*\)/g, "")
70
70
  .replace(/\s+/g, " ")
71
71
  .trim();
72
- if (!brut)
72
+ if (!raw)
73
73
  return null;
74
74
  let civilite = "";
75
- let reste = brut;
76
- for (const civ of CIVILITES) {
77
- if (brut === civ)
75
+ let rest = raw;
76
+ for (const civ of CIVILITIES) {
77
+ if (raw === civ)
78
78
  return null;
79
- if (brut.startsWith(`${civ} `)) {
79
+ if (raw.startsWith(`${civ} `)) {
80
80
  civilite = civ;
81
- reste = brut.slice(civ.length + 1).trim();
81
+ rest = raw.slice(civ.length + 1).trim();
82
82
  break;
83
83
  }
84
84
  }
85
- if (!reste)
85
+ if (!rest)
86
86
  return null;
87
- const jetons = reste.split(" ");
88
- const estMajuscule = (jeton) => {
89
- const lettres = jeton.replace(/[^A-Za-zÀ-ÿ]/g, "");
90
- return lettres.length > 0 && lettres === lettres.toUpperCase();
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 jetonsNom = [];
92
+ const nameTokens = [];
93
93
  let i = 0;
94
- // Particules de tête en minuscules (ex. "de", "de la") faisant partie du nom.
95
- while (i < jetons.length && PARTICULES.has(jetons[i].toLowerCase())) {
96
- jetonsNom.push(jetons[i]);
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
- // Puis les jetons du nom en majuscules.
100
- while (i < jetons.length && estMajuscule(jetons[i])) {
101
- jetonsNom.push(jetons[i]);
99
+ // Then uppercase name tokens.
100
+ while (i < tokens.length && isUppercase(tokens[i])) {
101
+ nameTokens.push(tokens[i]);
102
102
  i++;
103
103
  }
104
- // Une particule seule sans nom en majuscules n'est pas un nom valide : on rétablit.
105
- if (jetonsNom.length > 0 && jetonsNom.every((j) => PARTICULES.has(j.toLowerCase()))) {
106
- jetonsNom.length = 0;
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
- // Si aucun jeton majuscule (cas inattendu), on prend le premier comme nom.
110
- if (jetonsNom.length === 0 && jetons.length > 0) {
111
- jetonsNom.push(jetons[0]);
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 = jetonsNom.join(" ");
115
- const prenom = jetons.slice(i).join(" ");
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
- /** Extrait la date d'édition imprimée en pied de page ("Edition du JJ/MM/AAAA"). */
121
- export function extraireDateEdition(strings) {
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 [, jj, mm, aaaa] = m;
126
- return new Date(Date.UTC(Number(aaaa), Number(mm) - 1, Number(jj)));
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
- * Reconstruit la liste {sénateur, collaborateur} à partir des éléments de texte positionnés.
133
- * Parcourt les pages puis les lignes de haut en bas ; mémorise le sénateur courant et lui
134
- * rattache les collaborateurs des lignes suivantes tant qu'aucun nouvel employeur n'apparaît.
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 construireLignesCollaborateurs(items) {
137
- const utiles = items.filter((it) => !estIgnoree(it.str));
138
- // Tri lecture : page croissante, puis y décroissant (hautbas), puis x croissant (gauchedroite).
139
- const tries = [...utiles].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
140
- const lignes = [];
141
- let senateurCourant = null;
142
- for (const it of tries) {
143
- const dansEmployeur = it.x >= COLONNE_EMPLOYEUR.min && it.x <= COLONNE_EMPLOYEUR.max;
144
- const dansCollaborateur = it.x >= COLONNE_COLLABORATEUR.min && it.x <= COLONNE_COLLABORATEUR.max;
145
- if (dansEmployeur) {
146
- const personne = parseCellulePersonne(it.str);
147
- if (personne)
148
- senateurCourant = personne;
136
+ export function buildCollaboratorRows(items) {
137
+ const useful = items.filter((it) => !isIgnored(it.str));
138
+ // Reading order: increasing page, then decreasing y (topbottom), then increasing x (leftright).
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 (dansCollaborateur && senateurCourant) {
151
- const collaborateur = parseCellulePersonne(it.str);
150
+ else if (inCollaborator && currentSenator) {
151
+ const collaborateur = parsePersonCell(it.str);
152
152
  if (collaborateur)
153
- lignes.push({ senateur: senateurCourant, collaborateur });
153
+ rows.push({ senateur: currentSenator, collaborateur });
154
154
  }
155
155
  }
156
- return lignes;
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 { construireLignesCollaborateurs, extraireDateEdition, normaliserPourComparaison, } from "../parsers/collaborateurs.js";
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 URL_PDF_DEFAUT = "https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf";
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 telechargerPdf(url) {
9
+ async function downloadPdf(url) {
10
10
  const cookies = new Map();
11
- let courant = url;
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 reponse = await fetch(courant, { redirect: "manual", headers });
18
- for (const brut of reponse.headers.getSetCookie?.() ?? []) {
19
- const [paire] = brut.split(";");
20
- const idx = paire.indexOf("=");
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(paire.slice(0, idx).trim(), paire.slice(idx + 1).trim());
22
+ cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
23
23
  }
24
- if (reponse.status >= 300 && reponse.status < 400) {
25
- const location = reponse.headers.get("location");
24
+ if (response.status >= 300 && response.status < 400) {
25
+ const location = response.headers.get("location");
26
26
  if (!location)
27
- throw new Error(`Redirection sans en-tête Location (HTTP ${reponse.status})`);
28
- courant = new URL(location, courant).toString();
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 (!reponse.ok)
32
- throw new Error(`Téléchargement du PDF échoué : HTTP ${reponse.status}`);
33
- const buffer = Buffer.from(await reponse.arrayBuffer());
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("Le contenu téléchargé n'est pas un PDF");
35
+ throw new Error("Downloaded content is not a PDF");
36
36
  }
37
37
  return buffer;
38
38
  }
39
- throw new Error("Trop de redirections lors du téléchargement du PDF DRH");
39
+ throw new Error("Too many redirects during HR PDF download");
40
40
  }
41
- async function extraireItemsPdf(buffer) {
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 contenu = await page.getTextContent();
48
- for (const item of contenu.items) {
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 chargerSenateurs(dataDir) {
58
- const senateursDir = path.join(dataDir, "sens", "senateurs");
57
+ function loadSenators(dataDir) {
58
+ const senatorsDir = path.join(dataDir, "sens", "senateurs");
59
59
  const files = new Map();
60
- const indexParNom = new Map();
61
- if (!fs.existsSync(senateursDir))
62
- return { files, indexParNom };
63
- for (const filePath of iterFilePaths(senateursDir)) {
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 cle = normaliserPourComparaison(senator.nom_usuel);
67
- const liste = indexParNom.get(cle);
68
- if (liste)
69
- liste.push(senator);
66
+ const key = normalizeForComparison(senator.nom_usuel);
67
+ const list = indexByName.get(key);
68
+ if (list)
69
+ list.push(senator);
70
70
  else
71
- indexParNom.set(cle, [senator]);
71
+ indexByName.set(key, [senator]);
72
72
  }
73
- return { files, indexParNom };
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. Téléchargement + parsing du PDF
79
- console.log(`Téléchargement du PDF DRH : ${URL_PDF_DEFAUT}`);
80
- const buffer = await telechargerPdf(URL_PDF_DEFAUT);
81
- const { items, strings } = await extraireItemsPdf(buffer);
82
- const lignes = construireLignesCollaborateurs(items);
83
- const dateEdition = extraireDateEdition(strings);
84
- console.log(`PDF lu : ${lignes.length} lignes collaborateur, édition ${dateEdition?.toISOString().slice(0, 10) ?? "inconnue"}`);
85
- if (lignes.length === 0) {
86
- throw new Error("Aucune ligne collaborateur extraite du PDF : format inattendu, abandon sans modification.");
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. Charger les fichiers sénateurs existants
89
- const { indexParNom } = chargerSenateurs(dataDir);
90
- if (indexParNom.size === 0) {
91
- throw new Error("Aucun fichier sénateur trouvé. Lancez d'abord data:download pour générer les fichiers.");
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. Résoudre les sénateurs du PDF fichiers
94
- let nbEnrichis = 0;
95
- let nbNonResolus = 0;
96
- // Grouper les collaborateurs par matricule de sénateur
97
- const collabsParMatricule = new Map();
98
- const nomsNonResolus = new Set();
99
- for (const ligne of lignes) {
100
- const cleNom = normaliserPourComparaison(ligne.senateur.nom);
101
- const candidats = indexParNom.get(cleNom);
102
- if (!candidats || candidats.length === 0) {
103
- nomsNonResolus.add(`${ligne.senateur.civilite} ${ligne.senateur.nom} ${ligne.senateur.prenom}`.trim());
104
- nbNonResolus += 1;
93
+ // 3. Resolve PDF senatorsfiles
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
- // Désambiguïsation par préfixe de prénom
108
- const prenomCible = normaliserPourComparaison(ligne.senateur.prenom);
109
- const parPrenom = candidats.filter((c) => normaliserPourComparaison(c.prenom_usuel).startsWith(prenomCible));
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 (candidats.length === 1) {
112
- senator = candidats[0];
111
+ if (candidates.length === 1) {
112
+ senator = candidates[0];
113
113
  }
114
- else if (parPrenom.length === 1) {
115
- senator = parPrenom[0];
114
+ else if (byFirstName.length === 1) {
115
+ senator = byFirstName[0];
116
116
  }
117
117
  else {
118
- nomsNonResolus.add(`${ligne.senateur.civilite} ${ligne.senateur.nom} ${ligne.senateur.prenom}`.trim());
119
- nbNonResolus += 1;
118
+ unresolvedNames.add(`${row.senateur.civilite} ${row.senateur.nom} ${row.senateur.prenom}`.trim());
119
+ nbUnresolved += 1;
120
120
  continue;
121
121
  }
122
- let collabs = collabsParMatricule.get(senator.matricule);
122
+ let collabs = collabsByMatricule.get(senator.matricule);
123
123
  if (!collabs) {
124
124
  collabs = [];
125
- collabsParMatricule.set(senator.matricule, collabs);
125
+ collabsByMatricule.set(senator.matricule, collabs);
126
126
  }
127
- // Dédoublonnage
128
- const nNom = normaliserPourComparaison(ligne.collaborateur.nom);
129
- const nPrenom = normaliserPourComparaison(ligne.collaborateur.prenom);
130
- if (!collabs.some((c) => normaliserPourComparaison(c.nom) === nNom && normaliserPourComparaison(c.prenom) === nPrenom)) {
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: ligne.collaborateur.civilite,
133
- nom: ligne.collaborateur.nom,
134
- prenom: ligne.collaborateur.prenom,
132
+ civilite: row.collaborateur.civilite,
133
+ nom: row.collaborateur.nom,
134
+ prenom: row.collaborateur.prenom,
135
135
  });
136
136
  }
137
137
  }
138
- // 4. Écrire les collaborateurs dans les fichiers sénateurs
139
- for (const [matricule, collaborateurs] of collabsParMatricule) {
140
- const cheminFichier = path.join(dataDir, "sens", "senateurs", `${matricule}.json`);
141
- if (!fs.existsSync(cheminFichier))
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(cheminFichier, "utf-8"));
143
+ const senator = JSON.parse(fs.readFileSync(filePath, "utf-8"));
144
144
  senator.collaborateurs = collaborateurs;
145
- fs.writeFileSync(cheminFichier, JSON.stringify(senator, null, 2) + "\n");
146
- nbEnrichis += 1;
145
+ fs.writeFileSync(filePath, JSON.stringify(senator, null, 2) + "\n");
146
+ nbEnriched += 1;
147
147
  }
148
- // 5. Nettoyer les collaborateurs des sénateurs absents du PDF
149
- const matriculesEnrichis = new Set(collabsParMatricule.keys());
150
- const { files: tousLesFichiers } = chargerSenateurs(dataDir);
151
- for (const [matricule, senator] of tousLesFichiers) {
152
- if (!matriculesEnrichis.has(matricule) && senator.collaborateurs) {
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 cheminFichier = path.join(dataDir, "sens", "senateurs", `${matricule}.json`);
155
- fs.writeFileSync(cheminFichier, JSON.stringify(senator, null, 2) + "\n");
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(`${nbEnrichis} sénateur(s) enrichi(s) avec leurs collaborateurs`);
159
- if (nomsNonResolus.size > 0) {
160
- console.warn(`${nbNonResolus} sénateur(s) du PDF non rattaché(s) : ${[...nomsNonResolus].slice(0, 10).join(", ")}${nomsNonResolus.size > 10 ? "…" : ""}`);
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 { construireLignesCollaborateurs, extraireDateEdition, normaliserPourComparaison, parseCellulePersonne, } from "../src/parsers/collaborateurs.js";
3
- // Bandes x mesurées sur le vrai PDF : employeur ≈ 147, collaborateur ≈ 285.
4
- const X_EMPLOYEUR = 147;
5
- const X_COLLABORATEUR = 285;
6
- /** Construit une page synthétique : pour chaque ligne, [employeur?, collaborateur]. */
7
- function pageItems(lignes, page = 1) {
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 [employeur, collaborateur] of lignes) {
11
- if (employeur)
12
- items.push({ str: employeur, x: X_EMPLOYEUR, y, page });
13
- items.push({ str: collaborateur, x: X_COLLABORATEUR, y, page });
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("parseCellulePersonne", () => {
19
- it("sépare civilité, nom et prénom", () => {
20
- expect(parseCellulePersonne("M. CHAREF Dahmane")).toEqual({ civilite: "M.", nom: "CHAREF", prenom: "Dahmane" });
21
- expect(parseCellulePersonne("Mme OLIVIER Béatrice")).toEqual({
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("gère les noms composés (plusieurs jetons en majuscules)", () => {
28
- expect(parseCellulePersonne("Mme MAZENOT CHAPPUY Sophie")).toEqual({
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(parseCellulePersonne("M. DI FOLCO Camille")).toEqual({ civilite: "M.", nom: "DI FOLCO", prenom: "Camille" });
33
+ expect(parsePersonCell("M. DI FOLCO Camille")).toEqual({ civilite: "M.", nom: "DI FOLCO", prenom: "Camille" });
34
34
  });
35
- it("gère les particules nobiliaires en tête de nom (de, de la)", () => {
36
- expect(parseCellulePersonne("Mme de CIDRAC Marta")).toEqual({ civilite: "Mme", nom: "de CIDRAC", prenom: "Marta" });
37
- expect(parseCellulePersonne("Mme de LA GONTRIE Marie-Pierre")).toEqual({
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(parseCellulePersonne("M. de LEGGE Dominique")).toEqual({
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(parseCellulePersonne("Mme APOURCEAU-POLY Cathy")).toEqual({
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("conserve un prénom tronqué tel quel", () => {
54
- expect(parseCellulePersonne("Mme AESCHLIMANN Marie-Do")).toEqual({
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("ignore le marqueur (*) de congé non rémunéré", () => {
61
- expect(parseCellulePersonne("M. DURAND (*) Paul")).toEqual({ civilite: "M.", nom: "DURAND", prenom: "Paul" });
60
+ it("ignores the (*) unpaid leave marker", () => {
61
+ expect(parsePersonCell("M. DURAND (*) Paul")).toEqual({ civilite: "M.", nom: "DURAND", prenom: "Paul" });
62
62
  });
63
- it("renvoie null pour une cellule vide ou une civilité seule", () => {
64
- expect(parseCellulePersonne("")).toBeNull();
65
- expect(parseCellulePersonne("M.")).toBeNull();
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("construireLignesCollaborateurs", () => {
69
- it("rattache plusieurs collaborateurs au sénateur courant (report quand l'employeur est vide)", () => {
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 lignes = construireLignesCollaborateurs(items);
77
- expect(lignes).toHaveLength(4);
78
- expect(lignes.slice(0, 3).every((l) => l.senateur.nom === "AESCHLIMANN")).toBe(true);
79
- expect(lignes[3].senateur.nom === "ALLIZARD" && lignes[3].collaborateur.nom === "OLIVIER").toBe(true);
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("ignore en-têtes, pieds de page et numéros, et gère plusieurs pages", () => {
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 lignes = construireLignesCollaborateurs(items);
92
- expect(lignes).toHaveLength(2);
93
- expect(lignes[0].senateur.nom).toBe("BACCI");
94
- expect(lignes[1].senateur.nom).toBe("BÉLIM");
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("n'émet rien tant qu'aucun employeur n'a été rencontré", () => {
97
- const orphelin = [{ str: "M. ORPHELIN Jean", x: X_COLLABORATEUR, y: 700, page: 1 }];
98
- expect(construireLignesCollaborateurs(orphelin)).toHaveLength(0);
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("extraireDateEdition", () => {
102
- it("extrait la date d'édition du pied de page", () => {
103
- const date = extraireDateEdition(["…", "Edition du 24/06/2026", "(*) Congé non rémunéré"]);
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("renvoie null si absente", () => {
107
- expect(extraireDateEdition(["rien ici"])).toBeNull();
106
+ it("returns null if absent", () => {
107
+ expect(extractEditionDate(["nothing here"])).toBeNull();
108
108
  });
109
109
  });
110
- describe("normaliserPourComparaison", () => {
111
- it("met en majuscules, retire les accents et compacte les espaces", () => {
112
- expect(normaliserPourComparaison(" Béatrice ")).toBe("BEATRICE");
113
- expect(normaliserPourComparaison("Bélim")).toBe("BELIM");
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.23",
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
  },