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%
12.3 KB · 265 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   src/main.py (ka-instagram)4# Desc:   Profils Instagram publics ULTRA DÉTAILLÉS via l'endpoint web officiel5#         web_profile_info (JSON du web public) : identité complète, compteurs,6#         12 derniers posts (likes/commentaires/vues/légende/musique/lieu/7#         co-auteurs/épinglés), agrégats d'engagement et de cadence, profils8#         reliés — + recherche d'utilisateurs (topsearch) en découverte.9# ==============================================================================10from __future__ import annotations1112import asyncio13import json14import urllib.parse15from datetime import datetime, timezone1617from apify import Actor1819from .net import Fetcher2021PROFILE_API = ("https://i.instagram.com/api/v1/users/web_profile_info/"22               "?username={u}")23SEARCH_API = ("https://www.instagram.com/web/search/topsearch/"24              "?context=blended&query={q}&count=30")25SEARCH_API_ALT = ("https://i.instagram.com/api/v1/web/search/topsearch/"26                  "?context=blended&query={q}&count=30")27HDRS = {"x-ig-app-id": "936619743392459", "Accept": "application/json"}282930def _iso(ts) -> str | None:31    try:32        return datetime.fromtimestamp(int(ts), tz=timezone.utc) \33            .strftime("%Y-%m-%dT%H:%M:%SZ")34    except (TypeError, ValueError, OSError):35        return None363738def _avg(vals: list) -> int | None:39    vals = [v for v in vals if isinstance(v, (int, float))]40    return round(sum(vals) / len(vals)) if vals else None414243def parse_post(node: dict) -> dict:44    """Nœud média du fil → post détaillé (métriques, musique, lieu, épinglé)."""45    cap = (node.get("edge_media_to_caption") or {}).get("edges") or []46    music = node.get("clips_music_attribution_info") or {}47    dims = node.get("dimensions") or {}48    return {49        "id": node.get("id"),50        "shortcode": node.get("shortcode"),51        "url": f"https://www.instagram.com/p/{node.get('shortcode')}/",52        "type": ("reel" if node.get("product_type") == "clips"53                 else "video" if node.get("is_video") else54                 "carousel" if node.get("edge_sidecar_to_children")55                 else "image"),56        "caption": ((cap[0].get("node") or {}).get("text", "")57                    if cap else "")[:500],58        "likes": (node.get("edge_liked_by") or {}).get("count"),59        "comments": (node.get("edge_media_to_comment") or {}).get("count"),60        "video_views": node.get("video_view_count"),61        "video_duration_s": node.get("video_duration"),62        "timestamp": node.get("taken_at_timestamp"),63        "posted_at": _iso(node.get("taken_at_timestamp")),64        "display_url": node.get("display_url"),65        "thumbnail_url": node.get("thumbnail_src"),66        "width": dims.get("width"),67        "height": dims.get("height"),68        "is_pinned": bool(node.get("pinned_for_users")),69        "comments_disabled": node.get("comments_disabled"),70        "location": (node.get("location") or {}).get("name"),71        "coauthors": [c.get("username")72                      for c in (node.get("coauthor_producers") or [])73                      if c.get("username")],74        "music": ({"artist": music.get("artist_name"),75                   "song": music.get("song_name"),76                   "uses_original_audio": music.get("uses_original_audio")}77                  if music.get("song_name") or music.get("artist_name")78                  else None),79        "accessibility_caption": (node.get("accessibility_caption")80                                  or "")[:200] or None,81        "tagged_users": [((t.get("node") or {}).get("user") or {})82                         .get("username")83                         for t in ((node.get("edge_media_to_tagged_user")84                                    or {}).get("edges") or [])][:10],85    }868788def parse_user(user: dict) -> dict:89    """Objet user web_profile_info → enregistrement riche standardisé."""90    posts = [parse_post((e.get("node") or {}))91             for e in ((user.get("edge_owner_to_timeline_media") or {})92                       .get("edges") or [])]93    bio_ent = user.get("biography_with_entities") or {}94    bio_mentions, bio_hashtags = [], []95    for ent in (bio_ent.get("entities") or []):96        eu = ((ent or {}).get("user") or {}).get("username")97        eh = ((ent or {}).get("hashtag") or {}).get("name")98        if eu:99            bio_mentions.append(eu)100        if eh:101            bio_hashtags.append(eh)102    followers = (user.get("edge_followed_by") or {}).get("count")103    avg_likes = _avg([p["likes"] for p in posts])104    avg_comments = _avg([p["comments"] for p in posts])105    videos = [p for p in posts if p["type"] in ("video", "reel")]106    avg_video_views = _avg([p["video_views"] for p in videos])107    engagement = None108    if followers and avg_likes is not None:109        engagement = round(110            (avg_likes + (avg_comments or 0)) / followers * 100, 3)111    timestamps = sorted(p["timestamp"] for p in posts112                        if isinstance(p["timestamp"], int))113    posts_per_week = None114    if len(timestamps) >= 3 and timestamps[-1] > timestamps[0]:115        span_weeks = (timestamps[-1] - timestamps[0]) / 604_800116        posts_per_week = round((len(timestamps) - 1) / span_weeks, 2)117    top_post = max((p for p in posts if isinstance(p["likes"], int)),118                   key=lambda p: p["likes"], default=None)119    related = []120    for edge in ((user.get("edge_related_profiles") or {}).get("edges") or []):121        n = edge.get("node") or {}122        if n.get("username"):123            related.append({"username": n["username"],124                            "full_name": n.get("full_name"),125                            "is_verified": n.get("is_verified"),126                            "is_private": n.get("is_private")})127    return {128        "kind": "profile",129        "platform": "instagram",130        "found": True,131        "id": user.get("id"),132        "username": user.get("username"),133        "full_name": user.get("full_name"),134        "biography": user.get("biography"),135        "bio_mentions": bio_mentions[:10],   # @comptes cités dans la bio136        "bio_hashtags": bio_hashtags[:10],137        "pronouns": user.get("pronouns") or None,138        "external_url": user.get("external_url"),139        "bio_links": [b.get("url") for b in (user.get("bio_links") or [])140                      if b.get("url")],141        "followers": followers,142        "following": (user.get("edge_follow") or {}).get("count"),143        "posts_count": (user.get("edge_owner_to_timeline_media")144                        or {}).get("count"),145        "igtv_videos_count": (user.get("edge_felix_video_timeline")146                              or {}).get("count"),147        "mutual_followers": (user.get("edge_mutual_followed_by")148                             or {}).get("count"),149        "highlight_reels": user.get("highlight_reel_count"),150        "is_verified": user.get("is_verified"),151        "is_private": user.get("is_private"),152        "is_business": user.get("is_business_account"),153        "is_professional": user.get("is_professional_account"),154        "category": user.get("category_name"),155        "business_category": user.get("business_category_name"),156        "business_address": user.get("business_address_json"),157        # courriel PRO affiché publiquement par le créateur (§15 : public only)158        "business_email": user.get("business_email") or None,159        # présent = compte Threads relié (cross-link fort, même handle)160        "has_threads": (bool(user.get("has_onboarded_to_text_post_app"))161                        if "has_onboarded_to_text_post_app" in user162                        else None),163        "has_clips": user.get("has_clips"),164        "has_channel": user.get("has_channel"),165        "has_ar_effects": user.get("has_ar_effects"),166        "recently_joined": user.get("is_joined_recently"),167        "avatar": user.get("profile_pic_url_hd")168                  or user.get("profile_pic_url"),169        # agrégats calculés sur les 12 derniers posts170        "avg_likes": avg_likes,171        "avg_comments": avg_comments,172        "avg_video_views": avg_video_views,173        "engagement_rate_pct": engagement,174        "posts_per_week": posts_per_week,175        "last_post_at": _iso(timestamps[-1]) if timestamps else None,176        "video_share_pct": (round(len(videos) / len(posts) * 100)177                            if posts else None),178        "top_post": ({"url": top_post["url"], "likes": top_post["likes"],179                      "comments": top_post["comments"],180                      "type": top_post["type"]} if top_post else None),181        "recent_posts": posts,182        "related_profiles": related,183    }184185186async def main() -> None:187    async with Actor:188        inp = await Actor.get_input() or {}189        usernames = [u.strip().lstrip("@").lower()190                     for u in (inp.get("usernames") or []) if u and u.strip()]191        queries = [q.strip() for q in (inp.get("queries") or [])192                   if q and q.strip()]193        proxy = await Actor.create_proxy_configuration(194            actor_proxy_input=inp.get("proxyConfiguration"))195        fetcher = Fetcher(proxy, delay=float(inp.get("requestDelay") or 1))196        sem = asyncio.Semaphore(int(inp.get("concurrency") or 3))197198        async def one_profile(u: str) -> None:199            async with sem:200                miss = {"kind": "profile", "platform": "instagram",201                        "username": u, "found": False}202                try:203                    resp = await fetcher.get(PROFILE_API.format(u=u),204                                             headers=HDRS, session_id=u)205                except Exception as exc:206                    await Actor.push_data({**miss, "error": str(exc)[:200]})207                    return208                if resp.status_code == 404:209                    await Actor.push_data({**miss, "error": "http_404"})210                    return211                user = None212                if resp.status_code == 200:213                    try:214                        user = ((json.loads(resp.text).get("data") or {})215                                .get("user"))216                    except Exception:217                        user = None218                if not user:219                    await Actor.push_data(220                        {**miss, "error": f"http_{resp.status_code}"})221                    return222                await Actor.push_data(parse_user(user))223224        async def one_search(q: str) -> None:225            async with sem:226                data = {}227                for endpoint in (SEARCH_API, SEARCH_API_ALT):228                    u2 = endpoint.format(q=urllib.parse.quote(q))229                    try:230                        resp = await fetcher.get(u2, headers=HDRS,231                                                 session_id=f"srch{q[:4]}")232                    except Exception as exc:233                        Actor.log.warning(f"recherche '{q}' : {exc}")234                        continue235                    if resp.status_code == 200:236                        try:237                            data = json.loads(resp.text)238                        except Exception:239                            data = {}240                    if data.get("users"):241                        break242                if not data.get("users"):243                    Actor.log.warning(244                        f"recherche '{q}' : 0 user "245                        f"(http {resp.status_code}) {resp.text[:200]!r}")246                for item in data.get("users") or []:247                    u = item.get("user") or {}248                    if not u.get("username") or u.get("is_private"):249                        continue250                    await Actor.push_data({251                        "kind": "search_user",252                        "platform": "instagram",253                        "query": q,254                        "username": u.get("username"),255                        "full_name": u.get("full_name"),256                        "is_verified": u.get("is_verified"),257                        "followers": u.get("follower_count"),258                        "avatar": u.get("profile_pic_url"),259                    })260261        await asyncio.gather(*[one_profile(u) for u in usernames],262                             *[one_search(q) for q in queries])263        Actor.log.info(f"terminé : {len(usernames)} profils, "264                       f"{len(queries)} recherches")265