#!/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
' + '
'+url+'
GROUPE ·KA
') def tiles_html(tiles, p, T): cells = "" for v, l in tiles: cells += '
'+esc(v)+'
'+esc(l)+'
' 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 = ("" + "
"+body+"
"+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))