@tricoteuses/senat 2.9.1 → 2.9.6

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/LICENSE.md CHANGED
@@ -1,22 +1,22 @@
1
- # Tricoteuses-Senat
2
-
3
- ## _Handle French Sénat's open data_
4
-
5
- By: Emmanuel Raviart <mailto:emmanuel@raviart.com>
6
-
7
- Copyright (C) 2019, 2020, 2021 Emmanuel Raviart
8
-
9
- https://git.tricoteuses.fr/logiciels/tricoteuses-senat
10
-
11
- > Tricoteuses-Senat is free software; you can redistribute it and/or modify
12
- > it under the terms of the GNU Affero General Public License as
13
- > published by the Free Software Foundation, either version 3 of the
14
- > License, or (at your option) any later version.
15
- >
16
- > Tricoteuses-Senat is distributed in the hope that it will be useful,
17
- > but WITHOUT ANY WARRANTY; without even the implied warranty of
18
- > MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
- > GNU Affero General Public License for more details.
20
- >
21
- > You should have received a copy of the GNU Affero General Public License
22
- > along with this program. If not, see <http://www.gnu.org/licenses/>.
1
+ # Tricoteuses-Senat
2
+
3
+ ## _Handle French Sénat's open data_
4
+
5
+ By: Emmanuel Raviart <mailto:emmanuel@raviart.com>
6
+
7
+ Copyright (C) 2019, 2020, 2021 Emmanuel Raviart
8
+
9
+ https://git.tricoteuses.fr/logiciels/tricoteuses-senat
10
+
11
+ > Tricoteuses-Senat is free software; you can redistribute it and/or modify
12
+ > it under the terms of the GNU Affero General Public License as
13
+ > published by the Free Software Foundation, either version 3 of the
14
+ > License, or (at your option) any later version.
15
+ >
16
+ > Tricoteuses-Senat is distributed in the hope that it will be useful,
17
+ > but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ > MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ > GNU Affero General Public License for more details.
20
+ >
21
+ > You should have received a copy of the GNU Affero General Public License
22
+ > along with this program. If not, see <http://www.gnu.org/licenses/>.
package/README.md CHANGED
@@ -1,116 +1,116 @@
1
- # Tricoteuses-Senat
2
-
3
- ## _Retrieve, clean up & handle French Sénat's open data_
4
-
5
- ## Requirements
6
-
7
- - Node >= 22
8
-
9
- ## Installation
10
-
11
- ```bash
12
- git clone https://git.tricoteuses.fr/logiciels/tricoteuses-senat
13
- cd tricoteuses-senat/
14
- ```
15
-
16
- Create a `.env` file to set PostgreSQL database informations and other configuration variables (you can use `example.env` as a template). Then
17
-
18
- ```bash
19
- npm install
20
- ```
21
-
22
- ### Database creation (not needed if downloading with Docker image)
23
-
24
- #### Using Docker
25
-
26
- ```bash
27
- docker run --name local-postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=$YOUR_CUSTOM_DB_PASSWORD postgres
28
- # Default Postgres user is postgres
29
- # But scripts require an "opendata" role
30
- docker exec -it local-postgres psql -U postgres -c "CREATE ROLE opendata;"
31
- ```
32
-
33
- ## Download data
34
-
35
- Create a folder where the data will be downloaded and run the following command to download the data and convert it into JSON files.
36
-
37
- ```bash
38
- mkdir ../senat-data/
39
-
40
- # Available options for optional `categories` parameter : All, Ameli, Debats, DosLeg, Questions, Sens
41
- npm run data:download ../senat-data -- [--categories All]
42
- ```
43
-
44
- Data from other sources is also available :
45
- ```bash
46
- # Retrieval of textes and rapports from Sénat's website
47
- # Available options for optional `formats` parameter : xml, html, pdf
48
- # Available options for optional `types` parameter : textes, rapports
49
- npm run data:retrieve_documents ../senat-data -- --fromSession 2022 [--formats xml pdf] [--types textes]
50
-
51
- # Retrieval & parsing (textes in xml format only for now)
52
- npm run data:retrieve_documents ../senat-data -- --fromSession 2022 --parseDocuments
53
-
54
- # Parsing only
55
- npm run data:parse_textes_lois ../senat-data
56
-
57
- # Retrieval (& parsing) of agenda from Sénat's website
58
- npm run data:retrieve_agenda ../senat-data -- --fromSession 2022 [--parseAgenda]
59
-
60
- # Retrieval (& parsing) of comptes-rendus des débats from Sénat's website
61
- npm run data:retrieve_comptes_rendus ../senat-data -- [--parseDebats]
62
-
63
- # Retrieval of sénateurs' pictures from Sénat's website
64
- npm run data:retrieve_senateurs_photos ../senat-data
65
- ```
66
-
67
- ## Data download using Docker
68
-
69
- A Docker image that downloads and converts the data all at once is available. Build it locally or run it from the container registry.
70
- Use the environment variables `FROM_SESSION` and `CATEGORIES` if needed.
71
-
72
- ```bash
73
- docker run --pull always --name tricoteuses-senat -v ../senat-data:/app/senat-data -d git.tricoteuses.fr/logiciels/tricoteuses-senat:latest
74
- ```
75
-
76
- Use the environment variable `CATEGORIES` and `FROM_SESSION` if needed.
77
-
78
- ## Using the data
79
-
80
- Once the data is downloaded, you can use loaders to retrieve it.
81
- To use loaders in your project, you can install the _@tricoteuses/senat_ package, and import the iterator functions that you need.
82
-
83
- ```bash
84
- npm install @tricoteuses/senat
85
- ```
86
-
87
- ```js
88
- import { iterLoadSenatQuestions } from "@tricoteuses/senat/loaders"
89
-
90
- // Pass data directory and legislature as arguments
91
- for (const { item: question } of iterLoadSenatQuestions("../senat-data", 17)) {
92
- console.log(question.id)
93
- }
94
- ```
95
-
96
- ## Generation of raw types from SQL schema (for contributors only)
97
-
98
- ```bash
99
- npm run data:generate_schemas ../senat-data
100
- ```
101
-
102
- ## Publishing
103
-
104
- To publish a new version of this package onto npm, bump the package version and publish.
105
-
106
- ```bash
107
- npm version x.y.z # Bumps version in package.json and creates a new tag x.y.z
108
- npx tsc
109
- npm publish
110
- ```
111
-
112
- The Docker image will be automatically built during a CI Workflow if you push the tag to the remote repository.
113
-
114
- ```bash
115
- git push --tags
116
- ```
1
+ # Tricoteuses-Senat
2
+
3
+ ## _Retrieve, clean up & handle French Sénat's open data_
4
+
5
+ ## Requirements
6
+
7
+ - Node >= 22
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ git clone https://git.tricoteuses.fr/logiciels/tricoteuses-senat
13
+ cd tricoteuses-senat/
14
+ ```
15
+
16
+ Create a `.env` file to set PostgreSQL database informations and other configuration variables (you can use `example.env` as a template). Then
17
+
18
+ ```bash
19
+ npm install
20
+ ```
21
+
22
+ ### Database creation (not needed if downloading with Docker image)
23
+
24
+ #### Using Docker
25
+
26
+ ```bash
27
+ docker run --name local-postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=$YOUR_CUSTOM_DB_PASSWORD postgres
28
+ # Default Postgres user is postgres
29
+ # But scripts require an "opendata" role
30
+ docker exec -it local-postgres psql -U postgres -c "CREATE ROLE opendata;"
31
+ ```
32
+
33
+ ## Download data
34
+
35
+ Create a folder where the data will be downloaded and run the following command to download the data and convert it into JSON files.
36
+
37
+ ```bash
38
+ mkdir ../senat-data/
39
+
40
+ # Available options for optional `categories` parameter : All, Ameli, Debats, DosLeg, Questions, Sens
41
+ npm run data:download ../senat-data -- [--categories All]
42
+ ```
43
+
44
+ Data from other sources is also available :
45
+ ```bash
46
+ # Retrieval of textes and rapports from Sénat's website
47
+ # Available options for optional `formats` parameter : xml, html, pdf
48
+ # Available options for optional `types` parameter : textes, rapports
49
+ npm run data:retrieve_documents ../senat-data -- --fromSession 2022 [--formats xml pdf] [--types textes]
50
+
51
+ # Retrieval & parsing (textes in xml format only for now)
52
+ npm run data:retrieve_documents ../senat-data -- --fromSession 2022 --parseDocuments
53
+
54
+ # Parsing only
55
+ npm run data:parse_textes_lois ../senat-data
56
+
57
+ # Retrieval (& parsing) of agenda from Sénat's website
58
+ npm run data:retrieve_agenda ../senat-data -- --fromSession 2022 [--parseAgenda]
59
+
60
+ # Retrieval (& parsing) of comptes-rendus des débats from Sénat's website
61
+ npm run data:retrieve_comptes_rendus ../senat-data -- [--parseDebats]
62
+
63
+ # Retrieval of sénateurs' pictures from Sénat's website
64
+ npm run data:retrieve_senateurs_photos ../senat-data
65
+ ```
66
+
67
+ ## Data download using Docker
68
+
69
+ A Docker image that downloads and converts the data all at once is available. Build it locally or run it from the container registry.
70
+ Use the environment variables `FROM_SESSION` and `CATEGORIES` if needed.
71
+
72
+ ```bash
73
+ docker run --pull always --name tricoteuses-senat -v ../senat-data:/app/senat-data -d git.tricoteuses.fr/logiciels/tricoteuses-senat:latest
74
+ ```
75
+
76
+ Use the environment variable `CATEGORIES` and `FROM_SESSION` if needed.
77
+
78
+ ## Using the data
79
+
80
+ Once the data is downloaded, you can use loaders to retrieve it.
81
+ To use loaders in your project, you can install the _@tricoteuses/senat_ package, and import the iterator functions that you need.
82
+
83
+ ```bash
84
+ npm install @tricoteuses/senat
85
+ ```
86
+
87
+ ```js
88
+ import { iterLoadSenatQuestions } from "@tricoteuses/senat/loaders"
89
+
90
+ // Pass data directory and legislature as arguments
91
+ for (const { item: question } of iterLoadSenatQuestions("../senat-data", 17)) {
92
+ console.log(question.id)
93
+ }
94
+ ```
95
+
96
+ ## Generation of raw types from SQL schema (for contributors only)
97
+
98
+ ```bash
99
+ npm run data:generate_schemas ../senat-data
100
+ ```
101
+
102
+ ## Publishing
103
+
104
+ To publish a new version of this package onto npm, bump the package version and publish.
105
+
106
+ ```bash
107
+ npm version x.y.z # Bumps version in package.json and creates a new tag x.y.z
108
+ npx tsc
109
+ npm publish
110
+ ```
111
+
112
+ The Docker image will be automatically built during a CI Workflow if you push the tag to the remote repository.
113
+
114
+ ```bash
115
+ git push --tags
116
+ ```
package/lib/loaders.d.ts CHANGED
@@ -5,6 +5,7 @@ import { QuestionResult } from "./model/questions";
5
5
  import { CirconscriptionResult, OrganismeResult, SenateurResult } from "./model/sens";
6
6
  import { AgendaEvent } from "./types/agenda";
7
7
  import { FlatTexte } from "./types/texte";
8
+ import { CompteRendu } from "./types/compte_rendu";
8
9
  export { EnabledDatasets } from "./datasets";
9
10
  export declare const AGENDA_FOLDER = "agenda";
10
11
  export declare const COMPTES_RENDUS_FOLDER = "seances";
@@ -17,7 +18,7 @@ export declare const TEXTE_FOLDER = "leg";
17
18
  export declare const DATA_ORIGINAL_FOLDER = "original";
18
19
  export declare const DATA_TRANSFORMED_FOLDER = "transformed";
19
20
  export declare const DOCUMENT_METADATA_FILE = "metadata.json";
20
- type IterItem<T> = {
21
+ export type IterItem<T> = {
21
22
  item: T;
22
23
  filePathFromDataset?: string;
23
24
  legislature?: number;
@@ -69,6 +70,9 @@ export declare function iterLoadSenatDossiersLegislatifsDocuments(dataDir: strin
69
70
  export declare function iterLoadSenatDossiersLegislatifsRapports(dataDir: string, session: number | undefined, options?: {}): Generator<IterItem<DossierLegislatifDocumentResult>>;
70
71
  export declare function iterLoadSenatDossiersLegislatifsTextes(dataDir: string, session: number | undefined, options?: {}): Generator<IterItem<DossierLegislatifDocumentResult>>;
71
72
  export declare function loadSenatTexteContent(dataDir: string, textePathFromDataset: string): IterItem<FlatTexte | null>;
73
+ export declare function loadSenatCompteRenduContent(dataDir: string, session: number, debatId: string | number): {
74
+ item: CompteRendu | null;
75
+ };
72
76
  export declare function iterLoadSenatAgendas(dataDir: string, session: number | undefined, options?: {}): Generator<IterItem<AgendaEvent[]>>;
73
77
  export declare function iterLoadSenatEvenements(dataDir: string, session: number | undefined, options?: {}): Generator<IterItem<AgendaEvent>>;
74
78
  export declare function iterLoadSenatCirconscriptions(dataDir: string, options?: {}): Generator<IterItem<CirconscriptionResult>>;
package/lib/loaders.js CHANGED
@@ -144,6 +144,14 @@ export function loadSenatTexteContent(dataDir, textePathFromDataset) {
144
144
  const texteJson = fs.readFileSync(fullTextePath, { encoding: "utf8" });
145
145
  return { item: JSON.parse(texteJson) };
146
146
  }
147
+ export function loadSenatCompteRenduContent(dataDir, session, debatId) {
148
+ const fullPath = path.join(dataDir, COMPTES_RENDUS_FOLDER, DATA_TRANSFORMED_FOLDER, String(session), `${debatId}.json`);
149
+ if (!fs.existsSync(fullPath)) {
150
+ return { item: null };
151
+ }
152
+ const json = fs.readFileSync(fullPath, { encoding: "utf8" });
153
+ return { item: JSON.parse(json) };
154
+ }
147
155
  export function* iterLoadSenatAgendas(dataDir, session, options = {}) {
148
156
  for (const evenementsItem of iterLoadSenatItems(dataDir, AGENDA_FOLDER, session, DATA_TRANSFORMED_FOLDER, options)) {
149
157
  yield evenementsItem;
@@ -119,6 +119,8 @@ function transformAgenda(document, fileName) {
119
119
  captationVideo: videoElement !== null,
120
120
  urlDossierSenat: urlDossierSenat,
121
121
  quantieme: eventIsSeance(eventElement) ? getQuantieme(eventElement, seanceElements) : null,
122
+ urlVideo: null,
123
+ timecodeDebutVideo: null
122
124
  });
123
125
  }
124
126
  return agendaEvents;
@@ -1,3 +1,2 @@
1
1
  import { CompteRendu } from "../types/compte_rendu";
2
- import { DebatResult } from "./debats";
3
- export declare function parseCompteRenduFromFile(htmlFilePath: string, debat: DebatResult): Promise<CompteRendu | null>;
2
+ export declare function parseCompteRenduFromFile(htmlFilePath: string): Promise<CompteRendu | null>;
@@ -1,32 +1,313 @@
1
1
  import { JSDOM } from "jsdom";
2
- function transformCompteRendu(document, debat) {
3
- const compteRendu = {
4
- sections: [],
2
+ import * as cheerio from "cheerio";
3
+ const norm = (s) => s.replace(/\u00A0/g, " ").replace(/\s+/g, " ").trim();
4
+ const toTexte = (s) => ({ _: s });
5
+ function extractSommaire($) {
6
+ const root = $("#wysiwyg").length ? $("#wysiwyg") : $("#cri");
7
+ const sommaire = {
8
+ presidentSeance: toTexte(""),
9
+ sommaire1: [],
5
10
  };
6
- for (const section of debat.sections) {
7
- const compteRenduSection = {
8
- id: section.id,
9
- interventions: [],
11
+ // (1) presidency line (e.g., "Présidence de Mme …")
12
+ const pres = root.find("p.tm2").filter((_, el) => /présidence/i.test($(el).text())).first();
13
+ if (pres.length) {
14
+ sommaire.presidentSeance = toTexte(norm(pres.text()));
15
+ }
16
+ // (2) extra info lines like "Secrétaires :" (tm5)
17
+ const paras = [];
18
+ root.find("p.tm5").each((_, el) => {
19
+ const t = norm($(el).text());
20
+ if (t)
21
+ paras.push(toTexte(t));
22
+ });
23
+ if (paras.length) {
24
+ sommaire.para = paras.length === 1 ? paras[0] : paras;
25
+ }
26
+ // (3) first-level items (tm3)
27
+ const items = [];
28
+ root.find("p.tm3").each((_, el) => {
29
+ const $p = $(el);
30
+ const full = norm($p.text());
31
+ // try to extract the numeric order at the start: "1. ..." or "2 – ..." etc.
32
+ const numMatch = full.match(/^(\d+)\s*[.\-–—]/);
33
+ const valeur = numMatch ? numMatch[1] : undefined;
34
+ // prefer the linked title text; fallback to full text
35
+ const a = $p.find("a").first();
36
+ const intitule = norm(a.text() || full.replace(/^(\d+)\s*[.\-–—]\s*/, ""));
37
+ // id_syceron = href target without '#' ? TODO verify
38
+ const href = a.attr("href") || "";
39
+ const idSyceron = href.startsWith("#") ? href.slice(1) : href;
40
+ const titreStruct = {
41
+ id_syceron: idSyceron || "",
42
+ intitule,
10
43
  };
11
- compteRendu.sections.push(compteRenduSection);
12
- for (const sectionIntervention of section.interventions) {
13
- const interventionAnchor = document.querySelector(`a[name="int${sectionIntervention.id}"]`);
14
- const interventionParent = interventionAnchor?.parentElement;
15
- compteRenduSection.interventions.push({
16
- id: sectionIntervention.id,
17
- texteHtml: interventionParent?.outerHTML || null,
18
- });
44
+ const elem = {
45
+ valeur_pts_odj: valeur,
46
+ titreStruct,
47
+ // sommaire2/3 undefined (first level only)
48
+ };
49
+ items.push(elem);
50
+ });
51
+ if (items.length) {
52
+ sommaire.sommaire1 = items;
53
+ }
54
+ return sommaire;
55
+ }
56
+ function stripTrailingPunct(s) {
57
+ return s.replace(/\s*([:,.;])\s*$/u, "").trim();
58
+ }
59
+ function dedupeSpeaker(raw) {
60
+ let s = norm(raw);
61
+ s = stripTrailingPunct(s);
62
+ const dupPatterns = [
63
+ /^(.+?)\s*[.]\s*\1$/u,
64
+ /^(.+?)\s*,\s*\1,?$/u,
65
+ /^(.+?)\s+\1$/u,
66
+ ];
67
+ for (const re of dupPatterns) {
68
+ const m = s.match(re);
69
+ if (m) {
70
+ s = m[1];
71
+ break;
19
72
  }
20
73
  }
21
- return compteRendu;
74
+ return s.replace(/\.\s*$/, "");
22
75
  }
23
- export async function parseCompteRenduFromFile(htmlFilePath, debat) {
76
+ function decodeHtmlEntities(s) {
77
+ return s
78
+ .replace(/&#(\d+);/g, (_, d) => String.fromCharCode(parseInt(d, 10)))
79
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
80
+ }
81
+ function fixApostrophes(s) {
82
+ // Tighten spacing around French apostrophes and punctuation
83
+ let out = s;
84
+ out = out.replace(/\s*’\s*/g, "’");
85
+ out = out.replace(/\b([dljctmsn])\s*’/gi, (_, m) => m + "’");
86
+ out = out.replace(/’\s+([A-Za-zÀ-ÖØ-öø-ÿ])/g, "’$1");
87
+ out = out.replace(/\s+([,;:.!?])/g, "$1");
88
+ return out;
89
+ }
90
+ function normalizeTitle(text) {
91
+ return text.replace(/^PR[ÉE]SIDENCE DE\b/i, "Présidence de ");
92
+ }
93
+ function roleForSpeaker(labelOrQualite) {
94
+ const s = labelOrQualite.toLowerCase();
95
+ if (/^(m\.|mme)?\s*(le|la)\s+pr[ée]sident(e)?\b/.test(s) ||
96
+ /\bpr[ée]sident[e]?\s+de\s+séance\b/.test(s)) {
97
+ return "président";
98
+ }
99
+ return "";
100
+ }
101
+ // ---------------- DOM helpers ----------------
102
+ function parseCriIntervenantComment(html) {
103
+ // From <!-- cri:intervenant mat="..." nom="..." qua="..." ... -->
104
+ const m = html.match(/<!--\s*cri:intervenant\b([^>]+)-->/i);
105
+ if (!m)
106
+ return {};
107
+ const attrs = m[1];
108
+ const out = {};
109
+ const re = /(\w+)="([^"]*)"/g;
110
+ let a;
111
+ while ((a = re.exec(attrs))) {
112
+ out[a[1]] = decodeHtmlEntities(a[2]);
113
+ }
114
+ return { mat: out["mat"], nom: out["nom"], qua: out["qua"] };
115
+ }
116
+ /**
117
+ * Extract leading .orateur_qualite chunks from the FIRST <p> only,
118
+ * concatenate them, clean punctuation/apostrophes, and REMOVE those nodes
119
+ * (and .orateur_nom) from the first paragraph so the speech starts cleanly.
120
+ */
121
+ function extractAndRemoveLeadingQualite($, $block) {
122
+ const firstP = $block.find("p").first();
123
+ if (firstP.length === 0)
124
+ return "";
125
+ const parts = [];
126
+ // Iterate over the first <p>'s children from the start
127
+ let stop = false;
128
+ firstP.contents().each((_, node) => {
129
+ if (stop)
130
+ return;
131
+ if (node.type === "tag") {
132
+ const $node = $(node);
133
+ if ($node.hasClass("orateur_nom")) {
134
+ // speaker label node — remove it
135
+ $node.remove();
136
+ return;
137
+ }
138
+ if ($node.hasClass("orateur_qualite")) {
139
+ parts.push($node.text() || "");
140
+ $node.remove();
141
+ return;
142
+ }
143
+ // Non-qualite tag: if it has meaningful text, we reached the speech
144
+ const t = norm($node.text() || "");
145
+ if (t) {
146
+ stop = true;
147
+ }
148
+ else {
149
+ // empty-ish node; remove to avoid stray punctuation
150
+ $node.remove();
151
+ }
152
+ }
153
+ else if (node.type === "text") {
154
+ const t = norm(node.data || "");
155
+ if (!t) {
156
+ // whitespace only — drop it
157
+ ;
158
+ node.data = "";
159
+ return;
160
+ }
161
+ // boundary punctuation like ":" just after label — drop it
162
+ if (/^[:.,;–—-]+$/.test(t)) {
163
+ ;
164
+ node.data = "";
165
+ return;
166
+ }
167
+ // any other text means speech starts here
168
+ stop = true;
169
+ }
170
+ else {
171
+ // comment or others — ignore
172
+ }
173
+ });
174
+ const qual = fixApostrophes(norm(parts.join(" ")));
175
+ return qual;
176
+ }
177
+ function sanitizeInterventionHtml($, $block) {
178
+ // Clone to avoid mutating outer tree order
179
+ const $clone = $block.clone();
180
+ // Remove navigation / anchors / images
181
+ $clone.find('a[name]').remove();
182
+ $clone.find('div[align="right"]').remove();
183
+ $clone.find('a.link').remove();
184
+ $clone.find('img').remove();
185
+ // Remove technical anchors inside interventions
186
+ $clone.find('a#ameli_amendement_cri_phrase, a#ameli_amendement_cra_contenu, a#ameli_amendement_cra_objet').remove();
187
+ // Remove any remaining speaker label / quality spans anywhere
188
+ $clone.find(".orateur_nom").remove();
189
+ $clone.find(".orateur_qualite").remove();
190
+ // Strip HTML comments
191
+ let html = $clone.html() || "";
192
+ html = html.replace(/<!--[\s\S]*?-->/g, "");
193
+ return html.trim();
194
+ }
195
+ function extractMetadonnees($) {
196
+ const headerText = norm($("h1.page-title").text() || "");
197
+ const dateMatch = headerText.match(/\b(\d{1,2}\s+\w+\s+\d{4})\b/i);
198
+ const bodyText = norm($("#cri").text() || "");
199
+ const sessionMatch = bodyText.match(/\bsession\s+(\d{4}-\d{4})\b/i);
200
+ return {
201
+ dateSeance: dateMatch?.[1] || "",
202
+ dateSeanceJour: dateMatch?.[1] || "",
203
+ numSeanceJour: "",
204
+ numSeance: "",
205
+ typeAssemblee: "SN",
206
+ legislature: "",
207
+ session: sessionMatch?.[1] || "",
208
+ nomFichierJo: "",
209
+ validite: "",
210
+ etat: "",
211
+ diffusion: "",
212
+ version: "1.0",
213
+ environnement: "",
214
+ heureGeneration: new Date(),
215
+ sommaire: extractSommaire($)
216
+ };
217
+ }
218
+ // ---------------- main transform ----------------
219
+ export async function parseCompteRenduFromFile(htmlFilePath) {
24
220
  try {
25
- const { document } = (await JSDOM.fromFile(htmlFilePath, { contentType: "text/html" })).window;
26
- return transformCompteRendu(document, debat);
221
+ const { window } = await JSDOM.fromFile(htmlFilePath, { contentType: "text/html" });
222
+ const $ = cheerio.load(window.document.documentElement.outerHTML);
223
+ const metadonnees = extractMetadonnees($);
224
+ const points = [];
225
+ let ordre = 0;
226
+ const addPoint = (p) => points.push({ ...p, ordre_absolu_seance: String(++ordre) });
227
+ // (1) Global section titles (common high-level headings)
228
+ let lastTitle = "";
229
+ $("#cri p[class^='titre_S']").each((_, el) => {
230
+ const t = normalizeTitle(norm($(el).text() || ""));
231
+ if (t && t !== lastTitle) {
232
+ addPoint({ code_grammaire: "TITRE_TEXTE_DISCUSSION", texte: { _: t }, code_style: "Titre" });
233
+ lastTitle = t;
234
+ }
235
+ });
236
+ // (2) Interventions
237
+ $("#cri div.intervenant").each((_, block) => {
238
+ const $block = $(block);
239
+ // (2.a) Extract internal structural titles inside this block (and remove them)
240
+ const structuralSel = [
241
+ "p[class^='titre_S']",
242
+ "p.mention_titre",
243
+ "p.intitule_titre",
244
+ "p.mention_chapitre",
245
+ "p.intitule_chapitre",
246
+ "p.mention_article",
247
+ "p.intitule_article",
248
+ "p.mention_section",
249
+ "p.intitule_section",
250
+ ].join(",");
251
+ $block.find(structuralSel).each((__, el) => {
252
+ const title = normalizeTitle(norm($(el).text() || ""));
253
+ if (title && title !== lastTitle) {
254
+ addPoint({ code_grammaire: "TITRE_TEXTE_DISCUSSION", texte: { _: title }, code_style: "Titre" });
255
+ lastTitle = title;
256
+ }
257
+ $(el).remove();
258
+ });
259
+ // (2.b) Speaker label & quality
260
+ const firstP = $block.find("p").first();
261
+ const speakerLabelRaw = firstP.find(".orateur_nom").text() ||
262
+ firstP.find("a.lien_senfic").text() ||
263
+ "";
264
+ const speakerLabel = dedupeSpeaker(speakerLabelRaw);
265
+ // Prefer <!--cri:intervenant ...--> for id/name/qualite when available
266
+ const rawHtml = $block.html() || "";
267
+ const { mat, nom: nomFromComment, qua: quaFromCommentRaw } = parseCriIntervenantComment(rawHtml);
268
+ // Extract and remove leading .orateur_qualite chunks from first <p>
269
+ const qualFromSpans = extractAndRemoveLeadingQualite($, $block);
270
+ const qualite = norm(decodeHtmlEntities(quaFromCommentRaw || "")) ||
271
+ qualFromSpans;
272
+ const canonicalName = dedupeSpeaker(nomFromComment || speakerLabel);
273
+ const role = roleForSpeaker(speakerLabel) ||
274
+ roleForSpeaker(qualite) ||
275
+ roleForSpeaker(quaFromCommentRaw || "");
276
+ // (2.c) Build cleaned speech HTML
277
+ let speechHtml = sanitizeInterventionHtml($, $block);
278
+ // If nothing meaningful remains, skip
279
+ if (!norm(cheerio.load(speechHtml).text() || ""))
280
+ return;
281
+ addPoint({
282
+ code_grammaire: "PAROLE_GENERIQUE",
283
+ roledebat: role,
284
+ orateurs: {
285
+ orateur: {
286
+ nom: canonicalName,
287
+ id: mat || "",
288
+ qualite: qualite,
289
+ },
290
+ },
291
+ texte: { _: speechHtml },
292
+ });
293
+ });
294
+ const contenu = {
295
+ quantiemes: {
296
+ journee: metadonnees.dateSeance,
297
+ session: metadonnees.session,
298
+ },
299
+ point: points,
300
+ };
301
+ return {
302
+ uid: htmlFilePath.replace(/^.*?(\d{8}).*$/i, "$1"),
303
+ seanceRef: htmlFilePath.replace(/^.*?(\d{8}).*$/i, "$1"),
304
+ sessionRef: metadonnees.session,
305
+ metadonnees,
306
+ contenu,
307
+ };
27
308
  }
28
- catch (error) {
29
- console.error(`Could not parse compte-rendu with error ${error}`);
309
+ catch (e) {
310
+ console.error("Could not parse compte-rendu with error", e);
311
+ return null;
30
312
  }
31
- return null;
32
313
  }