Files
anwar-pi-website/automation/anwarpy/publication_meta.py
T

169 lines
5.8 KiB
Python

from __future__ import annotations
from datetime import datetime
import re
import unicodedata
from urllib.parse import urlsplit
CATEGORIES = ("وكلاء", "نماذج", "استرجاع", "سلامة", "أدوات", "أعمال")
ACCESSES = ("free", "vip")
STATUSES = ("pending", "publishing", "published", "blocked", "republish")
BLOCKED_REASONS = ("sanitizer", "generation", "article_schema", "cover", "video")
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_INVISIBLE = re.compile(r"[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]")
_NO_BREAK = re.compile(r"[\u00a0\u202f]")
_FIELD_ORDER = (
"status",
"access",
"category",
"queued_at",
"slug",
"published_at",
"url",
"blocked_reason",
)
_REQUIRED = {
"pending": {"status", "access", "category", "queued_at"},
"publishing": {"status", "access", "category", "queued_at", "slug"},
"published": {"status", "access", "category", "queued_at", "slug", "published_at", "url"},
"blocked": {"status", "access", "category", "queued_at", "blocked_reason"},
"republish": {"status", "access", "category", "queued_at", "slug", "published_at", "url"},
}
def clean(line: str) -> str:
line = _INVISIBLE.sub(" ", line)
line = _NO_BREAK.sub(" ", line)
return unicodedata.normalize("NFC", line).strip()
def _aware_datetime(value: str, field: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"{field} must be a valid ISO-8601 datetime") from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError(f"{field} must include a timezone offset")
return parsed
def _validate_url(value: str, slug: str) -> None:
parsed = urlsplit(value)
expected_path = f"/posts/{slug}"
if (
parsed.scheme != "https"
or parsed.netloc != "anwarpy.ai"
or parsed.path != expected_path
or parsed.query
or parsed.fragment
):
raise ValueError(f"url must equal https://anwarpy.ai{expected_path}")
def parse_meta(raw: str) -> dict[str, str]:
line = clean(raw)
if not line.startswith("anwarpy:"):
raise ValueError("marker must start with exact prefix anwarpy:")
body = line.removeprefix("anwarpy:").strip()
if not body:
raise ValueError("marker fields are required")
meta: dict[str, str] = {}
for part in body.split("|"):
segment = clean(part)
if "=" not in segment:
raise ValueError(f"invalid marker field: {segment}")
key, value = (clean(item) for item in segment.split("=", 1))
if key not in _FIELD_ORDER:
raise ValueError(f"unknown marker field: {key}")
if key in meta:
raise ValueError(f"duplicate marker field: {key}")
if not value:
raise ValueError(f"empty marker field: {key}")
meta[key] = value
status = meta.get("status")
if status not in STATUSES:
raise ValueError("status is invalid")
required = _REQUIRED[status]
missing = required - meta.keys()
if missing:
raise ValueError(f"missing required fields: {', '.join(sorted(missing))}")
forbidden = meta.keys() - required
if forbidden:
raise ValueError(f"fields not allowed for {status}: {', '.join(sorted(forbidden))}")
if meta["access"] not in ACCESSES:
raise ValueError("access is invalid")
if meta["category"] not in CATEGORIES:
raise ValueError("category is invalid")
_aware_datetime(meta["queued_at"], "queued_at")
slug = meta.get("slug")
if slug is not None and not SLUG_RE.fullmatch(slug):
raise ValueError("slug must be lowercase ASCII kebab-case")
if "published_at" in meta:
_aware_datetime(meta["published_at"], "published_at")
if "url" in meta:
_validate_url(meta["url"], meta["slug"])
if "blocked_reason" in meta and meta["blocked_reason"] not in BLOCKED_REASONS:
raise ValueError("blocked_reason is invalid")
return meta
def render_meta(meta: dict[str, str]) -> str:
validated = parse_meta(
"anwarpy: " + " | ".join(f"{key}={value}" for key, value in meta.items())
)
return "anwarpy: " + " | ".join(
f"{key}={validated[key]}" for key in _FIELD_ORDER if key in validated
)
def same_meta(expected_raw: str, actual_raw: str) -> bool:
return parse_meta(expected_raw) == parse_meta(actual_raw)
def mark_publishing(raw: str, slug: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("pending", "republish"):
raise ValueError("only pending or republish markers can be claimed")
normalized_slug = clean(slug)
if meta["status"] == "republish" and normalized_slug != meta["slug"]:
raise ValueError("republish slug cannot change")
meta = {
"status": "publishing",
"access": meta["access"],
"category": meta["category"],
"queued_at": meta["queued_at"],
"slug": normalized_slug,
}
return render_meta(meta)
def mark_published(raw: str, published_at: str, url: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("publishing", "republish"):
raise ValueError("only publishing or republish markers can become published")
meta["status"] = "published"
meta["published_at"] = clean(published_at)
meta["url"] = clean(url)
return render_meta(meta)
def mark_blocked(raw: str, reason: str) -> str:
meta = parse_meta(raw)
if meta["status"] not in ("pending", "publishing"):
raise ValueError("only pending or publishing markers can be blocked")
meta = {
"status": "blocked",
"access": meta["access"],
"category": meta["category"],
"queued_at": meta["queued_at"],
"blocked_reason": clean(reason),
}
return render_meta(meta)