Skip to content

Instantly share code, notes, and snippets.

@nattaylor
Last active February 18, 2024 06:31
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nattaylor/e4a10ad3999d605b07a582dd3ea02e52 to your computer and use it in GitHub Desktop.
Save nattaylor/e4a10ad3999d605b07a582dd3ea02e52 to your computer and use it in GitHub Desktop.
Area Forecast Discussion Parser
#!/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