feat(app): add html weather view

This commit is contained in:
2024-07-25 15:51:27 +03:00
parent 234a2b7b0e
commit b3d88997eb
22 changed files with 393 additions and 34 deletions

View File

@@ -3,9 +3,10 @@ from typing import Any, Dict, List, NamedTuple
import aiohttp import aiohttp
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from . import dateutil
from .location import LOCATION_BUNDLE from .location import LOCATION_BUNDLE
from .parser import ROW_PARSERS, OneDayParser from .parser import ROW_PARSERS, ONE_DAY_PARSER
class WeatherValue(NamedTuple): class WeatherValue(NamedTuple):
@@ -32,7 +33,7 @@ class GismeteoApi:
def _parse_oneday(self, data: str) -> List[WeatherValue]: def _parse_oneday(self, data: str) -> List[WeatherValue]:
result: List[Dict[str, Any]] = [] result: List[Dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
widget = OneDayParser().parse_widget(soup) widget = ONE_DAY_PARSER.parse_widget(soup)
for parser in ROW_PARSERS: for parser in ROW_PARSERS:
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:
@@ -40,12 +41,7 @@ class GismeteoApi:
result[index][parser.KEY] = value result[index][parser.KEY] = value
return [WeatherValue(**item) for item in result] return [WeatherValue(**item) for item in result]
async def today(self, location_id: str) -> List[WeatherValue]: async def get_day(self, location_id: str, date: datetime.date) -> List[WeatherValue]:
location = LOCATION_BUNDLE.parse(location_id) location = LOCATION_BUNDLE.parse(location_id)
data = await self._request(f"weather-{location}/today") data = await self._request(f"weather-{location}/{dateutil.dump(date)}")
return self._parse_oneday(data)
async def tomorrow(self, location_id: str) -> List[WeatherValue]:
location = LOCATION_BUNDLE.parse(location_id)
data = await self._request(f"weather-{location}/tomorrow")
return self._parse_oneday(data) return self._parse_oneday(data)

View File

@@ -1,26 +1,17 @@
import locale
from os import environ from os import environ
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from gismeteo import custom_doc from gismeteo.route import api, doc, view
from gismeteo.api import GismeteoApi
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
app = FastAPI(docs_url=None, redoc_url=None) app = FastAPI(docs_url=None, redoc_url=None)
custom_doc.apply(app) doc.mount(app)
api.mount(app)
view.mount(app)
@app.get("/")
async def root():
return {}
@app.get("/weather/{location_id}")
async def get_weather(location_id: str):
api = GismeteoApi()
result = await api.today(location_id)
return [item._asdict() for item in result]
def run(): def run():

30
gismeteo/dateutil.py Normal file
View File

@@ -0,0 +1,30 @@
import datetime
import dateparser
import dateparser.date_parser
def parse(value: str) -> datetime.date:
if value == "today" or value == "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"

25
gismeteo/mock/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
import json
from pathlib import Path
from typing import List
import dateparser
from gismeteo.api import WeatherValue
class MockData:
@property
def html(self) -> str:
return (Path(__file__).parent / "data/weather.html").read_text()
@property
def values(self) -> List[WeatherValue]:
data = json.loads((Path(__file__).parent / "data/weather.json").read_text())
return [
WeatherValue(**{**item, "date": dateparser.parse(item["date"])})
for item in data
]
MOCK_DATA = MockData()

View File

@@ -0,0 +1 @@
[{"date":"2024-07-25T00:00:00","cloudness":"Ясно","temperature":14,"wind_speed":1,"wind_gust":1,"wind_direction":"С","precipitation":0.0,"pressure":739,"humidity":85},{"date":"2024-07-25T03:00:00","cloudness":"Ясно","temperature":13,"wind_speed":1,"wind_gust":2,"wind_direction":"СЗ","precipitation":0.0,"pressure":739,"humidity":92},{"date":"2024-07-25T06:00:00","cloudness":"Малооблачно, без осадков","temperature":14,"wind_speed":1,"wind_gust":2,"wind_direction":"СЗ","precipitation":0.0,"pressure":738,"humidity":89},{"date":"2024-07-25T09:00:00","cloudness":"Малооблачно, без осадков","temperature":23,"wind_speed":3,"wind_gust":5,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":58},{"date":"2024-07-25T12:00:00","cloudness":"Малооблачно, без осадков","temperature":26,"wind_speed":3,"wind_gust":6,"wind_direction":"СЗ","precipitation":0.0,"pressure":738,"humidity":47},{"date":"2024-07-25T15:00:00","cloudness":"Облачно, без осадков","temperature":26,"wind_speed":2,"wind_gust":6,"wind_direction":"С","precipitation":0.0,"pressure":737,"humidity":46},{"date":"2024-07-25T18:00:00","cloudness":"Малооблачно, небольшой дождь","temperature":24,"wind_speed":3,"wind_gust":7,"wind_direction":"СВ","precipitation":0.3,"pressure":737,"humidity":54},{"date":"2024-07-25T21:00:00","cloudness":"Ясно","temperature":19,"wind_speed":1,"wind_gust":5,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":80}]

View File

@@ -6,9 +6,7 @@ from bs4 import Tag
from .core import BaseWidgetParser, RowParser from .core import BaseWidgetParser, RowParser
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
class OneDayParser(BaseWidgetParser):
SELECT = ".widget.widget-oneday .widget-items"
class DateParser(RowParser[datetime.datetime]): class DateParser(RowParser[datetime.datetime]):

View File

12
gismeteo/route/api.py Normal file
View File

@@ -0,0 +1,12 @@
from fastapi import FastAPI
from gismeteo import dateutil
from gismeteo.api import GismeteoApi
def mount(app: FastAPI):
@app.get("/api/weather/{location}/{date}")
async def get_weather(location: str, date: str):
api = GismeteoApi()
result = await api.get_day(location, dateutil.parse(date))
return [item._asdict() for item in result]

View File

@@ -8,9 +8,10 @@ from fastapi.openapi.docs import (
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
def apply(app: FastAPI): def mount(app: FastAPI):
app.mount( app.mount(
"/docs/static", StaticFiles(directory=Path(__file__).parent.parent / "static/docs") "/docs/static",
StaticFiles(directory=Path(__file__).parent / "static"),
) )
@app.get("/docs", include_in_schema=False) @app.get("/docs", include_in_schema=False)

View File

@@ -0,0 +1,43 @@
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 gismeteo import dateutil
from gismeteo.api import GismeteoApi
from gismeteo.mock import MOCK_DATA
from .filters import cloudness_icon, wind_direction_icon
def mount(app: FastAPI):
base_dir = Path(__file__).parent
app.mount("/static", StaticFiles(directory=base_dir / "static"), name="static")
templates = Jinja2Templates(directory=base_dir / "templates")
templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon
@app.get("/weather/{location}")
async def get_weather_base(location: str):
return RedirectResponse(f"{location}/today")
@app.get("/weather/{location}/{date}", response_class=HTMLResponse)
async def get_weather(request: Request, location: str, date: str):
if date == "mock":
values = MOCK_DATA.values
else:
api = GismeteoApi()
values = await api.get_day(location, dateutil.parse(date))
return templates.TemplateResponse(
request=request,
name="weather.html",
context={
"datetime": datetime,
"location": location,
"date": dateutil.parse(date),
"values": values,
},
)

View File

@@ -0,0 +1,27 @@
def wind_direction_icon(wind_direction: str) -> str:
return {
"С": "🡫",
"СВ": "🡯",
"В": "🡨",
"ЮВ": "🡬",
"Ю": "🡡",
"ЮЗ": "🡭",
"З": "🡪",
"СЗ": "🡦",
"штиль": "",
}.get(wind_direction, wind_direction)
def cloudness_icon(cloudness: str) -> str:
return {
"Ясно": "☀️",
"Малооблачно, без осадков": "🌤️",
"Облачно, без осадков": "",
"Малооблачно, небольшой дождь": "🌦️",
"Пасмурно, без осадков": "☁️",
"Облачно, небольшой дождь": "🌧️",
"Облачно, дождь": "🌧️",
"Облачно, небольшой дождь, гроза": "⛈️",
"Малооблачно, дождь": "🌦️",
"Пасмурно, небольшой дождь": "🌧️",
}.get(cloudness, cloudness)

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -0,0 +1,81 @@
body {
font-size: 1.5rem;
}
h3 {
margin: 0.5rem 0;
}
a.button {
text-decoration: none;
color: inherit;
}
.button.disabled {
pointer-events: none;
cursor: default;
color: gray;
}
table {
/* width: 100%; */
table-layout: fixed;
border-collapse: collapse;
}
table,
th,
td {
/* border: 1px solid rgba(0, 0, 0, 0.2); */
text-align: center;
}
td {
padding: 0.1rem 0.4rem;
}
.header {
font-size: 1rem;
text-align: left;
}
.date {
font-size: 1.5rem;
background: rgba(0, 0, 0, 0.1);
}
.date.now {
background: rgba(0, 128, 255, 0.2);
}
.cloudness .icon {
font-size: 2rem;
}
.temperature.positive .value {
color: orangered;
}
.temperature.negative .value {
color: blue;
}
.wind .direction {
font-size: 1rem;
}
.wind .gust {
font-size: 1rem;
}
.precipitation .value {
color: blue;
}
.pressure .value {
color: blueviolet;
}
.humidity .value {
color: blue;
}

View File

@@ -0,0 +1,151 @@
<!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>Weather - {{date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet"
href="/static/style.css">
<link rel="icon"
href="/static/favicon.ico"
type="image/x-icon">
</head>
<body>
<h3>
<a class="button {{'disabled' if date == datetime.date.today() else ''}}"
href="{{date - datetime.timedelta(days=1)}}">🡨</a>
<span>{{date.strftime('%a, %d %B %Y')}}</span>
<a class="button"
href="{{date + datetime.timedelta(days=1)}}">🡪</a>
</h3>
<table>
<tbody>
<!-- date -->
<tr>
{% for value in values %}
<td
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}">
<span class="value">{{value.date.strftime('%H:%M')}}</span>
</td>
{% endfor %}
</tr>
<!-- cloudness -->
<tr>
<td colspan="{{values | length}}"
class="header">
Облачность
</td>
</tr>
<tr>
{% for value in values %}
<td class="cloudness">
<span class="icon">{{value.cloudness | cloudness_icon}}</span>
</td>
{% endfor %}
</tr>
<!-- temperature -->
<tr>
<td colspan="{{values | length}}"
class="header">
Температура, °C
</td>
</tr>
<tr>
{% for value in values %}
<td class="temperature {{'positive' if value.temperature > 0 else 'negative'}}"
style="background-color: rgba(255, 128, 128, {{(value.temperature - 10) * 0.015}});">
<span class="value">{{value.temperature}}</span>
</td>
{% endfor %}
</tr>
<!-- wind_direction -->
<tr>
<td colspan="{{values | length}}"
class="header">
Направление ветра
</td>
</tr>
<tr>
{% for value in values %}
<td class="wind">
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
<span class="direction">{{value.wind_direction}}</span>
</td>
{% endfor %}
</tr>
<!-- wind_speed -->
<tr>
<td colspan="{{values | length}}"
class="header">
Скорость ветра, м/с
</td>
</tr>
<tr>
{% for value in values %}
<td class="wind"
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
<span class="speed">{{value.wind_speed}}</span>
{% if value.wind_gust != value.wind_speed %}
<span class="gust">
({{value.wind_gust}})
</span>
{% endif %}
</td>
{% endfor %}
</tr>
<!-- precipitation -->
<tr>
<td colspan="{{values | length}}"
class="header">
Осадки, мм
</td>
</tr>
<tr>
{% for value in values %}
<td class="precipitation"
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
<span class="value">{{value.precipitation or ''}}</span>
</td>
{% endfor %}
</tr>
<!-- pressure -->
<tr>
<td colspan="{{values | length}}"
class="header">
Давление, мм рт. ст.
</td>
</tr>
<tr>
{% for value in values %}
<td class="pressure"
style="background-color: rgba(128, 0, 255, {{(value.pressure - 720) * 0.008}});">
<span class="value">{{value.pressure}}</span>
</td>
{% endfor %}
</tr>
<!-- humidity -->
<tr>
<td colspan="{{values | length}}"
class="header">
Влажность, %
</td>
</tr>
<tr>
{% for value in values %}
<td class="humidity"
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
<span class="value">{{value.humidity}}</span>
</td>
{% endfor %}
</tr>
</tbody>
</table>
<script src="/static/index.js"></script>
</body>
</html>

2
poetry.lock generated
View File

@@ -1795,4 +1795,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "1fd060f04380ffd2a390e2940573687736a03a7d668bc774f4942d9b63bcac3d" content-hash = "6940ed3fa5467b63dde6410fcb427cbcf93c4366f967e07cf24601d51b43a28f"

View File

@@ -14,6 +14,7 @@ dateparser = "^1.2.0"
[tool.poetry.group.app.dependencies] [tool.poetry.group.app.dependencies]
fastapi = "^0.111.1" fastapi = "^0.111.1"
jinja2 = "^3.1.4"
[tool.poetry.group.test.dependencies] [tool.poetry.group.test.dependencies]
pytest = "^8.3.1" pytest = "^8.3.1"

View File

@@ -1,8 +1,9 @@
from pathlib import Path import datetime
import pytest import pytest
from gismeteo.api import GismeteoApi from gismeteo.api import GismeteoApi
from gismeteo.mock import MOCK_DATA
@pytest.fixture(name="gismeteo_api", scope="module") @pytest.fixture(name="gismeteo_api", scope="module")
@@ -10,13 +11,14 @@ def gismeteo_api_fixture() -> GismeteoApi:
api = GismeteoApi() api = GismeteoApi()
async def _request(endpoint: str) -> str: async def _request(endpoint: str) -> str:
target = endpoint.split("/")[-1] return MOCK_DATA.html
return (Path(__file__).parent / f"{target}.html").read_text()
api._request = _request api._request = _request
return api return api
async def test_api(gismeteo_api: GismeteoApi): async def test_api(gismeteo_api: GismeteoApi):
result = await gismeteo_api.tomorrow("zmiyevka") result = await gismeteo_api.get_day(
"zmiyevka", datetime.date.today() + datetime.timedelta(days=1)
)
assert len(result) == 8 assert len(result) == 8