Last active
February 18, 2024 06:31
-
-
Save nattaylor/e4a10ad3999d605b07a582dd3ea02e52 to your computer and use it in GitHub Desktop.
Area Forecast Discussion Parser
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
"""Parse AFD and output formatted HTML | |
Design: | |
1. Retrieve AFD | |
2. Parse topics via regex | |
3. Light post-processing | |
4. Format HTML template | |
""" | |
import requests | |
import re | |
import json | |
import datetime | |
import traceback | |
s = requests.Session() | |
s.headers = { | |
'User-Agent': '(NatsAfdPage, nattaylor@gmail.com)' | |
} | |
def parse(s: requests.Session) -> dict: | |
"""Parse AFD (loosely) according to spec [1] and return a dict of sections | |
https://www.nws.noaa.gov/directives/sym/pd01005003curr.pdf""" | |
products = s.get('https://api.weather.gov/products/types/afd/locations/box').json() | |
raw = s.get(products['@graph'][0]['@id']).json() | |
pattern = r"(?P<meta>.*?)\.SYNOPSIS\.\.\.\n(?P<synopsis>.*?)" + \ | |
"\n\n&&\n\n\.NEAR TERM \/(?P<nearlabel>.*?)\/\.\.\.\n(?P<neartext>.*?)" + \ | |
"\n\n&&\n\n\.SHORT TERM \/(?P<shortlabel>.*?)\/\.\.\.\n(?P<shorttext>.*?)" + \ | |
"\n\n&&\n\n\.LONG TERM \/(?P<longlabel>.*?)\/\.\.\.\n(?P<longtext>.*?)" + \ | |
"\n\n&&\n\n\.AVIATION \/(?P<aviationlabel>.*?)\/\.\.\.\n(?P<aviationtext>.*?)" + \ | |
"\n\n&&\n\n\.MARINE\.\.\.\n(?P<marinetext>.*?)\n\n&&\n\n(?P<end>.*)" | |
afd = re.match(pattern, raw['productText'], re.DOTALL).groupdict() | |
# Post-processing | |
afd['meta'] = [m for m in afd['meta'].split('\n') if m != ''] | |
afd['synopsis'] = afd['synopsis'].replace('\n', ' ') | |
afd['neartext'] = afd['neartext'].replace('\n\n', '<br><br>').replace('\n* ', '<br>* ').replace('\n', ' ') | |
afd['shorttext'] = afd['shorttext'].replace('\n\n', '<br><br>').replace('\n', ' ') | |
afd['longtext'] = afd['longtext'].replace('\n\n', '<br><br>').replace('\n', ' ') | |
afd['aviationtext'] = afd['aviationtext'].replace('\n\n', '<br><br>').replace('\n', ' ') | |
afd['marinetext'] = afd['marinetext'].replace('\n\n', '<br><br>').replace('\n', ' ') | |
if 'highlights' not in afd: | |
afd['highlights'] = '' | |
else: | |
afd['highlights'] = "<ul><li>%s</li></ul>" % "</li><li>".join([h for h in afd['highlights'].replace('\n', ' ').split('*') if h != '']) | |
afd['updated'] = afd['meta'][-1] | |
wind_url = 'https://graphical.weather.gov/images/massachusetts/WindSpd%s_massachusetts.png' | |
afd['wind'] = "\n<br>\n".join([f'<img src="{wind_url}" loading="lazy">' % x for x in range(1, 26)] + [f'<img src="{wind_url}" loading="lazy">' % x for x in range(27, 52, 2)]) | |
afd['meta_json'] = json.dumps(afd['meta']) | |
# Seperate long paragraphs | |
for k in ['neartext', 'shorttext', 'longtext']: | |
paragraphs = [""] | |
for s in afd[k].split(". "): | |
if len(paragraphs[-1]) < 750: | |
paragraphs[-1] += s + ". " | |
else: | |
paragraphs.append(s + ". ") | |
afd[k] = "<br><br>".join(paragraphs) | |
return afd | |
def render_forecast(s: requests.Session): | |
forecast = s.get('https://api.weather.gov/gridpoints/BOX/76,97/forecast').json() | |
start = 0 if forecast['properties']['periods'][0]['isDaytime'] else 1 | |
for i in range(start, len(forecast['properties']['periods'])-start, 2): | |
forecast['properties']['periods'][i]['temperatureLow'] = forecast['properties']['periods'][i+1]['temperature'] | |
t = """ | |
<div class="day"> | |
<div>{day}</div> | |
<div><img src="{src}" loading="lazy"></div> | |
<div>{high}° {low}°</div> | |
</div> | |
""" | |
return "".join([t.format(high = ff['temperature'], low = ff.get('temperatureLow'), src = ff['icon'].replace('size=medium', 'size=small'), day = datetime.datetime.fromisoformat(ff['startTime']).strftime('%a')) for ff in forecast['properties']['periods'] if ff['isDaytime']]) | |
template = """ | |
<!DOCTYPE html> | |
<html lang="en-US"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Area Forceast Discussion - Boston</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<style> | |
body {{ | |
max-width: 480px; | |
margin: 0 auto; | |
font-family: system-ui; | |
margin: 1rem; | |
text-wrap: pretty; | |
}} | |
@media (min-width: 481px) {{ | |
body {{ | |
margin: 1rem auto; | |
}} | |
}} | |
img {{ | |
max-width: 100%; | |
}} | |
h1, h2, h3 {{ | |
font-family: Palatino; | |
}} | |
.day {{ | |
display: inline-block | |
}} | |
button {{ | |
position: fixed; | |
bottom: 0; | |
right: 0; | |
clip-path: circle(40%); | |
border: 0; | |
background-color: azure; | |
font-size: xx-large; | |
}} | |
</style> | |
</head> | |
<body> | |
<h1>AFD - Boston</h1> | |
<p>Updated: {updated}</p> | |
<!-- | |
{meta_json} | |
--> | |
<p>Links: | |
<a href="https://graphical.weather.gov/sectors/massachusettsLoop.php#tabs">Graphical</a> | | |
<a href="https://forecast.weather.gov/MapClick.php?lat=42.37458823179665&lon=-71.03582395942152">Weather</a> | | |
<a href="https://forecast.weather.gov/product.php?site=BOX&issuedby=BOX&product=AFD&format=CI&version=1&glossary=1&highlight=off">AFD</a> | | |
<a href="https://www.weather.gov/box/winter">Snow</a> | | |
<a href="https://nattaylor.com/blog/2024/afd-viewer/">About</a> | |
</p> | |
<h2>Synopsis</h2> | |
<p>{synopsis}</p> | |
<details> | |
<summary>Forecast</summary> | |
<div>{forecast}</div> | |
</details> | |
<details> | |
<summary>Weather Maps</summary> | |
<p>12 Hour</p> | |
<img src="https://www.wpc.ncep.noaa.gov/basicwx/92fndfd.gif" loading="lazy"> | |
<p>24 Hour</p> | |
<img src="http://www.wpc.ncep.noaa.gov/basicwx/94f.gif" loading="lazy"> | |
<p>36 Hour</p> | |
<img src="http://www.wpc.ncep.noaa.gov/basicwx/96f.gif" loading="lazy"> | |
<p>48 Hour</p> | |
<img src="http://www.wpc.ncep.noaa.gov/basicwx/98f.gif" loading="lazy"> | |
</details> | |
<details> | |
<summary>Precipitation Maps</summary> | |
<p>Day 1</p> | |
<img src="http://www.wpc.ncep.noaa.gov/qpf/94qwbg.gif" loading="lazy"> | |
<p>Day 2</p> | |
<img src="http://www.wpc.ncep.noaa.gov/qpf/98qwbg.gif" loading="lazy"> | |
<p>Day 3</p> | |
<img src="http://www.wpc.ncep.noaa.gov/qpf/99qwbg.gif" loading="lazy"> | |
</details> | |
<details> | |
<summary>Medium Range Forecasts</summary> | |
<p>Day 3</p> | |
<img src="https://www.wpc.ncep.noaa.gov/medr/9jhwbg_conus.gif" loading="lazy"> | |
<p>Day 4</p> | |
<img src="https://www.wpc.ncep.noaa.gov/medr/9khwbg_conus.gif" loading="lazy"> | |
<p>Day 5</p> | |
<img src="https://www.wpc.ncep.noaa.gov/medr/9lhwbg_conus.gif" loading="lazy"> | |
<p>Day 6</p> | |
<img src="https://www.wpc.ncep.noaa.gov/medr/9mhwbg_conus.gif" loading="lazy"> | |
<p>Day 7</p> | |
<img src="https://www.wpc.ncep.noaa.gov/medr/9nhwbg_conus.gif" loading="lazy"> | |
</details> | |
<details> | |
<summary>Graphical Wind</summary> | |
{wind} | |
</details> | |
<section> | |
<h3>Near Term</h3> | |
<p>{nearlabel}<br>{neartext}</p> | |
<h3>Short Term</h3> | |
<p>{shortlabel}<br>{shorttext}</p> | |
<h3>Long Term</h3> | |
<p>{longlabel}</p> | |
{highlights} | |
<p>{longtext}</p> | |
<h3>Aviation</h3> | |
<p>{aviationtext}</p> | |
<h3>Marine</h3> | |
<p>{marinetext}</p> | |
</section> | |
<button>🪄</button> | |
<script src="../../abbrs.js"></script> | |
{script} | |
</body> | |
</html> | |
""" | |
script = """ | |
<script> | |
async function postData(prompt) { | |
// Default options are marked with * | |
const response = await fetch("/cgi-bin/chat.py", { | |
method: "POST", // *GET, POST, PUT, DELETE, etc. | |
mode: "cors", // no-cors, *cors, same-origin | |
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached | |
credentials: "same-origin", // include, *same-origin, omit | |
headers: { | |
"Content-Type": "application/json", | |
// 'Content-Type': 'application/x-www-form-urlencoded', | |
}, | |
redirect: "follow", // manual, *follow, error | |
referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url | |
body: JSON.stringify({"prompt": prompt}), // body data type must match "Content-Type" header | |
}); | |
return response.json(); // parses JSON response into native JavaScript objects | |
} | |
document.querySelector("button").addEventListener('click', chat); | |
function chat() { | |
if ('' === window.getSelection().toString()) { | |
alert('Select some text and try again.'); | |
return true | |
} | |
let phrase = window.getSelection().toString(); | |
document.querySelector('#completion').innerText = 'Loading... this will take a few seconds.'; | |
document.querySelector('#dialog').showModal(); | |
postData(phrase).then(x=>{ | |
document.querySelector('#completion').innerText = x.message; | |
}); | |
} | |
document.querySelectorAll("section *").forEach(e=>abbr(e)); | |
function abbr(e) { | |
for (i in e.childNodes) { | |
ee = e.childNodes[i] | |
if (ee.nodeName != "#text") { | |
continue; | |
} | |
tokens = ee.textContent.split(" ") | |
newTokens = [] | |
for (token of tokens) { | |
newToken = token | |
for (key in abbrs) { | |
if (key != token) { | |
continue | |
} | |
newToken = `<a href="javascript:alert(\`${abbrs[key]}\`)">${key}</a>` | |
} | |
newTokens.push(newToken) | |
} | |
el = document.createElement('div') | |
el.innerHTML = newTokens.join(' ') | |
e.replaceChild(el, ee) | |
} | |
} | |
</script> | |
<dialog id="dialog" style="max-width: 480px;"> | |
<div id="completion">Loading... this will take a few seconds.</div> | |
<form method="dialog"> | |
<button>OK</button> | |
</form> | |
</dialog> | |
""" | |
template_error = """ | |
Nat's lousy code had an error so head on over to <a href="https://forecast.weather.gov/product.php?site=BOX&issuedby=BOX&product=AFD&format=txt&version=1&glossary=1&highlight=off">https://forecast.weather.gov/product.php?site=BOX&issuedby=BOX&product=AFD&format=txt&version=1&glossary=1&highlight=off</a> | |
""" | |
print("Content-Type: text/html\n") | |
try: | |
afd = parse(s) | |
afd['forecast'] = render_forecast(s) | |
afd['script'] = script | |
except Exception as e: | |
traceback.print_exc() | |
afd = None | |
if afd: | |
print(template.format(**afd)) | |
else: | |
print(template_error) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment