// 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, false)); } export function publishReelAssisted(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, true)); } async function _publishMedia(caption, mediaPath, isVideo, assisted) { 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 && assisted) { // mode assisté : l'utilisateur clique « Suivant »/« Publier » sur l'écran du nœud logEvent({ kind: 'reel_waiting_user' }); } else 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 = assisted ? 45 : (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 KNOWN_SITES = ['lou-ka','immo-ka','vrai-prix','auto-ka','food-ka','fabri-ka','sorti-ka','job-ka']; 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; } // ---- listes (plusieurs items réels) ---- const LIST_TITLES = { 'lou-ka': 'Les derniers logements à louer', 'immo-ka': 'Les dernières propriétés à vendre', 'fabri-ka': 'Nouveautés québécoises', 'sorti-ka': 'Les prochains événements', }; function mapItem(site, it, fr) { const img = (it.images && it.images[0]) || it.image || ''; const title = (it.title || it.name || '').trim(); if (!img || !title) return null; const price = it.price || it.price_min || null; 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 }; 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 }; 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 }; 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 }; return { title, subtitle: it.city || '', price: it.price_label || '', image: img, _price: price }; } const LIST_CFG = { 'lou-ka': { n: 60, min: 500, max: 6000 }, 'immo-ka': { n: 300, min: 200000, max: 9000000 }, // feed trié par prix croissant -> chercher large 'fabri-ka': { n: 60, min: 0, max: 0 }, 'sorti-ka': { n: 60, min: 0, max: 0 }, }; async function fetchItems(site, count) { const cfg = SPOT[site]; if (!cfg) return null; const lc = LIST_CFG[site] || { n: 60, min: 0, max: 0 }; const listMin = lc.min, listMax = lc.max; const url = cfg.url.replace(/(limit|per_page)=\d+/, '$1=' + lc.n); try { const res = await fetch(url, { signal: AbortSignal.timeout(14000) }); const data = await res.json(); const arr = data[cfg.arr] || []; const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' '); const valid = []; const seen = new Set(); for (const it of arr) { const row = mapItem(site, it, fr); if (!row || !row.image || !row.title) continue; if (seen.has(row.title)) continue; // pas deux fois la même adresse/annonce seen.add(row.title); if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…'; valid.push(row); } if (valid.length < 2) return null; // préférence : items dans la fourchette de prix ; sinon repli sur les plus chers let pick = valid.filter(r => (!listMin || (r._price && r._price >= listMin)) && (!listMax || !r._price || r._price <= listMax)); if (pick.length < Math.min(count, 3)) pick = valid.slice().sort((a, b) => (b._price || 0) - (a._price || 0)); pick = pick.slice(0, count); pick.forEach(r => delete r._price); return pick.length >= 2 ? pick : null; } catch { return null; } } // analyse le prompt -> {site, mode, count} async function planFromPrompt(prompt) { const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"\",\"mode\":\"stat|spotlight|list|grid|donut|quote\",\"count\":<1-6>}.\n" + "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" + "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" + "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."; try { const raw = await anthropic(sys, "Demande : " + prompt, model, 120); const m = raw.match(/\{[\s\S]*\}/); const plan = JSON.parse(m ? m[0] : raw); return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) }; } catch { return { site: '', mode: 'stat', count: 5 }; } } // pose la variante (rotation thème visuel + ambiance musicale des rendus) function applyVariant(insight) { insight.variant = state.postCount || 0; return insight; } const isPct = (insight) => /%\s*$/.test(String(insight.headline || '')); // attache les données nécessaires à un genre ; renvoie le genre réellement possible async function attachGenre(insight, genre, count) { if (genre === 'spotlight') { const feat = await fetchFeatured(insight.site); if (feat) { insight.product = feat; return 'spotlight'; } return null; } if (genre === 'list' || genre === 'grid') { const items = await fetchItems(insight.site, genre === 'grid' ? 4 : (count || 5)); if (items && items.length >= (genre === 'grid' ? 4 : 2)) { insight.list = items; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; return genre; } return null; } if (genre === 'donut') return isPct(insight) ? 'donut' : null; if (genre === 'quote') return String(insight.fact || '').length >= 40 ? 'quote' : null; if (genre === 'ranking') return (insight.bars && insight.bars.length >= 4) ? 'ranking' : null; return 'hero'; } // applique un plan à un insight (mode explicite du Studio) + repli async function decoratePlan(insight, plan) { applyVariant(insight); const wanted = plan.mode === 'stat' ? (isPct(insight) ? 'donut' : 'hero') : plan.mode; let g = await attachGenre(insight, wanted, plan.count); if (!g && (wanted === 'list' || wanted === 'grid')) g = await attachGenre(insight, 'spotlight'); if (!g) g = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero'; insight.genre = g; return insight; } // enrichit un insight pour un REEL multi-scènes : toujours tenter d'attacher // de vraies annonces avec photos (scènes photo) en plus du genre choisi async function enrichReel(insight) { if (SPOT[insight.site] && !insight.list) { const items = await fetchItems(insight.site, 4); if (items && items.length >= 2) { insight.list = items; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; } } if (SPOT[insight.site] && !insight.list && !insight.product) { const feat = await fetchFeatured(insight.site); if (feat) insight.product = feat; } return insight; } // rotation des genres pour les REELS auto / lots (sans prompt) async function decorateForPrompt(insight) { const n = applyVariant(insight).variant; const wheel = []; if (SPOT[insight.site]) wheel.push('spotlight', 'list'); if (isPct(insight)) wheel.push('donut'); wheel.push('hero', 'quote'); let g = await attachGenre(insight, wheel[n % wheel.length]); if (!g) g = 'hero'; insight.genre = g; return insight; } // rotation des genres pour les POSTS image auto (le plus de diversité possible) async function decorateCreative(insight) { const n = applyVariant(insight).variant; const wheel = ['hero']; if (insight.bars && insight.bars.length >= 4) wheel.push('ranking'); if (isPct(insight)) wheel.push('donut'); wheel.push('quote'); if (SPOT[insight.site]) wheel.push('spotlight', 'list', 'grid'); let g = await attachGenre(insight, wheel[n % wheel.length]); if (!g) g = 'hero'; insight.genre = g; 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.list && (insight.genre === 'list' || insight.genre === 'grid')) { const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n'); user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` + `${insight.list_title || ''}\n${lignes}\n\n` + `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` + (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') + `\nRédige une publication Facebook qui donne envie de parcourir cette sélection (tu peux mentionner 2-3 items, sans tout lister).`; } else 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) { const plan = await planFromPrompt(prompt); if (site) plan.site = site; const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : ''; insight = await pickInsight({ site: chosenSite }); await decoratePlan(insight, plan); } else { insight = await pickInsight({ site }); await decorateCreative(insight); } const image = await renderCard(insight); const caption = await writeCaption(insight, prompt); const imageUrl='/api/social/card/'+path.basename(image); addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() }); return { insight, image, caption, imageUrl }; } // 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) { const plan = await planFromPrompt(prompt); if (site) plan.site = site; const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : ''; insight = await pickInsight({ site: chosenSite }); await decoratePlan(insight, plan); } else { insight = await pickInsight({ site }); await decorateForPrompt(insight); } await enrichReel(insight); const video = await makeReel(insight); const caption = await writeCaption(insight, prompt); const videoUrl='/api/social/reel/'+path.basename(video); addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() }); return { insight, video, caption, videoUrl }; } 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({}); await decorateForPrompt(insight); await enrichReel(insight); 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 }; } } // ---------- Galerie (tout le contenu généré : posts + reels) ---------- const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json'); function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } } function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} } function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); } export function getGallery(){ return loadGallery().slice(0,60); } let genBatchRunning = false; export function startGenBatch(sites){ if (genBatchRunning) return { started:false, reason:'génération en cours' }; genBatchRunning = true; (async () => { logEvent({ kind:'genbatch_start', total:sites.length }); for (let i=0;i { logEvent({ kind: 'batch_start', total: sites.length }); for (let i = 0; i < sites.length; i++) { const site = sites[i]; try { logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' }); const insight = await pickInsight({ site }); await decorateForPrompt(insight); await enrichReel(insight); const video = await makeReel(insight); const caption = await writeCaption(insight); 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' }); await publishReelAssisted(caption, video); state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState(); logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() }); } catch (e) { logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message }); } } logEvent({ kind: 'batch_done', total: sites.length }); batchRunning = false; })(); return { started: true, count: sites.length }; } export function batchStatus() { return { running: batchRunning }; } // ---------- 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 }); }