SPB Git

spb/admin-ka

Public
33.2 KB · 700 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 via ClipboardEvent synthétique (base64, sans clavier)245  const b64cap = Buffer.from(caption, 'utf8').toString('base64');246  const pasteJS = `(function(){247    var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';248    var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';249    box.focus();250    var txt=decodeURIComponent(escape(atob('${b64cap}')));251    var dt=new DataTransfer(); dt.setData('text/plain', txt);252    box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));253    return 'pasted';254  })();`;255  const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +256    `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +257    `return 'len:'+(box?box.innerText.length:-1);})();`;258  let len = 0;259  for (let i = 0; i < 3; i++) {260    await osaJS(pasteJS); await sleep(2500);261    const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);262    if (len > 40) break; await sleep(1500);263  }264  if (!(len > 40)) throw new Error('collage légende échoué');265266  // 3) injecter le média (image OU vidéo) dans l'input file du composeur267  const mediaB64 = fs.readFileSync(mediaPath).toString('base64');268  const fname = isVideo ? 'ka-reel.mp4' : 'ka-stat.png';269  const mtype = isVideo ? 'video/mp4' : 'image/png';270  const injectJS = `(function(){271    var inp=document.querySelector('div[role=dialog] input[type=file]') ||272            document.querySelector('input[type=file][accept*="${isVideo ? 'video' : 'image'}"]') ||273            document.querySelector('input[type=file]');274    if(!inp) return 'NO_INPUT';275    try{276      var bin=atob('${mediaB64}'); var len=bin.length; var arr=new Uint8Array(len);277      for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);278      var file=new File([arr], '${fname}', {type:'${mtype}'});279      var dt=new DataTransfer(); dt.items.add(file);280      inp.files=dt.files;281      inp.dispatchEvent(new Event('change',{bubbles:true}));282      return 'injected';283    }catch(e){ return 'ERR:'+e.message; }284  })();`;285  const inj = await osaJS(injectJS);286  if (inj !== 'injected') {287    // repli : bouton Photo/Vidéo puis re-tenter l'injection288    const clickPhoto = `(function(){var btn=null;289      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){290        if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});291      if(!btn) return 'NO_BTN';292      ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});293      return 'clicked';})();`;294    await osaJS(clickPhoto); await sleep(2500);295    const inj2 = await osaJS(injectJS);296    if (inj2 !== 'injected') throw new Error('injection média échouée (' + inj + ' / ' + inj2 + ')');297  }298299  // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)300  const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';301    var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});302    dlg.querySelectorAll('video').forEach(function(){n++;});303    return ''+n;})();`;304  const prevTries = isVideo ? 30 : 12;   // upload/traitement vidéo plus long305  const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, prevTries, 5000);306  if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu média absent');307  await sleep(isVideo ? 4000 : 2000);308309  // 5) Suivant (si présent) puis Publier310  const clickBtn = (label) => `(function(){311    var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;312    dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){313      if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});314    if(!btn) return 'NO_BTN';315    if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';316    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});317    return 'clicked';})();`;318  // pour la vidéo, FB exige un clic « de confiance » : on FOCUS le bouton en JS319  // puis on envoie une vraie touche Entrée via System Events (indépendant des coords écran)320  const focusBtn = (labels) => `(function(){321    var want=${JSON.stringify(labels)}; var btn=null;322    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){323      if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();324      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;325    });326    if(!btn) return 'NO_BTN'; try{btn.scrollIntoView({block:'center'});}catch(e){} btn.focus();327    return (document.activeElement===btn)?'focused':'focus_fail';328  })();`;329  async function clickBtnOS(labels) {330    const coordJS = `(function(){331      var want=${JSON.stringify(labels)}; var btn=null;332      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){333        if(btn)return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();334        if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;335      });336      if(!btn) return 'NO_BTN';337      try{btn.scrollIntoView({block:'center',inline:'center'});}catch(e){}338      var r=btn.getBoundingClientRect();339      var gx=Math.round(window.screenX + r.left + r.width/2);340      var gy=Math.round(window.screenY + r.top + r.height/2);341      return gx+' '+gy;342    })();`;343    const co = await osaJS(coordJS);344    if (co === 'NO_BTN' || co.indexOf(' ') < 0) return 'NO_BTN';345    const [gx, gy] = co.split(' ').map(n => parseInt(n, 10));346    if (!(gx > 0 && gy > 0)) return 'OFFSCREEN:' + co;347    const r = await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.3',348      '-e',`tell application "System Events" to click at {${gx}, ${gy}}`]);349    return r.error ? ('ERR:' + r.error.slice(0,60)) : 'clicked';350  }351  async function advanceByKey(labels) {352    const f = await osaJS(focusBtn(labels));353    if (f !== 'focused') return f;354    await run('/usr/bin/osascript', ['-e','tell application "Safari" to activate','-e','delay 0.35','-e','tell application "System Events" to key code 36']);355    return 'pressed';356  }357  const clickAny = (labels) => `(function(){358    var want=${JSON.stringify(labels)}; var btn=null;359    document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){360      if(btn) return; var al=(b.getAttribute('aria-label')||b.innerText||'').trim();361      if(want.indexOf(al)>=0 && b.getAttribute('aria-disabled')!=='true') btn=b;362    });363    if(!btn) return 'NO_BTN';364    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});365    return 'clicked';366  })();`;367  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';})();`;368  if (isVideo) {369    // Reel/vidéo : avancer les « Suivant » par focus + touche Entrée réelle370    for (let i = 0; i < 6; i++) {371      if ((await osaJS(canPublishJS)) === 'YES') break;372      const r = await clickBtnOS(['Suivant']);373      if (r === 'clicked') { await sleep(6000); await dismissModals(1); } else { await sleep(4000); }374    }375    // Publier / Partager par focus + Entrée (poll pendant le téléversement)376    let done = false;377    for (let i = 0; i < 14; i++) {378      if ((await osaJS(canPublishJS)) === 'YES') {379        const r = await clickBtnOS(['Publier', 'Partager']);380        if (r === 'clicked') { done = true; break; }381      }382      await sleep(5000); await dismissModals(1);383    }384    if (!done) throw new Error('bouton Publier/Partager indisponible (vidéo)');385  } else {386    const nx = await osaJS(clickAny(['Suivant']));387    if (nx === 'clicked') await sleep(7000);388    const pub = await pollJS(clickAny(['Publier', 'Partager']), v => v === 'clicked', 6, 4000);389    if (pub !== 'clicked') throw new Error('bouton Publier/Partager indisponible (' + pub + ')');390  }391392  // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte393  // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique394  // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.395  const composerGoneJS = `(function(){396    var open=false;397    document.querySelectorAll('div[role=dialog]').forEach(function(d){398      if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;399    });400    return open ? 'OPEN' : 'GONE';401  })();`;402  let gone = 'OPEN';403  const goneTries = isVideo ? 40 : 18;404  for (let i = 0; i < goneTries; i++) {405    await sleep(8000);406    await dismissModals(2);                 // ferme toute boîte non bloquante en route407    try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }408    if (gone === 'GONE') break;409  }410  await dismissModals(3);                    // ferme la boîte « Plus tard » finale411  if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');412  return true;413}414415// ---------- génération d'un brouillon (insight -> image + légende) ----------416export async function pickInsight({ site = '', excludeRecent = true } = {}) {417  const args = [INSIGHTS];418  if (site) args.push('--site', site);419  else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));420  const r = await run(PY, args);421  if (r.error) throw new Error('insights.py: ' + r.error);422  const data = JSON.parse(r.stdout);423  const cand = (data.candidates || [])[0];424  if (!cand) throw new Error('aucun insight disponible');425  return cand;426}427428export async function renderCard(insight) {429  // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec430  let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));431  let p = (r.stdout || '').trim().split('\n').pop();432  if (r.error || !p || !fs.existsSync(p)) {433    logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });434    r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));435    p = (r.stdout || '').trim().split('\n').pop();436    if (r.error) throw new Error('render: ' + r.error);437  }438  if (!p || !fs.existsSync(p)) throw new Error('image non produite');439  return p;440}441442// --- produit vedette (photo réelle) pour le genre « spotlight » ---443const SPOT = {444  'lou-ka':   { url: 'https://www.lou-ka.com/api/listings?limit=24',  arr: 'listings', minPrice: 800 },445  'immo-ka':  { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },446  'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },447  'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24',  arr: 'events', minPrice: 0 },448};449async function fetchFeatured(site) {450  const cfg = SPOT[site]; if (!cfg) return null;451  try {452    const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });453    const data = await res.json();454    const arr = data[cfg.arr] || [];455    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');456    for (const it of arr) {457      const img = (it.images && it.images[0]) || it.image || '';458      const title = (it.title || it.name || '').trim();459      if (!img || !title || title.length > 80) continue;460      const price = it.price || it.price_min || null;461      if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;462      if (site === 'lou-ka') {463        return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),464          price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,465          meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };466      }467      if (site === 'immo-ka') {468        const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;469        return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),470          price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,471          meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };472      }473      if (site === 'fabri-ka') {474        return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),475          price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,476          meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };477      }478      if (site === 'sorti-ka') {479        return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),480          price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,481          meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };482      }483    }484  } catch {}485  return null;486}487488// choisit le genre visuel (rotation) et attache le produit vedette si spotlight489async function decorateCreative(insight) {490  const n = state.postCount || 0;491  let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';492  if (SPOT[insight.site] && (n % 3 === 0)) {493    const feat = await fetchFeatured(insight.site);494    if (feat) { insight.product = feat; genre = 'spotlight'; }495  }496  insight.genre = genre;497  return insight;498}499500// légende via API Anthropic (le modèle Claude rédige à partir du fait)501async function anthropic(system, user, model, maxTokens = 500) {502  const key = CFG.anthropicKey;503  if (!key) throw new Error('Clé Anthropic non configurée');504  const resp = await fetch('https://api.anthropic.com/v1/messages', {505    method: 'POST',506    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },507    body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),508  });509  const data = await resp.json();510  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');511  return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();512}513514const 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.515516Règles STRICTES :517- 1 seule publication, 40 à 90 mots.518- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).519- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).520- 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).`;521522export async function writeCaption(insight, extraPrompt = '') {523  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';524  const url = `https://www.${insight.site}.com`;525  const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +526    `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +527    (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +528    `\nRédige la publication Facebook.`;529  const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);530  return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";531}532533// brouillon complet : insight -> image + légende (sans publier)534export async function generateDraft({ site = '', prompt = '' } = {}) {535  let insight;536  if (prompt && !site) {537    // laisser le modèle choisir le site le plus pertinent pour ce prompt538    const r = await run(PY, [INSIGHTS]);539    const data = JSON.parse(r.stdout || '{}');540    const cands = data.candidates || [];541    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');542    const chosen = await anthropic(543      "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.",544      `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,545      CFG.socialModel || 'claude-opus-4-8', 30);546    const pickSite = (chosen || '').trim().split(/\s/)[0];547    insight = cands.find(c => c.site === pickSite) || cands[0];548  } else {549    insight = await pickInsight({ site });550  }551  await decorateCreative(insight);552  const image = await renderCard(insight);553  const caption = await writeCaption(insight, prompt);554  return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };555}556557// cycle complet automatique : choisir -> rendre -> rédiger -> publier558export async function runAutoCycle(trigger = 'auto') {559  const started = logEvent({ kind: 'cycle_start', trigger });560  try {561    const login = await fbLoginState();562    if (login !== 'LOGGED_IN') {563      logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });564      return { ok: false, reason: 'facebook_non_connecté' };565    }566    const insight = await pickInsight({});567    await decorateCreative(insight);568    const image = await renderCard(insight);569    const caption = await writeCaption(insight);570    await publishToPage(caption, image);571    state.lastSites = [...state.lastSites, insight.site].slice(-6);572    state.lastPostAt = Date.now();573    state.postCount = (state.postCount || 0) + 1;574    saveState();575    logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,576      headline: insight.headline, caption, image: path.basename(image) });577    return { ok: true, insight, caption, image };578  } catch (e) {579    logEvent({ kind: 'cycle_error', trigger, error: e.message });580    return { ok: false, error: e.message };581  }582}583584// publier un brouillon fourni (post sur demande)585export async function publishDraft({ caption, image }) {586  if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');587  const login = await fbLoginState();588  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');589  await publishToPage(caption, image);590  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();591  logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });592  return true;593}594595// ---------- Reels (vidéo animée + musique) ----------596export async function makeReel(insight) {597  const r = await runInput(PY, [MAKE_REEL, REELS_DIR], JSON.stringify(insight));598  const p = (r.stdout || '').trim().split('\n').pop();599  if (r.error || !p || !fs.existsSync(p)) throw new Error('make_reel: ' + (r.error || 'mp4 absent'));600  return p;601}602603export function reelPath(name) {604  const safe = path.basename(name);605  const fp = path.join(REELS_DIR, safe);606  return fp.startsWith(REELS_DIR) && fs.existsSync(fp) ? fp : null;607}608609// brouillon reel : insight -> vidéo + légende (sans publier)610export async function generateReelDraft({ site = '', prompt = '' } = {}) {611  let insight;612  if (prompt && !site) {613    const r = await run(PY, [INSIGHTS]);614    const cands = (JSON.parse(r.stdout || '{}').candidates) || [];615    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');616    const chosen = await anthropic(617      "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",618      `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);619    insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];620  } else {621    insight = await pickInsight({ site });622  }623  const video = await makeReel(insight);624  const caption = await writeCaption(insight, prompt);625  return { insight, video, caption, videoUrl: '/api/social/reel/' + path.basename(video) };626}627628export async function publishReelDraft({ caption, video }) {629  if (!caption || !video || !fs.existsSync(video)) throw new Error('brouillon reel invalide');630  const login = await fbLoginState();631  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');632  await publishReelToPage(caption, video);633  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();634  logEvent({ kind: 'published', trigger: 'reel-manuel', format: 'reel', video: path.basename(video), caption });635  return true;636}637638// cycle reel complet automatique639export async function runReelCycle(trigger = 'reel') {640  logEvent({ kind: 'cycle_start', trigger, format: 'reel' });641  try {642    const login = await fbLoginState();643    if (login !== 'LOGGED_IN') { logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', format: 'reel' }); return { ok: false, reason: 'facebook_non_connecté' }; }644    const insight = await pickInsight({});645    const video = await makeReel(insight);646    const caption = await writeCaption(insight);647    await publishReelToPage(caption, video);648    state.lastSites = [...state.lastSites, insight.site].slice(-6);649    state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();650    logEvent({ kind: 'published', trigger, format: 'reel', site: insight.site, headline: insight.headline, caption, video: path.basename(video) });651    return { ok: true, insight, caption, video };652  } catch (e) {653    logEvent({ kind: 'cycle_error', trigger, format: 'reel', error: e.message });654    return { ok: false, error: e.message };655  }656}657658// ---------- planificateur horaire ----------659function scheduleNext() {660  clearTimeout(timer);661  if (!state.auto) { state.nextAt = 0; saveState(); return; }662  const period = (CFG.socialIntervalMin || 60) * 60 * 1000;663  const since = Date.now() - (state.lastPostAt || 0);664  const wait = Math.max(60 * 1000, period - since);665  state.nextAt = Date.now() + wait; saveState();666  timer = setTimeout(async () => {667    const every = CFG.reelEvery || 0;   // 0 = jamais de reel auto ; N = 1 reel tous les N posts668    if (every > 0 && (((state.postCount || 0) + 1) % every === 0)) await runReelCycle('auto-reel');669    else await runAutoCycle('auto');670    scheduleNext();671  }, wait);672}673674export function setAuto(on) {675  state.auto = !!on; saveState();676  scheduleNext();677  logEvent({ kind: on ? 'auto_on' : 'auto_off' });678  return socialState();679}680681export function socialState() {682  return {683    auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,684    lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,685  };686}687688export function cardPath(name) {689  const safe = path.basename(name);690  const p = path.join(CARDS_DIR, safe);691  return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;692}693694export function initSocial(cfg, logger) {695  CFG = cfg || {};696  LOGGER = logger || (() => {});697  if (state.auto) scheduleNext();698  logEvent({ kind: 'init', auto: state.auto });699}700
Files · 1c6b5394eddd35a085e520678959ffbb71307f7e
Menu

Theme