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).
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user