# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/x_profil.py # Desc: Connecteur ENRICHISSEMENT « x-profil » — abonnés X (Twitter) SANS clé # via le service public de syndication (widgets d'intégration officiels) # syndication.twitter.com/srv/timeline-profile/screen-name/{handle}, # repli page publique x.com/{handle}. Palier 3 (§9). # Facebook sondé aussi : page publique bloquée sans clé (HTTP 400) # → aucune voie fiable, on passe (documenté dans data/sources.json). # ============================================================================== """Enrichissement X (Twitter) : nombre d'abonnés par créateur, sans clé. L'API officielle de lecture est payante depuis 2023 et l'ancien widget followbutton (cdn.syndication.twimg.com/widgets/followbutton) est mort (vérifié 2026-08-18 : réponse vide). Deux voies publiques restent : 1. ``syndication.twitter.com/srv/timeline-profile/screen-name/{h}`` — le service OFFICIEL des widgets d'intégration : son __NEXT_DATA__ contient l'objet user complet (followers_count, verified, avatar). Fiable et conçu pour être appelé sans authentification. VOIE PRINCIPALE. 2. la page ``x.com/{handle}`` rendue côté serveur expose ``UserRelationshipCounts",followers:N`` — mais mur de connexion après ~15-20 requêtes par IP (constaté 2026-08-18). REPLI seulement. ⚠ Ces services 429-ent l'empreinte TLS de python-requests (constaté 2026-08-18 : curl 200, requests 429, même IP/UA/en-têtes) → le fetch passe par le binaire système ``curl`` (toujours présent sur macOS), avec le même User-Agent CreaKaBot et le même throttling poli que les autres connecteurs. On ne lit QUE les compteurs publics du profil déjà rattaché (§15). Cap 150 profils/passage + re-visite 7 j (~180 comptes X → rotation douce). """ from __future__ import annotations import json import re import subprocess import time from datetime import datetime, timedelta, timezone from ..schema import Creator, now_iso from .base import USER_AGENT, BaseConnector SYNDICATION_URL = ("https://syndication.twitter.com/srv/timeline-profile/" "screen-name/{h}") PAGE_URL = "https://x.com/{h}" _NEXT_DATA_RE = re.compile( r'', re.S) # repli x.com : état intégré du rendu serveur (clés non citées, pas du JSON) _COUNT_RES = ( re.compile(r'UserRelationshipCounts",followers:(\d+),following:(\d+)'), re.compile(r"followers:(\d+),following:(\d+)"), ) def _find_user(node, handle: str) -> dict | None: """Parcourt le JSON Next.js : l'objet user dont screen_name == handle.""" if isinstance(node, dict): sn = node.get("screen_name") if isinstance(sn, str) and sn.lower() == handle \ and "followers_count" in node: return node for v in node.values(): hit = _find_user(v, handle) if hit: return hit elif isinstance(node, list): for item in node: hit = _find_user(item, handle) if hit: return hit return None def parse_syndication(html: str, handle: str) -> dict | None: """Page timeline-profile → {followers, following, verified, avatar} ou None.""" m = _NEXT_DATA_RE.search(html) if not m: return None try: data = json.loads(m.group(1)) except ValueError: return None user = _find_user(data, handle.lower()) if not user: return None return { "followers": int(user.get("followers_count") or 0), "following": user.get("friends_count"), "verified": bool(user.get("verified")) or None, "avatar": (user.get("profile_image_url_https") or "").replace( "_normal.", "_400x400."), } def parse_counts(html: str) -> tuple[int, int] | None: """(repli) HTML x.com → (followers, following) ou None (mur de connexion).""" for rx in _COUNT_RES: m = rx.search(html) if m: return int(m.group(1)), int(m.group(2)) return None class XProfilConnector(BaseConnector): source_id = "x-profil" kind = "enrichment" request_delay = 2.0 # poli — service d'intégration public timeout = 15 max_profiles = 150 revisit_days = 7 def __init__(self) -> None: super().__init__() self.errors = 0 self._fallback_left = 10 # x.com bloque vite : repli très limité def _needs_visit(self, metrics: dict) -> bool: raw = metrics.get("x_checked") if not raw: return True try: checked = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return True return (datetime.now(timezone.utc) - checked > timedelta(days=self.revisit_days)) def _curl(self, url: str) -> str: """GET via le binaire curl (voir docstring : requests est 429-é), throttling poli hérité du connecteur ; lève en cas d'échec HTTP.""" self._throttle() try: proc = subprocess.run( ["curl", "-sS", "--fail", "--max-time", str(self.timeout), "-A", USER_AGENT, "-H", "Accept-Language: en", url], capture_output=True, text=True, timeout=self.timeout + 5) finally: self._last_request = time.time() if proc.returncode != 0: raise RuntimeError(f"curl {proc.returncode}: {url}") return proc.stdout def _lookup(self, handle: str) -> dict | None: info = parse_syndication(self._curl(SYNDICATION_URL.format(h=handle)), handle) if info: return info if self._fallback_left <= 0: return None self._fallback_left -= 1 counts = parse_counts(self._curl(PAGE_URL.format(h=handle))) if counts is None: return None return {"followers": counts[0], "following": counts[1], "verified": None, "avatar": ""} def enrich(self, creators: list[Creator]) -> list[Creator]: # jamais lus d'abord, puis par portée (l'appelant trie déjà par reach) candidates = [] for cr in creators: acc = next((a for a in cr.platforms if a.platform == "x"), None) if acc is not None and self._needs_visit(acc.metrics): candidates.append((cr, acc)) candidates.sort(key=lambda t: bool(t[1].metrics.get("x_checked"))) enriched: list[Creator] = [] fetched = 0 for cr, acc in candidates: if fetched >= self.max_profiles: break fetched += 1 # le cap borne les TENTATIVES réseau try: info = self._lookup(acc.handle) except Exception: self.errors += 1 continue if info is None: # profil sans tweet public / suspendu : rien à lire — mais on # horodate pour ne pas re-consommer le cap avant 7 jours acc.metrics["x_checked"] = now_iso() enriched.append(cr) continue acc.followers = info["followers"] if info.get("verified") and acc.verified is None: acc.verified = True if info.get("following") is not None: acc.metrics["following"] = info["following"] acc.metrics["x_checked"] = now_iso() acc.last_checked = now_iso() if info.get("avatar", "").startswith("http") and not cr.avatar_url: cr.avatar_url = info["avatar"] enriched.append(cr) return enriched