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 (

,

,

, , ). 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//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 @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//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//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']) ) 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//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)