metar-0.0.5: src/Data/Aviation/Metar.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wall #-}
{- FOURMOLU_DISABLE -}
-- $setup
-- >>> import Text.HTML.TagSoup (parseTags)
{- FOURMOLU_ENABLE -}
-- | Fetching METAR observations from the BOM (Australia) and NOAA (rest of world).
module Data.Aviation.Metar (
getBOMMETAR,
getNOAAMETAR,
getMETAR,
runMETAR,
) where
import Control.Exception (catch)
import Control.Lens (view, (&), (.~), (^.), _Wrapped)
import Control.Monad (unless)
import Control.Monad.Trans.Class (lift)
import Data.Aviation.Metar.Cache (mergeCache, readCache)
import Data.Aviation.Metar.METARError (METARError (ConnErrorAt, ParseErrorAt))
import Data.Aviation.Metar.METARResult (METARResult (METARResultFailure, METARResultValue))
import Data.Aviation.Metar.METARResultT (METARResultT (METARResultT))
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BS
import Data.Char (isAsciiUpper, isDigit, isSpace, toUpper)
import Data.Functor.Alt ((<!>))
import Data.List.NonEmpty (NonEmpty ((:|)))
import Network.HTTP.Client (HttpException (HttpExceptionRequest, InvalidUrlException), HttpExceptionContent (ConnectionTimeout, ResponseTimeout, StatusCodeException), responseStatus)
import Network.HTTP.Types.Status (statusCode)
import Network.Stream (ConnError (ErrorMisc))
import Network.Wreq (FormParam ((:=)), Options, defaults, getWith, headers, postWith, responseBody)
import qualified Network.Wreq as Wreq (Response)
import System.IO (hPutStrLn, stderr)
import Text.HTML.TagSoup (Tag (TagClose, TagOpen, TagText), parseTags)
-- | The seven BOM state selectors accepted by @process.php@.
--
-- >>> length bomStates
-- 7
-- >>> head bomStates
-- "New-South-Wales"
-- >>> last bomStates
-- "Northern-Territory"
bomStates ::
[String]
bomStates =
[ "New-South-Wales"
, "Victoria"
, "Queensland"
, "Western-Australia"
, "South-Australia"
, "Tasmania"
, "Northern-Territory"
]
-- | HTTP options used when POSTing to the BOM aviation observations endpoint.
--
-- >>> :t bomOptions
-- bomOptions :: Options
bomOptions ::
Options
bomOptions =
defaults
& headers
.~ [ ("User-Agent", "tonymorris/metar")
, ("Accept", "*/*")
, ("Accept-Language", "en-US,en;q=0.5")
, ("Referer", "https://www.bom.gov.au/aviation/observations/metar-speci/")
, ("X-Requested-With", "XMLHttpRequest")
, ("Cookie", "check=ok")
]
-- | Translate an 'HttpException' into a 'METARError' tagged with a source.
-- 404s become 'ParseErrorAt' (upstream doesn't have this ICAO); other
-- failures become 'ConnErrorAt' with a short label.
--
-- >>> :t classifyHttp
-- classifyHttp :: String -> HttpException -> METARError
classifyHttp ::
String ->
HttpException ->
METARError
classifyHttp src (HttpExceptionRequest _ content) =
case content of
StatusCodeException resp _ ->
case statusCode (responseStatus resp) of
404 -> ParseErrorAt src "HTTP 404"
n -> ConnErrorAt src (ErrorMisc ("HTTP " <> show n))
ResponseTimeout ->
ConnErrorAt src (ErrorMisc "response timeout")
ConnectionTimeout ->
ConnErrorAt src (ErrorMisc "connection timeout")
other ->
ConnErrorAt src (ErrorMisc (show other))
classifyHttp src (InvalidUrlException url reason) =
ConnErrorAt src (ErrorMisc ("invalid URL: " ++ url ++ " (" ++ reason ++ ")"))
-- | Build a single-element failure result.
--
-- >>> :t failWith
-- failWith :: METARError -> METARResult a
failWith ::
METARError ->
METARResult a
failWith e =
METARResultFailure (e :| [])
-- | Extract @(ICAO, product-text)@ pairs from a BOM aviation response body.
--
-- >>> extractStations (parseTags "<h3>SYDNEY YSSY 28/07/2026 UTC</h3><p class=\"product\">METAR YSSY 280600Z NCD Q1018</p>")
-- [("YSSY","METAR YSSY 280600Z NCD Q1018")]
-- >>> extractStations (parseTags "<h3>Nothing here</h3><p>ignored</p>")
-- []
-- >>> extractStations []
-- []
extractStations ::
[Tag String] ->
[(String, String)]
extractStations tags =
let go (TagOpen "h3" _ : ts) =
let (title, ts') = takeText ts
(product', ts'') = pickProduct ts'
in case (extractIcao title, product') of
(Just i, Just p) -> (i, p) : go ts''
_ -> go ts''
go (_ : ts) = go ts
go [] = []
in go tags
-- | Concatenate 'TagText' contents up to the next @\</h3\>@.
--
-- >>> fst (takeText (parseTags "hello <b>world</b></h3>rest"))
-- "hello world"
-- >>> takeText []
-- ("",[])
takeText ::
[Tag String] ->
(String, [Tag String])
takeText =
let go acc (TagClose "h3" : rest) = (acc, rest)
go acc (TagText t : rest) = go (acc <> t) rest
go acc (_ : rest) = go acc rest
go acc [] = (acc, [])
in go ""
-- | Find and consume the next @\<p class="product"\>...\</p\>@, returning its
-- text and the tags following. If a fresh @\<h3\>@ appears first we stop and
-- rewind, so the caller can handle it.
--
-- >>> fst (pickProduct (parseTags "<p class=\"product\">METAR YSSY hi</p>rest"))
-- Just "METAR YSSY hi"
-- >>> fst (pickProduct (parseTags "<p>not product</p>"))
-- Nothing
-- >>> fst (pickProduct [])
-- Nothing
pickProduct ::
[Tag String] ->
(Maybe String, [Tag String])
pickProduct (TagOpen "p" attrs : rest)
| lookup "class" attrs == Just "product" =
let (inside, rest') = break isPClose rest
txt = concatMap tagText inside
in (Just txt, drop 1 rest')
where
isPClose (TagClose "p") = True
isPClose _ = False
tagText (TagText t) = t
tagText (TagOpen "br" _) = " "
tagText _ = ""
pickProduct (TagOpen "h3" _ : rest) =
(Nothing, TagOpen "h3" [] : rest)
pickProduct (_ : rest) =
pickProduct rest
pickProduct [] =
(Nothing, [])
-- | Pull the ICAO out of an aerodrome heading. Returns the last four-letter
-- upper-case-alphanumeric token in the title (so multi-word airport names work).
--
-- >>> extractIcao "SYDNEY YSSY 28/07/2026 UTC"
-- Just "YSSY"
-- >>> extractIcao "MOUNT ISA YBMA 28/07/2026 UTC"
-- Just "YBMA"
-- >>> extractIcao "no icao here"
-- Nothing
-- >>> extractIcao ""
-- Nothing
extractIcao ::
String ->
Maybe String
extractIcao title =
let ws = wordsBySpace title
isIcao s = length s == 4 && all (\c -> isAsciiUpper c || isDigit c) s
lastIcao (s : rest)
| isIcao s = case lastIcao rest of
Just later -> Just later
Nothing -> Just s
| otherwise = lastIcao rest
lastIcao [] = Nothing
in lastIcao ws
-- | Split on any whitespace. Equivalent to 'Prelude.words'.
--
-- >>> wordsBySpace "hello world"
-- ["hello","world"]
-- >>> wordsBySpace " spaced out "
-- ["spaced","out"]
-- >>> wordsBySpace ""
-- []
wordsBySpace ::
String ->
[String]
wordsBySpace s =
case dropWhile isSpace s of
"" -> []
s' -> let (w, s'') = break isSpace s' in w : wordsBySpace s''
-- | Locate the METAR/SPECI product line for a given ICAO in a list of parsed
-- station entries.
--
-- >>> findMETAR "YSSY" [("YSSY", "METAR YSSY 280600Z NCD Q1018")]
-- Just "METAR YSSY 280600Z NCD Q1018"
-- >>> findMETAR "YSSY" [("YSSY", "SPECI YSSY 280601Z 27015G30KT Q1017")]
-- Just "SPECI YSSY 280601Z 27015G30KT Q1017"
-- >>> findMETAR "YSSY" [("YMML", "METAR YMML 280600Z ...")]
-- Nothing
-- >>> findMETAR "YSSY" []
-- Nothing
-- >>> findMETAR "YSSY" [("YSSY", "not a metar")]
-- Nothing
findMETAR ::
String ->
[(String, String)] ->
Maybe String
findMETAR icao =
let matches (i, p) =
i == icao && (isPrefixOf "METAR " p || isPrefixOf "SPECI " p)
go [] = Nothing
go (s : ss) = if matches s then Just (snd s) else go ss
in go
where
isPrefixOf p s = take (length p) s == p
-- | POST to @process.php@ for one BOM state selector. Network exceptions are
-- caught and returned in the 'Left'.
--
-- >>> :t requestState
-- requestState
-- :: String -> IO (Either HttpException (Wreq.Response ByteString))
requestState ::
String ->
IO (Either HttpException (Wreq.Response ByteString))
requestState state =
let url = "https://www.bom.gov.au/aviation/php/process.php"
body :: [FormParam]
body =
[ "state" := state
, "page" := ("metar-speci" :: String)
]
in catch (fmap Right (postWith bomOptions url body)) (pure . Left)
-- | Fetch a single state, populate the cache with everything we learned, and
-- return the METAR for the target ICAO (if it was in that state's response).
--
-- >>> :t fetchState
-- fetchState :: String -> String -> METARResultT IO String
fetchState ::
String ->
String ->
METARResultT IO String
fetchState icao state =
METARResultT $
requestState state >>= \case
Left e ->
pure (failWith (classifyHttp state e))
Right resp ->
let stations = extractStations (parseTags (BS.unpack (resp ^. responseBody)))
entries = fmap (\(i, _) -> (i, state)) stations
in do
unless (null entries) (mergeCache entries)
pure $ case findMETAR icao stations of
Just m -> METARResultValue m
Nothing -> failWith (ParseErrorAt state (icao <> " not in response"))
-- | Fetch a METAR from the Bureau of Meteorology. Only @Y*@ ICAOs
-- (Australia's block) are attempted; anything else returns a
-- 'ParseErrorAt' at @"BOM"@ without a network call. Uses the on-disk cache
-- to pick a state first; falls back to scanning all seven states on a miss.
--
-- >>> :t getBOMMETAR
-- getBOMMETAR :: String -> METARResultT IO String
getBOMMETAR ::
String ->
METARResultT IO String
getBOMMETAR icao =
let icao' = fmap toUpper icao
in case icao' of
('Y' : _) ->
do
cached <- lift readCache
case lookup icao' cached of
Just state ->
fetchState icao' state <!> scanAllStates icao' [state]
Nothing ->
scanAllStates icao' []
_ ->
METARResultT (pure (failWith (ParseErrorAt "BOM" (icao' <> " is not an Australian ICAO (Y*)"))))
-- | Try every state (except any already tried) in order.
--
-- >>> :t scanAllStates
-- scanAllStates :: String -> [String] -> METARResultT IO String
scanAllStates ::
String ->
[String] ->
METARResultT IO String
scanAllStates icao exclude =
let remaining = filter (`notElem` exclude) bomStates
in case remaining of
[] -> METARResultT (pure (failWith (ParseErrorAt "BOM" (icao <> " not found in any state"))))
rs -> foldr1 (<!>) (fmap (fetchState icao) rs)
-- | Fetch a METAR from NOAA's @tgftp.nws.noaa.gov@ station-file endpoint.
--
-- >>> :t getNOAAMETAR
-- getNOAAMETAR :: String -> METARResultT IO String
getNOAAMETAR ::
String ->
METARResultT IO String
getNOAAMETAR =
let options ::
Options
options =
defaults
& headers
.~ [ ("Host", "tgftp.nws.noaa.gov")
, ("User-Agent", "tonymorris/metar")
, ("Accept", "*/*")
, ("Accept-Language", "en-US,en;q=0.5")
, ("Accept-Encoding", "text/html")
, ("Connection", "keep-alive")
, ("Pragma", "no-cache")
, ("Cache-Control", "no-cache")
, ("DNT", "1")
]
request xxxx =
catch
(fmap Right (getWith options ("https://tgftp.nws.noaa.gov/data/observations/metar/stations/" <> fmap toUpper xxxx <> ".TXT")))
(pure . Left)
respMETAR ::
Wreq.Response ByteString ->
Maybe String
respMETAR r =
case BS.lines (r ^. responseBody) of
[_, r'] -> Just (BS.unpack r')
_ -> Nothing
in METARResultT
. fmap
( \case
Left e -> failWith (classifyHttp "NOAA" e)
Right response ->
case respMETAR response of
Just m -> METARResultValue m
Nothing -> failWith (ParseErrorAt "NOAA" "unexpected response format")
)
. request
-- | Fetch a METAR. Try BOM first (which itself returns a 'ParseErrorAt' for
-- non-@Y*@ codes), then fall back to NOAA. Errors from both sources are
-- accumulated in the 'METARResultFailure' list.
--
-- >>> :t getMETAR
-- getMETAR :: String -> METARResultT IO String
getMETAR ::
String ->
METARResultT IO String
getMETAR icao =
getBOMMETAR icao <!> getNOAAMETAR icao
-- | Render one 'METARError' as a human-readable line.
--
-- >>> renderError (ConnErrorAt "NOAA" (ErrorMisc "response timeout"))
-- "NOAA: connection error: response timeout"
-- >>> renderError (ParseErrorAt "New-South-Wales" "not in response")
-- "New-South-Wales: not in response"
renderError ::
METARError ->
String
renderError (ConnErrorAt src (ErrorMisc msg)) =
src <> ": connection error: " <> msg
renderError (ConnErrorAt src e) =
src <> ": connection error: " <> show e
renderError (ParseErrorAt src msg) =
src <> ": " <> msg
-- | CLI entry point. Takes the argv list and prints a METAR to @stdout@ or
-- all accumulated errors to @stderr@.
--
-- >>> :t runMETAR
-- runMETAR :: [String] -> IO ()
runMETAR ::
[String] ->
IO ()
runMETAR x =
let stderr' =
hPutStrLn stderr
in case x of
[] ->
do
putStrLn ("metar version " <> VERSION_metar)
stderr' "enter an argument (ICAO code)"
(r : _) ->
let s = view _Wrapped (getMETAR r)
in s >>= \case
METARResultValue a ->
putStrLn a
METARResultFailure (e :| es) ->
do
stderr' ("No METAR for " <> r <> ":")
mapM_ (stderr' . (" " <>) . renderError) (e : es)