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).
882 lines
24 KiB
Go
882 lines
24 KiB
Go
package main
|
|
|
|
// Pedigree lookup: resolve a dog by chip / registration number / name against
|
|
// SKK (Svenska Kennelklubben) HUNDDATA, then crawl its ancestry and expose it as
|
|
// an ahnentafel-positioned tree. SKK has no public API, so this scrapes the
|
|
// interactive ASP.NET WebForms app the same way a browser drives it:
|
|
//
|
|
// 1. Resolve — POST Hund_sok.aspx/HundData (a JSON page-method) → hundid.
|
|
// 2. Fetch — GET Hund_Stamtavla.aspx?hundid=X, then POST ddlGenerationer=7
|
|
// to render 7 generations in one page; parse its rowspan grid.
|
|
// 3. Deepen — each generation-7 leaf links via __doPostBack; POST that link
|
|
// and read the ancestor's hundid out of the response __VIEWSTATE,
|
|
// then recurse. BFS terminates when the ancestry runs out.
|
|
//
|
|
// A deep crawl is dozens of sequential requests, so a lookup returns the first
|
|
// 7 generations immediately and keeps crawling in the background; the finished
|
|
// tree is cached per hundid (pedigrees don't change) so a dog is crawled once.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
const (
|
|
skkBase = "https://hundar.skk.se/hunddata/"
|
|
skkUA = "Mozilla/5.0 (X11; Linux x86_64) puppy-tracker pedigree lookup"
|
|
skkDelay = 250 * time.Millisecond // politeness between upstream requests
|
|
crawlGens = 7 // generations SKK renders per page
|
|
|
|
maxCrawlPages = 400 // hard caps so a crawl can never run away
|
|
maxCrawlRequests = 3000
|
|
crawlDeadline = 5 * time.Minute
|
|
maxActiveJobs = 4 // concurrent background crawls, total
|
|
firstPageWait = 20 * time.Second
|
|
)
|
|
|
|
// pedNode is one dog at an ahnentafel position (1 = subject, sire = 2n, dam = 2n+1).
|
|
type pedNode struct {
|
|
Reg string `json:"reg,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Titles string `json:"titles,omitempty"`
|
|
Hundid string `json:"hundid,omitempty"`
|
|
}
|
|
|
|
// pedSubject is the looked-up dog's headline info, from the resolver row.
|
|
type pedSubject struct {
|
|
Hundid string `json:"hundid"`
|
|
Reg string `json:"reg"`
|
|
Name string `json:"name"`
|
|
Breed string `json:"breed"`
|
|
Chip string `json:"chip"`
|
|
Sex string `json:"sex,omitempty"`
|
|
}
|
|
|
|
// skkClient is a single browser-like session against SKK (cookie jar + UA).
|
|
type skkClient struct {
|
|
hc *http.Client
|
|
}
|
|
|
|
func newSKKClient() (*skkClient, error) {
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &skkClient{hc: &http.Client{Jar: jar, Timeout: 30 * time.Second}}, nil
|
|
}
|
|
|
|
func (c *skkClient) do(req *http.Request) (*http.Response, error) {
|
|
req.Header.Set("User-Agent", skkUA)
|
|
return c.hc.Do(req)
|
|
}
|
|
|
|
// warm establishes an ASP.NET session (SessionId + anti-XSRF cookies) that the
|
|
// resolver and pedigree pages both require.
|
|
func (c *skkClient) warm(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skkBase+"Hund_sok.aspx", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// hundDataRow mirrors the fields the resolver page-method returns.
|
|
type hundDataRow struct {
|
|
Hundid string `json:"hundid"`
|
|
Regnr string `json:"Regnr"`
|
|
Hundnamn string `json:"hundnamn"`
|
|
Chipnr string `json:"chipnr"`
|
|
Rastext string `json:"rastext"`
|
|
Kon string `json:"Kon"`
|
|
IDnummer string `json:"IDnummer"`
|
|
Antal string `json:"Antal"`
|
|
IsError bool `json:"IsError"`
|
|
ErrorText string `json:"ErrorText"`
|
|
}
|
|
|
|
var digitsRE = regexp.MustCompile(`^\d+$`)
|
|
|
|
// resolve turns a user query (chip number, registration number, or name) into
|
|
// matching dogs. The field is chosen by shape: a long all-digit string is a
|
|
// chip; anything with a letter or slash is a registration number; otherwise a
|
|
// name search (which may return several rows to disambiguate).
|
|
func (c *skkClient) resolve(ctx context.Context, q string) ([]hundDataRow, error) {
|
|
body := map[string]string{
|
|
"txtRegnr": "", "txtIDnummer": "", "txtChipnr": "",
|
|
"txtHundnamn": "", "ddlRasIn": "", "ddlKon": "", "txtLicensnr": "",
|
|
}
|
|
switch {
|
|
case digitsRE.MatchString(q) && len(q) >= 10:
|
|
body["txtChipnr"] = q
|
|
case strings.ContainsAny(q, "/") || strings.IndexFunc(q, isLetter) >= 0:
|
|
body["txtRegnr"] = q
|
|
default:
|
|
body["txtHundnamn"] = q
|
|
}
|
|
buf, _ := json.Marshal(body)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
skkBase+"Hund_sok.aspx/HundData", bytes.NewReader(buf))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json;charset=utf-8")
|
|
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("resolver HTTP %d", resp.StatusCode)
|
|
}
|
|
var wrap struct {
|
|
D []hundDataRow `json:"d"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err != nil {
|
|
return nil, fmt.Errorf("resolver response: %w", err)
|
|
}
|
|
// SKK signals "no matches" (and other soft failures like a query needing more
|
|
// input) via a single IsError row rather than an HTTP error. Treat it as an
|
|
// empty result so the caller reports a clean "not found" instead of a 502.
|
|
if len(wrap.D) == 1 && wrap.D[0].IsError {
|
|
return nil, nil
|
|
}
|
|
return wrap.D, nil
|
|
}
|
|
|
|
func isLetter(r rune) bool {
|
|
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
|
}
|
|
|
|
// fetchPage GETs a dog's pedigree then POSTs ddlGenerationer=7 (with titles on)
|
|
// to render 7 generations, returning that page's HTML. The returned HTML is used
|
|
// for both grid parsing and the __doPostBack calls that resolve its leaf dogs,
|
|
// so its hidden fields (viewstate / event validation) match its ctl ids.
|
|
func (c *skkClient) fetchPage(ctx context.Context, hundid string) (string, string, error) {
|
|
pageURL := skkBase + "Hund_Stamtavla.aspx?hundid=" + url.QueryEscape(hundid)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, nil)
|
|
if err != nil {
|
|
return "", pageURL, err
|
|
}
|
|
req.Header.Set("Referer", skkBase+"Hund_sok.aspx")
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return "", pageURL, err
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return "", pageURL, err
|
|
}
|
|
first := string(raw)
|
|
|
|
form := hiddenFields(first)
|
|
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
|
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
|
form.Set("__EVENTTARGET", "ctl00$bodyContent$ddlGenerationer")
|
|
form.Set("__EVENTARGUMENT", "")
|
|
html7, err := c.postForm(ctx, pageURL, form)
|
|
if err != nil {
|
|
return "", pageURL, err
|
|
}
|
|
return html7, pageURL, nil
|
|
}
|
|
|
|
func (c *skkClient) postForm(ctx context.Context, pageURL string, form url.Values) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, pageURL,
|
|
strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Referer", pageURL)
|
|
resp, err := c.do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
var (
|
|
viewstateRE = regexp.MustCompile(`name="__VIEWSTATE" id="__VIEWSTATE" value="([^"]+)"`)
|
|
// The clicked dog's internal hundid, encoded in the response viewstate as the
|
|
// string "hundid", a type byte (\x05), a length byte, then ASCII digits.
|
|
vsHundidRE = regexp.MustCompile(`(?s)hundid\x05.(\d+)`)
|
|
)
|
|
|
|
// postbackHundid clicks an ancestor's __doPostBack link on the given page and
|
|
// recovers that ancestor's hundid from the response viewstate. The rendered
|
|
// pedigree table never re-roots on such a click, but the viewstate carries the
|
|
// clicked dog's id — which is exactly the handle needed to fetch its own page.
|
|
func (c *skkClient) postbackHundid(ctx context.Context, pageHTML, ctlid, pageURL string) (string, error) {
|
|
form := hiddenFields(pageHTML)
|
|
form.Set("ctl00$bodyContent$ddlGenerationer", strconv.Itoa(crawlGens))
|
|
form.Set("ctl00$bodyContent$ddlTitlar", "J")
|
|
form.Set("__EVENTTARGET", ctlid)
|
|
form.Set("__EVENTARGUMENT", "")
|
|
resp, err := c.postForm(ctx, pageURL, form)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
m := viewstateRE.FindStringSubmatch(resp)
|
|
if m == nil {
|
|
return "", nil
|
|
}
|
|
dec := decodeB64(m[1])
|
|
mm := vsHundidRE.FindSubmatch(dec)
|
|
if mm == nil {
|
|
return "", nil
|
|
}
|
|
return string(mm[1]), nil
|
|
}
|
|
|
|
func decodeB64(s string) []byte {
|
|
if m := len(s) % 4; m != 0 {
|
|
s += strings.Repeat("=", 4-m)
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return b
|
|
}
|
|
|
|
// hiddenFields collects every <input type=hidden> on a page into a form value
|
|
// set, so an ASP.NET postback can echo back __VIEWSTATE / __EVENTVALIDATION etc.
|
|
func hiddenFields(pageHTML string) url.Values {
|
|
vals := url.Values{}
|
|
node, err := html.Parse(strings.NewReader(pageHTML))
|
|
if err != nil {
|
|
return vals
|
|
}
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "input" {
|
|
var typ, name, val string
|
|
for _, a := range n.Attr {
|
|
switch a.Key {
|
|
case "type":
|
|
typ = a.Val
|
|
case "name":
|
|
name = a.Val
|
|
case "value":
|
|
val = a.Val
|
|
}
|
|
}
|
|
if typ == "hidden" && name != "" {
|
|
vals.Set(name, val)
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(node)
|
|
return vals
|
|
}
|
|
|
|
// gridCell is a parsed pedigree table cell.
|
|
type gridCell struct {
|
|
reg, name, titles, ctlid string
|
|
occupied bool
|
|
}
|
|
|
|
var doPostBackRE = regexp.MustCompile(`__doPostBack\('([^']+)'`)
|
|
var wsRE = regexp.MustCompile(`\s+`)
|
|
|
|
// parseGrid reconstructs the rowspan-based pedigree table into columns. Column c
|
|
// holds 2^c cells top-to-bottom; a cell's index within its column is its
|
|
// ahnentafel offset. Returns column index -> ordered cells.
|
|
func parseGrid(pageHTML string) map[int][]gridCell {
|
|
node, err := html.Parse(strings.NewReader(pageHTML))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
tbl := findByID(node, "bodyContent_tblStamtavla")
|
|
if tbl == nil {
|
|
return nil
|
|
}
|
|
occ := map[[2]int]bool{}
|
|
type placed struct {
|
|
r, c int
|
|
cell gridCell
|
|
}
|
|
var placedCells []placed
|
|
r := 0
|
|
for _, tr := range descendants(tbl, "tr") {
|
|
c := 0
|
|
for _, td := range directChildElements(tr, "td") {
|
|
for occ[[2]int{r, c}] {
|
|
c++
|
|
}
|
|
rs := 1
|
|
if v := attr(td, "rowspan"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
rs = n
|
|
}
|
|
}
|
|
for dr := 0; dr < rs; dr++ {
|
|
occ[[2]int{r + dr, c}] = true
|
|
}
|
|
placedCells = append(placedCells, placed{r, c, parseCell(td)})
|
|
c++
|
|
}
|
|
r++
|
|
}
|
|
byCol := map[int][]placed{}
|
|
for _, p := range placedCells {
|
|
byCol[p.c] = append(byCol[p.c], p)
|
|
}
|
|
out := map[int][]gridCell{}
|
|
for col, lst := range byCol {
|
|
sort.SliceStable(lst, func(i, j int) bool { return lst[i].r < lst[j].r })
|
|
cells := make([]gridCell, len(lst))
|
|
for i, p := range lst {
|
|
cells[i] = p.cell
|
|
}
|
|
out[col] = cells
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseCell extracts reg / name / titles / ctlid from one <td>, mirroring how
|
|
// SKK marks them up: a <a __doPostBack> holds the reg number (subject cells use
|
|
// a bold <span> instead), a <font> holds championship titles, and the last plain
|
|
// <span> holds the dog's name. "Uppgift saknas" placeholders are left unoccupied.
|
|
func parseCell(td *html.Node) gridCell {
|
|
var cell gridCell
|
|
if a := findElement(td, "a"); a != nil {
|
|
if href := attr(a, "href"); strings.Contains(href, "__doPostBack") {
|
|
cell.reg = normalizeText(text(a))
|
|
if m := doPostBackRE.FindStringSubmatch(html.UnescapeString(href)); m != nil {
|
|
cell.ctlid = m[1]
|
|
}
|
|
}
|
|
}
|
|
if f := findElement(td, "font"); f != nil {
|
|
cell.titles = normalizeText(text(f))
|
|
}
|
|
// Name: the last <span> whose text isn't the titles string. Subject cells put
|
|
// the reg in a leading bold span; if we found no link reg, adopt it.
|
|
for _, sp := range descendants(td, "span") {
|
|
t := normalizeText(text(sp))
|
|
if t == "" || t == cell.titles {
|
|
continue
|
|
}
|
|
cell.name = t
|
|
}
|
|
if cell.reg == "" {
|
|
if b := findBoldSpan(td); b != "" {
|
|
cell.reg = b
|
|
if cell.name == b {
|
|
cell.name = ""
|
|
}
|
|
}
|
|
}
|
|
if strings.EqualFold(cell.name, "Uppgift saknas") {
|
|
cell.name = ""
|
|
}
|
|
cell.occupied = cell.reg != "" || cell.name != ""
|
|
return cell
|
|
}
|
|
|
|
// crawlProgress is the mutable snapshot a running crawl publishes.
|
|
type crawlProgress struct {
|
|
Pages int
|
|
Distinct int
|
|
MaxGen int
|
|
Nodes map[string]pedNode
|
|
}
|
|
|
|
// crawl performs the breadth-first ancestry walk from a subject hundid, calling
|
|
// report after each page with a fresh snapshot. It places every dog at its global
|
|
// ahnentafel position and resolves each generation-7 leaf to a hundid to recurse.
|
|
func (c *skkClient) crawl(ctx context.Context, hundid string, report func(crawlProgress)) (map[string]pedNode, error) {
|
|
tree := map[string]pedNode{}
|
|
edges := map[string]string{} // subjectHundid|ctlid -> ancestor hundid
|
|
done := map[string]bool{}
|
|
type qitem struct {
|
|
hundid string
|
|
basePos uint64
|
|
}
|
|
queue := []qitem{{hundid, 1}}
|
|
pages, requests, maxGen := 0, 0, 0
|
|
|
|
snapshot := func() crawlProgress {
|
|
nodes := make(map[string]pedNode, len(tree))
|
|
for k, v := range tree {
|
|
nodes[k] = v
|
|
}
|
|
return crawlProgress{Pages: pages, Distinct: countDistinct(tree), MaxGen: maxGen, Nodes: nodes}
|
|
}
|
|
|
|
for len(queue) > 0 {
|
|
if err := ctx.Err(); err != nil {
|
|
return tree, err
|
|
}
|
|
if pages >= maxCrawlPages || requests >= maxCrawlRequests {
|
|
log.Printf("pedigree crawl %s: hit cap (pages=%d requests=%d)", hundid, pages, requests)
|
|
break
|
|
}
|
|
item := queue[0]
|
|
queue = queue[1:]
|
|
if done[item.hundid] {
|
|
continue
|
|
}
|
|
done[item.hundid] = true
|
|
|
|
time.Sleep(skkDelay)
|
|
pageHTML, pageURL, err := c.fetchPage(ctx, item.hundid)
|
|
requests += 2
|
|
if err != nil {
|
|
if pages == 0 {
|
|
return tree, err // couldn't even fetch the subject
|
|
}
|
|
log.Printf("pedigree crawl %s: fetch %s failed: %v", hundid, item.hundid, err)
|
|
continue
|
|
}
|
|
pages++
|
|
grid := parseGrid(pageHTML)
|
|
|
|
var frontier []struct {
|
|
gpos uint64
|
|
ctlid string
|
|
}
|
|
for col := 0; col <= crawlGens-1; col++ {
|
|
cells, ok := grid[col]
|
|
if !ok {
|
|
continue
|
|
}
|
|
for pos, cell := range cells {
|
|
if !cell.occupied {
|
|
continue
|
|
}
|
|
gpos := item.basePos*(1<<uint(col)) + uint64(pos)
|
|
key := strconv.FormatUint(gpos, 10)
|
|
node := tree[key]
|
|
node.Reg, node.Name, node.Titles = cell.reg, cell.name, cell.titles
|
|
if col == 0 {
|
|
node.Hundid = item.hundid
|
|
}
|
|
tree[key] = node
|
|
if g := bitsLen(gpos); g > maxGen {
|
|
maxGen = g
|
|
}
|
|
if col == crawlGens-1 && cell.ctlid != "" {
|
|
frontier = append(frontier, struct {
|
|
gpos uint64
|
|
ctlid string
|
|
}{gpos, cell.ctlid})
|
|
}
|
|
}
|
|
}
|
|
report(snapshot())
|
|
|
|
for _, f := range frontier {
|
|
if err := ctx.Err(); err != nil {
|
|
return tree, err
|
|
}
|
|
if requests >= maxCrawlRequests {
|
|
break
|
|
}
|
|
ekey := item.hundid + "|" + f.ctlid
|
|
hid, ok := edges[ekey]
|
|
if !ok {
|
|
time.Sleep(skkDelay)
|
|
hid, err = c.postbackHundid(ctx, pageHTML, f.ctlid, pageURL)
|
|
requests++
|
|
if err != nil {
|
|
continue
|
|
}
|
|
edges[ekey] = hid
|
|
}
|
|
if hid != "" {
|
|
key := strconv.FormatUint(f.gpos, 10)
|
|
node := tree[key]
|
|
node.Hundid = hid
|
|
tree[key] = node
|
|
queue = append(queue, qitem{hid, f.gpos})
|
|
}
|
|
}
|
|
}
|
|
report(snapshot())
|
|
return tree, nil
|
|
}
|
|
|
|
func bitsLen(x uint64) int {
|
|
n := 0
|
|
for x > 0 {
|
|
n++
|
|
x >>= 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
// ---- job manager ---------------------------------------------------------
|
|
|
|
type jobState string
|
|
|
|
const (
|
|
jobRunning jobState = "running"
|
|
jobDone jobState = "done"
|
|
jobError jobState = "error"
|
|
)
|
|
|
|
type pedJob struct {
|
|
id string
|
|
hundid string
|
|
subject pedSubject
|
|
firstPage chan struct{} // closed once the subject's own page is parsed
|
|
|
|
mu sync.Mutex
|
|
state jobState
|
|
pages int
|
|
distinct int
|
|
maxGen int
|
|
nodes map[string]pedNode
|
|
errMsg string
|
|
}
|
|
|
|
func (j *pedJob) apply(p crawlProgress) {
|
|
j.mu.Lock()
|
|
j.pages, j.distinct, j.maxGen, j.nodes = p.Pages, p.Distinct, p.MaxGen, p.Nodes
|
|
j.mu.Unlock()
|
|
}
|
|
|
|
type pedManager struct {
|
|
db *sql.DB
|
|
|
|
mu sync.Mutex
|
|
jobs map[string]*pedJob // keyed by hundid (coalesces duplicate lookups)
|
|
}
|
|
|
|
func newPedManager(db *sql.DB) *pedManager {
|
|
return &pedManager{db: db, jobs: map[string]*pedJob{}}
|
|
}
|
|
|
|
func (m *pedManager) activeCount() int {
|
|
n := 0
|
|
for _, j := range m.jobs {
|
|
j.mu.Lock()
|
|
if j.state == jobRunning {
|
|
n++
|
|
}
|
|
j.mu.Unlock()
|
|
}
|
|
return n
|
|
}
|
|
|
|
// startOrAttach returns the running/finished job for a hundid, or starts a new
|
|
// background crawl. The boolean reports whether a fresh job was created.
|
|
func (m *pedManager) startOrAttach(client *skkClient, subject pedSubject) (*pedJob, bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if j, ok := m.jobs[subject.Hundid]; ok {
|
|
return j, false, nil
|
|
}
|
|
if m.activeCount() >= maxActiveJobs {
|
|
return nil, false, errors.New("busy: too many pedigree lookups in progress, try again shortly")
|
|
}
|
|
j := &pedJob{
|
|
id: subject.Hundid,
|
|
hundid: subject.Hundid,
|
|
subject: subject,
|
|
firstPage: make(chan struct{}),
|
|
state: jobRunning,
|
|
nodes: map[string]pedNode{},
|
|
}
|
|
m.jobs[subject.Hundid] = j
|
|
go m.run(client, j)
|
|
return j, true, nil
|
|
}
|
|
|
|
func (m *pedManager) run(client *skkClient, j *pedJob) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), crawlDeadline)
|
|
defer cancel()
|
|
|
|
firstDone := false
|
|
report := func(p crawlProgress) {
|
|
j.apply(p)
|
|
if !firstDone && p.Pages >= 1 {
|
|
firstDone = true
|
|
close(j.firstPage)
|
|
}
|
|
}
|
|
nodes, err := client.crawl(ctx, j.hundid, report)
|
|
if !firstDone {
|
|
close(j.firstPage) // unblock waiters even if the very first fetch failed
|
|
}
|
|
j.mu.Lock()
|
|
if err != nil && len(nodes) == 0 {
|
|
j.state = jobError
|
|
j.errMsg = err.Error()
|
|
} else {
|
|
j.state = jobDone
|
|
}
|
|
j.mu.Unlock()
|
|
|
|
if len(nodes) > 0 {
|
|
m.persist(j.subject, nodes)
|
|
}
|
|
}
|
|
|
|
// snapshot copies a job's current public state under its lock.
|
|
func (j *pedJob) snapshot() (jobState, int, int, int, map[string]pedNode, string) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
nodes := make(map[string]pedNode, len(j.nodes))
|
|
for k, v := range j.nodes {
|
|
nodes[k] = v
|
|
}
|
|
return j.state, j.pages, j.distinct, j.maxGen, nodes, j.errMsg
|
|
}
|
|
|
|
// ---- persistent cache ----------------------------------------------------
|
|
|
|
func (m *pedManager) persist(subject pedSubject, nodes map[string]pedNode) {
|
|
sj, _ := json.Marshal(subject)
|
|
nj, _ := json.Marshal(nodes)
|
|
_, err := m.db.Exec(`
|
|
INSERT INTO pedigree_cache (hundid, subject, nodes, generations, fetched)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(hundid) DO UPDATE SET
|
|
subject = excluded.subject, nodes = excluded.nodes,
|
|
generations = excluded.generations, fetched = excluded.fetched`,
|
|
subject.Hundid, string(sj), string(nj), maxGenerations(nodes), time.Now().UnixMilli())
|
|
if err != nil {
|
|
log.Printf("pedigree cache save %s: %v", subject.Hundid, err)
|
|
}
|
|
}
|
|
|
|
// cached returns a stored tree for a hundid, if present.
|
|
func (m *pedManager) cached(hundid string) (pedSubject, map[string]pedNode, bool) {
|
|
var sj, nj string
|
|
err := m.db.QueryRow(
|
|
`SELECT subject, nodes FROM pedigree_cache WHERE hundid = ?`, hundid,
|
|
).Scan(&sj, &nj)
|
|
if err != nil {
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
log.Printf("pedigree cache get %s: %v", hundid, err)
|
|
}
|
|
return pedSubject{}, nil, false
|
|
}
|
|
var subject pedSubject
|
|
var nodes map[string]pedNode
|
|
json.Unmarshal([]byte(sj), &subject)
|
|
json.Unmarshal([]byte(nj), &nodes)
|
|
return subject, nodes, true
|
|
}
|
|
|
|
func maxGenerations(nodes map[string]pedNode) int {
|
|
max := 0
|
|
for k := range nodes {
|
|
if p, err := strconv.ParseUint(k, 10, 64); err == nil {
|
|
if g := bitsLen(p); g > max {
|
|
max = g
|
|
}
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// ---- HTTP handlers -------------------------------------------------------
|
|
|
|
type pedLookupResponse struct {
|
|
Status string `json:"status"` // done | running | choose
|
|
JobID string `json:"jobId,omitempty"`
|
|
Hundid string `json:"hundid,omitempty"`
|
|
Subject *pedSubject `json:"subject,omitempty"`
|
|
Generations int `json:"generations,omitempty"`
|
|
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
|
Matches []hundDataRow `json:"matches,omitempty"`
|
|
}
|
|
|
|
func rowToSubject(r hundDataRow) pedSubject {
|
|
return pedSubject{
|
|
Hundid: r.Hundid,
|
|
Reg: strings.TrimSpace(r.Regnr),
|
|
Name: strings.TrimSpace(r.Hundnamn),
|
|
Breed: strings.TrimSpace(r.Rastext),
|
|
Chip: strings.TrimSpace(r.Chipnr),
|
|
Sex: strings.TrimSpace(r.Kon),
|
|
}
|
|
}
|
|
|
|
// handleLookup resolves a query and returns a cached tree, a disambiguation list,
|
|
// or an immediate 7-generation tree with a background job crawling deeper.
|
|
func (m *pedManager) handleLookup(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Q string `json:"q"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
q := strings.TrimSpace(req.Q)
|
|
if q == "" {
|
|
http.Error(w, "empty query", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
client, err := newSKKClient()
|
|
if err != nil {
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
if err := client.warm(ctx); err != nil {
|
|
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
rows, err := client.resolve(ctx, q)
|
|
if err != nil {
|
|
http.Error(w, "lookup failed: "+err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
rows = withHundid(rows)
|
|
switch {
|
|
case len(rows) == 0:
|
|
http.Error(w, "no dog found for "+q, http.StatusNotFound)
|
|
return
|
|
case len(rows) > 1:
|
|
writeJSON(w, pedLookupResponse{Status: "choose", Matches: rows})
|
|
return
|
|
}
|
|
subject := rowToSubject(rows[0])
|
|
|
|
if subj, nodes, ok := m.cached(subject.Hundid); ok {
|
|
writeJSON(w, pedLookupResponse{
|
|
Status: "done", Hundid: subj.Hundid, Subject: &subj,
|
|
Generations: maxGenerations(nodes), Nodes: nodes,
|
|
})
|
|
return
|
|
}
|
|
|
|
job, _, err := m.startOrAttach(client, subject)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
select {
|
|
case <-job.firstPage:
|
|
case <-time.After(firstPageWait):
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
state, _, _, gen, nodes, msg := job.snapshot()
|
|
if state == jobError {
|
|
http.Error(w, "pedigree fetch failed: "+msg, http.StatusBadGateway)
|
|
return
|
|
}
|
|
status := "running"
|
|
if state == jobDone {
|
|
status = "done"
|
|
}
|
|
writeJSON(w, pedLookupResponse{
|
|
Status: status, JobID: job.id, Hundid: subject.Hundid,
|
|
Subject: &subject, Generations: gen, Nodes: nodes,
|
|
})
|
|
}
|
|
|
|
type pedStatusResponse struct {
|
|
Status string `json:"status"`
|
|
Pages int `json:"pages"`
|
|
Distinct int `json:"distinct"`
|
|
Generations int `json:"generations"`
|
|
Nodes map[string]pedNode `json:"nodes,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// handleStatus returns a running crawl's current partial tree so the client can
|
|
// fill the view in progressively, and the final tree when it finishes.
|
|
func (m *pedManager) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
id := r.URL.Query().Get("job")
|
|
m.mu.Lock()
|
|
job := m.jobs[id]
|
|
m.mu.Unlock()
|
|
if job == nil {
|
|
// A finished job may have been evicted, but the cache still has the tree.
|
|
if _, nodes, ok := m.cached(id); ok {
|
|
writeJSON(w, pedStatusResponse{
|
|
Status: "done", Generations: maxGenerations(nodes),
|
|
Distinct: countDistinct(nodes), Nodes: nodes, Pages: 0,
|
|
})
|
|
return
|
|
}
|
|
http.Error(w, "unknown job", http.StatusNotFound)
|
|
return
|
|
}
|
|
state, pages, distinct, gen, nodes, msg := job.snapshot()
|
|
writeJSON(w, pedStatusResponse{
|
|
Status: string(state), Pages: pages, Distinct: distinct,
|
|
Generations: gen, Nodes: nodes, Error: msg,
|
|
})
|
|
}
|
|
|
|
// countDistinct counts unique ancestors, keyed by registration number (falling
|
|
// back to name). Pedigree collapse means one dog can fill many positions, so
|
|
// this is smaller than the number of occupied positions.
|
|
func countDistinct(nodes map[string]pedNode) int {
|
|
seen := map[string]bool{}
|
|
for _, n := range nodes {
|
|
k := n.Reg
|
|
if k == "" {
|
|
k = n.Name
|
|
}
|
|
if k != "" {
|
|
seen[k] = true
|
|
}
|
|
}
|
|
return len(seen)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// withHundid drops resolver rows lacking a usable hundid (defensive).
|
|
func withHundid(rows []hundDataRow) []hundDataRow {
|
|
out := rows[:0]
|
|
for _, r := range rows {
|
|
if strings.TrimSpace(r.Hundid) != "" {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|