# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/connectors/instagram.py # Desc: Connecteur ENRICHISSEMENT Instagram — profil public par créateur : # abonnés, badge vérifié, bio, liens de bio (→ découverte croisée des # autres plateformes + page link-in-bio). Mode d'accès : Scrapfly # (plateforme hostile au scraping, pas d'API publique de lecture) — # palier 4 du catalogue (§9), CGU/rate-limit respectés (§15). # ============================================================================== """Enrichissement Instagram, créateur par créateur. Pour chaque fiche ayant un compte Instagram rattaché, on lit le profil PUBLIC via l'endpoint web_profile_info (JSON servi par le web public d'Instagram), à travers Scrapfly (anti-bot). On en tire : - followers / verified / bio du compte Instagram (métriques, §13-14) ; - `external_url` + `bio_links` : si c'est une page link-in-bio connue → `link_in_bio_url` (traitée ensuite par le connecteur link-in-bio) ; si c'est un autre réseau → nouveau compte rattaché avec signal `cross_link` (0.90, §12.1 : le créateur pointe LUI-MÊME vers cet autre compte). Uniquement des profils publics ; un profil privé/introuvable est ignoré. """ from __future__ import annotations from datetime import datetime, timedelta, timezone import json 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_API = ("https://i.instagram.com/api/v1/users/web_profile_info/" "?username={h}") IG_APP_ID = "936619743392459" # app id du web public instagram.com 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 InstagramConnector(BaseConnector): source_id = "instagram-profil" kind = "enrichment" request_delay = 1.5 max_profiles = 700 # garde-fou par passage def fetch_profile(self, handle: str) -> dict | None: """Profil public JSON d'un handle Instagram (None si privé/introuvable).""" result = self.scrapfly(PROFILE_API.format(h=handle), render_js=False, headers={"x-ig-app-id": IG_APP_ID, "accept": "application/json"}) if result.get("status_code") != 200: return None try: data = json.loads(result.get("content") or "{}") except Exception: return None user = ((data.get("data") or {}).get("user")) or None if not user or user.get("is_private"): return None return user def enrich(self, creators: list[Creator]) -> list[Creator]: enriched: list[Creator] = [] fetched = 0 for cr in creators: ig = next((a for a in cr.platforms if a.platform == "instagram"), None) if ig is None or fetched >= self.max_profiles: continue if _fresh(ig.last_checked): continue try: user = self.fetch_profile(ig.handle) except Exception: continue fetched += 1 if not user: continue # métriques du compte lui-même (étendues : la référence, pas le minimum) ig.followers = parse_count( (user.get("edge_followed_by") or {}).get("count")) or ig.followers ig.verified = bool(user.get("is_verified")) ig.last_checked = now_iso() extra = { "following": (user.get("edge_follow") or {}).get("count"), "posts": (user.get("edge_owner_to_timeline_media") or {}).get("count"), "category": user.get("category_name"), "is_business": user.get("is_business_account"), } ig.metrics.update({k: v for k, v in extra.items() if v not in (None, "")}) if not cr.avatar_url and user.get("profile_pic_url_hd"): cr.avatar_url = user["profile_pic_url_hd"] if not cr.bio and user.get("biography"): cr.bio = user["biography"] # liens de bio → découverte croisée (§12.1 signal 2) new_accounts = [] links = [user.get("external_url") or ""] links += [(b.get("url") or "") for b in (user.get("bio_links") or [])] for url in links: if not url: continue if is_linkinbio(url) and not cr.link_in_bio_url: cr.link_in_bio_url = url continue hit = platform_from_url(url) if hit and hit[0] != "instagram": new_accounts.append( account(hit[0], hit[1], "cross_link", url=url).finalize()) if new_accounts: cr.platforms = merge_accounts(cr.platforms, new_accounts) enriched.append(cr) return enriched