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.
This commit is contained in:
2026-08-12 17:34:45 +02:00
parent 4f3c3edaee
commit 2f5fd63afd
9 changed files with 202 additions and 15 deletions
+29 -1
View File
@@ -94,6 +94,13 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
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)
@@ -112,11 +119,20 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
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: strings.TrimSpace(s.Nick + " " + s.LastName),
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),
@@ -124,6 +140,8 @@ func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result,
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)
@@ -182,4 +200,14 @@ type hamqthSearch struct {
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"`
}