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{} }