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%
5.0 KB · 114 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   src/main.py (ka-threads)4# Desc:   Profils Meta Threads publics : la page @handle embarque le profil5#         dans des scripts JSON Relay (follower_count, biography, badge,6#         avatar) + les posts récents (caption/like_count) quand servis.7#         Extraction par regex ciblées — robuste aux réorganisations du JSON.8# ==============================================================================9from __future__ import annotations1011import asyncio12import html as htmllib13import json14import re1516from apify import Actor1718from .net import Fetcher1920PROFILE_URL = "https://www.threads.com/@{u}"2122_FOLLOWERS_RE = re.compile(r'"follower_count"\s*:\s*(\d+)')23_BIO_RE = re.compile(r'"biography"\s*:\s*"((?:[^"\\]|\\.)*)"')24_NAME_RE = re.compile(r'"full_name"\s*:\s*"((?:[^"\\]|\\.)*)"')25_VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)')26_PIC_RE = re.compile(r'"profile_pic_url"\s*:\s*"((?:[^"\\]|\\.)*)"')27# 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,})"?')31# posts : paires texte + like_count dans les payloads thread_items32_POST_RE = re.compile(33    r'"caption"\s*:\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"[^{}]*?\}'34    r'.{0,600}?"like_count"\s*:\s*(\d+)', re.S)353637async def main() -> None:38    async with Actor:39        inp = await Actor.get_input() or {}40        usernames = [u.strip().lstrip("@").lower()41                     for u in (inp.get("usernames") or []) if u and u.strip()]42        proxy = await Actor.create_proxy_configuration(43            actor_proxy_input=inp.get("proxyConfiguration"))44        fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2))45        sem = asyncio.Semaphore(int(inp.get("concurrency") or 2))4647        async def one(u: str) -> None:48            async with sem:49                miss = {"kind": "profile", "platform": "threads",50                        "username": u, "found": False}51                try:52                    resp = await fetcher.get(PROFILE_URL.format(u=u),53                                             session_id=u)54                except Exception as exc:55                    await Actor.push_data({**miss, "error": str(exc)[:200]})56                    return57                text = resp.text or ""58                fol = _FOLLOWERS_RE.search(text)59                if resp.status_code != 200 or not fol:60                    await Actor.push_data(61                        {**miss,62                         "error": f"shell_or_{resp.status_code}"})63                    return64                posts = []65                for m in _POST_RE.finditer(text):66                    cap = _dec(m.group(1))[:500]67                    if cap and all(p["caption"] != cap for p in posts):68                        posts.append({"caption": cap,69                                      "likes": int(m.group(2))})70                    if len(posts) >= 15:71                        break72                bio = _BIO_RE.search(text)73                name = _NAME_RE.search(text)74                ver = _VERIFIED_RE.search(text)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)84                likes = [p["likes"] for p in posts]85                top = max(posts, key=lambda p: p["likes"], default=None)86                await Actor.push_data({87                    "kind": "profile",88                    "platform": "threads",89                    "found": True,90                    "username": u,91                    "id": pk.group(1) if pk else None,92                    "full_name": _dec(name.group(1)) if name else None,93                    "biography": _dec(bio.group(1)) if bio else None,94                    "bio_links": bio_links[:5] or None,95                    "followers": int(fol.group(1)),96                    "is_verified": (ver.group(1) == "true") if ver else None,97                    "avatar": _dec(pic.group(1)) if pic else None,98                    "avg_likes": round(sum(likes) / len(likes))99                                 if likes else None,100                    "top_post": top,101                    "recent_posts": posts,102                })103104        await asyncio.gather(*[one(u) for u in usernames])105        Actor.log.info(f"terminé : {len(usernames)} profils")106107108def _dec(raw: str) -> str:109    """Décode une chaîne échappée JSON (\\uXXXX, \\/ …)."""110    try:111        return htmllib.unescape(json.loads(f'"{raw}"'))112    except Exception:113        return raw114