Initial commit: Lunch Notifier source from server

This commit is contained in:
mattie726 2026-08-25 22:53:07 -07:00 committed by Barely Removable
commit b568287528
9 changed files with 878 additions and 0 deletions

View file

@ -0,0 +1,21 @@
{
"permissions": {
"allow": [
"WebFetch(domain:kyrene.nutrislice.com)",
"Bash(docker info)",
"Bash(docker compose build)",
"Bash(docker compose up -d)",
"Bash(curl -s http://localhost:2323/)",
"Bash(curl -s -X POST http://localhost:2323/send)",
"Bash(docker exec lunch-lunch-notifier-1 python -c \":*)",
"Bash(docker compose build --no-cache)",
"Bash(docker compose up -d --force-recreate)",
"Bash(curl -s http://localhost:2323/api/status)",
"Bash(curl -s -X POST http://localhost:2323/api/send)",
"Bash(ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@192.168.86.33 \"uname -a && docker info 2>&1 | head -3\")",
"Bash(ssh root@192.168.86.33 \"timedatectl 2>/dev/null || date && cat /etc/timezone 2>/dev/null || ls /etc/localtime\")",
"Bash(ssh root@192.168.86.33 \"mkdir -p /mnt/user/appdata/lunch-notifier\")",
"Bash(rsync -av --exclude='__pycache__' --exclude='*.pyc' /Users/matt/Lunch/ root@192.168.86.33:/mnt/user/appdata/lunch-notifier/)"
]
}
}

26
.env.example Normal file
View file

@ -0,0 +1,26 @@
# Copy this to .env and fill in your values
# --- Required ---
# 10-digit phone number (no + or country code)
PHONE_NUMBER=4805405363
# Your carrier's SMS email gateway
# Verizon: vtext.com | T-Mobile: tmomail.net | AT&T: txt.att.net | Cricket: sms.cricketwireless.net
CARRIER_GATEWAY=vtext.com
# Gmail address to send from
GMAIL_USER=you@gmail.com
# Gmail App Password (myaccount.google.com → Security → App passwords)
# Remove spaces from the 16-char code
GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx
# --- Optional ---
# Time to send the text each day (24-hour HH:MM, container timezone)
RUN_TIME=07:00
# Days to run (comma-separated: mon,tue,wed,thu,fri,sat,sun)
RUN_DAYS=mon,tue,wed,thu,fri
# HTTP trigger server port
PORT=2323

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.env
data/
__pycache__/
*.pyc

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM python:3.12-slim
LABEL org.opencontainers.image.title="🍱 Lunch Notifier"
LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → SMS"
LABEL org.opencontainers.image.url="http://localhost:2323"
LABEL org.opencontainers.image.icon="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY scraper.py app.py ./
# Settings persist here (mount as a volume)
VOLUME ["/data"]
CMD ["python", "app.py"]

576
app.py Normal file
View file

@ -0,0 +1,576 @@
"""
Lunch Notifier web UI + scheduler combined.
Visit http://localhost:2323 to manage settings.
"""
import json
import logging
import os
import smtplib
import threading
import time
from datetime import date, datetime
from email.message import EmailMessage
from pathlib import Path
import schedule
from flask import Flask, jsonify, request
from scraper import get_lunch_menu
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
PORT = int(os.environ.get("PORT", "2323"))
# Env-var defaults (used only when no settings file exists yet)
_DEFAULTS = {
"phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p],
"carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"),
"run_time": os.environ.get("RUN_TIME", "07:00"),
"run_days": [d.strip() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",") if d.strip()],
"vacations": [],
}
_status = {"last_run": None, "last_result": None, "last_message": None}
_lock = threading.Lock()
# ── Settings ──────────────────────────────────────────────────────────────────
def load_settings() -> dict:
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE) as f:
return json.load(f)
except Exception:
pass
return dict(_DEFAULTS)
def save_settings(s: dict) -> None:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(s, f, indent=2)
def is_on_vacation(settings: dict) -> str | None:
today = date.today().isoformat()
for v in settings.get("vacations", []):
if v["start"] <= today <= v["end"]:
return v.get("label", "Vacation")
return None
# ── Core job ──────────────────────────────────────────────────────────────────
def _send_email_sms(message: str, phone: str, gateway: str) -> None:
gmail_user = os.environ["GMAIL_USER"]
gmail_pass = os.environ["GMAIL_APP_PASSWORD"]
to_addr = f"{phone}@{gateway}"
msg = EmailMessage()
msg["From"] = gmail_user
msg["To"] = to_addr
msg.set_content(message)
log.info(f"Sending → {to_addr}")
with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as smtp:
smtp.starttls()
smtp.login(gmail_user, gmail_pass)
smtp.send_message(msg)
log.info(f"Sent ✓ → {to_addr}")
def run_job() -> None:
global _status
settings = load_settings()
vac = is_on_vacation(settings)
if vac:
log.info(f"Skipping — vacation: {vac}")
_status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"}
return
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
gateway = settings.get("carrier_gateway", "vtext.com")
try:
items, date_str = get_lunch_menu()
if items:
body = "\n".join(f"{i}" for i in items)
message = f"Mariposa Lunch {date_str}:\n{body}"
else:
message = f"No lunch menu posted for {date_str} (may be a break or holiday)."
except Exception as e:
log.exception("Menu fetch failed")
message = f"Error fetching Mariposa lunch menu: {e}"
if len(message) > 1400:
message = message[:1397] + "..."
log.info(f"Message:\n{message}")
errors = []
for phone in phones:
try:
_send_email_sms(message, phone, gateway)
except Exception as e:
log.error(f"Failed to send to {phone}: {e}")
errors.append(str(e))
_status = {
"last_run": datetime.now().isoformat(),
"last_result": "error: " + "; ".join(errors) if errors else "ok",
"last_message": message,
}
# ── 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 apply_schedule(settings: dict) -> None:
with _lock:
schedule.clear()
run_time = settings.get("run_time", "07:00")
for day in settings.get("run_days", []):
day = day.strip().lower()
if day in DAY_MAP_FNS:
DAY_MAP_FNS[day]().at(run_time).do(run_job)
log.info(f"Scheduled: {day} at {run_time}")
def _schedule_loop() -> None:
while True:
with _lock:
schedule.run_pending()
time.sleep(30)
# ── Flask API ─────────────────────────────────────────────────────────────────
MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
@app.route("/")
def index():
return HTML, 200, {"Content-Type": "text/html"}
@app.route("/icon.png")
def icon():
import requests as req
r = req.get(MASCOT_URL, timeout=10)
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"}
@app.route("/api/settings", methods=["GET"])
def api_get_settings():
return jsonify(load_settings())
@app.route("/api/settings", methods=["POST"])
def api_post_settings():
data = request.get_json(force=True)
s = load_settings()
for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"):
if key in data:
s[key] = data[key]
save_settings(s)
apply_schedule(s)
return jsonify({"ok": True})
@app.route("/api/vacation", methods=["POST"])
def api_add_vacation():
data = request.get_json(force=True)
s = load_settings()
s.setdefault("vacations", []).append({
"id": str(int(time.time() * 1000)),
"start": data["start"],
"end": data["end"],
"label": data.get("label", "Vacation"),
})
save_settings(s)
return jsonify({"ok": True})
@app.route("/api/vacation/<vid>", methods=["DELETE"])
def api_delete_vacation(vid: str):
s = load_settings()
s["vacations"] = [v for v in s.get("vacations", []) if str(v.get("id")) != vid]
save_settings(s)
return jsonify({"ok": True})
@app.route("/api/send", methods=["POST"])
def api_send():
threading.Thread(target=run_job, daemon=True).start()
return jsonify({"ok": True})
@app.route("/api/status")
def api_status():
s = load_settings()
with _lock:
next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run])
return jsonify({
**_status,
"next_run": next_runs[0].isoformat() if next_runs else None,
"on_vacation": is_on_vacation(s),
})
# ── Embedded UI ───────────────────────────────────────────────────────────────
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🍱 Lunch Notifier</title>
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--primary: #14b8a6;
--primary-hover: #0d9488;
--danger: #f87171;
--success: #4ade80;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
header { margin-bottom: 28px; }
header h1 { font-size: 1.5rem; font-weight: 700; }
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
/* Status grid */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
.stat dd { font-size: .875rem; font-weight: 500; }
/* Badges */
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
.badge-green { background: #14532d; color: #4ade80; }
.badge-yellow { background: #713f12; color: #fbbf24; }
.badge-red { background: #7f1d1d; color: #fca5a5; }
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
/* Buttons */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg); }
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
.btn-danger:hover { background: #1a0f0f; }
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
.btn-sm { padding: 6px 12px; font-size: .75rem; }
/* Form elements */
.field { margin-bottom: 14px; }
.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=date] {
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); }
/* Lists */
.item-list { list-style: none; }
.item-row { display: flex; align-items: center; gap: 10px; padding: 10px 0;
border-bottom: 1px solid var(--border); }
.item-row:last-child { border-bottom: none; }
.item-main { flex: 1; }
.item-title { font-size: .875rem; font-weight: 500; }
.item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; }
/* Add row */
.add-row { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; }
.add-row .field { flex: 1; margin-bottom: 0; }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
/* Toast */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
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); }
.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
</style>
</head>
<body>
<div class="container">
<header>
<div style="display:flex;align-items:center;gap:16px;">
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
<div>
<h1>Lunch Notifier</h1>
<p>Kyrene de la Mariposa &middot; Automated lunch menu texts</p>
</div>
</div>
</header>
<!-- Status -->
<div class="card">
<div class="card-label">Status</div>
<dl class="stat-grid">
<div class="stat"><dt>Last Run</dt><dd id="s-last-run"></dd></div>
<div class="stat"><dt>Result</dt><dd id="s-result"></dd></div>
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next"></dd></div>
<div class="stat"><dt>Mode</dt><dd id="s-mode"></dd></div>
</dl>
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div>
<!-- Send Now -->
<div class="card">
<div class="card-label">Manual Send</div>
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
Send Lunch Menu Now
</button>
</div>
<!-- Schedule -->
<div class="card">
<div class="card-label">Schedule</div>
<div class="field">
<label class="field-label">Send Time</label>
<input type="time" id="f-time" style="max-width:160px">
</div>
<div class="field">
<label class="field-label">Days</label>
<div class="days-row" id="days-row"></div>
</div>
<hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
</div>
<!-- Phone Numbers -->
<div class="card">
<div class="card-label">Phone Numbers</div>
<ul class="item-list" id="phone-list"></ul>
<div class="add-row">
<div class="field">
<label class="field-label">Add Number (10 digits)</label>
<input type="text" id="f-phone" placeholder="4805551234" maxlength="15"
onkeydown="if(event.key==='Enter') addPhone()">
</div>
<button class="btn btn-primary btn-sm" onclick="addPhone()">Add</button>
</div>
</div>
<!-- Vacation -->
<div class="card">
<div class="card-label">Vacation / No-Send Periods</div>
<ul class="item-list" id="vac-list"></ul>
<hr class="divider">
<div class="field">
<label class="field-label">Label</label>
<input type="text" id="f-vac-label" placeholder="Spring Break">
</div>
<div class="row-2">
<div class="field">
<label class="field-label">Start Date</label>
<input type="date" id="f-vac-start">
</div>
<div class="field">
<label class="field-label">End Date</label>
<input type="date" id="f-vac-end">
</div>
</div>
<button class="btn btn-primary" onclick="addVacation()">Add Period</button>
</div>
</div>
<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() {
const [s, st] = await Promise.all([
fetch('/api/settings').then(r=>r.json()),
fetch('/api/status').then(r=>r.json()),
]);
cfg = s;
applySettings(s);
applyStatus(st);
}
function fmt(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
}
function applySettings(s) {
document.getElementById('f-time').value = s.run_time || '07:00';
// Days
const row = document.getElementById('days-row');
row.innerHTML = DAYS.map(d =>
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}"
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
).join('');
// Phones
const pl = document.getElementById('phone-list');
if (!(s.phone_numbers||[]).length) {
pl.innerHTML = '<li class="empty-state">No numbers added yet.</li>';
} else {
pl.innerHTML = (s.phone_numbers||[]).map((p,i) => `
<li class="item-row">
<div class="item-main"><div class="item-title">${p}</div></div>
<button class="btn btn-danger btn-sm" onclick="removePhone(${i})">Remove</button>
</li>`).join('');
}
// Vacations
const vl = document.getElementById('vac-list');
if (!(s.vacations||[]).length) {
vl.innerHTML = '<li class="empty-state">No vacation periods set.</li>';
} else {
vl.innerHTML = (s.vacations||[]).map(v => `
<li class="item-row">
<div class="item-main">
<div class="item-title">${v.label}</div>
<div class="item-sub">${v.start} &rarr; ${v.end}</div>
</div>
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
</li>`).join('');
}
}
function applyStatus(st) {
document.getElementById('s-last-run').textContent = fmt(st.last_run);
const res = st.last_result;
let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : '';
document.getElementById('s-result').innerHTML = res ? `<span class="badge ${cls}">${res}</span>` : '';
document.getElementById('s-next').textContent = fmt(st.next_run);
const vac = st.on_vacation;
document.getElementById('s-mode').innerHTML = vac
? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
: '<span class="badge badge-green">Active</span>';
const msgEl = document.getElementById('s-last-msg');
if (st.last_message) {
msgEl.textContent = st.last_message;
msgEl.style.display = '';
} else {
msgEl.style.display = 'none';
}
}
async function saveSchedule() {
const run_time = document.getElementById('f-time').value;
const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day);
await post('/api/settings', {run_time, run_days});
toast('Schedule saved');
load();
}
async function addPhone() {
const input = document.getElementById('f-phone');
const phone = input.value.trim().replace(/\D/g,'');
if (!phone) { toast('Enter a 10-digit number'); return; }
const phone_numbers = [...(cfg.phone_numbers||[]), phone];
await post('/api/settings', {phone_numbers});
input.value = '';
toast('Number added');
load();
}
async function removePhone(idx) {
const phone_numbers = (cfg.phone_numbers||[]).filter((_,i)=>i!==idx);
await post('/api/settings', {phone_numbers});
toast('Number removed');
load();
}
async function addVacation() {
const label = document.getElementById('f-vac-label').value.trim() || 'Vacation';
const start = document.getElementById('f-vac-start').value;
const end = document.getElementById('f-vac-end').value;
if (!start || !end) { toast('Set both start and end dates'); return; }
if (end < start) { toast('End must be after start'); return; }
await post('/api/vacation', {label, start, end});
document.getElementById('f-vac-label').value = '';
document.getElementById('f-vac-start').value = '';
document.getElementById('f-vac-end').value = '';
toast('Vacation period added');
load();
}
async function removeVacation(id) {
await fetch(`/api/vacation/${id}`, {method:'DELETE'});
toast('Removed');
load();
}
async function sendNow() {
const btn = document.getElementById('send-btn');
btn.disabled = true;
btn.textContent = 'Sending…';
await post('/api/send', {});
toast('Triggered! Check status in a moment.');
setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000);
}
async function post(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
}
let toastTimer;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
load();
setInterval(load, 30000);
</script>
</body>
</html>"""
# ── Entrypoint ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if not SETTINGS_FILE.exists():
log.info(f"Creating default settings at {SETTINGS_FILE}")
save_settings(_DEFAULTS)
apply_schedule(load_settings())
threading.Thread(target=_schedule_loop, daemon=True).start()
log.info(f"Web UI → http://localhost:{PORT}")
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
services:
lunch-notifier:
container_name: Lunch-Notifier
build: .
restart: unless-stopped
env_file: .env
ports:
- "2323:2323"
volumes:
- ./data:/data
# Uncomment to share host timezone so RUN_TIME matches your local clock:
# - /etc/localtime:/etc/localtime:ro

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask==3.1.0
requests==2.32.3
schedule==1.2.2

115
scheduler.py Normal file
View file

@ -0,0 +1,115 @@
"""
Runs scraper.py on a schedule inside the container.
HTTP trigger server on PORT (default 2323):
GET / health check / status
POST /send trigger scraper immediately
Env vars:
PHONE_NUMBER destination phone (required)
TEXTBELT_KEY textbelt key (default: "textbelt" free tier)
RUN_TIME HH:MM 24h time to fire daily (default: 07:00)
RUN_DAYS comma-sep days: mon,tue,wed,thu,fri (default)
PORT HTTP port (default: 2323)
"""
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import schedule
from scraper import main as run_scraper # now a plain sync function
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
RUN_TIME = os.environ.get("RUN_TIME", "07:00")
RUN_DAYS = [d.strip().lower() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",")]
PORT = int(os.environ.get("PORT", "2323"))
DAY_MAP = {
"mon": schedule.every().monday,
"tue": schedule.every().tuesday,
"wed": schedule.every().wednesday,
"thu": schedule.every().thursday,
"fri": schedule.every().friday,
"sat": schedule.every().saturday,
"sun": schedule.every().sunday,
}
_last_run: str = "never"
_last_result: str = "n/a"
def job():
global _last_run, _last_result
log.info("Running scheduled lunch menu check...")
try:
run_scraper()
_last_result = "ok"
except SystemExit:
_last_result = "sms_failed"
except Exception as e:
log.exception("Scraper failed")
_last_result = f"error: {e}"
_last_run = time.strftime("%Y-%m-%d %H:%M:%S")
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
log.info("HTTP %s", fmt % args)
def _respond(self, code: int, body: str):
data = body.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path == "/":
status = (
f"lunch-notifier running\n"
f"schedule : {', '.join(RUN_DAYS)} at {RUN_TIME}\n"
f"phone : {os.environ.get('PHONE_NUMBER', 'NOT SET')}\n"
f"last run : {_last_run}\n"
f"last result: {_last_result}\n\n"
f"POST /send to trigger immediately"
)
self._respond(200, status)
else:
self._respond(404, "not found")
def do_POST(self):
if self.path == "/send":
log.info("Manual trigger via HTTP POST /send")
threading.Thread(target=job, daemon=True).start()
self._respond(200, "Triggered — check container logs for result.")
else:
self._respond(404, "not found")
def start_http_server():
server = HTTPServer(("0.0.0.0", PORT), Handler)
log.info(f"HTTP trigger server listening on port {PORT}")
server.serve_forever()
# ── Schedule setup ────────────────────────────────────────────────────────────
for day in RUN_DAYS:
if day in DAY_MAP:
DAY_MAP[day].at(RUN_TIME).do(job)
log.info(f"Scheduled: {day} at {RUN_TIME}")
else:
log.warning(f"Unknown day '{day}', skipping.")
log.info(f"Phone: {os.environ.get('PHONE_NUMBER', 'NOT SET')}")
threading.Thread(target=start_http_server, daemon=True).start()
while True:
schedule.run_pending()
time.sleep(30)

101
scraper.py Normal file
View file

@ -0,0 +1,101 @@
import os
import sys
import logging
import smtplib
from datetime import datetime
from email.message import EmailMessage
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"
def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]:
if date_str is None:
date_str = datetime.now().strftime("%Y-%m-%d")
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,
)
resp.raise_for_status()
data = resp.json()
items = []
for day in data.get("days", []):
if day.get("date") != date_str:
continue
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, date_str
def send_sms(message: str, phone: str) -> None:
gmail_user = os.environ["GMAIL_USER"]
gmail_pass = os.environ["GMAIL_APP_PASSWORD"]
gateway = os.environ.get("CARRIER_GATEWAY", "vtext.com")
to_addr = f"{phone}@{gateway}"
msg = EmailMessage()
msg["From"] = gmail_user
msg["To"] = to_addr
msg.set_content(message)
log.info(f"Sending email-to-SMS → {to_addr}")
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
smtp.starttls()
smtp.login(gmail_user, gmail_pass)
smtp.send_message(msg)
log.info("Sent.")
def main():
phone = os.environ.get("PHONE_NUMBER", "").strip()
if not phone:
log.error("PHONE_NUMBER environment variable is not set.")
sys.exit(1)
try:
items, date_str = get_lunch_menu()
if items:
body = "\n".join(f"{i}" for i in items)
message = f"Mariposa Lunch {date_str}:\n{body}"
else:
message = (
f"No lunch menu posted for {date_str} "
f"(Kyrene de la Mariposa — may be a break or holiday)."
)
except Exception as e:
log.exception("Failed to fetch menu")
message = f"Error fetching Mariposa lunch menu: {e}"
# SMS via email has a ~160 char limit per message segment; keep it short
if len(message) > 1400:
message = message[:1397] + "..."
log.info(f"Message:\n{message}")
try:
send_sms(message, phone)
except Exception as e:
log.error(f"Failed to send SMS: {e}")
sys.exit(1)
if __name__ == "__main__":
main()