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%
25.0 KB · 583 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 électorales depuis Wikipédia (sondages, résultats 2022).67Chaque exécution sauvegarde une copie brute du HTML dans data/raw/ (provenance)8et produit des CSV normalisés dans data/. Le pipeline recharge ensuite ces CSV9dans la base. Toute valeur conserve : source, URL, date d'accès, valeur brute.10"""11from __future__ import annotations1213import re14import unicodedata15from datetime import date, datetime, timedelta, timezone16from io import StringIO17from pathlib import Path1819import httpx20import pandas as pd2122from ..config import DATA_DIR2324URL_2026 = "https://en.wikipedia.org/wiki/2026_Quebec_general_election"25URL_2022 = "https://en.wikipedia.org/wiki/2022_Quebec_general_election"26UA = {"User-Agent": "Mozilla/5.0 (qc-election-forecast; research/aggregation)"}2728# Renommages carte électorale 2025 (ancienne → nouvelle)29RENAMES_2025 = {30    "Johnson": "Daniel-Johnson",31    "Arthabaska": "Arthabaska–L'Érable",32    "Laporte": "Pierre-Laporte",33    "Matane-Matapédia": "Matane-Matapédia-Mitis",34    "Rivière-du-Loup–Témiscouata": "Rivière-du-Loup–Témiscouata–Les Basques",35    "Vimont": "Vimont-Auteuil",36}3738# Nouvelles circonscriptions 2026 : (nom, région, circonscriptions parentes)39NEW_DISTRICTS_2026 = [40    ("Bellefeuille", "Laurentides", ["Saint-Jérôme", "Mirabel", "Argenteuil"]),41    ("Marie-Lacoste-Gérin-Lajoie", "Chaudière-Appalaches et Centre-du-Québec",42     ["Johnson", "Nicolet-Bécancour", "Drummond–Bois-Francs"]),43]4445# Régions de modélisation (13 groupes — navbox Wikipédia fr, arbitrages documentés46# pour les circonscriptions chevauchant deux régions administratives).47REGION_RIDINGS = {48    "Bas-Saint-Laurent–Gaspésie": [49        "Bonaventure", "Côte-du-Sud", "Gaspé", "Îles-de-la-Madeleine",50        "Matane-Matapédia-Mitis", "Rimouski", "Rivière-du-Loup–Témiscouata–Les Basques"],51    "Saguenay–Lac-Saint-Jean et Côte-Nord": [52        "Chicoutimi", "Dubuc", "Duplessis", "Jonquière", "Lac-Saint-Jean",53        "René-Lévesque", "Roberval"],54    "Capitale-Nationale": [55        "Charlesbourg", "Charlevoix–Côte-de-Beaupré", "Chauveau", "Jean-Lesage",56        "Jean-Talon", "La Peltrie", "Louis-Hébert", "Montmorency", "Portneuf",57        "Taschereau", "Vanier-Les Rivières"],58    "Mauricie": ["Champlain", "Laviolette–Saint-Maurice", "Maskinongé", "Trois-Rivières"],59    "Chaudière-Appalaches et Centre-du-Québec": [60        "Arthabaska–L'Érable", "Beauce-Nord", "Beauce-Sud", "Bellechasse",61        "Chutes-de-la-Chaudière", "Drummond–Bois-Francs", "Daniel-Johnson", "Lévis",62        "Lotbinière-Frontenac", "Marie-Lacoste-Gérin-Lajoie", "Mégantic",63        "Nicolet-Bécancour"],64    "Estrie": ["Brome-Missisquoi", "Granby", "Orford", "Richmond", "Saint-François",65               "Sherbrooke"],66    "Montérégie": [67        "Beauharnois", "Borduas", "Chambly", "Châteauguay", "Huntingdon", "Iberville",68        "La Pinière", "Pierre-Laporte", "La Prairie", "Marie-Victorin", "Montarville",69        "Richelieu", "Saint-Hyacinthe", "Saint-Jean", "Sanguinet", "Soulanges",70        "Taillon", "Vachon", "Vaudreuil", "Verchères"],71    "Montréal": [72        "Acadie", "Anjou–Louis-Riel", "Bourassa-Sauvé", "Camille-Laurin",73        "D'Arcy-McGee", "Gouin", "Hochelaga-Maisonneuve", "Jacques-Cartier",74        "Jeanne-Mance–Viger", "LaFontaine", "Laurier-Dorion", "Marguerite-Bourgeoys",75        "Marquette", "Maurice-Richard", "Mercier", "Mont-Royal–Outremont", "Nelligan",76        "Notre-Dame-de-Grâce", "Pointe-aux-Trembles", "Robert-Baldwin", "Rosemont",77        "Saint-Henri–Sainte-Anne", "Sainte-Marie–Saint-Jacques", "Saint-Laurent",78        "Verdun", "Viau", "Westmount–Saint-Louis"],79    "Laval": ["Chomedey", "Fabre", "Laval-des-Rapides", "Mille-Îles", "Sainte-Rose",80              "Vimont-Auteuil"],81    "Lanaudière": ["Berthier", "Joliette", "L'Assomption", "Les Plaines", "Masson",82                   "Repentigny", "Rousseau", "Terrebonne"],83    "Laurentides": ["Argenteuil", "Bellefeuille", "Bertrand", "Blainville",84                    "Deux-Montagnes", "Groulx", "Labelle", "Mirabel", "Prévost",85                    "Saint-Jérôme"],86    "Outaouais": ["Chapleau", "Gatineau", "Hull", "Papineau", "Pontiac"],87    "Abitibi-Témiscamingue et Nord-du-Québec": [88        "Abitibi-Est", "Abitibi-Ouest", "Rouyn-Noranda–Témiscamingue", "Ungava"],89}9091# Mode de collecte habituel par maison (design effects §2 de la méthodologie).92HOUSE_MODES = {93    "Léger": "web", "Mainstreet": "ivr", "Pallas Data": "ivr", "Synopsis": "web",94    "SEGMA": "phone", "Research Co.": "web", "Angus Reid": "web", "EKOS": "ivr",95    "Liaison Strategies": "ivr", "Innovative Research": "web", "CROP": "web",96    "Forum": "ivr", "Abacus Data": "web", "Ipsos": "web",97}9899POLLSTER_ALIASES = {100    "leger": "Léger", "léger": "Léger", "leger marketing": "Léger",101    "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet",102    "mainstreet (exit poll)": "Mainstreet",103    "pallas data": "Pallas Data", "pallas": "Pallas Data",104    "research co.": "Research Co.", "research co": "Research Co.",105    "angus reid institute": "Angus Reid", "angus reid": "Angus Reid",106    "liaison strategies": "Liaison Strategies",107    "segma recherche": "SEGMA", "segma": "SEGMA",108    "ekos": "EKOS",109    "synopsis recherche": "Synopsis", "synopsis": "Synopsis",110    "innovative research group": "Innovative Research",111    "innovative research": "Innovative Research",112}113114115def norm_name(s: str) -> str:116    """Normalise un nom de circonscription (apostrophes, tirets, espaces)."""117    s = unicodedata.normalize("NFC", str(s)).strip()118    s = s.replace("’", "'").replace("–", "–").replace("—", "–")119    s = re.sub(r"\s+", " ", s)120    s = re.sub(r"\s*\[.*?\]\s*$", "", s)  # notes wiki [a]121    return s122123124def _dash_key(s: str) -> str:125    return norm_name(s).replace("–", "-").lower()126127128_RENAMES_BY_KEY = {}129130131def canon_district(s: str) -> str:132    if not _RENAMES_BY_KEY:133        _RENAMES_BY_KEY.update({_dash_key(k): v for k, v in RENAMES_2025.items()})134    n = norm_name(s)135    return _RENAMES_BY_KEY.get(_dash_key(n), n)136137138def _district_key(s: str) -> str:139    return _dash_key(canon_district(s))140141142def norm_pollster(s: str) -> str:143    key = norm_name(s).lower()144    key = re.sub(r"\s*\(.*\)$", "", key).strip()145    return POLLSTER_ALIASES.get(key, norm_name(s))146147148def fetch(url: str, tag: str) -> str:149    r = httpx.get(url, headers=UA, timeout=60, follow_redirects=True)150    r.raise_for_status()151    raw_dir = DATA_DIR / "raw"152    raw_dir.mkdir(parents=True, exist_ok=True)153    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")154    (raw_dir / f"{tag}_{stamp}.html").write_text(r.text)155    return r.text156157158def _num(x) -> float | None:159    if x is None or (isinstance(x, float) and pd.isna(x)):160        return None161    s = str(x).strip().replace("%", "").replace(",", "")162    s = re.sub(r"\[.*?\]", "", s)163    m = re.search(r"-?\d+(\.\d+)?", s)164    return float(m.group()) if m else None165166167def _parse_date(x, default_year: int | None = None) -> date | None:168    from dateutil import parser as dp169    s = re.sub(r"\[.*?\]", "", str(x)).strip()170    if not s or s.lower() in ("nan", "none"):171        return None172    # plages "September 30 – October 2, 2022" → prendre la fin173    s = re.split(r"[–—]|-(?=\s*[A-Z])", s)[-1].strip()174    try:175        d = dp.parse(s, default=datetime(default_year or 2000, 6, 15))176        return d.date()177    except (ValueError, OverflowError):178        return None179180181def _parse_date_range(x, default_year: int | None = None) -> tuple[date | None, date | None]:182    """Plage de terrain « Sept. 30 – Oct. 2, 2026 » → (début, fin).183    Sans plage : (None, fin)."""184    from dateutil import parser as dp185    s = re.sub(r"\[.*?\]", "", str(x)).strip()186    end = _parse_date(x, default_year)187    parts = re.split(r"[–—]|-(?=\s*[A-Z])", s)188    if end is None or len(parts) < 2:189        return None, end190    lead = parts[0].strip().rstrip(",")191    try:192        # « September 30 » hérite de l'année/du mois de la fin si absents193        start = dp.parse(lead, default=datetime(end.year, end.month, end.day)).date()194        if start <= end and (end - start).days <= 45:195            return start, end196    except (ValueError, OverflowError):197        pass198    return None, end199200201# Sections de la page dont les tables sont des sondages NATIONAUX; on exclut202# explicitement les ventilations (langue, région, âge) et les projections.203SECTION_ACCEPT = re.compile(r"opinion poll|campaign period|pre-?campaign", re.I)204SECTION_REJECT = re.compile(205    r"language|region|montreal|quebec city|age|gender|francophone|anglophone|"206    r"projection|leadership|approval|preferred premier|by-?election", re.I)207208209def iter_section_tables(html: str):210    """Itère (en-tête de section, HTML de la table) pour chaque wikitable —211    le contexte de section permet d'accepter la table nationale et la table de212    campagne (même à 1 rangée) tout en rejetant les ventilations régionales."""213    heads = [(m.start(), re.sub(r"<[^>]+>", "", m.group(2)).strip())214             for m in re.finditer(r"<h([2-5])[^>]*>(.*?)</h\1>", html, re.S)]215    section = "?"216    hi = 0217    for m in re.finditer(r'<table class="wikitable.*?</table>', html, re.S):218        while hi < len(heads) and heads[hi][0] < m.start():219            section = heads[hi][1]220            hi += 1221        yield section, m.group(0)222223224def _flat_cols(df: pd.DataFrame) -> list[str]:225    out = []226    for c in df.columns:227        if isinstance(c, tuple):228            parts = [str(p) for p in c if not str(p).startswith("Unnamed")]229            out.append(parts[-1] if parts else str(c[0]))230        else:231            out.append(str(c))232    return out233234235PARTY_COL_MAP = {"caq": "CAQ", "qs": "QS", "pq": "PQ", "liberal": "PLQ", "plq": "PLQ",236                 "pcq": "PCQ", "conservative": "PCQ", "other": "AUT"}237238239def parse_poll_table(df: pd.DataFrame, election_year: int, source_url: str) -> list[dict]:240    """Table de sondages Wikipédia → liste de dicts normalisés."""241    df = df.copy()242    df.columns = _flat_cols(df)243    cols = {c.lower().strip(): c for c in df.columns}244    org_col = next((cols[k] for k in cols if "polling organisation" in k or k == "firm"), None)245    date_col = next((cols[k] for k in cols if "last date" in k or "date" in k), None)246    if org_col is None or date_col is None:247        return []248    party_cols = {}249    for k, orig in cols.items():250        if k in PARTY_COL_MAP:251            party_cols[PARTY_COL_MAP[k]] = orig252    rows = []253    for _, r in df.iterrows():254        org = r.get(org_col)255        if org is None or pd.isna(org):256            continue257        org = str(org)258        vals = [str(r.get(c)) for c in df.columns]259        if len(set(vals)) <= 2 and len(org) > 40:      # rangée-bannière (événement)260            continue261        if "election" in org.lower():                   # rangée résultat officiel262            continue263        end = _parse_date(r.get(date_col), election_year)264        if end is None:265            continue266        shares = {}267        for party, c in party_cols.items():268            v = _num(r.get(c))269            if v is not None:270                shares[party] = v271        if len([p for p in shares if p != "AUT"]) < 4:272            continue273        n = _num(r.get(cols.get("sample size", cols.get("sample", ""))))274        moe = _num(r.get(cols.get("moe", "")))275        und_col = next((cols[k] for k in cols if "undecided" in k or "und." in k), None)276        undecided = _num(r.get(und_col)) if und_col else None277        start, _ = _parse_date_range(r.get(date_col), election_year)278        house = norm_pollster(org)279        rows.append({280            "pollster": house,281            "field_end": end.isoformat(),282            "field_start": (start or (end - timedelta(days=3))).isoformat(),283            "sample_size": int(n) if n else None,284            "moe": abs(moe) if moe else None,285            "undecided": undecided,286            "population": "adults",287            "mode": HOUSE_MODES.get(house, "unknown"),288            "region": "QC",289            "source_name": "Wikipédia (agrégation)",290            "source_url": source_url,291            "shares": shares,292        })293    return rows294295296def parse_polls_2026(html: str) -> list[dict]:297    """Tables de sondages NATIONAUX de la page 2026, sélectionnées par leur298    section (« Opinion polls », « Campaign period »…) — aucune contrainte de299    taille : la table de campagne est captée dès sa première rangée. Les300    ventilations (langue, région, projections) sont exclues par section."""301    out, seen = [], set()302    for section, thtml in iter_section_tables(html):303        if not SECTION_ACCEPT.search(section) or SECTION_REJECT.search(section):304            continue305        try:306            tables = pd.read_html(StringIO(thtml))307        except ValueError:308            continue309        for t in tables:310            cols = " ".join(str(c).lower() for c in t.columns)311            if ("polling organisation" in cols or "firm" in cols) and "caq" in cols:312                for row in parse_poll_table(t, 2026, URL_2026):313                    key = (row["pollster"], row["field_end"], row["sample_size"])314                    if key not in seen:      # une table peut apparaître deux fois315                        seen.add(key)316                        out.append(row)317    return out318319320URL_2026_FR = ("https://fr.wikipedia.org/wiki/Liste_de_sondages_sur_les_"321               "%C3%A9lections_g%C3%A9n%C3%A9rales_qu%C3%A9b%C3%A9coises_de_2026")322_MOIS = {"janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,323         "juillet": 7, "août": 8, "aout": 8, "septembre": 9, "octobre": 10,324         "novembre": 11, "décembre": 12, "decembre": 12}325326327def _parse_date_fr(s: str) -> date | None:328    m = re.search(r"(\d{1,2})(?:er)?\s+([a-zéû]+)\s+(\d{4})", str(s), re.I)329    if not m or m.group(2).lower() not in _MOIS:330        return None331    try:332        return date(int(m.group(3)), _MOIS[m.group(2).lower()], int(m.group(1)))333    except ValueError:334        return None335336337def parse_polls_2026_fr(html: str) -> list[dict]:338    """Wikipédia FR « Liste de sondages… 2026 » — seconde source d'agrégation,339    souvent plus fraîche que la page EN pendant la campagne. Colonnes :340    Dernier jour du sondage | CAQ | PLQ | QS | PQ | PCQ | Autres | Sondeur |341    Échantillon | ME | Source. Dédupliquée avec la page EN par (maison, fin)."""342    out = []343    for t in pd.read_html(StringIO(html)):344        t = t.copy()345        t.columns = _flat_cols(t)346        cols = {str(c).strip(): c for c in t.columns}347        if not ({"CAQ", "PQ", "PLQ"} <= set(cols) and348                any("dernier jour" in str(c).lower() for c in cols)):349            continue350        date_col = next(cols[c] for c in cols if "dernier jour" in c.lower())351        for _, r in t.iterrows():352            end = _parse_date_fr(r.get(date_col))353            org = r.get(cols.get("Sondeur"))354            if end is None or org is None or pd.isna(org):355                continue356            org = str(org)357            # rangée-bannière d'événement (cellule fusionnée) : sondeur qui358            # commence par une date ou contient un événement → ignorer359            if re.match(r"\d", org) or "élection" in org.lower() \360                    or "déclenchement" in org.lower() or len(org) > 60:361                continue362            sponsor = None363            if "/" in org:                      # « Synopsis / La Presse »364                org, sponsor = [x.strip() for x in org.split("/", 1)]365            shares = {}366            for party in ("CAQ", "PLQ", "QS", "PQ", "PCQ"):367                v = _num(r.get(cols.get(party)))368                if v is not None and 0 <= v <= 80:369                    shares[party] = v370            aut = _num(r.get(cols.get("Autres", "")))371            if aut is not None:372                shares["AUT"] = aut373            if len([p for p in shares if p != "AUT"]) < 4:374                continue375            if len(set(shares.values())) <= 2:   # cellule fusionnée répétée376                continue377            n = _num(r.get(cols.get("Échantillon", "")))378            house = norm_pollster(org)379            out.append({"pollster": house,380                        "field_end": end.isoformat(),381                        "field_start": (end - timedelta(days=3)).isoformat(),382                        "sample_size": int(n) if n and n > 40 else None,383                        "moe": abs(_num(r.get(cols.get("ME", ""))) or 0) or None,384                        "population": "adults",385                        "mode": HOUSE_MODES.get(house, "unknown"),386                        "region": "QC",387                        "source_name": "Wikipédia FR (agrégation)"388                                       + (f" · {sponsor}" if sponsor else ""),389                        "source_url": URL_2026_FR,390                        "shares": shares})391    return out392393394def parse_polls_2022(html: str) -> list[dict]:395    """Tables 'Timeline of opinion polls' de l'article 2022 (campagne + pré-campagne)."""396    tables = pd.read_html(StringIO(html))397    out = []398    for t in tables:399        cols = " ".join(str(c).lower() for c in t.columns)400        if "timeline of opinion polls" in cols and "caq" in cols:401            year = 2022402            parsed = parse_poll_table(t, year, URL_2022)403            out.extend(parsed)404    return out405406407def parse_riding_results_2022(html: str) -> pd.DataFrame:408    """Table % par parti et par circonscription (élection 2022)."""409    tables = pd.read_html(StringIO(html))410    target = None411    for t in tables:412        raw = [c for c in t.columns]413        heads = {str(c[0]) if isinstance(c, tuple) else str(c) for c in raw}414        if {"Riding", "CAQ", "QS", "PQ", "PLQ", "PCQ"} <= heads and len(t) > 100:415            target = t416            break417    if target is None:418        raise RuntimeError("Table des résultats par circonscription introuvable")419    # première sous-colonne '%' et 'Change (pp)' de chaque parti420    pct_col: dict[str, object] = {}421    chg_col: dict[str, object] = {}422    name_col = None423    for c in target.columns:424        top = str(c[0]) if isinstance(c, tuple) else str(c)425        sub = str(c[-1]) if isinstance(c, tuple) else ""426        if top == "Riding" and name_col is None:427            name_col = c428        elif top in ("CAQ", "QS", "PQ", "PLQ", "PCQ") and sub == "%" and top not in pct_col:429            pct_col[top] = c430        elif (top in ("CAQ", "QS", "PQ", "PLQ", "PCQ") and sub.startswith("Change")431              and top not in chg_col):432            chg_col[top] = c433    rows = []434    for _, r in target.iterrows():435        name = r.get(name_col)436        if name is None or pd.isna(name):437            continue438        name = norm_name(name)439        if name.lower().startswith(("total", "riding", "source")):440            continue441        rec = {"district": name}442        n_num = 0443        for p, c in pct_col.items():444            v = _num(r.get(c))445            if v is not None:446                n_num += 1447            rec[p] = v if v is not None else 0.0  # parti sans candidat448        if n_num < 3:  # rangée d'en-tête ou de total449            continue450        s = sum(rec[p] for p in pct_col)451        rec["AUT"] = round(max(0.0, 100.0 - s), 2)452        # reconstruction 2018 : % de 2022 − variation (pp)453        for p, c in chg_col.items():454            chg = _num(r.get(c))455            rec[f"prev_{p}"] = round(max(0.0, rec[p] - (chg if chg is not None else rec[p])), 2)456        s18 = sum(rec.get(f"prev_{p}", 0.0) for p in pct_col)457        rec["prev_AUT"] = round(max(0.0, 100.0 - s18), 2)458        rows.append(rec)459    return pd.DataFrame(rows)460461462def parse_turnout_2022(html: str) -> dict[str, float]:463    tables = pd.read_html(StringIO(html))464    for t in tables:465        heads = {str(p) for c in t.columns for p in (c if isinstance(c, tuple) else (c,))}466        if "Turnout" not in heads or len(t) < 100:467            continue468        name_col = t.columns[0]469        turn_col = next((c for c in t.columns470                         if isinstance(c, tuple) and "Turnout" in [str(p) for p in c]471                         and str(c[-1]) == "%"), None)472        if turn_col is not None:473            out = {}474            for _, r in t.iterrows():475                n, v = r.get(name_col), _num(r.get(turn_col))476                if n is not None and not pd.isna(n) and v is not None and 20 < v < 95:477                    out[norm_name(n)] = v478            if len(out) > 100:479                return out480    return {}481482483def parse_retirements_2026(html: str) -> set[str]:484    """Circonscriptions dont le député sortant ne se représente pas."""485    tables = pd.read_html(StringIO(html))486    for t in tables:487        cols = [str(c) for c in t.columns]488        if any("Electoral District" in c for c in cols) and any("Date announced" in c for c in cols):489            col = next(c for c in t.columns if "Electoral District" in str(c))490            return {canon_district(v) for v in t[col].dropna().astype(str)}491    return set()492493494def build_districts(riding_2022: pd.DataFrame, turnout: dict[str, float],495                    retirements: set[str]) -> pd.DataFrame:496    """Construit les 127 circonscriptions 2026 avec référence 2022 (transposition approx.)."""497    parties = ["CAQ", "PCQ", "PLQ", "PQ", "QS", "AUT"]498    base = {norm_name(r["district"]): {p: r[p] for p in parties}499            for _, r in riding_2022.iterrows()}500    region_of = {}501    for reg, names in REGION_RIDINGS.items():502        for n in names:503            region_of[_district_key(n)] = reg504505    rows = []506    for old_name, shares in base.items():507        new_name = canon_district(old_name)508        reg = region_of.get(_district_key(new_name))509        rows.append({510            "district": new_name, "region": reg or "Inconnue",511            **{f"b_{p}": shares[p] for p in parties},512            "turnout_2022": turnout.get(old_name),513            "incumbent_party": max((p for p in parties if p != "AUT"),514                                   key=lambda p: shares[p]),515            "incumbent_running": new_name not in retirements,516            "is_new_2026": False,517            "baseline_source": f"Résultat 2022 ({old_name}) — Wikipédia/DGEQ",518        })519    for name, reg, parents in NEW_DISTRICTS_2026:520        ps = [base[p] for p in parents if p in base]521        shares = {p: round(sum(x[p] for x in ps) / len(ps), 2) for p in parties}522        rows.append({523            "district": name, "region": reg,524            **{f"b_{p}": shares[p] for p in parties},525            "turnout_2022": None,526            "incumbent_party": None, "incumbent_running": False, "is_new_2026": True,527            "baseline_source": "Moyenne des circonscriptions parentes ("528                               + ", ".join(parents) + ") — approximation transposition 2025",529        })530    df = pd.DataFrame(rows)531    missing = df[df.region == "Inconnue"]["district"].tolist()532    if missing:533        raise RuntimeError(f"Régions manquantes pour: {missing}")534    return df535536537def refresh_all(out_dir: Path | None = None, offline_html: dict[str, str] | None = None) -> dict:538    """Télécharge et normalise toutes les données; écrit les CSV de seed."""539    out_dir = out_dir or DATA_DIR540    out_dir.mkdir(parents=True, exist_ok=True)541    html26 = offline_html.get("2026") if offline_html else fetch(URL_2026, "wiki2026")542    html22 = offline_html.get("2022") if offline_html else fetch(URL_2022, "wiki2022")543544    polls26 = parse_polls_2026(html26)545    # seconde agrégation : Wikipédia FR (souvent plus fraîche en campagne);546    # union dédupliquée par (maison, fin de terrain) — la EN fait foi si doublon547    try:548        html_fr = (offline_html.get("2026fr") if offline_html549                   else fetch(URL_2026_FR, "wiki2026fr"))550        from datetime import date as _d551        def _near(house, end_iso, days=1):552            e = _d.fromisoformat(end_iso)553            return any(q["pollster"] == house and554                       abs((_d.fromisoformat(q["field_end"]) - e).days) <= days555                       for q in polls26)556        extra = [p for p in parse_polls_2026_fr(html_fr)557                 if not _near(p["pollster"], p["field_end"])]558        polls26.extend(extra)559    except Exception:560        pass561    polls22 = parse_polls_2022(html22)562    riding = parse_riding_results_2022(html22)563    turnout = parse_turnout_2022(html22)564    retirements = parse_retirements_2026(html26)565    districts = build_districts(riding, turnout, retirements)566567    accessed = datetime.now(timezone.utc).isoformat()568    for polls, tag in ((polls26, "polls_2026"), (polls22, "polls_2022")):569        recs = []570        for p in polls:571            for party, v in p["shares"].items():572                recs.append({**{k: p[k] for k in p if k != "shares"},573                             "party": party, "raw_value": v, "accessed_at": accessed})574        pd.DataFrame(recs).to_csv(out_dir / f"{tag}.csv", index=False)575    districts.to_csv(out_dir / "districts_2026.csv", index=False)576    riding.to_csv(out_dir / "riding_results_2022.csv", index=False)577    return {"polls_2026": len(polls26), "polls_2022": len(polls22),578            "districts": len(districts), "retirements": len(retirements)}579580581if __name__ == "__main__":582    print(refresh_all())583