@tricoteuses/senat 2.13.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -200,21 +200,18 @@ export function parseCommissionCRFromFile(htmlFilePath, best, fallback) {
200
200
  }
201
201
  const raw = fs.readFileSync(htmlFilePath, "utf8");
202
202
  const $ = cheerio.load(raw, { xmlMode: false });
203
- // --- champs déterminés depuis best OU fallback (aucun fallback via filename) ---
204
203
  const dateISO = best?.date ?? fallback.dateISO;
205
204
  const startTime = best?.startTime ?? hourShortToStartTime(fallback.hourShort);
206
205
  const organe = best?.organe ?? fallback?.organe ?? undefined;
207
- // UIDs alignés sur makeTypeGroupUid (RUSN…) mais CR = RUSN → CRC
208
206
  const seanceRef = best?.uid ?? makeTypeGroupUid(dateISO, "COM", fallback.hourShort ?? "NA", organe);
209
207
  const uid = seanceRef.replace(/^RU/, "CRC");
210
208
  const dateSeance = toCRDate(dateISO, startTime);
211
- // --- scope du jour ---
212
209
  const $dayRoot = findDayRoot($, dateISO);
213
210
  if ($dayRoot.length === 0) {
214
211
  console.warn(`[COM-CR][parse] day root not found for ${dateISO} in ${path.basename(htmlFilePath)}`);
215
212
  return null;
216
213
  }
217
- // --- collecte des paragraphes/h3 jusqu’au prochain h2 ---
214
+ // --- Collect paragraphes/h3 until next h2 ---
218
215
  const dayParas = [];
219
216
  let $cursor = $dayRoot.next();
220
217
  while ($cursor.length && !$cursor.is("h2")) {
@@ -260,8 +257,8 @@ export function parseCommissionCRFromFile(htmlFilePath, best, fallback) {
260
257
  heureGeneration: new Date(),
261
258
  };
262
259
  return {
263
- uid, // ex: CRC20240117IDC…-HHMM
264
- seanceRef, // ex: RUSN20240117IDC…-HHMM
260
+ uid,
261
+ seanceRef,
265
262
  sessionRef: session,
266
263
  metadonnees,
267
264
  contenu,
@@ -22,14 +22,12 @@ export async function parseCompteRenduSlotFromFile(xmlFilePath, wantedSlot, firs
22
22
  const points = [];
23
23
  let ordre = 0;
24
24
  const addPoint = (p) => points.push({ ...p, ordre_absolu_seance: String(++ordre) });
25
- // Titles
26
- $("cri\\:titreS1 p.titre_S1").each((_, el) => {
27
- if (!elementInAnyInterval(el, idx, intervals))
28
- return;
29
- const t = normalizeTitle(norm($(el).text() || ""));
30
- if (t)
31
- addPoint({ code_grammaire: "TITRE_TEXTE_DISCUSSION", texte: { _: t }, code_style: "Titre" });
32
- });
25
+ // Titles removes because they are just listed at the top of the file and not linked to any ancre
26
+ // $("cri\\:titreS1 p.titre_S1").each((_, el) => {
27
+ // if (!elementInAnyInterval(el, idx, intervals)) return
28
+ // const t = normalizeTitle(norm($(el).text() || ""))
29
+ // if (t) addPoint({ code_grammaire: "TITRE_TEXTE_DISCUSSION", texte: { _: t }, code_style: "Titre" })
30
+ // })
33
31
  // Interventions
34
32
  $("div.intervenant").each((_, block) => {
35
33
  if (!elementInAnyInterval(block, idx, intervals))
@@ -245,7 +245,7 @@ async function retrieveCommissionCRs(options = {}) {
245
245
  deltaMin = candidates[0].d;
246
246
  }
247
247
  }
248
- // Parse CR (avec ou sans best)
248
+ // Parse CR
249
249
  const hourShort = toHourShort(day.openTime) ?? "NA";
250
250
  const cr = parseCommissionCRFromFile(htmlPath, best ?? undefined, {
251
251
  dateISO: day.date,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tricoteuses/senat",
3
- "version": "2.13.0",
3
+ "version": "2.13.1",
4
4
  "description": "Handle French Sénat's open data",
5
5
  "keywords": [
6
6
  "France",
@@ -1,9 +0,0 @@
1
- import { CompteRendu, Sommaire } from "../types/compte_rendu";
2
- import { TimeSlot } from "../types/agenda";
3
- export declare function parseCompteRenduSlotFromFile(xmlFilePath: string, wantedSlot: TimeSlot, firstSlotOfDay?: TimeSlot): Promise<CompteRendu | null>;
4
- export declare function sessionStartYearFromDate(d: Date): number;
5
- export declare function parseYYYYMMDD(yyyymmdd: string): Date | null;
6
- export declare function deriveTitreObjetFromSommaire(sommaire: Sommaire | undefined, slot: TimeSlot): {
7
- titre: string;
8
- objet: string;
9
- };
@@ -1,325 +0,0 @@
1
- import fs from "fs";
2
- import * as cheerio from "cheerio";
3
- import path from "path";
4
- import { computeIntervalsBySlot } from "../utils/cr_spliting";
5
- import { norm } from "./util";
6
- const asArray = (x) => x == null ? [] : Array.isArray(x) ? x : [x];
7
- const toInt = (s) => Number.isFinite(Number(s)) ? Number(s) : Number.POSITIVE_INFINITY;
8
- export async function parseCompteRenduSlotFromFile(xmlFilePath, wantedSlot, firstSlotOfDay) {
9
- try {
10
- const raw = fs.readFileSync(xmlFilePath, "utf8");
11
- const $ = cheerio.load(raw, { xml: false });
12
- const metadonnees = extractMetadonnees($, xmlFilePath);
13
- const order = $("body *").toArray();
14
- const idx = new Map(order.map((el, i) => [el, i]));
15
- const intervalsAll = computeIntervalsBySlot($, idx, firstSlotOfDay);
16
- const intervals = intervalsAll.filter(iv => iv.slot === wantedSlot);
17
- if (intervals.length === 0) {
18
- console.warn(`[CRI] no intervals for ${path.basename(xmlFilePath)} [${wantedSlot}]`);
19
- return null;
20
- }
21
- metadonnees.sommaire = extractSommaireForIntervals($, idx, intervals);
22
- const points = [];
23
- let ordre = 0;
24
- const addPoint = (p) => points.push({ ...p, ordre_absolu_seance: String(++ordre) });
25
- // Titles
26
- $("cri\\:titreS1 p.titre_S1").each((_, el) => {
27
- if (!elementInAnyInterval(el, idx, intervals))
28
- return;
29
- const t = normalizeTitle(norm($(el).text() || ""));
30
- if (t)
31
- addPoint({ code_grammaire: "TITRE_TEXTE_DISCUSSION", texte: { _: t }, code_style: "Titre" });
32
- });
33
- // Interventions
34
- $("div.intervenant").each((_, block) => {
35
- if (!elementInAnyInterval(block, idx, intervals))
36
- return;
37
- const $block = $(block);
38
- $block.find([
39
- "p[class^='titre_S']",
40
- "p.mention_titre",
41
- "p.intitule_titre",
42
- "p.mention_chapitre",
43
- "p.intitule_chapitre",
44
- "p.mention_article",
45
- "p.intitule_article",
46
- "p.mention_section",
47
- "p.intitule_section",
48
- ].join(",")).remove();
49
- const firstP = $block.find("p").first();
50
- const speakerLabelRaw = firstP.find(".orateur_nom").text() || firstP.find("a.lien_senfic").text() || "";
51
- const speakerLabel = dedupeSpeaker(speakerLabelRaw);
52
- const { mat, nom: nomCRI, qua: quaCRI } = readIntervenantMeta($block);
53
- const qualFromSpans = extractAndRemoveLeadingQualite($, $block);
54
- const qualite = norm(decodeHtmlEntities(quaCRI || "")) || qualFromSpans;
55
- const canonicalName = dedupeSpeaker(nomCRI || speakerLabel);
56
- const role = roleForSpeaker(speakerLabel) || roleForSpeaker(qualite) || roleForSpeaker(quaCRI || "");
57
- const speechHtml = sanitizeInterventionHtml($, $block);
58
- if (!norm(cheerio.load(speechHtml).text() || ""))
59
- return;
60
- addPoint({
61
- code_grammaire: "PAROLE_GENERIQUE",
62
- roledebat: role,
63
- orateurs: { orateur: { nom: canonicalName, id: mat || "", qualite } },
64
- texte: { _: speechHtml },
65
- });
66
- });
67
- const contenu = {
68
- quantiemes: { journee: metadonnees.dateSeance, session: metadonnees.session },
69
- point: points,
70
- };
71
- return {
72
- uid: "CRSSN" + xmlFilePath.replace(/^.*?(\d{8}).*$/i, "$1") + `-${wantedSlot}`,
73
- seanceRef: "RUSN" + xmlFilePath.replace(/^.*?(\d{8}).*$/i, "$1") + "IDS-" + wantedSlot,
74
- sessionRef: metadonnees.session,
75
- metadonnees,
76
- contenu,
77
- };
78
- }
79
- catch (e) {
80
- console.error(`[CRI] parseSlot error file=${xmlFilePath} slot=${wantedSlot}:`, e);
81
- return null;
82
- }
83
- }
84
- export function sessionStartYearFromDate(d) {
85
- // Session (1th oct N → 30 sept N+1)
86
- const m = d.getMonth();
87
- const y = d.getFullYear();
88
- return m >= 9 ? y : y - 1;
89
- }
90
- export function parseYYYYMMDD(yyyymmdd) {
91
- if (!/^\d{8}$/.test(yyyymmdd))
92
- return null;
93
- const y = Number(yyyymmdd.slice(0, 4));
94
- const m = Number(yyyymmdd.slice(4, 6)) - 1;
95
- const d = Number(yyyymmdd.slice(6, 8));
96
- const dt = new Date(y, m, d);
97
- return Number.isFinite(dt.getTime()) ? dt : null;
98
- }
99
- export function deriveTitreObjetFromSommaire(sommaire, slot) {
100
- const items = extractLevel1Items(sommaire);
101
- const meaningful = items.filter(it => !isBoilerplate(it.label));
102
- if (meaningful.length === 0) {
103
- return {
104
- titre: `Séance publique ${slotLabel(slot)}`,
105
- objet: "",
106
- };
107
- }
108
- const titre = meaningful[0].label;
109
- const objet = meaningful.slice(0, 3).map(it => it.label).join(" ; ");
110
- return { titre, objet };
111
- }
112
- function slotLabel(slot) {
113
- switch (slot) {
114
- case "MATIN": return "du matin";
115
- case "APRES-MIDI": return "de l’après-midi";
116
- case "SOIR": return "du soir";
117
- default: return "";
118
- }
119
- }
120
- const BOILERPLATE_PATTERNS = [
121
- /proc(?:è|e)s-?verbal/i,
122
- /hommages?/i,
123
- /désignation des vice-?président/i,
124
- /candidatures? aux?/i,
125
- /ordre du jour/i,
126
- /rappels? au règlement/i,
127
- /communications?/i,
128
- /dépôts?/i,
129
- /proclamation/i,
130
- /présidence de/i,
131
- /questions? diverses?/i,
132
- /ouverture de la séance/i,
133
- /clo(?:t|̂)ure de la séance/i,
134
- ];
135
- const isBoilerplate = (label) => !label?.trim() || BOILERPLATE_PATTERNS.some(rx => rx.test(label));
136
- function extractLevel1Items(sommaire) {
137
- const level1 = asArray(sommaire?.sommaire1);
138
- return level1
139
- .map(el => ({
140
- numero: toInt(el?.valeur_pts_odj),
141
- label: String(el?.titreStruct?.intitule ?? "").trim(),
142
- }))
143
- .filter(it => !!it.label)
144
- .sort((a, b) => a.numero - b.numero);
145
- }
146
- function stripTrailingPunct(s) { return s.replace(/\s*([:,.;])\s*$/u, "").trim(); }
147
- function dedupeSpeaker(raw) {
148
- let s = norm(raw);
149
- s = stripTrailingPunct(s);
150
- const dupPatterns = [/^(.+?)\s*[.]\s*\1$/u, /^(.+?)\s*,\s*\1,?$/u, /^(.+?)\s+\1$/u];
151
- for (const re of dupPatterns) {
152
- const m = s.match(re);
153
- if (m) {
154
- s = m[1];
155
- break;
156
- }
157
- }
158
- return s.replace(/\.\s*$/, "");
159
- }
160
- function decodeHtmlEntities(s) {
161
- return s.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
162
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
163
- }
164
- function fixApostrophes(s) {
165
- let out = s;
166
- out = out.replace(/\s*’\s*/g, "’");
167
- out = out.replace(/\b([dljctmsn])\s*’/gi, (_, m) => m + "’");
168
- out = out.replace(/’\s+([A-Za-zÀ-ÖØ-öø-ÿ])/g, "’$1");
169
- out = out.replace(/\s+([,;:.!?])/g, "$1");
170
- return out;
171
- }
172
- function normalizeTitle(text) { return text.replace(/^PR[ÉE]SIDENCE DE\b/i, "Présidence de "); }
173
- function roleForSpeaker(labelOrQualite) {
174
- const s = (labelOrQualite || "").toLowerCase();
175
- if (/^(m\.|mme)?\s*(le|la)\s+pr[ée]sident(e)?\b/.test(s) || /\bpr[ée]sident[e]?\s+de\s+séance\b/.test(s))
176
- return "président";
177
- return "";
178
- }
179
- function readIntervenantMeta($block) {
180
- const int = $block.find('cri\\:intervenant').first();
181
- if (int.length)
182
- return { mat: int.attr("mat") || undefined, nom: int.attr("nom") || undefined, qua: int.attr("qua") || undefined };
183
- const html = $block.html() || "";
184
- const m = html.match(/<!--\s*cri:intervenant\b([^>]+)-->/i);
185
- if (!m)
186
- return {};
187
- const out = {};
188
- const re = /(\w+)="([^"]*)"/g;
189
- let a;
190
- while ((a = re.exec(m[1])))
191
- out[a[1]] = decodeHtmlEntities(a[2]);
192
- return { mat: out["mat"], nom: out["nom"], qua: out["qua"] };
193
- }
194
- function extractAndRemoveLeadingQualite($, $block) {
195
- const firstP = $block.find("p").first();
196
- if (firstP.length === 0)
197
- return "";
198
- const parts = [];
199
- let stop = false;
200
- firstP.contents().each((_, node) => {
201
- if (stop)
202
- return;
203
- if (node.type === "tag") {
204
- const $node = $(node);
205
- if ($node.hasClass("orateur_nom")) {
206
- $node.remove();
207
- return;
208
- }
209
- if ($node.hasClass("orateur_qualite")) {
210
- parts.push($node.text() || "");
211
- $node.remove();
212
- return;
213
- }
214
- const t = norm($node.text() || "");
215
- if (t)
216
- stop = true;
217
- else
218
- $node.remove();
219
- }
220
- else if (node.type === "text") {
221
- const t = norm(node.data || "");
222
- if (!t || /^[:.,;–—-]+$/.test(t)) {
223
- node.data = "";
224
- return;
225
- }
226
- stop = true;
227
- }
228
- });
229
- return fixApostrophes(norm(parts.join(" ")));
230
- }
231
- function sanitizeInterventionHtml($, $block) {
232
- const $clone = $block.clone();
233
- $clone.find('a[name]').remove();
234
- $clone.find('div[align="right"]').remove();
235
- $clone.find('a.link').remove();
236
- $clone.find('img').remove();
237
- $clone.find('a#ameli_amendement_cri_phrase, a#ameli_amendement_cra_contenu, a#ameli_amendement_cra_objet').remove();
238
- $clone.find(".orateur_nom, .orateur_qualite").remove();
239
- let html = $clone.html() || "";
240
- html = html.replace(/<!--[\s\S]*?-->/g, "");
241
- return html.trim();
242
- }
243
- function extractSommaireForIntervals($, idx, intervals) {
244
- const inIv = (el) => elementInAnyInterval(el, idx, intervals);
245
- const root = $("body");
246
- const sommaire = { presidentSeance: { _: "" }, sommaire1: [] };
247
- // (1) Présidence (tm2) — première ligne dans l’intervalle
248
- const pres = root.find("p.tm2").filter((_, el) => inIv(el)).first();
249
- if (pres.length)
250
- sommaire.presidentSeance = { _: norm(pres.text()) };
251
- // (2) Paras tm5 présents dans l’intervalle
252
- const paras = [];
253
- root.find("p.tm5").each((_, el) => {
254
- if (!inIv(el))
255
- return;
256
- const t = norm($(el).text());
257
- if (t)
258
- paras.push({ _: t });
259
- });
260
- if (paras.length)
261
- sommaire.para = paras.length === 1 ? paras[0] : paras;
262
- // (3) Items de 1er niveau (tm3) présents dans l’intervalle
263
- const items = [];
264
- root.find("p.tm3").each((_, el) => {
265
- if (!inIv(el))
266
- return;
267
- const $p = $(el);
268
- const full = norm($p.text() || "");
269
- if (!full)
270
- return;
271
- const numMatch = full.match(/^(\d+)\s*[.\-–—]\s*/);
272
- const valeur = numMatch ? numMatch[1] : undefined;
273
- // prefere intitule in ancre <a> if present
274
- const a = $p.find("a").first();
275
- const intituleRaw = a.length ? a.text() : full.replace(/^(\d+)\s*[.\-–—]\s*/, "");
276
- const intitule = norm(intituleRaw);
277
- // id_syceron from href="#Niv1_SOMx"
278
- const href = (a.attr("href") || "").trim();
279
- const idSyceron = href.startsWith("#") ? href.slice(1) : href;
280
- const titreStruct = { id_syceron: idSyceron || "", intitule };
281
- items.push({ valeur_pts_odj: valeur, titreStruct });
282
- });
283
- if (items.length)
284
- sommaire.sommaire1 = items;
285
- return sommaire;
286
- }
287
- function extractMetadonnees($, filePath) {
288
- let dateText = norm($("h1, h2, .page-title").first().text() || "");
289
- if (!dateText)
290
- dateText = norm($("p").first().text() || "");
291
- const dateMatch = dateText.match(/\b(\d{1,2}\s+\w+\s+\d{4})\b/i);
292
- const allText = norm($("body").text() || "");
293
- const sessionMatch = allText.match(/\bsession\s+(\d{4}-\d{4})\b/i);
294
- let dateSeance = dateMatch?.[1] || "";
295
- if (!dateSeance) {
296
- const m = filePath.match(/d(\d{4})(\d{2})(\d{2})\.xml$/i);
297
- if (m)
298
- dateSeance = `${m[1]}-${m[2]}-${m[3]}`;
299
- }
300
- return {
301
- dateSeance,
302
- dateSeanceJour: dateSeance,
303
- numSeanceJour: "",
304
- numSeance: "",
305
- typeAssemblee: "SN",
306
- legislature: "",
307
- session: sessionMatch?.[1] || "",
308
- nomFichierJo: "",
309
- validite: "",
310
- etat: "",
311
- diffusion: "",
312
- version: "1.0",
313
- environnement: "",
314
- heureGeneration: new Date()
315
- };
316
- }
317
- function elementInAnyInterval(el, idx, intervals) {
318
- const p = idx.get(el);
319
- if (p == null)
320
- return false;
321
- for (const iv of intervals)
322
- if (p >= iv.start && p < iv.end)
323
- return true;
324
- return false;
325
- }