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%

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 1 mo ago (Aug 23, 2026) parent 23a7546

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 29 const PY = '/opt/homebrew/bin/python3';
30 30 const INSIGHTS = path.join(__dirname, 'social', 'insights.py');
31 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 34 let CFG = {}; // injecté par init()
34 35 let LOGGER = () => {};
@@ -37,7 +38,7 @@ let timer = null;
37 38 // ---------- état persistant ----------
38 39 function loadState() {
39 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 43 let state = loadState();
43 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 347 export async function renderCard(insight) {
347 const r = await runInput(PY, [RENDER, CARDS_DIR], JSON.stringify(insight));
348 if (r.error) throw new Error('render_card.py: ' + r.error);
349 const p = r.stdout.trim().split('\n').pop();
348 + // moteur HTML/Chrome (haute qualité) ; repli sur PIL si échec
349 + let r = await runInput(PY, [RENDER_HTML, CARDS_DIR], JSON.stringify(insight));
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 357 if (!p || !fs.existsSync(p)) throw new Error('image non produite');
351 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 419 // légende via API Anthropic (le modèle Claude rédige à partir du fait)
355 420 async function anthropic(system, user, model, maxTokens = 500) {
356 421 const key = CFG.anthropicKey;
@@ -371,7 +436,7 @@ Règles STRICTES :
371 436 - 1 seule publication, 40 à 90 mots.
372 437 - Commence par 1 emoji pertinent. Mets en valeur LE fait statistique fourni (ne l'invente jamais, n'ajoute aucun chiffre non fourni).
373 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 441 export async function writeCaption(insight, extraPrompt = '') {
377 442 const model = CFG.socialModel || CFG.upgraderModel || 'claude-opus-4-8';
@@ -380,7 +445,8 @@ export async function writeCaption(insight, extraPrompt = '') {
380 445 `Site : ${insight.label} (${url})\nAngle/tagline : ${insight.tagline || ''}\n` +
381 446 (extraPrompt ? `\nConsigne supplémentaire de l'administrateur :\n${extraPrompt}\n` : '') +
382 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 452 // brouillon complet : insight -> image + légende (sans publier)
@@ -401,6 +467,7 @@ export async function generateDraft({ site = '', prompt = '' } = {}) {
401 467 } else {
402 468 insight = await pickInsight({ site });
403 469 }
470 + await decorateCreative(insight);
404 471 const image = await renderCard(insight);
405 472 const caption = await writeCaption(insight, prompt);
406 473 return { insight, image, caption, imageUrl: '/api/social/card/' + path.basename(image) };
@@ -416,11 +483,13 @@ export async function runAutoCycle(trigger = 'auto') {
416 483 return { ok: false, reason: 'facebook_non_connecté' };
417 484 }
418 485 const insight = await pickInsight({});
486 + await decorateCreative(insight);
419 487 const image = await renderCard(insight);
420 488 const caption = await writeCaption(insight);
421 489 await publishToPage(caption, image);
422 490 state.lastSites = [...state.lastSites, insight.site].slice(-6);
423 491 state.lastPostAt = Date.now();
492 + state.postCount = (state.postCount || 0) + 1;
424 493 saveState();
425 494 logEvent({ kind: 'published', trigger, site: insight.site, insight: insight.insight_id,
426 495 headline: insight.headline, caption, image: path.basename(image) });
@@ -437,7 +506,7 @@ export async function publishDraft({ caption, image }) {
437 506 const login = await fbLoginState();
438 507 if (login !== 'LOGGED_IN') throw new Error('Facebook non connecté sur le nœud');
439 508 await publishToPage(caption, image);
440 state.lastPostAt = Date.now(); saveState();
509 + state.lastPostAt = Date.now(); state.postCount = (state.postCount || 0) + 1; saveState();
441 510 logEvent({ kind: 'published', trigger: 'manuel', caption, image: path.basename(image) });
442 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