diff --git a/README.md b/README.md index 5a3c753..fdc7f1b 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ You can override this behavior by adding `?u` or `?m` to a URL like this: $ curl wttr.in/Amsterdam?u $ curl wttr.in/Amsterdam?m -## Supported output formats +## Supported output formats and views -wttr.in currently supports four output formats: +wttr.in currently supports five output formats: * ANSI for the terminal; -* ANSI for the terminal, one-line mode; +* Plain-text for the terminal and scripts; * HTML for the browser; * PNG for the graphical viewers; * JSON for scripts and APIs. @@ -122,53 +122,6 @@ You can embed a special wttr.in widget, that displays the weather condition for ![Embedded wttr.in example at feuerwehr-eisolzried.de](https://user-images.githubusercontent.com/3875145/65265457-50eac180-db11-11e9-8f9b-2e1711dfc436.png) -## JSON output - -The JSON format is a feature providing access to wttr.in data through an easy-to-parse format, without requiring the user to create a complex script to reinterpret wttr.in's graphical output. - -To fetch information in JSON format, use the following syntax: - - $ curl wttr.in/Detroit?format=j1 - -This will fetch information on the Detroit region in JSON format. The j1 format code is used to allow for the use of other layouts for the JSON output. - -The result will look something like the following: - - { - "current_condition": [ - { - "FeelsLikeC": "25", - "FeelsLikeF": "76", - "cloudcover": "100", - "humidity": "76", - "observation_time": "04:08 PM", - "precipMM": "0.2", - "pressure": "1019", - "temp_C": "22", - "temp_F": "72", - "uvIndex": 5, - "visibility": "16", - "weatherCode": "122", - "weatherDesc": [ - { - "value": "Overcast" - } - ], - "weatherIconUrl": [ - { - "value": "" - } - ], - "winddir16Point": "NNE", - "winddirDegree": "20", - "windspeedKmph": "7", - "windspeedMiles": "4" - } - ], - ... - -Most of these values are self-explanatory, aside from `weatherCode`. The `weatherCode` is an enumeration which you can find at either [the WorldWeatherOnline website](https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx) or [in the wttr.in source code](https://github.com/chubin/wttr.in/blob/master/lib/constants.py). - ## One-line output For one-line output format, specify additional URL parameter `format`: @@ -209,6 +162,14 @@ To specify your own custom output format, use the special `%`-notation: p precipitation (mm), o Probability of Precipitation, P pressure (hPa), + + D Dawn*, + S Sunrise*, + z Zenith*, + s Sunset*, + d Dusk*. + +(times are shown in the local timezone) ``` So, these two calls are the same: @@ -316,6 +277,54 @@ The result, should look like: ![URXVT Emoji line](https://user-images.githubusercontent.com/24360204/63842949-1d36d480-c975-11e9-81dd-998d1329bd8a.png) +## JSON output + +The JSON format is a feature providing access to wttr.in data through an easy-to-parse format, without requiring the user to create a complex script to reinterpret wttr.in's graphical output. + +To fetch information in JSON format, use the following syntax: + + $ curl wttr.in/Detroit?format=j1 + +This will fetch information on the Detroit region in JSON format. The j1 format code is used to allow for the use of other layouts for the JSON output. + +The result will look something like the following: + + { + "current_condition": [ + { + "FeelsLikeC": "25", + "FeelsLikeF": "76", + "cloudcover": "100", + "humidity": "76", + "observation_time": "04:08 PM", + "precipMM": "0.2", + "pressure": "1019", + "temp_C": "22", + "temp_F": "72", + "uvIndex": 5, + "visibility": "16", + "weatherCode": "122", + "weatherDesc": [ + { + "value": "Overcast" + } + ], + "weatherIconUrl": [ + { + "value": "" + } + ], + "winddir16Point": "NNE", + "winddirDegree": "20", + "windspeedKmph": "7", + "windspeedMiles": "4" + } + ], + ... + +Most of these values are self-explanatory, aside from `weatherCode`. The `weatherCode` is an enumeration which you can find at either [the WorldWeatherOnline website](https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx) or [in the wttr.in source code](https://github.com/chubin/wttr.in/blob/master/lib/constants.py). + + ## Moon phases wttr.in can also be used to check the phase of the Moon. This example shows how to see the current Moon phase @@ -446,11 +455,15 @@ If you want to get weather reports as PNG files, you'll also need to install: You can install most of them using `pip`. +Some python package use LLVM, so install it first: + + $ apt-get install llvm-7 llvm-7-dev + If `virtualenv` is used: - $ virtualenv ve - $ ve/bin/pip install -r requirements.txt - $ ve/bin/python bin/srv.py + $ virtualenv -p python3 ve + $ ve/bin/pip3 install -r requirements.txt + $ ve/bin/python3 bin/srv.py Also, you need to install the geoip2 database. You can use a free database GeoLite2 that can be downloaded from (http://dev.maxmind.com/geoip/geoip2/geolite2/). diff --git a/bin/proxy.py b/bin/proxy.py index ddb9642..49d2157 100644 --- a/bin/proxy.py +++ b/bin/proxy.py @@ -7,6 +7,10 @@ The proxy server acts as a backend for the wttr.in service. It caches the answers and handles various data sources transforming their answers into format supported by the wttr.in service. +If WTTRIN_TEST is specified, it works in a special test mode: +it does not fetch and does not store the data in the cache, +but is using the fake data from "test/proxy-data". + """ from __future__ import print_function @@ -19,6 +23,7 @@ import sys import os import time import json +import hashlib import requests import cyrtranslit @@ -35,7 +40,10 @@ from globals import PROXY_CACHEDIR, PROXY_HOST, PROXY_PORT from translations import PROXY_LANGS # pylint: enable=wrong-import-position +def is_testmode(): + """Server is running in the wttr.in test mode""" + return "WTTRIN_TEST" in os.environ def load_translations(): """ @@ -66,38 +74,56 @@ TRANSLATIONS = load_translations() def _find_srv_for_query(path, query): # pylint: disable=unused-argument return 'http://api.worldweatheronline.com' +def _cache_file(path, query): + """Return cache file name for specified `path` and `query` + and for the current time. + + Do smooth load on the server, expiration time + is slightly varied basing on the path+query sha1 hash digest. + """ + + digest = hashlib.sha1(("%s %s" % (path, query)).encode("utf-8")).hexdigest() + digest_number = ord(digest[0].upper()) + expiry_interval = 60*(digest_number+10) + + timestamp = "%010d" % (int(time.time())//expiry_interval*expiry_interval) + filename = os.path.join(PROXY_CACHEDIR, timestamp, path, query) + + return filename + + def _load_content_and_headers(path, query): - timestamp = time.strftime("%Y%m%d%H", time.localtime()) - cache_file = os.path.join(PROXY_CACHEDIR, timestamp, path, query) + if is_testmode(): + cache_file = "test/proxy-data/data1" + else: + cache_file = _cache_file(path, query) try: return (open(cache_file, 'r').read(), json.loads(open(cache_file+".headers", 'r').read())) except IOError: return None, None -def _touch_empty_file(path, query, content, headers): - timestamp = time.strftime("%Y%m%d%H", time.localtime()) - cache_file = os.path.join(PROXY_CACHEDIR + ".empty", timestamp, path, query) +def _touch_empty_file(path, query): + cache_file = _cache_file(path, query) cache_dir = os.path.dirname(cache_file) if not os.path.exists(cache_dir): os.makedirs(cache_dir) open(cache_file, 'w').write("") def _save_content_and_headers(path, query, content, headers): - timestamp = time.strftime("%Y%m%d%H", time.localtime()) - cache_file = os.path.join(PROXY_CACHEDIR, timestamp, path, query) + cache_file = _cache_file(path, query) cache_dir = os.path.dirname(cache_file) if not os.path.exists(cache_dir): os.makedirs(cache_dir) open(cache_file + ".headers", 'w').write(json.dumps(headers)) - open(cache_file, 'w').write(content) + open(cache_file, 'wb').write(content) def translate(text, lang): """ Translate `text` into `lang` """ translated = TRANSLATIONS.get(lang, {}).get(text, text) - if text.encode('utf-8') == translated: + if text == translated: print("%s: %s" % (lang, text)) return translated @@ -108,20 +134,25 @@ def cyr(to_translate): return cyrtranslit.to_cyrillic(to_translate) def _patch_greek(original): - return original.decode('utf-8').replace(u"Ηλιόλουστη/ο", u"Ηλιόλουστη").encode('utf-8') + return original.replace(u"Ηλιόλουστη/ο", u"Ηλιόλουστη") def add_translations(content, lang): """ Add `lang` translation to `content` (JSON) returned by the data source """ + + if content is "{}": + return {} + languages_to_translate = TRANSLATIONS.keys() try: d = json.loads(content) # pylint: disable=invalid-name - except ValueError as exception: + except (ValueError, TypeError) as exception: print("---") print(exception) print("---") + return {} try: weather_condition = d['data']['current_condition'][0]['weatherDesc'][0]['value'] @@ -132,16 +163,16 @@ def add_translations(content, lang): d['data']['current_condition'][0]['lang_%s' % lang] = \ [{'value': cyr( d['data']['current_condition'][0]['lang_%s' % lang][0]['value']\ - .encode('utf-8'))}] + )}] elif lang == 'el': d['data']['current_condition'][0]['lang_%s' % lang] = \ [{'value': _patch_greek( d['data']['current_condition'][0]['lang_%s' % lang][0]['value']\ - .encode('utf-8'))}] + )}] elif lang == 'sr-lat': d['data']['current_condition'][0]['lang_%s' % lang] = \ [{'value':d['data']['current_condition'][0]['lang_sr'][0]['value']\ - .encode('utf-8')}] + }] fixed_weather = [] for w in d['data']['weather']: # pylint: disable=invalid-name @@ -153,13 +184,13 @@ def add_translations(content, lang): [{'value': translate(weather_condition, lang)}] elif lang == 'sr': h['lang_%s' % lang] = \ - [{'value': cyr(h['lang_%s' % lang][0]['value'].encode('utf-8'))}] + [{'value': cyr(h['lang_%s' % lang][0]['value'])}] elif lang == 'el': h['lang_%s' % lang] = \ - [{'value': _patch_greek(h['lang_%s' % lang][0]['value'].encode('utf-8'))}] + [{'value': _patch_greek(h['lang_%s' % lang][0]['value'])}] elif lang == 'sr-lat': h['lang_%s' % lang] = \ - [{'value': h['lang_sr'][0]['value'].encode('utf-8')}] + [{'value': h['lang_sr'][0]['value']}] fixed_hourly.append(h) w['hourly'] = fixed_hourly fixed_weather.append(w) @@ -177,7 +208,7 @@ def proxy(path): """ lang = request.args.get('lang', 'en') - query_string = request.query_string + query_string = request.query_string.decode("utf-8") query_string = query_string.replace('sr-lat', 'sr') query_string = query_string.replace('lang=None', 'lang=en') query_string += "&extra=localObsTime" @@ -187,7 +218,6 @@ def proxy(path): if content is None: srv = _find_srv_for_query(path, query_string) url = '%s/%s?%s' % (srv, path, query_string) - print(url) attempts = 10 response = None @@ -203,14 +233,18 @@ def proxy(path): except ValueError: attempts -= 1 - _touch_empty_file(path, query_string, content, headers) + _touch_empty_file(path, query_string) if response: headers = {} headers['Content-Type'] = response.headers['content-type'] _save_content_and_headers(path, query_string, response.content, headers) - content = add_translations(response.content, lang) + content = response.content else: content = "{}" + else: + print("cache found") + + content = add_translations(content, lang) return content, 200, headers diff --git a/cmd/srv.go b/cmd/srv.go new file mode 100644 index 0000000..371cf21 --- /dev/null +++ b/cmd/srv.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "time" + + "github.com/hashicorp/golang-lru" +) + +var lruCache *lru.Cache + +type ResponseWithHeader struct { + Body []byte + Header http.Header + StatusCode int // e.g. 200 + +} + +func init() { + var err error + lruCache, err = lru.New(12800) + if err != nil { + panic(err) + } + + dialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + } + + http.DefaultTransport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + addr = "127.0.0.1:8002" + return dialer.DialContext(ctx, network, addr) + } + +} + +func readUserIP(r *http.Request) string { + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + var err error + IPAddress, _, err = net.SplitHostPort(IPAddress) + if err != nil { + fmt.Printf("userip: %q is not IP:port\n", IPAddress) + } + } + return IPAddress +} + +// implementation of the cache.get_signature of original wttr.in +func findCacheDigest(req *http.Request) string { + + userAgent := req.Header.Get("User-Agent") + + queryHost := req.Host + queryString := req.RequestURI + + clientIpAddress := readUserIP(req) + + lang := req.Header.Get("Accept-Language") + + now := time.Now() + secs := now.Unix() + timestamp := secs / 1000 + + return fmt.Sprintf("%s:%s%s:%s:%s:%d", userAgent, queryHost, queryString, clientIpAddress, lang, timestamp) +} + +func get(req *http.Request) ResponseWithHeader { + + client := &http.Client{} + + queryURL := fmt.Sprintf("http://%s%s", req.Host, req.RequestURI) + + proxyReq, err := http.NewRequest(req.Method, queryURL, req.Body) + if err != nil { + // handle error + } + + // proxyReq.Header.Set("Host", req.Host) + // proxyReq.Header.Set("X-Forwarded-For", req.RemoteAddr) + + for header, values := range req.Header { + for _, value := range values { + proxyReq.Header.Add(header, value) + } + } + + res, err := client.Do(proxyReq) + + if err != nil { + panic(err) + } + + body, err := ioutil.ReadAll(res.Body) + + return ResponseWithHeader{ + Body: body, + Header: res.Header, + StatusCode: res.StatusCode, + } +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} + +func main() { + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + var response ResponseWithHeader + + cacheDigest := findCacheDigest(r) + cacheBody, ok := lruCache.Get(cacheDigest) + if ok { + response = cacheBody.(ResponseWithHeader) + } else { + fmt.Println(cacheDigest) + response = get(r) + if response.StatusCode == 200 { + lruCache.Add(cacheDigest, response) + } + } + copyHeader(w.Header(), response.Header) + w.WriteHeader(response.StatusCode) + w.Write(response.Body) + }) + + log.Fatal(http.ListenAndServe(":8081", nil)) + +} diff --git a/lib/cache.py b/lib/cache.py index 8f923b4..2dd1151 100644 --- a/lib/cache.py +++ b/lib/cache.py @@ -5,17 +5,27 @@ LRU-Cache implementation for formatted (`format=`) answers import datetime import re import time -import pylru +import os +import hashlib +import random + import pytz +import pylru + +from globals import LRU_CACHE CACHE_SIZE = 10000 CACHE = pylru.lrucache(CACHE_SIZE) +# strings longer than this are stored not in ram +# but in the file cache +MIN_SIZE_FOR_FILECACHE = 80 + def _update_answer(answer): def _now_in_tz(timezone): return datetime.datetime.now(pytz.timezone(timezone)).strftime("%H:%M:%S%z") - if "%{{NOW(" in answer: + if isinstance(answer, str) and "%{{NOW(" in answer: answer = re.sub(r"%{{NOW\(([^}]*)\)}}", lambda x: _now_in_tz(x.group(1)), answer) return answer @@ -26,9 +36,9 @@ def get_signature(user_agent, query_string, client_ip_address, lang): `lang`, and `client_ip_address` """ - timestamp = int(time.time()) / 1000 - signature = "%s:%s:%s:%s:%s" % \ - (user_agent, query_string, client_ip_address, lang, timestamp) + signature = "%s:%s:%s:%s" % \ + (user_agent, query_string, client_ip_address, lang) + print(signature) return signature def get(signature): @@ -38,13 +48,92 @@ def get(signature): the `_update_answer` function. """ - if signature in CACHE: - return _update_answer(CACHE[signature]) + value_record = CACHE.get(signature) + if not value_record: + return None + + value = value_record["val"] + expiry = value_record["expiry"] + if value and time.time() < expiry: + if value.startswith("file:") or value.startswith("bfile:"): + value = _read_from_file(signature, sighash=value) + if not value: + return None + return _update_answer(value) return None +def _randint(minimum, maximum): + return random.randrange(maximum - minimum) + def store(signature, value): """ Store in cache `value` for `signature` """ - CACHE[signature] = value + + if len(value) >= MIN_SIZE_FOR_FILECACHE: + value_to_store = _store_in_file(signature, value) + else: + value_to_store = value + + value_record = { + "val": value_to_store, + "expiry": time.time() + _randint(1000, 2000), + } + + CACHE[signature] = value_record + return _update_answer(value) + +def _hash(signature): + return hashlib.md5(signature.encode("utf-8")).hexdigest() + +def _store_in_file(signature, value): + """Store `value` for `signature` in cache file. + Return file name (signature_hash) as the result. + `value` can be string as well as bytes. + Returned filename is prefixed with "file:" (for text files) + or "bfile:" (for binary files). + """ + + signature_hash = _hash(signature) + filename = os.path.join(LRU_CACHE, signature_hash) + if not os.path.exists(LRU_CACHE): + os.makedirs(LRU_CACHE) + + if isinstance(value, bytes): + mode = "wb" + signature_hash = "bfile:%s" % signature_hash + else: + mode = "w" + signature_hash = "file:%s" % signature_hash + + with open(filename, mode) as f_cache: + f_cache.write(value) + return signature_hash + +def _read_from_file(signature, sighash=None): + """Read value for `signature` from cache file, + or return None if file is not found. + If `sighash` is specified, do not calculate file name + from signature, but use `sighash` instead. + + `sigash` can be prefixed with "file:" (for text files) + or "bfile:" (for binary files). + """ + + mode = "r" + if sighash: + if sighash.startswith("file:"): + sighash = sighash[5:] + elif sighash.startswith("bfile:"): + sighash = sighash[6:] + mode = "rb" + else: + sighash = _hash(signature) + + filename = os.path.join(LRU_CACHE, sighash) + if not os.path.exists(filename): + return None + + with open(filename, mode) as f_cache: + return f_cache.read() diff --git a/lib/constants.py b/lib/constants.py index f6ff07d..2a44f9b 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -52,55 +52,55 @@ WWO_CODE = { } WEATHER_SYMBOL = { - "Unknown": u"✨", - "Cloudy": u"☁️", - "Fog": u"🌫", - "HeavyRain": u"🌧", - "HeavyShowers": u"🌧", - "HeavySnow": u"❄️", - "HeavySnowShowers": u"❄️", - "LightRain": u"🌦", - "LightShowers": u"🌦", - "LightSleet": u"🌧", - "LightSleetShowers": u"🌧", - "LightSnow": u"🌨", - "LightSnowShowers": u"🌨", - "PartlyCloudy": u"⛅️", - "Sunny": u"☀️", - "ThunderyHeavyRain": u"🌩", - "ThunderyShowers": u"⛈", - "ThunderySnowShowers": u"⛈", - "VeryCloudy": u"☁️", + "Unknown": "✨", + "Cloudy": "☁️", + "Fog": "🌫", + "HeavyRain": "🌧", + "HeavyShowers": "🌧", + "HeavySnow": "❄️", + "HeavySnowShowers": "❄️", + "LightRain": "🌦", + "LightShowers": "🌦", + "LightSleet": "🌧", + "LightSleetShowers": "🌧", + "LightSnow": "🌨", + "LightSnowShowers": "🌨", + "PartlyCloudy": "⛅️", + "Sunny": "☀️", + "ThunderyHeavyRain": "🌩", + "ThunderyShowers": "⛈", + "ThunderySnowShowers": "⛈", + "VeryCloudy": "☁️", } WEATHER_SYMBOL_WIDTH_VTE = { - u"✨": 2, - u"☁️": 1, - u"🌫": 1, - u"🌧": 2, - u"🌧": 2, - u"❄️": 1, - u"❄️": 1, - u"🌦": 1, - u"🌦": 1, - u"🌧": 1, - u"🌧": 1, - u"🌨": 2, - u"🌨": 2, - u"⛅️": 2, - u"☀️": 1, - u"🌩": 2, - u"⛈": 1, - u"⛈": 1, - u"☁️": 1, + "✨": 2, + "☁️": 1, + "🌫": 2, + "🌧": 2, + "🌧": 2, + "❄️": 1, + "❄️": 1, + "🌦": 1, + "🌦": 1, + "🌧": 1, + "🌧": 1, + "🌨": 2, + "🌨": 2, + "⛅️": 2, + "☀️": 1, + "🌩": 2, + "⛈": 1, + "⛈": 1, + "☁️": 1, } WIND_DIRECTION = [ - u"↓", u"↙", u"←", u"↖", u"↑", u"↗", u"→", u"↘", + "↓", "↙", "←", "↖", "↑", "↗", "→", "↘", ] MOON_PHASES = ( - u"🌑", u"🌒", u"🌓", u"🌔", u"🌕", u"🌖", u"🌗", u"🌘" + "🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘" ) WEATHER_SYMBOL_WEGO = { diff --git a/lib/extract_emoji.py b/lib/extract_emoji.py new file mode 100644 index 0000000..8c42675 --- /dev/null +++ b/lib/extract_emoji.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +#vim: fileencoding=utf-8 + +""" + +At the moment, Pillow library does not support colorful emojis, +that is why emojis must be extracted to external files first, +and then they must be handled as usual graphical objects +and not as text. + +The files are extracted using Imagemagick. + +Usage: + + ve/bi/python lib/extract_emoji.py +""" + +import subprocess + +EMOJIS = [ + "✨", + "☁️", + "🌫", + "🌧", + "🌧", + "❄️", + "❄️", + "🌦", + "🌦", + "🌧", + "🌧", + "🌨", + "🌨", + "⛅️", + "☀️", + "🌩", + "⛈", + "⛈", + "☁️", + "🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘" +] + +def extract_emojis_to_directory(dirname): + """ + Extract emoji from an emoji font, to separate files. + """ + + emoji_font = "Noto Color Emoji" + emoji_size = 30 + + for emoji in EMOJIS: + filename = "%s/%s.png" % (dirname, emoji) + convert_string = [ + "convert", "-background", "black", "-size", "%sx%s" % (emoji_size, emoji_size), + "-set", "colorspace", "sRGB", + "pango:%s" % (emoji_font, emoji), + filename + ] + subprocess.Popen(convert_string) + +if __name__ == '__main__': + extract_emojis_to_directory("share/emoji") diff --git a/lib/fmt/__init__.py b/lib/fmt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/fmt/png.py b/lib/fmt/png.py new file mode 100644 index 0000000..6f7f063 --- /dev/null +++ b/lib/fmt/png.py @@ -0,0 +1,283 @@ +#!/usr/bin/python +#vim: encoding=utf-8 +# pylint: disable=wrong-import-position,wrong-import-order,redefined-builtin + +""" +This module is used to generate png-files for wttr.in queries. +The only exported function is: + +* render_ansi(png_file, text, options=None) + +`render_ansi` is the main function of the module, +which does rendering of stream into a PNG-file. + +The module uses PIL for graphical tasks, and pyte for rendering +of ANSI stream into terminal representation. +""" + +from __future__ import print_function + +import sys +import io +import os +import glob + +from PIL import Image, ImageFont, ImageDraw +import pyte.screens +import emoji +import grapheme + +from . import unicodedata2 + +sys.path.insert(0, "..") +import constants +import globals + +COLS = 180 +ROWS = 100 +CHAR_WIDTH = 8 +CHAR_HEIGHT = 14 +FONT_SIZE = 13 +FONT_CAT = { + 'default': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + 'Cyrillic': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + 'Greek': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + 'Arabic': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + 'Hebrew': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + 'Han': "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + 'Hiragana': "/usr/share/fonts/truetype/motoya-l-cedar/MTLc3m.ttf", + 'Katakana': "/usr/share/fonts/truetype/motoya-l-cedar/MTLc3m.ttf", + 'Hangul': "/usr/share/fonts/truetype/lexi/LexiGulim.ttf", + 'Braille': "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf", + 'Emoji': "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf", +} + +# +# How to find font for non-standard scripts: +# +# $ fc-list :lang=ja +# +# GNU/Debian packages, that the fonts come from: +# +# * fonts-dejavu-core +# * fonts-wqy-zenhei (Han) +# * fonts-motoya-l-cedar (Hiragana/Katakana) +# * fonts-lexi-gulim (Hangul) +# * fonts-symbola (Braille/Emoji) +# + +def render_ansi(text, options=None): + """Render `text` (terminal sequence) in a PNG file + paying attention to passed command line `options`. + + Return: file content + """ + + screen = pyte.screens.Screen(COLS, ROWS) + screen.set_mode(pyte.modes.LNM) + stream = pyte.Stream(screen) + + text, graphemes = _fix_graphemes(text) + stream.feed(text) + + buf = sorted(screen.buffer.items(), key=lambda x: x[0]) + buf = [[x[1] for x in sorted(line[1].items(), key=lambda x: x[0])] for line in buf] + + return _gen_term(buf, graphemes, options=options) + +def _color_mapping(color): + """Convert pyte color to PIL color + + Return: tuple of color values (R,G,B) + """ + + if color == 'default': + return 'lightgray' + if color in ['green', 'black', 'cyan', 'blue', 'brown']: + return color + try: + return ( + int(color[0:2], 16), + int(color[2:4], 16), + int(color[4:6], 16)) + except (ValueError, IndexError): + # if we do not know this color and it can not be decoded as RGB, + # print it and return it as it is (will be displayed as black) + # print color + return color + return color + +def _strip_buf(buf): + """Strips empty spaces from behind and from the right side. + (from the right side is not yet implemented) + """ + + def empty_line(line): + "Returns True if the line consists from spaces" + return all(x.data == ' ' for x in line) + + def line_len(line): + "Returns len of the line excluding spaces from the right" + + last_pos = len(line) + while last_pos > 0 and line[last_pos-1].data == ' ': + last_pos -= 1 + return last_pos + + number_of_lines = 0 + for line in buf[::-1]: + if not empty_line(line): + break + number_of_lines += 1 + + if number_of_lines: + buf = buf[:-number_of_lines] + + max_len = max(line_len(x) for x in buf) + buf = [line[:max_len] for line in buf] + + return buf + +def _script_category(char): + """Returns category of a Unicode character + + Possible values: + default, Cyrillic, Greek, Han, Hiragana + """ + + if char in emoji.UNICODE_EMOJI: + return "Emoji" + + cat = unicodedata2.script_cat(char)[0] + if char == u':': + return 'Han' + if cat in ['Latin', 'Common']: + return 'default' + return cat + +def _load_emojilib(): + """Load known emojis from a directory, and return dictionary + of PIL Image objects correspodent to the loaded emojis. + Each emoji is resized to the CHAR_HEIGHT size. + """ + + emojilib = {} + for filename in glob.glob("share/emoji/*.png"): + character = os.path.basename(filename)[:-4] + emojilib[character] = \ + Image.open(filename).resize((CHAR_HEIGHT, CHAR_HEIGHT)) + return emojilib + +# pylint: disable=too-many-locals,too-many-branches,too-many-statements +def _gen_term(buf, graphemes, options=None): + """Renders rendered pyte buffer `buf` and list of workaround `graphemes` + to a PNG file, and return its content + """ + + if not options: + options = {} + + current_grapheme = 0 + + buf = _strip_buf(buf) + cols = max(len(x) for x in buf) + rows = len(buf) + + image = Image.new('RGB', (cols * CHAR_WIDTH, rows * CHAR_HEIGHT)) + + buf = buf[-ROWS:] + + draw = ImageDraw.Draw(image) + font = {} + for cat in FONT_CAT: + font[cat] = ImageFont.truetype(FONT_CAT[cat], FONT_SIZE) + + emojilib = _load_emojilib() + + x_pos = 0 + y_pos = 0 + for line in buf: + x_pos = 0 + for char in line: + current_color = _color_mapping(char.fg) + if char.bg != 'default': + draw.rectangle( + ((x_pos, y_pos), + (x_pos+CHAR_WIDTH, y_pos+CHAR_HEIGHT)), + fill=_color_mapping(char.bg)) + + if char.data == "!": + try: + data = graphemes[current_grapheme] + except IndexError: + pass + current_grapheme += 1 + else: + data = char.data + + if data: + cat = _script_category(data[0]) + if cat not in font: + globals.log("Unknown font category: %s" % cat) + if cat == 'Emoji' and emojilib.get(data): + image.paste(emojilib.get(data), (x_pos, y_pos)) + else: + draw.text( + (x_pos, y_pos), + data, + font=font.get(cat, font.get('default')), + fill=current_color) + + x_pos += CHAR_WIDTH * constants.WEATHER_SYMBOL_WIDTH_VTE.get(data, 1) + y_pos += CHAR_HEIGHT + + if 'transparency' in options: + transparency = options.get('transparency', '255') + try: + transparency = int(transparency) + except ValueError: + transparency = 255 + + if transparency < 0: + transparency = 0 + + if transparency > 255: + transparency = 255 + + image = image.convert("RGBA") + datas = image.getdata() + + new_data = [] + for item in datas: + new_item = tuple(list(item[:3]) + [transparency]) + new_data.append(new_item) + + image.putdata(new_data) + + img_bytes = io.BytesIO() + image.save(img_bytes, format="png") + return img_bytes.getvalue() + +def _fix_graphemes(text): + """ + Extract long graphemes sequences that can't be handled + by pyte correctly because of the bug pyte#131. + Graphemes are omited and replaced with placeholders, + and returned as a list. + + Return: + text_without_graphemes, graphemes + """ + + output = "" + graphemes = [] + + for gra in grapheme.graphemes(text): + if len(gra) > 1: + character = "!" + graphemes.append(gra) + else: + character = gra + output += character + + return output, graphemes diff --git a/lib/unicodedata2.py b/lib/fmt/unicodedata2.py similarity index 99% rename from lib/unicodedata2.py rename to lib/fmt/unicodedata2.py index ed9070e..45e7ec4 100644 --- a/lib/unicodedata2.py +++ b/lib/fmt/unicodedata2.py @@ -1,3 +1,9 @@ +# downloaded from https://gist.github.com/2204527 +# described/recommended here: +# +# http://stackoverflow.com/questions/9868792/find-out-the-unicode-script-of-a-character +# + from __future__ import print_function from unicodedata import * diff --git a/lib/globals.py b/lib/globals.py index cf69d15..fbe34b7 100644 --- a/lib/globals.py +++ b/lib/globals.py @@ -14,6 +14,7 @@ from __future__ import print_function import logging import os +import re MYDIR = os.path.abspath(os.path.dirname(os.path.dirname('__file__'))) @@ -28,9 +29,9 @@ PYPHOON = "/home/igor/pyphoon/bin/pyphoon-lolcat" _DATADIR = "/wttr.in" _LOGDIR = "/wttr.in/log" -CACHEDIR = os.path.join(_DATADIR, "cache/wego/") IP2LCACHE = os.path.join(_DATADIR, "cache/ip2l/") PNG_CACHE = os.path.join(_DATADIR, "cache/png") +LRU_CACHE = os.path.join(_DATADIR, "cache/lru") LOG_FILE = os.path.join(_LOGDIR, 'main.log') @@ -41,7 +42,6 @@ BLACKLIST = os.path.join(MYDIR, "share/blacklist") HELP_FILE = os.path.join(MYDIR, 'share/help.txt') BASH_FUNCTION_FILE = os.path.join(MYDIR, 'share/bash-function.txt') TRANSLATION_FILE = os.path.join(MYDIR, 'share/translation.txt') -TEST_FILE = os.path.join(MYDIR, 'share/test-NAME.txt') IATA_CODES_FILE = os.path.join(MYDIR, 'share/list-of-iata-codes.txt') @@ -77,8 +77,8 @@ PLAIN_TEXT_AGENTS = [ "lwp-request", "wget", "python-requests", - "OpenBSD ftp", - "PowerShell" + "openbsd ftp", + "powershell", ] PLAIN_TEXT_PAGES = [':help', ':bash.function', ':translation', ':iterm2'] @@ -123,3 +123,7 @@ def get_help_file(lang): if os.path.exists(help_file): return help_file return HELP_FILE + +def remove_ansi(sometext): + ansi_escape = re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]') + return ansi_escape.sub('', sometext) diff --git a/lib/location.py b/lib/location.py index f931092..20f5a12 100644 --- a/lib/location.py +++ b/lib/location.py @@ -9,6 +9,7 @@ precise location description. """ from __future__ import print_function +import sys import os import json import socket @@ -35,12 +36,15 @@ def is_ip(ip_addr): Check if `ip_addr` looks like an IP Address """ + if sys.version_info[0] < 3: + ip_addr = ip_addr.encode("utf-8") + try: - socket.inet_pton(socket.AF_INET, ip_addr.encode("utf-8")) + socket.inet_pton(socket.AF_INET, ip_addr) return True except socket.error: try: - socket.inet_pton(socket.AF_INET6, ip_addr.encode("utf-8")) + socket.inet_pton(socket.AF_INET6, ip_addr) return True except socket.error: return False @@ -260,7 +264,7 @@ def location_processing(location, ip_addr): # if location is not None and location.upper() in IATA_CODES: # location = '~%s' % location - if location is not None and location.startswith('~'): + if location is not None and not location.startswith("~-,") and location.startswith('~'): geolocation = geolocator(location_canonical_name(location[1:])) if geolocation is not None: if not override_location_name: diff --git a/lib/parse_query.py b/lib/parse_query.py index 0cdf958..6c45e56 100644 --- a/lib/parse_query.py +++ b/lib/parse_query.py @@ -1,3 +1,36 @@ +import re +import json +import zlib +import base64 + +def serialize(parsed_query): + return base64.b64encode( + zlib.compress( + json.dumps(parsed_query).encode("utf-8")), + altchars=b"-_").decode("utf-8") + +def deserialize(url): + + string = url[2:] + + extension = None + if "." in string: + string, extension = string.split(".", 1) + + try: + result = json.loads( + zlib.decompress( + base64.b64decode(string, altchars=b"-_")).decode("utf-8")) + except zlib.error: + return None + + if extension == "png": + result["png_filename"] = url + result["html_output"] = False + + return result + + def metric_or_imperial(query, lang, us_ip=False): """ """ @@ -29,7 +62,6 @@ def parse_query(args): result = {} reserved_args = ["lang"] - #q = "&".join(x for x in args.keys() if x not in reserved_args) q = "" @@ -68,8 +100,6 @@ def parse_query(args): if days in q: result['days'] = days - result['no-caption'] = False - result['no-city'] = False if 'q' in q: result['no-caption'] = True if 'Q' in q: @@ -82,7 +112,61 @@ def parse_query(args): val = True if val == 'False': val = False - result[key] = val + if val: + result[key] = val + + # currently `view` is alias for `format` + if "format" in result and not result.get("view"): + result["view"] = result["format"] + del result["format"] return result +def parse_wttrin_png_name(name): + """ + Parse the PNG filename and return the result as a dictionary. + For example: + input = City_200x_lang=ru.png + output = { + "lang": "ru", + "width": "200", + "filetype": "png", + "location": "City" + } + """ + + parsed = {} + to_be_parsed = {} + + if name.lower()[-4:] == '.png': + parsed['filetype'] = 'png' + name = name[:-4] + + parts = name.split('_') + parsed['location'] = parts[0] + + one_letter_options = "" + for part in parts[1:]: + if re.match('(?:[0-9]+)x', part): + parsed['width'] = part[:-1] + elif re.match('x(?:[0-9]+)', part): + parsed['height'] = part[1:] + elif re.match(part, '(?:[0-9]+)x(?:[0-9]+)'): + parsed['width'], parsed['height'] = part.split('x', 1) + elif '=' in part: + arg, val = part.split('=', 1) + to_be_parsed[arg] = val + else: + one_letter_options += part + + for letter in one_letter_options: + to_be_parsed[letter] = '' + + parsed.update(parse_query(to_be_parsed)) + + # currently `view` is alias for `format` + if "format" in parsed and not parsed.get("view"): + parsed["view"] = parsed["format"] + del parsed["format"] + + return parsed diff --git a/lib/translations.py b/lib/translations.py index 782f3ff..b6eb75b 100644 --- a/lib/translations.py +++ b/lib/translations.py @@ -13,17 +13,17 @@ FULL_TRANSLATION = [ PARTIAL_TRANSLATION = [ "az", "bg", "bs", "cy", "cs", - "eo", "es", "fi", "ga", "hi", "hr", + "eo", "es", "eu", "fi", "ga", "hi", "hr", "hy", "is", "ja", "jv", "ka", "kk", "ko", "ky", "lt", "lv", "mk", "ml", "nl", "fy", "nn", "pt", "pt-br", "sk", "sl", "sr", "sr-lat", - "sv", "sw", "te", "uz", + "sv", "sw", "te", "uz", "zh", "zu", "he", ] PROXY_LANGS = [ "af", "ar", "az", "be", "bs", "ca", - "cy", "el", "eo", "et", "fa", + "cy", "de", "el", "eo", "et", "eu", "fa", "fr", "fy", "he", "hr", "hu", "hy", "ia", "id", "is", "it", "ja", "kk", "lv", "mk", "nb", "nn", "ro", diff --git a/lib/view/__init__.py b/lib/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/wttr_line.py b/lib/view/line.py similarity index 54% rename from lib/wttr_line.py rename to lib/view/line.py index f632936..e701bdf 100644 --- a/lib/wttr_line.py +++ b/lib/view/line.py @@ -17,13 +17,17 @@ import sys import re import datetime import json -try: - from astral import Astral, Location -except ImportError: - pass -from constants import WWO_CODE, WEATHER_SYMBOL, WIND_DIRECTION +import requests + +from astral import LocationInfo +from astral import moon +from astral.sun import sun + +import pytz + +from constants import WWO_CODE, WEATHER_SYMBOL, WIND_DIRECTION, WEATHER_SYMBOL_WIDTH_VTE from weather_data import get_weather_data -import spark +from . import v2 PRECONFIGURED_FORMAT = { '1': u'%c %t', @@ -57,12 +61,13 @@ def render_temperature(data, query): return temperature def render_condition(data, query): - """ - condition (c) + """Emoji encoded weather condition (c) """ weather_condition = WEATHER_SYMBOL[WWO_CODE[data['weatherCode']]] - return weather_condition + spaces = " "*(WEATHER_SYMBOL_WIDTH_VTE.get(weather_condition) - 1) + + return weather_condition + spaces def render_condition_fullname(data, query): """ @@ -140,18 +145,18 @@ def render_wind(data, query): degree = "" if degree: - wind_direction = WIND_DIRECTION[((degree+22)%360)/45] + wind_direction = WIND_DIRECTION[((degree+22)%360)//45] else: wind_direction = "" if query.get('use_ms_for_wind', False): - unit = ' m/s' + unit = 'm/s' wind = u'%s%.1f%s' % (wind_direction, float(data['windspeedKmph'])/36.0*10.0, unit) elif query.get('use_imperial', False): - unit = ' mph' + unit = 'mph' wind = u'%s%s%s' % (wind_direction, data['windspeedMiles'], unit) else: - unit = ' km/h' + unit = 'km/h' wind = u'%s%s%s' % (wind_direction, data['windspeedKmph'], unit) return wind @@ -161,35 +166,58 @@ def render_location(data, query): location (l) """ - return (data['override_location'] or data['location']) # .title() + return (data['override_location'] or data['location']) def render_moonphase(_, query): - """ + """moonpahse(m) A symbol describing the phase of the moon """ - astral = Astral() - moon_index = int( - int(32.0*astral.moon_phase(date=datetime.datetime.today())/28+2)%32/4 - ) + moon_phase = moon.phase(date=datetime.datetime.today()) + moon_index = int(int(32.0*moon_phase/28+2)%32/4) return MOON_PHASES[moon_index] def render_moonday(_, query): - """ + """moonday(M) An number describing the phase of the moon (days after the New Moon) """ - astral = Astral() - return str(int(astral.moon_phase(date=datetime.datetime.today()))) + moon_phase = moon.phase(date=datetime.datetime.today()) + return str(int(moon_phase)) -def render_sunset(data, query): - location = data['location'] - city_name = location - astral = Astral() - location = Location(('Nuremberg', 'Germany', - 49.453872, 11.077298, 'Europe/Berlin', 0)) - sun = location.sun(date=datetime.datetime.today(), local=True) +################################## +# this part should be rewritten +# this is just a temporary solution + +def get_geodata(location): + text = requests.get("http://localhost:8004/%s" % location).text + return json.loads(text) - return str(sun['sunset']) +def render_dawn(data, query, local_time_of): + """dawn (D) + Local time of dawn""" + return local_time_of("dawn") + +def render_dusk(data, query, local_time_of): + """dusk (d) + Local time of dusk""" + return local_time_of("dusk") + +def render_sunrise(data, query, local_time_of): + """sunrise (S) + Local time of sunrise""" + return local_time_of("sunrise") + +def render_sunset(data, query, local_time_of): + """sunset (s) + Local time of sunset""" + return local_time_of("sunset") + +def render_zenith(data, query, local_time_of): + """zenith (z) + Local time of zenith""" + return local_time_of("noon") + +################################## FORMAT_SYMBOL = { 'c': render_condition, @@ -200,17 +228,48 @@ FORMAT_SYMBOL = { 'l': render_location, 'm': render_moonphase, 'M': render_moonday, - 's': render_sunset, 'p': render_precipitation, 'o': render_precipitation_chance, 'P': render_pressure, } +FORMAT_SYMBOL_ASTRO = { + 'D': render_dawn, + 'd': render_dusk, + 'S': render_sunrise, + 's': render_sunset, + 'z': render_zenith, +} + def render_line(line, data, query): """ Render format `line` using `data` """ + def get_local_time_of(): + + location = data["location"] + geo_data = get_geodata(location) + + city = LocationInfo() + city.latitude = geo_data["latitude"] + city.longitude = geo_data["longitude"] + city.timezone = geo_data["timezone"] + + timezone = city.timezone + + local_tz = pytz.timezone(timezone) + + datetime_day_start = datetime.datetime.now()\ + .replace(hour=0, minute=0, second=0, microsecond=0) + current_sun = sun(city.observer, date=datetime_day_start) + + local_time_of = lambda x: current_sun[x]\ + .replace(tzinfo=pytz.utc)\ + .astimezone(local_tz)\ + .strftime("%H:%M:%S") + return local_time_of + def render_symbol(match): """ Render one format symbol from re `match` @@ -220,13 +279,22 @@ def render_line(line, data, query): symbol_string = match.group(0) symbol = symbol_string[-1] - if symbol not in FORMAT_SYMBOL: - return '' + if symbol in FORMAT_SYMBOL: + render_function = FORMAT_SYMBOL[symbol] + return render_function(data, query) + if symbol in FORMAT_SYMBOL_ASTRO and local_time_of is not None: + render_function = FORMAT_SYMBOL_ASTRO[symbol] + return render_function(data, query, local_time_of) - render_function = FORMAT_SYMBOL[symbol] - return render_function(data, query) + return '' - return re.sub(r'%[^%]*[a-zA-Z]', render_symbol, line) + template_regexp = r'%[^%]*[a-zA-Z]' + for template_code in re.findall(template_regexp, line): + if template_code.lstrip("%") in FORMAT_SYMBOL_ASTRO: + local_time_of = get_local_time_of() + break + + return re.sub(template_regexp, render_symbol, line) def render_json(data): output = json.dumps(data, indent=4, sort_keys=True) @@ -237,47 +305,41 @@ def render_json(data): return output -def format_weather_data(format_line, location, override_location, full_address, data, query): +def format_weather_data(query, parsed_query, data): """ Format information about current weather `data` for `location` with specified in `format_line` format """ if 'data' not in data: - return 'Unknown location; please try ~%s' % location + return 'Unknown location; please try ~%s' % parsed_query["location"] + + format_line = parsed_query.get("view", "") + if format_line in PRECONFIGURED_FORMAT: + format_line = PRECONFIGURED_FORMAT[format_line] if format_line == "j1": return render_json(data['data']) if format_line[:2] == "v2": - return spark.main(location, - override_location=override_location, - full_address=full_address, data=data, - view=format_line) + return v2.main(query, parsed_query, data) current_condition = data['data']['current_condition'][0] - current_condition['location'] = location - current_condition['override_location'] = override_location + current_condition['location'] = parsed_query["location"] + current_condition['override_location'] = parsed_query["override_location_name"] output = render_line(format_line, current_condition, query) return output -def wttr_line(location, override_location_name, full_address, query, lang, fmt): +def wttr_line(query, parsed_query): """ Return 1line weather information for `location` in format `line_format` """ + location = parsed_query['location'] + lang = parsed_query['lang'] - format_line = query.get('format', fmt or '') - - if format_line in PRECONFIGURED_FORMAT: - format_line = PRECONFIGURED_FORMAT[format_line] - - weather_data = get_weather_data(location, lang) - - output = format_weather_data( - format_line, location, override_location_name, full_address, - weather_data, query) - output = output.rstrip("\n")+"\n" - return output + data = get_weather_data(location, lang) + output = format_weather_data(query, parsed_query, data) + return output.rstrip("\n")+"\n" def main(): """ @@ -288,8 +350,14 @@ def main(): query = { 'line': sys.argv[2], } + parsed_query = { + "location": location, + "orig_location": location, + "language": "en", + "format": "v2", + } - sys.stdout.write(wttr_line(location, location, None, query, 'en', "v1")) + sys.stdout.write(wttr_line(query, parsed_query)) if __name__ == '__main__': main() diff --git a/lib/view/moon.py b/lib/view/moon.py new file mode 100644 index 0000000..8fc8c18 --- /dev/null +++ b/lib/view/moon.py @@ -0,0 +1,53 @@ +import sys + +import os +import dateutil.parser + +from gevent.subprocess import Popen, PIPE + +sys.path.insert(0, "..") +import constants +import parse_query +import globals + +def get_moon(parsed_query): + + location = parsed_query['orig_location'] + html = parsed_query['html_output'] + lang = parsed_query['lang'] + + date = None + if '@' in location: + date = location[location.index('@')+1:] + location = location[:location.index('@')] + + cmd = [globals.PYPHOON] + if lang: + cmd += ["-l", lang] + + if date: + try: + dateutil.parser.parse(date) + except Exception as e: + print("ERROR: %s" % e) + else: + cmd += [date] + + p = Popen(cmd, stdout=PIPE, stderr=PIPE) + stdout = p.communicate()[0] + stdout = stdout.decode("utf-8") + + if parsed_query.get('no-terminal', False): + stdout = globals.remove_ansi(stdout) + + if html: + p = Popen( + ["bash", globals.ANSI2HTML, "--palette=solarized", "--bg=dark"], + stdin=PIPE, stdout=PIPE, stderr=PIPE) + stdout, stderr = p.communicate(stdout.encode("utf-8")) + stdout = stdout.decode("utf-8") + stderr = stderr.decode("utf-8") + if p.returncode != 0: + globals.error(stdout + stderr) + + return stdout diff --git a/lib/spark.py b/lib/view/v2.py similarity index 71% rename from lib/spark.py rename to lib/view/v2.py index 6a4b9d5..b671c7d 100644 --- a/lib/spark.py +++ b/lib/view/v2.py @@ -26,27 +26,23 @@ import re import math import json import datetime -try: - import StringIO -except: - import io as StringIO +import io import requests import diagram import pyjq import pytz import numpy as np -try: - from astral import Astral, Location -except ImportError: - pass +from astral import LocationInfo +from astral import moon, sun from scipy.interpolate import interp1d from babel.dates import format_datetime from globals import WWO_KEY import constants import translations -import wttr_line +import parse_query +from . import line as wttr_line if not sys.version_info >= (3, 0): reload(sys) # noqa: F821 @@ -74,9 +70,11 @@ def interpolate_data(input_data, max_width): Resample `input_data` to number of `max_width` counts """ - x = list(range(len(input_data))) + input_data = list(input_data) + input_data_len = len(input_data) + x = list(range(input_data_len)) y = input_data - xvals = np.linspace(0, len(input_data)-1, max_width) + xvals = np.linspace(0, input_data_len-1, max_width) yinterp = interp1d(x, y, kind='cubic') return yinterp(xvals) @@ -86,13 +84,16 @@ def jq_query(query, data_parsed): """ pyjq_data = pyjq.all(query, data_parsed) - data = map(float, pyjq_data) + data = list(map(float, pyjq_data)) return data # }}} # utils {{{ -def colorize(string, color_code): - return "\033[%sm%s\033[0m" % (color_code, string) +def colorize(string, color_code, html_output=False): + if html_output: + return "%s" % (string) + else: + return "\033[%sm%s\033[0m" % (color_code, string) # }}} # draw_spark {{{ @@ -140,11 +141,11 @@ def draw_spark(data, height, width, color_data): orig_max_line = max_line # aligning it - if len(max_line)/2 < j and len(max_line)/2 + j < width: - spaces = " "*(j - len(max_line)/2) + if len(max_line)//2 < j and len(max_line)//2 + j < width: + spaces = " "*(j - len(max_line)//2) max_line = spaces + max_line # + spaces max_line = max_line + " "*(width - len(max_line)) - elif len(max_line)/2 + j >= width: + elif len(max_line)//2 + j >= width: max_line = " "*(width - len(max_line)) + max_line max_line = max_line.replace(orig_max_line, colorize(orig_max_line, "38;5;33")) @@ -164,13 +165,13 @@ def draw_diagram(data, height, width): option.size = diagram.Point([width, height]) option.mode = 'g' - stream = StringIO.StringIO() + stream = io.BytesIO() gram = diagram.DGWrapper( data=[list(data), range(len(data))], dg_option=option, ostream=stream) gram.show() - return stream.getvalue() + return stream.getvalue().decode("utf-8") # }}} # draw_date {{{ @@ -189,7 +190,7 @@ def draw_date(config, geo_data): datetime_ = datetime_day_start + datetime.timedelta(hours=24*day) date = format_datetime(datetime_, "EEE dd MMM", locale=locale, tzinfo=tzinfo) - spaces = ((24-len(date))/2)*" " + spaces = ((24-len(date))//2)*" " date = spaces + date + spaces date = " "*(24-len(date)) + date answer += date @@ -241,10 +242,7 @@ def draw_time(geo_data): def draw_astronomical(city_name, geo_data): datetime_day_start = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - a = Astral() - a.solar_depression = 'civil' - - city = Location() + city = LocationInfo() city.latitude = geo_data["latitude"] city.longitude = geo_data["longitude"] city.timezone = geo_data["timezone"] @@ -256,35 +254,54 @@ def draw_astronomical(city_name, geo_data): current_date = ( datetime_day_start + datetime.timedelta(hours=1*time_interval)).replace(tzinfo=pytz.timezone(geo_data["timezone"])) - sun = city.sun(date=current_date, local=False) - dawn = sun['dawn'] # .replace(tzinfo=None) - dusk = sun['dusk'] # .replace(tzinfo=None) - sunrise = sun['sunrise'] # .replace(tzinfo=None) - sunset = sun['sunset'] # .replace(tzinfo=None) + try: + dawn = sun.dawn(city.observer, date=current_date) + except ValueError: + dawn = current_date + try: + dusk = sun.dusk(city.observer, date=current_date) + except ValueError: + dusk = current_date + datetime.timedelta(hours=24) + + try: + sunrise = sun.sunrise(city.observer, date=current_date) + except ValueError: + sunrise = current_date + + try: + sunset = sun.sunset(city.observer, date=current_date) + except ValueError: + sunset = current_date + datetime.timedelta(hours=24) + + char = "." if current_date < dawn: char = " " elif current_date > dusk: char = " " - elif dawn < current_date and current_date < sunrise: + elif dawn <= current_date and current_date <= sunrise: char = u"─" - elif sunset < current_date and current_date < dusk: + elif sunset <= current_date and current_date <= dusk: char = u"─" - elif sunrise < current_date and current_date < sunset: + elif sunrise <= current_date and current_date <= sunset: char = u"━" answer += char # moon - if time_interval % 3 == 0: - moon_phase = city.moon_phase( + if time_interval in [0,23,47,69]: # time_interval % 3 == 0: + moon_phase = moon.phase( date=datetime_day_start + datetime.timedelta(hours=time_interval)) - moon_phase_emoji = constants.MOON_PHASES[int(math.floor(moon_phase*1.0/28.0*8+0.5)) % len(constants.MOON_PHASES)] - if time_interval in [0, 24, 48, 69]: - moon_line += moon_phase_emoji + " " - else: + moon_phase_emoji = constants.MOON_PHASES[ + int(math.floor(moon_phase*1.0/28.0*8+0.5)) % len(constants.MOON_PHASES)] + # if time_interval in [0, 24, 48, 69]: + moon_line += moon_phase_emoji # + " " + elif time_interval % 3 == 0: + if time_interval not in [24,28]: #se: moon_line += " " + else: + moon_line += " " answer = moon_line + "\n" + answer + "\n" @@ -333,7 +350,7 @@ def draw_wind(data, color_data): degree = int(degree) if degree: - wind_direction = constants.WIND_DIRECTION[((degree+22)%360)/45] + wind_direction = constants.WIND_DIRECTION[((degree+22)%360)//45] else: wind_direction = "" @@ -364,9 +381,9 @@ def add_frame(output, width, config): output = "\n".join(u"│"+(x or empty_line)+u"│" for x in output.splitlines()) + "\n" weather_report = \ - translations.CAPTION[config["lang"]] \ + translations.CAPTION[config.get("lang") or "en"] \ + " " \ - + (config["override_location"] or config["location"]) + + (config["override_location_name"] or config["location"]) caption = u"┤ " + " " + weather_report + " " + u" ├" output = u"┌" + caption + u"─"*(width-len(caption)) + u"┐\n" \ @@ -429,7 +446,7 @@ def generate_panel(data_parsed, geo_data, config): # }}} # textual information {{{ -def textual_information(data_parsed, geo_data, config): +def textual_information(data_parsed, geo_data, config, html_output=False): """ Add textual information about current weather and astronomical conditions @@ -452,9 +469,11 @@ def textual_information(data_parsed, geo_data, config): output += "," + word return output - - city = Location() + def _colorize(text, color): + return colorize(text, color, html_output=html_output) + + city = LocationInfo() city.latitude = geo_data["latitude"] city.longitude = geo_data["longitude"] city.timezone = geo_data["timezone"] @@ -464,7 +483,6 @@ def textual_information(data_parsed, geo_data, config): datetime_day_start = datetime.datetime.now()\ .replace(hour=0, minute=0, second=0, microsecond=0) - sun = city.sun(date=datetime_day_start, local=True) format_line = "%c %C, %t, %h, %w, %P" current_condition = data_parsed['data']['current_condition'][0] @@ -474,22 +492,41 @@ def textual_information(data_parsed, geo_data, config): output.append('Timezone: %s' % timezone) - tmp_output = [] - tmp_output.append(' Now: %%{{NOW(%s)}}' % timezone) - tmp_output.append('Dawn: %s' - % str(sun['dawn'].strftime("%H:%M:%S"))) - tmp_output.append('Sunrise: %s' - % str(sun['sunrise'].strftime("%H:%M:%S"))) - tmp_output.append(' Zenith: %s' - % str(sun['noon'].strftime("%H:%M:%S "))) - tmp_output.append('Sunset: %s' - % str(sun['sunset'].strftime("%H:%M:%S"))) - tmp_output.append('Dusk: %s' - % str(sun['dusk'].strftime("%H:%M:%S"))) + local_tz = pytz.timezone(timezone) + + def _get_local_time_of(what): + _sun = { + "dawn": sun.dawn, + "sunrise": sun.sunrise, + "noon": sun.noon, + "sunset": sun.sunset, + "dusk": sun.dusk, + }[what] + + current_time_of_what = _sun(city.observer, date=datetime_day_start) + return current_time_of_what\ + .replace(tzinfo=pytz.utc)\ + .astimezone(local_tz)\ + .strftime("%H:%M:%S") + + local_time_of = {} + for what in ["dawn", "sunrise", "noon", "sunset", "dusk"]: + try: + local_time_of[what] = _get_local_time_of(what) + except ValueError: + local_time_of[what] = "-"*8 + + tmp_output = [] + + tmp_output.append(' Now: %%{{NOW(%s)}}' % timezone) + tmp_output.append('Dawn: %s' % local_time_of["dawn"]) + tmp_output.append('Sunrise: %s' % local_time_of["sunrise"]) + tmp_output.append(' Zenith: %s ' % local_time_of["noon"]) + tmp_output.append('Sunset: %s' % local_time_of["sunset"]) + tmp_output.append('Dusk: %s' % local_time_of["dusk"]) - color_code = "38;5;246" tmp_output = [ - re.sub("^([A-Za-z]*:)", lambda m: colorize(m.group(1), color_code), x) + re.sub("^([A-Za-z]*:)", lambda m: _colorize(m.group(1), "2"), x) for x in tmp_output] output.append( @@ -517,9 +554,9 @@ def textual_information(data_parsed, geo_data, config): )) output = [ - re.sub("^( *[A-Za-z]*:)", lambda m: colorize(m.group(1), color_code), - re.sub("^( +[A-Za-z]*:)", lambda m: colorize(m.group(1), color_code), - re.sub(r"(\|)", lambda m: colorize(m.group(1), color_code), x))) + re.sub("^( *[A-Za-z]*:)", lambda m: _colorize(m.group(1), "2"), + re.sub("^( +[A-Za-z]*:)", lambda m: _colorize(m.group(1), "2"), + re.sub(r"(\|)", lambda m: _colorize(m.group(1), "2"), x))) for x in output] return "".join("%s\n" % x for x in output) @@ -531,24 +568,42 @@ def get_geodata(location): return json.loads(text) # }}} -def main(location, override_location=None, data=None, full_address=None, view=None): - config = { - "lang": "en", - "locale": "en_US", - "location": location, - "override_location": override_location, - "full_address": full_address, - "view": view, - } +def main(query, parsed_query, data): + parsed_query["locale"] = "en_US" + + location = parsed_query["location"] + html_output = parsed_query["html_output"] geo_data = get_geodata(location) if data is None: - data_parsed = get_data(config) + data_parsed = get_data(parsed_query) else: data_parsed = data - output = generate_panel(data_parsed, geo_data, config) - output += textual_information(data_parsed, geo_data, config) + if html_output: + parsed_query["text"] = "no" + filename = "b_" + parse_query.serialize(parsed_query) + ".png" + output = """ + + +Weather report for {orig_location} + + + + +
+{textual_information}
+
+ + +""".format( + filename=filename, orig_location=parsed_query["orig_location"], + textual_information=textual_information( + data_parsed, geo_data, parsed_query, html_output=True)) + else: + output = generate_panel(data_parsed, geo_data, parsed_query) + if query.get("text") != "no" and parsed_query.get("text") != "no": + output += textual_information(data_parsed, geo_data, parsed_query) return output if __name__ == '__main__': diff --git a/lib/view/wttr.py b/lib/view/wttr.py new file mode 100644 index 0000000..a3874f3 --- /dev/null +++ b/lib/view/wttr.py @@ -0,0 +1,166 @@ +# vim: set encoding=utf-8 +# pylint: disable=wrong-import-position + +""" +Main view (wttr.in) implementation. +The module is a wrapper for the modified Wego program. +""" + +import sys +import re + +from gevent.subprocess import Popen, PIPE + +sys.path.insert(0, "..") +from translations import get_message, SUPPORTED_LANGS +from globals import WEGO, NOT_FOUND_LOCATION, DEFAULT_LOCATION, ANSI2HTML, \ + error, remove_ansi + + +def get_wetter(parsed_query): + + location = parsed_query['location'] + html = parsed_query['html_output'] + lang = parsed_query['lang'] + + location_not_found = False + if location == NOT_FOUND_LOCATION: + location_not_found = True + + stderr = "" + returncode = 0 + if not location_not_found: + stdout, stderr, returncode = _wego_wrapper(location, parsed_query) + + if location_not_found or \ + (returncode != 0 \ + and ('Unable to find any matching weather' + ' location to the parsed_query submitted') in stderr): + stdout, stderr, returncode = _wego_wrapper(NOT_FOUND_LOCATION, parsed_query) + location_not_found = True + stdout += get_message('NOT_FOUND_MESSAGE', lang) + + first_line, stdout = _wego_postprocessing(location, parsed_query, stdout) + + + if html: + return _htmlize(stdout, first_line, parsed_query) + return stdout + +def _wego_wrapper(location, parsed_query): + + lang = parsed_query['lang'] + location_name = parsed_query['override_location_name'] + + cmd = [WEGO, '--city=%s' % location] + + if parsed_query.get('inverted_colors'): + cmd += ['-inverse'] + + if parsed_query.get('use_ms_for_wind'): + cmd += ['-wind_in_ms'] + + if parsed_query.get('narrow'): + cmd += ['-narrow'] + + if lang and lang in SUPPORTED_LANGS: + cmd += ['-lang=%s'%lang] + + if parsed_query.get('use_imperial', False): + cmd += ['-imperial'] + + if location_name: + cmd += ['-location_name', location_name] + + proc = Popen(cmd, stdout=PIPE, stderr=PIPE) + stdout, stderr = proc.communicate() + stdout = stdout.decode("utf-8") + stderr = stderr.decode("utf-8") + + return stdout, stderr, proc.returncode + +def _wego_postprocessing(location, parsed_query, stdout): + full_address = parsed_query['full_address'] + lang = parsed_query['lang'] + + if 'days' in parsed_query: + if parsed_query['days'] == '0': + stdout = "\n".join(stdout.splitlines()[:7]) + "\n" + if parsed_query['days'] == '1': + stdout = "\n".join(stdout.splitlines()[:17]) + "\n" + if parsed_query['days'] == '2': + stdout = "\n".join(stdout.splitlines()[:27]) + "\n" + + + first = stdout.splitlines()[0] + rest = stdout.splitlines()[1:] + if parsed_query.get('no-caption', False): + if ':' in first: + first = first.split(":", 1)[1] + stdout = "\n".join([first.strip()] + rest) + "\n" + + if parsed_query.get('no-terminal', False): + stdout = remove_ansi(stdout) + + if parsed_query.get('no-city', False): + stdout = "\n".join(stdout.splitlines()[2:]) + "\n" + + if full_address \ + and parsed_query.get('format', 'txt') != 'png' \ + and (not parsed_query.get('no-city') + and not parsed_query.get('no-caption') + and not parsed_query.get('days') == '0'): + line = "%s: %s [%s]\n" % ( + get_message('LOCATION', lang), + full_address, + location) + stdout += line + + if parsed_query.get('padding', False): + lines = [x.rstrip() for x in stdout.splitlines()] + max_l = max(len(remove_ansi(x)) for x in lines) + last_line = " "*max_l + " .\n" + stdout = " \n" + "\n".join(" %s " %x for x in lines) + "\n" + last_line + + return first, stdout + +def _htmlize(ansi_output, title, parsed_query): + """Return HTML representation of `ansi_output`. + Use `title` as the title of the page. + Format page according to query parameters from `parsed_query`.""" + + cmd = ["bash", ANSI2HTML, "--palette=solarized"] + if not parsed_query.get('inverted_colors'): + cmd += ["--bg=dark"] + + proc = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) + stdout, stderr = proc.communicate(ansi_output.encode("utf-8")) + stdout = stdout.decode("utf-8") + stderr = stderr.decode("utf-8") + if proc.returncode != 0: + error(stdout + stderr) + + if parsed_query.get('inverted_colors'): + stdout = stdout.replace( + '', '') + + title = "%s" % title + opengraph = _get_opengraph(parsed_query) + stdout = re.sub("", "" + title + opengraph, stdout) + return stdout + +def _get_opengraph(parsed_query): + """Return OpenGraph data for `parsed_query`""" + + url = parsed_query['request_url'] or "" + pic_url = url.replace('?', '_') + + return ( + '' + '' + '' + '' + ) % { + 'pic_url': pic_url, + 'url': url, + } diff --git a/lib/wttr.py b/lib/wttr.py deleted file mode 100644 index b1724cd..0000000 --- a/lib/wttr.py +++ /dev/null @@ -1,247 +0,0 @@ -# vim: set encoding=utf-8 - -from __future__ import print_function -import gevent -from gevent.pywsgi import WSGIServer -from gevent.queue import Queue -from gevent.monkey import patch_all -from gevent.subprocess import Popen, PIPE, STDOUT -patch_all() - -import os -import re -import time -import dateutil.parser - -from translations import get_message, FULL_TRANSLATION, PARTIAL_TRANSLATION, SUPPORTED_LANGS -from globals import WEGO, PYPHOON, CACHEDIR, ANSI2HTML, \ - NOT_FOUND_LOCATION, DEFAULT_LOCATION, TEST_FILE, \ - log, error - -def _is_invalid_location(location): - if '.png' in location: - return True - -def remove_ansi(sometext): - ansi_escape = re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]') - return ansi_escape.sub('', sometext) - -def get_wetter(location, ip, html=False, lang=None, query=None, location_name=None, full_address=None, url=None): - - local_url = url - local_location = location - - def get_opengraph(): - - if local_url is None: - url = "" - else: - url = local_url.encode('utf-8') - - if local_location is None: - location = "" - else: - location = local_location.encode('utf-8') - - pic_url = url.replace('?', '_') - - return ( - '' - '' - '' - '' - ) % { - 'pic_url': pic_url, - 'url': url, - 'location': location, - } - - # '' - # '' - - def get_filename(location, lang=None, query=None, location_name=None): - location = location.replace('/', '_') - timestamp = time.strftime( "%Y%m%d%H", time.localtime() ) - - imperial_suffix = '' - if query.get('use_imperial', False): - imperial_suffix = '-imperial' - - lang_suffix = '' - if lang is not None: - lang_suffix = '-lang_%s' % lang - - if query != None: - query_line = "_" + "_".join("%s=%s" % (key, value) for (key, value) in query.items()) - else: - query_line = "" - - if location_name is None: - location_name = "" - return "%s/%s/%s%s%s%s%s" % (CACHEDIR, location, timestamp, imperial_suffix, lang_suffix, query_line, location_name) - - def save_weather_data(location, filename, lang=None, query=None, location_name=None, full_address=None): - - if _is_invalid_location( location ): - error("Invalid location: %s" % location) - - NOT_FOUND_MESSAGE_HEADER = "" - while True: - location_not_found = False - if location in [ "test-thunder" ]: - test_name = location[5:] - test_file = TEST_FILE.replace('NAME', test_name) - stdout = open(test_file, 'r').read() - stderr = "" - break - if location == NOT_FOUND_LOCATION: - location_not_found = True - location = DEFAULT_LOCATION - - cmd = [WEGO, '--city=%s' % location] - - if query.get('inverted_colors'): - cmd += ['-inverse'] - - if query.get('use_ms_for_wind'): - cmd += ['-wind_in_ms'] - - if query.get('narrow'): - cmd += ['-narrow'] - - if lang and lang in SUPPORTED_LANGS: - cmd += ['-lang=%s'%lang] - - if query.get('use_imperial', False): - cmd += ['-imperial'] - - if location_name: - cmd += ['-location_name', location_name] - - p = Popen(cmd, stdout=PIPE, stderr=PIPE) - stdout, stderr = p.communicate() - if p.returncode != 0: - print("ERROR: location not found: %s" % location) - if 'Unable to find any matching weather location to the query submitted' in stderr: - if location != NOT_FOUND_LOCATION: - NOT_FOUND_MESSAGE_HEADER = u"ERROR: %s: %s\n---\n\n" % (get_message('UNKNOWN_LOCATION', lang), location) - location = NOT_FOUND_LOCATION - continue - error(stdout + stderr) - break - - dirname = os.path.dirname(filename) - if not os.path.exists(dirname): - os.makedirs(dirname) - - if location_not_found: - stdout += get_message('NOT_FOUND_MESSAGE', lang).encode('utf-8') - stdout = NOT_FOUND_MESSAGE_HEADER.encode('utf-8') + stdout - - if 'days' in query: - if query['days'] == '0': - stdout = "\n".join(stdout.splitlines()[:7]) + "\n" - if query['days'] == '1': - stdout = "\n".join(stdout.splitlines()[:17]) + "\n" - if query['days'] == '2': - stdout = "\n".join(stdout.splitlines()[:27]) + "\n" - - first = stdout.splitlines()[0].decode('utf-8') - rest = stdout.splitlines()[1:] - if query.get('no-caption', False): - - separator = None - if ':' in first: - separator = ':' - if u':' in first: - separator = u':' - - if separator: - first = first.split(separator,1)[1] - stdout = "\n".join([first.strip().encode('utf-8')] + rest) + "\n" - - if query.get('no-terminal', False): - stdout = remove_ansi(stdout) - - if query.get('no-city', False): - stdout = "\n".join(stdout.splitlines()[2:]) + "\n" - - if full_address \ - and query.get('format', 'txt') != 'png' \ - and (not query.get('no-city') - and not query.get('no-caption') - and not query.get('days') == '0'): - line = "%s: %s [%s]\n" % ( - get_message('LOCATION', lang).encode('utf-8'), - full_address.encode('utf-8'), - location.encode('utf-8')) - stdout += line - - if query.get('padding', False): - lines = [x.rstrip() for x in stdout.splitlines()] - max_l = max(len(remove_ansi(x).decode('utf8')) for x in lines) - last_line = " "*max_l + " .\n" - stdout = " \n" + "\n".join(" %s " %x for x in lines) + "\n" + last_line - - open(filename, 'w').write(stdout) - - cmd = ["bash", ANSI2HTML, "--palette=solarized"] - if not query.get('inverted_colors'): - cmd += ["--bg=dark"] - - p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE ) - stdout, stderr = p.communicate(stdout) - if p.returncode != 0: - error(stdout + stderr) - - if query.get('inverted_colors'): - stdout = stdout.replace('', '') - - title = "%s" % first.encode('utf-8') - opengraph = get_opengraph() - stdout = re.sub("", "" + title + opengraph, stdout) - open(filename+'.html', 'w').write(stdout) - - filename = get_filename(location, lang=lang, query=query, location_name=location_name) - if not os.path.exists(filename): - save_weather_data(location, filename, lang=lang, query=query, location_name=location_name, full_address=full_address) - if html: - filename += '.html' - - return open(filename).read() - -def get_moon(location, html=False, lang=None, query=None): - if query is None: - query = {} - - date = None - if '@' in location: - date = location[location.index('@')+1:] - location = location[:location.index('@')] - - cmd = [PYPHOON] - if date: - try: - dateutil.parser.parse(date) - except Exception as e: - print("ERROR: %s" % e) - else: - cmd += [date] - - env = os.environ.copy() - if lang: - env['LANG'] = lang - p = Popen(cmd, stdout=PIPE, stderr=PIPE, env=env) - stdout = p.communicate()[0] - - if query.get('no-terminal', False): - stdout = remove_ansi(stdout) - - if html: - p = Popen(["bash", ANSI2HTML, "--palette=solarized", "--bg=dark"], stdin=PIPE, stdout=PIPE, stderr=PIPE) - stdout, stderr = p.communicate(stdout) - if p.returncode != 0: - error(stdout + stderr) - - return stdout - diff --git a/lib/wttr_srv.py b/lib/wttr_srv.py index 632855e..ca42f90 100644 --- a/lib/wttr_srv.py +++ b/lib/wttr_srv.py @@ -6,15 +6,18 @@ Main wttr.in rendering function implementation """ import logging +import io import os import time +from gevent.threadpool import ThreadPool from flask import render_template, send_file, make_response -import wttrin_png +import fmt.png + import parse_query from translations import get_message, FULL_TRANSLATION, PARTIAL_TRANSLATION, SUPPORTED_LANGS from buttons import add_buttons -from globals import get_help_file, log, \ +from globals import get_help_file, \ BASH_FUNCTION_FILE, TRANSLATION_FILE, LOG_FILE, \ NOT_FOUND_LOCATION, \ MALFORMED_RESPONSE_HTML_PAGE, \ @@ -22,17 +25,20 @@ from globals import get_help_file, log, \ MY_EXTERNAL_IP, QUERY_LIMITS from location import is_location_blocked, location_processing from limits import Limits -from wttr import get_wetter, get_moon -from wttr_line import wttr_line +from view.wttr import get_wetter +from view.moon import get_moon +from view.line import wttr_line import cache if not os.path.exists(os.path.dirname(LOG_FILE)): os.makedirs(os.path.dirname(LOG_FILE)) -logging.basicConfig(filename=LOG_FILE, level=logging.DEBUG, format='%(asctime)s %(message)s') +logging.basicConfig(filename=LOG_FILE, level=logging.INFO, format='%(asctime)s %(message)s') LIMITS = Limits(whitelist=[MY_EXTERNAL_IP], limits=QUERY_LIMITS) +TASKS = ThreadPool(25) + def show_text_file(name, lang): """ show static file `name` for `lang` @@ -51,12 +57,10 @@ def show_text_file(name, lang): text = text\ .replace('NUMBER_OF_LANGUAGES', str(len(SUPPORTED_LANGS)))\ .replace('SUPPORTED_LANGUAGES', ' '.join(SUPPORTED_LANGS)) - return text.decode('utf-8') + return text -def client_ip_address(request): - """ - Return client ip address for `request`. - Flask related +def _client_ip_address(request): + """Return client ip address for flask `request`. """ if request.headers.getlist("X-PNG-Query-For"): @@ -112,55 +116,56 @@ def _parse_language_header(header): elif lang == 'en': yield None, lang_tuple[1] try: - return max(supported_langs(), key=lambda lang_tuple:lang_tuple[1])[0] + return max(supported_langs(), key=lambda lang_tuple: lang_tuple[1])[0] except ValueError: return None return _find_supported_language(_parse_accept_language(header)) -def get_answer_language_and_format(request): +def get_answer_language_and_view(request): """ Return preferred answer language based on domain name, query arguments and headers """ lang = None - fmt = None + view_name = None hostname = request.headers['Host'] if hostname != 'wttr.in' and hostname.endswith('.wttr.in'): lang = hostname[:-8] - if lang == "v2": - fmt = "v2" + if lang.startswith("v2"): + view_name = lang lang = None if 'lang' in request.args: lang = request.args.get('lang') + if lang.lower() == 'none': + lang = None header_accept_language = request.headers.get('Accept-Language', '') if lang is None and header_accept_language: lang = _parse_language_header(header_accept_language) - return lang, fmt + return lang, view_name -def get_output_format(request, query): +def get_output_format(query, parsed_query): """ Return preferred output format: ansi, text, html or png based on arguments and headers in `request`. Return new location (can be rewritten) """ - if 'format' in query: - return False - # FIXME - user_agent = request.headers.get('User-Agent', '').lower() - if query.get('force-ansi'): + if ('view' in query and not query["view"].startswith("v2")) \ + or parsed_query.get("png_filename") \ + or query.get('force-ansi'): return False + + user_agent = parsed_query.get("user_agent", "").lower() html_output = not any(agent in user_agent for agent in PLAIN_TEXT_AGENTS) return html_output -def cyclic_location_selection(locations, period): - """ - Return one of `locations` (: separated list) +def _cyclic_location_selection(locations, period): + """Return one of `locations` (: separated list) basing on the current time and query interval `period` """ @@ -173,144 +178,202 @@ def cyclic_location_selection(locations, period): except ValueError: period = 1 - index = int(time.time())/period % len(locations) + index = int(time.time()/period) % len(locations) return locations[index] -def wttr(location, request): +def _response(parsed_query, query, fast_mode=False): + """Create response text based on `parsed_query` and `query` data. + If `fast_mode` is True, process only requests that can + be handled very fast (cached and static files). """ - Main rendering function, it processes incoming weather queries. - Depending on user agent it returns output in HTML or ANSI format. + + answer = None + cache_signature = cache.get_signature( + parsed_query["user_agent"], + parsed_query["request_url"], + parsed_query["ip_addr"], + parsed_query["lang"]) + answer = cache.get(cache_signature) + + if parsed_query['orig_location'] in PLAIN_TEXT_PAGES: + answer = show_text_file(parsed_query['orig_location'], parsed_query['lang']) + if parsed_query['html_output']: + answer = render_template('index.html', body=answer) + + if answer or fast_mode: + return answer + + # at this point, we could not handle the query fast, + # so we handle it with all available logic + loc = (parsed_query['orig_location'] or "").lower() + if parsed_query.get("view"): + output = wttr_line(query, parsed_query) + elif loc == 'moon' or loc.startswith('moon@'): + output = get_moon(parsed_query) + else: + output = get_wetter(parsed_query) + + if parsed_query.get('png_filename'): + # originally it was just a usual function call, + # but it was a blocking call, so it was moved + # to separate threads: + # + # output = fmt.png.render_ansi( + # output, options=parsed_query) + result = TASKS.spawn(fmt.png.render_ansi, output, options=parsed_query) + output = result.get() + else: + if query.get('days', '3') != '0' \ + and not query.get('no-follow-line') \ + and ((parsed_query.get("view") or "v2")[:2] in ["v2"]): + if parsed_query['html_output']: + output = add_buttons(output) + else: + output += '\n' + get_message('FOLLOW_ME', parsed_query['lang']) + '\n' + + return cache.store(cache_signature, output) + +def parse_request(location, request, query, fast_mode=False): + """Parse request and provided extended information for the query, + including location data, language, output format, view, etc. Incoming data: - request.args - request.headers - request.remote_addr - request.referrer - request.query_string + + `location` location name extracted from the query url + `request.args` + `request.headers` + `request.remote_addr` + `request.referrer` + `request.query_string` + `query` parsed command line arguments + + Parameters priorities (from low to high): + + * HTTP-header + * Domain name + * URL + * Filename + + Return: dictionary with parsed parameters """ - def _wrap_response(response_text, html_output): - response = make_response(response_text) - response.mimetype = 'text/html' if html_output else 'text/plain' - return response - - if is_location_blocked(location): - return "" - - ip_addr = client_ip_address(request) - - try: - LIMITS.check_ip(ip_addr) - except RuntimeError as exception: - return str(exception) + if location and location.startswith("b_"): + result = parse_query.deserialize(location) + result["request_url"] = request.url + if result: + return result png_filename = None if location is not None and location.lower().endswith(".png"): png_filename = location location = location[:-4] + if location and ':' in location and location[0] != ":": + location = _cyclic_location_selection(location, query.get('period', 1)) - lang, fmt = get_answer_language_and_format(request) - query = parse_query.parse_query(request.args) - html_output = get_output_format(request, query) - user_agent = request.headers.get('User-Agent', '').lower() + parsed_query = { + 'ip_addr': _client_ip_address(request), + 'user_agent': request.headers.get('User-Agent', '').lower(), + 'request_url': request.url, + } - # generating cache signature - cache_signature = cache.get_signature(user_agent, request.url, ip_addr, lang) - answer = cache.get(cache_signature) - if answer: - return _wrap_response(answer, html_output) + if png_filename: + parsed_query["png_filename"] = png_filename + parsed_query.update(parse_query.parse_wttrin_png_name(png_filename)) - if location in PLAIN_TEXT_PAGES: - help_ = show_text_file(location, lang) - if html_output: - return _wrap_response(render_template('index.html', body=help_), html_output) - return _wrap_response(help_, html_output) + lang, _view = get_answer_language_and_view(request) - if location and ':' in location: - location = cyclic_location_selection(location, query.get('period', 1)) + parsed_query["view"] = parsed_query.get("view", query.get("view", _view)) + parsed_query["location"] = parsed_query.get("location", location) + parsed_query["orig_location"] = parsed_query["location"] + parsed_query["lang"] = parsed_query.get("lang", lang) - orig_location = location + parsed_query["html_output"] = get_output_format(query, parsed_query) - if not png_filename: + if not fast_mode: # not png_filename and not fast_mode: location, override_location_name, full_address, country, query_source_location = \ - location_processing(location, ip_addr) + location_processing(parsed_query["location"], parsed_query["ip_addr"]) - us_ip = query_source_location[1] == 'United States' and 'slack' not in user_agent + us_ip = query_source_location[1] == 'United States' \ + and 'slack' not in parsed_query['user_agent'] query = parse_query.metric_or_imperial(query, lang, us_ip=us_ip) - # logging query - orig_location_utf8 = (orig_location or "").encode('utf-8') - location_utf8 = location.encode('utf-8') - use_imperial = query.get('use_imperial', False) - log(" ".join(map(str, - [ip_addr, user_agent, orig_location_utf8, location_utf8, use_imperial, lang]))) - if country and location != NOT_FOUND_LOCATION: location = "%s,%s" % (location, country) - # We are ready to return the answer - try: - if fmt or 'format' in query: - response_text = wttr_line( - location, override_location_name, full_address, query, lang, fmt) - fmt = fmt or query.get('format') - response_text = cache.store(cache_signature, response_text) + parsed_query.update({ + 'location': location, + 'override_location_name': override_location_name, + 'full_address': full_address, + 'country': country, + 'query_source_location': query_source_location}) - return _wrap_response( - response_text, - html_output) + parsed_query.update(query) + return parsed_query + + +def wttr(location, request): + """Main rendering function, it processes incoming weather queries, + and depending on the User-Agent string and other paramters of the query + it returns output in HTML, ANSI or other format. + """ + + def _wrap_response(response_text, html_output, png_filename=None): + if not isinstance(response_text, str) and \ + not isinstance(response_text, bytes): + return response_text if png_filename: - options = { - 'ip_addr': ip_addr, - 'lang': lang, - 'location': location} - options.update(query) + response = make_response(send_file( + io.BytesIO(response_text), + attachment_filename=png_filename, + mimetype='image/png')) - cached_png_file = wttrin_png.make_wttr_in_png(png_filename, options=options) - response = make_response(send_file(cached_png_file, - attachment_filename=png_filename, - mimetype='image/png')) for key, value in { 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache', 'Expires': '0', }.items(): response.headers[key] = value - - # Trying to disable github caching - return response - - if orig_location and (orig_location.lower() == 'moon' or orig_location.lower().startswith('moon@')): - output = get_moon(orig_location, html=html_output, lang=lang, query=query) else: - output = get_wetter(location, ip_addr, - html=html_output, - lang=lang, - query=query, - location_name=override_location_name, - full_address=full_address, - url=request.url, - ) + response = make_response(response_text) + response.mimetype = 'text/html' if html_output else 'text/plain' + return response - if query.get('days', '3') != '0' and not query.get('no-follow-line'): - if html_output: - output = add_buttons(output) - else: - #output += '\n' + get_message('NEW_FEATURE', lang).encode('utf-8') - output += '\n' + get_message('FOLLOW_ME', lang).encode('utf-8') + '\n' + if is_location_blocked(location): + return "" - return _wrap_response(output, html_output) + try: + LIMITS.check_ip(_client_ip_address(request)) + except RuntimeError as exception: + return str(exception) + query = parse_query.parse_query(request.args) + + # first, we try to process the query as fast as possible + # (using the cache and static files), + # and only if "fast_mode" was unsuccessful, + # use the full track + parsed_query = parse_request(location, request, query, fast_mode=True) + response = _response(parsed_query, query, fast_mode=True) + try: + if not response: + parsed_query = parse_request(location, request, query) + response = _response(parsed_query, query) + # pylint: disable=broad-except except Exception as exception: - # if 'Malformed response' in str(exception) \ - # or 'API key has reached calls per day allowed limit' in str(exception): - if html_output: - return _wrap_response(MALFORMED_RESPONSE_HTML_PAGE, html_output) - return _wrap_response(get_message('CAPACITY_LIMIT_REACHED', lang).encode('utf-8'), html_output) - # logging.error("Exception has occured", exc_info=1) - # return "ERROR" + logging.error("Exception has occured", exc_info=1) + if parsed_query['html_output']: + response = MALFORMED_RESPONSE_HTML_PAGE + else: + response = get_message('CAPACITY_LIMIT_REACHED', parsed_query['lang']) + + # if exception is occured, we return not a png file but text + if "png_filename" in parsed_query: + del parsed_query["png_filename"] + return _wrap_response( + response, parsed_query['html_output'], + png_filename=parsed_query.get('png_filename')) if __name__ == "__main__": import doctest diff --git a/lib/wttrin_png.py b/lib/wttrin_png.py deleted file mode 100644 index 005a4a0..0000000 --- a/lib/wttrin_png.py +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/python -#vim: encoding=utf-8 - -from __future__ import print_function -import sys -import os -import re -import time - -""" -This module is used to generate png-files for wttr.in queries. -The only exported function is: - - make_wttr_in_png(filename) - -in filename (in the shortname) is coded the weather query. -The function saves the weather report in the file and returns None. -""" - -import requests - -from PIL import Image, ImageFont, ImageDraw -import pyte.screens - -# downloaded from https://gist.github.com/2204527 -# described/recommended here: -# -# http://stackoverflow.com/questions/9868792/find-out-the-unicode-script-of-a-character -# -import unicodedata2 - -MYDIR = os.path.abspath(os.path.dirname(os.path.dirname('__file__'))) -sys.path.append("%s/lib/" % MYDIR) -import parse_query - -from globals import PNG_CACHE, log - -COLS = 180 -ROWS = 100 - -CHAR_WIDTH = 7 -CHAR_HEIGHT = 14 - -# How to find font for non-standard scripts: -# -# $ fc-list :lang=ja - -FONT_SIZE = 12 -FONT_CAT = { - 'default': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - 'Cyrillic': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - 'Greek': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - 'Arabic': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - 'Hebrew': "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", - 'Han': "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", - 'Hiragana': "/usr/share/fonts/truetype/motoya-l-cedar/MTLc3m.ttf", - 'Katakana': "/usr/share/fonts/truetype/motoya-l-cedar/MTLc3m.ttf", - 'Hangul': "/usr/share/fonts/truetype/lexi/LexiGulim.ttf", -} - -def color_mapping(color): - """ - Convert pyte color to PIL color - """ - if color == 'default': - return 'lightgray' - if color in ['green', 'black', 'cyan', 'blue', 'brown']: - return color - try: - return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)) - except: - # if we do not know this color and it can not be decoded as RGB, - # print it and return it as it is (will be displayed as black) - # print color - return color - return color - -def strip_buf(buf): - """ - Strips empty spaces from behind and from the right side. - (from the right side is not yet implemented) - """ - def empty_line(line): - "Returns True if the line consists from spaces" - return all(x.data == ' ' for x in line) - - def line_len(line): - "Returns len of the line excluding spaces from the right" - - last_pos = len(line) - while last_pos > 0 and line[last_pos-1].data == ' ': - last_pos -= 1 - return last_pos - - number_of_lines = 0 - for line in buf[::-1]: - if not empty_line(line): - break - number_of_lines += 1 - - if number_of_lines: - buf = buf[:-number_of_lines] - - max_len = max(line_len(x) for x in buf) - buf = [line[:max_len] for line in buf] - - return buf - -def script_category(char): - """ - Returns category of a Unicode character - Possible values: - default, Cyrillic, Greek, Han, Hiragana - """ - cat = unicodedata2.script_cat(char)[0] - if char == u':': - return 'Han' - if cat in ['Latin', 'Common']: - return 'default' - else: - return cat - -def gen_term(filename, buf, options=None): - buf = strip_buf(buf) - cols = max(len(x) for x in buf) - rows = len(buf) - - image = Image.new('RGB', (cols * CHAR_WIDTH, rows * CHAR_HEIGHT)) - - buf = buf[-ROWS:] - - draw = ImageDraw.Draw(image) - font = {} - for cat in FONT_CAT: - font[cat] = ImageFont.truetype(FONT_CAT[cat], FONT_SIZE) - - x_pos = 0 - y_pos = 0 - for line in buf: - x_pos = 0 - for char in line: - current_color = color_mapping(char.fg) - if char.bg != 'default': - draw.rectangle( - ((x_pos, y_pos), - (x_pos+CHAR_WIDTH, y_pos+CHAR_HEIGHT)), - fill=color_mapping(char.bg)) - - if char.data: - cat = script_category(char.data) - if cat not in font: - log("Unknown font category: %s" % cat) - draw.text( - (x_pos, y_pos), - char.data, - font=font.get(cat, font.get('default')), - fill=current_color) - #sys.stdout.write(c.data) - - x_pos += CHAR_WIDTH - y_pos += CHAR_HEIGHT - #sys.stdout.write('\n') - - if 'transparency' in options: - transparency = options.get('transparency', '255') - try: - transparency = int(transparency) - except: - transparceny = 255 - - if transparency < 0: - transparency = 0 - - if transparency > 255: - transparency = 255 - - image = image.convert("RGBA") - datas = image.getdata() - - new_data = [] - for item in datas: - new_item = tuple(list(item[:3]) + [transparency]) - new_data.append(new_item) - - image.putdata(new_data) - - - image.save(filename) - -def typescript_to_one_frame(png_file, text, options=None): - """ - Render text (terminal sequence) in png_file - """ - - # fixing some broken characters because of bug #... in pyte 6.0 - text = text.replace('Н', 'H').replace('Ν', 'N') - - screen = pyte.screens.Screen(COLS, ROWS) - #screen.define_charset("B", "(") - - stream = pyte.streams.ByteStream() - stream.attach(screen) - - stream.feed(text) - - buf = sorted(screen.buffer.items(), key=lambda x: x[0]) - buf = [[x[1] for x in sorted(line[1].items(), key=lambda x: x[0])] for line in buf] - - gen_term(png_file, buf, options=options) - -# -# wttr.in related functions -# - -def parse_wttrin_png_name(name): - """ - Parse the PNG filename and return the result as a dictionary. - For example: - input = City_200x_lang=ru.png - output = { - "lang": "ru", - "width": "200", - "filetype": "png", - "location": "City" - } - """ - - parsed = {} - to_be_parsed = {} - - if name.lower()[-4:] == '.png': - parsed['filetype'] = 'png' - name = name[:-4] - - parts = name.split('_') - parsed['location'] = parts[0] - - for part in parts[1:]: - if re.match('(?:[0-9]+)x', part): - parsed['width'] = part[:-1] - elif re.match('x(?:[0-9]+)', part): - parsed['height'] = part[1:] - elif re.match(part, '(?:[0-9]+)x(?:[0-9]+)'): - parsed['width'], parsed['height'] = part.split('x', 1) - elif '=' in part: - arg, val = part.split('=', 1) - to_be_parsed[arg] = val - else: - to_be_parsed[part] = '' - - parsed.update(parse_query.parse_query(to_be_parsed)) - - return parsed - -def make_wttrin_query(parsed): - """ - Convert parsed data into query name - """ - - for key in ['width', 'height', 'filetype']: - if key in parsed: - del parsed[key] - - location = parsed['location'] - del parsed['location'] - - args = [] - if 'options' in parsed: - args = [parsed['options']] - del parsed['options'] - else: - args = [] - - for key, val in parsed.items(): - args.append('%s=%s' % (key, val)) - - args.append('filetype=png') - - url = "http://wttr.in/%s" % location - if args != []: - url += "?%s" % ("&".join(args)) - - return url - - -def make_wttr_in_png(png_name, options=None): - """ - The function saves the weather report in the file and returns None. - The weather query is coded in filename (in the shortname). - """ - - parsed = parse_wttrin_png_name(png_name) - - # if location is MyLocation it should be overriden - # with autodetected location (from options) - if parsed.get('location', 'MyLocation') == 'MyLocation' or not parsed.get('location', ''): - del parsed['location'] - - if options is not None: - for key, val in options.items(): - if key not in parsed: - parsed[key] = val - url = make_wttrin_query(parsed) - - timestamp = time.strftime("%Y%m%d%H", time.localtime()) - cached_basename = url[14:].replace('/','_') - - cached_png_file = "%s/%s/%s.png" % (PNG_CACHE, timestamp, cached_basename) - - dirname = os.path.dirname(cached_png_file) - if not os.path.exists(dirname): - os.makedirs(dirname) - - if os.path.exists(cached_png_file): - return cached_png_file - - headers = {'X-PNG-Query-For': options.get('ip_addr', '1.1.1.1')} - text = requests.get(url, headers=headers).text.replace('\n', '\r\n') - curl_output = text.encode('utf-8') - - typescript_to_one_frame(cached_png_file, curl_output, options=parsed) - - return cached_png_file diff --git a/requirements.txt b/requirements.txt index 69744ec..1a0bf96 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,14 +8,17 @@ pylint cyrtranslit astral timezonefinder==2.1.2 -Pillow pyte python-dateutil diagram pyjq scipy +numpy +pillow babel pylru pysocks supervisor numba +emoji +grapheme diff --git a/share/emoji/☀️.png b/share/emoji/☀️.png new file mode 100644 index 0000000..183386f Binary files /dev/null and b/share/emoji/☀️.png differ diff --git a/share/emoji/☁️.png b/share/emoji/☁️.png new file mode 100644 index 0000000..5660627 Binary files /dev/null and b/share/emoji/☁️.png differ diff --git a/share/emoji/⛅️.png b/share/emoji/⛅️.png new file mode 100644 index 0000000..72b417d Binary files /dev/null and b/share/emoji/⛅️.png differ diff --git a/share/emoji/⛈.png b/share/emoji/⛈.png new file mode 100644 index 0000000..ddacbf1 Binary files /dev/null and b/share/emoji/⛈.png differ diff --git a/share/emoji/✨.png b/share/emoji/✨.png new file mode 100644 index 0000000..f6419fa Binary files /dev/null and b/share/emoji/✨.png differ diff --git a/share/emoji/❄️.png b/share/emoji/❄️.png new file mode 100644 index 0000000..d4a3f9c Binary files /dev/null and b/share/emoji/❄️.png differ diff --git a/share/emoji/🌑.png b/share/emoji/🌑.png new file mode 100644 index 0000000..1821059 Binary files /dev/null and b/share/emoji/🌑.png differ diff --git a/share/emoji/🌒.png b/share/emoji/🌒.png new file mode 100644 index 0000000..e7aa0ae Binary files /dev/null and b/share/emoji/🌒.png differ diff --git a/share/emoji/🌓.png b/share/emoji/🌓.png new file mode 100644 index 0000000..84e0a21 Binary files /dev/null and b/share/emoji/🌓.png differ diff --git a/share/emoji/🌔.png b/share/emoji/🌔.png new file mode 100644 index 0000000..ebdfd35 Binary files /dev/null and b/share/emoji/🌔.png differ diff --git a/share/emoji/🌕.png b/share/emoji/🌕.png new file mode 100644 index 0000000..975682f Binary files /dev/null and b/share/emoji/🌕.png differ diff --git a/share/emoji/🌖.png b/share/emoji/🌖.png new file mode 100644 index 0000000..b5c9791 Binary files /dev/null and b/share/emoji/🌖.png differ diff --git a/share/emoji/🌗.png b/share/emoji/🌗.png new file mode 100644 index 0000000..2358e7c Binary files /dev/null and b/share/emoji/🌗.png differ diff --git a/share/emoji/🌘.png b/share/emoji/🌘.png new file mode 100644 index 0000000..325eb50 Binary files /dev/null and b/share/emoji/🌘.png differ diff --git a/share/emoji/🌦.png b/share/emoji/🌦.png new file mode 100644 index 0000000..b5063f9 Binary files /dev/null and b/share/emoji/🌦.png differ diff --git a/share/emoji/🌧.png b/share/emoji/🌧.png new file mode 100644 index 0000000..b4352b7 Binary files /dev/null and b/share/emoji/🌧.png differ diff --git a/share/emoji/🌨.png b/share/emoji/🌨.png new file mode 100644 index 0000000..c1d1e1f Binary files /dev/null and b/share/emoji/🌨.png differ diff --git a/share/emoji/🌩.png b/share/emoji/🌩.png new file mode 100644 index 0000000..bb84231 Binary files /dev/null and b/share/emoji/🌩.png differ diff --git a/share/emoji/🌫.png b/share/emoji/🌫.png new file mode 100644 index 0000000..5a15aa4 Binary files /dev/null and b/share/emoji/🌫.png differ diff --git a/share/static/example-tmux-status-line.png b/share/static/example-tmux-status-line.png new file mode 100644 index 0000000..2f755f6 Binary files /dev/null and b/share/static/example-tmux-status-line.png differ diff --git a/share/translation.txt b/share/translation.txt index 0f6d163..9d11a02 100644 --- a/share/translation.txt +++ b/share/translation.txt @@ -10,6 +10,7 @@ Translated/improved/corrected by: Grigor Khachatryan @grigortw * Azerbaijani: Dmytro Nikitiuk, Elsevar Abbasov, Eldar Velibekov (@welibekov on github) + * Basque: Iker Sagasti (@isagasti on github) * Belarusian: Igor Chubin, Anton Zhavoronkov @edogby (on github) * Bosnian: Ismar Kunc @ismarkunc * Bulgarian: Vladimir Vitkov @zeridon (on github) diff --git a/share/we-lang/we-lang.go b/share/we-lang/we-lang.go index c872458..a26aa14 100644 --- a/share/we-lang/we-lang.go +++ b/share/we-lang/we-lang.go @@ -303,6 +303,7 @@ var ( "da": "da_DK", "de": "de_DE", "el": "el_GR", + "eu": "eu_ES", "eo": "eo", "es": "es_ES", "et": "et_EE", @@ -423,7 +424,7 @@ var ( daytimeTranslation = map[string][]string{ "af":{"Oggend","Middag", "Vroegaand", "Laatnag"}, - "ar":{"صباح", "ظهر", "مساء", "ليل" }, + "ar":{ "ﺎﻠﻠﻴﻟ", "ﺎﻠﻤﺳﺍﺀ", "ﺎﻠﻈﻫﺭ", "ﺎﻠﺼﺑﺎﺣ" }, "az":{"Səhər", "Gün", "Axşam", "Gecə" }, "be":{"Раніца", "Дзень", "Вечар", "Ноч" }, "bg":{"Сутрин", "Обяд", "Вечер", "Нощ" }, @@ -915,6 +916,9 @@ func printDay(w weather) (ret []string) { if config.Lang == "ko" { dateName = lctime.Strftime("%b %d일 %a", d) } + if config.Lang == "zh" || config.Lang == "zh-tw" || config.Lang == "zh-cn" { + dateName = lctime.Strftime("%b%d日%A", d) + } } // appendSide := 0 // // for utf8.RuneCountInString(dateName) <= dateWidth { diff --git a/test/proxy-data/data1 b/test/proxy-data/data1 new file mode 100644 index 0000000..8e1f651 --- /dev/null +++ b/test/proxy-data/data1 @@ -0,0 +1 @@ +{"data":{"request":[{"type":"LatLon","query":"Lat 27.64 and Lon -80.40"}],"nearest_area":[{"areaName":[{"value":"Vero Beach"}],"country":[{"value":"United States of America"}],"region":[{"value":"Florida"}],"latitude":"27.638","longitude":"-80.398","population":"17262","weatherUrl":[{"value":"https://www.worldweatheronline.com/v2/weather.aspx?q=27.6387163,-80.3975399"}]}],"current_condition":[{"observation_time":"10:24 AM","localObsDateTime":"2020-04-26 06:24 AM","temp_C":"22","temp_F":"72","weatherCode":"143","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0006_mist.png"}],"weatherDesc":[{"value":"Mist"}],"windspeedMiles":"4","windspeedKmph":"6","winddirDegree":"240","winddir16Point":"WSW","precipMM":"0.4","humidity":"94","visibility":"10","pressure":"1015","cloudcover":"75","FeelsLikeC":"25","FeelsLikeF":"76","uvIndex":1}],"weather":[{"date":"2020-04-26","astronomy":[{"sunrise":"06:46 AM","sunset":"07:53 PM","moonrise":"09:12 AM","moonset":"11:17 PM","moon_phase":"Waxing Crescent","moon_illumination":"22"}],"maxtempC":"30","maxtempF":"87","mintempC":"23","mintempF":"73","totalSnow_cm":"0.0","sunHour":"6.5","uvIndex":"9","hourly":[{"time":"0","tempC":"25","tempF":"77","windspeedMiles":"7","windspeedKmph":"12","winddirDegree":"235","winddir16Point":"SW","weatherCode":"176","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0025_light_rain_showers_night.png"}],"weatherDesc":[{"value":"Patchy rain possible"}],"precipMM":"0.4","humidity":"85","visibility":"9","pressure":"1015","cloudcover":"86","HeatIndexC":"28","HeatIndexF":"82","DewPointC":"22","DewPointF":"72","WindChillC":"25","WindChillF":"77","WindGustMiles":"14","WindGustKmph":"22","FeelsLikeC":"28","FeelsLikeF":"82","chanceofrain":"76","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"89","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"71","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"300","tempC":"24","tempF":"76","windspeedMiles":"7","windspeedKmph":"12","winddirDegree":"242","winddir16Point":"WSW","weatherCode":"386","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0032_thundery_showers_night.png"}],"weatherDesc":[{"value":"Patchy light rain with thunder"}],"precipMM":"0.4","humidity":"88","visibility":"9","pressure":"1015","cloudcover":"81","HeatIndexC":"27","HeatIndexF":"80","DewPointC":"22","DewPointF":"72","WindChillC":"24","WindChillF":"76","WindGustMiles":"13","WindGustKmph":"21","FeelsLikeC":"27","FeelsLikeF":"80","chanceofrain":"70","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"87","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"13","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"600","tempC":"24","tempF":"75","windspeedMiles":"7","windspeedKmph":"12","winddirDegree":"246","winddir16Point":"WSW","weatherCode":"386","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0032_thundery_showers_night.png"}],"weatherDesc":[{"value":"Patchy light rain with thunder"}],"precipMM":"0.6","humidity":"89","visibility":"10","pressure":"1015","cloudcover":"74","HeatIndexC":"26","HeatIndexF":"79","DewPointC":"22","DewPointF":"72","WindChillC":"24","WindChillF":"75","WindGustMiles":"12","WindGustKmph":"20","FeelsLikeC":"26","FeelsLikeF":"79","chanceofrain":"77","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"87","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"12","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"900","tempC":"26","tempF":"78","windspeedMiles":"7","windspeedKmph":"12","winddirDegree":"246","winddir16Point":"WSW","weatherCode":"386","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0016_thundery_showers.png"}],"weatherDesc":[{"value":"Patchy light rain with thunder"}],"precipMM":"1.1","humidity":"83","visibility":"10","pressure":"1017","cloudcover":"80","HeatIndexC":"28","HeatIndexF":"83","DewPointC":"22","DewPointF":"72","WindChillC":"26","WindChillF":"78","WindGustMiles":"11","WindGustKmph":"17","FeelsLikeC":"28","FeelsLikeF":"83","chanceofrain":"73","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"88","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"42","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"9"},{"time":"1200","tempC":"29","tempF":"84","windspeedMiles":"7","windspeedKmph":"12","winddirDegree":"247","winddir16Point":"WSW","weatherCode":"353","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0009_light_rain_showers.png"}],"weatherDesc":[{"value":"Light rain shower"}],"precipMM":"0.7","humidity":"68","visibility":"10","pressure":"1017","cloudcover":"76","HeatIndexC":"32","HeatIndexF":"90","DewPointC":"22","DewPointF":"72","WindChillC":"29","WindChillF":"84","WindGustMiles":"9","WindGustKmph":"15","FeelsLikeC":"32","FeelsLikeF":"90","chanceofrain":"74","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"85","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"95","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"31","uvIndex":"9"},{"time":"1500","tempC":"30","tempF":"86","windspeedMiles":"7","windspeedKmph":"11","winddirDegree":"259","winddir16Point":"WSW","weatherCode":"386","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0016_thundery_showers.png"}],"weatherDesc":[{"value":"Patchy light rain with thunder"}],"precipMM":"1.0","humidity":"63","visibility":"10","pressure":"1016","cloudcover":"62","HeatIndexC":"33","HeatIndexF":"92","DewPointC":"22","DewPointF":"72","WindChillC":"30","WindChillF":"86","WindGustMiles":"9","WindGustKmph":"14","FeelsLikeC":"33","FeelsLikeF":"92","chanceofrain":"76","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"87","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"96","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"87","uvIndex":"9"},{"time":"1800","tempC":"28","tempF":"82","windspeedMiles":"6","windspeedKmph":"9","winddirDegree":"259","winddir16Point":"WSW","weatherCode":"386","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0016_thundery_showers.png"}],"weatherDesc":[{"value":"Patchy light rain with thunder"}],"precipMM":"1.5","humidity":"70","visibility":"10","pressure":"1015","cloudcover":"72","HeatIndexC":"31","HeatIndexF":"88","DewPointC":"22","DewPointF":"71","WindChillC":"28","WindChillF":"82","WindGustMiles":"9","WindGustKmph":"15","FeelsLikeC":"31","FeelsLikeF":"88","chanceofrain":"72","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"86","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"93","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"48","uvIndex":"9"},{"time":"2100","tempC":"25","tempF":"77","windspeedMiles":"7","windspeedKmph":"11","winddirDegree":"243","winddir16Point":"WSW","weatherCode":"176","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0025_light_rain_showers_night.png"}],"weatherDesc":[{"value":"Patchy rain possible"}],"precipMM":"0.2","humidity":"74","visibility":"10","pressure":"1016","cloudcover":"75","HeatIndexC":"27","HeatIndexF":"81","DewPointC":"20","DewPointF":"68","WindChillC":"25","WindChillF":"77","WindGustMiles":"13","WindGustKmph":"21","FeelsLikeC":"27","FeelsLikeF":"81","chanceofrain":"81","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"90","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"61","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"}]},{"date":"2020-04-27","astronomy":[{"sunrise":"06:45 AM","sunset":"07:54 PM","moonrise":"09:58 AM","moonset":"No moonset","moon_phase":"Waxing Crescent","moon_illumination":"30"}],"maxtempC":"24","maxtempF":"76","mintempC":"19","mintempF":"66","totalSnow_cm":"0.0","sunHour":"11.6","uvIndex":"11","hourly":[{"time":"0","tempC":"22","tempF":"71","windspeedMiles":"11","windspeedKmph":"17","winddirDegree":"304","winddir16Point":"NW","weatherCode":"176","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0025_light_rain_showers_night.png"}],"weatherDesc":[{"value":"Patchy rain possible"}],"precipMM":"0.1","humidity":"67","visibility":"10","pressure":"1018","cloudcover":"72","HeatIndexC":"23","HeatIndexF":"74","DewPointC":"15","DewPointF":"60","WindChillC":"22","WindChillF":"71","WindGustMiles":"19","WindGustKmph":"31","FeelsLikeC":"22","FeelsLikeF":"71","chanceofrain":"77","chanceofremdry":"0","chanceofwindy":"0","chanceofovercast":"92","chanceofsunshine":"0","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"300","tempC":"20","tempF":"67","windspeedMiles":"13","windspeedKmph":"20","winddirDegree":"323","winddir16Point":"NW","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0008_clear_sky_night.png"}],"weatherDesc":[{"value":"Clear"}],"precipMM":"0.1","humidity":"59","visibility":"10","pressure":"1017","cloudcover":"49","HeatIndexC":"20","HeatIndexF":"67","DewPointC":"12","DewPointF":"53","WindChillC":"20","WindChillF":"67","WindGustMiles":"22","WindGustKmph":"36","FeelsLikeC":"20","FeelsLikeF":"67","chanceofrain":"51","chanceofremdry":"30","chanceofwindy":"0","chanceofovercast":"63","chanceofsunshine":"29","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"600","tempC":"19","tempF":"67","windspeedMiles":"15","windspeedKmph":"24","winddirDegree":"340","winddir16Point":"NNW","weatherCode":"116","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0004_black_low_cloud.png"}],"weatherDesc":[{"value":"Partly cloudy"}],"precipMM":"0.0","humidity":"54","visibility":"10","pressure":"1018","cloudcover":"1","HeatIndexC":"19","HeatIndexF":"67","DewPointC":"10","DewPointF":"49","WindChillC":"19","WindChillF":"67","WindGustMiles":"24","WindGustKmph":"39","FeelsLikeC":"19","FeelsLikeF":"67","chanceofrain":"0","chanceofremdry":"88","chanceofwindy":"0","chanceofovercast":"13","chanceofsunshine":"83","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"900","tempC":"20","tempF":"68","windspeedMiles":"14","windspeedKmph":"22","winddirDegree":"339","winddir16Point":"NNW","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png"}],"weatherDesc":[{"value":"Sunny"}],"precipMM":"0.0","humidity":"51","visibility":"10","pressure":"1020","cloudcover":"1","HeatIndexC":"21","HeatIndexF":"69","DewPointC":"9","DewPointF":"49","WindChillC":"20","WindChillF":"68","WindGustMiles":"19","WindGustKmph":"31","FeelsLikeC":"20","FeelsLikeF":"68","chanceofrain":"0","chanceofremdry":"87","chanceofwindy":"0","chanceofovercast":"26","chanceofsunshine":"80","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"11"},{"time":"1200","tempC":"23","tempF":"73","windspeedMiles":"13","windspeedKmph":"21","winddirDegree":"230","winddir16Point":"SW","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png"}],"weatherDesc":[{"value":"Sunny"}],"precipMM":"0.0","humidity":"43","visibility":"10","pressure":"1020","cloudcover":"0","HeatIndexC":"24","HeatIndexF":"76","DewPointC":"9","DewPointF":"49","WindChillC":"23","WindChillF":"73","WindGustMiles":"15","WindGustKmph":"24","FeelsLikeC":"24","FeelsLikeF":"76","chanceofrain":"0","chanceofremdry":"88","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"88","chanceoffrost":"0","chanceofhightemp":"5","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"11"},{"time":"1500","tempC":"24","tempF":"74","windspeedMiles":"12","windspeedKmph":"20","winddirDegree":"12","winddir16Point":"NNE","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png"}],"weatherDesc":[{"value":"Sunny"}],"precipMM":"0.0","humidity":"41","visibility":"10","pressure":"1019","cloudcover":"0","HeatIndexC":"25","HeatIndexF":"76","DewPointC":"10","DewPointF":"49","WindChillC":"24","WindChillF":"74","WindGustMiles":"14","WindGustKmph":"23","FeelsLikeC":"25","FeelsLikeF":"76","chanceofrain":"0","chanceofremdry":"83","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"86","chanceoffrost":"0","chanceofhightemp":"11","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"11"},{"time":"1800","tempC":"22","tempF":"71","windspeedMiles":"13","windspeedKmph":"21","winddirDegree":"33","winddir16Point":"NNE","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0001_sunny.png"}],"weatherDesc":[{"value":"Sunny"}],"precipMM":"0.0","humidity":"53","visibility":"10","pressure":"1019","cloudcover":"0","HeatIndexC":"23","HeatIndexF":"74","DewPointC":"12","DewPointF":"53","WindChillC":"22","WindChillF":"71","WindGustMiles":"16","WindGustKmph":"26","FeelsLikeC":"22","FeelsLikeF":"71","chanceofrain":"0","chanceofremdry":"86","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"86","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"11"},{"time":"2100","tempC":"20","tempF":"68","windspeedMiles":"11","windspeedKmph":"17","winddirDegree":"38","winddir16Point":"NE","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0008_clear_sky_night.png"}],"weatherDesc":[{"value":"Clear"}],"precipMM":"0.0","humidity":"60","visibility":"10","pressure":"1020","cloudcover":"0","HeatIndexC":"20","HeatIndexF":"68","DewPointC":"12","DewPointF":"54","WindChillC":"20","WindChillF":"68","WindGustMiles":"18","WindGustKmph":"29","FeelsLikeC":"20","FeelsLikeF":"68","chanceofrain":"0","chanceofremdry":"81","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"88","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"}]},{"date":"2020-04-28","astronomy":[{"sunrise":"06:44 AM","sunset":"07:54 PM","moonrise":"10:50 AM","moonset":"12:13 AM","moon_phase":"Waxing Crescent","moon_illumination":"37"}],"maxtempC":"25","maxtempF":"76","mintempC":"19","mintempF":"67","totalSnow_cm":"0.0","sunHour":"10.3","uvIndex":"9","hourly":[{"time":"0","tempC":"20","tempF":"68","windspeedMiles":"10","windspeedKmph":"16","winddirDegree":"34","winddir16Point":"NNE","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0008_clear_sky_night.png"}],"weatherDesc":[{"value":"Clear"}],"precipMM":"0.0","humidity":"61","visibility":"10","pressure":"1021","cloudcover":"0","HeatIndexC":"20","HeatIndexF":"68","DewPointC":"12","DewPointF":"54","WindChillC":"20","WindChillF":"68","WindGustMiles":"18","WindGustKmph":"28","FeelsLikeC":"20","FeelsLikeF":"68","chanceofrain":"0","chanceofremdry":"82","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"89","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"300","tempC":"20","tempF":"67","windspeedMiles":"9","windspeedKmph":"15","winddirDegree":"35","winddir16Point":"NE","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0008_clear_sky_night.png"}],"weatherDesc":[{"value":"Clear"}],"precipMM":"0.0","humidity":"61","visibility":"10","pressure":"1020","cloudcover":"0","HeatIndexC":"20","HeatIndexF":"67","DewPointC":"12","DewPointF":"53","WindChillC":"20","WindChillF":"67","WindGustMiles":"17","WindGustKmph":"27","FeelsLikeC":"20","FeelsLikeF":"67","chanceofrain":"0","chanceofremdry":"85","chanceofwindy":"0","chanceofovercast":"0","chanceofsunshine":"89","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"600","tempC":"20","tempF":"68","windspeedMiles":"9","windspeedKmph":"14","winddirDegree":"53","winddir16Point":"NE","weatherCode":"116","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0004_black_low_cloud.png"}],"weatherDesc":[{"value":"Partly cloudy"}],"precipMM":"0.0","humidity":"62","visibility":"10","pressure":"1020","cloudcover":"5","HeatIndexC":"20","HeatIndexF":"68","DewPointC":"13","DewPointF":"55","WindChillC":"20","WindChillF":"68","WindGustMiles":"15","WindGustKmph":"25","FeelsLikeC":"20","FeelsLikeF":"68","chanceofrain":"0","chanceofremdry":"86","chanceofwindy":"0","chanceofovercast":"13","chanceofsunshine":"84","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"},{"time":"900","tempC":"22","tempF":"71","windspeedMiles":"10","windspeedKmph":"16","winddirDegree":"69","winddir16Point":"ENE","weatherCode":"116","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png"}],"weatherDesc":[{"value":"Partly cloudy"}],"precipMM":"0.0","humidity":"60","visibility":"10","pressure":"1021","cloudcover":"15","HeatIndexC":"22","HeatIndexF":"72","DewPointC":"14","DewPointF":"57","WindChillC":"22","WindChillF":"71","WindGustMiles":"14","WindGustKmph":"22","FeelsLikeC":"22","FeelsLikeF":"71","chanceofrain":"0","chanceofremdry":"87","chanceofwindy":"0","chanceofovercast":"43","chanceofsunshine":"74","chanceoffrost":"0","chanceofhightemp":"3","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"9"},{"time":"1200","tempC":"24","tempF":"75","windspeedMiles":"12","windspeedKmph":"19","winddirDegree":"77","winddir16Point":"ENE","weatherCode":"119","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0003_white_cloud.png"}],"weatherDesc":[{"value":"Cloudy"}],"precipMM":"0.0","humidity":"53","visibility":"10","pressure":"1022","cloudcover":"37","HeatIndexC":"25","HeatIndexF":"78","DewPointC":"14","DewPointF":"57","WindChillC":"24","WindChillF":"75","WindGustMiles":"14","WindGustKmph":"22","FeelsLikeC":"25","FeelsLikeF":"78","chanceofrain":"0","chanceofremdry":"86","chanceofwindy":"0","chanceofovercast":"63","chanceofsunshine":"56","chanceoffrost":"0","chanceofhightemp":"12","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"9"},{"time":"1500","tempC":"25","tempF":"76","windspeedMiles":"11","windspeedKmph":"18","winddirDegree":"83","winddir16Point":"E","weatherCode":"116","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png"}],"weatherDesc":[{"value":"Partly cloudy"}],"precipMM":"0.0","humidity":"52","visibility":"10","pressure":"1020","cloudcover":"65","HeatIndexC":"26","HeatIndexF":"78","DewPointC":"14","DewPointF":"57","WindChillC":"25","WindChillF":"76","WindGustMiles":"13","WindGustKmph":"21","FeelsLikeC":"26","FeelsLikeF":"78","chanceofrain":"0","chanceofremdry":"86","chanceofwindy":"0","chanceofovercast":"72","chanceofsunshine":"35","chanceoffrost":"0","chanceofhightemp":"15","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"9"},{"time":"1800","tempC":"23","tempF":"74","windspeedMiles":"9","windspeedKmph":"15","winddirDegree":"88","winddir16Point":"E","weatherCode":"116","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png"}],"weatherDesc":[{"value":"Partly cloudy"}],"precipMM":"0.0","humidity":"56","visibility":"10","pressure":"1019","cloudcover":"16","HeatIndexC":"25","HeatIndexF":"77","DewPointC":"14","DewPointF":"57","WindChillC":"23","WindChillF":"74","WindGustMiles":"13","WindGustKmph":"20","FeelsLikeC":"25","FeelsLikeF":"77","chanceofrain":"0","chanceofremdry":"91","chanceofwindy":"0","chanceofovercast":"35","chanceofsunshine":"82","chanceoffrost":"0","chanceofhightemp":"10","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"9"},{"time":"2100","tempC":"22","tempF":"71","windspeedMiles":"8","windspeedKmph":"13","winddirDegree":"90","winddir16Point":"E","weatherCode":"113","weatherIconUrl":[{"value":"http://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0008_clear_sky_night.png"}],"weatherDesc":[{"value":"Clear"}],"precipMM":"0.0","humidity":"61","visibility":"10","pressure":"1020","cloudcover":"6","HeatIndexC":"25","HeatIndexF":"76","DewPointC":"14","DewPointF":"57","WindChillC":"22","WindChillF":"71","WindGustMiles":"14","WindGustKmph":"23","FeelsLikeC":"22","FeelsLikeF":"71","chanceofrain":"0","chanceofremdry":"89","chanceofwindy":"0","chanceofovercast":"28","chanceofsunshine":"84","chanceoffrost":"0","chanceofhightemp":"0","chanceoffog":"0","chanceofsnow":"0","chanceofthunder":"0","uvIndex":"1"}]}]}} \ No newline at end of file diff --git a/test/proxy-data/data1.headers b/test/proxy-data/data1.headers new file mode 100644 index 0000000..9b1ac5b --- /dev/null +++ b/test/proxy-data/data1.headers @@ -0,0 +1 @@ +{"Content-Type": "application/json"} \ No newline at end of file diff --git a/test/query.sh b/test/query.sh new file mode 100644 index 0000000..d49d5f4 --- /dev/null +++ b/test/query.sh @@ -0,0 +1,71 @@ +queries=( + / + /Kiev + /Kiev.png + /?T + /Киев + /Kiev?2 + "/Kiev?format=1" + "/Kiev?format=2" + "/Kiev?format=3" + "/Kiev?format=4" + "/Kiev?format=v2" + "/Kiev?format=%s" + "/Kiev?format=%S" + "/Kiev?format=%D+%S+%z+%s+%d" + "/:help" + "/Kiev?T" + "/Kiev?p" + "/Kiev?q" + "/Kiev?Q" + "/Kiev_text=no_view=v2.png" + "/Kiev.png?1nqF" + "/Kiev_1nqF.png" +) + +options=$(cat < test-data/signatures +fi + +result_tmp=$(mktemp wttrin-test-XXXXX) + +while read -r -a args +do + for q in "${queries[@]}"; do + signature=$(echo "${args[@]}" "$q" | sha1sum | awk '{print $1}') + curl -ks "${args[@]}" "$server$q" > "$result_tmp" + + result=$(sha1sum "$result_tmp" | awk '{print $1}') + + # this must be moved to the server + # but for the moment we just clean up + # the cache after each call + rm -rf "/wttr.in/cache" + + if grep -Eq "(we are running out of queries|500 Internal Server Error)" "$result_tmp"; then + echo "$q" + fi + + if [[ $UPDATE = yes ]]; then + printf "%s %s %s\\n" "$signature" "$result" "${args[*]} $q" >> test-data/signatures + elif ! grep -q "$signature $result" test-data/signatures; then + echo "FAILED: curl -ks ${args[*]} $server$q" + fi + done +done <<< "${options}" + +rm "$result_tmp" diff --git a/test/test-data/signatures b/test/test-data/signatures new file mode 100644 index 0000000..d14dff1 --- /dev/null +++ b/test/test-data/signatures @@ -0,0 +1,88 @@ +8f27084b6294ddbe28dbcbf98f798730e8a79289 4ee2dd9cf8f5818902647ff832ef40d690096bf1 / +ae537911bb7b0568f478073e661abee1cb4ff941 d123e570da22dee9798d353c4281cb5a2bdbaeac /Kiev +4dc586807c16020b9f4dbb705326c698bea41665 a186d89e95061a7887c005ffa8bd1e29362de2da /Kiev.png +3db1938bedc0ee0047bf3b043ddaf0aba1912f13 febab92af9526163bc9e502ecd7fa4225345e6f6 /?T +2cc0ba7a57a6342e72fd7142ca18dbb0eae69416 ce7fb7a88cab697f5280ddabf344f0d397888956 /Киев +928142e88da142ea8075cbfe09bfef349e72dbb1 0f86f59a45b4485fea1375ca945503d9abb9a96d /Kiev?2 +4f6f0a16ff415fad1c102c8023c5d8365ef63402 de3b9821d587753149eded5411ec397e7a2000e2 /Kiev?format=1 +c99903b86971ccccfcca4f13e6fca72776b4fbcf f64086e48d84ac6eb440ca080eff28de1470ec30 /Kiev?format=2 +2a0d6cd8d30a84328580611ca6dd6bed1d805a04 da7b79cdff330edc1a7a78fcf2f9c8bf7e432d40 /Kiev?format=3 +4e4e15eeb6a8b6ddb5d00591c4b5e9b74a13e6bc 6a61305ea631fbccb98090c62a96898e8ffa0d75 /Kiev?format=4 +a27d3e4ad7f820124ef57c9299715bc61cb71387 fed12e63dd5fb5e348ee30d94c7231112eca71cd /Kiev?format=v2 +cbe9fc56091b519e6aebcedd9a7541241f4c4cda 2d97c405f1557b822cc86b038aeea40c3eb79d7d /Kiev?format=%s +84b79ec29670254c3570901e4c5db017516e088d 3f26bdbbd5faf7729f87a9cb964d407493e9a0d6 /Kiev?format=%S +8a0111eb7a519adad1210661bbb49f960ba7f95f fe58cbd420cf36a910551a1037f5c4fa19b31074 /Kiev?format=%D+%S+%z+%s+%d +83cc0ef08c24ad7ecc81d1a6cbd693bb06214ece 0ea998c1b53e452a373699ab953ab00e8a2870f0 /:help +310b64f65fc9f66a5142bf6104f4f9b9d5eef0ea b0bd07f0c87aae9464c091ccb955f41ec6973098 /Kiev?T +9bd1b460d4927df24724f45f69bd3132f3de8e04 d001bf6ab36b6c14f98f02fc4500706d7a9f05a8 /Kiev?p +3ee1a25d436799804d7ebd8371d8022fa55a71d7 80f18be012d0471dce9fcd2b500f482bcd635347 /Kiev?q +e0e8e7eca16bfac88503ac6d19a7a6c8b469c0fd d5d070c98237f0dffc82b176039f90a15f03a667 /Kiev?Q +d08d1fa2546fee0717d1eb663cf63cd1505b8885 e380ef1a22f62a7fa1133d0e35d923c8587cb3ed /Kiev_text=no_view=v2.png +b14e89e2c183139495bad5404748b3b6173063d7 ff70b13244929fa3a934e94f99f019c66501ad43 /Kiev.png?1nqF +04e1a945bdde39d646e397e55c096ec46b53a92b ff70b13244929fa3a934e94f99f019c66501ad43 /Kiev_1nqF.png +3e1be80e942a2ea5450c60e1c0ebfb154aca3da1 6a5bdefe64689f4d05128bd62a8118f4f2f52043 -A firefox / +ee6bf0665c2719cda3ec1fbdb80413d821c99b8e 3f9c5091269ece259cce13fc842265019001ed54 -A firefox /Kiev +98ef11678b7fd33425f97eeee70e00cd96206539 a186d89e95061a7887c005ffa8bd1e29362de2da -A firefox /Kiev.png +ecbcf2cb9004a754c4559ce7e92fead68f71721a 2ea6d52a2108a481cbc0f44a881eb88642d68e80 -A firefox /?T +74206d869128383dba2d840b848b90eb376fd851 7c6ce53ff25d91a5f46baa30077b69e0f09f2571 -A firefox /Киев +91b89025b5acd56ca475924e0eb559a9734f3333 dbd49d93eff2b2cf82f7d266f90de950207a0561 -A firefox /Kiev?2 +e6cb82dab95e05167ffbcb90a10d6cb03cd02ec1 de3b9821d587753149eded5411ec397e7a2000e2 -A firefox /Kiev?format=1 +e65bc57e8d1df26c442a9ecf45afee390ff331a3 f64086e48d84ac6eb440ca080eff28de1470ec30 -A firefox /Kiev?format=2 +d743b331d5f4c81bbc8b168ce84a99ab22dc70cf da7b79cdff330edc1a7a78fcf2f9c8bf7e432d40 -A firefox /Kiev?format=3 +bf359ee92690c3a3061542dc6e78cb42ca837412 6a61305ea631fbccb98090c62a96898e8ffa0d75 -A firefox /Kiev?format=4 +cb875772a6610c991b95b3fbfa22fc7192e25843 5367fd6790d55639b1536ec71abf340e5c79ff45 -A firefox /Kiev?format=v2 +91c0076d8e6665c06aab7c7b2326b29718bfeb80 2d97c405f1557b822cc86b038aeea40c3eb79d7d -A firefox /Kiev?format=%s +ae9d7b1ee27eb8201a0726a3e24fe195cf2ae9e4 3f26bdbbd5faf7729f87a9cb964d407493e9a0d6 -A firefox /Kiev?format=%S +e94371697b70bf956b6f9352fad913d716e774e7 fe58cbd420cf36a910551a1037f5c4fa19b31074 -A firefox /Kiev?format=%D+%S+%z+%s+%d +d520af45b491689d53024c696955db8b1e4eaa87 e916b140b1297cf5bea16d92a91260e9dc3e2bc9 -A firefox /:help +6b80492b79a4cc510cb4a9654cb6ff085cdc1943 801d4c6d6837c9604944168a3930dfe05b0e9f5d -A firefox /Kiev?T +e13d7449ce756e55ba3e84e4b7e601b36d0044b6 c5a87804710ab70b8798f56579d276f4ce1806bf -A firefox /Kiev?p +3f6c192a6da5b79ea59ef94e99b9cbf4b0e7ede2 372aca50f441920ad623d62ee8fcde46d609f6f9 -A firefox /Kiev?q +f28ca2a7a47f4859eac7d0307ec7ac67a40e0adf 1b4b66d58bd7e27abeeca45581e686f12fefe76a -A firefox /Kiev?Q +62c5029cf297b1434c57228dc8c8cdfb5e68285d e380ef1a22f62a7fa1133d0e35d923c8587cb3ed -A firefox /Kiev_text=no_view=v2.png +22aa5dc06076b086de776a3c601544b250fef07c ff70b13244929fa3a934e94f99f019c66501ad43 -A firefox /Kiev.png?1nqF +3a00ecab00da83f70c2abf30379c5e79c791e383 ff70b13244929fa3a934e94f99f019c66501ad43 -A firefox /Kiev_1nqF.png +ed573b89ca5522d6ab69dc1686b98b00391076bd 5ce7ea58bf02bff008baa3193b2db498268a244b -H Accept-Language:ru / +b879673f66235bbf1913ff9abc58aff2fb8962d1 00a96a5d83608c2dad7921862bb3f244775f6b19 -H Accept-Language:ru /Kiev +83d99896cf866ecbaa6d2c64c12bd31bc7b35068 92dc07acb93633974eaff19e8c1a99e590e140d9 -H Accept-Language:ru /Kiev.png +9cbb6aa3e0b46e78229a32688db1cced9a44271d b368cc8f39e7a7ced04e3f4e6506e1eb4551e904 -H Accept-Language:ru /?T +095d8d38c667923131801595b903e007b5f902f3 4ede3397f9def696adc7ecf3ffd46a59b8fb25cb -H Accept-Language:ru /Киев +4e6cdfc38c9d9f2436438b345776c42cb8cab8a5 1b00c96a05f9daea8248a8e063d990797be933ad -H Accept-Language:ru /Kiev?2 +b7d8d0f0bb4c38aabac468c9a354bb4e2b401893 de3b9821d587753149eded5411ec397e7a2000e2 -H Accept-Language:ru /Kiev?format=1 +8f3bbfc9be6418e82edacecc54a9f3e9f26b7fbf f64086e48d84ac6eb440ca080eff28de1470ec30 -H Accept-Language:ru /Kiev?format=2 +f1d4178892fd3dc38e9f966112d317859acc9122 da7b79cdff330edc1a7a78fcf2f9c8bf7e432d40 -H Accept-Language:ru /Kiev?format=3 +cf44e154504d9bc2b9b6066bbb0f5d52fc12f13e 6a61305ea631fbccb98090c62a96898e8ffa0d75 -H Accept-Language:ru /Kiev?format=4 +4955c849f67da53203b8c96b15a0bf0a4a471bc6 e2d3509ef7f6c3fc84151f55e1c5eb2f28dfd155 -H Accept-Language:ru /Kiev?format=v2 +e23e33569bbe34de944dad3a647d2a7a525513b4 2d97c405f1557b822cc86b038aeea40c3eb79d7d -H Accept-Language:ru /Kiev?format=%s +d3c44cb57a1ba487b9fe7ec37368d00eee5b4601 3f26bdbbd5faf7729f87a9cb964d407493e9a0d6 -H Accept-Language:ru /Kiev?format=%S +db91cc89883050beedd2afac7c74276a4d2dcf42 fe58cbd420cf36a910551a1037f5c4fa19b31074 -H Accept-Language:ru /Kiev?format=%D+%S+%z+%s+%d +3f69f4a605ce88643b4e0d62a588c92625d41aea 73b4142cd3af43472897989c61408d5765c2a6ef -H Accept-Language:ru /:help +08553ca4bf71c738c4321fe7d84b4e6ff830956f 016fc03b18a8902f838719bbc171184603c08b60 -H Accept-Language:ru /Kiev?T +b70f8b3fc8aee126c04b27b0d3b4c503b4292cbf b60b68a9e77275884812f7e52b06f6012ba5682a -H Accept-Language:ru /Kiev?p +400efdba61125f8cb850d7c33caf4fc2739a960b 5ee4a043a91509ef57aec46a14a0c24f09e8ec47 -H Accept-Language:ru /Kiev?q +b9fd454e73343f262a6d99dd80487495bd647c6f a10718896a07baadb87adf2bf0026b1f00252213 -H Accept-Language:ru /Kiev?Q +8fed034e57624d0e0b33140673094e56e04087bc 83d236565782aff7416bf526b38636148d6ba15a -H Accept-Language:ru /Kiev_text=no_view=v2.png +cf618118ddeff08355620e83a693c40239990545 37a4dd271b5a66513da4a6dc9caeab0ae15814db -H Accept-Language:ru /Kiev.png?1nqF +0f106ea5605ecc852ab6f63eee348a9a954e137f 37a4dd271b5a66513da4a6dc9caeab0ae15814db -H Accept-Language:ru /Kiev_1nqF.png +3ce3dd46413f236244410f142a4b44356a0cedf9 1c2eea391b35c8bfed2541435e1788307aa06bc1 -H X-Forwarded-For:1.1.1.1 / +89be0a5787592298ce34f10b36da7ee87d1a1353 d123e570da22dee9798d353c4281cb5a2bdbaeac -H X-Forwarded-For:1.1.1.1 /Kiev +a9977eadc628b1ede5d4f91ee103dfb740caa2b1 a186d89e95061a7887c005ffa8bd1e29362de2da -H X-Forwarded-For:1.1.1.1 /Kiev.png +eec20c6be5e528967cddf6d0b72c84dbda553d43 9a39dbafa7e1550d374e38059c0f4b8f437e1739 -H X-Forwarded-For:1.1.1.1 /?T +e304153f0e1e9b41781bf4eb6fb6c4a5b7513aec ce7fb7a88cab697f5280ddabf344f0d397888956 -H X-Forwarded-For:1.1.1.1 /Киев +98f0b3a28863a861c6ac6d89ee5d49adb7f3f518 0f86f59a45b4485fea1375ca945503d9abb9a96d -H X-Forwarded-For:1.1.1.1 /Kiev?2 +cf04d7fe2cf36eba8d7fb4fa6def1c9015036456 de3b9821d587753149eded5411ec397e7a2000e2 -H X-Forwarded-For:1.1.1.1 /Kiev?format=1 +67fbe9168566709450eb35d36c60c27105335a7e f64086e48d84ac6eb440ca080eff28de1470ec30 -H X-Forwarded-For:1.1.1.1 /Kiev?format=2 +b2604348bf39774c85b7c18ae7b51f63a2c9f31a da7b79cdff330edc1a7a78fcf2f9c8bf7e432d40 -H X-Forwarded-For:1.1.1.1 /Kiev?format=3 +cf012400156c842e569b6a9f05b094e6b75348cd 6a61305ea631fbccb98090c62a96898e8ffa0d75 -H X-Forwarded-For:1.1.1.1 /Kiev?format=4 +1f4981348cab19df9846cd3b3923ee7a972ff9fa fed12e63dd5fb5e348ee30d94c7231112eca71cd -H X-Forwarded-For:1.1.1.1 /Kiev?format=v2 +e7d772042819bb62e6e259656a75bd7b0621d1da 2d97c405f1557b822cc86b038aeea40c3eb79d7d -H X-Forwarded-For:1.1.1.1 /Kiev?format=%s +7db5a1daac653383c18aec25ad8583f2e5296845 3f26bdbbd5faf7729f87a9cb964d407493e9a0d6 -H X-Forwarded-For:1.1.1.1 /Kiev?format=%S +a9331379fa4b5d61b5a87a8e4cd4412cdae970a1 fe58cbd420cf36a910551a1037f5c4fa19b31074 -H X-Forwarded-For:1.1.1.1 /Kiev?format=%D+%S+%z+%s+%d +767a7407c14049fd77a6a2fedd1d8b35f6e47e0d 0ea998c1b53e452a373699ab953ab00e8a2870f0 -H X-Forwarded-For:1.1.1.1 /:help +10631d55b42e7bc5ec15ffc5cddae712785eb354 b0bd07f0c87aae9464c091ccb955f41ec6973098 -H X-Forwarded-For:1.1.1.1 /Kiev?T +031478f562663eb9f577b04032993e2f098146f6 d001bf6ab36b6c14f98f02fc4500706d7a9f05a8 -H X-Forwarded-For:1.1.1.1 /Kiev?p +e106cd21a6b67196159c2baa023142e3a8859612 80f18be012d0471dce9fcd2b500f482bcd635347 -H X-Forwarded-For:1.1.1.1 /Kiev?q +1a16c9b52ba90cb7ad3dd8902bf41b31a287d49e d5d070c98237f0dffc82b176039f90a15f03a667 -H X-Forwarded-For:1.1.1.1 /Kiev?Q +83bd9cc6a646e44b75524474dd32f0fd1f5c5a39 e380ef1a22f62a7fa1133d0e35d923c8587cb3ed -H X-Forwarded-For:1.1.1.1 /Kiev_text=no_view=v2.png +ac8dabb4d30cf6a6fae626e6fc29a4f2d9df0164 ff70b13244929fa3a934e94f99f019c66501ad43 -H X-Forwarded-For:1.1.1.1 /Kiev.png?1nqF +f0b3c0851a03a9b182ab938fc7f14935bf7af1f0 ff70b13244929fa3a934e94f99f019c66501ad43 -H X-Forwarded-For:1.1.1.1 /Kiev_1nqF.png