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.
161 lines
5.3 KiB
Go
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)
|
|
}
|