# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Ingestion des données ouvertes officielles d'Élections Québec (DGEQ). Sources (https://dgeq.org/donnees.html) : * GeoJSON des 127 circonscriptions 2026 (carte électorale 2025, sans plans d'eau); * liste officielle des circonscriptions 2026; * candidatures officielles (avec drapeau « député sortant »); * résultats en direct le soir du scrutin (resultats.json — provider prêt). """ from __future__ import annotations import json import unicodedata from datetime import datetime, timezone import httpx from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from .. import models as Mo BASE = "https://donnees.electionsquebec.qc.ca" URL_GEOJSON = f"{BASE}/autres/provincial/circonscriptions_electorales_sans_eau_2026.json" URL_LISTE = f"{BASE}/autres/provincial/liste_circonscriptions2026.csv" URL_CANDIDATURES = f"{BASE}/production/provincial/candidatures/candidatures.json" URL_RESULTATS = f"{BASE}/production/provincial/resultats/resultats.json" PARTY_BY_NAME = { "parti québécois": "PQ", "québec solidaire": "QS", "coalition avenir québec": "CAQ", "parti conservateur du québec": "PCQ", "parti libéral du québec": "PLQ", } def _norm(s: str) -> str: """Clé de rapprochement de noms (accents conservés, tirets/apostrophes unifiés).""" s = unicodedata.normalize("NFC", str(s)).strip() return s.replace("–", "-").replace("—", "-").replace("’", "'").lower() def party_code(nom_parti: str | None) -> str: if not nom_parti: return "AUT" n = _norm(nom_parti) for key, code in PARTY_BY_NAME.items(): if key in n: return code return "AUT" def _mark(db: Session, name: str, url: str, status: str) -> None: src = db.query(Mo.DataSource).filter_by(url=url).first() if src is None: src = Mo.DataSource(name=name, url=url, kind="officiel") db.add(src) src.last_fetch = datetime.now(timezone.utc) src.last_status = status[:200] db.commit() def fetch_geojson(db: Session | None = None) -> dict: """Télécharge la carte officielle et l'écrit (propriétés minimales).""" r = httpx.get(URL_GEOJSON, timeout=120) r.raise_for_status() g = r.json() slim = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"name": f["properties"]["NM_CEP"]}, "geometry": f["geometry"]} for f in g["features"]]} out = DATA_DIR / "carte_2026.geojson" out.write_text(json.dumps(slim, ensure_ascii=False)) if db: _mark(db, "DGEQ — carte électorale 2026 (GeoJSON)", URL_GEOJSON, f"ok ({len(slim['features'])} circonscriptions)") return {"features": len(slim["features"]), "path": str(out)} def validate_names(db: Session) -> dict: """Rapproche les noms officiels DGEQ et les circonscriptions en base.""" f = DATA_DIR / "carte_2026.geojson" if not f.exists(): return {"error": "GeoJSON absent"} geo_names = {_norm(x["properties"]["name"]) for x in json.loads(f.read_text())["features"]} db_names = {_norm(d.name): d.name for d in db.query(Mo.District).all()} missing_geo = sorted(n for n in db_names if n not in geo_names) missing_db = sorted(n for n in geo_names if n not in db_names) return {"non_trouvées_dans_geo": missing_geo, "non_trouvées_en_base": missing_db} def fetch_candidatures(db: Session) -> dict: """Candidatures officielles : stockées et utilisées pour raffiner l'incumbency.""" try: r = httpx.get(URL_CANDIDATURES, timeout=60) r.raise_for_status() data = r.json() except Exception as e: _mark(db, "DGEQ — candidatures officielles", URL_CANDIDATURES, f"error: {e}") raise districts = {_norm(d.name): d for d in db.query(Mo.District).all()} db.query(Mo.Candidate).delete() n, sortants = 0, 0 for c in data: dname = c.get("nom_circonscription") if not dname: continue d = districts.get(_norm(dname)) if d is None: continue code = party_code(c.get("nom_parti")) is_inc = c.get("depute_sortant") == "O" db.add(Mo.Candidate( district_id=d.id, party=code, name=f"{c.get('prenom_bulletin_vote') or ''} {c.get('nom_bulletin_vote') or ''}".strip(), party_full=c.get("nom_parti"), is_incumbent=is_inc, source_url=URL_CANDIDATURES)) n += 1 if is_inc: sortants += 1 # confirmation officielle que le sortant se représente if d.incumbent_party == code: d.incumbent_running = True db.commit() _mark(db, "DGEQ — candidatures officielles", URL_CANDIDATURES, f"ok ({n} candidatures, {sortants} sortants)") return {"candidatures": n, "sortants_confirmés": sortants} def refresh_dgeq(db: Session) -> dict: report = {} try: report["geojson"] = fetch_geojson(db) except Exception as e: report["geojson"] = f"échec: {e}" try: report["candidatures"] = fetch_candidatures(db) except Exception as e: report["candidatures"] = f"échec: {e}" report["validation_noms"] = validate_names(db) return report