Showing preview only (1,119K chars total). Download the full file or copy to clipboard to get everything.
Repository: chubin/wttr.in
Branch: master
Commit: 771d003db43b
Files: 222
Total size: 1.0 MB
Directory structure:
gitextract_3puwzenn/
├── .flake8
├── .github/
│ └── workflows/
│ └── makefile.yml
├── .gitignore
├── .golangci.yaml
├── .travis.yml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── bin/
│ ├── geo-proxy.py
│ ├── proxy.py
│ └── srv.py
├── config/
│ └── services/
│ └── services.yaml
├── doc/
│ ├── integrations.md
│ └── terminal-images.md
├── go.mod
├── go.sum
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── fmt/
│ │ └── png/
│ │ ├── colors.go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ └── png.go
│ ├── geo/
│ │ ├── ip/
│ │ │ ├── convert.go
│ │ │ ├── ip.go
│ │ │ └── ip_test.go
│ │ └── location/
│ │ ├── cache.go
│ │ ├── convert.go
│ │ ├── location.go
│ │ ├── nominatim.go
│ │ ├── nominatim_locationiq.go
│ │ ├── nominatim_opencage.go
│ │ ├── response.go
│ │ └── search.go
│ ├── logging/
│ │ ├── logging.go
│ │ └── suppress.go
│ ├── options/
│ │ ├── options.go
│ │ ├── parse.go
│ │ └── processlog.go
│ ├── processor/
│ │ ├── j1.go
│ │ ├── peak.go
│ │ └── processor.go
│ ├── routing/
│ │ └── routing.go
│ ├── stats/
│ │ └── stats.go
│ ├── types/
│ │ ├── errors.go
│ │ └── types.go
│ ├── util/
│ │ ├── files.go
│ │ ├── http.go
│ │ └── yaml.go
│ └── view/
│ └── v1/
│ ├── api.go
│ ├── cmd.go
│ ├── format.go
│ ├── icons.go
│ ├── locale.go
│ └── view1.go
├── lib/
│ ├── airports.py
│ ├── buttons.py
│ ├── cache.py
│ ├── constants.py
│ ├── datasource/
│ │ └── README.md
│ ├── duplicate_translations.py
│ ├── extract_emoji.py
│ ├── fields.py
│ ├── fmt/
│ │ ├── __init__.py
│ │ ├── png.py
│ │ └── unicodedata2.py
│ ├── globals.py
│ ├── limits.py
│ ├── location.py
│ ├── metno.py
│ ├── parse_query.py
│ ├── proxy_log.py
│ ├── translations.py
│ ├── translations_v2.py
│ ├── view/
│ │ ├── __init__.py
│ │ ├── line.py
│ │ ├── moon.py
│ │ ├── prometheus.py
│ │ ├── v2.py
│ │ └── wttr.py
│ ├── weather_data.py
│ └── wttr_srv.py
├── requirements.txt
├── share/
│ ├── aliases
│ ├── ansi2html.sh
│ ├── bash-function.txt
│ ├── blacklist
│ ├── docker/
│ │ └── supervisord.conf
│ ├── help.txt
│ ├── iterm2.txt
│ ├── list-of-iata-codes.txt
│ ├── salt/
│ │ ├── README.md
│ │ ├── init.sls
│ │ ├── pillar.sls
│ │ ├── start.sh
│ │ ├── wegorc
│ │ └── wttr.service
│ ├── screenrc
│ ├── scripts/
│ │ ├── build-welang.sh
│ │ ├── clean-cache.sh
│ │ ├── log-space.sh
│ │ └── start-screen.sh
│ ├── static/
│ │ ├── malformed-response.html
│ │ └── style.css
│ ├── systemd/
│ │ ├── README.md
│ │ ├── wttrin.service
│ │ └── wttrin.sh
│ ├── templates/
│ │ └── index.html
│ ├── test-thunder.txt
│ ├── translation.txt
│ └── translations/
│ ├── af-help.txt
│ ├── af.txt
│ ├── am-help.txt
│ ├── am.txt
│ ├── ar-help.txt
│ ├── ar.txt
│ ├── az.txt
│ ├── be-help.txt
│ ├── be.txt
│ ├── bg-help.txt
│ ├── bn-help.txt
│ ├── bn.txt
│ ├── bs.txt
│ ├── ca-help.txt
│ ├── ca.txt
│ ├── cs-help.txt
│ ├── cs.txt
│ ├── cy.txt
│ ├── da-help.txt
│ ├── da.txt
│ ├── de-help.txt
│ ├── de.txt
│ ├── dk-help.txt
│ ├── el-help.txt
│ ├── el.txt
│ ├── en.txt
│ ├── eo.txt
│ ├── es-help.txt
│ ├── es.txt
│ ├── et-help.txt
│ ├── et.txt
│ ├── eu-help.txt
│ ├── eu.txt
│ ├── fa-help.txt
│ ├── fa.txt
│ ├── fr-help.txt
│ ├── fr.txt
│ ├── fy.txt
│ ├── ga.txt
│ ├── gl-help.txt
│ ├── gl.txt
│ ├── gu-help.txt
│ ├── gu.txt
│ ├── he.txt
│ ├── hi-help.txt
│ ├── hi.txt
│ ├── hr.txt
│ ├── hu-help.txt
│ ├── hu.txt
│ ├── hy.txt
│ ├── ia-help.txt
│ ├── ia.txt
│ ├── id-help.txt
│ ├── id.txt
│ ├── is.txt
│ ├── it-help.txt
│ ├── it.txt
│ ├── ja.txt
│ ├── kk-help.txt
│ ├── kk.txt
│ ├── lt-help.txt
│ ├── lt.txt
│ ├── lv-help.txt
│ ├── lv.txt
│ ├── messages/
│ │ ├── en.yaml
│ │ └── gu.yaml
│ ├── mg-help.txt
│ ├── mg.txt
│ ├── mk.txt
│ ├── mr-help.txt
│ ├── mr.txt
│ ├── nb-help.txt
│ ├── nb.txt
│ ├── nl-help.txt
│ ├── nl.txt
│ ├── nn.txt
│ ├── oc-help.txt
│ ├── oc.txt
│ ├── pl-help.txt
│ ├── pl.txt
│ ├── pt-br-help.txt
│ ├── pt-br.txt
│ ├── pt-help.txt
│ ├── pt.txt
│ ├── ro-help.txt
│ ├── ro.txt
│ ├── ru-help.txt
│ ├── ru.txt
│ ├── sl.txt
│ ├── ta-help.txt
│ ├── ta.txt
│ ├── te-help.txt
│ ├── te.txt
│ ├── th-help.txt
│ ├── th.txt
│ ├── tr-help.txt
│ ├── tr.txt
│ ├── uk-help.txt
│ ├── uk.txt
│ ├── ukr-help.txt
│ ├── uz.txt
│ ├── vi-help.txt
│ ├── vi.txt
│ ├── zh-cn-help.txt
│ ├── zh-cn.txt
│ ├── zh-tw-help.txt
│ └── zh-tw.txt
├── spec/
│ └── options/
│ └── options.yaml
├── srv.go
└── test/
├── proxy-data/
│ ├── data1
│ └── data1.headers
├── query.sh
└── test-data/
└── signatures
================================================
FILE CONTENTS
================================================
================================================
FILE: .flake8
================================================
[flake8]
ignore = E402,E501
================================================
FILE: .github/workflows/makefile.yml
================================================
name: Makefile CI
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Set up Python environment
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11' # Specify the Python version you need (3.11 required for pyjq)
- name: Create and activate virtual environment
run: |
python -m venv venv
source venv/bin/activate
echo "Virtual environment created and activated."
- name: Install Python dependencies
run: |
sudo apt-get install libjq-dev
source venv/bin/activate
pip install -r requirements.txt
- name: Add Python virtual environment to PATH
run: |
echo "${{ github.workspace }}/venv/bin" >> $GITHUB_PATH
# Set up Go environment
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24' # Replace with the needed Go version
- name: Install gofumpt
run: |
go install mvdan.cc/gofumpt@latest
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
- name: Install swagger
run: |
cd /tmp \
&& go install github.com/go-swagger/go-swagger/cmd/swagger@latest
- name: Add Go bin to PATH
run: |
echo "${{ runner.temp }}/go/bin" >> $GITHUB_PATH
- name: Install dependencies
run: make
- name: Run check
run: make check
================================================
FILE: .gitignore
================================================
ve/
share/static/fonts/
*.pyc
data/
log/
.idea/
*.swp
*.mmdb
*.dat
================================================
FILE: .golangci.yaml
================================================
run:
skip-dirs:
- pkg/curlator
linters:
enable-all: true
disable:
- wsl
- wrapcheck
- varnamelen
- gci
- exhaustivestruct
- exhaustruct
- gomnd
- gofmt
# to be fixed:
- ireturn
- gosec
- noctx
- interfacer
# deprecated:
- scopelint
- deadcode
- varcheck
- maligned
- ifshort
- nosnakecase
- structcheck
- golint
================================================
FILE: .travis.yml
================================================
group: travis_latest
language: python
cache: pip
python:
- 3.7
install:
- pip install flake8 -r requirements.txt
before_script:
# stop the build if there are Python syntax errors or undefined names
- flake8 bin lib --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
- flake8 bin lib --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.
================================================
FILE: Dockerfile
================================================
# Build stage
FROM golang:1-alpine as builder
WORKDIR /app
COPY ./share/we-lang/ /app
RUN apk add --no-cache git
RUN go get -u github.com/mattn/go-colorable && \
go get -u github.com/klauspost/lctime && \
go get -u github.com/mattn/go-runewidth && \
cd /app && CGO_ENABLED=0 go build .
# Application stage
FROM alpine:3.21.1
WORKDIR /app
COPY ./requirements.txt /app
ENV LLVM_CONFIG=/usr/bin/llvm11-config
RUN apk add --no-cache --virtual .build \
autoconf \
automake \
g++ \
gcc \
jpeg-dev \
llvm11-dev\
make \
zlib-dev \
&& apk add --no-cache \
python3 \
py3-pip \
py3-scipy \
py3-wheel \
py3-gevent \
zlib \
jpeg \
llvm11 \
libtool \
supervisor \
py3-numpy-dev \
python3-dev && \
mkdir -p /app/cache && \
mkdir -p /var/log/supervisor && \
mkdir -p /etc/supervisor/conf.d && \
chmod -R o+rw /var/log/supervisor && \
chmod -R o+rw /var/run && \
pip install -r requirements.txt --no-cache-dir && \
apk del --no-cache -r .build
COPY --from=builder /app/wttr.in /app/bin/wttr.in
COPY ./bin /app/bin
COPY ./lib /app/lib
COPY ./share /app/share
COPY share/docker/supervisord.conf /etc/supervisor/supervisord.conf
ENV WTTR_MYDIR="/app"
ENV WTTR_GEOLITE="/app/GeoLite2-City.mmdb"
ENV WTTR_WEGO="/app/bin/wttr.in"
ENV WTTR_LISTEN_HOST="0.0.0.0"
ENV WTTR_LISTEN_PORT="8002"
EXPOSE 8002
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
================================================
FILE: LICENSE
================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================
FILE: Makefile
================================================
srv: srv.go internal/*/*.go internal/*/*/*.go
#go build -o srv -ldflags '-w -linkmode external -extldflags "-static"' ./
go build -o srv ./
check:
true
go-test:
go test ./...
lint:
golangci-lint run ./...
================================================
FILE: README.md
================================================
*wttr.in — the right way to ~check~ `curl` 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.
Originally started as a small project, a wrapper for [wego](https://github.com/schachmat/wego),
intended to demonstrate the power of the console-oriented services,
*wttr.in* became a popular weather reporting service, handling tens of millions[¹](#wttrin-usage-stats) of queries daily.
You can see it running here: [wttr.in](https://wttr.in).
[Documentation](https://wttr.in/:help) | [Usage](https://github.com/chubin/wttr.in#usage) | [One-line output](https://github.com/chubin/wttr.in#one-line-output) | [Data-rich output format](https://github.com/chubin/wttr.in#data-rich-output-format-v2) | [Map view](https://github.com/chubin/wttr.in#map-view-v3) | [Output formats](https://github.com/chubin/wttr.in#different-output-formats) | [Moon phases](https://github.com/chubin/wttr.in#moon-phases) | [Internationalization](https://github.com/chubin/wttr.in#internationalization-and-localization) | [Installation](https://github.com/chubin/wttr.in#installation)
## Usage
You can access the service from a shell or from a Web browser like this:
$ curl wttr.in
Weather for City: Paris, France
\ / Clear
.-. 10 – 11 °C
― ( ) ― ↑ 11 km/h
`-’ 10 km
/ \ 0.0 mm
Here is an example weather report:

Or in PowerShell:
```PowerShell
Invoke-RestMethod https://wttr.in
```
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
$ curl wttr.in/Salt+Lake+City
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
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
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 `@`) to specify a location:
$ curl wttr.in/@github.com
$ curl wttr.in/@msu.ru
To get detailed information online, you can access the [/:help](https://wttr.in/:help) page:
$ curl wttr.in/:help
### 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 by adding `?u`, `?m` or `?M` to a URL like this:
$ curl wttr.in/Amsterdam?u # USCS (used by default in US)
$ curl wttr.in/Amsterdam?m # metric (SI) (used by default everywhere except US)
$ curl wttr.in/Amsterdam?M # metric (SI), but show wind speed in m/s
If you have several options to pass, write them without delimiters in between for the one-letter options,
and use `&` as a delimiter for the long options with values:
$ curl 'wttr.in/Amsterdam?m2&lang=nl'
It would be a rough equivalent of `-m2 --lang nl` for the GNU CLI syntax.
## Supported output formats and views
wttr.in currently supports five output formats:
* ANSI for the terminal;
* Plain-text for the terminal and scripts;
* HTML for the browser;
* PNG for the graphical viewers;
* JSON for scripts and APIs;
* Prometheus metrics for scripts and APIs.
The ANSI and HTML formats are selected based on the User-Agent string.
To force plain text, which disables colors:
$ curl wttr.in/?T
To restrict output to glyphs available in standard console fonts (e.g. Consolas and Lucida Console):
$ curl wttr.in/?d
The PNG format can be forced by adding `.png` to the end of the query:
$ wget wttr.in/Paris.png
You can use all of the options with the PNG-format like in an URL, but you have
to separate them with `_` instead of `?` and `&`:
$ wget wttr.in/Paris_0tqp_lang=fr.png
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 weather PNGs are used to add weather data to pictures:
$ convert source.jpg <( curl wttr.in/Oymyakon_tqp0.png ) -geometry +50+50 -composite target.jpg
In this example:
* `source.jpg` - source file;
* `target.jpg` - target file;
* `Oymyakon` - name of the location;
* `tqp0` - options (recommended).

You can embed a special wttr.in widget, that displays the weather condition for the current or a selected location, into a HTML page using the [wttr-switcher](https://github.com/midzer/wttr-switcher). That is how it looks like: [wttr-switcher-example](https://midzer.github.io/wttr-switcher/) or on a real world web site: https://feuerwehr-eisolzried.de/.

## One-line output
One-line output format is convenient to be used to show weather info
in status bar of different programs, such as *tmux*, *weechat*, etc.
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).
* 1: Current weather at location: `🌦 +11⁰C`
* 2: Current weather at location with more details: `🌦 🌡️+11°C 🌬️↓4km/h`
* 3: Name of location and current weather at location: `Nuremberg: 🌦 +11⁰C`
* 4: Name of location and current weather at location with more details: `Nuremberg: 🌦 🌡️+11°C 🌬️↓4km/h`
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,
x Weather condition, plain-text symbol,
h Humidity,
t Temperature (Actual),
f Temperature (Feels Like),
w Wind,
l Location,
m Moon phase 🌑🌒🌓🌔🌕🌖🌗🌘,
M Moon day,
p Precipitation (mm/3 hours),
P Pressure (hPa),
e Dew point,
u UV index (1-12),
D Dawn*,
S Sunrise*,
z Zenith*,
s Sunset*,
d Dusk*,
T Current time*,
Z Local timezone.
(*times are shown in the local timezone)
```
So, these two calls are the same:
```
$ curl wttr.in/London?format=3
London: ⛅️ +7⁰C
$ curl wttr.in/London?format="%l:+%c+%t\n"
London: ⛅️ +7⁰C
```
## Integrations
Thanks to the ease of integrating *wttr.in* into any program, there are a
plethora of popular integrations across various libraries, programming
languages, and systems.
*wttr.in* is compatible with:
* terminal managers,
* window managers,
* editors,
* chat clients,
and more, these integrations enhance workflow efficiency by embedding weather information directly into user interfaces.
See the full list of integrations here: [wttr.in integrations](doc/integrations.md)
and some of them below.
### tmux
When using in `tmux.conf`, you have to escape `%` with `%`, i.e. write there `%%` instead of `%`.
The output does not contain new line by default, when the %-notation is used, but it does contain it when preconfigured format (`1`,`2`,`3` etc.)
are used. To have the new line in the output when the %-notation is used, use '\n' and single quotes when doing a query from the shell.
In programs, 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 ..."
```

### WeeChat
To embed in to an IRC ([WeeChat](https://github.com/weechat/weechat)) client's existing status bar:
```
/alias add wttr /exec -pipe "/mute /set plugins.var.wttr" url:wttr.in/Montreal?format=%l:+%c+%f+%h+%p+%P+%m+%w+%S+%s;/wait 3 /item refresh wttr
/trigger add wttr timer 60000;0;0 "" "" "/wttr"
/item add wttr "" "${plugins.var.wttr}"
/eval /set weechat.bar.status.items ${weechat.bar.status.items},spacer,wttr
/eval /set weechat.startup.command_after_plugins ${weechat.startup.command_after_plugins};/wttr
/wttr
```

### conky
Conky usage example:
```
${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png
| convert - -transparent black $HOME/.config/conky/out.png}
${image $HOME/.config/conky/out.png -p 0,0}
```

### IRC
IRC integration example:
* https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor
### Emojis support
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:
```xml
$ cat ~/.config/fontconfig/fonts.conf
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<alias>
<family>serif</family>
<prefer>
<family>Noto Color Emoji</family>
</prefer>
</alias>
<alias>
<family>sans-serif</family>
<prefer>
<family>Noto Color Emoji</family>
</prefer>
</alias>
<alias>
<family>monospace</family>
<prefer>
<family>Noto Color Emoji</family>
</prefer>
</alias>
</fontconfig>
```
(to apply the configuration, run `fc-cache -f -v`).
In some cases, `tmux` and the terminal understanding of some emoji characters may differ, which may
cause strange effects similar to that described in #579.
### Squeak
To embed into the world main docking bar:
```smalltalk
wttr := (UpdatingStringMorph on: [(WebClient httpGet: 'https://wttr.in/?format=%20%20%l:%20%C+%t') content] selector: #value)
stepTime: 60000;
useStringFormat;
yourself.
dockingBar := World mainDockingBars first.
dockingBar addMorph: wttr after: (dockingBar findA: ClockMorph).
```

## Data-rich output format (v2)
In the experimental data-rich output format, that is available under the view code `v2`,
a lot of additional weather and astronomical information is available:
* Temperature, and precipitation changes forecast throughout the days;
* Moonphase for today and the next three days;
* The current weather condition, temperature, humidity, wind speed and direction, pressure;
* Timezone;
* Dawn, sunrise, noon, sunset, dusk time for he selected location;
* Precise geographical coordinates for the selected location.
```
$ curl v2.wttr.in/München
```
or
```
$ curl wttr.in/München?format=v2
```
or, if you prefer Nerd Fonts instead of Emoji, `v2d` (day) or `v2n` (night):
```
$ curl v2d.wttr.in/München
```

(The mode is experimental, and it has several limitations currently:
* It works only in terminal;
* Only English is supported).
Currently, you need some tweaks for some terminals, to get the best possible visualization.
### URXVT
Depending on your configuration you might be taking all steps, or only a few. URXVT currently doesn't support emoji related fonts, but we can get almost the same effect using *Font-Symbola*. So add to your `.Xresources` file the following line:
```
xft:symbola:size=10:minspace=False
```
You can add it _after_ your preferred font and it will only show up when required.
Then, if you see or feel like you're having spacing issues, add this: `URxvt.letterSpace: 0`
For some reason URXVT sometimes stops deciding right the word spacing and we need to force it this way.
The result, should look like:

## Map view (v3)
In the experimental map view, that is available under the view code `v3`,
weather information about a geographical region is available:
```
$ curl v3.wttr.in/Bayern.sxl
```

or directly in browser:
* https://v3.wttr.in/Bayern
The map view currently supports three formats:
* PNG (for browser and messengers);
* Sixel (terminal inline images support);
* IIP (terminal with iterm2 inline images protocol support).
Terminal with inline images protocols support:
⟶ *Detailed article: [Images in terminal](doc/terminal-images.md)*
| Terminal | Environment | Images support | Protocol |
| --------------------- | --------- | ------------- | --------- |
| uxterm | X11 | yes | Sixel |
| mlterm | X11 | yes | Sixel |
| kitty | X11 | yes | Kitty |
| wezterm | X11 | yes | IIP |
| Darktile | X11 | yes | Sixel |
| Jexer | X11 | yes | Sixel |
| GNOME Terminal | X11 | [in-progress](https://gitlab.gnome.org/GNOME/vte/-/issues/253) | Sixel |
| alacritty | X11 | [in-progress](https://github.com/alacritty/alacritty/issues/910) | Sixel |
| foot | Wayland | yes | Sixel |
| DomTerm | Web | yes | Sixel |
| Yaft | FB | yes | Sixel |
| iTerm2 | Mac OS X| yes | IIP |
| mintty | Windows | yes | Sixel |
| Windows Terminal | Windows | [in-progress](https://github.com/microsoft/terminal/issues/448) | Sixel |
| [RLogin](http://nanno.dip.jp/softlib/man/rlogin/) | Windows | yes | Sixel | |
## Different output formats
### JSON output
The JSON format is a feature providing access to *wttr.in* data through an easy-to-parse format, without requiring the user to create a complex script to reinterpret wttr.in's graphical output.
To fetch information in JSON format, use the following syntax:
$ curl wttr.in/Detroit?format=j1
This will fetch information on the Detroit region in JSON format. The j1 format code is used to allow for the use of other layouts for the JSON output.
The result will look something like the following:
```json
{
"current_condition": [
{
"FeelsLikeC": "25",
"FeelsLikeF": "76",
"cloudcover": "100",
"humidity": "76",
"observation_time": "04:08 PM",
"precipMM": "0.2",
"pressure": "1019",
"temp_C": "22",
"temp_F": "72",
"uvIndex": 5,
"visibility": "16",
"weatherCode": "122",
"weatherDesc": [
{
"value": "Overcast"
}
],
"weatherIconUrl": [
{
"value": ""
}
],
"winddir16Point": "NNE",
"winddirDegree": "20",
"windspeedKmph": "7",
"windspeedMiles": "4"
}
],
...
```
Most of these values are self-explanatory, aside from `weatherCode`. The `weatherCode` is an enumeration which you can find at either [the WorldWeatherOnline website](https://www.worldweatheronline.com/developer/api/docs/weather-icons.aspx) or [in the wttr.in source code](https://github.com/chubin/wttr.in/blob/master/lib/constants.py).
A smaller version `format=j2` without hourly data is also availble. Can work well for microcontrollers with limited memory.
### Prometheus Metrics Output
The [Prometheus](https://github.com/prometheus/prometheus) Metrics format is a feature providing access to *wttr.in* data through an easy-to-parse format for monitoring systems, without requiring the user to create a complex script to reinterpret wttr.in's graphical output.
To fetch information in Prometheus format, use the following syntax:
$ curl wttr.in/Detroit?format=p1
This will fetch information on the Detroit region in Prometheus Metrics format. The `p1` format code is used to allow for the use of other layouts for the Prometheus Metrics output.
A possible configuration for Prometheus could look like this:
```yaml
- job_name: 'wttr_in_detroit'
static_configs:
- targets: ['wttr.in']
metrics_path: '/Detroit'
params:
format: ['p1']
```
The result will look something like the following:
# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius
temperature_feels_like_celsius{forecast="current"} 7
# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit
temperature_feels_like_fahrenheit{forecast="current"} 45
[truncated]
...
## 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
in the full-output mode:
$ curl wttr.in/Moon
Get the moon phase for a particular date by adding `@YYYY-MM-DD`:
$ curl wttr.in/Moon@2016-12-25
The moon phase information uses [pyphoon](https://github.com/chubin/pyphoon) as its backend.
To get the moon phase information in the online mode, use `%m`:
$ curl wttr.in/London?format=%m
🌖
Keep in mind that the Unicode representation of moon phases suffers 2 caveats:
- With some fonts, the representation `🌘` is ambiguous, for it either seem
almost-shadowed or almost-lit, depending on whether your terminal is in
light mode or dark mode. Relying on colored fonts like `noto-fonts` works
around this problem.
- The representation `🌘` is also ambiguous, for it means "last quarter" in
northern hemisphere, but "first quarter" in souther hemisphere. It also means
nothing in tropical zones. This is a limitation that
[Unicode](https://www.unicode.org/L2/L2017/17304-moon-var.pdf) is aware about.
But it has not been worked around at `wttr.in` yet.
See #247, #364 for the corresponding tracking issues,
and [pyphoon#1](https://github.com/chubin/pyphoon/issues/1) for pyphoon. Any help is welcome.
## Internationalization and localization
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). Spaces in the query string
must be replaced with `+`:
$ curl wttr.in/станция+Восток
Weather report: станция Восток
Overcast
.--. -65 – -47 °C
.-( ). ↑ 23 km/h
(___.__)__) 15 km
0.0 mm
The language used for the output (except the location name) does not depend on the input language
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`).
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 the DNS name used in the query:
$ curl de.wttr.in/Berlin
wttr.in is currently translated into 54 languages, and the number of supported languages is constantly growing.
See [/:translation](https://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.

## Installation
To install the application:
1. Install external dependencies
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 the HTTP-frontend service
### Install external dependencies
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
After you install [golang](https://golang.org/doc/install), install `wego`:
```bash
go install github.com/schachmat/wego@latest
```
### Install Python dependencies
Python requirements:
* Flask
* geoip2
* geopy
* requests
* gevent
If you want to get weather reports as PNG files, you'll also need to install:
* PIL
* pyte (>=0.6)
* necessary fonts
You can install most of them using `pip`.
Some python package use LLVM, so install it first:
```bash
apt-get install llvm-7 llvm-7-dev
```
If `virtualenv` is used:
```bash
virtualenv -p python3 ve
ve/bin/pip3 install -r requirements.txt
ve/bin/python3 bin/srv.py
```
Also, you need to install the geoip2 database.
You can use a free database GeoLite2 that can be downloaded from (http://dev.maxmind.com/geoip/geoip2/geolite2/).
### Configure IP2Location (optional)
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).
### Installation with Docker
* Install Docker
* Build Docker Image
* These files should be mounted by the user at runtime:
```
/root/.wegorc
/root/.ip2location.key (optional)
/app/airports.dat
/app/GeoLite2-City.mmdb
```
### Get a WorldWeatherOnline key and configure wego
To get a WorldWeatherOnline API key, you must register here:
https://developer.worldweatheronline.com/auth/register
After you have a WorldWeatherOnline key, you can save it into the
WWO key file: `~/.wwo.key`
Also, you have to specify the key in the `wego` configuration:
```json
$ cat ~/.wegorc
{
"APIKey": "00XXXXXXXXXXXXXXXXXXXXXXXXXXX",
"City": "London",
"Numdays": 3,
"Imperial": false,
"Lang": "en"
}
```
The `City` parameter in `~/.wegorc` is ignored.
### Configure wttr.in
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:
```bash
export WTTR_MYDIR="/home/igor/wttr.in"
export WTTR_GEOLITE="/home/igor/wttr.in/GeoLite2-City.mmdb"
export WTTR_WEGO="/home/igor/go/bin/wego"
export WTTR_LISTEN_HOST="0.0.0.0"
export WTTR_LISTEN_PORT="8002"
```
### Configure the HTTP-frontend service
It's recommended that you also configure the web server that will be used to access the service:
```nginx
server {
listen [::]:80;
server_name wttr.in *.wttr.in;
access_log /var/log/nginx/wttr.in-access.log main;
error_log /var/log/nginx/wttr.in-error.log;
location / {
proxy_pass http://127.0.0.1:8002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
client_max_body_size 10m;
client_body_buffer_size 128k;
proxy_connect_timeout 90;
proxy_send_timeout 90;
proxy_read_timeout 90;
proxy_buffer_size 4k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
proxy_temp_file_write_size 64k;
expires off;
}
}
```
## wttr.in usage stats
As of March 2026, *wttr.in* handles 22-27 million queries per day from 300,000 to 320,000 users, according to the access logs.

================================================
FILE: bin/geo-proxy.py
================================================
import gevent
from gevent.pywsgi import WSGIServer
from gevent.queue import Queue
from gevent.monkey import patch_all
from gevent.subprocess import Popen, PIPE, STDOUT
patch_all()
import sys
import os
import json
from flask import (
Flask,
request,
render_template,
send_from_directory,
send_file,
make_response,
jsonify,
Response,
)
app = Flask(__name__)
MYDIR = os.path.abspath(os.path.dirname("__file__"))
sys.path.append(os.path.join(MYDIR, "lib"))
CACHEDIR = os.path.join(MYDIR, "cache")
from geopy.geocoders import Nominatim # , Mapzen
# geomapzen = Mapzen("mapzen-RBNbmcZ") # Nominatim()
geoosm = Nominatim(timeout=7, user_agent="wttrin-geo/0.0.2")
import airports
# from tzwhere import tzwhere
import timezonefinder
tf = timezonefinder.TimezoneFinder()
def load_cache(location_string):
try:
location_string = location_string.replace("/", "_")
cachefile = os.path.join(CACHEDIR, location_string)
return json.loads(open(cachefile, "r").read())
except Exception:
return None
def shorten_full_address(address):
parts = address.split(",")
if len(parts) > 6:
parts = parts[:2] + [x for x in parts[-4:] if len(x) < 20]
return ",".join(parts)
return address
def save_cache(location_string, answer):
location_string = location_string.replace("/", "_")
cachefile = os.path.join(CACHEDIR, location_string)
open(cachefile, "w").write(json.dumps(answer))
def query_osm(location_string):
try:
location = geoosm.geocode(location_string)
return {
"address": location.address,
"latitude": location.latitude,
"longitude": location.longitude,
}
except Exception as e:
print(e)
return None
def add_timezone_information(geo_data):
# tzwhere_ = tzwhere.tzwhere()
# timezone_str = tzwhere_.tzNameAt(geo_data["latitude"], geo_data["longitude"])
timezone_str = tf.certain_timezone_at(
lat=geo_data["latitude"], lng=geo_data["longitude"]
)
answer = geo_data.copy()
answer["timezone"] = timezone_str
return answer
@app.route("/<string:location>")
def find_location(location):
airport_gps_location = airports.get_airport_gps_location(
location.upper().lstrip("~")
)
is_airport = False
if airport_gps_location is not None:
location = airport_gps_location
is_airport = True
location = location.replace("+", " ")
answer = load_cache(location)
loaded_answer = None
if answer is not None:
loaded_answer = answer.copy()
print("cache found: %s" % location)
if answer is None:
answer = query_osm(location)
if is_airport:
answer["address"] = shorten_full_address(answer["address"])
if "timezone" not in answer:
answer = add_timezone_information(answer)
if answer is not None and loaded_answer != answer:
save_cache(location, answer)
if answer is None:
return ""
else:
r = Response(json.dumps(answer))
r.headers["Content-Type"] = "text/json; charset=utf-8"
return r
app.config["JSON_AS_ASCII"] = False
server = WSGIServer(("127.0.0.1", 8004), app)
server.serve_forever()
================================================
FILE: bin/proxy.py
================================================
# 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.
If WTTRIN_TEST is specified, it works in a special test mode:
it does not fetch and does not store the data in the cache,
but is using the fake data from "test/proxy-data".
"""
from __future__ import print_function
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 hashlib
import re
import logging
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)
import proxy_log
import globals
from globals import (
PROXY_CACHEDIR,
PROXY_PORT,
USE_METNO,
USER_AGENT,
MISSING_TRANSLATION_LOG,
)
from metno import create_standard_json_from_metno, metno_request
from translations import PROXY_LANGS
# pylint: enable=wrong-import-position
proxy_logger = proxy_log.LoggerWWO(globals.PROXY_LOG_ACCESS, globals.PROXY_LOG_ERRORS)
def is_testmode():
"""Server is running in the wttr.in test mode"""
return "WTTRIN_TEST" in os.environ
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.lower()] = trans
translations[lang] = translation
return translations
TRANSLATIONS = load_translations()
def _is_metno():
return USE_METNO
def _find_srv_for_query(path, query): # pylint: disable=unused-argument
if _is_metno():
return "https://api.met.no"
return "http://api.worldweatheronline.com"
def _cache_file(path, query):
"""Return cache file name for specified `path` and `query`
and for the current time.
Do smooth load on the server, expiration time
is slightly varied basing on the path+query sha1 hash digest.
"""
digest = hashlib.sha1(("%s %s" % (path, query)).encode("utf-8")).hexdigest()
digest_number = ord(digest[0].upper())
penalty = 0
expiry_interval = 60 * (digest_number + penalty)
timestamp = "%010d" % (int(time.time()) // expiry_interval * expiry_interval)
filename = os.path.join(PROXY_CACHEDIR, timestamp, path, query)
return filename
def _load_content_and_headers(path, query):
if is_testmode():
cache_file = "test/proxy-data/data1"
else:
cache_file = _cache_file(path, query)
try:
return (
open(cache_file, "r").read(),
json.loads(open(cache_file + ".headers", "r").read()),
)
except IOError:
return None, None
def _touch_empty_file(path, query):
cache_file = _cache_file(path, query)
cache_dir = os.path.dirname(cache_file)
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
open(cache_file, "w").write("")
def _save_content_and_headers(path, query, content, headers):
cache_file = _cache_file(path, query)
cache_dir = os.path.dirname(cache_file)
if not os.path.exists(cache_dir):
os.makedirs(cache_dir)
open(cache_file + ".headers", "w").write(json.dumps(headers))
open(cache_file, "wb").write(content)
def translate(text, lang):
"""
Translate `text` into `lang`.
If `text` is comma-separated, translate each term independently.
If no translation found, leave it untouched.
"""
def _log_unknown_translation(lang, text):
with open(MISSING_TRANSLATION_LOG % lang, "a") as f_missing_translation:
f_missing_translation.write(text + "\n")
if "," in text:
terms = text.split(",")
translated_terms = [translate(term.strip(), lang) for term in terms]
return ", ".join(translated_terms)
if lang not in TRANSLATIONS:
_log_unknown_translation(lang, "UNKNOWN_LANGUAGE")
return text
if text.lower() not in TRANSLATIONS.get(lang, {}):
_log_unknown_translation(lang, text)
return text
translated = TRANSLATIONS.get(lang, {}).get(text.lower(), 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.replace("Ηλιόλουστη/ο", "Ηλιόλουστη")
def add_translations(content, lang):
"""
Add `lang` translation to `content` (JSON)
returned by the data source
"""
if content == "{}":
return {}
languages_to_translate = TRANSLATIONS.keys()
try:
d = json.loads(content) # pylint: disable=invalid-name
except (ValueError, TypeError) as exception:
print("---")
print(exception)
print("---")
return {}
if "current_condition" not in d["data"]:
return content
try:
weather_condition = (
d["data"]["current_condition"][0]["weatherDesc"][0]["value"]
.capitalize()
.strip()
)
d["data"]["current_condition"][0]["weatherDesc"][0]["value"] = weather_condition
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"]
)
}
]
elif lang == "el":
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{
"value": _patch_greek(
d["data"]["current_condition"][0]["lang_%s" % lang][0]["value"]
)
}
]
elif lang == "sr-lat":
d["data"]["current_condition"][0]["lang_%s" % lang] = [
{"value": d["data"]["current_condition"][0]["lang_sr"][0]["value"]}
]
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"].strip()
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"])}
]
elif lang == "el":
h["lang_%s" % lang] = [
{"value": _patch_greek(h["lang_%s" % lang][0]["value"])}
]
elif lang == "sr-lat":
h["lang_%s" % lang] = [{"value": h["lang_sr"][0]["value"]}]
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
def _fetch_content_and_headers(path, query_string, **kwargs):
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)
attempts = 10
response = None
error = ""
while attempts:
try:
response = requests.get(url, timeout=2, **kwargs)
except requests.ReadTimeout:
attempts -= 1
continue
try:
data = json.loads(response.content)
error = data.get("data", {}).get("error", "")
if error:
try:
error = error[0]["msg"]
except (ValueError, IndexError):
error = "invalid error format: %s" % error
break
except ValueError:
attempts -= 1
error = "invalid response"
proxy_logger.log(query_string, error)
_touch_empty_file(path, query_string)
if response:
headers = {}
headers["Content-Type"] = response.headers["content-type"]
_save_content_and_headers(path, query_string, response.content, headers)
content = response.content
else:
content = "{}"
return content, headers
def _make_query(path, query_string):
if _is_metno():
path, query, days = metno_request(path, query_string)
if USER_AGENT == "":
raise ValueError(
"User agent must be set to adhere to metno ToS: https://api.met.no/doc/TermsOfService"
)
content, headers = _fetch_content_and_headers(
path, query, headers={"User-Agent": USER_AGENT}
)
content = create_standard_json_from_metno(content, days)
else:
# WWO tweaks
query_string += "&extra=localObsTime"
query_string += "&includelocation=yes"
content, headers = _fetch_content_and_headers(path, query_string)
return content, headers
def _normalize_query_string(query_string):
# Normalized query string has the following fixes:
# 1. Uses , for the coordinates separation
# 2. Limits number of digits after .
coord_match = re.search(r"q=[^&]*", query_string)
coords_str = coord_match.group(0)
coords = re.findall(r"[-0-9.]+", coords_str)
if len(coords) != 2:
return query_string
lat = str(round(float(coords[0]), 2))
lng = str(round(float(coords[1]), 2))
query_string = re.sub(r"q=[^&]*", "q=" + lat + "," + lng, query_string)
# print(query_string)
# nqs = query_string.replace("%2C", ",")
# lat, lng = nqs.split(",", 1)
# nqs = f"{lat:.2f},{lng:.2f}"
return query_string
@APP.route("/<path:path>")
def proxy(path):
"""
Main proxy function. Handles incoming HTTP queries.
"""
lang = request.args.get("lang", "en")
query_string = request.query_string.decode("utf-8")
query_string = _normalize_query_string(query_string)
query_string = query_string.replace("sr-lat", "sr")
query_string = query_string.replace("lang=None", "lang=en")
content = ""
headers = ""
content, headers = _make_query(path, query_string)
# _log_query(path, query_string, error)
content = add_translations(content, lang)
if "Unable to find any" in content:
print(query_string)
return content, 200, headers
if __name__ == "__main__":
# app.run(host='0.0.0.0', port=5001, debug=False)
# app.debug = True
if len(sys.argv) == 1:
bind_addr = "0.0.0.0"
logging.getLogger('werkzeug').setLevel(logging.ERROR) # Suppress Werkzeug logs
SERVER = WSGIServer((bind_addr, PROXY_PORT), APP, log=None)
SERVER.serve_forever()
else:
print("running single request from command line arg")
APP.testing = True
with APP.test_client() as c:
resp = c.get(sys.argv[1])
print("Status: " + resp.status)
# print('Headers: ' + dumps(resp.headers))
print(resp.data.decode("utf-8"))
================================================
FILE: bin/srv.py
================================================
#!/usr/bin/env python
# vim: set encoding=utf-8
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 jinja2
from flask import Flask, request, send_from_directory, send_file
APP = Flask(__name__)
MYDIR = os.path.abspath(os.path.dirname(os.path.dirname("__file__")))
sys.path.append("%s/lib/" % MYDIR)
import wttr_srv
from globals import TEMPLATES, STATIC, LISTEN_HOST, LISTEN_PORT
# pylint: enable=wrong-import-position,wrong-import-order
# from view.v3 import v3_file
MY_LOADER = jinja2.ChoiceLoader(
[
APP.jinja_loader,
jinja2.FileSystemLoader(TEMPLATES),
]
)
APP.jinja_loader = MY_LOADER
# @APP.route("/v3/<string:location>")
# def send_v3(location):
# filepath = v3_file(location)
# if filepath.startswith("ERROR"):
# return filepath.rstrip("\n") + "\n"
# return send_file(filepath)
@APP.route("/files/<path:path>")
def send_static(path):
"Send any static file located in /files/"
return send_from_directory(STATIC, path)
@APP.route("/favicon.ico")
def send_favicon():
"Send static file favicon.ico"
return send_from_directory(STATIC, "favicon.ico")
@APP.route("/malformed-response.html")
def send_malformed():
"Send static file malformed-response.html"
return send_from_directory(STATIC, "malformed-response.html")
@APP.route("/")
@APP.route("/<string:location>")
def wttr(location=None):
"Main function wrapper"
return wttr_srv.wttr(location, request)
SERVER = WSGIServer(
(LISTEN_HOST, int(os.environ.get("WTTRIN_SRV_PORT", LISTEN_PORT))), APP
)
SERVER.serve_forever()
================================================
FILE: config/services/services.yaml
================================================
services:
- name: "main server"
command: "while true; do sudo /wttr.in/bin/srv big-cache.yaml ; sleep 5; done"
workdir: "$HOME"
port: ...
- name: "geo server"
command: "while true; do /wttr.in/bin/srv geo.yaml; sleep 5; done"
workdir: "$HOME"
env:
- NOMINATIM_OPENCAGE
port: 8085
- name: "proxy"
command: ve/bin/python3 bin/proxy.py
workdir: "/wttr.in/wttr.in-v2-v2"
port: 5001
- name: "format=j1"
command: "WTTRIN_SRV_PORT=9003 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9003
- name: "format=line"
command: "WTTRIN_SRV_PORT=9004 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9004
- name: "format=v1"
command: "WTTRIN_SRV_PORT=9005 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9005
- name: "filetype=png"
command: "WTTRIN_SRV_PORT=9006 ve/bin/python3 bin/srv.py"
workdir: "/wttr.in/wttr.in-v2-v2"
port: 9006
================================================
FILE: doc/integrations.md
================================================
## Integrations
Thanks to the ease of integrating *wttr.in* into any program, there are a
plethora of popular integrations across various libraries, programming
languages, and systems.
*wttr.in* is compatible with:
* terminal managers,
* window managers,
* editors,
* chat clients,
and more, these integrations enhance workflow efficiency by embedding weather information directly into user interfaces.
Below, we've compiled a list of some of these integrations. While not
exhaustive, it serves as a guide to help you use *wttr.in* within your favorite
application or add integration to a new one.
| Integration For | Short Description | Repository |
|----------------------------------------------------|------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| **Terminal Managers** | | |
| [tmux](#tmux) | Allows embedding weather data in the tmux status bar with customizable update intervals. | - |
| **Window Managers Statusbars** | | |
| [Waybar](#Waybar) | A custom module in Rust for displaying weather in Waybar, with support for custom indicators and error handling. | [wttrbar](https://github.com/bjesus/wttrbar) |
| [Xmobar](#Xmobar) | A Python script for integrating weather data into Xmobar. | [weather-xmobar-wttr.in](https://github.com/alexeygumirov/weather-xmobar-wttr.in) |
| [AwesomeWM](#AwesomeWM) | Displays weather in AwesomeWM with a focus on clean UI and tooltips. | [wttr-widget](https://github.com/pivaldi/wttr-widget) |
| **Editors** | | |
| [Emacs](#Emacs) | An Emacs frontend for showing weather forecasts directly within Emacs. | [emacs-wttrin](https://github.com/cjennings/emacs-wttrin) |
| [Emacs](#Emacs) | Displays weather in the Emacs mode line with emoji support, configurable for updates. | [display-wttr](https://git.sr.ht/~josegpt/display-wttr) |
| **Chats** | | |
| [Conky](#Conky) | Example script for displaying weather in Conky using transparent images. | - |
| [WeeChat](#WeeChat) | Script for embedding weather in the WeeChat IRC client's status bar. | - |
| [IRC](#IRC) | Uses Qt-based mirc bot to show weather updates. | [IRC integration](https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor) |
| **Other** | | |
| [ObsidianMD](#ObsidianMD) | Script for embedding weather in Squeak's world main docking bar. | - |
| [RainMeter](#Rainmeter) | | - |
| [Squeak](#Squeak) | | - |
| [Twitch](#Twitch) | | - |
| [GoogleSheets/Excel365](#google-sheets--excel-365) | Direct wttr.in access from Google Sheets / Excel 365 | - |
| [Raycast](#Raycast) | Weather extension for Raycast | - |
## Terminal Managers
### tmux
When using in `tmux.conf`, you have to escape `%` with `%`, i.e. write there `%%` instead of `%`.
The output does not contain new line by default, when the %-notation is used, but it does contain it when preconfigured format (`1`,`2`,`3` etc.)
are used. To have the new line in the output when the %-notation is used, use '\n' and single quotes when doing a query from the shell.
In programs, 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 ..."
```

## Window Managers Statusbars
### Waybar
[wttrbar](https://github.com/bjesus/wttrbar) by *bjesus*
A custom module for displaying weather in Waybar using wttr.in. It’s written in
Rust for reliability and supports features like custom indicators (e.g.,
temperature in Celsius or Fahrenheit), location specification, and date
formatting.
Example configuration:
```json
"custom/weather": {
"format": "{}°",
"tooltip": true,
"interval": 3600,
"exec": "wttrbar --location=Paris --main-indicator=FeelsLikeC --date-format=%d.%m.%Y",
"return-type": "json"
}
```
Installation:
Compile using cargo build --release or download pre-built binaries. Requires a font supporting emojis (e.g., Noto Emoji) for weather icons.
Features: Supports custom styling based on weather conditions (e.g., sunny), handles wttr.in errors gracefully, and allows wind direction display with Unicode arrows.
<img src="https://user-images.githubusercontent.com/55081/232401699-b8345fe0-ffce-4353-b51b-615389153448.png" alt="wttrbar" width="300px">
### Xmobar
[weather-xmobar-wttr.in](https://github.com/alexeygumirov/weather-xmobar-wttr.in) by *alexeygumirov*
A Python-based script for displaying weather status in Xmobar, leveraging
wttr.in. It provides a lightweight solution for integrating weather data into
the Xmobar status bar.

(displays weather conditions in a compact format suitable for Xmobar).
### AwesomeWM
[wttr-widget](https://github.com/pivaldi/wttr-widget) by *pivaldi*
A weather widget for AwesomeWM that uses wttr.in to display weather information. It’s designed to integrate seamlessly with the Awesome window manager, providing a tooltip with detailed weather data.
Displays weather conditions with a focus on a clean, customizable UI. The screenshot shows a tooltip with weather details like temperature and conditions.

(shows a tooltip with weather data integrated into AwesomeWM)
## Editors
### Emacs
#### emacs-wttrin by bcbcarl
[emacs-wttrin](https://github.com/bcbcarl/emacs-wttrin) by *bcbcarl*
An Emacs frontend for wttr.in, allowing users to view weather forecasts directly within Emacs. It’s designed for simplicity and integration into the Emacs workflow.
Users can configure it to fetch weather for specific locations and display it in a buffer.
#### display-wttr by josegpt
[display-wttr](https://git.sr.ht/~josegpt/display-wttr) by *josegpt*
Displays wttr.in weather data in the Emacs mode line with emoji support (requires Emacs 28 or later). It’s lightweight and configurable for periodic updates.
Configuration Example:
```elisp
(use-package display-wttr
:config
(display-wttr-mode))
```
Supports custom locations, update intervals, and emoji-based weather display. The repository has moved to https://git.sr.ht/~josegpt/display-wttr.
Screenshot:

(shows weather with emojis in the Emacs mode line).
## Chats
### conky
Conky usage example:
```
${texeci 1800 curl wttr.in/kyiv_0pq_lang=uk.png
| convert - -transparent black $HOME/.config/conky/out.png}
${image $HOME/.config/conky/out.png -p 0,0}
```

### WeeChat
To embed in to an IRC ([WeeChat](https://github.com/weechat/weechat)) client's existing status bar:
```
/alias add wttr /exec -pipe "/mute /set plugins.var.wttr" url:wttr.in/Montreal?format=%l:+%c+%f+%h+%p+%P+%m+%w+%S+%s;/wait 3 /item refresh wttr
/trigger add wttr timer 60000;0;0 "" "" "/wttr"
/item add wttr "" "${plugins.var.wttr}"
/eval /set weechat.bar.status.items ${weechat.bar.status.items},spacer,wttr
/eval /set weechat.startup.command_after_plugins ${weechat.startup.command_after_plugins};/wttr
/wttr
```

### IRC
[IRC integration](https://github.com/OpenSourceTreasure/Mirc-ASCII-weather-translate-pixel-editor)

## Other
### ObsidianMD Integration
A script for ObsidianMD (a note-taking app) integrates wttr.in to embed weather data in daily notes using the Templater plugin. It fetches and displays weather details for a specified location only for the current day’s note.
*Usage:* Add a script to the Templater plugin to check if the note’s date matches today and fetch weather data. Example script:
```javascript
<%* if (tp.file.title === tp.date.now()) { %>
<%* const weather = await requestUrl('https://wttr.in/Berlin?format=%l:+%c+%C+%t+feels+like+%f\nTime:+++++%T\nSunrise:++%S\nSunset:+++%s\nMoon:+++++%m\nWind:+++++%w\nRainfall:+%p\nHumidity:+%h') %>
<%* tR += weather.text %>
<%* } else { %>
Update the weather report!
<%* } %>
```
This displays weather details like temperature, sunrise/sunset, moon phase, wind, rainfall, and humidity in the note body.
*Features:* Updates only for the current day’s note, supports custom formats, and can be triggered manually via a hotkey.
### Rainmeter
Rainmeter, a desktop customization tool for Windows, can use wttr.in to display
weather data by parsing its JSON output. This is particularly useful for
creating custom desktop widgets.
Use a URL like `https://wttr.in/Alexandria,Virginia?format=j1` to fetch
JSON data, which includes detailed weather metrics (e.g., temperature,
humidity, wind speed, weather code). A Lua script can map weather codes to
local icons for visualization.
Parses JSON for detailed weather data, supports integration with
custom icons (referencing WorldWeatherOnline’s weather codes), and allows for
flexible widget design.
### Squeak
To embed into the world main docking bar:
```smalltalk
wttr := (UpdatingStringMorph on: [(WebClient httpGet: 'https://wttr.in/?format=%20%20%l:%20%C+%t') content] selector: #value)
stepTime: 60000;
useStringFormat;
yourself.
dockingBar := World mainDockingBars first.
dockingBar addMorph: wttr after: (dockingBar findA: ClockMorph).
```

### Twitch
wttr.in is used to create a custom `!weather` command for Twitch streams, allowing viewers to query weather for a specific location (e.g., `!weather Toronto`).
The command formats wttr.in output for display in chat.
```bash
curl "https://wttr.in/Toronto?format=:+%c+%l+is+currently+%C+with+a+temperature+of+%t+(+%f).+The+wind+is+blowing+from+%w.+The+Humidity+is+currently+%h&u"
```
This outputs, e.g., : ⛅️ Toronto is currently Partly Cloudy with a temperature
of +7°C (+45°F). The wind is blowing from NE. The humidity is currently 65%. To
display Fahrenheit in parentheses, add `?u` to the URL for USCS units.
Features: Customizable output for Twitch chat, supports both Celsius and
Fahrenheit, and handles location-based queries dynamically.
Details: [wttr.in-on-twitch](https://www.reddit.com/r/commandline/comments/1eqoa0w/creating_a_weather_command_using_wttrin_service/)
### Google Sheets / Excel 365
It is possible to show the live weather in Google Sheets.
Assume you want the weather image for a specific location, and the location is either hardcoded or stored in a cell.
Example 1: Hardcoded Location (London):
In a cell (e.g., A1), enter:
excel
```
=IMAGE("https://wttr.in/London_0pq.png", 1)
```
This inserts a weather image for London, resized to fit the cell while maintaining the aspect ratio.
Example 2: Dynamic Location (from a cell):
If cell B1 contains the location (e.g., London), use:
```excel
=IMAGE("https://wttr.in/"&B1&"_0pq.png", 4, 100, 200)
```
This inserts the image with a custom size (100px height, 200px width). Adjust height and width as needed.
Customize the wttr.in URL (Optional):
* `_0pq`: Simple weather image (current conditions, no background).
* `_m`: Metric units (e.g., Celsius).
* `_u`: USCS units (e.g., Fahrenheit).
* `_t`: Transparent background.
Example with metric units and transparency:
```
=IMAGE("https://wttr.in/"&B1&"_0pqt_m.png", 1)
```

### Raycast
* Raycast Store: https://www.raycast.com/tonka3000/weather
* Source code: [Github](https://github.com/raycast/extensions/tree/542ed079c2eb5a95df0835d83ab1f1c2b1970e44/extensions/weather/)
* Author: [tonka3000](https://github.com/tonka3000)
Raycast is a handy tool for Mac users that helps them work faster and more
efficiently. It's popular among developers, designers, and tech enthusiasts who
want to streamline their workflows.
With Raycast, you can quickly launch apps,
search for files, and perform everyday tasks all from one central place,
boosting productivity and saving time.
With the "Weather" extension, you can quickly check information about the current weather.

================================================
FILE: doc/terminal-images.md
================================================
## Map view (v3)
In the experimental map view, that is available under the view code `v3`,
weather information about a geographical region is available:
```
$ curl v3.wttr.in/Bayern.sxl
```

or directly in browser:
* https://v3.wttr.in/Bayern
The map view currently supports three formats:
* PNG (for browser and messangers);
* Sixel (terminal inline images support);
* IIP (terminal with iterm2 inline images protocol support).
## Terminal with images support
| Terminal | Environment | Images support | Protocol |
| --------------------- | --------- | ------------- | --------- |
| uxterm | X11 | yes | Sixel |
| mlterm | X11 | yes | Sixel |
| kitty | X11 | yes | Kitty |
| wezterm | X11 | yes | IIP |
| Darktile | X11 | yes | Sixel |
| Jexer | X11 | yes | Sixel |
| GNOME Terminal | X11 | [in-progress](https://gitlab.gnome.org/GNOME/vte/-/issues/253) | Sixel |
| alacritty | X11 | [in-progress](https://github.com/alacritty/alacritty/issues/910) | Sixel |
| st | X11 | [stixel](https://github.com/vizs/stixel) or [st-sixel](https://github.com/galatolofederico/st-sixel) | Sixel |
| Konsole | X11 | yes | Sixel |
| DomTerm | Web | yes | Sixel |
| Yaft | FB | yes | Sixel |
| iTerm2 | Mac OS X| yes | IIP |
| mintty | Windows | yes | Sixel |
| Windows Terminal | Windows | [in-progress](https://github.com/microsoft/terminal/issues/448) | Sixel |
| [RLogin](http://nanno.dip.jp/softlib/man/rlogin/) | Windows | yes | Sixel | |
Support in all VTE-based terminals: termite, terminator, etc is more or less the same as in the GNOME Terminal
## Notes
### xterm/uxterm
To start xterm/uxterm with Sixel support:
```
uxterm -ti vt340
```
### Kitty
To view images in kitty:
```
curl -s v3.wttr.in/Tabasco.png | kitty icat --align=left
```
or even without `curl` at all, because `icat` knows how to handle URLs:
```
kitty icat --align=left https://v3.wttr.in/Tabasco.png
```
================================================
FILE: go.mod
================================================
module github.com/chubin/wttr.in
go 1.16
require (
github.com/alecthomas/kong v1.7.0
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204 // indirect
github.com/go-sql-driver/mysql v1.5.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
github.com/hashicorp/golang-lru v1.0.2
github.com/itchyny/gojq v0.12.11 // indirect
github.com/klauspost/lctime v0.1.0
github.com/lib/pq v1.8.0 // indirect
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-runewidth v0.0.16
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron v1.2.0
github.com/samonzeweb/godb v1.0.13
github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/assertions v1.2.0 // indirect
github.com/smartystreets/goconvey v1.6.4 // indirect
github.com/stretchr/testify v1.8.1 // indirect
github.com/zsefvlol/timezonemapper v1.0.0
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sys v0.30.0 // indirect
google.golang.org/appengine v1.6.3 // indirect
gopkg.in/yaml.v3 v3.0.1
)
================================================
FILE: go.sum
================================================
github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v0.7.1 h1:azoTh0IOfwlAX3qN9sHWTxACE2oV8Bg2gAwBsMwDQY4=
github.com/alecthomas/kong v0.7.1/go.mod h1:n1iCIO2xS46oE8ZfYCNDqdR0b0wZNrXAIAqro/2132U=
github.com/alecthomas/kong v1.7.0 h1:MnT8+5JxFDCvISeI6vgd/mFbAJwueJ/pqQNzZMsiqZE=
github.com/alecthomas/kong v1.7.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
github.com/alecthomas/repr v0.1.0/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.0.0-20190920000552-128d9f4ae1cd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/denisenkom/go-mssqldb v0.0.0-20200910202707-1e08a3fab204/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/itchyny/gojq v0.12.11 h1:YhLueoHhHiN4mkfM+3AyJV6EPcCxKZsOnYf+aVSwaQw=
github.com/itchyny/gojq v0.12.11/go.mod h1:o3FT8Gkbg/geT4pLI0tF3hvip5F3Y/uskjRz9OYa38g=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70=
github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/samonzeweb/godb v1.0.8 h1:WRn6nq0FChYOzh+w8SgpXHUkEhL7W6ZqkCf5Ninx7Uc=
github.com/samonzeweb/godb v1.0.8/go.mod h1:LNDt3CakfBwpRY4AD0y/QPTbj+jB6O17tSxQES0p47o=
github.com/samonzeweb/godb v1.0.13 h1:dEWijZGizhSN7oOLFYq0+NquG54DCJ9WG55bEcH7GOA=
github.com/samonzeweb/godb v1.0.13/go.mod h1:Dcm9f9+aO6bQin4Ce4X3oOM2gvhGMt2naLIDLPQSaWQ=
github.com/samonzeweb/godb v1.0.15 h1:HyNb8o1w109as9KWE8ih1YIBe8jC4luJ22f1XNacW38=
github.com/samonzeweb/godb v1.0.15/go.mod h1:SxCHqyireDXNrZApknS9lGUEutA43x9eJF632ecbK5Q=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zsefvlol/timezonemapper v1.0.0 h1:HXqkOzf01gXYh2nDQcDSROikFgMaximnhE8BY9SyF6E=
github.com/zsefvlol/timezonemapper v1.0.0/go.mod h1:cVUCOLEmc/VvOMusEhpd2G/UBtadL26ZVz2syODXDoQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.3/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/config/config.go
================================================
package config
import (
"log"
"os"
"gopkg.in/yaml.v3"
"github.com/chubin/wttr.in/internal/types"
"github.com/chubin/wttr.in/internal/util"
)
// Config of the program.
type Config struct {
Cache
Geo
Logging
Server
Uplink
}
// Logging configuration.
type Logging struct {
// AccessLog path.
AccessLog string `yaml:"accessLog,omitempty"`
// ErrorsLog path.
ErrorsLog string `yaml:"errorsLog,omitempty"`
// Interval between access log flushes, in seconds.
Interval int `yaml:"interval,omitempty"`
}
// Server configuration.
type Server struct {
// PortHTTP is port where HTTP server must listen.
// If 0, HTTP is disabled.
PortHTTP int `yaml:"portHttp,omitempty"`
// PortHTTP is port where the HTTPS server must listen.
// If 0, HTTPS is disabled.
PortHTTPS int `yaml:"portHttps,omitempty"`
// TLSCertFile contains path to cert file for TLS Server.
TLSCertFile string `yaml:"tlsCertFile,omitempty"`
// TLSCertFile contains path to key file for TLS Server.
TLSKeyFile string `yaml:"tlsKeyFile,omitempty"`
}
// Uplink configuration.
type Uplink struct {
// Address1 contains address of the uplink server in form IP:PORT
// for format=j1 queries.
Address1 string `yaml:"address1,omitempty"`
// Address2 contains address of the uplink server in form IP:PORT
// for format=* queries.
Address2 string `yaml:"address2,omitempty"`
// Address3 contains address of the uplink server in form IP:PORT
// for all other queries.
Address3 string `yaml:"address3,omitempty"`
// Address4 contains address of the uplink server in form IP:PORT
// for PNG queries.
Address4 string `yaml:"address4,omitempty"`
// Timeout for upstream queries.
Timeout int `yaml:"timeout,omitempty"`
// PrefetchInterval contains time (in milliseconds) indicating,
// how long the prefetch procedure should take.
PrefetchInterval int `yaml:"prefetchInterval,omitempty"`
}
// Cache configuration.
type Cache struct {
// Size of the main cache.
Size int `yaml:"size,omitempty"`
}
// Geo contains geolocation configuration.
type Geo struct {
// IPCache contains the path to the IP Geodata cache.
IPCache string `yaml:"ipCache,omitempty"`
// IPCacheDB contains the path to the SQLite DB with the IP Geodata cache.
IPCacheDB string `yaml:"ipCacheDb,omitempty"`
IPCacheType types.CacheType `yaml:"ipCacheType,omitempty"`
// LocationCache contains the path to the Location Geodata cache.
LocationCache string `yaml:"locationCache,omitempty"`
// LocationCacheDB contains the path to the SQLite DB with the Location Geodata cache.
LocationCacheDB string `yaml:"locationCacheDb,omitempty"`
LocationCacheType types.CacheType `yaml:"locationCacheType,omitempty"`
Nominatim []Nominatim `yaml:"nominatim"`
}
type Nominatim struct {
Name string
// Type describes the type of the location service.
// Supported types: iq.
Type string
URL string
Token string
}
// Default contains the default configuration.
func Default() *Config {
return &Config{
Cache{
Size: 12800,
},
Geo{
IPCache: "/wttr.in/cache/ip2l",
IPCacheDB: "/wttr.in/cache/geoip.db",
IPCacheType: types.CacheTypeDB,
LocationCache: "/wttr.in/cache/loc",
LocationCacheDB: "/wttr.in/cache/geoloc.db",
LocationCacheType: types.CacheTypeDB,
Nominatim: []Nominatim{
{
Name: "locationiq",
Type: "iq",
URL: "https://eu1.locationiq.com/v1/search",
Token: os.Getenv("NOMINATIM_LOCATIONIQ"),
},
{
Name: "opencage",
Type: "opencage",
URL: "https://api.opencagedata.com/geocode/v1/json",
Token: os.Getenv("NOMINATIM_OPENCAGE"),
},
},
},
Logging{
AccessLog: "/wttr.in/log/access.log",
ErrorsLog: "/wttr.in/log/errors.log",
Interval: 300,
},
Server{
PortHTTP: 8083,
PortHTTPS: 8084,
TLSCertFile: "/wttr.in/etc/fullchain.pem",
TLSKeyFile: "/wttr.in/etc/privkey.pem",
},
Uplink{
Address1: "127.0.0.1:9002",
Address2: "127.0.0.1:9002",
Address3: "127.0.0.1:9002",
Address4: "127.0.0.1:9002",
Timeout: 30,
PrefetchInterval: 300,
},
}
}
// Load config from file.
func Load(filename string) (*Config, error) {
var (
config Config
data []byte
err error
)
data, err = os.ReadFile(filename)
if err != nil {
return nil, err
}
err = util.YamlUnmarshalStrict(data, &config)
if err != nil {
return nil, err
}
return &config, nil
}
func (c *Config) Dump() []byte {
data, err := yaml.Marshal(c)
if err != nil {
// should never happen.
log.Fatalln("config.Dump():", err)
}
return data
}
================================================
FILE: internal/fmt/png/colors.go
================================================
package main
// Source: https://www.ditig.com/downloads/256-colors.json
var ansiColorsDB = [][3]float64{
{
0, 0, 0,
},
{
128, 0, 0,
},
{
0, 128, 0,
},
{
128, 128, 0,
},
{
0, 0, 128,
},
{
128, 0, 128,
},
{
0, 128, 128,
},
{
192, 192, 192,
},
{
128, 128, 128,
},
{
255, 0, 0,
},
{
0, 255, 0,
},
{
255, 255, 0,
},
{
0, 0, 255,
},
{
255, 0, 255,
},
{
0, 255, 255,
},
{
255, 255, 255,
},
{
0, 0, 0,
},
{
0, 0, 95,
},
{
0, 0, 135,
},
{
0, 0, 175,
},
{
0, 0, 215,
},
{
0, 0, 255,
},
{
0, 95, 0,
},
{
0, 95, 95,
},
{
0, 95, 135,
},
{
0, 95, 175,
},
{
0, 95, 215,
},
{
0, 95, 255,
},
{
0, 135, 0,
},
{
0, 135, 95,
},
{
0, 135, 135,
},
{
0, 135, 175,
},
{
0, 135, 215,
},
{
0, 135, 255,
},
{
0, 175, 0,
},
{
0, 175, 95,
},
{
0, 175, 135,
},
{
0, 175, 175,
},
{
0, 175, 215,
},
{
0, 175, 255,
},
{
0, 215, 0,
},
{
0, 215, 95,
},
{
0, 215, 135,
},
{
0, 215, 175,
},
{
0, 215, 215,
},
{
0, 215, 255,
},
{
0, 255, 0,
},
{
0, 255, 95,
},
{
0, 255, 135,
},
{
0, 255, 175,
},
{
0, 255, 215,
},
{
0, 255, 255,
},
{
95, 0, 0,
},
{
95, 0, 95,
},
{
95, 0, 135,
},
{
95, 0, 175,
},
{
95, 0, 215,
},
{
95, 0, 255,
},
{
95, 95, 0,
},
{
95, 95, 95,
},
{
95, 95, 135,
},
{
95, 95, 175,
},
{
95, 95, 215,
},
{
95, 95, 255,
},
{
95, 135, 0,
},
{
95, 135, 95,
},
{
95, 135, 135,
},
{
95, 135, 175,
},
{
95, 135, 215,
},
{
95, 135, 255,
},
{
95, 175, 0,
},
{
95, 175, 95,
},
{
95, 175, 135,
},
{
95, 175, 175,
},
{
95, 175, 215,
},
{
95, 175, 255,
},
{
95, 215, 0,
},
{
95, 215, 95,
},
{
95, 215, 135,
},
{
95, 215, 175,
},
{
95, 215, 215,
},
{
95, 215, 255,
},
{
95, 255, 0,
},
{
95, 255, 95,
},
{
95, 255, 135,
},
{
95, 255, 175,
},
{
95, 255, 215,
},
{
95, 255, 255,
},
{
135, 0, 0,
},
{
135, 0, 95,
},
{
135, 0, 135,
},
{
135, 0, 175,
},
{
135, 0, 215,
},
{
135, 0, 255,
},
{
135, 95, 0,
},
{
135, 95, 95,
},
{
135, 95, 135,
},
{
135, 95, 175,
},
{
135, 95, 215,
},
{
135, 95, 255,
},
{
135, 135, 0,
},
{
135, 135, 95,
},
{
135, 135, 135,
},
{
135, 135, 175,
},
{
135, 135, 215,
},
{
135, 135, 255,
},
{
135, 175, 0,
},
{
135, 175, 95,
},
{
135, 175, 135,
},
{
135, 175, 175,
},
{
135, 175, 215,
},
{
135, 175, 255,
},
{
135, 215, 0,
},
{
135, 215, 95,
},
{
135, 215, 135,
},
{
135, 215, 175,
},
{
135, 215, 215,
},
{
135, 215, 255,
},
{
135, 255, 0,
},
{
135, 255, 95,
},
{
135, 255, 135,
},
{
135, 255, 175,
},
{
135, 255, 215,
},
{
135, 255, 255,
},
{
175, 0, 0,
},
{
175, 0, 95,
},
{
175, 0, 135,
},
{
175, 0, 175,
},
{
175, 0, 215,
},
{
175, 0, 255,
},
{
175, 95, 0,
},
{
175, 95, 95,
},
{
175, 95, 135,
},
{
175, 95, 175,
},
{
175, 95, 215,
},
{
175, 95, 255,
},
{
175, 135, 0,
},
{
175, 135, 95,
},
{
175, 135, 135,
},
{
175, 135, 175,
},
{
175, 135, 215,
},
{
175, 135, 255,
},
{
175, 175, 0,
},
{
175, 175, 95,
},
{
175, 175, 135,
},
{
175, 175, 175,
},
{
175, 175, 215,
},
{
175, 175, 255,
},
{
175, 215, 0,
},
{
175, 215, 95,
},
{
175, 215, 135,
},
{
175, 215, 175,
},
{
175, 215, 215,
},
{
175, 215, 255,
},
{
175, 255, 0,
},
{
175, 255, 95,
},
{
175, 255, 135,
},
{
175, 255, 175,
},
{
175, 255, 215,
},
{
175, 255, 255,
},
{
215, 0, 0,
},
{
215, 0, 95,
},
{
215, 0, 135,
},
{
215, 0, 175,
},
{
215, 0, 215,
},
{
215, 0, 255,
},
{
215, 95, 0,
},
{
215, 95, 95,
},
{
215, 95, 135,
},
{
215, 95, 175,
},
{
215, 95, 215,
},
{
215, 95, 255,
},
{
215, 135, 0,
},
{
215, 135, 95,
},
{
215, 135, 135,
},
{
215, 135, 175,
},
{
215, 135, 215,
},
{
215, 135, 255,
},
{
215, 175, 0,
},
{
215, 175, 95,
},
{
215, 175, 135,
},
{
215, 175, 175,
},
{
215, 175, 215,
},
{
215, 175, 255,
},
{
215, 215, 0,
},
{
215, 215, 95,
},
{
215, 215, 135,
},
{
215, 215, 175,
},
{
215, 215, 215,
},
{
215, 215, 255,
},
{
215, 255, 0,
},
{
215, 255, 95,
},
{
215, 255, 135,
},
{
215, 255, 175,
},
{
215, 255, 215,
},
{
215, 255, 255,
},
{
255, 0, 0,
},
{
255, 0, 95,
},
{
255, 0, 135,
},
{
255, 0, 175,
},
{
255, 0, 215,
},
{
255, 0, 255,
},
{
255, 95, 0,
},
{
255, 95, 95,
},
{
255, 95, 135,
},
{
255, 95, 175,
},
{
255, 95, 215,
},
{
255, 95, 255,
},
{
255, 135, 0,
},
{
255, 135, 95,
},
{
255, 135, 135,
},
{
255, 135, 175,
},
{
255, 135, 215,
},
{
255, 135, 255,
},
{
255, 175, 0,
},
{
255, 175, 95,
},
{
255, 175, 135,
},
{
255, 175, 175,
},
{
255, 175, 215,
},
{
255, 175, 255,
},
{
255, 215, 0,
},
{
255, 215, 95,
},
{
255, 215, 135,
},
{
255, 215, 175,
},
{
255, 215, 215,
},
{
255, 215, 255,
},
{
255, 255, 0,
},
{
255, 255, 95,
},
{
255, 255, 135,
},
{
255, 255, 175,
},
{
255, 255, 215,
},
{
255, 255, 255,
},
{
8, 8, 8,
},
{
18, 18, 18,
},
{
28, 28, 28,
},
{
38, 38, 38,
},
{
48, 48, 48,
},
{
58, 58, 58,
},
{
68, 68, 68,
},
{
78, 78, 78,
},
{
88, 88, 88,
},
{
98, 98, 98,
},
{
108, 108, 108,
},
{
118, 118, 118,
},
{
128, 128, 128,
},
{
138, 138, 138,
},
{
148, 148, 148,
},
{
158, 158, 158,
},
{
168, 168, 168,
},
{
178, 178, 178,
},
{
188, 188, 188,
},
{
198, 198, 198,
},
{
208, 208, 208,
},
{
218, 218, 218,
},
{
228, 228, 228,
},
{
238, 238, 238,
},
}
================================================
FILE: internal/fmt/png/go.mod
================================================
module example.com/m/v2
go 1.20
require (
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.14.0 // indirect
)
================================================
FILE: internal/fmt/png/go.sum
================================================
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 h1:CHg5BTAJZmCjBaAAQrD92s248JHH3JTsLlaC6QBJo/Y=
github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1/go.mod h1:mQssL2gI1LTqWgbffl6DESqe6QkAF67ujBdzSe4bWkU=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
================================================
FILE: internal/fmt/png/png.go
================================================
package main
import (
"fmt"
"log"
"os"
"strings"
"github.com/chubin/vt10x"
"github.com/fogleman/gg"
)
func StringSliceToRuneSlice(s string) [][]rune {
strings := strings.Split(s, "\n")
result := make([][]rune, len(strings))
i := 0
for _, str := range strings {
if len(str) == 0 {
continue
}
result[i] = []rune(str)
i++
}
return result
}
func maxRowLength(rows [][]rune) int {
maxLen := 0
for _, row := range rows {
if len(row) > maxLen {
maxLen = len(row)
}
}
return maxLen
}
func GeneratePng() {
runes := StringSliceToRuneSlice(`
Weather report: Hochstadt an der Aisch, Germany
\ / Partly cloudy
_ /"".-. +5(2) °C
\_( ). ↗ 9 km/h
/(___(__) 10 km
0.0 mm
┌─────────────┐
┌───────────────────────┤ Sat 11 Nov ├───────────────────────┐
│ Noon └──────┬──────┘ Night │
├──────────────────────────────┼──────────────────────────────┤
│ _'/"".-. Patchy rain po…│ _'/"".-. Patchy rain po…│
│ ,\_( ). +6(3) °C │ ,\_( ). +5(2) °C │
│ /(___(__) → 22-29 km/h │ /(___(__) ↗ 14-20 km/h │
│ ‘ ‘ ‘ ‘ 10 km │ ‘ ‘ ‘ ‘ 10 km │
│ ‘ ‘ ‘ ‘ 0.1 mm | 86% │ ‘ ‘ ‘ ‘ 0.0 mm | 89% │
└──────────────────────────────┴──────────────────────────────┘
┌─────────────┐
┌───────────────────────┤ Sun 12 Nov ├───────────────────────┐
│ Noon └──────┬──────┘ Night │
├──────────────────────────────┼──────────────────────────────┤
│ \ / Partly cloudy │ .-. Light drizzle │
│ _ /"".-. +8(7) °C │ ( ). +5(2) °C │
│ \_( ). ↑ 7-8 km/h │ (___(__) ↑ 13-18 km/h │
│ /(___(__) 10 km │ ‘ ‘ ‘ ‘ 2 km │
│ 0.0 mm | 0% │ ‘ ‘ ‘ ‘ 0.3 mm | 76% │
└──────────────────────────────┴──────────────────────────────┘
`)
// Dimensions of each rune in pixels
runeWidth := 8
runeHeight := 14
// Compute the width and height of the final image
imageWidth := runeWidth * maxRowLength(runes)
imageHeight := runeHeight * len(runes)
// Create a new context with the computed dimensions
dc := gg.NewContext(imageWidth, imageHeight)
// fontPath := "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
// fontPath := "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"
fontPath := "/usr/share/fonts/truetype/lexi/LexiGulim.ttf"
err := dc.LoadFontFace(fontPath, 13)
if err != nil {
log.Fatal(err)
}
// Loop through each rune in the array and draw it on the context
for i, row := range runes {
for j, char := range row {
// Compute the x and y coordinates for drawing the current rune
x := float64(j*runeWidth + runeWidth/2)
y := float64(i*runeHeight + runeHeight/2)
// Set the appropriate color for the current rune
if char == '#' {
dc.SetRGB(0, 0, 0) // Black
} else if char == '@' {
dc.SetRGB(1, 0, 0) // Red
} else {
dc.SetRGB(1, 1, 1) // White
}
character := string(char)
// if char == ' ' {
// character = fmt.Sprint(j % 10)
// }
dc.DrawRectangle(x, y, x+float64(runeWidth), y+float64(runeHeight))
dc.Fill()
// Draw a rectangle with the rune's dimensions and color
dc.DrawString(character, x, y) // Draw the character centered on the canvas
// dc.DrawStringAnchored(character, x, y, 0.5, 0.5) // Draw the character centered on the canvas
}
}
// Save the image to a PNG file
err = dc.SavePNG("output.png")
if err != nil {
fmt.Println("Error saving PNG:", err)
return
}
fmt.Println("PNG generated successfully")
}
func GeneratePngFromANSI(input []byte, outputFile string) error {
// Dimensions of each rune in pixels
runeWidth := 8
runeHeight := 14
fontSize := 13.0
// fontPath := "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
fontPath := "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"
imageCols := 80
imageRows := 25
// Compute the width and height of the final image
imageWidth := runeWidth * imageCols
imageHeight := runeHeight * imageRows
// Create terminal and feed it with input.
term := vt10x.New(vt10x.WithSize(imageCols, imageRows))
_, err := term.Write([]byte("\033[20h"))
if err != nil {
return fmt.Errorf("virtual terminal write error: %w", err)
}
_, err = term.Write(input)
if err != nil {
return fmt.Errorf("virtual terminal write error: %w", err)
}
// Create a new context with the computed dimensions
dc := gg.NewContext(imageWidth, imageHeight)
err = dc.LoadFontFace(fontPath, fontSize) // Set font size to 96
if err != nil {
return fmt.Errorf("error loading font: %w", err)
}
// Loop through each rune in the array and draw it on the context
for i := 0; i < imageRows; i++ {
for j := 0; j < imageCols; j++ {
// Compute the x and y coordinates for drawing the current rune
x := float64(j * runeWidth)
y := float64(i * runeHeight)
cell := term.Cell(j, i)
character := string(cell.Char)
dc.DrawRectangle(x, y, float64(runeWidth), float64(runeHeight))
bg := colorANSItoRGB(cell.BG)
dc.SetRGB(bg[0], bg[1], bg[2])
dc.Fill()
fg := colorANSItoRGB(cell.FG)
dc.SetRGB(fg[0], fg[1], fg[2])
// Draw a rectangle with the rune's dimensions and color
dc.DrawString(character, x, y+float64(runeHeight)-3) // Draw the character centered on the canvas
// dc.DrawStringAnchored(character, x, y, 0.5, 0.5) // Draw the character centered on the canvas
}
}
// Save the image to a PNG file
err = dc.SavePNG(outputFile)
if err != nil {
return fmt.Errorf("error saving png: %w", err)
}
return nil
}
func colorANSItoRGB(colorANSI vt10x.Color) [3]float64 {
defaultBG := vt10x.Color(0)
defaultFG := vt10x.Color(8)
if colorANSI == vt10x.DefaultFG {
colorANSI = defaultFG
}
if colorANSI == vt10x.DefaultBG {
colorANSI = defaultBG
}
if colorANSI > 255 {
return [3]float64{127, 127, 127}
}
return ansiColorsDB[colorANSI]
}
func main() {
data, err := os.ReadFile("zh-text.txt")
if err != nil {
log.Fatalln(err)
}
err = GeneratePngFromANSI(data, "output.png")
if err != nil {
log.Fatalln(err)
}
}
================================================
FILE: internal/geo/ip/convert.go
================================================
package ip
import (
"fmt"
"log"
"path/filepath"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
"github.com/chubin/wttr.in/internal/util"
)
//nolint:cyclop
func (c *Cache) ConvertCache() error {
dbfile := c.config.Geo.IPCacheDB
err := util.RemoveFileIfExists(dbfile)
if err != nil {
return err
}
db, err := godb.Open(sqlite.Adapter, dbfile)
if err != nil {
return err
}
err = createTable(db, "Address")
if err != nil {
return err
}
log.Println("listing cache entries...")
files, err := filepath.Glob(filepath.Join(c.config.Geo.IPCache, "*"))
if err != nil {
return err
}
log.Printf("going to convert %d entries\n", len(files))
block := []Address{}
for i, file := range files {
ip := filepath.Base(file)
loc, err := c.Read(ip)
if err != nil {
log.Println("invalid entry for", ip)
continue
}
block = append(block, *loc)
if i%1000 != 0 || i == 0 {
continue
}
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
block = []Address{}
log.Println("converted", i+1, "entries")
}
// inserting the rest.
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
log.Println("converted", len(files), "entries")
return nil
}
func createTable(db *godb.DB, tableName string) error {
createTable := fmt.Sprintf(
`create table %s (
name text not null primary key,
fullName text not null,
lat text not null,
long text not null);
`, tableName)
_, err := db.CurrentDB().Exec(createTable)
return err
}
================================================
FILE: internal/geo/ip/ip.go
================================================
package ip
import (
"fmt"
"log"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
"github.com/chubin/wttr.in/internal/config"
"github.com/chubin/wttr.in/internal/routing"
"github.com/chubin/wttr.in/internal/types"
"github.com/chubin/wttr.in/internal/util"
)
// Address information.
type Address struct {
IP string `db:"ip,key"`
CountryCode string `db:"countryCode"`
Country string `db:"country"`
Region string `db:"region"`
City string `db:"city"`
Latitude float64 `db:"latitude"`
Longitude float64 `db:"longitude"`
}
func (l *Address) String() string {
if l.Latitude == -1000 {
return fmt.Sprintf(
"%s;%s;%s;%s",
l.CountryCode, l.Country, l.Region, l.City)
}
return fmt.Sprintf(
"%s;%s;%s;%s;%v;%v",
l.CountryCode, l.Country, l.Region, l.City, l.Latitude, l.Longitude)
}
// Cache provides access to the IP Geodata cache.
type Cache struct {
config *config.Config
db *godb.DB
}
// NewCache returns new cache reader for the specified config.
func NewCache(config *config.Config) (*Cache, error) {
db, err := godb.Open(sqlite.Adapter, config.Geo.IPCacheDB)
if err != nil {
return nil, err
}
// Needed for "upsert" implementation in Put()
db.UseErrorParser()
return &Cache{
config: config,
db: db,
}, nil
}
// Read returns location information from the cache, if found,
// or types.ErrNotFound if not found. If the entry is found, but its format
// is invalid, types.ErrInvalidCacheEntry is returned.
//
// Format:
//
// [CountryCode];Country;Region;City;[Latitude];[Longitude]
//
// Example:
//
// DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782
//
func (c *Cache) Read(addr string) (*Address, error) {
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.readFromCacheDB(addr)
}
return c.readFromCacheFile(addr)
}
func (c *Cache) readFromCacheFile(addr string) (*Address, error) {
bytes, err := os.ReadFile(c.cacheFile(addr))
if err != nil {
return nil, types.ErrNotFound
}
return NewAddressFromString(addr, string(bytes))
}
func (c *Cache) readFromCacheDB(addr string) (*Address, error) {
result := Address{}
err := c.db.Select(&result).
Where("IP = ?", addr).
Do()
if err != nil {
return nil, err
}
return &result, nil
}
func (c *Cache) Put(addr string, loc *Address) error {
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.putToCacheDB(loc)
}
return c.putToCacheFile(addr, loc)
}
func (c *Cache) putToCacheDB(loc *Address) error {
err := c.db.Insert(loc).Do()
// it should work like this:
//
// target := dberror.UniqueConstraint{}
// if errors.As(err, &target) {
//
// See: https://github.com/samonzeweb/godb/pull/23
//
// But for some reason it does not work,
// so the dirty hack is used:
if strings.Contains(fmt.Sprint(err), "UNIQUE constraint failed") {
return c.db.Update(loc).Do()
}
return err
}
func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
return os.WriteFile(c.cacheFile(addr), []byte(loc.String()), 0o600)
}
// cacheFile returns path to the cache entry for addr.
func (c *Cache) cacheFile(addr string) string {
return path.Join(c.config.Geo.IPCache, addr)
}
// NewAddressFromString parses the location cache entry s,
// and return location, or error, if the cache entry is invalid.
func NewAddressFromString(addr, s string) (*Address, error) {
var (
lat float64 = -1000
long float64 = -1000
err error
)
parts := strings.Split(s, ";")
if len(parts) < 4 {
return nil, types.ErrInvalidCacheEntry
}
if len(parts) >= 6 {
lat, err = strconv.ParseFloat(parts[4], 64)
if err != nil {
return nil, types.ErrInvalidCacheEntry
}
long, err = strconv.ParseFloat(parts[5], 64)
if err != nil {
return nil, types.ErrInvalidCacheEntry
}
}
return &Address{
IP: addr,
CountryCode: parts[0],
Country: parts[1],
Region: parts[2],
City: parts[3],
Latitude: lat,
Longitude: long,
}, nil
}
// Response provides routing interface to the geo cache.
//
// Temporary workaround to switch IP addresses handling to the Go server.
// Handles two queries:
//
// - /:geo-ip-put?ip=IP&value=VALUE
// - /:geo-ip-get?ip=IP
//
//nolint:cyclop
func (c *Cache) Response(r *http.Request) *routing.Cadre {
var (
respERR = &routing.Cadre{Body: []byte("ERR")}
respOK = &routing.Cadre{Body: []byte("OK")}
)
if ip := util.ReadUserIP(r); ip != "127.0.0.1" {
log.Printf("geoIP access from %s rejected\n", ip)
return nil
}
if r.URL.Path == "/:geo-ip-put" {
ip := r.URL.Query().Get("ip")
value := r.URL.Query().Get("value")
if !validIP4(ip) || value == "" {
log.Printf("invalid geoIP put query: ip='%s' value='%s'\n", ip, value)
return respERR
}
location, err := NewAddressFromString(ip, value)
if err != nil {
return respERR
}
err = c.Put(ip, location)
if err != nil {
return respERR
}
return respOK
}
if r.URL.Path == "/:geo-ip-get" {
ip := r.URL.Query().Get("ip")
if !validIP4(ip) {
return respERR
}
result, err := c.Read(ip)
if result == nil || err != nil {
return respERR
}
return &routing.Cadre{Body: []byte(result.String())}
}
return nil
}
func validIP4(ipAddress string) bool {
re := regexp.MustCompile(
`^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`)
return re.MatchString(strings.Trim(ipAddress, " "))
}
================================================
FILE: internal/geo/ip/ip_test.go
================================================
package ip_test
import (
"testing"
"github.com/stretchr/testify/require"
. "github.com/chubin/wttr.in/internal/geo/ip"
"github.com/chubin/wttr.in/internal/types"
)
//nolint:funlen
func TestParseCacheEntry(t *testing.T) {
t.Parallel()
tests := []struct {
addr string
input string
expected Address
err error
}{
{
"1.2.3.4",
"DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;9.9782",
Address{
IP: "1.2.3.4",
CountryCode: "DE",
Country: "Germany",
Region: "Free and Hanseatic City of Hamburg",
City: "Hamburg",
Latitude: 53.5736,
Longitude: 9.9782,
},
nil,
},
{
"1.2.3.4",
"ES;Spain;Madrid, Comunidad de;Madrid;40.4165;-3.70256;28223;Orange Espagne SA;orange.es",
Address{
IP: "1.2.3.4",
CountryCode: "ES",
Country: "Spain",
Region: "Madrid, Comunidad de",
City: "Madrid",
Latitude: 40.4165,
Longitude: -3.70256,
},
nil,
},
{
"1.2.3.4",
"US;United States of America;California;Mountain View",
Address{
IP: "1.2.3.4",
CountryCode: "US",
Country: "United States of America",
Region: "California",
City: "Mountain View",
Latitude: -1000,
Longitude: -1000,
},
nil,
},
// Invalid entries
{
"1.2.3.4",
"DE;Germany;Free and Hanseatic City of Hamburg;Hamburg;53.5736;XXX",
Address{},
types.ErrInvalidCacheEntry,
},
}
for _, tt := range tests {
result, err := NewAddressFromString(tt.addr, tt.input)
if tt.err == nil {
require.NoError(t, err)
require.Equal(t, *result, tt.expected)
} else {
require.ErrorIs(t, err, tt.err)
}
}
}
================================================
FILE: internal/geo/location/cache.go
================================================
package location
import (
"encoding/json"
"errors"
"fmt"
"os"
"path"
"strconv"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
log "github.com/sirupsen/logrus"
"github.com/zsefvlol/timezonemapper"
"github.com/chubin/wttr.in/internal/config"
"github.com/chubin/wttr.in/internal/types"
)
// Cache is an implemenation of DB/file-based cache.
//
// At the moment, it is an implementation for the location cache,
// but it should be generalized to cache everything.
type Cache struct {
config *config.Config
db *godb.DB
searcher *Searcher
indexField string
filesCacheDir string
}
// NewCache returns new cache reader for the specified config.
func NewCache(config *config.Config) (*Cache, error) {
var (
db *godb.DB
err error
)
if config.Geo.LocationCacheType == types.CacheTypeDB {
log.Debugln("using db for location cache")
db, err = godb.Open(sqlite.Adapter, config.Geo.LocationCacheDB)
if err != nil {
return nil, err
}
log.Debugln("db file:", config.Geo.LocationCacheDB)
// Needed for "upsert" implementation in Put()
db.UseErrorParser()
}
return &Cache{
config: config,
db: db,
indexField: "name",
filesCacheDir: config.Geo.LocationCache,
searcher: NewSearcher(config),
}, nil
}
// Resolve returns location information for specified location.
// If the information is found in the cache, it is returned.
// If it is not found, the external service is queried,
// and the result is stored in the cache.
func (c *Cache) Resolve(location string) (*Location, error) {
location = normalizeLocationName(location)
loc, err := c.Read(location)
if !errors.Is(err, types.ErrNotFound) {
return loc, err
}
log.Debugln("geo/location: not found in cache:", location)
loc, err = c.searcher.Search(location)
if err != nil {
return nil, err
}
loc.Name = location
loc.Timezone = latLngToTimezoneString(loc.Lat, loc.Lon)
err = c.Put(location, loc)
if err != nil {
return nil, err
}
return loc, nil
}
// Read returns location information from the cache, if found,
// or types.ErrNotFound if not found. If the entry is found, but its format
// is invalid, types.ErrInvalidCacheEntry is returned.
func (c *Cache) Read(addr string) (*Location, error) {
if c.config.Geo.LocationCacheType == types.CacheTypeFiles {
return c.readFromCacheFile(addr)
}
return c.readFromCacheDB(addr)
}
func (c *Cache) readFromCacheFile(name string) (*Location, error) {
var (
fileLoc = struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Timezone string `json:"timezone"`
Address string `json:"address"`
}{}
location Location
)
bytes, err := os.ReadFile(c.cacheFile(name))
if err != nil {
return nil, types.ErrNotFound
}
err = json.Unmarshal(bytes, &fileLoc)
if err != nil {
return nil, err
}
// normalize name
name = strings.TrimSpace(
strings.TrimRight(
strings.TrimLeft(name, `"`), `"`))
timezone := fileLoc.Timezone
if timezone == "" {
timezone = timezonemapper.LatLngToTimezoneString(fileLoc.Latitude, fileLoc.Longitude)
}
location = Location{
Name: name,
Lat: fmt.Sprint(fileLoc.Latitude),
Lon: fmt.Sprint(fileLoc.Longitude),
Timezone: timezone,
Fullname: fileLoc.Address,
}
return &location, nil
}
func (c *Cache) readFromCacheDB(addr string) (*Location, error) {
result := Location{}
err := c.db.Select(&result).
Where(c.indexField+" = ?", addr).
Do()
if strings.Contains(fmt.Sprint(err), "no rows in result set") {
return nil, types.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("readFromCacheDB: %w", err)
}
return &result, nil
}
func (c *Cache) Put(addr string, loc *Location) error {
log.Infoln("geo/location: storing in cache:", loc)
if c.config.Geo.IPCacheType == types.CacheTypeDB {
return c.putToCacheDB(loc)
}
return c.putToCacheFile(addr, loc)
}
func (c *Cache) putToCacheDB(loc *Location) error {
err := c.db.Insert(loc).Do()
if strings.Contains(fmt.Sprint(err), "UNIQUE constraint failed") {
return c.db.Update(loc).Do()
}
return err
}
func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
return os.WriteFile(c.cacheFile(addr), []byte(loc.String()), 0o600)
}
// cacheFile returns path to the cache entry for addr.
func (c *Cache) cacheFile(item string) string {
return path.Join(c.filesCacheDir, item)
}
// normalizeLocationName converts name into the standard location form
// with the following steps:
// - remove excessive spaces,
// - remove quotes,
// - convert to lover case.
func normalizeLocationName(name string) string {
name = strings.ReplaceAll(name, `"`, " ")
name = strings.ReplaceAll(name, `'`, " ")
name = strings.TrimSpace(name)
name = strings.Join(strings.Fields(name), " ")
return strings.ToLower(name)
}
// latLngToTimezoneString returns timezone for lat, lon,
// or an empty string if they are invalid.
func latLngToTimezoneString(lat, lon string) string {
latFloat, err := strconv.ParseFloat(lat, 64)
if err != nil {
log.Errorln("geoloc: latLngToTimezoneString:", err)
return ""
}
lonFloat, err := strconv.ParseFloat(lon, 64)
if err != nil {
log.Errorln("geoloc: latLngToTimezoneString:", err)
return ""
}
return timezonemapper.LatLngToTimezoneString(latFloat, lonFloat)
}
================================================
FILE: internal/geo/location/convert.go
================================================
package location
import (
"database/sql"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/samonzeweb/godb"
"github.com/samonzeweb/godb/adapters/sqlite"
)
// ConvertCache converts files-based cache into the DB-based cache.
// If reset is true, the DB cache is created from scratch.
//
//nolint:funlen,cyclop
func (c *Cache) ConvertCache(reset bool) error {
var (
dbfile = c.config.Geo.LocationCacheDB
tableName = "Location"
cacheFiles = c.filesCacheDir
known = map[string]bool{}
)
if reset {
err := removeDBIfExists(dbfile)
if err != nil {
return err
}
}
db, err := godb.Open(sqlite.Adapter, dbfile)
if err != nil {
return err
}
if reset {
err = createTable(db, tableName)
if err != nil {
return err
}
}
log.Println("listing cache entries...")
files, err := filepath.Glob(filepath.Join(cacheFiles, "*"))
if err != nil {
return err
}
log.Printf("going to convert %d entries\n", len(files))
block := []Location{}
for i, file := range files {
ip := filepath.Base(file)
loc, err := c.Read(ip)
if err != nil {
log.Println("invalid entry for", ip)
continue
}
// Skip too long location names.
if len(loc.Name) > 25 {
continue
}
// Skip duplicates.
if known[loc.Name] {
log.Println("skipping", loc.Name)
continue
}
singleLocation := Location{}
err = db.Select(&singleLocation).
Where("name = ?", loc.Name).
Do()
if !errors.Is(err, sql.ErrNoRows) {
log.Println("found in db:", loc.Name)
continue
}
known[loc.Name] = true
// Skip some invalid names.
if strings.Contains(loc.Name, "\n") {
continue
}
block = append(block, *loc)
if i%1000 != 0 || i == 0 {
continue
}
log.Println("going to insert new entries")
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
block = []Location{}
log.Println("converted", i+1, "entries")
}
// inserting the rest.
err = db.BulkInsert(&block).Do()
if err != nil {
return err
}
log.Println("converted", len(files), "entries")
return nil
}
func createTable(db *godb.DB, tableName string) error {
createTable := fmt.Sprintf(
`create table %s (
name text not null primary key,
displayName text not null,
lat text not null,
lon text not null,
timezone text not null);
`, tableName)
_, err := db.CurrentDB().Exec(createTable)
return err
}
func removeDBIfExists(filename string) error {
_, err := os.Stat(filename)
if err != nil {
if !os.IsNotExist(err) {
return err
}
// no db file
return nil
}
return os.Remove(filename)
}
================================================
FILE: internal/geo/location/location.go
================================================
package location
import (
"encoding/json"
"log"
)
type Location struct {
Name string `db:"name,key" json:"name"`
Lat string `db:"lat" json:"latitude"`
Lon string `db:"lon" json:"longitude"`
Timezone string `db:"timezone" json:"timezone"`
Fullname string `db:"displayName" json:"address"`
}
// String returns string representation of location.
func (l *Location) String() string {
bytes, err := json.Marshal(l)
if err != nil {
// should never happen
log.Fatalln(err)
}
return string(bytes)
}
================================================
FILE: internal/geo/location/nominatim.go
================================================
package location
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/chubin/wttr.in/internal/types"
log "github.com/sirupsen/logrus"
)
type Nominatim struct {
name string
url string
token string
typ string
}
type locationQuerier interface {
Query(*Nominatim, string) (*Location, error)
}
func NewNominatim(name, typ, url, token string) *Nominatim {
return &Nominatim{
name: name,
url: url,
token: token,
typ: typ,
}
}
func (n *Nominatim) Query(location string) (*Location, error) {
var data locationQuerier
switch n.typ {
case "iq":
data = &locationIQ{}
case "opencage":
data = &locationOpenCage{}
default:
return nil, fmt.Errorf("%s: %w", n.name, types.ErrUnknownLocationService)
}
return data.Query(n, location)
}
func makeQuery(url string, result interface{}) error {
var errResponse struct {
Error string
}
log.Debugln("nominatim:", url)
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
err = json.Unmarshal(body, &errResponse)
if err == nil && errResponse.Error != "" {
return fmt.Errorf("%w: %s", types.ErrUpstream, errResponse.Error)
}
log.Debugln("nominatim: response: ", string(body))
err = json.Unmarshal(body, &result)
if err != nil {
return err
}
return nil
}
================================================
FILE: internal/geo/location/nominatim_locationiq.go
================================================
package location
import (
"fmt"
"net/url"
"github.com/chubin/wttr.in/internal/types"
)
type locationIQ []struct {
Name string `db:"name,key"`
Lat string `db:"lat"`
Lon string `db:"lon"`
//nolint:tagliatelle
Fullname string `db:"displayName" json:"display_name"`
}
func (data *locationIQ) Query(n *Nominatim, location string) (*Location, error) {
url := fmt.Sprintf(
"%s?q=%s&format=json&language=native&limit=1&key=%s",
n.url, url.QueryEscape(location), n.token)
err := makeQuery(url, data)
if err != nil {
return nil, fmt.Errorf("%s: %w", n.name, err)
}
if len(*data) != 1 {
return nil, fmt.Errorf("%w: %s: invalid response", types.ErrUpstream, n.name)
}
nl := &(*data)[0]
return &Location{
Lat: nl.Lat,
Lon: nl.Lon,
Fullname: nl.Fullname,
}, nil
}
================================================
FILE: internal/geo/location/nominatim_opencage.go
================================================
package location
import (
"fmt"
"net/url"
"github.com/chubin/wttr.in/internal/types"
)
type locationOpenCage struct {
Results []struct {
Name string `db:"name,key"`
Geometry struct {
Lat float64 `db:"lat"`
Lng float64 `db:"lng"`
}
Fullname string `json:"formatted"`
} `json:"results"`
}
func (data *locationOpenCage) Query(n *Nominatim, location string) (*Location, error) {
url := fmt.Sprintf(
"%s?q=%s&language=native&limit=1&key=%s",
n.url, url.QueryEscape(location), n.token)
err := makeQuery(url, data)
if err != nil {
return nil, fmt.Errorf("%s: %w", n.name, err)
}
if len(data.Results) != 1 {
return nil, fmt.Errorf("%w: %s: invalid response", types.ErrUpstream, n.name)
}
nl := data.Results[0]
return &Location{
Lat: fmt.Sprint(nl.Geometry.Lat),
Lon: fmt.Sprint(nl.Geometry.Lng),
Fullname: nl.Fullname,
}, nil
}
================================================
FILE: internal/geo/location/response.go
================================================
package location
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"github.com/chubin/wttr.in/internal/routing"
)
// Response provides routing interface to the geo cache.
func (c *Cache) Response(r *http.Request) *routing.Cadre {
var (
locationName = r.URL.Query().Get("location")
loc *Location
bytes []byte
err error
)
if locationName == "" {
return errorResponse("location is not specified")
}
if strings.Contains(locationName, ".html") || strings.Contains(locationName, ".txt") {
return errorResponse("invalid location")
}
loc, err = c.Resolve(locationName)
if err != nil {
log.Println("geo/location error:", locationName, r.RemoteAddr)
return errorResponse(fmt.Sprint(err))
}
bytes, err = json.Marshal(loc)
if err != nil {
return errorResponse(fmt.Sprint(err))
}
return &routing.Cadre{Body: bytes}
}
func errorResponse(s string) *routing.Cadre {
return &routing.Cadre{Body: []byte(
fmt.Sprintf(`{"error": %q}`, s),
)}
}
================================================
FILE: internal/geo/location/search.go
================================================
package location
import "github.com/chubin/wttr.in/internal/config"
type Provider interface {
Query(location string) (*Location, error)
}
type Searcher struct {
providers []Provider
}
// NewSearcher returns a new Searcher for the specified config.
func NewSearcher(config *config.Config) *Searcher {
providers := []Provider{}
for _, p := range config.Geo.Nominatim {
providers = append(providers, NewNominatim(p.Name, p.Type, p.URL, p.Token))
}
return &Searcher{
providers: providers,
}
}
// Search makes queries through all known providers,
// and returns response, as soon as it is not nil.
// If all responses were nil, the last response is returned.
func (s *Searcher) Search(location string) (*Location, error) {
var (
err error
result *Location
)
for _, p := range s.providers {
result, err = p.Query(location)
if result != nil && err == nil {
return result, nil
}
}
return result, err
}
================================================
FILE: internal/logging/logging.go
================================================
package logging
import (
"fmt"
"net/http"
"os"
"sync"
"time"
"github.com/chubin/wttr.in/internal/util"
)
// Logging request.
//
// RequestLogger logs all incoming HTTP requests.
type RequestLogger struct {
buf map[logEntry]int
filename string
m sync.Mutex
period time.Duration
lastFlush time.Time
}
type logEntry struct {
Proto string
IP string
URI string
UserAgent string
}
// NewRequestLogger returns a new RequestLogger for the specified log file.
// Flush logging entries after period of time.
//
// If filename is empty, no log will be written, and all logging entries
// will be silently dropped.
func NewRequestLogger(filename string, period time.Duration) *RequestLogger {
return &RequestLogger{
buf: map[logEntry]int{},
filename: filename,
m: sync.Mutex{},
period: period,
}
}
// Log logs information about a HTTP request.
func (rl *RequestLogger) Log(r *http.Request) error {
le := logEntry{
Proto: "http",
IP: util.ReadUserIP(r),
URI: r.RequestURI,
UserAgent: r.Header.Get("User-Agent"),
}
if r.TLS != nil {
le.Proto = "https"
}
// Do not log 127.0.0.1 connections
if le.IP == "127.0.0.1" {
return nil
}
rl.m.Lock()
rl.buf[le]++
rl.m.Unlock()
if time.Since(rl.lastFlush) > rl.period {
return rl.flush()
}
return nil
}
// flush stores log data to disk, and flushes the buffer.
func (rl *RequestLogger) flush() error {
rl.m.Lock()
defer rl.m.Unlock()
// It is possible, that while waiting the mutex,
// the buffer was already flushed.
if time.Since(rl.lastFlush) <= rl.period {
return nil
}
if rl.filename != "" {
// Generate log output.
output := ""
for k, hitsNumber := range rl.buf {
output += fmt.Sprintf("%s %3d %s\n", time.Now().Format(time.RFC3339), hitsNumber, k.String())
}
// Open log file.
//nolint:nosnakecase
f, err := os.OpenFile(rl.filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
if err != nil {
return err
}
defer f.Close()
// Save output to log file.
_, err = f.Write([]byte(output))
if err != nil {
return err
}
}
// Flush buffer.
rl.buf = map[logEntry]int{}
rl.lastFlush = time.Now()
return nil
}
// String returns string representation of logEntry.
func (e *logEntry) String() string {
return fmt.Sprintf(
"%s %s %s %s",
e.Proto,
e.IP,
e.URI,
e.UserAgent,
)
}
================================================
FILE: internal/logging/suppress.go
================================================
package logging
import (
"os"
"strings"
"sync"
)
// LogSuppressor provides io.Writer interface for logging
// with lines suppression. For usage with log.Logger.
type LogSuppressor struct {
filename string
suppress []string
linePrefix string
logFile *os.File
m sync.Mutex
}
// NewLogSuppressor creates a new LogSuppressor for specified
// filename and lines to be suppressed.
//
// If filename is empty, log entries will be printed to stderr.
func NewLogSuppressor(filename string, suppress []string, linePrefix string) *LogSuppressor {
return &LogSuppressor{
filename: filename,
suppress: suppress,
linePrefix: linePrefix,
}
}
// Open opens log file.
func (ls *LogSuppressor) Open() error {
var err error
if ls.filename == "" {
return nil
}
//nolint:nosnakecase
ls.logFile, err = os.OpenFile(ls.filename, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o600)
return err
}
// Close closes log file.
func (ls *LogSuppressor) Close() error {
if ls.filename == "" {
return nil
}
return ls.logFile.Close()
}
// Write writes p to log, and returns number f bytes written.
// Implements io.Writer interface.
func (ls *LogSuppressor) Write(p []byte) (int, error) {
var output string
if ls.filename == "" {
return os.Stdin.Write(p)
}
ls.m.Lock()
defer ls.m.Unlock()
lines := strings.Split(string(p), ls.linePrefix)
for _, line := range lines {
if (func(line string) bool {
for _, suppress := range ls.suppress {
if strings.Contains(line, suppress) {
return true
}
}
return false
})(line) {
continue
}
output += line
}
return ls.logFile.Write([]byte(output))
}
================================================
FILE: internal/options/options.go
================================================
package options
import (
"fmt"
"io/ioutil"
"gopkg.in/yaml.v3"
)
// WttrInOptions represents the configuration for wttr.in query options and format specifiers.
type WttrInOptions struct {
QueryOptions []QueryOption `yaml:"query_options"`
FormatSpecifiers []FormatSpecifier `yaml:"format_specifiers"`
}
// QueryOption defines a single query option for wttr.in, such as `lang` or `format`.
type QueryOption struct {
// Name is the long name of the option (e.g., "lang", "current_only").
Name string `yaml:"name"`
// Short is the short name of the option (e.g., "m"), omitted if not present.
Short string `yaml:"short,omitempty"`
// Description of the option's purpose (e.g., "Specify output language").
Description string `yaml:"description"`
// Type is the data type of the option (e.g., "boolean", "string", "integer").
Type string `yaml:"type"`
// ValuesMap is the map of possible values to their descriptions (e.g., for `lang`, `format`).
ValuesMap map[string]string `yaml:"values_map,omitempty"`
// Values is the list of supported values (e.g., ["true", "false"] for booleans).
Values []string `yaml:"values,omitempty"`
// Range is the numeric range for integer options (e.g., min and max for `transparency`).
Range *Range `yaml:"range,omitempty"`
// Default is the default value of the option (e.g., "en" for `lang`, null for `background`).
Default interface{} `yaml:"default"`
// Validate contains validation conditions in function call style (e.g., "length 6" for `background`).
Validate []string `yaml:"validate,omitempty"`
// Active indicates if the option is implemented (true) or proposed (false).
Active bool `yaml:"active"`
// Note includes additional notes or caveats (e.g., "Proposed but not officially supported").
Note string `yaml:"note,omitempty"`
}
// Range defines a numeric range for integer-type query options.
type Range struct {
// Min is the minimum value of the range (e.g., 0 for `transparency`).
Min int `yaml:"min"`
// Max is the maximum value of the range, nullable for unbounded (e.g., null for `date`).
Max *int `yaml:"max"`
}
// FormatSpecifier defines a format specifier for the `format` option (e.g., %c, %t).
type FormatSpecifier struct {
// Specifier is the format specifier (e.g., "%c").
Specifier string `yaml:"specifier"`
// Description provides the output description of the specifier (e.g., "Weather condition").
Description string `yaml:"description"`
// Active indicates if the specifier is implemented (true) or proposed (false).
Active bool `yaml:"active"`
// Note contains additional notes, if any (e.g., "Proposed in issue #585").
Note string `yaml:"note,omitempty"`
}
// NewFromFile reads a QueryOption from a YAML file and returns a pointer to it.
func NewFromFile(filename string) (*WttrInOptions, error) {
// Read the YAML file
data, err := ioutil.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// Unmarshal the YAML content into a QueryOption struct
var option WttrInOptions
if err := yaml.Unmarshal(data, &option); err != nil {
return nil, fmt.Errorf("failed to unmarshal YAML: %w", err)
}
return &option, nil
}
================================================
FILE: internal/options/parse.go
================================================
package options
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
)
// Errors for query parsing and validation
var (
ErrInvalidQueryString = errors.New("ERR001: failed to parse query string")
ErrMultipleValues = errors.New("ERR002: option has multiple values")
ErrUnknownOption = errors.New("ERR003: unknown option")
ErrOptionNotImplemented = errors.New("ERR004: option is not implemented")
ErrBooleanValueRequired = errors.New("ERR005: option requires boolean value (true/false)")
ErrInvalidBooleanValue = errors.New("ERR006: invalid boolean value")
ErrValueRequired = errors.New("ERR007: option requires a value")
ErrInvalidStringValue = errors.New("ERR008: invalid string value")
ErrInvalidValidationRule = errors.New("ERR009: invalid validation rule")
ErrInvalidLengthRule = errors.New("ERR010: invalid length rule")
ErrInvalidLength = errors.New("ERR011: invalid length")
ErrInvalidRegexpRule = errors.New("ERR012: invalid regexp rule")
ErrInvalidRegexp = errors.New("ERR013: invalid regexp match")
ErrUnsupportedRule = errors.New("ERR014: unsupported validation rule")
ErrInvalidInteger = errors.New("ERR015: option requires an integer")
ErrBelowMinimum = errors.New("ERR016: value below minimum")
ErrAboveMaximum = errors.New("ERR017: value exceeds maximum")
ErrUnsupportedType = errors.New("ERR018: unsupported option type")
ErrInactiveFormatSpecifier = errors.New("ERR019: format specifier is not implemented")
)
// ParseQueryString parses a wttr.in query string into a map of option names to values.
// It validates option names, types, content, and values based on the provided WttrInOptions.
// Returns the parsed map or an error if parsing or validation fails.
func ParseQueryString(query string, config *WttrInOptions) (map[string]string, error) {
// Initialize result map and lookup tables
result := make(map[string]string)
shortToName, nameToOption := buildOptionLookups(config.QueryOptions)
// Parse query string
query = strings.ReplaceAll(
strings.ReplaceAll(query, "%25", "%"),
"%", "%25")
parsed, err := url.ParseQuery(query)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidQueryString, err)
}
// Process query parameters
for key, values := range parsed {
if len(values) == 0 || (len(values) == 1 && values[0] == "") {
if err := handleFlagOption(key, shortToName, nameToOption, result); err != nil {
return nil, err
}
continue
}
if len(values) > 1 {
return nil, fmt.Errorf("%w: %s: %v", ErrMultipleValues, key, values)
}
if err := handleValueOption(key, values[0], shortToName, nameToOption, result); err != nil {
return nil, err
}
}
// Validate format specifiers if format=custom
if formatValue, exists := result["format"]; exists && formatValue == "custom" {
for _, spec := range config.FormatSpecifiers {
if !spec.Active {
return nil, fmt.Errorf("%w: %s", ErrInactiveFormatSpecifier, spec.Specifier)
}
}
}
return result, nil
}
// buildOptionLookups creates lookup maps for short-to-long names and option details.
func buildOptionLookups(options []QueryOption) (map[string]string, map[string]QueryOption) {
shortToName := make(map[string]string)
nameToOption := make(map[string]QueryOption)
for _, opt := range options {
nameToOption[opt.Name] = opt
if opt.Short != "" {
shortToName[opt.Short] = opt.Name
}
}
return shortToName, nameToOption
}
// handleFlagOption processes flag options (e.g., "0pq" or "m") without values.
func handleFlagOption(key string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
// Handle bundled short options (e.g., "0pq")
if len(key) > 1 && !strings.Contains(key, "=") {
for _, short := range strings.Split(key, "") {
if err := validateAndSetFlag(short, shortToName, nameToOption, result); err != nil {
return err
}
}
return nil
}
// Handle single flag
return validateAndSetFlag(key, shortToName, nameToOption, result)
}
// validateAndSetFlag validates and sets a single flag option.
func validateAndSetFlag(key string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
name, exists := shortToName[key]
if !exists {
name = key // Try long name
}
opt, exists := nameToOption[name]
if !exists {
return fmt.Errorf("%w: %s", ErrUnknownOption, key)
}
if !opt.Active {
return fmt.Errorf("%w: %s", ErrOptionNotImplemented, name)
}
if opt.Type != "boolean" {
return fmt.Errorf("%w: %s", ErrValueRequired, key)
}
result[name] = "true"
return nil
}
// handleValueOption processes options with values (e.g., "lang=fr").
func handleValueOption(key, value string, shortToName map[string]string, nameToOption map[string]QueryOption, result map[string]string) error {
// Resolve option name
name, exists := shortToName[key]
if !exists {
name = key // Assume long name
}
opt, exists := nameToOption[name]
if !exists {
return fmt.Errorf("%w: %s", ErrUnknownOption, key)
}
if !opt.Active {
return fmt.Errorf("%w: %s", ErrOptionNotImplemented, name)
}
// Validate based on type
switch opt.Type {
case "boolean":
return validateBooleanOption(name, value, opt, result)
case "string":
return validateStringOption(name, value, opt, result)
case "integer":
return validateIntegerOption(name, value, opt, result)
default:
return fmt.Errorf("%w: %s: %s", ErrUnsupportedType, name, opt.Type)
}
}
// validateBooleanOption validates and sets boolean option values.
func validateBooleanOption(name, value string, opt QueryOption, result map[string]string) error {
if value != "true" && value != "false" {
return fmt.Errorf("%w: %s: %s", ErrBooleanValueRequired, name, value)
}
if len(opt.Values) > 0 {
for _, v := range opt.Values {
if v == value {
result[name] = value
return nil
}
}
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidBooleanValue, name, value, opt.Values)
}
result[name] = value
return nil
}
// validateStringOption validates and sets string option values, including background-specific rules.
func validateStringOption(name, value string, opt QueryOption, result map[string]string) error {
if len(opt.ValuesMap) > 0 {
if _, valid := opt.ValuesMap[value]; !valid {
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidStringValue, name, value, getMapKeys(opt.ValuesMap))
}
}
if len(opt.Values) > 0 {
for _, v := range opt.Values {
if v == value {
result[name] = value
return nil
}
}
return fmt.Errorf("%w: %s: %s, expected one of %v", ErrInvalidStringValue, name, value, opt.Values)
}
if name == "background" && len(opt.Validate) > 0 {
for _, rule := range opt.Validate {
if err := applyValidationRule(name, value, rule); err != nil {
return err
}
}
}
result[name] = value
return nil
}
// applyValidationRule applies a single validation rule (e.g., "length 6", "regexp [0-9a-fA-F]{6}").
func applyValidationRule(name, value, rule string) error {
parts := strings.SplitN(rule, " ", 2)
if len(parts) < 2 {
return fmt.Errorf("%w: %s: %s", ErrInvalidValidationRule, name, rule)
}
switch parts[0] {
case "length":
length, err := strconv.Atoi(parts[1])
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidLengthRule, name, rule)
}
if len(value) != length {
return fmt.Errorf("%w: %s: %s, expected length %d", ErrInvalidLength, name, value, length)
}
case "regexp":
re, err := regexp.Compile(parts[1])
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidRegexpRule, name, rule)
}
if !re.MatchString(value) {
return fmt.Errorf("%w: %s: %s, expected match for %s", ErrInvalidRegexp, name, value, parts[1])
}
default:
return fmt.Errorf("%w: %s: %s", ErrUnsupportedRule, name, rule)
}
return nil
}
// validateIntegerOption validates and sets integer option values.
func validateIntegerOption(name, value string, opt QueryOption, result map[string]string) error {
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("%w: %s: %s", ErrInvalidInteger, name, value)
}
if opt.Range != nil {
if num < opt.Range.Min {
return fmt.Errorf("%w: %s: %d, minimum is %d", ErrBelowMinimum, name, num, opt.Range.Min)
}
if opt.Range.Max != nil && num > *opt.Range.Max {
return fmt.Errorf("%w: %s: %d, maximum is %d", ErrAboveMaximum, name, num, *opt.Range.Max)
}
}
result[name] = value
return nil
}
// getMapKeys returns a sorted slice of keys from a map for error messages.
func getMapKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// Sort for consistent error messages
for i := 0; i < len(keys)-1; i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
return keys
}
================================================
FILE: internal/options/processlog.go
================================================
package options
import (
"bufio"
"fmt"
"os"
"strings"
)
// ProcessLogFile reads a wttr.in log file, parses queries, and writes invalid entries to an error file.
// Each log line is expected in the format: "timestamp request_id protocol ip query user_agent".
// Invalid queries and their error messages are written to errorFilePath.
func ProcessLogFile(logFilePath, errorFilePath string, config *WttrInOptions) error {
logFile, err := openLogFile(logFilePath)
if err != nil {
return err
}
defer logFile.Close()
errorFile, writer, err := openErrorFile(errorFilePath)
if err != nil {
return err
}
defer errorFile.Close()
defer writer.Flush()
return processLogLines(logFile, writer, config)
}
// openLogFile opens the log file for reading.
func openLogFile(logFilePath string) (*os.File, error) {
logFile, err := os.Open(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
return logFile, nil
}
// openErrorFile creates and opens the error file for writing.
func openErrorFile(errorFilePath string) (*os.File, *bufio.Writer, error) {
errorFile, err := os.Create(errorFilePath)
if err != nil {
return nil, nil, fmt.Errorf("failed to create error file: %w", err)
}
writer := bufio.NewWriter(errorFile)
return errorFile, writer, nil
}
// processLogLines reads and processes each line of the log file.
func processLogLines(logFile *os.File, writer *bufio.Writer, config *WttrInOptions) error {
scanner := bufio.NewScanner(logFile)
lineNumber := 0
for scanner.Scan() {
lineNumber++
if err := processLogLine(scanner.Text(), lineNumber, writer, config); err != nil {
return err
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read log file: %w", err)
}
return nil
}
// processLogLine processes a single log line and writes errors to the error file.
func processLogLine(line string, lineNumber int, writer *bufio.Writer, config *WttrInOptions) error {
query, err := extractQueryFromLine(line, lineNumber, writer)
if err != nil {
return err
}
_, err = ParseQueryString(query, config)
if err != nil {
_, writeErr := fmt.Fprintf(writer, "Line %d: Query: %s, Error: %v\n", lineNumber, query, err)
if writeErr != nil {
return fmt.Errorf("failed to write to error file at line %d: %w", lineNumber, writeErr)
}
}
return nil
}
// extractQueryFromLine extracts the query from a log line and validates its format.
func extractQueryFromLine(line string, lineNumber int, writer *bufio.Writer) (string, error) {
fields := strings.Fields(line)
if len(fields) < 5 {
_, err := fmt.Fprintf(writer, "Line %d: Invalid log format: %s\n", lineNumber, line)
if err != nil {
return "", fmt.Errorf("failed to write to error file at line %d: %w", lineNumber, err)
}
return "", nil
}
query := fields[4]
if strings.Contains(query, "?") {
parts := strings.SplitN(query, "?", 2)
if len(parts) == 2 {
return parts[1], nil
}
return "", nil
}
return "", nil
}
================================================
FILE: internal/processor/j1.go
================================================
package processor
import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"time"
)
func getAny(req *http.Request, tr1, tr2, tr3, tr4 *http.Transport) (*ResponseWithHeader, error) {
uri := strings.ReplaceAll(req.URL.RequestURI(), "%", "%25")
u, err := url.Parse(uri)
if err != nil {
return nil, err
}
format := u.Query().Get("format")
if format == "j1" {
return getJ1(req, tr1)
} else if format != "" {
return getFormat(req, tr2)
}
// log.Println(req.URL.Query())
// log.Println()
if checkURLForPNG(req) {
return getDefault(req, tr4)
}
return getDefault(req, tr3)
}
func getJ1(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getFormat(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getDefault(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
return getUpstream(req, transport)
}
func getUpstream(req *http.Request, transport *http.Transport) (*ResponseWithHeader, error) {
client := &http.Client{
Transport: transport,
}
queryURL := fmt.Sprintf("http://%s%s", req.Host, req.RequestURI)
proxyReq, err := http.NewRequest(req.Method, queryURL, req.Body)
if err != nil {
return nil, err
}
// proxyReq.Header.Set("Host", req.Host)
// proxyReq.Header.Set("X-Forwarded-For", req.RemoteAddr)
for header, values := range req.Header {
for _, value := range values {
proxyReq.Header.Add(header, value)
}
}
if proxyReq.Header.Get("X-Forwarded-For") == "" {
proxyReq.Header.Set("X-Forwarded-For", ipFromAddr(req.RemoteAddr))
}
res, err := client.Do(proxyReq)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: res.Header,
StatusCode: res.StatusCode,
}, nil
}
func checkURLForPNG(r *http.Request) bool {
url := r.URL.String()
return strings.Contains(url, ".png") && !strings.Contains(url, "/files/")
}
================================================
FILE: internal/processor/peak.go
================================================
package processor
import (
"log"
"net/http"
"sync"
"time"
"github.com/robfig/cron"
)
func (rp *RequestProcessor) startPeakHandling() error {
var err error
c := cron.New()
// cronTime := fmt.Sprintf("%d,%d * * * *", 30-prefetchInterval/60, 60-prefetchInterval/60)
err = c.AddFunc(
"24 * * * *",
func() { rp.prefetchPeakRequests(&rp.peakRequest30) },
)
if err != nil {
return err
}
err = c.AddFunc(
"54 * * * *",
func() { rp.prefetchPeakRequests(&rp.peakRequest60) },
)
if err != nil {
return err
}
c.Start()
return nil
}
// registerPeakRequest registers requests coming in the peak time.
// Such requests can be prefetched afterwards just before the peak time comes.
func (rp *RequestProcessor) savePeakRequest(cacheDigest string, r *http.Request) {
if _, min, _ := time.Now().Clock(); min == 30 {
rp.peakRequest30.Store(cacheDigest, *r)
} else if min == 0 {
rp.peakRequest60.Store(cacheDigest, *r)
}
}
func (rp *RequestProcessor) prefetchRequest(r *http.Request) error {
_, err := rp.ProcessRequest(r)
return err
}
func syncMapLen(sm *sync.Map) int {
count := 0
f := func(key, value interface{}) bool {
// Not really certain about this part, don't know for sure
// if this is a good check for an entry's existence
if key == "" {
return false
}
count++
return true
}
sm.Range(f)
return count
}
func (rp *RequestProcessor) prefetchPeakRequests(peakRequestMap *sync.Map) {
peakRequestLen := syncMapLen(peakRequestMap)
if peakRequestLen == 0 {
return
}
log.Printf("PREFETCH: Prefetching %d requests\n", peakRequestLen)
sleepBetweenRequests := time.Duration(rp.config.Uplink.PrefetchInterval*1000/peakRequestLen) * time.Millisecond
peakRequestMap.Range(func(key interface{}, value interface{}) bool {
req, ok := value.(http.Request)
if !ok {
log.Println("missing value for:", key)
return true
}
go func(r http.Request) {
err := rp.prefetchRequest(&r)
if err != nil {
log.Println("prefetch request:", err)
}
}(req)
peakRequestMap.Delete(key)
time.Sleep(sleepBetweenRequests)
return true
})
}
================================================
FILE: internal/processor/processor.go
================================================
package processor
import (
"context"
"fmt"
"log"
"math/rand"
"net"
"net/http"
"strings"
"sync"
"time"
lru "github.com/hashicorp/golang-lru"
"github.com/chubin/wttr.in/internal/config"
geoip "github.com/chubin/wttr.in/internal/geo/ip"
geoloc "github.com/chubin/wttr.in/internal/geo/location"
"github.com/chubin/wttr.in/internal/routing"
"github.com/chubin/wttr.in/internal/stats"
"github.com/chubin/wttr.in/internal/util"
)
// plainTextAgents contains signatures of the plain-text agents.
func plainTextAgents() []string {
return []string{
"curl",
"httpie",
"lwp-request",
"wget",
"python-httpx",
"python-requests",
"openbsd ftp",
"powershell",
"fetch",
"aiohttp",
"http_get",
"xh",
"nushell",
"zig",
"node",
}
}
type ResponseWithHeader struct {
InProgress bool // true if the request is being processed
Expires time.Time // expiration time of the cache entry
Body []byte
Header http.Header
StatusCode int // e.g. 200
}
// RequestProcessor handles incoming requests.
type RequestProcessor struct {
peakRequest30 sync.Map
peakRequest60 sync.Map
lruCache *lru.Cache
stats *stats.Stats
router routing.Router
upstreamTransport1 *http.Transport
upstreamTransport2 *http.Transport
upstreamTransport3 *http.Transport
upstreamTransport4 *http.Transport
config *config.Config
geoIPCache *geoip.Cache
geoLocation *geoloc.Cache
}
// NewRequestProcessor returns new RequestProcessor.
func NewRequestProcessor(config *config.Config) (*RequestProcessor, error) {
lruCache, err := lru.New(config.Cache.Size)
if err != nil {
return nil, err
}
dialer := &net.Dialer{
Timeout: time.Duration(config.Uplink.Timeout) * time.Second,
KeepAlive: time.Duration(config.Uplink.Timeout) * time.Second,
DualStack: true,
}
transport1 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address1)
},
}
transport2 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address2)
},
}
transport3 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address3)
},
}
transport4 := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, config.Uplink.Address4)
},
}
geoCache, err := geoip.NewCache(config)
if err != nil {
return nil, err
}
geoLocation, err := geoloc.NewCache(config)
if err != nil {
return nil, err
}
rp := &RequestProcessor{
lruCache: lruCache,
stats: stats.New(),
upstreamTransport1: transport1,
upstreamTransport2: transport2,
upstreamTransport3: transport3,
upstreamTransport4: transport4,
config: config,
geoIPCache: geoCache,
geoLocation: geoLocation,
}
// Initialize routes.
rp.router.AddPath("/:stats", rp.stats)
rp.router.AddPath("/:geo-ip-get", rp.geoIPCache)
rp.router.AddPath("/:geo-ip-put", rp.geoIPCache)
rp.router.AddPath("/:geo-location", rp.geoLocation)
return rp, nil
}
// Start starts async request processor jobs, such as peak handling.
func (rp *RequestProcessor) Start() error {
return rp.startPeakHandling()
}
func (rp *RequestProcessor) ProcessRequest(r *http.Request) (*ResponseWithHeader, error) {
var (
response *ResponseWithHeader
ip = util.ReadUserIP(r)
)
if ip != "127.0.0.1" {
rp.stats.Inc("total")
}
// Main routing logic.
if rh := rp.router.Route(r); rh != nil {
result := rh.Response(r)
if result != nil {
return fromCadre(result), nil
}
}
if resp, ok := redirectInsecure(r); ok {
rp.stats.Inc("redirects")
return resp, nil
}
if dontCache(r) {
rp.stats.Inc("uncached")
return getAny(r, rp.upstreamTransport1, rp.upstreamTransport2, rp.upstreamTransport3, rp.upstreamTransport4)
}
// processing cached request
cacheDigest := getCacheDigest(r)
rp.savePeakRequest(cacheDigest, r)
response = rp.processRequestFromCache(r)
if response != nil {
return response, nil
}
return rp.processUncachedRequest(r)
}
// processRequestFromCache processes requests using the cache.
// If no entry in cache found, nil is returned.
func (rp *RequestProcessor) processRequestFromCache(r *http.Request) *ResponseWithHeader {
var (
cacheEntry ResponseWithHeader
cacheDigest = getCacheDigest(r)
ok bool
)
cacheBody, _ := rp.lruCache.Get(cacheDigest)
cacheEntry, ok = cacheBody.(ResponseWithHeader)
if !ok {
return nil
}
// If after all attempts we still have no answer,
// respond with an error message.
// (WAS: we try to make the query on our own)
for attempts := 0; attempts < 300; attempts++ {
if !ok || !cacheEntry.InProgress {
break
}
time.Sleep(30 * time.Millisecond)
cacheBody, _ = rp.lruCache.Get(cacheDigest)
v, ok := cacheBody.(ResponseWithHeader)
if ok {
cacheEntry = v
}
}
if cacheEntry.InProgress {
// log.Printf("TIMEOUT: %s\n", cacheDigest)
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: []byte("This query is already being processed"),
StatusCode: 200,
}
}
if ok && !cacheEntry.InProgress && cacheEntry.Expires.After(time.Now()) {
rp.stats.Inc("cache1")
return &cacheEntry
}
return nil
}
// processUncachedRequest processes requests that were not found in the cache.
func (rp *RequestProcessor) processUncachedRequest(r *http.Request) (*ResponseWithHeader, error) {
var (
cacheDigest = getCacheDigest(r)
ip = util.ReadUserIP(r)
response *ResponseWithHeader
err error
)
// Indicate, that the request is being handled.
rp.lruCache.Add(cacheDigest, ResponseWithHeader{InProgress: true})
// Response was not found in cache.
// Starting real handling.
format := r.URL.Query().Get("format")
if len(format) != 0 {
rp.stats.Inc("format")
if format == "j1" {
rp.stats.Inc("format=j1")
}
}
// Count, how many IP addresses are known.
_, err = rp.geoIPCache.Read(ip)
if err == nil {
rp.stats.Inc("geoip")
}
response, err = getAny(r, rp.upstreamTransport1, rp.upstreamTransport2, rp.upstreamTransport3, rp.upstreamTransport4)
if err != nil {
return nil, err
}
if response.StatusCode == 200 || response.StatusCode == 304 || response.StatusCode == 404 {
rp.lruCache.Add(cacheDigest, *response)
} else {
log.Printf("REMOVE: %d response for %s from cache\n", response.StatusCode, cacheDigest)
rp.lruCache.Remove(cacheDigest)
}
return response, nil
}
// getCacheDigest is an implementation of the cache.get_signature of original wttr.in.
func getCacheDigest(req *http.Request) string {
userAgent := req.Header.Get("User-Agent")
queryHost := req.Host
queryString := req.RequestURI
clientIPAddress := util.ReadUserIP(req)
lang := req.Header.Get("Accept-Language")
return fmt.Sprintf("%s:%s%s:%s:%s", userAgent, queryHost, queryString, clientIPAddress, lang)
}
// dontCache returns true if req should not be cached.
func dontCache(req *http.Request) bool {
// dont cache cyclic requests
loc := strings.Split(req.RequestURI, "?")[0]
return strings.Contains(loc, ":")
}
// redirectInsecure returns redirection response, and bool value, if redirection was needed,
// if the query comes from a browser, and it is insecure.
//
// Insecure queries are marked by the frontend web server
// with X-Forwarded-Proto header:
// `proxy_set_header X-Forwarded-Proto $scheme;`.
func redirectInsecure(req *http.Request) (*ResponseWithHeader, bool) {
if isPlainTextAgent(req.Header.Get("User-Agent")) {
return nil, false
}
if req.TLS != nil || strings.ToLower(req.Header.Get("X-Forwarded-Proto")) == "https" {
return nil, false
}
target := "https://" + req.Host + req.URL.Path
if len(req.URL.RawQuery) > 0 {
target += "?" + req.URL.RawQuery
}
body := []byte(fmt.Sprintf(`<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="%s">here</A>.
</BODY></HTML>
`, target))
return &ResponseWithHeader{
InProgress: false,
Expires: time.Now().Add(time.Duration(randInt(1000, 1500)) * time.Second),
Body: body,
Header: http.Header{"Location": []string{target}},
StatusCode: 301,
}, true
}
// isPlainTextAgent returns true if userAgent is a plain-text agent.
func isPlainTextAgent(userAgent string) bool {
userAgentLower := strings.ToLower(userAgent)
for _, signature := range plainTextAgents() {
if strings.Contains(userAgentLower, signature) {
return true
}
}
return false
}
func randInt(min int, max int) int {
return min + rand.Intn(max-min)
}
// ipFromAddr returns IP address from a ADDR:PORT pair.
func ipFromAddr(s string) string {
pos := strings.LastIndex(s, ":")
if pos == -1 {
return s
}
return s[:pos]
}
// fromCadre converts Cadre into a responseWithHeader.
func fromCadre(cadre *routing.Cadre) *ResponseWithHeader {
return &ResponseWithHeader{
Body: cadre.Body,
Expires: cadre.Expires,
StatusCode: 200,
InProgress: false,
}
}
================================================
FILE: internal/routing/routing.go
================================================
package routing
import (
"net/http"
"time"
)
// CadreFormat specifies how the shot data is formatted.
type CadreFormat int
const (
// CadreFormatANSI represents Terminal ANSI format.
CadreFormatANSI = iota
// CadreFormatHTML represents HTML.
CadreFormatHTML
// CadreFormatPNG represents PNG.
CadreFormatPNG
)
// Cadre contains result of a query execution.
type Cadre struct {
// Body contains the data of Cadre, formatted as Format.
Body []byte
// Format of the shot.
Format CadreFormat
// Expires contains the time of the Cadre expiration,
// or 0 if it does not expire.
Expires time.Time
}
// Handler can handle queries and return views.
type Handler interface {
Response(*http.Request) *Cadre
}
type routeFunc func(*http.Request) bool
type route struct {
routeFunc
Handler
}
// Router keeps a routing table, and finds queries handlers, based on its rules.
type Router struct {
rt []route
}
// Route returns a query handler based on its content.
func (r *Router) Route(req *http.Request) Handler {
for _, re := range r.rt {
if re.routeFunc(req) {
return re.Handler
}
}
return nil
}
// AddPath adds route for a static path.
func (r *Router) AddPath(path string, handler Handler) {
r.rt = append(r.rt, route{routePath(path), handler})
}
func routePath(path string) routeFunc {
return routeFunc(func(req *http.Request) bool {
return req.URL.Path == path
})
}
================================================
FILE: internal/stats/stats.go
================================================
package stats
import (
"bytes"
"fmt"
"net/http"
"sync"
"time"
"github.com/chubin/wttr.in/internal/routing"
)
// Stats holds processed requests statistics.
type Stats struct {
m sync.Mutex
v map[string]int
startTime time.Time
}
// New returns new Stats.
func New() *Stats {
return &Stats{
v: map[string]int{},
startTime: time.Now(),
}
}
// Inc key by one.
func (c *Stats) Inc(key string) {
c.m.Lock()
c.v[key]++
c.m.Unlock()
}
// Get current key counter value.
func (c *Stats) Get(key string) int {
c.m.Lock()
defer c.m.Unlock()
return c.v[key]
}
// Reset key counter.
func (c *Stats) Reset(key string) int {
c.m.Lock()
defer c.m.Unlock()
result := c.v[key]
c.v[key] = 0
return result
}
// Show returns current statistics formatted as []byte.
func (c *Stats) Show() []byte {
var b bytes.Buffer
c.m.Lock()
defer c.m.Unlock()
uptime := time.Since(c.startTime) / time.Second
fmt.Fprintf(&b, "%-20s: %v\n", "Running since", c.startTime.Format(time.RFC3339))
fmt.Fprintf(&b, "%-20s: %d\n", "Uptime (min)", uptime/60)
fmt.Fprintf(&b, "%-20s: %d\n", "Total queries", c.v["total"])
if uptime != 0 {
fmt.Fprintf(&b, "%-20s: %d\n", "Throughput (QpM)", c.v["total"]*60/int(uptime))
}
fmt.Fprintf(&b, "%-20s: %d\n", "Cache L1 queries", c.v["cache1"])
if c.v["total"] != 0 {
fmt.Fprintf(&b, "%-20s: %d\n", "Cache L1 queries (%)", (100*c.v["cache1"])/c.v["total"])
}
fmt.Fprintf(&b, "%-20s: %d\n", "Upstream queries", c.v["total"]-c.v["cache1"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with format", c.v["format"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with format=j1", c.v["format=j1"])
fmt.Fprintf(&b, "%-20s: %d\n", "Queries with known IP", c.v["geoip"])
return b.Bytes()
}
func (c *Stats) Response(*http.Request) *routing.Cadre {
return &routing.Cadre{
Body: c.Show(),
}
}
================================================
FILE: internal/types/errors.go
================================================
package types
import "errors"
var (
ErrNotFound = errors.New("cache entry not found")
ErrInvalidCacheEntry = errors.New("invalid cache entry format")
ErrUpstream = errors.New("upstream error")
// ErrNoServersConfigured means that there are no servers to run.
ErrNoServersConfigured = errors.New("no servers configured")
ErrUnknownLocationService = errors.New("unknown location service")
)
================================================
FILE: internal/types/types.go
================================================
package types
type CacheType string
const (
CacheTypeDB = "db"
CacheTypeFiles = "files"
)
================================================
FILE: internal/util/files.go
================================================
package util
import "os"
// RemoveFileIfExists removes filename if exists, or does nothing if the file
// is not there. Returns an error, if it occurred during deletion.
func RemoveFileIfExists(filename string) error {
_, err := os.Stat(filename)
if err != nil {
if !os.IsNotExist(err) {
return err
}
// no db file
return nil
}
return os.Remove(filename)
}
================================================
FILE: internal/util/http.go
================================================
package util
import (
"log"
"net"
"net/http"
)
// ReadUserIP returns IP address of the client from http.Request,
// taking into account the HTTP headers.
func ReadUserIP(r *http.Request) string {
IPAddress := r.Header.Get("X-Real-Ip")
if IPAddress == "" {
IPAddress = r.Header.Get("X-Forwarded-For")
}
if IPAddress == "" {
IPAddress = r.RemoteAddr
var err error
IPAddress, _, err = net.SplitHostPort(IPAddress)
if err != nil {
log.Printf("ERROR: userip: %q is not IP:port\n", IPAddress)
}
}
return IPAddress
}
================================================
FILE: internal/util/yaml.go
================================================
package util
import (
"bytes"
"gopkg.in/yaml.v3"
)
// YamlUnmarshalStrict unmarshals YAML data with an error when unknown fields are present.
func YamlUnmarshalStrict(in []byte, out interface{}) error {
dec := yaml.NewDecoder(bytes.NewReader(in))
dec.KnownFields(true)
return dec.Decode(out)
}
================================================
FILE: internal/view/v1/api.go
================================================
//nolint:forbidigo,funlen,nestif,goerr113,gocognit,cyclop
package v1
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strconv"
"strings"
)
//nolint:tagliatelle
type cond struct {
ChanceOfRain string `json:"chanceofrain"`
FeelsLikeC int `json:",string"`
PrecipMM float32 `json:"precipMM,string"`
TempC int `json:"tempC,string"`
TempC2 int `json:"temp_C,string"`
Time int `json:"time,string"`
VisibleDistKM int `json:"visibility,string"`
WeatherCode int `json:"weatherCode,string"`
WeatherDesc []struct{ Value string }
WindGustKmph int `json:",string"`
Winddir16Point string
WindspeedKmph int `json:"windspeedKmph,string"`
}
type astro struct {
Moonrise string
Moonset string
Sunrise string
Sunset string
}
type weather struct {
Astronomy []astro
Date string
Hourly []cond
MaxtempC int `json:"maxtempC,string"`
MintempC int `json:"mintempC,string"`
}
type loc struct {
Query string `json:"query"`
Type string `json:"type"`
}
//nolint:tagliatelle
type resp struct {
Data struct {
Cur []cond `json:"current_condition"`
Err []struct{ Msg string } `json:"error"`
Req []loc `json:"request"`
Weather []weather `json:"weather"`
} `json:"data"`
}
func (g *global) getDataFromAPI() (*resp, error) {
var (
ret resp
params []string
)
if len(g.config.APIKey) == 0 {
return nil, fmt.Errorf("no API key specified. Setup instructions are in the README")
}
params = append(params, "key="+g.config.APIKey)
// non-flag shortcut arguments will overwrite possible flag arguments
for _, arg := range flag.Args() {
if v, err := strconv.Atoi(arg); err == nil && len(arg) == 1 {
g.config.Numdays = v
} else {
g.config.City = arg
}
}
if len(g.config.City) > 0 {
params = append(params, "q="+url.QueryEscape(g.config.City))
}
params = append(params, "format=json", "num_of_days="+strconv.Itoa(g.config.Numdays), "tp=3")
if g.config.Lang != "" {
params = append(params, "lang="+g.config.Lang)
}
if g.debug {
fmt.Fprintln(os.Stderr, params)
}
res, err := http.Get(wuri + strings.Join(params, "&"))
if err != nil {
return nil, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, err
}
if g.debug {
var out bytes.Buffer
err := json.Indent(&out, body, "", " ")
if err != nil {
return nil, err
}
_, err = out.WriteTo(os.Stderr)
if err != nil {
return nil, err
}
fmt.Print("\n\n")
}
if g.config.Lang == "" {
if err = json.Unmarshal(body, &ret); err != nil {
return nil, err
}
} else {
if err = g.unmarshalLang(body, &ret); err != nil {
return nil, err
}
}
return &ret, nil
}
func (g *global) unmarshalLang(body []byte, r *resp) error {
var rv map[string]interface{}
if err := json.Unmarshal(body, &rv); err != nil {
return err
}
if data, ok := rv["data"].(map[string]interface{}); ok {
if ccs, ok := data["current_condition"].([]interface{}); ok {
for _, cci := range ccs {
cc, ok := cci.(map[string]interface{})
if !ok {
continue
}
langs, ok := cc["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 {
continue
}
weatherDesc, ok := cc["weatherDesc"].([]interface{})
if !ok || len(weatherDesc) == 0 {
continue
}
weatherDesc[0] = langs[0]
}
}
if ws, ok := data["weather"].([]interface{}); ok {
for _, wi := range ws {
w, ok := wi.(map[string]interface{})
if !ok {
continue
}
if hs, ok := w["hourly"].([]interface{}); ok {
for _, hi := range hs {
h, ok := hi.(map[string]interface{})
if !ok {
continue
}
langs, ok := h["lang_"+g.config.Lang].([]interface{})
if !ok || len(langs) == 0 {
continue
}
weatherDesc, ok := h["weatherDesc"].([]interface{})
if !ok || len(weatherDesc) == 0 {
continue
}
weatherDesc[0] = langs[0]
}
}
}
}
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(rv); err != nil {
return err
}
if err := json.NewDecoder(&buf).Decode(r); err != nil {
return err
}
return nil
}
================================================
FILE: internal/view/v1/cmd.go
================================================
// This code represents wttr.in view v1.
// It is based on wego (github.com/schachmat/wego) from which it diverged back in 2016.
//nolint:forbidigo,funlen,gocognit,cyclop
package v1
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/ioutil"
"log"
"os"
"os/user"
"path"
"regexp"
"strings"
"github.com/mattn/go-colorable"
"github.com/mattn/go-runewidth"
)
type Configuration struct {
APIKey string
City string
Numdays int
Imperial bool
WindUnit bool
Inverse bool
Lang string
Narrow bool
LocationName string
WindMS bool
RightToLeft bool
}
type global struct {
ansiEsc *regexp.Regexp
config Configuration
configpath string
debug bool
}
const (
wuri = "http://127.0.0.1:5001/premium/v1/weather.ashx?"
suri = "http://127.0.0.1:5001/premium/v1/search.ashx?"
slotcount = 4
)
func (g *global) configload() error {
b, err := ioutil.ReadFile(g.configpath)
if err == nil {
return json.Unmarshal(b, &g.config)
}
return err
}
func (g *global) configsave() error {
j, err := json.MarshalIndent(g.config, "", "\t")
if err == nil {
return ioutil.WriteFile(g.configpath, j, 0o600)
}
return err
}
func (g *global) init() {
flag.IntVar(&g.config.Numdays, "days", 3, "Number of days of weather forecast to be displayed")
flag.StringVar(&g.config.Lang, "lang", "en", "Language of the report")
flag.StringVar(&g.config.City, "city", "New York", "City to be queried")
flag.BoolVar(&g.debug, "debug", false, "Print out raw json response for debugging purposes")
flag.BoolVar(&g.config.Imperial, "imperial", false, "Use imperial units")
flag.BoolVar(&g.config.Inverse, "inverse", false, "Use inverted colors")
flag.BoolVar(&g.config.Narrow, "narrow", false, "Narrow output (two columns)")
flag.StringVar(&g.config.LocationName, "location_name", "", "Location name (used in the caption)")
flag.BoolVar(&g.config.WindMS, "wind_in_ms", false, "Show wind speed in m/s")
flag.BoolVar(&g.config.RightToLeft, "right_to_left", false, "Right to left script")
g.configpath = os.Getenv("WEGORC")
if g.configpath == "" {
usr, err := user.Current()
if err != nil {
log.Fatalf("%v\nYou can set the environment variable WEGORC to point to your config file as a workaround.", err)
}
g.configpath = path.Join(usr.HomeDir, ".wegorc")
}
g.config.APIKey = ""
g.config.Imperial = false
g.config.Lang = "en"
err := g.configload()
var pathError *os.PathError
if errors.Is(err, pathError) {
log.Printf("No config file found. Creating %s ...", g.configpath)
if err2 := g.configsave(); err2 != nil {
log.Fatal(err2)
}
} else if err != nil {
log.Fatalf("could not parse %v: %v", g.configpath, err)
}
g.ansiEsc = regexp.MustCompile("\033.*?m")
}
func Cmd() error {
g := global{}
g.init()
flag.Parse()
r, err := g.getDataFromAPI()
if err != nil {
return err
}
if r.Data.Req == nil || len(r.Data.Req) < 1 {
if r.Data.Err != nil && len(r.Data.Err) >= 1 {
log.Fatal(r.Data.Err[0].Msg)
}
log.Fatal("Malformed response.")
}
locationName := r.Data.Req[0].Query
if g.config.LocationName != "" {
locationName = g.config.LocationName
}
if g.config.Lang == "he" || g.config.Lang == "ar" || g.config.Lang == "fa" {
g.config.RightToLeft = true
}
if caption, ok := localizedCaption()[g.config.Lang]; !ok {
fmt.Printf("Weather report: %s\n\n", locationName)
} else {
if g.config.RightToLeft {
caption = locationName + " " + caption
space := strings.Repeat(" ", 125-runewidth.StringWidth(caption))
fmt.Printf("%s%s\n\n", space, caption)
} else {
fmt.Printf("%s %s\n\n", caption, locationName)
}
}
stdout := colorable.NewColorableStdout()
if r.Data.Cur == nil || len(r.Data.Cur) < 1 {
log.Fatal("No weather data available.")
}
out := g.formatCond(make([]string, 5), r.Data.Cur[0], true)
for _, val := range out {
if g.config.RightToLeft {
fmt.Fprint(stdout, strings.Repeat(" ", 94))
} else {
fmt.Fprint(stdout, " ")
}
fmt.Fprintln(stdout, val)
}
if g.config.Numdays == 0 {
return nil
}
if r.Data.Weather == nil {
log.Fatal("No detailed weather forecast available.")
}
for _, d := range r.Data.Weather {
lines, err := g.printDay(d)
if err != nil {
return err
}
for _, val := range lines {
fmt.Fprintln(stdout, val)
}
}
return nil
}
================================================
FILE: internal/view/v1/format.go
================================================
//nolint:funlen,nestif,cyclop,gocognit,gocyclo
package v1
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/mattn/go-runewidth"
)
func windDir() map[string]string {
return map[string]string{
"N": "\033[1m↓\033[0m",
"NNE": "\033[1m↓\033[0m",
"NE": "\033[1m↙\033[0m",
"ENE": "\033[1m↙\033[0m",
"E": "\033[1m←\033[0m",
"ESE": "\033[1m←\033[0m",
"SE": "\033[1m↖\033[0m",
"SSE": "\033[1m↖\033[0m",
"S": "\033[1m↑\033[0m",
"SSW": "\033[1m↑\033[0m",
"SW": "\033[1m↗\033[0m",
"WSW": "\033[1m↗\033[0m",
"W": "\033[1m→\033[0m",
"WNW": "\033[1m→\033[0m",
"NW": "\033[1m↘\033[0m",
"NNW": "\033[1m↘\033[0m",
}
}
func (g *global) formatTemp(c cond) string {
color := func(temp int, explicitPlus bool) string {
var col int
//nolint:dupl
if !g.config.Inverse {
// Extremely cold temperature must be shown with violet
// because dark blue is too dark
col = 165
switch temp {
case -15, -14, -13:
col = 171
case -12, -11, -10:
col = 33
case -9, -8, -7:
col = 39
case -6, -5, -4:
col = 45
case -3, -2, -1:
col = 51
case 0, 1:
col = 50
case 2, 3:
col = 49
case 4, 5:
col = 48
case 6, 7:
col = 47
case 8, 9:
col = 46
case 10, 11, 12:
col = 82
case 13, 14, 15:
col = 118
case 16, 17, 18:
col = 154
case 19, 20, 21:
col = 190
case 22, 23, 24:
col = 226
case 25, 26, 27:
col = 220
case 28, 29, 30:
col = 214
case 31, 32, 33:
col = 208
case 34, 35, 36:
col = 202
default:
if temp > 0 {
col = 196
}
}
} else {
col = 16
switch temp {
case -15, -14, -13:
col = 17
case -12, -11, -10:
col = 18
case -9, -8, -7:
col = 19
case -6, -5, -4:
col = 20
case -3, -2, -1:
col = 21
case 0, 1:
col = 30
case 2, 3:
col = 28
case 4, 5:
col = 29
case 6, 7:
col = 30
case 8, 9:
col = 34
case 10, 11, 12:
col = 35
case 13, 14, 15:
col = 36
case 16, 17, 18:
col = 40
case 19, 20, 21:
col = 59
case 22, 23, 24:
col = 100
case 25, 26, 27:
col = 101
case 28, 29, 30:
col = 94
case 31, 32, 33:
col = 166
case 34, 35, 36:
col = 52
default:
if temp > 0 {
col = 196
}
}
}
if g.config.Imperial {
temp = (temp*18 + 320) / 10
}
if explicitPlus {
return fmt.Sprintf("\033[38;5;%03dm+%d\033[0m", col, temp)
}
return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, temp)
}
t := c.TempC
if t == 0 {
t = c.TempC2
}
// hyphen := " - "
// if (config.Lang == "sl") {
// hyphen = "-"
// }
// hyphen = ".."
explicitPlus1 := false
explicitPlus2 := false
if c.FeelsLikeC != t {
if t > 0 {
explicitPlus1 = true
}
if c.FeelsLikeC > 0 {
explicitPlus2 = true
}
if explicitPlus1 {
explicitPlus2 = false
}
return g.pad(
fmt.Sprintf("%s(%s) °%s",
color(t, explicitPlus1),
color(c.FeelsLikeC, explicitPlus2),
unitTemp()[g.config.Imperial]),
15)
}
return g.pad(fmt.Sprintf("%s °%s", color(c.FeelsLikeC, false), unitTemp()[g.config.Imperial]), 15)
}
func (g *global) formatWind(c cond) string {
unitWindString := unitWind(0, g.config.Lang)
if g.config.WindMS {
unitWindString = unitWind(2, g.config.Lang)
} else if g.config.Imperial {
unitWindString = unitWind(1, g.config.Lang)
}
hyphen := "-"
cWindGustKmph := speedToColor(c.WindGustKmph, windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial))
cWindspeedKmph := speedToColor(c.WindspeedKmph, windInRightUnits(c.WindspeedKmph, g.config.WindMS, g.config.Imperial))
if windInRightUnits(c.WindGustKmph, g.config.WindMS, g.config.Imperial) >
windInRightUnits(c.WindspeedKmph, g.config.WindMS, g.config.Imperial) {
return g.pad(
fmt.Sprintf("%s %s%s%s %s", windDir()[c.Winddir16Point], cWindspeedKmph, hyphen, cWindGustKmph, unitWindString),
15)
}
return g.pad(fmt.Sprintf("%s %s %s", windDir()[c.Winddir16Point], cWindspeedKmph, unitWindString), 15)
}
func windInRightUnits(spd int, windMS, imperial bool) int {
if windMS {
spd = (spd * 1000) / 3600
} else if imperial {
spd = (spd * 1000) / 1609
}
return spd
}
func speedToColor(spd, spdConverted int) string {
col := 46
switch spd {
case 1, 2, 3:
col = 82
case 4, 5, 6:
col = 118
case 7, 8, 9:
col = 154
case 10, 11, 12:
col = 190
case 13, 14, 15:
col = 226
case 16, 17, 18, 19:
col = 220
case 20, 21, 22, 23:
col = 214
case 24, 25, 26, 27:
col = 208
case 28, 29, 30, 31:
col = 202
default:
if spd > 0 {
col = 196
}
}
return fmt.Sprintf("\033[38;5;%03dm%d\033[0m", col, spdConverted)
}
func (g *global) formatVisibility(c cond) string {
if g.config.Imperial {
c.VisibleDistKM = (c.VisibleDistKM * 621) / 1000
}
return g.pad(fmt.Sprintf("%d %s", c.VisibleDistKM, unitVis(g.config.Imperial, g.config.Lang)), 15)
}
func (g *global) formatRain(c cond) string {
rainUnit := c.PrecipMM
if g.config.Imperial {
rainUnit = c.PrecipMM * 0.039
}
if c.ChanceOfRain != "" {
return g.pad(fmt.Sprintf(
"%.1f %s | %s%%",
rainUnit,
unitRain(g.config.Imperial, g.config.Lang),
c.ChanceOfRain), 15)
}
return g.pad(fmt.Sprintf("%.1f %s", rainUnit, unitRain(g.config.Imperial, g.config.Lang)), 15)
}
func (g *global) formatCond(cur []string, c cond, current bool) []string {
var (
ret []string
icon []string
)
if i, ok := codes()[c.WeatherCode]; !ok {
icon = getIcon("iconUnknown")
} else {
icon = i
}
if g.config.Inverse {
// inverting colors
for i := range icon {
icon[i] = strings.ReplaceAll(icon[i], "38;5;226", "38;5;94")
icon[i] = strings.ReplaceAll(icon[i], "38;5;250", "38;5;243")
icon[i] = strings.ReplaceAll(icon[i], "38;5;21", "38;5;18")
icon[i] = strings.ReplaceAll(icon[i], "38;5;255", "38;5;245")
icon[i] = strings.ReplaceAll(icon[i], "38;5;111", "38;5;63")
icon[i] = strings.ReplaceAll(icon[i], "38;5;251", "38;5;238")
}
}
// desc := fmt.Sprintf("%-15.15v", c.WeatherDesc[0].Value)
desc := c.WeatherDesc[0].Value
if g.config.RightToLeft {
for runewidth.StringWidth(desc) < 15 {
desc = " " + desc
}
for runewidth.StringWidth(desc) > 15 {
_, size := utf8.DecodeLastRuneInString(desc)
desc = desc[size:]
}
} else {
for runewidth.StringWidth(desc) < 15 {
desc += " "
}
for runewidth.StringWidth(desc) > 15 {
_, size := utf8.DecodeLastRuneInString(desc)
desc = desc[:len(desc)-size]
}
}
if current {
if g.config.RightToLeft {
desc = c.WeatherDesc[0].Value
if runewidth.StringWidth(desc) < 15 {
desc = strings.Repeat(" ", 15-runewidth.StringWidth(desc)) + desc
}
} else {
desc = c.WeatherDesc[0].Value
}
} else {
if g.config.RightToLeft {
if frstRune, size := utf8.DecodeRuneInString(desc); frstRune != ' ' {
desc = "…" + desc[size:]
for runewidth.StringWidth(desc) < 15 {
desc = " " + desc
}
}
} else {
if lastRune, size := utf8.DecodeLastRuneInString(desc); lastRune != ' ' {
desc = desc[:len(desc)-size] + "…"
// for numberOfSpaces < runewidth.StringWidth(fmt.Sprintf("%c", lastRune)) - 1 {
for runewidth.StringWidth(desc) < 15 {
desc += " "
}
}
}
}
if g.config.RightToLeft {
ret = append(
ret,
fmt.Sprintf("%v %v %v", cur[0], desc, icon[0]),
fmt.Sprintf("%v %v %v", cur[1], g.formatTemp(c), icon[1]),
fmt.Sprintf("%v %v %v", cur[2], g.formatWind(c), icon[2]),
fmt.Sprintf("%v %v %v", cur[3], g.formatVisibility(c), icon[3]),
fmt.Sprintf("%v %v %v", cur[4], g.formatRain(c), icon[4]))
} else {
ret = append(
ret,
fmt.Sprintf("%v %v %v", cur[0], icon[0], desc),
fmt.Sprintf("%v %v %v", cur[1], icon[1], g.formatTemp(c)),
fmt.Sprintf("%v %v %v", cur[2], icon[2], g.formatWind(c)),
fmt.Sprintf("%v %v %v", cur[3], icon[3], g.formatVisibility(c)),
fmt.Sprintf("%v %v %v", cur[4], icon[4], g.formatRain(c)))
}
return ret
}
func justifyCenter(s string, width int) string {
appendSide := 0
for runewidth.StringWidth(s) <= width {
if appendSide == 1 {
s += " "
appendSide = 0
} else {
s = " " + s
appendSide = 1
}
}
return s
}
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func (g *global) pad(s string, mustLen int) string {
var ret string
ret = s
realLen := utf8.RuneCountInString(g.ansiEsc.ReplaceAllLiteralString(s, ""))
delta := mustLen - realLen
if delta > 0 {
if g.config.RightToLeft {
ret = strings.Repeat(" ", delta) + ret + "\033[0m"
} else {
ret += "\033[0m" + strings.Repeat(" ", delta)
}
} else if delta < 0 {
toks := g.ansiEsc.Split(s, 2)
tokLen := utf8.RuneCountInString(toks[0])
esc := g.ansiEsc.FindString(s)
if tokLen > mustLen {
ret = fmt.Sprintf("%.*s\033[0m", mustLen, toks[0])
} else {
ret = fmt.Sprintf("%s%s%s", toks[0], esc, g.pad(toks[1], mustLen-tokLen))
}
}
return ret
}
================================================
FILE: internal/view/v1/icons.go
================================================
package v1
//nolint:funlen
func getIcon(name string) []string {
icon := map[string][]string{
"iconUnknown": {
" .-. ",
" __) ",
" ( ",
" `-’ ",
" • ",
},
"iconSunny": {
"\033[38;5;226m \\ / \033[0m",
"\033[38;5;226m .-. \033[0m",
"\033[38;5;226m ― ( ) ― \033[0m",
"\033[38;5;226m `-’ \033[0m",
"\033[38;5;226m / \\ \033[0m",
},
"iconPartlyCloudy": {
"\033[38;5;226m \\ /\033[0m ",
"\033[38;5;226m _ /\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m \\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
" ",
},
"iconCloudy": {
" ",
"\033[38;5;250m .--. \033[0m",
"\033[38;5;250m .-( ). \033[0m",
"\033[38;5;250m (___.__)__) \033[0m",
" ",
},
"iconVeryCloudy": {
" ",
"\033[38;5;240;1m .--. \033[0m",
"\033[38;5;240;1m .-( ). \033[0m",
"\033[38;5;240;1m (___.__)__) \033[0m",
" ",
},
"iconLightShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m ‘ ‘ ‘ ‘ \033[0m",
"\033[38;5;111m ‘ ‘ ‘ ‘ \033[0m",
},
"iconHeavyShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;21;1m ‚‘‚‘‚‘‚‘ \033[0m",
"\033[38;5;21;1m ‚’‚’‚’‚’ \033[0m",
},
"iconLightSnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m * * * \033[0m",
"\033[38;5;255m * * * \033[0m",
},
"iconHeavySnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;240;1m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;240;1m( ). \033[0m",
"\033[38;5;226m /\033[38;5;240;1m(___(__) \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
"\033[38;5;255;1m * * * * \033[0m",
},
"iconLightSleetShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;111m ‘ \033[38;5;255m*\033[38;5;111m ‘ \033[38;5;255m* \033[0m",
"\033[38;5;255m *\033[38;5;111m ‘ \033[38;5;255m*\033[38;5;111m ‘ \033[0m",
},
"iconThunderyShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;228;5m ⚡\033[38;5;111;25m‘‘\033[38;5;228;5m⚡\033[38;5;111;25m‘‘ \033[0m",
"\033[38;5;111m ‘ ‘ ‘ ‘ \033[0m",
},
"iconThunderyHeavyRain": {
"\033[38;5;240;1m .-. \033[0m",
"\033[38;5;240;1m ( ). \033[0m",
"\033[38;5;240;1m (___(__) \033[0m",
"\033[38;5;21;1m ‚‘\033[38;5;228;5m⚡\033[38;5;21;25m‘‚\033[38;5;228;5m⚡\033[38;5;21;25m‚‘ \033[0m",
"\033[38;5;21;1m ‚’‚’\033[38;5;228;5m⚡\033[38;5;21;25m’‚’ \033[0m",
},
"iconThunderySnowShowers": {
"\033[38;5;226m _`/\"\"\033[38;5;250m.-. \033[0m",
"\033[38;5;226m ,\\_\033[38;5;250m( ). \033[0m",
"\033[38;5;226m /\033[38;5;250m(___(__) \033[0m",
"\033[38;5;255m *\033[38;5;228;5m⚡\033[38;5;255;25m*\033[38;5;228;5m⚡\033[38;5;255;25m* \033[0m",
"\033[38;5;255m * * * \033[0m",
},
"iconLightRain": {
"\033[38;5;250m .-. \033[0m",
"\033[38;5;250m ( ). \033[0m",
"\033[38;5;250m (___(__) \033[0m",
"\033[38;5;111m ‘ ‘ ‘ ‘ \033[0m",
"\033[38;5;111m ‘ ‘ ‘ ‘ \033[0m",
},
gitextract_3puwzenn/
├── .flake8
├── .github/
│ └── workflows/
│ └── makefile.yml
├── .gitignore
├── .golangci.yaml
├── .travis.yml
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── bin/
│ ├── geo-proxy.py
│ ├── proxy.py
│ └── srv.py
├── config/
│ └── services/
│ └── services.yaml
├── doc/
│ ├── integrations.md
│ └── terminal-images.md
├── go.mod
├── go.sum
├── internal/
│ ├── config/
│ │ └── config.go
│ ├── fmt/
│ │ └── png/
│ │ ├── colors.go
│ │ ├── go.mod
│ │ ├── go.sum
│ │ └── png.go
│ ├── geo/
│ │ ├── ip/
│ │ │ ├── convert.go
│ │ │ ├── ip.go
│ │ │ └── ip_test.go
│ │ └── location/
│ │ ├── cache.go
│ │ ├── convert.go
│ │ ├── location.go
│ │ ├── nominatim.go
│ │ ├── nominatim_locationiq.go
│ │ ├── nominatim_opencage.go
│ │ ├── response.go
│ │ └── search.go
│ ├── logging/
│ │ ├── logging.go
│ │ └── suppress.go
│ ├── options/
│ │ ├── options.go
│ │ ├── parse.go
│ │ └── processlog.go
│ ├── processor/
│ │ ├── j1.go
│ │ ├── peak.go
│ │ └── processor.go
│ ├── routing/
│ │ └── routing.go
│ ├── stats/
│ │ └── stats.go
│ ├── types/
│ │ ├── errors.go
│ │ └── types.go
│ ├── util/
│ │ ├── files.go
│ │ ├── http.go
│ │ └── yaml.go
│ └── view/
│ └── v1/
│ ├── api.go
│ ├── cmd.go
│ ├── format.go
│ ├── icons.go
│ ├── locale.go
│ └── view1.go
├── lib/
│ ├── airports.py
│ ├── buttons.py
│ ├── cache.py
│ ├── constants.py
│ ├── datasource/
│ │ └── README.md
│ ├── duplicate_translations.py
│ ├── extract_emoji.py
│ ├── fields.py
│ ├── fmt/
│ │ ├── __init__.py
│ │ ├── png.py
│ │ └── unicodedata2.py
│ ├── globals.py
│ ├── limits.py
│ ├── location.py
│ ├── metno.py
│ ├── parse_query.py
│ ├── proxy_log.py
│ ├── translations.py
│ ├── translations_v2.py
│ ├── view/
│ │ ├── __init__.py
│ │ ├── line.py
│ │ ├── moon.py
│ │ ├── prometheus.py
│ │ ├── v2.py
│ │ └── wttr.py
│ ├── weather_data.py
│ └── wttr_srv.py
├── requirements.txt
├── share/
│ ├── aliases
│ ├── ansi2html.sh
│ ├── bash-function.txt
│ ├── blacklist
│ ├── docker/
│ │ └── supervisord.conf
│ ├── help.txt
│ ├── iterm2.txt
│ ├── list-of-iata-codes.txt
│ ├── salt/
│ │ ├── README.md
│ │ ├── init.sls
│ │ ├── pillar.sls
│ │ ├── start.sh
│ │ ├── wegorc
│ │ └── wttr.service
│ ├── screenrc
│ ├── scripts/
│ │ ├── build-welang.sh
│ │ ├── clean-cache.sh
│ │ ├── log-space.sh
│ │ └── start-screen.sh
│ ├── static/
│ │ ├── malformed-response.html
│ │ └── style.css
│ ├── systemd/
│ │ ├── README.md
│ │ ├── wttrin.service
│ │ └── wttrin.sh
│ ├── templates/
│ │ └── index.html
│ ├── test-thunder.txt
│ ├── translation.txt
│ └── translations/
│ ├── af-help.txt
│ ├── af.txt
│ ├── am-help.txt
│ ├── am.txt
│ ├── ar-help.txt
│ ├── ar.txt
│ ├── az.txt
│ ├── be-help.txt
│ ├── be.txt
│ ├── bg-help.txt
│ ├── bn-help.txt
│ ├── bn.txt
│ ├── bs.txt
│ ├── ca-help.txt
│ ├── ca.txt
│ ├── cs-help.txt
│ ├── cs.txt
│ ├── cy.txt
│ ├── da-help.txt
│ ├── da.txt
│ ├── de-help.txt
│ ├── de.txt
│ ├── dk-help.txt
│ ├── el-help.txt
│ ├── el.txt
│ ├── en.txt
│ ├── eo.txt
│ ├── es-help.txt
│ ├── es.txt
│ ├── et-help.txt
│ ├── et.txt
│ ├── eu-help.txt
│ ├── eu.txt
│ ├── fa-help.txt
│ ├── fa.txt
│ ├── fr-help.txt
│ ├── fr.txt
│ ├── fy.txt
│ ├── ga.txt
│ ├── gl-help.txt
│ ├── gl.txt
│ ├── gu-help.txt
│ ├── gu.txt
│ ├── he.txt
│ ├── hi-help.txt
│ ├── hi.txt
│ ├── hr.txt
│ ├── hu-help.txt
│ ├── hu.txt
│ ├── hy.txt
│ ├── ia-help.txt
│ ├── ia.txt
│ ├── id-help.txt
│ ├── id.txt
│ ├── is.txt
│ ├── it-help.txt
│ ├── it.txt
│ ├── ja.txt
│ ├── kk-help.txt
│ ├── kk.txt
│ ├── lt-help.txt
│ ├── lt.txt
│ ├── lv-help.txt
│ ├── lv.txt
│ ├── messages/
│ │ ├── en.yaml
│ │ └── gu.yaml
│ ├── mg-help.txt
│ ├── mg.txt
│ ├── mk.txt
│ ├── mr-help.txt
│ ├── mr.txt
│ ├── nb-help.txt
│ ├── nb.txt
│ ├── nl-help.txt
│ ├── nl.txt
│ ├── nn.txt
│ ├── oc-help.txt
│ ├── oc.txt
│ ├── pl-help.txt
│ ├── pl.txt
│ ├── pt-br-help.txt
│ ├── pt-br.txt
│ ├── pt-help.txt
│ ├── pt.txt
│ ├── ro-help.txt
│ ├── ro.txt
│ ├── ru-help.txt
│ ├── ru.txt
│ ├── sl.txt
│ ├── ta-help.txt
│ ├── ta.txt
│ ├── te-help.txt
│ ├── te.txt
│ ├── th-help.txt
│ ├── th.txt
│ ├── tr-help.txt
│ ├── tr.txt
│ ├── uk-help.txt
│ ├── uk.txt
│ ├── ukr-help.txt
│ ├── uz.txt
│ ├── vi-help.txt
│ ├── vi.txt
│ ├── zh-cn-help.txt
│ ├── zh-cn.txt
│ ├── zh-tw-help.txt
│ └── zh-tw.txt
├── spec/
│ └── options/
│ └── options.yaml
├── srv.go
└── test/
├── proxy-data/
│ ├── data1
│ └── data1.headers
├── query.sh
└── test-data/
└── signatures
SYMBOL INDEX (378 symbols across 58 files)
FILE: bin/geo-proxy.py
function load_cache (line 44) | def load_cache(location_string):
function shorten_full_address (line 54) | def shorten_full_address(address):
function save_cache (line 62) | def save_cache(location_string, answer):
function query_osm (line 68) | def query_osm(location_string):
function add_timezone_information (line 82) | def add_timezone_information(geo_data):
function find_location (line 96) | def find_location(location):
FILE: bin/proxy.py
function is_testmode (line 60) | def is_testmode():
function load_translations (line 66) | def load_translations():
function _is_metno (line 95) | def _is_metno():
function _find_srv_for_query (line 99) | def _find_srv_for_query(path, query): # pylint: disable=unused-argument
function _cache_file (line 105) | def _cache_file(path, query):
function _load_content_and_headers (line 124) | def _load_content_and_headers(path, query):
function _touch_empty_file (line 138) | def _touch_empty_file(path, query):
function _save_content_and_headers (line 146) | def _save_content_and_headers(path, query, content, headers):
function translate (line 155) | def translate(text, lang):
function cyr (line 183) | def cyr(to_translate):
function _patch_greek (line 190) | def _patch_greek(original):
function add_translations (line 194) | def add_translations(content, lang):
function _fetch_content_and_headers (line 277) | def _fetch_content_and_headers(path, query_string, **kwargs):
function _make_query (line 318) | def _make_query(path, query_string):
function _normalize_query_string (line 338) | def _normalize_query_string(query_string):
function proxy (line 362) | def proxy(path):
FILE: bin/srv.py
function send_static (line 47) | def send_static(path):
function send_favicon (line 53) | def send_favicon():
function send_malformed (line 59) | def send_malformed():
function wttr (line 66) | def wttr(location=None):
FILE: internal/config/config.go
type Config (line 14) | type Config struct
method Dump (line 187) | func (c *Config) Dump() []byte {
type Logging (line 23) | type Logging struct
type Server (line 35) | type Server struct
type Uplink (line 52) | type Uplink struct
type Cache (line 78) | type Cache struct
type Geo (line 84) | type Geo struct
type Nominatim (line 104) | type Nominatim struct
function Default (line 117) | func Default() *Config {
function Load (line 167) | func Load(filename string) (*Config, error) {
FILE: internal/fmt/png/png.go
function StringSliceToRuneSlice (line 13) | func StringSliceToRuneSlice(s string) [][]rune {
function maxRowLength (line 29) | func maxRowLength(rows [][]rune) int {
function GeneratePng (line 39) | func GeneratePng() {
function GeneratePngFromANSI (line 129) | func GeneratePngFromANSI(input []byte, outputFile string) error {
function colorANSItoRGB (line 197) | func colorANSItoRGB(colorANSI vt10x.Color) [3]float64 {
function main (line 214) | func main() {
FILE: internal/geo/ip/convert.go
method ConvertCache (line 15) | func (c *Cache) ConvertCache() error {
function createTable (line 76) | func createTable(db *godb.DB, tableName string) error {
FILE: internal/geo/ip/ip.go
type Address (line 23) | type Address struct
method String (line 33) | func (l *Address) String() string {
type Cache (line 46) | type Cache struct
method Read (line 80) | func (c *Cache) Read(addr string) (*Address, error) {
method readFromCacheFile (line 88) | func (c *Cache) readFromCacheFile(addr string) (*Address, error) {
method readFromCacheDB (line 97) | func (c *Cache) readFromCacheDB(addr string) (*Address, error) {
method Put (line 109) | func (c *Cache) Put(addr string, loc *Address) error {
method putToCacheDB (line 117) | func (c *Cache) putToCacheDB(loc *Address) error {
method putToCacheFile (line 135) | func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
method cacheFile (line 140) | func (c *Cache) cacheFile(addr string) string {
method Response (line 190) | func (c *Cache) Response(r *http.Request) *routing.Cadre {
function NewCache (line 52) | func NewCache(config *config.Config) (*Cache, error) {
function NewAddressFromString (line 146) | func NewAddressFromString(addr, s string) (*Address, error) {
function validIP4 (line 240) | func validIP4(ipAddress string) bool {
FILE: internal/geo/ip/ip_test.go
function TestParseCacheEntry (line 13) | func TestParseCacheEntry(t *testing.T) {
FILE: internal/geo/location/cache.go
type Cache (line 25) | type Cache struct
method Resolve (line 66) | func (c *Cache) Resolve(location string) (*Location, error) {
method Read (line 94) | func (c *Cache) Read(addr string) (*Location, error) {
method readFromCacheFile (line 102) | func (c *Cache) readFromCacheFile(name string) (*Location, error) {
method readFromCacheDB (line 143) | func (c *Cache) readFromCacheDB(addr string) (*Location, error) {
method Put (line 160) | func (c *Cache) Put(addr string, loc *Location) error {
method putToCacheDB (line 169) | func (c *Cache) putToCacheDB(loc *Location) error {
method putToCacheFile (line 178) | func (c *Cache) putToCacheFile(addr string, loc fmt.Stringer) error {
method cacheFile (line 183) | func (c *Cache) cacheFile(item string) string {
function NewCache (line 34) | func NewCache(config *config.Config) (*Cache, error) {
function normalizeLocationName (line 192) | func normalizeLocationName(name string) string {
function latLngToTimezoneString (line 203) | func latLngToTimezoneString(lat, lon string) string {
FILE: internal/geo/location/convert.go
method ConvertCache (line 20) | func (c *Cache) ConvertCache(reset bool) error {
function createTable (line 119) | func createTable(db *godb.DB, tableName string) error {
function removeDBIfExists (line 134) | func removeDBIfExists(filename string) error {
FILE: internal/geo/location/location.go
type Location (line 8) | type Location struct
method String (line 17) | func (l *Location) String() string {
FILE: internal/geo/location/nominatim.go
type Nominatim (line 13) | type Nominatim struct
method Query (line 33) | func (n *Nominatim) Query(location string) (*Location, error) {
type locationQuerier (line 20) | type locationQuerier interface
function NewNominatim (line 24) | func NewNominatim(name, typ, url, token string) *Nominatim {
function makeQuery (line 48) | func makeQuery(url string, result interface{}) error {
FILE: internal/geo/location/nominatim_locationiq.go
type locationIQ (line 10) | type locationIQ
method Query (line 18) | func (data *locationIQ) Query(n *Nominatim, location string) (*Locatio...
FILE: internal/geo/location/nominatim_opencage.go
type locationOpenCage (line 10) | type locationOpenCage struct
method Query (line 21) | func (data *locationOpenCage) Query(n *Nominatim, location string) (*L...
FILE: internal/geo/location/response.go
method Response (line 14) | func (c *Cache) Response(r *http.Request) *routing.Cadre {
function errorResponse (line 45) | func errorResponse(s string) *routing.Cadre {
FILE: internal/geo/location/search.go
type Provider (line 5) | type Provider interface
type Searcher (line 9) | type Searcher struct
method Search (line 28) | func (s *Searcher) Search(location string) (*Location, error) {
function NewSearcher (line 14) | func NewSearcher(config *config.Config) *Searcher {
FILE: internal/logging/logging.go
type RequestLogger (line 17) | type RequestLogger struct
method Log (line 48) | func (rl *RequestLogger) Log(r *http.Request) error {
method flush (line 76) | func (rl *RequestLogger) flush() error {
type logEntry (line 26) | type logEntry struct
method String (line 116) | func (e *logEntry) String() string {
function NewRequestLogger (line 38) | func NewRequestLogger(filename string, period time.Duration) *RequestLog...
FILE: internal/logging/suppress.go
type LogSuppressor (line 11) | type LogSuppressor struct
method Open (line 33) | func (ls *LogSuppressor) Open() error {
method Close (line 47) | func (ls *LogSuppressor) Close() error {
method Write (line 57) | func (ls *LogSuppressor) Write(p []byte) (int, error) {
function NewLogSuppressor (line 24) | func NewLogSuppressor(filename string, suppress []string, linePrefix str...
FILE: internal/options/options.go
type WttrInOptions (line 11) | type WttrInOptions struct
type QueryOption (line 17) | type QueryOption struct
type Range (line 53) | type Range struct
type FormatSpecifier (line 62) | type FormatSpecifier struct
function NewFromFile (line 77) | func NewFromFile(filename string) (*WttrInOptions, error) {
FILE: internal/options/parse.go
function ParseQueryString (line 38) | func ParseQueryString(query string, config *WttrInOptions) (map[string]s...
function buildOptionLookups (line 82) | func buildOptionLookups(options []QueryOption) (map[string]string, map[s...
function handleFlagOption (line 95) | func handleFlagOption(key string, shortToName map[string]string, nameToO...
function validateAndSetFlag (line 110) | func validateAndSetFlag(key string, shortToName map[string]string, nameT...
function handleValueOption (line 130) | func handleValueOption(key, value string, shortToName map[string]string,...
function validateBooleanOption (line 158) | func validateBooleanOption(name, value string, opt QueryOption, result m...
function validateStringOption (line 176) | func validateStringOption(name, value string, opt QueryOption, result ma...
function applyValidationRule (line 203) | func applyValidationRule(name, value, rule string) error {
function validateIntegerOption (line 232) | func validateIntegerOption(name, value string, opt QueryOption, result m...
function getMapKeys (line 250) | func getMapKeys(m map[string]string) []string {
FILE: internal/options/processlog.go
function ProcessLogFile (line 13) | func ProcessLogFile(logFilePath, errorFilePath string, config *WttrInOpt...
function openLogFile (line 31) | func openLogFile(logFilePath string) (*os.File, error) {
function openErrorFile (line 40) | func openErrorFile(errorFilePath string) (*os.File, *bufio.Writer, error) {
function processLogLines (line 50) | func processLogLines(logFile *os.File, writer *bufio.Writer, config *Wtt...
function processLogLine (line 66) | func processLogLine(line string, lineNumber int, writer *bufio.Writer, c...
function extractQueryFromLine (line 83) | func extractQueryFromLine(line string, lineNumber int, writer *bufio.Wri...
FILE: internal/processor/j1.go
function getAny (line 12) | func getAny(req *http.Request, tr1, tr2, tr3, tr4 *http.Transport) (*Res...
function getJ1 (line 38) | func getJ1(req *http.Request, transport *http.Transport) (*ResponseWithH...
function getFormat (line 42) | func getFormat(req *http.Request, transport *http.Transport) (*ResponseW...
function getDefault (line 46) | func getDefault(req *http.Request, transport *http.Transport) (*Response...
function getUpstream (line 50) | func getUpstream(req *http.Request, transport *http.Transport) (*Respons...
function checkURLForPNG (line 95) | func checkURLForPNG(r *http.Request) bool {
FILE: internal/processor/peak.go
method startPeakHandling (line 12) | func (rp *RequestProcessor) startPeakHandling() error {
method savePeakRequest (line 40) | func (rp *RequestProcessor) savePeakRequest(cacheDigest string, r *http....
method prefetchRequest (line 48) | func (rp *RequestProcessor) prefetchRequest(r *http.Request) error {
function syncMapLen (line 54) | func syncMapLen(sm *sync.Map) int {
method prefetchPeakRequests (line 72) | func (rp *RequestProcessor) prefetchPeakRequests(peakRequestMap *sync.Ma...
FILE: internal/processor/processor.go
function plainTextAgents (line 25) | func plainTextAgents() []string {
type ResponseWithHeader (line 45) | type ResponseWithHeader struct
type RequestProcessor (line 55) | type RequestProcessor struct
method Start (line 136) | func (rp *RequestProcessor) Start() error {
method ProcessRequest (line 140) | func (rp *RequestProcessor) ProcessRequest(r *http.Request) (*Response...
method processRequestFromCache (line 185) | func (rp *RequestProcessor) processRequestFromCache(r *http.Request) *...
method processUncachedRequest (line 231) | func (rp *RequestProcessor) processUncachedRequest(r *http.Request) (*...
function NewRequestProcessor (line 71) | func NewRequestProcessor(config *config.Config) (*RequestProcessor, erro...
function getCacheDigest (line 273) | func getCacheDigest(req *http.Request) string {
function dontCache (line 287) | func dontCache(req *http.Request) bool {
function redirectInsecure (line 300) | func redirectInsecure(req *http.Request) (*ResponseWithHeader, bool) {
function isPlainTextAgent (line 332) | func isPlainTextAgent(userAgent string) bool {
function randInt (line 343) | func randInt(min int, max int) int {
function ipFromAddr (line 348) | func ipFromAddr(s string) string {
function fromCadre (line 358) | func fromCadre(cadre *routing.Cadre) *ResponseWithHeader {
FILE: internal/routing/routing.go
type CadreFormat (line 9) | type CadreFormat
constant CadreFormatANSI (line 13) | CadreFormatANSI = iota
constant CadreFormatHTML (line 16) | CadreFormatHTML
constant CadreFormatPNG (line 19) | CadreFormatPNG
type Cadre (line 23) | type Cadre struct
type Handler (line 36) | type Handler interface
type routeFunc (line 40) | type routeFunc
type route (line 42) | type route struct
type Router (line 48) | type Router struct
method Route (line 53) | func (r *Router) Route(req *http.Request) Handler {
method AddPath (line 64) | func (r *Router) AddPath(path string, handler Handler) {
function routePath (line 68) | func routePath(path string) routeFunc {
FILE: internal/stats/stats.go
type Stats (line 14) | type Stats struct
method Inc (line 29) | func (c *Stats) Inc(key string) {
method Get (line 36) | func (c *Stats) Get(key string) int {
method Reset (line 44) | func (c *Stats) Reset(key string) int {
method Show (line 54) | func (c *Stats) Show() []byte {
method Response (line 85) | func (c *Stats) Response(*http.Request) *routing.Cadre {
function New (line 21) | func New() *Stats {
FILE: internal/types/types.go
type CacheType (line 3) | type CacheType
constant CacheTypeDB (line 6) | CacheTypeDB = "db"
constant CacheTypeFiles (line 7) | CacheTypeFiles = "files"
FILE: internal/util/files.go
function RemoveFileIfExists (line 7) | func RemoveFileIfExists(filename string) error {
FILE: internal/util/http.go
function ReadUserIP (line 11) | func ReadUserIP(r *http.Request) string {
FILE: internal/util/yaml.go
function YamlUnmarshalStrict (line 10) | func YamlUnmarshalStrict(in []byte, out interface{}) error {
FILE: internal/view/v1/api.go
type cond (line 18) | type cond struct
type astro (line 33) | type astro struct
type weather (line 40) | type weather struct
type loc (line 48) | type loc struct
type resp (line 54) | type resp struct
method getDataFromAPI (line 63) | func (g *global) getDataFromAPI() (*resp, error) {
method unmarshalLang (line 134) | func (g *global) unmarshalLang(body []byte, r *resp) error {
FILE: internal/view/v1/cmd.go
type Configuration (line 24) | type Configuration struct
type global (line 38) | type global struct
method configload (line 51) | func (g *global) configload() error {
method configsave (line 60) | func (g *global) configsave() error {
method init (line 69) | func (g *global) init() {
constant wuri (line 46) | wuri = "http://127.0.0.1:5001/premium/v1/weather.ashx?"
constant suri (line 47) | suri = "http://127.0.0.1:5001/premium/v1/search.ashx?"
constant slotcount (line 48) | slotcount = 4
function Cmd (line 105) | func Cmd() error {
FILE: internal/view/v1/format.go
function windDir (line 12) | func windDir() map[string]string {
method formatTemp (line 33) | func (g *global) formatTemp(c cond) string {
method formatWind (line 178) | func (g *global) formatWind(c cond) string {
function windInRightUnits (line 200) | func windInRightUnits(spd int, windMS, imperial bool) int {
function speedToColor (line 210) | func speedToColor(spd, spdConverted int) string {
method formatVisibility (line 240) | func (g *global) formatVisibility(c cond) string {
method formatRain (line 248) | func (g *global) formatRain(c cond) string {
method formatCond (line 264) | func (g *global) formatCond(cur []string, c cond, current bool) []string {
function justifyCenter (line 353) | func justifyCenter(s string, width int) string {
function reverse (line 368) | func reverse(s string) string {
method pad (line 377) | func (g *global) pad(s string, mustLen int) string {
FILE: internal/view/v1/icons.go
function getIcon (line 4) | func getIcon(name string) []string {
function codes (line 162) | func codes() map[int][]string {
FILE: internal/view/v1/locale.go
function locale (line 4) | func locale() map[string]string {
function localizedCaption (line 80) | func localizedCaption() map[string]string {
function daytimeTranslation (line 157) | func daytimeTranslation() map[string][]string {
function unitTemp (line 234) | func unitTemp() map[bool]string {
function localizedRain (line 241) | func localizedRain() map[string]map[bool]string {
function localizedVis (line 262) | func localizedVis() map[string]map[bool]string {
function localizedWind (line 283) | func localizedWind() map[string]map[int]string {
function unitWind (line 313) | func unitWind(unit int, lang string) string {
function unitVis (line 322) | func unitVis(unit bool, lang string) string {
function unitRain (line 331) | func unitRain(unit bool, lang string) string {
FILE: internal/view/v1/view1.go
function slotTimes (line 10) | func slotTimes() []int {
method printDay (line 15) | func (g *global) printDay(w weather) ([]string, error) {
FILE: lib/airports.py
function load_aiports_index (line 6) | def load_aiports_index():
function get_airport_gps_location (line 20) | def get_airport_gps_location(iata_code):
FILE: lib/buttons.py
function add_buttons (line 25) | def add_buttons(output):
FILE: lib/cache.py
function _update_answer (line 25) | def _update_answer(answer):
function get_signature (line 37) | def get_signature(user_agent, query_string, client_ip_address, lang):
function get (line 60) | def get(signature):
function _randint (line 85) | def _randint(minimum, maximum):
function store (line 89) | def store(signature, value):
function _hash (line 111) | def _hash(signature):
function _store_in_file (line 115) | def _store_in_file(signature, value):
function _read_from_file (line 140) | def _read_from_file(signature, sighash=None):
FILE: lib/duplicate_translations.py
function remove_colon_and_strip_from_str (line 4) | def remove_colon_and_strip_from_str(line):
function print_result_for_file (line 11) | def print_result_for_file(file_path, file_name, duplicate_entries):
function find_duplicates (line 29) | def find_duplicates(directory, debug=False):
FILE: lib/extract_emoji.py
function extract_emojis_to_directory (line 51) | def extract_emojis_to_directory(dirname):
FILE: lib/fmt/png.py
function render_ansi (line 70) | def render_ansi(text, options=None):
function _color_mapping (line 90) | def _color_mapping(color, inverse=False):
function _strip_buf (line 113) | def _strip_buf(buf):
function _script_category (line 145) | def _script_category(char):
function _load_emojilib (line 163) | def _load_emojilib():
function _gen_term (line 177) | def _gen_term(buf, graphemes, options=None):
function _fix_graphemes (line 272) | def _fix_graphemes(text):
FILE: lib/fmt/unicodedata2.py
function script_cat (line 1784) | def script_cat(chr):
function script (line 1803) | def script(chr):
function category (line 1808) | def category(chr):
function _compile_scripts_txt (line 1813) | def _compile_scripts_txt():
FILE: lib/globals.py
function error (line 137) | def error(text):
function log (line 146) | def log(text):
function debug_log (line 154) | def debug_log(text):
function get_help_file (line 163) | def get_help_file(lang):
function remove_ansi (line 172) | def remove_ansi(sometext):
FILE: lib/limits.py
function _time_caps (line 25) | def _time_caps(minutes, hours, days):
class Limits (line 33) | class Limits(object):
method __init__ (line 42) | def __init__(self, whitelist=None, limits=None):
method _log_visit (line 66) | def _log_visit(self, interval, ip_address):
method _limit_exceeded (line 71) | def _limit_exceeded(self, interval, ip_address):
method _get_limit (line 76) | def _get_limit(self, interval):
method _report_excessive_visits (line 79) | def _report_excessive_visits(self, interval, ip_address):
method check_ip (line 84) | def check_ip(self, ip_address):
method reset (line 102) | def reset(self):
method _clear_counters_if_needed (line 109) | def _clear_counters_if_needed(self):
FILE: lib/location.py
function _debug_log (line 66) | def _debug_log(s):
function _is_ip (line 72) | def _is_ip(ip_addr):
function _location_normalize (line 91) | def _location_normalize(location):
function _geolocator (line 105) | def _geolocator(location):
function _ipcachewrite (line 137) | def _ipcachewrite(ip_addr, location):
function _ipcache (line 162) | def _ipcache(ip_addr):
function _ip2location (line 191) | def _ip2location(ip_addr):
function _ipinfo (line 223) | def _ipinfo(ip_addr):
function _geoip (line 246) | def _geoip(ip_addr):
function _country_name_workaround (line 278) | def _country_name_workaround(country):
function _get_location (line 285) | def _get_location(ip_addr):
function _location_canonical_name (line 328) | def _location_canonical_name(location):
function _load_aliases (line 337) | def _load_aliases(aliases_filename):
function _load_iata_codes (line 353) | def _load_iata_codes(iata_codes_filename):
function is_location_blocked (line 369) | def is_location_blocked(location):
function _get_hemisphere (line 377) | def _get_hemisphere(location):
function _fully_qualified_location (line 393) | def _fully_qualified_location(location, region, country):
function location_processing (line 422) | def location_processing(location, ip_addr):
function _main_ (line 521) | def _main_():
function _trace_ip (line 541) | def _trace_ip():
FILE: lib/metno.py
function metno_request (line 19) | def metno_request(path, query_string):
function celsius_to_f (line 60) | def celsius_to_f(celsius):
function to_weather_code (line 64) | def to_weather_code(symbol_code):
function to_description (line 123) | def to_description(symbol_code):
function to_16_point (line 129) | def to_16_point(degrees):
function meters_to_miles (line 165) | def meters_to_miles(meters):
function mm_to_inches (line 169) | def mm_to_inches(mm):
function hpa_to_mb (line 173) | def hpa_to_mb(hpa):
function hpa_to_in (line 177) | def hpa_to_in(hpa):
function hpa_to_mmHg (line 181) | def hpa_to_mmHg(hpa):
function group_hours_to_days (line 185) | def group_hours_to_days(lat, lng, hourlies, days_to_return):
function _convert_hour (line 246) | def _convert_hour(hour):
function _convert_hourly (line 367) | def _convert_hourly(hours):
function create_standard_json_from_metno (line 376) | def create_standard_json_from_metno(content, days_to_return):
FILE: lib/parse_query.py
function serialize (line 7) | def serialize(parsed_query):
function deserialize (line 13) | def deserialize(url):
function metric_or_imperial (line 35) | def metric_or_imperial(query, lang, us_ip=False):
function parse_query (line 62) | def parse_query(args):
function parse_wttrin_png_name (line 130) | def parse_wttrin_png_name(name):
FILE: lib/proxy_log.py
class Logger (line 11) | class Logger:
method __init__ (line 18) | def __init__(self, filename_access, filename_errors):
method _shorten_query (line 25) | def _shorten_query(self, query):
method log (line 28) | def log(self, query, error):
class LoggerWWO (line 45) | class LoggerWWO(Logger):
method _shorten_query (line 50) | def _shorten_query(self, query):
FILE: lib/translations.py
function get_message (line 1063) | def get_message(message_name, lang):
FILE: lib/view/line.py
function convert_to_fahrenheit (line 54) | def convert_to_fahrenheit(temp):
function render_temperature (line 60) | def render_temperature(data, query):
function render_feel_like_temperature (line 76) | def render_feel_like_temperature(data, query):
function render_condition (line 92) | def render_condition(data, query):
function render_condition_fullname (line 114) | def render_condition_fullname(data, query):
function render_condition_plain (line 135) | def render_condition_plain(data, query):
function render_condition_int (line 143) | def render_condition_int(data, query):
function render_humidity (line 148) | def render_humidity(data, query):
function render_precipitation (line 159) | def render_precipitation(data, query):
function render_precipitation_chance (line 170) | def render_precipitation_chance(data, query):
function render_pressure (line 181) | def render_pressure(data, query):
function render_dewpoint (line 192) | def render_dewpoint(data, query):
function render_uv_index (line 219) | def render_uv_index(data, query):
function render_wind (line 228) | def render_wind(data, query):
function render_location (line 268) | def render_location(data, query):
function render_moonphase (line 276) | def render_moonphase(_, query):
function render_moonday (line 285) | def render_moonday(_, query):
function get_geodata (line 298) | def get_geodata(location):
function render_dawn (line 306) | def render_dawn(data, query, local_time_of):
function render_dusk (line 312) | def render_dusk(data, query, local_time_of):
function render_sunrise (line 318) | def render_sunrise(data, query, local_time_of):
function render_sunset (line 324) | def render_sunset(data, query, local_time_of):
function render_zenith (line 330) | def render_zenith(data, query, local_time_of):
function render_local_time (line 336) | def render_local_time(data, query, local_time_of):
function render_local_timezone (line 342) | def render_local_timezone(data, query, local_time_of):
function render_line (line 380) | def render_line(line, data, query):
function render_json (line 441) | def render_json(data):
function format_weather_data (line 457) | def format_weather_data(query, parsed_query, data):
function wttr_line (line 491) | def wttr_line(query, parsed_query):
function main (line 504) | def main():
FILE: lib/view/moon.py
function get_moon (line 14) | def get_moon(parsed_query):
FILE: lib/view/prometheus.py
function _render_current (line 11) | def _render_current(data, for_day="current", already_seen=[]):
function _convert_time_to_minutes (line 54) | def _convert_time_to_minutes(time_str):
function render_prometheus (line 67) | def render_prometheus(data):
FILE: lib/view/v2.py
function get_data (line 54) | def get_data(config):
function interpolate_data (line 70) | def interpolate_data(input_data, max_width):
function jq_query (line 84) | def jq_query(query, data_parsed):
function colorize (line 96) | def colorize(string, color_code, html_output=False):
function draw_spark (line 107) | def draw_spark(data, height, width, color_data):
function draw_diagram (line 175) | def draw_diagram(data, height, width):
function draw_date (line 193) | def draw_date(config, geo_data):
function draw_time (line 221) | def draw_time(geo_data):
function draw_astronomical (line 257) | def draw_astronomical(city_name, geo_data, config):
function draw_emoji (line 338) | def draw_emoji(data, config):
function draw_wind (line 360) | def draw_wind(data, color_data, config):
function add_frame (line 418) | def add_frame(output, width, config):
function generate_panel (line 449) | def generate_panel(data_parsed, geo_data, config):
function textual_information (line 510) | def textual_information(data_parsed, geo_data, config, html_output=False):
function get_geodata (line 640) | def get_geodata(location):
function main (line 650) | def main(query, parsed_query, data):
FILE: lib/view/wttr.py
function get_wetter (line 27) | def get_wetter(parsed_query):
function _wego_wrapper (line 91) | def _wego_wrapper(location, parsed_query):
function _wego_postprocessing (line 127) | def _wego_postprocessing(location, parsed_query, stdout):
function _htmlize (line 180) | def _htmlize(ansi_output, title, parsed_query):
function _get_opengraph (line 207) | def _get_opengraph(parsed_query):
FILE: lib/weather_data.py
function get_weather_data (line 10) | def get_weather_data(location, lang):
FILE: lib/wttr_srv.py
function show_text_file (line 58) | def show_text_file(name, lang):
function _client_ip_address (line 79) | def _client_ip_address(request):
function _parse_language_header (line 96) | def _parse_language_header(header):
function get_answer_language_and_view (line 143) | def get_answer_language_and_view(request):
function get_output_format (line 170) | def get_output_format(query, parsed_query):
function _cyclic_location_selection (line 193) | def _cyclic_location_selection(locations, period):
function _response (line 211) | def _response(parsed_query, query, fast_mode=False):
function parse_request (line 278) | def parse_request(location, request, query, fast_mode=False):
function wttr (line 369) | def wttr(location, request):
FILE: srv.go
constant logLineStart (line 40) | logLineStart = "LOG_LINE_START "
function suppressMessages (line 42) | func suppressMessages() []string {
function copyHeader (line 51) | func copyHeader(dst, src http.Header) {
function serveHTTP (line 59) | func serveHTTP(mux *http.ServeMux, port int, logFile io.Writer, errs cha...
function serveHTTPS (line 71) | func serveHTTPS(mux *http.ServeMux, port int, certFile, keyFile string, ...
function serve (line 93) | func serve(conf *config.Config) error {
function mainHandler (line 152) | func mainHandler(
function main (line 183) | func main() {
function convertGeoIPCache (line 241) | func convertGeoIPCache(conf *config.Config) error {
function convertGeoLocationCache (line 250) | func convertGeoLocationCache(conf *config.Config) error {
function setLogLevel (line 259) | func setLogLevel(logLevel string) error {
Condensed preview — 222 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,156K chars).
[
{
"path": ".flake8",
"chars": 28,
"preview": "[flake8]\nignore = E402,E501\n"
},
{
"path": ".github/workflows/makefile.yml",
"chars": 1635,
"preview": "name: Makefile CI\n\non:\n push:\n branches: [ \"master\" ]\n pull_request:\n branches: [ \"master\" ]\n\njobs:\n build:\n\n "
},
{
"path": ".gitignore",
"chars": 67,
"preview": "ve/\nshare/static/fonts/\n*.pyc\ndata/\nlog/\n.idea/\n*.swp\n*.mmdb\n*.dat\n"
},
{
"path": ".golangci.yaml",
"chars": 412,
"preview": "run:\n skip-dirs:\n - pkg/curlator\nlinters:\n enable-all: true\n disable:\n - wsl\n - wrapcheck\n - varnamelen\n "
},
{
"path": ".travis.yml",
"chars": 658,
"preview": "group: travis_latest\nlanguage: python\ncache: pip\npython:\n - 3.7\ninstall:\n - pip install flake8 -r requirements.txt"
},
{
"path": "Dockerfile",
"chars": 1486,
"preview": "# Build stage\nFROM golang:1-alpine as builder\n\nWORKDIR /app\n\nCOPY ./share/we-lang/ /app\n\nRUN apk add --no-cache git\n\nRUN"
},
{
"path": "LICENSE",
"chars": 11358,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "Makefile",
"chars": 213,
"preview": "srv: srv.go internal/*/*.go internal/*/*/*.go\n\t#go build -o srv -ldflags '-w -linkmode external -extldflags \"-static\"' ."
},
{
"path": "README.md",
"chars": 25376,
"preview": "\n*wttr.in — the right way to ~check~ `curl` the weather!*\n\nwttr.in is a console-oriented weather forecast service that s"
},
{
"path": "bin/geo-proxy.py",
"chars": 3271,
"preview": "import gevent\nfrom gevent.pywsgi import WSGIServer\nfrom gevent.queue import Queue\nfrom gevent.monkey import patch_all\nfr"
},
{
"path": "bin/proxy.py",
"chars": 12178,
"preview": "# vim: fileencoding=utf-8\n\n\"\"\"\n\nThe proxy server acts as a backend for the wttr.in service.\n\nIt caches the answers and h"
},
{
"path": "bin/srv.py",
"chars": 1697,
"preview": "#!/usr/bin/env python\n# vim: set encoding=utf-8\n\nfrom gevent.pywsgi import WSGIServer\nfrom gevent.monkey import patch_al"
},
{
"path": "config/services/services.yaml",
"chars": 989,
"preview": "services:\n - name: \"main server\"\n command: \"while true; do sudo /wttr.in/bin/srv big-cache.yaml ; sleep 5; done\"\n "
},
{
"path": "doc/integrations.md",
"chars": 16759,
"preview": "## Integrations\n\nThanks to the ease of integrating *wttr.in* into any program, there are a\nplethora of popular integrati"
},
{
"path": "doc/terminal-images.md",
"chars": 2446,
"preview": "\n## Map view (v3)\n\nIn the experimental map view, that is available under the view code `v3`,\nweather information about a"
},
{
"path": "go.mod",
"chars": 1085,
"preview": "module github.com/chubin/wttr.in\n\ngo 1.16\n\nrequire (\n\tgithub.com/alecthomas/kong v1.7.0\n\tgithub.com/denisenkom/go-mssqld"
},
{
"path": "go.sum",
"chars": 14200,
"preview": "github.com/alecthomas/assert/v2 v2.1.0/go.mod h1:b/+1DI2Q6NckYi+3mXyH3wFb8qG37K/DuK80n7WefXA=\ngithub.com/alecthomas/asse"
},
{
"path": "internal/config/config.go",
"chars": 4618,
"preview": "package config\n\nimport (\n\t\"log\"\n\t\"os\"\n\n\t\"gopkg.in/yaml.v3\"\n\n\t\"github.com/chubin/wttr.in/internal/types\"\n\t\"github.com/chu"
},
{
"path": "internal/fmt/png/colors.go",
"chars": 5854,
"preview": "package main\n\n// Source: https://www.ditig.com/downloads/256-colors.json\n\nvar ansiColorsDB = [][3]float64{\n\t{\n\t\t0, 0, 0,"
},
{
"path": "internal/fmt/png/go.mod",
"chars": 276,
"preview": "module example.com/m/v2\n\ngo 1.20\n\nrequire (\n\tgithub.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 // indirect\n\tgit"
},
{
"path": "internal/fmt/png/go.sum",
"chars": 768,
"preview": "github.com/chubin/vt10x v0.0.0-20231112153020-ef4f56837bf1 h1:CHg5BTAJZmCjBaAAQrD92s248JHH3JTsLlaC6QBJo/Y=\ngithub.com/ch"
},
{
"path": "internal/fmt/png/png.go",
"chars": 6176,
"preview": "package main\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/chubin/vt10x\"\n\t\"github.com/fogleman/gg\"\n)\n\nfunc Stri"
},
{
"path": "internal/geo/ip/convert.go",
"chars": 1590,
"preview": "package ip\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"path/filepath\"\n\n\t\"github.com/samonzeweb/godb\"\n\t\"github.com/samonzeweb/godb/adapters"
},
{
"path": "internal/geo/ip/ip.go",
"chars": 5520,
"preview": "package ip\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"path\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/samonzeweb/go"
},
{
"path": "internal/geo/ip/ip_test.go",
"chars": 1733,
"preview": "package ip_test\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\t. \"github.com/chubin/wttr.in/internal/geo"
},
{
"path": "internal/geo/location/cache.go",
"chars": 5357,
"preview": "package location\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/samonzewe"
},
{
"path": "internal/geo/location/convert.go",
"chars": 2648,
"preview": "package location\n\nimport (\n\t\"database/sql\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/samon"
},
{
"path": "internal/geo/location/location.go",
"chars": 524,
"preview": "package location\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n)\n\ntype Location struct {\n\tName string `db:\"name,key\" json:\"name\""
},
{
"path": "internal/geo/location/nominatim.go",
"chars": 1371,
"preview": "package location\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\n\t\"github.com/chubin/wttr.in/internal/types\""
},
{
"path": "internal/geo/location/nominatim_locationiq.go",
"chars": 801,
"preview": "package location\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/chubin/wttr.in/internal/types\"\n)\n\ntype locationIQ []struct {\n"
},
{
"path": "internal/geo/location/nominatim_opencage.go",
"chars": 884,
"preview": "package location\n\nimport (\n\t\"fmt\"\n\t\"net/url\"\n\n\t\"github.com/chubin/wttr.in/internal/types\"\n)\n\ntype locationOpenCage struc"
},
{
"path": "internal/geo/location/response.go",
"chars": 1008,
"preview": "package location\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/chubin/wttr.in/internal/r"
},
{
"path": "internal/geo/location/search.go",
"chars": 934,
"preview": "package location\n\nimport \"github.com/chubin/wttr.in/internal/config\"\n\ntype Provider interface {\n\tQuery(location string) "
},
{
"path": "internal/logging/logging.go",
"chars": 2393,
"preview": "package logging\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/chubin/wttr.in/internal/util\"\n)\n\n// Log"
},
{
"path": "internal/logging/suppress.go",
"chars": 1643,
"preview": "package logging\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n)\n\n// LogSuppressor provides io.Writer interface for logging\n// with "
},
{
"path": "internal/options/options.go",
"chars": 3193,
"preview": "package options\n\nimport (\n\t\"fmt\"\n\t\"io/ioutil\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// WttrInOptions represents the configuration for "
},
{
"path": "internal/options/parse.go",
"chars": 8970,
"preview": "package options\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// Errors for query parsing and"
},
{
"path": "internal/options/processlog.go",
"chars": 2984,
"preview": "package options\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n)\n\n// ProcessLogFile reads a wttr.in log file, parses queries"
},
{
"path": "internal/processor/j1.go",
"chars": 2194,
"preview": "package processor\n\nimport (\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc getAny(req *http.Reque"
},
{
"path": "internal/processor/peak.go",
"chars": 2107,
"preview": "package processor\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/robfig/cron\"\n)\n\nfunc (rp *RequestProcessor)"
},
{
"path": "internal/processor/processor.go",
"chars": 9403,
"preview": "package processor\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\tlru \"g"
},
{
"path": "internal/routing/routing.go",
"chars": 1408,
"preview": "package routing\n\nimport (\n\t\"net/http\"\n\t\"time\"\n)\n\n// CadreFormat specifies how the shot data is formatted.\ntype CadreForm"
},
{
"path": "internal/stats/stats.go",
"chars": 1859,
"preview": "package stats\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/chubin/wttr.in/internal/routing\"\n)\n\n//"
},
{
"path": "internal/types/errors.go",
"chars": 417,
"preview": "package types\n\nimport \"errors\"\n\nvar (\n\tErrNotFound = errors.New(\"cache entry not found\")\n\tErrInvalidCacheEntry "
},
{
"path": "internal/types/types.go",
"chars": 97,
"preview": "package types\n\ntype CacheType string\n\nconst (\n\tCacheTypeDB = \"db\"\n\tCacheTypeFiles = \"files\"\n)\n"
},
{
"path": "internal/util/files.go",
"chars": 375,
"preview": "package util\n\nimport \"os\"\n\n// RemoveFileIfExists removes filename if exists, or does nothing if the file\n// is not there"
},
{
"path": "internal/util/http.go",
"chars": 537,
"preview": "package util\n\nimport (\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n)\n\n// ReadUserIP returns IP address of the client from http.Request,\n//"
},
{
"path": "internal/util/yaml.go",
"chars": 303,
"preview": "package util\n\nimport (\n\t\"bytes\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\n// YamlUnmarshalStrict unmarshals YAML data with an error when u"
},
{
"path": "internal/view/v1/api.go",
"chars": 4272,
"preview": "//nolint:forbidigo,funlen,nestif,goerr113,gocognit,cyclop\npackage v1\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n"
},
{
"path": "internal/view/v1/cmd.go",
"chars": 4341,
"preview": "// This code represents wttr.in view v1.\n// It is based on wego (github.com/schachmat/wego) from which it diverged back "
},
{
"path": "internal/view/v1/format.go",
"chars": 8979,
"preview": "//nolint:funlen,nestif,cyclop,gocognit,gocyclo\npackage v1\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\t\"unicode/utf8\"\n\n\t\"github.com/matt"
},
{
"path": "internal/view/v1/icons.go",
"chars": 6863,
"preview": "package v1\n\n//nolint:funlen\nfunc getIcon(name string) []string {\n\ticon := map[string][]string{\n\t\t\"iconUnknown\": {\n\t\t\t\" "
},
{
"path": "internal/view/v1/locale.go",
"chars": 9271,
"preview": "package v1\n\n//nolint:funlen\nfunc locale() map[string]string {\n\treturn map[string]string{\n\t\t\"af\": \"af_ZA\",\n\t\t\"am\": "
},
{
"path": "internal/view/v1/view1.go",
"chars": 3481,
"preview": "package v1\n\nimport (\n\t\"math\"\n\t\"time\"\n\n\t\"github.com/klauspost/lctime\"\n)\n\nfunc slotTimes() []int {\n\treturn []int{9 * 60, 1"
},
{
"path": "lib/airports.py",
"chars": 541,
"preview": "import csv\n\nAIRPORTS_DAT_FILE = \"/home/igor/wttrin-geo/share/airports.dat\"\n\n\ndef load_aiports_index():\n file_ = open("
},
{
"path": "lib/buttons.py",
"chars": 1926,
"preview": "TWITTER_BUTTON = \"\"\"\n<a href=\"https://twitter.com/igor_chubin?ref_src=twsrc%5Etfw\" class=\"twitter-follow-button\" data-sh"
},
{
"path": "lib/cache.py",
"chars": 4191,
"preview": "\"\"\"\nLRU-Cache implementation for formatted (`format=`) answers\n\"\"\"\n\nimport datetime\nimport re\nimport time\nimport os\nimpo"
},
{
"path": "lib/constants.py",
"chars": 10851,
"preview": "# vim: fileencoding=utf-8\n\nWWO_CODE = {\n \"113\": \"Sunny\",\n \"116\": \"PartlyCloudy\",\n \"119\": \"Cloudy\",\n \"122\": \""
},
{
"path": "lib/datasource/README.md",
"chars": 946,
"preview": "\nCurrently wttr.in uses just one data source, but more data sources must be added.\nHaving more data sources will increas"
},
{
"path": "lib/duplicate_translations.py",
"chars": 3047,
"preview": "import os\n\n\ndef remove_colon_and_strip_from_str(line):\n \"\"\"\n Removes the colon from the line and strips the line.\n"
},
{
"path": "lib/extract_emoji.py",
"chars": 1345,
"preview": "#!/usr/bin/env python\n# vim: fileencoding=utf-8\n\n\"\"\"\n\nAt the moment, Pillow library does not support colorful emojis,\nth"
},
{
"path": "lib/fields.py",
"chars": 2872,
"preview": "\"\"\"\nHuman readable description of the available data fields\ndescribing current weather, weather forecast, and astronomic"
},
{
"path": "lib/fmt/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "lib/fmt/png.py",
"chars": 8123,
"preview": "#!/usr/bin/python\n# vim: encoding=utf-8\n# pylint: disable=wrong-import-position,wrong-import-order,redefined-builtin\n\n\"\""
},
{
"path": "lib/fmt/unicodedata2.py",
"chars": 58217,
"preview": "# downloaded from https://gist.github.com/2204527\n# described/recommended here:\n#\n# http://stackoverflow.com/questions"
},
{
"path": "lib/globals.py",
"chars": 4588,
"preview": "\"\"\"\nglobal configuration of the project\n\nExternal environment variables:\n\n WTTR_MYDIR\n WTTR_GEOLITE\n WTTR_WEGO\n"
},
{
"path": "lib/limits.py",
"chars": 3211,
"preview": "\"\"\"\nConnection limitation.\n\nNumber of connections from one IP is limited.\nWe have nothing against scripting and automate"
},
{
"path": "lib/location.py",
"chars": 16854,
"preview": "\"\"\"\nAll location related functions and converters.\n\nThe main entry point is `location_processing` which gets `location` "
},
{
"path": "lib/metno.py",
"chars": 17444,
"preview": "#!/bin/env python\n# vim: fileencoding=utf-8\nfrom datetime import datetime, timedelta\nimport json\nimport logging\nimport o"
},
{
"path": "lib/parse_query.py",
"chars": 4395,
"preview": "import re\nimport json\nimport zlib\nimport base64\n\n\ndef serialize(parsed_query):\n return base64.b64encode(\n zlib"
},
{
"path": "lib/proxy_log.py",
"chars": 1248,
"preview": "\"\"\"\nLogger of proxy queries\n\n\"\"\"\n\n# pylint: disable=consider-using-with,too-few-public-methods\n\nimport datetime\n\n\nclass "
},
{
"path": "lib/translations.py",
"chars": 48210,
"preview": "# vim: fileencoding=utf-8\n\n\"\"\"\nTranslation of almost everything.\n\"\"\"\n\nFULL_TRANSLATION = [\n \"am\",\n \"ar\",\n \"af\","
},
{
"path": "lib/translations_v2.py",
"chars": 9784,
"preview": "# vim: fileencoding=utf-8\n\n\"\"\"\nTranslation of v2\n\"\"\"\n\n# pylint: disable=line-too-long,bad-whitespace\nV2_TRANSLATION = {\n"
},
{
"path": "lib/view/__init__.py",
"chars": 0,
"preview": ""
},
{
"path": "lib/view/line.py",
"chars": 12300,
"preview": "# vim: fileencoding=utf-8\n\n\"\"\"\nOne-line output mode.\n\nInitial implementation of one-line output mode.\n\n[ ] forecast\n[ ] "
},
{
"path": "lib/view/moon.py",
"chars": 1535,
"preview": "import sys\n\nimport os\nimport dateutil.parser\n\nfrom gevent.subprocess import Popen, PIPE\n\nsys.path.insert(0, \"..\")\nimport"
},
{
"path": "lib/view/prometheus.py",
"chars": 2048,
"preview": "\"\"\"\nRendering weather data in the Prometheus format.\n\n\"\"\"\n\nfrom datetime import datetime\n\nfrom fields import DESCRIPTION"
},
{
"path": "lib/view/v2.py",
"chars": 19230,
"preview": "# vim: fileencoding=utf-8\n# vim: foldmethod=marker foldenable:\n\n\"\"\"\n[X] emoji\n[ ] wego icon\n[ ] v2.wttr.in\n[X] astronomi"
},
{
"path": "lib/view/wttr.py",
"chars": 6438,
"preview": "# vim: set encoding=utf-8\n# pylint: disable=wrong-import-position\n\n\"\"\"\nMain view (wttr.in) implementation.\nThe module is"
},
{
"path": "lib/weather_data.py",
"chars": 541,
"preview": "\"\"\"\nWeather data source\n\"\"\"\n\nimport json\nimport requests\nfrom globals import WWO_KEY\n\n\ndef get_weather_data(location, la"
},
{
"path": "lib/wttr_srv.py",
"chars": 15701,
"preview": "#!/usr/bin/env python\n# vim: set encoding=utf-8\n\n\"\"\"\nMain wttr.in rendering function implementation\n\"\"\"\n\nimport logging\n"
},
{
"path": "requirements.txt",
"chars": 347,
"preview": "flask\ngeoip2\ngeopy\nrequests\ngevent\ndnspython\npylint\ncyrtranslit\nastral>=2.0,<=2.2\ntimezonefinder\npytz\npyte\npython-dateut"
},
{
"path": "share/aliases",
"chars": 1526,
"preview": "MDW : MDW Chicago\nmdw : MDW Chicago\nMsk : Moscow\nMoskva : Moscow\nMoskau : Moscow\nKyiv "
},
{
"path": "share/ansi2html.sh",
"chars": 16185,
"preview": "#!/bin/sh\n\n# Convert ANSI (terminal) colours and attributes to HTML\n\n# Licence: LGPLv2\n# Author:\n# http://www.pixelbe"
},
{
"path": "share/bash-function.txt",
"chars": 891,
"preview": "#! /usr/bin/env bash\n# If you source this file, it will set WTTR_PARAMS as well as show weather.\n\n# WTTR_PARAMS is space"
},
{
"path": "share/blacklist",
"chars": 106,
"preview": "NOT_FOUND\napple-touch-icon.png\napple-touch-icon-precomposed.png\napple-touch-icon-152x152-precomposed.png\n\n"
},
{
"path": "share/docker/supervisord.conf",
"chars": 622,
"preview": "[supervisord]\nnodaemon=true\nlogfile=/var/log/supervisor/supervisord.log\npidfile=/var/run/supervisord.pid\n\n[program:srv]\n"
},
{
"path": "share/help.txt",
"chars": 2568,
"preview": "Usage:\n\n $ curl wttr.in # current location\n $ curl wttr.in/muc # weather in the Munich airport\n\nSupp"
},
{
"path": "share/iterm2.txt",
"chars": 61696,
"preview": "\u001b]1337;File=name=TnVyZW1iZXJnLnBuZw==;size=46226;inline=1:iVBORw0KGgoAAAANSUhEUgAAA2sAAAIUCAIAAAAL1CNnAAC0WUlEQVR4nOzdeV"
},
{
"path": "share/list-of-iata-codes.txt",
"chars": 22700,
"preview": "GKA\nMAG\nHGU\nLAE\nPOM\nWWK\nUAK\nGOH\nSFJ\nTHU\nAEY\nEGS\nHFN\nHZK\nIFJ\nKEF\nPFJ\nRKV\nSIJ\nVEY\nYAM\nYAV\nYAW\nYAY\nYAZ\nYBB\nYBC\nYBG\nYBK\nYBL\n"
},
{
"path": "share/salt/README.md",
"chars": 705,
"preview": "# Opinionated example of deployment via Salt Stack\n\n## Assumptions:\n * user & group srv:srv exist, this is used as a ge"
},
{
"path": "share/salt/init.sls",
"chars": 2554,
"preview": "wttr:\n service.running:\n - enable: True\n - watch:\n - file: /srv/ephemeral/start.sh\n - git: wttr-repo\n "
},
{
"path": "share/salt/pillar.sls",
"chars": 75,
"preview": "wttr:\n apikey: insert-api-key-here-and-make-this-pillar-available-to-salt\n"
},
{
"path": "share/salt/start.sh",
"chars": 311,
"preview": "#!/bin/sh\nexport WEGORC=\"/srv/ephemeral/.wegorc\"\nexport GOPATH=\"/srv/ephemeral\"\n\nexport WTTR_MYDIR=\"/srv/ephemeral/wttr."
},
{
"path": "share/salt/wegorc",
"chars": 1676,
"preview": "# wego configuration\n# \n# This config has https://github.com/schachmat/ingo syntax.\n# Empty lines or lines starting with"
},
{
"path": "share/salt/wttr.service",
"chars": 163,
"preview": "[Unit]\nDescription=Wttr weather service\n\n[Service]\nExecStart=/usr/bin/authbind --deep /srv/ephemeral/start.sh\nRestart=al"
},
{
"path": "share/screenrc",
"chars": 154,
"preview": "screen -t srv.py bash -c \"cd ~/wttr.in; ve/bin/python bin/srv.py; bash -i\"\nscreen -t proxy.py bash -c \"cd ~/wttr.in; ve/"
},
{
"path": "share/scripts/build-welang.sh",
"chars": 979,
"preview": "#!/usr/bin/env bash\n\n# The scipr is used to build a standalone we-lang binary from\n# the wttr.in Go source code. The scr"
},
{
"path": "share/scripts/clean-cache.sh",
"chars": 569,
"preview": "#!/bin/bash\n\nLOGFILE=/tmp/clean-cache.log\n\n_log() {\n echo \"$(date +\"[%Y-%m-%d %H:%M:%S]\") $*\" >> \"$LOGFILE\"\n}\n\n_log_pip"
},
{
"path": "share/scripts/log-space.sh",
"chars": 242,
"preview": "#!/usr/bin/env bash\n\nLOG_DIR=\"/wttr.in/log\"\nLOG_FILE=\"$LOG_DIR/diskspace.log\"\n\nDISK=/wttr.in\n\nlog() {\n mkdir -p \"$LOG_D"
},
{
"path": "share/scripts/start-screen.sh",
"chars": 134,
"preview": "#!/bin/bash\n\nSESSION_NAME=wttr.in\nSCREENRC_PATH=$(dirname $(dirname \"$0\"))/screenrc\n\nscreen -dmS \"$SESSION_NAME\" -c \"$SC"
},
{
"path": "share/static/malformed-response.html",
"chars": 2057,
"preview": "<html>\n<title>wttr.in</title>\n<head>\n<script async src=\"https://platform.twitter.com/widgets.js\" charset=\"utf-8\"></scrip"
},
{
"path": "share/static/style.css",
"chars": 711,
"preview": "body {\n background: black;\n color: #bbbbbb;\n}\n\n/* Switch to light mode if the user prefers it */\n/*\n@media (prefer"
},
{
"path": "share/systemd/README.md",
"chars": 1259,
"preview": "To add **wttr.in** to systemd as a service, do the following steps.\n\n\n1. **Create a systemd service file**: You’ll need "
},
{
"path": "share/systemd/wttrin.service",
"chars": 229,
"preview": "[Unit]\nDescription=wttr.in services\n\n[Service]\nType=oneshot\nRemainAfterExit=yes\nExecStart=/home/igor/src/wttr.in/share/s"
},
{
"path": "share/systemd/wttrin.sh",
"chars": 1192,
"preview": "#!/usr/bin/env bash\n\nSESSION_NAME=\"\"\nSRC_DIR=/home/igor/src/wttr.in\nSERVICES_FILE=config/services/services.yaml\n\nstart_s"
},
{
"path": "share/templates/index.html",
"chars": 296,
"preview": "<html>\n<head>\n<meta name=\"color-scheme\" content=\"dark light\">\n<link rel=\"stylesheet\" type=\"text/css\" href=\"https://adobe"
},
{
"path": "share/test-thunder.txt",
"chars": 6230,
"preview": "Prévisions météo pour: Test-Milon, France\n\n \u001b[38;5;226m _`/\"\"\u001b[38;5;250m.-. \u001b[0m Averse de pluie légère\n \u001b[38;5;226m "
},
{
"path": "share/translation.txt",
"chars": 4885,
"preview": "wttr.in is translated in NUMBER_OF_LANGUAGES languages:\n\n SUPPORTED_LANGUAGES\n\nTranslated/improved/corrected by:\n\n "
},
{
"path": "share/translations/af-help.txt",
"chars": 2342,
"preview": "Gebruik:\n\n $ curl wttr.in # huidige ligging\n $ cur wttr.in/muc # weerberig in München se lughawe\n\nO"
},
{
"path": "share/translations/af.txt",
"chars": 3045,
"preview": "113: Helder : Clear\n113: Sonnig : Sunny\n116: Gedeeltelik"
},
{
"path": "share/translations/am-help.txt",
"chars": 2481,
"preview": "አጠቃቀም:\n\n $ curl wttr.in # አሁን ያሉበት አካባቢ\n $ curl wttr.in/add # የአየር ሁነታ በቦሌ አዲስ አበባ አለምአቀፍ ኤርፖርት \n\nተቀ"
},
{
"path": "share/translations/am.txt",
"chars": 2730,
"preview": "113: ጥርት ያለ : Clear\n113: ፀሐያማ : Sunny\n116: ከፊል ደመናማ "
},
{
"path": "share/translations/ar-help.txt",
"chars": 2410,
"preview": "الإستخدام:\n\n $ curl wttr.in # الموقع الحالي\n $ curl wttr.in/muc # الطقس في مطار ميونخ\n\nأنواع الأماكن"
},
{
"path": "share/translations/ar.txt",
"chars": 3672,
"preview": "113: صاف : Clear \n113: مشمس "
},
{
"path": "share/translations/az.txt",
"chars": 4729,
"preview": "113: Aydınlı : Clear : Ясно \n113: Günəşli"
},
{
"path": "share/translations/be-help.txt",
"chars": 2472,
"preview": "Выкарыстанне:\n\n $ curl wttr.in # надвор'е ў вашым месцы\n $ curl wttr.in/msq # надвор'е ў аэрапорце М"
},
{
"path": "share/translations/be.txt",
"chars": 4183,
"preview": "113: Ясна : Clear : \n113: Сонечна "
},
{
"path": "share/translations/bg-help.txt",
"chars": 2461,
"preview": "Употреба:\n\n $ curl wttr.in # текущо местоположение\n $ curl wttr.in/sof # времето на Софийското летищ"
},
{
"path": "share/translations/bn-help.txt",
"chars": 2412,
"preview": "ব্যবহার:\n\n $ curl wttr.in # এখন যেখানে আছ\n $ curl wttr.in/cdg # প্যারিস - চার্লস ডি গল বিমানবন্দরে আ"
},
{
"path": "share/translations/bn.txt",
"chars": 5664,
"preview": ": বজ্রঝড়ের সঙ্গে ভারী বৃষ্টি ও শিলাবৃষ্টি : Heavy rain and hail with thunderstorm\n: বজ্রঝড়ে"
},
{
"path": "share/translations/bs.txt",
"chars": 3139,
"preview": "113 : Vedro : Clear\n113 : Sunčano : Sunny\n116 : Djelom"
},
{
"path": "share/translations/ca-help.txt",
"chars": 2555,
"preview": "Instruccions:\n\n $ curl wttr.in # el clima de la ubicació actual\n $ curl wttr.in/muc # el clima de l'"
},
{
"path": "share/translations/ca.txt",
"chars": 3045,
"preview": "113 : Clar : Clear\n113 : Assolellat : Sunny\n116 : Parcialmen"
},
{
"path": "share/translations/cs-help.txt",
"chars": 2532,
"preview": "Použití:\n\n $ curl wttr.in # lokální informace\n $ curl wttr.in/muc # počasí v letšiti v Mnichově\n\nPod"
},
{
"path": "share/translations/cs.txt",
"chars": 3941,
"preview": ": Unášený sníh : Low drifting snow\n: Částečná mlha "
},
{
"path": "share/translations/cy.txt",
"chars": 2934,
"preview": "113: Glir : Clear\n113: Heulog : Sunny\n116: Rhannol gymylog "
},
{
"path": "share/translations/da-help.txt",
"chars": 2248,
"preview": "Brugsanvisning:\n\n $ curl wttr.in # Nuværende lokation\n $ curl wttr.in/aarhus # Vejret i Aarhus\n\nUnderst"
},
{
"path": "share/translations/da.txt",
"chars": 3045,
"preview": "113: Skyfrit : Clear\n113: Sol : Sunny\n116: Delvist sky"
},
{
"path": "share/translations/de-help.txt",
"chars": 2400,
"preview": "Benutzung:\n\n $ curl wttr.in # aktuelle Position\n $ curl wttr.in/muc # Wetter, Flughafen München\n\nUnt"
},
{
"path": "share/translations/de.txt",
"chars": 17969,
"preview": ": Dunst in der Nähe : Haze in vicinity\n: Starker Regen- und Schneeschauer "
},
{
"path": "share/translations/dk-help.txt",
"chars": 2233,
"preview": "Brugsanvisning:\n\n $ curl wttr.in # nuværende lokation\n $ curl wttr.in/osl # vejret på Gardermoen fly"
},
{
"path": "share/translations/el-help.txt",
"chars": 3028,
"preview": "Χρήση:\r\n\r\n $ curl wttr.in # καιρός τρέχουσας τοποθεσίας (κατά προσέγγιση, βάσει IP)\r\n $ curl wttr.in/ath "
},
{
"path": "share/translations/el.txt",
"chars": 2904,
"preview": "113: Καθαρός : Clear\r\n113: Λιακάδα : Sunny\r\n116: Αραιή συννεφιά "
},
{
"path": "share/translations/en.txt",
"chars": 2857,
"preview": "113: : Clear\n113: : Sunny\n116: "
},
{
"path": "share/translations/eo.txt",
"chars": 3045,
"preview": "113: Klara : Clear\n113: Suna : Sunny\n116: Parte nuba "
},
{
"path": "share/translations/es-help.txt",
"chars": 2843,
"preview": "Instrucciones:\n\n $ curl wttr.in # El clima en su ubicación actual\n $ curl wttr.in/muc # El clima en "
},
{
"path": "share/translations/es.txt",
"chars": 22013,
"preview": "114: Despejado : Clear :\n113: Soleado "
},
{
"path": "share/translations/et-help.txt",
"chars": 2283,
"preview": "Kasutus:\n\n $ curl wttr.in # praegune asukoht\n $ curl wttr.in/tll # ilmaprognoos Tallinna Lennujaamas"
},
{
"path": "share/translations/et.txt",
"chars": 3331,
"preview": "113: Selge : Clear\n113: Päikeseline : Sunny\n116: Vahelduv pilvisus "
},
{
"path": "share/translations/eu-help.txt",
"chars": 2491,
"preview": "Argibideak:\n\n $ curl wttr.in # eguraldia zure kokapenean\n $ curl wttr.in/bio # eguraldia Bilboko air"
},
{
"path": "share/translations/eu.txt",
"chars": 2113,
"preview": "114: Oskarbia: Clear\n113: Eguzkitsu: Sunny\n116: Neurri batean hodeitsu: Partly cloudy\n119: Hodeitsu: Cloudy\n122: Iluna: "
},
{
"path": "share/translations/fa-help.txt",
"chars": 2828,
"preview": " :نحوه استفاده\n\n $ curl wttr.in # م"
},
{
"path": "share/translations/fa.txt",
"chars": 3796,
"preview": "113: صاف : Clear\n113: آفتابی "
},
{
"path": "share/translations/fr-help.txt",
"chars": 2580,
"preview": "Usage:\n\n $ curl wttr.in # emplacement actuel\n $ curl wttr.in/cdg # météo à l'aéroport de Paris - Cha"
},
{
"path": "share/translations/fr.txt",
"chars": 21771,
"preview": "113: Ensoleillé : Sunny :\n114: Temps clair"
},
{
"path": "share/translations/fy.txt",
"chars": 2625,
"preview": "113: Helder : Clear \n113: Sinnich : Sunny\n116: By"
},
{
"path": "share/translations/ga.txt",
"chars": 2880,
"preview": "113: Geal : Clear\n113: Grianmhar : Sunny\n116: Breacscamallach "
},
{
"path": "share/translations/gl-help.txt",
"chars": 2541,
"preview": "Instrucións:\n\n $ curl wttr.in # o tempo na sua localización actual\n $ curl wttr.in/muc # o tempo no "
},
{
"path": "share/translations/gl.txt",
"chars": 2857,
"preview": "113: Despexado : Clear\n113: Solleiro : Sunny\n116: Parcialmente Nubrad"
},
{
"path": "share/translations/gu-help.txt",
"chars": 2490,
"preview": "Usage:\n\n $ curl wttr.in # current location\n $ curl wttr.in/muc # weather in the Munich airport\n\nSupp"
},
{
"path": "share/translations/gu.txt",
"chars": 5636,
"preview": ": Fortes pluies et orages de grêle : Heavy rain and hail with thunderstorm\n: Fortes pluies orageuses "
},
{
"path": "share/translations/he.txt",
"chars": 3691,
"preview": "113 : בהיר: Clear\n113 : שמשי: Sunny\n116 : "
},
{
"path": "share/translations/hi-help.txt",
"chars": 2597,
"preview": "उपयोग:\n\n $ curl wttr.in # वर्तमान स्थान के मौसम की जानकारी \n $ curl wttr.in/muc # म्यूनिख हवाई अड्डे"
},
{
"path": "share/translations/hi.txt",
"chars": 6389,
"preview": ": तेज बारिश और गरज के साथ ओलावृष्टि \t\t : Heavy rain and hail with thunderstorm\n: गरज के साथ तेज बारिश "
},
{
"path": "share/translations/hr.txt",
"chars": 21821,
"preview": " : Kiša : Rain :\n113 : Vedro "
},
{
"path": "share/translations/hu-help.txt",
"chars": 2545,
"preview": "Használat:\n\n $ curl wttr.in # jelenlegi tartózkodási hely\n $ curl wttr.in/muc # időjárás a müncheni "
},
{
"path": "share/translations/hu.txt",
"chars": 3336,
"preview": "113: Derült : Clear\n113: Napos : Sunny\n116: Közepesen felhős "
},
{
"path": "share/translations/hy.txt",
"chars": 4728,
"preview": "113: Պարզ : Clear : Ясно \n113: Արևոտ "
},
{
"path": "share/translations/ia-help.txt",
"chars": 2550,
"preview": "Instructiones:\n\n $ curl wttr.in # le tempore a vostre location actual\n $ curl wttr.in/muc # le tempo"
},
{
"path": "share/translations/ia.txt",
"chars": 2856,
"preview": "113: Clar : Clear\n113: Allegre : Sunny\n116: Pauc Nubilose "
},
{
"path": "share/translations/id-help.txt",
"chars": 2306,
"preview": "Cara penggunaan:\n\n $ curl wttr.in # lokasi saat ini\n $ curl wttr.in/muc # cuaca di bandara Munich\n\nD"
},
{
"path": "share/translations/id.txt",
"chars": 3046,
"preview": "113: Langit bersih : Clear \n113: Cerah : Sunny\n116: Sebagian b"
},
{
"path": "share/translations/is.txt",
"chars": 3327,
"preview": "113 : Heiðskýrt : Clear\n113 : Sól : Sunny\n116 "
},
{
"path": "share/translations/it-help.txt",
"chars": 2332,
"preview": "Istruzioni:\n\n\t$ curl wttr.in\t\t\t\t# Il tempo nella tua posizione attuale\n\t$ curl wttr.in/muc\t\t\t# Meteo all'aeroporto di Mo"
},
{
"path": "share/translations/it.txt",
"chars": 7335,
"preview": ": Pioggia intensa e grandine con temporali : Heavy rain and hail with thunderstorm\n: Pioggia"
},
{
"path": "share/translations/ja.txt",
"chars": 2668,
"preview": "113: 快晴 : Clear\n113: 晴れ : Sunny\n116: 所により曇り "
},
{
"path": "share/translations/kk-help.txt",
"chars": 2403,
"preview": "Использование:\n\n $ curl wttr.in # текущее местоположение\n $ curl wttr.in/svo # погода в аэропорту Ше"
},
{
"path": "share/translations/kk.txt",
"chars": 4746,
"preview": "113: Ашық : Clear : Ясно \n113: Шуақ кү"
},
{
"path": "share/translations/lt-help.txt",
"chars": 2595,
"preview": "Naudojimas:\n\n $ curl wttr.in # dabartinė vietovė\n $ curl wttr.in/plq # oras Palangos oro uoste\n\nPala"
},
{
"path": "share/translations/lt.txt",
"chars": 3995,
"preview": "113: Giedra : Clear :\n113: Saulėta "
},
{
"path": "share/translations/lv-help.txt",
"chars": 2744,
"preview": "Izmantošana:\n\n $ curl wttr.in # pašreizējā atrašanās vieta\n $ curl wttr.in/rix # laikapstākļi Rīgas "
},
{
"path": "share/translations/lv.txt",
"chars": 3475,
"preview": " : Tuvumā īslaicīgs lietus : Patchy rain nearby\n : Tuvumā pērkona negaiss "
},
{
"path": "share/translations/messages/en.yaml",
"chars": 1530,
"preview": "lang:\n name: English\n code: \"en\"\n issue: \"\"\n translators:\n - \"Igor Chubin @igor_chubin\"\n locale: \""
},
{
"path": "share/translations/messages/gu.yaml",
"chars": 1527,
"preview": "lang:\n name: Gujarati\n code: gu\n issue: \"774\"\n translators:\n - @wylited (on GitHub)\n locale: gu_IN"
},
{
"path": "share/translations/mg-help.txt",
"chars": 2786,
"preview": "Fampiasana azy:\n\n $ curl wttr.in # toetr'andro eo amin'ny toerana misy anao\n $ curl wttr.in/antanan"
},
{
"path": "share/translations/mg.txt",
"chars": 6561,
"preview": ": Oram-be manavandra sy oram-baratra : Heavy rain and hail with thunderstorm\n: Oram-be sy ora"
},
{
"path": "share/translations/mk.txt",
"chars": 2429,
"preview": "113: Ведро: Clear \n113: Сончево: Sunny\n116: Делумно Облачно: Partly cloudy \n119: Облачно: Cloudy \n122: Облачно: Overcast"
},
{
"path": "share/translations/mr-help.txt",
"chars": 2606,
"preview": "वापर:\n\n $ curl wttr.in # वर्तमान स्थळाचे हवामान\n $ curl wttr.in/muc # म्युनिक विमानतळावरील हवामान\n\nउ"
},
{
"path": "share/translations/mr.txt",
"chars": 4509,
"preview": " : मुसळधार पाऊस, गारा व झंझावात : Heavy rain and hail with thunderstorm\n : मुसळधार पाऊस व झंझावात : "
},
{
"path": "share/translations/nb-help.txt",
"chars": 2188,
"preview": "Bruk:\n\n $ curl wttr.in # nåværende lokasjon\n $ curl wttr.in/osl # været på Gardermoen flyplass\n\nStøt"
},
{
"path": "share/translations/nb.txt",
"chars": 10901,
"preview": "113: Sol : Sunny\n114: Klart : Clear\n116: Delvis skye"
},
{
"path": "share/translations/nl-help.txt",
"chars": 2600,
"preview": "Gebruik:\n\n $ curl wttr.in # huidige locatie\n $ curl wttr.in/muc # weer op de luchthaven van "
},
{
"path": "share/translations/nl.txt",
"chars": 5338,
"preview": ": Zware regen en hagel met onweer : Heavy rain and hail with thunderstorm\n: Zware regen met onweer "
},
{
"path": "share/translations/nn.txt",
"chars": 3045,
"preview": "113: Klart : Clear\n113: Sol : Sunny\n116: Til dels sk"
},
{
"path": "share/translations/oc-help.txt",
"chars": 2566,
"preview": "Usatge:\n\n $ curl wttr.in # emplaçament actual\n $ curl wttr.in/cdg # metèo a l'aeropòrt de Paris - Ch"
},
{
"path": "share/translations/oc.txt",
"chars": 3574,
"preview": ": Pluèja : Rain\n: Plugeta, Raissas : Light Rai"
},
{
"path": "share/translations/pl-help.txt",
"chars": 2580,
"preview": "Użycie:\n\n $ curl wttr.in # aktualna lokalizacja\n $ curl wttr.in/waw # wybrana lokalizacja (WAW - Lot"
},
{
"path": "share/translations/pl.txt",
"chars": 3891,
"preview": "113: Bezchmurnie : Clear\n113: Słonecznie "
},
{
"path": "share/translations/pt-br-help.txt",
"chars": 2906,
"preview": "Uso:\n\n $ curl wttr.in # apresenta o clima na sua localização atual\n $ curl wttr.in/muc # apresenta o"
},
{
"path": "share/translations/pt-br.txt",
"chars": 3626,
"preview": "113: Limpo : Clear\n113: Ensolarado : Sunny\n11"
},
{
"path": "share/translations/pt-help.txt",
"chars": 2885,
"preview": "Utilização:\n\n $ curl wttr.in # o clima na sua localização atual\n $ curl wttr.in/muc # o clima no aer"
},
{
"path": "share/translations/pt.txt",
"chars": 3722,
"preview": "113: Limpo : Clear\n113: Ensolarado : Sunny\n11"
},
{
"path": "share/translations/ro-help.txt",
"chars": 2373,
"preview": "Utilizare:\n\n $ curl wttr.in # localizare curentă\n $ curl wttr.in/otp # Aeroportul Internațional Henr"
},
{
"path": "share/translations/ro.txt",
"chars": 3468,
"preview": "113: Senin : Clear\n113: Însorit : Sunn"
},
{
"path": "share/translations/ru-help.txt",
"chars": 2403,
"preview": "Использование:\n\n $ curl wttr.in # текущее местоположение\n $ curl wttr.in/svo # погода в аэропорту Ше"
},
{
"path": "share/translations/ru.txt",
"chars": 29187,
"preview": " : Мгла в окрестностях : Haze in vicinity "
},
{
"path": "share/translations/sl.txt",
"chars": 3232,
"preview": "113: Jasno : Clear\n113: Sončno : Sunny\n116: Del"
},
{
"path": "share/translations/ta-help.txt",
"chars": 2600,
"preview": "பயன்பாடு:\n\n $ curl wttr.in # தற்போதைய இடம்\n $ curl wttr.in/cdg # பாரிஸ் - சார்லஸ் டி கோல் விமான நில"
},
{
"path": "share/translations/ta.txt",
"chars": 5751,
"preview": ": இடியுடன் கூடிய கனமழை மற்றும் ஆலங்கட்டி மழை : Heavy rain and hail with thunderstorm\n: இடியுடன் கூடிய கனமழை "
}
]
// ... and 22 more files (download for full content)
About this extraction
This page contains the full source code of the chubin/wttr.in GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 222 files (1.0 MB), approximately 352.5k tokens, and a symbol index with 378 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.