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:
@@ -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)
|
||||
Reference in New Issue
Block a user