$ 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