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