spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1<!DOCTYPE html>2<!--3 QC Élection Forecast — Plateforme de prévision électorale du Québec 20264 Auteur : Simon-Pierre Boucher5 Contact : contact@spboucher.ai6 https://www.qc-election.com7-->8<html lang="fr">9<head>10<script>document.documentElement.dataset.theme=localStorage.getItem("qce-theme")||"dark";</script>11<meta charset="utf-8">12<meta name="viewport" content="width=device-width, initial-scale=1">13<title>Intelligence électorale — QC Élection Forecast</title>14<link rel="icon" type="image/svg+xml" href="/assets/img/favicon.svg">15<link rel="icon" type="image/png" sizes="64x64" href="/assets/img/favicon-64.png">16<link rel="apple-touch-icon" href="/assets/img/apple-touch-icon.png">17<meta property="og:site_name" content="QC Élection Forecast">18<meta property="og:type" content="website">19<meta property="og:title" content="Intelligence électorale — QC Élection Forecast">20<meta property="og:description" content="Prévision probabiliste de l'élection générale québécoise du 5 octobre 2026 : 127 circonscriptions, modèle bayésien calibré, sources traçables.">21<meta property="og:image" content="https://www.qc-election.com/assets/img/og.png">22<meta property="og:image:width" content="1200">23<meta property="og:image:height" content="630">24<meta property="og:locale" content="fr_CA">25<meta name="twitter:card" content="summary_large_image">26<meta name="twitter:title" content="Intelligence électorale — QC Élection Forecast">27<meta name="twitter:image" content="https://www.qc-election.com/assets/img/og.png">28<meta name="theme-color" content="#0a0d14">29<link rel="stylesheet" href="/assets/style.css">30<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>31</head>32<body>33<header class="site"></header>34<div class="wrap">35 <div class="hero">36 <h1>Intelligence électorale</h1>37 <p class="sub">Sentiment médiatique, momentum et nouvelles regroupées.38 <span class="chip badge-aux">agit sur le forecast via un ajustement borné (±0,35 pp max) — <a href="/signaux">détail</a></span></p>39 </div>4041 <h2 class="section">Interroger le modèle</h2>42 <p class="section-sub">Les réponses sont générées à partir des données réelles du modèle (sondages, runs, événements) — chaque affirmation est traçable.</p>43 <div class="card">44 <div class="ask-box">45 <input type="text" id="q" placeholder="Ex. : pourquoi la probabilité du PQ a-t-elle changé cette semaine ?" maxlength="500">46 <button class="primary" id="ask">Demander</button>47 </div>48 <div class="answer" id="answer"></div>49 </div>5051 <h2 class="section">Momentum <span class="chip badge-aux">indicateur maison</span></h2>52 <div class="grid parties" id="momentum"></div>5354 <h2 class="section">Pouls social <span class="chip badge-aux">poids nul — actor Apify maison + sources ouvertes</span></h2>55 <p class="section-sub">Ce que les Québécois·es écrivent (commentaires YouTube publics, Reddit r/Quebec,56 Mastodon, Lemmy — sentiment pondéré par l'engagement) et ce qu'ils font (engagement des comptes57 officiels des partis, via notre acteur <code>qc-social-pulse</code>, proxy résidentiel CA).</p>58 <div class="grid parties" id="social-cards"><div class="loading">Chargement du pouls social…</div></div>59 <div class="grid two">60 <div class="card">61 <h3>Sentiment social quotidien (pondéré engagement)</h3>62 <div class="chart" id="social-chart"></div>63 <p class="chart-note" id="social-note"></p>64 </div>65 <div class="card">66 <h3>Verbatims les plus engagés (7-14 j)</h3>67 <div id="social-verbatims" style="display:flex;flex-direction:column;gap:10px;overflow-wrap:anywhere"></div>68 </div>69 </div>7071 <h2 class="section">Marchés prédictifs <span class="chip badge-aux">12 % de l'ensemble publié</span></h2>72 <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>73 <div class="card" id="markets-card"><div class="loading">Chargement des marchés…</div></div>7475 <h2 class="section">Sentiment médiatique par parti</h2>76 <div class="grid parties" id="sent-cards"><div class="loading">Chargement…</div></div>77 <div class="card">78 <h3>Sentiment quotidien (moyenne mobile des scores)</h3>79 <div class="chart" id="sent-chart"></div>80 <p class="chart-note" id="sent-note"></p>81 </div>8283 <h2 class="section">Nouvelles (regroupées par grappe)</h2>84 <div class="card" style="overflow-x:auto"><table class="data" id="news"></table></div>85</div>86<footer class="site"></footer>8788<script src="/assets/app.js"></script>89<script>90(async () => {91 const meta = await initShell("/intelligence");92 const parties = Object.keys(meta.parties).filter(p => p !== "AUT");9394 document.getElementById("ask").onclick = async () => {95 const q = document.getElementById("q").value.trim();96 if (q.length < 3) return;97 const el = document.getElementById("answer");98 el.style.display = "block";99 el.textContent = "Analyse des données du modèle…";100 try {101 const r = await getJSON(`${API}/ask`, { method: "POST",102 headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: q }) });103 el.textContent = r.answer;104 } catch (e) { el.textContent = "Erreur : " + e.message; }105 };106 document.getElementById("q").addEventListener("keydown", e => {107 if (e.key === "Enter") document.getElementById("ask").click(); });108109 try {110 const mom = await getJSON(`${API}/momentum`);111 document.getElementById("momentum").innerHTML = parties112 .filter(p => mom.momentum[p])113 .sort((a, b) => mom.momentum[b].score - mom.momentum[a].score)114 .map(p => {115 const m = mom.momentum[p];116 return `<div class="card party-card">117 <div class="bar" style="background:${partyColor(p)}"></div>118 <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span>119 <div class="pname">${p}</div></div>120 <div class="bignum" style="color:${partyColor(p)}">121 <span style="font-size:.6em" aria-hidden="true">${m.score > 0 ? "▲" : m.score < 0 ? "▼" : "·"}</span> ${Math.abs(m.score).toFixed(0)}</div>122 <div class="kv"><span>Δ vote 14 j</span><b>${m.d_vote_14j_pp >= 0 ? "+" : ""}${m.d_vote_14j_pp} pp</b></div>123 <div class="kv"><span>Sentiment 7 j</span><b>${m.sentiment_7j != null ? m.sentiment_7j : "—"}</b></div>124 <div class="kv"><span>Mentions 7 j</span><b>${m.volume_7j}</b></div>125 </div>`;126 }).join("");127 } catch (e) { document.getElementById("momentum").innerHTML = ""; }128129 /* ---- pouls social ---- */130 try {131 const so = await getJSON(`${API}/social`);132 const sp2 = so.parties || {};133 const ordS = parties.filter(p => sp2[p] && sp2[p].volume > 0)134 .sort((a, b) => sp2[b].part_de_voix - sp2[a].part_de_voix);135 document.getElementById("social-cards").innerHTML = ordS.map(p => {136 const s = sp2[p], sent = s.sentiment_pondere;137 const eng = (so.enthusiasm || {})[p];138 return `<div class="card party-card">139 <div class="bar" style="background:${partyColor(p)}"></div>140 <div class="party-head"><span class="avatars-stack">${avatarHTML(p, 34)}</span>141 <div class="pname">${p}</div></div>142 <div class="bignum" style="color:${partyColor(p)}">143 ${sent != null ? (sent > 0 ? "+" : "") + sent.toFixed(2) : "—"}</div>144 <div class="kv"><span>Part de voix</span><b>${s.part_de_voix} %</b></div>145 <div class="kv"><span>Mentions ${so.days} j</span><b>${s.volume}</b></div>146 <div class="kv"><span>Positions</span><b>${(s.stances.pro || 0)} pro · ${(s.stances.anti || 0)} anti</b></div>147 ${eng ? `<div class="kv"><span>Engagement/post (officiel)</span><b>${Math.round(eng.avg_post_engagement).toLocaleString("fr-CA")}</b></div>` : ""}148 </div>`;149 }).join("") || '<div class="card"><p class="note">Le premier cycle du pouls social alimentera cette section.</p></div>';150 document.getElementById("social-note").textContent =151 `${so.note} ${so.posts_total} publications analysées (` +152 Object.entries(so.platforms || {}).map(([k, v]) => `${k} ${v}`).join(" · ") + ").";153 const withTl2 = ordS.filter(p => (sp2[p].timeline || []).length > 1);154 if (withTl2.length) {155 const sc2 = baseChart(document.getElementById("social-chart"));156 sc2.setOption({157 grid: { left: 44, right: 20, top: 34, bottom: 28 },158 legend: { ...LEGEND, data: withTl2.map(partyName) },159 tooltip: { ...TOOLTIP },160 xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },161 yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE },162 series: withTl2.map(p => ({ name: partyName(p), type: "line",163 color: partyColor(p), symbol: "circle", symbolSize: 6,164 lineStyle: lineGlow(partyColor(p), 2),165 data: sp2[p].timeline.map(t => [t.date, t.sentiment]) })),166 });167 } else {168 document.getElementById("social-chart").innerHTML =169 '<p class="loading">Pas encore assez de jours de données sociales.</p>';170 }171 const verbs = ordS.flatMap(p => (sp2[p].verbatims || []).map(v => ({ ...v, party: p })))172 .sort((a, b) => b.engagement - a.engagement).slice(0, 6);173 document.getElementById("social-verbatims").innerHTML = verbs.map(v =>174 `<div style="border:1px solid var(--border);border-radius:12px;padding:10px 12px">175 <div style="font-size:.85rem;line-height:1.5">« ${v.text.replace(/</g, "<")} »</div>176 <div style="margin-top:6px;font-size:.74rem;color:var(--muted)">177 <span class="chip" style="background:${partyColor(v.party)}22;color:${partyColor(v.party)}">${v.party} ${v.stance || ""}</span>178 ${v.platform} · ${Math.round(v.engagement)} ♥ · <a href="${v.url}" target="_blank" rel="noopener">source</a></div>179 </div>`).join("") || '<p class="note">Aucun verbatim pour l\'instant.</p>';180 } catch (e) {181 document.getElementById("social-cards").innerHTML =182 '<div class="card"><p class="note">Pouls social indisponible pour le moment.</p></div>';183 }184185 try {186 const mk = await getJSON(`${API}/markets`);187 const rows = Object.entries(mk.implied_prob_most_seats)188 .sort((a, b) => b[1] - a[1]);189 document.getElementById("markets-card").innerHTML = `190 <table class="data">191 <tr><th>Parti</th><th>Marché (Polymarket)</th><th>Modèle QC Forecast</th><th>Écart</th></tr>192 ${rows.map(([p, v]) => {193 const m = (mk.model_prob_most_seats || {})[p];194 const d = m != null ? m - v : null;195 return `<tr><td><span class="dot" style="background:${partyColor(p)}"></span>${partyName(p)}</td>196 <td>${(v * 100).toFixed(1)} %</td>197 <td>${m != null ? (m * 100).toFixed(1) + " %" : "—"}</td>198 <td style="color:var(--muted)">${d != null ? (d >= 0 ? "+" : "−") + Math.abs(d * 100).toFixed(1) + " pts" : "—"}</td></tr>`;199 }).join("")}200 </table>201 <p class="note">${mk.note} Volume : ${mk.volume_usd.toLocaleString("fr-CA")} $ US ·202 <a href="${mk.url}" target="_blank" rel="noopener">${mk.event}</a> ·203 consulté ${new Date(mk.fetched_at).toLocaleString("fr-CA")}</p>`;204 } catch (e) {205 document.getElementById("markets-card").innerHTML =206 '<p class="note">Marchés prédictifs indisponibles pour le moment.</p>';207 }208209 const sent = await getJSON(`${API}/sentiment?days=60`);210 document.getElementById("sent-note").textContent = sent.note +211 ` (${sent.documents} documents analysés — presse québécoise via RSS)`;212 const sp = sent.parties || {};213 document.getElementById("sent-cards").innerHTML = parties.filter(p => sp[p]).map(p => {214 const s = sp[p];215 return `<div class="card party-card">216 <div class="bar" style="background:${partyColor(p)}"></div>217 <div class="pname">${p}</div>218 <div class="bignum">${s.sentiment_moyen > 0 ? "+" : ""}${s.sentiment_moyen.toFixed(2)}</div>219 <div class="kv"><span>Volume total</span><b>${s.volume}</b></div>220 <div class="kv"><span>24 h</span><b>${s.h24.volume} mentions ${s.h24.moyenne != null ? "(" + s.h24.moyenne + ")" : ""}</b></div>221 <div class="kv"><span>Positions</span><b>${s.stances.pro} pro · ${s.stances.anti} anti · ${s.stances.neutre} neutres</b></div>222 </div>`;223 }).join("") || '<div class="card"><p class="note">Aucun document de sentiment encore ingéré — le pipeline RSS alimentera cette section automatiquement.</p></div>';224225 const withTl = parties.filter(p => sp[p] && sp[p].timeline.length > 1);226 if (withTl.length) {227 const c = baseChart(document.getElementById("sent-chart"));228 c.setOption({229 grid: { left: 44, right: 20, top: 34, bottom: 28 },230 legend: { ...LEGEND, data: withTl.map(partyName) },231 tooltip: { ...TOOLTIP },232 xAxis: { type: "time", ...AXIS_STYLE, splitLine: { show: false } },233 yAxis: { type: "value", min: -1, max: 1, ...AXIS_STYLE },234 series: withTl.map(p => ({ name: partyName(p), type: "line", color: partyColor(p),235 symbol: "circle", symbolSize: 6, lineStyle: { width: 2 },236 data: sp[p].timeline.map(t => [t.date, t.sentiment]) })),237 });238 } else {239 document.getElementById("sent-chart").innerHTML =240 '<p class="loading">Pas encore assez de données quotidiennes.</p>';241 }242243 const news = await getJSON(`${API}/news`);244 document.getElementById("news").innerHTML =245 `<tr><th>Nouvelle</th><th>Source</th><th>Date</th><th>Articles</th><th>Partis (stance)</th></tr>` +246 (news.length ? news.map(n => `<tr>247 <td><a href="${n.url}" target="_blank" rel="noopener">${n.title}</a></td>248 <td>${n.source}</td><td>${fmtDate(n.published.slice(0, 10))}</td>249 <td>${n.n_articles}</td>250 <td>${Object.entries(n.parties).map(([p, s]) =>251 `<span class="chip" style="background:${partyColor(p)}22;color:${partyColor(p)}">${p} ${s.stance}</span>`).join(" ")}</td>252 </tr>`).join("") : `<tr><td colspan="5" style="color:var(--muted)">Aucune nouvelle ingérée pour l'instant.</td></tr>`);253})();254</script>255</body>256</html>257