SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
19 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%

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>
Simon-Pierre Boucher committed 24 days ago (Aug 30, 2026) parent 90a8573

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