added sorting and artist dates
This commit is contained in:
@@ -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
@@ -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.
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user