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%
8.0 KB · 185 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"""Sondages historiques 1998-2018 (Wikipédia EN) — carburant du replay6multi-élections (§52). Chaque élection a son ère de partis (ADQ 2007-2008,7CAQ depuis 2012, PCQ significatif seulement en 2022) : le cœur alr étant8paramétré par la liste de partis, le même moteur rejoue chaque ère.910Les sondages parsés sont mis en cache (`data/historical/polls_{année}.csv`,11provenance URL + date d'accès) — le replay ne refait pas le réseau."""12from __future__ import annotations1314import logging15import re16from datetime import date, datetime, timezone17from io import StringIO18from pathlib import Path1920import httpx21import pandas as pd2223from ..config import DATA_DIR24from .wikipedia import (_flat_cols, _num, _parse_date, norm_pollster, UA)2526log = logging.getLogger("wiki-historical")2728HIST_DIR = DATA_DIR / "historical"2930# Métadonnées des élections rejouables (résultats officiels DGEQ, % du vote).31ELECTIONS: dict[int, dict] = {32    2022: {"date": date(2022, 10, 3),33           "parties": ["CAQ", "PLQ", "PQ", "QS", "PCQ", "AUT"],34           "incumbent": "CAQ", "terms": 1,35           "result": {"CAQ": 40.98, "PLQ": 14.37, "PQ": 14.61, "QS": 15.43,36                      "PCQ": 12.91, "AUT": 1.70},37           "url": "https://en.wikipedia.org/wiki/2022_Quebec_general_election"},38    2018: {"date": date(2018, 10, 1),39           "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"],40           "incumbent": "PLQ", "terms": 1,41           "result": {"CAQ": 37.42, "PLQ": 24.82, "PQ": 17.06, "QS": 16.10, "AUT": 4.60},42           "url": ("https://en.wikipedia.org/wiki/"43                   "Opinion_polling_for_the_2018_Quebec_general_election")},44    2014: {"date": date(2014, 4, 7),45           "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"],46           "incumbent": "PQ", "terms": 1,47           "result": {"CAQ": 23.05, "PLQ": 41.52, "PQ": 25.38, "QS": 7.63, "AUT": 2.42},48           "url": "https://en.wikipedia.org/wiki/2014_Quebec_general_election"},49    2012: {"date": date(2012, 9, 4),50           "parties": ["CAQ", "PLQ", "PQ", "QS", "AUT"],51           "incumbent": "PLQ", "terms": 3,52           "result": {"CAQ": 27.05, "PLQ": 31.20, "PQ": 31.95, "QS": 6.03, "AUT": 3.77},53           "url": "https://en.wikipedia.org/wiki/2012_Quebec_general_election"},54    2008: {"date": date(2008, 12, 8),55           "parties": ["ADQ", "PLQ", "PQ", "QS", "AUT"],56           "incumbent": "PLQ", "terms": 2,57           "result": {"ADQ": 16.37, "PLQ": 42.08, "PQ": 35.17, "QS": 3.78, "AUT": 2.60},58           "url": "https://en.wikipedia.org/wiki/2008_Quebec_general_election"},59    2007: {"date": date(2007, 3, 26),60           "parties": ["ADQ", "PLQ", "PQ", "QS", "AUT"],61           "incumbent": "PLQ", "terms": 1,62           "result": {"ADQ": 30.84, "PLQ": 33.08, "PQ": 28.35, "QS": 3.64, "AUT": 4.09},63           "url": "https://en.wikipedia.org/wiki/2007_Quebec_general_election"},64}6566# Correspondance colonnes → partis, toutes ères confondues (minuscule, contient).67PARTY_PATTERNS = [68    ("CAQ", ("caq", "coalition")),69    ("PLQ", ("plq", "qlp", "liberal", "libéral")),70    ("PQ", ("parti québécois", "parti quebecois", "pq")),71    ("QS", ("québec solidaire", "quebec solidaire", "qs")),72    ("ADQ", ("adq", "action démocratique", "action democratique")),73    ("PCQ", ("pcq", "conservative", "conservateur")),74    ("AUT", ("other", "others", "autres")),75]767778def _party_of(col: str) -> str | None:79    c = col.lower().strip()80    for party, pats in PARTY_PATTERNS:81        for pat in pats:82            if pat in c:83                return party84    return None858687def parse_election_polls(html: str, year: int) -> list[dict]:88    """Tables de sondages d'une page d'élection historique → dicts normalisés.89    Tolère les deux mises en page : « Timeline of opinion polls » (multi-niveau)90    et « Date(s)/Firm/Sample » (2008)."""91    meta = ELECTIONS[year]92    parties = meta["parties"]93    out, seen = [], set()94    for t in pd.read_html(StringIO(html)):95        t = t.copy()96        t.columns = _flat_cols(t)97        cols = {str(c).lower().strip(): c for c in t.columns}98        org_col = next((cols[k] for k in cols99                        if "polling" in k and "organi" in k or k == "firm"100                        or "polling firm" in k), None)101        date_col = next((cols[k] for k in cols102                         if "last date" in k or k.startswith("date")), None)103        party_cols = {}104        for k, orig in cols.items():105            p = _party_of(k)106            if p and p in parties and p not in party_cols:107                party_cols[p] = orig108        if org_col is None or date_col is None or len(109                [p for p in party_cols if p != "AUT"]) < 3:110            continue111        n_col = next((cols[k] for k in cols if "sample" in k), None)112        for _, r in t.iterrows():113            org = r.get(org_col)114            if org is None or pd.isna(org):115                continue116            org = str(org)117            if "election" in org.lower() or len(org) > 60:118                continue119            end = _parse_date(r.get(date_col), year)120            if end is None or abs((end - meta["date"]).days) > 4 * 365:121                continue122            shares = {}123            for p, c in party_cols.items():124                v = _num(r.get(c))125                if v is not None and 0 <= v <= 80:126                    shares[p] = v127            if len([p for p in shares if p != "AUT"]) < 3:128                continue129            if "AUT" not in shares:130                s = sum(shares.values())131                if 80 <= s <= 100:132                    shares["AUT"] = round(100 - s, 1)133            n = _num(r.get(n_col)) if n_col is not None else None134            key = (norm_pollster(org), end.isoformat())135            if key in seen:136                continue137            seen.add(key)138            out.append({"pollster": norm_pollster(org),139                        "field_end": end.isoformat(),140                        "sample_size": int(n) if n and n > 40 else None,141                        "mode": "unknown", "shares": shares,142                        "source_url": meta["url"]})143    return out144145146def load_polls(year: int, refresh: bool = False) -> list[dict]:147    """Sondages d'une élection historique (cache CSV, réseau si absent)."""148    HIST_DIR.mkdir(parents=True, exist_ok=True)149    f = HIST_DIR / f"polls_{year}.csv"150    if f.exists() and not refresh:151        df = pd.read_csv(f)152        polls: dict[tuple, dict] = {}153        for _, r in df.iterrows():154            key = (r["pollster"], r["field_end"])155            p = polls.setdefault(key, {156                "pollster": r["pollster"], "field_end": r["field_end"],157                "sample_size": None if pd.isna(r["sample_size"]) else int(r["sample_size"]),158                "mode": r.get("mode", "unknown"), "shares": {}})159            p["shares"][r["party"]] = float(r["raw_value"])160        return list(polls.values())161    html = httpx.get(ELECTIONS[year]["url"], headers=UA, timeout=90,162                     follow_redirects=True).text163    polls = parse_election_polls(html, year)164    recs = []165    accessed = datetime.now(timezone.utc).isoformat()166    for p in polls:167        for party, v in p["shares"].items():168            recs.append({**{k: p[k] for k in p if k != "shares"},169                         "party": party, "raw_value": v, "accessed_at": accessed})170    pd.DataFrame(recs).to_csv(f, index=False)171    log.info("polls %s: %d sondages mis en cache", year, len(polls))172    return polls173174175def to_model_polls(polls: list[dict]) -> list[dict]:176    """Format consommé par fit_trend (field_end en date, parts normalisées)."""177    out = []178    for p in polls:179        total = sum(p["shares"].values()) or 100.0180        out.append({"id": None, "pollster": p["pollster"],181                    "field_end": date.fromisoformat(p["field_end"]),182                    "sample_size": p["sample_size"], "mode": p.get("mode", "unknown"),183                    "shares": {k: v * 100.0 / total for k, v in p["shares"].items()}})184    return out185