feat: split to weather and gismeteo modules

This commit is contained in:
2024-07-26 11:02:01 +03:00
parent c9e52c43a9
commit 848b6bd9ba
28 changed files with 157 additions and 108 deletions

13
.vscode/settings.json vendored
View File

@@ -1,7 +1,8 @@
{ {
"python.testing.pytestArgs": [ "python.testing.pytestArgs": ["tests", "-s"],
"tests", "-s" "python.testing.unittestEnabled": false,
], "python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false, "files.exclude": {
"python.testing.pytestEnabled": true "**/__pycache__": true
} }
}

View File

@@ -1,27 +1,18 @@
import datetime import datetime
from typing import Any, Dict, List, NamedTuple from typing import Any, Dict, List
import aiohttp import aiohttp
from bs4 import BeautifulSoup 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 .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): class GismeteoApi(WeatherApi):
date: datetime.datetime
cloudness: str
temperature: int
wind_speed: int
wind_gust: int
wind_direction: str
precipitation: float
pressure: int
humidity: int
class GismeteoApi:
BASE_URL = "https://www.gismeteo.ru" BASE_URL = "https://www.gismeteo.ru"
async def _request(self, endpoint: str) -> str: async def _request(self, endpoint: str) -> str:
@@ -30,18 +21,25 @@ class GismeteoApi:
async with session.request("GET", url) as response: async with session.request("GET", url) as response:
return await response.text() 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]] = [] result: List[Dict[str, Any]] = []
soup = BeautifulSoup(data, features="html.parser") soup = BeautifulSoup(data, features="html.parser")
location = LOCATION_PARSER.parse_location(data)
widget = ONE_DAY_PARSER.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:
result.append({}) result.append({})
result[index][parser.KEY] = value 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) location = LOCATION_BUNDLE.parse(location_id)
data = await self._request(f"weather-{location}/{dateutil.dump(date)}") data = await self._request(f"weather-{location}/{datehelp.dump(date)}")
return self._parse_oneday(data) return self._parse_oneday(date, data)

View File

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

View File

@@ -4,7 +4,6 @@ from bs4 import Tag
T = TypeVar("T") T = TypeVar("T")
class WidgetParser: class WidgetParser:
def parse_widget(self, tag: Tag) -> Tag: def parse_widget(self, tag: Tag) -> Tag:
raise NotImplementedError raise NotImplementedError

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import datetime import datetime
from typing import Dict, Iterable, List import re
from typing import Dict, Iterable, List, Optional
import dateparser import dateparser
from bs4 import Tag from bs4 import Tag
@@ -9,6 +10,19 @@ from .core import BaseWidgetParser, RowParser
ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items") 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]): class DateParser(RowParser[datetime.datetime]):
KEY = "date" KEY = "date"

View File

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

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 = "6940ed3fa5467b63dde6410fcb427cbcf93c4366f967e07cf24601d51b43a28f" content-hash = "bc04729da7680c2e078b4abd6e402186b54a938ef5cee388b0b2c462f6c167f1"

View File

@@ -1,16 +1,17 @@
[tool.poetry] [tool.poetry]
name = "gismeteo-api" name = "weather"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = ["shmyga <shmyga.z@gmail.com>"] authors = ["shmyga <shmyga.z@gmail.com>"]
readme = "README.md" readme = "README.md"
packages = [{ include = "gismeteo" }] packages = [{ include = "weather" }, { include = "gismeteo" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.12"
aiohttp = "^3.9.5" aiohttp = "^3.9.5"
beautifulsoup4 = "^4.12.3" beautifulsoup4 = "^4.12.3"
dateparser = "^1.2.0" dateparser = "^1.2.0"
pydantic = "^2.8.2"
[tool.poetry.group.app.dependencies] [tool.poetry.group.app.dependencies]
fastapi = "^0.111.1" fastapi = "^0.111.1"
@@ -30,7 +31,7 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts] [tool.poetry.scripts]
app = 'gismeteo.app:run' app = 'weather.app:run'
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-p no:warnings" addopts = "-p no:warnings"

View File

@@ -21,4 +21,4 @@ async def test_api(gismeteo_api: GismeteoApi):
result = await gismeteo_api.get_day( result = await gismeteo_api.get_day(
"zmiyevka", datetime.date.today() + datetime.timedelta(days=1) "zmiyevka", datetime.date.today() + datetime.timedelta(days=1)
) )
assert len(result) == 8 assert len(result.values) == 8

15
weather/api.py Normal file
View File

@@ -0,0 +1,15 @@
import datetime
from .model import WeatherResponse
class WeatherApi:
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
raise NotImplementedError
DEFAULT_API: WeatherApi = None
def get_api() -> WeatherApi:
return DEFAULT_API

27
weather/app.py Normal file
View File

@@ -0,0 +1,27 @@
import locale
from os import environ
import uvicorn
from fastapi import FastAPI
from gismeteo.api import GismeteoApi
from . import api as _api
from .route import api, doc, view
_api.DEFAULT_API = GismeteoApi()
locale.setlocale(locale.LC_TIME, "ru_RU.UTF-8")
app = FastAPI(
title="Weather",
docs_url=None,
redoc_url=None,
)
doc.mount(app)
api.mount(app)
view.mount(app)
def run():
uvicorn.run("weather.app:app", host="0.0.0.0", port=8000, reload="DEBUG" in environ)

22
weather/model.py Normal file
View File

@@ -0,0 +1,22 @@
import datetime
from pydantic import BaseModel
class WeatherValue(BaseModel):
date: datetime.datetime
cloudness: str
temperature: int
wind_speed: int
wind_gust: int
wind_direction: str
precipitation: float
pressure: int
humidity: int
class WeatherResponse(BaseModel):
location: str
date: datetime.date
period: str
values: list[WeatherValue]

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

@@ -0,0 +1,12 @@
import datetime
from fastapi import FastAPI
from weather.api import get_api
from weather.model import WeatherResponse
def mount(app: FastAPI):
@app.get("/api/weather/{location}/{date}")
async def get_weather(location: str, date: datetime.date) -> WeatherResponse:
return await get_api().get_day(location, date)

View File

@@ -6,9 +6,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from gismeteo import dateutil
from gismeteo.api import GismeteoApi
from gismeteo.mock import MOCK_DATA from gismeteo.mock import MOCK_DATA
from weather.api import get_api
from .filters import cloudness_icon, wind_direction_icon from .filters import cloudness_icon, wind_direction_icon
@@ -20,24 +19,21 @@ def mount(app: FastAPI):
templates.env.filters["wind_direction_icon"] = wind_direction_icon templates.env.filters["wind_direction_icon"] = wind_direction_icon
templates.env.filters["cloudness_icon"] = cloudness_icon templates.env.filters["cloudness_icon"] = cloudness_icon
@app.get("/weather/{location}") @app.get("/weather/{location}", response_class=RedirectResponse)
async def get_weather_base(location: str): async def get_weather_default(location: str):
return RedirectResponse(f"{location}/today") return RedirectResponse(f"{location}/{datetime.date.today()}")
@app.get("/weather/{location}/{date}", response_class=HTMLResponse) @app.get("/weather/{location}/{date}", response_class=HTMLResponse)
async def get_weather(request: Request, location: str, date: str): async def get_weather(request: Request, location: str, date: datetime.date):
if date == "mock": if date == "mock":
values = MOCK_DATA.values response = MOCK_DATA.response
else: else:
api = GismeteoApi() response = await get_api().get_day(location, date)
values = await api.get_day(location, dateutil.parse(date))
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,
name="weather.html", name="weather.html",
context={ context={
"datetime": datetime, "datetime": datetime,
"location": location, "response": response,
"date": dateutil.parse(date),
"values": values,
}, },
) )

View File

@@ -24,4 +24,5 @@ def cloudness_icon(cloudness: str) -> str:
"Облачно, небольшой дождь, гроза": "⛈️", "Облачно, небольшой дождь, гроза": "⛈️",
"Малооблачно, дождь": "🌦️", "Малооблачно, дождь": "🌦️",
"Пасмурно, небольшой дождь": "🌧️", "Пасмурно, небольшой дождь": "🌧️",
"Облачно, дождь, гроза": "⛈️",
}.get(cloudness, cloudness) }.get(cloudness, cloudness)

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

View File

@@ -37,6 +37,7 @@ td {
.header { .header {
font-size: 1rem; font-size: 1rem;
text-align: left; text-align: left;
padding-top: 0.25rem;
} }
.date { .date {

View File

@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1.0"> content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" <meta http-equiv="X-UA-Compatible"
content="ie=edge"> content="ie=edge">
<title>Weather - {{date.strftime('%a, %d %B %Y')}}</title> <title>Погода | {{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</title>
<link rel="stylesheet" <link rel="stylesheet"
href="/static/style.css"> href="/static/style.css">
<link rel="icon" <link rel="icon"
@@ -17,17 +17,17 @@
<body> <body>
<h3> <h3>
<a class="button {{'disabled' if date == datetime.date.today() else ''}}" <a class="button {{'disabled' if response.date == datetime.date.today() else ''}}"
href="{{date - datetime.timedelta(days=1)}}">🡨</a> href="{{response.date - datetime.timedelta(days=1)}}">🡨</a>
<span>{{date.strftime('%a, %d %B %Y')}}</span> <span>{{response.location}} | {{response.date.strftime('%a, %d %B %Y')}}</span>
<a class="button" <a class="button"
href="{{date + datetime.timedelta(days=1)}}">🡪</a> href="{{response.date + datetime.timedelta(days=1)}}">🡪</a>
</h3> </h3>
<table> <table>
<tbody> <tbody>
<!-- date --> <!-- date -->
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td <td
class="date {{'now' if value.date < datetime.datetime.now() and value.date + datetime.timedelta(hours=3) > datetime.datetime.now() else ''}}"> 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> <span class="value">{{value.date.strftime('%H:%M')}}</span>
@@ -36,13 +36,13 @@
</tr> </tr>
<!-- cloudness --> <!-- cloudness -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Облачность Облачность
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="cloudness"> <td class="cloudness">
<span class="icon">{{value.cloudness | cloudness_icon}}</span> <span class="icon">{{value.cloudness | cloudness_icon}}</span>
</td> </td>
@@ -50,13 +50,13 @@
</tr> </tr>
<!-- temperature --> <!-- temperature -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Температура, °C Температура, °C
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="temperature {{'positive' if value.temperature > 0 else 'negative'}}" <td class="temperature {{'positive' if value.temperature > 0 else 'negative'}}"
style="background-color: rgba(255, 128, 128, {{(value.temperature - 10) * 0.015}});"> style="background-color: rgba(255, 128, 128, {{(value.temperature - 10) * 0.015}});">
<span class="value">{{value.temperature}}</span> <span class="value">{{value.temperature}}</span>
@@ -65,13 +65,13 @@
</tr> </tr>
<!-- wind_direction --> <!-- wind_direction -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Направление ветра Направление ветра
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="wind"> <td class="wind">
<span class="icon">{{value.wind_direction | wind_direction_icon}}</span> <span class="icon">{{value.wind_direction | wind_direction_icon}}</span>
<span class="direction">{{value.wind_direction}}</span> <span class="direction">{{value.wind_direction}}</span>
@@ -80,13 +80,13 @@
</tr> </tr>
<!-- wind_speed --> <!-- wind_speed -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Скорость ветра, м/с Скорость ветра, м/с
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="wind" <td class="wind"
style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});"> style="background-color: rgba(128, 128, 128, {{value.wind_speed * 0.05}});">
<span class="speed">{{value.wind_speed}}</span> <span class="speed">{{value.wind_speed}}</span>
@@ -100,13 +100,13 @@
</tr> </tr>
<!-- precipitation --> <!-- precipitation -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Осадки, мм Осадки, мм
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="precipitation" <td class="precipitation"
style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});"> style="background-color: rgba(0, 128, 255, {{value.precipitation * 0.1}});">
<span class="value">{{value.precipitation or ''}}</span> <span class="value">{{value.precipitation or ''}}</span>
@@ -115,13 +115,13 @@
</tr> </tr>
<!-- pressure --> <!-- pressure -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Давление, мм рт. ст. Давление, мм рт. ст.
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="pressure" <td class="pressure"
style="background-color: rgba(128, 0, 255, {{(value.pressure - 720) * 0.008}});"> style="background-color: rgba(128, 0, 255, {{(value.pressure - 720) * 0.008}});">
<span class="value">{{value.pressure}}</span> <span class="value">{{value.pressure}}</span>
@@ -130,13 +130,13 @@
</tr> </tr>
<!-- humidity --> <!-- humidity -->
<tr> <tr>
<td colspan="{{values | length}}" <td colspan="{{response.values | length}}"
class="header"> class="header">
Влажность, % Влажность, %
</td> </td>
</tr> </tr>
<tr> <tr>
{% for value in values %} {% for value in response.values %}
<td class="humidity" <td class="humidity"
style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});"> style="background-color: rgba(128, 128, 255, {{value.humidity * 0.005}});">
<span class="value">{{value.humidity}}</span> <span class="value">{{value.humidity}}</span>