Upgrade majeur round 2 des 13 acteurs Apify : net.py commun durci (retries 403/407/408/425/429/500/502/503/504, backoff exponentiel + jitter, rotation d'IP par tentative, POST avec retries — Twitch GQL passe par ce moteur) + nouveaux champs par plateforme : IG (bio mentions/hashtags, flag Threads relié → cross-link, business_email public, IGTV), TikTok (likes_given, avg_saves, épinglés, flag mineur isUnderAge18 → is_minor §15), YouTube (videos_per_month, has_shorts, live), X (top_mentions §12.1, avg_quotes, parts liens/RT, verified_type), FB (page_id, website → cross-link, rating), Threads (bio_links → cross-links forts, pk), Twitch (totalCount vidéos, type/début de live), Kick (début/langue/maturité du live), Patreon (+ socials IG/TikTok/Twitch/Discord, published_at), Fansly (tiers/prix, createdAt), Discord (features, premium_tier, expiration), OnlyFans (joinDate, audios), Snapchat (bannière héro, snapcode) — 13 poussés, 12 validés par runs de test ; connecteur : metric_maps étendus, cross-links IG→Threads et bio_links Threads, 51 tests verts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
27 changed files +732 −157
modified
actors/ka-discord/src/main.py
+6 −0
@@ -81,6 +81,12 @@ async def main() -> None: | ||
| 81 | 81 | f"{splash_hash}.jpg?size=1024" |
| 82 | 82 | if gid and splash_hash else None), |
| 83 | 83 | "vanity_url_code": guild.get("vanity_url_code"), |
| 84 | + "features": sorted(guild.get("features") or [])[:12], | |
| 85 | + "nsfw_level": guild.get("nsfw_level"), | |
| 86 | + "premium_tier": guild.get("premium_tier"), | |
| 87 | + "invite_expires_at": d.get("expires_at"), | |
| 88 | + "inviter": ((d.get("inviter") or {}).get("username") | |
| 89 | + or None), | |
| 84 | 90 | }) |
| 85 | 91 | |
| 86 | 92 | await asyncio.gather(*[one(c) for c in codes]) |
modified
actors/ka-discord/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-facebook/src/main.py
+32 −0
@@ -11,6 +11,7 @@ from __future__ import annotations | ||
| 11 | 11 | |
| 12 | 12 | import asyncio |
| 13 | 13 | import html as htmllib |
| 14 | +import json | |
| 14 | 15 | import re |
| 15 | 16 | |
| 16 | 17 | from apify import Actor |
@@ -37,6 +38,17 @@ _TEXT_COUNT_RE = re.compile( | ||
| 37 | 38 | re.I) |
| 38 | 39 | _CATEGORY_RE = re.compile(r'"category_name"\s*:\s*"([^"]+)"') |
| 39 | 40 | _VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)') |
| 41 | +_PAGE_ID_RES = ( | |
| 42 | + re.compile(r'"page_id"\s*:\s*"?(\d{6,})"?'), | |
| 43 | + re.compile(r'"pageID"\s*:\s*"(\d{6,})"'), | |
| 44 | + re.compile(r'"delegate_page_id"\s*:\s*"(\d{6,})"'), | |
| 45 | +) | |
| 46 | +# site web auto-déclaré de la page → cross-link côté crea-ka | |
| 47 | +_WEBSITE_RE = re.compile( | |
| 48 | + r'"website(?:s)?"\s*:\s*\[?\s*"((?:https?:)?(?:[^"\\]|\\.)+)"') | |
| 49 | +_RATING_RE = re.compile(r'"overall_star_rating"\s*:\s*\{[^{}]*?' | |
| 50 | + r'"value"\s*:\s*([\d.]+)') | |
| 51 | +_OG_URL_RE = re.compile(r'<meta property="og:url" content="([^"]*)"') | |
| 40 | 52 | |
| 41 | 53 | |
| 42 | 54 | def parse_text_count(text: str) -> int | None: |
@@ -103,16 +115,36 @@ async def main() -> None: | ||
| 103 | 115 | return |
| 104 | 116 | cat = _CATEGORY_RE.search(text) |
| 105 | 117 | ver = _VERIFIED_RE.search(text) |
| 118 | + page_id = next((m.group(1) for rx in _PAGE_ID_RES | |
| 119 | + if (m := rx.search(text))), None) | |
| 120 | + site = _WEBSITE_RE.search(text) | |
| 121 | + website = None | |
| 122 | + if site: | |
| 123 | + try: | |
| 124 | + website = json.loads(f'"{site.group(1)}"').strip() | |
| 125 | + except Exception: | |
| 126 | + website = site.group(1) | |
| 127 | + if website and not website.startswith("http"): | |
| 128 | + website = f"https://{website}" | |
| 129 | + if website and "facebook.com" in website: | |
| 130 | + website = None # auto-référence sans valeur | |
| 131 | + rating = _RATING_RE.search(text) | |
| 132 | + canon = _OG_URL_RE.search(text) | |
| 106 | 133 | await Actor.push_data({ |
| 107 | 134 | "kind": "profile", |
| 108 | 135 | "platform": "facebook", |
| 109 | 136 | "found": True, |
| 110 | 137 | "username": u, |
| 138 | + "page_id": page_id, | |
| 111 | 139 | "full_name": og.get("title"), |
| 112 | 140 | "biography": og.get("description"), |
| 113 | 141 | "followers": followers, |
| 114 | 142 | "category": cat.group(1) if cat else None, |
| 115 | 143 | "is_verified": (ver.group(1) == "true") if ver else None, |
| 144 | + "website": website, | |
| 145 | + "rating": float(rating.group(1)) if rating else None, | |
| 146 | + "canonical_url": (htmllib.unescape(canon.group(1)) | |
| 147 | + if canon else None), | |
| 116 | 148 | "avatar": og.get("image"), |
| 117 | 149 | "og_description": og.get("description"), |
| 118 | 150 | }) |
modified
actors/ka-facebook/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-fansly/src/main.py
+7 −0
@@ -61,6 +61,9 @@ async def main() -> None: | ||
| 61 | 61 | continue |
| 62 | 62 | found.add(u) |
| 63 | 63 | stats = acc.get("timelineStats") or {} |
| 64 | + tiers = [{"name": t.get("name"), "price": t.get("price")} | |
| 65 | + for t in (acc.get("subscriptionTiers") or []) | |
| 66 | + if isinstance(t, dict) and t.get("name")] | |
| 64 | 67 | await Actor.push_data({ |
| 65 | 68 | "kind": "profile", |
| 66 | 69 | "platform": "fansly", |
@@ -82,6 +85,10 @@ async def main() -> None: | ||
| 82 | 85 | "avatar": _loc(acc.get("avatar")), |
| 83 | 86 | "banner": _loc(acc.get("banner")), |
| 84 | 87 | "location": acc.get("location"), |
| 88 | + "account_created_at": acc.get("createdAt"), | |
| 89 | + "subscription_tiers": tiers[:5] or None, | |
| 90 | + "subscription_price": (tiers[0].get("price") | |
| 91 | + if tiers else None), | |
| 85 | 92 | }) |
| 86 | 93 | for u in batch: |
| 87 | 94 | if u not in found: |
modified
actors/ka-fansly/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-instagram/src/main.py
+21 −0
@@ -90,6 +90,15 @@ def parse_user(user: dict) -> dict: | ||
| 90 | 90 | posts = [parse_post((e.get("node") or {})) |
| 91 | 91 | for e in ((user.get("edge_owner_to_timeline_media") or {}) |
| 92 | 92 | .get("edges") or [])] |
| 93 | + bio_ent = user.get("biography_with_entities") or {} | |
| 94 | + bio_mentions, bio_hashtags = [], [] | |
| 95 | + for ent in (bio_ent.get("entities") or []): | |
| 96 | + eu = ((ent or {}).get("user") or {}).get("username") | |
| 97 | + eh = ((ent or {}).get("hashtag") or {}).get("name") | |
| 98 | + if eu: | |
| 99 | + bio_mentions.append(eu) | |
| 100 | + if eh: | |
| 101 | + bio_hashtags.append(eh) | |
| 93 | 102 | followers = (user.get("edge_followed_by") or {}).get("count") |
| 94 | 103 | avg_likes = _avg([p["likes"] for p in posts]) |
| 95 | 104 | avg_comments = _avg([p["comments"] for p in posts]) |
@@ -123,6 +132,8 @@ def parse_user(user: dict) -> dict: | ||
| 123 | 132 | "username": user.get("username"), |
| 124 | 133 | "full_name": user.get("full_name"), |
| 125 | 134 | "biography": user.get("biography"), |
| 135 | + "bio_mentions": bio_mentions[:10], # @comptes cités dans la bio | |
| 136 | + "bio_hashtags": bio_hashtags[:10], | |
| 126 | 137 | "pronouns": user.get("pronouns") or None, |
| 127 | 138 | "external_url": user.get("external_url"), |
| 128 | 139 | "bio_links": [b.get("url") for b in (user.get("bio_links") or []) |
@@ -131,6 +142,10 @@ def parse_user(user: dict) -> dict: | ||
| 131 | 142 | "following": (user.get("edge_follow") or {}).get("count"), |
| 132 | 143 | "posts_count": (user.get("edge_owner_to_timeline_media") |
| 133 | 144 | or {}).get("count"), |
| 145 | + "igtv_videos_count": (user.get("edge_felix_video_timeline") | |
| 146 | + or {}).get("count"), | |
| 147 | + "mutual_followers": (user.get("edge_mutual_followed_by") | |
| 148 | + or {}).get("count"), | |
| 134 | 149 | "highlight_reels": user.get("highlight_reel_count"), |
| 135 | 150 | "is_verified": user.get("is_verified"), |
| 136 | 151 | "is_private": user.get("is_private"), |
@@ -139,6 +154,12 @@ def parse_user(user: dict) -> dict: | ||
| 139 | 154 | "category": user.get("category_name"), |
| 140 | 155 | "business_category": user.get("business_category_name"), |
| 141 | 156 | "business_address": user.get("business_address_json"), |
| 157 | + # courriel PRO affiché publiquement par le créateur (§15 : public only) | |
| 158 | + "business_email": user.get("business_email") or None, | |
| 159 | + # présent = compte Threads relié (cross-link fort, même handle) | |
| 160 | + "has_threads": (bool(user.get("has_onboarded_to_text_post_app")) | |
| 161 | + if "has_onboarded_to_text_post_app" in user | |
| 162 | + else None), | |
| 142 | 163 | "has_clips": user.get("has_clips"), |
| 143 | 164 | "has_channel": user.get("has_channel"), |
| 144 | 165 | "has_ar_effects": user.get("has_ar_effects"), |
modified
actors/ka-instagram/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-kick/src/main.py
+5 −0
@@ -82,6 +82,11 @@ async def main() -> None: | ||
| 82 | 82 | "subscription_enabled": ch.get("subscription_enabled"), |
| 83 | 83 | "is_live_now": bool(live), |
| 84 | 84 | "live_viewers": live.get("viewer_count"), |
| 85 | + "live_started_at": (live.get("start_time") | |
| 86 | + or live.get("created_at")), | |
| 87 | + "live_is_mature": (bool(live.get("is_mature")) | |
| 88 | + if live else None), | |
| 89 | + "live_language": live.get("language"), | |
| 85 | 90 | "live_title": (live.get("session_title") or "")[:200] |
| 86 | 91 | or None, |
| 87 | 92 | "live_thumbnail": ((live.get("thumbnail") or {}).get("url") |
modified
actors/ka-kick/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-onlyfans/src/main.py
+8 −0
@@ -26,7 +26,11 @@ _NUM_RES = { | ||
| 26 | 26 | "videos_count": re.compile(r'"videosCount"\s*:\s*(\d+)'), |
| 27 | 27 | "likes": re.compile(r'"favoritedCount"\s*:\s*(\d+)'), |
| 28 | 28 | "streams_count": re.compile(r'"finishedStreamsCount"\s*:\s*(\d+)'), |
| 29 | + "audios_count": re.compile(r'"audiosCount"\s*:\s*(\d+)'), | |
| 30 | + "medias_count": re.compile(r'"mediasCount"\s*:\s*(\d+)'), | |
| 29 | 31 | } |
| 32 | +_JOIN_RE = re.compile(r'"joinDate"\s*:\s*"((?:[^"\\]|\\.)*)"') | |
| 33 | +_PERFORMER_RE = re.compile(r'"isPerformer"\s*:\s*(true|false)') | |
| 30 | 34 | _PRICE_RE = re.compile(r'"subscribePrice"\s*:\s*([\d.]+)') |
| 31 | 35 | _STR_RES = { |
| 32 | 36 | "full_name": re.compile(r'"name"\s*:\s*"((?:[^"\\]|\\.)*)"'), |
@@ -99,6 +103,10 @@ async def main() -> None: | ||
| 99 | 103 | else None) |
| 100 | 104 | m = _VERIFIED_RE.search(text) |
| 101 | 105 | rec["is_verified"] = (m.group(1) == "true") if m else None |
| 106 | + m = _JOIN_RE.search(text) | |
| 107 | + rec["join_date"] = _dec(m.group(1)) if m else None | |
| 108 | + m = _PERFORMER_RE.search(text) | |
| 109 | + rec["is_performer"] = (m.group(1) == "true") if m else None | |
| 102 | 110 | if not rec.get("full_name"): |
| 103 | 111 | m = _OG_TITLE_RE.search(text) |
| 104 | 112 | if m: |
modified
actors/ka-onlyfans/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-patreon/src/main.py
+12 −0
@@ -34,7 +34,13 @@ _SOCIALS = { | ||
| 34 | 34 | "facebook": re.compile(r'"facebook"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), |
| 35 | 35 | "twitter": re.compile(r'"twitter"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), |
| 36 | 36 | "youtube": re.compile(r'"youtube"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), |
| 37 | + "instagram": re.compile(r'"instagram"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), | |
| 38 | + "tiktok": re.compile(r'"tiktok"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), | |
| 39 | + "twitch": re.compile(r'"twitch"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), | |
| 40 | + "discord": re.compile(r'"discord"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"'), | |
| 37 | 41 | } |
| 42 | +_PUBLISHED_RE = re.compile(r'"published_at"\s*:\s*"([^"]+)"') | |
| 43 | +_PPN_RE = re.compile(r'"pay_per_name"\s*:\s*"([^"]+)"') | |
| 38 | 44 | |
| 39 | 45 | |
| 40 | 46 | def _dec(raw: str) -> str: |
@@ -103,6 +109,12 @@ async def main() -> None: | ||
| 103 | 109 | "is_monthly": (monthly.group(1) == "true" |
| 104 | 110 | if monthly else None), |
| 105 | 111 | "is_nsfw": nsfw.group(1) == "true" if nsfw else None, |
| 112 | + "published_at": (pub.group(1) if | |
| 113 | + (pub := _PUBLISHED_RE.search(text)) | |
| 114 | + else None), | |
| 115 | + "pay_per_name": (ppn.group(1)[:40] if | |
| 116 | + (ppn := _PPN_RE.search(text)) | |
| 117 | + else None), | |
| 106 | 118 | "creation_name": (_dec(creation.group(1))[:200] |
| 107 | 119 | if creation else None), |
| 108 | 120 | "avatar": htmllib.unescape(img.group(1)) if img else None, |
modified
actors/ka-patreon/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-snapchat/src/main.py
+3 −0
@@ -103,6 +103,9 @@ async def main() -> None: | ||
| 103 | 103 | "website": info.get("websiteUrl"), |
| 104 | 104 | "address": info.get("address"), |
| 105 | 105 | "avatar": info.get("profilePictureUrl"), |
| 106 | + "banner": (info.get("squareHeroImageUrl") | |
| 107 | + or info.get("heroImageUrl") or None), | |
| 108 | + "snapcode": info.get("snapcodeImageUrl") or None, | |
| 106 | 109 | "has_story": bool(info.get("hasStory")), |
| 107 | 110 | "has_spotlight": bool(info.get("hasSpotlightHighlights")), |
| 108 | 111 | "has_curated_highlights": bool( |
modified
actors/ka-snapchat/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-threads/src/main.py
+14 −0
@@ -24,6 +24,10 @@ _BIO_RE = re.compile(r'"biography"\s*:\s*"((?:[^"\\]|\\.)*)"') | ||
| 24 | 24 | _NAME_RE = re.compile(r'"full_name"\s*:\s*"((?:[^"\\]|\\.)*)"') |
| 25 | 25 | _VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)') |
| 26 | 26 | _PIC_RE = re.compile(r'"profile_pic_url"\s*:\s*"((?:[^"\\]|\\.)*)"') |
| 27 | +# liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1) | |
| 28 | +_BIO_LINKS_RE = re.compile(r'"bio_links"\s*:\s*\[(.{0,2000}?)\]', re.S) | |
| 29 | +_LINK_URL_RE = re.compile(r'"url"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"') | |
| 30 | +_PK_RE = re.compile(r'"pk"\s*:\s*"?(\d{4,})"?') | |
| 27 | 31 | # posts : paires texte + like_count dans les payloads thread_items |
| 28 | 32 | _POST_RE = re.compile( |
| 29 | 33 | r'"caption"\s*:\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"[^{}]*?\}' |
@@ -69,6 +73,14 @@ async def main() -> None: | ||
| 69 | 73 | name = _NAME_RE.search(text) |
| 70 | 74 | ver = _VERIFIED_RE.search(text) |
| 71 | 75 | pic = _PIC_RE.search(text) |
| 76 | + pk = _PK_RE.search(text) | |
| 77 | + bio_links: list[str] = [] | |
| 78 | + bl = _BIO_LINKS_RE.search(text) | |
| 79 | + if bl: | |
| 80 | + for m2 in _LINK_URL_RE.finditer(bl.group(1)): | |
| 81 | + url2 = _dec(m2.group(1)) | |
| 82 | + if url2 not in bio_links: | |
| 83 | + bio_links.append(url2) | |
| 72 | 84 | likes = [p["likes"] for p in posts] |
| 73 | 85 | top = max(posts, key=lambda p: p["likes"], default=None) |
| 74 | 86 | await Actor.push_data({ |
@@ -76,8 +88,10 @@ async def main() -> None: | ||
| 76 | 88 | "platform": "threads", |
| 77 | 89 | "found": True, |
| 78 | 90 | "username": u, |
| 91 | + "id": pk.group(1) if pk else None, | |
| 79 | 92 | "full_name": _dec(name.group(1)) if name else None, |
| 80 | 93 | "biography": _dec(bio.group(1)) if bio else None, |
| 94 | + "bio_links": bio_links[:5] or None, | |
| 81 | 95 | "followers": int(fol.group(1)), |
| 82 | 96 | "is_verified": (ver.group(1) == "true") if ver else None, |
| 83 | 97 | "avatar": _dec(pic.group(1)) if pic else None, |
modified
actors/ka-threads/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-tiktok/src/main.py
+12 −0
@@ -138,6 +138,8 @@ async def main() -> None: | ||
| 138 | 138 | if isinstance(v["comments"], int)] |
| 139 | 139 | shares = [v["shares"] for v in videos |
| 140 | 140 | if isinstance(v["shares"], int)] |
| 141 | + saves = [v["saves"] for v in videos | |
| 142 | + if isinstance(v["saves"], int)] | |
| 141 | 143 | avg_views = round(sum(views) / len(views)) if views else None |
| 142 | 144 | avg_likes = round(sum(likes) / len(likes)) if likes else None |
| 143 | 145 | engagement = None |
@@ -187,6 +189,7 @@ async def main() -> None: | ||
| 187 | 189 | "followers": followers, |
| 188 | 190 | "following": _num(stats.get("followingCount")), |
| 189 | 191 | "friends": _num(stats.get("friendCount")), |
| 192 | + "likes_given": _num(stats.get("diggCount")), | |
| 190 | 193 | "total_likes": total_likes, |
| 191 | 194 | "videos_count": videos_count, |
| 192 | 195 | "avg_likes_per_video_lifetime": ( |
@@ -196,6 +199,11 @@ async def main() -> None: | ||
| 196 | 199 | "is_organization": bool(user.get("isOrganization")), |
| 197 | 200 | "is_seller": bool(user.get("ttSeller")), |
| 198 | 201 | "is_live_now": bool(user.get("roomId")), |
| 202 | + # drapeau mineur déclaré par TikTok → régime restreint §15 | |
| 203 | + "is_under_18": (bool(user.get("isUnderAge18")) | |
| 204 | + if "isUnderAge18" in user else None), | |
| 205 | + "is_embed_banned": (bool(user.get("isEmbedBanned")) | |
| 206 | + if "isEmbedBanned" in user else None), | |
| 199 | 207 | "commerce_category": (commerce.get("category") or None |
| 200 | 208 | if commerce.get("commerceUser") |
| 201 | 209 | else None), |
@@ -211,6 +219,10 @@ async def main() -> None: | ||
| 211 | 219 | if comments else None), |
| 212 | 220 | "avg_shares": (round(sum(shares) / len(shares)) |
| 213 | 221 | if shares else None), |
| 222 | + "avg_saves": (round(sum(saves) / len(saves)) | |
| 223 | + if saves else None), | |
| 224 | + "pinned_videos_count": (sum(1 for v in videos | |
| 225 | + if v["is_pinned"]) or None), | |
| 214 | 226 | "engagement_rate_pct": engagement, |
| 215 | 227 | "videos_per_week": videos_per_week, |
| 216 | 228 | "last_video_at": last_video_at, |
modified
actors/ka-tiktok/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-twitch/src/main.py
+10 −14
@@ -35,10 +35,11 @@ query($login: String!) { | ||
| 35 | 35 | primaryTeam { displayName } |
| 36 | 36 | channel { socialMedias { name title url } } |
| 37 | 37 | lastBroadcast { startedAt title game { displayName } } |
| 38 | − stream { viewersCount createdAt title | |
| 38 | + stream { viewersCount createdAt title type | |
| 39 | 39 | previewImageURL(width: 640, height: 360) |
| 40 | 40 | game { displayName } } |
| 41 | 41 | videos(first: 10, sort: TIME) { |
| 42 | + totalCount | |
| 42 | 43 | edges { node { |
| 43 | 44 | id title viewCount lengthSeconds publishedAt |
| 44 | 45 | previewThumbnailURL(width: 320, height: 180) |
@@ -60,23 +61,14 @@ async def main() -> None: | ||
| 60 | 61 | fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1)) |
| 61 | 62 | sem = asyncio.Semaphore(int(inp.get("concurrency") or 4)) |
| 62 | 63 | |
| 63 | − import random | |
| 64 | − from curl_cffi import requests as cffi | |
| 65 | − | |
| 66 | 64 | async def gql(login: str) -> dict | None: |
| 67 | 65 | body = json.dumps({"query": QUERY, |
| 68 | 66 | "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, | |
| 67 | + # fetcher.post = retries/backoff/rotation d'IP du net.py commun | |
| 68 | + resp = await fetcher.post( | |
| 69 | + GQL_URL, data=body, session_id=f"tw{login}", | |
| 76 | 70 | 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) | |
| 71 | + "Content-Type": "application/json"}) | |
| 80 | 72 | if resp.status_code != 200: |
| 81 | 73 | raise RuntimeError(f"gql http_{resp.status_code}") |
| 82 | 74 | return (resp.json().get("data") or {}).get("user") |
@@ -131,6 +123,8 @@ async def main() -> None: | ||
| 131 | 123 | "avatar": user.get("profileImageURL"), |
| 132 | 124 | "banner": user.get("bannerImageURL"), |
| 133 | 125 | "is_live_now": bool(stream), |
| 126 | + "live_type": stream.get("type"), | |
| 127 | + "live_started_at": stream.get("createdAt"), | |
| 134 | 128 | "live_viewers": stream.get("viewersCount"), |
| 135 | 129 | "live_title": (stream.get("title") or "")[:200] or None, |
| 136 | 130 | "live_thumbnail": stream.get("previewImageURL"), |
@@ -140,6 +134,8 @@ async def main() -> None: | ||
| 140 | 134 | or None, |
| 141 | 135 | "last_broadcast_game": ((last.get("game") or {}) |
| 142 | 136 | .get("displayName")), |
| 137 | + "videos_count": ((user.get("videos") or {}) | |
| 138 | + .get("totalCount")), | |
| 143 | 139 | "avg_video_views": (round(sum(views) / len(views)) |
| 144 | 140 | if views else None), |
| 145 | 141 | "recent_games": list(dict.fromkeys(games))[:5], |
modified
actors/ka-twitch/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-x/src/main.py
+19 −0
@@ -163,9 +163,14 @@ async def main() -> None: | ||
| 163 | 163 | top = max((t for t in own if isinstance(t["likes"], int)), |
| 164 | 164 | key=lambda t: t["likes"], default=None) |
| 165 | 165 | hashtags: dict[str, int] = {} |
| 166 | + mentions: dict[str, int] = {} | |
| 166 | 167 | for t in own: |
| 167 | 168 | for h in t["hashtags"]: |
| 168 | 169 | hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1 |
| 170 | + for mn in t["mentions"]: | |
| 171 | + mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1 | |
| 172 | + quotes = [t["quotes"] for t in own | |
| 173 | + if isinstance(t["quotes"], int)] | |
| 169 | 174 | created_ts = _ts(user.get("created_at")) |
| 170 | 175 | await Actor.push_data({ |
| 171 | 176 | "kind": "profile", |
@@ -187,6 +192,7 @@ async def main() -> None: | ||
| 187 | 192 | "is_verified": bool(user.get("verified") |
| 188 | 193 | or user.get("is_blue_verified")), |
| 189 | 194 | "is_blue_verified": user.get("is_blue_verified"), |
| 195 | + "verified_type": user.get("verified_type"), | |
| 190 | 196 | "is_protected": user.get("protected"), |
| 191 | 197 | "created_at": user.get("created_at"), |
| 192 | 198 | "account_age_days": (round((time.time() - created_ts) |
@@ -200,6 +206,8 @@ async def main() -> None: | ||
| 200 | 206 | "avg_retweets": avg_rts, |
| 201 | 207 | "avg_replies": (round(sum(reps) / len(reps)) |
| 202 | 208 | if reps else None), |
| 209 | + "avg_quotes": (round(sum(quotes) / len(quotes)) | |
| 210 | + if quotes else None), | |
| 203 | 211 | "engagement_rate_pct": engagement, |
| 204 | 212 | "tweets_per_week": tweets_per_week, |
| 205 | 213 | "last_tweet_at": own[0]["created_at"] if own else None, |
@@ -211,8 +219,19 @@ async def main() -> None: | ||
| 211 | 219 | for t in own) |
| 212 | 220 | / len(own) * 100) |
| 213 | 221 | if own else None), |
| 222 | + "link_share_pct": (round(sum(bool(t["links"]) | |
| 223 | + for t in own) | |
| 224 | + / len(own) * 100) | |
| 225 | + if own else None), | |
| 226 | + "retweet_share_pct": (round(sum(t["is_retweet"] | |
| 227 | + for t in tweets) | |
| 228 | + / len(tweets) * 100) | |
| 229 | + if tweets else None), | |
| 214 | 230 | "top_hashtags": sorted(hashtags, key=hashtags.get, |
| 215 | 231 | reverse=True)[:8], |
| 232 | + # comptes les plus mentionnés → signal d'identité §12.1 | |
| 233 | + "top_mentions": sorted(mentions, key=mentions.get, | |
| 234 | + reverse=True)[:8], | |
| 216 | 235 | "top_tweet": ({"url": top["url"], "likes": top["likes"], |
| 217 | 236 | "retweets": top["retweets"]} |
| 218 | 237 | if top else None), |
modified
actors/ka-x/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
actors/ka-youtube/src/main.py
+30 −0
@@ -164,6 +164,32 @@ def _s(v): | ||
| 164 | 164 | return v.get("content") if isinstance(v, dict) else v |
| 165 | 165 | |
| 166 | 166 | |
| 167 | +_REL_RE = re.compile( | |
| 168 | + r"(\d+)\s*(hour|day|week|month|year|heure|jour|semaine|mois|an)", re.I) | |
| 169 | +_REL_DAYS = {"hour": 1 / 24, "heure": 1 / 24, "day": 1, "jour": 1, | |
| 170 | + "week": 7, "semaine": 7, "month": 30.44, "mois": 30.44, | |
| 171 | + "year": 365.25, "an": 365.25} | |
| 172 | + | |
| 173 | + | |
| 174 | +def _rel_days(published: str | None) -> float | None: | |
| 175 | + """« 3 weeks ago » / « il y a 2 mois » → ancienneté approx. en jours.""" | |
| 176 | + m = _REL_RE.search(published or "") | |
| 177 | + if not m: | |
| 178 | + return None | |
| 179 | + unit = m.group(2).lower() | |
| 180 | + unit = next((k for k in _REL_DAYS if unit.startswith(k)), None) | |
| 181 | + return float(m.group(1)) * _REL_DAYS[unit] if unit else None | |
| 182 | + | |
| 183 | + | |
| 184 | +def _cadence(vids: list[dict]) -> float | None: | |
| 185 | + """Vidéos/mois estimées d'après les dates relatives (approximatif).""" | |
| 186 | + ages = sorted(a for a in (_rel_days(v.get("published")) for v in vids) | |
| 187 | + if a is not None) | |
| 188 | + if len(ages) < 3 or ages[-1] <= ages[0]: | |
| 189 | + return None | |
| 190 | + return round((len(ages) - 1) / ((ages[-1] - ages[0]) / 30.44), 2) | |
| 191 | + | |
| 192 | + | |
| 167 | 193 | def _banner_of(data: dict) -> str | None: |
| 168 | 194 | """Bannière de chaîne : nouvel en-tête (imageBannerViewModel) ou ancien.""" |
| 169 | 195 | b = _walk(data, "imageBannerViewModel") |
@@ -291,6 +317,10 @@ async def main() -> None: | ||
| 291 | 317 | "external_links": about.get("external_links") or [], |
| 292 | 318 | "avg_views": (round(sum(views) / len(views)) |
| 293 | 319 | if views else None), |
| 320 | + "videos_per_month": _cadence(vids), | |
| 321 | + "is_live_now": ('"style":"LIVE"' in text | |
| 322 | + or '"label":"LIVE"' in text) or None, | |
| 323 | + "has_shorts": ('"title":"Shorts"' in text) or None, | |
| 294 | 324 | "top_video": (max( |
| 295 | 325 | (v for v in vids if isinstance(v["views"], int)), |
| 296 | 326 | key=lambda v: v["views"], default=None)), |
modified
actors/ka-youtube/src/net.py
+38 −10
@@ -2,7 +2,10 @@ | ||
| 2 | 2 | # Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 3 | 3 | # File: src/net.py |
| 4 | 4 | # Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + |
| 5 | −# proxy Apify (résidentiel par défaut), throttling poli + retries | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 6 | 9 | # ============================================================================== |
| 7 | 10 | from __future__ import annotations |
| 8 | 11 | |
@@ -16,9 +19,17 @@ from curl_cffi import requests as cffi | ||
| 16 | 19 | UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " |
| 17 | 20 | "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") |
| 18 | 21 | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 19 | 30 | |
| 20 | 31 | class Fetcher: |
| 21 | − """GET poli avec proxy Apify + impersonation Chrome + retries 403/429.""" | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 22 | 33 | |
| 23 | 34 | def __init__(self, proxy_configuration, delay: float = 1.0) -> None: |
| 24 | 35 | self.proxy_configuration = proxy_configuration |
@@ -34,9 +45,10 @@ class Fetcher: | ||
| 34 | 45 | await asyncio.sleep(wait) |
| 35 | 46 | self._last = asyncio.get_event_loop().time() |
| 36 | 47 | |
| 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"): | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 40 | 52 | hdrs = {"User-Agent": UA, |
| 41 | 53 | "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} |
| 42 | 54 | if headers: |
@@ -48,22 +60,38 @@ class Fetcher: | ||
| 48 | 60 | if self.proxy_configuration: |
| 49 | 61 | sid = session_id or f"s{random.randint(1, 999_999)}" |
| 50 | 62 | sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 51 | 64 | proxy = await self.proxy_configuration.new_url( |
| 52 | 65 | session_id=f"{sid}r{attempt}") |
| 53 | 66 | try: |
| 54 | 67 | resp = await asyncio.to_thread( |
| 55 | − cffi.get, url, headers=hdrs, impersonate=impersonate, | |
| 56 | − timeout=60, allow_redirects=True, | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 57 | 70 | proxies={"http": proxy, "https": proxy} if proxy else None) |
| 58 | − if resp.status_code in (403, 407, 429, 502) and attempt < retries - 1: | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 59 | 73 | Actor.log.warning( |
| 60 | 74 | f"HTTP {resp.status_code} {url} — retry {attempt + 1}") |
| 61 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 62 | 76 | continue |
| 63 | 77 | return resp |
| 64 | 78 | except Exception as exc: # réseau/proxy : on retente |
| 65 | 79 | last_exc = exc |
| 66 | − await asyncio.sleep(1.5 * (attempt + 1)) | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 67 | 81 | if last_exc: |
| 68 | 82 | raise last_exc |
| 69 | 83 | raise RuntimeError(f"échec après {retries} tentatives : {url}") |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
creaka/connectors/apify_social.py
+59 −13
@@ -331,7 +331,11 @@ class ApifyInstagram(_ApifySocialEnrich): | ||
| 331 | 331 | "posts_per_week": "posts_per_week", |
| 332 | 332 | "last_post_at": "last_post_at", |
| 333 | 333 | "video_share_pct": "video_share_pct", |
| 334 | − "top_post": "top_post"} | |
| 334 | + "top_post": "top_post", | |
| 335 | + "bio_mentions": "bio_mentions", | |
| 336 | + "bio_hashtags": "bio_hashtags", | |
| 337 | + "igtv_videos_count": "igtv_videos", | |
| 338 | + "business_email": "business_email"} | |
| 335 | 339 | |
| 336 | 340 | def bio_urls(self, it: dict) -> list[str]: |
| 337 | 341 | return [it.get("external_url") or ""] + (it.get("bio_links") or []) |
@@ -342,6 +346,12 @@ class ApifyInstagram(_ApifySocialEnrich): | ||
| 342 | 346 | or []) if r.get("username")] |
| 343 | 347 | if related: # matière première de découvertes futures (§12.1 mention) |
| 344 | 348 | acc.metrics["related_profiles"] = related[:10] |
| 349 | + # flag Meta « compte Threads relié » : même handle → cross_link fort | |
| 350 | + if it.get("has_threads") and it.get("username"): | |
| 351 | + u = it["username"] | |
| 352 | + cr.platforms = merge_accounts(cr.platforms, [ | |
| 353 | + account("threads", u, "cross_link", | |
| 354 | + url=f"https://www.threads.com/@{u}").finalize()]) | |
| 345 | 355 | |
| 346 | 356 | |
| 347 | 357 | class ApifyTikTok(_ApifySocialEnrich): |
@@ -362,11 +372,19 @@ class ApifyTikTok(_ApifySocialEnrich): | ||
| 362 | 372 | "videos_per_week": "videos_per_week", |
| 363 | 373 | "last_video_at": "last_video_at", |
| 364 | 374 | "top_hashtags": "top_hashtags", "language": "language", |
| 365 | − "top_video": "top_video"} | |
| 375 | + "top_video": "top_video", | |
| 376 | + "likes_given": "likes_given", "avg_saves": "avg_saves", | |
| 377 | + "pinned_videos_count": "pinned_videos"} | |
| 366 | 378 | |
| 367 | 379 | def bio_urls(self, it: dict) -> list[str]: |
| 368 | 380 | return [it.get("bio_link") or ""] |
| 369 | 381 | |
| 382 | + def apply(self, cr, acc, it): | |
| 383 | + super().apply(cr, acc, it) | |
| 384 | + # drapeau mineur DÉCLARÉ par TikTok → régime restreint §15 | |
| 385 | + if it.get("is_under_18") is True: | |
| 386 | + cr.is_minor = True | |
| 387 | + | |
| 370 | 388 | |
| 371 | 389 | class ApifyX(_ApifySocialEnrich): |
| 372 | 390 | source_id = "x-apify" |
@@ -389,7 +407,11 @@ class ApifyX(_ApifySocialEnrich): | ||
| 389 | 407 | "reply_share_pct": "reply_share_pct", |
| 390 | 408 | "is_blue_verified": "is_blue_verified", |
| 391 | 409 | "likes_given": "likes_given", "website": "website", |
| 392 | − "top_hashtags": "top_hashtags", "top_tweet": "top_tweet"} | |
| 410 | + "top_hashtags": "top_hashtags", "top_tweet": "top_tweet", | |
| 411 | + "avg_quotes": "avg_quotes", "verified_type": "verified_type", | |
| 412 | + "link_share_pct": "link_share_pct", | |
| 413 | + "retweet_share_pct": "retweet_share_pct", | |
| 414 | + "top_mentions": "top_mentions"} | |
| 393 | 415 | |
| 394 | 416 | def extra_input(self) -> dict: |
| 395 | 417 | return {"maxTweets": 20} |
@@ -405,7 +427,12 @@ class ApifyFacebook(_ApifySocialEnrich): | ||
| 405 | 427 | run_concurrency = 6 # maximum du schéma d'input de ka-facebook |
| 406 | 428 | cap = 400 |
| 407 | 429 | revisit_days = 7 |
| 408 | − metric_map = {"category": "category"} | |
| 430 | + metric_map = {"category": "category", "page_id": "page_id", | |
| 431 | + "website": "website", "rating": "rating"} | |
| 432 | + | |
| 433 | + def bio_urls(self, it: dict) -> list[str]: | |
| 434 | + # site web auto-déclaré de la page → cross_link (§12.1) | |
| 435 | + return [it.get("website") or ""] | |
| 409 | 436 | |
| 410 | 437 | |
| 411 | 438 | class ApifyThreads(_ApifySocialEnrich): |
@@ -416,7 +443,12 @@ class ApifyThreads(_ApifySocialEnrich): | ||
| 416 | 443 | cap = 60 |
| 417 | 444 | revisit_days = 7 |
| 418 | 445 | content_key = "recent_posts" |
| 419 | − metric_map = {"avg_likes": "avg_likes", "top_post": "top_post"} | |
| 446 | + metric_map = {"avg_likes": "avg_likes", "top_post": "top_post", | |
| 447 | + "bio_links": "bio_links"} | |
| 448 | + | |
| 449 | + def bio_urls(self, it: dict) -> list[str]: | |
| 450 | + # liens de bio AUTO-DÉCLARÉS Threads → cross_link fort (§12.1) | |
| 451 | + return list(it.get("bio_links") or []) | |
| 420 | 452 | |
| 421 | 453 | |
| 422 | 454 | class ApifySnapchat(_ApifySocialEnrich): |
@@ -433,7 +465,7 @@ class ApifySnapchat(_ApifySocialEnrich): | ||
| 433 | 465 | "lenses_count": "lenses", |
| 434 | 466 | "story_previews": "story_previews", |
| 435 | 467 | "spotlight_previews": "spotlight_previews", |
| 436 | − "website": "website", | |
| 468 | + "website": "website", "snapcode": "snapcode", | |
| 437 | 469 | "spotlight_highlights_count": "spotlight_highlights"} |
| 438 | 470 | |
| 439 | 471 | def bio_urls(self, it: dict) -> list[str]: |
@@ -451,7 +483,9 @@ class ApifyYouTube(_ApifySocialEnrich): | ||
| 451 | 483 | "avg_views": "avg_views", "top_video": "top_video", |
| 452 | 484 | "total_views": "total_views", "joined_date": "joined_date", |
| 453 | 485 | "external_links": "external_links", |
| 454 | − "last_video_published": "last_video_published"} | |
| 486 | + "last_video_published": "last_video_published", | |
| 487 | + "videos_per_month": "videos_per_month", | |
| 488 | + "has_shorts": "has_shorts", "is_live_now": "is_live_now"} | |
| 455 | 489 | |
| 456 | 490 | def bio_urls(self, it: dict) -> list[str]: |
| 457 | 491 | # liens externes AUTO-DÉCLARÉS de la page À propos → cross_link (§12.1) |
@@ -476,7 +510,10 @@ class ApifyTwitch(_ApifySocialEnrich): | ||
| 476 | 510 | "live_thumbnail": "live_thumbnail", |
| 477 | 511 | "avg_video_views": "avg_video_views", |
| 478 | 512 | "social_links": "social_links", |
| 479 | − "recent_games": "recent_games"} | |
| 513 | + "recent_games": "recent_games", | |
| 514 | + "videos_count": "videos", | |
| 515 | + "live_type": "live_type", | |
| 516 | + "live_started_at": "live_started_at"} | |
| 480 | 517 | |
| 481 | 518 | def bio_urls(self, it: dict) -> list[str]: |
| 482 | 519 | # panneau « À propos » Twitch : liens sociaux AUTO-DÉCLARÉS (§12.1) |
@@ -495,7 +532,9 @@ class ApifyKick(_ApifySocialEnrich): | ||
| 495 | 532 | "live_thumbnail": "live_thumbnail", |
| 496 | 533 | "live_category": "live_category", |
| 497 | 534 | "vod_enabled": "vod_enabled", |
| 498 | − "recent_categories": "recent_categories"} | |
| 535 | + "recent_categories": "recent_categories", | |
| 536 | + "live_started_at": "live_started_at", | |
| 537 | + "live_language": "live_language"} | |
| 499 | 538 | |
| 500 | 539 | def cross_links(self, cr: Creator, it: dict) -> None: |
| 501 | 540 | """Liens sociaux AUTO-DÉCLARÉS de la fiche Kick → cross_link (§12.1).""" |
@@ -529,7 +568,8 @@ class ApifyOnlyFans(_ApifySocialEnrich): | ||
| 529 | 568 | "videos_count": "videos", "likes": "likes", |
| 530 | 569 | "streams_count": "streams", |
| 531 | 570 | "subscribe_price_usd": "subscribe_price_usd", |
| 532 | − "is_free": "is_free", "location": "location"} | |
| 571 | + "is_free": "is_free", "location": "location", | |
| 572 | + "join_date": "join_date", "audios_count": "audios"} | |
| 533 | 573 | |
| 534 | 574 | def bio_urls(self, it: dict) -> list[str]: |
| 535 | 575 | return [it.get("website") or ""] |
@@ -543,7 +583,9 @@ class ApifyFansly(_ApifySocialEnrich): | ||
| 543 | 583 | revisit_days = 7 |
| 544 | 584 | metric_map = {"posts_count": "posts", "images_count": "images", |
| 545 | 585 | "videos_count": "videos", "likes": "likes", |
| 546 | − "following": "following", "location": "location"} | |
| 586 | + "following": "following", "location": "location", | |
| 587 | + "subscription_price": "subscription_price", | |
| 588 | + "account_created_at": "account_created_at"} | |
| 547 | 589 | |
| 548 | 590 | |
| 549 | 591 | class ApifyPatreon(_ApifySocialEnrich): |
@@ -555,7 +597,9 @@ class ApifyPatreon(_ApifySocialEnrich): | ||
| 555 | 597 | metric_map = {"patrons": "patrons", "posts_count": "posts", |
| 556 | 598 | "is_monthly": "is_monthly", "is_nsfw": "is_nsfw", |
| 557 | 599 | "creation_name": "creation_name", |
| 558 | − "social_links": "social_links"} | |
| 600 | + "social_links": "social_links", | |
| 601 | + "published_at": "published_at", | |
| 602 | + "pay_per_name": "pay_per_name"} | |
| 559 | 603 | |
| 560 | 604 | def bio_urls(self, it: dict) -> list[str]: |
| 561 | 605 | # liens sociaux AUTO-DÉCLARÉS de la campagne Patreon → cross_link |
@@ -571,7 +615,9 @@ class ApifyDiscord(_ApifySocialEnrich): | ||
| 571 | 615 | metric_map = {"members": "members", "online": "online", "boosts": "boosts", |
| 572 | 616 | "partnered": "partnered", "guild_id": "guild_id", |
| 573 | 617 | "channel": "channel", "splash": "splash", |
| 574 | − "vanity_url_code": "vanity_url_code"} | |
| 618 | + "vanity_url_code": "vanity_url_code", | |
| 619 | + "premium_tier": "premium_tier", | |
| 620 | + "nsfw_level": "nsfw_level"} | |
| 575 | 621 | |
| 576 | 622 | def target_of(self, acc) -> str: |
| 577 | 623 | """Code d'invitation SENSIBLE À LA CASSE, repris de l'URL (pas du |
| 578 | 624 | |