// 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'); 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 }; } 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`]); } async function pollJS(jsCode, want, tries = 12, delay = 4000) { let last = ''; for (let i = 0; i < tries; i++) { try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; } if (want(last)) return last; await sleep(delay); } return last; } // vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause) export function fbLoginState() { return withSafari(_fbLoginState); } async function _fbLoginState() { 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 "https://www.facebook.com/"\nend timeout\nend tell`]); await sleep(6000); const js = `(function(){var b=document.body.innerText||"";` + `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` + `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` + `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`; const out = await pollJS(js, v => 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'; })();`; const r1 = await pollJS(openComposer, v => v === 'opened', 10, 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 fermeture du dialogue (upload lent) const closedJS = `document.querySelector('div[role=dialog]') ? 'OPEN' : 'CLOSED';`; const closed = await pollJS(closedJS, v => v === 'CLOSED', 18, 8000); if (closed !== 'CLOSED') throw new Error('dialogue non fermé 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) { const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight)); if (r.error) throw new Error('render_card.py: ' + r.error); const p = r.stdout.trim().split('\n').pop(); if (!p || !fs.existsSync(p)) throw new Error('image non produite'); return p; } // 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.`; 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.`; return anthropic(CAPTION_SYSTEM, user, model, 500); } // 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 }); } 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({}); 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(); 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(); 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 }); }