pota
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package pota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hunterLogURL is the authenticated POTA user logbook endpoint (paginated).
|
||||
// hunterOnly=1 restricts it to the user's chaser/hunter QSOs.
|
||||
const hunterLogURL = "https://api.pota.app/user/logbook?hunterOnly=1&page=%d&size=%d"
|
||||
|
||||
// HunterQSO is one entry from the POTA hunter log: a contact the user made with
|
||||
// a park activator, carrying the park reference to stamp onto the local QSO.
|
||||
type HunterQSO struct {
|
||||
Worked string `json:"worked"` // activator callsign (the station worked)
|
||||
Date time.Time `json:"date"` // QSO date/time (UTC)
|
||||
Band string `json:"band"` // ADIF band, e.g. "20m"
|
||||
Mode string `json:"mode"` // logged mode
|
||||
Reference string `json:"reference"` // park ref, e.g. "US-2072"
|
||||
}
|
||||
|
||||
// hunterEntry mirrors the POTA API logbook record (fields we use).
|
||||
//
|
||||
// IMPORTANT: POTA logbook entries come from the ACTIVATOR's uploaded log, so
|
||||
// "station_callsign" is the activator (the park station you worked) and
|
||||
// "worked_callsign" is YOU (the hunter, whom the activator worked). To match a
|
||||
// local QSO — whose callsign field holds the activator — we key on
|
||||
// station_callsign, NOT worked_callsign.
|
||||
type hunterEntry struct {
|
||||
StationCallsign string `json:"station_callsign"` // the activator (park station)
|
||||
WorkedCallsign string `json:"worked_callsign"` // the hunter (you)
|
||||
QSODateTime string `json:"qsoDateTime"`
|
||||
Band string `json:"band"`
|
||||
LoggedMode string `json:"loggedMode"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// FetchHunterLog downloads the user's entire POTA hunter log, page by page,
|
||||
// using their pota.app session token as the Authorization header. logf may be
|
||||
// nil. Returns a friendly error on an expired/invalid token (HTTP 401/403).
|
||||
func FetchHunterLog(ctx context.Context, token string, logf func(string, ...any)) ([]HunterQSO, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("no POTA token — paste it from pota.app (DevTools → Network → Authorization header)")
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
const size = 100
|
||||
const maxPages = 5000 // safety bound
|
||||
|
||||
var out []HunterQSO
|
||||
total := -1
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
url := fmt.Sprintf(hunterLogURL, page, size)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("POTA fetch: %w", err)
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("POTA rejected the token (HTTP %d) — it has likely expired; re-copy it from pota.app", resp.StatusCode)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("POTA HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
Entries []hunterEntry `json:"entries"`
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("POTA decode: %w", err)
|
||||
}
|
||||
total = body.Count
|
||||
for _, e := range body.Entries {
|
||||
ref := strings.ToUpper(strings.TrimSpace(e.Reference))
|
||||
// The activator is the station we worked → station_callsign.
|
||||
act := strings.ToUpper(strings.TrimSpace(e.StationCallsign))
|
||||
if act == "" {
|
||||
act = strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)) // fallback
|
||||
}
|
||||
if ref == "" || act == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, HunterQSO{
|
||||
Worked: act,
|
||||
Date: parseHunterTime(e.QSODateTime),
|
||||
Band: strings.ToLower(strings.TrimSpace(e.Band)),
|
||||
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
|
||||
Reference: ref,
|
||||
})
|
||||
}
|
||||
if logf != nil {
|
||||
logf("pota: hunter log page %d (%d/%d)", page, len(out), total)
|
||||
}
|
||||
if len(body.Entries) == 0 || (total >= 0 && len(out) >= total) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseHunterTime parses POTA's qsoDateTime, tolerating ISO variants (with/out
|
||||
// 'T', timezone, or fractional seconds). POTA times are UTC.
|
||||
func parseHunterTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano, time.RFC3339,
|
||||
"2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05.000", "2006-01-02 15:04:05", "2006-01-02 15:04:05Z",
|
||||
"2006-01-02T15:04Z", "2006-01-02T15:04", "2006-01-02 15:04",
|
||||
} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ func (c *Cache) Lookup(call string) (Info, bool) {
|
||||
return i, ok
|
||||
}
|
||||
|
||||
// BaseCall is the exported callsign normaliser used for hunter-log matching.
|
||||
func BaseCall(s string) string { return baseCall(s) }
|
||||
|
||||
// baseCall normalises a callsign for matching: upper-cased, and when it carries
|
||||
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
|
||||
// almost always the home call.
|
||||
|
||||
Reference in New Issue
Block a user