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%
23.7 KB · 417 lines python
Raw Blame History
1#!/usr/bin/env python32"""Rendu d'images sociales KA haute qualité via Chrome headless (HTML/CSS).34Lit un JSON « creative » sur stdin et produit une image 1080x1350 spectaculaire.56Genres : hero (nombre géant), ranking (palmarès), spotlight (produit vedette photo),7         list (sélection d'items photo), grid (mosaïque 2x2 de photos),8         donut (part en % en anneau SVG), quote (« le saviez-vous » typographique).9Thèmes (rotation via `variant` ou hash) : nuit (sombre), creme (clair néo-brutaliste Ka),10         cadre (sombre encadré). Les genres photo (spotlight/list/grid) restent en nuit.11Sortie : chemin du PNG sur stdout.12"""13import json, sys, os, re, base64, datetime, urllib.request, ssl, hashlib, subprocess, glob1415CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE1617def find_chrome():18    cands = glob.glob(os.path.expanduser(19        "~/Library/Caches/ms-playwright/chromium_headless_shell-*/chrome-headless-shell-mac-arm64/chrome-headless-shell"))20    if cands: return sorted(cands)[-1]21    for p in ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"]:22        if os.path.exists(p): return p23    raise SystemExit("chrome-headless-shell introuvable")2425MOIS = ["janvier","février","mars","avril","mai","juin","juillet","août",26        "septembre","octobre","novembre","décembre"]2728# palettes par site : accent vif (a), accent secondaire (b), dégradé sombre (g1,g2),29# accent assombri lisible sur fond clair (ad)30PALETTES = {31    "lou-ka":   {"a":"#ff6a00","b":"#ff9d4d","g1":"#2a1403","g2":"#0d0b09","ad":"#c24f00"},32    "immo-ka":  {"a":"#e23744","b":"#ff6b76","g1":"#2a0c10","g2":"#0d0a0b","ad":"#b02532"},33    "food-ka":  {"a":"#1f9d55","b":"#5fd08a","g1":"#07230f","g2":"#0a0d0b","ad":"#157a41"},34    "fabri-ka": {"a":"#c4532e","b":"#e8895f","g1":"#241009","g2":"#0d0a09","ad":"#9c3f21"},35    "auto-ka":  {"a":"#ff5a2a","b":"#ff8f6b","g1":"#26100a","g2":"#0d0a09","ad":"#cc3f14"},36    "vrai-prix":{"a":"#d9f26b","b":"#b8e04a","g1":"#16230a","g2":"#0b0d08","ad":"#6b7f1d"},37    "sorti-ka": {"a":"#d9f26b","b":"#ff8fd0","g1":"#210a1c","g2":"#0c090c","ad":"#6b7f1d"},38    "job-ka":   {"a":"#d9f26b","b":"#7ab8ff","g1":"#0a1522","g2":"#080b0d","ad":"#6b7f1d"},39}4041def pal(site): return PALETTES.get(site, PALETTES["vrai-prix"])4243def 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 None5455def esc(s): return (str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;"))5657def today_str():58    t = datetime.date.today()59    return f"{t.day} {MOIS[t.month-1]} {t.year}"6061def parse_pct(h):62    m = re.match(r'^\+?(\d+(?:[.,]\d+)?)\s*%$', str(h or "").strip())63    return float(m.group(1).replace(",", ".")) if m else None6465FONT = "'Avenir Next','SF Pro Display','Helvetica Neue',Helvetica,Arial,sans-serif"66THEMES = ["nuit","creme","cadre"]6768def theme_of(ins):69    t = ins.get("theme")70    if t in THEMES: return t71    v = ins.get("variant")72    if v is None:73        v = int(hashlib.md5((ins["site"]+str(ins.get("insight_id",""))).encode()).hexdigest(), 16)74    return THEMES[int(v) % len(THEMES)]7576def tokens(p, theme):77    if theme == "creme":78        return {79            "fg":"#141814","muted":"#141814b3","muted2":"#14181487","sig":"#14181466",80            "acc":p["ad"],81            "glass":"background:#ffffff;border:3px solid #141814;box-shadow:9px 9px 0 #141814;",82            "badge_extra":"border:3px solid #141814;box-shadow:7px 7px 0 #141814;",83            "numgrad":"background:linear-gradient(180deg,#141814 0%,"+p["ad"]+" 150%);",84            "numglow":"filter:drop-shadow(0 8px 22px "+p["a"]+"40);",85            "track":"#1418141f",86            "grain_op":".05",87            "stage":("background:radial-gradient(1100px 680px at 85% -8%, "+p["a"]+"30 0%, transparent 55%),"88                     "radial-gradient(900px 900px at -10% 112%, "+p["b"]+"26 0%, transparent 55%),"89                     "linear-gradient(168deg,#f7f6ef 0%,#edece4 100%);"),90            "frame":"",91        }92    t = {93        "fg":"#ffffff","muted":"#ffffffb0","muted2":"#ffffff8c","sig":"#ffffff66",94        "acc":p["a"],95        "glass":"background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);backdrop-filter:blur(8px);",96        "badge_extra":"box-shadow:0 10px 30px "+p["a"]+"55;",97        "numgrad":"background:linear-gradient(180deg,#fff 0%,"+p["a"]+" 130%);",98        "numglow":"filter:drop-shadow(0 12px 40px "+p["a"]+"44);",99        "track":"rgba(255,255,255,.09)",100        "grain_op":".045",101        "stage":("background:radial-gradient(1200px 700px at 82% -6%, "+p["a"]+"44 0%, transparent 55%),"102                 "radial-gradient(900px 900px at -12% 108%, "+p["b"]+"33 0%, transparent 55%),"103                 "linear-gradient(160deg, "+p["g1"]+" 0%, "+p["g2"]+" 100%);"),104        "frame":"",105    }106    if theme == "cadre":107        t["stage"] = ("background:radial-gradient(1100px 800px at 50% -12%, "+p["a"]+"3a 0%, transparent 60%),"108                      "radial-gradient(800px 800px at 100% 110%, "+p["b"]+"2a 0%, transparent 55%),"109                      "linear-gradient(200deg, "+p["g1"]+" 0%, "+p["g2"]+" 100%);")110        t["frame"] = (".stage::before{content:'';position:absolute;inset:26px;border:2px solid "+p["a"]+"66;"111                      "border-radius:36px;pointer-events:none;z-index:5}"112                      ".stage::after{content:'';position:absolute;inset:38px;border:1px solid #ffffff1c;"113                      "border-radius:28px;pointer-events:none;z-index:5}")114    return t115116def base_css(p, T):117    css = """118    *{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}119    html,body{width:1080px;height:1350px;overflow:hidden;font-family:__FONT__;color:__FG__;}120    .stage{position:relative;width:1080px;height:1350px;__STAGE__}121    __FRAME__122    .grain{position:absolute;inset:0;opacity:__GOP__;mix-blend-mode:overlay;123      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>");}124    .pad{position:absolute;inset:0;padding:70px 72px 250px;display:flex;flex-direction:column;z-index:2;}125    .eyebrow{display:flex;align-items:center;gap:14px;font-weight:800;letter-spacing:.16em;126      font-size:22px;text-transform:uppercase;color:__ACC__;}127    .eyebrow .dot{width:40px;height:5px;border-radius:3px;background:__ACC__;}128    .kabadge{position:absolute;top:60px;right:72px;background:__A__;color:#0d0d0d;129      font-weight:900;font-size:40px;padding:8px 20px;border-radius:16px;transform:rotate(-4deg);z-index:6;__BADGE__}130    .site{font-size:88px;font-weight:900;letter-spacing:-.02em;margin-top:26px;}131    .tag{font-size:31px;font-weight:600;color:__MUTED__;margin-top:6px;}132    .foot{position:absolute;left:72px;right:72px;bottom:66px;display:flex;justify-content:space-between;133      align-items:center;font-weight:800;z-index:6;}134    .foot .url{font-size:34px;color:__ACC__;letter-spacing:.01em;}135    .foot .brand{font-size:26px;color:__MUTED2__;letter-spacing:.12em;}136    .sig{position:absolute;left:72px;bottom:150px;font-size:20px;color:__SIG__;font-weight:600;137      display:flex;align-items:center;gap:8px;z-index:6;}138    .glass{border-radius:22px;__GLASS__}139    """140    return (css.replace("__FONT__",FONT).replace("__FG__",T["fg"]).replace("__STAGE__",T["stage"])141      .replace("__FRAME__",T["frame"]).replace("__GOP__",T["grain_op"])142      .replace("__MUTED2__",T["muted2"]).replace("__MUTED__",T["muted"]).replace("__SIG__",T["sig"])143      .replace("__GLASS__",T["glass"]).replace("__BADGE__",T["badge_extra"])144      .replace("__ACC__",T["acc"]).replace("__A__",p["a"]))145146def hdr(ins, p, kicker="STAT DU JOUR"):147    return ('<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · '148        + esc(kicker)+' · '+today_str().upper()+'</div>'149        + '<div class="kabadge">KA</div>'150        + '<div class="site">'+esc(ins["label"])+'</div>'151        + (('<div class="tag">'+esc(ins.get("tagline",""))+'</div>') if ins.get("tagline") else ''))152153def footer(ins, p):154    url = "www." + ins["site"] + ".com"155    return ('<div class="sig">✶ Rédigé et publié par l\'Agent KA</div>'156        + '<div class="foot"><div class="url">'+url+'</div><div class="brand">GROUPE ·KA</div></div>')157158def tiles_html(tiles, p, T):159    cells = ""160    for v, l in tiles:161        cells += '<div class="glass tile"><div class="tv" data-fit>'+esc(v)+'</div><div class="tl">'+esc(l)+'</div></div>'162    css = """163    .tiles{display:grid;grid-template-columns:repeat(__N__,1fr);gap:18px;margin-top:auto;margin-bottom:0;}164    .tile{padding:22px 24px;}165    .tv{font-size:46px;font-weight:900;color:__ACC__;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;}166    .tl{font-size:23px;font-weight:600;color:__MUTED__;margin-top:6px;line-height:1.2;}167    """.replace("__N__",str(len(tiles))).replace("__ACC__",T["acc"]).replace("__MUTED__",T["muted"])168    return '<div class="tiles">'+cells+'</div><style>'+css+'</style>'169170def genre_hero(ins, p, T):171    tiles = ins.get("tiles") or []172    hl = str(ins["headline"]); L = len(hl)173    numsize = 150 if L >= 10 else (190 if L >= 8 else 230)174    css = """175    .hero{margin-top:70px;}176    .num{font-size:__NS__px;font-weight:900;line-height:.9;letter-spacing:-.03em;display:block;177      max-width:100%;white-space:nowrap;overflow:hidden;178      __NUMGRAD__-webkit-background-clip:text;-webkit-text-fill-color:transparent;__NUMGLOW__}179    .lab{font-size:44px;font-weight:700;margin-top:20px;max-width:820px;line-height:1.12;}180    """.replace("__NS__",str(numsize)).replace("__NUMGRAD__",T["numgrad"]).replace("__NUMGLOW__",T["numglow"])181    return ('<div class="pad">'+hdr(ins,p)182        + '<div class="hero"><div class="num" data-fit>'+esc(hl)+'</div>'183        + '<div class="lab">'+esc(ins["headline_label"])+'</div></div>'184        + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')185        + footer(ins,p)+'</div><style>'+css+'</style>')186187def genre_ranking(ins, p, T):188    bars = ins.get("bars") or []189    # avec sous-textes, 6 rangées débordent sur le pied de page -> max 5190    has_sub = any(len(b) > 3 and b[3] for b in bars)191    bars = bars[:5] if has_sub else bars[:6]192    maxv = max([b[1] for b in bars] or [1])193    rows = ""194    for b in bars:195        label, val, vtxt = b[0], b[1], b[2]196        sub = b[3] if len(b) > 3 else ""197        w = max(8, int(100*val/maxv))198        rows += ('<div class="row"><div class="rl"><span class="rlab">'+esc(label)+'</span>'199            + '<span class="rval">'+esc(vtxt)+'</span></div>'200            + '<div class="track"><div class="fill" style="width:'+str(w)+'%"></div></div>'201            + (('<div class="rsub">'+esc(sub)+'</div>') if sub else '')+'</div>')202    css = """203    .rank-title{font-size:40px;font-weight:800;margin-top:50px;margin-bottom:8px;}204    .rows{margin-top:20px;display:flex;flex-direction:column;gap:30px;}205    .rl{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:12px;}206    .rlab{font-size:34px;font-weight:700;}207    .rval{font-size:36px;font-weight:900;color:__ACC__;}208    .track{height:20px;border-radius:12px;background:__TRACK__;overflow:hidden;}209    .fill{height:100%;border-radius:12px;background:linear-gradient(90deg,__ACC__,__B__);210      box-shadow:0 4px 18px __A__55;}211    .rsub{font-size:22px;color:__MUTED2__;margin-top:8px;font-weight:600;}212    """.replace("__ACC__",T["acc"]).replace("__TRACK__",T["track"]).replace("__B__",p["b"]) \213       .replace("__A__",p["a"]).replace("__MUTED2__",T["muted2"])214    return ('<div class="pad">'+hdr(ins,p)215        + '<div class="rank-title">'+esc(ins["headline_label"])+'</div>'216        + '<div class="rows">'+rows+'</div>'+footer(ins,p)+'</div><style>'+css+'</style>')217218def genre_spotlight(ins, p, T):219    prod = ins.get("product") or {}220    img = prod.get("image_data") or ""221    chips = "".join('<span class="chip">'+esc(c)+'</span>' for c in (prod.get("meta") or []))222    price = prod.get("price","")223    css = """224    .stage-photo{position:absolute;top:0;left:0;right:0;height:760px;background-size:cover;background-position:center;}225    .scrim{position:absolute;inset:0;background:226      linear-gradient(180deg, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 34%, __G2__cc 62%, __G2__ 82%);}227    .spot{margin-top:auto;}228    .ptitle{font-size:62px;font-weight:900;line-height:1.02;letter-spacing:-.02em;max-width:900px;229      text-shadow:0 4px 24px #000a;}230    .psub{font-size:30px;font-weight:600;color:#ffffffc0;margin-top:12px;}231    .chips{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}232    .chip{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);border-radius:999px;233      padding:10px 20px;font-size:24px;font-weight:700;}234    .price{font-size:80px;font-weight:900;color:__A__;margin-top:24px;letter-spacing:-.02em;235      filter:drop-shadow(0 8px 24px __A__55);}236    .tagline2{font-size:28px;font-weight:600;color:#ffffffa8;margin-top:18px;}237    """.replace("__G2__",p["g2"]).replace("__A__",p["a"])238    return ('<div class="stage-photo" style="background-image:url(\''+img+'\')"></div><div class="scrim"></div>'239        + '<div class="pad">'240        + '<div class="eyebrow"><span class="dot"></span>'+esc(ins["label"].upper())+' · À DÉCOUVRIR · '+today_str().upper()+'</div>'241        + '<div class="kabadge">KA</div>'242        + '<div class="spot"><div class="ptitle">'+esc(prod.get("title",""))+'</div>'243        + '<div class="psub">'+esc(prod.get("subtitle",""))+'</div>'244        + '<div class="chips">'+chips+'</div>'245        + (('<div class="price">'+esc(price)+'</div>') if price else '')246        + '<div class="tagline2">'+esc(ins.get("tagline",""))+'</div></div>'247        + footer(ins,p)+'</div><style>'+css+'</style>')248249def genre_list(ins, p, T):250    items = ins.get("list") or []251    rows = ""252    for it in items[:5]:253        img = it.get("image_data") or ""254        thumb = '<div class="lthumb" style="background-image:url(\'' + img + '\')"></div>' if img else '<div class="lthumb"></div>'255        price = esc(it.get("price", "") or "")256        pr = ('<div class="lp">' + price + '</div>') if price else ''257        rows += ('<div class="lrow">' + thumb258                 + '<div class="lmid"><div class="lt">' + esc(it.get("title", "")) + '</div>'259                 + '<div class="ls">' + esc(it.get("subtitle", "") or "") + '</div></div>'260                 + pr + '</div>')261    heading = esc(ins.get("list_title", "") or ins.get("headline_label", "") or "À la une")262    css = """263    .ltitle{font-size:40px;font-weight:800;color:#fff;margin-top:8px;margin-bottom:18px;max-width:920px;line-height:1.1}264    .lwrap{display:flex;flex-direction:column;gap:15px}265    .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}266    .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)}267    .lmid{flex:1;min-width:0}268    .lt{font-size:32px;font-weight:800;line-height:1.12;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}269    .ls{font-size:25px;font-weight:600;color:#ffffffa0;margin-top:6px}270    .lp{font-size:36px;font-weight:900;color:__A__;white-space:nowrap;text-align:right}271    """.replace("__A__", p["a"])272    return ('<div class="pad">'273        + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · ' + today_str().upper() + '</div>'274        + '<div class="kabadge">KA</div>'275        + '<div class="site" style="font-size:68px;margin-top:14px">' + esc(ins["label"]) + '</div>'276        + '<div class="ltitle">' + heading + '</div>'277        + '<div class="lwrap">' + rows + '</div>'278        + footer(ins, p)279        + '</div><style>' + css + '</style>')280281def genre_grid(ins, p, T):282    items = [it for it in (ins.get("list") or []) if it.get("image_data")][:4]283    cells = ""284    for it in items:285        cells += ('<div class="cell"><div class="cph" style="background-image:url(\''+it["image_data"]+'\')"></div>'286            + '<div class="cscrim"></div><div class="cbot"><div class="ct">'+esc(it.get("title",""))+'</div>'287            + (('<div class="cp">'+esc(it["price"])+'</div>') if it.get("price") else '')288            + '</div></div>')289    heading = esc(ins.get("list_title","") or ins.get("headline_label","") or "À la une")290    css = """291    .gtitle{font-size:42px;font-weight:800;color:#fff;margin-top:10px;margin-bottom:22px;max-width:920px;line-height:1.1}292    .gwrap{display:grid;grid-template-columns:1fr 1fr;gap:18px}293    .cell{position:relative;height:350px;border-radius:22px;overflow:hidden;border:1px solid rgba(255,255,255,.16)}294    .cph{position:absolute;inset:0;background-size:cover;background-position:center}295    .cscrim{position:absolute;inset:0;background:linear-gradient(180deg,transparent 42%,#000d 100%)}296    .cbot{position:absolute;left:18px;right:18px;bottom:14px}297    .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}298    .cp{font-size:28px;font-weight:900;color:__A__;margin-top:4px;text-shadow:0 2px 10px #000}299    """.replace("__A__", p["a"])300    return ('<div class="pad">'301        + '<div class="eyebrow"><span class="dot"></span>' + esc(ins["label"].upper()) + ' · EN IMAGES · ' + today_str().upper() + '</div>'302        + '<div class="kabadge">KA</div>'303        + '<div class="site" style="font-size:68px;margin-top:14px">' + esc(ins["label"]) + '</div>'304        + '<div class="gtitle">' + heading + '</div>'305        + '<div class="gwrap">' + cells + '</div>'306        + footer(ins, p)307        + '</div><style>' + css + '</style>')308309NUMRE = re.compile(r'(\d[\d\u00a0\u202f ,\.]*\d|\d)(\s?(%|\$|G\$|M\$|milliards?|millions?))?')310311def genre_quote(ins, p, T):312    fact = str(ins.get("fact") or (str(ins["headline"])+" "+ins.get("headline_label","")))313    txt = NUMRE.sub(lambda m: '<em>'+m.group(0)+'</em>', esc(fact))314    L = len(fact)315    fs = 64 if L < 170 else (56 if L < 240 else 48)316    tiles = (ins.get("tiles") or [])[:3]317    css = """318    .qmark{font-size:230px;line-height:.55;color:__ACC__;font-weight:900;margin-top:70px;height:118px;319      font-family:Georgia,serif;opacity:.9}320    .qtext{font-size:__FS__px;font-weight:800;line-height:1.3;letter-spacing:-.01em;max-width:930px;}321    .qtext em{font-style:normal;color:__ACC__;}322    """.replace("__ACC__",T["acc"]).replace("__FS__",str(fs))323    return ('<div class="pad">'+hdr(ins,p,"LE SAVIEZ-VOUS ?")324        + '<div class="qmark">“</div>'325        + '<div class="qtext">'+txt+'</div>'326        + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')327        + footer(ins,p)+'</div><style>'+css+'</style>')328329def genre_donut(ins, p, T):330    pct = parse_pct(ins["headline"]) or 0.0331    C = 2*3.14159*210332    filled = C*min(100.0, pct)/100.0333    svg = ('<svg width="540" height="540" viewBox="0 0 540 540">'334        + '<circle cx="270" cy="270" r="210" fill="none" stroke="'+T["track"]+'" stroke-width="60"/>'335        + '<circle cx="270" cy="270" r="210" fill="none" stroke="url(#gd)" stroke-width="60" stroke-linecap="round" '336        + 'stroke-dasharray="'+str(round(filled,1))+' '+str(round(C,1))+'" transform="rotate(-90 270 270)"/>'337        + '<defs><linearGradient id="gd" x1="0" y1="0" x2="1" y2="1">'338        + '<stop offset="0" stop-color="'+T["acc"]+'"/><stop offset="1" stop-color="'+p["b"]+'"/></linearGradient></defs></svg>')339    tiles = (ins.get("tiles") or [])[:3]340    css = """341    .drow{display:flex;align-items:center;gap:44px;margin-top:56px}342    .dwrap{position:relative;width:540px;height:540px;flex:0 0 540px}343    .dperc{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;344      font-size:104px;font-weight:900;color:__ACC__;letter-spacing:-.02em}345    .dlab{flex:1;font-size:41px;font-weight:700;line-height:1.18}346    """.replace("__ACC__",T["acc"])347    return ('<div class="pad">'+hdr(ins,p)348        + '<div class="drow"><div class="dwrap">'+svg+'<div class="dperc">'+esc(ins["headline"])+'</div></div>'349        + '<div class="dlab">'+esc(ins["headline_label"])+'</div></div>'350        + (tiles_html(tiles,p,T) if tiles else '<div style="margin-top:auto"></div>')351        + footer(ins,p)+'</div><style>'+css+'</style>')352353GENRES = {"hero":genre_hero, "ranking":genre_ranking, "spotlight":genre_spotlight,354          "list":genre_list, "grid":genre_grid, "quote":genre_quote, "donut":genre_donut,355          "deal":genre_hero, "pulse":genre_hero}356357FIT_JS = """<script>358document.querySelectorAll('[data-fit]').forEach(function(el){359  var s=parseFloat(getComputedStyle(el).fontSize);360  while(el.scrollWidth>el.clientWidth && s>24){ s-=4; el.style.fontSize=s+'px'; }361});362</script>"""363364def build_html(ins):365    p = pal(ins["site"])366    genre = ins.get("genre","hero")367    if genre == "ranking" and not ins.get("bars"): genre = "hero"368    if genre == "spotlight" and not (ins.get("product") or {}).get("image_data"): genre = "hero"369    if genre == "grid":370        n_img = len([i for i in (ins.get("list") or []) if i.get("image_data")])371        if n_img < 4: genre = "list" if n_img >= 2 else "hero"372    if genre == "list" and not ins.get("list"): genre = "hero"373    if genre == "donut" and parse_pct(ins.get("headline")) is None: genre = "hero"374    if genre == "quote" and len(str(ins.get("fact") or "")) < 40: genre = "hero"375    # les genres photo restent sur le thème sombre (les photos y ressortent mieux)376    theme = "nuit" if genre in ("spotlight","list","grid") else theme_of(ins)377    T = tokens(p, theme)378    body = GENRES.get(genre, genre_hero)(ins, p, T)379    html = ("<!doctype html><html><head><meta charset=\"utf-8\"><style>"+base_css(p,T)+"</style></head>"380        + "<body><div class=\"stage\"><div class=\"grain\"></div>"+body+"</div>"+FIT_JS+"</body></html>")381    return html, genre, theme382383def render(ins, out_dir):384    os.makedirs(out_dir, exist_ok=True)385    # télécharger les photos en data-uri (robuste, pas de hotlink)386    prod = ins.get("product")387    if prod and prod.get("image") and not prod.get("image_data"):388        du = data_uri(prod["image"])389        if du: prod["image_data"] = du390    for it in (ins.get("list") or []):391        if it.get("image") and not it.get("image_data"):392            du = data_uri(it["image"])393            if du: it["image_data"] = du394    html, genre, theme = build_html(ins)395    key = hashlib.md5((ins["site"]+str(ins.get("insight_id",""))+genre+theme).encode()).hexdigest()[:8]396    html_path = os.path.join(out_dir, f"card-{ins['site']}-{key}.html")397    big = os.path.join(out_dir, f"_big-{key}.png")398    out = os.path.join(out_dir, f"card-{ins['site']}-{ins.get('insight_id','x')}-{genre}-{theme}.png")399    open(html_path,"w",encoding="utf-8").write(html)400    chrome = find_chrome()401    subprocess.run([chrome,"--headless","--disable-gpu","--hide-scrollbars",402        "--force-device-scale-factor=2","--window-size=1080,1350",403        f"--screenshot={big}","--virtual-time-budget=9000","--run-all-compositor-stages-before-draw",404        f"file://{html_path}"], capture_output=True, timeout=60)405    if not os.path.exists(big):406        raise SystemExit("chrome screenshot a échoué")407    # downscale 2x → 1080x1350 (texte net) via sips408    subprocess.run(["/usr/bin/sips","-z","1350","1080",big,"--out",out],capture_output=True)409    try: os.remove(big); os.remove(html_path)410    except: pass411    return out412413if __name__ == "__main__":414    ins = json.load(sys.stdin)415    out_dir = sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-social-cards"416    print(render(ins, out_dir))417