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:
+31
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,2 +1,3 @@
|
||||
fastapi>=0.103
|
||||
uvicorn[standard]>=0.23
|
||||
croniter>=2.0
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user