# 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 """Attention Wikipédia — comportement de recherche d'information (v2.1). Littérature : les pageviews Wikipédia ajoutent du pouvoir prédictif AU-DELÀ des sondages et des fondamentaux (Smith & Gustafson, *Public Opinion Quarterly* 2017; Salem & Stephany 2020 — « challenger's best friend ») : contrairement aux réseaux sociaux, consulter une page est une recherche d'information privée, sans mécanisme de rétroaction sociale, et l'attention se porte de façon disproportionnée vers les partis en MOUVEMENT (gains de votes), surtout les challengers. Utilisation (prudente, jamais directionnelle) : 1. **Signal affiché** : parts d'attention 7 j / 28 j par parti (page du parti + pages des chef·fe·s, fr.wikipédia), tendance vs base de 180 j. 2. **Détecteur de turbulence** : un pic d'attention global (z ≥ seuil) signale un moment chaud de campagne → la variance de dérive du forecast est GONFLÉE (multiplicateur borné ≤ 1,45). L'attention ne déplace jamais la moyenne — un pic peut être bon ou mauvais pour un parti; il annonce du mouvement. Source : API REST Wikimedia (pageviews per-article), publique, sans clé. """ from __future__ import annotations import logging from datetime import date, timedelta import httpx import numpy as np from sqlalchemy.orm import Session from ..config import settings from .. import models as Mo log = logging.getLogger("wiki-attention") WM_API = ("https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/" "fr.wikipedia/all-access/user/{article}/daily/{start}/{end}") UA = {"User-Agent": "qc-election-forecast/2.1 (https://www.qc-election.com; " "contact@spboucher.ai)"} # Pages suivies : parti + chef·fe·s (fr.wikipédia) ARTICLES: dict[str, list[str]] = { "CAQ": ["Coalition_avenir_Québec", "Christine_Fréchette"], "PLQ": ["Parti_libéral_du_Québec", "Charles_Milliard"], "PQ": ["Parti_québécois", "Paul_St-Pierre_Plamondon"], "QS": ["Québec_solidaire", "Ruba_Ghazal", "Sol_Zanetti"], "PCQ": ["Parti_conservateur_du_Québec", "Éric_Duhaime"], } BASELINE_DAYS = 180 INDICATOR = "wiki_att_{party}" def _fetch_article(article: str, start: date, end: date) -> dict[date, int]: from urllib.parse import quote url = WM_API.format(article=quote(article, safe=""), start=start.strftime("%Y%m%d"), end=end.strftime("%Y%m%d")) r = httpx.get(url, headers=UA, timeout=30) if r.status_code == 404: # article sans données sur la période return {} r.raise_for_status() out = {} for item in r.json().get("items", []): d = date(int(item["timestamp"][:4]), int(item["timestamp"][4:6]), int(item["timestamp"][6:8])) out[d] = int(item["views"]) return out def refresh(db: Session, days: int = BASELINE_DAYS + 40) -> dict: """Récupère et upsert les pageviews quotidiens (parti + chefs agrégés).""" end = date.today() - timedelta(days=1) # la veille : journée complète # reprise incrémentale : depuis le dernier point stocké (moins 3 j de marge) last = (db.query(Mo.Indicator) .filter(Mo.Indicator.name.like("wiki_att_%")) .order_by(Mo.Indicator.as_of.desc()).first()) start = max(end - timedelta(days=days), (last.as_of - timedelta(days=3)) if last else end - timedelta(days=days)) added = 0 for party, arts in ARTICLES.items(): daily: dict[date, int] = {} for a in arts: try: for d, v in _fetch_article(a, start, end).items(): daily[d] = daily.get(d, 0) + v except Exception as e: log.warning("pageviews %s: %s", a, e) name = INDICATOR.format(party=party) for d, v in daily.items(): row = db.query(Mo.Indicator).filter_by(name=name, as_of=d).first() if row is None: db.add(Mo.Indicator(name=name, as_of=d, value=float(v), source="Wikimedia pageviews (fr)", source_url="https://wikimedia.org/api/rest_v1/", method="wikimedia-api", extra={"articles": arts})) added += 1 else: row.value = float(v) db.commit() return {"points_ajoutés": added, "fenêtre": [start.isoformat(), end.isoformat()]} def signal(db: Session) -> dict: """Parts d'attention, tendances et détecteur de turbulence. Retourne aussi `drift_multiplier` ∈ [1, 1,45] : gonfle la variance de dérive du forecast quand l'attention totale est anormalement élevée (z ≥ 2).""" end = date.today() - timedelta(days=1) start = end - timedelta(days=BASELINE_DAYS) series: dict[str, dict[date, float]] = {} for party in ARTICLES: rows = (db.query(Mo.Indicator) .filter(Mo.Indicator.name == INDICATOR.format(party=party), Mo.Indicator.as_of >= start).all()) series[party] = {r.as_of: r.value for r in rows} all_days = sorted(set().union(*[set(s) for s in series.values()])) if series else [] if len(all_days) < 30: return {"available": False, "drift_multiplier": 1.0, "note": "historique d'attention insuffisant (premier cycle)"} def win_sum(party, n): cut = end - timedelta(days=n) return sum(v for d, v in series[party].items() if d > cut) parties = list(ARTICLES) tot7 = {p: win_sum(p, 7) for p in parties} tot28 = {p: win_sum(p, 28) for p in parties} base = {p: win_sum(p, BASELINE_DAYS) for p in parties} s7, s28, sbase = sum(tot7.values()), sum(tot28.values()), sum(base.values()) out_p, z_max = {}, 0.0 for p in parties: vals = np.array([series[p].get(d, 0.0) for d in all_days]) mu, sd = float(vals[:-7].mean()), float(vals[:-7].std() or 1.0) z_recent = float((np.mean(vals[-3:]) - mu) / sd) z_max = max(z_max, z_recent) share7 = tot7[p] / s7 if s7 else 0.0 share_base = base[p] / sbase if sbase else 0.0 out_p[p] = { "views_7j": int(tot7[p]), "share_7j": round(share7 * 100, 1), "share_28j": round((tot28[p] / s28 if s28 else 0) * 100, 1), "share_base_180j": round(share_base * 100, 1), "delta_share_pp": round((share7 - share_base) * 100, 1), "z_3j": round(z_recent, 2), "sparkline": [int(v) for v in vals[-28:]], } # turbulence : pic d'attention global → variance de campagne gonflée (borné) mult = float(np.clip(1.0 + 0.15 * max(0.0, z_max - 2.0), 1.0, 1.45)) return {"available": True, "parties": out_p, "z_max": round(z_max, 2), "drift_multiplier": round(mult, 3), "as_of": end.isoformat(), "baseline_days": BASELINE_DAYS, "note": ("Signal d'attention (jamais directionnel) : un pic global " "gonfle l'incertitude de campagne, ne déplace aucune moyenne."), "sparkline_dates": [d.isoformat() for d in all_days[-28:]]}