diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..3e29ecb --- /dev/null +++ b/api/README.md @@ -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' +``` diff --git a/api/__pycache__/datasource.cpython-313.pyc b/api/__pycache__/datasource.cpython-313.pyc new file mode 100644 index 0000000..41b5034 Binary files /dev/null and b/api/__pycache__/datasource.cpython-313.pyc differ diff --git a/api/__pycache__/main.cpython-313.pyc b/api/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..193ff22 Binary files /dev/null and b/api/__pycache__/main.cpython-313.pyc differ diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..9198c7a Binary files /dev/null and b/api/__pycache__/models.cpython-313.pyc differ diff --git a/api/datasource.py b/api/datasource.py new file mode 100644 index 0000000..c8204e8 --- /dev/null +++ b/api/datasource.py @@ -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 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..b1ac055 --- /dev/null +++ b/api/main.py @@ -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 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..8dbabd8 --- /dev/null +++ b/api/models.py @@ -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] diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..f53a12a --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.103 +uvicorn[standard]>=0.23