feat: split to weather and gismeteo modules
This commit is contained in:
@@ -1,27 +1,18 @@
|
||||
import datetime
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from . import dateutil
|
||||
|
||||
from weather.api import WeatherApi
|
||||
from weather.model import WeatherResponse, WeatherValue
|
||||
|
||||
from . import datehelp
|
||||
from .location import LOCATION_BUNDLE
|
||||
from .parser import ROW_PARSERS, ONE_DAY_PARSER
|
||||
from .parser import LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS
|
||||
|
||||
|
||||
class WeatherValue(NamedTuple):
|
||||
date: datetime.datetime
|
||||
cloudness: str
|
||||
temperature: int
|
||||
wind_speed: int
|
||||
wind_gust: int
|
||||
wind_direction: str
|
||||
precipitation: float
|
||||
pressure: int
|
||||
humidity: int
|
||||
|
||||
|
||||
class GismeteoApi:
|
||||
class GismeteoApi(WeatherApi):
|
||||
BASE_URL = "https://www.gismeteo.ru"
|
||||
|
||||
async def _request(self, endpoint: str) -> str:
|
||||
@@ -30,18 +21,25 @@ class GismeteoApi:
|
||||
async with session.request("GET", url) as response:
|
||||
return await response.text()
|
||||
|
||||
def _parse_oneday(self, data: str) -> List[WeatherValue]:
|
||||
def _parse_oneday(self, date: datetime.date, data: str) -> WeatherResponse:
|
||||
result: List[Dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
location = LOCATION_PARSER.parse_location(data)
|
||||
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:
|
||||
result.append({})
|
||||
result[index][parser.KEY] = value
|
||||
return [WeatherValue(**item) for item in result]
|
||||
values = [WeatherValue(**item) for item in result]
|
||||
return WeatherResponse(
|
||||
location=location or "n/a",
|
||||
date=date,
|
||||
period="day",
|
||||
values=values,
|
||||
)
|
||||
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> List[WeatherValue]:
|
||||
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
|
||||
location = LOCATION_BUNDLE.parse(location_id)
|
||||
data = await self._request(f"weather-{location}/{dateutil.dump(date)}")
|
||||
return self._parse_oneday(data)
|
||||
data = await self._request(f"weather-{location}/{datehelp.dump(date)}")
|
||||
return self._parse_oneday(date, data)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import locale
|
||||
from os import environ
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gismeteo.route import api, doc, view
|
||||
|
||||
# locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None)
|
||||
doc.mount(app)
|
||||
api.mount(app)
|
||||
view.mount(app)
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run(
|
||||
"gismeteo.app:app", host="0.0.0.0", port=8000, reload="DEBUG" in environ
|
||||
)
|
||||
@@ -4,7 +4,6 @@ from bs4 import Tag
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class WidgetParser:
|
||||
def parse_widget(self, tag: Tag) -> Tag:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import dateparser
|
||||
|
||||
from gismeteo.api import WeatherValue
|
||||
from gismeteo.api import WeatherResponse
|
||||
|
||||
|
||||
class MockData:
|
||||
@@ -14,12 +11,9 @@ class MockData:
|
||||
return (Path(__file__).parent / "data/weather.html").read_text()
|
||||
|
||||
@property
|
||||
def values(self) -> List[WeatherValue]:
|
||||
def response(self) -> WeatherResponse:
|
||||
data = json.loads((Path(__file__).parent / "data/weather.json").read_text())
|
||||
return [
|
||||
WeatherValue(**{**item, "date": dateparser.parse(item["date"])})
|
||||
for item in data
|
||||
]
|
||||
return WeatherResponse(**data)
|
||||
|
||||
|
||||
MOCK_DATA = MockData()
|
||||
|
||||
@@ -1 +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}]
|
||||
{"location":"Змиевка","date":"2024-07-26","period":"day","values":[{"date":"2024-07-26T00:00:00","cloudness":"Облачно, без осадков","temperature":15,"wind_speed":1,"wind_gust":1,"wind_direction":"СВ","precipitation":0.0,"pressure":738,"humidity":96},{"date":"2024-07-26T03:00:00","cloudness":"Ясно","temperature":14,"wind_speed":0,"wind_gust":1,"wind_direction":"штиль","precipitation":0.0,"pressure":738,"humidity":98},{"date":"2024-07-26T06:00:00","cloudness":"Ясно","temperature":15,"wind_speed":1,"wind_gust":1,"wind_direction":"С","precipitation":0.0,"pressure":738,"humidity":97},{"date":"2024-07-26T09:00:00","cloudness":"Ясно","temperature":22,"wind_speed":1,"wind_gust":3,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":66},{"date":"2024-07-26T12:00:00","cloudness":"Малооблачно, без осадков","temperature":24,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":47},{"date":"2024-07-26T15:00:00","cloudness":"Облачно, без осадков","temperature":25,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":739,"humidity":40},{"date":"2024-07-26T18:00:00","cloudness":"Облачно, без осадков","temperature":25,"wind_speed":2,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":740,"humidity":39},{"date":"2024-07-26T21:00:00","cloudness":"Ясно","temperature":18,"wind_speed":1,"wind_gust":5,"wind_direction":"СВ","precipitation":0.0,"pressure":741,"humidity":62}]}
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from typing import Dict, Iterable, List
|
||||
import re
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
import dateparser
|
||||
from bs4 import Tag
|
||||
@@ -9,6 +10,19 @@ from .core import BaseWidgetParser, RowParser
|
||||
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items")
|
||||
|
||||
|
||||
class LocationParser:
|
||||
PATTERN = re.compile('{"ru":{"city":{"name":"(.*?)"')
|
||||
|
||||
def parse_location(self, data: str) -> Optional[str]:
|
||||
match = self.PATTERN.search(data)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
LOCATION_PARSER = LocationParser()
|
||||
|
||||
|
||||
class DateParser(RowParser[datetime.datetime]):
|
||||
KEY = "date"
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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]
|
||||
@@ -1,37 +0,0 @@
|
||||
from anyio import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.docs import (
|
||||
get_redoc_html,
|
||||
get_swagger_ui_html,
|
||||
get_swagger_ui_oauth2_redirect_html,
|
||||
)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
|
||||
def mount(app: FastAPI):
|
||||
app.mount(
|
||||
"/docs/static",
|
||||
StaticFiles(directory=Path(__file__).parent / "static"),
|
||||
)
|
||||
|
||||
@app.get("/docs", include_in_schema=False)
|
||||
async def custom_swagger_ui_html():
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title + " - Swagger UI",
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
swagger_js_url="docs/static/swagger-ui-bundle.js",
|
||||
swagger_css_url="docs/static/swagger-ui.css",
|
||||
)
|
||||
|
||||
@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
|
||||
async def swagger_ui_redirect():
|
||||
return get_swagger_ui_oauth2_redirect_html()
|
||||
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
async def redoc_html():
|
||||
return get_redoc_html(
|
||||
openapi_url=app.openapi_url,
|
||||
title=app.title + " - ReDoc",
|
||||
redoc_js_url="docs/static/redoc.standalone.js",
|
||||
)
|
||||
1782
gismeteo/route/doc/static/redoc.standalone.js
vendored
1782
gismeteo/route/doc/static/redoc.standalone.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
3
gismeteo/route/doc/static/swagger-ui.css
vendored
3
gismeteo/route/doc/static/swagger-ui.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,43 +0,0 @@
|
||||
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,
|
||||
},
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,81 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user