6 Commits

Author SHA1 Message Date
3096f97aa7 feat(easel): add api provider 2026-06-22 15:36:07 +03:00
a91ec12d22 ci(version): 0.3.3 2026-06-22 15:33:36 +03:00
9c862863e5 fix(gismeteo): fix precipitation parser 2026-06-22 15:33:19 +03:00
fc59e52337 ci(version): 0.3.2 2026-06-20 18:59:43 +03:00
3e93a41400 fix(gismeteo): fix date-time row parsing 2026-06-20 18:59:27 +03:00
c2cd18386b refactor(easel): update api router 2026-06-16 21:18:16 +03:00
22 changed files with 5419 additions and 5335 deletions

View File

@@ -4,8 +4,7 @@ from fastapi.staticfiles import StaticFiles
from gallery.sketch.bundle import ApiBundle
from gallery.util import root_path
from .route import api, doc
from .route.view import router as view_router
from .route import api, doc, view
def build_app(api_bundle: ApiBundle) -> FastAPI:
@@ -17,6 +16,6 @@ def build_app(api_bundle: ApiBundle) -> FastAPI:
app.state.api = api_bundle
app.mount("/static", StaticFiles(directory=root_path / "static/dist"))
doc.mount(app)
api.mount(app)
app.include_router(view_router)
app.include_router(api.router)
app.include_router(view.router)
return app

View File

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

View File

@@ -1,18 +1,20 @@
import datetime
from fastapi import FastAPI
from fastapi import APIRouter
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.model import ChannelId, Schedule
router = APIRouter(prefix="/schedule")
def mount(app: FastAPI):
@app.get("/api/schedule/channels", tags=["API"])
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channels()
@app.get("/api/schedule/{channel}/{date}", tags=["API"])
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channel_schedule(ChannelId(channel), date)
@router.get("/channels")
async def get_api_schedule_channels(request: AppRequest) -> list[ChannelId]:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channels()
@router.get("/{channel}/{date}")
async def get_api_schedule_channel_schedule(request: AppRequest, channel: str, date: datetime.date) -> Schedule:
schedule_api = request.app.state.api.schedule
return await schedule_api.get_channel_schedule(ChannelId(channel), date)

View File

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

View File

@@ -5,7 +5,7 @@ from .schedule import router as schedule_router
from .translation import set_language
from .weather import router as weather_router
router = APIRouter(dependencies=[Depends(set_language)])
router = APIRouter(tags=["view"], dependencies=[Depends(set_language)])
router.include_router(common_router)
router.include_router(weather_router)
router.include_router(schedule_router)

View File

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

View File

@@ -1,16 +1,32 @@
import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.schedule.catalog import BUNDLE
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import timedelta_format
async def get_schedule_api(request: AppRequest, provider: str | None = None) -> ScheduleApi:
return request.app.state.api.get_schedule(provider)
ScheduleApiDepends = Annotated[ScheduleApi, Depends(get_schedule_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(ScheduleApi.TYPE),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
@@ -18,12 +34,11 @@ templates = build_templates(
},
)
router = APIRouter()
router = APIRouter(prefix="/schedule")
@router.get("/schedule", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest):
schedule_api = request.app.state.api.schedule
@router.get("/", response_class=HTMLResponse)
async def get_schedule_list(request: AppRequest, schedule_api: ScheduleApiDepends):
channels = await schedule_api.get_channels()
channels_data = BUNDLE.select_items(channels)
return templates.TemplateResponse(
@@ -35,10 +50,9 @@ async def get_schedule_list(request: AppRequest):
)
@router.get("/schedule/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
@router.get("/tag/{tag}", response_class=HTMLResponse)
async def get_schedule_tag(request: AppRequest, schedule_api: ScheduleApiDepends, tag: str, live: bool = False):
tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
results = await schedule_api.get_all_schedules(tag_value.date)
return templates.TemplateResponse(
request=request,
@@ -53,15 +67,14 @@ async def get_schedule_tag(request: AppRequest, tag: str, live: bool = False):
)
@router.get("/schedule/{channel}", response_class=RedirectResponse)
@router.get("/{channel}", response_class=RedirectResponse)
async def get_channel_default(channel: str):
return RedirectResponse(f"{channel}/tag/today")
@router.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, channel: str, tag: str):
@router.get("/{channel}/tag/{tag}", response_class=HTMLResponse)
async def get_channel_tag(request: AppRequest, schedule_api: ScheduleApiDepends, channel: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
schedule_api = request.app.state.api.schedule
if tag_value.type == TagType.DAY:
response = await schedule_api.get_channel_schedule(channel, tag_value.date)
else:

View File

@@ -4,12 +4,12 @@
{% block content %}
<h1>{{_("TV program")}}</h1>
<div class="list-group mb-5">
<a href="schedule/tag/today"
<a href="tag/today"
class="list-group-item list-group-item-action px-4">
<span class="fw-bold">Все</span>
</a>
{% for channel in channels %}
<a href="schedule/{{channel.id}}"
<a href="{{channel.id}}"
class="list-group-item list-group-item-action px-4">
<span class="text-primary">{{channel.name}}</span>
</a>

View File

@@ -1,22 +1,39 @@
import datetime
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from gallery.easel.core import AppRequest
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import WeatherResponse
from ..common.utils.tag import TagType, TagUtil
from ..common.utils.template import build_templates
from .filters import cloudness_icon, wind_direction_icon
async def get_weather_api(request: AppRequest, provider: str | None = None) -> WeatherApi:
return request.app.state.api.get_weather(provider)
WeatherApiDepends = Annotated[WeatherApi, Depends(get_weather_api)]
def context_procesor(request: AppRequest) -> dict:
return {
"providers": request.app.state.api.get_api_providers(WeatherApi.TYPE),
}
templates = build_templates(
Path(__file__).parent / "templates",
{
"wind_direction_icon": wind_direction_icon,
"cloudness_icon": cloudness_icon,
},
context_procesor,
)
@@ -30,12 +47,11 @@ def build_weather_response(request: AppRequest, response: WeatherResponse):
)
router = APIRouter()
router = APIRouter(prefix="/weather")
@router.get("/weather", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, query: str | None = None):
weather_api = request.app.state.api.weather
@router.get("/", response_class=HTMLResponse)
async def get_weather_index(request: AppRequest, weather_api: WeatherApiDepends, query: str | None = None):
locations = (await weather_api.find_locations(query)) if query else []
return templates.TemplateResponse(
request=request,
@@ -46,29 +62,31 @@ async def get_weather_index(request: AppRequest, query: str | None = None):
)
@router.get("/weather/{location}", response_class=RedirectResponse)
@router.get("/{location}", response_class=RedirectResponse)
async def get_weather_default(location: str):
return RedirectResponse(f"{location}/tag/today")
@router.get("/weather/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(request: AppRequest, location: str, date: datetime.date):
weather_api = request.app.state.api.weather
@router.get("/{location}/day/{date}", response_class=HTMLResponse)
async def get_weather_day(
request: AppRequest,
weather_api: WeatherApiDepends,
location: str,
date: datetime.date,
):
response = await weather_api.get_day(location, date)
return build_weather_response(request, response)
@router.get("/weather/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, location: str, days: int):
weather_api = request.app.state.api.weather
@router.get("/{location}/days/{days}", response_class=HTMLResponse)
async def get_weather_days(request: AppRequest, weather_api: WeatherApiDepends, location: str, days: int):
response = await weather_api.get_days(location, days)
return build_weather_response(request, response)
@router.get("/weather/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, location: str, tag: str):
@router.get("/{location}/tag/{tag}", response_class=HTMLResponse)
async def get_weather_tag(request: AppRequest, weather_api: WeatherApiDepends, location: str, tag: str):
tag_value = TagUtil.parse_tag(tag)
weather_api = request.app.state.api.weather
if tag_value.type == TagType.DAY:
response = await weather_api.get_day(location, tag_value.date)
elif tag_value.type == TagType.DAYS:

View File

@@ -1,6 +1,23 @@
{% extends "base.html" %}
{% block title %}{{_("Weather")}}{% endblock %}
{# {% block header %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false">
{{provider or providers[0]}}
</button>
<ul class="dropdown-menu">
{% for provider in providers %}
<li><a class="dropdown-item"
href="{{ url_for('get_weather_index').include_query_params(provider=provider) }}">{{provider}}</a></li>
{% endfor %}
</ul>
</div>
{% endblock %} #}
{% block content %}
<h1>{{_("Weather")}}</h1>
<form action=""
@@ -19,7 +36,7 @@
<ul id="locations"
class="list-group mb-5">
{% for location in locations %}
<a href="weather/{{location.id}}"
<a href="{{location.id}}"
class="list-group-item list-group-item-action px-4"
onclick="saveLocation({id:'{{location.id}}', name:'{{location.name}}'});">
<span class="fi fi-{{location.country_code}} me-1"></span>
@@ -39,7 +56,7 @@
container.innerHTML = '';
for (const [id, name] of Object.entries(locations)) {
const element = document.createElement('a');
element.href = `weather/${id}`;
element.href = `${id}`;
element.className = 'list-group-item list-group-item-action px-4 d-flex justify-content-between align-items-start';
element.innerHTML = `
<span class="text-primary me-auto">${name}</span>

View File

@@ -39,14 +39,11 @@ class DateParser(RowParser[datetime.datetime]):
KEY = "date"
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
datetime_date_tag = tag.select_one(".widget-row.widget-row-datetime-date > .row-item")
if datetime_date_tag:
date_str = datetime_date_tag.find(text=True, recursive=False).text
date = dateparser.parse(date_str, languages=["ru"])
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
time_str = item.text
time = dateparser.parse(time_str, languages=["ru"])
time = time.replace(year=date.year, month=date.month, day=date.day)
datetime_time_row = tag.select_one(".widget-row.widget-row-datetime-time")
if datetime_time_row:
for item in datetime_time_row.select(".row-item > time-value"):
timestamp = int(item.attrs["timestamp"])
time = datetime.datetime.fromtimestamp(timestamp)
yield time
else:
for item in tag.select(".widget-row.widget-row-date > .row-item"):
@@ -174,7 +171,7 @@ class PrecipitationParser(RowParser[float]):
def parse_row(self, tag: Tag) -> Iterable[float]:
for item in tag.select(".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"):
yield float(item.text.replace(",", "."))
yield float(item.text.replace(",", ".").replace("< ", ""))
class PressureParser(RowParser[list[int]]):

View File

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

View File

@@ -3,26 +3,27 @@ from .schedule.api import ScheduleApi
from .weather.api import WeatherApi
class ApiBundle(list[Api]):
def __init__(self, values: list[Api]) -> None:
super().__init__(values)
class ApiBundle:
def __init__(self, values: list[Api]):
self._values = values
self._by_provider = {value.provider: value for value in values}
def get_api_by_provider(self, provider: str) -> Api:
for value in self:
if value.PROVIDER == provider:
return value
raise ValueError(provider)
def get_api_providers(self, api_type: str) -> list[str]:
result = []
for value in self._values:
if value.type == api_type:
result.append(value.provider)
return result
def get_api_by_type(self, api_type: type[API]) -> API:
for value in self:
def get_api(self, api_type: type[API], provider: str | None = None) -> API:
for value in self._values:
if isinstance(value, api_type):
return value
if provider is None or provider == value.provider:
return value
raise ValueError(api_type)
@property
def weather(self) -> WeatherApi:
return self.get_api_by_type(WeatherApi)
def get_weather(self, provider: str | None = None) -> WeatherApi:
return self.get_api(WeatherApi, provider)
@property
def schedule(self) -> ScheduleApi:
return self.get_api_by_type(ScheduleApi)
def get_schedule(self, provider: str | None = None) -> ScheduleApi:
return self.get_api(ScheduleApi, provider)

View File

@@ -6,6 +6,7 @@ from .model import ChannelId, Schedule
class ScheduleApi(Api):
TYPE = "schedule"
INTERVAL: float = 0.5
async def get_channels(self) -> list[ChannelId]:

View File

@@ -12,7 +12,7 @@ CACHE_PRESET = CachePreset(ttl=TimeUnit.HOUR * 6)
class CachedScheduleApi(ScheduleApi, CachedApi[ScheduleApi]):
CACHE_KEY = "schedule"
CACHE_KEY = ScheduleApi.TYPE
@cached(
key_builder=lambda fun, self: f"api.{self.CACHE_KEY}.{self.provider}.channels",

View File

@@ -5,6 +5,7 @@ from .model import Location, WeatherResponse
class WeatherApi(Api):
TYPE = "weather"
async def find_locations(self, query: str) -> list[Location]:
raise NotImplementedError

View File

@@ -11,7 +11,7 @@ CACHE_PRESET = DEFAULT_CACHE_PRESET
class CachedWeatherApi(WeatherApi, CachedApi[WeatherApi]):
CACHE_KEY = "weather"
CACHE_KEY = WeatherApi.TYPE
@cached(
key_builder=lambda fun, self, query: f"api.{self.CACHE_KEY}.{self.provider}.locations.{query}",

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "gallery"
version = "0.3.1"
version = "0.3.3"
description = ""
authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md"

View File

@@ -1,12 +1,12 @@
{
"name": "gallery",
"version": "0.3.1",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gallery",
"version": "0.3.1",
"version": "0.3.3",
"license": "ISC",
"dependencies": {
"@popperjs/core": "^2.11.8",

View File

@@ -1,6 +1,6 @@
{
"name": "gallery",
"version": "0.3.1",
"version": "0.3.3",
"scripts": {
"build": "vite build",
"dev": "vite build --watch"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long