SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
36 min agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

Normalisation phase 2 : bornes/réinterprétation des salaires structurés (sanitize_salary), extraction salariale par phrases avec garde-fou de contexte (financement/remboursements), clean_html itératif (contenus sur-échappés), détection de langue fr/en/bilingue, extraction des blocs Avantages, titre d'affichage normalisé (title_clean), régions administratives MAMH (17 régions), quarantaine qualité (jobka/quality.py) + colonnes company_logo/language/apply_url/title_clean/quarantine, secours cache détail périmé, expiration des offres orphelines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent b1ff23d

8 changed files +1,919 −18

added data/regions-qc.json +1261 −0
@@ -0,0 +1,1261 @@
1 +{
2 +"abercorn": "Estrie",
3 +"acton vale": "Montérégie",
4 +"adstock": "Chaudière-Appalaches",
5 +"aguanish": "Côte-Nord",
6 +"ahuntsic": "Montréal",
7 +"akulivik": "Nord-du-Québec",
8 +"akwesasne": "Montérégie",
9 +"albanel": "Saguenay–Lac-Saint-Jean",
10 +"albertville": "Bas-Saint-Laurent",
11 +"alleyn-et-cawood": "Outaouais",
12 +"alma": "Saguenay–Lac-Saint-Jean",
13 +"amherst": "Laurentides",
14 +"amos": "Abitibi-Témiscamingue",
15 +"amqui": "Bas-Saint-Laurent",
16 +"ange-gardien": "Montérégie",
17 +"anjou": "Montréal",
18 +"armagh": "Chaudière-Appalaches",
19 +"arundel": "Laurentides",
20 +"ascot corner": "Estrie",
21 +"aston-jonction": "Centre-du-Québec",
22 +"auclair": "Bas-Saint-Laurent",
23 +"audet": "Estrie",
24 +"aumond": "Outaouais",
25 +"aupaluk": "Nord-du-Québec",
26 +"austin": "Estrie",
27 +"authier": "Abitibi-Témiscamingue",
28 +"authier-nord": "Abitibi-Témiscamingue",
29 +"ayer's cliff": "Estrie",
30 +"aylmer": "Outaouais",
31 +"baie-atibenne": "Lanaudière",
32 +"baie-comeau": "Côte-Nord",
33 +"baie-d'hudson": "Nord-du-Québec",
34 +"baie-d'urfe": "Montréal",
35 +"baie-de-la-bouteille": "Lanaudière",
36 +"baie-des-chaloupes": "Laurentides",
37 +"baie-des-sables": "Bas-Saint-Laurent",
38 +"baie-du-febvre": "Centre-du-Québec",
39 +"baie-johan-beetz": "Côte-Nord",
40 +"baie-obaoca": "Lanaudière",
41 +"baie-saint-paul": "Capitale-Nationale",
42 +"baie-sainte-catherine": "Capitale-Nationale",
43 +"baie-trinite": "Côte-Nord",
44 +"barkmere": "Laurentides",
45 +"barnston-ouest": "Estrie",
46 +"barraute": "Abitibi-Témiscamingue",
47 +"batiscan": "Mauricie",
48 +"beaconsfield": "Montréal",
49 +"bearn": "Abitibi-Témiscamingue",
50 +"beauceville": "Chaudière-Appalaches",
51 +"beauharnois": "Montérégie",
52 +"beaulac-garthby": "Chaudière-Appalaches",
53 +"beaumont": "Chaudière-Appalaches",
54 +"beauport": "Capitale-Nationale",
55 +"beaupre": "Capitale-Nationale",
56 +"becancour": "Centre-du-Québec",
57 +"bedford": "Estrie",
58 +"begin": "Saguenay–Lac-Saint-Jean",
59 +"belcourt": "Abitibi-Témiscamingue",
60 +"belle-riviere": "Saguenay–Lac-Saint-Jean",
61 +"belleterre": "Abitibi-Témiscamingue",
62 +"beloeil": "Montérégie",
63 +"berry": "Abitibi-Témiscamingue",
64 +"berthier-sur-mer": "Chaudière-Appalaches",
65 +"berthierville": "Lanaudière",
66 +"bethanie": "Montérégie",
67 +"biencourt": "Bas-Saint-Laurent",
68 +"blainville": "Laurentides",
69 +"blanc-sablon": "Côte-Nord",
70 +"blue sea": "Outaouais",
71 +"boileau": "Outaouais",
72 +"bois-des-filion": "Laurentides",
73 +"bois-franc": "Outaouais",
74 +"boisbriand": "Laurentides",
75 +"boischatel": "Capitale-Nationale",
76 +"bolton-est": "Estrie",
77 +"bolton-ouest": "Estrie",
78 +"bonaventure": "Gaspésie–Îles-de-la-Madeleine",
79 +"bonne-esperance": "Côte-Nord",
80 +"bonsecours": "Estrie",
81 +"boucherville": "Montérégie",
82 +"bouchette": "Outaouais",
83 +"bowman": "Outaouais",
84 +"brebeuf": "Laurentides",
85 +"brigham": "Estrie",
86 +"bristol": "Outaouais",
87 +"brome": "Estrie",
88 +"bromont": "Estrie",
89 +"brossard": "Montérégie",
90 +"brownsburg-chatham": "Laurentides",
91 +"bryson": "Outaouais",
92 +"buckingham": "Outaouais",
93 +"bury": "Estrie",
94 +"cacouna": "Bas-Saint-Laurent",
95 +"calixa-lavallee": "Montérégie",
96 +"campbell's bay": "Outaouais",
97 +"candiac": "Montérégie",
98 +"caniapiscau": "Côte-Nord",
99 +"cantley": "Outaouais",
100 +"cap-chat": "Gaspésie–Îles-de-la-Madeleine",
101 +"cap-de-la-madeleine": "Mauricie",
102 +"cap-rouge": "Capitale-Nationale",
103 +"cap-saint-ignace": "Chaudière-Appalaches",
104 +"cap-sante": "Capitale-Nationale",
105 +"caplan": "Gaspésie–Îles-de-la-Madeleine",
106 +"carignan": "Montérégie",
107 +"carleton-sur-mer": "Gaspésie–Îles-de-la-Madeleine",
108 +"cascades-malignes": "Outaouais",
109 +"cascapedia--saint-jules": "Gaspésie–Îles-de-la-Madeleine",
110 +"causapscal": "Bas-Saint-Laurent",
111 +"cayamant": "Outaouais",
112 +"chambly": "Montérégie",
113 +"chambord": "Saguenay–Lac-Saint-Jean",
114 +"champlain": "Mauricie",
115 +"champneuf": "Abitibi-Témiscamingue",
116 +"chandler": "Gaspésie–Îles-de-la-Madeleine",
117 +"chapais": "Nord-du-Québec",
118 +"charette": "Mauricie",
119 +"charlemagne": "Lanaudière",
120 +"charlesbourg": "Capitale-Nationale",
121 +"charny": "Chaudière-Appalaches",
122 +"chartierville": "Estrie",
123 +"chateau-richer": "Capitale-Nationale",
124 +"chateauguay": "Montérégie",
125 +"chazel": "Abitibi-Témiscamingue",
126 +"chelsea": "Outaouais",
127 +"cheneville": "Outaouais",
128 +"chertsey": "Lanaudière",
129 +"chesterville": "Centre-du-Québec",
130 +"chibougamau": "Nord-du-Québec",
131 +"chichester": "Outaouais",
132 +"chicoutimi": "Saguenay–Lac-Saint-Jean",
133 +"chisasibi": "Nord-du-Québec",
134 +"chute-aux-outardes": "Côte-Nord",
135 +"chute-saint-philippe": "Laurentides",
136 +"clarenceville": "Montérégie",
137 +"clarendon": "Outaouais",
138 +"clermont": "Capitale-Nationale",
139 +"clerval": "Abitibi-Témiscamingue",
140 +"cleveland": "Estrie",
141 +"cloridorme": "Gaspésie–Îles-de-la-Madeleine",
142 +"coaticook": "Estrie",
143 +"collines-du-basque": "Gaspésie–Îles-de-la-Madeleine",
144 +"colombier": "Côte-Nord",
145 +"compton": "Estrie",
146 +"contrecoeur": "Montérégie",
147 +"cookshire-eaton": "Estrie",
148 +"cote-nord-du-golfe-du-saint-laurent": "Côte-Nord",
149 +"cote-saint-luc": "Montréal",
150 +"coteau-du-lac": "Montérégie",
151 +"coucoucache": "Mauricie",
152 +"coulee-des-adolphe": "Gaspésie–Îles-de-la-Madeleine",
153 +"courcelles--saint-evariste": "Chaudière-Appalaches",
154 +"cowansville": "Estrie",
155 +"crabtree": "Lanaudière",
156 +"danville": "Estrie",
157 +"daveluyville": "Centre-du-Québec",
158 +"degelis": "Bas-Saint-Laurent",
159 +"deleage": "Outaouais",
160 +"delson": "Montérégie",
161 +"denholm": "Outaouais",
162 +"depot-echouani": "Outaouais",
163 +"desbiens": "Saguenay–Lac-Saint-Jean",
164 +"deschaillons-sur-saint-laurent": "Centre-du-Québec",
165 +"deschambault-grondines": "Capitale-Nationale",
166 +"deux-montagnes": "Laurentides",
167 +"disraeli": "Chaudière-Appalaches",
168 +"dixville": "Estrie",
169 +"dolbeau-mistassini": "Saguenay–Lac-Saint-Jean",
170 +"dollard-des-ormeaux": "Montréal",
171 +"doncaster": "Laurentides",
172 +"donnacona": "Capitale-Nationale",
173 +"dorval": "Montréal",
174 +"dosquet": "Chaudière-Appalaches",
175 +"drummondville": "Centre-du-Québec",
176 +"dudswell": "Estrie",
177 +"duhamel": "Outaouais",
178 +"duhamel-ouest": "Abitibi-Témiscamingue",
179 +"dundee": "Montérégie",
180 +"dunham": "Estrie",
181 +"duparquet": "Abitibi-Témiscamingue",
182 +"dupuy": "Abitibi-Témiscamingue",
183 +"durham-sud": "Centre-du-Québec",
184 +"east angus": "Estrie",
185 +"east broughton": "Chaudière-Appalaches",
186 +"east farnham": "Estrie",
187 +"east hereford": "Estrie",
188 +"eastmain": "Nord-du-Québec",
189 +"eastman": "Estrie",
190 +"eeyou istchee baie-james": "Nord-du-Québec",
191 +"egan-sud": "Outaouais",
192 +"elgin": "Montérégie",
193 +"entrelacs": "Lanaudière",
194 +"escuminac": "Gaspésie–Îles-de-la-Madeleine",
195 +"esprit-saint": "Bas-Saint-Laurent",
196 +"essipit": "Côte-Nord",
197 +"esterel": "Laurentides",
198 +"farnham": "Estrie",
199 +"fassett": "Outaouais",
200 +"ferland-et-boilleau": "Saguenay–Lac-Saint-Jean",
201 +"ferme-neuve": "Laurentides",
202 +"fermont": "Côte-Nord",
203 +"forestville": "Côte-Nord",
204 +"fort-coulonge": "Outaouais",
205 +"fortierville": "Centre-du-Québec",
206 +"fossambault-sur-le-lac": "Capitale-Nationale",
207 +"frampton": "Chaudière-Appalaches",
208 +"franklin": "Montérégie",
209 +"franquelin": "Côte-Nord",
210 +"frelighsburg": "Estrie",
211 +"frontenac": "Estrie",
212 +"fugereville": "Abitibi-Témiscamingue",
213 +"gallichan": "Abitibi-Témiscamingue",
214 +"gaspe": "Gaspésie–Îles-de-la-Madeleine",
215 +"gatineau": "Outaouais",
216 +"gesgapegiag": "Gaspésie–Îles-de-la-Madeleine",
217 +"girardville": "Saguenay–Lac-Saint-Jean",
218 +"godbout": "Côte-Nord",
219 +"godmanchester": "Montérégie",
220 +"gore": "Laurentides",
221 +"gracefield": "Outaouais",
222 +"granby": "Estrie",
223 +"grand-metis": "Bas-Saint-Laurent",
224 +"grand-remous": "Outaouais",
225 +"grand-saint-esprit": "Centre-du-Québec",
226 +"grande-riviere": "Gaspésie–Îles-de-la-Madeleine",
227 +"grande-vallee": "Gaspésie–Îles-de-la-Madeleine",
228 +"grandes-piles": "Mauricie",
229 +"greenfield park": "Montérégie",
230 +"grenville": "Laurentides",
231 +"grenville-sur-la-rouge": "Laurentides",
232 +"griffintown": "Montréal",
233 +"gros-mecatina": "Côte-Nord",
234 +"grosse-ile": "Gaspésie–Îles-de-la-Madeleine",
235 +"grosses-roches": "Bas-Saint-Laurent",
236 +"guerin": "Abitibi-Témiscamingue",
237 +"ham-nord": "Centre-du-Québec",
238 +"ham-sud": "Estrie",
239 +"hampden": "Estrie",
240 +"hampstead": "Montréal",
241 +"harrington": "Laurentides",
242 +"hatley": "Estrie",
243 +"havelock": "Montérégie",
244 +"havre-saint-pierre": "Côte-Nord",
245 +"hebertville": "Saguenay–Lac-Saint-Jean",
246 +"hemmingford": "Montérégie",
247 +"henryville": "Montérégie",
248 +"herouxville": "Mauricie",
249 +"hinchinbrooke": "Montérégie",
250 +"hochelaga-maisonneuve": "Montréal",
251 +"honfleur": "Chaudière-Appalaches",
252 +"hope": "Gaspésie–Îles-de-la-Madeleine",
253 +"hope town": "Gaspésie–Îles-de-la-Madeleine",
254 +"howick": "Montérégie",
255 +"huberdeau": "Laurentides",
256 +"hudson": "Montérégie",
257 +"hull": "Outaouais",
258 +"huntingdon": "Montérégie",
259 +"ile-des-soeurs": "Montréal",
260 +"inukjuak": "Nord-du-Québec",
261 +"inverness": "Centre-du-Québec",
262 +"irlande": "Chaudière-Appalaches",
263 +"ivry-sur-le-lac": "Laurentides",
264 +"ivujivik": "Nord-du-Québec",
265 +"joliette": "Lanaudière",
266 +"jonquiere": "Saguenay–Lac-Saint-Jean",
267 +"kahnawake": "Montérégie",
268 +"kamouraska": "Bas-Saint-Laurent",
269 +"kangiqsualujjuaq": "Nord-du-Québec",
270 +"kangiqsujuaq": "Nord-du-Québec",
271 +"kangirsuk": "Nord-du-Québec",
272 +"kataskomiq": "Bas-Saint-Laurent",
273 +"kawawachikamach": "Nord-du-Québec",
274 +"kazabazua": "Outaouais",
275 +"kebaowek": "Abitibi-Témiscamingue",
276 +"kiamika": "Laurentides",
277 +"kingsbury": "Estrie",
278 +"kingsey falls": "Centre-du-Québec",
279 +"kinnear's mills": "Chaudière-Appalaches",
280 +"kipawa": "Abitibi-Témiscamingue",
281 +"kirkland": "Montréal",
282 +"kitigan zibi": "Outaouais",
283 +"kuujjuaq": "Nord-du-Québec",
284 +"kuujjuarapik": "Nord-du-Québec",
285 +"l'ancienne-lorette": "Capitale-Nationale",
286 +"l'ange-gardien": "Outaouais",
287 +"l'anse-saint-jean": "Saguenay–Lac-Saint-Jean",
288 +"l'ascension": "Laurentides",
289 +"l'ascension-de-notre-seigneur": "Saguenay–Lac-Saint-Jean",
290 +"l'ascension-de-patapedia": "Gaspésie–Îles-de-la-Madeleine",
291 +"l'assomption": "Lanaudière",
292 +"l'avenir": "Centre-du-Québec",
293 +"l'epiphanie": "Lanaudière",
294 +"l'ile-cadieux": "Montérégie",
295 +"l'ile-d'anticosti": "Côte-Nord",
296 +"l'ile-dorval": "Montréal",
297 +"l'ile-du-grand-calumet": "Outaouais",
298 +"l'ile-perrot": "Montérégie",
299 +"l'isle-aux-allumettes": "Outaouais",
300 +"l'isle-aux-coudres": "Capitale-Nationale",
301 +"l'isle-verte": "Bas-Saint-Laurent",
302 +"l'islet": "Chaudière-Appalaches",
303 +"la baie": "Saguenay–Lac-Saint-Jean",
304 +"la bostonnais": "Mauricie",
305 +"la conception": "Laurentides",
306 +"la corne": "Abitibi-Témiscamingue",
307 +"la dore": "Saguenay–Lac-Saint-Jean",
308 +"la durantaye": "Chaudière-Appalaches",
309 +"la guadeloupe": "Chaudière-Appalaches",
310 +"la macaza": "Laurentides",
311 +"la malbaie": "Capitale-Nationale",
312 +"la martre": "Gaspésie–Îles-de-la-Madeleine",
313 +"la minerve": "Laurentides",
314 +"la morandiere-rochebaucourt": "Abitibi-Témiscamingue",
315 +"la motte": "Abitibi-Témiscamingue",
316 +"la patrie": "Estrie",
317 +"la peche": "Outaouais",
318 +"la pocatiere": "Bas-Saint-Laurent",
319 +"la prairie": "Montérégie",
320 +"la presentation": "Montérégie",
321 +"la redemption": "Bas-Saint-Laurent",
322 +"la reine": "Abitibi-Témiscamingue",
323 +"la romaine": "Côte-Nord",
324 +"la sarre": "Abitibi-Témiscamingue",
325 +"la trinite-des-monts": "Bas-Saint-Laurent",
326 +"la tuque": "Mauricie",
327 +"la visitation-de-l'ile-dupas": "Lanaudière",
328 +"la visitation-de-yamaska": "Centre-du-Québec",
329 +"labelle": "Laurentides",
330 +"labrecque": "Saguenay–Lac-Saint-Jean",
331 +"lac-a-la-croix": "Bas-Saint-Laurent",
332 +"lac-achouakan": "Saguenay–Lac-Saint-Jean",
333 +"lac-akonapwehikan": "Laurentides",
334 +"lac-alfred": "Bas-Saint-Laurent",
335 +"lac-ashuapmushuan": "Saguenay–Lac-Saint-Jean",
336 +"lac-au-brochet": "Côte-Nord",
337 +"lac-au-saumon": "Bas-Saint-Laurent",
338 +"lac-aux-sables": "Mauricie",
339 +"lac-bazinet": "Laurentides",
340 +"lac-beauport": "Capitale-Nationale",
341 +"lac-blanc": "Capitale-Nationale",
342 +"lac-boisbouscache": "Bas-Saint-Laurent",
343 +"lac-bouchette": "Saguenay–Lac-Saint-Jean",
344 +"lac-boule": "Mauricie",
345 +"lac-brome": "Estrie",
346 +"lac-cabasta": "Lanaudière",
347 +"lac-casault": "Bas-Saint-Laurent",
348 +"lac-chicobi": "Abitibi-Témiscamingue",
349 +"lac-croche": "Capitale-Nationale",
350 +"lac-de la bidiere": "Laurentides",
351 +"lac-de-la-maison-de-pierre": "Laurentides",
352 +"lac-de-la-pomme": "Laurentides",
353 +"lac-delage": "Capitale-Nationale",
354 +"lac-des-aigles": "Bas-Saint-Laurent",
355 +"lac-des-dix-milles": "Lanaudière",
356 +"lac-des-eaux-mortes": "Bas-Saint-Laurent",
357 +"lac-des-ecorces": "Laurentides",
358 +"lac-des-plages": "Outaouais",
359 +"lac-des-seize-iles": "Laurentides",
360 +"lac-despinassy": "Abitibi-Témiscamingue",
361 +"lac-devenyns": "Lanaudière",
362 +"lac-douaire": "Laurentides",
363 +"lac-drolet": "Estrie",
364 +"lac-du-cerf": "Laurentides",
365 +"lac-du-taureau": "Lanaudière",
366 +"lac-duparquet": "Abitibi-Témiscamingue",
367 +"lac-edouard": "Mauricie",
368 +"lac-ernest": "Laurentides",
369 +"lac-etchemin": "Chaudière-Appalaches",
370 +"lac-frontiere": "Chaudière-Appalaches",
371 +"lac-granet": "Abitibi-Témiscamingue",
372 +"lac-huron": "Bas-Saint-Laurent",
373 +"lac-jacques-cartier": "Capitale-Nationale",
374 +"lac-jerome": "Côte-Nord",
375 +"lac-john": "Côte-Nord",
376 +"lac-juillet": "Côte-Nord",
377 +"lac-lapeyrere": "Capitale-Nationale",
378 +"lac-legendre": "Lanaudière",
379 +"lac-lenotre": "Outaouais",
380 +"lac-marguerite": "Laurentides",
381 +"lac-masketsi": "Mauricie",
382 +"lac-matapedia": "Bas-Saint-Laurent",
383 +"lac-matawin": "Lanaudière",
384 +"lac-megantic": "Estrie",
385 +"lac-metei": "Abitibi-Témiscamingue",
386 +"lac-minaki": "Lanaudière",
387 +"lac-ministuk": "Saguenay–Lac-Saint-Jean",
388 +"lac-moncouche": "Saguenay–Lac-Saint-Jean",
389 +"lac-moselle": "Outaouais",
390 +"lac-nilgaut": "Outaouais",
391 +"lac-normand": "Mauricie",
392 +"lac-oscar": "Laurentides",
393 +"lac-pikauba": "Capitale-Nationale",
394 +"lac-poulin": "Chaudière-Appalaches",
395 +"lac-pythonga": "Outaouais",
396 +"lac-rapide": "Outaouais",
397 +"lac-saguay": "Laurentides",
398 +"lac-saint-joseph": "Capitale-Nationale",
399 +"lac-saint-paul": "Laurentides",
400 +"lac-sainte-marie": "Outaouais",
401 +"lac-sante": "Lanaudière",
402 +"lac-sergent": "Capitale-Nationale",
403 +"lac-simon": "Abitibi-Témiscamingue",
404 +"lac-superieur": "Laurentides",
405 +"lac-tremblant-nord": "Laurentides",
406 +"lac-vacher": "Côte-Nord",
407 +"lac-wagwabika": "Laurentides",
408 +"lac-walker": "Côte-Nord",
409 +"lachine": "Montréal",
410 +"lachute": "Laurentides",
411 +"lacolle": "Montérégie",
412 +"laforce": "Abitibi-Témiscamingue",
413 +"lalemant": "Saguenay–Lac-Saint-Jean",
414 +"lamarche": "Saguenay–Lac-Saint-Jean",
415 +"lambton": "Estrie",
416 +"landrienne": "Abitibi-Témiscamingue",
417 +"laniel": "Abitibi-Témiscamingue",
418 +"lanoraie": "Lanaudière",
419 +"lantier": "Laurentides",
420 +"larouche": "Saguenay–Lac-Saint-Jean",
421 +"lasalle": "Montréal",
422 +"latulipe-et-gaboury": "Abitibi-Témiscamingue",
423 +"launay": "Abitibi-Témiscamingue",
424 +"laurier-station": "Chaudière-Appalaches",
425 +"laurierville": "Centre-du-Québec",
426 +"laval": "Laval",
427 +"lavaltrie": "Lanaudière",
428 +"laverlochere-angliers": "Abitibi-Témiscamingue",
429 +"lawrenceville": "Estrie",
430 +"le plateau-mont-royal": "Montréal",
431 +"lebel-sur-quevillon": "Nord-du-Québec",
432 +"leclercville": "Chaudière-Appalaches",
433 +"lefebvre": "Centre-du-Québec",
434 +"lejeune": "Bas-Saint-Laurent",
435 +"lemieux": "Centre-du-Québec",
436 +"lery": "Montérégie",
437 +"les bergeronnes": "Côte-Nord",
438 +"les cedres": "Montérégie",
439 +"les coteaux": "Montérégie",
440 +"les eboulements": "Capitale-Nationale",
441 +"les escoumins": "Côte-Nord",
442 +"les hauteurs": "Bas-Saint-Laurent",
443 +"les iles-de-la-madeleine": "Gaspésie–Îles-de-la-Madeleine",
444 +"les lacs-du-temiscamingue": "Abitibi-Témiscamingue",
445 +"les mechins": "Bas-Saint-Laurent",
446 +"levis": "Chaudière-Appalaches",
447 +"lingwick": "Estrie",
448 +"linton": "Capitale-Nationale",
449 +"listuguj": "Gaspésie–Îles-de-la-Madeleine",
450 +"litchfield": "Outaouais",
451 +"lochaber": "Outaouais",
452 +"lochaber-partie-ouest": "Outaouais",
453 +"longue-pointe-de-mingan": "Côte-Nord",
454 +"longue-rive": "Côte-Nord",
455 +"longueuil": "Montérégie",
456 +"loretteville": "Capitale-Nationale",
457 +"lorraine": "Laurentides",
458 +"lorrainville": "Abitibi-Témiscamingue",
459 +"lotbiniere": "Chaudière-Appalaches",
460 +"louiseville": "Mauricie",
461 +"low": "Outaouais",
462 +"lyster": "Centre-du-Québec",
463 +"macamic": "Abitibi-Témiscamingue",
464 +"maddington falls": "Centre-du-Québec",
465 +"magog": "Estrie",
466 +"malartic": "Abitibi-Témiscamingue",
467 +"maliotenam": "Côte-Nord",
468 +"manawan": "Lanaudière",
469 +"mandeville": "Lanaudière",
470 +"maniwaki": "Outaouais",
471 +"manseau": "Centre-du-Québec",
472 +"mansfield-et-pontefract": "Outaouais",
473 +"maria": "Gaspésie–Îles-de-la-Madeleine",
474 +"maricourt": "Estrie",
475 +"marieville": "Montérégie",
476 +"marsoui": "Gaspésie–Îles-de-la-Madeleine",
477 +"marston": "Estrie",
478 +"martinville": "Estrie",
479 +"mascouche": "Lanaudière",
480 +"mashteuiatsh": "Saguenay–Lac-Saint-Jean",
481 +"maskinonge": "Mauricie",
482 +"massueville": "Montérégie",
483 +"matagami": "Nord-du-Québec",
484 +"matane": "Bas-Saint-Laurent",
485 +"matapedia": "Gaspésie–Îles-de-la-Madeleine",
486 +"matchi-manitou": "Abitibi-Témiscamingue",
487 +"matimekosh": "Côte-Nord",
488 +"mayo": "Outaouais",
489 +"mcmasterville": "Montérégie",
490 +"melbourne": "Estrie",
491 +"mercier": "Montérégie",
492 +"messines": "Outaouais",
493 +"metabetchouan--lac-a-la-croix": "Saguenay–Lac-Saint-Jean",
494 +"metis-sur-mer": "Bas-Saint-Laurent",
495 +"milan": "Estrie",
496 +"mille-isles": "Laurentides",
497 +"mingan": "Côte-Nord",
498 +"mirabel": "Laurentides",
499 +"mistissini": "Nord-du-Québec",
500 +"moffet": "Abitibi-Témiscamingue",
501 +"mont-albert": "Gaspésie–Îles-de-la-Madeleine",
502 +"mont-alexandre": "Gaspésie–Îles-de-la-Madeleine",
503 +"mont-apica": "Saguenay–Lac-Saint-Jean",
504 +"mont-blanc": "Laurentides",
505 +"mont-carmel": "Bas-Saint-Laurent",
506 +"mont-elie": "Capitale-Nationale",
507 +"mont-joli": "Bas-Saint-Laurent",
508 +"mont-laurier": "Laurentides",
509 +"mont-royal": "Montréal",
510 +"mont-saint-gregoire": "Montérégie",
511 +"mont-saint-hilaire": "Montérégie",
512 +"mont-saint-michel": "Laurentides",
513 +"mont-saint-pierre": "Gaspésie–Îles-de-la-Madeleine",
514 +"mont-tremblant": "Laurentides",
515 +"mont-valin": "Saguenay–Lac-Saint-Jean",
516 +"montcalm": "Laurentides",
517 +"montcerf-lytton": "Outaouais",
518 +"montebello": "Outaouais",
519 +"montmagny": "Chaudière-Appalaches",
520 +"montpellier": "Outaouais",
521 +"montreal": "Montréal",
522 +"montreal-est": "Montréal",
523 +"montreal-nord": "Montréal",
524 +"montreal-ouest": "Montréal",
525 +"morin-heights": "Laurentides",
526 +"mulgrave-et-derry": "Outaouais",
527 +"murdochville": "Gaspésie–Îles-de-la-Madeleine",
528 +"namur": "Outaouais",
529 +"nantes": "Estrie",
530 +"napierville": "Montérégie",
531 +"natashquan": "Côte-Nord",
532 +"nedelec": "Abitibi-Témiscamingue",
533 +"nemaska": "Nord-du-Québec",
534 +"neuville": "Capitale-Nationale",
535 +"new carlisle": "Gaspésie–Îles-de-la-Madeleine",
536 +"new richmond": "Gaspésie–Îles-de-la-Madeleine",
537 +"newport": "Estrie",
538 +"nicolet": "Centre-du-Québec",
539 +"nominingue": "Laurentides",
540 +"normandin": "Saguenay–Lac-Saint-Jean",
541 +"normetal": "Abitibi-Témiscamingue",
542 +"north hatley": "Estrie",
543 +"notre-dame-auxiliatrice-de-buckland": "Chaudière-Appalaches",
544 +"notre-dame-de-bonsecours": "Outaouais",
545 +"notre-dame-de-ham": "Centre-du-Québec",
546 +"notre-dame-de-l'ile-perrot": "Montérégie",
547 +"notre-dame-de-la-merci": "Lanaudière",
548 +"notre-dame-de-la-paix": "Outaouais",
549 +"notre-dame-de-la-salette": "Outaouais",
550 +"notre-dame-de-lorette": "Saguenay–Lac-Saint-Jean",
551 +"notre-dame-de-lourdes": "Centre-du-Québec",
552 +"notre-dame-de-montauban": "Mauricie",
553 +"notre-dame-de-pontmain": "Laurentides",
554 +"notre-dame-de-stanbridge": "Estrie",
555 +"notre-dame-des-anges": "Capitale-Nationale",
556 +"notre-dame-des-bois": "Estrie",
557 +"notre-dame-des-monts": "Capitale-Nationale",
558 +"notre-dame-des-neiges": "Bas-Saint-Laurent",
559 +"notre-dame-des-pins": "Chaudière-Appalaches",
560 +"notre-dame-des-prairies": "Lanaudière",
561 +"notre-dame-des-sept-douleurs": "Bas-Saint-Laurent",
562 +"notre-dame-du-bon-conseil": "Centre-du-Québec",
563 +"notre-dame-du-laus": "Laurentides",
564 +"notre-dame-du-mont-carmel": "Mauricie",
565 +"notre-dame-du-nord": "Abitibi-Témiscamingue",
566 +"notre-dame-du-portage": "Bas-Saint-Laurent",
567 +"notre-dame-du-rosaire": "Chaudière-Appalaches",
568 +"notre-dame-du-sacre-coeur-d'issoudun": "Chaudière-Appalaches",
569 +"nouvelle": "Gaspésie–Îles-de-la-Madeleine",
570 +"noyan": "Montérégie",
571 +"obedjiwan": "Mauricie",
572 +"odanak": "Centre-du-Québec",
573 +"ogden": "Estrie",
574 +"oka": "Laurentides",
575 +"orford": "Estrie",
576 +"ormstown": "Montérégie",
577 +"otter lake": "Outaouais",
578 +"otterburn park": "Montérégie",
579 +"ouje-bougoumou": "Nord-du-Québec",
580 +"outremont": "Montréal",
581 +"packington": "Bas-Saint-Laurent",
582 +"padoue": "Bas-Saint-Laurent",
583 +"palmarolle": "Abitibi-Témiscamingue",
584 +"papineauville": "Outaouais",
585 +"parisville": "Centre-du-Québec",
586 +"paspebiac": "Gaspésie–Îles-de-la-Madeleine",
587 +"passes-dangereuses": "Saguenay–Lac-Saint-Jean",
588 +"perce": "Gaspésie–Îles-de-la-Madeleine",
589 +"peribonka": "Saguenay–Lac-Saint-Jean",
590 +"pessamit": "Côte-Nord",
591 +"petit-lac-sainte-anne": "Bas-Saint-Laurent",
592 +"petit-mecatina": "Côte-Nord",
593 +"petit-saguenay": "Saguenay–Lac-Saint-Jean",
594 +"petite-riviere-saint-francois": "Capitale-Nationale",
595 +"petite-vallee": "Gaspésie–Îles-de-la-Madeleine",
596 +"picard": "Bas-Saint-Laurent",
597 +"piedmont": "Laurentides",
598 +"pierrefonds": "Montréal",
599 +"pierreville": "Centre-du-Québec",
600 +"pike river": "Estrie",
601 +"pikogan": "Abitibi-Témiscamingue",
602 +"pincourt": "Montérégie",
603 +"piopolis": "Estrie",
604 +"plaisance": "Outaouais",
605 +"plessisville": "Centre-du-Québec",
606 +"pohenegamook": "Bas-Saint-Laurent",
607 +"pointe-a-la-croix": "Gaspésie–Îles-de-la-Madeleine",
608 +"pointe-aux-outardes": "Côte-Nord",
609 +"pointe-aux-trembles": "Montréal",
610 +"pointe-calumet": "Laurentides",
611 +"pointe-claire": "Montréal",
612 +"pointe-des-cascades": "Montérégie",
613 +"pointe-fortune": "Montérégie",
614 +"pointe-lebel": "Côte-Nord",
615 +"pont-rouge": "Capitale-Nationale",
616 +"pontiac": "Outaouais",
617 +"port-cartier": "Côte-Nord",
618 +"port-daniel--gascons": "Gaspésie–Îles-de-la-Madeleine",
619 +"portage-du-fort": "Outaouais",
620 +"portneuf": "Capitale-Nationale",
621 +"portneuf-sur-mer": "Côte-Nord",
622 +"potton": "Estrie",
623 +"poularies": "Abitibi-Témiscamingue",
624 +"preissac": "Abitibi-Témiscamingue",
625 +"prevost": "Laurentides",
626 +"price": "Bas-Saint-Laurent",
627 +"princeville": "Centre-du-Québec",
628 +"puvirnituq": "Nord-du-Québec",
629 +"quaqtaq": "Nord-du-Québec",
630 +"quebec": "Capitale-Nationale",
631 +"quebec city": "Capitale-Nationale",
632 +"racine": "Estrie",
633 +"ragueneau": "Côte-Nord",
634 +"rapide-danseur": "Abitibi-Témiscamingue",
635 +"rapides-des-joachims": "Outaouais",
636 +"rawdon": "Lanaudière",
637 +"remigny": "Abitibi-Témiscamingue",
638 +"repentigny": "Lanaudière",
639 +"reservoir-dozois": "Abitibi-Témiscamingue",
640 +"richelieu": "Montérégie",
641 +"richmond": "Estrie",
642 +"rigaud": "Montérégie",
643 +"rimouski": "Bas-Saint-Laurent",
644 +"ripon": "Outaouais",
645 +"ristigouche-sud-est": "Gaspésie–Îles-de-la-Madeleine",
646 +"riviere-a-claude": "Gaspésie–Îles-de-la-Madeleine",
647 +"riviere-a-pierre": "Capitale-Nationale",
648 +"riviere-au-tonnerre": "Côte-Nord",
649 +"riviere-aux-outardes": "Côte-Nord",
650 +"riviere-beaudette": "Montérégie",
651 +"riviere-bleue": "Bas-Saint-Laurent",
652 +"riviere-bonaventure": "Gaspésie–Îles-de-la-Madeleine",
653 +"riviere-bonjour": "Bas-Saint-Laurent",
654 +"riviere-de-la-savane": "Mauricie",
655 +"riviere-des-prairies": "Montréal",
656 +"riviere-du-loup": "Bas-Saint-Laurent",
657 +"riviere-eternite": "Saguenay–Lac-Saint-Jean",
658 +"riviere-heva": "Abitibi-Témiscamingue",
659 +"riviere-koksoak": "Nord-du-Québec",
660 +"riviere-mistassini": "Saguenay–Lac-Saint-Jean",
661 +"riviere-mouchalagane": "Côte-Nord",
662 +"riviere-nipissis": "Côte-Nord",
663 +"riviere-nouvelle": "Gaspésie–Îles-de-la-Madeleine",
664 +"riviere-ojima": "Abitibi-Témiscamingue",
665 +"riviere-ouelle": "Bas-Saint-Laurent",
666 +"riviere-patapedia-est": "Bas-Saint-Laurent",
667 +"riviere-rouge": "Laurentides",
668 +"riviere-saint-jean": "Gaspésie–Îles-de-la-Madeleine",
669 +"riviere-vaseuse": "Bas-Saint-Laurent",
670 +"roberval": "Saguenay–Lac-Saint-Jean",
671 +"roquemaure": "Abitibi-Témiscamingue",
672 +"rosemere": "Laurentides",
673 +"rosemont": "Montréal",
674 +"rougemont": "Montérégie",
675 +"routhierville": "Bas-Saint-Laurent",
676 +"rouyn-noranda": "Abitibi-Témiscamingue",
677 +"roxboro": "Montréal",
678 +"roxton": "Montérégie",
679 +"roxton falls": "Montérégie",
680 +"roxton pond": "Estrie",
681 +"ruisseau-des-mineurs": "Bas-Saint-Laurent",
682 +"ruisseau-ferguson": "Gaspésie–Îles-de-la-Madeleine",
683 +"sacre-coeur": "Côte-Nord",
684 +"sacre-coeur-de-jesus": "Chaudière-Appalaches",
685 +"sagard": "Capitale-Nationale",
686 +"saguenay": "Saguenay–Lac-Saint-Jean",
687 +"saint-adalbert": "Chaudière-Appalaches",
688 +"saint-adelme": "Bas-Saint-Laurent",
689 +"saint-adelphe": "Mauricie",
690 +"saint-adolphe-d'howard": "Laurentides",
691 +"saint-adrien": "Estrie",
692 +"saint-adrien-d'irlande": "Chaudière-Appalaches",
693 +"saint-agapit": "Chaudière-Appalaches",
694 +"saint-aime": "Montérégie",
695 +"saint-aime-des-lacs": "Capitale-Nationale",
696 +"saint-aime-du-lac-des-iles": "Laurentides",
697 +"saint-alban": "Capitale-Nationale",
698 +"saint-albert": "Centre-du-Québec",
699 +"saint-alexandre": "Montérégie",
700 +"saint-alexandre-de-kamouraska": "Bas-Saint-Laurent",
701 +"saint-alexandre-des-lacs": "Bas-Saint-Laurent",
702 +"saint-alexis": "Lanaudière",
703 +"saint-alexis-de-matapedia": "Gaspésie–Îles-de-la-Madeleine",
704 +"saint-alexis-des-monts": "Mauricie",
705 +"saint-alfred": "Chaudière-Appalaches",
706 +"saint-alphonse": "Gaspésie–Îles-de-la-Madeleine",
707 +"saint-alphonse-de-granby": "Estrie",
708 +"saint-alphonse-rodriguez": "Lanaudière",
709 +"saint-amable": "Montérégie",
710 +"saint-ambroise": "Saguenay–Lac-Saint-Jean",
711 +"saint-ambroise-de-kildare": "Lanaudière",
712 +"saint-anaclet-de-lessard": "Bas-Saint-Laurent",
713 +"saint-andre-avellin": "Outaouais",
714 +"saint-andre-d'argenteuil": "Laurentides",
715 +"saint-andre-de-kamouraska": "Bas-Saint-Laurent",
716 +"saint-andre-de-restigouche": "Gaspésie–Îles-de-la-Madeleine",
717 +"saint-andre-du-lac-saint-jean": "Saguenay–Lac-Saint-Jean",
718 +"saint-anicet": "Montérégie",
719 +"saint-anselme": "Chaudière-Appalaches",
720 +"saint-antoine-de-l'isle-aux-grues": "Chaudière-Appalaches",
721 +"saint-antoine-de-tilly": "Chaudière-Appalaches",
722 +"saint-antoine-sur-richelieu": "Montérégie",
723 +"saint-antonin": "Bas-Saint-Laurent",
724 +"saint-apollinaire": "Chaudière-Appalaches",
725 +"saint-armand": "Estrie",
726 +"saint-arsene": "Bas-Saint-Laurent",
727 +"saint-athanase": "Bas-Saint-Laurent",
728 +"saint-aubert": "Chaudière-Appalaches",
729 +"saint-augustin": "Saguenay–Lac-Saint-Jean",
730 +"saint-augustin-de-desmaures": "Capitale-Nationale",
731 +"saint-augustin-de-woburn": "Estrie",
732 +"saint-barnabe": "Mauricie",
733 +"saint-barnabe-sud": "Montérégie",
734 +"saint-barthelemy": "Lanaudière",
735 +"saint-basile": "Capitale-Nationale",
736 +"saint-basile-le-grand": "Montérégie",
737 +"saint-benjamin": "Chaudière-Appalaches",
738 +"saint-benoit-du-lac": "Estrie",
739 +"saint-benoit-labre": "Chaudière-Appalaches",
740 +"saint-bernard": "Chaudière-Appalaches",
741 +"saint-bernard-de-lacolle": "Montérégie",
742 +"saint-bernard-de-michaudville": "Montérégie",
743 +"saint-blaise-sur-richelieu": "Montérégie",
744 +"saint-bonaventure": "Centre-du-Québec",
745 +"saint-boniface": "Mauricie",
746 +"saint-bruno-de-guigues": "Abitibi-Témiscamingue",
747 +"saint-bruno-de-kamouraska": "Bas-Saint-Laurent",
748 +"saint-bruno-de-montarville": "Montérégie",
749 +"saint-calixte": "Lanaudière",
750 +"saint-camille": "Estrie",
751 +"saint-camille-de-lellis": "Chaudière-Appalaches",
752 +"saint-casimir": "Capitale-Nationale",
753 +"saint-celestin": "Centre-du-Québec",
754 +"saint-cesaire": "Montérégie",
755 +"saint-charles-borromee": "Lanaudière",
756 +"saint-charles-de-bellechasse": "Chaudière-Appalaches",
757 +"saint-charles-de-bourget": "Saguenay–Lac-Saint-Jean",
758 +"saint-charles-garnier": "Bas-Saint-Laurent",
759 +"saint-charles-sur-richelieu": "Montérégie",
760 +"saint-christophe-d'arthabaska": "Centre-du-Québec",
761 +"saint-chrysostome": "Montérégie",
762 +"saint-claude": "Estrie",
763 +"saint-clement": "Bas-Saint-Laurent",
764 +"saint-cleophas": "Bas-Saint-Laurent",
765 +"saint-cleophas-de-brandon": "Lanaudière",
766 +"saint-clet": "Montérégie",
767 +"saint-colomban": "Laurentides",
768 +"saint-come": "Lanaudière",
769 +"saint-come--liniere": "Chaudière-Appalaches",
770 +"saint-constant": "Montérégie",
771 +"saint-cuthbert": "Lanaudière",
772 +"saint-cyprien": "Chaudière-Appalaches",
773 +"saint-cyprien-de-napierville": "Montérégie",
774 +"saint-cyrille-de-lessard": "Chaudière-Appalaches",
775 +"saint-cyrille-de-wendover": "Centre-du-Québec",
776 +"saint-damase": "Montérégie",
777 +"saint-damase-de-l'islet": "Chaudière-Appalaches",
778 +"saint-damien": "Lanaudière",
779 +"saint-damien-de-buckland": "Chaudière-Appalaches",
780 +"saint-david": "Montérégie",
781 +"saint-david-de-falardeau": "Saguenay–Lac-Saint-Jean",
782 +"saint-denis-de la bouteillerie": "Bas-Saint-Laurent",
783 +"saint-denis-de-brompton": "Estrie",
784 +"saint-denis-sur-richelieu": "Montérégie",
785 +"saint-didace": "Lanaudière",
786 +"saint-dominique": "Montérégie",
787 +"saint-dominique-du-rosaire": "Abitibi-Témiscamingue",
788 +"saint-donat": "Bas-Saint-Laurent",
789 +"saint-edmond-de-grantham": "Centre-du-Québec",
790 +"saint-edmond-les-plaines": "Saguenay–Lac-Saint-Jean",
791 +"saint-edouard": "Montérégie",
792 +"saint-edouard-de-fabre": "Abitibi-Témiscamingue",
793 +"saint-edouard-de-lotbiniere": "Chaudière-Appalaches",
794 +"saint-edouard-de-maskinonge": "Mauricie",
795 +"saint-elie-de-caxton": "Mauricie",
796 +"saint-eloi": "Bas-Saint-Laurent",
797 +"saint-elphege": "Centre-du-Québec",
798 +"saint-elzear": "Chaudière-Appalaches",
799 +"saint-elzear-de-temiscouata": "Bas-Saint-Laurent",
800 +"saint-emile-de-suffolk": "Outaouais",
801 +"saint-ephrem-de-beauce": "Chaudière-Appalaches",
802 +"saint-epiphane": "Bas-Saint-Laurent",
803 +"saint-esprit": "Lanaudière",
804 +"saint-etienne-de-beauharnois": "Montérégie",
805 +"saint-etienne-de-bolton": "Estrie",
806 +"saint-etienne-des-gres": "Mauricie",
807 +"saint-eugene": "Centre-du-Québec",
808 +"saint-eugene-d'argentenay": "Saguenay–Lac-Saint-Jean",
809 +"saint-eugene-de-guigues": "Abitibi-Témiscamingue",
810 +"saint-eugene-de-ladriere": "Bas-Saint-Laurent",
811 +"saint-eusebe": "Bas-Saint-Laurent",
812 +"saint-eustache": "Laurentides",
813 +"saint-fabien": "Bas-Saint-Laurent",
814 +"saint-fabien-de-panet": "Chaudière-Appalaches",
815 +"saint-felicien": "Saguenay–Lac-Saint-Jean",
816 +"saint-felix-d'otis": "Saguenay–Lac-Saint-Jean",
817 +"saint-felix-de-kingsey": "Centre-du-Québec",
818 +"saint-felix-de-valois": "Lanaudière",
819 +"saint-ferdinand": "Centre-du-Québec",
820 +"saint-ferreol-les-neiges": "Capitale-Nationale",
821 +"saint-flavien": "Chaudière-Appalaches",
822 +"saint-fortunat": "Chaudière-Appalaches",
823 +"saint-francois-d'assise": "Gaspésie–Îles-de-la-Madeleine",
824 +"saint-francois-de-l'ile-d'orleans": "Capitale-Nationale",
825 +"saint-francois-de-la-riviere-du-sud": "Chaudière-Appalaches",
826 +"saint-francois-de-sales": "Saguenay–Lac-Saint-Jean",
827 +"saint-francois-du-lac": "Centre-du-Québec",
828 +"saint-francois-xavier-de-brompton": "Estrie",
829 +"saint-francois-xavier-de-viger": "Bas-Saint-Laurent",
830 +"saint-frederic": "Chaudière-Appalaches",
831 +"saint-fulgence": "Saguenay–Lac-Saint-Jean",
832 +"saint-gabriel": "Lanaudière",
833 +"saint-gabriel-de-brandon": "Lanaudière",
834 +"saint-gabriel-de-rimouski": "Bas-Saint-Laurent",
835 +"saint-gabriel-de-valcartier": "Capitale-Nationale",
836 +"saint-gabriel-lalemant": "Bas-Saint-Laurent",
837 +"saint-gedeon": "Saguenay–Lac-Saint-Jean",
838 +"saint-gedeon-de-beauce": "Chaudière-Appalaches",
839 +"saint-georges": "Chaudière-Appalaches",
840 +"saint-georges-de-windsor": "Estrie",
841 +"saint-gerard-majella": "Montérégie",
842 +"saint-germain-de-grantham": "Centre-du-Québec",
843 +"saint-germain-de-kamouraska": "Bas-Saint-Laurent",
844 +"saint-gervais": "Chaudière-Appalaches",
845 +"saint-gilbert": "Capitale-Nationale",
846 +"saint-gilles": "Chaudière-Appalaches",
847 +"saint-godefroi": "Gaspésie–Îles-de-la-Madeleine",
848 +"saint-guillaume": "Centre-du-Québec",
849 +"saint-guillaume-nord": "Lanaudière",
850 +"saint-henri": "Chaudière-Appalaches",
851 +"saint-henri-de-taillon": "Saguenay–Lac-Saint-Jean",
852 +"saint-hermenegilde": "Estrie",
853 +"saint-hilaire-de-dorset": "Chaudière-Appalaches",
854 +"saint-hilarion": "Capitale-Nationale",
855 +"saint-hippolyte": "Laurentides",
856 +"saint-honore": "Saguenay–Lac-Saint-Jean",
857 +"saint-honore-de-shenley": "Chaudière-Appalaches",
858 +"saint-honore-de-temiscouata": "Bas-Saint-Laurent",
859 +"saint-hubert": "Montérégie",
860 +"saint-hubert-de-riviere-du-loup": "Bas-Saint-Laurent",
861 +"saint-hugues": "Montérégie",
862 +"saint-hyacinthe": "Montérégie",
863 +"saint-ignace-de-loyola": "Lanaudière",
864 +"saint-ignace-de-stanbridge": "Estrie",
865 +"saint-irenee": "Capitale-Nationale",
866 +"saint-isidore": "Montérégie",
867 +"saint-isidore-de-clifton": "Estrie",
868 +"saint-jacques": "Lanaudière",
869 +"saint-jacques-de-leeds": "Chaudière-Appalaches",
870 +"saint-jacques-le-majeur-de-wolfestown": "Chaudière-Appalaches",
871 +"saint-jacques-le-mineur": "Montérégie",
872 +"saint-janvier-de-joly": "Chaudière-Appalaches",
873 +"saint-jean-baptiste": "Montérégie",
874 +"saint-jean-de-brebeuf": "Chaudière-Appalaches",
875 +"saint-jean-de-cherbourg": "Bas-Saint-Laurent",
876 +"saint-jean-de-dieu": "Bas-Saint-Laurent",
877 +"saint-jean-de-l'ile-d'orleans": "Capitale-Nationale",
878 +"saint-jean-de-la-lande": "Bas-Saint-Laurent",
879 +"saint-jean-de-matha": "Lanaudière",
880 +"saint-jean-port-joli": "Chaudière-Appalaches",
881 +"saint-jean-sur-richelieu": "Montérégie",
882 +"saint-jerome": "Laurentides",
883 +"saint-joachim": "Capitale-Nationale",
884 +"saint-joachim-de-shefford": "Estrie",
885 +"saint-joseph-de-beauce": "Chaudière-Appalaches",
886 +"saint-joseph-de-coleraine": "Chaudière-Appalaches",
887 +"saint-joseph-de-kamouraska": "Bas-Saint-Laurent",
888 +"saint-joseph-de-lepage": "Bas-Saint-Laurent",
889 +"saint-joseph-de-sorel": "Montérégie",
890 +"saint-joseph-des-erables": "Chaudière-Appalaches",
891 +"saint-joseph-du-lac": "Laurentides",
892 +"saint-jude": "Montérégie",
893 +"saint-jules": "Chaudière-Appalaches",
894 +"saint-julien": "Chaudière-Appalaches",
895 +"saint-just-de-bretenieres": "Chaudière-Appalaches",
896 +"saint-juste-du-lac": "Bas-Saint-Laurent",
897 +"saint-justin": "Mauricie",
898 +"saint-lambert": "Montérégie",
899 +"saint-lambert-de-lauzon": "Chaudière-Appalaches",
900 +"saint-laurent": "Montréal",
901 +"saint-laurent-de-l'ile-d'orleans": "Capitale-Nationale",
902 +"saint-lazare": "Montérégie",
903 +"saint-lazare-de-bellechasse": "Chaudière-Appalaches",
904 +"saint-leandre": "Bas-Saint-Laurent",
905 +"saint-leon-de-standon": "Chaudière-Appalaches",
906 +"saint-leon-le-grand": "Mauricie",
907 +"saint-leonard": "Montréal",
908 +"saint-leonard-d'aston": "Centre-du-Québec",
909 +"saint-leonard-de-portneuf": "Capitale-Nationale",
910 +"saint-liboire": "Montérégie",
911 +"saint-liguori": "Lanaudière",
912 +"saint-lin--laurentides": "Lanaudière",
913 +"saint-louis": "Montérégie",
914 +"saint-louis-de-blandford": "Centre-du-Québec",
915 +"saint-louis-de-gonzague": "Montérégie",
916 +"saint-louis-de-gonzague-du-cap-tourmente": "Capitale-Nationale",
917 +"saint-louis-du-ha! ha!": "Bas-Saint-Laurent",
918 +"saint-luc-de-bellechasse": "Chaudière-Appalaches",
919 +"saint-luc-de-vincennes": "Mauricie",
920 +"saint-lucien": "Centre-du-Québec",
921 +"saint-ludger": "Estrie",
922 +"saint-ludger-de-milot": "Saguenay–Lac-Saint-Jean",
923 +"saint-magloire": "Chaudière-Appalaches",
924 +"saint-majorique-de-grantham": "Centre-du-Québec",
925 +"saint-malachie": "Chaudière-Appalaches",
926 +"saint-malo": "Estrie",
927 +"saint-marc-de-figuery": "Abitibi-Témiscamingue",
928 +"saint-marc-des-carrieres": "Capitale-Nationale",
929 +"saint-marc-du-lac-long": "Bas-Saint-Laurent",
930 +"saint-marc-sur-richelieu": "Montérégie",
931 +"saint-marcel": "Chaudière-Appalaches",
932 +"saint-marcel-de-richelieu": "Montérégie",
933 +"saint-marcellin": "Bas-Saint-Laurent",
934 +"saint-martin": "Chaudière-Appalaches",
935 +"saint-mathias-sur-richelieu": "Montérégie",
936 +"saint-mathieu": "Montérégie",
937 +"saint-mathieu-d'harricana": "Abitibi-Témiscamingue",
938 +"saint-mathieu-de-beloeil": "Montérégie",
939 +"saint-mathieu-de-rioux": "Bas-Saint-Laurent",
940 +"saint-mathieu-du-parc": "Mauricie",
941 +"saint-maurice": "Mauricie",
942 +"saint-maxime-du-mont-louis": "Gaspésie–Îles-de-la-Madeleine",
943 +"saint-medard": "Bas-Saint-Laurent",
944 +"saint-michel": "Montérégie",
945 +"saint-michel-de-bellechasse": "Chaudière-Appalaches",
946 +"saint-michel-des-saints": "Lanaudière",
947 +"saint-michel-du-squatec": "Bas-Saint-Laurent",
948 +"saint-modeste": "Bas-Saint-Laurent",
949 +"saint-moise": "Bas-Saint-Laurent",
950 +"saint-narcisse": "Mauricie",
951 +"saint-narcisse-de-beaurivage": "Chaudière-Appalaches",
952 +"saint-narcisse-de-rimouski": "Bas-Saint-Laurent",
953 +"saint-nazaire": "Saguenay–Lac-Saint-Jean",
954 +"saint-nazaire-d'acton": "Montérégie",
955 +"saint-nazaire-de-dorchester": "Chaudière-Appalaches",
956 +"saint-neree-de-bellechasse": "Chaudière-Appalaches",
957 +"saint-nicolas": "Chaudière-Appalaches",
958 +"saint-noel": "Bas-Saint-Laurent",
959 +"saint-norbert": "Lanaudière",
960 +"saint-norbert-d'arthabaska": "Centre-du-Québec",
961 +"saint-octave-de-metis": "Bas-Saint-Laurent",
962 +"saint-odilon-de-cranbourne": "Chaudière-Appalaches",
963 +"saint-omer": "Chaudière-Appalaches",
964 +"saint-ours": "Montérégie",
965 +"saint-pacome": "Bas-Saint-Laurent",
966 +"saint-pamphile": "Chaudière-Appalaches",
967 +"saint-pascal": "Bas-Saint-Laurent",
968 +"saint-patrice-de-beaurivage": "Chaudière-Appalaches",
969 +"saint-patrice-de-sherrington": "Montérégie",
970 +"saint-paul": "Lanaudière",
971 +"saint-paul-d'abbotsford": "Montérégie",
972 +"saint-paul-de-l'ile-aux-noix": "Montérégie",
973 +"saint-paul-de-la-croix": "Bas-Saint-Laurent",
974 +"saint-paul-de-montminy": "Chaudière-Appalaches",
975 +"saint-paulin": "Mauricie",
976 +"saint-philemon": "Chaudière-Appalaches",
977 +"saint-philibert": "Chaudière-Appalaches",
978 +"saint-philippe": "Montérégie",
979 +"saint-philippe-de-neri": "Bas-Saint-Laurent",
980 +"saint-pie": "Montérégie",
981 +"saint-pie-de-guire": "Centre-du-Québec",
982 +"saint-pierre": "Lanaudière",
983 +"saint-pierre-baptiste": "Centre-du-Québec",
984 +"saint-pierre-de-broughton": "Chaudière-Appalaches",
985 +"saint-pierre-de-l'ile-d'orleans": "Capitale-Nationale",
986 +"saint-pierre-de-la-riviere-du-sud": "Chaudière-Appalaches",
987 +"saint-pierre-de-lamy": "Bas-Saint-Laurent",
988 +"saint-pierre-les-becquets": "Centre-du-Québec",
989 +"saint-placide": "Laurentides",
990 +"saint-polycarpe": "Montérégie",
991 +"saint-prime": "Saguenay–Lac-Saint-Jean",
992 +"saint-prosper": "Chaudière-Appalaches",
993 +"saint-prosper-de-champlain": "Mauricie",
994 +"saint-raphael": "Chaudière-Appalaches",
995 +"saint-raymond": "Capitale-Nationale",
996 +"saint-remi": "Montérégie",
997 +"saint-remi-de-tingwick": "Centre-du-Québec",
998 +"saint-rene": "Chaudière-Appalaches",
999 +"saint-rene-de-matane": "Bas-Saint-Laurent",
1000 +"saint-robert": "Montérégie",
1001 +"saint-robert-bellarmin": "Estrie",
1002 +"saint-roch-de-l'achigan": "Lanaudière",
1003 +"saint-roch-de-mekinac": "Mauricie",
1004 +"saint-roch-de-richelieu": "Montérégie",
1005 +"saint-roch-des-aulnaies": "Chaudière-Appalaches",
1006 +"saint-roch-ouest": "Lanaudière",
1007 +"saint-romain": "Estrie",
1008 +"saint-romuald": "Chaudière-Appalaches",
1009 +"saint-rosaire": "Centre-du-Québec",
1010 +"saint-samuel": "Centre-du-Québec",
1011 +"saint-sauveur": "Laurentides",
1012 +"saint-sebastien": "Estrie",
1013 +"saint-severe": "Mauricie",
1014 +"saint-severin": "Mauricie",
1015 +"saint-simeon": "Gaspésie–Îles-de-la-Madeleine",
1016 +"saint-simon": "Montérégie",
1017 +"saint-simon-de-rimouski": "Bas-Saint-Laurent",
1018 +"saint-simon-les-mines": "Chaudière-Appalaches",
1019 +"saint-sixte": "Outaouais",
1020 +"saint-stanislas": "Mauricie",
1021 +"saint-stanislas-de-kostka": "Montérégie",
1022 +"saint-sulpice": "Lanaudière",
1023 +"saint-sylvere": "Centre-du-Québec",
1024 +"saint-sylvestre": "Chaudière-Appalaches",
1025 +"saint-telesphore": "Montérégie",
1026 +"saint-tharcisius": "Bas-Saint-Laurent",
1027 +"saint-theodore-d'acton": "Montérégie",
1028 +"saint-theophile": "Chaudière-Appalaches",
1029 +"saint-thomas": "Lanaudière",
1030 +"saint-thomas-didyme": "Saguenay–Lac-Saint-Jean",
1031 +"saint-thuribe": "Capitale-Nationale",
1032 +"saint-tite": "Mauricie",
1033 +"saint-tite-des-caps": "Capitale-Nationale",
1034 +"saint-ubalde": "Capitale-Nationale",
1035 +"saint-ulric": "Bas-Saint-Laurent",
1036 +"saint-urbain": "Capitale-Nationale",
1037 +"saint-urbain-premier": "Montérégie",
1038 +"saint-valentin": "Montérégie",
1039 +"saint-valere": "Centre-du-Québec",
1040 +"saint-valerien": "Bas-Saint-Laurent",
1041 +"saint-valerien-de-milton": "Montérégie",
1042 +"saint-vallier": "Chaudière-Appalaches",
1043 +"saint-venant-de-paquette": "Estrie",
1044 +"saint-vianney": "Bas-Saint-Laurent",
1045 +"saint-victor": "Chaudière-Appalaches",
1046 +"saint-wenceslas": "Centre-du-Québec",
1047 +"saint-zacharie": "Chaudière-Appalaches",
1048 +"saint-zenon": "Lanaudière",
1049 +"saint-zenon-du-lac-humqui": "Bas-Saint-Laurent",
1050 +"saint-zephirin-de-courval": "Centre-du-Québec",
1051 +"saint-zotique": "Montérégie",
1052 +"sainte-adele": "Laurentides",
1053 +"sainte-agathe-de-lotbiniere": "Chaudière-Appalaches",
1054 +"sainte-agathe-des-monts": "Laurentides",
1055 +"sainte-angele-de-merici": "Bas-Saint-Laurent",
1056 +"sainte-angele-de-monnoir": "Montérégie",
1057 +"sainte-angele-de-premont": "Mauricie",
1058 +"sainte-anne-de-beaupre": "Capitale-Nationale",
1059 +"sainte-anne-de-bellevue": "Montréal",
1060 +"sainte-anne-de-la-perade": "Mauricie",
1061 +"sainte-anne-de-la-rochelle": "Estrie",
1062 +"sainte-anne-de-sabrevois": "Montérégie",
1063 +"sainte-anne-de-sorel": "Montérégie",
1064 +"sainte-anne-des-lacs": "Laurentides",
1065 +"sainte-anne-des-monts": "Gaspésie–Îles-de-la-Madeleine",
1066 +"sainte-anne-des-plaines": "Laurentides",
1067 +"sainte-anne-du-lac": "Laurentides",
1068 +"sainte-apolline-de-patton": "Chaudière-Appalaches",
1069 +"sainte-aurelie": "Chaudière-Appalaches",
1070 +"sainte-barbe": "Montérégie",
1071 +"sainte-beatrix": "Lanaudière",
1072 +"sainte-brigide-d'iberville": "Montérégie",
1073 +"sainte-brigitte-de-laval": "Capitale-Nationale",
1074 +"sainte-brigitte-des-saults": "Centre-du-Québec",
1075 +"sainte-catherine": "Montérégie",
1076 +"sainte-catherine-de-hatley": "Estrie",
1077 +"sainte-catherine-de-la-jacques-cartier": "Capitale-Nationale",
1078 +"sainte-cecile-de-levrard": "Centre-du-Québec",
1079 +"sainte-cecile-de-milton": "Estrie",
1080 +"sainte-cecile-de-whitton": "Estrie",
1081 +"sainte-christine": "Montérégie",
1082 +"sainte-christine-d'auvergne": "Capitale-Nationale",
1083 +"sainte-claire": "Chaudière-Appalaches",
1084 +"sainte-clotilde": "Montérégie",
1085 +"sainte-clotilde-de-beauce": "Chaudière-Appalaches",
1086 +"sainte-clotilde-de-horton": "Centre-du-Québec",
1087 +"sainte-croix": "Chaudière-Appalaches",
1088 +"sainte-edwidge-de-clifton": "Estrie",
1089 +"sainte-elisabeth": "Lanaudière",
1090 +"sainte-elisabeth-de-proulx": "Saguenay–Lac-Saint-Jean",
1091 +"sainte-elizabeth-de-warwick": "Centre-du-Québec",
1092 +"sainte-emelie-de-l'energie": "Lanaudière",
1093 +"sainte-eulalie": "Centre-du-Québec",
1094 +"sainte-euphemie-sur-riviere-du-sud": "Chaudière-Appalaches",
1095 +"sainte-famille-de-l'ile-d'orleans": "Capitale-Nationale",
1096 +"sainte-felicite": "Bas-Saint-Laurent",
1097 +"sainte-flavie": "Bas-Saint-Laurent",
1098 +"sainte-florence": "Bas-Saint-Laurent",
1099 +"sainte-foy": "Capitale-Nationale",
1100 +"sainte-francoise": "Bas-Saint-Laurent",
1101 +"sainte-genevieve-de-batiscan": "Mauricie",
1102 +"sainte-genevieve-de-berthier": "Lanaudière",
1103 +"sainte-germaine-boule": "Abitibi-Témiscamingue",
1104 +"sainte-gertrude-manneville": "Abitibi-Témiscamingue",
1105 +"sainte-hedwidge": "Saguenay–Lac-Saint-Jean",
1106 +"sainte-helene-de-bagot": "Montérégie",
1107 +"sainte-helene-de-chester": "Centre-du-Québec",
1108 +"sainte-helene-de-kamouraska": "Bas-Saint-Laurent",
1109 +"sainte-helene-de-mancebourg": "Abitibi-Témiscamingue",
1110 +"sainte-henedine": "Chaudière-Appalaches",
1111 +"sainte-irene": "Bas-Saint-Laurent",
1112 +"sainte-jeanne-d'arc": "Saguenay–Lac-Saint-Jean",
1113 +"sainte-jeanne-d'arc-de-la-mitis": "Bas-Saint-Laurent",
1114 +"sainte-julie": "Montérégie",
1115 +"sainte-julienne": "Lanaudière",
1116 +"sainte-justine": "Chaudière-Appalaches",
1117 +"sainte-justine-de-newton": "Montérégie",
1118 +"sainte-louise": "Chaudière-Appalaches",
1119 +"sainte-luce": "Bas-Saint-Laurent",
1120 +"sainte-lucie-de-beauregard": "Chaudière-Appalaches",
1121 +"sainte-lucie-des-laurentides": "Laurentides",
1122 +"sainte-madeleine": "Montérégie",
1123 +"sainte-madeleine-de-la-riviere-madeleine": "Gaspésie–Îles-de-la-Madeleine",
1124 +"sainte-marcelline-de-kildare": "Lanaudière",
1125 +"sainte-marguerite": "Chaudière-Appalaches",
1126 +"sainte-marguerite-du-lac-masson": "Laurentides",
1127 +"sainte-marguerite-marie": "Bas-Saint-Laurent",
1128 +"sainte-marie": "Chaudière-Appalaches",
1129 +"sainte-marie-de-blandford": "Centre-du-Québec",
1130 +"sainte-marie-madeleine": "Montérégie",
1131 +"sainte-marie-salome": "Lanaudière",
1132 +"sainte-marthe": "Montérégie",
1133 +"sainte-marthe-sur-le-lac": "Laurentides",
1134 +"sainte-martine": "Montérégie",
1135 +"sainte-melanie": "Lanaudière",
1136 +"sainte-monique": "Saguenay–Lac-Saint-Jean",
1137 +"sainte-paule": "Bas-Saint-Laurent",
1138 +"sainte-perpetue": "Chaudière-Appalaches",
1139 +"sainte-petronille": "Capitale-Nationale",
1140 +"sainte-praxede": "Chaudière-Appalaches",
1141 +"sainte-rita": "Bas-Saint-Laurent",
1142 +"sainte-rose-de-watford": "Chaudière-Appalaches",
1143 +"sainte-rose-du-nord": "Saguenay–Lac-Saint-Jean",
1144 +"sainte-sabine": "Estrie",
1145 +"sainte-seraphine": "Centre-du-Québec",
1146 +"sainte-sophie": "Laurentides",
1147 +"sainte-sophie-d'halifax": "Centre-du-Québec",
1148 +"sainte-sophie-de-levrard": "Centre-du-Québec",
1149 +"sainte-thecle": "Mauricie",
1150 +"sainte-therese": "Laurentides",
1151 +"sainte-therese-de-gaspe": "Gaspésie–Îles-de-la-Madeleine",
1152 +"sainte-therese-de-la-gatineau": "Outaouais",
1153 +"sainte-ursule": "Mauricie",
1154 +"sainte-victoire-de-sorel": "Montérégie",
1155 +"saints-anges": "Chaudière-Appalaches",
1156 +"saints-martyrs-canadiens": "Centre-du-Québec",
1157 +"salaberry-de-valleyfield": "Montérégie",
1158 +"salluit": "Nord-du-Québec",
1159 +"sault-au-cochon": "Capitale-Nationale",
1160 +"sayabec": "Bas-Saint-Laurent",
1161 +"schefferville": "Côte-Nord",
1162 +"scotstown": "Estrie",
1163 +"scott": "Chaudière-Appalaches",
1164 +"senneterre": "Abitibi-Témiscamingue",
1165 +"senneville": "Montréal",
1166 +"sept-iles": "Côte-Nord",
1167 +"shannon": "Capitale-Nationale",
1168 +"shawinigan": "Mauricie",
1169 +"shawville": "Outaouais",
1170 +"sheenboro": "Outaouais",
1171 +"shefford": "Estrie",
1172 +"sherbrooke": "Estrie",
1173 +"shigawake": "Gaspésie–Îles-de-la-Madeleine",
1174 +"sillery": "Capitale-Nationale",
1175 +"sorel-tracy": "Montérégie",
1176 +"stanbridge east": "Estrie",
1177 +"stanbridge station": "Estrie",
1178 +"stanstead": "Estrie",
1179 +"stanstead-est": "Estrie",
1180 +"stoke": "Estrie",
1181 +"stoneham-et-tewkesbury": "Capitale-Nationale",
1182 +"stornoway": "Estrie",
1183 +"stratford": "Estrie",
1184 +"stukely-sud": "Estrie",
1185 +"sutton": "Estrie",
1186 +"tadoussac": "Côte-Nord",
1187 +"taschereau": "Abitibi-Témiscamingue",
1188 +"tasiujaq": "Nord-du-Québec",
1189 +"temiscaming": "Abitibi-Témiscamingue",
1190 +"temiscouata-sur-le-lac": "Bas-Saint-Laurent",
1191 +"terrasse-vaudreuil": "Montérégie",
1192 +"terrebonne": "Lanaudière",
1193 +"thetford mines": "Chaudière-Appalaches",
1194 +"thorne": "Outaouais",
1195 +"thurso": "Outaouais",
1196 +"timiskaming": "Abitibi-Témiscamingue",
1197 +"tingwick": "Centre-du-Québec",
1198 +"toponyme a venir": "Nord-du-Québec",
1199 +"tourville": "Chaudière-Appalaches",
1200 +"trecesson": "Abitibi-Témiscamingue",
1201 +"tres-saint-redempteur": "Montérégie",
1202 +"tres-saint-sacrement": "Montérégie",
1203 +"tring-jonction": "Chaudière-Appalaches",
1204 +"trois-pistoles": "Bas-Saint-Laurent",
1205 +"trois-rives": "Mauricie",
1206 +"trois-rivieres": "Mauricie",
1207 +"uashat": "Côte-Nord",
1208 +"ulverton": "Estrie",
1209 +"umiujaq": "Nord-du-Québec",
1210 +"upton": "Montérégie",
1211 +"val-alain": "Chaudière-Appalaches",
1212 +"val-brillant": "Bas-Saint-Laurent",
1213 +"val-d'or": "Abitibi-Témiscamingue",
1214 +"val-david": "Laurentides",
1215 +"val-des-bois": "Outaouais",
1216 +"val-des-lacs": "Laurentides",
1217 +"val-des-monts": "Outaouais",
1218 +"val-des-sources": "Estrie",
1219 +"val-joli": "Estrie",
1220 +"val-morin": "Laurentides",
1221 +"val-racine": "Estrie",
1222 +"val-saint-gilles": "Abitibi-Témiscamingue",
1223 +"valcourt": "Estrie",
1224 +"vallee-jonction": "Chaudière-Appalaches",
1225 +"vanier": "Capitale-Nationale",
1226 +"varennes": "Montérégie",
1227 +"vaudreuil-dorion": "Montérégie",
1228 +"vaudreuil-sur-le-lac": "Montérégie",
1229 +"venise-en-quebec": "Montérégie",
1230 +"vercheres": "Montérégie",
1231 +"verdun": "Montréal",
1232 +"victoriaville": "Centre-du-Québec",
1233 +"vieux-longueuil": "Montérégie",
1234 +"ville de quebec": "Capitale-Nationale",
1235 +"ville saint-laurent": "Montréal",
1236 +"ville-marie": "Abitibi-Témiscamingue",
1237 +"villeray": "Montréal",
1238 +"villeroy": "Centre-du-Québec",
1239 +"waltham": "Outaouais",
1240 +"warden": "Estrie",
1241 +"warwick": "Centre-du-Québec",
1242 +"waskaganish": "Nord-du-Québec",
1243 +"waswanipi": "Nord-du-Québec",
1244 +"waterloo": "Estrie",
1245 +"waterville": "Estrie",
1246 +"weedon": "Estrie",
1247 +"wemindji": "Nord-du-Québec",
1248 +"wemotaci": "Mauricie",
1249 +"wendake": "Capitale-Nationale",
1250 +"wentworth": "Laurentides",
1251 +"wentworth-nord": "Laurentides",
1252 +"westbury": "Estrie",
1253 +"westmount": "Montréal",
1254 +"whapmagoostui": "Nord-du-Québec",
1255 +"wickham": "Centre-du-Québec",
1256 +"windsor": "Estrie",
1257 +"wolinak": "Centre-du-Québec",
1258 +"wotton": "Estrie",
1259 +"yamachiche": "Mauricie",
1260 +"yamaska": "Montérégie"
1261 +}
\ No newline at end of file
modified jobka/db.py +62 −4
@@ -134,15 +134,23 @@ CREATE TABLE IF NOT EXISTS geocode_cache (
134 134
135 135 # Colonnes ajoutées après la v1 — migration automatique des bases existantes.
136 136 _MIGRATIONS: dict[str, dict[str, str]] = {
137 − "jobs": {},
137 + "jobs": {
138 + # Phase 2 (2026-08-19) — enrichissement des connecteurs
139 + "company_logo": "TEXT", # URL du logo employeur (breezy, JSON-LD…)
140 + "language": "TEXT", # fr | en | bilingue (source ou heuristique)
141 + "apply_url": "TEXT", # lien de candidature directe
142 + "title_clean": "TEXT", # titre d'affichage normalisé (source intacte)
143 + "quarantine": "TEXT", # JSON des motifs qualité (NULL = publiable)
144 + },
138 145 "sync_log": {},
139 146 }
140 147
141 148
142 149 def connect() -> sqlite3.Connection:
143 150 DB_PATH.parent.mkdir(parents=True, exist_ok=True)
144 − con = sqlite3.connect(DB_PATH)
151 + con = sqlite3.connect(DB_PATH, timeout=30.0)
145 152 con.row_factory = sqlite3.Row
153 + con.execute("PRAGMA busy_timeout=30000") # BD vivante (sync horaire)
146 154 con.executescript(_SCHEMA)
147 155 for table, cols in _MIGRATIONS.items():
148 156 existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")}
@@ -207,12 +215,16 @@ def sync_source(con: sqlite3.Connection, source: str,
207 215
208 216 alert = _drift_alert(con, source, n, null_salary_rate)
209 217
218 + from .normalize import clean_title
219 + from .quality import issues_for_posting
220 +
210 221 for job in postings:
211 222 seen_uids.add(job.uid)
212 223 h = job.content_hash()
213 224 row = con.execute(
214 225 "SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?",
215 226 (job.uid,)).fetchone()
227 + defects = issues_for_posting(job)
216 228 params = dict(
217 229 uid=job.uid, source=job.source, external_id=job.external_id,
218 230 url=job.url, employer=job.employer, title=job.title,
@@ -232,6 +244,11 @@ def sync_source(con: sqlite3.Connection, source: str,
232 244 category=job.category, ats=job.ats,
233 245 details=json.dumps(job.details, ensure_ascii=False),
234 246 lat=job.lat, lng=job.lng, content_hash=h, now=now,
247 + company_logo=job.company_logo, language=job.language,
248 + apply_url=job.apply_url,
249 + title_clean=clean_title(job.title, job.city),
250 + quarantine=json.dumps(defects, ensure_ascii=False)
251 + if defects else None,
235 252 )
236 253 if row is None:
237 254 con.execute(
@@ -242,7 +259,8 @@ def sync_source(con: sqlite3.Connection, source: str,
242 259 salary_year_max, salary_hour_min, salary_hour_max, benefits,
243 260 requirements, date_posted, date_deadline, category, ats,
244 261 details, lat, lng, content_hash, first_seen, last_seen,
245 − updated_at, miss_count, active)
262 + updated_at, miss_count, active, company_logo, language,
263 + apply_url, title_clean, quarantine)
246 264 VALUES (:uid,:source,:external_id,:url,:employer,:title,
247 265 :description,:address,:city,:region,:postal_code,
248 266 :location_label,:work_mode,:employment_type,:salary_min,
@@ -250,7 +268,8 @@ def sync_source(con: sqlite3.Connection, source: str,
250 268 :salary_year_max,:salary_hour_min,:salary_hour_max,
251 269 :benefits,:requirements,:date_posted,:date_deadline,
252 270 :category,:ats,:details,:lat,:lng,:content_hash,
253 − :now,:now,:now,0,1)""", params)
271 + :now,:now,:now,0,1,:company_logo,:language,:apply_url,
272 + :title_clean,:quarantine)""", params)
254 273 if job.salary_min is not None: # point de départ de l'historique
255 274 con.execute(
256 275 "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)"
@@ -279,6 +298,9 @@ def sync_source(con: sqlite3.Connection, source: str,
279 298 date_posted=:date_posted, date_deadline=:date_deadline,
280 299 category=:category, ats=:ats, details=:details,
281 300 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
301 + company_logo=:company_logo, language=:language,
302 + apply_url=:apply_url, title_clean=:title_clean,
303 + quarantine=:quarantine,
282 304 content_hash=:content_hash, last_seen=:now,
283 305 updated_at=:now, miss_count=0, active=1
284 306 WHERE uid=:uid""", params)
@@ -344,6 +366,26 @@ def expire_past_deadline(con: sqlite3.Connection) -> int:
344 366 return cur.rowcount
345 367
346 368
369 +STALE_DAYS = 7 # offres plus jamais revues (connecteur retiré/muet) : archivées
370 +
371 +
372 +def expire_stale(con: sqlite3.Connection, days: int = STALE_DAYS) -> int:
373 + """Archive (active=0) les offres actives non revues depuis `days` jours.
374 +
375 + Filet de sécurité complémentaire au délai de grâce MISS_GRACE : couvre le
376 + cas d'un connecteur SUPPRIMÉ du code (ses offres ne passent plus jamais
377 + par sync_source et resteraient actives pour toujours — cas « valero »).
378 + Rien n'est supprimé : les offres sont archivées, réactivables si revues.
379 + """
380 + cutoff = time.time() - days * 86400
381 + cur = con.execute(
382 + "UPDATE jobs SET active=0, updated_at=? WHERE active=1 AND last_seen<?"
383 + " AND source<>'depot-direct'", # le dépôt direct n'est jamais resynchronisé
384 + (time.time(), cutoff))
385 + con.commit()
386 + return cur.rowcount
387 +
388 +
347 389 def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
348 390 con.execute(
349 391 "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
@@ -369,6 +411,22 @@ def get_cached_detail(con: sqlite3.Connection, source: str,
369 411 return None
370 412
371 413
414 +def get_stale_detail(con: sqlite3.Connection, source: str,
415 + external_id: str) -> dict | None:
416 + """Payload détail en cache MÊME si la clé a changé — secours quand le
417 + budget de re-visites est épuisé : un détail périmé vaut mieux qu'une
418 + description vide (la re-visite aura lieu à un prochain cycle)."""
419 + row = con.execute(
420 + "SELECT payload FROM detail_cache WHERE source=? AND external_id=?",
421 + (source, external_id)).fetchone()
422 + if row and row["payload"]:
423 + try:
424 + return json.loads(row["payload"])
425 + except ValueError:
426 + return None
427 + return None
428 +
429 +
372 430 def put_cached_detail(con: sqlite3.Connection, source: str,
373 431 external_id: str, key: str, payload: dict) -> None:
374 432 con.execute(
modified jobka/ingest.py +9 −0
@@ -56,6 +56,15 @@ def run(sources: list[str] | None = None) -> list[dict]:
56 56 print(f"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)")
57 57 except Exception:
58 58 pass
59 + # offres orphelines (connecteur retiré/muet) : archivées après STALE_DAYS
60 + # — seulement lors d'un passage complet (un sync ciblé ne juge pas les autres)
61 + if not sources:
62 + try:
63 + stale = db.expire_stale(con)
64 + if stale:
65 + print(f"[job-ka] offres orphelines archivées : {stale}")
66 + except Exception:
67 + pass
59 68 # statistiques du planificateur SQLite (requêtes bbox de la carte)
60 69 try:
61 70 con.execute("PRAGMA optimize")
modified jobka/normalize.py +234 −6
@@ -21,9 +21,11 @@ import unicodedata
21 21
22 22 __all__ = [
23 23 "strip_accents", "clean_text", "clean_html", "parse_salary",
24 − "salary_to_yearly", "salary_to_hourly", "parse_date", "parse_work_mode",
24 + "sanitize_salary", "salary_from_text", "salary_to_yearly",
25 + "salary_to_hourly", "parse_date", "parse_work_mode",
25 26 "parse_employment_type", "parse_location", "canonical_city",
26 − "is_quebec_location", "categorize",
27 + "is_quebec_location", "categorize", "detect_language",
28 + "extract_benefits", "clean_title",
27 29 ]
28 30
29 31
@@ -50,6 +52,12 @@ def clean_text(s: str) -> str:
50 52 _BLOCK_TAGS = re.compile(
51 53 r"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|table|section|article)[^>]*>", re.I)
52 54
55 +# Résidu HTML après une passe : balise ouvrante plausible ou entité encore
56 +# encodée — signe d'un contenu doublement échappé.
57 +_HTML_LEFTOVER = re.compile(
58 + r"<(?:p|br|li|ul|ol|div|span|strong|em|b|i|h[1-6]|a)\b|&(?:amp|lt|gt|nbsp|#\d+);",
59 + re.I)
60 +
53 61
54 62 def clean_html(html: str) -> str:
55 63 """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.
@@ -60,10 +68,16 @@ def clean_html(html: str) -> str:
60 68 if not html:
61 69 return ""
62 70 import html as _html
63 − s = _html.unescape(html)
64 − s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S)
65 − s = _BLOCK_TAGS.sub("\n", s)
66 − s = re.sub(r"<[^>]+>", " ", s)
71 + s = html
72 + # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :
73 + # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).
74 + for _ in range(3):
75 + s = _html.unescape(s)
76 + s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S)
77 + s = _BLOCK_TAGS.sub("\n", s)
78 + s = re.sub(r"<[^>]+>", " ", s)
79 + if not _HTML_LEFTOVER.search(s):
80 + break
67 81 s = s.replace("\xa0", " ").replace(" ", " ")
68 82 s = re.sub(r"[ \t]+", " ", s)
69 83 s = re.sub(r" ?\n ?", "\n", s)
@@ -176,6 +190,67 @@ def salary_to_hourly(value: float | None, unit: str | None) -> float | None:
176 190 return round(y / _HOURS_PER_YEAR, 2) if y is not None else None
177 191
178 192
193 +def sanitize_salary(lo: float | None, hi: float | None, unit: str | None
194 + ) -> tuple[float | None, float | None, str | None]:
195 + """Bornes de plausibilité sur les salaires STRUCTURÉS fournis par les ATS.
196 +
197 + Certaines sources étiquettent mal l'unité (75 000 « /mois » ou « /h » pour
198 + un salaire annuel — vu chez Lever et dans les JSON-LD de portails) : si la
199 + valeur sort des bornes de son unité, on tente une réinterprétation vers
200 + l'unité dont les bornes la contiennent (année d'abord, puis heure). Aucune
201 + unité plausible -> on jette (pas de salaire aberrant publié).
202 + """
203 + if lo is None or unit not in _SALARY_BOUNDS:
204 + return (lo, hi, unit)
205 + b = _SALARY_BOUNDS[unit]
206 + if not (b[0] <= lo <= b[1]):
207 + for candidate in ("year", "hour"):
208 + cb = _SALARY_BOUNDS[candidate]
209 + if candidate != unit and cb[0] <= lo <= cb[1]:
210 + unit, b = candidate, cb
211 + break
212 + else:
213 + return (None, None, None)
214 + if hi is not None and not (b[0] <= hi <= b[1]):
215 + hi = None # max aberrant : on garde le min plausible seul
216 + if hi is not None and hi < lo:
217 + lo, hi = hi, lo
218 + return (lo, hi, unit)
219 +
220 +
221 +# Contextes où un montant en $ n'est PAS un salaire (financement, dépenses,
222 +# allocations…) — garde-fou de l'extraction depuis la description.
223 +_NOT_SALARY_CONTEXT = re.compile(
224 + r"financement|funding|amorcage|seed|investisse|invest(?:ment|or)|"
225 + r"revenu[s]?\b|revenue|chiffre d affaires|budget|remboursement|"
226 + r"reimburse|expense|allocation|allowance|subvention|credit d impot|"
227 + r"cotisation|rabais|discount|gift|carte[- ]cadeau|referral (?:bonus|fee)|"
228 + r"signing bonus|prime (?:a l embauche|de demarrage|de reference)|"
229 + r"million|milliard")
230 +
231 +
232 +def salary_from_text(text: str) -> tuple[float | None, float | None,
233 + str | None, str]:
234 + """Cherche une mention salariale dans un texte libre (description).
235 +
236 + Découpe en phrases (les points décimaux « 182,500.00 » ne coupent pas),
237 + ignore les contextes non salariaux (financement, remboursements…), puis
238 + parse la première phrase plausible. Retourne (min, max, unité, libellé).
239 + """
240 + if not text or "$" not in text:
241 + return (None, None, None, "")
242 + for sentence in re.split(r"(?<!\d)\.(?!\d)|\n|;", text):
243 + if "$" not in sentence or not re.search(r"\d", sentence):
244 + continue
245 + frag = clean_text(sentence)[:160]
246 + if _NOT_SALARY_CONTEXT.search(_key(frag)):
247 + continue
248 + lo, hi, unit = parse_salary(frag)
249 + if lo is not None:
250 + return (lo, hi, unit, frag)
251 + return (None, None, None, "")
252 +
253 +
179 254 # ---------------------------------------------------------------------------
180 255 # Dates — ISO 8601, jamais de date inventée
181 256 # ---------------------------------------------------------------------------
@@ -499,6 +574,159 @@ _CATEGORY_RULES = [
499 574 ]
500 575
501 576
577 +# ---------------------------------------------------------------------------
578 +# Langue de l'offre — heuristique légère par mots-outils (fr | en | bilingue)
579 +# ---------------------------------------------------------------------------
580 +
581 +# Mots-outils très fréquents et quasi exclusifs à chaque langue (sans accents).
582 +_FR_STOPWORDS = re.compile(
583 + r"\b(?:le|la|les|des|une|et|est|sont|pour|avec|vous|nous|notre|nos|vos|"
584 + r"dans|chez|aux|du|au|ainsi|plus de|afin|etre|sera|poste|equipe|"
585 + r"exigences|taches|milieu|travail)\b")
586 +_EN_STOPWORDS = re.compile(
587 + r"\b(?:the|and|for|with|you|your|we|our|is|are|will|this|that|team|"
588 + r"work|skills|of|to be|as well|role|responsibilities|requirements|"
589 + r"experience in|ability)\b")
590 +
591 +
592 +def detect_language(title: str, description: str = "") -> str:
593 + """Langue du texte de l'offre : « fr », « en », « bilingue », ou "".
594 +
595 + Comptage de mots-outils sur le début du texte — déterministe, jamais de
596 + valeur inventée : texte trop court ou ambigu -> "".
597 + """
598 + text = _key(f"{title}\n{description or ''}"[:4000])
599 + if len(text) < 30:
600 + return ""
601 + fr = len(_FR_STOPWORDS.findall(text))
602 + en = len(_EN_STOPWORDS.findall(text))
603 + total = fr + en
604 + if total < 5:
605 + return ""
606 + # les deux langues nettement présentes -> offre bilingue
607 + if fr >= 8 and en >= 8 and min(fr, en) / max(fr, en) >= 0.35:
608 + return "bilingue"
609 + if fr > en:
610 + return "fr"
611 + if en > fr:
612 + return "en"
613 + return ""
614 +
615 +
616 +# ---------------------------------------------------------------------------
617 +# Avantages — extraction des blocs « Avantages / Benefits » des descriptions
618 +# ---------------------------------------------------------------------------
619 +
620 +_BENEFITS_HEADING = re.compile(
621 + r"^\s*(?:les\s+|nos\s+|our\s+|tes\s+|vos\s+)?(?:avantages(?:\s+sociaux)?|"
622 + r"benefits(?:\s+&\s+perks)?|perks(?:\s+(?:and|&)\s+benefits)?|"
623 + r"ce que nous (?:t')?offrons|what we offer|why join us|"
624 + r"pourquoi nous rejoindre|nous offrons|we offer)\s*:?\s*$", re.I)
625 +_BENEFIT_LINE_MAX = 120
626 +
627 +
628 +def extract_benefits(description: str) -> list[str]:
629 + """Liste d'avantages depuis un bloc structuré de la description.
630 +
631 + Cherche un titre de section « Avantages/Benefits/… » seul sur sa ligne,
632 + puis prend les lignes courtes qui suivent (puces), en s'arrêtant à la
633 + première ligne longue ou au prochain titre. Max 12 items — si le bloc ne
634 + ressemble pas à une liste, on ne retourne rien (pas d'à-peu-près).
635 + """
636 + if not description:
637 + return []
638 + lines = description.split("\n")
639 + for i, line in enumerate(lines):
640 + if not _BENEFITS_HEADING.match(strip_accents(line)):
641 + continue
642 + items: list[str] = []
643 + for nxt in lines[i + 1:]:
644 + t = clean_text(nxt).lstrip("•-–—*·◦▪ ").strip()
645 + if not t:
646 + if items:
647 + break # fin du bloc (ligne vide après les puces)
648 + continue
649 + if len(t) > _BENEFIT_LINE_MAX or _BENEFITS_HEADING.match(
650 + strip_accents(t)):
651 + break
652 + # un « titre » de section suivant (courte ligne sans ponctuation
653 + # finale, capitalisée) termine aussi le bloc
654 + if items and len(t.split()) <= 4 and t.endswith(":"):
655 + break
656 + items.append(t.rstrip(" ;,.").strip())
657 + if len(items) >= 12:
658 + break
659 + if len(items) >= 2:
660 + return items
661 + return []
662 +
663 +
664 +# ---------------------------------------------------------------------------
665 +# Titre d'affichage — casse propre, codes de réquisition et suffixes retirés
666 +# ---------------------------------------------------------------------------
667 +
668 +# codes de réquisition : « [R-12345] », « (REQ 2026-42) », « - JR0035678 »…
669 +_REQ_CODE = re.compile(
670 + # en tête : « 123456 - Titre », « [REQ-1234] Titre » (≥5 chiffres nus pour
671 + # ne pas manger une année « 2026 - Programme d'été »)
672 + r"^\s*[\[(]?(?:req(?:uisition)?|jr|job)[\s#:-]*\d{3,10}[\])]?\s*[-–—:|]\s*|"
673 + r"^\s*[\[(]?\d{5,10}[\])]?\s*[-–—:|]\s*|"
674 + # en queue : « Titre - R-12345 », « Titre (JOB 004521) »
675 + r"\s*[-–—|(]\s*(?:req(?:uisition)?|job|poste)?\s*#?\s*(?:[A-Z]{1,4}-?)?\d{4,10}\s*[)\]]?\s*$|"
676 + # au milieu : « Titre [R-12345] suite »
677 + r"\s*[\[(](?:R|REQ|JR|JOB)[-_ ]?\d{3,10}[\])]\s*", re.I)
678 +
679 +# petits mots gardés en minuscules lors de la remise en casse d'un titre
680 +# tout-majuscules ; sigles usuels gardés en majuscules
681 +_TITLE_SMALL = {"de", "du", "des", "la", "le", "les", "et", "en", "à", "a",
682 + "au", "aux", "d'", "l'", "of", "the", "and", "in", "for",
683 + "to", "or", "ou", "un", "une", "sur", "pour"}
684 +_TITLE_ACRONYMS = {"rh", "ti", "it", "qa", "qc", "pab", "cpa", "cfa", "erp",
685 + "sap", "crm", "sst", "cnc", "hvac", "rpa", "bi", "ia",
686 + "ai", "vp", "pdg", "dg"}
687 +
688 +
689 +def _smart_case(title: str) -> str:
690 + """Titre TOUT EN MAJUSCULES -> casse de phrase lisible (sigles préservés)."""
691 + words = title.lower().split()
692 + out = []
693 + for i, w in enumerate(words):
694 + base = w.strip("()[],.;:/")
695 + if base in _TITLE_ACRONYMS:
696 + out.append(w.replace(base, base.upper()))
697 + elif i > 0 and base in _TITLE_SMALL:
698 + out.append(w)
699 + else:
700 + out.append(w[:1].upper() + w[1:])
701 + return " ".join(out)
702 +
703 +
704 +def clean_title(title: str, city: str = "") -> str:
705 + """Titre d'affichage : codes de réquisition retirés, suffixe « - <ville> »
706 + redondant retiré, casse corrigée si tout-majuscules. Le titre SOURCE n'est
707 + jamais modifié — ceci alimente une colonne d'affichage distincte."""
708 + t = clean_text(title)
709 + if not t:
710 + return ""
711 + prev = None
712 + while prev != t: # les codes peuvent être empilés
713 + prev = t
714 + t = _REQ_CODE.sub(" ", t).strip(" -–—|:")
715 + t = clean_text(t)
716 + # suffixe « - Montréal » / « (Montréal, QC) » identique à la ville de
717 + # l'offre : repéré sans accents, coupé sur le titre original
718 + if city:
719 + ck = re.escape(strip_accents(city.lower()))
720 + m = re.search(
721 + rf"\s*[-–—|,(]\s*(?:ville de\s+)?{ck}(?:\s*,\s*(?:qc|quebec))?\s*\)?\s*$",
722 + strip_accents(t.lower()))
723 + if m and m.start() > 0:
724 + t = clean_text(t[:m.start()]).strip(" -–—|,(")
725 + if len(t) > 8 and t == t.upper() and t != t.lower():
726 + t = _smart_case(t)
727 + return t or clean_text(title)
728 +
729 +
502 730 def categorize(title: str, description: str = "") -> str:
503 731 """Titre -> catégorie interne (repli : début de description), ou "".
504 732
added jobka/quality.py +82 −0
@@ -0,0 +1,82 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : jobka/quality.py
6 +# Rôle : Contrôle qualité avant publication — règles anti-aberrations,
7 +# quarantaine (offres douteuses non publiées, jamais supprimées)
8 +# Créé : 2026-08-19 Modifié : 2026-08-19
9 +# =============================================================================
10 +"""Quarantaine qualité (patron hérité de Lou·Ka).
11 +
12 +Une offre qui viole une règle de plausibilité n'est PAS publiée « à peu
13 +près » : elle reste en base (active, resynchronisée normalement) avec la
14 +colonne ``quarantine`` = JSON des motifs, et les points d'accès publics
15 +l'excluent. Dès que la source corrige la donnée (ou qu'un correctif de
16 +parseur passe), le contrôle se ré-évalue au sync suivant et l'offre est
17 +réintégrée automatiquement.
18 +
19 +Règles (déterministes, bornes en CAD) :
20 +- salaire hors bornes : < 15 $/h ou > 200 $/h équivalent ; annuel < 25 k$ ou
21 + > 500 k$ ;
22 +- ville manifestement hors Québec ;
23 +- dates incohérentes : date limite antérieure à la publication ;
24 +- description absente ou < 50 caractères ;
25 +- titre vide.
26 +(Les titres tout-majuscules ne sont PAS quarantainés : la colonne
27 +``title_clean`` corrige la casse à l'affichage.)
28 +"""
29 +from __future__ import annotations
30 +
31 +from .normalize import _NON_QC_CITIES, strip_accents # type: ignore
32 +
33 +MIN_HOURLY, MAX_HOURLY = 15.0, 200.0
34 +MIN_YEARLY, MAX_YEARLY = 25000.0, 500000.0
35 +MIN_DESCRIPTION = 50
36 +
37 +
38 +def issues(*, title: str, description: str, city: str,
39 + date_posted: str | None, date_deadline: str | None,
40 + salary_hour_min: float | None, salary_hour_max: float | None,
41 + salary_year_min: float | None, salary_year_max: float | None,
42 + salary_unit: str | None = None) -> list[str]:
43 + """Motifs de quarantaine d'une offre (liste vide = publiable)."""
44 + out: list[str] = []
45 +
46 + if not (title or "").strip():
47 + out.append("titre_vide")
48 +
49 + if len((description or "").strip()) < MIN_DESCRIPTION:
50 + out.append("description_courte")
51 +
52 + key = " ".join(strip_accents((city or "").lower()).split())
53 + if key and key in _NON_QC_CITIES:
54 + out.append("ville_hors_quebec")
55 +
56 + if date_posted and date_deadline and date_deadline < date_posted:
57 + out.append("dates_incoherentes")
58 +
59 + # bornes salariales : les bornes HORAIRES ne s'appliquent qu'aux salaires
60 + # affichés à l'heure (un annuel de 460 k$ n'est pas « > 200 $/h ») ; les
61 + # autres unités sont jugées sur l'équivalent annuel
62 + if salary_unit == "hour":
63 + for v in (salary_hour_min, salary_hour_max):
64 + if v is not None and not (MIN_HOURLY <= v <= MAX_HOURLY):
65 + out.append("salaire_hors_bornes")
66 + break
67 + else:
68 + for v in (salary_year_min, salary_year_max):
69 + if v is not None and not (MIN_YEARLY <= v <= MAX_YEARLY):
70 + out.append("salaire_hors_bornes")
71 + break
72 + return out
73 +
74 +
75 +def issues_for_posting(job) -> list[str]:
76 + """Motifs de quarantaine d'un JobPosting finalisé (utilisé au sync)."""
77 + return issues(
78 + title=job.title, description=job.description, city=job.city,
79 + date_posted=job.date_posted, date_deadline=job.date_deadline,
80 + salary_hour_min=job.salary_hour_min(), salary_hour_max=job.salary_hour_max(),
81 + salary_year_min=job.salary_year_min(), salary_year_max=job.salary_year_max(),
82 + salary_unit=job.salary_unit)
added jobka/regions.py +87 −0
@@ -0,0 +1,87 @@
1 +# =============================================================================
2 +# Job·Ka — Groupe KA
3 +# Auteur : Simon-Pierre Boucher
4 +# Contact : contact@spboucher.ai
5 +# Fichier : jobka/regions.py
6 +# Rôle : Région administrative du Québec (17 régions) depuis la ville —
7 +# répertoire MAMH embarqué (data/regions-qc.json) + arrondissements
8 +# Créé : 2026-08-19 Modifié : 2026-08-19
9 +# =============================================================================
10 +"""Dérivation de la région administrative (17 régions du Québec).
11 +
12 +Source : Répertoire des municipalités du MAMH (données ouvertes, colonne
13 +`regadm`), figé dans ``data/regions-qc.json`` (~1 260 entrées : municipalités
14 ++ arrondissements/quartiers fréquents dans les libellés d'offres). Clé de
15 +recherche : nom de ville minuscules sans accents. Ville inconnue -> "" —
16 +jamais de région inventée.
17 +"""
18 +from __future__ import annotations
19 +
20 +import json
21 +from functools import lru_cache
22 +from pathlib import Path
23 +
24 +from .normalize import canonical_city, strip_accents
25 +
26 +_DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "regions-qc.json"
27 +
28 +# Les 17 régions administratives (référence pour validation/affichage)
29 +REGIONS_QC = (
30 + "Abitibi-Témiscamingue", "Bas-Saint-Laurent", "Capitale-Nationale",
31 + "Centre-du-Québec", "Chaudière-Appalaches", "Côte-Nord", "Estrie",
32 + "Gaspésie–Îles-de-la-Madeleine", "Lanaudière", "Laurentides", "Laval",
33 + "Mauricie", "Montérégie", "Montréal", "Nord-du-Québec", "Outaouais",
34 + "Saguenay–Lac-Saint-Jean",
35 +)
36 +
37 +
38 +@lru_cache(maxsize=1)
39 +def _mapping() -> dict[str, str]:
40 + try:
41 + return json.loads(_DATA_PATH.read_text(encoding="utf-8"))
42 + except (OSError, ValueError):
43 + return {}
44 +
45 +
46 +def _key(city: str) -> str:
47 + return " ".join(strip_accents((city or "").lower()).split())
48 +
49 +
50 +def city_from_slug_tokens(tokens: list[str]) -> str:
51 + """Plus long préfixe de `tokens` (segments d'un slug d'URL) qui forme une
52 + municipalité québécoise connue — résout « Pointe-Claire-Analyste-… » où
53 + ville et titre partagent le même séparateur « - ». Retourne la ville
54 + reconstituée (« Pointe-Claire »), ou "" si aucun préfixe ne correspond.
55 + """
56 + from .normalize import _QC_CITIES # table des libellés fréquents
57 + table = _mapping()
58 + for n in range(min(len(tokens), 5), 0, -1):
59 + raw = "-".join(t for t in tokens[:n] if t)
60 + k = _key(raw)
61 + k2 = k.replace("st-", "saint-").replace("ste-", "sainte-")
62 + if k in table or k2 in table or k in _QC_CITIES or k2 in _QC_CITIES:
63 + return raw
64 + return ""
65 +
66 +
67 +def region_for_city(city: str) -> str:
68 + """Ville -> région administrative (« Montérégie »…), ou "" si inconnue.
69 +
70 + La ville est d'abord canonicalisée (« Quebec City » -> « Québec ») ; les
71 + préfixes de type « Ville de » sont tolérés.
72 + """
73 + if not city:
74 + return ""
75 + table = _mapping()
76 + for candidate in (canonical_city(city), city):
77 + k = _key(candidate)
78 + if not k:
79 + continue
80 + if k in table:
81 + return table[k]
82 + # « ville de sherbrooke », « st-jerome » (st- = saint-)
83 + k2 = k.removeprefix("ville de ").replace("st-", "saint-") \
84 + .replace("ste-", "sainte-")
85 + if k2 in table:
86 + return table[k2]
87 + return ""
modified jobka/schema.py +35 −8
@@ -25,14 +25,18 @@ from .normalize import ( # ré-exportés pour les connecteurs
25 25 categorize,
26 26 clean_html,
27 27 clean_text,
28 + detect_language,
29 + extract_benefits,
28 30 is_quebec_location,
29 31 parse_date,
30 32 parse_employment_type,
31 33 parse_location,
32 34 parse_salary,
33 35 parse_work_mode,
36 + salary_from_text,
34 37 salary_to_hourly,
35 38 salary_to_yearly,
39 + sanitize_salary,
36 40 strip_accents,
37 41 )
38 42
@@ -74,6 +78,9 @@ class JobPosting:
74 78 details: dict = field(default_factory=dict) # champs structurés (sparse)
75 79 lat: float | None = None
76 80 lng: float | None = None
81 + company_logo: str = "" # URL du logo employeur (si exposé)
82 + language: str = "" # fr | en | bilingue ("" = indéterminée)
83 + apply_url: str = "" # lien de candidature directe (≠ url fiche)
77 84
78 85 @property
79 86 def uid(self) -> str:
@@ -118,6 +125,11 @@ class JobPosting:
118 125 self.region = self.region or loc["region"]
119 126 self.postal_code = self.postal_code or loc["postal_code"]
120 127 self.city = canonical_city(self.city)
128 + # région administrative (17 régions, répertoire MAMH embarqué) ;
129 + # repli : « Québec » (province) quand seule l'appartenance QC est sûre
130 + if self.city and (not self.region or self.region == "Québec"):
131 + from .regions import region_for_city
132 + self.region = region_for_city(self.city) or self.region
121 133 if self.city and not self.region and is_quebec_location(self.city):
122 134 self.region = "Québec"
123 135
@@ -126,14 +138,18 @@ class JobPosting:
126 138 lo, hi, unit = parse_salary(self.salary_label)
127 139 self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit
128 140 if self.salary_min is None and self.description:
129 − # chercher une mention salariale dans la description (fréquent au QC)
130 − import re
131 − m = re.search(r"[^.\n]{0,60}\d[^.\n]{0,40}\$[^.\n]{0,40}", self.description)
132 − if m:
133 − lo, hi, unit = parse_salary(m.group(0))
134 − if lo is not None:
135 − self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit
136 − self.salary_label = self.salary_label or clean_text(m.group(0))
141 + # mention salariale dans la description (fréquent au QC) — avec
142 + # garde-fou de contexte (financement, remboursements ≠ salaire)
143 + lo, hi, unit, label = salary_from_text(self.description)
144 + if lo is not None:
145 + self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit
146 + self.salary_label = self.salary_label or label
147 + # bornes de plausibilité aussi sur les valeurs STRUCTURÉES des ATS
148 + # (unités mal étiquetées : « 75 000 $/mois » qui est un annuel…)
149 + self.salary_min, self.salary_max, self.salary_unit = sanitize_salary(
150 + self.salary_min, self.salary_max, self.salary_unit)
151 + if self.salary_min is None:
152 + self.salary_unit = None
137 153
138 154 # dates : accepter epoch/textuel/relatif -> ISO
139 155 if self.date_posted is not None:
@@ -158,6 +174,17 @@ class JobPosting:
158 174 if not self.category:
159 175 self.category = categorize(self.title, self.description)
160 176
177 + # langue de l'offre : valeur source si fournie, sinon détection légère
178 + if not self.language:
179 + self.language = detect_language(self.title, self.description)
180 + elif self.language not in ("fr", "en", "bilingue"):
181 + lang = str(self.language).lower()[:2]
182 + self.language = {"fr": "fr", "en": "en"}.get(lang, "")
183 +
184 + # avantages : blocs structurés « Avantages / Benefits » des descriptions
185 + if not self.benefits and self.description:
186 + self.benefits = extract_benefits(self.description)
187 +
161 188 # coordonnées fournies par la source : rejeter tout point hors de la
162 189 # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra
163 190 # le relais sur l'adresse/la ville
modified tests/test_normalize.py +149 −0
@@ -176,3 +176,152 @@ def test_clean_html():
176 176 assert "Bonjour !" in txt
177 177 assert "Point 1" in txt and "Point 2" in txt
178 178 assert "<" not in txt
179 +
180 +
181 +# --- Phase 2 (2026-08-19) : enrichissement ------------------------------------
182 +
183 +def test_clean_html_double_escaped():
184 + # contenu sur-échappé (Greenhouse/Lever) : entités dans les entités
185 + html = "&lt;p&gt;Bonjour&amp;nbsp;!&lt;/p&gt;&lt;li&gt;Point&lt;/li&gt;"
186 + txt = clean_html(html)
187 + assert "<" not in txt and "&nbsp;" not in txt
188 + assert "Bonjour" in txt and "Point" in txt
189 +
190 +
191 +def test_sanitize_salary_month_is_actually_year():
192 + from jobka.normalize import sanitize_salary
193 + # « 75 000 $/mois » : hors bornes mensuelles -> réinterprété en annuel
194 + assert sanitize_salary(75000, 82500, "month") == (75000, 82500, "year")
195 +
196 +
197 +def test_sanitize_salary_hour_is_actually_year():
198 + from jobka.normalize import sanitize_salary
199 + assert sanitize_salary(57200, None, "hour") == (57200, None, "year")
200 +
201 +
202 +def test_sanitize_salary_plausible_untouched():
203 + from jobka.normalize import sanitize_salary
204 + assert sanitize_salary(25.0, 30.0, "hour") == (25.0, 30.0, "hour")
205 +
206 +
207 +def test_sanitize_salary_absurd_dropped():
208 + from jobka.normalize import sanitize_salary
209 + assert sanitize_salary(2.0, 3.0, "hour") == (None, None, None)
210 +
211 +
212 +def test_salary_from_text_skips_funding():
213 + from jobka.normalize import salary_from_text
214 + lo, _, _, _ = salary_from_text(
215 + "Nous offrons 600 000 $ en financement de pré-amorçage aux fondateurs.")
216 + assert lo is None
217 +
218 +
219 +def test_salary_from_text_decimal_amounts():
220 + from jobka.normalize import salary_from_text
221 + lo, hi, unit, _ = salary_from_text(
222 + "The annual salary range is $82,500.00 to $137,500.00 per year.")
223 + assert (lo, hi, unit) == (82500.0, 137500.0, "year")
224 +
225 +
226 +def test_salary_from_text_skips_expense():
227 + from jobka.normalize import salary_from_text
228 + lo, _, _, _ = salary_from_text(
229 + "You get a 250$ BEHappy expense reimbursement to buy what you want.")
230 + assert lo is None
231 +
232 +
233 +def test_detect_language_fr():
234 + from jobka.normalize import detect_language
235 + assert detect_language(
236 + "Développeur logiciel",
237 + "Nous cherchons une personne pour rejoindre notre équipe dans nos "
238 + "bureaux de Montréal. Le poste est pour vous si vous aimez le défi "
239 + "et les tâches variées avec des exigences claires.") == "fr"
240 +
241 +
242 +def test_detect_language_en():
243 + from jobka.normalize import detect_language
244 + assert detect_language(
245 + "Software Developer",
246 + "We are looking for someone to join our team in Montreal. You will "
247 + "work with the best and your skills will grow as we build this "
248 + "product for the world.") == "en"
249 +
250 +
251 +def test_detect_language_too_short():
252 + from jobka.normalize import detect_language
253 + assert detect_language("Poste", "") == ""
254 +
255 +
256 +def test_extract_benefits_block():
257 + from jobka.normalize import extract_benefits
258 + desc = ("Description du poste.\n\nAvantages\n"
259 + "• Assurances collectives\n• REER avec cotisation de l'employeur\n"
260 + "• 4 semaines de vacances\n\nComment postuler\nEnvoyez votre CV.")
261 + ben = extract_benefits(desc)
262 + assert "Assurances collectives" in ben
263 + assert "4 semaines de vacances" in ben
264 + assert len(ben) == 3
265 +
266 +
267 +def test_extract_benefits_none_without_block():
268 + from jobka.normalize import extract_benefits
269 + assert extract_benefits("Un poste intéressant sans liste structurée.") == []
270 +
271 +
272 +def test_clean_title_requisition_code():
273 + from jobka.normalize import clean_title
274 + assert clean_title("Analyste financier - R-12345") == "Analyste financier"
275 + assert clean_title("[REQ-9876] Technicien réseau") == "Technicien réseau"
276 +
277 +
278 +def test_clean_title_city_suffix():
279 + from jobka.normalize import clean_title
280 + assert clean_title("Développeur Python - Montréal", "Montréal") == \
281 + "Développeur Python"
282 + # ville différente : suffixe conservé
283 + assert clean_title("Développeur Python - Montréal", "Laval") == \
284 + "Développeur Python - Montréal"
285 +
286 +
287 +def test_clean_title_all_caps():
288 + from jobka.normalize import clean_title
289 + t = clean_title("CONSEILLER RH ET ACQUISITION DE TALENTS")
290 + assert t != t.upper()
291 + assert "RH" in t
292 +
293 +
294 +def test_region_for_city():
295 + from jobka.regions import region_for_city
296 + assert region_for_city("Montréal") == "Montréal"
297 + assert region_for_city("Québec") == "Capitale-Nationale"
298 + assert region_for_city("Lévis") == "Chaudière-Appalaches"
299 + assert region_for_city("Dorval") == "Montréal"
300 + assert region_for_city("Ville inconnue XYZ") == ""
301 +
302 +
303 +def test_quality_issues():
304 + from jobka.quality import issues
305 + ok = issues(title="Développeur", description="x" * 100, city="Montréal",
306 + date_posted="2026-08-01", date_deadline="2026-09-01",
307 + salary_hour_min=25.0, salary_hour_max=30.0,
308 + salary_year_min=None, salary_year_max=None,
309 + salary_unit="hour")
310 + assert ok == []
311 + bad = issues(title="", description="court", city="Toronto",
312 + date_posted="2026-08-01", date_deadline="2026-07-01",
313 + salary_hour_min=300.0, salary_hour_max=None,
314 + salary_year_min=None, salary_year_max=None,
315 + salary_unit="hour")
316 + assert set(bad) == {"titre_vide", "description_courte", "ville_hors_quebec",
317 + "dates_incoherentes", "salaire_hors_bornes"}
318 +
319 +
320 +def test_quality_high_yearly_not_hourly_capped():
321 + from jobka.quality import issues
322 + # 457 500 $/an (~ 220 $/h equiv.) : legitime, sous la borne annuelle 500 k$
323 + assert issues(title="Directeur", description="x" * 100, city="Montréal",
324 + date_posted=None, date_deadline=None,
325 + salary_hour_min=131.97, salary_hour_max=219.95,
326 + salary_year_min=274500.0, salary_year_max=457500.0,
327 + salary_unit="year") == []
179 328