feat(watchlist): the ClubLog expedition enrichment — phase 2 lands
clublog.org/watch.php, per entry: the DXpedition flag, OQRS, LiveStream, the log's QSO total and the last-24h rate — the fields the schema has carried since phase 1. Refreshed on DXHunter's own cadence (hourly for expeditions, six-hourly for the rest), two at a time with a breath between requests: the application API key is shared by every install, so a hundred-entry list must read as a trickle at ClubLog's end. Nothing to configure — OpsLog's own key, already used for cty and Most Wanted, serves. A starred pattern is asked about by its base (ClubLog has no log for VK9*). The card gains the 24h rate beside the QSO total and the Live link to the expedition's stream.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package watchlist
|
||||
|
||||
// ClubLog enrichment — phase 2 of the DXHunter port. clublog.org/watch.php
|
||||
// answers, per callsign: is this an expedition, does it run OQRS, is there a
|
||||
// LiveStream, the log's QSO total and the last 24 hours' rate. That block has
|
||||
// been in the Entry schema since phase 1 (the file round-trips DXHunter's);
|
||||
// this file finally fills it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WatchData is clublog.org/watch.php's answer, reduced to what the tab shows.
|
||||
type WatchData struct {
|
||||
IsExpedition bool `json:"is_expedition"`
|
||||
HasOQRS bool `json:"has_oqrs"`
|
||||
LiveStream bool `json:"livestream"`
|
||||
ClubLogInfo *struct {
|
||||
TotalQSOs int `json:"total_qsos"`
|
||||
} `json:"clublog_info"`
|
||||
// {} when populated, [] when empty — RawMessage swallows both shapes.
|
||||
QSOsPerBand json.RawMessage `json:"24h_qsos_per_band_mode"`
|
||||
}
|
||||
|
||||
// QSOs24h sums the per-band/per-mode counts.
|
||||
func (d *WatchData) QSOs24h() int {
|
||||
if len(d.QSOsPerBand) == 0 {
|
||||
return 0
|
||||
}
|
||||
var parsed map[string]map[string]int
|
||||
if err := json.Unmarshal(d.QSOsPerBand, &parsed); err != nil {
|
||||
return 0 // "[]" for an empty answer lands here, which is the right zero
|
||||
}
|
||||
n := 0
|
||||
for _, modes := range parsed {
|
||||
for _, c := range modes {
|
||||
n += c
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FetchWatch asks ClubLog about one callsign. A 404 is "ClubLog has no log for
|
||||
// this call" — an ordinary answer, not an error.
|
||||
func FetchWatch(ctx context.Context, client *http.Client, apiKey, callsign string) (*WatchData, error) {
|
||||
url := fmt.Sprintf("https://clublog.org/watch.php?call=%s&api=%s",
|
||||
strings.ToUpper(strings.TrimSpace(callsign)), apiKey)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "OpsLog")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return &WatchData{}, nil
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("clublog watch %s: HTTP %d", callsign, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(strings.TrimSpace(string(body))) == 0 {
|
||||
return &WatchData{}, nil
|
||||
}
|
||||
var data WatchData
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("clublog watch %s: %w", callsign, err)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// ApplyClubLog writes one answer into the entry and reports whether anything
|
||||
// the tab shows actually changed — the caller only broadcasts on true.
|
||||
func (s *Store) ApplyClubLog(callsign string, d *WatchData) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.entries[strings.ToUpper(strings.TrimSpace(callsign))]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
total := e.ClubLogTotalQSOs
|
||||
if d.ClubLogInfo != nil {
|
||||
total = d.ClubLogInfo.TotalQSOs
|
||||
}
|
||||
q24 := d.QSOs24h()
|
||||
changed := e.IsExpedition != d.IsExpedition || e.ClubLogHasOQRS != d.HasOQRS ||
|
||||
e.ClubLogLiveStream != d.LiveStream || e.ClubLogTotalQSOs != total ||
|
||||
e.ClubLogQSOs24h != q24
|
||||
e.IsExpedition = d.IsExpedition
|
||||
e.ClubLogHasOQRS = d.HasOQRS
|
||||
e.ClubLogLiveStream = d.LiveStream
|
||||
e.ClubLogTotalQSOs = total
|
||||
e.ClubLogQSOs24h = q24
|
||||
e.ClubLogUpdatedAt = time.Now()
|
||||
s.scheduleSave()
|
||||
return changed
|
||||
}
|
||||
|
||||
// StaleEntries lists the callsigns whose ClubLog block is due a refresh.
|
||||
// Expeditions age faster — their totals are the interesting number and move by
|
||||
// the hour; a quiet call is asked about four times a day at most.
|
||||
func (s *Store) StaleEntries(expTTL, otherTTL time.Duration) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
now := time.Now()
|
||||
var out []string
|
||||
for call, e := range s.entries {
|
||||
// A starred pattern is a family, not a station — ClubLog has no log
|
||||
// for "VK9*". The base (star stripped) is what gets asked about.
|
||||
ttl := otherTTL
|
||||
if e.IsExpedition {
|
||||
ttl = expTTL
|
||||
}
|
||||
if now.Sub(e.ClubLogUpdatedAt) >= ttl {
|
||||
out = append(out, call)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user