SPB Git forge

spb/crea-ka

Public

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

52commits 1branches 0releases
11.3 MBsize
maindefault branch
19 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%
11.6 KB · 241 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   src/main.py (ka-tiktok)4# Desc:   Profils TikTok publics ULTRA DÉTAILLÉS via la page @handle : le JSON5#         SSR __UNIVERSAL_DATA_FOR_REHYDRATION__ contient user + stats complètes6#         (abonnés, cœurs, vidéos) ; vidéos récentes avec COVERS (miniatures),7#         durée, hashtags, musique, épinglés + cadence (videos_per_week) et8#         top hashtags quand TikTok les inclut dans le SSR (ItemModule/itemList).9# ==============================================================================10from __future__ import annotations1112import asyncio13import json14import re1516from apify import Actor1718from .net import Fetcher1920PROFILE_URL = "https://www.tiktok.com/@{u}"21_STATE_RE = re.compile(22    r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)</script>',23    re.S)24_ITEM_RE = re.compile(r'"ItemModule"\s*:\s*(\{.*?\})\s*,\s*"[A-Z]', re.S)252627def parse_videos(html: str, data: dict) -> list[dict]:28    """Vidéos récentes si le SSR les inclut (souvent absent — best effort)."""29    items: list[dict] = []30    raw: dict = {}31    scope = data.get("__DEFAULT_SCOPE__") or {}32    detail = scope.get("webapp.user-detail") or {}33    for it in (detail.get("itemList") or []):34        raw[it.get("id", str(len(raw)))] = it35    if not raw:36        m = _ITEM_RE.search(html)37        if m:38            try:39                raw = json.loads(m.group(1))40            except Exception:41                raw = {}42    for vid in list(raw.values())[:20]:43        stats = vid.get("stats") or vid.get("statsV2") or {}44        author = vid.get("author")45        handle = author if isinstance(author, str) else \46            (author or {}).get("uniqueId", "")47        video = vid.get("video") or {}48        music = vid.get("music") or {}49        items.append({50            "id": vid.get("id"),51            "url": f"https://www.tiktok.com/@{handle}/video/{vid.get('id')}",52            "caption": (vid.get("desc") or "")[:500],53            "views": _num(stats.get("playCount")),54            "likes": _num(stats.get("diggCount")),55            "comments": _num(stats.get("commentCount")),56            "shares": _num(stats.get("shareCount")),57            "saves": _num(stats.get("collectCount")),58            "timestamp": vid.get("createTime"),59            "cover": (video.get("cover") or video.get("dynamicCover")60                      or video.get("originCover")),61            "duration_s": _num(video.get("duration")),62            "width": _num(video.get("width")),63            "height": _num(video.get("height")),64            "is_pinned": bool(vid.get("isPinnedItem")),65            "hashtags": [c.get("hashtagName")66                         for c in (vid.get("textExtra") or [])67                         if c.get("hashtagName")][:8],68            "music": ({"title": music.get("title"),69                       "author": music.get("authorName"),70                       "original": music.get("original")}71                      if music.get("title") else None),72        })73    return items747576def _num(v):77    try:78        return int(v)79    except (TypeError, ValueError):80        return None818283async def main() -> None:84    async with Actor:85        inp = await Actor.get_input() or {}86        usernames = [u.strip().lstrip("@").lower()87                     for u in (inp.get("usernames") or []) if u and u.strip()]88        proxy = await Actor.create_proxy_configuration(89            actor_proxy_input=inp.get("proxyConfiguration"))90        fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))91        sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))9293        async def one(u: str) -> None:94            async with sem:95                miss = {"kind": "profile", "platform": "tiktok",96                        "username": u, "found": False}97                resp = None98                m = None99                for attempt in range(3):  # coquille sans SSR → nouvelle IP100                    try:101                        resp = await fetcher.get(PROFILE_URL.format(u=u),102                                                 session_id=f"{u}t{attempt}")103                    except Exception as exc:104                        await Actor.push_data({**miss,105                                               "error": str(exc)[:200]})106                        return107                    if resp.status_code == 404:108                        await Actor.push_data({**miss, "error": "http_404"})109                        return110                    m = _STATE_RE.search(resp.text or "")111                    if m and "webapp.user-detail" in (resp.text or ""):112                        break113                info = None114                data: dict = {}115                if m:116                    try:117                        data = json.loads(m.group(1))118                        info = ((data.get("__DEFAULT_SCOPE__") or {})119                                .get("webapp.user-detail") or {}).get("userInfo")120                    except Exception:121                        info = None122                if not info or not (info.get("user") or {}).get("uniqueId"):123                    await Actor.push_data(124                        {**miss, "error": f"shell_page_{resp.status_code}"})125                    return126                user = info.get("user") or {}127                stats = info.get("statsV2") or info.get("stats") or {}128                if user.get("privateAccount"):129                    await Actor.push_data({**miss, "error": "private"})130                    return131                followers = _num(stats.get("followerCount"))132                videos = parse_videos(resp.text, data)133                views = [v["views"] for v in videos134                         if isinstance(v["views"], int)]135                likes = [v["likes"] for v in videos136                         if isinstance(v["likes"], int)]137                comments = [v["comments"] for v in videos138                            if isinstance(v["comments"], int)]139                shares = [v["shares"] for v in videos140                          if isinstance(v["shares"], int)]141                saves = [v["saves"] for v in videos142                         if isinstance(v["saves"], int)]143                avg_views = round(sum(views) / len(views)) if views else None144                avg_likes = round(sum(likes) / len(likes)) if likes else None145                engagement = None146                if followers and avg_likes is not None:147                    engagement = round(148                        (avg_likes149                         + (round(sum(comments) / len(comments))150                            if comments else 0)151                         + (round(sum(shares) / len(shares))152                            if shares else 0)) / followers * 100, 3)153                top_video = max(154                    (v for v in videos if isinstance(v["views"], int)),155                    key=lambda v: v["views"], default=None)156                stamps = sorted(_num(v["timestamp"]) for v in videos157                                if _num(v["timestamp"]))158                videos_per_week = None159                if len(stamps) >= 3 and stamps[-1] > stamps[0]:160                    videos_per_week = round(161                        (len(stamps) - 1)162                        / ((stamps[-1] - stamps[0]) / 604_800), 2)163                last_video_at = None164                if stamps:165                    from datetime import datetime, timezone166                    last_video_at = datetime.fromtimestamp(167                        stamps[-1], tz=timezone.utc) \168                        .strftime("%Y-%m-%dT%H:%M:%SZ")169                tag_counts: dict[str, int] = {}170                for v in videos:171                    for h in v.get("hashtags") or []:172                        tag_counts[h.lower()] = tag_counts.get(h.lower(), 0) + 1173                commerce = user.get("commerceUserInfo") or {}174                bio_link = ((user.get("bioLink") or {}).get("link")175                            or "").strip()176                total_likes = _num(stats.get("heartCount")177                                   or stats.get("heart"))178                videos_count = _num(stats.get("videoCount"))179                await Actor.push_data({180                    "kind": "profile",181                    "platform": "tiktok",182                    "found": True,183                    "id": user.get("id"),184                    "sec_uid": user.get("secUid"),185                    "username": user.get("uniqueId"),186                    "full_name": user.get("nickname"),187                    "biography": user.get("signature"),188                    "bio_link": bio_link,189                    "followers": followers,190                    "following": _num(stats.get("followingCount")),191                    "friends": _num(stats.get("friendCount")),192                    "likes_given": _num(stats.get("diggCount")),193                    "total_likes": total_likes,194                    "videos_count": videos_count,195                    "avg_likes_per_video_lifetime": (196                        round(total_likes / videos_count)197                        if total_likes and videos_count else None),198                    "is_verified": bool(user.get("verified")),199                    "is_organization": bool(user.get("isOrganization")),200                    "is_seller": bool(user.get("ttSeller")),201                    "is_live_now": bool(user.get("roomId")),202                    # drapeau mineur déclaré par TikTok → régime restreint §15203                    "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),207                    "commerce_category": (commerce.get("category") or None208                                          if commerce.get("commerceUser")209                                          else None),210                    "account_created_at": user.get("createTime"),211                    "region": user.get("region"),212                    "language": user.get("language"),213                    "avatar": user.get("avatarLarger")214                              or user.get("avatarMedium"),215                    # agrégats sur les vidéos présentes dans le SSR216                    "avg_views": avg_views,217                    "avg_likes": avg_likes,218                    "avg_comments": (round(sum(comments) / len(comments))219                                     if comments else None),220                    "avg_shares": (round(sum(shares) / len(shares))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 videos225                                                if v["is_pinned"]) or None),226                    "engagement_rate_pct": engagement,227                    "videos_per_week": videos_per_week,228                    "last_video_at": last_video_at,229                    "top_hashtags": sorted(tag_counts, key=tag_counts.get,230                                           reverse=True)[:8],231                    "top_video": ({"url": top_video["url"],232                                   "views": top_video["views"],233                                   "likes": top_video["likes"],234                                   "cover": top_video.get("cover")}235                                  if top_video else None),236                    "recent_videos": videos,237                })238239        await asyncio.gather(*[one(u) for u in usernames])240        Actor.log.info(f"terminé : {len(usernames)} profils")241