SPB Git

spb/admin-ka

Public
25.3 KB · 554 lines javascript
1// admin-ka — module « Social » : publication automatique horaire d'un insight2// statistique du Groupe KA sur la Page Facebook « Groupe-KA » (compte perso3// simonpboucher), + génération d'un post sur demande depuis un prompt.4//5// Pipeline : insights.py (choix du fait marquant) -> render_card.py (1 image)6// -> légende (Claude / API Anthropic) -> publication Safari (injection JS,7// zéro clavier, zéro presse-papiers système) sur la Page.8//9// Tout tourne sur le nœud M3U96a où Safari est connecté au compte Facebook.1011import fs from 'node:fs';12import path from 'node:path';13import os from 'node:os';14import crypto from 'node:crypto';15import { execFile, spawn } from 'node:child_process';16import { fileURLToPath } from 'node:url';1718const __dirname = path.dirname(fileURLToPath(import.meta.url));19const APP_DIR = path.join(__dirname, '..');20const DATA_DIR = path.join(APP_DIR, 'data');21const SOCIAL_DIR = path.join(DATA_DIR, 'social');22const CARDS_DIR = path.join(SOCIAL_DIR, 'cards');23const STATE_PATH = path.join(SOCIAL_DIR, 'state.json');24const LOG_PATH = path.join(SOCIAL_DIR, 'log.jsonl');25fs.mkdirSync(CARDS_DIR, { recursive: true });2627const PAGE_ID = '61593422723708'; // Page « Groupe-KA » du compte perso28const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;29const PY = '/opt/homebrew/bin/python3';30const INSIGHTS = path.join(__dirname, 'social', 'insights.py');31const RENDER = path.join(__dirname, 'social', 'render_card.py');32const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');3334let CFG = {};      // injecté par init()35let LOGGER = () => {};36let timer = null;3738// ---------- état persistant ----------39function loadState() {40  try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}41  return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };42}43let state = loadState();44function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }4546function logEvent(ev) {47  const line = { ts: Date.now(), ...ev };48  try { fs.appendFileSync(LOG_PATH, JSON.stringify(line) + '\n'); } catch {}49  LOGGER(line);50  return line;51}52export function socialLog(limit = 50) {53  try {54    const lines = fs.readFileSync(LOG_PATH, 'utf8').trim().split('\n').filter(Boolean);55    return lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();56  } catch { return []; }57}5859// ---------- utilitaires enfant ----------60function run(bin, args, opts = {}) {61  return new Promise((resolve) => {62    execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: 120000, killSignal: 'SIGTERM', ...opts }, (error, stdout, stderr) => {63      resolve({ error: error ? (stderr || error.message) : null, stdout: stdout || '', stderr: stderr || '' });64    });65  });66}67// mutex : sérialise TOUTES les opérations Safari (osascript concurrent = blocage)68let safariChain = Promise.resolve();69function withSafari(fn) {70  const next = safariChain.then(fn, fn);71  safariChain = next.catch(() => {});72  return next;73}74const sleep = (ms) => new Promise(r => setTimeout(r, ms));75function runInput(bin, args, input) {76  return new Promise((resolve) => {77    const ch = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });78    let out = '', err = '';79    ch.stdout.on('data', d => out += d);80    ch.stderr.on('data', d => err += d);81    ch.on('close', (code) => resolve({ error: code === 0 ? null : (err || ('exit ' + code)), stdout: out, stderr: err }));82    ch.on('error', (e) => resolve({ error: e.message, stdout: '', stderr: '' }));83    ch.stdin.write(input); ch.stdin.end();84  });85}8687// ---------- pont Safari (osascript, exécution JS lue depuis un fichier) ----------88let fbWinId = null;8990async function osaJS(jsCode) {91  // écrit le JS dans un fichier temp et l'exécute dans l'onglet FB résolu92  const tmp = path.join(os.tmpdir(), `ka-osa-${crypto.randomBytes(5).toString('hex')}.js`);93  fs.writeFileSync(tmp, jsCode);94  if (!fbWinId) { try { fs.unlinkSync(tmp); } catch {} throw new Error('fenêtre Facebook non résolue'); }95  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`;96  const r = await run('/usr/bin/osascript', ['-e', apple]);97  try { fs.unlinkSync(tmp); } catch {}98  if (r.error) throw new Error('osascript: ' + r.error.trim());99  return r.stdout.trim();100}101102async function resolveFbWindow() {103  // trouve (ou crée) une fenêtre Safari dont l'onglet 1 est sur facebook.com104  const findScript = `105tell application "Safari"106  with timeout of 15 seconds107    set out to ""108    repeat with w in windows109      try110        set u to URL of tab 1 of w111        if u contains "facebook.com" then return (id of w as string)112      end try113    end repeat114    return "NONE"115  end timeout116end tell`;117  let r = await run('/usr/bin/osascript', ['-e', findScript]);118  let id = (r.stdout || '').trim();119  const findDbg = 'find[out=' + JSON.stringify(id) + ' err=' + JSON.stringify(r.error || '') + ']';120  if (id && id !== 'NONE' && /^[0-9]+$/.test(id)) { fbWinId = parseInt(id, 10); return fbWinId; }121  // aucune : ouvrir la page dans une nouvelle fenêtre122  const openScript = `123tell application "Safari"124  with timeout of 25 seconds125    make new document with properties {URL:"${PAGE_URL}"}126    delay 2127    return (id of window 1 as string)128  end timeout129end tell`;130  r = await run('/usr/bin/osascript', ['-e', openScript]);131  const nid = (r.stdout || '').trim();132  fbWinId = /^[0-9]+$/.test(nid) ? parseInt(nid, 10) : null;133  if (!fbWinId) throw new Error('ouverture FB KO — ' + findDbg + ' open[out=' + JSON.stringify(nid) + ' err=' + JSON.stringify(r.error || '') + ']');134  return fbWinId;135}136137async function gotoPage() {138  await resolveFbWindow();139  await run('/usr/bin/osascript', ['-e',140    `tell application "Safari"\nwith timeout of 20 seconds\nset URL of tab 1 of window id ${fbWinId} to "${PAGE_URL}"\nend timeout\nend tell`]);141}142143// ferme les boîtes FB non bloquantes (« Plus tard », boost, onboarding…)144const DISMISS_JS = `(function(){145  var labels=['Plus tard','Pas maintenant','Non merci','Ignorer','Peut-être plus tard','Fermer','Terminer','OK','Continuer','Passer'];146  var clicked='';147  document.querySelectorAll('div[role=dialog] [role=button], [aria-label]').forEach(function(e){148    if(clicked) return;149    var t=(e.innerText||'').trim(); var al=e.getAttribute('aria-label')||'';150    for(var i=0;i<labels.length;i++){151      if(t===labels[i] || al===labels[i] || al==='Fermer' || al==='Fermer la boîte de dialogue'){152        ['mousedown','mouseup','click'].forEach(function(ev){e.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});153        clicked=labels[i]||al; break;154      }155    }156  });157  return clicked||'NONE';158})();`;159160async function pollJS(jsCode, want, tries = 12, delay = 4000) {161  let last = '';162  for (let i = 0; i < tries; i++) {163    try { last = await osaJS(jsCode); } catch (e) { last = 'ERR:' + e.message; }164    if (want(last)) return last;165    await sleep(delay);166  }167  return last;168}169170async function dismissModals(times) {171  times = times || 3;172  let any = false;173  for (let i = 0; i < times; i++) {174    let r = 'NONE';175    try { r = await osaJS(DISMISS_JS); } catch {}176    if (r && r !== 'NONE') { any = true; await sleep(1500); } else break;177  }178  return any;179}180181// vérifie que Facebook est connecté (sinon, l'auto-post est mis en pause)182export function fbLoginState() { return withSafari(_fbLoginState); }183async function _fbLoginState() {184  await resolveFbWindow();185  await run('/usr/bin/osascript', ['-e',186    `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`]);187  await sleep(6000);188  const js = `(function(){var b=document.body.innerText||"";` +189    `var login=/Adresse e-mail|Adresse courriel|Mot de passe|Se connecter|Create new account/i.test(b.slice(0,700));` +190    `var me=/Simon-pierre|Meta AI|Tableau de bord|Nombre de notifications/i.test(b);` +191    `return (me?"LOGGED_IN":(login?"LOGIN_PAGE":"UNKNOWN"));})();`;192  const out = await pollJS(js, v => v === 'LOGGED_IN' || v === 'LOGIN_PAGE', 4, 4000);193  return out;194}195196// ---------- publication (texte + 1 image) sur la Page ----------197export function publishToPage(caption, imagePath) { return withSafari(() => _publishToPage(caption, imagePath)); }198async function _publishToPage(caption, imagePath) {199  await gotoPage();200  await sleep(9000);201202  // 1) ouvrir le composeur203  const openComposer = `(function(){204    var target=null;205    document.querySelectorAll('span,div').forEach(function(e){206      var t=(e.innerText||'').trim();207      if(!target && e.children.length===0 &&208        (t.indexOf('Présentez votre marque')===0 || t.indexOf('Que voulez-vous dire')===0 ||209         t.indexOf('Quoi de neuf')===0 || t.indexOf('Exprimez-vous')===0)) target=e;210    });211    if(!target) return 'NOT_FOUND';212    var btn=target.closest('[role=button]'); if(!btn) return 'NO_BTN';213    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});214    return 'opened';215  })();`;216  // La Page peut s'ouvrir en vue « Gérer la Page » (contexte profil perso) sans217  // composeur : cliquer « Basculer » pour agir en tant que Page, puis réessayer.218  const basculer = `(function(){219    var btn=null;220    document.querySelectorAll('[role=button]').forEach(function(e){221      var t=(e.innerText||'').trim();222      if(!btn && (t==='Basculer' || t.indexOf('Basculer sur la Page')===0)) btn=e;223    });224    if(!btn) return 'NO_BTN';225    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});226    return 'switched';227  })();`;228  let r1 = await pollJS(openComposer, v => v === 'opened', 4, 4000);229  if (r1 !== 'opened') {230    const sw = await osaJS(basculer);231    if (sw === 'switched') { await sleep(9000); await dismissModals(3); }232    r1 = await pollJS(openComposer, v => v === 'opened', 8, 5000);233  }234  if (r1 !== 'opened') throw new Error('composeur introuvable (' + r1 + ')');235  await sleep(3000);236237  // 2) coller la légende via ClipboardEvent synthétique (base64, sans clavier)238  const b64cap = Buffer.from(caption, 'utf8').toString('base64');239  const pasteJS = `(function(){240    var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return 'NO_DIALOG';241    var box=dlg.querySelector('div[contenteditable=true][role=textbox]'); if(!box) return 'NO_BOX';242    box.focus();243    var txt=decodeURIComponent(escape(atob('${b64cap}')));244    var dt=new DataTransfer(); dt.setData('text/plain', txt);245    box.dispatchEvent(new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true}));246    return 'pasted';247  })();`;248  const checkLen = `(function(){var dlg=document.querySelector('div[role=dialog]');` +249    `var box=dlg&&dlg.querySelector('div[contenteditable=true][role=textbox]');` +250    `return 'len:'+(box?box.innerText.length:-1);})();`;251  let len = 0;252  for (let i = 0; i < 3; i++) {253    await osaJS(pasteJS); await sleep(2500);254    const l = await osaJS(checkLen); len = parseInt((l.split(':')[1] || '0'), 10);255    if (len > 40) break; await sleep(1500);256  }257  if (!(len > 40)) throw new Error('collage légende échoué');258259  // 3) injecter l'image dans l'input file du composeur (File construit en JS)260  const imgB64 = fs.readFileSync(imagePath).toString('base64');261  const injectJS = `(function(){262    var inp=document.querySelector('div[role=dialog] input[type=file]') ||263            document.querySelector('input[type=file][accept*="image"]') ||264            document.querySelector('input[type=file]');265    if(!inp) return 'NO_INPUT';266    try{267      var bin=atob('${imgB64}'); var len=bin.length; var arr=new Uint8Array(len);268      for(var i=0;i<len;i++) arr[i]=bin.charCodeAt(i);269      var file=new File([arr], 'ka-stat.png', {type:'image/png'});270      var dt=new DataTransfer(); dt.items.add(file);271      inp.files=dt.files;272      inp.dispatchEvent(new Event('change',{bubbles:true}));273      return 'injected';274    }catch(e){ return 'ERR:'+e.message; }275  })();`;276  const inj = await osaJS(injectJS);277  if (inj !== 'injected') {278    // repli : bouton Photo/Vidéo puis re-tenter l'injection279    const clickPhoto = `(function(){var btn=null;280      document.querySelectorAll('div[role=dialog] [role=button]').forEach(function(b){281        if((b.getAttribute('aria-label')||'')==='Photo/Vidéo') btn=b;});282      if(!btn) return 'NO_BTN';283      ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});284      return 'clicked';})();`;285    await osaJS(clickPhoto); await sleep(2500);286    const inj2 = await osaJS(injectJS);287    if (inj2 !== 'injected') throw new Error('injection image échouée (' + inj + ' / ' + inj2 + ')');288  }289290  // 4) attendre l'aperçu de l'image (une <img> blob: dans le dialogue)291  const previewJS = `(function(){var dlg=document.querySelector('div[role=dialog]'); if(!dlg) return '0';292    var n=0; dlg.querySelectorAll('img').forEach(function(im){if(/^blob:|^data:/.test(im.src)) n++;});293    return ''+n;})();`;294  const prev = await pollJS(previewJS, v => parseInt(v, 10) >= 1, 12, 4000);295  if (!(parseInt(prev, 10) >= 1)) throw new Error('aperçu image absent');296  await sleep(2000);297298  // 5) Suivant (si présent) puis Publier299  const clickBtn = (label) => `(function(){300    var dlgs=document.querySelectorAll('div[role=dialog]'); var btn=null;301    dlgs.forEach(function(dlg){dlg.querySelectorAll('[role=button]').forEach(function(b){302      if((b.getAttribute('aria-label')||'')===${JSON.stringify(label)}) btn=b;});});303    if(!btn) return 'NO_BTN';304    if(btn.getAttribute('aria-disabled')==='true') return 'DISABLED';305    ['mousedown','mouseup','click'].forEach(function(ev){btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));});306    return 'clicked';})();`;307  const nx = await osaJS(clickBtn('Suivant'));308  if (nx === 'clicked') await sleep(7000);309  const pub = await pollJS(clickBtn('Publier'), v => v === 'clicked', 6, 4000);310  if (pub !== 'clicked') throw new Error('bouton Publier indisponible (' + pub + ')');311312  // 6) attendre la disparition du COMPOSEUR (= publication soumise). Une boîte313  // « Plus tard » / boost peut apparaître ensuite : on la ferme, elle n'indique314  // PAS un échec. On juge le succès sur l'absence de la zone de texte du composeur.315  const composerGoneJS = `(function(){316    var open=false;317    document.querySelectorAll('div[role=dialog]').forEach(function(d){318      if(d.querySelector('div[contenteditable=true][role=textbox]')) open=true;319    });320    return open ? 'OPEN' : 'GONE';321  })();`;322  let gone = 'OPEN';323  for (let i = 0; i < 18; i++) {324    await sleep(8000);325    await dismissModals(2);                 // ferme toute boîte non bloquante en route326    try { gone = await osaJS(composerGoneJS); } catch (e) { gone = 'ERR'; }327    if (gone === 'GONE') break;328  }329  await dismissModals(3);                    // ferme la boîte « Plus tard » finale330  if (gone !== 'GONE') throw new Error('composeur toujours ouvert après Publier (doute — ne pas retenter)');331  return true;332}333334// ---------- génération d'un brouillon (insight -> image + légende) ----------335export async function pickInsight({ site = '', excludeRecent = true } = {}) {336  const args = [INSIGHTS];337  if (site) args.push('--site', site);338  else if (excludeRecent && state.lastSites.length) args.push('--exclude', state.lastSites.slice(-3).join(','));339  const r = await run(PY, args);340  if (r.error) throw new Error('insights.py: ' + r.error);341  const data = JSON.parse(r.stdout);342  const cand = (data.candidates || [])[0];343  if (!cand) throw new Error('aucun insight disponible');344  return cand;345}346347export async function renderCard(insight) {348  // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec349  let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));350  let p = (r.stdout || '').trim().split('\n').pop();351  if (r.error || !p || !fs.existsSync(p)) {352    logEvent({ kind: 'render_fallback', site: insight.site, err: (r.error || '').slice(0, 200) });353    r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));354    p = (r.stdout || '').trim().split('\n').pop();355    if (r.error) throw new Error('render: ' + r.error);356  }357  if (!p || !fs.existsSync(p)) throw new Error('image non produite');358  return p;359}360361// --- produit vedette (photo réelle) pour le genre « spotlight » ---362const SPOT = {363  'lou-ka':   { url: 'https://www.lou-ka.com/api/listings?limit=24',  arr: 'listings', minPrice: 800 },364  'immo-ka':  { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },365  'fabri-ka': { url: 'https://www.fabri-ka.com/api/products?per_page=24', arr: 'items', minPrice: 0 },366  'sorti-ka': { url: 'https://www.sorti-ka.com/api/events?limit=24',  arr: 'events', minPrice: 0 },367};368async function fetchFeatured(site) {369  const cfg = SPOT[site]; if (!cfg) return null;370  try {371    const res = await fetch(cfg.url, { signal: AbortSignal.timeout(12000) });372    const data = await res.json();373    const arr = data[cfg.arr] || [];374    const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');375    for (const it of arr) {376      const img = (it.images && it.images[0]) || it.image || '';377      const title = (it.title || it.name || '').trim();378      if (!img || !title || title.length > 80) continue;379      const price = it.price || it.price_min || null;380      if (cfg.minPrice && (!price || price < cfg.minPrice)) continue;381      if (site === 'lou-ka') {382        return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '),383          price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img,384          meta: [it.unit_type || 'Logement', it.city || ''].filter(Boolean) };385      }386      if (site === 'immo-ka') {387        const vp = it.vraiprix && it.vraiprix.value ? 'Vrai-Prix ' + fr(it.vraiprix.value) + ' $' : null;388        return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '),389          price: it.price_label || (price ? fr(price) + ' $' : ''), image: img,390          meta: [it.bedrooms ? it.bedrooms + ' ch' : 'Propriété', vp].filter(Boolean) };391      }392      if (site === 'fabri-ka') {393        return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '),394          price: it.price_label || (price ? fr(price) + ' $' : 'Voir le prix'), image: img,395          meta: [it.category || 'Fait au Québec', it.store_city || ''].filter(Boolean) };396      }397      if (site === 'sorti-ka') {398        return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '),399          price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img,400          meta: [it.city || 'Québec', it.is_free ? 'Entrée libre' : 'À l\'agenda'].filter(Boolean) };401      }402    }403  } catch {}404  return null;405}406407// choisit le genre visuel (rotation) et attache le produit vedette si spotlight408async function decorateCreative(insight) {409  const n = state.postCount || 0;410  let genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';411  if (SPOT[insight.site] && (n % 3 === 0)) {412    const feat = await fetchFeatured(insight.site);413    if (feat) { insight.product = feat; genre = 'spotlight'; }414  }415  insight.genre = genre;416  return insight;417}418419// légende via API Anthropic (le modèle Claude rédige à partir du fait)420async function anthropic(system, user, model, maxTokens = 500) {421  const key = CFG.anthropicKey;422  if (!key) throw new Error('Clé Anthropic non configurée');423  const resp = await fetch('https://api.anthropic.com/v1/messages', {424    method: 'POST',425    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },426    body: JSON.stringify({ model, max_tokens: maxTokens, system, messages: [{ role: 'user', content: user }] }),427  });428  const data = await resp.json();429  if (data.error) throw new Error(data.error.message || 'Erreur Anthropic');430  return (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('').trim();431}432433const 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.434435Règles STRICTES :436- 1 seule publication, 40 à 90 mots.437- Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).438- Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).439- 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).`;440441export async function writeCaption(insight, extraPrompt = '') {442  const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';443  const url = `https://www.${insight.site}.com`;444  const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +445    `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +446    (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +447    `\nRédige la publication Facebook.`;448  const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);449  return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";450}451452// brouillon complet : insight -> image + légende (sans publier)453export async function generateDraft({ site = '', prompt = '' } = {}) {454  let insight;455  if (prompt && !site) {456    // laisser le modèle choisir le site le plus pertinent pour ce prompt457    const r = await run(PY, [INSIGHTS]);458    const data = JSON.parse(r.stdout || '{}');459    const cands = data.candidates || [];460    const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');461    const chosen = await anthropic(462      "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.",463      `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,464      CFG.socialModel || 'claude-opus-4-8', 30);465    const pickSite = (chosen || '').trim().split(/\s/)[0];466    insight = cands.find(c => c.site === pickSite) || cands[0];467  } else {468    insight = await pickInsight({ site });469  }470  await decorateCreative(insight);471  const image = await renderCard(insight);472  const caption = await writeCaption(insight, prompt);473  return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };474}475476// cycle complet automatique : choisir -> rendre -> rédiger -> publier477export async function runAutoCycle(trigger = 'auto') {478  const started = logEvent({ kind: 'cycle_start', trigger });479  try {480    const login = await fbLoginState();481    if (login !== 'LOGGED_IN') {482      logEvent({ kind: 'cycle_skip', reason: 'facebook_non_connecté', login });483      return { ok: false, reason: 'facebook_non_connecté' };484    }485    const insight = await pickInsight({});486    await decorateCreative(insight);487    const image = await renderCard(insight);488    const caption = await writeCaption(insight);489    await publishToPage(caption, image);490    state.lastSites = [...state.lastSites, insight.site].slice(-6);491    state.lastPostAt = Date.now();492    state.postCount = (state.postCount || 0) + 1;493    saveState();494    logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,495      headline: insight.headline, caption, image: path.basename(image) });496    return { ok: true, insight, caption, image };497  } catch (e) {498    logEvent({ kind: 'cycle_error', trigger, error: e.message });499    return { ok: false, error: e.message };500  }501}502503// publier un brouillon fourni (post sur demande)504export async function publishDraft({ caption, image }) {505  if (!caption || !image || !fs.existsSync(image)) throw new Error('brouillon invalide');506  const login = await fbLoginState();507  if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');508  await publishToPage(caption, image);509  state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();510  logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });511  return true;512}513514// ---------- planificateur horaire ----------515function scheduleNext() {516  clearTimeout(timer);517  if (!state.auto) { state.nextAt = 0; saveState(); return; }518  const period = (CFG.socialIntervalMin || 60) * 60 * 1000;519  const since = Date.now() - (state.lastPostAt || 0);520  const wait = Math.max(60 * 1000, period - since);521  state.nextAt = Date.now() + wait; saveState();522  timer = setTimeout(async () => {523    await runAutoCycle('auto');524    scheduleNext();525  }, wait);526}527528export function setAuto(on) {529  state.auto = !!on; saveState();530  scheduleNext();531  logEvent({ kind: on ? 'auto_on' : 'auto_off' });532  return socialState();533}534535export function socialState() {536  return {537    auto: state.auto, nextAt: state.nextAt, lastPostAt: state.lastPostAt,538    lastSites: state.lastSites, intervalMin: CFG.socialIntervalMin || 60,539  };540}541542export function cardPath(name) {543  const safe = path.basename(name);544  const p = path.join(CARDS_DIR, safe);545  return p.startsWith(CARDS_DIR) && fs.existsSync(p) ? p : null;546}547548export function initSocial(cfg, logger) {549  CFG = cfg || {};550  LOGGER = logger || (() => {});551  if (state.auto) scheduleNext();552  logEvent({ kind: 'init', auto: state.auto });553}554
Files · 173c601f05ae672cef99888f601a44068c691f29
Menu

Theme