Files
OpsLog/internal/lookup/hamqth.go
T
rouggy 2f5fd63afd fix(lookup): read the fields the providers were already sending
Audit of every field a lookup returns against what OpsLog can store, prompted
by a report that HamQTH was not fetching the email.

The email was never broken: pinned now against a captured HamQTH answer, it
parses and reaches the QSO. The reported callsign simply has no public address
on HamQTH, which the empty field could not distinguish from a fault — so the
"[email protected]" placeholder is gone. A greyed-out sample address in the one
field an operator checks to see whether the lookup found one reads as a value.

Real gaps found and closed:

- web: the qso table has had the column since migration 0003 and no provider
  mapping ever read it. HamQTH sends <web>.
- picture: Result.ImageURL was documented "QRZ only" because HamQTH's element
  is <picture>, not <image>. It was there all along.
- zip: sent by both providers, read by neither.
- adr_name: used only as a fallback when a record carries neither nick nor
  name. Deliberately NOT preferred over <nick> — a log wants the name the
  operator goes by on the air, "Igor", not "Igor Vladimirovich Getman".

The parse tests use a real captured payload, including the stray <div> advert
the server injects into its own XML.
2026-08-12 17:34:45 +02:00

214 lines
6.0 KiB
Go

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
}
return parseHamQTHSearch(body)
}
// parseHamQTHSearch turns one answer into a Result. Split out from the request
// so the field mapping can be checked against a captured payload — which is how
// the missing full name and picture were found.
func parseHamQTHSearch(body []byte) (Result, error) {
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
}
// <nick> is the name the operator goes BY on the air, which is what belongs
// in a log — "Igor", not "Igor Vladimirovich Getman". adr_name is the postal
// name and is used only when there is nothing else, so a record carrying
// neither nick nor name no longer resolves to blank.
name := strings.TrimSpace(s.Nick + " " + s.LastName)
if name == "" {
name = strings.TrimSpace(s.AdrName)
}
r := Result{
Callsign: strings.ToUpper(s.Callsign),
Name: name,
QTH: firstNonEmpty(s.QTH, s.AdrCity),
Address: s.AdrStreet1,
Zip: s.AdrZip,
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,
Web: s.Web,
ImageURL: s.Picture,
}
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 <adif>
CQ string `xml:"cq"`
ITU string `xml:"itu"`
Continent string `xml:"continent"`
Email string `xml:"email"`
QSLVia string `xml:"qsl_via"`
// AdrName is the full postal name. Many records carry it and no <name> at
// all, so building the name from <nick> + <name> silently kept the first
// name and dropped the rest.
AdrName string `xml:"adr_name"`
AdrZip string `xml:"adr_zip"`
// Picture is HamQTH's profile photo. Result.ImageURL was documented as "QRZ
// only" because this element was never read — QRZ calls the same thing
// <image>.
Picture string `xml:"picture"`
Web string `xml:"web"`
}