added sorting and artist dates

This commit is contained in:
Ebbe Baß
2026-09-09 14:10:15 +02:00
parent b2468cc083
commit 5d56df1af8
11 changed files with 96 additions and 11 deletions
+14 -2
View File
@@ -126,7 +126,7 @@ DATA_FILE=/path/to/events.json ARCHIVE_DATA_FILE=/path/to/archive.json uvicorn m
| `GET` | `/events` | List events, with optional filters (below) | | `GET` | `/events` | List events, with optional filters (below) |
| `GET` | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` | | `GET` | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` |
| `GET` | `/events/{index}` | A single event by its 0-based position in the merged list; `404` if out of range | | `GET` | `/events/{index}` | A single event by its 0-based position in the merged list; `404` if out of range |
| `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artist names | | `GET` | `/artists` | Deduplicated, case-insensitively sorted list of all artists, each with every date they appear on |
| `GET` | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe from any calendar app | | `GET` | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe from any calendar app |
| `POST` | `/reload` | Re-read the data file(s) from disk (call after re-running the scraper); rate-limited (below); `500` if the main file is missing | | `POST` | `/reload` | Re-read the data file(s) from disk (call after re-running the scraper); rate-limited (below); `500` if the main file is missing |
@@ -142,6 +142,7 @@ same filters except `upcoming` (it's always `false`).
| `artist` | string | Case-insensitive substring match on any artist name in the line-up | | `artist` | string | Case-insensitive substring match on any artist name in the line-up |
| `date` | string | Exact match on the raw `event_date` string (`dd.mm.yy`) | | `date` | string | Exact match on the raw `event_date` string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = event date is today or later, `false` = past events (events with an unparseable date are excluded) | | `upcoming` | bool | `true` = event date is today or later, `false` = past events (events with an unparseable date are excluded) |
| `sort` | string | `asc`/`desc` = chronological by `event_date` (events with an unparseable date always sort last, regardless of direction); `az`/`za` = alphabetical by `event_name` (case-insensitive) |
### Response shapes ### Response shapes
@@ -155,7 +156,15 @@ same filters except `upcoming` (it's always `false`).
``` ```
`GET /events/{index}` returns a single event object (same schema as the scraper `GET /events/{index}` returns a single event object (same schema as the scraper
output). `GET /artists` returns a plain JSON array of strings. output). `GET /artists` returns each artist once with every date (past or
upcoming) they're in the line-up for, sorted chronologically:
```json
[
{ "artist_name": "Bizarre", "dates": ["05.09.26", "12.10.26"] },
{ "artist_name": "Skkin Velvet", "dates": ["04.09.26"] }
]
```
### Scrape schedule reporting ### Scrape schedule reporting
@@ -238,6 +247,9 @@ curl 'https://fundi.api.example.com/events?artist=randali&upcoming=true'
# Everything on a given night # Everything on a given night
curl 'https://fundi.api.example.com/events?date=05.09.26' curl 'https://fundi.api.example.com/events?date=05.09.26'
# Soonest events first
curl 'https://fundi.api.example.com/events?sort=asc'
# Past events (archive) # Past events (archive)
curl https://fundi.api.example.com/events/archive curl https://fundi.api.example.com/events/archive
+18 -1
View File
@@ -75,12 +75,14 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
| GET | `/events` | List events, with optional filters (see below) | | GET | `/events` | List events, with optional filters (see below) |
| GET | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` | | GET | `/events/archive` | Past events only — shorthand for `/events?upcoming=false` |
| GET | `/events/{index}` | Single event by its position in the merged list (0-based) | | GET | `/events/{index}` | Single event by its position in the merged list (0-based) |
| GET | `/artists` | Deduplicated, sorted list of all artist names | | GET | `/artists` | Deduplicated, sorted list of all artists with every date they appear on |
| GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal://<host>/calendar.ics` | | GET | `/calendar.ics` | iCalendar/webcal feed of all events — subscribe with `webcal://<host>/calendar.ics` |
| POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) - rate-limited, see below | | POST | `/reload` | Re-read the data file(s) from disk (after a fresh scrape) - rate-limited, see below |
### `/events` query parameters ### `/events` query parameters
`/events/archive` accepts the same parameters except `upcoming` (always `false`).
| Param | Type | Meaning | | Param | Type | Meaning |
|------------|--------|---------| |------------|--------|---------|
| `free` | bool | Only free / only paid events | | `free` | bool | Only free / only paid events |
@@ -88,11 +90,26 @@ RELOAD_MIN_INTERVAL_SECONDS=30 uvicorn main:app
| `artist` | string | Case-insensitive substring match on any artist name | | `artist` | string | Case-insensitive substring match on any artist name |
| `date` | string | Exact match on the raw date string (`dd.mm.yy`) | | `date` | string | Exact match on the raw date string (`dd.mm.yy`) |
| `upcoming` | bool | `true` = today or later, `false` = past events | | `upcoming` | bool | `true` = today or later, `false` = past events |
| `sort` | string | `asc`/`desc` = chronological by event date (unparseable dates sort last either way); `az`/`za` = alphabetical by event name (case-insensitive) |
Examples: Examples:
```bash ```bash
curl 'http://127.0.0.1:8000/events?free=true' 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?artist=randali&upcoming=true'
curl 'http://127.0.0.1:8000/events?sort=asc'
curl 'http://127.0.0.1:8000/events?sort=za'
curl 'http://127.0.0.1:8000/events/0' curl 'http://127.0.0.1:8000/events/0'
``` ```
### `/artists` response shape
Each artist appears once, with every date (past or upcoming) they're on the
line-up for, sorted chronologically:
```json
[
{ "artist_name": "Bizarre", "dates": ["05.09.26", "12.10.26"] },
{ "artist_name": "Skkin Velvet", "dates": ["04.09.26"] }
]
```
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35
View File
@@ -109,3 +109,38 @@ def iter_artist_names(event: Event):
for artist in group: for artist in group:
if artist.artist_name: if artist.artist_name:
yield artist.artist_name yield artist.artist_name
def sort_events(events: List[Event], sort: Optional[str]) -> List[Event]:
"""Sort events per the ``?sort=`` query param.
"asc"/"desc" order chronologically by ``event_date``; events with an
unparseable date always sort last, regardless of direction. "az"/"za"
order alphabetically (case-insensitive) by ``event_name``. Any other
value (including None) leaves the events in their existing order.
"""
if sort in ("asc", "desc"):
with_parsed = [(e, parse_event_date(e.event_date)) for e in events]
dated = sorted(
(item for item in with_parsed if item[1] is not None),
key=lambda item: item[1],
reverse=(sort == "desc"),
)
undated = [e for e, parsed in with_parsed if parsed is None]
return [e for e, _ in dated] + undated
if sort in ("az", "za"):
return sorted(events, key=lambda e: e.event_name.lower(), reverse=(sort == "za"))
return events
def sort_date_strings(dates) -> List[str]:
"""Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated.
Unparseable strings sort last (alphabetically among themselves).
"""
unique = set(dates)
dated = sorted(
(d for d in unique if parse_event_date(d) is not None), key=parse_event_date
)
undated = sorted(d for d in unique if parse_event_date(d) is None)
return dated + undated
+24 -8
View File
@@ -9,17 +9,23 @@ Run with:
import datetime import datetime
import os import os
from typing import List, Optional from collections import defaultdict
from typing import List, Literal, Optional
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date from datasource import EventStore, iter_artist_names, parse_event_date, sort_date_strings, sort_events
from models import Event, EventList from models import ArtistSchedule, Event, EventList
from rate_limit import Cooldown from rate_limit import Cooldown
from scrape_schedule import discover_cron_expr, get_schedule_info from scrape_schedule import discover_cron_expr, get_schedule_info
SortOrder = Literal["asc", "desc", "az", "za"]
_SORT_DESCRIPTION = (
"Sort order: asc/desc by event date, az/za alphabetically by event name"
)
app = FastAPI( app = FastAPI(
title="Fundi Scraper API", title="Fundi Scraper API",
description="REST API over the scraped upcoming/past events at the Fundbureau Hamburg.", description="REST API over the scraped upcoming/past events at the Fundbureau Hamburg.",
@@ -118,6 +124,7 @@ def list_events(
artist: Optional[str] = Query(None, description="Case-insensitive substring match on any artist 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)"), 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"), upcoming: Optional[bool] = Query(None, description="true = today or later, false = past events"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION),
): ):
today = datetime.date.today() today = datetime.date.today()
events = [ events = [
@@ -125,16 +132,23 @@ def list_events(
for e in store.all() for e in store.all()
if _matches(e, free, name, artist, date, upcoming, today) if _matches(e, free, name, artist, date, upcoming, today)
] ]
events = sort_events(events, sort)
return EventList(count=len(events), events=events) return EventList(count=len(events), events=events)
@app.get("/artists", response_model=List[str]) @app.get("/artists", response_model=List[ArtistSchedule])
def list_artists(): def list_artists():
"""Deduplicated, sorted list of all artist names across all events.""" """Deduplicated, sorted list of all artists, each with every date
names = set() (past or upcoming) they appear in the line-up.
"""
appearances: dict = defaultdict(set)
for event in store.all(): for event in store.all():
names.update(iter_artist_names(event)) for artist_name in iter_artist_names(event):
return sorted(names, key=str.lower) appearances[artist_name].add(event.event_date)
return [
ArtistSchedule(artist_name=artist_name, dates=sort_date_strings(dates))
for artist_name, dates in sorted(appearances.items(), key=lambda kv: kv[0].lower())
]
@app.get("/events/archive", response_model=EventList) @app.get("/events/archive", response_model=EventList)
@@ -143,6 +157,7 @@ def list_archive_events(
name: Optional[str] = Query(None, description="Case-insensitive substring match on event name"), 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"), 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)"), date: Optional[str] = Query(None, description="Exact match on the raw event date (dd.mm.yy)"),
sort: Optional[SortOrder] = Query(None, description=_SORT_DESCRIPTION),
): ):
"""Past events only - shorthand for ``/events?upcoming=false``.""" """Past events only - shorthand for ``/events?upcoming=false``."""
today = datetime.date.today() today = datetime.date.today()
@@ -151,6 +166,7 @@ def list_archive_events(
for e in store.all() for e in store.all()
if _matches(e, free, name, artist, date, upcoming=False, today=today) if _matches(e, free, name, artist, date, upcoming=False, today=today)
] ]
events = sort_events(events, sort)
return EventList(count=len(events), events=events) return EventList(count=len(events), events=events)
+5
View File
@@ -21,3 +21,8 @@ class Event(BaseModel):
class EventList(BaseModel): class EventList(BaseModel):
count: int count: int
events: List[Event] events: List[Event]
class ArtistSchedule(BaseModel):
artist_name: str
dates: List[str]