// 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 STATE_PATH = path.join(SOCIAL_DIR, 'state.json'); const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl'); fs.mkdirSync(CARDS_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'); 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(); 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(() => _publishToPage(caption, imagePath)); } async function _publishToPage(caption, imagePath) { 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 via ClipboardEvent synthétique (base64, sans clavier) const b64cap = Buffer.from(caption, 'utf8').toString('base64'); const pasteJS = `(function(){ var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG'; var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX'; 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 dlg=document.querySelector('div[role=dialog]');` + `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` + `return 'len:'+(box?box.innerText.length:-1);})();`; let len = 0; for (let i = 0; i < 3; i++) { await osaJS(pasteJS); await sleep(2500); const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10); if (len > 40) break; await sleep(1500); } if (!(len > 40)) throw new Error('collage légende échoué'); // 3) injecter l'image dans l'input file du composeur (File construit en JS) const imgB64 = fs.readFileSync(imagePath).toString('base64'); const injectJS = `(function(){ var inp=document.querySelector('div[role=dialog] input[type=file]') || document.querySelector('input[type=file][accept*="image"]') || document.querySelector('input[type=file]'); if(!inp) return 'NO_INPUT'; try{ var bin=atob('${imgB64}'); 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++;}); return ''+n;})();`; const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000); if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent'); await sleep(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';})();`; const nx = await osaJS(clickBtn('Suivant')); if (nx === 'clicked') await sleep(7000); const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000); if (pub !== 'clicked') throw new Error('bouton Publier 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'; for (let i = 0; i < 18; 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; } // 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`; const 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]; } 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; } // ---------- 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 () => { 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 }); }