Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/crier/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions src/crier/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions src/crier/platforms/bluesky.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/crier/platforms/linkedin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/crier/platforms/mastodon.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ class Mastodon(FediversePlatform):
description = "Short posts (500 chars)"
max_content_length = 500
default_instance = "mastodon.social"
appends_canonical_url = True
1 change: 1 addition & 0 deletions src/crier/platforms/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/crier/platforms/twitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/crier/publishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions tests/test_platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions tests/test_publishing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down