#!/usr/bin/env python3 """Rendu d'images sociales KA haute qualité via Chrome headless (HTML/CSS). Lit un JSON « creative » sur stdin et produit une image 1080x1350 spectaculaire. Plusieurs « genres » de mise en page, dégradés par site, photos produit réelles. Genres : hero (nombre géant), ranking (palmarès), spotlight (produit vedette photo), deal (aubaine prix), pulse (activité live). Sortie : chemin du PNG sur stdout. """ import json, sys, os, base64, datetime, urllib.request, ssl, hashlib, subprocess, glob CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE def find_chrome(): cands = glob.glob(os.path.expanduser( "~/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell")) if cands: return sorted(cands)[-1] for p in ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]: if os.path.exists(p): return p raise SystemExit("chrome-headless-shell introuvable") MOIS = ["janvier","février","mars","avril","mai","juin","juillet","août", "septembre","octobre","novembre","décembre"] # palettes de dégradé par site (2 arrêts sombres tintés + couleur d'accent vive) PALETTES = { "lou-ka": {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09"}, "immo-ka": {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b"}, "food-ka": {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b"}, "fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09"}, "resto-ka": {"a":"#f08c00","b":"#ffb84d","g1":"#241804","g2":"#0d0b08"}, "auto-ka": {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09"}, "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08"}, "trouve-ka":{"a":"#d9f26b","b":"#8fd3ff","g1":"#0a1a20","g2":"#080b0d"}, "sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c"}, "crea-ka": {"a":"#d9f26b","b":"#c08bff","g1":"#160a24","g2":"#0a090d"}, "job-ka": {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d"}, } def pal(site): return PALETTES.get(site, PALETTES["vrai-prix"]) def data_uri(url): try: req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0 (Macintosh)"}) with urllib.request.urlopen(req, timeout=15, context=CTX) as r: raw = r.read() mime = "image/jpeg" if url.lower().endswith(".png"): mime="image/png" elif url.lower().endswith(".webp"): mime="image/webp" return f"data:{mime};base64," + base64.b64encode(raw).decode() except Exception: return None def esc(s): return (str(s).replace("&","&").replace("<","<").replace(">",">")) def today_str(): t = datetime.date.today() return f"{t.day} {MOIS[t.month-1]} {t.year}" FONT = "'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif" def base_css(p): return f""" *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}} html,body{{width:1080px;height:1350px;overflow:hidden;font-family:{FONT};color:#fff;}} .stage{{position:relative;width:1080px;height:1350px; background: radial-gradient(1200px 700px at 82% -6%, {p['a']}44 0%, transparent 55%), radial-gradient(900px 900px at -12% 108%, {p['b']}33 0%, transparent 55%), linear-gradient(160deg, {p['g1']} 0%, {p['g2']} 100%);}} .grain{{position:absolute;inset:0;opacity:.045;mix-blend-mode:overlay; background-image:url("data:image/svg+xml;utf8,");}} .pad{{position:absolute;inset:0;padding:70px 72px 250px;display:flex;flex-direction:column;}} .eyebrow{{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em; font-size:22px;text-transform:uppercase;color:{p['a']};}} .eyebrow .dot{{width:40px;height:5px;border-radius:3px;background:{p['a']};}} .kabadge{{position:absolute;top:60px;right:72px;background:{p['a']};color:#0d0d0d; font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg); box-shadow:0 10px 30px {p['a']}55;}} .site{{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}} .tag{{font-size:31px;font-weight:600;color:#ffffffb0;margin-top:6px;}} .foot{{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between; align-items:center;font-weight:800;}} .foot .url{{font-size:34px;color:{p['a']};letter-spacing:.01em;}} .foot .brand{{font-size:26px;color:#ffffff88;letter-spacing:.12em;}} .sig{{position:absolute;left:72px;bottom:150px;font-size:20px;color:#ffffff66;font-weight:600; display:flex;align-items:center;gap:8px;}} .glass{{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12); border-radius:22px;backdrop-filter:blur(8px);}} """ def hdr(ins, p): return f"""
{esc(ins['label'].upper())} · STAT DU JOUR · {today_str().upper()}
KA
{esc(ins['label'])}
{f'
{esc(ins.get("tagline",""))}
' if ins.get('tagline') else ''} """ def footer(ins, p): url = "www." + ins["site"] + ".com" return f"""
✶ Rédigé et publié par l'Agent KA
{url}
GROUPE ·KA
""" def tiles_html(tiles, p): cells = "" for v, l in tiles: cells += f"""
{esc(v)}
{esc(l)}
""" return f"""
{cells}
""" def genre_hero(ins, p): tiles = ins.get("tiles") or [] return f"""
{hdr(ins,p)}
{esc(ins['headline'])}
{esc(ins['headline_label'])}
{tiles_html(tiles,p) if tiles else '
'} {footer(ins,p)}
""" def genre_ranking(ins, p): bars = ins.get("bars") or [] maxv = max([b[1] for b in bars] or [1]) rows = "" for b in bars: label, val, vtxt = b[0], b[1], b[2] sub = b[3] if len(b) > 3 else "" w = max(8, int(100*val/maxv)) rows += f"""
{esc(label)}{esc(vtxt)}
{f'
{esc(sub)}
' if sub else ''}
""" return f"""
{hdr(ins,p)}
{esc(ins['headline_label'])}
{rows}
{footer(ins,p)}
""" def genre_spotlight(ins, p): prod = ins.get("product") or {} img = prod.get("image_data") or "" chips = "".join(f'{esc(c)}' for c in (prod.get("meta") or [])) price = prod.get("price","") return f"""
{esc(ins['label'].upper())} · À DÉCOUVRIR · {today_str().upper()}
KA
{esc(prod.get('title',''))}
{esc(prod.get('subtitle',''))}
{chips}
{f'
{esc(price)}
' if price else ''}
{esc(ins.get('tagline',''))}
{footer(ins,p)}
""" GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight, "deal":genre_hero, "pulse":genre_hero} def build_html(ins): p = pal(ins["site"]) genre = ins.get("genre","hero") if genre == "ranking" and not ins.get("bars"): genre = "hero" if genre == "spotlight" and not (ins.get("product") or {}).get("image_data"): genre = "hero" body = GENRES.get(genre, genre_hero)(ins, p) return f"""
{body}
""", genre def render(ins, out_dir): os.makedirs(out_dir, exist_ok=True) # télécharger la photo produit en data-uri (robuste, pas de hotlink) prod = ins.get("product") if prod and prod.get("image") and not prod.get("image_data"): du = data_uri(prod["image"]) if du: prod["image_data"] = du html, genre = build_html(ins) key = hashlib.md5((ins["site"]+ins.get("insight_id","")+genre).encode()).hexdigest()[:8] html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html") big = os.path.join(out_dir, f"_big-{key}.png") out = os.path.join(out_dir, f"card-{ins['site']}-{ins.get('insight_id','x')}-{genre}.png") open(html_path,"w",encoding="utf-8").write(html) chrome = find_chrome() subprocess.run([chrome,"--headless","--disable-gpu","--hide-scrollbars", "--force-device-scale-factor=2","--window-size=1080,1350", f"--screenshot={big}","--virtual-time-budget=9000","--run-all-compositor-stages-before-draw", f"file://{html_path}"], capture_output=True, timeout=60) if not os.path.exists(big): raise SystemExit("chrome screenshot a échoué") # downscale 2x → 1080x1350 (texte net) via sips subprocess.run(["/usr/bin/sips","-z","1350","1080",big,"--out",out],capture_output=True) try: os.remove(big); os.remove(html_path) except: pass return out if __name__ == "__main__": ins = json.load(sys.stdin) out_dir = sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-social-cards" print(render(ins, out_dir))