AI Implementation feature(1085): Anniversary Matching Across Jekyll Posts (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-08-04 18:19:56 +00:00
parent 3fe2b90f51
commit 72c2369160
3 changed files with 414 additions and 6 deletions
+6 -6
View File
@@ -13,17 +13,17 @@ from .logging_setup import (
log_run_summary,
log_startup,
)
from .matching import iter_anniversary_paths
from .state import load_posted, save_posted
def _iter_candidates(config: Config) -> Iterable[str]:
"""Yield candidate post identifiers.
This is the extension seam for the future blog-clone + matching
pipeline. Job 1083 leaves it empty so the silent-on-no-matches
contract is the default behaviour.
"""Yield candidate post identifiers (the relative path under
``_posts/blog/``) for posts whose anniversary is exactly 10 years
before today.
"""
return []
post_root = config.blog_dir / "_posts" / "blog"
return iter_anniversary_paths(post_root, config.site_url)
def _run_once(config: Config) -> tuple[int, int, int, int, list[str]]:
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import calendar
import logging
import re
from dataclasses import dataclass
from datetime import date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import frontmatter
_LOGGER = logging.getLogger("tenbackward.matching")
_FILENAME_PATTERN = re.compile(r"^(\d{4})-(\d{2})-(\d{2})-(.+)$")
_DEFAULT_TZ = "Europe/Berlin"
@dataclass(frozen=True)
class MatchedPost:
path: str
title: str
date: date
url: str
def _today_in_berlin(tz_name: str = _DEFAULT_TZ) -> date:
return datetime.now(ZoneInfo(tz_name)).date()
def _is_leap_year(year: int) -> bool:
return calendar.isleap(year)
def _try_parse_date(value: object) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return None
def _safe_filename_match(stem: str) -> tuple[int, int, int, str] | None:
match = _FILENAME_PATTERN.match(stem)
if match is None:
return None
year_str, month_str, day_str, slug = match.groups()
try:
year = int(year_str)
month = int(month_str)
day = int(day_str)
except ValueError:
return None
try:
date(year, month, day)
except ValueError:
return None
if not slug:
return None
return year, month, day, slug
def _build_url(site_url: str, post_date: date, slug: str) -> str:
base = site_url.rstrip("/")
return f"{base}/{post_date.year:04d}/{post_date.month:02d}/{post_date.day:02d}/{slug}/"
def _is_unpublished(metadata: dict) -> bool:
if "published" not in metadata:
return False
return metadata["published"] is False
def _matches_anniversary(post_date: date, target_year: int, today: date) -> bool:
if post_date.year != target_year:
return False
if post_date.month == 2 and post_date.day == 29:
if _is_leap_year(today.year):
return today.month == 2 and today.day == 29
return today.month == 3 and today.day == 1
return post_date.month == today.month and post_date.day == today.day
def _process_file(
md_path: Path,
post_root: Path,
site_url: str,
target_year: int,
today: date,
) -> MatchedPost | None:
rel = md_path.relative_to(post_root).as_posix()
try:
with md_path.open("r", encoding="utf-8") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError) as exc:
_LOGGER.warning(
"skipping unreadable post file",
extra={"event": "post_unreadable", "path": rel, "error": type(exc).__name__},
)
return None
title: str | None = None
post_date: date | None = None
unpublished = False
frontmatter_parsed = False
try:
parsed = frontmatter.loads(text)
frontmatter_parsed = True
metadata = parsed.metadata if isinstance(parsed.metadata, dict) else {}
if isinstance(metadata.get("title"), str) and metadata["title"].strip():
title = metadata["title"]
post_date = _try_parse_date(metadata.get("date"))
unpublished = _is_unpublished(metadata)
except Exception as exc: # noqa: BLE001 — frontmatter is opaque
_LOGGER.warning(
"frontmatter parse failed",
extra={"event": "post_frontmatter_error", "path": rel, "error": type(exc).__name__},
)
slug_match = _safe_filename_match(md_path.stem)
if slug_match is None:
if not frontmatter_parsed:
return None
_LOGGER.warning(
"filename missing required YYYY-MM-DD-slug pattern",
extra={"event": "post_filename_invalid", "path": rel},
)
return None
fn_year, fn_month, fn_day, fn_slug = slug_match
if post_date is None:
post_date = date(fn_year, fn_month, fn_day)
if not _matches_anniversary(post_date, target_year, today):
return None
if unpublished:
_LOGGER.warning(
"skipping unpublished post",
extra={"event": "post_unpublished", "path": rel},
)
return None
if title is None:
title = fn_slug
return MatchedPost(
path=rel,
title=title,
date=post_date,
url=_build_url(site_url, post_date, fn_slug),
)
def find_anniversary_matches(
post_root: Path,
site_url: str,
*,
today: date | None = None,
) -> list[MatchedPost]:
"""Walk the Jekyll post directory and return posts whose anniversary
date is exactly 10 years before ``today`` (per the leap-day rules).
"""
if today is None:
today = _today_in_berlin()
target_year = today.year - 10
if not post_root.exists():
return []
matches: list[MatchedPost] = []
for md_path in sorted(post_root.rglob("*.md")):
if not md_path.is_file():
continue
try:
result = _process_file(md_path, post_root, site_url, target_year, today)
except Exception as exc: # noqa: BLE001 — never abort the scan
rel = md_path.relative_to(post_root).as_posix()
_LOGGER.warning(
"unexpected error processing post",
extra={
"event": "post_processing_error",
"path": rel,
"error": type(exc).__name__,
},
)
continue
if result is not None:
matches.append(result)
return matches
def iter_anniversary_paths(post_root: Path, site_url: str, *, today: date | None = None):
"""Yield each matched post's relative ``path`` (the dedupe identifier)."""
for match in find_anniversary_matches(post_root, site_url, today=today):
yield match.path
__all__ = [
"MatchedPost",
"find_anniversary_matches",
"iter_anniversary_paths",
]