Compare commits

...
19 Commits
Author SHA1 Message Date
shmyga 65b6adbf16 ci(version): 0.9.0 2026-09-16 15:52:56 +03:00
shmyga 2500ba19bd feat(api): add schedule groups api 2026-09-16 15:52:38 +03:00
shmyga f257caf42c ci(version): 0.8.4 2026-09-16 11:03:29 +03:00
shmyga 80d5c62480 feat(api): add CORSMiddleware to api routing 2026-09-16 11:02:39 +03:00
shmyga ff675bc04b build: remove unused .env-base values 2026-09-16 10:47:25 +03:00
shmyga d47dcf69e6 refactor(weather): add wind object to WeatherValue model 2026-09-15 18:00:22 +03:00
shmyga 91a1e4498c build(scripts): remove bash scripts, use project-manager instead 2026-09-15 17:27:22 +03:00
shmyga e6e97ed209 test(yandexweather): fix yandexweather day test 2026-09-14 23:06:14 +03:00
shmyga 126d5310f5 ci(version): 0.8.3 2026-09-14 21:55:15 +03:00
shmyga ee3003c09a build(docker): fix playwright setup 2026-09-14 21:48:13 +03:00
shmyga b556f2f948 fix(rp5): fix date row detection 2026-09-14 21:47:46 +03:00
shmyga d9016a0cb0 build(docker): add playwright browsers cache 2026-09-12 15:52:11 +03:00
shmyga 41914d7f74 ci(version): 0.8.2 2026-09-12 15:27:15 +03:00
shmyga de466de805 fix(view): fix build_url function 2026-09-12 15:26:35 +03:00
shmyga 1bffafd6a3 ci(version): 0.8.1 2026-09-10 18:48:42 +03:00
shmyga 46e72feb82 fix(rp5): fix rp5 parser 2026-09-10 18:48:23 +03:00
shmyga fdf6e0eac3 ci(version): 0.8.0 2026-09-10 16:16:03 +03:00
shmyga 60651de186 fix(rp5): fix rp5 parser 2026-09-10 10:28:24 +03:00
shmyga bed8d7af7b refactor(static): add base component types 2026-09-10 10:28:12 +03:00
51 changed files with 785 additions and 726 deletions
+1 -4
View File
@@ -1,9 +1,6 @@
DOCKER_REPO=git.shmyga.ru DOCKER_REPO=git.shmyga.ru
DOCKER_GROUP=infernalgames DOCKER_GROUP=infernalgames
DOCKER_ROOT="$DOCKER_REPO/$DOCKER_GROUP" DOCKER_ROOT="${DOCKER_REPO}/${DOCKER_GROUP}"
VERSION=$(grep -m 1 'version' ./pyproject.toml | grep -oP 'version\s*=\s*"\K[^"]+')
PYTHON_VERSION=$(grep -m 1 'python' ./pyproject.toml | grep -oP 'python\s*=\s*"\^?\K[^"]+')
DOCKER_PROJECTS=("gallery")
DOCKER_TAG="${DOCKER_TAG:-latest}" DOCKER_TAG="${DOCKER_TAG:-latest}"
OPENWEATHER_KEY="" OPENWEATHER_KEY=""
+1 -3
View File
@@ -1,6 +1,4 @@
[presets.python.tasks.setup.playwright] modules = [".", "static"]
conditions = ["{{ '.venv/bin/playwright' | file }}"]
commands = [".venv/bin/playwright install chromium --with-deps --only-shell"]
[presets.locales.tasks.build.locales] [presets.locales.tasks.build.locales]
priority = 1 priority = 1
+9 -4
View File
@@ -23,14 +23,19 @@ COPY static ./
RUN npm run build RUN npm run build
FROM python:3.14-slim FROM python:3.14-slim
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
ENV PLAYWRIGHT_BROWSERS_PATH="/opt/playwright-browsers"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
pip install playwright && \
playwright install --with-deps chromium
ENV PATH="/app/.venv/bin:$PATH"
ENV TZ="Europe/Moscow" ENV TZ="Europe/Moscow"
COPY --from=builder /app ./ WORKDIR /app
RUN .venv/bin/playwright install chromium --with-deps --only-shell
COPY --from=builder /app ./
COPY --from=node-builder /app/dist ./static/dist COPY --from=node-builder /app/dist ./static/dist
COPY gallery gallery/ COPY gallery gallery/
+9 -3
View File
@@ -1,10 +1,11 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path from gallery.util import root_path
from .route import api, doc, view from .route import api, view
def build_app(api_bundle: ApiBundle) -> FastAPI: def build_app(api_bundle: ApiBundle) -> FastAPI:
@@ -15,7 +16,12 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
) )
app.state.api = api_bundle app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist")) app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app) api.app.state.api = api_bundle
app.include_router(api.router) app.mount("/api", api.app)
app.include_router(view.router) app.include_router(view.router)
@app.get("/docs")
def get_docs():
return RedirectResponse("/api/docs")
return app return app
+33 -1
View File
@@ -1,4 +1,4 @@
from typing import Annotated from typing import Annotated, NamedTuple
from fastapi import Depends from fastapi import Depends
@@ -7,3 +7,35 @@ from gallery.sketch.schedule.api import ScheduleApi
from .api import api_resolver from .api import api_resolver
ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))] ScheduleApiDepends = Annotated[ScheduleApi, Depends(api_resolver(ScheduleApi))]
class ChannelDef(NamedTuple):
provider: str
id: str
GROUP_CANNELS_MAP: dict[str, list[ChannelDef]] = {
"central": [
ChannelDef("yandextv", "pervyy-16"),
ChannelDef("yandextv", "rossiya-1-31"),
ChannelDef("yandextv", "ntv-11"),
ChannelDef("yandextv", "sts-8"),
],
"sport": [
ChannelDef("matchtv", "matchtv"),
ChannelDef("matchtv", "igra"),
ChannelDef("matchtv", "arena"),
ChannelDef("matchtv", "futbol-1"),
ChannelDef("matchtv", "futbol-2"),
ChannelDef("matchtv", "futbol-3"),
ChannelDef("matchtv", "strana"),
ChannelDef("yandextv", "start-103"),
],
}
def resolve_group_channels(group_id: str) -> list[ChannelDef]:
return GROUP_CANNELS_MAP.get(group_id) or [ChannelDef(*item.split(":")) for item in group_id.split(",")]
ScheduleGroupChannelsDepends = Annotated[list[ChannelDef], Depends(resolve_group_channels)]
+18 -4
View File
@@ -1,7 +1,21 @@
from fastapi import APIRouter from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from gallery.easel.route import doc
from . import schedule, weather from . import schedule, weather
router = APIRouter(prefix="/api") app = FastAPI(
router.include_router(weather.router) docs_url=None,
router.include_router(schedule.router) redoc_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(weather.router)
app.include_router(schedule.router)
doc.mount(app, "/api")
+18 -1
View File
@@ -1,7 +1,11 @@
from fastapi import APIRouter from fastapi import APIRouter
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.easel.depends.schedule import ScheduleApiDepends from gallery.easel.depends.schedule import (
GROUP_CANNELS_MAP,
ScheduleApiDepends,
ScheduleGroupChannelsDepends,
)
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Channel, Schedule from gallery.sketch.schedule.model import Channel, Schedule
@@ -10,6 +14,19 @@ from ...depends.date import DateDepends
router = APIRouter(prefix="/schedule", tags=["Schedule"]) router = APIRouter(prefix="/schedule", tags=["Schedule"])
@router.get("/groups")
async def get_api_schedule_groups():
return list(GROUP_CANNELS_MAP.keys())
@router.get("/group/{group_id}/{date}")
async def get_api_schedule_group_schedule(
request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends
) -> list[Schedule]:
api = request.app.state.api
return [await api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date) for channel in channels]
@router.get("/providers") @router.get("/providers")
async def get_api_weather_providers(request: AppRequest) -> list[str]: async def get_api_weather_providers(request: AppRequest) -> list[str]:
return request.app.state.api.get_api_providers(ScheduleApi) return request.app.state.api.get_api_providers(ScheduleApi)
+4 -3
View File
@@ -8,7 +8,8 @@ from fastapi.openapi.docs import (
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
def mount(app: FastAPI): def mount(app: FastAPI, prefix: str | None = None):
openapi_url = prefix + app.openapi_url if prefix else app.openapi_url
app.mount( app.mount(
"/docs/static", "/docs/static",
StaticFiles(directory=Path(__file__).parent / "static"), StaticFiles(directory=Path(__file__).parent / "static"),
@@ -17,7 +18,7 @@ def mount(app: FastAPI):
@app.get("/docs", include_in_schema=False) @app.get("/docs", include_in_schema=False)
async def custom_swagger_ui_html(): async def custom_swagger_ui_html():
return get_swagger_ui_html( return get_swagger_ui_html(
openapi_url=app.openapi_url, openapi_url=openapi_url,
title=app.title + " - Swagger UI", title=app.title + " - Swagger UI",
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
swagger_js_url="docs/static/swagger-ui-bundle.js", swagger_js_url="docs/static/swagger-ui-bundle.js",
@@ -31,7 +32,7 @@ def mount(app: FastAPI):
@app.get("/redoc", include_in_schema=False) @app.get("/redoc", include_in_schema=False)
async def redoc_html(): async def redoc_html():
return get_redoc_html( return get_redoc_html(
openapi_url=app.openapi_url, openapi_url=openapi_url,
title=app.title + " - ReDoc", title=app.title + " - ReDoc",
redoc_js_url="docs/static/redoc.standalone.js", redoc_js_url="docs/static/redoc.standalone.js",
) )
@@ -23,7 +23,6 @@
<div class="app col-lg-8 mx-auto p-3 py-md-5"> <div class="app col-lg-8 mx-auto p-3 py-md-5">
{% if not is_widget %} {% if not is_widget %}
<header class="app-header pb-3 mb-5 border-bottom"> <header class="app-header pb-3 mb-5 border-bottom">
<div>{{request.query_params.widget}}</div>
<div class="link-list"> <div class="link-list">
<app-link href="/" <app-link href="/"
icon="gear">API Gallery</app-link> icon="gear">API Gallery</app-link>
@@ -45,7 +45,7 @@ def build_url(
if query: if query:
query_dict = {**dict(parse_qsl(parts.query)), **query} query_dict = {**dict(parse_qsl(parts.query)), **query}
parts = parts._replace(query=urlencode(query_dict)) parts = parts._replace(query=urlencode(query_dict))
return parts.geturl() return "/".join([""] + parts.geturl().split("/")[3:])
def build_templates( def build_templates(
@@ -17,13 +17,13 @@
</div> </div>
<h1>{{_("Docs")}}</h1> <h1>{{_("Docs")}}</h1>
<div class="list-group mb-3"> <div class="list-group mb-3">
<app-link href="/docs" <app-link href="/api/docs"
target="_blank" target="_blank"
icon="file-earmark-text" icon="file-earmark-text"
class="list-group-item list-group-item-action"> class="list-group-item list-group-item-action">
Swagger UI Swagger UI
</app-link> </app-link>
<app-link href="/redoc" <app-link href="/api/redoc"
target="_blank" target="_blank"
icon="file-earmark-text" icon="file-earmark-text"
class="list-group-item list-group-item-action"> class="list-group-item list-group-item-action">
@@ -1,18 +0,0 @@
GROUP_MAP = {
"central": [
("yandextv", "146"), # pervyy
("yandextv", "587"), # rossya-1
("yandextv", "162"), # ntv
("yandextv", "79"), # sts
],
"sport": [
("matchtv", "matchtv"),
("matchtv", "igra"),
("matchtv", "arena"),
("matchtv", "futbol-1"),
("matchtv", "futbol-2"),
("matchtv", "futbol-3"),
("matchtv", "strana"),
("yandextv", "1799"), # start
],
}
+11 -6
View File
@@ -8,13 +8,16 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest from gallery.easel.core import AppRequest
from gallery.easel.depends.api import api_resolver from gallery.easel.depends.api import api_resolver
from gallery.easel.depends.date import DateDepends from gallery.easel.depends.date import DateDepends
from gallery.easel.depends.schedule import ScheduleApiDepends from gallery.easel.depends.schedule import (
GROUP_CANNELS_MAP,
ScheduleApiDepends,
ScheduleGroupChannelsDepends,
)
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.model import Schedule from gallery.sketch.schedule.model import Schedule
from ..common.utils.template import build_templates from ..common.utils.template import build_templates
from .bundle import GROUP_MAP
from .filters import timedelta_format from .filters import timedelta_format
@@ -45,7 +48,7 @@ async def get_schedule_index(request: AppRequest, provider: str | None = None, q
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={"channels": channels, "groups": GROUP_MAP.keys()}, context={"channels": [item.model_dump() for item in channels], "groups": GROUP_CANNELS_MAP.keys()},
) )
@@ -61,9 +64,11 @@ async def get_group_default(group_id: str):
@router.get("/group/{group_id}/{date}", response_class=HTMLResponse) @router.get("/group/{group_id}/{date}", response_class=HTMLResponse)
async def get_group(request: AppRequest, group_id: str, date: DateDepends): async def get_group(request: AppRequest, channels: ScheduleGroupChannelsDepends, date: DateDepends):
channels = GROUP_MAP.get(group_id, [tuple(item.split(":")) for item in group_id.split(",")]) responses = [
responses = await _get_group_schedule(request.app.state.api, channels, date) await request.app.state.api.get_api(ScheduleApi, channel.provider).get_schedule(channel.id, date)
for channel in channels
]
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="group.html", name="group.html",
@@ -35,23 +35,26 @@
</div> </div>
</form> </form>
{% if channels %} {% if channels %}
<ul id="channels" <div class="mb-4">
class="list-group mb-5"> <schedule-channel-list value="{{channels | tojson | forceescape}}"
{% for channel in channels %} onclick="scheduleChannelManager.store(element)"
<schedule-channel channel="{{channel.model_dump() | tojson | forceescape}}" onadd="scheduleChannelManager.add(element)"></schedule-channel-list>
addeable></schedule-channel> <hr>
{% endfor %} </div>
</ul>
<hr>
{% endif %} {% endif %}
<ul id="storedChannels" <div class="mb-4">
class="list-group mb-4"> <h3>{{_("Stored channels")}}</h3>
</ul> <schedule-channel-list id="storedChannels"
<ul class="list-group"> onremove="scheduleChannelManager.remove(element)"></schedule-channel-list>
{% for group in groups %} </div>
<schedule-channel-group group="{{group}}"></schedule-channel-group> <div class="mb-4">
{% endfor %} <h3>{{_("Channel groups")}}</h3>
</ul> <ul class="list-group">
{% for group in groups %}
<schedule-channel-group value="{{group | tojson | forceescape}}"></schedule-channel-group>
{% endfor %}
</ul>
</div>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -59,9 +62,8 @@
if (searchQuery) { if (searchQuery) {
document.querySelector('#query').value = searchQuery; document.querySelector('#query').value = searchQuery;
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
scheduleChannelManager.loadChannels('#storedChannels'); scheduleChannelManager.load();
}); });
})(); })();
</script> </script>
+3 -5
View File
@@ -7,17 +7,15 @@ from gallery.sketch.weather.model import (
Precipitation, Precipitation,
Sky, Sky,
WindDirection, WindDirection,
WindDirectionDeg,
) )
from gallery.util import root_path from gallery.util import root_path
def wind_direction_icon(wind_direction_deg: float) -> str: def wind_direction_icon(wind_direction: WindDirection | None) -> str:
wind_direction = WindDirectionDeg(wind_direction_deg).direction if wind_direction is None:
if wind_direction == WindDirection.CALM:
return "wind-calm" return "wind-calm"
else: else:
return f"wind-from-{wind_direction.name.lower()}" return f"wind-from-{wind_direction.lower()}"
def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]: def cloudness_icon(sky: Sky, date: datetime.datetime, period: str) -> list[str]:
+1 -1
View File
@@ -53,7 +53,7 @@ async def get_weather_index(request: AppRequest, provider: str | None = None, qu
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="index.html", name="index.html",
context={"locations": locations}, context={"locations": [item.model_dump() for item in locations]},
) )
@@ -35,17 +35,18 @@
</div> </div>
</form> </form>
{% if locations %} {% if locations %}
<ul id="locations" <div class="mb-4">
class="list-group mb-5"> <weather-location-list value="{{locations | tojson | forceescape}}"
{% for location in locations %} onclick="weatherLocationManager.store(element)"
<weather-location location="{{location.model_dump() | tojson | forceescape}}"></weather-location> onadd="weatherLocationManager.add(element)"></weather-location-list>
{% endfor %} <hr>
</ul> </div>
<hr>
{% endif %} {% endif %}
<ul id="storedLocations" <div class="mb-4">
class="list-group mb-5"> <h3>{{_("Stored locations")}}</h3>
</ul> <weather-location-list id="storedLocations"
onremove="weatherLocationManager.remove(element)"></weather-location-list>
</div>
<script> <script>
(function () { (function () {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -55,7 +56,7 @@
} }
document.addEventListener("DOMContentLoaded", (event) => { document.addEventListener("DOMContentLoaded", (event) => {
weatherLocationManager.loadLocations('#storedLocations'); weatherLocationManager.load();
}); });
})(); })();
</script> </script>
@@ -107,8 +107,8 @@ locale=request.state.language)}}{% endblock %}
{% for value in response.values %} {% for value in response.values %}
<td class="wind" <td class="wind"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{{ value.wind_direction }}"> data-bs-title="{{ value.wind.direction }}">
<div class="wi-svg wi-s wi-{{value.wind_direction | wind_direction_icon}}" <div class="wi-svg wi-s wi-{{value.wind.direction | wind_direction_icon}}"
style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div> style="margin: 0 auto;">{{ 'wind-deg' | weather_icon_svg }}</div>
</td> </td>
{% endfor %} {% endfor %}
@@ -123,11 +123,11 @@ locale=request.state.language)}}{% endblock %}
<tr> <tr>
{% for value in response.values %} {% for value in response.values %}
<td class="wind" <td class="wind"
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});"> style="background-color: rgba(128, 128, 128, {{value.wind.speed * 0.05}});">
<span class="speed">{{value.wind_speed}}</span> <span class="speed">{{value.wind.speed}}</span>
{% if value.wind_gust != value.wind_speed and value.wind_gust > 0 %} {% if value.wind.gust != value.wind.speed and value.wind.gust > 0 %}
<span class="gust"> <span class="gust">
({{value.wind_gust}}) ({{value.wind.gust}})
</span> </span>
{% endif %} {% endif %}
</td> </td>
+23 -4
View File
@@ -12,12 +12,31 @@ from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue
from gallery.sketch.weather.util import merge_weather_values from gallery.sketch.weather.util import merge_weather_values
from . 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
logger = logging.getLogger("gismeteo") logger = logging.getLogger("gismeteo")
def format_date(date: datetime.date) -> str:
today = datetime.date.today()
days = (date - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
else:
return f"{days + 1}-day"
def set_dict_nested_value(target: dict, key: str, value: Any):
parts = key.split(".")
for part in parts[:-1]:
if part not in target:
target[part] = {}
target = target[part]
target[parts[-1]] = value
class GismeteoApi(WeatherApi): class GismeteoApi(WeatherApi):
PROVIDER = "gismeteo" PROVIDER = "gismeteo"
@@ -36,7 +55,7 @@ class GismeteoApi(WeatherApi):
for index, value in enumerate(parser.parse_row(widget)): for index, value in enumerate(parser.parse_row(widget)):
while len(result) < index + 1: while len(result) < index + 1:
result.append({}) result.append({})
result[index][parser.KEY] = value set_dict_nested_value(result[index], parser.KEY, value)
values = [WeatherValue(**item) for item in result] values = [WeatherValue(**item) for item in result]
# group values by 3 hours # group values by 3 hours
if len(values) > 8: if len(values) > 8:
@@ -60,7 +79,7 @@ class GismeteoApi(WeatherApi):
for index, value in enumerate(parser.parse_row(widget)): for index, value in enumerate(parser.parse_row(widget)):
while len(result) < index + 1: while len(result) < index + 1:
result.append({}) result.append({})
result[index][parser.KEY] = value set_dict_nested_value(result[index], parser.KEY, value)
values = [WeatherValue(**item) for item in result] values = [WeatherValue(**item) for item in result]
return WeatherResponse( return WeatherResponse(
location=location or "n/a", location=location or "n/a",
@@ -107,7 +126,7 @@ class GismeteoApi(WeatherApi):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")} 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}/{format_date(date)}")
return self._parse_oneday(date, data) return self._parse_oneday(date, data)
async def get_days(self, location_id: str, days: int) -> WeatherResponse: async def get_days(self, location_id: str, days: int) -> WeatherResponse:
-31
View File
@@ -1,31 +0,0 @@
from typing import Generic, Iterable, Optional, TypeVar
from bs4 import Tag
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError
class BaseWidgetParser(WidgetParser):
SELECT: str
def __init__(self, select: Optional[str] = None):
super().__init__()
self._select = select or self.SELECT
def parse_widget(self, tag: Tag) -> Tag:
widget = tag.select_one(self._select)
if widget is None:
raise ValueError(self._select)
return widget
class RowParser(Generic[T]):
KEY: str
def parse_row(self, tag: Tag) -> Iterable[T]:
raise NotImplementedError
-39
View File
@@ -1,39 +0,0 @@
import datetime
from enum import Enum
import dateparser
import dateparser.date_parser
class Day(str, Enum):
TODAY = "today"
TOMORROW = "tomorrow"
DAY_3 = "3-day"
DAY_4 = "4-day"
DAY_5 = "5-day"
def parse(value: str) -> datetime.date:
if value in ["today", "mock"]:
return datetime.date.today()
elif value == "tomorrow":
return datetime.date.today() + datetime.timedelta(days=1)
elif value.endswith("-day"):
days = int(value.split("-")[0]) - 1
return datetime.date.today() + datetime.timedelta(days=days)
else:
date = dateparser.parse(value)
if date is None:
raise ValueError(value)
return date.date()
def dump(date: datetime.date) -> str:
today = datetime.date.today()
days = (date - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
else:
return f"{days + 1}-day"
+33 -6
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import logging import logging
import re import re
from typing import Iterable from typing import Generic, Iterable, Optional, TypeVar
import dateparser import dateparser
from bs4 import Tag from bs4 import Tag
@@ -14,10 +14,37 @@ from gallery.sketch.weather.model import (
) )
from gallery.sketch.weather.parser import ParseError, parse_wind_direction from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from .core import BaseWidgetParser, RowParser
logger = logging.getLogger("gismeteo") logger = logging.getLogger("gismeteo")
T = TypeVar("T")
class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError
class BaseWidgetParser(WidgetParser):
SELECT: str
def __init__(self, select: Optional[str] = None):
super().__init__()
self._select = select or self.SELECT
def parse_widget(self, tag: Tag) -> Tag:
widget = tag.select_one(self._select)
if widget is None:
raise ValueError(self._select)
return widget
class RowParser(Generic[T]):
KEY: str
def parse_row(self, tag: Tag) -> Iterable[T]:
raise NotImplementedError
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")
@@ -134,7 +161,7 @@ class TemperatureParser(RowParser[list[int]]):
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(".widget-row-wind > .row-item > .wind-speed > speed-value"): for item in tag.select(".widget-row-wind > .row-item > .wind-speed > speed-value"):
@@ -142,7 +169,7 @@ class WindSpeedParser(RowParser[int]):
class WindGustParser(RowParser[int]): class WindGustParser(RowParser[int]):
KEY = "wind_gust" KEY = "wind.gust"
def parse_row(self, tag: Tag) -> Iterable[int]: def parse_row(self, tag: Tag) -> Iterable[int]:
for item in tag.select(".widget-row-wind > .row-item > .wind-gust"): for item in tag.select(".widget-row-wind > .row-item > .wind-gust"):
@@ -151,7 +178,7 @@ class WindGustParser(RowParser[int]):
class WindDirectionParser(RowParser[WindDirection]): class WindDirectionParser(RowParser[WindDirection]):
KEY = "wind_direction" KEY = "wind.direction"
def parse_row(self, tag: Tag) -> Iterable[float]: def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"): for item in tag.select(".widget-row-wind > .row-item > .wind-speed > .wind-direction"):
+4 -3
View File
@@ -3,6 +3,7 @@ import datetime
from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue from gallery.sketch.weather.model import Cloudness, Precipitation, WeatherValue
from gallery.sketch.weather.util import build_weather_value from gallery.sketch.weather.util import build_weather_value
from ...sketch.weather.parser import parse_wind_direction
from .openweather import ForecastItem from .openweather import ForecastItem
@@ -31,9 +32,9 @@ class ForecastItemParser:
# value.temperature = [round(item.main.temp_max), round(item.main.temp_min)] # value.temperature = [round(item.main.temp_max), round(item.main.temp_min)]
value.pressure = [round(item.main.pressure * 0.75006)] value.pressure = [round(item.main.pressure * 0.75006)]
value.humidity = item.main.humidity value.humidity = item.main.humidity
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 = parse_wind_direction(item.wind.deg)
value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR) value.sky.cloudness = self.CLOUDNESS_MAP.get(item.weather[0].description, Cloudness.CLEAR)
value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO) value.sky.precipitation = self.PRECIPITATION_MAP.get(item.weather[0].description, Precipitation.NO)
if item.rain: if item.rain:
+99 -61
View File
@@ -1,4 +1,6 @@
import re import re
from enum import Enum, auto
from typing import Iterable
import dateparser import dateparser
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
@@ -36,73 +38,109 @@ def _parse_wind_speed(cell: Tag) -> int:
return 0 return 0
class RowType(Enum):
DATE = auto()
TIME = auto()
CLOUDNESS = auto()
PRECIPITATION = auto()
TEMPERATURE = auto()
PRESSURE = auto()
WIND_SPEED = auto()
WIND_GUST = auto()
WIND_DIRECTION = auto()
HUMIDITY = auto()
def _iter_table_rows(table: Tag) -> Iterable[tuple[RowType, Tag]]:
for row in table.select("tr"):
title = row.select_one("td").text.strip().lower()
if "сегодня" in title or "завтра" in title:
yield RowType.DATE, row
elif "местное время" in title:
yield RowType.TIME, row
elif "облачность" in title:
yield RowType.CLOUDNESS, row
elif "осадки" in title:
yield RowType.PRECIPITATION, row
elif "температура" in title:
yield RowType.TEMPERATURE, row
elif "давление" in title:
yield RowType.PRESSURE, row
elif "скорость" in title:
yield RowType.WIND_SPEED, row
elif "порывы" in title:
yield RowType.WIND_GUST, row
elif "направление" in title:
yield RowType.WIND_DIRECTION, row
elif "влажность" in title:
yield RowType.HUMIDITY, row
# pylint:disable=too-many-branches # pylint:disable=too-many-branches
def parse_page(page: BeautifulSoup) -> list[WeatherResponse]: def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip() location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip()
table = page.select_one("#forecastTable") table = page.select_one("#forecastTable")
if table is None: if table is None:
raise ParseError("#forecastTable not found") raise ParseError("#forecastTable not found")
(
date_row,
time_row,
cloudness_row,
precipitation_row,
temperature_row,
_,
pressure_row,
wind_speed_row,
wind_gust_row,
wind_direction_row,
humidity_row,
*_,
) = table.select("tr")
result: list[WeatherResponse] = [] result: list[WeatherResponse] = []
values: list[WeatherValue] = [] values: list[WeatherValue] = []
for index, cell in enumerate(date_row.select("td")): for row_type, row in _iter_table_rows(table):
colspan = int(cell.attrs["colspan"]) match row_type:
if index == 0: case RowType.DATE:
colspan -= 1 for index, cell in enumerate(row.select("td")):
date_str = ",".join(cell.text.strip().split(",")[-2:]) colspan = int(cell.attrs["colspan"])
date = dateparser.parse(date_str, languages=["ru"]) if index == 0:
if date is None: colspan -= 1
raise ParseError.invalid_date(date_str) date_str = ",".join(cell.text.strip().split(",")[-2:])
item_values = [build_weather_value(date) for _ in range(colspan)] date = dateparser.parse(date_str, languages=["ru"])
result.append( if date is None:
WeatherResponse( raise ParseError.invalid_date(date_str)
location=location, item_values = [build_weather_value(date) for _ in range(colspan)]
date=date.date(), result.append(
period="day", WeatherResponse(
values=item_values, location=location,
) date=date.date(),
) period="day",
values += item_values values=item_values,
for index, cell in enumerate(time_row.select("td")[1:]): )
hour = int(cell.text.strip()) )
values[index].date = values[index].date.replace(hour=hour) values += item_values
for index, cell in enumerate(cloudness_row.select("td")[1:]): case RowType.TIME:
onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"] for index, cell in enumerate(row.select("td")[1:]):
cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip() hour = int(cell.text.strip())
values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str] values[index].date = values[index].date.replace(hour=hour)
for index, cell in enumerate(precipitation_row.select("td")[1:]): case RowType.CLOUDNESS:
onmouseover = cell.select_one(".pr_0").attrs["onmouseover"] for index, cell in enumerate(row.select("td")[1:]):
precipitation_str = onmouseover.split(",")[1].strip(" '") onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"]
precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str) cloudness_str = re.compile("<b>(.*?)</b>").search(onmouseover).group(1).strip()
if precipitation_value_matched: values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str]
# one hour value case RowType.PRECIPITATION:
values[index].precipitation = float(precipitation_value_matched.group(1)) * 6 for index, cell in enumerate(row.select("td")[1:]):
if "(" in precipitation_str: onmouseover = cell.select_one(".pr_0").attrs["onmouseover"]
precipitation_str = precipitation_str.split(" (")[0] precipitation_str = onmouseover.split(",")[1].strip(" '")
values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str] precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str)
for index, cell in enumerate(temperature_row.select("td")[1:]): if precipitation_value_matched:
values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())] # one hour value
for index, cell in enumerate(pressure_row.select("td")[1:]): values[index].precipitation = float(precipitation_value_matched.group(1)) * 6
values[index].pressure = [int(cell.select_one(".p_0").text.strip())] if "(" in precipitation_str:
for index, cell in enumerate(wind_speed_row.select("td")[1:]): precipitation_str = precipitation_str.split(" (")[0]
values[index].wind_speed = _parse_wind_speed(cell) values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str]
for index, cell in enumerate(wind_gust_row.select("td")[1:]): case RowType.TEMPERATURE:
values[index].wind_speed = _parse_wind_speed(cell) for index, cell in enumerate(row.select("td")[1:]):
for index, cell in enumerate(wind_direction_row.select("td")[1:]): values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())]
values[index].wind_direction = parse_wind_direction(cell.text.strip()) case RowType.PRESSURE:
for index, cell in enumerate(humidity_row.select("td")[1:]): for index, cell in enumerate(row.select("td")[1:]):
values[index].humidity = int(cell.text.strip()) values[index].pressure = [int(cell.select_one(".p_0").text.strip())]
case RowType.WIND_SPEED:
for index, cell in enumerate(row.select("td")[1:]):
values[index].wind.speed = _parse_wind_speed(cell)
case RowType.WIND_GUST:
for index, cell in enumerate(row.select("td")[1:]):
values[index].wind.gust = _parse_wind_speed(cell)
case RowType.WIND_DIRECTION:
for index, cell in enumerate(row.select("td")[1:]):
values[index].wind.direction = parse_wind_direction(cell.text.strip())
case RowType.HUMIDITY:
for index, cell in enumerate(row.select("td")[1:]):
values[index].humidity = int(cell.text.strip())
return result return result
+4 -2
View File
@@ -16,13 +16,15 @@ logger = logging.getLogger("matchtv")
class YandexTvApi(ScheduleApi): class YandexTvApi(ScheduleApi):
PROVIDER = "yandextv" PROVIDER = "yandextv"
lr = 213 # magic id
def _build_source(self) -> ApiSource: def _build_source(self) -> ApiSource:
return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru")) return ApiSource("https://tv.yandex.ru", headers=build_headers("tv.yandex.ru"))
async def find_channels(self, query: str) -> list[Channel]: async def find_channels(self, query: str) -> list[Channel]:
url = ( url = (
"https://suggest-multi.yandex.ru/suggest-tv2?" "https://suggest-multi.yandex.ru/suggest-tv2?"
f"v=4&uil=ru&lr=10&count_channels=4&count_programs=4&sn=50&part={query}" f"v=4&uil=ru&lr={self.lr}&count_channels=4&count_programs=4&sn=50&part={query}"
) )
_, values = json.loads(await self.source.request(url)) _, values = json.loads(await self.source.request(url))
result = [] result = []
@@ -33,7 +35,7 @@ class YandexTvApi(ScheduleApi):
return result return result
async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule: async def get_schedule(self, channel_id: str, date: datetime.date) -> Schedule:
endpoint = f"channels/{channel_id}?date={date:%Y-%m-%d}" endpoint = f"{self.lr}/channel/{channel_id}?date={date:%Y-%m-%d}"
data = await self.source.request(endpoint) data = await self.source.request(endpoint)
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
if soup.select_one(".CheckboxCaptcha") is not None: if soup.select_one(".CheckboxCaptcha") is not None:
+2 -2
View File
@@ -72,10 +72,10 @@ def parse_article(article: Tag, location: str) -> WeatherResponse:
value.temperature.append(temperature) value.temperature.append(temperature)
wind_speed_tag = temperature_tag.next_element.next_element wind_speed_tag = temperature_tag.next_element.next_element
wind_speed = int(wind_speed_tag.text.strip()) wind_speed = int(wind_speed_tag.text.strip())
value.wind_speed = wind_speed value.wind.speed = wind_speed
wind_direction_tag = wind_speed_tag.next_element.next_element.next_element.next_element wind_direction_tag = wind_speed_tag.next_element.next_element.next_element.next_element
wind_direction_str = wind_direction_tag.text.strip() wind_direction_str = wind_direction_tag.text.strip()
value.wind_direction = parse_wind_direction(wind_direction_str) value.wind.direction = parse_wind_direction(wind_direction_str)
humidity_tag = wind_direction_tag.next_element.next_element.next_element.next_element humidity_tag = wind_direction_tag.next_element.next_element.next_element.next_element
humidity = int(humidity_tag.text.strip().rstrip("%")) humidity = int(humidity_tag.text.strip().rstrip("%"))
value.humidity = humidity value.humidity = humidity
+5 -51
View File
@@ -58,7 +58,6 @@ class Sky(Model):
class WindDirection(StrEnum): class WindDirection(StrEnum):
CALM = auto()
N = auto() N = auto()
NE = auto() NE = auto()
E = auto() E = auto()
@@ -69,62 +68,17 @@ class WindDirection(StrEnum):
NW = auto() NW = auto()
class WindDirectionDeg(float): class Wind(Model):
@property speed: int
def direction(self) -> WindDirection: gust: int
return self.to_direction() direction: WindDirection | None = None
@property
def value(self) -> float:
return self
# pylint:disable=too-many-return-statements
def to_direction(self) -> WindDirection:
if self == -1:
return WindDirection.CALM
elif self > 337.5 or self <= 22.25:
return WindDirection.N
elif self <= 67.5:
return WindDirection.NE
elif self <= 112.5:
return WindDirection.E
elif self <= 157.5:
return WindDirection.SE
elif self <= 202.5:
return WindDirection.S
elif self <= 247.5:
return WindDirection.SW
elif self <= 292.5:
return WindDirection.W
elif self <= 337.5:
return WindDirection.NW
else:
raise ValueError(self)
@classmethod
def from_direction(cls, direction: WindDirection) -> Self:
return cls(
{
WindDirection.CALM: -1,
WindDirection.N: 0,
WindDirection.NE: 45,
WindDirection.E: 90,
WindDirection.SE: 135,
WindDirection.S: 180,
WindDirection.SW: 225,
WindDirection.W: 270,
WindDirection.NW: 315,
}[direction]
)
class WeatherValue(Model): class WeatherValue(Model):
date: datetime.datetime date: datetime.datetime
sky: Sky sky: Sky
wind: Wind
temperature: list[int] temperature: list[int]
wind_speed: int
wind_gust: int
wind_direction: float
precipitation: float precipitation: float
pressure: list[int] = Field(default=[]) pressure: list[int] = Field(default=[])
humidity: int = Field(default=-1) humidity: int = Field(default=-1)
+33 -8
View File
@@ -1,6 +1,6 @@
from typing import Self from typing import Self
from gallery.sketch.weather.model import WindDirection, WindDirectionDeg from gallery.sketch.weather.model import WindDirection
class ParseError(Exception): class ParseError(Exception):
@@ -9,11 +9,7 @@ class ParseError(Exception):
return cls("Invalid date string", date_str) return cls("Invalid date string", date_str)
WIND_DIRECTION_MAP: dict[str, WindDirection] = { WIND_DIRECTION_MAP: dict[str, WindDirection | None] = {
"—": WindDirection.CALM,
"": WindDirection.CALM,
"штиль": WindDirection.CALM,
"штл": WindDirection.CALM,
"с": WindDirection.N, "с": WindDirection.N,
"св": WindDirection.NE, "св": WindDirection.NE,
"в": WindDirection.E, "в": WindDirection.E,
@@ -25,5 +21,34 @@ WIND_DIRECTION_MAP: dict[str, WindDirection] = {
} }
def parse_wind_direction(value: str) -> WindDirectionDeg: # pylint:disable=too-many-return-statements
return WindDirectionDeg.from_direction(WIND_DIRECTION_MAP[value.replace("-", "").lower()]) def _parse_wind_direction_number(value: int | float) -> WindDirection | None:
if value > 337.5 or value <= 22.25:
return WindDirection.N
elif value <= 67.5:
return WindDirection.NE
elif value <= 112.5:
return WindDirection.E
elif value <= 157.5:
return WindDirection.SE
elif value <= 202.5:
return WindDirection.S
elif value <= 247.5:
return WindDirection.SW
elif value <= 292.5:
return WindDirection.W
elif value <= 337.5:
return WindDirection.NW
else:
return None
def _parse_wind_direction_str(value: str) -> WindDirection | None:
return WIND_DIRECTION_MAP.get(value.replace("-", "").lower())
def parse_wind_direction(value: str | int | float) -> WindDirection | None:
if isinstance(value, str):
return _parse_wind_direction_str(value)
else:
return _parse_wind_direction_number(value)
+13 -11
View File
@@ -1,8 +1,8 @@
import datetime import datetime
import statistics import statistics
from typing import Literal from typing import Counter, Literal
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg from .model import Cloudness, Precipitation, Sky, WeatherValue, Wind
MergeMode = Literal["interval", "average"] MergeMode = Literal["interval", "average"]
@@ -16,10 +16,12 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
thunder=False, thunder=False,
fog=False, fog=False,
), ),
wind=Wind(
speed=0,
gust=0,
direction=None,
),
temperature=[], temperature=[],
wind_speed=0,
wind_gust=0,
wind_direction=WindDirectionDeg(-1),
precipitation=0, precipitation=0,
pressure=[], pressure=[],
humidity=-1, humidity=-1,
@@ -55,18 +57,18 @@ def merge_weather_values(
temperatures += value.temperature temperatures += value.temperature
pressures += value.pressure pressures += value.pressure
humidities.append(value.humidity) humidities.append(value.humidity)
wind_speeds.append(value.wind_speed) wind_speeds.append(value.wind.speed)
wind_gusts.append(value.wind_gust) wind_gusts.append(value.wind.gust)
wind_directions.append(value.wind_direction) wind_directions.append(value.wind.direction)
cloudnesses.append(value.sky.cloudness) cloudnesses.append(value.sky.cloudness)
precipitations.append(value.sky.precipitation) precipitations.append(value.sky.precipitation)
precipitation += value.precipitation precipitation += value.precipitation
result.temperature = _merge_values(temperatures, mode) result.temperature = _merge_values(temperatures, mode)
result.pressure = _merge_values(pressures, mode) result.pressure = _merge_values(pressures, mode)
result.humidity = round(statistics.mean(humidities)) result.humidity = round(statistics.mean(humidities))
result.wind_speed = round(statistics.mean(wind_speeds)) result.wind.speed = round(statistics.mean(wind_speeds))
result.wind_gust = round(statistics.mean(wind_gusts)) result.wind.gust = round(statistics.mean(wind_gusts))
result.wind_direction = statistics.mean(wind_directions) result.wind.direction = Counter(wind_directions).most_common(1)[0][0]
for item in cloudnesses: for item in cloudnesses:
if Cloudness.values.index(item) > Cloudness.values.index(result.sky.cloudness): if Cloudness.values.index(item) > Cloudness.values.index(result.sky.cloudness):
result.sky.cloudness = item result.sky.cloudness = item
+9
View File
@@ -69,9 +69,18 @@ msgstr "Давление, мм рт. ст."
msgid "Humidity, %" msgid "Humidity, %"
msgstr "Влажность, %" msgstr "Влажность, %"
msgid "Stored locations"
msgstr "Сохранённые локации"
# tv # tv
msgid "TV program" msgid "TV program"
msgstr "Телепрограмма" msgstr "Телепрограмма"
msgid "Live broadcasts" msgid "Live broadcasts"
msgstr "Прямые трансляции" msgstr "Прямые трансляции"
msgid "Stored channels"
msgstr "Сохранённые каналы"
msgid "Channel groups"
msgstr "Группы каналов"
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "gallery" name = "gallery"
description = "" description = ""
version = "0.7.0" version = "0.9.0"
readme = "README.md" readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }] authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = '>=3.14,<4.0' requires-python = '>=3.14,<4.0'
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
docker compose -f docker-compose-develop.yaml up --build --watch
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
. .env
build () {
echo "build: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
ARGS=("build")
for ARG in ${DOCKER_ARGS[@]}; do
ARGS+=("--build-arg" "$ARG")
done
if [ -n "$PROJECT_TARGET" ]; then
ARGS+=("--target" "$PROJECT_TARGET")
fi
ARGS+=("-t" "$DOCKER_GROUP/$PROJECT_NAME" ".")
ARGS+=("-f" "$1/Dockerfile")
echo "${ARGS[@]}"
docker "${ARGS[@]}"
done
}
publish () {
echo "publish: $1"
. "$1/.env"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker tag $DOCKER_GROUP/$PROJECT_NAME $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
docker push $DOCKER_ROOT/$PROJECT_NAME:$VERSION
docker push $DOCKER_ROOT/$PROJECT_NAME:$DOCKER_TAG
done
}
save () {
echo "save: $1"
. "$1/.env"
mkdir -p "$1/dist"
for PROJECT in "${DOCKER_PROJECTS[@]}"; do
IFS=: read -r PROJECT_NAME PROJECT_TARGET <<< "$PROJECT"
docker save --output "$1/dist/$PROJECT_NAME-$VERSION.tar" "$DOCKER_GROUP/$PROJECT_NAME"
done
}
DEFAULT_TARGETS="."
TARGETS="${@-$DEFAULT_TARGETS}"
DOCKER_ACTION="${DOCKER_ACTION-build}"
for TARGET in $TARGETS; do
$DOCKER_ACTION "$TARGET"
done
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run isort $TARGET
poetry run black $TARGET -q
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
TARGET="gallery"
poetry run pylint $TARGET -sn
poetry run isort $TARGET --check-only
poetry run black $TARGET -q --check --diff
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
cd locales/ru/LC_MESSAGES || exit
msgfmt messages.po
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
# env
if [[ ! -f .env ]]; then
cp .env-base .env
fi
source .env
# python
poetry env use ${PYTHON_VERSION}
poetry install
.venv/bin/playwright install chromium --with-deps --only-shell
# static
cd static || exit
if [[ -f $HOME/.nvm/nvm.sh ]]; then
source "$HOME/.nvm/nvm.sh"
nvm use
fi
npm ci
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
OPENWEATHER_KEY="" poetry run pytest tests
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -e
cd "$(dirname $(dirname "$0"))" || exit
if [ -z "$1" ]; then
echo "Usage: $0 [version]"
exit 1
fi
if [[ ! -z "$(git status -s)" ]]; then
echo "Uncomitted changes"
exit 1
fi
poetry version $1
(cd static && npm version $1 --allow-same-version)
git add .
git commit -m "ci(version): $1"
git tag $1
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.9.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.7.0", "version": "0.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
+196
View File
@@ -0,0 +1,196 @@
export interface Type<T> extends Function {
new (...args: any[]): T;
}
export abstract class ValueElement<T, O> extends HTMLElement {
static observedAttributes = ["value"];
private _value: T | null = null;
get value(): T | null {
return this._value;
}
set value(value: T | null) {
if (value) {
this.setAttribute("value", JSON.stringify(value));
} else if (this.hasAttribute("value")) {
this.removeAttribute("value");
}
}
options: O | null;
constructor(value: T | null = null, options: O | null = null) {
super();
if (value) {
this.value = value;
}
this.options = options;
this.onInit();
}
onInit() {}
beforeRefresh() {}
onRefresh() {}
abstract buildHTML(): string;
refresh() {
const rawValue = this.getAttribute("value");
if (rawValue) {
this._value = JSON.parse(rawValue);
} else {
this._value = null;
}
if (this.className) {
this.classList = this.className;
}
this.beforeRefresh();
this.innerHTML = this.buildHTML();
this.onRefresh();
}
connectedCallback() {
this.refresh();
}
attributeChangedCallback(name: string, oldValue: any, newValue: any) {
setTimeout(() => this.refresh());
}
}
export interface ListOptions<T> {
onClick?: (element: ListItemElement<T>) => void;
onAdd?: (element: ListItemElement<T>) => void;
onRemove?: (element: ListItemElement<T>) => void;
}
export abstract class ListItemElement<T> extends ValueElement<T, ListOptions<T>> {
abstract getHref(value: T): string;
abstract getContent(value: T): string;
className = "list-group-item list-group-item-action";
onInit(): void {
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
this.handleAddClick = this.handleAddClick.bind(this);
}
buildHTML(): string {
if (!this.value) {
return "";
}
const href = this.getHref(this.value);
const content = this.getContent(this.value);
return `
<a href="${href}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-success me-2 ${this.options?.onAdd ? "" : "d-none"}" style="color: green;" data-add>
&plus;
</span>
${content}
</span>
<span>
<span class="text-danger ms-2 ${this.options?.onRemove ? "" : "d-none"}" data-remove>
&#x2715;
</span>
</span>
</a>
`;
}
onRefresh(): void {
this.addEventListener("click", this.handleClick);
if (this.options?.onAdd) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
if (this.options?.onRemove) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
handleClick(event: MouseEvent) {
if (this.value && this.options?.onClick) {
this.options.onClick(this);
}
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onAdd) {
this.options.onAdd(this);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.value && this.options?.onRemove) {
this.options.onRemove(this);
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-add]")?.removeEventListener("click", this.handleAddClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
}
export abstract class ListElement<T, C extends ListItemElement<T> = ListItemElement<T>> extends ValueElement<
T[],
ListOptions<T>
> {
static observedAttributes = ["value", "onadd", "onremove", "onclick"];
abstract childType: Type<C>;
className = "list-group";
buildHTML(): string {
return "";
}
beforeRefresh() {
if (this.hasAttribute("onclick")) {
const onclick = new Function("element", this.getAttribute("onclick")!) as ListOptions<T>["onClick"];
if (!this.options) {
this.options = {};
}
this.options.onClick = onclick;
}
if (this.hasAttribute("onadd")) {
const onadd = new Function("element", this.getAttribute("onadd")!) as ListOptions<T>["onAdd"];
if (!this.options) {
this.options = {};
}
this.options.onAdd = onadd;
}
if (this.hasAttribute("onremove")) {
const onremove = new Function("element", this.getAttribute("onremove")!) as ListOptions<T>["onRemove"];
if (!this.options) {
this.options = {};
}
this.options.onRemove = onremove;
}
}
onRefresh() {
if (this.value) {
for (const item of this.value) {
this.appendChild(new this.childType(item, this.options));
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ListElement, ListItemElement } from "./component";
import { DataStorage, StorageValue } from "./storage";
export class DataManager<T extends StorageValue> {
private storage: DataStorage<T>;
private get dataList(): ListElement<T> {
return document.querySelector(this.dataListSelector)!;
}
constructor(
listComponentType: typeof ListElement<T>, //prevent removing as unusing
storageKey: string,
private dataListSelector: string,
) {
this.storage = new DataStorage(storageKey);
this.load = this.load.bind(this);
this.store = this.store.bind(this);
this.add = this.add.bind(this);
this.remove = this.remove.bind(this);
}
load() {
this.dataList.value = this.storage.getAll();
}
store(element: ListItemElement<T>) {
if (element.value) {
this.storage.add(element.value);
}
}
add(element: ListItemElement<T>) {
const value = element.value;
value;
if (value && this.storage.add(value)) {
const clone: ListItemElement<T> = element.cloneNode(true) as ListItemElement<T>;
clone.options = { onRemove: this.remove };
this.dataList.appendChild(clone);
}
}
remove(element: ListItemElement<T>) {
const value = element.value;
if (value) {
this.storage.remove(value.id);
element.remove();
}
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
export class DataStorage<T extends { id: string }> { export interface StorageValue {
id: string;
}
export class DataStorage<T extends StorageValue> {
constructor(private storageKey: string) {} constructor(private storageKey: string) {}
private readValues(): T[] { private readValues(): T[] {
+40 -98
View File
@@ -1,110 +1,52 @@
import { ListElement, ListItemElement, ValueElement } from "../base/component";
import { Channel } from "./model"; import { Channel } from "./model";
export class ScheduleChannelElement extends HTMLElement { export class ScheduleChannelGroup extends ValueElement<string | Channel[], {}> {
static observedAttributes = ["channel", "removable"]; className: string = "list-group-item list-group-item-action";
channel: Channel | null = null; buildHTML(): string {
const groupName = Array.isArray(this.value) ? "Open All" : this.value;
constructor() { const groupId = Array.isArray(this.value)
super(); ? this.value.map((item) => `${item.provider}:${item.id}`).join(",")
this.handleClick = this.handleClick.bind(this); : this.value || "";
this.handleRemoveClick = this.handleRemoveClick.bind(this); return `
this.handleAddClick = this.handleAddClick.bind(this); <a href="group/${encodeURIComponent(groupId)}"
}
connectedCallback() {
this.channel = JSON.parse(this.getAttribute("channel") || "{}");
this.innerHTML = `
<a href="${this.channel?.provider}/${this.channel?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none"> class="d-flex justify-content-between align-items-start text-decoration-none">
<span> <span>
<span class="text-success me-2" style="color: green;" data-add> <span class="text-primary" style="text-transform: capitalize">${groupName}</span>
&plus;
</span>
<span class="badge me-1 text-bg-secondary">${this.channel?.provider}</span>
<span class="text-primary">${this.channel?.name}</span>
</span>
<span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span> </span>
</a>`; </a>`;
this.classList = "list-group-item list-group-item-action"; }
this.addEventListener("click", this.handleClick); }
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick); customElements.define("schedule-channel-group", ScheduleChannelGroup);
export class ScheduleChannelItem extends ListItemElement<Channel> {
getHref(value: Channel): string {
return `${value.provider}/${value.id}`;
}
getContent(value: Channel): string {
return `
<span class="badge me-1 text-bg-secondary">${value.provider}</span>
<span class="text-primary">${value.name}</span>
`;
}
}
export class ScheduleChannelList extends ListElement<Channel, ScheduleChannelItem> {
childType = ScheduleChannelItem;
public onRefresh(): void {
super.onRefresh();
if (this.value?.length) {
const groupElement = new ScheduleChannelGroup(this.value);
this.prepend(groupElement);
} else { } else {
this.querySelector("[data-remove]")?.remove(); this.prepend("Empty");
}
if (this.hasAttribute("addeable")) {
this.querySelector<HTMLElement>("[data-add]")?.addEventListener("click", this.handleAddClick);
} else {
this.querySelector("[data-add]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.channel) {
window.scheduleChannelStorage.add(this.channel);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
window.scheduleChannelStorage.remove(this.channel.id);
}
this.remove();
}
handleAddClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.channel) {
if (window.scheduleChannelStorage.add(this.channel)) {
const storedChannels = document.querySelector("#storedChannels");
if (storedChannels) {
const clone: Element = this.cloneNode(true) as Element;
clone.removeAttribute("addeable");
clone.setAttribute("removable", "");
storedChannels.appendChild(clone);
}
}
} }
} }
} }
export class ScheduleChannelGroupElement extends HTMLElement { customElements.define("schedule-channel-item", ScheduleChannelItem);
static observedAttributes = ["group", "channels"]; customElements.define("schedule-channel-list", ScheduleChannelList);
group: string | null = null;
channels: Channel[] | null = null;
constructor() {
super();
}
connectedCallback() {
this.channels = JSON.parse(this.getAttribute("channels") || "[]");
this.group = this.getAttribute("group");
const groupId = this.channels?.map((item) => `${item.provider}:${item.id}`).join(",") || this.group || "n/a";
this.innerHTML = `
<a href="group/${encodeURIComponent(groupId)}"
class="d-flex justify-content-between align-items-start text-decoration-none">
<span>
<span class="text-primary" style="text-transform: capitalize">${this.group || "Open group"}</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
}
}
customElements.define("schedule-channel", ScheduleChannelElement);
customElements.define("schedule-channel-group", ScheduleChannelGroupElement);
+4 -27
View File
@@ -1,34 +1,11 @@
import { DataStorage } from "../base/storage"; import { DataManager } from "../base/manager";
import { ScheduleChannelElement, ScheduleChannelGroupElement } from "./component"; import { ScheduleChannelList } from "./component";
import { Channel } from "./model"; import { Channel } from "./model";
export class ScheduleChannelManager {
loadChannels(selector: string) {
const channels = window.scheduleChannelStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
if (channels.length > 0) {
const groupElement = new ScheduleChannelGroupElement();
groupElement.setAttribute("channels", JSON.stringify(channels));
container.append(groupElement);
}
for (const channel of channels) {
const element = new ScheduleChannelElement();
element.setAttribute("channel", JSON.stringify(channel));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
declare global { declare global {
interface Window { interface Window {
scheduleChannelStorage: DataStorage<Channel>; scheduleChannelManager: DataManager<Channel>;
scheduleChannelManager: ScheduleChannelManager;
} }
} }
window.scheduleChannelStorage = new DataStorage("schedule:channels"); window.scheduleChannelManager = new DataManager(ScheduleChannelList, "schedule:channels", "#storedChannels");
window.scheduleChannelManager = new ScheduleChannelManager();
+26 -56
View File
@@ -1,63 +1,33 @@
import { ListElement, ListItemElement } from "../base/component";
import { Location } from "./model"; import { Location } from "./model";
export class WeatherLocationElement extends HTMLElement { export class WheatherLocationItem extends ListItemElement<Location> {
static observedAttributes = ["location", "removable"]; getHref(value: Location): string {
return `${value.provider}/${value.id}`;
location: Location | null = null;
constructor() {
super();
this.handleClick = this.handleClick.bind(this);
this.handleRemoveClick = this.handleRemoveClick.bind(this);
} }
connectedCallback() { getContent(value: Location): string {
this.location = JSON.parse(this.getAttribute("location") || "{}"); return `
this.innerHTML = ` <span class="badge me-1 text-bg-secondary">${value.provider}</span>
<a href="${this.location?.provider}/${this.location?.id}" <span class="fi fi-${value.country_code} me-1"></span>
class="d-flex justify-content-between align-items-start text-decoration-none"> <span class="text-primary">${value.name}</span>
<span> <span class="small ms-1 text-secondary">
<span class="badge me-1 text-bg-secondary">${this.location?.provider}</span> ${value.country}, ${value.district}, ${value.subdistrict}
<span class="fi fi-${this.location?.country_code} me-1"></span> </span>
<span class="text-primary">${this.location?.name}</span> `;
<span class="small ms-1 text-secondary">
${this.location?.country}, ${this.location?.district}, ${this.location?.subdistrict}
</span>
</span>
<span>
<span class="text-danger ms-2" data-remove>
&#x2715;
</span>
</span>
</a>`;
this.classList = "list-group-item list-group-item-action";
this.addEventListener("click", this.handleClick);
if (this.hasAttribute("removable")) {
this.querySelector<HTMLElement>("[data-remove]")?.addEventListener("click", this.handleRemoveClick);
} else {
this.querySelector("[data-remove]")?.remove();
}
}
disconnectedCallback() {
this.removeEventListener("click", this.handleClick);
this.querySelector<HTMLElement>("[data-remove]")?.removeEventListener("click", this.handleRemoveClick);
}
handleClick(event: MouseEvent) {
if (this.location) {
window.weatherLocationStorage.add(this.location);
}
}
handleRemoveClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (this.location) {
window.weatherLocationStorage.remove(this.location.id);
}
this.remove();
} }
} }
customElements.define("weather-location", WeatherLocationElement); export class WeatherLocationList extends ListElement<Location, WheatherLocationItem> {
childType = WheatherLocationItem;
public onRefresh(): void {
super.onRefresh();
if (!this.value?.length) {
this.prepend("Empty");
}
}
}
customElements.define("weather-location-item", WheatherLocationItem);
customElements.define("weather-location-list", WeatherLocationList);
+4 -22
View File
@@ -1,29 +1,11 @@
import { DataStorage } from "../base/storage"; import { DataManager } from "../base/manager";
import { WeatherLocationElement } from "./component"; import { WeatherLocationList } from "./component";
import { Location } from "./model"; import { Location } from "./model";
export class WeatherLocationManager {
loadLocations(selector: string) {
const locations = window.weatherLocationStorage.getAll();
const container = document.querySelector(selector);
if (container) {
container.innerHTML = "";
for (const location of locations) {
const element = new WeatherLocationElement();
element.setAttribute("location", JSON.stringify(location));
element.setAttribute("removable", "");
container.appendChild(element);
}
}
}
}
declare global { declare global {
interface Window { interface Window {
weatherLocationStorage: DataStorage<Location>; weatherLocationManager: DataManager<Location>;
weatherLocationManager: WeatherLocationManager;
} }
} }
window.weatherLocationStorage = new DataStorage("weather:locations"); window.weatherLocationManager = new DataManager(WeatherLocationList, "weather:locations", "#storedLocations");
window.weatherLocationManager = new WeatherLocationManager();
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -18,10 +18,10 @@ async def test_search(rp5_api: RP5WeatherApi):
async def test_day(rp5_api: RP5WeatherApi): async def test_day(rp5_api: RP5WeatherApi):
result = await rp5_api.get_day("test", datetime.date(2026, 9, 2)) result = await rp5_api.get_day("test", datetime.date(2026, 9, 11))
assert len(result.values) == 4 assert len(result.values) == 4
async def test_days(rp5_api: RP5WeatherApi): async def test_days(rp5_api: RP5WeatherApi):
result = await rp5_api.get_days("test", 10) result = await rp5_api.get_days("test", 10)
assert len(result.values) == 7 assert len(result.values) == 8
+1 -1
View File
@@ -18,7 +18,7 @@ async def test_search(yandexweather_api: YandexWeatherApi):
async def test_day(yandexweather_api: YandexWeatherApi): async def test_day(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.get_day("test", datetime.date.today()) result = await yandexweather_api.get_day("test", datetime.date(2026, 9, 1))
assert len(result.values) == 4 assert len(result.values) == 4