A DXpeditions tab holding the two feeds the DX world announces itself on: NG3K's ADXO (structured — dates, entity, calls, bands, modes, QSL route) and DX-World's headlines. What a logger can say that a news reader cannot is whether the operation is worth chasing, so every announcement is put through the SAME verdict the cluster paints on a spot — one badge, strongest wins, dimmed when the need is only a missing QSL — with an 'only what I need' filter. One click watches every callsign of an operation; the news headlines are mined for callsigns so they can be watched the same way. Parsers ported from DXHunter's and pinned with table tests against real feed text: the 'as PJ2/W2APF' mining, the DXCC-prefix-first normalisation without which no spot ever matches, both ADXO date forms, and the '160-6m' span whose unit is written once (DXHunter read that as 6m alone and lost the low end). Watchlist membership now announces itself app-wide, on the same card as a new version: the bindings emit watchlist:changed, so a call added from the cluster or this tab is confirmed where the operator is actually looking — the panel's own inline message never was.
547 lines
16 KiB
Go
547 lines
16 KiB
Go
// Package dxped reads the two feeds the DX world announces itself on.
|
||
//
|
||
// NG3K's ADXO is the STRUCTURED one: an RSS item per announced operation whose
|
||
// description is a fixed, dash-separated sentence — dates, entity, callsign,
|
||
// QSL route, source, then the operators/bands/modes prose. It is what a
|
||
// DXpedition list is actually made of.
|
||
//
|
||
// DX-World's feed is NEWS: WordPress posts with a headline and an excerpt. It
|
||
// carries no structure to act on, but the headline nearly always names the
|
||
// callsign, so the calls are mined from the title and the reader can act on
|
||
// them the same way (watchlist, chase status).
|
||
//
|
||
// Both are cached: the announcements change a few times a day, the news a few
|
||
// times an hour, and neither is worth a request per screen repaint.
|
||
package dxped
|
||
|
||
import (
|
||
"context"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
adxoURL = "https://www.ng3k.com/adxo.xml"
|
||
dxworldURL = "https://dx-world.net/feed/"
|
||
|
||
adxoTTL = 1 * time.Hour
|
||
dxworldTTL = 30 * time.Minute
|
||
)
|
||
|
||
// Activation is one announced operation, as ADXO describes it.
|
||
type Activation struct {
|
||
DXCC string `json:"dxcc"`
|
||
Callsign string `json:"callsign"` // display form; "A, B" when several
|
||
Calls []string `json:"calls"` // the individual callsigns, normalised
|
||
StartDate string `json:"start_date"`
|
||
EndDate string `json:"end_date"`
|
||
Bands []string `json:"bands"`
|
||
Modes []string `json:"modes"`
|
||
QSL string `json:"qsl"`
|
||
Operators string `json:"operators"`
|
||
Source string `json:"source"`
|
||
Link string `json:"link"`
|
||
Status string `json:"status"` // "active" | "upcoming"
|
||
}
|
||
|
||
// News is one DX-World post.
|
||
type News struct {
|
||
Title string `json:"title"`
|
||
Link string `json:"link"`
|
||
PubDate string `json:"pub_date"` // RFC3339, "" when unparseable
|
||
Excerpt string `json:"excerpt"`
|
||
Creator string `json:"creator"`
|
||
ImageURL string `json:"image_url"`
|
||
Tag string `json:"tag"` // NEWS / UPDATE / NEW ACTIVITY…
|
||
Calls []string `json:"calls"` // callsigns mined from the headline
|
||
}
|
||
|
||
// Manager holds both caches and fetches on demand.
|
||
type Manager struct {
|
||
mu sync.RWMutex
|
||
acts []Activation
|
||
actsAt time.Time
|
||
news []News
|
||
newsAt time.Time
|
||
client *http.Client
|
||
fetching sync.Mutex // one refresh at a time, whichever pane asked
|
||
}
|
||
|
||
func New() *Manager {
|
||
return &Manager{client: &http.Client{Timeout: 30 * time.Second}}
|
||
}
|
||
|
||
// Activations returns the cached announcements, refreshing when stale.
|
||
func (m *Manager) Activations(ctx context.Context) ([]Activation, error) {
|
||
m.mu.RLock()
|
||
fresh := time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||
out := m.acts
|
||
m.mu.RUnlock()
|
||
if fresh {
|
||
return out, nil
|
||
}
|
||
m.fetching.Lock()
|
||
defer m.fetching.Unlock()
|
||
// Someone else may have refreshed while we waited for the lock.
|
||
m.mu.RLock()
|
||
fresh = time.Since(m.actsAt) < adxoTTL && m.acts != nil
|
||
out = m.acts
|
||
m.mu.RUnlock()
|
||
if fresh {
|
||
return out, nil
|
||
}
|
||
acts, err := m.fetchADXO(ctx)
|
||
if err != nil {
|
||
// Stale beats empty: a feed that is down should not blank a list the
|
||
// operator was reading a minute ago.
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
return m.acts, err
|
||
}
|
||
m.mu.Lock()
|
||
m.acts, m.actsAt = acts, time.Now()
|
||
m.mu.Unlock()
|
||
return acts, nil
|
||
}
|
||
|
||
// News returns the cached DX-World posts, refreshing when stale.
|
||
func (m *Manager) News(ctx context.Context) ([]News, error) {
|
||
m.mu.RLock()
|
||
fresh := time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||
out := m.news
|
||
m.mu.RUnlock()
|
||
if fresh {
|
||
return out, nil
|
||
}
|
||
m.fetching.Lock()
|
||
defer m.fetching.Unlock()
|
||
m.mu.RLock()
|
||
fresh = time.Since(m.newsAt) < dxworldTTL && m.news != nil
|
||
out = m.news
|
||
m.mu.RUnlock()
|
||
if fresh {
|
||
return out, nil
|
||
}
|
||
news, err := m.fetchDXWorld(ctx)
|
||
if err != nil {
|
||
m.mu.RLock()
|
||
defer m.mu.RUnlock()
|
||
return m.news, err
|
||
}
|
||
m.mu.Lock()
|
||
m.news, m.newsAt = news, time.Now()
|
||
m.mu.Unlock()
|
||
return news, nil
|
||
}
|
||
|
||
// Invalidate drops both caches so the next read refetches.
|
||
func (m *Manager) Invalidate() {
|
||
m.mu.Lock()
|
||
m.actsAt, m.newsAt = time.Time{}, time.Time{}
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
func (m *Manager) get(ctx context.Context, url string) ([]byte, error) {
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("User-Agent", "OpsLog")
|
||
resp, err := m.client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||
}
|
||
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||
}
|
||
|
||
// ── ADXO ────────────────────────────────────────────────────────────────
|
||
|
||
type rssItem struct {
|
||
Title string `xml:"title"`
|
||
Description string `xml:"description"`
|
||
Link string `xml:"link"`
|
||
PubDate string `xml:"pubDate"`
|
||
Creator string `xml:"creator"`
|
||
Enclosure struct {
|
||
URL string `xml:"url,attr"`
|
||
} `xml:"enclosure"`
|
||
MediaContent struct {
|
||
URL string `xml:"url,attr"`
|
||
} `xml:"content"`
|
||
}
|
||
|
||
type rssFeed struct {
|
||
Items []rssItem `xml:"channel>item"`
|
||
}
|
||
|
||
func (m *Manager) fetchADXO(ctx context.Context) ([]Activation, error) {
|
||
body, err := m.get(ctx, adxoURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("adxo: %w", err)
|
||
}
|
||
var feed rssFeed
|
||
if err := xml.Unmarshal(body, &feed); err != nil {
|
||
return nil, fmt.Errorf("adxo: parse: %w", err)
|
||
}
|
||
now := time.Now()
|
||
out := make([]Activation, 0, len(feed.Items))
|
||
for _, it := range feed.Items {
|
||
a := parseActivation(it.Description, it.Link)
|
||
if a == nil {
|
||
continue
|
||
}
|
||
a.Status = activationStatus(a.StartDate, a.EndDate, now)
|
||
if a.Status == "ended" {
|
||
continue // the list is about what is on or coming
|
||
}
|
||
out = append(out, *a)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
var spaceRe = regexp.MustCompile(`\s+`)
|
||
|
||
// parseActivation reads one ADXO description. Its shape is fixed and has been
|
||
// for twenty years:
|
||
//
|
||
// "Feb 17-Mar 30, 2026 -- Entity -- CALL -- QSL: route -- Source: who (date)
|
||
// -- By ops; bands; modes; notes"
|
||
func parseActivation(desc, link string) *Activation {
|
||
desc = spaceRe.ReplaceAllString(strings.NewReplacer("\n", " ", "\r", " ").Replace(desc), " ")
|
||
desc = strings.TrimSpace(html.UnescapeString(desc))
|
||
if desc == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(desc, " -- ")
|
||
if len(parts) < 3 {
|
||
return nil
|
||
}
|
||
a := &Activation{Link: link}
|
||
a.StartDate, a.EndDate = parseDateRange(strings.TrimSpace(parts[0]))
|
||
a.DXCC = strings.TrimSpace(parts[1])
|
||
a.Callsign = strings.TrimSpace(parts[2])
|
||
|
||
for _, p := range parts[3:] {
|
||
p = strings.TrimSpace(p)
|
||
switch {
|
||
case strings.HasPrefix(p, "QSL:"):
|
||
a.QSL = strings.TrimSpace(strings.TrimPrefix(p, "QSL:"))
|
||
case strings.HasPrefix(p, "Source:"):
|
||
a.Source = strings.TrimSpace(strings.TrimPrefix(p, "Source:"))
|
||
}
|
||
}
|
||
|
||
// The tail carries "By <ops>; <bands>; <modes>; <notes>".
|
||
tail := parts[len(parts)-1]
|
||
if i := strings.Index(tail, "By "); i >= 0 {
|
||
sub := strings.Split(tail[i+3:], ";")
|
||
if len(sub) > 0 {
|
||
a.Operators = strings.TrimSpace(sub[0])
|
||
}
|
||
if len(sub) > 1 {
|
||
a.Bands = parseBands(sub[1])
|
||
}
|
||
if len(sub) > 2 {
|
||
a.Modes = parseModes(sub[2])
|
||
}
|
||
}
|
||
|
||
// The callsign field often holds only the PREFIX; the real calls hide in the
|
||
// operators prose as "W2APF as PJ2/W2APF". Prefer those when present.
|
||
if calls := callsAfterAs(a.Operators); len(calls) > 0 {
|
||
prefix := strings.ToUpper(strings.TrimSpace(parts[2]))
|
||
for i := range calls {
|
||
calls[i] = normalizeCall(calls[i], prefix)
|
||
}
|
||
a.Calls = calls
|
||
a.Callsign = strings.Join(calls, ", ")
|
||
} else if c := strings.ToUpper(a.Callsign); plausibleCall(c) {
|
||
a.Calls = []string{c}
|
||
}
|
||
return a
|
||
}
|
||
|
||
// parseDateRange handles the two forms ADXO writes: "Feb 17-Mar 30, 2026" and
|
||
// "Mar 3-20, 2026" (the month carried over).
|
||
var (
|
||
fullRangeRe = regexp.MustCompile(`(?i)(\w+ \d+)\s*-\s*(\w+ \d+),\s*(\d{4})`)
|
||
shortRangeRe = regexp.MustCompile(`(?i)(\w+) (\d+)\s*-\s*(\d+),\s*(\d{4})`)
|
||
singleDayRe = regexp.MustCompile(`(?i)(\w+ \d+),\s*(\d{4})`)
|
||
)
|
||
|
||
func parseDateRange(s string) (start, end string) {
|
||
if m := fullRangeRe.FindStringSubmatch(s); m != nil {
|
||
return m[1] + ", " + m[3], m[2] + ", " + m[3]
|
||
}
|
||
if m := shortRangeRe.FindStringSubmatch(s); m != nil {
|
||
return m[1] + " " + m[2] + ", " + m[4], m[1] + " " + m[3] + ", " + m[4]
|
||
}
|
||
if m := singleDayRe.FindStringSubmatch(s); m != nil {
|
||
return m[1] + ", " + m[2], m[1] + ", " + m[2]
|
||
}
|
||
return s, s
|
||
}
|
||
|
||
func parseADXODate(s string) (time.Time, bool) {
|
||
for _, layout := range []string{"Jan 2, 2006", "January 2, 2006"} {
|
||
if t, err := time.Parse(layout, strings.TrimSpace(s)); err == nil {
|
||
return t, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func activationStatus(start, end string, now time.Time) string {
|
||
s, okS := parseADXODate(start)
|
||
e, okE := parseADXODate(end)
|
||
if !okS || !okE {
|
||
return "upcoming" // unreadable dates: keep it, an operator can read them
|
||
}
|
||
switch {
|
||
case now.Before(s):
|
||
return "upcoming"
|
||
// The end date is a DAY, so an operation is on until that day is over.
|
||
case now.After(e.Add(24 * time.Hour)):
|
||
return "ended"
|
||
default:
|
||
return "active"
|
||
}
|
||
}
|
||
|
||
// callsAfterAs mines "…as CALL…" out of the operators prose:
|
||
//
|
||
// "W2APF as PJ2/W2APF" → PJ2/W2APF
|
||
// "SQ2RAD as VP2EAD, M0PLX as VP2ELX" → VP2EAD, VP2ELX
|
||
var asCallRe = regexp.MustCompile(`(?i)\bas\s+([A-Z0-9]+(?:/[A-Z0-9]+)*)`)
|
||
|
||
func callsAfterAs(operators string) []string {
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
for _, m := range asCallRe.FindAllStringSubmatch(operators, -1) {
|
||
c := strings.ToUpper(strings.TrimSpace(m[1]))
|
||
if !plausibleCall(c) || seen[c] {
|
||
continue
|
||
}
|
||
seen[c] = true
|
||
out = append(out, c)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// normalizeCall puts the DXCC prefix first: a cluster spot says JD1/JG8NQJ, and
|
||
// matching the log against JG8NQJ/JD1 would find nothing.
|
||
func normalizeCall(call, dxccPrefix string) string {
|
||
if dxccPrefix == "" || !strings.Contains(call, "/") {
|
||
return call
|
||
}
|
||
left, right, _ := strings.Cut(call, "/")
|
||
if strings.HasPrefix(right, dxccPrefix) && !strings.HasPrefix(left, dxccPrefix) {
|
||
return right + "/" + left
|
||
}
|
||
return call
|
||
}
|
||
|
||
var (
|
||
// A span carries its unit only once — "160-6m" is the commonest way ADXO
|
||
// states coverage, and reading it as "6m" alone loses the whole low end.
|
||
bandRe = regexp.MustCompile(`(?i)\b(?:(\d{1,4})\s*-\s*)?(\d{1,4})\s*(m|cm)\b`)
|
||
// The modes ADXO actually writes. A list beats a pattern here: "FT8" and
|
||
// "SSB" have no shape in common, and inventing one invites "QSL" as a mode.
|
||
knownModes = []string{"SSB", "CW", "FT8", "FT4", "RTTY", "PSK", "SSTV", "AM", "FM", "JT65", "JS8", "Q65", "MSK144", "DIGI", "DATA"}
|
||
)
|
||
|
||
func parseBands(s string) []string {
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
add := func(num, unit string) {
|
||
if num == "" {
|
||
return
|
||
}
|
||
b := strings.ToLower(num + unit)
|
||
if seen[b] {
|
||
return
|
||
}
|
||
seen[b] = true
|
||
out = append(out, b)
|
||
}
|
||
// A span keeps only its endpoints: ADXO states coverage in prose, and the
|
||
// two ends are the part it gives reliably.
|
||
for _, m := range bandRe.FindAllStringSubmatch(s, -1) {
|
||
add(m[1], m[3])
|
||
add(m[2], m[3])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseModes(s string) []string {
|
||
up := strings.ToUpper(s)
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
for _, mode := range knownModes {
|
||
if seen[mode] {
|
||
continue
|
||
}
|
||
if regexp.MustCompile(`\b` + mode + `\b`).MatchString(up) {
|
||
seen[mode] = true
|
||
out = append(out, mode)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ── DX-World ────────────────────────────────────────────────────────────
|
||
|
||
var (
|
||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||
tagPrefixRe = regexp.MustCompile(`(?i)^\s*\[([^\]]+)\]\s*`)
|
||
)
|
||
|
||
func stripHTML(s string) string {
|
||
s = htmlTagRe.ReplaceAllString(s, " ")
|
||
s = html.UnescapeString(s)
|
||
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
|
||
}
|
||
|
||
func (m *Manager) fetchDXWorld(ctx context.Context) ([]News, error) {
|
||
body, err := m.get(ctx, dxworldURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dx-world: %w", err)
|
||
}
|
||
var feed rssFeed
|
||
if err := xml.Unmarshal(body, &feed); err != nil {
|
||
return nil, fmt.Errorf("dx-world: parse: %w", err)
|
||
}
|
||
out := make([]News, 0, len(feed.Items))
|
||
for _, it := range feed.Items {
|
||
title := stripHTML(it.Title)
|
||
tag, title := splitTag(title)
|
||
n := News{
|
||
Title: title,
|
||
Link: strings.TrimSpace(it.Link),
|
||
Creator: strings.TrimSpace(it.Creator),
|
||
Tag: tag,
|
||
Calls: callsInHeadline(title),
|
||
}
|
||
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
|
||
n.PubDate = t.UTC().Format(time.RFC3339)
|
||
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
|
||
n.PubDate = t.UTC().Format(time.RFC3339)
|
||
}
|
||
ex := stripHTML(it.Description)
|
||
if t2, rest := splitTag(ex); t2 != "" {
|
||
if n.Tag == "" {
|
||
n.Tag = t2
|
||
}
|
||
ex = rest
|
||
}
|
||
n.Excerpt = truncateRunes(ex, 400)
|
||
if u := strings.TrimSpace(it.Enclosure.URL); u != "" {
|
||
n.ImageURL = u
|
||
} else if u := strings.TrimSpace(it.MediaContent.URL); u != "" {
|
||
n.ImageURL = u
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// splitTag pulls the leading "[UPDATE]" DX-World puts on most posts.
|
||
func splitTag(s string) (tag, rest string) {
|
||
m := tagPrefixRe.FindStringSubmatchIndex(s)
|
||
if m == nil {
|
||
return "", s
|
||
}
|
||
tag = strings.ToUpper(strings.TrimSpace(s[m[2]:m[3]]))
|
||
rest = strings.TrimSpace(s[m[1]:])
|
||
rest = strings.TrimPrefix(strings.TrimPrefix(rest, "– "), "- ")
|
||
return tag, strings.TrimSpace(rest)
|
||
}
|
||
|
||
func truncateRunes(s string, max int) string {
|
||
r := []rune(s)
|
||
if len(r) <= max {
|
||
return s
|
||
}
|
||
return strings.TrimSpace(string(r[:max])) + "…"
|
||
}
|
||
|
||
// callsInHeadline mines callsigns out of a news headline — "3B7M, St Brandon"
|
||
// or "TX5S team lands". The reader can then chase or watch them, which is the
|
||
// whole reason a news feed sits next to the announcements.
|
||
func callsInHeadline(title string) []string {
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
for _, tok := range strings.FieldsFunc(title, func(r rune) bool {
|
||
return !(r == '/' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z'))
|
||
}) {
|
||
c := strings.ToUpper(tok)
|
||
if !plausibleCall(c) || seen[c] {
|
||
continue
|
||
}
|
||
seen[c] = true
|
||
out = append(out, c)
|
||
}
|
||
return out
|
||
}
|
||
|
||
var (
|
||
bandTokenRe = regexp.MustCompile(`^\d{1,4}(M|CM)$`)
|
||
// Jargon shaped exactly like a callsign. Every one of these was seen in a
|
||
// real headline before it earned its place here.
|
||
notACall = map[string]bool{
|
||
"FT8": true, "FT4": true, "JT65": true, "JT9": true, "JS8": true, "Q65": true,
|
||
"MSK144": true, "PSK31": true, "SSTV": true, "OQRS": true, "LOTW": true,
|
||
"IOTA": true, "SOTA": true, "POTA": true, "WWFF": true, "DXCC": true,
|
||
"CQWW": true, "CQWPX": true, "ARRL": true, "3D": true, "4K": true,
|
||
}
|
||
)
|
||
|
||
// plausibleCall keeps tokens shaped like an amateur callsign: 3–12 characters
|
||
// of A–Z/0–9 (with optional /prefix or /suffix), at least one letter AND one
|
||
// digit, and a letter somewhere after the first digit — which is what separates
|
||
// a callsign from a band or a year.
|
||
func plausibleCall(s string) bool {
|
||
s = strings.ToUpper(strings.TrimSpace(s))
|
||
if len(s) < 3 || len(s) > 12 || notACall[s] || bandTokenRe.MatchString(s) {
|
||
return false
|
||
}
|
||
// Judge the longest part — the real call in "PJ2/W2APF" either way.
|
||
base := s
|
||
if strings.Contains(s, "/") {
|
||
base = ""
|
||
for _, p := range strings.Split(s, "/") {
|
||
if len(p) > len(base) {
|
||
base = p
|
||
}
|
||
}
|
||
}
|
||
var hasLetter, hasDigit, letterAfterDigit bool
|
||
seenDigit := false
|
||
for _, r := range base {
|
||
switch {
|
||
case r >= 'A' && r <= 'Z':
|
||
hasLetter = true
|
||
if seenDigit {
|
||
letterAfterDigit = true
|
||
}
|
||
case r >= '0' && r <= '9':
|
||
hasDigit = true
|
||
seenDigit = true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return hasLetter && hasDigit && letterAfterDigit
|
||
}
|