spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Découverte et vérification AUTOMATIQUE des comptes sociaux (ADDENDUM §32).67Chaque semaine : scrape des sites officiels des partis (Firecrawl) → extraction8des liens sociaux → comparaison au registre canonique (`config/social_seeds.json`).910- 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"""18from __future__ import annotations1920import json21import logging22import re23from datetime import date24from pathlib import Path2526from sqlalchemy.orm import Session2728from ..config import BASE_DIR29from .. import models as Mo3031log = logging.getLogger("account-discovery")3233SEEDS_PATH = BASE_DIR / "config" / "social_seeds.json"3435PATTERNS = {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}44NOISE = {"sharer.php", "share", "intent", "plugins", "hashtag", "watch",45 "embed", "policies", "tr", "privacy", "p", "reel", "stories",46 "home.php", "profile.php", "wp-content", "legal", "search"}474849def load_seeds() -> dict:50 return json.loads(SEEDS_PATH.read_text())515253def 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))565758def 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 found666768def _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))727374def 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, _post77 if not _enabled():78 return {"skipped": "Firecrawl requis pour la découverte"}79 seeds = load_seeds()80 report: dict = {}81 changed = False82 for code, party in seeds.get("parties", {}).items():83 site = party.get("website")84 if not site:85 continue86 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 continue91 found = extract_handles(html)92 confirmed, new = 0, 093 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 officielle100 acc["status"] = "verified"101 acc["source"] = "official_site"102 confirmed += 1103 changed = True104 elif (plat, h.lower()) not in known:105 # anti-bruit : les sites listent les pages FB des dizaines106 # de candidats (ids numériques) — on ne signale un handle107 # numérique que si AUCUN compte n'est connu sur la108 # plateforme, et au plus 2 découvertes par plateforme/cycle109 if h.isdigit() and acc is not None:110 continue111 if new >= 2 * len(PATTERNS):112 break113 _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 += 1119 # dérive de chef : le nom du registre doit apparaître sur le site120 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 report132