Add archive scraping, webcal feed, and scrape schedule reporting

- scraper.py: --archive flag to scrape https://fundbureau.de/archiv.html
  (past events), fixing a container-specific wait selector and a crash
  on events with no door time that this surfaced.
- api: EventStore now merges an optional archive JSON file into the
  main event list (deduped by date+name); adds GET /events/archive.
- api: GET /calendar.ics serves an RFC 5545 feed of all events for
  webcal subscriptions, with an all-day fallback when no door time is
  parseable.
- api: GET /health reports last-scrape time (from file mtime) and, via
  new SCRAPE_CRON/ARCHIVE_SCRAPE_CRON env vars, the next scheduled run
  and seconds until it, for both the regular and archive scrape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 13:42:51 +02:00
co-authored by Claude Sonnet 5
parent e4ffa61988
commit eaed7d4ce6
8 changed files with 436 additions and 46 deletions
+31 -10
View File
@@ -19,22 +19,43 @@ 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:
By default the API reads `../fundi-scraped-output.json` (upcoming events) and
`../fundi-archive-output.json` (past events, optional — produced by
`scraper.py --archive`), merging both into one in-memory list. Point either
elsewhere with an env var:
```bash
DATA_FILE=/path/to/events.json uvicorn main:app
DATA_FILE=/path/to/events.json ARCHIVE_DATA_FILE=/path/to/archive.json uvicorn main:app
```
### Scrape schedule reporting
`/health` reports when each scraper output file was last written (its mtime)
and, if you tell it the cron schedule, when it's next due. This doesn't read
your crontab — set the same expression(s) you put there as env vars:
```bash
SCRAPE_CRON="0 * * * *" ARCHIVE_SCRAPE_CRON="0 4 * * *" uvicorn main:app
```
`SCRAPE_CRON` covers the plain scrape (`fundi-scraped-output.json`);
`ARCHIVE_SCRAPE_CRON` covers `--archive` (`fundi-archive-output.json`) and
falls back to `SCRAPE_CRON` if unset — set it separately only if the archive
scrape runs on its own cron line. Leave both unset to just get the
last-scrape info with `next_scrape_at` / `seconds_until_next_scrape` as
`null`.
## 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) |
| Method | Path | Description |
|--------|-------------------|-------------|
| GET | `/health` | Status, event counts, and last/next scrape timing (see below) |
| GET | `/events` | List events, with optional filters (see below) |
| 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 | `/artists` | Deduplicated, sorted list of all artist names |
| 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) |
### `/events` query parameters
+126
View File
@@ -0,0 +1,126 @@
"""Builds an RFC 5545 (iCalendar) feed from events, for webcal subscriptions.
Point a calendar app at ``webcal://<host>/calendar.ics`` (or the plain
``https://`` URL) to subscribe - the app will periodically re-fetch it.
"""
import hashlib
import re
from datetime import date, datetime, timedelta, timezone
from typing import List, Optional
from datasource import iter_artist_names, parse_event_date
from models import Event
_STARTTIME_RE = re.compile(r"(\d{1,2}):(\d{2})")
_DEFAULT_DURATION = timedelta(hours=5)
_UID_DOMAIN = "fundbureau-scraper"
def _escape_text(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n")
)
def _fold_line(line: str) -> str:
"""Fold a logical line to <=75 octets per line, as RFC 5545 requires."""
data = line.encode("utf-8")
if len(data) <= 75:
return line
chunks = []
start = 0
limit = 75
while start < len(data):
end = min(start + limit, len(data))
# Don't split a multi-byte UTF-8 sequence in half.
while end < len(data) and (data[end] & 0xC0) == 0x80:
end -= 1
chunks.append(data[start:end].decode("utf-8"))
start = end
limit = 74 # continuation lines are prefixed with a single space
return "\r\n ".join(chunks)
def _parse_starttime(raw: str) -> Optional[tuple]:
match = _STARTTIME_RE.search(raw or "")
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if 0 <= hour < 24 and 0 <= minute < 60:
return hour, minute
return None
def _event_uid(event: Event) -> str:
digest = hashlib.sha1(f"{event.event_date}|{event.event_name}".encode("utf-8")).hexdigest()
return f"{digest}@{_UID_DOMAIN}"
def _build_vevent(event: Event, dtstamp: str) -> List[str]:
event_date = parse_event_date(event.event_date)
lines = ["BEGIN:VEVENT", f"UID:{_event_uid(event)}", f"DTSTAMP:{dtstamp}"]
if event_date is None:
# Can't place it on the calendar without a parseable date.
return []
starttime = _parse_starttime(event.event_starttime)
if starttime is not None:
hour, minute = starttime
dtstart = datetime(event_date.year, event_date.month, event_date.day, hour, minute)
dtend = dtstart + _DEFAULT_DURATION
lines.append(f"DTSTART:{dtstart.strftime('%Y%m%dT%H%M%S')}")
lines.append(f"DTEND:{dtend.strftime('%Y%m%dT%H%M%S')}")
else:
# No parseable door time - render as an all-day event instead of
# guessing a time.
next_day = event_date + timedelta(days=1)
lines.append(f"DTSTART;VALUE=DATE:{event_date.strftime('%Y%m%d')}")
lines.append(f"DTEND;VALUE=DATE:{next_day.strftime('%Y%m%d')}")
lines.append(f"SUMMARY:{_escape_text(event.event_name)}")
lines.append("LOCATION:" + _escape_text("Fundbureau, Hamburg"))
description_parts = []
lineup = ", ".join(iter_artist_names(event))
if lineup:
description_parts.append(f"Line-up: {lineup}")
if event.event_starttime:
description_parts.append(event.event_starttime)
if event.event_free:
description_parts.append("Eintritt frei")
elif event.event_ticket_link and event.event_ticket_link != "n/a":
description_parts.append(f"Tickets: {event.event_ticket_link}")
if description_parts:
lines.append(f"DESCRIPTION:{_escape_text(chr(10).join(description_parts))}")
if event.event_ticket_link and event.event_ticket_link not in ("", "n/a"):
lines.append(f"URL:{event.event_ticket_link}")
lines.append("END:VEVENT")
return lines
def build_ics(events: List[Event]) -> str:
"""Render a list of events as a VCALENDAR document (CRLF-terminated)."""
dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//fundi-scraper-api//Fundbureau Events//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Fundbureau Events",
"X-WR-CALDESC:Upcoming and past events at the Fundbureau, Hamburg",
"X-WR-TIMEZONE:Europe/Berlin",
"REFRESH-INTERVAL;VALUE=DURATION:PT12H",
"X-PUBLISHED-TTL:PT12H",
]
for event in events:
lines.extend(_build_vevent(event, dtstamp))
lines.append("END:VCALENDAR")
return "\r\n".join(_fold_line(line) for line in lines) + "\r\n"
+54 -11
View File
@@ -1,7 +1,14 @@
"""Loads the scraped events JSON file and keeps it in memory.
"""Loads the scraped events JSON file(s) and keeps them in memory.
The file is treated as read-only. Call ``reload()`` to pick up a fresh
Files are treated as read-only. Call ``reload()`` to pick up a fresh
scrape without restarting the server.
Two files are read:
- the main data file (upcoming events, produced by a plain
``scraper.py`` run) - required, resolved from DATA_FILE.
- the archive file (past events, produced by ``scraper.py --archive``)
- optional, resolved from ARCHIVE_DATA_FILE. Missing archive file
just means no archived events are available yet.
"""
import json
@@ -13,28 +20,54 @@ 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.
# Defaults: the fundi-*-output.json files sitting in the repo root, one
# directory above this file. Override with the DATA_FILE / ARCHIVE_DATA_FILE
# env vars.
_DEFAULT_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-scraped-output.json"
_DEFAULT_ARCHIVE_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-archive-output.json"
class EventStore:
def __init__(self, path: Optional[os.PathLike] = None):
def __init__(self, path: Optional[os.PathLike] = None, archive_path: Optional[os.PathLike] = None):
self.path = Path(path or os.environ.get("DATA_FILE") or _DEFAULT_DATA_FILE)
self.archive_path = Path(archive_path or os.environ.get("ARCHIVE_DATA_FILE") or _DEFAULT_ARCHIVE_DATA_FILE)
self._lock = Lock()
self._events: List[Event] = []
self._current_count = 0
self._archive_count = 0
self.reload()
@staticmethod
def _load_file(path: Path) -> List[Event]:
if not path.exists():
return []
with path.open(encoding="utf-8") as fh:
raw = json.load(fh)
return [Event.model_validate(item) for item in raw]
def reload(self) -> int:
"""Re-read the JSON file from disk. Returns the number of events loaded."""
"""Re-read the data file(s) 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]
current = self._load_file(self.path)
archive = self._load_file(self.archive_path)
# Merge, de-duplicating on (date, name) in case an event that was
# scraped while upcoming was later also picked up by an archive
# scrape - the current-file copy wins.
seen = {(event.event_date, event.event_name) for event in current}
merged = list(current)
for event in archive:
key = (event.event_date, event.event_name)
if key not in seen:
merged.append(event)
seen.add(key)
with self._lock:
self._events = events
return len(events)
self._events = merged
self._current_count = len(current)
self._archive_count = len(merged) - len(current)
return len(merged)
def all(self) -> List[Event]:
with self._lock:
@@ -46,6 +79,16 @@ class EventStore:
return self._events[index]
return None
@property
def current_count(self) -> int:
with self._lock:
return self._current_count
@property
def archive_count(self) -> int:
with self._lock:
return self._archive_count
def parse_event_date(value: str) -> Optional[date]:
"""Parse the scraper's ``dd.mm.yy`` date strings. Returns None if unparseable."""
+56 -3
View File
@@ -8,12 +8,16 @@ Run with:
"""
import datetime
import os
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import PlainTextResponse
from calendar_feed import build_ics
from datasource import EventStore, iter_artist_names, parse_event_date
from models import Event, EventList
from scrape_schedule import get_schedule_info
app = FastAPI(
title="Fundi Scraper API",
@@ -55,17 +59,34 @@ def _matches(
@app.get("/health")
def health():
return {"status": "ok", "events_loaded": len(store.all()), "data_file": str(store.path)}
scrape_cron = os.environ.get("SCRAPE_CRON")
archive_scrape_cron = os.environ.get("ARCHIVE_SCRAPE_CRON") or scrape_cron
return {
"status": "ok",
"events_loaded": len(store.all()),
"data_file": str(store.path),
"current_events": store.current_count,
"archive_file": str(store.archive_path),
"archive_file_found": store.archive_path.exists(),
"archive_events": store.archive_count,
"scrape": get_schedule_info(store.path, scrape_cron),
"archive_scrape": get_schedule_info(store.archive_path, archive_scrape_cron),
}
@app.post("/reload")
def reload():
"""Re-read the JSON file from disk (e.g. after a fresh scrape)."""
"""Re-read the data file(s) 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}
return {
"reloaded": True,
"events_loaded": count,
"current_events": store.current_count,
"archive_events": store.archive_count,
}
@app.get("/events", response_model=EventList)
@@ -94,6 +115,38 @@ def list_artists():
return sorted(names, key=str.lower)
@app.get("/events/archive", response_model=EventList)
def list_archive_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)"),
):
"""Past events only - shorthand for ``/events?upcoming=false``."""
today = datetime.date.today()
events = [
e
for e in store.all()
if _matches(e, free, name, artist, date, upcoming=False, today=today)
]
return EventList(count=len(events), events=events)
@app.get("/calendar.ics")
def calendar_ics():
"""iCalendar feed of all known events (upcoming + archived).
Subscribe from a calendar app with ``webcal://<host>/calendar.ics``
(or the plain https URL) to get an auto-refreshing calendar.
"""
ics = build_ics(store.all())
return PlainTextResponse(
content=ics,
media_type="text/calendar",
headers={"Content-Disposition": "inline; filename=fundbureau-events.ics"},
)
@app.get("/events/{index}", response_model=Event)
def get_event(index: int):
event = store.get(index)
+1
View File
@@ -1,2 +1,3 @@
fastapi>=0.103
uvicorn[standard]>=0.23
croniter>=2.0
+58
View File
@@ -0,0 +1,58 @@
"""Reports when a scrape last ran and when cron will next run it.
"Last ran" is read from the mtime of the scraper's output file - true
regardless of how/when the scrape was actually invoked. "Next run" requires
telling this module the cron schedule via an env var (there's no reliable,
portable way to introspect another process's crontab from here), given as
a standard 5-field cron expression, e.g. "0 * * * *".
"""
from datetime import datetime
from pathlib import Path
from typing import Optional
from croniter import croniter
def _now_local() -> datetime:
return datetime.now().astimezone()
def _file_mtime_local(path: Path) -> Optional[datetime]:
try:
return datetime.fromtimestamp(path.stat().st_mtime).astimezone()
except FileNotFoundError:
return None
def _next_cron_run(cron_expr: str, after: datetime) -> Optional[datetime]:
try:
naive_after = after.replace(tzinfo=None)
return croniter(cron_expr, naive_after).get_next(datetime).astimezone()
except (ValueError, KeyError):
return None
def get_schedule_info(path: Path, cron_expr: Optional[str]) -> dict:
"""Build the last/next-scrape block for one scraper output file.
``cron_expr`` is that scrape's cron expression (e.g. "0 * * * *");
None/invalid -> next-run fields are null.
"""
now = _now_local()
cron_expr = (cron_expr or "").strip() or None
last_scrape_at = _file_mtime_local(path)
next_scrape_at = _next_cron_run(cron_expr, now) if cron_expr else None
return {
"cron_schedule": cron_expr,
"last_scrape_at": last_scrape_at.isoformat() if last_scrape_at else None,
"seconds_since_last_scrape": (
round((now - last_scrape_at).total_seconds()) if last_scrape_at else None
),
"next_scrape_at": next_scrape_at.isoformat() if next_scrape_at else None,
"seconds_until_next_scrape": (
round((next_scrape_at - now).total_seconds()) if next_scrape_at else None
),
}