# 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 """Sondages historiques 1998-2018 (Wikipédia EN) — carburant du replay multi-élections (§52). Chaque élection a son ère de partis (ADQ 2007-2008, CAQ depuis 2012, PCQ significatif seulement en 2022) : le cœur alr étant paramétré par la liste de partis, le même moteur rejoue chaque ère. Les sondages parsés sont mis en cache (`data/historical/polls_{année}.csv`, provenance URL + date d'accès) — le replay ne refait pas le réseau.""" from __future__ import annotations import logging import re from datetime import date, datetime, timezone from io import StringIO from pathlib import Path import httpx import pandas as pd from ..config import DATA_DIR from .wikipedia import (_flat_cols, _num, _parse_date, norm_pollster, UA) log = logging.getLogger("wiki-historical") HIST_DIR = DATA_DIR / "historical" # Métadonnées des élections rejouables (résultats officiels DGEQ, % du vote). ELECTIONS: dict[int, dict] = { 2022: {"date": date(2022, 10, 3), "parties": ["CAQ", "PLQ", "PQ", "QS", "PCQ", "AUT"], "incumbent": "CAQ", "terms": 1, "result": {"CAQ": 40.98, "PLQ": 14.37, "PQ": 14.61, "QS": 15.43, "PCQ": 12.91, "AUT": 1.70}, "url": "https://en.wikipedia.org/wiki/2022_Quebec_general_election"}, 2018: {"date": date(2018, 10, 1), "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"], "incumbent": "PLQ", "terms": 1, "result": {"CAQ": 37.42, "PLQ": 24.82, "PQ": 17.06, "QS": 16.10, "AUT": 4.60}, "url": ("https://en.wikipedia.org/wiki/" "Opinion_polling_for_the_2018_Quebec_general_election")}, 2014: {"date": date(2014, 4, 7), "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"], "incumbent": "PQ", "terms": 1, "result": {"CAQ": 23.05, "PLQ": 41.52, "PQ": 25.38, "QS": 7.63, "AUT": 2.42}, "url": "https://en.wikipedia.org/wiki/2014_Quebec_general_election"}, 2012: {"date": date(2012, 9, 4), "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"], "incumbent": "PLQ", "terms": 3, "result": {"CAQ": 27.05, "PLQ": 31.20, "PQ": 31.95, "QS": 6.03, "AUT": 3.77}, "url": "https://en.wikipedia.org/wiki/2012_Quebec_general_election"}, 2008: {"date": date(2008, 12, 8), "parties": ["ADQ", "PLQ", "PQ", "QS", "AUT"], "incumbent": "PLQ", "terms": 2, "result": {"ADQ": 16.37, "PLQ": 42.08, "PQ": 35.17, "QS": 3.78, "AUT": 2.60}, "url": "https://en.wikipedia.org/wiki/2008_Quebec_general_election"}, 2007: {"date": date(2007, 3, 26), "parties": ["ADQ", "PLQ", "PQ", "QS", "AUT"], "incumbent": "PLQ", "terms": 1, "result": {"ADQ": 30.84, "PLQ": 33.08, "PQ": 28.35, "QS": 3.64, "AUT": 4.09}, "url": "https://en.wikipedia.org/wiki/2007_Quebec_general_election"}, } # Correspondance colonnes → partis, toutes ères confondues (minuscule, contient). PARTY_PATTERNS = [ ("CAQ", ("caq", "coalition")), ("PLQ", ("plq", "qlp", "liberal", "libéral")), ("PQ", ("parti québécois", "parti quebecois", "pq")), ("QS", ("québec solidaire", "quebec solidaire", "qs")), ("ADQ", ("adq", "action démocratique", "action democratique")), ("PCQ", ("pcq", "conservative", "conservateur")), ("AUT", ("other", "others", "autres")), ] def _party_of(col: str) -> str | None: c = col.lower().strip() for party, pats in PARTY_PATTERNS: for pat in pats: if pat in c: return party return None def parse_election_polls(html: str, year: int) -> list[dict]: """Tables de sondages d'une page d'élection historique → dicts normalisés. Tolère les deux mises en page : « Timeline of opinion polls » (multi-niveau) et « Date(s)/Firm/Sample » (2008).""" meta = ELECTIONS[year] parties = meta["parties"] out, seen = [], set() for t in pd.read_html(StringIO(html)): t = t.copy() t.columns = _flat_cols(t) cols = {str(c).lower().strip(): c for c in t.columns} org_col = next((cols[k] for k in cols if "polling" in k and "organi" in k or k == "firm" or "polling firm" in k), None) date_col = next((cols[k] for k in cols if "last date" in k or k.startswith("date")), None) party_cols = {} for k, orig in cols.items(): p = _party_of(k) if p and p in parties and p not in party_cols: party_cols[p] = orig if org_col is None or date_col is None or len( [p for p in party_cols if p != "AUT"]) < 3: continue n_col = next((cols[k] for k in cols if "sample" in k), None) for _, r in t.iterrows(): org = r.get(org_col) if org is None or pd.isna(org): continue org = str(org) if "election" in org.lower() or len(org) > 60: continue end = _parse_date(r.get(date_col), year) if end is None or abs((end - meta["date"]).days) > 4 * 365: continue shares = {} for p, c in party_cols.items(): v = _num(r.get(c)) if v is not None and 0 <= v <= 80: shares[p] = v if len([p for p in shares if p != "AUT"]) < 3: continue if "AUT" not in shares: s = sum(shares.values()) if 80 <= s <= 100: shares["AUT"] = round(100 - s, 1) n = _num(r.get(n_col)) if n_col is not None else None key = (norm_pollster(org), end.isoformat()) if key in seen: continue seen.add(key) out.append({"pollster": norm_pollster(org), "field_end": end.isoformat(), "sample_size": int(n) if n and n > 40 else None, "mode": "unknown", "shares": shares, "source_url": meta["url"]}) return out def load_polls(year: int, refresh: bool = False) -> list[dict]: """Sondages d'une élection historique (cache CSV, réseau si absent).""" HIST_DIR.mkdir(parents=True, exist_ok=True) f = HIST_DIR / f"polls_{year}.csv" if f.exists() and not refresh: df = pd.read_csv(f) polls: dict[tuple, dict] = {} for _, r in df.iterrows(): key = (r["pollster"], r["field_end"]) p = polls.setdefault(key, { "pollster": r["pollster"], "field_end": r["field_end"], "sample_size": None if pd.isna(r["sample_size"]) else int(r["sample_size"]), "mode": r.get("mode", "unknown"), "shares": {}}) p["shares"][r["party"]] = float(r["raw_value"]) return list(polls.values()) html = httpx.get(ELECTIONS[year]["url"], headers=UA, timeout=90, follow_redirects=True).text polls = parse_election_polls(html, year) recs = [] accessed = datetime.now(timezone.utc).isoformat() for p in polls: for party, v in p["shares"].items(): recs.append({**{k: p[k] for k in p if k != "shares"}, "party": party, "raw_value": v, "accessed_at": accessed}) pd.DataFrame(recs).to_csv(f, index=False) log.info("polls %s: %d sondages mis en cache", year, len(polls)) return polls def to_model_polls(polls: list[dict]) -> list[dict]: """Format consommé par fit_trend (field_end en date, parts normalisées).""" out = [] for p in polls: total = sum(p["shares"].values()) or 100.0 out.append({"id": None, "pollster": p["pollster"], "field_end": date.fromisoformat(p["field_end"]), "sample_size": p["sample_size"], "mode": p.get("mode", "unknown"), "shares": {k: v * 100.0 / total for k, v in p["shares"].items()}}) return out