package nws import ( "fmt" "html" "regexp" "strconv" "strings" "time" ) type ForecastDiscussion struct { OfficeID string OfficeName string Product string IssuedAt time.Time UpdatedAt *time.Time KeyMessages []string ShortTerm *ForecastDiscussionSection LongTerm *ForecastDiscussionSection } type ForecastDiscussionSection struct { Qualifier string IssuedAt *time.Time 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 ( forecastDiscussionSectionRoles = map[string]forecastDiscussionSectionRole{ "KEY MESSAGES": forecastDiscussionSectionRoleKeyMessages, "KEY POINTS": forecastDiscussionSectionRoleKeyMessages, "SHORT TERM": forecastDiscussionSectionRoleShortTerm, "LONG TERM": forecastDiscussionSectionRoleLongTerm, } 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) { text, err := ExtractForecastDiscussionText(raw) if err != nil { return ForecastDiscussion{}, err } parsed, err := ParseForecastDiscussionText(text) if err != nil { return ForecastDiscussion{}, err } parsed.UpdatedAt = parseForecastDiscussionUpdatedAt(raw) return parsed, nil } func ExtractForecastDiscussionText(raw string) (string, error) { lower := strings.ToLower(raw) searchFrom := 0 for { openStart := strings.Index(lower[searchFrom:], " block") } openStart += searchFrom openEnd := strings.Index(lower[openStart:], ">") if openEnd < 0 { return "", fmt.Errorf("unterminated
 tag")
		}
		openEnd += openStart

		tag := lower[openStart : openEnd+1]
		if isGlossaryProductTag(tag) {
			closeStart := strings.Index(lower[openEnd+1:], "
") if closeStart < 0 { return "", fmt.Errorf("missing closing for glossaryProduct block") } closeStart += openEnd + 1 text := html.UnescapeString(raw[openEnd+1 : closeStart]) text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r", "\n") return text, nil } searchFrom = openEnd + 1 } } func ParseForecastDiscussionText(text string) (ForecastDiscussion, error) { lines := splitLines(text) officeID := parseForecastDiscussionOfficeID(lines) officeName, issuedAt, err := parseForecastDiscussionHeader(lines) if err != nil { return ForecastDiscussion{}, err } out := ForecastDiscussion{ OfficeID: officeID, OfficeName: officeName, Product: "afd", IssuedAt: issuedAt.UTC(), } seenRoles := make(map[forecastDiscussionSectionRole]bool, len(forecastDiscussionSectionRoles)) for _, block := range parseForecastDiscussionSectionBlocks(lines) { role, ok := forecastDiscussionSectionRoles[block.heading.section] if !ok || seenRoles[role] { continue } seenRoles[role] = true switch role { case forecastDiscussionSectionRoleKeyMessages: out.KeyMessages = parseForecastDiscussionKeyMessages(block.body) case forecastDiscussionSectionRoleShortTerm: section, err := parseForecastDiscussionTextSection(block) if err != nil { return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) } out.ShortTerm = §ion case forecastDiscussionSectionRoleLongTerm: section, err := parseForecastDiscussionTextSection(block) if err != nil { return ForecastDiscussion{}, fmt.Errorf("parse %s: %w", block.heading.section, err) } out.LongTerm = §ion } } return out, nil } func isGlossaryProductTag(tag string) bool { tag = strings.ToLower(tag) return strings.Contains(tag, `class="glossaryproduct"`) || strings.Contains(tag, `class='glossaryproduct'`) || strings.Contains(tag, `class="glossaryproduct `) || strings.Contains(tag, `class='glossaryproduct `) } func parseForecastDiscussionUpdatedAt(raw string) *time.Time { lower := strings.ToLower(raw) searchFrom := 0 for { metaStart := strings.Index(lower[searchFrom:], "") if metaEnd < 0 { return nil } metaEnd += metaStart tag := raw[metaStart : metaEnd+1] if !strings.EqualFold(strings.TrimSpace(extractHTMLAttr(tag, "name")), "DC.date.created") { searchFrom = metaEnd + 1 continue } content := strings.TrimSpace(extractHTMLAttr(tag, "content")) if content == "" { return nil } t, err := ParseTime(content) if err != nil { return nil } tt := t.UTC() return &tt } } func extractHTMLAttr(tag, attr string) string { lower := strings.ToLower(tag) attrLower := strings.ToLower(attr) for i := 0; i < len(lower); i++ { idx := strings.Index(lower[i:], attrLower) if idx < 0 { return "" } idx += i if idx > 0 { prev := lower[idx-1] if isAttrNameChar(prev) { i = idx + len(attrLower) continue } } j := idx + len(attrLower) for j < len(lower) && isHTMLSpace(lower[j]) { j++ } if j >= len(lower) || lower[j] != '=' { i = idx + len(attrLower) continue } j++ for j < len(lower) && isHTMLSpace(lower[j]) { j++ } if j >= len(tag) { return "" } quote := tag[j] if quote != '"' && quote != '\'' { return "" } j++ k := j for k < len(tag) && tag[k] != quote { k++ } if k >= len(tag) { return "" } return html.UnescapeString(tag[j:k]) } return "" } func isHTMLSpace(b byte) bool { switch b { case ' ', '\n', '\r', '\t', '\f': return true default: return false } } func isAttrNameChar(b byte) bool { switch { case b >= 'a' && b <= 'z': return true case b >= 'A' && b <= 'Z': return true case b >= '0' && b <= '9': return true case b == '-' || b == '_' || b == ':': return true default: return false } } func splitLines(text string) []string { text = strings.ReplaceAll(text, "\r\n", "\n") text = strings.ReplaceAll(text, "\r", "\n") return strings.Split(text, "\n") } func parseForecastDiscussionOfficeID(lines []string) string { for _, raw := range lines { line := strings.TrimSpace(raw) if m := forecastDiscussionAFDRE.FindStringSubmatch(line); len(m) == 2 { return m[1] } } for _, raw := range lines { line := strings.TrimSpace(raw) if m := forecastDiscussionWMORE.FindStringSubmatch(line); len(m) == 2 { return m[1] } } return "" } func parseForecastDiscussionHeader(lines []string) (string, time.Time, error) { for i, raw := range lines { line := strings.TrimSpace(raw) if !strings.HasPrefix(line, "National Weather Service ") { continue } officeName := line for j := i + 1; j < len(lines); j++ { tsLine := strings.TrimSpace(lines[j]) if tsLine == "" { continue } issuedAt, err := parseForecastDiscussionIssueTime(tsLine) if err != nil { return "", time.Time{}, fmt.Errorf("parse bulletin issuedAt %q: %w", tsLine, err) } return officeName, issuedAt.UTC(), nil } return "", time.Time{}, fmt.Errorf("missing bulletin issue time after office line") } return "", time.Time{}, fmt.Errorf("missing office header") } func parseForecastDiscussionIssueTime(line string) (time.Time, error) { line = strings.TrimSpace(line) if isForecastDiscussionIssuedAtLine(line) { line = strings.TrimSpace(line[len("Issued at"):]) } parts := strings.Fields(line) if len(parts) != 7 { return time.Time{}, fmt.Errorf("unexpected issue time format") } loc, err := forecastDiscussionLocation(parts[2]) if err != nil { return time.Time{}, err } datePart, err := time.Parse("Mon Jan 2 2006", strings.Join(parts[3:], " ")) if err != nil { return time.Time{}, err } hour, minute, err := parseForecastDiscussionClock(parts[0], parts[1]) if err != nil { return time.Time{}, err } return time.Date( datePart.Year(), datePart.Month(), datePart.Day(), hour, minute, 0, 0, loc, ), nil } func parseForecastDiscussionClock(rawClock, rawAMPM string) (int, int, error) { clock := strings.TrimSpace(rawClock) ampm := strings.ToUpper(strings.TrimSpace(rawAMPM)) if ampm != "AM" && ampm != "PM" { return 0, 0, fmt.Errorf("unexpected meridiem %q", rawAMPM) } n, err := strconv.Atoi(clock) if err != nil { return 0, 0, fmt.Errorf("invalid clock %q", rawClock) } hour := n minute := 0 if len(clock) >= 3 { hour = n / 100 minute = n % 100 } if hour < 1 || hour > 12 { return 0, 0, fmt.Errorf("invalid hour %q", rawClock) } if minute < 0 || minute > 59 { return 0, 0, fmt.Errorf("invalid minute %q", rawClock) } if ampm == "AM" { if hour == 12 { hour = 0 } return hour, minute, nil } if hour != 12 { hour += 12 } return hour, minute, nil } func forecastDiscussionLocation(abbrev string) (*time.Location, error) { offsets := map[string]int{ "AST": -4 * 3600, "ADT": -3 * 3600, "EST": -5 * 3600, "EDT": -4 * 3600, "CST": -6 * 3600, "CDT": -5 * 3600, "MST": -7 * 3600, "MDT": -6 * 3600, "PST": -8 * 3600, "PDT": -7 * 3600, "AKST": -9 * 3600, "AKDT": -8 * 3600, "HST": -10 * 3600, "UTC": 0, "GMT": 0, } abbr := strings.ToUpper(strings.TrimSpace(abbrev)) offset, ok := offsets[abbr] if !ok { return nil, fmt.Errorf("unsupported time zone %q", abbrev) } return time.FixedZone(abbr, offset), nil } func parseForecastDiscussionSectionHeading(line string) (forecastDiscussionSectionHeading, bool) { line = strings.TrimSpace(line) 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) if line == "$$" { finish() break } if line == "&&" || strings.Contains(line, "WATCHES/WARNINGS/ADVISORIES") { finish() embeddedHeadings = false continue } heading, ok := parseForecastDiscussionSectionHeading(raw) if ok { finish() active = &forecastDiscussionSectionBlock{heading: heading} embeddedHeadings = isForecastDiscussionEmbeddedSectionWrapper(heading.section) continue } if embeddedHeadings { heading, ok = parseForecastDiscussionEmbeddedSectionHeading(raw) if ok { finish() active = &forecastDiscussionSectionBlock{heading: heading} continue } } if active != nil { active.body = append(active.body, raw) } } finish() return blocks } func isForecastDiscussionEmbeddedSectionWrapper(section string) bool { return section == "PREV DISCUSSION" } func parseForecastDiscussionEmbeddedSectionHeading(line string) (forecastDiscussionSectionHeading, bool) { 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 } hasMarkers := false for _, raw := range body { line := strings.TrimSpace(raw) if line == "" { continue } if _, ok := stripForecastDiscussionKeyMessageMarker(line); ok { hasMarkers = true break } } var messages []string var current strings.Builder flush := func() { msg := strings.TrimSpace(current.String()) if msg != "" { messages = append(messages, msg) } current.Reset() } seenMarker := false for _, raw := range body { line := strings.TrimSpace(raw) if line == "" { if !hasMarkers || !seenMarker { flush() } continue } if stripped, ok := stripForecastDiscussionKeyMessageMarker(line); ok { flush() seenMarker = true line = stripped } if line == "" { continue } if current.Len() > 0 { current.WriteByte(' ') } current.WriteString(line) } flush() return messages } func parseForecastDiscussionTextSection(block forecastDiscussionSectionBlock) (ForecastDiscussionSection, error) { section := ForecastDiscussionSection{ Qualifier: block.heading.qualifier, } 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 { return section, nil } if isForecastDiscussionIssuedAtLine(body[0]) { issuedAt, err := parseForecastDiscussionIssueTime(body[0]) if err != nil { return ForecastDiscussionSection{}, fmt.Errorf("parse section issuedAt %q: %w", strings.TrimSpace(body[0]), err) } tt := issuedAt.UTC() section.IssuedAt = &tt body = trimBlankLines(body[1:]) } body = trimForecastDiscussionSignatureLines(body) section.Text = joinForecastDiscussionParagraphs(body) return section, nil } func isForecastDiscussionPresentationMarker(line string) bool { switch { case strings.EqualFold(strings.TrimSpace(line), "-- Changed Discussion --"): return true case strings.EqualFold(strings.TrimSpace(line), "-- End Changed Discussion --"): return true default: return false } } 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 { start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" { start++ } end := len(lines) for end > start && strings.TrimSpace(lines[end-1]) == "" { end-- } return lines[start:end] } func trimForecastDiscussionSignatureLines(lines []string) []string { lines = trimBlankLines(lines) for len(lines) > 0 { last := strings.TrimSpace(lines[len(lines)-1]) if last == "" { lines = lines[:len(lines)-1] continue } if forecastDiscussionSigRE.MatchString(last) { lines = trimBlankLines(lines[:len(lines)-1]) continue } break } return lines } func joinForecastDiscussionParagraphs(lines []string) string { lines = trimBlankLines(lines) if len(lines) == 0 { return "" } var paragraphs []string current := make([]string, 0, len(lines)) flush := func() { if len(current) == 0 { return } paragraphs = append(paragraphs, strings.Join(current, " ")) current = current[:0] } for _, raw := range lines { line := strings.TrimSpace(raw) if line == "" { flush() continue } current = append(current, line) } flush() return strings.Join(paragraphs, "\n\n") }