metar-0.0.6: src/Data/Aviation/GPWT.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wall #-}
{- FOURMOLU_DISABLE -}
-- $setup
-- >>> import Text.HTML.TagSoup (parseTags)
{- FOURMOLU_ENABLE -}
-- | Fetching Grid Point Wind and Temperature forecasts (GPWTs) from the
-- Australian Bureau of Meteorology.
--
-- The BOM's aviation site publishes GPWT charts at
-- <https://www.bom.gov.au/aviation/charts/grid-point-forecasts/> in three
-- flight-level bands ('GPWTLow', 'GPWTMid', 'GPWTHigh'). Each band offers a
-- number of area codes (e.g. @AUS@, @NSW@, @QLD-N@, @VIC\/TAS@, @TIMS@) and
-- eight three-hourly time slices (@00Z@, @03Z@ ... @21Z@). This module
-- parses the HTML page for the (level, area, time) → product-id map and
-- fetches the requested PNG.
module Data.Aviation.GPWT (
-- * Types
GPWTLevel (..),
GPWTError (..),
GPWTImage (..),
GPWTEntry (..),
-- * Fetching
getGPWT,
-- * Parsing (exported for testing)
parseGPWTEntries,
parseLevel,
levelPrefix,
levelAndTimeFromTitle,
pidFromHref,
normaliseCode,
normaliseTime,
renderGPWTError,
) where
import Control.Exception (catch)
import Control.Lens ((&), (.~), (^.))
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import Data.Char (isAsciiLower, isAsciiUpper, isDigit, isSpace, toUpper)
import Data.List (nub, sort)
import Network.HTTP.Client (HttpException)
import Network.Wreq (Options, defaults, getWith, headers, responseBody)
import Text.HTML.TagSoup (Tag (TagOpen, TagText), parseTags)
-- | The three flight-level bands published on the GPWT page.
--
-- >>> [GPWTLow, GPWTMid, GPWTHigh]
-- [GPWTLow,GPWTMid,GPWTHigh]
data GPWTLevel
= GPWTLow
| GPWTMid
| GPWTHigh
deriving (Eq, Show)
-- | One (level, area, time) product parsed from the GPWT page.
--
-- The @gpwtEntryPid@ is the bare product identifier (without the @.png@
-- suffix), e.g. @\"IDY04650\"@.
data GPWTEntry = GPWTEntry
{ gpwtEntryLevel :: GPWTLevel
, gpwtEntryArea :: String
, gpwtEntryTime :: String
, gpwtEntryPid :: String
}
deriving (Eq, Show)
-- | One raw GPWT image response.
data GPWTImage = GPWTImage
{ gpwtContentType :: String
, gpwtBytes :: BL.ByteString
}
deriving (Eq, Show)
-- | Something that can go wrong while retrieving a GPWT.
data GPWTError
= -- | The URL segment for the level was not one of @low@, @mid@, @high@.
GPWTUnknownLevel String
| -- | No product for the requested (level, area, time) triple. The valid
-- (area, [time]) pairs at that level are returned for the caller to
-- render.
GPWTNoSuchProduct GPWTLevel String String [(String, [String])]
| -- | The BOM GPWT page did not contain any recognisable product entries.
GPWTParseError
| -- | A network request failed. First field is the source label.
GPWTHttpError String String
deriving (Eq, Show)
-- | Render a 'GPWTError' as a single line of human-readable text.
--
-- >>> renderGPWTError (GPWTUnknownLevel "xyz")
-- "unknown level \"xyz\" (valid: low, mid, high)"
--
-- >>> renderGPWTError (GPWTNoSuchProduct GPWTLow "ZZZ" "99Z" [("AUS", ["00Z","03Z"])])
-- "no low-level GPWT for area \"ZZZ\" time \"99Z\" (valid: AUS [00Z, 03Z])"
--
-- >>> renderGPWTError GPWTParseError
-- "no GPWT products found on grid-point-forecasts page"
--
-- >>> renderGPWTError (GPWTHttpError "grid-point-forecasts" "connection timeout")
-- "grid-point-forecasts: connection timeout"
renderGPWTError ::
GPWTError ->
String
renderGPWTError e =
case e of
GPWTUnknownLevel l ->
"unknown level " <> show l <> " (valid: low, mid, high)"
GPWTNoSuchProduct lv area time valid ->
"no "
<> levelPrefix lv
<> "-level GPWT for area "
<> show area
<> " time "
<> show time
<> " (valid: "
<> renderValid valid
<> ")"
GPWTParseError ->
"no GPWT products found on grid-point-forecasts page"
GPWTHttpError src msg ->
src <> ": " <> msg
where
renderValid xs =
intercalateSep "; " [a <> " [" <> intercalateSep ", " ts <> "]" | (a, ts) <- xs]
intercalateSep _ [] = ""
intercalateSep _ [x] = x
intercalateSep sep (x : xs) = x <> sep <> intercalateSep sep xs
-- | Prefix used in BOM's title attributes (@\"Low-level\"@, @\"Mid-level\"@,
-- @\"High-level\"@) for the given 'GPWTLevel', lowercased.
--
-- >>> map levelPrefix [GPWTLow, GPWTMid, GPWTHigh]
-- ["low","mid","high"]
levelPrefix ::
GPWTLevel ->
String
levelPrefix GPWTLow = "low"
levelPrefix GPWTMid = "mid"
levelPrefix GPWTHigh = "high"
-- | Parse a URL segment (@low@\/@mid@\/@high@, case-insensitive) into a
-- 'GPWTLevel'.
--
-- >>> map parseLevel ["low", "Mid", "HIGH"]
-- [Just GPWTLow,Just GPWTMid,Just GPWTHigh]
--
-- >>> parseLevel "other"
-- Nothing
--
-- >>> parseLevel ""
-- Nothing
parseLevel ::
String ->
Maybe GPWTLevel
parseLevel s =
case fmap toUpper s of
"LOW" -> Just GPWTLow
"MID" -> Just GPWTMid
"HIGH" -> Just GPWTHigh
_ -> Nothing
-- | Normalise an area code for comparison. Uppercases and drops every
-- character that is not a letter or digit, so callers can use any of
-- @\"VIC\/TAS\"@, @\"VIC-TAS\"@ or @\"VICTAS\"@ interchangeably.
--
-- >>> map normaliseCode ["VIC/TAS", "vic-tas", "victas", "QLD-N", "qldn"]
-- ["VICTAS","VICTAS","VICTAS","QLDN","QLDN"]
normaliseCode ::
String ->
String
normaliseCode =
fmap toUpper . filter isAlnum
where
isAlnum c = isAsciiUpper c || isAsciiLower c || isDigit c
-- | Normalise a time slot for comparison. Uppercases, strips whitespace, and
-- pads a single leading zero if only one digit was given before the @Z@.
--
-- >>> map normaliseTime ["00Z", "03z", "9Z", "18Z", " 21z "]
-- ["00Z","03Z","09Z","18Z","21Z"]
--
-- >>> normaliseTime "notatime"
-- "NOTATIME"
normaliseTime ::
String ->
String
normaliseTime =
let padZ s =
case s of
[d, 'Z'] | isDigit d -> ['0', d, 'Z']
_ -> s
in padZ . fmap toUpper . filter (not . isSpace)
-- | Extract the trailing @\"XXZ\"@ time from a BOM title attribute and pair
-- it with a 'GPWTLevel'. Titles look like @\"Low-level, Australia 00Z\"@.
--
-- >>> levelAndTimeFromTitle "Low-level, Australia 00Z"
-- Just (GPWTLow,"00Z")
--
-- >>> levelAndTimeFromTitle "Mid-level, North-East 12Z"
-- Just (GPWTMid,"12Z")
--
-- >>> levelAndTimeFromTitle "High-level, Tasman 21Z"
-- Just (GPWTHigh,"21Z")
--
-- >>> levelAndTimeFromTitle "Something else"
-- Nothing
--
-- Malformed times (BOM has one @\"015\"@ typo entry) are rejected:
--
-- >>> levelAndTimeFromTitle "Mid-level, South-East 015"
-- Nothing
levelAndTimeFromTitle ::
String ->
Maybe (GPWTLevel, String)
levelAndTimeFromTitle t =
let (prefix, rest0) = break (== ',') t
rest = dropWhile isSpace (drop 1 rest0)
in do
lv <- case prefix of
"Low-level" -> Just GPWTLow
"Mid-level" -> Just GPWTMid
"High-level" -> Just GPWTHigh
_ -> Nothing
tm <- case reverse (words rest) of
(w : _) | isValidTime w -> Just w
_ -> Nothing
Just (lv, tm)
where
isValidTime [d1, d2, 'Z'] = isDigit d1 && isDigit d2
isValidTime _ = False
-- | Extract a product id (without the @.png@ suffix) from an anchor's
-- @href@ attribute.
--
-- >>> pidFromHref "/difacs/aviation/IDY04650.png"
-- Just "IDY04650"
--
-- >>> pidFromHref "/fwo/aviation/IDY04651.png"
-- Just "IDY04651"
--
-- >>> pidFromHref "/aviation/gaf/gaf.shtml"
-- Nothing
--
-- >>> pidFromHref ""
-- Nothing
pidFromHref ::
String ->
Maybe String
pidFromHref href =
let base = reverse (takeWhile (/= '/') (reverse href))
in case reverse base of
'g' : 'n' : 'p' : '.' : rest ->
let name = reverse rest
in if not (null name) && all (\c -> isAsciiUpper c || isDigit c) name
then Just name
else Nothing
_ -> Nothing
-- | Walk a parsed HTML tag stream and emit one 'GPWTEntry' per
-- @\<a class=\"loc\"\>@ product link. Area codes are inherited from the
-- most recently opened @\<li\>@; the level and time come from each
-- anchor's own @title@ attribute.
--
-- >>> parseGPWTEntries (parseTags "<ul><li>AUS: <a class=\"loc\" href=\"/difacs/aviation/IDY04650.png\" title=\"Low-level, Australia 00Z\">00Z</a></li></ul>")
-- [GPWTEntry {gpwtEntryLevel = GPWTLow, gpwtEntryArea = "AUS", gpwtEntryTime = "00Z", gpwtEntryPid = "IDY04650"}]
--
-- >>> length (parseGPWTEntries (parseTags "<ul><li>AUS: <a class=\"loc\" href=\"/difacs/aviation/IDY04650.png\" title=\"Low-level, Australia 00Z\">00Z</a> <a class=\"loc\" href=\"/difacs/aviation/IDY04651.png\" title=\"Low-level, Australia 03Z\">03Z</a></li></ul>"))
-- 2
--
-- >>> parseGPWTEntries []
-- []
--
-- Anchors without a preceding @\<li\>@ header are skipped:
--
-- >>> parseGPWTEntries (parseTags "<a class=\"loc\" href=\"/difacs/aviation/IDY04650.png\" title=\"Low-level, Australia 00Z\">00Z</a>")
-- []
parseGPWTEntries ::
[Tag String] ->
[GPWTEntry]
parseGPWTEntries =
let go _ [] = []
go _ (TagOpen "li" _ : ts) =
let (code, ts') = takeAreaCode ts
in go (Just code) ts'
go area (TagOpen "a" attrs : ts)
| lookup "class" attrs == Just "loc"
, Just href <- lookup "href" attrs
, Just title <- lookup "title" attrs
, Just pid <- pidFromHref href
, Just (lv, tm) <- levelAndTimeFromTitle title
, Just areaC <- area =
GPWTEntry lv areaC tm pid : go area ts
go area (_ : ts) = go area ts
in go Nothing
-- | Consume the first non-whitespace text node after a @\<li\>@ open tag
-- and return the trimmed content up to (but not including) any @\':\'@.
--
-- >>> takeAreaCode (parseTags "AUS: some link")
-- ("AUS",[TagText " some link"])
--
-- >>> takeAreaCode (parseTags " NE:")
-- ("NE",[TagText ""])
--
-- >>> fst (takeAreaCode (parseTags "VIC/TAS:"))
-- "VIC/TAS"
--
-- >>> takeAreaCode []
-- ("",[])
takeAreaCode ::
[Tag String] ->
(String, [Tag String])
takeAreaCode ts0 =
case dropWhile isBlankText ts0 of
TagText t : rest ->
let (before, after) = break (== ':') t
code = strip before
rest' = case after of
':' : more -> TagText more : rest
_ -> rest
in (code, rest')
_ -> ("", ts0)
where
isBlankText (TagText s) = all isSpace s
isBlankText _ = False
strip = dropWhile isSpace . reverse . dropWhile isSpace . reverse
-- | HTTP options used when talking to bom.gov.au. Sending a browser-shaped
-- User-Agent (and the @check=ok@ cookie the site expects) is necessary to
-- avoid the anti-scraping block page.
bomOptions ::
Options
bomOptions =
defaults
& headers
.~ [ ("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0")
, ("Accept", "*/*")
, ("Accept-Language", "en-US,en;q=0.5")
, ("Cookie", "check=ok")
]
-- | Fetch a GPWT image for a given level, area code, and time slot.
--
-- Area codes are matched via 'normaliseCode' (case-insensitive, punctuation
-- ignored) so @\"VIC/TAS\"@, @\"vic-tas\"@ and @\"VICTAS\"@ all resolve to
-- the same product. Time slots are normalised with 'normaliseTime' so
-- @\"9Z\"@, @\"09z\"@ and @\"09Z\"@ are equivalent.
--
-- >>> :t getGPWT
-- getGPWT
-- :: GPWTLevel -> String -> String -> IO (Either GPWTError GPWTImage)
getGPWT ::
GPWTLevel ->
String ->
String ->
IO (Either GPWTError GPWTImage)
getGPWT level areaIn timeIn =
fetchEntries >>= \case
Left err -> pure (Left err)
Right es ->
let wantArea = normaliseCode areaIn
wantTime = normaliseTime timeIn
matches =
[ e
| e <- es
, gpwtEntryLevel e == level
, normaliseCode (gpwtEntryArea e) == wantArea
, normaliseTime (gpwtEntryTime e) == wantTime
]
in case matches of
(e : _) -> fetchImage (gpwtEntryPid e)
[] -> pure (Left (GPWTNoSuchProduct level areaIn timeIn (validPairs level es)))
-- | Collect @(area, [times])@ pairs for a given level, in the order they
-- first appeared, with times sorted lexicographically.
--
-- >>> validPairs GPWTLow [GPWTEntry GPWTLow "AUS" "03Z" "A", GPWTEntry GPWTLow "AUS" "00Z" "B", GPWTEntry GPWTHigh "AUS" "00Z" "C"]
-- [("AUS",["00Z","03Z"])]
validPairs ::
GPWTLevel ->
[GPWTEntry] ->
[(String, [String])]
validPairs level es =
let filtered = filter (\e -> gpwtEntryLevel e == level) es
areas = nub (fmap gpwtEntryArea filtered)
in [ (a, sort (nub [gpwtEntryTime e | e <- filtered, gpwtEntryArea e == a]))
| a <- areas
]
-- | Fetch and parse the GPWT landing page for its full product list.
fetchEntries ::
IO (Either GPWTError [GPWTEntry])
fetchEntries =
let url = "https://www.bom.gov.au/aviation/charts/grid-point-forecasts/"
in httpGet "grid-point-forecasts" url >>= \case
Left err -> pure (Left err)
Right body ->
case parseGPWTEntries (parseTags (BLC.unpack body)) of
[] -> pure (Left GPWTParseError)
xs -> pure (Right xs)
-- | Fetch the actual GPWT PNG for a given product id. BOM serves @IDY*@
-- products from @\/fwo\/aviation\/@ and @IDX*@ products from
-- @\/difacs\/aviation\/@; the HTML's @href@ is not reliable across both
-- families, so we route by product-id prefix instead of trusting it.
fetchImage ::
String ->
IO (Either GPWTError GPWTImage)
fetchImage pid =
let url = "https://www.bom.gov.au" <> imagePath pid <> pid <> ".png"
in httpGet pid url >>= \case
Left err -> pure (Left err)
Right body -> pure (Right (GPWTImage "image/png" body))
-- | Choose the URL base for a GPWT product id.
--
-- >>> imagePath "IDY04650"
-- "/fwo/aviation/"
--
-- >>> imagePath "IDX0129"
-- "/difacs/aviation/"
--
-- >>> imagePath "IDX0476"
-- "/difacs/aviation/"
--
-- >>> imagePath "OTHER"
-- "/difacs/aviation/"
imagePath ::
String ->
String
imagePath pid =
case pid of
'I' : 'D' : 'Y' : _ -> "/fwo/aviation/"
_ -> "/difacs/aviation/"
-- | Wrap a Wreq GET call, catching 'HttpException's and returning them as a
-- 'GPWTHttpError' tagged with the given source label.
httpGet ::
String ->
String ->
IO (Either GPWTError BL.ByteString)
httpGet src url =
catch
(fmap (Right . (^. responseBody)) (getWith bomOptions url))
(\e -> pure (Left (GPWTHttpError src (show (e :: HttpException)))))