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

@ -6,12 +6,17 @@
# e.g. http://192.168.86.X:8123/api/webhook/<random-token> # e.g. http://192.168.86.X:8123/api/webhook/<random-token>
HA_WEBHOOK_URL= HA_WEBHOOK_URL=
# Anthropic API key. The district's lunch menu is now a graphic PDF
# calendar (Nutrislice was discontinued), so scraper.py sends it to Claude
# to read into structured data — see console.anthropic.com/settings/keys.
ANTHROPIC_API_KEY=
# --- Optional --- # --- Optional ---
# Day(s) of week to pull the upcoming school week and push it to the # Day of the month (1-28) to pull whichever month is currently published
# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more # and push it to the calendar. Re-pushing the same month creates duplicate
# than one day creates duplicate events for days already pushed — most # events, so pick one day near month-end (the district tends to post next
# people want just one. # month's menu a few days early).
CALENDAR_RUN_DAYS=sun CALENDAR_RUN_DAY_OF_MONTH=25
# Time to run the pull (24-hour HH:MM, container timezone) # Time to run the pull (24-hour HH:MM, container timezone)
CALENDAR_RUN_TIME=18:00 CALENDAR_RUN_TIME=18:00

143
CLAUDE.md
View file

@ -1,9 +1,8 @@
# Lunch Notifier # Lunch Notifier
Single-file Flask app that scrapes the weekly Mariposa (Kyrene school Single-file Flask app that scrapes the Mariposa (Kyrene school district)
district) lunch menu and pushes each day to a Home Assistant calendar as an lunch menu and pushes each day to a Home Assistant calendar as an all-day
all-day event. Runs as one Docker container on the user's Unraid home event. Runs as one Docker container on the user's Unraid home server.
server.
**2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone **2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone
numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP, numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP,
@ -12,40 +11,70 @@ user's request in favor of Home Assistant calendar events exclusively. If
you find references to texting/SMS/phone numbers anywhere (docs, old you find references to texting/SMS/phone numbers anywhere (docs, old
commits), they describe the pre-2026-09-03 app, not the current one. commits), they describe the pre-2026-09-03 app, not the current one.
**2026-09-08: pivoted from Nutrislice to a Claude-read PDF, and from
weekly to monthly pulls.** The district discontinued Nutrislice (the old
scraper hit `kyrene.api.nutrislice.com`, a clean per-day JSON API) in
favor of publishing one monthly menu PDF per grade band on
`kyrene.org/family-student-resources/food-services/school-menus`. See
"Menu source" and "Monthly pull scheduler" below for how that's handled
now. Anything describing a weekly Nutrislice pull describes the
pre-2026-09-08 app.
## Architecture ## Architecture
- **`app.py`** — everything: Flask web UI/API, `schedule`-library job - **`app.py`** — everything: Flask web UI/API, `schedule`-library job
scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend
is embedded as a Python string constant (`HTML`) rather than a separate is embedded as a Python string constant (`HTML`) rather than a separate
template file — no build step, no template engine. template file — no build step, no template engine.
- **`scraper.py`** — fetches and parses the school's published lunch menu - **`scraper.py`** — fetches the currently-published K-5 lunch menu PDF and
from Nutrislice's API. `get_week_menu(date_str)` returns every day in the asks Claude (Anthropic API, `claude-opus-5`, structured JSON output via
API's week-granular response as `[{"date": ..., "items": [...]}, ...]` `output_config.format`) to read it into `[{"date": ..., "items": [...]}, ...]`
(the API returns a full week regardless of which single date you ask for every day of whatever month is published. Days with no posted menu
for). Days with no posted menu (weekends, breaks, holidays) come back (weekends, breaks, holidays) come back with an empty `items` list rather
with an empty `items` list rather than being omitted. than being omitted — same contract the old Nutrislice-based scraper used,
so `app.py`'s push logic didn't need to change.
- **Menu source (Nutrislice → PDF, 2026-09-08):** the district's page links
a *stable* Finalsite "resource manager" permalink
(`kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571`
for the K-5/"Standard" menu — there's a different UUID per grade band/
campus on that page) that 302-redirects to whichever monthly PDF is
currently live (`scraper.MENU_PAGE_URL`). **Don't hardcode the PDF URL
itself** — it's a Finalsite CDN path that changes every month (verified:
the September 2026 PDF and August's page both linked through the same
permalink); only the permalink UUID is stable, and only changes if the
district restructures that page entirely. The PDF itself is a single-page
graphic calendar grid (mascots, colored cells, day numbers laid out
visually) — not text-extractable in any reliable way (raw text extraction
jumbles day numbers away from their menu items), which is why this reads
it via Claude vision/document understanding rather than `pdfplumber`/
regex. Cost is negligible: one ~370KB single-page PDF per month, well
under a cent of Opus 5 tokens.
- **`ANTHROPIC_API_KEY`** is now required (server `.env`, alongside
`HA_WEBHOOK_URL`) — `scraper.get_month_menu()` fails without it, same
fail-soft-into-`_status`-error behavior as a Home Assistant push failure.
`GET /api/integrations` reports both keys' configured/not-configured
state.
- Settings (calendar pull schedule) persist to `/data/settings.json`, not - Settings (calendar pull schedule) persist to `/data/settings.json`, not
environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`) environment variables — env vars (`CALENDAR_RUN_TIME`,
are defaults used only the very first time, before a settings file exists. `CALENDAR_RUN_DAY_OF_MONTH`) are defaults used only the very first time,
`load_settings()` merges the file's contents *over* `_DEFAULTS` rather before a settings file exists. `load_settings()` merges the file's
than returning the file verbatim — this matters because the server's contents *over* `_DEFAULTS` rather than returning the file verbatim —
settings.json predates this schema (it's from the SMS era) and lacks this matters because the server's settings.json predates several schema
`calendar_run_time`/`calendar_run_days` entirely; without the merge, the changes (SMS era, then the weekly-schedule era) and is missing whatever
scheduler would silently come up with zero jobs after every deploy until keys the current schema expects; without the merge, the scheduler would
someone happened to re-save the Schedule card in the UI. Verified via a silently come up with zero jobs after every deploy until someone happened
migration test (old-schema settings.json + fresh container → correct to re-save the Schedule card in the UI.
defaults filled in, `next_calendar_run` populated).
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).** - **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
A day with no posted menu already produces an empty `items` list from A day with no posted menu already produces an empty `items` list from
`get_week_menu()`, and `_push_calendar()` already skips pushing when `get_month_menu()`, and `_push_calendar()` already skips pushing when
`items` is empty — that's sufficient for "don't create an event for a day `items` is empty — that's sufficient for "don't create an event for a day
with nothing happening." A separate vacation-range setting to *additionally* with nothing happening." A separate vacation-range setting to *additionally*
suppress days the school did post a menu for was judged unnecessary suppress days the school did post a menu for was judged unnecessary
complexity and deleted at the user's request; don't re-add it without complexity and deleted at the user's request; don't re-add it without
being asked. being asked.
- **Home Assistant integration (built 2026-09-02/03, calendar-only pivot - **Home Assistant integration (built 2026-09-02/03, calendar-only pivot
2026-09-03):** `HA_WEBHOOK_URL` is now required, not optional — it's the 2026-09-03):** `HA_WEBHOOK_URL` is required — it's the only way this app
only way this app does anything externally-visible. On the HA side does anything externally-visible. On the HA side
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`), (`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
a `local_only` webhook trigger runs `calendar.create_event` against a `local_only` webhook trigger runs `calendar.create_event` against
`calendar.family_2` (the "Family" Google Calendar entity — there are two `calendar.family_2` (the "Family" Google Calendar entity — there are two
@ -55,38 +84,47 @@ commits), they describe the pre-2026-09-03 app, not the current one.
`delete_event` — a bad push can't be cleaned up via the HA API, only `delete_event` — a bad push can't be cleaned up via the HA API, only
manually in Google Calendar. No duplicate-prevention: re-pushing the manually in Google Calendar. No duplicate-prevention: re-pushing the
same date's menu creates a second event — this is why the default same date's menu creates a second event — this is why the default
schedule is a single day/week, not daily (see below). schedule is a single day/month, not daily (see below).
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a **`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
real bug):** a week push fires one webhook POST per school day real bug):** a push fires one webhook POST per school day back-to-back
back-to-back with no delay. Under `mode: single`, HA rejects any with no delay. Under `mode: single`, HA rejects any webhook trigger that
webhook trigger that arrives while a previous run is still executing arrives while a previous run is still executing (`calendar.create_event`
(`calendar.create_event` itself takes ~600-700ms) — 4 of every 5 days itself takes ~600-700ms) — most days in a batch were silently dropped as
were silently dropped as `failed_single`, so only one day's event ever `failed_single`, so only one day's event ever actually got created per
actually got created per week push. Found via `ha_get_automation_traces` push. Found via `ha_get_automation_traces` after the user reported a
after the user reported a manual test push not showing up on the manual test push not showing up on the calendar. Each invocation is
calendar. Each invocation is independent (one `calendar.create_event` independent (one `calendar.create_event` call, no shared state), so
call, no shared state), so `parallel` is the correct mode per HA's own `parallel` is the correct mode per HA's own guidance ("independent
guidance ("independent per-entity actions → parallel"), not just a per-entity actions → parallel"), not just a workaround. The webhook id is
workaround. The webhook id is
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`) a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
and in the automation's trigger config — never committed here. If the and in the automation's trigger config — never committed here. If the
calendar push stops working, check both ends: `GET /api/integrations` calendar push stops working, check both ends: `GET /api/integrations`
on this app reports whether `HA_WEBHOOK_URL` is set at all, and on this app reports whether `HA_WEBHOOK_URL` is set at all, and
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar` `ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
shows whether HA is even receiving the webhook. shows whether HA is even receiving the webhook.
- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on - **Monthly pull scheduler (weekly → monthly, 2026-09-08):** calendar pulls
their own schedule (`calendar_run_time`/`calendar_run_days` in settings, run on their own schedule (`calendar_run_time`/`calendar_run_day_of_month`
`schedule` jobs tagged `"calendar"`), completely decoupled from any other in settings, `schedule` jobs tagged `"calendar"`), completely decoupled
job — there's nothing else in this app anymore, but the tag/isolation from anything else — the tag/isolation pattern
pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`) (`schedule.clear("calendar")` instead of a bare `schedule.clear()`) is
is deliberate so a future second schedule wouldn't stomp this one. deliberate so a future second schedule wouldn't stomp this one. The
`push_week_to_calendar()` fetches the whole current school week and `schedule` library has no native day-of-month trigger, so
pushes every day with a posted menu. Default is Sunday 18:00 (one `apply_calendar_schedule()` registers a *daily* job
firing/week) — the UI warns that picking more than one day/week creates (`_maybe_push_month`) that no-ops unless `datetime.now().day` matches
duplicate events for days already covered, since there's no dedup. the configured day — `push_month_to_calendar()` only actually runs on
- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar` that one day per month. `next_calendar_run()` computes the real next
→ `push_week_to_calendar()`, same function the schedule calls) and firing directly from settings (handling month/year rollover and clamping
"Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()` the configured day to short months, e.g. day 30 in February → the 28th)
for the UI's "Next Scheduled" field, since the underlying `schedule` job's
own `next_run` is meaningless here (it's always just "tomorrow"). Default
is day 25 at 18:00 — picked because the district tends to post next
month's PDF a few days before month-end, so pulling then usually catches
the *upcoming* month rather than the one ending. Whatever month is live
at the permalink when the job fires is what gets pushed — there's no
logic forcing "next month specifically."
- **Manual actions:** "Push This Month to Calendar" (`POST /api/push-calendar`
→ `push_month_to_calendar()`, same function the schedule calls) and
"Preview This Month's Menu" (`GET /api/preview` → `get_month_menu()`
directly, pure dry run, no Home Assistant push, no side effects at all — directly, pure dry run, no Home Assistant push, no side effects at all —
use this to check the scraper before trusting a scheduled push). use this to check the scraper before trusting a scheduled push).
@ -97,8 +135,10 @@ pip install -r requirements.txt
python3 app.py python3 app.py
``` ```
Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app Needs `ANTHROPIC_API_KEY` set for anything menu-related to work at all
still runs and previews fine, `GET /api/integrations` just reports (including Preview, since that calls the same Claude-backed scraper) and
`HA_WEBHOOK_URL` set to actually push anywhere — without the latter the
app still runs and previews fine, `GET /api/integrations` just reports
`ha_webhook_configured: false` and pushes resolve to `"not configured"`. `ha_webhook_configured: false` and pushes resolve to `"not configured"`.
Visit `http://localhost:2323`. Visit `http://localhost:2323`.
@ -124,7 +164,8 @@ manually-edited `.env` on its own.
**Persistent state, never touched by deploys:** `data/settings.json` (the **Persistent state, never touched by deploys:** `data/settings.json` (the
volume-mounted `/data` inside the container) holds the calendar pull volume-mounted `/data` inside the container) holds the calendar pull
schedule — this is live user data, not deploy-managed. `.env` holds schedule — this is live user data, not deploy-managed. `.env` holds
`HA_WEBHOOK_URL` and is server-only, gitignored, never committed. `HA_WEBHOOK_URL` and `ANTHROPIC_API_KEY` and is server-only, gitignored,
never committed.
**Container timezone (fixed 2026-09-08):** the container previously had no **Container timezone (fixed 2026-09-08):** the container previously had no
timezone configured — `python:3.12-slim` defaults to UTC and the timezone configured — `python:3.12-slim` defaults to UTC and the

151
app.py
View file

@ -2,6 +2,7 @@
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
Assistant calendar. Visit http://localhost:2323 to manage settings. Assistant calendar. Visit http://localhost:2323 to manage settings.
""" """
import calendar
import json import json
import logging import logging
import os import os
@ -14,7 +15,7 @@ import requests
import schedule import schedule
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
from scraper import get_week_menu from scraper import get_month_menu
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -27,7 +28,7 @@ PORT = int(os.environ.get("PORT", "2323"))
# Env-var defaults (used only when no settings file exists yet) # Env-var defaults (used only when no settings file exists yet)
_DEFAULTS = { _DEFAULTS = {
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"), "calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
"calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()], "calendar_run_day_of_month": int(os.environ.get("CALENDAR_RUN_DAY_OF_MONTH", "25")),
} }
_status = {"last_calendar_run": None, "calendar_result": None} _status = {"last_calendar_run": None, "calendar_result": None}
@ -41,8 +42,9 @@ def load_settings() -> dict:
try: try:
with open(SETTINGS_FILE) as f: with open(SETTINGS_FILE) as f:
# Merge over _DEFAULTS so a settings.json from before a schema # Merge over _DEFAULTS so a settings.json from before a schema
# change (e.g. the pre-calendar SMS-era file) still gets # change (e.g. the pre-calendar SMS-era file, or the
# calendar_run_time/calendar_run_days rather than silently # weekly-schedule era before calendar_run_day_of_month
# existed) still gets a valid schedule rather than silently
# scheduling nothing. # scheduling nothing.
return {**_DEFAULTS, **json.load(f)} return {**_DEFAULTS, **json.load(f)}
except Exception: except Exception:
@ -80,23 +82,23 @@ def _push_calendar(items: list[str], date_str: str | None) -> str:
return f"error: {e}" return f"error: {e}"
def push_week_to_calendar() -> None: def push_month_to_calendar() -> None:
"""Fetch the current school week and push every day that has a posted """Fetch whichever month is currently published and push every day that
menu — days with nothing posted (weekends, breaks, holidays) are has a posted menu — days with nothing posted (weekends, breaks,
skipped automatically since there's nothing to create an event from. holidays) are skipped automatically since there's nothing to create an
Runs on its own schedule (calendar_run_time/calendar_run_days), event from. Runs on its own schedule (calendar_run_time/
independent of anything else.""" calendar_run_day_of_month), independent of anything else."""
global _status global _status
try: try:
week = get_week_menu() month = get_month_menu()
except Exception as e: except Exception as e:
log.exception("Week fetch failed") log.exception("Month fetch failed")
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"} _status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
return return
pushed = errors = no_items = 0 pushed = errors = no_items = 0
for day in week: for day in month:
result = _push_calendar(day["items"], day["date"]) result = _push_calendar(day["items"], day["date"])
if result == "ok": if result == "ok":
pushed += 1 pushed += 1
@ -105,7 +107,7 @@ def push_week_to_calendar() -> None:
else: else:
errors += 1 errors += 1
log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu") log.info(f"Month push: {pushed} pushed, {errors} errors, {no_items} no menu")
if errors: if errors:
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "") summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
@ -123,26 +125,43 @@ def push_week_to_calendar() -> None:
# ── Scheduler ───────────────────────────────────────────────────────────────── # ── Scheduler ─────────────────────────────────────────────────────────────────
DAY_MAP_FNS = { def _maybe_push_month(day_of_month: int) -> None:
"mon": lambda: schedule.every().monday, """schedule fires this daily at run_time; only actually pushes on the
"tue": lambda: schedule.every().tuesday, configured day of the month (the `schedule` library has no native
"wed": lambda: schedule.every().wednesday, day-of-month trigger)."""
"thu": lambda: schedule.every().thursday, if datetime.now().day == day_of_month:
"fri": lambda: schedule.every().friday, push_month_to_calendar()
"sat": lambda: schedule.every().saturday,
"sun": lambda: schedule.every().sunday,
}
def apply_calendar_schedule(settings: dict) -> None: def apply_calendar_schedule(settings: dict) -> None:
with _lock: with _lock:
schedule.clear("calendar") schedule.clear("calendar")
run_time = settings.get("calendar_run_time", "18:00") run_time = settings.get("calendar_run_time", "18:00")
for day in settings.get("calendar_run_days", []): day_of_month = settings.get("calendar_run_day_of_month", 25)
day = day.strip().lower() schedule.every().day.at(run_time).do(_maybe_push_month, day_of_month).tag("calendar")
if day in DAY_MAP_FNS: log.info(f"Scheduled calendar pull: day {day_of_month} of each month at {run_time}")
DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar")
log.info(f"Scheduled calendar pull: {day} at {run_time}")
def next_calendar_run(settings: dict) -> datetime:
"""The `schedule` job above fires daily (it has no native day-of-month
trigger — see _maybe_push_month), so its own next_run is just
"tomorrow", not the actual next push. Compute the real one directly
from settings for display in the UI."""
run_time = settings.get("calendar_run_time", "18:00")
hour, minute = (int(p) for p in run_time.split(":"))
day_of_month = settings.get("calendar_run_day_of_month", 25)
now = datetime.now()
candidate = now.replace(
day=min(day_of_month, calendar.monthrange(now.year, now.month)[1]),
hour=hour, minute=minute, second=0, microsecond=0,
)
if candidate <= now:
y, m = (now.year, now.month + 1) if now.month < 12 else (now.year + 1, 1)
candidate = candidate.replace(
year=y, month=m, day=min(day_of_month, calendar.monthrange(y, m)[1]),
)
return candidate
def _schedule_loop() -> None: def _schedule_loop() -> None:
@ -176,7 +195,7 @@ def api_get_settings():
def api_post_settings(): def api_post_settings():
data = request.get_json(force=True) data = request.get_json(force=True)
s = load_settings() s = load_settings()
for key in ("calendar_run_time", "calendar_run_days"): for key in ("calendar_run_time", "calendar_run_day_of_month"):
if key in data: if key in data:
s[key] = data[key] s[key] = data[key]
save_settings(s) save_settings(s)
@ -186,32 +205,33 @@ def api_post_settings():
@app.route("/api/push-calendar", methods=["POST"]) @app.route("/api/push-calendar", methods=["POST"])
def api_push_calendar(): def api_push_calendar():
threading.Thread(target=push_week_to_calendar, daemon=True).start() threading.Thread(target=push_month_to_calendar, daemon=True).start()
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.route("/api/preview") @app.route("/api/preview")
def api_preview(): def api_preview():
"""Dry-run week scrape: no Home Assistant push, no side effects.""" """Dry-run month scrape: no Home Assistant push, no side effects."""
try: try:
week = get_week_menu() month = get_month_menu()
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 502 return jsonify({"error": str(e)}), 502
return jsonify({"days": week}) return jsonify({"days": month})
@app.route("/api/integrations") @app.route("/api/integrations")
def api_integrations(): def api_integrations():
return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))}) return jsonify({
"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")),
"anthropic_api_key_configured": bool(os.environ.get("ANTHROPIC_API_KEY")),
})
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
with _lock:
next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run])
return jsonify({ return jsonify({
**_status, **_status,
"next_calendar_run": next_runs[0].isoformat() if next_runs else None, "next_calendar_run": next_calendar_run(load_settings()).isoformat(),
}) })
@ -277,17 +297,11 @@ header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
.field:last-child { margin-bottom: 0; } .field:last-child { margin-bottom: 0; }
label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text); label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text);
margin-bottom: 6px; } margin-bottom: 6px; }
input[type=text], input[type=time] { input[type=text], input[type=time], input[type=number] {
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: .875rem; color: var(--text); background: var(--card); font-size: .875rem; color: var(--text); background: var(--card);
} }
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; } input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
/* Days toggle */
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card);
transition: all .15s; user-select: none; }
.day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); }
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; } .item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
/* Divider */ /* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; } .divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
@ -297,11 +311,11 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s; font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s;
pointer-events: none; white-space: nowrap; z-index: 100; } pointer-events: none; white-space: nowrap; z-index: 100; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); } .day-row { padding: 8px 0; border-bottom: 1px solid var(--border); }
.week-day:last-child { border-bottom: none; } .day-row:last-child { border-bottom: none; }
.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; } .day-row-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; }
.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; } .day-row-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; }
.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; } .day-row-empty { font-size: .8rem; color: var(--muted); font-style: italic; }
</style> </style>
</head> </head>
<body> <body>
@ -330,10 +344,10 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card"> <div class="card">
<div class="card-label">Manual Actions</div> <div class="card-label">Manual Actions</div>
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()"> <button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
Push This Week to Calendar Push This Month to Calendar
</button> </button>
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()"> <button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
Preview This Week's Menu Preview This Month's Menu
</button> </button>
<div id="preview-box" style="display:none;margin-top:14px;"></div> <div id="preview-box" style="display:none;margin-top:14px;"></div>
</div> </div>
@ -343,8 +357,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card-label">Integrations</div> <div class="card-label">Integrations</div>
<dl class="stat-grid"> <dl class="stat-grid">
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div> <div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
<div class="stat"><dt>Anthropic API</dt><dd id="i-anthropic">—</dd></div>
</dl> </dl>
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div> <div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>, <code>ANTHROPIC_API_KEY</code>) on the server, not here.</div>
</div> </div>
<!-- Schedule --> <!-- Schedule -->
@ -355,9 +370,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<input type="time" id="f-time" style="max-width:160px"> <input type="time" id="f-time" style="max-width:160px">
</div> </div>
<div class="field"> <div class="field">
<label class="field-label">Pull Days</label> <label class="field-label">Pull Day of Month</label>
<div class="days-row" id="days-row"></div> <input type="number" id="f-day" min="1" max="28" style="max-width:100px">
<div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed &mdash; most people want just one (e.g. Sunday evening).</div> <div class="item-sub" style="margin-top:8px;">Fires once a month and pushes whichever month is currently published &mdash; e.g. day 25 catches next month's menu once the district posts it near month-end. There's no dedup, so re-pushing the same month creates duplicate calendar events.</div>
</div> </div>
<hr class="divider"> <hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button> <button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
@ -367,8 +382,6 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
let cfg = {}; let cfg = {};
async function load() { async function load() {
@ -391,18 +404,16 @@ function fmt(iso) {
} }
function applyIntegrations(ig) { function applyIntegrations(ig) {
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured const badge = ok => ok
? '<span class="badge badge-green">Configured</span>' ? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>'; : '<span class="badge badge-yellow">Not configured</span>';
document.getElementById('i-ha').innerHTML = badge(ig.ha_webhook_configured);
document.getElementById('i-anthropic').innerHTML = badge(ig.anthropic_api_key_configured);
} }
function applySettings(s) { function applySettings(s) {
document.getElementById('f-time').value = s.calendar_run_time || '18:00'; document.getElementById('f-time').value = s.calendar_run_time || '18:00';
const row = document.getElementById('days-row'); document.getElementById('f-day').value = s.calendar_run_day_of_month || 25;
row.innerHTML = DAYS.map(d =>
`<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
).join('');
} }
function applyStatus(st) { function applyStatus(st) {
@ -417,8 +428,8 @@ function applyStatus(st) {
async function saveSchedule() { async function saveSchedule() {
const calendar_run_time = document.getElementById('f-time').value; const calendar_run_time = document.getElementById('f-time').value;
const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day); const calendar_run_day_of_month = parseInt(document.getElementById('f-day').value, 10);
await post('/api/settings', {calendar_run_time, calendar_run_days}); await post('/api/settings', {calendar_run_time, calendar_run_day_of_month});
toast('Schedule saved'); toast('Schedule saved');
load(); load();
} }
@ -441,14 +452,14 @@ async function previewMenu() {
const r = await fetch('/api/preview'); const r = await fetch('/api/preview');
const data = await r.json(); const data = await r.json();
if (data.error) { if (data.error) {
box.innerHTML = `<div class="week-day-empty">Error: ${data.error}</div>`; box.innerHTML = `<div class="day-row-empty">Error: ${data.error}</div>`;
} else { } else {
box.innerHTML = (data.days||[]).map(d => ` box.innerHTML = (data.days||[]).map(d => `
<div class="week-day"> <div class="day-row">
<div class="week-day-date">${d.date}</div> <div class="day-row-date">${d.date}</div>
${d.items.length ${d.items.length
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>` ? `<div class="day-row-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
: '<div class="week-day-empty">No menu posted</div>'} : '<div class="day-row-empty">No menu posted</div>'}
</div>`).join(''); </div>`).join('');
} }
box.style.display = ''; box.style.display = '';

View file

@ -1,3 +1,4 @@
anthropic==0.125.0
flask==3.1.0 flask==3.1.0
requests==2.32.3 requests==2.32.3
schedule==1.2.2 schedule==1.2.2

View file

@ -1,51 +1,93 @@
import base64
import json
import logging import logging
from datetime import datetime
import anthropic
import requests import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
SCHOOL_SLUG = "kyrene-de-la-mariposa" # Stable Finalsite "resource manager" permalink for the K-5 lunch menu, found
MENU_TYPE = "lunch" # on https://www.kyrene.org/family-student-resources/food-services/school-menus.
API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school" # 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: def _fetch_current_pdf() -> bytes:
dt = datetime.strptime(date_str, "%Y-%m-%d") resp = requests.get(MENU_PAGE_URL, timeout=20)
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,
)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.content
def _extract_items(day: dict) -> list[str]: def get_month_menu() -> list[dict]:
items = [] """Fetches whichever monthly K-5 lunch menu PDF is currently published
for entry in day.get("menu_items", []): and asks Claude to read it into structured per-day data. Returns
if entry.get("is_section_title") or entry.get("is_station_header"): [{"date": "YYYY-MM-DD", "items": [...]}, ...] for every day of that
continue month — days with no posted menu (weekends, breaks, holidays) come back
food = entry.get("food") with an empty items list, same contract the old Nutrislice-based scraper
if food and food.get("name"): used."""
items.append(food["name"].strip()) log.info(f"Fetching {MENU_PAGE_URL}")
return items 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]: text = next(b.text for b in response.content if b.type == "text")
"""Returns every day in the school-published week containing date_str, return json.loads(text)["days"]
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")
]