added uvicorn web api
This commit is contained in:
@@ -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.
@@ -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
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi>=0.103
|
||||||
|
uvicorn[standard]>=0.23
|
||||||
Reference in New Issue
Block a user