diff --git a/arakava-news/STATE.md b/arakava-news/STATE.md index bef936ba..69d14409 100644 --- a/arakava-news/STATE.md +++ b/arakava-news/STATE.md @@ -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.) diff --git a/fuenfvoracht/STATE.md b/fuenfvoracht/STATE.md index fdd2100e..0d03154a 100644 --- a/fuenfvoracht/STATE.md +++ b/fuenfvoracht/STATE.md @@ -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 diff --git a/redakteur/STATE.md b/redakteur/STATE.md index c2aab10c..b7c69326 100644 --- a/redakteur/STATE.md +++ b/redakteur/STATE.md @@ -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 diff --git a/redax-wp/README.md b/redax-wp/README.md index 6ad2ee82..8aaeb18b 100644 --- a/redax-wp/README.md +++ b/redax-wp/README.md @@ -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 diff --git a/redax-wp/STATE.md b/redax-wp/STATE.md index feb58c75..e0f2178b 100644 --- a/redax-wp/STATE.md +++ b/redax-wp/STATE.md @@ -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 diff --git a/redax-wp/docker-compose.yml b/redax-wp/docker-compose.yml index 2aae648c..e3bf1b1e 100644 --- a/redax-wp/docker-compose.yml +++ b/redax-wp/docker-compose.yml @@ -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: diff --git a/redax-wp/src/app.py b/redax-wp/src/app.py index 795c9b8a..a980e267 100644 --- a/redax-wp/src/app.py +++ b/redax-wp/src/app.py @@ -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//mirrors') +def api_article_mirrors(article_id): + mirrors = db.get_mirror_posts(article_id) + return jsonify(mirrors) + + # ── API: Feeds ──────────────────────────────────────────────────────────────── @app.route('/feeds') diff --git a/redax-wp/src/database.py b/redax-wp/src/database.py index bc6375a1..8f893316 100644 --- a/redax-wp/src/database.py +++ b/redax-wp/src/database.py @@ -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] diff --git a/redax-wp/src/openrouter.py b/redax-wp/src/openrouter.py index 47e6e107..8cee30be 100644 --- a/redax-wp/src/openrouter.py +++ b/redax-wp/src/openrouter.py @@ -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()) diff --git a/redax-wp/src/templates/index.html b/redax-wp/src/templates/index.html index 4dc37e8c..f87e1967 100644 --- a/redax-wp/src/templates/index.html +++ b/redax-wp/src/templates/index.html @@ -5,7 +5,7 @@
-
+
{% if last_published %} Letzter Post: {{ last_published.wp_url[:50] if last_published.wp_url else last_published.title[:40] }} — {{ last_published.published_at[:16] if last_published.published_at else '' }} {% endif %} @@ -14,6 +14,55 @@ {% endif %}
+ +
+
📡 Publish-Ziele
+
+ {% for t in wp_targets %} +
+ + + {% if t.primary %} + + {% else %} +
+ + +
+ {% endif %} + + + | + + + 🌐 Website + + + ⚙️ WP-Admin + + + + {{ t.username }} / {{ t.admin_pw }} + + +
+ {% endfor %} +
+
+
@@ -59,6 +108,8 @@ style="min-height: 14rem; max-height: 24rem; font-family: Georgia, serif; line-height: 1.8;"> Vorschau erscheint beim Tippen...
+ +
@@ -133,55 +184,142 @@
-
-

📅 Redaktionsplan — 7 Tage

+ {% 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 %} +
+

📝 Entwürfe ohne Datum

- {% set status_icons = {'draft':'📝','scheduled':'🗓️','published':'📤'} %} - {% set type_icons = {'ki':'🤖','rss':'📡'} %} + {% for art in undated_drafts %} +
+ 🤖 + {{ (art.title or 'Kein Titel')[:55] }} + Entwurf +
+ {% endfor %} +
+
+ {% endif %} - {% for d in plan_days %} - {% set arts = plan_articles.get(d, []) %} - {% set is_today = (d == today) %} +
+

📅 Redaktionsplan — 7 Tage

+

Artikel per Drag & Drop auf einen anderen Tag ziehen zum Umplanen.

-
- {{ d[8:] }}.{{ d[5:7] }}. - {% if is_today %}Heute{% endif %} - {% if not arts %}— leer{% endif %} + {% set type_icons = {'ki':'🤖','rss':'📡'} %} + {% for d in plan_days %} + {% set arts = plan_articles.get(d, []) %} + {% set is_today = (d == today) %} + + +
+ +
+ + {{ d[8:] }}.{{ d[5:7] }}. + + {% if is_today %} + Heute + {% endif %} + {% if not arts %} + — leer + {% endif %}
{% for art in arts %} -
-
- {{ type_icons.get(art.article_type, '📝') }} - {{ art.post_time }} -
-
{{ (art.title or 'Kein Titel')[:55] }}
+ +
+ + +
+ {{ type_icons.get(art.article_type, '📝') }} +
+
+ {{ art.post_time }} + + {{ {'draft':'Entwurf','scheduled':'Geplant','published':'✓ Live'}.get(art.status, art.status) }} + +
+ +
+ {{ (art.title or 'Kein Titel')[:70] }} +
+ + {% if art.seo_description %} +
+ {{ art.seo_description[:100] }}… +
+ {% endif %} + +
+ + {% if art.wp_post_id %} + + 🌐 WP-Editor + + + 👁 Vorschau + + {% endif %} + {% if art.status != 'published' %} + + + {% endif %} +
- - {{ {'draft':'Entwurf','scheduled':'Geplant','published':'Live'}.get(art.status, art.status) }} - - {% if art.status != 'published' %} -
- - -
- {% endif %}
+ - +
+ {% endfor %}
@@ -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 = ` + 👁 Entwurf in WordPress ansehen →`; + 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 = ` + 👁 Entwurf in WordPress ansehen →`; + 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 = '
📡 Mirror-Status:
'; + for (const m of mirrors) { + const ok = m.status === 'ok'; + box.innerHTML += `
+ ${ok ? '✅' : '❌'} ${m.mirror_label} + ${ok && m.mirror_url ? `→ ansehen` : ''} + ${!ok && m.error ? `(${m.error})` : ''} +
`; + } + box.classList.remove('hidden'); + } catch(e) {} +} + document.addEventListener('DOMContentLoaded', () => { const today = new Date().toISOString().split('T')[0]; document.getElementById('schedule-date').value = today; diff --git a/redax-wp/src/wordpress.py b/redax-wp/src/wordpress.py index 7d6fdc24..9197c47b 100644 --- a/redax-wp/src/wordpress.py +++ b/redax-wp/src/wordpress.py @@ -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']]