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}141142// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)143const DISMISS_JS = `(function(){144 var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];145 var clicked='';146 document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){147 if(clicked) return;148 var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';149 for(var i=0;i<labels.length;i++){150 if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){151 ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});152 clicked=labels[i]||al; break;153 }154 }155 });156 return clicked||'NONE';157})();`;158159async function pollJS(jsCode, want, tries = 12, delay = 4000) {160 let last = '';161 for (let i = 0; i < tries; i++) {162 try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }163 if (want(last)) return last;164 await sleep(delay);165 }166 return last;167}168169async function dismissModals(times) {170 times = times || 3;171 let any = false;172 for (let i = 0; i < times; i++) {173 let r = 'NONE';174 try { r = await osaJS(DISMISS_JS); } catch {}175 if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;176 }177 return any;178}179180// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)181export function fbLoginState() { return withSafari(_fbLoginState); }182async function _fbLoginState() {183 await resolveFbWindow();184 await run('/usr/bin/osascript', ['-e',185 `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`]);186 await sleep(6000);187 const js = `(function(){var b=document.body.innerText||"";` +188 `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +189 `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +190 `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;191 const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);192 return out;193}194195// ---------- publication (texte + 1 image) sur la Page ----------196export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); }197async function _publishToPage(caption, imagePath) {198 await gotoPage();199 await sleep(9000);200201 // 1) ouvrir le composeur202 const openComposer = `(function(){203 var target=null;204 document.querySelectorAll('span,div').forEach(function(e){205 var t=(e.innerText||'').trim();206 if(!target && e.children.length===0 &&207 (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||208 t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;209 });210 if(!target) return 'NOT_FOUND';211 var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';212 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});213 return 'opened';214 })();`;215 // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans216 // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.217 const basculer = `(function(){218 var btn=null;219 document.querySelectorAll('[role=button]').forEach(function(e){220 var t=(e.innerText||'').trim();221 if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;222 });223 if(!btn) return 'NO_BTN';224 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});225 return 'switched';226 })();`;227 let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);228 if (r1 !== 'opened') {229 const sw = await osaJS(basculer);230 if (sw === 'switched') { await sleep(9000); await dismissModals(3); }231 r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);232 }233 if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');234 await sleep(3000);235236 // 2) coller la légende via ClipboardEvent synthétique (base64, sans clavier)237 const b64cap = Buffer.from(caption, 'utf8').toString('base64');238 const pasteJS = `(function(){239 var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';240 var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';241 box.focus();242 var txt=decodeURIComponent(escape(atob('${b64cap}')));243 var dt=new DataTransfer(); dt.setData('text/plain', txt);244 box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));245 return 'pasted';246 })();`;247 const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +248 `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +249 `return 'len:'+(box?box.innerText.length:-1);})();`;250 let len = 0;251 for (let i = 0; i < 3; i++) {252 await osaJS(pasteJS); await sleep(2500);253 const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);254 if (len > 40) break; await sleep(1500);255 }256 if (!(len > 40)) throw new Error('collage légende échoué');257258 // 3) injecter l'image dans l'input file du composeur (File construit en JS)259 const imgB64 = fs.readFileSync(imagePath).toString('base64');260 const injectJS = `(function(){261 var inp=document.querySelector('div[role=dialog] input[type=file]') ||262 document.querySelector('input[type=file][accept*="image"]') ||263 document.querySelector('input[type=file]');264 if(!inp) return 'NO_INPUT';265 try{266 var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len);267 for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);268 var file=new File([arr], 'ka-stat.png', {type:'image/png'});269 var dt=new DataTransfer(); dt.items.add(file);270 inp.files=dt.files;271 inp.dispatchEvent(new Event('change',{bubbles:true}));272 return 'injected';273 }catch(e){ return 'ERR:'+e.message; }274 })();`;275 const inj = await osaJS(injectJS);276 if (inj !== 'injected') {277 // repli : bouton Photo/Vidéo puis re-tenter l'injection278 const clickPhoto = `(function(){var btn=null;279 document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){280 if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});281 if(!btn) return 'NO_BTN';282 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});283 return 'clicked';})();`;284 await osaJS(clickPhoto); await sleep(2500);285 const inj2 = await osaJS(injectJS);286 if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')');287 }288289 // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)290 const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';291 var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});292 return ''+n;})();`;293 const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000);294 if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent');295 await sleep(2000);296297 // 5) Suivant (si présent) puis Publier298 const clickBtn = (label) => `(function(){299 var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;300 dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){301 if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});302 if(!btn) return 'NO_BTN';303 if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';304 ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});305 return 'clicked';})();`;306 const nx = await osaJS(clickBtn('Suivant'));307 if (nx === 'clicked') await sleep(7000);308 const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000);309 if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')');310311 // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte312 // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique313 // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.314 const composerGoneJS = `(function(){315 var open=false;316 document.querySelectorAll('div[role=dialog]').forEach(function(d){317 if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;318 });319 return open ? 'OPEN' : 'GONE';320 })();`;321 let gone = 'OPEN';322 for (let i = 0; i < 18; i++) {323 await sleep(8000);324 await dismissModals(2); // ferme toute boîte non bloquante en route325 try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }326 if (gone === 'GONE') break;327 }328 await dismissModals(3); // ferme la boîte « Plus tard » finale329 if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');330 return true;331}332333// ---------- génération d'un brouillon (insight -> image + légende) ----------334export async function pickInsight({ site = '', excludeRecent = true } = {}) {335 const args = [INSIGHTS];336 if (site) args.push('--site', site);337 else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));338 const r = await run(PY, args);339 if (r.error) throw new Error('insights.py: ' + r.error);340 const data = JSON.parse(r.stdout);341 const cand = (data.candidates || [])[0];342 if (!cand) throw new Error('aucun insight disponible');343 return cand;344}345346export async function renderCard(insight) {347 const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));348 if (r.error) throw new Error('render_card.py: ' + r.error);349 const p = r.stdout.trim().split('\n').pop();350 if (!p || !fs.existsSync(p)) throw new Error('image non produite');351 return p;352}353354// légende via API Anthropic (le modèle Claude rédige à partir du fait)355async function anthropic(system, user, model, maxTokens = 500) {356 const key = CFG.anthropicKey;357 if (!key) throw new Error('Clé Anthropic non configurée');358 const resp = await fetch('https://api.anthropic.com/v1/messages', {359 method: 'POST',360 headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },361 body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),362 });363 const data = await resp.json();364 if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');365 return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();366}367368const 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.369370Règles STRICTES :371- 1 seule publication, 40 à 90 mots.372- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).373- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).374- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.`;375376export async function writeCaption(insight, extraPrompt = '') {377 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';378 const url = `https://www.${insight.site}.com`;379 const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +380 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +381 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +382 `\nRédige la publication Facebook.`;383 return anthropic(CAPTION_SYSTEM, user, model, 500);384}385386// brouillon complet : insight -> image + légende (sans publier)387export async function generateDraft({ site = '', prompt = '' } = {}) {388 let insight;389 if (prompt && !site) {390 // laisser le modèle choisir le site le plus pertinent pour ce prompt391 const r = await run(PY, [INSIGHTS]);392 const data = JSON.parse(r.stdout || '{}');393 const cands = data.candidates || [];394 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');395 const chosen = await anthropic(396 "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.",397 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,398 CFG.socialModel || 'claude-opus-4-8', 30);399 const pickSite = (chosen || '').trim().split(/\s/)[0];400 insight = cands.find(c => c.site === pickSite) || cands[0];401 } else {402 insight = await pickInsight({ site });403 }404 const image = await renderCard(insight);405 const caption = await writeCaption(insight, prompt);406 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };407}408409// cycle complet automatique : choisir -> rendre -> rédiger -> publier410export async function runAutoCycle(trigger = 'auto') {411 const started = logEvent({ kind: 'cycle_start', trigger });412 try {413 const login = await fbLoginState();414 if (login !== 'LOGGED_IN') {415 logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });416 return { ok: false, reason: 'facebook_non_connecté' };417 }418 const insight = await pickInsight({});419 const image = await renderCard(insight);420 const caption = await writeCaption(insight);421 await publishToPage(caption, image);422 state.lastSites = [...state.lastSites, insight.site].slice(-6);423 state.lastPostAt = Date.now();424 saveState();425 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,426 headline: insight.headline, caption, image: path.basename(image) });427 return { ok: true, insight, caption, image };428 } catch (e) {429 logEvent({ kind: 'cycle_error', trigger, error: e.message });430 return { ok: false, error: e.message };431 }432}433434// publier un brouillon fourni (post sur demande)435export async function publishDraft({ caption, image }) {436 if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');437 const login = await fbLoginState();438 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');439 await publishToPage(caption, image);440 state.lastPostAt = Date.now(); saveState();441 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });442 return true;443}444445// ---------- planificateur horaire ----------446function scheduleNext() {447 clearTimeout(timer);448 if (!state.auto) { state.nextAt = 0; saveState(); return; }449 const period = (CFG.socialIntervalMin || 60) * 60 * 1000;450 const since = Date.now() - (state.lastPostAt || 0);451 const wait = Math.max(60 * 1000, period - since);452 state.nextAt = Date.now() + wait; saveState();453 timer = setTimeout(async () => {454 await runAutoCycle('auto');455 scheduleNext();456 }, wait);457}458459export function setAuto(on) {460 state.auto = !!on; saveState();461 scheduleNext();462 logEvent({ kind: on ? 'auto_on' : 'auto_off' });463 return socialState();464}465466export function socialState() {467 return {468 auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,469 lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,470 };471}472473export function cardPath(name) {474 const safe = path.basename(name);475 const p = path.join(CARDS_DIR, safe);476 return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;477}478479export function initSocial(cfg, logger) {480 CFG = cfg || {};481 LOGGER = logger || (() => {});482 if (state.auto) scheduleNext();483 logEvent({ kind: 'init', auto: state.auto });484}485