feat: Anniversary Matching Across Jekyll Posts

This commit is contained in:
OpenVelo Agent
2026-08-04 18:19:54 +00:00
parent 3fe2b90f51
commit 89dd3c4faf
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",
]
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import logging
from datetime import date
from pathlib import Path
from typing import Callable
import pytest
from tenbackward.matching import (
MatchedPost,
find_anniversary_matches,
iter_anniversary_paths,
)
SITE_URL = "https://chaospott.de"
_MATCHING_LOGGER = "tenbackward.matching"
def _write_post(root: Path, rel_path: str, *, body: str = "post") -> None:
full = root / rel_path
full.parent.mkdir(parents=True, exist_ok=True)
full.write_text(body, encoding="utf-8")
def _capture_matching_warnings(call: Callable[[], object]) -> list[logging.LogRecord]:
records: list[logging.LogRecord] = []
handler = logging.Handler()
handler.setLevel(logging.WARNING)
handler.emit = records.append # type: ignore[assignment]
logger = logging.getLogger(_MATCHING_LOGGER)
previous_level = logger.level
logger.setLevel(logging.WARNING)
logger.addHandler(handler)
try:
call()
finally:
logger.removeHandler(handler)
logger.setLevel(previous_level)
return records
def _event_names(records: list[logging.LogRecord]) -> list[str]:
return [getattr(record, "event", None) for record in records]
def test_match_uses_frontmatter_date_and_title(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(
root,
"2014/2014-08-04-foo.md",
body="---\ntitle: Foo\ndate: 2014-08-04\n---\nbody\n",
)
today = date(2024, 8, 4)
matches = find_anniversary_matches(root, SITE_URL, today=today)
assert len(matches) == 1
match = matches[0]
assert isinstance(match, MatchedPost)
assert match.path == "2014/2014-08-04-foo.md"
assert match.title == "Foo"
assert match.date == date(2014, 8, 4)
assert match.url == "https://chaospott.de/2014/08/04/foo/"
def test_match_falls_back_to_filename_when_frontmatter_missing(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(root, "2015/2015-03-10-no-frontmatter.md", body="no frontmatter at all\n")
_write_post(
root, "2015/2015-03-11-broken-frontmatter.md", body="---\ndate: not-a-date\n: :\n"
)
today = date(2025, 3, 10)
matches: list[MatchedPost] = []
records = _capture_matching_warnings(
lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today))
)
paths = {m.path for m in matches}
assert "2015/2015-03-10-no-frontmatter.md" in paths
assert "2015/2015-03-11-broken-frontmatter.md" not in paths
match = next(m for m in matches if m.path == "2015/2015-03-10-no-frontmatter.md")
assert match.title == "no-frontmatter"
assert match.date == date(2015, 3, 10)
assert match.url == "https://chaospott.de/2015/03/10/no-frontmatter/"
def test_unpublished_post_is_skipped(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(
root,
"2014/2014-08-04-draft.md",
body="---\ntitle: Draft\ndate: 2014-08-04\npublished: false\n---\n",
)
today = date(2024, 8, 4)
matches: list[MatchedPost] = []
records = _capture_matching_warnings(
lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today))
)
assert matches == []
assert _event_names(records) == ["post_unpublished"]
def test_iter_anniversary_paths_yields_only_path(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(
root,
"2014/2014-08-04-foo.md",
body="---\ntitle: Foo\ndate: 2014-08-04\n---\n",
)
paths = list(iter_anniversary_paths(root, SITE_URL, today=date(2024, 8, 4)))
assert paths == ["2014/2014-08-04-foo.md"]
@pytest.mark.parametrize(
"today, source_iso, expected_match",
[
# Normal exact match (Mar 1 source, Mar 1 today) -> match
(date(2025, 3, 1), "2015-03-01", True),
# Mar 1 source (2016, the day after Feb 29) in a non-leap current year -> no match
(date(2025, 3, 1), "2016-03-01", False),
# Feb 29 source in a non-leap current year, today is Mar 1: source year
# (2016) does not equal target_year (2015), so it does NOT match under
# the strict year-equality rule.
(date(2025, 3, 1), "2016-02-29", False),
],
)
def test_leap_day_matching(
tmp_path: Path, today: date, source_iso: str, expected_match: bool
) -> None:
root = tmp_path / "_posts" / "blog"
target_year = today.year - 10
rel = f"{target_year}/{source_iso}-leap.md"
_write_post(
root,
rel,
body=f"---\ntitle: Leap\ndate: {source_iso}\n---\n",
)
matches = find_anniversary_matches(root, SITE_URL, today=today)
matched_paths = [m.path for m in matches]
if expected_match:
assert rel in matched_paths
else:
assert rel not in matched_paths
def test_match_uses_datetime_frontmatter_date(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(
root,
"2014/2014-08-04-with-time.md",
body="---\ntitle: WithTime\ndate: 2014-08-04T12:30:00\n---\n",
)
today = date(2024, 8, 4)
matches = find_anniversary_matches(root, SITE_URL, today=today)
assert len(matches) == 1
assert matches[0].date == date(2014, 8, 4)
def test_match_returns_results_in_deterministic_order(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
for slug, day in [("alpha", 1), ("beta", 2), ("gamma", 3)]:
_write_post(
root,
f"2014/2014-08-0{day}-{slug}.md",
body=f"---\ntitle: {slug}\ndate: 2014-08-0{day}\n---\n",
)
today = date(2024, 8, 2)
matches = find_anniversary_matches(root, SITE_URL, today=today)
assert [m.path for m in matches] == ["2014/2014-08-02-beta.md"]
def test_missing_post_root_returns_empty(tmp_path: Path) -> None:
today = date(2024, 8, 4)
assert find_anniversary_matches(tmp_path / "does-not-exist", SITE_URL, today=today) == []
def test_invalid_filename_is_skipped(tmp_path: Path) -> None:
root = tmp_path / "_posts" / "blog"
_write_post(root, "2014/not-a-date.md", body="---\ntitle: Bad\n---\n")
today = date(2024, 8, 4)
matches: list[MatchedPost] = []
records = _capture_matching_warnings(
lambda: matches.extend(find_anniversary_matches(root, SITE_URL, today=today))
)
assert matches == []
assert "post_filename_invalid" in _event_names(records)