26ebe3bd86
New 🌳 Pedigree view: enter a dog's ISO chip or SKK registration number
and see its ancestry rendered as a tree. SKK has no public API, so the
server drives SKK Hunddata like a browser: it resolves the input to an
internal hundid via the Hund_sok.aspx/HundData page-method, renders 7
generations per pedigree page, parses the rowspan grid into ahnentafel
positions, and follows each generation's leaves deeper by reading their
hundid out of the __doPostBack response viewstate.
A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
103 lines
2.4 KiB
Go
103 lines
2.4 KiB
Go
package main
|
|
|
|
// Small helpers over golang.org/x/net/html for walking the SKK pedigree markup.
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
func attr(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if a.Key == key {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// findByID returns the first element in the tree with the given id attribute.
|
|
func findByID(n *html.Node, id string) *html.Node {
|
|
if n.Type == html.ElementNode && attr(n, "id") == id {
|
|
return n
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if got := findByID(c, id); got != nil {
|
|
return got
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// descendants returns every element with the given tag anywhere under n, in
|
|
// document order.
|
|
func descendants(n *html.Node, tag string) []*html.Node {
|
|
var out []*html.Node
|
|
var walk func(*html.Node)
|
|
walk = func(x *html.Node) {
|
|
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.ElementNode && c.Data == tag {
|
|
out = append(out, c)
|
|
}
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(n)
|
|
return out
|
|
}
|
|
|
|
// directChildElements returns the immediate element children of n with the tag.
|
|
func directChildElements(n *html.Node, tag string) []*html.Node {
|
|
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
|
|
}
|
|
|
|
// findElement returns the first descendant element with the given tag.
|
|
func findElement(n *html.Node, tag string) *html.Node {
|
|
els := descendants(n, tag)
|
|
if len(els) == 0 {
|
|
return nil
|
|
}
|
|
return els[0]
|
|
}
|
|
|
|
// text concatenates all text under n.
|
|
func text(n *html.Node) string {
|
|
var sb strings.Builder
|
|
var walk func(*html.Node)
|
|
walk = func(x *html.Node) {
|
|
if x.Type == html.TextNode {
|
|
sb.WriteString(x.Data)
|
|
}
|
|
for c := x.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(n)
|
|
return sb.String()
|
|
}
|
|
|
|
// normalizeText trims and collapses internal whitespace.
|
|
func normalizeText(s string) string {
|
|
return strings.TrimSpace(wsRE.ReplaceAllString(s, " "))
|
|
}
|
|
|
|
// findBoldSpan returns the normalized text of the first bold <span> (how subject
|
|
// cells carry the registration number), or "".
|
|
func findBoldSpan(n *html.Node) string {
|
|
for _, sp := range descendants(n, "span") {
|
|
if strings.Contains(strings.ReplaceAll(attr(sp, "style"), " ", ""), "font-weight:bold") {
|
|
if t := normalizeText(text(sp)); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|