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

151
app.py
View file

@ -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; }
</style>
</head>
<body>
@ -330,10 +344,10 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card">
<div class="card-label">Manual Actions</div>
<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 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>
<div id="preview-box" style="display:none;margin-top:14px;"></div>
</div>
@ -343,8 +357,9 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card-label">Integrations</div>
<dl class="stat-grid">
<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>
<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>
<!-- 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">
</div>
<div class="field">
<label class="field-label">Pull Days</label>
<div class="days-row" id="days-row"></div>
<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>
<label class="field-label">Pull Day of Month</label>
<input type="number" id="f-day" min="1" max="28" style="max-width:100px">
<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>
<hr class="divider">
<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>
<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 = {};
async function load() {
@ -391,18 +404,16 @@ function fmt(iso) {
}
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-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) {
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
const row = document.getElementById('days-row');
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('');
document.getElementById('f-day').value = s.calendar_run_day_of_month || 25;
}
function applyStatus(st) {
@ -417,8 +428,8 @@ function applyStatus(st) {
async function saveSchedule() {
const calendar_run_time = document.getElementById('f-time').value;
const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
await post('/api/settings', {calendar_run_time, calendar_run_days});
const calendar_run_day_of_month = parseInt(document.getElementById('f-day').value, 10);
await post('/api/settings', {calendar_run_time, calendar_run_day_of_month});
toast('Schedule saved');
load();
}
@ -441,14 +452,14 @@ async function previewMenu() {
const r = await fetch('/api/preview');
const data = await r.json();
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 {
box.innerHTML = (data.days||[]).map(d => `
<div class="week-day">
<div class="week-day-date">${d.date}</div>
<div class="day-row">
<div class="day-row-date">${d.date}</div>
${d.items.length
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
: '<div class="week-day-empty">No menu posted</div>'}
? `<div class="day-row-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
: '<div class="day-row-empty">No menu posted</div>'}
</div>`).join('');
}
box.style.display = '';