SPB Git

spb/admin-ka

Public

Social: images haute qualite (Chrome/HTML) + produit vedette + signature Agent KA

- render_card_html.py: rendu HTML via chrome-headless-shell, degrades par site, 3 genres (hero/ranking/spotlight)
- spotlight = vraie photo produit/annonce depuis les API (lou-ka/immo-ka/fabri-ka/sorti-ka), filtree
- rotation des genres (postCount), repli PIL si Chrome echoue
- chaque legende signee: — Publie par l Agent KA

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

2 changed files +326 −7

modified server/social.js +76 −7
@@ -29,6 +29,7 @@ const PAGE_URL = `https://www.facebook.com/profile.php?id=${PAGE_ID}`;
29 const PY = '/opt/homebrew/bin/python3'; 29 const PY = '/opt/homebrew/bin/python3';
30 const INSIGHTS = path.join(__dirname, 'social', 'insights.py'); 30 const INSIGHTS = path.join(__dirname, 'social', 'insights.py');
31 const RENDER = path.join(__dirname, 'social', 'render_card.py'); 31 const RENDER = path.join(__dirname, 'social', 'render_card.py');
32+const RENDER_HTML = path.join(__dirname, 'social', 'render_card_html.py');
32 33
33 let CFG = {}; // injecté par init() 34 let CFG = {}; // injecté par init()
34 let LOGGER = () => {}; 35 let LOGGER = () => {};
@@ -37,7 +38,7 @@ let timer = null;
37 // ---------- état persistant ---------- 38 // ---------- état persistant ----------
38 function loadState() { 39 function loadState() {
39 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {} 40 try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch {}
40− return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0 }; 41+ return { auto: false, lastSites: [], lastPostAt: 0, nextAt: 0, postCount: 0 };
41 } 42 }
42 let state = loadState(); 43 let state = loadState();
43 function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); } 44 function saveState() { fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)); }
@@ -344,13 +345,77 @@ export async function pickInsight({ site = '', excludeRecent = true } = {}) {
344 } 345 }
345 346
346 export async function renderCard(insight) { 347 export async function renderCard(insight) {
347− const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight)); 348+ // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec
348− if (r.error) throw new Error('render_card.py: ' + r.error); 349+ let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));
349− const p = r.stdout.trim().split('\n').pop(); 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+ }
350 if (!p || !fs.existsSync(p)) throw new Error('image non produite'); 357 if (!p || !fs.existsSync(p)) throw new Error('image non produite');
351 return p; 358 return p;
352 } 359 }
353 360
361+// --- produit vedette (photo réelle) pour le genre « spotlight » ---
362+const 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+};
368+async 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+}
406+
407+// choisit le genre visuel (rotation) et attache le produit vedette si spotlight
408+async 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+}
418+
354 // légende via API Anthropic (le modèle Claude rédige à partir du fait) 419 // légende via API Anthropic (le modèle Claude rédige à partir du fait)
355 async function anthropic(system, user, model, maxTokens = 500) { 420 async function anthropic(system, user, model, maxTokens = 500) {
356 const key = CFG.anthropicKey; 421 const key = CFG.anthropicKey;
@@ -371,7 +436,7 @@ Règles STRICTES :
371 - 1 seule publication, 40 à 90 mots. 436 - 1 seule publication, 40 à 90 mots.
372 - Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni). 437 - Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).
373 - Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA). 438 - Termine par un appel à l'action avec l'URL du site concerné, puis 3-5 hashtags pertinents (dont #GroupeKA).
374−- Pas de titre, pas de gras Markdown, pas de guillemets autour du texte. Réponds UNIQUEMENT par le texte de la publication.`; 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).`;
375 440
376 export async function writeCaption(insight, extraPrompt = '') { 441 export async function writeCaption(insight, extraPrompt = '') {
377 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8'; 442 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
@@ -380,7 +445,8 @@ export async function writeCaption(insight, extraPrompt = '') {
380 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` + 445 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +
381 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') + 446 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +
382 `\nRédige la publication Facebook.`; 447 `\nRédige la publication Facebook.`;
383− return anthropic(CAPTION_SYSTEM, user, model, 500); 448+ const txt = await anthropic(CAPTION_SYSTEM, user, model, 500);
449+ return txt + "\n\n— ✶ Publié par l'Agent KA · administration-ka.com";
384 } 450 }
385 451
386 // brouillon complet : insight -> image + légende (sans publier) 452 // brouillon complet : insight -> image + légende (sans publier)
@@ -401,6 +467,7 @@ export async function generateDraft({ site = '', prompt = '' } = {}) {
401 } else { 467 } else {
402 insight = await pickInsight({ site }); 468 insight = await pickInsight({ site });
403 } 469 }
470+ await decorateCreative(insight);
404 const image = await renderCard(insight); 471 const image = await renderCard(insight);
405 const caption = await writeCaption(insight, prompt); 472 const caption = await writeCaption(insight, prompt);
406 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) }; 473 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };
@@ -416,11 +483,13 @@ export async function runAutoCycle(trigger = 'auto') {
416 return { ok: false, reason: 'facebook_non_connecté' }; 483 return { ok: false, reason: 'facebook_non_connecté' };
417 } 484 }
418 const insight = await pickInsight({}); 485 const insight = await pickInsight({});
486+ await decorateCreative(insight);
419 const image = await renderCard(insight); 487 const image = await renderCard(insight);
420 const caption = await writeCaption(insight); 488 const caption = await writeCaption(insight);
421 await publishToPage(caption, image); 489 await publishToPage(caption, image);
422 state.lastSites = [...state.lastSites, insight.site].slice(-6); 490 state.lastSites = [...state.lastSites, insight.site].slice(-6);
423 state.lastPostAt = Date.now(); 491 state.lastPostAt = Date.now();
492+ state.postCount = (state.postCount || 0) + 1;
424 saveState(); 493 saveState();
425 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id, 494 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,
426 headline: insight.headline, caption, image: path.basename(image) }); 495 headline: insight.headline, caption, image: path.basename(image) });
@@ -437,7 +506,7 @@ export async function publishDraft({ caption, image }) {
437 const login = await fbLoginState(); 506 const login = await fbLoginState();
438 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud'); 507 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');
439 await publishToPage(caption, image); 508 await publishToPage(caption, image);
440− state.lastPostAt = Date.now(); saveState(); 509+ state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();
441 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) }); 510 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });
442 return true; 511 return true;
443 } 512 }
added server/social/render_card_html.py +250 −0
@@ -0,0 +1,250 @@
1+#!/usr/bin/env python3
2+"""Rendu d'images sociales KA haute qualité via Chrome headless (HTML/CSS).
3+
4+Lit un JSON « creative » sur stdin et produit une image 1080x1350 spectaculaire.
5+Plusieurs « genres » de mise en page, dégradés par site, photos produit réelles.
6+
7+Genres : hero (nombre géant), ranking (palmarès), spotlight (produit vedette photo),
8+ deal (aubaine prix), pulse (activité live).
9+Sortie : chemin du PNG sur stdout.
10+"""
11+import json, sys, os, base64, datetime, urllib.request, ssl, hashlib, subprocess, glob
12+
13+CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
14+
15+def find_chrome():
16+ cands = glob.glob(os.path.expanduser(
17+ "~/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell"))
18+ if cands: return sorted(cands)[-1]
19+ for p in ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]:
20+ if os.path.exists(p): return p
21+ raise SystemExit("chrome-headless-shell introuvable")
22+
23+MOIS = ["janvier","février","mars","avril","mai","juin","juillet","août",
24+ "septembre","octobre","novembre","décembre"]
25+
26+# palettes de dégradé par site (2 arrêts sombres tintés + couleur d'accent vive)
27+PALETTES = {
28+ "lou-ka": {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"},
29+ "immo-ka": {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"},
30+ "food-ka": {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"},
31+ "fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"},
32+ "resto-ka": {"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"},
33+ "auto-ka": {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"},
34+ "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"},
35+ "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"},
36+ "sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"},
37+ "crea-ka": {"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"},
38+ "job-ka": {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"},
39+}
40+
41+def pal(site): return PALETTES.get(site, PALETTES["vrai-prix"])
42+
43+def data_uri(url):
44+ try:
45+ req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"})
46+ with urllib.request.urlopen(req, timeout=15, context=CTX) as r:
47+ raw = r.read()
48+ mime = "image/jpeg"
49+ if url.lower().endswith(".png"): mime="image/png"
50+ elif url.lower().endswith(".webp"): mime="image/webp"
51+ return f"data:{mime};base64," + base64.b64encode(raw).decode()
52+ except Exception:
53+ return None
54+
55+def esc(s): return (str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;"))
56+
57+def today_str():
58+ t = datetime.date.today()
59+ return f"{t.day} {MOIS[t.month-1]} {t.year}"
60+
61+FONT = "'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
62+
63+def base_css(p):
64+ return f"""
65+ *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}}
66+ html,body{{width:1080px;height:1350px;overflow:hidden;font-family:{FONT};color:#fff;}}
67+ .stage{{position:relative;width:1080px;height:1350px;
68+ background:
69+ radial-gradient(1200px 700px at 82% -6%, {p['a']}44 0%, transparent 55%),
70+ radial-gradient(900px 900px at -12% 108%, {p['b']}33 0%, transparent 55%),
71+ linear-gradient(160deg, {p['g1']} 0%, {p['g2']} 100%);}}
72+ .grain{{position:absolute;inset:0;opacity:.045;mix-blend-mode:overlay;
73+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");}}
74+ .pad{{position:absolute;inset:0;padding:70px 72px 250px;display:flex;flex-direction:column;}}
75+ .eyebrow{{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;
76+ font-size:22px;text-transform:uppercase;color:{p['a']};}}
77+ .eyebrow .dot{{width:40px;height:5px;border-radius:3px;background:{p['a']};}}
78+ .kabadge{{position:absolute;top:60px;right:72px;background:{p['a']};color:#0d0d0d;
79+ font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg);
80+ box-shadow:0 10px 30px {p['a']}55;}}
81+ .site{{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}}
82+ .tag{{font-size:31px;font-weight:600;color:#ffffffb0;margin-top:6px;}}
83+ .foot{{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between;
84+ align-items:center;font-weight:800;}}
85+ .foot .url{{font-size:34px;color:{p['a']};letter-spacing:.01em;}}
86+ .foot .brand{{font-size:26px;color:#ffffff88;letter-spacing:.12em;}}
87+ .sig{{position:absolute;left:72px;bottom:150px;font-size:20px;color:#ffffff66;font-weight:600;
88+ display:flex;align-items:center;gap:8px;}}
89+ .glass{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);
90+ border-radius:22px;backdrop-filter:blur(8px);}}
91+ """
92+
93+def hdr(ins, p):
94+ return f"""
95+ <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · STAT DU JOUR · {today_str().upper()}</div>
96+ <div class="kabadge">KA</div>
97+ <div class="site">{esc(ins['label'])}</div>
98+ {f'<div class="tag">{esc(ins.get("tagline",""))}</div>' if ins.get('tagline') else ''}
99+ """
100+
101+def footer(ins, p):
102+ url = "www." + ins["site"] + ".com"
103+ return f"""
104+ <div class="sig">✶ Rédigé et publié par l'Agent KA</div>
105+ <div class="foot"><div class="url">{url}</div><div class="brand">GROUPE ·KA</div></div>
106+ """
107+
108+def tiles_html(tiles, p):
109+ cells = ""
110+ for v, l in tiles:
111+ cells += f"""<div class="glass tile"><div class="tv">{esc(v)}</div><div class="tl">{esc(l)}</div></div>"""
112+ return f"""<div class="tiles">{cells}</div>
113+ <style>
114+ .tiles{{display:grid;grid-template-columns:repeat({len(tiles)},1fr);gap:18px;margin-top:auto;margin-bottom:0;}}
115+ .tile{{padding:22px 24px;}}
116+ .tv{{font-size:46px;font-weight:900;color:{p['a']};letter-spacing:-.01em;}}
117+ .tl{{font-size:23px;font-weight:600;color:#ffffffa0;margin-top:6px;line-height:1.2;}}
118+ </style>"""
119+
120+def genre_hero(ins, p):
121+ tiles = ins.get("tiles") or []
122+ return f"""
123+ <div class="pad">
124+ {hdr(ins,p)}
125+ <div class="hero">
126+ <div class="num">{esc(ins['headline'])}</div>
127+ <div class="lab">{esc(ins['headline_label'])}</div>
128+ </div>
129+ {tiles_html(tiles,p) if tiles else '<div style="margin-top:auto"></div>'}
130+ {footer(ins,p)}
131+ </div>
132+ <style>
133+ .hero{{margin-top:70px;}}
134+ .num{{font-size:230px;font-weight:900;line-height:.9;letter-spacing:-.03em;
135+ background:linear-gradient(180deg,#fff 0%,{p['a']} 130%);-webkit-background-clip:text;
136+ -webkit-text-fill-color:transparent;filter:drop-shadow(0 12px 40px {p['a']}44);}}
137+ .lab{{font-size:44px;font-weight:700;color:#fff;margin-top:20px;max-width:820px;line-height:1.12;}}
138+ </style>"""
139+
140+def genre_ranking(ins, p):
141+ bars = ins.get("bars") or []
142+ maxv = max([b[1] for b in bars] or [1])
143+ rows = ""
144+ for b in bars:
145+ label, val, vtxt = b[0], b[1], b[2]
146+ sub = b[3] if len(b) > 3 else ""
147+ w = max(8, int(100*val/maxv))
148+ rows += f"""
149+ <div class="row">
150+ <div class="rl"><span class="rlab">{esc(label)}</span><span class="rval">{esc(vtxt)}</span></div>
151+ <div class="track"><div class="fill" style="width:{w}%"></div></div>
152+ {f'<div class="rsub">{esc(sub)}</div>' if sub else ''}
153+ </div>"""
154+ return f"""
155+ <div class="pad">
156+ {hdr(ins,p)}
157+ <div class="rank-title">{esc(ins['headline_label'])}</div>
158+ <div class="rows">{rows}</div>
159+ {footer(ins,p)}
160+ </div>
161+ <style>
162+ .rank-title{{font-size:40px;font-weight:800;color:#fff;margin-top:50px;margin-bottom:8px;}}
163+ .rows{{margin-top:20px;display:flex;flex-direction:column;gap:30px;}}
164+ .rl{{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;}}
165+ .rlab{{font-size:34px;font-weight:700;}}
166+ .rval{{font-size:36px;font-weight:900;color:{p['a']};}}
167+ .track{{height:20px;border-radius:12px;background:rgba(255,255,255,.09);overflow:hidden;}}
168+ .fill{{height:100%;border-radius:12px;background:linear-gradient(90deg,{p['a']},{p['b']});
169+ box-shadow:0 4px 18px {p['a']}55;}}
170+ .rsub{{font-size:22px;color:#ffffff8c;margin-top:8px;font-weight:600;}}
171+ </style>"""
172+
173+def genre_spotlight(ins, p):
174+ prod = ins.get("product") or {}
175+ img = prod.get("image_data") or ""
176+ chips = "".join(f'<span class="chip">{esc(c)}</span>' for c in (prod.get("meta") or []))
177+ price = prod.get("price","")
178+ return f"""
179+ <div class="stage-photo" style="background-image:url('{img}')"></div>
180+ <div class="scrim"></div>
181+ <div class="pad">
182+ <div class="eyebrow"><span class="dot"></span>{esc(ins['label'].upper())} · À DÉCOUVRIR · {today_str().upper()}</div>
183+ <div class="kabadge">KA</div>
184+ <div class="spot">
185+ <div class="ptitle">{esc(prod.get('title',''))}</div>
186+ <div class="psub">{esc(prod.get('subtitle',''))}</div>
187+ <div class="chips">{chips}</div>
188+ {f'<div class="price">{esc(price)}</div>' if price else ''}
189+ <div class="tagline2">{esc(ins.get('tagline',''))}</div>
190+ </div>
191+ {footer(ins,p)}
192+ </div>
193+ <style>
194+ .stage-photo{{position:absolute;top:0;left:0;right:0;height:760px;background-size:cover;background-position:center;}}
195+ .scrim{{position:absolute;inset:0;background:
196+ linear-gradient(180deg, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 34%, {p['g2']}cc 62%, {p['g2']} 82%);}}
197+ .spot{{margin-top:auto;}}
198+ .ptitle{{font-size:62px;font-weight:900;line-height:1.02;letter-spacing:-.02em;max-width:900px;
199+ text-shadow:0 4px 24px #000a;}}
200+ .psub{{font-size:30px;font-weight:600;color:#ffffffc0;margin-top:12px;}}
201+ .chips{{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}}
202+ .chip{{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);border-radius:999px;
203+ padding:10px 20px;font-size:24px;font-weight:700;}}
204+ .price{{font-size:80px;font-weight:900;color:{p['a']};margin-top:24px;letter-spacing:-.02em;
205+ filter:drop-shadow(0 8px 24px {p['a']}55);}}
206+ .tagline2{{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}}
207+ </style>"""
208+
209+GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight,
210+ "deal":genre_hero, "pulse":genre_hero}
211+
212+def build_html(ins):
213+ p = pal(ins["site"])
214+ genre = ins.get("genre","hero")
215+ 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"
217+ 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>
219+ <body><div class="stage"><div class="grain"></div>{body}</div></body></html>""", genre
220+
221+def render(ins, out_dir):
222+ os.makedirs(out_dir, exist_ok=True)
223+ # télécharger la photo produit en data-uri (robuste, pas de hotlink)
224+ prod = ins.get("product")
225+ if prod and prod.get("image") and not prod.get("image_data"):
226+ du = data_uri(prod["image"])
227+ if du: prod["image_data"] = du
228+ html, genre = build_html(ins)
229+ 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")
231+ big = os.path.join(out_dir, f"_big-{key}.png")
232+ out = os.path.join(out_dir, f"card-{ins['site']}-{ins.get('insight_id','x')}-{genre}.png")
233+ open(html_path,"w",encoding="utf-8").write(html)
234+ chrome = find_chrome()
235+ subprocess.run([chrome,"--headless","--disable-gpu","--hide-scrollbars",
236+ "--force-device-scale-factor=2","--window-size=1080,1350",
237+ f"--screenshot={big}","--virtual-time-budget=9000","--run-all-compositor-stages-before-draw",
238+ f"file://{html_path}"], capture_output=True, timeout=60)
239+ if not os.path.exists(big):
240+ raise SystemExit("chrome screenshot a échoué")
241+ # downscale 2x → 1080x1350 (texte net) via sips
242+ subprocess.run(["/usr/bin/sips","-z","1350","1080",big,"--out",out],capture_output=True)
243+ try: os.remove(big); os.remove(html_path)
244+ except: pass
245+ return out
246+
247+if __name__ == "__main__":
248+ ins = json.load(sys.stdin)
249+ out_dir = sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-social-cards"
250+ print(render(ins, out_dir))
251
Menu

Theme