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}487488// choisit le genre visuel (rotation) et attache le produit vedette si spotlight489async function decorateCreative(insight) {490 const n = state.postCount || 0;491 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';492 if (SPOT[insight.site] && (n % 3 === 0)) {493 const feat = await fetchFeatured(insight.site);494 if (feat) { insight.product = feat; genre = 'spotlight'; }495 }496 insight.genre = genre;497 return insight;498}499500// légende via API Anthropic (le modèle Claude rédige à partir du fait)501async function anthropic(system, user, model, maxTokens = 500) {502 const key = CFG.anthropicKey;503 if (!key) throw new Error('Clé Anthropic non configurée');504 const resp = await fetch('https://api.anthropic.com/v1/messages', {505 method: 'POST',506 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },507 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),508 });509 const data = await resp.json();510 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');511 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();512}513514const 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.515516Règles STRICTES :517- 1 seule publication, 40 à 90 mots.518- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).519- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).520- 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).`;521522export async function writeCaption(insight, extraPrompt = '') {523 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';524 const url = `https://www.${insight.site}.com`;525 const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +526 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +527 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +528 `\nRédige la publication Facebook.`;529 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);530 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";531}532533// brouillon complet : insight -> image + légende (sans publier)534export async function generateDraft({ site = '', prompt = '' } = {}) {535 let insight;536 if (prompt && !site) {537 // laisser le modèle choisir le site le plus pertinent pour ce prompt538 const r = await run(PY, [INSIGHTS]);539 const data = JSON.parse(r.stdout || '{}');540 const cands = data.candidates || [];541 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');542 const chosen = await anthropic(543 "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.",544 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,545 CFG.socialModel || 'claude-opus-4-8', 30);546 const pickSite = (chosen || '').trim().split(/\s/)[0];547 insight = cands.find(c => c.site === pickSite) || cands[0];548 } else {549 insight = await pickInsight({ site });550 }551 await decorateCreative(insight);552 const image = await renderCard(insight);553 const caption = await writeCaption(insight, prompt);554 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };555}556557// cycle complet automatique : choisir -> rendre -> rédiger -> publier558export async function runAutoCycle(trigger = 'auto') {559 const started = logEvent({ kind: 'cycle_start', trigger });560 try {561 const login = await fbLoginState();562 if (login !== 'LOGGED_IN') {563 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });564 return { ok: false, reason: 'facebook_non_connecté' };565 }566 const insight = await pickInsight({});567 await decorateCreative(insight);568 const image = await renderCard(insight);569 const caption = await writeCaption(insight);570 await publishToPage(caption, image);571 state.lastSites = [...state.lastSites, insight.site].slice(-6);572 state.lastPostAt = Date.now();573 state.postCount = (state.postCount || 0) + 1;574 saveState();575 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,576 headline: insight.headline, caption, image: path.basename(image) });577 return { ok: true, insight, caption, image };578 } catch (e) {579 logEvent({ kind: 'cycle_error', trigger, error: e.message });580 return { ok: false, error: e.message };581 }582}583584// publier un brouillon fourni (post sur demande)585export async function publishDraft({ caption, image }) {586 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');587 const login = await fbLoginState();588 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');589 await publishToPage(caption, image);590 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();591 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });592 return true;593}594595// ---------- Reels (vidéo animée + musique) ----------596export async function makeReel(insight) {597 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));598 const p = (r.stdout || '').trim().split('\n').pop();599 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));600 return p;601}602603export function reelPath(name) {604 const safe = path.basename(name);605 const fp = path.join(REELS_DIR, safe);606 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;607}608609// brouillon reel : insight -> vidéo + légende (sans publier)610export async function generateReelDraft({ site = '', prompt = '' } = {}) {611 let insight;612 if (prompt && !site) {613 const r = await run(PY, [INSIGHTS]);614 const cands = (JSON.parse(r.stdout || '{}').candidates) || [];615 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');616 const chosen = await anthropic(617 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",618 `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);619 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];620 } else {621 insight = await pickInsight({ site });622 }623 const video = await makeReel(insight);624 const caption = await writeCaption(insight, prompt);625 return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };626}627628export async function publishReelDraft({ caption, video }) {629 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');630 const login = await fbLoginState();631 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');632 await publishReelToPage(caption, video);633 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();634 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });635 return true;636}637638// cycle reel complet automatique639export async function runReelCycle(trigger = 'reel') {640 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });641 try {642 const login = await fbLoginState();643 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }644 const insight = await pickInsight({});645 const video = await makeReel(insight);646 const caption = await writeCaption(insight);647 await publishReelToPage(caption, video);648 state.lastSites = [...state.lastSites, insight.site].slice(-6);649 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();650 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });651 return { ok: true, insight, caption, video };652 } catch (e) {653 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });654 return { ok: false, error: e.message };655 }656}657658// ---------- planificateur horaire ----------659function scheduleNext() {660 clearTimeout(timer);661 if (!state.auto) { state.nextAt = 0; saveState(); return; }662 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;663 const since = Date.now() - (state.lastPostAt || 0);664 const wait = Math.max(60 * 1000, period - since);665 state.nextAt = Date.now() + wait; saveState();666 timer = setTimeout(async () => {667 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts668 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');669 else await runAutoCycle('auto');670 scheduleNext();671 }, wait);672}673674export function setAuto(on) {675 state.auto = !!on; saveState();676 scheduleNext();677 logEvent({ kind: on ? 'auto_on' : 'auto_off' });678 return socialState();679}680681export function socialState() {682 return {683 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,684 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,685 };686}687688export function cardPath(name) {689 const safe = path.basename(name);690 const p = path.join(CARDS_DIR, safe);691 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;692}693694export function initSocial(cfg, logger) {695 CFG = cfg || {};696 LOGGER = logger || (() => {});697 if (state.auto) scheduleNext();698 logEvent({ kind: 'init', auto: state.auto });699}700