Files
OpsLog/internal/lookup/qrz.go
T
2026-05-26 01:14:43 +02:00

238 lines
5.9 KiB
Go

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,
ImageURL: strings.TrimSpace(c.Image),
}
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"`
Image string `xml:"image"` // direct URL to the profile picture (subscribers only on QRZ)
}
// 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 ""
}