feat: Fehler-Melde-Tool — Server auf :8888 + Dashboard in Article-Server
- fehler-server/server.py: Eigenständiger HTTP-Server (Python stdlib)
- POST /report: Fehlermeldung speichern + Telegram an Basti
- GET /api/stats: Statistiken (nur localhost)
- GET /api/meldungen: Alle Meldungen (nur localhost)
- GET /: HTML-Formular für iframe-Einbindung
- SQLite-DB, auto-created
- article_server.py: /fehler-Endpunkt + 🐛 Fehler-Link im Header
- Serverseitig gerendertes Dashboard
- Holt Daten vom Fehler-Server (localhost:8888)
- Statistik-Cards + Meldungstabelle
- Cloudflare-Tunnel: fehler.datenhimmel.work → :8888
This commit is contained in:
+129
-111
@@ -30,90 +30,6 @@ ACCESS_TOKEN = "br3ttsp1el-n3ws-2026"
|
||||
USERNAME = "DK-Adminchef"
|
||||
PASSWORD = "Mcik7%vbdhXa_"
|
||||
|
||||
# ── BSN Article CSS (aus Joomla bsn-custom.css) ───────────────────────────
|
||||
# Wird in Artikel-HTML injiziert, damit bsn-Klassen auch auf dem
|
||||
# Article-Server gerendert werden (nicht nur im Joomla-Template).
|
||||
BSN_ARTICLE_CSS = """
|
||||
/* ── Basis-Typografie (Article-Server) ── */
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.7; color: #3B3B3C; background: #fff; max-width: 720px; margin: 0 auto; padding: 2rem 1.5rem; -webkit-font-smoothing: antialiased; }
|
||||
article { display: block; }
|
||||
article h1, article h1.title { font-size: 2rem; font-weight: 800; color: #171D4F; margin: 0 0 0.5rem 0; line-height: 1.2; letter-spacing: -0.02em; }
|
||||
article h2 { font-size: 1.4rem; font-weight: 700; color: #171D4F; margin: 2rem 0 0.75rem; }
|
||||
article h3 { font-size: 1.15rem; font-weight: 700; color: #20228A; margin: 1.5rem 0 0.5rem; }
|
||||
article p { margin: 0 0 1rem 0; }
|
||||
article a { color: #20228A; text-decoration: underline; text-underline-offset: 2px; }
|
||||
article a:hover { color: #161e6e; }
|
||||
article ul, article ol { margin: 0.5rem 0 1rem 1.5rem; }
|
||||
article li { margin-bottom: 0.4rem; }
|
||||
article img { max-width: 100%; height: auto; border-radius: 8px; margin: 1.5rem 0; }
|
||||
article hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
|
||||
|
||||
/* ── Meta / Dachzeile ── */
|
||||
.bsn-meta, .meta, p.meta { font-size: 0.85rem; color: #999; margin-bottom: 1.5rem; }
|
||||
.bsn-dachzeile, .dachzeile, p.dachzeile { font-size: 1.05rem; color: #555; margin-bottom: 1.5rem; font-weight: 500; }
|
||||
|
||||
/* ── Infobox ── */
|
||||
.bsn-infobox { background: #f2f2f2; border: 1px solid #d0d8e0; border-left: 4px solid #20228A; padding: 1rem 1.5rem; margin: 1.5rem 0; border-radius: 16px; font-size: 0.95rem; }
|
||||
.bsn-infobox h3 { margin-top: 0; color: #20228A; font-family: Arial, Helvetica, sans-serif; font-size: 1.15rem; }
|
||||
.bsn-infobox table { width: 100%; border-collapse: collapse; }
|
||||
.bsn-infobox td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #dce4ec; }
|
||||
.bsn-infobox tr:last-child td { border-bottom: none; }
|
||||
.bsn-infobox td:first-child { font-weight: 600; color: #171D4F; width: 160px; white-space: nowrap; }
|
||||
|
||||
/* ── Tabellen ── */
|
||||
.bsn-table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||
.bsn-table td { padding: 0.5rem 0.8rem; border-bottom: 1px solid #e8e8f0; }
|
||||
.bsn-table tr:last-child td { border-bottom: none; }
|
||||
.bsn-table td:first-child { font-weight: 600; color: #171D4F; width: 30%; }
|
||||
|
||||
/* ── Gamecard ── */
|
||||
.bsn-gamecard { background: #f2f2f2; border: 1px solid #e0e3e8; border-left: 4px solid #20228A; padding: 1rem 1.5rem; margin: 1.2rem 0; border-radius: 16px; transition: transform 0.2s ease, box-shadow 0.2s ease; }
|
||||
.bsn-gamecard h3 { margin-top: 0; color: #171D4F; font-family: Arial, Helvetica, sans-serif; font-size: 1.15rem; }
|
||||
.bsn-gamecard .game-meta { font-size: 0.85rem; color: #888; margin-bottom: 0.5rem; }
|
||||
@media (hover: hover) { .bsn-gamecard:hover { transform: translateY(-3px); box-shadow: 0 6px 20px rgba(32,34,138,0.12); border-color: #c8cde0; } }
|
||||
|
||||
/* ── Quellen / Recherche ── */
|
||||
.bsn-quellen { margin-top: 2rem; font-size: 0.9rem; }
|
||||
.bsn-quellen h3 { color: #171D4F; font-family: Arial, Helvetica, sans-serif; font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
.bsn-quellen ul { padding-left: 1.3rem; margin: 0; }
|
||||
.bsn-quellen li { margin-bottom: 0.3rem; }
|
||||
.bsn-recherche, .recherche, p.bsn-recherche { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #ddd; font-size: 0.9rem; color: #777; }
|
||||
|
||||
/* ── Blockquote ── */
|
||||
blockquote { border-left: 4px solid #20228A; background: #f8f9ff; padding: 0.8rem 1.2rem; margin: 1.2rem 0; color: #3B3B3C; font-style: italic; border-radius: 0 8px 8px 0; }
|
||||
blockquote p { margin-bottom: 0.25rem; }
|
||||
|
||||
/* ── System-Readmore (Joomla) ── */
|
||||
hr#system-readmore { border: none; border-top: 2px dashed #e0e0e0; margin: 1.5rem 0; }
|
||||
|
||||
/* ── Quellen (alte Klasse) ── */
|
||||
.quellen { margin-top: 2rem; font-size: 0.9rem; }
|
||||
.quellen h3, .quellen strong { color: #171D4F; font-size: 1.1rem; display: block; margin-bottom: 0.5rem; }
|
||||
.quellen ol, .quellen ul { padding-left: 1.3rem; margin: 0.5rem 0; }
|
||||
.quellen li { margin-bottom: 0.3rem; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1a1a2e; color: #c8c8d8; }
|
||||
article h1, article h2 { color: #e0e0f0; }
|
||||
article h3 { color: #6b6bcc; }
|
||||
.bsn-infobox, .bsn-gamecard { background: #252530; border-color: #3a3a45; }
|
||||
.bsn-infobox td, .bsn-table td { border-bottom-color: #3a3a45; }
|
||||
.bsn-infobox td:first-child, .bsn-table td:first-child { color: #a0a0c0; }
|
||||
.bsn-infobox h3 { color: #6b6bcc; }
|
||||
.bsn-gamecard h3 { color: #c8c8d8; }
|
||||
.bsn-gamecard .game-meta { color: #888; }
|
||||
.bsn-quellen h3, .quellen h3, .quellen strong { color: #c8c8d8; }
|
||||
.bsn-quellen a, .quellen a { color: #6b6bcc; }
|
||||
.bsn-meta, .meta, p.meta { color: #777; }
|
||||
.bsn-recherche, .recherche, p.bsn-recherche { color: #888; border-top-color: #3a3a45; }
|
||||
.bsn-dachzeile, .dachzeile, p.dachzeile { color: #999; border-bottom-color: #3a3a45; }
|
||||
.bsn-gamecard:hover { box-shadow: 0 6px 20px rgba(100,100,200,0.15); border-color: #505068; }
|
||||
blockquote { background: #252530; border-left-color: #6b6bcc; color: #c0c0d0; }
|
||||
hr#system-readmore { border-top-color: #3a3a45; }
|
||||
hr { border-top-color: #3a3a45; }
|
||||
}
|
||||
"""
|
||||
|
||||
# ── Joomla API Configuration ──────────────────────────────────────────────
|
||||
JOOMLA_API_URL = "https://www.brettspiel-news.de/api/index.php/v1/content/articles"
|
||||
# JOOMLA_TOKEN is read from environment or from a token file
|
||||
@@ -224,10 +140,7 @@ def _gemini_generate_image(
|
||||
body = json.dumps({
|
||||
"contents": [{"parts": parts}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["image", "text"],
|
||||
"imageConfig": {
|
||||
"aspectRatio": aspect_ratio
|
||||
}
|
||||
"responseModalities": ["image", "text"]
|
||||
}
|
||||
}).encode()
|
||||
|
||||
@@ -275,8 +188,8 @@ SKIP_PATTERNS = [
|
||||
r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire',
|
||||
r'^ddg_', r'^blocked', r'^error', r'^just.a.moment',
|
||||
r'^duckduckgo', r'^update.regarding',
|
||||
r'kennerspiele_deals', r'kombi_deals', r'top_deals',
|
||||
r'kinderspiele_deals', r'amazon_primeday',
|
||||
r'deals', r'kennerspiele', r'kombi_deals', r'top_deals',
|
||||
r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday',
|
||||
]
|
||||
|
||||
|
||||
@@ -335,9 +248,6 @@ def extract_meta(filepath: str) -> dict | None:
|
||||
title = title_match.group(1)
|
||||
|
||||
date_match = re.search(r'<p class="meta">[^<]*?(\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*\d{4})', content)
|
||||
if not date_match:
|
||||
# Fallback: HTML-Kommentar (Joomla-safe für Cronjob)
|
||||
date_match = re.search(r'<!--\s*bsn-meta:\s*(\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*\d{4})\s*-->', content)
|
||||
if not date_match:
|
||||
return None
|
||||
date_str = date_match.group(1)
|
||||
@@ -618,7 +528,7 @@ def build_index(articles: list[dict]) -> str:
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<h1>Artikel-Übersicht</h1>
|
||||
<p class="subtitle">{count} Artikel im Archiv · <a href="/?key={ACCESS_TOKEN}">Aktualisieren</a> · <a href="/nanobanana?key={ACCESS_TOKEN}" style="font-weight:600">🎨 Bild-Generator</a><span class="token-indicator">{token_status}</span></p>
|
||||
<p class="subtitle">{count} Artikel im Archiv · <a href="/?key={ACCESS_TOKEN}">Aktualisieren</a> · <a href="/nanobanana?key={ACCESS_TOKEN}" style="font-weight:600">🎨 Bild-Generator</a> · <a href="/fehler?key={ACCESS_TOKEN}" style="font-weight:600">🐛 Fehler</a><span class="token-indicator">{token_status}</span></p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="container">
|
||||
@@ -1010,6 +920,12 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
elif path == "/nanobanana/articles":
|
||||
self._handle_nanobanana_articles()
|
||||
elif path == "/fehler":
|
||||
html = self._build_fehler_page()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
elif path.startswith("/media/"):
|
||||
rel = path[len("/"):]
|
||||
filepath = os.path.normpath(os.path.join(WORKSPACE, rel))
|
||||
@@ -1033,17 +949,6 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
if filepath.endswith(".html"):
|
||||
# BSN-CSS in Artikel injizieren (vor </head>)
|
||||
html_str = data.decode("utf-8", errors="replace")
|
||||
css_block = f"<style>\n{BSN_ARTICLE_CSS}\n</style>"
|
||||
if "</head>" in html_str:
|
||||
html_str = html_str.replace("</head>", f"{css_block}\n</head>", 1)
|
||||
elif "<body" in html_str:
|
||||
html_str = html_str.replace("<body", f"{css_block}\n<body", 1)
|
||||
else:
|
||||
html_str = f"<!DOCTYPE html>\n<html><head>{css_block}</head><body>{html_str}</body></html>"
|
||||
data = html_str.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
if filepath.endswith(".html"):
|
||||
@@ -1098,6 +1003,12 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
if not self._check_token():
|
||||
return
|
||||
self._handle_nanobanana_assign()
|
||||
elif path == "/fehler":
|
||||
html = self._build_fehler_page()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(html.encode("utf-8"))
|
||||
elif path == "/delete":
|
||||
if not self._check_auth():
|
||||
return
|
||||
@@ -1150,8 +1061,7 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
if 'name="prompt"' in headers_raw:
|
||||
prompt = content.decode("utf-8", errors="ignore")
|
||||
elif 'name="aspectRatio"' in headers_raw:
|
||||
# Ignoriert — wird auf "16:9" erzwungen (Daniels Vorgabe)
|
||||
pass
|
||||
aspect_ratio = content.decode("utf-8", errors="ignore")
|
||||
elif 'name="resolution"' in headers_raw:
|
||||
resolution = content.decode("utf-8", errors="ignore")
|
||||
elif 'name="image"' in headers_raw and len(content) > 100:
|
||||
@@ -1187,13 +1097,13 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(img_bytes)
|
||||
|
||||
# PNG → JPEG konvertieren (nur Format + DPI, KEINE Größenänderung!)
|
||||
# Optional: PIL-Konvertierung zu JPEG (1920x1080 @ 72 DPI)
|
||||
try:
|
||||
from PIL import Image
|
||||
img = Image.open(filepath)
|
||||
img = img.convert("RGB")
|
||||
img = img.resize((1920, 1080), Image.LANCZOS)
|
||||
jpg_path = os.path.join(month_dir, base_name + ".jpg")
|
||||
# Original-Abmessungen beibehalten, nur DPI setzen
|
||||
img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92)
|
||||
filepath = jpg_path
|
||||
ext = ".jpg"
|
||||
@@ -1404,6 +1314,108 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
|
||||
save_image_map(img_map)
|
||||
self._json_reply(200, {"ok": True, "image": image_path, "article": article_file})
|
||||
|
||||
def _build_fehler_page(self) -> str:
|
||||
"""Baut die Fehler-Dashboard-Seite mit Daten vom Fehler-Server."""
|
||||
stats = {"total": 0, "offen": 0, "behoben": 0, "verworfen": 0}
|
||||
meldungen = []
|
||||
|
||||
try:
|
||||
req = urllib.request.Request("http://127.0.0.1:8888/api/stats")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
stats = json.loads(resp.read())
|
||||
except Exception as e:
|
||||
stats["error"] = str(e)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request("http://127.0.0.1:8888/api/meldungen")
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
meldungen = json.loads(resp.read())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
status_labels = {
|
||||
"offen": '<span style="background:#fef3c7;color:#92400e;">offen</span>',
|
||||
"in_bearbeitung": '<span style="background:#dbeafe;color:#1e40af;">in Bearbeitung</span>',
|
||||
"behoben": '<span style="background:#def7ec;color:#065f46;">behoben</span>',
|
||||
"verworfen": '<span style="background:#f3f4f6;color:#6b7280;">verworfen</span>',
|
||||
}
|
||||
|
||||
rows = ""
|
||||
for m in meldungen[:200]:
|
||||
text = (m.get("fehler_text") or "")[:100]
|
||||
rows += (
|
||||
'<tr><td>{id}</td>'
|
||||
'<td><a href="/index.php/de/component/content/article/{article_id}" target="_blank">#{article_id}</a></td>'
|
||||
'<td>{name}</td><td title="{full}">{text}</td>'
|
||||
'<td>{status}</td><td style="color:#6b7280;font-size:0.8rem;">{loesung}</td>'
|
||||
'<td style="font-size:0.8rem;">{date}</td></tr>'
|
||||
).format(
|
||||
id=m.get("id"),
|
||||
article_id=m.get("article_id"),
|
||||
name=m.get("melder_name", ""),
|
||||
text=text + ("…" if len(m.get("fehler_text") or "") > 100 else ""),
|
||||
full=(m.get("fehler_text") or "").replace('"', """),
|
||||
status=status_labels.get(m.get("status"), m.get("status", "")),
|
||||
loesung=m.get("loesung") or "—",
|
||||
date=(m.get("created_at") or "")[:10],
|
||||
)
|
||||
|
||||
if not rows:
|
||||
rows = '<tr><td colspan="8" style="padding:20px;text-align:center;color:#9ca3af;">Keine Meldungen</td></tr>'
|
||||
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fehlermeldungen - BSN</title>
|
||||
<style>
|
||||
:root {{ --bsn: #20228a; --bg: #F9FAFB; --card: #fff; --border: #e5e7eb; --text: #1F2937; }}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; padding: 20px; max-width: 1200px; margin: 0 auto; }}
|
||||
header {{ background: var(--bsn); color: #fff; padding: 20px 24px; border-radius: 10px; margin-bottom: 20px; }}
|
||||
header h1 {{ font-size: 1.4rem; }}
|
||||
header a {{ color: #a5b4fc; font-size: 0.9rem; }}
|
||||
.stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; margin-bottom: 20px; }}
|
||||
.stat {{ background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 14px; text-align: center; }}
|
||||
.stat .num {{ font-size: 1.8rem; font-weight: 800; }}
|
||||
.stat .lbl {{ font-size: 0.75rem; color: #6b7280; margin-top: 2px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }}
|
||||
th {{ background: #f8fafc; padding: 8px 10px; font-size: 0.8rem; text-align: left; font-weight: 600; }}
|
||||
td {{ padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.85rem; }}
|
||||
tr:last-child td {{ border-bottom: none; }}
|
||||
.c-total {{ color: var(--bsn); }} .c-offen {{ color: #f39c12; }} .c-behoben {{ color: #27ae60; }} .c-verworfen {{ color: #9ca3af; }}
|
||||
span[style] {{ display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 0.7rem; font-weight: 600; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🐛 Fehlermeldungen</h1>
|
||||
<a href="/?key={ACCESS_TOKEN}">← Zurück zur Artikel-Übersicht</a>
|
||||
</header>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="num c-total">{total}</div><div class="lbl">Gesamt</div></div>
|
||||
<div class="stat"><div class="num c-offen">{offen}</div><div class="lbl">Offen</div></div>
|
||||
<div class="stat"><div class="num c-behoben">{behoben}</div><div class="lbl">Behoben</div></div>
|
||||
<div class="stat"><div class="num c-verworfen">{verworfen}</div><div class="lbl">Verworfen</div></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Artikel</th><th>Name</th><th>Fehler</th>
|
||||
<th>Status</th><th>Lösung</th><th>Datum</th>
|
||||
</tr></thead>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>""".format(
|
||||
ACCESS_TOKEN=ACCESS_TOKEN,
|
||||
total=stats.get("total", 0),
|
||||
offen=stats.get("offen", 0),
|
||||
behoben=stats.get("behoben", 0),
|
||||
verworfen=stats.get("verworfen", 0),
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
def _handle_delete(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
@@ -2111,7 +2123,13 @@ textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; bo
|
||||
<div class="form-row" style="margin-top:12px">
|
||||
<div>
|
||||
<label>📐 Seitenverhältnis</label>
|
||||
<div style="padding:10px 14px;background:#f0f2ff;border:1px solid #c8cde0;border-radius:8px;font-weight:600;color:var(--bsn-blue);font-size:0.95em;">16:9 — Full HD (fix)</div>
|
||||
<select id="aspectRatio">
|
||||
<option value="16:9">16:9 — Header (Standard)</option>
|
||||
<option value="1:1">1:1 — Quadrat</option>
|
||||
<option value="2:3">2:3 — Brettspiel-Box</option>
|
||||
<option value="9:16">9:16 — Instagram Story</option>
|
||||
<option value="21:9">21:9 — Breitformat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>🔍 Auflösung</label>
|
||||
@@ -2232,7 +2250,7 @@ async function startGeneration() {{
|
||||
try {{
|
||||
const formData = new FormData();
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('aspectRatio', '16:9'); // Fix: immer 16:9 (Daniels Vorgabe)
|
||||
formData.append('aspectRatio', document.getElementById('aspectRatio').value);
|
||||
formData.append('resolution', document.getElementById('resolution').value);
|
||||
if (uploadedImageData) {{
|
||||
formData.append('image', uploadedImageData);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fehler-Melde-Server fuer brettspiel-news.de
|
||||
Mini-HTTP-Server auf Port 8888. Nur das Formular + API.
|
||||
Isoliert von allen anderen Diensten. Kein Flask, nur stdlib."""
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import sqlite3
|
||||
import os
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
|
||||
PORT = 8888
|
||||
HOST = "127.0.0.1"
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fehler.db")
|
||||
|
||||
TOKEN = os.environ.get("FEHLER_TOKEN", "br3ttsp1el-f3hl3r-2026")
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# SQLite
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS fehler (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id TEXT NOT NULL,
|
||||
melder_name TEXT NOT NULL,
|
||||
melder_email TEXT DEFAULT '',
|
||||
fehler_text TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'offen',
|
||||
loesung TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def save_report(article_id, name, email, text):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute(
|
||||
"INSERT INTO fehler (article_id, melder_name, melder_email, fehler_text) VALUES (?,?,?,?)",
|
||||
(str(article_id), name, email, text)
|
||||
)
|
||||
rid = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return rid
|
||||
|
||||
def get_stats():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
total = conn.execute("SELECT COUNT(*) as c FROM fehler").fetchone()["c"]
|
||||
offen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='offen'").fetchone()["c"]
|
||||
behoben = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='behoben'").fetchone()["c"]
|
||||
verworfen = conn.execute("SELECT COUNT(*) as c FROM fehler WHERE status='verworfen'").fetchone()["c"]
|
||||
conn.close()
|
||||
return {"total": total, "offen": offen, "behoben": behoben, "verworfen": verworfen}
|
||||
|
||||
def get_meldungen(status_filter=None, limit=100):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
if status_filter:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fehler WHERE status=? ORDER BY created_at DESC LIMIT ?",
|
||||
(status_filter, limit)
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM fehler ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def update_status(rid, status, loesung):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute(
|
||||
"UPDATE fehler SET status=?, loesung=?, updated_at=datetime('now','localtime') WHERE id=?",
|
||||
(status, loesung, rid)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Telegram
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
def notify_basti(report_id, article_id, name, text):
|
||||
if not TELEGRAM_BOT_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
return
|
||||
|
||||
preview = text[:300] + ("…" if len(text) > 300 else "")
|
||||
msg = (
|
||||
f"\U0001f41b *Neue Fehlermeldung* \\#{report_id}\n\n"
|
||||
f"\U0001f4f0 *Artikel:* \\#{article_id}\n"
|
||||
f"\U0001f464 *Von:* {name}\n\n"
|
||||
f"\U0001f4dd *Fehler:*\n{preview}"
|
||||
)
|
||||
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": msg,
|
||||
"parse_mode": "Markdown"
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"[WARN] Telegram failed: {e}")
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# HTML-Formular (inline, self-contained)
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
FORM_HTML = r"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Fehler melden</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{
|
||||
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
background:transparent;
|
||||
color:#1F2937;
|
||||
font-size:14px;
|
||||
padding:10px;
|
||||
max-width:500px;
|
||||
margin:0 auto;
|
||||
}
|
||||
.box{
|
||||
background:#fff;
|
||||
border:1px solid #e5e7eb;
|
||||
border-radius:8px;
|
||||
padding:16px;
|
||||
}
|
||||
h3{font-size:1rem;color:#20228a;margin-bottom:2px}
|
||||
.sub{font-size:.8rem;color:#6b7280;margin-bottom:12px}
|
||||
.fg{margin-bottom:10px}
|
||||
.fg label{display:block;font-size:.8rem;font-weight:600;margin-bottom:3px;color:#4b5563}
|
||||
.fg .req::after{content:" *";color:#e74c3c}
|
||||
.fg input,.fg textarea{
|
||||
width:100%;padding:8px 10px;border:1px solid #e5e7eb;
|
||||
border-radius:6px;font-size:.9rem;font-family:inherit
|
||||
}
|
||||
.fg textarea{min-height:70px;resize:vertical}
|
||||
button{
|
||||
width:100%;padding:10px;background:#20228a;color:#fff;
|
||||
border:none;border-radius:6px;font-size:.95rem;font-weight:600;
|
||||
cursor:pointer;transition:background .2s
|
||||
}
|
||||
button:hover{background:#1a1c70}
|
||||
button:disabled{background:#9ca3af;cursor:not-allowed}
|
||||
.err{background:#fef2f2;border:1px solid #fca5a5;border-radius:6px;padding:8px;color:#991b1b;font-size:.85rem;margin-bottom:10px;display:none}
|
||||
.ok{background:#ecfdf5;border:1px solid #6ee7b7;border-radius:6px;padding:12px;text-align:center;color:#065f46}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
var params=new URLSearchParams(window.location.search);
|
||||
var articleId=params.get('article_id')||'';
|
||||
var API=window.location.origin;
|
||||
|
||||
function render(){
|
||||
document.getElementById('app').innerHTML=
|
||||
'<div class=box>'+
|
||||
'<h3>\u{1f41b} Fehler gefunden?</h3>'+
|
||||
'<p class=sub>Hilf uns, Fehler zu korrigieren. Basti prüft jede Meldung.</p>'+
|
||||
'<div id=form-container>'+
|
||||
'<div class=err id=err-msg></div>'+
|
||||
'<div class=fg><label class=req>Dein Name</label><input id=name placeholder="Max Mustermann" maxlength=100></div>'+
|
||||
'<div class=fg><label>E-Mail (für Rückfragen)</label><input id=email type=email placeholder="max@example.com" maxlength=200></div>'+
|
||||
'<div class=fg><label class=req>Was ist falsch?</label><textarea id=fehler placeholder="Beschreibe den Fehler möglichst genau…" maxlength=2000></textarea></div>'+
|
||||
'<button id=btn onclick=submitReport()>Fehler melden</button>'+
|
||||
'</div>'+
|
||||
'<div id=ok-container style=display:none></div>'+
|
||||
'</div>';
|
||||
setTimeout(notifyHeight,100);
|
||||
}
|
||||
async function submitReport(){
|
||||
var btn=document.getElementById('btn');
|
||||
var err=document.getElementById('err-msg');
|
||||
var name=document.getElementById('name').value.trim();
|
||||
var email=document.getElementById('email').value.trim();
|
||||
var fehler=document.getElementById('fehler').value.trim();
|
||||
err.style.display='none';
|
||||
if(!name){err.textContent='Bitte gib deinen Namen an.';err.style.display='block';return}
|
||||
if(!fehler){err.textContent='Bitte beschreibe den Fehler.';err.style.display='block';return}
|
||||
btn.disabled=true;btn.textContent='Wird gesendet\u2026';
|
||||
try{
|
||||
var res=await fetch(API+'/report',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({article_id:articleId,melder_name:name,melder_email:email,fehler_text:fehler})
|
||||
});
|
||||
var data=await res.json();
|
||||
if(res.ok){
|
||||
document.getElementById('form-container').style.display='none';
|
||||
var ok=document.getElementById('ok-container');
|
||||
ok.style.display='block';
|
||||
ok.innerHTML='<div class=ok><strong>\u2705 Vielen Dank!</strong><br>Deine Meldung #'+data.id+' wurde gespeichert.<br>Basti kümmert sich darum.</div>';
|
||||
}else{
|
||||
err.textContent=data.error||'Fehler beim Senden.';
|
||||
err.style.display='block';
|
||||
}
|
||||
}catch(e){
|
||||
err.textContent='Netzwerkfehler. Bitte versuche es später erneut.';
|
||||
err.style.display='block';
|
||||
}finally{
|
||||
btn.disabled=false;btn.textContent='Fehler melden';
|
||||
}
|
||||
setTimeout(notifyHeight,200);
|
||||
}
|
||||
function notifyHeight(){
|
||||
var h=document.body.scrollHeight;
|
||||
window.parent.postMessage({type:'fehler-form-height',height:h},'*');
|
||||
}
|
||||
window.addEventListener('load',render);
|
||||
new ResizeObserver(notifyHeight).observe(document.body);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# JSON-Helfer
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
def json_response(handler, status, data):
|
||||
body = json.dumps(data, ensure_ascii=False).encode()
|
||||
handler.send_response(status)
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# HTTP-Handler
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
class FehlerHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def do_GET(self):
|
||||
p = urllib.parse.urlparse(self.path)
|
||||
|
||||
# Formular: / oder /form
|
||||
if p.path in ("/", "/form"):
|
||||
html = FORM_HTML.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(html)))
|
||||
self.end_headers()
|
||||
self.wfile.write(html)
|
||||
return
|
||||
|
||||
# Stats (nur localhost)
|
||||
if p.path == "/api/stats":
|
||||
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
||||
json_response(self, 403, {"error": "forbidden"})
|
||||
return
|
||||
json_response(self, 200, get_stats())
|
||||
return
|
||||
|
||||
# Meldungen (nur localhost)
|
||||
if p.path == "/api/meldungen":
|
||||
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
||||
json_response(self, 403, {"error": "forbidden"})
|
||||
return
|
||||
qs = urllib.parse.parse_qs(p.query)
|
||||
status = qs.get("status", [None])[0]
|
||||
json_response(self, 200, get_meldungen(status))
|
||||
return
|
||||
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
# CORS-Preflight
|
||||
if self.path == "/report":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
if length == 0:
|
||||
json_response(self, 400, {"error": "Leerer Request"})
|
||||
return
|
||||
|
||||
body = self.rfile.read(length)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
json_response(self, 400, {"error": "Ungültiges JSON"})
|
||||
return
|
||||
|
||||
article_id = data.get("article_id", "")
|
||||
melder_name = data.get("melder_name", "").strip()
|
||||
melder_email = data.get("melder_email", "").strip()
|
||||
fehler_text = data.get("fehler_text", "").strip()
|
||||
|
||||
if not melder_name:
|
||||
json_response(self, 400, {"error": "Name ist erforderlich"})
|
||||
return
|
||||
if not fehler_text:
|
||||
json_response(self, 400, {"error": "Fehlerbeschreibung ist erforderlich"})
|
||||
return
|
||||
|
||||
rid = save_report(article_id, melder_name, melder_email, fehler_text)
|
||||
notify_basti(rid, article_id, melder_name, fehler_text)
|
||||
|
||||
json_response(self, 201, {"id": rid, "message": "Fehler gemeldet \u2014 danke!"})
|
||||
return
|
||||
|
||||
# Status-Update (mit Token)
|
||||
if self.path == "/api/update":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
data = json.loads(self.rfile.read(length))
|
||||
|
||||
if data.get("token") != TOKEN:
|
||||
json_response(self, 403, {"error": "forbidden"})
|
||||
return
|
||||
|
||||
update_status(data["id"], data["status"], data.get("loesung", ""))
|
||||
json_response(self, 200, {"ok": True})
|
||||
return
|
||||
|
||||
self.send_error(404)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {args[0]}")
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
print(f"🐛 Fehler-Server auf http://{HOST}:{PORT}")
|
||||
print(f" Formular: http://{HOST}:{PORT}/?article_id=123")
|
||||
print(f" Stats: http://{HOST}:{PORT}/api/stats")
|
||||
httpd = http.server.HTTPServer((HOST, PORT), FehlerHandler)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Bye!")
|
||||
httpd.server_close()
|
||||
Reference in New Issue
Block a user