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

@ -100,6 +100,7 @@ from routes.sitting_access import router as sitting_access_router
from routes.stats import router as stats_router
from routes.achievements import router as achievements_router
from routes.training import router as training_router
from routes.praise import router as praise_router
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
@ -137,6 +138,7 @@ app.include_router(sitting_access_router, prefix="/api/sitting-access", tags=["
app.include_router(stats_router, prefix="/api/stats", tags=["Stats"])
app.include_router(achievements_router, prefix="/api/achievements", tags=["Achievements"])
app.include_router(training_router, prefix="/api/training", tags=["Training"])
app.include_router(praise_router, prefix="/api/praise", tags=["Praise"])
# ------------------------------------------------------------------
@ -165,6 +167,439 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
@app.get("/robots.txt")
async def robots():
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
@app.get("/llms.txt")
async def llms():
return FileResponse(f"{STATIC_DIR}/llms.txt", media_type="text/plain")
@app.get("/sitemap.xml")
async def sitemap():
from fastapi.responses import Response
from database import db as _db
from datetime import date
today = date.today().isoformat()
urls = [
("https://banyaro.app/", "weekly", "1.0"),
("https://banyaro.app/info", "monthly", "0.9"),
("https://banyaro.app/wiki/rassen", "weekly", "0.8"),
("https://banyaro.app/knigge", "monthly", "0.8"),
]
try:
with _db() as conn:
rassen = conn.execute(
"SELECT slug FROM wiki_rassen WHERE slug IS NOT NULL AND slug != '' LIMIT 500"
).fetchall()
if rassen:
urls.append(("https://banyaro.app/wiki/rassen", "weekly", "0.8"))
for r in rassen:
urls.append((f"https://banyaro.app/wiki/rasse/{r['slug']}", "monthly", "0.7"))
events = conn.execute(
"SELECT id FROM events WHERE datum >= date('now') LIMIT 200"
).fetchall()
for e in events:
urls.append((f"https://banyaro.app/api/events/{e['id']}", "weekly", "0.5"))
except Exception:
pass
entries = "\n".join(
f""" <url>
<loc>{loc}</loc>
<lastmod>{today}</lastmod>
<changefreq>{freq}</changefreq>
<priority>{prio}</priority>
</url>"""
for loc, freq, prio in urls
)
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{entries}
</urlset>"""
return Response(content=xml, media_type="application/xml")
@app.get("/info")
async def info_page():
return FileResponse(f"{STATIC_DIR}/landing.html", headers={"Cache-Control": "max-age=3600"})
# ------------------------------------------------------------------
# SEO: Server-gerenderete Wiki-Rassen-Übersicht /wiki/rassen
# ------------------------------------------------------------------
@app.get("/wiki/rassen")
async def wiki_rassen_page():
from fastapi.responses import HTMLResponse
from database import db as _db
with _db() as conn:
rows = conn.execute(
"""SELECT name, slug, gruppe, groesse, aktivitaet, foto_url, kinder_geeignet, wohnung_geeignet
FROM wiki_rassen WHERE slug IS NOT NULL
ORDER BY name ASC"""
).fetchall()
rassen = [dict(r) for r in rows]
total = len(rassen)
groessen_map = {"klein": "Klein", "mittel": "Mittel", "gross": "Groß", "sehr_gross": "Sehr groß"}
aktivitaet_map = {"niedrig": "Niedrig", "mittel": "Mittel", "hoch": "Hoch", "sehr_hoch": "Sehr hoch"}
cards = ""
for r in rassen:
foto = r["foto_url"] or ""
img = f'<img src="{foto}" alt="{r["name"]}" loading="lazy">' if foto else '<div class="breed-placeholder">🐕</div>'
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "")
kinder = "✓ Kinder" if r.get("kinder_geeignet") else ""
wohnung = "✓ Wohnung" if r.get("wohnung_geeignet") else ""
tags = " ".join(f'<span class="tag">{t}</span>' for t in [groesse, kinder, wohnung] if t)
cards += f"""<a href="/wiki/rasse/{r['slug']}" class="breed-card">
{img}
<div class="breed-info">
<h2>{r['name']}</h2>
<p class="gruppe">{r.get('gruppe') or ''}</p>
<div class="tags">{tags}</div>
</div>
</a>\n"""
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunderassen-Wiki {total} Rassen im Überblick | Ban Yaro</title>
<meta name="description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen. Das Hunderassen-Wiki von Ban Yaro.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/wiki/rassen">
<meta property="og:title" content="Hunderassen-Wiki — {total} Rassen | Ban Yaro">
<meta property="og:description" content="Alle {total} Hunderassen im Überblick: Charakter, Größe, Aktivität, Eignung für Familien und Wohnungen.">
<meta property="og:url" content="https://banyaro.app/wiki/rassen">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<script type="application/ld+json">
{{"@context":"https://schema.org","@type":"CollectionPage","name":"Hunderassen-Wiki","description":"Übersicht über {total} Hunderassen mit Charakter, Größe, Aktivität und Eignungsprofil.","url":"https://banyaro.app/wiki/rassen","publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}}}}
</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a}}
header{{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2rem 1.5rem;text-align:center}}
header h1{{font-size:clamp(1.4rem,4vw,2rem);font-weight:700;margin-bottom:.4rem}}
header p{{opacity:.9;font-size:.95rem}}
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.6rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap}}
nav a{{color:#555;text-decoration:none;font-size:.9rem;font-weight:500}}
nav a:hover{{color:#C4843A}}
nav .brand{{font-weight:800;margin-right:auto;color:#C4843A}}
.container{{max-width:1100px;margin:0 auto;padding:2rem 1.5rem}}
.grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1.25rem}}
.breed-card{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden;text-decoration:none;color:#1a1a1a;transition:box-shadow .15s,transform .15s;display:flex;flex-direction:column}}
.breed-card:hover{{box-shadow:0 4px 20px rgba(0,0,0,.1);transform:translateY(-2px)}}
.breed-card img{{width:100%;height:140px;object-fit:cover}}
.breed-placeholder{{width:100%;height:140px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:3rem}}
.breed-info{{padding:.85rem 1rem;flex:1}}
.breed-info h2{{font-size:.95rem;font-weight:700;margin-bottom:.2rem}}
.gruppe{{font-size:.78rem;color:#888;margin-bottom:.4rem}}
.tags{{display:flex;flex-wrap:wrap;gap:.3rem}}
.tag{{background:#f5e6d3;color:#a86e2e;font-size:.7rem;font-weight:600;padding:.15rem .5rem;border-radius:999px}}
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
footer a{{color:#C4843A}}
</style>
</head>
<body>
<header>
<h1>Hunderassen-Wiki</h1>
<p>{total} Rassen Charakter, Eignung, Pflege auf einen Blick</p>
</header>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/info">Über die App</a>
<a href="/knigge">Knigge</a>
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700">App öffnen</a>
</nav>
<div class="container">
<div class="grid">
{cards}
</div>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Die Hunde-Plattform · <a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
# ------------------------------------------------------------------
# SEO: Server-gerenderete Wiki-Rassen-Detailseite /wiki/rasse/{slug}
# ------------------------------------------------------------------
@app.get("/wiki/rasse/{slug}")
async def wiki_rasse_page(slug: str):
from fastapi.responses import HTMLResponse
from database import db as _db
def esc(s):
if not s: return ""
return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace('"',"&quot;")
with _db() as conn:
rasse = conn.execute("SELECT * FROM wiki_rassen WHERE slug=?", (slug,)).fetchone()
if not rasse:
return HTMLResponse("<html><body><h1>Rasse nicht gefunden</h1><a href='/wiki/rassen'>Alle Rassen</a></body></html>", status_code=404)
berichte = conn.execute(
"""SELECT wb.titel, wb.text, wb.created_at, u.name AS autor
FROM wiki_berichte wb JOIN users u ON u.id=wb.user_id
WHERE wb.rasse=? ORDER BY wb.created_at DESC LIMIT 20""",
(slug,)
).fetchall()
r = dict(rasse)
try:
dogs_count = conn.execute(
"SELECT COUNT(DISTINCT d.user_id) FROM dogs d WHERE LOWER(d.rasse) LIKE ?",
(f"%{r.get('name','').lower()}%",)
).fetchone()[0]
except Exception:
dogs_count = 0
try:
zuchter_count = conn.execute(
"SELECT COUNT(*) FROM wiki_zuchter WHERE rasse_slug=? AND verified=1",
(slug,)
).fetchone()[0]
except Exception:
zuchter_count = 0
berichte = [dict(b) for b in berichte]
berichte_count = len(berichte)
groessen_map = {"klein":"Klein","mittel":"Mittel","gross":"Groß","sehr_gross":"Sehr groß"}
aktivitaet_map = {"niedrig":"Niedrig","mittel":"Mittel","hoch":"Hoch","sehr_hoch":"Sehr hoch"}
erfahrung_map = {"anfaenger":"Anfänger geeignet","fortgeschritten":"Für Erfahrene","experte":"Nur für Experten"}
groesse = groessen_map.get(r.get("groesse") or "", r.get("groesse") or "")
aktivitaet = aktivitaet_map.get(r.get("aktivitaet") or "", r.get("aktivitaet") or "")
erfahrung = erfahrung_map.get(r.get("erfahrung") or "", r.get("erfahrung") or "")
kinder = "Ja" if r.get("kinder_geeignet") else "Bedingt"
wohnung = "Ja" if r.get("wohnung_geeignet") else "Besser Haus mit Garten"
gewicht = ""
if r.get("gewicht_min_kg") and r.get("gewicht_max_kg"):
gewicht = f"{r['gewicht_min_kg']}{r['gewicht_max_kg']} kg"
elif r.get("gewicht_max_kg"):
gewicht = f"bis {r['gewicht_max_kg']} kg"
foto_html = f'<img src="{esc(r["foto_url"])}" alt="{esc(r["name"])}" class="breed-photo">' if r.get("foto_url") else '<div class="breed-photo-placeholder">🐕</div>'
facts = [
("Gruppe / FCI", esc(r.get("gruppe") or "")),
("Herkunft", esc(r.get("herkunft") or "")),
("Größe", groesse),
("Gewicht", gewicht),
("Lebensdauer", esc(r.get("lebensdauer") or "")),
("Aktivitätslevel", aktivitaet),
("Für Anfänger", erfahrung),
("Kinder geeignet", kinder),
("Wohnungsgeeignet", wohnung),
("Ursprüngliche Aufgabe", esc(r.get("bred_for") or "")),
]
facts_html = "".join(
f'<div class="fact-row"><span class="fact-label">{label}</span><span class="fact-value">{val}</span></div>'
for label, val in facts if val
)
temperament_html = ""
if r.get("temperament"):
tags = [t.strip() for t in str(r["temperament"]).split(",") if t.strip()]
temperament_html = (
'<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem">'
+ "".join(
f'<span style="background:#f5e6d3;color:#a86e2e;padding:.2rem .7rem;border-radius:999px;font-size:.8rem">{esc(t)}</span>'
for t in tags
)
+ '</div>'
)
berichte_html = ""
if berichte:
for b in berichte:
datum = b.get("created_at","")[:10] if b.get("created_at") else ""
berichte_html += f"""<div class="bericht">
<div class="bericht-meta">{esc(b.get('autor',''))} · {datum}</div>
<h3 class="bericht-titel">{esc(b.get('titel',''))}</h3>
<p class="bericht-text">{esc(b.get('text',''))}</p>
</div>"""
beschreibung_html = ""
if r.get("beschreibung"):
beschreibung_html = (
'<section>'
'<h2>Charakter &amp; Wesen</h2>'
f'<p style="font-size:.95rem;color:#444;line-height:1.7">{esc(r["beschreibung"])}</p>'
'</section>'
)
vorkommen_html = ""
if r.get("vorkommen_de"):
vorkommen_html = (
'<section>'
'<h2>Vorkommen in Deutschland</h2>'
f'<p style="font-size:.9rem;color:#555;line-height:1.65">{esc(r["vorkommen_de"])}</p>'
'</section>'
)
stats_html = (
'<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1.5rem;font-size:.85rem;color:#888">'
f'<span>&#x1F415; {dogs_count} Nutzer haben diesen Hund</span>'
f'<span>&#x1F3C6; {zuchter_count} Z&uuml;chter eingetragen</span>'
f'<span>&#x1F4AC; {berichte_count} Community-Berichte</span>'
'</div>'
)
name = esc(r.get("name",""))
gruppe = esc(r.get("gruppe") or "")
herkunft = esc(r.get("herkunft") or "")
temp_str = esc(r.get("temperament") or "")
beschr_str = esc(r.get("beschreibung") or "")
if beschr_str:
desc = f"{name}{beschr_str[:160]}".strip().rstrip(".")
else:
desc = f"{name} — Hunderasse aus {herkunft}. Größe: {groesse}. Aktivität: {aktivitaet}. {temp_str[:120] if temp_str else ''}".strip().rstrip(".")
json_ld = f"""{{
"@context":"https://schema.org",
"@type":"Article",
"headline":"{name} — Rasse-Profil",
"description":"{desc}",
"url":"https://banyaro.app/wiki/rasse/{slug}",
"inLanguage":"de",
"publisher":{{"@type":"Organization","name":"Ban Yaro","url":"https://banyaro.app"}},
"mainEntityOfPage":{{"@type":"WebPage","@id":"https://banyaro.app/wiki/rasse/{slug}"}}
}}"""
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name} Hunderasse Profil | Ban Yaro Wiki</title>
<meta name="description" content="{desc[:160]}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/wiki/rasse/{slug}">
<meta property="og:type" content="article">
<meta property="og:title" content="{name} — Hunderasse | Ban Yaro">
<meta property="og:description" content="{desc[:200]}">
<meta property="og:url" content="https://banyaro.app/wiki/rasse/{slug}">
<meta property="og:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{name} — Hunderasse | Ban Yaro">
<meta name="twitter:description" content="{desc[:200]}">
<meta name="twitter:image" content="{esc(r.get('foto_url') or 'https://banyaro.app/icons/icon-512.png')}">
<script type="application/ld+json">{json_ld}</script>
<script type="application/ld+json">
{{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"}},
{{"@type":"ListItem","position":2,"name":"Hunderassen-Wiki","item":"https://banyaro.app/wiki/rassen"}},
{{"@type":"ListItem","position":3,"name":"{name}","item":"https://banyaro.app/wiki/rasse/{slug}"}}
]}}
</script>
<style>
*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.6}}
a{{color:#C4843A;text-decoration:none}}
a:hover{{text-decoration:underline}}
nav{{background:#fff;border-bottom:1px solid #e8ddd0;padding:.7rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}}
nav .brand{{font-weight:800;color:#C4843A;margin-right:auto}}
nav a{{font-size:.9rem;font-weight:500;color:#555}}
nav a:hover{{color:#C4843A;text-decoration:none}}
.container{{max-width:860px;margin:0 auto;padding:2rem 1.5rem}}
.hero{{display:flex;gap:2rem;align-items:flex-start;margin-bottom:2.5rem;flex-wrap:wrap}}
.breed-photo{{width:200px;height:200px;border-radius:16px;object-fit:cover;border:3px solid #e8ddd0;flex-shrink:0}}
.breed-photo-placeholder{{width:200px;height:200px;border-radius:16px;background:#f0e8dc;display:flex;align-items:center;justify-content:center;font-size:5rem;flex-shrink:0}}
.hero-info h1{{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.3rem}}
.hero-info .gruppe{{color:#888;font-size:.95rem;margin-bottom:1rem}}
.temp-tags{{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem}}
.temp-tag{{background:#f5e6d3;color:#a86e2e;font-size:.8rem;font-weight:600;padding:.25rem .7rem;border-radius:999px}}
.cta{{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.5rem;border-radius:999px;font-size:.9rem;margin-top:.75rem}}
.cta:hover{{background:#a86e2e;text-decoration:none}}
section{{margin-bottom:2.5rem}}
h2{{font-size:1.15rem;font-weight:700;color:#C4843A;margin-bottom:1rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc;text-transform:uppercase;letter-spacing:.04em;font-size:.85rem}}
.facts{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;overflow:hidden}}
.fact-row{{display:flex;padding:.7rem 1.25rem;border-bottom:1px solid #f5f0eb}}
.fact-row:last-child{{border-bottom:none}}
.fact-label{{width:180px;font-size:.85rem;color:#888;font-weight:500;flex-shrink:0}}
.fact-value{{font-size:.9rem;font-weight:600;color:#1a1a1a}}
.bericht{{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem;margin-bottom:1rem}}
.bericht-meta{{font-size:.78rem;color:#aaa;margin-bottom:.35rem}}
.bericht-titel{{font-size:1rem;font-weight:700;margin-bottom:.4rem}}
.bericht-text{{font-size:.9rem;color:#444;line-height:1.6}}
.breadcrumb{{font-size:.82rem;color:#aaa;margin-bottom:1.5rem}}
.breadcrumb a{{color:#C4843A}}
footer{{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:3rem}}
footer a{{color:#C4843A}}
@media(max-width:500px){{.breed-photo,.breed-photo-placeholder{{width:140px;height:140px;font-size:3.5rem}}.fact-label{{width:140px}}}}
</style>
</head>
<body>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/wiki/rassen">Alle Rassen</a>
<a href="/knigge">Knigge</a>
<a href="/info">Über die App</a>
<a href="/" class="cta" style="padding:.35rem 1rem;font-size:.82rem">App öffnen</a>
</nav>
<div class="container">
<div class="breadcrumb">
<a href="/wiki/rassen">Hunderassen-Wiki</a> {name}
</div>
<div class="hero">
{foto_html}
<div class="hero-info">
<h1>{name}</h1>
{'<p class="gruppe">' + gruppe + '</p>' if gruppe else ''}
{temperament_html}
<a href="/" class="cta">In der App öffnen</a>
</div>
</div>
{beschreibung_html}
{vorkommen_html}
<section>
<h2>Steckbrief</h2>
<div class="facts">{facts_html}</div>
</section>
{stats_html}
{'<section><h2>Erfahrungsberichte der Community (' + str(berichte_count) + ')</h2>' + berichte_html + '</section>' if berichte else ''}
<section>
<h2>In der App</h2>
<p style="font-size:.9rem;color:#555;margin-bottom:.75rem">
Ban Yaro ist die kostenlose Hunde-App für Deutschland, Österreich und die Schweiz.
Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community und mehr DSGVO-konform, ohne App Store.
</p>
<a href="/" class="cta">Kostenlos starten</a>
</section>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Hunderassen-Wiki ·
<a href="/wiki/rassen">Alle Rassen</a> ·
<a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
@app.get("/favicon.ico")
async def favicon():
return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
@ -193,12 +628,45 @@ async def share_target(request: Request):
# Öffentliche Hunde-Profilseite (für NFC-Tags, kein Login nötig)
@app.get("/hund/{dog_id}")
async def public_dog_page(dog_id: int):
from database import db as _db
_og_name = "Hunde-Profil"
_og_desc = "Hunde-Profil auf Ban Yaro — der deutschen Hunde-Plattform"
_og_img = "https://banyaro.app/icons/icon-512.png"
try:
with _db() as conn:
_dog = conn.execute(
"SELECT name, rasse, foto_url, bio FROM dogs WHERE id=? AND is_public=1",
(dog_id,)
).fetchone()
if _dog:
_og_name = _dog["name"]
_rasse = f" · {_dog['rasse']}" if _dog.get("rasse") else ""
_og_desc = f"{_dog['name']}{_rasse} — Profil auf Ban Yaro"
if _dog.get("bio"):
_og_desc = f"{_dog['name']}{_rasse}: {str(_dog['bio'])[:120]}"
if _dog.get("foto_url"):
_og_img = f"https://banyaro.app{_dog['foto_url']}"
except Exception:
pass
html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Profil BAN YARO</title>
<title>{_og_name} Ban Yaro</title>
<meta name="description" content="{_og_desc}">
<meta name="robots" content="noindex">
<meta property="og:type" content="profile">
<meta property="og:title" content="{_og_name} — Ban Yaro">
<meta property="og:description" content="{_og_desc}">
<meta property="og:url" content="https://banyaro.app/hund/{dog_id}">
<meta property="og:image" content="{_og_img}">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{_og_name} — Ban Yaro">
<meta name="twitter:image" content="{_og_img}">
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/components.css">
<style>
@ -688,6 +1156,133 @@ async def ausweis_page(dog_id: int, request: Request):
return HTMLResponse(html)
# ------------------------------------------------------------------
# SEO: Hunde-Knigge als server-gerenderete Seite /knigge
# ------------------------------------------------------------------
@app.get("/knigge")
async def knigge_page():
from fastapi.responses import HTMLResponse
begegnungen = [
("Fremder Hund", "Kurze Leine, ruhig bleiben. Hunde schnüffeln lassen wenn beide entspannt sind. Bei Eskalation: weglenken, Richtung wechseln. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich — der angeleine Hund kann in Stress (Leinenfrust) geraten."),
("Kinder", "Hund nie unbeaufsichtigt mit fremden Kindern lassen. Kind fragen ob es streicheln darf. Hund seitlich positionieren, nicht zwischen Kind und Weg. Kinder sollten keinen direkten Augenkontakt mit unbekannten Hunden halten."),
("Radfahrer", "Hund rechtzeitig an die Seite nehmen. Fahrräder können für manche Hunde bedrohlich wirken. Frühzeitig Abstand gewinnen, ruhig bleiben, Hund bei sich halten."),
("Jogger", "Kurze Leine, Abstand halten, Hund nicht anspringen lassen. Jogger bewegen sich schnell — das kann Jagdinstinkt auslösen."),
("ÖPNV (Bus & Bahn)", "In Deutschland gilt im ÖPNV grundsätzlich Maulkorbpflicht für Hunde. Kleine Hunde in Transportbox fahren kostenlos oder zum Kindertarif. Große Hunde brauchen in den meisten Städten einen eigenen Fahrschein. Regeln variieren je nach Verkehrsverbund."),
("Supermarkt & Geschäfte", "Es gilt das Hausrecht des Betreibers. Ein 'Hunde willkommen'-Schild ist eine explizite Einladung. Im Zweifel immer fragen. Außen anbinden ist nur kurzzeitig und mit Sichtkontakt akzeptabel."),
("Restaurant", "Im Außenbereich erlauben viele Restaurants Hunde — aber Hausrecht gilt. Wenn ein anderer Gast Angst hat, ist Kompromissbereitschaft ein Zeichen guter Hundehaltung. Im Zweifelsfall Personal entscheiden lassen."),
("Spielplätze", "Hunde sind auf Kinderspielplätzen generell verboten (§ 2 Abs. 4 BImSchG und Gemeindesatzungen). Gilt auch für gut erzogene Hunde. Abstand halten, auch beim Vorbeigehen."),
]
szenarien = [
("Muss Kot immer aufgesammelt werden?", "Ja — auch im Gebüsch abseits des Weges. Kinder spielen überall, und Parasiten wie Spulwurm können für Menschen gefährlich sein. Bußgelder für liegengelassenen Hundekot liegen je nach Stadt zwischen 50 € und 500 €."),
("Leinenpflicht ohne Schild?", "Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften und öffentlichen Grünanlagen. Im Zweifel anleinen und die Gemeindesatzung prüfen."),
("Hund frei laufen trotz angeleintem Hund?", "Nein. Freilaufende Hunde auf angeleine Hunde zuzulassen ist unhöflich und kann den angeleinten Hund in Stress versetzen (Leinenfrust). Immer erst anleinen und fragen ob ein Treffen gewünscht ist."),
("Haftung bei Beißvorfall?", "Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Hunde-Haftpflichtversicherung ist in vielen Bundesländern Pflicht und in jedem Fall empfehlenswert."),
]
begs_html = "".join(
f'<div class="item"><h3>{titel}</h3><p>{text}</p></div>'
for titel, text in begegnungen
)
szen_html = "".join(
f'<div class="item"><h3>{frage}</h3><p>{antwort}</p></div>'
for frage, antwort in szenarien
)
html = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hunde-Knigge Regeln für Hundebesitzer | Ban Yaro</title>
<meta name="description" content="Der Hunde-Knigge: Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://banyaro.app/knigge">
<meta property="og:type" content="article">
<meta property="og:title" content="Hunde-Knigge — Regeln für Hundebesitzer | Ban Yaro">
<meta property="og:description" content="Begegnungen mit fremden Hunden, Kindern, Radfahrern, Regeln im ÖPNV, Leinenpflicht, Haftung — alles was Hundebesitzer wissen müssen.">
<meta property="og:url" content="https://banyaro.app/knigge">
<meta property="og:image" content="https://banyaro.app/icons/icon-512.png">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[
{"@type":"Question","name":"Muss Hundekot immer aufgesammelt werden?","acceptedAnswer":{"@type":"Answer","text":"Ja — auch im Gebüsch abseits des Weges. Bußgelder liegen je nach Stadt zwischen 50 € und 500 €."}},
{"@type":"Question","name":"Gilt Leinenpflicht ohne Schild?","acceptedAnswer":{"@type":"Answer","text":"Leinenpflicht ist Ländersache. Viele Bundesländer haben eine allgemeine Anleinpflicht in Ortschaften. Im Zweifel anleinen."}},
{"@type":"Question","name":"Darf mein Hund auf einen angeleinten Hund zulaufen?","acceptedAnswer":{"@type":"Answer","text":"Nein. Freilaufende Hunde auf angeleine Hunde zulaufen lassen ist unhöflich und verursacht Leinenfrust."}},
{"@type":"Question","name":"Wer haftet bei einem Beißvorfall?","acceptedAnswer":{"@type":"Answer","text":"Hundehalter haften verschuldensunabhängig für Schäden durch ihren Hund (§ 833 BGB). Eine Haftpflichtversicherung ist in vielen Bundesländern Pflicht."}}
]}
</script>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[
{"@type":"ListItem","position":1,"name":"Ban Yaro","item":"https://banyaro.app"},
{"@type":"ListItem","position":2,"name":"Hunde-Knigge","item":"https://banyaro.app/knigge"}
]}
</script>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#FAF7F2;color:#1a1a1a;line-height:1.65}
a{color:#C4843A;text-decoration:none}
a:hover{text-decoration:underline}
header{background:linear-gradient(135deg,#C4843A,#e8a857);color:#fff;padding:2.5rem 1.5rem;text-align:center}
header h1{font-size:clamp(1.5rem,4vw,2.2rem);font-weight:800;margin-bottom:.5rem}
header p{opacity:.92;max-width:560px;margin:0 auto}
nav{background:#fff;border-bottom:1px solid #e8ddd0;padding:.65rem 1.5rem;display:flex;gap:1rem;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:10}
nav .brand{font-weight:800;color:#C4843A;margin-right:auto}
nav a{font-size:.9rem;font-weight:500;color:#555}
nav a:hover{color:#C4843A;text-decoration:none}
.container{max-width:820px;margin:0 auto;padding:2.5rem 1.5rem}
section{margin-bottom:3rem}
h2{font-size:.82rem;font-weight:700;color:#C4843A;text-transform:uppercase;letter-spacing:.06em;margin-bottom:1.25rem;padding-bottom:.4rem;border-bottom:2px solid #f0e8dc}
.item{background:#fff;border:1px solid #e8ddd0;border-radius:12px;padding:1.25rem 1.5rem;margin-bottom:.9rem}
.item h3{font-size:1rem;font-weight:700;margin-bottom:.4rem;color:#1a1a1a}
.item p{font-size:.9rem;color:#444;line-height:1.65}
.cta-box{background:linear-gradient(135deg,#f5e6d3,#fdf6ef);border:1px solid #e8c99a;border-radius:14px;padding:1.75rem;text-align:center;margin-top:2.5rem}
.cta-box h3{font-size:1.1rem;font-weight:700;margin-bottom:.5rem}
.cta-box p{font-size:.9rem;color:#555;margin-bottom:1rem}
.cta-btn{display:inline-block;background:#C4843A;color:#fff;font-weight:700;padding:.65rem 1.75rem;border-radius:999px;font-size:.95rem}
.cta-btn:hover{background:#a86e2e;text-decoration:none}
footer{background:#1a1a1a;color:#aaa;text-align:center;padding:1.5rem;font-size:.82rem;margin-top:2rem}
footer a{color:#C4843A}
</style>
</head>
<body>
<header>
<h1>Hunde-Knigge</h1>
<p>Regeln, Tipps und häufige Fragen für Hundebesitzer im Alltag, im ÖPNV und in der Community</p>
</header>
<nav>
<span class="brand">Ban Yaro</span>
<a href="/wiki/rassen">Rassen-Wiki</a>
<a href="/info">Über die App</a>
<a href="/" style="background:#C4843A;color:#fff;padding:.35rem 1rem;border-radius:999px;font-weight:700;font-size:.85rem;text-decoration:none">App öffnen</a>
</nav>
<div class="container">
<section>
<h2>Begegnungen im Alltag</h2>
""" + begs_html + """
</section>
<section>
<h2>Häufige Fragen &amp; Regeln</h2>
""" + szen_html + """
</section>
<div class="cta-box">
<h3>Mehr Tipps in der Ban Yaro App</h3>
<p>Community-Abstimmungen zu kniffligen Situationen, KI-Situationsberater und alle Hunde-Funktionen kostenlos nutzen.</p>
<a href="/" class="cta-btn">Kostenlos starten</a>
</div>
</div>
<footer>
<strong style="color:#fff">Ban Yaro</strong> Hunde-Knigge ·
<a href="/wiki/rassen">Rassen-Wiki</a> ·
<a href="https://banyaro.app">banyaro.app</a>
</footer>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=7200"})
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):