SPB Git forge

spb/crea-ka

Public

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

52commits 1branches 0releases
11.3 MBsize
maindefault branch
20 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
11.9 KB · 243 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   src/main.py (ka-x)4# Desc:   Profils X (Twitter) SANS clé via le service OFFICIEL de syndication5#         des widgets (syndication.twitter.com/srv/timeline-profile) : objet6#         user complet + timeline récente avec métriques par tweet. Conçu pour7#         être appelé sans authentification ; l'empreinte TLS Chrome de8#         curl_cffi évite les 429 réservés aux clients python-requests.9# ==============================================================================10from __future__ import annotations1112import asyncio13import json14import re15import time1617from apify import Actor1819from .net import Fetcher2021SYNDICATION_URL = ("https://syndication.twitter.com/srv/timeline-profile/"22                   "screen-name/{u}")23_NEXT_DATA_RE = re.compile(24    r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)252627def _find_user(node, handle: str):28    """Parcourt le JSON Next.js : l'objet user dont screen_name == handle."""29    if isinstance(node, dict):30        if str(node.get("screen_name", "")).lower() == handle and \31                "followers_count" in node:32            return node33        for v in node.values():34            hit = _find_user(v, handle)35            if hit is not None:36                return hit37    elif isinstance(node, list):38        for v in node:39            hit = _find_user(v, handle)40            if hit is not None:41                return hit42    return None434445def parse_tweets(data: dict, handle: str, cap: int) -> list[dict]:46    entries = (((data.get("props") or {}).get("pageProps") or {})47               .get("timeline") or {}).get("entries") or []48    tweets: list[dict] = []49    for entry in entries:50        t = (entry.get("content") or {}).get("tweet") or {}51        if not t.get("id_str"):52            continue53        u = (t.get("user") or {})54        ent = t.get("entities") or {}55        media = (t.get("extended_entities") or ent).get("media") or []56        tweets.append({57            "id": t.get("id_str"),58            "url": f"https://x.com/{u.get('screen_name', handle)}"59                   f"/status/{t.get('id_str')}",60            "text": (t.get("full_text") or t.get("text") or "")[:500],61            "likes": t.get("favorite_count"),62            "retweets": t.get("retweet_count"),63            "replies": t.get("reply_count"),64            "quotes": t.get("quote_count"),65            "created_at": t.get("created_at"),66            "is_retweet": bool(t.get("retweeted_status")),67            "is_reply": bool(t.get("in_reply_to_status_id_str")),68            "is_quote": bool(t.get("is_quote_status")),69            "lang": t.get("lang"),70            "hashtags": [h.get("text") for h in (ent.get("hashtags") or [])71                         if h.get("text")][:8],72            "mentions": [m.get("screen_name")73                         for m in (ent.get("user_mentions") or [])74                         if m.get("screen_name")][:8],75            "media_type": (media[0].get("type") if media else None),76            "media_count": len(media) or None,77            "media_urls": [x.get("media_url_https") for x in media78                           if x.get("media_url_https")][:4],79            "links": [x.get("expanded_url") for x in (ent.get("urls") or [])80                      if x.get("expanded_url")][:4],81        })82        if len(tweets) >= cap:83            break84    return tweets858687def _ts(created_at: str) -> float | None:88    """« Tue Sep 12 23:56:24 +0000 2023 » → epoch (None si illisible)."""89    try:90        from datetime import datetime91        return datetime.strptime(created_at,92                                 "%a %b %d %H:%M:%S %z %Y").timestamp()93    except (TypeError, ValueError):94        return None959697async def main() -> None:98    async with Actor:99        inp = await Actor.get_input() or {}100        usernames = [u.strip().lstrip("@").lower()101                     for u in (inp.get("usernames") or []) if u and u.strip()]102        max_tweets = int(inp.get("maxTweets", 20))103        proxy = await Actor.create_proxy_configuration(104            actor_proxy_input=inp.get("proxyConfiguration"))105        fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))106        sem = asyncio.Semaphore(int(inp.get("concurrency") or 4))107108        async def one(u: str) -> None:109            async with sem:110                miss = {"kind": "profile", "platform": "x",111                        "username": u, "found": False}112                try:113                    resp = await fetcher.get(SYNDICATION_URL.format(u=u),114                                             session_id=u, impersonate=None)115                    # 429 : retries avec backoff + NOUVELLE session proxy116                    # (nouvelle IP résidentielle) — une seule relance ne117                    # suffisait pas (13/40 handles en 429, constat 2026-08-21)118                    for i, imp in enumerate(("chrome", None, "chrome")):119                        if resp.status_code != 429:120                            break121                        await asyncio.sleep(5 * (i + 1))122                        resp = await fetcher.get(SYNDICATION_URL.format(u=u),123                                                 session_id=f"{u}r{i}",124                                                 impersonate=imp)125                except Exception as exc:126                    await Actor.push_data({**miss, "error": str(exc)[:200]})127                    return128                m = _NEXT_DATA_RE.search(resp.text or "")129                if resp.status_code != 200 or not m:130                    await Actor.push_data(131                        {**miss, "error": f"http_{resp.status_code}"})132                    return133                try:134                    data = json.loads(m.group(1))135                except Exception:136                    await Actor.push_data({**miss, "error": "bad_json"})137                    return138                user = _find_user(data, u)139                if not user:140                    await Actor.push_data({**miss, "error": "user_not_found"})141                    return142                tweets = parse_tweets(data, u, max_tweets)143                own = [t for t in tweets if not t["is_retweet"]]144                likes = [t["likes"] for t in own if isinstance(t["likes"], int)]145                rts = [t["retweets"] for t in own146                       if isinstance(t["retweets"], int)]147                reps = [t["replies"] for t in own148                        if isinstance(t["replies"], int)]149                followers = user.get("followers_count")150                avg_likes = round(sum(likes) / len(likes)) if likes else None151                avg_rts = round(sum(rts) / len(rts)) if rts else None152                engagement = None153                if followers and avg_likes is not None:154                    engagement = round((avg_likes + (avg_rts or 0))155                                       / followers * 100, 3)156                stamps = sorted(s for s in (_ts(t["created_at"])157                                            for t in own) if s)158                tweets_per_week = None159                if len(stamps) >= 3 and stamps[-1] > stamps[0]:160                    tweets_per_week = round(161                        (len(stamps) - 1) / ((stamps[-1] - stamps[0])162                                             / 604_800), 2)163                top = max((t for t in own if isinstance(t["likes"], int)),164                          key=lambda t: t["likes"], default=None)165                hashtags: dict[str, int] = {}166                mentions: dict[str, int] = {}167                for t in own:168                    for h in t["hashtags"]:169                        hashtags[h.lower()] = hashtags.get(h.lower(), 0) + 1170                    for mn in t["mentions"]:171                        mentions[mn.lower()] = mentions.get(mn.lower(), 0) + 1172                quotes = [t["quotes"] for t in own173                          if isinstance(t["quotes"], int)]174                created_ts = _ts(user.get("created_at"))175                await Actor.push_data({176                    "kind": "profile",177                    "platform": "x",178                    "found": True,179                    "id": user.get("id_str"),180                    "username": user.get("screen_name"),181                    "full_name": user.get("name"),182                    "biography": user.get("description"),183                    "location": user.get("location"),184                    "website": ((user.get("entities") or {}).get("url") or {})185                        .get("urls", [{}])[0].get("expanded_url")186                        if (user.get("entities") or {}).get("url") else None,187                    "followers": followers,188                    "following": user.get("friends_count"),189                    "tweets_count": user.get("statuses_count"),190                    "listed_count": user.get("listed_count"),191                    "likes_given": user.get("favourites_count"),192                    "is_verified": bool(user.get("verified")193                                        or user.get("is_blue_verified")),194                    "is_blue_verified": user.get("is_blue_verified"),195                    "verified_type": user.get("verified_type"),196                    "is_protected": user.get("protected"),197                    "created_at": user.get("created_at"),198                    "account_age_days": (round((time.time() - created_ts)199                                               / 86_400)200                                         if created_ts else None),201                    "avatar": (user.get("profile_image_url_https") or "")202                        .replace("_normal.", "_400x400."),203                    "banner": user.get("profile_banner_url"),204                    # agrégats sur les tweets récents (hors retweets)205                    "avg_likes": avg_likes,206                    "avg_retweets": avg_rts,207                    "avg_replies": (round(sum(reps) / len(reps))208                                    if reps else None),209                    "avg_quotes": (round(sum(quotes) / len(quotes))210                                   if quotes else None),211                    "engagement_rate_pct": engagement,212                    "tweets_per_week": tweets_per_week,213                    "last_tweet_at": own[0]["created_at"] if own else None,214                    "reply_share_pct": (round(sum(t["is_reply"]215                                                  for t in own)216                                              / len(own) * 100)217                                        if own else None),218                    "media_share_pct": (round(sum(bool(t["media_count"])219                                                  for t in own)220                                              / len(own) * 100)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),230                    "top_hashtags": sorted(hashtags, key=hashtags.get,231                                           reverse=True)[:8],232                    # comptes les plus mentionnés → signal d'identité §12.1233                    "top_mentions": sorted(mentions, key=mentions.get,234                                           reverse=True)[:8],235                    "top_tweet": ({"url": top["url"], "likes": top["likes"],236                                   "retweets": top["retweets"]}237                                  if top else None),238                    "recent_tweets": tweets,239                })240241        await asyncio.gather(*[one(u) for u in usernames])242        Actor.log.info(f"terminé : {len(usernames)} profils")243