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.
257 lines
7.0 KiB
Go
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)
|
|
}
|