Finalize SPC outlook feature addition and clean up implemented roadmap documentation
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -13,6 +13,8 @@ var (
|
||||
preBlockRE = regexp.MustCompile(`(?is)<pre\b[^>]*>(.*?)</pre>`)
|
||||
tagRE = regexp.MustCompile(`(?is)<[^>]+>`)
|
||||
updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`)
|
||||
pageUpdatedRE = regexp.MustCompile(`(?i)\bUpdated:\s*((?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(?:[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+UTC\s+\d{4})|(?:\d{4}\s+UTC\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})|(?:\d{4}Z\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}))`)
|
||||
productCodeRE = regexp.MustCompile(`(?i)^SPC\s+AC\s+\d+\s*$`)
|
||||
sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`)
|
||||
)
|
||||
|
||||
@@ -41,6 +43,20 @@ func ExtractProductText(rawHTML string) (string, error) {
|
||||
return "", fmt.Errorf("no useful pre block found")
|
||||
}
|
||||
|
||||
// ParseDiscussionHTML extracts SPC product text and page-level metadata from a
|
||||
// print-page HTML document.
|
||||
func ParseDiscussionHTML(rawHTML string) (DiscussionText, error) {
|
||||
text, err := ExtractProductText(rawHTML)
|
||||
if err != nil {
|
||||
return DiscussionText{}, err
|
||||
}
|
||||
parsed := ParseDiscussionText(text)
|
||||
if updatedAt := ParsePageUpdatedTimestamp(rawHTML); updatedAt != nil {
|
||||
parsed.UpdatedAt = updatedAt
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ParseDiscussionText extracts common SPC narrative metadata from cleaned
|
||||
// product text.
|
||||
func ParseDiscussionText(text string) DiscussionText {
|
||||
@@ -55,6 +71,19 @@ func ParseDiscussionText(text string) DiscussionText {
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePageUpdatedTimestamp parses the page-level Updated row from an SPC print
|
||||
// page. SPC currently places this outside the product <pre> block.
|
||||
func ParsePageUpdatedTimestamp(rawHTML string) *time.Time {
|
||||
text := cleanHTMLText(rawHTML)
|
||||
text = strings.ReplaceAll(text, "\u00a0", " ")
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
match := pageUpdatedRE.FindStringSubmatch(text)
|
||||
if len(match) != 2 {
|
||||
return nil
|
||||
}
|
||||
return parseUpdatedValue(match[1])
|
||||
}
|
||||
|
||||
// ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
|
||||
func ParseUpdatedTimestamp(text string) *time.Time {
|
||||
match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
|
||||
@@ -68,7 +97,7 @@ func ParseUpdatedTimestamp(text string) *time.Time {
|
||||
func ParseProductTitle(text string) string {
|
||||
for _, line := range strings.Split(normalizeNewlines(text), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Updated:") {
|
||||
if line == "" || strings.HasPrefix(line, "Updated:") || productCodeRE.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
@@ -142,6 +171,7 @@ func parseUpdatedValue(value string) *time.Time {
|
||||
return t
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"Mon Jan 2 15:04:05 UTC 2006",
|
||||
"1504 UTC Mon Jan 2 2006",
|
||||
"1504Z Mon Jan 2 2006",
|
||||
"3:04 PM UTC Mon Jan 2 2006",
|
||||
|
||||
@@ -22,18 +22,19 @@ func TestExtractProductTextCleansPreBlock(t *testing.T) {
|
||||
if !strings.Contains(got, "Day 1 Convective Outlook") {
|
||||
t.Fatalf("ExtractProductText() missing headline: %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "SPC AC 111234") {
|
||||
t.Fatalf("ExtractProductText() = %q, want product code prefix", 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")))
|
||||
func TestParseDiscussionHTMLExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||
got, err := ParseDiscussionHTML(string(readTestFile(t, "day1_prt.html")))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractProductText() error = %v", err)
|
||||
t.Fatalf("ParseDiscussionHTML() error = %v", err)
|
||||
}
|
||||
|
||||
got := ParseDiscussionText(text)
|
||||
if got.ProductTitle != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("ProductTitle = %q", got.ProductTitle)
|
||||
}
|
||||
@@ -47,27 +48,37 @@ func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
|
||||
if !strings.Contains(got.Discussion, "...DISCUSSION...") {
|
||||
t.Fatalf("Discussion missing full text: %q", got.Discussion)
|
||||
}
|
||||
if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
|
||||
t.Fatalf("Discussion = %q, want product code prefix", 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 TestParseProductTitleSkipsSPCProductCode(t *testing.T) {
|
||||
got := ParseProductTitle("SPC AC 101959\nDay 1 Convective Outlook\nNWS Storm Prediction Center Norman OK")
|
||||
if got != "Day 1 Convective Outlook" {
|
||||
t.Fatalf("ParseProductTitle() = %q, want Day 1 Convective Outlook", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
|
||||
text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
|
||||
got, err := ParseDiscussionHTML(string(readTestFile(t, "day2_prt_corr.html")))
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractProductText() error = %v", err)
|
||||
t.Fatalf("ParseDiscussionHTML() 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)
|
||||
wantUpdated := time.Date(2026, 6, 11, 17, 30, 0, 0, time.UTC)
|
||||
if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
|
||||
t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +99,11 @@ func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
|
||||
t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageUpdatedTimestampAcceptsLiveSPCShape(t *testing.T) {
|
||||
got := ParsePageUpdatedTimestamp(string(readTestFile(t, "day3_prt.html")))
|
||||
want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
|
||||
if got == nil || !got.Equal(want) {
|
||||
t.Fatalf("ParsePageUpdatedTimestamp() = %v, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
<html>
|
||||
<head><title>Day 1 Convective Outlook</title></head>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td align="center" class="rpttext" nowrap>Updated: Thu Jun 11 12:45:00 UTC 2026 (<a href="archive/day1-geojson.zip">geojson</a>)</td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
<script>window.bad = "<b>ignore me</b>";</script>
|
||||
SPC AC 111234
|
||||
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
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td class="rpttext">Updated: Thu Jun 11 17:30:00 UTC 2026 </td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
SPC AC 111730
|
||||
Day 2 Convective Outlook CORR 1
|
||||
NWS Storm Prediction Center Norman OK
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
<tr><td class="rpttext">Updated: Thu Jun 11 20:00:00 UTC 2026 </td></tr>
|
||||
</table>
|
||||
<pre>
|
||||
SPC AC 112000
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user