SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
5.5 KB · 132 lines python
Raw Blame History
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