metar-http-0.0.7: src/Data/Aviation/Metar/Http.hs
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wall #-}
-- | HTTP server exposing METAR observations, BOM Graphical Area Forecast
-- (GAF) images, and BOM Grid Point Wind and Temperature (GPWT) images.
--
-- @/metar/\<icao\>@ returns a METAR observation as text: Australian aerodromes
-- (ICAO codes beginning with @Y@) are fetched from BOM; other codes fall back
-- to NOAA.
--
-- @/gaf/\<area\>/current@ and @/gaf/\<area\>/next@ return the corresponding
-- BOM GAF PNG image. Areas are @WA-N@, @WA-S@, @NT@, @QLD-N@, @QLD-S@, @SA@,
-- @NSW-W@, @NSW-E@, @VIC@, @TAS@.
--
-- @/gpwt/\<level\>/\<area\>/\<time\>@ returns the corresponding BOM GPWT PNG.
-- @level@ is @low@, @mid@ or @high@; @area@ is one of the codes advertised on
-- the grid-point-forecasts page (e.g. @AUS@, @NSW@, @QLD-N@, @VIC-TAS@,
-- @TIMS@); @time@ is a 3-hourly UTC slot like @00Z@, @03Z@ ... @21Z@.
module Data.Aviation.Metar.Http (
metarHTTP,
metarHTTPapp,
) where
import Control.Lens (folded, (^.), (^?), _Wrapped)
import Data.Aviation.GAF (GAFError (GAFHttpError, GAFUnknownArea), GAFImage (GAFImage), GAFPeriod (GAFCurrent, GAFNext), getGAF, renderGAFError)
import Data.Aviation.GPWT (GPWTError (GPWTHttpError, GPWTNoSuchProduct, GPWTUnknownLevel), GPWTImage (GPWTImage), getGPWT, parseLevel, renderGPWTError)
import Data.Aviation.Metar (getMETAR, getTAF)
import Data.Aviation.Metar.METARResult (_METARResultValue)
import Data.Aviation.Metar.METARResultT (METARResultT)
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy as BL
import Data.ByteString.Lazy.UTF8 (fromString)
import Data.List (intercalate)
import Data.Text (Text, toLower, unpack)
import Network.HTTP.Types.Header (hContentType)
import Network.HTTP.Types.Status (status200, status404, status502)
import Network.Wai (Application, Response, pathInfo, responseLBS)
import Network.Wai.Handler.Warp (defaultSettings, runSettings, setPort, setTimeout)
import System.Environment (getArgs)
{- FOURMOLU_DISABLE -}
-- $setup
-- >>> import Data.Aviation.Metar.Http
{- FOURMOLU_ENABLE -}
-- | Parse a value using its 'Read' instance, returning 'Nothing' on failure.
--
-- >>> readMaybe "42" :: Maybe Int
-- Just 42
--
-- >>> readMaybe "notanumber" :: Maybe Int
-- Nothing
readMaybe ::
(Read a) =>
String ->
Maybe a
readMaybe n =
fst <$> reads n ^? folded
-- | How to truncate a rendered METAR line.
data CharLimit
= NoCharLimit
| MaxChars Int
| MaxCharsAppend Int String
deriving (Eq, Show)
-- | Apply a 'CharLimit' to a string.
--
-- >>> charLimit NoCharLimit "hello world"
-- "hello world"
--
-- >>> charLimit (MaxChars 5) "hello world"
-- "hello"
--
-- >>> charLimit (MaxCharsAppend 5 "...") "hello world"
-- "hello..."
--
-- >>> charLimit (MaxCharsAppend 5 "...") "hi"
-- "hi"
charLimit ::
CharLimit ->
String ->
String
charLimit m s =
case m of
NoCharLimit ->
s
MaxChars n ->
take n s
MaxCharsAppend n l ->
let (a, b) = splitAt n s
b' = case b of
[] -> []
_ : _ -> l
in a <> b'
-- | How to format a list of METAR lines for a response body.
data Format
= Raw
| MaxLines Int CharLimit
| AllOneLine CharLimit
deriving (Eq, Show)
-- | Render lines of METAR text according to the given 'Format'.
--
-- >>> format (MaxLines 3 NoCharLimit) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK\nRF00.0/000.4"
--
-- >>> format (MaxLines 1 NoCharLimit) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK"
--
-- >>> format (MaxLines 1 (MaxChars 15)) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 0712"
--
-- >>> format (MaxLines 1 (MaxCharsAppend 15 "abc")) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 0712abc"
--
-- >>> format (AllOneLine (MaxCharsAppend 15 "abc")) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 0712abc"
--
-- >>> format (AllOneLine (MaxCharsAppend 150 "abc")) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK RF00.0/000.4"
--
-- >>> format (AllOneLine (MaxCharsAppend 60 "abc")) ["METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK","RF00.0/000.4"]
-- "METAR YBAF 071230Z AUTO 16006KT 9999 // NCD 24/20 Q1011 RMK abc"
format ::
Format ->
[String] ->
String
format f s =
let limitCalate l x =
charLimit l . intercalate x
in case f of
Raw ->
intercalate "\n" s
MaxLines n l ->
limitCalate l "\n" . take n $ s
AllOneLine l ->
limitCalate l " " s
-- | Parse the trailing URI path components into a 'Format'.
--
-- URI grammar:
--
-- @
-- (empty) -> Raw
-- * -> AllOneLine NoCharLimit
-- *\/n -> AllOneLine (MaxChars n)
-- *\/n\/xyz -> AllOneLine (MaxCharsAppend n xyz)
-- n -> MaxLines n NoCharLimit
-- n\/m -> MaxLines n (MaxChars m)
-- n\/m\/xyz -> MaxLines n (MaxCharsAppend m xyz)
-- @
--
-- >>> uriPathFormat []
-- Raw
--
-- >>> uriPathFormat ["*"]
-- AllOneLine NoCharLimit
--
-- >>> uriPathFormat ["*", "80"]
-- AllOneLine (MaxChars 80)
--
-- >>> uriPathFormat ["*", "80", "..."]
-- AllOneLine (MaxCharsAppend 80 "...")
--
-- >>> uriPathFormat ["3"]
-- MaxLines 3 NoCharLimit
--
-- >>> uriPathFormat ["3", "40"]
-- MaxLines 3 (MaxChars 40)
--
-- >>> uriPathFormat ["3", "40", "..."]
-- MaxLines 3 (MaxCharsAppend 40 "...")
--
-- >>> uriPathFormat ["notanumber"]
-- Raw
uriPathFormat ::
[String] ->
Format
uriPathFormat [] =
Raw
uriPathFormat (q : r) =
let rawMaybe ::
(Read a) =>
(a -> CharLimit) ->
String ->
CharLimit
rawMaybe f n =
maybe NoCharLimit f (readMaybe n)
r' = case r of
[] ->
NoCharLimit
s : ss ->
rawMaybe
( \n -> case ss of
[] ->
MaxChars n
t : _ ->
MaxCharsAppend n t
)
s
in case q of
"*" ->
AllOneLine r'
_ ->
case readMaybe q of
Nothing ->
Raw
Just l ->
MaxLines l r'
-- | WAI 'Application' serving METAR observations and BOM GAF/GPWT images.
metarHTTPapp ::
Application
metarHTTPapp req withResp =
let _404 =
responseLBS
status404
[(hContentType, htmlContentType)]
indexHTML
in case pathInfo req of
["gaf", area, periodTxt]
| Just period <- gafPeriod (toLower periodTxt) ->
do
r <- getGAF (unpack area) period
withResp (gafResponse r)
["gpwt", levelTxt, area, timeTxt] ->
let levelStr = unpack (toLower levelTxt)
in case parseLevel levelStr of
Nothing ->
withResp $
responseLBS
status404
[(hContentType, "text/plain")]
(fromString (renderGPWTError (GPWTUnknownLevel levelStr)))
Just lv ->
do
r <- getGPWT lv (unpack area) (unpack timeTxt)
withResp (gpwtResponse r)
(rpt : xxxx : r)
| Just (rptName, fetch) <- reportKind rpt ->
let xxxx' = unpack xxxx
modifyOutput = format (uriPathFormat (unpack <$> r))
in do
t <- fetch xxxx' ^. _Wrapped
withResp $
case t ^? _METARResultValue of
Nothing ->
responseLBS
status404
[]
("no " <> fromString rptName <> " found for " <> fromString xxxx')
Just x ->
responseLBS
status200
[(hContentType, "text/plain")]
(fromString (modifyOutput (lines x)))
[] ->
withResp $
responseLBS
status200
[(hContentType, htmlContentType)]
indexHTML
_ ->
withResp _404
-- | Dispatch table for the text-report routes (@/metar/@ and @/taf/@).
-- Returns the display label used in \"no X found\" errors and the fetcher
-- to call, or 'Nothing' if the path prefix is not a known report kind.
--
-- >>> import Data.Aviation.Metar.METARResultT (METARResultT)
-- >>> import Data.Text (pack)
-- >>> fmap fst (reportKind (pack "metar"))
-- Just "METAR"
-- >>> fmap fst (reportKind (pack "TAF"))
-- Just "TAF"
-- >>> fmap fst (reportKind (pack "other"))
-- Nothing
reportKind ::
Text ->
Maybe (String, String -> METARResultT IO String)
reportKind rpt =
case toLower rpt of
"metar" -> Just ("METAR", getMETAR)
"taf" -> Just ("TAF", getTAF)
_ -> Nothing
-- | Parse the trailing path segment of a @/gaf/<area>/...@ request into a
-- 'GAFPeriod'.
--
-- >>> gafPeriod "current"
-- Just GAFCurrent
--
-- >>> gafPeriod "next"
-- Just GAFNext
--
-- >>> gafPeriod "other"
-- Nothing
gafPeriod ::
Text ->
Maybe GAFPeriod
gafPeriod t =
case t of
"current" -> Just GAFCurrent
"next" -> Just GAFNext
_ -> Nothing
-- | Turn a GAF fetch outcome into a WAI response. Successful fetches serve
-- the raw image bytes with the BOM-declared content type; errors map to
-- @404@ (unknown area) or @502@ (upstream failure) with a plain-text body.
gafResponse ::
Either GAFError GAFImage ->
Response
gafResponse r =
case r of
Right (GAFImage ct bs) ->
responseLBS
status200
[(hContentType, BS.pack ct)]
bs
Left err@(GAFUnknownArea _ _) ->
responseLBS
status404
[(hContentType, "text/plain")]
(fromString (renderGAFError err))
Left err@(GAFHttpError _ _) ->
responseLBS
status502
[(hContentType, "text/plain")]
(fromString (renderGAFError err))
Left err ->
responseLBS
status502
[(hContentType, "text/plain")]
(fromString (renderGAFError err))
-- | Turn a GPWT fetch outcome into a WAI response. Successful fetches serve
-- the raw image bytes with the BOM-declared content type; errors map to
-- @404@ (unknown level, or no product for the requested area/time) or @502@
-- (upstream failure) with a plain-text body.
gpwtResponse ::
Either GPWTError GPWTImage ->
Response
gpwtResponse r =
case r of
Right (GPWTImage ct bs) ->
responseLBS
status200
[(hContentType, BS.pack ct)]
bs
Left err@(GPWTUnknownLevel _) ->
responseLBS
status404
[(hContentType, "text/plain")]
(fromString (renderGPWTError err))
Left err@(GPWTNoSuchProduct{}) ->
responseLBS
status404
[(hContentType, "text/plain")]
(fromString (renderGPWTError err))
Left err@(GPWTHttpError _ _) ->
responseLBS
status502
[(hContentType, "text/plain")]
(fromString (renderGPWTError err))
Left err ->
responseLBS
status502
[(hContentType, "text/plain")]
(fromString (renderGPWTError err))
-- | Run 'metarHTTPapp' with Warp, optionally taking a port from the first
-- command-line argument.
metarHTTP ::
IO ()
metarHTTP =
do
a <- getArgs
let p = case a of
[] ->
id
(q : _) ->
maybe id setPort (readMaybe q)
runSettings (setTimeout 6 (p defaultSettings)) metarHTTPapp
-- | @text/html; charset=utf-8@ as a strict 'BS.ByteString'.
htmlContentType ::
BS.ByteString
htmlContentType =
"text/html; charset=utf-8"
-- | HTML documentation page served at @/@ (and as the body of any @404@).
-- Describes every endpoint the server exposes, with worked @curl@ examples.
indexHTML ::
BL.ByteString
indexHTML =
fromString $
unlines
[ "<!doctype html>"
, "<html lang=\"en\">"
, "<head>"
, "<meta charset=\"utf-8\">"
, "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
, "<title>metar-http</title>"
, "<style>"
, ":root { color-scheme: light dark;"
, " --bg:#fafbfc; --fg:#24292f; --muted:#57606a; --accent:#0969da;"
, " --code-bg:#eff1f3; --border:#d0d7de; }"
, "@media (prefers-color-scheme: dark) { :root {"
, " --bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --accent:#4493f8;"
, " --code-bg:#161b22; --border:#30363d; } }"
, "* { box-sizing: border-box; }"
, "body { margin:0; padding:2.5rem 1.25rem 3rem;"
, " font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,sans-serif;"
, " color:var(--fg); background:var(--bg); line-height:1.55; }"
, "main { max-width:780px; margin:0 auto; }"
, "h1 { font-size:1.85rem; letter-spacing:-0.01em; margin:0 0 0.25rem; }"
, ".tagline { color:var(--muted); margin:0 0 2rem; font-size:1.02rem; }"
, "section { margin:2rem 0 0; padding-top:1.75rem; border-top:1px solid var(--border); }"
, "h2 { font-size:1.2rem; margin:0 0 0.6rem; }"
, "h3 { font-size:1rem; margin:1.4rem 0 0.5rem; color:var(--muted);"
, " text-transform:uppercase; letter-spacing:0.06em; font-weight:600; }"
, "p { margin:0.55rem 0; }"
, "code, pre, .endpoints dt {"
, " font-family:ui-monospace,'SF Mono',Menlo,Consolas,'Liberation Mono',monospace;"
, " font-size:0.925em; }"
, "code { background:var(--code-bg); padding:0.12em 0.38em; border-radius:3px; }"
, "pre { background:var(--code-bg); border:1px solid var(--border); border-radius:6px;"
, " padding:0.85rem 1rem; margin:0.6rem 0; overflow-x:auto; line-height:1.5; }"
, "pre code { background:transparent; padding:0; }"
, ".endpoints { margin:0.75rem 0; }"
, ".endpoints dt { font-weight:600; margin-top:0.8rem;"
, " color:var(--accent); }"
, ".endpoints dt:first-child { margin-top:0; }"
, ".endpoints dd { margin:0.2rem 0 0 1.5rem; color:var(--muted); }"
, ".codes { display:flex; flex-wrap:wrap; gap:0.35rem; margin:0.5rem 0 0.75rem; }"
, ".codes code { font-size:0.85em; }"
, "table.areas { border-collapse:collapse; margin:0.5rem 0 0.75rem; }"
, "table.areas th, table.areas td { padding:0.35rem 0.9rem 0.35rem 0;"
, " border-bottom:1px solid var(--border); vertical-align:top; text-align:left; }"
, "table.areas th { font-weight:600; white-space:nowrap; padding-right:1.25rem; }"
, "table.areas td code, table.areas th code { font-size:0.85em; }"
, "table.areas tr:last-child th, table.areas tr:last-child td { border-bottom:0; }"
, "footer { color:var(--muted); font-size:0.85rem; margin-top:3rem;"
, " padding-top:1.25rem; border-top:1px solid var(--border); }"
, "a { color:var(--accent); }"
, "a:hover { text-decoration:underline; }"
, "</style>"
, "</head>"
, "<body>"
, "<main>"
, "<h1>metar-http</h1>"
, "<p class=\"tagline\">Aviation weather products \8212 METAR observations, TAF forecasts, Graphical Area Forecasts and Grid Point Wind & Temperature charts \8212 from the Australian Bureau of Meteorology, with NOAA fallback for non-Australian aerodromes.</p>"
, ""
, "<section>"
, "<h2>METAR observations</h2>"
, "<p>Fetch the current METAR/SPECI observation for an aerodrome by ICAO code. Australian aerodromes (codes beginning with <code>Y</code>) come from BOM; others fall back to NOAA.</p>"
, "<dl class=\"endpoints\">"
, "<dt>GET /metar/<icao></dt>"
, "<dd>Raw METAR (may span multiple lines).</dd>"
, "<dt>GET /metar/<icao>/*</dt>"
, "<dd>METAR collapsed onto one line.</dd>"
, "<dt>GET /metar/<icao>/*/<maxchars></dt>"
, "<dd>One line, truncated to <code><maxchars></code> characters.</dd>"
, "<dt>GET /metar/<icao>/*/<maxchars>/<append></dt>"
, "<dd>One line, truncated to <code><maxchars></code>; if truncation occurred, <code><append></code> is appended.</dd>"
, "<dt>GET /metar/<icao>/<maxlines></dt>"
, "<dd>First <code><maxlines></code> lines.</dd>"
, "<dt>GET /metar/<icao>/<maxlines>/<maxchars></dt>"
, "<dd>First <code><maxlines></code> lines, each truncated to <code><maxchars></code>.</dd>"
, "<dt>GET /metar/<icao>/<maxlines>/<maxchars>/<append></dt>"
, "<dd>First <code><maxlines></code> lines, truncated with <code><append></code> suffix on truncation.</dd>"
, "</dl>"
, "<h3>Examples</h3>"
, "<pre><code>curl http://localhost:8080/metar/YSSY"
, "curl http://localhost:8080/metar/YBAF/*"
, "curl http://localhost:8080/metar/KJFK/1/80/...</code></pre>"
, "</section>"
, ""
, "<section>"
, "<h2>TAF forecasts</h2>"
, "<p>Fetch the current TAF (Terminal Aerodrome Forecast) for an aerodrome by ICAO code. Australian aerodromes (codes beginning with <code>Y</code>) come from BOM; others fall back to NOAA. TAFs typically span several lines \8212 the same truncation/one-line options as <code>/metar</code> apply.</p>"
, "<dl class=\"endpoints\">"
, "<dt>GET /taf/<icao></dt>"
, "<dd>Raw TAF (multi-line).</dd>"
, "<dt>GET /taf/<icao>/*</dt>"
, "<dd>TAF collapsed onto one line.</dd>"
, "<dt>GET /taf/<icao>/*/<maxchars></dt>"
, "<dd>One line, truncated to <code><maxchars></code> characters.</dd>"
, "<dt>GET /taf/<icao>/*/<maxchars>/<append></dt>"
, "<dd>One line, truncated to <code><maxchars></code>; if truncation occurred, <code><append></code> is appended.</dd>"
, "<dt>GET /taf/<icao>/<maxlines></dt>"
, "<dd>First <code><maxlines></code> lines.</dd>"
, "<dt>GET /taf/<icao>/<maxlines>/<maxchars></dt>"
, "<dd>First <code><maxlines></code> lines, each truncated to <code><maxchars></code>.</dd>"
, "<dt>GET /taf/<icao>/<maxlines>/<maxchars>/<append></dt>"
, "<dd>First <code><maxlines></code> lines, truncated with <code><append></code> suffix on truncation.</dd>"
, "</dl>"
, "<h3>Examples</h3>"
, "<pre><code>curl http://localhost:8080/taf/YSSY"
, "curl http://localhost:8080/taf/YBBN/*"
, "curl http://localhost:8080/taf/KJFK/3</code></pre>"
, "</section>"
, ""
, "<section>"
, "<h2>Graphical Area Forecasts (GAF)</h2>"
, "<p>The current or next PNG chart for one of BOM's ten forecast areas. Which of the four rotating products counts as \"current\" or \"next\" is derived from the current UTC hour (rotations at 05, 11, 17 and 23Z).</p>"
, "<dl class=\"endpoints\">"
, "<dt>GET /gaf/<area>/current</dt>"
, "<dd>Current GAF PNG for <code><area></code>.</dd>"
, "<dt>GET /gaf/<area>/next</dt>"
, "<dd>Next GAF PNG for <code><area></code>.</dd>"
, "</dl>"
, "<p>Valid areas:</p>"
, "<p class=\"codes\"><code>WA-N</code> <code>WA-S</code> <code>NT</code> <code>QLD-N</code> <code>QLD-S</code> <code>SA</code> <code>NSW-W</code> <code>NSW-E</code> <code>VIC</code> <code>TAS</code></p>"
, "<h3>Examples</h3>"
, "<pre><code>curl -o wan-current.png http://localhost:8080/gaf/WA-N/current"
, "curl -o qlds-next.png http://localhost:8080/gaf/QLD-S/next</code></pre>"
, "</section>"
, ""
, "<section>"
, "<h2>Grid Point Wind & Temperature (GPWT)</h2>"
, "<p>PNG chart for a given flight-level band, forecast area and 3-hourly UTC time slot. The valid <em>(level, area, time)</em> triples are discovered by scraping BOM's grid-point-forecasts page; requesting a triple that doesn't exist returns <code>404</code> with the valid list.</p>"
, "<dl class=\"endpoints\">"
, "<dt>GET /gpwt/<level>/<area>/<time></dt>"
, "<dd>GPWT PNG for the requested triple.</dd>"
, "</dl>"
, "<p><strong>Level and area (each row shows all areas available at that level):</strong></p>"
, "<table class=\"areas\">"
, "<tr><th><code>low</code></th><td><code>AUS</code> <code>NSW</code> <code>NT</code> <code>QLD-N</code> <code>QLD-S</code> <code>SA</code> <code>TIMS</code> <code>VIC/TAS</code> <code>WA-N</code> <code>WA-S</code></td></tr>"
, "<tr><th><code>mid</code></th><td><code>AUS</code> <code>NE</code> <code>SE</code> <code>TASMAN-SEA</code> <code>WEST</code></td></tr>"
, "<tr><th><code>high</code></th><td><code>AUS</code> <code>TASMAN-SEA</code></td></tr>"
, "</table>"
, "<p>Codes are matched case-insensitively with punctuation ignored \8212 <code>VIC/TAS</code>, <code>vic-tas</code> and <code>VICTAS</code> all resolve to the same product.</p>"
, "<p><strong>Time:</strong> <code>00Z</code>, <code>03Z</code>, <code>06Z</code>, <code>09Z</code>, <code>12Z</code>, <code>15Z</code>, <code>18Z</code>, <code>21Z</code>.</p>"
, "<h3>Examples</h3>"
, "<pre><code>curl -o qldn-09z.png http://localhost:8080/gpwt/low/QLD-N/09Z"
, "curl -o aus-mid-12z.png http://localhost:8080/gpwt/mid/AUS/12Z"
, "curl -o tims-18z.png http://localhost:8080/gpwt/low/TIMS/18Z"
, "curl -o tasman-00z.png http://localhost:8080/gpwt/high/TASMAN-SEA/00Z</code></pre>"
, "</section>"
, ""
, "<footer>"
, "Data sourced from the Australian Bureau of Meteorology (<a href=\"https://www.bom.gov.au/aviation/\">bom.gov.au/aviation</a>) and NOAA (<a href=\"https://tgftp.nws.noaa.gov/data/observations/metar/stations/\">tgftp.nws.noaa.gov</a>). Not for operational use \8212 refer to <a href=\"https://www.airservicesaustralia.com/\">Airservices Australia</a> for flight planning."
, "</footer>"
, "</main>"
, "</body>"
, "</html>"
]