$ cat node-template.py

S

Social Post Preparer

// Prepares 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 Preparer34Builds 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 html37import json38import re39import sys40from typing import Any4142# ── Platform constants ────────────────────────────────────────────────────────4344PLATFORMS = {"facebook", "instagram", "twitter", "linkedin"}4546# Hard limits (exceeded → ValueError)47TWITTER_MAX_CHARS = 28048TWITTER_URL_WEIGHT = 23  # Twitter t.co shortener accounting49INSTAGRAM_MAX_CHARS = 220050LINKEDIN_MAX_CHARS = 30005152# Soft thresholds53LINKEDIN_SHORT_THRESHOLD = 1005455HASHTAG_RE = re.compile(r"(^|\s)#[A-Za-z0-9_]+")565758# ── Validation ────────────────────────────────────────────────────────────────596061def validate(inputs: dict[str, Any]) -> list[str]:62    """Run hard + soft validations. Returns the soft-warnings list.6364    Raises ValueError on the first hard failure (so the node FAILs with a clear65    error visible in the Tasks panel).66    """67    platform = inputs.get("platform") or ""68    if platform not in PLATFORMS:69        raise ValueError(70            f"Unknown platform '{platform}'. Expected one of: "71            + ", ".join(sorted(PLATFORMS))72        )7374    title = inputs.get("title") or ""75    description = inputs.get("description") or ""76    image = inputs.get("image") or ""77    url = inputs.get("url") or ""7879    if not title.strip():80        raise ValueError("`title` cannot be empty.")81    if not description.strip():82        raise ValueError("`description` cannot be empty.")8384    warnings: list[str] = []8586    # Instagram: image required (hard)87    if platform == "instagram" and not image:88        raise ValueError(89            "Instagram posts require an image. Connect an Image output to the "90            "`image` input."91        )9293    # Twitter: 280-char hard limit (URL counts 23)94    if platform == "twitter":95        effective_len = len(description)96        if url:97            effective_len += TWITTER_URL_WEIGHT + 1  # +1 for separating space98        if effective_len > TWITTER_MAX_CHARS:99            raise ValueError(100                f"Twitter post exceeds {TWITTER_MAX_CHARS} chars "101                f"({effective_len}/{TWITTER_MAX_CHARS}). Shorten the description."102            )103104    # Instagram description hard limit105    if platform == "instagram" and len(description) > INSTAGRAM_MAX_CHARS:106        raise ValueError(107            f"Instagram caption exceeds {INSTAGRAM_MAX_CHARS} chars "108            f"({len(description)}/{INSTAGRAM_MAX_CHARS})."109        )110111    # LinkedIn description hard limit112    if platform == "linkedin" and len(description) > LINKEDIN_MAX_CHARS:113        raise ValueError(114            f"LinkedIn post exceeds {LINKEDIN_MAX_CHARS} chars "115            f"({len(description)}/{LINKEDIN_MAX_CHARS})."116        )117118    # ── Soft warnings ────────────────────────────────────────────────────────119    if platform == "facebook" and not image:120        warnings.append("Posts with images get ~2x more engagement on Facebook.")121122    if platform == "linkedin" and len(description) < LINKEDIN_SHORT_THRESHOLD:123        warnings.append(124            "Longer, story-style posts (200+ chars) tend to perform better on LinkedIn."125        )126127    if platform == "twitter" and not HASHTAG_RE.search(description):128        warnings.append("Adding 1-2 hashtags can increase reach on X/Twitter.")129130    return warnings131132133# ── Payload assembly ──────────────────────────────────────────────────────────134135136def extract_hashtags(text: str) -> list[str]:137    """Pull hashtags out of the description text (used by future publishers)."""138    return [m.group(0).strip().lstrip("#") for m in HASHTAG_RE.finditer(text)]139140141def build_payload(inputs: dict[str, Any]) -> dict[str, Any]:142    """Normalised payload — stable contract for the future publisher nodes."""143    return {144        "platform": inputs.get("platform") or "",145        "title": (inputs.get("title") or "").strip(),146        "description": (inputs.get("description") or "").strip(),147        "image": inputs.get("image") or None,148        "url": (inputs.get("url") or "").strip() or None,149        "hashtags": extract_hashtags(inputs.get("description") or ""),150    }151152153# ── HTML preview templates ────────────────────────────────────────────────────154#155# Each renderer returns a COMPLETE HTML document (starts with <!DOCTYPE html>)156# so the workspace iframe preview (sandbox="allow-scripts") can render it.157# All user content is HTML-escaped to prevent injection. URLs are also escaped158# but rendered without href to avoid making AI-generated links clickable inside159# the preview (consistent with the security model used for web-sync PDFs).160# ----------------------------------------------------------------------------161162163def _e(s: str | None) -> str:164    """HTML-escape a possibly-None string."""165    return html.escape(s or "", quote=True)166167168_BASE_CSS = """169  *{box-sizing:border-box;margin:0;padding:0}170  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,Arial,sans-serif;background:#f0f2f5;color:#0f172a;padding:24px;display:flex;justify-content:center}171  .card{background:white;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);max-width:520px;width:100%;overflow:hidden}172  .row{display:flex;align-items:center;gap:8px}173  .avatar{width:40px;height:40px;border-radius:50%;background:#cbd5e1;flex-shrink:0}174  .meta{font-size:13px;color:#64748b}175  .title{font-size:15px;font-weight:600;color:#0f172a}176  .body{padding:12px 16px;font-size:14px;line-height:1.45;color:#0f172a;white-space:pre-wrap;word-break:break-word}177  .img{width:100%;display:block;max-height:520px;object-fit:cover;background:#e2e8f0}178  .img-placeholder{aspect-ratio:1.91/1;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);display:flex;align-items:center;justify-content:center;color:#64748b;font-size:12px;font-weight:500}179  .url{font-size:12px;color:#64748b;padding:0 16px 8px;word-break:break-all}180  .actions{display:flex;border-top:1px solid #e2e8f0;padding:6px 0}181  .action{flex:1;text-align:center;padding:8px;font-size:13px;color:#64748b;font-weight:500}182"""183184185def _render_image_block(image: str | None, placeholder_label: str = "Image preview") -> str:186    if image:187        return f'<img class="img" src="{_e(image)}" alt="">'188    return f'<div class="img img-placeholder">{_e(placeholder_label)}</div>'189190191def _render_url_block(url: str | None) -> str:192    if not url:193        return ""194    return f'<div class="url">{_e(url)}</div>'195196197def render_facebook(payload: dict[str, Any]) -> str:198    return f"""<!DOCTYPE html>199<html lang="en"><head><meta charset="utf-8"><title>Facebook preview</title>200<style>{_BASE_CSS}201  .header{{padding:12px 16px;display:flex;gap:8px;align-items:center}}202  .name{{font-weight:600;font-size:14px;color:#050505}}203  .actions .action:hover{{background:#f0f2f5}}204  .actions{{color:#65676b}}205</style></head><body>206<div class="card">207  <div class="header">208    <div class="avatar"></div>209    <div>210      <div class="name">{_e(payload['title'])}</div>211      <div class="meta">Now · 🌎</div>212    </div>213  </div>214  <div class="body">{_e(payload['description'])}</div>215  {_render_url_block(payload['url'])}216  {_render_image_block(payload['image'])}217  <div class="actions">218    <div class="action">👍 Like</div>219    <div class="action">💬 Comment</div>220    <div class="action">↗ Share</div>221  </div>222</div>223</body></html>"""224225226def render_instagram(payload: dict[str, Any]) -> str:227    return f"""<!DOCTYPE html>228<html lang="en"><head><meta charset="utf-8"><title>Instagram preview</title>229<style>{_BASE_CSS}230  body{{background:#fafafa}}231  .card{{max-width:470px;border:1px solid #dbdbdb;border-radius:8px}}232  .header{{padding:14px 16px;display:flex;gap:10px;align-items:center}}233  .avatar{{background:linear-gradient(45deg,#fdf497,#fdf497,#fd5949,#d6249f,#285AEB);padding:2px}}234  .avatar-inner{{width:36px;height:36px;border-radius:50%;background:#cbd5e1;border:2px solid white}}235  .name{{font-weight:600;font-size:14px;color:#262626}}236  .meta{{font-size:12px}}237  .img{{aspect-ratio:1;object-fit:cover}}238  .img-placeholder{{aspect-ratio:1}}239  .ig-actions{{padding:10px 16px;font-size:24px;color:#262626;display:flex;gap:14px}}240  .body{{padding:0 16px 12px;font-size:14px}}241  .body .name-inline{{font-weight:600;margin-right:6px}}242</style></head><body>243<div class="card">244  <div class="header">245    <div class="avatar"><div class="avatar-inner"></div></div>246    <div>247      <div class="name">{_e(payload['title'])}</div>248      <div class="meta">Sponsored</div>249    </div>250  </div>251  {_render_image_block(payload['image'], 'Instagram image (required)')}252  <div class="ig-actions">♡ 💬 ↗ <span style="margin-left:auto">🔖</span></div>253  <div class="body"><span class="name-inline">{_e(payload['title'])}</span>{_e(payload['description'])}</div>254  {_render_url_block(payload['url'])}255</div>256</body></html>"""257258259def render_twitter(payload: dict[str, Any]) -> str:260    char_count = len(payload["description"]) + (261        TWITTER_URL_WEIGHT + 1 if payload["url"] else 0262    )263    return f"""<!DOCTYPE html>264<html lang="en"><head><meta charset="utf-8"><title>X / Twitter preview</title>265<style>{_BASE_CSS}266  body{{background:#000;color:#e7e9ea}}267  .card{{background:#000;border:1px solid #2f3336;box-shadow:none;max-width:560px;border-radius:16px}}268  .header{{padding:12px 16px;display:flex;gap:12px}}269  .avatar{{background:#2f3336}}270  .name{{font-weight:700;font-size:15px;color:#e7e9ea}}271  .handle{{font-size:14px;color:#71767b}}272  .body{{padding:0 16px 12px;color:#e7e9ea}}273  .img{{border-radius:0 0 16px 16px;border-top:1px solid #2f3336}}274  .img-placeholder{{aspect-ratio:1.91/1;background:#16181c;color:#71767b;border-radius:0 0 16px 16px}}275  .url{{color:#1d9bf0;padding:0 16px 10px}}276  .counter{{padding:8px 16px;font-size:12px;color:#71767b;border-top:1px solid #2f3336}}277  .actions{{border-top:1px solid #2f3336;color:#71767b}}278</style></head><body>279<div class="card">280  <div class="header">281    <div class="avatar" style="width:48px;height:48px"></div>282    <div style="flex:1">283      <div class="name">{_e(payload['title'])}</div>284      <div class="handle">@your_handle · now</div>285      <div class="body" style="padding:8px 0 0">{_e(payload['description'])}</div>286      {_render_url_block(payload['url'])}287    </div>288  </div>289  {(_render_image_block(payload['image']) if payload['image'] else '')}290  <div class="counter">{char_count} / {TWITTER_MAX_CHARS} characters</div>291  <div class="actions">292    <div class="action">💬</div>293    <div class="action">🔁</div>294    <div class="action">♡</div>295    <div class="action">↗</div>296  </div>297</div>298</body></html>"""299300301def render_linkedin(payload: dict[str, Any]) -> str:302    return f"""<!DOCTYPE html>303<html lang="en"><head><meta charset="utf-8"><title>LinkedIn preview</title>304<style>{_BASE_CSS}305  body{{background:#f3f2ef}}306  .card{{max-width:560px;border:1px solid #e2e8f0;border-radius:8px}}307  .header{{padding:12px 16px;display:flex;gap:10px;align-items:center}}308  .name{{font-weight:600;font-size:14px;color:#0f172a}}309  .role{{font-size:12px;color:#64748b}}310  .meta{{font-size:11px;color:#64748b;margin-top:2px}}311  .body{{padding:0 16px 12px;font-size:14px;color:#0f172a}}312  .url-card{{margin:8px 16px;border:1px solid #e2e8f0;border-radius:4px;padding:10px 12px;font-size:12px;color:#0a66c2;word-break:break-all}}313  .actions{{color:#666;font-weight:600;font-size:13px}}314  .actions .action:hover{{background:#f3f2ef}}315</style></head><body>316<div class="card">317  <div class="header">318    <div class="avatar" style="width:48px;height:48px"></div>319    <div>320      <div class="name">{_e(payload['title'])}</div>321      <div class="role">Your role · Your company</div>322      <div class="meta">Now · 🌐</div>323    </div>324  </div>325  <div class="body">{_e(payload['description'])}</div>326  {(f'<div class="url-card">🔗 {_e(payload["url"])}</div>' if payload['url'] else '')}327  {_render_image_block(payload['image'])}328  <div class="actions">329    <div class="action">👍 Like</div>330    <div class="action">💬 Comment</div>331    <div class="action">↻ Repost</div>332    <div class="action">↗ Send</div>333  </div>334</div>335</body></html>"""336337338_RENDERERS = {339    "facebook": render_facebook,340    "instagram": render_instagram,341    "twitter": render_twitter,342    "linkedin": render_linkedin,343}344345346def render_preview(payload: dict[str, Any]) -> str:347    """Dispatch to the platform-specific renderer."""348    return _RENDERERS[payload["platform"]](payload)349350351# ── Orchestration ─────────────────────────────────────────────────────────────352353354def process(inputs: dict[str, Any]) -> dict[str, Any]:355    warnings = validate(inputs)356    payload = build_payload(inputs)357    preview_html = render_preview(payload)358    return {359        "payload": payload,360        "preview_html": preview_html,361        "validation_warnings": warnings,362    }363364365def main() -> None:366    raw = sys.stdin.read() or "{}"367    try:368        envelope = json.loads(raw)369    except json.JSONDecodeError as err:370        print(f"invalid JSON on stdin: {err}", file=sys.stderr)371        sys.exit(2)372373    inputs = envelope.get("inputs", envelope) if isinstance(envelope, dict) else {}374    outputs = process(inputs)375    json.dump(outputs, sys.stdout)376377378if __name__ == "__main__":379    main()

$ git log --oneline

v1.0.0
HEAD
2026-08-18
v2.0.12026-06-05