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 STATE_PATH = path.join(SOCIAL_DIR, 'state.json');24const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');25fs.mkdirSync(CARDS_DIR, { recursive: true });2627const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso28const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;29const PY = '/opt/homebrew/bin/python3';30const INSIGHTS = path.join(__dirname, 'social', 'insights.py');31const RENDER = path.join(__dirname, 'social', 'render_card.py');32const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');3334let CFG = {}; // injecté par init()35let LOGGER = () => {};36let timer = null;3738// ---------- état persistant ----------39function loadState() {40 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}41 return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };42}43let state = loadState();44function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4546function logEvent(ev) {47 const line = { ts: Date.now(), ...ev };48 try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}49 LOGGER(line);50 return line;51}52export function socialLog(limit = 50) {53 try {54 const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);55 return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();56 } catch { return []; }57}5859// ---------- utilitaires enfant ----------60function run(bin, args, opts = {}) {61 return new Promise((resolve) => {62 execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {63 resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });64 });65 });66}67// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)68let safariChain = Promise.resolve();69function withSafari(fn) {70 const next = safariChain.then(fn, fn);71 safariChain = next.catch(() => {});72 return next;73}74const sleep = (ms) => new Promise(r => setTimeout(r, ms));75function runInput(bin, args, input) {76 return new Promise((resolve) => {77 const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });78 let out = '', err = '';79 ch.stdout.on('data', d => out += d);80 ch.stderr.on('data', d => err += d);81 ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));82 ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));83 ch.stdin.write(input); ch.stdin.end();84 });85}8687// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------88let fbWinId = null;8990async function osaJS(jsCode) {91 // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu92 const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);93 fs.writeFileSync(tmp, jsCode);94 if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }95 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`;96 const r = await run('/usr/bin/osascript', ['-e', apple]);97 try { fs.unlinkSync(tmp); } catch {}98 if (r.error) throw new Error('osascript: ' + r.error.trim());99 return r.stdout.trim();100}101102async function resolveFbWindow() {103 // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com104 const findScript = `105tell application "Safari"106 with timeout of 15 seconds107 set out to ""108 repeat with w in windows109 try110 set u to URL of tab 1 of w111 if u contains "facebook.com" then return (id of w as string)112 end try113 end repeat114 return "NONE"115 end timeout116end tell`;117 let r = await run('/usr/bin/osascript', ['-e', findScript]);118 let id = (r.stdout || '').trim();119 const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';120 if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }121 // aucune : ouvrir la page dans une nouvelle fenêtre122 const openScript = `123tell application "Safari"124 with timeout of 25 seconds125 make new document with properties {URL:"${PAGE_URL}"}126 delay 2127 return (id of window 1 as string)128 end timeout129end tell`;130 r = await run('/usr/bin/osascript', ['-e', openScript]);131 const nid = (r.stdout || '').trim();132 fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;133 if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');134 return fbWinId;135}136137async function gotoPage() {138 await resolveFbWindow();139 await run('/usr/bin/osascript', ['-e',140 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);141}142143// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)144const DISMISS_JS = `(function(){145 var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];146 var clicked='';147 document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){148 if(clicked) return;149 var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';150 for(var i=0;i<labels.length;i++){151 if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){152 ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});153 clicked=labels[i]||al; break;154 }155 }156 });157 return clicked||'NONE';158})();`;159160async function pollJS(jsCode, want, tries = 12, delay = 4000) {161 let last = '';162 for (let i = 0; i < tries; i++) {163 try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }164 if (want(last)) return last;165 await sleep(delay);166 }167 return last;168}169170async function dismissModals(times) {171 times = times || 3;172 let any = false;173 for (let i = 0; i < times; i++) {174 let r = 'NONE';175 try { r = await osaJS(DISMISS_JS); } catch {}176 if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;177 }178 return any;179}180181// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)182export function fbLoginState() { return withSafari(_fbLoginState); }183async function _fbLoginState() {184 await resolveFbWindow();185 await run('/usr/bin/osascript', ['-e',186 `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`]);187 await sleep(6000);188 const js = `(function(){var b=document.body.innerText||"";` +189 `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +190 `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +191 `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;192 const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);193 return out;194}195196// ---------- publication (texte + 1 image) sur la Page ----------197export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); }198async function _publishToPage(caption, imagePath) {199 await gotoPage();200 await sleep(9000);201202 // 1) ouvrir le composeur203 const openComposer = `(function(){204 var target=null;205 document.querySelectorAll('span,div').forEach(function(e){206 var t=(e.innerText||'').trim();207 if(!target && e.children.length===0 &&208 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||209 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;210 });211 if(!target) return 'NOT_FOUND';212 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';213 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});214 return 'opened';215 })();`;216 // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans217 // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.218 const basculer = `(function(){219 var btn=null;220 document.querySelectorAll('[role=button]').forEach(function(e){221 var t=(e.innerText||'').trim();222 if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;223 });224 if(!btn) return 'NO_BTN';225 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});226 return 'switched';227 })();`;228 let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);229 if (r1 !== 'opened') {230 const sw = await osaJS(basculer);231 if (sw === 'switched') { await sleep(9000); await dismissModals(3); }232 r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);233 }234 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');235 await sleep(3000);236237 // 2) coller la légende via ClipboardEvent synthétique (base64, sans clavier)238 const b64cap = Buffer.from(caption, 'utf8').toString('base64');239 const pasteJS = `(function(){240 var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';241 var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';242 box.focus();243 var txt=decodeURIComponent(escape(atob('${b64cap}')));244 var dt=new DataTransfer(); dt.setData('text/plain', txt);245 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));246 return 'pasted';247 })();`;248 const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +249 `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +250 `return 'len:'+(box?box.innerText.length:-1);})();`;251 let len = 0;252 for (let i = 0; i < 3; i++) {253 await osaJS(pasteJS); await sleep(2500);254 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);255 if (len > 40) break; await sleep(1500);256 }257 if (!(len > 40)) throw new Error('collage légende échoué');258259 // 3) injecter l'image dans l'input file du composeur (File construit en JS)260 const imgB64 = fs.readFileSync(imagePath).toString('base64');261 const injectJS = `(function(){262 var inp=document.querySelector('div[role=dialog] input[type=file]') ||263 document.querySelector('input[type=file][accept*="image"]') ||264 document.querySelector('input[type=file]');265 if(!inp) return 'NO_INPUT';266 try{267 var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len);268 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);269 var file=new File([arr], 'ka-stat.png', {type:'image/png'});270 var dt=new DataTransfer(); dt.items.add(file);271 inp.files=dt.files;272 inp.dispatchEvent(new Event('change',{bubbles:true}));273 return 'injected';274 }catch(e){ return 'ERR:'+e.message; }275 })();`;276 const inj = await osaJS(injectJS);277 if (inj !== 'injected') {278 // repli : bouton Photo/Vidéo puis re-tenter l'injection279 const clickPhoto = `(function(){var btn=null;280 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){281 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});282 if(!btn) return 'NO_BTN';283 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});284 return 'clicked';})();`;285 await osaJS(clickPhoto); await sleep(2500);286 const inj2 = await osaJS(injectJS);287 if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')');288 }289290 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)291 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';292 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});293 return ''+n;})();`;294 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000);295 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent');296 await sleep(2000);297298 // 5) Suivant (si présent) puis Publier299 const clickBtn = (label) => `(function(){300 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;301 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){302 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});303 if(!btn) return 'NO_BTN';304 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';305 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});306 return 'clicked';})();`;307 const nx = await osaJS(clickBtn('Suivant'));308 if (nx === 'clicked') await sleep(7000);309 const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000);310 if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')');311312 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte313 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique314 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.315 const composerGoneJS = `(function(){316 var open=false;317 document.querySelectorAll('div[role=dialog]').forEach(function(d){318 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;319 });320 return open ? 'OPEN' : 'GONE';321 })();`;322 let gone = 'OPEN';323 for (let i = 0; i < 18; i++) {324 await sleep(8000);325 await dismissModals(2); // ferme toute boîte non bloquante en route326 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }327 if (gone === 'GONE') break;328 }329 await dismissModals(3); // ferme la boîte « Plus tard » finale330 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');331 return true;332}333334// ---------- génération d'un brouillon (insight -> image + légende) ----------335export async function pickInsight({ site = '', excludeRecent = true } = {}) {336 const args = [INSIGHTS];337 if (site) args.push('--site', site);338 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));339 const r = await run(PY, args);340 if (r.error) throw new Error('insights.py: ' + r.error);341 const data = JSON.parse(r.stdout);342 const cand = (data.candidates || [])[0];343 if (!cand) throw new Error('aucun insight disponible');344 return cand;345}346347export async function renderCard(insight) {348 // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec349 let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));350 let p = (r.stdout || '').trim().split('\n').pop();351 if (r.error || !p || !fs.existsSync(p)) {352 logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });353 r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));354 p = (r.stdout || '').trim().split('\n').pop();355 if (r.error) throw new Error('render: ' + r.error);356 }357 if (!p || !fs.existsSync(p)) throw new Error('image non produite');358 return p;359}360361// --- produit vedette (photo réelle) pour le genre « spotlight » ---362const SPOT = {363 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },364 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },365 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },366 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 },367};368async function fetchFeatured(site) {369 const cfg = SPOT[site]; if (!cfg) return null;370 try {371 const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });372 const data = await res.json();373 const arr = data[cfg.arr] || [];374 const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');375 for (const it of arr) {376 const img = (it.images && it.images[0]) || it.image || '';377 const title = (it.title || it.name || '').trim();378 if (!img || !title || title.length > 80) continue;379 const price = it.price || it.price_min || null;380 if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;381 if (site === 'lou-ka') {382 return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),383 price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,384 meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };385 }386 if (site === 'immo-ka') {387 const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;388 return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),389 price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,390 meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };391 }392 if (site === 'fabri-ka') {393 return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),394 price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,395 meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };396 }397 if (site === 'sorti-ka') {398 return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),399 price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,400 meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };401 }402 }403 } catch {}404 return null;405}406407// choisit le genre visuel (rotation) et attache le produit vedette si spotlight408async function decorateCreative(insight) {409 const n = state.postCount || 0;410 let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';411 if (SPOT[insight.site] && (n % 3 === 0)) {412 const feat = await fetchFeatured(insight.site);413 if (feat) { insight.product = feat; genre = 'spotlight'; }414 }415 insight.genre = genre;416 return insight;417}418419// légende via API Anthropic (le modèle Claude rédige à partir du fait)420async function anthropic(system, user, model, maxTokens = 500) {421 const key = CFG.anthropicKey;422 if (!key) throw new Error('Clé Anthropic non configurée');423 const resp = await fetch('https://api.anthropic.com/v1/messages', {424 method: 'POST',425 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },426 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),427 });428 const data = await resp.json();429 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');430 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();431}432433const 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.434435Règles STRICTES :436- 1 seule publication, 40 à 90 mots.437- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).438- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).439- 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).`;440441export async function writeCaption(insight, extraPrompt = '') {442 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';443 const url = `https://www.${insight.site}.com`;444 const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +445 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +446 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +447 `\nRédige la publication Facebook.`;448 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);449 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";450}451452// brouillon complet : insight -> image + légende (sans publier)453export async function generateDraft({ site = '', prompt = '' } = {}) {454 let insight;455 if (prompt && !site) {456 // laisser le modèle choisir le site le plus pertinent pour ce prompt457 const r = await run(PY, [INSIGHTS]);458 const data = JSON.parse(r.stdout || '{}');459 const cands = data.candidates || [];460 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');461 const chosen = await anthropic(462 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site (ex: immo-ka), rien d'autre.",463 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,464 CFG.socialModel || 'claude-opus-4-8', 30);465 const pickSite = (chosen || '').trim().split(/\s/)[0];466 insight = cands.find(c => c.site === pickSite) || cands[0];467 } else {468 insight = await pickInsight({ site });469 }470 await decorateCreative(insight);471 const image = await renderCard(insight);472 const caption = await writeCaption(insight, prompt);473 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };474}475476// cycle complet automatique : choisir -> rendre -> rédiger -> publier477export async function runAutoCycle(trigger = 'auto') {478 const started = logEvent({ kind: 'cycle_start', trigger });479 try {480 const login = await fbLoginState();481 if (login !== 'LOGGED_IN') {482 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });483 return { ok: false, reason: 'facebook_non_connecté' };484 }485 const insight = await pickInsight({});486 await decorateCreative(insight);487 const image = await renderCard(insight);488 const caption = await writeCaption(insight);489 await publishToPage(caption, image);490 state.lastSites = [...state.lastSites, insight.site].slice(-6);491 state.lastPostAt = Date.now();492 state.postCount = (state.postCount || 0) + 1;493 saveState();494 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,495 headline: insight.headline, caption, image: path.basename(image) });496 return { ok: true, insight, caption, image };497 } catch (e) {498 logEvent({ kind: 'cycle_error', trigger, error: e.message });499 return { ok: false, error: e.message };500 }501}502503// publier un brouillon fourni (post sur demande)504export async function publishDraft({ caption, image }) {505 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');506 const login = await fbLoginState();507 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');508 await publishToPage(caption, image);509 state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();510 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });511 return true;512}513514// ---------- planificateur horaire ----------515function scheduleNext() {516 clearTimeout(timer);517 if (!state.auto) { state.nextAt = 0; saveState(); return; }518 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;519 const since = Date.now() - (state.lastPostAt || 0);520 const wait = Math.max(60 * 1000, period - since);521 state.nextAt = Date.now() + wait; saveState();522 timer = setTimeout(async () => {523 await runAutoCycle('auto');524 scheduleNext();525 }, wait);526}527528export function setAuto(on) {529 state.auto = !!on; saveState();530 scheduleNext();531 logEvent({ kind: on ? 'auto_on' : 'auto_off' });532 return socialState();533}534535export function socialState() {536 return {537 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,538 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,539 };540}541542export function cardPath(name) {543 const safe = path.basename(name);544 const p = path.join(CARDS_DIR, safe);545 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;546}547548export function initSocial(cfg, logger) {549 CFG = cfg || {};550 LOGGER = logger || (() => {});551 if (state.auto) scheduleNext();552 logEvent({ kind: 'init', auto: state.auto });553}554