SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
5.3 KB · 145 lines python
Raw Blame History
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Ingestion des données ouvertes officielles d'Élections Québec (DGEQ).67Sources (https://dgeq.org/donnees.html) :8  * GeoJSON des 127 circonscriptions 2026 (carte électorale 2025, sans plans d'eau);9  * liste officielle des circonscriptions 2026;10  * candidatures officielles (avec drapeau « député sortant »);11  * résultats en direct le soir du scrutin (resultats.json — provider prêt).12"""13from __future__ import annotations1415import json16import unicodedata17from datetime import datetime, timezone1819import httpx20from sqlalchemy.orm import Session2122from ..config import DATA_DIR, settings23from .. import models as Mo2425BASE = "https://donnees.electionsquebec.qc.ca"26URL_GEOJSON = f"{BASE}/autres/provincial/circonscriptions_electorales_sans_eau_2026.json"27URL_LISTE = f"{BASE}/autres/provincial/liste_circonscriptions2026.csv"28URL_CANDIDATURES = f"{BASE}/production/provincial/candidatures/candidatures.json"29URL_RESULTATS = f"{BASE}/production/provincial/resultats/resultats.json"3031PARTY_BY_NAME = {32    "parti québécois": "PQ",33    "québec solidaire": "QS",34    "coalition avenir québec": "CAQ",35    "parti conservateur du québec": "PCQ",36    "parti libéral du québec": "PLQ",37}383940def _norm(s: str) -> str:41    """Clé de rapprochement de noms (accents conservés, tirets/apostrophes unifiés)."""42    s = unicodedata.normalize("NFC", str(s)).strip()43    return s.replace("–", "-").replace("—", "-").replace("’", "'").lower()444546def party_code(nom_parti: str | None) -> str:47    if not nom_parti:48        return "AUT"49    n = _norm(nom_parti)50    for key, code in PARTY_BY_NAME.items():51        if key in n:52            return code53    return "AUT"545556def _mark(db: Session, name: str, url: str, status: str) -> None:57    src = db.query(Mo.DataSource).filter_by(url=url).first()58    if src is None:59        src = Mo.DataSource(name=name, url=url, kind="officiel")60        db.add(src)61    src.last_fetch = datetime.now(timezone.utc)62    src.last_status = status[:200]63    db.commit()646566def fetch_geojson(db: Session | None = None) -> dict:67    """Télécharge la carte officielle et l'écrit (propriétés minimales)."""68    r = httpx.get(URL_GEOJSON, timeout=120)69    r.raise_for_status()70    g = r.json()71    slim = {"type": "FeatureCollection",72            "features": [{"type": "Feature",73                          "properties": {"name": f["properties"]["NM_CEP"]},74                          "geometry": f["geometry"]} for f in g["features"]]}75    out = DATA_DIR / "carte_2026.geojson"76    out.write_text(json.dumps(slim, ensure_ascii=False))77    if db:78        _mark(db, "DGEQ — carte électorale 2026 (GeoJSON)", URL_GEOJSON,79              f"ok ({len(slim['features'])} circonscriptions)")80    return {"features": len(slim["features"]), "path": str(out)}818283def validate_names(db: Session) -> dict:84    """Rapproche les noms officiels DGEQ et les circonscriptions en base."""85    f = DATA_DIR / "carte_2026.geojson"86    if not f.exists():87        return {"error": "GeoJSON absent"}88    geo_names = {_norm(x["properties"]["name"]) for x in json.loads(f.read_text())["features"]}89    db_names = {_norm(d.name): d.name for d in db.query(Mo.District).all()}90    missing_geo = sorted(n for n in db_names if n not in geo_names)91    missing_db = sorted(n for n in geo_names if n not in db_names)92    return {"non_trouvées_dans_geo": missing_geo, "non_trouvées_en_base": missing_db}939495def fetch_candidatures(db: Session) -> dict:96    """Candidatures officielles : stockées et utilisées pour raffiner l'incumbency."""97    try:98        r = httpx.get(URL_CANDIDATURES, timeout=60)99        r.raise_for_status()100        data = r.json()101    except Exception as e:102        _mark(db, "DGEQ — candidatures officielles", URL_CANDIDATURES, f"error: {e}")103        raise104    districts = {_norm(d.name): d for d in db.query(Mo.District).all()}105    db.query(Mo.Candidate).delete()106    n, sortants = 0, 0107    for c in data:108        dname = c.get("nom_circonscription")109        if not dname:110            continue111        d = districts.get(_norm(dname))112        if d is None:113            continue114        code = party_code(c.get("nom_parti"))115        is_inc = c.get("depute_sortant") == "O"116        db.add(Mo.Candidate(117            district_id=d.id, party=code,118            name=f"{c.get('prenom_bulletin_vote') or ''} {c.get('nom_bulletin_vote') or ''}".strip(),119            party_full=c.get("nom_parti"), is_incumbent=is_inc,120            source_url=URL_CANDIDATURES))121        n += 1122        if is_inc:123            sortants += 1124            # confirmation officielle que le sortant se représente125            if d.incumbent_party == code:126                d.incumbent_running = True127    db.commit()128    _mark(db, "DGEQ — candidatures officielles", URL_CANDIDATURES,129          f"ok ({n} candidatures, {sortants} sortants)")130    return {"candidatures": n, "sortants_confirmés": sortants}131132133def refresh_dgeq(db: Session) -> dict:134    report = {}135    try:136        report["geojson"] = fetch_geojson(db)137    except Exception as e:138        report["geojson"] = f"échec: {e}"139    try:140        report["candidatures"] = fetch_candidatures(db)141    except Exception as e:142        report["candidatures"] = f"échec: {e}"143    report["validation_noms"] = validate_names(db)144    return report145