From 46e72feb82a9d2fb313f1e099b4bd8d4f4c2fcb9 Mon Sep 17 00:00:00 2001 From: shmyga Date: Thu, 10 Sep 2026 18:48:23 +0300 Subject: [PATCH] fix(rp5): fix rp5 parser --- gallery/painting/gismeteo/api.py | 14 ++- gallery/painting/gismeteo/core.py | 31 ----- gallery/painting/gismeteo/datehelp.py | 39 ------- gallery/painting/gismeteo/parser.py | 33 +++++- gallery/painting/rp5/parser.py | 159 ++++++++++++++++---------- tests/data/rp5/weather.html | 154 +++++++++++++------------ tests/test_rp5.py | 4 +- 7 files changed, 222 insertions(+), 212 deletions(-) delete mode 100644 gallery/painting/gismeteo/core.py delete mode 100644 gallery/painting/gismeteo/datehelp.py diff --git a/gallery/painting/gismeteo/api.py b/gallery/painting/gismeteo/api.py index bd5c829..88973f2 100644 --- a/gallery/painting/gismeteo/api.py +++ b/gallery/painting/gismeteo/api.py @@ -12,12 +12,22 @@ from gallery.sketch.weather.api import WeatherApi from gallery.sketch.weather.model import Location, WeatherResponse, WeatherValue from gallery.sketch.weather.util import merge_weather_values -from . import datehelp from .parser import DAYS_PARSER, LOCATION_PARSER, ONE_DAY_PARSER, ROW_PARSERS logger = logging.getLogger("gismeteo") +def format_date(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" + + class GismeteoApi(WeatherApi): PROVIDER = "gismeteo" @@ -107,7 +117,7 @@ class GismeteoApi(WeatherApi): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"max_date": max_date.strftime("%Y-%m-%d")} ) - data = await self.source.request(f"weather-{location_id}/{datehelp.dump(date)}") + data = await self.source.request(f"weather-{location_id}/{format_date(date)}") return self._parse_oneday(date, data) async def get_days(self, location_id: str, days: int) -> WeatherResponse: diff --git a/gallery/painting/gismeteo/core.py b/gallery/painting/gismeteo/core.py deleted file mode 100644 index b168e57..0000000 --- a/gallery/painting/gismeteo/core.py +++ /dev/null @@ -1,31 +0,0 @@ -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 diff --git a/gallery/painting/gismeteo/datehelp.py b/gallery/painting/gismeteo/datehelp.py deleted file mode 100644 index 196a604..0000000 --- a/gallery/painting/gismeteo/datehelp.py +++ /dev/null @@ -1,39 +0,0 @@ -import datetime -from enum import Enum - -import dateparser -import dateparser.date_parser - - -class Day(str, Enum): - TODAY = "today" - TOMORROW = "tomorrow" - DAY_3 = "3-day" - DAY_4 = "4-day" - DAY_5 = "5-day" - - -def parse(value: str) -> datetime.date: - if value in ["today", "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" diff --git a/gallery/painting/gismeteo/parser.py b/gallery/painting/gismeteo/parser.py index 017da50..56f5d24 100644 --- a/gallery/painting/gismeteo/parser.py +++ b/gallery/painting/gismeteo/parser.py @@ -1,7 +1,7 @@ import datetime import logging import re -from typing import Iterable +from typing import Generic, Iterable, Optional, TypeVar import dateparser from bs4 import Tag @@ -14,10 +14,37 @@ from gallery.sketch.weather.model import ( ) from gallery.sketch.weather.parser import ParseError, parse_wind_direction -from .core import BaseWidgetParser, RowParser - logger = logging.getLogger("gismeteo") +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 + + ONE_DAY_PARSER = BaseWidgetParser(".widget.widget-oneday .widget-items") DAYS_PARSER = BaseWidgetParser(".widget.widget-days .widget-items") diff --git a/gallery/painting/rp5/parser.py b/gallery/painting/rp5/parser.py index b98129c..c53383e 100644 --- a/gallery/painting/rp5/parser.py +++ b/gallery/painting/rp5/parser.py @@ -1,4 +1,6 @@ import re +from enum import Enum, auto +from typing import Iterable import dateparser from bs4 import BeautifulSoup, Tag @@ -36,72 +38,109 @@ def _parse_wind_speed(cell: Tag) -> int: return 0 +class RowType(Enum): + DATE = auto() + TIME = auto() + CLOUDNESS = auto() + PRECIPITATION = auto() + TEMPERATURE = auto() + PRESSURE = auto() + WIND_SPEED = auto() + WIND_GUST = auto() + WIND_DIRECTION = auto() + HUMIDITY = auto() + + +def _iter_table_rows(table: Tag) -> Iterable[tuple[RowType, Tag]]: + for row in table.select("tr"): + title = row.select_one("td").text.strip().lower() + if "сегодня" in title: + yield RowType.DATE, row + elif "местное время" in title: + yield RowType.TIME, row + elif "облачность" in title: + yield RowType.CLOUDNESS, row + elif "осадки" in title: + yield RowType.PRECIPITATION, row + elif "температура" in title: + yield RowType.TEMPERATURE, row + elif "давление" in title: + yield RowType.PRESSURE, row + elif "скорость" in title: + yield RowType.WIND_SPEED, row + elif "порывы" in title: + yield RowType.WIND_GUST, row + elif "направление" in title: + yield RowType.WIND_DIRECTION, row + elif "влажность" in title: + yield RowType.HUMIDITY, row + + # pylint:disable=too-many-branches def parse_page(page: BeautifulSoup) -> list[WeatherResponse]: location = page.select(".intoLeftNavi > .verticalBottom > a")[-1].text.strip() table = page.select_one("#forecastTable") if table is None: raise ParseError("#forecastTable not found") - ( - date_row, - time_row, - cloudness_row, - precipitation_row, - temperature_row, - pressure_row, - wind_speed_row, - wind_gust_row, - wind_direction_row, - humidity_row, - *_, - ) = table.select("tr") result: list[WeatherResponse] = [] values: list[WeatherValue] = [] - for index, cell in enumerate(date_row.select("td")): - colspan = int(cell.attrs["colspan"]) - if index == 0: - colspan -= 1 - date_str = ",".join(cell.text.strip().split(",")[-2:]) - date = dateparser.parse(date_str, languages=["ru"]) - if date is None: - raise ParseError.invalid_date(date_str) - item_values = [build_weather_value(date) for _ in range(colspan)] - result.append( - WeatherResponse( - location=location, - date=date.date(), - period="day", - values=item_values, - ) - ) - values += item_values - for index, cell in enumerate(time_row.select("td")[1:]): - hour = int(cell.text.strip()) - values[index].date = values[index].date.replace(hour=hour) - for index, cell in enumerate(cloudness_row.select("td")[1:]): - onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"] - cloudness_str = re.compile("(.*?)").search(onmouseover).group(1).strip() - values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str] - for index, cell in enumerate(precipitation_row.select("td")[1:]): - onmouseover = cell.select_one(".pr_0").attrs["onmouseover"] - precipitation_str = onmouseover.split(",")[1].strip(" '") - precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str) - if precipitation_value_matched: - # one hour value - values[index].precipitation = float(precipitation_value_matched.group(1)) * 6 - if "(" in precipitation_str: - precipitation_str = precipitation_str.split(" (")[0] - values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str] - for index, cell in enumerate(temperature_row.select("td")[1:]): - values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())] - for index, cell in enumerate(pressure_row.select("td")[1:]): - values[index].pressure = [int(cell.select_one(".p_0").text.strip())] - for index, cell in enumerate(wind_speed_row.select("td")[1:]): - values[index].wind_speed = _parse_wind_speed(cell) - for index, cell in enumerate(wind_gust_row.select("td")[1:]): - values[index].wind_gust = _parse_wind_speed(cell) - for index, cell in enumerate(wind_direction_row.select("td")[1:]): - values[index].wind_direction = parse_wind_direction(cell.text.strip()) - for index, cell in enumerate(humidity_row.select("td")[1:]): - values[index].humidity = int(cell.text.strip()) + for row_type, row in _iter_table_rows(table): + match row_type: + case RowType.DATE: + for index, cell in enumerate(row.select("td")): + colspan = int(cell.attrs["colspan"]) + if index == 0: + colspan -= 1 + date_str = ",".join(cell.text.strip().split(",")[-2:]) + date = dateparser.parse(date_str, languages=["ru"]) + if date is None: + raise ParseError.invalid_date(date_str) + item_values = [build_weather_value(date) for _ in range(colspan)] + result.append( + WeatherResponse( + location=location, + date=date.date(), + period="day", + values=item_values, + ) + ) + values += item_values + case RowType.TIME: + for index, cell in enumerate(row.select("td")[1:]): + hour = int(cell.text.strip()) + values[index].date = values[index].date.replace(hour=hour) + case RowType.CLOUDNESS: + for index, cell in enumerate(row.select("td")[1:]): + onmouseover = cell.select_one(".cc_0").next_element.attrs["onmouseover"] + cloudness_str = re.compile("(.*?)").search(onmouseover).group(1).strip() + values[index].sky.cloudness = CLOUDNESS_MAP[cloudness_str] + case RowType.PRECIPITATION: + for index, cell in enumerate(row.select("td")[1:]): + onmouseover = cell.select_one(".pr_0").attrs["onmouseover"] + precipitation_str = onmouseover.split(",")[1].strip(" '") + precipitation_value_matched = re.compile("(\\d+\\.\\d+) мм").search(precipitation_str) + if precipitation_value_matched: + # one hour value + values[index].precipitation = float(precipitation_value_matched.group(1)) * 6 + if "(" in precipitation_str: + precipitation_str = precipitation_str.split(" (")[0] + values[index].sky.precipitation = PRECIPITATION_MAP[precipitation_str] + case RowType.TEMPERATURE: + for index, cell in enumerate(row.select("td")[1:]): + values[index].temperature = [int(cell.select_one(".t_0").text.lstrip("+").strip())] + case RowType.PRESSURE: + for index, cell in enumerate(row.select("td")[1:]): + values[index].pressure = [int(cell.select_one(".p_0").text.strip())] + case RowType.WIND_SPEED: + for index, cell in enumerate(row.select("td")[1:]): + values[index].wind_speed = _parse_wind_speed(cell) + case RowType.WIND_GUST: + for index, cell in enumerate(row.select("td")[1:]): + values[index].wind_gust = _parse_wind_speed(cell) + case RowType.WIND_DIRECTION: + for index, cell in enumerate(row.select("td")[1:]): + values[index].wind_direction = parse_wind_direction(cell.text.strip()) + case RowType.HUMIDITY: + for index, cell in enumerate(row.select("td")[1:]): + values[index].humidity = int(cell.text.strip()) return result diff --git a/tests/data/rp5/weather.html b/tests/data/rp5/weather.html index 0866690..3644a73 100644 --- a/tests/data/rp5/weather.html +++ b/tests/data/rp5/weather.html @@ -6,11 +6,11 @@ Погода в Орле, Орловская область - РП5 - + @@ -59,15 +59,15 @@ t_p1 = "Атмосферное давление у поверхности земли (дюймы ртутного столба)", t_p2 = "Атмосферное давление у поверхности земли (миллибары)", t_p3 = "Атмосферное давление у поверхности земли (гектопаскали)", t_wv0 = "Ветер на высоте 10 метров (метры в секунду)", t_wv1 = "Ветер на высоте 10 метров (километры в час)", t_wv2 = "Ветер на высоте 10 метров (мили в час)", t_wv3 = "Ветер на высоте 10 метров (узлы)", t_wv4 = "Ветер на высоте 10 метров (баллы Бофорта)", - t_ft0 = "Температура с учётом влияния ветра и влажности (градусы Цельсия)", t_ft1 = "Температура с учётом влияния ветра и влажности (градусы Фаренгейта)"; cRp5TxtHref="https://txt.rp5.ru/?AdvertMgmt=new&targetPlace=p003000080002239000000631802&check=401cf1c01f996bf"; cAdvertMgmtClass="b8a584247184083a88446c9a8600280e65cab1d40";var _0x2e66=["\x73\x43\x6F\x6E\x74\x61\x69\x6E\x65\x72\x42\x6F\x74\x74\x6F\x6D","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x2e66[1]][_0x2e66[0]] = "#b4e9a7567d80113db2d81947da14069a6094ab314";var _0x248f=["\x63\x41\x42\x4E\x6F\x43\x68\x65\x63\x6B","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x248f[1]][_0x248f[0]] = ".";var _0x751f=["\x73\x41\x64\x49\x64\x53\x65\x6E\x73\x65\x52\x69\x67\x68\x74","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x751f[1]][_0x751f[0]] = "#bc5e74b9d245e46bb696fdb929fb13eafc9678d45";var _0x14c2=["\x73\x41\x64\x49\x64\x53\x65\x6E\x73\x65\x42\x6F\x74\x74\x6F\x6D","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x14c2[1]][_0x14c2[0]] = "#bb324079366864d8ecaa5cb7b1446"; + t_ft0 = "Температура с учётом влияния ветра и влажности (градусы Цельсия)", t_ft1 = "Температура с учётом влияния ветра и влажности (градусы Фаренгейта)"; cRp5TxtHref="https://txt.rp5.ru/?AdvertMgmt=new&targetPlace=p003000080002239000000631802&check=401cf1c01f996bf"; cAdvertMgmtClass="o3de1f464a9d721adb6120ca505c60aeaddf5e47c";var _0x2e66=["\x73\x43\x6F\x6E\x74\x61\x69\x6E\x65\x72\x42\x6F\x74\x74\x6F\x6D","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x2e66[1]][_0x2e66[0]] = "#o1cd530eb1fec4685da3c4d6095d829d957c6c597";var _0x248f=["\x63\x41\x42\x4E\x6F\x43\x68\x65\x63\x6B","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x248f[1]][_0x248f[0]] = ".";var _0x6a2d=["\x73\x41\x64\x49\x64\x53\x65\x6E\x73\x65\x52\x69\x67\x68\x74","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x6a2d[1]][_0x6a2d[0]] = "#od4c577505bb83801cb4e7404b26dcebac0590501";var _0x5bd5=["\x73\x41\x64\x49\x64\x53\x65\x6E\x73\x65\x42\x6F\x74\x74\x6F\x6D","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x5bd5[1]][_0x5bd5[0]] = "#oe65347c5afb5f2b6c2a7a73fe47ae94"; var sRp5TxtHref = "https://txt.rp5.ru/?AdvertMgmt=new&targetPlace=p003000080002239000000631802&check=401cf1c01f996bf"; - var sABWarning = "Обнapужeнa блокиpовкa peклaмы (eдинcтвeнного иcточникa доходa caйтa rp5.ru). Пожaлуйcтa, отключитe (в cвоём бpaузepe, опepaционной cиcтeмe или локaльной ceти) блокиpовку peклaмы для вышeукaзaнного caйтa. В cлучae cохpaнeния блокиpовки peклaмы, мы будeм вынуждeны блокиpовaть для вac дaнныe о погодe."; + var sABWarning = "Обнaружeнa блoкирoвкa рeклaмы (eдинствeннoгo истoчникa дoхoдa сaйтa rp5.ru). Пoжaлуйстa, oтключитe (в свoём брaузeрe, oпeрaциoннoй систeмe или лoкaльнoй сeти) блoкирoвку рeклaмы для вышeукaзaннoгo сaйтa. В случae сoхрaнeния блoкирoвки рeклaмы, мы будeм вынуждeны блoкирoвaть для вaс дaнныe o пoгoдe."; var sHtmlBlock = "
"+sABWarning+"
"; var sContentBottom = ""; - var _0x55c1=["\x73\x41\x42\x4D\x65\x73\x73\x31","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x55c1[1]][_0x55c1[0]] = "Блокировка рeкламы";var _0x52c3=["\x73\x41\x42\x57\x61\x72\x6E\x69\x6E\x67\x31","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x52c3[1]][_0x52c3[0]] = "Обнapужeнa блокиpовкa peклaмы (eдинcтвeнного иcточникa доходa caйтa rp5.ru). Пожaлуйcтa, отключитe (в cвоём бpaузepe, опepaционной cиcтeмe или локaльной ceти) блокиpовку peклaмы для вышeукaзaнного caйтa. В cлучae cохpaнeния блокиpовки peклaмы, мы будeм вынуждeны блокиpовaть для вac дaнныe о погодe."; cSearch = "Название города или села"; + var _0x2429=["\x73\x41\x42\x4D\x65\x73\x73\x31","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x2429[1]][_0x2429[0]] = "Блокировка рeкламы";var _0x9a7c=["\x73\x41\x42\x57\x61\x72\x6E\x69\x6E\x67\x31","\x64\x6F\x63\x75\x6D\x65\x6E\x74"];this[_0x9a7c[1]][_0x9a7c[0]] = "Обнaружeнa блoкирoвкa рeклaмы (eдинствeннoгo истoчникa дoхoдa сaйтa rp5.ru). Пoжaлуйстa, oтключитe (в свoём брaузeрe, oпeрaциoннoй систeмe или лoкaльнoй сeти) блoкирoвку рeклaмы для вышeукaзaннoгo сaйтa. В случae сoхрaнeния блoкирoвки рeклaмы, мы будeм вынуждeны блoкирoвaть для вaс дaнныe o пoгoдe."; cSearch = "Название города или села"; @@ -432,7 +432,7 @@
-
+
Все страны  Россия   @@ -442,7 +442,7 @@
-
+
@@ -464,21 +464,21 @@ - это сайт, с которого распечатан данный прогноз. Чтобы узнать больше, посетите сайт.
-
-
-
-
+
+
+
+
Завтра ожидается +23..+16 °C, без осадков, слабый ветер. Послезавтра: +14..+22 °C, cлабый дождь, легкий ветер.
7 суток
Завтра
Чт
Пт
Сб
Вс
Пн
Вт
Завтра, ср, 2 сентября
Чт, 3 сентября
Пт, 4 сентября
Сб, 5 сентября
Вс, 6 сентября
Пн, 7 сентября
Вт, 8 сентября
Местное время 030915210309152103091521030915210309152103091521030915
Облачность,
%
Осадки, мм
Температура,
°C
+15
+17
+23
+14
+11
+16
+22
+14
+10
+16
+23
+14
+14
+15
+19
+13
+12
+13
+19
+11
+10
+13
+18
+10
+7
+11
+18
Ощущается как,
°C
+8
Давление,
мм рт. ст.
748
748
747
748
748
749
748
748
747
748
746
744
741
735
736
738
739
742
742
743
744
746
747
748
749
751
750
Ветер: скорость,
м/с
1
3
4
1
1
2
3
1
1
2
3
2
3
6
6
4
4
4
3
3
3
5
5
1
1
2
3
порывы,
м/с
7
10
5
9
8
6
9
10
8
10
6
7
10
5
6
направление Ю-ЗЮ-ЗЗЮ-ЗЮ-ЗЮ-ЗЗЮ-ЗЮ-ЗЮ-ЗЮ-ЗЮЮЮЗЮ-ЗЗЗЗЗЗС-ЗС-ЗС-ЗС-ЗС-ЗС-З
Развернуть таблицу (Влажность. Солнце, Луна: восход, заход, фаза)
больше информации о погоде
на сайте rp5.ru
+ })
Сегодня ожидается +20..+19 °C, без осадков, легкий ветер. Завтра: +20..+13 °C, без осадков, слабый ветер.
7 суток
Сегодня
Завтра
Сб
Вс
Пн
Вт
Ср
Чт
Сегодня, чт, 10 сентября
Завтра, пт, 11 сентября
Сб, 12 сентября
Вс, 13 сентября
Пн, 14 сентября
Вт, 15 сентября
Ср, 16 сентября
Чт
Местное время 2103091521030915210309152103091521030915210309152103
Облачность,
%
Осадки, мм
Температура,
°C
+18
+15
+14
+20
+11
+7
+14
+18
+12
+9
+13
+20
+11
+5
+11
+21
+12
+7
+14
+20
+11
+4
+13
+21
+13
+8
Ощущается как,
°C
+3
Давление,
мм рт. ст.
745
745
748
749
750
751
751
750
750
750
751
750
750
751
751
751
751
751
751
750
749
748
747
745
745
745
Ветер: скорость,
м/с
2
2
4
5
1
1
1
2
00
1
1
0
1
00
1
1
1
0
1
1
1
2
1
1
порывы,
м/с
9
5
4
6
направление ЮЮ-ЗЗЗЗЮ-ЗЗЗШТЛШТЛССШТЛЮШТЛШТЛЮ-ВЮ-ВЮ-ВШТЛЮ-ВС-ВСВВС
Влажность,
%
8281844389967153100100734381100823778916841891007147100100
Солнце:
восход заход
19:04 06:0219:01  06:0418:59  06:0618:57  06:0718:54  06:0918:52  06:1118:49 06:13
Луна:
восход заход
18:39 06:1418:50  07:3719:01  08:5819:12  10:1919:25  11:3819:42  12:5520:05 14:07
фаза
Свернуть таблицу
больше информации о погоде
на сайте rp5.ru
Новости погоды
- За последние 12 часов максимальная температура воздуха наблюдалась в Ливенском районе в Ливнах: +28 oC - . + 4 часа назад минимальная температура воздуха наблюдалась в Мценском районе в Мценске: +27 oC + .
@@ -492,8 +492,8 @@
Новости погоды
-

3 часа назад минимальная температура воздуха наблюдалась в Мценском районе в Мценске: +15 oC - .

За последние 12 часов максимальная температура воздуха наблюдалась в Ливенском районе в Ливнах: +28 oC +

4 часа назад минимальная температура воздуха наблюдалась в Мценском районе в Мценске: +27 oC + .

4 часа назад максимальная температура воздуха наблюдалась в Дмитровском районе в Дмитровске: +28 oC .

@@ -506,8 +506,8 @@
-
-
+
+