$ 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-18v2.0.12026-06-05