SPB Git

spb/admin-ka

Public
43.8 KB · 877 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 KNOWN_SITES = ['lou-ka','immo-ka','vrai-prix','auto-ka','food-ka','fabri-ka','resto-ka','sorti-ka','crea-ka','job-ka','trouve-ka'];456const SPOT = {457  'lou-ka':   { url: 'https://www.lou-ka.com/api/listings?limit=24',  arr: 'listings', minPrice: 800 },458  'immo-ka':  { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },459  'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },460  'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24',  arr: 'events', minPrice: 0 },461};462async function fetchFeatured(site) {463  const cfg = SPOT[site]; if (!cfg) return null;464  try {465    const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });466    const data = await res.json();467    const arr = data[cfg.arr] || [];468    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');469    for (const it of arr) {470      const img = (it.images && it.images[0]) || it.image || '';471      const title = (it.title || it.name || '').trim();472      if (!img || !title || title.length > 80) continue;473      const price = it.price || it.price_min || null;474      if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;475      if (site === 'lou-ka') {476        return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),477          price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,478          meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };479      }480      if (site === 'immo-ka') {481        const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;482        return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),483          price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,484          meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };485      }486      if (site === 'fabri-ka') {487        return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),488          price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,489          meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };490      }491      if (site === 'sorti-ka') {492        return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),493          price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,494          meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };495      }496    }497  } catch {}498  return null;499}500501// ---- listes (plusieurs items réels) ----502const LIST_TITLES = {503  'lou-ka': 'Les derniers logements à louer', 'immo-ka': 'Les dernières propriétés à vendre',504  'fabri-ka': 'Nouveautés québécoises', 'sorti-ka': 'Les prochains événements',505};506function mapItem(site, it, fr) {507  const img = (it.images && it.images[0]) || it.image || '';508  const title = (it.title || it.name || '').trim();509  if (!img || !title) return null;510  const price = it.price || it.price_min || null;511  if (site === 'lou-ka') return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img, _price: price };512  if (site === 'immo-ka') return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };513  if (site === 'fabri-ka') return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };514  if (site === 'sorti-ka') return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '), price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img, _price: price };515  return { title, subtitle: it.city || '', price: it.price_label || '', image: img, _price: price };516}517const LIST_CFG = {518  'lou-ka':   { n: 60,  min: 500,    max: 6000 },519  'immo-ka':  { n: 300, min: 200000, max: 9000000 },  // feed trié par prix croissant -> chercher large520  'fabri-ka': { n: 60,  min: 0,      max: 0 },521  'sorti-ka': { n: 60,  min: 0,      max: 0 },522};523async function fetchItems(site, count) {524  const cfg = SPOT[site]; if (!cfg) return null;525  const lc = LIST_CFG[site] || { n: 60, min: 0, max: 0 };526  const listMin = lc.min, listMax = lc.max;527  const url = cfg.url.replace(/(limit|per_page)=\d+/, '$1=' + lc.n);528  try {529    const res = await fetch(url, { signal: AbortSignal.timeout(14000) });530    const data = await res.json();531    const arr = data[cfg.arr] || [];532    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');533    const valid = [];534    for (const it of arr) {535      const row = mapItem(site, it, fr);536      if (!row || !row.image || !row.title) continue;537      if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…';538      valid.push(row);539    }540    if (valid.length < 2) return null;541    // préférence : items dans la fourchette de prix ; sinon repli sur les plus chers542    let pick = valid.filter(r => (!listMin || (r._price && r._price >= listMin)) && (!listMax || !r._price || r._price <= listMax));543    if (pick.length < Math.min(count, 3)) pick = valid.slice().sort((a, b) => (b._price || 0) - (a._price || 0));544    pick = pick.slice(0, count);545    pick.forEach(r => delete r._price);546    return pick.length >= 2 ? pick : null;547  } catch { return null; }548}549// analyse le prompt -> {site, mode, count}550async function planFromPrompt(prompt) {551  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';552  const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list\",\"count\":<1-6>}.\n" +553    "Sites: lou-ka (logements à louer), immo-ka (propriétés à vendre), vrai-prix (évaluations), auto-ka (autos occasion), food-ka (épicerie/soldes), fabri-ka (produits québécois), resto-ka (restos), sorti-ka (événements), crea-ka (créateurs), job-ka (emplois), trouve-ka (recherche web).\n" +554    "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items (logements, propriétés, produits, événements).\n" +555    "Règles: 'liste/derniers/plusieurs/top N/montre' => mode=list (count = N demandé ou 5). 'dernier/une annonce/en vedette' => spotlight. Sinon stat. list & spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat. Choisis le site le plus pertinent.";556  try {557    const raw = await anthropic(sys, "Demande : " + prompt, model, 120);558    const m = raw.match(/\{[\s\S]*\}/);559    const plan = JSON.parse(m ? m[0] : raw);560    return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) };561  } catch { return { site: '', mode: 'stat', count: 5 }; }562}563// applique un plan à un insight (list / spotlight / stat) + repli564async function decoratePlan(insight, plan) {565  if (plan.mode === 'list' && SPOT[insight.site]) {566    const items = await fetchItems(insight.site, plan.count);567    if (items && items.length >= 2) { insight.list = items; insight.genre = 'list'; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; return insight; }568  }569  if ((plan.mode === 'list' || plan.mode === 'spotlight') && SPOT[insight.site]) {570    const feat = await fetchFeatured(insight.site);571    if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }572  }573  insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';574  return insight;575}576577async function decorateForPrompt(insight) {578  if (SPOT[insight.site]) {579    const feat = await fetchFeatured(insight.site);580    if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }581  }582  insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';583  return insight;584}585586// choisit le genre visuel (rotation) et attache le produit vedette si spotlight587async function decorateCreative(insight) {588  const n = state.postCount || 0;589  let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';590  if (SPOT[insight.site] && (n % 3 === 0)) {591    const feat = await fetchFeatured(insight.site);592    if (feat) { insight.product = feat; genre = 'spotlight'; }593  }594  insight.genre = genre;595  return insight;596}597598// légende via API Anthropic (le modèle Claude rédige à partir du fait)599async function anthropic(system, user, model, maxTokens = 500) {600  const key = CFG.anthropicKey;601  if (!key) throw new Error('Clé Anthropic non configurée');602  const resp = await fetch('https://api.anthropic.com/v1/messages', {603    method: 'POST',604    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },605    body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),606  });607  const data = await resp.json();608  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');609  return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();610}611612const 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.613614Règles STRICTES :615- 1 seule publication, 40 à 90 mots.616- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).617- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).618- 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).`;619620export async function writeCaption(insight, extraPrompt = '') {621  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';622  const url = `https://www.${insight.site}.com`;623  let user;624  if (insight.list && insight.genre === 'list') {625    const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n');626    user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` +627      `${insight.list_title || ''}\n${lignes}\n\n` +628      `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +629      (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +630      `\nRédige une publication Facebook qui donne envie de parcourir cette sélection (tu peux mentionner 2-3 items, sans tout lister).`;631  } else if (insight.product && insight.genre === 'spotlight') {632    const pr = insight.product;633    user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +634      `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +635      (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +636      `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +637      (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +638      `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;639  } else {640    user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +641      `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +642      (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +643      `\nRédige la publication Facebook.`;644  }645  const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);646  return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";647}648649// brouillon complet : insight -> image + légende (sans publier)650export async function generateDraft({ site = '', prompt = '' } = {}) {651  let insight;652  if (prompt) {653    const plan = await planFromPrompt(prompt);654    if (site) plan.site = site;655    const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';656    insight = await pickInsight({ site: chosenSite });657    await decoratePlan(insight, plan);658  } else {659    insight = await pickInsight({ site });660    await decorateCreative(insight);661  }662  const image = await renderCard(insight);663  const caption = await writeCaption(insight, prompt);664  const imageUrl='/api/social/card/'+path.basename(image);665  addToGallery({ kind:'post', site:insight.site, genre:insight.genre, caption, file:path.basename(image), url:imageUrl, ts:Date.now() });666  return { insight, image, caption, imageUrl };667}668669// cycle complet automatique : choisir -> rendre -> rédiger -> publier670export async function runAutoCycle(trigger = 'auto') {671  const started = logEvent({ kind: 'cycle_start', trigger });672  try {673    const login = await fbLoginState();674    if (login !== 'LOGGED_IN') {675      logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });676      return { ok: false, reason: 'facebook_non_connecté' };677    }678    const insight = await pickInsight({});679    await decorateCreative(insight);680    const image = await renderCard(insight);681    const caption = await writeCaption(insight);682    await publishToPage(caption, image);683    state.lastSites = [...state.lastSites, insight.site].slice(-6);684    state.lastPostAt = Date.now();685    state.postCount = (state.postCount || 0) + 1;686    saveState();687    logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,688      headline: insight.headline, caption, image: path.basename(image) });689    return { ok: true, insight, caption, image };690  } catch (e) {691    logEvent({ kind: 'cycle_error', trigger, error: e.message });692    return { ok: false, error: e.message };693  }694}695696// publier un brouillon fourni (post sur demande)697export async function publishDraft({ caption, image }) {698  if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');699  const login = await fbLoginState();700  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');701  await publishToPage(caption, image);702  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();703  logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });704  return true;705}706707// ---------- Reels (vidéo animée + musique) ----------708export async function makeReel(insight) {709  const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));710  const p = (r.stdout || '').trim().split('\n').pop();711  if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));712  return p;713}714715export function reelPath(name) {716  const safe = path.basename(name);717  const fp = path.join(REELS_DIR, safe);718  return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;719}720721// brouillon reel : insight -> vidéo + légende (sans publier)722export async function generateReelDraft({ site = '', prompt = '' } = {}) {723  let insight;724  if (prompt) {725    const plan = await planFromPrompt(prompt);726    if (site) plan.site = site;727    const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';728    insight = await pickInsight({ site: chosenSite });729    await decoratePlan(insight, plan);730  } else {731    insight = await pickInsight({ site });732    await decorateForPrompt(insight);733  }734  const video = await makeReel(insight);735  const caption = await writeCaption(insight, prompt);736  const videoUrl='/api/social/reel/'+path.basename(video);737  addToGallery({ kind:'reel', site:insight.site, genre:insight.genre, caption, file:path.basename(video), url:videoUrl, ts:Date.now() });738  return { insight, video, caption, videoUrl };739}740741export async function publishReelDraft({ caption, video }) {742  if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');743  const login = await fbLoginState();744  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');745  await publishReelToPage(caption, video);746  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();747  logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });748  return true;749}750751// cycle reel complet automatique752export async function runReelCycle(trigger = 'reel') {753  logEvent({ kind: 'cycle_start', trigger, format: 'reel' });754  try {755    const login = await fbLoginState();756    if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }757    const insight = await pickInsight({});758    const video = await makeReel(insight);759    const caption = await writeCaption(insight);760    await publishReelToPage(caption, video);761    state.lastSites = [...state.lastSites, insight.site].slice(-6);762    state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();763    logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });764    return { ok: true, insight, caption, video };765  } catch (e) {766    logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });767    return { ok: false, error: e.message };768  }769}770771// ---------- Galerie (tout le contenu généré : posts + reels) ----------772const GALLERY_PATH = path.join(SOCIAL_DIR, 'gallery.json');773function loadGallery(){ try { return JSON.parse(fs.readFileSync(GALLERY_PATH,'utf8')); } catch { return []; } }774function saveGallery(g){ try { fs.writeFileSync(GALLERY_PATH, JSON.stringify(g.slice(0,200))); } catch {} }775function addToGallery(item){ const g=loadGallery(); g.unshift(item); saveGallery(g); }776export function getGallery(){ return loadGallery().slice(0,60); }777778let genBatchRunning = false;779export function startGenBatch(sites){780  if (genBatchRunning) return { started:false, reason:'génération en cours' };781  genBatchRunning = true;782  (async () => {783    logEvent({ kind:'genbatch_start', total:sites.length });784    for (let i=0;i<sites.length;i++){785      const site=sites[i];786      try{787        logEvent({ kind:'genbatch_item', i:i+1, total:sites.length, site });788        const insight=await pickInsight({ site });789        await decorateForPrompt(insight);790        const video=await makeReel(insight);791        const caption=await writeCaption(insight);792        addToGallery({ kind:'reel', site, genre:insight.genre, caption, file:path.basename(video),793          url:'/api/social/reel/'+path.basename(video), ts:Date.now() });794        logEvent({ kind:'genbatch_done_item', i:i+1, site });795      }catch(e){ logEvent({ kind:'genbatch_error', i:i+1, site, error:e.message }); }796    }797    logEvent({ kind:'genbatch_done', total:sites.length });798    genBatchRunning=false;799  })();800  return { started:true, count:sites.length };801}802export function genBatchStatus(){ return { running: genBatchRunning }; }803804// ---------- Lot de reels (génération + publication assistée) ----------805let batchRunning = false;806export const REEL_BATCH_DEFAULT = ['lou-ka','immo-ka','fabri-ka','sorti-ka','food-ka','auto-ka','vrai-prix','crea-ka','trouve-ka','resto-ka'];807export function startReelBatch(sites) {808  if (batchRunning) return { started: false, reason: 'lot déjà en cours' };809  batchRunning = true;810  (async () => {811    logEvent({ kind: 'batch_start', total: sites.length });812    for (let i = 0; i < sites.length; i++) {813      const site = sites[i];814      try {815        logEvent({ kind: 'batch_item', i: i + 1, total: sites.length, site, step: 'génération vidéo' });816        const insight = await pickInsight({ site });817        await decorateForPrompt(insight);818        const video = await makeReel(insight);819        const caption = await writeCaption(insight);820        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' });821        await publishReelAssisted(caption, video);822        state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();823        logEvent({ kind: 'published', trigger: 'batch', format: 'reel', site, headline: insight.headline, caption, video: video.split('/').pop() });824      } catch (e) {825        logEvent({ kind: 'batch_error', i: i + 1, site, error: e.message });826      }827    }828    logEvent({ kind: 'batch_done', total: sites.length });829    batchRunning = false;830  })();831  return { started: true, count: sites.length };832}833export function batchStatus() { return { running: batchRunning }; }834835// ---------- planificateur horaire ----------836function scheduleNext() {837  clearTimeout(timer);838  if (!state.auto) { state.nextAt = 0; saveState(); return; }839  const period = (CFG.socialIntervalMin || 60) * 60 * 1000;840  const since = Date.now() - (state.lastPostAt || 0);841  const wait = Math.max(60 * 1000, period - since);842  state.nextAt = Date.now() + wait; saveState();843  timer = setTimeout(async () => {844    const every = CFG.reelEvery || 0;   // 0 = jamais de reel auto ; N = 1 reel tous les N posts845    if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');846    else await runAutoCycle('auto');847    scheduleNext();848  }, wait);849}850851export function setAuto(on) {852  state.auto = !!on; saveState();853  scheduleNext();854  logEvent({ kind: on ? 'auto_on' : 'auto_off' });855  return socialState();856}857858export function socialState() {859  return {860    auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,861    lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,862  };863}864865export function cardPath(name) {866  const safe = path.basename(name);867  const p = path.join(CARDS_DIR, safe);868  return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;869}870871export function initSocial(cfg, logger) {872  CFG = cfg || {};873  LOGGER = logger || (() => {});874  if (state.auto) scheduleNext();875  logEvent({ kind: 'init', auto: state.auto });876}877
Files · 125ca1d72b0dd773a04142bcfa3b3f7e1d50019c
Menu

Theme