SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
31.0 KB · 703 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   creaka/connectors/apify_social.py4# Desc:   Connecteurs Apify MAISON — acteurs KA (~/Desktop/ka-apify-actors,5#         compte gorgeous_thistle) exécutés sur la plateforme Apify avec proxy6#         RÉSIDENTIEL : enrichissement ULTRA DÉTAILLÉ (abonnés, bio, badges,7#         contenu récent avec métriques, engagement) pour Instagram, TikTok,8#         X, Facebook, Threads et Snapchat + découverte Instagram (topsearch).9#         Un passage = UN run d'acteur par plateforme (lot de handles), au lieu10#         d'une requête Scrapfly par profil — moins cher, plus fiable (IP11#         résidentielles + empreinte TLS Chrome côté acteur).12# ==============================================================================13"""Enrichissement multi-plateforme via les acteurs Apify KA.1415Chaque connecteur d'enrichissement :161. sélectionne les comptes de SA plateforme dont `last_checked` est plus vieux17   que `revisit_days` (rotation douce = maîtrise du coût proxy) ;182. lance l'acteur `gorgeous_thistle/ka-<plateforme>` avec le lot de handles19   (proxy résidentiel Apify forcé) et attend le dataset ;203. applique aux fiches : abonnés/badge (§13), métriques étendues + CONTENU21   récent (posts/vidéos/tweets avec likes, commentaires, vues) dans22   `account.metrics`, avatar/bio manquants, liens de bio → `cross_link` (§12.1).2324Nécessite APIFY_TOKEN (.env). Sans clé : SkipSource propre (§16/§18).25Uniquement des profils PUBLICS (§15) ; un profil privé/introuvable est ignoré.26"""27from __future__ import annotations2829import os30import random31import time32from concurrent.futures import ThreadPoolExecutor, as_completed33from datetime import datetime, timedelta, timezone3435import requests3637from ..identity import account, merge_accounts38from ..normalize import parse_count, platform_from_url39from ..schema import Creator, now_iso40from .base import BaseConnector, SkipSource41from .linkinbio import is_supported as is_linkinbio4243APIFY_API = "https://api.apify.com/v2"44ACTOR_OWNER = "gorgeous_thistle"45RESIDENTIAL = {"useApifyProxy": True, "apifyProxyGroups": ["RESIDENTIAL"]}4647# erreurs DÉFINITIVES des acteurs (handle mort, profil privé, mur de login…) :48# on tamponne le compte quand même pour qu'il ne soit re-sondé qu'à la rotation49# `revisit_days`, au lieu d'être re-payé chaque jour en pure perte. Les erreurs50# transitoires (429, 5xx, shell, proxy) ne sont PAS tamponnées → re-tentées.51MISS_PREFIXES = ("user_not_found", "private", "not_found", "no_profile",52                 "no_channel", "login_wall", "invite_invalide", "http_404",53                 "wall_")5455# --- parallélisme : on dispose de 256 Go de RAM Apify → au lieu d'UN run à56# 1 Go, on éclate le lot en tranches lancées EN PARALLÈLE, chacune avec plus de57# mémoire (donc plus de CPU/vCPU côté Apify) et une concurrence interne accrue.58# profil « max parallélisme » : ces acteurs sont I/O-bound (requêtes proxy),59# donc BEAUCOUP de petits runs > peu de gros runs. 32 × 2 Go = 64 Go de pointe60# (large sous les 256 Go / 128 runs concurrents du plan).61SHARD_SIZE = int(os.environ.get("APIFY_SHARD_SIZE", "60"))    # handles / run62MAX_SHARDS = int(os.environ.get("APIFY_MAX_SHARDS", "32"))    # runs simultanés63RUN_MEMORY_MB = int(os.environ.get("APIFY_RUN_MEMORY_MB", "2048"))  # /run64RUN_CONCURRENCY = int(os.environ.get("APIFY_RUN_CONCURRENCY", "10"))  # interne (max 10)65# rafraîchissement : un compte est re-sondé s'il n'a pas été enrichi depuis66# REVISIT_DAYS jours. Le watch quotidien couvre ainsi TOUT le bassin en ≤ N j.67REVISIT_DAYS = int(os.environ.get("APIFY_REVISIT_DAYS", "7"))68# passage FORCÉ (APIFY_FORCE=1) : ignore fraîcheur ET caps — couvre TOUT le69# bassin de chaque plateforme en un seul passage (rattrapage de couverture)70FORCE_PASS = os.environ.get("APIFY_FORCE", "") == "1"717273def _token() -> str:74    token = os.environ.get("APIFY_TOKEN")75    if not token:76        raise SkipSource("APIFY_TOKEN manquant (voir .env)")77    return token787980def _launch(name: str, run_input: dict, memory_mb: int,81            retries: int = 6) -> str:82    """Démarre un run. Retry sur 400/429 (limite mémoire concurrente atteinte :83    elle se libère quand d'autres tranches terminent) avec backoff."""84    headers = {"Authorization": f"Bearer {_token()}"}85    for attempt in range(retries):86        resp = requests.post(87            f"{APIFY_API}/acts/{ACTOR_OWNER}~{name}/runs",88            json=run_input, headers=headers,89            params={"memory": memory_mb}, timeout=60)90        if resp.status_code in (400, 429, 402) or resp.status_code >= 500:91            if attempt < retries - 1:92                # backoff + jitter : désynchronise les tranches (évite le93                # lockstep où toutes redemandent la RAM au même instant)94                time.sleep(min(60, 8 * (attempt + 1)) + random.uniform(0, 6))95                continue96        resp.raise_for_status()97        return resp.json()["data"]["id"]98    resp.raise_for_status()99    return resp.json()["data"]["id"]100101102def _await_items(run_id: str, name: str, timeout_s: int) -> list[dict]:103    headers = {"Authorization": f"Bearer {_token()}"}104    t0 = time.time()105    while True:106        time.sleep(15)107        r = requests.get(f"{APIFY_API}/actor-runs/{run_id}",108                         headers=headers, timeout=60)109        r.raise_for_status()110        status = r.json()["data"]111        if status["status"] in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):112            break113        if time.time() - t0 > timeout_s:114            requests.post(f"{APIFY_API}/actor-runs/{run_id}/abort",115                          headers=headers, timeout=60)116            raise RuntimeError(f"acteur {name} : délai {timeout_s}s dépassé")117    if status["status"] != "SUCCEEDED":118        raise RuntimeError(f"acteur {name} : run {status['status']}")119    dataset = status["defaultDatasetId"]120    items: list[dict] = []121    offset = 0122    while True:123        r = requests.get(f"{APIFY_API}/datasets/{dataset}/items",124                         params={"offset": offset, "limit": 1000,125                                 "clean": "true"},126                         headers=headers, timeout=120)127        r.raise_for_status()128        chunk = r.json()129        items.extend(chunk)130        if len(chunk) < 1000:131            return items132        offset += 1000133134135def run_actor(name: str, run_input: dict, *, timeout_s: int = 2400,136              memory_mb: int = RUN_MEMORY_MB) -> list[dict]:137    """Un seul run (recherche, petit lot)."""138    return _await_items(_launch(name, run_input, memory_mb), name, timeout_s)139140141def run_actor_sharded(name: str, base_input: dict, usernames: list[str], *,142                      timeout_s: int = 2400,143                      shard_size: int = SHARD_SIZE,144                      max_shards: int = MAX_SHARDS,145                      memory_mb: int = RUN_MEMORY_MB) -> list[dict]:146    """Éclate `usernames` en tranches lancées EN PARALLÈLE sur Apify.147148    Chaque tranche = un run indépendant (mémoire dédiée → plus de CPU). On149    plafonne à `max_shards` runs simultanés (256 Go de RAM = large marge :150    12 × 4 Go = 48 Go). Les datasets sont fusionnés. Une tranche qui échoue151    n'annule pas les autres.152    """153    if not usernames:154        return []155    n = max(1, min(max_shards,156                   (len(usernames) + shard_size - 1) // shard_size))157    shards = [usernames[i::n] for i in range(n)]   # répartition équilibrée158    items: list[dict] = []159    with ThreadPoolExecutor(max_workers=n) as pool:160        futs = {pool.submit(run_actor, name,161                            {**base_input, "usernames": shard},162                            timeout_s=timeout_s, memory_mb=memory_mb): i163                for i, shard in enumerate(shards) if shard}164        for fut in as_completed(futs):165            try:166                items.extend(fut.result())167            except Exception as exc:  # une tranche morte ne bloque pas le reste168                print(f"[crea-ka] {name} tranche {futs[fut]} échouée : {exc}")169    return items170171172class _ApifySocialEnrich(BaseConnector):173    """Base commune des enrichissements Apify (un acteur par plateforme)."""174175    kind = "enrichment"176    platform = ""             # plateforme canonique (§6.2)177    actor = ""                # nom court de l'acteur (ka-instagram…)178    # concurrence interne du run — DOIT respecter le `maximum` du schéma179    # d'input de l'acteur, sinon Apify rejette le run en 400 (cause des180    # passages facebook/threads à zéro, diagnostiqué le 2026-08-21)181    run_concurrency = RUN_CONCURRENCY182    cap = 3000                # comptes max par passage (shardé en parallèle)183    revisit_days = REVISIT_DAYS  # re-sonde hebdo par défaut (env APIFY_REVISIT_DAYS)184    content_key = ""          # clé du contenu récent dans l'item acteur185    # clés d'item copiées telles quelles dans account.metrics si non nulles186    metric_map: dict[str, str] = {}187188    @property189    def _stamp_key(self) -> str:190        # tampon PROPRE au connecteur : quand CE connecteur a enrichi ce compte.191        # On ne se base PAS sur acc.last_checked, re-tamponné par la découverte192        # (kick-decouverte etc.) même sans enrichissement → fausse fraîcheur.193        return f"{self.source_id}_at"194195    def _stale(self, acc) -> bool:196        if FORCE_PASS:197            return True198        stamp = (acc.metrics or {}).get(self._stamp_key)199        if not stamp:200            return True201        try:202            seen = datetime.fromisoformat(str(stamp).replace("Z", "+00:00"))203        except ValueError:204            return True205        return (datetime.now(timezone.utc) - seen206                > timedelta(days=self.revisit_days))207208    def extra_input(self) -> dict:209        return {}210211    def target_of(self, acc) -> str:212        """Valeur envoyée à l'acteur pour ce compte (défaut : le handle).213214        Certaines plateformes exigent autre chose que le handle minusculé —215        ex. Discord, dont le code d'invitation est SENSIBLE À LA CASSE et doit216        être repris depuis l'URL. Le rapprochement se fait ensuite en217        minuscules des deux côtés.218        """219        return acc.handle220221    def enrich(self, creators: list[Creator]) -> list[Creator]:222        targets: dict[str, list] = {}223        for cr in creators:224            acc = next((a for a in cr.platforms225                        if a.platform == self.platform and a.handle), None)226            if acc is None or not self._stale(acc):227                continue228            targets.setdefault(self.target_of(acc), []).append((cr, acc))229            if not FORCE_PASS and len(targets) >= self.cap:230                break231        if not targets:232            return []233        items = run_actor_sharded(234            self.actor,235            {"proxyConfiguration": RESIDENTIAL,236             "concurrency": self.run_concurrency,237             **self.extra_input()},238            sorted(targets),239            # passage forcé : tranches plus grosses (bassin entier / 32 runs)240            # → délai par run élargi en proportion241            timeout_s=7200 if FORCE_PASS else 2400)242        by_handle = {str(it.get("username") or "").lower(): it243                     for it in items if it.get("kind") == "profile"}244        enriched: list[Creator] = []245        self.errors = 0246        for handle, pairs in targets.items():247            it = by_handle.get(handle.lower())248            if it is None:249                continue250            if not it.get("found"):251                err = str(it.get("error", ""))252                if err.startswith(("http_5", "shell")):253                    self.errors += 1254                elif err.startswith(MISS_PREFIXES):255                    for cr, acc in pairs:  # miss définitif → rotation douce256                        acc.metrics[self._stamp_key] = now_iso()257                        acc.metrics[f"{self.source_id}_miss"] = err258                        enriched.append(cr)259                continue260            for cr, acc in pairs:261                self.apply(cr, acc, it)262                enriched.append(cr)263        return enriched264265    # -- application aux fiches ------------------------------------------------266    def apply(self, cr: Creator, acc, it: dict) -> None:267        acc.followers = parse_count(it.get("followers")) or acc.followers268        if it.get("is_verified") is not None:269            acc.verified = bool(it["is_verified"])270        acc.last_checked = now_iso()271        metrics = {dst: it.get(src) for src, dst in self.metric_map.items()272                   if it.get(src) is not None}273        if self.content_key and it.get(self.content_key):274            metrics[self.content_key] = it[self.content_key][:12]275        # images du profil : avatar/bannière conservés PAR COMPTE (affichage276        # par plateforme + chaîne de repli côté frontend)277        for img_key in ("avatar", "banner"):278            if it.get(img_key):279                metrics[img_key] = it[img_key]280        metrics[self._stamp_key] = now_iso()  # tampon d'enrichissement propre281        acc.metrics.pop(f"{self.source_id}_miss", None)  # ressuscité282        acc.metrics.update(metrics)283        # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la284        # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;285        # sinon on remplit seulement les manquants286        if it.get("avatar") and (not cr.avatar_url287                                 or acc.platform == cr.primary_platform):288            cr.avatar_url = it["avatar"]289        if it.get("banner") and (not getattr(cr, "banner_url", None)290                                 or acc.platform == cr.primary_platform):291            cr.banner_url = it["banner"]292        if not cr.bio and it.get("biography"):293            cr.bio = it["biography"]294        self.cross_links(cr, it)295296    def cross_links(self, cr: Creator, it: dict) -> None:297        """Liens de bio → link-in-bio ou compte `cross_link` (§12.1)."""298        for url in self.bio_urls(it):299            url = (url or "").strip()300            if url and not url.startswith("http"):301                url = "https://" + url302            if not url:303                continue304            if is_linkinbio(url) and not cr.link_in_bio_url:305                cr.link_in_bio_url = url306                continue307            hit = platform_from_url(url)308            if hit and hit[0] != self.platform:309                cr.platforms = merge_accounts(cr.platforms, [310                    account(hit[0], hit[1], "cross_link",311                            url=url).finalize()])312313    def bio_urls(self, it: dict) -> list[str]:314        return []315316317class ApifyInstagram(_ApifySocialEnrich):318    source_id = "instagram-apify"319    platform = "instagram"320    actor = "ka-instagram"321    cap = 1800322    content_key = "recent_posts"323    metric_map = {"following": "following", "posts_count": "posts",324                  "category": "category", "is_business": "is_business",325                  "business_category": "business_category",326                  "highlight_reels": "highlight_reels",327                  "pronouns": "pronouns", "bio_links": "bio_links",328                  "avg_likes": "avg_likes", "avg_comments": "avg_comments",329                  "avg_video_views": "avg_video_views",330                  "engagement_rate_pct": "engagement_rate_pct",331                  "posts_per_week": "posts_per_week",332                  "last_post_at": "last_post_at",333                  "video_share_pct": "video_share_pct",334                  "top_post": "top_post",335                  "bio_mentions": "bio_mentions",336                  "bio_hashtags": "bio_hashtags",337                  "igtv_videos_count": "igtv_videos",338                  "business_email": "business_email"}339340    def bio_urls(self, it: dict) -> list[str]:341        return [it.get("external_url") or ""] + (it.get("bio_links") or [])342343    def apply(self, cr, acc, it):344        super().apply(cr, acc, it)345        related = [r.get("username") for r in (it.get("related_profiles")346                                               or []) if r.get("username")]347        if related:  # matière première de découvertes futures (§12.1 mention)348            acc.metrics["related_profiles"] = related[:10]349        # flag Meta « compte Threads relié » : même handle → cross_link fort350        if it.get("has_threads") and it.get("username"):351            u = it["username"]352            cr.platforms = merge_accounts(cr.platforms, [353                account("threads", u, "cross_link",354                        url=f"https://www.threads.com/@{u}").finalize()])355356357class ApifyTikTok(_ApifySocialEnrich):358    source_id = "tiktok-apify"359    platform = "tiktok"360    actor = "ka-tiktok"361    cap = 1800362    content_key = "recent_videos"363    metric_map = {"following": "following", "total_likes": "likes",364                  "videos_count": "videos", "region": "region",365                  "friends": "friends", "is_seller": "is_seller",366                  "is_live_now": "is_live_now",367                  "commerce_category": "commerce_category",368                  "account_created_at": "account_created_at",369                  "avg_views": "avg_views", "avg_likes": "avg_likes",370                  "avg_comments": "avg_comments", "avg_shares": "avg_shares",371                  "engagement_rate_pct": "engagement_rate_pct",372                  "videos_per_week": "videos_per_week",373                  "last_video_at": "last_video_at",374                  "top_hashtags": "top_hashtags", "language": "language",375                  "top_video": "top_video",376                  "likes_given": "likes_given", "avg_saves": "avg_saves",377                  "pinned_videos_count": "pinned_videos"}378379    def bio_urls(self, it: dict) -> list[str]:380        return [it.get("bio_link") or ""]381382    def apply(self, cr, acc, it):383        super().apply(cr, acc, it)384        # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15385        if it.get("is_under_18") is True:386            cr.is_minor = True387388389class ApifyX(_ApifySocialEnrich):390    source_id = "x-apify"391    platform = "x"392    actor = "ka-x"393    # l'endpoint syndication de X rend des 429 en rafale au-delà de ~4394    # requêtes simultanées par run (constat 2026-08-21)395    run_concurrency = 4396    cap = 1800397    content_key = "recent_tweets"398    metric_map = {"following": "following", "tweets_count": "tweets",399                  "location": "location", "created_at": "created_at",400                  "listed_count": "listed", "account_age_days": "account_age_days",401                  "avg_likes": "avg_likes", "avg_retweets": "avg_retweets",402                  "avg_replies": "avg_replies",403                  "engagement_rate_pct": "engagement_rate_pct",404                  "tweets_per_week": "tweets_per_week",405                  "last_tweet_at": "last_tweet_at",406                  "media_share_pct": "media_share_pct",407                  "reply_share_pct": "reply_share_pct",408                  "is_blue_verified": "is_blue_verified",409                  "likes_given": "likes_given", "website": "website",410                  "top_hashtags": "top_hashtags", "top_tweet": "top_tweet",411                  "avg_quotes": "avg_quotes", "verified_type": "verified_type",412                  "link_share_pct": "link_share_pct",413                  "retweet_share_pct": "retweet_share_pct",414                  "top_mentions": "top_mentions"}415416    def extra_input(self) -> dict:417        return {"maxTweets": 20}418419    def bio_urls(self, it: dict) -> list[str]:420        return [it.get("website") or ""]421422423class ApifyFacebook(_ApifySocialEnrich):424    source_id = "facebook-apify"425    platform = "facebook"426    actor = "ka-facebook"427    run_concurrency = 6       # maximum du schéma d'input de ka-facebook428    cap = 400429    revisit_days = 7430    metric_map = {"category": "category", "page_id": "page_id",431                  "website": "website", "rating": "rating"}432433    def bio_urls(self, it: dict) -> list[str]:434        # site web auto-déclaré de la page → cross_link (§12.1)435        return [it.get("website") or ""]436437438class ApifyThreads(_ApifySocialEnrich):439    source_id = "threads-apify"440    platform = "threads"441    actor = "ka-threads"442    run_concurrency = 6       # maximum du schéma d'input de ka-threads443    cap = 60444    revisit_days = 7445    content_key = "recent_posts"446    metric_map = {"avg_likes": "avg_likes", "top_post": "top_post",447                  "bio_links": "bio_links"}448449    def bio_urls(self, it: dict) -> list[str]:450        # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1)451        return list(it.get("bio_links") or [])452453454class ApifySnapchat(_ApifySocialEnrich):455    source_id = "snapchat-apify"456    platform = "snapchat"457    actor = "ka-snapchat"458    cap = 60459    revisit_days = 7460    metric_map = {"category": "category", "has_story": "has_story",461                  "has_spotlight": "has_spotlight", "address": "address",462                  "subcategory": "subcategory",463                  "publisher_type": "publisher_type",464                  "story_snaps_count": "story_snaps",465                  "lenses_count": "lenses",466                  "story_previews": "story_previews",467                  "spotlight_previews": "spotlight_previews",468                  "website": "website", "snapcode": "snapcode",469                  "spotlight_highlights_count": "spotlight_highlights"}470471    def bio_urls(self, it: dict) -> list[str]:472        return [it.get("website") or ""]473474475class ApifyYouTube(_ApifySocialEnrich):476    source_id = "youtube-apify"477    platform = "youtube"478    actor = "ka-youtube"479    cap = 1800480    content_key = "recent_videos"481    metric_map = {"videos_count": "videos", "country": "country",482                  "keywords": "keywords", "channel_id": "channel_id",483                  "avg_views": "avg_views", "top_video": "top_video",484                  "total_views": "total_views", "joined_date": "joined_date",485                  "external_links": "external_links",486                  "last_video_published": "last_video_published",487                  "videos_per_month": "videos_per_month",488                  "has_shorts": "has_shorts", "is_live_now": "is_live_now"}489490    def bio_urls(self, it: dict) -> list[str]:491        # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)492        return [ln.get("url") or "" for ln in (it.get("external_links") or [])493                if isinstance(ln, dict)]494495496class ApifyTwitch(_ApifySocialEnrich):497    source_id = "twitch-apify"498    platform = "twitch"499    actor = "ka-twitch"500    cap = 1200501    content_key = "recent_videos"502    metric_map = {"is_partner": "is_partner", "is_affiliate": "is_affiliate",503                  "team": "team", "created_at": "created_at",504                  "is_live_now": "is_live_now", "live_viewers": "live_viewers",505                  "live_game": "live_game",506                  "last_broadcast_at": "last_broadcast_at",507                  "last_broadcast_game": "last_broadcast_game",508                  "last_broadcast_title": "last_broadcast_title",509                  "live_title": "live_title",510                  "live_thumbnail": "live_thumbnail",511                  "avg_video_views": "avg_video_views",512                  "social_links": "social_links",513                  "recent_games": "recent_games",514                  "videos_count": "videos",515                  "live_type": "live_type",516                  "live_started_at": "live_started_at"}517518    def bio_urls(self, it: dict) -> list[str]:519        # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)520        return [ln.get("url") or "" for ln in (it.get("social_links") or [])521                if isinstance(ln, dict)]522523524class ApifyKick(_ApifySocialEnrich):525    source_id = "kick-apify"526    platform = "kick"527    actor = "ka-kick"528    cap = 600529    metric_map = {"is_live_now": "is_live_now", "live_viewers": "live_viewers",530                  "subscription_enabled": "subscription_enabled",531                  "live_title": "live_title",532                  "live_thumbnail": "live_thumbnail",533                  "live_category": "live_category",534                  "vod_enabled": "vod_enabled",535                  "recent_categories": "recent_categories",536                  "live_started_at": "live_started_at",537                  "live_language": "live_language"}538539    def cross_links(self, cr: Creator, it: dict) -> None:540        """Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1)."""541        socials = it.get("social_links") or {}542        tmpl = {"instagram": "https://instagram.com/{}",543                "twitter": "https://x.com/{}",544                "youtube": "https://youtube.com/{}",545                "tiktok": "https://www.tiktok.com/@{}",546                "facebook": "https://facebook.com/{}",547                "discord": "https://discord.gg/{}"}548        for key, val in socials.items():549            val = (val or "").strip()550            if not val:551                continue552            url = val if val.startswith("http") else \553                tmpl.get(key, "{}").format(val.lstrip("@"))554            hit = platform_from_url(url)555            if hit and hit[0] != self.platform:556                cr.platforms = merge_accounts(cr.platforms, [557                    account(hit[0], hit[1], "cross_link",558                            url=url).finalize()])559560561class ApifyOnlyFans(_ApifySocialEnrich):562    source_id = "onlyfans-apify"563    platform = "onlyfans"564    actor = "ka-onlyfans"565    cap = 100566    revisit_days = 7567    metric_map = {"posts_count": "posts", "photos_count": "photos",568                  "videos_count": "videos", "likes": "likes",569                  "streams_count": "streams",570                  "subscribe_price_usd": "subscribe_price_usd",571                  "is_free": "is_free", "location": "location",572                  "join_date": "join_date", "audios_count": "audios"}573574    def bio_urls(self, it: dict) -> list[str]:575        return [it.get("website") or ""]576577578class ApifyFansly(_ApifySocialEnrich):579    source_id = "fansly-apify"580    platform = "fansly"581    actor = "ka-fansly"582    cap = 600583    revisit_days = 7584    metric_map = {"posts_count": "posts", "images_count": "images",585                  "videos_count": "videos", "likes": "likes",586                  "following": "following", "location": "location",587                  "subscription_price": "subscription_price",588                  "account_created_at": "account_created_at"}589590591class ApifyPatreon(_ApifySocialEnrich):592    source_id = "patreon-apify"593    platform = "patreon"594    actor = "ka-patreon"595    cap = 400596    revisit_days = 7597    metric_map = {"patrons": "patrons", "posts_count": "posts",598                  "is_monthly": "is_monthly", "is_nsfw": "is_nsfw",599                  "creation_name": "creation_name",600                  "social_links": "social_links",601                  "published_at": "published_at",602                  "pay_per_name": "pay_per_name"}603604    def bio_urls(self, it: dict) -> list[str]:605        # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link606        return list((it.get("social_links") or {}).values())607608609class ApifyDiscord(_ApifySocialEnrich):610    source_id = "discord-apify"611    platform = "discord"612    actor = "ka-discord"613    cap = 400614    revisit_days = 7615    metric_map = {"members": "members", "online": "online", "boosts": "boosts",616                  "partnered": "partnered", "guild_id": "guild_id",617                  "channel": "channel", "splash": "splash",618                  "vanity_url_code": "vanity_url_code",619                  "premium_tier": "premium_tier",620                  "nsfw_level": "nsfw_level"}621622    def target_of(self, acc) -> str:623        """Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du624        handle minusculé) : discord.gg/<CODE> ou discord.com/invite/<CODE>."""625        url = acc.url or ""626        for sep in ("discord.gg/", "discord.com/invite/", "/invite/"):627            if sep in url:628                return url.split(sep, 1)[1].split("/")[0].split("?")[0]629        return acc.handle630631632# --- découverte Instagram (topsearch, mots-clés QC) ---------------------------633634# thèmes × marqueurs QC — mêmes familles que youtube-recherche, adaptées à la635# recherche d'UTILISATEURS Instagram (courtes, telles qu'on tape dans l'app)636_THEMES = ["humoriste", "gamer", "gaming", "maquillage", "beauté", "fitness",637           "cuisine", "chef", "mode", "voyage", "musique", "chanteuse",638           "chanteur", "danse", "maman", "papa", "plein air", "chasse",639           "pêche", "photographe", "artiste", "peintre", "tatoueur",640           "coiffure", "esthétique", "auto", "moto", "déco", "immobilier",641           "entrepreneure", "podcast", "comédien", "comédienne", "drag",642           "twitch", "youtubeur", "youtubeuse", "tiktokeuse", "influenceuse",643           "créateur de contenu", "créatrice de contenu"]644_MARKERS = ["québec", "qc", "montréal", "mtl"]645646647class InstagramDecouverteApify(BaseConnector):648    """Découverte Instagram par recherche de mots-clés QC (acteur ka-instagram).649650    L'endpoint topsearch du web public retourne les meilleurs comptes pour un651    mot-clé (nom, handle, abonnés approximatifs, badge). Requêtes tournantes652    (~40/passage sur ~160 combinaisons) pour accumuler sans re-payer les mêmes653    résultats chaque jour. Signal : profil_source (0.98) — le compte découvert654    EST la source de la fiche.655    """656657    source_id = "instagram-decouverte"658    kind = "discovery"659    queries_per_pass = 40660661    def build_queries(self) -> list[str]:662        combos = [f"{t} {m}" for t in _THEMES for m in _MARKERS]663        day = datetime.now(timezone.utc).timetuple().tm_yday664        start = (day * self.queries_per_pass) % len(combos)665        rotated = combos[start:] + combos[:start]666        return rotated[:self.queries_per_pass]667668    def fetch(self) -> list[Creator]:669        # NOTE 2026-08-18 : l'endpoint topsearch du web public exige désormais670        # une session authentifiée (401 « Server Error » depuis toute IP non671        # connectée). La découverte par mots-clés est donc suspendue proprement672        # jusqu'à ce qu'une voie publique existe ; l'enrichissement Instagram673        # (instagram-apify) alimente `related_profiles` comme graine future.674        raise SkipSource("topsearch Instagram exige une session connectée "675                         "(voie publique fermée le 2026-08-18)")676        items = run_actor("ka-instagram", {  # noqa: unreachable (repli futur)677            "usernames": [],678            "queries": self.build_queries(),679            "proxyConfiguration": RESIDENTIAL,680        })681        creators: list[Creator] = []682        seen: set[str] = set()683        for it in items:684            if it.get("kind") != "search_user" or not it.get("username"):685                continue686            handle = str(it["username"]).lower()687            if handle in seen:688                continue689            seen.add(handle)690            acc = account("instagram", handle, "profil_source",691                          followers=parse_count(it.get("followers")),692                          verified=it.get("is_verified"),693                          url=f"https://www.instagram.com/{handle}/")694            creators.append(Creator(695                source=self.source_id,696                external_id=handle,697                display_name=it.get("full_name") or handle,698                avatar_url=it.get("avatar") or None,699                platforms=[acc],700                notes=f"découvert via recherche Instagram « {it.get('query')} »",701            ))702        return creators703