Files
OpsLog/internal/lookup/qrz.go
T
rouggy d0f06d9670 fix(lookup): QRZ Name is the first name, not first + surname
A log greets an operator; it does not address an envelope. "Robert Smith"
filled the Name field with something no one would ever send on the air, and it
is the first name that gets used when the contact is answered.

So fname alone, with the surname kept only as a last resort for a record that
has no first name at all — better a surname than an empty field. The nickname
option now falls back to the first name too, which is what it should have done
from the start.

joinName went with it: nothing composed a name any more.
2026-08-13 10:53:13 +02:00

257 lines
7.0 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
// PreferNickname takes QRZ's <nickname> over the composed first+last name
// when the operator has published one. It is the name they go BY on the air,
// which is what belongs in a log — HamQTH's <nick> is already used that way,
// and this brings QRZ into line for operators who want it.
PreferNickname bool
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: qrzName(q.PreferNickname, c.Nickname, 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"`
Nickname string `xml:"nickname"` // the name the operator goes by on the air
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 firstNonEmpty(s ...string) string {
for _, v := range s {
v = strings.TrimSpace(v)
if v != "" {
return v
}
}
return ""
}
// qrzName picks what goes in the log's Name field.
//
// The FIRST name only. A log greets an operator, it does not address an
// envelope: "Robert" is what goes out on the air, and "Robert Smith" filled the
// field with something no one would ever send. The surname stays available in
// the record; it simply is not the name of the contact.
//
// The nickname is taken ahead of it when the operator asked for that AND QRZ
// has one — a blank nickname must never blank the name, which is the whole
// reason this is a fallback rather than a swap.
//
// The surname is the last resort: a record with no first name at all is better
// answered with a surname than with nothing.
func qrzName(preferNickname bool, nickname, fname, name string) string {
if preferNickname {
if n := strings.TrimSpace(nickname); n != "" {
return n
}
}
if f := strings.TrimSpace(fname); f != "" {
return f
}
return strings.TrimSpace(name)
}