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

-## 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:

+## 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