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%

Wikipédia FR = seconde source d'agrégation (sondages de campagne plus frais)

- parse_polls_2026_fr : « Liste de sondages sur les élections générales
  québécoises de 2026 » (fr.wikipedia) — colonnes CAQ/PLQ/QS/PQ/PCQ/Autres,
  dates françaises, sondeur/commanditaire, échantillon, ME; union dédupliquée
  avec la page EN par (maison, fin de terrain) dans refresh_all
- Résout le retard EN pendant la campagne : Pallas 29 août (CAQ 24, PLQ 22,
  QS 9, PQ 29, PCQ 16, n=1108) et Synopsis corrigé au 27 août entrent
  AUTOMATIQUEMENT au prochain cycle
- Garde-fous : rangées-bannières d'événements (dates/cellules fusionnées)
  exclues; extraction d'articles scopée par PHRASE avec garde RÉGIONALE
  (un sondage « grande région de Québec » ne devient jamais national);
  qc125/338 restent détection seulement; date de terrain estimée ÉTIQUETÉE
- Hebdo automatique : replay + ablation régénérés par le scheduler

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 23 days ago (Sep 1, 2026) parent dac7da8

2 changed files +133 −12

modified backend/app/ingest/pollster_reports.py +48 −12
@@ -74,19 +74,43 @@ N_RE = re.compile(r"(?:n\s*=\s*|aupr[èe]s\s+de\s+|échantillon\s+de\s+)"
74 74 r"([\d\s ,]{3,6})", re.I)
75 75
76 76
77 +REGIONAL_RE = re.compile(
78 + r"grande?\s+région|région\s+de\s+(qu[ée]bec|montr[ée]al)|couronne|\b450\b|"
79 + r"île\s+de\s+montr[ée]al|chez\s+les\s+(francophones|anglophones|jeunes|"
80 + r"femmes|hommes|a[îi]n[ée]s)|région\s+métropolitaine", re.I)
81 +NATIONAL_RE = re.compile(
82 + r"échelle\s+nationale|ensemble\s+du\s+qu[ée]bec|à\s+travers\s+le\s+qu[ée]bec|"
83 + r"intentions\s+de\s+vote|province", re.I)
84 +
85 +
77 86 def extract_toplines(md: str) -> dict:
78 − """Extraction PAR RÈGLES des intentions nationales d'un rapport.
79 − Retourne {shares, field_end, sample_size, confidence, raisons}."""
87 + """Extraction PAR RÈGLES des intentions NATIONALES d'un rapport ou article.
88 +
89 + Scopée par PHRASE : une phrase contenant un marqueur régional/démographique
90 + (« grande région de Québec », « chez les jeunes »…) n'alimente JAMAIS les
91 + toplines nationales — les articles mêlent souvent les deux. Les phrases à
92 + ancre nationale (« à l'échelle nationale ») sont lues en priorité."""
80 93 reasons, shares = [], {}
81 − # fenêtre autour de la première mention « intentions de vote »
82 − idx = md.lower().find("intentions de vote")
83 − window = md[max(0, idx - 300):idx + 3500] if idx >= 0 else md[:5000]
84 − for party, pat in PARTY_RES.items():
85 − m = re.search(pat + r"[^%\n]{0,60}?(\d{1,2})\s*%", window)
86 − if m:
87 − v = float(m.group(1))
88 − if 1 <= v <= 60:
89 − shares[party] = v
94 + text = md[:20000]
95 + sentences = re.split(r"(?<=[.!?])\s+|\n{2,}", text)
96 + natl = [s for s in sentences if NATIONAL_RE.search(s)
97 + and not REGIONAL_RE.search(s)]
98 + neutral = [s for s in sentences if not NATIONAL_RE.search(s)
99 + and not REGIONAL_RE.search(s)]
100 + for pool in (natl, neutral):
101 + for s in pool:
102 + for party, pat in PARTY_RES.items():
103 + if party in shares:
104 + continue
105 + m = re.search(pat + r"[^%\n]{0,120}?(\d{1,2})\s*%", s)
106 + if m:
107 + v = float(m.group(1))
108 + if 1 <= v <= 60:
109 + shares[party] = v
110 + if len([p for p in shares if p != "AUT"]) >= 4:
111 + break
112 + if not shares and REGIONAL_RE.search(text):
113 + reasons.append("contenu possiblement régional/démographique seulement")
90 114 total = sum(shares.values())
91 115 field_end = None
92 116 m = FIELD_RE.search(md[:6000])
@@ -98,6 +122,14 @@ def extract_toplines(md: str) -> dict:
98 122 field_end = date(year, month, int(m.group(3)))
99 123 except ValueError:
100 124 pass
125 + field_end_estimated = False
126 + if field_end is None and re.search(
127 + r"fin de semaine|cette semaine|derniers jours|week-?end", md[:6000], re.I):
128 + # dates exactes absentes mais terrain très récent affirmé par l'article :
129 + # estimation ÉTIQUETÉE (sert au recoupement ±2 j, jamais présentée
130 + # comme une date officielle — la fiche reste en révision)
131 + field_end = date.today() - timedelta(days=2)
132 + field_end_estimated = True
101 133 n = None
102 134 mn = N_RE.search(md[:6000])
103 135 if mn:
@@ -113,13 +145,17 @@ def extract_toplines(md: str) -> dict:
113 145 conf += 0.3
114 146 elif shares:
115 147 reasons.append(f"somme implausible ({total:.0f})")
116 − if field_end:
148 + if field_end and not field_end_estimated:
117 149 conf += 0.2
150 + elif field_end_estimated:
151 + conf += 0.1
152 + reasons.append("fin de terrain ESTIMÉE (article : terrain très récent)")
118 153 else:
119 154 reasons.append("dates de terrain introuvables")
120 155 if n:
121 156 conf += 0.1
122 157 return {"shares": shares, "field_end": field_end, "sample_size": n,
158 + "field_end_estimated": field_end_estimated,
123 159 "confidence": round(conf, 2), "reasons": reasons}
124 160
125 161
modified backend/app/ingest/wikipedia.py +85 −0
@@ -317,6 +317,80 @@ def parse_polls_2026(html: str) -> list[dict]:
317 317 return out
318 318
319 319
320 +URL_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}
325 +
326 +
327 +def _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 None
331 + try:
332 + return date(int(m.group(3)), _MOIS[m.group(2).lower()], int(m.group(1)))
333 + except ValueError:
334 + return None
335 +
336 +
337 +def 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) and
348 + any("dernier jour" in str(c).lower() for c in cols)):
349 + continue
350 + 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 + continue
356 + org = str(org)
357 + # rangée-bannière d'événement (cellule fusionnée) : sondeur qui
358 + # commence par une date ou contient un événement → ignorer
359 + if re.match(r"\d", org) or "élection" in org.lower() \
360 + or "déclenchement" in org.lower() or len(org) > 60:
361 + continue
362 + sponsor = None
363 + 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] = v
370 + aut = _num(r.get(cols.get("Autres", "")))
371 + if aut is not None:
372 + shares["AUT"] = aut
373 + if len([p for p in shares if p != "AUT"]) < 4:
374 + continue
375 + if len(set(shares.values())) <= 2: # cellule fusionnée répétée
376 + continue
377 + 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 out
392 +
393 +
320 394 def parse_polls_2022(html: str) -> list[dict]:
321 395 """Tables 'Timeline of opinion polls' de l'article 2022 (campagne + pré-campagne)."""
322 396 tables = pd.read_html(StringIO(html))
@@ -468,6 +542,17 @@ def refresh_all(out_dir: Path | None = None, offline_html: dict[str, str] | None
468 542 html22 = offline_html.get("2022") if offline_html else fetch(URL_2022, "wiki2022")
469 543
470 544 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 doublon
547 + try:
548 + html_fr = (offline_html.get("2026fr") if offline_html
549 + else fetch(URL_2026_FR, "wiki2026fr"))
550 + seen = {(p["pollster"], p["field_end"]) for p in polls26}
551 + extra = [p for p in parse_polls_2026_fr(html_fr)
552 + if (p["pollster"], p["field_end"]) not in seen]
553 + polls26.extend(extra)
554 + except Exception:
555 + pass
471 556 polls22 = parse_polls_2022(html22)
472 557 riding = parse_riding_results_2022(html22)
473 558 turnout = parse_turnout_2022(html22)
474 559