diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b472a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +ve/ +share/static/fonts/ +*.pyc +data/ +log/ +.idea/ +*.swp diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2981838 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +group: travis_latest +language: python +cache: pip +python: + - 2.7 + - 3.6 +install: + - pip install -r requirements.txt + - pip install flake8 # pytest # add another testing frameworks later +before_script: + # stop the build if there are Python syntax errors or undefined names + - flake8 . --count --select=E901,E999,F821,F822,F823 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics +script: + - true # pytest --capture=sys # add other tests here +notifications: + on_success: change + on_failure: change # `always` will be the setting once code changes slow down. diff --git a/README.md b/README.md index 3eee0ae..464540b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -*wttr.in — the right way to check the weather.* +*wttr.in — the right way to check the weather!* + +wttr.in is a console-oriented weather forecast service that supports various information +representation methods like terminal-oriented ANSI-sequences for console HTTP clients +(curl, httpie, or wget), HTML for web browsers, or PNG for graphical viewers. -wttr.in is a console oriented weather forecast service, that supports various information representation methods -like terminal oriented ANSI-sequences for console HTTP clients such as curl, httpie or wget; -HTML for web browsers; or PNG for graphical viewers. wttr.in uses [wego](http://github.com/schachmat/wego) for visualization and various data sources for weather forecast information. -You can check it at [wttr.in](http://wttr.in). +You can see it running here: [wttr.in](http://wttr.in). ## Usage -You can access the service from a shell or from a Web browser: +You can access the service from a shell or from a Web browser like this: $ curl wttr.in Weather for City: Paris, France @@ -22,42 +23,42 @@ You can access the service from a shell or from a Web browser: / \ 0.0 mm -That is how the actual weather report for your location looks like (it is live!): +Here is an actual weather report for your location (it's live!): ![Weather Report](http://wttr.in/MyLocation.png?) -(it's not your location actually, becasue GitHub's CDN hides your real IP address with its own IP address, -but it is still a live weather report in your language). +(It's not your actual location - GitHub's CDN hides your real IP address with its own IP address, +but it's still a live weather report in your language.) -You can specify the location that you want to get the weather information for. -If you omit the location name, you will get the report for your current location, -based on your IP address. +Want to get the weather information for a specific location? You can add the desired location to the URL in your +request like this: $ curl wttr.in/London $ curl wttr.in/Moscow -You can use 3-letters airport codes if you want to get the weather information -about some airports: +If you omit the location name, you will get the report for your current location based on your IP address. + +Use 3-letter airport codes in order to get the weather information at a certain airport: $ curl wttr.in/muc # Weather for IATA: muc, Munich International Airport, Germany $ curl wttr.in/ham # Weather for IATA: ham, Hamburg Airport, Germany -If you want to specify a location that is not a city/town's name, but a name of some geographical location -(e.g. it can be a site in a city, a mountain name, a special location etc.) you should place `~` before its name. -That means the location name should be looked up before: +Let's say you'd like to get the weather for a geographical location other than a town or city - maybe an attraction +in a city, a mountain name, or some special location. Add the character `~` before the name to look up that special +location name before the weather is then retrieved: $ curl wttr.in/~Vostok+Station $ curl wttr.in/~Eiffel+Tower $ curl wttr.in/~Kilimanjaro -In this case there is a line below the weather forecast output describing the founded precise position: +For these examples, you'll see a line below the weather forecast output that shows the geolocation +results of looking up the location: Location: Vostok Station, станция Восток, AAT, Antarctica [-78.4642714,106.8364678] Location: Tour Eiffel, 5, Avenue Anatole France, Gros-Caillou, 7e, Paris, Île-de-France, 75007, France [48.8582602,2.29449905432] Location: Kilimanjaro, Northern, Tanzania [-3.4762789,37.3872648] -You can also use IP-addresses (direct) or domain names (prefixed with @) -as a location specificator: +You can also use IP-addresses (direct) or domain names (prefixed with `@`) to specify a location: $ curl wttr.in/@github.com $ curl wttr.in/@msu.ru @@ -66,20 +67,20 @@ To get detailed information online, you can access the [/:help](http://wttr.in/: $ curl wttr.in/:help -## Additional options +### Weather Units -By default the USCS units are used for the queries from the USA and the -metric system for the rest of the world. -You can override this behavior with the following options: +By default the USCS units are used for the queries from the USA and the metric system for the rest of the world. +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 formats +## Supported output formats -wttr.in supports three output formats at the moment: +wttr.in currently supports three output formats: * ANSI for the terminal; +* ANSI for the terminal, one-line mode; * HTML for the browser; * PNG for the graphical viewers. @@ -93,44 +94,143 @@ to separate them with `_` instead of `?` and `&`: $ wget wttr.in/Paris_0tqp_lang=fr.png -Special useful options for the PNG format: +Useful options for the PNG format: * `t` for transparency (`transparency=150`); * transparency=0..255 for a custom transparency level. -Transparency is a useful feature when the weather PNGs are used to -add weather data to the pictures: +Transparency is a useful feature when weather PNGs are used to add weather data to pictures: - $ convert 1.jpg <( curl wttr.in/Oymyakon_tqp0.png ) -geometry +50+50 -composite 2.jpg + $ convert source.jpg <( curl wttr.in/Oymyakon_tqp0.png ) -geometry +50+50 -composite target.jpg -Here: +In this example: -* `1.jpg` - source file; -* `2.jpg` - target file; -* Oymyakon - name of the location; -* tqp0 - options (recommended). +* `source.jpg` - source file; +* `target.jpg` - target file; +* `Oymyakon` - name of the location; +* `tqp0` - options (recommended). ![Picture with weather data](https://pbs.twimg.com/media/C69-wsIW0AAcAD5.jpg) -## Special pages +## One-line output -wttr.in can be used not only to check the weather, but also for some other purposes: +For one-line output format, specify additional URL parameter `format`: + +``` +$ curl wttr.in/Nuremberg?format=3 +Nuremberg: 🌦 +11⁰C +``` + +Available preconfigured formats: 1, 2, 3, 4 and the custom format using the percent notation (see below). + +You can specify multiple locations separated with `:` (for repeating queries): + +``` +$ curl wttr.in/Nuremberg:Hamburg:Berlin?format=3 +Nuremberg: 🌦 +11⁰C +``` +Or to process all this queries at once: + +``` +$ curl -s 'wttr.in/{Nuremberg,Hamburg,Berlin}?format=3' +Nuremberg: 🌦 +11⁰C +Hamburg: 🌦 +8⁰C +Berlin: 🌦 +8⁰C +``` + +To specify your own custom output format, use the special `%`-notation: + +``` + c Weather condition, + C Weather condition textual name, + h Humidity, + t Temperature, + w Wind, + l Location, + m Moonphase 🌑🌒🌓🌔🌕🌖🌗🌘, + M Moonday, + p precipitation (mm), + P pressure (hPa), +``` + +So, these two calls are the same: + +``` + $ curl wttr.in/London?format=3 + London: ⛅️ +7⁰C + $ curl wttr.in/London?format="%l:+%c+%t" + London: ⛅️ +7⁰C +``` +Keep in mind, that when using in `tmux.conf`, you have to escape `%` with `%`, i.e. write there `%%` instead of `%`. + +In progams, that are querying the service automatically (such as tmux),it is better to use some reasonable update interval. In tmux, you can configure it with `status-interval`. + +If several, `:` separated locations, are specified in the query, specify update period +as an additional query parameter `period=`: +``` +set -g status-interval 60 +WEATHER='#(curl -s wttr.in/London:Stockholm:Moscow\?format\="%%l:+%%c%%20%%t%%60%%w&period=60")' +set -g status-right "$WEATHER ..." +``` +![wttr.in in tmux status bar](https://wttr.in/files/example-tmux-status-line.png) + +To see emojis in terminal, you need: + +1. Terminal support for emojis (was added to Cairo 1.15.8); +2. Font with emojis support. + +For the Emoji font, we recommend *Noto Color Emoji*, and a good alternative option would be the *Emoji One* font; +both of them support all necessary emoji glyphs. + +Font configuration: + +``` +$ cat ~/.config/fontconfig/fonts.conf + + + + + serif + + Noto Color Emoji + + + + sans-serif + + Noto Color Emoji + + + + monospace + + Noto Color Emoji + + + +``` + +(to apply the configuration, run `fc-cache -f -v`) + +## 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: $ curl wttr.in/Moon -To see the current Moon phase (uses [pyphoon](https://github.com/chubin/pyphoon) as its backend). +Get the Moon phase for a particular date by adding `@YYYY-MM-DD`: $ curl wttr.in/Moon@2016-12-25 -To see the Moon phase for the specified date (2016-12-25). +The Moon phase information uses [pyphoon](https://github.com/chubin/pyphoon) as its backend. ## Internationalization and localization -wttr.in supports multilingual locations names: they can be specified in any language in the world -(it may be surprising, but many locations in the world do not have any English name at all). +wttr.in supports multilingual locations names that can be specified in any language in the world +(it may be surprising, but many locations in the world don't have an English name). -The query string should be specified in Unicode (hex encoded or not). If it contains spaces -they must be replaced with +: +The query string should be specified in Unicode (hex-encoded or not). Spaces in the query string +must be replaced with `+`: $ curl wttr.in/станция+Восток Weather report: станция Восток @@ -145,48 +245,51 @@ The language used for the output (except the location name) does not depend on t and it is either English (by default) or the preferred language of the browser (if the query was issued from a browser) that is specified in the query headers (`Accept-Language`). -It can be set explicitly when using console clients by means of the appropriate command line options -(for example: `curl -H "Accept-Language: fr" wttr.in` or `http GET wttr.in Accept-Language:ru`). +The language can be set explicitly when using console clients by using command-line options like this: + + curl -H "Accept-Language: fr" wttr.in + http GET wttr.in Accept-Language:ru The preferred language can be forced using the `lang` option: $ curl wttr.in/Berlin?lang=de -The third option is to choose the language using DNS name used in the query: +The third option is to choose the language using the DNS name used in the query: $ curl de.wttr.in/Berlin -wttr.in is currently translated in more than 45 languages and the number of supported languages -is constantly growing. +wttr.in is currently translated into 54 languages, and the number of supported languages is constantly growing. See [/:translation](http://wttr.in/:translation) to learn more about the translation process, -to see the list of supported languages and contributors, or to know how you can help to translate wttr.in in your language. +to see the list of supported languages and contributors, or to know how you can help to translate wttr.in +in your language. ![Queries to wttr.in in various languages](https://pbs.twimg.com/media/C7hShiDXQAES6z1.jpg) ## Installation -To install the program: +To install the application: 1. Install external dependencies -2. Install python dependencies used by the service -3. Get WorldWeatherOnline API Key -4. Configure wego +2. Install Python dependencies used by the service +3. Configure IP2Location (optional) +4. Get a WorldWeatherOnline API and configure wego 5. Configure wttr.in -6. Configure HTTP-frontend service +6. Configure the HTTP-frontend service ### Install external dependencies -External requirements: +wttr.in has the following external dependencies: +* [golang](https://golang.org/doc/install), wego dependency * [wego](https://github.com/schachmat/wego), weather client for terminal -To install `wego` you must have [golang](https://golang.org/doc/install) installed. After that: +After you install [golang](https://golang.org/doc/install), install `wego`: $ go get -u github.com/schachmat/wego $ go install github.com/schachmat/wego -### Install python dependencies +### Install Python dependencies Python requirements: @@ -196,7 +299,7 @@ Python requirements: * requests * gevent -If you want to get weather reports as PNG files, install also: +If you want to get weather reports as PNG files, you'll also need to install: * PIL * pyte (>=0.6) @@ -208,22 +311,33 @@ If `virtualenv` is used: $ virtualenv ve $ ve/bin/pip install -r requirements.txt - $ ve/bin/pip bin/srv.py - -(pyte 0.6 is not yet released and it should be installed direct from the source code from its GitHub repository at the moment). + $ ve/bin/python 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/ +You can use a free database GeoLite2 that can be downloaded from (http://dev.maxmind.com/geoip/geoip2/geolite2/). -### Get WorldWeatherOnline key +### Configure IP2Location (optional) -To get the WorldWeatherOnline API key, you must register here: +If you want to use the IP2location service for IP-addresses that are not covered by GeoLite2, +you have to obtain a API key of that service, and after that save into the `~/.ip2location.key` file: + +``` +$ echo 'YOUR_IP2LOCATION_KEY' > ~/.ip2location.key +``` + +If you don't have this file, the service will be silently skipped (it is not a big problem, +because the MaxMind database is pretty good). + +### Get a WorldWeatherOnline key and configure wego + +To get a WorldWeatherOnline API key, you must register here: https://developer.worldweatheronline.com/auth/register -### Configure wego +After you have a WorldWeatherOnline key, you can save it into the +WWO key file: `~/.wwo.key` -After you have the key, configure `wego`: +Also, you have to specify the key in the `wego` configuration: $ cat ~/.wegorc { @@ -238,8 +352,8 @@ The `City` parameter in `~/.wegorc` is ignored. ### Configure wttr.in -Configure the following environment variables specifing the path to the local `wttr.in` -installation, to the GeoLite database and to the `wego` installation. For example: +Configure the following environment variables that define the path to the local `wttr.in` +installation, to the GeoLite database, and to the `wego` installation. For example: export WTTR_MYDIR="/home/igor/wttr.in" export WTTR_GEOLITE="/home/igor/wttr.in/GeoLite2-City.mmdb" @@ -248,10 +362,9 @@ installation, to the GeoLite database and to the `wego` installation. For exampl export WTTR_LISTEN_PORT="8002" -### Configure HTTP-frontend service +### Configure the HTTP-frontend service -Configure the web server, that will be used -to access the service (if you want to use a web frontend; it's recommended): +It's recommended that you also configure the web server that will be used to access the service: server { listen [::]:80; @@ -281,5 +394,3 @@ to access the service (if you want to use a web frontend; it's recommended): expires off; } } - - diff --git a/bin/proxy.py b/bin/proxy.py new file mode 100644 index 0000000..fda7131 --- /dev/null +++ b/bin/proxy.py @@ -0,0 +1,202 @@ +#vim: fileencoding=utf-8 + +""" + +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. + +""" +from __future__ import print_function + +from gevent.pywsgi import WSGIServer +from gevent.monkey import patch_all +patch_all() + +# pylint: disable=wrong-import-position,wrong-import-order +import sys +import os +import time +import json + +import requests +import cyrtranslit + +from flask import Flask, request +APP = Flask(__name__) + + +MYDIR = os.path.abspath( + os.path.dirname(os.path.dirname('__file__'))) +sys.path.append("%s/lib/" % MYDIR) + +from globals import PROXY_CACHEDIR, PROXY_HOST, PROXY_PORT +from translations import PROXY_LANGS +# pylint: enable=wrong-import-position + + + +def load_translations(): + """ + load all translations + """ + translations = {} + + for f_name in PROXY_LANGS: + f_name = 'share/translations/%s.txt' % f_name + translation = {} + lang = f_name.split('/')[-1].split('.', 1)[0] + with open(f_name, "r") as f_file: + for line in f_file: + if ':' not in line: + continue + if line.count(':') == 3: + _, trans, orig, _ = line.strip().split(':', 4) + else: + _, trans, orig = line.strip().split(':', 3) + trans = trans.strip() + orig = orig.strip() + + translation[orig] = trans + translations[lang] = translation + return translations +TRANSLATIONS = load_translations() + + +def _find_srv_for_query(path, query): # pylint: disable=unused-argument + return 'http://api.worldweatheronline.com/' + +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) + try: + return (open(cache_file, 'r').read(), + json.loads(open(cache_file+".headers", 'r').read())) + except IOError: + return None, None + +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_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) + +def translate(text, lang): + """ + Translate `text` into `lang` + """ + translated = TRANSLATIONS.get(lang, {}).get(text, text) + if text.encode('utf-8') == translated: + print("%s: %s" % (lang, text)) + return translated + +def cyr(to_translate): + """ + Transliterate `to_translate` from latin into cyrillic + """ + return cyrtranslit.to_cyrillic(to_translate) + +def _patch_greek(original): + return original.decode('utf-8').replace(u"Ηλιόλουστη/ο", u"Ηλιόλουστη").encode('utf-8') + +def add_translations(content, lang): + """ + Add `lang` translation to `content` (JSON) + returned by the data source + """ + languages_to_translate = TRANSLATIONS.keys() + try: + d = json.loads(content) # pylint: disable=invalid-name + except ValueError as exception: + print("---") + print(exception) + print("---") + + try: + weather_condition = d['data']['current_condition'][0]['weatherDesc'][0]['value'] + if lang in languages_to_translate: + d['data']['current_condition'][0]['lang_%s' % lang] = \ + [{'value': translate(weather_condition, lang)}] + elif lang == 'sr': + 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 + fixed_hourly = [] + for h in w['hourly']: # pylint: disable=invalid-name + weather_condition = h['weatherDesc'][0]['value'] + if lang in languages_to_translate: + h['lang_%s' % lang] = \ + [{'value': translate(weather_condition, lang)}] + elif lang == 'sr': + h['lang_%s' % lang] = \ + [{'value': cyr(h['lang_%s' % lang][0]['value'].encode('utf-8'))}] + elif lang == 'el': + h['lang_%s' % lang] = \ + [{'value': _patch_greek(h['lang_%s' % lang][0]['value'].encode('utf-8'))}] + elif lang == 'sr-lat': + h['lang_%s' % lang] = \ + [{'value': h['lang_sr'][0]['value'].encode('utf-8')}] + fixed_hourly.append(h) + w['hourly'] = fixed_hourly + fixed_weather.append(w) + d['data']['weather'] = fixed_weather + + content = json.dumps(d) + except (IndexError, ValueError) as exception: + print(exception) + return content + +@APP.route("/") +def proxy(path): + """ + Main proxy function. Handles incoming HTTP queries. + """ + + lang = request.args.get('lang', 'en') + query_string = request.query_string + query_string = query_string.replace('sr-lat', 'sr') + content, headers = _load_content_and_headers(path, query_string) + + if content is None: + srv = _find_srv_for_query(path, query_string) + url = '%s/%s?%s' % (srv, path, query_string) + print(url) + + attempts = 5 + while attempts: + response = requests.get(url, timeout=10) + try: + json.loads(response.content) + break + except ValueError: + attempts -= 1 + + headers = {} + headers['Content-Type'] = response.headers['content-type'] + content = add_translations(response.content, lang) + _save_content_and_headers(path, query_string, content, headers) + + return content, 200, headers + +if __name__ == "__main__": + #app.run(host='0.0.0.0', port=5001, debug=False) + #app.debug = True + SERVER = WSGIServer((PROXY_HOST, PROXY_PORT), APP) + SERVER.serve_forever() diff --git a/bin/srv.py b/bin/srv.py index b146780..3214a58 100644 --- a/bin/srv.py +++ b/bin/srv.py @@ -1,280 +1,54 @@ -import logging -import os -import re -import requests -import socket -import time +#!/usr/bin/env python +# vim: set encoding=utf-8 -import geoip2.database -from geopy.geocoders import Nominatim -import jinja2 - -from gevent.wsgi import WSGIServer +from gevent.pywsgi import WSGIServer from gevent.monkey import patch_all -from gevent.subprocess import Popen, PIPE patch_all() -import dns.resolver -from dns.exception import DNSException +# pylint: disable=wrong-import-position,wrong-import-order +import sys +import os +import jinja2 -from flask import Flask, request, render_template, send_from_directory -app = Flask(__name__) +from flask import Flask, request, send_from_directory +APP = Flask(__name__) -MYDIR = os.environ.get('WTTR_MYDIR', os.path.abspath(os.path.dirname( os.path.dirname('__file__') ))) -GEOLITE = os.environ.get('WTTR_GEOLITE', os.path.join( MYDIR, "GeoLite2-City.mmdb" )) -WEGO = os.environ.get('WTTR_WEGO', "/home/igor/go/bin/wego") -LISTEN_HOST = os.environ.get('WTTR_LISTEN_HOST', "127.0.0.1") -LISTEN_PORT = int(os.environ.get('WTTR_LISTEN_PORT', "8002")) +MYDIR = os.path.abspath( + os.path.dirname(os.path.dirname('__file__'))) +sys.path.append("%s/lib/" % MYDIR) -CACHEDIR = os.path.join( MYDIR, "cache" ) -IP2LCACHE = os.path.join( MYDIR, "cache/ip2l" ) -ALIASES = os.path.join( MYDIR, "share/aliases" ) -ANSI2HTML = os.path.join( MYDIR, "share/ansi2html.sh" ) -HELP_FILE = os.path.join( MYDIR, 'share/help.txt' ) -LOG_FILE = os.path.join( MYDIR, 'log/main.log' ) -TEMPLATES = os.path.join( MYDIR, 'share/templates' ) -STATIC = os.path.join( MYDIR, 'share/static' ) +import wttr_srv +from globals import TEMPLATES, STATIC, LISTEN_HOST, LISTEN_PORT +# pylint: enable=wrong-import-position,wrong-import-order -NOT_FOUND_LOCATION = "NOT_FOUND" -DEFAULT_LOCATION = "Oymyakon" -NOT_FOUND_MESSAGE = """ -We were unable to find your location, -so we have brought you to Oymyakon, -one of the coldest permanently inhabited locales on the planet. -""" -PLAIN_TEXT_AGENTS = [ - "curl", - "httpie", - "lwp-request", - "wget", - "python-requests" -] - -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) - -reader = geoip2.database.Reader(GEOLITE) -geolocator = Nominatim() - -my_loader = jinja2.ChoiceLoader([ - app.jinja_loader, +MY_LOADER = jinja2.ChoiceLoader([ + APP.jinja_loader, jinja2.FileSystemLoader(TEMPLATES), ]) -app.jinja_loader = my_loader +APP.jinja_loader = MY_LOADER -class Limits: - def __init__( self ): - self.intervals = [ 'min', 'hour', 'day' ] - self.divisor = { - 'min': 60, - 'hour': 3600, - 'day': 86400, - } - self.counter = { - 'min': {}, - 'hour': {}, - 'day': {}, - } - self.limit = { - 'min': 10, - 'hour': 20, - 'day': 100, - } - self.last_update = { - 'min': 0, - 'hour': 0, - 'day': 0, - } - self.clear_counters() - - def check_ip( self, ip ): - self.clear_counters() - for interval in self.intervals: - if ip not in self.counter[interval]: - self.counter[interval][ip] = 0 - self.counter[interval][ip] += 1 - if self.limit[interval] <= self.counter[interval][ip]: - log("Too many queries: %s in %s for %s" % (self.limit[interval], interval, ip) ) - raise RuntimeError("Not so fast! Number of queries per %s is limited to %s" % (interval, self.limit[interval])) - print self.counter - - def clear_counters( self ): - t = int( time.time() ) - for interval in self.intervals: - if t / self.divisor[interval] != self.last_update[interval]: - self.counter[interval] = {} - self.last_update[interval] = t / self.divisor[interval] - - -limits = Limits() - -def error( text ): - print text - raise RuntimeError(text) - -def log( text ): - print text.encode('utf-8') - logging.info( text.encode('utf-8') ) - -def is_ip( ip ): - try: - socket.inet_pton(socket.AF_INET, ip) - return True - except socket.error: - try: - socket.inet_pton(socket.AF_INET6, ip) - return True - except socket.error: - return False - -def save_weather_data( location, filename ): - - if location == NOT_FOUND_LOCATION: - location_not_found = True - location = DEFAULT_LOCATION - else: - location_not_found = False - p = Popen( [ WEGO, '-location=%s' % location ], stdout=PIPE, stderr=PIPE ) - stdout, stderr = p.communicate() - if p.returncode != 0: - error( stdout + stderr ) - - dirname = os.path.dirname( filename ) - if not os.path.exists( dirname ): - os.makedirs( dirname ) - - if location_not_found: - stdout += NOT_FOUND_MESSAGE - - open( filename, 'w' ).write( stdout ) - - 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 ) - - open( filename+'.html', 'w' ).write( stdout ) - -def get_filename( location ): - location = location.replace('/', '_') - timestamp = time.strftime( "%Y%m%d%H", time.localtime() ) - return "%s/%s/%s" % ( CACHEDIR, location, timestamp ) - -def get_wetter(location, ip, html=False): - filename = get_filename( location ) - if not os.path.exists( filename ): - limits.check_ip( ip ) - save_weather_data( location, filename ) - if html: - filename += '.html' - return open(filename).read() - - -def ip2location( ip ): - cached = os.path.join( IP2LCACHE, ip ) - - if os.path.exists( cached ): - return open( cached, 'r' ).read() - - try: - t = requests.get( 'http://api.ip2location.com/?ip=%s&key=demo&package=WS10' % ip ).text - if ';' in t: - location = t.split(';')[3] - if not os.path.exists( IP2LCACHE ): - os.makedirs( IP2LCACHE ) - open( cached, 'w' ).write( location ) - return location - except: - pass - -def get_location( ip_addr ): - response = reader.city( ip_addr ) - city = response.city.name - if city is None and response.location: - coord = "%s, %s" % (response.location.latitude, response.location.longitude) - location = geolocator.reverse(coord, language='en') - city = location.raw.get('address', {}).get('city') - if city is None: - print ip_addr - city = ip2location( ip_addr ) - return city or NOT_FOUND_LOCATION - -def load_aliases( aliases_filename ): - aliases_db = {} - with open( aliases_filename, 'r' ) as f: - for line in f.readlines(): - from_, to_ = line.split(':', 1) - aliases_db[ from_.strip().lower() ] = to_.strip() - return aliases_db - -location_alias = load_aliases( ALIASES ) -def location_canonical_name( location ): - if location.lower() in location_alias: - return location_alias[location.lower()] - return location - -def show_help(): - return open(HELP_FILE, 'r').read() - -@app.route('/files/') +@APP.route('/files/') def send_static(path): + "Send any static file located in /files/" return send_from_directory(STATIC, path) -@app.route('/favicon.ico') +@APP.route('/favicon.ico') def send_favicon(): + "Send static file favicon.ico" return send_from_directory(STATIC, 'favicon.ico') -@app.route("/") -@app.route("/") -def wttr(location = None): +@APP.route('/malformed-response.html') +def send_malformed(): + "Send static file malformed-response.html" + return send_from_directory(STATIC, 'malformed-response.html') - user_agent = request.headers.get('User-Agent').lower() - - if any(agent in user_agent for agent in PLAIN_TEXT_AGENTS): - html_output = False - else: - html_output = True - - - if location == ':help': - help_ = show_help() - if html_output: - return render_template( 'index.html', body=help_ ) - else: - return help_ - - orig_location = location - - if request.headers.getlist("X-Forwarded-For"): - ip = request.headers.getlist("X-Forwarded-For")[0] - if ip.startswith('::ffff:'): - ip = ip[7:] - else: - ip = request.remote_addr - - try: - if location is None: - location = get_location( ip ) - - if is_ip( location ): - location = get_location( location ) - if location.startswith('@'): - try: - loc = dns.resolver.query( location[1:], 'LOC' ) - location = str("%.7f,%.7f" % (loc[0].float_latitude, loc[0].float_longitude)) - except DNSException, e: - location = get_location( socket.getaddrinfo( location[1:], None )[0][4][0] ) - - location = location_canonical_name( location ) - log("%s %s %s %s" % (ip, user_agent, orig_location, location)) - return get_wetter( location, ip, html=html_output ) - except Exception, e: - logging.error("Exception has occurred", exc_info=1) - return str(e).rstrip()+"\n" - -server = WSGIServer((LISTEN_HOST, LISTEN_PORT), app) -server.serve_forever() +@APP.route("/") +@APP.route("/") +def wttr(location=None): + "Main function wrapper" + return wttr_srv.wttr(location, request) +SERVER = WSGIServer((LISTEN_HOST, LISTEN_PORT), APP) +SERVER.serve_forever() diff --git a/lib/buttons.py b/lib/buttons.py index 96180a8..c08e6b2 100644 --- a/lib/buttons.py +++ b/lib/buttons.py @@ -4,18 +4,17 @@ TWITTER_BUTTON = """ """ GITHUB_BUTTON = """ - -wttr.in +wttr.in """ GITHUB_BUTTON_2 = """ -wego +wego """ GITHUB_BUTTON_3 = """ -pyphoon +pyphoon """ GITHUB_BUTTON_FOOTER = """ @@ -23,3 +22,14 @@ GITHUB_BUTTON_FOOTER = """ """ +def add_buttons(output): + """ + Add buttons to html output + """ + + return output.replace('', + (TWITTER_BUTTON + + GITHUB_BUTTON + + GITHUB_BUTTON_3 + + GITHUB_BUTTON_2 + + GITHUB_BUTTON_FOOTER) + '') diff --git a/lib/constants.py b/lib/constants.py new file mode 100644 index 0000000..2c61536 --- /dev/null +++ b/lib/constants.py @@ -0,0 +1,78 @@ +#vim: fileencoding=utf-8 + +WWO_CODE = { + "113": "Sunny", + "116": "PartlyCloudy", + "119": "Cloudy", + "122": "VeryCloudy", + "143": "Fog", + "176": "LightShowers", + "179": "LightSleetShowers", + "182": "LightSleet", + "185": "LightSleet", + "200": "ThunderyShowers", + "227": "LightSnow", + "230": "HeavySnow", + "248": "Fog", + "260": "Fog", + "263": "LightShowers", + "266": "LightRain", + "281": "LightSleet", + "284": "LightSleet", + "293": "LightRain", + "296": "LightRain", + "299": "HeavyShowers", + "302": "HeavyRain", + "305": "HeavyShowers", + "308": "HeavyRain", + "311": "LightSleet", + "314": "LightSleet", + "317": "LightSleet", + "320": "LightSnow", + "323": "LightSnowShowers", + "326": "LightSnowShowers", + "329": "HeavySnow", + "332": "HeavySnow", + "335": "HeavySnowShowers", + "338": "HeavySnow", + "350": "LightSleet", + "353": "LightShowers", + "356": "HeavyShowers", + "359": "HeavyRain", + "362": "LightSleetShowers", + "365": "LightSleetShowers", + "368": "LightSnowShowers", + "371": "HeavySnowShowers", + "374": "LightSleetShowers", + "377": "LightSleet", + "386": "ThunderyShowers", + "389": "ThunderyHeavyRain", + "392": "ThunderySnowShowers", + "395": "HeavySnowShowers", +} + +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"☁️", +} + +WIND_DIRECTION = [ + u"↓", u"↙", u"←", u"↖", u"↑", u"↗", u"→", u"↘", +] diff --git a/lib/globals.py b/lib/globals.py index 879ea3a..444e97a 100644 --- a/lib/globals.py +++ b/lib/globals.py @@ -1,41 +1,124 @@ +""" +global configuration of the project + +External environment variables: + + WTTR_MYDIR + WTTR_GEOLITE + WTTR_WEGO + WTTR_LISTEN_HOST + WTTR_LISTEN_PORT + +""" +from __future__ import print_function + import logging import os -MYDIR = os.path.abspath(os.path.dirname( os.path.dirname('__file__') )) +MYDIR = os.path.abspath(os.path.dirname(os.path.dirname('__file__'))) -GEOLITE = os.path.join( MYDIR, "GeoLite2-City.mmdb" ) -WEGO = "/home/igor/go/bin/we-lang" -PYPHOON = "/home/igor/wttr.in/pyphoon/bin/pyphoon-lolcat" +if "WTTR_GEOLITE" in os.environ: + GEOLITE = os.environ["WTTR_GEOLITE"] +else: + GEOLITE = os.path.join(MYDIR, 'data', "GeoLite2-City.mmdb") -CACHEDIR = os.path.join( MYDIR, "cache" ) -IP2LCACHE = os.path.join( MYDIR, "cache/ip2l" ) +WEGO = os.environ.get("WTTR_WEGO", "/home/igor/go/bin/we-lang") +PYPHOON = "/home/igor/pyphoon/bin/pyphoon-lolcat" -ALIASES = os.path.join( MYDIR, "share/aliases" ) -ANSI2HTML = os.path.join( MYDIR, "share/ansi2html.sh" ) -BLACKLIST = os.path.join( MYDIR, "share/blacklist" ) +_DATADIR = "/wttr.in" +_LOGDIR = "/wttr.in/log" -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' ) +CACHEDIR = os.path.join(_DATADIR, "cache/wego/") +IP2LCACHE = os.path.join(_DATADIR, "cache/ip2l/") +PNG_CACHE = os.path.join(_DATADIR, "cache/png") -LOG_FILE = os.path.join( MYDIR, 'log/main.log' ) -TEMPLATES = os.path.join( MYDIR, 'share/templates' ) -STATIC = os.path.join( MYDIR, 'share/static' ) +LOG_FILE = os.path.join(_LOGDIR, 'main.log') + +ALIASES = os.path.join(MYDIR, "share/aliases") +ANSI2HTML = os.path.join(MYDIR, "share/ansi2html.sh") +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') + +TEMPLATES = os.path.join(MYDIR, 'share/templates') +STATIC = os.path.join(MYDIR, 'share/static') NOT_FOUND_LOCATION = "not found" DEFAULT_LOCATION = "oymyakon" MALFORMED_RESPONSE_HTML_PAGE = open(os.path.join(STATIC, 'malformed-response.html')).read() +GEOLOCATOR_SERVICE = 'http://localhost:8004' + +# number of queries from the same IP address is limited +# (minute, hour, day) limitations: +QUERY_LIMITS = (300, 3600, 24*3600) + +LISTEN_HOST = os.environ.get("WTTR_LISTEN_HOST", "") +try: + LISTEN_PORT = int(os.environ.get("WTTR_LISTEN_PORT")) +except (TypeError, ValueError): + LISTEN_PORT = 8002 + +PROXY_HOST = "127.0.0.1" +PROXY_PORT = 5001 +PROXY_CACHEDIR = os.path.join(_DATADIR, "cache/proxy-wwo/") + +MY_EXTERNAL_IP = '5.9.243.187' + +PLAIN_TEXT_AGENTS = [ + "curl", + "httpie", + "lwp-request", + "wget", + "python-requests", + "OpenBSD ftp" +] + +PLAIN_TEXT_PAGES = [':help', ':bash.function', ':translation'] + +_IP2LOCATION_KEY_FILE = os.environ['HOME'] + '/.ip2location.key' +IP2LOCATION_KEY = None +if os.path.exists(_IP2LOCATION_KEY_FILE): + IP2LOCATION_KEY = open(_IP2LOCATION_KEY_FILE, 'r').read().strip() + +_WWO_KEY_FILE = os.environ['HOME'] + '/.wwo.key' +WWO_KEY = "key-is-not-specified" +if os.path.exists(_WWO_KEY_FILE): + WWO_KEY = open(_WWO_KEY_FILE, 'r').read().strip() + def error(text): + "log error `text` and raise a RuntimeError exception" + if not text.startswith('Too many queries'): - print text - logging.error("ERROR "+text) + print(text) + logging.error("ERROR %s", text) raise RuntimeError(text) def log(text): + "log error `text` and do not raise any exceptions" + if not text.startswith('Too many queries'): - print text + print(text) logging.info(text) +def debug_log(text): + """ + Write `text` to the debug log + """ + with open('/tmp/wttr.in-debug.log', 'a') as f_debug: + f_debug.write(text+'\n') + +def get_help_file(lang): + "Return help file for `lang`" + + help_file = os.path.join(MYDIR, 'share/translations/%s-help.txt' % lang) + if os.path.exists(help_file): + return help_file + return HELP_FILE diff --git a/lib/limits.py b/lib/limits.py new file mode 100644 index 0000000..2ff32e6 --- /dev/null +++ b/lib/limits.py @@ -0,0 +1,108 @@ +""" +Connection limitation. + +Number of connections from one IP is limited. +We have nothing against scripting and automated queries. +Even the opposite, we encourage them. But there are some +connection limits that even we can't handle. +Currently the limits are quite restrictive, but they will be relaxed +in the future. + +Usage: + + limits = Limits() + not_allowed = limits.check_ip(ip_address) + if not_allowed: + return "ERROR: %s" % not_allowed + +[Taken from github.com/chubin/cheat.sh] +""" + +import time +from globals import log + +def _time_caps(minutes, hours, days): + return { + 'min': minutes, + 'hour': hours, + 'day': days, + } + +class Limits(object): + """ + Queries limitation (by IP). + + Exports: + + check_ip(ip_address) + """ + + def __init__(self, whitelist=None, limits=None): + self.intervals = ['min', 'hour', 'day'] + + self.divisor = _time_caps(60, 3600, 86400) + self.last_update = _time_caps(0, 0, 0) + + if limits: + self.limit = _time_caps(*limits) + else: + self.limit = _time_caps(30, 600, 1000) + + if whitelist: + self.whitelist = whitelist[:] + else: + self.whitelist = [] + + self.counter = { + 'min': {}, + 'hour': {}, + 'day': {}, + } + + self._clear_counters_if_needed() + + def _log_visit(self, interval, ip_address): + if ip_address not in self.counter[interval]: + self.counter[interval][ip_address] = 0 + self.counter[interval][ip_address] += 1 + + def _limit_exceeded(self, interval, ip_address): + visits = self.counter[interval][ip_address] + limit = self._get_limit(interval) + return visits > limit + + def _get_limit(self, interval): + return self.limit[interval] + + def _report_excessive_visits(self, interval, ip_address): + log("%s LIMITED [%s for %s]" % (ip_address, self._get_limit(interval), interval)) + + def check_ip(self, ip_address): + """ + Check if `ip_address` is allowed, and if not raise an RuntimeError exception. + Return True otherwise + """ + if ip_address in self.whitelist: + return None + self._clear_counters_if_needed() + for interval in self.intervals: + self._log_visit(interval, ip_address) + if self._limit_exceeded(interval, ip_address): + self._report_excessive_visits(interval, ip_address) + return ("Not so fast! Number of queries per %s is limited to %s" + % (interval, self._get_limit(interval))) + return None + + def reset(self): + """ + Reset all counters for all IPs + """ + for interval in self.intervals: + self.counter[interval] = {} + + def _clear_counters_if_needed(self): + current_time = int(time.time()) + for interval in self.intervals: + if current_time // self.divisor[interval] != self.last_update[interval]: + self.counter[interval] = {} + self.last_update[interval] = current_time / self.divisor[interval] diff --git a/lib/location.py b/lib/location.py new file mode 100644 index 0000000..2b40949 --- /dev/null +++ b/lib/location.py @@ -0,0 +1,273 @@ +""" +All location related functions and converters. + +The main entry point is `location_processing` +which gets `location` and `source_ip_address` +and basing on this information generates +precise location description. + +""" +from __future__ import print_function + +import os +import json +import re +import socket +import requests +import geoip2.database + +from globals import GEOLITE, GEOLOCATOR_SERVICE, IP2LCACHE, IP2LOCATION_KEY, NOT_FOUND_LOCATION, \ + ALIASES, BLACKLIST, IATA_CODES_FILE + +GEOIP_READER = geoip2.database.Reader(GEOLITE) + +def ascii_only(string): + "Check if `string` contains only ASCII symbols" + + try: + for _ in range(5): + string = string.encode('utf-8') + return True + except UnicodeDecodeError: + return False + +def is_ip(ip_addr): + """ + Check if `ip_addr` looks like an IP Address + """ + + if re.match(r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}', ip_addr) is None: + return False + try: + socket.inet_aton(ip_addr) + return True + except socket.error: + return False + +def location_normalize(location): + """ + Normalize location name `location` + """ + #translation_table = dict.fromkeys(map(ord, '!@#$*;'), None) + def _remove_chars(chars, string): + return ''.join(x for x in string if x not in chars) + + location = location.lower().replace('_', ' ').replace('+', ' ').strip() + if not location.startswith('moon@'): + location = _remove_chars(r'!@#$*;:\\', location) + return location + + + +def geolocator(location): + """ + Return a GPS pair for specified `location` or None + if nothing can't be found + """ + + try: + geo = requests.get('%s/%s' % (GEOLOCATOR_SERVICE, location)).text + except requests.exceptions.ConnectionError as exception: + print("ERROR: %s" % exception) + return None + + if geo == "": + return None + + try: + answer = json.loads(geo.encode('utf-8')) + return answer + except ValueError as exception: + print("ERROR: %s" % exception) + return None + + return None + +def ip2location(ip_addr): + "Convert IP address `ip_addr` to a location name" + + cached = os.path.join(IP2LCACHE, ip_addr) + if not os.path.exists(IP2LCACHE): + os.makedirs(IP2LCACHE) + + location = None + + if os.path.exists(cached): + location = open(cached, 'r').read() + else: + # if IP2LOCATION_KEY is not set, do not the query, + # because the query wont be processed anyway + if IP2LOCATION_KEY: + try: + ip2location_response = requests\ + .get('http://api.ip2location.com/?ip=%s&key=%s&package=WS3' \ + % (ip_addr, IP2LOCATION_KEY)).text + if ';' in ip2location_response: + open(cached, 'w').write(ip2location_response) + location = ip2location_response + except requests.exceptions.ConnectionError: + pass + + if location and ';' in location: + location = location.split(';')[3], location.split(';')[1] + else: + location = location, None + + return location + +def get_location(ip_addr): + """ + Return location pair (CITY, COUNTRY) for `ip_addr` + """ + + try: + response = GEOIP_READER.city(ip_addr) + country = response.country.name + city = response.city.name + except geoip2.errors.AddressNotFoundError: + country = None + city = None + + # + # temporary disabled it because of geoip services capcacity + # + #if city is None and response.location: + # coord = "%s, %s" % (response.location.latitude, response.location.longitude) + # try: + # location = geolocator.reverse(coord, language='en') + # city = location.raw.get('address', {}).get('city') + # except: + # pass + if city is None: + city, country = ip2location(ip_addr) + + # workaround for the strange bug with the country name + # maybe some other countries has this problem too + if country == 'Russian Federation': + country = 'Russia' + + if city: + return city, country + else: + return NOT_FOUND_LOCATION, None + + +def location_canonical_name(location): + "Find canonical name for `location`" + + location = location_normalize(location) + if location.lower() in LOCATION_ALIAS: + return LOCATION_ALIAS[location.lower()] + return location + +def load_aliases(aliases_filename): + """ + Load aliases from the aliases file + """ + aliases_db = {} + with open(aliases_filename, 'r') as f_aliases: + for line in f_aliases.readlines(): + from_, to_ = line.decode('utf-8').split(':', 1) + aliases_db[location_normalize(from_)] = location_normalize(to_) + return aliases_db + +def load_iata_codes(iata_codes_filename): + """ + Load IATA codes from the IATA codes file + """ + with open(iata_codes_filename, 'r') as f_iata_codes: + result = [] + for line in f_iata_codes.readlines(): + result.append(line.strip()) + return set(result) + +LOCATION_ALIAS = load_aliases(ALIASES) +LOCATION_BLACK_LIST = [x.strip() for x in open(BLACKLIST, 'r').readlines()] +IATA_CODES = load_iata_codes(IATA_CODES_FILE) + +def is_location_blocked(location): + """ + Return True if this location is blocked + or False if it is allowed + """ + return location is not None and location.lower() in LOCATION_BLACK_LIST + + +def location_processing(location, ip_addr): + """ + """ + + # if location is starting with ~ + # or has non ascii symbols + # it should be handled like a search term (for geolocator) + override_location_name = None + full_address = None + hide_full_address = False + force_show_full_address = location is not None and location.startswith('~') + + # location ~ means that it should be detected automatically, + # and shown in the location line below the report + if location == '~': + location = None + + if location and location.lstrip('~ ').startswith('@'): + try: + location, country = get_location( + socket.gethostbyname( + location.lstrip('~ ')[1:])) + location = '~' + location + if country: + location += ", %s" % country + hide_full_address = not force_show_full_address + except: + location, country = NOT_FOUND_LOCATION, None + + query_source_location = get_location(ip_addr) + + country = None + if not location or location == 'MyLocation': + location = ip_addr + + if is_ip(location): + location, country = get_location(location) + + # here too + if location: + location = '~' + location + if country: + location += ", %s" % country + hide_full_address = not force_show_full_address + + if location and not location.startswith('~'): + tmp_location = location_canonical_name(location) + if tmp_location != location: + override_location_name = location + location = tmp_location + + # up to this point it is possible that the name + # contains some unicode symbols + # here we resolve them + if location is not None and not ascii_only(location): + location = "~" + location.lstrip('~ ') + + if location is not None and location.upper() in IATA_CODES: + location = '~%s' % location + + if location is not None and location.startswith('~'): + geolocation = geolocator(location_canonical_name(location[1:])) + if geolocation is not None: + override_location_name = location[1:].replace('+', ' ') + location = "%s,%s" % (geolocation['latitude'], geolocation['longitude']) + country = None + if not hide_full_address: + full_address = geolocation['address'] + else: + full_address = None + else: + location = NOT_FOUND_LOCATION #location[1:] + + return location, \ + override_location_name, \ + full_address, \ + country, \ + query_source_location diff --git a/lib/parse_query.py b/lib/parse_query.py index b422cc8..0cdf958 100644 --- a/lib/parse_query.py +++ b/lib/parse_query.py @@ -1,3 +1,29 @@ +def metric_or_imperial(query, lang, us_ip=False): + """ + """ + + # what units should be used + # metric or imperial + # based on query and location source (imperial for US by default) + if query.get('use_metric', False) and not query.get('use_imperial', False): + query['use_imperial'] = False + query['use_metric'] = True + elif query.get('use_imperial', False) and not query.get('use_metric', False): + query['use_imperial'] = True + query['use_metric'] = False + elif lang == 'us': + # slack uses m by default, to override it speciy us.wttr.in + query['use_imperial'] = True + query['use_metric'] = False + else: + if us_ip: + query['use_imperial'] = True + query['use_metric'] = False + else: + query['use_imperial'] = False + query['use_metric'] = True + + return query def parse_query(args): result = {} @@ -19,6 +45,8 @@ def parse_query(args): if q is None: return result + if 'A' in q: + result['force-ansi'] = True if 'n' in q: result['narrow'] = True if 'm' in q: @@ -46,6 +74,8 @@ def parse_query(args): result['no-caption'] = True if 'Q' in q: result['no-city'] = True + if 'F' in q: + result['no-follow-line'] = True for key, val in args.items(): if val == 'True': diff --git a/lib/translations.py b/lib/translations.py index 52c9739..79787e0 100644 --- a/lib/translations.py +++ b/lib/translations.py @@ -1,20 +1,31 @@ -# vim: set encoding=utf-8 +# vim: fileencoding=utf-8 + +""" +Translation of almost everything. +""" FULL_TRANSLATION = [ - "de", "nb", + "af", "da", "de", "el", "et", "fr", "fa", "hu", + "id", "it", "nb", "nl", "pl", "pt-br", "ro", "ru", + "uk" ] PARTIAL_TRANSLATION = [ - "az", "be", "bg", "bs", "ca", "cy", "cs", - "da", "el", "eo", "es", "et", "fi", "fr", - "hi", "hr", "hu", "hy", "is", "it", "ja", - "jv", "ka", "kk", "ko", "ky", "lt", "lv", - "mk", "ml", "nl", "nn", "pt", "pl", "ro", - "ru", "sk", "sl", "sr", "sr-lat", "sv", - "sw", "th", "tr", "uk", "uz", "vi", "zh", - "zu", + "az", "be", "bg", "bs", "ca", "cy", "cs", + "eo", "es", "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", "th", "tr", "te", "uz", "vi", + "zh", "zu", "he", ] +PROXY_LANGS = [ + "af", "az", "be", "bs", "ca", "cy", "el", "eo", + "et", "fa", "fy", "he", "hr", "hu", "hy", "id", "is", + "it", "ja", "kk", "lv", "mk", "nb", "nn", + "ro", "ru", "sl", "pt-br", "uk", "uz", +] SUPPORTED_LANGS = FULL_TRANSLATION + PARTIAL_TRANSLATION @@ -24,202 +35,302 @@ MESSAGE = { We were unable to find your location so we have brought you to Oymyakon, one of the coldest permanently inhabited locales on the planet. +""", + 'af': u""" +Ons kon nie u ligging opspoor nie +gevolglik het ons vir u na Oymyakon geneem, +een van die koudste permanent bewoonde plekke op aarde. +""", + 'be': u""" +Ваша месцазнаходжанне вызначыць не атрымалася, +таму мы пакажам вам надвор'е ў Аймяконе, +самым халодным населеным пункце на планеце. +Будзем спадзявацца, што ў вас сёння надвор'е лепей! +""", + 'bg':u""" +Не успяхме да открием вашето местоположение +така че ви доведохме в Оймякон, +едно от най-студените постоянно обитавани места на планетата. """, 'bs': u""" Nismo mogli pronaći vašu lokaciju, tako da smo te doveli do Oymyakon, jedan od najhladnijih stalno naseljena mjesta na planeti. -Nadamo se da ćete imati bolje vreme! +Nadamo se da ćete imati bolje vreme! """, 'ca': u""" Hem estat incapaços de trobar la seva ubicació, -és per aquest motiu que l'hem portat fins Oymyakon, -un dels llocs més freds inhabitats de manera permanent al planeta. +per això l'hem portat fins Oymyakon, +un dels llocs més freds i permanentment deshabitats del planeta. """, - 'cs': u""" Nepodařilo se nám najít vaši polohu, takže jsme vás přivedl do Ojmjakonu. Je to jedno z nejchladnějších trvale obydlených míst na planetě. Doufáme, že budete mít lepší počasí! """, - 'cy': u""" Ni darganfyddwyd eich lleoliad, felly rydym wedi dod â chi i Oymyakon, -un o'r llefydd oerach ar y blaned lle mae pobl yn fyw! +un o'r llefydd oeraf ar y blaned ble mae pobl yn dal i fyw! """, - 'de': u""" Wir konnten Ihren Standort nicht finden, also haben wir Sie nach Oimjakon gebracht, einer der kältesten dauerhaft bewohnten Orte auf dem Planeten. Wir hoffen, dass Sie besseres Wetter haben! -""", - +""", 'el': u""" Δεν μπορέσαμε να βρούμε την τοποθεσία σου, για αυτό διαλέξαμε το Οϊμιάκον για εσένα, μία από τις πιο κρύες μόνιμα κατοικημένες περιοχές στον πλανήτη. Ελπίζουμε να έχεις καλύτερο καιρό! """, - + 'es': u""" +No hemos logrado encontrar tu ubicación, +asi que hemos decidido enseñarte el tiempo en Oymyakon, +uno de los sitios más fríos y permanentemente deshabitados del planeta. +""", + 'fa': u""" +ما نتونستیم مکان شما رو پیدا کنیم. به همین خاطر شما رو به om بردیم +، یکی از سردترین مکان های روی زمین که اصلا قابل سکونت نیست! +""", 'fi': u""" Emme löytänyt sijaintiasi, joten toimme sinut Oimjakoniin, yhteen maailman kylmimmistä pysyvästi asutetuista paikoista. Toivottavasti sinulla on parempi sää! """, - 'fr': u""" Nous n'avons pas pu déterminer votre position, Nous vous avons donc amenés à Oïmiakon, l'un des endroits les plus froids habités en permanence sur la planète. Nous espérons qu'il fait meilleur chez vous ! """, - + 'hu': u""" +Nem sikerült megtalálni a pozíciódat, +így elhoztunk Ojmjakonba; +az egyik leghidegebb állandóan lakott településre a bolygón. +""", 'hy': u""" Ձեր գտնվելու վայրը չհաջողվեց որոշել, այդ պատճառով մենք ձեզ կցուցադրենք եղանակը Օյմյակոնում. երկրագնդի ամենասառը բնակավայրում։ Հույս ունենք որ ձեր եղանակը այսօր ավելի լավն է։ """, - + 'id': u""" +Kami tidak dapat menemukan lokasi anda, +jadi kami membawa anda ke Oymyakon, +salah satu tempat terdingin yang selalu dihuni di planet ini! +""", 'is': u""" Við finnum ekki staðsetninguna þína og vísum þér þar með á Ojmjakon, ein af köldustu byggðum jarðar. Vonandi er betra veður hjá þér. """, - + 'it': u""" +Non siamo riusciti a trovare la sua posizione +quindi la abbiamo portato a Oymyakon, +uno dei luoghi abitualmente abitati più freddi del pianeta. +Ci auguriamo che le condizioni dove lei si trova siano migliori! +""", 'ja': u""" 指定された場所が見つかりませんでした。 代わりにオイミャコンの天気予報を表示しています。 オイミャコンは地球上で最も寒い居住地の一つです。 """, - 'ko': u""" 지정된 장소를 찾을 수 없습니다, 대신 오이먀콘의 일기 예보를 표시합니다, 오이먀콘은 지구상에서 가장 추운 곳에 위치한 마을입니다! """, - + 'lv': u""" +Mēs nevarējām atrast jūsu atrašanās vietu tādēļ nogādājām jūs Oimjakonā, +vienā no aukstākajām apdzīvotajām vietām uz planētas. +""", 'mk': u""" Неможевме да ја пронајдеме вашата локација, затоа ве однесовме во Ојмајкон, еден од најладните трајно населени места на планетата. """, - 'nb': u""" Vi kunne ikke finne din lokasjon, så her får du Ojmjakon, et av de kaldeste bebodde stedene på planeten. Vi håper været er bedre hos deg! """, - + 'nl': u""" +Wij konden uw locatie niet vaststellen +dus hebben we u naar Ojmjakon gebracht, +één van de koudste permanent bewoonde gebieden op deze planeet. +""", + 'fy': u""" +Wy koenen jo lokaasje net fêststelle +dus wy ha jo nei Ojmjakon brocht, +ien fan de kâldste permanent bewenbere plakken op ierde. +""", + 'pt': u""" +Não conseguimos encontrar a sua localização, +então decidimos te mostrar o tempo em Oymyakon, +um dos lugares mais frios e permanentemente desabitados do planeta. +""", + 'pt-br': u""" +Não conseguimos encontrar a sua localização, +então decidimos te mostrar o tempo em Oymyakon, +um dos lugares mais frios e permanentemente desabitados do planeta. +""", + 'pl': u""" +Nie udało nam się znaleźć podanej przez Ciebie lokalizacji, +więc zabraliśmy Cię do Ojmiakonu, +jednego z najzimniejszych, stale zamieszkanych miejsc na Ziemi. +Mamy nadzieję, że u Ciebie jest cieplej! +""", 'ro': u""" -Nu v-am putut identifica locația, prin urmare va aratam vremea din Oimiakon, +Nu v-am putut identifica localitatea, prin urmare vă arătăm vremea din Oimiakon, una dintre cele mai reci localități permanent locuite de pe planetă. Sperăm că aveți vreme mai bună! """, - 'ru': u""" Ваше местоположение определить не удалось, поэтому мы покажем вам погоду в Оймяконе, самом холодном населённом пункте на планете. Будем надеяться, что у вас сегодня погода лучше! """, - 'sk': u""" Nepodarilo sa nám nájsť vašu polohu, takže sme vás priviedli do Ojmiakonu. Je to jedno z najchladnejších trvale obývaných miest na planéte. Dúfame, že budete mať lepšie počasie! """, - 'sr': u""" Нисмо успели да пронађемо Вашу локацију, па смо Вас довели у Ојмјакон, једно од најхладнијих стално насељених места на планети. Надамо се да је време код Вас боље него што је то случај овде! """, - 'sv': u""" Vi lyckades inte hitta er plats så vi har istället tagit er till Ojmjakon, en av planetens kallaste platser med permanent bosättning. Vi hoppas att vädret är bättre hos dig! """, - 'tr': u""" -Aradığınız bölge bulunamadı. O yüzden sizi dünyadaki en soğuk sürekli +Aradığınız konum bulunamadı. O yüzden sizi dünyadaki en soğuk sürekli yerleşim yerlerinden biri olan Oymyakon'e getirdik. Umarız sizin olduğunuz yerde havalar daha iyidir! """, - - 'uk': u""" -Ваше місце розташування визначити не вдалося, -тому ми покажемо вам погоду в Оймяконе, -найхолоднішому населеному пункті на планеті. -Будемо сподіватися, що у вас сьогодні погода краще! + 'te': u""" +మేము మీ స్థానాన్ని కనుగొనలేకపోయాము +కనుక మనం "ఓమాయకాన్కు" తీసుకొని వచ్చాము, +భూమిపై అత్యల్ప శాశ్వతంగా నివసించే స్థానిక ప్రదేశాలలో ఒకటి. +""", + 'uk': u""" +Ми не змогли визначити Ваше місцезнаходження, +тому покажемо Вам погоду в Оймяконі — +найхолоднішому населеному пункті на планеті. +Будемо сподіватися, що у Вас сьогодні погода краще! """, - 'uz': u""" Sizning joylashuvingizni aniqlay olmadik, shuning uchun sizga sayyoramizning eng sovuq aholi punkti - Oymyakondagi ob-havo haqida ma'lumot beramiz. Umid qilamizki, sizda bugungi ob-havo bundan yaxshiroq! -""" - - }, - +""", + 'da': u""" +Vi kunne desværre ikke finde din lokation +så vi har bragt dig til Oymyakon, +En af koldeste og helt ubolige lokationer på planeten. +""", + 'et': u""" +Me ei suutnud tuvastada teie asukohta +ning seetõttu paigutasime teid Oymyakoni, +mis on üks kõige külmemaid püsivalt asustatud paiku planeedil. +""", + }, 'UNKNOWN_LOCATION': { 'en': u'Unknown location', + 'af': u'Onbekende ligging', + 'be': u'Невядомае месцазнаходжанне', + 'bg': u'Неизвестно местоположение', 'bs': u'Nepoznatoja lokacija', - 'ca': u'Localització desconeguda', + 'ca': u'Ubicació desconeguda', 'cs': u'Neznámá poloha', 'cy': u'Lleoliad anhysbys', 'de': u'Unbekannter Ort', + 'da': u'Ukendt lokation', 'el': u'Άνγωστη τοποθεσία', + 'es': u'Ubicación desconocida', + 'et': u'Tundmatu asukoht', + 'fa': u'مکان نامعلوم', 'fi': u'Tuntematon sijainti', 'fr': u'Emplacement inconnu', + 'hu': u'Ismeretlen lokáció', 'hy': u'Անհայտ գտնվելու վայր', + 'id': u'Lokasi tidak diketahui', 'is': u'Óþekkt staðsetning', + 'it': u'Località sconosciuta', 'ja': u'未知の場所です', 'ko': u'알 수 없는 장소', + 'kk': u'', + 'lv': u'Nezināma atrašanās vieta', 'mk': u'Непозната локација', 'nb': u'Ukjent sted', - 'ro': u'Locaţie necunoscută', + 'nl': u'Onbekende locatie', + 'fy': u'Ûnbekende lokaasje', + 'pl': u'Nieznana lokalizacja', + 'pt': u'Localização desconhecida', + 'pt-br': u'Localização desconhecida', + 'ro': u'Localitate necunoscută', 'ru': u'Неизвестное местоположение', 'sk': u'Neznáma poloha', 'sl': u'Neznano lokacijo', 'sr': u'Непозната локација', 'sv': u'Okänd plats', - 'tr': u'Bölge bulunamadı', - 'ua': u'Невідоме місце', + 'te': u'తెలియని ప్రదేశం', + 'tr': u'Bilinmeyen konum', + 'uk': u'Невідоме місце', 'uz': u'Аникланмаган худуд', }, 'LOCATION': { 'en': u'Location', + 'af': u'Ligging', + 'be': u'Месцазнаходжанне', + 'bg': u'Местоположение', 'bs': u'Lokacija', - 'ca': u'Localització', + 'ca': u'Ubicació', 'cs': u'Poloha', 'cy': u'Lleoliad', 'de': u'Ort', + 'da': u'Lokation', 'el': u'Τοποθεσία', + 'es': u'Ubicación', + 'et': u'Asukoht', + 'fa': u'مکان', 'fi': u'Tuntematon sijainti', 'fr': u'Emplacement', + 'hu': u'Lokáció', 'hy': u'Դիրք', + 'id': u'Lokasi', 'is': u'Staðsetning', + 'it': u'Località', 'ja': u'位置情報', 'ko': u'위치', + 'kk': u'', + 'lv': u'Atrašanās vieta', 'mk': u'Локација', 'nb': u'Sted', - 'ro': u'Locaţie', + 'nl': u'Locatie', + 'fy': u'Lokaasje', + 'pl': u'Lokalizacja', + 'pt': u'Localização', + 'pt-br': u'Localização', + 'ro': u'Localitate', 'ru': u'Местоположение', 'sk': u'Poloha', 'sl': u'Lokacijo', 'sr': u'Локација', 'sv': u'Plats', - 'tr': u'Bölge bulunamadı', - 'ua': u'Місце', + 'te': u'స్థానము', + 'tr': u'Konum', + 'uk': u'Місцезнаходження' }, 'CAPACITY_LIMIT_REACHED': { @@ -229,6 +340,26 @@ Here is the weather report for the default city (just to show you, how it looks We will get new queries as soon as possible. You can follow https://twitter.com/igor_chubin for the updates. ====================================================================================== +""", + 'af': u""" +Verskoning, ons oorskry tans die vermoë om navrae aan die weerdiens te rig. +Hier is die weerberig van 'n voorbeeld ligging (bloot om aan u te wys hoe dit lyk). +Ons sal weereens nuwe navrae kan hanteer so gou as moontlik. +U kan vir https://twitter.com/igor_chubin volg vir opdaterings. +====================================================================================== +""", + 'be': u""" +Прабачце, мы выйшлі за ліміты колькасці запытаў да службы надвор'я ў дадзены момант. +Вось прагноз надвор'я для горада па змаўчанні (толькі, каб паказаць вам, як гэта выглядае). +Мы вернемся як мага хутчэй. +Вы можаце сачыць на https://twitter.com/igor_chubin за абнаўленнямі. +====================================================================================== +""", + 'bg': u""" +Съжаляваме, количеството заявки към услугата за предсказване на време е почти изчерпано. +Ето доклад за града по подразбиране (просто да видите как изглежда). +Ще осогурим допълнителни заявки максимално бързо. +Може да последвате https://twitter.com/igor_chubin за обновления. """, 'bs': u""" Žao mi je, mi ponestaje upita i vremenska prognoza u ovom trenutku. @@ -238,24 +369,10 @@ Možete pratiti https://twitter.com/igor_chubin za ažuriranja. ====================================================================================== """, 'ca': u""" -Disculpi'ns, ens hem quedat sense consultes al servei meteorològic momentàniament. -Aquí li oferim l'informe del temps a la ciutat per defecte (només per mostrar, quin aspecte té). +Disculpa'ns, ens hem quedat momentàniament sense consultes al servei meteorològic. +Aquí t'oferim l'informe del temps a la ciutat per defecte (només per mostrar quin aspecte té). Obtindrem noves consultes tan aviat com ens sigui possible. -Pot seguir https://twitter.com/igor_chubin per noves actualitzacions. -====================================================================================== -""", - 'fr': u""" -Désolé, nous avons épuisé les requêtes vers le service météo. -Voici un bulletin météo de l'emplacement par défaut (pour vous donner un aperçu). -Nous serons très bientôt en mesure de faire de nouvelles requêtes. -Vous pouvez suivre https://twitter.com/igor_chubin pour rester informé. -====================================================================================== -""", - 'mk': u""" -Извинете, ни снемуваат барања за до сервисот кој ни нуди временска прогноза во моментот. -Еве една временска прогноза за град (за да видите како изгледа). -Ќе добиеме нови барања најбрзо што можеме. -Следете го https://twitter.com/igor_chubin за известувања +Pots seguir https://twitter.com/igor_chubin per noves actualitzacions. ====================================================================================== """, 'de': u""" @@ -264,6 +381,41 @@ Dafür zeigen wir Ihnen das Wetter an einem Beispielort, damit Sie sehen wie die Wir werden versuchen das Problem so schnell wie möglich zu beheben. Folgen Sie https://twitter.com/igor_chubin für Updates. ====================================================================================== +""", + 'cy': u""" +Rydym yn brin o ymholiadau i'r gwasanaeth tywydd ar hyn o bryd. +Felly dyma'r adroddiad tywydd ar gyfer y ddinas ragosod (er mwyn arddangos sut mae'n edrych). +Byddwn gyda ymholiadau newydd yn fuan. +Gellir dilyn https://twitter.com/igor_chubin i gael newyddion pellach. +====================================================================================== +""", + 'es': u""" +Lo siento, hemos alcanzado el límite de peticiones al servicio de previsión del tiempo en este momento. +A continuación, la previsión del tiempo para una ciudad estándar (solo para que puedas ver que aspecto tiene el informe). +Muy pronto volveremos a tener acceso a las peticiones. +Puedes seguir https://twitter.com/igor_chubin para estar al tanto de la situación. +====================================================================================== +""", + 'fa': u""" +متأسفانه در حال حاضر ظرفیت ما برای درخواست به سرویس هواشناسی به اتمام رسیده. +اینجا می تونید گزارش هواشناسی برای شهر پیش فرض رو ببینید (فقط برای اینه که بهتون نشون بدیم چه شکلی هست) +ما تلاش میکنیم در اسرع وقت ظرفیت جدید به دست بیاریم. +برای دنبال کردن اخبار جدید میتونید https://twitter.com/igor_chubin رو فالو کنید. +====================================================================================== +""", + 'fr': u""" +Désolé, nous avons épuisé les requêtes vers le service météo. +Voici un bulletin météo de l'emplacement par défaut (pour vous donner un aperçu). +Nous serons très bientôt en mesure de faire de nouvelles requêtes. +Vous pouvez suivre https://twitter.com/igor_chubin pour rester informé. +====================================================================================== +""", + 'hu': u""" +Sajnáljuk, kifogytunk az időjárási szolgáltatásra fordított erőforrásokból. +Itt van az alapértelmezett város időjárási jelentése (hogy lásd, hogyan néz ki). +A lehető leghamarabb új erőforrásokat fogunk kapni. +A frissítésekért tekintsd meg a https://twitter.com/igor_chubin oldalt. +====================================================================================== """, 'hy': u""" Կներեք, այս պահին մենք գերազանցել ենք եղանակային տեսության կայանին հարցումների քանակը. @@ -271,6 +423,20 @@ Folgen Sie https://twitter.com/igor_chubin für Updates. Մենք մշտապես աշխատում ենք հարցումների քանակը բարելավելու ուղղությամբ: Կարող եք հետևել մեզ https://twitter.com/igor_chubin թարմացումների համար. ====================================================================================== +""", + 'id': u""" +Maaf, kami kehabian permintaan ke layanan cuaca saat ini. +Ini adalah laporan cuaca dari kota standar (hanya untuk menunjukkan kepada anda bagaimana tampilannya). +Kami akan mencoba permintaan baru lagi sesegera mungkin. +Anda dapat mengikuti https://twitter.com/igor_chubin untuk informasi terbaru. +====================================================================================== +""", + 'it': u""" +Scusate, attualmente stiamo esaurendo le risorse a disposizione del servizio meteo. +Qui trovate il bollettino del tempo per la città di default (solo per mostrarvi come si presenta). +Potremo elaborare nuove richieste appena possibile. +Potete seguire https://twitter.com/igor_chubin per gli aggiornamenti. +====================================================================================== """, 'ko': u""" 죄송합니다. 현재 날씨 정보를 가져오는 쿼리 요청이 한도에 도달했습니다. @@ -278,12 +444,19 @@ Folgen Sie https://twitter.com/igor_chubin für Updates. 쿼리 요청이 가능한 한 빨리 이루어질 수 있도록 하겠습니다. 업데이트 소식을 원하신다면 https://twitter.com/igor_chubin 을 팔로우 해주세요. ====================================================================================== +""", + 'lv': u""" +Atvainojiet, uz doto brīdi mēs esam mazliet noslogoti. +Šeit ir laika ziņas noklusējuma pilsētai (lai parādītu jums, kā izskatās izveidotais ziņojums). +Mēs atsāksim darbu cik ātri vien varēsim. +Jūs varat sekot https://twitter.com/igor_chubin lai redzētu visus jaunumus. +====================================================================================== """, 'mk': u""" Извинете, ни снемуваат барања за до сервисот кој ни нуди временска прогноза во моментот. Еве една временска прогноза за град (за да видите како изгледа). Ќе добиеме нови барања најбрзо што можеме. -Следете го https://twitter.com/igor_chubin за известувања. +Следете го https://twitter.com/igor_chubin за известувања ====================================================================================== """, 'nb': u""" @@ -293,39 +466,158 @@ Vi vil forsøke å fikse problemet så snart som mulig. Du kan følge https://twitter.com/igor_chubin for oppdateringer. ====================================================================================== """, - + 'nl': u""" +Excuse, wij kunnen u op dit moment dit weerbericht niet laten zien. +Hier is het weerbericht voor de standaard stad(zodat u weet hoe het er uitziet) +Wij lossen dit probleem zo snel mogelijk op. +voor updates kunt u ons op https://twitter.com/igor_chubin volgen. +====================================================================================== +""", + 'fy': u""" +Excuses, wy kinne op dit moment 't waarberjocht net sjin litte. +Hjir is 't waarberjocht foar de standaard stêd. +Wy losse dit probleem sa gau mooglik op. +Foar updates kinne jo ús op https://twitter.com/igor_chubin folgje. +====================================================================================== +""", + 'pl': u""" +Bardzo nam przykro, ale chwilowo wykorzystaliśmy limit zapytań do serwisu pogodowego. +To, co widzisz jest przykładowym raportem pogodowym dla domyślnego miasta. +Postaramy się przywrócić funkcjonalność tak szybko, jak to tylko możliwe. +Możesz śledzić https://twitter.com/igor_chubin na Twitterze, aby być na bieżąco. +====================================================================================== +""", + 'pt': u""" +Desculpe-nos, estamos atingindo o limite de consultas ao serviço de previsão do tempo neste momento. +Veja a seguir a previsão do tempo para uma cidade padrão (apenas para você ver que aspecto o relatório tem). +Em breve voltaremos a ter acesso às consultas. +Você pode seguir https://twitter.com/igor_chubin para acompanhar a situação. +====================================================================================== +""", + 'pt-br': u""" +Desculpe-nos, atingimos o limite de consultas ao serviço de previsão do tempo neste momento. +Veja a seguir a previsão do tempo para uma cidade padrão (apenas para você ver que aspecto o relatório tem). +Em breve voltaremos a ter acesso às consultas. +Você pode seguir https://twitter.com/igor_chubin para acompanhar a situação. +====================================================================================== +""", + 'ro': u""" +Ne pare rău, momentan am epuizat cererile alocate de către serviciul de prognoză meteo. +Vă arătăm prognoza meteo pentru localitatea implicită (ca exemplu, să vedeți cum arată). +Vom obține alocarea de cereri noi cât de curând posibil. +Puteți urmări https://twitter.com/igor_chubin pentru actualizări. +====================================================================================== +""", + 'te': u""" +క్షమించండి, ప్రస్తుతానికి మేము వాతావరణ సేవకు ప్రశ్నలను గడుపుతున్నాం. +ఇక్కడ డిఫాల్ట్ నగరం కోసం వాతావరణ నివేదిక (కేవలం మీకు చూపించడానికి, ఇది ఎలా కనిపిస్తుంది). +సాధ్యమైనంత త్వరలో కొత్త ప్రశ్నలను పొందుతారు. +నవీకరణల కోసం https://twitter.com/igor_chubin ను మీరు అనుసరించవచ్చు. +====================================================================================== +""", + 'tr': u""" +Üzgünüz, an itibariyle hava durumu servisine yapabileceğimiz sorgu limitine ulaştık. +Varsayılan şehir için hava durumu bilgisini görüyorsunuz (neye benzediğini gösterebilmek için). +Mümkün olan en kısa sürede servise yeniden sorgu yapmaya başlayacağız. +Gelişmeler için https://twitter.com/igor_chubin adresini takip edebilirsiniz. +====================================================================================== +""", + 'da': u""" +Beklager, men vi er ved at løbe tør for forespørgsler til vejr-servicen lige nu. +Her er vejr rapporten for standard byen (bare så du ved hvordan det kan se ud). +Vi får nye forespørsler hurtigst muligt. +Du kan følge https://twitter.com/igor_chubin for at få opdateringer. +====================================================================================== +""", + 'et': u""" +Vabandage, kuid hetkel on päringud ilmateenusele piiratud. +Selle asemel kuvame hetkel näidislinna ilmaprognoosi (näitamaks, kuidas see välja näeb). +Üritame probleemi lahendada niipea kui võimalik. +Jälgige https://twitter.com/igor_chubin värskenduste jaoks. +====================================================================================== +""", + 'uk': u""" +Вибачте, ми перевищили максимальну кількість запитів до сервісу погоди. +Ось прогноз погоди у нашому місті (просто показати Вам як це виглядає). +Ми відновимо роботу як тільки зможемо. +Ви можете підписатися на https://twitter.com/igor_chubin для отримання новин. +====================================================================================== +""" }, - #'Check new Feature: \033[92mwttr.in/Moon\033[0m or \033[92mwttr.in/Moon@2016-Mar-23\033[0m to see the phase of the Moon' - #'New feature: \033[92mwttr.in/Rome?lang=it\033[0m or \033[92mcurl -H "Accept-Language: it" wttr.in/Rome\033[0m for the localized version. Your lang instead of "it"' + # Historical messages: + # 'Check new Feature: \033[92mwttr.in/Moon\033[0m or \033[92mwttr.in/Moon@2016-Mar-23\033[0m to see the phase of the Moon' + # 'New feature: \033[92mwttr.in/Rome?lang=it\033[0m or \033[92mcurl -H "Accept-Language: it" wttr.in/Rome\033[0m for the localized version. Your lang instead of "it"' 'NEW_FEATURE': { 'en': u'New feature: multilingual location names \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) and location search \033[92mwttr.in/~Kilimanjaro\033[0m (just add ~ before)', - 'bs': u'XXXXXXXXXXXXXXXXXXXX: XXXXXXXXXXXXXXXXXXXXXXXXXXXXX\ 033[92mwttr.in/станция+Восток\033 [0m (XX UTF-8) XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', - 'ca': u'Noves funcionalitats: noms d\'ubicació multilingües \033[92mwttr.in/станция+Восток\033 [0m (en UTF-8) i la ubicació de recerca \ 033 [92mwttr.in/~Kilimanjaro\033 [0m (només cal afegir ~ abans)', - 'fr': u'Nouvelles fonctionnalités: noms d\'emplacements multilingues \033[92mwttr.in/станция+Восток\033 [0m (en UTF-8) et recherche d\'emplacement \ 033 [92mwttr.in/~Kilimanjaro\033 [0m (ajouter ~ devant)', + 'af': u'Nuwe eienskap: veeltalige name vir liggings \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) en ligging soek \033[92mwttr.in/~Kilimanjaro\033[0m (plaas net ~ vooraan)', + 'be': u'Новыя магчымасці: назвы месц на любой мове \033[92mwttr.in/станция+Восток\033[0m (в UTF-8) i пошук месц \033[92mwttr.in/~Kilimanjaro\033[0m (трэба дадаць ~ ў пачатак)', + 'bg': u'Нова функционалност: многоезични имена на места\033[92mwttr.in/станция+Восток\033[0m (в UTF-8) и в търсенето \033[92mwttr.in/~Kilimanjaro\033[0m (добавете ~ преди)', + 'bs': u'XXXXXXXXXXXXXXXXXXXX: XXXXXXXXXXXXXXXXXXXXXXXXXXXXX\033[92mwttr.in/станция+Восток\033[0m (XX UTF-8) XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + 'ca': u'Noves funcionalitats: noms d\'ubicació multilingües \033[92mwttr.in/станция+Восток\033[0m (en UTF-8) i la ubicació de recerca \033[92mwttr.in/~Kilimanjaro\033[0m (només cal afegir ~ abans)', + 'es': u'Nuevas funcionalidades: los nombres de las ubicaciones en vários idiomas \033[92mwttr.in/станция+Восток\033[0m (em UTF-8) y la búsqueda por ubicaciones \033[92mwttr.in/~Kilimanjaro\033[0m (tan solo inserte ~ en frente)', + 'fa': u'قابلیت جدید: پشتیبانی از نام چند زبانه مکانها \033[92mwttr.in/станция+Восток\033[0m (در فرمت UTF-8) و جسجتوی مکان ها \033[92mwttr.in/~Kilimanjaro\033[0m (فقط قبل از اون ~ اضافه کنید)', + 'fr': u'Nouvelles fonctionnalités: noms d\'emplacements multilingues \033[92mwttr.in/станция+Восток\033[0m (en UTF-8) et recherche d\'emplacement \033[92mwttr.in/~Kilimanjaro\033[0m (ajouter ~ devant)', 'mk': u'Нова функција: повеќе јазично локациски имиња \033[92mwttr.in/станция+Восток\033[0m (во UTF-8) и локациско пребарување \033[92mwttr.in/~Kilimanjaro\033[0m (just add ~ before)', 'nb': u'Ny funksjon: flerspråklige stedsnavn \033[92mwttr.in/станция+Восток\033[0m (i UTF-8) og lokasjonssøk \033[92mwttr.in/~Kilimanjaro\033[0m (bare legg til ~ foran)', - 'cy': u'Nodwedd newydd: enwau lleoliad amlieithog \033[92mwttr.in/станция+Восток\033[0m (yn UTF-8) a chwilio lleoliad \033[92mwttr.in/~Kilimanjaro\033[0m (ychwanegwch ~ yn gyntaf)', + 'nl': u'Nieuwe functie: tweetalige locatie namen \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) en locatie zoeken \033[92mwttr.in/~Kilimanjaro\033[0m (zet er gewoon een ~ voor)', + 'fy': u'Nije funksje: twatalige lokaasje nammen \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) en lokaasje sykjen \033[92mwttr.in/~Kilimanjaro\033[0m (set er gewoan in ~ foar)', + 'cy': u'Nodwedd newydd: enwau lleoliadau amlieithog \033[92mwttr.in/станция+Восток\033[0m (yn UTF-8) a chwilio am leoliad \033[92mwttr.in/~Kilimanjaro\033[0m (ychwanegwch ~ yn gyntaf)', 'de': u'Neue Funktion: mehrsprachige Ortsnamen \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) und Ortssuche \033[92mwttr.in/~Kilimanjaro\033[0m (fügen Sie ein ~ vor dem Ort ein)', + 'hu': u'Új funkcinalitás: többnyelvű helynevek \033[92mwttr.in/станция+Восток\033[0m (UTF-8-ban) és pozíció keresés \033[92mwttr.in/~Kilimanjaro\033[0m (csak adj egy ~ jelet elé)', 'hy': u'Փորձարկեք: տեղամասերի անունները կամայական լեզվով \033[92mwttr.in/Դիլիջան\033[0m (в UTF-8) և տեղանքի որոնում \033[92mwttr.in/~Kilimanjaro\033[0m (հարկավոր է ~ ավելացնել դիմացից)', + 'id': u'Fitur baru: nama lokasi dalam multibahasa \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) dan pencarian lokasi \033[92mwttr.in/~Kilimanjaro\033[0m (hanya tambah tanda ~ sebelumnya)', + 'it': u'Nuove funzionalità: nomi delle località multilingue \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) e ricerca della località \033[92mwttr.in/~Kilimanjaro\033[0m (basta premettere ~)', 'ko': u'새로운 기능: 다국어로 대응된 위치 \033[92mwttr.in/서울\033[0m (UTF-8에서) 장소 검색 \033[92mwttr.in/~Kilimanjaro\033[0m (앞에 ~를 붙이세요)', + 'kk': u'', + 'lv': u'Jaunums: Daudzvalodu atrašanās vietu nosaukumi \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) un dabas objektu meklēšana \033[92mwttr.in/~Kilimanjaro\033[0m (tikai priekšā pievieno ~)', 'mk': u'Нова функција: повеќе јазично локациски имиња \033[92mwttr.in/станция+Восток\033[0m (во UTF-8) и локациско пребарување \033[92mwttr.in/~Kilimanjaro\033[0m (just add ~ before)', + 'pl': u'Nowa funkcjonalność: wielojęzyczne nazwy lokalizacji \033[92mwttr.in/станция+Восток\033[0m (w UTF-8) i szukanie lokalizacji \033[92mwttr.in/~Kilimanjaro\033[0m (poprzedź zapytanie ~ - znakiem tyldy)', + 'pt': u'Nova funcionalidade: nomes de localidades em várias línguas \033[92mwttr.in/станция+Восток\033[0m (em UTF-8) e procura por localidades \033[92mwttr.in/~Kilimanjaro\033[0m (é só colocar ~ antes)', + 'pt-br': u'Nova funcionalidade: nomes de localidades em várias línguas \033[92mwttr.in/станция+Восток\033[0m (em UTF-8) e procura por localidades \033[92mwttr.in/~Kilimanjaro\033[0m (é só colocar ~ antes)', + 'ro': u'Funcționalitate nouă: nume de localități multilingve \033[92mwttr.in/станция+Восток\033[0m (in UTF-8) și căutare de localități \033[92mwttr.in/~Kilimanjaro\033[0m (adăuați ~ în față)', 'ru': u'Попробуйте: названия мест на любом языке \033[92mwttr.in/станция+Восток\033[0m (в UTF-8) и поиск мест \033[92mwttr.in/~Kilimanjaro\033[0m (нужно добавить ~ спереди)', + 'tr': u'Yeni özellik: çok dilli konum isimleri \033[92mwttr.in/станция+Восток\033[0m (UTF-8 ile) ve konum arama \033[92mwttr.in/~Kilimanjaro\033[0m (sadece önüne ~ ekleyin)', + 'te': u'క్రొత్త లక్షణం: బహుభాషా స్థాన పేర్లు \ 033 [92mwttr.in/stancelя+Vostок\033 [0 U (UTF-8 లో) మరియు స్థానం శోధన \ 033 [92mwttr.in/~kilimanjaro\033 [0m (కేవలం ~ ముందుకి జోడించండి)', + 'da': u'Ny funktion: flersprogede lokationsnavne \033[92mwttr.in/станция+Восток\033[0m (som UTF-8) og lokations søgning \033[92mwttr.in/~Kilimanjaro\033[0m (bare tilføj ~ inden)', + 'et': u'Uus funktsioon: mitmekeelsed asukohanimed \033[92mwttr.in/станция+Восток\033[0m (UTF-8 vormingus) ja asukoha otsing \033[92mwttr.in/~Kilimanjaro\033[0m (lisa ~ enne)', + 'uk': u'Спробуйте: назви місць будь-якою мовою \033[92mwttr.in/станція+Восток\033[0m (в UTF-8) та пошук місць \033[92mwttr.in/~Kilimanjaro\033[0m (потрібно додати ~ спочатку)' }, 'FOLLOW_ME': { 'en': u'Follow \033[46m\033[30m@igor_chubin\033[0m for wttr.in updates', - 'bs': u'XXXXXX \033[46m\033[30m@igor_chubin\033[0m XXXXXXXXXXXXXXXXXXX', - 'ca': u'Seguiu \033[46m\033[30m@igor_chubin\033[0m per actualitzacions de wttr.in', - 'cy': u'Dilyn \033[46m\033[30m@igor_Chubin\033[0m am diweddariadau wttr.in', + 'af': u'Volg \033[46m\033[30m@igor_chubin\033[0m vir wttr.in opdaterings', + 'be': u'Сачыце за \033[46m\033[30m@igor_chubin\033[0m за навінамі wttr.in', + 'bg': u'Последвай \033[46m\033[30m@igor_chubin\033[0m за обновления свързани с wttr.in', + 'bs': u'XXXXXX \033[46m\033[30m@igor_chubin\033[0m XXXXXXXXXXXXXXXXXXX', + 'ca': u'Segueix \033[46m\033[30m@igor_chubin\033[0m per actualitzacions de wttr.in', + 'es': u'Seguir \033[46m\033[30m@igor_chubin\033[0m para recibir las novedades de wttr.in', + 'cy': u'Dilyner \033[46m\033[30m@igor_Chubin\033[0m am diweddariadau wttr.in', + 'fa': u'برای دنبال کردن خبرهای wttr.in شناسه \033[46m\033[30m@igor_chubin\033[0m رو فالو کنید.', 'fr': u'Suivez \033[46m\033[30m@igor_Chubin\033[0m pour rester informé sur wttr.in', 'de': u'Folgen Sie \033[46m\033[30mhttps://twitter.com/igor_chubin\033[0m für wttr.in Updates', + 'hu': u'Kövesd \033[46m\033[30m@igor_chubin\033[0m-t további wttr.in információkért', 'hy': u'Նոր ֆիչռների համար հետևեք՝ \033[46m\033[30m@igor_chubin\033[0m', + 'id': u'Ikuti \033[46m\033[30m@igor_chubin\033[0m untuk informasi wttr.in terbaru', + 'it': u'Seguite \033[46m\033[30m@igor_chubin\033[0m per aggiornamenti a wttr.in', 'ko': u'wttr.in의 업데이트 소식을 원하신다면 \033[46m\033[30m@igor_chubin\033[0m 을 팔로우 해주세요', + 'kk': u'', + 'lv': u'Seko \033[46m\033[30m@igor_chubin\033[0m , lai uzzinātu wttr.in jaunumus', 'mk': u'Следете \033[46m\033[30m@igor_chubin\033[0m за wttr.in новости', 'nb': u'Følg \033[46m\033[30m@igor_chubin\033[0m for wttr.in oppdateringer', + 'nl': u'Volg \033[46m\033[30m@igor_chubin\033[0m voor wttr.in updates', + 'fy': u'Folgje \033[46m\033[30m@igor_chubin\033[0m foar wttr.in updates', + 'pl': u'Śledź \033[46m\033[30m@igor_chubin\033[0m aby być na bieżąco z nowościami dotyczącymi wttr.in', + 'pt': u'Seguir \033[46m\033[30m@igor_chubin\033[0m para as novidades de wttr.in', + 'pt-br': u'Seguir \033[46m\033[30m@igor_chubin\033[0m para as novidades de wttr.in', + 'ro': u'Urmăriți \033[46m\033[30m@igor_chubin\033[0m pentru actualizări despre wttr.in', 'ru': u'Все новые фичи публикуются здесь: \033[46m\033[30m@igor_chubin\033[0m', + 'te': u'అనుసరించండి \ 033 [46m \ 033 [30m @ igor_chubin \ 033 [wttr.in నవీకరణలను కోసం', + 'tr': u'wttr.in ile ilgili gelişmeler için \033[46m\033[30m@igor_chubin\033[0m adresini takip edin', + 'da': u'Følg \033[46m\033[30m@igor_chubin\033[0m for at få wttr.in opdateringer', + 'et': u'Jälgi \033[46m\033[30m@igor_chubin\033[0m wttr.in uudiste tarbeks', + 'uk': u'Нові можливості wttr.in публікуються тут: \033[46m\033[30m@igor_chubin\033[0m' }, } diff --git a/lib/unicodedata2.py b/lib/unicodedata2.py index 8d65283..ed9070e 100644 --- a/lib/unicodedata2.py +++ b/lib/unicodedata2.py @@ -1,3 +1,4 @@ +from __future__ import print_function from unicodedata import * script_data = { @@ -599,7 +600,7 @@ def _compile_scripts_txt(): idx.append((int(a, 16), int(b or a, 16), names.index(name), cats.index(cat))) idx.sort() - print 'script_data = {\n"names":%s,\n"cats":%s,\n"idx":[\n%s\n]}' % ( + print('script_data = {\n"names":%s,\n"cats":%s,\n"idx":[\n%s\n]}' % ( '\n'.join(textwrap.wrap(repr(names), 80)), '\n'.join(textwrap.wrap(repr(cats), 80)), - '\n'.join(textwrap.wrap(', '.join('(0x%x,0x%x,%d,%d)' % c for c in idx), 80))) + '\n'.join(textwrap.wrap(', '.join('(0x%x,0x%x,%d,%d)' % c for c in idx), 80)))) diff --git a/lib/weather_data.py b/lib/weather_data.py new file mode 100644 index 0000000..72fd3eb --- /dev/null +++ b/lib/weather_data.py @@ -0,0 +1,24 @@ +""" +Weather data source +""" + +import json +import requests +from globals import WWO_KEY + +def get_weather_data(location, lang): + """ + Get weather data for `location` + """ + key = WWO_KEY + url = ('/premium/v1/weather.ashx' + '?key=%s&q=%s&format=json' + '&num_of_days=3&tp=3&lang=%s') % (key, location, lang) + url = 'http://127.0.0.1:5001' + url + + response = requests.get(url, timeout=1) + try: + data = json.loads(response.content) + except ValueError: + data = {} + return data diff --git a/lib/wttr.py b/lib/wttr.py index 1593651..b215b1a 100644 --- a/lib/wttr.py +++ b/lib/wttr.py @@ -1,7 +1,8 @@ # vim: set encoding=utf-8 +from __future__ import print_function import gevent -from gevent.wsgi import WSGIServer +from gevent.pywsgi import WSGIServer from gevent.queue import Queue from gevent.monkey import patch_all from gevent.subprocess import Popen, PIPE, STDOUT @@ -10,18 +11,53 @@ patch_all() import os import re import time -import dateutil +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, \ + NOT_FOUND_LOCATION, DEFAULT_LOCATION, TEST_FILE, \ log, error def _is_invalid_location(location): if '.png' in location: return True -def get_wetter(location, ip, html=False, lang=None, query=None, location_name=None, full_address=None): +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('/', '_') @@ -45,20 +81,22 @@ def get_wetter(location, ip, html=False, lang=None, query=None, location_name=No 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): - ansi_escape = re.compile(r'(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]') - def remove_ansi(sometext): - return ansi_escape.sub('', sometext) 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 - else: - location_not_found = False cmd = [WEGO, '--city=%s' % location] @@ -83,7 +121,7 @@ def get_wetter(location, ip, html=False, lang=None, query=None, location_name=No p = Popen(cmd, stdout=PIPE, stderr=PIPE) stdout, stderr = p.communicate() if p.returncode != 0: - print "ERROR: location not found: %s" % location + 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) @@ -128,8 +166,13 @@ def get_wetter(location, ip, html=False, lang=None, query=None, location_name=No if query.get('no-city', False): stdout = "\n".join(stdout.splitlines()[2:]) + "\n" - if full_address: - line = "%s: %s [%s]\n" % (get_message('LOCATION', lang).encode('utf-8'), full_address.encode('utf-8'), location) + if full_address \ + and query.get('format', 'txt') != 'png' \ + and (not query.get('no-city') and not query.get('no-caption')): + 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): @@ -153,7 +196,8 @@ def get_wetter(location, ip, html=False, lang=None, query=None, location_name=No stdout = stdout.replace('', '') title = "%s" % first.encode('utf-8') - stdout = re.sub("", "" + title, stdout) + 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) @@ -164,7 +208,10 @@ def get_wetter(location, ip, html=False, lang=None, query=None, location_name=No return open(filename).read() -def get_moon(location, html=False, lang=None): +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:] @@ -174,18 +221,20 @@ def get_moon(location, html=False, lang=None): if date: try: dateutil.parser.parse(date) - except: - pass + except Exception as e: + print("ERROR: %s" % e) else: cmd += [date] env = os.environ.copy() if lang: env['LANG'] = lang - print cmd 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) diff --git a/lib/wttr_line.py b/lib/wttr_line.py new file mode 100644 index 0000000..e189087 --- /dev/null +++ b/lib/wttr_line.py @@ -0,0 +1,259 @@ +#vim: fileencoding=utf-8 + +""" +One-line output mode. + +Initial implementation of one-line output mode. + +[ ] forecast +[ ] spark +[ ] several locations +[ ] location handling +[ ] more preconfigured format lines +[ ] add information about this mode to /:help +""" + +import sys +import re +import datetime +from astral import Astral, Location +from constants import WWO_CODE, WEATHER_SYMBOL, WIND_DIRECTION +from weather_data import get_weather_data + +PRECONFIGURED_FORMAT = { + '1': u'%c %t', + '2': u'%c 🌡️%t 🌬️%w', + '3': u'%l: %c %t', + '4': u'%l: %c 🌡️%t 🌬️%w', +} + +MOON_PHASES = ( + u"🌑", u"🌒", u"🌓", u"🌔", u"🌕", u"🌖", u"🌗", u"🌘" +) + +def convert_to_fahrenheit(temp): + "Convert Celcius `temp` to Fahrenheit" + + return (temp*9.0/5)+32 + +def render_temperature(data, query): + """ + temperature (t) + """ + + if query.get('use_imperial', False): + temperature = u'%s°F' % data['temp_F'] + else: + temperature = u'%s°C' % data['temp_C'] + + if temperature[0] != '-': + temperature = '+' + temperature + + return temperature + +def render_condition(data, query): + """ + condition (c) + """ + + weather_condition = WEATHER_SYMBOL[WWO_CODE[data['weatherCode']]] + return weather_condition + +def render_condition_fullname(data, query): + """ + condition_fullname (C) + """ + + found = None + for key, val in data.items(): + if key.startswith('lang_'): + found = val + break + if not found: + found = data['weatherDesc'] + + try: + weather_condition = found[0]['value'] + except KeyError: + weather_condition = '' + + return weather_condition + +def render_humidity(data, query): + """ + humidity (h) + """ + + humidity = data.get('humidity', '') + if humidity: + humidity += '%' + return humidity + +def render_precipitation(data, query): + """ + precipitation (p) + """ + + answer = data.get('precipMM', '') + if answer: + answer += 'mm' + return answer + +def render_pressure(data, query): + """ + pressure (P) + """ + + answer = data.get('pressure', '') + if answer: + answer += 'hPa' + return answer + +def render_wind(data, query): + """ + wind (w) + """ + + try: + degree = data["winddirDegree"] + except KeyError: + degree = "" + + try: + degree = int(degree) + except ValueError: + degree = "" + + if degree: + wind_direction = WIND_DIRECTION[((degree+22)%360)/45] + else: + wind_direction = "" + + if query.get('use_ms_for_wind', False): + 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' + wind = u'%s%s%s' % (wind_direction, data['windspeedMiles'], unit) + else: + unit = ' km/h' + wind = u'%s%s%s' % (wind_direction, data['windspeedKmph'], unit) + + return wind + +def render_location(data, query): + """ + location (l) + """ + + return (data['override_location'] or data['location']) # .title() + +def render_moonphase(_, query): + """ + 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 + ) + return MOON_PHASES[moon_index] + +def render_moonday(_, query): + """ + 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()))) + +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) + + + return str(sun['sunset']) + +FORMAT_SYMBOL = { + 'c': render_condition, + 'C': render_condition_fullname, + 'h': render_humidity, + 't': render_temperature, + 'w': render_wind, + 'l': render_location, + 'm': render_moonphase, + 'M': render_moonday, + 's': render_sunset, + 'p': render_precipitation, + 'P': render_pressure, + } + +def render_line(line, data, query): + """ + Render format `line` using `data` + """ + + def render_symbol(match): + """ + Render one format symbol from re `match` + using `data` from external scope. + """ + + symbol_string = match.group(0) + symbol = symbol_string[-1] + + if symbol not in FORMAT_SYMBOL: + return '' + + render_function = FORMAT_SYMBOL[symbol] + return render_function(data, query) + + return re.sub(r'%[^%]*[a-zA-Z]', render_symbol, line) + +def format_weather_data(format_line, location, override_location, data, query): + """ + 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 + current_condition = data['data']['current_condition'][0] + current_condition['location'] = location + current_condition['override_location'] = override_location + output = render_line(format_line, current_condition, query) + return output + +def wttr_line(location, override_location_name, query, lang): + """ + Return 1line weather information for `location` + in format `line_format` + """ + + format_line = query.get('format', '') + + 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, weather_data, query) + output = output.rstrip("\n")+"\n" + return output + +def main(): + """ + Function for standalone module usage + """ + + location = sys.argv[1] + query = { + 'line': sys.argv[2], + } + + sys.stdout.write(wttr_line(location, location, query, 'en')) + +if __name__ == '__main__': + main() diff --git a/lib/wttr_srv.py b/lib/wttr_srv.py new file mode 100644 index 0000000..70ef249 --- /dev/null +++ b/lib/wttr_srv.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +# vim: set encoding=utf-8 + +""" +Main wttr.in rendering function implementation +""" + +import logging +import os +import time +from flask import render_template, send_file, make_response + +import wttrin_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, \ + BASH_FUNCTION_FILE, TRANSLATION_FILE, LOG_FILE, \ + NOT_FOUND_LOCATION, \ + MALFORMED_RESPONSE_HTML_PAGE, \ + PLAIN_TEXT_AGENTS, PLAIN_TEXT_PAGES, \ + 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 + +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') + +LIMITS = Limits(whitelist=[MY_EXTERNAL_IP], limits=QUERY_LIMITS) + +def show_text_file(name, lang): + """ + show static file `name` for `lang` + """ + text = "" + if name == ":help": + text = open(get_help_file(lang), 'r').read() + text = text.replace('FULL_TRANSLATION', ' '.join(FULL_TRANSLATION)) + text = text.replace('PARTIAL_TRANSLATION', ' '.join(PARTIAL_TRANSLATION)) + elif name == ":bash.function": + text = open(BASH_FUNCTION_FILE, 'r').read() + elif name == ":translation": + text = open(TRANSLATION_FILE, 'r').read() + text = text\ + .replace('NUMBER_OF_LANGUAGES', str(len(SUPPORTED_LANGS)))\ + .replace('SUPPORTED_LANGUAGES', ' '.join(SUPPORTED_LANGS)) + return text.decode('utf-8') + +def client_ip_address(request): + """ + Return client ip address for `request`. + Flask related + """ + + if request.headers.getlist("X-Forwarded-For"): + ip_addr = request.headers.getlist("X-Forwarded-For")[0] + if ip_addr.startswith('::ffff:'): + ip_addr = ip_addr[7:] + else: + ip_addr = request.remote_addr + + return ip_addr + +def get_answer_language(request): + """ + Return preferred answer language based on + domain name, query arguments and headers + """ + + def _parse_accept_language(accept_language): + languages = accept_language.split(",") + locale_q_pairs = [] + + for language in languages: + try: + if language.split(";")[0] == language: + # no q => q = 1 + locale_q_pairs.append((language.strip(), "1")) + else: + locale = language.split(";")[0].strip() + weight = language.split(";")[1].split("=")[1] + locale_q_pairs.append((locale, weight)) + except IndexError: + pass + + return locale_q_pairs + + def _find_supported_language(accepted_languages): + for lang_tuple in accepted_languages: + lang = lang_tuple[0] + if '-' in lang: + lang = lang.split('-', 1)[0] + if lang in SUPPORTED_LANGS: + return lang + return None + + lang = None + hostname = request.headers['Host'] + if hostname != 'wttr.in' and hostname.endswith('.wttr.in'): + lang = hostname[:-8] + + if 'lang' in request.args: + lang = request.args.get('lang') + + header_accept_language = request.headers.get('Accept-Language', '') + if lang is None and header_accept_language: + lang = _find_supported_language( + _parse_accept_language(header_accept_language)) + + return lang + +def get_output_format(request, 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'): + return False + 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) + basing on the current time and query interval `period` + """ + + locations = locations.split(':') + max_len = max(len(x) for x in locations) + locations = [x.rjust(max_len) for x in locations] + + try: + period = int(period) + except ValueError: + period = 1 + + index = int(time.time())/period % len(locations) + return locations[index] + + +def wttr(location, request): + """ + Main rendering function, it processes incoming weather queries. + Depending on user agent it returns output in HTML or ANSI format. + + Incoming data: + request.args + request.headers + request.remote_addr + request.referrer + request.query_string + """ + + 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) + + png_filename = None + if location is not None and location.lower().endswith(".png"): + png_filename = location + location = location[:-4] + + lang = get_answer_language(request) + query = parse_query.parse_query(request.args) + html_output = get_output_format(request, query) + user_agent = request.headers.get('User-Agent', '').lower() + + 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) + + if location and ':' in location: + location = cyclic_location_selection(location, query.get('period', 1)) + + orig_location = location + + if not png_filename: + location, override_location_name, full_address, country, query_source_location = \ + location_processing(location, ip_addr) + + us_ip = query_source_location[1] == 'United States' and 'slack' not in 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 'format' in query: + return _wrap_response(wttr_line(location, override_location_name, query, lang), html_output) + + if png_filename: + options = { + 'lang': lang, + 'location': location} + options.update(query) + + 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 location.lower() == 'moon' or location.lower().startswith('moon@'): + output = get_moon(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, + ) + + 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' + + return _wrap_response(output, html_output) + + except RuntimeError 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" diff --git a/lib/wttrin_png.py b/lib/wttrin_png.py index 86e4b50..74055d8 100644 --- a/lib/wttrin_png.py +++ b/lib/wttrin_png.py @@ -1,6 +1,7 @@ #!/usr/bin/python #vim: encoding=utf-8 +from __future__ import print_function import sys import os import re @@ -32,7 +33,7 @@ MYDIR = os.path.abspath(os.path.dirname(os.path.dirname('__file__'))) sys.path.append("%s/lib/" % MYDIR) import parse_query -PNG_CACHE = os.path.join(MYDIR, "cache/png") +from globals import PNG_CACHE, log COLS = 180 ROWS = 100 @@ -95,7 +96,8 @@ def strip_buf(buf): break number_of_lines += 1 - buf = buf[:-number_of_lines] + 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] @@ -118,7 +120,7 @@ def script_category(char): def gen_term(filename, buf, options=None): buf = strip_buf(buf) - cols = len(buf[0]) + cols = max(len(x) for x in buf) rows = len(buf) image = Image.new('RGB', (cols * CHAR_WIDTH, rows * CHAR_HEIGHT)) @@ -142,14 +144,15 @@ def gen_term(filename, buf, options=None): (x_pos+CHAR_WIDTH, y_pos+CHAR_HEIGHT)), fill=color_mapping(char.bg)) - cat = script_category(char.data) - if cat not in font: - print "Unknown font category: %s" % cat - draw.text( - (x_pos, y_pos), - char.data, - font=font.get(cat, font.get('default')), - fill=current_color) + 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 @@ -198,7 +201,10 @@ def typescript_to_one_frame(png_file, text, options=None): stream.feed(text) - gen_term(png_file, screen.buffer, options=options) + 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 @@ -266,6 +272,8 @@ def make_wttrin_query(parsed): 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)) @@ -280,13 +288,10 @@ def make_wttr_in_png(png_name, options=None): """ parsed = parse_wttrin_png_name(png_name) - print "------" - print parsed - print "------" # if location is MyLocation it should be overriden # with autodetected location (from options) - if parsed.get('location', 'MyLocation') == 'MyLocation': + if parsed.get('location', 'MyLocation') == 'MyLocation' or not parsed.get('location', ''): del parsed['location'] if options is not None: @@ -294,7 +299,7 @@ def make_wttr_in_png(png_name, options=None): if key not in parsed: parsed[key] = val url = make_wttrin_query(parsed) - print "URL = ", url + print("URL = ", url) timestamp = time.strftime("%Y%m%d%H", time.localtime()) cached_basename = url[14:].replace('/','_') @@ -305,11 +310,9 @@ def make_wttr_in_png(png_name, options=None): if not os.path.exists(dirname): os.makedirs(dirname) - print "Cached file: %s" % cached_png_file if os.path.exists(cached_png_file): return cached_png_file - print "Requesting URL: %s" % url text = requests.get(url).text.replace('\n', '\r\n') curl_output = text.encode('utf-8') diff --git a/requirements.txt b/requirements.txt index e87b4b4..6ab23b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,10 @@ geopy requests gevent dnspython +pylint +cyrtranslit +astral +timezonefinder +Pillow +pyte +python-dateutil diff --git a/share/aliases b/share/aliases index f34bf21..f370c52 100644 --- a/share/aliases +++ b/share/aliases @@ -1,8 +1,9 @@ Msk : Moscow Moskva : Moscow Moskau : Moscow -Kyiv : Kiev -Kiew : Kiev +Kyiv : Kiev,Ukraine +Kiew : Kiev,Ukraine +Kiev : Kiev,Ukraine Kijev : Kiev Kharkov : Kharkiv spb : Saint Petersburg @@ -35,8 +36,14 @@ tel-aviv : Tel Aviv sao paulo : São Paulo los-angeles : Los Angeles Sevastopol : Sevastopol, Ukraine +Simferopol : Simferopol, Ukraine Beersheva : Beersheba Be'ersheva : Beersheba Be'er Sheva : Beersheba Lugansk : Luhansk Bjalistoko : Białystok +Chicago : Chicago,IL +Paris : Paris,France +Giessen : Giessen, Germany +Braga : Braga, Portugal +Kashan : ~Kashan,Iran diff --git a/share/ansi2html.sh b/share/ansi2html.sh index 2221a1e..b4561d6 100644 --- a/share/ansi2html.sh +++ b/share/ansi2html.sh @@ -104,7 +104,6 @@ printf '%s' " -