1// admin-ka — module « Social » : publication automatique horaire d'un insight2// statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso3// simonpboucher), + génération d'un post sur demande depuis un prompt.4//5// Pipeline : insights.py (choix du fait marquant) -> render_card.py (1 image)6// -> légende (Claude / API Anthropic) -> publication Safari (injection JS,7// zéro clavier, zéro presse-papiers système) sur la Page.8//9// Tout tourne sur le nœud M3U96a où Safari est connecté au compte Facebook.1011import fs from 'node:fs';12import path from 'node:path';13import os from 'node:os';14import crypto from 'node:crypto';15import { execFile, spawn } from 'node:child_process';16import { fileURLToPath } from 'node:url';1718const __dirname = path.dirname(fileURLToPath(import.meta.url));19const APP_DIR = path.join(__dirname, '..');20const DATA_DIR = path.join(APP_DIR, 'data');21const SOCIAL_DIR = path.join(DATA_DIR, 'social');22const CARDS_DIR = path.join(SOCIAL_DIR, 'cards');23const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');24const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');25fs.mkdirSync(CARDS_DIR, { recursive: true });2627const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso28const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;29const PY = '/opt/homebrew/bin/python3';30const INSIGHTS = path.join(__dirname, 'social', 'insights.py');31const RENDER = path.join(__dirname, 'social', 'render_card.py');3233let CFG = {}; // injecté par init()34let LOGGER = () => {};35let timer = null;3637// ---------- état persistant ----------38function loadState() {39 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}40 return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0 };41}42let state = loadState();43function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4445function logEvent(ev) {46 const line = { ts: Date.now(), ...ev };47 try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}48 LOGGER(line);49 return line;50}51export function socialLog(limit = 50) {52 try {53 const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);54 return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();55 } catch { return []; }56}5758// ---------- utilitaires enfant ----------59function run(bin, args, opts = {}) {60 return new Promise((resolve) => {61 execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {62 resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });63 });64 });65}66// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)67let safariChain = Promise.resolve();68function withSafari(fn) {69 const next = safariChain.then(fn, fn);70 safariChain = next.catch(() => {});71 return next;72}73const sleep = (ms) => new Promise(r => setTimeout(r, ms));74function runInput(bin, args, input) {75 return new Promise((resolve) => {76 const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });77 let out = '', err = '';78 ch.stdout.on('data', d => out += d);79 ch.stderr.on('data', d => err += d);80 ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));81 ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));82 ch.stdin.write(input); ch.stdin.end();83 });84}8586// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------87let fbWinId = null;8889async function osaJS(jsCode) {90 // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu91 const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);92 fs.writeFileSync(tmp, jsCode);93 if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }94 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`;95 const r = await run('/usr/bin/osascript', ['-e', apple]);96 try { fs.unlinkSync(tmp); } catch {}97 if (r.error) throw new Error('osascript: ' + r.error.trim());98 return r.stdout.trim();99}100101async function resolveFbWindow() {102 // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com103 const findScript = `104tell application "Safari"105 with timeout of 15 seconds106 set out to ""107 repeat with w in windows108 try109 set u to URL of tab 1 of w110 if u contains "facebook.com" then return (id of w as string)111 end try112 end repeat113 return "NONE"114 end timeout115end tell`;116 let r = await run('/usr/bin/osascript', ['-e', findScript]);117 let id = (r.stdout || '').trim();118 const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';119 if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }120 // aucune : ouvrir la page dans une nouvelle fenêtre121 const openScript = `122tell application "Safari"123 with timeout of 25 seconds124 make new document with properties {URL:"${PAGE_URL}"}125 delay 2126 return (id of window 1 as string)127 end timeout128end tell`;129 r = await run('/usr/bin/osascript', ['-e', openScript]);130 const nid = (r.stdout || '').trim();131 fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;132 if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');133 return fbWinId;134}135136async function gotoPage() {137 await resolveFbWindow();138 await run('/usr/bin/osascript', ['-e',139 `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);140}141142async function pollJS(jsCode, want, tries = 12, delay = 4000) {143 let last = '';144 for (let i = 0; i < tries; i++) {145 try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }146 if (want(last)) return last;147 await sleep(delay);148 }149 return last;150}151152// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)153export function fbLoginState() { return withSafari(_fbLoginState); }154async function _fbLoginState() {155 await resolveFbWindow();156 await run('/usr/bin/osascript', ['-e',157 `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`]);158 await sleep(6000);159 const js = `(function(){var b=document.body.innerText||"";` +160 `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +161 `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +162 `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;163 const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);164 return out;165}166167// ---------- publication (texte + 1 image) sur la Page ----------168export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); }169async function _publishToPage(caption, imagePath) {170 await gotoPage();171 await sleep(9000);172173 // 1) ouvrir le composeur174 const openComposer = `(function(){175 var target=null;176 document.querySelectorAll('span,div').forEach(function(e){177 var t=(e.innerText||'').trim();178 if(!target && e.children.length===0 &&179 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||180 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;181 });182 if(!target) return 'NOT_FOUND';183 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';184 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});185 return 'opened';186 })();`;187 const r1 = await pollJS(openComposer, v => v === 'opened', 10, 5000);188 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');189 await sleep(3000);190191 // 2) coller la légende via ClipboardEvent synthétique (base64, sans clavier)192 const b64cap = Buffer.from(caption, 'utf8').toString('base64');193 const pasteJS = `(function(){194 var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';195 var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';196 box.focus();197 var txt=decodeURIComponent(escape(atob('${b64cap}')));198 var dt=new DataTransfer(); dt.setData('text/plain', txt);199 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));200 return 'pasted';201 })();`;202 const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +203 `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +204 `return 'len:'+(box?box.innerText.length:-1);})();`;205 let len = 0;206 for (let i = 0; i < 3; i++) {207 await osaJS(pasteJS); await sleep(2500);208 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);209 if (len > 40) break; await sleep(1500);210 }211 if (!(len > 40)) throw new Error('collage légende échoué');212213 // 3) injecter l'image dans l'input file du composeur (File construit en JS)214 const imgB64 = fs.readFileSync(imagePath).toString('base64');215 const injectJS = `(function(){216 var inp=document.querySelector('div[role=dialog] input[type=file]') ||217 document.querySelector('input[type=file][accept*="image"]') ||218 document.querySelector('input[type=file]');219 if(!inp) return 'NO_INPUT';220 try{221 var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len);222 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);223 var file=new File([arr], 'ka-stat.png', {type:'image/png'});224 var dt=new DataTransfer(); dt.items.add(file);225 inp.files=dt.files;226 inp.dispatchEvent(new Event('change',{bubbles:true}));227 return 'injected';228 }catch(e){ return 'ERR:'+e.message; }229 })();`;230 const inj = await osaJS(injectJS);231 if (inj !== 'injected') {232 // repli : bouton Photo/Vidéo puis re-tenter l'injection233 const clickPhoto = `(function(){var btn=null;234 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){235 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});236 if(!btn) return 'NO_BTN';237 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});238 return 'clicked';})();`;239 await osaJS(clickPhoto); await sleep(2500);240 const inj2 = await osaJS(injectJS);241 if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')');242 }243244 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)245 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';246 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});247 return ''+n;})();`;248 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000);249 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent');250 await sleep(2000);251252 // 5) Suivant (si présent) puis Publier253 const clickBtn = (label) => `(function(){254 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;255 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){256 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});257 if(!btn) return 'NO_BTN';258 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';259 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});260 return 'clicked';})();`;261 const nx = await osaJS(clickBtn('Suivant'));262 if (nx === 'clicked') await sleep(7000);263 const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000);264 if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')');265266 // 6) attendre la fermeture du dialogue (upload lent)267 const closedJS = `document.querySelector('div[role=dialog]') ? 'OPEN' : 'CLOSED';`;268 const closed = await pollJS(closedJS, v => v === 'CLOSED', 18, 8000);269 if (closed !== 'CLOSED') throw new Error('dialogue non fermé après Publier (doute — ne pas retenter)');270 return true;271}272273// ---------- génération d'un brouillon (insight -> image + légende) ----------274export async function pickInsight({ site = '', excludeRecent = true } = {}) {275 const args = [INSIGHTS];276 if (site) args.push('--site', site);277 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));278 const r = await run(PY, args);279 if (r.error) throw new Error('insights.py: ' + r.error);280 const data = JSON.parse(r.stdout);281 const cand = (data.candidates || [])[0];282 if (!cand) throw new Error('aucun insight disponible');283 return cand;284}285286export async function renderCard(insight) {287 const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));288 if (r.error) throw new Error('render_card.py: ' + r.error);289 const p = r.stdout.trim().split('\n').pop();290 if (!p || !fs.existsSync(p)) throw new Error('image non produite');291 return p;292}293294// légende via API Anthropic (le modèle Claude rédige à partir du fait)295async function anthropic(system, user, model, maxTokens = 500) {296 const key = CFG.anthropicKey;297 if (!key) throw new Error('Clé Anthropic non configurée');298 const resp = await fetch('https://api.anthropic.com/v1/messages', {299 method: 'POST',300 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },301 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),302 });303 const data = await resp.json();304 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');305 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();306}307308const 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.309310Règles STRICTES :311- 1 seule publication, 40 à 90 mots.312- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).313- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).314- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.`;315316export async function writeCaption(insight, extraPrompt = '') {317 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';318 const url = `https://www.${insight.site}.com`;319 const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +320 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +321 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +322 `\nRédige la publication Facebook.`;323 return anthropic(CAPTION_SYSTEM, user, model, 500);324}325326// brouillon complet : insight -> image + légende (sans publier)327export async function generateDraft({ site = '', prompt = '' } = {}) {328 let insight;329 if (prompt && !site) {330 // laisser le modèle choisir le site le plus pertinent pour ce prompt331 const r = await run(PY, [INSIGHTS]);332 const data = JSON.parse(r.stdout || '{}');333 const cands = data.candidates || [];334 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');335 const chosen = await anthropic(336 "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.",337 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,338 CFG.socialModel || 'claude-opus-4-8', 30);339 const pickSite = (chosen || '').trim().split(/\s/)[0];340 insight = cands.find(c => c.site === pickSite) || cands[0];341 } else {342 insight = await pickInsight({ site });343 }344 const image = await renderCard(insight);345 const caption = await writeCaption(insight, prompt);346 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };347}348349// cycle complet automatique : choisir -> rendre -> rédiger -> publier350export async function runAutoCycle(trigger = 'auto') {351 const started = logEvent({ kind: 'cycle_start', trigger });352 try {353 const login = await fbLoginState();354 if (login !== 'LOGGED_IN') {355 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });356 return { ok: false, reason: 'facebook_non_connecté' };357 }358 const insight = await pickInsight({});359 const image = await renderCard(insight);360 const caption = await writeCaption(insight);361 await publishToPage(caption, image);362 state.lastSites = [...state.lastSites, insight.site].slice(-6);363 state.lastPostAt = Date.now();364 saveState();365 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,366 headline: insight.headline, caption, image: path.basename(image) });367 return { ok: true, insight, caption, image };368 } catch (e) {369 logEvent({ kind: 'cycle_error', trigger, error: e.message });370 return { ok: false, error: e.message };371 }372}373374// publier un brouillon fourni (post sur demande)375export async function publishDraft({ caption, image }) {376 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');377 const login = await fbLoginState();378 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');379 await publishToPage(caption, image);380 state.lastPostAt = Date.now(); saveState();381 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });382 return true;383}384385// ---------- planificateur horaire ----------386function scheduleNext() {387 clearTimeout(timer);388 if (!state.auto) { state.nextAt = 0; saveState(); return; }389 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;390 const since = Date.now() - (state.lastPostAt || 0);391 const wait = Math.max(60 * 1000, period - since);392 state.nextAt = Date.now() + wait; saveState();393 timer = setTimeout(async () => {394 await runAutoCycle('auto');395 scheduleNext();396 }, wait);397}398399export function setAuto(on) {400 state.auto = !!on; saveState();401 scheduleNext();402 logEvent({ kind: on ? 'auto_on' : 'auto_off' });403 return socialState();404}405406export function socialState() {407 return {408 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,409 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,410 };411}412413export function cardPath(name) {414 const safe = path.basename(name);415 const p = path.join(CARDS_DIR, safe);416 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;417}418419export function initSocial(cfg, logger) {420 CFG = cfg || {};421 LOGGER = logger || (() => {});422 if (state.auto) scheduleNext();423 logEvent({ kind: 'init', auto: state.auto });424}425