Compare commits
10 commits
8467f01b5b
...
9fd146440f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fd146440f | |||
| f2e87bd681 | |||
| 00b7a54a8f | |||
| da036d2b93 | |||
| 4e5a13d9e2 | |||
| bff54dcfd3 | |||
| f02b9aa4ab | |||
| 55ae22615d | |||
| 662190e308 | |||
| 857c83bd65 |
9 changed files with 214 additions and 232 deletions
|
|
@ -327,7 +327,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||||
|
|
||||||
APP_VER = "774" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "777" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ def start():
|
||||||
)
|
)
|
||||||
_scheduler.add_job(
|
_scheduler.add_job(
|
||||||
_job_import_events,
|
_job_import_events,
|
||||||
CronTrigger(day_of_week='sun', hour=2), # jeden Sonntag 02:00 Uhr
|
CronTrigger(month="1,4,7,10", day=2, hour=2), # quartalsweise Jan/Apr/Jul/Okt
|
||||||
id="import_events",
|
id="import_events",
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=7200,
|
misfire_grace_time=7200,
|
||||||
|
|
|
||||||
|
|
@ -7,310 +7,235 @@ Bei Fehler oder 0 Ergebnissen: Fallback auf hartcodierte Events.
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
FALLBACK_EVENTS = [
|
FALLBACK_EVENTS = [
|
||||||
{"titel": "VDH-Europasiegershow 2026", "datum": "2026-06-14", "ort_name": "Dortmund", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-europasieger-2026"},
|
{"titel": "VDH-Europasiegershow 2026", "datum": "2026-06-14", "ort_name": "Dortmund", "typ": "ausstellung", "link": "https://www.vdh.de/ausstellungen/", "external_id": "vdh-fallback-europasieger-2026"},
|
||||||
{"titel": "Internationale Hundeausstellung Frankfurt", "datum": "2026-05-03", "ort_name": "Frankfurt am Main", "typ": "ausstellung", "link": "https://www.vdh.de", "external_id": "vdh-fallback-frankfurt-2026"},
|
{"titel": "Internationale Hundeausstellung Hannover", "datum": "2026-06-27", "ort_name": "Hannover", "typ": "ausstellung", "link": "https://www.vdh.de/ausstellungen/", "external_id": "vdh-fallback-hannover-2026"},
|
||||||
{"titel": "VDH-Bundessiegerprüfung Agility", "datum": "2026-07-19", "ort_name": "Leipzig", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-agility-2026"},
|
{"titel": "VDH-Bundessiegerprüfung Agility", "datum": "2026-07-19", "ort_name": "Leipzig", "typ": "wettkampf", "link": "https://www.vdh.de/hundesport/termine/", "external_id": "vdh-fallback-agility-2026"},
|
||||||
{"titel": "Rassehundetreffen München", "datum": "2026-08-22", "ort_name": "München", "typ": "treffen", "link": "https://www.vdh.de", "external_id": "vdh-fallback-muenchen-2026"},
|
{"titel": "Internationale Hundeausstellung München", "datum": "2026-08-01", "ort_name": "München", "typ": "ausstellung", "link": "https://www.vdh.de/ausstellungen/", "external_id": "vdh-fallback-muenchen-2026"},
|
||||||
{"titel": "Hundesport-Turnier Berlin", "datum": "2026-09-12", "ort_name": "Berlin", "typ": "wettkampf", "link": "https://www.vdh.de", "external_id": "vdh-fallback-berlin-2026"},
|
{"titel": "Deutsche Meisterschaft Agility", "datum": "2026-10-24", "ort_name": "Hemsbach", "typ": "wettkampf", "link": "https://www.vdh.de/hundesport/termine/", "external_id": "vdh-fallback-dm-agility-2026"},
|
||||||
|
{"titel": "Deutsche Meisterschaft Para Agility", "datum": "2026-11-07", "ort_name": "Hückelhoven", "typ": "wettkampf", "link": "https://www.vdh.de/hundesport/termine/", "external_id": "vdh-fallback-para-agility-2026"},
|
||||||
|
{"titel": "Internationale Hundeausstellung Frankfurt", "datum": "2026-11-21", "ort_name": "Frankfurt am Main", "typ": "ausstellung", "link": "https://www.vdh.de/ausstellungen/", "external_id": "vdh-fallback-frankfurt-nov-2026"},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mapping VDH-Kategorienamen → interne Typen
|
|
||||||
_TYP_MAP = {
|
_TYP_MAP = {
|
||||||
"ausstellung": "ausstellung",
|
"ausstellung": "ausstellung", "show": "ausstellung", "siegershow": "ausstellung",
|
||||||
"show": "ausstellung",
|
"agility": "wettkampf", "wettkampf": "wettkampf", "turnier": "wettkampf",
|
||||||
"siegershow": "ausstellung",
|
"prüfung": "wettkampf", "meisterschaft": "wettkampf",
|
||||||
"agility": "wettkampf",
|
"training": "training", "treffen": "treffen", "markt": "markt",
|
||||||
"wettkampf": "wettkampf",
|
|
||||||
"turnier": "wettkampf",
|
|
||||||
"prüfung": "wettkampf",
|
|
||||||
"training": "training",
|
|
||||||
"treffen": "treffen",
|
|
||||||
"markt": "markt",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Monatsnamen Deutsch → Zahl
|
|
||||||
_MONATE = {
|
|
||||||
"januar": 1, "februar": 2, "märz": 3, "maerz": 3,
|
|
||||||
"april": 4, "mai": 5, "juni": 6, "juli": 7,
|
|
||||||
"august": 8, "september": 9, "oktober": 10,
|
|
||||||
"november": 11, "dezember": 12,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _guess_typ(text: str) -> str:
|
def _guess_typ(text: str) -> str:
|
||||||
"""Bestimmt den Event-Typ anhand des Titels."""
|
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
for keyword, typ in _TYP_MAP.items():
|
for keyword, typ in _TYP_MAP.items():
|
||||||
if keyword in t:
|
if keyword in t:
|
||||||
return typ
|
return typ
|
||||||
return "sonstiges"
|
return "ausstellung"
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(raw: str) -> str | None:
|
def _parse_date(raw: str) -> str | None:
|
||||||
"""
|
"""DD.MM.YYYY oder DD.MM.YYYY - DD.MM.YYYY → YYYY-MM-DD (erstes Datum)."""
|
||||||
Versucht verschiedene Datumsformate zu parsen.
|
raw = raw.strip().split(" - ")[0].strip()
|
||||||
Gibt YYYY-MM-DD zurück oder None.
|
|
||||||
"""
|
|
||||||
raw = raw.strip()
|
|
||||||
|
|
||||||
# ISO: 2026-05-03
|
|
||||||
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', raw)
|
|
||||||
if m:
|
|
||||||
return raw
|
|
||||||
|
|
||||||
# DD.MM.YYYY oder D.M.YYYY
|
|
||||||
m = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', raw)
|
m = re.match(r'^(\d{1,2})\.(\d{1,2})\.(\d{4})$', raw)
|
||||||
if m:
|
if m:
|
||||||
d, mo, y = m.groups()
|
d, mo, y = m.groups()
|
||||||
return f"{y}-{int(mo):02d}-{int(d):02d}"
|
return f"{y}-{int(mo):02d}-{int(d):02d}"
|
||||||
|
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', raw)
|
||||||
# DD. Monatsname YYYY (z.B. "14. Juni 2026")
|
|
||||||
m = re.match(r'^(\d{1,2})\.\s*(\w+)\s+(\d{4})$', raw)
|
|
||||||
if m:
|
if m:
|
||||||
d, mon_str, y = m.groups()
|
return raw
|
||||||
mon_num = _MONATE.get(mon_str.lower())
|
|
||||||
if mon_num:
|
|
||||||
return f"{y}-{mon_num:02d}-{int(d):02d}"
|
|
||||||
|
|
||||||
# Monatsname DD, YYYY (englisch, Fallback)
|
|
||||||
try:
|
|
||||||
dt = datetime.strptime(raw, "%B %d, %Y")
|
|
||||||
return dt.strftime("%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class _VDHParser(HTMLParser):
|
def _strip_tags(html: str) -> str:
|
||||||
"""
|
"""Entfernt HTML-Tags."""
|
||||||
Einfacher Zustandsautomat-Parser für die VDH-Veranstaltungsseite.
|
return re.sub(r'<[^>]+>', '', html).strip()
|
||||||
Sucht nach typischen Strukturen: article, li.event, div mit Datums-/Titel-Klassen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self._events: list[dict] = []
|
|
||||||
self._current: dict | None = None
|
|
||||||
self._depth = 0
|
|
||||||
self._start_depth = 0
|
|
||||||
self._capture = None # 'titel' | 'datum' | 'ort'
|
|
||||||
self._buf = ""
|
|
||||||
self._in_event = False
|
|
||||||
|
|
||||||
# ---------- Hilfsmethoden ----------
|
|
||||||
|
|
||||||
def _is_event_container(self, tag, attrs):
|
|
||||||
"""Erkennt Start eines Event-Blocks."""
|
|
||||||
a = dict(attrs)
|
|
||||||
cls = a.get("class", "")
|
|
||||||
return (
|
|
||||||
tag == "article"
|
|
||||||
or (tag in ("li", "div") and any(
|
|
||||||
kw in cls for kw in ("event", "veranstaltung", "termin", "entry", "item")
|
|
||||||
))
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_title_tag(self, tag, attrs):
|
|
||||||
a = dict(attrs)
|
|
||||||
cls = a.get("class", "")
|
|
||||||
return tag in ("h2", "h3", "h4") or any(
|
|
||||||
kw in cls for kw in ("title", "titel", "name", "heading")
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_date_tag(self, tag, attrs):
|
|
||||||
a = dict(attrs)
|
|
||||||
cls = a.get("class", "")
|
|
||||||
it = a.get("itemprop", "")
|
|
||||||
return (
|
|
||||||
tag in ("time",)
|
|
||||||
or any(kw in cls for kw in ("date", "datum", "time"))
|
|
||||||
or it in ("startDate", "endDate")
|
|
||||||
)
|
|
||||||
|
|
||||||
def _is_location_tag(self, tag, attrs):
|
|
||||||
a = dict(attrs)
|
|
||||||
cls = a.get("class", "")
|
|
||||||
it = a.get("itemprop", "")
|
|
||||||
return (
|
|
||||||
any(kw in cls for kw in ("location", "ort", "venue", "place", "city"))
|
|
||||||
or it in ("location", "addressLocality")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- SAX-Events ----------
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
|
||||||
self._depth += 1
|
|
||||||
a = dict(attrs)
|
|
||||||
|
|
||||||
if not self._in_event and self._is_event_container(tag, attrs):
|
|
||||||
self._in_event = True
|
|
||||||
self._start_depth = self._depth
|
|
||||||
self._current = {"titel": "", "datum": "", "ort_name": "", "link": ""}
|
|
||||||
# Direkter Link auf dem Container?
|
|
||||||
if tag == "a" and "href" in a:
|
|
||||||
self._current["link"] = a["href"]
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._in_event:
|
|
||||||
# Link innerhalb des Event-Blocks
|
|
||||||
if tag == "a" and "href" in a and not self._current.get("link"):
|
|
||||||
href = a["href"]
|
|
||||||
if "vdh.de" in href or href.startswith("/"):
|
|
||||||
self._current["link"] = href
|
|
||||||
|
|
||||||
# <time datetime="…">
|
|
||||||
if tag == "time":
|
|
||||||
dt = a.get("datetime", "")
|
|
||||||
if dt:
|
|
||||||
parsed = _parse_date(dt)
|
|
||||||
if parsed:
|
|
||||||
self._current["datum"] = parsed
|
|
||||||
|
|
||||||
if self._is_title_tag(tag, attrs):
|
|
||||||
self._capture = "titel"
|
|
||||||
self._buf = ""
|
|
||||||
elif self._is_date_tag(tag, attrs) and not self._current.get("datum"):
|
|
||||||
self._capture = "datum"
|
|
||||||
self._buf = ""
|
|
||||||
elif self._is_location_tag(tag, attrs):
|
|
||||||
self._capture = "ort"
|
|
||||||
self._buf = ""
|
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
|
||||||
if self._capture:
|
|
||||||
val = self._buf.strip()
|
|
||||||
if self._capture == "titel" and val:
|
|
||||||
self._current["titel"] = val
|
|
||||||
elif self._capture == "datum" and val and not self._current.get("datum"):
|
|
||||||
parsed = _parse_date(val)
|
|
||||||
if parsed:
|
|
||||||
self._current["datum"] = parsed
|
|
||||||
elif self._capture == "ort" and val:
|
|
||||||
self._current["ort_name"] = val
|
|
||||||
self._capture = None
|
|
||||||
self._buf = ""
|
|
||||||
|
|
||||||
self._depth -= 1
|
|
||||||
|
|
||||||
if self._in_event and self._depth < self._start_depth:
|
|
||||||
self._in_event = False
|
|
||||||
ev = self._current
|
|
||||||
# Nur speichern wenn wir Titel + Datum haben
|
|
||||||
if ev and ev.get("titel") and ev.get("datum"):
|
|
||||||
self._events.append(ev)
|
|
||||||
self._current = None
|
|
||||||
|
|
||||||
def handle_data(self, data):
|
|
||||||
if self._capture:
|
|
||||||
self._buf += data
|
|
||||||
|
|
||||||
def get_events(self) -> list[dict]:
|
|
||||||
return self._events
|
|
||||||
|
|
||||||
|
|
||||||
def _build_external_id(ev: dict) -> str:
|
def _build_external_id(ev: dict) -> str:
|
||||||
"""Erzeugt einen stabilen Dedup-Key aus Datum + Titel."""
|
|
||||||
raw = f"vdh-{ev['datum']}-{ev['titel']}"
|
raw = f"vdh-{ev['datum']}-{ev['titel']}"
|
||||||
# Einfache Normalisierung: lowercase, Sonderzeichen raus
|
|
||||||
key = re.sub(r'[^a-z0-9]+', '-', raw.lower()).strip('-')
|
key = re.sub(r'[^a-z0-9]+', '-', raw.lower()).strip('-')
|
||||||
return key[:120]
|
return key[:120]
|
||||||
|
|
||||||
|
|
||||||
|
# ── PARSER 1: /ausstellungen/liste/typ/spezial/ ──────────────────────────────
|
||||||
|
# Struktur innerhalb div.ausstellung_liste:
|
||||||
|
# <div class="row">
|
||||||
|
# <div class="span6">
|
||||||
|
# <b>Rassen</b><br>DD.MM.YYYY<br> Verein<br> Straße<br> PLZ Ort<br>
|
||||||
|
# </div>
|
||||||
|
# <div class="span6">…Kontakt…</div>
|
||||||
|
# </div>
|
||||||
|
|
||||||
|
def _parse_spezial(html: str) -> list[dict]:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# Ausstellung_liste-Block extrahieren
|
||||||
|
m = re.search(r'<div class="ausstellung_liste">(.*?)(?=<div class="row">\s*<div class="span12">)',
|
||||||
|
html, re.DOTALL)
|
||||||
|
block = m.group(1) if m else html
|
||||||
|
|
||||||
|
# Jede linke span6 (erstes span6 pro row) extrahieren
|
||||||
|
# Pattern: <div class="row"> ... <div class="span6">INHALT</div>
|
||||||
|
row_pattern = re.compile(
|
||||||
|
r'<div class="row">\s*<div class="span6">(.*?)</div>',
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
for row_m in row_pattern.finditer(block):
|
||||||
|
cell = row_m.group(1)
|
||||||
|
|
||||||
|
# Titel aus <b>...</b>
|
||||||
|
title_m = re.search(r'<b>(.*?)</b>', cell, re.DOTALL)
|
||||||
|
if not title_m:
|
||||||
|
continue
|
||||||
|
title = _strip_tags(title_m.group(1)).strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Datum: erste DD.MM.YYYY nach dem <b>-Block
|
||||||
|
after_b = cell[title_m.end():]
|
||||||
|
date_m = re.search(r'(\d{1,2}\.\d{1,2}\.\d{4})', after_b)
|
||||||
|
if not date_m:
|
||||||
|
continue
|
||||||
|
date_str = _parse_date(date_m.group(1))
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# PLZ + Ort: "12345 Stadtname"
|
||||||
|
ort = ""
|
||||||
|
ort_m = re.search(r'(\d{5})\s+([^<\n\r]+)', after_b)
|
||||||
|
if ort_m:
|
||||||
|
ort = ort_m.group(2).strip()
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
"titel": title,
|
||||||
|
"datum": date_str,
|
||||||
|
"ort_name": ort,
|
||||||
|
"link": "https://www.vdh.de/ausstellungen/",
|
||||||
|
})
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
# ── PARSER 2: /hundesport/termine/ ───────────────────────────────────────────
|
||||||
|
# Struktur: <h2 class="h2ash1">Kategorie</h2> dann <ul><li>
|
||||||
|
# <li>DD.MM.YYYY - DD.MM.YYYY<br>Titel<br><b>Ort:</b> Stadt<br></li>
|
||||||
|
|
||||||
|
def _parse_sport(html: str) -> list[dict]:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# <li>-Blöcke extrahieren
|
||||||
|
li_pattern = re.compile(r'<li>(.*?)</li>', re.DOTALL)
|
||||||
|
|
||||||
|
for li_m in li_pattern.finditer(html):
|
||||||
|
cell = li_m.group(1)
|
||||||
|
# Datum: erstes DD.MM.YYYY oder DD.MM.YYYY - DD.MM.YYYY
|
||||||
|
date_m = re.search(r'(\d{1,2}\.\d{1,2}\.\d{4}(?:\s*-\s*\d{1,2}\.\d{1,2}\.\d{4})?)', cell)
|
||||||
|
if not date_m:
|
||||||
|
continue
|
||||||
|
date_str = _parse_date(date_m.group(1))
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Text nach dem Datum ohne Tags
|
||||||
|
after_date = cell[date_m.end():]
|
||||||
|
# "Ort:" aus <b>Ort:</b> Stadt entfernen wir für den Titel
|
||||||
|
parts = [p.strip() for p in re.split(r'<br\s*/?>|<b>[^<]*</b>', after_date) if p.strip()]
|
||||||
|
parts = [_strip_tags(p) for p in parts if _strip_tags(p)]
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
ort = ""
|
||||||
|
for part in parts:
|
||||||
|
if re.match(r'^Ort:\s*', part, re.IGNORECASE):
|
||||||
|
ort = re.sub(r'^Ort:\s*', '', part, flags=re.IGNORECASE).strip()
|
||||||
|
elif not title and not re.match(r'^\d', part):
|
||||||
|
title = part
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
"titel": title,
|
||||||
|
"datum": date_str,
|
||||||
|
"ort_name": ort,
|
||||||
|
"link": "https://www.vdh.de/hundesport/termine/",
|
||||||
|
})
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
async def fetch_vdh_events() -> list[dict]:
|
async def fetch_vdh_events() -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Scrapt VDH-Veranstaltungen und gibt eine Liste von Dicts zurück:
|
Scrapt VDH-Veranstaltungen von ausstellungen/liste und hundesport/termine.
|
||||||
{titel, datum, ort_name, typ, link, external_id}
|
Gibt eine Liste von Dicts zurück: {titel, datum, ort_name, typ, link, external_id}
|
||||||
|
|
||||||
Bei Fehler oder 0 Ergebnissen: Fallback auf FALLBACK_EVENTS.
|
|
||||||
"""
|
"""
|
||||||
urls = [
|
sources = [
|
||||||
"https://www.vdh.de/veranstaltungen/ausstellungen/",
|
("https://www.vdh.de/ausstellungen/liste/typ/spezial/", _parse_spezial),
|
||||||
"https://www.vdh.de/veranstaltungen/",
|
("https://www.vdh.de/hundesport/termine/", _parse_sport),
|
||||||
]
|
]
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": (
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/124.0.0.0 Safari/537.36",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/124.0.0.0 Safari/537.36"
|
|
||||||
),
|
|
||||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
||||||
"Accept-Language": "de-DE,de;q=0.9,en;q=0.5",
|
"Accept-Language": "de-DE,de;q=0.9,en;q=0.5",
|
||||||
}
|
}
|
||||||
|
|
||||||
raw_events: list[dict] = []
|
raw_events: list[dict] = []
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
timeout = httpx.Timeout(connect=15.0, read=60.0, write=10.0, pool=10.0)
|
||||||
for url in urls:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
|
for url, parse_fn in sources:
|
||||||
try:
|
try:
|
||||||
resp = await client.get(url, headers=headers)
|
resp = await client.get(url, headers=headers)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
html = resp.text
|
found = parse_fn(resp.text)
|
||||||
|
|
||||||
parser = _VDHParser()
|
|
||||||
parser.feed(html)
|
|
||||||
found = parser.get_events()
|
|
||||||
|
|
||||||
if found:
|
if found:
|
||||||
logger.info(f"VDH-Scraper: {len(found)} Events von {url} geparst.")
|
logger.info(f"VDH-Scraper: {len(found)} Events von {url}")
|
||||||
raw_events = found
|
raw_events.extend(found)
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"VDH-Scraper: Keine Events auf {url} gefunden, nächste URL versuchen.")
|
logger.info(f"VDH-Scraper: Keine Events auf {url}")
|
||||||
|
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.warning(f"VDH-Scraper HTTP-Fehler {e.response.status_code} für {url}: {e}")
|
logger.warning(f"VDH-Scraper HTTP-Fehler {e.response.status_code} für {url}: {e}")
|
||||||
except httpx.RequestError as e:
|
except httpx.TimeoutException as e:
|
||||||
logger.warning(f"VDH-Scraper Netzwerkfehler für {url}: {e}")
|
logger.warning(f"VDH-Scraper Timeout für {url}: {type(e).__name__}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"VDH-Scraper unbekannter Fehler für {url}: {e}")
|
logger.warning(f"VDH-Scraper Fehler für {url}: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
if not raw_events:
|
if not raw_events:
|
||||||
logger.warning("VDH-Scraper: Keine Daten erhalten — verwende Fallback-Events.")
|
logger.warning("VDH-Scraper: Keine Daten — verwende Fallback-Events.")
|
||||||
return list(FALLBACK_EVENTS)
|
return list(FALLBACK_EVENTS)
|
||||||
|
|
||||||
# Normalisieren
|
|
||||||
today = datetime.today().strftime("%Y-%m-%d")
|
today = datetime.today().strftime("%Y-%m-%d")
|
||||||
result = []
|
result = []
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
for ev in raw_events:
|
for ev in raw_events:
|
||||||
datum = ev.get("datum", "")
|
datum = ev.get("datum", "")
|
||||||
# Nur zukünftige Events
|
|
||||||
if datum < today:
|
if datum < today:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
titel = ev.get("titel", "").strip()
|
titel = ev.get("titel", "").strip()
|
||||||
if not titel or len(titel) < 3:
|
if not titel or len(titel) < 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
link = ev.get("link", "")
|
|
||||||
if link and link.startswith("/"):
|
|
||||||
link = "https://www.vdh.de" + link
|
|
||||||
|
|
||||||
entry = {
|
entry = {
|
||||||
"titel": titel,
|
"titel": titel,
|
||||||
"datum": datum,
|
"datum": datum,
|
||||||
"ort_name": ev.get("ort_name") or None,
|
"ort_name": ev.get("ort_name") or None,
|
||||||
"typ": _guess_typ(titel),
|
"typ": _guess_typ(titel),
|
||||||
"link": link or "https://www.vdh.de",
|
"link": ev.get("link", "https://www.vdh.de"),
|
||||||
"external_id": _build_external_id(ev),
|
"external_id": _build_external_id(ev),
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry["external_id"] not in seen_ids:
|
if entry["external_id"] not in seen_ids:
|
||||||
seen_ids.add(entry["external_id"])
|
seen_ids.add(entry["external_id"])
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
logger.warning("VDH-Scraper: Nach Filterung 0 zukünftige Events — verwende Fallback-Events.")
|
logger.warning("VDH-Scraper: Nach Filterung 0 Events — verwende Fallback.")
|
||||||
return list(FALLBACK_EVENTS)
|
return list(FALLBACK_EVENTS)
|
||||||
|
|
||||||
logger.info(f"VDH-Scraper: {len(result)} zukünftige Events nach Normalisierung.")
|
logger.info(f"VDH-Scraper: {len(result)} zukünftige Events nach Normalisierung.")
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
--c-danger: #C4391A;
|
--c-danger: #C4391A;
|
||||||
--c-danger-dark: #9E2A10;
|
--c-danger-dark: #9E2A10;
|
||||||
--c-danger-subtle: #FDEEE9;
|
--c-danger-subtle: #FDEEE9;
|
||||||
|
--c-danger-border: rgba(196,57,26,0.3);
|
||||||
--c-success: #5B8A4A;
|
--c-success: #5B8A4A;
|
||||||
--c-success-subtle: #EBF4E7;
|
--c-success-subtle: #EBF4E7;
|
||||||
--c-warning: #D4923A;
|
--c-warning: #D4923A;
|
||||||
|
|
@ -132,6 +133,7 @@
|
||||||
--c-nature-subtle: #1A2214;
|
--c-nature-subtle: #1A2214;
|
||||||
--c-sky-subtle: #141C22;
|
--c-sky-subtle: #141C22;
|
||||||
--c-danger-subtle: #2A100A;
|
--c-danger-subtle: #2A100A;
|
||||||
|
--c-danger-border: rgba(196,57,26,0.4);
|
||||||
--c-success-subtle: #122010;
|
--c-success-subtle: #122010;
|
||||||
--c-warning-subtle: #261A08;
|
--c-warning-subtle: #261A08;
|
||||||
--c-info-subtle: #10182A;
|
--c-info-subtle: #10182A;
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,7 @@
|
||||||
<script src="/js/api.js?v=94"></script>
|
<script src="/js/api.js?v=94"></script>
|
||||||
<script src="/js/ui.js?v=94"></script>
|
<script src="/js/ui.js?v=94"></script>
|
||||||
<script src="/js/app.js?v=94"></script>
|
<script src="/js/app.js?v=94"></script>
|
||||||
<script src="/js/worlds.js?v=774"></script>
|
<script src="/js/worlds.js?v=777"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '774'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '777'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
|
|
|
||||||
|
|
@ -460,22 +460,25 @@ window.Page_ernaehrung = (() => {
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="padding:var(--space-4) 0">
|
<div style="padding:var(--space-4) 0">
|
||||||
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:var(--radius-md);
|
<div style="background:var(--c-warning-subtle,rgba(255,193,7,0.15));
|
||||||
padding:var(--space-3);margin-bottom:var(--space-4);
|
border:1px solid var(--c-warning,#ffc107);
|
||||||
font-size:var(--text-sm)">
|
border-radius:var(--radius-md);padding:var(--space-3);
|
||||||
|
margin-bottom:var(--space-4);font-size:var(--text-sm);
|
||||||
|
color:var(--c-text)">
|
||||||
<strong>⚠️ Notfall-Tierarzt:</strong> Bei Verdacht auf Vergiftung sofort zum Tierarzt.
|
<strong>⚠️ Notfall-Tierarzt:</strong> Bei Verdacht auf Vergiftung sofort zum Tierarzt.
|
||||||
Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
|
Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:grid;gap:var(--space-2)">
|
<div style="display:grid;gap:var(--space-2)">
|
||||||
${items.map(item => `
|
${items.map(item => `
|
||||||
<div style="background:#fff5f5;border:1px solid #fed7d7;
|
<div style="background:var(--c-danger-subtle,rgba(220,38,38,0.08));
|
||||||
|
border:1px solid var(--c-danger-border,rgba(220,38,38,0.25));
|
||||||
border-radius:var(--radius-md);padding:var(--space-3)">
|
border-radius:var(--radius-md);padding:var(--space-3)">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||||
<span style="font-size:1.4rem">${item.emoji}</span>
|
<span style="font-size:1.4rem">${item.emoji}</span>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.name)}</div>
|
<div style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(item.name)}</div>
|
||||||
<div style="font-size:var(--text-xs);color:#c53030">${_esc(item.grund)}</div>
|
<div style="font-size:var(--text-xs);color:var(--c-danger)">${_esc(item.grund)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1236,6 +1236,23 @@ window.Worlds = (() => {
|
||||||
if (_dogIdx >= _dogs.length) _dogIdx = 0;
|
if (_dogIdx >= _dogs.length) _dogIdx = 0;
|
||||||
const dog = _dogs[_dogIdx];
|
const dog = _dogs[_dogIdx];
|
||||||
|
|
||||||
|
// Geburtstag prüfen (heute oder morgen → Feature sichtbar)
|
||||||
|
function _birthdayState(geb) {
|
||||||
|
if (!geb) return null;
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1);
|
||||||
|
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0');
|
||||||
|
const mt = String(tomorrow.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dt = String(tomorrow.getDate()).padStart(2, '0');
|
||||||
|
const mmdd = geb.slice(5); // 'MM-DD'
|
||||||
|
if (mmdd === `${mm}-${dd}`) return 'today';
|
||||||
|
if (mmdd === `${mt}-${dt}`) return 'tomorrow';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bday = _birthdayState(dog.geburtstag);
|
||||||
|
const bdayYear = dog.geburtstag ? new Date().getFullYear() - parseInt(dog.geburtstag.slice(0, 4)) : null;
|
||||||
|
|
||||||
const [streakRes, diaryRes] = await Promise.allSettled([
|
const [streakRes, diaryRes] = await Promise.allSettled([
|
||||||
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
|
_cachedGet(`streak_${dog.id}`, `/streak/${dog.id}`),
|
||||||
_cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`),
|
_cachedGet(`diary_${dog.id}`, `/dogs/${dog.id}/diary?limit=1`),
|
||||||
|
|
@ -1294,6 +1311,41 @@ window.Worlds = (() => {
|
||||||
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
|
<div style="justify-self:end;display:flex;align-items:center">${otherAvatarsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${bday ? `
|
||||||
|
<style>
|
||||||
|
@keyframes bday-pop {
|
||||||
|
0% { transform: scale(0.7) rotate(-8deg); opacity:0; }
|
||||||
|
60% { transform: scale(1.15) rotate(4deg); }
|
||||||
|
100% { transform: scale(1) rotate(0deg); opacity:1; }
|
||||||
|
}
|
||||||
|
@keyframes bday-fw1 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-8px) scale(1.3);opacity:0.7} }
|
||||||
|
@keyframes bday-fw2 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-12px) scale(1.4);opacity:0.6} }
|
||||||
|
@keyframes bday-fw3 { 0%,100%{transform:translateY(0) scale(1);opacity:1} 50%{transform:translateY(-6px) scale(1.2);opacity:0.8} }
|
||||||
|
.bday-pop { animation: bday-pop .5s cubic-bezier(.34,1.56,.64,1) both; }
|
||||||
|
.bday-fw1 { display:inline-block; animation: bday-fw1 1.4s ease-in-out infinite; }
|
||||||
|
.bday-fw2 { display:inline-block; animation: bday-fw2 1.1s ease-in-out infinite .2s; }
|
||||||
|
.bday-fw3 { display:inline-block; animation: bday-fw3 1.6s ease-in-out infinite .4s; }
|
||||||
|
</style>
|
||||||
|
<div class="world-reminder bday-pop" style="flex-direction:column;align-items:center;
|
||||||
|
text-align:center;gap:6px;padding:14px 16px;
|
||||||
|
background:rgba(0,0,0,0.42);border-color:rgba(196,132,58,0.6)">
|
||||||
|
<div style="display:flex;gap:6px;font-size:1.5rem;line-height:1">
|
||||||
|
<span class="bday-fw1">🎆</span>
|
||||||
|
<span style="font-size:2rem">${bday === 'today' ? '🎂' : '🎁'}</span>
|
||||||
|
<span class="bday-fw2">🎇</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:800;font-size:var(--text-sm);color:#fff;letter-spacing:0.01em">
|
||||||
|
${bday === 'today'
|
||||||
|
? `Alles Gute zum ${bdayYear}. Geburtstag, ${_esc(dog.name)}!`
|
||||||
|
: `Morgen hat ${_esc(dog.name)} Geburtstag! 🥳`}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:4px;font-size:1.1rem">
|
||||||
|
<span class="bday-fw3">🎉</span><span class="bday-fw1">✨</span><span class="bday-fw2">🎊</span><span class="bday-fw3">✨</span><span class="bday-fw1">🎉</span>
|
||||||
|
</div>
|
||||||
|
${bdayYear ? `<div style="font-size:10px;color:rgba(255,255,255,0.55)">
|
||||||
|
${bday === 'today' ? `${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} gemeinsam 🐾` : `Wird ${bdayYear} Jahr${bdayYear !== 1 ? 'e' : ''} alt`}
|
||||||
|
</div>` : ''}
|
||||||
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="world-bottom">
|
<div class="world-bottom">
|
||||||
${!_hasBgPhoto ? `
|
${!_hasBgPhoto ? `
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v774';
|
const CACHE_VERSION = 'by-v777';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue