redax-wp: Sprint 1+2 — vollständiger Stack

Infrastruktur:
- CT 113 auf pve-hetzner erstellt (Docker, Tailscale)
- Forgejo-Repo redax-wp angelegt

Code (Sprint 2):
- docker-compose.yml: wordpress + db + redax-web
- .env.example mit allen Variablen
- database.py: articles, feeds, feed_items, prompts, settings
- wordpress.py: WP REST API Client (create/update post, media upload, Yoast SEO)
- rss_fetcher.py: Feed-Import, Blacklist, Teaser-Modus, KI-Rewrite
- app.py: Flask Dashboard, Scheduler (publish/rss/briefing), alle API-Routen
- templates: base, login, index (Zwei-Spalten-Editor), feeds, history, prompts, settings, hilfe
- README.md + .gitignore

Made-with: Cursor
This commit is contained in:
root 2026-02-27 07:52:24 +07:00
parent 2fe60d8220
commit 064ae085b5
20 changed files with 2458 additions and 0 deletions

32
redax-wp/.env.example Normal file
View file

@ -0,0 +1,32 @@
# ─── Redax-WP Konfiguration ───────────────────────────────────────────────────
# Kopiere diese Datei zu .env und fülle alle Werte aus.
# ─── Dashboard Auth ───────────────────────────────────────────────────────────
DASHBOARD_USER=admin
DASHBOARD_PASSWORD=changeme
# ─── WordPress (intern via Docker-Netzwerk) ───────────────────────────────────
WP_URL=http://wordpress
WP_USERNAME=admin
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
WORDPRESS_DB_PASSWORD=wp_secret_123
# ─── KI (OpenRouter) ──────────────────────────────────────────────────────────
OPENROUTER_API_KEY=sk-or-v1-...
# ─── Telegram ─────────────────────────────────────────────────────────────────
# Bot-Token für Benachrichtigungen nach Veröffentlichung
TELEGRAM_BOT_TOKEN=
# Kanal für KI-Artikel Teaser (z.B. @meinkanal oder -1001234567890)
TELEGRAM_CHANNEL_ID=
# Reviewer Chat-IDs (kommagetrennt) für Fehler-Alarm + Morgen-Briefing
TELEGRAM_REVIEWER_IDS=
# ─── Zeitzone ─────────────────────────────────────────────────────────────────
TIMEZONE=Europe/Berlin
# ─── WordPress Datenbank ──────────────────────────────────────────────────────
MYSQL_ROOT_PASSWORD=root_secret_123
MYSQL_DATABASE=wordpress
MYSQL_USER=wordpress
MYSQL_PASSWORD=wp_secret_123

7
redax-wp/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
*.pyo
data/
logs/
*.db

90
redax-wp/README.md Normal file
View file

@ -0,0 +1,90 @@
# Redax-WP
KI-gestütztes Redaktionssystem für WordPress mit integriertem RSS-Feed-Manager.
## Was ist Redax-WP?
Redax-WP ersetzt das WordPress-Admin-Backend für redaktionelle Arbeit. Es kombiniert:
- **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
### 1. Repository klonen
```bash
git clone https://git.orbitalo.net/orbitalo/redax-wp.git
cd redax-wp
```
### 2. Konfiguration
```bash
cp .env.example .env
# .env mit eigenen Werten befüllen (Editor öffnen)
nano .env
```
### 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-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
### 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
### 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-compose.yml
├── web Flask Dashboard (:8080)
├── wordpress WordPress + Apache (:80 intern)
└── db MySQL 8
```
## Lizenz
MIT

View file

@ -0,0 +1,57 @@
services:
db:
image: mysql:8.0
container_name: redax-db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- ./data/mysql:/var/lib/mysql
networks:
- redax-internal
wordpress:
image: wordpress:6.7-apache
container_name: redax-wordpress
restart: unless-stopped
depends_on:
- db
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_NAME: ${MYSQL_DATABASE}
WORDPRESS_DB_USER: ${MYSQL_USER}
WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
volumes:
- ./data/wordpress:/var/www/html
networks:
- redax-internal
- redax-public
web:
build:
context: ./src
dockerfile: Dockerfile.web
container_name: redax-web
restart: unless-stopped
depends_on:
- wordpress
- db
env_file: .env
volumes:
- ./data/db:/data
- ./logs:/logs
ports:
- "8080:8080"
networks:
- redax-internal
- redax-public
networks:
redax-internal:
driver: bridge
redax-public:
driver: bridge

View file

@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Tailwind CSS lokal (kein CDN, kein JIT)
RUN mkdir -p /app/static && \
curl -sL https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css \
-o /app/static/tailwind.min.css
COPY *.py ./
COPY templates/ ./templates/
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "4", "--timeout", "120", "app:app"]

456
redax-wp/src/app.py Normal file
View file

@ -0,0 +1,456 @@
import os
import json
from datetime import date, timedelta, datetime
from flask import Flask, render_template, request, jsonify, session, redirect, url_for
from apscheduler.schedulers.background import BackgroundScheduler
import pytz
import database as db
import logger as flog
import openrouter
import rss_fetcher
from wordpress import WordPressClient
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
DASHBOARD_USER = os.environ.get('DASHBOARD_USER', 'admin')
DASHBOARD_PASSWORD = os.environ.get('DASHBOARD_PASSWORD', 'changeme')
TIMEZONE = os.environ.get('TIMEZONE', 'Europe/Berlin')
TZ = pytz.timezone(TIMEZONE)
TELEGRAM_BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_CHANNEL_ID = os.environ.get('TELEGRAM_CHANNEL_ID', '')
TELEGRAM_REVIEWER_IDS = [
i.strip() for i in os.environ.get('TELEGRAM_REVIEWER_IDS', '').split(',') if i.strip()
]
# ── Scheduler ─────────────────────────────────────────────────────────────────
def job_publish_due():
articles = db.get_due_articles()
for art in articles:
try:
wp = WordPressClient()
media_id = None
if art.get('featured_image_url'):
media_id = wp.upload_media(art['featured_image_url'])
result = wp.create_post(
title=art['title'] or 'Ohne Titel',
content=art['content'] or '',
status='publish',
category_ids=[art['category_id']] if art.get('category_id') else [],
featured_media_id=media_id,
seo_title=art.get('seo_title'),
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'])
except Exception as e:
flog.error('publish_failed', article_id=art['id'], error=str(e))
_send_error_alarm(f"❌ Publish fehlgeschlagen: Artikel #{art['id']}\n{str(e)}")
def job_fetch_feeds():
rss_fetcher.run_all_feeds()
def job_morning_briefing():
today = date.today().strftime('%Y-%m-%d')
tomorrow = (date.today() + timedelta(days=1)).strftime('%Y-%m-%d')
today_arts = db.get_articles_for_date(today)
tomorrow_arts = db.get_articles_for_date(tomorrow)
queue = db.get_feed_queue(status='new', limit=10)
lines = ["📰 *Morgen-Briefing Redax-WP*\n"]
lines.append(f"*Heute ({today}):* {len(today_arts)} Artikel geplant")
for a in today_arts:
status_icon = {'published': '', 'scheduled': '🗓️', 'draft': '📝'}.get(a['status'], '')
lines.append(f" {status_icon} {a['post_time']}{(a['title'] or 'Kein Titel')[:50]}")
lines.append(f"\n*Morgen ({tomorrow}):* {len(tomorrow_arts)} Artikel")
lines.append(f"*RSS-Queue:* {len(queue)} neue Artikel warten")
msg = '\n'.join(lines)
for chat_id in TELEGRAM_REVIEWER_IDS:
_tg_send(chat_id, msg, parse_mode='Markdown')
def _send_telegram_teaser(article: dict, wp_url: str):
title = article.get('title', '')
seo_desc = article.get('seo_description', '')
teaser = seo_desc[:200] if seo_desc else ''
msg = f"📰 *{title}*\n\n{teaser}\n\n🔗 {wp_url}"
_tg_send(TELEGRAM_CHANNEL_ID, msg, parse_mode='Markdown')
def _send_error_alarm(message: str):
for chat_id in TELEGRAM_REVIEWER_IDS:
_tg_send(chat_id, f"🚨 *Redax-WP Fehler*\n{message}", parse_mode='Markdown')
def _tg_send(chat_id: str, text: str, parse_mode: str = None):
if not TELEGRAM_BOT_TOKEN or not chat_id:
return
import requests as req
try:
req.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
json={'chat_id': chat_id, 'text': text, 'parse_mode': parse_mode},
timeout=10
)
except Exception as e:
flog.error('tg_send_failed', error=str(e))
# ── Auth ──────────────────────────────────────────────────────────────────────
def logged_in():
return session.get('user') == DASHBOARD_USER
@app.before_request
def require_login():
open_routes = ['login', 'static']
if request.endpoint not in open_routes and not logged_in():
return redirect(url_for('login'))
@app.route('/login', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
if request.form.get('user') == DASHBOARD_USER and \
request.form.get('password') == DASHBOARD_PASSWORD:
session['user'] = DASHBOARD_USER
return redirect(url_for('index'))
error = 'Falsche Zugangsdaten'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
session.clear()
return redirect(url_for('login'))
# ── Main Dashboard ─────────────────────────────────────────────────────────────
@app.route('/')
def index():
today = date.today().strftime('%Y-%m-%d')
plan_days = [(date.today() + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
plan_raw = db.get_week_articles(plan_days[0], plan_days[-1])
plan_articles = {}
for a in plan_raw:
plan_articles.setdefault(a['post_date'], []).append(a)
wp_categories = []
try:
wp = WordPressClient()
if wp.is_reachable():
wp_categories = wp.get_categories()
except Exception:
pass
last_published = db.get_last_published()
feeds = db.get_feeds()
queue_count = len(db.get_feed_queue(status='new'))
prompts = db.get_prompts()
return render_template('index.html',
today=today,
plan_days=plan_days,
plan_articles=plan_articles,
wp_categories=wp_categories,
last_published=last_published,
feeds=feeds,
queue_count=queue_count,
prompts=prompts)
# ── API: Artikel ──────────────────────────────────────────────────────────────
@app.route('/api/generate', methods=['POST'])
def api_generate():
data = request.json
source = data.get('source', '')
tone = data.get('tone', 'informativ')
prompt = db.get_default_prompt()
if not prompt:
return jsonify({'error': 'Kein Prompt konfiguriert'}), 400
system = prompt['system_prompt']
system = system.replace('{tone}', tone).replace('{date}', date.today().strftime('%d.%m.%Y'))
try:
raw = openrouter.generate(system, source)
content, seo_title, seo_desc, keyword = rss_fetcher._parse_ki_output(raw)
# Erste Zeile als Titel extrahieren
lines = content.strip().split('\n')
title = lines[0].lstrip('#').strip() if lines else ''
content_body = '\n'.join(lines[1:]).strip() if len(lines) > 1 else content
flog.info('article_generated', tone=tone)
return jsonify({
'title': title,
'content': content_body,
'seo_title': seo_title,
'seo_description': seo_desc,
'focus_keyword': keyword,
})
except Exception as e:
flog.error('generate_failed', error=str(e))
return jsonify({'error': str(e)}), 500
@app.route('/api/article/save', methods=['POST'])
def api_save_article():
data = request.json
article_id = data.get('id')
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})
@app.route('/api/article/schedule', methods=['POST'])
def api_schedule_article():
data = request.json
article_id = data.get('id')
post_date = data.get('post_date')
post_time = data.get('post_time')
send_to_telegram = 1 if data.get('article_type', 'ki') == 'ki' else 0
if article_id:
db.update_article(article_id, {
'post_date': post_date,
'post_time': post_time,
'status': 'scheduled',
'send_to_telegram': send_to_telegram,
})
else:
article_id = db.create_article({
**data,
'article_type': 'ki',
'status': 'scheduled',
'send_to_telegram': send_to_telegram,
})
flog.info('article_scheduled', article_id=article_id, date=post_date, time=post_time)
return jsonify({'success': True, 'id': article_id})
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
def api_reschedule(article_id):
data = request.json
ok, msg = db.reschedule_article(article_id, data['post_date'], data['post_time'])
return jsonify({'success': ok, 'error': msg if not ok else None})
@app.route('/api/article/<int:article_id>/delete', methods=['POST'])
def api_delete_article(article_id):
db.delete_article(article_id)
flog.info('article_deleted', article_id=article_id)
return jsonify({'success': True})
@app.route('/api/article/<int:article_id>', methods=['GET'])
def api_get_article(article_id):
art = db.get_article(article_id)
if not art:
return jsonify({'error': 'Not found'}), 404
return jsonify(art)
@app.route('/api/slots/<date_str>')
def api_slots(date_str):
return jsonify({'taken': db.get_taken_slots(date_str)})
@app.route('/api/og-image', methods=['POST'])
def api_og_image():
url = request.json.get('url', '')
image = rss_fetcher._extract_og_image(url)
return jsonify({'image': image})
@app.route('/api/wp/categories')
def api_wp_categories():
try:
wp = WordPressClient()
return jsonify(wp.get_categories())
except Exception as e:
return jsonify({'error': str(e)}), 500
# ── API: Feeds ────────────────────────────────────────────────────────────────
@app.route('/feeds')
def feeds_page():
feeds = db.get_feeds()
queue = db.get_feed_queue(status='new', limit=30)
return render_template('feeds.html', feeds=feeds, queue=queue)
@app.route('/api/feeds', methods=['GET'])
def api_get_feeds():
return jsonify(db.get_feeds())
@app.route('/api/feeds/add', methods=['POST'])
def api_add_feed():
data = request.json
fid = db.create_feed(data)
flog.info('feed_added', feed_id=fid, name=data.get('name'))
return jsonify({'success': True, 'id': fid})
@app.route('/api/feeds/<int:feed_id>/update', methods=['POST'])
def api_update_feed(feed_id):
db.update_feed(feed_id, request.json)
return jsonify({'success': True})
@app.route('/api/feeds/<int:feed_id>/delete', methods=['POST'])
def api_delete_feed(feed_id):
db.delete_feed(feed_id)
flog.info('feed_deleted', feed_id=feed_id)
return jsonify({'success': True})
@app.route('/api/feeds/<int:feed_id>/fetch', methods=['POST'])
def api_fetch_feed(feed_id):
feed = db.get_feed(feed_id)
if not feed:
return jsonify({'error': 'Feed not found'}), 404
count = rss_fetcher.fetch_feed(feed)
return jsonify({'success': True, 'new_items': count})
@app.route('/api/queue/<int:item_id>/approve', methods=['POST'])
def api_approve_queue_item(item_id):
items = db.get_feed_queue(status='new')
item = next((i for i in items if i['id'] == item_id), None)
if not item:
return jsonify({'error': 'Item not found'}), 404
feed = db.get_feed(item['feed_id'])
article_id = rss_fetcher.process_auto_publish(feed, item)
return jsonify({'success': True, 'article_id': article_id})
@app.route('/api/queue/<int:item_id>/reject', methods=['POST'])
def api_reject_queue_item(item_id):
db.update_feed_item_status(item_id, 'rejected')
return jsonify({'success': True})
# ── Prompts ───────────────────────────────────────────────────────────────────
@app.route('/prompts')
def prompts_page():
return render_template('prompts.html', prompts=db.get_prompts())
@app.route('/api/prompts/save', methods=['POST'])
def api_save_prompt():
data = request.json
conn = db.get_conn()
if data.get('id'):
conn.execute(
"UPDATE prompts SET name=?, system_prompt=? WHERE id=?",
(data['name'], data['system_prompt'], data['id'])
)
else:
conn.execute(
"INSERT INTO prompts (name, system_prompt) VALUES (?,?)",
(data['name'], data['system_prompt'])
)
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/prompts/<int:pid>/default', methods=['POST'])
def api_set_default_prompt(pid):
conn = db.get_conn()
conn.execute("UPDATE prompts SET is_default=0")
conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (pid,))
conn.commit()
conn.close()
return jsonify({'success': True})
@app.route('/api/prompts/<int:pid>/delete', methods=['POST'])
def api_delete_prompt(pid):
conn = db.get_conn()
conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (pid,))
conn.commit()
conn.close()
return jsonify({'success': True})
# ── History ───────────────────────────────────────────────────────────────────
@app.route('/history')
def history():
articles = db.get_articles(limit=50, status='published')
return render_template('history.html', articles=articles)
# ── Settings ──────────────────────────────────────────────────────────────────
@app.route('/settings')
def settings():
return render_template('settings.html',
wp_url=os.environ.get('WP_URL', ''),
wp_username=os.environ.get('WP_USERNAME', ''),
timezone=TIMEZONE)
# ── Hilfe ─────────────────────────────────────────────────────────────────────
@app.route('/hilfe')
def hilfe():
return render_template('hilfe.html')
# ── Startup ───────────────────────────────────────────────────────────────────
def start_scheduler():
scheduler = BackgroundScheduler(timezone=TZ)
# Artikel-Publishing: jede Minute prüfen
scheduler.add_job(job_publish_due, 'interval', minutes=1, id='publish_due')
# RSS-Feeds: alle 30 Minuten
scheduler.add_job(job_fetch_feeds, 'interval', minutes=30, id='fetch_feeds')
# Morgen-Briefing: täglich 10:00
scheduler.add_job(job_morning_briefing, 'cron', hour=10, minute=0, id='morning_briefing')
scheduler.start()
flog.info('scheduler_started')
db.init_db()
start_scheduler()
flog.info('app_started')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080, debug=False)

406
redax-wp/src/database.py Normal file
View file

@ -0,0 +1,406 @@
import sqlite3
import os
from datetime import datetime, date
DB_PATH = os.environ.get('DB_PATH', '/data/redax.db')
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_conn()
c = conn.cursor()
c.executescript("""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
content_raw TEXT,
source_url TEXT,
article_type TEXT NOT NULL DEFAULT 'ki', -- 'ki' or 'rss'
source_feed_id INTEGER REFERENCES feeds(id),
status TEXT NOT NULL DEFAULT 'draft',
tone TEXT DEFAULT 'informativ',
post_date TEXT,
post_time TEXT DEFAULT '19:00',
wp_post_id INTEGER,
wp_url TEXT,
category_id INTEGER,
featured_image_url TEXT,
seo_title TEXT,
seo_description TEXT,
focus_keyword TEXT,
send_to_telegram INTEGER DEFAULT 0,
version INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
UNIQUE(post_date, post_time)
);
CREATE TABLE IF NOT EXISTS feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
schedule TEXT DEFAULT '*/2 * * * *',
active INTEGER DEFAULT 1,
auto_publish INTEGER DEFAULT 0,
ki_rewrite INTEGER DEFAULT 0,
teaser_only INTEGER DEFAULT 1,
category_id INTEGER,
blacklist TEXT DEFAULT '',
last_fetched_at TEXT,
last_error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS feed_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL REFERENCES feeds(id),
guid TEXT NOT NULL,
title TEXT,
url TEXT,
summary TEXT,
published_at TEXT,
status TEXT DEFAULT 'new',
article_id INTEGER REFERENCES articles(id),
fetched_at TEXT DEFAULT (datetime('now')),
UNIQUE(feed_id, guid)
);
CREATE TABLE IF NOT EXISTS prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
system_prompt TEXT NOT NULL,
is_default INTEGER DEFAULT 0,
last_tested_at TEXT,
test_result TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS post_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER REFERENCES articles(id),
wp_post_id INTEGER,
wp_url TEXT,
tg_message_id INTEGER,
posted_at TEXT DEFAULT (datetime('now'))
);
""")
# Seed default prompt
c.execute("SELECT COUNT(*) FROM prompts")
if c.fetchone()[0] == 0:
c.execute("""
INSERT INTO prompts (name, system_prompt, is_default) VALUES (
'Standard',
'Du bist ein erfahrener Redakteur. Schreibe einen vollständigen, gut strukturierten Artikel auf Basis der folgenden Quelle.
Ton: {tone}
Datum: {date}
Formatierung:
- Titel als erste Zeile (ohne #)
- Dann den Artikeltext in HTML (H2, H3, <p>, <ul>, <strong>)
- Am Ende: SEO_TITLE: [max 60 Zeichen]
- SEO_DESC: [max 155 Zeichen]
- KEYWORD: [1 Fokus-Keyword]
Quelle:
{source}',
1
)
""")
conn.commit()
conn.close()
# ── Articles ──────────────────────────────────────────────────────────────────
def get_articles(limit=50, status=None, article_type=None):
conn = get_conn()
q = "SELECT * FROM articles WHERE 1=1"
params = []
if status:
q += " AND status=?"
params.append(status)
if article_type:
q += " AND article_type=?"
params.append(article_type)
q += " ORDER BY post_date DESC, post_time DESC LIMIT ?"
params.append(limit)
rows = conn.execute(q, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_article(article_id):
conn = get_conn()
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
conn.close()
return dict(row) if row else None
def get_articles_for_date(date_str):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles WHERE post_date=? ORDER BY post_time ASC",
(date_str,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_week_articles(from_date, to_date):
conn = get_conn()
rows = conn.execute(
"SELECT * FROM articles WHERE post_date BETWEEN ? AND ? ORDER BY post_date ASC, post_time ASC",
(from_date, to_date)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def create_article(data: dict) -> int:
conn = get_conn()
fields = ['title', 'content', 'content_raw', 'source_url', 'article_type',
'source_feed_id', 'status', 'tone', 'post_date', 'post_time',
'category_id', 'featured_image_url', 'seo_title', 'seo_description',
'focus_keyword', 'send_to_telegram']
cols = [f for f in fields if f in data]
vals = [data[f] for f in cols]
sql = f"INSERT INTO articles ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})"
cur = conn.execute(sql, vals)
article_id = cur.lastrowid
conn.commit()
conn.close()
return article_id
def update_article(article_id, data: dict):
conn = get_conn()
fields = ['title', 'content', 'content_raw', 'source_url', 'status', 'tone',
'post_date', 'post_time', 'wp_post_id', 'wp_url', 'category_id',
'featured_image_url', 'seo_title', 'seo_description', 'focus_keyword',
'send_to_telegram', 'published_at']
updates = {f: data[f] for f in fields if f in data}
updates['updated_at'] = datetime.utcnow().isoformat()
set_clause = ', '.join(f"{k}=?" for k in updates)
sql = f"UPDATE articles SET {set_clause} WHERE id=?"
conn.execute(sql, list(updates.values()) + [article_id])
conn.commit()
conn.close()
def delete_article(article_id):
conn = get_conn()
conn.execute("DELETE FROM articles WHERE id=?", (article_id,))
conn.commit()
conn.close()
def reschedule_article(article_id, new_date, new_time):
conn = get_conn()
existing = conn.execute(
"SELECT id FROM articles WHERE post_date=? AND post_time=? AND id!=?",
(new_date, new_time, article_id)
).fetchone()
if existing:
conn.close()
return False, "Slot bereits belegt"
conn.execute(
"UPDATE articles SET post_date=?, post_time=?, updated_at=? WHERE id=?",
(new_date, new_time, datetime.utcnow().isoformat(), article_id)
)
conn.commit()
conn.close()
return True, "OK"
def get_taken_slots(date_str):
conn = get_conn()
rows = conn.execute(
"SELECT post_time FROM articles WHERE post_date=?", (date_str,)
).fetchall()
conn.close()
return [r['post_time'] for r in rows]
def get_due_articles():
now = datetime.now()
today = now.strftime('%Y-%m-%d')
current_time = now.strftime('%H:%M')
conn = get_conn()
rows = conn.execute("""
SELECT * FROM articles
WHERE status='scheduled'
AND post_date=?
AND post_time<=?
""", (today, current_time)).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_last_published():
conn = get_conn()
row = conn.execute(
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT 1"
).fetchone()
conn.close()
return dict(row) if row else None
def save_post_history(article_id, wp_post_id, wp_url, tg_message_id=None):
conn = get_conn()
conn.execute(
"INSERT INTO post_history (article_id, wp_post_id, wp_url, tg_message_id) VALUES (?,?,?,?)",
(article_id, wp_post_id, wp_url, tg_message_id)
)
conn.commit()
conn.close()
# ── Feeds ─────────────────────────────────────────────────────────────────────
def get_feeds(active_only=False):
conn = get_conn()
q = "SELECT * FROM feeds"
if active_only:
q += " WHERE active=1"
q += " ORDER BY name ASC"
rows = conn.execute(q).fetchall()
conn.close()
return [dict(r) for r in rows]
def get_feed(feed_id):
conn = get_conn()
row = conn.execute("SELECT * FROM feeds WHERE id=?", (feed_id,)).fetchone()
conn.close()
return dict(row) if row else None
def create_feed(data: dict) -> int:
conn = get_conn()
fields = ['name', 'url', 'schedule', 'active', 'auto_publish', 'ki_rewrite',
'teaser_only', 'category_id', 'blacklist']
cols = [f for f in fields if f in data]
vals = [data[f] for f in cols]
cur = conn.execute(
f"INSERT INTO feeds ({','.join(cols)}) VALUES ({','.join(['?']*len(cols))})",
vals
)
fid = cur.lastrowid
conn.commit()
conn.close()
return fid
def update_feed(feed_id, data: dict):
conn = get_conn()
fields = ['name', 'url', 'schedule', 'active', 'auto_publish', 'ki_rewrite',
'teaser_only', 'category_id', 'blacklist', 'last_fetched_at', 'last_error']
updates = {f: data[f] for f in fields if f in data}
set_clause = ', '.join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE feeds SET {set_clause} WHERE id=?", list(updates.values()) + [feed_id])
conn.commit()
conn.close()
def delete_feed(feed_id):
conn = get_conn()
conn.execute("DELETE FROM feeds WHERE id=?", (feed_id,))
conn.commit()
conn.close()
def get_feed_queue(status='new', limit=50):
conn = get_conn()
rows = conn.execute("""
SELECT fi.*, f.name as feed_name FROM feed_items fi
JOIN feeds f ON fi.feed_id = f.id
WHERE fi.status=?
ORDER BY fi.fetched_at DESC LIMIT ?
""", (status, limit)).fetchall()
conn.close()
return [dict(r) for r in rows]
def save_feed_item(feed_id, guid, title, url, summary, published_at):
conn = get_conn()
try:
conn.execute("""
INSERT OR IGNORE INTO feed_items (feed_id, guid, title, url, summary, published_at)
VALUES (?,?,?,?,?,?)
""", (feed_id, guid, title, url, summary, published_at))
conn.commit()
inserted = conn.execute("SELECT changes()").fetchone()[0]
conn.close()
return inserted > 0
except Exception:
conn.close()
return False
def update_feed_item_status(item_id, status, article_id=None):
conn = get_conn()
conn.execute(
"UPDATE feed_items SET status=?, article_id=? WHERE id=?",
(status, article_id, item_id)
)
conn.commit()
conn.close()
def guid_exists(feed_id, guid):
conn = get_conn()
row = conn.execute(
"SELECT id FROM feed_items WHERE feed_id=? AND guid=?", (feed_id, guid)
).fetchone()
conn.close()
return row is not None
# ── Prompts ───────────────────────────────────────────────────────────────────
def get_prompts():
conn = get_conn()
rows = conn.execute("SELECT * FROM prompts ORDER BY is_default DESC, name ASC").fetchall()
conn.close()
return [dict(r) for r in rows]
def get_default_prompt():
conn = get_conn()
row = conn.execute("SELECT * FROM prompts WHERE is_default=1 LIMIT 1").fetchone()
conn.close()
return dict(row) if row else None
# ── Settings ──────────────────────────────────────────────────────────────────
def get_setting(key, default=None):
conn = get_conn()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
return row['value'] if row else default
def set_setting(key, value):
conn = get_conn()
conn.execute("INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)", (key, value))
conn.commit()
conn.close()

100
redax-wp/src/logger.py Normal file
View file

@ -0,0 +1,100 @@
"""
Strukturiertes Logging für FünfVorAcht.
Schreibt JSON-Lines nach /logs/fuenfvoracht.log
"""
import json
import logging
import os
from datetime import datetime
LOG_PATH = os.environ.get('LOG_PATH', '/logs/fuenfvoracht.log')
_file_handler = None
def _get_file_handler():
global _file_handler
if _file_handler is None:
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
_file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8')
_file_handler.setLevel(logging.DEBUG)
return _file_handler
def _write(level: str, event: str, **kwargs):
entry = {
'ts': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
'level': level,
'event': event,
**kwargs,
}
line = json.dumps(entry, ensure_ascii=False)
try:
handler = _get_file_handler()
record = logging.LogRecord(
name='fuenfvoracht', level=getattr(logging, level),
pathname='', lineno=0, msg=line, args=(), exc_info=None
)
handler.emit(record)
except Exception:
pass
# Auch in stdout damit docker logs es zeigt
print(line, flush=True)
def info(event: str, **kwargs):
_write('INFO', event, **kwargs)
def warning(event: str, **kwargs):
_write('WARNING', event, **kwargs)
def error(event: str, **kwargs):
_write('ERROR', event, **kwargs)
# Kurzformen für häufige Events
def article_generated(date: str, source: str, version: int, tag: str):
info('article_generated', date=date, source=source[:120], version=version, tag=tag)
def article_saved(date: str, post_time: str):
info('article_saved', date=date, post_time=post_time)
def article_scheduled(date: str, post_time: str, notify_at: str):
info('article_scheduled', date=date, post_time=post_time, notify_at=notify_at)
def article_sent_to_bot(date: str, post_time: str, chat_ids: list):
info('article_sent_to_bot', date=date, post_time=post_time, chat_ids=chat_ids)
def article_approved(date: str, post_time: str, by_chat_id: int):
info('article_approved', date=date, post_time=post_time, by_chat_id=by_chat_id)
def article_posted(date: str, post_time: str, channel_id: str, message_id: int):
info('article_posted', date=date, post_time=post_time,
channel_id=channel_id, message_id=message_id)
def article_skipped(date: str, post_time: str):
info('article_skipped', date=date, post_time=post_time)
def posting_failed(date: str, post_time: str, reason: str):
error('posting_failed', date=date, post_time=post_time, reason=reason[:300])
def reviewer_added(chat_id: int, name: str):
info('reviewer_added', chat_id=chat_id, name=name)
def reviewer_removed(chat_id: int):
info('reviewer_removed', chat_id=chat_id)
def slot_conflict(date: str, post_time: str):
warning('slot_conflict', date=date, post_time=post_time)

View file

@ -0,0 +1,72 @@
import os
import logging
import aiohttp
import asyncio
logger = logging.getLogger(__name__)
OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '')
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = os.environ.get('AI_MODEL', 'openai/gpt-4o-mini')
async def generate_article(source: str, prompt_template: str, date_str: str, tag: str = "allgemein") -> str:
system_prompt = prompt_template.format(
source=source,
date=date_str,
tag=tag.lower().replace(" ", "")
)
payload = {
"model": DEFAULT_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Schreibe jetzt den Artikel basierend auf dieser Quelle:\n\n{source}"}
],
"max_tokens": 600,
"temperature": 0.8
}
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://fuenfvoracht.orbitalo.net",
"X-Title": "FünfVorAcht Bot"
}
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()
async def get_balance() -> dict:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json"
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(f"{OPENROUTER_BASE}/auth/key", headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
key_data = data.get("data", {})
limit = key_data.get("limit")
usage = key_data.get("usage", 0)
if limit:
remaining = round(limit - usage, 4)
else:
remaining = None
return {
"usage": round(usage, 4),
"limit": limit,
"remaining": remaining,
"label": key_data.get("label", ""),
"is_free_tier": key_data.get("is_free_tier", False)
}
except Exception as e:
logger.error("Balance-Abfrage fehlgeschlagen: %s", e)
return {"usage": None, "limit": None, "remaining": None}
def get_balance_sync() -> dict:
return asyncio.run(get_balance())

View file

@ -0,0 +1,10 @@
flask==3.0.3
gunicorn==22.0.0
apscheduler==3.10.4
requests==2.32.3
feedparser==6.0.11
python-telegram-bot==20.7
pytz==2024.1
beautifulsoup4==4.12.3
lxml==5.2.2
Werkzeug==3.0.3

157
redax-wp/src/rss_fetcher.py Normal file
View file

@ -0,0 +1,157 @@
import os
import feedparser
import requests
from bs4 import BeautifulSoup
from datetime import datetime
import database as db
import logger as flog
import openrouter
BLACKLIST_DEFAULT = ['Anzeige:', 'Sponsored', 'Werbung', 'Advertisement', '[Anzeige]']
def _is_blacklisted(title: str, blacklist_str: str) -> bool:
terms = [t.strip() for t in blacklist_str.split(',') if t.strip()] + BLACKLIST_DEFAULT
return any(term.lower() in title.lower() for term in terms)
def _extract_og_image(url: str) -> str | None:
try:
r = requests.get(url, timeout=10, headers={'User-Agent': 'Mozilla/5.0'})
soup = BeautifulSoup(r.text, 'lxml')
tag = soup.find('meta', property='og:image')
return tag['content'] if tag and tag.get('content') else None
except Exception:
return None
def fetch_feed(feed: dict) -> int:
"""Fetch a single feed, save new items. Returns count of new items."""
new_count = 0
try:
parsed = feedparser.parse(feed['url'])
for entry in parsed.entries:
guid = getattr(entry, 'id', entry.get('link', ''))
title = entry.get('title', '').strip()
url = entry.get('link', '')
summary = entry.get('summary', '')
published = entry.get('published', datetime.utcnow().isoformat())
if not guid or not title or not url:
continue
if _is_blacklisted(title, feed.get('blacklist', '')):
flog.info('rss_blacklisted', feed=feed['name'], title=title)
continue
if db.guid_exists(feed['id'], guid):
continue
is_new = db.save_feed_item(feed['id'], guid, title, url, summary, published)
if is_new:
new_count += 1
db.update_feed(feed['id'], {
'last_fetched_at': datetime.utcnow().isoformat(),
'last_error': ''
})
flog.info('rss_fetched', feed=feed['name'], new_items=new_count)
except Exception as e:
db.update_feed(feed['id'], {'last_error': str(e)})
flog.error('rss_fetch_failed', feed=feed['name'], error=str(e))
return new_count
def process_auto_publish(feed: dict, item: dict):
"""Process a feed item for auto-publish (teaser or KI-rewrite)."""
try:
title = item['title']
source_url = item['url']
summary = item.get('summary', '')
og_image = _extract_og_image(source_url)
if feed.get('ki_rewrite'):
content, seo_title, seo_desc, keyword = _ki_rewrite(title, source_url, summary)
elif feed.get('teaser_only', 1):
content = _build_teaser(title, summary, source_url)
seo_title = title[:60]
seo_desc = summary[:155] if summary else ''
keyword = ''
else:
content = summary
seo_title = title[:60]
seo_desc = ''
keyword = ''
article_id = db.create_article({
'title': title,
'content': content,
'source_url': source_url,
'article_type': 'rss',
'source_feed_id': feed['id'],
'status': 'scheduled',
'tone': 'informativ',
'category_id': feed.get('category_id'),
'featured_image_url': og_image,
'seo_title': seo_title,
'seo_description': seo_desc,
'focus_keyword': keyword,
'send_to_telegram': 0, # RSS-Artikel nie auf Telegram
})
db.update_feed_item_status(item['id'], 'queued', article_id)
flog.info('rss_article_queued', feed=feed['name'], title=title, article_id=article_id)
return article_id
except Exception as e:
flog.error('rss_process_failed', feed=feed['name'], error=str(e))
return None
def _ki_rewrite(title: str, url: str, summary: str) -> tuple:
"""KI rewrites a RSS article. Returns (content, seo_title, seo_desc, keyword)."""
prompt = db.get_default_prompt()
system = prompt['system_prompt'] if prompt else 'Schreibe einen Artikel.'
source_text = f"Titel: {title}\nURL: {url}\nZusammenfassung: {summary}"
system = system.replace('{tone}', 'informativ').replace('{date}', datetime.now().strftime('%d.%m.%Y'))
raw = openrouter.generate(system, source_text)
return _parse_ki_output(raw)
def _parse_ki_output(raw: str) -> tuple:
"""Parse KI output into (content, seo_title, seo_desc, keyword)."""
lines = raw.strip().split('\n')
seo_title, seo_desc, keyword = '', '', ''
content_lines = []
for line in lines:
if line.startswith('SEO_TITLE:'):
seo_title = line.replace('SEO_TITLE:', '').strip()
elif line.startswith('SEO_DESC:'):
seo_desc = line.replace('SEO_DESC:', '').strip()
elif line.startswith('KEYWORD:'):
keyword = line.replace('KEYWORD:', '').strip()
else:
content_lines.append(line)
content = '\n'.join(content_lines).strip()
return content, seo_title, seo_desc, keyword
def _build_teaser(title: str, summary: str, url: str) -> str:
"""Build a teaser post that links back to the original source."""
clean_summary = BeautifulSoup(summary, 'lxml').get_text()[:400] if summary else ''
return f"""<p>{clean_summary}</p>
<p><a href="{url}" target="_blank" rel="noopener"> Weiterlesen beim Original</a></p>"""
def run_all_feeds():
"""Fetch all active feeds and process auto-publish items."""
feeds = db.get_feeds(active_only=True)
for feed in feeds:
new_items = fetch_feed(feed)
if feed.get('auto_publish') and new_items > 0:
items = db.get_feed_queue(status='new')
feed_items = [i for i in items if i['feed_id'] == feed['id']]
for item in feed_items:
process_auto_publish(feed, item)

View file

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Redax-WP{% endblock %}</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>
body { background: #0f172a; color: #e2e8f0; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
textarea, input[type=text], input[type=url], input[type=date], input[type=time], select {
background: #0f172a; border: 1px solid #334155; color: #e2e8f0;
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.875rem;
}
textarea:focus, input:focus, select:focus { outline: none; border-color: #3b82f6; }
textarea { resize: vertical; font-family: inherit; line-height: 1.6; }
.btn { border-radius: 8px; font-size: 0.875rem; font-weight: 500; padding: 0.5rem 1.25rem; transition: all .15s; cursor: pointer; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-success { background: #15803d; color: #fff; }
.btn-success:hover { background: #166534; }
.btn-ghost { background: #334155; color: #cbd5e1; }
.btn-ghost:hover { background: #475569; }
.btn-danger { background: #991b1b; color: #fff; }
.btn-danger:hover { background: #7f1d1d; }
.status-draft { background: #1e293b; border: 1px solid #475569; color: #94a3b8; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.status-scheduled { background: #4c1d9522; border: 1px solid #7c3aed; color: #c4b5fd; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.status-published { background: #1e3a5f22; border: 1px solid #3b82f6; color: #60a5fa; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.spinner { animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
#toast { position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 9999;
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
padding: 0.75rem 1.25rem; font-size: 0.875rem; color: #e2e8f0;
box-shadow: 0 4px 24px #00000060; transition: opacity .3s; opacity: 0; pointer-events: none; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen">
<!-- Nav -->
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
<div class="flex items-center gap-4">
<!-- Tool-Switcher -->
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">
📝 Redax-WP
</span>
<a href="https://fuenfvoracht.orbitalo.net" target="_blank"
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">
🕗 FünfVorAcht
</a>
</div>
</div>
<div class="flex items-center gap-5 text-sm">
<a href="/" class="{% if request.endpoint == 'index' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">Studio</a>
<a href="/feeds" class="{% if request.endpoint == 'feeds_page' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">Feeds</a>
<a href="/history" class="{% if request.endpoint == 'history' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">History</a>
<a href="/prompts" class="{% if request.endpoint == 'prompts_page' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">Prompts</a>
<a href="/settings" class="{% if request.endpoint == 'settings' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">Einstellungen</a>
<a href="/hilfe" class="{% if request.endpoint == 'hilfe' %}text-blue-400 font-semibold{% else %}text-slate-400 hover:text-white{% endif %}">❓ Hilfe</a>
<a href="/logout" class="text-slate-600 hover:text-red-400 text-xs transition">Abmelden</a>
</div>
</nav>
<div id="toast"></div>
{% block content %}{% endblock %}
<script>
function showToast(msg, duration=3000) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.opacity = '1';
setTimeout(() => t.style.opacity = '0', duration);
}
{% block extra_js %}{% endblock %}
</script>
</body>
</html>

View file

@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Feeds{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto px-6 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">📡 RSS-Feeds</h1>
<button onclick="showAddFeed()" class="btn btn-primary text-sm">+ Feed hinzufügen</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Feed-Liste -->
<div>
<h2 class="text-sm font-semibold text-slate-400 mb-3">Aktive Feeds ({{ feeds|length }})</h2>
<div class="space-y-2">
{% for feed in feeds %}
<div class="card p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full {% if feed.active %}bg-green-400{% else %}bg-slate-600{% endif %}"></span>
<span class="text-sm font-semibold text-white">{{ feed.name }}</span>
</div>
<div class="flex gap-1">
<button onclick="fetchFeedNow({{ feed.id }}, this)"
class="text-xs text-slate-500 hover:text-blue-400 px-2 py-1 rounded hover:bg-slate-700 transition">
🔄 Abrufen
</button>
<button onclick="deleteFeed({{ feed.id }}, '{{ feed.name }}')"
class="text-xs text-slate-500 hover:text-red-400 px-2 py-1 rounded hover:bg-slate-700 transition">
🗑️
</button>
</div>
</div>
<div class="text-xs text-slate-500 truncate mb-2">{{ feed.url }}</div>
<div class="flex gap-2 flex-wrap text-xs">
<span class="bg-slate-700 text-slate-300 px-2 py-0.5 rounded">{{ feed.schedule }}</span>
{% if feed.auto_publish %}<span class="bg-green-900/40 text-green-400 px-2 py-0.5 rounded border border-green-800">Auto-Publish</span>{% endif %}
{% if feed.ki_rewrite %}<span class="bg-purple-900/40 text-purple-400 px-2 py-0.5 rounded border border-purple-800">KI-Rewrite</span>{% endif %}
{% if feed.teaser_only %}<span class="bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded">Teaser</span>{% endif %}
</div>
{% if feed.last_error %}
<div class="text-xs text-red-400 mt-2">⚠️ {{ feed.last_error[:80] }}</div>
{% endif %}
{% if feed.last_fetched_at %}
<div class="text-xs text-slate-600 mt-1">Zuletzt: {{ feed.last_fetched_at[:16] }}</div>
{% endif %}
</div>
{% else %}
<div class="card p-6 text-center text-slate-600 text-sm">Noch keine Feeds konfiguriert.</div>
{% endfor %}
</div>
</div>
<!-- Artikel-Queue -->
<div>
<h2 class="text-sm font-semibold text-slate-400 mb-3">Artikel-Queue ({{ queue|length }} neu)</h2>
<div class="space-y-2">
{% for item in queue %}
<div class="card p-3" id="queue-item-{{ item.id }}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="text-xs text-slate-500 mb-1">📡 {{ item.feed_name }}</div>
<div class="text-sm text-slate-200 truncate font-medium">{{ item.title }}</div>
<a href="{{ item.url }}" target="_blank" class="text-xs text-blue-400 hover:text-blue-300 truncate block">{{ item.url[:60] }}...</a>
</div>
<div class="flex flex-col gap-1 shrink-0">
<button onclick="approveItem({{ item.id }})"
class="text-xs bg-green-800 hover:bg-green-700 text-white px-2 py-1 rounded">✓ Übernehmen</button>
<button onclick="rejectItem({{ item.id }})"
class="text-xs text-slate-600 hover:text-red-400 px-2 py-1 rounded hover:bg-slate-700">✗ Ablehnen</button>
</div>
</div>
</div>
{% else %}
<div class="card p-6 text-center text-slate-600 text-sm">Queue ist leer — alle Artikel verarbeitet.</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Add Feed Modal -->
<div id="add-feed-modal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6 w-full max-w-md mx-4">
<h3 class="text-white font-semibold mb-4">+ Feed hinzufügen</h3>
<div class="space-y-3">
<div>
<label class="text-xs text-slate-400 block mb-1">Name</label>
<input type="text" id="new-feed-name" class="w-full" placeholder="z.B. Heise Online">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">RSS-URL</label>
<input type="url" id="new-feed-url" class="w-full" placeholder="https://...">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Abruf-Intervall (Minuten)</label>
<select id="new-feed-schedule" class="w-full">
<option value="*/30 * * * *">Alle 30 Minuten</option>
<option value="*/60 * * * *">Stündlich</option>
<option value="*/120 * * * *">Alle 2 Stunden</option>
<option value="*/180 * * * *">Alle 3 Stunden</option>
<option value="0 8,14,20 * * *">3x täglich (8/14/20 Uhr)</option>
</select>
</div>
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input type="checkbox" id="new-feed-auto" class="rounded"> Auto-Publish
</label>
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input type="checkbox" id="new-feed-ki" class="rounded"> KI-Rewrite
</label>
<label class="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input type="checkbox" id="new-feed-teaser" class="rounded" checked> Nur Teaser
</label>
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Blacklist (kommagetrennt)</label>
<input type="text" id="new-feed-blacklist" class="w-full text-xs" placeholder="Anzeige:, Sponsored, Werbung">
</div>
</div>
<div class="flex gap-2 mt-5">
<button onclick="submitAddFeed()" class="btn btn-primary flex-1">Feed hinzufügen</button>
<button onclick="hideAddFeed()" class="btn btn-ghost">Abbrechen</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
function showAddFeed() { document.getElementById('add-feed-modal').classList.remove('hidden'); }
function hideAddFeed() { document.getElementById('add-feed-modal').classList.add('hidden'); }
async function submitAddFeed() {
const data = {
name: document.getElementById('new-feed-name').value,
url: document.getElementById('new-feed-url').value,
schedule: document.getElementById('new-feed-schedule').value,
active: 1,
auto_publish: document.getElementById('new-feed-auto').checked ? 1 : 0,
ki_rewrite: document.getElementById('new-feed-ki').checked ? 1 : 0,
teaser_only: document.getElementById('new-feed-teaser').checked ? 1 : 0,
blacklist: document.getElementById('new-feed-blacklist').value,
};
if (!data.name || !data.url) { showToast('⚠️ Name und URL erforderlich'); return; }
const r = await fetch('/api/feeds/add', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const d = await r.json();
if (d.success) { showToast('✅ Feed hinzugefügt'); setTimeout(() => location.reload(), 1000); }
else showToast('❌ Fehler');
}
async function fetchFeedNow(id, btn) {
btn.textContent = '⟳ ...';
const r = await fetch(`/api/feeds/${id}/fetch`, {method:'POST'});
const d = await r.json();
if (d.success) showToast(`✅ ${d.new_items} neue Artikel`);
btn.textContent = '🔄 Abrufen';
}
async function deleteFeed(id, name) {
if (!confirm(`Feed "${name}" wirklich löschen?`)) return;
const r = await fetch(`/api/feeds/${id}/delete`, {method:'POST'});
const d = await r.json();
if (d.success) { showToast('🗑️ Feed gelöscht'); setTimeout(() => location.reload(), 1000); }
}
async function approveItem(id) {
const r = await fetch(`/api/queue/${id}/approve`, {method:'POST'});
const d = await r.json();
if (d.success) {
showToast('✅ Artikel übernommen');
document.getElementById(`queue-item-${id}`).remove();
}
}
async function rejectItem(id) {
const r = await fetch(`/api/queue/${id}/reject`, {method:'POST'});
const d = await r.json();
if (d.success) document.getElementById(`queue-item-${id}`).remove();
}
{% endblock %}

View file

@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Hilfe{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-6 py-8 space-y-8">
<h1 class="text-2xl font-bold text-white">❓ Hilfe & Anleitung</h1>
<div class="card p-6 space-y-3">
<h2 class="text-white font-semibold text-lg">✍️ Artikel schreiben</h2>
<ol class="space-y-2 text-sm text-slate-300 list-decimal list-inside">
<li>Quelle (URL oder Text) in das Feld "Quelle" einfügen</li>
<li>Ton wählen: Informativ / Meinungsstark / Reportage</li>
<li><strong class="text-white">KI generieren</strong> klicken — Titel, Inhalt und SEO-Felder werden automatisch ausgefüllt</li>
<li>Artikel in der rechten Vorschau-Spalte prüfen, bei Bedarf direkt im Editor anpassen</li>
<li>Featured Image, Kategorie und SEO-Felder kontrollieren</li>
<li>Veröffentlichen: <strong class="text-white">Sofort</strong>, <strong class="text-white">Entwurf</strong> oder <strong class="text-white">Einplanen</strong></li>
</ol>
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-3 text-xs text-blue-300">
KI-Artikel werden automatisch per Telegram-Teaser an den konfigurierten Kanal gesendet.
</div>
</div>
<div class="card p-6 space-y-3">
<h2 class="text-white font-semibold text-lg">📡 RSS-Feeds</h2>
<div class="space-y-2 text-sm text-slate-300">
<p>Feeds werden unter <a href="/feeds" class="text-blue-400">Feeds</a> verwaltet.</p>
<p><strong class="text-white">Drei Modi pro Feed:</strong></p>
<ul class="space-y-1 list-disc list-inside ml-2">
<li><strong class="text-white">Manuell:</strong> Neue Artikel landen in der Queue — du entscheidest ob sie übernommen werden</li>
<li><strong class="text-white">Auto-Publish + Teaser:</strong> Artikel werden automatisch als Teaser mit Link zur Quelle veröffentlicht</li>
<li><strong class="text-white">Auto-Publish + KI-Rewrite:</strong> KI schreibt den Artikel um, dann automatisch live</li>
</ul>
<div class="bg-slate-700/30 border border-slate-700 rounded p-2 text-xs text-slate-400 mt-2">
RSS-Artikel erscheinen nie auf Telegram — nur auf WordPress.
</div>
</div>
</div>
<div class="card p-6 space-y-3">
<h2 class="text-white font-semibold text-lg">📅 Redaktionsplan</h2>
<div class="text-sm text-slate-300 space-y-2">
<p>Der Redaktionsplan (rechts auf der Startseite) zeigt alle geplanten Artikel der nächsten 7 Tage.</p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li>🤖 = KI-generierter Artikel</li>
<li>📡 = RSS-importierter Artikel</li>
<li><strong class="text-white">🔄 Umplanen:</strong> Datum und Uhrzeit direkt im Board ändern</li>
<li><strong class="text-white">🗑️ Löschen:</strong> Artikel aus der Planung entfernen</li>
<li>Klick auf den Titel: Artikel im Studio öffnen und bearbeiten</li>
</ul>
</div>
</div>
<div class="card p-6 space-y-3">
<h2 class="text-white font-semibold text-lg">⚙️ Einstellungen & Konfiguration</h2>
<div class="text-sm text-slate-300 space-y-2">
<p>Alle Zugangsdaten und Verbindungen werden in der <code class="bg-slate-700 px-1 rounded">.env</code> Datei konfiguriert.</p>
<p>Vorlage: <code class="bg-slate-700 px-1 rounded">.env.example</code> im Repo-Root kopieren und ausfüllen.</p>
<p>Unter <a href="/settings" class="text-blue-400">Einstellungen</a> kann die WordPress-Verbindung getestet werden.</p>
</div>
</div>
<div class="card p-6">
<h2 class="text-white font-semibold text-lg mb-3">❓ FAQ</h2>
<div class="space-y-4 text-sm">
<div>
<div class="text-white font-medium">Artikel wird nicht veröffentlicht?</div>
<div class="text-slate-400 mt-1">WordPress-Verbindung unter Einstellungen testen. WP_APP_PASSWORD muss ein gültiges Application Password sein (in WP-Admin unter Benutzer → Profil erstellen).</div>
</div>
<div>
<div class="text-white font-medium">Kein Telegram-Teaser angekommen?</div>
<div class="text-slate-400 mt-1">TELEGRAM_BOT_TOKEN und TELEGRAM_CHANNEL_ID in .env prüfen. Der Bot muss Admin im Kanal sein.</div>
</div>
<div>
<div class="text-white font-medium">RSS-Feed liefert keine Artikel?</div>
<div class="text-slate-400 mt-1">Auf der Feeds-Seite "Abrufen" klicken und die Fehlermeldung unter dem Feed prüfen.</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Redax-WP — History{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-6 py-6">
<h1 class="text-xl font-bold text-white mb-6">📋 Veröffentlichte Artikel</h1>
<div class="space-y-3">
{% for art in articles %}
<div class="card p-4 flex items-center gap-4">
<span class="text-lg">{% if art.article_type == 'rss' %}📡{% else %}🤖{% endif %}</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-white truncate">{{ art.title or 'Kein Titel' }}</div>
<div class="text-xs text-slate-500 mt-0.5">
{{ art.published_at[:16] if art.published_at else '' }}
{% if art.category_id %} · Kategorie {{ art.category_id }}{% endif %}
</div>
</div>
{% if art.wp_url %}
<a href="{{ art.wp_url }}" target="_blank"
class="text-xs text-blue-400 hover:text-blue-300 shrink-0">🔗 Artikel</a>
{% endif %}
</div>
{% else %}
<div class="card p-8 text-center text-slate-600">Noch keine veröffentlichten Artikel.</div>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,378 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Studio{% endblock %}
{% block content %}
<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">
{% 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 %}
{% if queue_count > 0 %}
<a href="/feeds" class="text-yellow-400 hover:text-yellow-300">📥 {{ queue_count }} RSS-Artikel in Queue</a>
{% endif %}
</div>
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- ═══ STUDIO (links, 2/3) ═══ -->
<div class="xl:col-span-2 space-y-4">
<!-- Artikel-Generator -->
<div class="card p-5">
<h2 class="text-base font-semibold text-white mb-4">✍️ Artikel-Studio</h2>
<!-- Quelle + Ton -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-4">
<div class="md:col-span-2">
<label class="text-xs text-slate-400 block mb-1">Quelle (URL oder Text)</label>
<input type="url" id="source-input" placeholder="https://..." class="w-full">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Ton</label>
<select id="tone-select" class="w-full">
<option value="informativ">Informativ</option>
<option value="meinungsstark">Meinungsstark</option>
<option value="reportage">Reportage</option>
</select>
</div>
</div>
<!-- Titel -->
<div class="mb-3">
<label class="text-xs text-slate-400 block mb-1">Titel</label>
<input type="text" id="article-title" placeholder="Artikel-Titel" class="w-full">
</div>
<!-- Zwei-Spalten Editor -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<label class="text-xs text-slate-400 block mb-1">Inhalt (HTML)</label>
<textarea id="article-content" rows="14" placeholder="Artikel-Inhalt..."
oninput="updatePreview()"></textarea>
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">WordPress-Vorschau</label>
<div id="wp-preview"
class="bg-white text-slate-900 rounded-lg p-4 text-sm overflow-y-auto"
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>
</div>
<!-- SEO-Panel -->
<div class="bg-slate-900/50 border border-slate-700 rounded-lg p-3 mb-4">
<div class="text-xs text-slate-400 font-semibold mb-2">🔍 SEO</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div>
<label class="text-xs text-slate-500 block mb-1">Meta-Title <span id="seo-title-count" class="text-slate-600">0/60</span></label>
<input type="text" id="seo-title" placeholder="SEO Titel" class="w-full text-xs" oninput="document.getElementById('seo-title-count').textContent=this.value.length+'/60'">
</div>
<div>
<label class="text-xs text-slate-500 block mb-1">Meta-Description <span id="seo-desc-count" class="text-slate-600">0/155</span></label>
<input type="text" id="seo-description" placeholder="Kurzbeschreibung" class="w-full text-xs" oninput="document.getElementById('seo-desc-count').textContent=this.value.length+'/155'">
</div>
<div>
<label class="text-xs text-slate-500 block mb-1">Fokus-Keyword</label>
<input type="text" id="focus-keyword" placeholder="Hauptkeyword" class="w-full text-xs">
</div>
</div>
</div>
<!-- Medien + Kategorie -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div>
<label class="text-xs text-slate-400 block mb-1">Featured Image URL</label>
<input type="url" id="featured-image" placeholder="https://..." class="w-full text-xs">
</div>
<div>
<label class="text-xs text-slate-400 block mb-1">Kategorie</label>
<select id="category-select" class="w-full text-xs">
<option value="">— keine —</option>
{% for cat in wp_categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
</select>
</div>
</div>
<!-- Aktions-Buttons -->
<div class="flex gap-2 flex-wrap">
<button onclick="generateArticle()" class="btn btn-primary">
<span id="gen-spinner" class="spinner hidden"></span> 🤖 KI generieren
</button>
<button onclick="saveDraft()" class="btn btn-ghost">💾 Entwurf</button>
<button onclick="publishNow()" class="btn btn-success">🚀 Sofort veröffentlichen</button>
<button onclick="toggleSchedulePanel()" class="btn" style="background:#4c1d95;color:#fff">📅 Einplanen</button>
</div>
<!-- Einplan-Panel -->
<div id="schedule-panel" class="hidden mt-4 bg-slate-900 border border-slate-700 rounded-lg p-4">
<div class="text-sm text-purple-400 font-semibold mb-3">📅 Artikel einplanen</div>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="text-xs text-slate-500 block mb-1">Datum</label>
<input type="date" id="schedule-date" class="text-xs" onchange="checkSlot()">
</div>
<div>
<label class="text-xs text-slate-500 block mb-1">Uhrzeit (15-Min-Slots)</label>
<input type="time" id="schedule-time" value="19:00" step="900" class="text-xs" onchange="checkSlot()">
</div>
<button onclick="confirmSchedule()" id="schedule-confirm-btn"
class="btn btn-primary text-xs">✓ Einplanen</button>
<button onclick="toggleSchedulePanel()" class="text-xs text-slate-500 hover:text-white px-2 py-1.5">Abbrechen</button>
</div>
<div id="slot-status" class="text-xs mt-2 hidden"></div>
</div>
</div>
</div>
<!-- ═══ REDAKTIONSPLAN (rechts, 1/3) ═══ -->
<div class="space-y-4">
<div class="card p-5">
<h2 class="text-base font-semibold text-white mb-4">📅 Redaktionsplan — 7 Tage</h2>
<div class="space-y-1">
{% set status_icons = {'draft':'📝','scheduled':'🗓️','published':'📤'} %}
{% set type_icons = {'ki':'🤖','rss':'📡'} %}
{% for d in plan_days %}
{% set arts = plan_articles.get(d, []) %}
{% set is_today = (d == today) %}
<div class="flex items-center gap-2 pt-2 pb-0.5 px-1 {% if is_today %}text-blue-400{% else %}text-slate-500{% endif %}">
<span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
{% if is_today %}<span class="text-xs bg-blue-900/40 border border-blue-800 text-blue-400 px-2 py-0.5 rounded-full">Heute</span>{% endif %}
{% if not arts %}<span class="text-xs text-slate-700 italic">— leer</span>{% endif %}
</div>
{% for art in arts %}
<div class="rounded-lg border border-slate-700/50 hover:border-slate-600 bg-slate-800/30 transition" id="plan-row-{{ art.id }}">
<div class="flex items-center gap-2 px-3 py-2 cursor-pointer" onclick="loadArticle({{ art.id }})">
<span class="text-sm">{{ type_icons.get(art.article_type, '📝') }}</span>
<span class="text-xs font-mono text-slate-500 w-12 shrink-0">{{ art.post_time }}</span>
<div class="flex-1 min-w-0">
<div class="text-xs text-slate-300 truncate">{{ (art.title or 'Kein Titel')[:55] }}</div>
</div>
<span class="text-xs {{ 'status-published' if art.status == 'published' else 'status-scheduled' if art.status == 'scheduled' else 'status-draft' }}">
{{ {'draft':'Entwurf','scheduled':'Geplant','published':'Live'}.get(art.status, art.status) }}
</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">
<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>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
<!-- hidden article id -->
<input type="hidden" id="current-article-id" value="">
{% endblock %}
{% block extra_js %}
let currentArticleId = null;
function updatePreview() {
const content = document.getElementById('article-content').value;
const title = document.getElementById('article-title').value;
const preview = document.getElementById('wp-preview');
preview.innerHTML = (title ? `<h1 style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">${title}</h1>` : '') + content;
}
async function generateArticle() {
const source = document.getElementById('source-input').value.trim();
const tone = document.getElementById('tone-select').value;
if (!source) { showToast('⚠️ Bitte Quelle eingeben'); return; }
document.getElementById('gen-spinner').classList.remove('hidden');
const r = await fetch('/api/generate', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({source, tone})
});
document.getElementById('gen-spinner').classList.add('hidden');
const d = await r.json();
if (d.error) { showToast('❌ ' + d.error); return; }
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
document.getElementById('seo-title').value = d.seo_title || '';
document.getElementById('seo-description').value = d.seo_description || '';
document.getElementById('focus-keyword').value = d.focus_keyword || '';
updatePreview();
// og:image automatisch holen
if (source.startsWith('http')) {
fetchOgImage(source);
}
showToast('✅ Artikel generiert');
}
async function fetchOgImage(url) {
try {
const r = await fetch('/api/og-image', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({url})
});
const d = await r.json();
if (d.image) document.getElementById('featured-image').value = d.image;
} catch(e) {}
}
function getArticleData() {
return {
id: currentArticleId,
title: document.getElementById('article-title').value,
content: document.getElementById('article-content').value,
source_url: document.getElementById('source-input').value,
tone: document.getElementById('tone-select').value,
seo_title: document.getElementById('seo-title').value,
seo_description: document.getElementById('seo-description').value,
focus_keyword: document.getElementById('focus-keyword').value,
featured_image_url: document.getElementById('featured-image').value,
category_id: document.getElementById('category-select').value || null,
article_type: 'ki',
};
}
async function saveDraft() {
const r = await fetch('/api/article/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...getArticleData(), status: 'draft'})
});
const d = await r.json();
if (d.success) { currentArticleId = d.id; showToast('💾 Entwurf gespeichert'); }
}
async function publishNow() {
if (!document.getElementById('article-title').value) { showToast('⚠️ Titel fehlt'); return; }
const r = await fetch('/api/article/schedule', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
...getArticleData(),
status: 'scheduled',
post_date: new Date().toISOString().split('T')[0],
post_time: new Date().toTimeString().slice(0,5),
})
});
const d = await r.json();
if (d.success) { currentArticleId = d.id; showToast('🚀 Wird sofort veröffentlicht!'); setTimeout(() => location.reload(), 2000); }
}
function toggleSchedulePanel() {
const p = document.getElementById('schedule-panel');
p.classList.toggle('hidden');
if (!p.classList.contains('hidden')) {
const today = new Date().toISOString().split('T')[0];
document.getElementById('schedule-date').value = today;
}
}
async function checkSlot() {
const date = document.getElementById('schedule-date').value;
const time = document.getElementById('schedule-time').value;
if (!date || !time) return;
const r = await fetch(`/api/slots/${date}`);
const d = await r.json();
const statusEl = document.getElementById('slot-status');
statusEl.classList.remove('hidden');
if (d.taken && d.taken.includes(time)) {
statusEl.className = 'text-xs mt-2 text-red-400';
statusEl.textContent = `❌ Slot ${date} ${time} bereits belegt`;
document.getElementById('schedule-confirm-btn').disabled = true;
} else {
statusEl.className = 'text-xs mt-2 text-green-400';
statusEl.textContent = `✅ Slot ${date} ${time} ist frei`;
document.getElementById('schedule-confirm-btn').disabled = false;
}
}
async function confirmSchedule() {
const post_date = document.getElementById('schedule-date').value;
const post_time = document.getElementById('schedule-time').value;
if (!post_date || !post_time) { showToast('⚠️ Datum und Uhrzeit wählen'); return; }
if (!document.getElementById('article-title').value) { showToast('⚠️ Titel fehlt'); return; }
const r = await fetch('/api/article/schedule', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...getArticleData(), post_date, post_time})
});
const d = await r.json();
if (d.success) {
showToast(`📅 Eingeplant: ${post_date} ${post_time}`);
setTimeout(() => location.reload(), 1500);
} else {
showToast('❌ Fehler beim Einplanen');
}
}
async function loadArticle(id) {
const r = await fetch(`/api/article/${id}`);
const d = await r.json();
currentArticleId = id;
document.getElementById('article-title').value = d.title || '';
document.getElementById('article-content').value = d.content || '';
document.getElementById('source-input').value = d.source_url || '';
document.getElementById('seo-title').value = d.seo_title || '';
document.getElementById('seo-description').value = d.seo_description || '';
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;
updatePreview();
window.scrollTo({top: 0, behavior: 'smooth'});
}
// ── Board: Umplanen ──
function openReschedule(id, date, time) {
document.querySelectorAll('[id^="rs-panel-"]').forEach(el => el.classList.add('hidden'));
document.getElementById(`rs-panel-${id}`).classList.remove('hidden');
}
function closeReschedule(id) {
document.getElementById(`rs-panel-${id}`).classList.add('hidden');
}
async function confirmReschedule(id) {
const date = document.getElementById(`rs-date-${id}`).value;
const time = document.getElementById(`rs-time-${id}`).value;
const r = await fetch(`/api/article/${id}/reschedule`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({post_date: date, post_time: time})
});
const d = await r.json();
if (d.success) { showToast(`✅ Umgeplant: ${date} ${time}`); setTimeout(() => location.reload(), 1200); }
else showToast('❌ ' + (d.error || 'Fehler'));
}
async function deleteArticle(id) {
if (!confirm('Artikel wirklich löschen?')) return;
const r = await fetch(`/api/article/${id}/delete`, {method: 'POST'});
const d = await r.json();
if (d.success) { showToast('🗑️ Gelöscht'); setTimeout(() => location.reload(), 1000); }
}
// Datum-Vorauswahl
document.addEventListener('DOMContentLoaded', () => {
const today = new Date().toISOString().split('T')[0];
document.getElementById('schedule-date').value = today;
});
{% endblock %}

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Redax-WP — Login</title>
<link rel="stylesheet" href="/static/tailwind.min.css">
<style>body { background: #0f172a; color: #e2e8f0; }</style>
</head>
<body class="min-h-screen flex items-center justify-center">
<div class="w-full max-w-sm">
<div class="text-center mb-8">
<div class="text-5xl mb-3">📝</div>
<h1 class="text-2xl font-bold text-white">Redax-WP</h1>
<p class="text-slate-400 text-sm mt-1">KI-Redaktion für WordPress</p>
</div>
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
<form method="POST">
{% if error %}
<div class="bg-red-900/40 border border-red-700 text-red-300 text-sm px-4 py-3 rounded-lg mb-4">{{ error }}</div>
{% endif %}
<div class="mb-4">
<label class="text-xs text-slate-400 block mb-1">Benutzername</label>
<input type="text" name="user" autofocus
class="w-full bg-slate-900 border border-slate-600 text-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500">
</div>
<div class="mb-6">
<label class="text-xs text-slate-400 block mb-1">Passwort</label>
<input type="password" name="password"
class="w-full bg-slate-900 border border-slate-600 text-white rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500">
</div>
<button type="submit"
class="w-full bg-blue-600 hover:bg-blue-500 text-white font-semibold py-2 rounded-lg transition">
Anmelden
</button>
</form>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Prompts{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-6 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-bold text-white">🧠 Prompt-Bibliothek</h1>
<button onclick="newPrompt()" class="btn btn-primary text-sm">+ Neuer Prompt</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="space-y-3">
{% for p in prompts %}
<div class="card p-4 cursor-pointer hover:border-blue-500 transition {% if p.is_default %}border-green-700{% endif %}"
onclick="loadPrompt({{ p.id }}, {{ p.system_prompt|tojson }}, {{ p.name|tojson }}, {{ p.is_default }})">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-white">{{ p.name }}</span>
{% if p.is_default %}<span class="text-xs bg-green-900 text-green-300 px-2 py-0.5 rounded-full">Standard</span>{% endif %}
</div>
{% if not p.is_default %}
<button onclick="event.stopPropagation(); deletePrompt({{ p.id }})"
class="text-slate-600 hover:text-red-400 text-xs px-1">🗑</button>
{% endif %}
</div>
<div class="text-xs text-slate-500 mt-1 truncate">{{ p.system_prompt[:80] }}...</div>
</div>
{% endfor %}
</div>
<div class="card p-5">
<input type="hidden" id="prompt-id">
<div class="mb-3">
<label class="text-xs text-slate-400 block mb-1">Name</label>
<input type="text" id="prompt-name" class="w-full" placeholder="Prompt-Name">
</div>
<div class="mb-3">
<label class="text-xs text-slate-400 block mb-1">
System-Prompt
<span class="text-slate-600 ml-2">Variablen: {source} {date} {tone}</span>
</label>
<textarea id="prompt-text" rows="12" class="w-full text-sm"></textarea>
</div>
<div class="flex gap-2 flex-wrap">
<button onclick="savePrompt()" class="btn btn-primary text-sm">💾 Speichern</button>
<button onclick="setDefault()" class="btn btn-success text-sm">⭐ Standard</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
function loadPrompt(id, text, name, is_default) {
document.getElementById('prompt-id').value = id;
document.getElementById('prompt-name').value = name;
document.getElementById('prompt-text').value = text;
}
function newPrompt() {
document.getElementById('prompt-id').value = '';
document.getElementById('prompt-name').value = '';
document.getElementById('prompt-text').value = '';
}
async function savePrompt() {
const r = await fetch('/api/prompts/save', {method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({id: document.getElementById('prompt-id').value || null,
name: document.getElementById('prompt-name').value,
system_prompt: document.getElementById('prompt-text').value})});
if ((await r.json()).success) { showToast('💾 Gespeichert'); location.reload(); }
}
async function setDefault() {
const id = document.getElementById('prompt-id').value;
if (!id) { showToast('⚠️ Prompt zuerst speichern'); return; }
await fetch(`/api/prompts/${id}/default`, {method:'POST'});
showToast('⭐ Als Standard gesetzt'); location.reload();
}
async function deletePrompt(id) {
if (!confirm('Prompt löschen?')) return;
await fetch(`/api/prompts/${id}/delete`, {method:'POST'});
location.reload();
}
{% endblock %}

View file

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Redax-WP — Einstellungen{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto px-6 py-6 space-y-6">
<h1 class="text-xl font-bold text-white">⚙️ Einstellungen</h1>
<div class="card p-6">
<h2 class="text-white font-semibold mb-1">🔌 WordPress-Verbindung</h2>
<p class="text-xs text-slate-500 mb-4">Konfigurierbar via <code class="bg-slate-700 px-1 rounded">.env</code> — Neustart nach Änderung erforderlich.</p>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-3">
<span class="text-slate-500 w-28">WP_URL</span>
<span class="text-slate-300 font-mono">{{ wp_url }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-slate-500 w-28">WP_USERNAME</span>
<span class="text-slate-300 font-mono">{{ wp_username }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-slate-500 w-28">WP_APP_PASSWORD</span>
<span class="text-slate-400">●●●●●●●● (in .env)</span>
</div>
</div>
<div class="mt-4">
<button onclick="testWpConnection()" class="btn btn-ghost text-sm">🔍 Verbindung testen</button>
<span id="wp-test-result" class="text-sm ml-3 hidden"></span>
</div>
</div>
<div class="card p-6">
<h2 class="text-white font-semibold mb-1">⏰ Zeitzone</h2>
<p class="text-sm text-slate-400">Aktuell: <span class="text-white font-mono">{{ timezone }}</span></p>
<p class="text-xs text-slate-600 mt-1">Änderbar via <code class="bg-slate-700 px-1 rounded">TIMEZONE</code> in .env</p>
</div>
<div class="card p-6">
<h2 class="text-white font-semibold mb-3">📋 .env Variablen</h2>
<div class="bg-slate-900 rounded-lg p-4 text-xs font-mono text-slate-400 space-y-1">
<div>DASHBOARD_USER / DASHBOARD_PASSWORD</div>
<div>WP_URL / WP_USERNAME / WP_APP_PASSWORD</div>
<div>OPENROUTER_API_KEY</div>
<div>TELEGRAM_BOT_TOKEN / TELEGRAM_CHANNEL_ID</div>
<div>TELEGRAM_REVIEWER_IDS</div>
<div>TIMEZONE</div>
<div>MYSQL_ROOT_PASSWORD / MYSQL_DATABASE / MYSQL_USER / MYSQL_PASSWORD</div>
</div>
<p class="text-xs text-slate-600 mt-3">Vorlage: <code class="bg-slate-700 px-1 rounded">.env.example</code> im Repo-Root</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
async function testWpConnection() {
const r = await fetch('/api/wp/categories');
const el = document.getElementById('wp-test-result');
el.classList.remove('hidden');
if (r.ok) {
const d = await r.json();
el.className = 'text-sm ml-3 text-green-400';
el.textContent = `✅ Verbunden — ${Array.isArray(d) ? d.length : 0} Kategorien gefunden`;
} else {
el.className = 'text-sm ml-3 text-red-400';
el.textContent = '❌ WordPress nicht erreichbar';
}
}
{% endblock %}

121
redax-wp/src/wordpress.py Normal file
View file

@ -0,0 +1,121 @@
import os
import requests
from requests.auth import HTTPBasicAuth
import logger as flog
class WordPressClient:
def __init__(self):
self.base_url = os.environ.get('WP_URL', 'http://wordpress').rstrip('/')
self.api_url = f"{self.base_url}/wp-json/wp/v2"
self.username = os.environ.get('WP_USERNAME', 'admin')
self.app_password = os.environ.get('WP_APP_PASSWORD', '')
self.auth = HTTPBasicAuth(self.username, self.app_password)
def _get(self, endpoint, params=None):
r = requests.get(f"{self.api_url}/{endpoint}", auth=self.auth,
params=params or {}, timeout=15)
r.raise_for_status()
return r.json()
def _post(self, endpoint, data):
r = requests.post(f"{self.api_url}/{endpoint}", auth=self.auth,
json=data, timeout=30)
r.raise_for_status()
return r.json()
def _put(self, endpoint, data):
r = requests.put(f"{self.api_url}/{endpoint}", auth=self.auth,
json=data, timeout=30)
r.raise_for_status()
return r.json()
def is_reachable(self) -> bool:
try:
requests.get(f"{self.base_url}/wp-json/", timeout=5)
return True
except Exception:
return False
def get_categories(self) -> list:
try:
return self._get('categories', {'per_page': 100, 'hide_empty': False})
except Exception as e:
flog.error('wp_get_categories_failed', error=str(e))
return []
def get_tags(self) -> list:
try:
return self._get('tags', {'per_page': 100})
except Exception as e:
flog.error('wp_get_tags_failed', error=str(e))
return []
def upload_media(self, image_url: str, filename: str = 'featured.jpg') -> int | None:
"""Download image from URL and upload to WordPress Media Library."""
try:
img_response = requests.get(image_url, timeout=15)
img_response.raise_for_status()
content_type = img_response.headers.get('Content-Type', 'image/jpeg')
r = requests.post(
f"{self.api_url}/media",
auth=self.auth,
headers={
'Content-Disposition': f'attachment; filename="{filename}"',
'Content-Type': content_type,
},
data=img_response.content,
timeout=30
)
r.raise_for_status()
media_id = r.json()['id']
flog.info('wp_media_uploaded', media_id=media_id, url=image_url)
return media_id
except Exception as e:
flog.error('wp_media_upload_failed', error=str(e), url=image_url)
return None
def create_post(self, title: str, content: str, status: str = 'publish',
scheduled_at: str = None, category_ids: list = None,
tag_ids: list = None, featured_media_id: int = None,
seo_title: str = None, seo_description: str = None,
focus_keyword: str = None) -> dict:
"""
Create a WordPress post.
status: 'publish' | 'draft' | 'future' (requires scheduled_at)
Returns: {'id': int, 'url': str}
"""
data = {
'title': title,
'content': content,
'status': status,
}
if scheduled_at and status == 'future':
data['date'] = scheduled_at
if category_ids:
data['categories'] = category_ids
if tag_ids:
data['tags'] = tag_ids
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:
data['meta']['_yoast_wpseo_title'] = seo_title
if seo_description:
data['meta']['_yoast_wpseo_metadesc'] = seo_description
if focus_keyword:
data['meta']['_yoast_wpseo_focuskw'] = focus_keyword
result = self._post('posts', data)
return {'id': result['id'], 'url': result['link']}
def update_post(self, wp_post_id: int, **kwargs) -> dict:
result = self._put(f'posts/{wp_post_id}', kwargs)
return {'id': result['id'], 'url': result['link']}
def get_post(self, wp_post_id: int) -> dict:
return self._get(f'posts/{wp_post_id}')