SPB Git

spb/job-ka

Public

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 (Aug 19, 2026) parent b1ff23d Browse files

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 # Colonnes ajoutées après la v1 — migration automatique des bases existantes. 135 # Colonnes ajoutées après la v1 — migration automatique des bases existantes.
136 _MIGRATIONS: dict[str, dict[str, str]] = { 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 "sync_log": {}, 145 "sync_log": {},
139 } 146 }
140 147
141 148
142 def connect() -> sqlite3.Connection: 149 def connect() -> sqlite3.Connection:
143 DB_PATH.parent.mkdir(parents=True, exist_ok=True) 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 con.row_factory = sqlite3.Row 152 con.row_factory = sqlite3.Row
153+ con.execute("PRAGMA busy_timeout=30000") # BD vivante (sync horaire)
146 con.executescript(_SCHEMA) 154 con.executescript(_SCHEMA)
147 for table, cols in _MIGRATIONS.items(): 155 for table, cols in _MIGRATIONS.items():
148 existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} 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 alert = _drift_alert(con, source, n, null_salary_rate) 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 for job in postings: 221 for job in postings:
211 seen_uids.add(job.uid) 222 seen_uids.add(job.uid)
212 h = job.content_hash() 223 h = job.content_hash()
213 row = con.execute( 224 row = con.execute(
214 "SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?", 225 "SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?",
215 (job.uid,)).fetchone() 226 (job.uid,)).fetchone()
227+ defects = issues_for_posting(job)
216 params = dict( 228 params = dict(
217 uid=job.uid, source=job.source, external_id=job.external_id, 229 uid=job.uid, source=job.source, external_id=job.external_id,
218 url=job.url, employer=job.employer, title=job.title, 230 url=job.url, employer=job.employer, title=job.title,
@@ -232,6 +244,11 @@ def sync_source(con: sqlite3.Connection, source: str,
232 category=job.category, ats=job.ats, 244 category=job.category, ats=job.ats,
233 details=json.dumps(job.details, ensure_ascii=False), 245 details=json.dumps(job.details, ensure_ascii=False),
234 lat=job.lat, lng=job.lng, content_hash=h, now=now, 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 if row is None: 253 if row is None:
237 con.execute( 254 con.execute(
@@ -242,7 +259,8 @@ def sync_source(con: sqlite3.Connection, source: str,
242 salary_year_max, salary_hour_min, salary_hour_max, benefits, 259 salary_year_max, salary_hour_min, salary_hour_max, benefits,
243 requirements, date_posted, date_deadline, category, ats, 260 requirements, date_posted, date_deadline, category, ats,
244 details, lat, lng, content_hash, first_seen, last_seen, 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 VALUES (:uid,:source,:external_id,:url,:employer,:title, 264 VALUES (:uid,:source,:external_id,:url,:employer,:title,
247 :description,:address,:city,:region,:postal_code, 265 :description,:address,:city,:region,:postal_code,
248 :location_label,:work_mode,:employment_type,:salary_min, 266 :location_label,:work_mode,:employment_type,:salary_min,
@@ -250,7 +268,8 @@ def sync_source(con: sqlite3.Connection, source: str,
250 :salary_year_max,:salary_hour_min,:salary_hour_max, 268 :salary_year_max,:salary_hour_min,:salary_hour_max,
251 :benefits,:requirements,:date_posted,:date_deadline, 269 :benefits,:requirements,:date_posted,:date_deadline,
252 :category,:ats,:details,:lat,:lng,:content_hash, 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 if job.salary_min is not None: # point de départ de l'historique 273 if job.salary_min is not None: # point de départ de l'historique
255 con.execute( 274 con.execute(
256 "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)" 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 date_posted=:date_posted, date_deadline=:date_deadline, 298 date_posted=:date_posted, date_deadline=:date_deadline,
280 category=:category, ats=:ats, details=:details, 299 category=:category, ats=:ats, details=:details,
281 lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), 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 content_hash=:content_hash, last_seen=:now, 304 content_hash=:content_hash, last_seen=:now,
283 updated_at=:now, miss_count=0, active=1 305 updated_at=:now, miss_count=0, active=1
284 WHERE uid=:uid""", params) 306 WHERE uid=:uid""", params)
@@ -344,6 +366,26 @@ def expire_past_deadline(con: sqlite3.Connection) -> int:
344 return cur.rowcount 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 def log_failure(con: sqlite3.Connection, source: str, message: str) -> None: 389 def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
348 con.execute( 390 con.execute(
349 "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)" 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 return None 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 def put_cached_detail(con: sqlite3.Connection, source: str, 430 def put_cached_detail(con: sqlite3.Connection, source: str,
373 external_id: str, key: str, payload: dict) -> None: 431 external_id: str, key: str, payload: dict) -> None:
374 con.execute( 432 con.execute(
modified jobka/ingest.py +9 −0
@@ -56,6 +56,15 @@ def run(sources: list[str] | None = None) -> list[dict]:
56 print(f"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)") 56 print(f"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)")
57 except Exception: 57 except Exception:
58 pass 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 # statistiques du planificateur SQLite (requêtes bbox de la carte) 68 # statistiques du planificateur SQLite (requêtes bbox de la carte)
60 try: 69 try:
61 con.execute("PRAGMA optimize") 70 con.execute("PRAGMA optimize")
modified jobka/normalize.py +234 −6
@@ -21,9 +21,11 @@ import unicodedata
21 21
22 __all__ = [ 22 __all__ = [
23 "strip_accents", "clean_text", "clean_html", "parse_salary", 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 "parse_employment_type", "parse_location", "canonical_city", 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 _BLOCK_TAGS = re.compile( 52 _BLOCK_TAGS = re.compile(
51 r"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|table|section|article)[^>]*>", re.I) 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 def clean_html(html: str) -> str: 62 def clean_html(html: str) -> str:
55 """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré. 63 """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré.
@@ -60,10 +68,16 @@ def clean_html(html: str) -> str:
60 if not html: 68 if not html:
61 return "" 69 return ""
62 import html as _html 70 import html as _html
63− s = _html.unescape(html) 71+ s = html
64− s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S) 72+ # Contenus sur-échappés (entités dans les entités — Greenhouse/Lever) :
65− s = _BLOCK_TAGS.sub("\n", s) 73+ # nettoyer en boucle tant qu'il reste des balises/entités (max 3 passes).
66− s = re.sub(r"<[^>]+>", " ", s) 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 s = s.replace("\xa0", " ").replace(" ", " ") 81 s = s.replace("\xa0", " ").replace(" ", " ")
68 s = re.sub(r"[ \t]+", " ", s) 82 s = re.sub(r"[ \t]+", " ", s)
69 s = re.sub(r" ?\n ?", "\n", s) 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 return round(y / _HOURS_PER_YEAR, 2) if y is not None else None 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 # Dates — ISO 8601, jamais de date inventée 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 def categorize(title: str, description: str = "") -> str: 730 def categorize(title: str, description: str = "") -> str:
503 """Titre -> catégorie interne (repli : début de description), ou "". 731 """Titre -> catégorie interne (repli : début de description), ou "".
504 732
added jobka/quality.py +82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added jobka/regions.py +87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
modified jobka/schema.py +35 −8
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
modified tests/test_normalize.py +149 −0
Large diff (153 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme