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
|
||||
> 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.)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–4 (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 1–4)
|
||||
- [ ] 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 1–4)
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
)
|
||||
db.update_article(art['id'], {
|
||||
'status': 'published',
|
||||
'wp_post_id': result['id'],
|
||||
'wp_url': 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'])
|
||||
|
||||
if art.get('send_to_telegram') and art.get('article_type') == 'ki':
|
||||
_send_telegram_teaser(art, result['url'])
|
||||
primary_result = results.get('primary')
|
||||
if primary_result:
|
||||
db.update_article(art['id'], {
|
||||
'status': 'published',
|
||||
'wp_post_id': primary_result['id'],
|
||||
'wp_url': primary_result['url'],
|
||||
'published_at': datetime.utcnow().isoformat(),
|
||||
})
|
||||
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, 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')
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{% 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">
|
||||
{% set status_icons = {'draft':'📝','scheduled':'🗓️','published':'📤'} %}
|
||||
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
|
||||
{% 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 %}
|
||||
|
||||
{% for d in plan_days %}
|
||||
{% set arts = plan_articles.get(d, []) %}
|
||||
{% set is_today = (d == today) %}
|
||||
<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>
|
||||
|
||||
<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 %}
|
||||
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
|
||||
{% for d in plan_days %}
|
||||
{% set arts = plan_articles.get(d, []) %}
|
||||
{% set is_today = (d == today) %}
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
</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>
|
||||
<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) }}
|
||||
</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>
|
||||
{% endif %}
|
||||
</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>
|
||||
</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">
|
||||
👁 Entwurf in WordPress ansehen →</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">
|
||||
👁 Entwurf in WordPress ansehen →</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">→ 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;
|
||||
|
|
|
|||
|
|
@ -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']]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue