homelab-brain/redax-wp/src/app.py
2026-03-03 16:19:53 +07:00

750 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)