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 SPOT = {456 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },457 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },458 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },459 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },460};461async function fetchFeatured(site) {462 const cfg = SPOT[site]; if (!cfg) return null;463 try {464 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });465 const data = await res.json();466 const arr = data[cfg.arr] || [];467 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');468 for (const it of arr) {469 const img = (it.images && it.images[0]) || it.image || '';470 const title = (it.title || it.name || '').trim();471 if (!img || !title || title.length > 80) continue;472 const price = it.price || it.price_min || null;473 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;474 if (site === 'lou-ka') {475 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),476 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,477 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };478 }479 if (site === 'immo-ka') {480 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;481 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),482 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,483 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };484 }485 if (site === 'fabri-ka') {486 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),487 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,488 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };489 }490 if (site === 'sorti-ka') {491 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),492 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,493 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };494 }495 }496 } catch {}497 return null;498}499500async function decorateForPrompt(insight) {501 if (SPOT[insight.site]) {502 const feat = await fetchFeatured(insight.site);503 if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }504 }505 insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';506 return insight;507}508509// choisit le genre visuel (rotation) et attache le produit vedette si spotlight510async function decorateCreative(insight) {511 const n = state.postCount || 0;512 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';513 if (SPOT[insight.site] && (n % 3 === 0)) {514 const feat = await fetchFeatured(insight.site);515 if (feat) { insight.product = feat; genre = 'spotlight'; }516 }517 insight.genre = genre;518 return insight;519}520521// légende via API Anthropic (le modèle Claude rédige à partir du fait)522async function anthropic(system, user, model, maxTokens = 500) {523 const key = CFG.anthropicKey;524 if (!key) throw new Error('Clé Anthropic non configurée');525 const resp = await fetch('https://api.anthropic.com/v1/messages', {526 method: 'POST',527 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },528 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),529 });530 const data = await resp.json();531 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');532 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();533}534535const 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.536537Règles STRICTES :538- 1 seule publication, 40 à 90 mots.539- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).540- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).541- 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).`;542543export async function writeCaption(insight, extraPrompt = '') {544 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';545 const url = `https://www.${insight.site}.com`;546 let user;547 if (insight.product && insight.genre === 'spotlight') {548 const pr = insight.product;549 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +550 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +551 (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +552 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +553 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +554 `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;555 } else {556 user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +557 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +558 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +559 `\nRédige la publication Facebook.`;560 }561 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);562 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";563}564565// brouillon complet : insight -> image + légende (sans publier)566export async function generateDraft({ site = '', prompt = '' } = {}) {567 let insight;568 if (prompt && !site) {569 // laisser le modèle choisir le site le plus pertinent pour ce prompt570 const r = await run(PY, [INSIGHTS]);571 const data = JSON.parse(r.stdout || '{}');572 const cands = data.candidates || [];573 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');574 const chosen = await anthropic(575 "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.",576 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,577 CFG.socialModel || 'claude-opus-4-8', 30);578 const pickSite = (chosen || '').trim().split(/\s/)[0];579 insight = cands.find(c => c.site === pickSite) || cands[0];580 await decorateForPrompt(insight);581 } else {582 insight = await pickInsight({ site });583 await decorateCreative(insight);584 }585 const image = await renderCard(insight);586 const caption = await writeCaption(insight, prompt);587 const imageUrl='/api/social/card/'+path.basename(image);588 addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() });589 return { insight, image, caption, imageUrl };590}591592// cycle complet automatique : choisir -> rendre -> rédiger -> publier593export async function runAutoCycle(trigger = 'auto') {594 const started = logEvent({ kind: 'cycle_start', trigger });595 try {596 const login = await fbLoginState();597 if (login !== 'LOGGED_IN') {598 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });599 return { ok: false, reason: 'facebook_non_connecté' };600 }601 const insight = await pickInsight({});602 await decorateCreative(insight);603 const image = await renderCard(insight);604 const caption = await writeCaption(insight);605 await publishToPage(caption, image);606 state.lastSites = [...state.lastSites, insight.site].slice(-6);607 state.lastPostAt = Date.now();608 state.postCount = (state.postCount || 0) + 1;609 saveState();610 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,611 headline: insight.headline, caption, image: path.basename(image) });612 return { ok: true, insight, caption, image };613 } catch (e) {614 logEvent({ kind: 'cycle_error', trigger, error: e.message });615 return { ok: false, error: e.message };616 }617}618619// publier un brouillon fourni (post sur demande)620export async function publishDraft({ caption, image }) {621 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');622 const login = await fbLoginState();623 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');624 await publishToPage(caption, image);625 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();626 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });627 return true;628}629630// ---------- Reels (vidéo animée + musique) ----------631export async function makeReel(insight) {632 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));633 const p = (r.stdout || '').trim().split('\n').pop();634 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));635 return p;636}637638export function reelPath(name) {639 const safe = path.basename(name);640 const fp = path.join(REELS_DIR, safe);641 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;642}643644// brouillon reel : insight -> vidéo + légende (sans publier)645export async function generateReelDraft({ site = '', prompt = '' } = {}) {646 let insight;647 if (prompt && !site) {648 const r = await run(PY, [INSIGHTS]);649 const cands = (JSON.parse(r.stdout || '{}').candidates) || [];650 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');651 const chosen = await anthropic(652 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",653 `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);654 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];655 await decorateForPrompt(insight);656 } else {657 insight = await pickInsight({ site });658 await decorateForPrompt(insight);659 }660 const video = await makeReel(insight);661 const caption = await writeCaption(insight, prompt);662 const videoUrl='/api/social/reel/'+path.basename(video);663 addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() });664 return { insight, video, caption, videoUrl };665}666667export async function publishReelDraft({ caption, video }) {668 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');669 const login = await fbLoginState();670 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');671 await publishReelToPage(caption, video);672 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();673 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });674 return true;675}676677// cycle reel complet automatique678export async function runReelCycle(trigger = 'reel') {679 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });680 try {681 const login = await fbLoginState();682 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }683 const insight = await pickInsight({});684 const video = await makeReel(insight);685 const caption = await writeCaption(insight);686 await publishReelToPage(caption, video);687 state.lastSites = [...state.lastSites, insight.site].slice(-6);688 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();689 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });690 return { ok: true, insight, caption, video };691 } catch (e) {692 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });693 return { ok: false, error: e.message };694 }695}696697// ---------- Galerie (tout le contenu généré : posts + reels) ----------698const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json');699function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } }700function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} }701function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); }702export function getGallery(){ return loadGallery().slice(0,60); }703704let genBatchRunning = false;705export function startGenBatch(sites){706 if (genBatchRunning) return { started:false, reason:'génération en cours' };707 genBatchRunning = true;708 (async () => {709 logEvent({ kind:'genbatch_start', total:sites.length });710 for (let i=0;i<sites.length;i++){711 const site=sites[i];712 try{713 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });714 const insight=await pickInsight({ site });715 await decorateForPrompt(insight);716 const video=await makeReel(insight);717 const caption=await writeCaption(insight);718 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),719 url:'/api/social/reel/'+path.basename(video), ts:Date.now() });720 logEvent({ kind:'genbatch_done_item', i:i+1, site });721 }catch(e){ logEvent({ kind:'genbatch_error', i:i+1, site, error:e.message }); }722 }723 logEvent({ kind:'genbatch_done', total:sites.length });724 genBatchRunning=false;725 })();726 return { started:true, count:sites.length };727}728export function genBatchStatus(){ return { running: genBatchRunning }; }729730// ---------- Lot de reels (génération + publication assistée) ----------731let batchRunning = false;732export const REEL_BATCH_DEFAULT = ['lou-ka','immo-ka','fabri-ka','sorti-ka','food-ka','auto-ka','vrai-prix','crea-ka','trouve-ka','resto-ka'];733export function startReelBatch(sites) {734 if (batchRunning) return { started: false, reason: 'lot déjà en cours' };735 batchRunning = true;736 (async () => {737 logEvent({ kind: 'batch_start', total: sites.length });738 for (let i = 0; i < sites.length; i++) {739 const site = sites[i];740 try {741 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });742 const insight = await pickInsight({ site });743 await decorateForPrompt(insight);744 const video = await makeReel(insight);745 const caption = await writeCaption(insight);746 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' });747 await publishReelAssisted(caption, video);748 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();749 logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() });750 } catch (e) {751 logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message });752 }753 }754 logEvent({ kind: 'batch_done', total: sites.length });755 batchRunning = false;756 })();757 return { started: true, count: sites.length };758}759export function batchStatus() { return { running: batchRunning }; }760761// ---------- planificateur horaire ----------762function scheduleNext() {763 clearTimeout(timer);764 if (!state.auto) { state.nextAt = 0; saveState(); return; }765 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;766 const since = Date.now() - (state.lastPostAt || 0);767 const wait = Math.max(60 * 1000, period - since);768 state.nextAt = Date.now() + wait; saveState();769 timer = setTimeout(async () => {770 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts771 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');772 else await runAutoCycle('auto');773 scheduleNext();774 }, wait);775}776777export function setAuto(on) {778 state.auto = !!on; saveState();779 scheduleNext();780 logEvent({ kind: on ? 'auto_on' : 'auto_off' });781 return socialState();782}783784export function socialState() {785 return {786 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,787 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,788 };789}790791export function cardPath(name) {792 const safe = path.basename(name);793 const p = path.join(CARDS_DIR, safe);794 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;795}796797export function initSocial(cfg, logger) {798 CFG = cfg || {};799 LOGGER = logger || (() => {});800 if (state.auto) scheduleNext();801 logEvent({ kind: 'init', auto: state.auto });802}803