Frontend v3 « sharp » + ingestion de sondages parfaite (v2.1.1)
Frontend : - Refonte style.css : header compact blur, drawer hamburger mobile (verrou, backdrop, Escape), safe-areas, anti-zoom iOS 16px, cartes dégradées + hover lift, focus rings, skeleton shimmer, clamp typographique, prefers-reduced-motion - Charts : thème Inter, grille recessive, tooltips arrondis/blur, légendes cercle, dégradés sous courbe (areaGrad), traits lumineux (lineGlow), pastilles endLabel, barres à coins arrondis, ResizeObserver - Palette des partis validée (dataviz : CVD ΔE≥20, contraste ≥3:1, 2 surfaces) Sondages (« ça doit être parfait ») : - Parseur Wikipédia par SECTION (« Opinion polls »/« Campaign period » acceptées, ventilations langue/région et projections exclues) — plus de seuil de 20 rangées : la table de campagne entre dès sa 1re rangée - Mode de collecte par maison (design effects réels), vraie plage de terrain, indécis parsés - Upsert par (maison, fin de terrain) : n/MoE/chiffres révisés mis à jour, jamais dupliqués; purge des doublons hérités - Boucle de garantie radar→base (statut intégré/en attente), pipeline 3 h en campagne Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
12 changed files +1,856 −1,522
modified
METHODOLOGY.md
+11 −2
@@ -27,8 +27,17 @@ Ce document est la référence auditable du système. La version grand public es | |||||
| 27 | Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée. | 27 | Chaque enregistrement conserve : source, URL, date d'accès, valeur brute, valeur normalisée. | ||
| 28 | Les HTML bruts sont archivés dans `backend/data/raw/`. | 28 | Les HTML bruts sont archivés dans `backend/data/raw/`. | ||
| 29 | 29 | ||||
| 30 | −Dédoublonnage des sondages : clé (maison, fin de terrain, taille d'échantillon[, parti]); | 30 | +Dédoublonnage des sondages : clé (maison, fin de terrain); un même sondage publié à | ||
| 31 | −un même sondage publié à plusieurs endroits n'est compté qu'une fois. | 31 | +plusieurs endroits n'est compté qu'une fois, et une fiche déjà en base est **mise à | ||
| 32 | +jour** (taille, MoE, chiffres révisés) plutôt que dupliquée quand Wikipédia la complète. | ||||
| 33 | +Les tables de sondages sont sélectionnées par leur **section** de page (« Opinion | ||||
| 34 | +polls », « Campaign period »…) — la table de campagne est captée dès sa première | ||||
| 35 | +rangée; les ventilations (langue, région) et projections sont exclues. Le mode de | ||||
| 36 | +collecte est attribué par maison (Léger web, Mainstreet/Pallas IVR, SEGMA téléphone…) | ||||
| 37 | +pour des design effects réels; la plage de terrain complète est parsée. En période de | ||||
| 38 | +campagne (≤ 45 j), le pipeline passe de 6 h à **3 h**. Boucle de garantie : chaque | ||||
| 39 | +sondage détecté par le radar de veille doit se retrouver en base (statut « intégré »), | ||||
| 40 | +sinon l'alerte reste visible dans l'admin et le point du jour. | ||||
| 32 | 41 | ||||
| 33 | ## 2. Pondération des sondages | 42 | ## 2. Pondération des sondages | ||
| 34 | 43 | ||||
modified
backend/app/ingest/firecrawl_watch.py
+31 −0
@@ -264,6 +264,36 @@ def sweep_news(db: Session) -> dict: | |||||
| 264 | return {"documents": added, "scores": scored} | 264 | return {"documents": added, "scores": scored} | ||
| 265 | 265 | ||||
| 266 | 266 | ||||
| 267 | +def reconcile_radar(db: Session) -> dict: | ||||
| 268 | + """Boucle de garantie : chaque sondage détecté par le radar doit finir dans | ||||
| 269 | + la base. Une détection avec maison connue est marquée « intégré » quand un | ||||
| 270 | + sondage de cette maison a été ajouté/actualisé autour de la détection; | ||||
| 271 | + sinon elle reste « nouveau » = alerte visible (admin + point du jour).""" | ||||
| 272 | + from datetime import date as _date | ||||
| 273 | + cutoff = datetime.now(timezone.utc) - timedelta(days=14) | ||||
| 274 | + pending = (db.query(Mo.WebSignal) | ||||
| 275 | + .filter(Mo.WebSignal.kind == "poll-radar", | ||||
| 276 | + Mo.WebSignal.status == "nouveau", | ||||
| 277 | + Mo.WebSignal.detected_at >= cutoff, | ||||
| 278 | + Mo.WebSignal.pollster.isnot(None)).all()) | ||||
| 279 | + integrated, waiting = 0, 0 | ||||
| 280 | + for sig in pending: | ||||
| 281 | + d0 = sig.detected_at.date() | ||||
| 282 | + match = (db.query(Mo.Poll).join(Mo.Pollster) | ||||
| 283 | + .filter(Mo.Pollster.name == sig.pollster, | ||||
| 284 | + Mo.Poll.field_end >= d0 - timedelta(days=10), | ||||
| 285 | + Mo.Poll.field_end <= d0 + timedelta(days=3)).first()) | ||||
| 286 | + if match: | ||||
| 287 | + sig.status = "intégré" | ||||
| 288 | + sig.extra = {**(sig.extra or {}), "poll_id": match.id, | ||||
| 289 | + "field_end": match.field_end.isoformat()} | ||||
| 290 | + integrated += 1 | ||||
| 291 | + else: | ||||
| 292 | + waiting += 1 | ||||
| 293 | + db.commit() | ||||
| 294 | + return {"intégrés": integrated, "en_attente": waiting} | ||||
| 295 | + | ||||
| 296 | + | ||||
| 267 | # --------------------------------------------------------------------------- | 297 | # --------------------------------------------------------------------------- | ||
| 268 | def run_watch(db: Session) -> dict: | 298 | def run_watch(db: Session) -> dict: | ||
| 269 | """Cycle complet de veille — chaque mission est isolée (échec toléré).""" | 299 | """Cycle complet de veille — chaque mission est isolée (échec toléré).""" | ||
@@ -271,6 +301,7 @@ def run_watch(db: Session) -> dict: | |||||
| 271 | return {"skipped": "Firecrawl désactivé ou clé absente"} | 301 | return {"skipped": "Firecrawl désactivé ou clé absente"} | ||
| 272 | report: dict = {} | 302 | report: dict = {} | ||
| 273 | for name, fn in [("radar_sondages", radar_polls), | 303 | for name, fn in [("radar_sondages", radar_polls), | ||
| 304 | + ("réconciliation", reconcile_radar), | ||||
| 274 | ("satisfaction", fetch_gov_satisfaction), | 305 | ("satisfaction", fetch_gov_satisfaction), | ||
| 275 | ("presse_elargie", sweep_news)]: | 306 | ("presse_elargie", sweep_news)]: | ||
| 276 | try: | 307 | try: | ||
modified
backend/app/ingest/wikipedia.py
+79 −9
@@ -88,6 +88,14 @@ REGION_RIDINGS = { | |||||
| 88 | "Abitibi-Est", "Abitibi-Ouest", "Rouyn-Noranda–Témiscamingue", "Ungava"], | 88 | "Abitibi-Est", "Abitibi-Ouest", "Rouyn-Noranda–Témiscamingue", "Ungava"], | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | +# Mode de collecte habituel par maison (design effects §2 de la méthodologie). | ||||
| 92 | +HOUSE_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 | +} | ||||
| 98 | + | ||||
| 91 | POLLSTER_ALIASES = { | 99 | POLLSTER_ALIASES = { | ||
| 92 | "leger": "Léger", "léger": "Léger", "leger marketing": "Léger", | 100 | "leger": "Léger", "léger": "Léger", "leger marketing": "Léger", | ||
| 93 | "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet", | 101 | "mainstreet research": "Mainstreet", "mainstreet": "Mainstreet", | ||
@@ -170,6 +178,49 @@ def _parse_date(x, default_year: int | None = None) -> date | None: | |||||
| 170 | return None | 178 | return None | ||
| 171 | 179 | ||||
| 172 | 180 | ||||
| 181 | +def _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 dp | ||||
| 185 | + 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, end | ||||
| 190 | + lead = parts[0].strip().rstrip(",") | ||||
| 191 | + try: | ||||
| 192 | + # « September 30 » hérite de l'année/du mois de la fin si absents | ||||
| 193 | + 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, end | ||||
| 196 | + except (ValueError, OverflowError): | ||||
| 197 | + pass | ||||
| 198 | + return None, end | ||||
| 199 | + | ||||
| 200 | + | ||||
| 201 | +# Sections de la page dont les tables sont des sondages NATIONAUX; on exclut | ||||
| 202 | +# explicitement les ventilations (langue, région, âge) et les projections. | ||||
| 203 | +SECTION_ACCEPT = re.compile(r"opinion poll|campaign period|pre-?campaign", re.I) | ||||
| 204 | +SECTION_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) | ||||
| 207 | + | ||||
| 208 | + | ||||
| 209 | +def 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 de | ||||
| 212 | + 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 = 0 | ||||
| 217 | + 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 += 1 | ||||
| 221 | + yield section, m.group(0) | ||||
| 222 | + | ||||
| 223 | + | ||||
| 173 | def _flat_cols(df: pd.DataFrame) -> list[str]: | 224 | def _flat_cols(df: pd.DataFrame) -> list[str]: | ||
| 174 | out = [] | 225 | out = [] | ||
| 175 | for c in df.columns: | 226 | for c in df.columns: | ||
@@ -221,14 +272,19 @@ def parse_poll_table(df: pd.DataFrame, election_year: int, source_url: str) -> l | |||||
| 221 | continue | 272 | continue | ||
| 222 | n = _num(r.get(cols.get("sample size", cols.get("sample", "")))) | 273 | n = _num(r.get(cols.get("sample size", cols.get("sample", "")))) | ||
| 223 | moe = _num(r.get(cols.get("moe", ""))) | 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 None | ||||
| 277 | + start, _ = _parse_date_range(r.get(date_col), election_year) | ||||
| 278 | + house = norm_pollster(org) | ||||
| 224 | rows.append({ | 279 | rows.append({ | ||
| 225 | − "pollster": norm_pollster(org), | 280 | + "pollster": house, | ||
| 226 | "field_end": end.isoformat(), | 281 | "field_end": end.isoformat(), | ||
| 227 | − "field_start": (end - timedelta(days=3)).isoformat(), | 282 | + "field_start": (start or (end - timedelta(days=3))).isoformat(), | ||
| 228 | "sample_size": int(n) if n else None, | 283 | "sample_size": int(n) if n else None, | ||
| 229 | "moe": abs(moe) if moe else None, | 284 | "moe": abs(moe) if moe else None, | ||
| 285 | + "undecided": undecided, | ||||
| 230 | "population": "adults", | 286 | "population": "adults", | ||
| 231 | − "mode": "unknown", | 287 | + "mode": HOUSE_MODES.get(house, "unknown"), | ||
| 232 | "region": "QC", | 288 | "region": "QC", | ||
| 233 | "source_name": "Wikipédia (agrégation)", | 289 | "source_name": "Wikipédia (agrégation)", | ||
| 234 | "source_url": source_url, | 290 | "source_url": source_url, | ||
@@ -238,12 +294,26 @@ def parse_poll_table(df: pd.DataFrame, election_year: int, source_url: str) -> l | |||||
| 238 | 294 | ||||
| 239 | 295 | ||||
| 240 | def parse_polls_2026(html: str) -> list[dict]: | 296 | def parse_polls_2026(html: str) -> list[dict]: | ||
| 241 | − tables = pd.read_html(StringIO(html)) | 297 | + """Tables de sondages NATIONAUX de la page 2026, sélectionnées par leur | ||
| 242 | − out = [] | 298 | + section (« Opinion polls », « Campaign period »…) — aucune contrainte de | ||
| 243 | − for t in tables: | 299 | + taille : la table de campagne est captée dès sa première rangée. Les | ||
| 244 | − cols = " ".join(str(c).lower() for c in t.columns) | 300 | + ventilations (langue, région, projections) sont exclues par section.""" | ||
| 245 | − if "polling organisation" in cols and "caq" in cols and len(t) > 20: | 301 | + out, seen = [], set() | ||
| 246 | − out.extend(parse_poll_table(t, 2026, URL_2026)) | 302 | + for section, thtml in iter_section_tables(html): | ||
| 303 | + if not SECTION_ACCEPT.search(section) or SECTION_REJECT.search(section): | ||||
| 304 | + continue | ||||
| 305 | + try: | ||||
| 306 | + tables = pd.read_html(StringIO(thtml)) | ||||
| 307 | + except ValueError: | ||||
| 308 | + continue | ||||
| 309 | + 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 fois | ||||
| 315 | + seen.add(key) | ||||
| 316 | + out.append(row) | ||||
| 247 | return out | 317 | return out | ||
| 248 | 318 | ||||
| 249 | 319 | ||||
modified
backend/app/main.py
+7 −2
@@ -46,7 +46,10 @@ async def rate_limiter(request: Request, call_next): | |||||
| 46 | 46 | ||||
| 47 | 47 | ||||
| 48 | async def _scheduler(): | 48 | async def _scheduler(): | ||
| 49 | − """Rafraîchissement automatique du pipeline (collecte + modèle + simulation).""" | 49 | + """Rafraîchissement automatique du pipeline (collecte + modèle + simulation). | ||
| 50 | + Cadence resserrée à 3 h en période de campagne (≤ 45 jours du scrutin) : | ||||
| 51 | + les sondages tombent chaque jour, ils doivent entrer sans délai.""" | ||||
| 52 | + from datetime import date as _date | ||||
| 50 | from .pipeline import run_pipeline | 53 | from .pipeline import run_pipeline | ||
| 51 | await asyncio.sleep(60) | 54 | await asyncio.sleep(60) | ||
| 52 | while True: | 55 | while True: | ||
@@ -55,7 +58,9 @@ async def _scheduler(): | |||||
| 55 | log.info(f"pipeline: {report}") | 58 | log.info(f"pipeline: {report}") | ||
| 56 | except Exception as e: # le service continue même si un cycle échoue | 59 | except Exception as e: # le service continue même si un cycle échoue | ||
| 57 | log.error(f"pipeline en échec: {e}") | 60 | log.error(f"pipeline en échec: {e}") | ||
| 58 | − await asyncio.sleep(settings.refresh_interval_hours * 3600) | 61 | + days_left = (settings.election_date - _date.today()).days | ||
| 62 | + hours = 3.0 if 0 <= days_left <= 45 else settings.refresh_interval_hours | ||||
| 63 | + await asyncio.sleep(hours * 3600) | ||||
| 59 | 64 | ||||
| 60 | 65 | ||||
| 61 | @asynccontextmanager | 66 | @asynccontextmanager | ||
modified
backend/app/seed.py
+54 −11
@@ -93,8 +93,28 @@ def seed_all(db: Session | None = None) -> dict: | |||||
| 93 | party=p, defaults=dict(pct=float(r[p]))) | 93 | party=p, defaults=dict(pct=float(r[p]))) | ||
| 94 | obj.pct = float(r[p]) | 94 | obj.pct = float(r[p]) | ||
| 95 | 95 | ||||
| 96 | + # Purge des doublons hérités de l'ancienne clé (maison, fin, taille) : | ||||
| 97 | + # un même sondage réingéré quand Wikipédia complétait sa taille. | ||||
| 98 | + n_dupes = 0 | ||||
| 99 | + seen_polls: dict[tuple, Mo.Poll] = {} | ||||
| 100 | + for poll in db.query(Mo.Poll).order_by(Mo.Poll.id.asc()).all(): | ||||
| 101 | + key = (poll.pollster_id, poll.field_end, poll.election_id) | ||||
| 102 | + kept = seen_polls.get(key) | ||||
| 103 | + if kept is None: | ||||
| 104 | + seen_polls[key] = poll | ||||
| 105 | + else: # garder la version la plus complète (taille connue, + de partis) | ||||
| 106 | + a = (kept.sample_size is not None, len(kept.results), -kept.id) | ||||
| 107 | + b = (poll.sample_size is not None, len(poll.results), -poll.id) | ||||
| 108 | + if b > a: | ||||
| 109 | + seen_polls[key], poll = poll, kept | ||||
| 110 | + db.delete(poll) | ||||
| 111 | + n_dupes += 1 | ||||
| 112 | + if n_dupes: | ||||
| 113 | + db.flush() | ||||
| 114 | + stats["dupes_purgés"] = n_dupes | ||||
| 115 | + | ||||
| 96 | # Sondages | 116 | # Sondages | ||
| 97 | − n_polls = 0 | 117 | + n_polls, n_updated = 0, 0 | ||
| 98 | for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)): | 118 | for fname, el in (("polls_2026.csv", el26), ("polls_2022.csv", el22)): | ||
| 99 | f = DATA_DIR / fname | 119 | f = DATA_DIR / fname | ||
| 100 | if not f.exists(): | 120 | if not f.exists(): | ||
@@ -107,30 +127,53 @@ def seed_all(db: Session | None = None) -> dict: | |||||
| 107 | pol, _ = get_or_create(db, Mo.Pollster, name=pollster) | 127 | pol, _ = get_or_create(db, Mo.Pollster, name=pollster) | ||
| 108 | first = grp.iloc[0] | 128 | first = grp.iloc[0] | ||
| 109 | ss = None if pd.isna(first["sample_size"]) else int(first["sample_size"]) | 129 | ss = None if pd.isna(first["sample_size"]) else int(first["sample_size"]) | ||
| 130 | + # correspondance par (maison, fin de terrain) SANS la taille : | ||||
| 131 | + # Wikipédia complète souvent n/MoE après coup — même sondage. | ||||
| 110 | poll = (db.query(Mo.Poll) | 132 | poll = (db.query(Mo.Poll) | ||
| 111 | .filter_by(pollster_id=pol.id, | 133 | .filter_by(pollster_id=pol.id, | ||
| 112 | − field_end=date.fromisoformat(fe), sample_size=ss).first()) | 134 | + field_end=date.fromisoformat(fe)).first()) | ||
| 113 | if poll is None: | 135 | if poll is None: | ||
| 114 | poll = Mo.Poll( | 136 | poll = Mo.Poll( | ||
| 115 | election_id=el.id, pollster_id=pol.id, | 137 | election_id=el.id, pollster_id=pol.id, | ||
| 116 | field_start=date.fromisoformat(first["field_start"]), | 138 | field_start=date.fromisoformat(first["field_start"]), | ||
| 117 | field_end=date.fromisoformat(fe), sample_size=ss, | 139 | field_end=date.fromisoformat(fe), sample_size=ss, | ||
| 118 | − moe=None if pd.isna(first["moe"]) else float(first["moe"]), | 140 | + population=first["population"], | ||
| 119 | − population=first["population"], mode=first["mode"], | ||||
| 120 | region=first["region"], source_name=first["source_name"], | 141 | region=first["region"], source_name=first["source_name"], | ||
| 121 | − source_url=first["source_url"], | ||||
| 122 | accessed_at=datetime.fromisoformat(first["accessed_at"])) | 142 | accessed_at=datetime.fromisoformat(first["accessed_at"])) | ||
| 123 | db.add(poll); db.flush() | 143 | db.add(poll); db.flush() | ||
| 124 | n_polls += 1 | 144 | n_polls += 1 | ||
| 125 | − existing = {pr.party for pr in poll.results} | 145 | + # métadonnées raffinées à chaque passage (n, MoE, mode, indécis, URL) | ||
| 146 | + if ss is not None: | ||||
| 147 | + poll.sample_size = ss | ||||
| 148 | + if not pd.isna(first["moe"]): | ||||
| 149 | + poll.moe = float(first["moe"]) | ||||
| 150 | + if "undecided" in first and not pd.isna(first.get("undecided")): | ||||
| 151 | + poll.undecided = float(first["undecided"]) | ||||
| 152 | + if first["mode"] and str(first["mode"]) != "nan" and ( | ||||
| 153 | + poll.mode in (None, "unknown") or poll.mode != first["mode"]): | ||||
| 154 | + poll.mode = first["mode"] | ||||
| 155 | + if not pd.isna(first["source_url"]): | ||||
| 156 | + poll.source_url = first["source_url"] | ||||
| 157 | + # résultats : création OU correction si Wikipédia a révisé un chiffre | ||||
| 126 | total = grp["raw_value"].sum() | 158 | total = grp["raw_value"].sum() | ||
| 159 | + by_party = {pr.party: pr for pr in poll.results} | ||||
| 160 | + changed = False | ||||
| 127 | for _, row in grp.iterrows(): | 161 | for _, row in grp.iterrows(): | ||
| 128 | − if row["party"] in existing: | 162 | + raw = float(row["raw_value"]) | ||
| 129 | − continue | 163 | + normalized = round(raw * 100.0 / total, 3) | ||
| 130 | − poll.results.append(Mo.PollResult( | 164 | + pr = by_party.get(row["party"]) | ||
| 131 | − party=row["party"], raw_value=float(row["raw_value"]), | 165 | + if pr is None: | ||
| 132 | − normalized_value=round(float(row["raw_value"]) * 100.0 / total, 3))) | 166 | + poll.results.append(Mo.PollResult( | ||
| 167 | + party=row["party"], raw_value=raw, | ||||
| 168 | + normalized_value=normalized)) | ||||
| 169 | + elif abs(pr.raw_value - raw) > 1e-9: | ||||
| 170 | + pr.raw_value = raw | ||||
| 171 | + pr.normalized_value = normalized | ||||
| 172 | + changed = True | ||||
| 173 | + if changed: | ||||
| 174 | + n_updated += 1 | ||||
| 133 | stats["new_polls"] = n_polls | 175 | stats["new_polls"] = n_polls | ||
| 176 | + stats["updated_polls"] = n_updated | ||||
| 134 | 177 | ||||
| 135 | # Événements clés | 178 | # Événements clés | ||
| 136 | for d, title, kind, imp in KEY_EVENTS: | 179 | for d, title, kind, imp in KEY_EVENTS: | ||
modified
backend/data/polls_2022.csv
+911 −911
Large diff (1,822 lines) is not loaded by default to keep the page fast.Load diff
modified
backend/data/polls_2026.csv
+413 −413
Large diff (826 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/assets/app.js
+93 −27
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/assets/style.css
+239 −136
Large diff (493 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/aujourdhui.html
+3 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/index.html
+10 −5
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/signaux.html
+5 −4
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff