init
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.venv
|
||||
#.vscode
|
||||
33
.pylintrc
Normal file
33
.pylintrc
Normal file
@@ -0,0 +1,33 @@
|
||||
[MAIN]
|
||||
ignore=.venv
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=
|
||||
missing-docstring,
|
||||
too-few-public-methods,
|
||||
too-many-instance-attributes,
|
||||
too-many-arguments,
|
||||
too-many-locals,
|
||||
too-many-boolean-expressions,
|
||||
too-many-public-methods,
|
||||
no-else-return,
|
||||
singleton-comparison,
|
||||
unused-argument,
|
||||
unspecified-encoding,
|
||||
fixme,
|
||||
duplicate-code,
|
||||
|
||||
extension-pkg-allow-list=
|
||||
lxml,
|
||||
GeoIP,
|
||||
pydantic
|
||||
|
||||
[MISCELLANEOUS]
|
||||
notes=FIXME,TODO
|
||||
|
||||
[FORMAT]
|
||||
max-line-length=120
|
||||
|
||||
[DESIGN]
|
||||
max-parents=8
|
||||
max-args=8
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"tests", "-s"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
0
gismeteo/__init__.py
Normal file
0
gismeteo/__init__.py
Normal file
51
gismeteo/api.py
Normal file
51
gismeteo/api.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import datetime
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .location import LOCATION_BUNDLE
|
||||
from .parser import ROW_PARSERS, OneDayParser
|
||||
|
||||
|
||||
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:
|
||||
BASE_URL = "https://www.gismeteo.ru"
|
||||
|
||||
async def _request(self, endpoint: str) -> str:
|
||||
url = f"{self.BASE_URL}/{endpoint}"
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
async with session.request("GET", url) as response:
|
||||
return await response.text()
|
||||
|
||||
def _parse_oneday(self, data: str) -> List[WeatherValue]:
|
||||
result: List[Dict[str, Any]] = []
|
||||
soup = BeautifulSoup(data, features="html.parser")
|
||||
widget = OneDayParser().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]
|
||||
|
||||
async def today(self, location_id: str) -> 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")
|
||||
return self._parse_oneday(data)
|
||||
29
gismeteo/app.py
Normal file
29
gismeteo/app.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from os import environ
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gismeteo import custom_doc
|
||||
from gismeteo.api import GismeteoApi
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def run():
|
||||
uvicorn.run(
|
||||
"gismeteo.app:app", host="0.0.0.0", port=8000, reload="DEBUG" in environ
|
||||
)
|
||||
31
gismeteo/core.py
Normal file
31
gismeteo/core.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from typing import Generic, Iterable, Optional, TypeVar
|
||||
|
||||
from bs4 import Tag
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class WidgetParser:
|
||||
def parse_widget(self, tag: Tag) -> Tag:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class BaseWidgetParser(WidgetParser):
|
||||
SELECT: str
|
||||
|
||||
def __init__(self, select: Optional[str] = None):
|
||||
super().__init__()
|
||||
self._select = select or self.SELECT
|
||||
|
||||
def parse_widget(self, tag: Tag) -> Tag:
|
||||
widget = tag.select_one(self._select)
|
||||
if widget is None:
|
||||
raise ValueError(self._select)
|
||||
return widget
|
||||
|
||||
|
||||
class RowParser(Generic[T]):
|
||||
KEY: str
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[T]:
|
||||
raise NotImplementedError
|
||||
36
gismeteo/custom_doc.py
Normal file
36
gismeteo/custom_doc.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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 apply(app: FastAPI):
|
||||
app.mount(
|
||||
"/docs/static", StaticFiles(directory=Path(__file__).parent.parent / "static/docs")
|
||||
)
|
||||
|
||||
@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",
|
||||
)
|
||||
34
gismeteo/location.py
Normal file
34
gismeteo/location.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class LocationValue(NamedTuple):
|
||||
location_id: int
|
||||
name: str
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name}-{self. location_id}"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source: str) -> "LocationValue":
|
||||
location, name = source.split("-")
|
||||
return cls(int(location), name)
|
||||
|
||||
|
||||
class LocationBundle:
|
||||
def __init__(self, *values: LocationValue) -> None:
|
||||
self._values = values
|
||||
self._values_by_name = {value.name: value for value in self._values}
|
||||
self._values_by_id = {value.location_id: value for value in self._values}
|
||||
|
||||
def parse(self, value: str) -> LocationValue:
|
||||
if str.isdigit(value):
|
||||
return self._values_by_id[int(value)]
|
||||
if value in self._values_by_name:
|
||||
return self._values_by_name[value]
|
||||
return LocationValue.parse(value)
|
||||
|
||||
|
||||
LOCATION_BUNDLE = LocationBundle(
|
||||
LocationValue(4432, "orel"),
|
||||
LocationValue(184640, "zmiyevka"),
|
||||
)
|
||||
118
gismeteo/parser.py
Normal file
118
gismeteo/parser.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import datetime
|
||||
from typing import Dict, Iterable, List
|
||||
|
||||
import dateparser
|
||||
from bs4 import Tag
|
||||
|
||||
from .core import BaseWidgetParser, RowParser
|
||||
|
||||
|
||||
class OneDayParser(BaseWidgetParser):
|
||||
SELECT = ".widget.widget-oneday .widget-items"
|
||||
|
||||
|
||||
class DateParser(RowParser[datetime.datetime]):
|
||||
KEY = "date"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[datetime.datetime]:
|
||||
date_str = (
|
||||
tag.select_one(".widget-row.widget-row-datetime-date > .row-item")
|
||||
.find(text=True, recursive=False)
|
||||
.text
|
||||
)
|
||||
date = dateparser.parse(date_str, languages=["ru"])
|
||||
for item in tag.select(".widget-row.widget-row-datetime-time > .row-item"):
|
||||
time_str = item.text
|
||||
time = dateparser.parse(time_str, languages=["ru"])
|
||||
time = time.replace(year=date.year, month=date.month, day=date.day)
|
||||
yield time
|
||||
|
||||
|
||||
class CloudnessParser(RowParser[str]):
|
||||
KEY = "cloudness"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[str]:
|
||||
for item in tag.select(".widget-row[data-row=icon-tooltip] > .row-item"):
|
||||
yield item.attrs["data-tooltip"]
|
||||
|
||||
|
||||
class TemperatureParser(RowParser[int]):
|
||||
KEY = "temperature"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=temperature-air] > .chart > .values > .value > temperature-value"
|
||||
):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
class WindSpeedParser(RowParser[int]):
|
||||
KEY = "wind_speed"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=wind-speed] > .row-item > speed-value"
|
||||
):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
class WindGustParser(RowParser[int]):
|
||||
KEY = "wind_gust"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(".widget-row[data-row=wind-gust] > .row-item"):
|
||||
value = item.select_one("speed-value")
|
||||
yield int(value.attrs["value"]) if value else 0
|
||||
|
||||
|
||||
class WindDirectionParser(RowParser[str]):
|
||||
KEY = "wind_direction"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[str]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=wind-direction] > .row-item > .direction"
|
||||
):
|
||||
yield item.text
|
||||
|
||||
|
||||
class WindPrecipitationParser(RowParser[float]):
|
||||
KEY = "precipitation"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[float]:
|
||||
for item in tag.select(
|
||||
".widget-row[data-row=precipitation-bars] > .row-item > .item-unit"
|
||||
):
|
||||
yield float(item.text.replace(",", "."))
|
||||
|
||||
|
||||
class PressureParser(RowParser[int]):
|
||||
KEY = "pressure"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(
|
||||
".widget-row-chart[data-row=pressure] > .chart > .values > .value > pressure-value"
|
||||
):
|
||||
yield int(item.attrs["value"])
|
||||
|
||||
|
||||
class HumidityParser(RowParser[int]):
|
||||
KEY = "humidity"
|
||||
|
||||
def parse_row(self, tag: Tag) -> Iterable[int]:
|
||||
for item in tag.select(".widget-row[data-row=humidity] > .row-item"):
|
||||
yield int(item.text)
|
||||
|
||||
|
||||
ROW_PARSERS: List[RowParser] = [
|
||||
DateParser(),
|
||||
CloudnessParser(),
|
||||
TemperatureParser(),
|
||||
WindSpeedParser(),
|
||||
WindGustParser(),
|
||||
WindDirectionParser(),
|
||||
WindPrecipitationParser(),
|
||||
PressureParser(),
|
||||
HumidityParser(),
|
||||
]
|
||||
|
||||
ROW_PARSERS_MAP: Dict[str, RowParser] = {parser.KEY: parser for parser in ROW_PARSERS}
|
||||
1798
poetry.lock
generated
Normal file
1798
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
poetry.toml
Normal file
2
poetry.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[tool.poetry]
|
||||
name = "gismeteo-api"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["shmyga <shmyga.z@gmail.com>"]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "gismeteo" }]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
aiohttp = "^3.9.5"
|
||||
beautifulsoup4 = "^4.12.3"
|
||||
dateparser = "^1.2.0"
|
||||
|
||||
[tool.poetry.group.app.dependencies]
|
||||
fastapi = "^0.111.1"
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^8.3.1"
|
||||
pytest-asyncio = "^0.23.8"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pylint = "^3.2.6"
|
||||
black = "^24.4.2"
|
||||
isort = "^5.13.2"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
app = 'gismeteo.app:run'
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-p no:warnings"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
1782
static/docs/redoc.standalone.js
Normal file
1782
static/docs/redoc.standalone.js
Normal file
File diff suppressed because one or more lines are too long
2
static/docs/swagger-ui-bundle.js
Normal file
2
static/docs/swagger-ui-bundle.js
Normal file
File diff suppressed because one or more lines are too long
3
static/docs/swagger-ui.css
Normal file
3
static/docs/swagger-ui.css
Normal file
File diff suppressed because one or more lines are too long
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
22
tests/test_api.py
Normal file
22
tests/test_api.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gismeteo.api import GismeteoApi
|
||||
|
||||
|
||||
@pytest.fixture(name="gismeteo_api", scope="module")
|
||||
def gismeteo_api_fixture() -> GismeteoApi:
|
||||
api = GismeteoApi()
|
||||
|
||||
async def _request(endpoint: str) -> str:
|
||||
target = endpoint.split("/")[-1]
|
||||
return (Path(__file__).parent / f"{target}.html").read_text()
|
||||
|
||||
api._request = _request
|
||||
return api
|
||||
|
||||
|
||||
async def test_api(gismeteo_api: GismeteoApi):
|
||||
result = await gismeteo_api.tomorrow("zmiyevka")
|
||||
assert len(result) == 8
|
||||
374
tests/tomorrow.html
Normal file
374
tests/tomorrow.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user