Add SPC provider parsing helpers

This commit is contained in:
2026-06-11 00:06:55 +00:00
parent 0f20d1e4cb
commit b2c429983c
18 changed files with 979 additions and 0 deletions

View File

@@ -0,0 +1,158 @@
package spc
import (
"fmt"
"html"
"regexp"
"strings"
"time"
)
var (
scriptBlockRE = regexp.MustCompile(`(?is)<script\b[^>]*>.*?</script>`)
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
)
// DiscussionText contains parsed text from an SPC print page.
type DiscussionText struct {
ProductTitle string
Headline string
Summary string
Discussion string
UpdatedAt *time.Time
}
// ExtractProductText extracts and cleans the first useful preformatted SPC
// product text block from a print page.
func ExtractProductText(rawHTML string) (string, error) {
matches := preBlockRE.FindAllStringSubmatch(rawHTML, -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
text := cleanHTMLText(match[1])
if strings.TrimSpace(text) != "" {
return text, nil
}
}
return "", fmt.Errorf("no useful pre block found")
}
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
// product text.
func ParseDiscussionText(text string) DiscussionText {
text = trimBlankLines(normalizeNewlines(text))
title := ParseProductTitle(text)
return DiscussionText{
ProductTitle: title,
Headline: title,
Summary: ExtractSummary(text),
Discussion: text,
UpdatedAt: ParseUpdatedTimestamp(text),
}
}
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
func ParseUpdatedTimestamp(text string) *time.Time {
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
if len(match) != 2 {
return nil
}
return parseUpdatedValue(match[1])
}
// ParseProductTitle returns the first non-empty product line from cleaned text.
func ParseProductTitle(text string) string {
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "Updated:") {
continue
}
return line
}
return ""
}
// ParseHeadline returns the human-facing headline from cleaned text.
func ParseHeadline(text string) string {
return ParseProductTitle(text)
}
// ExtractSummary returns text under the ...SUMMARY... section through the next
// SPC section heading.
func ExtractSummary(text string) string {
lines := strings.Split(normalizeNewlines(text), "\n")
start := -1
for i, line := range lines {
if strings.EqualFold(strings.TrimSpace(line), "...SUMMARY...") {
start = i + 1
break
}
}
if start < 0 {
return ""
}
var out []string
for _, line := range lines[start:] {
if sectionRE.MatchString(line) {
break
}
out = append(out, line)
}
return trimBlankLines(strings.Join(out, "\n"))
}
func cleanHTMLText(raw string) string {
raw = scriptBlockRE.ReplaceAllString(raw, "")
raw = tagRE.ReplaceAllString(raw, "")
raw = html.UnescapeString(raw)
raw = normalizeNewlines(raw)
return trimBlankLines(raw)
}
func normalizeNewlines(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return text
}
func trimBlankLines(text string) string {
lines := strings.Split(normalizeNewlines(text), "\n")
start := 0
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
start++
}
end := len(lines)
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
return strings.Join(lines[start:end], "\n")
}
func parseUpdatedValue(value string) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if t := parseOptionalISOTimestamp(value); t != nil {
return t
}
for _, layout := range []string{
"1504 UTC Mon Jan 2 2006",
"1504Z Mon Jan 2 2006",
"3:04 PM UTC Mon Jan 2 2006",
time.RFC1123,
time.RFC1123Z,
} {
t, err := time.Parse(layout, value)
if err == nil {
tt := t.UTC()
return &tt
}
}
return nil
}

View File

@@ -0,0 +1,90 @@
package spc
import (
"strings"
"testing"
"time"
)
func TestExtractProductTextCleansPreBlock(t *testing.T) {
raw := string(readTestFile(t, "day1_prt.html"))
got, err := ExtractProductText(raw)
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
}
if strings.Contains(got, "<script") || strings.Contains(got, "<pre") {
t.Fatalf("ExtractProductText() retained HTML: %q", got)
}
if strings.Contains(got, "ignore me") {
t.Fatalf("ExtractProductText() retained script content: %q", got)
}
if !strings.Contains(got, "Day 1 Convective Outlook") {
t.Fatalf("ExtractProductText() missing headline: %q", got)
}
if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
}
}
func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
text, err := ExtractProductText(string(readTestFile(t, "day1_prt.html")))
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
}
got := ParseDiscussionText(text)
if got.ProductTitle != "Day 1 Convective Outlook" {
t.Fatalf("ProductTitle = %q", got.ProductTitle)
}
if got.Headline != "Day 1 Convective Outlook" {
t.Fatalf("Headline = %q", got.Headline)
}
wantSummary := "Severe thunderstorms are possible across parts of the central Plains\nand mid Mississippi Valley this afternoon and evening."
if got.Summary != wantSummary {
t.Fatalf("Summary = %q, want %q", got.Summary, wantSummary)
}
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
t.Fatalf("Discussion missing full text: %q", got.Discussion)
}
wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
}
}
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
}
got := ParseDiscussionText(text)
if !strings.Contains(got.Headline, "CORR 1") {
t.Fatalf("Headline = %q, want correction marker", got.Headline)
}
if !strings.Contains(got.Discussion, "CORR 1") {
t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
}
if got.UpdatedAt != nil {
t.Fatalf("UpdatedAt = %v, want nil", got.UpdatedAt)
}
}
func TestParseUpdatedTimestampReturnsNilWhenAbsent(t *testing.T) {
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
if err != nil {
t.Fatalf("ExtractProductText() error = %v", err)
}
if got := ParseUpdatedTimestamp(text); got != nil {
t.Fatalf("ParseUpdatedTimestamp() = %v, want nil", got)
}
}
func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
got := ParseUpdatedTimestamp("Updated: 1945 UTC Thu Jun 11 2026")
want := time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)
if got == nil || !got.Equal(want) {
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
}
}

View File

@@ -0,0 +1,8 @@
// Package spc contains deterministic helper code for Storm Prediction Center
// products used by sources and normalizers.
//
// Rules:
// - No network I/O here.
// - Keep helpers deterministic and easy to unit test.
// - Preserve upstream payload fragments needed for canonical mapping.
package spc

View File

@@ -0,0 +1,105 @@
package spc
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// GeoJSONFeatureCollection is the minimal SPC outlook FeatureCollection shape
// needed by weatherfeeder.
type GeoJSONFeatureCollection struct {
Type string `json:"type"`
Features []GeoJSONFeature `json:"features"`
}
// GeoJSONFeature preserves typed SPC properties and compact raw geometry.
type GeoJSONFeature struct {
Type string `json:"type"`
Properties GeoJSONProperties `json:"properties"`
Geometry json.RawMessage `json:"geometry"`
}
// GeoJSONProperties contains the SPC fields used by canonical mapping.
type GeoJSONProperties struct {
ValidISO string `json:"VALID_ISO"`
ExpireISO string `json:"EXPIRE_ISO"`
IssueISO string `json:"ISSUE_ISO"`
Forecaster string `json:"FORECASTER"`
Label string `json:"LABEL"`
Label2 string `json:"LABEL2"`
DN *int `json:"DN"`
}
// DecodeGeoJSON decodes an SPC GeoJSON outlook product and compacts feature
// geometry JSON for stable downstream storage.
func DecodeGeoJSON(raw []byte) (GeoJSONFeatureCollection, error) {
var collection GeoJSONFeatureCollection
if err := json.Unmarshal(raw, &collection); err != nil {
return GeoJSONFeatureCollection{}, fmt.Errorf("decode geojson: %w", err)
}
for i := range collection.Features {
geom, err := compactJSON(collection.Features[i].Geometry)
if err != nil {
return GeoJSONFeatureCollection{}, fmt.Errorf("features[%d].geometry: %w", i, err)
}
collection.Features[i].Geometry = geom
}
return collection, nil
}
func (p *GeoJSONProperties) UnmarshalJSON(raw []byte) error {
type alias GeoJSONProperties
var aux struct {
alias
DN any `json:"DN"`
}
if err := json.Unmarshal(raw, &aux); err != nil {
return err
}
*p = GeoJSONProperties(aux.alias)
dn, err := parseSeverityRank(aux.DN)
if err != nil {
return err
}
p.DN = dn
return nil
}
func parseSeverityRank(value any) (*int, error) {
switch v := value.(type) {
case nil:
return nil, nil
case float64:
rank := int(v)
if float64(rank) != v {
return nil, fmt.Errorf("DN must be an integer, got %v", v)
}
return &rank, nil
case string:
v = strings.TrimSpace(v)
if v == "" {
return nil, nil
}
rank, err := strconv.Atoi(v)
if err != nil {
return nil, fmt.Errorf("DN must be an integer, got %q", v)
}
return &rank, nil
default:
return nil, fmt.Errorf("DN must be an integer or string, got %T", value)
}
}
func compactJSON(raw json.RawMessage) (json.RawMessage, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("missing")
}
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
return nil, err
}
return json.RawMessage(buf.Bytes()), nil
}

View File

@@ -0,0 +1,90 @@
package spc
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDecodeGeoJSONExposesSPCPropertiesAndCompactGeometry(t *testing.T) {
raw := readTestFile(t, "day1_cat.geojson")
got, err := DecodeGeoJSON(raw)
if err != nil {
t.Fatalf("DecodeGeoJSON() error = %v", err)
}
if got.Type != "FeatureCollection" {
t.Fatalf("Type = %q, want FeatureCollection", got.Type)
}
if len(got.Features) != 1 {
t.Fatalf("Features length = %d, want 1", len(got.Features))
}
feature := got.Features[0]
props := feature.Properties
if props.ValidISO != "2026-06-11T13:00:00Z" {
t.Fatalf("VALID_ISO = %q", props.ValidISO)
}
if props.ExpireISO != "2026-06-12T12:00:00Z" {
t.Fatalf("EXPIRE_ISO = %q", props.ExpireISO)
}
if props.IssueISO != "2026-06-11T12:34:56Z" {
t.Fatalf("ISSUE_ISO = %q", props.IssueISO)
}
if props.Forecaster != "SMITH" {
t.Fatalf("FORECASTER = %q", props.Forecaster)
}
if props.Label != "SLGT" {
t.Fatalf("LABEL = %q", props.Label)
}
if props.Label2 != "Slight Risk" {
t.Fatalf("LABEL2 = %q", props.Label2)
}
if props.DN == nil || *props.DN != 3 {
t.Fatalf("DN = %v, want 3", props.DN)
}
wantGeometry := `{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}`
if string(feature.Geometry) != wantGeometry {
t.Fatalf("Geometry = %s, want %s", feature.Geometry, wantGeometry)
}
if strings.Contains(string(feature.Geometry), "\n") || strings.Contains(string(feature.Geometry), " ") {
t.Fatalf("Geometry is not compact: %q", feature.Geometry)
}
}
func TestDecodeGeoJSONParsesSeverityRankString(t *testing.T) {
raw := readTestFile(t, "day2_torn.geojson")
got, err := DecodeGeoJSON(raw)
if err != nil {
t.Fatalf("DecodeGeoJSON() error = %v", err)
}
props := got.Features[0].Properties
if props.DN == nil || *props.DN != 5 {
t.Fatalf("DN = %v, want 5", props.DN)
}
}
func TestParseISOTimestampTrimsAndReturnsUTC(t *testing.T) {
got, err := ParseISOTimestamp(" 2026-06-11T12:34:56Z ")
if err != nil {
t.Fatalf("ParseISOTimestamp() error = %v", err)
}
want := time.Date(2026, 6, 11, 12, 34, 56, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("ParseISOTimestamp() = %s, want %s", got, want)
}
}
func readTestFile(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("testdata", name)
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return raw
}

View File

@@ -0,0 +1,89 @@
package spc
import "fmt"
const (
OutlookTypeCategorical = "categorical"
OutlookTypeTornado = "tornado"
OutlookTypeHail = "hail"
OutlookTypeWind = "wind"
)
// GeoJSONProduct describes one required SPC convective outlook GeoJSON product.
type GeoJSONProduct struct {
Key string
Day int
OutlookType string
URL string
}
// DiscussionProduct describes one required SPC convective outlook print page.
type DiscussionProduct struct {
Key string
Day int
URL string
}
var geoJSONProducts = []GeoJSONProduct{
{Key: "day1_categorical", Day: 1, OutlookType: OutlookTypeCategorical, URL: "https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson"},
{Key: "day1_tornado", Day: 1, OutlookType: OutlookTypeTornado, URL: "https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson"},
{Key: "day1_hail", Day: 1, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day1otlk_hail.nolyr.geojson"},
{Key: "day1_wind", Day: 1, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day1otlk_wind.nolyr.geojson"},
{Key: "day2_categorical", Day: 2, OutlookType: OutlookTypeCategorical, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_cat.nolyr.geojson"},
{Key: "day2_tornado", Day: 2, OutlookType: OutlookTypeTornado, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_torn.nolyr.geojson"},
{Key: "day2_hail", Day: 2, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson"},
{Key: "day2_wind", Day: 2, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson"},
{Key: "day3_categorical", Day: 3, OutlookType: OutlookTypeCategorical, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson"},
{Key: "day3_tornado", Day: 3, OutlookType: OutlookTypeTornado, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson"},
{Key: "day3_hail", Day: 3, OutlookType: OutlookTypeHail, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson"},
{Key: "day3_wind", Day: 3, OutlookType: OutlookTypeWind, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson"},
}
var discussionProducts = []DiscussionProduct{
{Key: "day1", Day: 1, URL: "https://www.spc.noaa.gov/products/outlook/day1otlk_prt.html"},
{Key: "day2", Day: 2, URL: "https://www.spc.noaa.gov/products/outlook/day2otlk_prt.html"},
{Key: "day3", Day: 3, URL: "https://www.spc.noaa.gov/products/outlook/day3otlk_prt.html"},
}
// GeoJSONProducts returns the required SPC convective outlook GeoJSON products
// in stable day/type order.
func GeoJSONProducts() []GeoJSONProduct {
out := make([]GeoJSONProduct, len(geoJSONProducts))
copy(out, geoJSONProducts)
return out
}
// DiscussionProducts returns the required SPC convective outlook print pages in
// stable day order.
func DiscussionProducts() []DiscussionProduct {
out := make([]DiscussionProduct, len(discussionProducts))
copy(out, discussionProducts)
return out
}
// GeoJSONProductByKey returns product metadata for a configured product key.
func GeoJSONProductByKey(key string) (GeoJSONProduct, bool) {
for _, product := range geoJSONProducts {
if product.Key == key {
return product, true
}
}
return GeoJSONProduct{}, false
}
// DiscussionProductByKey returns discussion metadata for a configured day key.
func DiscussionProductByKey(key string) (DiscussionProduct, bool) {
for _, product := range discussionProducts {
if product.Key == key {
return product, true
}
}
return DiscussionProduct{}, false
}
func validateProductDay(day int) error {
if day < 1 || day > 3 {
return fmt.Errorf("day must be 1, 2, or 3, got %d", day)
}
return nil
}

View File

@@ -0,0 +1,56 @@
package spc
import "testing"
func TestGeoJSONProductsStableOrder(t *testing.T) {
got := GeoJSONProducts()
if len(got) != 12 {
t.Fatalf("GeoJSONProducts() length = %d, want 12", len(got))
}
wantKeys := []string{
"day1_categorical",
"day1_tornado",
"day1_hail",
"day1_wind",
"day2_categorical",
"day2_tornado",
"day2_hail",
"day2_wind",
"day3_categorical",
"day3_tornado",
"day3_hail",
"day3_wind",
}
for i, want := range wantKeys {
if got[i].Key != want {
t.Fatalf("GeoJSONProducts()[%d].Key = %q, want %q", i, got[i].Key, want)
}
if err := validateProductDay(got[i].Day); err != nil {
t.Fatalf("GeoJSONProducts()[%d].Day invalid: %v", i, err)
}
if got[i].URL == "" {
t.Fatalf("GeoJSONProducts()[%d].URL is empty", i)
}
}
}
func TestDiscussionProductsStableOrder(t *testing.T) {
got := DiscussionProducts()
if len(got) != 3 {
t.Fatalf("DiscussionProducts() length = %d, want 3", len(got))
}
wantKeys := []string{"day1", "day2", "day3"}
for i, want := range wantKeys {
if got[i].Key != want {
t.Fatalf("DiscussionProducts()[%d].Key = %q, want %q", i, got[i].Key, want)
}
if got[i].Day != i+1 {
t.Fatalf("DiscussionProducts()[%d].Day = %d, want %d", i, got[i].Day, i+1)
}
if got[i].URL == "" {
t.Fatalf("DiscussionProducts()[%d].URL is empty", i)
}
}
}

View File

@@ -0,0 +1,45 @@
package spc
import (
"encoding/json"
"time"
)
// RawConvectiveOutlookBundle is the provider payload shape for SPC convective
// outlook fetch bundles.
type RawConvectiveOutlookBundle struct {
LocationID string `json:"locationId,omitempty"`
LocationName string `json:"locationName,omitempty"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
FetchedAt time.Time `json:"fetchedAt"`
Products []RawOutlookProduct `json:"products"`
Discussions []RawDiscussionPage `json:"discussions"`
RSS *RawRSSFeed `json:"rss,omitempty"`
}
// RawOutlookProduct contains one fetched SPC GeoJSON product.
type RawOutlookProduct struct {
Key string `json:"key"`
Day int `json:"day"`
OutlookType string `json:"outlookType"`
URL string `json:"url"`
FetchedAt time.Time `json:"fetchedAt"`
Body json.RawMessage `json:"body"`
}
// RawDiscussionPage contains one fetched SPC print page.
type RawDiscussionPage struct {
Key string `json:"key"`
Day int `json:"day"`
URL string `json:"url"`
FetchedAt time.Time `json:"fetchedAt"`
Body string `json:"body"`
}
// RawRSSFeed contains optional fetched SPC RSS metadata.
type RawRSSFeed struct {
URL string `json:"url"`
FetchedAt time.Time `json:"fetchedAt"`
Body string `json:"body"`
}

View File

@@ -0,0 +1,51 @@
package spc
import (
"encoding/json"
"testing"
"time"
)
func TestRawConvectiveOutlookBundleJSONShape(t *testing.T) {
fetchedAt := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
bundle := RawConvectiveOutlookBundle{
LocationID: "stl",
LocationName: "St. Louis, MO",
Latitude: 38.6239,
Longitude: -90.3571,
FetchedAt: fetchedAt,
Products: []RawOutlookProduct{{
Key: "day1_categorical",
Day: 1,
OutlookType: OutlookTypeCategorical,
URL: "https://example.invalid/day1.geojson",
FetchedAt: fetchedAt,
Body: json.RawMessage(`{"type":"FeatureCollection","features":[]}`),
}},
Discussions: []RawDiscussionPage{{
Key: "day1",
Day: 1,
URL: "https://example.invalid/day1.html",
FetchedAt: fetchedAt,
Body: "Day 1 Convective Outlook",
}},
}
raw, err := json.Marshal(bundle)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
for _, key := range []string{"locationId", "locationName", "latitude", "longitude", "fetchedAt", "products", "discussions"} {
if _, ok := got[key]; !ok {
t.Fatalf("marshaled bundle missing key %q in %s", key, raw)
}
}
if _, ok := got["rss"]; ok {
t.Fatalf("marshaled bundle included empty rss: %s", raw)
}
}

View File

@@ -0,0 +1,81 @@
package spc
import (
"encoding/xml"
"fmt"
"strings"
"time"
)
// RSSFeed is a minimal view of the optional SPC RSS feed.
type RSSFeed struct {
Title string
Link string
Description string
LastBuildDate *time.Time
Items []RSSItem
}
// RSSItem is a minimal view of one optional SPC RSS item.
type RSSItem struct {
Title string
Link string
Description string
PubDate string
GUID string
}
// ParseRSSFeed decodes supplemental SPC RSS metadata.
func ParseRSSFeed(raw string) (RSSFeed, error) {
var doc struct {
Channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
LastBuildDate string `xml:"lastBuildDate"`
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
} `xml:"item"`
} `xml:"channel"`
}
if err := xml.Unmarshal([]byte(raw), &doc); err != nil {
return RSSFeed{}, fmt.Errorf("decode rss: %w", err)
}
feed := RSSFeed{
Title: strings.TrimSpace(doc.Channel.Title),
Link: strings.TrimSpace(doc.Channel.Link),
Description: strings.TrimSpace(doc.Channel.Description),
LastBuildDate: parseRSSDate(doc.Channel.LastBuildDate),
Items: make([]RSSItem, 0, len(doc.Channel.Items)),
}
for _, item := range doc.Channel.Items {
feed.Items = append(feed.Items, RSSItem{
Title: strings.TrimSpace(item.Title),
Link: strings.TrimSpace(item.Link),
Description: strings.TrimSpace(item.Description),
PubDate: strings.TrimSpace(item.PubDate),
GUID: strings.TrimSpace(item.GUID),
})
}
return feed, nil
}
func parseRSSDate(value string) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
t, err := time.Parse(layout, value)
if err == nil {
tt := t.UTC()
return &tt
}
}
return nil
}

View File

@@ -0,0 +1,43 @@
package spc
import (
"testing"
"time"
)
func TestParseRSSFeed(t *testing.T) {
const raw = `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>SPC AC RSS</title>
<link>https://www.spc.noaa.gov/products/</link>
<description>SPC products</description>
<lastBuildDate>Thu, 11 Jun 2026 19:00:00 +0000</lastBuildDate>
<item>
<title>Day 1 Convective Outlook</title>
<link>https://www.spc.noaa.gov/products/outlook/day1otlk.html</link>
<description>Outlook text</description>
<pubDate>Thu, 11 Jun 2026 18:55:00 +0000</pubDate>
<guid>day1</guid>
</item>
</channel>
</rss>`
got, err := ParseRSSFeed(raw)
if err != nil {
t.Fatalf("ParseRSSFeed() error = %v", err)
}
if got.Title != "SPC AC RSS" {
t.Fatalf("Title = %q", got.Title)
}
wantBuild := time.Date(2026, 6, 11, 19, 0, 0, 0, time.UTC)
if got.LastBuildDate == nil || !got.LastBuildDate.Equal(wantBuild) {
t.Fatalf("LastBuildDate = %v, want %s", got.LastBuildDate, wantBuild)
}
if len(got.Items) != 1 {
t.Fatalf("Items length = %d, want 1", len(got.Items))
}
if got.Items[0].GUID != "day1" {
t.Fatalf("Item GUID = %q", got.Items[0].GUID)
}
}

View File

@@ -0,0 +1,29 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"VALID_ISO": "2026-06-11T13:00:00Z",
"EXPIRE_ISO": "2026-06-12T12:00:00Z",
"ISSUE_ISO": "2026-06-11T12:34:56Z",
"FORECASTER": "SMITH",
"LABEL": "SLGT",
"LABEL2": "Slight Risk",
"DN": 3
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-91.0, 38.0],
[-90.0, 38.0],
[-90.0, 39.0],
[-91.0, 39.0],
[-91.0, 38.0]
]
]
}
}
]
}

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html>
<head><title>Day 1 Convective Outlook</title></head>
<body>
<pre>
<script>window.bad = "<b>ignore me</b>";</script>
Day 1 Convective Outlook
NWS Storm Prediction Center Norman OK
Updated: 2026-06-11T12:45:00Z
...SUMMARY...
Severe thunderstorms are possible across parts of the central Plains
and mid Mississippi Valley this afternoon and evening.
...DISCUSSION...
The primary threats will be damaging wind and large hail.
</pre>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html>
<body>
<pre>
Day 2 Convective Outlook CORR 1
NWS Storm Prediction Center Norman OK
...SUMMARY...
Scattered severe thunderstorms remain possible across the southern Plains.
...DISCUSSION...
Corrected outlook text remains otherwise unchanged.
</pre>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"VALID_ISO": "2026-06-12T12:00:00Z",
"EXPIRE_ISO": "2026-06-13T12:00:00Z",
"ISSUE_ISO": "2026-06-11T17:30:00Z",
"FORECASTER": "DOE",
"LABEL": "5",
"LABEL2": "5% Tornado",
"DN": "5"
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-100.0, 35.0],
[-98.0, 35.0],
[-98.0, 37.0],
[-100.0, 37.0],
[-100.0, 35.0]
]
]
}
}
]
}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<body>
<pre>
Day 3 Convective Outlook
NWS Storm Prediction Center Norman OK
Updated: 2026-06-11T20:00:00Z
...SUMMARY...
A corridor of strong to severe storms may develop near a frontal zone.
...DISCUSSION...
Confidence remains moderate for organized storms.
</pre>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"VALID_ISO": "2026-06-13T12:00:00Z",
"EXPIRE_ISO": "2026-06-14T12:00:00Z",
"ISSUE_ISO": "2026-06-11T19:45:00Z",
"FORECASTER": "LEE",
"LABEL": "15",
"LABEL2": "15% Wind",
"DN": 15
},
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[-103.0, 34.0],
[-101.0, 34.0],
[-101.0, 36.0],
[-103.0, 36.0],
[-103.0, 34.0]
]
]
]
}
}
]
}

View File

@@ -0,0 +1,24 @@
package spc
import (
"strings"
"time"
)
// ParseISOTimestamp parses SPC ISO timestamps from GeoJSON properties.
func ParseISOTimestamp(value string) (time.Time, error) {
return time.Parse(time.RFC3339, strings.TrimSpace(value))
}
func parseOptionalISOTimestamp(value string) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
t, err := ParseISOTimestamp(value)
if err != nil {
return nil
}
tt := t.UTC()
return &tt
}