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
from bs4 import BeautifulSoup
from . import dateutil
from .location import LOCATION_BUNDLE
from .parser import ROW_PARSERS, OneDayParser
from .parser import ROW_PARSERS, ONE_DAY_PARSER
class WeatherValue(NamedTuple):
@@ -32,7 +33,7 @@ class GismeteoApi:
def _parse_oneday(self, data: str) -> List[WeatherValue]:
result: List[Dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser")
widget = OneDayParser().parse_widget(soup)
widget = ONE_DAY_PARSER.parse_widget(soup)
for parser in ROW_PARSERS:
for index, value in enumerate(parser.parse_row(widget)):
while len(result) < index + 1:
@@ -40,12 +41,7 @@ class GismeteoApi:
result[index][parser.KEY] = value
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)
data = await self._request(f"weather-{location}/today")
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")
data = await self._request(f"weather-{location}/{dateutil.dump(date)}")
return self._parse_oneday(data)

View File

@@ -1,26 +1,17 @@
import locale
from os import environ
import uvicorn
from fastapi import FastAPI
from gismeteo import custom_doc
from gismeteo.api import GismeteoApi
from gismeteo.route import api, doc, view
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
app = FastAPI(docs_url=None, redoc_url=None)
custom_doc.apply(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]
doc.mount(app)
api.mount(app)
view.mount(app)
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()

File diff suppressed because one or more lines are too long

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
class OneDayParser(BaseWidgetParser):
SELECT = ".widget.widget-oneday .widget-items"
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
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
def apply(app: FastAPI):
def mount(app: FastAPI):
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)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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>