1// admin-ka — module « Social » : publication automatique horaire d'un insight2// statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso3// 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.1011import fs from 'node:fs';12import path from 'node:path';13import os from 'node:os';14import crypto from 'node:crypto';15import { execFile, spawn } from 'node:child_process';16import { fileURLToPath } from 'node:url';1718const __dirname = path.dirname(fileURLToPath(import.meta.url));19const APP_DIR = path.join(__dirname, '..');20const DATA_DIR = path.join(APP_DIR, 'data');21const SOCIAL_DIR = path.join(DATA_DIR, 'social');22const CARDS_DIR = path.join(SOCIAL_DIR, 'cards');23const REELS_DIR = path.join(SOCIAL_DIR, 'reels');24const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');25const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');26fs.mkdirSync(CARDS_DIR, { recursive: true });27fs.mkdirSync(REELS_DIR, { recursive: true });2829const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso30const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;31const PY = '/opt/homebrew/bin/python3';32const INSIGHTS = path.join(__dirname, 'social', 'insights.py');33const RENDER = path.join(__dirname, 'social', 'render_card.py');34const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');35const MAKE_REEL = path.join(__dirname, 'social', 'make_reel.py');3637let CFG = {}; // injecté par init()38let LOGGER = () => {};39let timer = null;4041// ---------- état persistant ----------42function loadState() {43 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}44 return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };45}46let state = loadState();47function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4849function logEvent(ev) {50 const line = { ts: Date.now(), ...ev };51 try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}52 LOGGER(line);53 return line;54}55export function socialLog(limit = 50) {56 try {57 const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);58 return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();59 } catch { return []; }60}6162// ---------- utilitaires enfant ----------63function run(bin, args, opts = {}) {64 return new Promise((resolve) => {65 execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {66 resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });67 });68 });69}70// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)71let safariChain = Promise.resolve();72function withSafari(fn) {73 const next = safariChain.then(fn, fn);74 safariChain = next.catch(() => {});75 return next;76}77const sleep = (ms) => new Promise(r => setTimeout(r, ms));78function runInput(bin, args, input) {79 return new Promise((resolve) => {80 const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });81 let out = '', err = '';82 ch.stdout.on('data', d => out += d);83 ch.stderr.on('data', d => err += d);84 ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));85 ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));86 ch.stdin.write(input); ch.stdin.end();87 });88}8990// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------91let fbWinId = null;9293async function osaJS(jsCode) {94 // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu95 const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);96 fs.writeFileSync(tmp, jsCode);97 if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }98 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`;99 const r = await run('/usr/bin/osascript', ['-e', apple]);100 try { fs.unlinkSync(tmp); } catch {}101 if (r.error) throw new Error('osascript: ' + r.error.trim());102 return r.stdout.trim();103}104105async function resolveFbWindow() {106 // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com107 const findScript = `108tell application "Safari"109 with timeout of 15 seconds110 set out to ""111 repeat with w in windows112 try113 set u to URL of tab 1 of w114 if u contains "facebook.com" then return (id of w as string)115 end try116 end repeat117 return "NONE"118 end timeout119end tell`;120 let r = await run('/usr/bin/osascript', ['-e', findScript]);121 let id = (r.stdout || '').trim();122 const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';123 if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }124 // aucune : ouvrir la page dans une nouvelle fenêtre125 const openScript = `126tell application "Safari"127 with timeout of 25 seconds128 make new document with properties {URL:"${PAGE_URL}"}129 delay 2130 return (id of window 1 as string)131 end timeout132end tell`;133 r = await run('/usr/bin/osascript', ['-e', openScript]);134 const nid = (r.stdout || '').trim();135 fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;136 if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');137 return fbWinId;138}139140async function gotoPage() {141 await resolveFbWindow();142 try { await run('/usr/bin/osascript', ['-e', `tell application "Safari" to set bounds of window id ${fbWinId} to {0, 0, 1512, 964}`]); } catch {}143 try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}144 await run('/usr/bin/osascript', ['-e',145 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);146}147148// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)149const DISMISS_JS = `(function(){150 var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];151 var clicked='';152 document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){153 if(clicked) return;154 var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';155 for(var i=0;i<labels.length;i++){156 if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){157 ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});158 clicked=labels[i]||al; break;159 }160 }161 });162 return clicked||'NONE';163})();`;164165async function pollJS(jsCode, want, tries = 12, delay = 4000) {166 let last = '';167 for (let i = 0; i < tries; i++) {168 try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }169 if (want(last)) return last;170 await sleep(delay);171 }172 return last;173}174175async function dismissModals(times) {176 times = times || 3;177 let any = false;178 for (let i = 0; i < times; i++) {179 let r = 'NONE';180 try { r = await osaJS(DISMISS_JS); } catch {}181 if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;182 }183 return any;184}185186// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)187export function fbLoginState() { return withSafari(_fbLoginState); }188async function _fbLoginState() {189 await resolveFbWindow();190 try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}191 await run('/usr/bin/osascript', ['-e',192 `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`]);193 await sleep(6000);194 const js = `(function(){var b=document.body.innerText||"";` +195 `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +196 `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +197 `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;198 const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);199 return out;200}201202// ---------- publication (texte + 1 image) sur la Page ----------203export function publishToPage(caption, imagePath) { return withSafari(() => _publishMedia(caption, imagePath, false)); }204export function publishReelToPage(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true)); }205async function _publishMedia(caption, mediaPath, isVideo) {206 await gotoPage();207 await sleep(9000);208209 // 1) ouvrir le composeur210 const openComposer = `(function(){211 var target=null;212 document.querySelectorAll('span,div').forEach(function(e){213 var t=(e.innerText||'').trim();214 if(!target && e.children.length===0 &&215 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||216 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;217 });218 if(!target) return 'NOT_FOUND';219 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';220 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});221 return 'opened';222 })();`;223 // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans224 // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.225 const basculer = `(function(){226 var btn=null;227 document.querySelectorAll('[role=button]').forEach(function(e){228 var t=(e.innerText||'').trim();229 if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;230 });231 if(!btn) return 'NO_BTN';232 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});233 return 'switched';234 })();`;235 let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);236 if (r1 !== 'opened') {237 const sw = await osaJS(basculer);238 if (sw === 'switched') { await sleep(9000); await dismissModals(3); }239 r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);240 }241 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');242 await sleep(3000);243244 // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés245 // et le double collage) — atomique : ne colle que si le box est encore vide.246 const b64cap = Buffer.from(caption, 'utf8').toString('base64');247 // trouve le contenteditable visible le plus « profond » (composeur actif)248 const findBoxFn = `function(){249 var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]'));250 if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]'));251 var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;});252 return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null);253 }`;254 const pasteJS = `(function(){255 var box=(${findBoxFn})(); if(!box) return 'NO_BOX';256 if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller257 box.focus();258 var txt=decodeURIComponent(escape(atob('${b64cap}')));259 var dt=new DataTransfer(); dt.setData('text/plain', txt);260 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));261 return 'pasted';262 })();`;263 const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`;264 let len = 0;265 for (let i = 0; i < 3; i++) {266 const r = await osaJS(pasteJS);267 await sleep(2500);268 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);269 if (len > 40) break;270 await sleep(1200);271 }272 if (!(len > 40)) throw new Error('collage légende échoué');273274 // 3) injecter le média (image OU vidéo) dans l'input file du composeur275 const mediaB64 = fs.readFileSync(mediaPath).toString('base64');276 const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';277 const mtype = isVideo ? 'video/mp4' : 'image/png';278 const injectJS = `(function(){279 var inp=document.querySelector('div[role=dialog] input[type=file]') ||280 document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||281 document.querySelector('input[type=file]');282 if(!inp) return 'NO_INPUT';283 try{284 var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);285 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);286 var file=new File([arr], '${fname}', {type:'${mtype}'});287 var dt=new DataTransfer(); dt.items.add(file);288 inp.files=dt.files;289 inp.dispatchEvent(new Event('change',{bubbles:true}));290 return 'injected';291 }catch(e){ return 'ERR:'+e.message; }292 })();`;293 const inj = await osaJS(injectJS);294 if (inj !== 'injected') {295 // repli : bouton Photo/Vidéo puis re-tenter l'injection296 const clickPhoto = `(function(){var btn=null;297 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){298 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});299 if(!btn) return 'NO_BTN';300 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});301 return 'clicked';})();`;302 await osaJS(clickPhoto); await sleep(2500);303 const inj2 = await osaJS(injectJS);304 if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');305 }306307 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)308 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';309 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});310 dlg.querySelectorAll('video').forEach(function(){n++;});311 return ''+n;})();`;312 const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long313 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);314 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');315 await sleep(isVideo ? 4000 : 2000);316317 // 5) Suivant (si présent) puis Publier318 const clickBtn = (label) => `(function(){319 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;320 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){321 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});322 if(!btn) return 'NO_BTN';323 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';324 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});325 return 'clicked';})();`;326 // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS327 // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)328 const focusBtn = (labels) => `(function(){329 var want=${JSON.stringify(labels)}; var btn=null;330 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){331 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();332 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;333 });334 if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();335 return (document.activeElement===btn)?'focused':'focus_fail';336 })();`;337 async function clickBtnOS(labels) {338 const coordJS = `(function(){339 var want=${JSON.stringify(labels)}; var btn=null;340 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){341 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();342 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;343 });344 if(!btn) return 'NO_BTN';345 try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}346 var r=btn.getBoundingClientRect();347 var gx=Math.round(window.screenX + r.left + r.width/2);348 var gy=Math.round(window.screenY + r.top + r.height/2);349 return gx+' '+gy;350 })();`;351 const co = await osaJS(coordJS);352 if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';353 const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));354 if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;355 const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',356 '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);357 return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';358 }359 async function advanceByKey(labels) {360 const f = await osaJS(focusBtn(labels));361 if (f !== 'focused') return f;362 await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);363 return 'pressed';364 }365 const clickAny = (labels) => `(function(){366 var want=${JSON.stringify(labels)}; var btn=null;367 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){368 if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();369 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;370 });371 if(!btn) return 'NO_BTN';372 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});373 return 'clicked';374 })();`;375 const canPublishJS = `(function(){var ok=false;document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){var al=(b.getAttribute('aria-label')||b.innerText||'').trim();if((al==='Publier'||al==='Partager')&&b.getAttribute('aria-disabled')!=='true')ok=true;});return ok?'YES':'NO';})();`;376 if (isVideo) {377 // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle378 for (let i = 0; i < 6; i++) {379 if ((await osaJS(canPublishJS)) === 'YES') break;380 const r = await clickBtnOS(['Suivant']);381 if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }382 }383 // Publier / Partager par focus + Entrée (poll pendant le téléversement)384 let done = false;385 for (let i = 0; i < 14; i++) {386 if ((await osaJS(canPublishJS)) === 'YES') {387 const r = await clickBtnOS(['Publier', 'Partager']);388 if (r === 'clicked') { done = true; break; }389 }390 await sleep(5000); await dismissModals(1);391 }392 if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');393 } else {394 const nx = await osaJS(clickAny(['Suivant']));395 if (nx === 'clicked') await sleep(7000);396 const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);397 if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');398 }399400 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte401 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique402 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.403 const composerGoneJS = `(function(){404 var open=false;405 document.querySelectorAll('div[role=dialog]').forEach(function(d){406 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;407 });408 return open ? 'OPEN' : 'GONE';409 })();`;410 let gone = 'OPEN';411 const goneTries = isVideo ? 40 : 18;412 for (let i = 0; i < goneTries; i++) {413 await sleep(8000);414 await dismissModals(2); // ferme toute boîte non bloquante en route415 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }416 if (gone === 'GONE') break;417 }418 await dismissModals(3); // ferme la boîte « Plus tard » finale419 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');420 return true;421}422423// ---------- génération d'un brouillon (insight -> image + légende) ----------424export async function pickInsight({ site = '', excludeRecent = true } = {}) {425 const args = [INSIGHTS];426 if (site) args.push('--site', site);427 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));428 const r = await run(PY, args);429 if (r.error) throw new Error('insights.py: ' + r.error);430 const data = JSON.parse(r.stdout);431 const cand = (data.candidates || [])[0];432 if (!cand) throw new Error('aucun insight disponible');433 return cand;434}435436export async function renderCard(insight) {437 // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec438 let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));439 let p = (r.stdout || '').trim().split('\n').pop();440 if (r.error || !p || !fs.existsSync(p)) {441 logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });442 r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));443 p = (r.stdout || '').trim().split('\n').pop();444 if (r.error) throw new Error('render: ' + r.error);445 }446 if (!p || !fs.existsSync(p)) throw new Error('image non produite');447 return p;448}449450// --- produit vedette (photo réelle) pour le genre « spotlight » ---451const SPOT = {452 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },453 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },454 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },455 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },456};457async function fetchFeatured(site) {458 const cfg = SPOT[site]; if (!cfg) return null;459 try {460 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });461 const data = await res.json();462 const arr = data[cfg.arr] || [];463 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');464 for (const it of arr) {465 const img = (it.images && it.images[0]) || it.image || '';466 const title = (it.title || it.name || '').trim();467 if (!img || !title || title.length > 80) continue;468 const price = it.price || it.price_min || null;469 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;470 if (site === 'lou-ka') {471 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),472 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,473 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };474 }475 if (site === 'immo-ka') {476 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;477 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),478 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,479 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };480 }481 if (site === 'fabri-ka') {482 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),483 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,484 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };485 }486 if (site === 'sorti-ka') {487 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),488 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,489 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };490 }491 }492 } catch {}493 return null;494}495496async function decorateForPrompt(insight) {497 if (SPOT[insight.site]) {498 const feat = await fetchFeatured(insight.site);499 if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }500 }501 insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';502 return insight;503}504505// choisit le genre visuel (rotation) et attache le produit vedette si spotlight506async function decorateCreative(insight) {507 const n = state.postCount || 0;508 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';509 if (SPOT[insight.site] && (n % 3 === 0)) {510 const feat = await fetchFeatured(insight.site);511 if (feat) { insight.product = feat; genre = 'spotlight'; }512 }513 insight.genre = genre;514 return insight;515}516517// légende via API Anthropic (le modèle Claude rédige à partir du fait)518async function anthropic(system, user, model, maxTokens = 500) {519 const key = CFG.anthropicKey;520 if (!key) throw new Error('Clé Anthropic non configurée');521 const resp = await fetch('https://api.anthropic.com/v1/messages', {522 method: 'POST',523 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },524 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),525 });526 const data = await resp.json();527 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');528 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();529}530531const 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.532533Règles STRICTES :534- 1 seule publication, 40 à 90 mots.535- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).536- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).537- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.\n- N'ajoute PAS de signature toi-même (elle est ajoutée automatiquement).`;538539export async function writeCaption(insight, extraPrompt = '') {540 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';541 const url = `https://www.${insight.site}.com`;542 let user;543 if (insight.product && insight.genre === 'spotlight') {544 const pr = insight.product;545 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +546 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +547 (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +548 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +549 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +550 `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;551 } else {552 user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +553 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +554 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +555 `\nRédige la publication Facebook.`;556 }557 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);558 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";559}560561// brouillon complet : insight -> image + légende (sans publier)562export async function generateDraft({ site = '', prompt = '' } = {}) {563 let insight;564 if (prompt && !site) {565 // laisser le modèle choisir le site le plus pertinent pour ce prompt566 const r = await run(PY, [INSIGHTS]);567 const data = JSON.parse(r.stdout || '{}');568 const cands = data.candidates || [];569 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');570 const chosen = await anthropic(571 "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.",572 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,573 CFG.socialModel || 'claude-opus-4-8', 30);574 const pickSite = (chosen || '').trim().split(/\s/)[0];575 insight = cands.find(c => c.site === pickSite) || cands[0];576 await decorateForPrompt(insight);577 } else {578 insight = await pickInsight({ site });579 await decorateCreative(insight);580 }581 const image = await renderCard(insight);582 const caption = await writeCaption(insight, prompt);583 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };584}585586// cycle complet automatique : choisir -> rendre -> rédiger -> publier587export async function runAutoCycle(trigger = 'auto') {588 const started = logEvent({ kind: 'cycle_start', trigger });589 try {590 const login = await fbLoginState();591 if (login !== 'LOGGED_IN') {592 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });593 return { ok: false, reason: 'facebook_non_connecté' };594 }595 const insight = await pickInsight({});596 await decorateCreative(insight);597 const image = await renderCard(insight);598 const caption = await writeCaption(insight);599 await publishToPage(caption, image);600 state.lastSites = [...state.lastSites, insight.site].slice(-6);601 state.lastPostAt = Date.now();602 state.postCount = (state.postCount || 0) + 1;603 saveState();604 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,605 headline: insight.headline, caption, image: path.basename(image) });606 return { ok: true, insight, caption, image };607 } catch (e) {608 logEvent({ kind: 'cycle_error', trigger, error: e.message });609 return { ok: false, error: e.message };610 }611}612613// publier un brouillon fourni (post sur demande)614export async function publishDraft({ caption, image }) {615 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');616 const login = await fbLoginState();617 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');618 await publishToPage(caption, image);619 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();620 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });621 return true;622}623624// ---------- Reels (vidéo animée + musique) ----------625export async function makeReel(insight) {626 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));627 const p = (r.stdout || '').trim().split('\n').pop();628 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));629 return p;630}631632export function reelPath(name) {633 const safe = path.basename(name);634 const fp = path.join(REELS_DIR, safe);635 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;636}637638// brouillon reel : insight -> vidéo + légende (sans publier)639export async function generateReelDraft({ site = '', prompt = '' } = {}) {640 let insight;641 if (prompt && !site) {642 const r = await run(PY, [INSIGHTS]);643 const cands = (JSON.parse(r.stdout || '{}').candidates) || [];644 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');645 const chosen = await anthropic(646 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",647 `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);648 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];649 await decorateForPrompt(insight);650 } else {651 insight = await pickInsight({ site });652 await decorateForPrompt(insight);653 }654 const video = await makeReel(insight);655 const caption = await writeCaption(insight, prompt);656 return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };657}658659export async function publishReelDraft({ caption, video }) {660 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');661 const login = await fbLoginState();662 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');663 await publishReelToPage(caption, video);664 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();665 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });666 return true;667}668669// cycle reel complet automatique670export async function runReelCycle(trigger = 'reel') {671 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });672 try {673 const login = await fbLoginState();674 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }675 const insight = await pickInsight({});676 const video = await makeReel(insight);677 const caption = await writeCaption(insight);678 await publishReelToPage(caption, video);679 state.lastSites = [...state.lastSites, insight.site].slice(-6);680 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();681 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });682 return { ok: true, insight, caption, video };683 } catch (e) {684 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });685 return { ok: false, error: e.message };686 }687}688689// ---------- planificateur horaire ----------690function scheduleNext() {691 clearTimeout(timer);692 if (!state.auto) { state.nextAt = 0; saveState(); return; }693 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;694 const since = Date.now() - (state.lastPostAt || 0);695 const wait = Math.max(60 * 1000, period - since);696 state.nextAt = Date.now() + wait; saveState();697 timer = setTimeout(async () => {698 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts699 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');700 else await runAutoCycle('auto');701 scheduleNext();702 }, wait);703}704705export function setAuto(on) {706 state.auto = !!on; saveState();707 scheduleNext();708 logEvent({ kind: on ? 'auto_on' : 'auto_off' });709 return socialState();710}711712export function socialState() {713 return {714 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,715 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,716 };717}718719export function cardPath(name) {720 const safe = path.basename(name);721 const p = path.join(CARDS_DIR, safe);722 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;723}724725export function initSocial(cfg, logger) {726 CFG = cfg || {};727 LOGGER = logger || (() => {});728 if (state.auto) scheduleNext();729 logEvent({ kind: 'init', auto: state.auto });730}731