SPB Git

spb/admin-ka

Public
38.7 KB · 803 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 REELS_DIR = path.join(SOCIAL_DIR, 'reels');24const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');25const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');26fs.mkdirSync(CARDS_DIR, { recursive: true });27fs.mkdirSync(REELS_DIR, { recursive: true });2829const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso30const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;31const PY = '/opt/homebrew/bin/python3';32const INSIGHTS = path.join(__dirname, 'social', 'insights.py');33const RENDER = path.join(__dirname, 'social', 'render_card.py');34const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');35const MAKE_REEL = path.join(__dirname, 'social', 'make_reel.py');3637let CFG = {};      // injecté par init()38let LOGGER = () => {};39let timer = null;4041// ---------- état persistant ----------42function loadState() {43  try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}44  return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };45}46let state = loadState();47function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4849function logEvent(ev) {50  const line = { ts: Date.now(), ...ev };51  try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}52  LOGGER(line);53  return line;54}55export function socialLog(limit = 50) {56  try {57    const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);58    return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();59  } catch { return []; }60}6162// ---------- utilitaires enfant ----------63function run(bin, args, opts = {}) {64  return new Promise((resolve) => {65    execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {66      resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });67    });68  });69}70// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)71let safariChain = Promise.resolve();72function withSafari(fn) {73  const next = safariChain.then(fn, fn);74  safariChain = next.catch(() => {});75  return next;76}77const sleep = (ms) => new Promise(r => setTimeout(r, ms));78function runInput(bin, args, input) {79  return new Promise((resolve) => {80    const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });81    let out = '', err = '';82    ch.stdout.on('data', d => out += d);83    ch.stderr.on('data', d => err += d);84    ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));85    ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));86    ch.stdin.write(input); ch.stdin.end();87  });88}8990// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------91let fbWinId = null;9293async function osaJS(jsCode) {94  // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu95  const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);96  fs.writeFileSync(tmp, jsCode);97  if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }98  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`;99  const r = await run('/usr/bin/osascript', ['-e', apple]);100  try { fs.unlinkSync(tmp); } catch {}101  if (r.error) throw new Error('osascript: ' + r.error.trim());102  return r.stdout.trim();103}104105async function resolveFbWindow() {106  // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com107  const findScript = `108tell application "Safari"109  with timeout of 15 seconds110    set out to ""111    repeat with w in windows112      try113        set u to URL of tab 1 of w114        if u contains "facebook.com" then return (id of w as string)115      end try116    end repeat117    return "NONE"118  end timeout119end tell`;120  let r = await run('/usr/bin/osascript', ['-e', findScript]);121  let id = (r.stdout || '').trim();122  const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';123  if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }124  // aucune : ouvrir la page dans une nouvelle fenêtre125  const openScript = `126tell application "Safari"127  with timeout of 25 seconds128    make new document with properties {URL:"${PAGE_URL}"}129    delay 2130    return (id of window 1 as string)131  end timeout132end tell`;133  r = await run('/usr/bin/osascript', ['-e', openScript]);134  const nid = (r.stdout || '').trim();135  fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;136  if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');137  return fbWinId;138}139140async function gotoPage() {141  await resolveFbWindow();142  try { await run('/usr/bin/osascript', ['-e', `tell application "Safari" to set bounds of window id ${fbWinId} to {0, 0, 1512, 964}`]); } catch {}143  try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}144  await run('/usr/bin/osascript', ['-e',145    `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);146}147148// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)149const DISMISS_JS = `(function(){150  var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];151  var clicked='';152  document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){153    if(clicked) return;154    var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';155    for(var i=0;i<labels.length;i++){156      if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){157        ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});158        clicked=labels[i]||al; break;159      }160    }161  });162  return clicked||'NONE';163})();`;164165async function pollJS(jsCode, want, tries = 12, delay = 4000) {166  let last = '';167  for (let i = 0; i < tries; i++) {168    try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }169    if (want(last)) return last;170    await sleep(delay);171  }172  return last;173}174175async function dismissModals(times) {176  times = times || 3;177  let any = false;178  for (let i = 0; i < times; i++) {179    let r = 'NONE';180    try { r = await osaJS(DISMISS_JS); } catch {}181    if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;182  }183  return any;184}185186// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)187export function fbLoginState() { return withSafari(_fbLoginState); }188async function _fbLoginState() {189  await resolveFbWindow();190  try { await osaJS("window.onbeforeunload=null; 'ok'"); } catch {}191  await run('/usr/bin/osascript', ['-e',192    `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`]);193  await sleep(6000);194  const js = `(function(){var b=document.body.innerText||"";` +195    `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +196    `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +197    `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;198  const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);199  return out;200}201202// ---------- publication (texte + 1 image) sur la Page ----------203export function publishToPage(caption, imagePath) { return withSafari(() => _publishMedia(caption, imagePath, false)); }204export function publishReelToPage(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, false)); }205export function publishReelAssisted(caption, videoPath) { return withSafari(() => _publishMedia(caption, videoPath, true, true)); }206async function _publishMedia(caption, mediaPath, isVideo, assisted) {207  await gotoPage();208  await sleep(9000);209210  // 1) ouvrir le composeur211  const openComposer = `(function(){212    var target=null;213    document.querySelectorAll('span,div').forEach(function(e){214      var t=(e.innerText||'').trim();215      if(!target && e.children.length===0 &&216        (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||217         t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;218    });219    if(!target) return 'NOT_FOUND';220    var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';221    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});222    return 'opened';223  })();`;224  // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans225  // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.226  const basculer = `(function(){227    var btn=null;228    document.querySelectorAll('[role=button]').forEach(function(e){229      var t=(e.innerText||'').trim();230      if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;231    });232    if(!btn) return 'NO_BTN';233    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});234    return 'switched';235  })();`;236  let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);237  if (r1 !== 'opened') {238    const sw = await osaJS(basculer);239    if (sw === 'switched') { await sleep(9000); await dismissModals(3); }240    r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);241  }242  if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');243  await sleep(3000);244245  // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés246  //    et le double collage) — atomique : ne colle que si le box est encore vide.247  const b64cap = Buffer.from(caption, 'utf8').toString('base64');248  // trouve le contenteditable visible le plus « profond » (composeur actif)249  const findBoxFn = `function(){250    var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]'));251    if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]'));252    var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;});253    return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null);254  }`;255  const pasteJS = `(function(){256    var box=(${findBoxFn})(); if(!box) return 'NO_BOX';257    if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller258    box.focus();259    var txt=decodeURIComponent(escape(atob('${b64cap}')));260    var dt=new DataTransfer(); dt.setData('text/plain', txt);261    box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));262    return 'pasted';263  })();`;264  const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`;265  let len = 0;266  for (let i = 0; i < 3; i++) {267    const r = await osaJS(pasteJS);268    await sleep(2500);269    const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);270    if (len > 40) break;271    await sleep(1200);272  }273  if (!(len > 40)) throw new Error('collage légende échoué');274275  // 3) injecter le média (image OU vidéo) dans l'input file du composeur276  const mediaB64 = fs.readFileSync(mediaPath).toString('base64');277  const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';278  const mtype = isVideo ? 'video/mp4' : 'image/png';279  const injectJS = `(function(){280    var inp=document.querySelector('div[role=dialog] input[type=file]') ||281            document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||282            document.querySelector('input[type=file]');283    if(!inp) return 'NO_INPUT';284    try{285      var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);286      for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);287      var file=new File([arr], '${fname}', {type:'${mtype}'});288      var dt=new DataTransfer(); dt.items.add(file);289      inp.files=dt.files;290      inp.dispatchEvent(new Event('change',{bubbles:true}));291      return 'injected';292    }catch(e){ return 'ERR:'+e.message; }293  })();`;294  const inj = await osaJS(injectJS);295  if (inj !== 'injected') {296    // repli : bouton Photo/Vidéo puis re-tenter l'injection297    const clickPhoto = `(function(){var btn=null;298      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){299        if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});300      if(!btn) return 'NO_BTN';301      ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});302      return 'clicked';})();`;303    await osaJS(clickPhoto); await sleep(2500);304    const inj2 = await osaJS(injectJS);305    if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');306  }307308  // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)309  const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';310    var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});311    dlg.querySelectorAll('video').forEach(function(){n++;});312    return ''+n;})();`;313  const prevTries = isVideo ? 30 : 12;   // upload/traitement vidéo plus long314  const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);315  if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');316  await sleep(isVideo ? 4000 : 2000);317318  // 5) Suivant (si présent) puis Publier319  const clickBtn = (label) => `(function(){320    var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;321    dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){322      if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});323    if(!btn) return 'NO_BTN';324    if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';325    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});326    return 'clicked';})();`;327  // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS328  // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)329  const focusBtn = (labels) => `(function(){330    var want=${JSON.stringify(labels)}; var btn=null;331    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){332      if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();333      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;334    });335    if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();336    return (document.activeElement===btn)?'focused':'focus_fail';337  })();`;338  async function clickBtnOS(labels) {339    const coordJS = `(function(){340      var want=${JSON.stringify(labels)}; var btn=null;341      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){342        if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();343        if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;344      });345      if(!btn) return 'NO_BTN';346      try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}347      var r=btn.getBoundingClientRect();348      var gx=Math.round(window.screenX + r.left + r.width/2);349      var gy=Math.round(window.screenY + r.top + r.height/2);350      return gx+' '+gy;351    })();`;352    const co = await osaJS(coordJS);353    if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';354    const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));355    if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;356    const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',357      '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);358    return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';359  }360  async function advanceByKey(labels) {361    const f = await osaJS(focusBtn(labels));362    if (f !== 'focused') return f;363    await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);364    return 'pressed';365  }366  const clickAny = (labels) => `(function(){367    var want=${JSON.stringify(labels)}; var btn=null;368    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){369      if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();370      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;371    });372    if(!btn) return 'NO_BTN';373    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});374    return 'clicked';375  })();`;376  const canPublishJS = `(function(){var ok=false;document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){var al=(b.getAttribute('aria-label')||b.innerText||'').trim();if((al==='Publier'||al==='Partager')&&b.getAttribute('aria-disabled')!=='true')ok=true;});return ok?'YES':'NO';})();`;377  if (isVideo && assisted) {378    // mode assisté : l'utilisateur clique « Suivant »/« Publier » sur l'écran du nœud379    logEvent({ kind: 'reel_waiting_user' });380  } else if (isVideo) {381    // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle382    for (let i = 0; i < 6; i++) {383      if ((await osaJS(canPublishJS)) === 'YES') break;384      const r = await clickBtnOS(['Suivant']);385      if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }386    }387    // Publier / Partager par focus + Entrée (poll pendant le téléversement)388    let done = false;389    for (let i = 0; i < 14; i++) {390      if ((await osaJS(canPublishJS)) === 'YES') {391        const r = await clickBtnOS(['Publier', 'Partager']);392        if (r === 'clicked') { done = true; break; }393      }394      await sleep(5000); await dismissModals(1);395    }396    if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');397  } else {398    const nx = await osaJS(clickAny(['Suivant']));399    if (nx === 'clicked') await sleep(7000);400    const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);401    if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');402  }403404  // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte405  // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique406  // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.407  const composerGoneJS = `(function(){408    var open=false;409    document.querySelectorAll('div[role=dialog]').forEach(function(d){410      if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;411    });412    return open ? 'OPEN' : 'GONE';413  })();`;414  let gone = 'OPEN';415  const goneTries = assisted ? 45 : (isVideo ? 40 : 18);416  for (let i = 0; i < goneTries; i++) {417    await sleep(8000);418    await dismissModals(2);                 // ferme toute boîte non bloquante en route419    try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }420    if (gone === 'GONE') break;421  }422  await dismissModals(3);                    // ferme la boîte « Plus tard » finale423  if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');424  return true;425}426427// ---------- génération d'un brouillon (insight -> image + légende) ----------428export async function pickInsight({ site = '', excludeRecent = true } = {}) {429  const args = [INSIGHTS];430  if (site) args.push('--site', site);431  else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));432  const r = await run(PY, args);433  if (r.error) throw new Error('insights.py: ' + r.error);434  const data = JSON.parse(r.stdout);435  const cand = (data.candidates || [])[0];436  if (!cand) throw new Error('aucun insight disponible');437  return cand;438}439440export async function renderCard(insight) {441  // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec442  let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));443  let p = (r.stdout || '').trim().split('\n').pop();444  if (r.error || !p || !fs.existsSync(p)) {445    logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });446    r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));447    p = (r.stdout || '').trim().split('\n').pop();448    if (r.error) throw new Error('render: ' + r.error);449  }450  if (!p || !fs.existsSync(p)) throw new Error('image non produite');451  return p;452}453454// --- produit vedette (photo réelle) pour le genre « spotlight » ---455const SPOT = {456  'lou-ka':   { url: 'https://www.lou-ka.com/api/listings?limit=24',  arr: 'listings', minPrice: 800 },457  'immo-ka':  { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },458  'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },459  'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24',  arr: 'events', minPrice: 0 },460};461async function fetchFeatured(site) {462  const cfg = SPOT[site]; if (!cfg) return null;463  try {464    const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });465    const data = await res.json();466    const arr = data[cfg.arr] || [];467    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');468    for (const it of arr) {469      const img = (it.images && it.images[0]) || it.image || '';470      const title = (it.title || it.name || '').trim();471      if (!img || !title || title.length > 80) continue;472      const price = it.price || it.price_min || null;473      if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;474      if (site === 'lou-ka') {475        return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),476          price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,477          meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };478      }479      if (site === 'immo-ka') {480        const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;481        return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),482          price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,483          meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };484      }485      if (site === 'fabri-ka') {486        return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),487          price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,488          meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };489      }490      if (site === 'sorti-ka') {491        return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),492          price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,493          meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };494      }495    }496  } catch {}497  return null;498}499500async function decorateForPrompt(insight) {501  if (SPOT[insight.site]) {502    const feat = await fetchFeatured(insight.site);503    if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }504  }505  insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';506  return insight;507}508509// choisit le genre visuel (rotation) et attache le produit vedette si spotlight510async function decorateCreative(insight) {511  const n = state.postCount || 0;512  let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';513  if (SPOT[insight.site] && (n % 3 === 0)) {514    const feat = await fetchFeatured(insight.site);515    if (feat) { insight.product = feat; genre = 'spotlight'; }516  }517  insight.genre = genre;518  return insight;519}520521// légende via API Anthropic (le modèle Claude rédige à partir du fait)522async function anthropic(system, user, model, maxTokens = 500) {523  const key = CFG.anthropicKey;524  if (!key) throw new Error('Clé Anthropic non configurée');525  const resp = await fetch('https://api.anthropic.com/v1/messages', {526    method: 'POST',527    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },528    body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),529  });530  const data = await resp.json();531  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');532  return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();533}534535const 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.536537Règles STRICTES :538- 1 seule publication, 40 à 90 mots.539- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).540- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).541- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.\n- N'ajoute PAS de signature toi-même (elle est ajoutée automatiquement).`;542543export async function writeCaption(insight, extraPrompt = '') {544  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';545  const url = `https://www.${insight.site}.com`;546  let user;547  if (insight.product && insight.genre === 'spotlight') {548    const pr = insight.product;549    user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +550      `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +551      (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +552      `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +553      (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +554      `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;555  } else {556    user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +557      `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +558      (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +559      `\nRédige la publication Facebook.`;560  }561  const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);562  return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";563}564565// brouillon complet : insight -> image + légende (sans publier)566export async function generateDraft({ site = '', prompt = '' } = {}) {567  let insight;568  if (prompt && !site) {569    // laisser le modèle choisir le site le plus pertinent pour ce prompt570    const r = await run(PY, [INSIGHTS]);571    const data = JSON.parse(r.stdout || '{}');572    const cands = data.candidates || [];573    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');574    const chosen = await anthropic(575      "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.",576      `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,577      CFG.socialModel || 'claude-opus-4-8', 30);578    const pickSite = (chosen || '').trim().split(/\s/)[0];579    insight = cands.find(c => c.site === pickSite) || cands[0];580    await decorateForPrompt(insight);581  } else {582    insight = await pickInsight({ site });583    await decorateCreative(insight);584  }585  const image = await renderCard(insight);586  const caption = await writeCaption(insight, prompt);587  const imageUrl='/api/social/card/'+path.basename(image);588  addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() });589  return { insight, image, caption, imageUrl };590}591592// cycle complet automatique : choisir -> rendre -> rédiger -> publier593export async function runAutoCycle(trigger = 'auto') {594  const started = logEvent({ kind: 'cycle_start', trigger });595  try {596    const login = await fbLoginState();597    if (login !== 'LOGGED_IN') {598      logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });599      return { ok: false, reason: 'facebook_non_connecté' };600    }601    const insight = await pickInsight({});602    await decorateCreative(insight);603    const image = await renderCard(insight);604    const caption = await writeCaption(insight);605    await publishToPage(caption, image);606    state.lastSites = [...state.lastSites, insight.site].slice(-6);607    state.lastPostAt = Date.now();608    state.postCount = (state.postCount || 0) + 1;609    saveState();610    logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,611      headline: insight.headline, caption, image: path.basename(image) });612    return { ok: true, insight, caption, image };613  } catch (e) {614    logEvent({ kind: 'cycle_error', trigger, error: e.message });615    return { ok: false, error: e.message };616  }617}618619// publier un brouillon fourni (post sur demande)620export async function publishDraft({ caption, image }) {621  if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');622  const login = await fbLoginState();623  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');624  await publishToPage(caption, image);625  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();626  logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });627  return true;628}629630// ---------- Reels (vidéo animée + musique) ----------631export async function makeReel(insight) {632  const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));633  const p = (r.stdout || '').trim().split('\n').pop();634  if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));635  return p;636}637638export function reelPath(name) {639  const safe = path.basename(name);640  const fp = path.join(REELS_DIR, safe);641  return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;642}643644// brouillon reel : insight -> vidéo + légende (sans publier)645export async function generateReelDraft({ site = '', prompt = '' } = {}) {646  let insight;647  if (prompt && !site) {648    const r = await run(PY, [INSIGHTS]);649    const cands = (JSON.parse(r.stdout || '{}').candidates) || [];650    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');651    const chosen = await anthropic(652      "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",653      `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);654    insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];655    await decorateForPrompt(insight);656  } else {657    insight = await pickInsight({ site });658    await decorateForPrompt(insight);659  }660  const video = await makeReel(insight);661  const caption = await writeCaption(insight, prompt);662  const videoUrl='/api/social/reel/'+path.basename(video);663  addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() });664  return { insight, video, caption, videoUrl };665}666667export async function publishReelDraft({ caption, video }) {668  if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');669  const login = await fbLoginState();670  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');671  await publishReelToPage(caption, video);672  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();673  logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });674  return true;675}676677// cycle reel complet automatique678export async function runReelCycle(trigger = 'reel') {679  logEvent({ kind: 'cycle_start', trigger, format: 'reel' });680  try {681    const login = await fbLoginState();682    if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }683    const insight = await pickInsight({});684    const video = await makeReel(insight);685    const caption = await writeCaption(insight);686    await publishReelToPage(caption, video);687    state.lastSites = [...state.lastSites, insight.site].slice(-6);688    state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();689    logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });690    return { ok: true, insight, caption, video };691  } catch (e) {692    logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });693    return { ok: false, error: e.message };694  }695}696697// ---------- Galerie (tout le contenu généré : posts + reels) ----------698const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json');699function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } }700function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} }701function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); }702export function getGallery(){ return loadGallery().slice(0,60); }703704let genBatchRunning = false;705export function startGenBatch(sites){706  if (genBatchRunning) return { started:false, reason:'génération en cours' };707  genBatchRunning = true;708  (async () => {709    logEvent({ kind:'genbatch_start', total:sites.length });710    for (let i=0;i<sites.length;i++){711      const site=sites[i];712      try{713        logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });714        const insight=await pickInsight({ site });715        await decorateForPrompt(insight);716        const video=await makeReel(insight);717        const caption=await writeCaption(insight);718        addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),719          url:'/api/social/reel/'+path.basename(video), ts:Date.now() });720        logEvent({ kind:'genbatch_done_item', i:i+1, site });721      }catch(e){ logEvent({ kind:'genbatch_error', i:i+1, site, error:e.message }); }722    }723    logEvent({ kind:'genbatch_done', total:sites.length });724    genBatchRunning=false;725  })();726  return { started:true, count:sites.length };727}728export function genBatchStatus(){ return { running: genBatchRunning }; }729730// ---------- Lot de reels (génération + publication assistée) ----------731let batchRunning = false;732export const REEL_BATCH_DEFAULT = ['lou-ka','immo-ka','fabri-ka','sorti-ka','food-ka','auto-ka','vrai-prix','crea-ka','trouve-ka','resto-ka'];733export function startReelBatch(sites) {734  if (batchRunning) return { started: false, reason: 'lot déjà en cours' };735  batchRunning = true;736  (async () => {737    logEvent({ kind: 'batch_start', total: sites.length });738    for (let i = 0; i < sites.length; i++) {739      const site = sites[i];740      try {741        logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });742        const insight = await pickInsight({ site });743        await decorateForPrompt(insight);744        const video = await makeReel(insight);745        const caption = await writeCaption(insight);746        logEvent({ kind: 'batch_ready', i: i + 1, total: sites.length, site, genre: insight.genre, step: '⏳ CLIQUE « Suivant » puis « Publier » sur l\'écran du nœud' });747        await publishReelAssisted(caption, video);748        state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();749        logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() });750      } catch (e) {751        logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message });752      }753    }754    logEvent({ kind: 'batch_done', total: sites.length });755    batchRunning = false;756  })();757  return { started: true, count: sites.length };758}759export function batchStatus() { return { running: batchRunning }; }760761// ---------- planificateur horaire ----------762function scheduleNext() {763  clearTimeout(timer);764  if (!state.auto) { state.nextAt = 0; saveState(); return; }765  const period = (CFG.socialIntervalMin || 60) * 60 * 1000;766  const since = Date.now() - (state.lastPostAt || 0);767  const wait = Math.max(60 * 1000, period - since);768  state.nextAt = Date.now() + wait; saveState();769  timer = setTimeout(async () => {770    const every = CFG.reelEvery || 0;   // 0 = jamais de reel auto ; N = 1 reel tous les N posts771    if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');772    else await runAutoCycle('auto');773    scheduleNext();774  }, wait);775}776777export function setAuto(on) {778  state.auto = !!on; saveState();779  scheduleNext();780  logEvent({ kind: on ? 'auto_on' : 'auto_off' });781  return socialState();782}783784export function socialState() {785  return {786    auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,787    lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,788  };789}790791export function cardPath(name) {792  const safe = path.basename(name);793  const p = path.join(CARDS_DIR, safe);794  return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;795}796797export function initSocial(cfg, logger) {798  CFG = cfg || {};799  LOGGER = logger || (() => {});800  if (state.auto) scheduleNext();801  logEvent({ kind: 'init', auto: state.auto });802}803
Files · 6e3e330995168d92148e4ad71a84d44e9a0572ad
Menu

Theme