SPB Git

spb/crea-ka

Public

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

Fiches légendaires : upgrade majeur des 13 acteurs Apify (bannières, covers TikTok, miniatures YouTube/Twitch, media X, previews Snapchat, /about YouTube, socialMedias Twitch GQL, liens sociaux Patreon) + pipeline étendu (banner_url, metric_maps, cross-links), snapshots quotidiens d'audience, insights/Ka Score /100 par créateur (croissance 7j/30j, engagement pondéré, rythme), fiche frontend enrichie (bannière héro, Ka Score, sparkline, bloc live) — acteurs versionnés dans actors/, 8 poussés et validés par runs de test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 21, 2026) parent e7c36ce Browse files

98 changed files +4,340 −13

98 changed files
Unified Split
added actors/ka-discord/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-discord)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-discord/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-discord",
4+ "title": "KA Discord — serveurs via invitation",
5+ "description": "Serveurs Discord via l'API publique d'invitation : membres approximatifs, en ligne, boosts, nom/description. Code d'invit sensible à la casse.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-discord/.actor/input_schema.json +38 −0
@@ -0,0 +1,38 @@
1+{
2+ "title": "ka-discord input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles / codes",
8+ "type": "array",
9+ "description": "Handles (patreon) ou codes d'invitation (discord).",
10+ "editor": "stringList",
11+ "prefill": []
12+ },
13+ "requestDelay": {
14+ "title": "Délai (s)",
15+ "type": "integer",
16+ "default": 1,
17+ "minimum": 0,
18+ "description": "Pause entre requêtes."
19+ },
20+ "concurrency": {
21+ "title": "Concurrence",
22+ "type": "integer",
23+ "default": 5,
24+ "minimum": 1,
25+ "maximum": 10,
26+ "description": "Requêtes simultanées."
27+ },
28+ "proxyConfiguration": {
29+ "title": "Proxy",
30+ "type": "object",
31+ "editor": "proxy",
32+ "description": "Proxy Apify.",
33+ "prefill": {
34+ "useApifyProxy": true
35+ }
36+ }
37+ }
38+}
\ No newline at end of file
added actors/ka-discord/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-discord)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-discord/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-discord)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-discord/src/main.py +87 −0
@@ -0,0 +1,87 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-discord)
4+# Desc: Serveurs Discord via l'API PUBLIQUE d'invitation
5+# /api/v9/invites/{code}?with_counts=true : membres approximatifs,
6+# présents en ligne, nom/description/boosts du serveur. Le CODE d'invit
7+# est SENSIBLE À LA CASSE → il vient de l'URL, pas du handle minusculé.
8+# ==============================================================================
9+from __future__ import annotations
10+
11+import asyncio
12+import json
13+
14+from apify import Actor
15+
16+from .net import Fetcher
17+
18+INVITE_API = ("https://discord.com/api/v9/invites/{code}"
19+ "?with_counts=true&with_expiration=true")
20+
21+
22+async def main() -> None:
23+ async with Actor:
24+ inp = await Actor.get_input() or {}
25+ # ici « usernames » = codes d'invitation (casse préservée par l'appelant)
26+ codes = [c.strip() for c in (inp.get("usernames") or []) if c.strip()]
27+ proxy = await Actor.create_proxy_configuration(
28+ actor_proxy_input=inp.get("proxyConfiguration"))
29+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
30+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 6))
31+
32+ async def one(code: str) -> None:
33+ async with sem:
34+ miss = {"kind": "profile", "platform": "discord",
35+ "username": code, "found": False}
36+ try:
37+ resp = await fetcher.get(
38+ INVITE_API.format(code=code), session_id=code[:12],
39+ headers={"Accept": "application/json"})
40+ except Exception as exc:
41+ await Actor.push_data({**miss, "error": str(exc)[:200]})
42+ return
43+ if resp.status_code == 404:
44+ await Actor.push_data({**miss, "error": "invite_invalide"})
45+ return
46+ if resp.status_code != 200:
47+ await Actor.push_data(
48+ {**miss, "error": f"http_{resp.status_code}"})
49+ return
50+ try:
51+ d = json.loads(resp.text)
52+ except Exception:
53+ await Actor.push_data({**miss, "error": "bad_json"})
54+ return
55+ guild = d.get("guild") or {}
56+ icon = guild.get("icon")
57+ gid = guild.get("id")
58+ banner_hash = guild.get("banner")
59+ splash_hash = guild.get("splash")
60+ await Actor.push_data({
61+ "kind": "profile",
62+ "platform": "discord",
63+ "found": True,
64+ "username": code,
65+ "guild_id": gid,
66+ "full_name": guild.get("name"),
67+ "biography": guild.get("description"),
68+ "followers": d.get("approximate_member_count"), # membres
69+ "members": d.get("approximate_member_count"),
70+ "online": d.get("approximate_presence_count"),
71+ "boosts": guild.get("premium_subscription_count"),
72+ "verified": ("VERIFIED" in (guild.get("features") or [])),
73+ "partnered": ("PARTNERED" in (guild.get("features") or [])),
74+ "channel": (d.get("channel") or {}).get("name"),
75+ "avatar": (f"https://cdn.discordapp.com/icons/{gid}/{icon}.png"
76+ if gid and icon else None),
77+ "banner": (f"https://cdn.discordapp.com/banners/{gid}/"
78+ f"{banner_hash}.png?size=1024"
79+ if gid and banner_hash else None),
80+ "splash": (f"https://cdn.discordapp.com/splashes/{gid}/"
81+ f"{splash_hash}.jpg?size=1024"
82+ if gid and splash_hash else None),
83+ "vanity_url_code": guild.get("vanity_url_code"),
84+ })
85+
86+ await asyncio.gather(*[one(c) for c in codes])
87+ Actor.log.info(f"terminé : {len(codes)} invitations")
added actors/ka-discord/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-facebook/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-facebook)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-facebook/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-facebook",
4+ "title": "KA Facebook — pages publiques",
5+ "description": "Métadonnées de pages Facebook publiques (nom, abonnés/mentions J'aime, catégorie, avatar) extraites du HTML public via proxy résidentiel + empreinte Chrome. Expérimental : Facebook mure agressivement.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-facebook/.actor/input_schema.json +43 −0
@@ -0,0 +1,43 @@
1+{
2+ "title": "KA Facebook input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Pages à enrichir",
8+ "type": "array",
9+ "description": "Slugs de pages Facebook (facebook.com/<slug>) ou URLs complètes.",
10+ "editor": "stringList",
11+ "prefill": [
12+ "facebook"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 2,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 2,
26+ "minimum": 1,
27+ "maximum": 6,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "description": "Résidentiel obligatoire (Facebook rejette les IP datacenter).",
35+ "prefill": {
36+ "useApifyProxy": true,
37+ "apifyProxyGroups": [
38+ "RESIDENTIAL"
39+ ]
40+ }
41+ }
42+ }
43+}
\ No newline at end of file
added actors/ka-facebook/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-facebook)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-facebook/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-facebook)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-facebook/src/main.py +121 −0
@@ -0,0 +1,121 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-facebook)
4+# Desc: Pages Facebook publiques — extraction best-effort du HTML servi aux
5+# navigateurs non connectés : compteur d'abonnés (JSON embarqué
6+# follower_count / texte « N followers/abonnés »), nom (og:title),
7+# avatar (og:image), catégorie. EXPÉRIMENTAL : FB mure agressivement ;
8+# un résultat found=False n'est pas une erreur du pipeline.
9+# ==============================================================================
10+from __future__ import annotations
11+
12+import asyncio
13+import html as htmllib
14+import re
15+
16+from apify import Actor
17+
18+from .net import Fetcher
19+
20+PAGE_URL = "https://www.facebook.com/{u}"
21+
22+_OG_RE = {
23+ "title": re.compile(r'<meta property="og:title" content="([^"]*)"'),
24+ "image": re.compile(r'<meta property="og:image" content="([^"]*)"'),
25+ "description": re.compile(
26+ r'<meta (?:property="og:description"|name="description") '
27+ r'content="([^"]*)"'),
28+}
29+_FOLLOWER_RES = (
30+ re.compile(r'"follower_count"\s*:\s*(\d+)'),
31+ re.compile(r'"global_likers_count"\s*:\s*(\d+)'),
32+)
33+# texte « 1,2 M followers » / « 12 k abonnés » / « 4 016 971 mentions J’aime »
34+_TEXT_COUNT_RE = re.compile(
35+ r'([\d][\d\s  .,]*)\s*([KkMm]?)\s*'
36+ r'(?:followers|abonnés|mentions\s+J’aime|J’aime|likes)',
37+ re.I)
38+_CATEGORY_RE = re.compile(r'"category_name"\s*:\s*"([^"]+)"')
39+_VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)')
40+
41+
42+def parse_text_count(text: str) -> int | None:
43+ m = _TEXT_COUNT_RE.search(text or "")
44+ if not m:
45+ return None
46+ num = m.group(1).replace(" ", "").replace(" ", "").replace(",", ".")
47+ try:
48+ val = float(num)
49+ except ValueError:
50+ return None
51+ unit = (m.group(2) or "").lower()
52+ if unit == "k":
53+ val *= 1_000
54+ elif unit == "m":
55+ val *= 1_000_000
56+ return int(val)
57+
58+
59+async def main() -> None:
60+ async with Actor:
61+ inp = await Actor.get_input() or {}
62+ usernames = []
63+ for raw in (inp.get("usernames") or []):
64+ raw = (raw or "").strip()
65+ if not raw:
66+ continue
67+ raw = re.sub(r"^https?://(www\.|m\.)?facebook\.com/", "", raw)
68+ usernames.append(raw.strip("/").split("?")[0])
69+ proxy = await Actor.create_proxy_configuration(
70+ actor_proxy_input=inp.get("proxyConfiguration"))
71+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2))
72+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 2))
73+
74+ async def one(u: str) -> None:
75+ async with sem:
76+ miss = {"kind": "profile", "platform": "facebook",
77+ "username": u, "found": False}
78+ try:
79+ resp = await fetcher.get(PAGE_URL.format(u=u),
80+ session_id=u)
81+ except Exception as exc:
82+ await Actor.push_data({**miss, "error": str(exc)[:200]})
83+ return
84+ text = resp.text or ""
85+ if resp.status_code != 200 or len(text) < 5000:
86+ await Actor.push_data(
87+ {**miss, "error": f"http_{resp.status_code}"})
88+ return
89+ followers = None
90+ for rx in _FOLLOWER_RES:
91+ m = rx.search(text)
92+ if m:
93+ followers = int(m.group(1))
94+ break
95+ og = {k: htmllib.unescape(m.group(1)) if (m := rx.search(text))
96+ else None for k, rx in _OG_RE.items()}
97+ if followers is None:
98+ followers = parse_text_count(og.get("description") or "")
99+ if followers is None: # texte « N followers/abonnés » du corps
100+ followers = parse_text_count(text)
101+ if followers is None and not og.get("title"):
102+ await Actor.push_data({**miss, "error": "login_wall"})
103+ return
104+ cat = _CATEGORY_RE.search(text)
105+ ver = _VERIFIED_RE.search(text)
106+ await Actor.push_data({
107+ "kind": "profile",
108+ "platform": "facebook",
109+ "found": True,
110+ "username": u,
111+ "full_name": og.get("title"),
112+ "biography": og.get("description"),
113+ "followers": followers,
114+ "category": cat.group(1) if cat else None,
115+ "is_verified": (ver.group(1) == "true") if ver else None,
116+ "avatar": og.get("image"),
117+ "og_description": og.get("description"),
118+ })
119+
120+ await asyncio.gather(*[one(u) for u in usernames])
121+ Actor.log.info(f"terminé : {len(usernames)} pages")
added actors/ka-facebook/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-fansly/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-fansly)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-fansly/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-fansly",
4+ "title": "KA Fansly — profils",
5+ "description": "Profils Fansly via l'API publique (abonnés, bio, badge, compteurs de médias, avatar) — lots de 20 handles.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-fansly/.actor/input_schema.json +38 −0
@@ -0,0 +1,38 @@
1+{
2+ "title": "ka-fansly input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles/slugs à traiter (sans @).",
10+ "editor": "stringList",
11+ "prefill": []
12+ },
13+ "requestDelay": {
14+ "title": "Délai entre requêtes (s)",
15+ "type": "integer",
16+ "default": 1,
17+ "minimum": 0,
18+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
19+ },
20+ "concurrency": {
21+ "title": "Requêtes simultanées",
22+ "type": "integer",
23+ "default": 3,
24+ "minimum": 1,
25+ "maximum": 10,
26+ "description": "Nombre de requêtes menées en parallèle."
27+ },
28+ "proxyConfiguration": {
29+ "title": "Proxy",
30+ "type": "object",
31+ "editor": "proxy",
32+ "description": "Configuration du proxy Apify.",
33+ "prefill": {
34+ "useApifyProxy": true
35+ }
36+ }
37+ }
38+}
\ No newline at end of file
added actors/ka-fansly/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-fansly)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-fansly/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-fansly)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-fansly/src/main.py +93 −0
@@ -0,0 +1,93 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-fansly)
4+# Desc: Profils Fansly via l'API publique apiv3.fansly.com/api/v1/account
5+# (?usernames=) : abonnés (followCount), bio, badge, compteurs de
6+# médias (images/vidéos), avatar/bannière. Lots de 20 handles/requête.
7+# ==============================================================================
8+from __future__ import annotations
9+
10+import asyncio
11+import json
12+
13+from apify import Actor
14+
15+from .net import Fetcher
16+
17+API_URL = ("https://apiv3.fansly.com/api/v1/account"
18+ "?usernames={us}&ngsw-bypass=true")
19+
20+
21+def _loc(media: dict | None) -> str | None:
22+ """Objet média Fansly → URL de la première variante disponible."""
23+ if not isinstance(media, dict):
24+ return None
25+ for variant in ([media] + (media.get("variants") or [])):
26+ for loc in (variant.get("locations") or []):
27+ if loc.get("location"):
28+ return loc["location"]
29+ return None
30+
31+
32+async def main() -> None:
33+ async with Actor:
34+ inp = await Actor.get_input() or {}
35+ usernames = [u.strip().lstrip("@").lower()
36+ for u in (inp.get("usernames") or []) if u and u.strip()]
37+ proxy = await Actor.create_proxy_configuration(
38+ actor_proxy_input=inp.get("proxyConfiguration"))
39+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
40+
41+ found: set[str] = set()
42+ for i in range(0, len(usernames), 20):
43+ batch = usernames[i:i + 20]
44+ try:
45+ resp = await fetcher.get(API_URL.format(us=",".join(batch)),
46+ session_id=f"b{i}",
47+ headers={"Accept":
48+ "application/json"})
49+ data = (json.loads(resp.text)
50+ if resp.status_code == 200 else {})
51+ except Exception as exc:
52+ for u in batch:
53+ await Actor.push_data(
54+ {"kind": "profile", "platform": "fansly",
55+ "username": u, "found": False,
56+ "error": str(exc)[:200]})
57+ continue
58+ for acc in (data.get("response") or []):
59+ u = str(acc.get("username") or "").lower()
60+ if not u:
61+ continue
62+ found.add(u)
63+ stats = acc.get("timelineStats") or {}
64+ await Actor.push_data({
65+ "kind": "profile",
66+ "platform": "fansly",
67+ "found": True,
68+ "id": acc.get("id"),
69+ "username": u,
70+ "full_name": acc.get("displayName") or u,
71+ "biography": (acc.get("about") or "")[:1000],
72+ "followers": acc.get("followCount"),
73+ "following": acc.get("followingCount"),
74+ "is_verified": bool((acc.get("statusInfo") or {})
75+ .get("verified")
76+ or acc.get("verified")),
77+ "posts_count": acc.get("postCount")
78+ or stats.get("postCount"),
79+ "images_count": stats.get("imageCount"),
80+ "videos_count": stats.get("videoCount"),
81+ "likes": acc.get("accountMediaLikes"),
82+ "avatar": _loc(acc.get("avatar")),
83+ "banner": _loc(acc.get("banner")),
84+ "location": acc.get("location"),
85+ })
86+ for u in batch:
87+ if u not in found:
88+ await Actor.push_data(
89+ {"kind": "profile", "platform": "fansly",
90+ "username": u, "found": False,
91+ "error": f"not_found_{resp.status_code}"})
92+ Actor.log.info(f"terminé : {len(usernames)} profils, "
93+ f"{len(found)} trouvés")
added actors/ka-fansly/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-instagram/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-instagram)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-instagram/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-instagram",
4+ "title": "KA Instagram — profils détaillés + recherche",
5+ "description": "Profils Instagram publics ultra détaillés (abonnés, bio, liens, 12 derniers posts avec likes/commentaires/vues, engagement, profils reliés) + recherche d'utilisateurs par mot-clé. Endpoints web publics via proxy résidentiel.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-instagram/.actor/input_schema.json +49 −0
@@ -0,0 +1,49 @@
1+{
2+ "title": "KA Instagram input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles Instagram (sans @) dont on veut le profil détaillé.",
10+ "editor": "stringList",
11+ "prefill": [
12+ "instagram"
13+ ]
14+ },
15+ "queries": {
16+ "title": "Recherches (découverte)",
17+ "type": "array",
18+ "description": "Mots-clés de recherche d'utilisateurs (topsearch).",
19+ "editor": "stringList"
20+ },
21+ "requestDelay": {
22+ "title": "Délai entre requêtes (s)",
23+ "type": "integer",
24+ "default": 1,
25+ "minimum": 0,
26+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
27+ },
28+ "concurrency": {
29+ "title": "Requêtes simultanées",
30+ "type": "integer",
31+ "default": 3,
32+ "minimum": 1,
33+ "maximum": 10,
34+ "description": "Nombre de requêtes menées en parallèle."
35+ },
36+ "proxyConfiguration": {
37+ "title": "Proxy",
38+ "type": "object",
39+ "editor": "proxy",
40+ "description": "Résidentiel recommandé (Instagram bloque les IP datacenter).",
41+ "prefill": {
42+ "useApifyProxy": true,
43+ "apifyProxyGroups": [
44+ "RESIDENTIAL"
45+ ]
46+ }
47+ }
48+ }
49+}
\ No newline at end of file
added actors/ka-instagram/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-instagram)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-instagram/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-instagram)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-instagram/src/main.py +243 −0
@@ -0,0 +1,243 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-instagram)
4+# Desc: Profils Instagram publics ULTRA DÉTAILLÉS via l'endpoint web officiel
5+# web_profile_info (JSON du web public) : identité complète, compteurs,
6+# 12 derniers posts (likes/commentaires/vues/légende/musique/lieu/
7+# co-auteurs/épinglés), agrégats d'engagement et de cadence, profils
8+# reliés — + recherche d'utilisateurs (topsearch) en découverte.
9+# ==============================================================================
10+from __future__ import annotations
11+
12+import asyncio
13+import json
14+import urllib.parse
15+from datetime import datetime, timezone
16+
17+from apify import Actor
18+
19+from .net import Fetcher
20+
21+PROFILE_API = ("https://i.instagram.com/api/v1/users/web_profile_info/"
22+ "?username={u}")
23+SEARCH_API = ("https://www.instagram.com/web/search/topsearch/"
24+ "?context=blended&query={q}&count=30")
25+SEARCH_API_ALT = ("https://i.instagram.com/api/v1/web/search/topsearch/"
26+ "?context=blended&query={q}&count=30")
27+HDRS = {"x-ig-app-id": "936619743392459", "Accept": "application/json"}
28+
29+
30+def _iso(ts) -> str | None:
31+ try:
32+ return datetime.fromtimestamp(int(ts), tz=timezone.utc) \
33+ .strftime("%Y-%m-%dT%H:%M:%SZ")
34+ except (TypeError, ValueError, OSError):
35+ return None
36+
37+
38+def _avg(vals: list) -> int | None:
39+ vals = [v for v in vals if isinstance(v, (int, float))]
40+ return round(sum(vals) / len(vals)) if vals else None
41+
42+
43+def parse_post(node: dict) -> dict:
44+ """Nœud média du fil → post détaillé (métriques, musique, lieu, épinglé)."""
45+ cap = (node.get("edge_media_to_caption") or {}).get("edges") or []
46+ music = node.get("clips_music_attribution_info") or {}
47+ dims = node.get("dimensions") or {}
48+ return {
49+ "id": node.get("id"),
50+ "shortcode": node.get("shortcode"),
51+ "url": f"https://www.instagram.com/p/{node.get('shortcode')}/",
52+ "type": ("reel" if node.get("product_type") == "clips"
53+ else "video" if node.get("is_video") else
54+ "carousel" if node.get("edge_sidecar_to_children")
55+ else "image"),
56+ "caption": ((cap[0].get("node") or {}).get("text", "")
57+ if cap else "")[:500],
58+ "likes": (node.get("edge_liked_by") or {}).get("count"),
59+ "comments": (node.get("edge_media_to_comment") or {}).get("count"),
60+ "video_views": node.get("video_view_count"),
61+ "video_duration_s": node.get("video_duration"),
62+ "timestamp": node.get("taken_at_timestamp"),
63+ "posted_at": _iso(node.get("taken_at_timestamp")),
64+ "display_url": node.get("display_url"),
65+ "thumbnail_url": node.get("thumbnail_src"),
66+ "width": dims.get("width"),
67+ "height": dims.get("height"),
68+ "is_pinned": bool(node.get("pinned_for_users")),
69+ "comments_disabled": node.get("comments_disabled"),
70+ "location": (node.get("location") or {}).get("name"),
71+ "coauthors": [c.get("username")
72+ for c in (node.get("coauthor_producers") or [])
73+ if c.get("username")],
74+ "music": ({"artist": music.get("artist_name"),
75+ "song": music.get("song_name"),
76+ "uses_original_audio": music.get("uses_original_audio")}
77+ if music.get("song_name") or music.get("artist_name")
78+ else None),
79+ "accessibility_caption": (node.get("accessibility_caption")
80+ or "")[:200] or None,
81+ "tagged_users": [((t.get("node") or {}).get("user") or {})
82+ .get("username")
83+ for t in ((node.get("edge_media_to_tagged_user")
84+ or {}).get("edges") or [])][:10],
85+ }
86+
87+
88+def parse_user(user: dict) -> dict:
89+ """Objet user web_profile_info → enregistrement riche standardisé."""
90+ posts = [parse_post((e.get("node") or {}))
91+ for e in ((user.get("edge_owner_to_timeline_media") or {})
92+ .get("edges") or [])]
93+ followers = (user.get("edge_followed_by") or {}).get("count")
94+ avg_likes = _avg([p["likes"] for p in posts])
95+ avg_comments = _avg([p["comments"] for p in posts])
96+ videos = [p for p in posts if p["type"] in ("video", "reel")]
97+ avg_video_views = _avg([p["video_views"] for p in videos])
98+ engagement = None
99+ if followers and avg_likes is not None:
100+ engagement = round(
101+ (avg_likes + (avg_comments or 0)) / followers * 100, 3)
102+ timestamps = sorted(p["timestamp"] for p in posts
103+ if isinstance(p["timestamp"], int))
104+ posts_per_week = None
105+ if len(timestamps) >= 3 and timestamps[-1] > timestamps[0]:
106+ span_weeks = (timestamps[-1] - timestamps[0]) / 604_800
107+ posts_per_week = round((len(timestamps) - 1) / span_weeks, 2)
108+ top_post = max((p for p in posts if isinstance(p["likes"], int)),
109+ key=lambda p: p["likes"], default=None)
110+ related = []
111+ for edge in ((user.get("edge_related_profiles") or {}).get("edges") or []):
112+ n = edge.get("node") or {}
113+ if n.get("username"):
114+ related.append({"username": n["username"],
115+ "full_name": n.get("full_name"),
116+ "is_verified": n.get("is_verified"),
117+ "is_private": n.get("is_private")})
118+ return {
119+ "kind": "profile",
120+ "platform": "instagram",
121+ "found": True,
122+ "id": user.get("id"),
123+ "username": user.get("username"),
124+ "full_name": user.get("full_name"),
125+ "biography": user.get("biography"),
126+ "pronouns": user.get("pronouns") or None,
127+ "external_url": user.get("external_url"),
128+ "bio_links": [b.get("url") for b in (user.get("bio_links") or [])
129+ if b.get("url")],
130+ "followers": followers,
131+ "following": (user.get("edge_follow") or {}).get("count"),
132+ "posts_count": (user.get("edge_owner_to_timeline_media")
133+ or {}).get("count"),
134+ "highlight_reels": user.get("highlight_reel_count"),
135+ "is_verified": user.get("is_verified"),
136+ "is_private": user.get("is_private"),
137+ "is_business": user.get("is_business_account"),
138+ "is_professional": user.get("is_professional_account"),
139+ "category": user.get("category_name"),
140+ "business_category": user.get("business_category_name"),
141+ "business_address": user.get("business_address_json"),
142+ "has_clips": user.get("has_clips"),
143+ "has_channel": user.get("has_channel"),
144+ "has_ar_effects": user.get("has_ar_effects"),
145+ "recently_joined": user.get("is_joined_recently"),
146+ "avatar": user.get("profile_pic_url_hd")
147+ or user.get("profile_pic_url"),
148+ # agrégats calculés sur les 12 derniers posts
149+ "avg_likes": avg_likes,
150+ "avg_comments": avg_comments,
151+ "avg_video_views": avg_video_views,
152+ "engagement_rate_pct": engagement,
153+ "posts_per_week": posts_per_week,
154+ "last_post_at": _iso(timestamps[-1]) if timestamps else None,
155+ "video_share_pct": (round(len(videos) / len(posts) * 100)
156+ if posts else None),
157+ "top_post": ({"url": top_post["url"], "likes": top_post["likes"],
158+ "comments": top_post["comments"],
159+ "type": top_post["type"]} if top_post else None),
160+ "recent_posts": posts,
161+ "related_profiles": related,
162+ }
163+
164+
165+async def main() -> None:
166+ async with Actor:
167+ inp = await Actor.get_input() or {}
168+ usernames = [u.strip().lstrip("@").lower()
169+ for u in (inp.get("usernames") or []) if u and u.strip()]
170+ queries = [q.strip() for q in (inp.get("queries") or [])
171+ if q and q.strip()]
172+ proxy = await Actor.create_proxy_configuration(
173+ actor_proxy_input=inp.get("proxyConfiguration"))
174+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
175+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))
176+
177+ async def one_profile(u: str) -> None:
178+ async with sem:
179+ miss = {"kind": "profile", "platform": "instagram",
180+ "username": u, "found": False}
181+ try:
182+ resp = await fetcher.get(PROFILE_API.format(u=u),
183+ headers=HDRS, session_id=u)
184+ except Exception as exc:
185+ await Actor.push_data({**miss, "error": str(exc)[:200]})
186+ return
187+ if resp.status_code == 404:
188+ await Actor.push_data({**miss, "error": "http_404"})
189+ return
190+ user = None
191+ if resp.status_code == 200:
192+ try:
193+ user = ((json.loads(resp.text).get("data") or {})
194+ .get("user"))
195+ except Exception:
196+ user = None
197+ if not user:
198+ await Actor.push_data(
199+ {**miss, "error": f"http_{resp.status_code}"})
200+ return
201+ await Actor.push_data(parse_user(user))
202+
203+ async def one_search(q: str) -> None:
204+ async with sem:
205+ data = {}
206+ for endpoint in (SEARCH_API, SEARCH_API_ALT):
207+ u2 = endpoint.format(q=urllib.parse.quote(q))
208+ try:
209+ resp = await fetcher.get(u2, headers=HDRS,
210+ session_id=f"srch{q[:4]}")
211+ except Exception as exc:
212+ Actor.log.warning(f"recherche '{q}' : {exc}")
213+ continue
214+ if resp.status_code == 200:
215+ try:
216+ data = json.loads(resp.text)
217+ except Exception:
218+ data = {}
219+ if data.get("users"):
220+ break
221+ if not data.get("users"):
222+ Actor.log.warning(
223+ f"recherche '{q}' : 0 user "
224+ f"(http {resp.status_code}) {resp.text[:200]!r}")
225+ for item in data.get("users") or []:
226+ u = item.get("user") or {}
227+ if not u.get("username") or u.get("is_private"):
228+ continue
229+ await Actor.push_data({
230+ "kind": "search_user",
231+ "platform": "instagram",
232+ "query": q,
233+ "username": u.get("username"),
234+ "full_name": u.get("full_name"),
235+ "is_verified": u.get("is_verified"),
236+ "followers": u.get("follower_count"),
237+ "avatar": u.get("profile_pic_url"),
238+ })
239+
240+ await asyncio.gather(*[one_profile(u) for u in usernames],
241+ *[one_search(q) for q in queries])
242+ Actor.log.info(f"terminé : {len(usernames)} profils, "
243+ f"{len(queries)} recherches")
added actors/ka-instagram/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-kick/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-kick)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-kick/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-kick",
4+ "title": "KA Kick — chaînes détaillées",
5+ "description": "Chaînes Kick via l'API v2 publique (abonnés, live, bio + liens sociaux auto-déclarés) — proxy résidentiel pour Cloudflare.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-kick/.actor/input_schema.json +43 −0
@@ -0,0 +1,43 @@
1+{
2+ "title": "ka-kick input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles/slugs à traiter (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "xqc"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 1,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 3,
26+ "minimum": 1,
27+ "maximum": 10,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "description": "Configuration du proxy Apify.",
35+ "prefill": {
36+ "useApifyProxy": true,
37+ "apifyProxyGroups": [
38+ "RESIDENTIAL"
39+ ]
40+ }
41+ }
42+ }
43+}
\ No newline at end of file
added actors/ka-kick/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-kick)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-kick/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-kick)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-kick/src/main.py +103 −0
@@ -0,0 +1,103 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-kick)
4+# Desc: Chaînes Kick détaillées via l'API publique /api/v2/channels/{slug}
5+# (Cloudflare : proxy résidentiel + empreinte Chrome) : abonnés, badge,
6+# live en cours, bio + LIENS SOCIAUX AUTO-DÉCLARÉS (instagram/twitter/
7+# youtube/tiktok/facebook/discord → cross_link 0.90 côté crea-ka).
8+# ==============================================================================
9+from __future__ import annotations
10+
11+import asyncio
12+import json
13+
14+from apify import Actor
15+
16+from .net import Fetcher
17+
18+API_URL = "https://kick.com/api/v2/channels/{u}"
19+SOCIAL_KEYS = ("instagram", "twitter", "youtube", "tiktok", "facebook",
20+ "discord")
21+
22+
23+async def main() -> None:
24+ async with Actor:
25+ inp = await Actor.get_input() or {}
26+ usernames = [u.strip().lstrip("@").lower()
27+ for u in (inp.get("usernames") or []) if u and u.strip()]
28+ proxy = await Actor.create_proxy_configuration(
29+ actor_proxy_input=inp.get("proxyConfiguration"))
30+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
31+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))
32+
33+ async def one(u: str) -> None:
34+ async with sem:
35+ miss = {"kind": "profile", "platform": "kick",
36+ "username": u, "found": False}
37+ resp = None
38+ for attempt in range(3): # mur Cloudflare → nouvelle IP
39+ try:
40+ resp = await fetcher.get(
41+ API_URL.format(u=u), session_id=f"{u}k{attempt}",
42+ headers={"Accept": "application/json"})
43+ except Exception as exc:
44+ await Actor.push_data({**miss,
45+ "error": str(exc)[:200]})
46+ return
47+ if resp.status_code == 404:
48+ await Actor.push_data({**miss, "error": "http_404"})
49+ return
50+ if resp.status_code == 200 and \
51+ (resp.text or "").startswith("{"):
52+ break
53+ if resp.status_code != 200 or \
54+ not (resp.text or "").startswith("{"):
55+ await Actor.push_data(
56+ {**miss, "error": f"http_{resp.status_code}"})
57+ return
58+ try:
59+ ch = json.loads(resp.text)
60+ except Exception:
61+ await Actor.push_data({**miss, "error": "bad_json"})
62+ return
63+ user = ch.get("user") or {}
64+ live = ch.get("livestream") or {}
65+ cats = [((c.get("category") or {}).get("name"))
66+ for c in (ch.get("recent_categories") or [])
67+ if isinstance(c, dict)]
68+ socials = {k: (user.get(k) or "").strip()
69+ for k in SOCIAL_KEYS if (user.get(k) or "").strip()}
70+ await Actor.push_data({
71+ "kind": "profile",
72+ "platform": "kick",
73+ "found": True,
74+ "id": ch.get("id"),
75+ "username": ch.get("slug"),
76+ "full_name": user.get("username"),
77+ "biography": user.get("bio"),
78+ "followers": ch.get("followers_count")
79+ or ch.get("followersCount"),
80+ "is_verified": bool(ch.get("verified")),
81+ "is_banned": bool(ch.get("is_banned")),
82+ "subscription_enabled": ch.get("subscription_enabled"),
83+ "is_live_now": bool(live),
84+ "live_viewers": live.get("viewer_count"),
85+ "live_title": (live.get("session_title") or "")[:200]
86+ or None,
87+ "live_thumbnail": ((live.get("thumbnail") or {}).get("url")
88+ if isinstance(live.get("thumbnail"),
89+ dict) else None),
90+ "live_category": next(
91+ (c.get("name") for c in (live.get("categories") or [])
92+ if isinstance(c, dict) and c.get("name")), None),
93+ "vod_enabled": ch.get("vod_enabled"),
94+ "avatar": user.get("profile_pic"),
95+ "banner": ((ch.get("banner_image") or {}).get("url")
96+ if isinstance(ch.get("banner_image"), dict)
97+ else None),
98+ "recent_categories": [c for c in cats if c][:5],
99+ "social_links": socials,
100+ })
101+
102+ await asyncio.gather(*[one(u) for u in usernames])
103+ Actor.log.info(f"terminé : {len(usernames)} chaînes")
added actors/ka-kick/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-onlyfans/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-onlyfans)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-onlyfans/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-onlyfans",
4+ "title": "KA OnlyFans — profils publics",
5+ "description": "Profils OnlyFans publics best-effort (posts, photos, vidéos, J'aime, prix d'abonnement, bio, avatar) — EXPÉRIMENTAL.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-onlyfans/.actor/input_schema.json +41 −0
@@ -0,0 +1,41 @@
1+{
2+ "title": "ka-onlyfans input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles/slugs à traiter (sans @).",
10+ "editor": "stringList",
11+ "prefill": []
12+ },
13+ "requestDelay": {
14+ "title": "Délai entre requêtes (s)",
15+ "type": "integer",
16+ "default": 1,
17+ "minimum": 0,
18+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
19+ },
20+ "concurrency": {
21+ "title": "Requêtes simultanées",
22+ "type": "integer",
23+ "default": 3,
24+ "minimum": 1,
25+ "maximum": 10,
26+ "description": "Nombre de requêtes menées en parallèle."
27+ },
28+ "proxyConfiguration": {
29+ "title": "Proxy",
30+ "type": "object",
31+ "editor": "proxy",
32+ "description": "Configuration du proxy Apify.",
33+ "prefill": {
34+ "useApifyProxy": true,
35+ "apifyProxyGroups": [
36+ "RESIDENTIAL"
37+ ]
38+ }
39+ }
40+ }
41+}
\ No newline at end of file
added actors/ka-onlyfans/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-onlyfans)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-onlyfans/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-onlyfans)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-onlyfans/src/main.py +112 −0
@@ -0,0 +1,112 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-onlyfans)
4+# Desc: Profils OnlyFans publics (page de profil hors connexion) — extraction
5+# best-effort des compteurs embarqués : posts, photos, vidéos, J'aime
6+# (favoritedCount), prix d'abonnement, badge, bio, avatar/bannière.
7+# EXPÉRIMENTAL : OnlyFans change souvent son rendu ; found=False n'est
8+# pas une erreur du pipeline.
9+# ==============================================================================
10+from __future__ import annotations
11+
12+import asyncio
13+import html as htmllib
14+import json
15+import re
16+
17+from apify import Actor
18+
19+from .net import Fetcher
20+
21+PROFILE_URL = "https://onlyfans.com/{u}"
22+
23+_NUM_RES = {
24+ "posts_count": re.compile(r'"postsCount"\s*:\s*(\d+)'),
25+ "photos_count": re.compile(r'"photosCount"\s*:\s*(\d+)'),
26+ "videos_count": re.compile(r'"videosCount"\s*:\s*(\d+)'),
27+ "likes": re.compile(r'"favoritedCount"\s*:\s*(\d+)'),
28+ "streams_count": re.compile(r'"finishedStreamsCount"\s*:\s*(\d+)'),
29+}
30+_PRICE_RE = re.compile(r'"subscribePrice"\s*:\s*([\d.]+)')
31+_STR_RES = {
32+ "full_name": re.compile(r'"name"\s*:\s*"((?:[^"\\]|\\.)*)"'),
33+ "biography": re.compile(r'"rawAbout"\s*:\s*"((?:[^"\\]|\\.)*)"'),
34+ "location": re.compile(r'"location"\s*:\s*"((?:[^"\\]|\\.)*)"'),
35+ "website": re.compile(r'"website"\s*:\s*"((?:[^"\\]|\\.)*)"'),
36+ "avatar": re.compile(r'"avatar"\s*:\s*"((?:[^"\\]|\\.)*)"'),
37+ "banner": re.compile(r'"header"\s*:\s*"((?:[^"\\]|\\.)*)"'),
38+}
39+_VERIFIED_RE = re.compile(r'"isVerified"\s*:\s*(true|false)')
40+_OG_TITLE_RE = re.compile(r'<meta property="og:title" content="([^"]*)"')
41+
42+
43+def _dec(raw: str) -> str:
44+ try:
45+ return htmllib.unescape(json.loads(f'"{raw}"'))
46+ except Exception:
47+ return raw
48+
49+
50+async def main() -> None:
51+ async with Actor:
52+ inp = await Actor.get_input() or {}
53+ usernames = [u.strip().lstrip("@").lower()
54+ for u in (inp.get("usernames") or []) if u and u.strip()]
55+ proxy = await Actor.create_proxy_configuration(
56+ actor_proxy_input=inp.get("proxyConfiguration"))
57+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2))
58+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 2))
59+
60+ async def one(u: str) -> None:
61+ async with sem:
62+ miss = {"kind": "profile", "platform": "onlyfans",
63+ "username": u, "found": False}
64+ resp = None
65+ for attempt in range(3): # mur Cloudflare → nouvelle IP
66+ try:
67+ resp = await fetcher.get(PROFILE_URL.format(u=u),
68+ session_id=f"{u}o{attempt}")
69+ except Exception as exc:
70+ await Actor.push_data({**miss,
71+ "error": str(exc)[:200]})
72+ return
73+ if resp.status_code == 404:
74+ await Actor.push_data({**miss, "error": "http_404"})
75+ return
76+ if resp.status_code == 200 and \
77+ "postsCount" in (resp.text or ""):
78+ break
79+ text = resp.text or ""
80+ if resp.status_code != 200 or "postsCount" not in text:
81+ await Actor.push_data(
82+ {**miss, "error": f"wall_{resp.status_code}"})
83+ return
84+ rec: dict = {"kind": "profile", "platform": "onlyfans",
85+ "found": True, "username": u}
86+ for key, rx in _NUM_RES.items():
87+ m = rx.search(text)
88+ rec[key] = int(m.group(1)) if m else None
89+ for key, rx in _STR_RES.items():
90+ m = rx.search(text)
91+ rec[key] = _dec(m.group(1)) if m else None
92+ if rec.get("biography"):
93+ rec["biography"] = re.sub(r"<[^>]+>", " ",
94+ rec["biography"])[:1000].strip()
95+ m = _PRICE_RE.search(text)
96+ rec["subscribe_price_usd"] = float(m.group(1)) if m else None
97+ rec["is_free"] = (rec["subscribe_price_usd"] == 0.0
98+ if rec["subscribe_price_usd"] is not None
99+ else None)
100+ m = _VERIFIED_RE.search(text)
101+ rec["is_verified"] = (m.group(1) == "true") if m else None
102+ if not rec.get("full_name"):
103+ m = _OG_TITLE_RE.search(text)
104+ if m:
105+ rec["full_name"] = htmllib.unescape(
106+ m.group(1)).split(" OnlyFans")[0].strip()
107+ # « followers » au sens crea-ka : le compteur public = J'aime
108+ rec["followers"] = None
109+ await Actor.push_data(rec)
110+
111+ await asyncio.gather(*[one(u) for u in usernames])
112+ Actor.log.info(f"terminé : {len(usernames)} profils")
added actors/ka-onlyfans/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-patreon/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-patreon)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-patreon/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-patreon",
4+ "title": "KA Patreon — pages publiques",
5+ "description": "Pages Patreon publiques : patrons, publications, nom, avatar, résumé (données embarquées de la campagne).",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-patreon/.actor/input_schema.json +41 −0
@@ -0,0 +1,41 @@
1+{
2+ "title": "ka-patreon input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles / codes",
8+ "type": "array",
9+ "description": "Handles (patreon) ou codes d'invitation (discord).",
10+ "editor": "stringList",
11+ "prefill": []
12+ },
13+ "requestDelay": {
14+ "title": "Délai (s)",
15+ "type": "integer",
16+ "default": 1,
17+ "minimum": 0,
18+ "description": "Pause entre requêtes."
19+ },
20+ "concurrency": {
21+ "title": "Concurrence",
22+ "type": "integer",
23+ "default": 5,
24+ "minimum": 1,
25+ "maximum": 10,
26+ "description": "Requêtes simultanées."
27+ },
28+ "proxyConfiguration": {
29+ "title": "Proxy",
30+ "type": "object",
31+ "editor": "proxy",
32+ "description": "Proxy Apify.",
33+ "prefill": {
34+ "useApifyProxy": true,
35+ "apifyProxyGroups": [
36+ "RESIDENTIAL"
37+ ]
38+ }
39+ }
40+ }
41+}
\ No newline at end of file
added actors/ka-patreon/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-patreon)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-patreon/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-patreon)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-patreon/src/main.py +114 −0
@@ -0,0 +1,114 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-patreon)
4+# Desc: Pages Patreon publiques — la page /<vanity> embarque l'objet campagne
5+# (patron_count, creation_count, is_monthly, pay_per_name, résumé) +
6+# og:title/og:image. Aucune clé. Proxy résidentiel recommandé.
7+# ==============================================================================
8+from __future__ import annotations
9+
10+import asyncio
11+import html as htmllib
12+import json
13+import re
14+
15+from apify import Actor
16+
17+from .net import Fetcher
18+
19+PAGE_URL = "https://www.patreon.com/{u}"
20+_NUM = {
21+ "patrons": re.compile(r'"patron_count"\s*:\s*(\d+)'),
22+ "posts": re.compile(r'"creation_count"\s*:\s*(\d+)'),
23+ "paid_posts": re.compile(r'"paid_member_count"\s*:\s*(\d+)'),
24+}
25+_OG_TITLE = re.compile(r'<meta property="og:title" content="([^"]*)"')
26+_OG_IMG = re.compile(r'<meta property="og:image" content="([^"]*)"')
27+_OG_DESC = re.compile(r'<meta property="og:description" content="([^"]*)"')
28+_MONTHLY = re.compile(r'"is_monthly"\s*:\s*(true|false)')
29+_NSFW = re.compile(r'"is_nsfw"\s*:\s*(true|false)')
30+_COVER = re.compile(r'"cover_photo_url"\s*:\s*"((?:[^"\\]|\\.)*)"')
31+_CREATION = re.compile(r'"creation_name"\s*:\s*"((?:[^"\\]|\\.)*)"')
32+# liens sociaux auto-déclarés de la campagne (objets campaign de l'app Patreon)
33+_SOCIALS = {
34+ "facebook": re.compile(r'"facebook"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'),
35+ "twitter": re.compile(r'"twitter"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'),
36+ "youtube": re.compile(r'"youtube"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'),
37+}
38+
39+
40+def _dec(raw: str) -> str:
41+ try:
42+ return htmllib.unescape(json.loads(f'"{raw}"'))
43+ except Exception:
44+ return raw
45+
46+
47+async def main() -> None:
48+ async with Actor:
49+ inp = await Actor.get_input() or {}
50+ usernames = [u.strip().lstrip("@").lower()
51+ for u in (inp.get("usernames") or []) if u and u.strip()]
52+ proxy = await Actor.create_proxy_configuration(
53+ actor_proxy_input=inp.get("proxyConfiguration"))
54+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
55+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 4))
56+
57+ async def one(u: str) -> None:
58+ async with sem:
59+ miss = {"kind": "profile", "platform": "patreon",
60+ "username": u, "found": False}
61+ try:
62+ resp = await fetcher.get(PAGE_URL.format(u=u),
63+ session_id=u)
64+ except Exception as exc:
65+ await Actor.push_data({**miss, "error": str(exc)[:200]})
66+ return
67+ text = resp.text or ""
68+ if resp.status_code == 404:
69+ await Actor.push_data({**miss, "error": "http_404"})
70+ return
71+ m = _NUM["patrons"].search(text)
72+ if resp.status_code != 200 or not m:
73+ await Actor.push_data(
74+ {**miss, "error": f"http_{resp.status_code}"})
75+ return
76+
77+ def num(key):
78+ mm = _NUM[key].search(text)
79+ return int(mm.group(1)) if mm else None
80+ title = _OG_TITLE.search(text)
81+ img = _OG_IMG.search(text)
82+ desc = _OG_DESC.search(text)
83+ monthly = _MONTHLY.search(text)
84+ nsfw = _NSFW.search(text)
85+ cover = _COVER.search(text)
86+ creation = _CREATION.search(text)
87+ socials = {k: _dec(m.group(1))
88+ for k, rx in _SOCIALS.items()
89+ if (m := rx.search(text))}
90+ name = htmllib.unescape(title.group(1)) if title else u
91+ name = re.sub(r"\s*\|\s*Patreon\s*$", "", name).strip()
92+ await Actor.push_data({
93+ "kind": "profile",
94+ "platform": "patreon",
95+ "found": True,
96+ "username": u,
97+ "full_name": name,
98+ "biography": (htmllib.unescape(desc.group(1))
99+ if desc else None),
100+ "followers": num("patrons"), # patrons = « abonnés » KA
101+ "patrons": num("patrons"),
102+ "posts_count": num("posts"),
103+ "is_monthly": (monthly.group(1) == "true"
104+ if monthly else None),
105+ "is_nsfw": nsfw.group(1) == "true" if nsfw else None,
106+ "creation_name": (_dec(creation.group(1))[:200]
107+ if creation else None),
108+ "avatar": htmllib.unescape(img.group(1)) if img else None,
109+ "banner": _dec(cover.group(1)) if cover else None,
110+ "social_links": socials or None,
111+ })
112+
113+ await asyncio.gather(*[one(u) for u in usernames])
114+ Actor.log.info(f"terminé : {len(usernames)} pages")
added actors/ka-patreon/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-snapchat/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-snapchat)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-snapchat/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-snapchat",
4+ "title": "KA Snapchat — profils publics",
5+ "description": "Profils Snapchat publics (abonnés, bio, badge, catégorie, avatar, présence story/spotlight, comptes reliés) via le JSON __NEXT_DATA__ de snapchat.com/add/<user>.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-snapchat/.actor/input_schema.json +43 −0
@@ -0,0 +1,43 @@
1+{
2+ "title": "KA Snapchat input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles Snapchat (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "team.snapchat"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 1,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 3,
26+ "minimum": 1,
27+ "maximum": 10,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "prefill": {
35+ "useApifyProxy": true,
36+ "apifyProxyGroups": [
37+ "RESIDENTIAL"
38+ ]
39+ },
40+ "description": "Configuration du proxy Apify."
41+ }
42+ }
43+}
\ No newline at end of file
added actors/ka-snapchat/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-snapchat)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-snapchat/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-snapchat)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-snapchat/src/main.py +121 −0
@@ -0,0 +1,121 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-snapchat)
4+# Desc: Profils Snapchat publics via le JSON __NEXT_DATA__ de la page
5+# snapchat.com/add/<user> : publicProfileInfo (abonnés, bio, badge,
6+# catégorie, avatar) + présence story/spotlight + comptes reliés.
7+# Structure validée le 2026-08-18 (pageProps.userProfile.$case).
8+# ==============================================================================
9+from __future__ import annotations
10+
11+import asyncio
12+import json
13+import re
14+
15+from apify import Actor
16+
17+from .net import Fetcher
18+
19+PROFILE_URL = "https://www.snapchat.com/add/{u}"
20+_NEXT_DATA_RE = re.compile(
21+ r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
22+
23+
24+def _num(v):
25+ try:
26+ return int(v)
27+ except (TypeError, ValueError):
28+ return None
29+
30+
31+async def main() -> None:
32+ async with Actor:
33+ inp = await Actor.get_input() or {}
34+ usernames = [u.strip().lstrip("@").lower()
35+ for u in (inp.get("usernames") or []) if u and u.strip()]
36+ proxy = await Actor.create_proxy_configuration(
37+ actor_proxy_input=inp.get("proxyConfiguration"))
38+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
39+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))
40+
41+ async def one(u: str) -> None:
42+ async with sem:
43+ miss = {"kind": "profile", "platform": "snapchat",
44+ "username": u, "found": False}
45+ try:
46+ resp = await fetcher.get(PROFILE_URL.format(u=u),
47+ session_id=u)
48+ except Exception as exc:
49+ await Actor.push_data({**miss, "error": str(exc)[:200]})
50+ return
51+ m = _NEXT_DATA_RE.search(resp.text or "")
52+ if resp.status_code != 200 or not m:
53+ await Actor.push_data(
54+ {**miss, "error": f"http_{resp.status_code}"})
55+ return
56+ try:
57+ props = (json.loads(m.group(1)).get("props") or {}) \
58+ .get("pageProps") or {}
59+ except Exception:
60+ await Actor.push_data({**miss, "error": "bad_json"})
61+ return
62+ up = props.get("userProfile") or {}
63+ info = up.get("publicProfileInfo") or {}
64+ story = (props.get("story") or {})
65+ snaps = story.get("snapList") or []
66+ lenses = props.get("lenses") or []
67+ spotlight = props.get("spotlightHighlights") or []
68+ if not info.get("username"):
69+ await Actor.push_data({**miss, "error": "no_profile"})
70+ return
71+ related = [
72+ {"username": r.get("username"), "title": r.get("title")}
73+ for r in ((info.get("relatedAccountsInfo") or [])
74+ if isinstance(info.get("relatedAccountsInfo"),
75+ list) else [])
76+ if isinstance(r, dict) and r.get("username")]
77+ story_previews = []
78+ for s in snaps[:6]:
79+ if not isinstance(s, dict):
80+ continue
81+ urls = s.get("snapUrls") or {}
82+ prev = (urls.get("mediaPreviewUrl") or {}) \
83+ if isinstance(urls.get("mediaPreviewUrl"), dict) \
84+ else {"value": urls.get("mediaPreviewUrl")}
85+ url = prev.get("value") or urls.get("mediaUrl")
86+ if url:
87+ story_previews.append(url)
88+ spotlight_previews = [
89+ h.get("thumbnailUrl")
90+ for h in spotlight[:6]
91+ if isinstance(h, dict) and h.get("thumbnailUrl")]
92+ subs = _num(info.get("subscriberCount"))
93+ await Actor.push_data({
94+ "kind": "profile",
95+ "platform": "snapchat",
96+ "found": True,
97+ "username": info.get("username"),
98+ "full_name": info.get("title"),
99+ "biography": info.get("bio"),
100+ "followers": subs if subs else None, # 0 = masqué
101+ "is_verified": str(info.get("badge")) == "1",
102+ "category": info.get("categoryStringId"),
103+ "website": info.get("websiteUrl"),
104+ "address": info.get("address"),
105+ "avatar": info.get("profilePictureUrl"),
106+ "has_story": bool(info.get("hasStory")),
107+ "has_spotlight": bool(info.get("hasSpotlightHighlights")),
108+ "has_curated_highlights": bool(
109+ info.get("hasCuratedHighlights")),
110+ "story_snaps_count": len(snaps) or None,
111+ "story_previews": story_previews or None,
112+ "lenses_count": len(lenses) or None,
113+ "spotlight_highlights_count": len(spotlight) or None,
114+ "spotlight_previews": spotlight_previews or None,
115+ "publisher_type": info.get("publisherType") or None,
116+ "subcategory": info.get("subcategoryStringId") or None,
117+ "related_accounts": related,
118+ })
119+
120+ await asyncio.gather(*[one(u) for u in usernames])
121+ Actor.log.info(f"terminé : {len(usernames)} profils")
added actors/ka-snapchat/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-threads/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-threads)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-threads/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-threads",
4+ "title": "KA Threads — profils",
5+ "description": "Profils Meta Threads publics (abonnés, bio, badge, avatar + posts récents si embarqués) extraits du JSON de la page publique via proxy résidentiel.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-threads/.actor/input_schema.json +43 −0
@@ -0,0 +1,43 @@
1+{
2+ "title": "KA Threads input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles Threads (sans @, généralement = handle Instagram).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "zuck"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 2,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 2,
26+ "minimum": 1,
27+ "maximum": 6,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "description": "Résidentiel recommandé (Meta sert une coquille aux IP datacenter).",
35+ "prefill": {
36+ "useApifyProxy": true,
37+ "apifyProxyGroups": [
38+ "RESIDENTIAL"
39+ ]
40+ }
41+ }
42+ }
43+}
\ No newline at end of file
added actors/ka-threads/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-threads)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-threads/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-threads)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-threads/src/main.py +99 −0
@@ -0,0 +1,99 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-threads)
4+# Desc: Profils Meta Threads publics : la page @handle embarque le profil
5+# dans des scripts JSON Relay (follower_count, biography, badge,
6+# avatar) + les posts récents (caption/like_count) quand servis.
7+# Extraction par regex ciblées — robuste aux réorganisations du JSON.
8+# ==============================================================================
9+from __future__ import annotations
10+
11+import asyncio
12+import html as htmllib
13+import json
14+import re
15+
16+from apify import Actor
17+
18+from .net import Fetcher
19+
20+PROFILE_URL = "https://www.threads.com/@{u}"
21+
22+_FOLLOWERS_RE = re.compile(r'"follower_count"\s*:\s*(\d+)')
23+_BIO_RE = re.compile(r'"biography"\s*:\s*"((?:[^"\\]|\\.)*)"')
24+_NAME_RE = re.compile(r'"full_name"\s*:\s*"((?:[^"\\]|\\.)*)"')
25+_VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)')
26+_PIC_RE = re.compile(r'"profile_pic_url"\s*:\s*"((?:[^"\\]|\\.)*)"')
27+# posts : paires texte + like_count dans les payloads thread_items
28+_POST_RE = re.compile(
29+ r'"caption"\s*:\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"[^{}]*?\}'
30+ r'.{0,600}?"like_count"\s*:\s*(\d+)', re.S)
31+
32+
33+async def main() -> None:
34+ async with Actor:
35+ inp = await Actor.get_input() or {}
36+ usernames = [u.strip().lstrip("@").lower()
37+ for u in (inp.get("usernames") or []) if u and u.strip()]
38+ proxy = await Actor.create_proxy_configuration(
39+ actor_proxy_input=inp.get("proxyConfiguration"))
40+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2))
41+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 2))
42+
43+ async def one(u: str) -> None:
44+ async with sem:
45+ miss = {"kind": "profile", "platform": "threads",
46+ "username": u, "found": False}
47+ try:
48+ resp = await fetcher.get(PROFILE_URL.format(u=u),
49+ session_id=u)
50+ except Exception as exc:
51+ await Actor.push_data({**miss, "error": str(exc)[:200]})
52+ return
53+ text = resp.text or ""
54+ fol = _FOLLOWERS_RE.search(text)
55+ if resp.status_code != 200 or not fol:
56+ await Actor.push_data(
57+ {**miss,
58+ "error": f"shell_or_{resp.status_code}"})
59+ return
60+ posts = []
61+ for m in _POST_RE.finditer(text):
62+ cap = _dec(m.group(1))[:500]
63+ if cap and all(p["caption"] != cap for p in posts):
64+ posts.append({"caption": cap,
65+ "likes": int(m.group(2))})
66+ if len(posts) >= 15:
67+ break
68+ bio = _BIO_RE.search(text)
69+ name = _NAME_RE.search(text)
70+ ver = _VERIFIED_RE.search(text)
71+ pic = _PIC_RE.search(text)
72+ likes = [p["likes"] for p in posts]
73+ top = max(posts, key=lambda p: p["likes"], default=None)
74+ await Actor.push_data({
75+ "kind": "profile",
76+ "platform": "threads",
77+ "found": True,
78+ "username": u,
79+ "full_name": _dec(name.group(1)) if name else None,
80+ "biography": _dec(bio.group(1)) if bio else None,
81+ "followers": int(fol.group(1)),
82+ "is_verified": (ver.group(1) == "true") if ver else None,
83+ "avatar": _dec(pic.group(1)) if pic else None,
84+ "avg_likes": round(sum(likes) / len(likes))
85+ if likes else None,
86+ "top_post": top,
87+ "recent_posts": posts,
88+ })
89+
90+ await asyncio.gather(*[one(u) for u in usernames])
91+ Actor.log.info(f"terminé : {len(usernames)} profils")
92+
93+
94+def _dec(raw: str) -> str:
95+ """Décode une chaîne échappée JSON (\\uXXXX, \\/ …)."""
96+ try:
97+ return htmllib.unescape(json.loads(f'"{raw}"'))
98+ except Exception:
99+ return raw
added actors/ka-threads/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-tiktok/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-tiktok)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-tiktok/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-tiktok",
4+ "title": "KA TikTok — profils détaillés",
5+ "description": "Profils TikTok publics détaillés (abonnés, cœurs, vidéos, bio, lien de bio, badge, vidéos récentes si présentes dans le SSR) via la page publique + proxy résidentiel.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-tiktok/.actor/input_schema.json +43 −0
@@ -0,0 +1,43 @@
1+{
2+ "title": "KA TikTok input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles TikTok (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "tiktok"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 1,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 3,
26+ "minimum": 1,
27+ "maximum": 10,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "description": "Résidentiel recommandé (TikTok sert une coquille vide aux IP datacenter).",
35+ "prefill": {
36+ "useApifyProxy": true,
37+ "apifyProxyGroups": [
38+ "RESIDENTIAL"
39+ ]
40+ }
41+ }
42+ }
43+}
\ No newline at end of file
added actors/ka-tiktok/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-tiktok)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-tiktok/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-tiktok)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-tiktok/src/main.py +228 −0
@@ -0,0 +1,228 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-tiktok)
4+# Desc: Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON
5+# SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes
6+# (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),
7+# durée, hashtags, musique, épinglés + cadence (videos_per_week) et
8+# top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList).
9+# ==============================================================================
10+from __future__ import annotations
11+
12+import asyncio
13+import json
14+import re
15+
16+from apify import Actor
17+
18+from .net import Fetcher
19+
20+PROFILE_URL = "https://www.tiktok.com/@{u}"
21+_STATE_RE = re.compile(
22+ r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)</script>',
23+ re.S)
24+_ITEM_RE = re.compile(r'"ItemModule"\s*:\s*(\{.*?\})\s*,\s*"[A-Z]', re.S)
25+
26+
27+def parse_videos(html: str, data: dict) -> list[dict]:
28+ """Vidéos récentes si le SSR les inclut (souvent absent — best effort)."""
29+ items: list[dict] = []
30+ raw: dict = {}
31+ scope = data.get("__DEFAULT_SCOPE__") or {}
32+ detail = scope.get("webapp.user-detail") or {}
33+ for it in (detail.get("itemList") or []):
34+ raw[it.get("id", str(len(raw)))] = it
35+ if not raw:
36+ m = _ITEM_RE.search(html)
37+ if m:
38+ try:
39+ raw = json.loads(m.group(1))
40+ except Exception:
41+ raw = {}
42+ for vid in list(raw.values())[:20]:
43+ stats = vid.get("stats") or vid.get("statsV2") or {}
44+ author = vid.get("author")
45+ handle = author if isinstance(author, str) else \
46+ (author or {}).get("uniqueId", "")
47+ video = vid.get("video") or {}
48+ music = vid.get("music") or {}
49+ items.append({
50+ "id": vid.get("id"),
51+ "url": f"https://www.tiktok.com/@{handle}/video/{vid.get('id')}",
52+ "caption": (vid.get("desc") or "")[:500],
53+ "views": _num(stats.get("playCount")),
54+ "likes": _num(stats.get("diggCount")),
55+ "comments": _num(stats.get("commentCount")),
56+ "shares": _num(stats.get("shareCount")),
57+ "saves": _num(stats.get("collectCount")),
58+ "timestamp": vid.get("createTime"),
59+ "cover": (video.get("cover") or video.get("dynamicCover")
60+ or video.get("originCover")),
61+ "duration_s": _num(video.get("duration")),
62+ "width": _num(video.get("width")),
63+ "height": _num(video.get("height")),
64+ "is_pinned": bool(vid.get("isPinnedItem")),
65+ "hashtags": [c.get("hashtagName")
66+ for c in (vid.get("textExtra") or [])
67+ if c.get("hashtagName")][:8],
68+ "music": ({"title": music.get("title"),
69+ "author": music.get("authorName"),
70+ "original": music.get("original")}
71+ if music.get("title") else None),
72+ })
73+ return items
74+
75+
76+def _num(v):
77+ try:
78+ return int(v)
79+ except (TypeError, ValueError):
80+ return None
81+
82+
83+async def main() -> None:
84+ async with Actor:
85+ inp = await Actor.get_input() or {}
86+ usernames = [u.strip().lstrip("@").lower()
87+ for u in (inp.get("usernames") or []) if u and u.strip()]
88+ proxy = await Actor.create_proxy_configuration(
89+ actor_proxy_input=inp.get("proxyConfiguration"))
90+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
91+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))
92+
93+ async def one(u: str) -> None:
94+ async with sem:
95+ miss = {"kind": "profile", "platform": "tiktok",
96+ "username": u, "found": False}
97+ resp = None
98+ m = None
99+ for attempt in range(3): # coquille sans SSR → nouvelle IP
100+ try:
101+ resp = await fetcher.get(PROFILE_URL.format(u=u),
102+ session_id=f"{u}t{attempt}")
103+ except Exception as exc:
104+ await Actor.push_data({**miss,
105+ "error": str(exc)[:200]})
106+ return
107+ if resp.status_code == 404:
108+ await Actor.push_data({**miss, "error": "http_404"})
109+ return
110+ m = _STATE_RE.search(resp.text or "")
111+ if m and "webapp.user-detail" in (resp.text or ""):
112+ break
113+ info = None
114+ data: dict = {}
115+ if m:
116+ try:
117+ data = json.loads(m.group(1))
118+ info = ((data.get("__DEFAULT_SCOPE__") or {})
119+ .get("webapp.user-detail") or {}).get("userInfo")
120+ except Exception:
121+ info = None
122+ if not info or not (info.get("user") or {}).get("uniqueId"):
123+ await Actor.push_data(
124+ {**miss, "error": f"shell_page_{resp.status_code}"})
125+ return
126+ user = info.get("user") or {}
127+ stats = info.get("statsV2") or info.get("stats") or {}
128+ if user.get("privateAccount"):
129+ await Actor.push_data({**miss, "error": "private"})
130+ return
131+ followers = _num(stats.get("followerCount"))
132+ videos = parse_videos(resp.text, data)
133+ views = [v["views"] for v in videos
134+ if isinstance(v["views"], int)]
135+ likes = [v["likes"] for v in videos
136+ if isinstance(v["likes"], int)]
137+ comments = [v["comments"] for v in videos
138+ if isinstance(v["comments"], int)]
139+ shares = [v["shares"] for v in videos
140+ if isinstance(v["shares"], int)]
141+ avg_views = round(sum(views) / len(views)) if views else None
142+ avg_likes = round(sum(likes) / len(likes)) if likes else None
143+ engagement = None
144+ if followers and avg_likes is not None:
145+ engagement = round(
146+ (avg_likes
147+ + (round(sum(comments) / len(comments))
148+ if comments else 0)
149+ + (round(sum(shares) / len(shares))
150+ if shares else 0)) / followers * 100, 3)
151+ top_video = max(
152+ (v for v in videos if isinstance(v["views"], int)),
153+ key=lambda v: v["views"], default=None)
154+ stamps = sorted(_num(v["timestamp"]) for v in videos
155+ if _num(v["timestamp"]))
156+ videos_per_week = None
157+ if len(stamps) >= 3 and stamps[-1] > stamps[0]:
158+ videos_per_week = round(
159+ (len(stamps) - 1)
160+ / ((stamps[-1] - stamps[0]) / 604_800), 2)
161+ last_video_at = None
162+ if stamps:
163+ from datetime import datetime, timezone
164+ last_video_at = datetime.fromtimestamp(
165+ stamps[-1], tz=timezone.utc) \
166+ .strftime("%Y-%m-%dT%H:%M:%SZ")
167+ tag_counts: dict[str, int] = {}
168+ for v in videos:
169+ for h in v.get("hashtags") or []:
170+ tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1
171+ commerce = user.get("commerceUserInfo") or {}
172+ bio_link = ((user.get("bioLink") or {}).get("link")
173+ or "").strip()
174+ total_likes = _num(stats.get("heartCount")
175+ or stats.get("heart"))
176+ videos_count = _num(stats.get("videoCount"))
177+ await Actor.push_data({
178+ "kind": "profile",
179+ "platform": "tiktok",
180+ "found": True,
181+ "id": user.get("id"),
182+ "sec_uid": user.get("secUid"),
183+ "username": user.get("uniqueId"),
184+ "full_name": user.get("nickname"),
185+ "biography": user.get("signature"),
186+ "bio_link": bio_link,
187+ "followers": followers,
188+ "following": _num(stats.get("followingCount")),
189+ "friends": _num(stats.get("friendCount")),
190+ "total_likes": total_likes,
191+ "videos_count": videos_count,
192+ "avg_likes_per_video_lifetime": (
193+ round(total_likes / videos_count)
194+ if total_likes and videos_count else None),
195+ "is_verified": bool(user.get("verified")),
196+ "is_organization": bool(user.get("isOrganization")),
197+ "is_seller": bool(user.get("ttSeller")),
198+ "is_live_now": bool(user.get("roomId")),
199+ "commerce_category": (commerce.get("category") or None
200+ if commerce.get("commerceUser")
201+ else None),
202+ "account_created_at": user.get("createTime"),
203+ "region": user.get("region"),
204+ "language": user.get("language"),
205+ "avatar": user.get("avatarLarger")
206+ or user.get("avatarMedium"),
207+ # agrégats sur les vidéos présentes dans le SSR
208+ "avg_views": avg_views,
209+ "avg_likes": avg_likes,
210+ "avg_comments": (round(sum(comments) / len(comments))
211+ if comments else None),
212+ "avg_shares": (round(sum(shares) / len(shares))
213+ if shares else None),
214+ "engagement_rate_pct": engagement,
215+ "videos_per_week": videos_per_week,
216+ "last_video_at": last_video_at,
217+ "top_hashtags": sorted(tag_counts, key=tag_counts.get,
218+ reverse=True)[:8],
219+ "top_video": ({"url": top_video["url"],
220+ "views": top_video["views"],
221+ "likes": top_video["likes"],
222+ "cover": top_video.get("cover")}
223+ if top_video else None),
224+ "recent_videos": videos,
225+ })
226+
227+ await asyncio.gather(*[one(u) for u in usernames])
228+ Actor.log.info(f"terminé : {len(usernames)} profils")
added actors/ka-tiktok/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-twitch/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-twitch)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-twitch/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-twitch",
4+ "title": "KA Twitch — profils détaillés",
5+ "description": "Profils Twitch détaillés via le GQL public (abonnés, partenaire/affilié, live en cours, dernière diffusion, jeux et vidéos récentes).",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-twitch/.actor/input_schema.json +40 −0
@@ -0,0 +1,40 @@
1+{
2+ "title": "ka-twitch input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles/slugs à traiter (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "xqc"
13+ ]
14+ },
15+ "requestDelay": {
16+ "title": "Délai entre requêtes (s)",
17+ "type": "integer",
18+ "default": 1,
19+ "minimum": 0,
20+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
21+ },
22+ "concurrency": {
23+ "title": "Requêtes simultanées",
24+ "type": "integer",
25+ "default": 3,
26+ "minimum": 1,
27+ "maximum": 10,
28+ "description": "Nombre de requêtes menées en parallèle."
29+ },
30+ "proxyConfiguration": {
31+ "title": "Proxy",
32+ "type": "object",
33+ "editor": "proxy",
34+ "description": "Configuration du proxy Apify.",
35+ "prefill": {
36+ "useApifyProxy": true
37+ }
38+ }
39+ }
40+}
\ No newline at end of file
added actors/ka-twitch/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-twitch)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-twitch/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-twitch)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-twitch/src/main.py +158 −0
@@ -0,0 +1,158 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-twitch)
4+# Desc: Profils Twitch ULTRA DÉTAILLÉS via le GQL PUBLIC du web (Client-ID
5+# public kimne78kx3ncx6brgo4mv6wki5h1ko, celui du site twitch.tv) :
6+# abonnés, partenaire/affilié, live en cours (titre + MINIATURE),
7+# dernière diffusion, jeux récents, vidéos récentes avec MINIATURES et
8+# vues + LIENS SOCIAUX auto-déclarés du panneau « À propos ».
9+# Pas de clé requise.
10+# ==============================================================================
11+from __future__ import annotations
12+
13+import asyncio
14+import json
15+
16+from apify import Actor
17+
18+from .net import Fetcher, UA
19+
20+GQL_URL = "https://gql.twitch.tv/gql"
21+CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
22+
23+QUERY = """
24+query($login: String!) {
25+ user(login: $login) {
26+ id
27+ login
28+ displayName
29+ description
30+ createdAt
31+ profileImageURL(width: 300)
32+ bannerImageURL
33+ followers { totalCount }
34+ roles { isPartner isAffiliate }
35+ primaryTeam { displayName }
36+ channel { socialMedias { name title url } }
37+ lastBroadcast { startedAt title game { displayName } }
38+ stream { viewersCount createdAt title
39+ previewImageURL(width: 640, height: 360)
40+ game { displayName } }
41+ videos(first: 10, sort: TIME) {
42+ edges { node {
43+ id title viewCount lengthSeconds publishedAt
44+ previewThumbnailURL(width: 320, height: 180)
45+ game { displayName }
46+ } }
47+ }
48+ }
49+}
50+"""
51+
52+
53+async def main() -> None:
54+ async with Actor:
55+ inp = await Actor.get_input() or {}
56+ usernames = [u.strip().lstrip("@").lower()
57+ for u in (inp.get("usernames") or []) if u and u.strip()]
58+ proxy = await Actor.create_proxy_configuration(
59+ actor_proxy_input=inp.get("proxyConfiguration"))
60+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
61+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 4))
62+
63+ import random
64+ from curl_cffi import requests as cffi
65+
66+ async def gql(login: str) -> dict | None:
67+ body = json.dumps({"query": QUERY,
68+ "variables": {"login": login}})
69+ p = None
70+ if fetcher.proxy_configuration:
71+ p = await fetcher.proxy_configuration.new_url(
72+ session_id=f"tw{random.randint(1, 999_999)}")
73+ await fetcher._throttle()
74+ resp = await asyncio.to_thread(
75+ cffi.post, GQL_URL, data=body,
76+ headers={"Client-ID": CLIENT_ID, "User-Agent": UA,
77+ "Content-Type": "application/json"},
78+ impersonate="chrome", timeout=45,
79+ proxies={"http": p, "https": p} if p else None)
80+ if resp.status_code != 200:
81+ raise RuntimeError(f"gql http_{resp.status_code}")
82+ return (resp.json().get("data") or {}).get("user")
83+
84+ async def one(u: str) -> None:
85+ async with sem:
86+ miss = {"kind": "profile", "platform": "twitch",
87+ "username": u, "found": False}
88+ try:
89+ user = await gql(u)
90+ except Exception as exc:
91+ await Actor.push_data({**miss, "error": str(exc)[:200]})
92+ return
93+ if not user:
94+ await Actor.push_data({**miss, "error": "not_found"})
95+ return
96+ vids = []
97+ for e in ((user.get("videos") or {}).get("edges") or []):
98+ n = e.get("node") or {}
99+ vids.append({
100+ "id": n.get("id"),
101+ "url": f"https://www.twitch.tv/videos/{n.get('id')}",
102+ "title": (n.get("title") or "")[:200],
103+ "views": n.get("viewCount"),
104+ "duration_s": n.get("lengthSeconds"),
105+ "published_at": n.get("publishedAt"),
106+ "thumbnail": n.get("previewThumbnailURL"),
107+ "game": (n.get("game") or {}).get("displayName"),
108+ })
109+ views = [v["views"] for v in vids
110+ if isinstance(v["views"], int)]
111+ games = [v["game"] for v in vids if v["game"]]
112+ stream = user.get("stream") or {}
113+ last = user.get("lastBroadcast") or {}
114+ roles = user.get("roles") or {}
115+ await Actor.push_data({
116+ "kind": "profile",
117+ "platform": "twitch",
118+ "found": True,
119+ "id": user.get("id"),
120+ "username": user.get("login"),
121+ "full_name": user.get("displayName"),
122+ "biography": user.get("description"),
123+ "followers": ((user.get("followers") or {})
124+ .get("totalCount")),
125+ "is_partner": roles.get("isPartner"),
126+ "is_affiliate": roles.get("isAffiliate"),
127+ "is_verified": bool(roles.get("isPartner")),
128+ "team": ((user.get("primaryTeam") or {})
129+ .get("displayName")),
130+ "created_at": user.get("createdAt"),
131+ "avatar": user.get("profileImageURL"),
132+ "banner": user.get("bannerImageURL"),
133+ "is_live_now": bool(stream),
134+ "live_viewers": stream.get("viewersCount"),
135+ "live_title": (stream.get("title") or "")[:200] or None,
136+ "live_thumbnail": stream.get("previewImageURL"),
137+ "live_game": (stream.get("game") or {}).get("displayName"),
138+ "last_broadcast_at": last.get("startedAt"),
139+ "last_broadcast_title": (last.get("title") or "")[:200]
140+ or None,
141+ "last_broadcast_game": ((last.get("game") or {})
142+ .get("displayName")),
143+ "avg_video_views": (round(sum(views) / len(views))
144+ if views else None),
145+ "recent_games": list(dict.fromkeys(games))[:5],
146+ "recent_videos": vids,
147+ # liens sociaux AUTO-DÉCLARÉS (panneau « À propos ») →
148+ # cross_link fort côté crea-ka (§12.1)
149+ "social_links": [
150+ {"name": s.get("name"), "title": s.get("title"),
151+ "url": s.get("url")}
152+ for s in (((user.get("channel") or {})
153+ .get("socialMedias")) or [])
154+ if s.get("url")][:10],
155+ })
156+
157+ await asyncio.gather(*[one(u) for u in usernames])
158+ Actor.log.info(f"terminé : {len(usernames)} profils")
added actors/ka-twitch/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-x/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-x)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-x/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-x",
4+ "title": "KA X (Twitter) — profils + tweets récents",
5+ "description": "Profils X (Twitter) détaillés SANS clé API via le service officiel de syndication des widgets : abonnés, bio, badge, avatar + tweets récents avec métriques (favoris, RT, réponses) et engagement moyen.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
added actors/ka-x/.actor/input_schema.json +48 −0
@@ -0,0 +1,48 @@
1+{
2+ "title": "KA X input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles X/Twitter (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "x"
13+ ]
14+ },
15+ "maxTweets": {
16+ "title": "Tweets récents conservés par profil",
17+ "type": "integer",
18+ "default": 20,
19+ "minimum": 0,
20+ "maximum": 100,
21+ "description": "Nombre maximal de tweets récents conservés par profil."
22+ },
23+ "requestDelay": {
24+ "title": "Délai entre requêtes (s)",
25+ "type": "integer",
26+ "default": 1,
27+ "minimum": 0,
28+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
29+ },
30+ "concurrency": {
31+ "title": "Requêtes simultanées",
32+ "type": "integer",
33+ "default": 4,
34+ "minimum": 1,
35+ "maximum": 10,
36+ "description": "Nombre de requêtes menées en parallèle."
37+ },
38+ "proxyConfiguration": {
39+ "title": "Proxy",
40+ "type": "object",
41+ "editor": "proxy",
42+ "description": "Datacenter suffit généralement (service de widgets ouvert).",
43+ "prefill": {
44+ "useApifyProxy": true
45+ }
46+ }
47+ }
48+}
\ No newline at end of file
added actors/ka-x/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-x)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-x/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-x)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-x/src/main.py +217 −0
@@ -0,0 +1,217 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-x)
4+# Desc: Profils X (Twitter) SANS clé via le service OFFICIEL de syndication
5+# des widgets (syndication.twitter.com/srv/timeline-profile) : objet
6+# user complet + timeline récente avec métriques par tweet. Conçu pour
7+# être appelé sans authentification ; l'empreinte TLS Chrome de
8+# curl_cffi évite les 429 réservés aux clients python-requests.
9+# ==============================================================================
10+from __future__ import annotations
11+
12+import asyncio
13+import json
14+import re
15+import time
16+
17+from apify import Actor
18+
19+from .net import Fetcher
20+
21+SYNDICATION_URL = ("https://syndication.twitter.com/srv/timeline-profile/"
22+ "screen-name/{u}")
23+_NEXT_DATA_RE = re.compile(
24+ r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
25+
26+
27+def _find_user(node, handle: str):
28+ """Parcourt le JSON Next.js : l'objet user dont screen_name == handle."""
29+ if isinstance(node, dict):
30+ if str(node.get("screen_name", "")).lower() == handle and \
31+ "followers_count" in node:
32+ return node
33+ for v in node.values():
34+ hit = _find_user(v, handle)
35+ if hit is not None:
36+ return hit
37+ elif isinstance(node, list):
38+ for v in node:
39+ hit = _find_user(v, handle)
40+ if hit is not None:
41+ return hit
42+ return None
43+
44+
45+def parse_tweets(data: dict, handle: str, cap: int) -> list[dict]:
46+ entries = (((data.get("props") or {}).get("pageProps") or {})
47+ .get("timeline") or {}).get("entries") or []
48+ tweets: list[dict] = []
49+ for entry in entries:
50+ t = (entry.get("content") or {}).get("tweet") or {}
51+ if not t.get("id_str"):
52+ continue
53+ u = (t.get("user") or {})
54+ ent = t.get("entities") or {}
55+ media = (t.get("extended_entities") or ent).get("media") or []
56+ tweets.append({
57+ "id": t.get("id_str"),
58+ "url": f"https://x.com/{u.get('screen_name', handle)}"
59+ f"/status/{t.get('id_str')}",
60+ "text": (t.get("full_text") or t.get("text") or "")[:500],
61+ "likes": t.get("favorite_count"),
62+ "retweets": t.get("retweet_count"),
63+ "replies": t.get("reply_count"),
64+ "quotes": t.get("quote_count"),
65+ "created_at": t.get("created_at"),
66+ "is_retweet": bool(t.get("retweeted_status")),
67+ "is_reply": bool(t.get("in_reply_to_status_id_str")),
68+ "is_quote": bool(t.get("is_quote_status")),
69+ "lang": t.get("lang"),
70+ "hashtags": [h.get("text") for h in (ent.get("hashtags") or [])
71+ if h.get("text")][:8],
72+ "mentions": [m.get("screen_name")
73+ for m in (ent.get("user_mentions") or [])
74+ if m.get("screen_name")][:8],
75+ "media_type": (media[0].get("type") if media else None),
76+ "media_count": len(media) or None,
77+ "media_urls": [x.get("media_url_https") for x in media
78+ if x.get("media_url_https")][:4],
79+ "links": [x.get("expanded_url") for x in (ent.get("urls") or [])
80+ if x.get("expanded_url")][:4],
81+ })
82+ if len(tweets) >= cap:
83+ break
84+ return tweets
85+
86+
87+def _ts(created_at: str) -> float | None:
88+ """« Tue Sep 12 23:56:24 +0000 2023 » → epoch (None si illisible)."""
89+ try:
90+ from datetime import datetime
91+ return datetime.strptime(created_at,
92+ "%a %b %d %H:%M:%S %z %Y").timestamp()
93+ except (TypeError, ValueError):
94+ return None
95+
96+
97+async def main() -> None:
98+ async with Actor:
99+ inp = await Actor.get_input() or {}
100+ usernames = [u.strip().lstrip("@").lower()
101+ for u in (inp.get("usernames") or []) if u and u.strip()]
102+ max_tweets = int(inp.get("maxTweets", 20))
103+ proxy = await Actor.create_proxy_configuration(
104+ actor_proxy_input=inp.get("proxyConfiguration"))
105+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
106+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 4))
107+
108+ async def one(u: str) -> None:
109+ async with sem:
110+ miss = {"kind": "profile", "platform": "x",
111+ "username": u, "found": False}
112+ try:
113+ resp = await fetcher.get(SYNDICATION_URL.format(u=u),
114+ session_id=u, impersonate=None)
115+ if resp.status_code == 429:
116+ resp = await fetcher.get(SYNDICATION_URL.format(u=u),
117+ session_id=f"{u}b",
118+ impersonate="chrome")
119+ except Exception as exc:
120+ await Actor.push_data({**miss, "error": str(exc)[:200]})
121+ return
122+ m = _NEXT_DATA_RE.search(resp.text or "")
123+ if resp.status_code != 200 or not m:
124+ await Actor.push_data(
125+ {**miss, "error": f"http_{resp.status_code}"})
126+ return
127+ try:
128+ data = json.loads(m.group(1))
129+ except Exception:
130+ await Actor.push_data({**miss, "error": "bad_json"})
131+ return
132+ user = _find_user(data, u)
133+ if not user:
134+ await Actor.push_data({**miss, "error": "user_not_found"})
135+ return
136+ tweets = parse_tweets(data, u, max_tweets)
137+ own = [t for t in tweets if not t["is_retweet"]]
138+ likes = [t["likes"] for t in own if isinstance(t["likes"], int)]
139+ rts = [t["retweets"] for t in own
140+ if isinstance(t["retweets"], int)]
141+ reps = [t["replies"] for t in own
142+ if isinstance(t["replies"], int)]
143+ followers = user.get("followers_count")
144+ avg_likes = round(sum(likes) / len(likes)) if likes else None
145+ avg_rts = round(sum(rts) / len(rts)) if rts else None
146+ engagement = None
147+ if followers and avg_likes is not None:
148+ engagement = round((avg_likes + (avg_rts or 0))
149+ / followers * 100, 3)
150+ stamps = sorted(s for s in (_ts(t["created_at"])
151+ for t in own) if s)
152+ tweets_per_week = None
153+ if len(stamps) >= 3 and stamps[-1] > stamps[0]:
154+ tweets_per_week = round(
155+ (len(stamps) - 1) / ((stamps[-1] - stamps[0])
156+ / 604_800), 2)
157+ top = max((t for t in own if isinstance(t["likes"], int)),
158+ key=lambda t: t["likes"], default=None)
159+ hashtags: dict[str, int] = {}
160+ for t in own:
161+ for h in t["hashtags"]:
162+ hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1
163+ created_ts = _ts(user.get("created_at"))
164+ await Actor.push_data({
165+ "kind": "profile",
166+ "platform": "x",
167+ "found": True,
168+ "id": user.get("id_str"),
169+ "username": user.get("screen_name"),
170+ "full_name": user.get("name"),
171+ "biography": user.get("description"),
172+ "location": user.get("location"),
173+ "website": ((user.get("entities") or {}).get("url") or {})
174+ .get("urls", [{}])[0].get("expanded_url")
175+ if (user.get("entities") or {}).get("url") else None,
176+ "followers": followers,
177+ "following": user.get("friends_count"),
178+ "tweets_count": user.get("statuses_count"),
179+ "listed_count": user.get("listed_count"),
180+ "likes_given": user.get("favourites_count"),
181+ "is_verified": bool(user.get("verified")
182+ or user.get("is_blue_verified")),
183+ "is_blue_verified": user.get("is_blue_verified"),
184+ "is_protected": user.get("protected"),
185+ "created_at": user.get("created_at"),
186+ "account_age_days": (round((time.time() - created_ts)
187+ / 86_400)
188+ if created_ts else None),
189+ "avatar": (user.get("profile_image_url_https") or "")
190+ .replace("_normal.", "_400x400."),
191+ "banner": user.get("profile_banner_url"),
192+ # agrégats sur les tweets récents (hors retweets)
193+ "avg_likes": avg_likes,
194+ "avg_retweets": avg_rts,
195+ "avg_replies": (round(sum(reps) / len(reps))
196+ if reps else None),
197+ "engagement_rate_pct": engagement,
198+ "tweets_per_week": tweets_per_week,
199+ "last_tweet_at": own[0]["created_at"] if own else None,
200+ "reply_share_pct": (round(sum(t["is_reply"]
201+ for t in own)
202+ / len(own) * 100)
203+ if own else None),
204+ "media_share_pct": (round(sum(bool(t["media_count"])
205+ for t in own)
206+ / len(own) * 100)
207+ if own else None),
208+ "top_hashtags": sorted(hashtags, key=hashtags.get,
209+ reverse=True)[:8],
210+ "top_tweet": ({"url": top["url"], "likes": top["likes"],
211+ "retweets": top["retweets"]}
212+ if top else None),
213+ "recent_tweets": tweets,
214+ })
215+
216+ await asyncio.gather(*[one(u) for u in usernames])
217+ Actor.log.info(f"terminé : {len(usernames)} profils")
added actors/ka-x/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
added actors/ka-youtube/.actor/Dockerfile +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: Dockerfile (ka-youtube)
4+# Desc: Acteur Apify KA — Dockerfile
5+# ==============================================================================
6+FROM apify/actor-python:3.12
7+COPY requirements.txt ./
8+RUN pip install --no-cache-dir -r requirements.txt
9+COPY . ./
10+CMD ["python3", "-m", "src"]
added actors/ka-youtube/.actor/actor.json +10 −0
@@ -0,0 +1,10 @@
1+{
2+ "actorSpecification": 1,
3+ "name": "ka-youtube",
4+ "title": "KA YouTube — chaînes détaillées",
5+ "description": "Chaînes YouTube publiques détaillées SANS clé (abonnés, vidéos, description, pays, avatar + vidéos récentes avec vues) via ytInitialData.",
6+ "version": "0.1",
7+ "buildTag": "latest",
8+ "input": "./input_schema.json",
9+ "dockerfile": "./Dockerfile"
10+}
\ No newline at end of file
added actors/ka-youtube/.actor/input_schema.json +46 −0
@@ -0,0 +1,46 @@
1+{
2+ "title": "ka-youtube input",
3+ "type": "object",
4+ "schemaVersion": 1,
5+ "properties": {
6+ "usernames": {
7+ "title": "Handles à enrichir",
8+ "type": "array",
9+ "description": "Handles/slugs à traiter (sans @).",
10+ "editor": "stringList",
11+ "prefill": [
12+ "@mrbeast"
13+ ]
14+ },
15+ "fetchAbout": {
16+ "title": "Récupérer la page À propos",
17+ "type": "boolean",
18+ "default": true,
19+ "description": "2e requête /about par chaîne : vues totales, date de création, pays, liens externes auto-déclarés."
20+ },
21+ "requestDelay": {
22+ "title": "Délai entre requêtes (s)",
23+ "type": "integer",
24+ "default": 1,
25+ "minimum": 0,
26+ "description": "Pause minimale entre deux requêtes sortantes, en secondes."
27+ },
28+ "concurrency": {
29+ "title": "Requêtes simultanées",
30+ "type": "integer",
31+ "default": 3,
32+ "minimum": 1,
33+ "maximum": 10,
34+ "description": "Nombre de requêtes menées en parallèle."
35+ },
36+ "proxyConfiguration": {
37+ "title": "Proxy",
38+ "type": "object",
39+ "editor": "proxy",
40+ "description": "Configuration du proxy Apify.",
41+ "prefill": {
42+ "useApifyProxy": true
43+ }
44+ }
45+ }
46+}
\ No newline at end of file
added actors/ka-youtube/requirements.txt +7 −0
@@ -0,0 +1,7 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: requirements.txt (ka-youtube)
4+# Desc: Acteur Apify KA — requirements.txt
5+# ==============================================================================
6+apify
7+curl_cffi>=0.9
added actors/ka-youtube/src/__main__.py +10 −0
@@ -0,0 +1,10 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/__main__.py (ka-youtube)
4+# Desc: Point d'entrée de l'acteur (asyncio.run).
5+# ==============================================================================
6+import asyncio
7+
8+from .main import main
9+
10+asyncio.run(main())
added actors/ka-youtube/src/main.py +303 −0
@@ -0,0 +1,303 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/main.py (ka-youtube)
4+# Desc: Chaînes YouTube ULTRA DÉTAILLÉES SANS clé API via le JSON ytInitialData
5+# de la page /videos : abonnés, nb de vidéos, description, avatar,
6+# BANNIÈRE, badge vérifié + vidéos récentes avec MINIATURES (vues, durée,
7+# date relative) et agrégats — plus un 2e passage /about (vues totales de
8+# la chaîne, date de création, pays, LIENS EXTERNES auto-déclarés).
9+# Accepte @handle, handle nu, ou id de chaîne (UC…).
10+# ==============================================================================
11+from __future__ import annotations
12+
13+import asyncio
14+import json
15+import re
16+import urllib.parse
17+
18+from apify import Actor
19+
20+from .net import Fetcher
21+
22+_INITIAL_RE = re.compile(r"var ytInitialData\s*=\s*(\{.*?\});</script>", re.S)
23+_COUNT_RE = re.compile(r"([\d][\d\s  .,]*)\s*([KkMB]?)")
24+
25+
26+def _url_for(handle: str) -> str:
27+ h = urllib.parse.unquote(handle).strip()
28+ if h.startswith("UC") and len(h) == 24:
29+ return f"https://www.youtube.com/channel/{h}/videos"
30+ if not h.startswith("@"):
31+ h = "@" + h
32+ return f"https://www.youtube.com/{urllib.parse.quote(h)}/videos"
33+
34+
35+def parse_text_count(text: str) -> int | None:
36+ """« 1.23M subscribers », « 12 345 vues », « 4,5 k » → entier."""
37+ if not text:
38+ return None
39+ m = _COUNT_RE.search(str(text))
40+ if not m:
41+ return None
42+ num = re.sub(r"[\s  ]", "", m.group(1))
43+ unit = (m.group(2) or "").upper()
44+ if unit:
45+ num = num.replace(",", ".")
46+ if num.count(".") > 1:
47+ num = num.replace(".", "", num.count(".") - 1)
48+ try:
49+ val = float(num)
50+ except ValueError:
51+ return None
52+ return int(val * {"K": 1e3, "M": 1e6, "B": 1e9}[unit])
53+ num = re.sub(r"[.,]", "", num)
54+ return int(num) if num.isdigit() else None
55+
56+
57+def _walk(node, key: str):
58+ """Premier objet portant `key` dans l'arbre ytInitialData."""
59+ if isinstance(node, dict):
60+ if key in node:
61+ return node[key]
62+ for v in node.values():
63+ hit = _walk(v, key)
64+ if hit is not None:
65+ return hit
66+ elif isinstance(node, list):
67+ for v in node:
68+ hit = _walk(v, key)
69+ if hit is not None:
70+ return hit
71+ return None
72+
73+
74+def _texts(node) -> list[str]:
75+ """Toutes les chaînes `content`/`simpleText`/`text` sous un nœud."""
76+ out: list[str] = []
77+
78+ def rec(n):
79+ if isinstance(n, dict):
80+ for k in ("content", "simpleText", "text"):
81+ if isinstance(n.get(k), str):
82+ out.append(n[k])
83+ for v in n.values():
84+ rec(v)
85+ elif isinstance(n, list):
86+ for v in n:
87+ rec(v)
88+ rec(node)
89+ return out
90+
91+
92+def _lockup_video(lv: dict) -> dict | None:
93+ """Carte vidéo moderne (lockupViewModel) → dict vidéo standardisé."""
94+ if lv.get("contentType") != "LOCKUP_CONTENT_TYPE_VIDEO":
95+ return None
96+ vid = lv.get("contentId")
97+ if not vid:
98+ return None
99+ md = lv.get("metadata") or {}
100+ title = ((md.get("lockupMetadataViewModel") or {}).get("title") or {}) \
101+ .get("content")
102+ strings = _texts(md)
103+ views = next((parse_text_count(s) for s in strings
104+ if "view" in s.lower() or "vue" in s.lower()), None)
105+ published = next((s for s in strings
106+ if "ago" in s.lower() or "il y a" in s.lower()), None)
107+ overlays = _texts(lv.get("contentImage") or {})
108+ duration = next((s for s in overlays
109+ if re.fullmatch(r"\d?\d:\d\d(:\d\d)?", s.strip())), None)
110+ return {
111+ "id": vid,
112+ "url": f"https://www.youtube.com/watch?v={vid}",
113+ "title": (title or "")[:200],
114+ "views": views,
115+ "published": published,
116+ "duration": duration,
117+ "thumbnail": f"https://i.ytimg.com/vi/{vid}/hqdefault.jpg",
118+ }
119+
120+
121+def _collect_videos(data: dict, cap: int = 15) -> list[dict]:
122+ out: list[dict] = []
123+
124+ def rec(node):
125+ if len(out) >= cap:
126+ return
127+ if isinstance(node, dict):
128+ lv = node.get("lockupViewModel")
129+ if isinstance(lv, dict):
130+ v = _lockup_video(lv)
131+ if v:
132+ out.append(v)
133+ return
134+ vr = node.get("videoRenderer") or node.get("gridVideoRenderer")
135+ if vr and vr.get("videoId"):
136+ out.append({
137+ "id": vr["videoId"],
138+ "url": f"https://www.youtube.com/watch?v={vr['videoId']}",
139+ "title": "".join(
140+ r.get("text", "") for r in
141+ (vr.get("title") or {}).get("runs") or [])[:200],
142+ "views": parse_text_count(
143+ (vr.get("viewCountText") or {}).get("simpleText")),
144+ "published": ((vr.get("publishedTimeText") or {})
145+ .get("simpleText")),
146+ "duration": ((vr.get("lengthText") or {})
147+ .get("simpleText")),
148+ "thumbnail": (f"https://i.ytimg.com/vi/{vr['videoId']}"
149+ f"/hqdefault.jpg"),
150+ })
151+ return
152+ for v in node.values():
153+ rec(v)
154+ elif isinstance(node, list):
155+ for v in node:
156+ rec(v)
157+
158+ rec(data)
159+ return out
160+
161+
162+def _s(v):
163+ """ViewModel : champ tantôt chaîne nue, tantôt {content: "..."}."""
164+ return v.get("content") if isinstance(v, dict) else v
165+
166+
167+def _banner_of(data: dict) -> str | None:
168+ """Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien."""
169+ b = _walk(data, "imageBannerViewModel")
170+ if isinstance(b, dict):
171+ srcs = (b.get("image") or {}).get("sources") or []
172+ if srcs and srcs[-1].get("url"):
173+ return srcs[-1]["url"]
174+ thumbs = (((_walk(data, "c4TabbedHeaderRenderer") or {}).get("banner")
175+ or {}).get("thumbnails")) or []
176+ return thumbs[-1].get("url") if thumbs else None
177+
178+
179+def _about_of(data: dict) -> dict:
180+ """Panneau « À propos » (/about) → vues totales, date, pays, liens."""
181+ acv = _walk(data, "aboutChannelViewModel") or {}
182+ if not acv:
183+ return {}
184+ links = []
185+ for entry in (acv.get("links") or []):
186+ lv = entry.get("channelExternalLinkViewModel") or {}
187+ url = _s(lv.get("link"))
188+ if url:
189+ links.append({"title": _s(lv.get("title")), "url": url})
190+ return {
191+ "total_views": parse_text_count(_s(acv.get("viewCountText"))),
192+ "joined_date": _s(acv.get("joinedDateText")),
193+ "country": _s(acv.get("country")),
194+ "external_links": links[:10],
195+ }
196+
197+
198+async def main() -> None:
199+ async with Actor:
200+ inp = await Actor.get_input() or {}
201+ fetch_about = bool(inp.get("fetchAbout", True))
202+ usernames = [u.strip() for u in (inp.get("usernames") or [])
203+ if u and u.strip()]
204+ proxy = await Actor.create_proxy_configuration(
205+ actor_proxy_input=inp.get("proxyConfiguration"))
206+ fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))
207+ sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))
208+
209+ async def one(u: str) -> None:
210+ async with sem:
211+ miss = {"kind": "profile", "platform": "youtube",
212+ "username": u, "found": False}
213+ try:
214+ resp = await fetcher.get(
215+ _url_for(u), session_id=u,
216+ headers={"Accept-Language": "en-US,en;q=0.9"})
217+ except Exception as exc:
218+ await Actor.push_data({**miss, "error": str(exc)[:200]})
219+ return
220+ m = _INITIAL_RE.search(resp.text or "")
221+ if resp.status_code != 200 or not m:
222+ await Actor.push_data(
223+ {**miss, "error": f"http_{resp.status_code}"})
224+ return
225+ try:
226+ data = json.loads(m.group(1))
227+ except Exception:
228+ await Actor.push_data({**miss, "error": "bad_json"})
229+ return
230+ meta = _walk(data, "channelMetadataRenderer") or {}
231+ if not meta.get("externalId"):
232+ await Actor.push_data({**miss, "error": "no_channel"})
233+ return
234+ # compteurs : le nouvel en-tête n'a plus subscriberCountText ;
235+ # on lit les chaînes « N subscribers » / « N videos » du header
236+ header = _walk(data, "pageHeaderViewModel") or {}
237+ header_txt = _texts(header) or _texts(
238+ _walk(data, "c4TabbedHeaderRenderer") or {})
239+ sub_text = next((t for t in header_txt
240+ if "subscriber" in t.lower()
241+ or "abonné" in t.lower()), "")
242+ vid_text = next((t for t in header_txt
243+ if ("video" in t.lower()
244+ or "vidéo" in t.lower())
245+ and any(c.isdigit() for c in t)), "")
246+ if not sub_text: # ancien en-tête
247+ sub_text = ((_walk(data, "subscriberCountText") or {})
248+ .get("simpleText") or "")
249+ subs = parse_text_count(sub_text)
250+ videos_count = parse_text_count(vid_text)
251+ vids = _collect_videos(data)
252+ views = [v["views"] for v in vids
253+ if isinstance(v["views"], int)]
254+ text = resp.text or ""
255+ is_verified = True if (
256+ '"BADGE_STYLE_TYPE_VERIFIED"' in text
257+ or '"BADGE_STYLE_TYPE_VERIFIED_ARTIST"' in text
258+ or '"OFFICIAL_ARTIST_BADGE"' in text) else None
259+ about: dict = {}
260+ if fetch_about:
261+ try:
262+ resp_a = await fetcher.get(
263+ _url_for(u).replace("/videos", "/about"),
264+ session_id=f"{u}a",
265+ headers={"Accept-Language": "en-US,en;q=0.9"})
266+ ma = _INITIAL_RE.search(resp_a.text or "")
267+ if ma:
268+ about = _about_of(json.loads(ma.group(1)))
269+ except Exception as exc:
270+ Actor.log.warning(f"/about {u} : {exc}")
271+ await Actor.push_data({
272+ "kind": "profile",
273+ "platform": "youtube",
274+ "found": True,
275+ "username": u,
276+ "channel_id": meta.get("externalId"),
277+ "full_name": meta.get("title"),
278+ "biography": (meta.get("description") or "")[:1000],
279+ "followers": subs,
280+ "videos_count": videos_count,
281+ "is_family_safe": meta.get("isFamilySafe"),
282+ "is_verified": is_verified,
283+ "keywords": (meta.get("keywords") or "")[:300] or None,
284+ "country": about.get("country") or _walk(data, "country"),
285+ "canonical_url": meta.get("vanityChannelUrl"),
286+ "avatar": (((meta.get("avatar") or {}).get("thumbnails")
287+ or [{}])[-1].get("url")),
288+ "banner": _banner_of(data),
289+ "total_views": about.get("total_views"),
290+ "joined_date": about.get("joined_date"),
291+ "external_links": about.get("external_links") or [],
292+ "avg_views": (round(sum(views) / len(views))
293+ if views else None),
294+ "top_video": (max(
295+ (v for v in vids if isinstance(v["views"], int)),
296+ key=lambda v: v["views"], default=None)),
297+ "last_video_published": (vids[0]["published"]
298+ if vids else None),
299+ "recent_videos": vids,
300+ })
301+
302+ await asyncio.gather(*[one(u) for u in usernames])
303+ Actor.log.info(f"terminé : {len(usernames)} chaînes")
added actors/ka-youtube/src/net.py +69 −0
@@ -0,0 +1,69 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: src/net.py
4+# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) +
5+# proxy Apify (résidentiel par défaut), throttling poli + retries
6+# ==============================================================================
7+from __future__ import annotations
8+
9+import asyncio
10+import random
11+import re
12+
13+from apify import Actor
14+from curl_cffi import requests as cffi
15+
16+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
17+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
18+
19+
20+class Fetcher:
21+ """GET poli avec proxy Apify + impersonation Chrome + retries 403/429."""
22+
23+ def __init__(self, proxy_configuration, delay: float = 1.0) -> None:
24+ self.proxy_configuration = proxy_configuration
25+ self.delay = delay
26+ self._lock = asyncio.Lock()
27+ self._last = 0.0
28+
29+ async def _throttle(self) -> None:
30+ async with self._lock:
31+ now = asyncio.get_event_loop().time()
32+ wait = self.delay - (now - self._last)
33+ if wait > 0:
34+ await asyncio.sleep(wait)
35+ self._last = asyncio.get_event_loop().time()
36+
37+ async def get(self, url: str, headers: dict | None = None,
38+ retries: int = 3, session_id: str | None = None,
39+ impersonate: str | None = "chrome"):
40+ hdrs = {"User-Agent": UA,
41+ "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"}
42+ if headers:
43+ hdrs.update(headers)
44+ last_exc: Exception | None = None
45+ for attempt in range(retries):
46+ await self._throttle()
47+ proxy = None
48+ if self.proxy_configuration:
49+ sid = session_id or f"s{random.randint(1, 999_999)}"
50+ sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s"
51+ proxy = await self.proxy_configuration.new_url(
52+ session_id=f"{sid}r{attempt}")
53+ try:
54+ resp = await asyncio.to_thread(
55+ cffi.get, url, headers=hdrs, impersonate=impersonate,
56+ timeout=60, allow_redirects=True,
57+ proxies={"http": proxy, "https": proxy} if proxy else None)
58+ if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1:
59+ Actor.log.warning(
60+ f"HTTP {resp.status_code} {url} — retry {attempt + 1}")
61+ await asyncio.sleep(1.5 * (attempt + 1))
62+ continue
63+ return resp
64+ except Exception as exc: # réseau/proxy : on retente
65+ last_exc = exc
66+ await asyncio.sleep(1.5 * (attempt + 1))
67+ if last_exc:
68+ raise last_exc
69+ raise RuntimeError(f"échec après {retries} tentatives : {url}")
modified creaka/connectors/apify_social.py +53 −3
@@ -246,10 +246,22 @@ class _ApifySocialEnrich(BaseConnector):
246 if it.get(src) is not None} 246 if it.get(src) is not None}
247 if self.content_key and it.get(self.content_key): 247 if self.content_key and it.get(self.content_key):
248 metrics[self.content_key] = it[self.content_key][:12] 248 metrics[self.content_key] = it[self.content_key][:12]
249+ # images du profil : avatar/bannière conservés PAR COMPTE (affichage
250+ # par plateforme + chaîne de repli côté frontend)
251+ for img_key in ("avatar", "banner"):
252+ if it.get(img_key):
253+ metrics[img_key] = it[img_key]
249 metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre 254 metrics[self._stamp_key] = now_iso() # tampon d'enrichissement propre
250 acc.metrics.update(metrics) 255 acc.metrics.update(metrics)
251− if not cr.avatar_url and it.get("avatar"): 256+ # avatar/bannière de la FICHE : rafraîchis à chaque passage depuis la
257+ # plateforme principale (les URLs CDN signées expirent — ex. Instagram) ;
258+ # sinon on remplit seulement les manquants
259+ if it.get("avatar") and (not cr.avatar_url
260+ or acc.platform == cr.primary_platform):
252 cr.avatar_url = it["avatar"] 261 cr.avatar_url = it["avatar"]
262+ if it.get("banner") and (not getattr(cr, "banner_url", None)
263+ or acc.platform == cr.primary_platform):
264+ cr.banner_url = it["banner"]
253 if not cr.bio and it.get("biography"): 265 if not cr.bio and it.get("biography"):
254 cr.bio = it["biography"] 266 cr.bio = it["biography"]
255 self.cross_links(cr, it) 267 self.cross_links(cr, it)
@@ -285,6 +297,7 @@ class ApifyInstagram(_ApifySocialEnrich):
285 "category": "category", "is_business": "is_business", 297 "category": "category", "is_business": "is_business",
286 "business_category": "business_category", 298 "business_category": "business_category",
287 "highlight_reels": "highlight_reels", 299 "highlight_reels": "highlight_reels",
300+ "pronouns": "pronouns", "bio_links": "bio_links",
288 "avg_likes": "avg_likes", "avg_comments": "avg_comments", 301 "avg_likes": "avg_likes", "avg_comments": "avg_comments",
289 "avg_video_views": "avg_video_views", 302 "avg_video_views": "avg_video_views",
290 "engagement_rate_pct": "engagement_rate_pct", 303 "engagement_rate_pct": "engagement_rate_pct",
@@ -319,6 +332,9 @@ class ApifyTikTok(_ApifySocialEnrich):
319 "avg_views": "avg_views", "avg_likes": "avg_likes", 332 "avg_views": "avg_views", "avg_likes": "avg_likes",
320 "avg_comments": "avg_comments", "avg_shares": "avg_shares", 333 "avg_comments": "avg_comments", "avg_shares": "avg_shares",
321 "engagement_rate_pct": "engagement_rate_pct", 334 "engagement_rate_pct": "engagement_rate_pct",
335+ "videos_per_week": "videos_per_week",
336+ "last_video_at": "last_video_at",
337+ "top_hashtags": "top_hashtags", "language": "language",
322 "top_video": "top_video"} 338 "top_video": "top_video"}
323 339
324 def bio_urls(self, it: dict) -> list[str]: 340 def bio_urls(self, it: dict) -> list[str]:
@@ -340,6 +356,9 @@ class ApifyX(_ApifySocialEnrich):
340 "tweets_per_week": "tweets_per_week", 356 "tweets_per_week": "tweets_per_week",
341 "last_tweet_at": "last_tweet_at", 357 "last_tweet_at": "last_tweet_at",
342 "media_share_pct": "media_share_pct", 358 "media_share_pct": "media_share_pct",
359+ "reply_share_pct": "reply_share_pct",
360+ "is_blue_verified": "is_blue_verified",
361+ "likes_given": "likes_given", "website": "website",
343 "top_hashtags": "top_hashtags", "top_tweet": "top_tweet"} 362 "top_hashtags": "top_hashtags", "top_tweet": "top_tweet"}
344 363
345 def extra_input(self) -> dict: 364 def extra_input(self) -> dict:
@@ -380,6 +399,9 @@ class ApifySnapchat(_ApifySocialEnrich):
380 "publisher_type": "publisher_type", 399 "publisher_type": "publisher_type",
381 "story_snaps_count": "story_snaps", 400 "story_snaps_count": "story_snaps",
382 "lenses_count": "lenses", 401 "lenses_count": "lenses",
402+ "story_previews": "story_previews",
403+ "spotlight_previews": "spotlight_previews",
404+ "website": "website",
383 "spotlight_highlights_count": "spotlight_highlights"} 405 "spotlight_highlights_count": "spotlight_highlights"}
384 406
385 def bio_urls(self, it: dict) -> list[str]: 407 def bio_urls(self, it: dict) -> list[str]:
@@ -395,8 +417,15 @@ class ApifyYouTube(_ApifySocialEnrich):
395 metric_map = {"videos_count": "videos", "country": "country", 417 metric_map = {"videos_count": "videos", "country": "country",
396 "keywords": "keywords", "channel_id": "channel_id", 418 "keywords": "keywords", "channel_id": "channel_id",
397 "avg_views": "avg_views", "top_video": "top_video", 419 "avg_views": "avg_views", "top_video": "top_video",
420+ "total_views": "total_views", "joined_date": "joined_date",
421+ "external_links": "external_links",
398 "last_video_published": "last_video_published"} 422 "last_video_published": "last_video_published"}
399 423
424+ def bio_urls(self, it: dict) -> list[str]:
425+ # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1)
426+ return [ln.get("url") or "" for ln in (it.get("external_links") or [])
427+ if isinstance(ln, dict)]
428+
400 429
401 class ApifyTwitch(_ApifySocialEnrich): 430 class ApifyTwitch(_ApifySocialEnrich):
402 source_id = "twitch-apify" 431 source_id = "twitch-apify"
@@ -410,9 +439,18 @@ class ApifyTwitch(_ApifySocialEnrich):
410 "live_game": "live_game", 439 "live_game": "live_game",
411 "last_broadcast_at": "last_broadcast_at", 440 "last_broadcast_at": "last_broadcast_at",
412 "last_broadcast_game": "last_broadcast_game", 441 "last_broadcast_game": "last_broadcast_game",
442+ "last_broadcast_title": "last_broadcast_title",
443+ "live_title": "live_title",
444+ "live_thumbnail": "live_thumbnail",
413 "avg_video_views": "avg_video_views", 445 "avg_video_views": "avg_video_views",
446+ "social_links": "social_links",
414 "recent_games": "recent_games"} 447 "recent_games": "recent_games"}
415 448
449+ def bio_urls(self, it: dict) -> list[str]:
450+ # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1)
451+ return [ln.get("url") or "" for ln in (it.get("social_links") or [])
452+ if isinstance(ln, dict)]
453+
416 454
417 class ApifyKick(_ApifySocialEnrich): 455 class ApifyKick(_ApifySocialEnrich):
418 source_id = "kick-apify" 456 source_id = "kick-apify"
@@ -421,6 +459,10 @@ class ApifyKick(_ApifySocialEnrich):
421 cap = 600 459 cap = 600
422 metric_map = {"is_live_now": "is_live_now", "live_viewers": "live_viewers", 460 metric_map = {"is_live_now": "is_live_now", "live_viewers": "live_viewers",
423 "subscription_enabled": "subscription_enabled", 461 "subscription_enabled": "subscription_enabled",
462+ "live_title": "live_title",
463+ "live_thumbnail": "live_thumbnail",
464+ "live_category": "live_category",
465+ "vod_enabled": "vod_enabled",
424 "recent_categories": "recent_categories"} 466 "recent_categories": "recent_categories"}
425 467
426 def cross_links(self, cr: Creator, it: dict) -> None: 468 def cross_links(self, cr: Creator, it: dict) -> None:
@@ -479,7 +521,13 @@ class ApifyPatreon(_ApifySocialEnrich):
479 cap = 400 521 cap = 400
480 revisit_days = 7 522 revisit_days = 7
481 metric_map = {"patrons": "patrons", "posts_count": "posts", 523 metric_map = {"patrons": "patrons", "posts_count": "posts",
482− "is_monthly": "is_monthly"} 524+ "is_monthly": "is_monthly", "is_nsfw": "is_nsfw",
525+ "creation_name": "creation_name",
526+ "social_links": "social_links"}
527+
528+ def bio_urls(self, it: dict) -> list[str]:
529+ # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link
530+ return list((it.get("social_links") or {}).values())
483 531
484 532
485 class ApifyDiscord(_ApifySocialEnrich): 533 class ApifyDiscord(_ApifySocialEnrich):
@@ -489,7 +537,9 @@ class ApifyDiscord(_ApifySocialEnrich):
489 cap = 400 537 cap = 400
490 revisit_days = 7 538 revisit_days = 7
491 metric_map = {"members": "members", "online": "online", "boosts": "boosts", 539 metric_map = {"members": "members", "online": "online", "boosts": "boosts",
492− "partnered": "partnered", "guild_id": "guild_id"} 540+ "partnered": "partnered", "guild_id": "guild_id",
541+ "channel": "channel", "splash": "splash",
542+ "vanity_url_code": "vanity_url_code"}
493 543
494 def target_of(self, acc) -> str: 544 def target_of(self, acc) -> str:
495 """Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du 545 """Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du
modified creaka/db.py +56 −0
@@ -66,6 +66,16 @@ CREATE TABLE IF NOT EXISTS accounts (
66 CREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id); 66 CREATE INDEX IF NOT EXISTS idx_accounts_creator ON accounts(creator_id);
67 CREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status); 67 CREATE INDEX IF NOT EXISTS idx_creators_status ON creators(status);
68 CREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier); 68 CREATE INDEX IF NOT EXISTS idx_creators_tier ON creators(audience_tier);
69+CREATE TABLE IF NOT EXISTS snapshots (
70+ day TEXT NOT NULL,
71+ platform TEXT NOT NULL,
72+ handle TEXT NOT NULL,
73+ creator_id TEXT NOT NULL,
74+ followers INTEGER,
75+ engagement REAL,
76+ PRIMARY KEY (day, platform, handle)
77+);
78+CREATE INDEX IF NOT EXISTS idx_snapshots_creator ON snapshots(creator_id, day);
69 CREATE TABLE IF NOT EXISTS sync_log ( 79 CREATE TABLE IF NOT EXISTS sync_log (
70 id INTEGER PRIMARY KEY AUTOINCREMENT, 80 id INTEGER PRIMARY KEY AUTOINCREMENT,
71 ts TEXT NOT NULL, 81 ts TEXT NOT NULL,
@@ -180,6 +190,20 @@ def _write_creator(con: sqlite3.Connection, cid: str, cr: Creator,
180 None if acc.verified is None else int(acc.verified), 190 None if acc.verified is None else int(acc.verified),
181 acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked, 191 acc.confidence, acc.signal, int(needs_review(acc)), acc.last_checked,
182 json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None)) 192 json.dumps(acc.metrics, ensure_ascii=False) if acc.metrics else None))
193+ # historisation (§13-14) : un point par jour et par compte — matière
194+ # première des courbes de croissance et des insights (7 j / 30 j)
195+ if acc.followers:
196+ con.execute(
197+ """INSERT INTO snapshots (day, platform, handle, creator_id,
198+ followers, engagement)
199+ VALUES (?,?,?,?,?,?)
200+ ON CONFLICT(day, platform, handle) DO UPDATE SET
201+ creator_id=excluded.creator_id,
202+ followers=excluded.followers,
203+ engagement=COALESCE(excluded.engagement,
204+ snapshots.engagement)""",
205+ (ts[:10], acc.platform, acc.handle, cid, acc.followers,
206+ (acc.metrics or {}).get("engagement_rate_pct")))
183 207
184 208
185 def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None: 209 def _find_existing(con: sqlite3.Connection, cr: Creator) -> str | None:
@@ -373,6 +397,38 @@ def search(con: sqlite3.Connection, *, q: str = "", niche: str = "",
373 return {"total": total, "count": len(items), "offset": offset, "items": items} 397 return {"total": total, "count": len(items), "offset": offset, "items": items}
374 398
375 399
400+def follower_history(con: sqlite3.Connection, cid: str,
401+ days: int = 90) -> dict:
402+ """Historique d'audience d'une fiche (snapshots quotidiens, §13-14).
403+
404+ Retourne les séries par plateforme (jour → abonnés) + la série totale
405+ (somme des plateformes connues ce jour-là), prêtes pour une sparkline.
406+ """
407+ cutoff = (datetime.now(timezone.utc) - timedelta(days=days)) \
408+ .strftime("%Y-%m-%d")
409+ rows = con.execute(
410+ "SELECT day, platform, handle, followers FROM snapshots "
411+ "WHERE creator_id=? AND day>=? ORDER BY day", (cid, cutoff)).fetchall()
412+ by_platform: dict[str, dict[str, int]] = {}
413+ for r in rows:
414+ by_platform.setdefault(r["platform"], {})[r["day"]] = r["followers"]
415+ total: dict[str, int] = {}
416+ for series in by_platform.values():
417+ # report de la dernière valeur connue pour que la somme quotidienne
418+ # ne s'effondre pas quand une plateforme n'a pas de point ce jour-là
419+ last = None
420+ for day in sorted({d for s in by_platform.values() for d in s}):
421+ last = series.get(day, last)
422+ if last is not None:
423+ total[day] = total.get(day, 0) + last
424+ return {
425+ "platforms": {p: [{"day": d, "followers": f}
426+ for d, f in sorted(s.items())]
427+ for p, s in by_platform.items()},
428+ "total": [{"day": d, "followers": f} for d, f in sorted(total.items())],
429+ }
430+
431+
376 def get_creator(con: sqlite3.Connection, cid: str) -> dict | None: 432 def get_creator(con: sqlite3.Connection, cid: str) -> dict | None:
377 row = con.execute( 433 row = con.execute(
378 "SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0", 434 "SELECT * FROM creators WHERE id=? AND status='active' AND is_minor=0",
modified creaka/schema.py +1 −0
@@ -100,6 +100,7 @@ class Creator:
100 platforms: list[PlatformAccount] = field(default_factory=list) 100 platforms: list[PlatformAccount] = field(default_factory=list)
101 link_in_bio_url: str | None = None 101 link_in_bio_url: str | None = None
102 avatar_url: str | None = None # photo de profil PUBLIQUE (URL source) 102 avatar_url: str | None = None # photo de profil PUBLIQUE (URL source)
103+ banner_url: str | None = None # bannière/cover PUBLIQUE (URL source)
103 total_reach: int | None = None 104 total_reach: int | None = None
104 audience_tier: str = "" 105 audience_tier: str = ""
105 business_contact: str | None = None # courriel PRO affiché publiquement (§15) 106 business_contact: str | None = None # courriel PRO affiché publiquement (§15)
modified creaka/stats.py +90 −0
@@ -628,3 +628,93 @@ def dashboard(con: sqlite3.Connection, period: str = "30j",
628 _CACHE.clear() 628 _CACHE.clear()
629 _CACHE[key] = (now, data) 629 _CACHE[key] = (now, data)
630 return data 630 return data
631+
632+
633+# --- insights par créateur (fiche « légendaire ») ------------------------------
634+
635+def creator_insights(con: sqlite3.Connection, doc: dict) -> dict:
636+ """Insights d'une fiche créateur : croissance (snapshots §13-14),
637+ engagement pondéré, rythme de publication et Ka Score composite /100.
638+
639+ AUCUNE stat inventée : tout provient des comptes rattachés (accounts.metrics
640+ remplis par les acteurs Apify) et des snapshots quotidiens. Un champ absent
641+ reste absent — pas d'estimation.
642+ """
643+ import math
644+
645+ from .db import follower_history
646+
647+ platforms = doc.get("platforms") or []
648+ reach = doc.get("total_reach") or sum(
649+ p.get("followers") or 0 for p in platforms) or 0
650+
651+ # croissance : série totale quotidienne (report dernière valeur connue)
652+ hist = follower_history(con, doc["id"], days=95)
653+ total = hist["total"]
654+
655+ def growth(days: int) -> dict | None:
656+ if len(total) < 2:
657+ return None
658+ last = total[-1]
659+ cutoff = (date.today() - timedelta(days=days)).isoformat()
660+ base = next((p for p in total if p["day"] >= cutoff), None)
661+ if base is None or base["day"] == last["day"] or not base["followers"]:
662+ return None
663+ delta = last["followers"] - base["followers"]
664+ return {"since": base["day"], "delta": delta,
665+ "pct": round(100 * delta / base["followers"], 2)}
666+
667+ # engagement moyen pondéré par l'audience de chaque plateforme
668+ weighted = [(p["metrics"].get("engagement_rate_pct"), p.get("followers") or 1)
669+ for p in platforms
670+ if isinstance(p.get("metrics"), dict)
671+ and p["metrics"].get("engagement_rate_pct") is not None]
672+ engagement = (round(sum(e * w for e, w in weighted)
673+ / sum(w for _, w in weighted), 2)
674+ if weighted else None)
675+
676+ # rythme de publication : somme des cadences hebdo déclarées par plateforme
677+ rates = [p["metrics"].get(k) for p in platforms
678+ if isinstance(p.get("metrics"), dict)
679+ for k in ("posts_per_week", "videos_per_week", "tweets_per_week")
680+ if isinstance(p["metrics"].get(k), (int, float))]
681+ pubs_week = round(sum(rates), 1) if rates else None
682+
683+ # dernière activité publique connue, toutes plateformes confondues
684+ last_dates = [str(p["metrics"].get(k)) for p in platforms
685+ if isinstance(p.get("metrics"), dict)
686+ for k in ("last_post_at", "last_video_at", "last_tweet_at",
687+ "last_broadcast_at", "last_video_published")
688+ if p["metrics"].get(k)]
689+ last_activity = max(last_dates) if last_dates else None
690+
691+ is_verified = any(p.get("verified") for p in platforms)
692+ live_now = any(isinstance(p.get("metrics"), dict)
693+ and p["metrics"].get("is_live_now") for p in platforms)
694+
695+ # Ka Score /100 : audience 40 (log), engagement 25, présence 20, rythme 10,
696+ # vérification 5 — comparable d'un créateur à l'autre, jamais inventé :
697+ # une composante inconnue vaut simplement 0.
698+ parts = {
699+ "audience": round(40 * min(1.0, math.log10(max(reach, 1)) / 7), 1),
700+ "engagement": round(25 * min(1.0, (engagement or 0) / 10), 1),
701+ "presence": round(20 * min(1.0, len(platforms) / 5), 1),
702+ "rythme": round(10 * min(1.0, (pubs_week or 0) / 3), 1),
703+ "verification": 5.0 if is_verified else 0.0,
704+ }
705+ top = max(platforms, key=lambda p: p.get("followers") or 0, default=None)
706+ return {
707+ "ka_score": round(sum(parts.values()), 1),
708+ "ka_score_parts": parts,
709+ "total_reach": reach or None,
710+ "growth_7d": growth(7),
711+ "growth_30d": growth(30),
712+ "avg_engagement_pct": engagement,
713+ "publications_per_week": pubs_week,
714+ "last_activity": last_activity,
715+ "platforms_count": len(platforms),
716+ "is_verified_somewhere": is_verified,
717+ "is_live_now": live_now,
718+ "top_platform": (top or {}).get("platform"),
719+ "history": hist,
720+ }
modified creaka/web.py +3 −0
@@ -75,6 +75,9 @@ def get_creator(cid: str):
75 creator = db.get_creator(_db(), cid) 75 creator = db.get_creator(_db(), cid)
76 if creator is None: 76 if creator is None:
77 raise HTTPException(404, "créateur introuvable") 77 raise HTTPException(404, "créateur introuvable")
78+ # insights « légendaires » : croissance (snapshots), engagement pondéré,
79+ # rythme de publication, Ka Score /100 + historique pour la sparkline
80+ creator["insights"] = stats_mod.creator_insights(_db(), creator)
78 return creator 81 return creator
79 82
80 83
modified frontend/dist/index.html +102 −5
@@ -997,6 +997,50 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
997 .linkinbio svg{width:16px;height:16px;fill:currentColor} 997 .linkinbio svg{width:16px;height:16px;fill:currentColor}
998 .cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3); 998 .cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);
999 border-top:1.5px solid var(--ink);padding-top:16px} 999 border-top:1.5px solid var(--ink);padding-top:16px}
1000+/* bannière héro (banner_url capté par les acteurs Apify) */
1001+.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;
1002+ border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);
1003+ background:var(--ink)}
1004+.cbanner img{width:100%;height:100%;object-fit:cover;display:block}
1005+.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}
1006+/* Ka Score */
1007+.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}
1008+.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;
1009+ flex-direction:column;align-items:center;justify-content:center;
1010+ border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);
1011+ box-shadow:var(--shadow-off-soft)}
1012+.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}
1013+.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;
1014+ text-transform:uppercase;letter-spacing:.06em;margin-top:3px}
1015+.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}
1016+.kspart{display:flex;align-items:center;gap:10px}
1017+.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;
1018+ font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}
1019+.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);
1020+ border-radius:5px;overflow:hidden}
1021+.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}
1022+.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);
1023+ font-size:11px;font-weight:700;color:var(--ink)}
1024+@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}
1025+/* sparkline évolution d'audience */
1026+.spark-wrap{margin-top:8px}
1027+.spark-svg{width:100%;height:88px;display:block}
1028+.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}
1029+.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}
1030+.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);
1031+ font-size:10px;color:var(--ink-3);margin-top:4px}
1032+.growth-up{color:var(--green-deep);font-weight:700}
1033+.growth-down{color:#b0201a;font-weight:700}
1034+/* bloc EN DIRECT (miniature live Twitch/Kick) */
1035+.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;
1036+ border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;
1037+ background:var(--surface);text-decoration:none;color:inherit}
1038+.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}
1039+.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}
1040+.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}
1041+.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}
1042+.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}
1043+@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}
1000 .cpage-foot a{color:var(--green);font-weight:600} 1044 .cpage-foot a{color:var(--green);font-weight:600}
1001 1045
1002 /* ===== page stats ===== */ 1046 /* ===== page stats ===== */
@@ -1455,7 +1499,11 @@ const _relTimeC=ts=>_relTime(ts);
1455 window.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add("noimg"); 1499 window.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add("noimg");
1456 w.innerHTML=w.dataset.icon||"";}}; 1500 w.innerHTML=w.dataset.icon||"";}};
1457 function _mediaItems(a){const m=a.metrics||{}; 1501 function _mediaItems(a){const m=a.metrics||{};
1458− return m.recent_posts||m.recent_videos||m.recent_tweets||[];} 1502+ const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];
1503+ if(base.length)return base;
1504+ // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images
1505+ const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];
1506+ return prev.map(u=>({display_url:u,type:"story"}));}
1459 1507
1460 /* grille de contenu récent, images/vidéos bien affichées */ 1508 /* grille de contenu récent, images/vidéos bien affichées */
1461 function contentGrid(a){ 1509 function contentGrid(a){
@@ -1463,7 +1511,7 @@ function contentGrid(a){
1463 if(!items.length)return `<div class="cg-empty">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`; 1511 if(!items.length)return `<div class="cg-empty">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;
1464 const ico=icon(a.platform); 1512 const ico=icon(a.platform);
1465 const cards=items.slice(0,12).map(it=>{ 1513 const cards=items.slice(0,12).map(it=>{
1466− const img=it.display_url||it.thumbnail_url||""; 1514+ const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||"";
1467 const isVid=it.type==="video"||it.type==="reel"||!!it.duration||it.video_views!=null; 1515 const isVid=it.type==="video"||it.type==="reel"||!!it.duration||it.video_views!=null;
1468 const cap=esc((it.caption||it.title||it.text||"").slice(0,140)); 1516 const cap=esc((it.caption||it.title||it.text||"").slice(0,140));
1469 const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at); 1517 const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);
@@ -1497,7 +1545,8 @@ function platformKPIs(a){
1497 push(m.avg_views!=null?m.avg_views:m.avg_video_views,"vues / pub (moy.)"); 1545 push(m.avg_views!=null?m.avg_views:m.avg_video_views,"vues / pub (moy.)");
1498 push(m.avg_comments,"comm. / pub (moy.)"); 1546 push(m.avg_comments,"comm. / pub (moy.)");
1499 push(m.avg_retweets,"RT / pub (moy.)"); 1547 push(m.avg_retweets,"RT / pub (moy.)");
1500− push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,"pubs / semaine"); 1548+ push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),"pubs / semaine");
1549+ push(m.total_views,"vues totales");
1501 push(m.posts,"publications"); 1550 push(m.posts,"publications");
1502 push(m.videos,"vidéos"); 1551 push(m.videos,"vidéos");
1503 push(m.likes,a.platform==="tiktok"?"j'aime cumulés":"j'aime"); 1552 push(m.likes,a.platform==="tiktok"?"j'aime cumulés":"j'aime");
@@ -1521,6 +1570,10 @@ function platformBadges(a){
1521 m.is_monthly?'<span class="abadge">Mensuel</span>':"", 1570 m.is_monthly?'<span class="abadge">Mensuel</span>':"",
1522 m.has_story?'<span class="abadge">Story active</span>':"", 1571 m.has_story?'<span class="abadge">Story active</span>':"",
1523 m.has_spotlight?'<span class="abadge">Spotlight</span>':"", 1572 m.has_spotlight?'<span class="abadge">Spotlight</span>':"",
1573+ m.is_nsfw?'<span class="abadge live">18+</span>':"",
1574+ m.creation_name?`<span class="abadge">${esc(String(m.creation_name).slice(0,60))}</span>`:"",
1575+ m.joined_date?`<span class="abadge">${esc(m.joined_date)}</span>`:"",
1576+ m.is_blue_verified?'<span class="abadge ok">✓ Bleu</span>':"",
1524 m.country?`<span class="abadge">${esc(m.country)}</span>`:"", 1577 m.country?`<span class="abadge">${esc(m.country)}</span>`:"",
1525 (m.recent_games||[]).slice(0,2).map(g=>`<span class="abadge">🎮 ${esc(g)}</span>`).join(""), 1578 (m.recent_games||[]).slice(0,2).map(g=>`<span class="abadge">🎮 ${esc(g)}</span>`).join(""),
1526 (m.top_hashtags||[]).slice(0,3).map(h=>`<span class="abadge">#${esc(h)}</span>`).join(""), 1579 (m.top_hashtags||[]).slice(0,3).map(h=>`<span class="abadge">#${esc(h)}</span>`).join(""),
@@ -1544,6 +1597,13 @@ function platformPanel(a,idx,active){
1544 <span class="conf ${confCls}" title="Score de rattachement">${conf}%</span> 1597 <span class="conf ${confCls}" title="Score de rattachement">${conf}%</span>
1545 </div> 1598 </div>
1546 ${badges?`<div class="abadge-row">${badges}</div>`:""} 1599 ${badges?`<div class="abadge-row">${badges}</div>`:""}
1600+ ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`
1601+ <a class="live-card" href="${esc(a.url)}" target="_blank" rel="noopener">
1602+ <img loading="lazy" src="${esc((a.metrics||{}).live_thumbnail)}" alt="" referrerpolicy="no-referrer" onerror="this.remove()">
1603+ <span class="live-card-body"><span class="live-card-tag">● EN DIRECT</span>
1604+ ${(a.metrics||{}).live_title?`<span class="live-card-title">${esc((a.metrics||{}).live_title)}</span>`:""}
1605+ <span class="live-card-meta">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+" spectateurs":""}${(a.metrics||{}).live_game?" · "+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?" · "+esc((a.metrics||{}).live_category):"")}</span>
1606+ </span></a>`:""}
1547 <div class="kpi-grid">${kpis||'<div class="kpi"><b>—</b><span>pas encore de métriques</span></div>'}</div> 1607 <div class="kpi-grid">${kpis||'<div class="kpi"><b>—</b><span>pas encore de métriques</span></div>'}</div>
1548 ${top&&top.url?`<a class="ppanel-top" href="${esc(top.url)}" target="_blank" rel="noopener">★ Top contenu — ${top.views!=null?fmt.format(top.views)+" vues":""}${top.likes!=null?" · "+fmt.format(top.likes)+" ❤":""} ↗</a>`:""} 1608 ${top&&top.url?`<a class="ppanel-top" href="${esc(top.url)}" target="_blank" rel="noopener">★ Top contenu — ${top.views!=null?fmt.format(top.views)+" vues":""}${top.likes!=null?" · "+fmt.format(top.likes)+" ❤":""} ↗</a>`:""}
1549 <h3 class="cg-title">Contenu récent</h3> 1609 <h3 class="cg-title">Contenu récent</h3>
@@ -1571,6 +1631,21 @@ function computeAgg(c){
1571 nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main}; 1631 nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};
1572 } 1632 }
1573 1633
1634+/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */
1635+function sparkSvg(pts){
1636+ if(!pts||pts.length<2)return"";
1637+ const w=600,h=88,pad=4;
1638+ const vals=pts.map(p=>p.followers);
1639+ const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);
1640+ const x=i=>pad+i*(w-2*pad)/(pts.length-1);
1641+ const y=v=>h-pad-((v-min)/span)*(h-2*pad);
1642+ const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(" ");
1643+ const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;
1644+ return `<div class="spark-wrap"><svg class="spark-svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">
1645+ <polygon class="spark-fill" points="${area}"/><polyline class="spark-line" points="${line}"/></svg>
1646+ <div class="spark-meta"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;
1647+}
1648+
1574 async function renderCreator(id){ 1649 async function renderCreator(id){
1575 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav(); 1650 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
1576 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))} 1651 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
@@ -1581,16 +1656,35 @@ async function renderCreator(id){
1581 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join(""); 1656 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
1582 const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0)); 1657 const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));
1583 const g=computeAgg(c); 1658 const g=computeAgg(c);
1659+ const ins=c.insights||{};
1660+ const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);
1584 // bandeau de métriques CALCULÉES (multi-plateforme) 1661 // bandeau de métriques CALCULÉES (multi-plateforme)
1585 const kpi=(v,l)=>v==null?"":`<div class="hkpi"><b>${typeof v==="number"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`; 1662 const kpi=(v,l)=>v==null?"":`<div class="hkpi"><b>${typeof v==="number"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;
1663+ const gkpi=(d,l)=>!d||!d.delta?"":`<div class="hkpi"><b class="${d.delta>=0?"growth-up":"growth-down"}">${d.delta>=0?"+":""}${fmt.format(d.delta)}</b><span>${l}</span></div>`;
1586 const heroKpis=[ 1664 const heroKpis=[
1587 kpi(g.reach,"abonnés cumulés"), 1665 kpi(g.reach,"abonnés cumulés"),
1666+ gkpi(ins.growth_7d,"abonnés / 7 j"),
1667+ gkpi(ins.growth_30d,"abonnés / 30 j"),
1588 kpi(g.nPlat,"plateformes"), 1668 kpi(g.nPlat,"plateformes"),
1589− kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+" %":null,"engagement moyen"), 1669+ kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+" %":(g.avgEng!=null?Math.round(g.avgEng*100)/100+" %":null),"engagement moyen"),
1590− kpi(g.maxCad!=null?g.maxCad:null,"pubs / semaine"), 1670+ kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,"pubs / semaine"),
1591 kpi(g.content||null,"contenus analysés"), 1671 kpi(g.content||null,"contenus analysés"),
1592 kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,"plateforme nº 1"), 1672 kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,"plateforme nº 1"),
1593 ].filter(Boolean).join(""); 1673 ].filter(Boolean).join("");
1674+ // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)
1675+ const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};
1676+ const KS_LBL={audience:"Audience",engagement:"Engagement",presence:"Multi-plateforme",
1677+ rythme:"Rythme de pub.",verification:"Vérification"};
1678+ const ksParts=ins.ka_score_parts||{};
1679+ const ksHtml=ins.ka_score!=null?`<h3 class="cg-title">Ka Score</h3><div class="kascore">
1680+ <div class="kascore-num"><b>${ins.ka_score}</b><span>/ 100</span></div>
1681+ <div class="kascore-parts">${Object.keys(KS_MAX).map(k=>`<div class="kspart">
1682+ <span class="kspart-lab">${KS_LBL[k]}</span>
1683+ <span class="kspart-track"><span class="kspart-fill" style="width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%"></span></span>
1684+ <span class="kspart-val">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join("")}</div>
1685+ </div>`:"";
1686+ const hist=((ins.history||{}).total)||[];
1687+ const sparkHtml=hist.length>1?`<h3 class="cg-title">Évolution de l'audience</h3>${sparkSvg(hist)}`:"";
1594 // barres comparatives de portée par plateforme (vue d'ensemble) 1688 // barres comparatives de portée par plateforme (vue d'ensemble)
1595 const maxF=Math.max(1,...P.map(a=>a.followers||0)); 1689 const maxF=Math.max(1,...P.map(a=>a.followers||0));
1596 const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100); 1690 const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);
@@ -1605,8 +1699,10 @@ async function renderCreator(id){
1605 <span class="ptab-ico" style="color:${p.color}">${icon(a.platform)}</span>${p.label} 1699 <span class="ptab-ico" style="color:${p.color}">${icon(a.platform)}</span>${p.label}
1606 ${a.followers!=null?`<span class="ptab-n">${fmt.format(a.followers)}</span>`:""}</button>`;}).join(""); 1700 ${a.followers!=null?`<span class="ptab-n">${fmt.format(a.followers)}</span>`:""}</button>`;}).join("");
1607 const ovPanel=`<section class="ppanel on" id="ov-panel" data-panel="ov"> 1701 const ovPanel=`<section class="ppanel on" id="ov-panel" data-panel="ov">
1702+ ${ksHtml}
1608 <h3 class="cg-title">Portée par plateforme</h3> 1703 <h3 class="cg-title">Portée par plateforme</h3>
1609 <div class="obars">${bars||"<p class=klabel>Aucune plateforme.</p>"}</div> 1704 <div class="obars">${bars||"<p class=klabel>Aucune plateforme.</p>"}</div>
1705+ ${sparkHtml}
1610 ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""} 1706 ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""}
1611 </section>`; 1707 </section>`;
1612 const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(""); 1708 const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join("");
@@ -1614,6 +1710,7 @@ async function renderCreator(id){
1614 app.innerHTML=header()+` 1710 app.innerHTML=header()+`
1615 <div class="container cpage"> 1711 <div class="container cpage">
1616 <a class="backlink" href="/" data-nav>← Tous les créateurs</a> 1712 <a class="backlink" href="/" data-nav>← Tous les créateurs</a>
1713+ ${banner?`<div class="cbanner"><img src="${esc(banner)}" alt="" referrerpolicy="no-referrer" onerror="this.parentElement.remove()"></div>`:""}
1617 <div class="cpage-head"> 1714 <div class="cpage-head">
1618 <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div> 1715 <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div>
1619 <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1> 1716 <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1>
modified frontend/src/index.template.html +102 −5
@@ -359,6 +359,50 @@ select.f.active{background-color:var(--accent-soft);color:var(--ink);border-colo
359 .linkinbio svg{width:16px;height:16px;fill:currentColor} 359 .linkinbio svg{width:16px;height:16px;fill:currentColor}
360 .cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3); 360 .cpage-foot{margin-top:38px;font-size:12px;color:var(--ink-3);
361 border-top:1.5px solid var(--ink);padding-top:16px} 361 border-top:1.5px solid var(--ink);padding-top:16px}
362+/* bannière héro (banner_url capté par les acteurs Apify) */
363+.cbanner{position:relative;border:1.5px solid var(--ink);border-bottom:none;
364+ border-radius:var(--r-card) var(--r-card) 0 0;overflow:hidden;height:clamp(120px,22vw,240px);
365+ background:var(--ink)}
366+.cbanner img{width:100%;height:100%;object-fit:cover;display:block}
367+.cbanner+.cpage-head{border-radius:0 0 var(--r-card) var(--r-card)}
368+/* Ka Score */
369+.kascore{display:flex;gap:22px;align-items:center;flex-wrap:wrap;margin:6px 0 2px}
370+.kascore-num{flex:none;width:112px;height:112px;border-radius:50%;display:flex;
371+ flex-direction:column;align-items:center;justify-content:center;
372+ border:2px solid var(--ink);background:var(--accent);color:var(--on-accent);
373+ box-shadow:var(--shadow-off-soft)}
374+.kascore-num b{font-family:var(--font-display);font-size:34px;font-weight:800;line-height:1}
375+.kascore-num span{font-family:var(--font-mono);font-size:9px;font-weight:700;
376+ text-transform:uppercase;letter-spacing:.06em;margin-top:3px}
377+.kascore-parts{flex:1;min-width:230px;display:flex;flex-direction:column;gap:7px}
378+.kspart{display:flex;align-items:center;gap:10px}
379+.kspart-lab{width:110px;flex:none;font-family:var(--font-mono);font-size:10px;
380+ font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--ink-3)}
381+.kspart-track{flex:1;height:9px;background:var(--surface-2);border:1px solid var(--line);
382+ border-radius:5px;overflow:hidden}
383+.kspart-fill{display:block;height:100%;background:var(--green);border-radius:5px 0 0 5px;min-width:2px}
384+.kspart-val{width:52px;flex:none;text-align:right;font-family:var(--font-mono);
385+ font-size:11px;font-weight:700;color:var(--ink)}
386+@media(max-width:640px){.kspart-lab{width:88px;font-size:9px}}
387+/* sparkline évolution d'audience */
388+.spark-wrap{margin-top:8px}
389+.spark-svg{width:100%;height:88px;display:block}
390+.spark-line{fill:none;stroke:var(--green);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}
391+.spark-fill{fill:var(--lime-soft);opacity:.7;stroke:none}
392+.spark-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);
393+ font-size:10px;color:var(--ink-3);margin-top:4px}
394+.growth-up{color:var(--green-deep);font-weight:700}
395+.growth-down{color:#b0201a;font-weight:700}
396+/* bloc EN DIRECT (miniature live Twitch/Kick) */
397+.live-card{display:flex;gap:14px;align-items:stretch;margin:14px 0 4px;
398+ border:1.5px solid #b0201a;border-radius:var(--r-card);overflow:hidden;
399+ background:var(--surface);text-decoration:none;color:inherit}
400+.live-card img{width:200px;min-height:100%;object-fit:cover;flex:none;display:block}
401+.live-card-body{padding:12px 14px;display:flex;flex-direction:column;gap:5px;justify-content:center}
402+.live-card-tag{font-family:var(--font-mono);font-size:10px;font-weight:700;color:#b0201a}
403+.live-card-title{font-size:13.5px;font-weight:700;color:var(--ink);line-height:1.3}
404+.live-card-meta{font-family:var(--font-mono);font-size:10.5px;color:var(--ink-3)}
405+@media(max-width:560px){.live-card{flex-direction:column}.live-card img{width:100%;height:150px}}
362 .cpage-foot a{color:var(--green);font-weight:600} 406 .cpage-foot a{color:var(--green);font-weight:600}
363 407
364 /* ===== page stats ===== */ 408 /* ===== page stats ===== */
@@ -817,7 +861,11 @@ const _relTimeC=ts=>_relTime(ts);
817 window.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add("noimg"); 861 window.thumbFail=el=>{const w=el.parentElement;if(w){w.classList.add("noimg");
818 w.innerHTML=w.dataset.icon||"";}}; 862 w.innerHTML=w.dataset.icon||"";}};
819 function _mediaItems(a){const m=a.metrics||{}; 863 function _mediaItems(a){const m=a.metrics||{};
820− return m.recent_posts||m.recent_videos||m.recent_tweets||[];} 864+ const base=m.recent_posts||m.recent_videos||m.recent_tweets||[];
865+ if(base.length)return base;
866+ // Snapchat : les previews de story/spotlight sont des listes d'URLs d'images
867+ const prev=[...(m.story_previews||[]),...(m.spotlight_previews||[])];
868+ return prev.map(u=>({display_url:u,type:"story"}));}
821 869
822 /* grille de contenu récent, images/vidéos bien affichées */ 870 /* grille de contenu récent, images/vidéos bien affichées */
823 function contentGrid(a){ 871 function contentGrid(a){
@@ -825,7 +873,7 @@ function contentGrid(a){
825 if(!items.length)return `<div class="cg-empty">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`; 873 if(!items.length)return `<div class="cg-empty">${icon(a.platform)} Aucun contenu récent capté pour ce compte.</div>`;
826 const ico=icon(a.platform); 874 const ico=icon(a.platform);
827 const cards=items.slice(0,12).map(it=>{ 875 const cards=items.slice(0,12).map(it=>{
828− const img=it.display_url||it.thumbnail_url||""; 876+ const img=it.display_url||it.thumbnail_url||it.thumbnail||it.cover||(it.media_urls||[])[0]||"";
829 const isVid=it.type==="video"||it.type==="reel"||!!it.duration||it.video_views!=null; 877 const isVid=it.type==="video"||it.type==="reel"||!!it.duration||it.video_views!=null;
830 const cap=esc((it.caption||it.title||it.text||"").slice(0,140)); 878 const cap=esc((it.caption||it.title||it.text||"").slice(0,140));
831 const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at); 879 const when=_relTimeC(it.timestamp||it.published_at||it.published||it.created_at);
@@ -859,7 +907,8 @@ function platformKPIs(a){
859 push(m.avg_views!=null?m.avg_views:m.avg_video_views,"vues / pub (moy.)"); 907 push(m.avg_views!=null?m.avg_views:m.avg_video_views,"vues / pub (moy.)");
860 push(m.avg_comments,"comm. / pub (moy.)"); 908 push(m.avg_comments,"comm. / pub (moy.)");
861 push(m.avg_retweets,"RT / pub (moy.)"); 909 push(m.avg_retweets,"RT / pub (moy.)");
862− push(m.posts_per_week!=null?m.posts_per_week:m.tweets_per_week,"pubs / semaine"); 910+ push(m.posts_per_week!=null?m.posts_per_week:(m.tweets_per_week!=null?m.tweets_per_week:m.videos_per_week),"pubs / semaine");
911+ push(m.total_views,"vues totales");
863 push(m.posts,"publications"); 912 push(m.posts,"publications");
864 push(m.videos,"vidéos"); 913 push(m.videos,"vidéos");
865 push(m.likes,a.platform==="tiktok"?"j'aime cumulés":"j'aime"); 914 push(m.likes,a.platform==="tiktok"?"j'aime cumulés":"j'aime");
@@ -883,6 +932,10 @@ function platformBadges(a){
883 m.is_monthly?'<span class="abadge">Mensuel</span>':"", 932 m.is_monthly?'<span class="abadge">Mensuel</span>':"",
884 m.has_story?'<span class="abadge">Story active</span>':"", 933 m.has_story?'<span class="abadge">Story active</span>':"",
885 m.has_spotlight?'<span class="abadge">Spotlight</span>':"", 934 m.has_spotlight?'<span class="abadge">Spotlight</span>':"",
935+ m.is_nsfw?'<span class="abadge live">18+</span>':"",
936+ m.creation_name?`<span class="abadge">${esc(String(m.creation_name).slice(0,60))}</span>`:"",
937+ m.joined_date?`<span class="abadge">${esc(m.joined_date)}</span>`:"",
938+ m.is_blue_verified?'<span class="abadge ok">✓ Bleu</span>':"",
886 m.country?`<span class="abadge">${esc(m.country)}</span>`:"", 939 m.country?`<span class="abadge">${esc(m.country)}</span>`:"",
887 (m.recent_games||[]).slice(0,2).map(g=>`<span class="abadge">🎮 ${esc(g)}</span>`).join(""), 940 (m.recent_games||[]).slice(0,2).map(g=>`<span class="abadge">🎮 ${esc(g)}</span>`).join(""),
888 (m.top_hashtags||[]).slice(0,3).map(h=>`<span class="abadge">#${esc(h)}</span>`).join(""), 941 (m.top_hashtags||[]).slice(0,3).map(h=>`<span class="abadge">#${esc(h)}</span>`).join(""),
@@ -906,6 +959,13 @@ function platformPanel(a,idx,active){
906 <span class="conf ${confCls}" title="Score de rattachement">${conf}%</span> 959 <span class="conf ${confCls}" title="Score de rattachement">${conf}%</span>
907 </div> 960 </div>
908 ${badges?`<div class="abadge-row">${badges}</div>`:""} 961 ${badges?`<div class="abadge-row">${badges}</div>`:""}
962+ ${(a.metrics||{}).is_live_now&&(a.metrics||{}).live_thumbnail?`
963+ <a class="live-card" href="${esc(a.url)}" target="_blank" rel="noopener">
964+ <img loading="lazy" src="${esc((a.metrics||{}).live_thumbnail)}" alt="" referrerpolicy="no-referrer" onerror="this.remove()">
965+ <span class="live-card-body"><span class="live-card-tag">● EN DIRECT</span>
966+ ${(a.metrics||{}).live_title?`<span class="live-card-title">${esc((a.metrics||{}).live_title)}</span>`:""}
967+ <span class="live-card-meta">${(a.metrics||{}).live_viewers!=null?fmt.format((a.metrics||{}).live_viewers)+" spectateurs":""}${(a.metrics||{}).live_game?" · "+esc((a.metrics||{}).live_game):((a.metrics||{}).live_category?" · "+esc((a.metrics||{}).live_category):"")}</span>
968+ </span></a>`:""}
909 <div class="kpi-grid">${kpis||'<div class="kpi"><b>—</b><span>pas encore de métriques</span></div>'}</div> 969 <div class="kpi-grid">${kpis||'<div class="kpi"><b>—</b><span>pas encore de métriques</span></div>'}</div>
910 ${top&&top.url?`<a class="ppanel-top" href="${esc(top.url)}" target="_blank" rel="noopener">★ Top contenu — ${top.views!=null?fmt.format(top.views)+" vues":""}${top.likes!=null?" · "+fmt.format(top.likes)+" ❤":""} ↗</a>`:""} 970 ${top&&top.url?`<a class="ppanel-top" href="${esc(top.url)}" target="_blank" rel="noopener">★ Top contenu — ${top.views!=null?fmt.format(top.views)+" vues":""}${top.likes!=null?" · "+fmt.format(top.likes)+" ❤":""} ↗</a>`:""}
911 <h3 class="cg-title">Contenu récent</h3> 971 <h3 class="cg-title">Contenu récent</h3>
@@ -933,6 +993,21 @@ function computeAgg(c){
933 nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main}; 993 nPlat:P.length,nVerif:P.filter(a=>a.verified).length,main};
934 } 994 }
935 995
996+/* sparkline SVG (série quotidienne des snapshots — insights.history.total) */
997+function sparkSvg(pts){
998+ if(!pts||pts.length<2)return"";
999+ const w=600,h=88,pad=4;
1000+ const vals=pts.map(p=>p.followers);
1001+ const min=Math.min(...vals),max=Math.max(...vals),span=Math.max(1,max-min);
1002+ const x=i=>pad+i*(w-2*pad)/(pts.length-1);
1003+ const y=v=>h-pad-((v-min)/span)*(h-2*pad);
1004+ const line=pts.map((p,i)=>`${x(i).toFixed(1)},${y(p.followers).toFixed(1)}`).join(" ");
1005+ const area=`${pad},${h-pad} ${line} ${(w-pad).toFixed(1)},${h-pad}`;
1006+ return `<div class="spark-wrap"><svg class="spark-svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">
1007+ <polygon class="spark-fill" points="${area}"/><polyline class="spark-line" points="${line}"/></svg>
1008+ <div class="spark-meta"><span>${esc(pts[0].day)}</span><span>${fmt.format(min)} → ${fmt.format(max)}</span><span>${esc(pts[pts.length-1].day)}</span></div></div>`;
1009+}
1010+
936 async function renderCreator(id){ 1011 async function renderCreator(id){
937 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav(); 1012 app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
938 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))} 1013 let c;try{c=await api("/api/creators/"+encodeURIComponent(id))}
@@ -943,16 +1018,35 @@ async function renderCreator(id){
943 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join(""); 1018 const niches=(c.niches||[]).map(n=>`<span class="mchip">${NICHE_LBL[n]||n}</span>`).join("");
944 const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0)); 1019 const P=(c.platforms||[]).slice().sort((x,y)=>(y.followers||0)-(x.followers||0));
945 const g=computeAgg(c); 1020 const g=computeAgg(c);
1021+ const ins=c.insights||{};
1022+ const banner=c.banner_url||P.map(a=>(a.metrics||{}).banner).find(Boolean);
946 // bandeau de métriques CALCULÉES (multi-plateforme) 1023 // bandeau de métriques CALCULÉES (multi-plateforme)
947 const kpi=(v,l)=>v==null?"":`<div class="hkpi"><b>${typeof v==="number"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`; 1024 const kpi=(v,l)=>v==null?"":`<div class="hkpi"><b>${typeof v==="number"?fmt.format(v):esc(v)}</b><span>${l}</span></div>`;
1025+ const gkpi=(d,l)=>!d||!d.delta?"":`<div class="hkpi"><b class="${d.delta>=0?"growth-up":"growth-down"}">${d.delta>=0?"+":""}${fmt.format(d.delta)}</b><span>${l}</span></div>`;
948 const heroKpis=[ 1026 const heroKpis=[
949 kpi(g.reach,"abonnés cumulés"), 1027 kpi(g.reach,"abonnés cumulés"),
1028+ gkpi(ins.growth_7d,"abonnés / 7 j"),
1029+ gkpi(ins.growth_30d,"abonnés / 30 j"),
950 kpi(g.nPlat,"plateformes"), 1030 kpi(g.nPlat,"plateformes"),
951− kpi(g.avgEng!=null?Math.round(g.avgEng*100)/100+" %":null,"engagement moyen"), 1031+ kpi(ins.avg_engagement_pct!=null?ins.avg_engagement_pct+" %":(g.avgEng!=null?Math.round(g.avgEng*100)/100+" %":null),"engagement moyen"),
952− kpi(g.maxCad!=null?g.maxCad:null,"pubs / semaine"), 1032+ kpi(ins.publications_per_week!=null?ins.publications_per_week:g.maxCad,"pubs / semaine"),
953 kpi(g.content||null,"contenus analysés"), 1033 kpi(g.content||null,"contenus analysés"),
954 kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,"plateforme nº 1"), 1034 kpi(g.main?(PLAT[g.main.platform]||PLAT.autre).label:null,"plateforme nº 1"),
955 ].filter(Boolean).join(""); 1035 ].filter(Boolean).join("");
1036+ // Ka Score /100 (insights serveur) + sparkline d'audience (snapshots)
1037+ const KS_MAX={audience:40,engagement:25,presence:20,rythme:10,verification:5};
1038+ const KS_LBL={audience:"Audience",engagement:"Engagement",presence:"Multi-plateforme",
1039+ rythme:"Rythme de pub.",verification:"Vérification"};
1040+ const ksParts=ins.ka_score_parts||{};
1041+ const ksHtml=ins.ka_score!=null?`<h3 class="cg-title">Ka Score</h3><div class="kascore">
1042+ <div class="kascore-num"><b>${ins.ka_score}</b><span>/ 100</span></div>
1043+ <div class="kascore-parts">${Object.keys(KS_MAX).map(k=>`<div class="kspart">
1044+ <span class="kspart-lab">${KS_LBL[k]}</span>
1045+ <span class="kspart-track"><span class="kspart-fill" style="width:${Math.min(100,Math.round((ksParts[k]||0)/KS_MAX[k]*100))}%"></span></span>
1046+ <span class="kspart-val">${ksParts[k]!=null?ksParts[k]:0}/${KS_MAX[k]}</span></div>`).join("")}</div>
1047+ </div>`:"";
1048+ const hist=((ins.history||{}).total)||[];
1049+ const sparkHtml=hist.length>1?`<h3 class="cg-title">Évolution de l'audience</h3>${sparkSvg(hist)}`:"";
956 // barres comparatives de portée par plateforme (vue d'ensemble) 1050 // barres comparatives de portée par plateforme (vue d'ensemble)
957 const maxF=Math.max(1,...P.map(a=>a.followers||0)); 1051 const maxF=Math.max(1,...P.map(a=>a.followers||0));
958 const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100); 1052 const bars=P.map(a=>{const p=PLAT[a.platform]||PLAT.autre;const w=Math.round((a.followers||0)/maxF*100);
@@ -967,8 +1061,10 @@ async function renderCreator(id){
967 <span class="ptab-ico" style="color:${p.color}">${icon(a.platform)}</span>${p.label} 1061 <span class="ptab-ico" style="color:${p.color}">${icon(a.platform)}</span>${p.label}
968 ${a.followers!=null?`<span class="ptab-n">${fmt.format(a.followers)}</span>`:""}</button>`;}).join(""); 1062 ${a.followers!=null?`<span class="ptab-n">${fmt.format(a.followers)}</span>`:""}</button>`;}).join("");
969 const ovPanel=`<section class="ppanel on" id="ov-panel" data-panel="ov"> 1063 const ovPanel=`<section class="ppanel on" id="ov-panel" data-panel="ov">
1064+ ${ksHtml}
970 <h3 class="cg-title">Portée par plateforme</h3> 1065 <h3 class="cg-title">Portée par plateforme</h3>
971 <div class="obars">${bars||"<p class=klabel>Aucune plateforme.</p>"}</div> 1066 <div class="obars">${bars||"<p class=klabel>Aucune plateforme.</p>"}</div>
1067+ ${sparkHtml}
972 ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""} 1068 ${c.link_in_bio_url?`<a class="linkinbio" href="${esc(c.link_in_bio_url)}" target="_blank" rel="noopener">${icon("linktree")} Sa page de liens officielle ↗</a>`:""}
973 </section>`; 1069 </section>`;
974 const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join(""); 1070 const panels=ovPanel+P.map((a,i)=>platformPanel(a,i,false)).join("");
@@ -976,6 +1072,7 @@ async function renderCreator(id){
976 app.innerHTML=header()+` 1072 app.innerHTML=header()+`
977 <div class="container cpage"> 1073 <div class="container cpage">
978 <a class="backlink" href="/" data-nav>← Tous les créateurs</a> 1074 <a class="backlink" href="/" data-nav>← Tous les créateurs</a>
1075+ ${banner?`<div class="cbanner"><img src="${esc(banner)}" alt="" referrerpolicy="no-referrer" onerror="this.parentElement.remove()"></div>`:""}
979 <div class="cpage-head"> 1076 <div class="cpage-head">
980 <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div> 1077 <div class="cavatar">${esc(initials(c.display_name))}${photoImg(c)}</div>
981 <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1> 1078 <div style="flex:1;min-width:200px"><h1>${esc(c.display_name)}${verified?checkSvg:""}</h1>
982 1079
Menu

Theme