Compare commits
105
Commits
0.3.2
..
b0187506fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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,20 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
#needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
- uses: ./.gitea/actions/setup
|
||||||
|
- 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
|
||||||
|
|||||||
+15
-5
@@ -1,4 +1,4 @@
|
|||||||
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 && \
|
RUN apt update && \
|
||||||
@@ -9,7 +9,7 @@ COPY pyproject.toml poetry.lock README.md ./
|
|||||||
RUN poetry config virtualenvs.in-project true
|
RUN poetry config virtualenvs.in-project true
|
||||||
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
|
||||||
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
|
||||||
poetry install --with app --no-root
|
poetry install --extras app --no-root
|
||||||
COPY locales ./locales
|
COPY locales ./locales
|
||||||
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
|
||||||
|
|
||||||
@@ -22,14 +22,24 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
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 && \
|
||||||
|
playwright install --with-deps chromium
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV TZ="Europe/Moscow"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV TZ="Europe/Moscow"
|
|
||||||
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/
|
||||||
COPY --from=builder --parents locales/**/*.mo ./
|
# 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,13 +1,75 @@
|
|||||||
# API Gallery
|
# API Gallery
|
||||||
|
|
||||||
Weather and TV program API
|
Currently implemented APIs:
|
||||||
|
|
||||||

|
- Weather forecast
|
||||||
|
- TV program
|
||||||
|
|
||||||
## View
|
## 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:
|
||||||
|
|||||||
@@ -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": ".",
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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, view
|
from .route import api, view
|
||||||
|
|
||||||
|
|
||||||
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||||
@@ -15,7 +16,12 @@ def build_app(api_bundle: ApiBundle) -> 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
|
||||||
app.include_router(api.router)
|
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,7 +1,21 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from gallery.easel.route import doc
|
||||||
|
|
||||||
from . import schedule, weather
|
from . import schedule, weather
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["API"])
|
app = FastAPI(
|
||||||
router.include_router(weather.router)
|
docs_url=None,
|
||||||
router.include_router(schedule.router)
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
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 APIRouter
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
router = APIRouter(prefix="/schedule")
|
from ...depends.date import DateDepends
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/schedule", tags=["Schedule"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/channels")
|
@router.get("/groups")
|
||||||
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
|
async def get_api_schedule_groups():
|
||||||
schedule_api = request.app.state.api.schedule
|
return list(GROUP_CANNELS_MAP.keys())
|
||||||
return await schedule_api.get_channels()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{channel}/{date}")
|
@router.get("/group/{group_id}/{date}")
|
||||||
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
|
async def get_api_schedule_group_schedule(
|
||||||
schedule_api = request.app.state.api.schedule
|
request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends
|
||||||
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
|
) -> 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(
|
||||||
|
schedule_api: ScheduleApiDepends, channel: str, date: DateDepends
|
||||||
|
) -> Schedule:
|
||||||
|
return await schedule_api.get_schedule(channel, date)
|
||||||
|
|||||||
@@ -1,26 +1,30 @@
|
|||||||
import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
router = APIRouter(prefix="/weather")
|
from ...depends.date import DateDepends
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/weather", tags=["Weather"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/locations")
|
@router.get("/providers")
|
||||||
async def get_api_weather_locations(request: AppRequest, query: str) -> 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)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{location}/day/{date}")
|
@router.get("/{provider}/{location}/day/{date}")
|
||||||
async def get_api_weather_day(request: AppRequest, location: str, date: datetime.date) -> WeatherResponse:
|
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: DateDepends) -> WeatherResponse:
|
||||||
weather_api = request.app.state.api.weather
|
|
||||||
return await weather_api.get_day(location, date)
|
return await weather_api.get_day(location, date)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{location}/days/{days}")
|
@router.get("/{provider}/{location}/days/{days}")
|
||||||
async def get_api_weather_days(request: AppRequest, location: str, days: int) -> WeatherResponse:
|
async def get_api_weather_days(weather_api: WeatherApiDepends, location: str, days: int) -> 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(tags=["view"], 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,36 +0,0 @@
|
|||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
async def get_section_list(request: Request):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="root_index.html",
|
|
||||||
context={
|
|
||||||
"sections": SECTIONS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<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">
|
||||||
@@ -23,16 +23,12 @@
|
|||||||
<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">
|
||||||
{% if not is_widget %}
|
{% if not is_widget %}
|
||||||
<header class="app-header pb-3 mb-5 border-bottom">
|
<header class="app-header pb-3 mb-5 border-bottom">
|
||||||
<div>{{request.query_params.widget}}</div>
|
|
||||||
<div class="link-list">
|
<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>
|
</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 me-2">
|
|
||||||
<span class="nav-link">{{ version }}</span>
|
|
||||||
</li>
|
|
||||||
<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"
|
||||||
id="bd-language"
|
id="bd-language"
|
||||||
@@ -115,8 +111,11 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
{% if not is_widget %}
|
{% if not is_widget %}
|
||||||
<footer class="pt-5 my-5 text-muted border-top">
|
<footer class="app-footer pt-5 my-5 text-muted border-top">
|
||||||
Created by shmyga · © 2026
|
<div class="d-flex justify-content-between">
|
||||||
|
<span>Created by shmyga · © 2026</span>
|
||||||
|
<span>v{{ version }}</span>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import typing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||||
|
|
||||||
from babel.dates import format_date
|
from babel.dates import format_date
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from gallery.easel.core import AppRequest
|
||||||
from gallery.version import __version__
|
from gallery.version import __version__
|
||||||
|
|
||||||
from ...translation import _
|
from ...translation import _
|
||||||
from .tag import TagUtil
|
|
||||||
|
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
|
||||||
|
|
||||||
|
|
||||||
def is_widget(request: Request) -> bool:
|
def is_widget(request: Request) -> bool:
|
||||||
@@ -17,25 +21,51 @@ def is_widget(request: Request) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def context_processor(request: Request) -> dict:
|
def base_context_processor(request: Request) -> dict:
|
||||||
return {
|
return {
|
||||||
"is_widget": is_widget(request),
|
"is_widget": is_widget(request),
|
||||||
|
"provider": request.query_params.get("provider") or request.path_params.get("provider"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_templates(templates_dir: Path | None = None, filters: dict | None = None) -> Jinja2Templates:
|
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"]
|
directory = [Path(__file__).parent.parent / "templates"]
|
||||||
if templates_dir:
|
if templates_dir:
|
||||||
directory.append(templates_dir)
|
directory.append(templates_dir)
|
||||||
templates = Jinja2Templates(directory=directory, context_processors=[context_processor])
|
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(
|
templates.env.globals.update(
|
||||||
{
|
{
|
||||||
"_": _,
|
"_": _,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"format_date": format_date,
|
"format_date": format_date,
|
||||||
"datetime": datetime,
|
"datetime": datetime,
|
||||||
"tag_util": TagUtil,
|
|
||||||
"DATE_FORMAT": "E, d MMMM Y",
|
"DATE_FORMAT": "E, d MMMM Y",
|
||||||
|
"build_url": build_url,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if filters:
|
if filters:
|
||||||
|
|||||||
@@ -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,77 +1 @@
|
|||||||
import datetime
|
from .route import router
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
|
||||||
from gallery.sketch.schedule.catalog import BUNDLE
|
|
||||||
|
|
||||||
from ..common.utils.tag import TagType, TagUtil
|
|
||||||
from ..common.utils.template import build_templates
|
|
||||||
from .filters import timedelta_format
|
|
||||||
|
|
||||||
templates = build_templates(
|
|
||||||
Path(__file__).parent / "templates",
|
|
||||||
{
|
|
||||||
"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={
|
|
||||||
"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={
|
|
||||||
"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={
|
|
||||||
"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,4 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% set live = request.query_params.live == '1' %}
|
||||||
|
{% set date = response.date %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||||
locale=request.state.language)}}
|
locale=request.state.language)}}
|
||||||
@@ -10,18 +13,23 @@ locale=request.state.language)}}
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4 class="mb-4">
|
||||||
<a class="icon-link {{'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)}}">
|
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
|
||||||
<i class="bi bi-arrow-left-square"></i>
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
</a>
|
</a>
|
||||||
<a class="icon-link"
|
<a class="icon-link"
|
||||||
href="../..">
|
href="{{build_url(request, up=3)}}">
|
||||||
<i class="bi bi-arrow-up-square"></i>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
</a>
|
</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>
|
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
<a class="icon-link"
|
<a class="icon-link"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
|
||||||
<i class="bi bi-arrow-right-square"></i>
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
</a>
|
</a>
|
||||||
</h4>
|
</h4>
|
||||||
@@ -29,15 +37,15 @@ locale=request.state.language)}}
|
|||||||
<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,59 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
|
|
||||||
locale=request.state.language)}}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<app-link href="/schedule"
|
|
||||||
icon="tv">{{_("TV program")}}</app-link>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h4>
|
|
||||||
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
|
||||||
<i class="bi bi-arrow-left-square"></i>
|
|
||||||
</a>
|
|
||||||
<a class="icon-link"
|
|
||||||
href="..">
|
|
||||||
<i class="bi bi-arrow-up-square"></i>
|
|
||||||
</a>
|
|
||||||
<span>{{_("Live broadcasts") if live else _("TV program")}} | {{format_date(response.date, DATE_FORMAT,
|
|
||||||
locale=request.state.language)}}</span>
|
|
||||||
<a class="icon-link"
|
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
|
||||||
<i class="bi bi-arrow-right-square"></i>
|
|
||||||
</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>
|
|
||||||
</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,78 +1 @@
|
|||||||
import datetime
|
from .route import router
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
||||||
|
|
||||||
from gallery.easel.core import AppRequest
|
|
||||||
from gallery.sketch.weather.model import WeatherResponse
|
|
||||||
|
|
||||||
from ..common.utils.tag import TagType, TagUtil
|
|
||||||
from ..common.utils.template import build_templates
|
|
||||||
from .filters import cloudness_icon, wind_direction_icon
|
|
||||||
|
|
||||||
templates = build_templates(
|
|
||||||
Path(__file__).parent / "templates",
|
|
||||||
{
|
|
||||||
"wind_direction_icon": wind_direction_icon,
|
|
||||||
"cloudness_icon": cloudness_icon,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_weather_response(request: AppRequest, response: WeatherResponse):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request=request,
|
|
||||||
name="weather.html",
|
|
||||||
context={
|
|
||||||
"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={
|
|
||||||
"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,20 +1,21 @@
|
|||||||
import datetime
|
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:
|
||||||
if wind_direction == WindDirection.CALM:
|
|
||||||
return "wind-calm"
|
return "wind-calm"
|
||||||
else:
|
else:
|
||||||
return f"wind-from-{wind_direction.name.lower()}"
|
return f"wind-from-{wind_direction.lower()}"
|
||||||
|
|
||||||
|
|
||||||
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
||||||
@@ -48,8 +49,31 @@ def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
|
|||||||
Precipitation.SHOWER: "rain",
|
Precipitation.SHOWER: "rain",
|
||||||
Precipitation.SNOW: "snow",
|
Precipitation.SNOW: "snow",
|
||||||
Precipitation.HEAVY_SNOW: "snow",
|
Precipitation.HEAVY_SNOW: "snow",
|
||||||
|
Precipitation.HAIL: "hail",
|
||||||
}[sky.precipitation]
|
}[sky.precipitation]
|
||||||
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
|
||||||
main_icon = f"{day_prefix}-{main_icon}"
|
main_icon = f"{day_prefix}-{main_icon}"
|
||||||
icons = [main_icon]
|
icons = [main_icon]
|
||||||
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)
|
||||||
@@ -12,63 +12,52 @@
|
|||||||
id="query"
|
id="query"
|
||||||
name="query"
|
name="query"
|
||||||
placeholder="{{_('Enter the city name')}}">
|
placeholder="{{_('Enter the city name')}}">
|
||||||
|
<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 %}
|
||||||
|
</ul>
|
||||||
<button class="btn btn-primary"
|
<button class="btn btn-primary"
|
||||||
type="submit">{{_("Search")}}</button>
|
type="submit">{{_("Search")}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<ul id="locations"
|
{% if locations %}
|
||||||
class="list-group mb-5">
|
<div class="mb-4">
|
||||||
{% for location in locations %}
|
<weather-location-list value="{{locations | tojson | forceescape}}"
|
||||||
<a href="weather/{{location.id}}"
|
onclick="weatherLocationManager.store(element)"
|
||||||
class="list-group-item list-group-item-action px-4"
|
onadd="weatherLocationManager.add(element)"></weather-location-list>
|
||||||
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
|
<hr>
|
||||||
<span class="fi fi-{{location.country_code}} me-1"></span>
|
</div>
|
||||||
<span class="text-primary">{{location.name}}</span>
|
{% endif %}
|
||||||
<span class="small ms-1 text-secondary">
|
<div class="mb-4">
|
||||||
{{location.country}}, {{location.district}}, {{location.subdistrict}}
|
<h3>{{_("Stored locations")}}</h3>
|
||||||
</span>
|
<weather-location-list id="storedLocations"
|
||||||
<span></span>
|
onremove="weatherLocationManager.remove(element)"></weather-location-list>
|
||||||
</a>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<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,5 +1,8 @@
|
|||||||
{% 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 %}
|
||||||
<app-link href="/weather"
|
<app-link href="/weather"
|
||||||
@@ -9,21 +12,23 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h4>
|
<h4>
|
||||||
{% if response.period == 'day' %}
|
{% if response.period == 'day' %}
|
||||||
<a class="icon-link {{'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)}}">
|
href="{{build_url(request, path=['..', date - datetime.timedelta(days=1)])}}">
|
||||||
<i class="bi bi-arrow-left-square"></i>
|
<i class="bi bi-arrow-left-square"></i>
|
||||||
</a>
|
</a>
|
||||||
<a class="icon-link"
|
<a class="icon-link"
|
||||||
href="../tag/days-10">
|
href="{{build_url(request, path=['..', '..', 'days', '10'])}}">
|
||||||
<i class="bi bi-arrow-up-square"></i>
|
<i class="bi bi-arrow-up-square"></i>
|
||||||
</a>
|
</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>
|
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||||
<a class="icon-link"
|
<a class="icon-link"
|
||||||
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">
|
href="{{build_url(request, path=['..', date + datetime.timedelta(days=1)])}}">
|
||||||
<i class="bi bi-arrow-right-square"></i>
|
<i class="bi bi-arrow-right-square"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if response.period == 'days' %}
|
{% if response.period == 'days' %}
|
||||||
|
<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>{{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,
|
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
|
||||||
locale=request.state.language)}}</span>
|
locale=request.state.language)}}</span>
|
||||||
@@ -45,7 +50,7 @@
|
|||||||
{% 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 {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
|
<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()])}}">
|
||||||
{{format_date(value.date, 'E d', locale=request.state.language)}}
|
{{format_date(value.date, 'E d', locale=request.state.language)}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -66,7 +71,8 @@
|
|||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{{ value.sky }}">
|
data-bs-title="{{ value.sky }}">
|
||||||
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
|
||||||
<div class="wi wi-{{icon}} wi-xl text-primary"></div>
|
<div class="wi-svg wi-l wi-border"
|
||||||
|
style="margin: 0 auto;">{{ icon | weather_icon_svg }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -101,8 +107,9 @@
|
|||||||
{% for value in response.values %}
|
{% for value in response.values %}
|
||||||
<td class="wind"
|
<td class="wind"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{{ value.wind_direction }}">
|
data-bs-title="{{ value.wind.direction }}">
|
||||||
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
|
<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>
|
||||||
@@ -116,11 +123,11 @@
|
|||||||
<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>
|
||||||
@@ -137,7 +144,7 @@
|
|||||||
{% 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>
|
||||||
@@ -170,7 +177,7 @@
|
|||||||
{% 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,113 +1,55 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
from os import environ
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
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"],
|
|
||||||
country_code=item["country"]["code"].lower(),
|
|
||||||
district=item["translations"]["kk"]["district"]["name"],
|
|
||||||
subdistrict=(
|
|
||||||
item["translations"]["kk"]["subdistrict"]["name"]
|
|
||||||
if "subdistrict" in item["translations"]["kk"]
|
|
||||||
else ""
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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:
|
||||||
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
||||||
if date > max_date:
|
if date > max_date:
|
||||||
raise HTTPException(
|
raise ValueError(f"Invalid date: {date}, max: {max_date}")
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")}
|
data = await self.source.request(f"weather-{location_id}/{format_date(date)}")
|
||||||
)
|
context = ForecastData(date, "day", data, 8)
|
||||||
data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
|
return self.forecast_parser.parse(context)
|
||||||
return self._parse_oneday(date, data)
|
|
||||||
|
|
||||||
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,31 +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"
|
|
||||||
+147
-101
@@ -1,59 +1,59 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
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
|
||||||
|
|
||||||
logger = logging.getLogger("gismeteo")
|
|
||||||
|
|
||||||
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_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
|
return DATE_PARSER.parse(data.text)
|
||||||
if datetime_time_row:
|
|
||||||
for item in datetime_time_row.select(".row-item > time-value"):
|
|
||||||
timestamp = int(item.attrs["timestamp"])
|
|
||||||
time = datetime.datetime.fromtimestamp(timestamp)
|
|
||||||
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,
|
||||||
@@ -65,10 +65,10 @@ class SkyParser(RowParser[Sky]):
|
|||||||
|
|
||||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||||
"без осадков": Precipitation.NO,
|
"без осадков": Precipitation.NO,
|
||||||
"небольшой дождь": Precipitation.SMALL_RAIN, # TODO: remove it?
|
"без существенных осадков": Precipitation.NO,
|
||||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||||
"сильный дождь": Precipitation.HEAVY_RAIN, # TODO: remove it?
|
|
||||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
|
"очень сильный дождь": Precipitation.HEAVY_RAIN,
|
||||||
"ливневый дождь": Precipitation.SHOWER,
|
"ливневый дождь": Precipitation.SHOWER,
|
||||||
"дождь": Precipitation.RAIN,
|
"дождь": Precipitation.RAIN,
|
||||||
"ливень": Precipitation.SHOWER,
|
"ливень": Precipitation.SHOWER,
|
||||||
@@ -80,25 +80,24 @@ class SkyParser(RowParser[Sky]):
|
|||||||
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
"сильный снег с дождём": Precipitation.HEAVY_SNOW,
|
||||||
"небольшой снег с дождём": Precipitation.SNOW,
|
"небольшой снег с дождём": Precipitation.SNOW,
|
||||||
"небольшой мокрый снег": Precipitation.SNOW,
|
"небольшой мокрый снег": Precipitation.SNOW,
|
||||||
|
"град": Precipitation.HAIL,
|
||||||
}
|
}
|
||||||
|
|
||||||
THUNDER = "гроза"
|
THUNDER = {"гроза"}
|
||||||
FOG = "дымка"
|
FOG = {"дымка", "туман"}
|
||||||
|
|
||||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
@classmethod
|
||||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
def _detect_flag(cls, values: set[str], flag_values: set[str]) -> tuple[set[str], bool]:
|
||||||
sky_str = item.attrs["data-tooltip"]
|
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 = False
|
values, thunder = self._detect_flag(values, self.THUNDER)
|
||||||
fog = False
|
values, fog = self._detect_flag(values, self.FOG)
|
||||||
if self.THUNDER in values:
|
|
||||||
thunder = True
|
|
||||||
values.remove(self.THUNDER)
|
|
||||||
if self.FOG in values:
|
|
||||||
fog = True
|
|
||||||
values.remove(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
|
||||||
@@ -110,8 +109,8 @@ class SkyParser(RowParser[Sky]):
|
|||||||
values.remove(k)
|
values.remove(k)
|
||||||
break
|
break
|
||||||
if values:
|
if values:
|
||||||
logger.warning("unknown sky values: %s:", values)
|
raise ParseError("Unknown sky values", values)
|
||||||
yield Sky(
|
return Sky(
|
||||||
cloudness=cloudness,
|
cloudness=cloudness,
|
||||||
precipitation=precipitation,
|
precipitation=precipitation,
|
||||||
thunder=thunder,
|
thunder=thunder,
|
||||||
@@ -119,80 +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(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
|
return [int(value.attrs["value"]) for value in data.select("temperature-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(".widget-row-wind > .row-item > .wind-speed > speed-value"):
|
value = data.select_one("speed-value")
|
||||||
yield int(item.attrs["value"])
|
return int(value.attrs["value"]) if value else 0
|
||||||
|
|
||||||
|
|
||||||
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(".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"):
|
value = data.select_one("precipitation-value")
|
||||||
yield float(item.text.replace(",", "."))
|
return float(value.attrs["value"]) if value else 0
|
||||||
|
|
||||||
|
|
||||||
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(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
|
return [int(value.attrs["value"]) for value in data.select("pressure-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(),
|
||||||
@@ -204,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,53 +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,
|
||||||
|
MatchTVChannelParser,
|
||||||
|
MatchTVScheduleParser,
|
||||||
|
ScheduleData,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MatchTvApi(ScheduleApi):
|
class MatchTVApi(ScheduleApi):
|
||||||
PROVIDER = "matchtv"
|
PROVIDER = PROVIDER
|
||||||
SOURCE = ApiSource("https://matchtv.ru")
|
|
||||||
|
|
||||||
async def get_channels(self) -> list[ChannelId]:
|
schedule_parser = MatchTVScheduleParser()
|
||||||
return [
|
channel_parser = MatchTVChannelParser()
|
||||||
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:
|
def _build_source(self) -> ApiSource:
|
||||||
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}"
|
return ApiSource("https://matchtv.ru")
|
||||||
data = await self.SOURCE.request(endpoint)
|
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
values = []
|
endpoint = "api/v1/channels"
|
||||||
channel_name = soup.select_one(".p-tv-guide-header__title").text.replace("Телепрограмма ", "").strip()
|
data = await self.source.request(endpoint)
|
||||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
return self.channel_parser.parse(ChannelData(query, data))
|
||||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
|
||||||
prev_value: ScheduleValue | None = None
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
for item in soup.select(
|
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
|
||||||
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission"
|
data = await self.source.request(endpoint)
|
||||||
):
|
return self.schedule_parser.parse(ScheduleData(date, data))
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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,55 +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)
|
values = [
|
||||||
for item in data.list:
|
merge_weather_values(datetime.datetime.combine(k, datetime.datetime.min.time()), list(g))
|
||||||
value = FORECAST_ITEM_PARSER.parse(item)
|
for k, g in groupby(
|
||||||
item_date = value.date.replace(hour=0, minute=0)
|
sorted(await self._get_location_forecast(location_id), key=lambda item: item.date.date()),
|
||||||
values_by_date[item_date].append(value)
|
lambda item: item.date.date(),
|
||||||
values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
|
)
|
||||||
|
]
|
||||||
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)),
|
||||||
|
|||||||
@@ -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,15 +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 = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
|
||||||
response = await self._source.request(endpoint)
|
response = await self.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,23 +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 = item_date.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None).replace(tzinfo=None)
|
datetime.datetime.fromtimestamp(data.dt, datetime.UTC)
|
||||||
value = build_weather_value(item_date)
|
.replace(tzinfo=datetime.timezone.utc)
|
||||||
# TODO parse temperature interval flag
|
.astimezone(tz=None)
|
||||||
value.temperature = [round(item.main.temp)]
|
.replace(tzinfo=None)
|
||||||
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
|
)
|
||||||
value.pressure = [round(item.main.pressure / 133.3 * 100)]
|
return WeatherValue.model_validate(
|
||||||
value.humidity = item.main.humidity
|
{
|
||||||
value.wind_speed = round(item.wind.speed)
|
"date": date,
|
||||||
value.wind_gust = round(item.wind.gust)
|
"sky": {
|
||||||
value.wind_direction = item.wind.deg
|
"cloudness": self.CLOUDNESS_MAP.get(data.weather[0].description, Cloudness.CLEAR),
|
||||||
value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
|
"precipitation": self.PRECIPITATION_MAP.get(data.weather[0].description, Precipitation.NO),
|
||||||
value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
|
},
|
||||||
if item.rain:
|
"wind": {
|
||||||
value.precipitation = round(item.rain.interval_3h, 1)
|
"speed": round(data.wind.speed),
|
||||||
return value
|
"gust": round(data.wind.gust),
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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,88 +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(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
lr = 213 # magic id
|
||||||
endpoint = f"channel/{CHANNELS_MAP[channel_id]}?date={date:%Y-%m-%d}"
|
|
||||||
data = await self.SOURCE.request(endpoint)
|
def _build_source(self) -> ApiSource:
|
||||||
soup = BeautifulSoup(data, features="html.parser")
|
return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
|
||||||
if soup.select_one(".CheckboxCaptcha") is not None:
|
|
||||||
raise RuntimeError("Captcha")
|
async def find_channels(self, query: str) -> list[Channel]:
|
||||||
values = []
|
url = (
|
||||||
channel_name = soup.select_one(".channel-header__text").text.strip()
|
"https://suggest-multi.yandex.ru/suggest-tv2?"
|
||||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
f"v=4&uil=ru&lr={self.lr}&count_channels=4&count_programs=4&sn=50&part={query}"
|
||||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
)
|
||||||
prev_value: ScheduleValue | None = None
|
data = await self.source.request(url)
|
||||||
for item in soup.select(".channel-schedule .channel-schedule__event"):
|
return self.channel_parser.parse(data)
|
||||||
title = item.select_one(".channel-schedule__title").text.strip()
|
|
||||||
time_str = item.select_one(".channel-schedule__time").text.strip()
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
hours, minutes = map(int, time_str.split(":"))
|
endpoint = f"{self.lr}/channel/{channel_id}?date={date:%Y-%m-%d}"
|
||||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
data = await self.source.request(endpoint)
|
||||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
return self.schedule_parser.parse(ScheduleData(date, channel_id, data))
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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,24 +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(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
async def get_schedule(self, channel_id: str, 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,12 +27,5 @@ class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
|
|||||||
),
|
),
|
||||||
**CACHE_PRESET._asdict(),
|
**CACHE_PRESET._asdict(),
|
||||||
)
|
)
|
||||||
async def get_channel_schedule(self, channel_id: ChannelId, date: datetime.date) -> Schedule:
|
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||||
return await self._api.get_channel_schedule(channel_id, date)
|
return await self._api.get_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
-11
@@ -1,38 +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,6 +13,7 @@ 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
|
||||||
@@ -20,105 +22,70 @@ class Location(Model):
|
|||||||
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 == -1:
|
|
||||||
return WindDirection.CALM
|
|
||||||
elif 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:
|
|
||||||
raise ValueError(self)
|
|
||||||
|
|
||||||
@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,30 +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(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
|
def merge_weather_values(
|
||||||
result = build_weather_value(date)
|
date: datetime.datetime,
|
||||||
|
values: list[WeatherValue],
|
||||||
|
mode: MergeMode = "interval",
|
||||||
|
) -> WeatherValue:
|
||||||
|
result = WeatherValue(date=date)
|
||||||
temperatures = []
|
temperatures = []
|
||||||
pressures = []
|
pressures = []
|
||||||
humidities = []
|
humidities = []
|
||||||
@@ -38,25 +36,43 @@ def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) ->
|
|||||||
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
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import tomllib
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]
|
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["project"]["version"]
|
||||||
|
|||||||
@@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст."
|
|||||||
msgid "Humidity, %"
|
msgid "Humidity, %"
|
||||||
msgstr "Влажность, %"
|
msgstr "Влажность, %"
|
||||||
|
|
||||||
|
msgid "Stored locations"
|
||||||
|
msgstr "Сохранённые локации"
|
||||||
|
|
||||||
# tv
|
# tv
|
||||||
msgid "TV program"
|
msgid "TV program"
|
||||||
msgstr "Телепрограмма"
|
msgstr "Телепрограмма"
|
||||||
|
|
||||||
msgid "Live broadcasts"
|
msgid "Live broadcasts"
|
||||||
msgstr "Прямые трансляции"
|
msgstr "Прямые трансляции"
|
||||||
|
|
||||||
|
msgid "Stored channels"
|
||||||
|
msgstr "Сохранённые каналы"
|
||||||
|
|
||||||
|
msgid "Channel groups"
|
||||||
|
msgstr "Группы каналов"
|
||||||
Generated
+2217
-1045
File diff suppressed because it is too large
Load Diff
+31
-29
@@ -1,42 +1,44 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "gallery"
|
name = "gallery"
|
||||||
version = "0.3.2"
|
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
version = "0.10.3"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
|
||||||
|
requires-python = '>=3.14,<4.0'
|
||||||
|
dependencies = [
|
||||||
|
'aiohttp (>=3.14,<4.0)',
|
||||||
|
'beautifulsoup4 (>=4.15,<5.0)',
|
||||||
|
'dateparser (>=1.4,<2.0)',
|
||||||
|
'pydantic (>=2.13,<3.0)',
|
||||||
|
'aiocache[redis] (>=0.12,<0.13)',
|
||||||
|
"playwright (>=1.62.0,<2.0.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
app = [
|
||||||
|
"fastapi[standard] (>=0.139,<0.140)",
|
||||||
|
"jinja2 (>=3.1,<4.0)",
|
||||||
|
"babel (>=2.18,<3.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
test = ["pytest (>=9.1,<10.0)", "pytest-asyncio (>=1.4,<2.0)", "coverage (>=7.16.1,<8.0.0)"]
|
||||||
|
dev = ["pylint (>=4.0.6,<5.0.0)", "black (>=26.5,<27.0)", "isort (>=8.0,<9.0)"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
gallery = "gallery.main:run"
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
packages = [{ include = "gallery" }]
|
packages = [{ include = "gallery" }]
|
||||||
|
requires-poetry = '>=2.0.0,<3.0.0'
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.12"
|
|
||||||
aiohttp = "^3.9.5"
|
|
||||||
beautifulsoup4 = "^4.12.3"
|
|
||||||
dateparser = "^1.2.0"
|
|
||||||
pydantic = "^2.8.2"
|
|
||||||
aiocache = { extras = ["redis"], version = "^0.12.2" }
|
|
||||||
|
|
||||||
[tool.poetry.group.app.dependencies]
|
|
||||||
fastapi = "^0.111.1"
|
|
||||||
jinja2 = "^3.1.4"
|
|
||||||
babel = "^2.18.0"
|
|
||||||
|
|
||||||
[tool.poetry.group.test.dependencies]
|
|
||||||
pytest = "^8.3.1"
|
|
||||||
pytest-asyncio = "^0.23.8"
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
|
||||||
pylint = "^3.2.6"
|
|
||||||
black = "^24.4.2"
|
|
||||||
isort = "^5.13.2"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ['poetry-core (>=2.0.0,<3.0.0)']
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
|
||||||
gallery = "gallery.main:run"
|
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
|
target-version = ["py314"]
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = "black"
|
profile = "black"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
docker compose -f docker-compose-develop.yaml up --build --watch
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
. .env
|
|
||||||
|
|
||||||
build () {
|
|
||||||
echo "build: $1"
|
|
||||||
. "$1/.env"
|
|
||||||
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
|
||||||
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
|
||||||
ARGS=("build")
|
|
||||||
for ARG in ${DOCKER_ARGS[@]}; do
|
|
||||||
ARGS+=("--build-arg" "$ARG")
|
|
||||||
done
|
|
||||||
if [ -n "$PROJECT_TARGET" ]; then
|
|
||||||
ARGS+=("--target" "$PROJECT_TARGET")
|
|
||||||
fi
|
|
||||||
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
|
|
||||||
ARGS+=("-f" "$1/Dockerfile")
|
|
||||||
echo "${ARGS[@]}"
|
|
||||||
docker "${ARGS[@]}"
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
publish () {
|
|
||||||
echo "publish: $1"
|
|
||||||
. "$1/.env"
|
|
||||||
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
|
||||||
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
|
||||||
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
|
||||||
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:latest
|
|
||||||
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
|
||||||
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_TARGETS="."
|
|
||||||
TARGETS="${@-$DEFAULT_TARGETS}"
|
|
||||||
|
|
||||||
DOCKER_ACTION="${DOCKER_ACTION-build}"
|
|
||||||
|
|
||||||
for TARGET in $TARGETS; do
|
|
||||||
$DOCKER_ACTION "$TARGET"
|
|
||||||
done
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
TARGET="gallery"
|
|
||||||
|
|
||||||
poetry run isort $TARGET
|
|
||||||
poetry run black $TARGET -q
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
TARGET="gallery"
|
|
||||||
|
|
||||||
poetry run pylint $TARGET
|
|
||||||
poetry run isort $TARGET --check-only
|
|
||||||
poetry run black $TARGET -q --check --diff
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
cd "$(dirname $(dirname "$0"))" || exit
|
|
||||||
|
|
||||||
cd locales/ru/LC_MESSAGES || exit
|
|
||||||
msgfmt messages.po
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user