Add SPC provider parsing helpers
This commit is contained in:
158
internal/providers/spc/discussion.go
Normal file
158
internal/providers/spc/discussion.go
Normal 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
|
||||
}
|
||||
90
internal/providers/spc/discussion_test.go
Normal file
90
internal/providers/spc/discussion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
8
internal/providers/spc/doc.go
Normal file
8
internal/providers/spc/doc.go
Normal 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
|
||||
105
internal/providers/spc/geojson.go
Normal file
105
internal/providers/spc/geojson.go
Normal 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
|
||||
}
|
||||
90
internal/providers/spc/geojson_test.go
Normal file
90
internal/providers/spc/geojson_test.go
Normal 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
|
||||
}
|
||||
89
internal/providers/spc/product.go
Normal file
89
internal/providers/spc/product.go
Normal 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
|
||||
}
|
||||
56
internal/providers/spc/product_test.go
Normal file
56
internal/providers/spc/product_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
internal/providers/spc/raw.go
Normal file
45
internal/providers/spc/raw.go
Normal 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"`
|
||||
}
|
||||
51
internal/providers/spc/raw_test.go
Normal file
51
internal/providers/spc/raw_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
81
internal/providers/spc/rss.go
Normal file
81
internal/providers/spc/rss.go
Normal 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
|
||||
}
|
||||
43
internal/providers/spc/rss_test.go
Normal file
43
internal/providers/spc/rss_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
internal/providers/spc/testdata/day1_cat.geojson
vendored
Normal file
29
internal/providers/spc/testdata/day1_cat.geojson
vendored
Normal 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]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
19
internal/providers/spc/testdata/day1_prt.html
vendored
Normal file
19
internal/providers/spc/testdata/day1_prt.html
vendored
Normal 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>
|
||||
15
internal/providers/spc/testdata/day2_prt_corr.html
vendored
Normal file
15
internal/providers/spc/testdata/day2_prt_corr.html
vendored
Normal 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>
|
||||
29
internal/providers/spc/testdata/day2_torn.geojson
vendored
Normal file
29
internal/providers/spc/testdata/day2_torn.geojson
vendored
Normal 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]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
16
internal/providers/spc/testdata/day3_prt.html
vendored
Normal file
16
internal/providers/spc/testdata/day3_prt.html
vendored
Normal 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>
|
||||
31
internal/providers/spc/testdata/day3_wind.geojson
vendored
Normal file
31
internal/providers/spc/testdata/day3_wind.geojson
vendored
Normal 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]
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
internal/providers/spc/time.go
Normal file
24
internal/providers/spc/time.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user