750 lines
28 KiB
Python
750 lines
28 KiB
Python
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, WordPressMirrorClient
|
||
|
||
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:
|
||
mirror_client = WordPressMirrorClient()
|
||
|
||
# Bild zuerst auf Primary hochladen
|
||
primary_client = WordPressClient()
|
||
media_id = None
|
||
if art.get('featured_image_url'):
|
||
media_id = primary_client.upload_media(art['featured_image_url'])
|
||
|
||
# Manuell deaktivierte Targets aus DB laden
|
||
for t in mirror_client.targets:
|
||
if db.get_setting(f'target_disabled_{t["name"]}', '0') == '1':
|
||
t['enabled'] = False
|
||
|
||
# Auf Primary + alle aktiven Mirrors veröffentlichen
|
||
results = mirror_client.publish_to_all(
|
||
title=art['title'] or 'Ohne Titel',
|
||
content=art['content'] or '',
|
||
status='publish',
|
||
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'),
|
||
)
|
||
|
||
primary_result = results.get('primary')
|
||
if primary_result:
|
||
db.update_article(art['id'], {
|
||
'status': 'published',
|
||
'wp_post_id': primary_result['id'],
|
||
'wp_url': primary_result['url'],
|
||
'published_at': datetime.utcnow().isoformat(),
|
||
})
|
||
db.save_post_history(art['id'], primary_result['id'], primary_result['url'])
|
||
flog.info('article_published', article_id=art['id'], wp_url=primary_result['url'])
|
||
|
||
if art.get('send_to_telegram') and art.get('article_type') == 'ki':
|
||
_send_telegram_teaser(art, primary_result['url'])
|
||
|
||
# Mirror-Ergebnisse speichern
|
||
for m in results.get('mirrors', []):
|
||
db.save_mirror_post(
|
||
article_id=art['id'],
|
||
mirror_name=m['name'],
|
||
mirror_label=m['label'],
|
||
mirror_wp_id=m.get('id'),
|
||
mirror_url=m.get('url'),
|
||
status='ok' if not m.get('error') else 'error',
|
||
error=m.get('error'),
|
||
)
|
||
|
||
except Exception as e:
|
||
flog.error('publish_failed', article_id=art['id'], error=str(e))
|
||
_send_error_alarm(f"❌ Publish fehlgeschlagen: Artikel #{art['id']}\n{str(e)}")
|
||
|
||
|
||
def job_fetch_feeds():
|
||
rss_fetcher.run_all_feeds()
|
||
|
||
|
||
def job_cleanup_db():
|
||
"""Woechentliche DB-Bereinigung: alte Feed-Items und Post-History loeschen."""
|
||
import sqlite3, os
|
||
db_path = os.environ.get('DB_PATH', '/data/redax.db')
|
||
con = sqlite3.connect(db_path)
|
||
r1 = con.execute(
|
||
"DELETE FROM feed_items WHERE status IN ('published', 'rejected') "
|
||
"AND fetched_at < datetime('now', '-60 days')"
|
||
).rowcount
|
||
r2 = con.execute(
|
||
"DELETE FROM feed_items WHERE status = 'new' "
|
||
"AND fetched_at < datetime('now', '-30 days')"
|
||
).rowcount
|
||
r3 = con.execute("DELETE FROM post_history WHERE posted_at < datetime('now', '-90 days')").rowcount
|
||
con.execute("VACUUM")
|
||
con.commit()
|
||
con.close()
|
||
flog.info('db_cleanup', fi_processed=r1, fi_stale=r2, ph_del=r3)
|
||
|
||
|
||
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()
|
||
|
||
# Targets für serverseitiges Rendering
|
||
wp_targets = []
|
||
try:
|
||
mirror_client = WordPressMirrorClient()
|
||
for t in mirror_client.targets:
|
||
disabled = db.get_setting(f'target_disabled_{t["name"]}', '0') == '1'
|
||
if t['primary']:
|
||
admin_pw = os.environ.get('WP_ADMIN_PASSWORD', '')
|
||
admin_direct = os.environ.get('WP_ADMIN_DIRECT_URL', t['url'].rstrip('/'))
|
||
else:
|
||
idx = t['name'].replace('mirror_', '')
|
||
suffix = '' if idx == '1' else idx
|
||
admin_pw = os.environ.get(f'WP_MIRROR{suffix}_ADMIN_PASSWORD', '')
|
||
admin_direct = os.environ.get(f'WP_MIRROR{suffix}_ADMIN_DIRECT_URL', t['url'].rstrip('/'))
|
||
|
||
wp_targets.append({
|
||
'name': t['name'],
|
||
'label': t['label'],
|
||
'url': t['url'],
|
||
'admin_url': admin_direct + '/wp-admin',
|
||
'login_url': admin_direct + '/wp-login.php',
|
||
'username': t['username'],
|
||
'admin_pw': admin_pw,
|
||
'admin_direct': admin_direct,
|
||
'primary': t['primary'],
|
||
'enabled': not disabled,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
undated_drafts = db.get_articles(limit=20, status='draft')
|
||
undated_drafts = [a for a in undated_drafts if not a.get('post_date')]
|
||
|
||
return render_template('index.html',
|
||
today=today,
|
||
plan_days=plan_days,
|
||
plan_articles=plan_articles,
|
||
wp_categories=wp_categories,
|
||
last_published=last_published,
|
||
feeds=feeds,
|
||
queue_count=queue_count,
|
||
prompts=prompts,
|
||
wp_url=os.getenv('WP_URL', '').rstrip('/'),
|
||
wp_admin_direct=os.getenv('WP_ADMIN_DIRECT_URL', os.getenv('WP_URL', '')).rstrip('/'),
|
||
wp_targets=wp_targets,
|
||
undated_drafts=undated_drafts)
|
||
|
||
|
||
# ── API: Artikel ──────────────────────────────────────────────────────────────
|
||
|
||
@app.route('/api/generate', methods=['POST'])
|
||
def api_generate():
|
||
data = request.json
|
||
source = data.get('source', '')
|
||
tone = data.get('tone', 'informativ')
|
||
prompt_id = data.get('prompt_id')
|
||
|
||
prompt = db.get_prompt_by_id(prompt_id) if prompt_id else 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/polish', methods=['POST'])
|
||
def api_polish():
|
||
"""KI-gestützte Textverbesserung: Nutzer gibt Anweisung, KI poliert Titel + Inhalt."""
|
||
data = request.json
|
||
title = data.get('title', '')
|
||
content = data.get('content', '')
|
||
instruction = data.get('instruction', '').strip()
|
||
|
||
if not instruction:
|
||
return jsonify({'error': 'Bitte Anweisung eingeben (z.B. kürzer, lockerer, Einstieg packender)'}), 400
|
||
if not content and not title:
|
||
return jsonify({'error': 'Kein Inhalt zum Verbessern'}), 400
|
||
|
||
system = """Du bist ein Redakteur. Deine Aufgabe: Artikel bearbeiten nach Anweisung. Gib IMMER den bearbeiteten Text zurück – keine Ablehnung, kein "Ich kann nicht helfen".
|
||
|
||
Erledige die Anweisung (Stil ändern, kürzen, Bild einfügen, etc.) und gib NUR den Ergebnis-Text zurück.
|
||
Format: Erste Zeile = Titel (ohne #). Dann HTML (<p>, <h2>, <h3>, <strong>, <img src="..." />).
|
||
Am Ende: SEO_TITLE: [max 60]
|
||
SEO_DESC: [max 155]
|
||
KEYWORD: [1 Wort]"""
|
||
|
||
user_msg = f"Anweisung: {instruction}\n\n---\nTitel: {title}\n\nInhalt:\n{content}"
|
||
|
||
try:
|
||
raw = openrouter.generate(system, user_msg)
|
||
parsed_content, seo_title, seo_desc, keyword = rss_fetcher._parse_ki_output(raw)
|
||
lines = parsed_content.strip().split('\n')
|
||
new_title = lines[0].lstrip('#').strip() if lines else title
|
||
new_content = '\n'.join(lines[1:]).strip() if len(lines) > 1 else parsed_content
|
||
flog.info('article_polished', instruction=instruction[:50])
|
||
return jsonify({
|
||
'title': new_title,
|
||
'content': new_content,
|
||
'seo_title': seo_title or data.get('seo_title'),
|
||
'seo_description': seo_desc or data.get('seo_description'),
|
||
'focus_keyword': keyword or data.get('focus_keyword'),
|
||
})
|
||
except Exception as e:
|
||
flog.error('polish_failed', error=str(e))
|
||
return jsonify({'error': str(e)}), 500
|
||
|
||
|
||
@app.route('/api/chat', methods=['POST'])
|
||
def api_chat():
|
||
"""Freier KI-Chat mit Artikelkontext. History max 6 Paare."""
|
||
data = request.json
|
||
message = (data.get('message') or '').strip()
|
||
history = data.get('history') or []
|
||
ctx = data.get('context') or {}
|
||
|
||
if not message:
|
||
return jsonify({'error': 'Bitte Nachricht eingeben'}), 400
|
||
|
||
# History auf 6 Paare begrenzen
|
||
history = history[-12:] # max 12 messages (6 user + 6 assistant)
|
||
|
||
title = ctx.get('title', '')
|
||
content = ctx.get('content', '')
|
||
seo_title = ctx.get('seo_title', '')
|
||
seo_desc = ctx.get('seo_description', '')
|
||
keyword = ctx.get('focus_keyword', '')
|
||
|
||
system = """Du bist ein Redakteurs-Assistent. Der User arbeitet an einem WordPress-Artikel.
|
||
Kontext des aktuellen Artikels:
|
||
- Titel: """ + (title or '(leer)') + """
|
||
- Inhalt: """ + (content[:3000] + '...' if len(content or '') > 3000 else (content or '(leer)')) + """
|
||
- SEO-Titel: """ + (seo_title or '(leer)') + """
|
||
- SEO-Beschreibung: """ + (seo_desc or '(leer)') + """
|
||
- Fokus-Keyword: """ + (keyword or '(leer)') + """
|
||
|
||
Antworte kurz und handlungsorientiert. Wenn der User Änderungen am Artikel wünscht (kürzen, umschreiben, Teaser, etc.):
|
||
Gib deine Antwort, und am Ende in diesem Format den überarbeiteten Inhalt:
|
||
===APPLY===
|
||
TITEL: [neuer Titel falls geändert]
|
||
INHALT: [HTML-Inhalt]
|
||
SEO_TITLE: [optional, max 60 Zeichen]
|
||
SEO_DESC: [optional, max 155 Zeichen]
|
||
KEYWORD: [optional]
|
||
===ENDAPPLY===
|
||
Nur die Felder angeben die sich ändern. Ohne ===APPLY=== wenn du keinen Text zum Übernehmen lieferst."""
|
||
|
||
messages = [{"role": "system", "content": system}]
|
||
for h in history:
|
||
if h.get('role') in ('user', 'assistant') and h.get('content'):
|
||
messages.append({"role": h["role"], "content": h["content"]})
|
||
messages.append({"role": "user", "content": message})
|
||
|
||
try:
|
||
raw = openrouter.generate_chat(messages)
|
||
suggested = None
|
||
if '===APPLY===' in raw and '===ENDAPPLY===' in raw:
|
||
try:
|
||
block = raw.split('===APPLY===')[1].split('===ENDAPPLY===')[0].strip()
|
||
parts = {}
|
||
for line in block.split('\n'):
|
||
if ':' in line:
|
||
k, v = line.split(':', 1)
|
||
parts[k.strip().upper()] = v.strip()
|
||
suggested = {
|
||
'title': parts.get('TITEL') or title,
|
||
'content': parts.get('INHALT') or content,
|
||
'seo_title': parts.get('SEO_TITLE') or seo_title,
|
||
'seo_description': parts.get('SEO_DESC') or seo_desc,
|
||
'focus_keyword': parts.get('KEYWORD') or keyword,
|
||
}
|
||
reply = raw.split('===APPLY===')[0].strip()
|
||
except Exception:
|
||
reply = raw
|
||
else:
|
||
reply = raw
|
||
|
||
flog.info('chat_message', msg_len=len(message))
|
||
out = {'reply': reply}
|
||
if suggested:
|
||
out['suggested_content'] = suggested
|
||
return jsonify(out)
|
||
except Exception as e:
|
||
flog.error('chat_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')
|
||
wp_post_id = data.get('wp_post_id')
|
||
wp_preview_url = None
|
||
|
||
if article_id:
|
||
db.update_article(article_id, data)
|
||
else:
|
||
article_id = db.create_article({**data, 'article_type': 'ki', 'status': 'draft'})
|
||
|
||
# Als WP-Draft pushen (neu oder aktualisieren)
|
||
try:
|
||
wp = WordPressClient()
|
||
art = db.get_article(article_id)
|
||
if wp_post_id:
|
||
# Bereits in WP vorhanden — aktualisieren
|
||
result = wp.update_post(
|
||
wp_post_id,
|
||
title=art.get('title') or 'Ohne Titel',
|
||
content=art.get('content') or '',
|
||
status='draft',
|
||
)
|
||
else:
|
||
# Neu als Draft anlegen
|
||
result = wp.create_post(
|
||
title=art.get('title') or 'Ohne Titel',
|
||
content=art.get('content') or '',
|
||
status='draft',
|
||
category_ids=[art['category_id']] if art.get('category_id') else [],
|
||
)
|
||
wp_post_id = result['id']
|
||
db.update_article(article_id, {'wp_post_id': wp_post_id})
|
||
|
||
wp_base = os.getenv('WP_URL', '').rstrip('/')
|
||
admin_base = os.getenv('WP_ADMIN_DIRECT_URL', wp_base).rstrip('/')
|
||
wp_preview_url = f"{wp_base}/?p={wp_post_id}&preview=true"
|
||
wp_edit_url = f"{admin_base}/wp-admin/post.php?post={wp_post_id}&action=edit"
|
||
flog.info('article_saved_as_draft', article_id=article_id, wp_post_id=wp_post_id)
|
||
except Exception as e:
|
||
flog.warn('draft_push_failed', article_id=article_id, error=str(e))
|
||
if wp_post_id:
|
||
wp_base = os.getenv('WP_URL', '').rstrip('/')
|
||
admin_base = os.getenv('WP_ADMIN_DIRECT_URL', wp_base).rstrip('/')
|
||
wp_edit_url = f"{admin_base}/wp-admin/post.php?post={wp_post_id}&action=edit"
|
||
else:
|
||
wp_edit_url = None
|
||
|
||
return jsonify({
|
||
'success': True, 'id': article_id, 'wp_post_id': wp_post_id,
|
||
'wp_preview_url': wp_preview_url,
|
||
'wp_edit_url': wp_edit_url if wp_post_id else None
|
||
})
|
||
|
||
|
||
@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
|
||
|
||
|
||
@app.route('/api/wp/targets')
|
||
def api_wp_targets():
|
||
"""Gibt alle konfigurierten WordPress-Targets zurück (inkl. manuell deaktivierter)."""
|
||
mirror_client = WordPressMirrorClient()
|
||
targets_info = []
|
||
for t in mirror_client.targets:
|
||
# Manuelle Deaktivierung aus DB-Settings prüfen
|
||
disabled = db.get_setting(f'target_disabled_{t["name"]}', '0') == '1'
|
||
enabled = not disabled
|
||
client = mirror_client._client_for(t)
|
||
reachable = client.is_reachable() if enabled else False
|
||
targets_info.append({
|
||
'name': t['name'],
|
||
'label': t['label'],
|
||
'url': t['url'],
|
||
'primary': t['primary'],
|
||
'enabled': enabled,
|
||
'reachable': reachable,
|
||
})
|
||
return jsonify(targets_info)
|
||
|
||
|
||
@app.route('/targets/toggle', methods=['POST'])
|
||
def toggle_target():
|
||
name = request.form.get('name')
|
||
if not name:
|
||
return redirect(url_for('index'))
|
||
mirror_client = WordPressMirrorClient()
|
||
target = next((t for t in mirror_client.targets if t['name'] == name), None)
|
||
if target and not target['primary']:
|
||
current = db.get_setting(f'target_disabled_{name}', '0')
|
||
db.set_setting(f'target_disabled_{name}', '0' if current == '1' else '1')
|
||
return redirect(url_for('index'))
|
||
|
||
|
||
@app.route('/api/article/<int:article_id>/mirrors')
|
||
def api_article_mirrors(article_id):
|
||
mirrors = db.get_mirror_posts(article_id)
|
||
return jsonify(mirrors)
|
||
|
||
|
||
# ── API: Feeds ────────────────────────────────────────────────────────────────
|
||
|
||
@app.route('/feeds')
|
||
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'])
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({'success': True, 'id': data['id']})
|
||
else:
|
||
cur = conn.execute(
|
||
"INSERT INTO prompts (name, system_prompt) VALUES (?,?)",
|
||
(data['name'], data['system_prompt'])
|
||
)
|
||
new_id = cur.lastrowid
|
||
conn.commit()
|
||
conn.close()
|
||
return jsonify({'success': True, 'id': new_id})
|
||
|
||
|
||
@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')
|
||
# Woechentliche DB-Bereinigung (Sonntags 03:00 Uhr)
|
||
scheduler.add_job(job_cleanup_db, 'cron', day_of_week='sun', hour=3, minute=0, id='db_cleanup')
|
||
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)
|