SPB Git

spb/admin-ka

Public
34.9 KB · 731 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)); }205async function _publishMedia(caption, mediaPath, isVideo) {206  await gotoPage();207  await sleep(9000);208209  // 1) ouvrir le composeur210  const openComposer = `(function(){211    var target=null;212    document.querySelectorAll('span,div').forEach(function(e){213      var t=(e.innerText||'').trim();214      if(!target && e.children.length===0 &&215        (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||216         t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;217    });218    if(!target) return 'NOT_FOUND';219    var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';220    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});221    return 'opened';222  })();`;223  // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans224  // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.225  const basculer = `(function(){226    var btn=null;227    document.querySelectorAll('[role=button]').forEach(function(e){228      var t=(e.innerText||'').trim();229      if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;230    });231    if(!btn) return 'NO_BTN';232    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});233    return 'switched';234  })();`;235  let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);236  if (r1 !== 'opened') {237    const sw = await osaJS(basculer);238    if (sw === 'switched') { await sleep(9000); await dismissModals(3); }239    r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);240  }241  if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');242  await sleep(3000);243244  // 2) coller la légende dans le TEXTBOX VISIBLE (évite les dialogues empilés245  //    et le double collage) — atomique : ne colle que si le box est encore vide.246  const b64cap = Buffer.from(caption, 'utf8').toString('base64');247  // trouve le contenteditable visible le plus « profond » (composeur actif)248  const findBoxFn = `function(){249    var all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true][role=textbox]'));250    if(!all.length) all=[].slice.call(document.querySelectorAll('div[role=dialog] div[contenteditable=true]'));251    var vis=all.filter(function(b){var r=b.getBoundingClientRect();return r.width>10&&r.height>10&&r.top>=0&&r.left>=0;});252    return vis.length?vis[vis.length-1]:(all.length?all[all.length-1]:null);253  }`;254  const pasteJS = `(function(){255    var box=(${findBoxFn})(); if(!box) return 'NO_BOX';256    if((box.innerText||'').replace(/\\s/g,'').length>20) return 'already:'+box.innerText.length; // déjà rempli -> ne pas recoller257    box.focus();258    var txt=decodeURIComponent(escape(atob('${b64cap}')));259    var dt=new DataTransfer(); dt.setData('text/plain', txt);260    box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));261    return 'pasted';262  })();`;263  const checkLen = `(function(){var box=(${findBoxFn})();return 'len:'+(box?box.innerText.length:-1);})();`;264  let len = 0;265  for (let i = 0; i < 3; i++) {266    const r = await osaJS(pasteJS);267    await sleep(2500);268    const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);269    if (len > 40) break;270    await sleep(1200);271  }272  if (!(len > 40)) throw new Error('collage légende échoué');273274  // 3) injecter le média (image OU vidéo) dans l'input file du composeur275  const mediaB64 = fs.readFileSync(mediaPath).toString('base64');276  const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';277  const mtype = isVideo ? 'video/mp4' : 'image/png';278  const injectJS = `(function(){279    var inp=document.querySelector('div[role=dialog] input[type=file]') ||280            document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||281            document.querySelector('input[type=file]');282    if(!inp) return 'NO_INPUT';283    try{284      var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);285      for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);286      var file=new File([arr], '${fname}', {type:'${mtype}'});287      var dt=new DataTransfer(); dt.items.add(file);288      inp.files=dt.files;289      inp.dispatchEvent(new Event('change',{bubbles:true}));290      return 'injected';291    }catch(e){ return 'ERR:'+e.message; }292  })();`;293  const inj = await osaJS(injectJS);294  if (inj !== 'injected') {295    // repli : bouton Photo/Vidéo puis re-tenter l'injection296    const clickPhoto = `(function(){var btn=null;297      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){298        if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});299      if(!btn) return 'NO_BTN';300      ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});301      return 'clicked';})();`;302    await osaJS(clickPhoto); await sleep(2500);303    const inj2 = await osaJS(injectJS);304    if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');305  }306307  // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)308  const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';309    var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});310    dlg.querySelectorAll('video').forEach(function(){n++;});311    return ''+n;})();`;312  const prevTries = isVideo ? 30 : 12;   // upload/traitement vidéo plus long313  const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);314  if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');315  await sleep(isVideo ? 4000 : 2000);316317  // 5) Suivant (si présent) puis Publier318  const clickBtn = (label) => `(function(){319    var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;320    dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){321      if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});322    if(!btn) return 'NO_BTN';323    if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';324    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});325    return 'clicked';})();`;326  // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS327  // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)328  const focusBtn = (labels) => `(function(){329    var want=${JSON.stringify(labels)}; var btn=null;330    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){331      if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();332      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;333    });334    if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();335    return (document.activeElement===btn)?'focused':'focus_fail';336  })();`;337  async function clickBtnOS(labels) {338    const coordJS = `(function(){339      var want=${JSON.stringify(labels)}; var btn=null;340      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){341        if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();342        if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;343      });344      if(!btn) return 'NO_BTN';345      try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}346      var r=btn.getBoundingClientRect();347      var gx=Math.round(window.screenX + r.left + r.width/2);348      var gy=Math.round(window.screenY + r.top + r.height/2);349      return gx+' '+gy;350    })();`;351    const co = await osaJS(coordJS);352    if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';353    const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));354    if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;355    const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',356      '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);357    return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';358  }359  async function advanceByKey(labels) {360    const f = await osaJS(focusBtn(labels));361    if (f !== 'focused') return f;362    await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);363    return 'pressed';364  }365  const clickAny = (labels) => `(function(){366    var want=${JSON.stringify(labels)}; var btn=null;367    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){368      if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();369      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;370    });371    if(!btn) return 'NO_BTN';372    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});373    return 'clicked';374  })();`;375  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';})();`;376  if (isVideo) {377    // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle378    for (let i = 0; i < 6; i++) {379      if ((await osaJS(canPublishJS)) === 'YES') break;380      const r = await clickBtnOS(['Suivant']);381      if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }382    }383    // Publier / Partager par focus + Entrée (poll pendant le téléversement)384    let done = false;385    for (let i = 0; i < 14; i++) {386      if ((await osaJS(canPublishJS)) === 'YES') {387        const r = await clickBtnOS(['Publier', 'Partager']);388        if (r === 'clicked') { done = true; break; }389      }390      await sleep(5000); await dismissModals(1);391    }392    if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');393  } else {394    const nx = await osaJS(clickAny(['Suivant']));395    if (nx === 'clicked') await sleep(7000);396    const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);397    if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');398  }399400  // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte401  // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique402  // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.403  const composerGoneJS = `(function(){404    var open=false;405    document.querySelectorAll('div[role=dialog]').forEach(function(d){406      if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;407    });408    return open ? 'OPEN' : 'GONE';409  })();`;410  let gone = 'OPEN';411  const goneTries = isVideo ? 40 : 18;412  for (let i = 0; i < goneTries; i++) {413    await sleep(8000);414    await dismissModals(2);                 // ferme toute boîte non bloquante en route415    try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }416    if (gone === 'GONE') break;417  }418  await dismissModals(3);                    // ferme la boîte « Plus tard » finale419  if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');420  return true;421}422423// ---------- génération d'un brouillon (insight -> image + légende) ----------424export async function pickInsight({ site = '', excludeRecent = true } = {}) {425  const args = [INSIGHTS];426  if (site) args.push('--site', site);427  else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));428  const r = await run(PY, args);429  if (r.error) throw new Error('insights.py: ' + r.error);430  const data = JSON.parse(r.stdout);431  const cand = (data.candidates || [])[0];432  if (!cand) throw new Error('aucun insight disponible');433  return cand;434}435436export async function renderCard(insight) {437  // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec438  let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));439  let p = (r.stdout || '').trim().split('\n').pop();440  if (r.error || !p || !fs.existsSync(p)) {441    logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });442    r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));443    p = (r.stdout || '').trim().split('\n').pop();444    if (r.error) throw new Error('render: ' + r.error);445  }446  if (!p || !fs.existsSync(p)) throw new Error('image non produite');447  return p;448}449450// --- produit vedette (photo réelle) pour le genre « spotlight » ---451const SPOT = {452  'lou-ka':   { url: 'https://www.lou-ka.com/api/listings?limit=24',  arr: 'listings', minPrice: 800 },453  'immo-ka':  { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },454  'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },455  'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24',  arr: 'events', minPrice: 0 },456};457async function fetchFeatured(site) {458  const cfg = SPOT[site]; if (!cfg) return null;459  try {460    const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });461    const data = await res.json();462    const arr = data[cfg.arr] || [];463    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');464    for (const it of arr) {465      const img = (it.images && it.images[0]) || it.image || '';466      const title = (it.title || it.name || '').trim();467      if (!img || !title || title.length > 80) continue;468      const price = it.price || it.price_min || null;469      if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;470      if (site === 'lou-ka') {471        return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),472          price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,473          meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };474      }475      if (site === 'immo-ka') {476        const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;477        return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),478          price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,479          meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };480      }481      if (site === 'fabri-ka') {482        return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),483          price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,484          meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };485      }486      if (site === 'sorti-ka') {487        return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),488          price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,489          meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };490      }491    }492  } catch {}493  return null;494}495496async function decorateForPrompt(insight) {497  if (SPOT[insight.site]) {498    const feat = await fetchFeatured(insight.site);499    if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }500  }501  insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';502  return insight;503}504505// choisit le genre visuel (rotation) et attache le produit vedette si spotlight506async function decorateCreative(insight) {507  const n = state.postCount || 0;508  let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';509  if (SPOT[insight.site] && (n % 3 === 0)) {510    const feat = await fetchFeatured(insight.site);511    if (feat) { insight.product = feat; genre = 'spotlight'; }512  }513  insight.genre = genre;514  return insight;515}516517// légende via API Anthropic (le modèle Claude rédige à partir du fait)518async function anthropic(system, user, model, maxTokens = 500) {519  const key = CFG.anthropicKey;520  if (!key) throw new Error('Clé Anthropic non configurée');521  const resp = await fetch('https://api.anthropic.com/v1/messages', {522    method: 'POST',523    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },524    body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),525  });526  const data = await resp.json();527  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');528  return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();529}530531const 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.532533Règles STRICTES :534- 1 seule publication, 40 à 90 mots.535- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).536- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).537- 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).`;538539export async function writeCaption(insight, extraPrompt = '') {540  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';541  const url = `https://www.${insight.site}.com`;542  let user;543  if (insight.product && insight.genre === 'spotlight') {544    const pr = insight.product;545    user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +546      `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +547      (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +548      `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +549      (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +550      `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;551  } else {552    user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +553      `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +554      (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +555      `\nRédige la publication Facebook.`;556  }557  const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);558  return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";559}560561// brouillon complet : insight -> image + légende (sans publier)562export async function generateDraft({ site = '', prompt = '' } = {}) {563  let insight;564  if (prompt && !site) {565    // laisser le modèle choisir le site le plus pertinent pour ce prompt566    const r = await run(PY, [INSIGHTS]);567    const data = JSON.parse(r.stdout || '{}');568    const cands = data.candidates || [];569    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');570    const chosen = await anthropic(571      "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.",572      `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,573      CFG.socialModel || 'claude-opus-4-8', 30);574    const pickSite = (chosen || '').trim().split(/\s/)[0];575    insight = cands.find(c => c.site === pickSite) || cands[0];576    await decorateForPrompt(insight);577  } else {578    insight = await pickInsight({ site });579    await decorateCreative(insight);580  }581  const image = await renderCard(insight);582  const caption = await writeCaption(insight, prompt);583  return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };584}585586// cycle complet automatique : choisir -> rendre -> rédiger -> publier587export async function runAutoCycle(trigger = 'auto') {588  const started = logEvent({ kind: 'cycle_start', trigger });589  try {590    const login = await fbLoginState();591    if (login !== 'LOGGED_IN') {592      logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });593      return { ok: false, reason: 'facebook_non_connecté' };594    }595    const insight = await pickInsight({});596    await decorateCreative(insight);597    const image = await renderCard(insight);598    const caption = await writeCaption(insight);599    await publishToPage(caption, image);600    state.lastSites = [...state.lastSites, insight.site].slice(-6);601    state.lastPostAt = Date.now();602    state.postCount = (state.postCount || 0) + 1;603    saveState();604    logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,605      headline: insight.headline, caption, image: path.basename(image) });606    return { ok: true, insight, caption, image };607  } catch (e) {608    logEvent({ kind: 'cycle_error', trigger, error: e.message });609    return { ok: false, error: e.message };610  }611}612613// publier un brouillon fourni (post sur demande)614export async function publishDraft({ caption, image }) {615  if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');616  const login = await fbLoginState();617  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');618  await publishToPage(caption, image);619  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();620  logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });621  return true;622}623624// ---------- Reels (vidéo animée + musique) ----------625export async function makeReel(insight) {626  const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));627  const p = (r.stdout || '').trim().split('\n').pop();628  if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));629  return p;630}631632export function reelPath(name) {633  const safe = path.basename(name);634  const fp = path.join(REELS_DIR, safe);635  return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;636}637638// brouillon reel : insight -> vidéo + légende (sans publier)639export async function generateReelDraft({ site = '', prompt = '' } = {}) {640  let insight;641  if (prompt && !site) {642    const r = await run(PY, [INSIGHTS]);643    const cands = (JSON.parse(r.stdout || '{}').candidates) || [];644    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');645    const chosen = await anthropic(646      "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",647      `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);648    insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];649    await decorateForPrompt(insight);650  } else {651    insight = await pickInsight({ site });652    await decorateForPrompt(insight);653  }654  const video = await makeReel(insight);655  const caption = await writeCaption(insight, prompt);656  return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };657}658659export async function publishReelDraft({ caption, video }) {660  if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');661  const login = await fbLoginState();662  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');663  await publishReelToPage(caption, video);664  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();665  logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });666  return true;667}668669// cycle reel complet automatique670export async function runReelCycle(trigger = 'reel') {671  logEvent({ kind: 'cycle_start', trigger, format: 'reel' });672  try {673    const login = await fbLoginState();674    if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }675    const insight = await pickInsight({});676    const video = await makeReel(insight);677    const caption = await writeCaption(insight);678    await publishReelToPage(caption, video);679    state.lastSites = [...state.lastSites, insight.site].slice(-6);680    state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();681    logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });682    return { ok: true, insight, caption, video };683  } catch (e) {684    logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });685    return { ok: false, error: e.message };686  }687}688689// ---------- planificateur horaire ----------690function scheduleNext() {691  clearTimeout(timer);692  if (!state.auto) { state.nextAt = 0; saveState(); return; }693  const period = (CFG.socialIntervalMin || 60) * 60 * 1000;694  const since = Date.now() - (state.lastPostAt || 0);695  const wait = Math.max(60 * 1000, period - since);696  state.nextAt = Date.now() + wait; saveState();697  timer = setTimeout(async () => {698    const every = CFG.reelEvery || 0;   // 0 = jamais de reel auto ; N = 1 reel tous les N posts699    if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');700    else await runAutoCycle('auto');701    scheduleNext();702  }, wait);703}704705export function setAuto(on) {706  state.auto = !!on; saveState();707  scheduleNext();708  logEvent({ kind: on ? 'auto_on' : 'auto_off' });709  return socialState();710}711712export function socialState() {713  return {714    auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,715    lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,716  };717}718719export function cardPath(name) {720  const safe = path.basename(name);721  const p = path.join(CARDS_DIR, safe);722  return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;723}724725export function initSocial(cfg, logger) {726  CFG = cfg || {};727  LOGGER = logger || (() => {});728  if (state.auto) scheduleNext();729  logEvent({ kind: 'init', auto: state.auto });730}731
Files · 6e04c504767d9d1f08f3aab491074f19252cb65f
Menu

Theme