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:
parent
2fe60d8220
commit
064ae085b5
20 changed files with 2458 additions and 0 deletions
32
redax-wp/.env.example
Normal file
32
redax-wp/.env.example
Normal 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
7
redax-wp/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
data/
|
||||
logs/
|
||||
*.db
|
||||
90
redax-wp/README.md
Normal file
90
redax-wp/README.md
Normal 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
|
||||
57
redax-wp/docker-compose.yml
Normal file
57
redax-wp/docker-compose.yml
Normal 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
|
||||
22
redax-wp/src/Dockerfile.web
Normal file
22
redax-wp/src/Dockerfile.web
Normal 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
456
redax-wp/src/app.py
Normal 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
406
redax-wp/src/database.py
Normal 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
100
redax-wp/src/logger.py
Normal 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)
|
||||
72
redax-wp/src/openrouter.py
Normal file
72
redax-wp/src/openrouter.py
Normal 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())
|
||||
10
redax-wp/src/requirements.txt
Normal file
10
redax-wp/src/requirements.txt
Normal 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
157
redax-wp/src/rss_fetcher.py
Normal 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)
|
||||
79
redax-wp/src/templates/base.html
Normal file
79
redax-wp/src/templates/base.html
Normal 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>
|
||||
182
redax-wp/src/templates/feeds.html
Normal file
182
redax-wp/src/templates/feeds.html
Normal 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 %}
|
||||
79
redax-wp/src/templates/hilfe.html
Normal file
79
redax-wp/src/templates/hilfe.html
Normal 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 %}
|
||||
27
redax-wp/src/templates/history.html
Normal file
27
redax-wp/src/templates/history.html
Normal 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 %}
|
||||
378
redax-wp/src/templates/index.html
Normal file
378
redax-wp/src/templates/index.html
Normal 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 %}
|
||||
40
redax-wp/src/templates/login.html
Normal file
40
redax-wp/src/templates/login.html
Normal 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>
|
||||
78
redax-wp/src/templates/prompts.html
Normal file
78
redax-wp/src/templates/prompts.html
Normal 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 %}
|
||||
65
redax-wp/src/templates/settings.html
Normal file
65
redax-wp/src/templates/settings.html
Normal 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
121
redax-wp/src/wordpress.py
Normal 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}')
|
||||
Loading…
Add table
Reference in a new issue