Logs into a portal with a real browser, does one thing, and reports what it saw. What's interesting isn't the Selenium — it's the three architectures it took to get a "simple cron" to fire reliably every day, for months, with nobody watching.
Drives a real headless browser, does a real login, and captures a screenshot as evidence — so you find out the auth flow broke before your users do. That's how it caught an upstream portal going down intermittently at certain hours.
Some recurring web task has to happen within a time window, unattended, and can't silently be skipped. The runner reads the portal's current state and acts idempotently: it never repeats something already done.
Each fix exposed the next weakest link: trigger → network identity → host.
A cron: in a workflow. But GitHub's scheduled events are best-effort: under load they lag 1–1.5 h or get dropped. A monitor that silently skips days is worse than no monitor.
Timekeeping moves to a Worker (cron in ~4–7 s, no queue) that calls workflow_dispatch. GitHub's schedule becomes a redundant backup. New failure: the portal timed out logins from datacenter IPs.
Runs under systemd timers on an owned machine. Residential IP → no login throttling. Full control of the clock, the browser, and the retries. ~20 W, months unattended.
A scheduled action that runs unattended has one job it must never get wrong: doing the thing twice. This one never decides from memory — it reads what the portal is currently offering and derives what to do from that. Pick a scenario and watch it decide.
What makes redundant triggers safe: if two schedulers fire, the second one sees the action already done, reports ALREADY, and clicks nothing.
ALREADY / NOT_APPLICABLE states: safe against duplicate/redundant triggers and manual intervention.
RandomizedDelaySec=600 spreads each run across a 10-minute window: systemd delays by a random 0..600s, always forward, never earlier. The target sees naturally-paced traffic, not a synchronized spike. If there's a minimal gap between two runs, size the nominal so the worst-case jitter still meets it.
Refuses to act outside sane hours even if mis-triggered. Weekend and holiday gating in two layers: workflow + code.
Every run writes STATUS|HOUR|MESSAGE — even an uncaught exception is trapped to leave one. Retries + backoff (3 attempts, 15s apart), and a screenshot on every outcome that opens a browser. The caller reads the status line and decides whether to shout.
Riding on the reliable scheduler, a second run sends the team chat the clock-in reminder — and enriches it with the day's menu, weather with forecast, and live travel times to the office. The interesting engineering isn't fetching the data: it's enriching without becoming fragile.
Lesson: scrape the marker, not the list. The portal flags the default dish and the vegetarian one with an icon; the first parser ignored them and dumped all 17 orderable items. The fix read that marker on the node it had been skipping.
Lesson: the weather upstream returned 503 right on the hour — the peak — and the block dropped out. Retries with backoff instead of skipping: temp + humidity + forecast with icon.
Live-traffic times from several zones — inbound in the morning, outbound in the afternoon. The free tier caps QPS: sequential calls with retry. Color by with/without-traffic ratio (🟢🟡🔴).
Each block is optional and independent: if the menu, weather, or traffic block fails, the reminder still goes out. Per-block logging to a daily file + classified errors (401/403/429/5xx/timeout).
🕘 Reminder: clock in 🍽️ Menu of the day: Pizza 🥗 Vegetarian: Veggie pizza 🌡️ 12° (humidity 58%) ☁️ forecast: cloudy, low 1° / high 15° 🚗 Arrival (with traffic): • North zone → 27 min 🟢 • Downtown → 47 min 🟡
Same principle as the rest of the project: unattended = observable. A per-block checkpoint (CK-BLOQUES) says what came through and what got skipped; the detail of every external call goes into a daily log for debugging.
# 1. A cron you don't control is a suggestion. if reliability_matters: own_the_clock() # 2. Each fix reveals the next weakest link trigger -> network_identity -> host # 3. Idempotency is what makes redundancy safe read_state() # don't assume # 4. Unattended = observable screenshots + structured_result
The right place for a small, private, always-on job is usually a box you own — not someone else's CI. Both schedulers ship included (GitHub Actions and systemd): the job logic is identical, the scheduler is swappable.
zoneinfo TZscreenshots