| |
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("&","&").replace("<","<").replace(">",">")) |
| |
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 | |