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//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') # 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)