feat: validate Anwarpy publication metadata
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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"] != "pending":
|
||||
raise ValueError("only pending markers can be claimed")
|
||||
meta["status"] = "publishing"
|
||||
meta["slug"] = clean(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"] != "pending":
|
||||
raise ValueError("only pending markers can be blocked")
|
||||
meta["status"] = "blocked"
|
||||
meta["blocked_reason"] = clean(reason)
|
||||
return render_meta(meta)
|
||||
Reference in New Issue
Block a user