Compare commits
15 Commits
50215d2105
...
v0.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b88be4dd2 | |||
| 456a46e01b | |||
| 3740c779eb | |||
| 6943a5ebc9 | |||
| 9a09454621 | |||
| b8c6708439 | |||
| a62cb87b78 | |||
| 0b5eaf46f4 | |||
| 882059014c | |||
| 0a3e52d0e5 | |||
| 2b19a121fa | |||
| 29c65971eb | |||
| 3ecf4c5b7f | |||
| f402e27542 | |||
| f720b6cdc0 |
@@ -4,6 +4,45 @@
|
|||||||
|
|
||||||
This document is the catch-all roadmap for planned, deferred, aspirational, experimental, or unimplemented weatherfeeder work. Current behavior belongs in the canonical docs outside `docs/roadmap/`.
|
This document is the catch-all roadmap for planned, deferred, aspirational, experimental, or unimplemented weatherfeeder work. Current behavior belongs in the canonical docs outside `docs/roadmap/`.
|
||||||
|
|
||||||
|
## NWS AFD Parsing Resilience
|
||||||
|
|
||||||
|
The current parser handles the concrete RAH, LWX, and MFR variants that
|
||||||
|
motivated these ideas. Future work should keep those extension points
|
||||||
|
maintainable as additional evidence appears.
|
||||||
|
|
||||||
|
### Generalize Wrapper-Scoped Embedded Sections
|
||||||
|
|
||||||
|
The scanner currently permits undotted nested headings only inside `PREV
|
||||||
|
DISCUSSION`. If other wrapper identities are observed, replace the single
|
||||||
|
wrapper check with a small explicit provider-local registry and add a fixture
|
||||||
|
for each wrapper family. Do not make the leading dot globally optional: wrapper
|
||||||
|
scope is the safeguard against classifying uppercase prose as a section.
|
||||||
|
|
||||||
|
### Extend Conservative Preamble Classification
|
||||||
|
|
||||||
|
Leading key-message metadata currently supports validated `Issued at`, `Updated
|
||||||
|
at`, and `As of <clock> <weekday>...` forms. Add future wording variants as
|
||||||
|
small, ordered classifiers with strict label boundaries and value grammars.
|
||||||
|
Every addition should include collision tests proving that similar message prose
|
||||||
|
and malformed metadata remain canonical content.
|
||||||
|
|
||||||
|
### Keep List-Marker Recognition Extensible
|
||||||
|
|
||||||
|
The marker parser currently supports hyphens, asterisks, `N)`, `N.`, `(N)`, and
|
||||||
|
composite forms such as `- (N)`. If new decorators appear, evolve the helper
|
||||||
|
toward an explicit marker grammar or typed classification result rather than a
|
||||||
|
broad punctuation heuristic. Preserve positive-number and whitespace-boundary
|
||||||
|
checks so ordinary prose is not stripped.
|
||||||
|
|
||||||
|
### Maintain a Cross-Office Fixture Corpus
|
||||||
|
|
||||||
|
The compact RAH, LWX, and current MFR fixtures seed regression coverage for the
|
||||||
|
observed layouts. Future parser changes should add concise, deterministic HTML
|
||||||
|
fixtures for materially distinct office formats and exercise them through both
|
||||||
|
the provider parser and normalizer. Fixture comments should identify the format
|
||||||
|
family and state that edited prose is not an archived product; tests must remain
|
||||||
|
offline and assert both intended extraction and adjacent-section isolation.
|
||||||
|
|
||||||
## SPC Convective Outlook Follow-Ups
|
## SPC Convective Outlook Follow-Ups
|
||||||
|
|
||||||
### Weatherapi Outlook Endpoints
|
### Weatherapi Outlook Endpoints
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -71,6 +72,277 @@ func TestForecastDiscussionNormalizerProducesCanonicalSchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestForecastDiscussionNormalizerSupportsMixedHeadingFormats(t *testing.T) {
|
||||||
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
|
ID: "evt-discussion-mixed-format",
|
||||||
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
|
Source: "nws-discussion-test",
|
||||||
|
EmittedAt: time.Date(2026, 3, 28, 19, 25, 0, 0, time.UTC),
|
||||||
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
|
Payload: loadMixedFormatForecastDiscussionSampleHTML(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("Normalize() returned nil output")
|
||||||
|
}
|
||||||
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||||
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||||
|
}
|
||||||
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||||
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||||
|
}
|
||||||
|
wantEffectiveAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC)
|
||||||
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
||||||
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||||
|
}
|
||||||
|
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
||||||
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||||
|
}
|
||||||
|
if payload.ShortTerm.Qualifier != "Through Late Sunday Night" {
|
||||||
|
t.Fatalf("ShortTerm.Qualifier = %q", payload.ShortTerm.Qualifier)
|
||||||
|
}
|
||||||
|
if !strings.Contains(payload.ShortTerm.Text, "After a chilly morning") {
|
||||||
|
t.Fatalf("ShortTerm.Text missing expected prose: %q", payload.ShortTerm.Text)
|
||||||
|
}
|
||||||
|
if payload.LongTerm.Qualifier != "Monday through Next Saturday" {
|
||||||
|
t.Fatalf("LongTerm.Qualifier = %q", payload.LongTerm.Qualifier)
|
||||||
|
}
|
||||||
|
if !strings.Contains(payload.LongTerm.Text, "The peak of the warmth arrives Monday and Tuesday") {
|
||||||
|
t.Fatalf("LongTerm.Text missing expected prose: %q", payload.LongTerm.Text)
|
||||||
|
}
|
||||||
|
if strings.Contains(payload.LongTerm.Text, "AVIATION") || strings.Contains(payload.LongTerm.Text, "VFR conditions are expected") {
|
||||||
|
t.Fatalf("LongTerm.Text includes aviation content: %q", payload.LongTerm.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(out.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(b, &fields); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"aviation", "sections"} {
|
||||||
|
if _, ok := fields[key]; ok {
|
||||||
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForecastDiscussionNormalizerSupportsCrossOfficeLayout(t *testing.T) {
|
||||||
|
in := event.Event{
|
||||||
|
ID: "evt-discussion-bou",
|
||||||
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
|
Source: "nws-discussion-bou-test",
|
||||||
|
EmittedAt: time.Date(2026, 4, 7, 19, 1, 0, 0, time.UTC),
|
||||||
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
|
Payload: loadForecastDiscussionBOUSampleHTML(t),
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("Normalize() returned nil output")
|
||||||
|
}
|
||||||
|
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
||||||
|
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
||||||
|
}
|
||||||
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||||
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||||
|
}
|
||||||
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||||
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||||
|
}
|
||||||
|
wantEffectiveAt := time.Date(2026, 4, 7, 19, 0, 0, 0, time.UTC)
|
||||||
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(wantEffectiveAt) {
|
||||||
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, wantEffectiveAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||||
|
}
|
||||||
|
if payload.OfficeID != "BOU" || payload.OfficeName != "National Weather Service Denver CO" {
|
||||||
|
t.Fatalf("OfficeID=%q OfficeName=%q", payload.OfficeID, payload.OfficeName)
|
||||||
|
}
|
||||||
|
wantMessages := []string{
|
||||||
|
"Strong winds are expected along the Front Range this evening.",
|
||||||
|
"Cooler temperatures arrive on Wednesday.",
|
||||||
|
}
|
||||||
|
if len(payload.KeyMessages) != len(wantMessages) {
|
||||||
|
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, wantMessages)
|
||||||
|
}
|
||||||
|
for i := range wantMessages {
|
||||||
|
if payload.KeyMessages[i] != wantMessages[i] {
|
||||||
|
t.Fatalf("KeyMessages[%d] = %q, want %q", i, payload.KeyMessages[i], wantMessages[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if payload.ShortTerm == nil || payload.LongTerm == nil {
|
||||||
|
t.Fatalf("ShortTerm=%v LongTerm=%v, want both populated", payload.ShortTerm, payload.LongTerm)
|
||||||
|
}
|
||||||
|
if payload.ShortTerm.Qualifier != "(Tonight through Wednesday)" || payload.ShortTerm.Text != "Gusty west winds will continue through the evening before decreasing overnight." {
|
||||||
|
t.Fatalf("ShortTerm = %#v", payload.ShortTerm)
|
||||||
|
}
|
||||||
|
if payload.LongTerm.Qualifier != "(Thursday through Saturday)" || payload.LongTerm.Text != "Warmer and drier conditions return Thursday, followed by a chance of showers Friday." {
|
||||||
|
t.Fatalf("LongTerm = %#v", payload.LongTerm)
|
||||||
|
}
|
||||||
|
if payload.ShortTerm.IssuedAt == nil || payload.LongTerm.IssuedAt == nil ||
|
||||||
|
!payload.ShortTerm.IssuedAt.Equal(wantEffectiveAt) || !payload.LongTerm.IssuedAt.Equal(wantEffectiveAt) {
|
||||||
|
t.Fatalf("section issue times = short %v long %v, want %s", payload.ShortTerm.IssuedAt, payload.LongTerm.IssuedAt, wantEffectiveAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(out.Payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(b, &fields); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"aviation", "discussion", "sections"} {
|
||||||
|
if _, ok := fields[key]; ok {
|
||||||
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForecastDiscussionNormalizerSupportsCrossOfficeKeyMessageFixtures(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filename string
|
||||||
|
id string
|
||||||
|
source string
|
||||||
|
emittedAt time.Time
|
||||||
|
effectiveAt time.Time
|
||||||
|
messages []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "numbered key messages",
|
||||||
|
filename: "forecast_discussion_bgm_numbered_sample.html",
|
||||||
|
id: "evt-discussion-bgm",
|
||||||
|
source: "nws-discussion-bgm-test",
|
||||||
|
emittedAt: time.Date(2026, 4, 10, 17, 31, 0, 0, time.UTC),
|
||||||
|
effectiveAt: time.Date(2026, 4, 10, 17, 30, 0, 0, time.UTC),
|
||||||
|
messages: []string{
|
||||||
|
"Periods of rain are expected through Saturday, with locally heavier amounts possible.",
|
||||||
|
"Cooler temperatures return late this weekend.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key points alias",
|
||||||
|
filename: "forecast_discussion_mfr_key_points_sample.html",
|
||||||
|
id: "evt-discussion-mfr",
|
||||||
|
source: "nws-discussion-mfr-test",
|
||||||
|
emittedAt: time.Date(2026, 4, 10, 19, 46, 0, 0, time.UTC),
|
||||||
|
effectiveAt: time.Date(2026, 4, 10, 19, 45, 0, 0, time.UTC),
|
||||||
|
messages: []string{
|
||||||
|
"Gusty winds will develop over exposed ridges, especially during the afternoon.",
|
||||||
|
"Inland valleys remain dry through Saturday.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "as of preamble",
|
||||||
|
filename: "forecast_discussion_rah_as_of_sample.html",
|
||||||
|
id: "evt-discussion-rah",
|
||||||
|
source: "nws-discussion-rah-test",
|
||||||
|
emittedAt: time.Date(2026, 8, 2, 16, 36, 0, 0, time.UTC),
|
||||||
|
effectiveAt: time.Date(2026, 8, 2, 16, 35, 0, 0, time.UTC),
|
||||||
|
messages: []string{
|
||||||
|
"Scattered storms may produce locally heavy rain this afternoon.",
|
||||||
|
"Drier weather arrives Monday.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parenthesized numeric markers",
|
||||||
|
filename: "forecast_discussion_lwx_parenthesized_number_sample.html",
|
||||||
|
id: "evt-discussion-lwx",
|
||||||
|
source: "nws-discussion-lwx-test",
|
||||||
|
emittedAt: time.Date(2026, 8, 2, 18, 1, 0, 0, time.UTC),
|
||||||
|
effectiveAt: time.Date(2026, 8, 2, 18, 0, 0, 0, time.UTC),
|
||||||
|
messages: []string{
|
||||||
|
"Thunderstorms remain possible near the Blue Ridge this evening.",
|
||||||
|
"Seasonably warm conditions continue Monday.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "embedded key messages in previous discussion",
|
||||||
|
filename: "forecast_discussion_mfr_prev_discussion_sample.html",
|
||||||
|
id: "evt-discussion-mfr-previous",
|
||||||
|
source: "nws-discussion-mfr-previous-test",
|
||||||
|
emittedAt: time.Date(2026, 8, 2, 22, 20, 0, 0, time.UTC),
|
||||||
|
effectiveAt: time.Date(2026, 8, 2, 22, 19, 0, 0, time.UTC),
|
||||||
|
messages: []string{
|
||||||
|
"Heat returns to inland valleys Monday.",
|
||||||
|
"Gusty afternoon winds develop east of the Cascades.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
in := event.Event{
|
||||||
|
ID: tt.id,
|
||||||
|
Kind: event.Kind(standards.KindForecastDiscussion),
|
||||||
|
Source: tt.source,
|
||||||
|
EmittedAt: tt.emittedAt,
|
||||||
|
Schema: standards.SchemaRawNWSForecastDiscussionV1,
|
||||||
|
Payload: loadForecastDiscussionFixtureHTML(t, tt.filename),
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := (ForecastDiscussionNormalizer{}).Normalize(nil, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
t.Fatalf("Normalize() returned nil output")
|
||||||
|
}
|
||||||
|
if out.ID != in.ID || out.Source != in.Source || !out.EmittedAt.Equal(in.EmittedAt) {
|
||||||
|
t.Fatalf("envelope = %#v, want ID/source/emittedAt from input", out)
|
||||||
|
}
|
||||||
|
if out.Kind != event.Kind(standards.KindForecastDiscussion) {
|
||||||
|
t.Fatalf("Kind = %q, want forecast_discussion", out.Kind)
|
||||||
|
}
|
||||||
|
if out.Schema != standards.SchemaWeatherForecastDiscussionV1 {
|
||||||
|
t.Fatalf("Schema = %q, want %q", out.Schema, standards.SchemaWeatherForecastDiscussionV1)
|
||||||
|
}
|
||||||
|
if out.EffectiveAt == nil || !out.EffectiveAt.Equal(tt.effectiveAt) {
|
||||||
|
t.Fatalf("EffectiveAt = %v, want %s", out.EffectiveAt, tt.effectiveAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, ok := out.Payload.(model.WeatherForecastDiscussion)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Payload type = %T, want model.WeatherForecastDiscussion", out.Payload)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(payload.KeyMessages, tt.messages) {
|
||||||
|
t.Fatalf("KeyMessages = %#v, want %#v", payload.KeyMessages, tt.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
var fields map[string]any
|
||||||
|
if err := json.Unmarshal(b, &fields); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(payload) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"nearTerm", "discussion", "aviation", "sections"} {
|
||||||
|
if _, ok := fields[key]; ok {
|
||||||
|
t.Fatalf("unexpected key %q in canonical payload", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
func TestForecastDiscussionNormalizerRejectsMissingIssueTime(t *testing.T) {
|
||||||
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
_, err := (ForecastDiscussionNormalizer{}).Normalize(nil, event.Event{
|
||||||
ID: "evt-discussion-bad",
|
ID: "evt-discussion-bad",
|
||||||
@@ -128,3 +400,56 @@ func loadForecastDiscussionSampleHTML(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadForecastDiscussionBOUSampleHTML(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join("..", "..", "providers", "nws", "testdata", "forecast_discussion_bou_sample.html")
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadForecastDiscussionFixtureHTML(t *testing.T, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join("..", "..", "providers", "nws", "testdata", filename)
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.ReadFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMixedFormatForecastDiscussionSampleHTML(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw := loadForecastDiscussionSampleHTML(t)
|
||||||
|
replacements := []struct {
|
||||||
|
original string
|
||||||
|
replacement string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
original: ".SHORT TERM... (Through Late Sunday Night)",
|
||||||
|
replacement: ".SHORT TERM /Through Late Sunday Night/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".LONG TERM... (Monday through Next Saturday)",
|
||||||
|
replacement: ".LONG TERM /Monday through Next Saturday/...",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
original: ".AVIATION... (For the 18z TAFs through 18z Sunday Afternoon)",
|
||||||
|
replacement: ".AVIATION /For the 18z TAFs through 18z Sunday Afternoon/...",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, replacement := range replacements {
|
||||||
|
if !strings.Contains(raw, replacement.original) {
|
||||||
|
t.Fatalf("fixture missing heading %q", replacement.original)
|
||||||
|
}
|
||||||
|
raw = strings.Replace(raw, replacement.original, replacement.replacement, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,8 +27,31 @@ type ForecastDiscussionSection struct {
|
|||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type forecastDiscussionSectionRole uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
forecastDiscussionSectionRoleKeyMessages forecastDiscussionSectionRole = iota
|
||||||
|
forecastDiscussionSectionRoleShortTerm
|
||||||
|
forecastDiscussionSectionRoleLongTerm
|
||||||
|
)
|
||||||
|
|
||||||
|
type forecastDiscussionSectionHeading struct {
|
||||||
|
section string
|
||||||
|
qualifier string
|
||||||
|
}
|
||||||
|
|
||||||
|
type forecastDiscussionSectionBlock struct {
|
||||||
|
heading forecastDiscussionSectionHeading
|
||||||
|
body []string
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
forecastDiscussionHeaderRE = regexp.MustCompile(`^\.(KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION)\.\.\.(.*)$`)
|
forecastDiscussionSectionRoles = map[string]forecastDiscussionSectionRole{
|
||||||
|
"KEY MESSAGES": forecastDiscussionSectionRoleKeyMessages,
|
||||||
|
"KEY POINTS": forecastDiscussionSectionRoleKeyMessages,
|
||||||
|
"SHORT TERM": forecastDiscussionSectionRoleShortTerm,
|
||||||
|
"LONG TERM": forecastDiscussionSectionRoleLongTerm,
|
||||||
|
}
|
||||||
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
||||||
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
||||||
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
||||||
@@ -99,23 +122,31 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
|
|||||||
IssuedAt: issuedAt.UTC(),
|
IssuedAt: issuedAt.UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if block, ok := extractForecastDiscussionSection(lines, "KEY MESSAGES"); ok {
|
seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles))
|
||||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block)
|
for _, block := range parseForecastDiscussionSectionBlocks(lines) {
|
||||||
|
role, ok := forecastDiscussionSectionRoles[block.heading.section]
|
||||||
|
if !ok || seenRoles[role] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if block, ok := extractForecastDiscussionSection(lines, "SHORT TERM"); ok {
|
seenRoles[role] = true
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case forecastDiscussionSectionRoleKeyMessages:
|
||||||
|
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
||||||
|
case forecastDiscussionSectionRoleShortTerm:
|
||||||
section, err := parseForecastDiscussionTextSection(block)
|
section, err := parseForecastDiscussionTextSection(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ForecastDiscussion{}, fmt.Errorf("parse SHORT TERM: %w", err)
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||||
}
|
}
|
||||||
out.ShortTerm = §ion
|
out.ShortTerm = §ion
|
||||||
}
|
case forecastDiscussionSectionRoleLongTerm:
|
||||||
if block, ok := extractForecastDiscussionSection(lines, "LONG TERM"); ok {
|
|
||||||
section, err := parseForecastDiscussionTextSection(block)
|
section, err := parseForecastDiscussionTextSection(block)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ForecastDiscussion{}, fmt.Errorf("parse LONG TERM: %w", err)
|
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err)
|
||||||
}
|
}
|
||||||
out.LongTerm = §ion
|
out.LongTerm = §ion
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -285,8 +316,9 @@ func parseForecastDiscussionHeader(lines []string) (string, time.Time, error) {
|
|||||||
|
|
||||||
func parseForecastDiscussionIssueTime(line string) (time.Time, error) {
|
func parseForecastDiscussionIssueTime(line string) (time.Time, error) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
line = strings.TrimPrefix(line, "Issued at ")
|
if isForecastDiscussionIssuedAtLine(line) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line[len("Issued at"):])
|
||||||
|
}
|
||||||
|
|
||||||
parts := strings.Fields(line)
|
parts := strings.Fields(line)
|
||||||
if len(parts) != 7 {
|
if len(parts) != 7 {
|
||||||
@@ -386,40 +418,217 @@ func forecastDiscussionLocation(abbrev string) (*time.Location, error) {
|
|||||||
return time.FixedZone(abbr, offset), nil
|
return time.FixedZone(abbr, offset), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractForecastDiscussionSection(lines []string, section string) ([]string, bool) {
|
func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||||
target := "." + section + "..."
|
line = strings.TrimSpace(line)
|
||||||
for i, raw := range lines {
|
if len(line) < 2 || line[0] != '.' {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(line, "/...") {
|
||||||
|
return parseForecastDiscussionSlashQualifiedHeading(line)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(line, "...") {
|
||||||
|
if heading, ok := parseForecastDiscussionParenthesizedTerminalHeading(line); ok {
|
||||||
|
return heading, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseForecastDiscussionEllipsisHeading(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForecastDiscussionSlashQualifiedHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||||
|
content := strings.TrimSuffix(line[1:], "/...")
|
||||||
|
separator := -1
|
||||||
|
for i := 1; i < len(content); i++ {
|
||||||
|
if content[i] == '/' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
||||||
|
separator = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if separator < 0 {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
||||||
|
if !ok {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
qualifier := strings.TrimSpace(content[separator+1:])
|
||||||
|
if qualifier == "" {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForecastDiscussionParenthesizedTerminalHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||||
|
if len(line) < 4 || line[0] != '.' || !strings.HasSuffix(line, "...") {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.TrimRight(line[1:len(line)-3], " \t")
|
||||||
|
if !strings.HasSuffix(content, ")") {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
separator := -1
|
||||||
|
for i := 1; i < len(content); i++ {
|
||||||
|
if content[i] == '(' && isForecastDiscussionHorizontalWhitespace(content[i-1]) {
|
||||||
|
separator = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if separator < 0 {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:separator])
|
||||||
|
if !ok {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
qualifier := content[separator:]
|
||||||
|
if len(qualifier) <= 2 || strings.TrimSpace(qualifier[1:len(qualifier)-1]) == "" {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
return forecastDiscussionSectionHeading{section: section, qualifier: qualifier}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForecastDiscussionEllipsisHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||||
|
content := line[1:]
|
||||||
|
delimiter := strings.Index(content, "...")
|
||||||
|
if delimiter < 0 {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
section, ok := normalizeForecastDiscussionSectionIdentity(content[:delimiter])
|
||||||
|
if !ok {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
return forecastDiscussionSectionHeading{
|
||||||
|
section: section,
|
||||||
|
qualifier: strings.TrimSpace(content[delimiter+3:]),
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeForecastDiscussionSectionIdentity(raw string) (string, bool) {
|
||||||
|
var normalized strings.Builder
|
||||||
|
pendingSpace := false
|
||||||
|
hasLetterOrDigit := false
|
||||||
|
|
||||||
|
for i := 0; i < len(raw); i++ {
|
||||||
|
b := raw[i]
|
||||||
|
switch {
|
||||||
|
case isForecastDiscussionIdentityLetterOrDigit(b):
|
||||||
|
hasLetterOrDigit = true
|
||||||
|
case b == ' ' || b == '\t':
|
||||||
|
pendingSpace = normalized.Len() > 0
|
||||||
|
continue
|
||||||
|
case b == '/' && i > 0 && isForecastDiscussionHorizontalWhitespace(raw[i-1]):
|
||||||
|
return "", false
|
||||||
|
case b != '/' && b != '&' && b != '\'' && b != '-':
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if pendingSpace {
|
||||||
|
normalized.WriteByte(' ')
|
||||||
|
pendingSpace = false
|
||||||
|
}
|
||||||
|
normalized.WriteByte(b)
|
||||||
|
}
|
||||||
|
if !hasLetterOrDigit {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return normalized.String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionIdentityLetterOrDigit(b byte) bool {
|
||||||
|
return b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionHorizontalWhitespace(b byte) bool {
|
||||||
|
return b == ' ' || b == '\t'
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForecastDiscussionSectionBlocks(lines []string) []forecastDiscussionSectionBlock {
|
||||||
|
var blocks []forecastDiscussionSectionBlock
|
||||||
|
var active *forecastDiscussionSectionBlock
|
||||||
|
embeddedHeadings := false
|
||||||
|
|
||||||
|
finish := func() {
|
||||||
|
if active == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blocks = append(blocks, *active)
|
||||||
|
active = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range lines {
|
||||||
line := strings.TrimSpace(raw)
|
line := strings.TrimSpace(raw)
|
||||||
if !strings.HasPrefix(line, target) {
|
if line == "$$" {
|
||||||
|
finish()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") {
|
||||||
|
finish()
|
||||||
|
embeddedHeadings = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
out := []string{line}
|
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
||||||
for j := i + 1; j < len(lines); j++ {
|
if ok {
|
||||||
next := strings.TrimSpace(lines[j])
|
finish()
|
||||||
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
|
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||||
break
|
embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if j > i+1 && isForecastDiscussionSectionHeader(next) {
|
if embeddedHeadings {
|
||||||
break
|
heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw)
|
||||||
|
if ok {
|
||||||
|
finish()
|
||||||
|
active = &forecastDiscussionSectionBlock{heading: heading}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, lines[j])
|
|
||||||
}
|
}
|
||||||
return out, true
|
if active != nil {
|
||||||
|
active.body = append(active.body, raw)
|
||||||
}
|
}
|
||||||
return nil, false
|
}
|
||||||
|
finish()
|
||||||
|
|
||||||
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
func isForecastDiscussionSectionHeader(line string) bool {
|
func isForecastDiscussionEmbeddedSectionWrapper(section string) bool {
|
||||||
return forecastDiscussionHeaderRE.MatchString(strings.TrimSpace(line))
|
return section == "PREV DISCUSSION"
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseForecastDiscussionKeyMessages(block []string) []string {
|
func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||||
if len(block) <= 1 {
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || line[0] == '.' {
|
||||||
|
return forecastDiscussionSectionHeading{}, false
|
||||||
|
}
|
||||||
|
return parseForecastDiscussionSectionHeading("." + line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseForecastDiscussionKeyMessages(body []string) []string {
|
||||||
|
body = removeForecastDiscussionPresentationMarkers(body)
|
||||||
|
body = trimBlankLines(body)
|
||||||
|
if len(body) > 0 && isForecastDiscussionKeyMessageMetadataLine(body[0]) {
|
||||||
|
body = trimBlankLines(body[1:])
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
hasMarkers := false
|
||||||
|
for _, raw := range body {
|
||||||
|
line := strings.TrimSpace(raw)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
||||||
|
hasMarkers = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body := trimBlankLines(block[1:])
|
|
||||||
var messages []string
|
var messages []string
|
||||||
var current strings.Builder
|
var current strings.Builder
|
||||||
|
|
||||||
@@ -431,15 +640,21 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
|
|||||||
current.Reset()
|
current.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seenMarker := false
|
||||||
for _, raw := range body {
|
for _, raw := range body {
|
||||||
line := strings.TrimSpace(raw)
|
line := strings.TrimSpace(raw)
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
if !hasMarkers || !seenMarker {
|
||||||
|
flush()
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "-") {
|
if stripped, ok := stripForecastDiscussionKeyMessageMarker(line); ok {
|
||||||
flush()
|
flush()
|
||||||
line = strings.TrimSpace(strings.TrimPrefix(line, "-"))
|
seenMarker = true
|
||||||
current.WriteString(line)
|
line = stripped
|
||||||
|
}
|
||||||
|
if line == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if current.Len() > 0 {
|
if current.Len() > 0 {
|
||||||
@@ -452,25 +667,24 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSection, error) {
|
func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) {
|
||||||
if len(block) == 0 {
|
|
||||||
return ForecastDiscussionSection{}, fmt.Errorf("empty section")
|
|
||||||
}
|
|
||||||
|
|
||||||
section := ForecastDiscussionSection{
|
section := ForecastDiscussionSection{
|
||||||
Qualifier: parseForecastDiscussionQualifier(strings.TrimSpace(block[0])),
|
Qualifier: block.heading.qualifier,
|
||||||
}
|
}
|
||||||
|
|
||||||
body := trimBlankLines(block[1:])
|
body := trimBlankLines(removeForecastDiscussionPresentationMarkers(block.body))
|
||||||
|
if section.Qualifier == "" && len(body) > 0 && isForecastDiscussionStandaloneParenthetical(body[0]) {
|
||||||
|
section.Qualifier = strings.TrimSpace(body[0])
|
||||||
|
body = trimBlankLines(body[1:])
|
||||||
|
}
|
||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
return section, nil
|
return section, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
first := strings.TrimSpace(body[0])
|
if isForecastDiscussionIssuedAtLine(body[0]) {
|
||||||
if strings.HasPrefix(first, "Issued at ") {
|
issuedAt, err := parseForecastDiscussionIssueTime(body[0])
|
||||||
issuedAt, err := parseForecastDiscussionIssueTime(first)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", first, err)
|
return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", strings.TrimSpace(body[0]), err)
|
||||||
}
|
}
|
||||||
tt := issuedAt.UTC()
|
tt := issuedAt.UTC()
|
||||||
section.IssuedAt = &tt
|
section.IssuedAt = &tt
|
||||||
@@ -482,12 +696,147 @@ func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSecti
|
|||||||
return section, nil
|
return section, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseForecastDiscussionQualifier(header string) string {
|
func isForecastDiscussionPresentationMarker(line string) bool {
|
||||||
m := forecastDiscussionHeaderRE.FindStringSubmatch(header)
|
switch {
|
||||||
if len(m) != 3 {
|
case strings.EqualFold(strings.TrimSpace(line), "-- Changed Discussion --"):
|
||||||
return ""
|
return true
|
||||||
|
case strings.EqualFold(strings.TrimSpace(line), "-- End Changed Discussion --"):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(m[2])
|
}
|
||||||
|
|
||||||
|
func removeForecastDiscussionPresentationMarkers(lines []string) []string {
|
||||||
|
body := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
if !isForecastDiscussionPresentationMarker(line) {
|
||||||
|
body = append(body, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionStandaloneParenthetical(line string) bool {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
return len(line) > 2 && line[0] == '(' && line[len(line)-1] == ')' && strings.TrimSpace(line[1:len(line)-1]) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionIssuedAtLine(line string) bool {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
return len(line) > len("Issued at") &&
|
||||||
|
strings.EqualFold(line[:len("Issued at")], "Issued at") &&
|
||||||
|
isForecastDiscussionHorizontalWhitespace(line[len("Issued at")])
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionKeyMessageMetadataLine(line string) bool {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
for _, label := range []string{"Issued at", "Updated at"} {
|
||||||
|
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := parseForecastDiscussionIssueTime(strings.TrimSpace(line[len(label):])); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isForecastDiscussionKeyMessageAsOfLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isForecastDiscussionKeyMessageAsOfLine(line string) bool {
|
||||||
|
const label = "As of"
|
||||||
|
|
||||||
|
if !hasForecastDiscussionASCIIPrefix(line, label) || len(line) == len(label) || !isForecastDiscussionHorizontalWhitespace(line[len(label)]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
remainder := strings.TrimSpace(line[len(label):])
|
||||||
|
if !strings.HasSuffix(remainder, "...") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fields := strings.Fields(strings.TrimSpace(strings.TrimSuffix(remainder, "...")))
|
||||||
|
if len(fields) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, _, err := parseForecastDiscussionClock(fields[0], fields[1]); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(fields[2]) {
|
||||||
|
case "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripForecastDiscussionKeyMessageMarker(line string) (string, bool) {
|
||||||
|
if line == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if line[0] == '-' || line[0] == '*' {
|
||||||
|
content := line[1:]
|
||||||
|
hadWhitespace := len(content) > 0 && isForecastDiscussionHorizontalWhitespace(content[0])
|
||||||
|
content = strings.TrimLeft(content, " \t")
|
||||||
|
if hadWhitespace {
|
||||||
|
if stripped, ok := stripForecastDiscussionKeyMessageNumericMarker(content); ok {
|
||||||
|
content = stripped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripForecastDiscussionKeyMessageNumericMarker(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripForecastDiscussionKeyMessageNumericMarker(line string) (string, bool) {
|
||||||
|
digitStart := 0
|
||||||
|
digitEnd := 0
|
||||||
|
parenthesized := len(line) > 0 && line[0] == '('
|
||||||
|
if parenthesized {
|
||||||
|
digitStart = 1
|
||||||
|
digitEnd = 1
|
||||||
|
}
|
||||||
|
for digitEnd < len(line) && line[digitEnd] >= '0' && line[digitEnd] <= '9' {
|
||||||
|
digitEnd++
|
||||||
|
}
|
||||||
|
if digitEnd == digitStart || digitEnd == len(line) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if parenthesized && line[digitEnd] != ')' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !parenthesized && line[digitEnd] != ')' && line[digitEnd] != '.' {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
markerEnd := digitEnd + 1
|
||||||
|
if markerEnd < len(line) && !isForecastDiscussionHorizontalWhitespace(line[markerEnd]) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseUint(line[digitStart:digitEnd], 10, 0)
|
||||||
|
if err != nil || value == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimLeft(line[markerEnd:], " \t"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasForecastDiscussionASCIIPrefix(line, prefix string) bool {
|
||||||
|
if len(line) < len(prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range prefix {
|
||||||
|
actual := line[i]
|
||||||
|
if actual >= 'A' && actual <= 'Z' {
|
||||||
|
actual += 'a' - 'A'
|
||||||
|
}
|
||||||
|
expected := prefix[i]
|
||||||
|
if expected >= 'A' && expected <= 'Z' {
|
||||||
|
expected += 'a' - 'A'
|
||||||
|
}
|
||||||
|
if actual != expected {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimBlankLines(lines []string) []string {
|
func trimBlankLines(lines []string) []string {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal file
26
internal/providers/nws/testdata/forecast_discussion_bgm_numbered_sample.html
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Representative BGM/CTP-style layout; prose is concise edited test data, not an archived product. -->
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS61 KBGM 101730
|
||||||
|
AFDBGM
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Binghamton NY
|
||||||
|
130 PM EDT Fri Apr 10 2026
|
||||||
|
|
||||||
|
.KEY MESSAGES...
|
||||||
|
1) Periods of rain are expected through Saturday,
|
||||||
|
with locally heavier amounts possible.
|
||||||
|
2. Cooler temperatures return late this weekend.
|
||||||
|
|
||||||
|
.DISCUSSION...
|
||||||
|
Discussion details remain boundary-only content.
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO BGM
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
internal/providers/nws/testdata/forecast_discussion_bou_sample.html
vendored
Normal file
48
internal/providers/nws/testdata/forecast_discussion_bou_sample.html
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS65 KBOU 071900
|
||||||
|
AFDBOU
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Denver CO
|
||||||
|
100 PM MDT Tue Apr 7 2026
|
||||||
|
|
||||||
|
.KEY MESSAGES...
|
||||||
|
-- Changed Discussion --
|
||||||
|
Updated at 100 PM MDT Tue Apr 7 2026
|
||||||
|
- Strong winds are expected along the Front Range this evening.
|
||||||
|
- Cooler temperatures arrive on Wednesday.
|
||||||
|
-- End Changed Discussion --
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.SHORT TERM...
|
||||||
|
(Tonight through Wednesday)
|
||||||
|
Issued at 100 PM MDT Tue Apr 7 2026
|
||||||
|
|
||||||
|
Gusty west winds will continue through the evening before decreasing overnight.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.LONG TERM...
|
||||||
|
(Thursday through Saturday)
|
||||||
|
ISSUED AT 100 PM MDT Tue Apr 7 2026
|
||||||
|
|
||||||
|
Warmer and drier conditions return Thursday, followed by a chance of showers Friday.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.AVIATION...
|
||||||
|
|
||||||
|
VFR conditions are expected at Denver-area terminals through Wednesday morning.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO BOU
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
27
internal/providers/nws/testdata/forecast_discussion_lwx_parenthesized_number_sample.html
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Representative LWX-style layout; prose is concise edited test data, not an archived product. -->
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS61 KLWX 021800
|
||||||
|
AFDLWX
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Baltimore MD/Washington DC
|
||||||
|
200 PM EDT Sun Aug 2 2026
|
||||||
|
|
||||||
|
.KEY MESSAGES...
|
||||||
|
- (1) Thunderstorms remain possible near the Blue Ridge this evening.
|
||||||
|
- (2) Seasonably warm conditions continue Monday.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.AVIATION...
|
||||||
|
Aviation details remain boundary-only content.
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO LWX
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal file
25
internal/providers/nws/testdata/forecast_discussion_mfr_key_points_sample.html
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Representative MFR-style layout; prose is concise edited test data, not an archived product. -->
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS66 KMFR 101945
|
||||||
|
AFDMFR
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Medford OR
|
||||||
|
1245 PM PDT Fri Apr 10 2026
|
||||||
|
|
||||||
|
.KEY POINTS...
|
||||||
|
* Gusty winds will develop over exposed ridges,
|
||||||
|
especially during the afternoon.
|
||||||
|
* Inland valleys remain dry through Saturday.
|
||||||
|
.DISCUSSION (Today through Thursday)...
|
||||||
|
Discussion details must not be included with key points.
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO MFR
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
33
internal/providers/nws/testdata/forecast_discussion_mfr_prev_discussion_sample.html
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Representative current MFR previous-discussion wrapper layout; prose is concise edited test data, not an archived product. -->
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS66 KMFR 022219
|
||||||
|
AFDMFR
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Medford OR
|
||||||
|
319 PM PDT Sun Aug 2 2026
|
||||||
|
|
||||||
|
.PREV DISCUSSION... /Issued 319 PM PDT Sun Aug 2 2026/
|
||||||
|
|
||||||
|
KEY MESSAGES...
|
||||||
|
|
||||||
|
* Heat returns to inland valleys Monday.
|
||||||
|
* Gusty afternoon winds develop east of the Cascades.
|
||||||
|
|
||||||
|
DISCUSSION...
|
||||||
|
Discussion details must not be included with key messages.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.MFR WATCHES/WARNINGS/ADVISORIES...
|
||||||
|
None.
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO MFR
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
29
internal/providers/nws/testdata/forecast_discussion_rah_as_of_sample.html
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Representative RAH-style layout; prose is concise edited test data, not an archived product. -->
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<pre class="glossaryProduct">
|
||||||
|
FXUS62 KRAH 021635
|
||||||
|
AFDRAH
|
||||||
|
|
||||||
|
Area Forecast Discussion
|
||||||
|
National Weather Service Raleigh NC
|
||||||
|
1235 PM EDT Sun Aug 2 2026
|
||||||
|
|
||||||
|
.KEY MESSAGES...
|
||||||
|
As of 1235 PM Sunday...
|
||||||
|
|
||||||
|
1) Scattered storms may produce locally heavy rain this afternoon.
|
||||||
|
2) Drier weather arrives Monday.
|
||||||
|
|
||||||
|
&&
|
||||||
|
|
||||||
|
.DISCUSSION...
|
||||||
|
Discussion details remain boundary-only content.
|
||||||
|
|
||||||
|
$$
|
||||||
|
|
||||||
|
WFO RAH
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user