SPB Git

spb/admin-ka

Public
19.1 KB · 425 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}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
Files · 0604e62d74c57fa158730b62f3af5b6553881ec9
Menu

Theme