feat: Mastodon Post Composition and Publishing
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user