#!/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)