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.
This commit is contained in:
commit
6b701819ca
20 files changed
+2876
No files matched your search
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// The site writes the team separator as NBSP-hyphen-NBSP wrapped in newlines,
|
||||
// so every string that leaves the parser goes through normalize and downstream
|
||||
// comparisons and diffs stay stable.
|
||||
func normalize(s string) string {
|
||||
return strings.Join(strings.FieldsFunc(s, isSpaceRune), " ")
|
||||
}
|
||||
|
||||
func isSpaceRune(r rune) bool {
|
||||
if unicode.IsSpace(r) {
|
||||
return true
|
||||
}
|
||||
// No-break and zero-width variants the site emits around team names.
|
||||
switch r {
|
||||
case '\u00a0', '\u2007', '\u202f', '\u200b', '\ufeff':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// textOf returns the normalized text of a node subtree.
|
||||
func textOf(n *html.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
b.WriteString(n.Data)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return normalize(b.String())
|
||||
}
|
||||
|
||||
// textExcluding returns the normalized text of a node subtree with the first
|
||||
// descendant matching skipTag (and its subtree) left out. The schedule uses
|
||||
// this to read the mobile cell's start time without its <bold> date.
|
||||
func textExcluding(n *html.Node, skipTag string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
var walk func(*html.Node) bool
|
||||
walk = func(n *html.Node) bool {
|
||||
if n.Type == html.ElementNode && n.Data == skipTag {
|
||||
return false
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
b.WriteString(n.Data)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
return true
|
||||
}
|
||||
walk(n)
|
||||
return normalize(b.String())
|
||||
}
|
||||
|
||||
// childElements returns the direct children of n with the given tag name.
|
||||
func childElements(n *html.Node, tag string) []*html.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
var out []*html.Node
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// descendants returns every element with the given tag name, in document order.
|
||||
func descendants(n *html.Node, tag string) []*html.Node {
|
||||
var out []*html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == tag {
|
||||
out = append(out, n)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(n)
|
||||
return out
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasClass(n *html.Node, class string) bool {
|
||||
for _, f := range strings.Fields(attr(n, "class")) {
|
||||
if f == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in new issue
Block a user