66 Commits

Author SHA1 Message Date
615d385f14 ci(version): 0.5.1 2026-07-10 15:03:32 +03:00
b1bf51c606 fix(static): fix bootstrap usage 2026-07-10 14:49:35 +03:00
2d54e0afd0 ci(version): 0.5.0 2026-07-10 13:33:09 +03:00
292cdd6037 build(scripts): add save docker action 2026-07-10 13:32:57 +03:00
01d0d1eeb8 fix(gismeteo): add heavy_rain variant 2026-07-10 13:32:13 +03:00
b85e9ac5f3 feat(static): colorize weather icons 2026-07-09 15:18:04 +03:00
8f244455ac refactor(static): refactor static module 2026-07-06 11:52:55 +03:00
820472796b build(static): fix webpack build warngins 2026-07-06 10:56:07 +03:00
d1bdac66c0 refactor(static): refactor weather-icons lib 2026-07-05 20:45:19 +03:00
d21e31ca35 feat(static): use webpack instead of vite 2026-07-04 21:04:42 +03:00
6c76dae760 ci(version): 0.4.1 2026-07-02 14:17:55 +03:00
28951df9b5 fix(openweather): optional visability field in forecast model 2026-07-02 14:17:48 +03:00
5aa290c772 ci(version): 0.4.0 2026-07-02 14:06:15 +03:00
13a70990e5 feat(weather): add hail precipitation variant 2026-07-02 02:59:49 +03:00
a3fab92e3d feat(view): fix index page 2026-07-02 02:43:43 +03:00
4fdb7e9717 fix(view): fix index pages api resolving 2026-07-02 01:30:38 +03:00
1d13f6e05d refactor(doc): update docs static 2026-07-01 22:48:24 +03:00
fcb000afb0 test: fix tests 2026-07-01 21:05:49 +03:00
0730b9f567 build(poetry): update python dependencies 2026-07-01 20:53:01 +03:00
081aa8d17f feat(openweather): add location name to forecast response 2026-07-01 16:40:16 +03:00
29fe06462e feat(schedule): implement multiple schedule providers 2026-06-27 16:03:28 +03:00
8b20eb1220 test(openweather): add openweather location seacrh test 2026-06-26 16:27:36 +03:00
5333690c47 feat(weather): implement provider in weather api endpoints 2026-06-25 19:19:21 +03:00
d82fc4ea46 feat(weather): improve weather locations list 2026-06-24 21:02:06 +03:00
df2f1d0d81 refactor(easel): add view root module 2026-06-24 16:00:39 +03:00
9192ca3e4a feat(easel): implement multiple weather providers 2026-06-24 16:00:39 +03:00
e37ca6eeac feat(easel): add api provider 2026-06-24 16:00:39 +03:00
657a62eab5 ci(version): 0.3.4 2026-06-24 15:54:43 +03:00
d8e2be013a fix(gismeteo): fix precipitation parser 2026-06-24 15:54:35 +03:00
a91ec12d22 ci(version): 0.3.3 2026-06-22 15:33:36 +03:00
9c862863e5 fix(gismeteo): fix precipitation parser 2026-06-22 15:33:19 +03:00
fc59e52337 ci(version): 0.3.2 2026-06-20 18:59:43 +03:00
3e93a41400 fix(gismeteo): fix date-time row parsing 2026-06-20 18:59:27 +03:00
c2cd18386b refactor(easel): update api router 2026-06-16 21:18:16 +03:00
2bca3dd75a ci(version): 0.3.1 2026-06-16 20:17:37 +03:00
469bd9bc1f feat(easel): add version to header 2026-06-16 20:17:12 +03:00
027d1e2d55 build(docker): add docker build caches mount 2026-06-16 20:01:29 +03:00
7cf0012229 feat(schedule): update navigate icons 2026-06-16 20:00:46 +03:00
edc014d98c docs: update screenshot 2026-06-15 23:37:31 +03:00
1813ec213b ci(version): 0.3.0 2026-06-15 23:19:57 +03:00
1b700086f2 feat(weather): update navigate icons 2026-06-15 23:18:48 +03:00
02a6ffc931 feat(weather): new weather icons 2026-06-15 11:22:41 +03:00
2dfedfea57 fix(easel): fix header icons 2026-06-15 11:22:28 +03:00
8012d9b8ed fix(gismeteo): add max_date validation 2026-06-14 10:23:38 +03:00
3a6faa85be fix(gismeteo): add new variants to sky parser 2026-06-14 10:23:13 +03:00
1af61aa3c7 feat(easel): add sky details tooltip 2026-06-14 10:22:43 +03:00
315838604e feat(easel): add widget mode support 2026-06-14 09:48:27 +03:00
f368e6717c style: format code 2026-06-12 00:16:15 +03:00
91e2c9d123 ci(version): 0.2.3 2026-06-10 11:49:56 +03:00
91d9c37612 build(vscode): add settings 2026-06-10 11:49:48 +03:00
b5f2c272bb feat(easel): update navbar 2026-06-10 11:37:49 +03:00
eec72c77ab docs: add screenshot 2026-05-07 01:11:42 +03:00
160ec2b48b ci(version): 0.2.2 2026-04-24 14:12:39 +03:00
7c57f939c0 feat(static): update and optimizate 2026-04-24 14:12:14 +03:00
c233b020fc ci(version): 0.2.1 2026-04-23 21:24:28 +03:00
869a8ae79f fix(gismeteo): fix optional subdistrict in gismeteo reponse 2026-04-23 21:24:13 +03:00
4c3b3aeafc ci(version): 0.2.0 2026-04-23 21:06:15 +03:00
d1592150fd build(scipts): add version script 2026-04-23 21:06:06 +03:00
9351b9f53a feat(easel): add localization 2026-04-23 15:47:16 +03:00
ecb574e286 feat(easel): add bootstrap 2026-04-22 21:42:39 +03:00
94870a5c86 feat(weather): add weather location search 2026-04-22 12:58:56 +03:00
3dd0a5410c docs: update README 2026-04-16 23:32:58 +03:00
a0e6f30e3b feat(schedule): update view 2026-04-16 23:05:41 +03:00
29fa6435ce feat(yandextv): add yandextv schedule api 2026-04-16 18:43:12 +03:00
a886322d0e test: fix tests 2026-04-14 22:40:10 +03:00
6112147b40 build(docker): update docker build 2026-04-14 09:22:07 +03:00
179 changed files with 22499 additions and 13570 deletions

View File

@@ -6,6 +6,7 @@ indent_style = space
indent_size = 2 indent_size = 2
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
max_line_length = 120
[*.md] [*.md]
max_line_length = off max_line_length = off

8
.env-base Normal file
View File

@@ -0,0 +1,8 @@
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")
OPENWEATHER_KEY="<EMPTY>"

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
*.pyc *.pyc
*.mo
.pytest_cache .pytest_cache
.venv .venv
#.vscode #.vscode
static/node_modules
static/dist
.env
dist

9
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:poetry",
"packageManager": "ms-python.python:poetry"
}
]
}

View File

@@ -1,23 +1,35 @@
FROM python:3.12 AS builder FROM python:3.14 AS builder
ENV POETRY_HOME="/opt/poetry" ENV POETRY_HOME="/opt/poetry"
ENV PATH="$POETRY_HOME/bin:$PATH" ENV PATH="$POETRY_HOME/bin:$PATH"
RUN apt update && \
apt install -y gettext
WORKDIR /app WORKDIR /app
RUN curl -sSL https://install.python-poetry.org | python3 - RUN curl -sSL https://install.python-poetry.org | python3 -
COPY pyproject.toml poetry.lock ./ COPY pyproject.toml poetry.lock README.md ./
RUN poetry config virtualenvs.in-project true RUN poetry config virtualenvs.in-project true
RUN poetry install --with app RUN --mount=type=cache,target=/root/.cache/pypoetry/cache \
--mount=type=cache,target=/root/.cache/pypoetry/artifacts \
poetry install --with app --no-root
COPY locales ./locales
RUN cd locales/ru/LC_MESSAGES && msgfmt messages.po
FROM python:3.12-slim FROM node:24 AS node-builder
ENV PATH=/app/node_modules/.bin:$PATH
WORKDIR /app
COPY static/package.json static/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY static ./
RUN npm run build
FROM python:3.14-slim
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app WORKDIR /app
RUN apt update && \
apt install -y locales && \
sed -i -e 's/# ru_RU.UTF-8 UTF-8/ru_RU.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales
ENV LANG=ru_RU.UTF-8
ENV LC_ALL=ru_RU.UTF-8
ENV TZ="Europe/Moscow" ENV TZ="Europe/Moscow"
COPY --from=builder /app ./ COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/ COPY gallery gallery/
COPY --from=builder --parents locales/**/*.mo ./
CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"] CMD ["uvicorn", "gallery.main:app", "--host", "0.0.0.0", "--port", "80", "--log-config", "gallery/logging.yaml"]

View File

@@ -1 +1,13 @@
# Gallery # API Gallery
Weather and TV program API
![API Gallery](docs/screenshot.png "API Gallery")
## View
https://api.shmyga.ru
## Swagger
https://api.shmyga.ru/docs

View File

@@ -0,0 +1,26 @@
name: gallery
services:
redis:
container_name: gallery-redis
image: redis:alpine
stop_grace_period: 3s
volumes:
- redis_data:/data
app:
container_name: gallery-app-develop
build: .
environment:
- REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
- DEBUG=1
ports:
- 8000:80
develop:
watch:
- action: sync
path: ./gallery
target: /app/gallery
volumes:
redis_data:

View File

@@ -1,3 +1,5 @@
name: gallery
services: services:
redis: redis:
container_name: gallery-redis container_name: gallery-redis
@@ -5,15 +7,16 @@ services:
stop_grace_period: 3s stop_grace_period: 3s
volumes: volumes:
- redis_data:/data - redis_data:/data
command: [ "redis-server", "--bind", "0.0.0.0", "--port", "6379" ]
app: app:
container_name: gallery-app container_name: gallery-app
build: . image: ${DOCKER_ROOT}/gallery
# image: shmyga/gallery
environment: environment:
- REDIS_HOST=redis - REDIS_HOST=redis
- OPENWEATHER_KEY=$OPENWEATHER_KEY
depends_on:
- redis
ports: ports:
- 8000:80 - 127.0.0.1:8000:80
volumes: volumes:
redis_data: redis_data:

BIN
docs/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

@@ -8,6 +8,7 @@
"python.testing.pytestArgs": ["tests", "-s"], "python.testing.pytestArgs": ["tests", "-s"],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.terminal.useEnvFile": true,
"python-envs.pythonProjects": [ "python-envs.pythonProjects": [
{ {
"path": ".", "path": ".",
@@ -33,12 +34,17 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "uvicorn", "module": "uvicorn",
"args": [ "args": ["gallery.main:app", "--reload", "--log-config", "gallery/logging.yaml"],
"gallery.main:app", "justMyCode": true,
"--reload", "consoleTitle": "gallery:app",
"--log-config", },
"gallery/logging.yaml", {
], "name": "gallery:static",
"cwd": "${workspaceFolder}/static",
"request": "launch",
"type": "node-terminal",
"command": "npm run dev",
"consoleTitle": "gallery:static",
}, },
], ],
}, },

View File

@@ -1,23 +1,21 @@
import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, doc, view from .route import api, doc, view
DEFAULT_LOCALE = "ru_RU.UTF-8"
def build_app(api_bundle: ApiBundle) -> FastAPI:
def build_app(api_bundle: ApiBundle, *, locale: str = DEFAULT_LOCALE) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale)
app = FastAPI( app = FastAPI(
title="Gallery", title="Gallery",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
app.state.api = api_bundle app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) doc.mount(app)
api.mount(app) app.include_router(api.router)
view.mount(app) app.include_router(view.router)
return app return app

View File

View File

@@ -0,0 +1,17 @@
from fastapi import HTTPException, status
from gallery.easel.core import AppRequest
from gallery.sketch.api import API
def api_resolver(api_type: type[API]):
def get_api(request: AppRequest, provider: str) -> API:
providers = request.app.state.api.get_api_providers(api_type)
if provider not in providers:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"provider": f"'{provider}' not in {providers}"},
)
return request.app.state.api.get_api(api_type, provider)
return get_api

View File

@@ -0,0 +1,9 @@
from typing import Annotated
from fastapi import Depends
from gallery.sketch.schedule.api import ScheduleApi
from .api import api_resolver
ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))]

View File

@@ -0,0 +1,9 @@
from typing import Annotated
from fastapi import Depends
from gallery.sketch.weather.api import WeatherApi
from .api import api_resolver
WeatherApiDepends = Annotated[WeatherApi, Depends(api_resolver(WeatherApi))]

View File

@@ -1,8 +1,7 @@
from fastapi import FastAPI from fastapi import APIRouter
from . import schedule, weather from . import schedule, weather
router = APIRouter(prefix="/api")
def mount(app: FastAPI): router.include_router(weather.router)
weather.mount(app) router.include_router(schedule.router)
schedule.mount(app)

View File

@@ -1,5 +1,27 @@
from fastapi import FastAPI import datetime
from fastapi import APIRouter
from gallery.easel.core import AppRequest
from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule
router = APIRouter(prefix="/schedule", tags=["Schedule"])
def mount(app: FastAPI): @router.get("/providers")
pass async def get_api_weather_providers(request: AppRequest) -> list[str]:
return request.app.state.api.get_api_providers(ScheduleApi)
@router.get("/{provider}/channels")
async def find_api_schedule_channels(schedule_api: ScheduleApiDepends, query: str) -> list[Channel]:
return await schedule_api.find_channels(query)
@router.get("/{provider}/{channel}/{date}")
async def get_api_schedule_channel_schedule(
schedule_api: ScheduleApiDepends, channel: str, date: datetime.date
) -> Schedule:
return await schedule_api.get_schedule(channel, date)

View File

@@ -1,27 +1,30 @@
import datetime import datetime
from fastapi import FastAPI from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.model import WeatherResponse from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse
router = APIRouter(prefix="/weather", tags=["Weather"])
def mount(app: FastAPI): @router.get("/providers")
@app.get("/api/weather/locations") async def get_api_weather_providers(request: AppRequest) -> list[str]:
async def get_api_weather_locations(request: AppRequest) -> list[str]: return request.app.state.api.get_api_providers(WeatherApi)
weather_api = request.app.state.api.weather
return await weather_api.get_locations()
@app.get("/api/weather/{location}/day/{date}")
async def get_api_weather_day( @router.get("/{provider}/locations")
request: AppRequest, location: str, date: datetime.date async def find_api_weather_locations(weather_api: WeatherApiDepends, query: str) -> list[Location]:
) -> WeatherResponse: return await weather_api.find_locations(query)
weather_api = request.app.state.api.weather
@router.get("/{provider}/{location}/day/{date}")
async def get_api_weather_day(weather_api: WeatherApiDepends, location: str, date: datetime.date) -> WeatherResponse:
return await weather_api.get_day(location, date) return await weather_api.get_day(location, date)
@app.get("/api/weather/{location}/days/{days}")
async def get_api_weather_days( @router.get("/{provider}/{location}/days/{days}")
request: AppRequest, location: str, days: int async def get_api_weather_days(weather_api: WeatherApiDepends, location: str, days: int) -> WeatherResponse:
) -> WeatherResponse:
weather_api = request.app.state.api.weather
return await weather_api.get_days(location, days) return await weather_api.get_days(location, days)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,11 @@
from fastapi import FastAPI from fastapi import APIRouter, Depends
from . import common, schedule, weather from .root import router as root_router
from .schedule import router as schedule_router
from .translation import set_language
from .weather import router as weather_router
router = APIRouter(tags=["view"], dependencies=[Depends(set_language)], include_in_schema=False)
def mount(app: FastAPI): router.include_router(root_router)
common.mount(app) router.include_router(weather_router)
weather.mount(app) router.include_router(schedule_router)
schedule.mount(app)

View File

@@ -1,37 +0,0 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.version import __version__
class Section(NamedTuple):
link: str
title: str
SECTIONS = [
Section("weather", "Погода"),
Section("schedule", "Телепрограмма"),
]
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static/common", StaticFiles(directory=base_dir / "static"))
templates = Jinja2Templates(directory=base_dir / "templates")
@app.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse(
request=request,
name="root_index.html",
context={
"version": __version__,
"sections": SECTIONS,
},
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,112 +0,0 @@
/*
base
*/
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
/*
table
*/
table {
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
/*
a.button
*/
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
filter: grayscale(100%);
}
/*
app
*/
.app-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
}
.app-menu {
display: flex;
flex-direction: column;
margin: 0.5rem;
}
.app-content {
display: flex;
flex: 1;
flex-direction: column;
}
.app-header {
width: 100%;
display: flex;
flex-direction: row;
gap: 0.5rem;
justify-content: center;
}
.app-link-home > * {
width: 2rem;
height: 2rem;
background-image: url("/static/common/gallery.png");
background-size: contain;
}
.icon {
display: inline-block;
width: 2rem;
height: 2rem;
background-size: contain;
}
ul.app-list {
list-style: none;
padding-left: 0;
}
ul.app-list > li {
border: 1px solid lightgrey;
}
ul.app-list > li > a {
display: flex;
gap: 0.25rem;
padding: 0.5rem 2rem;
text-decoration: none;
color: inherit;
}
ul.app-list > li:hover {
border-color: blue;
}
ul.app-list > li:hover > a {
color: blue;
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{request.state.language}}">
<head> <head>
{% block head %} {% block head %}
@@ -10,28 +10,115 @@
content="ie=edge"> content="ie=edge">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" <link rel="stylesheet"
href="/static/common/style.css?v={{version}}"> href="/static/gallery.css?v={{version}}">
<script type="module"
src="/static/gallery.js?v={{version}}"></script>
<link rel="icon" <link rel="icon"
href="/static/common/favicon.ico?v={{version}}" href="/favicon.ico?v={{version}}"
type="image/x-icon"> type="image/x-icon">
{% endblock %} {% endblock %}
</head> </head>
<body class="app-container"> <body class="{{ is_widget and 'widget' or ''}}">
<div class="app-menu"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
<a class="app-link-home" {% if not is_widget %}
href="/"> <header class="app-header pb-3 mb-5 border-bottom">
<div></div> <div>{{request.query_params.widget}}</div>
</a> <div class="link-list">
<app-link href="/"
icon="gear">API Gallery</app-link>
{% block header %}{% endblock %}
</div> </div>
<div class="app-content"> <ul class="navbar-nav flex-row flex-wrap ms-md-auto">
<h3 class="app-header"> <li class="nav-item dropdown">
{% block header %}{% endblock %}</span> <button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
</h3> id="bd-language"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="{{_('Select language')}} (default)">
<span class="fi fir fi-gb me-2 language-icon-active icon-header"></span>
<span class="d-lg-none ms-2"
id="bd-language-text">{{_("Select language")}}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-language-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="en"
aria-pressed="false">
<span class="fi fir fi-gb me-2 language-icon-active"></span>
{{_("English")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-language-value="ru"
aria-pressed="false">
<span class="fi fir fi-ru me-2 language-icon-active"></span>
{{_("Russian")}}
</button>
</li>
</ul>
</li>
<li class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-lg-2 dropdown-toggle d-flex align-items-center"
id="bd-theme"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
aria-label="Toggle theme (auto)">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon-active icon-header"></span>
<span class="d-lg-none ms-2"
id="bd-theme-text">{{_("Toggle theme")}}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end"
aria-labelledby="bd-theme-text">
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light"
aria-pressed="false">
<span class="bi bi-sun-fill me-2 opacity-50 theme-icon"></span>
{{_("Light")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false">
<span class="bi bi-moon-stars-fill me-2 opacity-50 theme-icon"></span>
{{_("Dark")}}
</button>
</li>
<li>
<button type="button"
class="dropdown-item d-flex align-items-center active"
data-bs-theme-value="auto"
aria-pressed="true">
<span class="bi bi-circle-half me-2 opacity-50 theme-icon"></span>
{{_("Auto")}}
</button>
</li>
</ul>
</li>
</ul>
</header>
{% endif %}
<main>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div class="app-footer"> </main>
{% block footer %}{% endblock %} {% if not is_widget %}
<footer class="app-footer pt-5 my-5 text-muted border-top">
<div class="d-flex justify-content-between">
<span>Created by shmyga &middot; &copy; 2026</span>
<span>v{{ version }}</span>
</div> </div>
</footer>
{% endif %}
</div> </div>
</body> </body>

View File

@@ -1,21 +0,0 @@
{% extends "base.html" %}
{% block title %}Информация{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block header %}Информация{% endblock %}
{% block content %}
<ul class="app-list">
{% for section in sections %}
<li>
<a href="{{section.link}}">
<span class="icon"
style="background-image: url(/static/{{section.link}}/{{section.link}}.png);"></span>
<span>{{section.title}}</span>
</a>
</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,53 @@
import datetime
import typing
from pathlib import Path
from babel.dates import format_date
from fastapi import Request
from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest
from gallery.version import __version__
from ...translation import _
from .tag import TagUtil
ContextProcessor = typing.Callable[[AppRequest], dict[str, typing.Any]]
def is_widget(request: Request) -> bool:
return (request.url.hostname and request.url.hostname.startswith("weather")) or (
request.query_params.get("widget") is not None
)
def base_context_processor(request: Request) -> dict:
return {
"is_widget": is_widget(request),
"provider": request.query_params.get("provider"),
}
def build_templates(
templates_dir: Path | None = None, filters: dict | None = None, context_processor: ContextProcessor | None = None
) -> Jinja2Templates:
directory = [Path(__file__).parent.parent / "templates"]
if templates_dir:
directory.append(templates_dir)
context_processors: list[ContextProcessor] = [base_context_processor]
if context_processor:
context_processors.append(context_processor)
templates = Jinja2Templates(directory=directory, context_processors=context_processors)
templates.env.globals.update(
{
"_": _,
"version": __version__,
"format_date": format_date,
"datetime": datetime,
"tag_util": TagUtil,
"DATE_FORMAT": "E, d MMMM Y",
}
)
if filters:
templates.env.filters.update(filters)
return templates

View File

@@ -0,0 +1,36 @@
from pathlib import Path
from typing import NamedTuple
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from ..common.utils.template import build_templates
class Section(NamedTuple):
link: str
title: str
icon: str
SECTIONS = [
Section("weather", "Weather", "brightness-high"),
Section("schedule", "TV program", "tv"),
]
base_dir = Path(__file__).parent
router = APIRouter()
templates = build_templates(Path(__file__).parent / "templates")
@router.get("/", response_class=HTMLResponse)
async def get_section_list(request: Request):
return templates.TemplateResponse(
request=request,
name="root_index.html",
context={
"sections": SECTIONS,
},
)

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{_("Index")}}{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block content %}
<h1>{{_("View")}}</h1>
<div class="list-group mb-3">
{% for section in sections %}
<app-link href="{{section.link}}"
icon="{{section.icon}}"
class="list-group-item list-group-item-action">
{{_(section.title)}}
</app-link>
{% endfor %}
</div>
<h1>{{_("Docs")}}</h1>
<div class="list-group mb-3">
<app-link href="/docs"
target="_blank"
icon="file-earmark-text"
class="list-group-item list-group-item-action">
Swagger UI
</app-link>
<app-link href="/redoc"
target="_blank"
icon="file-earmark-text"
class="list-group-item list-group-item-action">
ReDoc
</app-link>
</div>
{% endblock %}

View File

@@ -1,84 +1,68 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.schedule.catalog import BUNDLE from gallery.easel.depends.api import api_resolver
from gallery.version import __version__ from gallery.easel.depends.schedule import ScheduleApiDepends
from gallery.sketch.schedule.api import ScheduleApi
from ..common.util import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import timedelta_format from .filters import timedelta_format
def mount(app: FastAPI): def context_procesor(request: AppRequest) -> dict:
base_dir = Path(__file__).parent return {
app.mount("/static/schedule", StaticFiles(directory=base_dir / "static")) "providers": request.app.state.api.get_api_providers(ScheduleApi),
templates = Jinja2Templates( }
directory=[
base_dir.parent / "common/templates",
base_dir / "templates",
]
)
templates.env.filters["timedelta_format"] = timedelta_format
@app.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest): templates = build_templates(
schedule_api = request.app.state.api.schedule Path(__file__).parent / "templates",
channels = await schedule_api.get_channels() {
channels_data = BUNDLE.select_items(channels) "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( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={ context={
"version": __version__,
"channels": channels_data,
},
)
@app.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
channels = await schedule_api.get_channels()
responses = [
await schedule_api.get_channel_schedule(channel, tag_value.date)
for channel in channels
]
return templates.TemplateResponse(
request=request,
name="schedule.html",
context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"channels": channels, "channels": channels,
"response": responses[0],
"responses": responses,
"live": live,
}, },
) )
@app.get("/schedule/{channel}", response_class=RedirectResponse)
@router.get("/{provider}/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str): async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today") return RedirectResponse(f"{channel}/tag/today")
@app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str): @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) tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date) response = await schedule_api.get_schedule(channel, tag_value.date)
else: else:
raise ValueError(tag) raise ValueError(tag)
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="channel.html", name="channel.html",
context={ context={
"version": __version__,
"tag_util": TagUtil, "tag_util": TagUtil,
"datetime": datetime, "datetime": datetime,
"response": response, "response": response,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,18 +0,0 @@
tr {
border-bottom: 1px solid lightgray;
}
td {
text-align: left;
}
tr.live {
font-weight: bold;
}
.title {
margin-top: 0.5rem;
font-style: italic;
font-weight: bold;
font-size: 120%;
}

View File

@@ -1,40 +1,44 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} {% block title %}
Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}} {{_("TV program")}} | {{response.channel.name}} | {{format_date(response.date, DATE_FORMAT,
{% endblock %} locale=request.state.language)}}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" <app-link href="/schedule"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a> icon="tv">{{_("TV program")}}</app-link>
<a class="button"
href="../..">⬆️</a>
<span>{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<table> <h4>
<a class="icon-link {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="../../..">
<i class="bi bi-arrow-up-square"></i>
</a>
<span>{{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)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
</h4>
<table class="table">
<thead> <thead>
<tr> <tr>
<td></td> <td></td>
<td></td> <td></td>
<td></td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for value in response.values %} {% for value in response.values %}
<tr class="{{'live' if value.live else ''}}"> <tr class="{{'table-success' if value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td> <td>
<td>{{(value.end - value.start) | timedelta_format}}</td> <span>{{value.start.strftime('%H:%M')}}</span>
<span class="small ms-1">({{(value.end - value.start) | timedelta_format}})</span>
</td>
<td>{{value.label}}</td> <td>{{value.label}}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -1,21 +1,62 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}ТВ{% endblock %} {% block title %}{{_("TV program")}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Телепрограмма{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("TV program")}}</h1>
<li style="margin-bottom: 0.25rem; font-weight: bold;"><a href="schedule/tag/today">Все</a></li> <form action=""
{% for channel in channels %} method="get"
<li><a href="schedule/{{channel.id}}">{{channel.name}}</a></li> class="mb-4">
<div class="input-group mb-3">
<input type="text"
class="form-control"
id="query"
name="query"
placeholder="{{_('Enter the channel name')}}">
<input type="hidden"
class="form-control"
id="provider"
name="provider"
value="{{provider or providers[0]}}">
<button id="providerBtn"
class="btn btn-secondary dropdown-toggle"
type="text"
data-bs-toggle="dropdown"
aria-expanded="false">{{provider or providers[0]}}</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for provider in providers %}
<li>
<a class="dropdown-item"
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<button class="btn btn-primary"
type="submit">{{_("Search")}}</button>
</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>
{% endif %}
<ul id="storedChannels"
class="list-group mb-5">
</ul>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const searchQuery = params.get('query');
if (searchQuery) {
document.querySelector('#query').value = searchQuery;
}
document.addEventListener("DOMContentLoaded", (event) => {
scheduleChannelManager.loadChannels('#storedChannels');
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -1,57 +0,0 @@
{% extends "base.html" %}
{% block title %}
{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/schedule/style.css?v={{version}}">
<link rel="icon"
href="/static/schedule/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="..">⬆️</a>
<span>{{'Прямые трансляции' if live else 'Программа'}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endblock %}
{% block content %}
<div>
<table class="{{'live' if live else ''}}">
<thead>
<tr>
<td></td>
<td></td>
<td></td>
</tr>
</thead>
<tbody>
{% for response in responses %}
{% set values = (response.values|selectattr('live') if live else response.values)|list %}
{% if values|length > 0 %}
<tr>
<td colspan="3">
<div class="title">{{response.channel.name}}</div>
</td>
<td></td>
<td></td>
</tr>
{% for value in values %}
<tr class="{{'live' if not live and value.live else ''}}">
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{(value.end - value.start) | timedelta_format}}</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
import gettext
from contextvars import ContextVar
from fastapi import Cookie, Header, Request
from gallery.util import root_path
_translation: ContextVar[gettext.GNUTranslations | gettext.NullTranslations] = ContextVar("translation")
async def set_language(
request: Request,
accept_language: str = Header("en"),
language: str | None = Cookie(None),
):
# Simplify the header (e.g., "en-US,en;q=0.9" -> "en")
lang = language or accept_language.split(",")[0].split("-")[0]
try:
t = gettext.translation("messages", localedir=root_path / "locales", languages=[lang])
except FileNotFoundError:
t = gettext.NullTranslations()
token = _translation.set(t)
request.state.language = lang
yield lang
_translation.reset(token)
def _(message: str) -> str:
return _translation.get().gettext(message)

View File

@@ -1,89 +1,90 @@
import datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import APIRouter
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.sketch.weather.catalog import BUNDLE from gallery.easel.depends.api import api_resolver
from gallery.sketch.weather.mock import WEATHER_MOCK_DATA from gallery.easel.depends.weather import WeatherApiDepends
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse from gallery.sketch.weather.model import WeatherResponse
from gallery.version import __version__
from ..common.util import TagType, TagUtil from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI): def context_procesor(request: AppRequest) -> dict:
base_dir = Path(__file__).parent return {
app.mount("/static/weather", StaticFiles(directory=base_dir / "static")) "providers": request.app.state.api.get_api_providers(WeatherApi),
templates = Jinja2Templates( }
directory=[
base_dir.parent / "common/templates",
base_dir / "templates", templates = build_templates(
] Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
},
context_procesor,
) )
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
def build_weather_response(request: AppRequest, response: WeatherResponse): def build_weather_response(request: AppRequest, response: WeatherResponse):
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="weather.html", name="weather.html",
context={ context={
"version": __version__,
"tag_util": TagUtil,
"datetime": datetime,
"response": response, "response": response,
}, },
) )
@app.get("/weather", response_class=HTMLResponse)
async def get_weather_list(request: AppRequest): router = APIRouter(prefix="/weather")
weather_api = request.app.state.api.weather
locations = await weather_api.get_locations()
locations_data = BUNDLE.select_items(locations) @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( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={ context={
"version": __version__, "locations": locations,
"locations": locations_data,
}, },
) )
@app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str): @router.get("/{provider}/{location}", response_class=RedirectResponse)
async def get_weather(location: str):
return RedirectResponse(f"{location}/tag/today") return RedirectResponse(f"{location}/tag/today")
@app.get("/weather/{location}/day/mock", response_class=HTMLResponse)
async def get_weather_day_mock(request: AppRequest):
response = WEATHER_MOCK_DATA.get_response("day")
return build_weather_response(request, response)
@app.get("/weather/{location}/days/mock", response_class=HTMLResponse) @router.get("/{provider}/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_days_mock(request: AppRequest): async def get_weather_day(
response = WEATHER_MOCK_DATA.get_response("days") request: AppRequest,
return build_weather_response(request, response) weather_api: WeatherApiDepends,
location: str,
@app.get("/weather/{location}/day/{date}", response_class=HTMLResponse) date: datetime.date,
async def get_weather_day(request: AppRequest, location: str, date: datetime.date): ):
weather_api = request.app.state.api.weather
response = await weather_api.get_day(location, date) response = await weather_api.get_day(location, date)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int): @router.get("/{provider}/{location}/days/{days}", response_class=HTMLResponse)
weather_api = request.app.state.api.weather async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days) response = await weather_api.get_days(location, days)
return build_weather_response(request, response) return build_weather_response(request, response)
@app.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str): @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) tag_value = TagUtil.parse_tag(tag)
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY: if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date) response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS: elif tag_value.type == TagType.DAYS:
@@ -91,3 +92,10 @@ def mount(app: FastAPI):
else: else:
raise ValueError(tag) raise ValueError(tag)
return build_weather_response(request, response) 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))

View File

@@ -1,3 +1,5 @@
import datetime
from gallery.sketch.weather.model import ( from gallery.sketch.weather.model import (
Cloudness, Cloudness,
Precipitation, Precipitation,
@@ -9,40 +11,46 @@ from gallery.sketch.weather.model import (
def wind_direction_icon(wind_direction_deg: float) -> str: def wind_direction_icon(wind_direction_deg: float) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction wind_direction = WindDirectionDeg(wind_direction_deg).direction
return { if wind_direction == WindDirection.CALM:
WindDirection.N: "⬇️", return "wind-calm"
WindDirection.NE: "↙️", else:
WindDirection.E: "⬅️", return f"wind-from-{wind_direction.name.lower()}"
WindDirection.SE: "↖️",
WindDirection.S: "⬆️",
WindDirection.SW: "↗️",
WindDirection.W: "➡️",
WindDirection.NW: "↘️",
WindDirection.CALM: "",
}.get(wind_direction, wind_direction)
def cloudness_icon(sky: Sky) -> list[str]: def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
day = (3 < date.hour < 22) if period == "day" else True
day_prefix = "day" if day else "night-alt"
main_icon = "" main_icon = ""
if sky.thunder: if sky.thunder:
if sky.cloudness == Cloudness.CLEAR: main_icon = {
main_icon = "🌩️" Precipitation.NO: "lightning",
if sky.precipitation == Precipitation.NO: Precipitation.SMALL_RAIN: "storm-showers",
main_icon = "" Precipitation.RAIN: "thunderstorm",
else: Precipitation.HEAVY_RAIN: "thunderstorm",
main_icon = "⛈️" Precipitation.SHOWER: "thunderstorm",
Precipitation.SNOW: "storm-showers",
Precipitation.HEAVY_SNOW: "storm-showers",
}[sky.precipitation]
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
main_icon = f"{day_prefix}-{main_icon}"
elif sky.precipitation == Precipitation.NO: elif sky.precipitation == Precipitation.NO:
main_icon = { main_icon = {
Cloudness.CLEAR: "☀️", Cloudness.CLEAR: "day-sunny" if day else "night-clear",
Cloudness.PARTLY_CLOUDY: "🌤️", Cloudness.PARTLY_CLOUDY: f"{day_prefix}-cloudy",
Cloudness.CLOUDY: "", Cloudness.CLOUDY: "cloud",
Cloudness.MAINLY_CLOUDY: "☁️", Cloudness.MAINLY_CLOUDY: "cloudy",
}[sky.cloudness] }[sky.cloudness]
elif sky.precipitation in [Precipitation.SNOW, Precipitation.HEAVY_SNOW]:
main_icon = "🌨️"
else: else:
main_icon = "🌧️" main_icon = {
Precipitation.SMALL_RAIN: "showers",
Precipitation.RAIN: "rain-mix",
Precipitation.HEAVY_RAIN: "rain",
Precipitation.SHOWER: "rain",
Precipitation.SNOW: "snow",
Precipitation.HEAVY_SNOW: "snow",
Precipitation.HAIL: "hail",
}[sky.precipitation]
if sky.cloudness == Cloudness.PARTLY_CLOUDY:
main_icon = f"{day_prefix}-{main_icon}"
icons = [main_icon] icons = [main_icon]
if sky.fog:
icons.append("🌫️")
return icons return icons

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,72 +0,0 @@
.header {
font-size: 1rem;
text-align: left;
padding-top: 0.25rem;
}
.date {
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.1);
}
.date.now {
background: rgba(0, 128, 255, 0.2);
}
.date .value a {
all: unset;
cursor: pointer;
}
.cloudness {
vertical-align: top;
}
.cloudness .icon {
font-size: 1rem;
}
.cloudness .icon:first-child {
font-size: 2rem;
}
.temperature {
padding: 0;
}
.temperature .value {
padding: 0.1rem 0.4rem;
}
.temperature .value.positive {
color: orangered;
}
.temperature .value.negative {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure {
padding: 0;
}
.pressure .value {
padding: 0.1rem 0.4rem;
color: blueviolet;
}
.humidity .value {
color: blue;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

View File

@@ -1,20 +1,62 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода{% endblock %} {% block title %}{{_("Weather")}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %}Погода{% endblock %}
{% block content %} {% block content %}
<ul class="app-list"> <h1>{{_("Weather")}}</h1>
{% for location in locations %} <form action=""
<li><a href="weather/{{location.id}}">{{location.name}}</a></li> method="get"
class="mb-4">
<div class="input-group mb-3">
<input type="text"
class="form-control"
id="query"
name="query"
placeholder="{{_('Enter the city name')}}">
<input type="hidden"
class="form-control"
id="provider"
name="provider"
value="{{provider or providers[0]}}">
<button id="providerBtn"
class="btn btn-secondary dropdown-toggle"
type="text"
data-bs-toggle="dropdown"
aria-expanded="false">{{provider or providers[0]}}</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for provider in providers %}
<li>
<a class="dropdown-item"
onclick="provider.value='{{provider}}'; providerBtn.innerText='{{provider}}'">{{provider}}</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<button class="btn btn-primary"
type="submit">{{_("Search")}}</button>
</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>
{% endif %}
<ul id="storedLocations"
class="list-group mb-5">
</ul>
<script>
(function () {
const params = new URLSearchParams(window.location.search);
const searchQuery = params.get('query');
if (searchQuery) {
document.querySelector('#query').value = searchQuery;
}
document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations');
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -1,32 +1,37 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %} {% block title %}{{_("Weather")}} | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet"
href="/static/weather/style.css?v={{version}}">
<link rel="icon"
href="/static/weather/favicon.ico?v={{version}}"
type="image/x-icon">
{% endblock %}
{% block header %} {% block header %}
{% if response.period == 'day' %} <app-link href="/weather"
<a class="button {{'disabled' if response.date == datetime.date.today() else ''}}" icon="brightness-high">{{_("Weather")}}</app-link>
href="../tag/{{tag_util.create_tag('day', response.date, -1)}}">⬅️</a>
<a class="button"
href="../tag/days-10">⬆️</a>
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="../tag/{{tag_util.create_tag('day', response.date, 1)}}">➡️</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div> <h4>
<table style="margin: auto;"> {% 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)}}">
<i class="bi bi-arrow-left-square"></i>
</a>
<a class="icon-link"
href="../tag/days-10">
<i class="bi bi-arrow-up-square"></i>
</a>
<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)}}">
<i class="bi bi-arrow-right-square"></i>
</a>
{% endif %}
{% if response.period == 'days' %}
<span>{{response.location}} | {{format_date(response.date, DATE_FORMAT, locale=request.state.language)}}</span>
<span>- {{format_date(response.date + datetime.timedelta(days=(response.values | length - 1)), DATE_FORMAT,
locale=request.state.language)}}</span>
{% endif %}
</h4>
<div class="table-responsive">
<table class="table table-weather table-borderless table-compact text-center w-auto"
style="font-size: 130%;">
<tbody> <tbody>
<!-- date --> <!-- date -->
<tr> <tr>
@@ -39,9 +44,9 @@
{% endif %} {% endif %}
{% if response.period == 'days' %} {% if response.period == 'days' %}
<td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}"> <td class="date {{'now' if value.date.date() == datetime.date.today() else ''}}">
<span class="value"> <span class="value {{'text-danger' if value.date.weekday() in [5,6] else ''}}">
<a href="../tag/{{tag_util.create_tag('day', value.date.date())}}"> <a href="../tag/{{tag_util.create_tag('day', value.date.date())}}">
{{value.date.strftime('%a %d')}} {{format_date(value.date, 'E d', locale=request.state.language)}}
</a> </a>
</span> </span>
</td> </td>
@@ -52,14 +57,17 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Облачность {{_("Cloudiness")}}
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in response.values %} {% for value in response.values %}
<td class="cloudness"> <td class="cloudness"
{% for icon in value.sky | cloudness_icon %} data-bs-toggle="tooltip"
<div class="icon">{{icon}}</div> data-bs-title="{{ value.sky }}">
{% for icon in value.sky | cloudness_icon(value.date, response.period) %}
<!--<div class="wi wi-{{icon}} wi-xl text-primary"></div>-->
<weather-icon icon="{{icon}}"></svg-icon>
{% endfor %} {% endfor %}
</td> </td>
{% endfor %} {% endfor %}
@@ -68,7 +76,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Температура, °C {{_("Temperature, °C")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -87,13 +95,15 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Направление ветра {{_("Wind direction")}}
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in response.values %} {% for value in response.values %}
<td class="wind"> <td class="wind"
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span> data-bs-toggle="tooltip"
data-bs-title="{{ value.wind_direction }}">
<div class="wi wi-wind-deg wi-{{value.wind_direction | wind_direction_icon}} wi-l text-primary"></div>
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -101,7 +111,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Скорость ветра, м/с {{_("Wind speed, m/s")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -121,14 +131,14 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Осадки, мм {{_("Precipitation, mm")}}
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in response.values %} {% for value in response.values %}
<td class="precipitation" <td class="precipitation"
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});"> style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
<span class="value">{{value.precipitation or ''}}</span> <span class="value">{{(value.precipitation | round(2)) if value.precipitation else ''}}</span>
</td> </td>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -136,7 +146,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Давление, мм рт. ст. {{_("Pressure, mmHg")}}
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -154,7 +164,7 @@
<tr> <tr>
<td colspan="{{response.values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Влажность, % {{_("Humidity, %")}}
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@@ -7,12 +7,14 @@ from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.yandextv.api import YandexTvApi
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.cached import CachedScheduleApi from gallery.sketch.schedule.cached import CachedScheduleApi
from gallery.sketch.weather.cached import CachedWeatherApi from gallery.sketch.weather.cached import CachedWeatherApi
api = ApiBundle( api = ApiBundle(
[ [
CachedScheduleApi(YandexTvApi()),
CachedScheduleApi(MatchTvApi()), CachedScheduleApi(MatchTvApi()),
CachedWeatherApi(GismeteoApi()), CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()), CachedWeatherApi(OpenWeatherApi()),
@@ -24,8 +26,8 @@ app = build_app(api)
def run(): def run():
uvicorn.run( uvicorn.run(
"gallery.main:app", "gallery.main:app",
host="0.0.0.0", host=environ.get("GALLERY_HOST", "0.0.0.0"),
port=8000, port=int(environ.get("GALLERY_PORT", 8000)),
log_config=str(Path(__file__).parent / "logging.yaml"), log_config=str(Path(__file__).parent / "logging.yaml"),
reload="DEBUG" in environ, reload="DEBUG" in environ,
) )

View File

@@ -1,13 +1,14 @@
import datetime import datetime
import json
import logging import logging
from typing import Any, Dict, List from typing import Any
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi import HTTPException, status
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from . import datehelp from . import datehelp
from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
@@ -34,7 +35,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse: def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.parse_widget(soup) widget = ONE_DAY_PARSER.parse_widget(soup)
@@ -52,7 +53,7 @@ class GismeteoApi(WeatherApi):
) )
def _parse_manydays(self, data: str) -> WeatherResponse: def _parse_manydays(self, data: str) -> WeatherResponse:
result: List[Dict[str, Any]] = [] result: list[dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data) location = LOCATION_PARSER.parse_location(data)
widget = DAYS_PARSER.parse_widget(soup) widget = DAYS_PARSER.parse_widget(soup)
@@ -69,13 +70,44 @@ class GismeteoApi(WeatherApi):
values=values, values=values,
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return [ geo = "ru"
LocationId.OREL, latitude = 52.968498
LocationId.ZMIYEVKA, longitude = 36.0695
] data = json.loads(
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
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
max_date = datetime.date.today() + datetime.timedelta(days=9)
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)}") data = await self.SOURCE.request(f"weather-{location_id}/{datehelp.dump(date)}")
return self._parse_oneday(date, data) return self._parse_oneday(date, data)

View File

@@ -4,6 +4,7 @@ from bs4 import Tag
T = TypeVar("T") T = TypeVar("T")
class WidgetParser: class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag: def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError raise NotImplementedError

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
GISMETEO_MOCK_DATA = MockData(Path(__file__).parent / "data")

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import datetime import datetime
import logging
import re import re
from typing import Iterable from typing import Iterable
@@ -15,6 +16,8 @@ from gallery.sketch.weather.model import (
from .core import BaseWidgetParser, RowParser from .core import BaseWidgetParser, RowParser
logger = logging.getLogger("gismeteo")
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items") ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items") DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items")
@@ -36,16 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
KEY = "date" KEY = "date"
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]: def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_date_tag = tag.select_one( datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
".widget-row.widget-row-datetime-date > .row-item" if datetime_time_row:
) for item in datetime_time_row.select(".row-item > time-value"):
if datetime_date_tag: timestamp = int(item.attrs["timestamp"])
date_str = datetime_date_tag.find(text=True, recursive=False).text time = datetime.datetime.fromtimestamp(timestamp)
date = dateparser.parse(date_str, languages=["ru"])
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
time_str = item.text
time = dateparser.parse(time_str, languages=["ru"])
time = time.replace(year=date.year, month=date.month, day=date.day)
yield time yield time
else: else:
for item in tag.select(".widget-row.widget-row-date > .row-item"): for item in tag.select(".widget-row.widget-row-date > .row-item"):
@@ -59,6 +57,7 @@ class SkyParser(RowParser[Sky]):
CLOUDNESS_MAP: dict[str, Cloudness] = { CLOUDNESS_MAP: dict[str, Cloudness] = {
"ясно": Cloudness.CLEAR, "ясно": Cloudness.CLEAR,
"безоблачно": Cloudness.CLEAR,
"малооблачно": Cloudness.PARTLY_CLOUDY, "малооблачно": Cloudness.PARTLY_CLOUDY,
"облачно": Cloudness.CLOUDY, "облачно": Cloudness.CLOUDY,
"пасмурно": Cloudness.MAINLY_CLOUDY, "пасмурно": Cloudness.MAINLY_CLOUDY,
@@ -68,6 +67,8 @@ class SkyParser(RowParser[Sky]):
"без осадков": Precipitation.NO, "без осадков": Precipitation.NO,
"небольшой дождь": Precipitation.SMALL_RAIN, "небольшой дождь": Precipitation.SMALL_RAIN,
"сильный дождь": Precipitation.HEAVY_RAIN, "сильный дождь": Precipitation.HEAVY_RAIN,
"очень сильный дождь": Precipitation.HEAVY_RAIN,
"ливневый дождь": Precipitation.SHOWER,
"дождь": Precipitation.RAIN, "дождь": Precipitation.RAIN,
"ливень": Precipitation.SHOWER, "ливень": Precipitation.SHOWER,
"снег": Precipitation.SNOW, "снег": Precipitation.SNOW,
@@ -78,24 +79,38 @@ class SkyParser(RowParser[Sky]):
"сильный снег с дождём": Precipitation.HEAVY_SNOW, "сильный снег с дождём": Precipitation.HEAVY_SNOW,
"небольшой снег с дождём": Precipitation.SNOW, "небольшой снег с дождём": Precipitation.SNOW,
"небольшой мокрый снег": Precipitation.SNOW, "небольшой мокрый снег": Precipitation.SNOW,
"град": Precipitation.HAIL,
} }
THUNDER = "гроза"
FOG = "дымка"
def parse_row(self, tag: Tag) -> Iterable[Sky]: def parse_row(self, tag: Tag) -> Iterable[Sky]:
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"): for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
sky_str = item.attrs["data-tooltip"] sky_str = item.attrs["data-tooltip"]
values = {item.strip().lower() for item in sky_str.split(",")} values = {item.strip().lower() for item in sky_str.split(",")}
cloudness = Cloudness.CLEAR cloudness = Cloudness.CLEAR
precipitation = Precipitation.NO precipitation = Precipitation.NO
thunder = "гроза" in values thunder = False
fog = "дымка" in values 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(): for k, v in self.CLOUDNESS_MAP.items():
if k in values: if k in values:
cloudness = v cloudness = v
values.remove(k)
break break
for k, v in self.PRECIPITATION_MAP.items(): for k, v in self.PRECIPITATION_MAP.items():
if k in values: if k in values:
precipitation = v precipitation = v
values.remove(k)
break break
if values:
logger.warning("unknown sky values: %s:", values)
yield Sky( yield Sky(
cloudness=cloudness, cloudness=cloudness,
precipitation=precipitation, precipitation=precipitation,
@@ -108,21 +123,15 @@ class TemperatureParser(RowParser[list[int]]):
KEY = "temperature" KEY = "temperature"
def parse_row(self, tag: Tag) -> Iterable[list[int]]: def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select( for item in tag.select(".widget-row-chart[data-row=temperature-air] > .chart > .values > .value"):
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value" yield [int(value.attrs["value"]) for value in item.select("temperature-value")]
):
yield [
int(value.attrs["value"]) for value in item.select("temperature-value")
]
class WindSpeedParser(RowParser[int]): class WindSpeedParser(RowParser[int]):
KEY = "wind_speed" KEY = "wind_speed"
def parse_row(self, tag: Tag) -> Iterable[int]: def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select( for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
".widget-row-wind > .row-item > .wind-speed > speed-value"
):
yield int(item.attrs["value"]) yield int(item.attrs["value"])
@@ -152,32 +161,28 @@ class WindDirectionParser(RowParser[WindDirection]):
} }
def parse_row(self, tag: Tag) -> Iterable[float]: def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select( for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
".widget-row-wind > .row-item > .wind-speed > .wind-direction"
):
wind_direction_str = item.text.lower().strip() wind_direction_str = item.text.lower().strip()
yield WindDirectionDeg.from_direction( yield WindDirectionDeg.from_direction(self.WIND_DIRECTION_MAP[wind_direction_str]).value
self.WIND_DIRECTION_MAP[wind_direction_str]
).value
class PrecipitationParser(RowParser[float]): class PrecipitationParser(RowParser[float]):
KEY = "precipitation" KEY = "precipitation"
def parse_row(self, tag: Tag) -> Iterable[float]: def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select( for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item"):
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit" value = item.select_one("precipitation-value")
): if value:
yield float(item.text.replace(",", ".")) yield float(value.attrs["value"])
else:
yield 0
class PressureParser(RowParser[list[int]]): class PressureParser(RowParser[list[int]]):
KEY = "pressure" KEY = "pressure"
def parse_row(self, tag: Tag) -> Iterable[list[int]]: def parse_row(self, tag: Tag) -> Iterable[list[int]]:
for item in tag.select( for item in tag.select(".widget-row-chart[data-row=pressure] > .chart > .values > .value"):
".widget-row-chart[data-row=pressure] > .chart > .values > .value"
):
yield [int(value.attrs["value"]) for value in item.select("pressure-value")] yield [int(value.attrs["value"]) for value in item.select("pressure-value")]

View File

@@ -1,10 +1,8 @@
import datetime import datetime
import json
import logging import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import ChannelId
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource from gallery.sketch.source import ApiSource
@@ -15,39 +13,30 @@ class MatchTvApi(ScheduleApi):
PROVIDER = "matchtv" PROVIDER = "matchtv"
SOURCE = ApiSource("https://matchtv.ru") SOURCE = ApiSource("https://matchtv.ru")
async def get_channels(self) -> list[str]: async def find_channels(self, query: str) -> list[Channel]:
return [ endpoint = "api/v1/channels"
ChannelId.MATCH_TV, data = json.loads(await self.SOURCE.request(endpoint))
ChannelId.MATCH_IGRA, result = []
ChannelId.MATCH_ARENA, query = query.lower()
ChannelId.MATCH_FUTBOL_1, for item in data["result"]:
ChannelId.MATCH_FUTBOL_2, if query in item["name"].lower() or query in item["alias"]:
ChannelId.MATCH_FUTBOL_3, channel_id = item["alias"]
ChannelId.MATCH_STRANA, name = item["name"].split("|")[0].strip()
] result.append(Channel(id=channel_id, name=name, provider=self.provider))
return result
async def get_channel_schedule( async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
self, channel_id: str, date: datetime.date endpoint = f"api/v1/channels/{channel_id}/tv-schedule?date={date:%Y%m%d}"
) -> Schedule: data = json.loads(await self.SOURCE.request(endpoint))
endpoint = f"tvguide/{channel_id}?date={date:%Y%m%d}" channel_data = data["result"]["channels"][0]
data = await self.SOURCE.request(endpoint) channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=self.provider)
soup = BeautifulSoup(data, features="html.parser")
values = [] values = []
channel_name = ( current_day = datetime.datetime.combine(date.today(), datetime.datetime.min.time())
soup.select_one(".p-tv-guide-header__title")
.text.replace("Телепрограмма ", "")
.strip()
)
current_day = datetime.datetime.combine(
date.today(), datetime.datetime.min.time()
)
end = current_day + datetime.timedelta(days=1, hours=6) end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None prev_value: ScheduleValue | None = None
for item in soup.select( for item in channel_data["schedule"]:
".p-tv-guide-schedule-channel-carcass__transmissions .p-tv-guide-schedule-channel-transmission" title = item["title"]
): time_str = item["time"]
title = item.select_one(".p-tv-guide-schedule-channel-transmission__title").text.strip()
time_str = item.select_one(".p-tv-guide-schedule-channel-transmission__time-block").text.strip()
hours, minutes = map(int, time_str.split(":")) hours, minutes = map(int, time_str.split(":"))
item_date = current_day.replace(hour=hours, minute=minutes) item_date = current_day.replace(hour=hours, minute=minutes)
if prev_value is not None and item_date.hour < prev_value.start.hour: if prev_value is not None and item_date.hour < prev_value.start.hour:
@@ -60,5 +49,7 @@ class MatchTvApi(ScheduleApi):
prev_value.end = item_date prev_value.end = item_date
prev_value = value prev_value = value
return Schedule( return Schedule(
channel=Channel(id=channel_id, name=channel_name), date=date, values=values channel=channel,
date=date,
values=values,
) )

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data")

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,18 @@
import datetime import datetime
import logging import logging
from collections import defaultdict from collections import defaultdict
from os import environ
from aiocache import cached from aiocache import cached
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.catalog import BUNDLE, LocationId from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.model import WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit from gallery.util import TimeUnit
from .openweather import Forecast, OpenWeather from .openweather import Forecast
from .openweather import Location as OpenWeatherLocation
from .openweather import OpenWeather
from .parser import FORECAST_ITEM_PARSER from .parser import FORECAST_ITEM_PARSER
logger = logging.getLogger("openweather") logger = logging.getLogger("openweather")
@@ -18,13 +20,19 @@ logger = logging.getLogger("openweather")
class OpenWeatherApi(WeatherApi): class OpenWeatherApi(WeatherApi):
PROVIDER = "openweather" PROVIDER = "openweather"
SOURCE = OpenWeather("517a6bccceaa1c48127f6199ec3fb7cf") SOURCE = OpenWeather(environ["OPENWEATHER_KEY"])
async def get_locations(self) -> list[str]: @classmethod
return [ def _parse_location(cls, location_id: str) -> tuple[float, float]:
LocationId.OREL, return tuple(map(float, location_id.split(":", maxsplit=2)))
LocationId.ZMIYEVKA,
] @cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.location",
alias="redis",
ttl=TimeUnit.DAY,
)
async def _get_location(self, location_id: str) -> OpenWeatherLocation:
return await self.SOURCE.get_location(*self._parse_location(location_id))
@cached( @cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast", key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
@@ -32,17 +40,33 @@ class OpenWeatherApi(WeatherApi):
ttl=TimeUnit.DAY, ttl=TimeUnit.DAY,
) )
async def _get_location_forecast(self, location_id: str) -> Forecast: async def _get_location_forecast(self, location_id: str) -> Forecast:
location = BUNDLE.get_item(location_id) return await self.SOURCE.get_forecast(*self._parse_location(location_id))
return await self.SOURCE.get_forecast(location.lat, location.lon)
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
]
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: 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) data: Forecast = await self._get_location_forecast(location_id)
values = [] values = []
for item in data.list: for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item) value = FORECAST_ITEM_PARSER.parse(item)
if value.date.date() == date: if value.date.date() == date:
values.append(value) values.append(value)
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location.name,
date=date, date=date,
@@ -51,17 +75,14 @@ class OpenWeatherApi(WeatherApi):
) )
async def get_days(self, location_id: str, days: int) -> WeatherResponse: 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) data: Forecast = await self._get_location_forecast(location_id)
values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list) values_by_date: dict[datetime.datetime, list[WeatherValue]] = defaultdict(list)
for item in data.list: for item in data.list:
value = FORECAST_ITEM_PARSER.parse(item) value = FORECAST_ITEM_PARSER.parse(item)
item_date = value.date.replace(hour=0, minute=0) item_date = value.date.replace(hour=0, minute=0)
values_by_date[item_date].append(value) values_by_date[item_date].append(value)
values = [ values = [merge_weather_values(date, values) for date, values in values_by_date.items()]
merge_weather_values(date, values)
for date, values in values_by_date.items()
]
location = BUNDLE.get_item(location_id)
return WeatherResponse( return WeatherResponse(
location=location.name, location=location.name,
date=datetime.date.today(), date=datetime.date.today(),

View File

@@ -1,5 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
OPENWEATHER_MOCK_DATA = MockData(Path(__file__).parent / "data")

View File

@@ -53,7 +53,7 @@ class ForecastItem(Model):
weather: list[Weather] weather: list[Weather]
clouds: Clouds clouds: Clouds
wind: Wind wind: Wind
visibility: int visibility: int | None = None
pop: float pop: float
rain: Rain | None = None rain: Rain | None = None
sys: Sys sys: Sys
@@ -67,6 +67,15 @@ class Forecast(Model):
list: list[ForecastItem] list: list[ForecastItem]
class Location(Model):
name: str
lat: float
lon: float
country: str
state: str | None = None
local_names: dict[str, str] | None = None
class OpenWeather: class OpenWeather:
BASE_URL = "https://api.openweathermap.org" BASE_URL = "https://api.openweathermap.org"
@@ -75,9 +84,20 @@ class OpenWeather:
self._source = ApiSource(self.BASE_URL) self._source = ApiSource(self.BASE_URL)
async def get_forecast(self, lat: float, lon: float) -> Forecast: async def get_forecast(self, lat: float, lon: float) -> Forecast:
endpoint = ( endpoint = f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
f"data/2.5/forecast?lat={lat}&lon={lon}&appid={self._api_key}&units=metric"
)
response = await self._source.request(endpoint) response = await self._source.request(endpoint)
response_data = json.loads(response) response_data = json.loads(response)
return Forecast(**response_data) return Forecast.model_validate(response_data)
async def find_locations(self, query: str, limit: int = 5) -> list[Location]:
endpoint = f"geo/1.0/direct?q={query}&limit={limit}&appid={self._api_key}"
response = await self._source.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_data = json.loads(response)
return Location.model_validate(response_data[0])

View File

@@ -24,11 +24,7 @@ class ForecastItemParser:
def parse(self, item: ForecastItem) -> WeatherValue: def parse(self, item: ForecastItem) -> WeatherValue:
item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC) item_date = datetime.datetime.fromtimestamp(item.dt, datetime.UTC)
item_date = ( item_date = item_date.replace(tzinfo=datetime.timezone.utc).astimezone(tz=None).replace(tzinfo=None)
item_date.replace(tzinfo=datetime.timezone.utc)
.astimezone(tz=None)
.replace(tzinfo=None)
)
value = build_weather_value(item_date) value = build_weather_value(item_date)
# TODO parse temperature interval flag # TODO parse temperature interval flag
value.temperature = [round(item.main.temp)] value.temperature = [round(item.main.temp)]
@@ -38,12 +34,8 @@ class ForecastItemParser:
value.wind_speed = round(item.wind.speed) value.wind_speed = round(item.wind.speed)
value.wind_gust = round(item.wind.gust) value.wind_gust = round(item.wind.gust)
value.wind_direction = item.wind.deg value.wind_direction = item.wind.deg
value.sky.cloudness = self.CLOUDNESS_MAP.get( value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
item.weather[0].description, Cloudness.CLEAR value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
)
value.sky.precipitation = self.PRECIPITATION_MAP.get(
item.weather[0].description, Precipitation.NO
)
if item.rain: if item.rain:
value.precipitation = round(item.rain.interval_3h, 1) value.precipitation = round(item.rain.interval_3h, 1)
return value return value

View File

View File

@@ -0,0 +1,90 @@
import datetime
import json
import logging
from bs4 import BeautifulSoup
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from gallery.sketch.source import ApiSource
logger = logging.getLogger("matchtv")
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 = "yandextv"
SOURCE = ApiSource("https://tv.yandex.ru", headers=HEADERS)
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}"
)
_, 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
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,
)

View File

@@ -2,8 +2,13 @@ from typing import TypeVar
class Api: class Api:
TYPE: str
PROVIDER: str PROVIDER: str
@property
def type(self) -> str:
return self.TYPE
@property @property
def provider(self) -> str: def provider(self) -> str:
return self.PROVIDER return self.PROVIDER

View File

@@ -1,30 +1,19 @@
from typing import Type from collections import defaultdict
from .api import API, Api from .api import API, Api
from .schedule.api import ScheduleApi
from .weather.api import WeatherApi
class ApiBundle(list[Api]): class ApiBundle:
def __init__(self, values: list[Api]) -> None: def __init__(self, values: list[Api]):
super().__init__(values) self._values = values
self._providers_by_api = defaultdict(list)
self._api_map = {}
for value in self._values:
self._providers_by_api[value.type].append(value.provider)
self._api_map[(value.type, value.provider)] = value
def get_api_by_provider(self, provider: str) -> Api: def get_api_providers(self, api_type: type[API]) -> list[str]:
for value in self: return self._providers_by_api[api_type.TYPE]
if value.PROVIDER == provider:
return value
raise ValueError(provider)
def get_api_by_type(self, api_type: Type[API]) -> API: def get_api(self, api_type: type[API], provider: str) -> API:
for value in self: return self._api_map[(api_type.TYPE, provider)]
if isinstance(value, api_type):
return value
raise ValueError(api_type)
@property
def weather(self) -> WeatherApi:
return self.get_api_by_type(WeatherApi)
@property
def schedule(self) -> ScheduleApi:
return self.get_api_by_type(ScheduleApi)

View File

@@ -1,13 +1,19 @@
from typing import Generic from typing import Generic, NamedTuple
from gallery.util import TimeUnit from gallery.util import TimeUnit
from .api import API, Api from .api import API, Api
class CachePreset(NamedTuple):
ttl: int = TimeUnit.HOUR
alias: str = "redis"
DEFAULT_CACHE_PRESET = CachePreset()
class CachedApi(Api, Generic[API]): class CachedApi(Api, Generic[API]):
CACHE_TTL: int = TimeUnit.HOUR
CACHE_ALIAS: str = "redis"
CACHE_KEY: str CACHE_KEY: str
def __init__(self, api: API): def __init__(self, api: API):

View File

@@ -1,14 +0,0 @@
from typing import Generic, TypeVar
T = TypeVar("T")
class CatalogBundle(Generic[T]):
def __init__(self, items: list[T]) -> None:
self._items_by_id = {item.id: item for item in items}
def get_item(self, item_id: str) -> T:
return self._items_by_id[item_id]
def select_items(self, ids: list[str]) -> list[T]:
return [self._items_by_id[id_] for id_ in ids]

View File

@@ -1,17 +0,0 @@
import json
class MockData:
def __init__(self, data_dir) -> None:
self._data_dir = data_dir
def get_text(self, key: str) -> str:
return (self._data_dir / f"{key}").read_text()
def get_html(self, key: str) -> str:
return self.get_text(f"{key}.html")
def get_json(self, key: str) -> dict:
data = json.loads(self.get_text(f"{key}.json"))
return data

View File

@@ -1,14 +1,14 @@
import datetime import datetime
from ..api import Api from ..api import Api
from .model import Schedule from .model import Channel, Schedule
class ScheduleApi(Api): class ScheduleApi(Api):
async def get_channels(self) -> list[str]: TYPE = "schedule"
async def find_channels(self, query: str) -> list[Channel]:
raise NotImplementedError raise NotImplementedError
async def get_channel_schedule( async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
self, channel_id: str, date: datetime.date
) -> Schedule:
raise NotImplementedError raise NotImplementedError

View File

@@ -2,31 +2,30 @@ import datetime
from aiocache import cached from aiocache import cached
from gallery.sketch.cached import CachedApi from gallery.sketch.cached import CachedApi, CachePreset
from gallery.util import TimeUnit
from .api import ScheduleApi from .api import ScheduleApi
from .model import Schedule from .model import Channel, Schedule
CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]): class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
CACHE_KEY = "schedule" CACHE_KEY = ScheduleApi.TYPE
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.find.{query}",
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_channels(self) -> list[str]: async def find_channels(self, query: str) -> list[Channel]:
return await self._api.get_channels() return await self._api.find_channels(query)
@cached( @cached(
key_builder=lambda fun, self, channel_id, date: ( key_builder=lambda fun, self, channel_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.channel.{channel_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_channel_schedule( async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
self, channel_id: str, date: datetime.date return await self._api.get_schedule(channel_id, date)
) -> Schedule:
return await self._api.get_channel_schedule(channel_id, date)

View File

@@ -1,31 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Channel
class ChannelId(str, Enum):
MATCH_TV = "matchtv"
MATCH_IGRA = "igra"
MATCH_ARENA = "arena"
MATCH_FUTBOL_1 = "futbol-1"
MATCH_FUTBOL_2 = "futbol-2"
MATCH_FUTBOL_3 = "futbol-3"
MATCH_STRANA = "strana"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Channel(id=ChannelId.MATCH_TV, name="Матч ТВ"),
Channel(id=ChannelId.MATCH_IGRA, name="Матч! Игра"),
Channel(id=ChannelId.MATCH_ARENA, name="Матч! Арена"),
Channel(id=ChannelId.MATCH_FUTBOL_1, name="Футбол 1"),
Channel(id=ChannelId.MATCH_FUTBOL_2, name="Футбол 2"),
Channel(id=ChannelId.MATCH_FUTBOL_3, name="Футбол 3"),
Channel(id=ChannelId.MATCH_STRANA, name="Матч! Страна"),
]
)

View File

@@ -11,6 +11,7 @@ class Model(BaseModel):
class Channel(Model): class Channel(Model):
id: str id: str
name: str name: str
provider: str
class ScheduleValue(Model): class ScheduleValue(Model):

View File

@@ -7,9 +7,7 @@ logger = logging.getLogger("source")
class ApiSource: class ApiSource:
DEFAULT_USER_AGENT = ( DEFAULT_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
) )
DEFAULT_TIMEOUT = 30.0 DEFAULT_TIMEOUT = 30.0
@@ -19,18 +17,21 @@ class ApiSource:
user_agent: str = DEFAULT_USER_AGENT, user_agent: str = DEFAULT_USER_AGENT,
timeout: float = DEFAULT_TIMEOUT, timeout: float = DEFAULT_TIMEOUT,
cookies: dict[str, str] | None = None, cookies: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
): ):
self._base_url = base_url self._base_url = base_url
self._user_agent = user_agent self._user_agent = user_agent
self._timeout = timeout self._timeout = timeout
self._cookies = cookies self._cookies = cookies
self._headers = headers
async def request(self, endpoint: str) -> str: async def request(self, endpoint: str) -> str:
if endpoint.startswith("https:"):
url = endpoint
else:
url = f"{self._base_url}/{endpoint}" url = f"{self._base_url}/{endpoint}"
logger.info(url) logger.info(url)
headers = { headers = {"User-Agent": self._user_agent, **(self._headers or {})}
"User-Agent": self._user_agent,
}
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
headers=headers, headers=headers,
cookies=self._cookies, cookies=self._cookies,

View File

@@ -1,12 +1,13 @@
import datetime import datetime
from ..api import Api from ..api import Api
from .model import WeatherResponse from .model import Location, WeatherResponse
class WeatherApi(Api): class WeatherApi(Api):
TYPE = "weather"
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError raise NotImplementedError
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:

View File

@@ -2,29 +2,29 @@ import datetime
from aiocache import cached from aiocache import cached
from gallery.sketch.cached import CachedApi from gallery.sketch.cached import DEFAULT_CACHE_PRESET, CachedApi
from .api import WeatherApi from .api import WeatherApi
from .model import WeatherResponse from .model import Location, WeatherResponse
CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]): class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather" CACHE_KEY = WeatherApi.TYPE
@cached( @cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.locations", key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_locations(self) -> list[str]: async def find_locations(self, query: str) -> list[Location]:
return await self._api.get_locations() return await self._api.find_locations(query)
@cached( @cached(
key_builder=lambda fun, self, location_id, date: ( key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse: async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
return await self._api.get_day(location_id, date) return await self._api.get_day(location_id, date)
@@ -33,8 +33,7 @@ class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
key_builder=lambda fun, self, location_id, date: ( key_builder=lambda fun, self, location_id, date: (
f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}" f"api.{self.CACHE_KEY}.{self.provider}.day.{location_id}.{date}"
), ),
alias=CachedApi.CACHE_ALIAS, **CACHE_PRESET._asdict(),
ttl=CachedApi.CACHE_TTL,
) )
async def get_days(self, location_id: str, days: int) -> WeatherResponse: async def get_days(self, location_id: str, days: int) -> WeatherResponse:
return await self._api.get_days(location_id, days) return await self._api.get_days(location_id, days)

View File

@@ -1,31 +0,0 @@
from enum import Enum
from gallery.sketch.catalog import CatalogBundle
from .model import Location
class LocationId(str, Enum):
OREL = "orel-4432"
ZMIYEVKA = "zmiyevka-184640"
def __str__(self) -> str:
return self.value
BUNDLE = CatalogBundle(
[
Location(
id=LocationId.OREL,
name="Орёл",
lat=52.9687747,
lon=36.0694937,
),
Location(
id=LocationId.ZMIYEVKA,
name="Змиёвка",
lat=52.672192,
lon=36.380112,
),
]
)

View File

@@ -1,12 +0,0 @@
from pathlib import Path
from gallery.sketch.mock import MockData
from gallery.sketch.weather.model import WeatherResponse
class WeatherMockData(MockData):
def get_response(self, key: str) -> WeatherResponse:
return WeatherResponse(**self.get_json(key))
WEATHER_MOCK_DATA = WeatherMockData(Path(__file__).parent / "data")

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"day","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[20],"wind_speed":1,"wind_gust":1,"wind_direction":"SW","precipitation":0.0,"pressure":[744],"humidity":85},{"date":"2024-07-29T03:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[18],"wind_speed":1,"wind_gust":1,"wind_direction":"W","precipitation":0.6,"pressure":[742],"humidity":96},{"date":"2024-07-29T06:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":1,"wind_gust":2,"wind_direction":"S","precipitation":4.9,"pressure":[741],"humidity":95},{"date":"2024-07-29T09:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":7,"wind_direction":"S","precipitation":3.8,"pressure":[740],"humidity":83},{"date":"2024-07-29T12:00:00","sky":{"cloudness":"clear","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":0.0,"pressure":[740],"humidity":54},{"date":"2024-07-29T15:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[21],"wind_speed":4,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[738],"humidity":48},{"date":"2024-07-29T18:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[19],"wind_speed":3,"wind_gust":10,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":63},{"date":"2024-07-29T21:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[17],"wind_speed":3,"wind_gust":7,"wind_direction":"SW","precipitation":0.0,"pressure":[737],"humidity":77}]}

View File

@@ -1 +0,0 @@
{"location":"Орел","date":"2024-07-29","period":"days","values":[{"date":"2024-07-29T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":false,"fog":false},"temperature":[21,17],"wind_speed":4,"wind_gust":11,"wind_direction":"W","precipitation":9.3,"pressure":[744,737],"humidity":96},{"date":"2024-07-30T00:00:00","sky":{"cloudness":"mainly_cloudy","precipitation":"rain","thunder":true,"fog":false},"temperature":[19,14],"wind_speed":2,"wind_gust":7,"wind_direction":"N","precipitation":11.0,"pressure":[737,733],"humidity":100},{"date":"2024-07-31T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[22,14],"wind_speed":3,"wind_gust":10,"wind_direction":"NW","precipitation":1.8,"pressure":[741,738],"humidity":99},{"date":"2024-07-01T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":10,"wind_direction":"W","precipitation":0.1,"pressure":[741,740],"humidity":97},{"date":"2024-07-02T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,17],"wind_speed":2,"wind_gust":8,"wind_direction":"W","precipitation":0.2,"pressure":[740],"humidity":84},{"date":"2024-07-03T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":1,"wind_gust":4,"wind_direction":"N","precipitation":0.0,"pressure":[740,739],"humidity":99},{"date":"2024-07-04T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"no","thunder":false,"fog":false},"temperature":[25,14],"wind_speed":3,"wind_gust":6,"wind_direction":"N","precipitation":0.0,"pressure":[743,740],"humidity":92},{"date":"2024-07-05T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":true,"fog":false},"temperature":[25,15],"wind_speed":3,"wind_gust":7,"wind_direction":"NW","precipitation":2.1,"pressure":[744,743],"humidity":98},{"date":"2024-07-06T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[24,14],"wind_speed":3,"wind_gust":5,"wind_direction":"NW","precipitation":0.3,"pressure":[745,744],"humidity":98},{"date":"2024-07-07T00:00:00","sky":{"cloudness":"party_cloudy","precipitation":"small_rain","thunder":false,"fog":false},"temperature":[26,14],"wind_speed":2,"wind_gust":5,"wind_direction":"NW","precipitation":0.2,"pressure":[747,745],"humidity":95}]}

View File

@@ -1,5 +1,6 @@
import datetime import datetime
from enum import Enum from enum import StrEnum, auto
from typing import Self
from pydantic import BaseModel from pydantic import BaseModel
@@ -12,25 +13,31 @@ class Model(BaseModel):
class Location(Model): class Location(Model):
id: str id: str
name: str name: str
provider: str
lat: float lat: float
lon: float lon: float
country: str
country_code: str
district: str
subdistrict: str
class Cloudness(str, Enum): class Cloudness(StrEnum):
CLEAR = "clear" CLEAR = auto()
PARTLY_CLOUDY = "party_cloudy" PARTLY_CLOUDY = auto()
CLOUDY = "cloudy" CLOUDY = auto()
MAINLY_CLOUDY = "mainly_cloudy" MAINLY_CLOUDY = auto()
class Precipitation(str, Enum): class Precipitation(StrEnum):
NO = "no" NO = auto()
SMALL_RAIN = "small_rain" SMALL_RAIN = auto()
RAIN = "rain" RAIN = auto()
HEAVY_RAIN = "heavy_rain" HEAVY_RAIN = auto()
SHOWER = "shower" SHOWER = auto()
SNOW = "snow" SNOW = auto()
HEAVY_SNOW = "heavy_snow" HEAVY_SNOW = auto()
HAIL = auto()
class Sky(Model): class Sky(Model):
@@ -40,16 +47,16 @@ class Sky(Model):
fog: bool fog: bool
class WindDirection(str, Enum): class WindDirection(StrEnum):
CALM = "calm" CALM = auto()
N = "N" N = auto()
NE = "NE" NE = auto()
E = "E" E = auto()
SE = "SE" SE = auto()
S = "S" S = auto()
SW = "SW" SW = auto()
W = "W" W = auto()
NW = "NW" NW = auto()
class WindDirectionDeg(float): class WindDirectionDeg(float):
@@ -63,7 +70,9 @@ class WindDirectionDeg(float):
# pylint:disable=too-many-return-statements # pylint:disable=too-many-return-statements
def to_direction(self) -> WindDirection: def to_direction(self) -> WindDirection:
if self > 337.5 or self <= 22.25: if self == -1:
return WindDirection.CALM
elif self > 337.5 or self <= 22.25:
return WindDirection.N return WindDirection.N
elif self <= 67.5: elif self <= 67.5:
return WindDirection.NE return WindDirection.NE
@@ -80,10 +89,10 @@ class WindDirectionDeg(float):
elif self <= 337.5: elif self <= 337.5:
return WindDirection.NW return WindDirection.NW
else: else:
return WindDirection.CALM raise ValueError(self)
@classmethod @classmethod
def from_direction(cls, direction: WindDirection) -> "WindDirectionDeg": def from_direction(cls, direction: WindDirection) -> Self:
return cls( return cls(
{ {
WindDirection.CALM: -1, WindDirection.CALM: -1,

View File

@@ -23,9 +23,7 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
) )
def merge_weather_values( def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue:
date: datetime.datetime, values: list[WeatherValue]
) -> WeatherValue:
result = build_weather_value(date) result = build_weather_value(date)
temperatures = [] temperatures = []
pressures = [] pressures = []

View File

@@ -1,5 +1,11 @@
from pathlib import Path
class TimeUnit: class TimeUnit:
SECOND = 1 SECOND = 1
MINUTE = 60 * SECOND MINUTE = 60 * SECOND
HOUR = 60 * MINUTE HOUR = 60 * MINUTE
DAY = 24 * HOUR DAY = 24 * HOUR
root_path = Path(__file__).parent.parent

View File

@@ -1 +1,4 @@
__version__ = "0.1.0" import tomllib
from pathlib import Path
__version__ = tomllib.loads((Path(__file__).parent.parent / "pyproject.toml").read_text())["tool"]["poetry"]["version"]

View File

@@ -0,0 +1,77 @@
msgid ""
msgstr ""
"Project-Id-Version: Gallery\n"
"Last-Translator: shmyga <shmyga.z@gmail.com>\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
msgid "Index"
msgstr "Содержание"
msgid "View"
msgstr "Просмотр"
msgid "Docs"
msgstr "Документация"
msgid "Toggle theme"
msgstr "Переключить тему"
msgid "Light"
msgstr "Светлая"
msgid "Dark"
msgstr "Тёмная"
msgid "Auto"
msgstr "Авто"
msgid "Select language"
msgstr "Выберите язык"
msgid "English"
msgstr "Английский"
msgid "Russian"
msgstr "Русский"
# weather
msgid "Weather"
msgstr "Погода"
msgid "Enter the city name"
msgstr "Введите название города"
msgid "Search"
msgstr "Поиск"
msgid "Cloudiness"
msgstr "Облачность"
msgid "Temperature, °C"
msgstr "Температура, °C"
msgid "Wind direction"
msgstr "Направление ветра"
msgid "Wind speed, m/s"
msgstr "Скорость ветра, м/с"
msgid "Precipitation, mm"
msgstr "Осадки, мм"
msgid "Pressure, mmHg"
msgstr "Давление, мм рт. ст."
msgid "Humidity, %"
msgstr "Влажность, %"
# tv
msgid "TV program"
msgstr "Телепрограмма"
msgid "Live broadcasts"
msgstr "Прямые трансляции"

3474
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,32 @@
[tool.poetry] [tool.poetry]
name = "gallery" name = "gallery"
version = "0.1.0" version = "0.5.1"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [{ include = "gallery" }] packages = [{ include = "gallery" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.14"
aiohttp = "^3.9.5" aiohttp = "^3.14"
beautifulsoup4 = "^4.12.3" beautifulsoup4 = "^4.15"
dateparser = "^1.2.0" dateparser = "^1.4"
pydantic = "^2.8.2" pydantic = "^2.13"
aiocache = {extras = ["redis"], version = "^0.12.2"} aiocache = { extras = ["redis"], version = "^0.12" }
[tool.poetry.group.app.dependencies] [tool.poetry.group.app.dependencies]
fastapi = "^0.111.1" fastapi = {extras = ["standard"], version = "^0.139.0"}
jinja2 = "^3.1.4" jinja2 = "^3.1"
babel = "^2.18"
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
pytest = "^8.3.1" pytest = "^9.1"
pytest-asyncio = "^0.23.8" pytest-asyncio = "^1.4"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
pylint = "^3.2.6" pylint = "^4.0"
black = "^24.4.2" black = "^26.5"
isort = "^5.13.2" isort = "^8.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
@@ -34,6 +35,12 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
gallery = "gallery.main:run" gallery = "gallery.main:run"
[tool.black]
line-length = 120
[tool.isort]
profile = "black"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-p no:warnings" addopts = "-p no:warnings"
asyncio_mode = "auto" asyncio_mode = "auto"

5
scripts/develop Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker compose -f docker-compose-develop.yaml up --build --watch

55
scripts/docker-action Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
. .env
build () {
echo "build: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
ARGS=("build")
for ARG in ${DOCKER_ARGS[@]}; do
ARGS+=("--build-arg" "$ARG")
done
if [ -n "$PROJECT_TARGET" ]; then
ARGS+=("--target" "$PROJECT_TARGET")
fi
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
ARGS+=("-f" "$1/Dockerfile")
echo "${ARGS[@]}"
docker "${ARGS[@]}"
done
}
publish () {
echo "publish: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:latest
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker push $DOCKER_ROOT/$PROJECT_NAME:latest
done
}
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

8
scripts/format Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run isort $TARGET
poetry run black $TARGET -q

View File

@@ -2,4 +2,8 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
poetry run pylint gallery TARGET="gallery"
poetry run pylint $TARGET -sn
poetry run isort $TARGET --check-only
poetry run black $TARGET -q --check --diff

View File

@@ -2,4 +2,5 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
docker build -t shmyga/gallery . cd locales/ru/LC_MESSAGES || exit
msgfmt messages.po

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
IMAGE_NAME=shmyga/gallery
docker tag $IMAGE_NAME instreamatic.com:8083/$IMAGE_NAME
docker push instreamatic.com:8083/$IMAGE_NAME

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
# docker run --rm -p 8000:80 shmyga/gallery
docker compose up --build

22
scripts/setup Executable file
View File

@@ -0,0 +1,22 @@
#!/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

View File

@@ -2,4 +2,4 @@
set -e set -e
cd "$(dirname $(dirname "$0"))" || exit cd "$(dirname $(dirname "$0"))" || exit
poetry run pytest tests OPENWEATHER_KEY="" poetry run pytest tests

19
scripts/version Executable file
View File

@@ -0,0 +1,19 @@
#!/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

2
static/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

1
static/.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

2529
static/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More