package lookup import ( "context" "encoding/xml" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // HamQTH is a lookup.Provider for hamqth.com (free with registration). type HamQTH struct { User string Password string HTTP *http.Client mu sync.Mutex session string loggedAt time.Time } func NewHamQTH(user, password string) *HamQTH { return &HamQTH{ User: user, Password: password, HTTP: &http.Client{Timeout: 12 * time.Second}, } } func (h *HamQTH) Name() string { return "hamqth" } func (h *HamQTH) Lookup(ctx context.Context, callsign string) (Result, error) { if h.User == "" || h.Password == "" { return Result{}, fmt.Errorf("hamqth: credentials not set") } id, err := h.sessionID(ctx) if err != nil { return Result{}, err } r, err := h.fetch(ctx, id, callsign) if err == errHamQTHSessionExpired { h.mu.Lock() h.session = "" h.mu.Unlock() id, err = h.sessionID(ctx) if err != nil { return Result{}, err } r, err = h.fetch(ctx, id, callsign) } return r, err } func (h *HamQTH) sessionID(ctx context.Context) (string, error) { h.mu.Lock() defer h.mu.Unlock() // HamQTH sessions stay valid ~1h; re-login after 30min defensively. if h.session != "" && time.Since(h.loggedAt) < 30*time.Minute { return h.session, nil } u := fmt.Sprintf("https://www.hamqth.com/xml.php?u=%s&p=%s", url.QueryEscape(h.User), url.QueryEscape(h.Password)) body, err := h.get(ctx, u) if err != nil { return "", err } var resp hamqthRoot if err := xml.Unmarshal(body, &resp); err != nil { return "", fmt.Errorf("hamqth: parse session: %w", err) } if resp.Session.Error != "" { return "", fmt.Errorf("hamqth login: %s", resp.Session.Error) } if resp.Session.ID == "" { return "", fmt.Errorf("hamqth: empty session id") } h.session = resp.Session.ID h.loggedAt = time.Now() return h.session, nil } var errHamQTHSessionExpired = fmt.Errorf("hamqth: session expired") func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result, error) { u := fmt.Sprintf("https://www.hamqth.com/xml.php?id=%s&callsign=%s&prg=HamLog", url.QueryEscape(sessionID), url.QueryEscape(callsign)) body, err := h.get(ctx, u) if err != nil { return Result{}, err } var resp hamqthRoot if err := xml.Unmarshal(body, &resp); err != nil { return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err) } if resp.Session.Error != "" { msg := strings.ToLower(resp.Session.Error) if strings.Contains(msg, "session") || strings.Contains(msg, "expired") { return Result{}, errHamQTHSessionExpired } if strings.Contains(msg, "not found") || strings.Contains(msg, "callsign") { return Result{}, ErrNotFound } return Result{}, fmt.Errorf("hamqth: %s", resp.Session.Error) } s := resp.Search if s.Callsign == "" { return Result{}, ErrNotFound } r := Result{ Callsign: strings.ToUpper(s.Callsign), Name: strings.TrimSpace(s.Nick + " " + s.LastName), QTH: firstNonEmpty(s.QTH, s.AdrCity), Address: s.AdrStreet1, State: strings.ToUpper(s.USState), County: s.USCounty, Country: firstNonEmpty(s.AdrCountry, s.Country), Grid: strings.ToUpper(s.Grid), Continent: strings.ToUpper(s.Continent), Email: s.Email, QSLVia: s.QSLVia, } r.Lat, _ = strconv.ParseFloat(s.Latitude, 64) r.Lon, _ = strconv.ParseFloat(s.Longitude, 64) r.DXCC, _ = strconv.Atoi(s.DXCC) r.CQZ, _ = strconv.Atoi(s.CQ) r.ITUZ, _ = strconv.Atoi(s.ITU) return r, nil } func (h *HamQTH) get(ctx context.Context, u string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, err } resp, err := h.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("hamqth http: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("hamqth http %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // ----- XML shapes ----- type hamqthRoot struct { XMLName xml.Name `xml:"HamQTH"` Session hamqthSession `xml:"session"` Search hamqthSearch `xml:"search"` } type hamqthSession struct { ID string `xml:"session_id"` Error string `xml:"error"` } type hamqthSearch struct { Callsign string `xml:"callsign"` Nick string `xml:"nick"` LastName string `xml:"name"` QTH string `xml:"qth"` AdrStreet1 string `xml:"adr_street1"` AdrCity string `xml:"adr_city"` Country string `xml:"country"` AdrCountry string `xml:"adr_country"` USState string `xml:"us_state"` USCounty string `xml:"us_county"` Grid string `xml:"grid"` Latitude string `xml:"latitude"` Longitude string `xml:"longitude"` DXCC string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under CQ string `xml:"cq"` ITU string `xml:"itu"` Continent string `xml:"continent"` Email string `xml:"email"` QSLVia string `xml:"qsl_via"` }