Social: publication auto horaire FB (Page Groupe-KA) + generation a la demande
- insights.py: selection du fait marquant parmi 11 sites KA (scoring + anti-repetition) - render_card.py: carte stat unique 1080x1350 aux couleurs officielles du site - legende redigee par Claude (API Anthropic), max 1 image - publication Safari sur le noeud (injection JS, sans clavier) - planificateur horaire + onglet Social dans l app admin (toggle auto, generateur par prompt, journal) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8 changed files +1,099 −1
added
.gitignore
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +data/social/ | |
modified
public/app.js
+93 −1
@@ -51,7 +51,7 @@ | ||
| 51 | 51 | function fmtMB(b) { return b ? Math.round(b / 1048576) + ' Mo' : '—'; } |
| 52 | 52 | |
| 53 | 53 | // ---------- navigation ---------- |
| 54 | − const views = ['chat', 'sessions', 'prompts', 'eco', 'settings']; | |
| 54 | + const views = ['chat', 'sessions', 'prompts', 'eco', 'social', 'settings']; | |
| 55 | 55 | function switchView(name) { |
| 56 | 56 | curView = name; |
| 57 | 57 | for (const v of views) $('view-' + v).classList.toggle('hidden', v !== name); |
@@ -62,9 +62,101 @@ | ||
| 62 | 62 | if (name === 'sessions') loadSessions(); |
| 63 | 63 | if (name === 'prompts') loadPrompts(); |
| 64 | 64 | if (name === 'eco') { loadEco(); send({ type: 'eco_sub' }); } else { send({ type: 'eco_unsub' }); } |
| 65 | + if (name === 'social') loadSocial(); | |
| 65 | 66 | if (name === 'settings') refreshSettingsView(); |
| 66 | 67 | } |
| 67 | 68 | document.querySelectorAll('.nav-btn').forEach((b) => { b.onclick = () => switchView(b.dataset.view); }); |
| 69 | + | |
| 70 | + // ---------- Social ---------- | |
| 71 | + let socDraft = null; | |
| 72 | + async function loadSocial() { | |
| 73 | + try { | |
| 74 | + const r = await fetch('/api/social/state'); const d = await r.json(); | |
| 75 | + renderSocialState(d.state); renderSocialLog(d.log || []); | |
| 76 | + } catch {} | |
| 77 | + } | |
| 78 | + function renderSocialState(st) { | |
| 79 | + if (!st) return; | |
| 80 | + const tog = $('socAutoToggle'); if (tog) tog.checked = !!st.auto; | |
| 81 | + const nx = $('socNext'); | |
| 82 | + if (nx) { | |
| 83 | + if (st.auto && st.nextAt) nx.textContent = 'Prochaine publication ~ ' + new Date(st.nextAt).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) + ' (toutes les ' + st.intervalMin + ' min)'; | |
| 84 | + else nx.textContent = st.auto ? 'Automatique activé' : 'Automatique désactivé'; | |
| 85 | + } | |
| 86 | + } | |
| 87 | + function renderSocialLog(log) { | |
| 88 | + const el = $('socLog'); if (!el) return; | |
| 89 | + if (!log.length) { el.innerHTML = '<div class="soc-sub">Aucun événement.</div>'; return; } | |
| 90 | + el.innerHTML = log.map(function (e) { | |
| 91 | + const t = new Date(e.ts).toLocaleString('fr-CA', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); | |
| 92 | + let msg = e.kind; | |
| 93 | + if (e.kind === 'published') msg = '📣 Publié — ' + (e.site || e.trigger || '') + (e.headline ? ' · ' + e.headline : ''); | |
| 94 | + else if (e.kind === 'cycle_error') msg = '⚠️ Erreur — ' + (e.error || ''); | |
| 95 | + else if (e.kind === 'cycle_skip') msg = '⏸️ Ignoré — ' + (e.reason || ''); | |
| 96 | + else if (e.kind === 'auto_on') msg = '▶️ Automatique activé'; | |
| 97 | + else if (e.kind === 'auto_off') msg = '⏹️ Automatique désactivé'; | |
| 98 | + else if (e.kind === 'cycle_start') msg = '… cycle démarré (' + (e.trigger || '') + ')'; | |
| 99 | + return '<div class="soc-logrow"><span class="soc-logt">' + t + '</span> ' + esc(msg) + '</div>'; | |
| 100 | + }).join(''); | |
| 101 | + } | |
| 102 | + function bindSocial() { | |
| 103 | + const tog = $('socAutoToggle'); | |
| 104 | + if (tog) tog.onchange = async () => { | |
| 105 | + const r = await fetch('/api/social/auto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ on: tog.checked }) }); | |
| 106 | + const d = await r.json(); renderSocialState(d.state); | |
| 107 | + banner(tog.checked ? 'good' : 'muted', tog.checked ? 'Publication automatique activée' : 'Publication automatique désactivée'); | |
| 108 | + }; | |
| 109 | + const gen = $('socGenerate'); | |
| 110 | + if (gen) gen.onclick = doGenerate; | |
| 111 | + const rg = $('socRegen'); if (rg) rg.onclick = doGenerate; | |
| 112 | + const pub = $('socPublish'); | |
| 113 | + if (pub) pub.onclick = async () => { | |
| 114 | + if (!socDraft) return; | |
| 115 | + pub.disabled = true; pub.textContent = 'Publication…'; | |
| 116 | + try { | |
| 117 | + const r = await fetch('/api/social/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caption: $('socCaption').value, image: socDraft.image.split('/').pop() }) }); | |
| 118 | + const d = await r.json(); | |
| 119 | + if (d.ok) { banner('good', '📣 Post publié sur la Page Groupe-KA'); $('socDraft').classList.add('hidden'); socDraft = null; loadSocial(); } | |
| 120 | + else banner('bad', 'Échec : ' + (d.error || '?')); | |
| 121 | + } catch (e) { banner('bad', 'Échec : ' + e.message); } | |
| 122 | + pub.disabled = false; pub.textContent = '📣 Publier ce post'; | |
| 123 | + }; | |
| 124 | + const run = $('socRunNow'); | |
| 125 | + if (run) run.onclick = async () => { | |
| 126 | + run.disabled = true; run.textContent = 'Publication…'; | |
| 127 | + try { | |
| 128 | + const r = await fetch('/api/social/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); | |
| 129 | + const d = await r.json(); | |
| 130 | + banner(d.ok ? 'good' : 'bad', d.ok ? ('📣 Publié : ' + (d.insight ? d.insight.site : '')) : ('Échec : ' + (d.error || d.reason || '?'))); | |
| 131 | + loadSocial(); | |
| 132 | + } catch (e) { banner('bad', 'Échec : ' + e.message); } | |
| 133 | + run.disabled = false; run.textContent = '⚡ Publier maintenant'; | |
| 134 | + }; | |
| 135 | + const chk = $('socCheckLogin'); | |
| 136 | + if (chk) chk.onclick = async () => { | |
| 137 | + const el = $('socLogin'); el.textContent = 'Vérification…'; | |
| 138 | + try { const r = await fetch('/api/social/login'); const d = await r.json(); | |
| 139 | + el.textContent = d.login === 'LOGGED_IN' ? '✅ Facebook connecté (Simon-Pierre)' : (d.login === 'LOGIN_PAGE' ? '⚠️ Facebook déconnecté — reconnecter Safari sur le nœud' : ('État : ' + (d.login || d.error))); | |
| 140 | + } catch (e) { el.textContent = 'Erreur : ' + e.message; } | |
| 141 | + }; | |
| 142 | + } | |
| 143 | + async function doGenerate() { | |
| 144 | + const gen = $('socGenerate'); const prev = gen.textContent; | |
| 145 | + gen.disabled = true; gen.textContent = 'Génération…'; | |
| 146 | + try { | |
| 147 | + const r = await fetch('/api/social/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ site: $('socSite').value, prompt: $('socPrompt').value }) }); | |
| 148 | + const d = await r.json(); | |
| 149 | + if (d.draft) { | |
| 150 | + socDraft = d.draft; | |
| 151 | + $('socImg').src = d.draft.imageUrl + '?t=' + Date.now(); | |
| 152 | + $('socCaption').value = d.draft.caption; | |
| 153 | + $('socDraft').classList.remove('hidden'); | |
| 154 | + } else banner('bad', 'Échec : ' + (d.error || '?')); | |
| 155 | + } catch (e) { banner('bad', 'Échec : ' + e.message); } | |
| 156 | + gen.disabled = false; gen.textContent = prev; | |
| 157 | + } | |
| 158 | + | |
| 159 | + bindSocial(); | |
| 68 | 160 | function navBadge(view) { |
| 69 | 161 | if (curView === view) return; |
| 70 | 162 | const b = document.querySelector(`.nav-btn[data-view="${view}"] .badge`); |
modified
public/index.html
+47 −0
@@ -146,6 +146,49 @@ | ||
| 146 | 146 | <button id="logoutBtn" class="btn-danger">Se déconnecter</button> |
| 147 | 147 | </section> |
| 148 | 148 | |
| 149 | + <!-- ======== Vue SOCIAL ======== --> | |
| 150 | + <section id="view-social" class="view hidden"> | |
| 151 | + <div class="view-head"><h2>Social</h2></div> | |
| 152 | + | |
| 153 | + <div class="soc-card"> | |
| 154 | + <label class="soc-switch"> | |
| 155 | + <input type="checkbox" id="socAutoToggle"> | |
| 156 | + <span>Publication automatique sur la Page Facebook Groupe-KA</span> | |
| 157 | + </label> | |
| 158 | + <div class="soc-sub" id="socNext">—</div> | |
| 159 | + <div class="soc-actions"> | |
| 160 | + <button id="socRunNow" class="btn-cta small">⚡ Publier maintenant</button> | |
| 161 | + <button id="socCheckLogin" class="btn-ghost small">Vérifier Facebook</button> | |
| 162 | + </div> | |
| 163 | + <div class="soc-sub" id="socLogin"></div> | |
| 164 | + </div> | |
| 165 | + | |
| 166 | + <div class="soc-card"> | |
| 167 | + <h3>Générer un post</h3> | |
| 168 | + <textarea id="socPrompt" rows="3" placeholder="Décris le post voulu (optionnel). Ex : mets en valeur les aubaines de logement sous le prix du marché."></textarea> | |
| 169 | + <div class="soc-actions"> | |
| 170 | + <select id="socSite" class="soc-select"> | |
| 171 | + <option value="">Choix auto (fait marquant)</option> | |
| 172 | + <option value="lou-ka">Lou·Ka</option><option value="immo-ka">Immo·Ka</option><option value="vrai-prix">Vrai-Prix</option><option value="auto-ka">Auto·Ka</option><option value="food-ka">Food·Ka</option><option value="fabri-ka">Fabri·Ka</option><option value="resto-ka">Resto·Ka</option><option value="sorti-ka">Sorti·Ka</option><option value="crea-ka">Créa·Ka</option><option value="job-ka">Job·Ka</option><option value="trouve-ka">Trouve·Ka</option> | |
| 173 | + </select> | |
| 174 | + <button id="socGenerate" class="btn-cta small">✨ Générer</button> | |
| 175 | + </div> | |
| 176 | + <div id="socDraft" class="soc-draft hidden"> | |
| 177 | + <img id="socImg" alt="aperçu"> | |
| 178 | + <textarea id="socCaption" rows="7"></textarea> | |
| 179 | + <div class="soc-actions"> | |
| 180 | + <button id="socPublish" class="btn-cta small">📣 Publier ce post</button> | |
| 181 | + <button id="socRegen" class="btn-ghost small">↻ Regénérer</button> | |
| 182 | + </div> | |
| 183 | + </div> | |
| 184 | + </div> | |
| 185 | + | |
| 186 | + <div class="soc-card"> | |
| 187 | + <h3>Journal</h3> | |
| 188 | + <div id="socLog" class="soc-log"></div> | |
| 189 | + </div> | |
| 190 | + </section> | |
| 191 | + | |
| 149 | 192 | <!-- ======== Bottom nav ======== --> |
| 150 | 193 | <nav class="bottomnav"> |
| 151 | 194 | <button data-view="chat" class="nav-btn active"> |
@@ -164,6 +207,10 @@ | ||
| 164 | 207 | <svg viewBox="0 0 24 24"><path d="M4 17l4-6 4 3 4-8 4 5" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21h18" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg> |
| 165 | 208 | <span>Écosystème</span><i class="badge hidden"></i> |
| 166 | 209 | </button> |
| 210 | + <button data-view="social" class="nav-btn"> | |
| 211 | + <svg viewBox="0 0 24 24"><path d="M18 8a3 3 0 10-2.8-4H15a3 3 0 00.2 4l-6.4 3.7M18 16a3 3 0 10.2 4M8.6 12.3L15 16" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="1.8" fill="none"/></svg> | |
| 212 | + <span>Social</span><i class="badge hidden"></i> | |
| 213 | + </button> | |
| 167 | 214 | <button data-view="settings" class="nav-btn"> |
| 168 | 215 | <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.8" fill="none"/><path d="M12 2.8v3M12 18.2v3M2.8 12h3M18.2 12h3M5.5 5.5l2.1 2.1M16.4 16.4l2.1 2.1M18.5 5.5l-2.1 2.1M7.6 16.4l-2.1 2.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg> |
| 169 | 216 | <span>Réglages</span><i class="badge hidden"></i> |
modified
public/style.css
+16 −0
@@ -644,3 +644,19 @@ details.thinking-box summary { color: var(--muted); font-size: 12.5px; cursor: p | ||
| 644 | 644 | .seg button.on { background: var(--orange); font-weight: 800; box-shadow: var(--bshadow-sm); } |
| 645 | 645 | .set-row { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; font-size: 14px; font-weight: 600; } |
| 646 | 646 | .set-actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } |
| 647 | + | |
| 648 | + | |
| 649 | +/* ---- Social ---- */ | |
| 650 | +.soc-card{background:var(--card,#fff);border:2px solid var(--ink,#1a1a1a);border-radius:16px;padding:14px 16px;margin:0 0 14px;box-shadow:3px 3px 0 var(--ink,#1a1a1a);} | |
| 651 | +.soc-card h3{margin:0 0 10px;font-size:15px;} | |
| 652 | +.soc-switch{display:flex;align-items:center;gap:10px;font-weight:700;cursor:pointer;} | |
| 653 | +.soc-switch input{width:20px;height:20px;accent-color:var(--accent,#f97316);} | |
| 654 | +.soc-sub{color:var(--muted,#6b7280);font-size:13px;margin-top:8px;} | |
| 655 | +.soc-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px;} | |
| 656 | +.soc-select{padding:9px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;background:#fff;} | |
| 657 | +.soc-draft{margin-top:14px;display:flex;flex-direction:column;gap:10px;} | |
| 658 | +.soc-draft img{width:100%;max-width:340px;border-radius:12px;border:2px solid var(--ink,#1a1a1a);align-self:center;} | |
| 659 | +#view-social textarea{width:100%;padding:10px 12px;border:2px solid var(--ink,#1a1a1a);border-radius:10px;font:inherit;resize:vertical;} | |
| 660 | +.soc-log{display:flex;flex-direction:column;gap:6px;max-height:340px;overflow-y:auto;} | |
| 661 | +.soc-logrow{font-size:13px;padding:6px 8px;border-radius:8px;background:rgba(0,0,0,.04);} | |
| 662 | +.soc-logt{color:var(--muted,#6b7280);font-variant-numeric:tabular-nums;margin-right:6px;} | |
modified
server/server.js
+49 −0
@@ -10,6 +10,7 @@ import { spawn } from 'node:child_process'; | ||
| 10 | 10 | import { fileURLToPath } from 'node:url'; |
| 11 | 11 | import { WebSocketServer } from 'ws'; |
| 12 | 12 | import { startMonitor, ecoSummary, ecoSite, ecoIncidents, ecoNodes, getIcon, SITES } from './monitor.js'; |
| 13 | +import { initSocial, socialState, socialLog, setAuto, generateDraft, publishDraft, runAutoCycle, cardPath, fbLoginState } from './social.js'; | |
| 13 | 14 | import { execFile } from 'node:child_process'; |
| 14 | 15 | |
| 15 | 16 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
@@ -663,6 +664,47 @@ const server = http.createServer(async (req, res) => { | ||
| 663 | 664 | const out = await runOnNode(site.node, `cd ${site.dir} && echo "=== derniers commits ===" && git log --oneline -8 && echo && echo "=== statut ===" && (git status --short | head -10; [ -z "$(git status --short)" ] && echo "propre ✓")`); |
| 664 | 665 | return json(res, 200, { text: out.stdout || out.error || '(vide)' }); |
| 665 | 666 | } |
| 667 | + // --- Social : publication automatique + a la demande --- | |
| 668 | + if (p === '/api/social/state' && req.method === 'GET') { | |
| 669 | + return json(res, 200, { state: socialState(), log: socialLog(40) }); | |
| 670 | + } | |
| 671 | + if (p === '/api/social/auto' && req.method === 'POST') { | |
| 672 | + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } | |
| 673 | + audit({ kind: 'social_auto', on: !!body.on, ip: clientIp(req) }); | |
| 674 | + return json(res, 200, { state: setAuto(!!body.on) }); | |
| 675 | + } | |
| 676 | + if (p === '/api/social/login' && req.method === 'GET') { | |
| 677 | + try { return json(res, 200, { login: await fbLoginState() }); } | |
| 678 | + catch (e) { return json(res, 502, { error: e.message }); } | |
| 679 | + } | |
| 680 | + if (p === '/api/social/generate' && req.method === 'POST') { | |
| 681 | + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } | |
| 682 | + try { | |
| 683 | + const draft = await generateDraft({ site: (body.site || '').trim(), prompt: (body.prompt || '').trim() }); | |
| 684 | + audit({ kind: 'social_generate', site: draft.insight.site }); | |
| 685 | + return json(res, 200, { draft }); | |
| 686 | + } catch (e) { return json(res, 502, { error: e.message }); } | |
| 687 | + } | |
| 688 | + if (p === '/api/social/publish' && req.method === 'POST') { | |
| 689 | + let body; try { body = JSON.parse(await readBody(req)); } catch { return json(res, 400, { error: 'bad json' }); } | |
| 690 | + try { | |
| 691 | + await publishDraft({ caption: String(body.caption || ''), image: cardPath(body.image || '') }); | |
| 692 | + audit({ kind: 'social_publish', ip: clientIp(req) }); | |
| 693 | + return json(res, 200, { ok: true }); | |
| 694 | + } catch (e) { return json(res, 502, { error: e.message }); } | |
| 695 | + } | |
| 696 | + if (p === '/api/social/run' && req.method === 'POST') { | |
| 697 | + audit({ kind: 'social_run_now', ip: clientIp(req) }); | |
| 698 | + const r = await runAutoCycle('manuel'); | |
| 699 | + return json(res, r.ok ? 200 : 502, r); | |
| 700 | + } | |
| 701 | + if (p.startsWith('/api/social/card/') && req.method === 'GET') { | |
| 702 | + const fp = cardPath(p.split('/')[4] || ''); | |
| 703 | + if (!fp) { res.writeHead(404); return res.end(); } | |
| 704 | + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' }); | |
| 705 | + return res.end(fs.readFileSync(fp)); | |
| 706 | + } | |
| 707 | + | |
| 666 | 708 | return json(res, 404, { error: 'not found' }); |
| 667 | 709 | } |
| 668 | 710 | |
@@ -849,6 +891,13 @@ startMonitor({ | ||
| 849 | 891 | }, |
| 850 | 892 | }); |
| 851 | 893 | |
| 894 | +// module Social : publication automatique horaire + génération sur demande | |
| 895 | +initSocial( | |
| 896 | + { anthropicKey: UPGRADER_KEY, upgraderModel: UPGRADER_MODEL, | |
| 897 | + socialModel: config.socialModel, socialIntervalMin: config.socialIntervalMin }, | |
| 898 | + (line) => broadcastAll({ type: 'social_log', line }), | |
| 899 | +); | |
| 900 | + | |
| 852 | 901 | // au démarrage, les chats marqués "running" par un ancien processus sont orphelins |
| 853 | 902 | for (const c of chats) if (c.state !== 'idle') c.state = 'idle'; |
| 854 | 903 | saveChats(); |
added
server/social.js
+424 −0
@@ -0,0 +1,424 @@ | ||
| 1 | +// admin-ka — module « Social » : publication automatique horaire d'un insight | |
| 2 | +// statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso | |
| 3 | +// simonpboucher), + génération d'un post sur demande depuis un prompt. | |
| 4 | +// | |
| 5 | +// Pipeline : insights.py (choix du fait marquant) -> render_card.py (1 image) | |
| 6 | +// -> légende (Claude / API Anthropic) -> publication Safari (injection JS, | |
| 7 | +// zéro clavier, zéro presse-papiers système) sur la Page. | |
| 8 | +// | |
| 9 | +// Tout tourne sur le nœud M3U96a où Safari est connecté au compte Facebook. | |
| 10 | + | |
| 11 | +import fs from 'node:fs'; | |
| 12 | +import path from 'node:path'; | |
| 13 | +import os from 'node:os'; | |
| 14 | +import crypto from 'node:crypto'; | |
| 15 | +import { execFile, spawn } from 'node:child_process'; | |
| 16 | +import { fileURLToPath } from 'node:url'; | |
| 17 | + | |
| 18 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 19 | +const APP_DIR = path.join(__dirname, '..'); | |
| 20 | +const DATA_DIR = path.join(APP_DIR, 'data'); | |
| 21 | +const SOCIAL_DIR = path.join(DATA_DIR, 'social'); | |
| 22 | +const CARDS_DIR = path.join(SOCIAL_DIR, 'cards'); | |
| 23 | +const STATE_PATH = path.join(SOCIAL_DIR, 'state.json'); | |
| 24 | +const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl'); | |
| 25 | +fs.mkdirSync(CARDS_DIR, { recursive: true }); | |
| 26 | + | |
| 27 | +const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso | |
| 28 | +const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`; | |
| 29 | +const PY = '/opt/homebrew/bin/python3'; | |
| 30 | +const INSIGHTS = path.join(__dirname, 'social', 'insights.py'); | |
| 31 | +const RENDER = path.join(__dirname, 'social', 'render_card.py'); | |
| 32 | + | |
| 33 | +let CFG = {}; // injecté par init() | |
| 34 | +let LOGGER = () => {}; | |
| 35 | +let timer = null; | |
| 36 | + | |
| 37 | +// ---------- état persistant ---------- | |
| 38 | +function loadState() { | |
| 39 | + try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {} | |
| 40 | + return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0 }; | |
| 41 | +} | |
| 42 | +let state = loadState(); | |
| 43 | +function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); } | |
| 44 | + | |
| 45 | +function logEvent(ev) { | |
| 46 | + const line = { ts: Date.now(), ...ev }; | |
| 47 | + try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {} | |
| 48 | + LOGGER(line); | |
| 49 | + return line; | |
| 50 | +} | |
| 51 | +export function socialLog(limit = 50) { | |
| 52 | + try { | |
| 53 | + const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean); | |
| 54 | + return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse(); | |
| 55 | + } catch { return []; } | |
| 56 | +} | |
| 57 | + | |
| 58 | +// ---------- utilitaires enfant ---------- | |
| 59 | +function run(bin, args, opts = {}) { | |
| 60 | + return new Promise((resolve) => { | |
| 61 | + execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => { | |
| 62 | + resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' }); | |
| 63 | + }); | |
| 64 | + }); | |
| 65 | +} | |
| 66 | +// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage) | |
| 67 | +let safariChain = Promise.resolve(); | |
| 68 | +function withSafari(fn) { | |
| 69 | + const next = safariChain.then(fn, fn); | |
| 70 | + safariChain = next.catch(() => {}); | |
| 71 | + return next; | |
| 72 | +} | |
| 73 | +const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| 74 | +function runInput(bin, args, input) { | |
| 75 | + return new Promise((resolve) => { | |
| 76 | + const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); | |
| 77 | + let out = '', err = ''; | |
| 78 | + ch.stdout.on('data', d => out += d); | |
| 79 | + ch.stderr.on('data', d => err += d); | |
| 80 | + ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err })); | |
| 81 | + ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' })); | |
| 82 | + ch.stdin.write(input); ch.stdin.end(); | |
| 83 | + }); | |
| 84 | +} | |
| 85 | + | |
| 86 | +// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ---------- | |
| 87 | +let fbWinId = null; | |
| 88 | + | |
| 89 | +async function osaJS(jsCode) { | |
| 90 | + // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu | |
| 91 | + const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`); | |
| 92 | + fs.writeFileSync(tmp, jsCode); | |
| 93 | + if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); } | |
| 94 | + const apple = `tell application "Safari"\nwith timeout of 25 seconds\ndo JavaScript (read POSIX file ${JSON.stringify(tmp)}) in tab 1 of window id ${fbWinId}\nend timeout\nend tell`; | |
| 95 | + const r = await run('/usr/bin/osascript', ['-e', apple]); | |
| 96 | + try { fs.unlinkSync(tmp); } catch {} | |
| 97 | + if (r.error) throw new Error('osascript: ' + r.error.trim()); | |
| 98 | + return r.stdout.trim(); | |
| 99 | +} | |
| 100 | + | |
| 101 | +async function resolveFbWindow() { | |
| 102 | + // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com | |
| 103 | + const findScript = ` | |
| 104 | +tell application "Safari" | |
| 105 | + with timeout of 15 seconds | |
| 106 | + set out to "" | |
| 107 | + repeat with w in windows | |
| 108 | + try | |
| 109 | + set u to URL of tab 1 of w | |
| 110 | + if u contains "facebook.com" then return (id of w as string) | |
| 111 | + end try | |
| 112 | + end repeat | |
| 113 | + return "NONE" | |
| 114 | + end timeout | |
| 115 | +end tell`; | |
| 116 | + let r = await run('/usr/bin/osascript', ['-e', findScript]); | |
| 117 | + let id = (r.stdout || '').trim(); | |
| 118 | + const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']'; | |
| 119 | + if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; } | |
| 120 | + // aucune : ouvrir la page dans une nouvelle fenêtre | |
| 121 | + const openScript = ` | |
| 122 | +tell application "Safari" | |
| 123 | + with timeout of 25 seconds | |
| 124 | + make new document with properties {URL:"${PAGE_URL}"} | |
| 125 | + delay 2 | |
| 126 | + return (id of window 1 as string) | |
| 127 | + end timeout | |
| 128 | +end tell`; | |
| 129 | + r = await run('/usr/bin/osascript', ['-e', openScript]); | |
| 130 | + const nid = (r.stdout || '').trim(); | |
| 131 | + fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null; | |
| 132 | + if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']'); | |
| 133 | + return fbWinId; | |
| 134 | +} | |
| 135 | + | |
| 136 | +async function gotoPage() { | |
| 137 | + await resolveFbWindow(); | |
| 138 | + await run('/usr/bin/osascript', ['-e', | |
| 139 | + `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]); | |
| 140 | +} | |
| 141 | + | |
| 142 | +async function pollJS(jsCode, want, tries = 12, delay = 4000) { | |
| 143 | + let last = ''; | |
| 144 | + for (let i = 0; i < tries; i++) { | |
| 145 | + try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; } | |
| 146 | + if (want(last)) return last; | |
| 147 | + await sleep(delay); | |
| 148 | + } | |
| 149 | + return last; | |
| 150 | +} | |
| 151 | + | |
| 152 | +// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause) | |
| 153 | +export function fbLoginState() { return withSafari(_fbLoginState); } | |
| 154 | +async function _fbLoginState() { | |
| 155 | + await resolveFbWindow(); | |
| 156 | + await run('/usr/bin/osascript', ['-e', | |
| 157 | + `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "https://www.facebook.com/"\nend timeout\nend tell`]); | |
| 158 | + await sleep(6000); | |
| 159 | + const js = `(function(){var b=document.body.innerText||"";` + | |
| 160 | + `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` + | |
| 161 | + `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` + | |
| 162 | + `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`; | |
| 163 | + const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000); | |
| 164 | + return out; | |
| 165 | +} | |
| 166 | + | |
| 167 | +// ---------- publication (texte + 1 image) sur la Page ---------- | |
| 168 | +export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); } | |
| 169 | +async function _publishToPage(caption, imagePath) { | |
| 170 | + await gotoPage(); | |
| 171 | + await sleep(9000); | |
| 172 | + | |
| 173 | + // 1) ouvrir le composeur | |
| 174 | + const openComposer = `(function(){ | |
| 175 | + var target=null; | |
| 176 | + document.querySelectorAll('span,div').forEach(function(e){ | |
| 177 | + var t=(e.innerText||'').trim(); | |
| 178 | + if(!target && e.children.length===0 && | |
| 179 | + (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 || | |
| 180 | + t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e; | |
| 181 | + }); | |
| 182 | + if(!target) return 'NOT_FOUND'; | |
| 183 | + var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN'; | |
| 184 | + ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); | |
| 185 | + return 'opened'; | |
| 186 | + })();`; | |
| 187 | + const r1 = await pollJS(openComposer, v => v === 'opened', 10, 5000); | |
| 188 | + if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')'); | |
| 189 | + await sleep(3000); | |
| 190 | + | |
| 191 | + // 2) coller la légende via ClipboardEvent synthétique (base64, sans clavier) | |
| 192 | + const b64cap = Buffer.from(caption, 'utf8').toString('base64'); | |
| 193 | + const pasteJS = `(function(){ | |
| 194 | + var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG'; | |
| 195 | + var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX'; | |
| 196 | + box.focus(); | |
| 197 | + var txt=decodeURIComponent(escape(atob('${b64cap}'))); | |
| 198 | + var dt=new DataTransfer(); dt.setData('text/plain', txt); | |
| 199 | + box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true})); | |
| 200 | + return 'pasted'; | |
| 201 | + })();`; | |
| 202 | + const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` + | |
| 203 | + `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` + | |
| 204 | + `return 'len:'+(box?box.innerText.length:-1);})();`; | |
| 205 | + let len = 0; | |
| 206 | + for (let i = 0; i < 3; i++) { | |
| 207 | + await osaJS(pasteJS); await sleep(2500); | |
| 208 | + const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10); | |
| 209 | + if (len > 40) break; await sleep(1500); | |
| 210 | + } | |
| 211 | + if (!(len > 40)) throw new Error('collage légende échoué'); | |
| 212 | + | |
| 213 | + // 3) injecter l'image dans l'input file du composeur (File construit en JS) | |
| 214 | + const imgB64 = fs.readFileSync(imagePath).toString('base64'); | |
| 215 | + const injectJS = `(function(){ | |
| 216 | + var inp=document.querySelector('div[role=dialog] input[type=file]') || | |
| 217 | + document.querySelector('input[type=file][accept*="image"]') || | |
| 218 | + document.querySelector('input[type=file]'); | |
| 219 | + if(!inp) return 'NO_INPUT'; | |
| 220 | + try{ | |
| 221 | + var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len); | |
| 222 | + for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i); | |
| 223 | + var file=new File([arr], 'ka-stat.png', {type:'image/png'}); | |
| 224 | + var dt=new DataTransfer(); dt.items.add(file); | |
| 225 | + inp.files=dt.files; | |
| 226 | + inp.dispatchEvent(new Event('change',{bubbles:true})); | |
| 227 | + return 'injected'; | |
| 228 | + }catch(e){ return 'ERR:'+e.message; } | |
| 229 | + })();`; | |
| 230 | + const inj = await osaJS(injectJS); | |
| 231 | + if (inj !== 'injected') { | |
| 232 | + // repli : bouton Photo/Vidéo puis re-tenter l'injection | |
| 233 | + const clickPhoto = `(function(){var btn=null; | |
| 234 | + document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){ | |
| 235 | + if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;}); | |
| 236 | + if(!btn) return 'NO_BTN'; | |
| 237 | + ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); | |
| 238 | + return 'clicked';})();`; | |
| 239 | + await osaJS(clickPhoto); await sleep(2500); | |
| 240 | + const inj2 = await osaJS(injectJS); | |
| 241 | + if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')'); | |
| 242 | + } | |
| 243 | + | |
| 244 | + // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue) | |
| 245 | + const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0'; | |
| 246 | + var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;}); | |
| 247 | + return ''+n;})();`; | |
| 248 | + const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000); | |
| 249 | + if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent'); | |
| 250 | + await sleep(2000); | |
| 251 | + | |
| 252 | + // 5) Suivant (si présent) puis Publier | |
| 253 | + const clickBtn = (label) => `(function(){ | |
| 254 | + var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null; | |
| 255 | + dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){ | |
| 256 | + if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});}); | |
| 257 | + if(!btn) return 'NO_BTN'; | |
| 258 | + if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED'; | |
| 259 | + ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); | |
| 260 | + return 'clicked';})();`; | |
| 261 | + const nx = await osaJS(clickBtn('Suivant')); | |
| 262 | + if (nx === 'clicked') await sleep(7000); | |
| 263 | + const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000); | |
| 264 | + if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')'); | |
| 265 | + | |
| 266 | + // 6) attendre la fermeture du dialogue (upload lent) | |
| 267 | + const closedJS = `document.querySelector('div[role=dialog]') ? 'OPEN' : 'CLOSED';`; | |
| 268 | + const closed = await pollJS(closedJS, v => v === 'CLOSED', 18, 8000); | |
| 269 | + if (closed !== 'CLOSED') throw new Error('dialogue non fermé après Publier (doute — ne pas retenter)'); | |
| 270 | + return true; | |
| 271 | +} | |
| 272 | + | |
| 273 | +// ---------- génération d'un brouillon (insight -> image + légende) ---------- | |
| 274 | +export async function pickInsight({ site = '', excludeRecent = true } = {}) { | |
| 275 | + const args = [INSIGHTS]; | |
| 276 | + if (site) args.push('--site', site); | |
| 277 | + else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(',')); | |
| 278 | + const r = await run(PY, args); | |
| 279 | + if (r.error) throw new Error('insights.py: ' + r.error); | |
| 280 | + const data = JSON.parse(r.stdout); | |
| 281 | + const cand = (data.candidates || [])[0]; | |
| 282 | + if (!cand) throw new Error('aucun insight disponible'); | |
| 283 | + return cand; | |
| 284 | +} | |
| 285 | + | |
| 286 | +export async function renderCard(insight) { | |
| 287 | + const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight)); | |
| 288 | + if (r.error) throw new Error('render_card.py: ' + r.error); | |
| 289 | + const p = r.stdout.trim().split('\n').pop(); | |
| 290 | + if (!p || !fs.existsSync(p)) throw new Error('image non produite'); | |
| 291 | + return p; | |
| 292 | +} | |
| 293 | + | |
| 294 | +// légende via API Anthropic (le modèle Claude rédige à partir du fait) | |
| 295 | +async function anthropic(system, user, model, maxTokens = 500) { | |
| 296 | + const key = CFG.anthropicKey; | |
| 297 | + if (!key) throw new Error('Clé Anthropic non configurée'); | |
| 298 | + const resp = await fetch('https://api.anthropic.com/v1/messages', { | |
| 299 | + method: 'POST', | |
| 300 | + headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, | |
| 301 | + body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }), | |
| 302 | + }); | |
| 303 | + const data = await resp.json(); | |
| 304 | + if (data.error) throw new Error(data.error.message || 'Erreur Anthropic'); | |
| 305 | + return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim(); | |
| 306 | +} | |
| 307 | + | |
| 308 | +const CAPTION_SYSTEM = `Tu es le gestionnaire des réseaux sociaux du Groupe KA, un écosystème québécois de sites d'agrégation de données (logement, immobilier, auto, épicerie, achat local, restos, sorties, emplois, créateurs, recherche). Tu écris des publications Facebook pour la Page « Groupe-KA », en français québécois, ton dynamique, clair et crédible. | |
| 309 | + | |
| 310 | +Règles STRICTES : | |
| 311 | +- 1 seule publication, 40 à 90 mots. | |
| 312 | +- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni). | |
| 313 | +- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA). | |
| 314 | +- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.`; | |
| 315 | + | |
| 316 | +export async function writeCaption(insight, extraPrompt = '') { | |
| 317 | + const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; | |
| 318 | + const url = `https://www.${insight.site}.com`; | |
| 319 | + const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` + | |
| 320 | + `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + | |
| 321 | + (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + | |
| 322 | + `\nRédige la publication Facebook.`; | |
| 323 | + return anthropic(CAPTION_SYSTEM, user, model, 500); | |
| 324 | +} | |
| 325 | + | |
| 326 | +// brouillon complet : insight -> image + légende (sans publier) | |
| 327 | +export async function generateDraft({ site = '', prompt = '' } = {}) { | |
| 328 | + let insight; | |
| 329 | + if (prompt && !site) { | |
| 330 | + // laisser le modèle choisir le site le plus pertinent pour ce prompt | |
| 331 | + const r = await run(PY, [INSIGHTS]); | |
| 332 | + const data = JSON.parse(r.stdout || '{}'); | |
| 333 | + const cands = data.candidates || []; | |
| 334 | + const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n'); | |
| 335 | + const chosen = await anthropic( | |
| 336 | + "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site (ex: immo-ka), rien d'autre.", | |
| 337 | + `Demande : ${prompt}\n\nInsights disponibles :\n${list}`, | |
| 338 | + CFG.socialModel || 'claude-opus-4-8', 30); | |
| 339 | + const pickSite = (chosen || '').trim().split(/\s/)[0]; | |
| 340 | + insight = cands.find(c => c.site === pickSite) || cands[0]; | |
| 341 | + } else { | |
| 342 | + insight = await pickInsight({ site }); | |
| 343 | + } | |
| 344 | + const image = await renderCard(insight); | |
| 345 | + const caption = await writeCaption(insight, prompt); | |
| 346 | + return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) }; | |
| 347 | +} | |
| 348 | + | |
| 349 | +// cycle complet automatique : choisir -> rendre -> rédiger -> publier | |
| 350 | +export async function runAutoCycle(trigger = 'auto') { | |
| 351 | + const started = logEvent({ kind: 'cycle_start', trigger }); | |
| 352 | + try { | |
| 353 | + const login = await fbLoginState(); | |
| 354 | + if (login !== 'LOGGED_IN') { | |
| 355 | + logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login }); | |
| 356 | + return { ok: false, reason: 'facebook_non_connecté' }; | |
| 357 | + } | |
| 358 | + const insight = await pickInsight({}); | |
| 359 | + const image = await renderCard(insight); | |
| 360 | + const caption = await writeCaption(insight); | |
| 361 | + await publishToPage(caption, image); | |
| 362 | + state.lastSites = [...state.lastSites, insight.site].slice(-6); | |
| 363 | + state.lastPostAt = Date.now(); | |
| 364 | + saveState(); | |
| 365 | + logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id, | |
| 366 | + headline: insight.headline, caption, image: path.basename(image) }); | |
| 367 | + return { ok: true, insight, caption, image }; | |
| 368 | + } catch (e) { | |
| 369 | + logEvent({ kind: 'cycle_error', trigger, error: e.message }); | |
| 370 | + return { ok: false, error: e.message }; | |
| 371 | + } | |
| 372 | +} | |
| 373 | + | |
| 374 | +// publier un brouillon fourni (post sur demande) | |
| 375 | +export async function publishDraft({ caption, image }) { | |
| 376 | + if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide'); | |
| 377 | + const login = await fbLoginState(); | |
| 378 | + if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud'); | |
| 379 | + await publishToPage(caption, image); | |
| 380 | + state.lastPostAt = Date.now(); saveState(); | |
| 381 | + logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) }); | |
| 382 | + return true; | |
| 383 | +} | |
| 384 | + | |
| 385 | +// ---------- planificateur horaire ---------- | |
| 386 | +function scheduleNext() { | |
| 387 | + clearTimeout(timer); | |
| 388 | + if (!state.auto) { state.nextAt = 0; saveState(); return; } | |
| 389 | + const period = (CFG.socialIntervalMin || 60) * 60 * 1000; | |
| 390 | + const since = Date.now() - (state.lastPostAt || 0); | |
| 391 | + const wait = Math.max(60 * 1000, period - since); | |
| 392 | + state.nextAt = Date.now() + wait; saveState(); | |
| 393 | + timer = setTimeout(async () => { | |
| 394 | + await runAutoCycle('auto'); | |
| 395 | + scheduleNext(); | |
| 396 | + }, wait); | |
| 397 | +} | |
| 398 | + | |
| 399 | +export function setAuto(on) { | |
| 400 | + state.auto = !!on; saveState(); | |
| 401 | + scheduleNext(); | |
| 402 | + logEvent({ kind: on ? 'auto_on' : 'auto_off' }); | |
| 403 | + return socialState(); | |
| 404 | +} | |
| 405 | + | |
| 406 | +export function socialState() { | |
| 407 | + return { | |
| 408 | + auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt, | |
| 409 | + lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60, | |
| 410 | + }; | |
| 411 | +} | |
| 412 | + | |
| 413 | +export function cardPath(name) { | |
| 414 | + const safe = path.basename(name); | |
| 415 | + const p = path.join(CARDS_DIR, safe); | |
| 416 | + return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null; | |
| 417 | +} | |
| 418 | + | |
| 419 | +export function initSocial(cfg, logger) { | |
| 420 | + CFG = cfg || {}; | |
| 421 | + LOGGER = logger || (() => {}); | |
| 422 | + if (state.auto) scheduleNext(); | |
| 423 | + logEvent({ kind: 'init', auto: state.auto }); | |
| 424 | +} | |
added
server/social/insights.py
+328 −0
@@ -0,0 +1,328 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Sélecteur de « fait marquant » pour la publication sociale automatique KA. | |
| 3 | + | |
| 4 | +Interroge les API stats des sites du Groupe KA, extrait plusieurs insights | |
| 5 | +candidats par site, les score par saillance, et renvoie un classement JSON. | |
| 6 | + | |
| 7 | +Usage : | |
| 8 | + insights.py -> classement complet (JSON sur stdout) | |
| 9 | + insights.py --exclude lou-ka,food-ka -> exclut ces sites (anti-répétition) | |
| 10 | + insights.py --site immo-ka -> ne renvoie que les candidats de ce site | |
| 11 | + insights.py --top 1 -> ne renvoie que le meilleur candidat | |
| 12 | +""" | |
| 13 | +import json, sys, urllib.request, argparse, ssl | |
| 14 | + | |
| 15 | +CTX = ssl.create_default_context() | |
| 16 | +CTX.check_hostname = False | |
| 17 | +CTX.verify_mode = ssl.CERT_NONE | |
| 18 | + | |
| 19 | +LIME = "#d9f26b" | |
| 20 | +ACCENT = { | |
| 21 | + "lou-ka": "#ff6a00", "immo-ka": "#e23744", "food-ka": "#1f9d55", | |
| 22 | + "fabri-ka": "#c4532e", "resto-ka": "#f08c00", "auto-ka": "#ff5a2a", | |
| 23 | + "vrai-prix": LIME, "trouve-ka": LIME, "sorti-ka": LIME, | |
| 24 | + "crea-ka": LIME, "job-ka": LIME, "groupe-ka": LIME, | |
| 25 | +} | |
| 26 | +LABELS = { | |
| 27 | + "lou-ka": "Lou·Ka", "immo-ka": "Immo·Ka", "food-ka": "Food·Ka", | |
| 28 | + "fabri-ka": "Fabri·Ka", "resto-ka": "Resto·Ka", "auto-ka": "Auto·Ka", | |
| 29 | + "vrai-prix": "Vrai-Prix", "trouve-ka": "Trouve·Ka", "sorti-ka": "Sorti·Ka", | |
| 30 | + "crea-ka": "Créa·Ka", "job-ka": "Job·Ka", "trouve-ka": "Trouve·Ka", | |
| 31 | +} | |
| 32 | +URLS = { | |
| 33 | + "lou-ka": "https://www.lou-ka.com/api/stats/detailed", | |
| 34 | + "immo-ka": "https://www.immo-ka.com/api/stats", | |
| 35 | + "food-ka": "https://www.food-ka.com/api/stats", | |
| 36 | + "fabri-ka": "https://www.fabri-ka.com/api/stats", | |
| 37 | + "resto-ka": "https://www.resto-ka.com/api/stats", | |
| 38 | + "auto-ka": "https://www.auto-ka.com/api/stats", | |
| 39 | + "vrai-prix": "https://www.vrai-prix.com/api/stats", | |
| 40 | + "sorti-ka": "https://www.sorti-ka.com/api/stats", | |
| 41 | + "crea-ka": "https://www.crea-ka.com/api/stats", | |
| 42 | + "job-ka": "https://www.job-ka.com/api/stats", | |
| 43 | + "trouve-ka": "https://www.trouve-ka.com/api/status", | |
| 44 | +} | |
| 45 | + | |
| 46 | + | |
| 47 | +def fr(n): | |
| 48 | + try: | |
| 49 | + n = int(round(float(n))) | |
| 50 | + except Exception: | |
| 51 | + return str(n) | |
| 52 | + return f"{n:,}".replace(",", " ") | |
| 53 | + | |
| 54 | + | |
| 55 | +def money(n): | |
| 56 | + return fr(n) + " $" | |
| 57 | + | |
| 58 | + | |
| 59 | +def get(url): | |
| 60 | + req = urllib.request.Request(url, headers={"User-Agent": "KA-Social/1.0"}) | |
| 61 | + with urllib.request.urlopen(req, timeout=15, context=CTX) as r: | |
| 62 | + return json.load(r) | |
| 63 | + | |
| 64 | + | |
| 65 | +def mk(site, insight_id, headline, headline_label, tiles, fact, salience, | |
| 66 | + bars=None, note=None, tagline=None): | |
| 67 | + return { | |
| 68 | + "site": site, "label": LABELS.get(site, site), "accent": ACCENT.get(site, LIME), | |
| 69 | + "insight_id": insight_id, "headline": headline, "headline_label": headline_label, | |
| 70 | + "tiles": tiles, "bars": bars, "note": note, "tagline": tagline, | |
| 71 | + "fact": fact, "salience": round(salience, 2), | |
| 72 | + } | |
| 73 | + | |
| 74 | + | |
| 75 | +# ---- extracteurs par site : renvoient une liste de candidats ---- | |
| 76 | + | |
| 77 | +def ex_lou_ka(d): | |
| 78 | + t = d.get("totals", d) | |
| 79 | + total = t.get("total", 0) | |
| 80 | + out = [] | |
| 81 | + out.append(mk("lou-ka", "total", fr(total), "logements à louer actifs au Québec", | |
| 82 | + [(fr(t.get("sources", 0)), "sources"), (fr(t.get("cities", 0)), "villes"), | |
| 83 | + (money(t.get("median", 0)), "loyer médian")], | |
| 84 | + f"Lou·Ka agrège maintenant {fr(total)} logements à louer actifs partout au Québec, " | |
| 85 | + f"depuis {fr(t.get('sources',0))} sources, loyer médian {money(t.get('median',0))}.", | |
| 86 | + 0.55 + milestone_bonus(total), | |
| 87 | + tagline="Tous les logements à louer du Québec, au même endroit.")) | |
| 88 | + # aubaines sous le marché (si dispo via dashboard non ici) — approx via by_type | |
| 89 | + bt = d.get("by_type", []) | |
| 90 | + bt = [x for x in bt if isinstance(x.get("avg_price"), (int, float)) and x.get("count", 0) > 500] | |
| 91 | + if bt: | |
| 92 | + bt = sorted(bt, key=lambda x: -x["count"])[:6] | |
| 93 | + out.append(mk("lou-ka", "by_type", fr(sum(x["count"] for x in bt)), | |
| 94 | + "logements ventilés par type", | |
| 95 | + [(money(t.get("avg", 0)), "loyer moyen"), (money(t.get("median", 0)), "médian"), | |
| 96 | + (fr(t.get("dispo_now", 0)), "dispo maintenant")], | |
| 97 | + "Le marché locatif québécois par type de logement, du studio au 6½ — " | |
| 98 | + "avec le loyer moyen de chacun.", | |
| 99 | + 0.5, | |
| 100 | + bars=[[x["key"], x["count"], fr(x["count"]), f"loyer moyen {money(x['avg_price'])}"] for x in bt], | |
| 101 | + tagline="Le marché locatif, mesuré en temps réel.")) | |
| 102 | + return out | |
| 103 | + | |
| 104 | + | |
| 105 | +def ex_immo_ka(d): | |
| 106 | + out = [] | |
| 107 | + total = d.get("total", 0) | |
| 108 | + vp = d.get("vraiprix", {}).get("ensemble", {}) | |
| 109 | + out.append(mk("immo-ka", "total", fr(total), "propriétés à vendre au Québec", | |
| 110 | + [(fr(d.get("sources", 0)), "sources"), (fr(d.get("cities", 0)), "villes"), | |
| 111 | + (money(d.get("avg_price", 0)), "prix moyen affiché")], | |
| 112 | + f"Immo·Ka réunit {fr(total)} propriétés à vendre au Québec, dans {fr(d.get('cities',0))} villes.", | |
| 113 | + 0.5 + milestone_bonus(total), | |
| 114 | + tagline="Toutes les propriétés à vendre, un seul site.")) | |
| 115 | + if vp.get("pct_sur10"): | |
| 116 | + out.append(mk("immo-ka", "vraiprix", f"{str(vp['pct_sur10']).replace('.',',')} %", | |
| 117 | + "des propriétés affichées à +10 % au-dessus de leur valeur réelle", | |
| 118 | + [(f"{str(vp.get('pct_sous5',0)).replace('.',',')} %", "sous-évaluées"), | |
| 119 | + (f"{str(vp.get('pct_juste',0)).replace('.',',')} %", "prix justes"), | |
| 120 | + (f"+{str(vp.get('median_delta_pct',0)).replace('.',',')} %", "écart médian")], | |
| 121 | + f"En croisant les prix affichés avec la valeur Vrai-Prix, Immo·Ka révèle que " | |
| 122 | + f"{str(vp['pct_sur10']).replace('.',',')} % des propriétés sont affichées à plus de 10 % " | |
| 123 | + f"au-dessus de leur valeur réelle estimée.", | |
| 124 | + 0.8, # insight distinctif et fort | |
| 125 | + tagline="Le seul site qui compare le prix affiché à la vraie valeur.")) | |
| 126 | + return out | |
| 127 | + | |
| 128 | + | |
| 129 | +def ex_vrai_prix(d): | |
| 130 | + out = [] | |
| 131 | + units = d.get("units_total", 0) | |
| 132 | + out.append(mk("vrai-prix", "units", fr(units), "unités d'évaluation couvertes au Québec", | |
| 133 | + [(f"{fr(round(d.get('value_total_2026',0)/1e9))} G$", "valeur totale 2026"), | |
| 134 | + (money(d.get("value_median_2026", 0)), "valeur médiane"), | |
| 135 | + (f"+{str(d.get('growth_2021_2026_pct','')).replace('.',',')} %", "croissance 2021→2026")], | |
| 136 | + f"Vrai-Prix évalue {fr(units)} unités — la quasi-totalité du parc immobilier québécois — " | |
| 137 | + f"pour une valeur totale estimée de {fr(round(d.get('value_total_2026',0)/1e9))} milliards de dollars en 2026.", | |
| 138 | + 0.7 + milestone_bonus(units), | |
| 139 | + note="Modèle hédonique + comparables — erreur médiane 11 %.", | |
| 140 | + tagline="La vraie valeur de chaque propriété du Québec.")) | |
| 141 | + return out | |
| 142 | + | |
| 143 | + | |
| 144 | +def ex_auto_ka(d): | |
| 145 | + out = [] | |
| 146 | + total = d.get("total", 0) | |
| 147 | + by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6] | |
| 148 | + out.append(mk("auto-ka", "total", fr(total), "véhicules d'occasion en vente au Québec", | |
| 149 | + [(fr(d.get("sources", 0)), "sources"), (money(d.get("avg_price", 0)), "prix moyen"), | |
| 150 | + (fr(d.get("recalls_total", 0)), "rappels croisés")], | |
| 151 | + f"Auto·Ka agrège {fr(total)} véhicules d'occasion depuis {fr(d.get('sources',0))} sources, " | |
| 152 | + f"et croise automatiquement {fr(d.get('recalls_total',0))} rappels de sécurité avec les annonces.", | |
| 153 | + 0.55 + milestone_bonus(total), | |
| 154 | + bars=[[x["region"], x["n"], fr(x["n"])] for x in by] if by else None, | |
| 155 | + tagline="Le marché de l'occasion, décodé.")) | |
| 156 | + return out | |
| 157 | + | |
| 158 | + | |
| 159 | +def ex_food_ka(d): | |
| 160 | + out = [] | |
| 161 | + total = d.get("total", 0) | |
| 162 | + on_sale = d.get("on_sale", 0) | |
| 163 | + out.append(mk("food-ka", "sales", fr(on_sale), "produits d'épicerie en solde en ce moment", | |
| 164 | + [(fr(total), "produits suivis"), (fr(d.get("sources", 0)), "bannières"), | |
| 165 | + (money(round(d.get("avg_price", 0), 2)).replace(" $", " $"), "prix moyen")], | |
| 166 | + f"Food·Ka repère en ce moment {fr(on_sale)} produits d'épicerie en solde, " | |
| 167 | + f"sur {fr(total)} produits suivis dans {fr(d.get('sources',0))} bannières.", | |
| 168 | + 0.6, | |
| 169 | + tagline="L'épicerie comparée, chaque jour.")) | |
| 170 | + cat = sorted([x for x in d.get("by_category", []) if x.get("n")], key=lambda x: -x["n"])[:6] | |
| 171 | + if cat: | |
| 172 | + out.append(mk("food-ka", "categories", fr(total), "produits d'épicerie suivis", | |
| 173 | + [(fr(on_sale), "en solde"), (fr(d.get("sources", 0)), "bannières"), | |
| 174 | + (fr(d.get("categories", 0)), "catégories")], | |
| 175 | + "Le panier québécois catégorie par catégorie, avec le prix moyen de chacune.", | |
| 176 | + 0.45, | |
| 177 | + bars=[[x["category"], x["n"], fr(x["n"]), f"prix moyen {money(round(x['avg_price'],2))}"] for x in cat], | |
| 178 | + tagline="L'épicerie comparée, chaque jour.")) | |
| 179 | + return out | |
| 180 | + | |
| 181 | + | |
| 182 | +def ex_fabri_ka(d): | |
| 183 | + out = [] | |
| 184 | + t = d.get("totals", {}) | |
| 185 | + total = t.get("products", 0) | |
| 186 | + by = sorted([x for x in d.get("by_region", []) if x.get("products")], key=lambda x: -x["products"])[:6] | |
| 187 | + out.append(mk("fabri-ka", "products", fr(total), "produits québécois répertoriés", | |
| 188 | + [(fr(t.get("stores_live", 0)), "boutiques en ligne"), (fr(t.get("regions", 0)), "régions"), | |
| 189 | + (fr(t.get("stores_registry", 0)), "au registre")], | |
| 190 | + f"Fabri·Ka répertorie {fr(total)} produits québécois provenant de {fr(t.get('stores_live',0))} " | |
| 191 | + f"boutiques en ligne, dans les {fr(t.get('regions',0))} régions du Québec.", | |
| 192 | + 0.6 + milestone_bonus(total), | |
| 193 | + bars=[[x["region"], x["products"], fr(x["products"])] for x in by] if by else None, | |
| 194 | + tagline="Le savoir-faire québécois, agrégé.")) | |
| 195 | + return out | |
| 196 | + | |
| 197 | + | |
| 198 | +def ex_resto_ka(d): | |
| 199 | + out = [] | |
| 200 | + total = d.get("restaurants", 0) | |
| 201 | + out.append(mk("resto-ka", "total", fr(total), "restaurants répertoriés au Québec", | |
| 202 | + [(fr(d.get("items", 0)), "plats aux menus"), (fr(d.get("menus", 0)), "menus complets"), | |
| 203 | + (fr(d.get("regions", 0)), "régions")], | |
| 204 | + f"Resto·Ka répertorie {fr(total)} restaurants et {fr(d.get('items',0))} plats détaillés " | |
| 205 | + f"dans {fr(d.get('menus',0))} menus complets, partout au Québec.", | |
| 206 | + 0.5 + milestone_bonus(total), | |
| 207 | + tagline="Tous les restos du Québec, une seule adresse.")) | |
| 208 | + by = sorted([x for x in d.get("by_region", []) if x.get("n")], key=lambda x: -x["n"])[:6] | |
| 209 | + if by: | |
| 210 | + out.append(mk("resto-ka", "regions", fr(total), "restaurants, par région", | |
| 211 | + [(fr(d.get("items", 0)), "plats"), (fr(d.get("inspections", 0)), "inspections MAPAQ"), | |
| 212 | + (fr(d.get("with_alcohol_permit", 0)), "permis d'alcool")], | |
| 213 | + "Le Québec gourmand région par région, des grands centres aux régions.", | |
| 214 | + 0.4, | |
| 215 | + bars=[[x["region"], x["n"], fr(x["n"])] for x in by], | |
| 216 | + tagline="Tous les restos du Québec, une seule adresse.")) | |
| 217 | + return out | |
| 218 | + | |
| 219 | + | |
| 220 | +def ex_sorti_ka(d): | |
| 221 | + out = [] | |
| 222 | + total = d.get("total_active", d.get("upcoming", 0)) | |
| 223 | + free = d.get("free_upcoming", 0) | |
| 224 | + out.append(mk("sorti-ka", "free", fr(free), "événements GRATUITS à venir au Québec", | |
| 225 | + [(fr(total), "événements actifs"), (fr(d.get("cities", 0)), "villes"), | |
| 226 | + (fr(d.get("sources", 0)), "sources")], | |
| 227 | + f"Sorti·Ka recense {fr(total)} événements à venir au Québec — dont {fr(free)} entièrement gratuits, " | |
| 228 | + f"dans {fr(d.get('cities',0))} villes.", | |
| 229 | + 0.6, | |
| 230 | + tagline="Toutes les sorties du Québec, en un coup d'œil.")) | |
| 231 | + return out | |
| 232 | + | |
| 233 | + | |
| 234 | +def ex_crea_ka(d): | |
| 235 | + out = [] | |
| 236 | + total = d.get("creators", 0) | |
| 237 | + bp = d.get("by_platform", {}) | |
| 238 | + bars = sorted(bp.items(), key=lambda x: -x[1])[:6] | |
| 239 | + out.append(mk("crea-ka", "creators", fr(total), "créateurs de contenu québécois recensés", | |
| 240 | + [(fr(d.get("accounts", 0)), "comptes reliés"), | |
| 241 | + (str(len(bp)), "plateformes"), (str(len(d.get("by_niche", {}))), "niches")], | |
| 242 | + f"Créa·Ka a cartographié {fr(total)} créateurs de contenu québécois, avec {fr(d.get('accounts',0))} " | |
| 243 | + f"comptes reliés sur {len(bp)} plateformes.", | |
| 244 | + 0.5 + milestone_bonus(total), | |
| 245 | + bars=[[k.capitalize(), v, fr(v)] for k, v in bars] if bars else None, | |
| 246 | + tagline="Les créateurs de contenu d'ici, cartographiés.")) | |
| 247 | + return out | |
| 248 | + | |
| 249 | + | |
| 250 | +def ex_job_ka(d): | |
| 251 | + out = [] | |
| 252 | + total = d.get("total", 0) | |
| 253 | + tc = sorted([x for x in d.get("top_cities", []) if x.get("n")], key=lambda x: -x["n"])[:6] | |
| 254 | + out.append(mk("job-ka", "total", fr(total), "offres d'emploi agrégées au Québec", | |
| 255 | + [(fr(d.get("employers", 0)), "employeurs"), (fr(d.get("sources", 0)), "sources"), | |
| 256 | + (money(round(d.get("avg_salary_year", 0))), "salaire moyen")], | |
| 257 | + f"Job·Ka agrège {fr(total)} offres d'emploi de {fr(d.get('employers',0))} employeurs, " | |
| 258 | + f"dont {fr(d.get('remote',0))} en télétravail.", | |
| 259 | + 0.5 + milestone_bonus(total), | |
| 260 | + bars=[[x["city"], x["n"], fr(x["n"])] for x in tc] if tc else None, | |
| 261 | + tagline="Tous les emplois du Québec, au même endroit.")) | |
| 262 | + return out | |
| 263 | + | |
| 264 | + | |
| 265 | +def ex_trouve_ka(d): | |
| 266 | + out = [] | |
| 267 | + pages = d.get("pages_indexed", 0) | |
| 268 | + out.append(mk("trouve-ka", "pages", fr(pages), "pages web québécoises indexées", | |
| 269 | + [(fr(d.get("domains_count", 0)), "domaines"), (fr(d.get("indexed_last_hour", 0)), "pages/heure"), | |
| 270 | + ("100 %", "recherche sémantique")], | |
| 271 | + f"Trouve·Ka a indexé {fr(pages)} pages web québécoises sur {fr(d.get('domains_count',0))} domaines, " | |
| 272 | + f"au rythme de {fr(d.get('indexed_last_hour',0))} pages à l'heure.", | |
| 273 | + 0.55 + milestone_bonus(pages), | |
| 274 | + tagline="Le moteur de recherche 100 % québécois.")) | |
| 275 | + return out | |
| 276 | + | |
| 277 | + | |
| 278 | +EXTRACT = { | |
| 279 | + "lou-ka": ex_lou_ka, "immo-ka": ex_immo_ka, "vrai-prix": ex_vrai_prix, | |
| 280 | + "auto-ka": ex_auto_ka, "food-ka": ex_food_ka, "fabri-ka": ex_fabri_ka, | |
| 281 | + "resto-ka": ex_resto_ka, "sorti-ka": ex_sorti_ka, "crea-ka": ex_crea_ka, | |
| 282 | + "job-ka": ex_job_ka, "trouve-ka": ex_trouve_ka, | |
| 283 | +} | |
| 284 | + | |
| 285 | + | |
| 286 | +def milestone_bonus(n): | |
| 287 | + """Bonus de saillance si le nombre vient de franchir un cap rond.""" | |
| 288 | + try: | |
| 289 | + n = float(n) | |
| 290 | + except Exception: | |
| 291 | + return 0.0 | |
| 292 | + if n <= 0: | |
| 293 | + return 0.0 | |
| 294 | + for cap in (1_000_000, 500_000, 100_000, 50_000, 10_000): | |
| 295 | + if n >= cap and n < cap * 1.06: # fraîchement franchi | |
| 296 | + return 0.25 | |
| 297 | + return 0.0 | |
| 298 | + | |
| 299 | + | |
| 300 | +def main(): | |
| 301 | + ap = argparse.ArgumentParser() | |
| 302 | + ap.add_argument("--exclude", default="") | |
| 303 | + ap.add_argument("--site", default="") | |
| 304 | + ap.add_argument("--top", type=int, default=0) | |
| 305 | + args = ap.parse_args() | |
| 306 | + exclude = set(s for s in args.exclude.split(",") if s) | |
| 307 | + sites = [args.site] if args.site else list(EXTRACT.keys()) | |
| 308 | + | |
| 309 | + cands = [] | |
| 310 | + errors = {} | |
| 311 | + for site in sites: | |
| 312 | + if site in exclude or site not in EXTRACT: | |
| 313 | + continue | |
| 314 | + try: | |
| 315 | + d = get(URLS[site]) | |
| 316 | + cands.extend(EXTRACT[site](d)) | |
| 317 | + except Exception as e: | |
| 318 | + errors[site] = str(e) | |
| 319 | + | |
| 320 | + cands.sort(key=lambda c: -c["salience"]) | |
| 321 | + out = {"candidates": cands, "errors": errors} | |
| 322 | + if args.top: | |
| 323 | + out["candidates"] = cands[:args.top] | |
| 324 | + print(json.dumps(out, ensure_ascii=False)) | |
| 325 | + | |
| 326 | + | |
| 327 | +if __name__ == "__main__": | |
| 328 | + main() | |
added
server/social/render_card.py
+141 −0
@@ -0,0 +1,141 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Rend UNE carte de stat KA (1080x1350) à partir d'un insight JSON sur stdin. | |
| 3 | + | |
| 4 | +Style maison Groupe KA : charbon + accent du site + crème. Nombre-héros, | |
| 5 | +3 tuiles, et barres à étiquettes directes si l'insight en fournit. | |
| 6 | +Imprime le chemin du PNG produit sur stdout. | |
| 7 | +""" | |
| 8 | +import json, sys, os, datetime | |
| 9 | +from PIL import Image, ImageDraw, ImageFont | |
| 10 | + | |
| 11 | +CHARCOAL = (20, 24, 20) | |
| 12 | +CHARCOAL2 = (30, 36, 30) | |
| 13 | +CREAM = (242, 241, 234) | |
| 14 | +GRAY = (150, 156, 148) | |
| 15 | +RING = (46, 54, 46) | |
| 16 | + | |
| 17 | +BLACK_F = "/System/Library/Fonts/Supplemental/Arial Black.ttf" | |
| 18 | +BOLD_F = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" | |
| 19 | +MONO_F = "/System/Library/Fonts/Supplemental/Courier New Bold.ttf" | |
| 20 | + | |
| 21 | +W, H = 1080, 1350 | |
| 22 | + | |
| 23 | + | |
| 24 | +def hex2rgb(h): | |
| 25 | + h = h.lstrip("#") | |
| 26 | + return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) | |
| 27 | + | |
| 28 | + | |
| 29 | +def f(p, s): | |
| 30 | + return ImageFont.truetype(p, s) | |
| 31 | + | |
| 32 | + | |
| 33 | +def fit(d, text, path, size, maxw, floor=40): | |
| 34 | + while size > floor: | |
| 35 | + fo = f(path, size) | |
| 36 | + if d.textlength(text, font=fo) <= maxw: | |
| 37 | + return fo | |
| 38 | + size -= 4 | |
| 39 | + return f(path, size) | |
| 40 | + | |
| 41 | + | |
| 42 | +def wrap(d, text, font, maxw, maxlines=2): | |
| 43 | + words, lines, cur = text.split(), [], "" | |
| 44 | + for w in words: | |
| 45 | + probe = (cur + " " + w).strip() | |
| 46 | + if d.textlength(probe, font=font) <= maxw: | |
| 47 | + cur = probe | |
| 48 | + else: | |
| 49 | + lines.append(cur); cur = w | |
| 50 | + lines.append(cur) | |
| 51 | + return lines[:maxlines] | |
| 52 | + | |
| 53 | + | |
| 54 | +def render(ins, out_dir): | |
| 55 | + accent = hex2rgb(ins["accent"]) | |
| 56 | + domain = "www." + ins["site"] + ".com" | |
| 57 | + img = Image.new("RGB", (W, H), CHARCOAL) | |
| 58 | + d = ImageDraw.Draw(img) | |
| 59 | + d.rounded_rectangle([16, 16, W - 16, H - 16], radius=26, outline=RING, width=3) | |
| 60 | + | |
| 61 | + # Eyebrow avec la date du jour | |
| 62 | + today = datetime.date.today() | |
| 63 | + mois = ["janvier","février","mars","avril","mai","juin","juillet","août", | |
| 64 | + "septembre","octobre","novembre","décembre"][today.month - 1] | |
| 65 | + d.line([64, 92, 112, 92], fill=accent, width=6) | |
| 66 | + d.text((130, 92), f"{ins['label'].upper()} · STAT DU JOUR · {today.day} {mois.upper()} {today.year}", | |
| 67 | + font=f(BOLD_F, 25), fill=accent, anchor="lm") | |
| 68 | + | |
| 69 | + # Boîte KA inclinée | |
| 70 | + box = Image.new("RGBA", (150, 96), (0, 0, 0, 0)) | |
| 71 | + bd = ImageDraw.Draw(box) | |
| 72 | + bd.rounded_rectangle([0, 0, 149, 95], radius=20, fill=accent) | |
| 73 | + bd.text((75, 48), "KA", font=f(BLACK_F, 58), fill=CHARCOAL, anchor="mm") | |
| 74 | + box = box.rotate(-4, expand=True, resample=Image.BICUBIC) | |
| 75 | + img.paste(box, (W - 64 - box.width, 52), box) | |
| 76 | + | |
| 77 | + # Nom + tagline | |
| 78 | + d.text((64, 165), ins["label"], font=f(BLACK_F, 88), fill=CREAM) | |
| 79 | + if ins.get("tagline"): | |
| 80 | + d.text((66, 292), ins["tagline"], font=f(BOLD_F, 31), fill=GRAY) | |
| 81 | + | |
| 82 | + # Héros | |
| 83 | + hf = fit(d, ins["headline"], BLACK_F, 176, W - 128) | |
| 84 | + d.text((60, 372), ins["headline"], font=hf, fill=accent) | |
| 85 | + hl = wrap(d, ins["headline_label"], f(BOLD_F, 40), W - 128, 2) | |
| 86 | + for i, ln in enumerate(hl): | |
| 87 | + d.text((66, 372 + hf.size + 44 + i * 46), ln, font=f(BOLD_F, 40), fill=CREAM) | |
| 88 | + | |
| 89 | + y = 372 + hf.size + 44 + len(hl) * 46 + 40 | |
| 90 | + | |
| 91 | + # Tuiles | |
| 92 | + tiles = ins.get("tiles") or [] | |
| 93 | + if tiles: | |
| 94 | + d.line([64, y, W - 64, y], fill=RING, width=2) | |
| 95 | + y += 26 | |
| 96 | + col = (W - 128) / len(tiles) | |
| 97 | + for i, (val, lab) in enumerate(tiles): | |
| 98 | + x = 64 + int(i * col) | |
| 99 | + vf = fit(d, str(val), BLACK_F, 52, col - 24) | |
| 100 | + d.text((x, y), str(val), font=vf, fill=CREAM) | |
| 101 | + for j, ln in enumerate(wrap(d, lab, f(BOLD_F, 25), col - 24, 2)): | |
| 102 | + d.text((x, y + 70 + j * 32), ln, font=f(BOLD_F, 25), fill=GRAY) | |
| 103 | + y += 150 | |
| 104 | + | |
| 105 | + # Barres (optionnelles) | |
| 106 | + bars = ins.get("bars") | |
| 107 | + if bars: | |
| 108 | + maxv = max(b[1] for b in bars) or 1 | |
| 109 | + avail = (1150 if not ins.get("note") else 1120) - y | |
| 110 | + rowh = min(avail / len(bars), 118) | |
| 111 | + for i, b in enumerate(bars): | |
| 112 | + label, v, vtxt = b[0], b[1], b[2] | |
| 113 | + sub = b[3] if len(b) > 3 else None | |
| 114 | + yy = y + int(i * rowh) | |
| 115 | + d.text((64, yy), str(label), font=f(BOLD_F, 31), fill=CREAM) | |
| 116 | + d.text((W - 64, yy), str(vtxt), font=f(BLACK_F, 32), fill=accent, anchor="ra") | |
| 117 | + if sub: | |
| 118 | + d.text((64, yy + 42), sub, font=f(BOLD_F, 24), fill=GRAY) | |
| 119 | + by = yy + (78 if sub else 46) | |
| 120 | + d.rounded_rectangle([64, by, W - 64, by + 15], radius=7, fill=CHARCOAL2) | |
| 121 | + fw = max(18, int((W - 128) * v / maxv)) | |
| 122 | + d.rounded_rectangle([64, by, 64 + fw, by + 15], radius=7, fill=accent) | |
| 123 | + | |
| 124 | + if ins.get("note"): | |
| 125 | + d.text((64, 1150), ins["note"], font=f(BOLD_F, 27), fill=GRAY) | |
| 126 | + | |
| 127 | + # Bandeau bas | |
| 128 | + d.rounded_rectangle([16, 1246, W - 16, H - 16], radius=26, fill=accent) | |
| 129 | + d.text((64, 1290), domain, font=f(MONO_F, 40), fill=CHARCOAL, anchor="lm") | |
| 130 | + d.text((W - 64, 1290), "GROUPE ·KA", font=f(BLACK_F, 34), fill=CHARCOAL, anchor="rm") | |
| 131 | + | |
| 132 | + os.makedirs(out_dir, exist_ok=True) | |
| 133 | + path = os.path.join(out_dir, f"card-{ins['site']}-{ins['insight_id']}.png") | |
| 134 | + img.save(path) | |
| 135 | + return path | |
| 136 | + | |
| 137 | + | |
| 138 | +if __name__ == "__main__": | |
| 139 | + ins = json.load(sys.stdin) | |
| 140 | + out_dir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ka-social-cards" | |
| 141 | + print(render(ins, out_dir)) | |
| 142 | ||