JavaScript 65.5%
Python 17.8%
CSS 13%
HTML 3.7%
1// admin-ka — module « Social » : publication automatique horaire d'un insight2// statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso3// simonpboucher), + génération d'un post sur demande depuis un prompt.4//5// Pipeline : insights.py (choix du fait marquant) -> render_card.py (1 image)6// -> légende (Claude / API Anthropic) -> publication Safari (injection JS,7// zéro clavier, zéro presse-papiers système) sur la Page.8//9// Tout tourne sur le nœud M3U96a où Safari est connecté au compte Facebook.1011import fs from 'node:fs';12import path from 'node:path';13import os from 'node:os';14import crypto from 'node:crypto';15import { execFile, spawn } from 'node:child_process';16import { fileURLToPath } from 'node:url';1718const __dirname = path.dirname(fileURLToPath(import.meta.url));19const APP_DIR = path.join(__dirname, '..');20const DATA_DIR = path.join(APP_DIR, 'data');21const SOCIAL_DIR = path.join(DATA_DIR, 'social');22const CARDS_DIR = path.join(SOCIAL_DIR, 'cards');23const REELS_DIR = path.join(SOCIAL_DIR, 'reels');24const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');25const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');26fs.mkdirSync(CARDS_DIR, { recursive: true });27fs.mkdirSync(REELS_DIR, { recursive: true });2829const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso30const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;31const PY = '/opt/homebrew/bin/python3';32const INSIGHTS = path.join(__dirname, 'social', 'insights.py');33const RENDER = path.join(__dirname, 'social', 'render_card.py');34const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');35const MAKE_REEL = path.join(__dirname, 'social', 'make_reel.py');3637let CFG = {}; // injecté par init()38let LOGGER = () => {};39let timer = null;4041// ---------- état persistant ----------42function loadState() {43 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}44 return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };45}46let state = loadState();47function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4849function logEvent(ev) {50 const line = { ts: Date.now(), ...ev };51 try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}52 LOGGER(line);53 return line;54}55export function socialLog(limit = 50) {56 try {57 const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);58 return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();59 } catch { return []; }60}6162// ---------- utilitaires enfant ----------63function run(bin, args, opts = {}) {64 return new Promise((resolve) => {65 execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {66 resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });67 });68 });69}70// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)71let safariChain = Promise.resolve();72function withSafari(fn) {73 const next = safariChain.then(fn, fn);74 safariChain = next.catch(() => {});75 return next;76}77const sleep = (ms) => new Promise(r => setTimeout(r, ms));78function runInput(bin, args, input) {79 return new Promise((resolve) => {80 const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });81 let out = '', err = '';82 ch.stdout.on('data', d => out += d);83 ch.stderr.on('data', d => err += d);84 ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));85 ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));86 ch.stdin.write(input); ch.stdin.end();87 });88}8990// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------91let fbWinId = null;9293async function osaJS(jsCode) {94 // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu95 const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);96 fs.writeFileSync(tmp, jsCode);97 if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }98 const apple = `tell application "Safari"\nwith timeout of 25 seconds\ndo JavaScript (read POSIX file ${JSON.stringify(tmp)}) in tab 1 of window id ${fbWinId}\nend timeout\nend tell`;99 const r = await run('/usr/bin/osascript', ['-e', apple]);100 try { fs.unlinkSync(tmp); } catch {}101 if (r.error) throw new Error('osascript: ' + r.error.trim());102 return r.stdout.trim();103}104105async function resolveFbWindow() {106 // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com107 const findScript = `108tell application "Safari"109 with timeout of 15 seconds110 set out to ""111 repeat with w in windows112 try113 set u to URL of tab 1 of w114 if u contains "facebook.com" then return (id of w as string)115 end try116 end repeat117 return "NONE"118 end timeout119end tell`;120 let r = await run('/usr/bin/osascript', ['-e', findScript]);121 let id = (r.stdout || '').trim();122 const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';123 if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }124 // aucune : ouvrir la page dans une nouvelle fenêtre125 const openScript = `126tell application "Safari"127 with timeout of 25 seconds128 make new document with properties {URL:"${PAGE_URL}"}129 delay 2130 return (id of window 1 as string)131 end timeout132end tell`;133 r = await run('/usr/bin/osascript', ['-e', openScript]);134 const nid = (r.stdout || '').trim();135 fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;136 if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');137 return fbWinId;138}139140async function gotoPage() {141 await resolveFbWindow();142 try { await run('/usr/bin/osascript', ['-e', `tell application "Safari" to set bounds of window id ${fbWinId} to {0, 0, 1512, 964}`]); } catch {}143 try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}144 await run('/usr/bin/osascript', ['-e',145 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);146}147148// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)149const DISMISS_JS = `(function(){150 var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];151 var clicked='';152 document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){153 if(clicked) return;154 var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';155 for(var i=0;i<labels.length;i++){156 if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){157 ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});158 clicked=labels[i]||al; break;159 }160 }161 });162 return clicked||'NONE';163})();`;164165async function pollJS(jsCode, want, tries = 12, delay = 4000) {166 let last = '';167 for (let i = 0; i < tries; i++) {168 try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }169 if (want(last)) return last;170 await sleep(delay);171 }172 return last;173}174175async function dismissModals(times) {176 times = times || 3;177 let any = false;178 for (let i = 0; i < times; i++) {179 let r = 'NONE';180 try { r = await osaJS(DISMISS_JS); } catch {}181 if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;182 }183 return any;184}185186// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)187export function fbLoginState() { return withSafari(_fbLoginState); }188async function _fbLoginState() {189 await resolveFbWindow();190 try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}191 await run('/usr/bin/osascript', ['-e',192 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "https://www.facebook.com/"\nend timeout\nend tell`]);193 await sleep(6000);194 const js = `(function(){var b=document.body.innerText||"";` +195 `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +196 `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +197 `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;198 const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);199 return out;200}201202// ---------- publication (texte + 1 image) sur la Page ----------203export function publishToPage(caption, imagePath) { return withSafari(() => _publishMedia(caption, imagePath, false)); }204export function publishReelToPage(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, false)); }205export function publishReelAssisted(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, true)); }206async function _publishMedia(caption, mediaPath, isVideo, assisted) {207 await gotoPage();208 await sleep(9000);209210 // 1) ouvrir le composeur211 const openComposer = `(function(){212 var target=null;213 document.querySelectorAll('span,div').forEach(function(e){214 var t=(e.innerText||'').trim();215 if(!target && e.children.length===0 &&216 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||217 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;218 });219 if(!target) return 'NOT_FOUND';220 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';221 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});222 return 'opened';223 })();`;224 // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans225 // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.226 const basculer = `(function(){227 var btn=null;228 document.querySelectorAll('[role=button]').forEach(function(e){229 var t=(e.innerText||'').trim();230 if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;231 });232 if(!btn) return 'NO_BTN';233 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});234 return 'switched';235 })();`;236 let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);237 if (r1 !== 'opened') {238 const sw = await osaJS(basculer);239 if (sw === 'switched') { await sleep(9000); await dismissModals(3); }240 r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);241 }242 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');243 await sleep(3000);244245 // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés246 // et le double collage) — atomique : ne colle que si le box est encore vide.247 const b64cap = Buffer.from(caption, 'utf8').toString('base64');248 // trouve le contenteditable visible le plus « profond » (composeur actif)249 const findBoxFn = `function(){250 var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]'));251 if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]'));252 var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;});253 return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null);254 }`;255 const pasteJS = `(function(){256 var box=(${findBoxFn})(); if(!box) return 'NO_BOX';257 if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller258 box.focus();259 var txt=decodeURIComponent(escape(atob('${b64cap}')));260 var dt=new DataTransfer(); dt.setData('text/plain', txt);261 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));262 return 'pasted';263 })();`;264 const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`;265 let len = 0;266 for (let i = 0; i < 3; i++) {267 const r = await osaJS(pasteJS);268 await sleep(2500);269 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);270 if (len > 40) break;271 await sleep(1200);272 }273 if (!(len > 40)) throw new Error('collage légende échoué');274275 // 3) injecter le média (image OU vidéo) dans l'input file du composeur276 const mediaB64 = fs.readFileSync(mediaPath).toString('base64');277 const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';278 const mtype = isVideo ? 'video/mp4' : 'image/png';279 const injectJS = `(function(){280 var inp=document.querySelector('div[role=dialog] input[type=file]') ||281 document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||282 document.querySelector('input[type=file]');283 if(!inp) return 'NO_INPUT';284 try{285 var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);286 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);287 var file=new File([arr], '${fname}', {type:'${mtype}'});288 var dt=new DataTransfer(); dt.items.add(file);289 inp.files=dt.files;290 inp.dispatchEvent(new Event('change',{bubbles:true}));291 return 'injected';292 }catch(e){ return 'ERR:'+e.message; }293 })();`;294 const inj = await osaJS(injectJS);295 if (inj !== 'injected') {296 // repli : bouton Photo/Vidéo puis re-tenter l'injection297 const clickPhoto = `(function(){var btn=null;298 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){299 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});300 if(!btn) return 'NO_BTN';301 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});302 return 'clicked';})();`;303 await osaJS(clickPhoto); await sleep(2500);304 const inj2 = await osaJS(injectJS);305 if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');306 }307308 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)309 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';310 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});311 dlg.querySelectorAll('video').forEach(function(){n++;});312 return ''+n;})();`;313 const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long314 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);315 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');316 await sleep(isVideo ? 4000 : 2000);317318 // 5) Suivant (si présent) puis Publier319 const clickBtn = (label) => `(function(){320 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;321 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){322 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});323 if(!btn) return 'NO_BTN';324 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';325 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});326 return 'clicked';})();`;327 // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS328 // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)329 const focusBtn = (labels) => `(function(){330 var want=${JSON.stringify(labels)}; var btn=null;331 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){332 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();333 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;334 });335 if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();336 return (document.activeElement===btn)?'focused':'focus_fail';337 })();`;338 async function clickBtnOS(labels) {339 const coordJS = `(function(){340 var want=${JSON.stringify(labels)}; var btn=null;341 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){342 if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();343 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;344 });345 if(!btn) return 'NO_BTN';346 try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}347 var r=btn.getBoundingClientRect();348 var gx=Math.round(window.screenX + r.left + r.width/2);349 var gy=Math.round(window.screenY + r.top + r.height/2);350 return gx+' '+gy;351 })();`;352 const co = await osaJS(coordJS);353 if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';354 const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));355 if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;356 const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',357 '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);358 return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';359 }360 async function advanceByKey(labels) {361 const f = await osaJS(focusBtn(labels));362 if (f !== 'focused') return f;363 await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);364 return 'pressed';365 }366 const clickAny = (labels) => `(function(){367 var want=${JSON.stringify(labels)}; var btn=null;368 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){369 if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();370 if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;371 });372 if(!btn) return 'NO_BTN';373 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});374 return 'clicked';375 })();`;376 const canPublishJS = `(function(){var ok=false;document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){var al=(b.getAttribute('aria-label')||b.innerText||'').trim();if((al==='Publier'||al==='Partager')&&b.getAttribute('aria-disabled')!=='true')ok=true;});return ok?'YES':'NO';})();`;377 if (isVideo && assisted) {378 // mode assisté : l'utilisateur clique « Suivant »/« Publier » sur l'écran du nœud379 logEvent({ kind: 'reel_waiting_user' });380 } else if (isVideo) {381 // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle382 for (let i = 0; i < 6; i++) {383 if ((await osaJS(canPublishJS)) === 'YES') break;384 const r = await clickBtnOS(['Suivant']);385 if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }386 }387 // Publier / Partager par focus + Entrée (poll pendant le téléversement)388 let done = false;389 for (let i = 0; i < 14; i++) {390 if ((await osaJS(canPublishJS)) === 'YES') {391 const r = await clickBtnOS(['Publier', 'Partager']);392 if (r === 'clicked') { done = true; break; }393 }394 await sleep(5000); await dismissModals(1);395 }396 if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');397 } else {398 const nx = await osaJS(clickAny(['Suivant']));399 if (nx === 'clicked') await sleep(7000);400 const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);401 if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');402 }403404 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte405 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique406 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.407 const composerGoneJS = `(function(){408 var open=false;409 document.querySelectorAll('div[role=dialog]').forEach(function(d){410 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;411 });412 return open ? 'OPEN' : 'GONE';413 })();`;414 let gone = 'OPEN';415 const goneTries = assisted ? 45 : (isVideo ? 40 : 18);416 for (let i = 0; i < goneTries; i++) {417 await sleep(8000);418 await dismissModals(2); // ferme toute boîte non bloquante en route419 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }420 if (gone === 'GONE') break;421 }422 await dismissModals(3); // ferme la boîte « Plus tard » finale423 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');424 return true;425}426427// ---------- génération d'un brouillon (insight -> image + légende) ----------428export async function pickInsight({ site = '', excludeRecent = true } = {}) {429 const args = [INSIGHTS];430 if (site) args.push('--site', site);431 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));432 const r = await run(PY, args);433 if (r.error) throw new Error('insights.py: ' + r.error);434 const data = JSON.parse(r.stdout);435 const cand = (data.candidates || [])[0];436 if (!cand) throw new Error('aucun insight disponible');437 return cand;438}439440export async function renderCard(insight) {441 // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec442 let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));443 let p = (r.stdout || '').trim().split('\n').pop();444 if (r.error || !p || !fs.existsSync(p)) {445 logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });446 r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));447 p = (r.stdout || '').trim().split('\n').pop();448 if (r.error) throw new Error('render: ' + r.error);449 }450 if (!p || !fs.existsSync(p)) throw new Error('image non produite');451 return p;452}453454// --- produit vedette (photo réelle) pour le genre « spotlight » ---455const KNOWN_SITES = ['lou-ka','immo-ka','vrai-prix','auto-ka','food-ka','fabri-ka','sorti-ka','job-ka'];456const SPOT = {457 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },458 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },459 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },460 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },461};462async function fetchFeatured(site) {463 const cfg = SPOT[site]; if (!cfg) return null;464 try {465 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });466 const data = await res.json();467 const arr = data[cfg.arr] || [];468 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');469 for (const it of arr) {470 const img = (it.images && it.images[0]) || it.image || '';471 const title = (it.title || it.name || '').trim();472 if (!img || !title || title.length > 80) continue;473 const price = it.price || it.price_min || null;474 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;475 if (site === 'lou-ka') {476 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),477 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,478 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };479 }480 if (site === 'immo-ka') {481 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;482 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),483 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,484 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };485 }486 if (site === 'fabri-ka') {487 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),488 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,489 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };490 }491 if (site === 'sorti-ka') {492 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),493 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,494 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };495 }496 }497 } catch {}498 return null;499}500501// ---- listes (plusieurs items réels) ----502const LIST_TITLES = {503 'lou-ka': 'Les derniers logements à louer', 'immo-ka': 'Les dernières propriétés à vendre',504 'fabri-ka': 'Nouveautés québécoises', 'sorti-ka': 'Les prochains événements',505};506function mapItem(site, it, fr) {507 const img = (it.images && it.images[0]) || it.image || '';508 const title = (it.title || it.name || '').trim();509 if (!img || !title) return null;510 const price = it.price || it.price_min || null;511 if (site === 'lou-ka') return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img, _price: price };512 if (site === 'immo-ka') return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };513 if (site === 'fabri-ka') return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };514 if (site === 'sorti-ka') return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '), price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img, _price: price };515 return { title, subtitle: it.city || '', price: it.price_label || '', image: img, _price: price };516}517const LIST_CFG = {518 'lou-ka': { n: 60, min: 500, max: 6000 },519 'immo-ka': { n: 300, min: 200000, max: 9000000 }, // feed trié par prix croissant -> chercher large520 'fabri-ka': { n: 60, min: 0, max: 0 },521 'sorti-ka': { n: 60, min: 0, max: 0 },522};523async function fetchItems(site, count) {524 const cfg = SPOT[site]; if (!cfg) return null;525 const lc = LIST_CFG[site] || { n: 60, min: 0, max: 0 };526 const listMin = lc.min, listMax = lc.max;527 const url = cfg.url.replace(/(limit|per_page)=\d+/, '$1=' + lc.n);528 try {529 const res = await fetch(url, { signal: AbortSignal.timeout(14000) });530 const data = await res.json();531 const arr = data[cfg.arr] || [];532 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');533 const valid = [];534 const seen = new Set();535 for (const it of arr) {536 const row = mapItem(site, it, fr);537 if (!row || !row.image || !row.title) continue;538 if (seen.has(row.title)) continue; // pas deux fois la même adresse/annonce539 seen.add(row.title);540 if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…';541 valid.push(row);542 }543 if (valid.length < 2) return null;544 // préférence : items dans la fourchette de prix ; sinon repli sur les plus chers545 let pick = valid.filter(r => (!listMin || (r._price && r._price >= listMin)) && (!listMax || !r._price || r._price <= listMax));546 if (pick.length < Math.min(count, 3)) pick = valid.slice().sort((a, b) => (b._price || 0) - (a._price || 0));547 pick = pick.slice(0, count);548 pick.forEach(r => delete r._price);549 return pick.length >= 2 ? pick : null;550 } catch { return null; }551}552// analyse le prompt -> {site, mode, count}553async function planFromPrompt(prompt) {554 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';555 const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list|grid|donut|quote\",\"count\":<1-6>}.\n" +556 "Sites: lou-ka (logements à louer), immo-ka (propriétés à vendre), vrai-prix (évaluations), auto-ka (autos occasion), food-ka (épicerie/soldes), fabri-ka (produits québécois), sorti-ka (événements), job-ka (emplois).\n" +557 "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items ; 'grid' = MOSAÏQUE de 4 photos d'items ; 'donut' = un POURCENTAGE mis en scène en anneau ; 'quote' = le fait raconté en grande typo (« le saviez-vous »).\n" +558 "Règles: 'liste/derniers/plusieurs/top N/montre' => list (count = N demandé ou 5). 'mosaïque/collage/en images' => grid. 'dernier/une annonce/en vedette' => spotlight. 'pourcentage/part/proportion' => donut. 'fait/anecdote/saviez-vous/storytelling' => quote. Sinon stat. list/grid/spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat/donut/quote. Choisis le site le plus pertinent.";559 try {560 const raw = await anthropic(sys, "Demande : " + prompt, model, 120);561 const m = raw.match(/\{[\s\S]*\}/);562 const plan = JSON.parse(m ? m[0] : raw);563 return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) };564 } catch { return { site: '', mode: 'stat', count: 5 }; }565}566// pose la variante (rotation thème visuel + ambiance musicale des rendus)567function applyVariant(insight) { insight.variant = state.postCount || 0; return insight; }568const isPct = (insight) => /%\s*$/.test(String(insight.headline || ''));569570// attache les données nécessaires à un genre ; renvoie le genre réellement possible571async function attachGenre(insight, genre, count) {572 if (genre === 'spotlight') {573 const feat = await fetchFeatured(insight.site);574 if (feat) { insight.product = feat; return 'spotlight'; }575 return null;576 }577 if (genre === 'list' || genre === 'grid') {578 const items = await fetchItems(insight.site, genre === 'grid' ? 4 : (count || 5));579 if (items && items.length >= (genre === 'grid' ? 4 : 2)) {580 insight.list = items;581 insight.list_title = LIST_TITLES[insight.site] || insight.headline_label;582 return genre;583 }584 return null;585 }586 if (genre === 'donut') return isPct(insight) ? 'donut' : null;587 if (genre === 'quote') return String(insight.fact || '').length >= 40 ? 'quote' : null;588 if (genre === 'ranking') return (insight.bars && insight.bars.length >= 4) ? 'ranking' : null;589 return 'hero';590}591592// applique un plan à un insight (mode explicite du Studio) + repli593async function decoratePlan(insight, plan) {594 applyVariant(insight);595 const wanted = plan.mode === 'stat' ? (isPct(insight) ? 'donut' : 'hero') : plan.mode;596 let g = await attachGenre(insight, wanted, plan.count);597 if (!g && (wanted === 'list' || wanted === 'grid')) g = await attachGenre(insight, 'spotlight');598 if (!g) g = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';599 insight.genre = g;600 return insight;601}602603// enrichit un insight pour un REEL multi-scènes : toujours tenter d'attacher604// de vraies annonces avec photos (scènes photo) en plus du genre choisi605async function enrichReel(insight) {606 if (SPOT[insight.site] && !insight.list) {607 const items = await fetchItems(insight.site, 4);608 if (items && items.length >= 2) {609 insight.list = items;610 insight.list_title = LIST_TITLES[insight.site] || insight.headline_label;611 }612 }613 if (SPOT[insight.site] && !insight.list && !insight.product) {614 const feat = await fetchFeatured(insight.site);615 if (feat) insight.product = feat;616 }617 return insight;618}619620// rotation des genres pour les REELS auto / lots (sans prompt)621async function decorateForPrompt(insight) {622 const n = applyVariant(insight).variant;623 const wheel = [];624 if (SPOT[insight.site]) wheel.push('spotlight', 'list');625 if (isPct(insight)) wheel.push('donut');626 wheel.push('hero', 'quote');627 let g = await attachGenre(insight, wheel[n % wheel.length]);628 if (!g) g = 'hero';629 insight.genre = g;630 return insight;631}632633// rotation des genres pour les POSTS image auto (le plus de diversité possible)634async function decorateCreative(insight) {635 const n = applyVariant(insight).variant;636 const wheel = ['hero'];637 if (insight.bars && insight.bars.length >= 4) wheel.push('ranking');638 if (isPct(insight)) wheel.push('donut');639 wheel.push('quote');640 if (SPOT[insight.site]) wheel.push('spotlight', 'list', 'grid');641 let g = await attachGenre(insight, wheel[n % wheel.length]);642 if (!g) g = 'hero';643 insight.genre = g;644 return insight;645}646647// légende via API Anthropic (le modèle Claude rédige à partir du fait)648async function anthropic(system, user, model, maxTokens = 500) {649 const key = CFG.anthropicKey;650 if (!key) throw new Error('Clé Anthropic non configurée');651 const resp = await fetch('https://api.anthropic.com/v1/messages', {652 method: 'POST',653 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },654 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),655 });656 const data = await resp.json();657 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');658 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();659}660661const 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.662663Règles STRICTES :664- 1 seule publication, 40 à 90 mots.665- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).666- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).667- 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).`;668669export async function writeCaption(insight, extraPrompt = '') {670 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';671 const url = `https://www.${insight.site}.com`;672 let user;673 if (insight.list && (insight.genre === 'list' || insight.genre === 'grid')) {674 const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n');675 user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` +676 `${insight.list_title || ''}\n${lignes}\n\n` +677 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +678 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +679 `\nRédige une publication Facebook qui donne envie de parcourir cette sélection (tu peux mentionner 2-3 items, sans tout lister).`;680 } else if (insight.product && insight.genre === 'spotlight') {681 const pr = insight.product;682 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +683 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +684 (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +685 `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +686 (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +687 `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;688 } else {689 user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +690 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +691 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +692 `\nRédige la publication Facebook.`;693 }694 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);695 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";696}697698// brouillon complet : insight -> image + légende (sans publier)699export async function generateDraft({ site = '', prompt = '' } = {}) {700 let insight;701 if (prompt) {702 const plan = await planFromPrompt(prompt);703 if (site) plan.site = site;704 const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';705 insight = await pickInsight({ site: chosenSite });706 await decoratePlan(insight, plan);707 } else {708 insight = await pickInsight({ site });709 await decorateCreative(insight);710 }711 const image = await renderCard(insight);712 const caption = await writeCaption(insight, prompt);713 const imageUrl='/api/social/card/'+path.basename(image);714 addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() });715 return { insight, image, caption, imageUrl };716}717718// cycle complet automatique : choisir -> rendre -> rédiger -> publier719export async function runAutoCycle(trigger = 'auto') {720 const started = logEvent({ kind: 'cycle_start', trigger });721 try {722 const login = await fbLoginState();723 if (login !== 'LOGGED_IN') {724 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });725 return { ok: false, reason: 'facebook_non_connecté' };726 }727 const insight = await pickInsight({});728 await decorateCreative(insight);729 const image = await renderCard(insight);730 const caption = await writeCaption(insight);731 await publishToPage(caption, image);732 state.lastSites = [...state.lastSites, insight.site].slice(-6);733 state.lastPostAt = Date.now();734 state.postCount = (state.postCount || 0) + 1;735 saveState();736 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,737 headline: insight.headline, caption, image: path.basename(image) });738 return { ok: true, insight, caption, image };739 } catch (e) {740 logEvent({ kind: 'cycle_error', trigger, error: e.message });741 return { ok: false, error: e.message };742 }743}744745// publier un brouillon fourni (post sur demande)746export async function publishDraft({ caption, image }) {747 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');748 const login = await fbLoginState();749 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');750 await publishToPage(caption, image);751 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();752 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });753 return true;754}755756// ---------- Reels (vidéo animée + musique) ----------757export async function makeReel(insight) {758 const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));759 const p = (r.stdout || '').trim().split('\n').pop();760 if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));761 return p;762}763764export function reelPath(name) {765 const safe = path.basename(name);766 const fp = path.join(REELS_DIR, safe);767 return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;768}769770// brouillon reel : insight -> vidéo + légende (sans publier)771export async function generateReelDraft({ site = '', prompt = '' } = {}) {772 let insight;773 if (prompt) {774 const plan = await planFromPrompt(prompt);775 if (site) plan.site = site;776 const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';777 insight = await pickInsight({ site: chosenSite });778 await decoratePlan(insight, plan);779 } else {780 insight = await pickInsight({ site });781 await decorateForPrompt(insight);782 }783 await enrichReel(insight);784 const video = await makeReel(insight);785 const caption = await writeCaption(insight, prompt);786 const videoUrl='/api/social/reel/'+path.basename(video);787 addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() });788 return { insight, video, caption, videoUrl };789}790791export async function publishReelDraft({ caption, video }) {792 if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');793 const login = await fbLoginState();794 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');795 await publishReelToPage(caption, video);796 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();797 logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });798 return true;799}800801// cycle reel complet automatique802export async function runReelCycle(trigger = 'reel') {803 logEvent({ kind: 'cycle_start', trigger, format: 'reel' });804 try {805 const login = await fbLoginState();806 if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }807 const insight = await pickInsight({});808 await decorateForPrompt(insight);809 await enrichReel(insight);810 const video = await makeReel(insight);811 const caption = await writeCaption(insight);812 await publishReelToPage(caption, video);813 state.lastSites = [...state.lastSites, insight.site].slice(-6);814 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();815 logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });816 return { ok: true, insight, caption, video };817 } catch (e) {818 logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });819 return { ok: false, error: e.message };820 }821}822823// ---------- Galerie (tout le contenu généré : posts + reels) ----------824const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json');825function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } }826function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} }827function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); }828export function getGallery(){ return loadGallery().slice(0,60); }829830let genBatchRunning = false;831export function startGenBatch(sites){832 if (genBatchRunning) return { started:false, reason:'génération en cours' };833 genBatchRunning = true;834 (async () => {835 logEvent({ kind:'genbatch_start', total:sites.length });836 for (let i=0;i<sites.length;i++){837 const site=sites[i];838 try{839 logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });840 const insight=await pickInsight({ site });841 await decorateForPrompt(insight);842 await enrichReel(insight);843 const video=await makeReel(insight);844 const caption=await writeCaption(insight);845 addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),846 url:'/api/social/reel/'+path.basename(video), ts:Date.now() });847 logEvent({ kind:'genbatch_done_item', i:i+1, site });848 }catch(e){ logEvent({ kind:'genbatch_error', i:i+1, site, error:e.message }); }849 }850 logEvent({ kind:'genbatch_done', total:sites.length });851 genBatchRunning=false;852 })();853 return { started:true, count:sites.length };854}855export function genBatchStatus(){ return { running: genBatchRunning }; }856857// ---------- Lot de reels (génération + publication assistée) ----------858let batchRunning = false;859export const REEL_BATCH_DEFAULT = ['lou-ka','immo-ka','fabri-ka','sorti-ka','food-ka','auto-ka','vrai-prix'];860export function startReelBatch(sites) {861 if (batchRunning) return { started: false, reason: 'lot déjà en cours' };862 batchRunning = true;863 (async () => {864 logEvent({ kind: 'batch_start', total: sites.length });865 for (let i = 0; i < sites.length; i++) {866 const site = sites[i];867 try {868 logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });869 const insight = await pickInsight({ site });870 await decorateForPrompt(insight);871 await enrichReel(insight);872 const video = await makeReel(insight);873 const caption = await writeCaption(insight);874 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' });875 await publishReelAssisted(caption, video);876 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();877 logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() });878 } catch (e) {879 logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message });880 }881 }882 logEvent({ kind: 'batch_done', total: sites.length });883 batchRunning = false;884 })();885 return { started: true, count: sites.length };886}887export function batchStatus() { return { running: batchRunning }; }888889// ---------- planificateur horaire ----------890function scheduleNext() {891 clearTimeout(timer);892 if (!state.auto) { state.nextAt = 0; saveState(); return; }893 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;894 const since = Date.now() - (state.lastPostAt || 0);895 const wait = Math.max(60 * 1000, period - since);896 state.nextAt = Date.now() + wait; saveState();897 timer = setTimeout(async () => {898 const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts899 if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');900 else await runAutoCycle('auto');901 scheduleNext();902 }, wait);903}904905export function setAuto(on) {906 state.auto = !!on; saveState();907 scheduleNext();908 logEvent({ kind: on ? 'auto_on' : 'auto_off' });909 return socialState();910}911912export function socialState() {913 return {914 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,915 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,916 };917}918919export function cardPath(name) {920 const safe = path.basename(name);921 const p = path.join(CARDS_DIR, safe);922 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;923}924925export function initSocial(cfg, logger) {926 CFG = cfg || {};927 LOGGER = logger || (() => {});928 if (state.auto) scheduleNext();929 logEvent({ kind: 'init', auto: state.auto });930}931