Initial scaffold: Flask webui + APScheduler + Playwright automation for MassPrice rule scheduling
Some checks failed
Deploy / deploy (push) Failing after 51s

Drives massprice.tcgplayer.com's Run Rule action per-rule on a
user-configured schedule (multiple times/day, day-of-week toggles),
replacing MassPrice's native scheduler (capped at 15 schedules
account-wide, one run/day, with observed clock drift). Deployed via
Forgejo push-to-deploy on Unraid, matching arr-summary/card-grader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6TgffxTvYhqxykAbVQg2v
This commit is contained in:
Barely Removable 2026-08-31 17:31:53 -07:00
commit 2fdb9be9b9
19 changed files with 1197 additions and 0 deletions

View file

@ -0,0 +1,82 @@
---
name: deploy-massprice
description: Use when deploying MassPrice Scheduler, restarting the massprice container on Unraid, confirming a deploy landed, or troubleshooting its scheduled-rule automation.
---
## Deployment
**Target:** Unraid server, container path `/mnt/user/appdata/massprice`,
built from `Dockerfile`/`docker-compose.yml` in this repo. Container name
`massprice`, port `5056` (overridable via `HOST_PORT`) → container port
`5000`.
**Deploy mechanism: Forgejo push-to-deploy**, the same pattern as
`arr-summary`, `card-grader`, `lunch-notifier`, `n64-tracker` — see
`arr-summary`'s `deploy-arr-summary` skill or `card-grader`'s `CLAUDE.md`
for the full runner mechanics. Push to `main` on the `forgejo` remote
(`https://git.hippofam.com/ninja_hippo/massprice.git` — create this repo
on the Forgejo instance first if it doesn't exist yet) triggers
`.forgejo/workflows/deploy.yml`, which syncs into
`/mnt/user/appdata/massprice` (excluding `.git`, `.env`, `data/`) and
runs `docker compose up -d --build`.
**Restarting the container manually: use Compose, not the Unraid Docker
UI** — same reasoning as the other projects (every deploy recreates the
container with a new Docker ID; Unraid's UI caches the old one). From the
Unraid terminal:
```bash
cd /mnt/user/appdata/massprice && docker compose restart
```
`docker compose up -d --force-recreate` for a guaranteed-clean recreate;
`docker ps --filter name=massprice` to check real state.
**Confirming a deploy landed:** the footer renders
`v<APP_VERSION> · <APP_VERSION_DATE>` from constants near the top of
`app.py`. Bump them every commit.
## First deploy checklist
1. Create `/mnt/user/appdata/massprice` on Unraid and a real `.env`
there (copy `.env.example`, fill in `LOGIN_PASSWORD`, `SECRET_KEY`,
`MASSPRICE_USERNAME`, `MASSPRICE_PASSWORD`, `TZ`).
2. First `docker compose up -d --build` — confirm the container starts
and `/login` responds on `HOST_PORT`.
3. Log into the webui, click "Refresh Rules" — confirms Playwright can
reach massprice.tcgplayer.com and log in automatically. **If this
fails**, check `data/run_history.json`/container logs for the login
error — the login form locators in `automation._login()` were written
generically (never verified against the real logged-out form, see
`CLAUDE.md`) and may need adjusting to match TCGplayer's actual field
names/labels.
4. Set up at least one schedule and use "Run Now" once to confirm a
click actually lands — cross-check by looking at MassPrice's own
Activity Log (`https://massprice.tcgplayer.com/Activity/List`) for a
fresh timestamp on that rule.
5. **Only after step 4 succeeds**, go to each of the 5 existing rules on
massprice.tcgplayer.com (three-dot menu → Edit Schedule) and set
Status to Inactive, so native scheduling stops double-triggering runs
and this tool becomes the sole source of scheduled runs.
## Persistent state, never touched by deploys
`.env` holds `LOGIN_PASSWORD`, `SECRET_KEY`, and the MassPrice
credentials. `data/` (bind-mounted to `/data`) holds `schedules.json`
(the schedules set from the webui), `auth_state.json` (the saved
MassPrice browser session — losing this just costs one re-login, not
data), `run_history.json`, and `rules_cache.json`. Both are excluded
from the deploy rsync (which runs `--delete`) and from git.
## If scheduled runs stop working
Check, in order:
1. **Activity page in the webui** — does the failure message say
"still on a login page" (credentials/CAPTCHA problem) or "rule id not
found" (a rule was renamed/deleted on MassPrice — click "Refresh
Rules")?
2. **Is the container still running** — `docker ps --filter
name=massprice` — a Chromium crash inside the container (rare, but
possible if `shm_size` ever gets dropped from `docker-compose.yml`)
would show as the container restarting.
3. **Timezone** — `TZ` in `.env` controls what "6:00 AM" means for every
schedule; confirm it matches what the user actually expects before
assuming a schedule itself is wrong.

26
.env.example Normal file
View file

@ -0,0 +1,26 @@
# ══════════════════════════════════════════════════════════════════
# MassPrice Scheduler — Environment Variables
# Copy this file to .env and fill in your values.
# ══════════════════════════════════════════════════════════════════
# ── Login (this tool's own login page, not your MassPrice account) ─
LOGIN_PASSWORD=changeme
# Secret key for Flask session signing — generate a random string:
# python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_this_to_a_random_secret_key
# ── MassPrice (TCGplayer) login ─────────────────────────────────────
# Used by Playwright to log in automatically the first time, and again
# any time the saved session expires. Stored here only — gitignored,
# never committed, never logged.
MASSPRICE_USERNAME=your_tcgplayer_email@example.com
MASSPRICE_PASSWORD=your_tcgplayer_password
# ── Host port (what port Unraid exposes) ────────────────────────────
# Default is 5056 (arr-summary already uses 5055) — change if in use
HOST_PORT=5056
# ── Timezone ─────────────────────────────────────────────────────────
# Controls what "6:00 AM" means for your schedules
TZ=America/New_York

View file

@ -0,0 +1,29 @@
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install rsync
run: apt-get update -qq && apt-get install -y -qq rsync
# .env and data/ are server-only state and must survive every
# deploy — without the excludes, --delete removes them because
# they aren't in the repo.
- name: Sync source to deploy path
run: |
rsync -a --delete \
--exclude='.git' \
--exclude='.env' \
--exclude='data' \
./ /mnt/user/appdata/massprice/
- name: Rebuild and restart
working-directory: /mnt/user/appdata/massprice
run: docker compose up -d --build

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.env
data/
__pycache__/
*.pyc
.DS_Store
.venv/

117
CLAUDE.md Normal file
View file

@ -0,0 +1,117 @@
# MassPrice Scheduler
A Flask app that drives a real headless-Chromium session against
`massprice.tcgplayer.com` (TCGplayer's bulk pricing-rule tool) to click
"Run Rule" for each of the user's pricing rules on a schedule set through
this app's own web UI. Runs as one Docker container on the user's Unraid
home server, deployed the same way as the other projects on that box
(`arr-summary`, `card-grader`, `lunch-notifier`, `n64-tracker`).
## Why this exists
MassPrice has its own native per-rule scheduler (day-of-week + one
time-of-day), but it's capped at 15 active schedules account-wide and
only allows **one run per day per rule** — no multiple runs/day. Native
runs also showed real clock drift during discovery (6–22 minutes late
against their scheduled time). This tool **replaces** native scheduling
entirely: each rule's native schedule should be set to Inactive once this
tool is confirmed working, so there's exactly one thing triggering runs.
## Architecture
- **`app.py`** — Flask app, single shared-password session auth
(`LOGIN_PASSWORD` env var, `hmac.compare_digest`) — this protects the
*admin UI itself*, unrelated to the MassPrice/TCGplayer login below.
Routes: `/` (Dashboard — rule cards with schedule editor), `/activity`
(run history), and a small JSON API
(`/api/rules/refresh`, `/api/rules/<id>/schedule`,
`/api/rules/<id>/run`) that the Dashboard's JS calls without full page
reloads.
- **`scheduler.py`** — an in-process APScheduler `BackgroundScheduler`
holding one `CronTrigger` job per `(rule, schedule entry)` pair,
rebuilt from `schedules.json` on startup and whenever the webui saves
a change. Triggered jobs (and manual "Run Now" clicks) are pushed onto
one `queue.Queue`, drained by a single dedicated worker thread — this
guarantees only one Playwright browser session is ever active at a
time, matching how a human would actually operate the site and
avoiding concurrent-session weirdness.
- **Must run in exactly one process.** `gunicorn.conf.py` pins
`workers = 1` for this reason — a second worker would instantiate a
second scheduler and duplicate every run. Fine for a low-traffic
single-user tool.
- **`automation.py`** — all Playwright logic. `list_rules()` scrapes
`https://massprice.tcgplayer.com/Rule/List` for the live rule
id/name pairs (cached to `rules_cache.json`, refreshed via the
Dashboard's "Refresh Rules" button or on first boot). `run_rule(id)`
finds that rule's `li.rule.widget-rule` row, clicks its
`.dropdown-ellipsis` three-dot toggle, then clicks the row's
`a.run-rule[data-rule-id=...]` item — mirroring exactly what a human
does in the UI. **No public API was found or used** — the "Run Rule"
click handler is JS/AJAX-bound (no plain href) and its request is
almost certainly anti-forgery-token protected, so automation drives
the real page rather than replaying a raw HTTP POST.
- **Session persistence** — a Playwright `storage_state` (cookies +
localStorage) is saved to `/data/auth_state.json` and reused across
runs: the tool logs in **once**, then slides the session forward after
every successful run, rather than hitting the login form on every
scheduled click. A fresh login (via `MASSPRICE_USERNAME` /
`MASSPRICE_PASSWORD`) only happens when the saved state is missing or
has expired (detected by landing back on a login page).
- **`storage.py`** — tiny JSON read/write helpers (temp-file +
`os.replace`, so a crash mid-write can't corrupt state) for
everything under `/data`: `schedules.json`, `auth_state.json`,
`run_history.json` (bounded to the last 200 entries), `rules_cache.json`.
- **`templates/` / `static/`** — a small, plain dark dashboard. This is
a deliberately different, simpler visual identity than Arr Summary's
teletext system — that design language is specific to that app, not a
house style to reuse here.
## Known limitation — automated login
`MASSPRICE_USERNAME`/`MASSPRICE_PASSWORD` drive a fully automated login
(the user's explicit choice over a manual hand-off flow). **If
TCGplayer's login ever presents a CAPTCHA or a 2FA challenge, automated
login will fail outright** — there is deliberately no fallback UI for
this yet; a failed login just shows up as a `failure` entry in Activity
with the error message. If this starts happening routinely, it needs a
design change (e.g. a Chrome-remote-debugging-based manual re-auth flow),
not a bigger retry loop.
**The real logged-out MassPrice login form was never inspected** during
development — the account was already signed in when the site was
explored, and logging it out to look at the form would have disrupted
the user's real, live MassPrice session. `automation._login()` uses
resilient role/label/placeholder-based Playwright locators rather than
brittle CSS, but **expect to need a one-time adjustment against the real
form on first deploy** if the generic locators don't match.
## Local development
```bash
pip install -r requirements.txt
playwright install chromium # only needed if testing automation.py directly
python3 app.py
```
Runs on port 5000 (`PORT` env to change). Without `MASSPRICE_USERNAME`/
`MASSPRICE_PASSWORD` set and without Chromium installed, the Dashboard
still boots — `_bootstrap()` in `app.py` best-effort-catches the initial
rule sync, so local UI/schedule-editing work doesn't require Docker or
real MassPrice credentials at all. `DATA_DIR` env var overrides where
JSON state is read/written (defaults to `/data`, which won't exist
locally — point it at a scratch directory).
**Every commit should be matched with a version bump** —
`APP_VERSION`/`APP_VERSION_DATE` near the top of `app.py` — the same
"does the footer prove this deploy landed" signal used on Arr Summary.
## Deployment
See the `deploy-massprice` skill for the Forgejo push-to-deploy
pipeline and manual container restart steps.
**Never put real credentials in the repo.** `.env` and `data/` are
gitignored and excluded from the deploy rsync precisely so
`MASSPRICE_USERNAME`/`MASSPRICE_PASSWORD`/`LOGIN_PASSWORD` and the saved
MassPrice session (`data/auth_state.json`) have somewhere to live
outside git.

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM mcr.microsoft.com/playwright/python:v1.47.0-jammy
LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/"
LABEL maintainer="MassPrice Scheduler"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"]

157
app.py Normal file
View file

@ -0,0 +1,157 @@
import hmac
import os
from functools import wraps
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
import automation
import scheduler
import storage
APP_VERSION = "0.1.0"
APP_VERSION_DATE = "2026-08-31"
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-change-me")
LOGIN_PASSWORD = os.environ.get("LOGIN_PASSWORD", "changeme")
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
DAY_LABELS = {"mon": "Mon", "tue": "Tue", "wed": "Wed", "thu": "Thu", "fri": "Fri", "sat": "Sat", "sun": "Sun"}
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("authenticated"):
return redirect(url_for("login", next=request.path))
return view(*args, **kwargs)
return wrapped
@app.context_processor
def inject_version():
return {"app_version": APP_VERSION, "app_version_date": APP_VERSION_DATE}
@app.route("/login", methods=["GET", "POST"])
def login():
error = None
if request.method == "POST":
password = request.form.get("password", "")
if hmac.compare_digest(password, LOGIN_PASSWORD):
session["authenticated"] = True
return redirect(request.args.get("next") or url_for("dashboard"))
error = "Incorrect password"
return render_template("login.html", error=error)
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
def _merged_rules():
"""Combine the cached rule list with saved schedules and last-run status."""
cache = storage.load("rules_cache.json", {"updated_at": None, "rules": []})
schedules = storage.load("schedules.json", {})
history = storage.load("run_history.json", [])
last_run_by_rule = {}
for entry in history:
rid = entry.get("rule_id")
if rid and rid not in last_run_by_rule:
last_run_by_rule[rid] = entry
rules = []
for r in cache.get("rules", []):
rid = r["id"]
sched = schedules.get(rid, {})
rules.append(
{
"id": rid,
"name": r["name"],
"enabled": sched.get("enabled", True),
"schedules": sched.get("schedules", []),
"last_run": last_run_by_rule.get(rid),
}
)
return rules, cache.get("updated_at")
@app.route("/")
@login_required
def dashboard():
rules, rules_updated_at = _merged_rules()
return render_template(
"dashboard.html",
rules=rules,
rules_updated_at=rules_updated_at,
days=DAYS,
day_labels=DAY_LABELS,
)
@app.route("/activity")
@login_required
def activity():
history = storage.load("run_history.json", [])
return render_template("activity.html", history=history)
@app.route("/api/rules/refresh", methods=["POST"])
@login_required
def api_refresh_rules():
try:
rules = automation.list_rules()
except Exception as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({"rules": rules})
@app.route("/api/rules/<rule_id>/schedule", methods=["POST"])
@login_required
def api_save_schedule(rule_id):
body = request.get_json(force=True, silent=True) or {}
schedules = storage.load("schedules.json", {})
cache = storage.load("rules_cache.json", {"rules": []})
name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None)
schedules[rule_id] = {
"name": name,
"enabled": bool(body.get("enabled", True)),
"schedules": body.get("schedules", []),
}
storage.save("schedules.json", schedules)
scheduler.rebuild_jobs()
return jsonify({"ok": True})
@app.route("/api/rules/<rule_id>/run", methods=["POST"])
@login_required
def api_run_rule(rule_id):
cache = storage.load("rules_cache.json", {"rules": []})
name = next((r["name"] for r in cache.get("rules", []) if r["id"] == rule_id), None)
scheduler.enqueue_run(rule_id, name)
return jsonify({"ok": True})
def _bootstrap():
scheduler.start(tz=os.environ.get("TZ"))
# Best-effort initial rule sync so the dashboard isn't blank before
# the user ever clicks "Refresh Rules". Silently skipped if
# Playwright/credentials aren't available yet (e.g. local dev).
if storage.load("rules_cache.json", None) is None:
try:
automation.list_rules()
except Exception:
pass
_bootstrap()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False)

202
automation.py Normal file
View file

@ -0,0 +1,202 @@
"""Playwright automation against massprice.tcgplayer.com.
Drives the real page rather than replaying a raw HTTP request: "Run Rule"
is bound to a JS/AJAX click handler with no plain href, and the request
it fires is almost certainly anti-forgery-token protected.
Session handling: a Playwright `storage_state` (cookies + localStorage)
is persisted to AUTH_STATE_PATH and reused across runs, so the tool logs
in once and slides the session forward on every successful run rather
than hitting the login form every time. A fresh login (via
MASSPRICE_USERNAME/MASSPRICE_PASSWORD) only happens when the saved state
is missing or has expired.
NOTE — the real logged-out login form was not inspected during
development (the account was already signed in during discovery, and
forcing a logout to inspect it would have disrupted the user's real
MassPrice session). The locators in _login() are written to be resilient
(role/label/placeholder based, not brittle CSS) but may need a one-time
adjustment against the live form on first deploy. See CLAUDE.md.
"""
import logging
import os
from datetime import datetime, timezone
from playwright.sync_api import sync_playwright
import storage
logger = logging.getLogger("massprice.automation")
BASE_URL = "https://massprice.tcgplayer.com"
AUTH_STATE_PATH = os.path.join(storage.DATA_DIR, "auth_state.json")
MASSPRICE_USERNAME = os.environ.get("MASSPRICE_USERNAME")
MASSPRICE_PASSWORD = os.environ.get("MASSPRICE_PASSWORD")
# Standard, necessary mitigations for headless Chromium inside Docker.
LAUNCH_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"]
class AutomationError(Exception):
pass
def _new_context(browser):
if os.path.exists(AUTH_STATE_PATH):
return browser.new_context(storage_state=AUTH_STATE_PATH)
return browser.new_context()
def _save_state(context):
os.makedirs(storage.DATA_DIR, exist_ok=True)
context.storage_state(path=AUTH_STATE_PATH)
def _is_logged_out(page):
if "login" in page.url.lower() or "signin" in page.url.lower():
return True
return page.locator('input[type="password"]').count() > 0
def _login(page):
if not MASSPRICE_USERNAME or not MASSPRICE_PASSWORD:
raise AutomationError(
"MASSPRICE_USERNAME/MASSPRICE_PASSWORD not set — cannot log in"
)
page.goto(f"{BASE_URL}/Dashboard")
if not _is_logged_out(page):
return
email_field = (
page.get_by_label("Email", exact=False)
.or_(page.get_by_placeholder("Email", exact=False))
.or_(page.locator('input[type="email"]'))
.or_(page.locator('input[name="username" i]'))
)
password_field = page.locator('input[type="password"]')
email_field.first.fill(MASSPRICE_USERNAME)
password_field.first.fill(MASSPRICE_PASSWORD)
submit = (
page.get_by_role("button", name="Log In", exact=False)
.or_(page.get_by_role("button", name="Sign In", exact=False))
.or_(page.locator('button[type="submit"]'))
)
submit.first.click()
page.wait_for_load_state("networkidle")
if _is_logged_out(page):
raise AutomationError(
"Login failed — still on a login page after submit "
"(bad credentials, or a CAPTCHA/2FA challenge that automated "
"login cannot get past — see CLAUDE.md)"
)
def _ensure_authenticated(page):
page.goto(f"{BASE_URL}/Rule/List")
if _is_logged_out(page):
_login(page)
page.goto(f"{BASE_URL}/Rule/List")
if _is_logged_out(page):
raise AutomationError("Still logged out after a login attempt")
def list_rules():
"""Scrape /Rule/List for current rule id/name pairs.
Returns a list of {"id": ..., "name": ...} dicts and caches it to
rules_cache.json.
"""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
try:
context = _new_context(browser)
page = context.new_page()
_ensure_authenticated(page)
_save_state(context)
rows = page.locator("li.rule.widget-rule")
rules = []
for i in range(rows.count()):
row = rows.nth(i)
run_link = row.locator("a.run-rule").first
rule_id = run_link.get_attribute("data-rule-id")
if not rule_id:
continue
name_text = None
try:
# The rule name renders as the first line of text in
# the row, ahead of the "My Selling Prices" /
# "Schedule Active…" detail lines.
full_text = row.inner_text().strip()
name_text = full_text.splitlines()[0].strip()
except Exception:
pass
rules.append({"id": rule_id, "name": name_text or f"Rule {rule_id}"})
storage.save(
"rules_cache.json",
{"updated_at": datetime.now(timezone.utc).isoformat(), "rules": rules},
)
return rules
finally:
browser.close()
def run_rule(rule_id, rule_name=None):
"""Click "Run Rule" for the given rule id. Logs the outcome to
run_history.json and returns (status, message)."""
status = "failure"
message = ""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True, args=LAUNCH_ARGS)
try:
context = _new_context(browser)
page = context.new_page()
_ensure_authenticated(page)
_save_state(context)
run_selector = f'a.run-rule[data-rule-id="{rule_id}"]'
row = page.locator("li.rule.widget-rule").filter(
has=page.locator(run_selector)
)
if row.count() == 0:
raise AutomationError(
f"Rule id {rule_id} not found on /Rule/List "
"(renamed or deleted on the MassPrice side?)"
)
row.first.locator(".dropdown-ellipsis").click()
run_link = row.first.locator(run_selector)
run_link.wait_for(state="visible", timeout=5000)
run_link.click()
page.wait_for_timeout(1500) # let the click's AJAX call fire
status = "success"
message = "Run Rule clicked"
except Exception as exc:
message = str(exc)
logger.exception("run_rule failed for rule %s", rule_id)
finally:
browser.close()
_log_history(rule_id, rule_name, status, message)
return status, message
def _log_history(rule_id, rule_name, status, message):
history = storage.load("run_history.json", [])
history.insert(
0,
{
"rule_id": rule_id,
"rule_name": rule_name,
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": status,
"message": message,
},
)
storage.save("run_history.json", history[:200])

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
services:
massprice:
build: .
container_name: massprice
# Headless Chromium's default /dev/shm (64MB) is too small in Docker
# and causes renderer crashes — this is the standard mitigation.
shm_size: "1gb"
ports:
- "${HOST_PORT:-5056}:5000"
volumes:
# Schedules, run history, the rule-name cache, and the saved
# MassPrice login session must outlive `up -d --build`, which
# recreates the container and discards its filesystem.
- ./data:/data
environment:
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
- SECRET_KEY=${SECRET_KEY}
- MASSPRICE_USERNAME=${MASSPRICE_USERNAME}
- MASSPRICE_PASSWORD=${MASSPRICE_PASSWORD}
- TZ=${TZ:-America/New_York}
restart: unless-stopped

13
gunicorn.conf.py Normal file
View file

@ -0,0 +1,13 @@
# Gunicorn configuration
import os
# Exactly one worker: APScheduler and the Playwright run-queue live in
# process memory, not a shared store — a second worker would duplicate
# every scheduled job and run each rule twice. Fine for a low-traffic
# single-user admin tool.
workers = 1
threads = 4
worker_class = "gthread"
bind = "0.0.0.0:" + os.environ.get("PORT", "5000")
timeout = 120 # a rule run launches Chromium, logs in, and clicks through
keepalive = 5

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
flask==3.1.0
gunicorn==23.0.0
playwright==1.47.0
apscheduler==3.10.4

93
scheduler.py Normal file
View file

@ -0,0 +1,93 @@
"""In-process cron scheduling for rule runs.
A single background worker thread owns every Playwright run — both
APScheduler-triggered runs and manual "Run Now" clicks are pushed onto
one queue.Queue so exactly one browser session is ever active at a time.
This must run in exactly one process; see gunicorn.conf.py's workers=1.
"""
import logging
import os
import queue
import threading
import pytz
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import automation
import storage
logger = logging.getLogger("massprice.scheduler")
_work_queue: "queue.Queue" = queue.Queue()
# Timezone must be set at construction time, not via a later .configure()
# call — APScheduler resolves a timezone immediately in __init__ via
# tzlocal's system lookup, which fails inside this container image for
# an arbitrary $TZ (no zoneinfo/tzdata wiring), so the explicit TZ env
# var has to win before that lookup ever runs.
_scheduler = BackgroundScheduler(timezone=pytz.timezone(os.environ.get("TZ", "UTC")))
_started = False
VALID_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
def _worker_loop():
while True:
rule_id, rule_name = _work_queue.get()
try:
automation.run_rule(rule_id, rule_name)
except Exception:
logger.exception("Unhandled error running rule %s", rule_id)
finally:
_work_queue.task_done()
def enqueue_run(rule_id, rule_name=None):
_work_queue.put((rule_id, rule_name))
def _job_id(rule_id, index):
return f"rule-{rule_id}-{index}"
def rebuild_jobs():
"""Clear and re-add every APScheduler job from schedules.json."""
for job in _scheduler.get_jobs():
job.remove()
schedules = storage.load("schedules.json", {})
for rule_id, rule in schedules.items():
if not rule.get("enabled", True):
continue
for idx, entry in enumerate(rule.get("schedules", [])):
days = [d for d in entry.get("days", []) if d in VALID_DAYS]
time_str = entry.get("time")
if not days or not time_str:
continue
hour, minute = time_str.split(":")
trigger = CronTrigger(
day_of_week=",".join(days),
hour=int(hour),
minute=int(minute),
)
_scheduler.add_job(
enqueue_run,
trigger=trigger,
args=[rule_id, rule.get("name")],
id=_job_id(rule_id, idx),
replace_existing=True,
misfire_grace_time=300,
)
logger.info("Rebuilt %d scheduled job(s)", len(_scheduler.get_jobs()))
def start(tz=None):
# tz is accepted for backwards-compat call sites but is a no-op now
# that timezone is fixed at construction time above.
global _started
if _started:
return
rebuild_jobs()
_scheduler.start()
threading.Thread(target=_worker_loop, daemon=True).start()
_started = True

159
static/css/style.css Normal file
View file

@ -0,0 +1,159 @@
:root {
--bg: #12161c;
--panel: #1b212b;
--panel-alt: #232a35;
--border: #2b3340;
--text: #e6e9ef;
--text-dim: #9aa4b2;
--accent: #3b82f6;
--accent-dim: #1e3a5f;
--success: #34d399;
--failure: #f87171;
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
}
h1 { font-size: 1.4rem; margin: 0; }
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: 1.1rem; }
.brand span { color: var(--text-dim); font-weight: 400; }
nav a {
color: var(--text-dim);
text-decoration: none;
margin-left: 20px;
font-size: 0.9rem;
}
nav a:hover, nav a.active { color: var(--text); }
main { max-width: 1100px; margin: 0 auto; padding: 24px; }
footer {
text-align: center;
color: var(--text-dim);
font-size: 0.75rem;
padding: 24px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-header-meta { display: flex; align-items: center; gap: 12px; }
.muted { color: var(--text-dim); }
.small { font-size: 0.78rem; }
.btn {
background: var(--panel-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 14px;
cursor: pointer;
font-size: 0.85rem;
}
.btn:hover { border-color: var(--accent); }
.btn:disabled { opacity: 0.6; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn-small { padding: 6px 10px; font-size: 0.8rem; }
.rule-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.rule-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.rule-card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.rule-card-header h2 { font-size: 1.05rem; margin: 0; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.slider {
position: absolute; inset: 0; background: var(--panel-alt);
border: 1px solid var(--border); border-radius: 22px; cursor: pointer; transition: .15s;
}
.slider::before {
content: ""; position: absolute; height: 16px; width: 16px; left: 2px; top: 2px;
background: var(--text-dim); border-radius: 50%; transition: .15s;
}
.switch input:checked + .slider { background: var(--accent-dim); border-color: var(--accent); }
.switch input:checked + .slider::before { transform: translateX(18px); background: var(--accent); }
.rule-last-run { font-size: 0.82rem; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status-dot.status-success { background: var(--success); }
.status-dot.status-failure { background: var(--failure); }
.status-row-failure td { color: #f2a3a3; }
.schedule-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.schedule-chip {
display: flex; align-items: center; gap: 10px;
background: var(--panel-alt); border: 1px solid var(--border); border-radius: 6px;
padding: 6px 10px; font-size: 0.82rem;
}
.chip-time { font-variant-numeric: tabular-nums; font-weight: 600; }
.chip-days { color: var(--text-dim); flex: 1; text-transform: uppercase; letter-spacing: 0.03em; font-size: 0.72rem; }
.chip-remove { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 1.1rem; line-height: 1; }
.chip-remove:hover { color: var(--failure); }
.schedule-add { display: flex; flex-direction: column; gap: 8px; border-top: 1px dashed var(--border); padding-top: 12px; }
.schedule-add-row { display: flex; gap: 8px; }
.new-time {
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text);
border-radius: 6px; padding: 6px 8px; font-size: 0.85rem; flex: 1;
}
.day-toggles { display: flex; gap: 4px; flex-wrap: wrap; }
.day-toggle {
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text-dim);
border-radius: 6px; padding: 4px 8px; font-size: 0.72rem; cursor: pointer; text-transform: uppercase;
}
.day-toggle.active { background: var(--accent-dim); border-color: var(--accent); color: var(--text); }
.rule-actions { display: flex; justify-content: flex-end; }
.login-card {
max-width: 320px; margin: 80px auto; background: var(--panel);
border: 1px solid var(--border); border-radius: var(--radius); padding: 28px;
display: flex; flex-direction: column; gap: 12px;
}
.login-card input {
background: var(--panel-alt); border: 1px solid var(--border); color: var(--text);
border-radius: 6px; padding: 10px; font-size: 1rem;
}
.error { color: var(--failure); font-size: 0.85rem; }
.empty-state { color: var(--text-dim); }
.activity-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.activity-table th, .activity-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.activity-table th { color: var(--text-dim); font-weight: 500; font-size: 0.75rem; text-transform: uppercase; }

107
static/js/app.js Normal file
View file

@ -0,0 +1,107 @@
document.addEventListener("DOMContentLoaded", () => {
const refreshBtn = document.getElementById("refresh-rules");
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
refreshBtn.disabled = true;
const original = refreshBtn.textContent;
refreshBtn.textContent = "Refreshing…";
try {
const res = await fetch("/api/rules/refresh", { method: "POST" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert("Refresh failed: " + (data.error || res.status));
return;
}
window.location.reload();
} finally {
refreshBtn.disabled = false;
refreshBtn.textContent = original;
}
});
}
document.querySelectorAll(".rule-card").forEach(initRuleCard);
});
function initRuleCard(card) {
const ruleId = card.dataset.ruleId;
card.querySelector(".rule-enabled").addEventListener("change", (e) => {
saveSchedule(card, { enabledOverride: e.target.checked });
});
card.querySelectorAll(".day-toggle").forEach((btn) => {
btn.addEventListener("click", () => btn.classList.toggle("active"));
});
card.querySelector(".add-schedule").addEventListener("click", () => {
const time = card.querySelector(".new-time").value;
const days = [...card.querySelectorAll(".day-toggle.active")].map((b) => b.dataset.day);
if (!time || days.length === 0) {
alert("Pick a time and at least one day.");
return;
}
addChip(card, time, days);
card.querySelectorAll(".day-toggle.active").forEach((b) => b.classList.remove("active"));
saveSchedule(card);
});
card.querySelectorAll(".chip-remove").forEach((btn) => bindRemove(btn));
card.querySelector(".run-now").addEventListener("click", async (e) => {
const btn = e.currentTarget;
const original = btn.textContent;
btn.disabled = true;
btn.textContent = "Queuing…";
try {
const res = await fetch(`/api/rules/${ruleId}/run`, { method: "POST" });
btn.textContent = res.ok ? "Queued" : "Failed";
} catch (err) {
btn.textContent = "Failed";
} finally {
setTimeout(() => {
btn.textContent = original;
btn.disabled = false;
}, 2000);
}
});
}
function bindRemove(btn) {
btn.addEventListener("click", () => {
const card = btn.closest(".rule-card");
btn.closest(".schedule-chip").remove();
saveSchedule(card);
});
}
function addChip(card, time, days) {
const list = card.querySelector(".schedule-list");
const li = document.createElement("li");
li.className = "schedule-chip";
li.innerHTML =
`<span class="chip-time">${time}</span>` +
`<span class="chip-days">${days.join(" ")}</span>` +
`<button type="button" class="chip-remove" title="Remove">&times;</button>`;
list.appendChild(li);
bindRemove(li.querySelector(".chip-remove"));
}
async function saveSchedule(card, opts = {}) {
const ruleId = card.dataset.ruleId;
const enabled =
opts.enabledOverride !== undefined ? opts.enabledOverride : card.querySelector(".rule-enabled").checked;
const schedules = [...card.querySelectorAll(".schedule-chip")].map((chip) => ({
time: chip.querySelector(".chip-time").textContent.trim(),
days: chip
.querySelector(".chip-days")
.textContent.trim()
.split(/\s+/)
.filter(Boolean),
}));
await fetch(`/api/rules/${ruleId}/schedule`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, schedules }),
});
}

35
storage.py Normal file
View file

@ -0,0 +1,35 @@
"""Small JSON read/write helpers for the state files under DATA_DIR.
Every file is written via a temp-file + os.replace so a crash mid-write
never leaves a half-written JSON file behind for the next read.
"""
import json
import os
import threading
DATA_DIR = os.environ.get("DATA_DIR", "/data")
_lock = threading.Lock()
def _path(name):
return os.path.join(DATA_DIR, name)
def load(name, default):
path = _path(name)
with _lock:
if not os.path.exists(path):
return default
with open(path) as f:
return json.load(f)
def save(name, data):
path = _path(name)
with _lock:
os.makedirs(DATA_DIR, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)

26
templates/_shell.html Normal file
View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MassPrice Scheduler{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="topbar">
<div class="brand">MassPrice <span>Scheduler</span></div>
{% if session.get('authenticated') %}
<nav>
<a href="{{ url_for('dashboard') }}" class="{{ 'active' if request.endpoint == 'dashboard' else '' }}">Dashboard</a>
<a href="{{ url_for('activity') }}" class="{{ 'active' if request.endpoint == 'activity' else '' }}">Activity</a>
<a href="{{ url_for('logout') }}">Log Out</a>
</nav>
{% endif %}
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>v{{ app_version }} &middot; {{ app_version_date }}</footer>
{% block scripts %}{% endblock %}
</body>
</html>

24
templates/activity.html Normal file
View file

@ -0,0 +1,24 @@
{% extends "_shell.html" %}
{% block title %}Activity &middot; MassPrice Scheduler{% endblock %}
{% block content %}
<h1>Activity</h1>
{% if not history %}
<p class="empty-state">No runs recorded yet.</p>
{% else %}
<table class="activity-table">
<thead>
<tr><th>Time</th><th>Rule</th><th>Status</th><th>Message</th></tr>
</thead>
<tbody>
{% for entry in history %}
<tr class="status-row-{{ entry.status }}">
<td>{{ entry.timestamp }}</td>
<td>{{ entry.rule_name or entry.rule_id }}</td>
<td><span class="status-dot status-{{ entry.status }}"></span>{{ entry.status }}</td>
<td class="muted">{{ entry.message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}

68
templates/dashboard.html Normal file
View file

@ -0,0 +1,68 @@
{% extends "_shell.html" %}
{% block title %}Dashboard &middot; MassPrice Scheduler{% endblock %}
{% block content %}
<div class="page-header">
<h1>Rules</h1>
<div class="page-header-meta">
{% if rules_updated_at %}<span class="muted">Rules last synced {{ rules_updated_at }}</span>{% endif %}
<button id="refresh-rules" class="btn btn-secondary">Refresh Rules</button>
</div>
</div>
{% if not rules %}
<p class="empty-state">No rules loaded yet. Click "Refresh Rules" to pull the current rule list from MassPrice.</p>
{% endif %}
<div class="rule-grid">
{% for rule in rules %}
<div class="rule-card" data-rule-id="{{ rule.id }}">
<div class="rule-card-header">
<h2>{{ rule.name }}</h2>
<label class="switch" title="Enabled">
<input type="checkbox" class="rule-enabled" {{ 'checked' if rule.enabled else '' }}>
<span class="slider"></span>
</label>
</div>
<div class="rule-last-run">
{% if rule.last_run %}
<span class="status-dot status-{{ rule.last_run.status }}"></span>
Last run {{ rule.last_run.timestamp }} &mdash; {{ rule.last_run.status }}
{% if rule.last_run.status == 'failure' %}<div class="muted small">{{ rule.last_run.message }}</div>{% endif %}
{% else %}
<span class="muted">No runs yet</span>
{% endif %}
</div>
<ul class="schedule-list">
{% for entry in rule.schedules %}
<li class="schedule-chip">
<span class="chip-time">{{ entry.time }}</span>
<span class="chip-days">{{ entry.days | join(' ') }}</span>
<button type="button" class="chip-remove" title="Remove">&times;</button>
</li>
{% endfor %}
</ul>
<div class="schedule-add">
<div class="schedule-add-row">
<input type="time" class="new-time" value="06:00">
<button type="button" class="btn btn-small add-schedule">Add Time</button>
</div>
<div class="day-toggles">
{% for d in days %}
<button type="button" class="day-toggle" data-day="{{ d }}">{{ day_labels[d] }}</button>
{% endfor %}
</div>
</div>
<div class="rule-actions">
<button type="button" class="btn btn-primary run-now">Run Now</button>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% endblock %}

13
templates/login.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "_shell.html" %}
{% block title %}Log In &middot; MassPrice Scheduler{% endblock %}
{% block content %}
<div class="login-card">
<h1>MassPrice Scheduler</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post">
<label for="password">Password</label>
<input type="password" id="password" name="password" autofocus required>
<button type="submit" class="btn btn-primary">Log In</button>
</form>
</div>
{% endblock %}