Switch menu source from Nutrislice to Claude-read PDF, weekly to monthly pull
All checks were successful
Deploy / deploy (push) Successful in 35s

The district discontinued Nutrislice in favor of a monthly PDF calendar
per grade band on kyrene.org. That PDF is a graphic calendar grid, not
reliably text-extractable, so scraper.py now sends it to Claude (vision +
structured JSON output) instead of hitting a JSON API. Fetches through a
stable Finalsite resource-manager permalink that always redirects to
whichever month is currently published, so no code change is needed each
month.

Also moves the pull schedule from weekly (day-of-week list) to monthly
(day-of-month), since the source itself is now monthly — defaults to the
25th, since the district tends to post next month's PDF a few days early.

Requires a new ANTHROPIC_API_KEY in the server's .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RbBoYm23muVyMZ18Ldqa6
This commit is contained in:
Barely Removable 2026-09-08 07:12:34 -07:00
parent 1bc07a7ad0
commit a809aa5a5a
5 changed files with 263 additions and 163 deletions

View file

@ -1,51 +1,93 @@
import base64
import json
import logging
from datetime import datetime
import anthropic
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
SCHOOL_SLUG = "kyrene-de-la-mariposa"
MENU_TYPE = "lunch"
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school"
# Stable Finalsite "resource manager" permalink for the K-5 lunch menu, found
# on https://www.kyrene.org/family-student-resources/food-services/school-menus.
# This URL itself never changes — it 302-redirects to whichever monthly PDF
# is currently published (verified 2026-09-08: it pointed straight at that
# month's file). Nutrislice, the previous data source, was discontinued by
# the district.
MENU_PAGE_URL = "https://www.kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571"
MODEL = "claude-opus-5"
_MENU_SCHEMA = {
"type": "object",
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": {"type": "string", "description": "ISO date, YYYY-MM-DD"},
"items": {"type": "array", "items": {"type": "string"}},
},
"required": ["date", "items"],
"additionalProperties": False,
},
},
},
"required": ["days"],
"additionalProperties": False,
}
_PROMPT = """This PDF is a school cafeteria's monthly lunch calendar (grades K-5). It's a
calendar-grid layout with one cell per school day, each listing a Breakfast
section and a Lunch section.
Extract ONLY the Lunch items (ignore Breakfast) for every day of the month
shown on the calendar. For each day 1 through the last day of the month:
- If the calendar shows lunch items for that day, list them.
- If the day has no cafeteria menu (weekend, holiday, or a no-school day
like a break), return an empty items list for that date.
Read the month and year printed on the calendar itself to build the ISO
dates. Return every calendar day of the month, in date order."""
def _fetch_week(date_str: str) -> dict:
dt = datetime.strptime(date_str, "%Y-%m-%d")
url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/"
log.info(f"Fetching {url}")
resp = requests.get(
url,
headers={"Accept": "application/json", "User-Agent": "Mozilla/5.0"},
timeout=20,
)
def _fetch_current_pdf() -> bytes:
resp = requests.get(MENU_PAGE_URL, timeout=20)
resp.raise_for_status()
return resp.json()
return resp.content
def _extract_items(day: dict) -> list[str]:
items = []
for entry in day.get("menu_items", []):
if entry.get("is_section_title") or entry.get("is_station_header"):
continue
food = entry.get("food")
if food and food.get("name"):
items.append(food["name"].strip())
return items
def get_month_menu() -> list[dict]:
"""Fetches whichever monthly K-5 lunch menu PDF is currently published
and asks Claude to read it into structured per-day data. Returns
[{"date": "YYYY-MM-DD", "items": [...]}, ...] for every day of that
month — days with no posted menu (weekends, breaks, holidays) come back
with an empty items list, same contract the old Nutrislice-based scraper
used."""
log.info(f"Fetching {MENU_PAGE_URL}")
pdf_b64 = base64.standard_b64encode(_fetch_current_pdf()).decode("utf-8")
client = anthropic.Anthropic()
response = client.messages.create(
model=MODEL,
max_tokens=8000,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": pdf_b64,
},
},
{"type": "text", "text": _PROMPT},
],
}],
output_config={"format": {"type": "json_schema", "schema": _MENU_SCHEMA}},
)
def get_week_menu(date_str: str | None = None) -> list[dict]:
"""Returns every day in the school-published week containing date_str,
as [{"date": "YYYY-MM-DD", "items": [...]}, ...] in the API's own order.
Days with no posted menu (weekends, breaks, holidays) have an empty items list."""
if date_str is None:
date_str = datetime.now().strftime("%Y-%m-%d")
data = _fetch_week(date_str)
return [
{"date": day.get("date"), "items": _extract_items(day)}
for day in data.get("days", [])
if day.get("date")
]
text = next(b.text for b in response.content if b.type == "text")
return json.loads(text)["days"]