Compare commits
53
Commits
a92b63b855
..
0.10.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+1
-1
@@ -10,4 +10,4 @@ max_line_length = 120
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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[^"]+')
|
||||
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
|
||||
DOCKER_PROJECTS=("gallery")
|
||||
DOCKER_TAG=latest
|
||||
DOCKER_ROOT="${DOCKER_REPO}/${DOCKER_GROUP}"
|
||||
DOCKER_TAG="${DOCKER_TAG:-latest}"
|
||||
|
||||
OPENWEATHER_KEY=""
|
||||
GISMETEO_AUTH=""
|
||||
@@ -0,0 +1,42 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# repo
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
# python
|
||||
- name: Set up python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
cache: "pip"
|
||||
# project-manager
|
||||
- name: Install Project Manager
|
||||
run: |
|
||||
pip install project-manager \
|
||||
--index-url https://git.shmyga.ru/api/packages/InfernalGames/pypi/simple/ \
|
||||
--extra-index-url https://pypi.org/simple
|
||||
# setup
|
||||
- name: Setup config
|
||||
run: pm setup:config -vv
|
||||
# build
|
||||
- name: Build docker
|
||||
run: pm build:docker -vv
|
||||
# login docker
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: git.shmyga.ru
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
# pubish
|
||||
- name: Publish docker
|
||||
run: pm publish:docker -vv
|
||||
@@ -0,0 +1,50 @@
|
||||
name: test
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# repo
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v7
|
||||
# python
|
||||
- name: Set up python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
# poetry
|
||||
- name: Load cached Poetry installation
|
||||
id: cached-poetry
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.local
|
||||
key: poetry-1 # increment to reset cache
|
||||
- name: Install Poetry
|
||||
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
||||
uses: snok/install-poetry@v1
|
||||
with:
|
||||
virtualenvs-create: true
|
||||
virtualenvs-in-project: true
|
||||
virtualenvs-path: .venv
|
||||
installer-parallel: true
|
||||
# venv
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
- name: Install dependencies
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
run: ~/.local/bin/poetry install --no-interaction --no-root
|
||||
- name: Install project
|
||||
run: ~/.local/bin/poetry install --no-interaction
|
||||
# test
|
||||
- name: Run tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
coverage run -m pytest tests/
|
||||
coverage report
|
||||
@@ -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"]
|
||||
@@ -9,7 +9,7 @@ disable=
|
||||
duplicate-code,
|
||||
too-few-public-methods,
|
||||
too-many-locals,
|
||||
no-else-return
|
||||
no-else-return,
|
||||
|
||||
[MISCELLANEOUS]
|
||||
notes=FIXME,TODO
|
||||
|
||||
+12
-2
@@ -23,13 +23,23 @@ COPY static ./
|
||||
RUN npm run build
|
||||
|
||||
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 TZ="Europe/Moscow"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV TZ="Europe/Moscow"
|
||||
COPY --from=builder /app ./
|
||||
COPY --from=node-builder /app/dist ./static/dist
|
||||
|
||||
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"]
|
||||
|
||||
@@ -1,13 +1,75 @@
|
||||
# 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
|
||||
|
||||
## Swagger
|
||||
|
||||
https://api.shmyga.ru/docs
|
||||

|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.terminal.useEnvFile": true,
|
||||
"python.analysis.importFormat": "relative",
|
||||
"python-envs.pythonProjects": [
|
||||
{
|
||||
"path": ".",
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ caches.set_config(
|
||||
{
|
||||
"default": {
|
||||
"cache": "aiocache.SimpleMemoryCache",
|
||||
"serializer": {"class": "aiocache.serializers.StringSerializer"},
|
||||
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
||||
},
|
||||
"redis": {
|
||||
"cache": "aiocache.RedisCache",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from gallery.sketch.bundle import ApiBundle
|
||||
from gallery.util import root_path
|
||||
|
||||
from .route import api, doc, view
|
||||
from .route import api, view
|
||||
|
||||
|
||||
def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||
@@ -15,7 +16,12 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
|
||||
)
|
||||
app.state.api = api_bundle
|
||||
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
|
||||
doc.mount(app)
|
||||
app.include_router(api.router)
|
||||
api.app.state.api = api_bundle
|
||||
app.mount("/api", api.app)
|
||||
app.include_router(view.router)
|
||||
|
||||
@app.get("/docs")
|
||||
def get_docs():
|
||||
return RedirectResponse("/api/docs")
|
||||
|
||||
return app
|
||||
|
||||
@@ -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)]
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, NamedTuple
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
@@ -7,3 +7,35 @@ 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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router.include_router(weather.router)
|
||||
router.include_router(schedule.router)
|
||||
app = FastAPI(
|
||||
docs_url=None,
|
||||
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,15 +1,32 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
from gallery.easel.depends.schedule import ScheduleApiDepends
|
||||
from gallery.easel.depends.schedule import (
|
||||
GROUP_CANNELS_MAP,
|
||||
ScheduleApiDepends,
|
||||
ScheduleGroupChannelsDepends,
|
||||
)
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, Schedule
|
||||
|
||||
from ...depends.date import DateDepends
|
||||
|
||||
router = APIRouter(prefix="/schedule", tags=["Schedule"])
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def get_api_schedule_groups():
|
||||
return list(GROUP_CANNELS_MAP.keys())
|
||||
|
||||
|
||||
@router.get("/group/{group_id}/{date}")
|
||||
async def get_api_schedule_group_schedule(
|
||||
request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends
|
||||
) -> list[Schedule]:
|
||||
api = request.app.state.api
|
||||
return [await api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date) for channel in channels]
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def get_api_weather_providers(request: AppRequest) -> list[str]:
|
||||
return request.app.state.api.get_api_providers(ScheduleApi)
|
||||
@@ -22,6 +39,6 @@ async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: st
|
||||
|
||||
@router.get("/{provider}/{channel}/{date}")
|
||||
async def get_api_schedule_channel_schedule(
|
||||
schedule_api: ScheduleApiDepends, channel: str, date: datetime.date
|
||||
schedule_api: ScheduleApiDepends, channel: str, date: DateDepends
|
||||
) -> Schedule:
|
||||
return await schedule_api.get_schedule(channel, date)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import datetime
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from gallery.easel.core import AppRequest
|
||||
@@ -7,6 +5,8 @@ from gallery.easel.depends.weather import WeatherApiDepends
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||
|
||||
from ...depends.date import DateDepends
|
||||
|
||||
router = APIRouter(prefix="/weather", tags=["Weather"])
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/day/{date}")
|
||||
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: datetime.date) -> WeatherResponse:
|
||||
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: DateDepends) -> WeatherResponse:
|
||||
return await weather_api.get_day(location, date)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ from fastapi.openapi.docs import (
|
||||
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(
|
||||
"/docs/static",
|
||||
StaticFiles(directory=Path(__file__).parent / "static"),
|
||||
@@ -17,7 +18,7 @@ def mount(app: FastAPI):
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=app.openapi_url,
|
||||
openapi_url=openapi_url,
|
||||
title=app.title + " - Swagger UI",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
swagger_js_url="docs/static/swagger-ui-bundle.js",
|
||||
@@ -31,7 +32,7 @@ def mount(app: FastAPI):
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
async def redoc_html():
|
||||
return get_redoc_html(
|
||||
openapi_url=app.openapi_url,
|
||||
openapi_url=openapi_url,
|
||||
title=app.title + " - ReDoc",
|
||||
redoc_js_url="docs/static/redoc.standalone.js",
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<div class="app col-lg-8 mx-auto p-3 py-md-5">
|
||||
{% if not is_widget %}
|
||||
<header class="app-header pb-3 mb-5 border-bottom">
|
||||
<div>{{request.query_params.widget}}</div>
|
||||
<div class="link-list">
|
||||
<app-link href="/"
|
||||
icon="gear">API Gallery</app-link>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
import datetime
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit
|
||||
|
||||
from babel.dates import format_date
|
||||
from fastapi import Request
|
||||
@@ -10,7 +11,6 @@ from gallery.easel.core import AppRequest
|
||||
from gallery.version import __version__
|
||||
|
||||
from ...translation import _
|
||||
from .tag import TagUtil
|
||||
|
||||
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
|
||||
|
||||
@@ -28,6 +28,26 @@ def base_context_processor(request: Request) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -44,8 +64,8 @@ def build_templates(
|
||||
"version": __version__,
|
||||
"format_date": format_date,
|
||||
"datetime": datetime,
|
||||
"tag_util": TagUtil,
|
||||
"DATE_FORMAT": "E, d MMMM Y",
|
||||
"build_url": build_url,
|
||||
}
|
||||
)
|
||||
if filters:
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</div>
|
||||
<h1>{{_("Docs")}}</h1>
|
||||
<div class="list-group mb-3">
|
||||
<app-link href="/docs"
|
||||
<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="/redoc"
|
||||
<app-link href="/api/redoc"
|
||||
target="_blank"
|
||||
icon="file-earmark-text"
|
||||
class="list-group-item list-group-item-action">
|
||||
|
||||
@@ -1,70 +1 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
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.schedule import ScheduleApiDepends
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
|
||||
from ..common.utils.tag import TagType, TagUtil
|
||||
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": channels,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
|
||||
async def get_channel_default(channel: str):
|
||||
return RedirectResponse(f"{channel}/tag/today")
|
||||
|
||||
|
||||
@router.get("/{provider}/{channel}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
if tag_value.type == TagType.DAY:
|
||||
response = await schedule_api.get_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,
|
||||
},
|
||||
)
|
||||
from .route import router
|
||||
|
||||
@@ -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" %}
|
||||
{% set live = request.query_params.live == '1' %}
|
||||
{% set date = response.date %}
|
||||
|
||||
{% block title %}
|
||||
{{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
|
||||
locale=request.state.language)}}
|
||||
@@ -10,19 +13,23 @@ locale=request.state.language)}}
|
||||
{% 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)}}">
|
||||
<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="../../..">
|
||||
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 class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
|
||||
<span>{{response.channel.name}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<a class="icon-link"
|
||||
href="../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>
|
||||
</a>
|
||||
</h4>
|
||||
@@ -30,15 +37,14 @@ locale=request.state.language)}}
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td style="width:100%"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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>
|
||||
<span>{{value.start.strftime('%H:%M')}}</span>
|
||||
<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -35,17 +35,26 @@
|
||||
</div>
|
||||
</form>
|
||||
{% if channels %}
|
||||
<ul id="channels"
|
||||
class="list-group mb-5">
|
||||
{% for channel in channels %}
|
||||
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}"></schedule-channel>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
<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 %}
|
||||
<ul id="storedChannels"
|
||||
class="list-group mb-5">
|
||||
</ul>
|
||||
<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);
|
||||
@@ -53,9 +62,8 @@
|
||||
if (searchQuery) {
|
||||
document.querySelector('#query').value = searchQuery;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
scheduleChannelManager.loadChannels('#storedChannels');
|
||||
scheduleChannelManager.load();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,102 +1 @@
|
||||
import datetime
|
||||
from pathlib import Path
|
||||
|
||||
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.weather import WeatherApiDepends
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
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, 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": locations,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}", response_class=RedirectResponse)
|
||||
async def get_weather(location: str):
|
||||
return RedirectResponse(f"{location}/tag/today")
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
|
||||
async def get_weather_day(
|
||||
request: AppRequest,
|
||||
weather_api: WeatherApiDepends,
|
||||
location: str,
|
||||
date: datetime.date,
|
||||
):
|
||||
response = await weather_api.get_day(location, date)
|
||||
return build_weather_response(request, response)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@router.get("/{provider}/{location}/tag/{tag}", response_class=HTMLResponse)
|
||||
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
|
||||
tag_value = TagUtil.parse_tag(tag)
|
||||
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)
|
||||
|
||||
|
||||
@router.get("/zmiyevka-184640/tag/{tag}", response_class=RedirectResponse)
|
||||
async def get_zmiyevka_weather_compat(request: AppRequest):
|
||||
path_parts = request.url.path.split("/")
|
||||
path_parts.insert(2, "gismeteo")
|
||||
return RedirectResponse("/".join(path_parts))
|
||||
from .route import router
|
||||
|
||||
@@ -7,17 +7,15 @@ from gallery.sketch.weather.model import (
|
||||
Precipitation,
|
||||
Sky,
|
||||
WindDirection,
|
||||
WindDirectionDeg,
|
||||
)
|
||||
from gallery.util import root_path
|
||||
|
||||
|
||||
def wind_direction_icon(wind_direction_deg: float) -> str:
|
||||
wind_direction = WindDirectionDeg(wind_direction_deg).direction
|
||||
if wind_direction == WindDirection.CALM:
|
||||
def wind_direction_icon(wind_direction: WindDirection | None) -> str:
|
||||
if wind_direction is None:
|
||||
return "wind-calm"
|
||||
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]:
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
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.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}/{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)
|
||||
@@ -35,17 +35,18 @@
|
||||
</div>
|
||||
</form>
|
||||
{% if locations %}
|
||||
<ul id="locations"
|
||||
class="list-group mb-5">
|
||||
{% for location in locations %}
|
||||
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<hr>
|
||||
<div class="mb-4">
|
||||
<weather-location-list value="{{locations | tojson | forceescape}}"
|
||||
onclick="weatherLocationManager.store(element)"
|
||||
onadd="weatherLocationManager.add(element)"></weather-location-list>
|
||||
<hr>
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul id="storedLocations"
|
||||
class="list-group mb-5">
|
||||
</ul>
|
||||
<div class="mb-4">
|
||||
<h3>{{_("Stored locations")}}</h3>
|
||||
<weather-location-list id="storedLocations"
|
||||
onremove="weatherLocationManager.remove(element)"></weather-location-list>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -55,7 +56,7 @@
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", (event) => {
|
||||
weatherLocationManager.loadLocations('#storedLocations');
|
||||
weatherLocationManager.load();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{% 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 %}
|
||||
<app-link href="/weather"
|
||||
@@ -9,22 +12,23 @@
|
||||
{% block content %}
|
||||
<h4>
|
||||
{% if response.period == 'day' %}
|
||||
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
|
||||
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
|
||||
<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="../tag/days-10">
|
||||
href="{{build_url(request, path=['..', '..', 'days', '10'])}}">
|
||||
<i class="bi bi-arrow-up-square"></i>
|
||||
</a>
|
||||
<span class="badge rounded-pill text-bg-secondary fs-6">{{provider}}</span>
|
||||
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
|
||||
<a class="icon-link"
|
||||
href="../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>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% 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>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
|
||||
locale=request.state.language)}}</span>
|
||||
@@ -46,7 +50,7 @@
|
||||
{% if response.period == 'days' %}
|
||||
<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 ''}}">
|
||||
<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)}}
|
||||
</a>
|
||||
</span>
|
||||
@@ -103,8 +107,8 @@
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{{ value.wind_direction }}">
|
||||
<div class="wi-svg wi-s wi-{{value.wind_direction | wind_direction_icon}}"
|
||||
data-bs-title="{{ value.wind.direction }}">
|
||||
<div class="wi-svg wi-s wi-{{value.wind.direction | wind_direction_icon}}"
|
||||
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
@@ -119,11 +123,11 @@
|
||||
<tr>
|
||||
{% for value in response.values %}
|
||||
<td class="wind"
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
|
||||
<span class="speed">{{value.wind_speed}}</span>
|
||||
{% if value.wind_gust != value.wind_speed %}
|
||||
style="background-color: rgba(128, 128, 128, {{value.wind.speed * 0.05}});">
|
||||
<span class="speed">{{value.wind.speed}}</span>
|
||||
{% if value.wind.gust != value.wind.speed and value.wind.gust > 0 %}
|
||||
<span class="gust">
|
||||
({{value.wind_gust}})
|
||||
({{value.wind.gust}})
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
+8
-4
@@ -5,19 +5,23 @@ import uvicorn
|
||||
|
||||
from gallery.easel import build_app
|
||||
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.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.schedule.cached import CachedScheduleApi
|
||||
from gallery.sketch.weather.cached import CachedWeatherApi
|
||||
|
||||
api = ApiBundle(
|
||||
[
|
||||
CachedScheduleApi(YandexTvApi()),
|
||||
CachedScheduleApi(MatchTvApi()),
|
||||
CachedScheduleApi(YandexTVApi()),
|
||||
CachedScheduleApi(MatchTVApi()),
|
||||
CachedWeatherApi(GismeteoApi()),
|
||||
CachedWeatherApi(OpenWeatherApi()),
|
||||
CachedWeatherApi(YandexWeatherApi()),
|
||||
CachedWeatherApi(RP5WeatherApi()),
|
||||
]
|
||||
)
|
||||
app = build_app(api)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PROVIDER = "gismeteo"
|
||||
|
||||
@@ -1,111 +1,55 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from os import environ
|
||||
from typing import Any
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
||||
from gallery.sketch.weather.util import merge_weather_values
|
||||
from gallery.sketch.weather.model import Location, WeatherResponse
|
||||
|
||||
from . import datehelp
|
||||
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
||||
from . import PROVIDER
|
||||
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):
|
||||
PROVIDER = "gismeteo"
|
||||
SOURCE = ApiSource(
|
||||
"https://www.gismeteo.ru",
|
||||
cookies={"xauth": auth} if (auth := environ.get("GISMETEO_AUTH")) else None,
|
||||
)
|
||||
PROVIDER = PROVIDER
|
||||
|
||||
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]
|
||||
# group values by 3 hours
|
||||
if len(values) > 8:
|
||||
n = 3
|
||||
values = [merge_weather_values(values[i].date, values[i : i + n]) for i in range(0, len(values), n)]
|
||||
return WeatherResponse(
|
||||
location=location or "n/a",
|
||||
date=date,
|
||||
period="day",
|
||||
values=values,
|
||||
)
|
||||
forecast_parser = GismeteoForecastParser()
|
||||
location_parser = GismeteoLocationParser()
|
||||
|
||||
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,
|
||||
def _build_source(self) -> ApiSource:
|
||||
return ApiSource(
|
||||
"https://www.gismeteo.ru",
|
||||
cookies={"xauth": auth} if (auth := environ.get("GISMETEO_AUTH")) else None,
|
||||
)
|
||||
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
geo = "ru"
|
||||
latitude = 52.968498
|
||||
longitude = 36.0695
|
||||
data = json.loads(
|
||||
await self.SOURCE.request(
|
||||
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
||||
)
|
||||
data = await self.source.request(
|
||||
f"mq/city/q/?q={query}&geo={geo}&latitude={latitude}&longitude={longitude}&limit=10"
|
||||
)
|
||||
result = []
|
||||
for item in data["data"]:
|
||||
result.append(
|
||||
Location(
|
||||
id=f"{item['slug']}-{item['id']}",
|
||||
name=item["translations"]["kk"]["city"]["name"],
|
||||
provider=self.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
|
||||
return self.location_parser.parse(data)
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
max_date = datetime.date.today() + datetime.timedelta(days=9)
|
||||
if date > max_date:
|
||||
raise HTTPException(
|
||||
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}/{datehelp.dump(date)}")
|
||||
return self._parse_oneday(date, data)
|
||||
raise ValueError(f"Invalid date: {date}, max: {max_date}")
|
||||
data = await self.source.request(f"weather-{location_id}/{format_date(date)}")
|
||||
context = ForecastData(date, "day", data, 8)
|
||||
return self.forecast_parser.parse(context)
|
||||
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
data = await self.SOURCE.request(f"weather-{location_id}/{days}-days")
|
||||
return self._parse_manydays(data)
|
||||
data = await self.source.request(f"weather-{location_id}/{days}-days")
|
||||
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"
|
||||
+197
-156
@@ -1,59 +1,59 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from typing import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
import dateparser
|
||||
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 (
|
||||
Cloudness,
|
||||
Location,
|
||||
Precipitation,
|
||||
Sky,
|
||||
WeatherResponse,
|
||||
WeatherValue,
|
||||
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
|
||||
|
||||
logger = logging.getLogger("gismeteo")
|
||||
|
||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
||||
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
|
||||
from . import PROVIDER
|
||||
|
||||
|
||||
class LocationParser:
|
||||
class LocationNameParser(Parser[str, str]):
|
||||
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)
|
||||
if match:
|
||||
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]):
|
||||
KEY = "date"
|
||||
class DateParser(TableValueParser[datetime.datetime]):
|
||||
selector = ".widget-row.widget-row-date > .row-item"
|
||||
model_key = "date"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
|
||||
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
|
||||
def extract(self, data: Tag) -> datetime.datetime:
|
||||
return DATE_PARSER.parse(data.text)
|
||||
|
||||
|
||||
class SkyParser(RowParser[Sky]):
|
||||
KEY = "sky"
|
||||
class SkyParser(TableValueParser[Sky]):
|
||||
selector = ".widget-row[data-row=icon-tooltip] > .row-item"
|
||||
model_key = "sky"
|
||||
|
||||
CLOUDNESS_MAP: dict[str, Cloudness] = {
|
||||
"ясно": Cloudness.CLEAR,
|
||||
@@ -65,6 +65,7 @@ class SkyParser(RowParser[Sky]):
|
||||
|
||||
PRECIPITATION_MAP: dict[str, Precipitation] = {
|
||||
"без осадков": Precipitation.NO,
|
||||
"без существенных осадков": Precipitation.NO,
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"сильный дождь": Precipitation.HEAVY_RAIN,
|
||||
"очень сильный дождь": Precipitation.HEAVY_RAIN,
|
||||
@@ -82,131 +83,171 @@ class SkyParser(RowParser[Sky]):
|
||||
"град": Precipitation.HAIL,
|
||||
}
|
||||
|
||||
THUNDER = "гроза"
|
||||
FOG = "дымка"
|
||||
THUNDER = {"гроза"}
|
||||
FOG = {"дымка", "туман"}
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[Sky]:
|
||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
||||
sky_str = item.attrs["data-tooltip"]
|
||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||
cloudness = Cloudness.CLEAR
|
||||
precipitation = Precipitation.NO
|
||||
thunder = False
|
||||
fog = False
|
||||
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():
|
||||
if k in values:
|
||||
cloudness = v
|
||||
values.remove(k)
|
||||
break
|
||||
for k, v in self.PRECIPITATION_MAP.items():
|
||||
if k in values:
|
||||
precipitation = v
|
||||
values.remove(k)
|
||||
break
|
||||
if values:
|
||||
logger.warning("unknown sky values: %s:", values)
|
||||
yield Sky(
|
||||
cloudness=cloudness,
|
||||
precipitation=precipitation,
|
||||
thunder=thunder,
|
||||
fog=fog,
|
||||
@classmethod
|
||||
def _detect_flag(cls, values: set[str], flag_values: set[str]) -> tuple[set[str], bool]:
|
||||
result_values = values - flag_values
|
||||
return result_values, len(result_values) < len(values)
|
||||
|
||||
def extract(self, data: Tag) -> Sky:
|
||||
sky_str = data.attrs["data-tooltip"]
|
||||
values = {item.strip().lower() for item in sky_str.split(",")}
|
||||
cloudness = Cloudness.CLEAR
|
||||
precipitation = Precipitation.NO
|
||||
values, thunder = self._detect_flag(values, self.THUNDER)
|
||||
values, fog = self._detect_flag(values, self.FOG)
|
||||
for k, v in self.CLOUDNESS_MAP.items():
|
||||
if k in values:
|
||||
cloudness = v
|
||||
values.remove(k)
|
||||
break
|
||||
for k, v in self.PRECIPITATION_MAP.items():
|
||||
if k in values:
|
||||
precipitation = v
|
||||
values.remove(k)
|
||||
break
|
||||
if values:
|
||||
raise ParseError("Unknown sky values", values)
|
||||
return Sky(
|
||||
cloudness=cloudness,
|
||||
precipitation=precipitation,
|
||||
thunder=thunder,
|
||||
fog=fog,
|
||||
)
|
||||
|
||||
|
||||
class TemperatureParser(TableValueParser[list[int]]):
|
||||
selector = ".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"
|
||||
model_key = "temperature"
|
||||
|
||||
def extract(self, data: Tag) -> list[int]:
|
||||
return [int(value.attrs["value"]) for value in data.select("temperature-value")]
|
||||
|
||||
|
||||
class WindSpeedParser(TableValueParser[int]):
|
||||
selector = ".widget-row-wind > .row-item > .wind-speed"
|
||||
model_key = "wind.speed"
|
||||
|
||||
def extract(self, data: Tag) -> int:
|
||||
value = data.select_one("speed-value")
|
||||
return int(value.attrs["value"]) if value else 0
|
||||
|
||||
|
||||
class WindGustParser(TableValueParser[int]):
|
||||
selector = ".widget-row-wind > .row-item > .wind-gust"
|
||||
model_key = "wind.gust"
|
||||
|
||||
def extract(self, data: Tag) -> int:
|
||||
value = data.select_one("speed-value")
|
||||
return int(value.attrs["value"]) if value else 0
|
||||
|
||||
|
||||
class WindDirectionParser(TableValueParser[WindDirection | None]):
|
||||
selector = ".widget-row-wind > .row-item > .wind-speed > .wind-direction"
|
||||
model_key = "wind.direction"
|
||||
|
||||
def extract(self, data: Tag) -> WindDirection | None:
|
||||
return WIND_DIRECTION_PARSER.parse(data.text.strip())
|
||||
|
||||
|
||||
class PrecipitationParser(TableValueParser[float]):
|
||||
selector = ".widget-row[data-row=precipitation-bars] > .row-item"
|
||||
model_key = "precipitation"
|
||||
|
||||
def extract(self, data: Tag) -> float:
|
||||
value = data.select_one("precipitation-value")
|
||||
return float(value.attrs["value"]) if value else 0
|
||||
|
||||
|
||||
class PressureParser(TableValueParser[list[int]]):
|
||||
selector = ".widget-row-chart[data-row=pressure] > .chart > .values > .value"
|
||||
model_key = "pressure"
|
||||
|
||||
def extract(self, data: Tag) -> list[int]:
|
||||
return [int(value.attrs["value"]) for value in data.select("pressure-value")]
|
||||
|
||||
|
||||
class HumidityParser(TableValueParser[int]):
|
||||
selector = ".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
||||
model_key = "humidity"
|
||||
|
||||
def extract(self, data: Tag) -> int:
|
||||
return int(data.text)
|
||||
|
||||
|
||||
class GismeteoTableParser(TableParser[WeatherValue]):
|
||||
selector = ".widget .widget-items"
|
||||
value_parsers = [
|
||||
TimeParser(),
|
||||
DateParser(),
|
||||
SkyParser(),
|
||||
TemperatureParser(),
|
||||
WindSpeedParser(),
|
||||
WindGustParser(),
|
||||
WindDirectionParser(),
|
||||
PrecipitationParser(),
|
||||
PressureParser(),
|
||||
HumidityParser(),
|
||||
]
|
||||
|
||||
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 ""
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TemperatureParser(RowParser[list[int]]):
|
||||
KEY = "temperature"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
|
||||
|
||||
|
||||
class WindSpeedParser(RowParser[int]):
|
||||
KEY = "wind_speed"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
class WindGustParser(RowParser[int]):
|
||||
KEY = "wind_gust"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
|
||||
value = item.select_one("speed-value")
|
||||
yield int(value.attrs["value"]) if value else 0
|
||||
|
||||
|
||||
class WindDirectionParser(RowParser[WindDirection]):
|
||||
KEY = "wind_direction"
|
||||
|
||||
WIND_DIRECTION_MAP: dict[str, WindDirection] = {
|
||||
"—": WindDirection.CALM,
|
||||
"": WindDirection.CALM,
|
||||
"штиль": 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]):
|
||||
KEY = "precipitation"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
|
||||
value = item.select_one("precipitation-value")
|
||||
if value:
|
||||
yield float(value.attrs["value"])
|
||||
else:
|
||||
yield 0
|
||||
|
||||
|
||||
class PressureParser(RowParser[list[int]]):
|
||||
KEY = "pressure"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[list[int]]:
|
||||
for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
|
||||
yield [int(value.attrs["value"]) for value in item.select("pressure-value")]
|
||||
|
||||
|
||||
class HumidityParser(RowParser[int]):
|
||||
KEY = "humidity"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=humidity] > .row-item, .widget-row[data-row=humidity-avg] > .row-item"
|
||||
):
|
||||
yield int(item.text)
|
||||
|
||||
|
||||
ROW_PARSERS: list[RowParser] = [
|
||||
DateParser(),
|
||||
SkyParser(),
|
||||
TemperatureParser(),
|
||||
WindSpeedParser(),
|
||||
WindGustParser(),
|
||||
WindDirectionParser(),
|
||||
PrecipitationParser(),
|
||||
PressureParser(),
|
||||
HumidityParser(),
|
||||
]
|
||||
|
||||
ROW_PARSERS_MAP: dict[str, RowParser] = {parser.KEY: parser for parser in ROW_PARSERS}
|
||||
return result
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
PROVIDER = "matchtv"
|
||||
|
||||
@@ -1,55 +1,33 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
from gallery.sketch.schedule.model import Channel, Schedule
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
from . import PROVIDER
|
||||
from .parser import (
|
||||
ChannelData,
|
||||
MatchTVChannelParser,
|
||||
MatchTVScheduleParser,
|
||||
ScheduleData,
|
||||
)
|
||||
|
||||
|
||||
class MatchTvApi(ScheduleApi):
|
||||
PROVIDER = "matchtv"
|
||||
SOURCE = ApiSource("https://matchtv.ru")
|
||||
class MatchTVApi(ScheduleApi):
|
||||
PROVIDER = PROVIDER
|
||||
|
||||
schedule_parser = MatchTVScheduleParser()
|
||||
channel_parser = MatchTVChannelParser()
|
||||
|
||||
def _build_source(self) -> ApiSource:
|
||||
return ApiSource("https://matchtv.ru")
|
||||
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
endpoint = "api/v1/channels"
|
||||
data = json.loads(await self.SOURCE.request(endpoint))
|
||||
result = []
|
||||
query = query.lower()
|
||||
for item in 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=self.provider))
|
||||
return result
|
||||
data = await self.source.request(endpoint)
|
||||
return self.channel_parser.parse(ChannelData(query, data))
|
||||
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
|
||||
data = json.loads(await self.SOURCE.request(endpoint))
|
||||
channel_data = data["result"]["channels"][0]
|
||||
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
|
||||
values = []
|
||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in channel_data["schedule"]:
|
||||
title = item["title"]
|
||||
time_str = item["time"]
|
||||
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,
|
||||
date=date,
|
||||
values=values,
|
||||
)
|
||||
data = await self.source.request(endpoint)
|
||||
return self.schedule_parser.parse(ScheduleData(date, data))
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import datetime
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from gallery.sketch.parse.core import ParsedContext, Parser
|
||||
from gallery.sketch.parse.json import JSONContextParser
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
|
||||
from . import PROVIDER
|
||||
|
||||
|
||||
class DateParser(Parser[str, datetime.date]):
|
||||
def parse(self, data: str) -> datetime.date:
|
||||
matched = re.compile('{\\"date\\":(\\d+)}').search(data)
|
||||
return datetime.date.strptime(matched.group(1), data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleData:
|
||||
date: datetime.date
|
||||
data: str
|
||||
|
||||
|
||||
class MatchTVScheduleParser(JSONContextParser[ScheduleData, Schedule]):
|
||||
date_parser = DateParser()
|
||||
|
||||
def extract(self, data: ParsedContext[ScheduleData, Any]) -> Schedule:
|
||||
channel_data = data.data["result"]["channels"][0]
|
||||
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=PROVIDER)
|
||||
values = []
|
||||
start = datetime.datetime.combine(data.context.date, datetime.time.min)
|
||||
end = start + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in channel_data["schedule"]:
|
||||
title = item["title"]
|
||||
time_str = item["time"]
|
||||
hours, minutes = map(int, time_str.split(":"))
|
||||
item_date = start.replace(hour=hours, minute=minutes)
|
||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||
start += datetime.timedelta(days=1)
|
||||
item_date += datetime.timedelta(days=1)
|
||||
live = "Прямая трансляция" in title
|
||||
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||
values.append(value)
|
||||
if prev_value is not None:
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=channel,
|
||||
date=data.context.date,
|
||||
values=values,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelData:
|
||||
query: str
|
||||
data: str
|
||||
|
||||
|
||||
class MatchTVChannelParser(JSONContextParser[ChannelData, list[Channel]]):
|
||||
def extract(self, data: ParsedContext[ChannelData, Any]) -> list[Channel]:
|
||||
result = []
|
||||
query = data.context.query.lower()
|
||||
for item in data.data["result"]:
|
||||
if query in item["name"].lower() or query in item["alias"]:
|
||||
channel_id = item["alias"]
|
||||
name = item["name"].split("|")[0].strip()
|
||||
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
PROVIDER = "openweather"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from itertools import groupby
|
||||
from os import environ
|
||||
|
||||
from aiocache import cached
|
||||
@@ -10,17 +9,20 @@ from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
|
||||
from gallery.sketch.weather.util import merge_weather_values
|
||||
from gallery.util import TimeUnit
|
||||
|
||||
from .openweather import Forecast
|
||||
from . import PROVIDER
|
||||
from .openweather import Location as OpenWeatherLocation
|
||||
from .openweather import OpenWeather
|
||||
from .parser import FORECAST_ITEM_PARSER
|
||||
|
||||
logger = logging.getLogger("openweather")
|
||||
from .parser import OpenWeatherForecastParser, OpenWeatherLocationParser
|
||||
|
||||
|
||||
class OpenWeatherApi(WeatherApi):
|
||||
PROVIDER = "openweather"
|
||||
SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
|
||||
class OpenWeatherApi(WeatherApi[OpenWeather]):
|
||||
PROVIDER = PROVIDER
|
||||
|
||||
forecast_parser = OpenWeatherForecastParser()
|
||||
location_parser = OpenWeatherLocationParser()
|
||||
|
||||
def _build_source(self) -> OpenWeather:
|
||||
return OpenWeather(environ["OPENWEATHER_KEY"])
|
||||
|
||||
@classmethod
|
||||
def _parse_location(cls, location_id: str) -> tuple[float, float]:
|
||||
@@ -32,41 +34,24 @@ class OpenWeatherApi(WeatherApi):
|
||||
ttl=TimeUnit.DAY,
|
||||
)
|
||||
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
|
||||
return await self.SOURCE.get_location(*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) -> Forecast:
|
||||
return await self.SOURCE.get_forecast(*self._parse_location(location_id))
|
||||
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]:
|
||||
result = await self.SOURCE.find_locations(query)
|
||||
return [
|
||||
Location(
|
||||
id=f"{item.lat}:{item.lon}",
|
||||
name=item.name,
|
||||
provider=self.provider,
|
||||
lat=item.lat,
|
||||
lon=item.lon,
|
||||
country=item.country,
|
||||
country_code=item.country.lower(),
|
||||
district=item.state or "",
|
||||
subdistrict="",
|
||||
)
|
||||
for item in result
|
||||
]
|
||||
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:
|
||||
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||
data: Forecast = await self._get_location_forecast(location_id)
|
||||
values = []
|
||||
for item in data.list:
|
||||
value = FORECAST_ITEM_PARSER.parse(item)
|
||||
if value.date.date() == date:
|
||||
values.append(value)
|
||||
values = [value for value in await self._get_location_forecast(location_id) if value.date.date() == date]
|
||||
return WeatherResponse(
|
||||
location=location.name,
|
||||
date=date,
|
||||
@@ -76,13 +61,13 @@ class OpenWeatherApi(WeatherApi):
|
||||
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
location: OpenWeatherLocation = await self._get_location(location_id)
|
||||
data: Forecast = await self._get_location_forecast(location_id)
|
||||
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
|
||||
for item in data.list:
|
||||
value = FORECAST_ITEM_PARSER.parse(item)
|
||||
item_date = value.date.replace(hour=0, minute=0)
|
||||
values_by_date[item_date].append(value)
|
||||
values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
|
||||
values = [
|
||||
merge_weather_values(datetime.datetime.combine(k, datetime.datetime.min.time()), list(g))
|
||||
for k, g in groupby(
|
||||
sorted(await self._get_location_forecast(location_id), key=lambda item: item.date.date()),
|
||||
lambda item: item.date.date(),
|
||||
)
|
||||
]
|
||||
return WeatherResponse(
|
||||
location=location.name,
|
||||
date=datetime.date.today(),
|
||||
|
||||
@@ -76,28 +76,28 @@ class Location(Model):
|
||||
local_names: dict[str, str] | None = None
|
||||
|
||||
|
||||
class OpenWeather:
|
||||
class OpenWeather(ApiSource):
|
||||
BASE_URL = "https://api.openweathermap.org"
|
||||
|
||||
def __init__(self, api_key: str):
|
||||
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:
|
||||
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)
|
||||
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._source.request(endpoint)
|
||||
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._source.request(endpoint)
|
||||
response = await self.request(endpoint)
|
||||
response_data = json.loads(response)
|
||||
return Location.model_validate(response_data[0])
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import datetime
|
||||
|
||||
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
|
||||
from gallery.sketch.weather.util import build_weather_value
|
||||
from gallery.sketch.parse.core import Parser
|
||||
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] = {
|
||||
"clear sky": Cloudness.CLEAR,
|
||||
"few clouds": Cloudness.PARTLY_CLOUDY,
|
||||
@@ -22,23 +31,54 @@ class ForecastItemParser:
|
||||
"heavy rain": Precipitation.SHOWER,
|
||||
}
|
||||
|
||||
def parse(self, item: ForecastItem) -> WeatherValue:
|
||||
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
|
||||
item_date = item_date.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None).replace(tzinfo=None)
|
||||
value = build_weather_value(item_date)
|
||||
# TODO parse temperature interval flag
|
||||
value.temperature = [round(item.main.temp)]
|
||||
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
|
||||
value.pressure = [round(item.main.pressure * 0.75006)]
|
||||
value.humidity = item.main.humidity
|
||||
value.wind_speed = round(item.wind.speed)
|
||||
value.wind_gust = round(item.wind.gust)
|
||||
value.wind_direction = item.wind.deg
|
||||
value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
|
||||
value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
|
||||
if item.rain:
|
||||
value.precipitation = round(item.rain.interval_3h, 1)
|
||||
return value
|
||||
def parse(self, data: ForecastItem) -> WeatherValue:
|
||||
date = (
|
||||
datetime.datetime.fromtimestamp(data.dt, datetime.UTC)
|
||||
.replace(tzinfo=datetime.timezone.utc)
|
||||
.astimezone(tz=None)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
return WeatherValue.model_validate(
|
||||
{
|
||||
"date": date,
|
||||
"sky": {
|
||||
"cloudness": self.CLOUDNESS_MAP.get(data.weather[0].description, Cloudness.CLEAR),
|
||||
"precipitation": self.PRECIPITATION_MAP.get(data.weather[0].description, Precipitation.NO),
|
||||
},
|
||||
"wind": {
|
||||
"speed": round(data.wind.speed),
|
||||
"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,90 +1,34 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from gallery.sketch.headers import build_headers
|
||||
from gallery.sketch.schedule.api import ScheduleApi
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
from gallery.sketch.schedule.model import Channel, Schedule
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
logger = logging.getLogger("matchtv")
|
||||
from . import PROVIDER
|
||||
from .parser import ScheduleData, YandexTVChannelParser, YandexTVScheduleParser
|
||||
|
||||
|
||||
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):
|
||||
PROVIDER = PROVIDER
|
||||
|
||||
schedule_parser = YandexTVScheduleParser()
|
||||
channel_parser = YandexTVChannelParser()
|
||||
|
||||
class YandexTvApi(ScheduleApi):
|
||||
PROVIDER = "yandextv"
|
||||
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
|
||||
lr = 213 # magic id
|
||||
|
||||
def _build_source(self) -> ApiSource:
|
||||
return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
|
||||
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
url = (
|
||||
"https://suggest-multi.yandex.ru/suggest-tv2?"
|
||||
f"v=4&uil=ru&lr=10&count_channels=4&count_programs=4&sn=50&part={query}"
|
||||
f"v=4&uil=ru&lr={self.lr}&count_channels=4&count_programs=4&sn=50&part={query}"
|
||||
)
|
||||
_, values = json.loads(await self.SOURCE.request(url))
|
||||
result = []
|
||||
for _, name, content in values:
|
||||
if content["label"] == "Каналы":
|
||||
channel_id = content["url"].split("/")[-1]
|
||||
result.append(Channel(id=channel_id, name=name, provider=self.provider))
|
||||
return result
|
||||
data = await self.source.request(url)
|
||||
return self.channel_parser.parse(data)
|
||||
|
||||
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
|
||||
endpoint = f"channels/{channel_id}?date={date:%Y-%m-%d}"
|
||||
data = await self.SOURCE.request(endpoint)
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
if soup.select_one(".CheckboxCaptcha") is not None:
|
||||
raise RuntimeError("Captcha")
|
||||
values = []
|
||||
channel_name = soup.select_one(".channel-header__text").text.strip()
|
||||
current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
|
||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in soup.select(".channel-schedule .channel-schedule__event"):
|
||||
title = item.select_one(".channel-schedule__title").text.strip()
|
||||
time_str = item.select_one(".channel-schedule__time").text.strip()
|
||||
hours, minutes = map(int, time_str.split(":"))
|
||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||
current_day += datetime.timedelta(days=1)
|
||||
item_date += datetime.timedelta(days=1)
|
||||
live = item.select_one(".channel-schedule__info .icon_live") is not None
|
||||
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||
values.append(value)
|
||||
if prev_value is not None:
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=Channel(id=channel_id, name=channel_name, provider=self.provider),
|
||||
date=date,
|
||||
values=values,
|
||||
)
|
||||
endpoint = f"{self.lr}/channel/{channel_id}?date={date:%Y-%m-%d}"
|
||||
data = await self.source.request(endpoint)
|
||||
return self.schedule_parser.parse(ScheduleData(date, channel_id, data))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
from gallery.sketch.parse.core import ParsedContext, ParseError
|
||||
from gallery.sketch.parse.json import JSONParser
|
||||
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
|
||||
|
||||
from ...sketch.parse.html import HTMLContextParser
|
||||
from . import PROVIDER
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleData:
|
||||
date: datetime.date
|
||||
channel_id: str
|
||||
data: str
|
||||
|
||||
|
||||
class YandexTVScheduleParser(HTMLContextParser[ScheduleData, Schedule]):
|
||||
def extract(self, data: ParsedContext[ScheduleData, Tag]) -> Schedule:
|
||||
if data.data.select_one(".CheckboxCaptcha") is not None:
|
||||
raise ParseError("Captcha")
|
||||
values = []
|
||||
channel_name = data.data.select_one(".channel-header__text").text.strip()
|
||||
current_day = datetime.datetime.combine(data.context.date, datetime.datetime.min.time())
|
||||
end = current_day + datetime.timedelta(days=1, hours=6)
|
||||
prev_value: ScheduleValue | None = None
|
||||
for item in data.data.select(".channel-schedule .channel-schedule__event"):
|
||||
title = item.select_one(".channel-schedule__title").text.strip()
|
||||
time_str = item.select_one(".channel-schedule__time").text.strip()
|
||||
hours, minutes = map(int, time_str.split(":"))
|
||||
item_date = current_day.replace(hour=hours, minute=minutes)
|
||||
if prev_value is not None and item_date.hour < prev_value.start.hour:
|
||||
current_day += datetime.timedelta(days=1)
|
||||
item_date += datetime.timedelta(days=1)
|
||||
live = item.select_one(".channel-schedule__info .icon_live") is not None
|
||||
value = ScheduleValue(start=item_date, end=end, label=title, live=live)
|
||||
values.append(value)
|
||||
if prev_value is not None:
|
||||
prev_value.end = item_date
|
||||
prev_value = value
|
||||
return Schedule(
|
||||
channel=Channel(id=data.context.channel_id, name=channel_name, provider=PROVIDER),
|
||||
date=data.context.date,
|
||||
values=values,
|
||||
)
|
||||
|
||||
|
||||
class YandexTVChannelParser(JSONParser[list[Channel]]):
|
||||
def extract(self, data: Any) -> list[Channel]:
|
||||
_, values = data
|
||||
result = []
|
||||
for _, name, content in values:
|
||||
if content["label"] == "Каналы":
|
||||
channel_id = content["url"].split("/")[-1]
|
||||
result.append(Channel(id=channel_id, name=name, provider=PROVIDER))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
PROVIDER = "yandex"
|
||||
@@ -0,0 +1,57 @@
|
||||
import datetime
|
||||
|
||||
from aiocache import cached
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
from gallery.sketch.weather.api import WeatherApi
|
||||
from gallery.sketch.weather.model import (
|
||||
Location,
|
||||
WeatherResponse,
|
||||
)
|
||||
from gallery.sketch.weather.util import merge_weather_values
|
||||
from gallery.util import TimeUnit
|
||||
|
||||
from . import PROVIDER
|
||||
from .parser import YandexForecastParser, YandexLocationParser
|
||||
|
||||
|
||||
class YandexWeatherApi(WeatherApi):
|
||||
PROVIDER = PROVIDER
|
||||
|
||||
forecast_parser = YandexForecastParser()
|
||||
location_parser = YandexLocationParser()
|
||||
|
||||
def _build_source(self) -> ApiSource:
|
||||
return ApiSource("https://yandex.ru")
|
||||
|
||||
@cached(
|
||||
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
|
||||
alias="redis",
|
||||
ttl=TimeUnit.HOUR,
|
||||
)
|
||||
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
|
||||
endpoint = f"pogoda/ru/{location_id}/details"
|
||||
content = await self.source.request(endpoint, mode="browser")
|
||||
return self.forecast_parser.parse(content)
|
||||
|
||||
async def find_locations(self, query: str) -> list[Location]:
|
||||
endpoint = f"weather/api/suggest?part={query}&lang=ru&type=weather"
|
||||
response = await self.source.request(endpoint)
|
||||
return self.location_parser.parse(response)
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
forecast = await self._get_location_forecast(location_id)
|
||||
for response in forecast:
|
||||
if response.date == date:
|
||||
return response
|
||||
raise ValueError(f"Unsupported date: {date}")
|
||||
|
||||
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
|
||||
forecast = await self._get_location_forecast(location_id)
|
||||
values = [merge_weather_values(item.date, item.values) for item in forecast]
|
||||
return WeatherResponse(
|
||||
location=forecast[0].location,
|
||||
date=datetime.date.today(),
|
||||
period="days",
|
||||
values=values,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
from gallery.sketch.parse.core import ParseError
|
||||
from gallery.sketch.parse.date import DATE_PARSER
|
||||
from gallery.sketch.parse.html import (
|
||||
HTMLParser,
|
||||
TableParser,
|
||||
TableValueParser,
|
||||
TagParser,
|
||||
)
|
||||
from gallery.sketch.parse.json import JSONParser
|
||||
from gallery.sketch.weather.model import (
|
||||
Cloudness,
|
||||
Location,
|
||||
Precipitation,
|
||||
Sky,
|
||||
WeatherResponse,
|
||||
WeatherValue,
|
||||
WindDirection,
|
||||
)
|
||||
from gallery.sketch.weather.parse.wind import WIND_DIRECTION_PARSER
|
||||
|
||||
from . import PROVIDER
|
||||
|
||||
|
||||
class LocationNameParser(TagParser[str]):
|
||||
selector = "[class^=Suggest_placeholder]"
|
||||
|
||||
def extract(self, data: Tag) -> str:
|
||||
return data.text.strip()
|
||||
|
||||
|
||||
class DateParser(TagParser[datetime.date]):
|
||||
selector = "[class^=AppForecastDayHeader_dayTitle]"
|
||||
|
||||
def extract(self, data: Tag) -> datetime.date:
|
||||
return DATE_PARSER.parse(data.text.split(",")[-1].strip())
|
||||
|
||||
|
||||
class TimeParser(TableValueParser[datetime.datetime]):
|
||||
selector = "[style$='-part']"
|
||||
model_key = "date"
|
||||
|
||||
HOUR_MAP = {
|
||||
"Утром": 6,
|
||||
"Днём": 12,
|
||||
"Вечером": 18,
|
||||
"Ночью": 0,
|
||||
}
|
||||
|
||||
def extract(self, data: Tag) -> datetime.datetime:
|
||||
hour = self.HOUR_MAP[data.text.strip()]
|
||||
return datetime.datetime.min.replace(hour=hour)
|
||||
|
||||
|
||||
class TemperatureParser(TableValueParser[list[int]]):
|
||||
selector = "[style$='-temp']"
|
||||
model_key = "temperature"
|
||||
|
||||
def extract(self, data: Tag) -> list[int]:
|
||||
return [int(data.text.strip().rstrip("°"))]
|
||||
|
||||
|
||||
class SkyParser(TableValueParser[Sky]):
|
||||
selector = "[style$='-text']"
|
||||
model_key = "sky"
|
||||
|
||||
CLOUDNESS_MAP = {
|
||||
"ясно": Cloudness.CLEAR,
|
||||
"облачно с прояснениями": Cloudness.CLOUDY,
|
||||
"малооблачно": Cloudness.CLOUDY,
|
||||
"пасмурно": Cloudness.MAINLY_CLOUDY,
|
||||
}
|
||||
|
||||
PRECIPITATION_MAP = {
|
||||
"небольшой дождь": Precipitation.SMALL_RAIN,
|
||||
"дождь": Precipitation.RAIN,
|
||||
"ливень": Precipitation.SHOWER,
|
||||
}
|
||||
|
||||
def extract(self, data: Tag) -> Sky:
|
||||
sky = data.text.strip()
|
||||
cloudness = Cloudness.CLEAR
|
||||
precipitation = Precipitation.NO
|
||||
thunder = False
|
||||
fog = False
|
||||
if sky in self.CLOUDNESS_MAP:
|
||||
cloudness = self.CLOUDNESS_MAP[sky]
|
||||
elif sky in self.PRECIPITATION_MAP:
|
||||
precipitation = self.PRECIPITATION_MAP[sky]
|
||||
else:
|
||||
raise ParseError("Unknown sky value", sky)
|
||||
return Sky(
|
||||
cloudness=cloudness,
|
||||
precipitation=precipitation,
|
||||
thunder=thunder,
|
||||
fog=fog,
|
||||
)
|
||||
|
||||
|
||||
class WindSpeedParser(TableValueParser[int]):
|
||||
selector = "[style$='-wind']:not([class*=AppForecastDayPart_wind_formatted])"
|
||||
model_key = "wind.speed"
|
||||
|
||||
def extract(self, data: Tag) -> int:
|
||||
return int(data.text.strip())
|
||||
|
||||
|
||||
class WindDirectionParser(TableValueParser[WindDirection | None]):
|
||||
selector = "[style$='-dir']"
|
||||
model_key = "wind.direction"
|
||||
|
||||
def extract(self, data: Tag) -> WindDirection | None:
|
||||
return WIND_DIRECTION_PARSER.parse(data.text.strip())
|
||||
|
||||
|
||||
class HumidityParser(TableValueParser[int]):
|
||||
selector = "[style$='-hum']"
|
||||
model_key = "humidity"
|
||||
|
||||
def extract(self, data: Tag) -> int:
|
||||
return int(data.text.strip().rstrip("%"))
|
||||
|
||||
|
||||
class PressureParser(TableValueParser[list[int]]):
|
||||
selector = "[style$='-press']"
|
||||
model_key = "pressure"
|
||||
|
||||
def extract(self, data: Tag) -> list[int]:
|
||||
return [int(data.text.strip().rstrip("%"))]
|
||||
|
||||
|
||||
class YandexTableParser(TableParser[WeatherValue]):
|
||||
selector = ""
|
||||
value_parsers = [
|
||||
TimeParser(),
|
||||
TemperatureParser(),
|
||||
SkyParser(),
|
||||
WindSpeedParser(),
|
||||
WindDirectionParser(),
|
||||
HumidityParser(),
|
||||
PressureParser(),
|
||||
]
|
||||
|
||||
def create_model(self, data: dict) -> WeatherValue:
|
||||
return WeatherValue.model_validate(data)
|
||||
|
||||
|
||||
class YandexForecastParser(HTMLParser[list[WeatherResponse]]):
|
||||
table_parser = YandexTableParser()
|
||||
date_parser = DateParser()
|
||||
location_parser = LocationNameParser()
|
||||
|
||||
def extract(self, data: Tag) -> list[WeatherResponse]:
|
||||
location = self.location_parser.parse(data)
|
||||
articles = data.select("article[class^=AppForecastDay]")
|
||||
return [
|
||||
WeatherResponse(
|
||||
location=location,
|
||||
date=self.date_parser.parse(item),
|
||||
period="day",
|
||||
values=self.table_parser.parse(item),
|
||||
)
|
||||
for item in articles
|
||||
]
|
||||
|
||||
|
||||
class YandexLocationParser(JSONParser[list[Location]]):
|
||||
|
||||
def extract(self, data: Any) -> list[Location]:
|
||||
return [
|
||||
Location(
|
||||
id=str(item["geoId"]),
|
||||
name=item["title"],
|
||||
provider=PROVIDER,
|
||||
lat=item["coords"]["lat"],
|
||||
lon=item["coords"]["lon"],
|
||||
country="ru",
|
||||
country_code="ru",
|
||||
district=item["description"],
|
||||
subdistrict="",
|
||||
)
|
||||
for item in data
|
||||
]
|
||||
+16
-1
@@ -1,10 +1,19 @@
|
||||
from typing import TypeVar
|
||||
|
||||
from .source import ApiSource
|
||||
|
||||
class Api:
|
||||
S = TypeVar("S", bound=ApiSource)
|
||||
|
||||
|
||||
class Api[S = ApiSource]:
|
||||
TYPE: str
|
||||
PROVIDER: str
|
||||
|
||||
_source: S | None = None
|
||||
|
||||
def _build_source(self) -> S:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self.TYPE
|
||||
@@ -13,5 +22,11 @@ class Api:
|
||||
def provider(self) -> str:
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Generic, NamedTuple
|
||||
|
||||
from gallery.sketch.source import ApiSource
|
||||
from gallery.util import TimeUnit
|
||||
|
||||
from .api import API, Api
|
||||
@@ -19,6 +20,9 @@ class CachedApi(Api, Generic[API]):
|
||||
def __init__(self, api: API):
|
||||
self._api = api
|
||||
|
||||
def _build_source(self) -> ApiSource:
|
||||
raise RuntimeError("Source not used in CachedApi")
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
return self._api.provider
|
||||
|
||||
@@ -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,10 +1,11 @@
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from ..source import ApiSource
|
||||
from .model import Channel, Schedule
|
||||
|
||||
|
||||
class ScheduleApi(Api):
|
||||
class ScheduleApi[S = ApiSource](Api[S]):
|
||||
TYPE = "schedule"
|
||||
|
||||
async def find_channels(self, query: str) -> list[Channel]:
|
||||
|
||||
+47
-14
@@ -1,41 +1,74 @@
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiohttp
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
logger = logging.getLogger("source")
|
||||
|
||||
RequestMode = Literal["base", "browser"]
|
||||
|
||||
DEFAULT_REQUEST_MODE: RequestMode = "base"
|
||||
DEFAULT_METHOD: str = "GET"
|
||||
|
||||
|
||||
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
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url,
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: float = DEFAULT_TIMEOUT,
|
||||
cookies: dict[str, str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
):
|
||||
self._base_url = base_url
|
||||
self._user_agent = user_agent
|
||||
self._timeout = timeout
|
||||
self._cookies = cookies
|
||||
self._headers = headers
|
||||
|
||||
async def request(self, endpoint: str) -> str:
|
||||
async def _base_request(
|
||||
self,
|
||||
url: str,
|
||||
method: str = DEFAULT_METHOD,
|
||||
data: Any | None = None,
|
||||
) -> str:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=self._headers,
|
||||
cookies=self._cookies,
|
||||
raise_for_status=True,
|
||||
) as session:
|
||||
async with session.request(method, url, timeout=self._timeout, data=data) as response:
|
||||
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)
|
||||
headers = {"User-Agent": self._user_agent, **(self._headers or {})}
|
||||
async with aiohttp.ClientSession(
|
||||
headers=headers,
|
||||
cookies=self._cookies,
|
||||
raise_for_status=True,
|
||||
) as session:
|
||||
async with session.request("GET", url, timeout=self._timeout) as response:
|
||||
return await response.text()
|
||||
fun = getattr(self, f"_{mode}_request")
|
||||
return await fun(url, method, data)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import datetime
|
||||
|
||||
from ..api import Api
|
||||
from ..source import ApiSource
|
||||
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]:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
from enum import StrEnum, auto
|
||||
from typing import Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -28,6 +28,11 @@ class Cloudness(StrEnum):
|
||||
CLOUDY = auto()
|
||||
MAINLY_CLOUDY = auto()
|
||||
|
||||
values: list[Self]
|
||||
|
||||
|
||||
Cloudness.values = list(Cloudness)
|
||||
|
||||
|
||||
class Precipitation(StrEnum):
|
||||
NO = auto()
|
||||
@@ -39,16 +44,20 @@ class Precipitation(StrEnum):
|
||||
HEAVY_SNOW = auto()
|
||||
HAIL = auto()
|
||||
|
||||
values: list[Self]
|
||||
|
||||
|
||||
Precipitation.values = list(Precipitation)
|
||||
|
||||
|
||||
class Sky(Model):
|
||||
cloudness: Cloudness
|
||||
precipitation: Precipitation
|
||||
thunder: bool
|
||||
fog: bool
|
||||
cloudness: Cloudness = Cloudness.CLEAR
|
||||
precipitation: Precipitation = Precipitation.NO
|
||||
thunder: bool = False
|
||||
fog: bool = False
|
||||
|
||||
|
||||
class WindDirection(StrEnum):
|
||||
CALM = auto()
|
||||
N = auto()
|
||||
NE = auto()
|
||||
E = auto()
|
||||
@@ -59,69 +68,24 @@ class WindDirection(StrEnum):
|
||||
NW = auto()
|
||||
|
||||
|
||||
class WindDirectionDeg(float):
|
||||
@property
|
||||
def direction(self) -> WindDirection:
|
||||
return self.to_direction()
|
||||
|
||||
@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) -> Self:
|
||||
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 Wind(Model):
|
||||
speed: int = 0
|
||||
gust: int = 0
|
||||
direction: WindDirection | None = None
|
||||
|
||||
|
||||
class WeatherValue(Model):
|
||||
date: datetime.datetime
|
||||
sky: Sky
|
||||
temperature: list[int]
|
||||
wind_speed: int
|
||||
wind_gust: int
|
||||
wind_direction: float
|
||||
precipitation: float
|
||||
pressure: list[int] = Field(default=[])
|
||||
humidity: int = Field(default=-1)
|
||||
sky: Sky = Field(default_factory=Sky)
|
||||
wind: Wind = Field(default_factory=Wind)
|
||||
temperature: list[int] = Field(default_factory=list)
|
||||
precipitation: float = 0
|
||||
pressure: list[int] = Field(default_factory=list)
|
||||
humidity: int = -1
|
||||
|
||||
|
||||
class WeatherResponse(Model):
|
||||
location: str
|
||||
date: datetime.date
|
||||
period: str
|
||||
period: Literal["day", "days"] = "day"
|
||||
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 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:
|
||||
return WeatherValue(
|
||||
date=date,
|
||||
sky=Sky(
|
||||
cloudness=Cloudness.CLEAR,
|
||||
precipitation=Precipitation.NO,
|
||||
thunder=False,
|
||||
fog=False,
|
||||
),
|
||||
temperature=[],
|
||||
wind_speed=0,
|
||||
wind_gust=0,
|
||||
wind_direction=WindDirectionDeg(-1),
|
||||
precipitation=0,
|
||||
pressure=[],
|
||||
humidity=0,
|
||||
)
|
||||
def _merge_values(values: list[int], mode: MergeMode) -> list[int]:
|
||||
if mode == "interval":
|
||||
if (max_value := max(values)) == (min_value := min(values)):
|
||||
return [max_value]
|
||||
else:
|
||||
return [max_value, min_value]
|
||||
else:
|
||||
return [round(sum(values) / len(values))]
|
||||
|
||||
|
||||
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
|
||||
result = build_weather_value(date)
|
||||
def merge_weather_values(
|
||||
date: datetime.datetime,
|
||||
values: list[WeatherValue],
|
||||
mode: MergeMode = "interval",
|
||||
) -> WeatherValue:
|
||||
result = WeatherValue(date=date)
|
||||
temperatures = []
|
||||
pressures = []
|
||||
humidities = []
|
||||
@@ -38,25 +36,43 @@ def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) ->
|
||||
temperatures += value.temperature
|
||||
pressures += value.pressure
|
||||
humidities.append(value.humidity)
|
||||
wind_speeds.append(value.wind_speed)
|
||||
wind_gusts.append(value.wind_gust)
|
||||
wind_directions.append(value.wind_direction)
|
||||
wind_speeds.append(value.wind.speed)
|
||||
wind_gusts.append(value.wind.gust)
|
||||
wind_directions.append(value.wind.direction)
|
||||
cloudnesses.append(value.sky.cloudness)
|
||||
precipitations.append(value.sky.precipitation)
|
||||
precipitation += value.precipitation
|
||||
result.temperature = [max(temperatures), min(temperatures)]
|
||||
result.pressure = [max(pressures), min(pressures)]
|
||||
result.temperature = _merge_values(temperatures, mode)
|
||||
result.pressure = _merge_values(pressures, mode)
|
||||
result.humidity = round(statistics.mean(humidities))
|
||||
result.wind_speed = round(statistics.mean(wind_speeds))
|
||||
result.wind_gust = round(statistics.mean(wind_gusts))
|
||||
result.wind_direction = statistics.mean(wind_directions)
|
||||
# TODO: merge cloudnesses
|
||||
result.wind.speed = round(statistics.mean(wind_speeds))
|
||||
result.wind.gust = round(statistics.mean(wind_gusts))
|
||||
result.wind.direction = Counter(wind_directions).most_common(1)[0][0]
|
||||
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
|
||||
# TODO: merge 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.precipitation = precipitation
|
||||
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
|
||||
|
||||
@@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст."
|
||||
msgid "Humidity, %"
|
||||
msgstr "Влажность, %"
|
||||
|
||||
msgid "Stored locations"
|
||||
msgstr "Сохранённые локации"
|
||||
|
||||
# tv
|
||||
msgid "TV program"
|
||||
msgstr "Телепрограмма"
|
||||
|
||||
msgid "Live broadcasts"
|
||||
msgstr "Прямые трансляции"
|
||||
|
||||
msgid "Stored channels"
|
||||
msgstr "Сохранённые каналы"
|
||||
|
||||
msgid "Channel groups"
|
||||
msgstr "Группы каналов"
|
||||
Generated
+268
-1
@@ -388,6 +388,140 @@ files = [
|
||||
]
|
||||
markers = {main = "extra == \"app\" and (platform_system == \"Windows\" or sys_platform == \"win32\")", dev = "platform_system == \"Windows\" or sys_platform == \"win32\"", test = "sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.1"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "coverage-7.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f12a9e27ca7b65e40a8475d27899b2d45064d9020e6a89148939e01987b5853"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f590d46c30d9e4c1fda3efefe5443f4c2f6a4192c5ca2ba403653e9ebadf097"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3df82f0a3cef4e1bcfc799436056f0b978dda319d0bfd4460c6e479b2802d98a"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb462d59146656e278d1e8ed913ce374d0ba68a4e081acde1867f6d3377fc881"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7db888dd0a1df1a653cae7f99d4047430d2187a3626eda51bc847b0fd6b9b43"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38a7e16f061504ac2b45370bf5bf97e8250d8d3f25e37385bae884978166554b"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b26b55b18e1a53e1a159dd743728c4ddbbef28ba19000a91aaff5ce023197ec"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb2f528b50953e29d22033b3256c396a700193c6e45b2490222ef9c333cbbf9"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3284754371dc78592aa3ae4d661d30ee20e02b2b6b0de3590a181a936d0d3b38"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:802d1246c540e07486d4ee1adfa19a797e4b33529ffc371dc140644e8f27da0a"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6fc735d6fe6d57f803e7ba021be4dde48e43e6aff94e6954350555d5332b0594"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed5ade1bb18f62edace1bd198c66f9d4c75a8385d5fd24e87d917ea1a5958773"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-win32.whl", hash = "sha256:0c309096926b119543dc16438a11ef4c80783d2f4e59ff94f7f462651a944cdc"},
|
||||
{file = "coverage-7.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:7562f8067ed9360e8b9739e5703403a7686dd1b36bf0f89fc538047c54cdea90"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7"},
|
||||
{file = "coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e"},
|
||||
{file = "coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa"},
|
||||
{file = "coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b"},
|
||||
{file = "coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d"},
|
||||
{file = "coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d"},
|
||||
{file = "coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db"},
|
||||
{file = "coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0"},
|
||||
{file = "coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4"},
|
||||
{file = "coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "dateparser"
|
||||
version = "1.4.1"
|
||||
@@ -853,6 +987,99 @@ files = [
|
||||
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.5"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4"},
|
||||
{file = "greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53"},
|
||||
{file = "greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18"},
|
||||
{file = "greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd"},
|
||||
{file = "greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71"},
|
||||
{file = "greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8"},
|
||||
{file = "greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b"},
|
||||
{file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx", "furo"]
|
||||
test = ["objgraph", "psutil", "setuptools"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -1396,6 +1623,28 @@ files = [
|
||||
{file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.62.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f"},
|
||||
{file = "playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034"},
|
||||
{file = "playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da"},
|
||||
{file = "playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3"},
|
||||
{file = "playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff"},
|
||||
{file = "playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c"},
|
||||
{file = "playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1"},
|
||||
{file = "playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = ">=3.1.1,<4.0.0"
|
||||
pyee = ">=13,<14"
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -1751,6 +2000,24 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
|
||||
toml = ["tomli (>=2.0.1)"]
|
||||
yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
|
||||
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -3015,4 +3282,4 @@ app = ["babel", "fastapi", "jinja2"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.14,<4.0"
|
||||
content-hash = "7f8a3d007747f68e9735e957a0c06459044c8006b89cf3f5d37512f36638abd5"
|
||||
content-hash = "1494c77fcc28f6899d0fd2101647cb880e793805898472a96e2a66b125dd6c72"
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "gallery"
|
||||
description = ""
|
||||
version = "0.5.4"
|
||||
version = "0.10.1"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
|
||||
requires-python = '>=3.14,<4.0'
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
'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]
|
||||
@@ -21,7 +22,7 @@ app = [
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
test = ["pytest (>=9.1,<10.0)", "pytest-asyncio (>=1.4,<2.0)"]
|
||||
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]
|
||||
@@ -37,6 +38,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ["py314"]
|
||||
|
||||
[tool.isort]
|
||||
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,55 +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:$DOCKER_TAG
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
|
||||
docker push $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
|
||||
done
|
||||
}
|
||||
|
||||
save () {
|
||||
echo "save: $1"
|
||||
. "$1/.env"
|
||||
mkdir -p "$1/dist"
|
||||
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
|
||||
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
|
||||
docker save --output "$1/dist/$PROJECT_NAME-$VERSION.tar" "$DOCKER_GROUP/$PROJECT_NAME"
|
||||
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 -sn
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
# env
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env-base .env
|
||||
fi
|
||||
source .env
|
||||
|
||||
# python
|
||||
poetry env use ${PYTHON_VERSION}
|
||||
poetry install
|
||||
|
||||
# static
|
||||
cd static || exit
|
||||
|
||||
if [[ -f $HOME/.nvm/nvm.sh ]]; then
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
nvm use
|
||||
fi
|
||||
npm ci
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
OPENWEATHER_KEY="" poetry run pytest tests
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
cd "$(dirname $(dirname "$0"))" || exit
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 [version]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -z "$(git status -s)" ]]; then
|
||||
echo "Uncomitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
poetry version $1
|
||||
(cd static && npm version $1 --allow-same-version)
|
||||
git add .
|
||||
git commit -m "ci(version): $1"
|
||||
git tag $1
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.5.4",
|
||||
"version": "0.10.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gallery",
|
||||
"version": "0.5.4",
|
||||
"version": "0.10.1",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gallery",
|
||||
"version": "0.5.4",
|
||||
"version": "0.10.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
export interface Type<T> extends Function {
|
||||
new (...args: any[]): T;
|
||||
}
|
||||
|
||||
export abstract class ValueElement<T, O> extends HTMLElement {
|
||||
static observedAttributes = ["value"];
|
||||
|
||||
private _value: T | null = null;
|
||||
|
||||
get value(): T | null {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set value(value: T | null) {
|
||||
if (value) {
|
||||
this.setAttribute("value", JSON.stringify(value));
|
||||
} else if (this.hasAttribute("value")) {
|
||||
this.removeAttribute("value");
|
||||
}
|
||||
}
|
||||
|
||||
options: O | null;
|
||||
|
||||
constructor(value: T | null = null, options: O | null = null) {
|
||||
super();
|
||||
if (value) {
|
||||
this.value = value;
|
||||
}
|
||||
this.options = options;
|
||||
this.onInit();
|
||||
}
|
||||
|
||||
onInit() {}
|
||||
|
||||
beforeRefresh() {}
|
||||
|
||||
onRefresh() {}
|
||||
|
||||
abstract buildHTML(): string;
|
||||
|
||||
refresh() {
|
||||
const rawValue = this.getAttribute("value");
|
||||
if (rawValue) {
|
||||
this._value = JSON.parse(rawValue);
|
||||
} else {
|
||||
this._value = null;
|
||||
}
|
||||
if (this.className) {
|
||||
this.classList = this.className;
|
||||
}
|
||||
this.beforeRefresh();
|
||||
this.innerHTML = this.buildHTML();
|
||||
this.onRefresh();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
|
||||
setTimeout(() => this.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListOptions<T> {
|
||||
onClick?: (element: ListItemElement<T>) => void;
|
||||
onAdd?: (element: ListItemElement<T>) => void;
|
||||
onRemove?: (element: ListItemElement<T>) => void;
|
||||
}
|
||||
|
||||
export abstract class ListItemElement<T> extends ValueElement<T, ListOptions<T>> {
|
||||
abstract getHref(value: T): string;
|
||||
|
||||
abstract getContent(value: T): string;
|
||||
|
||||
className = "list-group-item list-group-item-action";
|
||||
|
||||
onInit(): void {
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleRemoveClick = this.handleRemoveClick.bind(this);
|
||||
this.handleAddClick = this.handleAddClick.bind(this);
|
||||
}
|
||||
|
||||
buildHTML(): string {
|
||||
if (!this.value) {
|
||||
return "";
|
||||
}
|
||||
const href = this.getHref(this.value);
|
||||
const content = this.getContent(this.value);
|
||||
return `
|
||||
<a href="${href}"
|
||||
class="d-flex justify-content-between align-items-start text-decoration-none">
|
||||
<span>
|
||||
<span class="text-success me-2 ${this.options?.onAdd ? "" : "d-none"}" style="color: green;" data-add>
|
||||
+
|
||||
</span>
|
||||
${content}
|
||||
</span>
|
||||
<span>
|
||||
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
|
||||
✕
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
onRefresh(): void {
|
||||
this.addEventListener("click", this.handleClick);
|
||||
if (this.options?.onAdd) {
|
||||
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
|
||||
} else {
|
||||
this.querySelector("[data-add]")?.remove();
|
||||
}
|
||||
if (this.options?.onRemove) {
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
|
||||
} else {
|
||||
this.querySelector("[data-remove]")?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent) {
|
||||
if (this.value && this.options?.onClick) {
|
||||
this.options.onClick(this);
|
||||
}
|
||||
}
|
||||
|
||||
handleAddClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.value && this.options?.onAdd) {
|
||||
this.options.onAdd(this);
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.value && this.options?.onRemove) {
|
||||
this.options.onRemove(this);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("click", this.handleClick);
|
||||
this.querySelector<HTMLElement>("[data-add]")?.removeEventListener("click", this.handleAddClick);
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ListElement<T, C extends ListItemElement<T> = ListItemElement<T>> extends ValueElement<
|
||||
T[],
|
||||
ListOptions<T>
|
||||
> {
|
||||
static observedAttributes = ["value", "onadd", "onremove", "onclick"];
|
||||
|
||||
abstract childType: Type<C>;
|
||||
|
||||
className = "list-group";
|
||||
|
||||
buildHTML(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
beforeRefresh() {
|
||||
if (this.hasAttribute("onclick")) {
|
||||
const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions<T>["onClick"];
|
||||
if (!this.options) {
|
||||
this.options = {};
|
||||
}
|
||||
this.options.onClick = onclick;
|
||||
}
|
||||
if (this.hasAttribute("onadd")) {
|
||||
const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions<T>["onAdd"];
|
||||
if (!this.options) {
|
||||
this.options = {};
|
||||
}
|
||||
this.options.onAdd = onadd;
|
||||
}
|
||||
if (this.hasAttribute("onremove")) {
|
||||
const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions<T>["onRemove"];
|
||||
if (!this.options) {
|
||||
this.options = {};
|
||||
}
|
||||
this.options.onRemove = onremove;
|
||||
}
|
||||
}
|
||||
|
||||
onRefresh() {
|
||||
if (this.value) {
|
||||
for (const item of this.value) {
|
||||
this.appendChild(new this.childType(item, this.options));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ListElement, ListItemElement } from "./component";
|
||||
import { DataStorage, StorageValue } from "./storage";
|
||||
|
||||
export class DataManager<T extends StorageValue> {
|
||||
private storage: DataStorage<T>;
|
||||
|
||||
private get dataList(): ListElement<T> {
|
||||
return document.querySelector(this.dataListSelector)!;
|
||||
}
|
||||
|
||||
constructor(
|
||||
listComponentType: typeof ListElement<T>, //prevent removing as unusing
|
||||
storageKey: string,
|
||||
private dataListSelector: string,
|
||||
) {
|
||||
this.storage = new DataStorage(storageKey);
|
||||
this.load = this.load.bind(this);
|
||||
this.store = this.store.bind(this);
|
||||
this.add = this.add.bind(this);
|
||||
this.remove = this.remove.bind(this);
|
||||
}
|
||||
|
||||
load() {
|
||||
this.dataList.value = this.storage.getAll();
|
||||
}
|
||||
|
||||
store(element: ListItemElement<T>) {
|
||||
if (element.value) {
|
||||
this.storage.add(element.value);
|
||||
}
|
||||
}
|
||||
|
||||
add(element: ListItemElement<T>) {
|
||||
const value = element.value;
|
||||
value;
|
||||
if (value && this.storage.add(value)) {
|
||||
const clone: ListItemElement<T> = element.cloneNode(true) as ListItemElement<T>;
|
||||
clone.options = { onRemove: this.remove };
|
||||
this.dataList.appendChild(clone);
|
||||
}
|
||||
}
|
||||
|
||||
remove(element: ListItemElement<T>) {
|
||||
const value = element.value;
|
||||
if (value) {
|
||||
this.storage.remove(value.id);
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface StorageValue {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export class DataStorage<T extends StorageValue> {
|
||||
constructor(private storageKey: string) {}
|
||||
|
||||
private readValues(): T[] {
|
||||
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "[]");
|
||||
if (!Array.isArray(objects)) {
|
||||
return Object.values(objects);
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
private writeValues(values: T[]) {
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(values));
|
||||
}
|
||||
|
||||
getAll(): T[] {
|
||||
return this.readValues();
|
||||
}
|
||||
|
||||
add(value: T): boolean {
|
||||
const values = this.readValues();
|
||||
for (const v of values) {
|
||||
if (v.id == value.id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
values.push(value);
|
||||
this.writeValues(values);
|
||||
return true;
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const values = this.readValues();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i].id == id) {
|
||||
values.splice(i, 1);
|
||||
this.writeValues(values);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export class ScheduleChannelStorage {
|
||||
private storageKey: string = "schedule:channels";
|
||||
|
||||
getAll(): Channel[] {
|
||||
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
return Object.values(objects);
|
||||
}
|
||||
|
||||
add(object: Channel) {
|
||||
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
objects[object.id] = object;
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const objects = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
delete objects[id];
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(objects));
|
||||
}
|
||||
}
|
||||
|
||||
export class ScheduleChannelManager {
|
||||
loadChannels(selector: string) {
|
||||
const channels = window.scheduleChannelStorage.getAll();
|
||||
const container = document.querySelector(selector);
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
for (const channel of channels) {
|
||||
const element = new ScheduleChannelElement();
|
||||
element.setAttribute("channel", JSON.stringify(channel));
|
||||
element.setAttribute("removable", "");
|
||||
container.appendChild(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduleChannelElement extends HTMLElement {
|
||||
static observedAttributes = ["channel", "removable"];
|
||||
|
||||
channel: Channel | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleRemoveClick = this.handleRemoveClick.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.channel = JSON.parse(this.getAttribute("channel") || "{}");
|
||||
this.innerHTML = `
|
||||
<a href="${this.channel?.provider}/${this.channel?.id}"
|
||||
class="d-flex justify-content-between align-items-start text-decoration-none">
|
||||
<span>
|
||||
<span class="text-primary">${this.channel?.name}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge text-bg-secondary">${this.channel?.provider}</span>
|
||||
<span class="text-danger ms-2" data-remove>
|
||||
✕
|
||||
</span>
|
||||
</span>
|
||||
</a>`;
|
||||
this.classList = "list-group-item list-group-item-action";
|
||||
this.addEventListener("click", this.handleClick);
|
||||
if (this.hasAttribute("removable")) {
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
|
||||
} else {
|
||||
this.querySelector("[data-remove]")?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("click", this.handleClick);
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent) {
|
||||
if (this.channel) {
|
||||
window.scheduleChannelStorage.add(this.channel);
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.channel) {
|
||||
window.scheduleChannelStorage.remove(this.channel.id);
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("schedule-channel", ScheduleChannelElement);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
scheduleChannelStorage: ScheduleChannelStorage;
|
||||
scheduleChannelManager: ScheduleChannelManager;
|
||||
}
|
||||
}
|
||||
|
||||
window.scheduleChannelStorage = new ScheduleChannelStorage();
|
||||
window.scheduleChannelManager = new ScheduleChannelManager();
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ListElement, ListItemElement, ValueElement } from "../base/component";
|
||||
import { Channel } from "./model";
|
||||
|
||||
export class ScheduleChannelGroup extends ValueElement<string | Channel[], {}> {
|
||||
className: string = "list-group-item list-group-item-action";
|
||||
|
||||
buildHTML(): string {
|
||||
const groupName = Array.isArray(this.value) ? "Open All" : this.value;
|
||||
const groupId = Array.isArray(this.value)
|
||||
? this.value.map((item) => `${item.provider}:${item.id}`).join(",")
|
||||
: this.value || "";
|
||||
return `
|
||||
<a href="group/${encodeURIComponent(groupId)}"
|
||||
class="d-flex justify-content-between align-items-start text-decoration-none">
|
||||
<span>
|
||||
<span class="text-primary" style="text-transform: capitalize">${groupName}</span>
|
||||
</span>
|
||||
</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("schedule-channel-group", ScheduleChannelGroup);
|
||||
|
||||
export class ScheduleChannelItem extends ListItemElement<Channel> {
|
||||
getHref(value: Channel): string {
|
||||
return `${value.provider}/${value.id}`;
|
||||
}
|
||||
|
||||
getContent(value: Channel): string {
|
||||
return `
|
||||
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
|
||||
<span class="text-primary">${value.name}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ScheduleChannelList extends ListElement<Channel, ScheduleChannelItem> {
|
||||
childType = ScheduleChannelItem;
|
||||
|
||||
public onRefresh(): void {
|
||||
super.onRefresh();
|
||||
if (this.value?.length) {
|
||||
const groupElement = new ScheduleChannelGroup(this.value);
|
||||
this.prepend(groupElement);
|
||||
} else {
|
||||
this.prepend("Empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("schedule-channel-item", ScheduleChannelItem);
|
||||
customElements.define("schedule-channel-list", ScheduleChannelList);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DataManager } from "../base/manager";
|
||||
import { ScheduleChannelList } from "./component";
|
||||
import { Channel } from "./model";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
scheduleChannelManager: DataManager<Channel>;
|
||||
}
|
||||
}
|
||||
|
||||
window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels");
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
export interface Location {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
country_code: string;
|
||||
country?: string;
|
||||
district?: string;
|
||||
subdistrict?: string;
|
||||
}
|
||||
|
||||
export class WeatherLocationStorage {
|
||||
private storageKey: string = "weather:locations";
|
||||
|
||||
getAll(): Location[] {
|
||||
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
return Object.values(locations);
|
||||
}
|
||||
|
||||
add(location: Location) {
|
||||
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
locations[location.id] = location;
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
|
||||
}
|
||||
|
||||
remove(id: string) {
|
||||
const locations = JSON.parse(window.localStorage.getItem(this.storageKey) || "{}");
|
||||
delete locations[id];
|
||||
window.localStorage.setItem(this.storageKey, JSON.stringify(locations));
|
||||
}
|
||||
}
|
||||
|
||||
export class WeatherLocationManager {
|
||||
loadLocations(selector: string) {
|
||||
const locations = window.weatherLocationStorage.getAll();
|
||||
const container = document.querySelector(selector);
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
for (const location of locations) {
|
||||
const element = new WeatherLocationElement();
|
||||
element.setAttribute("location", JSON.stringify(location));
|
||||
element.setAttribute("removable", "");
|
||||
container.appendChild(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WeatherLocationElement extends HTMLElement {
|
||||
static observedAttributes = ["location", "removable"];
|
||||
|
||||
location: Location | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
this.handleRemoveClick = this.handleRemoveClick.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.location = JSON.parse(this.getAttribute("location") || "{}");
|
||||
this.innerHTML = `
|
||||
<a href="${this.location?.provider}/${this.location?.id}"
|
||||
class="d-flex justify-content-between align-items-start text-decoration-none">
|
||||
<span>
|
||||
<span class="fi fi-${this.location?.country_code} me-1"></span>
|
||||
<span class="text-primary">${this.location?.name}</span>
|
||||
<span class="small ms-1 text-secondary">
|
||||
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge text-bg-secondary">${this.location?.provider}</span>
|
||||
<span class="text-danger ms-2" data-remove>
|
||||
✕
|
||||
</span>
|
||||
</span>
|
||||
</a>`;
|
||||
this.classList = 'list-group-item list-group-item-action';
|
||||
this.addEventListener("click", this.handleClick);
|
||||
if (this.hasAttribute("removable")) {
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
|
||||
} else {
|
||||
this.querySelector("[data-remove]")?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("click", this.handleClick);
|
||||
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent) {
|
||||
if (this.location) {
|
||||
window.weatherLocationStorage.add(this.location);
|
||||
}
|
||||
}
|
||||
|
||||
handleRemoveClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (this.location) {
|
||||
window.weatherLocationStorage.remove(this.location.id);
|
||||
}
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("weather-location", WeatherLocationElement);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
weatherLocationStorage: WeatherLocationStorage;
|
||||
weatherLocationManager: WeatherLocationManager;
|
||||
}
|
||||
}
|
||||
|
||||
window.weatherLocationStorage = new WeatherLocationStorage();
|
||||
window.weatherLocationManager = new WeatherLocationManager();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ListElement, ListItemElement } from "../base/component";
|
||||
import { Location } from "./model";
|
||||
|
||||
export class WheatherLocationItem extends ListItemElement<Location> {
|
||||
getHref(value: Location): string {
|
||||
return `${value.provider}/${value.id}`;
|
||||
}
|
||||
|
||||
getContent(value: Location): string {
|
||||
return `
|
||||
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
|
||||
<span class="fi fi-${value.country_code} me-1"></span>
|
||||
<span class="text-primary">${value.name}</span>
|
||||
<span class="small ms-1 text-secondary">
|
||||
${value.country}, ${value.district}, ${value.subdistrict}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export class WeatherLocationList extends ListElement<Location, WheatherLocationItem> {
|
||||
childType = WheatherLocationItem;
|
||||
|
||||
public onRefresh(): void {
|
||||
super.onRefresh();
|
||||
if (!this.value?.length) {
|
||||
this.prepend("Empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("weather-location-item", WheatherLocationItem);
|
||||
customElements.define("weather-location-list", WeatherLocationList);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DataManager } from "../base/manager";
|
||||
import { WeatherLocationList } from "./component";
|
||||
import { Location } from "./model";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
weatherLocationManager: DataManager<Location>;
|
||||
}
|
||||
}
|
||||
|
||||
window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface Location {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
country_code: string;
|
||||
country?: string;
|
||||
district?: string;
|
||||
subdistrict?: string;
|
||||
}
|
||||
Vendored
+3
@@ -45,4 +45,7 @@
|
||||
&.bi-file-earmark-text {
|
||||
mask-image: url(bootstrap-icons/icons/file-earmark-text.svg);
|
||||
}
|
||||
&.bi-cast {
|
||||
mask-image: url(bootstrap-icons/icons/cast.svg);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,5 +1,7 @@
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
||||
from gallery.sketch.api import Api, S
|
||||
from gallery.sketch.source import ApiSource
|
||||
|
||||
|
||||
@@ -10,8 +12,20 @@ class MockSource(ApiSource):
|
||||
self._path = path
|
||||
self._mapping = mapping
|
||||
|
||||
async def request(self, endpoint: str) -> str:
|
||||
# pylint:disable=arguments-differ,unused-argument
|
||||
async def request(self, endpoint: str, /, **kwargs) -> str:
|
||||
for pattern, filename in self._mapping.items():
|
||||
if pattern in endpoint:
|
||||
return (self._path / filename).read_text()
|
||||
raise ValueError(endpoint)
|
||||
|
||||
|
||||
API = Api[S]
|
||||
A = TypeVar("A", bound=API)
|
||||
|
||||
|
||||
def create_mock_api(api_type: type[A], mock_source: MockSource) -> A:
|
||||
api = api_type()
|
||||
# pylint: disable=protected-access
|
||||
api._build_source = lambda: mock_source
|
||||
return api
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
from os import environ
|
||||
|
||||
from aiocache import caches
|
||||
|
||||
import gallery # pylint:disable=unused-import
|
||||
|
||||
environ.setdefault("OPENWEATHER_KEY", "test")
|
||||
|
||||
caches.set_config(
|
||||
{
|
||||
"default": {
|
||||
"cache": "aiocache.SimpleMemoryCache",
|
||||
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
||||
},
|
||||
"redis": {
|
||||
"cache": "aiocache.SimpleMemoryCache",
|
||||
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
from gallery.painting.openweather.openweather import OpenWeather
|
||||
from tests.common.mock import MockSource
|
||||
|
||||
OPENWEATHER_MOCK_SOURCE = MockSource(
|
||||
|
||||
class OpenWeatherMockSource(MockSource, OpenWeather):
|
||||
pass
|
||||
|
||||
|
||||
OPENWEATHER_MOCK_SOURCE = OpenWeatherMockSource(
|
||||
Path(__file__).parent,
|
||||
{
|
||||
"forecast": "forecast.json",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from pathlib import Path
|
||||
|
||||
from tests.common.mock import MockSource
|
||||
|
||||
RP5_MOCK_SOURCE = MockSource(
|
||||
Path(__file__).parent,
|
||||
{
|
||||
"search": "search.html",
|
||||
"test": "weather.html",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,379 @@
|
||||
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml"><head>
|
||||
<title>Результат поиска</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"dateModified": "2026-09-01"
|
||||
}
|
||||
</script>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="description" content="Результат поиска. Результаты поиска.">
|
||||
<meta name="keywords" content="результат поиска, результаты поиска">
|
||||
<meta name="viewport" content="height=device-height, user-scalable=yes">
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="msapplication-config" content="/browserconfig.xml">
|
||||
<meta name="msapplication-TileColor" content="#005eb5">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_57x57.png" sizes="57x57">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_60x60.png" sizes="60x60">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_72x72.png" sizes="72x72">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_76x76.png" sizes="76x76">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_114x114.png" sizes="114x114">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_120x120.png" sizes="120x120">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_152x152.png" sizes="152x152">
|
||||
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_180x180.png" sizes="180x180">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/192x192.png" sizes="192x192">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/144x144.png" sizes="144x144">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/128x128.png" sizes="128x128">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/96x96.png" sizes="96x96">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/72x72.png" sizes="72x72">
|
||||
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/48x48.png" sizes="48x48">
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="/css/mainStyle.css?q=1.0182" type="text/css">
|
||||
<link rel="stylesheet" href="/css/sprites.css?q=1.0182" type="text/css">
|
||||
<link rel="stylesheet" href="/css/jquery.autocomplete.css?q=1.0182" type="text/css">
|
||||
<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false&key=AIzaSyAvCiizuGON7hMCTMa79Zf7d_4k56cCJ_0"></script>
|
||||
<style media="print">
|
||||
</style>
|
||||
|
||||
<script type="text/javascript" src="/scripts/jquery-1.6.4.min.js"></script>
|
||||
<script type="text/javascript" src="/scripts/jquery.autocomplete.01.cr.js"></script>
|
||||
<script type="text/javascript" src="/scripts/browser.js"></script>
|
||||
<script type="text/javascript" src="/scripts/googlemap.js"></script>
|
||||
<script type="text/javascript" src="/scripts/main.022.cr.js?q=1.052"></script>
|
||||
<script type="text/javascript" src="/scripts/rp5hint.js"></script>
|
||||
<script type="text/javascript" src="/scripts/rcs.js"></script>
|
||||
<script type="text/javascript" src="/scripts/infobubble.001.js"></script>
|
||||
<script type="text/javascript" src="/scripts/maps.008.js"></script>
|
||||
<script type="text/javascript" src="/scripts/geo_vidget.js"></script>
|
||||
<!--<script type="text/javascript" src="//static.criteo.net/js/ld/publishertag.js"></script>-->
|
||||
<script>window.yaContextCb=window.yaContextCb||[]</script>
|
||||
<script src="https://yandex.ru/ads/system/context.js" [^]="" async=""></script><script type="text/javascript" language="JavaScript">cRole = ""; langId = 2; cSearch = "Название города или села";</script>
|
||||
<script type="text/javascript">
|
||||
// локальное время
|
||||
var gmtAdd = 10800000;
|
||||
var sumHour = 0;
|
||||
var cLocalTime = 'Местное время';
|
||||
// установки времени
|
||||
var Dphp = 1788293743;
|
||||
var gmtServ = 0;
|
||||
var Djs = Math.round(((D=new Date()).getTime()-D.getTimezoneOffset()*60000)/1000);
|
||||
// дельта во времени в сек.
|
||||
var tDelta = Dphp - Djs;
|
||||
</script>
|
||||
<style>
|
||||
#h8c34381aaf77ce0fb634a3b6884f16046b9fd201 {display: inline-block; width: 126px; margin: 79px 0 0 8px;}
|
||||
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {clear: both;}
|
||||
@media print {
|
||||
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {margin-top: 20px; background: #fff;}
|
||||
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {display: none !important;}
|
||||
}
|
||||
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {float: left; font-size: 12px; display: inline; margin: 5px 0 11px 0;}
|
||||
</style>
|
||||
<script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/common.js"></script><script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/util.js"></script></head>
|
||||
<body>
|
||||
<div id="back-wrapper" style="width: 1670px; height: 688px;"></div>
|
||||
<div id="message-outside" class="message-outside">
|
||||
<div id="message-outside-content" class="message-outside-content"></div>
|
||||
<div class="button-container">
|
||||
<input type="button" class="button-blue" value="OK" onclick="displayBlocks('#message-outside','','',true); return false;">
|
||||
</div>
|
||||
</div>
|
||||
<div id="map-screen-container">
|
||||
<div id="map-screen-close"></div>
|
||||
<div id="map-screen"></div>
|
||||
</div>
|
||||
|
||||
<div id="wrapper">
|
||||
<div id="header-wrapper" class="noprint">
|
||||
<div id="header">
|
||||
<table>
|
||||
<tbody><tr>
|
||||
<td rowspan="3" style="width: 180px;" class="td-top">
|
||||
<div id="logo">
|
||||
<a href="/Погода_в_мире" title=""><img src="/images/ru/logo.png" title="" alt="Погода_в_мире" style="margin: 4px 0 0 -17px"></a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="td-top">
|
||||
<div id="topMenuContent" style="text-align: left">
|
||||
<a id="mobile_link" class="pointer" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">Мобильная версия</a>|
|
||||
<a href="/Погода_в_мире">Главная</a>|
|
||||
<a href="/docs/about/ru">О сайте</a>|
|
||||
<a href="/docs/faq/ru">Частые вопросы (FAQ)</a>|
|
||||
<a href="/docs/contact/ru">Контакты</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height: 31px; vertical-align: middle"><div id="countries"><a href="/Погода_в_Беларуси">Беларусь</a> <a href="/Погода_в_Литве">Литва</a> <a href="/Погода_в_России">Россия</a> <a href="/Погода_в_Украине">Украина</a> <a href="/Погода_в_мире">Все страны</a></div>
|
||||
<div class="rightNavi noprint">
|
||||
<div class="intoNavi rss">
|
||||
<span class="verticalBottom" id="RSS_top">RSS <a href="http://rp5.ru/rss/24530/ru" id="rss_link"><div class="iconrss"></div></a> </span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 7px; vertical-align: top">
|
||||
<div id="telephone" class="buttonSummary" style="margin: 2px 2px 0 0;" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">
|
||||
<table><tbody><tr><td style="height: 35px; padding: 0 34px 0 7px; vertical-align: middle; ">
|
||||
<a class="inDd2Summary inDd2"><span>Мобильная версия</span></a>
|
||||
</td></tr></tbody></table>
|
||||
</div>
|
||||
<div id="informerC" class="buttonSummary">
|
||||
<div id="appButton" class="inDdSummary" style="font-size: 12px;">
|
||||
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Приложения</td></tr></tbody></table>
|
||||
</div>
|
||||
<div id="appMenu" style="display: none;">
|
||||
<ul>
|
||||
<li onclick="top.location.href='/docs/informer/ru'">Информеры</li>
|
||||
<li onclick="top.location.href='/docs/xml/ru?id=24530'">XML</li>
|
||||
<li onclick="top.location.href='/docs/sgate/ru'">API частных метеостанций</li>
|
||||
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
|
||||
<li onclick="top.location.href='/docs/gadget/ru'">Гаджет для WINDOWS</li>
|
||||
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
|
||||
<li onclick="window.open('https://play.google.com/store/apps/details?id=ru.rp5.rp5weatherhorizontal', '_blank')">Приложение для Android</li>
|
||||
<!--<li onclick="top.location.href='/wduck/source/new/app-release-v22.apk'">*.apk-файл</li>-->
|
||||
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7;"></div>
|
||||
<li onclick="top.location.href='/Дневник_погоды_в_Сватово'">Дневник погоды для школьников</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ddListSummary">
|
||||
<span>
|
||||
<div class="ddList2">
|
||||
<div id="unitsButton" class="inDdSummary">
|
||||
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Единицы измерений</td></tr></tbody></table>
|
||||
</div>
|
||||
<div id="unitsMenu" style="text-align: left; display: none;">
|
||||
<form name="fValues" action="">
|
||||
<table id="tValues" style="vertical-align: top;">
|
||||
<tbody><tr>
|
||||
<td>
|
||||
<div>Облачность:</div>
|
||||
<div>
|
||||
<input type="radio" id="r_cc1" class="radioUnits" name="r_cc" title="проценты (%)" onclick="if(this.checked) {chValue('cc',0,0);}" checked="checked">
|
||||
<label for="r_cc1">проценты (%)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_cc2" class="radioUnits" name="r_cc" title="1/10" onclick="if(this.checked) {chValue('cc',1,0);}">
|
||||
<label for="r_cc2">1/10</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_cc3" class="radioUnits" name="r_cc" title="окты" onclick="if(this.checked) {chValue('cc',2,0);}">
|
||||
<label for="r_cc3">окты</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Осадки:</div>
|
||||
<div>
|
||||
<input type="radio" id="r_pr1" class="radioUnits" name="r_pr" title="миллиметры (мм)" onclick="if(this.checked) {chValue('pr',0,0);}" checked="checked">
|
||||
<label for="r_pr1">миллиметры (мм) / сантиметры (см)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_pr2" class="radioUnits" name="r_pr" title="дюймы" onclick="if(this.checked) {chValue('pr',1,0);}">
|
||||
<label for="r_pr2">дюймы</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Температура:</div>
|
||||
<div>
|
||||
<input type="radio" id="r_t1" class="radioUnits" name="r_t" title="градусы Цельсия (°C)" onclick="if(this.checked) {chValue('t',0,0);}" checked="checked">
|
||||
<label for="r_t1">градусы Цельсия (°C)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_t2" class="radioUnits" name="r_t" title="градусы Фаренгейта (°F)" onclick="if(this.checked) {chValue('t',1,0);}">
|
||||
<label for="r_t2">градусы Фаренгейта (°F)</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div>Давление:</div>
|
||||
<div>
|
||||
<input type="radio" id="r_p1" class="radioUnits" name="r_p" title="миллиметры ртутного столба (мм рт. ст.)" onclick="if(this.checked) {chValue('p',0,0);}" checked="checked">
|
||||
<label for="r_p1">миллиметры ртутного столба (мм рт. ст.)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_p2" class="radioUnits" name="r_p" title="дюймы ртутного столба (дюймы рт. ст.)" onclick="if(this.checked) {chValue('p',1,0);}">
|
||||
<label for="r_p2">дюймы ртутного столба (дюймы рт. ст.)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_p3" class="radioUnits" name="r_p" title="миллибары (мбар)" onclick="if(this.checked) {chValue('p',2,0);}">
|
||||
<label for="r_p3">миллибары (мбар)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_p4" class="radioUnits" name="r_p" title="гектопаскали (гПа)" onclick="if(this.checked) {chValue('p',3,0);}">
|
||||
<label for="r_p3">гектопаскали (гПа)</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>Ветер:</div>
|
||||
<div>
|
||||
<input type="radio" id="r_wv1" class="radioUnits" name="r_wv" title="метры в секунду (м/с)" onclick="if(this.checked) {chValue('wv',0,0);}" checked="checked">
|
||||
<label for="r_wv1">метры в секунду (м/с)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_wv2" class="radioUnits" name="r_wv" title="километры в час (км/ч)" onclick="if(this.checked) {chValue('wv',1,0);}">
|
||||
<label for="r_wv2">километры в час (км/ч)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_wv3" class="radioUnits" name="r_wv" title="мили в час (мили/час)" onclick="if(this.checked) {chValue('wv',2,0);}">
|
||||
<label for="r_wv3">мили в час (мили/час)</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_wv4" class="radioUnits" name="r_wv" title="узлы" onclick="if(this.checked) {chValue('wv',3,0);}">
|
||||
<label for="r_wv4">узлы</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_wv5" class="radioUnits" name="r_wv" title="Бфрт (Бфрт)" onclick="if(this.checked) {chValue('wv',4,0);}">
|
||||
<label for="r_wv5">баллы Бофорта (Бфрт)</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div style="margin-bottom: 9px; border-top: 1px dashed #a7a7a7"></div>
|
||||
<div>Только для страниц «Архив погоды»</div>
|
||||
<div>Горизонтальная дальность видимости (VV):</div>
|
||||
<div>
|
||||
<input type="radio" id="r_vv1" class="radioUnits" name="r_vv" title="километры" onclick="if(this.checked) {chValue('vv',0,0);}" checked="checked">
|
||||
<label for="r_vv1">километры</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_vv2" class="radioUnits" name="r_vv" title="мили" onclick="if(this.checked) {chValue('vv',1,0);}">
|
||||
<label for="r_vv2">мили</label>
|
||||
</div>
|
||||
<div>Высота основания самых низких облаков (H):</div>
|
||||
<div>
|
||||
<input type="radio" id="r_h1" class="radioUnits" name="r_h" title="метры" onclick="if(this.checked) {chValue('h',0,0);}" checked="checked">
|
||||
<label for="r_h1">метры</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_h2" class="radioUnits" name="r_h" title="футы" onclick="if(this.checked) {chValue('h',1,0);}">
|
||||
<label for="r_h2">футы</label>
|
||||
</div>
|
||||
<div>Высота снежного покрова (sss):</div>
|
||||
<div>
|
||||
<input type="radio" id="r_sss1" class="radioUnits" name="r_sss" title="сантиметры" onclick="if(this.checked) {chValue('sss',0,0);}" checked="checked">
|
||||
<label for="r_sss1">сантиметры</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="r_sss2" class="radioUnits" name="r_sss" title="дюймы" onclick="if(this.checked) {chValue('sss',1,0);}">
|
||||
<label for="r_sss2">дюймы</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ddListSummary1">
|
||||
<span>
|
||||
<div class="ddList1">
|
||||
<div id="langButton" class="inDdSummary">
|
||||
<table><tbody><tr><td style="height: 35px; vertical-align: middle;">Language</td></tr></tbody></table>
|
||||
</div>
|
||||
<div id="langMenu" style="display: none;">
|
||||
<ul></ul>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div id="search">
|
||||
<form name="fsearch" autocomplete="off" method="post" action="/docs/search/ru" onsubmit="searchConfirm();">
|
||||
<input type="text" name="searchStr" id="searchStr" class="search ac_input" onfocus="clearTxt();" onblur="setTxt();" value="змиёвка" autocomplete="off">
|
||||
<input type="button" id="searchButton" class="searchButton" onclick="searchConfirm();" value="">
|
||||
<script type="text/javascript">setTxt();</script>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="content-wrapper">
|
||||
<div id="content">
|
||||
<div id="hf420d4b9b1efdbbe6ef03d062299d020ccae86d6">
|
||||
<div class="intoLeftNavi">
|
||||
<span class="verticalBottom">
|
||||
<a href="/Погода_в_мире"><nobr>Все страны</nobr></a> <img src="/images/iconArrow.png" alt="" title=""> <a href="/Погода_в_России"><nobr>Россия</nobr></a> <img src="/images/iconArrow.png" alt="" title="">
|
||||
<a href="/Погода_в_Луганской_Народной_Республике"><nobr>Луганская Народная Республика</nobr></a> <img src="/images/iconArrow.png" alt="" title="">
|
||||
<a href="/Погода_в_Сватовском_районе"><nobr>Сватовский район</nobr></a> <img src="/images/iconArrow.png" alt="" title="">
|
||||
<a href="/Погода_в_Змиевке,_Луганская_Народная_Республика"><nobr>Змиевка</nobr></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7"><div id="leftNavi" class="pageTitle"> <span class="verticalBottom"><h1>Результат поиска</h1></span></div><div class="clear"></div> <p class="stinfo"><b>Найдено населенных пунктов:</b> 3</p> <table cellspacing="0" cellpadding="0" class="searchResults"> <tbody><tr class="header"> <td> <div class="ImgLeft"></div> <div class="searchPointTitle"> <span class="innerTextHead">Населенный пункт</span> <div> </div></div></td> <td> <div class="searchTitles"> <span class="innerTextHead">Страна</span> </div> </td> <td> <div class="searchTitles"> <span class="innerTextHead">Регион</span> </div> </td> <td style="border-right: none;"> <div class="ImgRight"></div> <div class="searchTitles"> <span class="innerTextHead">Район</span> </div> </td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Луганская Народная Республика</span></td> <td><span class="innerTextResult">Сватовский район</span></td> </tr><tr class="srow1"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Орловская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Орловская область</span></td> <td><span class="innerTextResult">Свердловский район</span></td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Херсонская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Херсонская область</span></td> <td><span class="innerTextResult">Бериславский район</span></td> </tr> </tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clear"></div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer-wrapper" class="noprint">
|
||||
<div id="footer">
|
||||
|
||||
<div id="footer-info">
|
||||
<div class="footerMenu">
|
||||
<div class="footer_title"><b style="font-size: 10pt;">Показаны ранее:</b></div><span class="Ajax-PointID" id="6318">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+19</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+66</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Орле,_Орловская_область">Орел</a></div><br><span class="Ajax-PointID" id="24530">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></div><br>
|
||||
</div>
|
||||
<div class="footerMenu">
|
||||
<div class="footer_title"><b style="font-size: 10pt;">Ближайшие центры:</b></div><span class="Ajax-PointID" id="531">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Сватово,_Луганская_Народная_Республика">Сватово</a></div><br><span class="Ajax-PointID" id="720">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+15</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+59</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Боровой,_Боровский_район">Боровая</a></div><br><span class="Ajax-PointID" id="735">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Купянске">Купянск</a></div><br><span class="Ajax-PointID" id="518">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Кременной,_Россия">Кременная</a></div><br>
|
||||
</div>
|
||||
<div class="footerMenu">
|
||||
<div class="footer_title"><b style="font-size: 10pt;">Популярные:</b></div><span class="Ajax-PointID" id="488">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+13</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+55</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Беловодске">Беловодск</a></div><br><span class="Ajax-PointID" id="520">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Луганске,_Луганская_Народная_Республика">Луганск</a></div><br><span class="Ajax-PointID" id="529">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+21</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+70</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Ровеньках,_Луганская_Народная_Республика">Ровеньки</a></div><br><span class="Ajax-PointID" id="536">
|
||||
<div class="MainTemp">
|
||||
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
|
||||
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
|
||||
</div></span> <div class="footer_links"><a href="/Погода_в_Старобельске">Старобельск</a></div><br>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer-content">
|
||||
<div id="footer-copyright">Copyright © ООО «Расписание Погоды», 2004-2026</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- old code -->
|
||||
|
||||
|
||||
|
||||
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user