# 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 """Élections Québec (DGEQ) — SOURCE CANONIQUE (ADDENDUM §2, TIER 1). Complète l'ingestion DGEQ existante (carte, candidatures — ingest/dgeq.py) : * **partis autorisés** : liste officielle scrapeée (Firecrawl) et archivée (`data/partis_autorises.json`) — la plateforme ne fige jamais la liste; * **vérification des chefs** : le chef affiché par la source officielle prime sur le registre; toute divergence lève une alerte (jamais de remplacement silencieux); * **électeurs par circonscription** : conservés depuis les propriétés du GeoJSON officiel lorsque publiés (amorce du feature store de population). Quand Élections Québec contredit Wikipédia, Élections Québec a raison. """ from __future__ import annotations import json import logging import re from datetime import date from sqlalchemy.orm import Session from ..config import DATA_DIR, settings from .. import models as Mo log = logging.getLogger("elections-quebec") URL_PARTIS = ("https://www.electionsquebec.qc.ca/partis-et-autres-entites-" "politiques/partis-politiques/") OUT_PARTIS = DATA_DIR / "partis_autorises.json" # Rangée du tableau DGEQ des partis PROVINCIAUX : # | [Nom](…/fiche-du-parti/ID/ID/) | n candidats | Chef | date d'autorisation | _ROW_RE = re.compile( r"\|\s*\[([^\]]+)\]\((https://www\.electionsquebec\.qc\.ca/[^)]*" r"fiche-du-parti/[^)]*)\)\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|") def refresh_authorized_parties(db: Session) -> dict: """Scrape le tableau officiel des partis provinciaux autorisés (nom, fiche, nombre de candidats, chef, date d'autorisation).""" from .firecrawl_watch import scrape_markdown md = scrape_markdown(URL_PARTIS) if not md: return {"skipped": "page DGEQ inaccessible"} parties = [] for m in _ROW_RE.finditer(md): name, url, n_cand, leader, authorized = m.groups() if "fiche-du-parti-municipal" in url: continue parties.append({"name": name.strip(), "fiche_url": url, "candidates": int(n_cand), "leader": leader.strip(), "authorized_since": authorized.strip()}) if len(parties) < 5: return {"skipped": f"extraction trop pauvre ({len(parties)} partis)"} OUT_PARTIS.write_text(json.dumps( {"as_of": date.today().isoformat(), "source_url": URL_PARTIS, "parties": parties}, ensure_ascii=False, indent=1)) # vérification des chefs des partis principaux vs registre interne alerts = 0 for p in db.query(Mo.Party).all(): if not p.leader or p.code == "AUT": continue match = next((x for x in parties if p.name.lower() in x["name"].lower() or x["name"].lower() in p.name.lower()), None) if match and match["leader"]: reg_last = p.leader.split()[-1].lower() if reg_last not in match["leader"].lower(): url = f"{URL_PARTIS}#chef-{p.code}-{date.today().isoformat()}" if not db.query(Mo.WebSignal).filter_by(url=url, kind="dérive-chef").first(): db.add(Mo.WebSignal( kind="dérive-chef", status="pending_review", url=url, title=(f"DGEQ indique « {match['leader']} » pour " f"{p.name} — registre : « {p.leader} »"), extra={"party": p.code, "dgeq_leader": match["leader"], "registry_leader": p.leader})) alerts += 1 db.commit() return {"partis_autorisés": len(parties), "alertes_chef": alerts} def refresh_electors(db: Session) -> dict: """Électeurs inscrits par circonscription depuis le GeoJSON officiel brut (propriétés NB_ELECT*/ELECTEURS si publiées) — amorce du feature store.""" import httpx from .dgeq import URL_GEOJSON try: r = httpx.get(URL_GEOJSON, timeout=120) r.raise_for_status() feats = r.json().get("features") or [] except Exception as e: return {"skipped": f"GeoJSON inaccessible: {e}"} stored = 0 for f in feats: props = f.get("properties") or {} name = props.get("NM_CEP") or props.get("name") electors = next((props[k] for k in props if "ELECT" in str(k).upper() and isinstance(props[k], (int, float))), None) if not name or electors is None: continue d = db.query(Mo.District).filter(Mo.District.name == name).first() if d is not None: notes = json.loads(d.notes) if (d.notes or "").startswith("{") else {} notes["electors"] = int(electors) d.notes = json.dumps(notes, ensure_ascii=False) stored += 1 db.commit() return {"circonscriptions_avec_électeurs": stored} def run_canonical_checks(db: Session) -> dict: report = {} for name, fn in [("partis_autorisés", refresh_authorized_parties), ("électeurs", refresh_electors)]: try: report[name] = fn(db) except Exception as e: db.rollback() report[name] = f"échec: {e}" return report