SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent 47e6559

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