// admin-ka — module « Social » : publication automatique horaire d'un insight // statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso // simonpboucher), + génération d'un post sur demande depuis un prompt. // // Pipeline : insights.py (choix du fait marquant) -> render_card.py (1 image) // -> légende (Claude / API Anthropic) -> publication Safari (injection JS, // zéro clavier, zéro presse-papiers système) sur la Page. // // Tout tourne sur le nœud M3U96a où Safari est connecté au compte Facebook. import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const APP_DIR = path.join(__dirname, '..'); const DATA_DIR = path.join(APP_DIR, 'data'); const SOCIAL_DIR = path.join(DATA_DIR, 'social'); const CARDS_DIR = path.join(SOCIAL_DIR, 'cards'); const REELS_DIR = path.join(SOCIAL_DIR, 'reels'); const STATE_PATH = path.join(SOCIAL_DIR, 'state.json'); const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl'); fs.mkdirSync(CARDS_DIR, { recursive: true }); fs.mkdirSync(REELS_DIR, { recursive: true }); const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`; const PY = '/opt/homebrew/bin/python3'; const INSIGHTS = path.join(__dirname, 'social', 'insights.py'); const RENDER = path.join(__dirname, 'social', 'render_card.py'); const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py'); const MAKE_REEL = path.join(__dirname, 'social', 'make_reel.py'); let CFG = {}; // injecté par init() let LOGGER = () => {}; let timer = null; // ---------- état persistant ---------- function loadState() { try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {} return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 }; } let state = loadState(); function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); } function logEvent(ev) { const line = { ts: Date.now(), ...ev }; try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {} LOGGER(line); return line; } export function socialLog(limit = 50) { try { const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean); return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse(); } catch { return []; } } // ---------- utilitaires enfant ---------- function run(bin, args, opts = {}) { return new Promise((resolve) => { execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => { resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' }); }); }); } // mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage) let safariChain = Promise.resolve(); function withSafari(fn) { const next = safariChain.then(fn, fn); safariChain = next.catch(() => {}); return next; } const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function runInput(bin, args, input) { return new Promise((resolve) => { const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; ch.stdout.on('data', d => out += d); ch.stderr.on('data', d => err += d); ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err })); ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' })); ch.stdin.write(input); ch.stdin.end(); }); } // ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ---------- let fbWinId = null; async function osaJS(jsCode) { // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`); fs.writeFileSync(tmp, jsCode); if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); } 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`; const r = await run('/usr/bin/osascript', ['-e', apple]); try { fs.unlinkSync(tmp); } catch {} if (r.error) throw new Error('osascript: ' + r.error.trim()); return r.stdout.trim(); } async function resolveFbWindow() { // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com const findScript = ` tell application "Safari" with timeout of 15 seconds set out to "" repeat with w in windows try set u to URL of tab 1 of w if u contains "facebook.com" then return (id of w as string) end try end repeat return "NONE" end timeout end tell`; let r = await run('/usr/bin/osascript', ['-e', findScript]); let id = (r.stdout || '').trim(); const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']'; if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; } // aucune : ouvrir la page dans une nouvelle fenêtre const openScript = ` tell application "Safari" with timeout of 25 seconds make new document with properties {URL:"${PAGE_URL}"} delay 2 return (id of window 1 as string) end timeout end tell`; r = await run('/usr/bin/osascript', ['-e', openScript]); const nid = (r.stdout || '').trim(); fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null; if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']'); return fbWinId; } async function gotoPage() { await resolveFbWindow(); try { await run('/usr/bin/osascript', ['-e', `tell application "Safari" to set bounds of window id ${fbWinId} to {0, 0, 1512, 964}`]); } catch {} try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {} await run('/usr/bin/osascript', ['-e', `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]); } // ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…) const DISMISS_JS = `(function(){ var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer']; var clicked=''; document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){ if(clicked) return; var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||''; for(var i=0;i v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000); return out; } // ---------- publication (texte + 1 image) sur la Page ---------- export function publishToPage(caption, imagePath) { return withSafari(() => _publishMedia(caption, imagePath, false)); } export function publishReelToPage(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true)); } async function _publishMedia(caption, mediaPath, isVideo) { await gotoPage(); await sleep(9000); // 1) ouvrir le composeur const openComposer = `(function(){ var target=null; document.querySelectorAll('span,div').forEach(function(e){ var t=(e.innerText||'').trim(); if(!target && e.children.length===0 && (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 || t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e; }); if(!target) return 'NOT_FOUND'; var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN'; ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); return 'opened'; })();`; // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer. const basculer = `(function(){ var btn=null; document.querySelectorAll('[role=button]').forEach(function(e){ var t=(e.innerText||'').trim(); if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e; }); if(!btn) return 'NO_BTN'; ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); return 'switched'; })();`; let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000); if (r1 !== 'opened') { const sw = await osaJS(basculer); if (sw === 'switched') { await sleep(9000); await dismissModals(3); } r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000); } if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')'); await sleep(3000); // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés // et le double collage) — atomique : ne colle que si le box est encore vide. const b64cap = Buffer.from(caption, 'utf8').toString('base64'); // trouve le contenteditable visible le plus « profond » (composeur actif) const findBoxFn = `function(){ var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]')); if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]')); var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;}); return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null); }`; const pasteJS = `(function(){ var box=(${findBoxFn})(); if(!box) return 'NO_BOX'; if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller box.focus(); var txt=decodeURIComponent(escape(atob('${b64cap}'))); var dt=new DataTransfer(); dt.setData('text/plain', txt); box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true})); return 'pasted'; })();`; const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`; let len = 0; for (let i = 0; i < 3; i++) { const r = await osaJS(pasteJS); await sleep(2500); const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10); if (len > 40) break; await sleep(1200); } if (!(len > 40)) throw new Error('collage légende échoué'); // 3) injecter le média (image OU vidéo) dans l'input file du composeur const mediaB64 = fs.readFileSync(mediaPath).toString('base64'); const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png'; const mtype = isVideo ? 'video/mp4' : 'image/png'; const injectJS = `(function(){ var inp=document.querySelector('div[role=dialog] input[type=file]') || document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') || document.querySelector('input[type=file]'); if(!inp) return 'NO_INPUT'; try{ var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len); for(var i=0;i blob: dans le dialogue) const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0'; var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;}); dlg.querySelectorAll('video').forEach(function(){n++;}); return ''+n;})();`; const prevTries = isVideo ? 30 : 12; // upload/traitement vidéo plus long const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000); if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent'); await sleep(isVideo ? 4000 : 2000); // 5) Suivant (si présent) puis Publier const clickBtn = (label) => `(function(){ var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null; dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){ if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});}); if(!btn) return 'NO_BTN'; if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED'; ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); return 'clicked';})();`; // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran) const focusBtn = (labels) => `(function(){ var want=${JSON.stringify(labels)}; var btn=null; document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){ if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim(); if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b; }); if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus(); return (document.activeElement===btn)?'focused':'focus_fail'; })();`; async function clickBtnOS(labels) { const coordJS = `(function(){ var want=${JSON.stringify(labels)}; var btn=null; document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){ if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim(); if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b; }); if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){} var r=btn.getBoundingClientRect(); var gx=Math.round(window.screenX + r.left + r.width/2); var gy=Math.round(window.screenY + r.top + r.height/2); return gx+' '+gy; })();`; const co = await osaJS(coordJS); if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN'; const [gx, gy] = co.split(' ').map(n => parseInt(n, 10)); if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co; const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3', '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]); return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked'; } async function advanceByKey(labels) { const f = await osaJS(focusBtn(labels)); if (f !== 'focused') return f; await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']); return 'pressed'; } const clickAny = (labels) => `(function(){ var want=${JSON.stringify(labels)}; var btn=null; document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){ if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim(); if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b; }); if(!btn) return 'NO_BTN'; ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));}); return 'clicked'; })();`; 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';})();`; if (isVideo) { // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle for (let i = 0; i < 6; i++) { if ((await osaJS(canPublishJS)) === 'YES') break; const r = await clickBtnOS(['Suivant']); if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); } } // Publier / Partager par focus + Entrée (poll pendant le téléversement) let done = false; for (let i = 0; i < 14; i++) { if ((await osaJS(canPublishJS)) === 'YES') { const r = await clickBtnOS(['Publier', 'Partager']); if (r === 'clicked') { done = true; break; } } await sleep(5000); await dismissModals(1); } if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)'); } else { const nx = await osaJS(clickAny(['Suivant'])); if (nx === 'clicked') await sleep(7000); const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000); if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')'); } // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur. const composerGoneJS = `(function(){ var open=false; document.querySelectorAll('div[role=dialog]').forEach(function(d){ if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true; }); return open ? 'OPEN' : 'GONE'; })();`; let gone = 'OPEN'; const goneTries = isVideo ? 40 : 18; for (let i = 0; i < goneTries; i++) { await sleep(8000); await dismissModals(2); // ferme toute boîte non bloquante en route try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; } if (gone === 'GONE') break; } await dismissModals(3); // ferme la boîte « Plus tard » finale if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)'); return true; } // ---------- génération d'un brouillon (insight -> image + légende) ---------- export async function pickInsight({ site = '', excludeRecent = true } = {}) { const args = [INSIGHTS]; if (site) args.push('--site', site); else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(',')); const r = await run(PY, args); if (r.error) throw new Error('insights.py: ' + r.error); const data = JSON.parse(r.stdout); const cand = (data.candidates || [])[0]; if (!cand) throw new Error('aucun insight disponible'); return cand; } export async function renderCard(insight) { // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight)); let p = (r.stdout || '').trim().split('\n').pop(); if (r.error || !p || !fs.existsSync(p)) { logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) }); r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight)); p = (r.stdout || '').trim().split('\n').pop(); if (r.error) throw new Error('render: ' + r.error); } if (!p || !fs.existsSync(p)) throw new Error('image non produite'); return p; } // --- produit vedette (photo réelle) pour le genre « spotlight » --- const SPOT = { 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 }, 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 }, 'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 }, 'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24', arr: 'events', minPrice: 0 }, }; async function fetchFeatured(site) { const cfg = SPOT[site]; if (!cfg) return null; try { const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) }); const data = await res.json(); const arr = data[cfg.arr] || []; const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' '); for (const it of arr) { const img = (it.images && it.images[0]) || it.image || ''; const title = (it.title || it.name || '').trim(); if (!img || !title || title.length > 80) continue; const price = it.price || it.price_min || null; if (cfg.minPrice && (!price || price < cfg.minPrice)) continue; if (site === 'lou-ka') { return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img, meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) }; } if (site === 'immo-ka') { const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null; return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) }; } if (site === 'fabri-ka') { return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img, meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) }; } if (site === 'sorti-ka') { return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '), price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img, meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) }; } } } catch {} return null; } async function decorateForPrompt(insight) { if (SPOT[insight.site]) { const feat = await fetchFeatured(insight.site); if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; } } insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero'; return insight; } // choisit le genre visuel (rotation) et attache le produit vedette si spotlight async function decorateCreative(insight) { const n = state.postCount || 0; let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero'; if (SPOT[insight.site] && (n % 3 === 0)) { const feat = await fetchFeatured(insight.site); if (feat) { insight.product = feat; genre = 'spotlight'; } } insight.genre = genre; return insight; } // légende via API Anthropic (le modèle Claude rédige à partir du fait) async function anthropic(system, user, model, maxTokens = 500) { const key = CFG.anthropicKey; if (!key) throw new Error('Clé Anthropic non configurée'); const resp = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }), }); const data = await resp.json(); if (data.error) throw new Error(data.error.message || 'Erreur Anthropic'); return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim(); } const 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. Règles STRICTES : - 1 seule publication, 40 à 90 mots. - Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni). - Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA). - 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).`; export async function writeCaption(insight, extraPrompt = '') { const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; const url = `https://www.${insight.site}.com`; let user; if (insight.product && insight.genre === 'spotlight') { const pr = insight.product; user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` + `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') + (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') + `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` + (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') + `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`; } else { user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` + `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + `\nRédige la publication Facebook.`; } const txt = await anthropic(CAPTION_SYSTEM, user, model, 500); return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com"; } // brouillon complet : insight -> image + légende (sans publier) export async function generateDraft({ site = '', prompt = '' } = {}) { let insight; if (prompt && !site) { // laisser le modèle choisir le site le plus pertinent pour ce prompt const r = await run(PY, [INSIGHTS]); const data = JSON.parse(r.stdout || '{}'); const cands = data.candidates || []; const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n'); const chosen = await anthropic( "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.", `Demande : ${prompt}\n\nInsights disponibles :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30); const pickSite = (chosen || '').trim().split(/\s/)[0]; insight = cands.find(c => c.site === pickSite) || cands[0]; await decorateForPrompt(insight); } else { insight = await pickInsight({ site }); await decorateCreative(insight); } const image = await renderCard(insight); const caption = await writeCaption(insight, prompt); return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) }; } // cycle complet automatique : choisir -> rendre -> rédiger -> publier export async function runAutoCycle(trigger = 'auto') { const started = logEvent({ kind: 'cycle_start', trigger }); try { const login = await fbLoginState(); if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login }); return { ok: false, reason: 'facebook_non_connecté' }; } const insight = await pickInsight({}); await decorateCreative(insight); const image = await renderCard(insight); const caption = await writeCaption(insight); await publishToPage(caption, image); state.lastSites = [...state.lastSites, insight.site].slice(-6); state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState(); logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id, headline: insight.headline, caption, image: path.basename(image) }); return { ok: true, insight, caption, image }; } catch (e) { logEvent({ kind: 'cycle_error', trigger, error: e.message }); return { ok: false, error: e.message }; } } // publier un brouillon fourni (post sur demande) export async function publishDraft({ caption, image }) { if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide'); const login = await fbLoginState(); if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud'); await publishToPage(caption, image); state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState(); logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) }); return true; } // ---------- Reels (vidéo animée + musique) ---------- export async function makeReel(insight) { const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight)); const p = (r.stdout || '').trim().split('\n').pop(); if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent')); return p; } export function reelPath(name) { const safe = path.basename(name); const fp = path.join(REELS_DIR, safe); return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null; } // brouillon reel : insight -> vidéo + légende (sans publier) export async function generateReelDraft({ site = '', prompt = '' } = {}) { let insight; if (prompt && !site) { const r = await run(PY, [INSIGHTS]); const cands = (JSON.parse(r.stdout || '{}').candidates) || []; const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n'); const chosen = await anthropic( "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.", `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30); insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0]; await decorateForPrompt(insight); } else { insight = await pickInsight({ site }); await decorateForPrompt(insight); } const video = await makeReel(insight); const caption = await writeCaption(insight, prompt); return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) }; } export async function publishReelDraft({ caption, video }) { if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide'); const login = await fbLoginState(); if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud'); await publishReelToPage(caption, video); state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState(); logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption }); return true; } // cycle reel complet automatique export async function runReelCycle(trigger = 'reel') { logEvent({ kind: 'cycle_start', trigger, format: 'reel' }); try { const login = await fbLoginState(); if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; } const insight = await pickInsight({}); const video = await makeReel(insight); const caption = await writeCaption(insight); await publishReelToPage(caption, video); state.lastSites = [...state.lastSites, insight.site].slice(-6); state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState(); logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) }); return { ok: true, insight, caption, video }; } catch (e) { logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message }); return { ok: false, error: e.message }; } } // ---------- planificateur horaire ---------- function scheduleNext() { clearTimeout(timer); if (!state.auto) { state.nextAt = 0; saveState(); return; } const period = (CFG.socialIntervalMin || 60) * 60 * 1000; const since = Date.now() - (state.lastPostAt || 0); const wait = Math.max(60 * 1000, period - since); state.nextAt = Date.now() + wait; saveState(); timer = setTimeout(async () => { const every = CFG.reelEvery || 0; // 0 = jamais de reel auto ; N = 1 reel tous les N posts if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel'); else await runAutoCycle('auto'); scheduleNext(); }, wait); } export function setAuto(on) { state.auto = !!on; saveState(); scheduleNext(); logEvent({ kind: on ? 'auto_on' : 'auto_off' }); return socialState(); } export function socialState() { return { auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt, lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60, }; } export function cardPath(name) { const safe = path.basename(name); const p = path.join(CARDS_DIR, safe); return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null; } export function initSocial(cfg, logger) { CFG = cfg || {}; LOGGER = logger || (() => {}); if (state.auto) scheduleNext(); logEvent({ kind: 'init', auto: state.auto }); }