Pouls social continu — actor Apify maison qc-social-pulse + sources ouvertes
- Actor Apify maison (compte gorgeous_thistle, id aI5AYtFfeDn3KAanf) : Instagram (comptes officiels vérifiés : coalition_avenir_quebec, partiquebecois, psppqc, liberalquebec, quebecsolidaire, ericduhaimequebec, frechette_christine), Facebook/TikTok best-effort, YouTube recherche + COMMENTAIRES PUBLICS (innertube) — proxy résidentiel CA, retries rotation IP, source dans apify/qc-social-pulse/ - ingest/social_pulse.py : + Reddit r/Quebec (index Google/Serper), Mastodon, Lemmy.ca; stance dirigée + entité de contexte; pondération engagement 1+ln(1+votes); anti-bruit (dédup URL+texte intra-lot et DB, longueur min, plafonds); indice enthousiasme par parti (Indicator); anomalies → événements - Table social_posts; API /api/social; section « Pouls social » (intelligence) + ligne au point du jour; pipeline à chaque cycle; poids NUL dans le forecast - Test réel : 185 posts (74 commentaires YouTube, 40 Mastodon, 71 Lemmy), enthousiasme CAQ 261/PCQ 206/PLQ 132 eng/post Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 changed files +1,119 −0
modified
.env.example
+5 −0
@@ -8,6 +8,11 @@ ADMIN_TOKEN=changez-moi | ||
| 8 | 8 | # Clé Firecrawl (v2 — veille web continue : radar sondages, satisfaction, presse) |
| 9 | 9 | # FIRECRAWL_API_KEY= |
| 10 | 10 | |
| 11 | +# Pouls social (v2.1) — acteur Apify maison qc-social-pulse + Reddit via Serper | |
| 12 | +# APIFY_TOKEN= | |
| 13 | +# SERPER_API_KEY= | |
| 14 | +# QCE_SOCIAL_PULSE_ENABLED=true | |
| 15 | + | |
| 11 | 16 | # Couches v2 « au-delà des sondages » (débrayables) |
| 12 | 17 | # QCE_FUNDAMENTALS_ENABLED=true |
| 13 | 18 | # QCE_BYELECTIONS_ENABLED=true |
added
apify/qc-social-pulse/.actor/Dockerfile
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: Dockerfile (qc-social-pulse) | |
| 4 | +# ============================================================================== | |
| 5 | +FROM apify/actor-python:3.12 | |
| 6 | +COPY requirements.txt ./ | |
| 7 | +RUN pip install --no-cache-dir -r requirements.txt | |
| 8 | +COPY . ./ | |
| 9 | +CMD ["python3", "-m", "src"] | |
added
apify/qc-social-pulse/.actor/actor.json
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "actorSpecification": 1, | |
| 3 | + "name": "qc-social-pulse", | |
| 4 | + "title": "QC Social Pulse — élection Québec 2026", | |
| 5 | + "description": "Pouls social multi-plateformes (Instagram, Facebook, YouTube + commentaires publics, TikTok) des partis québécois : posts, engagement, commentaires citoyens. Proxy résidentiel CA, sans connexion. Alimente le signal « Pouls social » de qc-election.com.", | |
| 6 | + "version": "0.1", | |
| 7 | + "input": "./input_schema.json", | |
| 8 | + "meta": { "templateId": "python-empty" } | |
| 9 | +} | |
added
apify/qc-social-pulse/.actor/input_schema.json
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +{ | |
| 2 | + "title": "QC Social Pulse", | |
| 3 | + "type": "object", | |
| 4 | + "schemaVersion": 1, | |
| 5 | + "properties": { | |
| 6 | + "tasks": { | |
| 7 | + "title": "Tâches", | |
| 8 | + "type": "array", | |
| 9 | + "description": "[{platform: instagram|facebook|youtube|tiktok|x, party: CAQ|PLQ|PQ|QS|PCQ, target?: compte, query?: recherche, limit?: n}]", | |
| 10 | + "editor": "json", | |
| 11 | + "prefill": [ | |
| 12 | + { | |
| 13 | + "platform": "youtube", | |
| 14 | + "party": "PQ", | |
| 15 | + "query": "Parti québécois élections 2026", | |
| 16 | + "limit": 3 | |
| 17 | + } | |
| 18 | + ] | |
| 19 | + }, | |
| 20 | + "maxCommentsPerVideo": { | |
| 21 | + "title": "Commentaires max par vidéo", | |
| 22 | + "type": "integer", | |
| 23 | + "default": 20, | |
| 24 | + "minimum": 0, | |
| 25 | + "maximum": 100, | |
| 26 | + "description": "Nombre maximal de commentaires publics récoltés par vidéo YouTube.", | |
| 27 | + "editor": "number" | |
| 28 | + }, | |
| 29 | + "proxyCountry": { | |
| 30 | + "title": "Pays du proxy", | |
| 31 | + "type": "string", | |
| 32 | + "default": "CA", | |
| 33 | + "editor": "textfield", | |
| 34 | + "description": "Code pays du proxy résidentiel Apify (défaut CA)." | |
| 35 | + } | |
| 36 | + }, | |
| 37 | + "required": [ | |
| 38 | + "tasks" | |
| 39 | + ] | |
| 40 | +} | |
| \ No newline at end of file | ||
added
apify/qc-social-pulse/requirements.txt
+6 −0
@@ -0,0 +1,6 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: requirements.txt (qc-social-pulse) | |
| 4 | +# ============================================================================== | |
| 5 | +apify | |
| 6 | +curl_cffi>=0.9 | |
added
apify/qc-social-pulse/src/__main__.py
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: src/__main__.py (qc-social-pulse) | |
| 4 | +# ============================================================================== | |
| 5 | +import asyncio | |
| 6 | + | |
| 7 | +from .main import main | |
| 8 | + | |
| 9 | +asyncio.run(main()) | |
added
apify/qc-social-pulse/src/main.py
+405 −0
@@ -0,0 +1,405 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: src/main.py (qc-social-pulse) | |
| 4 | +# Desc: Pouls social de l'élection québécoise 2026 — acteur maison | |
| 5 | +# multi-plateformes, sans connexion, proxy résidentiel CA : | |
| 6 | +# * instagram : comptes des partis/chef·fe·s (web_profile_info) → | |
| 7 | +# 12 derniers posts (légende, likes, commentaires) + profil; | |
| 8 | +# * facebook : pages publiques → abonnés (best-effort, FB mure); | |
| 9 | +# * youtube : RECHERCHE de vidéos récentes par parti (ytInitialData, | |
| 10 | +# videoRenderer + lockupViewModel) puis COMMENTAIRES PUBLICS via | |
| 11 | +# l'API interne youtubei (opinion citoyenne réelle); | |
| 12 | +# * tiktok : profils (__UNIVERSAL_DATA_FOR_REHYDRATION__) → stats; | |
| 13 | +# * x : best-effort syndication (souvent mort — found=False). | |
| 14 | +# Sortie : items {platform, party, kind: post|comment|profile|video, | |
| 15 | +# text, url, created_at, engagement, meta} consommés par qc-election. | |
| 16 | +# ============================================================================== | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import asyncio | |
| 20 | +import json | |
| 21 | +import re | |
| 22 | +import urllib.parse | |
| 23 | +from datetime import datetime, timezone | |
| 24 | + | |
| 25 | +from apify import Actor | |
| 26 | + | |
| 27 | +from .net import Fetcher | |
| 28 | + | |
| 29 | +IG_PROFILE = ("https://i.instagram.com/api/v1/users/web_profile_info/" | |
| 30 | + "?username={u}") | |
| 31 | +IG_HDRS = {"x-ig-app-id": "936619743392459", "Accept": "application/json"} | |
| 32 | +FB_PAGE = "https://www.facebook.com/{u}" | |
| 33 | +TT_PAGE = "https://www.tiktok.com/@{u}" | |
| 34 | +YT_SEARCH = ("https://www.youtube.com/results?search_query={q}&sp=EgQIAxAB" | |
| 35 | + "&hl=fr&gl=CA") # sp=EgQIAxAB : téléversées cette semaine | |
| 36 | +YT_NEXT = "https://www.youtube.com/youtubei/v1/next?prettyPrint=false" | |
| 37 | +YT_CTX = {"context": {"client": {"clientName": "WEB", | |
| 38 | + "clientVersion": "2.20250101.00.00", | |
| 39 | + "hl": "fr", "gl": "CA"}}} | |
| 40 | +X_SYND = ("https://cdn.syndication.twimg.com/timeline/profile" | |
| 41 | + "?screen_name={u}&showReplies=false") | |
| 42 | + | |
| 43 | +_INITIAL_RE = re.compile(r"var ytInitialData\s*=\s*(\{.*?\});</script>", re.S) | |
| 44 | +_FB_FOLLOWERS = (re.compile(r'"follower_count"\s*:\s*(\d+)'), | |
| 45 | + re.compile(r'"global_likers_count"\s*:\s*(\d+)')) | |
| 46 | +_TT_UNIVERSAL = re.compile( | |
| 47 | + r'<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(\{.*?\})</script>', | |
| 48 | + re.S) | |
| 49 | + | |
| 50 | + | |
| 51 | +def _iso(ts) -> str | None: | |
| 52 | + try: | |
| 53 | + return datetime.fromtimestamp(int(ts), tz=timezone.utc) \ | |
| 54 | + .strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 55 | + except (TypeError, ValueError, OSError): | |
| 56 | + return None | |
| 57 | + | |
| 58 | + | |
| 59 | +def _walk(node, key: str): | |
| 60 | + """Tous les objets portant `key` dans un arbre JSON (générateur).""" | |
| 61 | + if isinstance(node, dict): | |
| 62 | + if key in node: | |
| 63 | + yield node[key] | |
| 64 | + for v in node.values(): | |
| 65 | + yield from _walk(v, key) | |
| 66 | + elif isinstance(node, list): | |
| 67 | + for v in node: | |
| 68 | + yield from _walk(v, key) | |
| 69 | + | |
| 70 | + | |
| 71 | +def _runs_text(node) -> str: | |
| 72 | + if not isinstance(node, dict): | |
| 73 | + return "" | |
| 74 | + if "simpleText" in node: | |
| 75 | + return str(node["simpleText"]) | |
| 76 | + return "".join(str(r.get("text", "")) for r in (node.get("runs") or [])) | |
| 77 | + | |
| 78 | + | |
| 79 | +# ---------------------------------------------------------------- instagram -- | |
| 80 | +IG_PROFILE_ALT = ("https://www.instagram.com/api/v1/users/web_profile_info/" | |
| 81 | + "?username={u}") | |
| 82 | + | |
| 83 | + | |
| 84 | +async def do_instagram(f: Fetcher, task: dict) -> list[dict]: | |
| 85 | + u = task["target"] | |
| 86 | + resp = None | |
| 87 | + # IG répond parfois 400 (challenge) sur une IP donnée : on tourne les | |
| 88 | + # sessions proxy et on alterne les deux hôtes de l'endpoint web. | |
| 89 | + for attempt in range(4): | |
| 90 | + url = (IG_PROFILE if attempt % 2 == 0 else IG_PROFILE_ALT) | |
| 91 | + resp = await f.get(url.format(u=urllib.parse.quote(u)), | |
| 92 | + headers={**IG_HDRS, | |
| 93 | + "Referer": f"https://www.instagram.com/{u}/"}, | |
| 94 | + session_id=f"ig{u}a{attempt}", retries=2) | |
| 95 | + if resp.status_code == 200: | |
| 96 | + break | |
| 97 | + if resp.status_code == 404: # compte inexistant : inutile d'insister | |
| 98 | + break | |
| 99 | + await asyncio.sleep(1.5) | |
| 100 | + if resp is None or resp.status_code != 200: | |
| 101 | + return [{"platform": "instagram", "party": task["party"], | |
| 102 | + "kind": "profile", "target": u, "found": False, | |
| 103 | + "status": resp.status_code if resp is not None else None}] | |
| 104 | + user = ((resp.json().get("data") or {}).get("user") or {}) | |
| 105 | + if not user: | |
| 106 | + return [{"platform": "instagram", "party": task["party"], | |
| 107 | + "kind": "profile", "target": u, "found": False}] | |
| 108 | + items: list[dict] = [] | |
| 109 | + followers = (user.get("edge_followed_by") or {}).get("count") | |
| 110 | + posts = [(e.get("node") or {}) for e in | |
| 111 | + ((user.get("edge_owner_to_timeline_media") or {}).get("edges") or [])] | |
| 112 | + for n in posts: | |
| 113 | + cap = (n.get("edge_media_to_caption") or {}).get("edges") or [] | |
| 114 | + likes = (n.get("edge_liked_by") or {}).get("count") or 0 | |
| 115 | + comments = (n.get("edge_media_to_comment") or {}).get("count") or 0 | |
| 116 | + items.append({ | |
| 117 | + "platform": "instagram", "party": task["party"], "kind": "post", | |
| 118 | + "target": u, "found": True, | |
| 119 | + "text": ((cap[0].get("node") or {}).get("text", "") if cap else "")[:600], | |
| 120 | + "url": f"https://www.instagram.com/p/{n.get('shortcode')}/", | |
| 121 | + "created_at": _iso(n.get("taken_at_timestamp")), | |
| 122 | + "engagement": likes + comments, | |
| 123 | + "meta": {"likes": likes, "comments": comments, | |
| 124 | + "video_views": n.get("video_view_count")}}) | |
| 125 | + items.append({ | |
| 126 | + "platform": "instagram", "party": task["party"], "kind": "profile", | |
| 127 | + "target": u, "found": True, | |
| 128 | + "url": f"https://www.instagram.com/{u}/", | |
| 129 | + "engagement": followers or 0, | |
| 130 | + "meta": {"followers": followers, | |
| 131 | + "full_name": user.get("full_name"), | |
| 132 | + "n_posts": (user.get("edge_owner_to_timeline_media") or {}).get("count")}}) | |
| 133 | + return items | |
| 134 | + | |
| 135 | + | |
| 136 | +# ----------------------------------------------------------------- facebook -- | |
| 137 | +_FB_TEXT_COUNT = re.compile( | |
| 138 | + r"([\d][\d\s .,]*)\s*([KkMm]?)\s*" | |
| 139 | + r"(?:followers|abonnés|mentions\s+J[’']aime|J[’']aime|likes)", re.I) | |
| 140 | +_FB_OGDESC = re.compile( | |
| 141 | + r'<meta (?:property="og:description"|name="description") content="([^"]*)"') | |
| 142 | + | |
| 143 | + | |
| 144 | +def _fb_text_count(text: str) -> int | None: | |
| 145 | + m = _FB_TEXT_COUNT.search(text or "") | |
| 146 | + if not m: | |
| 147 | + return None | |
| 148 | + num = re.sub(r"[\s ]", "", m.group(1)).replace(",", ".") | |
| 149 | + try: | |
| 150 | + val = float(num) | |
| 151 | + except ValueError: | |
| 152 | + return None | |
| 153 | + unit = (m.group(2) or "").lower() | |
| 154 | + return int(val * (1_000 if unit == "k" else 1_000_000 if unit == "m" else 1)) | |
| 155 | + | |
| 156 | + | |
| 157 | +async def do_facebook(f: Fetcher, task: dict) -> list[dict]: | |
| 158 | + u = task["target"] | |
| 159 | + followers, status = None, None | |
| 160 | + for url in (FB_PAGE.format(u=u), f"https://mbasic.facebook.com/{u}"): | |
| 161 | + resp = await f.get(url, session_id=f"fb{u}", retries=3) | |
| 162 | + status = resp.status_code | |
| 163 | + txt = resp.text or "" | |
| 164 | + for rx in _FB_FOLLOWERS: | |
| 165 | + m = rx.search(txt) | |
| 166 | + if m: | |
| 167 | + followers = int(m.group(1)) | |
| 168 | + break | |
| 169 | + if followers is None: # texte « N abonnés » de l'og:description | |
| 170 | + og = _FB_OGDESC.search(txt) | |
| 171 | + if og: | |
| 172 | + followers = _fb_text_count(og.group(1)) | |
| 173 | + if followers is not None: | |
| 174 | + break | |
| 175 | + return [{"platform": "facebook", "party": task["party"], "kind": "profile", | |
| 176 | + "target": u, "found": followers is not None, | |
| 177 | + "url": FB_PAGE.format(u=u), "engagement": followers or 0, | |
| 178 | + "meta": {"followers": followers, "status": status}}] | |
| 179 | + | |
| 180 | + | |
| 181 | +# ------------------------------------------------------------------- tiktok -- | |
| 182 | +async def do_tiktok(f: Fetcher, task: dict) -> list[dict]: | |
| 183 | + u = task["target"] | |
| 184 | + resp = await f.get(TT_PAGE.format(u=urllib.parse.quote(u)), | |
| 185 | + session_id=f"tt{u}", retries=3) | |
| 186 | + txt = resp.text or "" | |
| 187 | + m = _TT_UNIVERSAL.search(txt) | |
| 188 | + stats, user = {}, {} | |
| 189 | + if m: | |
| 190 | + try: | |
| 191 | + data = json.loads(m.group(1)) | |
| 192 | + ui = (((data.get("__DEFAULT_SCOPE__") or {}) | |
| 193 | + .get("webapp.user-detail") or {}).get("userInfo") or {}) | |
| 194 | + stats, user = ui.get("stats") or {}, ui.get("user") or {} | |
| 195 | + except (json.JSONDecodeError, AttributeError): | |
| 196 | + pass | |
| 197 | + if not user: # repli : ancien état SIGI_STATE | |
| 198 | + m2 = re.search(r'<script id="SIGI_STATE"[^>]*>(\{.*?\})</script>', | |
| 199 | + txt, re.S) | |
| 200 | + if m2: | |
| 201 | + try: | |
| 202 | + sigi = json.loads(m2.group(1)) | |
| 203 | + um = (sigi.get("UserModule") or {}) | |
| 204 | + user = next(iter((um.get("users") or {}).values()), {}) | |
| 205 | + stats = next(iter((um.get("stats") or {}).values()), {}) | |
| 206 | + except (json.JSONDecodeError, AttributeError): | |
| 207 | + pass | |
| 208 | + if not user: | |
| 209 | + return [{"platform": "tiktok", "party": task["party"], | |
| 210 | + "kind": "profile", "target": u, "found": False, | |
| 211 | + "status": resp.status_code}] | |
| 212 | + return [{"platform": "tiktok", "party": task["party"], "kind": "profile", | |
| 213 | + "target": u, "found": bool(user), | |
| 214 | + "url": TT_PAGE.format(u=u), | |
| 215 | + "engagement": stats.get("followerCount") or 0, | |
| 216 | + "meta": {"followers": stats.get("followerCount"), | |
| 217 | + "hearts": stats.get("heartCount"), | |
| 218 | + "videos": stats.get("videoCount"), | |
| 219 | + "verified": user.get("verified")}}] | |
| 220 | + | |
| 221 | + | |
| 222 | +# ------------------------------------------------------------------ youtube -- | |
| 223 | +def _yt_videos(data: dict, cap: int) -> list[dict]: | |
| 224 | + """videoRenderer (classique) + lockupViewModel (moderne) → vidéos.""" | |
| 225 | + vids: list[dict] = [] | |
| 226 | + for vr in _walk(data, "videoRenderer"): | |
| 227 | + vid = vr.get("videoId") | |
| 228 | + if not vid: | |
| 229 | + continue | |
| 230 | + vids.append({"videoId": vid, | |
| 231 | + "title": _runs_text(vr.get("title") or {})[:200], | |
| 232 | + "published": _runs_text(vr.get("publishedTimeText") or {}), | |
| 233 | + "views": _runs_text(vr.get("viewCountText") or {}), | |
| 234 | + "channel": _runs_text(vr.get("ownerText") or {})}) | |
| 235 | + if len(vids) >= cap: | |
| 236 | + return vids | |
| 237 | + for lv in _walk(data, "lockupViewModel"): | |
| 238 | + vid = ((lv.get("rendererContext") or {}).get("commandContext") or {}) | |
| 239 | + vid = (((vid.get("onTap") or {}).get("innertubeCommand") or {}) | |
| 240 | + .get("watchEndpoint") or {}).get("videoId") \ | |
| 241 | + or lv.get("contentId") | |
| 242 | + if not vid or any(v["videoId"] == vid for v in vids): | |
| 243 | + continue | |
| 244 | + md = (lv.get("metadata") or {}).get("lockupMetadataViewModel") or {} | |
| 245 | + title = ((md.get("title") or {}).get("content") or "")[:200] | |
| 246 | + vids.append({"videoId": vid, "title": title, "published": "", | |
| 247 | + "views": "", "channel": ""}) | |
| 248 | + if len(vids) >= cap: | |
| 249 | + break | |
| 250 | + return vids | |
| 251 | + | |
| 252 | + | |
| 253 | +def _yt_comments(data: dict, cap: int) -> list[dict]: | |
| 254 | + """Réponse youtubei/next (continuation) → commentaires publics. | |
| 255 | + Format moderne : frameworkUpdates → commentEntityPayload; repli sur | |
| 256 | + l'ancien commentRenderer.""" | |
| 257 | + out: list[dict] = [] | |
| 258 | + for payload in _walk(data, "commentEntityPayload"): | |
| 259 | + props = payload.get("properties") or {} | |
| 260 | + content = ((props.get("content") or {}).get("content") or "").strip() | |
| 261 | + toolbar = payload.get("toolbar") or {} | |
| 262 | + likes_txt = str(toolbar.get("likeCountNotliked") or | |
| 263 | + toolbar.get("likeCountLiked") or "0").strip() | |
| 264 | + m = re.search(r"[\d,.\s]+", likes_txt.replace(" ", " ")) | |
| 265 | + likes = 0 | |
| 266 | + if m: | |
| 267 | + digits = re.sub(r"[^\d]", "", m.group()) | |
| 268 | + likes = int(digits) if digits else 0 | |
| 269 | + if "k" in likes_txt.lower(): | |
| 270 | + likes *= 1000 | |
| 271 | + if content: | |
| 272 | + out.append({"text": content[:600], "likes": likes, | |
| 273 | + "published": str(props.get("publishedTime") or "")}) | |
| 274 | + if len(out) >= cap: | |
| 275 | + return out | |
| 276 | + if not out: # ancien format | |
| 277 | + for cr in _walk(data, "commentRenderer"): | |
| 278 | + content = _runs_text(cr.get("contentText") or {}).strip() | |
| 279 | + if content: | |
| 280 | + out.append({"text": content[:600], | |
| 281 | + "likes": int(cr.get("likeCount") or 0), | |
| 282 | + "published": _runs_text( | |
| 283 | + cr.get("publishedTimeText") or {})}) | |
| 284 | + if len(out) >= cap: | |
| 285 | + break | |
| 286 | + return out | |
| 287 | + | |
| 288 | + | |
| 289 | +async def do_youtube(f: Fetcher, task: dict, max_comments: int) -> list[dict]: | |
| 290 | + q = task.get("query") or task.get("target") or "" | |
| 291 | + limit = int(task.get("limit") or 3) | |
| 292 | + resp = await f.get(YT_SEARCH.format(q=urllib.parse.quote(q)), | |
| 293 | + session_id=f"yt{abs(hash(q)) % 99999}") | |
| 294 | + m = _INITIAL_RE.search(resp.text or "") | |
| 295 | + if not m: | |
| 296 | + return [{"platform": "youtube", "party": task["party"], | |
| 297 | + "kind": "video", "found": False, "query": q, | |
| 298 | + "status": resp.status_code}] | |
| 299 | + try: | |
| 300 | + data = json.loads(m.group(1)) | |
| 301 | + except json.JSONDecodeError: | |
| 302 | + return [{"platform": "youtube", "party": task["party"], | |
| 303 | + "kind": "video", "found": False, "query": q}] | |
| 304 | + items: list[dict] = [] | |
| 305 | + for v in _yt_videos(data, limit): | |
| 306 | + items.append({"platform": "youtube", "party": task["party"], | |
| 307 | + "kind": "video", "found": True, "query": q, | |
| 308 | + "text": v["title"], | |
| 309 | + "url": f"https://www.youtube.com/watch?v={v['videoId']}", | |
| 310 | + "created_at": None, | |
| 311 | + "engagement": 0, | |
| 312 | + "meta": {"published": v["published"], "views": v["views"], | |
| 313 | + "channel": v["channel"]}}) | |
| 314 | + if max_comments <= 0: | |
| 315 | + continue | |
| 316 | + # 1er appel next → jeton de continuation de la section commentaires | |
| 317 | + body = dict(YT_CTX); body["videoId"] = v["videoId"] | |
| 318 | + r1 = await f.post(YT_NEXT, data=json.dumps(body), | |
| 319 | + headers={"Content-Type": "application/json"}, | |
| 320 | + session_id=f"ytc{v['videoId']}") | |
| 321 | + token = None | |
| 322 | + try: | |
| 323 | + d1 = r1.json() | |
| 324 | + for isr in _walk(d1, "itemSectionRenderer"): | |
| 325 | + if isr.get("sectionIdentifier") == "comment-item-section": | |
| 326 | + for cont in _walk(isr, "continuationCommand"): | |
| 327 | + token = cont.get("token") | |
| 328 | + break | |
| 329 | + if token: | |
| 330 | + break | |
| 331 | + if not token: # certains rendus placent le jeton ailleurs | |
| 332 | + for cont in _walk(d1, "continuationCommand"): | |
| 333 | + tok = cont.get("token") or "" | |
| 334 | + if tok.startswith("Eg"): | |
| 335 | + token = tok | |
| 336 | + break | |
| 337 | + except (json.JSONDecodeError, AttributeError): | |
| 338 | + token = None | |
| 339 | + if not token: | |
| 340 | + continue | |
| 341 | + body2 = dict(YT_CTX); body2["continuation"] = token | |
| 342 | + r2 = await f.post(YT_NEXT, data=json.dumps(body2), | |
| 343 | + headers={"Content-Type": "application/json"}, | |
| 344 | + session_id=f"ytc{v['videoId']}") | |
| 345 | + try: | |
| 346 | + comments = _yt_comments(r2.json(), max_comments) | |
| 347 | + except (json.JSONDecodeError, AttributeError): | |
| 348 | + comments = [] | |
| 349 | + for c in comments: | |
| 350 | + items.append({"platform": "youtube", "party": task["party"], | |
| 351 | + "kind": "comment", "found": True, "query": q, | |
| 352 | + "text": c["text"], | |
| 353 | + "url": f"https://www.youtube.com/watch?v={v['videoId']}", | |
| 354 | + "created_at": None, | |
| 355 | + "engagement": c["likes"], | |
| 356 | + "meta": {"video_title": v["title"], | |
| 357 | + "published": c["published"]}}) | |
| 358 | + return items | |
| 359 | + | |
| 360 | + | |
| 361 | +# ------------------------------------------------------------------------ x -- | |
| 362 | +async def do_x(f: Fetcher, task: dict) -> list[dict]: | |
| 363 | + u = task["target"] | |
| 364 | + resp = await f.get(X_SYND.format(u=urllib.parse.quote(u)), | |
| 365 | + session_id=f"x{u}", retries=2) | |
| 366 | + ok = resp.status_code == 200 and "timeline" in (resp.text or "")[:2000].lower() | |
| 367 | + return [{"platform": "x", "party": task["party"], "kind": "profile", | |
| 368 | + "target": u, "found": ok, "status": resp.status_code}] | |
| 369 | + | |
| 370 | + | |
| 371 | +HANDLERS = {"instagram": do_instagram, "facebook": do_facebook, | |
| 372 | + "tiktok": do_tiktok, "x": do_x} | |
| 373 | + | |
| 374 | + | |
| 375 | +async def main() -> None: | |
| 376 | + async with Actor: | |
| 377 | + inp = await Actor.get_input() or {} | |
| 378 | + tasks = inp.get("tasks") or [] | |
| 379 | + max_comments = int(inp.get("maxCommentsPerVideo") or 20) | |
| 380 | + country = (inp.get("proxyCountry") or "CA").upper() | |
| 381 | + proxy_conf = await Actor.create_proxy_configuration( | |
| 382 | + groups=["RESIDENTIAL"], country_code=country) | |
| 383 | + f = Fetcher(proxy_conf, delay=1.2) | |
| 384 | + pushed = 0 | |
| 385 | + for task in tasks: | |
| 386 | + platform = (task.get("platform") or "").lower().strip() | |
| 387 | + try: | |
| 388 | + if platform == "youtube": | |
| 389 | + items = await do_youtube(f, task, max_comments) | |
| 390 | + elif platform in HANDLERS: | |
| 391 | + items = await HANDLERS[platform](f, task) | |
| 392 | + else: | |
| 393 | + items = [{"platform": platform, "found": False, | |
| 394 | + "error": "plateforme inconnue"}] | |
| 395 | + except Exception as exc: # une tâche ne tue jamais le run | |
| 396 | + Actor.log.exception(f"tâche {task}: {exc}") | |
| 397 | + items = [{"platform": platform, | |
| 398 | + "party": task.get("party"), "found": False, | |
| 399 | + "error": str(exc)[:200]}] | |
| 400 | + fetched = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | |
| 401 | + for it in items: | |
| 402 | + it["fetched_at"] = fetched | |
| 403 | + await Actor.push_data(items) | |
| 404 | + pushed += len(items) | |
| 405 | + Actor.log.info(f"terminé : {pushed} items pour {len(tasks)} tâches") | |
added
apify/qc-social-pulse/src/net.py
+97 −0
@@ -0,0 +1,97 @@ | ||
| 1 | +# ============================================================================== | |
| 2 | +# Author: Simon-Pierre Boucher <contact@spboucher.ai> | |
| 3 | +# File: src/net.py | |
| 4 | +# Desc: Fetch commun des acteurs KA — curl_cffi (empreinte TLS Chrome) + | |
| 5 | +# proxy Apify (résidentiel par défaut), throttling poli, retries à | |
| 6 | +# backoff EXPONENTIEL + jitter sur 403/407/408/425/429/500/502/503/504 | |
| 7 | +# avec rotation de session proxy (nouvelle IP) à chaque tentative. | |
| 8 | +# GET et POST partagent le même moteur de retries. | |
| 9 | +# ============================================================================== | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import asyncio | |
| 13 | +import random | |
| 14 | +import re | |
| 15 | + | |
| 16 | +from apify import Actor | |
| 17 | +from curl_cffi import requests as cffi | |
| 18 | + | |
| 19 | +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " | |
| 20 | + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") | |
| 21 | + | |
| 22 | +# statuts transitoires : mur anti-bot, proxy, rate-limit, erreurs serveur | |
| 23 | +RETRY_STATUSES = frozenset({403, 407, 408, 425, 429, 500, 502, 503, 504}) | |
| 24 | + | |
| 25 | + | |
| 26 | +def _backoff(attempt: int) -> float: | |
| 27 | + """Backoff exponentiel plafonné + jitter (anti-troupeau).""" | |
| 28 | + return min(1.2 * (2 ** attempt), 12.0) + random.uniform(0.0, 0.8) | |
| 29 | + | |
| 30 | + | |
| 31 | +class Fetcher: | |
| 32 | + """HTTP poli : proxy Apify + impersonation Chrome + retries robustes.""" | |
| 33 | + | |
| 34 | + def __init__(self, proxy_configuration, delay: float = 1.0) -> None: | |
| 35 | + self.proxy_configuration = proxy_configuration | |
| 36 | + self.delay = delay | |
| 37 | + self._lock = asyncio.Lock() | |
| 38 | + self._last = 0.0 | |
| 39 | + | |
| 40 | + async def _throttle(self) -> None: | |
| 41 | + async with self._lock: | |
| 42 | + now = asyncio.get_event_loop().time() | |
| 43 | + wait = self.delay - (now - self._last) | |
| 44 | + if wait > 0: | |
| 45 | + await asyncio.sleep(wait) | |
| 46 | + self._last = asyncio.get_event_loop().time() | |
| 47 | + | |
| 48 | + async def request(self, method: str, url: str, | |
| 49 | + headers: dict | None = None, data=None, | |
| 50 | + retries: int = 4, session_id: str | None = None, | |
| 51 | + impersonate: str | None = "chrome"): | |
| 52 | + hdrs = {"User-Agent": UA, | |
| 53 | + "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} | |
| 54 | + if headers: | |
| 55 | + hdrs.update(headers) | |
| 56 | + last_exc: Exception | None = None | |
| 57 | + for attempt in range(retries): | |
| 58 | + await self._throttle() | |
| 59 | + proxy = None | |
| 60 | + if self.proxy_configuration: | |
| 61 | + sid = session_id or f"s{random.randint(1, 999_999)}" | |
| 62 | + sid = re.sub(r"[^\w._~]", "", sid)[:40] or "s" | |
| 63 | + # nouvelle session proxy (nouvelle IP) à chaque tentative | |
| 64 | + proxy = await self.proxy_configuration.new_url( | |
| 65 | + session_id=f"{sid}r{attempt}") | |
| 66 | + try: | |
| 67 | + resp = await asyncio.to_thread( | |
| 68 | + cffi.request, method, url, headers=hdrs, data=data, | |
| 69 | + impersonate=impersonate, timeout=60, allow_redirects=True, | |
| 70 | + proxies={"http": proxy, "https": proxy} if proxy else None) | |
| 71 | + if resp.status_code in RETRY_STATUSES \ | |
| 72 | + and attempt < retries - 1: | |
| 73 | + Actor.log.warning( | |
| 74 | + f"HTTP {resp.status_code} {url} — retry {attempt + 1}") | |
| 75 | + await asyncio.sleep(_backoff(attempt)) | |
| 76 | + continue | |
| 77 | + return resp | |
| 78 | + except Exception as exc: # réseau/proxy : on retente | |
| 79 | + last_exc = exc | |
| 80 | + await asyncio.sleep(_backoff(attempt)) | |
| 81 | + if last_exc: | |
| 82 | + raise last_exc | |
| 83 | + raise RuntimeError(f"échec après {retries} tentatives : {url}") | |
| 84 | + | |
| 85 | + async def get(self, url: str, headers: dict | None = None, | |
| 86 | + retries: int = 4, session_id: str | None = None, | |
| 87 | + impersonate: str | None = "chrome"): | |
| 88 | + return await self.request("GET", url, headers=headers, | |
| 89 | + retries=retries, session_id=session_id, | |
| 90 | + impersonate=impersonate) | |
| 91 | + | |
| 92 | + async def post(self, url: str, data=None, headers: dict | None = None, | |
| 93 | + retries: int = 4, session_id: str | None = None, | |
| 94 | + impersonate: str | None = "chrome"): | |
| 95 | + return await self.request("POST", url, data=data, headers=headers, | |
| 96 | + retries=retries, session_id=session_id, | |
| 97 | + impersonate=impersonate) | |
modified
backend/app/api/public.py
+8 −0
@@ -219,6 +219,14 @@ def signals(db: Session = Depends(get_db), kind: str | None = None, | ||
| 219 | 219 | for s in rows]} |
| 220 | 220 | |
| 221 | 221 | |
| 222 | +@router.get("/social") | |
| 223 | +def social(db: Session = Depends(get_db), days: int = Query(14, le=60)): | |
| 224 | + """Pouls social continu (YouTube/Reddit/Mastodon/Lemmy + engagement des | |
| 225 | + comptes officiels via acteur Apify maison) — poids nul dans le forecast.""" | |
| 226 | + from ..ingest.social_pulse import pulse_summary | |
| 227 | + return pulse_summary(db, days=days) | |
| 228 | + | |
| 229 | + | |
| 222 | 230 | @router.get("/attention") |
| 223 | 231 | def attention(db: Session = Depends(get_db)): |
| 224 | 232 | """Attention Wikipédia : parts, tendances, turbulence (signal non directionnel).""" |
modified
backend/app/config.py
+5 −0
@@ -119,6 +119,11 @@ class Settings(BaseSettings): | ||
| 119 | 119 | firecrawl_api_key: str = os.environ.get("FIRECRAWL_API_KEY", "") |
| 120 | 120 | firecrawl_enabled: bool = True |
| 121 | 121 | firecrawl_max_docs_per_cycle: int = 40 |
| 122 | + # Pouls social continu : acteur Apify maison (qc-social-pulse) + Reddit via | |
| 123 | + # index Google (Serper) + Mastodon + Lemmy — poids nul dans le forecast. | |
| 124 | + social_pulse_enabled: bool = True | |
| 125 | + apify_token: str = os.environ.get("APIFY_TOKEN", "") | |
| 126 | + serper_api_key: str = os.environ.get("SERPER_API_KEY", "") | |
| 122 | 127 | |
| 123 | 128 | # --- Sentiment (signal affiché; n'agit que via l'ajustement médias borné) --- |
| 124 | 129 | sentiment_feeds: list[str] = [ |
added
backend/app/ingest/social_pulse.py
+414 −0
@@ -0,0 +1,414 @@ | ||
| 1 | +# QC Élection Forecast — Plateforme de prévision électorale du Québec 2026 | |
| 2 | +# Auteur : Simon-Pierre Boucher | |
| 3 | +# Contact : contact@spboucher.ai | |
| 4 | +# https://www.qc-election.com | |
| 5 | +"""Pouls social CONTINU — sentiment des médias sociaux à chaque cycle (3-6 h). | |
| 6 | + | |
| 7 | +Deux couches complémentaires, toutes plateformes confondues : | |
| 8 | + | |
| 9 | +* **Opinion citoyenne (texte)** — ce que les gens ÉCRIVENT : | |
| 10 | + - commentaires publics YouTube des vidéos électorales de la semaine | |
| 11 | + (acteur Apify maison `qc-social-pulse`, proxy résidentiel CA); | |
| 12 | + - Reddit r/Quebec via l'index Google (Serper) — titres + extraits réels; | |
| 13 | + - Mastodon (fils de tags publics) et Lemmy.ca (API ouverte). | |
| 14 | + Chaque texte passe par le moteur de stance dirigée (négations, attaques) et | |
| 15 | + est PONDÉRÉ PAR L'ENGAGEMENT : w = 1 + ln(1 + votes/likes). | |
| 16 | + | |
| 17 | +* **Enthousiasme (engagement)** — ce que les gens FONT : abonnés et engagement | |
| 18 | + des comptes officiels des partis/chef·fe·s (Instagram/Facebook/TikTok via | |
| 19 | + l'acteur maison). La littérature montre que l'engagement relatif corrèle | |
| 20 | + avec le vote; publié comme indice, jamais injecté dans le forecast. | |
| 21 | + | |
| 22 | +Anti-bruit : dédoublonnage URL + empreinte de texte, longueur minimale, | |
| 23 | +plafonds par plateforme et par cycle. Anomalies de volume → événements. | |
| 24 | +**Poids dans le forecast : nul** (signal affiché, évalué après le 5 octobre). | |
| 25 | +""" | |
| 26 | +from __future__ import annotations | |
| 27 | + | |
| 28 | +import hashlib | |
| 29 | +import json | |
| 30 | +import logging | |
| 31 | +import math | |
| 32 | +import re | |
| 33 | +from datetime import date, datetime, timedelta, timezone | |
| 34 | + | |
| 35 | +import httpx | |
| 36 | +import numpy as np | |
| 37 | +from sqlalchemy.orm import Session | |
| 38 | + | |
| 39 | +from ..config import settings | |
| 40 | +from .. import models as Mo | |
| 41 | +from .news_rss import (ENTITY_ALIASES, NEG_WORDS, POS_WORDS, detect_entities, | |
| 42 | + lexicon_score) | |
| 43 | + | |
| 44 | +log = logging.getLogger("social-pulse") | |
| 45 | + | |
| 46 | +APIFY_ACTOR = "aI5AYtFfeDn3KAanf" # qc-social-pulse (compte gorgeous_thistle) | |
| 47 | +APIFY_RUN = ("https://api.apify.com/v2/acts/{act}/run-sync-get-dataset-items" | |
| 48 | + "?token={token}&timeout=240&memory=1024") | |
| 49 | + | |
| 50 | +# Comptes officiels vérifiés (Serper, 2026-08-30) | |
| 51 | +SOCIAL_TARGETS = { | |
| 52 | + "instagram": [("CAQ", "coalition_avenir_quebec"), ("CAQ", "frechette_christine"), | |
| 53 | + ("PQ", "partiquebecois"), ("PQ", "psppqc"), | |
| 54 | + ("PLQ", "liberalquebec"), ("QS", "quebecsolidaire"), | |
| 55 | + ("PCQ", "ericduhaimequebec")], | |
| 56 | + "facebook": [("CAQ", "coalitionavenirquebec"), ("PQ", "partiquebecois"), | |
| 57 | + ("PLQ", "LiberalQuebec"), ("QS", "quebecsolidaire"), | |
| 58 | + ("PCQ", "ericduhaime")], | |
| 59 | + "tiktok": [("PQ", "partiquebecois"), ("QS", "quebecsolidaire"), | |
| 60 | + ("PCQ", "ericduhaime")], | |
| 61 | +} | |
| 62 | +YT_QUERIES = {"CAQ": "CAQ Christine Fréchette élections Québec", | |
| 63 | + "PQ": "Parti québécois PSPP élections", | |
| 64 | + "PLQ": "Charles Milliard parti libéral Québec", | |
| 65 | + "QS": "Québec solidaire élections", | |
| 66 | + "PCQ": "Éric Duhaime conservateur Québec"} | |
| 67 | + | |
| 68 | +MIN_TEXT_LEN = 25 | |
| 69 | +MAX_PER_PLATFORM = 80 # plafond de nouveaux posts par plateforme/cycle | |
| 70 | +SERPER_URL = "https://google.serper.dev/search" | |
| 71 | +MASTODON_TAGS = ["quebec", "polqc", "assnat"] | |
| 72 | +MASTODON_HOSTS = ["https://mastodon.social", "https://mstdn.ca"] | |
| 73 | +LEMMY_SEARCH = ("https://lemmy.ca/api/v3/search?q={q}&type_=Posts" | |
| 74 | + "&sort=New&listing_type=All&limit=15") | |
| 75 | + | |
| 76 | + | |
| 77 | +def _th(text: str) -> str: | |
| 78 | + toks = sorted(re.findall(r"[a-zà-ü0-9]{3,}", text.lower()))[:24] | |
| 79 | + return hashlib.sha1(" ".join(toks).encode()).hexdigest()[:16] | |
| 80 | + | |
| 81 | + | |
| 82 | +def _score_text(text: str, fallback_party: str | None = None) -> dict: | |
| 83 | + """Stance dirigée par entité détectée; repli sur le parti du contexte | |
| 84 | + (ex. commentaire sous une vidéo « CAQ » qui ne nomme aucun parti).""" | |
| 85 | + detected = detect_entities(text) | |
| 86 | + entities = detected or ([fallback_party] if fallback_party else []) | |
| 87 | + scores = {} | |
| 88 | + for ent in entities: | |
| 89 | + if ent not in ENTITY_ALIASES: | |
| 90 | + continue | |
| 91 | + if ent in detected: | |
| 92 | + s, stance = lexicon_score(text, ent) | |
| 93 | + else: | |
| 94 | + # entité de contexte (ex. commentaire sous une vidéo « CAQ » qui ne | |
| 95 | + # nomme personne) : tonalité générale du texte, dirigée vers elle | |
| 96 | + toks = re.findall(r"[a-zà-ü']+", text.lower()) | |
| 97 | + pos = sum(1 for t in toks if t in POS_WORDS) | |
| 98 | + neg = sum(1 for t in toks if t in NEG_WORDS) | |
| 99 | + tot = pos + neg | |
| 100 | + s = float(np.clip((pos - neg) / tot, -1, 1)) if tot else 0.0 | |
| 101 | + stance = ("pro" if s > 0.25 else "anti" if s < -0.25 | |
| 102 | + else "neutre" if abs(s) < 0.1 else "ambigu") | |
| 103 | + scores[ent] = {"sentiment": round(s, 3), "stance": stance, | |
| 104 | + "method": "lexicon"} | |
| 105 | + return scores | |
| 106 | + | |
| 107 | + | |
| 108 | +def _add_post(db: Session, *, platform: str, url: str, text: str, | |
| 109 | + engagement: float, created_at: datetime | None, | |
| 110 | + community: str | None, fallback_party: str | None, | |
| 111 | + query: str | None, seen_hashes: set) -> bool: | |
| 112 | + text = (text or "").strip() | |
| 113 | + if len(text) < MIN_TEXT_LEN or not url: | |
| 114 | + return False | |
| 115 | + th = _th(text) | |
| 116 | + if th in seen_hashes or f"u:{url}" in seen_hashes: | |
| 117 | + return False | |
| 118 | + if db.query(Mo.SocialPost).filter_by(url=url).first(): | |
| 119 | + return False | |
| 120 | + if db.query(Mo.SocialPost).filter_by(text_hash=th).first(): | |
| 121 | + return False | |
| 122 | + scores = _score_text(text, fallback_party) | |
| 123 | + if not scores: | |
| 124 | + return False | |
| 125 | + seen_hashes.add(th) | |
| 126 | + seen_hashes.add(f"u:{url}") | |
| 127 | + db.add(Mo.SocialPost(platform=platform, community=community, url=url, | |
| 128 | + author_hash=None, text=text[:800], text_hash=th, | |
| 129 | + engagement=float(engagement or 0), scores=scores, | |
| 130 | + created_at=created_at, query=query)) | |
| 131 | + db.flush() | |
| 132 | + return True | |
| 133 | + | |
| 134 | + | |
| 135 | +# --------------------------------------------------------------------------- | |
| 136 | +# Sources | |
| 137 | +# --------------------------------------------------------------------------- | |
| 138 | +def collect_apify(db: Session, seen: set) -> dict: | |
| 139 | + """Acteur maison qc-social-pulse : YouTube (commentaires publics) + | |
| 140 | + Instagram/Facebook/TikTok (engagement des comptes officiels).""" | |
| 141 | + if not settings.apify_token: | |
| 142 | + return {"skipped": "APIFY_TOKEN absent"} | |
| 143 | + tasks = [{"platform": "youtube", "party": p, "query": q, "limit": 2} | |
| 144 | + for p, q in YT_QUERIES.items()] | |
| 145 | + for platform, pairs in SOCIAL_TARGETS.items(): | |
| 146 | + tasks += [{"platform": platform, "party": p, "target": t} | |
| 147 | + for p, t in pairs] | |
| 148 | + try: | |
| 149 | + r = httpx.post(APIFY_RUN.format(act=APIFY_ACTOR, | |
| 150 | + token=settings.apify_token), | |
| 151 | + json={"tasks": tasks, "maxCommentsPerVideo": 12}, | |
| 152 | + timeout=280) | |
| 153 | + r.raise_for_status() | |
| 154 | + items = r.json() | |
| 155 | + except Exception as e: | |
| 156 | + return {"échec": str(e)[:200]} | |
| 157 | + n_opinion = 0 | |
| 158 | + engagement: dict[str, dict] = {} | |
| 159 | + for it in items: | |
| 160 | + if not it.get("found"): | |
| 161 | + continue | |
| 162 | + party, kind, plat = it.get("party"), it.get("kind"), it["platform"] | |
| 163 | + if kind == "comment": | |
| 164 | + url = f"{it['url']}#c{_th(it.get('text') or '')[:8]}" | |
| 165 | + if _add_post(db, platform="youtube", url=url, | |
| 166 | + text=it.get("text") or "", | |
| 167 | + engagement=it.get("engagement") or 0, | |
| 168 | + created_at=None, community=(it.get("meta") or {} | |
| 169 | + ).get("video_title"), | |
| 170 | + fallback_party=party, query=it.get("query"), | |
| 171 | + seen_hashes=seen): | |
| 172 | + n_opinion += 1 | |
| 173 | + elif kind in ("post", "profile"): | |
| 174 | + e = engagement.setdefault(party, {"followers": 0, "post_eng": [], | |
| 175 | + "platforms": set()}) | |
| 176 | + meta = it.get("meta") or {} | |
| 177 | + if kind == "profile" and meta.get("followers"): | |
| 178 | + e["followers"] += int(meta["followers"]) | |
| 179 | + e["platforms"].add(plat) | |
| 180 | + if kind == "post": | |
| 181 | + e["post_eng"].append(float(it.get("engagement") or 0)) | |
| 182 | + # indice d'enthousiasme quotidien par parti (Indicator, provenance actor) | |
| 183 | + today = date.today() | |
| 184 | + for party, e in engagement.items(): | |
| 185 | + if not e["followers"] and not e["post_eng"]: | |
| 186 | + continue | |
| 187 | + avg_eng = float(np.mean(e["post_eng"])) if e["post_eng"] else 0.0 | |
| 188 | + row = (db.query(Mo.Indicator) | |
| 189 | + .filter_by(name=f"social_engagement_{party}", as_of=today).first()) | |
| 190 | + if row is None: | |
| 191 | + row = Mo.Indicator(name=f"social_engagement_{party}", as_of=today, | |
| 192 | + value=0.0) | |
| 193 | + db.add(row) | |
| 194 | + row.value = round(avg_eng, 1) | |
| 195 | + row.source = "Acteur Apify qc-social-pulse (comptes officiels)" | |
| 196 | + row.method = "apify-actor" | |
| 197 | + row.extra = {"followers_total": e["followers"], | |
| 198 | + "avg_post_engagement": round(avg_eng, 1), | |
| 199 | + "n_posts": len(e["post_eng"]), | |
| 200 | + "platforms": sorted(e["platforms"])} | |
| 201 | + db.commit() | |
| 202 | + return {"items_acteur": len(items), "commentaires_opinion": n_opinion, | |
| 203 | + "partis_engagement": sorted(engagement)} | |
| 204 | + | |
| 205 | + | |
| 206 | +def collect_reddit_serper(db: Session, seen: set) -> dict: | |
| 207 | + """Reddit r/Quebec via l'index Google (Serper) : titres + extraits réels.""" | |
| 208 | + if not settings.serper_api_key: | |
| 209 | + return {"skipped": "SERPER_API_KEY absent"} | |
| 210 | + added = 0 | |
| 211 | + for party in ENTITY_ALIASES: | |
| 212 | + try: | |
| 213 | + r = httpx.post(SERPER_URL, timeout=20, | |
| 214 | + headers={"X-API-KEY": settings.serper_api_key, | |
| 215 | + "Content-Type": "application/json"}, | |
| 216 | + json={"q": f"site:reddit.com/r/Quebec {party}", | |
| 217 | + "tbs": "qdr:d", "gl": "ca", "hl": "fr", | |
| 218 | + "num": 10}) | |
| 219 | + r.raise_for_status() | |
| 220 | + except Exception as e: | |
| 221 | + log.warning("serper reddit %s: %s", party, e) | |
| 222 | + continue | |
| 223 | + for res in (r.json().get("organic") or []): | |
| 224 | + text = f"{res.get('title', '')}. {res.get('snippet', '')}" | |
| 225 | + if _add_post(db, platform="reddit", url=res.get("link", ""), | |
| 226 | + text=text, engagement=1.0, created_at=None, | |
| 227 | + community="r/Quebec", fallback_party=party, | |
| 228 | + query=party, seen_hashes=seen): | |
| 229 | + added += 1 | |
| 230 | + db.commit() | |
| 231 | + return {"posts": added} | |
| 232 | + | |
| 233 | + | |
| 234 | +def collect_mastodon(db: Session, seen: set) -> dict: | |
| 235 | + added = 0 | |
| 236 | + for host in MASTODON_HOSTS: | |
| 237 | + for tag in MASTODON_TAGS: | |
| 238 | + try: | |
| 239 | + r = httpx.get(f"{host}/api/v1/timelines/tag/{tag}?limit=40", | |
| 240 | + timeout=15) | |
| 241 | + r.raise_for_status() | |
| 242 | + except Exception: | |
| 243 | + continue | |
| 244 | + for st in r.json(): | |
| 245 | + text = re.sub(r"<[^>]+>", " ", st.get("content") or "") | |
| 246 | + if not detect_entities(text): | |
| 247 | + continue | |
| 248 | + eng = (st.get("favourites_count") or 0) + \ | |
| 249 | + (st.get("reblogs_count") or 0) | |
| 250 | + created = None | |
| 251 | + try: | |
| 252 | + created = datetime.fromisoformat( | |
| 253 | + st["created_at"].replace("Z", "+00:00")) | |
| 254 | + except (KeyError, ValueError): | |
| 255 | + pass | |
| 256 | + if _add_post(db, platform="mastodon", url=st.get("url") or "", | |
| 257 | + text=text, engagement=eng, created_at=created, | |
| 258 | + community=f"#{tag}", fallback_party=None, | |
| 259 | + query=tag, seen_hashes=seen): | |
| 260 | + added += 1 | |
| 261 | + db.commit() | |
| 262 | + return {"posts": added} | |
| 263 | + | |
| 264 | + | |
| 265 | +def collect_lemmy(db: Session, seen: set) -> dict: | |
| 266 | + added = 0 | |
| 267 | + for party in ENTITY_ALIASES: | |
| 268 | + try: | |
| 269 | + r = httpx.get(LEMMY_SEARCH.format(q=party), timeout=15, | |
| 270 | + headers={"User-Agent": "qc-election/2.1"}) | |
| 271 | + r.raise_for_status() | |
| 272 | + except Exception: | |
| 273 | + continue | |
| 274 | + for p in (r.json().get("posts") or []): | |
| 275 | + post, counts = p.get("post") or {}, p.get("counts") or {} | |
| 276 | + text = f"{post.get('name', '')}. {(post.get('body') or '')[:400]}" | |
| 277 | + created = None | |
| 278 | + try: | |
| 279 | + created = datetime.fromisoformat( | |
| 280 | + (post.get("published") or "").replace("Z", "+00:00")) | |
| 281 | + except ValueError: | |
| 282 | + pass | |
| 283 | + if _add_post(db, platform="lemmy", url=post.get("ap_id") or "", | |
| 284 | + text=text, engagement=counts.get("score") or 0, | |
| 285 | + created_at=created, | |
| 286 | + community=(p.get("community") or {}).get("name"), | |
| 287 | + fallback_party=party, query=party, seen_hashes=seen): | |
| 288 | + added += 1 | |
| 289 | + db.commit() | |
| 290 | + return {"posts": added} | |
| 291 | + | |
| 292 | + | |
| 293 | +# --------------------------------------------------------------------------- | |
| 294 | +def detect_social_anomalies(db: Session, z_threshold: float = 2.5) -> int: | |
| 295 | + """Pic de volume social par parti (z vs 14 jours) → événement.""" | |
| 296 | + today = date.today() | |
| 297 | + created = 0 | |
| 298 | + for party in ENTITY_ALIASES: | |
| 299 | + counts = [] | |
| 300 | + for back in range(15): | |
| 301 | + d0 = datetime(today.year, today.month, today.day, | |
| 302 | + tzinfo=timezone.utc) - timedelta(days=back) | |
| 303 | + n = 0 | |
| 304 | + for post in (db.query(Mo.SocialPost) | |
| 305 | + .filter(Mo.SocialPost.fetched_at >= d0, | |
| 306 | + Mo.SocialPost.fetched_at < d0 + timedelta(days=1)) | |
| 307 | + .all()): | |
| 308 | + if party in (post.scores or {}): | |
| 309 | + n += 1 | |
| 310 | + counts.append(n) | |
| 311 | + base = counts[1:] | |
| 312 | + if len(base) < 7 or np.std(base) == 0: | |
| 313 | + continue | |
| 314 | + z = (counts[0] - np.mean(base)) / np.std(base) | |
| 315 | + if z >= z_threshold and counts[0] >= 8: | |
| 316 | + if not (db.query(Mo.NewsEvent) | |
| 317 | + .filter_by(event_date=today, kind="anomalie-sociale").count()): | |
| 318 | + db.add(Mo.NewsEvent( | |
| 319 | + event_date=today, kind="anomalie-sociale", | |
| 320 | + title=f"Pic de conversation sociale — {settings.party_names[party]}", | |
| 321 | + description=f"Volume {counts[0]} vs moyenne {np.mean(base):.1f} (z={z:.1f})", | |
| 322 | + parties=[party], importance=min(0.9, 0.4 + z / 10), | |
| 323 | + detected_by="social-pulse")) | |
| 324 | + created += 1 | |
| 325 | + db.commit() | |
| 326 | + return created | |
| 327 | + | |
| 328 | + | |
| 329 | +def run_pulse(db: Session) -> dict: | |
| 330 | + """Cycle complet du pouls social — chaque source est isolée.""" | |
| 331 | + if not settings.social_pulse_enabled: | |
| 332 | + return {"skipped": "pouls social désactivé"} | |
| 333 | + seen: set = set() | |
| 334 | + report: dict = {} | |
| 335 | + for name, fn in [("acteur_apify", collect_apify), | |
| 336 | + ("reddit", collect_reddit_serper), | |
| 337 | + ("mastodon", collect_mastodon), | |
| 338 | + ("lemmy", collect_lemmy)]: | |
| 339 | + try: | |
| 340 | + report[name] = fn(db, seen) | |
| 341 | + except Exception as e: | |
| 342 | + db.rollback() | |
| 343 | + report[name] = f"échec: {e}" | |
| 344 | + try: | |
| 345 | + report["anomalies"] = detect_social_anomalies(db) | |
| 346 | + except Exception as e: | |
| 347 | + report["anomalies"] = f"échec: {e}" | |
| 348 | + return report | |
| 349 | + | |
| 350 | + | |
| 351 | +# --------------------------------------------------------------------------- | |
| 352 | +def pulse_summary(db: Session, days: int = 14) -> dict: | |
| 353 | + """Indice « Pouls social » par parti : volume, sentiment pondéré par | |
| 354 | + l'engagement, part de voix, tendance, verbatims les plus engagés.""" | |
| 355 | + cutoff = datetime.now(timezone.utc) - timedelta(days=days) | |
| 356 | + posts = (db.query(Mo.SocialPost) | |
| 357 | + .filter(Mo.SocialPost.fetched_at >= cutoff).all()) | |
| 358 | + parties = [p for p in settings.parties if p != "AUT"] | |
| 359 | + out = {p: {"volume": 0, "wsum": 0.0, "wtot": 0.0, "stances": {}, | |
| 360 | + "timeline": {}, "recent": []} for p in parties} | |
| 361 | + platforms: dict[str, int] = {} | |
| 362 | + for post in posts: | |
| 363 | + w = 1.0 + math.log1p(max(0.0, post.engagement)) | |
| 364 | + d = (post.created_at or post.fetched_at).date().isoformat() | |
| 365 | + platforms[post.platform] = platforms.get(post.platform, 0) + 1 | |
| 366 | + for party, sc in (post.scores or {}).items(): | |
| 367 | + if party not in out: | |
| 368 | + continue | |
| 369 | + o = out[party] | |
| 370 | + o["volume"] += 1 | |
| 371 | + o["wsum"] += sc["sentiment"] * w | |
| 372 | + o["wtot"] += w | |
| 373 | + o["stances"][sc["stance"]] = o["stances"].get(sc["stance"], 0) + 1 | |
| 374 | + tl = o["timeline"].setdefault(d, [0.0, 0.0]) | |
| 375 | + tl[0] += sc["sentiment"] * w | |
| 376 | + tl[1] += w | |
| 377 | + o["recent"].append((w, post)) | |
| 378 | + total_volume = sum(o["volume"] for o in out.values()) or 1 | |
| 379 | + result = {} | |
| 380 | + for p in parties: | |
| 381 | + o = out[p] | |
| 382 | + top = sorted(o["recent"], key=lambda t: -t[0])[:3] | |
| 383 | + result[p] = { | |
| 384 | + "volume": o["volume"], | |
| 385 | + "part_de_voix": round(o["volume"] / total_volume * 100, 1), | |
| 386 | + "sentiment_pondere": (round(o["wsum"] / o["wtot"], 3) | |
| 387 | + if o["wtot"] else None), | |
| 388 | + "stances": o["stances"], | |
| 389 | + "timeline": [{"date": d, "sentiment": round(v[0] / v[1], 3), | |
| 390 | + "poids": round(v[1], 1)} | |
| 391 | + for d, v in sorted(o["timeline"].items())], | |
| 392 | + "verbatims": [{"platform": post.platform, "url": post.url, | |
| 393 | + "engagement": post.engagement, | |
| 394 | + "text": post.text[:220], | |
| 395 | + "stance": (post.scores.get(p) or {}).get("stance")} | |
| 396 | + for _, post in top], | |
| 397 | + } | |
| 398 | + # enthousiasme (engagement des comptes officiels, dernier point) | |
| 399 | + enthusiasm = {} | |
| 400 | + for p in parties: | |
| 401 | + row = (db.query(Mo.Indicator) | |
| 402 | + .filter_by(name=f"social_engagement_{p}") | |
| 403 | + .order_by(Mo.Indicator.as_of.desc()).first()) | |
| 404 | + if row: | |
| 405 | + enthusiasm[p] = {"as_of": row.as_of.isoformat(), | |
| 406 | + "avg_post_engagement": row.value, **(row.extra or {})} | |
| 407 | + return {"note": ("Pouls social — poids NUL dans le forecast. Sentiment " | |
| 408 | + "pondéré par l'engagement (1+ln(1+votes)); sources : " | |
| 409 | + "commentaires YouTube (acteur maison), Reddit r/Quebec " | |
| 410 | + "(index Google), Mastodon, Lemmy; enthousiasme : comptes " | |
| 411 | + "officiels via acteur Apify maison."), | |
| 412 | + "posts_total": len(posts), "platforms": platforms, | |
| 413 | + "parties": result, "enthusiasm": enthusiasm, | |
| 414 | + "days": days} | |
modified
backend/app/models.py
+22 −0
@@ -261,6 +261,28 @@ class Indicator(Base): | ||
| 261 | 261 | extra: Mapped[dict] = mapped_column(JSON, default=dict) |
| 262 | 262 | |
| 263 | 263 | |
| 264 | +class SocialPost(Base): | |
| 265 | + """Publication de médias sociaux (Reddit via index Google, Mastodon, Lemmy…) | |
| 266 | + scorée par le moteur de sentiment — signal « Pouls social », poids nul dans | |
| 267 | + le forecast, anomalies de volume détectées.""" | |
| 268 | + __tablename__ = "social_posts" | |
| 269 | + __table_args__ = (UniqueConstraint("url", name="uq_social_url"), | |
| 270 | + Index("ix_social_created", "created_at"),) | |
| 271 | + id: Mapped[int] = mapped_column(primary_key=True) | |
| 272 | + fetched_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow) | |
| 273 | + created_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) | |
| 274 | + platform: Mapped[str] = mapped_column(String(20), index=True) # reddit/mastodon/lemmy | |
| 275 | + community: Mapped[str | None] = mapped_column(String(80), nullable=True) | |
| 276 | + url: Mapped[str] = mapped_column(Text) | |
| 277 | + author_hash: Mapped[str | None] = mapped_column(String(16), nullable=True) | |
| 278 | + text: Mapped[str] = mapped_column(Text) | |
| 279 | + text_hash: Mapped[str] = mapped_column(String(16), index=True) # anti-doublon | |
| 280 | + engagement: Mapped[float] = mapped_column(Float, default=0.0) # votes/favs/boosts | |
| 281 | + # {party: {"sentiment": s, "stance": st, "method": m}} | |
| 282 | + scores: Mapped[dict] = mapped_column(JSON, default=dict) | |
| 283 | + query: Mapped[str | None] = mapped_column(String(120), nullable=True) | |
| 284 | + | |
| 285 | + | |
| 264 | 286 | class DataSource(Base): |
| 265 | 287 | __tablename__ = "data_sources" |
| 266 | 288 | id: Mapped[int] = mapped_column(primary_key=True) |
modified
backend/app/pipeline.py
+7 −0
@@ -305,6 +305,13 @@ def run_pipeline(full_refresh: bool = True) -> dict: | ||
| 305 | 305 | except Exception as e: |
| 306 | 306 | report["veille"] = f"échec: {e}" |
| 307 | 307 | _log(db, "veille-firecrawl", "error", traceback.format_exc()) |
| 308 | + try: | |
| 309 | + from .ingest.social_pulse import run_pulse | |
| 310 | + report["pouls_social"] = run_pulse(db) | |
| 311 | + _log(db, "pouls-social", "ok", str(report["pouls_social"])[:500]) | |
| 312 | + except Exception as e: | |
| 313 | + report["pouls_social"] = f"échec: {e}" | |
| 314 | + _log(db, "pouls-social", "error", traceback.format_exc()) | |
| 308 | 315 | try: |
| 309 | 316 | from .ingest.wiki_attention import refresh as att_refresh |
| 310 | 317 | report["attention"] = att_refresh(db) |
modified
frontend/aujourdhui.html
+10 −0
@@ -246,6 +246,16 @@ | ||
| 246 | 246 | ["📰 Médias", `nudge ±${nudgeMax.toFixed(2)} pp`], |
| 247 | 247 | ["📈 Marchés", mkt ? `${(mkt.weight_market * 100).toFixed(0)} % dans P(1er)` : "indisponible"], |
| 248 | 248 | ].map(([t, d]) => `<div class="kv"><span>${t}</span><b style="font-weight:500;color:var(--muted)">${d}</b></div>`).join(""); |
| 249 | + try { | |
| 250 | + const so = await getJSON(`${API}/social?days=7`); | |
| 251 | + const tops = Object.entries(so.parties || {}).filter(([, s]) => s.volume > 0) | |
| 252 | + .sort((a, b) => b[1].part_de_voix - a[1].part_de_voix).slice(0, 3); | |
| 253 | + if (tops.length) document.getElementById("layer-state").insertAdjacentHTML("beforeend", | |
| 254 | + `<div class="kv"><span>💬 Pouls social 7 j</span><b style="font-weight:500;color:var(--muted)">${ | |
| 255 | + tops.map(([p, s]) => `${p} ${s.part_de_voix}%${s.sentiment_pondere != null | |
| 256 | + ? " (" + (s.sentiment_pondere > 0 ? "+" : "") + s.sentiment_pondere.toFixed(2) + ")" : ""}`).join(" · ") | |
| 257 | + } — <a href="/intelligence">détail</a></b></div>`); | |
| 258 | + } catch (e) { /* premier cycle à venir */ } | |
| 249 | 259 | })(); |
| 250 | 260 | </script> |
| 251 | 261 | </body> |
modified
frontend/intelligence.html
+73 −0
@@ -36,6 +36,23 @@ | ||
| 36 | 36 | <h2 class="section">Momentum <span class="chip badge-aux">indicateur maison</span></h2> |
| 37 | 37 | <div class="grid parties" id="momentum"></div> |
| 38 | 38 | |
| 39 | + <h2 class="section">Pouls social <span class="chip badge-aux">poids nul — actor Apify maison + sources ouvertes</span></h2> | |
| 40 | + <p class="section-sub">Ce que les Québécois·es écrivent (commentaires YouTube publics, Reddit r/Quebec, | |
| 41 | + Mastodon, Lemmy — sentiment pondéré par l'engagement) et ce qu'ils font (engagement des comptes | |
| 42 | + officiels des partis, via notre acteur <code>qc-social-pulse</code>, proxy résidentiel CA).</p> | |
| 43 | + <div class="grid parties" id="social-cards"><div class="loading">Chargement du pouls social…</div></div> | |
| 44 | + <div class="grid two"> | |
| 45 | + <div class="card"> | |
| 46 | + <h3>Sentiment social quotidien (pondéré engagement)</h3> | |
| 47 | + <div class="chart" id="social-chart"></div> | |
| 48 | + <p class="chart-note" id="social-note"></p> | |
| 49 | + </div> | |
| 50 | + <div class="card"> | |
| 51 | + <h3>Verbatims les plus engagés (7-14 j)</h3> | |
| 52 | + <div id="social-verbatims" style="display:flex;flex-direction:column;gap:10px"></div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | + | |
| 39 | 56 | <h2 class="section">Marchés prédictifs <span class="chip badge-aux">12 % de l'ensemble publié</span></h2> |
| 40 | 57 | <p class="section-sub">Probabilités implicites des parieurs (Polymarket) comparées au modèle — depuis la v2, la probabilité « plus de sièges » publiée est un ensemble modèle/marché (88/12). <a href="/signaux">Voir la décomposition.</a></p> |
| 41 | 58 | <div class="card" id="markets-card"><div class="loading">Chargement des marchés…</div></div> |
@@ -95,6 +112,62 @@ | ||
| 95 | 112 | }).join(""); |
| 96 | 113 | } catch (e) { document.getElementById("momentum").innerHTML = ""; } |
| 97 | 114 | |
| 115 | + /* ---- pouls social ---- */ | |
| 116 | + try { | |
| 117 | + const so = await getJSON(`${API}/social`); | |
| 118 | + const sp2 = so.parties || {}; | |
| 119 | + const ordS = parties.filter(p => sp2[p] && sp2[p].volume > 0) | |
| 120 | + .sort((a, b) => sp2[b].part_de_voix - sp2[a].part_de_voix); | |
| 121 | + document.getElementById("social-cards").innerHTML = ordS.map(p => { | |
| 122 | + const s = sp2[p], sent = s.sentiment_pondere; | |
| 123 | + const eng = (so.enthusiasm || {})[p]; | |
| 124 | + return `<div class="card party-card"> | |
| 125 | + <div class="bar" style="background:${partyColor(p)}"></div> | |
| 126 | + <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span> | |
| 127 | + <div class="pname">${p}</div></div> | |
| 128 | + <div class="bignum" style="color:${sent > 0.08 ? "var(--good)" : sent < -0.08 ? "var(--danger)" : "var(--ink)"}"> | |
| 129 | + ${sent != null ? (sent > 0 ? "+" : "") + sent.toFixed(2) : "—"}</div> | |
| 130 | + <div class="kv"><span>Part de voix</span><b>${s.part_de_voix} %</b></div> | |
| 131 | + <div class="kv"><span>Mentions ${so.days} j</span><b>${s.volume}</b></div> | |
| 132 | + <div class="kv"><span>Positions</span><b>${(s.stances.pro || 0)} pro · ${(s.stances.anti || 0)} anti</b></div> | |
| 133 | + ${eng ? `<div class="kv"><span>Engagement/post (officiel)</span><b>${Math.round(eng.avg_post_engagement).toLocaleString("fr-CA")}</b></div>` : ""} | |
| 134 | + </div>`; | |
| 135 | + }).join("") || '<div class="card"><p class="note">Le premier cycle du pouls social alimentera cette section.</p></div>'; | |
| 136 | + document.getElementById("social-note").textContent = | |
| 137 | + `${so.note} ${so.posts_total} publications analysées (` + | |
| 138 | + Object.entries(so.platforms || {}).map(([k, v]) => `${k} ${v}`).join(" · ") + ")."; | |
| 139 | + const withTl2 = ordS.filter(p => (sp2[p].timeline || []).length > 1); | |
| 140 | + if (withTl2.length) { | |
| 141 | + const sc2 = baseChart(document.getElementById("social-chart")); | |
| 142 | + sc2.setOption({ | |
| 143 | + grid: { left: 44, right: 20, top: 34, bottom: 28 }, | |
| 144 | + legend: { ...LEGEND, data: withTl2.map(partyName) }, | |
| 145 | + tooltip: { ...TOOLTIP }, | |
| 146 | + xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } }, | |
| 147 | + yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE }, | |
| 148 | + series: withTl2.map(p => ({ name: partyName(p), type: "line", | |
| 149 | + color: partyColor(p), symbol: "circle", symbolSize: 6, | |
| 150 | + lineStyle: lineGlow(partyColor(p), 2), | |
| 151 | + data: sp2[p].timeline.map(t => [t.date, t.sentiment]) })), | |
| 152 | + }); | |
| 153 | + } else { | |
| 154 | + document.getElementById("social-chart").innerHTML = | |
| 155 | + '<p class="loading">Pas encore assez de jours de données sociales.</p>'; | |
| 156 | + } | |
| 157 | + const verbs = ordS.flatMap(p => (sp2[p].verbatims || []).map(v => ({ ...v, party: p }))) | |
| 158 | + .sort((a, b) => b.engagement - a.engagement).slice(0, 6); | |
| 159 | + document.getElementById("social-verbatims").innerHTML = verbs.map(v => | |
| 160 | + `<div style="border:1px solid var(--border);border-radius:12px;padding:10px 12px"> | |
| 161 | + <div style="font-size:.85rem;line-height:1.5">« ${v.text.replace(/</g, "<")} »</div> | |
| 162 | + <div style="margin-top:6px;font-size:.74rem;color:var(--muted)"> | |
| 163 | + <span class="chip" style="background:${partyColor(v.party)}22;color:${partyColor(v.party)}">${v.party} ${v.stance || ""}</span> | |
| 164 | + ${v.platform} · ${Math.round(v.engagement)} ♥ · <a href="${v.url}" target="_blank" rel="noopener">source</a></div> | |
| 165 | + </div>`).join("") || '<p class="note">Aucun verbatim pour l\'instant.</p>'; | |
| 166 | + } catch (e) { | |
| 167 | + document.getElementById("social-cards").innerHTML = | |
| 168 | + '<div class="card"><p class="note">Pouls social indisponible pour le moment.</p></div>'; | |
| 169 | + } | |
| 170 | + | |
| 98 | 171 | try { |
| 99 | 172 | const mk = await getJSON(`${API}/markets`); |
| 100 | 173 | const rows = Object.entries(mk.implied_prob_most_seats) |
| 101 | 174 | |