# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # https://www.qc-election.com """Découverte et vérification AUTOMATIQUE des comptes sociaux (ADDENDUM §32). Chaque semaine : scrape des sites officiels des partis (Firecrawl) → extraction des liens sociaux → comparaison au registre canonique (`config/social_seeds.json`). - lien officiel qui CONFIRME un handle `pending_review` → promu `verified` (source: official_site) directement dans le fichier; - handle INCONNU découvert → WebSignal `compte-social` en `pending_review` (jamais intégré au dataset officiel sans revue — règle §32); - handle du registre CONTREDIT par le site officiel → alerte `dérive-compte`; - chef affiché sur le site ≠ chef du registre → alerte `dérive-chef` (« ne jamais garder automatiquement un ancien chef », §10). """ from __future__ import annotations import json import logging import re from datetime import date from pathlib import Path from sqlalchemy.orm import Session from ..config import BASE_DIR from .. import models as Mo log = logging.getLogger("account-discovery") SEEDS_PATH = BASE_DIR / "config" / "social_seeds.json" PATTERNS = { "x": r'(?:twitter|x)\.com/([A-Za-z0-9_]{2,})', "facebook": r'facebook\.com/([A-Za-z0-9.]{3,})', "instagram": r'instagram\.com/([A-Za-z0-9._]{2,})', "tiktok": r'tiktok\.com/@([A-Za-z0-9._]+)', "youtube": (r'youtube\.com/(channel/[A-Za-z0-9_-]{10,}|@[A-Za-z0-9._-]+' r'|user/[A-Za-z0-9_-]+|c/[A-Za-z0-9_-]+)'), "bluesky": r'bsky\.app/profile/([A-Za-z0-9.-]+)', } NOISE = {"sharer.php", "share", "intent", "plugins", "hashtag", "watch", "embed", "policies", "tr", "privacy", "p", "reel", "stories", "home.php", "profile.php", "wp-content", "legal", "search"} def load_seeds() -> dict: return json.loads(SEEDS_PATH.read_text()) def save_seeds(seeds: dict) -> None: seeds["_verified_at"] = date.today().isoformat() SEEDS_PATH.write_text(json.dumps(seeds, ensure_ascii=False, indent=2)) def extract_handles(html: str) -> dict[str, set[str]]: found: dict[str, set[str]] = {k: set() for k in PATTERNS} for plat, pat in PATTERNS.items(): for m in re.finditer(pat, html or ""): h = m.group(1) if h and h.lower() not in NOISE and "photo" not in h.lower(): found[plat].add(h) return found def _signal(db: Session, kind: str, title: str, url: str, extra: dict) -> None: if not db.query(Mo.WebSignal).filter_by(url=url, kind=kind).first(): db.add(Mo.WebSignal(kind=kind, title=title[:500], url=url, status="pending_review", extra=extra)) def run_discovery(db: Session) -> dict: """Cycle de découverte — tolérant : un site muré n'arrête rien.""" from .firecrawl_watch import scrape_markdown, _enabled, _post if not _enabled(): return {"skipped": "Firecrawl requis pour la découverte"} seeds = load_seeds() report: dict = {} changed = False for code, party in seeds.get("parties", {}).items(): site = party.get("website") if not site: continue data = _post("/scrape", {"url": site, "formats": ["html"]}, timeout=90.0) html = ((data or {}).get("data") or {}).get("html") or "" if not html: report[code] = "site inaccessible" continue found = extract_handles(html) confirmed, new = 0, 0 known = {(p, (a.get("handle") or "").lower()) for p, a in (party.get("accounts") or {}).items()} for plat, handles in found.items(): acc = (party.get("accounts") or {}).get(plat) for h in handles: if acc and acc.get("handle", "").lower() == h.lower(): if acc.get("status") != "verified": # promotion officielle acc["status"] = "verified" acc["source"] = "official_site" confirmed += 1 changed = True elif (plat, h.lower()) not in known: # anti-bruit : les sites listent les pages FB des dizaines # de candidats (ids numériques) — on ne signale un handle # numérique que si AUCUN compte n'est connu sur la # plateforme, et au plus 2 découvertes par plateforme/cycle if h.isdigit() and acc is not None: continue if new >= 2 * len(PATTERNS): break _signal(db, "compte-social", f"Compte découvert — {code} {plat} @{h}", f"{site}#@{h}", {"party": code, "platform": plat, "handle": h, "found_on": site}) new += 1 # dérive de chef : le nom du registre doit apparaître sur le site leader = party.get("leader") or "" surname = leader.split()[-1] if leader else "" if surname and surname.lower() not in html.lower(): _signal(db, "dérive-chef", f"Chef à vérifier — {code} : « {leader} » absent de {site}", f"{site}#chef-{date.today().isoformat()}", {"party": code, "registered_leader": leader}) report[code] = {"confirmés": confirmed, "découverts": new} if changed: save_seeds(seeds) db.commit() return report