Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
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