Fabrik als Provider Korrigiert
This commit is contained in:
parent
d77d9c0c0f
commit
5510d58e5a
@ -9,7 +9,7 @@ DB_NAME=eventlens
|
||||
MYSQL_ROOT_PASSWORD=rootsecret
|
||||
|
||||
TICKETMASTER_API_KEY=
|
||||
BANDSINTOWN_APP_ID=eventlens
|
||||
BANDSINTOWN_APP_ID=
|
||||
EVENTIM_ENABLED=true
|
||||
POLL_INTERVAL_HOURS=6
|
||||
REMINDER_INTERVAL_HOURS=12
|
||||
|
||||
@ -33,7 +33,7 @@ API-Statusinfo findest du unter `http://127.0.0.1:8000/api`.
|
||||
## Wichtige Umgebungsvariablen
|
||||
|
||||
- `TICKETMASTER_API_KEY`: Ticketmaster Discovery API
|
||||
- `BANDSINTOWN_APP_ID`: Bandsintown App-ID fuer Artist-Events
|
||||
- `BANDSINTOWN_APP_ID`: echte Bandsintown App-ID fuer Artist-Events
|
||||
- `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider
|
||||
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
|
||||
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
|
||||
@ -110,3 +110,7 @@ sudo docker compose up -d --build
|
||||
- Ticketmaster nutzt die offizielle Discovery API.
|
||||
- Bandsintown nutzt die offizielle Artist-Events-API und arbeitet deshalb vor allem fuer Watchlist-Eintraege vom Typ `artist`.
|
||||
- Eventim nutzt eine beobachtete oeffentliche JSON-Web-API von eventim.de. Das ist keine offiziell dokumentierte Developer-API, aber stabiler als HTML-Scraping. Aenderungen an diesem Web-Endpoint koennen trotzdem Anpassungen noetig machen.
|
||||
- Eventim kann serverseitig durch Eventim/Akamai geblockt werden. In dem Fall liefert `eventlens` bewusst keine falschen Treffer, sondern ueberspringt den Provider und schreibt einen Hinweis ins Backend-Log.
|
||||
- Bandsintown benoetigt eine echte, von Bandsintown freigeschaltete App-ID. Ohne diese wird der Provider deaktiviert oder als `blocked` angezeigt.
|
||||
- Barclays Arena wird ueber die offizielle Eventseite der Arena abgefragt.
|
||||
- Fabrik wird ueber die offizielle Veranstaltungsseite der Fabrik Hamburg abgefragt.
|
||||
|
||||
@ -12,7 +12,7 @@ class Settings:
|
||||
db_name = os.getenv("DB_NAME", "eventlens")
|
||||
|
||||
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
|
||||
bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "eventlens")
|
||||
bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "")
|
||||
eventim_enabled = os.getenv("EVENTIM_ENABLED", "true").lower() == "true"
|
||||
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
|
||||
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
|
||||
|
||||
@ -115,6 +115,8 @@
|
||||
<option value="ticketmaster">Ticketmaster</option>
|
||||
<option value="bandsintown">Bandsintown</option>
|
||||
<option value="eventim">Eventim</option>
|
||||
<option value="barclays_arena">Barclays Arena</option>
|
||||
<option value="fabrik">Fabrik</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -124,6 +126,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-wide">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="section-kicker">Provider</p>
|
||||
<h2>Status der Quellen</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="provider-status-list" class="provider-status-list empty-state">
|
||||
Noch keine Provider-Statusdaten vorhanden.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-wide">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
|
||||
@ -2,6 +2,7 @@ const state = {
|
||||
watchItems: [],
|
||||
events: [],
|
||||
notifications: [],
|
||||
providerStatuses: [],
|
||||
};
|
||||
|
||||
const watchItemsEl = document.querySelector("#watch-items");
|
||||
@ -13,6 +14,7 @@ const providerFilter = document.querySelector("#provider-filter");
|
||||
const syncButton = document.querySelector("#sync-button");
|
||||
const toastEl = document.querySelector("#toast");
|
||||
const providerSummaryEl = document.querySelector("#provider-summary");
|
||||
const providerStatusListEl = document.querySelector("#provider-status-list");
|
||||
|
||||
function showToast(message) {
|
||||
toastEl.textContent = message;
|
||||
@ -28,7 +30,12 @@ function formatDate(value) {
|
||||
return "unbekannt";
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
const normalizedValue =
|
||||
typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$/.test(value)
|
||||
? `${value}Z`
|
||||
: value;
|
||||
|
||||
const date = new Date(normalizedValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
@ -89,6 +96,8 @@ function prettifyProviderName(value) {
|
||||
ticketmaster: "Ticketmaster",
|
||||
bandsintown: "Bandsintown",
|
||||
eventim: "Eventim",
|
||||
barclays_arena: "Barclays Arena",
|
||||
fabrik: "Fabrik",
|
||||
};
|
||||
|
||||
return names[value] || value;
|
||||
@ -99,6 +108,8 @@ function getProviderClass(value) {
|
||||
ticketmaster: "provider-ticketmaster",
|
||||
bandsintown: "provider-bandsintown",
|
||||
eventim: "provider-eventim",
|
||||
barclays_arena: "provider-barclays-arena",
|
||||
fabrik: "provider-fabrik",
|
||||
};
|
||||
|
||||
return classes[value] || "";
|
||||
@ -128,6 +139,40 @@ function renderProviderSummary() {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderProviderStatuses() {
|
||||
if (!state.providerStatuses.length) {
|
||||
providerStatusListEl.className = "provider-status-list empty-state";
|
||||
providerStatusListEl.textContent = "Noch keine Provider-Statusdaten vorhanden.";
|
||||
return;
|
||||
}
|
||||
|
||||
providerStatusListEl.className = "provider-status-list";
|
||||
providerStatusListEl.innerHTML = state.providerStatuses
|
||||
.map(
|
||||
(entry) => `
|
||||
<article class="provider-status-card">
|
||||
<div class="notification-header">
|
||||
<div>
|
||||
<h3>${escapeHtml(prettifyProviderName(entry.provider_name))}</h3>
|
||||
<div class="pill-row">
|
||||
<span class="pill ${
|
||||
entry.status === "ok"
|
||||
? "success"
|
||||
: entry.status === "blocked"
|
||||
? "warning"
|
||||
: "danger"
|
||||
}">${escapeHtml(entry.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="muted">${escapeHtml(formatDate(entry.last_checked_at))}</span>
|
||||
</div>
|
||||
<p>${escapeHtml(entry.message)}</p>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderWatchItems() {
|
||||
if (!state.watchItems.length) {
|
||||
watchItemsEl.className = "watch-list empty-state";
|
||||
@ -283,19 +328,22 @@ function updateSyncStatus(message) {
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const [watchItems, events, notifications] = await Promise.all([
|
||||
const [watchItems, events, notifications, providerStatuses] = await Promise.all([
|
||||
apiFetch("/watch-items"),
|
||||
apiFetch("/events"),
|
||||
apiFetch("/notifications"),
|
||||
apiFetch("/provider-statuses"),
|
||||
]);
|
||||
|
||||
state.watchItems = watchItems;
|
||||
state.events = events;
|
||||
state.notifications = notifications;
|
||||
state.providerStatuses = providerStatuses;
|
||||
|
||||
renderStats();
|
||||
renderWatchItems();
|
||||
renderEvents();
|
||||
renderProviderStatuses();
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
|
||||
@ -255,7 +255,8 @@ button:hover,
|
||||
|
||||
.watch-list,
|
||||
.event-list,
|
||||
.notification-list {
|
||||
.notification-list,
|
||||
.provider-status-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
@ -270,7 +271,8 @@ button:hover,
|
||||
|
||||
.watch-card,
|
||||
.event-card,
|
||||
.notification-card {
|
||||
.notification-card,
|
||||
.provider-status-card {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-strong);
|
||||
@ -357,13 +359,24 @@ button:hover,
|
||||
color: #4f3790;
|
||||
}
|
||||
|
||||
.provider-barclays-arena {
|
||||
background: rgba(26, 87, 163, 0.14);
|
||||
color: #1a57a3;
|
||||
}
|
||||
|
||||
.provider-fabrik {
|
||||
background: rgba(154, 77, 24, 0.14);
|
||||
color: #8e4516;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.watch-card p,
|
||||
.event-card p,
|
||||
.notification-card p {
|
||||
.notification-card p,
|
||||
.provider-status-card p {
|
||||
margin-top: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ from app.models import TrackedEvent, WatchItem
|
||||
from app.scheduler import start_scheduler
|
||||
from app.schemas import (
|
||||
NotificationLogRead,
|
||||
ProviderStatusRead,
|
||||
PurchaseUpdate,
|
||||
SyncResult,
|
||||
TrackedEventRead,
|
||||
@ -19,7 +20,13 @@ from app.schemas import (
|
||||
WatchItemRead,
|
||||
WatchItemUpdate,
|
||||
)
|
||||
from app.services import list_events, list_notifications, list_watch_items, run_sync
|
||||
from app.services import (
|
||||
list_events,
|
||||
list_notifications,
|
||||
list_provider_statuses,
|
||||
list_watch_items,
|
||||
run_sync,
|
||||
)
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@ -135,6 +142,11 @@ def get_notifications(db: Session = Depends(get_db)):
|
||||
return list_notifications(db)
|
||||
|
||||
|
||||
@app.get("/provider-statuses", response_model=list[ProviderStatusRead])
|
||||
def get_provider_statuses(db: Session = Depends(get_db)):
|
||||
return list_provider_statuses(db)
|
||||
|
||||
|
||||
@app.post("/sync", response_model=SyncResult)
|
||||
def trigger_sync(db: Session = Depends(get_db)):
|
||||
return run_sync(db)
|
||||
|
||||
@ -29,6 +29,12 @@ class NotificationStatus(str, Enum):
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class ProviderStatusType(str, Enum):
|
||||
ok = "ok"
|
||||
blocked = "blocked"
|
||||
error = "error"
|
||||
|
||||
|
||||
class WatchItem(Base):
|
||||
__tablename__ = "watch_items"
|
||||
|
||||
@ -109,3 +115,17 @@ class NotificationLog(Base):
|
||||
|
||||
tracked_event: Mapped[TrackedEvent] = relationship(back_populates="notifications")
|
||||
|
||||
|
||||
class ProviderStatus(Base):
|
||||
__tablename__ = "provider_statuses"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
provider_name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
|
||||
status: Mapped[ProviderStatusType] = mapped_column(
|
||||
SqlEnum(ProviderStatusType), nullable=False
|
||||
)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
last_checked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
last_success_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@ -5,6 +5,7 @@ import requests
|
||||
|
||||
from app.config import settings
|
||||
from app.models import RegionScope, WatchType
|
||||
from app.providers.utils import normalize_search_text
|
||||
|
||||
|
||||
class BandsintownProvider:
|
||||
@ -21,9 +22,21 @@ class BandsintownProvider:
|
||||
region_scope: RegionScope,
|
||||
) -> list[dict]:
|
||||
if not self.is_configured():
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
"Bandsintown ist nicht konfiguriert. Bitte eine gueltige BANDSINTOWN_APP_ID setzen.",
|
||||
)
|
||||
return []
|
||||
|
||||
if watch_type != WatchType.artist:
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Bandsintown skipped non-artist watch item '{term}'.",
|
||||
)
|
||||
return []
|
||||
|
||||
artist_name = quote(term, safe="")
|
||||
@ -34,15 +47,46 @@ class BandsintownProvider:
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code in {401, 403, 429}:
|
||||
setattr(self, "last_status", "blocked")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
(
|
||||
f"Bandsintown rejected app_id for '{term}' "
|
||||
f"with status {response.status_code}."
|
||||
),
|
||||
)
|
||||
return []
|
||||
|
||||
if response.status_code == 404:
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Bandsintown found no artist match for '{term}'.",
|
||||
)
|
||||
return []
|
||||
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
setattr(self, "last_status", "error")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Bandsintown returned an unexpected payload for '{term}'.",
|
||||
)
|
||||
return []
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Bandsintown returned {len(payload)} raw events for term '{term}'.",
|
||||
)
|
||||
|
||||
results: list[dict] = []
|
||||
normalized_term = normalize_search_text(term)
|
||||
for item in payload:
|
||||
venue = item.get("venue") or {}
|
||||
city = venue.get("city")
|
||||
@ -66,10 +110,16 @@ class BandsintownProvider:
|
||||
offers = item.get("offers") or []
|
||||
ticket_url = next((offer.get("url") for offer in offers if offer.get("url")), None)
|
||||
|
||||
title = item.get("title") or f"{term} live"
|
||||
artist_name = item.get("artist", {}).get("name", term)
|
||||
haystack = normalize_search_text(f"{artist_name} {title}")
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
|
||||
results.append(
|
||||
{
|
||||
"external_id": str(item.get("id")),
|
||||
"title": item.get("title") or f"{term} live",
|
||||
"title": title,
|
||||
"matched_term": term,
|
||||
"venue_name": venue.get("name"),
|
||||
"city": city,
|
||||
@ -82,4 +132,3 @@ class BandsintownProvider:
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
101
backend/app/providers/barclays_arena.py
Normal file
101
backend/app/providers/barclays_arena.py
Normal file
@ -0,0 +1,101 @@
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.models import RegionScope, WatchType
|
||||
from app.providers.utils import normalize_search_text
|
||||
|
||||
|
||||
class BarclaysArenaProvider:
|
||||
source_name = "barclays_arena"
|
||||
events_url = "https://www.barclays-arena.de/events"
|
||||
|
||||
def search_events(
|
||||
self,
|
||||
term: str,
|
||||
watch_type: WatchType,
|
||||
region_scope: RegionScope,
|
||||
) -> list[dict]:
|
||||
response = requests.get(
|
||||
self.events_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
normalized_term = normalize_search_text(term)
|
||||
results: list[dict] = []
|
||||
|
||||
headings = soup.find_all("h3")
|
||||
for heading in headings:
|
||||
title = heading.get_text(" ", strip=True)
|
||||
if not title:
|
||||
continue
|
||||
|
||||
subtitle = ""
|
||||
subtitle_el = heading.find_next("h4")
|
||||
if subtitle_el:
|
||||
subtitle = subtitle_el.get_text(" ", strip=True)
|
||||
|
||||
haystack = normalize_search_text(f"{title} {subtitle}")
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
|
||||
date_text = self._find_previous_date_text(heading)
|
||||
event_date = self._parse_german_date(date_text)
|
||||
|
||||
link = heading.find_previous("a", href=True)
|
||||
if link is None:
|
||||
continue
|
||||
|
||||
results.append(
|
||||
{
|
||||
"external_id": link["href"],
|
||||
"title": title,
|
||||
"matched_term": term,
|
||||
"venue_name": "Barclays Arena",
|
||||
"city": "Hamburg",
|
||||
"country_code": "DE",
|
||||
"event_date": event_date,
|
||||
"ticket_url": urljoin(self.events_url, link["href"]),
|
||||
"image_url": None,
|
||||
"raw_payload": {
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"date_text": date_text,
|
||||
"href": link["href"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.last_status = "ok"
|
||||
self.last_message = (
|
||||
f"Barclays Arena returned {len(results)} matched events for term '{term}'."
|
||||
)
|
||||
return results
|
||||
|
||||
def _find_previous_date_text(self, heading) -> str | None:
|
||||
current = heading.previous_sibling
|
||||
while current is not None:
|
||||
text = getattr(current, "get_text", lambda *args, **kwargs: str(current))(
|
||||
" ", strip=True
|
||||
)
|
||||
if text and "|" in text:
|
||||
return text
|
||||
current = current.previous_sibling
|
||||
return None
|
||||
|
||||
def _parse_german_date(self, value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parts = [part.strip() for part in value.split("|")]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(parts[1], "%d.%m.%Y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from app.config import settings
|
||||
from app.models import RegionScope, WatchType
|
||||
from app.providers.utils import normalize_search_text
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventimProvider:
|
||||
@ -46,12 +51,31 @@ class EventimProvider:
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code in {403, 429}:
|
||||
logger.warning(
|
||||
"Eventim API blocked request with status %s for term '%s'.",
|
||||
response.status_code,
|
||||
term,
|
||||
)
|
||||
setattr(self, "last_status", "blocked")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Eventim API blocked request with status {response.status_code} for term '{term}'.",
|
||||
)
|
||||
return []
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
products = payload.get("products") or []
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Eventim returned {len(products)} raw products for term '{term}'.",
|
||||
)
|
||||
|
||||
results: list[dict] = []
|
||||
normalized_term = term.casefold()
|
||||
normalized_term = normalize_search_text(term)
|
||||
|
||||
for product in products:
|
||||
title = product.get("name") or ""
|
||||
@ -59,10 +83,10 @@ class EventimProvider:
|
||||
attraction_names = [entry.get("name", "") for entry in attractions]
|
||||
|
||||
if watch_type == WatchType.artist:
|
||||
haystack = " ".join(attraction_names + [title]).casefold()
|
||||
haystack = normalize_search_text(" ".join(attraction_names + [title]))
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
elif normalized_term not in title.casefold():
|
||||
elif normalized_term not in normalize_search_text(title):
|
||||
continue
|
||||
|
||||
live_data = product.get("typeAttributes", {}).get("liveEntertainment", {})
|
||||
|
||||
88
backend/app/providers/fabrik.py
Normal file
88
backend/app/providers/fabrik.py
Normal file
@ -0,0 +1,88 @@
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.models import RegionScope, WatchType
|
||||
from app.providers.utils import normalize_search_text
|
||||
|
||||
|
||||
class FabrikProvider:
|
||||
source_name = "fabrik"
|
||||
events_url = "https://fabrik.de/programm"
|
||||
|
||||
def search_events(
|
||||
self,
|
||||
term: str,
|
||||
watch_type: WatchType,
|
||||
region_scope: RegionScope,
|
||||
) -> list[dict]:
|
||||
response = requests.get(
|
||||
self.events_url,
|
||||
headers={"User-Agent": "Mozilla/5.0"},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
normalized_term = normalize_search_text(term)
|
||||
results: list[dict] = []
|
||||
|
||||
for event_block in soup.select(".programm_termin"):
|
||||
detail_link = event_block.select_one('a[href*="/veranstaltungsdetail/"][title]')
|
||||
if detail_link is None:
|
||||
continue
|
||||
|
||||
title = detail_link.get("title") or detail_link.get_text(" ", strip=True)
|
||||
if not title:
|
||||
continue
|
||||
|
||||
block_text = event_block.get_text(" ", strip=True)
|
||||
haystack = normalize_search_text(f"{title} {block_text}")
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
|
||||
date_el = event_block.select_one(".programm_termin_lh_datum a")
|
||||
date_text = date_el.get_text(" ", strip=True) if date_el else ""
|
||||
event_date = self._parse_fabrik_date(date_text)
|
||||
href = detail_link["href"]
|
||||
|
||||
results.append(
|
||||
{
|
||||
"external_id": href,
|
||||
"title": title,
|
||||
"matched_term": term,
|
||||
"venue_name": "Fabrik",
|
||||
"city": "Hamburg",
|
||||
"country_code": "DE",
|
||||
"event_date": event_date,
|
||||
"ticket_url": urljoin(self.events_url, href),
|
||||
"image_url": None,
|
||||
"raw_payload": {
|
||||
"title": title,
|
||||
"date_text": date_text,
|
||||
"href": href,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
unique_results: dict[str, dict] = {}
|
||||
for result in results:
|
||||
unique_results[result["external_id"]] = result
|
||||
|
||||
self.last_status = "ok"
|
||||
self.last_message = (
|
||||
f"Fabrik returned {len(unique_results)} matched events for term '{term}'."
|
||||
)
|
||||
return list(unique_results.values())
|
||||
|
||||
def _parse_fabrik_date(self, value: str) -> datetime | None:
|
||||
cleaned = value.replace(" ", "")
|
||||
if len(cleaned) < 10:
|
||||
return None
|
||||
date_value = cleaned[-10:]
|
||||
try:
|
||||
return datetime.strptime(date_value, "%d.%m.%Y")
|
||||
except ValueError:
|
||||
return None
|
||||
@ -1,5 +1,7 @@
|
||||
from app.providers.bandsintown import BandsintownProvider
|
||||
from app.providers.barclays_arena import BarclaysArenaProvider
|
||||
from app.providers.eventim import EventimProvider
|
||||
from app.providers.fabrik import FabrikProvider
|
||||
from app.providers.ticketmaster import TicketmasterProvider
|
||||
|
||||
|
||||
@ -8,5 +10,6 @@ def get_providers():
|
||||
TicketmasterProvider(),
|
||||
BandsintownProvider(),
|
||||
EventimProvider(),
|
||||
BarclaysArenaProvider(),
|
||||
FabrikProvider(),
|
||||
]
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import requests
|
||||
|
||||
from app.config import settings
|
||||
from app.models import RegionScope, WatchType
|
||||
from app.providers.utils import normalize_search_text
|
||||
|
||||
|
||||
class TicketmasterProvider:
|
||||
@ -38,19 +39,25 @@ class TicketmasterProvider:
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("_embedded", {}).get("events", [])
|
||||
setattr(self, "last_status", "ok")
|
||||
setattr(
|
||||
self,
|
||||
"last_message",
|
||||
f"Ticketmaster returned {len(items)} raw events for term '{term}'.",
|
||||
)
|
||||
results: list[dict] = []
|
||||
|
||||
normalized_term = term.casefold()
|
||||
normalized_term = normalize_search_text(term)
|
||||
for item in items:
|
||||
title = item.get("name", "")
|
||||
attractions = item.get("_embedded", {}).get("attractions", [])
|
||||
attraction_names = [entry.get("name", "") for entry in attractions]
|
||||
|
||||
if watch_type == WatchType.artist:
|
||||
haystack = " ".join(attraction_names + [title]).casefold()
|
||||
haystack = normalize_search_text(" ".join(attraction_names + [title]))
|
||||
if normalized_term not in haystack:
|
||||
continue
|
||||
elif normalized_term not in title.casefold():
|
||||
elif normalized_term not in normalize_search_text(title):
|
||||
continue
|
||||
|
||||
dates = item.get("dates", {}).get("start", {})
|
||||
@ -88,4 +95,3 @@ class TicketmasterProvider:
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
16
backend/app/providers/utils.py
Normal file
16
backend/app/providers/utils.py
Normal file
@ -0,0 +1,16 @@
|
||||
import unicodedata
|
||||
|
||||
|
||||
def normalize_search_text(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
value = (
|
||||
value.casefold()
|
||||
.replace("ä", "ae")
|
||||
.replace("ö", "oe")
|
||||
.replace("ü", "ue")
|
||||
.replace("ß", "ss")
|
||||
)
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
return "".join(char for char in normalized if not unicodedata.combining(char))
|
||||
@ -2,7 +2,13 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models import NotificationStatus, NotificationType, RegionScope, WatchType
|
||||
from app.models import (
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
ProviderStatusType,
|
||||
RegionScope,
|
||||
WatchType,
|
||||
)
|
||||
|
||||
|
||||
class WatchItemCreate(BaseModel):
|
||||
@ -78,3 +84,13 @@ class SyncResult(BaseModel):
|
||||
notifications_sent: int
|
||||
notifications_skipped: int
|
||||
|
||||
|
||||
class ProviderStatusRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
provider_name: str
|
||||
status: ProviderStatusType
|
||||
message: str
|
||||
last_checked_at: datetime
|
||||
last_success_at: datetime | None
|
||||
|
||||
@ -1,21 +1,38 @@
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import re
|
||||
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
|
||||
from app.models import (
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
ProviderStatus,
|
||||
ProviderStatusType,
|
||||
TrackedEvent,
|
||||
WatchItem,
|
||||
)
|
||||
from app.notifications import send_email_notification
|
||||
from app.providers.registry import get_providers
|
||||
from app.schemas import SyncResult
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PROVIDER_PRIORITY = {
|
||||
"ticketmaster": 3,
|
||||
"eventim": 2,
|
||||
"bandsintown": 1,
|
||||
}
|
||||
|
||||
PROVIDER_STATUS_PRIORITY = {
|
||||
ProviderStatusType.error: 3,
|
||||
ProviderStatusType.blocked: 2,
|
||||
ProviderStatusType.ok: 1,
|
||||
}
|
||||
|
||||
|
||||
def list_watch_items(db: Session) -> list[WatchItem]:
|
||||
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
|
||||
@ -50,6 +67,86 @@ def list_notifications(db: Session):
|
||||
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
|
||||
|
||||
|
||||
def list_provider_statuses(db: Session) -> list[ProviderStatus]:
|
||||
return list(db.scalars(select(ProviderStatus).order_by(ProviderStatus.provider_name)))
|
||||
|
||||
|
||||
def update_provider_status(
|
||||
db: Session,
|
||||
provider_name: str,
|
||||
status: ProviderStatusType,
|
||||
message: str,
|
||||
):
|
||||
provider_status = db.scalar(
|
||||
select(ProviderStatus).where(ProviderStatus.provider_name == provider_name)
|
||||
)
|
||||
now = datetime.utcnow()
|
||||
|
||||
if provider_status is None:
|
||||
provider_status = ProviderStatus(
|
||||
provider_name=provider_name,
|
||||
status=status,
|
||||
message=message,
|
||||
last_checked_at=now,
|
||||
last_success_at=now if status == ProviderStatusType.ok else None,
|
||||
)
|
||||
db.add(provider_status)
|
||||
return
|
||||
|
||||
provider_status.status = status
|
||||
provider_status.message = message
|
||||
provider_status.last_checked_at = now
|
||||
if status == ProviderStatusType.ok:
|
||||
provider_status.last_success_at = now
|
||||
|
||||
|
||||
def init_provider_sync_state(provider_name: str) -> dict:
|
||||
return {
|
||||
"provider_name": provider_name,
|
||||
"status": ProviderStatusType.ok,
|
||||
"ok_count": 0,
|
||||
"blocked_terms": [],
|
||||
"error_terms": [],
|
||||
"last_message": "",
|
||||
}
|
||||
|
||||
|
||||
def record_provider_sync_state(
|
||||
state: dict,
|
||||
status: ProviderStatusType,
|
||||
term: str,
|
||||
message: str,
|
||||
):
|
||||
if PROVIDER_STATUS_PRIORITY[status] > PROVIDER_STATUS_PRIORITY[state["status"]]:
|
||||
state["status"] = status
|
||||
|
||||
if status == ProviderStatusType.ok:
|
||||
state["ok_count"] += 1
|
||||
elif status == ProviderStatusType.blocked:
|
||||
if term not in state["blocked_terms"]:
|
||||
state["blocked_terms"].append(term)
|
||||
elif status == ProviderStatusType.error:
|
||||
if term not in state["error_terms"]:
|
||||
state["error_terms"].append(term)
|
||||
|
||||
state["last_message"] = message
|
||||
|
||||
|
||||
def build_provider_status_message(state: dict) -> str:
|
||||
if state["status"] == ProviderStatusType.error:
|
||||
terms = ", ".join(state["error_terms"][:3])
|
||||
return f"Fehler bei {state['provider_name']} fuer: {terms}."
|
||||
|
||||
if state["status"] == ProviderStatusType.blocked:
|
||||
terms = ", ".join(state["blocked_terms"][:3])
|
||||
return f"{state['provider_name']} wurde geblockt fuer: {terms}."
|
||||
|
||||
return (
|
||||
f"{state['provider_name']} erfolgreich fuer {state['ok_count']} "
|
||||
f"Watchlist-Eintraege geprueft."
|
||||
)
|
||||
|
||||
|
||||
def normalize_event_text(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
@ -164,6 +261,10 @@ def upsert_event(
|
||||
|
||||
def run_sync(db: Session) -> SyncResult:
|
||||
providers = get_providers()
|
||||
provider_states = {
|
||||
provider.source_name: init_provider_sync_state(provider.source_name)
|
||||
for provider in providers
|
||||
}
|
||||
active_items = list(
|
||||
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
|
||||
)
|
||||
@ -181,8 +282,31 @@ def run_sync(db: Session) -> SyncResult:
|
||||
watch_type=watch_item.watch_type,
|
||||
region_scope=watch_item.region_scope,
|
||||
)
|
||||
provider_status_name = getattr(provider, "last_status", "ok")
|
||||
provider_message = getattr(
|
||||
provider,
|
||||
"last_message",
|
||||
f"{provider.source_name} completed successfully for '{watch_item.name}'.",
|
||||
)
|
||||
record_provider_sync_state(
|
||||
state=provider_states[provider.source_name],
|
||||
status=ProviderStatusType(provider_status_name),
|
||||
term=watch_item.name,
|
||||
message=provider_message,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Provider sync failed for provider=%s watch_item=%s",
|
||||
provider.source_name,
|
||||
watch_item.name,
|
||||
)
|
||||
db.rollback()
|
||||
record_provider_sync_state(
|
||||
state=provider_states[provider.source_name],
|
||||
status=ProviderStatusType.error,
|
||||
term=watch_item.name,
|
||||
message=f"Provider sync failed for '{watch_item.name}'.",
|
||||
)
|
||||
continue
|
||||
for event_data in events:
|
||||
tracked_event, is_new = upsert_event(
|
||||
@ -225,6 +349,15 @@ def run_sync(db: Session) -> SyncResult:
|
||||
notifications_skipped += 1
|
||||
db.commit()
|
||||
|
||||
for provider_name, state in provider_states.items():
|
||||
update_provider_status(
|
||||
db=db,
|
||||
provider_name=provider_name,
|
||||
status=state["status"],
|
||||
message=build_provider_status_message(state),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return SyncResult(
|
||||
scanned_watch_items=len(active_items),
|
||||
new_events=new_events,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
apscheduler==3.10.4
|
||||
beautifulsoup4==4.12.3
|
||||
fastapi==0.115.12
|
||||
pymysql==1.1.1
|
||||
python-dotenv==1.0.1
|
||||
requests==2.32.3
|
||||
sqlalchemy==2.0.40
|
||||
uvicorn[standard]==0.34.1
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user