package clublog // mostwanted.go — the ClubLog "Most Wanted" DXCC ranking. ClubLog publishes, per // callsign, how wanted each DXCC entity is (rank 1 = the single most wanted, e.g. // P5 North Korea). We fetch it, invert it to a DXCC-number → rank map, cache it on // disk (personalised to the operator's callsign) and refresh daily. The rank is // surfaced next to the entity in the entry-strip band/slot matrix. // // Endpoint (same one DXHunter uses): mostwanted.php?api=1 returns JSON // { "1": "344", "2": "123", ... } rank → ADIF (DXCC) number // Passing &callsign= personalises the ranking to that operator's log. // api=1 is just the "return JSON" flag — no API key is needed here. import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" ) const mwURL = "https://clublog.org/mostwanted.php?api=1" const mwFile = "clublog_mostwanted.json" // mwUserAgent — ClubLog's nginx 403s the default Go user-agent, so set a real one. const mwUserAgent = "OpsLog (amateur radio logger)" // mwCache is the on-disk shape (ranks keyed by DXCC number as a string for JSON). type mwCache struct { Callsign string `json:"callsign"` FetchedAt time.Time `json:"fetched_at"` Ranks map[string]int `json:"ranks"` } // MostWanted owns the on-disk cache and the parsed DXCC-number → rank map. type MostWanted struct { dir string mu sync.RWMutex ranks map[int]int // dxcc number → rank (1 = most wanted) call string // callsign the current ranks were fetched for fetched time.Time } func NewMostWanted(dataDir string) *MostWanted { return &MostWanted{dir: dataDir, ranks: map[int]int{}} } func (m *MostWanted) path() string { return filepath.Join(m.dir, mwFile) } // Rank returns the most-wanted rank for a DXCC number, or 0 if unknown/unloaded. func (m *MostWanted) Rank(dxcc int) int { if dxcc <= 0 { return 0 } m.mu.RLock() defer m.mu.RUnlock() return m.ranks[dxcc] } // Ranks returns a copy of the whole DXCC-number → rank map. func (m *MostWanted) Ranks() map[int]int { m.mu.RLock() defer m.mu.RUnlock() out := make(map[int]int, len(m.ranks)) for k, v := range m.ranks { out[k] = v } return out } // Info reports the callsign the list was fetched for, its date, and entity count. func (m *MostWanted) Info() (call, date string, count int) { m.mu.RLock() defer m.mu.RUnlock() d := "" if !m.fetched.IsZero() { d = m.fetched.Format("2006-01-02") } return m.call, d, len(m.ranks) } func (m *MostWanted) Loaded() bool { m.mu.RLock() defer m.mu.RUnlock() return len(m.ranks) > 0 } // LoadFromDisk reads the cached JSON into memory. Does NOT download. func (m *MostWanted) LoadFromDisk() error { b, err := os.ReadFile(m.path()) if err != nil { return err } var c mwCache if err := json.Unmarshal(b, &c); err != nil { return err } ranks := make(map[int]int, len(c.Ranks)) for k, v := range c.Ranks { if n, err := strconv.Atoi(k); err == nil && n > 0 && v > 0 { ranks[n] = v } } m.mu.Lock() m.ranks = ranks m.call = c.Callsign m.fetched = c.FetchedAt m.mu.Unlock() return nil } // NeedsRefresh reports whether the list is empty, older than maxAge, or was // fetched for a DIFFERENT callsign (the ranking is personalised, so a profile // switch invalidates it). func (m *MostWanted) NeedsRefresh(callsign string, maxAge time.Duration) bool { m.mu.RLock() defer m.mu.RUnlock() if len(m.ranks) == 0 { return true } if !strings.EqualFold(strings.TrimSpace(callsign), m.call) { return true } return time.Since(m.fetched) > maxAge } // Download fetches the most-wanted list for callsign, writes it atomically, and // swaps it into memory. func (m *MostWanted) Download(ctx context.Context, callsign string) error { if err := os.MkdirAll(m.dir, 0o755); err != nil { return err } call := strings.ToUpper(strings.TrimSpace(callsign)) url := mwURL if call != "" { url += "&callsign=" + call } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("User-Agent", mwUserAgent) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("clublog mostwanted HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return err } // { "1": "344", "2": "123", ... } — rank → ADIF (DXCC) number. var raw map[string]string if err := json.Unmarshal(body, &raw); err != nil { return fmt.Errorf("clublog mostwanted parse: %w", err) } ranks := make(map[int]int, len(raw)) strRanks := make(map[string]int, len(raw)) for rankStr, adif := range raw { rank, e1 := strconv.Atoi(strings.TrimSpace(rankStr)) dxcc, e2 := strconv.Atoi(strings.TrimSpace(adif)) if e1 == nil && e2 == nil && rank > 0 && dxcc > 0 { ranks[dxcc] = rank strRanks[adif] = rank } } if len(ranks) == 0 { return fmt.Errorf("clublog mostwanted: empty/invalid response") } now := time.Now() if b, err := json.Marshal(mwCache{Callsign: call, FetchedAt: now, Ranks: strRanks}); err == nil { tmp := m.path() + ".tmp" if os.WriteFile(tmp, b, 0o644) == nil { _ = os.Rename(tmp, m.path()) } } m.mu.Lock() m.ranks = ranks m.call = call m.fetched = now m.mu.Unlock() return nil }