- fuenfvoracht/STATE.md: Review-Flow entfernt, direkt approved, neuer Changelog - arakava-news/STATE.md: Aktualisiert - redax-wp/STATE.md + src/app.py: Aktualisiert - flugpreisscanner/STATE.md: Aktualisiert - infrastructure/STATE.md: Aktualisiert - fuenfvoracht READMEs und Kurzuebersichten hinzugefuegt Made-with: Cursor
478 lines
17 KiB
Python
478 lines
17 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
|
|
|
|
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_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()
|
|
|
|
return render_template('index.html',
|
|
today=today,
|
|
plan_days=plan_days,
|
|
plan_articles=plan_articles,
|
|
wp_categories=wp_categories,
|
|
last_published=last_published,
|
|
feeds=feeds,
|
|
queue_count=queue_count,
|
|
prompts=prompts)
|
|
|
|
|
|
# ── API: Artikel ──────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/api/generate', methods=['POST'])
|
|
def api_generate():
|
|
data = request.json
|
|
source = data.get('source', '')
|
|
tone = data.get('tone', 'informativ')
|
|
|
|
prompt = db.get_default_prompt()
|
|
if not prompt:
|
|
return jsonify({'error': 'Kein Prompt konfiguriert'}), 400
|
|
|
|
system = prompt['system_prompt']
|
|
system = system.replace('{tone}', tone).replace('{date}', date.today().strftime('%d.%m.%Y'))
|
|
|
|
try:
|
|
raw = openrouter.generate(system, source)
|
|
content, seo_title, seo_desc, keyword = rss_fetcher._parse_ki_output(raw)
|
|
|
|
# Erste Zeile als Titel extrahieren
|
|
lines = content.strip().split('\n')
|
|
title = lines[0].lstrip('#').strip() if lines else ''
|
|
content_body = '\n'.join(lines[1:]).strip() if len(lines) > 1 else content
|
|
|
|
flog.info('article_generated', tone=tone)
|
|
return jsonify({
|
|
'title': title,
|
|
'content': content_body,
|
|
'seo_title': seo_title,
|
|
'seo_description': seo_desc,
|
|
'focus_keyword': keyword,
|
|
})
|
|
except Exception as e:
|
|
flog.error('generate_failed', error=str(e))
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@app.route('/api/article/save', methods=['POST'])
|
|
def api_save_article():
|
|
data = request.json
|
|
article_id = data.get('id')
|
|
if article_id:
|
|
db.update_article(article_id, data)
|
|
else:
|
|
article_id = db.create_article({**data, 'article_type': 'ki', 'status': 'draft'})
|
|
flog.info('article_saved', article_id=article_id)
|
|
return jsonify({'success': True, 'id': article_id})
|
|
|
|
|
|
@app.route('/api/article/schedule', methods=['POST'])
|
|
def api_schedule_article():
|
|
data = request.json
|
|
article_id = data.get('id')
|
|
post_date = data.get('post_date')
|
|
post_time = data.get('post_time')
|
|
send_to_telegram = 1 if data.get('article_type', 'ki') == 'ki' else 0
|
|
|
|
if article_id:
|
|
db.update_article(article_id, {
|
|
'post_date': post_date,
|
|
'post_time': post_time,
|
|
'status': 'scheduled',
|
|
'send_to_telegram': send_to_telegram,
|
|
})
|
|
else:
|
|
article_id = db.create_article({
|
|
**data,
|
|
'article_type': 'ki',
|
|
'status': 'scheduled',
|
|
'send_to_telegram': send_to_telegram,
|
|
})
|
|
|
|
flog.info('article_scheduled', article_id=article_id, date=post_date, time=post_time)
|
|
return jsonify({'success': True, 'id': article_id})
|
|
|
|
|
|
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
|
|
def api_reschedule(article_id):
|
|
data = request.json
|
|
ok, msg = db.reschedule_article(article_id, data['post_date'], data['post_time'])
|
|
return jsonify({'success': ok, 'error': msg if not ok else None})
|
|
|
|
|
|
@app.route('/api/article/<int:article_id>/delete', methods=['POST'])
|
|
def api_delete_article(article_id):
|
|
db.delete_article(article_id)
|
|
flog.info('article_deleted', article_id=article_id)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/article/<int:article_id>', methods=['GET'])
|
|
def api_get_article(article_id):
|
|
art = db.get_article(article_id)
|
|
if not art:
|
|
return jsonify({'error': 'Not found'}), 404
|
|
return jsonify(art)
|
|
|
|
|
|
@app.route('/api/slots/<date_str>')
|
|
def api_slots(date_str):
|
|
return jsonify({'taken': db.get_taken_slots(date_str)})
|
|
|
|
|
|
@app.route('/api/og-image', methods=['POST'])
|
|
def api_og_image():
|
|
url = request.json.get('url', '')
|
|
image = rss_fetcher._extract_og_image(url)
|
|
return jsonify({'image': image})
|
|
|
|
|
|
@app.route('/api/wp/categories')
|
|
def api_wp_categories():
|
|
try:
|
|
wp = WordPressClient()
|
|
return jsonify(wp.get_categories())
|
|
except Exception as e:
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ── API: Feeds ────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/feeds')
|
|
def feeds_page():
|
|
feeds = db.get_feeds()
|
|
queue = db.get_feed_queue(status='new', limit=30)
|
|
return render_template('feeds.html', feeds=feeds, queue=queue)
|
|
|
|
|
|
@app.route('/api/feeds', methods=['GET'])
|
|
def api_get_feeds():
|
|
return jsonify(db.get_feeds())
|
|
|
|
|
|
@app.route('/api/feeds/add', methods=['POST'])
|
|
def api_add_feed():
|
|
data = request.json
|
|
fid = db.create_feed(data)
|
|
flog.info('feed_added', feed_id=fid, name=data.get('name'))
|
|
return jsonify({'success': True, 'id': fid})
|
|
|
|
|
|
@app.route('/api/feeds/<int:feed_id>/update', methods=['POST'])
|
|
def api_update_feed(feed_id):
|
|
db.update_feed(feed_id, request.json)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/feeds/<int:feed_id>/delete', methods=['POST'])
|
|
def api_delete_feed(feed_id):
|
|
db.delete_feed(feed_id)
|
|
flog.info('feed_deleted', feed_id=feed_id)
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/feeds/<int:feed_id>/fetch', methods=['POST'])
|
|
def api_fetch_feed(feed_id):
|
|
feed = db.get_feed(feed_id)
|
|
if not feed:
|
|
return jsonify({'error': 'Feed not found'}), 404
|
|
count = rss_fetcher.fetch_feed(feed)
|
|
return jsonify({'success': True, 'new_items': count})
|
|
|
|
|
|
@app.route('/api/queue/<int:item_id>/approve', methods=['POST'])
|
|
def api_approve_queue_item(item_id):
|
|
items = db.get_feed_queue(status='new')
|
|
item = next((i for i in items if i['id'] == item_id), None)
|
|
if not item:
|
|
return jsonify({'error': 'Item not found'}), 404
|
|
feed = db.get_feed(item['feed_id'])
|
|
article_id = rss_fetcher.process_auto_publish(feed, item)
|
|
return jsonify({'success': True, 'article_id': article_id})
|
|
|
|
|
|
@app.route('/api/queue/<int:item_id>/reject', methods=['POST'])
|
|
def api_reject_queue_item(item_id):
|
|
db.update_feed_item_status(item_id, 'rejected')
|
|
return jsonify({'success': True})
|
|
|
|
|
|
# ── Prompts ───────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/prompts')
|
|
def prompts_page():
|
|
return render_template('prompts.html', prompts=db.get_prompts())
|
|
|
|
|
|
@app.route('/api/prompts/save', methods=['POST'])
|
|
def api_save_prompt():
|
|
data = request.json
|
|
conn = db.get_conn()
|
|
if data.get('id'):
|
|
conn.execute(
|
|
"UPDATE prompts SET name=?, system_prompt=? WHERE id=?",
|
|
(data['name'], data['system_prompt'], data['id'])
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"INSERT INTO prompts (name, system_prompt) VALUES (?,?)",
|
|
(data['name'], data['system_prompt'])
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/prompts/<int:pid>/default', methods=['POST'])
|
|
def api_set_default_prompt(pid):
|
|
conn = db.get_conn()
|
|
conn.execute("UPDATE prompts SET is_default=0")
|
|
conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (pid,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'success': True})
|
|
|
|
|
|
@app.route('/api/prompts/<int:pid>/delete', methods=['POST'])
|
|
def api_delete_prompt(pid):
|
|
conn = db.get_conn()
|
|
conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (pid,))
|
|
conn.commit()
|
|
conn.close()
|
|
return jsonify({'success': True})
|
|
|
|
|
|
# ── History ───────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/history')
|
|
def history():
|
|
articles = db.get_articles(limit=50, status='published')
|
|
return render_template('history.html', articles=articles)
|
|
|
|
|
|
# ── Settings ──────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/settings')
|
|
def settings():
|
|
return render_template('settings.html',
|
|
wp_url=os.environ.get('WP_URL', ''),
|
|
wp_username=os.environ.get('WP_USERNAME', ''),
|
|
timezone=TIMEZONE)
|
|
|
|
|
|
# ── Hilfe ─────────────────────────────────────────────────────────────────────
|
|
|
|
@app.route('/hilfe')
|
|
def hilfe():
|
|
return render_template('hilfe.html')
|
|
|
|
|
|
# ── Startup ───────────────────────────────────────────────────────────────────
|
|
|
|
def start_scheduler():
|
|
scheduler = BackgroundScheduler(timezone=TZ)
|
|
# Artikel-Publishing: jede Minute prüfen
|
|
scheduler.add_job(job_publish_due, 'interval', minutes=1, id='publish_due')
|
|
# RSS-Feeds: alle 30 Minuten
|
|
scheduler.add_job(job_fetch_feeds, 'interval', minutes=30, id='fetch_feeds')
|
|
# Morgen-Briefing: täglich 10:00
|
|
scheduler.add_job(job_morning_briefing, 'cron', hour=10, minute=0, id='morning_briefing')
|
|
# 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)
|