diff --git a/.env.example b/.env.example index 0eb457e..d0bd1b7 100644 --- a/.env.example +++ b/.env.example @@ -6,12 +6,17 @@ # e.g. http://192.168.86.X:8123/api/webhook/ 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 --- -# Day(s) of week to pull the upcoming school week and push it to the -# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more -# than one day creates duplicate events for days already pushed — most -# people want just one. -CALENDAR_RUN_DAYS=sun +# Day of the month (1-28) to pull whichever month is currently published +# and push it to the calendar. Re-pushing the same month creates duplicate +# events, so pick one day near month-end (the district tends to post next +# month's menu a few days early). +CALENDAR_RUN_DAY_OF_MONTH=25 # Time to run the pull (24-hour HH:MM, container timezone) CALENDAR_RUN_TIME=18:00 diff --git a/CLAUDE.md b/CLAUDE.md index 7813e62..e620c6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,8 @@ # Lunch Notifier -Single-file Flask app that scrapes the weekly Mariposa (Kyrene school -district) lunch menu and pushes each day to a Home Assistant calendar as an -all-day event. Runs as one Docker container on the user's Unraid home -server. +Single-file Flask app that scrapes the Mariposa (Kyrene school district) +lunch menu and pushes each day to a Home Assistant calendar as an all-day +event. Runs as one Docker container on the user's Unraid home server. **2026-09-03: pivoted from SMS to calendar-only.** Originally texted phone 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 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 - **`app.py`** — everything: Flask web UI/API, `schedule`-library job scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend is embedded as a Python string constant (`HTML`) rather than a separate template file — no build step, no template engine. -- **`scraper.py`** — fetches and parses the school's published lunch menu - from Nutrislice's API. `get_week_menu(date_str)` returns every day in the - API's week-granular response as `[{"date": ..., "items": [...]}, ...]` - (the API returns a full week regardless of which single date you ask - for). Days with no posted menu (weekends, breaks, holidays) come back - with an empty `items` list rather than being omitted. +- **`scraper.py`** — fetches the currently-published K-5 lunch menu PDF and + asks Claude (Anthropic API, `claude-opus-5`, structured JSON output via + `output_config.format`) to read it into `[{"date": ..., "items": [...]}, ...]` + for every day of whatever month is published. Days with no posted menu + (weekends, breaks, holidays) come back with an empty `items` list rather + 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 - environment variables — env vars (`CALENDAR_RUN_TIME`, `CALENDAR_RUN_DAYS`) - are defaults used only the very first time, before a settings file exists. - `load_settings()` merges the file's contents *over* `_DEFAULTS` rather - than returning the file verbatim — this matters because the server's - settings.json predates this schema (it's from the SMS era) and lacks - `calendar_run_time`/`calendar_run_days` entirely; without the merge, the - scheduler would silently come up with zero jobs after every deploy until - someone happened to re-save the Schedule card in the UI. Verified via a - migration test (old-schema settings.json + fresh container → correct - defaults filled in, `next_calendar_run` populated). + environment variables — env vars (`CALENDAR_RUN_TIME`, + `CALENDAR_RUN_DAY_OF_MONTH`) are defaults used only the very first time, + before a settings file exists. `load_settings()` merges the file's + contents *over* `_DEFAULTS` rather than returning the file verbatim — + this matters because the server's settings.json predates several schema + changes (SMS era, then the weekly-schedule era) and is missing whatever + keys the current schema expects; without the merge, the scheduler would + silently come up with zero jobs after every deploy until someone happened + to re-save the Schedule card in the UI. - **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 - `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 with nothing happening." A separate vacation-range setting to *additionally* 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 being asked. - **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 - only way this app does anything externally-visible. On the HA side + 2026-09-03):** `HA_WEBHOOK_URL` is required — it's the only way this app + does anything externally-visible. On the HA side (`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`), a `local_only` webhook trigger runs `calendar.create_event` against `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 manually in Google Calendar. No duplicate-prevention: re-pushing the 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 - real bug):** a week push fires one webhook POST per school day - back-to-back with no delay. Under `mode: single`, HA rejects any - webhook trigger that arrives while a previous run is still executing - (`calendar.create_event` itself takes ~600-700ms) — 4 of every 5 days - were silently dropped as `failed_single`, so only one day's event ever - actually got created per week push. Found via `ha_get_automation_traces` - after the user reported a manual test push not showing up on the - calendar. Each invocation is independent (one `calendar.create_event` - call, no shared state), so `parallel` is the correct mode per HA's own - guidance ("independent per-entity actions → parallel"), not just a - workaround. The webhook id is + real bug):** a push fires one webhook POST per school day back-to-back + with no delay. Under `mode: single`, HA rejects any webhook trigger that + arrives while a previous run is still executing (`calendar.create_event` + itself takes ~600-700ms) — most days in a batch were silently dropped as + `failed_single`, so only one day's event ever actually got created per + push. Found via `ha_get_automation_traces` after the user reported a + manual test push not showing up on the calendar. Each invocation is + independent (one `calendar.create_event` call, no shared state), so + `parallel` is the correct mode per HA's own guidance ("independent + per-entity actions → parallel"), not just a workaround. The webhook id is 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 calendar push stops working, check both ends: `GET /api/integrations` on this app reports whether `HA_WEBHOOK_URL` is set at all, and `ha_get_automation_traces` on `automation.lunch_menu_family_calendar` shows whether HA is even receiving the webhook. -- **Independent weekly-pull scheduler (2026-09-03):** calendar pulls run on - their own schedule (`calendar_run_time`/`calendar_run_days` in settings, - `schedule` jobs tagged `"calendar"`), completely decoupled from any other - job — there's nothing else in this app anymore, but the tag/isolation - pattern (`schedule.clear("calendar")` instead of a bare `schedule.clear()`) - is deliberate so a future second schedule wouldn't stomp this one. - `push_week_to_calendar()` fetches the whole current school week and - pushes every day with a posted menu. Default is Sunday 18:00 (one - firing/week) — the UI warns that picking more than one day/week creates - duplicate events for days already covered, since there's no dedup. -- **Manual actions:** "Push This Week to Calendar" (`POST /api/push-calendar` - → `push_week_to_calendar()`, same function the schedule calls) and - "Preview This Week's Menu" (`GET /api/preview` → `get_week_menu()` +- **Monthly pull scheduler (weekly → monthly, 2026-09-08):** calendar pulls + run on their own schedule (`calendar_run_time`/`calendar_run_day_of_month` + in settings, `schedule` jobs tagged `"calendar"`), completely decoupled + from anything else — the tag/isolation pattern + (`schedule.clear("calendar")` instead of a bare `schedule.clear()`) is + deliberate so a future second schedule wouldn't stomp this one. The + `schedule` library has no native day-of-month trigger, so + `apply_calendar_schedule()` registers a *daily* job + (`_maybe_push_month`) that no-ops unless `datetime.now().day` matches + the configured day — `push_month_to_calendar()` only actually runs on + that one day per month. `next_calendar_run()` computes the real next + firing directly from settings (handling month/year rollover and clamping + 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 — use this to check the scraper before trusting a scheduled push). @@ -97,8 +135,10 @@ pip install -r requirements.txt python3 app.py ``` -Needs `HA_WEBHOOK_URL` set to actually push anywhere; without it the app -still runs and previews fine, `GET /api/integrations` just reports +Needs `ANTHROPIC_API_KEY` set for anything menu-related to work at all +(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"`. Visit `http://localhost:2323`. @@ -124,7 +164,8 @@ manually-edited `.env` on its own. **Persistent state, never touched by deploys:** `data/settings.json` (the volume-mounted `/data` inside the container) holds the calendar pull 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 timezone configured — `python:3.12-slim` defaults to UTC and the diff --git a/app.py b/app.py index b207e17..b50f547 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home Assistant calendar. Visit http://localhost:2323 to manage settings. """ +import calendar import json import logging import os @@ -14,7 +15,7 @@ import requests import schedule 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") 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) _DEFAULTS = { "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} @@ -41,8 +42,9 @@ def load_settings() -> dict: try: with open(SETTINGS_FILE) as f: # Merge over _DEFAULTS so a settings.json from before a schema - # change (e.g. the pre-calendar SMS-era file) still gets - # calendar_run_time/calendar_run_days rather than silently + # change (e.g. the pre-calendar SMS-era file, or the + # weekly-schedule era before calendar_run_day_of_month + # existed) still gets a valid schedule rather than silently # scheduling nothing. return {**_DEFAULTS, **json.load(f)} except Exception: @@ -80,23 +82,23 @@ def _push_calendar(items: list[str], date_str: str | None) -> str: return f"error: {e}" -def push_week_to_calendar() -> None: - """Fetch the current school week and push every day that has a posted - menu — days with nothing posted (weekends, breaks, holidays) are - skipped automatically since there's nothing to create an event from. - Runs on its own schedule (calendar_run_time/calendar_run_days), - independent of anything else.""" +def push_month_to_calendar() -> None: + """Fetch whichever month is currently published and push every day that + has a posted menu — days with nothing posted (weekends, breaks, + holidays) are skipped automatically since there's nothing to create an + event from. Runs on its own schedule (calendar_run_time/ + calendar_run_day_of_month), independent of anything else.""" global _status try: - week = get_week_menu() + month = get_month_menu() 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}"} return pushed = errors = no_items = 0 - for day in week: + for day in month: result = _push_calendar(day["items"], day["date"]) if result == "ok": pushed += 1 @@ -105,7 +107,7 @@ def push_week_to_calendar() -> None: else: 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: summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "") @@ -123,26 +125,43 @@ def push_week_to_calendar() -> None: # ── Scheduler ───────────────────────────────────────────────────────────────── -DAY_MAP_FNS = { - "mon": lambda: schedule.every().monday, - "tue": lambda: schedule.every().tuesday, - "wed": lambda: schedule.every().wednesday, - "thu": lambda: schedule.every().thursday, - "fri": lambda: schedule.every().friday, - "sat": lambda: schedule.every().saturday, - "sun": lambda: schedule.every().sunday, -} +def _maybe_push_month(day_of_month: int) -> None: + """schedule fires this daily at run_time; only actually pushes on the + configured day of the month (the `schedule` library has no native + day-of-month trigger).""" + if datetime.now().day == day_of_month: + push_month_to_calendar() def apply_calendar_schedule(settings: dict) -> None: with _lock: schedule.clear("calendar") run_time = settings.get("calendar_run_time", "18:00") - for day in settings.get("calendar_run_days", []): - day = day.strip().lower() - if day in DAY_MAP_FNS: - DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar") - log.info(f"Scheduled calendar pull: {day} at {run_time}") + day_of_month = settings.get("calendar_run_day_of_month", 25) + schedule.every().day.at(run_time).do(_maybe_push_month, day_of_month).tag("calendar") + log.info(f"Scheduled calendar pull: day {day_of_month} of each month 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: @@ -176,7 +195,7 @@ def api_get_settings(): def api_post_settings(): data = request.get_json(force=True) 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: s[key] = data[key] save_settings(s) @@ -186,32 +205,33 @@ def api_post_settings(): @app.route("/api/push-calendar", methods=["POST"]) 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}) @app.route("/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: - week = get_week_menu() + month = get_month_menu() except Exception as e: return jsonify({"error": str(e)}), 502 - return jsonify({"days": week}) + return jsonify({"days": month}) @app.route("/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") def api_status(): - with _lock: - next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run]) return jsonify({ **_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; } label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text); 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; font-size: .875rem; color: var(--text); background: var(--card); } 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; } /* Divider */ .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; pointer-events: none; white-space: nowrap; z-index: 100; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } -.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); } -.week-day:last-child { border-bottom: none; } -.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; } -.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; } -.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; } +.day-row { padding: 8px 0; border-bottom: 1px solid var(--border); } +.day-row:last-child { border-bottom: none; } +.day-row-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; } +.day-row-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; } +.day-row-empty { font-size: .8rem; color: var(--muted); font-style: italic; } @@ -330,10 +344,10 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
Manual Actions
@@ -343,8 +357,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
Integrations
Home Assistant
—
+
Anthropic API
—
-
Configured via .env (HA_WEBHOOK_URL) on the server, not here.
+
Configured via .env (HA_WEBHOOK_URL, ANTHROPIC_API_KEY) on the server, not here.
@@ -355,9 +370,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
- -
-
Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed — most people want just one (e.g. Sunday evening).
+ + +
Fires once a month and pushes whichever month is currently published — 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.

@@ -367,8 +382,6 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co