Daily Art backend: curated Met pool, daily cached pick, /api/art (prototype)
The engine for the /art room (design-independent; deploy held for Codex review).
- goodnews/art.py: harvest a curated pool of public-domain HIGHLIGHT artworks from the
Met (isHighlight+isPublicDomain+hasImages -> masterworks, never potsherds; CC0). Daily
deterministic pick from the least-recently-shown (no soon-repeats, same for everyone),
fetch metadata + download the image to OUR cache (data/art_cache) so the homepage never
waits on or hotlinks the museum. Bulletproof: bad object/image falls through candidates;
a failed day keeps the last piece (room never empty). Injectable HTTP for tests.
- Schema: art_pool + daily_art. /api/art/today (edge-cacheable) + /api/art/image/{id}
(served from cache, immutable). CLI `art [--harvest] [--force]` + a non-fatal cycle step.
- Tests (5, mocked HTTP) + verified live against the Met: harvested 1641 works,
picked/cached "Repose" by John White Alexander. 371 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-2
@@ -32,11 +32,11 @@ from pathlib import Path
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
|
||||
from . import art, auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
|
||||
from .localtime import local_today
|
||||
from .markup import reply_html_to_text, sanitize_reply_html
|
||||
from .db import connect
|
||||
@@ -2258,6 +2258,30 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
return queries.available_dates(conn, limit=limit)
|
||||
|
||||
# --- Daily Art (the /art room) -----------------------------------------
|
||||
@app.get("/api/art/today")
|
||||
def art_today(response: Response) -> dict:
|
||||
with get_conn() as conn:
|
||||
a = art.get_today(conn)
|
||||
if not a:
|
||||
response.headers["Cache-Control"] = _PRIVATE
|
||||
raise HTTPException(status_code=404, detail="No art yet.")
|
||||
response.headers["Cache-Control"] = _EDGE_FEED # one piece a day, same for everyone
|
||||
return {
|
||||
"date": a["art_date"], "object_id": a["object_id"], "title": a["title"],
|
||||
"artist": a["artist"], "date_text": a["date_text"], "medium": a["medium"],
|
||||
"department": a["department"], "credit": a["credit"], "source_url": a["source_url"],
|
||||
"source": a["source"], "image_url": f"/api/art/image/{a['object_id']}",
|
||||
}
|
||||
|
||||
@app.get("/api/art/image/{object_id}")
|
||||
def art_image(object_id: int) -> FileResponse:
|
||||
matches = sorted(art.cache_dir().glob(f"{object_id}.*"))
|
||||
if not matches:
|
||||
raise HTTPException(status_code=404, detail="Not cached.")
|
||||
# Cached museum image: immutable for a given object id.
|
||||
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||
|
||||
@app.get("/api/replacement", response_model=Article | None)
|
||||
def replacement(
|
||||
exclude: str = Query("", description="comma-separated article ids already shown"),
|
||||
|
||||
Reference in New Issue
Block a user