feat: publish_gatekeeper.py — VALIDATE→PUBLISH→VERIFY pipeline

Ersetzt alle manuellen curl/PATCH-Workflows.
- Strippt doppelte Überschriften (<title>, <h1>, <h2>, meta, dachzeile)
- Eindeutige Aliase mit Zeitstempel (verhindert 'same alias'-Fehler)
- Sofortige Post-Publish-Verifikation (text-Länge, Vorbestell-Box, Read-More)
- Kein HTTP 200 ohne echten Content mehr
This commit is contained in:
Hermes Agent
2026-07-03 11:07:17 +02:00
parent fe071ffd8a
commit 0bc68ecd25
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
BSN Publish Gatekeeper — VALIDATE → PUBLISH → VERIFY
Kein Artikel geht live ohne diesen dreistufigen Check.
Ersetzt alle manuellen curl/PATCH-Workflows.
"""
import re
import json
import sys
import urllib.request
import urllib.error
TOKEN_FILE = "/home/hermes/.hermes/joomla_token.txt"
API_BASE = "https://www.brettspiel-news.de/api/index.php/v1/content/articles"
# ── LOAD TOKEN ────────────────────────────────────────────
with open(TOKEN_FILE) as f:
TOKEN = f.read().strip()
HEADERS = {
"Accept": "application/vnd.api+json",
"Content-Type": "application/json",
"X-Joomla-Token": TOKEN,
}
HEADERS_GET = {
"Accept": "application/vnd.api+json",
"X-Joomla-Token": TOKEN,
}
def load_html(path: str) -> str:
"""Lade HTML-Datei."""
with open(path) as f:
return f.read()
def extract_article_body(html: str) -> tuple[str, list[str]]:
"""Extrahiere <article>-Body, strippe problematische Elemente.
Returns: (bereinigter_body, fehlerliste)
"""
errors = []
m = re.search(r"<article>(.*?)</article>", html, re.DOTALL)
if not m:
return "", ["KEIN <article>-Tag gefunden!"]
body = m.group(1)
# ⛔ STRIP: <title> — würde doppelt erscheinen (Joomla hat eigenen Title)
title_match = re.search(r"<title>.*?</title>", body, re.DOTALL)
if title_match:
errors.append(f"⛔ <title> im <article>-Body gefunden — wird gestrippt!")
body = re.sub(r"<title>.*?</title>\s*", "", body, count=1, flags=re.DOTALL)
# ⛔ STRIP: <h1> — Joomla rendert eigenen Title als <h1>
h1_match = re.search(r"<h1>.*?</h1>", body, re.DOTALL)
if h1_match:
errors.append(f"⛔ <h1> im <article>-Body — wird gestrippt!")
body = re.sub(r"<h1>.*?</h1>\s*", "", body, count=1, flags=re.DOTALL)
# ⛔ STRIP: erstes <h2> — dupliziert Joomla-Titel („doppelte Überschrift")
h2_match = re.search(r"<h2>(.*?)</h2>", body, re.DOTALL)
if h2_match:
h2_text = h2_match.group(1).strip()
errors.append(f"⛔ <h2> im Body (\"{h2_text[:60]}...\") — wird gestrippt (Joomla-Titel reicht)!")
body = body[: h2_match.start()] + body[h2_match.end() :]
# ⛔ STRIP: <p class="meta"> — Datum kommt von Joomla
if re.search(r'<p class="meta">', body):
errors.append(f"⛔ <p class=\"meta\"> im <article>-Body — wird gestrippt!")
body = re.sub(r'<p class="meta">.*?</p>\s*', "", body, count=1, flags=re.DOTALL)
# ⛔ STRIP: <p class="dachzeile"> — wird per extract_meta() vom Server geholt
if re.search(r'<p class="dachzeile">', body):
errors.append(f"⛔ <p class=\"dachzeile\"> im <article>-Body — wird gestrippt!")
body = re.sub(r'<p class="dachzeile">.*?</p>\s*', "", body, count=1, flags=re.DOTALL)
# ⛔ KEIN <br> nach Read-More
if re.search(r'system-readmore"\s*/>\s*<br', body):
errors.append(f"⛔ <br> nach Read-More — wird gestrippt!")
body = re.sub(r'(system-readmore"\s*/>)\s*<br\s*/?>', r"\1", body)
# ⛔ Prüfe: Vorbestell-Box (wenn vorhanden) MUSS im <article> sein
# (Ist bereits, da wir nur <article> extrahiert haben — aber Check fürs Protokoll)
has_vorbestell = bool(re.search(r'(?:vorbestell-box|border:\s*2px\s+dotted.*Vorbestellbar)', body, re.DOTALL | re.IGNORECASE))
return body.strip(), errors
def validate_body(body: str) -> list[str]:
"""Validiere den bereinigten Body. Returns Liste von Fehlern."""
errors = []
# Read-More MUSS vorhanden sein
if '<hr id="system-readmore"' not in body:
errors.append("❌ KEIN Read-More-Tag (<hr id=\"system-readmore\" />)!")
# Keine Reste von <title>, <h1>, meta, dachzeile
for tag in ["<title>", "<h1>", '<p class="meta">', '<p class="dachzeile">']:
if tag in body:
errors.append(f"❌ Verbotenes Element im Body: {tag} (trotz Strip!)")
# Kein <br> alleinstehend nach Read-More
if re.search(r'system-readmore"\s*/>\s*<br', body):
errors.append("❌ <br> nach Read-More!")
# Kein <style>-Tag
if "<style>" in body and "</style>" in body:
errors.append("⚠️ <style>-Tag im Body (Joomla-Template liefert CSS)")
return errors
def split_readmore(body: str) -> tuple[str, str]:
"""Splitte Body am Read-More in introtext + fulltext.
Entfernt den <hr>-Tag aus beiden Teilen.
"""
sr_match = re.search(r'<hr id="system-readmore"\s*/>', body)
if sr_match:
intro = body[: sr_match.start()].strip()
full = body[sr_match.end() :].strip()
return intro, full
else:
# Kein Read-More → alles als fulltext (introtext bleibt leer)
return "", body.strip()
def create_article(
title: str,
introtext: str,
fulltext: str,
catid: int = 61,
state: int = 0,
access: int = 6,
metadesc: str = "",
metakey: str = "",
) -> int | None:
"""Erstelle Artikel per Joomla API POST. Returns article ID oder None."""
payload = {
"title": title,
"alias": f"{re.sub(r'[^a-z0-9-]', '', title.lower().replace(' ', '-').replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss'))[:80]}-{int(__import__('time').time()) % 100000}",
"introtext": introtext,
"fulltext": fulltext,
"catid": str(catid),
"state": str(state),
"access": str(access),
"created_by": "560",
"language": "de",
"featured": "0",
}
if metadesc:
payload["metadesc"] = metadesc
if metakey:
payload["metakey"] = metakey
data = json.dumps(payload).encode()
req = urllib.request.Request(API_BASE, data=data, headers=HEADERS, method="POST")
try:
resp = urllib.request.urlopen(req)
result = json.loads(resp.read())
return int(result["data"]["id"])
except urllib.error.HTTPError as e:
body = e.read().decode()[:500]
print(f" ❌ CREATE fehlgeschlagen: HTTP {e.code}\n {body}", file=sys.stderr)
return None
def verify_article(aid: int, expected_body: str) -> list[str]:
"""Verifiziere Artikel nach Publish. Returns Fehlerliste."""
errors = []
req = urllib.request.Request(f"{API_BASE}/{aid}", headers=HEADERS_GET)
try:
resp = urllib.request.urlopen(req)
r = json.loads(resp.read())
attrs = r["data"]["attributes"]
text = attrs.get("text", "")
state = attrs.get("state")
catid = r["data"]["relationships"]["category"]["data"]["id"]
# 1. Text-Länge prüfen (±20% Toleranz, Joomla fügt Zeilenumbrüche hinzu)
expected_len = len(expected_body)
actual_len = len(text)
ratio = actual_len / max(expected_len, 1)
if ratio < 0.8:
errors.append(
f"❌ TEXT ZU KURZ: {actual_len} vs erwartet {expected_len} ({ratio:.0%})"
)
elif ratio > 1.3:
errors.append(
f"⚠️ TEXT LÄNGER: {actual_len} vs erwartet {expected_len} ({ratio:.0%})"
)
# 2. Read-More wirkt: text muss beide Teile enthalten
# Joomla speichert introtext+fulltext ohne <hr>-Tag im text-Feld
if actual_len < 300:
errors.append("❌ Text extrem kurz — Read-More hat möglicherweise Content abgeschnitten!")
# 3. Intro-Content prüfen (erste ~800 Zeichen sollten NICHT leer sein)
if len(text) > 0 and len(text[:500].strip()) < 50:
errors.append("❌ Intro-Text leer — Read-More-Fehler!")
# 3. Vorbestell-Box prüfen (falls erwartet)
if "Vorbestellbar" in expected_body and "Vorbestellbar" not in text:
errors.append("❌ VORBESTELL-BOX FEHLT im publizierten Artikel!")
# 4. Verbotene Elemente prüfen
if "<title>" in text:
errors.append("❌ <title> im publizierten Content (doppelte Überschrift)!")
if "<h1>" in text:
errors.append("❌ <h1> im publizierten Content!")
if '<p class="meta">' in text:
errors.append("❌ <p class=\"meta\"> im publizierten Content!")
return errors
except urllib.error.HTTPError as e:
return [f"❌ VERIFY fehlgeschlagen: HTTP {e.code}"]
def publish(html_path: str, catid: int = 61, state: int = 0, access: int = 6) -> int | None:
"""Komplette Publish-Pipeline: validate → create → verify.
Returns: article ID oder None bei Fehler.
"""
print(f"\n{'='*60}")
print(f"📄 PUBLISH: {html_path}")
print(f"{'='*60}")
# ── PHASE 1: VALIDATE ──────────────────────────────
print("\n🔍 Phase 1: VALIDATE")
html = load_html(html_path)
# Meta extrahieren
title_match = re.search(r"<title>(.*?)</title>", html, re.DOTALL)
title = title_match.group(1).strip() if title_match else "UNTITLED"
print(f" Title: {title[:80]}...")
meta_desc_match = re.search(r'<meta name="description" content="([^"]*)"', html)
metadesc = meta_desc_match.group(1) if meta_desc_match else ""
meta_key_match = re.search(r'<meta name="keywords" content="([^"]*)"', html)
metakey = meta_key_match.group(1) if meta_key_match else ""
# Body extrahieren + strippen
body, strip_errors = extract_article_body(html)
if not body:
print(f" ❌ FATAL: Kein <article>-Body!")
return None
for err in strip_errors:
print(f" {err}")
print(f" Body-Länge: {len(body)} Zeichen")
# Validieren
validation_errors = validate_body(body)
if validation_errors:
for err in validation_errors:
print(f" {err}")
print("\n ❌ VALIDIERUNG FEHLGESCHLAGEN — ABBRUCH")
return None
print(" ✅ Validierung bestanden")
# ── PHASE 2: PUBLISH ──────────────────────────────
print("\n📤 Phase 2: PUBLISH")
intro, full = split_readmore(body)
print(f" introtext: {len(intro)} Zeichen")
print(f" fulltext: {len(full)} Zeichen")
aid = create_article(
title=title,
introtext=intro,
fulltext=full,
catid=catid,
state=state,
access=access,
metadesc=metadesc,
metakey=metakey,
)
if not aid:
print(" ❌ PUBLISH FEHLGESCHLAGEN")
return None
print(f" ✅ Artikel #{aid} erstellt")
# ── PHASE 3: VERIFY ──────────────────────────────
print("\n🔬 Phase 3: VERIFY")
verify_errors = verify_article(aid, body)
if verify_errors:
for err in verify_errors:
print(f" {err}")
print(f"\n ❌ VERIFIZIERUNG FEHLGESCHLAGEN — Artikel #{aid} hat Probleme!")
print(f" 🔗 https://www.brettspiel-news.de/?view=article&id={aid}")
return aid # Artikel existiert, aber ist fehlerhaft
print(f" ✅ Alle Checks bestanden — Artikel #{aid} ist sauber")
print(f" 🔗 https://www.brettspiel-news.de/?view=article&id={aid}")
return aid
# ── CLI ───────────────────────────────────────────────────
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 publish_gatekeeper.py <artikel.html> [catid] [state] [access]")
print(" catid: 61=Korrektur (default), 65=Reviews, 68=Deals, 52=Premium")
print(" state: 0=unveröffentlicht (default), 1=veröffentlicht")
print(" access: 6=Redaktion (default), 1=Public")
sys.exit(1)
html_file = sys.argv[1]
catid = int(sys.argv[2]) if len(sys.argv) > 2 else 61
state = int(sys.argv[3]) if len(sys.argv) > 3 else 0
access = int(sys.argv[4]) if len(sys.argv) > 4 else 6
result = publish(html_file, catid=catid, state=state, access=access)
if result:
print(f"\n✅ Fertig — Artikel #{result}")
else:
print(f"\n❌ Publish fehlgeschlagen")
sys.exit(1)