Compare commits
132
Commits
0.2.0
..
2e64224840
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e64224840 | ||
|
|
f35d3f8cb8 | ||
|
|
b0187506fd | ||
|
|
beafed8faf | ||
|
|
52315cc8bd | ||
|
|
80e9a80edd | ||
|
|
b3412d9e53 | ||
|
|
ba91d6d161 | ||
|
|
62efd54298 | ||
|
|
f3b831649e | ||
|
|
829a12612e | ||
|
|
73df7a2b5d | ||
|
|
3612658323 | ||
|
|
c0503458e4 | ||
|
|
0baa1277ef | ||
|
|
5d025998b9 | ||
|
|
a14f49742b | ||
|
|
6178c694f9 | ||
|
|
2e9d04c10e | ||
|
|
ece1a58a27 | ||
|
|
a12a81e26d | ||
|
|
d214849e2e | ||
|
|
9ae9d1be14 | ||
|
|
65b6adbf16 | ||
|
|
2500ba19bd | ||
|
|
f257caf42c | ||
|
|
80d5c62480 | ||
|
|
ff675bc04b | ||
|
|
d47dcf69e6 | ||
|
|
91a1e4498c | ||
|
|
e6e97ed209 | ||
|
|
126d5310f5 | ||
|
|
ee3003c09a | ||
|
|
b556f2f948 | ||
|
|
d9016a0cb0 | ||
|
|
41914d7f74 | ||
|
|
de466de805 | ||
|
|
1bffafd6a3 | ||
|
|
46e72feb82 | ||
|
|
fdf6e0eac3 | ||
|
|
60651de186 | ||
|
|
bed8d7af7b | ||
|
|
6683b7b5b0 | ||
|
|
fa0edca522 | ||
|
|
f76f33b501 | ||
|
|
a6f6586969 | ||
|
|
1835935eaf | ||
|
|
5db61da32b | ||
|
|
d44c8076b4 | ||
|
|
24214ebc3a | ||
|
|
b5a8a1536c | ||
|
|
4aafe720dd | ||
|
|
7c2436fb57 | ||
|
|
d6487facf4 | ||
|
|
86879a3219 | ||
|
|
aa0d5a7a41 | ||
|
|
bec48e3289 | ||
|
|
d8ee66480c | ||
|
|
9f5c43ce92 | ||
|
|
60c0a7a6fc | ||
|
|
50c4221b8d | ||
|
|
a92b63b855 | ||
|
|
712c7615a6 | ||
|
|
1b5d009cdf | ||
|
|
ca08bc282d | ||
|
|
a19de3e01d | ||
|
|
f79f754f1b | ||
|
|
555b7f4392 | ||
|
|
b412a9cee3 | ||
|
|
b0245a1714 | ||
|
|
91b530f84c | ||
|
|
2b1dfad929 | ||
|
|
1df83ec34d | ||
|
|
1c4dac74ca | ||
|
|
75343a21b9 | ||
|
|
83fabdd538 | ||
|
|
29364bb7d4 | ||
|
|
195bd76608 | ||
|
|
436c6c7364 | ||
|
|
0a734f9c43 | ||
|
|
6171ec8a26 | ||
|
|
72fd148a54 | ||
|
|
12774db70f | ||
|
|
0008e99c66 | ||
|
|
bad5dff2b2 | ||
|
|
e2af31b812 | ||
|
|
6c76dae760 | ||
|
|
28951df9b5 | ||
|
|
5aa290c772 | ||
|
|
13a70990e5 | ||
|
|
a3fab92e3d | ||
|
|
4fdb7e9717 | ||
|
|
1d13f6e05d | ||
|
|
fcb000afb0 | ||
|
|
0730b9f567 | ||
|
|
081aa8d17f | ||
|
|
29fe06462e | ||
|
|
8b20eb1220 | ||
|
|
5333690c47 | ||
|
|
d82fc4ea46 | ||
|
|
df2f1d0d81 | ||
|
|
9192ca3e4a | ||
|
|
e37ca6eeac | ||
|
|
657a62eab5 | ||
|
|
d8e2be013a | ||
|
|
a91ec12d22 | ||
|
|
9c862863e5 | ||
|
|
fc59e52337 | ||
|
|
3e93a41400 | ||
|
|
c2cd18386b | ||
|
|
2bca3dd75a | ||
|
|
469bd9bc1f | ||
|
|
027d1e2d55 | ||
|
|
7cf0012229 | ||
|
|
edc014d98c | ||
|
|
1813ec213b | ||
|
|
1b700086f2 | ||
|
|
02a6ffc931 | ||
|
|
2dfedfea57 | ||
|
|
8012d9b8ed | ||
|
|
3a6faa85be | ||
|
|
1af61aa3c7 | ||
|
|
315838604e | ||
|
|
f368e6717c | ||
|
|
91e2c9d123 | ||
|
|
91d9c37612 | ||
|
|
b5f2c272bb | ||
|
|
eec72c77ab | ||
|
|
160ec2b48b | ||
|
|
7c57f939c0 | ||
|
|
c233b020fc | ||
|
|
869a8ae79f |
+1
-1
@@ -10,4 +10,4 @@ max_line_length = 120
|
|||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
max_line_length = off
|
max_line_length = off
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = true
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
DOCKER_REPO=git.shmyga.ru
|
|
||||||
DOCKER_GROUP=infernalgames
|
|
||||||
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP"
|
|
||||||
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
|
|
||||||
DOCKER_PROJECTS=("gallery")
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DOCKER_REPO=git.shmyga.ru
|
||||||
|
DOCKER_GROUP=infernalgames
|
||||||
|
DOCKER_ROOT="${DOCKER_REPO}/${DOCKER_GROUP}"
|
||||||
|
DOCKER_TAG="${DOCKER_TAG:-latest}"
|
||||||
|
|
||||||
|
OPENWEATHER_KEY=""
|
||||||
|
GISMETEO_AUTH=""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: "Setup"
|
||||||
|
description: "Setup project"
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
default: "3.14"
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- id: setup-python
|
||||||
|
uses: actions/setup-python@v7
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- run: |
|
||||||
|
pip install project-manager \
|
||||||
|
--index-url https://git.shmyga.ru/api/packages/InfernalGames/pypi/simple/ \
|
||||||
|
--extra-index-url https://pypi.org/simple
|
||||||
|
|
||||||
|
- run: pm setup:config -vv
|
||||||
|
|
||||||
|
# setup python venv
|
||||||
|
- run: pip install poetry
|
||||||
|
|
||||||
|
- id: cache-venv
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: .venv
|
||||||
|
key: venv-${{ runner.os }}-python-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||||
|
|
||||||
|
- if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||||
|
run: pm setup:python -vv
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: ./.gitea/actions/setup
|
||||||
|
- run: pm lint -vv
|
||||||
|
- run: pm test -vv
|
||||||
|
- run: pm build:docker -vv
|
||||||
|
- uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: git.shmyga.ru
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- run: pm publish:docker -vv
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name: test
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: ./.gitea/actions/setup
|
||||||
|
- run: pm lint -vv
|
||||||
|
- run: pm test -vv
|
||||||
@@ -5,3 +5,5 @@
|
|||||||
#.vscode
|
#.vscode
|
||||||
static/node_modules
|
static/node_modules
|
||||||
static/dist
|
static/dist
|
||||||
|
.env
|
||||||
|
dist
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
modules = [".", "static"]
|
||||||
|
|
||||||
|
[presets.locales.tasks.build.locales]
|
||||||
|
priority = 1
|
||||||
|
conditions = ["{{ 'locales/ru/LC_MESSAGES' | file }}"]
|
||||||
|
cwd = "locales/ru/LC_MESSAGES"
|
||||||
|
commands = ["msgfmt messages.po"]
|
||||||
@@ -4,23 +4,12 @@ ignore=.venv
|
|||||||
[MESSAGES CONTROL]
|
[MESSAGES CONTROL]
|
||||||
disable=
|
disable=
|
||||||
missing-docstring,
|
missing-docstring,
|
||||||
too-few-public-methods,
|
|
||||||
too-many-instance-attributes,
|
|
||||||
too-many-arguments,
|
|
||||||
too-many-locals,
|
|
||||||
too-many-boolean-expressions,
|
|
||||||
too-many-public-methods,
|
|
||||||
no-else-return,
|
|
||||||
singleton-comparison,
|
|
||||||
unused-argument,
|
|
||||||
unspecified-encoding,
|
|
||||||
fixme,
|
fixme,
|
||||||
|
invalid-name,
|
||||||
duplicate-code,
|
duplicate-code,
|
||||||
|
too-few-public-methods,
|
||||||
extension-pkg-allow-list=
|
too-many-locals,
|
||||||
lxml,
|
no-else-return,
|
||||||
GeoIP,
|
|
||||||
pydantic
|
|
||||||
|
|
||||||
[MISCELLANEOUS]
|
[MISCELLANEOUS]
|
||||||
notes=FIXME,TODO
|
notes=FIXME,TODO
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"python-envs.pythonProjects": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
"envManager": "ms-python.python:poetry",
|
||||||
|
"packageManager": "ms-python.python:poetry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+24
-12
@@ -1,33 +1,45 @@
|
|||||||
FROM python:3.12 AS builder
|
FROM python:3.14 AS builder
|
||||||
ENV POETRY_HOME="/opt/poetry"
|
ENV POETRY_HOME="/opt/poetry"
|
||||||
ENV PATH="$POETRY_HOME/bin:$PATH"
|
ENV PATH="$POETRY_HOME/bin:$PATH"
|
||||||
|
RUN apt update && \
|
||||||
|
apt install -y gettext
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
COPY pyproject.toml poetry.lock README.md ./
|
COPY pyproject.toml poetry.lock README.md ./
|
||||||
RUN poetry config virtualenvs.in-project true
|
RUN poetry config virtualenvs.in-project true
|
||||||
RUN poetry install --with app --no-root
|
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
||||||
|
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
||||||
|
poetry install --extras app --no-root
|
||||||
|
COPY locales ./locales
|
||||||
|
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||||
|
|
||||||
FROM node:24 AS node-builder
|
FROM node:24 AS node-builder
|
||||||
ENV PATH=/app/node_modules/.bin:$PATH
|
ENV PATH=/app/node_modules/.bin:$PATH
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY static/package.json static/package-lock.json ./
|
COPY static/package.json static/package-lock.json ./
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
COPY static ./
|
COPY static ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH="/opt/playwright-browsers"
|
||||||
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
|
pip install playwright==1.62 && \
|
||||||
|
playwright install --with-deps chromium
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
WORKDIR /app
|
|
||||||
RUN apt update && \
|
|
||||||
apt install -y locales gettext && \
|
|
||||||
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
|
|
||||||
dpkg-reconfigure --frontend=noninteractive locales
|
|
||||||
ENV LANG=ru_RU.UTF-8
|
|
||||||
ENV LC_ALL=ru_RU.UTF-8
|
|
||||||
ENV TZ="Europe/Moscow"
|
ENV TZ="Europe/Moscow"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app ./
|
COPY --from=builder /app ./
|
||||||
COPY --from=node-builder /app/dist ./static/dist
|
COPY --from=node-builder /app/dist ./static/dist
|
||||||
|
|
||||||
COPY gallery gallery/
|
COPY gallery gallery/
|
||||||
RUN cd gallery/easel/route/view/locales/ru/LC_MESSAGES && msgfmt messages.po
|
# COPY --from=builder --parents locales/**/*.mo ./
|
||||||
|
COPY --from=builder /app/locales ./locales
|
||||||
|
|
||||||
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]
|
||||||
|
|||||||
@@ -1,9 +1,75 @@
|
|||||||
# API Gallery
|
# API Gallery
|
||||||
|
|
||||||
## View
|
Currently implemented APIs:
|
||||||
|
|
||||||
|
- Weather forecast
|
||||||
|
- TV program
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Swagger: https://api.shmyga.ru/docs
|
||||||
|
|
||||||
|
### Weather
|
||||||
|
|
||||||
|
#### Providers list
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/weather/providers
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Locations search
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/weather/{provider}/locations?query={query}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Weather forecast
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/weather/{provider}/{location_id}/day/{date}
|
||||||
|
https://api.shmyga.ru/api/weather/{provider}/{location_id}/days/{days_count}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TV program
|
||||||
|
|
||||||
|
#### Providers list
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/schedule/providers
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Channels search
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/schedule/{provider}/channels?query={query}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TV program
|
||||||
|
|
||||||
|
```
|
||||||
|
https://api.shmyga.ru/api/schedule/{provider}/{channel_id}/{date}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### Weather
|
||||||
|
|
||||||
|
| Provider | Site | API sample |
|
||||||
|
| :- | :- | :- |
|
||||||
|
| gismeteo | https://www.gismeteo.ru | https://api.shmyga.ru/api/weather/gismeteo/orel-4432/day/today |
|
||||||
|
| openweather | https://openweathermap.org | https://api.shmyga.ru/api/weather/openweather/52.9685433%3A36.0692477/day/today |
|
||||||
|
| yandex | https://yandex.ru/pogoda | https://api.shmyga.ru/api/weather/yandex/10/day/today |
|
||||||
|
| rp5 | https://rp5.ru | https://api.shmyga.ru/api/weather/rp5/Погода_в_Орле%2C_Орловская_область/day/today |
|
||||||
|
|
||||||
|
### TV program
|
||||||
|
|
||||||
|
| Provider | Site | API sample |
|
||||||
|
| :- | :- | :- |
|
||||||
|
| matchtv | https://matchtv.ru/tvguide | https://api.shmyga.ru/api/schedule/matchtv/igra/today |
|
||||||
|
| yandex | https://tv.yandex.ru | https://api.shmyga.ru/api/schedule/yandex/start-103/today |
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
https://api.shmyga.ru
|
https://api.shmyga.ru
|
||||||
|
|
||||||
## Swagger
|

|
||||||
|
|
||||||
https://api.shmyga.ru/docs
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
environment:
|
environment:
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
|
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||||
|
- GISMETEO_AUTH=$GISMETEO_AUTH
|
||||||
- DEBUG=1
|
- DEBUG=1
|
||||||
ports:
|
ports:
|
||||||
- 8000:80
|
- 8000:80
|
||||||
|
|||||||
+6
-4
@@ -1,17 +1,19 @@
|
|||||||
name: gallery
|
name: gallery-${DOCKER_TAG}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
container_name: gallery-redis
|
container_name: gallery-${DOCKER_TAG}-redis
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
stop_grace_period: 3s
|
stop_grace_period: 3s
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
app:
|
app:
|
||||||
container_name: gallery-app
|
container_name: gallery-${DOCKER_TAG}-app
|
||||||
image: ${DOCKER_ROOT}/gallery
|
image: ${DOCKER_ROOT}/gallery:${DOCKER_TAG}
|
||||||
environment:
|
environment:
|
||||||
- REDIS_HOST=redis
|
- REDIS_HOST=redis
|
||||||
|
- OPENWEATHER_KEY=$OPENWEATHER_KEY
|
||||||
|
- GISMETEO_AUTH=$GISMETEO_AUTH
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
@@ -8,6 +8,8 @@
|
|||||||
"python.testing.pytestArgs": ["tests", "-s"],
|
"python.testing.pytestArgs": ["tests", "-s"],
|
||||||
"python.testing.unittestEnabled": false,
|
"python.testing.unittestEnabled": false,
|
||||||
"python.testing.pytestEnabled": true,
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.terminal.useEnvFile": true,
|
||||||
|
"python.analysis.importFormat": "relative",
|
||||||
"python-envs.pythonProjects": [
|
"python-envs.pythonProjects": [
|
||||||
{
|
{
|
||||||
"path": ".",
|
"path": ".",
|
||||||
@@ -34,6 +36,8 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "uvicorn",
|
"module": "uvicorn",
|
||||||
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
|
"args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
|
||||||
|
"justMyCode": true,
|
||||||
|
"consoleTitle": "gallery:app",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "gallery:static",
|
"name": "gallery:static",
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"command": "npm run dev",
|
"command": "npm run dev",
|
||||||
|
"consoleTitle": "gallery:static",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ caches.set_config(
|
|||||||
{
|
{
|
||||||
"default": {
|
"default": {
|
||||||
"cache": "aiocache.SimpleMemoryCache",
|
"cache": "aiocache.SimpleMemoryCache",
|
||||||
"serializer": {"class": "aiocache.serializers.StringSerializer"},
|
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
||||||
},
|
},
|
||||||
"redis": {
|
"redis": {
|
||||||
"cache": "aiocache.RedisCache",
|
"cache": "aiocache.RedisCache",
|
||||||
|
|||||||
+11
-11
@@ -1,19 +1,14 @@
|
|||||||
import locale as _locale
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from gallery.sketch.bundle import ApiBundle
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.util import root_path
|
from gallery.util import root_path
|
||||||
|
|
||||||
from .route import api, doc
|
from .route import api, view
|
||||||
from .route.view import router as view_router
|
|
||||||
|
|
||||||
DEFAULT_LOCALE = "ru_RU.UTF-8"
|
|
||||||
|
|
||||||
|
|
||||||
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
|
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||||
_locale.setlocale(_locale.LC_TIME, locale)
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gallery",
|
title="Gallery",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
@@ -21,7 +16,12 @@ def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI
|
|||||||
)
|
)
|
||||||
app.state.api = api_bundle
|
app.state.api = api_bundle
|
||||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||||
doc.mount(app)
|
api.app.state.api = api_bundle
|
||||||
api.mount(app)
|
app.mount("/api", api.app)
|
||||||
app.include_router(view_router)
|
app.include_router(view.router)
|
||||||
|
|
||||||
|
@app.get("/docs")
|
||||||
|
def get_docs():
|
||||||
|
return RedirectResponse("/api/docs")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.sketch.api import API
|
||||||
|
|
||||||
|
|
||||||
|
def api_resolver(api_type: type[API]):
|
||||||
|
def get_api(request: AppRequest, provider: str) -> API:
|
||||||
|
providers = request.app.state.api.get_api_providers(api_type)
|
||||||
|
if provider not in providers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"provider": f"'{provider}' not in {providers}"},
|
||||||
|
)
|
||||||
|
return request.app.state.api.get_api(api_type, provider)
|
||||||
|
|
||||||
|
return get_api
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
|
||||||
|
|
||||||
|
def date_parser(date: str) -> datetime.date:
|
||||||
|
if date == "today":
|
||||||
|
return datetime.date.today()
|
||||||
|
elif date == "tomorrow":
|
||||||
|
return datetime.date.today() + datetime.timedelta(days=1)
|
||||||
|
elif date.startswith("day-"):
|
||||||
|
days = int(date.split("-")[-1])
|
||||||
|
return datetime.date.today() + datetime.timedelta(days=days)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return datetime.date.strptime(date, "%Y-%m-%d")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"date": f"{exc}"},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
DateDepends = Annotated[datetime.date, Depends(date_parser)]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from typing import Annotated, NamedTuple
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
|
|
||||||
|
from .api import api_resolver
|
||||||
|
|
||||||
|
ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDef(NamedTuple):
|
||||||
|
provider: str
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
GROUP_CANNELS_MAP: dict[str, list[ChannelDef]] = {
|
||||||
|
"central": [
|
||||||
|
ChannelDef("yandex", "pervyy-16"),
|
||||||
|
ChannelDef("yandex", "rossiya-1-31"),
|
||||||
|
ChannelDef("yandex", "ntv-11"),
|
||||||
|
ChannelDef("yandex", "sts-8"),
|
||||||
|
],
|
||||||
|
"sport": [
|
||||||
|
ChannelDef("matchtv", "matchtv"),
|
||||||
|
ChannelDef("matchtv", "igra"),
|
||||||
|
ChannelDef("matchtv", "arena"),
|
||||||
|
ChannelDef("matchtv", "futbol-1"),
|
||||||
|
ChannelDef("matchtv", "futbol-2"),
|
||||||
|
ChannelDef("matchtv", "futbol-3"),
|
||||||
|
ChannelDef("matchtv", "strana"),
|
||||||
|
ChannelDef("yandex", "start-103"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_group_channels(group_id: str) -> list[ChannelDef]:
|
||||||
|
return GROUP_CANNELS_MAP.get(group_id) or [ChannelDef(*item.split(":")) for item in group_id.split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
ScheduleGroupChannelsDepends = Annotated[list[ChannelDef], Depends(resolve_group_channels)]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
|
|
||||||
|
from .api import api_resolver
|
||||||
|
|
||||||
|
WeatherApiDepends = Annotated[WeatherApi, Depends(api_resolver(WeatherApi))]
|
||||||
@@ -1,8 +1,21 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from gallery.easel.route import doc
|
||||||
|
|
||||||
from . import schedule, weather
|
from . import schedule, weather
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
def mount(app: FastAPI):
|
docs_url=None,
|
||||||
weather.mount(app)
|
redoc_url=None,
|
||||||
schedule.mount(app)
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(weather.router)
|
||||||
|
app.include_router(schedule.router)
|
||||||
|
doc.mount(app, "/api")
|
||||||
|
|||||||
@@ -1,20 +1,44 @@
|
|||||||
import datetime
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.sketch.schedule.model import ChannelId, Schedule
|
from gallery.easel.depends.schedule import (
|
||||||
|
GROUP_CANNELS_MAP,
|
||||||
|
ScheduleApiDepends,
|
||||||
|
ScheduleGroupChannelsDepends,
|
||||||
|
)
|
||||||
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
|
from gallery.sketch.schedule.model import Channel, Schedule
|
||||||
|
|
||||||
|
from ...depends.date import DateDepends
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule", tags=["Schedule"])
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
@router.get("/groups")
|
||||||
@app.get("/api/schedule/channels", tags=["API"])
|
async def get_api_schedule_groups():
|
||||||
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
return list(GROUP_CANNELS_MAP.keys())
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
return await schedule_api.get_channels()
|
|
||||||
|
|
||||||
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
|
|
||||||
|
@router.get("/group/{group_id}/{date}")
|
||||||
|
async def get_api_schedule_group_schedule(
|
||||||
|
request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends
|
||||||
|
) -> list[Schedule]:
|
||||||
|
api = request.app.state.api
|
||||||
|
return [await api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date) for channel in channels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers")
|
||||||
|
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||||
|
return request.app.state.api.get_api_providers(ScheduleApi)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/channels")
|
||||||
|
async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: str) -> list[Channel]:
|
||||||
|
return await schedule_api.find_channels(query)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/{channel}/{date}")
|
||||||
async def get_api_schedule_channel_schedule(
|
async def get_api_schedule_channel_schedule(
|
||||||
request: AppRequest, channel: str, date: datetime.date
|
schedule_api: ScheduleApiDepends, channel: str, date: DateDepends
|
||||||
) -> Schedule:
|
) -> Schedule:
|
||||||
schedule_api = request.app.state.api.schedule
|
return await schedule_api.get_schedule(channel, date)
|
||||||
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import datetime
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.easel.depends.weather import WeatherApiDepends
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||||
|
|
||||||
|
from ...depends.date import DateDepends
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
router = APIRouter(prefix="/weather", tags=["Weather"])
|
||||||
@app.get("/api/weather/locations", tags=["API"])
|
|
||||||
async def get_api_weather_locations(
|
|
||||||
request: AppRequest, query: str
|
@router.get("/providers")
|
||||||
) -> list[Location]:
|
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||||
weather_api = request.app.state.api.weather
|
return request.app.state.api.get_api_providers(WeatherApi)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/locations")
|
||||||
|
async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str) -> list[Location]:
|
||||||
return await weather_api.find_locations(query)
|
return await weather_api.find_locations(query)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/day/{date}", tags=["API"])
|
|
||||||
async def get_api_weather_day(
|
@router.get("/{provider}/{location}/day/{date}")
|
||||||
request: AppRequest, location: str, date: datetime.date
|
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: DateDepends) -> WeatherResponse:
|
||||||
) -> WeatherResponse:
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
return await weather_api.get_day(location, date)
|
return await weather_api.get_day(location, date)
|
||||||
|
|
||||||
@app.get("/api/weather/{location}/days/{days}", tags=["API"])
|
|
||||||
async def get_api_weather_days(
|
@router.get("/{provider}/{location}/days/{days}")
|
||||||
request: AppRequest, location: str, days: int
|
async def get_api_weather_days(weather_api: WeatherApiDepends, location: str, days: int) -> WeatherResponse:
|
||||||
) -> WeatherResponse:
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
return await weather_api.get_days(location, days)
|
return await weather_api.get_days(location, days)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ from fastapi.openapi.docs import (
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
|
||||||
def mount(app: FastAPI):
|
def mount(app: FastAPI, prefix: str | None = None):
|
||||||
|
openapi_url = prefix + app.openapi_url if prefix else app.openapi_url
|
||||||
app.mount(
|
app.mount(
|
||||||
"/docs/static",
|
"/docs/static",
|
||||||
StaticFiles(directory=Path(__file__).parent / "static"),
|
StaticFiles(directory=Path(__file__).parent / "static"),
|
||||||
@@ -17,7 +18,7 @@ def mount(app: FastAPI):
|
|||||||
@app.get("/docs", include_in_schema=False)
|
@app.get("/docs", include_in_schema=False)
|
||||||
async def custom_swagger_ui_html():
|
async def custom_swagger_ui_html():
|
||||||
return get_swagger_ui_html(
|
return get_swagger_ui_html(
|
||||||
openapi_url=app.openapi_url,
|
openapi_url=openapi_url,
|
||||||
title=app.title + " - Swagger UI",
|
title=app.title + " - Swagger UI",
|
||||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||||
swagger_js_url="docs/static/swagger-ui-bundle.js",
|
swagger_js_url="docs/static/swagger-ui-bundle.js",
|
||||||
@@ -31,7 +32,7 @@ def mount(app: FastAPI):
|
|||||||
@app.get("/redoc", include_in_schema=False)
|
@app.get("/redoc", include_in_schema=False)
|
||||||
async def redoc_html():
|
async def redoc_html():
|
||||||
return get_redoc_html(
|
return get_redoc_html(
|
||||||
openapi_url=app.openapi_url,
|
openapi_url=openapi_url,
|
||||||
title=app.title + " - ReDoc",
|
title=app.title + " - ReDoc",
|
||||||
redoc_js_url="docs/static/redoc.standalone.js",
|
redoc_js_url="docs/static/redoc.standalone.js",
|
||||||
)
|
)
|
||||||
|
|||||||
+346
-290
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from .common import router as common_router
|
from .root import router as root_router
|
||||||
from .schedule import router as schedule_router
|
from .schedule import router as schedule_router
|
||||||
from .translation import set_language
|
from .translation import set_language
|
||||||
from .weather import router as weather_router
|
from .weather import router as weather_router
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(set_language)])
|
router = APIRouter(tags=["view"], dependencies=[Depends(set_language)], include_in_schema=False)
|
||||||
router.include_router(common_router)
|
router.include_router(root_router)
|
||||||
router.include_router(weather_router)
|
router.include_router(weather_router)
|
||||||
router.include_router(schedule_router)
|
router.include_router(schedule_router)
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from gallery.version import __version__
|
|
||||||
|
|
||||||
from ..translation import _
|
|
||||||
|
|
||||||
|
|
||||||
class Section(NamedTuple):
|
|
||||||
link: str
|
|
||||||
title: str
|
|
||||||
icon: str
|
|
||||||
|
|
||||||
|
|
||||||
SECTIONS = [
|
|
||||||
Section("weather", "Weather", "brightness-high"),
|
|
||||||
Section("schedule", "TV program", "tv"),
|
|
||||||
]
|
|
||||||
|
|
||||||
base_dir = Path(__file__).parent
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=base_dir / "templates")
|
|
||||||
templates.env.globals.update({"_": _})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def get_section_list(request: Request):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="root_index.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"sections": SECTIONS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -12,19 +12,22 @@
|
|||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="/static/gallery.css?v={{version}}">
|
href="/static/gallery.css?v={{version}}">
|
||||||
<script type="module"
|
<script type="module"
|
||||||
src="/static/gallery.es.js?v={{version}}"></script>
|
src="/static/gallery.js?v={{version}}"></script>
|
||||||
<link rel="icon"
|
<link rel="icon"
|
||||||
href="/favicon.ico?v={{version}}"
|
href="/favicon.ico?v={{version}}"
|
||||||
type="image/x-icon">
|
type="image/x-icon">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="{{ is_widget and 'widget' or ''}}">
|
||||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
||||||
<header class="d-flex align-items-center pb-3 mb-5 border-bottom">
|
{% if not is_widget %}
|
||||||
|
<header class="app-header pb-3 mb-5 border-bottom">
|
||||||
|
<div class="link-list">
|
||||||
<app-link href="/"
|
<app-link href="/"
|
||||||
icon="gear">API Gallery</app-link>
|
icon="gear">API Gallery</app-link>
|
||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
|
</div>
|
||||||
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
<ul class="navbar-nav flex-row flex-wrap ms-md-auto">
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
|
||||||
@@ -32,10 +35,10 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
aria-label="Select language (default)">
|
aria-label="{{_('Select language')}} (default)">
|
||||||
<span class="me-2 language-icon-active">🇬🇧</span>
|
<span class="fi fir fi-gb me-2 language-icon-active icon-header"></span>
|
||||||
<span class="d-lg-none ms-2"
|
<span class="d-lg-none ms-2"
|
||||||
id="bd-language-text">Select language</span>
|
id="bd-language-text">{{_("Select language")}}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end"
|
<ul class="dropdown-menu dropdown-menu-end"
|
||||||
aria-labelledby="bd-language-text">
|
aria-labelledby="bd-language-text">
|
||||||
@@ -44,8 +47,8 @@
|
|||||||
class="dropdown-item d-flex align-items-center"
|
class="dropdown-item d-flex align-items-center"
|
||||||
data-bs-language-value="en"
|
data-bs-language-value="en"
|
||||||
aria-pressed="false">
|
aria-pressed="false">
|
||||||
<span class="me-2 language-icon">🇬🇧</span>
|
<span class="fi fir fi-gb me-2 language-icon-active"></span>
|
||||||
English
|
{{_("English")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -53,8 +56,8 @@
|
|||||||
class="dropdown-item d-flex align-items-center"
|
class="dropdown-item d-flex align-items-center"
|
||||||
data-bs-language-value="ru"
|
data-bs-language-value="ru"
|
||||||
aria-pressed="false">
|
aria-pressed="false">
|
||||||
<span class="me-2 language-icon">🇷🇺</span>
|
<span class="fi fir fi-ru me-2 language-icon-active"></span>
|
||||||
Russian
|
{{_("Russian")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -66,9 +69,9 @@
|
|||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
aria-label="Toggle theme (auto)">
|
aria-label="Toggle theme (auto)">
|
||||||
<i class="bi me-2 opacity-50 theme-icon-active"></i>
|
<span class="bi bi-circle-half me-2 opacity-50 theme-icon-active icon-header"></span>
|
||||||
<span class="d-lg-none ms-2"
|
<span class="d-lg-none ms-2"
|
||||||
id="bd-theme-text">Toggle theme</span>
|
id="bd-theme-text">{{_("Toggle theme")}}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end"
|
<ul class="dropdown-menu dropdown-menu-end"
|
||||||
aria-labelledby="bd-theme-text">
|
aria-labelledby="bd-theme-text">
|
||||||
@@ -77,8 +80,8 @@
|
|||||||
class="dropdown-item d-flex align-items-center"
|
class="dropdown-item d-flex align-items-center"
|
||||||
data-bs-theme-value="light"
|
data-bs-theme-value="light"
|
||||||
aria-pressed="false">
|
aria-pressed="false">
|
||||||
<i class="bi bi-sun-fill me-2 opacity-50 theme-icon"></i>
|
<span class="bi bi-sun-fill me-2 opacity-50 theme-icon"></span>
|
||||||
Light
|
{{_("Light")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -86,8 +89,8 @@
|
|||||||
class="dropdown-item d-flex align-items-center"
|
class="dropdown-item d-flex align-items-center"
|
||||||
data-bs-theme-value="dark"
|
data-bs-theme-value="dark"
|
||||||
aria-pressed="false">
|
aria-pressed="false">
|
||||||
<i class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></i>
|
<span class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></span>
|
||||||
Dark
|
{{_("Dark")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -95,30 +98,27 @@
|
|||||||
class="dropdown-item d-flex align-items-center active"
|
class="dropdown-item d-flex align-items-center active"
|
||||||
data-bs-theme-value="auto"
|
data-bs-theme-value="auto"
|
||||||
aria-pressed="true">
|
aria-pressed="true">
|
||||||
<i class="bi bi-circle-half me-2 opacity-50 theme-icon"></i>
|
<span class="bi bi-circle-half me-2 opacity-50 theme-icon"></span>
|
||||||
Auto
|
{{_("Auto")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
{% endif %}
|
||||||
<main>
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
<footer class="pt-5 my-5 text-muted border-top">
|
{% if not is_widget %}
|
||||||
Created by shmyga · © 2026
|
<footer class="app-footer pt-5 my-5 text-muted border-top">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<span>Created by shmyga · © 2026</span>
|
||||||
|
<span>v{{ version }}</span>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const widget = params.get('widget') || window.location.hostname.startsWith('weather');
|
|
||||||
if (widget) {
|
|
||||||
document.body.classList.add('widget');
|
|
||||||
}
|
|
||||||
}());
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Index{% endblock %}
|
|
||||||
{% block head %}
|
|
||||||
{{ super() }}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>View</h1>
|
|
||||||
<div class="list-group mb-5">
|
|
||||||
{% for section in sections %}
|
|
||||||
<a href="{{section.link}}"
|
|
||||||
class="list-group-item list-group-item-action px-4">
|
|
||||||
<app-link href="{{section.link}}"
|
|
||||||
icon="{{section.icon}}">
|
|
||||||
{{_(section.title)}}
|
|
||||||
</app-link>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<hr class="col-3 col-md-2 mb-5">
|
|
||||||
<h1>Docs</h1>
|
|
||||||
<a href="/docs"
|
|
||||||
target="_blank">
|
|
||||||
<h4>Swagger</h4>
|
|
||||||
</a>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
|
|
||||||
class TagType(str, Enum):
|
|
||||||
DAY = "day"
|
|
||||||
DAYS = "days"
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(NamedTuple):
|
|
||||||
type: TagType
|
|
||||||
date: datetime.date
|
|
||||||
days: int = 1
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.type == TagType.DAY:
|
|
||||||
today = datetime.date.today()
|
|
||||||
day = (self.date - today).days
|
|
||||||
return f"day-{day}"
|
|
||||||
elif self.type == TagType.DAYS:
|
|
||||||
return f"days-{self.days}"
|
|
||||||
else:
|
|
||||||
raise ValueError(self.type)
|
|
||||||
|
|
||||||
|
|
||||||
class TagUtil:
|
|
||||||
@classmethod
|
|
||||||
def parse_tag(cls, tag: str) -> Tag:
|
|
||||||
if tag == "today":
|
|
||||||
return Tag(TagType.DAY, datetime.date.today())
|
|
||||||
elif tag == "tomorrow":
|
|
||||||
return Tag(TagType.DAY, datetime.date.today() + datetime.timedelta(days=1))
|
|
||||||
elif tag.startswith("day-"):
|
|
||||||
days = int(tag.split("-")[-1])
|
|
||||||
return Tag(
|
|
||||||
TagType.DAY,
|
|
||||||
datetime.date.today() + datetime.timedelta(days=days),
|
|
||||||
)
|
|
||||||
elif tag.startswith("days-"):
|
|
||||||
days = int(tag.split("-")[-1])
|
|
||||||
return Tag(TagType.DAYS, datetime.date.today(), days)
|
|
||||||
raise ValueError(tag)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create_tag(
|
|
||||||
cls,
|
|
||||||
tag_type: TagType,
|
|
||||||
date: datetime.date,
|
|
||||||
day: int = 0,
|
|
||||||
days: int = 10,
|
|
||||||
) -> Tag:
|
|
||||||
if tag_type == TagType.DAY:
|
|
||||||
return Tag(tag_type, date + datetime.timedelta(days=day))
|
|
||||||
if tag_type == TagType.DAYS:
|
|
||||||
return Tag(tag_type, date, days)
|
|
||||||
raise ValueError(tag_type)
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import datetime
|
||||||
|
import typing
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||||
|
|
||||||
|
from babel.dates import format_date
|
||||||
|
from fastapi import Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.version import __version__
|
||||||
|
|
||||||
|
from ...translation import _
|
||||||
|
|
||||||
|
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def is_widget(request: Request) -> bool:
|
||||||
|
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
|
||||||
|
request.query_params.get("widget") is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def base_context_processor(request: Request) -> dict:
|
||||||
|
return {
|
||||||
|
"is_widget": is_widget(request),
|
||||||
|
"provider": request.query_params.get("provider") or request.path_params.get("provider"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_url(
|
||||||
|
request: Request, *, path: list[str] | None = None, up: int | None = None, query: dict | None = None
|
||||||
|
) -> str:
|
||||||
|
parts = urlsplit(str(request.url))
|
||||||
|
if up:
|
||||||
|
path = [".."] * up
|
||||||
|
if path:
|
||||||
|
path_parts = parts.path.split("/")
|
||||||
|
for item in path:
|
||||||
|
if item == "..":
|
||||||
|
path_parts = path_parts[:-1]
|
||||||
|
else:
|
||||||
|
path_parts.append(str(item))
|
||||||
|
parts = parts._replace(path="/".join(path_parts))
|
||||||
|
if query:
|
||||||
|
query_dict = {**dict(parse_qsl(parts.query)), **query}
|
||||||
|
parts = parts._replace(query=urlencode(query_dict))
|
||||||
|
return "/".join([""] + parts.geturl().split("/")[3:])
|
||||||
|
|
||||||
|
|
||||||
|
def build_templates(
|
||||||
|
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
|
||||||
|
) -> Jinja2Templates:
|
||||||
|
directory = [Path(__file__).parent.parent / "templates"]
|
||||||
|
if templates_dir:
|
||||||
|
directory.append(templates_dir)
|
||||||
|
context_processors: list[ContextProcessor] = [base_context_processor]
|
||||||
|
if context_processor:
|
||||||
|
context_processors.append(context_processor)
|
||||||
|
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
|
||||||
|
templates.env.globals.update(
|
||||||
|
{
|
||||||
|
"_": _,
|
||||||
|
"version": __version__,
|
||||||
|
"format_date": format_date,
|
||||||
|
"datetime": datetime,
|
||||||
|
"DATE_FORMAT": "E, d MMMM Y",
|
||||||
|
"build_url": build_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if filters:
|
||||||
|
templates.env.filters.update(filters)
|
||||||
|
return templates
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Project-Id-Version: Gallery\n"
|
|
||||||
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
|
|
||||||
"Language: ru\n"
|
|
||||||
"MIME-Version: 1.0\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
|
||||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
|
||||||
|
|
||||||
msgid "Weather"
|
|
||||||
msgstr "Погода"
|
|
||||||
|
|
||||||
msgid "TV program"
|
|
||||||
msgstr "Телепрограмма"
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from ..common.utils.template import build_templates
|
||||||
|
|
||||||
|
|
||||||
|
class Section(NamedTuple):
|
||||||
|
link: str
|
||||||
|
title: str
|
||||||
|
icon: str
|
||||||
|
|
||||||
|
|
||||||
|
SECTIONS = [
|
||||||
|
Section("weather", "Weather", "brightness-high"),
|
||||||
|
Section("schedule", "TV program", "tv"),
|
||||||
|
]
|
||||||
|
|
||||||
|
base_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
templates = build_templates(Path(__file__).parent / "templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def get_section_list(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="root_index.html",
|
||||||
|
context={
|
||||||
|
"sections": SECTIONS,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{_("Index")}}{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
{{ super() }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{_("View")}}</h1>
|
||||||
|
<div class="list-group mb-3">
|
||||||
|
{% for section in sections %}
|
||||||
|
<app-link href="{{section.link}}"
|
||||||
|
icon="{{section.icon}}"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
{{_(section.title)}}
|
||||||
|
</app-link>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<h1>{{_("Docs")}}</h1>
|
||||||
|
<div class="list-group mb-3">
|
||||||
|
<app-link href="/api/docs"
|
||||||
|
target="_blank"
|
||||||
|
icon="file-earmark-text"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
Swagger UI
|
||||||
|
</app-link>
|
||||||
|
<app-link href="/api/redoc"
|
||||||
|
target="_blank"
|
||||||
|
icon="file-earmark-text"
|
||||||
|
class="list-group-item list-group-item-action">
|
||||||
|
ReDoc
|
||||||
|
</app-link>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,85 +1 @@
|
|||||||
import datetime
|
from .route import router
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
|
||||||
from gallery.sketch.schedule.catalog import BUNDLE
|
|
||||||
from gallery.version import __version__
|
|
||||||
|
|
||||||
from ..common.util import TagType, TagUtil
|
|
||||||
from ..translation import _
|
|
||||||
from .filters import timedelta_format
|
|
||||||
|
|
||||||
base_dir = Path(__file__).parent
|
|
||||||
templates = Jinja2Templates(
|
|
||||||
directory=[
|
|
||||||
base_dir.parent / "common/templates",
|
|
||||||
base_dir / "templates",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
templates.env.globals.update({"_": _})
|
|
||||||
templates.env.filters["timedelta_format"] = timedelta_format
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule", response_class=HTMLResponse)
|
|
||||||
async def get_schedule_list(request: AppRequest):
|
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
channels = await schedule_api.get_channels()
|
|
||||||
channels_data = BUNDLE.select_items(channels)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="index.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"channels": channels_data,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
|
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
results = await schedule_api.get_all_schedules(tag_value.date)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="schedule.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
|
||||||
"datetime": datetime,
|
|
||||||
"response": results[0],
|
|
||||||
"responses": results,
|
|
||||||
"live": live,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/{channel}", response_class=RedirectResponse)
|
|
||||||
async def get_channel_default(channel: str):
|
|
||||||
return RedirectResponse(f"{channel}/tag/today")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
|
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
|
||||||
schedule_api = request.app.state.api.schedule
|
|
||||||
if tag_value.type == TagType.DAY:
|
|
||||||
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
|
|
||||||
else:
|
|
||||||
raise ValueError(tag)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="channel.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
|
||||||
"datetime": datetime,
|
|
||||||
"response": response,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib import parse
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.easel.depends.api import api_resolver
|
||||||
|
from gallery.easel.depends.date import DateDepends
|
||||||
|
from gallery.easel.depends.schedule import (
|
||||||
|
GROUP_CANNELS_MAP,
|
||||||
|
ScheduleApiDepends,
|
||||||
|
ScheduleGroupChannelsDepends,
|
||||||
|
)
|
||||||
|
from gallery.sketch.bundle import ApiBundle
|
||||||
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
|
from gallery.sketch.schedule.model import Schedule
|
||||||
|
|
||||||
|
from ..common.utils.template import build_templates
|
||||||
|
from .filters import timedelta_format
|
||||||
|
|
||||||
|
|
||||||
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
|
return {
|
||||||
|
"providers": request.app.state.api.get_api_providers(ScheduleApi),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templates = build_templates(
|
||||||
|
Path(__file__).parent / "templates",
|
||||||
|
{
|
||||||
|
"timedelta_format": timedelta_format,
|
||||||
|
},
|
||||||
|
context_procesor,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def get_schedule_index(request: AppRequest, provider: str | None = None, query: str | None = None):
|
||||||
|
if query and provider:
|
||||||
|
schedule_api = api_resolver(ScheduleApi)(request, provider)
|
||||||
|
channels = await schedule_api.find_channels(query)
|
||||||
|
else:
|
||||||
|
channels = []
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="index.html",
|
||||||
|
context={"channels": [item.model_dump() for item in channels], "groups": GROUP_CANNELS_MAP.keys()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_group_schedule(api: ApiBundle, channels: list[tuple[str, str]], date: datetime.date) -> list[Schedule]:
|
||||||
|
return [
|
||||||
|
await api.get_api(ScheduleApi, provider).get_schedule(channel_id, date) for provider, channel_id in channels
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/group/{group_id}", response_class=RedirectResponse)
|
||||||
|
async def get_group_default(group_id: str):
|
||||||
|
return RedirectResponse(f"{parse.quote(group_id)}/today")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/group/{group_id}/{date}", response_class=HTMLResponse)
|
||||||
|
async def get_group(request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends):
|
||||||
|
responses = [
|
||||||
|
await request.app.state.api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date)
|
||||||
|
for channel in channels
|
||||||
|
]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="group.html",
|
||||||
|
context={"responses": responses},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
|
||||||
|
async def get_channel_default(channel: str):
|
||||||
|
return RedirectResponse(f"{channel}/today")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/{channel}/{date}", response_class=HTMLResponse)
|
||||||
|
async def get_channel(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, date: DateDepends):
|
||||||
|
response = await schedule_api.get_schedule(channel, date)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="channel.html",
|
||||||
|
context={"response": response},
|
||||||
|
)
|
||||||
@@ -1,37 +1,51 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% set live = request.query_params.live == '1' %}
|
||||||
|
{% set date = response.date %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
|
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
|
||||||
<app-link href="/schedule"
|
<app-link href="/schedule"
|
||||||
icon="tv">{{_("TV program")}}</app-link>
|
icon="tv">{{_("TV program")}}</app-link>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4 class="mb-4">
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
|
||||||
<a class="button"
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
href="../..">⬆️</a>
|
</a>
|
||||||
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="{{build_url(request, up=3)}}">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
|
</a>
|
||||||
|
<a class="icon-link {{'text-secondary' if not live else ''}}"
|
||||||
|
href="{{build_url(request, query={'live': 0 if live else 1})}}">
|
||||||
|
<i class="bi bi-cast"></i>
|
||||||
|
</a>
|
||||||
|
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
|
||||||
|
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
|
||||||
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
|
</a>
|
||||||
</h4>
|
</h4>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td style="width:100%"></td>
|
||||||
<td></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<tr class="{{'table-success' if value.live else ''}}">
|
<tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
|
||||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
<td>
|
||||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
<span>{{value.start.strftime('%H:%M')}}</span> <span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
|
||||||
|
</td>
|
||||||
<td>{{value.label}}</td>
|
<td>{{value.label}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% set live = request.query_params.live == '1' %}
|
||||||
|
{% set date = responses[0].date %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{_("TV program")}}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<app-link href="/schedule"
|
||||||
|
icon="tv">{{_("TV program")}}</app-link>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h4 class="mb-4">
|
||||||
|
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
|
||||||
|
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
|
||||||
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
|
</a>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="{{build_url(request, up=3)}}">
|
||||||
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
|
</a>
|
||||||
|
<a class="icon-link {{'text-secondary' if not live else ''}}"
|
||||||
|
href="{{build_url(request, query={'live': 0 if live else 1})}}">
|
||||||
|
<i class="bi bi-cast"></i>
|
||||||
|
</a>
|
||||||
|
<span>{{format_date(date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
|
||||||
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
|
</a>
|
||||||
|
</h4>
|
||||||
|
{% for response in responses %}
|
||||||
|
<h5>
|
||||||
|
<span class="badge rounded-pill text-bg-secondary fs-6">{{response.channel.provider}}</span>
|
||||||
|
<span>{{response.channel.name}}</span>
|
||||||
|
</h5>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td style="width:100%"></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for value in response.values %}
|
||||||
|
<tr class="{{'d-none' if (live and not value.live) else 'table-success' if (not live and value.live) else ''}}">
|
||||||
|
<td>
|
||||||
|
<span>{{value.start.strftime('%H:%M')}}</span> <span class="small ms-1">({{(value.end - value.start) |
|
||||||
|
timedelta_format}})</span>
|
||||||
|
</td>
|
||||||
|
<td>{{value.label}}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
@@ -3,16 +3,68 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{_("TV program")}}</h1>
|
<h1>{{_("TV program")}}</h1>
|
||||||
<div class="list-group mb-5">
|
<form action=""
|
||||||
<a href="schedule/tag/today"
|
method="get"
|
||||||
class="list-group-item list-group-item-action px-4">
|
class="mb-4">
|
||||||
<span class="fw-bold">Все</span>
|
<div class="input-group mb-3">
|
||||||
</a>
|
<input type="text"
|
||||||
{% for channel in channels %}
|
class="form-control"
|
||||||
<a href="schedule/{{channel.id}}"
|
id="query"
|
||||||
class="list-group-item list-group-item-action px-4">
|
name="query"
|
||||||
<span class="text-primary">{{channel.name}}</span>
|
placeholder="{{_('Enter the channel name')}}">
|
||||||
</a>
|
<input type="hidden"
|
||||||
|
class="form-control"
|
||||||
|
id="provider"
|
||||||
|
name="provider"
|
||||||
|
value="{{provider or providers[0]}}">
|
||||||
|
<button id="providerBtn"
|
||||||
|
class="btn btn-secondary dropdown-toggle"
|
||||||
|
type="text"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
{% for provider in providers %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item"
|
||||||
|
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
type="submit">{{_("Search")}}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if channels %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<schedule-channel-list value="{{channels | tojson | forceescape}}"
|
||||||
|
onclick="scheduleChannelManager.store(element)"
|
||||||
|
onadd="scheduleChannelManager.add(element)"></schedule-channel-list>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3>{{_("Stored channels")}}</h3>
|
||||||
|
<schedule-channel-list id="storedChannels"
|
||||||
|
onremove="scheduleChannelManager.remove(element)"></schedule-channel-list>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3>{{_("Channel groups")}}</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for group in groups %}
|
||||||
|
<schedule-channel-group value="{{group | tojson | forceescape}}"></schedule-channel-group>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const searchQuery = params.get('query');
|
||||||
|
if (searchQuery) {
|
||||||
|
document.querySelector('#query').value = searchQuery;
|
||||||
|
}
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
scheduleChannelManager.load();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
{{'Прямые трансляции' if live else _("TV program")}} | {{response.date.strftime('%a, %d %B %Y')}}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
|
||||||
<app-link href="/schedule"
|
|
||||||
icon="tv">{{_("TV program")}}</app-link>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h4>
|
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
|
||||||
<a class="button"
|
|
||||||
href="..">⬆️</a>
|
|
||||||
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
|
||||||
<a class="button"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
|
||||||
</h4>
|
|
||||||
<div>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for response in responses %}
|
|
||||||
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
|
|
||||||
{% if values|length > 0 %}
|
|
||||||
<tr class="table-primary fs-4">
|
|
||||||
<td colspan="3">
|
|
||||||
<div>{{response.channel.name}}</div>
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
{% for value in values %}
|
|
||||||
<tr class="{{'table-success' if not live and value.live else ''}}">
|
|
||||||
<td>{{value.start.strftime('%H:%M')}}</td>
|
|
||||||
<td>{{(value.end - value.start) | timedelta_format}}</td>
|
|
||||||
<td>{{value.label}}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import gettext
|
import gettext
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import Cookie, Header, Request
|
from fastapi import Cookie, Header, Request
|
||||||
|
|
||||||
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = (
|
from gallery.util import root_path
|
||||||
ContextVar("translation")
|
|
||||||
)
|
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = ContextVar("translation")
|
||||||
|
|
||||||
|
|
||||||
async def set_language(
|
async def set_language(
|
||||||
@@ -18,9 +17,7 @@ async def set_language(
|
|||||||
lang = language or accept_language.split(",")[0].split("-")[0]
|
lang = language or accept_language.split(",")[0].split("-")[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t = gettext.translation(
|
t = gettext.translation("messages", localedir=root_path / "locales", languages=[lang])
|
||||||
"messages", localedir=Path(__file__).parent / "locales", languages=[lang]
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
t = gettext.NullTranslations()
|
t = gettext.NullTranslations()
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +1 @@
|
|||||||
import datetime
|
from .route import router
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, FastAPI
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
|
||||||
from gallery.version import __version__
|
|
||||||
|
|
||||||
from ..common.util import TagType, TagUtil
|
|
||||||
from ..translation import _
|
|
||||||
from .filters import cloudness_icon, wind_direction_icon
|
|
||||||
|
|
||||||
|
|
||||||
base_dir = Path(__file__).parent
|
|
||||||
templates = Jinja2Templates(
|
|
||||||
directory=[
|
|
||||||
base_dir.parent / "common/templates",
|
|
||||||
base_dir / "templates",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
templates.env.globals.update({"_": _})
|
|
||||||
templates.env.filters["wind_direction_icon"] = wind_direction_icon
|
|
||||||
templates.env.filters["cloudness_icon"] = cloudness_icon
|
|
||||||
|
|
||||||
|
|
||||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="weather.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"tag_util": TagUtil,
|
|
||||||
"datetime": datetime,
|
|
||||||
"response": response,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/weather", response_class=HTMLResponse)
|
|
||||||
async def get_weather_index(request: AppRequest, query: str | None = None):
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
locations = (await weather_api.find_locations(query)) if query else []
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="index.html",
|
|
||||||
context={
|
|
||||||
"version": __version__,
|
|
||||||
"locations": locations,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}", response_class=RedirectResponse)
|
|
||||||
async def get_weather_default(location: str):
|
|
||||||
return RedirectResponse(f"{location}/tag/today")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
response = await weather_api.get_day(location, date)
|
|
||||||
return build_weather_response(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_days(request: AppRequest, location: str, days: int):
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
response = await weather_api.get_days(location, days)
|
|
||||||
return build_weather_response(request, response)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
|
|
||||||
async def get_weather_tag(request: AppRequest, location: str, tag: str):
|
|
||||||
tag_value = TagUtil.parse_tag(tag)
|
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
if tag_value.type == TagType.DAY:
|
|
||||||
response = await weather_api.get_day(location, tag_value.date)
|
|
||||||
elif tag_value.type == TagType.DAYS:
|
|
||||||
response = await weather_api.get_days(location, tag_value.days)
|
|
||||||
else:
|
|
||||||
raise ValueError(tag)
|
|
||||||
return build_weather_response(request, response)
|
|
||||||
|
|||||||
@@ -1,48 +1,79 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from markupsafe import Markup
|
||||||
|
|
||||||
from gallery.sketch.weather.model import (
|
from gallery.sketch.weather.model import (
|
||||||
Cloudness,
|
Cloudness,
|
||||||
Precipitation,
|
Precipitation,
|
||||||
Sky,
|
Sky,
|
||||||
WindDirection,
|
WindDirection,
|
||||||
WindDirectionDeg,
|
|
||||||
)
|
)
|
||||||
|
from gallery.util import root_path
|
||||||
|
|
||||||
|
|
||||||
def wind_direction_icon(wind_direction_deg: float) -> str:
|
def wind_direction_icon(wind_direction: WindDirection | None) -> str:
|
||||||
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
if wind_direction is None:
|
||||||
return {
|
return "wind-calm"
|
||||||
WindDirection.N: "⬇️",
|
else:
|
||||||
WindDirection.NE: "↙️",
|
return f"wind-from-{wind_direction.lower()}"
|
||||||
WindDirection.E: "⬅️",
|
|
||||||
WindDirection.SE: "↖️",
|
|
||||||
WindDirection.S: "⬆️",
|
|
||||||
WindDirection.SW: "↗️",
|
|
||||||
WindDirection.W: "➡️",
|
|
||||||
WindDirection.NW: "↘️",
|
|
||||||
WindDirection.CALM: "",
|
|
||||||
}.get(wind_direction, wind_direction)
|
|
||||||
|
|
||||||
|
|
||||||
def cloudness_icon(sky: Sky) -> list[str]:
|
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
||||||
|
day = (3 < date.hour < 22) if period == "day" else True
|
||||||
|
day_prefix = "day" if day else "night-alt"
|
||||||
main_icon = ""
|
main_icon = ""
|
||||||
if sky.thunder:
|
if sky.thunder:
|
||||||
if sky.cloudness == Cloudness.CLEAR:
|
main_icon = {
|
||||||
main_icon = "🌩️"
|
Precipitation.NO: "lightning",
|
||||||
if sky.precipitation == Precipitation.NO:
|
Precipitation.SMALL_RAIN: "storm-showers",
|
||||||
main_icon = "⚡"
|
Precipitation.RAIN: "thunderstorm",
|
||||||
else:
|
Precipitation.HEAVY_RAIN: "thunderstorm",
|
||||||
main_icon = "⛈️"
|
Precipitation.SHOWER: "thunderstorm",
|
||||||
|
Precipitation.SNOW: "storm-showers",
|
||||||
|
Precipitation.HEAVY_SNOW: "storm-showers",
|
||||||
|
}[sky.precipitation]
|
||||||
|
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||||
|
main_icon = f"{day_prefix}-{main_icon}"
|
||||||
elif sky.precipitation == Precipitation.NO:
|
elif sky.precipitation == Precipitation.NO:
|
||||||
main_icon = {
|
main_icon = {
|
||||||
Cloudness.CLEAR: "☀️",
|
Cloudness.CLEAR: "day-sunny" if day else "night-clear",
|
||||||
Cloudness.PARTLY_CLOUDY: "🌤️",
|
Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
|
||||||
Cloudness.CLOUDY: "⛅",
|
Cloudness.CLOUDY: "cloud",
|
||||||
Cloudness.MAINLY_CLOUDY: "☁️",
|
Cloudness.MAINLY_CLOUDY: "cloudy",
|
||||||
}[sky.cloudness]
|
}[sky.cloudness]
|
||||||
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
|
|
||||||
main_icon = "🌨️"
|
|
||||||
else:
|
else:
|
||||||
main_icon = "🌧️"
|
main_icon = {
|
||||||
|
Precipitation.SMALL_RAIN: "showers",
|
||||||
|
Precipitation.RAIN: "rain-mix",
|
||||||
|
Precipitation.HEAVY_RAIN: "rain",
|
||||||
|
Precipitation.SHOWER: "rain",
|
||||||
|
Precipitation.SNOW: "snow",
|
||||||
|
Precipitation.HEAVY_SNOW: "snow",
|
||||||
|
Precipitation.HAIL: "hail",
|
||||||
|
}[sky.precipitation]
|
||||||
|
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||||
|
main_icon = f"{day_prefix}-{main_icon}"
|
||||||
icons = [main_icon]
|
icons = [main_icon]
|
||||||
if sky.fog:
|
|
||||||
icons.append("🌫️")
|
|
||||||
return icons
|
return icons
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherIconBundle:
|
||||||
|
def __init__(self):
|
||||||
|
self._icon_path = root_path / "static/dist/weather-icons/icons"
|
||||||
|
self._cache = {}
|
||||||
|
|
||||||
|
def _load_icon(self, icon: str) -> Markup:
|
||||||
|
icon_file = self._icon_path / f"wi-{icon}.svg"
|
||||||
|
return Markup(icon_file.read_text())
|
||||||
|
|
||||||
|
def get(self, icon: str) -> Markup:
|
||||||
|
if icon not in self._cache:
|
||||||
|
self._cache[icon] = self._load_icon(icon)
|
||||||
|
return self._cache[icon]
|
||||||
|
|
||||||
|
|
||||||
|
WEATHER_ICON_BUNDLE = WeatherIconBundle()
|
||||||
|
|
||||||
|
|
||||||
|
def weather_icon_svg(icon: str) -> Markup:
|
||||||
|
return WEATHER_ICON_BUNDLE.get(icon)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from starlette.datastructures import URL
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
|
from gallery.easel.depends.api import api_resolver
|
||||||
|
from gallery.easel.depends.date import DateDepends
|
||||||
|
from gallery.easel.depends.weather import WeatherApiDepends
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
|
from gallery.sketch.weather.model import WeatherResponse
|
||||||
|
|
||||||
|
from ..common.utils.template import build_templates
|
||||||
|
from .filters import cloudness_icon, weather_icon_svg, wind_direction_icon
|
||||||
|
|
||||||
|
|
||||||
|
def context_procesor(request: AppRequest) -> dict:
|
||||||
|
return {
|
||||||
|
"providers": request.app.state.api.get_api_providers(WeatherApi),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
templates = build_templates(
|
||||||
|
Path(__file__).parent / "templates",
|
||||||
|
{
|
||||||
|
"wind_direction_icon": wind_direction_icon,
|
||||||
|
"cloudness_icon": cloudness_icon,
|
||||||
|
"weather_icon_svg": weather_icon_svg,
|
||||||
|
},
|
||||||
|
context_procesor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="weather.html",
|
||||||
|
context={"response": response},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/weather")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def get_weather_index(request: AppRequest, provider: str | None = None, query: str | None = None):
|
||||||
|
if query and provider:
|
||||||
|
weather_api = api_resolver(WeatherApi)(request, provider)
|
||||||
|
locations = await weather_api.find_locations(query)
|
||||||
|
else:
|
||||||
|
locations = []
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="index.html",
|
||||||
|
context={"locations": [item.model_dump() for item in locations]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/{location}", response_class=RedirectResponse)
|
||||||
|
async def get_weather(location: str):
|
||||||
|
return RedirectResponse(f"{location}/day/today")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
|
||||||
|
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
|
||||||
|
response = await weather_api.get_days(location, days)
|
||||||
|
return build_weather_response(request, response)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: tag mode route deprecated, used in tizen app
|
||||||
|
@router.get("/{provider}/{location}/tag/days-{days}", response_class=RedirectResponse)
|
||||||
|
async def get_tag_days(request: AppRequest, days: int):
|
||||||
|
url = URL(path=f"../days/{days}", query=request.url.query)
|
||||||
|
return RedirectResponse(url)
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: tag mode route deprecated, used in tizen app
|
||||||
|
@router.get("/{provider}/{location}/{mode:str}/{date}", response_class=HTMLResponse)
|
||||||
|
async def get_weather_day(
|
||||||
|
request: AppRequest,
|
||||||
|
weather_api: WeatherApiDepends,
|
||||||
|
location: str,
|
||||||
|
date: DateDepends,
|
||||||
|
mode: Literal["day", "tag"], # pylint:disable=unused-argument
|
||||||
|
):
|
||||||
|
response = await weather_api.get_day(location, date)
|
||||||
|
return build_weather_response(request, response)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Weather{% endblock %}
|
{% block title %}{{_("Weather")}}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Weather</h1>
|
<h1>{{_("Weather")}}</h1>
|
||||||
<form action=""
|
<form action=""
|
||||||
method="get"
|
method="get"
|
||||||
class="mb-4">
|
class="mb-4">
|
||||||
@@ -11,63 +11,53 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
id="query"
|
id="query"
|
||||||
name="query"
|
name="query"
|
||||||
placeholder="Enter the city name">
|
placeholder="{{_('Enter the city name')}}">
|
||||||
<button class="btn btn-primary"
|
<input type="hidden"
|
||||||
type="submit">Search</button>
|
class="form-control"
|
||||||
</div>
|
id="provider"
|
||||||
</form>
|
name="provider"
|
||||||
<ul id="locations"
|
value="{{provider or providers[0]}}">
|
||||||
class="list-group mb-5">
|
<button id="providerBtn"
|
||||||
{% for location in locations %}
|
class="btn btn-secondary dropdown-toggle"
|
||||||
<a href="weather/{{location.id}}"
|
type="text"
|
||||||
class="list-group-item list-group-item-action px-4"
|
data-bs-toggle="dropdown"
|
||||||
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
|
aria-expanded="false">{{provider or providers[0]}}</button>
|
||||||
<span class="text-primary">{{location.name}}</span>
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<span class="small ms-1 text-secondary">
|
{% for provider in providers %}
|
||||||
{{location.country}}, {{location.district}}, {{location.subdistrict}}
|
<li>
|
||||||
</span>
|
<a class="dropdown-item"
|
||||||
<span></span>
|
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
|
||||||
</a>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
type="submit">{{_("Search")}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if locations %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<weather-location-list value="{{locations | tojson | forceescape}}"
|
||||||
|
onclick="weatherLocationManager.store(element)"
|
||||||
|
onadd="weatherLocationManager.add(element)"></weather-location-list>
|
||||||
|
<hr>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3>{{_("Stored locations")}}</h3>
|
||||||
|
<weather-location-list id="storedLocations"
|
||||||
|
onremove="weatherLocationManager.remove(element)"></weather-location-list>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
document.loadLocations = () => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
const container = document.querySelector('#locations');
|
|
||||||
container.innerHTML = '';
|
|
||||||
for (const [id, name] of Object.entries(locations)) {
|
|
||||||
const element = document.createElement('a');
|
|
||||||
element.href = `weather/${id}`;
|
|
||||||
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
|
|
||||||
element.innerHTML = `
|
|
||||||
<span class="text-primary me-auto">${name}</span>
|
|
||||||
<span class="text-danger" onclick="removeLocation('${id}'); event.preventDefault();">✕</span>
|
|
||||||
`;
|
|
||||||
container.appendChild(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.saveLocation = (location) => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
locations[location.id] = location.name;
|
|
||||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeLocation = (id) => {
|
|
||||||
const locations = JSON.parse(window.localStorage.getItem('locations') || '{}');
|
|
||||||
delete locations[id];
|
|
||||||
window.localStorage.setItem('locations', JSON.stringify(locations));
|
|
||||||
document.loadLocations();
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const searchQuery = params.get('query');
|
const searchQuery = params.get('query');
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
document.querySelector('#query').value = searchQuery;
|
document.querySelector('#query').value = searchQuery;
|
||||||
} else {
|
|
||||||
document.loadLocations();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", (event) => {
|
||||||
|
weatherLocationManager.load();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,24 +1,37 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
|
{% set date = response.date %}
|
||||||
|
|
||||||
|
{% block title %}{{_("Weather")}} | {{response.location}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}{% endblock %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<span class="fs-4 text-body ms-2 me-2">/</span>
|
<app-link href="/weather"
|
||||||
<app-link href="/weather" icon="brightness-high">{{_("Weather")}}</app-link>
|
icon="brightness-high">{{_("Weather")}}</app-link>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4>
|
||||||
{% if response.period == 'day' %}
|
{% if response.period == 'day' %}
|
||||||
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
|
<a class="icon-link {{'disabled' if date == datetime.date.today() else ''}}"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
|
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
|
||||||
<a class="button"
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
href="../tag/days-10">⬆️</a>
|
</a>
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<a class="icon-link"
|
||||||
<a class="button"
|
href="{{build_url(request, path=['..', '..', 'days', '10'])}}">
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
|
</a>
|
||||||
|
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
|
||||||
|
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
|
<a class="icon-link"
|
||||||
|
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
|
||||||
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
|
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
|
||||||
|
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
|
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
|
||||||
|
locale=request.state.language)}}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h4>
|
</h4>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -36,9 +49,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
|
||||||
<span class="value">
|
<span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
|
||||||
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
|
<a href="{{build_url(request, path=['..', '..', 'day', value.date.date()])}}">
|
||||||
{{value.date.strftime('%a %d')}}
|
{{format_date(value.date, 'E d', locale=request.state.language)}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -49,14 +62,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Облачность
|
{{_("Cloudiness")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="cloudness">
|
<td class="cloudness"
|
||||||
{% for icon in value.sky | cloudness_icon %}
|
data-bs-toggle="tooltip"
|
||||||
<div class="icon">{{icon}}</div>
|
data-bs-title="{{ value.sky }}">
|
||||||
|
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||||
|
<div class="wi-svg wi-l wi-border"
|
||||||
|
style="margin: 0 auto;">{{ icon | weather_icon_svg }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -65,7 +81,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Температура, °C
|
{{_("Temperature, °C")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -84,13 +100,16 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Направление ветра
|
{{_("Wind direction")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="wind">
|
<td class="wind"
|
||||||
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{{ value.wind.direction }}">
|
||||||
|
<div class="wi-svg wi-s wi-{{value.wind.direction | wind_direction_icon}}"
|
||||||
|
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -98,17 +117,17 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Скорость ветра, м/с
|
{{_("Wind speed, m/s")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="wind"
|
<td class="wind"
|
||||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
style="background-color: rgba(128, 128, 128, {{value.wind.speed * 0.05}});">
|
||||||
<span class="speed">{{value.wind_speed}}</span>
|
<span class="speed">{{value.wind.speed}}</span>
|
||||||
{% if value.wind_gust != value.wind_speed %}
|
{% if value.wind.gust != value.wind.speed and value.wind.gust > 0 %}
|
||||||
<span class="gust">
|
<span class="gust">
|
||||||
({{value.wind_gust}})
|
({{value.wind.gust}})
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -118,14 +137,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Осадки, мм
|
{{_("Precipitation, mm")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="precipitation"
|
<td class="precipitation"
|
||||||
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
|
||||||
<span class="value">{{value.precipitation or ' '}}</span>
|
<span class="value">{{(value.precipitation | round(2)) if value.precipitation else ' '}}</span>
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -133,7 +152,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Давление, мм рт. ст.
|
{{_("Pressure, mmHg")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -151,14 +170,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{response.values | length}}"
|
<td colspan="{{response.values | length}}"
|
||||||
class="header">
|
class="header">
|
||||||
Влажность, %
|
{{_("Humidity, %")}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="humidity"
|
<td class="humidity"
|
||||||
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
|
||||||
<span class="value">{{value.humidity}}</span>
|
{% if value.humidity > -1%}<span class="value">{{value.humidity}}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+8
-4
@@ -5,19 +5,23 @@ import uvicorn
|
|||||||
|
|
||||||
from gallery.easel import build_app
|
from gallery.easel import build_app
|
||||||
from gallery.painting.gismeteo.api import GismeteoApi
|
from gallery.painting.gismeteo.api import GismeteoApi
|
||||||
from gallery.painting.matchtv.api import MatchTvApi
|
from gallery.painting.matchtv.api import MatchTVApi
|
||||||
from gallery.painting.openweather.api import OpenWeatherApi
|
from gallery.painting.openweather.api import OpenWeatherApi
|
||||||
from gallery.painting.yandextv.api import YandexTvApi
|
from gallery.painting.rp5.api import RP5WeatherApi
|
||||||
|
from gallery.painting.yandextv.api import YandexTVApi
|
||||||
|
from gallery.painting.yandexweather.api import YandexWeatherApi
|
||||||
from gallery.sketch.bundle import ApiBundle
|
from gallery.sketch.bundle import ApiBundle
|
||||||
from gallery.sketch.schedule.cached import CachedScheduleApi
|
from gallery.sketch.schedule.cached import CachedScheduleApi
|
||||||
from gallery.sketch.weather.cached import CachedWeatherApi
|
from gallery.sketch.weather.cached import CachedWeatherApi
|
||||||
|
|
||||||
api = ApiBundle(
|
api = ApiBundle(
|
||||||
[
|
[
|
||||||
CachedScheduleApi(YandexTvApi()),
|
CachedScheduleApi(YandexTVApi()),
|
||||||
CachedScheduleApi(MatchTvApi()),
|
CachedScheduleApi(MatchTVApi()),
|
||||||
CachedWeatherApi(GismeteoApi()),
|
CachedWeatherApi(GismeteoApi()),
|
||||||
CachedWeatherApi(OpenWeatherApi()),
|
CachedWeatherApi(OpenWeatherApi()),
|
||||||
|
CachedWeatherApi(YandexWeatherApi()),
|
||||||
|
CachedWeatherApi(RP5WeatherApi()),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
app = build_app(api)
|
app = build_app(api)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "gismeteo"
|
||||||
|
|||||||
@@ -1,102 +1,55 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
from os import environ
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
from gallery.sketch.weather.api import WeatherApi
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||||
|
|
||||||
from . import datehelp
|
from . import PROVIDER
|
||||||
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
from .parser import ForecastData, GismeteoForecastParser, GismeteoLocationParser
|
||||||
|
|
||||||
logger = logging.getLogger("gismeteo")
|
|
||||||
|
def format_date(date: datetime.date) -> str:
|
||||||
|
today = datetime.date.today()
|
||||||
|
days = (date - today).days
|
||||||
|
if days == 0:
|
||||||
|
return "today"
|
||||||
|
elif days == 1:
|
||||||
|
return "tomorrow"
|
||||||
|
else:
|
||||||
|
return f"{days + 1}-day"
|
||||||
|
|
||||||
|
|
||||||
class GismeteoApi(WeatherApi):
|
class GismeteoApi(WeatherApi):
|
||||||
PROVIDER = "gismeteo"
|
PROVIDER = PROVIDER
|
||||||
SOURCE = ApiSource(
|
|
||||||
|
forecast_parser = GismeteoForecastParser()
|
||||||
|
location_parser = GismeteoLocationParser()
|
||||||
|
|
||||||
|
def _build_source(self) -> ApiSource:
|
||||||
|
return ApiSource(
|
||||||
"https://www.gismeteo.ru",
|
"https://www.gismeteo.ru",
|
||||||
cookies={
|
cookies={"xauth": auth} if (auth := environ.get("GISMETEO_AUTH")) else None,
|
||||||
"cf_clearance": (
|
|
||||||
"zPj8qeaYZWp5BUZsWIRD7kd32s59IDWWaVvD7GGf6b4-1724254262-1.2.1.1-"
|
|
||||||
"R1PaHoqlm4OEgHfrOg6oAJmVmFJ7053JI_QDjIFlCAmbNY0oyw1J4QtgmEkAkTWF4C99X5hpXmZB2en."
|
|
||||||
"9Ey3NL0kzaE10UGBHPli5OLxHNKC3wQ34xmxTxg6GcKouXOPRgqo92lVRpQP7ghvDmvvT_"
|
|
||||||
"S6YXDbRonkamptWhZiAsIhsnIQg2Mt7Lk1fxSEVbaey_RXVQBvaR2ofiFgIavcbQPRFSvJ."
|
|
||||||
"Ct_FujmymTAZWh1XHTptK32kur81NXyPuARThetOKQm8AklaMa855Oom6Bms1a4F9b0yEHSd2inA4j3FR7uauu8hhwO0Z2goa3."
|
|
||||||
"ktpXXAQLz6dm.fBEWIHD7wooda15X57tCcDOely4hxhO4LK2lIH6TbOr."
|
|
||||||
"rZQAiRRhltHiCizP1Ou2SiF_81lb1umoDh739tQD1cnw_USnL."
|
|
||||||
"JwTHvkB00ZnaHRMi4FCBrsKIE0NxJO6kiv1QANqumzf5.8_pSIGRo2cKeO8xCydduDNFbStdOmLR6FU7ZKqVu"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
|
||||||
result: list[dict[str, Any]] = []
|
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
|
||||||
location = LOCATION_PARSER.parse_location(data)
|
|
||||||
widget = ONE_DAY_PARSER.parse_widget(soup)
|
|
||||||
for parser in ROW_PARSERS:
|
|
||||||
for index, value in enumerate(parser.parse_row(widget)):
|
|
||||||
while len(result) < index + 1:
|
|
||||||
result.append({})
|
|
||||||
result[index][parser.KEY] = value
|
|
||||||
values = [WeatherValue(**item) for item in result]
|
|
||||||
return WeatherResponse(
|
|
||||||
location=location or "n/a",
|
|
||||||
date=date,
|
|
||||||
period="day",
|
|
||||||
values=values,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_manydays(self, data: str) -> WeatherResponse:
|
|
||||||
result: list[dict[str, Any]] = []
|
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
|
||||||
location = LOCATION_PARSER.parse_location(data)
|
|
||||||
widget = DAYS_PARSER.parse_widget(soup)
|
|
||||||
for parser in ROW_PARSERS:
|
|
||||||
for index, value in enumerate(parser.parse_row(widget)):
|
|
||||||
while len(result) < index + 1:
|
|
||||||
result.append({})
|
|
||||||
result[index][parser.KEY] = value
|
|
||||||
values = [WeatherValue(**item) for item in result]
|
|
||||||
return WeatherResponse(
|
|
||||||
location=location or "n/a",
|
|
||||||
date=datetime.date.today(),
|
|
||||||
period="days",
|
|
||||||
values=values,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def find_locations(self, query: str) -> list[Location]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
geo = "ru"
|
geo = "ru"
|
||||||
latitude = 52.968498
|
latitude = 52.968498
|
||||||
longitude = 36.0695
|
longitude = 36.0695
|
||||||
data = json.loads(
|
data = await self.source.request(
|
||||||
await self.SOURCE.request(
|
|
||||||
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
||||||
)
|
)
|
||||||
)
|
return self.location_parser.parse(data)
|
||||||
result = []
|
|
||||||
for item in data["data"]:
|
|
||||||
result.append(
|
|
||||||
Location(
|
|
||||||
id=f"{item['slug']}-{item['id']}",
|
|
||||||
name=item["translations"]["kk"]["city"]["name"],
|
|
||||||
lat=item["coordinates"]["latitude"],
|
|
||||||
lon=item["coordinates"]["longitude"],
|
|
||||||
country=item["translations"]["kk"]["country"]["name"],
|
|
||||||
district=item["translations"]["kk"]["district"]["name"],
|
|
||||||
subdistrict=item["translations"]["kk"]["subdistrict"]["name"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
||||||
return self._parse_oneday(date, data)
|
if date > max_date:
|
||||||
|
raise ValueError(f"Invalid date: {date}, max: {max_date}")
|
||||||
|
data = await self.source.request(f"weather-{location_id}/{format_date(date)}")
|
||||||
|
context = ForecastData(date, "day", data, 8)
|
||||||
|
return self.forecast_parser.parse(context)
|
||||||
|
|
||||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
|
data = await self.source.request(f"weather-{location_id}/{days}-days")
|
||||||
return self._parse_manydays(data)
|
context = ForecastData(datetime.date.today(), "days", data)
|
||||||
|
return self.forecast_parser.parse(context)
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
from typing import Generic, Iterable, Optional, TypeVar
|
|
||||||
|
|
||||||
from bs4 import Tag
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
class WidgetParser:
|
|
||||||
def parse_widget(self, tag: Tag) -> Tag:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class BaseWidgetParser(WidgetParser):
|
|
||||||
SELECT: str
|
|
||||||
|
|
||||||
def __init__(self, select: Optional[str] = None):
|
|
||||||
super().__init__()
|
|
||||||
self._select = select or self.SELECT
|
|
||||||
|
|
||||||
def parse_widget(self, tag: Tag) -> Tag:
|
|
||||||
widget = tag.select_one(self._select)
|
|
||||||
if widget is None:
|
|
||||||
raise ValueError(self._select)
|
|
||||||
return widget
|
|
||||||
|
|
||||||
|
|
||||||
class RowParser(Generic[T]):
|
|
||||||
KEY: str
|
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[T]:
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import datetime
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
import dateparser
|
|
||||||
import dateparser.date_parser
|
|
||||||
|
|
||||||
|
|
||||||
class Day(str, Enum):
|
|
||||||
TODAY = "today"
|
|
||||||
TOMORROW = "tomorrow"
|
|
||||||
DAY_3 = "3-day"
|
|
||||||
DAY_4 = "4-day"
|
|
||||||
DAY_5 = "5-day"
|
|
||||||
|
|
||||||
|
|
||||||
def parse(value: str) -> datetime.date:
|
|
||||||
if value in ["today", "mock"]:
|
|
||||||
return datetime.date.today()
|
|
||||||
elif value == "tomorrow":
|
|
||||||
return datetime.date.today() + datetime.timedelta(days=1)
|
|
||||||
elif value.endswith("-day"):
|
|
||||||
days = int(value.split("-")[0]) - 1
|
|
||||||
return datetime.date.today() + datetime.timedelta(days=days)
|
|
||||||
else:
|
|
||||||
date = dateparser.parse(value)
|
|
||||||
if date is None:
|
|
||||||
raise ValueError(value)
|
|
||||||
return date.date()
|
|
||||||
|
|
||||||
|
|
||||||
def dump(date: datetime.date) -> str:
|
|
||||||
today = datetime.date.today()
|
|
||||||
days = (date - today).days
|
|
||||||
if days == 0:
|
|
||||||
return "today"
|
|
||||||
elif days == 1:
|
|
||||||
return "tomorrow"
|
|
||||||
else:
|
|
||||||
return f"{days + 1}-day"
|
|
||||||
+153
-106
@@ -1,64 +1,63 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
from typing import Iterable
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
import dateparser
|
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
|
|
||||||
|
from gallery.sketch.parse.core import ParsedContext, ParseError, Parser
|
||||||
|
from gallery.sketch.parse.date import DATE_PARSER
|
||||||
|
from gallery.sketch.parse.html import HTMLContextParser, TableParser, TableValueParser
|
||||||
|
from gallery.sketch.parse.json import JSONParser
|
||||||
from gallery.sketch.weather.model import (
|
from gallery.sketch.weather.model import (
|
||||||
Cloudness,
|
Cloudness,
|
||||||
|
Location,
|
||||||
Precipitation,
|
Precipitation,
|
||||||
Sky,
|
Sky,
|
||||||
|
WeatherResponse,
|
||||||
|
WeatherValue,
|
||||||
WindDirection,
|
WindDirection,
|
||||||
WindDirectionDeg,
|
|
||||||
)
|
)
|
||||||
|
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
|
||||||
|
from gallery.sketch.weather.util import fix_dates_sequence, merge_weather_values
|
||||||
|
|
||||||
from .core import BaseWidgetParser, RowParser
|
from . import PROVIDER
|
||||||
|
|
||||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
|
||||||
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
|
||||||
|
|
||||||
|
|
||||||
class LocationParser:
|
class LocationNameParser(Parser[str, str]):
|
||||||
PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"')
|
PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"')
|
||||||
|
|
||||||
def parse_location(self, data: str) -> str | None:
|
def parse(self, data: str) -> str:
|
||||||
match = self.PATTERN.search(data)
|
match = self.PATTERN.search(data)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
return None
|
raise ParseError("Location not found")
|
||||||
|
|
||||||
|
|
||||||
LOCATION_PARSER = LocationParser()
|
class TimeParser(TableValueParser[datetime.datetime]):
|
||||||
|
selector = ".widget-row.widget-row-datetime-time > .row-item > time-value"
|
||||||
|
model_key = "date"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> datetime.datetime:
|
||||||
|
timestamp = int(data.attrs["timestamp"])
|
||||||
|
return datetime.datetime.fromtimestamp(timestamp)
|
||||||
|
|
||||||
|
|
||||||
class DateParser(RowParser[datetime.datetime]):
|
class DateParser(TableValueParser[datetime.datetime]):
|
||||||
KEY = "date"
|
selector = ".widget-row.widget-row-date > .row-item"
|
||||||
|
model_key = "date"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
def extract(self, data: Tag) -> datetime.datetime:
|
||||||
datetime_date_tag = tag.select_one(
|
return DATE_PARSER.parse(data.text)
|
||||||
".widget-row.widget-row-datetime-date > .row-item"
|
|
||||||
)
|
|
||||||
if datetime_date_tag:
|
|
||||||
date_str = datetime_date_tag.find(text=True, recursive=False).text
|
|
||||||
date = dateparser.parse(date_str, languages=["ru"])
|
|
||||||
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
|
|
||||||
time_str = item.text
|
|
||||||
time = dateparser.parse(time_str, languages=["ru"])
|
|
||||||
time = time.replace(year=date.year, month=date.month, day=date.day)
|
|
||||||
yield time
|
|
||||||
else:
|
|
||||||
for item in tag.select(".widget-row.widget-row-date > .row-item"):
|
|
||||||
date_str = item.text
|
|
||||||
date = dateparser.parse(date_str, languages=["ru"])
|
|
||||||
yield date
|
|
||||||
|
|
||||||
|
|
||||||
class SkyParser(RowParser[Sky]):
|
class SkyParser(TableValueParser[Sky]):
|
||||||
KEY = "sky"
|
selector = ".widget-row[data-row=icon-tooltip] > .row-item"
|
||||||
|
model_key = "sky"
|
||||||
|
|
||||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||||
"ясно": Cloudness.CLEAR,
|
"ясно": Cloudness.CLEAR,
|
||||||
|
"безоблачно": Cloudness.CLEAR,
|
||||||
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
"малооблачно": Cloudness.PARTLY_CLOUDY,
|
||||||
"облачно": Cloudness.CLOUDY,
|
"облачно": Cloudness.CLOUDY,
|
||||||
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||||
@@ -66,8 +65,11 @@ class SkyParser(RowParser[Sky]):
|
|||||||
|
|
||||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
"без осадков": Precipitation.NO,
|
"без осадков": Precipitation.NO,
|
||||||
|
"без существенных осадков": Precipitation.NO,
|
||||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
|
"очень сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
|
"ливневый дождь": Precipitation.SHOWER,
|
||||||
"дождь": Precipitation.RAIN,
|
"дождь": Precipitation.RAIN,
|
||||||
"ливень": Precipitation.SHOWER,
|
"ливень": Precipitation.SHOWER,
|
||||||
"снег": Precipitation.SNOW,
|
"снег": Precipitation.SNOW,
|
||||||
@@ -78,25 +80,37 @@ class SkyParser(RowParser[Sky]):
|
|||||||
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||||
"небольшой снег с дождём": Precipitation.SNOW,
|
"небольшой снег с дождём": Precipitation.SNOW,
|
||||||
"небольшой мокрый снег": Precipitation.SNOW,
|
"небольшой мокрый снег": Precipitation.SNOW,
|
||||||
|
"град": Precipitation.HAIL,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
THUNDER = {"гроза"}
|
||||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
FOG = {"дымка", "туман"}
|
||||||
sky_str = item.attrs["data-tooltip"]
|
|
||||||
|
@classmethod
|
||||||
|
def _detect_flag(cls, values: set[str], flag_values: set[str]) -> tuple[set[str], bool]:
|
||||||
|
result_values = values - flag_values
|
||||||
|
return result_values, len(result_values) < len(values)
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> Sky:
|
||||||
|
sky_str = data.attrs["data-tooltip"]
|
||||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||||
cloudness = Cloudness.CLEAR
|
cloudness = Cloudness.CLEAR
|
||||||
precipitation = Precipitation.NO
|
precipitation = Precipitation.NO
|
||||||
thunder = "гроза" in values
|
values, thunder = self._detect_flag(values, self.THUNDER)
|
||||||
fog = "дымка" in values
|
values, fog = self._detect_flag(values, self.FOG)
|
||||||
for k, v in self.CLOUDNESS_MAP.items():
|
for k, v in self.CLOUDNESS_MAP.items():
|
||||||
if k in values:
|
if k in values:
|
||||||
cloudness = v
|
cloudness = v
|
||||||
|
values.remove(k)
|
||||||
break
|
break
|
||||||
for k, v in self.PRECIPITATION_MAP.items():
|
for k, v in self.PRECIPITATION_MAP.items():
|
||||||
if k in values:
|
if k in values:
|
||||||
precipitation = v
|
precipitation = v
|
||||||
|
values.remove(k)
|
||||||
break
|
break
|
||||||
yield Sky(
|
if values:
|
||||||
|
raise ParseError("Unknown sky values", values)
|
||||||
|
return Sky(
|
||||||
cloudness=cloudness,
|
cloudness=cloudness,
|
||||||
precipitation=precipitation,
|
precipitation=precipitation,
|
||||||
thunder=thunder,
|
thunder=thunder,
|
||||||
@@ -104,94 +118,69 @@ class SkyParser(RowParser[Sky]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TemperatureParser(RowParser[list[int]]):
|
class TemperatureParser(TableValueParser[list[int]]):
|
||||||
KEY = "temperature"
|
selector = ".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
||||||
|
model_key = "temperature"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
def extract(self, data: Tag) -> list[int]:
|
||||||
for item in tag.select(
|
return [int(value.attrs["value"]) for value in data.select("temperature-value")]
|
||||||
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
|
||||||
):
|
|
||||||
yield [
|
|
||||||
int(value.attrs["value"]) for value in item.select("temperature-value")
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class WindSpeedParser(RowParser[int]):
|
class WindSpeedParser(TableValueParser[int]):
|
||||||
KEY = "wind_speed"
|
selector = ".widget-row-wind > .row-item > .wind-speed"
|
||||||
|
model_key = "wind.speed"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def extract(self, data: Tag) -> int:
|
||||||
for item in tag.select(
|
value = data.select_one("speed-value")
|
||||||
".widget-row-wind > .row-item > .wind-speed > speed-value"
|
return int(value.attrs["value"]) if value else 0
|
||||||
):
|
|
||||||
yield int(item.attrs["value"])
|
|
||||||
|
|
||||||
|
|
||||||
class WindGustParser(RowParser[int]):
|
class WindGustParser(TableValueParser[int]):
|
||||||
KEY = "wind_gust"
|
selector = ".widget-row-wind > .row-item > .wind-gust"
|
||||||
|
model_key = "wind.gust"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def extract(self, data: Tag) -> int:
|
||||||
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
|
value = data.select_one("speed-value")
|
||||||
value = item.select_one("speed-value")
|
return int(value.attrs["value"]) if value else 0
|
||||||
yield int(value.attrs["value"]) if value else 0
|
|
||||||
|
|
||||||
|
|
||||||
class WindDirectionParser(RowParser[WindDirection]):
|
class WindDirectionParser(TableValueParser[WindDirection | None]):
|
||||||
KEY = "wind_direction"
|
selector = ".widget-row-wind > .row-item > .wind-speed > .wind-direction"
|
||||||
|
model_key = "wind.direction"
|
||||||
|
|
||||||
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
def extract(self, data: Tag) -> WindDirection | None:
|
||||||
"—": WindDirection.CALM,
|
return WIND_DIRECTION_PARSER.parse(data.text.strip())
|
||||||
"штиль": WindDirection.CALM,
|
|
||||||
"с": WindDirection.N,
|
|
||||||
"св": WindDirection.NE,
|
|
||||||
"в": WindDirection.E,
|
|
||||||
"юв": WindDirection.SE,
|
|
||||||
"ю": WindDirection.S,
|
|
||||||
"юз": WindDirection.SW,
|
|
||||||
"з": WindDirection.W,
|
|
||||||
"сз": WindDirection.NW,
|
|
||||||
}
|
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
|
||||||
for item in tag.select(
|
|
||||||
".widget-row-wind > .row-item > .wind-speed > .wind-direction"
|
|
||||||
):
|
|
||||||
wind_direction_str = item.text.lower().strip()
|
|
||||||
yield WindDirectionDeg.from_direction(
|
|
||||||
self.WIND_DIRECTION_MAP[wind_direction_str]
|
|
||||||
).value
|
|
||||||
|
|
||||||
|
|
||||||
class PrecipitationParser(RowParser[float]):
|
class PrecipitationParser(TableValueParser[float]):
|
||||||
KEY = "precipitation"
|
selector = ".widget-row[data-row=precipitation-bars] > .row-item"
|
||||||
|
model_key = "precipitation"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
def extract(self, data: Tag) -> float:
|
||||||
for item in tag.select(
|
value = data.select_one("precipitation-value")
|
||||||
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
|
return float(value.attrs["value"]) if value else 0
|
||||||
):
|
|
||||||
yield float(item.text.replace(",", "."))
|
|
||||||
|
|
||||||
|
|
||||||
class PressureParser(RowParser[list[int]]):
|
class PressureParser(TableValueParser[list[int]]):
|
||||||
KEY = "pressure"
|
selector = ".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
||||||
|
model_key = "pressure"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
def extract(self, data: Tag) -> list[int]:
|
||||||
for item in tag.select(
|
return [int(value.attrs["value"]) for value in data.select("pressure-value")]
|
||||||
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
|
||||||
):
|
|
||||||
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
|
||||||
|
|
||||||
|
|
||||||
class HumidityParser(RowParser[int]):
|
class HumidityParser(TableValueParser[int]):
|
||||||
KEY = "humidity"
|
selector = ".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
||||||
|
model_key = "humidity"
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
def extract(self, data: Tag) -> int:
|
||||||
for item in tag.select(
|
return int(data.text)
|
||||||
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
|
||||||
):
|
|
||||||
yield int(item.text)
|
|
||||||
|
|
||||||
|
|
||||||
ROW_PARSERS: list[RowParser] = [
|
class GismeteoTableParser(TableParser[WeatherValue]):
|
||||||
|
selector = ".widget .widget-items"
|
||||||
|
value_parsers = [
|
||||||
|
TimeParser(),
|
||||||
DateParser(),
|
DateParser(),
|
||||||
SkyParser(),
|
SkyParser(),
|
||||||
TemperatureParser(),
|
TemperatureParser(),
|
||||||
@@ -203,4 +192,62 @@ ROW_PARSERS: list[RowParser] = [
|
|||||||
HumidityParser(),
|
HumidityParser(),
|
||||||
]
|
]
|
||||||
|
|
||||||
ROW_PARSERS_MAP: dict[str, RowParser] = {parser.KEY: parser for parser in ROW_PARSERS}
|
def create_model(self, data: dict) -> WeatherValue:
|
||||||
|
return WeatherValue.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ForecastData:
|
||||||
|
date: datetime.date
|
||||||
|
period: Literal["day", "days"]
|
||||||
|
data: str
|
||||||
|
values: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GismeteoForecastParser(HTMLContextParser[ForecastData, WeatherResponse]):
|
||||||
|
location_parser = LocationNameParser()
|
||||||
|
table_parser = GismeteoTableParser()
|
||||||
|
|
||||||
|
def extract(self, data: ParsedContext[ForecastData, Tag]) -> WeatherResponse:
|
||||||
|
location = self.location_parser.parse(data.context.data)
|
||||||
|
values = self.table_parser.parse(data.data)
|
||||||
|
if data.context.values and len(values) > data.context.values:
|
||||||
|
n = int(len((values)) / data.context.values)
|
||||||
|
values = [
|
||||||
|
merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n)
|
||||||
|
]
|
||||||
|
if data.context.period == "days":
|
||||||
|
fix_dates_sequence(values)
|
||||||
|
return WeatherResponse(
|
||||||
|
location=location,
|
||||||
|
date=data.context.date,
|
||||||
|
period=data.context.period,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GismeteoLocationParser(JSONParser[list[Location]]):
|
||||||
|
|
||||||
|
def extract(self, data: Any) -> list[Location]:
|
||||||
|
result = []
|
||||||
|
for item in data["data"]:
|
||||||
|
result.append(
|
||||||
|
Location(
|
||||||
|
id=f"{item['slug']}-{item['id']}",
|
||||||
|
name=item["translations"]["kk"]["city"]["name"],
|
||||||
|
provider=PROVIDER,
|
||||||
|
lat=item["coordinates"]["latitude"],
|
||||||
|
lon=item["coordinates"]["longitude"],
|
||||||
|
country=item["translations"]["kk"]["country"]["name"],
|
||||||
|
country_code=item["country"]["code"].lower(),
|
||||||
|
district=(
|
||||||
|
item["translations"]["kk"]["district"]["name"] if item["translations"]["kk"]["district"] else ""
|
||||||
|
),
|
||||||
|
subdistrict=(
|
||||||
|
item["translations"]["kk"]["subdistrict"]["name"]
|
||||||
|
if "subdistrict" in item["translations"]["kk"]
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "matchtv"
|
||||||
|
|||||||
@@ -1,67 +1,33 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
|
from gallery.sketch.schedule.model import Channel, Schedule
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
logger = logging.getLogger("matchtv")
|
from . import PROVIDER
|
||||||
|
from .parser import (
|
||||||
|
ChannelData,
|
||||||
class MatchTvApi(ScheduleApi):
|
MatchTVChannelParser,
|
||||||
PROVIDER = "matchtv"
|
MatchTVScheduleParser,
|
||||||
SOURCE = ApiSource("https://matchtv.ru")
|
ScheduleData,
|
||||||
|
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
|
||||||
return [
|
|
||||||
ChannelId.MATCH_TV,
|
|
||||||
ChannelId.MATCH_IGRA,
|
|
||||||
ChannelId.MATCH_ARENA,
|
|
||||||
ChannelId.MATCH_FUTBOL_1,
|
|
||||||
ChannelId.MATCH_FUTBOL_2,
|
|
||||||
ChannelId.MATCH_FUTBOL_3,
|
|
||||||
ChannelId.MATCH_STRANA,
|
|
||||||
]
|
|
||||||
|
|
||||||
async def get_channel_schedule(
|
|
||||||
self, channel_id: ChannelId, date: datetime.date
|
|
||||||
) -> Schedule:
|
|
||||||
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
|
|
||||||
data = await self.SOURCE.request(endpoint)
|
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
|
||||||
values = []
|
|
||||||
channel_name = (
|
|
||||||
soup.select_one(".p-tv-guide-header__title")
|
|
||||||
.text.replace("Телепрограмма ", "")
|
|
||||||
.strip()
|
|
||||||
)
|
|
||||||
current_day = datetime.datetime.combine(
|
|
||||||
date.today(), datetime.datetime.min.time()
|
|
||||||
)
|
|
||||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
|
||||||
prev_value: ScheduleValue | None = None
|
|
||||||
for item in soup.select(
|
|
||||||
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
|
|
||||||
):
|
|
||||||
title = item.select_one(
|
|
||||||
".p-tv-guide-schedule-channel-transmission__title"
|
|
||||||
).text.strip()
|
|
||||||
time_str = item.select_one(
|
|
||||||
".p-tv-guide-schedule-channel-transmission__time-block"
|
|
||||||
).text.strip()
|
|
||||||
hours, minutes = map(int, time_str.split(":"))
|
|
||||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
|
||||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
|
||||||
current_day += datetime.timedelta(days=1)
|
|
||||||
item_date += datetime.timedelta(days=1)
|
|
||||||
live = "Прямая трансляция" in title
|
|
||||||
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
|
||||||
values.append(value)
|
|
||||||
if prev_value is not None:
|
|
||||||
prev_value.end = item_date
|
|
||||||
prev_value = value
|
|
||||||
return Schedule(
|
|
||||||
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MatchTVApi(ScheduleApi):
|
||||||
|
PROVIDER = PROVIDER
|
||||||
|
|
||||||
|
schedule_parser = MatchTVScheduleParser()
|
||||||
|
channel_parser = MatchTVChannelParser()
|
||||||
|
|
||||||
|
def _build_source(self) -> ApiSource:
|
||||||
|
return ApiSource("https://matchtv.ru")
|
||||||
|
|
||||||
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
|
endpoint = "api/v1/channels"
|
||||||
|
data = await self.source.request(endpoint)
|
||||||
|
return self.channel_parser.parse(ChannelData(query, data))
|
||||||
|
|
||||||
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
|
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
|
||||||
|
data = await self.source.request(endpoint)
|
||||||
|
return self.schedule_parser.parse(ScheduleData(date, data))
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from gallery.sketch.parse.core import ParsedContext, Parser
|
||||||
|
from gallery.sketch.parse.json import JSONContextParser
|
||||||
|
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||||
|
|
||||||
|
from . import PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
class DateParser(Parser[str, datetime.date]):
|
||||||
|
def parse(self, data: str) -> datetime.date:
|
||||||
|
matched = re.compile('{\\"date\\":(\\d+)}').search(data)
|
||||||
|
return datetime.date.strptime(matched.group(1), data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScheduleData:
|
||||||
|
date: datetime.date
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
class MatchTVScheduleParser(JSONContextParser[ScheduleData, Schedule]):
|
||||||
|
date_parser = DateParser()
|
||||||
|
|
||||||
|
def extract(self, data: ParsedContext[ScheduleData, Any]) -> Schedule:
|
||||||
|
channel_data = data.data["result"]["channels"][0]
|
||||||
|
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=PROVIDER)
|
||||||
|
values = []
|
||||||
|
start = datetime.datetime.combine(data.context.date, datetime.time.min)
|
||||||
|
end = start + datetime.timedelta(days=1, hours=6)
|
||||||
|
prev_value: ScheduleValue | None = None
|
||||||
|
for item in channel_data["schedule"]:
|
||||||
|
title = item["title"]
|
||||||
|
time_str = item["time"]
|
||||||
|
hours, minutes = map(int, time_str.split(":"))
|
||||||
|
item_date = start.replace(hour=hours, minute=minutes)
|
||||||
|
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||||
|
start += datetime.timedelta(days=1)
|
||||||
|
item_date += datetime.timedelta(days=1)
|
||||||
|
live = "Прямая трансляция" in title
|
||||||
|
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||||
|
values.append(value)
|
||||||
|
if prev_value is not None:
|
||||||
|
prev_value.end = item_date
|
||||||
|
prev_value = value
|
||||||
|
return Schedule(
|
||||||
|
channel=channel,
|
||||||
|
date=data.context.date,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChannelData:
|
||||||
|
query: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
class MatchTVChannelParser(JSONContextParser[ChannelData, list[Channel]]):
|
||||||
|
def extract(self, data: ParsedContext[ChannelData, Any]) -> list[Channel]:
|
||||||
|
result = []
|
||||||
|
query = data.context.query.lower()
|
||||||
|
for item in data.data["result"]:
|
||||||
|
if query in item["name"].lower() or query in item["alias"]:
|
||||||
|
channel_id = item["alias"]
|
||||||
|
name = item["name"].split("|")[0].strip()
|
||||||
|
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
|
||||||
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "openweather"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
from itertools import groupby
|
||||||
from collections import defaultdict
|
from os import environ
|
||||||
|
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
|
|
||||||
@@ -9,58 +9,67 @@ from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
|||||||
from gallery.sketch.weather.util import merge_weather_values
|
from gallery.sketch.weather.util import merge_weather_values
|
||||||
from gallery.util import TimeUnit
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .openweather import Forecast, OpenWeather
|
from . import PROVIDER
|
||||||
from .parser import FORECAST_ITEM_PARSER
|
from .openweather import Location as OpenWeatherLocation
|
||||||
|
from .openweather import OpenWeather
|
||||||
logger = logging.getLogger("openweather")
|
from .parser import OpenWeatherForecastParser, OpenWeatherLocationParser
|
||||||
|
|
||||||
|
|
||||||
class OpenWeatherApi(WeatherApi):
|
class OpenWeatherApi(WeatherApi[OpenWeather]):
|
||||||
PROVIDER = "openweather"
|
PROVIDER = PROVIDER
|
||||||
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf")
|
|
||||||
|
forecast_parser = OpenWeatherForecastParser()
|
||||||
|
location_parser = OpenWeatherLocationParser()
|
||||||
|
|
||||||
|
def _build_source(self) -> OpenWeather:
|
||||||
|
return OpenWeather(environ["OPENWEATHER_KEY"])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
||||||
return tuple(map(float, location_id.split(":", maxsplit=2)))
|
return tuple(map(float, location_id.split(":", maxsplit=2)))
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.location",
|
||||||
alias="redis",
|
alias="redis",
|
||||||
ttl=TimeUnit.DAY,
|
ttl=TimeUnit.DAY,
|
||||||
)
|
)
|
||||||
async def _get_location_forecast(self, location_id: str) -> Forecast:
|
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
|
||||||
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
|
return await self.source.get_location(*self._parse_location(location_id))
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||||
|
alias="redis",
|
||||||
|
ttl=TimeUnit.HOUR,
|
||||||
|
)
|
||||||
|
async def _get_location_forecast(self, location_id: str) -> list[WeatherValue]:
|
||||||
|
content = await self.source.get_forecast(*self._parse_location(location_id))
|
||||||
|
return self.forecast_parser.parse(content)
|
||||||
|
|
||||||
async def find_locations(self, query: str) -> list[Location]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
raise NotImplementedError
|
content = await self.source.find_locations(query)
|
||||||
|
return self.location_parser.parse(content)
|
||||||
|
|
||||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
data: Forecast = await self._get_location_forecast(location_id)
|
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||||
values = []
|
values = [value for value in await self._get_location_forecast(location_id) if value.date.date() == date]
|
||||||
for item in data.list:
|
|
||||||
value = FORECAST_ITEM_PARSER.parse(item)
|
|
||||||
if value.date.date() == date:
|
|
||||||
values.append(value)
|
|
||||||
return WeatherResponse(
|
return WeatherResponse(
|
||||||
location=location_id,
|
location=location.name,
|
||||||
date=date,
|
date=date,
|
||||||
period="day",
|
period="day",
|
||||||
values=values,
|
values=values,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
data: Forecast = await self._get_location_forecast(location_id)
|
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||||
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
|
|
||||||
for item in data.list:
|
|
||||||
value = FORECAST_ITEM_PARSER.parse(item)
|
|
||||||
item_date = value.date.replace(hour=0, minute=0)
|
|
||||||
values_by_date[item_date].append(value)
|
|
||||||
values = [
|
values = [
|
||||||
merge_weather_values(date, values)
|
merge_weather_values(datetime.datetime.combine(k, datetime.datetime.min.time()), list(g))
|
||||||
for date, values in values_by_date.items()
|
for k, g in groupby(
|
||||||
|
sorted(await self._get_location_forecast(location_id), key=lambda item: item.date.date()),
|
||||||
|
lambda item: item.date.date(),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
return WeatherResponse(
|
return WeatherResponse(
|
||||||
location=location_id,
|
location=location.name,
|
||||||
date=datetime.date.today(),
|
date=datetime.date.today(),
|
||||||
period="days",
|
period="days",
|
||||||
values=list(sorted(values, key=lambda item: item.date)),
|
values=list(sorted(values, key=lambda item: item.date)),
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from gallery.sketch.mock import MockData
|
|
||||||
|
|
||||||
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")
|
|
||||||
@@ -53,7 +53,7 @@ class ForecastItem(Model):
|
|||||||
weather: list[Weather]
|
weather: list[Weather]
|
||||||
clouds: Clouds
|
clouds: Clouds
|
||||||
wind: Wind
|
wind: Wind
|
||||||
visibility: int
|
visibility: int | None = None
|
||||||
pop: float
|
pop: float
|
||||||
rain: Rain | None = None
|
rain: Rain | None = None
|
||||||
sys: Sys
|
sys: Sys
|
||||||
@@ -67,17 +67,37 @@ class Forecast(Model):
|
|||||||
list: list[ForecastItem]
|
list: list[ForecastItem]
|
||||||
|
|
||||||
|
|
||||||
class OpenWeather:
|
class Location(Model):
|
||||||
|
name: str
|
||||||
|
lat: float
|
||||||
|
lon: float
|
||||||
|
country: str
|
||||||
|
state: str | None = None
|
||||||
|
local_names: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWeather(ApiSource):
|
||||||
BASE_URL = "https://api.openweathermap.org"
|
BASE_URL = "https://api.openweathermap.org"
|
||||||
|
|
||||||
def __init__(self, api_key: str):
|
def __init__(self, api_key: str):
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._source = ApiSource(self.BASE_URL)
|
super().__init__(self.BASE_URL)
|
||||||
|
|
||||||
async def get_forecast(self, lat: float, lon: float) -> Forecast:
|
async def get_forecast(self, lat: float, lon: float) -> Forecast:
|
||||||
endpoint = (
|
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||||
f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
response = await self.request(endpoint)
|
||||||
)
|
|
||||||
response = await self._source.request(endpoint)
|
|
||||||
response_data = json.loads(response)
|
response_data = json.loads(response)
|
||||||
return Forecast(**response_data)
|
return Forecast.model_validate(response_data)
|
||||||
|
|
||||||
|
async def find_locations(self, query: str, limit: int = 5) -> list[Location]:
|
||||||
|
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
|
||||||
|
response = await self.request(endpoint)
|
||||||
|
response_data = json.loads(response)
|
||||||
|
return [Location.model_validate(item) for item in response_data]
|
||||||
|
|
||||||
|
async def get_location(self, lat: float, lon: float) -> Location:
|
||||||
|
limit = 1
|
||||||
|
endpoint = f"geo/1.0/reverse?lat={lat}&lon={lon}&limit={limit}&appid={self._api_key}"
|
||||||
|
response = await self.request(endpoint)
|
||||||
|
response_data = json.loads(response)
|
||||||
|
return Location.model_validate(response_data[0])
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
|
from gallery.sketch.parse.core import Parser
|
||||||
from gallery.sketch.weather.util import build_weather_value
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Location,
|
||||||
|
Precipitation,
|
||||||
|
WeatherValue,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
|
||||||
|
from gallery.sketch.weather.util import convert_hPa_to_mmHg
|
||||||
|
|
||||||
from .openweather import ForecastItem
|
from . import PROVIDER
|
||||||
|
from .openweather import Forecast, ForecastItem
|
||||||
|
from .openweather import Location as OpenWeatherLocation
|
||||||
|
|
||||||
|
|
||||||
class ForecastItemParser:
|
class ForecastItemParser(Parser[ForecastItem, list[WeatherValue]]):
|
||||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||||
"clear sky": Cloudness.CLEAR,
|
"clear sky": Cloudness.CLEAR,
|
||||||
"few clouds": Cloudness.PARTLY_CLOUDY,
|
"few clouds": Cloudness.PARTLY_CLOUDY,
|
||||||
@@ -22,31 +31,54 @@ class ForecastItemParser:
|
|||||||
"heavy rain": Precipitation.SHOWER,
|
"heavy rain": Precipitation.SHOWER,
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse(self, item: ForecastItem) -> WeatherValue:
|
def parse(self, data: ForecastItem) -> WeatherValue:
|
||||||
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
|
date = (
|
||||||
item_date = (
|
datetime.datetime.fromtimestamp(data.dt, datetime.UTC)
|
||||||
item_date.replace(tzinfo=datetime.timezone.utc)
|
.replace(tzinfo=datetime.timezone.utc)
|
||||||
.astimezone(tz=None)
|
.astimezone(tz=None)
|
||||||
.replace(tzinfo=None)
|
.replace(tzinfo=None)
|
||||||
)
|
)
|
||||||
value = build_weather_value(item_date)
|
return WeatherValue.model_validate(
|
||||||
# TODO parse temperature interval flag
|
{
|
||||||
value.temperature = [round(item.main.temp)]
|
"date": date,
|
||||||
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
|
"sky": {
|
||||||
value.pressure = [round(item.main.pressure / 133.3 * 100)]
|
"cloudness": self.CLOUDNESS_MAP.get(data.weather[0].description, Cloudness.CLEAR),
|
||||||
value.humidity = item.main.humidity
|
"precipitation": self.PRECIPITATION_MAP.get(data.weather[0].description, Precipitation.NO),
|
||||||
value.wind_speed = round(item.wind.speed)
|
},
|
||||||
value.wind_gust = round(item.wind.gust)
|
"wind": {
|
||||||
value.wind_direction = item.wind.deg
|
"speed": round(data.wind.speed),
|
||||||
value.sky.cloudness = self.CLOUDNESS_MAP.get(
|
"gust": round(data.wind.gust),
|
||||||
item.weather[0].description, Cloudness.CLEAR
|
"direction": WIND_DIRECTION_PARSER.parse(data.wind.deg),
|
||||||
|
},
|
||||||
|
"temperature": [round(data.main.temp)], # [round(data.main.temp_max), round(data.main.temp_min)]
|
||||||
|
"precipitation": round(data.rain.interval_3h, 1) if data.rain else 0,
|
||||||
|
"pressure": [convert_hPa_to_mmHg(data.main.pressure)],
|
||||||
|
"humidity": data.main.humidity,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
value.sky.precipitation = self.PRECIPITATION_MAP.get(
|
|
||||||
item.weather[0].description, Precipitation.NO
|
|
||||||
)
|
|
||||||
if item.rain:
|
|
||||||
value.precipitation = round(item.rain.interval_3h, 1)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
FORECAST_ITEM_PARSER = ForecastItemParser()
|
class OpenWeatherForecastParser(Parser[Forecast, list[WeatherValue]]):
|
||||||
|
item_parser = ForecastItemParser()
|
||||||
|
|
||||||
|
def parse(self, data: Forecast) -> list[WeatherValue]:
|
||||||
|
return [self.item_parser.parse(item) for item in data.list]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenWeatherLocationParser(Parser[list[OpenWeatherLocation], list[Location]]):
|
||||||
|
|
||||||
|
def parse(self, data: list[OpenWeatherLocation]) -> list[Location]:
|
||||||
|
return [
|
||||||
|
Location(
|
||||||
|
id=f"{item.lat}:{item.lon}",
|
||||||
|
name=item.name,
|
||||||
|
provider=PROVIDER,
|
||||||
|
lat=item.lat,
|
||||||
|
lon=item.lon,
|
||||||
|
country=item.country,
|
||||||
|
country_code=item.country.lower(),
|
||||||
|
district=item.state or "",
|
||||||
|
subdistrict="",
|
||||||
|
)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "rp5"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from aiocache import cached
|
||||||
|
|
||||||
|
from gallery.sketch.headers import build_headers
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
|
from gallery.sketch.weather.model import (
|
||||||
|
Location,
|
||||||
|
WeatherResponse,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.util import merge_weather_values
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
|
from . import PROVIDER
|
||||||
|
from .parser import RP5ForecastParser, RP5LocationParser
|
||||||
|
|
||||||
|
|
||||||
|
class RP5WeatherApi(WeatherApi):
|
||||||
|
PROVIDER = PROVIDER
|
||||||
|
|
||||||
|
forecast_parser = RP5ForecastParser()
|
||||||
|
location_parser = RP5LocationParser()
|
||||||
|
|
||||||
|
def _build_source(self) -> ApiSource:
|
||||||
|
return ApiSource("https://rp5.ru", headers=build_headers("rp5.ru"))
|
||||||
|
|
||||||
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
|
endpoint = "docs/search/ru"
|
||||||
|
data = aiohttp.FormData({"searchStr": query})
|
||||||
|
content = await self.source.request(endpoint, method="POST", data=data)
|
||||||
|
return self.location_parser.parse(content)
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||||
|
alias="redis",
|
||||||
|
ttl=TimeUnit.HOUR,
|
||||||
|
)
|
||||||
|
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
|
||||||
|
endpoint = f"{location_id}"
|
||||||
|
content = await self.source.request(endpoint)
|
||||||
|
return self.forecast_parser.parse(content)
|
||||||
|
|
||||||
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
|
forecast = await self._get_location_forecast(location_id)
|
||||||
|
for response in forecast:
|
||||||
|
if response.date == date:
|
||||||
|
return response
|
||||||
|
if date == datetime.date.today():
|
||||||
|
return WeatherResponse(location=location_id, date=date, period="day", values=[])
|
||||||
|
raise ValueError(f"Unsupported date: {date}")
|
||||||
|
|
||||||
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
|
forecast = await self._get_location_forecast(location_id)
|
||||||
|
values = [merge_weather_values(item.date, item.values) for item in forecast]
|
||||||
|
return WeatherResponse(
|
||||||
|
location=forecast[0].location,
|
||||||
|
date=datetime.date.today(),
|
||||||
|
period="days",
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
|
||||||
|
from bs4 import Tag
|
||||||
|
|
||||||
|
from gallery.sketch.parse.core import ParseError
|
||||||
|
from gallery.sketch.parse.date import DATE_PARSER
|
||||||
|
from gallery.sketch.parse.html import (
|
||||||
|
HTMLParser,
|
||||||
|
TableParser,
|
||||||
|
TableValueParser,
|
||||||
|
TagIterParser,
|
||||||
|
TagParser,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Location,
|
||||||
|
Precipitation,
|
||||||
|
WeatherResponse,
|
||||||
|
WeatherValue,
|
||||||
|
WindDirection,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
|
||||||
|
from gallery.sketch.weather.util import fix_dates_sequence
|
||||||
|
|
||||||
|
from . import PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
class LocationNameParser(TagParser[str]):
|
||||||
|
selector = ".intoLeftNavi > .verticalBottom > a"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> str:
|
||||||
|
return data.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class DatesParser(TagIterParser[tuple[datetime.datetime, int]]):
|
||||||
|
selector = "#forecastTable > tr.forecastDate > td"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> tuple[datetime.datetime, int]:
|
||||||
|
colspan = int(data.attrs["colspan"])
|
||||||
|
index = data.parent.index(data)
|
||||||
|
if index == 1:
|
||||||
|
colspan -= 1
|
||||||
|
date_str = ",".join(data.text.strip().split(",")[-2:])
|
||||||
|
date = DATE_PARSER.parse(date_str)
|
||||||
|
return (date, colspan)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeParser(TableValueParser[datetime.datetime]):
|
||||||
|
selector = "tr.forecastTime > td:not(:first-of-type)"
|
||||||
|
model_key = "date"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> datetime.datetime:
|
||||||
|
hour = int(data.text.strip())
|
||||||
|
return datetime.datetime.min.replace(hour=hour)
|
||||||
|
|
||||||
|
|
||||||
|
class CloudnessParser(TableValueParser[Cloudness]):
|
||||||
|
selector = "tr:nth-of-type(3) > td:not(:first-of-type)"
|
||||||
|
model_key = "sky.cloudness"
|
||||||
|
|
||||||
|
CLOUDNESS_MAP = {
|
||||||
|
"Ясно": Cloudness.CLEAR,
|
||||||
|
"Малооблачно": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"Облачно с прояснениями": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"Переменная облачность": Cloudness.PARTLY_CLOUDY,
|
||||||
|
"Небольшая облачность": Cloudness.CLOUDY,
|
||||||
|
"Облачно": Cloudness.CLOUDY,
|
||||||
|
"Значительная облачность": Cloudness.MAINLY_CLOUDY,
|
||||||
|
"Пасмурная погода": Cloudness.MAINLY_CLOUDY,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> Cloudness:
|
||||||
|
onmouseover = data.select_one(".cc_0").next_element.attrs["onmouseover"]
|
||||||
|
cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip()
|
||||||
|
if cloudness_str not in self.CLOUDNESS_MAP:
|
||||||
|
raise ParseError("Unknown cloudness value", cloudness_str)
|
||||||
|
return self.CLOUDNESS_MAP[cloudness_str]
|
||||||
|
|
||||||
|
|
||||||
|
class PrecipitationParser(TableValueParser[tuple[Precipitation, float]]):
|
||||||
|
selector = "tr:nth-of-type(4) > td:not(:first-of-type)"
|
||||||
|
model_key = ["sky.precipitation", "precipitation"]
|
||||||
|
|
||||||
|
PRECIPITATION_MAP = {
|
||||||
|
"Явления погоды отсутствуют": Precipitation.NO,
|
||||||
|
"Cлабый дождь": Precipitation.SMALL_RAIN,
|
||||||
|
"Умеренный дождь": Precipitation.RAIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> tuple[Precipitation, float]:
|
||||||
|
onmouseover = data.select_one(".pr_0").attrs["onmouseover"]
|
||||||
|
precipitation_str = onmouseover.split(",")[1].strip(" '")
|
||||||
|
precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str)
|
||||||
|
precipitation_value = 0
|
||||||
|
if precipitation_value_matched:
|
||||||
|
# one hour value
|
||||||
|
precipitation_value = float(precipitation_value_matched.group(1)) * 6
|
||||||
|
if " (" in precipitation_str:
|
||||||
|
precipitation_str = precipitation_str.split(" (")[0]
|
||||||
|
if precipitation_str not in self.PRECIPITATION_MAP:
|
||||||
|
raise ParseError("Unknown precipitation value", precipitation_str)
|
||||||
|
return self.PRECIPITATION_MAP[precipitation_str], precipitation_value
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureParser(TableValueParser[list[int]]):
|
||||||
|
selector = "tr:nth-of-type(5) > td:not(:first-of-type)"
|
||||||
|
model_key = "temperature"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[int]:
|
||||||
|
return [int(data.select_one(".t_0").text.lstrip("+").strip())]
|
||||||
|
|
||||||
|
|
||||||
|
class PressureParser(TableValueParser[list[int]]):
|
||||||
|
selector = "tr:nth-last-of-type(8) > td:not(:first-of-type)"
|
||||||
|
model_key = "pressure"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[int]:
|
||||||
|
return [int(data.select_one(".p_0").text.strip())]
|
||||||
|
|
||||||
|
|
||||||
|
class WindSpeedParser(TableValueParser[int]):
|
||||||
|
selector = "tr:nth-last-of-type(7) > td:not(:first-of-type)"
|
||||||
|
model_key = "wind.speed"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> int:
|
||||||
|
element = data.select_one(".wv_0")
|
||||||
|
if element:
|
||||||
|
return int(element.text.strip() or "0")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class WindGustParser(WindSpeedParser):
|
||||||
|
selector = "tr:nth-last-of-type(6) > td:not(:first-of-type)"
|
||||||
|
model_key = "wind.gust"
|
||||||
|
|
||||||
|
|
||||||
|
class WindDirectionParser(TableValueParser[WindDirection | None]):
|
||||||
|
selector = "tr:nth-last-of-type(5) > td:not(:first-of-type)"
|
||||||
|
model_key = "wind.direction"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> WindDirection | None:
|
||||||
|
return WIND_DIRECTION_PARSER.parse(data.text.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityParser(TableValueParser[int]):
|
||||||
|
selector = "tr:nth-last-of-type(4) > td:not(:first-of-type)"
|
||||||
|
model_key = "humidity"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> int:
|
||||||
|
return int(data.text.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class RP5TableParser(TableParser[WeatherValue]):
|
||||||
|
selector = "#forecastTable"
|
||||||
|
value_parsers = [
|
||||||
|
TimeParser(),
|
||||||
|
CloudnessParser(),
|
||||||
|
PrecipitationParser(),
|
||||||
|
TemperatureParser(),
|
||||||
|
PressureParser(),
|
||||||
|
WindSpeedParser(),
|
||||||
|
WindGustParser(),
|
||||||
|
WindDirectionParser(),
|
||||||
|
HumidityParser(),
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_model(self, data: dict) -> WeatherValue:
|
||||||
|
return WeatherValue.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
class RP5ForecastParser(HTMLParser[list[WeatherResponse]]):
|
||||||
|
location_parser = LocationNameParser()
|
||||||
|
dates_parser = DatesParser()
|
||||||
|
table_parser = RP5TableParser()
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[WeatherResponse]:
|
||||||
|
location = self.location_parser.parse(data)
|
||||||
|
dates = self.dates_parser.parse(data)
|
||||||
|
values = self.table_parser.parse(data)
|
||||||
|
responses = []
|
||||||
|
for date, count in dates:
|
||||||
|
item_values, values = values[:count], values[count:]
|
||||||
|
for value in item_values:
|
||||||
|
value.date = date.replace(hour=value.date.hour)
|
||||||
|
responses.append(
|
||||||
|
WeatherResponse(
|
||||||
|
location=location,
|
||||||
|
date=date,
|
||||||
|
period="day",
|
||||||
|
values=item_values,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
fix_dates_sequence(responses)
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
class RP5LocationParser(HTMLParser[list[Location]]):
|
||||||
|
selector = ".searchResults"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[Location]:
|
||||||
|
result = []
|
||||||
|
for row in data.select("tr")[1:]:
|
||||||
|
cells = row.select("td")
|
||||||
|
name = cells[0].text.strip()
|
||||||
|
location_id = cells[0].select_one("a").attrs["href"].lstrip("/")
|
||||||
|
country = cells[1].text.strip()
|
||||||
|
district = cells[2].text.strip()
|
||||||
|
subdistrict = cells[3].text.strip()
|
||||||
|
result.append(
|
||||||
|
Location(
|
||||||
|
id=location_id,
|
||||||
|
name=name,
|
||||||
|
provider=PROVIDER,
|
||||||
|
lat=0,
|
||||||
|
lon=0,
|
||||||
|
country=country,
|
||||||
|
country_code="ru", # TODO: contry_code from country
|
||||||
|
district=district,
|
||||||
|
subdistrict=subdistrict,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "yandex"
|
||||||
|
|||||||
@@ -1,94 +1,34 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
|
from gallery.sketch.headers import build_headers
|
||||||
from gallery.sketch.schedule.api import ScheduleApi
|
from gallery.sketch.schedule.api import ScheduleApi
|
||||||
from gallery.sketch.schedule.model import Channel, ChannelId, Schedule, ScheduleValue
|
from gallery.sketch.schedule.model import Channel, Schedule
|
||||||
from gallery.sketch.source import ApiSource
|
from gallery.sketch.source import ApiSource
|
||||||
|
|
||||||
logger = logging.getLogger("matchtv")
|
from . import PROVIDER
|
||||||
|
from .parser import ScheduleData, YandexTVChannelParser, YandexTVScheduleParser
|
||||||
CHANNELS_MAP: dict[ChannelId, str] = {
|
|
||||||
ChannelId.MATCH_TV: "match-tv-49",
|
|
||||||
ChannelId.MATCH_IGRA: "match-igra-1174",
|
|
||||||
ChannelId.MATCH_ARENA: "match-arena-1173",
|
|
||||||
ChannelId.MATCH_FUTBOL_1: "match-futbol-1-646",
|
|
||||||
ChannelId.MATCH_FUTBOL_2: "match-futbol-2-593",
|
|
||||||
ChannelId.MATCH_FUTBOL_3: "match-futbol-3-797",
|
|
||||||
ChannelId.MATCH_STRANA: "match-strana-1356",
|
|
||||||
ChannelId.MATCH_PLANETA: "match-planeta-1177",
|
|
||||||
# ChannelId.EUROSPORT: "eurosport-677",
|
|
||||||
# ChannelId.EUROSPORT_2: "eurosport-2-720",
|
|
||||||
ChannelId.START: "start-103",
|
|
||||||
}
|
|
||||||
|
|
||||||
HEADERS: dict[str, str] = {
|
|
||||||
"Accept": (
|
|
||||||
"text/html,"
|
|
||||||
"application/xhtml+xml,"
|
|
||||||
"application/xml;q=0.9,"
|
|
||||||
"image/avif,image/webp,"
|
|
||||||
"image/apng,*/*;q=0.8,"
|
|
||||||
"application/signed-exchange;v=b3;q=0.9"
|
|
||||||
),
|
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"Host": "tv.yandex.ru",
|
|
||||||
"sec-ch-ua": '"Chromium";v="100", " Not A;Brand";v="99"',
|
|
||||||
"sec-ch-ua-mobile": "?0",
|
|
||||||
"sec-ch-ua-platform": '"Linux"',
|
|
||||||
"Sec-Fetch-Dest": "document",
|
|
||||||
"Sec-Fetch-Mode": "navigate",
|
|
||||||
"Sec-Fetch-Site": "none",
|
|
||||||
"Sec-Fetch-User": "?1",
|
|
||||||
"Upgrade-Insecure-Requests": "1",
|
|
||||||
"User-Agent": (
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/100.0.4896.133 "
|
|
||||||
"Safari/537.36"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class YandexTvApi(ScheduleApi):
|
class YandexTVApi(ScheduleApi):
|
||||||
PROVIDER = "yandextv"
|
PROVIDER = PROVIDER
|
||||||
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
|
|
||||||
|
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
schedule_parser = YandexTVScheduleParser()
|
||||||
return list(CHANNELS_MAP.keys())
|
channel_parser = YandexTVChannelParser()
|
||||||
|
|
||||||
async def get_channel_schedule(
|
lr = 213 # magic id
|
||||||
self, channel_id: ChannelId, date: datetime.date
|
|
||||||
) -> Schedule:
|
def _build_source(self) -> ApiSource:
|
||||||
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
|
return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
|
||||||
data = await self.SOURCE.request(endpoint)
|
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
if soup.select_one(".CheckboxCaptcha") is not None:
|
url = (
|
||||||
raise RuntimeError("Captcha")
|
"https://suggest-multi.yandex.ru/suggest-tv2?"
|
||||||
values = []
|
f"v=4&uil=ru&lr={self.lr}&count_channels=4&count_programs=4&sn=50&part={query}"
|
||||||
channel_name = soup.select_one(".channel-header__text").text.strip()
|
|
||||||
current_day = datetime.datetime.combine(
|
|
||||||
date.today(), datetime.datetime.min.time()
|
|
||||||
)
|
|
||||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
|
||||||
prev_value: ScheduleValue | None = None
|
|
||||||
for item in soup.select(".channel-schedule .channel-schedule__event"):
|
|
||||||
title = item.select_one(".channel-schedule__title").text.strip()
|
|
||||||
time_str = item.select_one(".channel-schedule__time").text.strip()
|
|
||||||
hours, minutes = map(int, time_str.split(":"))
|
|
||||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
|
||||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
|
||||||
current_day += datetime.timedelta(days=1)
|
|
||||||
item_date += datetime.timedelta(days=1)
|
|
||||||
live = item.select_one(".channel-schedule__info .icon_live") is not None
|
|
||||||
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
|
||||||
values.append(value)
|
|
||||||
if prev_value is not None:
|
|
||||||
prev_value.end = item_date
|
|
||||||
prev_value = value
|
|
||||||
return Schedule(
|
|
||||||
channel=Channel(id=channel_id, name=channel_name), date=date, values=values
|
|
||||||
)
|
)
|
||||||
|
data = await self.source.request(url)
|
||||||
|
return self.channel_parser.parse(data)
|
||||||
|
|
||||||
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
|
endpoint = f"{self.lr}/channel/{channel_id}?date={date:%Y-%m-%d}"
|
||||||
|
data = await self.source.request(endpoint)
|
||||||
|
return self.schedule_parser.parse(ScheduleData(date, channel_id, data))
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import datetime
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bs4 import Tag
|
||||||
|
|
||||||
|
from gallery.sketch.parse.core import ParsedContext, ParseError
|
||||||
|
from gallery.sketch.parse.json import JSONParser
|
||||||
|
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||||
|
|
||||||
|
from ...sketch.parse.html import HTMLContextParser
|
||||||
|
from . import PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScheduleData:
|
||||||
|
date: datetime.date
|
||||||
|
channel_id: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
class YandexTVScheduleParser(HTMLContextParser[ScheduleData, Schedule]):
|
||||||
|
def extract(self, data: ParsedContext[ScheduleData, Tag]) -> Schedule:
|
||||||
|
if data.data.select_one(".CheckboxCaptcha") is not None:
|
||||||
|
raise ParseError("Captcha")
|
||||||
|
values = []
|
||||||
|
channel_name = data.data.select_one(".channel-header__text").text.strip()
|
||||||
|
current_day = datetime.datetime.combine(data.context.date, datetime.datetime.min.time())
|
||||||
|
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||||
|
prev_value: ScheduleValue | None = None
|
||||||
|
for item in data.data.select(".channel-schedule .channel-schedule__event"):
|
||||||
|
title = item.select_one(".channel-schedule__title").text.strip()
|
||||||
|
time_str = item.select_one(".channel-schedule__time").text.strip()
|
||||||
|
hours, minutes = map(int, time_str.split(":"))
|
||||||
|
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||||
|
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||||
|
current_day += datetime.timedelta(days=1)
|
||||||
|
item_date += datetime.timedelta(days=1)
|
||||||
|
live = item.select_one(".channel-schedule__info .icon_live") is not None
|
||||||
|
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||||
|
values.append(value)
|
||||||
|
if prev_value is not None:
|
||||||
|
prev_value.end = item_date
|
||||||
|
prev_value = value
|
||||||
|
return Schedule(
|
||||||
|
channel=Channel(id=data.context.channel_id, name=channel_name, provider=PROVIDER),
|
||||||
|
date=data.context.date,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YandexTVChannelParser(JSONParser[list[Channel]]):
|
||||||
|
def extract(self, data: Any) -> list[Channel]:
|
||||||
|
_, values = data
|
||||||
|
result = []
|
||||||
|
for _, name, content in values:
|
||||||
|
if content["label"] == "Каналы":
|
||||||
|
channel_id = content["url"].split("/")[-1]
|
||||||
|
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
|
||||||
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PROVIDER = "yandex"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from aiocache import cached
|
||||||
|
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
|
from gallery.sketch.weather.api import WeatherApi
|
||||||
|
from gallery.sketch.weather.model import (
|
||||||
|
Location,
|
||||||
|
WeatherResponse,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.util import merge_weather_values
|
||||||
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
|
from . import PROVIDER
|
||||||
|
from .parser import YandexForecastParser, YandexLocationParser
|
||||||
|
|
||||||
|
|
||||||
|
class YandexWeatherApi(WeatherApi):
|
||||||
|
PROVIDER = PROVIDER
|
||||||
|
|
||||||
|
forecast_parser = YandexForecastParser()
|
||||||
|
location_parser = YandexLocationParser()
|
||||||
|
|
||||||
|
def _build_source(self) -> ApiSource:
|
||||||
|
return ApiSource("https://yandex.ru")
|
||||||
|
|
||||||
|
@cached(
|
||||||
|
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||||
|
alias="redis",
|
||||||
|
ttl=TimeUnit.HOUR,
|
||||||
|
)
|
||||||
|
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
|
||||||
|
endpoint = f"pogoda/ru/{location_id}/details"
|
||||||
|
content = await self.source.request(endpoint, mode="browser")
|
||||||
|
return self.forecast_parser.parse(content)
|
||||||
|
|
||||||
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
|
endpoint = f"weather/api/suggest?part={query}&lang=ru&type=weather"
|
||||||
|
response = await self.source.request(endpoint)
|
||||||
|
return self.location_parser.parse(response)
|
||||||
|
|
||||||
|
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||||
|
forecast = await self._get_location_forecast(location_id)
|
||||||
|
for response in forecast:
|
||||||
|
if response.date == date:
|
||||||
|
return response
|
||||||
|
raise ValueError(f"Unsupported date: {date}")
|
||||||
|
|
||||||
|
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||||
|
forecast = await self._get_location_forecast(location_id)
|
||||||
|
values = [merge_weather_values(item.date, item.values) for item in forecast]
|
||||||
|
return WeatherResponse(
|
||||||
|
location=forecast[0].location,
|
||||||
|
date=datetime.date.today(),
|
||||||
|
period="days",
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bs4 import Tag
|
||||||
|
|
||||||
|
from gallery.sketch.parse.core import ParseError
|
||||||
|
from gallery.sketch.parse.date import DATE_PARSER
|
||||||
|
from gallery.sketch.parse.html import (
|
||||||
|
HTMLParser,
|
||||||
|
TableParser,
|
||||||
|
TableValueParser,
|
||||||
|
TagParser,
|
||||||
|
)
|
||||||
|
from gallery.sketch.parse.json import JSONParser
|
||||||
|
from gallery.sketch.weather.model import (
|
||||||
|
Cloudness,
|
||||||
|
Location,
|
||||||
|
Precipitation,
|
||||||
|
Sky,
|
||||||
|
WeatherResponse,
|
||||||
|
WeatherValue,
|
||||||
|
WindDirection,
|
||||||
|
)
|
||||||
|
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
|
||||||
|
|
||||||
|
from . import PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
class LocationNameParser(TagParser[str]):
|
||||||
|
selector = "[class^=Suggest_placeholder]"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> str:
|
||||||
|
return data.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class DateParser(TagParser[datetime.date]):
|
||||||
|
selector = "[class^=AppForecastDayHeader_dayTitle]"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> datetime.date:
|
||||||
|
return DATE_PARSER.parse(data.text.split(",")[-1].strip())
|
||||||
|
|
||||||
|
|
||||||
|
class TimeParser(TableValueParser[datetime.datetime]):
|
||||||
|
selector = "[style$='-part']"
|
||||||
|
model_key = "date"
|
||||||
|
|
||||||
|
HOUR_MAP = {
|
||||||
|
"Утром": 6,
|
||||||
|
"Днём": 12,
|
||||||
|
"Вечером": 18,
|
||||||
|
"Ночью": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> datetime.datetime:
|
||||||
|
hour = self.HOUR_MAP[data.text.strip()]
|
||||||
|
return datetime.datetime.min.replace(hour=hour)
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureParser(TableValueParser[list[int]]):
|
||||||
|
selector = "[style$='-temp']"
|
||||||
|
model_key = "temperature"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[int]:
|
||||||
|
return [int(data.text.strip().rstrip("°"))]
|
||||||
|
|
||||||
|
|
||||||
|
class SkyParser(TableValueParser[Sky]):
|
||||||
|
selector = "[style$='-text']"
|
||||||
|
model_key = "sky"
|
||||||
|
|
||||||
|
CLOUDNESS_MAP = {
|
||||||
|
"ясно": Cloudness.CLEAR,
|
||||||
|
"облачно с прояснениями": Cloudness.CLOUDY,
|
||||||
|
"малооблачно": Cloudness.CLOUDY,
|
||||||
|
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||||
|
}
|
||||||
|
|
||||||
|
PRECIPITATION_MAP = {
|
||||||
|
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||||
|
"дождь": Precipitation.RAIN,
|
||||||
|
"ливень": Precipitation.SHOWER,
|
||||||
|
}
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> Sky:
|
||||||
|
sky = data.text.strip()
|
||||||
|
cloudness = Cloudness.CLEAR
|
||||||
|
precipitation = Precipitation.NO
|
||||||
|
thunder = False
|
||||||
|
fog = False
|
||||||
|
if sky in self.CLOUDNESS_MAP:
|
||||||
|
cloudness = self.CLOUDNESS_MAP[sky]
|
||||||
|
elif sky in self.PRECIPITATION_MAP:
|
||||||
|
precipitation = self.PRECIPITATION_MAP[sky]
|
||||||
|
else:
|
||||||
|
raise ParseError("Unknown sky value", sky)
|
||||||
|
return Sky(
|
||||||
|
cloudness=cloudness,
|
||||||
|
precipitation=precipitation,
|
||||||
|
thunder=thunder,
|
||||||
|
fog=fog,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WindSpeedParser(TableValueParser[int]):
|
||||||
|
selector = "[style$='-wind']:not([class*=AppForecastDayPart_wind_formatted])"
|
||||||
|
model_key = "wind.speed"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> int:
|
||||||
|
return int(data.text.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class WindDirectionParser(TableValueParser[WindDirection | None]):
|
||||||
|
selector = "[style$='-dir']"
|
||||||
|
model_key = "wind.direction"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> WindDirection | None:
|
||||||
|
return WIND_DIRECTION_PARSER.parse(data.text.strip())
|
||||||
|
|
||||||
|
|
||||||
|
class HumidityParser(TableValueParser[int]):
|
||||||
|
selector = "[style$='-hum']"
|
||||||
|
model_key = "humidity"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> int:
|
||||||
|
return int(data.text.strip().rstrip("%"))
|
||||||
|
|
||||||
|
|
||||||
|
class PressureParser(TableValueParser[list[int]]):
|
||||||
|
selector = "[style$='-press']"
|
||||||
|
model_key = "pressure"
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[int]:
|
||||||
|
return [int(data.text.strip().rstrip("%"))]
|
||||||
|
|
||||||
|
|
||||||
|
class YandexTableParser(TableParser[WeatherValue]):
|
||||||
|
selector = ""
|
||||||
|
value_parsers = [
|
||||||
|
TimeParser(),
|
||||||
|
TemperatureParser(),
|
||||||
|
SkyParser(),
|
||||||
|
WindSpeedParser(),
|
||||||
|
WindDirectionParser(),
|
||||||
|
HumidityParser(),
|
||||||
|
PressureParser(),
|
||||||
|
]
|
||||||
|
|
||||||
|
def create_model(self, data: dict) -> WeatherValue:
|
||||||
|
return WeatherValue.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
class YandexForecastParser(HTMLParser[list[WeatherResponse]]):
|
||||||
|
table_parser = YandexTableParser()
|
||||||
|
date_parser = DateParser()
|
||||||
|
location_parser = LocationNameParser()
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[WeatherResponse]:
|
||||||
|
location = self.location_parser.parse(data)
|
||||||
|
articles = data.select("article[class^=AppForecastDay]")
|
||||||
|
return [
|
||||||
|
WeatherResponse(
|
||||||
|
location=location,
|
||||||
|
date=self.date_parser.parse(item),
|
||||||
|
period="day",
|
||||||
|
values=self.table_parser.parse(item),
|
||||||
|
)
|
||||||
|
for item in articles
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class YandexLocationParser(JSONParser[list[Location]]):
|
||||||
|
|
||||||
|
def extract(self, data: Any) -> list[Location]:
|
||||||
|
return [
|
||||||
|
Location(
|
||||||
|
id=str(item["geoId"]),
|
||||||
|
name=item["title"],
|
||||||
|
provider=PROVIDER,
|
||||||
|
lat=item["coords"]["lat"],
|
||||||
|
lon=item["coords"]["lon"],
|
||||||
|
country="ru",
|
||||||
|
country_code="ru",
|
||||||
|
district=item["description"],
|
||||||
|
subdistrict="",
|
||||||
|
)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
+21
-1
@@ -1,12 +1,32 @@
|
|||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from .source import ApiSource
|
||||||
|
|
||||||
class Api:
|
S = TypeVar("S", bound=ApiSource)
|
||||||
|
|
||||||
|
|
||||||
|
class Api[S = ApiSource]:
|
||||||
|
TYPE: str
|
||||||
PROVIDER: str
|
PROVIDER: str
|
||||||
|
|
||||||
|
_source: S | None = None
|
||||||
|
|
||||||
|
def _build_source(self) -> S:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
return self.TYPE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> str:
|
def provider(self) -> str:
|
||||||
return self.PROVIDER
|
return self.PROVIDER
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> S:
|
||||||
|
if self._source is None:
|
||||||
|
self._source = self._build_source()
|
||||||
|
return self._source
|
||||||
|
|
||||||
|
|
||||||
API = TypeVar("API", bound=Api)
|
API = TypeVar("API", bound=Api)
|
||||||
|
|||||||
+14
-23
@@ -1,28 +1,19 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from .api import API, Api
|
from .api import API, Api
|
||||||
from .schedule.api import ScheduleApi
|
|
||||||
from .weather.api import WeatherApi
|
|
||||||
|
|
||||||
|
|
||||||
class ApiBundle(list[Api]):
|
class ApiBundle:
|
||||||
def __init__(self, values: list[Api]) -> None:
|
def __init__(self, values: list[Api]):
|
||||||
super().__init__(values)
|
self._values = values
|
||||||
|
self._providers_by_api = defaultdict(list)
|
||||||
|
self._api_map = {}
|
||||||
|
for value in self._values:
|
||||||
|
self._providers_by_api[value.type].append(value.provider)
|
||||||
|
self._api_map[(value.type, value.provider)] = value
|
||||||
|
|
||||||
def get_api_by_provider(self, provider: str) -> Api:
|
def get_api_providers(self, api_type: type[API]) -> list[str]:
|
||||||
for value in self:
|
return self._providers_by_api[api_type.TYPE]
|
||||||
if value.PROVIDER == provider:
|
|
||||||
return value
|
|
||||||
raise ValueError(provider)
|
|
||||||
|
|
||||||
def get_api_by_type(self, api_type: type[API]) -> API:
|
def get_api(self, api_type: type[API], provider: str) -> API:
|
||||||
for value in self:
|
return self._api_map[(api_type.TYPE, provider)]
|
||||||
if isinstance(value, api_type):
|
|
||||||
return value
|
|
||||||
raise ValueError(api_type)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def weather(self) -> WeatherApi:
|
|
||||||
return self.get_api_by_type(WeatherApi)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def schedule(self) -> ScheduleApi:
|
|
||||||
return self.get_api_by_type(ScheduleApi)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Generic, NamedTuple
|
from typing import Generic, NamedTuple
|
||||||
|
|
||||||
|
from gallery.sketch.source import ApiSource
|
||||||
from gallery.util import TimeUnit
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .api import API, Api
|
from .api import API, Api
|
||||||
@@ -19,6 +20,9 @@ class CachedApi(Api, Generic[API]):
|
|||||||
def __init__(self, api: API):
|
def __init__(self, api: API):
|
||||||
self._api = api
|
self._api = api
|
||||||
|
|
||||||
|
def _build_source(self) -> ApiSource:
|
||||||
|
raise RuntimeError("Source not used in CachedApi")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> str:
|
def provider(self) -> str:
|
||||||
return self._api.provider
|
return self._api.provider
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
from typing import Generic, TypeVar
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogBundle(Generic[T]):
|
|
||||||
def __init__(self, items: list[T]) -> None:
|
|
||||||
self._items_by_id = {item.id: item for item in items}
|
|
||||||
|
|
||||||
def get_item(self, item_id: str) -> T:
|
|
||||||
return self._items_by_id[item_id]
|
|
||||||
|
|
||||||
def select_items(self, ids: list[str]) -> list[T]:
|
|
||||||
return [self._items_by_id[id_] for id_ in ids]
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
DEFAULT_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_ACCEPT = (
|
||||||
|
"text/html,"
|
||||||
|
"application/xhtml+xml,"
|
||||||
|
"application/xml;q=0.9,"
|
||||||
|
"image/avif,"
|
||||||
|
"image/webp,"
|
||||||
|
"image/apng,"
|
||||||
|
"*/*;q=0.8,"
|
||||||
|
"application/signed-exchange;v=b3;q=0.7"
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_HEADERS = {
|
||||||
|
"Accept": DEFAULT_ACCEPT,
|
||||||
|
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||||
|
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
|
||||||
|
"Cache-Control": "max-age=0",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"Host": None,
|
||||||
|
"Referer": None,
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"User-Agent": DEFAULT_USER_AGENT,
|
||||||
|
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Linux"',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers(host: str, *, user_agent: str | None = None) -> dict[str, str]:
|
||||||
|
result = DEFAULT_HEADERS.copy()
|
||||||
|
result["Host"] = host
|
||||||
|
result["Referer"] = f"https://{host}/"
|
||||||
|
if user_agent is not None:
|
||||||
|
result["User-Agent"] = user_agent
|
||||||
|
return result
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Generic, NamedTuple, Protocol, TypeVar
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
D = TypeVar("D")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class Parser[D, R](Protocol):
|
||||||
|
def parse(self, data: D) -> R: ...
|
||||||
|
|
||||||
|
|
||||||
|
P = TypeVar("P")
|
||||||
|
|
||||||
|
|
||||||
|
class PrepareParser(ABC, Parser[D, R], Generic[D, P, R]):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def prepare(self, data: D) -> P: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract(self, data: P) -> R: ...
|
||||||
|
|
||||||
|
def parse(self, data: D) -> R:
|
||||||
|
return self.extract(self.prepare(data))
|
||||||
|
|
||||||
|
|
||||||
|
class Context(Protocol):
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
C = TypeVar("C", bound=Context)
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedContext(NamedTuple, Generic[C, D]):
|
||||||
|
context: C
|
||||||
|
data: D
|
||||||
|
|
||||||
|
|
||||||
|
class ContextParser(PrepareParser[C, ParsedContext[C, P], R], Generic[C, P, R]):
|
||||||
|
@abstractmethod
|
||||||
|
def prepare_context(self, data: str) -> P: ...
|
||||||
|
|
||||||
|
def prepare(self, data: C) -> ParsedContext[C, P]:
|
||||||
|
return ParsedContext(context=data, data=self.prepare_context(data.data))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
import dateparser
|
||||||
|
|
||||||
|
from .core import ParseError, Parser
|
||||||
|
|
||||||
|
|
||||||
|
class DateParser(Parser[str, datetime.datetime]):
|
||||||
|
def parse(self, data: str) -> datetime.datetime:
|
||||||
|
date = dateparser.parse(data, languages=["ru"])
|
||||||
|
if date is None:
|
||||||
|
raise ParseError("Invalid date", data)
|
||||||
|
return date
|
||||||
|
|
||||||
|
|
||||||
|
DATE_PARSER = DateParser()
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from typing import Generic, Iterable
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
|
||||||
|
from .core import C, ContextParser, ParseError, Parser, PrepareParser, R
|
||||||
|
from .util import set_dict_nested_value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_tag(source: Tag, selector: str | None = None) -> Tag:
|
||||||
|
if selector:
|
||||||
|
tag = source.select_one(selector)
|
||||||
|
if tag is None:
|
||||||
|
raise ParseError("Tag not found", selector)
|
||||||
|
return tag
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
class _HTMLParser(Parser[str, BeautifulSoup]):
|
||||||
|
def parse(self, data: str) -> BeautifulSoup:
|
||||||
|
return BeautifulSoup(data, features="html.parser")
|
||||||
|
|
||||||
|
|
||||||
|
HTML_PARSER = _HTMLParser()
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLParser(PrepareParser[str, Tag, R], Generic[R]):
|
||||||
|
selector: str | None = None
|
||||||
|
|
||||||
|
def prepare(self, data: str) -> Tag:
|
||||||
|
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLContextParser(ContextParser[C, Tag, R], Generic[C, R]):
|
||||||
|
selector: str | None = None
|
||||||
|
|
||||||
|
def prepare_context(self, data: str) -> Tag:
|
||||||
|
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
|
||||||
|
|
||||||
|
|
||||||
|
class TagParser(PrepareParser[Tag, Tag, R], Generic[R]):
|
||||||
|
selector: str | None = None
|
||||||
|
|
||||||
|
def prepare(self, data: Tag) -> Tag:
|
||||||
|
return _resolve_tag(data, self.selector)
|
||||||
|
|
||||||
|
|
||||||
|
class TagIterParser(Parser[Tag, Iterable[R]], Generic[R]):
|
||||||
|
selector: str
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> R:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def parse(self, data: Tag) -> Iterable[R]:
|
||||||
|
tags = data.select(self.selector)
|
||||||
|
for tag in tags:
|
||||||
|
yield self.extract(tag)
|
||||||
|
|
||||||
|
|
||||||
|
class TableValueParser(TagIterParser[R], Generic[R]):
|
||||||
|
model_key: str | list[str]
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> R:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class TableParser(TagParser[list[R]], Generic[R]):
|
||||||
|
value_parsers: list[TableValueParser]
|
||||||
|
|
||||||
|
def create_model(self, data: dict) -> R:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def extract(self, data: Tag) -> list[R]:
|
||||||
|
results = []
|
||||||
|
for parser in self.value_parsers:
|
||||||
|
for index, value in enumerate(parser.parse(data)):
|
||||||
|
while index > len(results) - 1:
|
||||||
|
results.append({})
|
||||||
|
if isinstance(parser.model_key, list):
|
||||||
|
for model_key_index, model_key in enumerate(parser.model_key):
|
||||||
|
set_dict_nested_value(results[index], model_key, value[model_key_index])
|
||||||
|
else:
|
||||||
|
set_dict_nested_value(results[index], parser.model_key, value)
|
||||||
|
return [self.create_model(item) for item in results]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Generic
|
||||||
|
|
||||||
|
from .core import C, ContextParser, Parser, PrepareParser, R
|
||||||
|
|
||||||
|
|
||||||
|
class _JSONParser(Parser[str, Any]):
|
||||||
|
def parse(self, data: str) -> Any:
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
|
||||||
|
JSON_PARSER = _JSONParser()
|
||||||
|
|
||||||
|
|
||||||
|
class JSONParser(PrepareParser[str, Any, R], Generic[R]):
|
||||||
|
def prepare(self, data: str) -> Any:
|
||||||
|
return JSON_PARSER.parse(data)
|
||||||
|
|
||||||
|
|
||||||
|
class JSONContextParser(ContextParser[C, Any, R], Generic[C, R]):
|
||||||
|
def prepare_context(self, data: str) -> Any:
|
||||||
|
return JSON_PARSER.parse(data)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def set_nested_attr(target: Any, key: str, value: Any):
|
||||||
|
parts = key.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
target = getattr(target, part)
|
||||||
|
setattr(target, parts[-1], value)
|
||||||
|
|
||||||
|
|
||||||
|
def set_dict_nested_value(target: dict, key: str, value: Any):
|
||||||
|
parts = key.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if part not in target:
|
||||||
|
target[part] = {}
|
||||||
|
target = target[part]
|
||||||
|
target[parts[-1]] = value
|
||||||
@@ -1,28 +1,15 @@
|
|||||||
import asyncio
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from ..api import Api
|
from ..api import Api
|
||||||
from .model import ChannelId, Schedule
|
from ..source import ApiSource
|
||||||
|
from .model import Channel, Schedule
|
||||||
|
|
||||||
|
|
||||||
class ScheduleApi(Api):
|
class ScheduleApi[S = ApiSource](Api[S]):
|
||||||
INTERVAL: float = 0.5
|
TYPE = "schedule"
|
||||||
|
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def get_channel_schedule(
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
self, channel_id: ChannelId, date: datetime.date
|
|
||||||
) -> Schedule:
|
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
|
||||||
channels = await self.get_channels()
|
|
||||||
results = []
|
|
||||||
for channel in channels:
|
|
||||||
results.append(
|
|
||||||
await self.get_channel_schedule(channel_id=channel, date=date)
|
|
||||||
)
|
|
||||||
if self.INTERVAL > 0:
|
|
||||||
await asyncio.sleep(self.INTERVAL)
|
|
||||||
return results
|
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ from gallery.sketch.cached import CachedApi, CachePreset
|
|||||||
from gallery.util import TimeUnit
|
from gallery.util import TimeUnit
|
||||||
|
|
||||||
from .api import ScheduleApi
|
from .api import ScheduleApi
|
||||||
from .model import ChannelId, Schedule
|
from .model import Channel, Schedule
|
||||||
|
|
||||||
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
|
||||||
|
|
||||||
|
|
||||||
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
||||||
CACHE_KEY = "schedule"
|
CACHE_KEY = ScheduleApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",
|
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.find.{query}",
|
||||||
**CACHE_PRESET._asdict(),
|
**CACHE_PRESET._asdict(),
|
||||||
)
|
)
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
return await self._api.get_channels()
|
return await self._api.find_channels(query)
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, channel_id, date: (
|
key_builder=lambda fun, self, channel_id, date: (
|
||||||
@@ -27,16 +27,5 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
|||||||
),
|
),
|
||||||
**CACHE_PRESET._asdict(),
|
**CACHE_PRESET._asdict(),
|
||||||
)
|
)
|
||||||
async def get_channel_schedule(
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
self, channel_id: ChannelId, date: datetime.date
|
return await self._api.get_schedule(channel_id, date)
|
||||||
) -> Schedule:
|
|
||||||
return await self._api.get_channel_schedule(channel_id, date)
|
|
||||||
|
|
||||||
@cached(
|
|
||||||
key_builder=lambda fun, self, date: (
|
|
||||||
f"api.{self.CACHE_KEY}.{self.provider}.all.{date}"
|
|
||||||
),
|
|
||||||
**CACHE_PRESET._asdict(),
|
|
||||||
)
|
|
||||||
async def get_all_schedules(self, date: datetime.date) -> list[Schedule]:
|
|
||||||
return await self._api.get_all_schedules(date)
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from gallery.sketch.catalog import CatalogBundle
|
|
||||||
|
|
||||||
from .model import Channel, ChannelId
|
|
||||||
|
|
||||||
BUNDLE = CatalogBundle(
|
|
||||||
[
|
|
||||||
Channel(id=ChannelId.MATCH_TV, name="Матч ТВ"),
|
|
||||||
Channel(id=ChannelId.MATCH_IGRA, name="Матч! Игра"),
|
|
||||||
Channel(id=ChannelId.MATCH_ARENA, name="Матч! Арена"),
|
|
||||||
Channel(id=ChannelId.MATCH_FUTBOL_1, name="Футбол 1"),
|
|
||||||
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
|
|
||||||
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
|
|
||||||
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
|
|
||||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
|
||||||
Channel(id=ChannelId.MATCH_PLANETA, name="Матч! Планета"),
|
|
||||||
Channel(id=ChannelId.EUROSPORT, name="Europsort"),
|
|
||||||
Channel(id=ChannelId.EUROSPORT_2, name="Europsort 2"),
|
|
||||||
Channel(id=ChannelId.START, name="Старт!"),
|
|
||||||
Channel(id=ChannelId.TEST, name="Тест"),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from enum import StrEnum
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -9,27 +8,10 @@ class Model(BaseModel):
|
|||||||
use_enum_values = True
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class ChannelId(StrEnum):
|
|
||||||
MATCH_TV = "matchtv"
|
|
||||||
MATCH_IGRA = "igra"
|
|
||||||
MATCH_ARENA = "arena"
|
|
||||||
MATCH_FUTBOL_1 = "futbol-1"
|
|
||||||
MATCH_FUTBOL_2 = "futbol-2"
|
|
||||||
MATCH_FUTBOL_3 = "futbol-3"
|
|
||||||
MATCH_STRANA = "strana"
|
|
||||||
MATCH_PLANETA = "planeta"
|
|
||||||
EUROSPORT = "eurosport"
|
|
||||||
EUROSPORT_2 = "eurosport-2"
|
|
||||||
START = "start"
|
|
||||||
TEST = "test"
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.value
|
|
||||||
|
|
||||||
|
|
||||||
class Channel(Model):
|
class Channel(Model):
|
||||||
id: ChannelId
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
provider: str
|
||||||
|
|
||||||
|
|
||||||
class ScheduleValue(Model):
|
class ScheduleValue(Model):
|
||||||
|
|||||||
+47
-13
@@ -1,40 +1,74 @@
|
|||||||
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
logger = logging.getLogger("source")
|
logger = logging.getLogger("source")
|
||||||
|
|
||||||
|
RequestMode = Literal["base", "browser"]
|
||||||
|
|
||||||
|
DEFAULT_REQUEST_MODE: RequestMode = "base"
|
||||||
|
DEFAULT_METHOD: str = "GET"
|
||||||
|
|
||||||
|
|
||||||
class ApiSource:
|
class ApiSource:
|
||||||
DEFAULT_USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/126.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
DEFAULT_TIMEOUT = 30.0
|
DEFAULT_TIMEOUT = 30.0
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url,
|
base_url,
|
||||||
user_agent: str = DEFAULT_USER_AGENT,
|
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
cookies: dict[str, str] | None = None,
|
cookies: dict[str, str] | None = None,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
self._user_agent = user_agent
|
|
||||||
self._timeout = timeout
|
self._timeout = timeout
|
||||||
self._cookies = cookies
|
self._cookies = cookies
|
||||||
self._headers = headers
|
self._headers = headers
|
||||||
|
|
||||||
async def request(self, endpoint: str) -> str:
|
async def _base_request(
|
||||||
url = f"{self._base_url}/{endpoint}"
|
self,
|
||||||
logger.info(url)
|
url: str,
|
||||||
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
|
method: str = DEFAULT_METHOD,
|
||||||
|
data: Any | None = None,
|
||||||
|
) -> str:
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
headers=headers,
|
headers=self._headers,
|
||||||
cookies=self._cookies,
|
cookies=self._cookies,
|
||||||
raise_for_status=True,
|
raise_for_status=True,
|
||||||
) as session:
|
) as session:
|
||||||
async with session.request("GET", url, timeout=self._timeout) as response:
|
async with session.request(method, url, timeout=self._timeout, data=data) as response:
|
||||||
return await response.text()
|
return await response.text()
|
||||||
|
|
||||||
|
# pylint:disable=unused-argument
|
||||||
|
async def _browser_request(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
method: str = DEFAULT_METHOD,
|
||||||
|
data: Any | None = None,
|
||||||
|
) -> str:
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch()
|
||||||
|
page = await browser.new_page()
|
||||||
|
await page.goto(url, timeout=datetime.timedelta(seconds=self._timeout), wait_until="networkidle")
|
||||||
|
content = await page.content()
|
||||||
|
await browser.close()
|
||||||
|
return content
|
||||||
|
|
||||||
|
async def request(
|
||||||
|
self,
|
||||||
|
endpoint: str,
|
||||||
|
/,
|
||||||
|
mode: RequestMode = DEFAULT_REQUEST_MODE,
|
||||||
|
method: str = DEFAULT_METHOD,
|
||||||
|
data: Any | None = None,
|
||||||
|
) -> str:
|
||||||
|
if endpoint.startswith("https:"):
|
||||||
|
url = endpoint
|
||||||
|
else:
|
||||||
|
url = f"{self._base_url}/{endpoint}"
|
||||||
|
logger.info(url)
|
||||||
|
fun = getattr(self, f"_{mode}_request")
|
||||||
|
return await fun(url, method, data)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from ..api import Api
|
from ..api import Api
|
||||||
|
from ..source import ApiSource
|
||||||
from .model import Location, WeatherResponse
|
from .model import Location, WeatherResponse
|
||||||
|
|
||||||
|
|
||||||
class WeatherApi(Api):
|
class WeatherApi[S = ApiSource](Api[S]):
|
||||||
|
TYPE = "weather"
|
||||||
|
|
||||||
async def find_locations(self, query: str) -> list[Location]:
|
async def find_locations(self, query: str) -> list[Location]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ CACHE_PRESET = DEFAULT_CACHE_PRESET
|
|||||||
|
|
||||||
|
|
||||||
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
|
||||||
CACHE_KEY = "weather"
|
CACHE_KEY = WeatherApi.TYPE
|
||||||
|
|
||||||
@cached(
|
@cached(
|
||||||
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from enum import Enum
|
from enum import StrEnum, auto
|
||||||
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class Model(BaseModel):
|
class Model(BaseModel):
|
||||||
@@ -12,110 +13,79 @@ class Model(BaseModel):
|
|||||||
class Location(Model):
|
class Location(Model):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
provider: str
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
country: str
|
country: str
|
||||||
|
country_code: str
|
||||||
district: str
|
district: str
|
||||||
subdistrict: str
|
subdistrict: str
|
||||||
|
|
||||||
|
|
||||||
class Cloudness(str, Enum):
|
class Cloudness(StrEnum):
|
||||||
CLEAR = "clear"
|
CLEAR = auto()
|
||||||
PARTLY_CLOUDY = "party_cloudy"
|
PARTLY_CLOUDY = auto()
|
||||||
CLOUDY = "cloudy"
|
CLOUDY = auto()
|
||||||
MAINLY_CLOUDY = "mainly_cloudy"
|
MAINLY_CLOUDY = auto()
|
||||||
|
|
||||||
|
values: list[Self]
|
||||||
|
|
||||||
|
|
||||||
class Precipitation(str, Enum):
|
Cloudness.values = list(Cloudness)
|
||||||
NO = "no"
|
|
||||||
SMALL_RAIN = "small_rain"
|
|
||||||
RAIN = "rain"
|
class Precipitation(StrEnum):
|
||||||
HEAVY_RAIN = "heavy_rain"
|
NO = auto()
|
||||||
SHOWER = "shower"
|
SMALL_RAIN = auto()
|
||||||
SNOW = "snow"
|
RAIN = auto()
|
||||||
HEAVY_SNOW = "heavy_snow"
|
HEAVY_RAIN = auto()
|
||||||
|
SHOWER = auto()
|
||||||
|
SNOW = auto()
|
||||||
|
HEAVY_SNOW = auto()
|
||||||
|
HAIL = auto()
|
||||||
|
|
||||||
|
values: list[Self]
|
||||||
|
|
||||||
|
|
||||||
|
Precipitation.values = list(Precipitation)
|
||||||
|
|
||||||
|
|
||||||
class Sky(Model):
|
class Sky(Model):
|
||||||
cloudness: Cloudness
|
cloudness: Cloudness = Cloudness.CLEAR
|
||||||
precipitation: Precipitation
|
precipitation: Precipitation = Precipitation.NO
|
||||||
thunder: bool
|
thunder: bool = False
|
||||||
fog: bool
|
fog: bool = False
|
||||||
|
|
||||||
|
|
||||||
class WindDirection(str, Enum):
|
class WindDirection(StrEnum):
|
||||||
CALM = "calm"
|
N = auto()
|
||||||
N = "N"
|
NE = auto()
|
||||||
NE = "NE"
|
E = auto()
|
||||||
E = "E"
|
SE = auto()
|
||||||
SE = "SE"
|
S = auto()
|
||||||
S = "S"
|
SW = auto()
|
||||||
SW = "SW"
|
W = auto()
|
||||||
W = "W"
|
NW = auto()
|
||||||
NW = "NW"
|
|
||||||
|
|
||||||
|
|
||||||
class WindDirectionDeg(float):
|
class Wind(Model):
|
||||||
@property
|
speed: int = 0
|
||||||
def direction(self) -> WindDirection:
|
gust: int = 0
|
||||||
return self.to_direction()
|
direction: WindDirection | None = None
|
||||||
|
|
||||||
@property
|
|
||||||
def value(self) -> float:
|
|
||||||
return self
|
|
||||||
|
|
||||||
# pylint:disable=too-many-return-statements
|
|
||||||
def to_direction(self) -> WindDirection:
|
|
||||||
if self > 337.5 or self <= 22.25:
|
|
||||||
return WindDirection.N
|
|
||||||
elif self <= 67.5:
|
|
||||||
return WindDirection.NE
|
|
||||||
elif self <= 112.5:
|
|
||||||
return WindDirection.E
|
|
||||||
elif self <= 157.5:
|
|
||||||
return WindDirection.SE
|
|
||||||
elif self <= 202.5:
|
|
||||||
return WindDirection.S
|
|
||||||
elif self <= 247.5:
|
|
||||||
return WindDirection.SW
|
|
||||||
elif self <= 292.5:
|
|
||||||
return WindDirection.W
|
|
||||||
elif self <= 337.5:
|
|
||||||
return WindDirection.NW
|
|
||||||
else:
|
|
||||||
return WindDirection.CALM
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg":
|
|
||||||
return cls(
|
|
||||||
{
|
|
||||||
WindDirection.CALM: -1,
|
|
||||||
WindDirection.N: 0,
|
|
||||||
WindDirection.NE: 45,
|
|
||||||
WindDirection.E: 90,
|
|
||||||
WindDirection.SE: 135,
|
|
||||||
WindDirection.S: 180,
|
|
||||||
WindDirection.SW: 225,
|
|
||||||
WindDirection.W: 270,
|
|
||||||
WindDirection.NW: 315,
|
|
||||||
}[direction]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WeatherValue(Model):
|
class WeatherValue(Model):
|
||||||
date: datetime.datetime
|
date: datetime.datetime
|
||||||
sky: Sky
|
sky: Sky = Field(default_factory=Sky)
|
||||||
temperature: list[int]
|
wind: Wind = Field(default_factory=Wind)
|
||||||
wind_speed: int
|
temperature: list[int] = Field(default_factory=list)
|
||||||
wind_gust: int
|
precipitation: float = 0
|
||||||
wind_direction: float
|
pressure: list[int] = Field(default_factory=list)
|
||||||
precipitation: float
|
humidity: int = -1
|
||||||
pressure: list[int]
|
|
||||||
humidity: int
|
|
||||||
|
|
||||||
|
|
||||||
class WeatherResponse(Model):
|
class WeatherResponse(Model):
|
||||||
location: str
|
location: str
|
||||||
date: datetime.date
|
date: datetime.date
|
||||||
period: str
|
period: Literal["day", "days"] = "day"
|
||||||
values: list[WeatherValue]
|
values: list[WeatherValue]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from gallery.sketch.parse.core import Parser
|
||||||
|
|
||||||
|
from ..model import WindDirection
|
||||||
|
|
||||||
|
|
||||||
|
class WindDirectionParser(Parser[str | int | float, WindDirection | None]):
|
||||||
|
WIND_DIRECTION_MAP: dict[str, WindDirection | None] = {
|
||||||
|
"с": WindDirection.N,
|
||||||
|
"св": WindDirection.NE,
|
||||||
|
"в": WindDirection.E,
|
||||||
|
"юв": WindDirection.SE,
|
||||||
|
"ю": WindDirection.S,
|
||||||
|
"юз": WindDirection.SW,
|
||||||
|
"з": WindDirection.W,
|
||||||
|
"сз": WindDirection.NW,
|
||||||
|
}
|
||||||
|
|
||||||
|
# pylint:disable=too-many-return-statements
|
||||||
|
def _parse_number(self, value: int | float) -> WindDirection | None:
|
||||||
|
if value > 337.5 or value <= 22.25:
|
||||||
|
return WindDirection.N
|
||||||
|
elif value <= 67.5:
|
||||||
|
return WindDirection.NE
|
||||||
|
elif value <= 112.5:
|
||||||
|
return WindDirection.E
|
||||||
|
elif value <= 157.5:
|
||||||
|
return WindDirection.SE
|
||||||
|
elif value <= 202.5:
|
||||||
|
return WindDirection.S
|
||||||
|
elif value <= 247.5:
|
||||||
|
return WindDirection.SW
|
||||||
|
elif value <= 292.5:
|
||||||
|
return WindDirection.W
|
||||||
|
elif value <= 337.5:
|
||||||
|
return WindDirection.NW
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_str(self, value: str) -> WindDirection | None:
|
||||||
|
return self.WIND_DIRECTION_MAP.get(value.replace("-", "").lower())
|
||||||
|
|
||||||
|
def parse(self, data: str | int | float) -> WindDirection | None:
|
||||||
|
if isinstance(data, str):
|
||||||
|
return self._parse_str(data)
|
||||||
|
else:
|
||||||
|
return self._parse_number(data)
|
||||||
|
|
||||||
|
|
||||||
|
WIND_DIRECTION_PARSER = WindDirectionParser()
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import statistics
|
import statistics
|
||||||
|
from typing import Counter, Literal, overload
|
||||||
|
|
||||||
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
|
from .model import Cloudness, Precipitation, WeatherResponse, WeatherValue
|
||||||
|
|
||||||
|
MergeMode = Literal["interval", "average"]
|
||||||
|
|
||||||
|
|
||||||
def build_weather_value(date: datetime.datetime) -> WeatherValue:
|
def _merge_values(values: list[int], mode: MergeMode) -> list[int]:
|
||||||
return WeatherValue(
|
if mode == "interval":
|
||||||
date=date,
|
if (max_value := max(values)) == (min_value := min(values)):
|
||||||
sky=Sky(
|
return [max_value]
|
||||||
cloudness=Cloudness.CLEAR,
|
else:
|
||||||
precipitation=Precipitation.NO,
|
return [max_value, min_value]
|
||||||
thunder=False,
|
else:
|
||||||
fog=False,
|
return [round(sum(values) / len(values))]
|
||||||
),
|
|
||||||
temperature=[],
|
|
||||||
wind_speed=0,
|
|
||||||
wind_gust=0,
|
|
||||||
wind_direction=WindDirectionDeg(-1),
|
|
||||||
precipitation=0,
|
|
||||||
pressure=[],
|
|
||||||
humidity=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_weather_values(
|
def merge_weather_values(
|
||||||
date: datetime.datetime, values: list[WeatherValue]
|
date: datetime.datetime,
|
||||||
|
values: list[WeatherValue],
|
||||||
|
mode: MergeMode = "interval",
|
||||||
) -> WeatherValue:
|
) -> WeatherValue:
|
||||||
result = build_weather_value(date)
|
result = WeatherValue(date=date)
|
||||||
temperatures = []
|
temperatures = []
|
||||||
pressures = []
|
pressures = []
|
||||||
humidities = []
|
humidities = []
|
||||||
@@ -40,25 +36,43 @@ def merge_weather_values(
|
|||||||
temperatures += value.temperature
|
temperatures += value.temperature
|
||||||
pressures += value.pressure
|
pressures += value.pressure
|
||||||
humidities.append(value.humidity)
|
humidities.append(value.humidity)
|
||||||
wind_speeds.append(value.wind_speed)
|
wind_speeds.append(value.wind.speed)
|
||||||
wind_gusts.append(value.wind_gust)
|
wind_gusts.append(value.wind.gust)
|
||||||
wind_directions.append(value.wind_direction)
|
wind_directions.append(value.wind.direction)
|
||||||
cloudnesses.append(value.sky.cloudness)
|
cloudnesses.append(value.sky.cloudness)
|
||||||
precipitations.append(value.sky.precipitation)
|
precipitations.append(value.sky.precipitation)
|
||||||
precipitation += value.precipitation
|
precipitation += value.precipitation
|
||||||
result.temperature = [max(temperatures), min(temperatures)]
|
result.temperature = _merge_values(temperatures, mode)
|
||||||
result.pressure = [max(pressures), min(pressures)]
|
result.pressure = _merge_values(pressures, mode)
|
||||||
result.humidity = round(statistics.mean(humidities))
|
result.humidity = round(statistics.mean(humidities))
|
||||||
result.wind_speed = round(statistics.mean(wind_speeds))
|
result.wind.speed = round(statistics.mean(wind_speeds))
|
||||||
result.wind_gust = round(statistics.mean(wind_gusts))
|
result.wind.gust = round(statistics.mean(wind_gusts))
|
||||||
result.wind_direction = statistics.mean(wind_directions)
|
result.wind.direction = Counter(wind_directions).most_common(1)[0][0]
|
||||||
# TODO: merge cloudnesses
|
|
||||||
for item in cloudnesses:
|
for item in cloudnesses:
|
||||||
if item != Cloudness.CLEAR:
|
# TODO: Instance of 'FieldInfo' has no 'cloudness' member (no-member)
|
||||||
|
# pylint:disable=no-member
|
||||||
|
if Cloudness.values.index(item) > Cloudness.values.index(result.sky.cloudness):
|
||||||
result.sky.cloudness = item
|
result.sky.cloudness = item
|
||||||
# TODO: merge precipitations
|
|
||||||
for item in precipitations:
|
for item in precipitations:
|
||||||
if item != Precipitation.NO:
|
# TODO: Instance of 'FieldInfo' has no 'precipitation' member (no-member)
|
||||||
|
# pylint:disable=no-member
|
||||||
|
if Precipitation.values.index(item) > Precipitation.values.index(result.sky.precipitation):
|
||||||
result.sky.precipitation = item
|
result.sky.precipitation = item
|
||||||
result.precipitation = precipitation
|
result.precipitation = precipitation
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def convert_hPa_to_mmHg(value: int) -> int:
|
||||||
|
return round(value / 1.333)
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def fix_dates_sequence(items: list[WeatherValue]): ...
|
||||||
|
@overload
|
||||||
|
def fix_dates_sequence(items: list[WeatherResponse]): ...
|
||||||
|
def fix_dates_sequence(items):
|
||||||
|
date = None
|
||||||
|
for item in items:
|
||||||
|
if date and (item.date - date).total_seconds() < 0:
|
||||||
|
item.date = date + datetime.timedelta(days=1)
|
||||||
|
date = item.date
|
||||||
|
|||||||
+4
-1
@@ -1 +1,4 @@
|
|||||||
__version__ = "0.2.0"
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["project"]["version"]
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: Gallery\n"
|
||||||
|
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
|
||||||
|
"Language: ru\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
|
|
||||||
|
msgid "Index"
|
||||||
|
msgstr "Содержание"
|
||||||
|
|
||||||
|
msgid "View"
|
||||||
|
msgstr "Просмотр"
|
||||||
|
|
||||||
|
msgid "Docs"
|
||||||
|
msgstr "Документация"
|
||||||
|
|
||||||
|
msgid "Toggle theme"
|
||||||
|
msgstr "Переключить тему"
|
||||||
|
|
||||||
|
msgid "Light"
|
||||||
|
msgstr "Светлая"
|
||||||
|
|
||||||
|
msgid "Dark"
|
||||||
|
msgstr "Тёмная"
|
||||||
|
|
||||||
|
msgid "Auto"
|
||||||
|
msgstr "Авто"
|
||||||
|
|
||||||
|
msgid "Select language"
|
||||||
|
msgstr "Выберите язык"
|
||||||
|
|
||||||
|
msgid "English"
|
||||||
|
msgstr "Английский"
|
||||||
|
|
||||||
|
msgid "Russian"
|
||||||
|
msgstr "Русский"
|
||||||
|
|
||||||
|
# weather
|
||||||
|
msgid "Weather"
|
||||||
|
msgstr "Погода"
|
||||||
|
|
||||||
|
msgid "Enter the city name"
|
||||||
|
msgstr "Введите название города"
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Поиск"
|
||||||
|
|
||||||
|
msgid "Cloudiness"
|
||||||
|
msgstr "Облачность"
|
||||||
|
|
||||||
|
msgid "Temperature, °C"
|
||||||
|
msgstr "Температура, °C"
|
||||||
|
|
||||||
|
msgid "Wind direction"
|
||||||
|
msgstr "Направление ветра"
|
||||||
|
|
||||||
|
msgid "Wind speed, m/s"
|
||||||
|
msgstr "Скорость ветра, м/с"
|
||||||
|
|
||||||
|
msgid "Precipitation, mm"
|
||||||
|
msgstr "Осадки, мм"
|
||||||
|
|
||||||
|
msgid "Pressure, mmHg"
|
||||||
|
msgstr "Давление, мм рт. ст."
|
||||||
|
|
||||||
|
msgid "Humidity, %"
|
||||||
|
msgstr "Влажность, %"
|
||||||
|
|
||||||
|
msgid "Stored locations"
|
||||||
|
msgstr "Сохранённые локации"
|
||||||
|
|
||||||
|
# tv
|
||||||
|
msgid "TV program"
|
||||||
|
msgstr "Телепрограмма"
|
||||||
|
|
||||||
|
msgid "Live broadcasts"
|
||||||
|
msgstr "Прямые трансляции"
|
||||||
|
|
||||||
|
msgid "Stored channels"
|
||||||
|
msgstr "Сохранённые каналы"
|
||||||
|
|
||||||
|
msgid "Channel groups"
|
||||||
|
msgstr "Группы каналов"
|
||||||
Generated
+2611
-1227
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user