From 17fe2c62b0ff9784d3e2d2f13f4d4bd2b099b399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20TH=C3=89RAGE?= Date: Tue, 11 Aug 2026 14:36:52 +0200 Subject: [PATCH] fix(rewrite): reserve canonical-URL headroom for short-form platforms Auto-rewrites are generated to exactly max_content_length characters, then _append_canonical_url() appends the canonical URL on top, pushing the final post past the platform limit and failing the content-length check. Bluesky (300 chars) always broke this way; Mastodon only worked due to slack in its 500-char limit. Add Platform.appends_canonical_url and Platform.rewrite_max_length(), which return a reduced budget (limit minus URL plus "\n\n" separator) for platforms that append the URL. Use it at every auto-rewrite call site (publish, publish --dry-run, audit, schedule, and publishing.prepare_publish). URL-appending platforms set appends_canonical_url: bluesky, mastodon, twitter, threads, linkedin. --- src/crier/cli.py | 8 ++-- src/crier/platforms/base.py | 31 +++++++++++++ src/crier/platforms/bluesky.py | 1 + src/crier/platforms/linkedin.py | 1 + src/crier/platforms/mastodon.py | 1 + src/crier/platforms/threads.py | 1 + src/crier/platforms/twitter.py | 1 + src/crier/publishing.py | 2 +- tests/test_platforms.py | 82 +++++++++++++++++++++++++++++++++ tests/test_publishing.py | 2 + 10 files changed, 125 insertions(+), 5 deletions(-) diff --git a/src/crier/cli.py b/src/crier/cli.py index 029cb2b..fa40f7a 100644 --- a/src/crier/cli.py +++ b/src/crier/cli.py @@ -860,7 +860,7 @@ def publish(file: str, platform_args: tuple[str, ...], profile_name: str | None, else: platform_cls = get_platform(platform_name) platform = platform_cls(api_key or "dry-run") - max_len = platform.max_content_length + max_len = platform.rewrite_max_length(article) # Check if auto-rewrite would be triggered if auto_rewrite and llm_provider and max_len and len(article.body) > max_len: @@ -1052,7 +1052,7 @@ def publish(file: str, platform_args: tuple[str, ...], profile_name: str | None, platform_rewrite_content = None if auto_rewrite and llm_provider and platform.max_content_length: - max_len = platform.max_content_length + max_len = platform.rewrite_max_length(article) if len(article.body) > max_len: from .rewrite import auto_rewrite_for_platform @@ -3168,7 +3168,7 @@ def get_display_path(fp: Path) -> str: platform_cls = get_platform(platform) plat = platform_cls(api_key) publish_article = article - max_len = platform_cls.max_content_length + max_len = plat.rewrite_max_length(article) rewritten = False rewrite_content = None @@ -4299,7 +4299,7 @@ def schedule_run(dry_run: bool, json_output: bool): ) platform_cls = get_platform(platform_name) - max_len = platform_cls(api_key).max_content_length + max_len = platform_cls(api_key).rewrite_max_length(article) if max_len and len(article.body) > max_len: retry = ( post.auto_rewrite_retry diff --git a/src/crier/platforms/base.py b/src/crier/platforms/base.py index 6e3e987..54aedb3 100644 --- a/src/crier/platforms/base.py +++ b/src/crier/platforms/base.py @@ -96,6 +96,9 @@ class Platform(ABC): supports_threads: bool = False # Maximum number of posts in a thread (if supports_threads) thread_max_posts: int = 25 + #: Short-form platforms append the canonical URL to rewritten posts + #: (see ``_append_canonical_url``). Auto-rewrites must reserve room for it. + appends_canonical_url: bool = False # Request timeout in seconds timeout: int = 30 # Max retries for transient failures (429, 502, 503, 504, ConnectionError) @@ -119,6 +122,34 @@ def format_for_manual(self, article: Article) -> str: """ return article.body + def rewrite_max_length(self, article: Article) -> int | None: + """Return the max output length for an auto-rewrite of ``article``. + + Short-form platforms append the canonical URL to rewritten content + (see ``_append_canonical_url``). If the LLM is told to write up to + ``max_content_length`` characters and the URL is appended afterwards, + the final post exceeds the platform limit and the content-length check + fails. Returning a reduced budget lets the rewrite leave room for the + URL. + + Subclasses that append the URL should set ``appends_canonical_url``. + Returns ``None`` when the platform has no character limit. + """ + if self.max_content_length is None: + return None + + budget = self.max_content_length + if ( + self.appends_canonical_url + and article.canonical_url + and article.canonical_url not in (article.body or "") + ): + # Reserve room for the "\n\n" separator plus the URL itself. + budget -= len(article.canonical_url) + 2 + + # Guard against limits smaller than a canonical URL. + return max(budget, 1) + def _check_content_length(self, content: str) -> str | None: """Check if content exceeds platform limit. diff --git a/src/crier/platforms/bluesky.py b/src/crier/platforms/bluesky.py index a5dccae..a76ac03 100644 --- a/src/crier/platforms/bluesky.py +++ b/src/crier/platforms/bluesky.py @@ -99,6 +99,7 @@ class Bluesky(Platform): base_url = "https://bsky.social/xrpc" max_content_length = 300 # Bluesky character limit is_short_form = True + appends_canonical_url = True api_key_url = "https://bsky.app/settings/app-passwords" supports_threads = True supports_stats = True diff --git a/src/crier/platforms/linkedin.py b/src/crier/platforms/linkedin.py index c99eeaf..b4a3111 100644 --- a/src/crier/platforms/linkedin.py +++ b/src/crier/platforms/linkedin.py @@ -25,6 +25,7 @@ class LinkedIn(Platform): base_url = "https://api.linkedin.com/v2" compose_url = "https://www.linkedin.com/feed/?shareActive=true" max_content_length = 3000 + appends_canonical_url = True supports_stats = True api_key_url = None # Requires OAuth app setup diff --git a/src/crier/platforms/mastodon.py b/src/crier/platforms/mastodon.py index b07612b..a701324 100644 --- a/src/crier/platforms/mastodon.py +++ b/src/crier/platforms/mastodon.py @@ -28,3 +28,4 @@ class Mastodon(FediversePlatform): description = "Short posts (500 chars)" max_content_length = 500 default_instance = "mastodon.social" + appends_canonical_url = True diff --git a/src/crier/platforms/threads.py b/src/crier/platforms/threads.py index c4f0b8f..bfb7c73 100644 --- a/src/crier/platforms/threads.py +++ b/src/crier/platforms/threads.py @@ -26,6 +26,7 @@ class Threads(Platform): base_url = "https://graph.threads.net/v1.0" max_content_length = 500 # Threads character limit is_short_form = True + appends_canonical_url = True api_key_url = "https://developers.facebook.com/" supports_delete = False supports_stats = True diff --git a/src/crier/platforms/twitter.py b/src/crier/platforms/twitter.py index aa18815..8d6c9dd 100644 --- a/src/crier/platforms/twitter.py +++ b/src/crier/platforms/twitter.py @@ -22,6 +22,7 @@ class Twitter(Platform): description = "Short posts (280 chars, manual)" max_content_length = 280 # Twitter character limit is_short_form = True + appends_canonical_url = True compose_url = "https://twitter.com/compose/tweet" api_key_url = None # Manual mode only supports_delete = False diff --git a/src/crier/publishing.py b/src/crier/publishing.py index d5dbf44..27bd72b 100644 --- a/src/crier/publishing.py +++ b/src/crier/publishing.py @@ -113,7 +113,7 @@ def prepare_publish( posted_content = rewrite_content elif auto_rewrite and llm_provider: platform_obj = get_platform(platform)(api_key) - max_len = platform_obj.max_content_length + max_len = platform_obj.rewrite_max_length(article) if max_len and len(article.body) > max_len: rw = auto_rewrite_for_platform( article, platform, max_len, llm_provider, diff --git a/tests/test_platforms.py b/tests/test_platforms.py index a1bc84c..ab2efe1 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -142,6 +142,88 @@ def test_check_content_length_exceeds_limit(self): assert "280" in error +class TestRewriteMaxLength: + """Tests for Platform.rewrite_max_length URL headroom logic.""" + + def test_no_limit_returns_none(self, sample_article): + """Platforms without a character limit have no rewrite budget.""" + platform = DevTo("k") + assert platform.max_content_length is None + assert platform.rewrite_max_length(sample_article) is None + + def test_non_appending_platform_full_budget(self): + """Platforms that don't append the URL keep the full limit.""" + platform = DevTo("k") # long-form, appends_canonical_url=False + # DevTo has no limit; use a hypothetical limited non-appender instead + class Limited(Platform): + name = "limited" + max_content_length = 300 + + def publish(self, article): ... + def update(self, article_id, article): ... + def list_articles(self, limit=10): ... + def get_article(self, article_id): ... + + p = Limited("k") + assert p.appends_canonical_url is False + article = Article(title="t", body="x", + canonical_url="https://example.com/a") + assert p.rewrite_max_length(article) == 300 + + def test_appending_platform_reserves_url_room(self): + """Budget subtracts canonical URL + separator for URL-appenders.""" + platform = Bluesky("handle:pw") + url = "https://example.com/blog/some-post" + article = Article(title="t", body="x", canonical_url=url) + budget = platform.rewrite_max_length(article) + assert budget == 300 - len(url) - 2 + # Final post (budget + separator + URL) fits exactly within the limit. + assert budget + len(url) + 2 <= platform.max_content_length + + def test_appending_platform_no_url_full_budget(self): + """Without a canonical URL nothing is reserved.""" + platform = Bluesky("handle:pw") + assert platform.rewrite_max_length( + Article(title="t", body="x", canonical_url=None) + ) == 300 + + def test_url_already_in_body_not_double_counted(self): + """A rewrite that already contains the URL isn't penalised.""" + platform = Bluesky("handle:pw") + url = "https://example.com/blog/some-post" + article = Article(title="t", body="x " + url, canonical_url=url) + assert platform.rewrite_max_length(article) == 300 + + def test_limit_smaller_than_url_floored_at_one(self): + """Even a tiny limit never yields a non-positive budget.""" + class Tiny(Platform): + name = "tiny" + max_content_length = 5 + appends_canonical_url = True + + def publish(self, article): ... + def update(self, article_id, article): ... + def list_articles(self, limit=10): ... + def get_article(self, article_id): ... + + p = Tiny("k") + url = "https://example.com/a-very-long-canonical-url" + assert p.rewrite_max_length( + Article(title="t", body="x", canonical_url=url) + ) == 1 + + @pytest.mark.parametrize("platform_cls", [Bluesky, Twitter, Mastodon]) + def test_short_form_url_appenders_flag_set(self, platform_cls): + """URL-appending short-form platforms opt into the headroom logic.""" + p = platform_cls("k") + assert p.appends_canonical_url is True + + def test_threads_appends_canonical_url(self): + """Threads (user_id:token key) opts into the headroom logic too.""" + p = Threads("12345:token") + assert p.appends_canonical_url is True + + class TestTwitterManualMode: """Tests for Twitter manual mode platform.""" diff --git a/tests/test_publishing.py b/tests/test_publishing.py index b06c4fb..a97891d 100644 --- a/tests/test_publishing.py +++ b/tests/test_publishing.py @@ -123,6 +123,7 @@ def test_prepare_publish_auto_rewrite_success(mode, key, parse, get_plat, auto_r inst = MagicMock() inst.max_content_length = 300 + inst.rewrite_max_length.return_value = 300 get_plat.return_value = lambda _k: inst llm_provider = MagicMock() @@ -156,6 +157,7 @@ def test_publish_one_auto_rewrite_failure(mode, key, parse, get_plat, auto_rw): inst = MagicMock() inst.max_content_length = 300 + inst.rewrite_max_length.return_value = 300 get_plat.return_value = lambda _k: inst llm_provider = MagicMock()