Files
fundi-scraper-api/api/main.py
T
ebbe.bassandClaude Sonnet 5 eaed7d4ce6 Add archive scraping, webcal feed, and scrape schedule reporting
- scraper.py: --archive flag to scrape https://fundbureau.de/archiv.html
  (past events), fixing a container-specific wait selector and a crash
  on events with no door time that this surfaced.
- api: EventStore now merges an optional archive JSON file into the
  main event list (deduped by date+name); adds GET /events/archive.
- api: GET /calendar.ics serves an RFC 5545 feed of all events for
  webcal subscriptions, with an all-day fallback when no door time is
  parseable.
- api: GET /health reports last-scrape time (from file mtime) and, via
  new SCRAPE_CRON/ARCHIVE_SCRAPE_CRON env vars, the next scheduled run
  and seconds until it, for both the regular and archive scrape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 13:42:51 +02:00

156 lines
5.1 KiB
Python

"""REST API over the scraped Fundbureau events.
Datasource is the JSON file produced by ``scraper/scraper.py``
(``fundi-scraped-output.json`` by default; override with the DATA_FILE env var).
Run with:
uvicorn main:app --reload # from inside the api/ folder
"""
import datetime
import os
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date
from models import Event, EventList
from scrape_schedule import get_schedule_info
app = FastAPI(
title="Fundi Scraper API",
description="REST API over the scraped upcoming/past events at the Fundbureau Hamburg.",
version="1.0.0",
)
store = EventStore()
def _matches(
event: Event,
free: Optional[bool],
name: Optional[str],
artist: Optional[str],
event_date: Optional[str],
upcoming: Optional[bool],
today: datetime.date,
) -> bool:
if free is not None and event.event_free is not free:
return False
if name is not None and name.lower() not in event.event_name.lower():
return False
if artist is not None:
needle = artist.lower()
if not any(needle in n.lower() for n in iter_artist_names(event)):
return False
if event_date is not None and event.event_date != event_date:
return False
if upcoming is not None:
parsed = parse_event_date(event.event_date)
if parsed is None:
return False
is_upcoming = parsed >= today
if is_upcoming is not upcoming:
return False
return True
@app.get("/health")
def health():
scrape_cron = os.environ.get("SCRAPE_CRON")
archive_scrape_cron = os.environ.get("ARCHIVE_SCRAPE_CRON") or scrape_cron
return {
"status": "ok",
"events_loaded": len(store.all()),
"data_file": str(store.path),
"current_events": store.current_count,
"archive_file": str(store.archive_path),
"archive_file_found": store.archive_path.exists(),
"archive_events": store.archive_count,
"scrape": get_schedule_info(store.path, scrape_cron),
"archive_scrape": get_schedule_info(store.archive_path, archive_scrape_cron),
}
@app.post("/reload")
def reload():
"""Re-read the data file(s) from disk (e.g. after a fresh scrape)."""
try:
count = store.reload()
except FileNotFoundError as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {
"reloaded": True,
"events_loaded": count,
"current_events": store.current_count,
"archive_events": store.archive_count,
}
@app.get("/events", response_model=EventList)
def list_events(
free: Optional[bool] = Query(None, description="Filter by free admission"),
name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"),
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
upcoming: Optional[bool] = Query(None, description="true = today or later, false = past events"),
):
today = datetime.date.today()
events = [
e
for e in store.all()
if _matches(e, free, name, artist, date, upcoming, today)
]
return EventList(count=len(events), events=events)
@app.get("/artists", response_model=List[str])
def list_artists():
"""Deduplicated, sorted list of all artist names across all events."""
names = set()
for event in store.all():
names.update(iter_artist_names(event))
return sorted(names, key=str.lower)
@app.get("/events/archive", response_model=EventList)
def list_archive_events(
free: Optional[bool] = Query(None, description="Filter by free admission"),
name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"),
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist name"),
date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
):
"""Past events only - shorthand for ``/events?upcoming=false``."""
today = datetime.date.today()
events = [
e
for e in store.all()
if _matches(e, free, name, artist, date, upcoming=False, today=today)
]
return EventList(count=len(events), events=events)
@app.get("/calendar.ics")
def calendar_ics():
"""iCalendar feed of all known events (upcoming + archived).
Subscribe from a calendar app with ``webcal://<host>/calendar.ics``
(or the plain https URL) to get an auto-refreshing calendar.
"""
ics = build_ics(store.all())
return PlainTextResponse(
content=ics,
media_type="text/calendar",
headers={"Content-Disposition": "inline; filename=fundbureau-events.ics"},
)
@app.get("/events/{index}", response_model=Event)
def get_event(index: int):
event = store.get(index)
if event is None:
raise HTTPException(status_code=404, detail=f"No event at index {index}")
return event