LiveData Tooling · Operations

Next.js admin · Playwright scraper · Bash runner · Redis history · EV modeling

State Line

Kansas vs Missouri scratch-off EV, with the cron job treated as part of the product.

State Line answers a practical question for someone living on the Kansas/Missouri border: if you are going to buy a scratch-off anyway, which side of the state line is least bad today? It pulls every active game from both lotteries, parses prize tiers, estimates remaining-ticket value, tracks day-over-day movement, catches retired games that still look mathematically attractive, and turns the answer into a password-gated dashboard. The build plumbing matters as much as the UI because a stale or malformed scrape would quietly make the recommendation worse than useless.

Origin

How it started

The first insight was that lottery sites publish enough remaining-prize data to compute a more honest ranking than the odds printed on a ticket. The second insight was less glamorous and more important: those public pages update on a batch cadence. Sampling them at the wrong time measures the lottery's publish job, not sales. So the product became two things at once — a decision board and the instrumentation around the data feed that keeps it honest.

Features

What it does

  • True EV over printed odds

    Each game is scored from the remaining prize tiers, not from the headline overall odds that count winning your ticket price back as a win. The board shows payback per dollar, profit probability, big-prize probability, and after-tax EV where jackpots distort the headline.

  • Kansas and Missouri in one model

    The dashboard compares both states at each price point, names the best buy, and exposes the state-line choice directly. That matters in Kansas City because a buyer can cross the border more easily than a national tracker can explain the difference.

  • Nightly data spine

    The OCI cron box runs the scraper, writes a dated JSON snapshot, posts to the Vercel ingest API, and leaves logs plus the last fourteen snapshots on disk. The site stores the latest board and daily history under namespaced Redis keys.

  • Bad-scrape refusal

    The shell runner counts parsed games before posting. If fewer than 40 parse successfully, it refuses to overwrite the board. A page-shape change should create a log to investigate, not publish an empty or misleading ranking.

  • Refresh-cadence probe

    A separate hourly probe hashes prize-count digits for representative games to discover when each lottery actually updates. The production cron then runs after the measured batch windows so trend and velocity features are not out of phase.

  • Dead-game traps

    Date-expired games and Missouri's 'No Longer Distributed' badge are treated as authoritative stop signals. Without that, old games with great-looking unclaimed prizes rank first even though nobody can buy them.

Under the hood

Engineering

  • run-nightly.sh is a product guardrail

    The shell runner sources secrets from a locked env file, runs node scrape.mjs, writes logs and a dated snapshot, counts total and successfully parsed games, refuses to POST below the parse floor, sends the JSON to /api/stats/ingest with a bearer token, records the HTTP response, then prunes logs and snapshots to the last fourteen. It is small, but it encodes the product's failure policy: stale beats wrong.

  • Scraping public pages politely

    Kansas and Missouri publish the data on public, unauthenticated pages. The scraper uses Playwright because Missouri blocks plain fetch and Kansas renders prize tables in-page. It waits for the prize data itself rather than networkidle, spaces requests by 700ms, parses free-ticket tiers correctly, reads collapsed Kansas 'How to Play' text with textContent, and captures ticket art from the DOM when available.

  • The batch-publish trap

    The first data looked too stable: Missouri was byte-identical across prime retail hours. That was not zero sales; it was the state publishing in a batch. refresh-probe.mjs was added to hash only the digits in the prize block every hour and find the real update windows. Moving the cron to 16:00 UTC leaves margin after the later Kansas window, so daily deltas represent published daily movement rather than sampling noise.

  • Estimated denominators, labeled honestly

    Missouri publishes total and unclaimed prize counts. Kansas publishes remaining counts but not original tier totals, so tickets remaining are estimated from total remaining winning prizes times the printed overall odds. The UI labels that asymmetry instead of hiding it, because an EV model that pretends its denominator is exact becomes false precision.

  • API and storage boundary

    The scraper runs outside the Next.js app so build output never carries browser automation code. The app exposes a token-authenticated ingest route, scores games on the server, stores history in Redis under lotto:* keys, and gates the dashboard with the existing admin-cookie pattern. The public portfolio can describe the system without exposing the board.

  • What it deliberately does not do

    No store-picking heuristics, roll-position advice, or 'due number' folklore. Tickets are printed and randomized before distribution; per-store inventory is not public. The honest product is current expected value, claim movement, and the traps most public trackers miss — not pretending randomness has a schedule.

Stack

  • Next.js 16 (App Router)
  • TypeScript
  • Playwright
  • Bash
  • Redis
  • Vercel Functions
  • Cron
  • Inline SVG charts
  • Expected-value modeling