#!/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.
Genres : hero (nombre géant), ranking (palmarès), spotlight (produit vedette photo),
list (sélection d'items photo), grid (mosaïque 2x2 de photos),
donut (part en % en anneau SVG), quote (« le saviez-vous » typographique).
Thèmes (rotation via `variant` ou hash) : nuit (sombre), creme (clair néo-brutaliste Ka),
cadre (sombre encadré). Les genres photo (spotlight/list/grid) restent en nuit.
Sortie : chemin du PNG sur stdout.
"""
import json, sys, os, re, 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 par site : accent vif (a), accent secondaire (b), dégradé sombre (g1,g2),
# accent assombri lisible sur fond clair (ad)
PALETTES = {
"lou-ka": {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09","ad":"#c24f00"},
"immo-ka": {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b","ad":"#b02532"},
"food-ka": {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b","ad":"#157a41"},
"fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09","ad":"#9c3f21"},
"auto-ka": {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09","ad":"#cc3f14"},
"vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08","ad":"#6b7f1d"},
"sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c","ad":"#6b7f1d"},
"job-ka": {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d","ad":"#6b7f1d"},
}
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}"
def parse_pct(h):
m = re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())
return float(m.group(1).replace(",", ".")) if m else None
FONT = "'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"
THEMES = ["nuit","creme","cadre"]
def theme_of(ins):
t = ins.get("theme")
if t in THEMES: return t
v = ins.get("variant")
if v is None:
v = int(hashlib.md5((ins["site"]+str(ins.get("insight_id",""))).encode()).hexdigest(), 16)
return THEMES[int(v) % len(THEMES)]
def tokens(p, theme):
if theme == "creme":
return {
"fg":"#141814","muted":"#141814b3","muted2":"#14181487","sig":"#14181466",
"acc":p["ad"],
"glass":"background:#ffffff;border:3px solid #141814;box-shadow:9px 9px 0 #141814;",
"badge_extra":"border:3px solid #141814;box-shadow:7px 7px 0 #141814;",
"numgrad":"background:linear-gradient(180deg,#141814 0%,"+p["ad"]+" 150%);",
"numglow":"filter:drop-shadow(0 8px 22px "+p["a"]+"40);",
"track":"#1418141f",
"grain_op":".05",
"stage":("background:radial-gradient(1100px 680px at 85% -8%, "+p["a"]+"30 0%, transparent 55%),"
"radial-gradient(900px 900px at -10% 112%, "+p["b"]+"26 0%, transparent 55%),"
"linear-gradient(168deg,#f7f6ef 0%,#edece4 100%);"),
"frame":"",
}
t = {
"fg":"#ffffff","muted":"#ffffffb0","muted2":"#ffffff8c","sig":"#ffffff66",
"acc":p["a"],
"glass":"background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(8px);",
"badge_extra":"box-shadow:0 10px 30px "+p["a"]+"55;",
"numgrad":"background:linear-gradient(180deg,#fff 0%,"+p["a"]+" 130%);",
"numglow":"filter:drop-shadow(0 12px 40px "+p["a"]+"44);",
"track":"rgba(255,255,255,.09)",
"grain_op":".045",
"stage":("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%);"),
"frame":"",
}
if theme == "cadre":
t["stage"] = ("background:radial-gradient(1100px 800px at 50% -12%, "+p["a"]+"3a 0%, transparent 60%),"
"radial-gradient(800px 800px at 100% 110%, "+p["b"]+"2a 0%, transparent 55%),"
"linear-gradient(200deg, "+p["g1"]+" 0%, "+p["g2"]+" 100%);")
t["frame"] = (".stage::before{content:'';position:absolute;inset:26px;border:2px solid "+p["a"]+"66;"
"border-radius:36px;pointer-events:none;z-index:5}"
".stage::after{content:'';position:absolute;inset:38px;border:1px solid #ffffff1c;"
"border-radius:28px;pointer-events:none;z-index:5}")
return t
def base_css(p, T):
css = """
*{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}
html,body{width:1080px;height:1350px;overflow:hidden;font-family:__FONT__;color:__FG__;}
.stage{position:relative;width:1080px;height:1350px;__STAGE__}
__FRAME__
.grain{position:absolute;inset:0;opacity:__GOP__;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;z-index:2;}
.eyebrow{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;
font-size:22px;text-transform:uppercase;color:__ACC__;}
.eyebrow .dot{width:40px;height:5px;border-radius:3px;background:__ACC__;}
.kabadge{position:absolute;top:60px;right:72px;background:__A__;color:#0d0d0d;
font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg);z-index:6;__BADGE__}
.site{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}
.tag{font-size:31px;font-weight:600;color:__MUTED__;margin-top:6px;}
.foot{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between;
align-items:center;font-weight:800;z-index:6;}
.foot .url{font-size:34px;color:__ACC__;letter-spacing:.01em;}
.foot .brand{font-size:26px;color:__MUTED2__;letter-spacing:.12em;}
.sig{position:absolute;left:72px;bottom:150px;font-size:20px;color:__SIG__;font-weight:600;
display:flex;align-items:center;gap:8px;z-index:6;}
.glass{border-radius:22px;__GLASS__}
"""
return (css.replace("__FONT__",FONT).replace("__FG__",T["fg"]).replace("__STAGE__",T["stage"])
.replace("__FRAME__",T["frame"]).replace("__GOP__",T["grain_op"])
.replace("__MUTED2__",T["muted2"]).replace("__MUTED__",T["muted"]).replace("__SIG__",T["sig"])
.replace("__GLASS__",T["glass"]).replace("__BADGE__",T["badge_extra"])
.replace("__ACC__",T["acc"]).replace("__A__",p["a"]))
def hdr(ins, p, kicker="STAT DU JOUR"):
return ('
'+esc(ins["label"].upper())+' · '
+ esc(kicker)+' · '+today_str().upper()+'
'
+ 'KA
'
+ ''+esc(ins["label"])+'
'
+ ((''+esc(ins.get("tagline",""))+'
') if ins.get("tagline") else ''))
def footer(ins, p):
url = "www." + ins["site"] + ".com"
return ('✶ Rédigé et publié par l\'Agent KA
'
+ '')
def tiles_html(tiles, p, T):
cells = ""
for v, l in tiles:
cells += ''
css = """
.tiles{display:grid;grid-template-columns:repeat(__N__,1fr);gap:18px;margin-top:auto;margin-bottom:0;}
.tile{padding:22px 24px;}
.tv{font-size:46px;font-weight:900;color:__ACC__;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;}
.tl{font-size:23px;font-weight:600;color:__MUTED__;margin-top:6px;line-height:1.2;}
""".replace("__N__",str(len(tiles))).replace("__ACC__",T["acc"]).replace("__MUTED__",T["muted"])
return ''+cells+'
'
def genre_hero(ins, p, T):
tiles = ins.get("tiles") or []
hl = str(ins["headline"]); L = len(hl)
numsize = 150 if L >= 10 else (190 if L >= 8 else 230)
css = """
.hero{margin-top:70px;}
.num{font-size:__NS__px;font-weight:900;line-height:.9;letter-spacing:-.03em;display:block;
max-width:100%;white-space:nowrap;overflow:hidden;
__NUMGRAD__-webkit-background-clip:text;-webkit-text-fill-color:transparent;__NUMGLOW__}
.lab{font-size:44px;font-weight:700;margin-top:20px;max-width:820px;line-height:1.12;}
""".replace("__NS__",str(numsize)).replace("__NUMGRAD__",T["numgrad"]).replace("__NUMGLOW__",T["numglow"])
return (''+hdr(ins,p)
+ '
'+esc(hl)+'
'
+ '
'+esc(ins["headline_label"])+'
'
+ (tiles_html(tiles,p,T) if tiles else '
')
+ footer(ins,p)+'
')
def genre_ranking(ins, p, T):
bars = ins.get("bars") or []
# avec sous-textes, 6 rangées débordent sur le pied de page -> max 5
has_sub = any(len(b) > 3 and b[3] for b in bars)
bars = bars[:5] if has_sub else bars[:6]
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 += (''+esc(label)+' '
+ ''+esc(vtxt)+'
'
+ '
'
+ (('
'+esc(sub)+'
') if sub else '')+'
')
css = """
.rank-title{font-size:40px;font-weight:800;margin-top:50px;margin-bottom:8px;}
.rows{margin-top:20px;display:flex;flex-direction:column;gap:30px;}
.rl{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;}
.rlab{font-size:34px;font-weight:700;}
.rval{font-size:36px;font-weight:900;color:__ACC__;}
.track{height:20px;border-radius:12px;background:__TRACK__;overflow:hidden;}
.fill{height:100%;border-radius:12px;background:linear-gradient(90deg,__ACC__,__B__);
box-shadow:0 4px 18px __A__55;}
.rsub{font-size:22px;color:__MUTED2__;margin-top:8px;font-weight:600;}
""".replace("__ACC__",T["acc"]).replace("__TRACK__",T["track"]).replace("__B__",p["b"]) \
.replace("__A__",p["a"]).replace("__MUTED2__",T["muted2"])
return (''+hdr(ins,p)
+ '
'+esc(ins["headline_label"])+'
'
+ '
'+rows+'
'+footer(ins,p)+'
')
def genre_spotlight(ins, p, T):
prod = ins.get("product") or {}
img = prod.get("image_data") or ""
chips = "".join(''+esc(c)+' ' for c in (prod.get("meta") or []))
price = prod.get("price","")
css = """
.stage-photo{position:absolute;top:0;left:0;right:0;height:760px;background-size:cover;background-position:center;}
.scrim{position:absolute;inset:0;background:
linear-gradient(180deg, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 34%, __G2__cc 62%, __G2__ 82%);}
.spot{margin-top:auto;}
.ptitle{font-size:62px;font-weight:900;line-height:1.02;letter-spacing:-.02em;max-width:900px;
text-shadow:0 4px 24px #000a;}
.psub{font-size:30px;font-weight:600;color:#ffffffc0;margin-top:12px;}
.chips{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
.chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);border-radius:999px;
padding:10px 20px;font-size:24px;font-weight:700;}
.price{font-size:80px;font-weight:900;color:__A__;margin-top:24px;letter-spacing:-.02em;
filter:drop-shadow(0 8px 24px __A__55);}
.tagline2{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}
""".replace("__G2__",p["g2"]).replace("__A__",p["a"])
return ('
'
+ ''
+ '
'+esc(ins["label"].upper())+' · À DÉCOUVRIR · '+today_str().upper()+'
'
+ '
KA
'
+ '
'+esc(prod.get("title",""))+'
'
+ '
'+esc(prod.get("subtitle",""))+'
'
+ '
'+chips+'
'
+ (('
'+esc(price)+'
') if price else '')
+ '
'+esc(ins.get("tagline",""))+'
'
+ footer(ins,p)+'
')
def genre_list(ins, p, T):
items = ins.get("list") or []
rows = ""
for it in items[:5]:
img = it.get("image_data") or ""
thumb = '
' if img else '
'
price = esc(it.get("price", "") or "")
pr = ('' + price + '
') if price else ''
rows += ('' + thumb
+ '
' + esc(it.get("title", "")) + '
'
+ '
' + esc(it.get("subtitle", "") or "") + '
'
+ pr + '
')
heading = esc(ins.get("list_title", "") or ins.get("headline_label", "") or "À la une")
css = """
.ltitle{font-size:40px;font-weight:800;color:#fff;margin-top:8px;margin-bottom:18px;max-width:920px;line-height:1.1}
.lwrap{display:flex;flex-direction:column;gap:15px}
.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}
.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)}
.lmid{flex:1;min-width:0}
.lt{font-size:32px;font-weight:800;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
.ls{font-size:25px;font-weight:600;color:#ffffffa0;margin-top:6px}
.lp{font-size:36px;font-weight:900;color:__A__;white-space:nowrap;text-align:right}
""".replace("__A__", p["a"])
return (''
+ '
' + esc(ins["label"].upper()) + ' · ' + today_str().upper() + '
'
+ '
KA
'
+ '
' + esc(ins["label"]) + '
'
+ '
' + heading + '
'
+ '
' + rows + '
'
+ footer(ins, p)
+ '
')
def genre_grid(ins, p, T):
items = [it for it in (ins.get("list") or []) if it.get("image_data")][:4]
cells = ""
for it in items:
cells += ('
'
+ '
'+esc(it.get("title",""))+'
'
+ (('
'+esc(it["price"])+'
') if it.get("price") else '')
+ '
')
heading = esc(ins.get("list_title","") or ins.get("headline_label","") or "À la une")
css = """
.gtitle{font-size:42px;font-weight:800;color:#fff;margin-top:10px;margin-bottom:22px;max-width:920px;line-height:1.1}
.gwrap{display:grid;grid-template-columns:1fr 1fr;gap:18px}
.cell{position:relative;height:350px;border-radius:22px;overflow:hidden;border:1px solid rgba(255,255,255,.16)}
.cph{position:absolute;inset:0;background-size:cover;background-position:center}
.cscrim{position:absolute;inset:0;background:linear-gradient(180deg,transparent 42%,#000d 100%)}
.cbot{position:absolute;left:18px;right:18px;bottom:14px}
.ct{font-size:26px;font-weight:800;line-height:1.15;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;text-shadow:0 2px 10px #000}
.cp{font-size:28px;font-weight:900;color:__A__;margin-top:4px;text-shadow:0 2px 10px #000}
""".replace("__A__", p["a"])
return (''
+ '
' + esc(ins["label"].upper()) + ' · EN IMAGES · ' + today_str().upper() + '
'
+ '
KA
'
+ '
' + esc(ins["label"]) + '
'
+ '
' + heading + '
'
+ '
' + cells + '
'
+ footer(ins, p)
+ '
')
NUMRE = re.compile(r'(\d[\d\u00a0\u202f ,\.]*\d|\d)(\s?(%|\$|G\$|M\$|milliards?|millions?))?')
def genre_quote(ins, p, T):
fact = str(ins.get("fact") or (str(ins["headline"])+" "+ins.get("headline_label","")))
txt = NUMRE.sub(lambda m: ''+m.group(0)+' ', esc(fact))
L = len(fact)
fs = 64 if L < 170 else (56 if L < 240 else 48)
tiles = (ins.get("tiles") or [])[:3]
css = """
.qmark{font-size:230px;line-height:.55;color:__ACC__;font-weight:900;margin-top:70px;height:118px;
font-family:Georgia,serif;opacity:.9}
.qtext{font-size:__FS__px;font-weight:800;line-height:1.3;letter-spacing:-.01em;max-width:930px;}
.qtext em{font-style:normal;color:__ACC__;}
""".replace("__ACC__",T["acc"]).replace("__FS__",str(fs))
return (''+hdr(ins,p,"LE SAVIEZ-VOUS ?")
+ '
“
'
+ '
'+txt+'
'
+ (tiles_html(tiles,p,T) if tiles else '
')
+ footer(ins,p)+'
')
def genre_donut(ins, p, T):
pct = parse_pct(ins["headline"]) or 0.0
C = 2*3.14159*210
filled = C*min(100.0, pct)/100.0
svg = (''
+ ' '
+ ' '
+ ''
+ ' ')
tiles = (ins.get("tiles") or [])[:3]
css = """
.drow{display:flex;align-items:center;gap:44px;margin-top:56px}
.dwrap{position:relative;width:540px;height:540px;flex:0 0 540px}
.dperc{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:104px;font-weight:900;color:__ACC__;letter-spacing:-.02em}
.dlab{flex:1;font-size:41px;font-weight:700;line-height:1.18}
""".replace("__ACC__",T["acc"])
return (''+hdr(ins,p)
+ '
'+svg+'
'+esc(ins["headline"])+'
'
+ '
'+esc(ins["headline_label"])+'
'
+ (tiles_html(tiles,p,T) if tiles else '
')
+ footer(ins,p)+'
')
GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight,
"list":genre_list, "grid":genre_grid, "quote":genre_quote, "donut":genre_donut,
"deal":genre_hero, "pulse":genre_hero}
FIT_JS = """"""
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"
if genre == "grid":
n_img = len([i for i in (ins.get("list") or []) if i.get("image_data")])
if n_img < 4: genre = "list" if n_img >= 2 else "hero"
if genre == "list" and not ins.get("list"): genre = "hero"
if genre == "donut" and parse_pct(ins.get("headline")) is None: genre = "hero"
if genre == "quote" and len(str(ins.get("fact") or "")) < 40: genre = "hero"
# les genres photo restent sur le thème sombre (les photos y ressortent mieux)
theme = "nuit" if genre in ("spotlight","list","grid") else theme_of(ins)
T = tokens(p, theme)
body = GENRES.get(genre, genre_hero)(ins, p, T)
html = (" "
+ ""+FIT_JS+"")
return html, genre, theme
def render(ins, out_dir):
os.makedirs(out_dir, exist_ok=True)
# télécharger les photos 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
for it in (ins.get("list") or []):
if it.get("image") and not it.get("image_data"):
du = data_uri(it["image"])
if du: it["image_data"] = du
html, genre, theme = build_html(ins)
key = hashlib.md5((ins["site"]+str(ins.get("insight_id",""))+genre+theme).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}-{theme}.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))