1// Package atsc3 reads the RabbitEars.info list of ATSC 3.0 (NextGen TV)2// transmitters and the services each one carries.3package atsc345import (6 "bytes"7 "context"8 "fmt"9 "net/http"10 "net/url"11 "regexp"12 "strconv"13 "strings"1415 "golang.org/x/net/html"1617 "airwaves/internal/web"18)1920const listURL = "https://www.rabbitears.info/market.php?request=atsc3"2122// Host is a transmitter broadcasting ATSC 3.0.23type Host struct {24 Market string `json:"market"`25 CallSign string `json:"callSign"`26 FacilityID int `json:"facilityId"`27 RF string `json:"rf"` // may describe a move, e.g. "2 → 3"28 Launched string `json:"launched"`29 Simulcast string `json:"simulcast"` // station hosting this one's ATSC 1.0 signal, or "N/A"30 Services []Service `json:"services"`31}3233// Service is one virtual channel inside an ATSC 3.0 multiplex.34type Service struct {35 Display string `json:"display"` // "02-1"36 Major int `json:"major"`37 Minor int `json:"minor"`38 Network string `json:"network"`39 Name string `json:"name"` // usually the originating call sign40 // ATSC1Call is the transmitter carrying this service's ATSC 1.041 // simulcast, when there is one.42 ATSC1Call string `json:"atsc1Call"`43 ATSC1Display string `json:"atsc1Display"`44}4546// Fetch downloads and parses the national ATSC 3.0 list.47func Fetch(ctx context.Context, c *http.Client) ([]Host, error) {48 body, err := web.Get(ctx, c, listURL)49 if err != nil {50 return nil, fmt.Errorf("rabbitears atsc3 list: %w", err)51 }52 return Parse(body)53}5455type cell struct {56 text string57 href string58}5960var (61 marketRank = regexp.MustCompile(`^\d+$`)62 display = regexp.MustCompile(`^(\d+)-(\d+)$`)63 source = regexp.MustCompile(`^\(\s*(\S+)\s+(\d+-\d+)\s*\)$`)64)6566// Parse decodes the RabbitEars ATSC 3.0 list page. The page is one table:67// market header rows (rank, name), host rows (call, RF, launch date, 1.068// simulcast host) and service rows (bullet, display channel, network, name,69// 1.0 source).70func Parse(page []byte) ([]Host, error) {71 doc, err := html.Parse(bytes.NewReader(page))72 if err != nil {73 return nil, fmt.Errorf("parse atsc3 list: %w", err)74 }75 var hosts []Host76 var market string77 for _, row := range rows(doc) {78 switch {79 case len(row) == 2 && marketRank.MatchString(row[0].text):80 market = row[1].text81 case len(row) == 5 && row[0].text == "" && market != "" && row[1].text != "":82 hosts = append(hosts, Host{83 Market: market,84 CallSign: row[1].text,85 FacilityID: facilityID(row[1].href),86 RF: row[2].text,87 Launched: row[3].text,88 Simulcast: row[4].text,89 })90 case len(row) == 6 && row[1].text == "•" && len(hosts) > 0:91 m := display.FindStringSubmatch(row[2].text)92 if m == nil {93 continue94 }95 s := Service{Display: row[2].text, Network: row[3].text, Name: row[4].text}96 s.Major, _ = strconv.Atoi(m[1])97 s.Minor, _ = strconv.Atoi(m[2])98 if src := source.FindStringSubmatch(row[5].text); src != nil {99 s.ATSC1Call, s.ATSC1Display = src[1], src[2]100 }101 h := &hosts[len(hosts)-1]102 h.Services = append(h.Services, s)103 }104 }105 if len(hosts) == 0 {106 return nil, fmt.Errorf("parse atsc3 list: no stations found, page layout may have changed")107 }108 return hosts, nil109}110111// facilityID extracts the facility ID from links like112// "market.php?request=station_search&callsign=56043#station".113func facilityID(href string) int {114 u, err := url.Parse(href)115 if err != nil {116 return 0117 }118 id, _ := strconv.Atoi(u.Query().Get("callsign"))119 return id120}121122func rows(n *html.Node) [][]cell {123 var out [][]cell124 var walk func(*html.Node)125 walk = func(n *html.Node) {126 if n.Type == html.ElementNode && n.Data == "tr" {127 var row []cell128 for c := n.FirstChild; c != nil; c = c.NextSibling {129 if c.Type == html.ElementNode && (c.Data == "td" || c.Data == "th") {130 row = append(row, cell{text: text(c), href: firstHref(c)})131 }132 }133 out = append(out, row)134 }135 for c := n.FirstChild; c != nil; c = c.NextSibling {136 walk(c)137 }138 }139 walk(n)140 return out141}142143func text(n *html.Node) string {144 var b strings.Builder145 var walk func(*html.Node)146 walk = func(n *html.Node) {147 if n.Type == html.TextNode {148 b.WriteString(n.Data)149 }150 for c := n.FirstChild; c != nil; c = c.NextSibling {151 walk(c)152 }153 }154 walk(n)155 return strings.Join(strings.Fields(strings.ReplaceAll(b.String(), " ", " ")), " ")156}157158func firstHref(n *html.Node) string {159 if n.Type == html.ElementNode && n.Data == "a" {160 for _, a := range n.Attr {161 if a.Key == "href" {162 return a.Val163 }164 }165 }166 for c := n.FirstChild; c != nil; c = c.NextSibling {167 if h := firstHref(c); h != "" {168 return h169 }170 }171 return ""172}