// 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) }