SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

Phase 2 — noyau : archivage des passés, statut, end_time, quarantaine, séances, villes MAMH

- Règle d ARCHIVAGE temporel : un événement terminé passe active=0 à chaque
  cycle (jamais supprimé) ; un passé archivé n est plus réactivé par un sync
  « unchanged » (backfill immédiat : 3 745 passés actifs → 0).
- Colonnes additives events.end_time (HH:MM), events.status
  (cancelled/postponed/soldout, NULL = prévu) et events.quarantine
  (raison, NULL = publiable) + migration auto à la connexion.
- dedup_key inclut désormais l HEURE de début : même titre/ville/jour à des
  heures différentes = séances distinctes (661 groupes → 259 ; les 11 séances
  d un même parcours redeviennent visibles). Deux fiches sans heure fusionnent
  toujours ; une fiche avec heure ne fusionne qu à heure égale (règle consignée
  dans schema.py).
- Quarantaine qualité : date passée aberrante (< 2000), début à plus de 2 ans,
  ville hors Québec — non publiée, comptée (/api/stats), réintégrable à chaque
  sync dès que la donnée redevient saine. L heure 00:00 est affichée « heure
  non confirmée » ; l image 404 n est pas vérifiée (coût réseau), le frontend
  retire les images cassées.
- Alerte « source qui meurt en silence » : événements FUTURS par source à
  chaque cycle (data/future_counts.json) — alerte si 0 futur ou chute > 80 %
  (le cas Laval : 41 runs verts, max(date)=2025-12-09).
- Géocodage par ADRESSE CIVIQUE prioritaire (Nominatim, même cache, 1 req/s) :
  Brossard 0,7 % → 94 % de GPS, Longueuil 0 % → 75 %.
- Villes normalisées au Répertoire MAMH (data/villes_canon.json, généré de
  MUN.csv + ARR.csv) : « Montreal » 375, « Quebec » 166, 75 graphies, 804
  événements backfillés — facettes ville unifiées.

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

9 changed files +406 −45

modified .gitignore +1 −0
@@ -16,3 +16,4 @@ data/sortika.db-shm
16 16 data/sortika.db-wal
17 17 data/sortika.db
18 18 data/*.bak-*
19 +data/future_counts.json
added data/villes_canon.json +1 −0
@@ -0,0 +1 @@
1 +{"_comment": "Sorti-Ka — noms canoniques (accentués) des municipalités et arrondissements du Québec, générés depuis le Répertoire des municipalités (MAMH, MUN.csv + ARR.csv, données ouvertes) le 2026-08-19. Clé = nom normalisé (minuscules, sans accents/tirets). Sert à normaliser l'affichage des villes (« Montreal » → « Montréal »).", "villes": {"abercorn": "Abercorn", "acton vale": "Acton Vale", "adstock": "Adstock", "aguanish": "Aguanish", "ahuntsic cartierville": "Ahuntsic-Cartierville", "akulivik": "Akulivik", "akwesasne": "Akwesasne", "albanel": "Albanel", "albertville": "Albertville", "alleyn et cawood": "Alleyn-et-Cawood", "alma": "Alma", "amherst": "Amherst", "amos": "Amos", "amqui": "Amqui", "ange gardien": "Ange-Gardien", "anjou": "Anjou", "armagh": "Armagh", "arundel": "Arundel", "ascot corner": "Ascot Corner", "aston jonction": "Aston-Jonction", "auclair": "Auclair", "audet": "Audet", "aumond": "Aumond", "aupaluk": "Aupaluk", "austin": "Austin", "authier": "Authier", "authier nord": "Authier-Nord", "ayer s cliff": "Ayer's Cliff", "baie atibenne": "Baie-Atibenne", "baie comeau": "Baie-Comeau", "baie d hudson": "Baie-d'Hudson", "baie d urfe": "Baie-D'Urfé", "baie de la bouteille": "Baie-de-la-Bouteille", "baie des chaloupes": "Baie-des-Chaloupes", "baie des sables": "Baie-des-Sables", "baie du febvre": "Baie-du-Febvre", "baie johan beetz": "Baie-Johan-Beetz", "baie obaoca": "Baie-Obaoca", "baie saint paul": "Baie-Saint-Paul", "baie sainte catherine": "Baie-Sainte-Catherine", "baie trinite": "Baie-Trinité", "barkmere": "Barkmere", "barnston ouest": "Barnston-Ouest", "barraute": "Barraute", "batiscan": "Batiscan", "beaconsfield": "Beaconsfield", "bearn": "Béarn", "beauceville": "Beauceville", "beauharnois": "Beauharnois", "beaulac garthby": "Beaulac-Garthby", "beaumont": "Beaumont", "beauport": "Beauport", "beaupre": "Beaupré", "becancour": "Bécancour", "bedford": "Bedford", "begin": "Bégin", "belcourt": "Belcourt", "belle riviere": "Belle-Rivière", "belleterre": "Belleterre", "beloeil": "Beloeil", "berry": "Berry", "berthier sur mer": "Berthier-sur-Mer", "berthierville": "Berthierville", "bethanie": "Béthanie", "biencourt": "Biencourt", "blainville": "Blainville", "blanc sablon": "Blanc-Sablon", "blue sea": "Blue Sea", "boileau": "Boileau", "bois des filion": "Bois-des-Filion", "bois franc": "Bois-Franc", "boisbriand": "Boisbriand", "boischatel": "Boischatel", "bolton est": "Bolton-Est", "bolton ouest": "Bolton-Ouest", "bonaventure": "Bonaventure", "bonne esperance": "Bonne-Espérance", "bonsecours": "Bonsecours", "boucherville": "Boucherville", "bouchette": "Bouchette", "bowman": "Bowman", "brebeuf": "Brébeuf", "brigham": "Brigham", "bristol": "Bristol", "brome": "Brome", "bromont": "Bromont", "brompton rock forest saint elie deauville": "Brompton--Rock Forest--Saint-Élie--Deauville", "brossard": "Brossard", "brownsburg chatham": "Brownsburg-Chatham", "bryson": "Bryson", "bury": "Bury", "cacouna": "Cacouna", "calixa lavallee": "Calixa-Lavallée", "calumet": "Calumet", "campbell s bay": "Campbell's Bay", "candiac": "Candiac", "caniapiscau": "Caniapiscau", "cantley": "Cantley", "cap chat": "Cap-Chat", "cap saint ignace": "Cap-Saint-Ignace", "cap sante": "Cap-Santé", "caplan": "Caplan", "carignan": "Carignan", "carleton sur mer": "Carleton-sur-Mer", "cascades malignes": "Cascades-Malignes", "cascapedia saint jules": "Cascapédia--Saint-Jules", "causapscal": "Causapscal", "cayamant": "Cayamant", "chambly": "Chambly", "chambord": "Chambord", "champlain": "Champlain", "champneuf": "Champneuf", "chandler": "Chandler", "chapais": "Chapais", "charette": "Charette", "charlemagne": "Charlemagne", "charlesbourg": "Charlesbourg", "chartierville": "Chartierville", "chateau richer": "Château-Richer", "chateauguay": "Châteauguay", "chazel": "Chazel", "chelsea": "Chelsea", "cheneville": "Chénéville", "chertsey": "Chertsey", "chesterville": "Chesterville", "chibougamau": "Chibougamau", "chichester": "Chichester", "chicoutimi": "Chicoutimi", "chisasibi": "Chisasibi", "chute aux outardes": "Chute-aux-Outardes", "chute saint philippe": "Chute-Saint-Philippe", "clarenceville": "Clarenceville", "clarendon": "Clarendon", "clermont": "Clermont", "clerval": "Clerval", "cleveland": "Cleveland", "cloridorme": "Cloridorme", "coaticook": "Coaticook", "collines du basque": "Collines-du-Basque", "colombier": "Colombier", "compton": "Compton", "contrecoeur": "Contrecoeur", "cookshire eaton": "Cookshire-Eaton", "cote des neiges notre dame de grace": "Côte-des-Neiges--Notre-Dame-de-Grâce", "cote nord du golfe du saint laurent": "Côte-Nord-du-Golfe-du-Saint-Laurent", "cote saint luc": "Côte-Saint-Luc", "coteau du lac": "Coteau-du-Lac", "coucoucache": "Coucoucache", "coulee des adolphe": "Coulée-des-Adolphe", "courcelles saint evariste": "Courcelles--Saint-Évariste", "cowansville": "Cowansville", "crabtree": "Crabtree", "danville": "Danville", "daveluyville": "Daveluyville", "degelis": "Dégelis", "deleage": "Déléage", "delson": "Delson", "denholm": "Denholm", "depot echouani": "Dépôt-Échouani", "desbiens": "Desbiens", "deschaillons sur saint laurent": "Deschaillons-sur-Saint-Laurent", "deschambault grondines": "Deschambault-Grondines", "desjardins": "Desjardins", "deux montagnes": "Deux-Montagnes", "disraeli": "Disraeli", "dixville": "Dixville", "dolbeau mistassini": "Dolbeau-Mistassini", "dollard des ormeaux": "Dollard-des-Ormeaux", "doncaster": "Doncaster", "donnacona": "Donnacona", "dorval": "Dorval", "dosquet": "Dosquet", "drummondville": "Drummondville", "dudswell": "Dudswell", "duhamel": "Duhamel", "duhamel ouest": "Duhamel-Ouest", "dundee": "Dundee", "dunham": "Dunham", "duparquet": "Duparquet", "dupuy": "Dupuy", "durham sud": "Durham-Sud", "east angus": "East Angus", "east broughton": "East Broughton", "east farnham": "East Farnham", "east hereford": "East Hereford", "eastmain": "Eastmain", "eastman": "Eastman", "eeyou istchee baie james": "Eeyou Istchee Baie-James", "egan sud": "Egan-Sud", "elgin": "Elgin", "entrelacs": "Entrelacs", "escuminac": "Escuminac", "esprit saint": "Esprit-Saint", "essipit": "Essipit", "esterel": "Estérel", "farnham": "Farnham", "fassett": "Fassett", "ferland et boilleau": "Ferland-et-Boilleau", "ferme neuve": "Ferme-Neuve", "fermont": "Fermont", "fleurimont": "Fleurimont", "forestville": "Forestville", "fort coulonge": "Fort-Coulonge", "fortierville": "Fortierville", "fossambault sur le lac": "Fossambault-sur-le-Lac", "frampton": "Frampton", "franklin": "Franklin", "franquelin": "Franquelin", "frelighsburg": "Frelighsburg", "frontenac": "Frontenac", "fugereville": "Fugèreville", "gallichan": "Gallichan", "gaspe": "Gaspé", "gatineau": "Gatineau", "gesgapegiag": "Gesgapegiag", "girardville": "Girardville", "godbout": "Godbout", "godmanchester": "Godmanchester", "gore": "Gore", "gracefield": "Gracefield", "granby": "Granby", "grand metis": "Grand-Métis", "grand remous": "Grand-Remous", "grand saint esprit": "Grand-Saint-Esprit", "grande riviere": "Grande-Rivière", "grande vallee": "Grande-Vallée", "grandes piles": "Grandes-Piles", "greenfield park": "Greenfield Park", "grenville": "Grenville", "grenville sur la rouge": "Grenville-sur-la-Rouge", "gros mecatina": "Gros-Mécatina", "grosse ile": "Grosse-Île", "grosses roches": "Grosses-Roches", "guerin": "Guérin", "ham nord": "Ham-Nord", "ham sud": "Ham-Sud", "hampden": "Hampden", "hampstead": "Hampstead", "harrington": "Harrington", "hatley": "Hatley", "havelock": "Havelock", "havre saint pierre": "Havre-Saint-Pierre", "hebertville": "Hébertville", "hemmingford": "Hemmingford", "henryville": "Henryville", "herouxville": "Hérouxville", "hinchinbrooke": "Hinchinbrooke", "honfleur": "Honfleur", "hope": "Hope", "hope town": "Hope Town", "howick": "Howick", "huberdeau": "Huberdeau", "hudson": "Hudson", "huntingdon": "Huntingdon", "inukjuak": "Inukjuak", "inverness": "Inverness", "irlande": "Irlande", "ivry sur le lac": "Ivry-sur-le-Lac", "ivujivik": "Ivujivik", "joliette": "Joliette", "jonquiere": "Jonquière", "kahnawake": "Kahnawake", "kamouraska": "Kamouraska", "kangiqsualujjuaq": "Kangiqsualujjuaq", "kangiqsujuaq": "Kangiqsujuaq", "kangirsuk": "Kangirsuk", "kataskomiq": "Kataskomiq", "kawawachikamach": "Kawawachikamach", "kazabazua": "Kazabazua", "kebaowek": "Kebaowek", "kiamika": "Kiamika", "kingsbury": "Kingsbury", "kingsey falls": "Kingsey Falls", "kinnear s mills": "Kinnear's Mills", "kipawa": "Kipawa", "kirkland": "Kirkland", "kitigan zibi": "Kitigan Zibi", "kuujjuaq": "Kuujjuaq", "kuujjuarapik": "Kuujjuarapik", "l ancienne lorette": "L'Ancienne-Lorette", "l ange gardien": "L'Ange-Gardien", "l anse saint jean": "L'Anse-Saint-Jean", "l ascension": "L'Ascension", "l ascension de notre seigneur": "L'Ascension-de-Notre-Seigneur", "l ascension de patapedia": "L'Ascension-de-Patapédia", "l assomption": "L'Assomption", "l avenir": "L'Avenir", "l epiphanie": "L'Épiphanie", "l ile bizard sainte genevieve": "L'Île-Bizard--Sainte-Geneviève", "l ile cadieux": "L'Île-Cadieux", "l ile d anticosti": "L'Île-d'Anticosti", "l ile dorval": "L'Île-Dorval", "l ile du grand calumet": "L'Île-du-Grand-Calumet", "l ile perrot": "L'Île-Perrot", "l isle aux allumettes": "L'Isle-aux-Allumettes", "l isle aux coudres": "L'Isle-aux-Coudres", "l isle verte": "L'Isle-Verte", "l islet": "L'Islet", "la baie": "La Baie", "la bostonnais": "La Bostonnais", "la cite limoilou": "La Cité-Limoilou", "la conception": "La Conception", "la corne": "La Corne", "la dore": "La Doré", "la durantaye": "La Durantaye", "la guadeloupe": "La Guadeloupe", "la haute saint charles": "La Haute-Saint-Charles", "la macaza": "La Macaza", "la malbaie": "La Malbaie", "la martre": "La Martre", "la minerve": "La Minerve", "la morandiere rochebaucourt": "La Morandière-Rochebaucourt", "la motte": "La Motte", "la patrie": "La Patrie", "la peche": "La Pêche", "la pocatiere": "La Pocatière", "la prairie": "La Prairie", "la presentation": "La Présentation", "la redemption": "La Rédemption", "la reine": "La Reine", "la romaine": "La Romaine", "la sarre": "La Sarre", "la trinite des monts": "La Trinité-des-Monts", "la tuque": "La Tuque", "la visitation de l ile dupas": "La Visitation-de-l'Île-Dupas", "la visitation de yamaska": "La Visitation-de-Yamaska", "labelle": "Labelle", "labrecque": "Labrecque", "lac a la croix": "Lac-à-la-Croix", "lac achouakan": "Lac-Achouakan", "lac akonapwehikan": "Lac-Akonapwehikan", "lac alfred": "Lac-Alfred", "lac ashuapmushuan": "Lac-Ashuapmushuan", "lac au brochet": "Lac-au-Brochet", "lac au saumon": "Lac-au-Saumon", "lac aux sables": "Lac-aux-Sables", "lac bazinet": "Lac-Bazinet", "lac beauport": "Lac-Beauport", "lac blanc": "Lac-Blanc", "lac boisbouscache": "Lac-Boisbouscache", "lac bouchette": "Lac-Bouchette", "lac boule": "Lac-Boulé", "lac brome": "Lac-Brome", "lac cabasta": "Lac-Cabasta", "lac casault": "Lac-Casault", "lac chicobi": "Lac-Chicobi", "lac croche": "Lac-Croche", "lac de la bidiere": "Lac-De La Bidière", "lac de la maison de pierre": "Lac-de-la-Maison-de-Pierre", "lac de la pomme": "Lac-de-la-Pomme", "lac delage": "Lac-Delage", "lac des aigles": "Lac-des-Aigles", "lac des dix milles": "Lac-des-Dix-Milles", "lac des eaux mortes": "Lac-des-Eaux-Mortes", "lac des ecorces": "Lac-des-Écorces", "lac des plages": "Lac-des-Plages", "lac des seize iles": "Lac-des-Seize-Îles", "lac despinassy": "Lac-Despinassy", "lac devenyns": "Lac-Devenyns", "lac douaire": "Lac-Douaire", "lac drolet": "Lac-Drolet", "lac du cerf": "Lac-du-Cerf", "lac du taureau": "Lac-du-Taureau", "lac duparquet": "Lac-Duparquet", "lac edouard": "Lac-Édouard", "lac ernest": "Lac-Ernest", "lac etchemin": "Lac-Etchemin", "lac frontiere": "Lac-Frontière", "lac granet": "Lac-Granet", "lac huron": "Lac-Huron", "lac jacques cartier": "Lac-Jacques-Cartier", "lac jerome": "Lac-Jérôme", "lac john": "Lac-John", "lac juillet": "Lac-Juillet", "lac lapeyrere": "Lac-Lapeyrère", "lac legendre": "Lac-Legendre", "lac lenotre": "Lac-Lenôtre", "lac marguerite": "Lac-Marguerite", "lac masketsi": "Lac-Masketsi", "lac matapedia": "Lac-Matapédia", "lac matawin": "Lac-Matawin", "lac megantic": "Lac-Mégantic", "lac metei": "Lac-Metei", "lac minaki": "Lac-Minaki", "lac ministuk": "Lac-Ministuk", "lac moncouche": "Lac-Moncouche", "lac moselle": "Lac-Moselle", "lac nilgaut": "Lac-Nilgaut", "lac normand": "Lac-Normand", "lac oscar": "Lac-Oscar", "lac pikauba": "Lac-Pikauba", "lac poulin": "Lac-Poulin", "lac pythonga": "Lac-Pythonga", "lac rapide": "Lac-Rapide", "lac saguay": "Lac-Saguay", "lac saint joseph": "Lac-Saint-Joseph", "lac saint paul": "Lac-Saint-Paul", "lac sainte marie": "Lac-Sainte-Marie", "lac sante": "Lac-Santé", "lac sergent": "Lac-Sergent", "lac simon": "Lac-Simon", "lac superieur": "Lac-Supérieur", "lac tremblant nord": "Lac-Tremblant-Nord", "lac vacher": "Lac-Vacher", "lac wagwabika": "Lac-Wagwabika", "lac walker": "Lac-Walker", "lachine": "Lachine", "lachute": "Lachute", "lacolle": "Lacolle", "laforce": "Laforce", "lalemant": "Lalemant", "lamarche": "Lamarche", "lambton": "Lambton", "landrienne": "Landrienne", "laniel": "Laniel", "lanoraie": "Lanoraie", "lantier": "Lantier", "larouche": "Larouche", "lasalle": "LaSalle", "latulipe et gaboury": "Latulipe-et-Gaboury", "launay": "Launay", "laurier station": "Laurier-Station", "laurierville": "Laurierville", "laval": "Laval", "lavaltrie": "Lavaltrie", "laverlochere angliers": "Laverlochère-Angliers", "lawrenceville": "Lawrenceville", "le plateau mont royal": "Le Plateau-Mont-Royal", "le sud ouest": "Le Sud-Ouest", "le vieux longueuil": "Le Vieux-Longueuil", "lebel sur quevillon": "Lebel-sur-Quévillon", "leclercville": "Leclercville", "lefebvre": "Lefebvre", "lejeune": "Lejeune", "lemieux": "Lemieux", "lennoxville": "Lennoxville", "lery": "Léry", "les bergeronnes": "Les Bergeronnes", "les cedres": "Les Cèdres", "les chutes de la chaudiere est": "Les Chutes-de-la-Chaudière-Est", "les chutes de la chaudiere ouest": "Les Chutes-de-la-Chaudière-Ouest", "les coteaux": "Les Coteaux", "les eboulements": "Les Éboulements", "les escoumins": "Les Escoumins", "les hauteurs": "Les Hauteurs", "les iles de la madeleine": "Les Îles-de-la-Madeleine", "les lacs du temiscamingue": "Les Lacs-du-Témiscamingue", "les mechins": "Les Méchins", "les nations": "Les Nations", "les rivieres": "Les Rivières", "levis": "Lévis", "lingwick": "Lingwick", "linton": "Linton", "listuguj": "Listuguj", "litchfield": "Litchfield", "lochaber": "Lochaber", "lochaber partie ouest": "Lochaber-Partie-Ouest", "longue pointe de mingan": "Longue-Pointe-de-Mingan", "longue rive": "Longue-Rive", "longueuil": "Longueuil", "lorraine": "Lorraine", "lorrainville": "Lorrainville", "lotbiniere": "Lotbinière", "louiseville": "Louiseville", "low": "Low", "lyster": "Lyster", "macamic": "Macamic", "macnider": "MacNider", "maddington falls": "Maddington Falls", "magog": "Magog", "malartic": "Malartic", "maliotenam": "Maliotenam", "manawan": "Manawan", "mandeville": "Mandeville", "maniwaki": "Maniwaki", "manseau": "Manseau", "mansfield et pontefract": "Mansfield-et-Pontefract", "maria": "Maria", "maricourt": "Maricourt", "marieville": "Marieville", "marsoui": "Marsoui", "marston": "Marston", "martinville": "Martinville", "mascouche": "Mascouche", "mashteuiatsh": "Mashteuiatsh", "maskinonge": "Maskinongé", "massueville": "Massueville", "matagami": "Matagami", "matane": "Matane", "matapedia": "Matapédia", "matchi manitou": "Matchi-Manitou", "matimekosh": "Matimekosh", "mayo": "Mayo", "mcmasterville": "McMasterville", "melbourne": "Melbourne", "mercier": "Mercier", "mercier hochelaga maisonneuve": "Mercier--Hochelaga-Maisonneuve", "messines": "Messines", "metabetchouan lac a la croix": "Métabetchouan--Lac-à-la-Croix", "metis sur mer": "Métis-sur-Mer", "milan": "Milan", "mille isles": "Mille-Isles", "mingan": "Mingan", "mirabel": "Mirabel", "mistissini": "Mistissini", "moffet": "Moffet", "mont albert": "Mont-Albert", "mont alexandre": "Mont-Alexandre", "mont apica": "Mont-Apica", "mont blanc": "Mont-Blanc", "mont carmel": "Mont-Carmel", "mont elie": "Mont-Élie", "mont joli": "Mont-Joli", "mont laurier": "Mont-Laurier", "mont royal": "Mont-Royal", "mont saint gregoire": "Mont-Saint-Grégoire", "mont saint hilaire": "Mont-Saint-Hilaire", "mont saint michel": "Mont-Saint-Michel", "mont saint pierre": "Mont-Saint-Pierre", "mont tremblant": "Mont-Tremblant", "mont valin": "Mont-Valin", "montcalm": "Montcalm", "montcerf lytton": "Montcerf-Lytton", "montebello": "Montebello", "montmagny": "Montmagny", "montpellier": "Montpellier", "montreal": "Montréal", "montreal est": "Montréal-Est", "montreal nord": "Montréal-Nord", "montreal ouest": "Montréal-Ouest", "morin heights": "Morin-Heights", "mulgrave et derry": "Mulgrave-et-Derry", "murdochville": "Murdochville", "namur": "Namur", "nantes": "Nantes", "napierville": "Napierville", "natashquan": "Natashquan", "nedelec": "Nédélec", "nemaska": "Nemaska", "neuville": "Neuville", "new carlisle": "New Carlisle", "new richmond": "New Richmond", "newport": "Newport", "nicolet": "Nicolet", "nominingue": "Nominingue", "normandin": "Normandin", "normetal": "Normétal", "north hatley": "North Hatley", "notre dame auxiliatrice de buckland": "Notre-Dame-Auxiliatrice-de-Buckland", "notre dame de bonsecours": "Notre-Dame-de-Bonsecours", "notre dame de ham": "Notre-Dame-de-Ham", "notre dame de l ile perrot": "Notre-Dame-de-l'Île-Perrot", "notre dame de la merci": "Notre-Dame-de-la-Merci", "notre dame de la paix": "Notre-Dame-de-la-Paix", "notre dame de la salette": "Notre-Dame-de-la-Salette", "notre dame de lorette": "Notre-Dame-de-Lorette", "notre dame de lourdes": "Notre-Dame-de-Lourdes", "notre dame de montauban": "Notre-Dame-de-Montauban", "notre dame de pontmain": "Notre-Dame-de-Pontmain", "notre dame de stanbridge": "Notre-Dame-de-Stanbridge", "notre dame des anges": "Notre-Dame-des-Anges", "notre dame des bois": "Notre-Dame-des-Bois", "notre dame des monts": "Notre-Dame-des-Monts", "notre dame des neiges": "Notre-Dame-des-Neiges", "notre dame des pins": "Notre-Dame-des-Pins", "notre dame des prairies": "Notre-Dame-des-Prairies", "notre dame des sept douleurs": "Notre-Dame-des-Sept-Douleurs", "notre dame du bon conseil": "Notre-Dame-du-Bon-Conseil", "notre dame du laus": "Notre-Dame-du-Laus", "notre dame du mont carmel": "Notre-Dame-du-Mont-Carmel", "notre dame du nord": "Notre-Dame-du-Nord", "notre dame du portage": "Notre-Dame-du-Portage", "notre dame du rosaire": "Notre-Dame-du-Rosaire", "notre dame du sacre coeur d issoudun": "Notre-Dame-du-Sacré-Coeur-d'Issoudun", "nouvelle": "Nouvelle", "noyan": "Noyan", "obedjiwan": "Obedjiwan", "odanak": "Odanak", "ogden": "Ogden", "oka": "Oka", "orford": "Orford", "ormstown": "Ormstown", "otter lake": "Otter Lake", "otterburn park": "Otterburn Park", "ouje bougoumou": "Oujé-Bougoumou", "outremont": "Outremont", "packington": "Packington", "padoue": "Padoue", "palmarolle": "Palmarolle", "papineauville": "Papineauville", "parisville": "Parisville", "paspebiac": "Paspébiac", "passes dangereuses": "Passes-Dangereuses", "perce": "Percé", "peribonka": "Péribonka", "pessamit": "Pessamit", "petit lac sainte anne": "Petit-Lac-Sainte-Anne", "petit mecatina": "Petit-Mécatina", "petit saguenay": "Petit-Saguenay", "petite riviere saint francois": "Petite-Rivière-Saint-François", "petite vallee": "Petite-Vallée", "picard": "Picard", "piedmont": "Piedmont", "pierrefonds roxboro": "Pierrefonds-Roxboro", "pierreville": "Pierreville", "pike river": "Pike River", "pikogan": "Pikogan", "pincourt": "Pincourt", "piopolis": "Piopolis", "plaisance": "Plaisance", "plessisville": "Plessisville", "pohenegamook": "Pohénégamook", "pointe a la croix": "Pointe-à-la-Croix", "pointe aux outardes": "Pointe-aux-Outardes", "pointe calumet": "Pointe-Calumet", "pointe claire": "Pointe-Claire", "pointe des cascades": "Pointe-des-Cascades", "pointe fortune": "Pointe-Fortune", "pointe lebel": "Pointe-Lebel", "pont rouge": "Pont-Rouge", "pontiac": "Pontiac", "port cartier": "Port-Cartier", "port daniel gascons": "Port-Daniel--Gascons", "portage du fort": "Portage-du-Fort", "portneuf": "Portneuf", "portneuf sur mer": "Portneuf-sur-Mer", "potton": "Potton", "poularies": "Poularies", "preissac": "Preissac", "prevost": "Prévost", "price": "Price", "princeville": "Princeville", "puvirnituq": "Puvirnituq", "quaqtaq": "Quaqtaq", "quebec": "Québec", "racine": "Racine", "ragueneau": "Ragueneau", "rapide danseur": "Rapide-Danseur", "rapides des joachims": "Rapides-des-Joachims", "rawdon": "Rawdon", "remigny": "Rémigny", "repentigny": "Repentigny", "reservoir dozois": "Réservoir-Dozois", "richelieu": "Richelieu", "richmond": "Richmond", "rigaud": "Rigaud", "rimouski": "Rimouski", "ripon": "Ripon", "ristigouche sud est": "Ristigouche-Sud-Est", "riviere a claude": "Rivière-à-Claude", "riviere a pierre": "Rivière-à-Pierre", "riviere au tonnerre": "Rivière-au-Tonnerre", "riviere aux outardes": "Rivière-aux-Outardes", "riviere beaudette": "Rivière-Beaudette", "riviere bleue": "Rivière-Bleue", "riviere bonaventure": "Rivière-Bonaventure", "riviere bonjour": "Rivière-Bonjour", "riviere de la savane": "Rivière-de-la-Savane", "riviere des prairies pointe aux trembles": "Rivière-des-Prairies--Pointe-aux-Trembles", "riviere du loup": "Rivière-du-Loup", "riviere eternite": "Rivière-Éternité", "riviere heva": "Rivière-Héva", "riviere koksoak": "Rivière-Koksoak", "riviere mistassini": "Rivière-Mistassini", "riviere mouchalagane": "Rivière-Mouchalagane", "riviere nipissis": "Rivière-Nipissis", "riviere nouvelle": "Rivière-Nouvelle", "riviere ojima": "Rivière-Ojima", "riviere ouelle": "Rivière-Ouelle", "riviere patapedia est": "Rivière-Patapédia-Est", "riviere rouge": "Rivière-Rouge", "riviere saint jean": "Rivière-Saint-Jean", "riviere vaseuse": "Rivière-Vaseuse", "roberval": "Roberval", "roquemaure": "Roquemaure", "rosemere": "Rosemère", "rosemont la petite patrie": "Rosemont--La Petite-Patrie", "rougemont": "Rougemont", "routhierville": "Routhierville", "rouyn noranda": "Rouyn-Noranda", "roxton": "Roxton", "roxton falls": "Roxton Falls", "roxton pond": "Roxton Pond", "ruisseau des mineurs": "Ruisseau-des-Mineurs", "ruisseau ferguson": "Ruisseau-Ferguson", "sacre coeur": "Sacré-Coeur", "sacre coeur de jesus": "Sacré-Coeur-de-Jésus", "sagard": "Sagard", "saguenay": "Saguenay", "saint adalbert": "Saint-Adalbert", "saint adelme": "Saint-Adelme", "saint adelphe": "Saint-Adelphe", "saint adolphe d howard": "Saint-Adolphe-d'Howard", "saint adrien": "Saint-Adrien", "saint adrien d irlande": "Saint-Adrien-d'Irlande", "saint agapit": "Saint-Agapit", "saint aime": "Saint-Aimé", "saint aime des lacs": "Saint-Aimé-des-Lacs", "saint aime du lac des iles": "Saint-Aimé-du-Lac-des-Îles", "saint alban": "Saint-Alban", "saint albert": "Saint-Albert", "saint alexandre": "Saint-Alexandre", "saint alexandre de kamouraska": "Saint-Alexandre-de-Kamouraska", "saint alexandre des lacs": "Saint-Alexandre-des-Lacs", "saint alexis": "Saint-Alexis", "saint alexis de matapedia": "Saint-Alexis-de-Matapédia", "saint alexis des monts": "Saint-Alexis-des-Monts", "saint alfred": "Saint-Alfred", "saint alphonse": "Saint-Alphonse", "saint alphonse de granby": "Saint-Alphonse-de-Granby", "saint alphonse rodriguez": "Saint-Alphonse-Rodriguez", "saint amable": "Saint-Amable", "saint ambroise": "Saint-Ambroise", "saint ambroise de kildare": "Saint-Ambroise-de-Kildare", "saint anaclet de lessard": "Saint-Anaclet-de-Lessard", "saint andre avellin": "Saint-André-Avellin", "saint andre d argenteuil": "Saint-André-d'Argenteuil", "saint andre de kamouraska": "Saint-André-de-Kamouraska", "saint andre de restigouche": "Saint-André-de-Restigouche", "saint andre du lac saint jean": "Saint-André-du-Lac-Saint-Jean", "saint anicet": "Saint-Anicet", "saint anselme": "Saint-Anselme", "saint antoine de l isle aux grues": "Saint-Antoine-de-l'Isle-aux-Grues", "saint antoine de tilly": "Saint-Antoine-de-Tilly", "saint antoine sur richelieu": "Saint-Antoine-sur-Richelieu", "saint antonin": "Saint-Antonin", "saint apollinaire": "Saint-Apollinaire", "saint armand": "Saint-Armand", "saint arsene": "Saint-Arsène", "saint athanase": "Saint-Athanase", "saint aubert": "Saint-Aubert", "saint augustin": "Saint-Augustin", "saint augustin de desmaures": "Saint-Augustin-de-Desmaures", "saint augustin de woburn": "Saint-Augustin-de-Woburn", "saint barnabe": "Saint-Barnabé", "saint barnabe sud": "Saint-Barnabé-Sud", "saint barthelemy": "Saint-Barthélemy", "saint basile": "Saint-Basile", "saint basile le grand": "Saint-Basile-le-Grand", "saint benjamin": "Saint-Benjamin", "saint benoit du lac": "Saint-Benoît-du-Lac", "saint benoit labre": "Saint-Benoît-Labre", "saint bernard": "Saint-Bernard", "saint bernard de lacolle": "Saint-Bernard-de-Lacolle", "saint bernard de michaudville": "Saint-Bernard-de-Michaudville", "saint blaise sur richelieu": "Saint-Blaise-sur-Richelieu", "saint bonaventure": "Saint-Bonaventure", "saint boniface": "Saint-Boniface", "saint bruno de guigues": "Saint-Bruno-de-Guigues", "saint bruno de kamouraska": "Saint-Bruno-de-Kamouraska", "saint bruno de montarville": "Saint-Bruno-de-Montarville", "saint calixte": "Saint-Calixte", "saint camille": "Saint-Camille", "saint camille de lellis": "Saint-Camille-de-Lellis", "saint casimir": "Saint-Casimir", "saint celestin": "Saint-Célestin", "saint cesaire": "Saint-Césaire", "saint charles borromee": "Saint-Charles-Borromée", "saint charles de bellechasse": "Saint-Charles-de-Bellechasse", "saint charles de bourget": "Saint-Charles-de-Bourget", "saint charles garnier": "Saint-Charles-Garnier", "saint charles sur richelieu": "Saint-Charles-sur-Richelieu", "saint christophe d arthabaska": "Saint-Christophe-d'Arthabaska", "saint chrysostome": "Saint-Chrysostome", "saint claude": "Saint-Claude", "saint clement": "Saint-Clément", "saint cleophas": "Saint-Cléophas", "saint cleophas de brandon": "Saint-Cléophas-de-Brandon", "saint clet": "Saint-Clet", "saint colomban": "Saint-Colomban", "saint come": "Saint-Côme", "saint come liniere": "Saint-Côme--Linière", "saint constant": "Saint-Constant", "saint cuthbert": "Saint-Cuthbert", "saint cyprien": "Saint-Cyprien", "saint cyprien de napierville": "Saint-Cyprien-de-Napierville", "saint cyrille de lessard": "Saint-Cyrille-de-Lessard", "saint cyrille de wendover": "Saint-Cyrille-de-Wendover", "saint damase": "Saint-Damase", "saint damase de l islet": "Saint-Damase-de-L'Islet", "saint damien": "Saint-Damien", "saint damien de buckland": "Saint-Damien-de-Buckland", "saint david": "Saint-David", "saint david de falardeau": "Saint-David-de-Falardeau", "saint denis de brompton": "Saint-Denis-de-Brompton", "saint denis de la bouteillerie": "Saint-Denis-De La Bouteillerie", "saint denis sur richelieu": "Saint-Denis-sur-Richelieu", "saint didace": "Saint-Didace", "saint dominique": "Saint-Dominique", "saint dominique du rosaire": "Saint-Dominique-du-Rosaire", "saint donat": "Saint-Donat", "saint edmond de grantham": "Saint-Edmond-de-Grantham", "saint edmond les plaines": "Saint-Edmond-les-Plaines", "saint edouard": "Saint-Édouard", "saint edouard de fabre": "Saint-Édouard-de-Fabre", "saint edouard de lotbiniere": "Saint-Édouard-de-Lotbinière", "saint edouard de maskinonge": "Saint-Édouard-de-Maskinongé", "saint elie de caxton": "Saint-Élie-de-Caxton", "saint eloi": "Saint-Éloi", "saint elphege": "Saint-Elphège", "saint elzear": "Saint-Elzéar", "saint elzear de temiscouata": "Saint-Elzéar-de-Témiscouata", "saint emile de suffolk": "Saint-Émile-de-Suffolk", "saint ephrem de beauce": "Saint-Éphrem-de-Beauce", "saint epiphane": "Saint-Épiphane", "saint esprit": "Saint-Esprit", "saint etienne de beauharnois": "Saint-Étienne-de-Beauharnois", "saint etienne de bolton": "Saint-Étienne-de-Bolton", "saint etienne des gres": "Saint-Étienne-des-Grès", "saint eugene": "Saint-Eugène", "saint eugene d argentenay": "Saint-Eugène-d'Argentenay", "saint eugene de guigues": "Saint-Eugène-de-Guigues", "saint eugene de ladriere": "Saint-Eugène-de-Ladrière", "saint eusebe": "Saint-Eusèbe", "saint eustache": "Saint-Eustache", "saint fabien": "Saint-Fabien", "saint fabien de panet": "Saint-Fabien-de-Panet", "saint felicien": "Saint-Félicien", "saint felix d otis": "Saint-Félix-d'Otis", "saint felix de kingsey": "Saint-Félix-de-Kingsey", "saint felix de valois": "Saint-Félix-de-Valois", "saint ferdinand": "Saint-Ferdinand", "saint ferreol les neiges": "Saint-Ferréol-les-Neiges", "saint flavien": "Saint-Flavien", "saint fortunat": "Saint-Fortunat", "saint francois d assise": "Saint-François-d'Assise", "saint francois de l ile d orleans": "Saint-François-de-l'Île-d'Orléans", "saint francois de la riviere du sud": "Saint-François-de-la-Rivière-du-Sud", "saint francois de sales": "Saint-François-de-Sales", "saint francois du lac": "Saint-François-du-Lac", "saint francois xavier de brompton": "Saint-François-Xavier-de-Brompton", "saint francois xavier de viger": "Saint-François-Xavier-de-Viger", "saint frederic": "Saint-Frédéric", "saint fulgence": "Saint-Fulgence", "saint gabriel": "Saint-Gabriel", "saint gabriel de brandon": "Saint-Gabriel-de-Brandon", "saint gabriel de rimouski": "Saint-Gabriel-de-Rimouski", "saint gabriel de valcartier": "Saint-Gabriel-de-Valcartier", "saint gabriel lalemant": "Saint-Gabriel-Lalemant", "saint gedeon": "Saint-Gédéon", "saint gedeon de beauce": "Saint-Gédéon-de-Beauce", "saint georges": "Saint-Georges", "saint georges de windsor": "Saint-Georges-de-Windsor", "saint gerard majella": "Saint-Gérard-Majella", "saint germain de grantham": "Saint-Germain-de-Grantham", "saint germain de kamouraska": "Saint-Germain-de-Kamouraska", "saint gervais": "Saint-Gervais", "saint gilbert": "Saint-Gilbert", "saint gilles": "Saint-Gilles", "saint godefroi": "Saint-Godefroi", "saint guillaume": "Saint-Guillaume", "saint guillaume nord": "Saint-Guillaume-Nord", "saint henri": "Saint-Henri", "saint henri de taillon": "Saint-Henri-de-Taillon", "saint hermenegilde": "Saint-Herménégilde", "saint hilaire de dorset": "Saint-Hilaire-de-Dorset", "saint hilarion": "Saint-Hilarion", "saint hippolyte": "Saint-Hippolyte", "saint honore": "Saint-Honoré", "saint honore de shenley": "Saint-Honoré-de-Shenley", "saint honore de temiscouata": "Saint-Honoré-de-Témiscouata", "saint hubert": "Saint-Hubert", "saint hubert de riviere du loup": "Saint-Hubert-de-Rivière-du-Loup", "saint hugues": "Saint-Hugues", "saint hyacinthe": "Saint-Hyacinthe", "saint ignace de loyola": "Saint-Ignace-de-Loyola", "saint ignace de stanbridge": "Saint-Ignace-de-Stanbridge", "saint irenee": "Saint-Irénée", "saint isidore": "Saint-Isidore", "saint isidore de clifton": "Saint-Isidore-de-Clifton", "saint jacques": "Saint-Jacques", "saint jacques de leeds": "Saint-Jacques-de-Leeds", "saint jacques le majeur de wolfestown": "Saint-Jacques-le-Majeur-de-Wolfestown", "saint jacques le mineur": "Saint-Jacques-le-Mineur", "saint janvier de joly": "Saint-Janvier-de-Joly", "saint jean baptiste": "Saint-Jean-Baptiste", "saint jean de brebeuf": "Saint-Jean-de-Brébeuf", "saint jean de cherbourg": "Saint-Jean-de-Cherbourg", "saint jean de dieu": "Saint-Jean-de-Dieu", "saint jean de l ile d orleans": "Saint-Jean-de-l'Île-d'Orléans", "saint jean de la lande": "Saint-Jean-de-la-Lande", "saint jean de matha": "Saint-Jean-de-Matha", "saint jean port joli": "Saint-Jean-Port-Joli", "saint jean sur richelieu": "Saint-Jean-sur-Richelieu", "saint jerome": "Saint-Jérôme", "saint joachim": "Saint-Joachim", "saint joachim de shefford": "Saint-Joachim-de-Shefford", "saint joseph de beauce": "Saint-Joseph-de-Beauce", "saint joseph de coleraine": "Saint-Joseph-de-Coleraine", "saint joseph de kamouraska": "Saint-Joseph-de-Kamouraska", "saint joseph de lepage": "Saint-Joseph-de-Lepage", "saint joseph de sorel": "Saint-Joseph-de-Sorel", "saint joseph des erables": "Saint-Joseph-des-Érables", "saint joseph du lac": "Saint-Joseph-du-Lac", "saint jude": "Saint-Jude", "saint jules": "Saint-Jules", "saint julien": "Saint-Julien", "saint just de bretenieres": "Saint-Just-de-Bretenières", "saint juste du lac": "Saint-Juste-du-Lac", "saint justin": "Saint-Justin", "saint lambert": "Saint-Lambert", "saint lambert de lauzon": "Saint-Lambert-de-Lauzon", "saint laurent": "Saint-Laurent", "saint laurent de l ile d orleans": "Saint-Laurent-de-l'Île-d'Orléans", "saint lazare": "Saint-Lazare", "saint lazare de bellechasse": "Saint-Lazare-de-Bellechasse", "saint leandre": "Saint-Léandre", "saint leon de standon": "Saint-Léon-de-Standon", "saint leon le grand": "Saint-Léon-le-Grand", "saint leonard": "Saint-Léonard", "saint leonard d aston": "Saint-Léonard-d'Aston", "saint leonard de portneuf": "Saint-Léonard-de-Portneuf", "saint liboire": "Saint-Liboire", "saint liguori": "Saint-Liguori", "saint lin laurentides": "Saint-Lin--Laurentides", "saint louis": "Saint-Louis", "saint louis de blandford": "Saint-Louis-de-Blandford", "saint louis de gonzague": "Saint-Louis-de-Gonzague", "saint louis de gonzague du cap tourmente": "Saint-Louis-de-Gonzague-du-Cap-Tourmente", "saint louis du ha! ha!": "Saint-Louis-du-Ha! Ha!", "saint luc de bellechasse": "Saint-Luc-de-Bellechasse", "saint luc de vincennes": "Saint-Luc-de-Vincennes", "saint lucien": "Saint-Lucien", "saint ludger": "Saint-Ludger", "saint ludger de milot": "Saint-Ludger-de-Milot", "saint magloire": "Saint-Magloire", "saint majorique de grantham": "Saint-Majorique-de-Grantham", "saint malachie": "Saint-Malachie", "saint malo": "Saint-Malo", "saint marc de figuery": "Saint-Marc-de-Figuery", "saint marc des carrieres": "Saint-Marc-des-Carrières", "saint marc du lac long": "Saint-Marc-du-Lac-Long", "saint marc sur richelieu": "Saint-Marc-sur-Richelieu", "saint marcel": "Saint-Marcel", "saint marcel de richelieu": "Saint-Marcel-de-Richelieu", "saint marcellin": "Saint-Marcellin", "saint martin": "Saint-Martin", "saint mathias sur richelieu": "Saint-Mathias-sur-Richelieu", "saint mathieu": "Saint-Mathieu", "saint mathieu d harricana": "Saint-Mathieu-d'Harricana", "saint mathieu de beloeil": "Saint-Mathieu-de-Beloeil", "saint mathieu de rioux": "Saint-Mathieu-de-Rioux", "saint mathieu du parc": "Saint-Mathieu-du-Parc", "saint maurice": "Saint-Maurice", "saint maxime du mont louis": "Saint-Maxime-du-Mont-Louis", "saint medard": "Saint-Médard", "saint michel": "Saint-Michel", "saint michel de bellechasse": "Saint-Michel-de-Bellechasse", "saint michel des saints": "Saint-Michel-des-Saints", "saint michel du squatec": "Saint-Michel-du-Squatec", "saint modeste": "Saint-Modeste", "saint moise": "Saint-Moïse", "saint narcisse": "Saint-Narcisse", "saint narcisse de beaurivage": "Saint-Narcisse-de-Beaurivage", "saint narcisse de rimouski": "Saint-Narcisse-de-Rimouski", "saint nazaire": "Saint-Nazaire", "saint nazaire d acton": "Saint-Nazaire-d'Acton", "saint nazaire de dorchester": "Saint-Nazaire-de-Dorchester", "saint neree de bellechasse": "Saint-Nérée-de-Bellechasse", "saint noel": "Saint-Noël", "saint norbert": "Saint-Norbert", "saint norbert d arthabaska": "Saint-Norbert-d'Arthabaska", "saint octave de metis": "Saint-Octave-de-Métis", "saint odilon de cranbourne": "Saint-Odilon-de-Cranbourne", "saint omer": "Saint-Omer", "saint ours": "Saint-Ours", "saint pacome": "Saint-Pacôme", "saint pamphile": "Saint-Pamphile", "saint pascal": "Saint-Pascal", "saint patrice de beaurivage": "Saint-Patrice-de-Beaurivage", "saint patrice de sherrington": "Saint-Patrice-de-Sherrington", "saint paul": "Saint-Paul", "saint paul d abbotsford": "Saint-Paul-d'Abbotsford", "saint paul de l ile aux noix": "Saint-Paul-de-l'Île-aux-Noix", "saint paul de la croix": "Saint-Paul-de-la-Croix", "saint paul de montminy": "Saint-Paul-de-Montminy", "saint paulin": "Saint-Paulin", "saint philemon": "Saint-Philémon", "saint philibert": "Saint-Philibert", "saint philippe": "Saint-Philippe", "saint philippe de neri": "Saint-Philippe-de-Néri", "saint pie": "Saint-Pie", "saint pie de guire": "Saint-Pie-de-Guire", "saint pierre": "Saint-Pierre", "saint pierre baptiste": "Saint-Pierre-Baptiste", "saint pierre de broughton": "Saint-Pierre-de-Broughton", "saint pierre de l ile d orleans": "Saint-Pierre-de-l'Île-d'Orléans", "saint pierre de la riviere du sud": "Saint-Pierre-de-la-Rivière-du-Sud", "saint pierre de lamy": "Saint-Pierre-de-Lamy", "saint pierre les becquets": "Saint-Pierre-les-Becquets", "saint placide": "Saint-Placide", "saint polycarpe": "Saint-Polycarpe", "saint prime": "Saint-Prime", "saint prosper": "Saint-Prosper", "saint prosper de champlain": "Saint-Prosper-de-Champlain", "saint raphael": "Saint-Raphaël", "saint raymond": "Saint-Raymond", "saint remi": "Saint-Rémi", "saint remi de tingwick": "Saint-Rémi-de-Tingwick", "saint rene": "Saint-René", "saint rene de matane": "Saint-René-de-Matane", "saint robert": "Saint-Robert", "saint robert bellarmin": "Saint-Robert-Bellarmin", "saint roch de l achigan": "Saint-Roch-de-l'Achigan", "saint roch de mekinac": "Saint-Roch-de-Mékinac", "saint roch de richelieu": "Saint-Roch-de-Richelieu", "saint roch des aulnaies": "Saint-Roch-des-Aulnaies", "saint roch ouest": "Saint-Roch-Ouest", "saint romain": "Saint-Romain", "saint rosaire": "Saint-Rosaire", "saint samuel": "Saint-Samuel", "saint sauveur": "Saint-Sauveur", "saint sebastien": "Saint-Sébastien", "saint severe": "Saint-Sévère", "saint severin": "Saint-Séverin", "saint simeon": "Saint-Siméon", "saint simon": "Saint-Simon", "saint simon de rimouski": "Saint-Simon-de-Rimouski", "saint simon les mines": "Saint-Simon-les-Mines", "saint sixte": "Saint-Sixte", "saint stanislas": "Saint-Stanislas", "saint stanislas de kostka": "Saint-Stanislas-de-Kostka", "saint sulpice": "Saint-Sulpice", "saint sylvere": "Saint-Sylvère", "saint sylvestre": "Saint-Sylvestre", "saint telesphore": "Saint-Télesphore", "saint tharcisius": "Saint-Tharcisius", "saint theodore d acton": "Saint-Théodore-d'Acton", "saint theophile": "Saint-Théophile", "saint thomas": "Saint-Thomas", "saint thomas didyme": "Saint-Thomas-Didyme", "saint thuribe": "Saint-Thuribe", "saint tite": "Saint-Tite", "saint tite des caps": "Saint-Tite-des-Caps", "saint ubalde": "Saint-Ubalde", "saint ulric": "Saint-Ulric", "saint urbain": "Saint-Urbain", "saint urbain premier": "Saint-Urbain-Premier", "saint valentin": "Saint-Valentin", "saint valere": "Saint-Valère", "saint valerien": "Saint-Valérien", "saint valerien de milton": "Saint-Valérien-de-Milton", "saint vallier": "Saint-Vallier", "saint venant de paquette": "Saint-Venant-de-Paquette", "saint vianney": "Saint-Vianney", "saint victor": "Saint-Victor", "saint wenceslas": "Saint-Wenceslas", "saint zacharie": "Saint-Zacharie", "saint zenon": "Saint-Zénon", "saint zenon du lac humqui": "Saint-Zénon-du-Lac-Humqui", "saint zephirin de courval": "Saint-Zéphirin-de-Courval", "saint zotique": "Saint-Zotique", "sainte adele": "Sainte-Adèle", "sainte agathe de lotbiniere": "Sainte-Agathe-de-Lotbinière", "sainte agathe des monts": "Sainte-Agathe-des-Monts", "sainte angele de merici": "Sainte-Angèle-de-Mérici", "sainte angele de monnoir": "Sainte-Angèle-de-Monnoir", "sainte angele de premont": "Sainte-Angèle-de-Prémont", "sainte anne de beaupre": "Sainte-Anne-de-Beaupré", "sainte anne de bellevue": "Sainte-Anne-de-Bellevue", "sainte anne de la perade": "Sainte-Anne-de-la-Pérade", "sainte anne de la rochelle": "Sainte-Anne-de-la-Rochelle", "sainte anne de sabrevois": "Sainte-Anne-de-Sabrevois", "sainte anne de sorel": "Sainte-Anne-de-Sorel", "sainte anne des lacs": "Sainte-Anne-des-Lacs", "sainte anne des monts": "Sainte-Anne-des-Monts", "sainte anne des plaines": "Sainte-Anne-des-Plaines", "sainte anne du lac": "Sainte-Anne-du-Lac", "sainte apolline de patton": "Sainte-Apolline-de-Patton", "sainte aurelie": "Sainte-Aurélie", "sainte barbe": "Sainte-Barbe", "sainte beatrix": "Sainte-Béatrix", "sainte brigide d iberville": "Sainte-Brigide-d'Iberville", "sainte brigitte de laval": "Sainte-Brigitte-de-Laval", "sainte brigitte des saults": "Sainte-Brigitte-des-Saults", "sainte catherine": "Sainte-Catherine", "sainte catherine de hatley": "Sainte-Catherine-de-Hatley", "sainte catherine de la jacques cartier": "Sainte-Catherine-de-la-Jacques-Cartier", "sainte cecile de levrard": "Sainte-Cécile-de-Lévrard", "sainte cecile de milton": "Sainte-Cécile-de-Milton", "sainte cecile de whitton": "Sainte-Cécile-de-Whitton", "sainte christine": "Sainte-Christine", "sainte christine d auvergne": "Sainte-Christine-d'Auvergne", "sainte claire": "Sainte-Claire", "sainte clotilde": "Sainte-Clotilde", "sainte clotilde de beauce": "Sainte-Clotilde-de-Beauce", "sainte clotilde de horton": "Sainte-Clotilde-de-Horton", "sainte croix": "Sainte-Croix", "sainte edwidge de clifton": "Sainte-Edwidge-de-Clifton", "sainte elisabeth": "Sainte-Élisabeth", "sainte elisabeth de proulx": "Sainte-Élisabeth-de-Proulx", "sainte elizabeth de warwick": "Sainte-Élizabeth-de-Warwick", "sainte emelie de l energie": "Sainte-Émélie-de-l'Énergie", "sainte eulalie": "Sainte-Eulalie", "sainte euphemie sur riviere du sud": "Sainte-Euphémie-sur-Rivière-du-Sud", "sainte famille de l ile d orleans": "Sainte-Famille-de-l'Île-d'Orléans", "sainte felicite": "Sainte-Félicité", "sainte flavie": "Sainte-Flavie", "sainte florence": "Sainte-Florence", "sainte foy sillery cap rouge": "Sainte-Foy--Sillery--Cap-Rouge", "sainte francoise": "Sainte-Françoise", "sainte genevieve de batiscan": "Sainte-Geneviève-de-Batiscan", "sainte genevieve de berthier": "Sainte-Geneviève-de-Berthier", "sainte germaine boule": "Sainte-Germaine-Boulé", "sainte gertrude manneville": "Sainte-Gertrude-Manneville", "sainte hedwidge": "Sainte-Hedwidge", "sainte helene de bagot": "Sainte-Hélène-de-Bagot", "sainte helene de chester": "Sainte-Hélène-de-Chester", "sainte helene de kamouraska": "Sainte-Hélène-de-Kamouraska", "sainte helene de mancebourg": "Sainte-Hélène-de-Mancebourg", "sainte henedine": "Sainte-Hénédine", "sainte irene": "Sainte-Irène", "sainte jeanne d arc": "Sainte-Jeanne-d'Arc", "sainte jeanne d arc de la mitis": "Sainte-Jeanne-d'Arc-de-la-Mitis", "sainte julie": "Sainte-Julie", "sainte julienne": "Sainte-Julienne", "sainte justine": "Sainte-Justine", "sainte justine de newton": "Sainte-Justine-de-Newton", "sainte louise": "Sainte-Louise", "sainte luce": "Sainte-Luce", "sainte lucie de beauregard": "Sainte-Lucie-de-Beauregard", "sainte lucie des laurentides": "Sainte-Lucie-des-Laurentides", "sainte madeleine": "Sainte-Madeleine", "sainte madeleine de la riviere madeleine": "Sainte-Madeleine-de-la-Rivière-Madeleine", "sainte marcelline de kildare": "Sainte-Marcelline-de-Kildare", "sainte marguerite": "Sainte-Marguerite", "sainte marguerite du lac masson": "Sainte-Marguerite-du-Lac-Masson", "sainte marguerite marie": "Sainte-Marguerite-Marie", "sainte marie": "Sainte-Marie", "sainte marie de blandford": "Sainte-Marie-de-Blandford", "sainte marie madeleine": "Sainte-Marie-Madeleine", "sainte marie salome": "Sainte-Marie-Salomé", "sainte marthe": "Sainte-Marthe", "sainte marthe sur le lac": "Sainte-Marthe-sur-le-Lac", "sainte martine": "Sainte-Martine", "sainte melanie": "Sainte-Mélanie", "sainte monique": "Sainte-Monique", "sainte paule": "Sainte-Paule", "sainte perpetue": "Sainte-Perpétue", "sainte petronille": "Sainte-Pétronille", "sainte praxede": "Sainte-Praxède", "sainte rita": "Sainte-Rita", "sainte rose de watford": "Sainte-Rose-de-Watford", "sainte rose du nord": "Sainte-Rose-du-Nord", "sainte sabine": "Sainte-Sabine", "sainte seraphine": "Sainte-Séraphine", "sainte sophie": "Sainte-Sophie", "sainte sophie d halifax": "Sainte-Sophie-d'Halifax", "sainte sophie de levrard": "Sainte-Sophie-de-Lévrard", "sainte thecle": "Sainte-Thècle", "sainte therese": "Sainte-Thérèse", "sainte therese de gaspe": "Sainte-Thérèse-de-Gaspé", "sainte therese de la gatineau": "Sainte-Thérèse-de-la-Gatineau", "sainte ursule": "Sainte-Ursule", "sainte victoire de sorel": "Sainte-Victoire-de-Sorel", "saints anges": "Saints-Anges", "saints martyrs canadiens": "Saints-Martyrs-Canadiens", "salaberry de valleyfield": "Salaberry-de-Valleyfield", "salluit": "Salluit", "sault au cochon": "Sault-au-Cochon", "sayabec": "Sayabec", "schefferville": "Schefferville", "scotstown": "Scotstown", "scott": "Scott", "senneterre": "Senneterre", "senneville": "Senneville", "sept iles": "Sept-Îles", "shannon": "Shannon", "shawinigan": "Shawinigan", "shawville": "Shawville", "sheenboro": "Sheenboro", "shefford": "Shefford", "sherbrooke": "Sherbrooke", "shigawake": "Shigawake", "sorel tracy": "Sorel-Tracy", "stanbridge east": "Stanbridge East", "stanbridge station": "Stanbridge Station", "stanstead": "Stanstead", "stanstead est": "Stanstead-Est", "stoke": "Stoke", "stoneham et tewkesbury": "Stoneham-et-Tewkesbury", "stornoway": "Stornoway", "stratford": "Stratford", "stukely sud": "Stukely-Sud", "sutton": "Sutton", "tadoussac": "Tadoussac", "taschereau": "Taschereau", "tasiujaq": "Tasiujaq", "temiscaming": "Témiscaming", "temiscouata sur le lac": "Témiscouata-sur-le-Lac", "terrasse vaudreuil": "Terrasse-Vaudreuil", "terrebonne": "Terrebonne", "thetford mines": "Thetford Mines", "thorne": "Thorne", "thurso": "Thurso", "timiskaming": "Timiskaming", "tingwick": "Tingwick", "toponyme a venir": "Toponyme à venir", "tourville": "Tourville", "trecesson": "Trécesson", "tres saint redempteur": "Très-Saint-Rédempteur", "tres saint sacrement": "Très-Saint-Sacrement", "tring jonction": "Tring-Jonction", "trois pistoles": "Trois-Pistoles", "trois rives": "Trois-Rives", "trois rivieres": "Trois-Rivières", "uashat": "Uashat", "ulverton": "Ulverton", "umiujaq": "Umiujaq", "upton": "Upton", "val alain": "Val-Alain", "val brillant": "Val-Brillant", "val d or": "Val-d'Or", "val david": "Val-David", "val des bois": "Val-des-Bois", "val des lacs": "Val-des-Lacs", "val des monts": "Val-des-Monts", "val des sources": "Val-des-Sources", "val joli": "Val-Joli", "val morin": "Val-Morin", "val racine": "Val-Racine", "val saint gilles": "Val-Saint-Gilles", "valcourt": "Valcourt", "vallee jonction": "Vallée-Jonction", "varennes": "Varennes", "vaudreuil dorion": "Vaudreuil-Dorion", "vaudreuil sur le lac": "Vaudreuil-sur-le-Lac", "venise en quebec": "Venise-en-Québec", "vercheres": "Verchères", "verdun": "Verdun", "victoriaville": "Victoriaville", "ville marie": "Ville-Marie", "villeray saint michel parc extension": "Villeray--Saint-Michel--Parc-Extension", "villeroy": "Villeroy", "waltham": "Waltham", "warden": "Warden", "warwick": "Warwick", "waskaganish": "Waskaganish", "waswanipi": "Waswanipi", "waterloo": "Waterloo", "waterville": "Waterville", "weedon": "Weedon", "wemindji": "Wemindji", "wemotaci": "Wemotaci", "wendake": "Wendake", "wentworth": "Wentworth", "wentworth nord": "Wentworth-Nord", "westbury": "Westbury", "westmount": "Westmount", "whapmagoostui": "Whapmagoostui", "wickham": "Wickham", "windsor": "Windsor", "wolinak": "Wôlinak", "wotton": "Wotton", "yamachiche": "Yamachiche", "yamaska": "Yamaska"}}
\ No newline at end of file
modified sortika/db.py +98 −11
@@ -11,9 +11,10 @@ import json
11 11 import sqlite3
12 12 import statistics
13 13 import time
14 +from datetime import date
14 15 from pathlib import Path
15 16
16 from .schema import Event
17 +from .schema import Event, quarantine_reason
17 18
18 19 DB_PATH = Path(__file__).resolve().parent.parent / "data" / "sortika.db"
19 20
@@ -49,6 +50,9 @@ CREATE TABLE IF NOT EXISTS events (
49 50 start_date TEXT,
50 51 start_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
51 52 end_date TEXT,
53 + end_time TEXT, -- "HH:MM" locale QC (NULL = inconnue)
54 + status TEXT, -- cancelled/postponed/soldout (NULL = prévu)
55 + quarantine TEXT, -- raison de quarantaine (NULL = publiable)
52 56 artists TEXT, -- JSON (artistes à l'affiche)
53 57 is_free INTEGER, -- 1 gratuit / 0 payant / NULL inconnu
54 58 price_min REAL,
@@ -111,9 +115,15 @@ def connect(path: Path | str | None = None) -> sqlite3.Connection:
111 115 return con
112 116
113 117
114 # Colonnes additives (vague 2) : les bases créées avant leur ajout sont
115 # migrées à la connexion — jamais de changement destructif.
116 _ADDITIVE_COLUMNS = {"start_time": "TEXT", "artists": "TEXT"}
118 +# Colonnes additives (vague 2 : start_time/artists ; Phase 2 : end_time,
119 +# status, quarantine) : les bases créées avant leur ajout sont migrées à la
120 +# connexion — jamais de changement destructif.
121 +_ADDITIVE_COLUMNS = {
122 + "start_time": "TEXT", "artists": "TEXT",
123 + "end_time": "TEXT", # "HH:MM" locale QC (NULL = inconnue)
124 + "status": "TEXT", # cancelled / postponed / soldout (NULL = prévu)
125 + "quarantine": "TEXT", # raison de quarantaine (NULL = publiable)
126 +}
117 127
118 128
119 129 def _migrate(con: sqlite3.Connection) -> None:
@@ -124,7 +134,7 @@ def _migrate(con: sqlite3.Connection) -> None:
124 134 con.commit()
125 135
126 136
127 def _event_row(ev: Event, now: float) -> dict:
137 +def _event_row(ev: Event, now: float, today: str) -> dict:
128 138 return {
129 139 "uid": ev.uid, "source": ev.source, "external_id": ev.external_id,
130 140 "url": ev.url, "title": ev.title, "description": ev.description,
@@ -134,7 +144,9 @@ def _event_row(ev: Event, now: float) -> dict:
134 144 "city": ev.city, "region": ev.region, "tourist_region": ev.tourist_region,
135 145 "postal_code": ev.postal_code, "lat": ev.lat, "lng": ev.lng,
136 146 "start_date": ev.start_date, "start_time": ev.start_time,
137 "end_date": ev.end_date,
147 + "end_date": ev.end_date, "end_time": ev.end_time,
148 + "status": ev.status or None,
149 + "quarantine": quarantine_reason(ev, today),
138 150 "artists": json.dumps(ev.artists, ensure_ascii=False),
139 151 "is_free": (None if ev.is_free is None else int(ev.is_free)),
140 152 "price_min": ev.price_min, "price_label": ev.price_label,
@@ -161,9 +173,10 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
161 173 alert = (f"dérive : {len(events)} événements reçus vs médiane {med:.0f} "
162 174 f"— retraits suspendus (connecteur probablement cassé)")
163 175
176 + today = date.today().isoformat()
164 177 seen: set[str] = set()
165 178 for ev in events:
166 row = _event_row(ev, now)
179 + row = _event_row(ev, now, today)
167 180 seen.add(ev.uid)
168 181 cur = con.execute("SELECT content_hash FROM events WHERE uid=?", (ev.uid,))
169 182 old = cur.fetchone()
@@ -172,14 +185,16 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
172 185 """INSERT INTO events (uid, source, external_id, url, title,
173 186 description, categories, raw_categories, audience, venue,
174 187 address, city, region, tourist_region, postal_code, lat, lng,
175 start_date, start_time, end_date, artists, is_free,
188 + start_date, start_time, end_date, end_time, status,
189 + quarantine, artists, is_free,
176 190 price_min, price_label,
177 191 organizer, website, image, dedup_key, content_hash,
178 192 first_seen, last_seen, updated_at, miss_count, active)
179 193 VALUES (:uid,:source,:external_id,:url,:title,:description,
180 194 :categories,:raw_categories,:audience,:venue,:address,:city,
181 195 :region,:tourist_region,:postal_code,:lat,:lng,:start_date,
182 :start_time,:end_date,:artists,:is_free,:price_min,
196 + :start_time,:end_date,:end_time,:status,:quarantine,
197 + :artists,:is_free,:price_min,
183 198 :price_label,:organizer,
184 199 :website,:image,:dedup_key,:content_hash,
185 200 :now,:now,:now,0,1)""", row)
@@ -193,6 +208,7 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
193 208 tourist_region=:tourist_region, postal_code=:postal_code,
194 209 lat=:lat, lng=:lng, start_date=:start_date,
195 210 start_time=:start_time, end_date=:end_date,
211 + end_time=:end_time, status=:status, quarantine=:quarantine,
196 212 artists=:artists, is_free=:is_free, price_min=:price_min,
197 213 price_label=:price_label, organizer=:organizer,
198 214 website=:website, image=:image, dedup_key=:dedup_key,
@@ -200,8 +216,14 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
200 216 miss_count=0, active=1 WHERE uid=:uid""", row)
201 217 stats["updated"] += 1
202 218 else:
203 con.execute("UPDATE events SET last_seen=?, miss_count=0, active=1 "
204 "WHERE uid=?", (now, ev.uid))
219 + # inchangé : on rafraîchit la présence ; la réactivation (active=1)
220 + # ne s'applique qu'aux événements non terminés — un passé archivé
221 + # que la source publie encore RESTE archivé (règle d'archivage).
222 + con.execute(
223 + "UPDATE events SET last_seen=?, miss_count=0, "
224 + "active = CASE WHEN coalesce(end_date, start_date) >= ? "
225 + "OR coalesce(end_date, start_date) IS NULL THEN 1 ELSE active END "
226 + "WHERE uid=?", (now, today, ev.uid))
205 227 stats["unchanged"] += 1
206 228
207 229 # Retraits (avec délai de grâce), sauf en cas de dérive détectée.
@@ -230,6 +252,71 @@ def sync_source(con: sqlite3.Connection, source: str, events: list[Event]) -> di
230 252 return stats
231 253
232 254
255 +def archive_past_events(con: sqlite3.Connection, today: str | None = None) -> int:
256 + """Règle d'archivage temporel (Phase 2) : un événement TERMINÉ (date de fin
257 + — ou de début à défaut — strictement avant aujourd'hui) est archivé
258 + (active=0), jamais supprimé. Appelée à chaque cycle d'ingestion ; les
259 + événements sans aucune date ne sont pas touchés. Retourne le nombre
260 + d'événements archivés."""
261 + today = today or date.today().isoformat()
262 + cur = con.execute(
263 + "UPDATE events SET active=0, updated_at=? WHERE active=1 "
264 + "AND coalesce(end_date, start_date) < ?",
265 + (time.time(), today))
266 + con.commit()
267 + return cur.rowcount
268 +
269 +
270 +def future_counts(con: sqlite3.Connection, today: str | None = None) -> dict:
271 + """Événements FUTURS actifs par source — la métrique de santé qui manquait
272 + (une source « verte » peut mourir en silence : Laval, max 2025-12-09)."""
273 + today = today or date.today().isoformat()
274 + return {r["source"]: r["n"] for r in con.execute(
275 + "SELECT source, COUNT(*) AS n FROM events WHERE active=1 "
276 + "AND quarantine IS NULL AND coalesce(end_date, start_date) >= ? "
277 + "GROUP BY source", (today,))}
278 +
279 +
280 +# seuil de chute du volume futur d'une source entre deux cycles → alerte
281 +FUTURE_DROP_RATIO = 0.2 # il reste moins de 20 % du volume précédent
282 +
283 +_FUTURE_STATE = Path(__file__).resolve().parent.parent / "data" / "future_counts.json"
284 +
285 +
286 +def check_future_health(con: sqlite3.Connection) -> list[str]:
287 + """Alerte « source qui meurt en silence » : 0 événement futur, ou chute de
288 + plus de 80 % par rapport au cycle précédent. État persisté sur disque
289 + (data/future_counts.json) ; les alertes sont retournées (et affichées par
290 + le pipeline) + exposées via /api/sources (canal lu par la supervision)."""
291 + counts = future_counts(con)
292 + try:
293 + previous = json.loads(_FUTURE_STATE.read_text(encoding="utf-8"))
294 + except Exception:
295 + previous = {}
296 + alerts: list[str] = []
297 + # toutes les sources connues de la base (une source à 0 futur n'apparaît
298 + # pas dans counts — c'est justement elle qu'il faut attraper)
299 + known = ({r[0] for r in con.execute("SELECT DISTINCT source FROM events")}
300 + | set(counts) | {s for s in previous if s != "_alerts"})
301 + for source in sorted(known):
302 + n, prev = counts.get(source, 0), previous.get(source)
303 + if n == 0:
304 + alerts.append(f"{source} : 0 événement futur — source morte "
305 + "ou connecteur cassé en silence")
306 + elif isinstance(prev, int) and prev > 0 and n <= prev * FUTURE_DROP_RATIO:
307 + alerts.append(f"{source} : chute des événements futurs "
308 + f"{prev}{n} (> 80 %)")
309 + state = dict(counts)
310 + state["_alerts"] = alerts
311 + try:
312 + _FUTURE_STATE.parent.mkdir(parents=True, exist_ok=True)
313 + _FUTURE_STATE.write_text(json.dumps(state, ensure_ascii=False),
314 + encoding="utf-8")
315 + except Exception:
316 + pass
317 + return alerts
318 +
319 +
233 320 def log_failure(con: sqlite3.Connection, source: str, error: str) -> None:
234 321 con.execute("INSERT INTO sync_log (source, ts, fetched, added, updated, "
235 322 "removed, error) VALUES (?,?,0,0,0,0,?)",
modified sortika/geocode.py +45 −24
@@ -132,36 +132,57 @@ def run(con: sqlite3.Connection, max_requests: int = GEOCODE_MAX) -> dict:
132 132 stats = {"events_filled": 0, "lookups": 0, "hits": 0, "misses": 0,
133 133 "cached_places": len(cache)}
134 134
135 + # Phase 2 : l'ADRESSE CIVIQUE prime quand la source la publie (Brossard :
136 + # 94 % d'adresses mais 0,7 % de GPS via salle+ville ; Longueuil : 100 %
137 + # d'adresses). Repli : salle + ville (comportement historique).
135 138 rows = con.execute(
136 "SELECT DISTINCT venue, city FROM events WHERE active=1 "
137 "AND lat IS NULL AND venue != '' AND city != '' "
138 "ORDER BY venue, city").fetchall()
139 + "SELECT DISTINCT venue, address, city FROM events WHERE active=1 "
140 + "AND lat IS NULL AND city != '' AND (venue != '' OR address != '') "
141 + "ORDER BY address DESC, venue, city").fetchall()
139 142
140 143 for row in rows:
141 venue, city = row["venue"], row["city"]
142 key = _cache_key(venue, city)
143 entry = cache.get(key)
144 if entry is None or (not entry.get("ok")
145 and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):
146 if stats["lookups"] >= max_requests:
147 continue # plafond atteint : salle re-tentée au prochain cycle
148 try:
149 found, used = _lookup(session, venue, city)
150 except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé
151 stats["lookups"] += 1
152 continue
153 stats["lookups"] += used
154 if found:
155 entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}
156 stats["hits"] += 1
157 else: # échec mémorisé (re-tenté après 30 jours)
158 entry = {"ok": False, "ts": now}
159 stats["misses"] += 1
160 cache[key] = entry
144 + venue, address, city = row["venue"], row["address"], row["city"]
145 + # candidats dans l'ordre de précision : adresse civique, puis salle
146 + candidates = []
147 + if address:
148 + candidates.append(("addr:" + _cache_key(address, city),
149 + "addr", address))
150 + if venue:
151 + candidates.append((_cache_key(venue, city), "venue", venue))
152 + entry = None
153 + for key, kind, query in candidates:
154 + entry = cache.get(key)
155 + if entry is None or (not entry.get("ok")
156 + and now - entry.get("ts", 0) > RETRY_FAIL_AFTER):
157 + if stats["lookups"] >= max_requests:
158 + entry = None
159 + break # plafond atteint : lieu re-tenté au prochain cycle
160 + try:
161 + if kind == "addr":
162 + found = _nominatim(
163 + session, f"{query}, {city}, Québec, Canada", city)
164 + used = 1
165 + else:
166 + found, used = _lookup(session, query, city)
167 + except Exception: # réseau/HTTP : ne bloque pas le cycle, pas mémorisé
168 + stats["lookups"] += 1
169 + entry = None
170 + continue
171 + stats["lookups"] += used
172 + if found:
173 + entry = {"ok": True, "lat": found[0], "lng": found[1], "ts": now}
174 + stats["hits"] += 1
175 + else: # échec mémorisé (re-tenté après 30 jours)
176 + entry = {"ok": False, "ts": now}
177 + stats["misses"] += 1
178 + cache[key] = entry
179 + if entry and entry.get("ok"):
180 + break # premier candidat résolu = le plus précis
161 181 if entry and entry.get("ok"):
162 182 cur = con.execute(
163 183 "UPDATE events SET lat=?, lng=? WHERE active=1 AND lat IS NULL "
164 "AND venue=? AND city=?", (entry["lat"], entry["lng"], venue, city))
184 + "AND venue=? AND address=? AND city=?",
185 + (entry["lat"], entry["lng"], venue, address, city))
165 186 stats["events_filled"] += cur.rowcount
166 187
167 188 save_cache(cache)
modified sortika/ingest.py +19 −1
@@ -47,8 +47,26 @@ def run(sources: list[str] | None = None) -> list[dict]:
47 47 db.log_failure(con, sid, f"{exc}")
48 48 traceback.print_exc()
49 49 results.append({"source": sid, "error": str(exc)})
50 + # Cycle de vie temporel (Phase 2) : les événements terminés sont archivés
51 + # (active=0, jamais supprimés) à chaque cycle — les sources qui publient
52 + # leur historique (montreal, sitq, laval…) ne gonflent plus l'inventaire.
53 + try:
54 + archived = db.archive_past_events(con)
55 + if archived:
56 + print(f"[sorti-ka] archivage : {archived} événement(s) terminé(s) → active=0")
57 + except Exception:
58 + traceback.print_exc()
59 + # Santé « futurs » (Phase 2) : une source verte peut mourir en silence
60 + # (Laval : 41 runs sans erreur, max(date)=2025-12-09). Alerte si une
61 + # source n'a plus AUCUN événement futur ou chute de plus de 80 %.
62 + try:
63 + for alert in db.check_future_health(con):
64 + print(f"[sorti-ka] ⚠ ALERTE futurs — {alert}")
65 + except Exception:
66 + traceback.print_exc()
50 67 # Géocodage des salles (transversal) : remplit lat/lng des événements
51 # actifs sans GPS (salle + ville connues) — Nominatim + cache disque,
68 + # actifs sans GPS (salle + ville connues ; adresse civique en priorité
69 + # quand la source la publie) — Nominatim + cache disque,
52 70 # plafond de requêtes réseau par cycle (voir sortika/geocode.py).
53 71 try:
54 72 gstats = geocode.run(con)
modified sortika/normalize.py +35 −2
@@ -40,12 +40,45 @@ def _key(s: str) -> str:
40 40 return strip_accents(clean_text(s or "")).lower()
41 41
42 42
43 +# Noms canoniques des municipalités/arrondissements du Québec (Répertoire
44 +# MAMH, données ouvertes) : « Montreal » → « Montréal », « Quebec » →
45 +# « Québec », « LEVIS » → « Lévis »… Les sources anglophones/dégradées
46 +# éclataient les facettes ville (Phase 1 : Montreal 242 vs Montréal 5 249).
47 +_CITY_CANON: dict | None = None
48 +
49 +
50 +def _city_canon_table() -> dict:
51 + global _CITY_CANON
52 + if _CITY_CANON is None:
53 + import json
54 + from pathlib import Path
55 + p = Path(__file__).resolve().parent.parent / "data" / "villes_canon.json"
56 + try:
57 + _CITY_CANON = json.loads(p.read_text(encoding="utf-8"))["villes"]
58 + except Exception:
59 + _CITY_CANON = {}
60 + return _CITY_CANON
61 +
62 +
63 +def canonical_city(city: str) -> str:
64 + """Nom officiel (accentué) d'une ville du Répertoire MAMH, sinon inchangé."""
65 + if not city:
66 + return city
67 + from .regions import _k
68 + key = _k(city)
69 + table = _city_canon_table()
70 + hit = table.get(key) or table.get(
71 + re.sub(r"\bste\b", "sainte", re.sub(r"\bst\b", "saint", key)))
72 + return hit or city
73 +
74 +
43 75 def clean_city(raw: str) -> str:
44 """Nettoie une ville : « Montréal (Québec) » → « Montréal », casse conservée."""
76 + """Nettoie une ville : « Montréal (Québec) » → « Montréal », puis nom
77 + canonique du Répertoire MAMH (« Montreal » → « Montréal »)."""
45 78 s = clean_text(raw)
46 79 s = re.sub(r"\s*\((qu[eé]bec|qc)\)\s*$", "", s, flags=re.I)
47 80 s = re.sub(r",\s*(qu[eé]bec|qc|canada)\s*$", "", s, flags=re.I)
48 return s.strip(" ,")
81 + return canonical_city(s.strip(" ,"))
49 82
50 83
51 84 # ---------------------------------------------------------------------------
modified sortika/schema.py +94 −3
@@ -29,7 +29,83 @@ from .normalize import (
29 29 )
30 30 from .regions import is_valid_region, region_from_city, region_from_tourist
31 31
32 __all__ = ["Event"]
32 +__all__ = ["Event", "normalize_status", "quarantine_reason"]
33 +
34 +# ---------------------------------------------------------------------------
35 +# Statut du cycle de vie billetterie (vague Phase 2) : "" = prévu (défaut),
36 +# et 3 états explicites remontés par les sources qui les publient
37 +# (eventbrite is_cancelled, evenko representation_status, ticketmaster
38 +# dates.status.code / cancelled, laval préfixe « ANNULÉ - » du titre).
39 +# ---------------------------------------------------------------------------
40 +STATUSES = ("", "cancelled", "postponed", "soldout")
41 +
42 +_STATUS_HINTS = (
43 + ("cancel", "cancelled"), ("annul", "cancelled"),
44 + ("postpon", "postponed"), ("report", "postponed"), ("rescheduled", "postponed"),
45 + ("sold_out", "soldout"), ("soldout", "soldout"), ("sold out", "soldout"),
46 + ("complet", "soldout"),
47 +)
48 +
49 +
50 +def normalize_status(raw: str | None) -> str:
51 + """Normalise un statut source vers la taxonomie interne — jamais inventé."""
52 + if not raw:
53 + return ""
54 + k = str(raw).strip().lower()
55 + if k in STATUSES:
56 + return k
57 + for hint, status in _STATUS_HINTS:
58 + if hint in k:
59 + return status
60 + return ""
61 +
62 +
63 +# ---------------------------------------------------------------------------
64 +# Quarantaine qualité (Phase 2) : règles anti-aberrations évaluées à chaque
65 +# sync. Un événement en quarantaine reste en base (comptée, réintégrable dès
66 +# que la donnée source redevient saine) mais n'est PAS publié.
67 +# ---------------------------------------------------------------------------
68 +
69 +# villes canadiennes/américaines fréquentes hors Québec (clés sans accents) —
70 +# un connecteur qui laisse passer une date hors province tombe ici.
71 +_NON_QC_CITIES = {
72 + "ottawa", "toronto", "vancouver", "calgary", "edmonton", "winnipeg",
73 + "halifax", "moncton", "fredericton", "hamilton", "mississauga", "kingston",
74 + "london", "cornwall", "niagara falls", "st catharines", "windsor",
75 + "saskatoon", "regina", "victoria", "charlottetown", "st john s",
76 + "boston", "new york", "burlington", "plattsburgh", "paris",
77 +}
78 +
79 +QUARANTINE_HORIZON_DAYS = 730 # date de début à plus de 2 ans = suspecte
80 +
81 +
82 +def quarantine_reason(ev: "Event", today: str) -> str | None:
83 + """Raison de quarantaine, ou None si l'événement est publiable.
84 +
85 + Règles (consignées Phase 2) :
86 + · date de début aberrante (avant 2000 — les passés « normaux » sont
87 + gérés par l'archivage, pas par la quarantaine) ;
88 + · date de début à plus de 2 ans dans le futur (erreur de saisie source) ;
89 + · ville manifestement hors Québec (liste de villes connues hors province).
90 + L'heure 00:00 n'est PAS une cause de quarantaine : elle est affichée
91 + « heure non confirmée » côté frontend. L'image 404 n'est pas vérifiée
92 + (coût réseau prohibitif) : le frontend retire les images cassées (onerror).
93 + """
94 + from datetime import date, timedelta
95 + if ev.start_date:
96 + if ev.start_date < "2000-01-01":
97 + return f"date de début aberrante ({ev.start_date})"
98 + try:
99 + horizon = (date.fromisoformat(today)
100 + + timedelta(days=QUARANTINE_HORIZON_DAYS)).isoformat()
101 + except ValueError:
102 + horizon = "2100-01-01"
103 + if ev.start_date > horizon:
104 + return f"date de début à plus de 2 ans ({ev.start_date})"
105 + from .normalize import _key
106 + if ev.city and _key(ev.city) in _NON_QC_CITIES:
107 + return f"ville hors Québec ({ev.city})"
108 + return None
33 109
34 110
35 111 @dataclass
@@ -55,6 +131,8 @@ class Event:
55 131 start_date: str | None = None # ISO "2026-09-11"
56 132 start_time: str | None = None # "HH:MM" locale QC (None = inconnue)
57 133 end_date: str | None = None # ISO "2026-09-13"
134 + end_time: str | None = None # "HH:MM" locale QC (None = inconnue)
135 + status: str = "" # "" (prévu) | cancelled | postponed | soldout
58 136 artists: list[str] = field(default_factory=list) # artistes à l'affiche
59 137 is_free: bool | None = None # gratuit (None = inconnu)
60 138 price_min: float | None = None # plus bas prix ($ CAD) si payant
@@ -74,16 +152,27 @@ class Event:
74 152 return hashlib.sha256(blob.encode("utf-8")).hexdigest()
75 153
76 154 def dedup_key(self) -> str:
77 """Empreinte inter-sources : titre normalisé + ville + date de début.
155 + """Empreinte inter-sources : titre normalisé + ville + date + HEURE.
78 156
79 157 Un même événement publié par deux sources (ex. SITQ et la ville) doit
80 158 produire la même clé pour être fusionné (patron Lou-Ka / section 12).
159 +
160 + RÈGLE (Phase 2, 2026-08-19) : l'heure de début fait partie de la clé.
161 + Un même titre le même jour dans la même ville mais à des heures
162 + DIFFÉRENTES = des séances distinctes (ex. lepointdevente publie 11
163 + séances horodatées d'un parcours le même jour) — la fusion d'affichage
164 + ne doit pas les écraser. Deux sources publiant la même séance à la
165 + même heure fusionnent toujours ; une fiche sans heure ne fusionne
166 + qu'avec les autres fiches sans heure (compromis assumé : mieux vaut
167 + un doublon visible que dix séances invisibles).
81 168 """
82 169 from .normalize import strip_accents
83 170 t = strip_accents(self.title).lower()
84 171 t = "".join(c for c in t if c.isalnum())
85 172 c = strip_accents(self.city).lower().replace(" ", "")
86 return hashlib.sha1(f"{t}|{c}|{self.start_date or ''}".encode()).hexdigest()
173 + return hashlib.sha1(
174 + f"{t}|{c}|{self.start_date or ''}|{self.start_time or ''}"
175 + .encode()).hexdigest()
87 176
88 177 def finalize(self) -> "Event":
89 178 """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
@@ -101,6 +190,8 @@ class Event:
101 190 self.start_date = parse_date_iso(self.start_date)
102 191 self.start_time = parse_time(self.start_time)
103 192 self.end_date = parse_date_iso(self.end_date) or self.start_date
193 + self.end_time = parse_time(self.end_time)
194 + self.status = normalize_status(self.status)
104 195
105 196 # artistes : nettoyés, dédupliqués (ordre conservé), jamais inventés
106 197 seen: set[str] = set()
modified tests/test_db.py +88 −4
@@ -69,14 +69,98 @@ def test_start_time_and_artists_persisted(tmp_path):
69 69 def test_migration_adds_columns(tmp_path):
70 70 p = tmp_path / "old.db"
71 71 con = db.connect(p)
72 # simuler une base d'avant la vague 2 (sans les colonnes additives)
73 con.execute("ALTER TABLE events DROP COLUMN start_time")
74 con.execute("ALTER TABLE events DROP COLUMN artists")
72 + # simuler une base d'avant la vague 2 / Phase 2 (sans colonnes additives)
73 + for col in ("start_time", "artists", "end_time", "status", "quarantine"):
74 + con.execute(f"ALTER TABLE events DROP COLUMN {col}")
75 75 con.commit()
76 76 con.close()
77 77 con = db.connect(p) # la connexion migre (colonnes additives)
78 78 cols = {r["name"] for r in con.execute("PRAGMA table_info(events)")}
79 assert {"start_time", "artists"} <= cols
79 + assert {"start_time", "artists", "end_time", "status", "quarantine"} <= cols
80 +
81 +
82 +def test_end_time_and_status_persisted(tmp_path):
83 + con = db.connect(tmp_path / "t.db")
84 + ev = Event(source="eventbrite", external_id="e1", url="https://x/e1",
85 + title="Atelier", city="Montréal", start_date="2027-01-15",
86 + start_time="19:00", end_time="21h30",
87 + status="CANCELLED").finalize()
88 + assert ev.end_time == "21:30" # normalisée "HH:MM"
89 + assert ev.status == "cancelled" # taxonomie interne
90 + db.sync_source(con, "eventbrite", [ev])
91 + row = con.execute("SELECT end_time, status FROM events").fetchone()
92 + assert row["end_time"] == "21:30" and row["status"] == "cancelled"
93 +
94 +
95 +def test_archive_past_events(tmp_path):
96 + """Règle Phase 2 : passé = archivé (active=0), jamais supprimé — et un
97 + passé archivé n'est pas réactivé par un sync « unchanged »."""
98 + con = db.connect(tmp_path / "t.db")
99 + past = Event(source="sitq", external_id="p1", url="https://x/p1",
100 + title="Marché passé", city="Québec",
101 + start_date="2020-05-01", end_date="2020-05-02").finalize()
102 + future = _ev(eid="f1", title="Événement futur")
103 + db.sync_source(con, "sitq", [past, future])
104 + n = db.archive_past_events(con)
105 + assert n == 1
106 + rows = {r["uid"]: r["active"] for r in
107 + con.execute("SELECT uid, active FROM events")}
108 + assert rows["sitq:p1"] == 0 and rows["sitq:f1"] == 1
109 + # la source publie toujours le passé (inchangé) → il RESTE archivé
110 + db.sync_source(con, "sitq", [past, future])
111 + assert con.execute("SELECT active FROM events WHERE uid='sitq:p1'"
112 + ).fetchone()[0] == 0
113 +
114 +
115 +def test_quarantine_rules(tmp_path):
116 + """Anti-aberrations : > 2 ans ou hors QC → quarantaine (réintégrable),
117 + comptée à part et absente des vues publiées."""
118 + con = db.connect(tmp_path / "t.db")
119 + far = Event(source="sitq", external_id="q1", url="https://x/q1",
120 + title="Trop loin", city="Québec",
121 + start_date="2031-01-01").finalize()
122 + hors = Event(source="bandsintown", external_id="q2", url="https://x/q2",
123 + title="Show à Toronto", city="Toronto",
124 + start_date="2026-10-01").finalize()
125 + ok = _ev(eid="q3", title="Événement sain")
126 + db.sync_source(con, "sitq", [far, ok])
127 + db.sync_source(con, "bandsintown", [hors])
128 + rows = {r["uid"]: r["quarantine"] for r in
129 + con.execute("SELECT uid, quarantine FROM events")}
130 + assert rows["sitq:q1"] and "2 ans" in rows["sitq:q1"]
131 + assert rows["bandsintown:q2"] and "hors Québec" in rows["bandsintown:q2"]
132 + assert rows["sitq:q3"] is None
133 +
134 +
135 +def test_future_counts_and_alerts(tmp_path, monkeypatch):
136 + con = db.connect(tmp_path / "t.db")
137 + monkeypatch.setattr(db, "_FUTURE_STATE", tmp_path / "future_counts.json")
138 + past = Event(source="laval", external_id="m1", url="https://x/m1",
139 + title="Activité terminée", city="Laval",
140 + start_date="2025-12-09").finalize()
141 + db.sync_source(con, "laval", [past])
142 + db.sync_source(con, "sitq", [_ev(eid="ok")])
143 + counts = db.future_counts(con)
144 + assert counts.get("sitq") == 1 and "laval" not in counts
145 + alerts = db.check_future_health(con)
146 + assert any(a.startswith("laval") and "0 événement futur" in a
147 + for a in alerts)
148 +
149 +
150 +def test_dedup_key_seances_distinctes():
151 + """Même titre/ville/jour mais heures différentes = séances distinctes
152 + (clés différentes) ; même heure = fusion inter-sources conservée."""
153 + a = Event(source="lepointdevente", external_id="1", url="https://x/1",
154 + title="Parcours découverte", city="Québec",
155 + start_date="2026-09-03", start_time="09:30").finalize()
156 + b = Event(source="lepointdevente", external_id="2", url="https://x/2",
157 + title="Parcours découverte", city="Québec",
158 + start_date="2026-09-03", start_time="14:30").finalize()
159 + c = Event(source="lavitrine", external_id="3", url="https://x/3",
160 + title="Parcours découverte", city="Québec",
161 + start_date="2026-09-03", start_time="09:30").finalize()
162 + assert a.dedup_key() != b.dedup_key() # séances préservées
163 + assert a.dedup_key() == c.dedup_key() # fusion inter-sources conservée
80 164
81 165
82 166 def test_dedup_key_cross_source():
modified tests/test_normalize.py +25 −0
@@ -105,3 +105,28 @@ def test_event_finalize_and_dedup():
105 105 assert a.is_free is True
106 106 assert a.end_date == "2026-07-01"
107 107 assert a.dedup_key() == b.dedup_key()
108 +
109 +
110 +def test_canonical_city_mamh():
111 + """Phase 2 : les graphies sans accents des sources sont ramenées au nom
112 + officiel du Répertoire MAMH (facettes ville unifiées)."""
113 + from sortika.normalize import canonical_city
114 + assert canonical_city("Montreal") == "Montréal"
115 + assert canonical_city("Quebec") == "Québec"
116 + assert canonical_city("Levis") == "Lévis"
117 + assert canonical_city("Trois-Rivieres") == "Trois-Rivières"
118 + assert canonical_city("MONTRÉAL") == "Montréal"
119 + assert canonical_city("Toronto") == "Toronto" # hors répertoire : intact
120 + assert clean_city("Montreal, Québec") == "Montréal"
121 +
122 +
123 +def test_normalize_status():
124 + from sortika.schema import normalize_status
125 + assert normalize_status("cancelled") == "cancelled"
126 + assert normalize_status("ANNULÉ") == "cancelled"
127 + assert normalize_status("sold_out") == "soldout"
128 + assert normalize_status("postponed") == "postponed"
129 + assert normalize_status("rescheduled") == "postponed"
130 + assert normalize_status("buy_now") == ""
131 + assert normalize_status("") == ""
132 + assert normalize_status(None) == ""
108 133