Centralize NWS forecast discussion section headings
This commit is contained in:
@@ -27,11 +27,31 @@ type ForecastDiscussionSection struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
const (
|
||||
forecastDiscussionSectionKeyMessages = "KEY MESSAGES"
|
||||
forecastDiscussionSectionShortTerm = "SHORT TERM"
|
||||
forecastDiscussionSectionLongTerm = "LONG TERM"
|
||||
forecastDiscussionSectionAviation = "AVIATION"
|
||||
|
||||
forecastDiscussionSectionIdentityPattern = "KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION"
|
||||
)
|
||||
|
||||
type forecastDiscussionSectionHeading struct {
|
||||
section string
|
||||
qualifier string
|
||||
}
|
||||
|
||||
type forecastDiscussionSectionBlock struct {
|
||||
heading forecastDiscussionSectionHeading
|
||||
body []string
|
||||
}
|
||||
|
||||
var (
|
||||
forecastDiscussionHeaderRE = regexp.MustCompile(`^\.(KEY MESSAGES|SHORT TERM|LONG TERM|AVIATION)\.\.\.(.*)$`)
|
||||
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
||||
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
||||
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
||||
forecastDiscussionEllipsisHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)\.\.\.(.*)$`)
|
||||
forecastDiscussionSlashHeadingRE = regexp.MustCompile(`^\.(` + forecastDiscussionSectionIdentityPattern + `)[ \t]+/([^/]+)/\.\.\.$`)
|
||||
forecastDiscussionAFDRE = regexp.MustCompile(`^AFD([A-Z]{3})$`)
|
||||
forecastDiscussionWMORE = regexp.MustCompile(`\bK([A-Z]{3})\b`)
|
||||
forecastDiscussionSigRE = regexp.MustCompile(`^[A-Z]{2,6}$`)
|
||||
)
|
||||
|
||||
func ParseForecastDiscussionHTML(raw string) (ForecastDiscussion, error) {
|
||||
@@ -99,20 +119,20 @@ func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) {
|
||||
IssuedAt: issuedAt.UTC(),
|
||||
}
|
||||
|
||||
if block, ok := extractForecastDiscussionSection(lines, "KEY MESSAGES"); ok {
|
||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block)
|
||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionKeyMessages); ok {
|
||||
out.KeyMessages = parseForecastDiscussionKeyMessages(block.body)
|
||||
}
|
||||
if block, ok := extractForecastDiscussionSection(lines, "SHORT TERM"); ok {
|
||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionShortTerm); ok {
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse SHORT TERM: %w", err)
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionShortTerm, err)
|
||||
}
|
||||
out.ShortTerm = §ion
|
||||
}
|
||||
if block, ok := extractForecastDiscussionSection(lines, "LONG TERM"); ok {
|
||||
if block, ok := extractForecastDiscussionSection(lines, forecastDiscussionSectionLongTerm); ok {
|
||||
section, err := parseForecastDiscussionTextSection(block)
|
||||
if err != nil {
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse LONG TERM: %w", err)
|
||||
return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", forecastDiscussionSectionLongTerm, err)
|
||||
}
|
||||
out.LongTerm = §ion
|
||||
}
|
||||
@@ -386,40 +406,56 @@ func forecastDiscussionLocation(abbrev string) (*time.Location, error) {
|
||||
return time.FixedZone(abbr, offset), nil
|
||||
}
|
||||
|
||||
func extractForecastDiscussionSection(lines []string, section string) ([]string, bool) {
|
||||
target := "." + section + "..."
|
||||
func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if m := forecastDiscussionEllipsisHeadingRE.FindStringSubmatch(line); len(m) == 3 {
|
||||
return forecastDiscussionSectionHeading{
|
||||
section: m[1],
|
||||
qualifier: strings.TrimSpace(m[2]),
|
||||
}, true
|
||||
}
|
||||
if m := forecastDiscussionSlashHeadingRE.FindStringSubmatch(line); len(m) == 3 {
|
||||
qualifier := strings.TrimSpace(m[2])
|
||||
if qualifier == "" {
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
return forecastDiscussionSectionHeading{
|
||||
section: m[1],
|
||||
qualifier: qualifier,
|
||||
}, true
|
||||
}
|
||||
return forecastDiscussionSectionHeading{}, false
|
||||
}
|
||||
|
||||
func extractForecastDiscussionSection(lines []string, section string) (forecastDiscussionSectionBlock, bool) {
|
||||
for i, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, target) {
|
||||
heading, ok := parseForecastDiscussionSectionHeading(raw)
|
||||
if !ok || heading.section != section {
|
||||
continue
|
||||
}
|
||||
|
||||
out := []string{line}
|
||||
block := forecastDiscussionSectionBlock{heading: heading}
|
||||
for j := i + 1; j < len(lines); j++ {
|
||||
next := strings.TrimSpace(lines[j])
|
||||
if next == "&&" || next == "$$" || strings.Contains(next, "WATCHES/WARNINGS/ADVISORIES") {
|
||||
break
|
||||
}
|
||||
if j > i+1 && isForecastDiscussionSectionHeader(next) {
|
||||
if _, ok := parseForecastDiscussionSectionHeading(next); ok {
|
||||
break
|
||||
}
|
||||
out = append(out, lines[j])
|
||||
block.body = append(block.body, lines[j])
|
||||
}
|
||||
return out, true
|
||||
return block, true
|
||||
}
|
||||
return nil, false
|
||||
return forecastDiscussionSectionBlock{}, false
|
||||
}
|
||||
|
||||
func isForecastDiscussionSectionHeader(line string) bool {
|
||||
return forecastDiscussionHeaderRE.MatchString(strings.TrimSpace(line))
|
||||
}
|
||||
|
||||
func parseForecastDiscussionKeyMessages(block []string) []string {
|
||||
if len(block) <= 1 {
|
||||
func parseForecastDiscussionKeyMessages(body []string) []string {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body := trimBlankLines(block[1:])
|
||||
body = trimBlankLines(body)
|
||||
var messages []string
|
||||
var current strings.Builder
|
||||
|
||||
@@ -452,16 +488,12 @@ func parseForecastDiscussionKeyMessages(block []string) []string {
|
||||
return messages
|
||||
}
|
||||
|
||||
func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSection, error) {
|
||||
if len(block) == 0 {
|
||||
return ForecastDiscussionSection{}, fmt.Errorf("empty section")
|
||||
}
|
||||
|
||||
func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) {
|
||||
section := ForecastDiscussionSection{
|
||||
Qualifier: parseForecastDiscussionQualifier(strings.TrimSpace(block[0])),
|
||||
Qualifier: block.heading.qualifier,
|
||||
}
|
||||
|
||||
body := trimBlankLines(block[1:])
|
||||
body := trimBlankLines(block.body)
|
||||
if len(body) == 0 {
|
||||
return section, nil
|
||||
}
|
||||
@@ -482,14 +514,6 @@ func parseForecastDiscussionTextSection(block []string) (ForecastDiscussionSecti
|
||||
return section, nil
|
||||
}
|
||||
|
||||
func parseForecastDiscussionQualifier(header string) string {
|
||||
m := forecastDiscussionHeaderRE.FindStringSubmatch(header)
|
||||
if len(m) != 3 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[2])
|
||||
}
|
||||
|
||||
func trimBlankLines(lines []string) []string {
|
||||
start := 0
|
||||
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
|
||||
|
||||
@@ -8,6 +8,135 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseForecastDiscussionSectionHeading(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
wantSection string
|
||||
wantQualifier string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "ellipsis-first key messages",
|
||||
line: ".KEY MESSAGES... (Through Tonight)",
|
||||
wantSection: forecastDiscussionSectionKeyMessages,
|
||||
wantQualifier: "(Through Tonight)",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ellipsis-first short term",
|
||||
line: ".SHORT TERM... (Sunday)",
|
||||
wantSection: forecastDiscussionSectionShortTerm,
|
||||
wantQualifier: "(Sunday)",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ellipsis-first long term",
|
||||
line: ".LONG TERM... (Monday Through Friday)",
|
||||
wantSection: forecastDiscussionSectionLongTerm,
|
||||
wantQualifier: "(Monday Through Friday)",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ellipsis-first aviation without qualifier",
|
||||
line: ".AVIATION...",
|
||||
wantSection: forecastDiscussionSectionAviation,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "slash-qualified key messages",
|
||||
line: ".KEY MESSAGES /Tonight/...",
|
||||
wantSection: forecastDiscussionSectionKeyMessages,
|
||||
wantQualifier: "Tonight",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "slash-qualified short term with outer whitespace",
|
||||
line: " .SHORT TERM /Through Late Sunday Night/... ",
|
||||
wantSection: forecastDiscussionSectionShortTerm,
|
||||
wantQualifier: "Through Late Sunday Night",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "slash-qualified long term",
|
||||
line: ".LONG TERM /Monday through Next Saturday/...",
|
||||
wantSection: forecastDiscussionSectionLongTerm,
|
||||
wantQualifier: "Monday through Next Saturday",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "slash-qualified aviation",
|
||||
line: ".AVIATION /18Z TAFS/...",
|
||||
wantSection: forecastDiscussionSectionAviation,
|
||||
wantQualifier: "18Z TAFS",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "unknown identity",
|
||||
line: ".SYNOPSIS... Overview",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier missing opening slash",
|
||||
line: ".SHORT TERM Through Tonight/...",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier missing closing slash",
|
||||
line: ".SHORT TERM /Through Tonight...",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier contains embedded slash",
|
||||
line: ".SHORT TERM /Tonight/Sunday/...",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier whitespace only",
|
||||
line: ".SHORT TERM / /...",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier missing required whitespace",
|
||||
line: ".SHORT TERM/Through Tonight/...",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier missing final ellipsis",
|
||||
line: ".SHORT TERM /Through Tonight/..",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "slash qualifier has trailing text",
|
||||
line: ".SHORT TERM /Through Tonight/... more",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "lowercase identity",
|
||||
line: ".short term... (Tonight)",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseForecastDiscussionSectionHeading(tt.line)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseForecastDiscussionSectionHeading(%q) ok = %t, want %t", tt.line, ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if got.section != tt.wantSection {
|
||||
t.Fatalf("section = %q, want %q", got.section, tt.wantSection)
|
||||
}
|
||||
if got.qualifier != tt.wantQualifier {
|
||||
t.Fatalf("qualifier = %q, want %q", got.qualifier, tt.wantQualifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForecastDiscussionHTMLParsesExpectedFields(t *testing.T) {
|
||||
raw := loadForecastDiscussionSampleHTML(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user