Files
paragliding/backend/internal/client/faa.go
scott 4974780d89 Add TFR warning banner for airspace restrictions
Fetches active Temporary Flight Restrictions from the FAA website,
filters by configured state (LOCATION_STATE env var), and displays
a red warning banner at the top of the dashboard when TFRs are present.
Data is cached for 30 minutes and degrades gracefully if the FAA is unreachable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:44:13 -08:00

137 lines
3.3 KiB
Go

package client
import (
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/scottyah/paragliding/internal/model"
)
const (
faaTFRListURL = "https://tfr.faa.gov/tfr2/list.jsp"
faaBaseURL = "https://tfr.faa.gov"
)
// FAAClient fetches TFR data from the FAA website
type FAAClient struct {
httpClient *http.Client
}
// NewFAAClient creates a new FAA TFR client
func NewFAAClient() *FAAClient {
return &FAAClient{
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
var (
anchorRe = regexp.MustCompile(`(?is)<a\s+([^>]*)>(.*?)</a>`)
hrefRe = regexp.MustCompile(`(?i)href="([^"]*)"`)
dateRe = regexp.MustCompile(`^\d{2}/\d{2}/\d{4}$`)
htmlTagRe = regexp.MustCompile(`<[^>]*>`)
)
type anchor struct {
href string
text string
}
// FetchTFRs retrieves the list of active TFRs from the FAA website
func (c *FAAClient) FetchTFRs(ctx context.Context) ([]model.TFR, error) {
req, err := http.NewRequestWithContext(ctx, "GET", faaTFRListURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "ParaglidingWeatherApp/1.0")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch TFR list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TFR list returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return parseTFRList(string(body)), nil
}
// extractAnchors finds all <a> tags in HTML and returns their href and text content
func extractAnchors(html string) []anchor {
matches := anchorRe.FindAllStringSubmatch(html, -1)
anchors := make([]anchor, 0, len(matches))
for _, m := range matches {
text := htmlTagRe.ReplaceAllString(m[2], "")
text = strings.TrimSpace(text)
a := anchor{text: text}
if hrefMatch := hrefRe.FindStringSubmatch(m[1]); hrefMatch != nil {
a.href = hrefMatch[1]
}
anchors = append(anchors, a)
}
return anchors
}
// parseTFRList extracts TFR entries from the FAA list page HTML.
// The page contains a table where each row has anchor tags for:
// date, notam ID (with detail link), facility, state, type, description, zoom link
func parseTFRList(html string) []model.TFR {
rows := strings.Split(html, "<tr")
var tfrs []model.TFR
for _, row := range rows {
anchors := extractAnchors(row)
if len(anchors) < 6 {
continue
}
// First anchor should contain a date (mm/dd/yyyy)
if !dateRe.MatchString(anchors[0].text) {
continue
}
// Build detail URL from the NOTAM anchor's href
detailURL := anchors[1].href
if detailURL != "" {
detailURL = strings.Replace(detailURL, "..", faaBaseURL, 1)
detailURL = strings.ReplaceAll(detailURL, "\n", "")
detailURL = strings.ReplaceAll(detailURL, "\r", "")
}
desc := anchors[5].text
desc = strings.ReplaceAll(desc, "\n", "")
desc = strings.ReplaceAll(desc, "\r", "")
// Collapse multiple spaces
for strings.Contains(desc, " ") {
desc = strings.ReplaceAll(desc, " ", " ")
}
tfr := model.TFR{
Date: anchors[0].text,
NotamID: anchors[1].text,
Facility: anchors[2].text,
State: anchors[3].text,
Type: anchors[4].text,
Description: strings.TrimSpace(desc),
DetailURL: detailURL,
}
tfrs = append(tfrs, tfr)
}
return tfrs
}