Files
Mats Tornberg 47e1811d9e Add the Python variant, agreeing with Go byte for byte
A second, independent implementation of the same contract, in the same repo:

- swehockey/dom.py builds the tree on html.parser, so the variant has no
  runtime dependencies; the only HTML5 behaviour it depends on - stray <tr>
  landing in an implicit <tbody> - is handled explicitly by table_rows().
- parse.py mirrors the Go layout profiles, dual-render checks, carry-forward and
  invariants; a DriftError is raised in the same places with the same advice.
- cli.py offers the same three commands, flags and exit codes as the Go main.
- tests/ assert against the Go golden files, so the suite is also a
  cross-implementation check. 23 offline tests plus an opt-in live test.

Verified against the live site: JSON and CSV are byte-identical to the Go
output (364 games, 102536 bytes / 365 lines), and the full archive crawl gives
the same 27 seasons, 2119 games and 1779 played.

Also split the DriftError hint out in Go, so a golden mismatch no longer tells
the reader to extend layouts[].
2026-09-27 08:53:23 +00:00

144 lines
3.5 KiB
Go

package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
// render turns a schedule into bytes. Both encoders are pure functions of the
// data: no timestamps, no maps, fixed column order, LF line endings. Running
// the crawler twice on the same page produces identical bytes, which is what
// makes -check and diff-based review meaningful.
func render(format string, s *Schedule) ([]byte, error) {
switch format {
case "json":
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // keep team names readable
enc.SetIndent("", " ")
if err := enc.Encode(s); err != nil {
return nil, err
}
return buf.Bytes(), nil
case "csv":
return renderCSV(s)
default:
return nil, fmt.Errorf("unknown format %q: want json, csv or ndjson", format)
}
}
func renderNDJSON(s *Schedule) ([]byte, error) {
var buf bytes.Buffer
if err := jsonEncoder(&buf).Encode(s); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// jsonEncoder keeps "ä" readable instead of \u00e4, which matters for diffs
// of team names.
func jsonEncoder(buf *bytes.Buffer) *json.Encoder {
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
return enc
}
var csvHeader = []string{
"season_id", "game_id", "event_id", "date", "time", "home", "away",
"home_goals", "away_goals", "periods", "spectators", "venue",
}
func renderCSV(s *Schedule) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf) // LF, not CRLF
if err := w.Write(csvHeader); err != nil {
return nil, err
}
for _, g := range s.Games {
periods := make([]string, 0, len(g.Periods))
for _, p := range g.Periods {
periods = append(periods, fmt.Sprintf("%s:%d-%d", p.Label, p.Home, p.Away))
}
row := []string{
strconv.Itoa(s.SeasonID),
g.GameID,
g.EventID,
g.Date,
g.Time,
g.Home,
g.Away,
optInt(g.HomeGoals),
optInt(g.AwayGoals),
strings.Join(periods, " "),
optInt(g.Spectators),
g.Venue,
}
if err := w.Write(row); err != nil {
return nil, err
}
}
w.Flush()
return buf.Bytes(), w.Error()
}
func optInt(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
// emit writes the bytes to -out or stdout and, when -check is set, compares
// them with a committed golden file. A mismatch exits 3 so CI turns an upstream
// change into a reviewable diff instead of a silent data change.
func emit(out, check string, b []byte) error {
if check != "" {
golden, err := os.ReadFile(check)
switch {
case err == nil && bytes.Equal(golden, b):
case err != nil:
return fmt.Errorf("golden file %s: %w", check, err)
default:
// A golden mismatch is not a layout problem, so it gets its own
// advice instead of the "extend layouts[]" one.
return &DriftError{Msg: fmt.Sprintf("output no longer matches %s: %s", check, firstDiff(golden, b)),
Hint: " - regenerate the golden file only after reviewing the change"}
}
}
if out == "" {
_, err := os.Stdout.Write(b)
return err
}
return os.WriteFile(out, b, 0o644)
}
// firstDiff describes the first differing line, for a readable failure.
func firstDiff(a, b []byte) string {
al, bl := strings.Split(string(a), "\n"), strings.Split(string(b), "\n")
for i := 0; i < len(al) || i < len(bl); i++ {
var x, y string
if i < len(al) {
x = al[i]
}
if i < len(bl) {
y = bl[i]
}
if x != y {
return fmt.Sprintf("line %d: %q != %q", i+1, trim(x), trim(y))
}
}
return "documents differ only in length"
}
func trim(s string) string {
if len(s) > 80 {
return s[:80] + "..."
}
return s
}