From 7065dfd8d28168857b1e1a4af1f5a62aa66aa7ff Mon Sep 17 00:00:00 2001 From: Ebbe Date: Wed, 2 Sep 2026 20:20:01 +0200 Subject: [PATCH] added uvicorn web api --- api/README.md | 55 +++++++++++ api/__pycache__/datasource.cpython-313.pyc | Bin 0 -> 4592 bytes api/__pycache__/main.cpython-313.pyc | Bin 0 -> 5149 bytes api/__pycache__/models.cpython-313.pyc | Bin 0 -> 1304 bytes api/datasource.py | 68 ++++++++++++++ api/main.py | 102 +++++++++++++++++++++ api/models.py | 23 +++++ api/requirements.txt | 2 + 8 files changed, 250 insertions(+) create mode 100644 api/README.md create mode 100644 api/__pycache__/datasource.cpython-313.pyc create mode 100644 api/__pycache__/main.cpython-313.pyc create mode 100644 api/__pycache__/models.cpython-313.pyc create mode 100644 api/datasource.py create mode 100644 api/main.py create mode 100644 api/models.py create mode 100644 api/requirements.txt 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 0000000000000000000000000000000000000000..41b50340ba2e55b7c37bb13c9ef523639012849a GIT binary patch literal 4592 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 IxP7hv0tQRB761SM literal 0 HcmV?d00001 diff --git a/api/__pycache__/main.cpython-313.pyc b/api/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..193ff22be138936dd5ddd2b33aa212699626afdf GIT binary patch literal 5149 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 literal 0 HcmV?d00001 diff --git a/api/__pycache__/models.cpython-313.pyc b/api/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9198c7ae958715959d3118f962d9f9a0b1573bcc GIT binary patch literal 1304 zcma)5&ubGw6rRn_ZZ>K9GpP;1{t}|@p+>~sL{RC$RNKR%AS^7KWE$6QcjN3t=uPOs zdaH**{S*8P{5vF2!dyIvNNz&o-S=jbQn82&^X+@z%)B@6doyQ~lX(K)kM_x{&l(}$ z&>4Tm$T(|&aZDWIXq%+2ajl-=nHBUin|hscxiOM6l(x$USLhd zW`VUXu-SIb&JS>-osslOkO*mQcSI0JerRhl_s~z+)40XLW*R)jQ6_SB7yM)560SLf zXB>^|RpL_Da56VwMp^o~MbUB$ZnjN3E6oR7K)Qhmq@3`T<3;`+lUGJgC-nC{5$rK9 z5kSe(Up$LipU4&jvpluc^JL!h_TpAIWMCIO?{(J?F(`P`p0^Y5M1(=aq8JE05gb`R zielko%?Ysc9;OpP)ANPkL8B`e(7XW0JcB{xo!Ya6@ubEY4OV*;w}Pl$+v!HFU@d8K zzr*;N-wA3s;2v%NJ~H zt6oi}eA<~rPN%Nw7j{n0rUb~j%Y+Bg?{1Cv?{LPHtagDAt)wlH{>!kEy>XcT~h#2hhX55CEe>7v%01#h20Ecp`ZP=#FZJ?0&&<1!~X*A<* zB&4ZyN>nXdcJ4Akq#i`#f*`8S%LG`3xEui!Bq$>YFfnyd?-yo{=HG9g(uK4*T(f0c z(iHn0xW%c?N?oh7-|{2q8poTFYOnIC*nbKxt$XQ 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