Files
Alexander Heldt 26ebe3bd86 Add pedigree lookup and ancestry tree page
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).
2026-07-26 09:22:14 +00:00

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 ""
}