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:
jay
2026-06-21 14:50:20 -04:00
parent 0c68c22221
commit 308516a263
6 changed files with 341 additions and 2 deletions
+26 -2
View File
@@ -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"),