feat(schedule): add matchtv schedule

This commit is contained in:
2024-08-12 01:19:14 +03:00
parent 6db33ca669
commit bf51ddabc0
17 changed files with 5721 additions and 8 deletions

View File

@@ -1,23 +1,31 @@
import locale import locale as _locale
from fastapi import FastAPI from fastapi import FastAPI
from gallery.sketch.schedule.api import ScheduleApi
from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.api import WeatherApi
from .route import doc from .route import doc
from .route.api import schedule as schedule_api_route
from .route.api import weather as weather_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 from .route.view import weather as weather_view_route
def build_app(weather_api: WeatherApi) -> FastAPI: def build_app(
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8") weather_api: WeatherApi, schedule_api: ScheduleApi, *, locale: str = "ru_RU.UTF-8"
) -> FastAPI:
_locale.setlocale(_locale.LC_TIME, locale)
app = FastAPI( app = FastAPI(
title="Gallery", title="Gallery",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
app.state.weather_api = weather_api app.state.weather_api = weather_api
app.state.schedule_api = schedule_api
doc.mount(app) doc.mount(app)
weather_api_route.mount(app) weather_api_route.mount(app)
schedule_api_route.mount(app)
weather_view_route.mount(app) weather_view_route.mount(app)
schedule_view_route.mount(app)
return app return app

View File

@@ -0,0 +1,5 @@
from fastapi import FastAPI
def mount(app: FastAPI):
pass

View File

@@ -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,
},
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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;
}

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>ТВ</title>
<link rel="stylesheet"
href="/schedule/static/style.css?v=1">
<link rel="icon"
href="/schedule/static/favicon.ico"
type="image/x-icon">
</head>
<body class="app-container">
<ul>
{% for channel in channels %}
<li><a href="schedule/{{channel}}">{{channel}}</a></li>
{% endfor %}
</ul>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible"
content="ie=edge">
<title>Программа | {{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/schedule/static/style.css?v=1">
<link rel="icon"
href="/schedule/static/favicon.ico"
type="image/x-icon">
</head>
<body class="app-container">
<h3>
{{response.channel.name}} | {{response.date.strftime('%a, %d %B %Y')}}
</h3>
<table>
<thead>
<tr>
<td>TIME</td>
<td>NAME</td>
</tr>
</thead>
<tbody>
{% for value in response.values %}
<tr>
<td>{{value.start.strftime('%H:%M')}}</td>
<td>{{value.label}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>

View File

@@ -171,7 +171,6 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<script src="/static/index.js"></script>
</body> </body>
</html> </html>

View File

@@ -5,8 +5,9 @@ import uvicorn
from gallery.easel import build_app from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi
app = build_app(GismeteoApi()) app = build_app(GismeteoApi(), MatchTvApi())
def run(): def run():

View File

View File

@@ -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
)

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -13,14 +13,14 @@ class Channel(Model):
name: str name: str
class ScheduleItem(Model): class ScheduleValue(Model):
start: datetime.datetime start: datetime.datetime
end: datetime.datetime end: datetime.datetime
label: str label: str
category: str | None category: str | None = None
class Schedule(Model): class Schedule(Model):
channel: Channel channel: Channel
date: datetime.date date: datetime.date
items: list[ScheduleItem] values: list[ScheduleValue]

24
tests/test_matchtv_api.py Normal file
View File

@@ -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)))