# ============================================================================== # Author: Simon-Pierre Boucher # File: src/main.py (ka-threads) # Desc: Profils Meta Threads publics : la page @handle embarque le profil # dans des scripts JSON Relay (follower_count, biography, badge, # avatar) + les posts récents (caption/like_count) quand servis. # Extraction par regex ciblées — robuste aux réorganisations du JSON. # ============================================================================== from __future__ import annotations import asyncio import html as htmllib import json import re from apify import Actor from .net import Fetcher PROFILE_URL = "https://www.threads.com/@{u}" _FOLLOWERS_RE = re.compile(r'"follower_count"\s*:\s*(\d+)') _BIO_RE = re.compile(r'"biography"\s*:\s*"((?:[^"\\]|\\.)*)"') _NAME_RE = re.compile(r'"full_name"\s*:\s*"((?:[^"\\]|\\.)*)"') _VERIFIED_RE = re.compile(r'"is_verified"\s*:\s*(true|false)') _PIC_RE = re.compile(r'"profile_pic_url"\s*:\s*"((?:[^"\\]|\\.)*)"') # liens auto-déclarés de la bio → cross-links forts côté crea-ka (§12.1) _BIO_LINKS_RE = re.compile(r'"bio_links"\s*:\s*\[(.{0,2000}?)\]', re.S) _LINK_URL_RE = re.compile(r'"url"\s*:\s*"(https?:(?:[^"\\]|\\.)+)"') _PK_RE = re.compile(r'"pk"\s*:\s*"?(\d{4,})"?') # posts : paires texte + like_count dans les payloads thread_items _POST_RE = re.compile( r'"caption"\s*:\s*\{\s*"text"\s*:\s*"((?:[^"\\]|\\.)*)"[^{}]*?\}' r'.{0,600}?"like_count"\s*:\s*(\d+)', re.S) async def main() -> None: async with Actor: inp = await Actor.get_input() or {} usernames = [u.strip().lstrip("@").lower() for u in (inp.get("usernames") or []) if u and u.strip()] proxy = await Actor.create_proxy_configuration( actor_proxy_input=inp.get("proxyConfiguration")) fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2)) sem = asyncio.Semaphore(int(inp.get("concurrency") or 2)) async def one(u: str) -> None: async with sem: miss = {"kind": "profile", "platform": "threads", "username": u, "found": False} try: resp = await fetcher.get(PROFILE_URL.format(u=u), session_id=u) except Exception as exc: await Actor.push_data({**miss, "error": str(exc)[:200]}) return text = resp.text or "" fol = _FOLLOWERS_RE.search(text) if resp.status_code != 200 or not fol: await Actor.push_data( {**miss, "error": f"shell_or_{resp.status_code}"}) return posts = [] for m in _POST_RE.finditer(text): cap = _dec(m.group(1))[:500] if cap and all(p["caption"] != cap for p in posts): posts.append({"caption": cap, "likes": int(m.group(2))}) if len(posts) >= 15: break bio = _BIO_RE.search(text) name = _NAME_RE.search(text) ver = _VERIFIED_RE.search(text) pic = _PIC_RE.search(text) pk = _PK_RE.search(text) bio_links: list[str] = [] bl = _BIO_LINKS_RE.search(text) if bl: for m2 in _LINK_URL_RE.finditer(bl.group(1)): url2 = _dec(m2.group(1)) if url2 not in bio_links: bio_links.append(url2) likes = [p["likes"] for p in posts] top = max(posts, key=lambda p: p["likes"], default=None) await Actor.push_data({ "kind": "profile", "platform": "threads", "found": True, "username": u, "id": pk.group(1) if pk else None, "full_name": _dec(name.group(1)) if name else None, "biography": _dec(bio.group(1)) if bio else None, "bio_links": bio_links[:5] or None, "followers": int(fol.group(1)), "is_verified": (ver.group(1) == "true") if ver else None, "avatar": _dec(pic.group(1)) if pic else None, "avg_likes": round(sum(likes) / len(likes)) if likes else None, "top_post": top, "recent_posts": posts, }) await asyncio.gather(*[one(u) for u in usernames]) Actor.log.info(f"terminé : {len(usernames)} profils") def _dec(raw: str) -> str: """Décode une chaîne échappée JSON (\\uXXXX, \\/ …).""" try: return htmllib.unescape(json.loads(f'"{raw}"')) except Exception: return raw