Socle de données v3 (ADDENDUM) — sources canoniques, registres vérifiés, owned/earned
- ingest/elections_quebec.py : DGEQ CANONIQUE — tableau officiel des partis provinciaux autorisés (20 partis, chefs, nb de candidats, date d'autorisation, data/partis_autorises.json) + alertes dérive-chef (jamais de remplacement silencieux) + électeurs/circ. best-effort (GeoJSON brut) - config/social_seeds.json : registre canonique des comptes (provenance official_site=verified / search=pending_review, PLQ 100 % vérifié site officiel, CAQ FB+YT officiels) + config/social_queries.json versionné - ingest/social_account_discovery.py : découverte hebdo depuis les sites officiels (Firecrawl) → promotion verified / WebSignal pending_review / alertes dérive-chef; anti-bruit (ids FB numériques des candidats, plafond) - social_pulse : cibles/requêtes chargées des registres (plus de handles en dur); SocialPost.content_class (owned ≠ discussion publique, §33) + micro-migration SQLite additive (db._migrate) - Cadence : vérifications canoniques + découverte 1×/7 j dans le pipeline - Livrables §42 : DATA_SOURCE_AUDIT.md (inventaire tiers 1-5, statuts réels testés), POPULATION_FEATURES.md (plan StatCan AD→127, cellules MRP), SOCIAL_ACCOUNT_REGISTRY.csv (26 comptes) - 29/29 tests verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
13 changed files +754 −17
added
DATA_SOURCE_AUDIT.md
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +<!-- QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 --> | |
| 2 | +<!-- Auteur : Simon-Pierre Boucher --> | |
| 3 | +<!-- Contact : contact@spboucher.ai --> | |
| 4 | +<!-- https://www.qc-election.com --> | |
| 5 | + | |
| 6 | +# DATA SOURCE AUDIT — QC Élection Forecast v3 « 127 » | |
| 7 | + | |
| 8 | +Inventaire réel des sources (ADDENDUM §42). Hiérarchie : TIER 1 officiel > | |
| 9 | +TIER 2 maisons de sondage > TIER 3 grands médias > TIER 4 plateformes sociales > | |
| 10 | +TIER 5 agrégateurs. Quand une source de tier supérieur contredit une source de | |
| 11 | +tier inférieur, la source supérieure a raison (ex. DGEQ > Wikipédia). | |
| 12 | + | |
| 13 | +| Source | Tier | Type | URL | Données | Fréquence | Historique | Collecteur | Statut | | |
| 14 | +|---|---|---|---|---|---|---|---|---| | |
| 15 | +| Élections Québec (DGEQ) | 1 | officiel | electionsquebec.qc.ca · donnees.electionsquebec.qc.ca | carte 2026 (GeoJSON 127), candidatures + sortants, partis autorisés + chefs, électeurs/circ. (si publié), résultats soir du vote | chaque cycle (carte/candidatures) · hebdo (partis/chefs) | résultats 2018-2022 | `ingest/dgeq.py`, `ingest/elections_quebec.py` | ✅ actif | | |
| 16 | +| Wikipédia EN (sondages) | 5 | agrégateur | en.wikipedia.org/wiki/2026_Quebec_general_election | sondages 2022→2026 (sections « Opinion polls »/« Campaign period », ventilations exclues), retraits, résultats 2022/2018 par circ. | chaque cycle (3 h campagne / 6 h) | 2022 complet | `ingest/wikipedia.py` | ✅ actif | | |
| 17 | +| Wikimedia pageviews | 5 | attention | wikimedia.org/api/rest_v1 | pageviews quotidiens partis+chef·fe·s (fr.wiki), base 180 j | chaque cycle | 180 j glissants | `ingest/wiki_attention.py` | ✅ actif | | |
| 18 | +| Léger (baromètre) | 2 | sondeur | leger360.com (via veille) | % satisfaits du gouvernement → prior de fondamentaux | mensuel (détection auto) | reconstitution 1998-2022 dans `fundamentals.py` | `ingest/firecrawl_watch.py` | ✅ actif (40 % capté 2026-08-11) | | |
| 19 | +| Presse QC (RSS) | 3 | média | Radio-Canada, Le Devoir, La Presse | manchettes politiques → stance/sentiment | chaque cycle | depuis 2026-08 | `ingest/news_rss.py` | ✅ actif | | |
| 20 | +| Presse élargie (Firecrawl search) | 3 | média | api.firecrawl.dev | articles hors RSS (JdM/JdQ, TVA, régionaux via index), radar de NOUVEAUX sondages | chaque cycle | — | `ingest/firecrawl_watch.py` | ✅ actif | | |
| 21 | +| Polymarket (Gamma) | 5 | marché | gamma-api.polymarket.com | P(plus de sièges) implicite, volume | 15 min (cache) | — | `ingest/markets.py` | ✅ actif (ensemble 88/12) | | |
| 22 | +| Actor Apify qc-social-pulse | 4 | social | api.apify.com (actor aI5AYtFfeDn3KAanf, compte gorgeous_thistle) | IG/FB/TikTok comptes officiels (OWNED, engagement seulement) + YouTube recherche & COMMENTAIRES PUBLICS (discussion) | chaque cycle | journalisé `apify_runs` | `ingest/social_pulse.py` + `apify/qc-social-pulse/` | ✅ actif | | |
| 23 | +| Reddit r/Quebec | 4 | social | via index Google (Serper) | titres + extraits réels (discussion publique) | chaque cycle | — | `ingest/social_pulse.py` | ✅ actif (accès direct/RSS/proxy/BrightData tous murés — testés 2026-08-30) | | |
| 24 | +| Mastodon | 4 | social | mastodon.social, mstdn.ca (tags publics) | posts #quebec/#polqc/#assnat | chaque cycle | — | `ingest/social_pulse.py` | ✅ actif | | |
| 25 | +| Lemmy.ca | 4 | social | lemmy.ca/api/v3 | posts politiques QC | chaque cycle | — | `ingest/social_pulse.py` | ✅ actif | | |
| 26 | +| Bluesky | 4 | social | public.api.bsky.app | recherche de posts | — | — | — | ❌ 403 même en résidentiel (testé 2026-08-30) — candidat actor avec session | | |
| 27 | +| X/Twitter | 4 | social | syndication | — | — | — | actor best-effort | ⚠️ endpoints publics morts; nécessite actor avec session (à évaluer) | | |
| 28 | +| Google Trends | 5 | attention | — | — | — | — | — | ⏳ non branché (pytrends instable; l'attention Wikipédia couvre le besoin) | | |
| 29 | +| Statistique Canada (Recensement 2021) | 1 | population | www12.statcan.gc.ca | âge, langue, revenu, locataires, scolarité, densité par AD | statique + estimations | 2021 | ⏳ Phase D | ⏳ planifié — voir POPULATION_FEATURES.md | | |
| 30 | +| ISQ | 1 | population/éco | statistique.quebec.ca | estimations démographiques récentes, séries QC | — | — | ⏳ Phase D | ⏳ planifié | | |
| 31 | +| Assemblée nationale | 1 | officiel | assnat.qc.ca | ministres, fonctions, biographies | — | — | ⏳ Phase E (candidate features) | ⏳ planifié | | |
| 32 | + | |
| 33 | +## Registres (config/, versionnés git) | |
| 34 | +- `backend/config/social_seeds.json` — comptes des partis/chef·fe·s avec provenance | |
| 35 | + (`official_site` = vérifié, `search` = pending_review) + date de vérification. | |
| 36 | + Découverte/promotion automatique hebdomadaire (`ingest/social_account_discovery.py`) | |
| 37 | + + alertes `dérive-chef`/`compte-social`. | |
| 38 | +- `backend/config/social_queries.json` — requêtes sociales versionnées (partis, | |
| 39 | + chefs, enjeux, tags). | |
| 40 | +- `SOCIAL_ACCOUNT_REGISTRY.csv` — export plat du registre (généré). | |
| 41 | + | |
| 42 | +## Règles appliquées | |
| 43 | +1. Aucun compte inventé — provenance + date pour chaque handle (§ addendum). | |
| 44 | +2. OWNED (contenu des partis) ≠ EARNED (discussion citoyenne) : les comptes | |
| 45 | + officiels n'alimentent QUE l'indice d'engagement; seuls les contenus citoyens | |
| 46 | + (commentaires YouTube, Reddit, Mastodon, Lemmy) entrent dans le sentiment | |
| 47 | + (`social_posts.content_class`). | |
| 48 | +3. « 20 000 posts » ≠ « 20 000 personnes » — les libellés publics parlent de | |
| 49 | + publications/comptes observés, jamais d'électeurs. | |
| 50 | +4. Un échec d'acteur/de source n'empêche jamais la publication du forecast | |
| 51 | + (isolation par étape + `apify_runs` + data-health). | |
modified
METHODOLOGY.md
+13 −0
@@ -110,6 +110,19 @@ sondages dans la presse — alerte avec provenance, aucune ingestion automatique | ||
| 110 | 110 | chiffres; (b) extraction du % de satisfaits (baromètres Léger); (c) presse élargie |
| 111 | 111 | au-delà des RSS. Toutes les pages brutes sont archivées (`data/raw/firecrawl/`). |
| 112 | 112 | |
| 113 | +**Socle de données v3 (ADDENDUM)** : hiérarchie de sources (TIER 1 officiel → | |
| 114 | +TIER 5 agrégateurs, inventaire complet dans `DATA_SOURCE_AUDIT.md`); Élections | |
| 115 | +Québec est CANONIQUE (partis autorisés + chefs + candidats scrapés chaque | |
| 116 | +semaine, alerte « dérive-chef » si divergence — jamais de remplacement | |
| 117 | +silencieux); registre de comptes sociaux versionné (`config/social_seeds.json`, | |
| 118 | +provenance + date, découverte automatique hebdomadaire depuis les sites | |
| 119 | +officiels, promotion `pending_review → verified`); requêtes sociales versionnées | |
| 120 | +(`config/social_queries.json`); séparation STRICTE contenu officiel (engagement | |
| 121 | +seulement) / discussion citoyenne (sentiment) via `social_posts.content_class`; | |
| 122 | +plan du feature store démographique StatCan→127 circonscriptions dans | |
| 123 | +`POPULATION_FEATURES.md` (interpolation pondérée par population des aires de | |
| 124 | +diffusion — jamais de proxy fédéral). | |
| 125 | + | |
| 113 | 126 | **Signaux web avancés (v2.1)** : |
| 114 | 127 | - **Attention Wikipédia** (API Wikimedia, pageviews des pages des partis et chef·fe·s, |
| 115 | 128 | fr.wikipédia) — ajoute du pouvoir prédictif au-delà des sondages+fondamentaux |
added
POPULATION_FEATURES.md
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +<!-- QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 --> | |
| 2 | +<!-- Auteur : Simon-Pierre Boucher --> | |
| 3 | +<!-- Contact : contact@spboucher.ai --> | |
| 4 | +<!-- https://www.qc-election.com --> | |
| 5 | + | |
| 6 | +# POPULATION FEATURES — plan du feature store démographique (Phase D) | |
| 7 | + | |
| 8 | +Objectif : `data/population/riding_2026_demographics.(csv|parquet)` — une ligne | |
| 9 | +par circonscription 2026 avec les distributions nécessaires au MRP et aux | |
| 10 | +facteurs géographiques. RÈGLE : ne jamais inventer une variable absente des | |
| 11 | +données; ne jamais utiliser une circonscription FÉDÉRALE comme proxy d'une | |
| 12 | +provinciale. | |
| 13 | + | |
| 14 | +## Géographie (le cœur du problème) | |
| 15 | +La carte provinciale 2026 (127) est NOUVELLE (délimitation 2025) — aucun profil | |
| 16 | +de recensement prêt n'existe pour elle. | |
| 17 | + | |
| 18 | +Méthode retenue (ADDENDUM §4) : | |
| 19 | +1. Aires de diffusion (AD, ~5 000 hab. max) du Recensement 2021 — géométries | |
| 20 | + StatCan (fichier des limites, format cartographique). | |
| 21 | +2. Intersection géométrique AD × GeoJSON DGEQ 2026 (127) — `shapely`. | |
| 22 | +3. Interpolation pondérée par la POPULATION des AD (population-weighted; | |
| 23 | + area-weighted en repli quand l'AD chevauche plusieurs circonscriptions). | |
| 24 | +4. Contrôles : somme des populations interpolées ≈ population du Québec; | |
| 25 | + comparaison aux électeurs inscrits DGEQ par circonscription. | |
| 26 | + | |
| 27 | +## Variables (Recensement 2021 — profil des AD) | |
| 28 | +| Variable | Source (profil 2021) | Transformation | Usage prévu | | |
| 29 | +|---|---|---|---| | |
| 30 | +| population, pop. 18+ | StatCan | somme pondérée | poids MRP, participation | | |
| 31 | +| âge (18-24/25-34/35-44/45-54/55-64/65+) | StatCan | parts | cellules MRP, z_age | | |
| 32 | +| genre | StatCan | parts | cellules MRP | | |
| 33 | +| langue maternelle (fr/en/autre) + connaissance fr-en | StatCan | parts | cellules MRP, z_franco | | |
| 34 | +| immigration, période d'immigration | StatCan | parts | features géo | | |
| 35 | +| revenu médian des ménages | StatCan | niveau + z | features géo/éco | | |
| 36 | +| scolarité (universitaire) | StatCan | part + z | features géo | | |
| 37 | +| locataires / propriétaires | StatCan | parts + z | features géo (logement) | | |
| 38 | +| densité, urbain/rural | StatCan (géo) | niveau + z | facteurs latents | | |
| 39 | +| chômage | StatCan | taux + z | fondamentaux locaux | | |
| 40 | + | |
| 41 | +Standardisation : `z_*` = (x − moyenne des 127) / écart-type — pour le moteur de | |
| 42 | +similarité de circonscriptions (§29 : top-10 semblables, partial pooling). | |
| 43 | + | |
| 44 | +## Cellules MRP (§5) | |
| 45 | +`circonscription × âge(6) × genre(2) × langue(3)` = 127 × 36 cellules au départ | |
| 46 | +(éducation/tenure ajoutées seulement si l'identification le permet). Partial | |
| 47 | +pooling obligatoire; cellules quasi vides fusionnées. | |
| 48 | + | |
| 49 | +## Vieillissement 2021 → 2026 (§30) | |
| 50 | +Base 2021 + ajustement par les estimations démographiques ISQ/StatCan (région), | |
| 51 | +avec incertitude explicite. Jamais l'affirmation que la population 2026 = 2021. | |
| 52 | + | |
| 53 | +## Déjà en place (amorce) | |
| 54 | +- Électeurs inscrits par circonscription : conservés depuis le GeoJSON DGEQ brut | |
| 55 | + quand la propriété est publiée (`ingest/elections_quebec.refresh_electors`, | |
| 56 | + stockés dans `districts.notes.electors`). | |
| 57 | +- Participation 2022 par circonscription (`districts.turnout_2022`). | |
| 58 | +- Régions de modélisation (13) et baselines 2022/2018 par circonscription. | |
| 59 | + | |
| 60 | +## Non-objectifs | |
| 61 | +Pas de minorités visibles ni de variables sensibles sans justification | |
| 62 | +méthodologique documentée; pas de coefficients démographiques inventés. | |
added
SOCIAL_ACCOUNT_REGISTRY.csv
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +Entity,Type,Party,Platform,Handle,URL,Verified,Source,VerifiedAt | |
| 2 | +Coalition avenir Québec,parti,CAQ,facebook,101058256034207,https://www.facebook.com/101058256034207,verified,official_site,2026-08-30 | |
| 3 | +Coalition avenir Québec,parti,CAQ,youtube,channel/UC7iv0H5EVTzx0RrBCjgu0ew,https://www.youtube.com/channel/UC7iv0H5EVTzx0RrBCjgu0ew,verified,official_site,2026-08-30 | |
| 4 | +Coalition avenir Québec,parti,CAQ,x,coalitionavenir,https://x.com/coalitionavenir,pending_review,search,2026-08-30 | |
| 5 | +Coalition avenir Québec,parti,CAQ,instagram,coalition_avenir_quebec,https://www.instagram.com/coalition_avenir_quebec/,pending_review,search,2026-08-30 | |
| 6 | +Coalition avenir Québec,parti,CAQ,tiktok,coalitionavenirquebec,https://www.tiktok.com/@coalitionavenirquebec,pending_review,search,2026-08-30 | |
| 7 | +Christine Fréchette,chef,CAQ,instagram,frechette_christine,https://www.instagram.com/frechette_christine/,pending_review,search,2026-08-30 | |
| 8 | +Parti québécois,parti,PQ,x,partiquebecois,https://x.com/partiquebecois,pending_review,search,2026-08-30 | |
| 9 | +Parti québécois,parti,PQ,facebook,lepartiquebecois,https://www.facebook.com/lepartiquebecois,pending_review,search,2026-08-30 | |
| 10 | +Parti québécois,parti,PQ,instagram,partiquebecois,https://www.instagram.com/partiquebecois/,pending_review,search,2026-08-30 | |
| 11 | +Parti québécois,parti,PQ,tiktok,partiquebecois,https://www.tiktok.com/@partiquebecois,pending_review,search,2026-08-30 | |
| 12 | +Parti québécois,parti,PQ,youtube,channel/UCUhFy8q8gse395l9E2VZDAQ,https://www.youtube.com/channel/UCUhFy8q8gse395l9E2VZDAQ,pending_review,search,2026-08-30 | |
| 13 | +Paul St-Pierre Plamondon,chef,PQ,instagram,psppqc,https://www.instagram.com/psppqc/,pending_review,search,2026-08-30 | |
| 14 | +Parti libéral du Québec,parti,PLQ,x,LiberalQuebec,https://x.com/LiberalQuebec,verified,official_site,2026-08-30 | |
| 15 | +Parti libéral du Québec,parti,PLQ,facebook,LiberalQuebec,https://www.facebook.com/LiberalQuebec,verified,official_site,2026-08-30 | |
| 16 | +Parti libéral du Québec,parti,PLQ,instagram,liberalquebec,https://www.instagram.com/liberalquebec/,verified,official_site,2026-08-30 | |
| 17 | +Parti libéral du Québec,parti,PLQ,tiktok,liberalqc,https://www.tiktok.com/@liberalqc,verified,official_site,2026-08-30 | |
| 18 | +Parti libéral du Québec,parti,PLQ,youtube,PartiLiberalduQuebec,https://www.youtube.com/PartiLiberalduQuebec,verified,official_site,2026-08-30 | |
| 19 | +Québec solidaire,parti,QS,x,QuebecSolidaire,https://x.com/QuebecSolidaire,pending_review,search,2026-08-30 | |
| 20 | +Québec solidaire,parti,QS,facebook,Quebecsolidaire,https://www.facebook.com/Quebecsolidaire,pending_review,search,2026-08-30 | |
| 21 | +Québec solidaire,parti,QS,instagram,quebecsolidaire,https://www.instagram.com/quebecsolidaire/,pending_review,search,2026-08-30 | |
| 22 | +Québec solidaire,parti,QS,tiktok,quebecsolidaire,https://www.tiktok.com/@quebecsolidaire,pending_review,search,2026-08-30 | |
| 23 | +Québec solidaire,parti,QS,youtube,user/QuebecSolidaire,https://www.youtube.com/user/QuebecSolidaire,pending_review,search,2026-08-30 | |
| 24 | +Parti conservateur du Québec,parti,PCQ,youtube,channel/UCf4Y6bCQgdFCQXqREQ7IMIw,https://www.youtube.com/channel/UCf4Y6bCQgdFCQXqREQ7IMIw,verified,official_site,2026-08-30 | |
| 25 | +Parti conservateur du Québec,parti,PCQ,tiktok,ericduhaime_pcq,https://www.tiktok.com/@ericduhaime_pcq,pending_review,search,2026-08-30 | |
| 26 | +Éric Duhaime,chef,PCQ,x,E_Duhaime,https://x.com/E_Duhaime,pending_review,search,2026-08-30 | |
| 27 | +Éric Duhaime,chef,PCQ,instagram,ericduhaimequebec,https://www.instagram.com/ericduhaimequebec/,pending_review,search,2026-08-30 | |
modified
backend/app/db.py
+20 −0
@@ -32,3 +32,23 @@ def get_db(): | ||
| 32 | 32 | def init_db() -> None: |
| 33 | 33 | from . import models # noqa: F401 |
| 34 | 34 | Base.metadata.create_all(engine) |
| 35 | + _migrate() | |
| 36 | + | |
| 37 | + | |
| 38 | +def _migrate() -> None: | |
| 39 | + """Micro-migrations SQLite : create_all n'ALTÈRE pas les tables existantes — | |
| 40 | + on ajoute ici les colonnes manquantes (idempotent, additif seulement).""" | |
| 41 | + from sqlalchemy import inspect, text | |
| 42 | + added = { | |
| 43 | + "social_posts": [("content_class", "VARCHAR(20) DEFAULT 'public_discussion'")], | |
| 44 | + } | |
| 45 | + insp = inspect(engine) | |
| 46 | + with engine.begin() as conn: | |
| 47 | + for table, cols in added.items(): | |
| 48 | + if table not in insp.get_table_names(): | |
| 49 | + continue | |
| 50 | + existing = {c["name"] for c in insp.get_columns(table)} | |
| 51 | + for name, ddl in cols: | |
| 52 | + if name not in existing: | |
| 53 | + conn.execute(text( | |
| 54 | + f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")) | |
added
backend/app/ingest/elections_quebec.py
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 | |
| 2 | +# Auteur : Simon-Pierre Boucher | |
| 3 | +# Contact : contact@spboucher.ai | |
| 4 | +# https://www.qc-election.com | |
| 5 | +"""Élections Québec (DGEQ) — SOURCE CANONIQUE (ADDENDUM §2, TIER 1). | |
| 6 | + | |
| 7 | +Complète l'ingestion DGEQ existante (carte, candidatures — ingest/dgeq.py) : | |
| 8 | + | |
| 9 | + * **partis autorisés** : liste officielle scrapeée (Firecrawl) et archivée | |
| 10 | + (`data/partis_autorises.json`) — la plateforme ne fige jamais la liste; | |
| 11 | + * **vérification des chefs** : le chef affiché par la source officielle prime | |
| 12 | + sur le registre; toute divergence lève une alerte (jamais de remplacement | |
| 13 | + silencieux); | |
| 14 | + * **électeurs par circonscription** : conservés depuis les propriétés du | |
| 15 | + GeoJSON officiel lorsque publiés (amorce du feature store de population). | |
| 16 | + | |
| 17 | +Quand Élections Québec contredit Wikipédia, Élections Québec a raison. | |
| 18 | +""" | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import json | |
| 22 | +import logging | |
| 23 | +import re | |
| 24 | +from datetime import date | |
| 25 | + | |
| 26 | +from sqlalchemy.orm import Session | |
| 27 | + | |
| 28 | +from ..config import DATA_DIR, settings | |
| 29 | +from .. import models as Mo | |
| 30 | + | |
| 31 | +log = logging.getLogger("elections-quebec") | |
| 32 | + | |
| 33 | +URL_PARTIS = ("https://www.electionsquebec.qc.ca/partis-et-autres-entites-" | |
| 34 | + "politiques/partis-politiques/") | |
| 35 | +OUT_PARTIS = DATA_DIR / "partis_autorises.json" | |
| 36 | + | |
| 37 | +# Rangée du tableau DGEQ des partis PROVINCIAUX : | |
| 38 | +# | [Nom](…/fiche-du-parti/ID/ID/) | n candidats | Chef | date d'autorisation | | |
| 39 | +_ROW_RE = re.compile( | |
| 40 | + r"\|\s*\[([^\]]+)\]\((https://www\.electionsquebec\.qc\.ca/[^)]*" | |
| 41 | + r"fiche-du-parti/[^)]*)\)\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|") | |
| 42 | + | |
| 43 | + | |
| 44 | +def refresh_authorized_parties(db: Session) -> dict: | |
| 45 | + """Scrape le tableau officiel des partis provinciaux autorisés | |
| 46 | + (nom, fiche, nombre de candidats, chef, date d'autorisation).""" | |
| 47 | + from .firecrawl_watch import scrape_markdown | |
| 48 | + md = scrape_markdown(URL_PARTIS) | |
| 49 | + if not md: | |
| 50 | + return {"skipped": "page DGEQ inaccessible"} | |
| 51 | + parties = [] | |
| 52 | + for m in _ROW_RE.finditer(md): | |
| 53 | + name, url, n_cand, leader, authorized = m.groups() | |
| 54 | + if "fiche-du-parti-municipal" in url: | |
| 55 | + continue | |
| 56 | + parties.append({"name": name.strip(), "fiche_url": url, | |
| 57 | + "candidates": int(n_cand), | |
| 58 | + "leader": leader.strip(), | |
| 59 | + "authorized_since": authorized.strip()}) | |
| 60 | + if len(parties) < 5: | |
| 61 | + return {"skipped": f"extraction trop pauvre ({len(parties)} partis)"} | |
| 62 | + OUT_PARTIS.write_text(json.dumps( | |
| 63 | + {"as_of": date.today().isoformat(), "source_url": URL_PARTIS, | |
| 64 | + "parties": parties}, ensure_ascii=False, indent=1)) | |
| 65 | + # vérification des chefs des partis principaux vs registre interne | |
| 66 | + alerts = 0 | |
| 67 | + for p in db.query(Mo.Party).all(): | |
| 68 | + if not p.leader or p.code == "AUT": | |
| 69 | + continue | |
| 70 | + match = next((x for x in parties | |
| 71 | + if p.name.lower() in x["name"].lower() | |
| 72 | + or x["name"].lower() in p.name.lower()), None) | |
| 73 | + if match and match["leader"]: | |
| 74 | + reg_last = p.leader.split()[-1].lower() | |
| 75 | + if reg_last not in match["leader"].lower(): | |
| 76 | + url = f"{URL_PARTIS}#chef-{p.code}-{date.today().isoformat()}" | |
| 77 | + if not db.query(Mo.WebSignal).filter_by(url=url, | |
| 78 | + kind="dérive-chef").first(): | |
| 79 | + db.add(Mo.WebSignal( | |
| 80 | + kind="dérive-chef", status="pending_review", url=url, | |
| 81 | + title=(f"DGEQ indique « {match['leader']} » pour " | |
| 82 | + f"{p.name} — registre : « {p.leader} »"), | |
| 83 | + extra={"party": p.code, "dgeq_leader": match["leader"], | |
| 84 | + "registry_leader": p.leader})) | |
| 85 | + alerts += 1 | |
| 86 | + db.commit() | |
| 87 | + return {"partis_autorisés": len(parties), "alertes_chef": alerts} | |
| 88 | + | |
| 89 | + | |
| 90 | +def refresh_electors(db: Session) -> dict: | |
| 91 | + """Électeurs inscrits par circonscription depuis le GeoJSON officiel brut | |
| 92 | + (propriétés NB_ELECT*/ELECTEURS si publiées) — amorce du feature store.""" | |
| 93 | + import httpx | |
| 94 | + from .dgeq import URL_GEOJSON | |
| 95 | + try: | |
| 96 | + r = httpx.get(URL_GEOJSON, timeout=120) | |
| 97 | + r.raise_for_status() | |
| 98 | + feats = r.json().get("features") or [] | |
| 99 | + except Exception as e: | |
| 100 | + return {"skipped": f"GeoJSON inaccessible: {e}"} | |
| 101 | + stored = 0 | |
| 102 | + for f in feats: | |
| 103 | + props = f.get("properties") or {} | |
| 104 | + name = props.get("NM_CEP") or props.get("name") | |
| 105 | + electors = next((props[k] for k in props | |
| 106 | + if "ELECT" in str(k).upper() and | |
| 107 | + isinstance(props[k], (int, float))), None) | |
| 108 | + if not name or electors is None: | |
| 109 | + continue | |
| 110 | + d = db.query(Mo.District).filter(Mo.District.name == name).first() | |
| 111 | + if d is not None: | |
| 112 | + notes = json.loads(d.notes) if (d.notes or "").startswith("{") else {} | |
| 113 | + notes["electors"] = int(electors) | |
| 114 | + d.notes = json.dumps(notes, ensure_ascii=False) | |
| 115 | + stored += 1 | |
| 116 | + db.commit() | |
| 117 | + return {"circonscriptions_avec_électeurs": stored} | |
| 118 | + | |
| 119 | + | |
| 120 | +def run_canonical_checks(db: Session) -> dict: | |
| 121 | + report = {} | |
| 122 | + for name, fn in [("partis_autorisés", refresh_authorized_parties), | |
| 123 | + ("électeurs", refresh_electors)]: | |
| 124 | + try: | |
| 125 | + report[name] = fn(db) | |
| 126 | + except Exception as e: | |
| 127 | + db.rollback() | |
| 128 | + report[name] = f"échec: {e}" | |
| 129 | + return report | |
added
backend/app/ingest/social_account_discovery.py
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 | |
| 2 | +# Auteur : Simon-Pierre Boucher | |
| 3 | +# Contact : contact@spboucher.ai | |
| 4 | +# https://www.qc-election.com | |
| 5 | +"""Découverte et vérification AUTOMATIQUE des comptes sociaux (ADDENDUM §32). | |
| 6 | + | |
| 7 | +Chaque semaine : scrape des sites officiels des partis (Firecrawl) → extraction | |
| 8 | +des liens sociaux → comparaison au registre canonique (`config/social_seeds.json`). | |
| 9 | + | |
| 10 | +- lien officiel qui CONFIRME un handle `pending_review` → promu `verified` | |
| 11 | + (source: official_site) directement dans le fichier; | |
| 12 | +- handle INCONNU découvert → WebSignal `compte-social` en `pending_review` | |
| 13 | + (jamais intégré au dataset officiel sans revue — règle §32); | |
| 14 | +- handle du registre CONTREDIT par le site officiel → alerte `dérive-compte`; | |
| 15 | +- chef affiché sur le site ≠ chef du registre → alerte `dérive-chef` | |
| 16 | + (« ne jamais garder automatiquement un ancien chef », §10). | |
| 17 | +""" | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import json | |
| 21 | +import logging | |
| 22 | +import re | |
| 23 | +from datetime import date | |
| 24 | +from pathlib import Path | |
| 25 | + | |
| 26 | +from sqlalchemy.orm import Session | |
| 27 | + | |
| 28 | +from ..config import BASE_DIR | |
| 29 | +from .. import models as Mo | |
| 30 | + | |
| 31 | +log = logging.getLogger("account-discovery") | |
| 32 | + | |
| 33 | +SEEDS_PATH = BASE_DIR / "config" / "social_seeds.json" | |
| 34 | + | |
| 35 | +PATTERNS = { | |
| 36 | + "x": r'(?:twitter|x)\.com/([A-Za-z0-9_]{2,})', | |
| 37 | + "facebook": r'facebook\.com/([A-Za-z0-9.]{3,})', | |
| 38 | + "instagram": r'instagram\.com/([A-Za-z0-9._]{2,})', | |
| 39 | + "tiktok": r'tiktok\.com/@([A-Za-z0-9._]+)', | |
| 40 | + "youtube": (r'youtube\.com/(channel/[A-Za-z0-9_-]{10,}|@[A-Za-z0-9._-]+' | |
| 41 | + r'|user/[A-Za-z0-9_-]+|c/[A-Za-z0-9_-]+)'), | |
| 42 | + "bluesky": r'bsky\.app/profile/([A-Za-z0-9.-]+)', | |
| 43 | +} | |
| 44 | +NOISE = {"sharer.php", "share", "intent", "plugins", "hashtag", "watch", | |
| 45 | + "embed", "policies", "tr", "privacy", "p", "reel", "stories", | |
| 46 | + "home.php", "profile.php", "wp-content", "legal", "search"} | |
| 47 | + | |
| 48 | + | |
| 49 | +def load_seeds() -> dict: | |
| 50 | + return json.loads(SEEDS_PATH.read_text()) | |
| 51 | + | |
| 52 | + | |
| 53 | +def save_seeds(seeds: dict) -> None: | |
| 54 | + seeds["_verified_at"] = date.today().isoformat() | |
| 55 | + SEEDS_PATH.write_text(json.dumps(seeds, ensure_ascii=False, indent=2)) | |
| 56 | + | |
| 57 | + | |
| 58 | +def extract_handles(html: str) -> dict[str, set[str]]: | |
| 59 | + found: dict[str, set[str]] = {k: set() for k in PATTERNS} | |
| 60 | + for plat, pat in PATTERNS.items(): | |
| 61 | + for m in re.finditer(pat, html or ""): | |
| 62 | + h = m.group(1) | |
| 63 | + if h and h.lower() not in NOISE and "photo" not in h.lower(): | |
| 64 | + found[plat].add(h) | |
| 65 | + return found | |
| 66 | + | |
| 67 | + | |
| 68 | +def _signal(db: Session, kind: str, title: str, url: str, extra: dict) -> None: | |
| 69 | + if not db.query(Mo.WebSignal).filter_by(url=url, kind=kind).first(): | |
| 70 | + db.add(Mo.WebSignal(kind=kind, title=title[:500], url=url, | |
| 71 | + status="pending_review", extra=extra)) | |
| 72 | + | |
| 73 | + | |
| 74 | +def run_discovery(db: Session) -> dict: | |
| 75 | + """Cycle de découverte — tolérant : un site muré n'arrête rien.""" | |
| 76 | + from .firecrawl_watch import scrape_markdown, _enabled, _post | |
| 77 | + if not _enabled(): | |
| 78 | + return {"skipped": "Firecrawl requis pour la découverte"} | |
| 79 | + seeds = load_seeds() | |
| 80 | + report: dict = {} | |
| 81 | + changed = False | |
| 82 | + for code, party in seeds.get("parties", {}).items(): | |
| 83 | + site = party.get("website") | |
| 84 | + if not site: | |
| 85 | + continue | |
| 86 | + data = _post("/scrape", {"url": site, "formats": ["html"]}, timeout=90.0) | |
| 87 | + html = ((data or {}).get("data") or {}).get("html") or "" | |
| 88 | + if not html: | |
| 89 | + report[code] = "site inaccessible" | |
| 90 | + continue | |
| 91 | + found = extract_handles(html) | |
| 92 | + confirmed, new = 0, 0 | |
| 93 | + known = {(p, (a.get("handle") or "").lower()) | |
| 94 | + for p, a in (party.get("accounts") or {}).items()} | |
| 95 | + for plat, handles in found.items(): | |
| 96 | + acc = (party.get("accounts") or {}).get(plat) | |
| 97 | + for h in handles: | |
| 98 | + if acc and acc.get("handle", "").lower() == h.lower(): | |
| 99 | + if acc.get("status") != "verified": # promotion officielle | |
| 100 | + acc["status"] = "verified" | |
| 101 | + acc["source"] = "official_site" | |
| 102 | + confirmed += 1 | |
| 103 | + changed = True | |
| 104 | + elif (plat, h.lower()) not in known: | |
| 105 | + # anti-bruit : les sites listent les pages FB des dizaines | |
| 106 | + # de candidats (ids numériques) — on ne signale un handle | |
| 107 | + # numérique que si AUCUN compte n'est connu sur la | |
| 108 | + # plateforme, et au plus 2 découvertes par plateforme/cycle | |
| 109 | + if h.isdigit() and acc is not None: | |
| 110 | + continue | |
| 111 | + if new >= 2 * len(PATTERNS): | |
| 112 | + break | |
| 113 | + _signal(db, "compte-social", | |
| 114 | + f"Compte découvert — {code} {plat} @{h}", | |
| 115 | + f"{site}#@{h}", | |
| 116 | + {"party": code, "platform": plat, "handle": h, | |
| 117 | + "found_on": site}) | |
| 118 | + new += 1 | |
| 119 | + # dérive de chef : le nom du registre doit apparaître sur le site | |
| 120 | + leader = party.get("leader") or "" | |
| 121 | + surname = leader.split()[-1] if leader else "" | |
| 122 | + if surname and surname.lower() not in html.lower(): | |
| 123 | + _signal(db, "dérive-chef", | |
| 124 | + f"Chef à vérifier — {code} : « {leader} » absent de {site}", | |
| 125 | + f"{site}#chef-{date.today().isoformat()}", | |
| 126 | + {"party": code, "registered_leader": leader}) | |
| 127 | + report[code] = {"confirmés": confirmed, "découverts": new} | |
| 128 | + if changed: | |
| 129 | + save_seeds(seeds) | |
| 130 | + db.commit() | |
| 131 | + return report | |
modified
backend/app/ingest/social_pulse.py
+34 −17
@@ -47,23 +47,40 @@ APIFY_ACTOR = "aI5AYtFfeDn3KAanf" # qc-social-pulse (compte gorgeous_this | ||
| 47 | 47 | APIFY_RUN = ("https://api.apify.com/v2/acts/{act}/run-sync-get-dataset-items" |
| 48 | 48 | "?token={token}&timeout=240&memory=1024") |
| 49 | 49 | |
| 50 | −# Comptes officiels vérifiés (Serper, 2026-08-30) | |
| 51 | −SOCIAL_TARGETS = { | |
| 52 | − "instagram": [("CAQ", "coalition_avenir_quebec"), ("CAQ", "frechette_christine"), | |
| 53 | − ("PQ", "partiquebecois"), ("PQ", "psppqc"), | |
| 54 | − ("PLQ", "liberalquebec"), ("QS", "quebecsolidaire"), | |
| 55 | − ("PCQ", "ericduhaimequebec")], | |
| 56 | − "facebook": [("CAQ", "coalitionavenirquebec"), ("PQ", "partiquebecois"), | |
| 57 | − ("PLQ", "LiberalQuebec"), ("QS", "quebecsolidaire"), | |
| 58 | − ("PCQ", "ericduhaime")], | |
| 59 | − "tiktok": [("PQ", "partiquebecois"), ("QS", "quebecsolidaire"), | |
| 60 | − ("PCQ", "ericduhaime")], | |
| 61 | −} | |
| 62 | −YT_QUERIES = {"CAQ": "CAQ Christine Fréchette élections Québec", | |
| 63 | − "PQ": "Parti québécois PSPP élections", | |
| 64 | − "PLQ": "Charles Milliard parti libéral Québec", | |
| 65 | − "QS": "Québec solidaire élections", | |
| 66 | − "PCQ": "Éric Duhaime conservateur Québec"} | |
| 50 | +# Registres canoniques (ADDENDUM §31/§37) : comptes vérifiés + requêtes | |
| 51 | +# versionnées — chargés depuis config/, jamais inventés dans le code. | |
| 52 | +from ..config import BASE_DIR | |
| 53 | + | |
| 54 | +_SEEDS_PATH = BASE_DIR / "config" / "social_seeds.json" | |
| 55 | +_QUERIES_PATH = BASE_DIR / "config" / "social_queries.json" | |
| 56 | + | |
| 57 | + | |
| 58 | +def _load_registry() -> tuple[dict, dict]: | |
| 59 | + """(SOCIAL_TARGETS par plateforme, YT_QUERIES par parti) depuis les | |
| 60 | + registres. Un compte `pending_review` reste collecté pour l'ENGAGEMENT | |
| 61 | + seulement (owned) — jamais comme opinion; un compte absent est ignoré.""" | |
| 62 | + targets: dict[str, list] = {"instagram": [], "facebook": [], "tiktok": []} | |
| 63 | + yt: dict[str, str] = {} | |
| 64 | + try: | |
| 65 | + seeds = json.loads(_SEEDS_PATH.read_text()) | |
| 66 | + for code, party in (seeds.get("parties") or {}).items(): | |
| 67 | + for group in ("accounts", "leader_accounts"): | |
| 68 | + for plat, acc in (party.get(group) or {}).items(): | |
| 69 | + if plat in targets and acc.get("handle"): | |
| 70 | + targets[plat].append((code, acc["handle"])) | |
| 71 | + except Exception: | |
| 72 | + log.warning("social_seeds.json illisible — cibles vides") | |
| 73 | + try: | |
| 74 | + q = json.loads(_QUERIES_PATH.read_text()) | |
| 75 | + yt = {code: v["youtube_query"] | |
| 76 | + for code, v in (q.get("parties") or {}).items() | |
| 77 | + if v.get("youtube_query")} | |
| 78 | + except Exception: | |
| 79 | + log.warning("social_queries.json illisible — requêtes YouTube vides") | |
| 80 | + return targets, yt | |
| 81 | + | |
| 82 | + | |
| 83 | +SOCIAL_TARGETS, YT_QUERIES = _load_registry() | |
| 67 | 84 | |
| 68 | 85 | MIN_TEXT_LEN = 25 |
| 69 | 86 | MAX_PER_PLATFORM = 80 # plafond de nouveaux posts par plateforme/cycle |
modified
backend/app/models.py
+4 −0
@@ -281,6 +281,10 @@ class SocialPost(Base): | ||
| 281 | 281 | # {party: {"sentiment": s, "stance": st, "method": m}} |
| 282 | 282 | scores: Mapped[dict] = mapped_column(JSON, default=dict) |
| 283 | 283 | query: Mapped[str | None] = mapped_column(String(120), nullable=True) |
| 284 | + # §33 : SÉPARATION STRICTE — public_discussion (citoyens) vs official_content | |
| 285 | + # (partis/candidats) vs media_content. Les sentiments ne se mélangent jamais : | |
| 286 | + # le « sentiment » d'un message officiel n'est pas de l'opinion publique. | |
| 287 | + content_class: Mapped[str] = mapped_column(String(20), default="public_discussion") | |
| 284 | 288 | |
| 285 | 289 | |
| 286 | 290 | class ApifyRun(Base): |
modified
backend/app/pipeline.py
+20 −0
@@ -328,6 +328,26 @@ def run_pipeline(full_refresh: bool = True) -> dict: | ||
| 328 | 328 | except Exception as e: |
| 329 | 329 | report["veille"] = f"échec: {e}" |
| 330 | 330 | _log(db, "veille-firecrawl", "error", traceback.format_exc()) |
| 331 | + try: | |
| 332 | + # source canonique + découverte de comptes : hebdomadaires | |
| 333 | + marker = (db.query(Mo.Indicator) | |
| 334 | + .filter_by(name="canonical_checks_last") | |
| 335 | + .order_by(Mo.Indicator.as_of.desc()).first()) | |
| 336 | + if marker is None or (date.today() - marker.as_of).days >= 7: | |
| 337 | + from .ingest.elections_quebec import run_canonical_checks | |
| 338 | + from .ingest.social_account_discovery import run_discovery | |
| 339 | + report["dgeq_canonique"] = run_canonical_checks(db) | |
| 340 | + report["découverte_comptes"] = run_discovery(db) | |
| 341 | + db.add(Mo.Indicator(name="canonical_checks_last", | |
| 342 | + as_of=date.today(), value=1.0, | |
| 343 | + method="scheduler")) | |
| 344 | + db.commit() | |
| 345 | + _log(db, "canonique-hebdo", "ok", | |
| 346 | + str({**report.get("dgeq_canonique", {}), | |
| 347 | + **report.get("découverte_comptes", {})})[:500]) | |
| 348 | + except Exception as e: | |
| 349 | + report["canonique"] = f"échec: {e}" | |
| 350 | + _log(db, "canonique-hebdo", "error", traceback.format_exc()) | |
| 331 | 351 | try: |
| 332 | 352 | from .ingest.social_pulse import run_pulse |
| 333 | 353 | report["pouls_social"] = run_pulse(db) |
added
backend/config/social_queries.json
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +{ | |
| 2 | + "_doc": "Registre VERSIONNÉ des requêtes sociales (ADDENDUM §37). Toute modification passe par git — la provenance d'un signal inclut la requête qui l'a produit. Attention aux acronymes ambigus (« PQ ») : toujours passer par l'entity linking en aval.", | |
| 3 | + "version": "2026-08-30", | |
| 4 | + "parties": { | |
| 5 | + "CAQ": {"official": "Coalition avenir Québec", "abbr": "CAQ", | |
| 6 | + "leader": "Christine Fréchette", | |
| 7 | + "youtube_query": "CAQ Christine Fréchette élections Québec", | |
| 8 | + "reddit_query": "CAQ"}, | |
| 9 | + "PQ": {"official": "Parti québécois", "abbr": "PQ", | |
| 10 | + "leader": "Paul St-Pierre Plamondon", | |
| 11 | + "youtube_query": "Parti québécois PSPP élections", | |
| 12 | + "reddit_query": "PQ"}, | |
| 13 | + "PLQ": {"official": "Parti libéral du Québec", "abbr": "PLQ", | |
| 14 | + "leader": "Charles Milliard", | |
| 15 | + "youtube_query": "Charles Milliard parti libéral Québec", | |
| 16 | + "reddit_query": "PLQ"}, | |
| 17 | + "QS": {"official": "Québec solidaire", "abbr": "QS", | |
| 18 | + "leader": "Ruba Ghazal et Sol Zanetti", | |
| 19 | + "youtube_query": "Québec solidaire élections", | |
| 20 | + "reddit_query": "QS"}, | |
| 21 | + "PCQ": {"official": "Parti conservateur du Québec", "abbr": "PCQ", | |
| 22 | + "leader": "Éric Duhaime", | |
| 23 | + "youtube_query": "Éric Duhaime conservateur Québec", | |
| 24 | + "reddit_query": "PCQ"} | |
| 25 | + }, | |
| 26 | + "issues": ["logement", "santé", "économie", "immigration", "souveraineté", | |
| 27 | + "éducation", "impôts", "coût de la vie", "environnement", | |
| 28 | + "troisième lien"], | |
| 29 | + "mastodon_tags": ["quebec", "polqc", "assnat"] | |
| 30 | +} | |
added
backend/config/social_seeds.json
+87 −0
@@ -0,0 +1,87 @@ | ||
| 1 | +{ | |
| 2 | + "_doc": "Registre canonique des comptes sociaux (ADDENDUM §8-§11, §31). RÈGLE : aucun compte inventé — chaque handle porte sa source de vérification (official_site = extrait du site officiel du parti; search = repéré par recherche, statut pending_review jusqu'à confirmation) et sa date. La découverte hebdomadaire (social_account_discovery.py) compare ce fichier aux sites officiels et alerte sur tout changement (chef, compte). Format JSON (zéro dépendance).", | |
| 3 | + "_verified_at": "2026-08-30", | |
| 4 | + "parties": { | |
| 5 | + "CAQ": { | |
| 6 | + "official_name": "Coalition avenir Québec", | |
| 7 | + "leader": "Christine Fréchette", | |
| 8 | + "website": "https://coalitionavenirquebec.org", | |
| 9 | + "accounts": { | |
| 10 | + "facebook": {"handle": "101058256034207", "source": "official_site", "status": "verified"}, | |
| 11 | + "youtube": {"handle": "channel/UC7iv0H5EVTzx0RrBCjgu0ew", "source": "official_site", "status": "verified"}, | |
| 12 | + "x": {"handle": "coalitionavenir", "source": "search", "status": "pending_review"}, | |
| 13 | + "instagram": {"handle": "coalition_avenir_quebec", "source": "search", "status": "pending_review"}, | |
| 14 | + "tiktok": {"handle": "coalitionavenirquebec", "source": "search", "status": "pending_review"} | |
| 15 | + }, | |
| 16 | + "leader_accounts": { | |
| 17 | + "instagram": {"handle": "frechette_christine", "source": "search", "status": "pending_review"} | |
| 18 | + } | |
| 19 | + }, | |
| 20 | + "PQ": { | |
| 21 | + "official_name": "Parti québécois", | |
| 22 | + "leader": "Paul St-Pierre Plamondon", | |
| 23 | + "website": "https://pq.org", | |
| 24 | + "accounts": { | |
| 25 | + "x": {"handle": "partiquebecois", "source": "search", "status": "pending_review"}, | |
| 26 | + "facebook": {"handle": "lepartiquebecois", "source": "search", "status": "pending_review"}, | |
| 27 | + "instagram": {"handle": "partiquebecois", "source": "search", "status": "pending_review"}, | |
| 28 | + "tiktok": {"handle": "partiquebecois", "source": "search", "status": "pending_review"}, | |
| 29 | + "youtube": {"handle": "channel/UCUhFy8q8gse395l9E2VZDAQ", "source": "search", "status": "pending_review"} | |
| 30 | + }, | |
| 31 | + "leader_accounts": { | |
| 32 | + "instagram": {"handle": "psppqc", "source": "search", "status": "pending_review"} | |
| 33 | + } | |
| 34 | + }, | |
| 35 | + "PLQ": { | |
| 36 | + "official_name": "Parti libéral du Québec", | |
| 37 | + "leader": "Charles Milliard", | |
| 38 | + "website": "https://plq.org", | |
| 39 | + "accounts": { | |
| 40 | + "x": {"handle": "LiberalQuebec", "source": "official_site", "status": "verified"}, | |
| 41 | + "facebook": {"handle": "LiberalQuebec", "source": "official_site", "status": "verified"}, | |
| 42 | + "instagram": {"handle": "liberalquebec", "source": "official_site", "status": "verified"}, | |
| 43 | + "tiktok": {"handle": "liberalqc", "source": "official_site", "status": "verified"}, | |
| 44 | + "youtube": {"handle": "PartiLiberalduQuebec", "source": "official_site", "status": "verified"} | |
| 45 | + }, | |
| 46 | + "leader_accounts": {} | |
| 47 | + }, | |
| 48 | + "QS": { | |
| 49 | + "official_name": "Québec solidaire", | |
| 50 | + "leader": "Ruba Ghazal et Sol Zanetti", | |
| 51 | + "website": "https://quebecsolidaire.net", | |
| 52 | + "accounts": { | |
| 53 | + "x": {"handle": "QuebecSolidaire", "source": "search", "status": "pending_review"}, | |
| 54 | + "facebook": {"handle": "Quebecsolidaire", "source": "search", "status": "pending_review"}, | |
| 55 | + "instagram": {"handle": "quebecsolidaire", "source": "search", "status": "pending_review"}, | |
| 56 | + "tiktok": {"handle": "quebecsolidaire", "source": "search", "status": "pending_review"}, | |
| 57 | + "youtube": {"handle": "user/QuebecSolidaire", "source": "search", "status": "pending_review"} | |
| 58 | + }, | |
| 59 | + "leader_accounts": {} | |
| 60 | + }, | |
| 61 | + "PCQ": { | |
| 62 | + "official_name": "Parti conservateur du Québec", | |
| 63 | + "leader": "Éric Duhaime", | |
| 64 | + "website": "https://conservateur.quebec", | |
| 65 | + "accounts": { | |
| 66 | + "youtube": {"handle": "channel/UCf4Y6bCQgdFCQXqREQ7IMIw", "source": "official_site", "status": "verified"}, | |
| 67 | + "tiktok": {"handle": "ericduhaime_pcq", "source": "search", "status": "pending_review"} | |
| 68 | + }, | |
| 69 | + "leader_accounts": { | |
| 70 | + "x": {"handle": "E_Duhaime", "source": "search", "status": "pending_review"}, | |
| 71 | + "instagram": {"handle": "ericduhaimequebec", "source": "search", "status": "pending_review"} | |
| 72 | + } | |
| 73 | + } | |
| 74 | + }, | |
| 75 | + "media": { | |
| 76 | + "Radio-Canada": {"website": "https://ici.radio-canada.ca", "rss": "https://ici.radio-canada.ca/rss/92"}, | |
| 77 | + "Le Devoir": {"website": "https://www.ledevoir.com", "rss": "https://www.ledevoir.com/rss/section/politique.xml"}, | |
| 78 | + "La Presse": {"website": "https://www.lapresse.ca", "rss": "https://www.lapresse.ca/actualites/politique/rss"} | |
| 79 | + }, | |
| 80 | + "institutions": { | |
| 81 | + "Élections Québec": {"website": "https://www.electionsquebec.qc.ca", | |
| 82 | + "donnees": "https://donnees.electionsquebec.qc.ca"}, | |
| 83 | + "Assemblée nationale": {"website": "https://www.assnat.qc.ca"}, | |
| 84 | + "Statistique Canada": {"website": "https://www12.statcan.gc.ca/census-recensement/"}, | |
| 85 | + "ISQ": {"website": "https://statistique.quebec.ca"} | |
| 86 | + } | |
| 87 | +} | |
added
backend/data/partis_autorises.json
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +{ | |
| 2 | + "as_of": "2026-08-30", | |
| 3 | + "source_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/", | |
| 4 | + "parties": [ | |
| 5 | + { | |
| 6 | + "name": "Bloc pot", | |
| 7 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00059/2/", | |
| 8 | + "candidates": 0, | |
| 9 | + "leader": "Jean-Patrick Berthiaume", | |
| 10 | + "authorized_since": "18 mars 1998" | |
| 11 | + }, | |
| 12 | + { | |
| 13 | + "name": "Climat Québec - Équipe Martine Ouellet", | |
| 14 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99237/99237/", | |
| 15 | + "candidates": 1, | |
| 16 | + "leader": "Martine Ouellet", | |
| 17 | + "authorized_since": "2 août 2021" | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "name": "Démocratie directe", | |
| 21 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99286/99286/", | |
| 22 | + "candidates": 0, | |
| 23 | + "leader": "Jean Charles Cléroux", | |
| 24 | + "authorized_since": "15 mai 2022" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "name": "Équipe autonomiste", | |
| 28 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00087/29/", | |
| 29 | + "candidates": 0, | |
| 30 | + "leader": "Louis Chandonnet", | |
| 31 | + "authorized_since": "21 mars 2012" | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + "name": "Équipe Christine Fréchette - Coalition avenir Québec", | |
| 35 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00085/27/", | |
| 36 | + "candidates": 22, | |
| 37 | + "leader": "Christine Fréchette", | |
| 38 | + "authorized_since": "14 février 2012" | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + "name": "Parti accès propriété et équité - Équipe Québec debout", | |
| 42 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99341/99341/", | |
| 43 | + "candidates": 3, | |
| 44 | + "leader": "Shawn Lalande McLean", | |
| 45 | + "authorized_since": "22 janvier 2025" | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "name": "Parti canadien du Québec / Canadian Party of Québec", | |
| 49 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99263/99263/", | |
| 50 | + "candidates": 3, | |
| 51 | + "leader": "Joseph Cianflone", | |
| 52 | + "authorized_since": "15 juin 2022" | |
| 53 | + }, | |
| 54 | + { | |
| 55 | + "name": "Parti communiste du Québec", | |
| 56 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99318/99318/", | |
| 57 | + "candidates": 6, | |
| 58 | + "leader": "Adrien Welsh", | |
| 59 | + "authorized_since": "3 mars 2023" | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + "name": "Parti conservateur du Québec", | |
| 63 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00079/22/", | |
| 64 | + "candidates": 21, | |
| 65 | + "leader": "Éric Duhaime", | |
| 66 | + "authorized_since": "25 mars 2009" | |
| 67 | + }, | |
| 68 | + { | |
| 69 | + "name": "Parti culinaire du Québec", | |
| 70 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00106/42/", | |
| 71 | + "candidates": 0, | |
| 72 | + "leader": "Jean-Louis Thémistocle", | |
| 73 | + "authorized_since": "8 juin 2018" | |
| 74 | + }, | |
| 75 | + { | |
| 76 | + "name": "Parti libéral du Québec/Quebec Liberal Party", | |
| 77 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00010/6/", | |
| 78 | + "candidates": 8, | |
| 79 | + "leader": "Charles Milliard", | |
| 80 | + "authorized_since": "22 février 1978" | |
| 81 | + }, | |
| 82 | + { | |
| 83 | + "name": "Parti libertarien du Québec", | |
| 84 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99291/99291/", | |
| 85 | + "candidates": 0, | |
| 86 | + "leader": "Charles Olivier", | |
| 87 | + "authorized_since": "25 juillet 2022" | |
| 88 | + }, | |
| 89 | + { | |
| 90 | + "name": "Parti marxiste-léniniste du Québec", | |
| 91 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00034/7/", | |
| 92 | + "candidates": 0, | |
| 93 | + "leader": "Christine Dandenault", | |
| 94 | + "authorized_since": "5 mai 1989" | |
| 95 | + }, | |
| 96 | + { | |
| 97 | + "name": "Parti nul", | |
| 98 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00080/23/", | |
| 99 | + "candidates": 0, | |
| 100 | + "leader": "Renaud Blais", | |
| 101 | + "authorized_since": "9 avril 2009" | |
| 102 | + }, | |
| 103 | + { | |
| 104 | + "name": "Parti populaire du Québec", | |
| 105 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99352/99352/", | |
| 106 | + "candidates": 0, | |
| 107 | + "leader": "Sylvain Pariseau", | |
| 108 | + "authorized_since": "9 janvier 2026" | |
| 109 | + }, | |
| 110 | + { | |
| 111 | + "name": "Parti québécois", | |
| 112 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00016/8/", | |
| 113 | + "candidates": 55, | |
| 114 | + "leader": "Paul St-Pierre Plamondon", | |
| 115 | + "authorized_since": "22 février 1978" | |
| 116 | + }, | |
| 117 | + { | |
| 118 | + "name": "Parti vert du Québec/Green Party of Québec", | |
| 119 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00061/10/", | |
| 120 | + "candidates": 9, | |
| 121 | + "leader": "Alex Tyrrell", | |
| 122 | + "authorized_since": "14 novembre 2001" | |
| 123 | + }, | |
| 124 | + { | |
| 125 | + "name": "Présence Québec", | |
| 126 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99331/99331/", | |
| 127 | + "candidates": 9, | |
| 128 | + "leader": "Guillaume Tremblay", | |
| 129 | + "authorized_since": "3 avril 2024" | |
| 130 | + }, | |
| 131 | + { | |
| 132 | + "name": "Québec innovant", | |
| 133 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/99330/99330/", | |
| 134 | + "candidates": 0, | |
| 135 | + "leader": "Alexandre St-Pierre", | |
| 136 | + "authorized_since": "3 avril 2024" | |
| 137 | + }, | |
| 138 | + { | |
| 139 | + "name": "Québec solidaire", | |
| 140 | + "fiche_url": "https://www.electionsquebec.qc.ca/partis-et-autres-entites-politiques/partis-politiques/fiche-du-parti/00039/40/", | |
| 141 | + "candidates": 23, | |
| 142 | + "leader": "Carmen Palardy", | |
| 143 | + "authorized_since": "1er janvier 2018" | |
| 144 | + } | |
| 145 | + ] | |
| 146 | +} | |
| \ No newline at end of file | ||
| 147 | ||