Switch menu source from Nutrislice to Claude-read PDF, weekly to monthly pull
All checks were successful
Deploy / deploy (push) Successful in 35s
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:
parent
1bc07a7ad0
commit
a809aa5a5a
5 changed files with 263 additions and 163 deletions
116
scraper.py
116
scraper.py
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue