From e1dfbc6641846b8cd63d54cac4da598b8601c65a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:13:52 +0000 Subject: [PATCH] Escape LinkedIn reserved characters in generated post copy LinkedIn parses commentary as little-text; reserved ASCII like ( ) [ ] < > @ silently truncates the post. Swap them for fullwidth look-alikes in headline, context and bullets; sanitize hashtags to alphanumerics so they still auto-link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wd36GnTiTuBTpbteZ4P5QC --- src/publishing/linkedin_copy.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/publishing/linkedin_copy.py b/src/publishing/linkedin_copy.py index 1210ae9..84a0424 100644 --- a/src/publishing/linkedin_copy.py +++ b/src/publishing/linkedin_copy.py @@ -71,6 +71,23 @@ def _to_bold_unicode(text: str) -> str: return "".join(result) +# LinkedIn parses commentary as "little text": these ASCII characters are +# reserved syntax, and an unescaped one silently truncates the post at that +# point (e.g. "Vector store memory is fast (10-40ms" -> post ends at "fast"). +# Swap them for fullwidth look-alikes, which render near-identically and are +# never parsed. Applied to LLM-generated text only; hashtags stay literal ASCII +# so they still auto-link. +_LINKEDIN_RESERVED = str.maketrans({ + "(": "(", ")": ")", "[": "[", "]": "]", "{": "{", "}": "}", + "<": "<", ">": ">", "@": "@", "#": "#", "*": "*", "_": "_", + "~": "~", "|": "|", "\\": "\", +}) + + +def _linkedin_safe(text: str) -> str: + return text.translate(_LINKEDIN_RESERVED) + + def generate_linkedin_copy(post_markdown: str, devto_url: str, intro_post_url: str) -> str: """ devto_url: link to the specific blog post being promoted. NOT embedded @@ -105,10 +122,15 @@ def generate_linkedin_copy(post_markdown: str, devto_url: str, intro_post_url: s except json.JSONDecodeError as e: raise RuntimeError(f"LinkedIn copy stage returned non-JSON:\n{raw}") from e - headline = _to_bold_unicode(parts["headline"]) - context = "\n".join(parts["context_lines"]) - bullets = "\n".join(f"- {b}" for b in parts["insight_bullets"]) - hashtags = " ".join(parts["hashtags"][:3]) + headline = _to_bold_unicode(_linkedin_safe(parts["headline"])) + context = "\n".join(_linkedin_safe(line) for line in parts["context_lines"]) + bullets = "\n".join(f"- {_linkedin_safe(b)}" for b in parts["insight_bullets"]) + # Hashtags: keep "#" literal so they link; strip anything that isn't a + # word character so a stray reserved char can't truncate the post here. + hashtags = " ".join( + "#" + "".join(ch for ch in tag.lstrip("#") if ch.isalnum()) + for tag in parts["hashtags"][:3] + ) post = f"""{headline} @@ -116,7 +138,7 @@ def generate_linkedin_copy(post_markdown: str, devto_url: str, intro_post_url: s {bullets} -🧠 Researched, drafted, and published by BlogMind — My AI-Powered Content Automation System (research -> source → draft → critique → human review → publish). +🧠 Researched, drafted, and published by BlogMind — My AI-Powered Content Automation System (research → source → draft → critique → human review → publish). {hashtags}"""