diff --git a/publish_gatekeeper.py b/publish_gatekeeper.py new file mode 100644 index 0000000..a6cbb2d --- /dev/null +++ b/publish_gatekeeper.py @@ -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
-Body, strippe problematische Elemente. + Returns: (bereinigter_body, fehlerliste) + """ + errors = [] + m = re.search(r"
(.*?)
", html, re.DOTALL) + if not m: + return "", ["KEIN
-Tag gefunden!"] + body = m.group(1) + + # ⛔ STRIP: — würde doppelt erscheinen (Joomla hat eigenen Title) + title_match = re.search(r"<title>.*?", body, re.DOTALL) + if title_match: + errors.append(f"⛔ im <article>-Body gefunden — wird gestrippt!") + body = re.sub(r"<title>.*?\s*", "", body, count=1, flags=re.DOTALL) + + # ⛔ STRIP:

— Joomla rendert eigenen Title als

+ h1_match = re.search(r"

.*?

", body, re.DOTALL) + if h1_match: + errors.append(f"⛔

im
-Body — wird gestrippt!") + body = re.sub(r"

.*?

\s*", "", body, count=1, flags=re.DOTALL) + + # ⛔ STRIP: erstes

— dupliziert Joomla-Titel („doppelte Überschrift") + h2_match = re.search(r"

(.*?)

", body, re.DOTALL) + if h2_match: + h2_text = h2_match.group(1).strip() + errors.append(f"⛔

im Body (\"{h2_text[:60]}...\") — wird gestrippt (Joomla-Titel reicht)!") + body = body[: h2_match.start()] + body[h2_match.end() :] + + # ⛔ STRIP:

— Datum kommt von Joomla + if re.search(r'

', body): + errors.append(f"⛔

im

-Body — wird gestrippt!") + body = re.sub(r'

.*?

\s*', "", body, count=1, flags=re.DOTALL) + + # ⛔ STRIP:

— wird per extract_meta() vom Server geholt + if re.search(r'

', body): + errors.append(f"⛔

im

-Body — wird gestrippt!") + body = re.sub(r'

.*?

\s*', "", body, count=1, flags=re.DOTALL) + + # ⛔ KEIN
nach Read-More + if re.search(r'system-readmore"\s*/>\s* nach Read-More — wird gestrippt!") + body = re.sub(r'(system-readmore"\s*/>)\s*', r"\1", body) + + # ⛔ Prüfe: Vorbestell-Box (wenn vorhanden) MUSS im
sein + # (Ist bereits, da wir nur
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 '
)!") + + # Keine Reste von , <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>(.*?)", 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'-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 [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)