diff --git a/gallery/easel/__init__.py b/gallery/easel/__init__.py index 5348817..79bc2e3 100644 --- a/gallery/easel/__init__.py +++ b/gallery/easel/__init__.py @@ -1,23 +1,31 @@ -import locale +import locale as _locale from fastapi import FastAPI +from gallery.sketch.schedule.api import ScheduleApi from gallery.sketch.weather.api import WeatherApi from .route import doc +from .route.api import schedule as schedule_api_route from .route.api import weather as weather_api_route +from .route.view import schedule as schedule_view_route from .route.view import weather as weather_view_route -def build_app(weather_api: WeatherApi) -> FastAPI: - locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") +def build_app( + weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8" +) -> FastAPI: + _locale.setlocale(_locale.LC_TIME, locale) app = FastAPI( title="Gallery", docs_url=None, redoc_url=None, ) app.state.weather_api = weather_api + app.state.schedule_api = schedule_api doc.mount(app) weather_api_route.mount(app) + schedule_api_route.mount(app) weather_view_route.mount(app) + schedule_view_route.mount(app) return app diff --git a/gallery/easel/route/api/schedule.py b/gallery/easel/route/api/schedule.py new file mode 100644 index 0000000..e515adf --- /dev/null +++ b/gallery/easel/route/api/schedule.py @@ -0,0 +1,5 @@ +from fastapi import FastAPI + + +def mount(app: FastAPI): + pass diff --git a/gallery/easel/route/view/schedule/__init__.py b/gallery/easel/route/view/schedule/__init__.py new file mode 100644 index 0000000..9d23d11 --- /dev/null +++ b/gallery/easel/route/view/schedule/__init__.py @@ -0,0 +1,52 @@ +import datetime +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from gallery.easel.route.view.weather.util import TagType, TagUtil +from gallery.sketch.schedule.api import ScheduleApi + + +def mount(app: FastAPI): + base_dir = Path(__file__).parent + app.mount( + "/schedule/static", StaticFiles(directory=base_dir / "static"), name="static" + ) + templates = Jinja2Templates(directory=base_dir / "templates") + + @app.get("/schedule", response_class=HTMLResponse) + async def get_schedule_list(request: Request): + schedule_api: ScheduleApi = request.app.state.schedule_api + channels = await schedule_api.get_channels() + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "channels": channels, + }, + ) + + @app.get("/schedule/{channel}", response_class=RedirectResponse) + async def get_schedule_default(channel: str): + return RedirectResponse(f"{channel}/tag/today") + + @app.get("/schedule/{channel}/tag/{tag}", response_class=HTMLResponse) + async def get_schedule_tag(request: Request, channel: str, tag: str): + tag_value = TagUtil.parse_tag(tag) + schedule_api: ScheduleApi = request.app.state.schedule_api + if tag_value.type == TagType.DAY: + response = await schedule_api.get_channel_schedule(channel, tag_value.date) + else: + raise ValueError(tag) + return templates.TemplateResponse( + request=request, + name="schedule.html", + context={ + "tag_util": TagUtil, + "datetime": datetime, + "response": response, + }, + ) diff --git a/gallery/easel/route/view/schedule/static/favicon.ico b/gallery/easel/route/view/schedule/static/favicon.ico new file mode 100644 index 0000000..a283ecb Binary files /dev/null and b/gallery/easel/route/view/schedule/static/favicon.ico differ diff --git a/gallery/easel/route/view/weather/static/index.js b/gallery/easel/route/view/schedule/static/index.js similarity index 100% rename from gallery/easel/route/view/weather/static/index.js rename to gallery/easel/route/view/schedule/static/index.js diff --git a/gallery/easel/route/view/schedule/static/style.css b/gallery/easel/route/view/schedule/static/style.css new file mode 100644 index 0000000..e435974 --- /dev/null +++ b/gallery/easel/route/view/schedule/static/style.css @@ -0,0 +1,21 @@ +body { + font-size: 1.5rem; +} + +.app-container { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + align-items: center; +} + +table, +th, +td { + /* border: 1px solid rgba(0, 0, 0, 0.2); */ + text-align: left; +} + +td { + padding: 0.1rem 0.4rem; +} diff --git a/gallery/easel/route/view/schedule/templates/index.html b/gallery/easel/route/view/schedule/templates/index.html new file mode 100644 index 0000000..e6acc56 --- /dev/null +++ b/gallery/easel/route/view/schedule/templates/index.html @@ -0,0 +1,26 @@ + + + + + + + + ТВ + + + + + + + + + \ No newline at end of file diff --git a/gallery/easel/route/view/schedule/templates/schedule.html b/gallery/easel/route/view/schedule/templates/schedule.html new file mode 100644 index 0000000..491ddfb --- /dev/null +++ b/gallery/easel/route/view/schedule/templates/schedule.html @@ -0,0 +1,41 @@ + + + + + + + + Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}} + + + + + +

+ {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}} +

+ + + + + + + + + + {% for value in response.values %} + + + + + {% endfor %} + +
TIMENAME
{{value.start.strftime('%H:%M')}}{{value.label}}
+ + + \ No newline at end of file diff --git a/gallery/easel/route/view/weather/templates/weather.html b/gallery/easel/route/view/weather/templates/weather.html index 9da5908..d523b72 100644 --- a/gallery/easel/route/view/weather/templates/weather.html +++ b/gallery/easel/route/view/weather/templates/weather.html @@ -171,7 +171,6 @@ - \ No newline at end of file diff --git a/gallery/main.py b/gallery/main.py index c745037..fad5ec1 100644 --- a/gallery/main.py +++ b/gallery/main.py @@ -5,8 +5,9 @@ import uvicorn from gallery.easel import build_app from gallery.painting.gismeteo.api import GismeteoApi +from gallery.painting.matchtv.api import MatchTvApi -app = build_app(GismeteoApi()) +app = build_app(GismeteoApi(), MatchTvApi()) def run(): diff --git a/gallery/painting/matchtv/__init__.py b/gallery/painting/matchtv/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/painting/matchtv/api.py b/gallery/painting/matchtv/api.py new file mode 100644 index 0000000..12e9fd4 --- /dev/null +++ b/gallery/painting/matchtv/api.py @@ -0,0 +1,72 @@ +import datetime +import logging + +import aiohttp +from bs4 import BeautifulSoup + +from gallery.sketch.schedule.api import ScheduleApi +from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue + +logger = logging.getLogger("matchtv") + + +CHANNEL_LIST = [ + "matchtv", + "igra", + "arena", + "futbol-1", + "futbol-2", + "futbol-2", + "strana", + "planeta", +] + + +class MatchTvApi(ScheduleApi): + BASE_URL = "https://matchtv.ru" + + USER_AGENT = ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/126.0.0.0 Safari/537.36" + ) + + async def _request(self, endpoint: str) -> str: + url = f"{self.BASE_URL}/{endpoint}" + print(url) + logger.info(url) + async with aiohttp.ClientSession( + headers={ + "User-Agent": self.USER_AGENT, + }, + raise_for_status=True, + ) as session: + async with session.request("GET", url) as response: + return await response.text() + + async def get_channels(self) -> list[str]: + return CHANNEL_LIST + + async def get_channel_schedule( + self, channel_id: str, date: datetime.date + ) -> Schedule: + endpoint = f"channel/{channel_id}/tvguide?date={date:%d-%m-%Y}" + data = await self._request(endpoint) + soup = BeautifulSoup(data, features="html.parser") + values = [] + channel_name = soup.select_one(".caption__heading").text.split("|")[0].strip() + current_date = datetime.datetime.combine( + date.today(), datetime.datetime.min.time() + ) + for item in soup.select(".teleprogram-schedule .teleprogram-schedule__item"): + title = item.select_one(".teleprogram-item__title").text.strip() + time_str = item.select_one(".teleprogram-item__time").text.strip() + hours, minutes = map(int, time_str.split(":")) + item_date = datetime.datetime.combine( + date, datetime.time(hour=hours, minute=minutes) + ) + values.append(ScheduleValue(start=current_date, end=item_date, label=title)) + current_date = item_date + return Schedule( + channel=Channel(id=channel_id, name=channel_name), date=date, values=values + ) diff --git a/gallery/painting/matchtv/mock/__init__.py b/gallery/painting/matchtv/mock/__init__.py new file mode 100644 index 0000000..de6a565 --- /dev/null +++ b/gallery/painting/matchtv/mock/__init__.py @@ -0,0 +1,5 @@ +from pathlib import Path + +from gallery.sketch.mock import MockData + +MATCHTV_MOCK_DATA = MockData(Path(__file__).parent / "data") diff --git a/gallery/painting/matchtv/mock/data/matchtv.html b/gallery/painting/matchtv/mock/data/matchtv.html new file mode 100644 index 0000000..4c82b42 --- /dev/null +++ b/gallery/painting/matchtv/mock/data/matchtv.html @@ -0,0 +1,5446 @@ + + + + + + + + + + + + + + Матч ТВ: программа передач на сегодня, завтра + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + Перейти к основному контенту + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
+
+ + + +
+ +
+ + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+ + + + + +
+
+ +
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + + + +
+
+
+
+ + + + + + + + + + + + + +
+
+
+ Иллюстрация канала «Матч ТВ» +
+
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+
+ +
+
+
+ + + +
+
+ +
+
Прямой эфир
+
+ + + +
+
+ +
+
+ +
+ +
+
+ + + + + + + + +
+
+ + +
+
+ + + + + +
+
    +
  • +
    +
    + 00:00 Бокс. Bare Knuckle FC. Коннор Тирни против Джонни Грэма. Трансляция из Великобритании 16+. +
    + + + + + + + + + +
    +
  • +
+
+ +
+
+ + + + + + + + + + + + +
+
+
+
+

«Матч ТВ» — российский федеральный общедоступный канал о спорте и активном образе жизни.

Матч ТВ — это Третья кнопка вашего телевизора!

В нашем эфире — новости, аналитика и развлекательные программы, документальные циклы и специальные репортажи, реалити- и ток-шоу, художественные фильмы и сериалы: всё о российском и мировом спорте!

Канал объединяет лучших спортивных комментаторов и ведущих. Это самая опытная и профессиональная команда на российском ТВ — вместе с нашими зрителями и болельщиками мы прошли все важнейшие турниры и первенства последних лет.

Мы зовем всех присоединиться к сообществу людей, которые по-настоящему любят спорт и живут им: у экранов ТВ, на стадионах или в спортзалах.

#всенаматч!

+
+
+
+
+ + + + + + + + + + + + +
+
+ +
+
+ + + +
+
+ + + + + +
+
+ +
+
+ +
+ + + + + + + + + + +
+
+ + + + +
+
    +
  • + + + + +
    +
    +
    +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    + + + + +
    + +
    +
    +
    + + + + +
    +
    + +
  • +
+
+ +
+
+ + + + + + + + + + + + +
+
+
+
+ +
+
    +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Звёзды без правил" 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Всё о главном" 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Новости. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Все на Матч! Прямой эфир. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Джулур. Мас-рестлинг". Художественный фильм. Россия, 2021 г. 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Специальный репортаж 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Новости. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Есть тема!" Прямой эфир. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Век нашего спорта". Документальный цикл 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Новости. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Громко" Прямой эфир. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Дартс. OLIMPBET Летняя серия. Прямая трансляция из Москвы. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Футбол. МИР Российская Премьер-Лига. Обзор тура 6+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Футбол. МЕЛБЕТ-Первая Лига. "КАМАЗ" (Набережные Челны) - "Торпедо" (Москва). Прямая трансляция. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Все на Матч! Прямой эфир. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Век нашего спорта". Документальный цикл 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Смешанные единоборства. UFC. Марчин Тыбура против Сергея Спивака. Трансляция из США 16+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Баскетбол 3х3. Международный студенческий кубок. Трансляция из Красноярска 6+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Новости 0+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Пляжный футбол. OLIMPBET Чемпионат России. "Лекс" (Санкт-Петербург) - "Строгино" (Москва). Трансляция из Москвы 0+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + Пляжный футбол. OLIMPBET Чемпионат России. "Краснодар-ЮМР" - "Саратов". Трансляция из Москвы 0+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
  • + + + +
    +
    + +
    + Логотип телеканала «Матч ТВ» + "Звёзды без правил" 12+. +
    +
    +
      +
    +
    +
    + + + + + + + + + + +
    + +
  • +
+
+ +
+ + + + + + + + + + + + + +
+
+
+ + +
+ + +
+ + + + + + + + + + + +
+
+
+ + + +
+ +
+ + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + diff --git a/gallery/sketch/schedule/api.py b/gallery/sketch/schedule/api.py new file mode 100644 index 0000000..2a17dd2 --- /dev/null +++ b/gallery/sketch/schedule/api.py @@ -0,0 +1,13 @@ +import datetime + +from .model import Schedule + + +class ScheduleApi: + async def get_channels(self) -> list[str]: + raise NotImplementedError + + async def get_channel_schedule( + self, channel_id: str, date: datetime.date + ) -> Schedule: + raise NotImplementedError diff --git a/gallery/sketch/schedule/model.py b/gallery/sketch/schedule/model.py index 4ac4306..1c554ae 100644 --- a/gallery/sketch/schedule/model.py +++ b/gallery/sketch/schedule/model.py @@ -13,14 +13,14 @@ class Channel(Model): name: str -class ScheduleItem(Model): +class ScheduleValue(Model): start: datetime.datetime end: datetime.datetime label: str - category: str | None + category: str | None = None class Schedule(Model): channel: Channel date: datetime.date - items: list[ScheduleItem] + values: list[ScheduleValue] diff --git a/tests/test_matchtv_api.py b/tests/test_matchtv_api.py new file mode 100644 index 0000000..c66388f --- /dev/null +++ b/tests/test_matchtv_api.py @@ -0,0 +1,24 @@ +import datetime + +import pytest + +from gallery.painting.matchtv.api import MatchTvApi +from gallery.painting.matchtv.mock import MATCHTV_MOCK_DATA + + +@pytest.fixture(name="matchtv_api", scope="module") +def matchtv_api_fixture() -> MatchTvApi: + api = MatchTvApi() + + async def _request(endpoint: str) -> str: + return MATCHTV_MOCK_DATA.get_html(endpoint.split("/")[1]) + + api._request = _request + return api + + +async def test_channel(matchtv_api: MatchTvApi): + result = await matchtv_api.get_channel_schedule("matchtv", datetime.date.today()) + assert result is not None + assert len(result.items) > 0 + print(">>", "\n".join(map(str, result.items)))