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>
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 | |