SPB Git forge

spb/admin-ka

Public
41commits 1branches 0releases
172.9 MBsize
maindefault branch
19 days agolast push
JavaScript 65.5% Python 17.8% CSS 13% HTML 3.7%

Studio MAJEUR: suit le prompt (plan Claude site+mode) + mode LISTE (plusieurs vrais items avec photos) pour image et reel ; fetchItems robuste (repli), legende liste

SPB committed 1 mo ago (Aug 23, 2026) parent 6e3e330

3 changed files +195 −24

modified server/social.js +97 −23
@@ -452,6 +452,7 @@ export async function renderCard(insight) {
452 452 }
453 453
454 454 // --- produit vedette (photo réelle) pour le genre « spotlight » ---
455 +const KNOWN_SITES = ['lou-ka','immo-ka','vrai-prix','auto-ka','food-ka','fabri-ka','resto-ka','sorti-ka','crea-ka','job-ka','trouve-ka'];
455 456 const SPOT = {
456 457 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 },
457 458 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 },
@@ -497,6 +498,82 @@ async function fetchFeatured(site) {
497 498 return null;
498 499 }
499 500
501 +// ---- listes (plusieurs items réels) ----
502 +const LIST_TITLES = {
503 + 'lou-ka': 'Les derniers logements à louer', 'immo-ka': 'Les dernières propriétés à vendre',
504 + 'fabri-ka': 'Nouveautés québécoises', 'sorti-ka': 'Les prochains événements',
505 +};
506 +function mapItem(site, it, fr) {
507 + const img = (it.images && it.images[0]) || it.image || '';
508 + const title = (it.title || it.name || '').trim();
509 + if (!img || !title) return null;
510 + const price = it.price || it.price_min || null;
511 + if (site === 'lou-ka') return { title, subtitle: [it.sector, it.city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $/mois' : ''), image: img, _price: price };
512 + if (site === 'immo-ka') return { title, subtitle: [it.city, it.property_type].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };
513 + if (site === 'fabri-ka') return { title, subtitle: [it.store_name, it.store_city].filter(Boolean).join(' · '), price: it.price_label || (price ? fr(price) + ' $' : ''), image: img, _price: price };
514 + if (site === 'sorti-ka') return { title, subtitle: [it.city, it.venue].filter(Boolean).join(' · '), price: it.is_free ? 'GRATUIT' : (it.price_label || ''), image: img, _price: price };
515 + return { title, subtitle: it.city || '', price: it.price_label || '', image: img, _price: price };
516 +}
517 +const LIST_CFG = {
518 + 'lou-ka': { n: 60, min: 500, max: 6000 },
519 + 'immo-ka': { n: 300, min: 200000, max: 9000000 }, // feed trié par prix croissant -> chercher large
520 + 'fabri-ka': { n: 60, min: 0, max: 0 },
521 + 'sorti-ka': { n: 60, min: 0, max: 0 },
522 +};
523 +async function fetchItems(site, count) {
524 + const cfg = SPOT[site]; if (!cfg) return null;
525 + const lc = LIST_CFG[site] || { n: 60, min: 0, max: 0 };
526 + const listMin = lc.min, listMax = lc.max;
527 + const url = cfg.url.replace(/(limit|per_page)=\d+/, '$1=' + lc.n);
528 + try {
529 + const res = await fetch(url, { signal: AbortSignal.timeout(14000) });
530 + const data = await res.json();
531 + const arr = data[cfg.arr] || [];
532 + const fr = (n) => new Intl.NumberFormat('fr-CA').format(Math.round(n)).replace(/,/g, ' ');
533 + const valid = [];
534 + for (const it of arr) {
535 + const row = mapItem(site, it, fr);
536 + if (!row || !row.image || !row.title) continue;
537 + if (row.title.length > 64) row.title = row.title.slice(0, 62) + '…';
538 + valid.push(row);
539 + }
540 + if (valid.length < 2) return null;
541 + // préférence : items dans la fourchette de prix ; sinon repli sur les plus chers
542 + let pick = valid.filter(r => (!listMin || (r._price && r._price >= listMin)) && (!listMax || !r._price || r._price <= listMax));
543 + if (pick.length < Math.min(count, 3)) pick = valid.slice().sort((a, b) => (b._price || 0) - (a._price || 0));
544 + pick = pick.slice(0, count);
545 + pick.forEach(r => delete r._price);
546 + return pick.length >= 2 ? pick : null;
547 + } catch { return null; }
548 +}
549 +// analyse le prompt -> {site, mode, count}
550 +async function planFromPrompt(prompt) {
551 + const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
552 + const sys = "Tu es le planificateur du Studio KA. À partir d'une demande, renvoie UNIQUEMENT un objet JSON (rien d'autre) : {\"site\":\"<id ou vide>\",\"mode\":\"stat|spotlight|list\",\"count\":<1-6>}.\n" +
553 + "Sites: lou-ka (logements à louer), immo-ka (propriétés à vendre), vrai-prix (évaluations), auto-ka (autos occasion), food-ka (épicerie/soldes), fabri-ka (produits québécois), resto-ka (restos), sorti-ka (événements), crea-ka (créateurs), job-ka (emplois), trouve-ka (recherche web).\n" +
554 + "Modes: 'stat' = un chiffre marquant ; 'spotlight' = UNE annonce/produit en vedette avec photo ; 'list' = une LISTE de plusieurs derniers items (logements, propriétés, produits, événements).\n" +
555 + "Règles: 'liste/derniers/plusieurs/top N/montre' => mode=list (count = N demandé ou 5). 'dernier/une annonce/en vedette' => spotlight. Sinon stat. list & spotlight ne valent que pour lou-ka, immo-ka, fabri-ka, sorti-ka ; sinon stat. Choisis le site le plus pertinent.";
556 + try {
557 + const raw = await anthropic(sys, "Demande : " + prompt, model, 120);
558 + const m = raw.match(/\{[\s\S]*\}/);
559 + const plan = JSON.parse(m ? m[0] : raw);
560 + return { site: (plan.site || '').trim(), mode: plan.mode || 'stat', count: Math.min(6, Math.max(2, plan.count || 5)) };
561 + } catch { return { site: '', mode: 'stat', count: 5 }; }
562 +}
563 +// applique un plan à un insight (list / spotlight / stat) + repli
564 +async function decoratePlan(insight, plan) {
565 + if (plan.mode === 'list' && SPOT[insight.site]) {
566 + const items = await fetchItems(insight.site, plan.count);
567 + if (items && items.length >= 2) { insight.list = items; insight.genre = 'list'; insight.list_title = LIST_TITLES[insight.site] || insight.headline_label; return insight; }
568 + }
569 + if ((plan.mode === 'list' || plan.mode === 'spotlight') && SPOT[insight.site]) {
570 + const feat = await fetchFeatured(insight.site);
571 + if (feat) { insight.product = feat; insight.genre = 'spotlight'; return insight; }
572 + }
573 + insight.genre = (insight.bars && insight.bars.length >= 4) ? 'ranking' : 'hero';
574 + return insight;
575 +}
576 +
500 577 async function decorateForPrompt(insight) {
501 578 if (SPOT[insight.site]) {
502 579 const feat = await fetchFeatured(insight.site);
@@ -544,7 +621,14 @@ export async function writeCaption(insight, extraPrompt = '') {
544 621 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
545 622 const url = `https://www.${insight.site}.com`;
546 623 let user;
547 if (insight.product && insight.genre === 'spotlight') {
624 + if (insight.list && insight.genre === 'list') {
625 + const lignes = insight.list.slice(0, 5).map(x => '• ' + x.title + (x.price ? ' — ' + x.price : '') + (x.subtitle ? ' (' + x.subtitle + ')' : '')).join('\n');
626 + user = `Présente CETTE liste réelle de ${insight.label} (n'invente rien, appuie-toi sur ces items) :\n` +
627 + `${insight.list_title || ''}\n${lignes}\n\n` +
628 + `Site : ${insight.label} (${url})\nAngle : ${insight.tagline || ''}\n` +
629 + (extraPrompt ? `\nConsigne de l'administrateur : ${extraPrompt}\n` : '') +
630 + `\nRédige une publication Facebook qui donne envie de parcourir cette sélection (tu peux mentionner 2-3 items, sans tout lister).`;
631 + } else if (insight.product && insight.genre === 'spotlight') {
548 632 const pr = insight.product;
549 633 user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` +
550 634 `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') +
@@ -565,19 +649,12 @@ export async function writeCaption(insight, extraPrompt = '') {
565 649 // brouillon complet : insight -> image + légende (sans publier)
566 650 export async function generateDraft({ site = '', prompt = '' } = {}) {
567 651 let insight;
568 if (prompt && !site) {
569 // laisser le modèle choisir le site le plus pertinent pour ce prompt
570 const r = await run(PY, [INSIGHTS]);
571 const data = JSON.parse(r.stdout || '{}');
572 const cands = data.candidates || [];
573 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');
574 const chosen = await anthropic(
575 "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.",
576 `Demande : ${prompt}\n\nInsights disponibles :\n${list}`,
577 CFG.socialModel || 'claude-opus-4-8', 30);
578 const pickSite = (chosen || '').trim().split(/\s/)[0];
579 insight = cands.find(c => c.site === pickSite) || cands[0];
580 await decorateForPrompt(insight);
652 + if (prompt) {
653 + const plan = await planFromPrompt(prompt);
654 + if (site) plan.site = site;
655 + const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';
656 + insight = await pickInsight({ site: chosenSite });
657 + await decoratePlan(insight, plan);
581 658 } else {
582 659 insight = await pickInsight({ site });
583 660 await decorateCreative(insight);
@@ -644,15 +721,12 @@ export function reelPath(name) {
644 721 // brouillon reel : insight -> vidéo + légende (sans publier)
645 722 export async function generateReelDraft({ site = '', prompt = '' } = {}) {
646 723 let insight;
647 if (prompt && !site) {
648 const r = await run(PY, [INSIGHTS]);
649 const cands = (JSON.parse(r.stdout || '{}').candidates) || [];
650 const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n');
651 const chosen = await anthropic(
652 "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.",
653 `Demande : ${prompt}\n\nInsights :\n${list}`, CFG.socialModel || 'claude-opus-4-8', 30);
654 insight = cands.find(c => c.site === (chosen || '').trim().split(/\s/)[0]) || cands[0];
655 await decorateForPrompt(insight);
724 + if (prompt) {
725 + const plan = await planFromPrompt(prompt);
726 + if (site) plan.site = site;
727 + const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : '';
728 + insight = await pickInsight({ site: chosenSite });
729 + await decoratePlan(insight, plan);
656 730 } else {
657 731 insight = await pickInsight({ site });
658 732 await decorateForPrompt(insight);
modified server/social/make_reel.py +60 −0
@@ -83,7 +83,63 @@ def build_spotlight(ins):
83 83 )
84 84 return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>"
85 85
86 +def build_list(ins):
87 + H=HREEL; p=pal(ins["site"]); items=ins.get("list") or []
88 + rows=""
89 + for idx,it in enumerate(items[:5]):
90 + img=it.get("image_data") or ""
91 + thumb='<div class="lthumb" style="background-image:url(\'' + img + '\')"></div>' if img else '<div class="lthumb"></div>'
92 + price=esc(it.get("price","") or ""); pr=('<div class="lp">' + price + '</div>') if price else ''
93 + rows+=('<div class="lrow" style="animation-delay:' + str(round(1.0+idx*0.2,2)) + 's">' + thumb
94 + + '<div class="lmid"><div class="lt">' + esc(it.get("title","")) + '</div>'
95 + + '<div class="ls">' + esc(it.get("subtitle","") or "") + '</div></div>' + pr + '</div>')
96 + heading=esc(ins.get("list_title","") or ins.get("headline_label","") or "À la une")
97 + css="""
98 + *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
99 + html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff}
100 + .stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
101 + background:radial-gradient(1300px 900px at 78% -5%, __A__55,transparent 55%),
102 + radial-gradient(1100px 1000px at -12% 105%, __B__3a,transparent 55%),
103 + linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 14s ease-in-out infinite alternate}
104 + @keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}}
105 + .pad{position:absolute;inset:0;padding:120px 80px 210px;display:flex;flex-direction:column}
106 + .eyebrow{display:flex;align-items:center;gap:16px;font-weight:800;letter-spacing:.16em;font-size:27px;text-transform:uppercase;color:__A__;opacity:0;animation:fu .8s ease .15s forwards}
107 + .eyebrow .dot{width:52px;height:6px;border-radius:3px;background:__A__}
108 + .kabadge{position:absolute;top:118px;right:80px;background:__A__;color:#0d0d0d;font-weight:900;font-size:52px;padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards}
109 + .site{font-size:82px;font-weight:900;letter-spacing:-.02em;margin-top:28px;line-height:1;opacity:0;animation:fu .8s ease .45s forwards}
110 + .ltitle{font-size:46px;font-weight:800;margin-top:10px;margin-bottom:26px;max-width:920px;line-height:1.1;opacity:0;animation:fu .8s ease .65s forwards}
111 + .lwrap{display:flex;flex-direction:column;gap:18px}
112 + .lrow{display:flex;align-items:center;gap:22px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:24px;padding:18px 20px;opacity:0;transform:translateY(30px);animation:fu .7s ease forwards;backdrop-filter:blur(6px)}
113 + .lthumb{width:150px;height:150px;flex:0 0 150px;border-radius:18px;background-color:#222;background-size:cover;background-position:center}
114 + .lmid{flex:1;min-width:0}
115 + .lt{font-size:40px;font-weight:800;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
116 + .ls{font-size:30px;font-weight:600;color:#ffffffa8;margin-top:8px}
117 + .lp{font-size:46px;font-weight:900;color:__A__;white-space:nowrap;text-align:right}
118 + .cta{position:absolute;left:80px;right:80px;bottom:150px;opacity:0;animation:fu .8s ease 2.4s forwards}
119 + .cta .go{font-size:46px;font-weight:900;color:__A__}
120 + .cta .brand{font-size:30px;font-weight:700;color:#ffffff8c;letter-spacing:.12em;margin-top:6px}
121 + .sig{position:absolute;left:80px;bottom:92px;font-size:26px;color:#ffffff6a;font-weight:600;opacity:0;animation:fu .8s ease 2.7s forwards}
122 + .prog{position:absolute;left:0;bottom:0;height:8px;background:__A__;width:0;animation:prog __DUR__s linear .2s forwards;box-shadow:0 0 20px __A__}
123 + @keyframes prog{to{width:100%}}
124 + @keyframes fu{to{opacity:1;transform:translateY(0)}}
125 + @keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
126 + """
127 + css=(css.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
128 + .replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"]).replace("__DUR__",str(DUR)))
129 + body=('<div class="stage"><div class="pad">'
130 + + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · ' + today().upper() + '</div>'
131 + + '<div class="kabadge">KA</div>'
132 + + '<div class="site">' + esc(ins["label"]) + '</div>'
133 + + '<div class="ltitle">' + heading + '</div>'
134 + + '<div class="lwrap">' + rows + '</div>'
135 + + '<div class="cta"><div class="go">\U0001f449 www.' + ins["site"] + '.com</div><div class="brand">GROUPE ·KA</div></div>'
136 + + '<div class="sig">✦ Rédigé et publié par l\'Agent KA</div><div class="prog"></div>'
137 + + '</div></div>')
138 + return "<!doctype html><html><head><meta charset=\"utf-8\"><style>" + css + "</style></head><body>" + body + "</body></html>"
139 +
86 140 def build_html(ins):
141 + if ins.get('genre')=='list' and ins.get('list'):
142 + return build_list(ins)
87 143 if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'):
88 144 return build_spotlight(ins)
89 145 H=HREEL; p=pal(ins["site"])
@@ -252,6 +308,10 @@ def render(ins, out_dir):
252 308 if prod and prod.get('image') and not prod.get('image_data'):
253 309 du=data_uri(prod['image'])
254 310 if du: prod['image_data']=du
311 + for it in (ins.get('list') or []):
312 + if it.get('image') and not it.get('image_data'):
313 + du=data_uri(it['image'])
314 + if du: it['image_data']=du
255 315 key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8]
256 316 tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
257 317 html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins))
modified server/social/render_card_html.py +38 −1
@@ -206,14 +206,47 @@ def genre_spotlight(ins, p):
206 206 .tagline2{{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}}
207 207 </style>"""
208 208
209 +def genre_list(ins, p):
210 + items = ins.get("list") or []
211 + rows = ""
212 + for it in items[:5]:
213 + img = it.get("image_data") or ""
214 + thumb = '<div class="lthumb" style="background-image:url(\'' + img + '\')"></div>' if img else '<div class="lthumb"></div>'
215 + price = esc(it.get("price", "") or "")
216 + pr = ('<div class="lp">' + price + '</div>') if price else ''
217 + rows += ('<div class="lrow">' + thumb
218 + + '<div class="lmid"><div class="lt">' + esc(it.get("title", "")) + '</div>'
219 + + '<div class="ls">' + esc(it.get("subtitle", "") or "") + '</div></div>'
220 + + pr + '</div>')
221 + heading = esc(ins.get("list_title", "") or ins.get("headline_label", "") or "À la une")
222 + css = """
223 + .ltitle{font-size:40px;font-weight:800;color:#fff;margin-top:8px;margin-bottom:18px;max-width:920px;line-height:1.1}
224 + .lwrap{display:flex;flex-direction:column;gap:15px}
225 + .lrow{display:flex;align-items:center;gap:20px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:15px 18px}
226 + .lthumb{width:118px;height:118px;flex:0 0 118px;border-radius:14px;background-color:#222;background-size:cover;background-position:center;box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}
227 + .lmid{flex:1;min-width:0}
228 + .lt{font-size:32px;font-weight:800;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
229 + .ls{font-size:25px;font-weight:600;color:#ffffffa0;margin-top:6px}
230 + .lp{font-size:36px;font-weight:900;color:__A__;white-space:nowrap;text-align:right}
231 + """.replace("__A__", p["a"])
232 + return ('<div class="pad">'
233 + + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · ' + today_str().upper() + '</div>'
234 + + '<div class="kabadge">KA</div>'
235 + + '<div class="site" style="font-size:68px;margin-top:14px">' + esc(ins["label"]) + '</div>'
236 + + '<div class="ltitle">' + heading + '</div>'
237 + + '<div class="lwrap">' + rows + '</div>'
238 + + footer(ins, p)
239 + + '</div><style>' + css + '</style>')
240 +
209 241 GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight,
210 "deal":genre_hero, "pulse":genre_hero}
242 + "list":genre_list, "deal":genre_hero, "pulse":genre_hero}
211 243
212 244 def build_html(ins):
213 245 p = pal(ins["site"])
214 246 genre = ins.get("genre","hero")
215 247 if genre == "ranking" and not ins.get("bars"): genre = "hero"
216 248 if genre == "spotlight" and not (ins.get("product") or {}).get("image_data"): genre = "hero"
249 + if genre == "list" and not ins.get("list"): genre = "hero"
217 250 body = GENRES.get(genre, genre_hero)(ins, p)
218 251 return f"""<!doctype html><html><head><meta charset="utf-8"><style>{base_css(p)}</style></head>
219 252 <body><div class="stage"><div class="grain"></div>{body}</div></body></html>""", genre
@@ -225,6 +258,10 @@ def render(ins, out_dir):
225 258 if prod and prod.get("image") and not prod.get("image_data"):
226 259 du = data_uri(prod["image"])
227 260 if du: prod["image_data"] = du
261 + for it in (ins.get("list") or []):
262 + if it.get("image") and not it.get("image_data"):
263 + du = data_uri(it["image"])
264 + if du: it["image_data"] = du
228 265 html, genre = build_html(ins)
229 266 key = hashlib.md5((ins["site"]+ins.get("insight_id","")+genre).encode()).hexdigest()[:8]
230 267 html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html")
231 268