SPB Git

spb/admin-ka

Public

Fix noVNC (import module statique window.KA_RFB) + reels polyvalents

- noVNC: import ESM statique en tete -> window.KA_RFB (fin de undefined is not a constructor)
- Reels genre spotlight: vraie annonce/produit avec photo (Ken Burns), legende decrivant l item
- decorateForPrompt: une demande (ex. dernier logement Lou-Ka) sort le vrai contenu, pas la stat globale
- fix count-up decimales/% (48,2 % ne devient plus 482 %)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent bfff2ef Browse files

4 changed files +89 −6

modified public/app.js +1 −1
@@ -80,7 +80,7 @@
80 statusEl.textContent = 'connexion…'; 80 statusEl.textContent = 'connexion…';
81 try { 81 try {
82 const pw = await (await fetch('/api/social/vncpw')).json(); 82 const pw = await (await fetch('/api/social/vncpw')).json();
83− const { default: RFB } = await import('/vendor/novnc/core/rfb.js'); 83+ const RFB = window.KA_RFB; if (!RFB) { statusEl.textContent = 'noVNC pas encore chargé — rafraîchis la page'; return; }
84 const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws'; 84 const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/vncws';
85 panel.style.display = 'block'; 85 panel.style.display = 'block';
86 _rfb = new RFB(panel, url, { credentials: { password: pw.password } }); 86 _rfb = new RFB(panel, url, { credentials: { password: pw.password } });
modified public/index.html +1 −0
@@ -267,5 +267,6 @@
267 <script src="/vendor/marked.min.js"></script> 267 <script src="/vendor/marked.min.js"></script>
268 <script src="/vendor/highlight.min.js"></script> 268 <script src="/vendor/highlight.min.js"></script>
269 <script src="/app.js"></script> 269 <script src="/app.js"></script>
270+<script type="module">import RFB from "/vendor/novnc/core/rfb.js?v=3"; window.KA_RFB=RFB;</script>
270 </body> 271 </body>
271 </html> 272 </html>
modified server/social.js +28 −5
@@ -485,6 +485,15 @@ async function fetchFeatured(site) {
485 return null; 485 return null;
486 } 486 }
487 487
488+async function decorateForPrompt(insight) {
489+ if (SPOT[insight.site]) {
490+ const feat = await fetchFeatured(insight.site);
491+ if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }
492+ }
493+ insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
494+ return insight;
495+}
496+
488 // choisit le genre visuel (rotation) et attache le produit vedette si spotlight 497 // choisit le genre visuel (rotation) et attache le produit vedette si spotlight
489 async function decorateCreative(insight) { 498 async function decorateCreative(insight) {
490 const n = state.postCount || 0; 499 const n = state.postCount || 0;
@@ -522,10 +531,21 @@ Règles STRICTES :
522 export async function writeCaption(insight, extraPrompt = '') { 531 export async function writeCaption(insight, extraPrompt = '') {
523 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; 532 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
524 const url = `https://www.${insight.site}.com`; 533 const url = `https://www.${insight.site}.com`;
525− const user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` + 534+ let user;
526− `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + 535+ if (insight.product && insight.genre === 'spotlight') {
527− (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + 536+ const pr = insight.product;
528− `\nRédige la publication Facebook.`; 537+ user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +
538+ `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +
539+ (pr.price ? `Prix : ${pr.price}\n` : '') + (pr.meta && pr.meta.length ? `Infos : ${pr.meta.join(', ')}\n` : '') +
540+ `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +
541+ (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +
542+ `\nRédige une publication Facebook accrocheuse qui donne envie de voir cette annonce.`;
543+ } else {
544+ user = `Fait statistique à mettre en valeur :\n${insight.fact}\n\n` +
545+ `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +
546+ (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +
547+ `\nRédige la publication Facebook.`;
548+ }
529 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500); 549 const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);
530 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com"; 550 return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";
531 } 551 }
@@ -545,10 +565,11 @@ export async function generateDraft({ site = '', prompt = '' } = {}) {
545 CFG.socialModel || 'claude-opus-4-8', 30); 565 CFG.socialModel || 'claude-opus-4-8', 30);
546 const pickSite = (chosen || '').trim().split(/\s/)[0]; 566 const pickSite = (chosen || '').trim().split(/\s/)[0];
547 insight = cands.find(c => c.site === pickSite) || cands[0]; 567 insight = cands.find(c => c.site === pickSite) || cands[0];
568+ await decorateForPrompt(insight);
548 } else { 569 } else {
549 insight = await pickInsight({ site }); 570 insight = await pickInsight({ site });
571+ await decorateCreative(insight);
550 } 572 }
551− await decorateCreative(insight);
552 const image = await renderCard(insight); 573 const image = await renderCard(insight);
553 const caption = await writeCaption(insight, prompt); 574 const caption = await writeCaption(insight, prompt);
554 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) }; 575 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };
@@ -617,8 +638,10 @@ export async function generateReelDraft({ site = '', prompt = '' } = {}) {
617 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.", 638 "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); 639 `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]; 640 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];
641+ await decorateForPrompt(insight);
620 } else { 642 } else {
621 insight = await pickInsight({ site }); 643 insight = await pickInsight({ site });
644+ await decorateForPrompt(insight);
622 } 645 }
623 const video = await makeReel(insight); 646 const video = await makeReel(insight);
624 const caption = await writeCaption(insight, prompt); 647 const caption = await writeCaption(insight, prompt);
modified server/social/make_reel.py +59 −0
@@ -27,10 +27,65 @@ PALETTES={
27 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"}, 27 "job-ka":{"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
28 } 28 }
29 def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"]) 29 def pal(s): return PALETTES.get(s, PALETTES["vrai-prix"])
30+def data_uri(url):
31+ import base64
32+ try:
33+ req=urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"})
34+ with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw=r.read()
35+ mime="image/jpeg"
36+ if url.lower().endswith(".png"): mime="image/png"
37+ elif url.lower().endswith(".webp"): mime="image/webp"
38+ return "data:"+mime+";base64,"+base64.b64encode(raw).decode()
39+ except Exception: return None
30 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;") 40 def esc(s): return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
31 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}" 41 def today(): t=datetime.date.today(); return f"{t.day} {MOIS[t.month-1]} {t.year}"
32 42
43+def build_spotlight(ins):
44+ H=HREEL; p=pal(ins["site"]); prod=ins.get("product") or {}
45+ img=prod.get("image_data",""); chips="".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or []))
46+ price=esc(prod.get("price","")); title=esc(prod.get("title","")); sub=esc(prod.get("subtitle",""))
47+ css = """
48+ *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
49+ html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__}
50+ .photo{position:absolute;top:0;left:0;right:0;height:__PH__px;background:url('__IMG__') center/cover;animation:kb 10s ease-out forwards}
51+ @keyframes kb{from{transform:scale(1.0)}to{transform:scale(1.12)}}
52+ .scrim{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.15) 0%,rgba(0,0,0,0) 30%,__G2__cc 55%,__G2__ 72%)}
53+ .eyebrow{position:absolute;top:120px;left:90px;display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;font-size:26px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .2s forwards;text-shadow:0 2px 12px #000}
54+ .eyebrow .dot{width:46px;height:6px;border-radius:3px;background:__A__}
55+ .kabadge{position:absolute;top:110px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.5);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .5s forwards;box-shadow:0 16px 40px __A__66}
56+ .info{position:absolute;left:90px;right:90px;bottom:270px}
57+ .ptitle{font-size:74px;font-weight:900;line-height:1.02;letter-spacing:-.02em;text-shadow:0 4px 24px #000a;opacity:0;transform:translateY(30px);animation:fu .9s ease 1.0s forwards}
58+ .psub{font-size:36px;font-weight:600;color:#ffffffc8;margin-top:14px;opacity:0;animation:fu .9s ease 1.25s forwards}
59+ .chips{display:flex;gap:14px;flex-wrap:wrap;margin-top:26px}
60+ .chip{background:rgba(255,255,255,.14);border:1px solid rgba(255,255,255,.22);border-radius:999px;padding:12px 24px;font-size:28px;font-weight:700;opacity:0;animation:fu .8s ease 1.45s forwards}
61+ .price{font-size:92px;font-weight:900;color:__A__;margin-top:26px;letter-spacing:-.02em;filter:drop-shadow(0 10px 30px __A__66);opacity:0;transform:scale(.9);animation:popc .8s cubic-bezier(.2,1.3,.4,1) 1.7s forwards}
62+ .cta{position:absolute;left:90px;right:90px;bottom:150px;opacity:0;animation:fu .8s ease 2.2s forwards}
63+ .cta .go{font-size:46px;font-weight:900;color:__A__}
64+ .cta .brand{font-size:30px;font-weight:700;color:#ffffff88;letter-spacing:.12em;margin-top:6px}
65+ .sig{position:absolute;left:90px;bottom:88px;font-size:26px;color:#ffffff66;font-weight:600;opacity:0;animation:fu .8s ease 2.5s forwards}
66+ @keyframes fu{to{opacity:1;transform:translateY(0)}}
67+ @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
68+ @keyframes popc{to{opacity:1;transform:scale(1)}}
69+ """
70+ css = (css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__G2__",p["g2"])
71+ .replace("__PH__",str(int(H*0.62))).replace("__IMG__",img).replace("__A__",p["a"]))
72+ body = (
73+ '<div class="photo"></div><div class="scrim"></div>'
74+ '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR</div>'
75+ '<div class="kabadge">KA</div>'
76+ '<div class="info"><div class="ptitle">'+title+'</div>'
77+ + (('<div class="psub">'+sub+'</div>') if sub else '')
78+ + '<div class="chips">'+chips+'</div>'
79+ + (('<div class="price">'+price+'</div>') if price else '')
80+ + '</div>'
81+ '<div class="cta"><div class="go">\U0001f449 www.'+ins["site"]+'.com</div><div class="brand">GROUPE ·KA</div></div>'
82+ '<div class="sig">✦ Publié par l\'Agent KA</div>'
83+ )
84+ return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>"
85+
33 def build_html(ins): 86 def build_html(ins):
87+ if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'):
88+ return build_spotlight(ins)
34 H=HREEL 89 H=HREEL
35 pt=130 if H>=1600 else 80 90 pt=130 if H>=1600 else 80
36 pb=400 if H>=1600 else 220 91 pb=400 if H>=1600 else 220
@@ -168,6 +223,10 @@ def make_music(dur, out_wav):
168 223
169 def render(ins, out_dir): 224 def render(ins, out_dir):
170 os.makedirs(out_dir,exist_ok=True) 225 os.makedirs(out_dir,exist_ok=True)
226+ prod=ins.get('product')
227+ if prod and prod.get('image') and not prod.get('image_data'):
228+ du=data_uri(prod['image'])
229+ if du: prod['image_data']=du
171 key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8] 230 key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]
172 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) 231 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
173 html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins)) 232 html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))
174 233
Menu

Theme