# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/tiktok.py # Desc: Connecteur ENRICHISSEMENT TikTok — profil public par créateur : # abonnés, badge vérifié, bio, lien de bio (découverte croisée). # Mode d'accès : Scrapfly (plateforme hostile au scraping, API # officielle limitée aux comptes connectés) — palier 4 (§9), §15. # ============================================================================== """Enrichissement TikTok, créateur par créateur. La page publique tiktok.com/@handle embarque un JSON d'hydratation (__UNIVERSAL_DATA_FOR_REHYDRATION__) contenant stats.followerCount, la bio (`signature`), le badge vérifié et le lien de bio (`bioLink`). On le lit via Scrapfly. Lien de bio → page link-in-bio ou compte croisé (signal cross_link). """ from __future__ import annotations from datetime import datetime, timedelta, timezone import json import re from ..identity import account, merge_accounts from ..normalize import parse_count, platform_from_url from ..schema import Creator, now_iso from .base import BaseConnector from .linkinbio import is_supported as is_linkinbio PROFILE_URL = "https://www.tiktok.com/@{h}" _STATE_RE = re.compile( r'', re.S) def parse_profile(html: str) -> dict | None: """HTML de profil TikTok → dict userInfo {user:…, stats:…} (None si absent).""" m = _STATE_RE.search(html) if not m: return None try: data = json.loads(m.group(1)) return (data.get("__DEFAULT_SCOPE__", {}) .get("webapp.user-detail", {}) .get("userInfo")) or None except Exception: return None def _fresh(last_checked: str, hours: int = 72) -> bool: """Compte déjà rafraîchi récemment (acteur Apify) — épargner Scrapfly.""" if not last_checked: return False try: seen = datetime.fromisoformat(last_checked.replace("Z", "+00:00")) except ValueError: return False return datetime.now(timezone.utc) - seen < timedelta(hours=hours) class TikTokConnector(BaseConnector): source_id = "tiktok-profil" kind = "enrichment" request_delay = 1.5 max_profiles = 700 def enrich(self, creators: list[Creator]) -> list[Creator]: enriched: list[Creator] = [] fetched = 0 for cr in creators: tk = next((a for a in cr.platforms if a.platform == "tiktok"), None) if tk is None or fetched >= self.max_profiles: continue if _fresh(tk.last_checked): continue try: result = self.scrapfly(PROFILE_URL.format(h=tk.handle), render_js=False) except Exception: continue fetched += 1 if result.get("status_code") != 200: continue info = parse_profile(result.get("content") or "") if not info: continue user = info.get("user") or {} stats = info.get("stats") or {} if user.get("privateAccount"): continue # §15 : uniquement du public tk.followers = parse_count(stats.get("followerCount")) or tk.followers tk.verified = bool(user.get("verified")) tk.last_checked = now_iso() extra = { "following": parse_count(stats.get("followingCount")), "likes": parse_count(stats.get("heartCount")), "videos": parse_count(stats.get("videoCount")), } tk.metrics.update({k: v for k, v in extra.items() if v is not None}) if not cr.avatar_url and user.get("avatarLarger"): cr.avatar_url = user["avatarLarger"] if not cr.bio and user.get("signature"): cr.bio = user["signature"] url = ((user.get("bioLink") or {}).get("link") or "").strip() if url and not url.startswith("http"): url = "https://" + url if url: if is_linkinbio(url) and not cr.link_in_bio_url: cr.link_in_bio_url = url else: hit = platform_from_url(url) if hit and hit[0] != "tiktok": cr.platforms = merge_accounts(cr.platforms, [ account(hit[0], hit[1], "cross_link", url=url).finalize()]) enriched.append(cr) return enriched