Files
swehockey/main.go
T
Mats Tornberg 6b701819ca Deterministic swehockey.se schedule crawler
One HTTP GET per season, no browser: the page is server-rendered, so every row
arrives in one response and the extraction becomes a pure function of the HTML.

- layouts[] declares one profile per version of the site's table (modern and
  legacy), each identified by its header row, so a new version of the page is a
  new profile plus a fixture instead of a guess.
- The table is located by header signature, not by a CSS path.
- Every cell is shape-checked, and facts the site renders twice (desktop and
  mobile date, time in the tooltip) must agree.
- Cross-field invariants: periods sum to the score, ids are unique, played games
  always have an event link and unplayed ones never do.
- Output is sorted by date, time and id, so it does not inherit the site's row
  order, and it carries no fetch timestamp: same HTML, same bytes.
- Drift is a typed error and exit code 3, with the header it actually found.
- Tests run on committed HTML fixtures and golden JSON; TestLive is opt-in.

Verified over the full SHL archive: 27/27 seasons, 2119 games, 1779 played.
2026-09-27 08:47:25 +00:00

161 lines
5.3 KiB
Go

// Command swehockey-schedule extracts the "Schedule and Results" table from
// stats.swehockey.se into JSON or CSV.
//
// The extraction is deterministic by construction:
//
// - The page is server-rendered. One plain HTTP GET returns every row of the
// table, so there is no browser, no JavaScript, no waiting and no
// timing-dependent retry. Same HTML in, same bytes out.
// - The table is found by its header signature, never by a CSS path, so
// ads, navigation and layout changes cannot move it out from under us.
// - Every field is read from a documented cell index *and* shape-checked.
// Markup drift produces an error naming the game and the cell, never a
// silently truncated or mis-mapped table.
// - The site renders each fact twice (a desktop cell and a mobile cell).
// The parser requires both renderings to agree, so a responsive-layout
// change cannot corrupt half the rows unnoticed.
// - Cross-field invariants are enforced: period goals must sum to the final
// score, game ids must be unique and ascending, dates must not go back.
// - Output is a pure function of the page: no fetch timestamps, no map
// iteration, fixed field order. Diffs between runs are meaningful.
//
// Commands:
//
// swehockey-schedule schedule -season 20961 # one season -> JSON
// swehockey-schedule schedule -season 20961 -format csv
// swehockey-schedule crawl -league 20961 # every season of a league
// swehockey-schedule leagues # league ids on the front page
// swehockey-schedule schedule -fixture page.html # offline, for tests/debugging
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"time"
)
const defaultSeason = 20961
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
var err error
switch cmd := os.Args[1]; cmd {
case "schedule":
err = cmdSchedule(ctx, os.Args[2:])
case "crawl":
err = cmdCrawl(ctx, os.Args[2:])
case "leagues":
err = cmdLeagues(ctx, os.Args[2:])
case "-h", "--help", "help":
usage()
return
default:
fmt.Fprintf(os.Stderr, "swehockey-schedule: unknown command %q\n\n", cmd)
usage()
os.Exit(2)
}
var de *DriftError
switch {
case err == nil:
case errors.Is(err, context.DeadlineExceeded):
fmt.Fprintf(os.Stderr, "swehockey-schedule: %v\n", err)
os.Exit(4)
case errors.As(err, &de):
// Drift is not a crash: it means the site changed shape. Exit 3 so a
// cron job or CI gate can alert without a stack trace.
fmt.Fprintln(os.Stderr, de.Error())
os.Exit(3)
default:
fmt.Fprintf(os.Stderr, "swehockey-schedule: %v\n", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `swehockey-schedule - deterministic stats.swehockey.se schedule crawler
usage:
swehockey-schedule schedule [flags] extract one season's schedule table
swehockey-schedule crawl [flags] extract every season of one league (NDJSON)
swehockey-schedule leagues [flags] list league ids and names
common flags:
-base string site root (default https://stats.swehockey.se)
-format string json|csv|ndjson (default json; crawl defaults to ndjson)
-out string output file (default stdout)
-fixture string parse a saved page instead of fetching (offline)
-state string state file; skip the run when the page is unchanged
-check string golden file; exit 3 if the output no longer matches
-timeout duration per request (default 30s)
-quiet bool suppress progress notes on stderr
exit codes: 0 ok, 1 error, 2 usage, 3 markup drift or golden mismatch, 4 timeout
`)
}
type commonFlags struct {
base string
format string
out string
fixture string
state string
check string
timeout time.Duration
quiet bool
}
func (c *commonFlags) register(fs *flag.FlagSet, defFormat string) {
fs.StringVar(&c.base, "base", "https://stats.swehockey.se", "site root")
fs.StringVar(&c.format, "format", defFormat, "output format: json, csv or ndjson")
fs.StringVar(&c.out, "out", "", "write to this file instead of stdout")
fs.StringVar(&c.fixture, "fixture", "", "parse a saved HTML page instead of fetching")
fs.StringVar(&c.state, "state", "", "state file for skip-when-unchanged runs")
fs.StringVar(&c.check, "check", "", "golden file; exit 3 when the output differs")
fs.DurationVar(&c.timeout, "timeout", 30*time.Second, "per-request timeout")
fs.BoolVar(&c.quiet, "quiet", false, "suppress progress notes on stderr")
}
func (c *commonFlags) client() *Client {
return &Client{BaseURL: c.base, Timeout: c.timeout}
}
// read returns the page bytes for a path, honouring -fixture and -state.
func (c *commonFlags) read(ctx context.Context, cl *Client, path, what string) ([]byte, error) {
if c.fixture != "" {
b, err := os.ReadFile(c.fixture)
if err != nil {
return nil, err
}
return b, nil
}
if c.state != "" {
st, err := loadState(c.state)
if err != nil {
return nil, err
}
b, err := cl.get(ctx, path)
if err != nil {
return nil, err
}
if st.SHA256 == sha256Hex(b) && st.Path == path {
// Byte-identical page: the derived output would be identical too.
if !c.quiet {
fmt.Fprintf(os.Stderr, "swehockey-schedule: %s unchanged since %s, nothing to do\n", what, st.SavedAt)
}
return nil, errUnchanged
}
return b, nil
}
return cl.get(ctx, path)
}