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:
parent
0acd451ed4
commit
82eaa1e4bc
11 changed files with 965 additions and 342 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
# Arakava News — Live State
|
# Arakava News — Live State
|
||||||
> Auto-generiert: 2026-02-28 13:15
|
> Auto-generiert: 2026-02-27 15:15
|
||||||
|
|
||||||
## Service Status
|
## Service Status
|
||||||
| Service | CT | Status |
|
| Service | CT | Status |
|
||||||
|
|
@ -8,11 +8,11 @@
|
||||||
| WordPress Docker | 101 | running |
|
| WordPress Docker | 101 | running |
|
||||||
|
|
||||||
## Letzte Feed-Aktivität (Top 5)
|
## Letzte Feed-Aktivität (Top 5)
|
||||||
Heise Security: 2026-02-28 10:48:09
|
Junge Freiheit: 2026-02-27 14:00:11
|
||||||
Heise Online: 2026-02-28 10:48:09
|
Dr. Bines Substack: 2026-02-27 14:00:00
|
||||||
Golem.de: 2026-02-28 10:48:06
|
Heise Online: 2026-02-27 13:48:13
|
||||||
Corona-Transition: 2026-02-28 10:48:03
|
Rubikon.news: 2026-02-27 13:47:56
|
||||||
Rubikon.news: 2026-02-28 10:47:55
|
Tichys Einblick: 2026-02-27 13:30:10
|
||||||
|
|
||||||
## Fehler (letzte 24h)
|
## Fehler (letzte 24h)
|
||||||
- Fehler gesamt: 0
|
- Fehler gesamt: 0
|
||||||
|
|
@ -20,13 +20,18 @@
|
||||||
- Letzter Fehler:
|
- Letzter Fehler:
|
||||||
|
|
||||||
## OpenRouter Guthaben
|
## OpenRouter Guthaben
|
||||||
$7.76 verbleibend
|
$8.88 verbleibend
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
- Blog: https://arakava-news-2.orbitalo.net
|
- Blog: https://arakava-news-2.orbitalo.net
|
||||||
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS)
|
- Admin: https://arakava-news-2.orbitalo.net/wp-admin (admin / eJIyhW0p5PFacjvvKGufKeXS) — ⚠️ nur via Tailscale erreichbar!
|
||||||
- RSS Manager: http://100.113.244.101:8080 (admin / astral66)
|
- 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)
|
- 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
|
## Container
|
||||||
| CT | Dienst | Tailscale |
|
| CT | Dienst | Tailscale |
|
||||||
|
|
@ -54,10 +59,62 @@ $7.76 verbleibend
|
||||||
| 16 | Antispiegel | 08:30/14:30/20:30 |
|
| 16 | Antispiegel | 08:30/14:30/20:30 |
|
||||||
| 17 | Riehle News | 09:00 Uhr |
|
| 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/)
|
## Code (CT 109: /opt/rss-manager/)
|
||||||
poster.py, scheduler.py, app.py, db.py
|
poster.py, scheduler.py, app.py, db.py
|
||||||
|
|
||||||
## Änderungshistorie
|
## Ä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: Scheduler Lock gegen Doppelstarts
|
||||||
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
|
- 24.02.2026: Telegram auf HTML-Modus (Sonderzeichen-Fix)
|
||||||
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.)
|
- 24.02.2026: Werbeartikel-Blacklist (Anzeige:, Sponsored, etc.)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
**Zweck:** KI-gestützter Telegram-Kanal-Poster für die tägliche Reihe "Fünf vor Acht"
|
**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
|
**Posting-Zeit:** 19:55 Uhr (Europe/Berlin) — pro Artikel individuell konfigurierbar
|
||||||
**Kanal:** Privater Kanal (`-1001285446620`)
|
**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)
|
- [ ] Redakteure-Verwaltung UI in settings.html (API vorhanden)
|
||||||
- [ ] Kanal-ID in Settings-UI editierbar (API vorhanden)
|
- [ ] Kanal-ID in Settings-UI editierbar (API vorhanden)
|
||||||
- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop)
|
- [ ] Media-Einbettung im Editor (Video/Link Drag & Drop)
|
||||||
- [ ] Letzter-Post Zeitstempel im Dashboard anzeigen
|
- [ ] 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
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# STATE: Redax-WP
|
# STATE: Redax-WP
|
||||||
**Stand: 27.02.2026**
|
**Stand: 28.02.2026**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status
|
## 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 | Redax-WP Dashboard | pve-hetzner | 10.10.10.113 | ✅ Läuft |
|
||||||
| 113 | WordPress (DeutschlandBlog) | 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
|
## Zugangsdaten
|
||||||
- Dashboard: `admin` / `astral66`
|
|
||||||
- WP-Admin: `admin` / `Redax2026!`
|
| Dienst | URL | Login |
|
||||||
- WP Application Password: `YJ7L4CFAxDPszGLXpamJmzl6`
|
|--------|-----|-------|
|
||||||
|
| 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:
|
Docker Container:
|
||||||
redax-db MySQL 8.0
|
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)
|
redax-web Flask/Gunicorn Dashboard (Port 8080)
|
||||||
cloudflared Tunnel für redax.orbitalo.net
|
cloudflared Tunnel für redax.orbitalo.net
|
||||||
cloudflared-wp Tunnel für deutschlandblog.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 |
|
| Feature | Beschreibung |
|
||||||
|---------|--------|
|
|---------|-------------|
|
||||||
| WordPress Version | 6.9.1 |
|
| Artikel-Studio | KI-Generierung via OpenRouter (Ton wählbar) |
|
||||||
| Domain | https://deutschlandblog.orbitalo.net |
|
| WP-Entwurf | Artikel direkt als Draft auf Primary pushen + Vorschau-Link |
|
||||||
| Theme | Blocksy |
|
| Redaktionsplan | 7-Tage-Kalender mit Status, Umplanen, Löschen |
|
||||||
| Yoast SEO | installiert & aktiv |
|
| Multi-Publish | Beim Veröffentlichen: Primary + alle aktiven Mirrors |
|
||||||
| Kategorien | Rubrik 1–4 (je 1 Platzhalterbeitrag) |
|
| Publish-Ziele | Checkboxen zum Ein-/Ausschalten pro Mirror + Links zu Website & WP-Admin + Zugangsdaten |
|
||||||
| Navigation | Hauptmenü → Header Menu 1 + Mobile |
|
| Mirror-Status | Pro Artikel: welche Sites wurden bespielt (✅/❌) |
|
||||||
| Permalink-Struktur | `/%postname%/` |
|
| RSS-Queue | Feed-Artikel verwalten, KI-Rewrite, Auto-Publish |
|
||||||
|
| Duplikat-Schutz | Mirror überspringt Artikel die bereits vorhanden sind |
|
||||||
---
|
|
||||||
|
|
||||||
## .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 1–4)
|
|
||||||
- [ ] Redax-WP Dashboard: KI-Artikel generieren & veröffentlichen testen
|
|
||||||
- [ ] RSS Feeds konfigurieren
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Changelog
|
## 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
|
### 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
|
- Blocksy Theme + Companion Plugin installiert
|
||||||
- Yoast SEO installiert
|
- Yoast SEO installiert
|
||||||
- 4 Kategorien angelegt (Rubrik 1–4)
|
|
||||||
- Hauptmenü mit allen Rubriken im Header
|
|
||||||
- Cloudflare Tunnel für WordPress eingerichtet
|
- Cloudflare Tunnel für WordPress eingerichtet
|
||||||
- WP Application Password generiert, in .env eingetragen
|
- WP Application Passwords generiert (beide Sites)
|
||||||
- Rohzustand der Website abgeschlossen
|
- WP-Draft Push: Artikel als Entwurf in WP speichern + Vorschau-Link im Dashboard
|
||||||
|
- Redakteur mit Arakava News (CT 101) verbunden
|
||||||
|
|
||||||
### 26.02.2026
|
### 26.02.2026
|
||||||
- CT 113 auf pve-hetzner erstellt
|
- 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
|
- Dashboard unter redax.orbitalo.net erreichbar
|
||||||
- Cloudflare Tunnel für Dashboard eingerichtet
|
- Login-Seite eingerichtet
|
||||||
- Login-Seite Fix (inline CSS)
|
|
||||||
|
|
|
||||||
|
|
@ -1,211 +1,90 @@
|
||||||
# Redax-WP
|
# 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?
|
- **KI-Artikelgenerierung** (OpenRouter) mit automatischen SEO-Feldern
|
||||||
|
- **RSS-Feed-Import** mit konfigurierbarem Auto-Publish und optionalem KI-Rewrite
|
||||||
| Feature | Beschreibung |
|
- **Redaktionsplanung** mit Kalender, Zeitslots und direktem Umplanen
|
||||||
|---------|-------------|
|
- **WordPress-Veröffentlichung** via REST API (Publish / Entwurf / Einplanen)
|
||||||
| ✍️ KI-Artikel | Artikel per KI generieren (OpenRouter / GPT-4o, Claude, Mistral...) |
|
- **Telegram-Benachrichtigung** nach Veröffentlichung von KI-Artikeln
|
||||||
| 📰 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 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart
|
||||||
|
|
||||||
```bash
|
### 1. Repository klonen
|
||||||
# 1. Repository klonen
|
|
||||||
git clone https://github.com/Orbitalo/Redax-Wordpress.git mein-blog
|
|
||||||
cd mein-blog
|
|
||||||
|
|
||||||
# 2. Setup starten (interaktiv)
|
```bash
|
||||||
chmod +x setup.sh
|
git clone https://git.orbitalo.net/orbitalo/redax-wp.git
|
||||||
./setup.sh
|
cd redax-wp
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Setup-Skript erledigt automatisch:
|
### 2. Konfiguration
|
||||||
- `.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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
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 |
|
| Variable | Beschreibung |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `DASHBOARD_USER` | Login für das Redax-WP Dashboard |
|
| `DASHBOARD_USER` | Login-Name für das Dashboard |
|
||||||
| `DASHBOARD_PASSWORD` | Passwort für das Dashboard |
|
| `DASHBOARD_PASSWORD` | Login-Passwort für das Dashboard |
|
||||||
| `WP_URL` | Öffentliche URL des WordPress-Blogs |
|
| `WP_URL` | WordPress-URL (intern: `http://wordpress`) |
|
||||||
| `WP_USERNAME` | WordPress Admin-Benutzername |
|
| `WP_USERNAME` | WordPress-Benutzername |
|
||||||
| `WP_APP_PASSWORD` | WordPress Application Password (auto via setup.sh) |
|
| `WP_APP_PASSWORD` | WordPress Application Password |
|
||||||
| `OPENROUTER_API_KEY` | API-Key für KI-Generierung ([openrouter.ai](https://openrouter.ai)) |
|
| `OPENROUTER_API_KEY` | API-Key von openrouter.ai |
|
||||||
| `MYSQL_ROOT_PASSWORD` | MySQL Root-Passwort |
|
| `TELEGRAM_BOT_TOKEN` | Telegram Bot-Token |
|
||||||
| `MYSQL_PASSWORD` | MySQL Benutzer-Passwort |
|
| `TELEGRAM_CHANNEL_ID` | Kanal für KI-Artikel Teaser |
|
||||||
|
|
||||||
### 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 |
|
|
||||||
| `TELEGRAM_REVIEWER_IDS` | Chat-IDs für Fehler-Alarm (kommagetrennt) |
|
| `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
|
### RSS-Artikel
|
||||||
make help # Alle Befehle anzeigen
|
1. Feed unter `/feeds` hinzufügen
|
||||||
make start # Stack starten
|
2. Modus wählen: Manuell / Auto-Publish / KI-Rewrite
|
||||||
make stop # Stack stoppen
|
3. Neue Artikel landen in Queue oder werden direkt veröffentlicht
|
||||||
make restart # Dashboard neustarten (nach .env-Änderung)
|
4. → Nur WordPress (kein Telegram)
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────┐
|
docker-compose.yml
|
||||||
│ Docker Stack │
|
├── web Flask Dashboard (:8080)
|
||||||
│ │
|
├── wordpress WordPress + Apache (:80 intern)
|
||||||
│ ┌────────────────┐ ┌─────────────────────────┐ │
|
└── db MySQL 8
|
||||||
│ │ 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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ö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
|
## Lizenz
|
||||||
|
|
||||||
MIT — frei verwendbar, anpassbar und weitergabe erlaubt.
|
MIT
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entwickelt von
|
|
||||||
|
|
||||||
[Orbitalo](https://github.com/Orbitalo) — Homelab & Automatisierungsprojekte
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# STATE: Redax-WP
|
# STATE: Redax-WP (Redakteur)
|
||||||
**Stand: 27.02.2026**
|
**Stand: 28.02.2026**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Status
|
## 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 |
|
| Dashboard | https://redax.orbitalo.net |
|
||||||
| Lokal | http://100.69.243.16:8080 |
|
| Lokal | http://100.69.243.16:8080 |
|
||||||
| Login | admin / astral66 |
|
| 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)
|
## Stack (CT 113)
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml
|
/opt/redax-wp/
|
||||||
├── redax-web Flask Dashboard (:8080)
|
├── docker-compose.yml
|
||||||
├── redax-wordpress WordPress + Apache (:80 intern)
|
├── src/
|
||||||
└── redax-db MySQL 8
|
│ ├── 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
|
- Kategorie + Tags aus WordPress live geladen
|
||||||
- Publish / Entwurf / Einplanen (15-Minuten-Slots)
|
- Publish / Entwurf / Einplanen (15-Minuten-Slots)
|
||||||
- Nach Publish → Telegram-Teaser an konfigurierten Kanal
|
- 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
|
### RSS-Feeds
|
||||||
- Beliebig viele Feeds konfigurierbar
|
- Beliebig viele Feeds konfigurierbar
|
||||||
|
|
@ -56,35 +95,37 @@ docker-compose.yml
|
||||||
- Werbeartikel-Blacklist (konfigurierbar pro Feed)
|
- Werbeartikel-Blacklist (konfigurierbar pro Feed)
|
||||||
- RSS-Artikel erscheinen nie auf Telegram
|
- RSS-Artikel erscheinen nie auf Telegram
|
||||||
|
|
||||||
### Redaktionsplan
|
|
||||||
- 7-Tage-Kalender mit KI + RSS gemeinsam
|
|
||||||
- Badge: 🤖 KI / 📡 RSS
|
|
||||||
- Umplanen + Löschen direkt im Board
|
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
- Nur KI-Artikel → Teaser-Post (Titel + SEO-Desc + WP-Link)
|
- Nur KI-Artikel → Teaser-Post (Titel + SEO-Desc + WP-Link)
|
||||||
- Morgen-Briefing täglich 10:00 Uhr
|
- Morgen-Briefing täglich 10:00 Uhr
|
||||||
- Fehler-Alarm bei WP-Publish-Fehler
|
- Fehler-Alarm bei WP-Publish-Fehler
|
||||||
|
|
||||||
### Weitere Features
|
### Weitere Features
|
||||||
- Prompt-Bibliothek (editierbar, Standard-Prompt wählbar)
|
|
||||||
- Post-History (alle veröffentlichten Artikel)
|
- Post-History (alle veröffentlichten Artikel)
|
||||||
- Einstellungen + WP-Verbindungstest
|
- Einstellungen + WP-Verbindungstest
|
||||||
- Hilfe-Seite (/hilfe)
|
- Hilfe-Seite (/hilfe)
|
||||||
- Tool-Switcher zu FünfVorAcht in der Nav
|
- Tool-Switcher zu FünfVorAcht in der Nav
|
||||||
- Strukturiertes JSON-Logging (/logs/redax.log)
|
- 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
|
**Problem:** Cloudflare WAF blockiert `/wp-login.php` auf Arakava News public domain.
|
||||||
2. **Yoast SEO Plugin** in WordPress installieren
|
|
||||||
3. **Application Password** in WP-Admin erstellen → in `.env` als `WP_APP_PASSWORD` eintragen
|
**Lösung:** socat-Proxy auf pve-hetzner + WordPress mu-plugin
|
||||||
4. **OpenRouter API Key** in `.env` eintragen
|
|
||||||
5. **Telegram Bot Token + Kanal-ID** in `.env` eintragen
|
```bash
|
||||||
6. Nach .env-Änderungen: `docker compose restart web`
|
# socat Service auf pve-hetzner (läuft als systemd)
|
||||||
7. **Cloudflare Tunnel** für `redax.orbitalo.net` einrichten (optional)
|
# /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 |
|
| Was | Pfad |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| App | /opt/redax-wp/ |
|
| App | /opt/redax-wp/ |
|
||||||
| Daten | /opt/redax-wp/data/ |
|
| Datenbank | /opt/redax-wp/data/redax.db |
|
||||||
| Datenbank | /opt/redax-wp/data/db/redax.db |
|
|
||||||
| WordPress-Dateien | /opt/redax-wp/data/wordpress/ |
|
| WordPress-Dateien | /opt/redax-wp/data/wordpress/ |
|
||||||
| MySQL-Daten | /opt/redax-wp/data/mysql/ |
|
| MySQL-Daten | /opt/redax-wp/data/mysql/ |
|
||||||
| Logs | /opt/redax-wp/logs/ |
|
| Logs | /opt/redax-wp/logs/ |
|
||||||
|
|
@ -102,21 +142,71 @@ docker-compose.yml
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offene Punkte
|
## Umgebungsvariablen (.env)
|
||||||
|
|
||||||
- [ ] WordPress-Ersteinrichtung + Yoast installieren
|
```env
|
||||||
- [ ] .env mit echten Credentials befüllen (OpenRouter, Telegram)
|
# Primäres WordPress
|
||||||
- [x] Cloudflare Tunnel → https://redax.orbitalo.net
|
WP_URL=https://arakava-news-2.orbitalo.net
|
||||||
- [ ] Erste Feeds hinzufügen
|
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
|
## Changelog
|
||||||
|
|
||||||
### 27.02.2026 — DB-Cleanup-Job + Datenbank-Hygiene
|
### 28.02.2026 — ESP32-Serie Teil 2 + Animiertes Hydraulikschema
|
||||||
- **Automatischer Cleanup:** Neuer Scheduler-Job läuft jeden Sonntag 03:00 Uhr
|
- **ESP32-Serie Teil 2** als WP-Entwurf erstellt (Post 1340 auf Arakava News)
|
||||||
- `feed_items` (published/rejected) älter als 60 Tage → automatisch gelöscht
|
- Titel: "70 Euro gegen Heizungschaos: Die Hardware für mein Smart-Home-Projekt"
|
||||||
- `feed_items` (new/unbearbeitet) älter als 30 Tage → automatisch gelöscht
|
- Status: Entwurf — wartet auf echte Fotos (Hardware liegt in DE, Einbau ab April)
|
||||||
- `post_history` älter als 90 Tage → automatisch gelöscht
|
- **Animiertes Fließschaltbild** (interaktives SVG-Hydraulikschema) in Teil 2 eingebaut
|
||||||
- VACUUM nach Cleanup → DB bleibt kompakt
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ services:
|
||||||
wordpress:
|
wordpress:
|
||||||
image: wordpress:latest
|
image: wordpress:latest
|
||||||
container_name: redax-wordpress
|
container_name: redax-wordpress
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import database as db
|
||||||
import logger as flog
|
import logger as flog
|
||||||
import openrouter
|
import openrouter
|
||||||
import rss_fetcher
|
import rss_fetcher
|
||||||
from wordpress import WordPressClient
|
from wordpress import WordPressClient, WordPressMirrorClient
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||||
|
|
@ -33,12 +33,21 @@ def job_publish_due():
|
||||||
articles = db.get_due_articles()
|
articles = db.get_due_articles()
|
||||||
for art in articles:
|
for art in articles:
|
||||||
try:
|
try:
|
||||||
wp = WordPressClient()
|
mirror_client = WordPressMirrorClient()
|
||||||
|
|
||||||
|
# Bild zuerst auf Primary hochladen
|
||||||
|
primary_client = WordPressClient()
|
||||||
media_id = None
|
media_id = None
|
||||||
if art.get('featured_image_url'):
|
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',
|
title=art['title'] or 'Ohne Titel',
|
||||||
content=art['content'] or '',
|
content=art['content'] or '',
|
||||||
status='publish',
|
status='publish',
|
||||||
|
|
@ -48,17 +57,32 @@ def job_publish_due():
|
||||||
seo_description=art.get('seo_description'),
|
seo_description=art.get('seo_description'),
|
||||||
focus_keyword=art.get('focus_keyword'),
|
focus_keyword=art.get('focus_keyword'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
primary_result = results.get('primary')
|
||||||
|
if primary_result:
|
||||||
db.update_article(art['id'], {
|
db.update_article(art['id'], {
|
||||||
'status': 'published',
|
'status': 'published',
|
||||||
'wp_post_id': result['id'],
|
'wp_post_id': primary_result['id'],
|
||||||
'wp_url': result['url'],
|
'wp_url': primary_result['url'],
|
||||||
'published_at': datetime.utcnow().isoformat(),
|
'published_at': datetime.utcnow().isoformat(),
|
||||||
})
|
})
|
||||||
db.save_post_history(art['id'], result['id'], result['url'])
|
db.save_post_history(art['id'], primary_result['id'], primary_result['url'])
|
||||||
flog.info('article_published', article_id=art['id'], wp_url=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':
|
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:
|
except Exception as e:
|
||||||
flog.error('publish_failed', article_id=art['id'], error=str(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'))
|
queue_count = len(db.get_feed_queue(status='new'))
|
||||||
prompts = db.get_prompts()
|
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',
|
return render_template('index.html',
|
||||||
today=today,
|
today=today,
|
||||||
plan_days=plan_days,
|
plan_days=plan_days,
|
||||||
|
|
@ -201,7 +258,11 @@ def index():
|
||||||
last_published=last_published,
|
last_published=last_published,
|
||||||
feeds=feeds,
|
feeds=feeds,
|
||||||
queue_count=queue_count,
|
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 ──────────────────────────────────────────────────────────────
|
# ── API: Artikel ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -245,12 +306,44 @@ def api_generate():
|
||||||
def api_save_article():
|
def api_save_article():
|
||||||
data = request.json
|
data = request.json
|
||||||
article_id = data.get('id')
|
article_id = data.get('id')
|
||||||
|
wp_post_id = data.get('wp_post_id')
|
||||||
|
wp_preview_url = None
|
||||||
|
|
||||||
if article_id:
|
if article_id:
|
||||||
db.update_article(article_id, data)
|
db.update_article(article_id, data)
|
||||||
else:
|
else:
|
||||||
article_id = db.create_article({**data, 'article_type': 'ki', 'status': 'draft'})
|
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'])
|
@app.route('/api/article/schedule', methods=['POST'])
|
||||||
|
|
@ -323,6 +416,47 @@ def api_wp_categories():
|
||||||
return jsonify({'error': str(e)}), 500
|
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 ────────────────────────────────────────────────────────────────
|
# ── API: Feeds ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route('/feeds')
|
@app.route('/feeds')
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,19 @@ def init_db():
|
||||||
tg_message_id INTEGER,
|
tg_message_id INTEGER,
|
||||||
posted_at TEXT DEFAULT (datetime('now'))
|
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
|
# 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.execute("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)", (key, value))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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]
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,28 @@ async def get_balance() -> dict:
|
||||||
|
|
||||||
def get_balance_sync() -> dict:
|
def get_balance_sync() -> dict:
|
||||||
return asyncio.run(get_balance())
|
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())
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="max-w-7xl mx-auto px-6 py-6">
|
<div class="max-w-7xl mx-auto px-6 py-6">
|
||||||
|
|
||||||
<!-- Status-Bar -->
|
<!-- 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 %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
|
@ -14,6 +14,55 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
|
|
||||||
<!-- ═══ STUDIO (links, 2/3) ═══ -->
|
<!-- ═══ STUDIO (links, 2/3) ═══ -->
|
||||||
|
|
@ -59,6 +108,8 @@
|
||||||
style="min-height: 14rem; max-height: 24rem; font-family: Georgia, serif; line-height: 1.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>
|
<span class="text-slate-400 italic text-xs">Vorschau erscheint beim Tippen...</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -133,55 +184,142 @@
|
||||||
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
|
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
||||||
<div class="card p-5">
|
{% set draft_arts = [] %}
|
||||||
<h2 class="text-base font-semibold text-white mb-4">📅 Redaktionsplan — 7 Tage</h2>
|
{% for d in plan_days %}
|
||||||
<div class="space-y-1">
|
{% for a in plan_articles.get(d, []) %}
|
||||||
{% set status_icons = {'draft':'📝','scheduled':'🗓️','published':'📤'} %}
|
{% if a.status == 'draft' %}{% set _ = draft_arts.append(a) %}{% endif %}
|
||||||
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
|
{% 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 & Drop auf einen anderen Tag ziehen zum Umplanen.</p>
|
||||||
|
|
||||||
|
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
|
||||||
{% for d in plan_days %}
|
{% for d in plan_days %}
|
||||||
{% set arts = plan_articles.get(d, []) %}
|
{% set arts = plan_articles.get(d, []) %}
|
||||||
{% set is_today = (d == today) %}
|
{% 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 %}">
|
<!-- Drop-Zone für jeden Tag -->
|
||||||
<span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
|
<div class="drop-zone mb-2" data-date="{{ d }}"
|
||||||
{% 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 %}
|
ondragover="event.preventDefault(); this.style.background='rgba(59,130,246,0.08)'"
|
||||||
{% if not arts %}<span class="text-xs text-slate-700 italic">— leer</span>{% endif %}
|
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>
|
</div>
|
||||||
|
|
||||||
{% for art in arts %}
|
{% 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 }}">
|
<!-- Artikel-Karte: draggable -->
|
||||||
<div class="flex items-center gap-2 px-3 py-2 cursor-pointer" onclick="loadArticle({{ art.id }})">
|
<div id="plan-row-{{ art.id }}"
|
||||||
<span class="text-sm">{{ type_icons.get(art.article_type, '📝') }}</span>
|
draggable="true"
|
||||||
<span class="text-xs font-mono text-slate-500 w-12 shrink-0">{{ art.post_time }}</span>
|
ondragstart="onDragStart(event, {{ art.id }}, '{{ art.post_time }}')"
|
||||||
<div class="flex-1 min-w-0">
|
ondragend="onDragEnd(event)"
|
||||||
<div class="text-xs text-slate-300 truncate">{{ (art.title or 'Kein Titel')[:55] }}</div>
|
style="border-radius:7px;border:1px solid #334155;background:#1e293b;
|
||||||
</div>
|
margin-bottom:5px;cursor:grab;transition:opacity 0.15s">
|
||||||
<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) }}
|
<!-- 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>
|
</span>
|
||||||
{% if art.status != 'published' %}
|
</div>
|
||||||
<div class="flex gap-1 shrink-0" onclick="event.stopPropagation()">
|
<!-- Titel -->
|
||||||
<button onclick="openReschedule({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
|
<div style="font-size:0.8rem;color:#e2e8f0;font-weight:500;margin-bottom:4px;cursor:pointer"
|
||||||
class="text-slate-600 hover:text-yellow-400 text-xs px-1 py-0.5 rounded hover:bg-slate-700 transition" title="Umplanen">🔄</button>
|
onclick="loadArticle({{ art.id }})">
|
||||||
<button onclick="deleteArticle({{ art.id }})"
|
{{ (art.title or 'Kein Titel')[:70] }}
|
||||||
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>
|
||||||
|
<!-- Snippet -->
|
||||||
|
{% if art.seo_description %}
|
||||||
|
<div style="font-size:0.7rem;color:#64748b;line-height:1.4">
|
||||||
|
{{ art.seo_description[:100] }}…
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Umplan-Panel -->
|
<!-- 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 id="rs-panel-{{ art.id }}" class="hidden"
|
||||||
<div class="flex gap-2 items-end flex-wrap">
|
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="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">
|
<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="confirmReschedule({{ art.id }})"
|
||||||
<button onclick="closeReschedule({{ art.id }})" class="text-xs text-slate-500 hover:text-white px-1">✗</button>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -194,6 +332,7 @@
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
let currentArticleId = null;
|
let currentArticleId = null;
|
||||||
|
let currentWpPostId = null;
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const content = document.getElementById('article-content').value;
|
const content = document.getElementById('article-content').value;
|
||||||
|
|
@ -260,10 +399,24 @@ function getArticleData() {
|
||||||
async function saveDraft() {
|
async function saveDraft() {
|
||||||
const r = await fetch('/api/article/save', {
|
const r = await fetch('/api/article/save', {
|
||||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
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();
|
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">
|
||||||
|
👁 Entwurf in WordPress ansehen →</a>`;
|
||||||
|
linkBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
showToast('💾 Entwurf gespeichert & nach WordPress gepusht');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publishNow() {
|
async function publishNow() {
|
||||||
|
|
@ -332,6 +485,7 @@ async function loadArticle(id) {
|
||||||
const r = await fetch(`/api/article/${id}`);
|
const r = await fetch(`/api/article/${id}`);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
currentArticleId = id;
|
currentArticleId = id;
|
||||||
|
currentWpPostId = d.wp_post_id || null;
|
||||||
document.getElementById('article-title').value = d.title || '';
|
document.getElementById('article-title').value = d.title || '';
|
||||||
document.getElementById('article-content').value = d.content || '';
|
document.getElementById('article-content').value = d.content || '';
|
||||||
document.getElementById('source-input').value = d.source_url || '';
|
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('focus-keyword').value = d.focus_keyword || '';
|
||||||
document.getElementById('featured-image').value = d.featured_image_url || '';
|
document.getElementById('featured-image').value = d.featured_image_url || '';
|
||||||
if (d.category_id) document.getElementById('category-select').value = d.category_id;
|
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">
|
||||||
|
👁 Entwurf in WordPress ansehen →</a>`;
|
||||||
|
linkBox.classList.remove('hidden');
|
||||||
|
} else if (linkBox) {
|
||||||
|
linkBox.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
updatePreview();
|
updatePreview();
|
||||||
|
loadMirrorStatus(id);
|
||||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
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 ──
|
// ── Board: Umplanen ──
|
||||||
function openReschedule(id, date, time) {
|
function openReschedule(id, date, time) {
|
||||||
document.querySelectorAll('[id^="rs-panel-"]').forEach(el => el.classList.add('hidden'));
|
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); }
|
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">→ ansehen</a>` : ''}
|
||||||
|
${!ok && m.error ? `<span class="text-slate-500">(${m.error})</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
document.getElementById('schedule-date').value = today;
|
document.getElementById('schedule-date').value = today;
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,6 @@ class WordPressClient:
|
||||||
if featured_media_id:
|
if featured_media_id:
|
||||||
data['featured_media'] = featured_media_id
|
data['featured_media'] = featured_media_id
|
||||||
|
|
||||||
# Yoast SEO meta fields
|
|
||||||
if any([seo_title, seo_description, focus_keyword]):
|
if any([seo_title, seo_description, focus_keyword]):
|
||||||
data['meta'] = {}
|
data['meta'] = {}
|
||||||
if seo_title:
|
if seo_title:
|
||||||
|
|
@ -119,3 +118,146 @@ class WordPressClient:
|
||||||
|
|
||||||
def get_post(self, wp_post_id: int) -> dict:
|
def get_post(self, wp_post_id: int) -> dict:
|
||||||
return self._get(f'posts/{wp_post_id}')
|
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']]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue