From 064ae085b55e95227e054c232fc758fb69bd2426 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 07:52:24 +0700 Subject: [PATCH] =?UTF-8?q?redax-wp:=20Sprint=201+2=20=E2=80=94=20vollst?= =?UTF-8?q?=C3=A4ndiger=20Stack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- redax-wp/.env.example | 32 ++ redax-wp/.gitignore | 7 + redax-wp/README.md | 90 ++++++ redax-wp/docker-compose.yml | 57 ++++ redax-wp/src/Dockerfile.web | 22 ++ redax-wp/src/app.py | 456 +++++++++++++++++++++++++++ redax-wp/src/database.py | 406 ++++++++++++++++++++++++ redax-wp/src/logger.py | 100 ++++++ redax-wp/src/openrouter.py | 72 +++++ redax-wp/src/requirements.txt | 10 + redax-wp/src/rss_fetcher.py | 157 +++++++++ redax-wp/src/templates/base.html | 79 +++++ redax-wp/src/templates/feeds.html | 182 +++++++++++ redax-wp/src/templates/hilfe.html | 79 +++++ redax-wp/src/templates/history.html | 27 ++ redax-wp/src/templates/index.html | 378 ++++++++++++++++++++++ redax-wp/src/templates/login.html | 40 +++ redax-wp/src/templates/prompts.html | 78 +++++ redax-wp/src/templates/settings.html | 65 ++++ redax-wp/src/wordpress.py | 121 +++++++ 20 files changed, 2458 insertions(+) create mode 100644 redax-wp/.env.example create mode 100644 redax-wp/.gitignore create mode 100644 redax-wp/README.md create mode 100644 redax-wp/docker-compose.yml create mode 100644 redax-wp/src/Dockerfile.web create mode 100644 redax-wp/src/app.py create mode 100644 redax-wp/src/database.py create mode 100644 redax-wp/src/logger.py create mode 100644 redax-wp/src/openrouter.py create mode 100644 redax-wp/src/requirements.txt create mode 100644 redax-wp/src/rss_fetcher.py create mode 100644 redax-wp/src/templates/base.html create mode 100644 redax-wp/src/templates/feeds.html create mode 100644 redax-wp/src/templates/hilfe.html create mode 100644 redax-wp/src/templates/history.html create mode 100644 redax-wp/src/templates/index.html create mode 100644 redax-wp/src/templates/login.html create mode 100644 redax-wp/src/templates/prompts.html create mode 100644 redax-wp/src/templates/settings.html create mode 100644 redax-wp/src/wordpress.py diff --git a/redax-wp/.env.example b/redax-wp/.env.example new file mode 100644 index 00000000..e771bfe6 --- /dev/null +++ b/redax-wp/.env.example @@ -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 diff --git a/redax-wp/.gitignore b/redax-wp/.gitignore new file mode 100644 index 00000000..e65e2a67 --- /dev/null +++ b/redax-wp/.gitignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +*.pyo +data/ +logs/ +*.db diff --git a/redax-wp/README.md b/redax-wp/README.md new file mode 100644 index 00000000..8aaeb18b --- /dev/null +++ b/redax-wp/README.md @@ -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 diff --git a/redax-wp/docker-compose.yml b/redax-wp/docker-compose.yml new file mode 100644 index 00000000..7262f1a3 --- /dev/null +++ b/redax-wp/docker-compose.yml @@ -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 diff --git a/redax-wp/src/Dockerfile.web b/redax-wp/src/Dockerfile.web new file mode 100644 index 00000000..c7d85d3d --- /dev/null +++ b/redax-wp/src/Dockerfile.web @@ -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"] diff --git a/redax-wp/src/app.py b/redax-wp/src/app.py new file mode 100644 index 00000000..6332e6d5 --- /dev/null +++ b/redax-wp/src/app.py @@ -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//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//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/', 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/') +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//update', methods=['POST']) +def api_update_feed(feed_id): + db.update_feed(feed_id, request.json) + return jsonify({'success': True}) + + +@app.route('/api/feeds//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//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//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//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//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//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) diff --git a/redax-wp/src/database.py b/redax-wp/src/database.py new file mode 100644 index 00000000..bc6375a1 --- /dev/null +++ b/redax-wp/src/database.py @@ -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,

,

    , ) +- 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() diff --git a/redax-wp/src/logger.py b/redax-wp/src/logger.py new file mode 100644 index 00000000..2adf7796 --- /dev/null +++ b/redax-wp/src/logger.py @@ -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) diff --git a/redax-wp/src/openrouter.py b/redax-wp/src/openrouter.py new file mode 100644 index 00000000..47e6e107 --- /dev/null +++ b/redax-wp/src/openrouter.py @@ -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()) diff --git a/redax-wp/src/requirements.txt b/redax-wp/src/requirements.txt new file mode 100644 index 00000000..e7b26488 --- /dev/null +++ b/redax-wp/src/requirements.txt @@ -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 diff --git a/redax-wp/src/rss_fetcher.py b/redax-wp/src/rss_fetcher.py new file mode 100644 index 00000000..2f8e8144 --- /dev/null +++ b/redax-wp/src/rss_fetcher.py @@ -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"""

    {clean_summary}

    +

    ➜ Weiterlesen beim Original

    """ + + +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) diff --git a/redax-wp/src/templates/base.html b/redax-wp/src/templates/base.html new file mode 100644 index 00000000..c67642fc --- /dev/null +++ b/redax-wp/src/templates/base.html @@ -0,0 +1,79 @@ + + + + + +{% block title %}Redax-WP{% endblock %} + + +{% block extra_head %}{% endblock %} + + + + + + +
    + +{% block content %}{% endblock %} + + + + diff --git a/redax-wp/src/templates/feeds.html b/redax-wp/src/templates/feeds.html new file mode 100644 index 00000000..2c733bb7 --- /dev/null +++ b/redax-wp/src/templates/feeds.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — Feeds{% endblock %} + +{% block content %} +
    +
    +

    📡 RSS-Feeds

    + +
    + +
    + + +
    +

    Aktive Feeds ({{ feeds|length }})

    +
    + {% for feed in feeds %} +
    +
    +
    + + {{ feed.name }} +
    +
    + + +
    +
    +
    {{ feed.url }}
    +
    + {{ feed.schedule }} + {% if feed.auto_publish %}Auto-Publish{% endif %} + {% if feed.ki_rewrite %}KI-Rewrite{% endif %} + {% if feed.teaser_only %}Teaser{% endif %} +
    + {% if feed.last_error %} +
    ⚠️ {{ feed.last_error[:80] }}
    + {% endif %} + {% if feed.last_fetched_at %} +
    Zuletzt: {{ feed.last_fetched_at[:16] }}
    + {% endif %} +
    + {% else %} +
    Noch keine Feeds konfiguriert.
    + {% endfor %} +
    +
    + + +
    +

    Artikel-Queue ({{ queue|length }} neu)

    +
    + {% for item in queue %} +
    +
    +
    +
    📡 {{ item.feed_name }}
    +
    {{ item.title }}
    + {{ item.url[:60] }}... +
    +
    + + +
    +
    +
    + {% else %} +
    Queue ist leer — alle Artikel verarbeitet.
    + {% endfor %} +
    +
    + +
    +
    + + + +{% 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 %} diff --git a/redax-wp/src/templates/hilfe.html b/redax-wp/src/templates/hilfe.html new file mode 100644 index 00000000..6b8f9d05 --- /dev/null +++ b/redax-wp/src/templates/hilfe.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — Hilfe{% endblock %} +{% block content %} +
    +

    ❓ Hilfe & Anleitung

    + +
    +

    ✍️ Artikel schreiben

    +
      +
    1. Quelle (URL oder Text) in das Feld "Quelle" einfügen
    2. +
    3. Ton wählen: Informativ / Meinungsstark / Reportage
    4. +
    5. KI generieren klicken — Titel, Inhalt und SEO-Felder werden automatisch ausgefüllt
    6. +
    7. Artikel in der rechten Vorschau-Spalte prüfen, bei Bedarf direkt im Editor anpassen
    8. +
    9. Featured Image, Kategorie und SEO-Felder kontrollieren
    10. +
    11. Veröffentlichen: Sofort, Entwurf oder Einplanen
    12. +
    +
    + KI-Artikel werden automatisch per Telegram-Teaser an den konfigurierten Kanal gesendet. +
    +
    + +
    +

    📡 RSS-Feeds

    +
    +

    Feeds werden unter Feeds verwaltet.

    +

    Drei Modi pro Feed:

    +
      +
    • Manuell: Neue Artikel landen in der Queue — du entscheidest ob sie übernommen werden
    • +
    • Auto-Publish + Teaser: Artikel werden automatisch als Teaser mit Link zur Quelle veröffentlicht
    • +
    • Auto-Publish + KI-Rewrite: KI schreibt den Artikel um, dann automatisch live
    • +
    +
    + RSS-Artikel erscheinen nie auf Telegram — nur auf WordPress. +
    +
    +
    + +
    +

    📅 Redaktionsplan

    +
    +

    Der Redaktionsplan (rechts auf der Startseite) zeigt alle geplanten Artikel der nächsten 7 Tage.

    +
      +
    • 🤖 = KI-generierter Artikel
    • +
    • 📡 = RSS-importierter Artikel
    • +
    • 🔄 Umplanen: Datum und Uhrzeit direkt im Board ändern
    • +
    • 🗑️ Löschen: Artikel aus der Planung entfernen
    • +
    • Klick auf den Titel: Artikel im Studio öffnen und bearbeiten
    • +
    +
    +
    + +
    +

    ⚙️ Einstellungen & Konfiguration

    +
    +

    Alle Zugangsdaten und Verbindungen werden in der .env Datei konfiguriert.

    +

    Vorlage: .env.example im Repo-Root kopieren und ausfüllen.

    +

    Unter Einstellungen kann die WordPress-Verbindung getestet werden.

    +
    +
    + +
    +

    ❓ FAQ

    +
    +
    +
    Artikel wird nicht veröffentlicht?
    +
    WordPress-Verbindung unter Einstellungen testen. WP_APP_PASSWORD muss ein gültiges Application Password sein (in WP-Admin unter Benutzer → Profil erstellen).
    +
    +
    +
    Kein Telegram-Teaser angekommen?
    +
    TELEGRAM_BOT_TOKEN und TELEGRAM_CHANNEL_ID in .env prüfen. Der Bot muss Admin im Kanal sein.
    +
    +
    +
    RSS-Feed liefert keine Artikel?
    +
    Auf der Feeds-Seite "Abrufen" klicken und die Fehlermeldung unter dem Feed prüfen.
    +
    +
    +
    +
    +{% endblock %} diff --git a/redax-wp/src/templates/history.html b/redax-wp/src/templates/history.html new file mode 100644 index 00000000..c209eac2 --- /dev/null +++ b/redax-wp/src/templates/history.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — History{% endblock %} +{% block content %} +
    +

    📋 Veröffentlichte Artikel

    +
    + {% for art in articles %} +
    + {% if art.article_type == 'rss' %}📡{% else %}🤖{% endif %} +
    +
    {{ art.title or 'Kein Titel' }}
    +
    + {{ art.published_at[:16] if art.published_at else '' }} + {% if art.category_id %} · Kategorie {{ art.category_id }}{% endif %} +
    +
    + {% if art.wp_url %} + 🔗 Artikel + {% endif %} +
    + {% else %} +
    Noch keine veröffentlichten Artikel.
    + {% endfor %} +
    +
    +{% endblock %} diff --git a/redax-wp/src/templates/index.html b/redax-wp/src/templates/index.html new file mode 100644 index 00000000..4dc37e8c --- /dev/null +++ b/redax-wp/src/templates/index.html @@ -0,0 +1,378 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — Studio{% endblock %} + +{% block content %} +
    + + +
    + {% if last_published %} + Letzter Post: {{ last_published.wp_url[:50] if last_published.wp_url else last_published.title[:40] }} — {{ last_published.published_at[:16] if last_published.published_at else '' }} + {% endif %} + {% if queue_count > 0 %} + 📥 {{ queue_count }} RSS-Artikel in Queue + {% endif %} +
    + +
    + + +
    + + +
    +

    ✍️ Artikel-Studio

    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + +
    + + +
    +
    + + +
    +
    + +
    + Vorschau erscheint beim Tippen... +
    +
    +
    + + +
    +
    🔍 SEO
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + + + + +
    + + + +
    + +
    + + +
    + +
    +

    📅 Redaktionsplan — 7 Tage

    +
    + {% 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) %} + +
    + {{ d[8:] }}.{{ d[5:7] }}. + {% if is_today %}Heute{% endif %} + {% if not arts %}— leer{% endif %} +
    + + {% for art in arts %} +
    +
    + {{ type_icons.get(art.article_type, '📝') }} + {{ art.post_time }} +
    +
    {{ (art.title or 'Kein Titel')[:55] }}
    +
    + + {{ {'draft':'Entwurf','scheduled':'Geplant','published':'Live'}.get(art.status, art.status) }} + + {% if art.status != 'published' %} +
    + + +
    + {% endif %} +
    + + +
    + {% endfor %} + {% endfor %} +
    +
    + +
    +
    +
    + + + +{% 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 ? `

    ${title}

    ` : '') + 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 %} diff --git a/redax-wp/src/templates/login.html b/redax-wp/src/templates/login.html new file mode 100644 index 00000000..7deaf663 --- /dev/null +++ b/redax-wp/src/templates/login.html @@ -0,0 +1,40 @@ + + + + + +Redax-WP — Login + + + + +
    +
    +
    📝
    +

    Redax-WP

    +

    KI-Redaktion für WordPress

    +
    +
    +
    + {% if error %} +
    {{ error }}
    + {% endif %} +
    + + +
    +
    + + +
    + +
    +
    +
    + + diff --git a/redax-wp/src/templates/prompts.html b/redax-wp/src/templates/prompts.html new file mode 100644 index 00000000..b465837e --- /dev/null +++ b/redax-wp/src/templates/prompts.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — Prompts{% endblock %} +{% block content %} +
    +
    +

    🧠 Prompt-Bibliothek

    + +
    +
    +
    + {% for p in prompts %} +
    +
    +
    + {{ p.name }} + {% if p.is_default %}Standard{% endif %} +
    + {% if not p.is_default %} + + {% endif %} +
    +
    {{ p.system_prompt[:80] }}...
    +
    + {% endfor %} +
    +
    + +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +{% 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 %} diff --git a/redax-wp/src/templates/settings.html b/redax-wp/src/templates/settings.html new file mode 100644 index 00000000..9f3e05b8 --- /dev/null +++ b/redax-wp/src/templates/settings.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Redax-WP — Einstellungen{% endblock %} +{% block content %} +
    +

    ⚙️ Einstellungen

    + +
    +

    🔌 WordPress-Verbindung

    +

    Konfigurierbar via .env — Neustart nach Änderung erforderlich.

    +
    +
    + WP_URL + {{ wp_url }} +
    +
    + WP_USERNAME + {{ wp_username }} +
    +
    + WP_APP_PASSWORD + ●●●●●●●● (in .env) +
    +
    +
    + + +
    +
    + +
    +

    ⏰ Zeitzone

    +

    Aktuell: {{ timezone }}

    +

    Änderbar via TIMEZONE in .env

    +
    + +
    +

    📋 .env Variablen

    +
    +
    DASHBOARD_USER / DASHBOARD_PASSWORD
    +
    WP_URL / WP_USERNAME / WP_APP_PASSWORD
    +
    OPENROUTER_API_KEY
    +
    TELEGRAM_BOT_TOKEN / TELEGRAM_CHANNEL_ID
    +
    TELEGRAM_REVIEWER_IDS
    +
    TIMEZONE
    +
    MYSQL_ROOT_PASSWORD / MYSQL_DATABASE / MYSQL_USER / MYSQL_PASSWORD
    +
    +

    Vorlage: .env.example im Repo-Root

    +
    +
    +{% 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 %} diff --git a/redax-wp/src/wordpress.py b/redax-wp/src/wordpress.py new file mode 100644 index 00000000..7d6fdc24 --- /dev/null +++ b/redax-wp/src/wordpress.py @@ -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}')