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
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 | // --- produit vedette (photo réelle) pour le genre « spotlight » --- | 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 | const SPOT = { | 456 | const SPOT = { | ||
| 456 | 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 }, | 457 | 'lou-ka': { url: 'https://www.lou-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 800 }, | ||
| 457 | 'immo-ka': { url: 'https://www.immo-ka.com/api/listings?limit=24', arr: 'listings', minPrice: 180000 }, | 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 | return null; | 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 | async function decorateForPrompt(insight) { | 577 | async function decorateForPrompt(insight) { | ||
| 501 | if (SPOT[insight.site]) { | 578 | if (SPOT[insight.site]) { | ||
| 502 | const feat = await fetchFeatured(insight.site); | 579 | const feat = await fetchFeatured(insight.site); | ||
@@ -544,7 +621,14 @@ export async function writeCaption(insight, extraPrompt = '') { | |||||
| 544 | const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; | 621 | const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; | ||
| 545 | const url = `https://www.${insight.site}.com`; | 622 | const url = `https://www.${insight.site}.com`; | ||
| 546 | let user; | 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 | const pr = insight.product; | 632 | const pr = insight.product; | ||
| 549 | user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` + | 633 | user = `Mets en vedette CETTE annonce/produit réel de ${insight.label} (ne cite que ces infos, n'invente rien) :\n` + | ||
| 550 | `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') + | 634 | `Titre : ${pr.title}\n` + (pr.subtitle ? `Détail : ${pr.subtitle}\n` : '') + | ||
@@ -565,19 +649,12 @@ export async function writeCaption(insight, extraPrompt = '') { | |||||
| 565 | // brouillon complet : insight -> image + légende (sans publier) | 649 | // brouillon complet : insight -> image + légende (sans publier) | ||
| 566 | export async function generateDraft({ site = '', prompt = '' } = {}) { | 650 | export async function generateDraft({ site = '', prompt = '' } = {}) { | ||
| 567 | let insight; | 651 | let insight; | ||
| 568 | − if (prompt && !site) { | 652 | + if (prompt) { | ||
| 569 | − // laisser le modèle choisir le site le plus pertinent pour ce prompt | 653 | + const plan = await planFromPrompt(prompt); | ||
| 570 | − const r = await run(PY, [INSIGHTS]); | 654 | + if (site) plan.site = site; | ||
| 571 | − const data = JSON.parse(r.stdout || '{}'); | 655 | + const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : ''; | ||
| 572 | − const cands = data.candidates || []; | 656 | + insight = await pickInsight({ site: chosenSite }); | ||
| 573 | − const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n'); | 657 | + await decoratePlan(insight, plan); | ||
| 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); | ||||
| 581 | } else { | 658 | } else { | ||
| 582 | insight = await pickInsight({ site }); | 659 | insight = await pickInsight({ site }); | ||
| 583 | await decorateCreative(insight); | 660 | await decorateCreative(insight); | ||
@@ -644,15 +721,12 @@ export function reelPath(name) { | |||||
| 644 | // brouillon reel : insight -> vidéo + légende (sans publier) | 721 | // brouillon reel : insight -> vidéo + légende (sans publier) | ||
| 645 | export async function generateReelDraft({ site = '', prompt = '' } = {}) { | 722 | export async function generateReelDraft({ site = '', prompt = '' } = {}) { | ||
| 646 | let insight; | 723 | let insight; | ||
| 647 | − if (prompt && !site) { | 724 | + if (prompt) { | ||
| 648 | − const r = await run(PY, [INSIGHTS]); | 725 | + const plan = await planFromPrompt(prompt); | ||
| 649 | − const cands = (JSON.parse(r.stdout || '{}').candidates) || []; | 726 | + if (site) plan.site = site; | ||
| 650 | − const list = cands.map(c => `${c.site}: ${c.fact}`).join('\n'); | 727 | + const chosenSite = KNOWN_SITES.includes(plan.site) ? plan.site : ''; | ||
| 651 | − const chosen = await anthropic( | 728 | + insight = await pickInsight({ site: chosenSite }); | ||
| 652 | − "Tu choisis le site du Groupe KA le plus pertinent pour une demande. Réponds UNIQUEMENT par l'identifiant du site.", | 729 | + await decoratePlan(insight, plan); | ||
| 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); | ||||
| 656 | } else { | 730 | } else { | ||
| 657 | insight = await pickInsight({ site }); | 731 | insight = await pickInsight({ site }); | ||
| 658 | await decorateForPrompt(insight); | 732 | await decorateForPrompt(insight); | ||
modified
server/social/make_reel.py
+60 −0
@@ -83,7 +83,63 @@ def build_spotlight(ins): | |||||
| 83 | ) | 83 | ) | ||
| 84 | return "<!doctype html><html><head><meta charset=\"utf-8\"><style>"+css+"</style></head><body>"+body+"</body></html>" | 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 | def build_html(ins): | 140 | def build_html(ins): | ||
| 141 | + if ins.get('genre')=='list' and ins.get('list'): | ||||
| 142 | + return build_list(ins) | ||||
| 87 | if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'): | 143 | if ins.get('genre')=='spotlight' and (ins.get('product') or {}).get('image_data'): | ||
| 88 | return build_spotlight(ins) | 144 | return build_spotlight(ins) | ||
| 89 | H=HREEL; p=pal(ins["site"]) | 145 | H=HREEL; p=pal(ins["site"]) | ||
@@ -252,6 +308,10 @@ def render(ins, out_dir): | |||||
| 252 | if prod and prod.get('image') and not prod.get('image_data'): | 308 | if prod and prod.get('image') and not prod.get('image_data'): | ||
| 253 | du=data_uri(prod['image']) | 309 | du=data_uri(prod['image']) | ||
| 254 | if du: prod['image_data']=du | 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 | key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8] | 315 | key=hashlib.md5((ins['site']+ins.get('insight_id','')+'reel').encode()).hexdigest()[:8] | ||
| 256 | tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) | 316 | tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True) | ||
| 257 | html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(build_html(ins)) | 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 | .tagline2{{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}} | 206 | .tagline2{{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}} | ||
| 207 | </style>""" | 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 | GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight, | 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 | def build_html(ins): | 244 | def build_html(ins): | ||
| 213 | p = pal(ins["site"]) | 245 | p = pal(ins["site"]) | ||
| 214 | genre = ins.get("genre","hero") | 246 | genre = ins.get("genre","hero") | ||
| 215 | if genre == "ranking" and not ins.get("bars"): genre = "hero" | 247 | if genre == "ranking" and not ins.get("bars"): genre = "hero" | ||
| 216 | if genre == "spotlight" and not (ins.get("product") or {}).get("image_data"): genre = "hero" | 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 | body = GENRES.get(genre, genre_hero)(ins, p) | 250 | body = GENRES.get(genre, genre_hero)(ins, p) | ||
| 218 | return f"""<!doctype html><html><head><meta charset="utf-8"><style>{base_css(p)}</style></head> | 251 | return f"""<!doctype html><html><head><meta charset="utf-8"><style>{base_css(p)}</style></head> | ||
| 219 | <body><div class="stage"><div class="grain"></div>{body}</div></body></html>""", genre | 252 | <body><div class="stage"><div class="grain"></div>{body}</div></body></html>""", genre | ||
@@ -225,6 +258,10 @@ def render(ins, out_dir): | |||||
| 225 | if prod and prod.get("image") and not prod.get("image_data"): | 258 | if prod and prod.get("image") and not prod.get("image_data"): | ||
| 226 | du = data_uri(prod["image"]) | 259 | du = data_uri(prod["image"]) | ||
| 227 | if du: prod["image_data"] = du | 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 | html, genre = build_html(ins) | 265 | html, genre = build_html(ins) | ||
| 229 | key = hashlib.md5((ins["site"]+ins.get("insight_id","")+genre).encode()).hexdigest()[:8] | 266 | key = hashlib.md5((ins["site"]+ins.get("insight_id","")+genre).encode()).hexdigest()[:8] | ||
| 230 | html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html") | 267 | html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html") | ||
| 231 | 268 | ||||