package lookup import ( "context" "encoding/xml" "fmt" "io" "net/http" "net/url" "strconv" "strings" "sync" "time" ) // QRZ is a lookup.Provider for xmldata.qrz.com. // A QRZ subscription is required for full data; basic info is free. type QRZ struct { User string Password string HTTP *http.Client mu sync.Mutex session string loggedAt time.Time } func NewQRZ(user, password string) *QRZ { return &QRZ{ User: user, Password: password, HTTP: &http.Client{Timeout: 12 * time.Second}, } } func (q *QRZ) Name() string { return "qrz" } // Lookup queries QRZ for the given callsign. func (q *QRZ) Lookup(ctx context.Context, callsign string) (Result, error) { if q.User == "" || q.Password == "" { return Result{}, fmt.Errorf("qrz: credentials not set") } key, err := q.sessionKey(ctx) if err != nil { return Result{}, err } r, err := q.fetch(ctx, key, callsign) if err == errQRZSessionExpired { // Force re-login and retry once. q.mu.Lock() q.session = "" q.mu.Unlock() key, err = q.sessionKey(ctx) if err != nil { return Result{}, err } r, err = q.fetch(ctx, key, callsign) } return r, err } func (q *QRZ) sessionKey(ctx context.Context) (string, error) { q.mu.Lock() defer q.mu.Unlock() // QRZ sessions are valid ~24h; re-login defensively after 12h. if q.session != "" && time.Since(q.loggedAt) < 12*time.Hour { return q.session, nil } u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?username=%s;password=%s;agent=HamLog", url.QueryEscape(q.User), url.QueryEscape(q.Password)) body, err := q.get(ctx, u) if err != nil { return "", err } var resp qrzDB if err := xml.Unmarshal(body, &resp); err != nil { return "", fmt.Errorf("qrz: parse session: %w", err) } if resp.Session.Error != "" { return "", fmt.Errorf("qrz login: %s", resp.Session.Error) } if resp.Session.Key == "" { return "", fmt.Errorf("qrz: empty session key") } q.session = resp.Session.Key q.loggedAt = time.Now() return q.session, nil } var errQRZSessionExpired = fmt.Errorf("qrz: session expired") func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, error) { u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?s=%s;callsign=%s", url.QueryEscape(sessionKey), url.QueryEscape(callsign)) body, err := q.get(ctx, u) if err != nil { return Result{}, err } var resp qrzDB if err := xml.Unmarshal(body, &resp); err != nil { return Result{}, fmt.Errorf("qrz: parse callsign: %w", err) } if resp.Session.Error != "" { msg := strings.ToLower(resp.Session.Error) if strings.Contains(msg, "session") || strings.Contains(msg, "invalid") { return Result{}, errQRZSessionExpired } if strings.Contains(msg, "not found") { return Result{}, ErrNotFound } return Result{}, fmt.Errorf("qrz: %s", resp.Session.Error) } c := resp.Callsign if c.Call == "" { return Result{}, ErrNotFound } r := Result{ Callsign: strings.ToUpper(c.Call), Name: joinName(c.FName, c.Name), QTH: c.Addr2, Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country), State: strings.ToUpper(c.State), County: c.County, Country: c.Country, Grid: strings.ToUpper(c.Grid), Continent: strings.ToUpper(c.Continent), Email: c.Email, QSLVia: c.QSLMgr, } r.Lat, _ = strconv.ParseFloat(c.Lat, 64) r.Lon, _ = strconv.ParseFloat(c.Lon, 64) r.DXCC, _ = strconv.Atoi(c.DXCC) r.CQZ, _ = strconv.Atoi(c.CQZone) r.ITUZ, _ = strconv.Atoi(c.ITUZone) return r, nil } func (q *QRZ) get(ctx context.Context, u string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", u, nil) if err != nil { return nil, err } resp, err := q.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("qrz http: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return nil, fmt.Errorf("qrz http %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // ----- XML shapes ----- type qrzDB struct { XMLName xml.Name `xml:"QRZDatabase"` Session qrzSession `xml:"Session"` Callsign qrzCallsign `xml:"Callsign"` } type qrzSession struct { Key string `xml:"Key"` Error string `xml:"Error"` } type qrzCallsign struct { Call string `xml:"call"` FName string `xml:"fname"` Name string `xml:"name"` Addr1 string `xml:"addr1"` Addr2 string `xml:"addr2"` Zip string `xml:"zip"` State string `xml:"state"` County string `xml:"county"` Country string `xml:"country"` Grid string `xml:"grid"` Lat string `xml:"lat"` Lon string `xml:"lon"` DXCC string `xml:"dxcc"` CQZone string `xml:"cqzone"` ITUZone string `xml:"ituzone"` Continent string `xml:"cont"` Email string `xml:"email"` QSLMgr string `xml:"qslmgr"` } // composeQRZAddress builds a multi-line postal address from QRZ's separate // fields (street, city, zip, country) the same way Log4OM displays it. // addr1 is the street, addr2 is the city — skip addr1 when it duplicates // the city (common for users who only filled the city). func composeQRZAddress(addr1, addr2, zip, country string) string { addr1 = strings.TrimSpace(addr1) addr2 = strings.TrimSpace(addr2) zip = strings.TrimSpace(zip) country = strings.TrimSpace(country) var parts []string if addr1 != "" && !strings.EqualFold(addr1, addr2) { parts = append(parts, addr1) } if addr2 != "" { parts = append(parts, addr2) } if zip != "" { parts = append(parts, zip) } if country != "" { parts = append(parts, country) } return strings.Join(parts, ", ") } func joinName(first, last string) string { first = strings.TrimSpace(first) last = strings.TrimSpace(last) switch { case first != "" && last != "": return first + " " + last case first != "": return first default: return last } } func firstNonEmpty(s ...string) string { for _, v := range s { v = strings.TrimSpace(v) if v != "" { return v } } return "" }