Session 2026-04-21: SEO, Wiki-Anreicherung, Training, Lober

SEO & Crawler:
- robots.txt, llms.txt, sitemap.xml (508 Seiten bei Google)
- SSR-Seiten: /info, /wiki/rassen, /wiki/rasse/{slug}, /knigge
- Open Graph, JSON-LD, Breadcrumbs in index.html

Navigation:
- Training unter "Mein Hund", Wissen collapsible
- Welcome-Seite und Landing-Page auf 5-Gruppen-Struktur

Wiki:
- KI-Anreicherung (Claude API): beschreibung, vorkommen_de, Steckbrief
- "So einen hab ich" / Züchter-Verzeichnis
- Scheduler: 50 Rassen beim Start, 20/Nacht

Training:
- Session-Logging (Erfolgsquote, Stimmung, Zufriedenheit)
- Virtueller KI-Trainer (6h-Cache)
- Trainingskalender (Habit-Tracker)
- Top-Training → automatischer Tagebucheintrag
- Gamification ohne Druck: Badges, Streak, Stats

Fortschritts-Lober:
- Jeden Montag 09:00: Claude schreibt Lob-Text pro Hund
- Push + Karte im Tagebuch

Monitoring:
- 4× täglich Status-Mail mit Scheduler-Status + Wiki-Fortschritt
This commit is contained in:
rene 2026-04-21 19:38:20 +02:00
parent 65d1cf6c7f
commit 180de32e57
22 changed files with 4351 additions and 189 deletions

View file

@ -19,6 +19,9 @@ logger = logging.getLogger(__name__)
_scheduler = AsyncIOScheduler(timezone="Europe/Berlin")
# In-Memory Job-Protokoll: {job_id: {"last_run": datetime, "result": str, "status": "ok"|"error"}}
_job_log: dict = {}
def start():
_scheduler.add_job(
@ -87,8 +90,41 @@ def start():
id="seed_wikidata_startup",
replace_existing=True,
)
# Täglich 02:30 Uhr — KI-Anreicherung für 20 noch nicht angereicherte Rassen
_scheduler.add_job(
_job_wiki_enrich,
CronTrigger(hour=2, minute=30),
id="wiki_enrich_nightly",
replace_existing=True,
misfire_grace_time=3600,
)
# Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober
_scheduler.add_job(
_job_weekly_praise,
CronTrigger(day_of_week='mon', hour=9, minute=0),
id="weekly_praise",
replace_existing=True,
misfire_grace_time=3600,
)
# 4× täglich Status-Report per Mail (07:00, 13:00, 19:00, 01:00)
for _h in [7, 13, 19, 1]:
_scheduler.add_job(
_job_status_report,
CronTrigger(hour=_h, minute=0),
id=f"status_report_{_h:02d}",
replace_existing=True,
misfire_grace_time=1800,
)
# Einmalig beim Start (nach 90s) — erste 50 Rassen sofort anreichern
_scheduler.add_job(
_job_wiki_enrich_startup,
'date',
run_date=datetime.now(tz=_TZ) + timedelta(seconds=90),
id="wiki_enrich_startup",
replace_existing=True,
)
_scheduler.start()
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start.")
logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed beim Start, Wiki-KI-Anreicherung 02:30.")
def stop():
@ -150,6 +186,7 @@ async def _job_health_reminders():
logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d")
logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.")
_log_job("health_reminders", "ok", f"{len(rows)} Einträge, {sent_total} Push")
# ------------------------------------------------------------------
@ -176,6 +213,7 @@ async def _job_poison_archive():
AND expires_at < ?
""", (now,))
count = result.rowcount
_log_job("poison_archive", "ok", f"{count} Meldungen archiviert")
if count:
logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.")
@ -202,6 +240,7 @@ async def _job_weather_alert():
thunderstorm = summary["thunderstorm"]
if max_temp >= 28:
_log_job("weather_alert", "ok", f"Hitze-Push: {max_temp:.0f}°C")
sent = send_push_to_all({
"type": "weather_heat",
"title": "☀️ Heißer Asphalt heute",
@ -222,6 +261,7 @@ async def _job_weather_alert():
return
logger.info("Wetter-Alert: Keine Warnung nötig heute.")
_log_job("weather_alert", "ok", "Keine Warnung")
# ------------------------------------------------------------------
@ -301,6 +341,7 @@ async def _job_milestone_check():
created_total += 1
logger.info(f"Meilenstein-Check fertig — {created_total} Einträge erstellt.")
_log_job("milestone_check", "ok", f"{created_total} Meilensteine erstellt")
# ------------------------------------------------------------------
@ -348,6 +389,7 @@ async def _job_import_events():
logger.warning(f"Event-Import: Fehler beim Speichern von '{ev.get('titel')}': {e}")
logger.info(f"Event-Import: {imported} neue Events importiert (von {len(events)} geparsten).")
_log_job("import_events", "ok", f"{imported} neue von {len(events)} Events")
# ------------------------------------------------------------------
@ -390,8 +432,10 @@ async def _job_seed_wikidata_breeds():
from scraper.wikipedia_photos import fetch_wikipedia_photos
wp_count = await fetch_wikipedia_photos()
logger.info(f"Wikipedia photo fetch done: {wp_count} Fotos")
_log_job("seed_wikidata_startup", "ok", f"{count} Rassen, {mirrored}+{wp_count} Fotos")
except Exception as e:
logger.error(f"Wikidata-Seed: Fehler: {e}")
_log_job("seed_wikidata_startup", "error", str(e))
# ------------------------------------------------------------------
@ -575,6 +619,359 @@ async def _job_prewarm_cities():
await _send_progress("abgeschlossen ✓", cities_done, total_cities)
# ------------------------------------------------------------------
# Hilfsfunktion: Job-Protokoll aktualisieren
# ------------------------------------------------------------------
def _log_job(job_id: str, status: str, result: str):
_job_log[job_id] = {
"last_run": datetime.now(tz=_TZ),
"status": status,
"result": result,
}
# ------------------------------------------------------------------
# JOB: KI-Anreicherung der Rassen-Daten (nächtlich)
# ------------------------------------------------------------------
async def _job_wiki_enrich():
"""Reichert 20 noch nicht angereicherte Rassen mit KI-Daten an."""
try:
from scraper.breed_enricher import enrich_breeds
enriched = await enrich_breeds(limit=20)
msg = f"{enriched} Rassen angereichert"
logger.info(f"Wiki-KI-Anreicherung (nächtlich): {msg}.")
_log_job("wiki_enrich_nightly", "ok", msg)
except Exception as e:
logger.error(f"Wiki-KI-Anreicherung: Fehler: {e}")
_log_job("wiki_enrich_nightly", "error", str(e))
async def _job_wiki_enrich_startup():
"""Beim Start: erste 50 Rassen sofort anreichern."""
try:
from scraper.breed_enricher import enrich_breeds
enriched = await enrich_breeds(limit=50)
msg = f"{enriched} Rassen angereichert (Startup)"
logger.info(f"Wiki-KI-Anreicherung (Startup): {msg}.")
_log_job("wiki_enrich_startup", "ok", msg)
except Exception as e:
logger.error(f"Wiki-KI-Anreicherung (Startup): Fehler: {e}")
_log_job("wiki_enrich_startup", "error", str(e))
# ------------------------------------------------------------------
# Hilfsfunktion: Lob-Text für einen Hund generieren
# ------------------------------------------------------------------
async def _generate_praise_for_dog(dog: dict, user_id: int) -> str:
"""Generiert einen Lob-Text für einen Hund basierend auf der letzten Woche."""
from ki import complete, KIUnavailableError
import json as _json
from datetime import date, timedelta
since = (date.today() - timedelta(days=7)).isoformat()
name = dog["name"]
rasse = dog.get("rasse") or "Hund"
stats = {}
try:
with db() as conn:
stats["diary"] = conn.execute("SELECT COUNT(*) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["top_training"] = conn.execute("SELECT COUNT(*) FROM training_sessions WHERE dog_id=? AND datum>=? AND ist_top=1", (dog["id"], since)).fetchone()[0]
stats["health"] = conn.execute("SELECT COUNT(*) FROM health WHERE dog_id=? AND datum>=?", (dog["id"], since)).fetchone()[0]
stats["days_active"] = conn.execute(
"SELECT COUNT(DISTINCT datum) FROM diary WHERE dog_id=? AND datum>=?", (dog["id"], since)
).fetchone()[0]
# Wie viele Wochen ist der User dabei?
first = conn.execute("SELECT MIN(datum) FROM diary WHERE dog_id=?", (dog["id"],)).fetchone()[0]
if first:
weeks_total = max(1, (date.today() - date.fromisoformat(first)).days // 7)
else:
weeks_total = 1
stats["weeks_total"] = weeks_total
except Exception:
pass
# Prompt aufbauen
aktivitaet_parts = []
if stats.get("diary", 0):
aktivitaet_parts.append(f"{stats['diary']} Tagebuch-Eintr\u00e4ge")
if stats.get("training", 0):
t = f"{stats['training']} Trainingseinheiten"
if stats.get("top_training", 0):
t += f" (davon {stats['top_training']} Top-Training)"
aktivitaet_parts.append(t)
if stats.get("health", 0):
aktivitaet_parts.append(f"{stats['health']} Gesundheitseintr\u00e4ge")
if not aktivitaet_parts:
aktivitaet_text = "Diese Woche war ruhig \u2014 keine erfassten Aktivit\u00e4ten."
else:
aktivitaet_text = ", ".join(aktivitaet_parts)
prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche.
Hund: {name} ({rasse})
Letzte 7 Tage: {aktivitaet_text}
Dabei seit: {stats.get('weeks_total', 1)} Wochen
Regeln (unbedingt einhalten):
- Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen
- Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer
- Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c)
- Maximal 3 kurze S\u00e4tze
- Warm, pers\u00f6nlich, keine Floskeln
- Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen"""
try:
text = await complete(
prompt,
system="Du schreibst kurze, warme Lob-Nachrichten f\u00fcr Hundebesitzer. Nur Lob, keine Ratschl\u00e4ge.",
max_tokens=150,
)
return text.strip()
except Exception:
# Fallback wenn KI nicht verfügbar
if aktivitaet_parts:
return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e"
else:
return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e"
# ------------------------------------------------------------------
# JOB: Wöchentlicher Fortschritts-Lober
# ------------------------------------------------------------------
async def _job_weekly_praise():
"""Jeden Montag: Lob-Text f\u00fcr alle aktiven Hunde generieren + Push senden."""
from datetime import date
import json as _json
today = date.today()
d = today.isocalendar()
week_key = f"{d[0]}-W{d[1]:02d}"
logger.info(f"Weekly Praise Job startet f\u00fcr Woche {week_key}")
# Alle Hunde laden, für die noch kein Lob diese Woche existiert
with db() as conn:
dogs = conn.execute("""
SELECT d.id, d.name, d.rasse, d.user_id, d.foto_url
FROM dogs d
WHERE NOT EXISTS (
SELECT 1 FROM weekly_praise wp
WHERE wp.dog_id=d.id AND wp.week_key=?
)
ORDER BY d.id
""", (week_key,)).fetchall()
dogs = [dict(d) for d in dogs]
logger.info(f"Weekly Praise: {len(dogs)} Hunde ohne Lob diese Woche.")
import asyncio
generated = 0
for dog in dogs:
try:
praise = await _generate_praise_for_dog(dog, dog["user_id"])
with db() as conn:
conn.execute("""
INSERT OR IGNORE INTO weekly_praise (user_id, dog_id, week_key, praise_text)
VALUES (?,?,?,?)
""", (dog["user_id"], dog["id"], week_key, praise))
# Push-Notification — erste 100 Zeichen als Preview
preview = praise[:100] + "\u2026" if len(praise) > 100 else praise
send_push_to_user(dog["user_id"], {
"type": "weekly_praise",
"title": f"\U0001f43e R\u00fcckblick f\u00fcr {dog['name']}",
"body": preview,
"data": {"page": "diary"},
"tag": f"weekly-praise-{dog['id']}-{week_key}",
})
generated += 1
await asyncio.sleep(2) # Rate limiting für KI
except Exception as e:
logger.error(f"Weekly Praise: Fehler f\u00fcr Hund {dog['id']}: {e}")
logger.info(f"Weekly Praise Job fertig \u2014 {generated}/{len(dogs)} Lob-Texte generiert.")
_log_job("weekly_praise", "ok", f"{generated} Lob-Texte f\u00fcr KW {d[1]}")
# ------------------------------------------------------------------
# JOB: Status-Report per Mail (4× täglich)
# ------------------------------------------------------------------
async def _job_status_report():
"""Sendet einen HTML-Status-Report an ADMIN_EMAIL."""
import os
from mailer import send_email
admin = os.getenv("ADMIN_EMAIL", "")
if not admin:
logger.info("Status-Report: ADMIN_EMAIL nicht gesetzt, übersprungen.")
return
now_str = datetime.now(tz=_TZ).strftime("%d.%m.%Y %H:%M")
# --- DB-Metriken abrufen ---
metrics = {}
try:
with db() as conn:
# Rassen-Anreicherung
metrics["rassen_total"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen").fetchone()[0]
metrics["rassen_enriched"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE ki_enriched=1").fetchone()[0]
metrics["rassen_mit_foto"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE foto_url IS NOT NULL AND foto_url NOT LIKE 'http%'").fetchone()[0]
metrics["rassen_mit_desc"] = conn.execute("SELECT COUNT(*) FROM wiki_rassen WHERE beschreibung IS NOT NULL AND beschreibung != ''").fetchone()[0]
# Züchter
try:
metrics["zuchter_pending"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=0").fetchone()[0]
metrics["zuchter_verified"] = conn.execute("SELECT COUNT(*) FROM wiki_zuchter WHERE verified=1").fetchone()[0]
except Exception:
metrics["zuchter_pending"] = metrics["zuchter_verified"] = 0
# Community
metrics["users"] = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
metrics["dogs"] = conn.execute("SELECT COUNT(*) FROM dogs").fetchone()[0]
metrics["diary_entries"] = conn.execute("SELECT COUNT(*) FROM diary").fetchone()[0]
metrics["poison_active"] = conn.execute("SELECT COUNT(*) FROM poison WHERE geloest=0").fetchone()[0]
metrics["lost_active"] = conn.execute("SELECT COUNT(*) FROM lost WHERE gefunden=0").fetchone()[0]
# Wiki-Interesse
try:
metrics["interesse_hat"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='hat'").fetchone()[0]
metrics["interesse_will"] = conn.execute("SELECT COUNT(*) FROM wiki_breed_interest WHERE typ='will'").fetchone()[0]
except Exception:
metrics["interesse_hat"] = metrics["interesse_will"] = 0
except Exception as e:
logger.error(f"Status-Report: DB-Fehler: {e}")
return
# --- Wiki-Fortschritt berechnen ---
total = metrics["rassen_total"] or 1
enriched = metrics["rassen_enriched"]
pct = round(enriched / total * 100)
remaining = total - enriched
nights_left = (remaining + 19) // 20 # bei 20/Nacht
bar_filled = round(pct / 5)
progress_bar = "" * bar_filled + "" * (20 - bar_filled)
# --- Job-Log-Tabelle ---
job_labels = {
"health_reminders": "Gesundheits-Erinnerungen",
"poison_archive": "Giftköder-Archiv",
"weather_alert": "Wetter-Alert",
"milestone_check": "Meilenstein-Check",
"import_events": "Event-Import (VDH)",
"wiki_enrich_nightly": "Wiki KI-Anreicherung (nächtlich)",
"wiki_enrich_startup": "Wiki KI-Anreicherung (Startup)",
"seed_breeds_startup": "Rassen-Seed (TheDogAPI)",
"seed_wikidata_startup":"Rassen-Seed (Wikidata)",
"weekly_praise": "Wöchentlicher Lober (Mo 09:00)",
}
job_rows_html = ""
job_rows_txt = ""
for jid, label in job_labels.items():
log = _job_log.get(jid)
if log:
ts = log["last_run"].strftime("%d.%m. %H:%M")
status = "" if log["status"] == "ok" else ""
result = log["result"]
color = "#16a34a" if log["status"] == "ok" else "#dc2626"
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;font-family:monospace;font-size:12px">{ts}</td><td style="padding:5px 10px;color:{color}">{status} {result}</td></tr>'
job_rows_txt += f" {status} {label}: {ts}{result}\n"
else:
job_rows_html += f'<tr><td style="padding:5px 10px;color:#555">{label}</td><td style="padding:5px 10px;color:#aaa" colspan="2">— noch nicht gelaufen</td></tr>'
job_rows_txt += f"{label}: noch nicht gelaufen\n"
html = f"""\
<!DOCTYPE html>
<html lang="de">
<head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f5f0ea;margin:0;padding:0">
<div style="max-width:600px;margin:24px auto;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.08)">
<!-- Header -->
<div style="background:linear-gradient(135deg,#C4843A,#e8a857);padding:24px 28px;color:#fff">
<div style="font-size:22px;font-weight:800;margin-bottom:2px">🐾 Ban Yaro Status-Report</div>
<div style="opacity:.88;font-size:13px">{now_str} Uhr</div>
</div>
<!-- Wiki-Fortschritt -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Wiki KI-Anreicherung</div>
<div style="font-family:monospace;font-size:13px;background:#fdf6ef;border-radius:8px;padding:12px 14px;line-height:1.8">
<span style="color:#555">{progress_bar}</span> <strong>{pct}%</strong><br>
Angereichert: <strong>{enriched}</strong> / {total}<br>
Verbleibend: <strong>{remaining}</strong> Rassen (~{nights_left} Nächte)<br>
📷 Mit lokalem Foto: <strong>{metrics['rassen_mit_foto']}</strong><br>
📝 Mit Beschreibung: <strong>{metrics['rassen_mit_desc']}</strong>
</div>
</div>
<!-- Scheduler-Status -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Scheduler-Jobs</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
{job_rows_html}
</table>
</div>
<!-- Community-Metriken -->
<div style="padding:20px 28px;border-bottom:1px solid #f0e8dc">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#C4843A;margin-bottom:10px">Community</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
{"".join(f'<div style="background:#fdf6ef;border-radius:8px;padding:10px 14px"><div style="font-size:20px;font-weight:800;color:#C4843A">{v}</div><div style="font-size:11px;color:#888">{k}</div></div>' for k,v in [
("Nutzer",metrics["users"]),
("Hunde",metrics["dogs"]),
("Tagebuch-Einträge",metrics["diary_entries"]),
("Aktive Giftköder",metrics["poison_active"]),
("Vermisste Hunde",metrics["lost_active"]),
("'So einen hab ich'",metrics["interesse_hat"]),
("'Interessiert mich'",metrics["interesse_will"]),
("Züchter (pending)",metrics["zuchter_pending"]),
])}
</div>
</div>
<!-- Footer -->
<div style="padding:14px 28px;background:#fdf6ef;font-size:11px;color:#aaa;text-align:center">
Ban Yaro · banyaro.app · Nächster Report in ~6h
</div>
</div>
</body>
</html>"""
plain = f"""Ban Yaro Status-Report — {now_str}
=== Wiki KI-Anreicherung ===
{progress_bar} {pct}%
Angereichert: {enriched}/{total}
Verbleibend: {remaining} Rassen (~{nights_left} Nächte à 20/Nacht)
Mit Foto: {metrics['rassen_mit_foto']}
Mit Beschreibung: {metrics['rassen_mit_desc']}
=== Scheduler-Jobs ===
{job_rows_txt}
=== Community ===
Nutzer: {metrics['users']}
Hunde: {metrics['dogs']}
Tagebuch-Einträge: {metrics['diary_entries']}
Aktive Giftköder: {metrics['poison_active']}
Vermisste Hunde: {metrics['lost_active']}
'So einen hab ich': {metrics['interesse_hat']}
'Interessiert mich': {metrics['interesse_will']}
Züchter (pending): {metrics['zuchter_pending']}
"""
try:
await send_email(admin, f"Ban Yaro Status {now_str}", html, plain)
logger.info(f"Status-Report gesendet an {admin}.")
except Exception as e:
logger.error(f"Status-Report: Mail-Fehler: {e}")
def _compute_milestone(today: date, bday: date, dog_name: str):
"""
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,