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 via ClipboardEvent synthétique (base64, sans clavier)245 const b64cap = Buffer.from(caption, 'utf8').toString('base64');246 const pasteJS = `(function(){247 var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';248 var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';249 box.focus();250 var txt=decodeURIComponent(escape(atob('${b64cap}')));251 var dt=new DataTransfer(); dt.setData('text/plain', txt);252 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));253 return 'pasted';254 })();`;255 const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +256 `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +257 `return 'len:'+(box?box.innerText.length:-1);})();`;258 let len = 0;259 for (let i = 0; i < 3; i++) {260 await osaJS(pasteJS); await sleep(2500);261 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);262 if (len > 40) break; await sleep(1500);263 }264 if (!(len > 40)) throw new Error('collage légende échoué');265266 // 3) injecter le média (image OU vidéo) dans l'input file du composeur267 const mediaB64 = fs.readFileSync(mediaPath).toString('base64');268 const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';269 const mtype = isVideo ? 'video/mp4' : 'image/png';270 const injectJS = `(function(){271 var inp=document.querySelector('div[role=dialog] input[type=file]') ||272 document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||273 document.querySelector('input[type=file]');274 if(!inp) return 'NO_INPUT';275 try{276 var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);277 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);278 var file=new File([arr], '${fname}', {type:'${mtype}'});279 var dt=new DataTransfer(); dt.items.add(file);280 inp.files=dt.files;281 inp.dispatchEvent(new Event('change',{bubbles:true}));282 return 'injected';283 }catch(e){ return 'ERR:'+e.message; }284 })();`;285 const inj = await osaJS(injectJS);286 if (inj !== 'injected') {287 // repli : bouton Photo/Vidéo puis re-tenter l'injection288 const clickPhoto = `(function(){var btn=null;289 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){290 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});291 if(!btn) return 'NO_BTN';292 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});293 return 'clicked';})();`;294 await osaJS(clickPhoto); await sleep(2500);295 const inj2 = await osaJS(injectJS);296 if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');297 }298299 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)300 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';301 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});302 dlg.querySelectorAll('video').forEach(function(){n++;});303 return ''+n;})();`;304 const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long305 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);306 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');307 await sleep(isVideo ? 4000 : 2000);308309 // 5) Suivant (si présent) puis Publier310 const clickBtn = (label) => `(function(){311 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;312 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){313 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});314 if(!btn) return 'NO_BTN';315 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';316 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});317 return 'clicked';})();`;318 // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS319 // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)320 const focusBtn = (labels) => `(function(){321 var want=${JSON.stringify(labels)}; var btn=null;322 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){323 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();324 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;325 });326 if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();327 return (document.activeElement===btn)?'focused':'focus_fail';328 })();`;329 async function clickBtnOS(labels) {330 const coordJS = `(function(){331 var want=${JSON.stringify(labels)}; var btn=null;332 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){333 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();334 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;335 });336 if(!btn) return 'NO_BTN';337 try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}338 var r=btn.getBoundingClientRect();339 var gx=Math.round(window.screenX + r.left + r.width/2);340 var gy=Math.round(window.screenY + r.top + r.height/2);341 return gx+' '+gy;342 })();`;343 const co = await osaJS(coordJS);344 if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';345 const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));346 if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;347 const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',348 '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);349 return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';350 }351 async function advanceByKey(labels) {352 const f = await osaJS(focusBtn(labels));353 if (f !== 'focused') return f;354 await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);355 return 'pressed';356 }357 const clickAny = (labels) => `(function(){358 var want=${JSON.stringify(labels)}; var btn=null;359 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){360 if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();361 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;362 });363 if(!btn) return 'NO_BTN';364 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});365 return 'clicked';366 })();`;367 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';})();`;368 if (isVideo) {369 // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle370 for (let i = 0; i < 6; i++) {371 if ((await osaJS(canPublishJS)) === 'YES') break;372 const r = await clickBtnOS(['Suivant']);373 if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }374 }375 // Publier / Partager par focus + Entrée (poll pendant le téléversement)376 let done = false;377 for (let i = 0; i < 14; i++) {378 if ((await osaJS(canPublishJS)) === 'YES') {379 const r = await clickBtnOS(['Publier', 'Partager']);380 if (r === 'clicked') { done = true; break; }381 }382 await sleep(5000); await dismissModals(1);383 }384 if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');385 } else {386 const nx = await osaJS(clickAny(['Suivant']));387 if (nx === 'clicked') await sleep(7000);388 const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);389 if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');390 }391392 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte393 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique394 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.395 const composerGoneJS = `(function(){396 var open=false;397 document.querySelectorAll('div[role=dialog]').forEach(function(d){398 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;399 });400 return open ? 'OPEN' : 'GONE';401 })();`;402 let gone = 'OPEN';403 const goneTries = isVideo ? 40 : 18;404 for (let i = 0; i < goneTries; i++) {405 await sleep(8000);406 await dismissModals(2); // ferme toute boîte non bloquante en route407 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }408 if (gone === 'GONE') break;409 }410 await dismissModals(3); // ferme la boîte « Plus tard » finale411 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');412 return true;413}414415// ---------- génération d'un brouillon (insight -> image + légende) ----------416export async function pickInsight({ site = '', excludeRecent = true } = {}) {417 const args = [INSIGHTS];418 if (site) args.push('--site', site);419 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));420 const r = await run(PY, args);421 if (r.error) throw new Error('insights.py: ' + r.error);422 const data = JSON.parse(r.stdout);423 const cand = (data.candidates || [])[0];424 if (!cand) throw new Error('aucun insight disponible');425 return cand;426}427428export async function renderCard(insight) {429 // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec430 let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));431 let p = (r.stdout || '').trim().split('\n').pop();432 if (r.error || !p || !fs.existsSync(p)) {433 logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });434 r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));435 p = (r.stdout || '').trim().split('\n').pop();436 if (r.error) throw new Error('render: ' + r.error);437 }438 if (!p || !fs.existsSync(p)) throw new Error('image non produite');439 return p;440}441442// --- produit vedette (photo réelle) pour le genre « spotlight » ---443const SPOT = {444 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },445 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },446 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },447 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },448};449async function fetchFeatured(site) {450 const cfg = SPOT[site]; if (!cfg) return null;451 try {452 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });453 const data = await res.json();454 const arr = data[cfg.arr] || [];455 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');456 for (const it of arr) {457 const img = (it.images && it.images[0]) || it.image || '';458 const title = (it.title || it.name || '').trim();459 if (!img || !title || title.length > 80) continue;460 const price = it.price || it.price_min || null;461 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;462 if (site === 'lou-ka') {463 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),464 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,465 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };466 }467 if (site === 'immo-ka') {468 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;469 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),470 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,471 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };472 }473 if (site === 'fabri-ka') {474 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),475 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,476 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };477 }478 if (site === 'sorti-ka') {479 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),480 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,481 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };482 }483 }484 } catch {}485 return null;486}487488async function decorateForPrompt(insight) {489 if (SPOT[insight.site]) {490 const feat = await fetchFeatured(insight.site);491 if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }492 }493 insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';494 return insight;495}496497// choisit le genre visuel (rotation) et attache le produit vedette si spotlight498async function decorateCreative(insight) {499 const n = state.postCount || 0;500 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';501 if (SPOT[insight.site] && (n % 3 === 0)) {502 const feat = await fetchFeatured(insight.site);503 if (feat) { insight.product = feat; genre = 'spotlight'; }504 }505 insight.genre = genre;506 return insight;507}508509// légende via API Anthropic (le modèle Claude rédige à partir du fait)510async function anthropic(system, user, model, maxTokens = 500) {511 const key = CFG.anthropicKey;512 if (!key) throw new Error('Clé Anthropic non configurée');513 const resp = await fetch('https://api.anthropic.com/v1/messages', {514 method: 'POST',515 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },516 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),517 });518 const data = await resp.json();519 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');520 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();521}522523const 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.524525Règles STRICTES :526- 1 seule publication, 40 à 90 mots.527- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).528- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).529- 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).`;530531export async function writeCaption(insight, extraPrompt = '') {532 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';533 const url = `https://www.${insight.site}.com`;534 let user;535 if (insight.product && insight.genre === 'spotlight') {536 const pr = insight.product;537 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +538 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +539 (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +540 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +541 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +542 `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;543 } else {544 user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +545 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +546 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +547 `\nRédige la publication Facebook.`;548 }549 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);550 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";551}552553// brouillon complet : insight -> image + légende (sans publier)554export async function generateDraft({ site = '', prompt = '' } = {}) {555 let insight;556 if (prompt && !site) {557 // laisser le modèle choisir le site le plus pertinent pour ce prompt558 const r = await run(PY, [INSIGHTS]);559 const data = JSON.parse(r.stdout || '{}');560 const cands = data.candidates || [];561 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');562 const chosen = await anthropic(563 "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.",564 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,565 CFG.socialModel || 'claude-opus-4-8', 30);566 const pickSite = (chosen || '').trim().split(/\s/)[0];567 insight = cands.find(c => c.site === pickSite) || cands[0];568 await decorateForPrompt(insight);569 } else {570 insight = await pickInsight({ site });571 await decorateCreative(insight);572 }573 const image = await renderCard(insight);574 const caption = await writeCaption(insight, prompt);575 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };576}577578// cycle complet automatique : choisir -> rendre -> rédiger -> publier579export async function runAutoCycle(trigger = 'auto') {580 const started = logEvent({ kind: 'cycle_start', trigger });581 try {582 const login = await fbLoginState();583 if (login !== 'LOGGED_IN') {584 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });585 return { ok: false, reason: 'facebook_non_connecté' };586 }587 const insight = await pickInsight({});588 await decorateCreative(insight);589 const image = await renderCard(insight);590 const caption = await writeCaption(insight);591 await publishToPage(caption, image);592 state.lastSites = [...state.lastSites, insight.site].slice(-6);593 state.lastPostAt = Date.now();594 state.postCount = (state.postCount || 0) + 1;595 saveState();596 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,597 headline: insight.headline, caption, image: path.basename(image) });598 return { ok: true, insight, caption, image };599 } catch (e) {600 logEvent({ kind: 'cycle_error', trigger, error: e.message });601 return { ok: false, error: e.message };602 }603}604605// publier un brouillon fourni (post sur demande)606export async function publishDraft({ caption, image }) {607 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');608 const login = await fbLoginState();609 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');610 await publishToPage(caption, image);611 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();612 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });613 return true;614}615616// ---------- Reels (vidéo animée + musique) ----------617export async function makeReel(insight) {618 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));619 const p = (r.stdout || '').trim().split('\n').pop();620 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));621 return p;622}623624export function reelPath(name) {625 const safe = path.basename(name);626 const fp = path.join(REELS_DIR, safe);627 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;628}629630// brouillon reel : insight -> vidéo + légende (sans publier)631export async function generateReelDraft({ site = '', prompt = '' } = {}) {632 let insight;633 if (prompt && !site) {634 const r = await run(PY, [INSIGHTS]);635 const cands = (JSON.parse(r.stdout || '{}').candidates) || [];636 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');637 const chosen = await anthropic(638 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",639 `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);640 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];641 await decorateForPrompt(insight);642 } else {643 insight = await pickInsight({ site });644 await decorateForPrompt(insight);645 }646 const video = await makeReel(insight);647 const caption = await writeCaption(insight, prompt);648 return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };649}650651export async function publishReelDraft({ caption, video }) {652 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');653 const login = await fbLoginState();654 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');655 await publishReelToPage(caption, video);656 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();657 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });658 return true;659}660661// cycle reel complet automatique662export async function runReelCycle(trigger = 'reel') {663 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });664 try {665 const login = await fbLoginState();666 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }667 const insight = await pickInsight({});668 const video = await makeReel(insight);669 const caption = await writeCaption(insight);670 await publishReelToPage(caption, video);671 state.lastSites = [...state.lastSites, insight.site].slice(-6);672 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();673 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });674 return { ok: true, insight, caption, video };675 } catch (e) {676 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });677 return { ok: false, error: e.message };678 }679}680681// ---------- planificateur horaire ----------682function scheduleNext() {683 clearTimeout(timer);684 if (!state.auto) { state.nextAt = 0; saveState(); return; }685 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;686 const since = Date.now() - (state.lastPostAt || 0);687 const wait = Math.max(60 * 1000, period - since);688 state.nextAt = Date.now() + wait; saveState();689 timer = setTimeout(async () => {690 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts691 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');692 else await runAutoCycle('auto');693 scheduleNext();694 }, wait);695}696697export function setAuto(on) {698 state.auto = !!on; saveState();699 scheduleNext();700 logEvent({ kind: on ? 'auto_on' : 'auto_off' });701 return socialState();702}703704export function socialState() {705 return {706 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,707 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,708 };709}710711export function cardPath(name) {712 const safe = path.basename(name);713 const p = path.join(CARDS_DIR, safe);714 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;715}716717export function initSocial(cfg, logger) {718 CFG = cfg || {};719 LOGGER = logger || (() => {});720 if (state.auto) scheduleNext();721 logEvent({ kind: 'init', auto: state.auto });722}723