From 6d304fd9fb4cc4b393c86ba6c3ffc6e40fb3d683 Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Sat, 30 Nov 2019 18:49:28 -0800 Subject: [PATCH] Display noteworthy weather conditions as a third line in event summaries. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is very quick and dirty. Right now we display the temperature if it is outside of the range 65‒85℉ / 18‒29℃, and any free-form forecast text that contains the words "rain" or "snow". A followup will extend this to include warnings and alerts (like "Winter Storm Warning: Heavy Mixed precipitation, Mixed Precipitation"). This should probably be displayed in groups in general, though, rather than just as additional weather exceptions (both because the information is more pressing and because alerts aren't really forecasted). See #80. --- metabot/conftest.py | 5 ++ metabot/util/eventutil.py | 23 +++++++++ metabot/util/geoutil.py | 98 +++++++++++++++++++++++++++++++++++++++ setup.py | 3 +- 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 metabot/util/geoutil.py diff --git a/metabot/conftest.py b/metabot/conftest.py index 455bbb9..d31e2b3 100644 --- a/metabot/conftest.py +++ b/metabot/conftest.py @@ -17,6 +17,11 @@ def _dont_mangle_callback_data(monkeypatch): monkeypatch.setattr('ntelebot.keyboardutil.fix', lambda keyboard, maxlen=0: None) +@pytest.fixture(autouse=True) +def _disable_geoutil(monkeypatch): + monkeypatch.setattr('metabot.util.geoutil._CLIENT', None) + + def _format_message(response): text = response.pop('text', None) or response.pop('caption', '(EMPTY MESSAGE)') reply_markup = response.pop('reply_markup', None) diff --git a/metabot/util/eventutil.py b/metabot/util/eventutil.py index 8d32d32..921af1a 100644 --- a/metabot/util/eventutil.py +++ b/metabot/util/eventutil.py @@ -6,6 +6,7 @@ import pytz +from metabot.util import geoutil from metabot.util import html from metabot.util import humanize from metabot.util import tickets @@ -52,11 +53,33 @@ def format_event(bot, event, tzinfo, full=True): 'q': event['location'].encode('utf-8'), }) # yapf: disable message = '%s @ %s' % (message, location_url, location_name) + geo = format_geo(event['location'], event['start']) + if geo: + message = '%s\n\u26a0 %s' % (message, geo) if full and event['description']: message = '%s\n\n%s' % (message, html.sanitize(event['description'])) return message +def format_geo(address, now): + """Build a string of weather exceptions for the given address as of the given time.""" + + geo = geoutil.lookup(address, now) + if not geo or not geo.get('forecast'): + return + warnings = [] + if geo['forecast']['temperatureUnit'] == 'F': + if not 65 <= geo['forecast']['temperature'] <= 85: + warnings.append('%s\u2109' % geo['forecast']['temperature']) + elif geo['forecast']['temperatureUnit'] == 'C': + if not 18 <= geo['forecast']['temperature'] <= 29: + warnings.append('%s\u2103' % geo['forecast']['temperature']) + short = geo['forecast']['shortForecast'].lower() + if 'rain' in short or 'snow' in short: + warnings.append(geo['forecast']['shortForecast']) + return ' \u2022 '.join(warnings) + + def humanize_range(start, end, tzinfo): """Return the range between start and end as human-friendly text.""" diff --git a/metabot/util/geoutil.py b/metabot/util/geoutil.py new file mode 100644 index 0000000..dec0329 --- /dev/null +++ b/metabot/util/geoutil.py @@ -0,0 +1,98 @@ +"""Quick utilities for geocoding an address and looking up its weather forecast.""" + +import logging +import time +import urllib.parse + +import googlemaps +import requests + +from metabot.util import iso8601 +from metabot.util import pickleutil + +try: + _CLIENT_KEY = next(open('config/google_maps_apikey')).strip() +except IOError: + _CLIENT = None +else: + _CLIENT = googlemaps.Client(key=_CLIENT_KEY) +_CACHEFILE = 'config/geoutil.pickle' +_CACHE = pickleutil.load(_CACHEFILE) or {} +_SHORTCACHE = {} + + +def _save(): + pickleutil.dump(_CACHEFILE, _CACHE) + + +def geocode(address): + """Look up the given address in Google Maps.""" + + if 'geocode' not in _CACHE: + _CACHE['geocode'] = {} + if address not in _CACHE['geocode']: + _CACHE['geocode'][address] = _CLIENT.geocode(address) + _save() + return _CACHE['geocode'][address] + + +def _weatherfetch(url): + url = urllib.parse.urljoin('https://api.weather.gov/', url) + headers = { + 'user-agent': 'https://github.com/nmlorg/metabot', + } + logging.info('Fetching %r.', url) + return requests.get(url, headers=headers).json() + + +def weatherpoint(lat, lon): + """Map a latitude/longitude to a weather.gov gridpoint.""" + + # https://weather-gov.github.io/api/general-faqs#how-do-i-get-a-forecast-for-a-location-from-the-api + lat = ('%.4f' % lat).rstrip('0') + lon = ('%.4f' % lon).rstrip('0') + key = '%s,%s' % (lat, lon) + + if 'weatherpoint' not in _CACHE: + _CACHE['weatherpoint'] = {} + if key not in _CACHE['weatherpoint']: + _CACHE['weatherpoint'][key] = _weatherfetch('points/' + key) + _save() + return _CACHE['weatherpoint'][key] + + +def hourlyforecast(lat, lon): + """Retrieve the hourly forecast for the given latitude/longitude.""" + + point = weatherpoint(lat, lon) + if not point.get('properties'): + return () + now = time.time() + url = point['properties']['forecastHourly'] + last, ret = _SHORTCACHE.get(url) or (0, None) + if last < now - 10 * 60: + ret = _weatherfetch(url)['properties']['periods'] + _SHORTCACHE[url] = (now, ret) + return ret + + +def lookup(address, now=None): + """Retrieve the forecasted weather conditions for the given address as of the given time.""" + + if _CLIENT is None: + return + if now is None: + now = time.time() + ret = {} + geo = geocode(address) + if not geo: + return + ret['lat'] = geo[0]['geometry']['location']['lat'] + ret['lon'] = geo[0]['geometry']['location']['lng'] + for period in hourlyforecast(ret['lat'], ret['lon']): + start = iso8601.totimestamp(period['startTime']) + end = iso8601.totimestamp(period['endTime']) + if start <= now < end: + ret['forecast'] = period + break + return ret diff --git a/setup.py b/setup.py index e12b83b..68c5659 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setuptools.setup( name='metabot', - version='0.3.16.4', + version='0.3.17', author='Daniel Reed', author_email='nmlorg@gmail.com', description='Modularized, multi-account bot.', @@ -14,6 +14,7 @@ package_data={'': ['*.html']}, python_requires='>=3.5', install_requires=[ + 'googlemaps', 'ntelebot >= 0.3.4', 'pytz', 'PyYAML >= 5.1',