SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%

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>
Simon-Pierre Boucher committed 25 days ago (Aug 30, 2026) parent 051ebda

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, "&lt;")} »</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