156 lines
5.3 KiB
Python
156 lines
5.3 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"] != "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)
|