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 | 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 | |