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%
5.5 KB · 121 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   src/main.py (ka-onlyfans)4# Desc:   Profils OnlyFans publics (page de profil hors connexion) — extraction5#         best-effort des compteurs embarqués : posts, photos, vidéos, J'aime6#         (favoritedCount), prix d'abonnement, badge, bio, avatar/bannière.7#         EXPÉRIMENTAL : OnlyFans change souvent son rendu ; found=False n'est8#         pas une erreur du pipeline.9# ==============================================================================10from __future__ import annotations1112import asyncio13import html as htmllib14import json15import re1617from apify import Actor1819from .net import Fetcher2021PROFILE_URL = "https://onlyfans.com/{u}"2223_NUM_RES = {24    "posts_count": re.compile(r'"postsCount"\s*:\s*(\d+)'),25    "photos_count": re.compile(r'"photosCount"\s*:\s*(\d+)'),26    "videos_count": re.compile(r'"videosCount"\s*:\s*(\d+)'),27    "likes": re.compile(r'"favoritedCount"\s*:\s*(\d+)'),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+)'),31}32_JOIN_RE = re.compile(r'"joinDate"\s*:\s*"((?:[^"\\]|\\.)*)"')33_PERFORMER_RE = re.compile(r'"isPerformer"\s*:\s*(true|false)')34_PRICE_RE = re.compile(r'"subscribePrice"\s*:\s*([\d.]+)')35_STR_RES = {36    "full_name": re.compile(r'"name"\s*:\s*"((?:[^"\\]|\\.)*)"'),37    "biography": re.compile(r'"rawAbout"\s*:\s*"((?:[^"\\]|\\.)*)"'),38    "location": re.compile(r'"location"\s*:\s*"((?:[^"\\]|\\.)*)"'),39    "website": re.compile(r'"website"\s*:\s*"((?:[^"\\]|\\.)*)"'),40    "avatar": re.compile(r'"avatar"\s*:\s*"((?:[^"\\]|\\.)*)"'),41    "banner": re.compile(r'"header"\s*:\s*"((?:[^"\\]|\\.)*)"'),42}43_VERIFIED_RE = re.compile(r'"isVerified"\s*:\s*(true|false)')44_OG_TITLE_RE = re.compile(r'<meta property="og:title" content="([^"]*)"')454647def _dec(raw: str) -> str:48    try:49        return htmllib.unescape(json.loads(f'"{raw}"'))50    except Exception:51        return raw525354async def main() -> None:55    async with Actor:56        inp = await Actor.get_input() or {}57        usernames = [u.strip().lstrip("@").lower()58                     for u in (inp.get("usernames") or []) if u and u.strip()]59        proxy = await Actor.create_proxy_configuration(60            actor_proxy_input=inp.get("proxyConfiguration"))61        fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 2))62        sem = asyncio.Semaphore(int(inp.get("concurrency") or 2))6364        async def one(u: str) -> None:65            async with sem:66                miss = {"kind": "profile", "platform": "onlyfans",67                        "username": u, "found": False}68                resp = None69                for attempt in range(3):  # mur Cloudflare → nouvelle IP70                    try:71                        resp = await fetcher.get(PROFILE_URL.format(u=u),72                                                 session_id=f"{u}o{attempt}")73                    except Exception as exc:74                        await Actor.push_data({**miss,75                                               "error": str(exc)[:200]})76                        return77                    if resp.status_code == 404:78                        await Actor.push_data({**miss, "error": "http_404"})79                        return80                    if resp.status_code == 200 and \81                            "postsCount" in (resp.text or ""):82                        break83                text = resp.text or ""84                if resp.status_code != 200 or "postsCount" not in text:85                    await Actor.push_data(86                        {**miss, "error": f"wall_{resp.status_code}"})87                    return88                rec: dict = {"kind": "profile", "platform": "onlyfans",89                             "found": True, "username": u}90                for key, rx in _NUM_RES.items():91                    m = rx.search(text)92                    rec[key] = int(m.group(1)) if m else None93                for key, rx in _STR_RES.items():94                    m = rx.search(text)95                    rec[key] = _dec(m.group(1)) if m else None96                if rec.get("biography"):97                    rec["biography"] = re.sub(r"<[^>]+>", " ",98                                              rec["biography"])[:1000].strip()99                m = _PRICE_RE.search(text)100                rec["subscribe_price_usd"] = float(m.group(1)) if m else None101                rec["is_free"] = (rec["subscribe_price_usd"] == 0.0102                                  if rec["subscribe_price_usd"] is not None103                                  else None)104                m = _VERIFIED_RE.search(text)105                rec["is_verified"] = (m.group(1) == "true") if m else None106                m = _JOIN_RE.search(text)107                rec["join_date"] = _dec(m.group(1)) if m else None108                m = _PERFORMER_RE.search(text)109                rec["is_performer"] = (m.group(1) == "true") if m else None110                if not rec.get("full_name"):111                    m = _OG_TITLE_RE.search(text)112                    if m:113                        rec["full_name"] = htmllib.unescape(114                            m.group(1)).split(" OnlyFans")[0].strip()115                # « followers » au sens crea-ka : le compteur public = J'aime116                rec["followers"] = None117                await Actor.push_data(rec)118119        await asyncio.gather(*[one(u) for u in usernames])120        Actor.log.info(f"terminé : {len(usernames)} profils")121