added uvicorn web api

This commit is contained in:
2026-09-02 20:20:01 +02:00
parent cabab25861
commit 7065dfd8d2
8 changed files with 250 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Fundi Scraper API
A small REST API (FastAPI) that serves the events scraped by
[`scraper/scraper.py`](../scraper/scraper.py) from a JSON file.
## Setup
```bash
cd api
pip install -r requirements.txt
```
## Run
```bash
cd api
uvicorn main:app --reload
```
Interactive docs: http://127.0.0.1:8000/docs
By default the API reads `../fundi-scraped-output.json` (the repo root).
Point it elsewhere with an env var:
```bash
DATA_FILE=/path/to/events.json uvicorn main:app
```
## Endpoints
| Method | Path | Description |
|--------|------------------|-------------|
| GET | `/health` | Status + number of events loaded |
| GET | `/events` | List events, with optional filters (see below) |
| GET | `/events/{index}`| Single event by its position in the file (0-based) |
| GET | `/artists` | Deduplicated, sorted list of all artist names |
| POST | `/reload` | Re-read the JSON file from disk (after a fresh scrape) |
### `/events` query parameters
| Param | Type | Meaning |
|------------|--------|---------|
| `free` | bool | Only free / only paid events |
| `name` | string | Case-insensitive substring match on the event name |
| `artist` | string | Case-insensitive substring match on any artist name |
| `date` | string | Exact match on the raw date string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = today or later, `false` = past events |
Examples:
```bash
curl 'http://127.0.0.1:8000/events?free=true'
curl 'http://127.0.0.1:8000/events?artist=randali&upcoming=true'
curl 'http://127.0.0.1:8000/events/0'
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
+68
View File
@@ -0,0 +1,68 @@
"""Loads the scraped events JSON file and keeps it in memory.
The file is treated as read-only. Call ``reload()`` to pick up a fresh
scrape without restarting the server.
"""
import json
import os
from datetime import date
from pathlib import Path
from threading import Lock
from typing import List, Optional
from models import Event
# Default: the fundi-scraped-output.json sitting in the repo root, one
# directory above this file. Override with the DATA_FILE env var.
_DEFAULT_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-scraped-output.json"
class EventStore:
def __init__(self, path: Optional[os.PathLike] = None):
self.path = Path(path or os.environ.get("DATA_FILE") or _DEFAULT_DATA_FILE)
self._lock = Lock()
self._events: List[Event] = []
self.reload()
def reload(self) -> int:
"""Re-read the JSON file from disk. Returns the number of events loaded."""
if not self.path.exists():
raise FileNotFoundError(f"Data file not found: {self.path}")
with self.path.open(encoding="utf-8") as fh:
raw = json.load(fh)
events = [Event.model_validate(item) for item in raw]
with self._lock:
self._events = events
return len(events)
def all(self) -> List[Event]:
with self._lock:
return list(self._events)
def get(self, index: int) -> Optional[Event]:
with self._lock:
if 0 <= index < len(self._events):
return self._events[index]
return None
def parse_event_date(value: str) -> Optional[date]:
"""Parse the scraper's ``dd.mm.yy`` date strings. Returns None if unparseable."""
try:
day, month, year = (int(part) for part in value.strip().split("."))
except (ValueError, AttributeError):
return None
if year < 100:
year += 2000
try:
return date(year, month, day)
except ValueError:
return None
def iter_artist_names(event: Event):
for group in event.event_artists:
for artist in group:
if artist.artist_name:
yield artist.artist_name
+102
View File
@@ -0,0 +1,102 @@
"""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
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from datasource import EventStore, iter_artist_names, parse_event_date
from models import Event, EventList
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():
return {"status": "ok", "events_loaded": len(store.all()), "data_file": str(store.path)}
@app.post("/reload")
def reload():
"""Re-read the JSON file 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}
@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/{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
+23
View File
@@ -0,0 +1,23 @@
from typing import List, Optional
from pydantic import BaseModel
class Artist(BaseModel):
artist_name: str
artist_play_time_start: str
artist_play_time_end: str
class Event(BaseModel):
event_date: str
event_name: str
event_artists: List[List[Artist]]
event_starttime: str
event_ticket_link: str
event_free: Optional[bool] = None
class EventList(BaseModel):
count: int
events: List[Event]
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.103
uvicorn[standard]>=0.23