SPB Git

spb/admin-ka

Public
21.7 KB · 485 lines javascript
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
Files · 23a7546cc58ef90166773e2568e64941d4c0df46
Menu

Theme