feat: Mastodon Post Composition and Publishing

This commit is contained in:
OpenVelo Agent
2026-08-04 18:57:43 +00:00
parent d435aa6f8f
commit 038e10b87d
9 changed files with 425 additions and 26 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ OPTIONAL_KEYS: tuple[str, ...] = ("BLOG_REPO_URL", "BLOG_DIR")
DEFAULTS = {
"VISIBILITY": "public",
"THROWNBACK_PREFIX": "Throwback:",
"THROWNBACK_PREFIX": "Heute vor 10 Jahren:",
"MAX_RETRIES": "3",
"TZ": "Europe/Berlin",
"RUN_AT": "09:00",
+19 -19
View File
@@ -13,17 +13,17 @@ from .logging_setup import (
log_run_summary,
log_startup,
)
from .matching import iter_anniversary_paths
from .matching import MatchedPost, find_anniversary_matches
from .publishing import publish_mastodon
from .state import PostedStore
def _iter_candidates(config: Config) -> Iterable[str]:
"""Yield candidate post identifiers (the relative path under
``_posts/blog/``) for posts whose anniversary is exactly 10 years
before today.
def _iter_candidates(config: Config) -> Iterable[MatchedPost]:
"""Yield candidate :class:`MatchedPost` objects whose anniversary is
exactly 10 years before today.
"""
post_root = config.blog_dir / "_posts" / "blog"
return iter_anniversary_paths(post_root, config.site_url)
return find_anniversary_matches(post_root, config.site_url)
def _run_once(config: Config) -> tuple[int, int, int, int, list[str]]:
@@ -40,24 +40,24 @@ def _run_once(config: Config) -> tuple[int, int, int, int, list[str]]:
store = PostedStore(config.data_dir)
scanned = 0
matched = 0
posted = 0
skipped = 0
posted_ids: list[str] = []
candidates = list(_iter_candidates(config))
scanned = len(candidates)
matched = len(candidates)
for candidate_id in _iter_candidates(config):
scanned += 1
matched += 1
if store.is_posted(candidate_id):
skipped += 1
unposted: list[MatchedPost] = []
for match in candidates:
if store.is_posted(match.path):
continue
posted_ids.append(candidate_id)
posted += 1
unposted.append(match)
if posted_ids:
skipped = matched - len(unposted)
posted_ids = [m.path for m in unposted]
if unposted:
publish_mastodon(config, unposted)
store.mark_posted_many(posted_ids)
posted = len(unposted)
return scanned, matched, posted, skipped, posted_ids
+19 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import calendar
import logging
import re
import unicodedata
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
@@ -15,6 +16,8 @@ _LOGGER = logging.getLogger("tenbackward.matching")
_FILENAME_PATTERN = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-(.+)$")
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
_DEFAULT_TZ = "Europe/Berlin"
@@ -26,6 +29,19 @@ class MatchedPost:
url: str
def slugify_title(title: str) -> str:
"""Return a URL-safe slug derived from ``title``.
Used by the matcher to derive the slug component of
:attr:`MatchedPost.url` from the resolved post title.
"""
normalized = unicodedata.normalize("NFKD", title)
ascii_only = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_only.lower()
dashed = _NON_ALNUM_RE.sub("-", lowered)
return dashed.strip("-")
def _today_in_berlin(tz_name: str = _DEFAULT_TZ) -> date:
return datetime.now(ZoneInfo(tz_name)).date()
@@ -152,7 +168,7 @@ def _process_file(
path=rel,
title=title,
date=post_date,
url=_build_url(site_url, post_date, fn_slug),
url=_build_url(site_url, post_date, slugify_title(title)),
)
@@ -206,4 +222,5 @@ __all__ = [
"MatchedPost",
"find_anniversary_matches",
"iter_anniversary_paths",
]
"slugify_title",
]
+141
View File
@@ -0,0 +1,141 @@
"""Mastodon publishing helpers and the API success boundary.
The :func:`publish_mastodon` function is the single boundary between the
pipeline orchestrator and the Mastodon HTTP API. Status composition and
length validation are pure and isolated from network access so they can
be tested without a server.
"""
from __future__ import annotations
from typing import Callable
from mastodon import Mastodon
from .config import Config
from .matching import MatchedPost, slugify_title
MASTODON_STATUS_LIMIT = 500
class PublishError(RuntimeError):
"""Raised when status composition, validation, or the Mastodon API
call fails. The pipeline orchestrator treats this like any other
pipeline error and lets the retry budget decide whether to give up.
"""
def _normalize_hashtags(hashtags: str) -> str:
parts = [token.strip() for token in (hashtags or "").split(",")]
parts = [token for token in parts if token]
return " ".join(parts)
def build_status_text(
prefix: str,
posts: list[MatchedPost],
hashtags: str,
) -> str:
"""Compose a single Mastodon status string for ``posts``.
The format is::
{prefix}
{title1}
{url1}
{title2}
{url2}
...
{hashtags}
Posts are sorted by ``(date, path)`` for deterministic output
regardless of the upstream ordering.
"""
if not posts:
raise PublishError("empty_posts: cannot compose status without posts")
ordered = sorted(posts, key=lambda m: (m.date, m.path))
blocks: list[str] = []
for match in ordered:
blocks.append(f"{match.title}\n{match.url}")
body = "\n\n".join(blocks)
lines: list[str] = [prefix.strip(), body]
tag_line = _normalize_hashtags(hashtags)
if tag_line:
lines.append(tag_line)
return "\n\n".join(lines) + "\n"
def validate_status(status: str, limit: int = MASTODON_STATUS_LIMIT) -> None:
"""Raise :class:`PublishError` when ``status`` exceeds ``limit``.
Never truncates: the spec requires failing safely rather than
shortening content.
"""
if len(status) > limit:
raise PublishError(
f"status_too_long: len={len(status)} limit={limit}"
)
def _post_status_via_mastodon_py(
status: str,
*,
base_url: str,
access_token: str,
visibility: str,
client_factory: Callable[..., Mastodon] | None = None,
) -> None:
factory = client_factory if client_factory is not None else Mastodon
client = factory(access_token=access_token, api_base_url=base_url)
client.status_post(status, visibility=visibility)
def publish_mastodon(
config: Config,
posts: list[MatchedPost],
*,
client_factory: Callable[..., Mastodon] | None = None,
) -> str:
"""Compose, validate, and publish ``posts`` to Mastodon.
Returns the composed status text on success. Raises
:class:`PublishError` on any failure (composition, length, or API).
The original exception is chained via ``raise ... from exc`` so the
caller can inspect the underlying cause.
"""
try:
status = build_status_text(
config.throwback_prefix, posts, config.hashtags
)
validate_status(status)
_post_status_via_mastodon_py(
status,
base_url=config.mastodon_base_url,
access_token=config.mastodon_access_token,
visibility=config.visibility,
client_factory=client_factory,
)
except PublishError:
raise
except Exception as exc: # noqa: BLE001 — third-party boundary
raise PublishError(
f"publish_failed: {type(exc).__name__}: {exc}"
) from exc
return status
__all__ = [
"MASTODON_STATUS_LIMIT",
"PublishError",
"build_status_text",
"publish_mastodon",
"slugify_title",
"validate_status",
]