# ============================================================================== # Author: Simon-Pierre Boucher # File: src/main.py (qc-social-pulse) # Desc: Pouls social de l'élection québécoise 2026 — acteur maison # multi-plateformes, sans connexion, proxy résidentiel CA : # * instagram : comptes des partis/chef·fe·s (web_profile_info) → # 12 derniers posts (légende, likes, commentaires) + profil; # * facebook : pages publiques → abonnés (best-effort, FB mure); # * youtube : RECHERCHE de vidéos récentes par parti (ytInitialData, # videoRenderer + lockupViewModel) puis COMMENTAIRES PUBLICS via # l'API interne youtubei (opinion citoyenne réelle); # * tiktok : profils (__UNIVERSAL_DATA_FOR_REHYDRATION__) → stats; # * x : best-effort syndication (souvent mort — found=False). # Sortie : items {platform, party, kind: post|comment|profile|video, # text, url, created_at, engagement, meta} consommés par qc-election. # ============================================================================== from __future__ import annotations import asyncio import json import re import urllib.parse from datetime import datetime, timezone from apify import Actor from .net import Fetcher IG_PROFILE = ("https://i.instagram.com/api/v1/users/web_profile_info/" "?username={u}") IG_HDRS = {"x-ig-app-id": "936619743392459", "Accept": "application/json"} FB_PAGE = "https://www.facebook.com/{u}" TT_PAGE = "https://www.tiktok.com/@{u}" YT_SEARCH = ("https://www.youtube.com/results?search_query={q}&sp=EgQIAxAB" "&hl=fr&gl=CA") # sp=EgQIAxAB : téléversées cette semaine YT_NEXT = "https://www.youtube.com/youtubei/v1/next?prettyPrint=false" YT_CTX = {"context": {"client": {"clientName": "WEB", "clientVersion": "2.20250101.00.00", "hl": "fr", "gl": "CA"}}} X_SYND = ("https://cdn.syndication.twimg.com/timeline/profile" "?screen_name={u}&showReplies=false") _INITIAL_RE = re.compile(r"var ytInitialData\s*=\s*(\{.*?\});", re.S) _FB_FOLLOWERS = (re.compile(r'"follower_count"\s*:\s*(\d+)'), re.compile(r'"global_likers_count"\s*:\s*(\d+)')) _TT_UNIVERSAL = re.compile( r'', re.S) def _iso(ts) -> str | None: try: return datetime.fromtimestamp(int(ts), tz=timezone.utc) \ .strftime("%Y-%m-%dT%H:%M:%SZ") except (TypeError, ValueError, OSError): return None def _walk(node, key: str): """Tous les objets portant `key` dans un arbre JSON (générateur).""" if isinstance(node, dict): if key in node: yield node[key] for v in node.values(): yield from _walk(v, key) elif isinstance(node, list): for v in node: yield from _walk(v, key) def _runs_text(node) -> str: if not isinstance(node, dict): return "" if "simpleText" in node: return str(node["simpleText"]) return "".join(str(r.get("text", "")) for r in (node.get("runs") or [])) # ---------------------------------------------------------------- instagram -- IG_PROFILE_ALT = ("https://www.instagram.com/api/v1/users/web_profile_info/" "?username={u}") async def do_instagram(f: Fetcher, task: dict) -> list[dict]: u = task["target"] resp = None # IG répond parfois 400 (challenge) sur une IP donnée : on tourne les # sessions proxy et on alterne les deux hôtes de l'endpoint web. for attempt in range(4): url = (IG_PROFILE if attempt % 2 == 0 else IG_PROFILE_ALT) resp = await f.get(url.format(u=urllib.parse.quote(u)), headers={**IG_HDRS, "Referer": f"https://www.instagram.com/{u}/"}, session_id=f"ig{u}a{attempt}", retries=2) if resp.status_code == 200: break if resp.status_code == 404: # compte inexistant : inutile d'insister break await asyncio.sleep(1.5) if resp is None or resp.status_code != 200: return [{"platform": "instagram", "party": task["party"], "kind": "profile", "target": u, "found": False, "status": resp.status_code if resp is not None else None}] user = ((resp.json().get("data") or {}).get("user") or {}) if not user: return [{"platform": "instagram", "party": task["party"], "kind": "profile", "target": u, "found": False}] items: list[dict] = [] followers = (user.get("edge_followed_by") or {}).get("count") posts = [(e.get("node") or {}) for e in ((user.get("edge_owner_to_timeline_media") or {}).get("edges") or [])] for n in posts: cap = (n.get("edge_media_to_caption") or {}).get("edges") or [] likes = (n.get("edge_liked_by") or {}).get("count") or 0 comments = (n.get("edge_media_to_comment") or {}).get("count") or 0 items.append({ "platform": "instagram", "party": task["party"], "kind": "post", "target": u, "found": True, "text": ((cap[0].get("node") or {}).get("text", "") if cap else "")[:600], "url": f"https://www.instagram.com/p/{n.get('shortcode')}/", "created_at": _iso(n.get("taken_at_timestamp")), "engagement": likes + comments, "meta": {"likes": likes, "comments": comments, "video_views": n.get("video_view_count")}}) items.append({ "platform": "instagram", "party": task["party"], "kind": "profile", "target": u, "found": True, "url": f"https://www.instagram.com/{u}/", "engagement": followers or 0, "meta": {"followers": followers, "full_name": user.get("full_name"), "n_posts": (user.get("edge_owner_to_timeline_media") or {}).get("count")}}) return items # ----------------------------------------------------------------- facebook -- _FB_TEXT_COUNT = re.compile( r"([\d][\d\s .,]*)\s*([KkMm]?)\s*" r"(?:followers|abonnés|mentions\s+J[’']aime|J[’']aime|likes)", re.I) _FB_OGDESC = re.compile( r' int | None: m = _FB_TEXT_COUNT.search(text or "") if not m: return None num = re.sub(r"[\s ]", "", m.group(1)).replace(",", ".") try: val = float(num) except ValueError: return None unit = (m.group(2) or "").lower() return int(val * (1_000 if unit == "k" else 1_000_000 if unit == "m" else 1)) async def do_facebook(f: Fetcher, task: dict) -> list[dict]: u = task["target"] followers, status = None, None for url in (FB_PAGE.format(u=u), f"https://mbasic.facebook.com/{u}"): resp = await f.get(url, session_id=f"fb{u}", retries=3) status = resp.status_code txt = resp.text or "" for rx in _FB_FOLLOWERS: m = rx.search(txt) if m: followers = int(m.group(1)) break if followers is None: # texte « N abonnés » de l'og:description og = _FB_OGDESC.search(txt) if og: followers = _fb_text_count(og.group(1)) if followers is not None: break return [{"platform": "facebook", "party": task["party"], "kind": "profile", "target": u, "found": followers is not None, "url": FB_PAGE.format(u=u), "engagement": followers or 0, "meta": {"followers": followers, "status": status}}] # ------------------------------------------------------------------- tiktok -- async def do_tiktok(f: Fetcher, task: dict) -> list[dict]: u = task["target"] resp = await f.get(TT_PAGE.format(u=urllib.parse.quote(u)), session_id=f"tt{u}", retries=3) txt = resp.text or "" m = _TT_UNIVERSAL.search(txt) stats, user = {}, {} if m: try: data = json.loads(m.group(1)) ui = (((data.get("__DEFAULT_SCOPE__") or {}) .get("webapp.user-detail") or {}).get("userInfo") or {}) stats, user = ui.get("stats") or {}, ui.get("user") or {} except (json.JSONDecodeError, AttributeError): pass if not user: # repli : ancien état SIGI_STATE m2 = re.search(r'', txt, re.S) if m2: try: sigi = json.loads(m2.group(1)) um = (sigi.get("UserModule") or {}) user = next(iter((um.get("users") or {}).values()), {}) stats = next(iter((um.get("stats") or {}).values()), {}) except (json.JSONDecodeError, AttributeError): pass if not user: return [{"platform": "tiktok", "party": task["party"], "kind": "profile", "target": u, "found": False, "status": resp.status_code}] return [{"platform": "tiktok", "party": task["party"], "kind": "profile", "target": u, "found": bool(user), "url": TT_PAGE.format(u=u), "engagement": stats.get("followerCount") or 0, "meta": {"followers": stats.get("followerCount"), "hearts": stats.get("heartCount"), "videos": stats.get("videoCount"), "verified": user.get("verified")}}] # ------------------------------------------------------------------ youtube -- def _yt_videos(data: dict, cap: int) -> list[dict]: """videoRenderer (classique) + lockupViewModel (moderne) → vidéos.""" vids: list[dict] = [] for vr in _walk(data, "videoRenderer"): vid = vr.get("videoId") if not vid: continue vids.append({"videoId": vid, "title": _runs_text(vr.get("title") or {})[:200], "published": _runs_text(vr.get("publishedTimeText") or {}), "views": _runs_text(vr.get("viewCountText") or {}), "channel": _runs_text(vr.get("ownerText") or {})}) if len(vids) >= cap: return vids for lv in _walk(data, "lockupViewModel"): vid = ((lv.get("rendererContext") or {}).get("commandContext") or {}) vid = (((vid.get("onTap") or {}).get("innertubeCommand") or {}) .get("watchEndpoint") or {}).get("videoId") \ or lv.get("contentId") if not vid or any(v["videoId"] == vid for v in vids): continue md = (lv.get("metadata") or {}).get("lockupMetadataViewModel") or {} title = ((md.get("title") or {}).get("content") or "")[:200] vids.append({"videoId": vid, "title": title, "published": "", "views": "", "channel": ""}) if len(vids) >= cap: break return vids def _yt_comments(data: dict, cap: int) -> list[dict]: """Réponse youtubei/next (continuation) → commentaires publics. Format moderne : frameworkUpdates → commentEntityPayload; repli sur l'ancien commentRenderer.""" out: list[dict] = [] for payload in _walk(data, "commentEntityPayload"): props = payload.get("properties") or {} content = ((props.get("content") or {}).get("content") or "").strip() toolbar = payload.get("toolbar") or {} likes_txt = str(toolbar.get("likeCountNotliked") or toolbar.get("likeCountLiked") or "0").strip() m = re.search(r"[\d,.\s]+", likes_txt.replace(" ", " ")) likes = 0 if m: digits = re.sub(r"[^\d]", "", m.group()) likes = int(digits) if digits else 0 if "k" in likes_txt.lower(): likes *= 1000 if content: out.append({"text": content[:600], "likes": likes, "published": str(props.get("publishedTime") or "")}) if len(out) >= cap: return out if not out: # ancien format for cr in _walk(data, "commentRenderer"): content = _runs_text(cr.get("contentText") or {}).strip() if content: out.append({"text": content[:600], "likes": int(cr.get("likeCount") or 0), "published": _runs_text( cr.get("publishedTimeText") or {})}) if len(out) >= cap: break return out async def do_youtube(f: Fetcher, task: dict, max_comments: int) -> list[dict]: q = task.get("query") or task.get("target") or "" limit = int(task.get("limit") or 3) resp = await f.get(YT_SEARCH.format(q=urllib.parse.quote(q)), session_id=f"yt{abs(hash(q)) % 99999}") m = _INITIAL_RE.search(resp.text or "") if not m: return [{"platform": "youtube", "party": task["party"], "kind": "video", "found": False, "query": q, "status": resp.status_code}] try: data = json.loads(m.group(1)) except json.JSONDecodeError: return [{"platform": "youtube", "party": task["party"], "kind": "video", "found": False, "query": q}] items: list[dict] = [] for v in _yt_videos(data, limit): items.append({"platform": "youtube", "party": task["party"], "kind": "video", "found": True, "query": q, "text": v["title"], "url": f"https://www.youtube.com/watch?v={v['videoId']}", "created_at": None, "engagement": 0, "meta": {"published": v["published"], "views": v["views"], "channel": v["channel"]}}) if max_comments <= 0: continue # 1er appel next → jeton de continuation de la section commentaires body = dict(YT_CTX); body["videoId"] = v["videoId"] r1 = await f.post(YT_NEXT, data=json.dumps(body), headers={"Content-Type": "application/json"}, session_id=f"ytc{v['videoId']}") token = None try: d1 = r1.json() for isr in _walk(d1, "itemSectionRenderer"): if isr.get("sectionIdentifier") == "comment-item-section": for cont in _walk(isr, "continuationCommand"): token = cont.get("token") break if token: break if not token: # certains rendus placent le jeton ailleurs for cont in _walk(d1, "continuationCommand"): tok = cont.get("token") or "" if tok.startswith("Eg"): token = tok break except (json.JSONDecodeError, AttributeError): token = None if not token: continue body2 = dict(YT_CTX); body2["continuation"] = token r2 = await f.post(YT_NEXT, data=json.dumps(body2), headers={"Content-Type": "application/json"}, session_id=f"ytc{v['videoId']}") try: comments = _yt_comments(r2.json(), max_comments) except (json.JSONDecodeError, AttributeError): comments = [] for c in comments: items.append({"platform": "youtube", "party": task["party"], "kind": "comment", "found": True, "query": q, "text": c["text"], "url": f"https://www.youtube.com/watch?v={v['videoId']}", "created_at": None, "engagement": c["likes"], "meta": {"video_title": v["title"], "published": c["published"]}}) return items # ------------------------------------------------------------------------ x -- async def do_x(f: Fetcher, task: dict) -> list[dict]: u = task["target"] resp = await f.get(X_SYND.format(u=urllib.parse.quote(u)), session_id=f"x{u}", retries=2) ok = resp.status_code == 200 and "timeline" in (resp.text or "")[:2000].lower() return [{"platform": "x", "party": task["party"], "kind": "profile", "target": u, "found": ok, "status": resp.status_code}] HANDLERS = {"instagram": do_instagram, "facebook": do_facebook, "tiktok": do_tiktok, "x": do_x} async def main() -> None: async with Actor: inp = await Actor.get_input() or {} tasks = inp.get("tasks") or [] max_comments = int(inp.get("maxCommentsPerVideo") or 20) country = (inp.get("proxyCountry") or "CA").upper() proxy_conf = await Actor.create_proxy_configuration( groups=["RESIDENTIAL"], country_code=country) f = Fetcher(proxy_conf, delay=1.2) pushed = 0 for task in tasks: platform = (task.get("platform") or "").lower().strip() try: if platform == "youtube": items = await do_youtube(f, task, max_comments) elif platform in HANDLERS: items = await HANDLERS[platform](f, task) else: items = [{"platform": platform, "found": False, "error": "plateforme inconnue"}] except Exception as exc: # une tâche ne tue jamais le run Actor.log.exception(f"tâche {task}: {exc}") items = [{"platform": platform, "party": task.get("party"), "found": False, "error": str(exc)[:200]}] fetched = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") for it in items: it["fetched_at"] = fetched await Actor.push_data(items) pushed += len(items) Actor.log.info(f"terminé : {pushed} items pour {len(tasks)} tâches")