Compare commits

...
12 Commits
34 changed files with 199 additions and 281 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",
) )
@@ -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": [item.model_dump() for item in 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",
+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]:
@@ -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>
+11 -2
View File
@@ -28,6 +28,15 @@ def format_date(date: datetime.date) -> str:
return f"{days + 1}-day" 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"
@@ -46,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:
@@ -70,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",
+3 -3
View File
@@ -161,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"):
@@ -169,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"):
@@ -178,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:
+4 -4
View File
@@ -54,7 +54,7 @@ class RowType(Enum):
def _iter_table_rows(table: Tag) -> Iterable[tuple[RowType, Tag]]: def _iter_table_rows(table: Tag) -> Iterable[tuple[RowType, Tag]]:
for row in table.select("tr"): for row in table.select("tr"):
title = row.select_one("td").text.strip().lower() title = row.select_one("td").text.strip().lower()
if "сегодня" in title: if "сегодня" in title or "завтра" in title:
yield RowType.DATE, row yield RowType.DATE, row
elif "местное время" in title: elif "местное время" in title:
yield RowType.TIME, row yield RowType.TIME, row
@@ -133,13 +133,13 @@ def parse_page(page: BeautifulSoup) -> list[WeatherResponse]:
values[index].pressure = [int(cell.select_one(".p_0").text.strip())] values[index].pressure = [int(cell.select_one(".p_0").text.strip())]
case RowType.WIND_SPEED: case RowType.WIND_SPEED:
for index, cell in enumerate(row.select("td")[1:]): for index, cell in enumerate(row.select("td")[1:]):
values[index].wind_speed = _parse_wind_speed(cell) values[index].wind.speed = _parse_wind_speed(cell)
case RowType.WIND_GUST: case RowType.WIND_GUST:
for index, cell in enumerate(row.select("td")[1:]): for index, cell in enumerate(row.select("td")[1:]):
values[index].wind_gust = _parse_wind_speed(cell) values[index].wind.gust = _parse_wind_speed(cell)
case RowType.WIND_DIRECTION: case RowType.WIND_DIRECTION:
for index, cell in enumerate(row.select("td")[1:]): for index, cell in enumerate(row.select("td")[1:]):
values[index].wind_direction = parse_wind_direction(cell.text.strip()) values[index].wind.direction = parse_wind_direction(cell.text.strip())
case RowType.HUMIDITY: case RowType.HUMIDITY:
for index, cell in enumerate(row.select("td")[1:]): for index, cell in enumerate(row.select("td")[1:]):
values[index].humidity = int(cell.text.strip()) values[index].humidity = int(cell.text.strip())
+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
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "gallery" name = "gallery"
description = "" description = ""
version = "0.8.2" 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.8.2", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.8.2", "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.8.2", "version": "0.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
+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