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
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
import base64
|
|
import json
|
|
import logging
|
|
|
|
import anthropic
|
|
import requests
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
log = logging.getLogger(__name__)
|
|
|
|
# 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_current_pdf() -> bytes:
|
|
resp = requests.get(MENU_PAGE_URL, timeout=20)
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
|
|
|
|
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}},
|
|
)
|
|
|
|
text = next(b.text for b in response.content if b.type == "text")
|
|
return json.loads(text)["days"]
|