$ cat node-template.py
S
Social Post Payload
// Builds a social media post payload and renders a platform-native HTML preview (Facebook, Instagram, X/Twitter, LinkedIn). Does not publish — the payload feeds a future publisher node.
Process
Communication
#social#post#preview#facebook#instagram#twitter#linkedin
template.py
1"""2social-node — Social Post Payload34Builds a normalised payload + a platform-native HTML preview for a social media5post. Does NOT publish — the payload is meant to feed a future publisher node.67Hard rules (raise ValueError → node fails):8- platform=instagram: image is required9- platform=twitter: description+url length must fit 280 chars (URL counts as 23)10- platform=linkedin: description max 3000 chars11- platform=instagram: description max 2200 chars1213Soft warnings (returned in validation_warnings, node succeeds):14- platform=facebook + no image → "Posts with images get ~2x engagement"15- platform=linkedin + description < 100 → "Longer posts perform better on LinkedIn"16- platform=twitter + no hashtag → "Adding 1-2 hashtags increases reach"1718Contract:19- Reads one JSON envelope from stdin: {"inputs": {...}, "config": {...}, ...}20- The keys declared in input_schema.json live under envelope["inputs"].21- Writes a single JSON document to stdout keyed by the output-schema properties.22- Any error must be raised as a Python exception OR written to stderr + non-zero23 exit code; the marketplace sandbox uses both signals.2425ASCII data flow:2627 inputs ──→ validate() ──→ build_payload() ──→ render_preview(payload, platform)28 │ │29 ↓ raises ValueError on hard fail ↓30 │ payload, preview_html,31 └─ accumulates soft warnings ────────────→ validation_warnings32"""3334from __future__ import annotations3536import base6437import html38import json39import mimetypes40import re41import sys42from typing import Any4344# Default display name shown in the preview header when `account_name` is not45# provided. The publisher node will eventually pull the real account name from46# WorkspaceSecret / OAuth profile and overwrite this.47_DEFAULT_ACCOUNT_NAME = "Emblema"4849# ── Platform constants ────────────────────────────────────────────────────────5051PLATFORMS = {"facebook", "instagram", "twitter", "linkedin"}5253# Hard limits (exceeded → ValueError)54TWITTER_MAX_CHARS = 28055TWITTER_URL_WEIGHT = 23 # Twitter t.co shortener accounting56INSTAGRAM_MAX_CHARS = 220057LINKEDIN_MAX_CHARS = 30005859# Soft thresholds60LINKEDIN_SHORT_THRESHOLD = 1006162HASHTAG_RE = re.compile(r"(^|\s)#[A-Za-z0-9_]+")636465# ── Validation ────────────────────────────────────────────────────────────────666768def validate(inputs: dict[str, Any]) -> list[str]:69 """Run hard + soft validations. Returns the soft-warnings list.7071 Raises ValueError on the first hard failure (so the node FAILs with a clear72 error visible in the Tasks panel).73 """74 platform = inputs.get("platform") or ""75 if platform not in PLATFORMS:76 raise ValueError(77 f"Unknown platform '{platform}'. Expected one of: "78 + ", ".join(sorted(PLATFORMS))79 )8081 description = inputs.get("description") or ""82 image = inputs.get("image") or ""83 url = inputs.get("url") or ""8485 if not description.strip():86 raise ValueError("`description` cannot be empty.")8788 warnings: list[str] = []8990 # Instagram: image required (hard)91 if platform == "instagram" and not image:92 raise ValueError(93 "Instagram posts require an image. Connect an Image output to the "94 "`image` input."95 )9697 # Twitter: 280-char hard limit (URL counts 23)98 if platform == "twitter":99 effective_len = len(description)100 if url:101 effective_len += TWITTER_URL_WEIGHT + 1 # +1 for separating space102 if effective_len > TWITTER_MAX_CHARS:103 raise ValueError(104 f"Twitter post exceeds {TWITTER_MAX_CHARS} chars "105 f"({effective_len}/{TWITTER_MAX_CHARS}). Shorten the description."106 )107108 # Instagram description hard limit109 if platform == "instagram" and len(description) > INSTAGRAM_MAX_CHARS:110 raise ValueError(111 f"Instagram caption exceeds {INSTAGRAM_MAX_CHARS} chars "112 f"({len(description)}/{INSTAGRAM_MAX_CHARS})."113 )114115 # LinkedIn description hard limit116 if platform == "linkedin" and len(description) > LINKEDIN_MAX_CHARS:117 raise ValueError(118 f"LinkedIn post exceeds {LINKEDIN_MAX_CHARS} chars "119 f"({len(description)}/{LINKEDIN_MAX_CHARS})."120 )121122 # ── Soft warnings ────────────────────────────────────────────────────────123 if platform == "facebook" and not image:124 warnings.append("Posts with images get ~2x more engagement on Facebook.")125126 if platform == "linkedin" and len(description) < LINKEDIN_SHORT_THRESHOLD:127 warnings.append(128 "Longer, story-style posts (200+ chars) tend to perform better on LinkedIn."129 )130131 if platform == "twitter" and not HASHTAG_RE.search(description):132 warnings.append("Adding 1-2 hashtags can increase reach on X/Twitter.")133134 return warnings135136137# ── Payload assembly ──────────────────────────────────────────────────────────138139140def extract_hashtags(text: str) -> list[str]:141 """Pull hashtags out of the description text (used by future publishers)."""142 return [m.group(0).strip().lstrip("#") for m in HASHTAG_RE.finditer(text)]143144145def build_payload(inputs: dict[str, Any]) -> dict[str, Any]:146 """Normalised payload — stable contract for the future publisher nodes."""147 account_name = (inputs.get("account_name") or "").strip() or _DEFAULT_ACCOUNT_NAME148 return {149 "platform": inputs.get("platform") or "",150 "account_name": account_name,151 "description": (inputs.get("description") or "").strip(),152 "image": inputs.get("image") or None,153 "url": (inputs.get("url") or "").strip() or None,154 "hashtags": extract_hashtags(inputs.get("description") or ""),155 }156157158# ── HTML preview templates ────────────────────────────────────────────────────159#160# Each renderer returns a COMPLETE HTML document (starts with <!DOCTYPE html>)161# so the workspace iframe preview (sandbox="allow-scripts") can render it.162# All user content is HTML-escaped to prevent injection. URLs are also escaped163# but rendered without href to avoid making AI-generated links clickable inside164# the preview (consistent with the security model used for web-sync PDFs).165# ----------------------------------------------------------------------------166167168def _e(s: str | None) -> str:169 """HTML-escape a possibly-None string."""170 return html.escape(s or "", quote=True)171172173# Hardcoded placeholder identity used wherever a real account/network name174# would appear (avatars, "recommended by", "liked by" footers). Real publishing175# will overwrite this once the publisher node ships — for now these strings176# stand in for the connected social account.177_PLACEHOLDER_NAME = "Emblema"178_PLACEHOLDER_NETWORK = "Emblema Network"179_PLACEHOLDER_HANDLE_FALLBACK = "emblema"180_HANDLE_CLEAN_RE = re.compile(r"[^a-z0-9_]+")181182183def _initial(s: str | None) -> str:184 """First letter of `s` (uppercase), defaulting to 'E' for Emblema."""185 text = (s or _PLACEHOLDER_NAME).strip()186 return _e(text[:1].upper() if text else "E")187188189def _handle(s: str | None) -> str:190 """Slugify `s` into a plausible @handle. Fallback: 'emblema'."""191 base = (s or "").strip().lower().replace(" ", "_")192 clean = _HANDLE_CLEAN_RE.sub("", base) or _PLACEHOLDER_HANDLE_FALLBACK193 return _e(clean[:24])194195196# Max bytes inlined as data: URI in the preview. ~10 MiB covers 1024×1024197# AI-generated PNGs (typical Flux/SDXL outputs sit at 3–8 MB) while keeping198# the HTML reasonably small for the iframe.199_MAX_INLINE_IMAGE_BYTES = 10 * 1024 * 1024200201# Workspace executor staging dirs (bind-mounted by docker_executor.py):202# /data/input — files staged FROM upstream node ports (read-only)203# /data/output — files this node writes204# When an upstream Image port emits "generated_image.png", the executor stages205# it under /data/input/<basename>. The bare basename arrives in `inputs`.206_IMAGE_STAGING_DIRS = ("/data/input", "/data/output")207208209def _read_image_bytes(path: str) -> bytes | None:210 """Open `path` and read up to the size cap. Returns None on failure or oversize."""211 try:212 with open(path, "rb") as f:213 blob = f.read(_MAX_INLINE_IMAGE_BYTES + 1)214 except (OSError, ValueError):215 return None216 if not blob or len(blob) > _MAX_INLINE_IMAGE_BYTES:217 return None218 return blob219220221def _image_to_data_uri(value: str | None) -> str | None:222 """Convert an image input to a renderable preview src.223224 The workspace executor passes file-port values as bare basenames; the225 actual file lives in /data/input/<basename> on the read-only staging bind.226 The iframe preview cannot reach those paths over the network, so we read227 the bytes and inline them as a data: URI.228229 - HTTP(S) URL or existing data: URI → passthrough.230 - Absolute local path → try directly.231 - Bare filename → probe /data/input/, then /data/output/.232 - Anything unreadable / too big → None (caller shows placeholder).233 """234 if not value or not isinstance(value, str):235 return None236 if value.startswith(("http://", "https://", "data:")):237 return value238239 if value.startswith("/"):240 candidates = [value]241 else:242 candidates = [f"{d}/{value}" for d in _IMAGE_STAGING_DIRS]243244 blob: bytes | None = None245 resolved: str | None = None246 for candidate in candidates:247 blob = _read_image_bytes(candidate)248 if blob is not None:249 resolved = candidate250 break251 if blob is None or resolved is None:252 return None253254 mime, _ = mimetypes.guess_type(resolved)255 mime = mime or "application/octet-stream"256 return f"data:{mime};base64,{base64.b64encode(blob).decode('ascii')}"257258259def _render_image_block(260 image: str | None,261 *,262 aspect: str = "1.91/1",263 placeholder_label: str = "Image preview",264 extra_class: str = "",265) -> str:266 """Render either <img> or a placeholder div with the given aspect ratio."""267 cls = f"img {extra_class}".strip()268 src = _image_to_data_uri(image)269 if src:270 return f'<img class="{cls}" src="{_e(src)}" alt="">'271 return (272 f'<div class="{cls} img-placeholder" '273 f'style="aspect-ratio:{aspect}">{_e(placeholder_label)}</div>'274 )275276277_BASE_RESET = (278 "*{box-sizing:border-box;margin:0;padding:0}"279 "img{display:block;max-width:100%}"280 "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;"281 "padding:24px;display:flex;justify-content:center;-webkit-font-smoothing:antialiased}"282)283284285# ── Facebook ──────────────────────────────────────────────────────────────────286287288def render_facebook(payload: dict[str, Any]) -> str:289 account_name = payload["account_name"]290 description = payload["description"]291 image_block = _render_image_block(292 payload["image"], aspect="4/3", placeholder_label="Image preview"293 )294 return f"""<!DOCTYPE html>295<html lang="en"><head><meta charset="utf-8"><title>Facebook preview</title>296<style>{_BASE_RESET}297 body{{background:#f0f2f5;color:#050505}}298 .card{{background:white;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.1);max-width:560px;width:100%;overflow:hidden}}299 .header{{display:flex;align-items:center;padding:12px 16px;gap:8px}}300 .avatar{{width:40px;height:40px;border-radius:50%;background:#1877f2;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:18px}}301 .meta{{flex:1;min-width:0}}302 .name{{font-weight:600;font-size:15px;color:#050505;line-height:1.2}}303 .submeta{{font-size:13px;color:#65676b;margin-top:2px;display:flex;align-items:center;gap:4px}}304 .more{{color:#65676b;font-size:18px;padding:8px;cursor:pointer}}305 .body{{padding:0 16px 12px;font-size:15px;color:#050505;line-height:1.4;white-space:pre-wrap;word-break:break-word}}306 .body .link{{color:#1877f2;font-weight:500;word-break:break-all}}307 .body .altro{{color:#65676b;cursor:pointer;font-weight:500}}308 .img{{width:100%;object-fit:cover;background:#e4e6eb;max-height:600px}}309 .img-placeholder{{display:flex;align-items:center;justify-content:center;color:#65676b;font-size:13px;background:linear-gradient(135deg,#e4e6eb,#bcc0c4)}}310 .reactions{{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;font-size:13px;color:#65676b}}311 .reactions .emojis{{display:inline-flex;align-items:center;gap:1px;font-size:14px;margin-right:6px}}312 .reactions .left{{display:flex;align-items:center}}313 .reactions .counts{{display:flex;gap:10px}}314 .actions{{display:flex;border-top:1px solid #ced0d4;padding:4px 8px}}315 .action{{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;padding:8px;font-size:14px;color:#65676b;font-weight:600;cursor:pointer;border-radius:6px}}316 .action:hover{{background:#f0f2f5}}317</style></head><body>318<div class="card">319 <div class="header">320 <div class="avatar">{_initial(account_name)}</div>321 <div class="meta">322 <div class="name">{_e(account_name)}</div>323 <div class="submeta">4 h · 🌐</div>324 </div>325 <div class="more">⋯</div>326 </div>327 <div class="body">{_e(description)}{(' <span class="link">' + _e(payload['url']) + '</span>') if payload['url'] else ''} <span class="altro">Altro...</span></div>328 {image_block}329 <div class="reactions">330 <div class="left">331 <span class="emojis">👍❤️😢</span>332 <span>39</span>333 </div>334 <div class="counts">335 <span>4 commenti</span>336 <span>6 condivisioni</span>337 </div>338 </div>339 <div class="actions">340 <div class="action">👍 Mi piace</div>341 <div class="action">💬 Commenta</div>342 <div class="action">↗ Condividi</div>343 </div>344</div>345</body></html>"""346347348# ── Instagram ─────────────────────────────────────────────────────────────────349350351def render_instagram(payload: dict[str, Any]) -> str:352 account_name = payload["account_name"]353 description = payload["description"]354 hashtags = payload["hashtags"]355 hashtags_inline = (356 " " + " ".join(f"#{_e(h)}" for h in hashtags) if hashtags else ""357 )358 image_block = _render_image_block(359 payload["image"], aspect="1/1", placeholder_label="Instagram image"360 )361 return f"""<!DOCTYPE html>362<html lang="en"><head><meta charset="utf-8"><title>Instagram preview</title>363<style>{_BASE_RESET}364 body{{background:#000;color:#fafafa}}365 .card{{background:#000;max-width:470px;width:100%;overflow:hidden;border-radius:4px}}366 .header{{display:flex;align-items:center;padding:10px 12px;gap:10px}}367 .avatar-ring{{width:34px;height:34px;border-radius:50%;background:conic-gradient(from 45deg,#feda77,#f58529,#dd2a7b,#8134af,#515bd4,#feda77);padding:2px;flex-shrink:0}}368 .avatar{{width:30px;height:30px;border-radius:50%;background:#262626;border:2px solid #000;display:flex;align-items:center;justify-content:center;color:#fafafa;font-weight:600;font-size:13px}}369 .name{{font-weight:600;font-size:14px;color:#fafafa;flex:1;display:flex;align-items:baseline;gap:6px;min-width:0}}370 .name .handle{{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}371 .name .dot{{color:#a8a8a8;font-weight:400}}372 .name .time{{color:#a8a8a8;font-weight:400;font-size:13px}}373 .more{{color:#fafafa;font-size:18px;cursor:pointer}}374 .img{{width:100%;object-fit:cover;background:#262626}}375 .img-placeholder{{display:flex;align-items:center;justify-content:center;color:#a8a8a8;font-size:13px;background:linear-gradient(135deg,#262626,#3a3a3a)}}376 .ig-actions{{display:flex;align-items:center;padding:8px 12px;gap:14px;font-size:24px;color:#fafafa}}377 .ig-actions .save{{margin-left:auto}}378 .likes{{padding:4px 12px;font-size:14px;color:#fafafa;display:flex;align-items:center;gap:6px}}379 .likes .heart{{font-size:14px}}380 .likes b{{font-weight:600}}381 .body{{padding:4px 12px 4px;font-size:14px;color:#fafafa;line-height:1.35;white-space:pre-wrap;word-break:break-word}}382 .body .name-inline{{font-weight:600;margin-right:6px}}383 .body .altro{{color:#a8a8a8;cursor:pointer}}384 .time-foot{{padding:8px 12px 4px;font-size:11px;color:#a8a8a8;text-transform:uppercase;letter-spacing:.3px}}385</style></head><body>386<div class="card">387 <div class="header">388 <div class="avatar-ring"><div class="avatar">{_initial(account_name)}</div></div>389 <div class="name">390 <span class="handle">{_e(account_name)}</span>391 <span class="dot">•</span>392 <span class="time">6 g</span>393 </div>394 <div class="more">⋯</div>395 </div>396 {image_block}397 <div class="ig-actions">398 <span>♡</span>399 <span>💬</span>400 <span>➤</span>401 <span class="save">🔖</span>402 </div>403 <div class="likes"><span class="heart">❤️</span> Piace a <b>{_PLACEHOLDER_NAME.lower()}_friend</b> e altri 30</div>404 <div class="body"><span class="name-inline">{_e(account_name)}</span>{_e(description)}{hashtags_inline} <span class="altro">altro</span></div>405 <div class="time-foot">6 giorni fa</div>406</div>407</body></html>"""408409410# ── X / Twitter ───────────────────────────────────────────────────────────────411412413def render_twitter(payload: dict[str, Any]) -> str:414 account_name = payload["account_name"]415 description = payload["description"]416 char_count = len(description) + (417 TWITTER_URL_WEIGHT + 1 if payload["url"] else 0418 )419 media_block = (420 f'<div class="media">{_render_image_block(payload["image"], aspect="4/3", placeholder_label="Media")}</div>'421 if payload["image"]422 else ""423 )424 url_inline = (425 f' <span class="link">{_e(payload["url"])}</span>'426 if payload["url"]427 else ""428 )429 return f"""<!DOCTYPE html>430<html lang="en"><head><meta charset="utf-8"><title>X preview</title>431<style>{_BASE_RESET}432 body{{background:#000;color:#e7e9ea}}433 .card{{background:#000;border:1px solid #2f3336;border-radius:16px;max-width:600px;width:100%;overflow:hidden}}434 .header{{display:flex;padding:12px 16px 4px;gap:12px;align-items:flex-start}}435 .avatar{{width:40px;height:40px;border-radius:50%;background:#536471;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:18px}}436 .meta{{flex:1;min-width:0}}437 .name-row{{display:flex;align-items:center;gap:4px;flex-wrap:wrap}}438 .name{{font-weight:700;font-size:15px;color:#e7e9ea}}439 .verified{{color:#1d9bf0;font-size:14px}}440 .handle-row{{display:flex;align-items:center;gap:4px;font-size:14px;color:#71767b;margin-top:1px}}441 .more{{color:#71767b;font-size:18px;padding:0 4px;cursor:pointer}}442 .lang{{padding:8px 16px 4px;font-size:13px;color:#71767b;display:flex;align-items:center;gap:8px}}443 .lang .show{{color:#1d9bf0;cursor:pointer}}444 .body{{padding:8px 16px 12px;font-size:15px;color:#e7e9ea;line-height:1.4;white-space:pre-wrap;word-break:break-word}}445 .body .link{{color:#1d9bf0;word-break:break-all}}446 .media{{margin:0 16px 12px}}447 .media .img{{width:100%;border-radius:16px;border:1px solid #2f3336;object-fit:cover;max-height:560px}}448 .media .img-placeholder{{display:flex;align-items:center;justify-content:center;color:#71767b;font-size:13px;background:#16181c;border:1px solid #2f3336;border-radius:16px}}449 .counter{{padding:8px 16px;font-size:13px;color:#71767b;border-top:1px solid #2f3336}}450 .actions{{display:flex;justify-content:space-between;padding:8px 16px;border-top:1px solid #2f3336;color:#71767b;font-size:13px}}451 .action{{display:flex;align-items:center;gap:6px;cursor:pointer}}452 .action:hover{{color:#1d9bf0}}453</style></head><body>454<div class="card">455 <div class="header">456 <div class="avatar">{_initial(account_name)}</div>457 <div class="meta">458 <div class="name-row">459 <span class="name">{_e(account_name)}</span>460 <span class="verified">✓</span>461 </div>462 <div class="handle-row">463 <span>@{_handle(account_name)}</span>464 <span>·</span>465 <span>21h</span>466 </div>467 </div>468 <span class="more">⋯</span>469 </div>470 <div class="body">{_e(description)}{url_inline}</div>471 {media_block}472 <div class="counter">{char_count} / {TWITTER_MAX_CHARS} characters</div>473 <div class="actions">474 <span class="action">💬 12</span>475 <span class="action">🔁 8</span>476 <span class="action">♡ 142</span>477 <span class="action">📊 1,2K</span>478 <span class="action">🔖</span>479 <span class="action">➤</span>480 </div>481</div>482</body></html>"""483484485# ── LinkedIn ──────────────────────────────────────────────────────────────────486487488def render_linkedin(payload: dict[str, Any]) -> str:489 account_name = payload["account_name"]490 description = payload["description"]491 image_block = _render_image_block(492 payload["image"], aspect="1.91/1", placeholder_label="Image preview"493 )494 url_card = (495 f'<div class="url-card">🔗 {_e(payload["url"])}</div>'496 if payload["url"]497 else ""498 )499 return f"""<!DOCTYPE html>500<html lang="en"><head><meta charset="utf-8"><title>LinkedIn preview</title>501<style>{_BASE_RESET}502 body{{background:#f4f2ee;color:rgba(0,0,0,.9)}}503 .card{{background:white;border:1px solid rgba(0,0,0,.08);border-radius:8px;max-width:555px;width:100%;overflow:hidden}}504 .reposted{{padding:8px 16px;font-size:12px;color:rgba(0,0,0,.6);display:flex;align-items:center;gap:6px;border-bottom:1px solid rgba(0,0,0,.08)}}505 .reposted .who{{font-weight:600;color:rgba(0,0,0,.9)}}506 .header{{display:flex;padding:12px 16px;gap:8px;align-items:flex-start}}507 .avatar{{width:48px;height:48px;border-radius:8px;background:#1d8d4d;flex-shrink:0;display:flex;align-items:center;justify-content:center;color:white;font-weight:700;font-size:20px}}508 .meta{{flex:1;min-width:0}}509 .name{{font-weight:600;font-size:14px;color:rgba(0,0,0,.9);line-height:1.2}}510 .role{{font-size:12px;color:rgba(0,0,0,.6);margin-top:2px;line-height:1.3}}511 .submeta{{font-size:12px;color:rgba(0,0,0,.6);margin-top:2px;display:flex;align-items:center;gap:4px}}512 .follow{{color:#0a66c2;font-weight:600;font-size:14px;align-self:flex-start;display:flex;align-items:center;gap:4px;flex-shrink:0;cursor:pointer;padding:6px 12px;border-radius:16px}}513 .follow:hover{{background:rgba(10,102,194,.08)}}514 .body{{padding:0 16px 12px;font-size:14px;color:rgba(0,0,0,.9);line-height:1.4;white-space:pre-wrap;word-break:break-word}}515 .body .altro{{color:rgba(0,0,0,.6);cursor:pointer}}516 .img{{width:100%;object-fit:cover;background:rgba(0,0,0,.08);max-height:600px}}517 .img-placeholder{{display:flex;align-items:center;justify-content:center;color:rgba(0,0,0,.6);font-size:13px;background:linear-gradient(135deg,#e9e5df,#d1cdc7)}}518 .url-card{{margin:8px 16px 0;border:1px solid rgba(0,0,0,.08);border-radius:4px;padding:10px 12px;font-size:12px;color:#0a66c2;word-break:break-all}}519 .reactions{{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;font-size:12px;color:rgba(0,0,0,.6)}}520 .reactions .left{{display:flex;align-items:center;gap:6px}}521 .reactions .emojis{{font-size:14px}}522 .actions{{display:flex;border-top:1px solid rgba(0,0,0,.08);padding:4px 8px}}523 .action{{flex:1;display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 6px;font-size:14px;color:rgba(0,0,0,.6);font-weight:600;cursor:pointer;border-radius:4px}}524 .action:hover{{background:#f3f2ef}}525</style></head><body>526<div class="card">527 <div class="reposted">↪ Consigliato da <span class="who">{_e(_PLACEHOLDER_NETWORK)}</span></div>528 <div class="header">529 <div class="avatar">{_initial(account_name)}</div>530 <div class="meta">531 <div class="name">{_e(account_name)}</div>532 <div class="role">Pubblicato su LinkedIn</div>533 <div class="submeta">23h • 🌐</div>534 </div>535 <div class="follow">+ Segui</div>536 </div>537 <div class="body">{_e(description)} <span class="altro">… altro</span></div>538 {image_block}539 {url_card}540 <div class="reactions">541 <div class="left">542 <span class="emojis">👍❤️</span>543 <span>{_e(_PLACEHOLDER_NAME)} e altre 2 persone</span>544 </div>545 <div>1 commento</div>546 </div>547 <div class="actions">548 <div class="action">👍 Consiglia</div>549 <div class="action">💬 Commenta</div>550 <div class="action">↻ Diffondi il post</div>551 <div class="action">➤ Invia</div>552 </div>553</div>554</body></html>"""555556557_RENDERERS = {558 "facebook": render_facebook,559 "instagram": render_instagram,560 "twitter": render_twitter,561 "linkedin": render_linkedin,562}563564565def render_preview(payload: dict[str, Any]) -> str:566 """Dispatch to the platform-specific renderer."""567 return _RENDERERS[payload["platform"]](payload)568569570# ── Orchestration ─────────────────────────────────────────────────────────────571572573def process(inputs: dict[str, Any]) -> dict[str, Any]:574 warnings = validate(inputs)575 payload = build_payload(inputs)576 preview_html = render_preview(payload)577 return {578 "payload": payload,579 "preview_html": preview_html,580 "validation_warnings": warnings,581 }582583584def main() -> None:585 raw = sys.stdin.read() or "{}"586 try:587 envelope = json.loads(raw)588 except json.JSONDecodeError as err:589 print(f"invalid JSON on stdin: {err}", file=sys.stderr)590 sys.exit(2)591592 inputs = envelope.get("inputs", envelope) if isinstance(envelope, dict) else {}593 outputs = process(inputs)594 json.dump(outputs, sys.stdout)595596597if __name__ == "__main__":598 main()599$ git log --oneline
v2.0.1
HEAD
2026-06-05