feat(redax-wp): Multi-Publish, Dashboard-Verbesserungen, ESP32-Serie Teil 2

Redax-WP (Redakteur):
- WordPressMirrorClient: Multi-Publish an mehrere WP-Instanzen
- Target-Toggles im Dashboard (Checkbox, server-side rendering)
- WP-Admin Direktzugang via socat-Proxy (bypass Cloudflare WAF)
- Drag & Drop im Redaktionsplan
- Artikel-Karten mit Titel + SEO-Snippet sichtbar
- Entwürfe ohne Datum in separater Sektion
- DB-Cleanup-Job (Sonntag 03:00 Uhr)
- openrouter.py: sync generate() Wrapper
- mirror_posts Tabelle in DB

ESP32-Serie (Arakava News):
- Teil 1 veröffentlicht (Post 1209)
- Teil 2 als WP-Entwurf erstellt (Post 1340)
- Animiertes Hydraulikschema (SVG, 4 Betriebsmodi) in Teil 2 eingebaut
- Hardware liegt in DE, Einbau ab April nach Kambodscha-Rückkehr

Doku:
- STATE.md Redax-WP vollständig aktualisiert
- STATE.md Arakava-News: Serie-Status + Hardware-Timeline

Made-with: Cursor
This commit is contained in:
root 2026-02-28 19:25:00 +07:00
parent 0acd451ed4
commit 82eaa1e4bc
11 changed files with 965 additions and 342 deletions

View file

@ -1,5 +1,5 @@
# Arakava News — Live State
> Auto-generiert: 2026-02-28 13:15
> Auto-generiert: 2026-02-27 15:15
## Service Status
| Service | CT | Status |
@ -8,11 +8,11 @@
| WordPress Docker | 101 | running |
## Letzte Feed-Aktivität (Top 5)
Heise Security: 2026-02-28 10:48:09
Heise Online: 2026-02-28 10:48:09
Golem.de: 2026-02-28 10:48:06
Corona-Transition: 2026-02-28 10:48:03
Rubikon.news: 2026-02-28 10:47:55
Junge Freiheit: 2026-02-27 14:00:11
Dr. Bines Substack: 2026-02-27 14:00:00
Heise Online: 2026-02-27 13:48:13
Rubikon.news: 2026-02-27 13:47:56
Tichys Einblick: 2026-02-27 13:30:10
## Fehler (letzte 24h)
- Fehler gesamt: 0
@ -20,13 +20,18 @@
- Letzter Fehler:
## OpenRouter Guthaben
$7.76 verbleibend
$8.88 verbleibend
## URLs
- Blog: https://arakava-news-2.orbitalo.net
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS)
- RSS Manager: http://100.113.244.101:8080 (admin / astral66)
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS) — ⚠️ nur via Tailscale erreichbar!
- RSS Manager: https://rss-manager.orbitalo.net (astral66) — Cloudflare Tunnel
- RSS Manager (Tailscale): http://100.113.244.101:8080
- Matomo: https://matomo.orbitalo.net (admin / astral66)
- Google Search Console: https://search.google.com/search-console?resource_id=https://arakava-news-2.orbitalo.net/
- Google-Konto: Mila.Dek1968@gmail.com / Sicherlich-neue-658@1
- Verifizierung: HTML-Datei (google248e38a1e3540863.html im WP-Root)
- Sitemap: https://arakava-news-2.orbitalo.net/sitemap_index.xml
## Container
| CT | Dienst | Tailscale |
@ -54,10 +59,62 @@ $7.76 verbleibend
| 16 | Antispiegel | 08:30/14:30/20:30 |
| 17 | Riehle News | 09:00 Uhr |
## SEO (Rank Math)
- Plugin: Rank Math SEO (CT 101, WordPress)
- Sitemap: /sitemap_index.xml (automatisch, alle Posts)
- Open Graph + Twitter Cards: aktiv für alle Beiträge
- wp-login.php: öffentlich gesperrt (.htaccess → 403), nur Tailscale (100.x.x.x)
- Google-Verifizierung: /google248e38a1e3540863.html
- Phishing-Review: beantragt 27.02.2026 (wp-login war öffentlich sichtbar)
## Eigene Artikel (Serie: ESP32 Heizung)
| Teil | Status | Titel | WP Post-ID |
|------|--------|-------|------------|
| 1 | ✅ veröffentlicht | Vier Heizungen, ein Pufferspeicher: Warum ich meine Heizung smart mache | 1209 |
| 2 | 📝 Entwurf (wartet auf echte Fotos) | 70 Euro gegen Heizungschaos: Die Hardware für mein Smart-Home-Projekt | 1340 |
| 3 | geplant | Software: InfluxDB, Grafana-Dashboard | — |
| 4 | geplant | Display-Projekt: Layout, Wetterprognose, Kostenrechnung | — |
| 5 | geplant | Bonus: 2.8-Zoll-ESP32 als WLAN-Scanner | — |
**Hinweis Teil 2:** Hardware liegt bereits in DE (Briefkasten). Einbau ab Ende März / April nach Rückkehr aus Kambodscha.
Teil 2 enthält das animierte Hydraulikschema (WP Custom HTML Block, `#hz-schaltbild-2026`).
### Quellen für Artikelinhalt
- Entwurf Teil 1: `/root/homelab-brain/arakava-news/artikel/esp32-serie-teil1-entwurf.md`
- Technische Doku (Display-Layouts, Regeln, MQTT, Hardware): `pct exec 999 -- cat /root/ESP32-Heizung-Projekt.md`
- Hardware + Pin-Belegung: `/root/homelab-brain/esp32/PLAN.md`
- Smart Home Kontext (ioBroker, Grafana, InfluxDB): `/root/homelab-brain/smart-home/STATE.md`
- Grafiken (Header + Schema): WP Media ID 1207, 1208
- Display-Mockups: Aus ASCII-Art in Doku generieren (noch offen)
- Heizung: 4 Quellen (Solar, Holzvergaser, Luft-Luft-WP, Ölkessel), 1800L Puffer, Luft-Luft-WP NICHT am Puffer!
### Seafile-Assets (lokal kopiert)
Quelle: `Seafile → Nextcloud-Migration/Meine Dateien/Server/ESP32 Projekt`
Lokal: `/root/homelab-brain/arakava-news/artikel/seafile-assets/`
- `Fließschaltbild v4 HTML.md` — Animiertes interaktives SVG-Schaltbild (Solar/Holz/Öl, Energieflüsse animiert, Buttons für Betriebsmodi) → in WP als Custom HTML Block einbettbar
- `Fließschaltbild von Clode 4.6 als HTML.md` — Vereinfachte Version
- `Sensoren und Bilder.md` — Detaillierte Sensor-Planung (17 Sensoren: 6 Erzeuger, 2 Heizung, 4 HK-Rückläufe, 5 Puffer-Zonen)
- `Kombipufferspeicher.webp` — Grafik Pufferspeicher
### Seafile: Echte Fotos der Heizungsanlage
Pfad: `Seafile → Nextcloud-Migration/Meine Dateien/Heizung und Lüftung/Bilder`
- Mein HV.JPG — Holzvergaser
- PT100 im Abgasrohr des Holzvergasers.JPG — Abgassensor
- Pufferspeicher mit Isolierung.JPG — Puffertank
- Puffertank ohne Isolierung.jpg — Tank nackt
- Solarwärmetauscher.jpg — Solarthermie
- Heizkreisverteiler vor dem Umbau.jpg — Verteiler
- Siemens Logo und Solarregler mit KWH Anzeige.JPG — Steuerung
## Code (CT 109: /opt/rss-manager/)
poster.py, scheduler.py, app.py, db.py
## Änderungshistorie
- 28.02.2026: ESP32-Serie Teil 2 als WP-Entwurf erstellt (Post 1340) inkl. animiertem Hydraulikschema
- 27.02.2026: Google Search Console eingerichtet + Sitemap eingereicht
- 27.02.2026: Rank Math SEO installiert (Open Graph, Sitemap, Meta Tags)
- 27.02.2026: wp-login.php öffentlich gesperrt (Anti-Phishing)
- 27.02.2026: ESP32-Heizung Artikel Teil 1 veröffentlicht (Post 1209)
- 24.02.2026: Scheduler Lock gegen Doppelstarts
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.)

View file

@ -8,7 +8,7 @@
**Zweck:** KI-gestützter Telegram-Kanal-Poster für die tägliche Reihe "Fünf vor Acht"
**Posting-Zeit:** 19:55 Uhr (Europe/Berlin) — pro Artikel individuell konfigurierbar
**Kanal:** Privater Kanal (`-1001285446620`)
**Status:** 🏁 Vorerst abgeschlossen — 27.02.2026
**Status:** ✅ Abgeschlossen — 28.02.2026
---
@ -201,9 +201,18 @@ Events: `article_generated`, `article_saved`, `article_scheduled`, `article_sent
---
## Offene Punkte / TODOs
## Offene Punkte / Nice-to-have (nicht blockierend)
- [ ] Redakteure-Verwaltung UI in settings.html (API vorhanden)
- [ ] Kanal-ID in Settings-UI editierbar (API vorhanden)
- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop)
- [ ] Letzter-Post Zeitstempel im Dashboard anzeigen
---
## Abnahme
**28.02.2026 — Projekt abgeschlossen.**
- Logs der letzten 48h: Keine Fehler
- 2 erfolgreiche Posts (26.02. + 27.02.)
- Bot, Web, Scheduler laufen stabil

View file

@ -1,11 +1,11 @@
# STATE: Redax-WP
**Stand: 27.02.2026**
**Stand: 28.02.2026**
---
## Status
🏁 **Vorläufig abgeschlossen — 27.02.2026**
✅ **Vollständig in Betrieb — 28.02.2026**
---
@ -16,15 +16,56 @@
| 113 | Redax-WP Dashboard | pve-hetzner | 10.10.10.113 | ✅ Läuft |
| 113 | WordPress (DeutschlandBlog) | pve-hetzner | 10.10.10.113 | ✅ Läuft |
### URLs
- **Dashboard:** `https://redax.orbitalo.net` (Cloudflare Tunnel → Port 8080)
- **Blog:** `https://deutschlandblog.orbitalo.net` (Cloudflare Tunnel → Port 80)
- **WP-Admin:** `https://deutschlandblog.orbitalo.net/wp-admin`
---
### Zugangsdaten
- Dashboard: `admin` / `astral66`
- WP-Admin: `admin` / `Redax2026!`
- WP Application Password: `YJ7L4CFAxDPszGLXpamJmzl6`
## Zugangsdaten
| Dienst | URL | Login |
|--------|-----|-------|
| Dashboard | https://redax.orbitalo.net | admin / astral66 |
| Arakava News (Primary) | https://arakava-news-2.orbitalo.net | — |
| Arakava News WP-Admin | https://arakava-news-2.orbitalo.net/wp-admin | admin / astral66 |
| Arakava News App PW | (REST API) | XPKjaHFiYb8LOo16BeRL3huF |
| DeutschlandBlog (Mirror) | https://deutschlandblog.orbitalo.net | — |
| DeutschlandBlog WP-Admin | https://deutschlandblog.orbitalo.net/wp-admin | admin / Redax2026! |
| DeutschlandBlog App PW | (REST API) | YJ7L4CFAxDPszGLXpamJmzl6 |
---
## Multi-Publish Architektur
```
WordPressMirrorClient (wordpress.py)
├── Primary: arakava-news-2.orbitalo.net (WP_URL)
└── Mirror 1: deutschlandblog.orbitalo.net (WP_MIRROR_URL)
Beim Publish (job_publish_due):
1. Artikel wird auf Primary veröffentlicht
2. Duplikat-Check auf Mirror (Titel-Vergleich vor dem Post)
3. Mirror erhält denselben Artikel
4. Ergebnis in mirror_posts Tabelle gespeichert
5. Dashboard zeigt Mirror-Status pro Artikel
Erweiterbar: WP_MIRROR2_URL, WP_MIRROR2_ENABLED, ... (bis Mirror9)
```
---
## .env Konfiguration (CT 113: /opt/redax-wp/.env)
| Variable | Wert |
|----------|------|
| `WP_URL` | `https://arakava-news-2.orbitalo.net` |
| `WP_USERNAME` | `admin` |
| `WP_APP_PASSWORD` | `XPKjaHFiYb8LOo16BeRL3huF` |
| `WP_ADMIN_PASSWORD` | `astral66` |
| `WP_MIRROR_URL` | `https://deutschlandblog.orbitalo.net` |
| `WP_MIRROR_USERNAME` | `admin` |
| `WP_MIRROR_APP_PASSWORD` | `YJ7L4CFAxDPszGLXpamJmzl6` |
| `WP_MIRROR_ADMIN_PASSWORD` | `Redax2026!` |
| `WP_MIRROR_ENABLED` | `true` |
| `OPENROUTER_API_KEY` | gesetzt |
| `DB_PATH` | `/data/redax.db` |
---
@ -33,73 +74,52 @@
```
Docker Container:
redax-db MySQL 8.0
redax-wordpress WordPress 6.9.1 + Apache
redax-wordpress WordPress 6.9.1 + Apache (DeutschlandBlog)
redax-web Flask/Gunicorn Dashboard (Port 8080)
cloudflared Tunnel für redax.orbitalo.net
cloudflared-wp Tunnel für deutschlandblog.orbitalo.net
```
### Installed Plugins
- **Yoast SEO** (aktiviert)
- **Blocksy Companion** (aktiviert)
### Installed Theme
- **Blocksy** (aktiviert)
---
## WordPress Zustand
## Dashboard Features
| Element | Status |
|---------|--------|
| WordPress Version | 6.9.1 |
| Domain | https://deutschlandblog.orbitalo.net |
| Theme | Blocksy |
| Yoast SEO | installiert & aktiv |
| Kategorien | Rubrik 14 (je 1 Platzhalterbeitrag) |
| Navigation | Hauptmenü → Header Menu 1 + Mobile |
| Permalink-Struktur | `/%postname%/` |
---
## .env Konfiguration (CT 113: /opt/redax-wp/.env)
| Variable | Status |
|----------|--------|
| `WP_URL` | ✅ `https://deutschlandblog.orbitalo.net` |
| `WP_USERNAME` | ✅ `admin` |
| `WP_APP_PASSWORD` | ✅ gesetzt |
| `OPENROUTER_API_KEY` | ⏳ noch einzutragen |
| `TELEGRAM_BOT_TOKEN` | ⏳ noch einzutragen |
| `TELEGRAM_CHANNEL_ID` | ⏳ noch einzutragen |
---
## Offene Aufgaben
- [ ] OpenRouter API Key in `.env` eintragen
- [ ] Telegram Bot Token + Channel ID in `.env` eintragen
- [ ] Rubriken umbenennen (aktuell: Rubrik 14)
- [ ] Redax-WP Dashboard: KI-Artikel generieren & veröffentlichen testen
- [ ] RSS Feeds konfigurieren
| Feature | Beschreibung |
|---------|-------------|
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) |
| WP-Entwurf | Artikel direkt als Draft auf Primary pushen + Vorschau-Link |
| Redaktionsplan | 7-Tage-Kalender mit Status, Umplanen, Löschen |
| Multi-Publish | Beim Veröffentlichen: Primary + alle aktiven Mirrors |
| Publish-Ziele | Checkboxen zum Ein-/Ausschalten pro Mirror + Links zu Website & WP-Admin + Zugangsdaten |
| Mirror-Status | Pro Artikel: welche Sites wurden bespielt (✅/❌) |
| RSS-Queue | Feed-Artikel verwalten, KI-Rewrite, Auto-Publish |
| Duplikat-Schutz | Mirror überspringt Artikel die bereits vorhanden sind |
---
## Changelog
### 28.02.2026
- Multi-Publish implementiert: `WordPressMirrorClient` in `wordpress.py`
- `mirror_posts` Tabelle in SQLite für Mirror-Tracking
- `job_publish_due` publiziert auf Primary + alle aktiven Mirrors
- Publish-Ziele im Dashboard: Checkboxen zum Ein/Ausschalten (per DB-Setting)
- Links zu Website + WP-Admin + Zugangsdaten direkt sichtbar im Dashboard
- `WP_ADMIN_PASSWORD` + `WP_MIRROR_ADMIN_PASSWORD` in `.env` ergänzt
- Arakava News WP-Admin Passwort auf `astral66` gesetzt
- Test: Beide Targets erreichbar bestätigt
### 27.02.2026
- WordPress 6.9.1 installiert (Update von 6.7)
- WordPress 6.9.1 auf DeutschlandBlog installiert (Update)
- Blocksy Theme + Companion Plugin installiert
- Yoast SEO installiert
- 4 Kategorien angelegt (Rubrik 14)
- Hauptmenü mit allen Rubriken im Header
- Cloudflare Tunnel für WordPress eingerichtet
- WP Application Password generiert, in .env eingetragen
- Rohzustand der Website abgeschlossen
- WP Application Passwords generiert (beide Sites)
- WP-Draft Push: Artikel als Entwurf in WP speichern + Vorschau-Link im Dashboard
- Redakteur mit Arakava News (CT 101) verbunden
### 26.02.2026
- CT 113 auf pve-hetzner erstellt
- Docker Stack deployed (DB + WordPress + Flask + cloudflared)
- Docker Stack deployed (MySQL + WordPress + Flask + cloudflared)
- Dashboard unter redax.orbitalo.net erreichbar
- Cloudflare Tunnel für Dashboard eingerichtet
- Login-Seite Fix (inline CSS)
- Login-Seite eingerichtet

View file

@ -1,211 +1,90 @@
# Redax-WP
> KI-gestützter WordPress-Redakteur — selbst gehostet, Docker-basiert, template-ready.
KI-gestütztes Redaktionssystem für WordPress mit integriertem RSS-Feed-Manager.
Redax-WP ist ein vollständiges Redaktionssystem aus **WordPress + einem Flask-Dashboard**. Es generiert KI-Artikel, importiert RSS-Feeds und veröffentlicht automatisch in WordPress und optional auf Telegram.
## Was ist Redax-WP?
---
Redax-WP ersetzt das WordPress-Admin-Backend für redaktionelle Arbeit. Es kombiniert:
## Was kann Redax-WP?
| Feature | Beschreibung |
|---------|-------------|
| ✍️ KI-Artikel | Artikel per KI generieren (OpenRouter / GPT-4o, Claude, Mistral...) |
| 📰 RSS-Import | Feeds automatisch importieren, prüfen und veröffentlichen |
| 📅 Redaktionskalender | 7-Tage-Planung mit Drag & Drop |
| 🔍 Yoast SEO | SEO-Titel, Meta-Beschreibung, Focus-Keyword direkt im Editor |
| 📲 Telegram | KI-Artikel als Teaser auf Telegram posten |
| ⏰ Morgen-Briefing | Tägliche Zusammenfassung um 10:00 Uhr |
| 🚨 Fehler-Alarm | Sofortbenachrichtigung bei Veröffentlichungsfehlern |
| 🗂️ Kategorie-Routing | KI-Artikel → WordPress + Telegram / RSS-Artikel → nur WordPress |
| 🐳 Docker | Kompletter Stack per Docker Compose |
---
- **KI-Artikelgenerierung** (OpenRouter) mit automatischen SEO-Feldern
- **RSS-Feed-Import** mit konfigurierbarem Auto-Publish und optionalem KI-Rewrite
- **Redaktionsplanung** mit Kalender, Zeitslots und direktem Umplanen
- **WordPress-Veröffentlichung** via REST API (Publish / Entwurf / Einplanen)
- **Telegram-Benachrichtigung** nach Veröffentlichung von KI-Artikeln
## Schnellstart
```bash
# 1. Repository klonen
git clone https://github.com/Orbitalo/Redax-Wordpress.git mein-blog
cd mein-blog
### 1. Repository klonen
# 2. Setup starten (interaktiv)
chmod +x setup.sh
./setup.sh
```bash
git clone https://git.orbitalo.net/orbitalo/redax-wp.git
cd redax-wp
```
Das Setup-Skript erledigt automatisch:
- `.env` mit zufälligen Passwörtern generieren
- Docker Stack starten (MySQL + WordPress + Dashboard)
- WordPress installieren (6.9+)
- Blocksy Theme + Yoast SEO installieren
- Kategorien & Navigationsmenü anlegen
- WordPress Application Password für die REST-API generieren
**Danach:** API-Keys in `.env` eintragen und `make restart` ausführen.
---
## Voraussetzungen
| Software | Version |
|----------|---------|
| Docker | 24+ |
| Docker Compose | 2.x |
| Freie Ports | 80 (WordPress), 8080 (Dashboard) |
| Betriebssystem | Linux (Debian/Ubuntu empfohlen) |
---
## Konfiguration
### 2. Konfiguration
```bash
cp .env.example .env
nano .env # Pflichtfelder ausfüllen
# .env mit eigenen Werten befüllen (Editor öffnen)
nano .env
```
### Pflichtfelder
### 3. Starten
```bash
docker compose up -d
```
Dashboard: `http://localhost:8080`
### 4. WordPress einrichten
Nach dem ersten Start WordPress unter `http://localhost:81` (oder intern) einrichten:
1. WordPress-Installation abschließen
2. **Yoast SEO Plugin** installieren (für SEO-Meta-Tags)
3. In WordPress-Admin unter **Benutzer → Profil → Application Passwords** ein neues Passwort erstellen
4. Passwort in `.env` als `WP_APP_PASSWORD` eintragen
5. Container neu starten: `docker compose restart web`
## Konfiguration (.env)
| Variable | Beschreibung |
|----------|-------------|
| `DASHBOARD_USER` | Login für das Redax-WP Dashboard |
| `DASHBOARD_PASSWORD` | Passwort für das Dashboard |
| `WP_URL` | Öffentliche URL des WordPress-Blogs |
| `WP_USERNAME` | WordPress Admin-Benutzername |
| `WP_APP_PASSWORD` | WordPress Application Password (auto via setup.sh) |
| `OPENROUTER_API_KEY` | API-Key für KI-Generierung ([openrouter.ai](https://openrouter.ai)) |
| `MYSQL_ROOT_PASSWORD` | MySQL Root-Passwort |
| `MYSQL_PASSWORD` | MySQL Benutzer-Passwort |
### Optionale Felder (Telegram)
| Variable | Beschreibung |
|----------|-------------|
| `TELEGRAM_BOT_TOKEN` | Bot-Token von [@BotFather](https://t.me/BotFather) |
| `TELEGRAM_CHANNEL_ID` | Kanal-ID für Artikel-Teaser |
| `DASHBOARD_USER` | Login-Name für das Dashboard |
| `DASHBOARD_PASSWORD` | Login-Passwort für das Dashboard |
| `WP_URL` | WordPress-URL (intern: `http://wordpress`) |
| `WP_USERNAME` | WordPress-Benutzername |
| `WP_APP_PASSWORD` | WordPress Application Password |
| `OPENROUTER_API_KEY` | API-Key von openrouter.ai |
| `TELEGRAM_BOT_TOKEN` | Telegram Bot-Token |
| `TELEGRAM_CHANNEL_ID` | Kanal für KI-Artikel Teaser |
| `TELEGRAM_REVIEWER_IDS` | Chat-IDs für Fehler-Alarm (kommagetrennt) |
| `TIMEZONE` | Zeitzone (Standard: `Europe/Berlin`) |
---
## Workflow
## Befehle
### KI-Artikel
1. Quelle eingeben + Ton wählen → KI generiert Artikel
2. In Vorschau prüfen, ggf. bearbeiten
3. Einplanen oder sofort veröffentlichen
4. → WordPress + automatischer Telegram-Teaser
```bash
make help # Alle Befehle anzeigen
make start # Stack starten
make stop # Stack stoppen
make restart # Dashboard neustarten (nach .env-Änderung)
make logs # Live-Logs aller Container
make logs-web # Nur Dashboard-Logs
make status # Container-Status anzeigen
make shell-web # Shell im Dashboard-Container
make shell-db # MySQL-Shell öffnen
make backup # Datensicherung → ./backups/
make update # Docker-Images aktualisieren
make clean # Alle Daten löschen (Vorsicht!)
```
### WP-CLI
```bash
# Beliebige WP-CLI Befehle ausführen:
make wp plugin list
make wp user list
make wp cache flush
make wp post list
```
---
### RSS-Artikel
1. Feed unter `/feeds` hinzufügen
2. Modus wählen: Manuell / Auto-Publish / KI-Rewrite
3. Neue Artikel landen in Queue oder werden direkt veröffentlicht
4. → Nur WordPress (kein Telegram)
## Architektur
```
┌─────────────────────────────────────────────────────┐
│ Docker Stack │
│ │
│ ┌────────────────┐ ┌─────────────────────────┐ │
│ │ redax-web │ │ redax-wordpress │ │
│ │ Flask :8080 │◄──►│ Apache/PHP :80 │ │
│ │ Dashboard │ │ WordPress 6.9+ │ │
│ └──────┬─────────┘ └────────────┬────────────┘ │
│ │ │ │
│ └──────────────┬────────────┘ │
│ ▼ │
│ ┌───────────────┐ │
│ │ redax-db │ │
│ │ MySQL 8 :3306│ │
│ └───────────────┘ │
└──────────────────────┬──────────────────────────────┘
│ │
▼ ▼
https://redax. https://blog.
example.com example.com
(Dashboard) (Blog)
docker-compose.yml
├── web Flask Dashboard (:8080)
├── wordpress WordPress + Apache (:80 intern)
└── db MySQL 8
```
---
## Öffentlicher Zugang via Cloudflare Tunnel
Für öffentliche Erreichbarkeit ohne offene Firewall-Ports:
1. **[Cloudflare Zero Trust](https://one.dash.cloudflare.com)** → Networks → Tunnels → Create
2. **Tunnel 1** (Dashboard):
- Public Hostname: `redax.example.com`
- Service: `http://localhost:8080`
3. **Tunnel 2** (Blog):
- Public Hostname: `blog.example.com`
- Service: `http://localhost:80`
4. Den `docker run cloudflare/cloudflared ...`-Befehl aus dem CF-Dashboard ausführen
---
## Verzeichnisstruktur
```
redax-wp/
├── setup.sh ← Automatisches Ersteinrichtungs-Skript
├── Makefile ← Komfort-Befehle
├── docker-compose.yml ← Docker Stack Definition
├── .env.example ← Konfigurationsvorlage
├── README.md ← Diese Datei
├── .gitignore ← .env, data/, logs/ ausgeschlossen
└── src/
├── app.py ← Flask Dashboard (Haupt-App)
├── database.py ← SQLite Datenbankschicht
├── wordpress.py ← WordPress REST API Client
├── rss_fetcher.py ← RSS Feed Import
├── logger.py ← Strukturiertes JSON-Logging
├── requirements.txt ← Python-Abhängigkeiten
├── Dockerfile.web ← Container-Definition
└── templates/ ← HTML-Templates (Jinja2)
├── base.html
├── index.html ← Studio / Editor
├── feeds.html ← RSS Feed-Verwaltung
├── history.html ← Veröffentlichungs-Historie
├── prompts.html ← KI-Prompt Bibliothek
├── settings.html ← Einstellungen
├── hilfe.html ← Hilfe-Seite
└── login.html
```
---
## Datensicherung
```bash
make backup
# → Erstellt: ./backups/redax-wp-YYYYMMDD_HHMMSS.tar.gz
# Enthält: MySQL-Daten, WordPress-Dateien, SQLite-DB, .env
```
---
## Lizenz
MIT — frei verwendbar, anpassbar und weitergabe erlaubt.
---
## Entwickelt von
[Orbitalo](https://github.com/Orbitalo) — Homelab & Automatisierungsprojekte
MIT

View file

@ -1,11 +1,11 @@
# STATE: Redax-WP
**Stand: 27.02.2026**
# STATE: Redax-WP (Redakteur)
**Stand: 28.02.2026**
---
## Status
✅ **Sprint 1 + 2 abgeschlossen — Stack läuft auf CT 113**
✅ **Vollständig in Betrieb — Multi-Publish, KI-Serie, animierte Grafiken**
---
@ -24,17 +24,37 @@
| Dashboard | https://redax.orbitalo.net |
| Lokal | http://100.69.243.16:8080 |
| Login | admin / astral66 |
| Repo | git.orbitalo.net/orbitalo/redax-wp |
| Repo (Forgejo) | http://100.89.246.60:3000/orbitalo/homelab-brain |
| Repo (GitHub) | https://github.com/Orbitalo/homelab-brain |
---
## Stack (CT 113)
```
docker-compose.yml
├── redax-web Flask Dashboard (:8080)
├── redax-wordpress WordPress + Apache (:80 intern)
└── redax-db MySQL 8
/opt/redax-wp/
├── docker-compose.yml
├── src/
│ ├── app.py Flask-App, Scheduler, alle Routes
│ ├── wordpress.py WordPressClient + WordPressMirrorClient
│ ├── database.py SQLite Schema + Helpers
│ ├── openrouter.py OpenRouter API (sync wrapper)
│ ├── rss_fetcher.py RSS Feed Parser
│ ├── logger.py JSON Logging
│ ├── Dockerfile.web Docker Image Build
│ ├── requirements.txt
│ └── templates/ Jinja2 Templates (index, history, ...)
├── wordpress/ WP-Daten (Plugins, Themes, Uploads)
└── data/
├── redax.db SQLite Hauptdatenbank
└── mysql/ MySQL-Daten
```
Docker Container:
```
redax-web Flask Dashboard (:8080)
redax-wordpress WordPress + Apache (:80 intern)
redax-db MySQL 8
```
---
@ -48,6 +68,25 @@ docker-compose.yml
- Kategorie + Tags aus WordPress live geladen
- Publish / Entwurf / Einplanen (15-Minuten-Slots)
- Nach Publish → Telegram-Teaser an konfigurierten Kanal
- **Prompt-Bibliothek** mit anpassbarem Default-Prompt (inkl. ESP32-Serie-Prompt)
### Multi-Publish (neu)
- **Primäres Ziel:** `WP_URL` (Arakava News)
- **Mirror-Ziele:** bis zu n weitere WordPress-Instanzen konfigurierbar
- Toggle pro Ziel direkt im Dashboard (Checkbox → sofort aktiv/inaktiv)
- Duplikat-Schutz: Vor Mirror-Publish wird Titel auf Ziel geprüft
- Ergebnisse pro Ziel in `mirror_posts` Tabelle gespeichert
- Credentials (User/PW) direkt im Dashboard sichtbar
- WP-Admin Direktzugang via socat-Proxy (bypass Cloudflare WAF)
### Redaktionsplan (verbessert)
- 7-Tage-Kalender mit KI + RSS gemeinsam
- Badge: 🤖 KI / 📡 RSS
- **Drag & Drop** zum Umplanen zwischen Tagen
- Artikel-Karten mit Titel + SEO-Beschreibung sichtbar
- Direkt-Buttons: ✏️ Bearbeiten / 🌐 WP-Editor / 👁 Vorschau / 🗓 Umplanen / 🗑 Löschen
- **Entwürfe ohne Datum** in separater Sektion sichtbar
- WP-Editor-Link für Arakava News via socat-Proxy: `http://100.88.230.59:8101/wp-admin/`
### RSS-Feeds
- Beliebig viele Feeds konfigurierbar
@ -56,35 +95,37 @@ docker-compose.yml
- Werbeartikel-Blacklist (konfigurierbar pro Feed)
- RSS-Artikel erscheinen nie auf Telegram
### Redaktionsplan
- 7-Tage-Kalender mit KI + RSS gemeinsam
- Badge: 🤖 KI / 📡 RSS
- Umplanen + Löschen direkt im Board
### Telegram
- Nur KI-Artikel → Teaser-Post (Titel + SEO-Desc + WP-Link)
- Morgen-Briefing täglich 10:00 Uhr
- Fehler-Alarm bei WP-Publish-Fehler
### Weitere Features
- Prompt-Bibliothek (editierbar, Standard-Prompt wählbar)
- Post-History (alle veröffentlichten Artikel)
- Einstellungen + WP-Verbindungstest
- Hilfe-Seite (/hilfe)
- Tool-Switcher zu FünfVorAcht in der Nav
- Strukturiertes JSON-Logging (/logs/redax.log)
- Automatischer DB-Cleanup jeden Sonntag 03:00 Uhr
---
## Noch einzurichten (manuell)
## WP-Admin Direktzugang (bypass Cloudflare)
1. **WordPress-Setup:** http://100.69.243.16:80 aufrufen und Erstinstallation durchführen
2. **Yoast SEO Plugin** in WordPress installieren
3. **Application Password** in WP-Admin erstellen → in `.env` als `WP_APP_PASSWORD` eintragen
4. **OpenRouter API Key** in `.env` eintragen
5. **Telegram Bot Token + Kanal-ID** in `.env` eintragen
6. Nach .env-Änderungen: `docker compose restart web`
7. **Cloudflare Tunnel** für `redax.orbitalo.net` einrichten (optional)
**Problem:** Cloudflare WAF blockiert `/wp-login.php` auf Arakava News public domain.
**Lösung:** socat-Proxy auf pve-hetzner + WordPress mu-plugin
```bash
# socat Service auf pve-hetzner (läuft als systemd)
# /etc/systemd/system/wp101-proxy.service
# Proxy: http://100.88.230.59:8101 → 10.10.10.101:80
# mu-plugin auf CT 101 (/var/www/html/wp-content/mu-plugins/proxy-admin.php)
# → Setzt WP_HOME/WP_SITEURL auf HTTP-Proxy wenn Zugriff via 100.88.230.59
```
Direkt-URL: `http://100.88.230.59:8101/wp-admin/`
---
@ -93,8 +134,7 @@ docker-compose.yml
| Was | Pfad |
|-----|------|
| App | /opt/redax-wp/ |
| Daten | /opt/redax-wp/data/ |
| Datenbank | /opt/redax-wp/data/db/redax.db |
| Datenbank | /opt/redax-wp/data/redax.db |
| WordPress-Dateien | /opt/redax-wp/data/wordpress/ |
| MySQL-Daten | /opt/redax-wp/data/mysql/ |
| Logs | /opt/redax-wp/logs/ |
@ -102,21 +142,71 @@ docker-compose.yml
---
## Offene Punkte
## Umgebungsvariablen (.env)
- [ ] WordPress-Ersteinrichtung + Yoast installieren
- [ ] .env mit echten Credentials befüllen (OpenRouter, Telegram)
- [x] Cloudflare Tunnel → https://redax.orbitalo.net
- [ ] Erste Feeds hinzufügen
```env
# Primäres WordPress
WP_URL=https://arakava-news-2.orbitalo.net
WP_USERNAME=admin
WP_APP_PASSWORD=...
WP_ADMIN_PASSWORD=...
WP_ADMIN_DIRECT_URL=http://100.88.230.59:8101
# Mirror 1 (DeutschlandBlog o.ä.)
WP_MIRROR_URL=https://...
WP_MIRROR_USERNAME=admin
WP_MIRROR_APP_PASSWORD=...
WP_MIRROR_ENABLED=true
WP_MIRROR_ADMIN_PASSWORD=...
# OpenRouter
OPENROUTER_API_KEY=...
# Telegram
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHANNEL_ID=...
```
---
## Datenbankschema (Wichtigste Tabellen)
| Tabelle | Inhalt |
|---------|--------|
| `articles` | KI-Artikel (Entwürfe + geplante) |
| `prompts` | Prompt-Bibliothek |
| `settings` | Key-Value Config (inkl. target_disabled_*) |
| `feed_items` | RSS-Artikel |
| `post_history` | Alle veröffentlichten Posts |
| `mirror_posts` | Multi-Publish Ergebnisse pro Ziel |
---
## Changelog
### 27.02.2026 — DB-Cleanup-Job + Datenbank-Hygiene
- **Automatischer Cleanup:** Neuer Scheduler-Job läuft jeden Sonntag 03:00 Uhr
- `feed_items` (published/rejected) älter als 60 Tage → automatisch gelöscht
- `feed_items` (new/unbearbeitet) älter als 30 Tage → automatisch gelöscht
- `post_history` älter als 90 Tage → automatisch gelöscht
- VACUUM nach Cleanup → DB bleibt kompakt
### 28.02.2026 — ESP32-Serie Teil 2 + Animiertes Hydraulikschema
- **ESP32-Serie Teil 2** als WP-Entwurf erstellt (Post 1340 auf Arakava News)
- Titel: "70 Euro gegen Heizungschaos: Die Hardware für mein Smart-Home-Projekt"
- Status: Entwurf — wartet auf echte Fotos (Hardware liegt in DE, Einbau ab April)
- **Animiertes Fließschaltbild** (interaktives SVG-Hydraulikschema) in Teil 2 eingebaut
- Quelle: `seafile-assets/Fließschaltbild v4 HTML.md` (original von Claude 4.6)
- WordPress-kompatibel: scoped CSS `#hz-schaltbild-2026`, keine Theme-Konflikte
- 4 Betriebsmodi: Solar / Holzvergaser / Ölkessel / Alle Quellen
- Einbauposition: nach "Die Verkabelung"-Sektion → erklärt warum 8 Sensoren nötig
### 27.02.2026 — Multi-Publish + Dashboard-Verbesserungen
- **WordPressMirrorClient** implementiert (wordpress.py)
- **Mirror-Targets** im Dashboard toggle-bar mit Credentials angezeigt
- **WP-Admin Direktzugang** via socat-Proxy + mu-plugin (bypass Cloudflare WAF)
- **Drag & Drop** im Redaktionsplan implementiert
- **Artikel-Karten** vergrößert (Titel + SEO-Snippet sichtbar)
- **Entwürfe ohne Datum** in separater Sektion
- **DB-Cleanup-Job** jeden Sonntag 03:00 Uhr
### 27.02.2026 — ESP32-Serie Teil 1 veröffentlicht
- Artikel "Vier Heizungen, ein Pufferspeicher" live (Post 1209, Arakava News)
- Seafile-Assets lokal gespeichert: `/root/homelab-brain/arakava-news/artikel/seafile-assets/`
- ESP32-Serie Prompt als Standard-Prompt gesetzt
### 27.02.2026 — Grundsystem
- DB-Cleanup, Scheduler Lock, Telegram HTML-Modus, Werbeartikel-Blacklist

View file

@ -17,9 +17,9 @@ services:
wordpress:
image: wordpress:latest
container_name: redax-wordpress
restart: unless-stopped
ports:
- "80:80"
restart: unless-stopped
depends_on:
- db
environment:

View file

@ -10,7 +10,7 @@ import database as db
import logger as flog
import openrouter
import rss_fetcher
from wordpress import WordPressClient
from wordpress import WordPressClient, WordPressMirrorClient
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
@ -33,12 +33,21 @@ def job_publish_due():
articles = db.get_due_articles()
for art in articles:
try:
wp = WordPressClient()
mirror_client = WordPressMirrorClient()
# Bild zuerst auf Primary hochladen
primary_client = WordPressClient()
media_id = None
if art.get('featured_image_url'):
media_id = wp.upload_media(art['featured_image_url'])
media_id = primary_client.upload_media(art['featured_image_url'])
result = wp.create_post(
# Manuell deaktivierte Targets aus DB laden
for t in mirror_client.targets:
if db.get_setting(f'target_disabled_{t["name"]}', '0') == '1':
t['enabled'] = False
# Auf Primary + alle aktiven Mirrors veröffentlichen
results = mirror_client.publish_to_all(
title=art['title'] or 'Ohne Titel',
content=art['content'] or '',
status='publish',
@ -48,17 +57,32 @@ def job_publish_due():
seo_description=art.get('seo_description'),
focus_keyword=art.get('focus_keyword'),
)
primary_result = results.get('primary')
if primary_result:
db.update_article(art['id'], {
'status': 'published',
'wp_post_id': result['id'],
'wp_url': result['url'],
'wp_post_id': primary_result['id'],
'wp_url': primary_result['url'],
'published_at': datetime.utcnow().isoformat(),
})
db.save_post_history(art['id'], result['id'], result['url'])
flog.info('article_published', article_id=art['id'], wp_url=result['url'])
db.save_post_history(art['id'], primary_result['id'], primary_result['url'])
flog.info('article_published', article_id=art['id'], wp_url=primary_result['url'])
if art.get('send_to_telegram') and art.get('article_type') == 'ki':
_send_telegram_teaser(art, result['url'])
_send_telegram_teaser(art, primary_result['url'])
# Mirror-Ergebnisse speichern
for m in results.get('mirrors', []):
db.save_mirror_post(
article_id=art['id'],
mirror_name=m['name'],
mirror_label=m['label'],
mirror_wp_id=m.get('id'),
mirror_url=m.get('url'),
status='ok' if not m.get('error') else 'error',
error=m.get('error'),
)
except Exception as e:
flog.error('publish_failed', article_id=art['id'], error=str(e))
@ -193,6 +217,39 @@ def index():
queue_count = len(db.get_feed_queue(status='new'))
prompts = db.get_prompts()
# Targets für serverseitiges Rendering
wp_targets = []
try:
mirror_client = WordPressMirrorClient()
for t in mirror_client.targets:
disabled = db.get_setting(f'target_disabled_{t["name"]}', '0') == '1'
if t['primary']:
admin_pw = os.environ.get('WP_ADMIN_PASSWORD', '')
admin_direct = os.environ.get('WP_ADMIN_DIRECT_URL', t['url'].rstrip('/'))
else:
idx = t['name'].replace('mirror_', '')
suffix = '' if idx == '1' else idx
admin_pw = os.environ.get(f'WP_MIRROR{suffix}_ADMIN_PASSWORD', '')
admin_direct = os.environ.get(f'WP_MIRROR{suffix}_ADMIN_DIRECT_URL', t['url'].rstrip('/'))
wp_targets.append({
'name': t['name'],
'label': t['label'],
'url': t['url'],
'admin_url': admin_direct + '/wp-admin',
'login_url': admin_direct + '/wp-login.php',
'username': t['username'],
'admin_pw': admin_pw,
'admin_direct': admin_direct,
'primary': t['primary'],
'enabled': not disabled,
})
except Exception:
pass
undated_drafts = db.get_articles(limit=20, status='draft')
undated_drafts = [a for a in undated_drafts if not a.get('post_date')]
return render_template('index.html',
today=today,
plan_days=plan_days,
@ -201,7 +258,11 @@ def index():
last_published=last_published,
feeds=feeds,
queue_count=queue_count,
prompts=prompts)
prompts=prompts,
wp_url=os.getenv('WP_URL', '').rstrip('/'),
wp_admin_direct=os.getenv('WP_ADMIN_DIRECT_URL', os.getenv('WP_URL', '')).rstrip('/'),
wp_targets=wp_targets,
undated_drafts=undated_drafts)
# ── API: Artikel ──────────────────────────────────────────────────────────────
@ -245,12 +306,44 @@ def api_generate():
def api_save_article():
data = request.json
article_id = data.get('id')
wp_post_id = data.get('wp_post_id')
wp_preview_url = None
if article_id:
db.update_article(article_id, data)
else:
article_id = db.create_article({**data, 'article_type': 'ki', 'status': 'draft'})
flog.info('article_saved', article_id=article_id)
return jsonify({'success': True, 'id': article_id})
# Als WP-Draft pushen (neu oder aktualisieren)
try:
wp = WordPressClient()
art = db.get_article(article_id)
if wp_post_id:
# Bereits in WP vorhanden — aktualisieren
result = wp.update_post(
wp_post_id,
title=art.get('title') or 'Ohne Titel',
content=art.get('content') or '',
status='draft',
)
else:
# Neu als Draft anlegen
result = wp.create_post(
title=art.get('title') or 'Ohne Titel',
content=art.get('content') or '',
status='draft',
category_ids=[art['category_id']] if art.get('category_id') else [],
)
wp_post_id = result['id']
db.update_article(article_id, {'wp_post_id': wp_post_id})
wp_base = os.getenv('WP_URL', '').rstrip('/')
wp_preview_url = f"{wp_base}/?p={wp_post_id}&preview=true"
flog.info('article_saved_as_draft', article_id=article_id, wp_post_id=wp_post_id)
except Exception as e:
flog.warn('draft_push_failed', article_id=article_id, error=str(e))
return jsonify({'success': True, 'id': article_id, 'wp_post_id': wp_post_id, 'wp_preview_url': wp_preview_url})
@app.route('/api/article/schedule', methods=['POST'])
@ -323,6 +416,47 @@ def api_wp_categories():
return jsonify({'error': str(e)}), 500
@app.route('/api/wp/targets')
def api_wp_targets():
"""Gibt alle konfigurierten WordPress-Targets zurück (inkl. manuell deaktivierter)."""
mirror_client = WordPressMirrorClient()
targets_info = []
for t in mirror_client.targets:
# Manuelle Deaktivierung aus DB-Settings prüfen
disabled = db.get_setting(f'target_disabled_{t["name"]}', '0') == '1'
enabled = not disabled
client = mirror_client._client_for(t)
reachable = client.is_reachable() if enabled else False
targets_info.append({
'name': t['name'],
'label': t['label'],
'url': t['url'],
'primary': t['primary'],
'enabled': enabled,
'reachable': reachable,
})
return jsonify(targets_info)
@app.route('/targets/toggle', methods=['POST'])
def toggle_target():
name = request.form.get('name')
if not name:
return redirect(url_for('index'))
mirror_client = WordPressMirrorClient()
target = next((t for t in mirror_client.targets if t['name'] == name), None)
if target and not target['primary']:
current = db.get_setting(f'target_disabled_{name}', '0')
db.set_setting(f'target_disabled_{name}', '0' if current == '1' else '1')
return redirect(url_for('index'))
@app.route('/api/article/<int:article_id>/mirrors')
def api_article_mirrors(article_id):
mirrors = db.get_mirror_posts(article_id)
return jsonify(mirrors)
# ── API: Feeds ────────────────────────────────────────────────────────────────
@app.route('/feeds')

View file

@ -98,6 +98,19 @@ def init_db():
tg_message_id INTEGER,
posted_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS mirror_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id),
mirror_name TEXT NOT NULL,
mirror_label TEXT,
mirror_wp_id INTEGER,
mirror_url TEXT,
status TEXT DEFAULT 'pending',
error TEXT,
posted_at TEXT DEFAULT (datetime('now')),
UNIQUE(article_id, mirror_name)
);
""")
# Seed default prompt
@ -404,3 +417,33 @@ def set_setting(key, value):
conn.execute("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)", (key, value))
conn.commit()
conn.close()
# ── Mirror Posts ───────────────────────────────────────────────────────────────
def save_mirror_post(article_id: int, mirror_name: str, mirror_label: str,
mirror_wp_id: int = None, mirror_url: str = None,
status: str = 'ok', error: str = None):
conn = get_conn()
conn.execute("""
INSERT INTO mirror_posts (article_id, mirror_name, mirror_label, mirror_wp_id, mirror_url, status, error)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(article_id, mirror_name) DO UPDATE SET
mirror_wp_id=excluded.mirror_wp_id,
mirror_url=excluded.mirror_url,
status=excluded.status,
error=excluded.error,
posted_at=datetime('now')
""", (article_id, mirror_name, mirror_label, mirror_wp_id, mirror_url, status, error))
conn.commit()
conn.close()
def get_mirror_posts(article_id: int) -> list:
conn = get_conn()
rows = conn.execute(
"SELECT * FROM mirror_posts WHERE article_id=? ORDER BY mirror_name",
(article_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]

View file

@ -70,3 +70,28 @@ async def get_balance() -> dict:
def get_balance_sync() -> dict:
return asyncio.run(get_balance())
def generate(system_prompt: str, user_message: str) -> str:
import asyncio, aiohttp as _aiohttp
async def _gen():
payload = {
"model": DEFAULT_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
],
"max_tokens": 2500,
"temperature": 0.8
}
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
async with _aiohttp.ClientSession() as session:
async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
data = await resp.json()
if resp.status != 200:
raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
return data["choices"][0]["message"]["content"].strip()
return asyncio.run(_gen())

View file

@ -5,7 +5,7 @@
<div class="max-w-7xl mx-auto px-6 py-6">
<!-- Status-Bar -->
<div class="flex items-center gap-4 mb-6 text-xs text-slate-500">
<div class="flex items-center gap-4 mb-4 text-xs text-slate-500 flex-wrap">
{% if last_published %}
<span>Letzter Post: <span class="text-slate-300">{{ last_published.wp_url[:50] if last_published.wp_url else last_published.title[:40] }}</span> — {{ last_published.published_at[:16] if last_published.published_at else '' }}</span>
{% endif %}
@ -14,6 +14,55 @@
{% endif %}
</div>
<!-- WordPress-Targets -->
<div class="mb-6">
<div class="text-xs text-slate-500 mb-2">📡 Publish-Ziele</div>
<div class="flex flex-wrap gap-3">
{% for t in wp_targets %}
<div style="background:#1e293b;border:1px solid #334155;border-radius:8px;padding:8px 12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<!-- Checkbox (Primary gesperrt) -->
{% if t.primary %}
<label style="cursor:default;color:#93c5fd;font-size:0.75rem;white-space:nowrap">
<input type="checkbox" checked disabled style="accent-color:#3b82f6"> {{ t.label }}
<span style="opacity:0.5;font-size:0.65rem">(Primary)</span>
</label>
{% else %}
<form method="POST" action="/targets/toggle" style="display:inline;margin:0">
<input type="hidden" name="name" value="{{ t.name }}">
<label style="cursor:pointer;color:{% if t.enabled %}#4ade80{% else %}#64748b{% endif %};font-size:0.75rem;white-space:nowrap">
<input type="checkbox" {% if t.enabled %}checked{% endif %}
onchange="this.form.submit()"
style="accent-color:#22c55e;cursor:pointer"> {{ t.label }}
</label>
</form>
{% endif %}
<!-- Trennlinie -->
<span style="color:#334155;font-size:0.8rem">|</span>
<!-- Website-Button -->
<a href="{{ t.url }}" target="_blank"
style="font-size:0.72rem;padding:3px 10px;border-radius:5px;background:#0f172a;
border:1px solid #334155;color:#94a3b8;text-decoration:none;white-space:nowrap"
title="{{ t.url }}">🌐 Website</a>
<!-- Admin-Button: direkte URL (bypass Cloudflare WAF) -->
<a href="{{ t.admin_url }}" target="_blank"
style="font-size:0.72rem;padding:3px 10px;border-radius:5px;background:#0f172a;
border:1px solid #334155;color:#94a3b8;text-decoration:none;white-space:nowrap"
title="Login: {{ t.username }}">⚙️ WP-Admin</a>
<!-- Login-Info -->
<span style="font-size:0.65rem;color:#475569;white-space:nowrap">
{{ t.username }} / {{ t.admin_pw }}
</span>
</div>
{% endfor %}
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- ═══ STUDIO (links, 2/3) ═══ -->
@ -59,6 +108,8 @@
style="min-height: 14rem; max-height: 24rem; font-family: Georgia, serif; line-height: 1.8;">
<span class="text-slate-400 italic text-xs">Vorschau erscheint beim Tippen...</span>
</div>
<div id="wp-draft-link" class="hidden"></div>
<div id="mirror-status-box" class="hidden mt-2 text-xs space-y-1"></div>
</div>
</div>
@ -133,55 +184,142 @@
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
<div class="space-y-4">
<div class="card p-5">
<h2 class="text-base font-semibold text-white mb-4">📅 Redaktionsplan — 7 Tage</h2>
<div class="space-y-1">
{% set status_icons = {'draft':'📝','scheduled':'🗓️','published':'📤'} %}
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
{% set draft_arts = [] %}
{% for d in plan_days %}
{% for a in plan_articles.get(d, []) %}
{% if a.status == 'draft' %}{% set _ = draft_arts.append(a) %}{% endif %}
{% endfor %}
{% endfor %}
{% set undated_drafts = undated_drafts if undated_drafts is defined else [] %}
{% if undated_drafts %}
<div class="card p-4">
<h2 class="text-sm font-semibold text-slate-300 mb-3">📝 Entwürfe ohne Datum</h2>
<div class="space-y-1">
{% for art in undated_drafts %}
<div class="flex items-center gap-2 px-3 py-2 rounded-lg border border-slate-700/50 bg-slate-800/30 cursor-pointer hover:border-slate-500 transition"
onclick="loadArticle({{ art.id }})">
<span class="text-xs">🤖</span>
<span class="text-xs text-slate-300 flex-1 truncate">{{ (art.title or 'Kein Titel')[:55] }}</span>
<span class="text-xs text-slate-500">Entwurf</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="card p-4">
<h2 class="text-base font-semibold text-white mb-3">📅 Redaktionsplan — 7 Tage</h2>
<p class="text-xs text-slate-600 mb-3">Artikel per Drag &amp; Drop auf einen anderen Tag ziehen zum Umplanen.</p>
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
{% for d in plan_days %}
{% set arts = plan_articles.get(d, []) %}
{% set is_today = (d == today) %}
<div class="flex items-center gap-2 pt-2 pb-0.5 px-1 {% if is_today %}text-blue-400{% else %}text-slate-500{% endif %}">
<span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
{% if is_today %}<span class="text-xs bg-blue-900/40 border border-blue-800 text-blue-400 px-2 py-0.5 rounded-full">Heute</span>{% endif %}
{% if not arts %}<span class="text-xs text-slate-700 italic">— leer</span>{% endif %}
<!-- Drop-Zone für jeden Tag -->
<div class="drop-zone mb-2" data-date="{{ d }}"
ondragover="event.preventDefault(); this.style.background='rgba(59,130,246,0.08)'"
ondragleave="this.style.background=''"
ondrop="onDrop(event, '{{ d }}')">
<div style="display:flex;align-items:center;gap:6px;padding:4px 4px 2px 4px">
<span style="font-size:0.72rem;font-weight:700;color:{% if is_today %}#60a5fa{% else %}#475569{% endif %}">
{{ d[8:] }}.{{ d[5:7] }}.
</span>
{% if is_today %}
<span style="font-size:0.65rem;background:#1e3a5f;border:1px solid #3b82f6;color:#60a5fa;padding:1px 7px;border-radius:999px">Heute</span>
{% endif %}
{% if not arts %}
<span style="font-size:0.68rem;color:#334155;font-style:italic">— leer</span>
{% endif %}
</div>
{% for art in arts %}
<div class="rounded-lg border border-slate-700/50 hover:border-slate-600 bg-slate-800/30 transition" id="plan-row-{{ art.id }}">
<div class="flex items-center gap-2 px-3 py-2 cursor-pointer" onclick="loadArticle({{ art.id }})">
<span class="text-sm">{{ type_icons.get(art.article_type, '📝') }}</span>
<span class="text-xs font-mono text-slate-500 w-12 shrink-0">{{ art.post_time }}</span>
<div class="flex-1 min-w-0">
<div class="text-xs text-slate-300 truncate">{{ (art.title or 'Kein Titel')[:55] }}</div>
</div>
<span class="text-xs {{ 'status-published' if art.status == 'published' else 'status-scheduled' if art.status == 'scheduled' else 'status-draft' }}">
{{ {'draft':'Entwurf','scheduled':'Geplant','published':'Live'}.get(art.status, art.status) }}
<!-- Artikel-Karte: draggable -->
<div id="plan-row-{{ art.id }}"
draggable="true"
ondragstart="onDragStart(event, {{ art.id }}, '{{ art.post_time }}')"
ondragend="onDragEnd(event)"
style="border-radius:7px;border:1px solid #334155;background:#1e293b;
margin-bottom:5px;cursor:grab;transition:opacity 0.15s">
<!-- Haupt-Zeile -->
<div style="display:flex;align-items:flex-start;gap:8px;padding:8px 10px"
onclick="loadArticle({{ art.id }})" style="cursor:pointer">
<span style="font-size:1rem;flex-shrink:0;margin-top:1px">{{ type_icons.get(art.article_type, '📝') }}</span>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;flex-wrap:wrap">
<span style="font-size:0.7rem;color:#64748b;font-family:monospace">{{ art.post_time }}</span>
<span style="font-size:0.72rem;padding:1px 7px;border-radius:999px;
{% if art.status == 'published' %}background:#14532d;color:#4ade80;border:1px solid #166534
{% elif art.status == 'scheduled' %}background:#1e3a5f;color:#60a5fa;border:1px solid #1d4ed8
{% else %}background:#292524;color:#a8a29e;border:1px solid #44403c{% endif %}">
{{ {'draft':'Entwurf','scheduled':'Geplant','published':'✓ Live'}.get(art.status, art.status) }}
</span>
{% if art.status != 'published' %}
<div class="flex gap-1 shrink-0" onclick="event.stopPropagation()">
<button onclick="openReschedule({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
class="text-slate-600 hover:text-yellow-400 text-xs px-1 py-0.5 rounded hover:bg-slate-700 transition" title="Umplanen">🔄</button>
<button onclick="deleteArticle({{ art.id }})"
class="text-slate-600 hover:text-red-400 text-xs px-1 py-0.5 rounded hover:bg-slate-700 transition" title="Löschen">🗑️</button>
</div>
<!-- Titel -->
<div style="font-size:0.8rem;color:#e2e8f0;font-weight:500;margin-bottom:4px;cursor:pointer"
onclick="loadArticle({{ art.id }})">
{{ (art.title or 'Kein Titel')[:70] }}
</div>
<!-- Snippet -->
{% if art.seo_description %}
<div style="font-size:0.7rem;color:#64748b;line-height:1.4">
{{ art.seo_description[:100] }}…
</div>
{% endif %}
<!-- Aktions-Buttons -->
<div style="display:flex;gap:5px;margin-top:6px;flex-wrap:wrap" onclick="event.stopPropagation()">
<button onclick="loadArticle({{ art.id }})"
style="font-size:0.67rem;padding:2px 8px;border-radius:4px;background:#1e293b;
border:1px solid #475569;color:#94a3b8;cursor:pointer">
✏️ Bearbeiten
</button>
{% if art.wp_post_id %}
<a href="{{ wp_admin_direct }}/wp-admin/post.php?post={{ art.wp_post_id }}&action=edit" target="_blank"
style="font-size:0.67rem;padding:2px 8px;border-radius:4px;background:#1e3a5f;
border:1px solid #3b82f6;color:#93c5fd;text-decoration:none">
🌐 WP-Editor
</a>
<a href="{{ wp_url }}/?p={{ art.wp_post_id }}&preview=true" target="_blank"
style="font-size:0.67rem;padding:2px 8px;border-radius:4px;background:#1e293b;
border:1px solid #475569;color:#94a3b8;text-decoration:none">
👁 Vorschau
</a>
{% endif %}
{% if art.status != 'published' %}
<button onclick="openReschedule({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
style="font-size:0.67rem;padding:2px 8px;border-radius:4px;background:#1e293b;
border:1px solid #475569;color:#94a3b8;cursor:pointer" title="Umplanen">
🗓 Umplanen
</button>
<button onclick="deleteArticle({{ art.id }})"
style="font-size:0.67rem;padding:2px 8px;border-radius:4px;background:#1e293b;
border:1px solid #475569;color:#94a3b8;cursor:pointer" title="Löschen">
🗑
</button>
{% endif %}
</div>
</div>
</div>
<!-- Umplan-Panel -->
<div id="rs-panel-{{ art.id }}" class="hidden border-t border-slate-700 px-3 py-2 bg-slate-900/60 rounded-b-lg">
<div class="flex gap-2 items-end flex-wrap">
<div id="rs-panel-{{ art.id }}" class="hidden"
style="border-top:1px solid #334155;padding:8px 10px;background:#0f172a;border-radius:0 0 7px 7px">
<div style="display:flex;gap:6px;align-items:flex-end;flex-wrap:wrap">
<input type="date" id="rs-date-{{ art.id }}" value="{{ d }}" class="text-xs py-1 px-2">
<input type="time" id="rs-time-{{ art.id }}" value="{{ art.post_time }}" step="900" class="text-xs py-1 px-2">
<button onclick="confirmReschedule({{ art.id }})" class="text-xs bg-yellow-700 hover:bg-yellow-600 text-white px-2 py-1 rounded"></button>
<button onclick="closeReschedule({{ art.id }})" class="text-xs text-slate-500 hover:text-white px-1"></button>
<button onclick="confirmReschedule({{ art.id }})"
style="font-size:0.72rem;background:#92400e;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer">✓ OK</button>
<button onclick="closeReschedule({{ art.id }})"
style="font-size:0.72rem;background:none;color:#64748b;border:none;cursor:pointer">✗</button>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
@ -194,6 +332,7 @@
{% block extra_js %}
let currentArticleId = null;
let currentWpPostId = null;
function updatePreview() {
const content = document.getElementById('article-content').value;
@ -260,10 +399,24 @@ function getArticleData() {
async function saveDraft() {
const r = await fetch('/api/article/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...getArticleData(), status: 'draft'})
body: JSON.stringify({...getArticleData(), id: currentArticleId, wp_post_id: currentWpPostId, status: 'draft'})
});
const d = await r.json();
if (d.success) { currentArticleId = d.id; showToast('💾 Entwurf gespeichert'); }
if (d.success) {
currentArticleId = d.id;
if (d.wp_post_id) currentWpPostId = d.wp_post_id;
// Vorschau-Link anzeigen
const linkBox = document.getElementById('wp-draft-link');
if (d.wp_preview_url && linkBox) {
linkBox.innerHTML = `<a href="${d.wp_preview_url}" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#f0f9ff;border:1px solid #bae6fd;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Entwurf in WordPress ansehen &rarr;</a>`;
linkBox.classList.remove('hidden');
}
showToast('💾 Entwurf gespeichert & nach WordPress gepusht');
}
}
async function publishNow() {
@ -332,6 +485,7 @@ async function loadArticle(id) {
const r = await fetch(`/api/article/${id}`);
const d = await r.json();
currentArticleId = id;
currentWpPostId = d.wp_post_id || null;
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
document.getElementById('source-input').value = d.source_url || '';
@ -340,10 +494,60 @@ async function loadArticle(id) {
document.getElementById('focus-keyword').value = d.focus_keyword || '';
document.getElementById('featured-image').value = d.featured_image_url || '';
if (d.category_id) document.getElementById('category-select').value = d.category_id;
// WP-Vorschau-Link wiederherstellen wenn vorhanden
const linkBox = document.getElementById('wp-draft-link');
if (d.wp_post_id && linkBox) {
const wpBase = '{{ wp_url }}';
linkBox.innerHTML = `<a href="${wpBase}/?p=${d.wp_post_id}&preview=true" target="_blank"
style="display:inline-flex;align-items:center;gap:6px;background:#f0f9ff;border:1px solid #bae6fd;
color:#0369a1;padding:6px 12px;border-radius:6px;font-size:0.8em;text-decoration:none;margin-top:8px">
&#128065; Entwurf in WordPress ansehen &rarr;</a>`;
linkBox.classList.remove('hidden');
} else if (linkBox) {
linkBox.classList.add('hidden');
}
updatePreview();
loadMirrorStatus(id);
window.scrollTo({top: 0, behavior: 'smooth'});
}
// ── Drag & Drop ──
let dragId = null, dragTime = null;
function onDragStart(event, id, time) {
dragId = id;
dragTime = time;
event.dataTransfer.effectAllowed = 'move';
const el = document.getElementById(`plan-row-${id}`);
setTimeout(() => { if(el) el.style.opacity = '0.4'; }, 0);
}
function onDragEnd(event) {
if (dragId) {
const el = document.getElementById(`plan-row-${dragId}`);
if (el) el.style.opacity = '1';
}
document.querySelectorAll('.drop-zone').forEach(z => z.style.background = '');
}
async function onDrop(event, newDate) {
event.preventDefault();
document.querySelectorAll('.drop-zone').forEach(z => z.style.background = '');
if (!dragId) return;
const r = await fetch(`/api/article/${dragId}/reschedule`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({post_date: newDate, post_time: dragTime})
});
const d = await r.json();
if (d.success) {
showToast(`📅 Verschoben auf ${newDate}`);
setTimeout(() => location.reload(), 800);
} else {
showToast('❌ ' + (d.error || 'Fehler'));
}
dragId = null;
}
// ── Board: Umplanen ──
function openReschedule(id, date, time) {
document.querySelectorAll('[id^="rs-panel-"]').forEach(el => el.classList.add('hidden'));
@ -370,7 +574,27 @@ async function deleteArticle(id) {
if (d.success) { showToast('🗑️ Gelöscht'); setTimeout(() => location.reload(), 1000); }
}
// Datum-Vorauswahl
// ── Mirror-Status nach Publish anzeigen ──
async function loadMirrorStatus(articleId) {
try {
const r = await fetch(`/api/article/${articleId}/mirrors`);
const mirrors = await r.json();
const box = document.getElementById('mirror-status-box');
if (!mirrors.length) { box.classList.add('hidden'); return; }
box.innerHTML = '<div class="text-slate-500 mb-1">📡 Mirror-Status:</div>';
for (const m of mirrors) {
const ok = m.status === 'ok';
box.innerHTML += `<div class="flex items-center gap-2 ${ok ? 'text-green-400' : 'text-red-400'}">
${ok ? '✅' : '❌'} ${m.mirror_label}
${ok && m.mirror_url ? `<a href="${m.mirror_url}" target="_blank" class="underline text-blue-400">&rarr; ansehen</a>` : ''}
${!ok && m.error ? `<span class="text-slate-500">(${m.error})</span>` : ''}
</div>`;
}
box.classList.remove('hidden');
} catch(e) {}
}
document.addEventListener('DOMContentLoaded', () => {
const today = new Date().toISOString().split('T')[0];
document.getElementById('schedule-date').value = today;

View file

@ -100,7 +100,6 @@ class WordPressClient:
if featured_media_id:
data['featured_media'] = featured_media_id
# Yoast SEO meta fields
if any([seo_title, seo_description, focus_keyword]):
data['meta'] = {}
if seo_title:
@ -119,3 +118,146 @@ class WordPressClient:
def get_post(self, wp_post_id: int) -> dict:
return self._get(f'posts/{wp_post_id}')
def post_exists(self, title: str) -> bool:
"""Prüft ob ein Artikel mit diesem Titel bereits existiert (Duplikat-Schutz)."""
try:
results = self._get('posts', {'search': title[:50], 'per_page': 5, 'status': 'any'})
for p in results:
if p.get('title', {}).get('rendered', '').strip() == title.strip():
return True
return False
except Exception:
return False
class WordPressMirrorClient:
"""Verwaltet mehrere WordPress-Targets (Primary + N Mirrors)."""
def __init__(self):
self.targets = []
# Primary (aus WP_URL)
primary_url = os.environ.get('WP_URL', '').rstrip('/')
if primary_url:
self.targets.append({
'name': 'primary',
'label': primary_url.replace('https://', '').replace('http://', ''),
'url': primary_url,
'username': os.environ.get('WP_USERNAME', 'admin'),
'app_password': os.environ.get('WP_APP_PASSWORD', ''),
'primary': True,
'enabled': True,
})
# Mirror 1 (aus WP_MIRROR_URL)
mirror_url = os.environ.get('WP_MIRROR_URL', '').rstrip('/')
mirror_enabled = os.environ.get('WP_MIRROR_ENABLED', 'false').lower() == 'true'
if mirror_url and mirror_enabled:
self.targets.append({
'name': 'mirror_1',
'label': mirror_url.replace('https://', '').replace('http://', ''),
'url': mirror_url,
'username': os.environ.get('WP_MIRROR_USERNAME', 'admin'),
'app_password': os.environ.get('WP_MIRROR_APP_PASSWORD', ''),
'primary': False,
'enabled': True,
})
# Erweiterbar: Mirror 2, 3, ... aus WP_MIRROR2_URL usw.
for i in range(2, 10):
m_url = os.environ.get(f'WP_MIRROR{i}_URL', '').rstrip('/')
m_enabled = os.environ.get(f'WP_MIRROR{i}_ENABLED', 'false').lower() == 'true'
if m_url and m_enabled:
self.targets.append({
'name': f'mirror_{i}',
'label': m_url.replace('https://', '').replace('http://',''),
'url': m_url,
'username': os.environ.get(f'WP_MIRROR{i}_USERNAME', 'admin'),
'app_password': os.environ.get(f'WP_MIRROR{i}_APP_PASSWORD', ''),
'primary': False,
'enabled': True,
})
def _client_for(self, target: dict) -> WordPressClient:
"""Erstellt einen temporären WordPressClient für ein Target."""
c = WordPressClient.__new__(WordPressClient)
c.base_url = target['url']
c.api_url = f"{target['url']}/wp-json/wp/v2"
c.username = target['username']
c.app_password = target['app_password']
c.auth = HTTPBasicAuth(c.username, c.app_password)
c._get = c.__class__._get.__get__(c)
c._post = c.__class__._post.__get__(c)
c._put = c.__class__._put.__get__(c)
return c
def publish_to_all(self, title: str, content: str, status: str = 'publish',
scheduled_at: str = None, category_ids: list = None,
featured_media_id: int = None, seo_title: str = None,
seo_description: str = None, focus_keyword: str = None) -> dict:
"""
Veröffentlicht auf allen aktiven Targets.
Returns: {
'primary': {'id': int, 'url': str},
'mirrors': [{'name': str, 'label': str, 'id': int, 'url': str, 'error': str|None}]
}
"""
results = {'primary': None, 'mirrors': []}
for target in self.targets:
if not target['enabled']:
continue
client = self._client_for(target)
try:
# Duplikat-Prüfung auf Mirror (nicht auf Primary)
if not target['primary']:
if client.post_exists(title):
flog.info('mirror_skip_duplicate', target=target['name'], title=title[:50])
results['mirrors'].append({
'name': target['name'],
'label': target['label'],
'id': None,
'url': None,
'error': 'Duplikat übersprungen',
})
continue
result = client.create_post(
title=title, content=content, status=status,
scheduled_at=scheduled_at, category_ids=category_ids,
featured_media_id=featured_media_id,
seo_title=seo_title, seo_description=seo_description,
focus_keyword=focus_keyword,
)
flog.info('published_to_target', target=target['name'], wp_id=result['id'], url=result['url'])
if target['primary']:
results['primary'] = result
else:
results['mirrors'].append({
'name': target['name'],
'label': target['label'],
'id': result['id'],
'url': result['url'],
'error': None,
})
except Exception as e:
flog.error('publish_target_failed', target=target['name'], error=str(e))
if target['primary']:
raise # Primary-Fehler weitergeben
else:
results['mirrors'].append({
'name': target['name'],
'label': target['label'],
'id': None,
'url': None,
'error': str(e),
})
return results
def get_active_targets(self) -> list:
return [t for t in self.targets if t['enabled']]