SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%
46.0 KB · 931 lines javascript
Raw Blame History
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