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, false)); }205export function publishReelAssisted(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, true)); }206async function _publishMedia(caption, mediaPath, isVideo, assisted) {207 await gotoPage();208 await sleep(9000);209210 // 1) ouvrir le composeur211 const openComposer = `(function(){212 var target=null;213 document.querySelectorAll('span,div').forEach(function(e){214 var t=(e.innerText||'').trim();215 if(!target && e.children.length===0 &&216 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||217 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;218 });219 if(!target) return 'NOT_FOUND';220 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';221 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});222 return 'opened';223 })();`;224 // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans225 // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.226 const basculer = `(function(){227 var btn=null;228 document.querySelectorAll('[role=button]').forEach(function(e){229 var t=(e.innerText||'').trim();230 if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;231 });232 if(!btn) return 'NO_BTN';233 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});234 return 'switched';235 })();`;236 let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);237 if (r1 !== 'opened') {238 const sw = await osaJS(basculer);239 if (sw === 'switched') { await sleep(9000); await dismissModals(3); }240 r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);241 }242 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');243 await sleep(3000);244245 // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés246 // et le double collage) — atomique : ne colle que si le box est encore vide.247 const b64cap = Buffer.from(caption, 'utf8').toString('base64');248 // trouve le contenteditable visible le plus « profond » (composeur actif)249 const findBoxFn = `function(){250 var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]'));251 if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]'));252 var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;});253 return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null);254 }`;255 const pasteJS = `(function(){256 var box=(${findBoxFn})(); if(!box) return 'NO_BOX';257 if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller258 box.focus();259 var txt=decodeURIComponent(escape(atob('${b64cap}')));260 var dt=new DataTransfer(); dt.setData('text/plain', txt);261 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));262 return 'pasted';263 })();`;264 const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`;265 let len = 0;266 for (let i = 0; i < 3; i++) {267 const r = await osaJS(pasteJS);268 await sleep(2500);269 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);270 if (len > 40) break;271 await sleep(1200);272 }273 if (!(len > 40)) throw new Error('collage légende échoué');274275 // 3) injecter le média (image OU vidéo) dans l'input file du composeur276 const mediaB64 = fs.readFileSync(mediaPath).toString('base64');277 const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';278 const mtype = isVideo ? 'video/mp4' : 'image/png';279 const injectJS = `(function(){280 var inp=document.querySelector('div[role=dialog] input[type=file]') ||281 document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||282 document.querySelector('input[type=file]');283 if(!inp) return 'NO_INPUT';284 try{285 var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);286 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);287 var file=new File([arr], '${fname}', {type:'${mtype}'});288 var dt=new DataTransfer(); dt.items.add(file);289 inp.files=dt.files;290 inp.dispatchEvent(new Event('change',{bubbles:true}));291 return 'injected';292 }catch(e){ return 'ERR:'+e.message; }293 })();`;294 const inj = await osaJS(injectJS);295 if (inj !== 'injected') {296 // repli : bouton Photo/Vidéo puis re-tenter l'injection297 const clickPhoto = `(function(){var btn=null;298 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){299 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});300 if(!btn) return 'NO_BTN';301 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});302 return 'clicked';})();`;303 await osaJS(clickPhoto); await sleep(2500);304 const inj2 = await osaJS(injectJS);305 if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');306 }307308 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)309 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';310 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});311 dlg.querySelectorAll('video').forEach(function(){n++;});312 return ''+n;})();`;313 const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long314 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);315 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');316 await sleep(isVideo ? 4000 : 2000);317318 // 5) Suivant (si présent) puis Publier319 const clickBtn = (label) => `(function(){320 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;321 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){322 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});323 if(!btn) return 'NO_BTN';324 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';325 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});326 return 'clicked';})();`;327 // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS328 // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)329 const focusBtn = (labels) => `(function(){330 var want=${JSON.stringify(labels)}; var btn=null;331 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){332 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();333 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;334 });335 if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();336 return (document.activeElement===btn)?'focused':'focus_fail';337 })();`;338 async function clickBtnOS(labels) {339 const coordJS = `(function(){340 var want=${JSON.stringify(labels)}; var btn=null;341 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){342 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();343 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;344 });345 if(!btn) return 'NO_BTN';346 try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}347 var r=btn.getBoundingClientRect();348 var gx=Math.round(window.screenX + r.left + r.width/2);349 var gy=Math.round(window.screenY + r.top + r.height/2);350 return gx+' '+gy;351 })();`;352 const co = await osaJS(coordJS);353 if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';354 const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));355 if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;356 const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',357 '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);358 return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';359 }360 async function advanceByKey(labels) {361 const f = await osaJS(focusBtn(labels));362 if (f !== 'focused') return f;363 await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);364 return 'pressed';365 }366 const clickAny = (labels) => `(function(){367 var want=${JSON.stringify(labels)}; var btn=null;368 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){369 if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();370 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;371 });372 if(!btn) return 'NO_BTN';373 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});374 return 'clicked';375 })();`;376 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';})();`;377 if (isVideo && assisted) {378 // mode assisté : l'utilisateur clique « Suivant »/« Publier » sur l'écran du nœud379 logEvent({ kind: 'reel_waiting_user' });380 } else if (isVideo) {381 // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle382 for (let i = 0; i < 6; i++) {383 if ((await osaJS(canPublishJS)) === 'YES') break;384 const r = await clickBtnOS(['Suivant']);385 if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }386 }387 // Publier / Partager par focus + Entrée (poll pendant le téléversement)388 let done = false;389 for (let i = 0; i < 14; i++) {390 if ((await osaJS(canPublishJS)) === 'YES') {391 const r = await clickBtnOS(['Publier', 'Partager']);392 if (r === 'clicked') { done = true; break; }393 }394 await sleep(5000); await dismissModals(1);395 }396 if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');397 } else {398 const nx = await osaJS(clickAny(['Suivant']));399 if (nx === 'clicked') await sleep(7000);400 const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);401 if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');402 }403404 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte405 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique406 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.407 const composerGoneJS = `(function(){408 var open=false;409 document.querySelectorAll('div[role=dialog]').forEach(function(d){410 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;411 });412 return open ? 'OPEN' : 'GONE';413 })();`;414 let gone = 'OPEN';415 const goneTries = assisted ? 45 : (isVideo ? 40 : 18);416 for (let i = 0; i < goneTries; i++) {417 await sleep(8000);418 await dismissModals(2); // ferme toute boîte non bloquante en route419 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }420 if (gone === 'GONE') break;421 }422 await dismissModals(3); // ferme la boîte « Plus tard » finale423 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');424 return true;425}426427// ---------- génération d'un brouillon (insight -> image + légende) ----------428export async function pickInsight({ site = '', excludeRecent = true } = {}) {429 const args = [INSIGHTS];430 if (site) args.push('--site', site);431 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));432 const r = await run(PY, args);433 if (r.error) throw new Error('insights.py: ' + r.error);434 const data = JSON.parse(r.stdout);435 const cand = (data.candidates || [])[0];436 if (!cand) throw new Error('aucun insight disponible');437 return cand;438}439440export async function renderCard(insight) {441 // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec442 let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));443 let p = (r.stdout || '').trim().split('\n').pop();444 if (r.error || !p || !fs.existsSync(p)) {445 logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });446 r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));447 p = (r.stdout || '').trim().split('\n').pop();448 if (r.error) throw new Error('render: ' + r.error);449 }450 if (!p || !fs.existsSync(p)) throw new Error('image non produite');451 return p;452}453454// --- produit vedette (photo réelle) pour le genre « spotlight » ---455const KNOWN_SITES = ['lou-ka','immo-ka','vrai-prix','auto-ka','food-ka','fabri-ka','resto-ka','sorti-ka','crea-ka','job-ka','trouve-ka'];456const SPOT = {457 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },458 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },459 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },460 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },461};462async function fetchFeatured(site) {463 const cfg = SPOT[site]; if (!cfg) return null;464 try {465 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });466 const data = await res.json();467 const arr = data[cfg.arr] || [];468 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');469 for (const it of arr) {470 const img = (it.images && it.images[0]) || it.image || '';471 const title = (it.title || it.name || '').trim();472 if (!img || !title || title.length > 80) continue;473 const price = it.price || it.price_min || null;474 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;475 if (site === 'lou-ka') {476 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),477 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,478 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };479 }480 if (site === 'immo-ka') {481 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;482 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),483 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,484 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };485 }486 if (site === 'fabri-ka') {487 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),488 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,489 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };490 }491 if (site === 'sorti-ka') {492 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),493 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,494 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };495 }496 }497 } catch {}498 return null;499}500501// ---- listes (plusieurs items réels) ----502const LIST_TITLES = {503 'lou-ka': 'Les derniers logements à louer', 'immo-ka': 'Les dernières propriétés à vendre',504 'fabri-ka': 'Nouveautés québécoises', 'sorti-ka': 'Les prochains événements',505};506function mapItem(site, it, fr) {507 const img = (it.images && it.images[0]) || it.image || '';508 const title = (it.title || it.name || '').trim();509 if (!img || !title) return null;510 const price = it.price || it.price_min || null;511 if (site === 'lou-ka') return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img, _price: price };512 if (site === 'immo-ka') return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };513 if (site === 'fabri-ka') return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };514 if (site === 'sorti-ka') return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '), price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img, _price: price };515 return { title, subtitle: it.city || '', price: it.price_label || '', image: img, _price: price };516}517const LIST_CFG = {518 'lou-ka': { n: 60, min: 500, max: 6000 },519 'immo-ka': { n: 300, min: 200000, max: 9000000 }, // feed trié par prix croissant -> chercher large520 'fabri-ka': { n: 60, min: 0, max: 0 },521 'sorti-ka': { n: 60, min: 0, max: 0 },522};523async function fetchItems(site, count) {524 const cfg = SPOT[site]; if (!cfg) return null;525 const lc = LIST_CFG[site] || { n: 60, min: 0, max: 0 };526 const listMin = lc.min, listMax = lc.max;527 const url = cfg.url.replace(/(limit|per_page)=\d+/, '$1=' + lc.n);528 try {529 const res = await fetch(url, { signal: AbortSignal.timeout(14000) });530 const data = await res.json();531 const arr = data[cfg.arr] || [];532 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');533 const valid = [];534 for (const it of arr) {535 const row = mapItem(site, it, fr);536 if (!row || !row.image || !row.title) continue;537 if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…';538 valid.push(row);539 }540 if (valid.length < 2) return null;541 // préférence : items dans la fourchette de prix ; sinon repli sur les plus chers542 let pick = valid.filter(r => (!listMin || (r._price && r._price >= listMin)) && (!listMax || !r._price || r._price <= listMax));543 if (pick.length < Math.min(count, 3)) pick = valid.slice().sort((a, b) => (b._price || 0) - (a._price || 0));544 pick = pick.slice(0, count);545 pick.forEach(r => delete r._price);546 return pick.length >= 2 ? pick : null;547 } catch { return null; }548}549// analyse le prompt -> {site, mode, count}550async function planFromPrompt(prompt) {551 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';552 const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list\",\"count\":<1-6>}.\n" +553 "Sites: lou-ka (logements à louer), immo-ka (propriétés à vendre), vrai-prix (évaluations), auto-ka (autos occasion), food-ka (épicerie/soldes), fabri-ka (produits québécois), resto-ka (restos), sorti-ka (événements), crea-ka (créateurs), job-ka (emplois), trouve-ka (recherche web).\n" +554 "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items (logements, propriétés, produits, événements).\n" +555 "Règles: 'liste/derniers/plusieurs/top N/montre' => mode=list (count = N demandé ou 5). 'dernier/une annonce/en vedette' => spotlight. Sinon stat. list & spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat. Choisis le site le plus pertinent.";556 try {557 const raw = await anthropic(sys, "Demande : " + prompt, model, 120);558 const m = raw.match(/\{[\s\S]*\}/);559 const plan = JSON.parse(m ? m[0] : raw);560 return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) };561 } catch { return { site: '', mode: 'stat', count: 5 }; }562}563// applique un plan à un insight (list / spotlight / stat) + repli564async function decoratePlan(insight, plan) {565 if (plan.mode === 'list' && SPOT[insight.site]) {566 const items = await fetchItems(insight.site, plan.count);567 if (items && items.length >= 2) { insight.list = items; insight.genre = 'list'; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; return insight; }568 }569 if ((plan.mode === 'list' || plan.mode === 'spotlight') && SPOT[insight.site]) {570 const feat = await fetchFeatured(insight.site);571 if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }572 }573 insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';574 return insight;575}576577async function decorateForPrompt(insight) {578 if (SPOT[insight.site]) {579 const feat = await fetchFeatured(insight.site);580 if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }581 }582 insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';583 return insight;584}585586// choisit le genre visuel (rotation) et attache le produit vedette si spotlight587async function decorateCreative(insight) {588 const n = state.postCount || 0;589 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';590 if (SPOT[insight.site] && (n % 3 === 0)) {591 const feat = await fetchFeatured(insight.site);592 if (feat) { insight.product = feat; genre = 'spotlight'; }593 }594 insight.genre = genre;595 return insight;596}597598// légende via API Anthropic (le modèle Claude rédige à partir du fait)599async function anthropic(system, user, model, maxTokens = 500) {600 const key = CFG.anthropicKey;601 if (!key) throw new Error('Clé Anthropic non configurée');602 const resp = await fetch('https://api.anthropic.com/v1/messages', {603 method: 'POST',604 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },605 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),606 });607 const data = await resp.json();608 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');609 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();610}611612const 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.613614Règles STRICTES :615- 1 seule publication, 40 à 90 mots.616- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).617- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).618- 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).`;619620export async function writeCaption(insight, extraPrompt = '') {621 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';622 const url = `https://www.${insight.site}.com`;623 let user;624 if (insight.list && insight.genre === 'list') {625 const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n');626 user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` +627 `${insight.list_title || ''}\n${lignes}\n\n` +628 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +629 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +630 `\nRédige une publication Facebook qui donne envie de parcourir cette sélection (tu peux mentionner 2-3 items, sans tout lister).`;631 } else if (insight.product && insight.genre === 'spotlight') {632 const pr = insight.product;633 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +634 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +635 (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +636 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +637 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +638 `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;639 } else {640 user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +641 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +642 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +643 `\nRédige la publication Facebook.`;644 }645 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);646 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";647}648649// brouillon complet : insight -> image + légende (sans publier)650export async function generateDraft({ site = '', prompt = '' } = {}) {651 let insight;652 if (prompt) {653 const plan = await planFromPrompt(prompt);654 if (site) plan.site = site;655 const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';656 insight = await pickInsight({ site: chosenSite });657 await decoratePlan(insight, plan);658 } else {659 insight = await pickInsight({ site });660 await decorateCreative(insight);661 }662 const image = await renderCard(insight);663 const caption = await writeCaption(insight, prompt);664 const imageUrl='/api/social/card/'+path.basename(image);665 addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() });666 return { insight, image, caption, imageUrl };667}668669// cycle complet automatique : choisir -> rendre -> rédiger -> publier670export async function runAutoCycle(trigger = 'auto') {671 const started = logEvent({ kind: 'cycle_start', trigger });672 try {673 const login = await fbLoginState();674 if (login !== 'LOGGED_IN') {675 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });676 return { ok: false, reason: 'facebook_non_connecté' };677 }678 const insight = await pickInsight({});679 await decorateCreative(insight);680 const image = await renderCard(insight);681 const caption = await writeCaption(insight);682 await publishToPage(caption, image);683 state.lastSites = [...state.lastSites, insight.site].slice(-6);684 state.lastPostAt = Date.now();685 state.postCount = (state.postCount || 0) + 1;686 saveState();687 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,688 headline: insight.headline, caption, image: path.basename(image) });689 return { ok: true, insight, caption, image };690 } catch (e) {691 logEvent({ kind: 'cycle_error', trigger, error: e.message });692 return { ok: false, error: e.message };693 }694}695696// publier un brouillon fourni (post sur demande)697export async function publishDraft({ caption, image }) {698 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');699 const login = await fbLoginState();700 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');701 await publishToPage(caption, image);702 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();703 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });704 return true;705}706707// ---------- Reels (vidéo animée + musique) ----------708export async function makeReel(insight) {709 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));710 const p = (r.stdout || '').trim().split('\n').pop();711 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));712 return p;713}714715export function reelPath(name) {716 const safe = path.basename(name);717 const fp = path.join(REELS_DIR, safe);718 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;719}720721// brouillon reel : insight -> vidéo + légende (sans publier)722export async function generateReelDraft({ site = '', prompt = '' } = {}) {723 let insight;724 if (prompt) {725 const plan = await planFromPrompt(prompt);726 if (site) plan.site = site;727 const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';728 insight = await pickInsight({ site: chosenSite });729 await decoratePlan(insight, plan);730 } else {731 insight = await pickInsight({ site });732 await decorateForPrompt(insight);733 }734 const video = await makeReel(insight);735 const caption = await writeCaption(insight, prompt);736 const videoUrl='/api/social/reel/'+path.basename(video);737 addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() });738 return { insight, video, caption, videoUrl };739}740741export async function publishReelDraft({ caption, video }) {742 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');743 const login = await fbLoginState();744 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');745 await publishReelToPage(caption, video);746 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();747 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });748 return true;749}750751// cycle reel complet automatique752export async function runReelCycle(trigger = 'reel') {753 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });754 try {755 const login = await fbLoginState();756 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }757 const insight = await pickInsight({});758 const video = await makeReel(insight);759 const caption = await writeCaption(insight);760 await publishReelToPage(caption, video);761 state.lastSites = [...state.lastSites, insight.site].slice(-6);762 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();763 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });764 return { ok: true, insight, caption, video };765 } catch (e) {766 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });767 return { ok: false, error: e.message };768 }769}770771// ---------- Galerie (tout le contenu généré : posts + reels) ----------772const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json');773function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } }774function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} }775function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); }776export function getGallery(){ return loadGallery().slice(0,60); }777778let genBatchRunning = false;779export function startGenBatch(sites){780 if (genBatchRunning) return { started:false, reason:'génération en cours' };781 genBatchRunning = true;782 (async () => {783 logEvent({ kind:'genbatch_start', total:sites.length });784 for (let i=0;i<sites.length;i++){785 const site=sites[i];786 try{787 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });788 const insight=await pickInsight({ site });789 await decorateForPrompt(insight);790 const video=await makeReel(insight);791 const caption=await writeCaption(insight);792 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),793 url:'/api/social/reel/'+path.basename(video), ts:Date.now() });794 logEvent({ kind:'genbatch_done_item', i:i+1, site });795 }catch(e){ logEvent({ kind:'genbatch_error', i:i+1, site, error:e.message }); }796 }797 logEvent({ kind:'genbatch_done', total:sites.length });798 genBatchRunning=false;799 })();800 return { started:true, count:sites.length };801}802export function genBatchStatus(){ return { running: genBatchRunning }; }803804// ---------- Lot de reels (génération + publication assistée) ----------805let batchRunning = false;806export const REEL_BATCH_DEFAULT = ['lou-ka','immo-ka','fabri-ka','sorti-ka','food-ka','auto-ka','vrai-prix','crea-ka','trouve-ka','resto-ka'];807export function startReelBatch(sites) {808 if (batchRunning) return { started: false, reason: 'lot déjà en cours' };809 batchRunning = true;810 (async () => {811 logEvent({ kind: 'batch_start', total: sites.length });812 for (let i = 0; i < sites.length; i++) {813 const site = sites[i];814 try {815 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });816 const insight = await pickInsight({ site });817 await decorateForPrompt(insight);818 const video = await makeReel(insight);819 const caption = await writeCaption(insight);820 logEvent({ kind: 'batch_ready', i: i + 1, total: sites.length, site, genre: insight.genre, step: '⏳ CLIQUE « Suivant » puis « Publier » sur l\'écran du nœud' });821 await publishReelAssisted(caption, video);822 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();823 logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() });824 } catch (e) {825 logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message });826 }827 }828 logEvent({ kind: 'batch_done', total: sites.length });829 batchRunning = false;830 })();831 return { started: true, count: sites.length };832}833export function batchStatus() { return { running: batchRunning }; }834835// ---------- planificateur horaire ----------836function scheduleNext() {837 clearTimeout(timer);838 if (!state.auto) { state.nextAt = 0; saveState(); return; }839 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;840 const since = Date.now() - (state.lastPostAt || 0);841 const wait = Math.max(60 * 1000, period - since);842 state.nextAt = Date.now() + wait; saveState();843 timer = setTimeout(async () => {844 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts845 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');846 else await runAutoCycle('auto');847 scheduleNext();848 }, wait);849}850851export function setAuto(on) {852 state.auto = !!on; saveState();853 scheduleNext();854 logEvent({ kind: on ? 'auto_on' : 'auto_off' });855 return socialState();856}857858export function socialState() {859 return {860 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,861 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,862 };863}864865export function cardPath(name) {866 const safe = path.basename(name);867 const p = path.join(CARDS_DIR, safe);868 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;869}870871export function initSocial(cfg, logger) {872 CFG = cfg || {};873 LOGGER = logger || (() => {});874 if (state.auto) scheduleNext();875 logEvent({ kind: 'init', auto: state.auto });876}877