Compare commits

...
6 Commits
Author SHA1 Message Date
shmyga b5a8a1536c ci(version): 0.7.0 2026-09-03 20:09:23 +03:00
shmyga 4aafe720dd feat(static): update WeatherLocationElement 2026-09-03 20:01:34 +03:00
shmyga 7c2436fb57 feat(rp5): add rp5 api 2026-09-03 19:47:42 +03:00
shmyga d6487facf4 test(yandexweather): add yandexweather tests 2026-09-01 22:36:54 +03:00
shmyga 86879a3219 ci(version): 0.6.1 2026-08-30 16:04:58 +03:00
shmyga aa0d5a7a41 feat(gismeteo): change gismeteo values merging mode 2026-08-30 16:04:43 +03:00
22 changed files with 1407 additions and 21 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ caches.set_config(
{ {
"default": { "default": {
"cache": "aiocache.SimpleMemoryCache", "cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.StringSerializer"}, "serializer": {"class": "aiocache.serializers.PickleSerializer"},
}, },
"redis": { "redis": {
"cache": "aiocache.RedisCache", "cache": "aiocache.RedisCache",
+2
View File
@@ -7,6 +7,7 @@ from gallery.easel import build_app
from gallery.painting.gismeteo.api import GismeteoApi from gallery.painting.gismeteo.api import GismeteoApi
from gallery.painting.matchtv.api import MatchTvApi from gallery.painting.matchtv.api import MatchTvApi
from gallery.painting.openweather.api import OpenWeatherApi from gallery.painting.openweather.api import OpenWeatherApi
from gallery.painting.rp5.api import RP5WeatherApi
from gallery.painting.yandextv.api import YandexTvApi from gallery.painting.yandextv.api import YandexTvApi
from gallery.painting.yandexweather.api import YandexWeatherApi from gallery.painting.yandexweather.api import YandexWeatherApi
from gallery.sketch.bundle import ApiBundle from gallery.sketch.bundle import ApiBundle
@@ -20,6 +21,7 @@ api = ApiBundle(
CachedWeatherApi(GismeteoApi()), CachedWeatherApi(GismeteoApi()),
CachedWeatherApi(OpenWeatherApi()), CachedWeatherApi(OpenWeatherApi()),
CachedWeatherApi(YandexWeatherApi()), CachedWeatherApi(YandexWeatherApi()),
CachedWeatherApi(RP5WeatherApi()),
] ]
) )
app = build_app(api) app = build_app(api)
+3 -1
View File
@@ -41,7 +41,9 @@ class GismeteoApi(WeatherApi):
# group values by 3 hours # group values by 3 hours
if len(values) > 8: if len(values) > 8:
n = 3 n = 3
values = [merge_weather_values(values[i].date, values[i : i + n]) for i in range(0, len(values), n)] values = [
merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n)
]
return WeatherResponse( return WeatherResponse(
location=location or "n/a", location=location or "n/a",
date=date, date=date,
View File
+103
View File
@@ -0,0 +1,103 @@
import datetime
import logging
import aiohttp
from aiocache import cached
from bs4 import BeautifulSoup
from gallery.sketch.source import ApiSource
from gallery.sketch.weather.api import WeatherApi
from gallery.sketch.weather.model import (
Location,
WeatherResponse,
)
from gallery.sketch.weather.util import merge_weather_values
from gallery.util import TimeUnit
from .parser import parse_page
logger = logging.getLogger("rp5")
HEADERS: dict[str, str] = {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Accept-Language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,uk;q=0.6",
"Connection": "keep-alive",
"Host": "rp5.ru",
"Referer": "https://rp5.ru/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"User-Agent": ApiSource.DEFAULT_USER_AGENT,
"X-Requested-With": "XMLHttpRequest",
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Linux"',
}
class RP5WeatherApi(WeatherApi):
PROVIDER = "rp5"
def _build_source(self) -> ApiSource:
return ApiSource(
"https://rp5.ru",
headers=HEADERS,
)
async def find_locations(self, query: str) -> list[Location]:
endpoint = "docs/search/ru"
data = aiohttp.FormData({"searchStr": query})
response = await self.source.request(endpoint, method="POST", data=data)
soup = BeautifulSoup(response, features="html.parser")
table = soup.select_one(".searchResults")
result = []
for row in table.select("tr")[1:]:
cells = row.select("td")
name = cells[0].text.strip()
location_id = cells[0].select_one("a").attrs["href"].lstrip("/")
country = cells[1].text.strip()
district = cells[2].text.strip()
subdistrict = cells[3].text.strip()
result.append(
Location(
id=location_id,
name=name,
provider=self.provider,
lat=0,
lon=0,
country=country,
country_code="ru", # TODO: contry_code from country
district=district,
subdistrict=subdistrict,
)
)
return result
@cached(
key_builder=lambda fun, self, location_id: f"api.weather.{self.provider}.source.{location_id}.forecast",
alias="redis",
ttl=TimeUnit.HOUR,
)
async def _get_location_forecast(self, location_id: str) -> list[WeatherResponse]:
endpoint = f"{location_id}"
content = await self.source.request(endpoint)
soup = BeautifulSoup(content, features="html.parser")
return parse_page(soup)
async def get_day(self, location_id: str, date: datetime.date) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
for response in forecast:
if response.date == date:
return response
raise ValueError(f"Unsupported date: {date}")
async def get_days(self, location_id: str, days: int) -> WeatherResponse:
forecast = await self._get_location_forecast(location_id)
values = [merge_weather_values(item.date, item.values) for item in forecast]
return WeatherResponse(
location=forecast[0].location,
date=datetime.date.today(),
period="days",
values=values,
)
+108
View File
@@ -0,0 +1,108 @@
import re
import dateparser
from bs4 import BeautifulSoup, Tag
from gallery.sketch.weather.model import (
Cloudness,
Precipitation,
WeatherResponse,
WeatherValue,
)
from gallery.sketch.weather.parser import ParseError, parse_wind_direction
from gallery.sketch.weather.util import build_weather_value
CLOUDNESS_MAP = {
"Ясно": Cloudness.CLEAR,
"Малооблачно": Cloudness.PARTLY_CLOUDY,
"Облачно с прояснениями": Cloudness.PARTLY_CLOUDY,
"Переменная облачность": Cloudness.PARTLY_CLOUDY,
"Небольшая облачность": Cloudness.CLOUDY,
"Облачно": Cloudness.CLOUDY,
"Значительная облачность": Cloudness.MAINLY_CLOUDY,
"Пасмурная погода": Cloudness.MAINLY_CLOUDY,
}
PRECIPITATION_MAP = {
"Явления погоды отсутствуют": Precipitation.NO,
"Cлабый дождь": Precipitation.SMALL_RAIN,
}
def _parse_wind_speed(cell: Tag) -> int:
element = cell.select_one(".wv_0")
if element:
return int(element.text.strip() or "0")
return 0
# 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("<b>(.*?)</b>").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_speed = _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())
return result
+27 -8
View File
@@ -1,5 +1,6 @@
import datetime
import logging import logging
from typing import Literal from typing import Any, Literal
import aiohttp import aiohttp
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
@@ -9,6 +10,7 @@ logger = logging.getLogger("source")
RequestMode = Literal["base", "browser"] RequestMode = Literal["base", "browser"]
DEFAULT_REQUEST_MODE: RequestMode = "base" DEFAULT_REQUEST_MODE: RequestMode = "base"
DEFAULT_METHOD = "GET"
class ApiSource: class ApiSource:
@@ -31,30 +33,47 @@ class ApiSource:
self._cookies = cookies self._cookies = cookies
self._headers = headers self._headers = headers
async def _base_request(self, url: str) -> str: async def _base_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
headers = {"User-Agent": self._user_agent, **(self._headers or {})} headers = {"User-Agent": self._user_agent, **(self._headers or {})}
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
headers=headers, headers=headers,
cookies=self._cookies, cookies=self._cookies,
raise_for_status=True, raise_for_status=True,
) as session: ) as session:
async with session.request("GET", url, timeout=self._timeout) as response: async with session.request(method, url, timeout=self._timeout, data=data) as response:
return await response.text() return await response.text()
async def _browser_request(self, url: str) -> str: async def _browser_request(
self,
url: str,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
async with async_playwright() as p: async with async_playwright() as p:
browser = await p.chromium.launch() browser = await p.chromium.launch()
page = await browser.new_page() page = await browser.new_page()
await page.goto(url, wait_until="networkidle") await page.goto(url, timeout=datetime.timedelta(seconds=self._timeout), wait_until="networkidle")
content = await page.content() content = await page.content()
await browser.close() await browser.close()
return content return content
async def request(self, endpoint: str, /, mode: RequestMode = DEFAULT_REQUEST_MODE) -> str: async def request(
self,
endpoint: str,
/,
mode: RequestMode = DEFAULT_REQUEST_MODE,
method: str = DEFAULT_METHOD,
data: Any | None = None,
) -> str:
if endpoint.startswith("https:"): if endpoint.startswith("https:"):
url = endpoint url = endpoint
else: else:
url = f"{self._base_url}/{endpoint}" url = f"{self._base_url}/{endpoint}"
logger.info(url) logger.info(url)
method = getattr(self, f"_{mode}_request") fun = getattr(self, f"_{mode}_request")
return await method(url) return await fun(url, method, data)
+2 -1
View File
@@ -13,6 +13,7 @@ WIND_DIRECTION_MAP: dict[str, WindDirection] = {
"—": WindDirection.CALM, "—": WindDirection.CALM,
"": WindDirection.CALM, "": WindDirection.CALM,
"штиль": WindDirection.CALM, "штиль": WindDirection.CALM,
"штл": WindDirection.CALM,
"с": WindDirection.N, "с": WindDirection.N,
"св": WindDirection.NE, "св": WindDirection.NE,
"в": WindDirection.E, "в": WindDirection.E,
@@ -25,4 +26,4 @@ WIND_DIRECTION_MAP: dict[str, WindDirection] = {
def parse_wind_direction(value: str) -> WindDirectionDeg: def parse_wind_direction(value: str) -> WindDirectionDeg:
return WindDirectionDeg.from_direction(WIND_DIRECTION_MAP[value.lower()]) return WindDirectionDeg.from_direction(WIND_DIRECTION_MAP[value.replace("-", "").lower()])
+20 -3
View File
@@ -1,8 +1,11 @@
import datetime import datetime
import statistics import statistics
from typing import Literal
from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg from .model import Cloudness, Precipitation, Sky, WeatherValue, WindDirectionDeg
MergeMode = Literal["interval", "average"]
def build_weather_value(date: datetime.datetime) -> WeatherValue: def build_weather_value(date: datetime.datetime) -> WeatherValue:
return WeatherValue( return WeatherValue(
@@ -23,7 +26,21 @@ def build_weather_value(date: datetime.datetime) -> WeatherValue:
) )
def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) -> WeatherValue: def _merge_values(values: list[int], mode: MergeMode) -> list[int]:
if mode == "interval":
if (max_value := max(values)) == (min_value := min(values)):
return [max_value]
else:
return [max_value, min_value]
else:
return [round(sum(values) / len(values))]
def merge_weather_values(
date: datetime.datetime,
values: list[WeatherValue],
mode: MergeMode = "interval",
) -> WeatherValue:
result = build_weather_value(date) result = build_weather_value(date)
temperatures = [] temperatures = []
pressures = [] pressures = []
@@ -44,8 +61,8 @@ def merge_weather_values(date: datetime.datetime, values: list[WeatherValue]) ->
cloudnesses.append(value.sky.cloudness) cloudnesses.append(value.sky.cloudness)
precipitations.append(value.sky.precipitation) precipitations.append(value.sky.precipitation)
precipitation += value.precipitation precipitation += value.precipitation
result.temperature = [max(temperatures), min(temperatures)] result.temperature = _merge_values(temperatures, mode)
result.pressure = [max(pressures), min(pressures)] result.pressure = _merge_values(pressures, mode)
result.humidity = round(statistics.mean(humidities)) result.humidity = round(statistics.mean(humidities))
result.wind_speed = round(statistics.mean(wind_speeds)) result.wind_speed = round(statistics.mean(wind_speeds))
result.wind_gust = round(statistics.mean(wind_gusts)) result.wind_gust = round(statistics.mean(wind_gusts))
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "gallery" name = "gallery"
description = "" description = ""
version = "0.6.0" version = "0.7.0"
readme = "README.md" readme = "README.md"
authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }] authors = [{ name = "shmyga", email = "shmyga.z@gmail.com" }]
requires-python = '>=3.14,<4.0' requires-python = '>=3.14,<4.0'
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.6.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery", "name": "gallery",
"version": "0.6.0", "version": "0.7.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gallery", "name": "gallery",
"version": "0.6.0", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "webpack --mode production", "build": "webpack --mode production",
+1 -1
View File
@@ -62,6 +62,7 @@ class WeatherLocationElement extends HTMLElement {
<a href="${this.location?.provider}/${this.location?.id}" <a href="${this.location?.provider}/${this.location?.id}"
class="d-flex justify-content-between align-items-start text-decoration-none"> class="d-flex justify-content-between align-items-start text-decoration-none">
<span> <span>
<span class="badge me-1 text-bg-secondary">${this.location?.provider}</span>
<span class="fi fi-${this.location?.country_code} me-1"></span> <span class="fi fi-${this.location?.country_code} me-1"></span>
<span class="text-primary">${this.location?.name}</span> <span class="text-primary">${this.location?.name}</span>
<span class="small ms-1 text-secondary"> <span class="small ms-1 text-secondary">
@@ -69,7 +70,6 @@ class WeatherLocationElement extends HTMLElement {
</span> </span>
</span> </span>
<span> <span>
<span class="badge text-bg-secondary">${this.location?.provider}</span>
<span class="text-danger ms-2" data-remove> <span class="text-danger ms-2" data-remove>
&#x2715; &#x2715;
</span> </span>
+2 -2
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from typing import TypeVar from typing import TypeVar
from gallery.sketch.api import Api, S from gallery.sketch.api import Api, S
from gallery.sketch.source import DEFAULT_REQUEST_MODE, ApiSource, RequestMode from gallery.sketch.source import ApiSource
class MockSource(ApiSource): class MockSource(ApiSource):
@@ -12,7 +12,7 @@ class MockSource(ApiSource):
self._path = path self._path = path
self._mapping = mapping self._mapping = mapping
async def request(self, endpoint: str, /, mode: RequestMode = DEFAULT_REQUEST_MODE) -> str: async def request(self, endpoint: str, /, **kwargs) -> str:
for pattern, filename in self._mapping.items(): for pattern, filename in self._mapping.items():
if pattern in endpoint: if pattern in endpoint:
return (self._path / filename).read_text() return (self._path / filename).read_text()
+17
View File
@@ -1,3 +1,20 @@
from os import environ from os import environ
from aiocache import caches
import gallery # pylint:disable=unused-import
environ.setdefault("OPENWEATHER_KEY", "test") environ.setdefault("OPENWEATHER_KEY", "test")
caches.set_config(
{
"default": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
},
"redis": {
"cache": "aiocache.SimpleMemoryCache",
"serializer": {"class": "aiocache.serializers.PickleSerializer"},
},
}
)
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from tests.common.mock import MockSource
RP5_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"search": "search.html",
"test": "weather.html",
},
)
+379
View File
@@ -0,0 +1,379 @@
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml"><head>
<title>Результат поиска</title>
<script type="application/ld+json">
{
"dateModified": "2026-09-01"
}
</script>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta name="description" content="Результат поиска. Результаты поиска.">
<meta name="keywords" content="результат поиска, результаты поиска">
<meta name="viewport" content="height=device-height, user-scalable=yes">
<meta name="referrer" content="origin">
<meta name="msapplication-config" content="/browserconfig.xml">
<meta name="msapplication-TileColor" content="#005eb5">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_57x57.png" sizes="57x57">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_60x60.png" sizes="60x60">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_72x72.png" sizes="72x72">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_76x76.png" sizes="76x76">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_114x114.png" sizes="114x114">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_120x120.png" sizes="120x120">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_152x152.png" sizes="152x152">
<link rel="apple-touch-icon" type="image/png" href="/images/favicons/ios/ic_ios_180x180.png" sizes="180x180">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/192x192.png" sizes="192x192">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/144x144.png" sizes="144x144">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/128x128.png" sizes="128x128">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/96x96.png" sizes="96x96">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/72x72.png" sizes="72x72">
<link rel="shortcut icon" type="image/png" href="/images/favicons/android_2/48x48.png" sizes="48x48">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/css/mainStyle.css?q=1.0182" type="text/css">
<link rel="stylesheet" href="/css/sprites.css?q=1.0182" type="text/css">
<link rel="stylesheet" href="/css/jquery.autocomplete.css?q=1.0182" type="text/css">
<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false&amp;key=AIzaSyAvCiizuGON7hMCTMa79Zf7d_4k56cCJ_0"></script>
<style media="print">
</style>
<script type="text/javascript" src="/scripts/jquery-1.6.4.min.js"></script>
<script type="text/javascript" src="/scripts/jquery.autocomplete.01.cr.js"></script>
<script type="text/javascript" src="/scripts/browser.js"></script>
<script type="text/javascript" src="/scripts/googlemap.js"></script>
<script type="text/javascript" src="/scripts/main.022.cr.js?q=1.052"></script>
<script type="text/javascript" src="/scripts/rp5hint.js"></script>
<script type="text/javascript" src="/scripts/rcs.js"></script>
<script type="text/javascript" src="/scripts/infobubble.001.js"></script>
<script type="text/javascript" src="/scripts/maps.008.js"></script>
<script type="text/javascript" src="/scripts/geo_vidget.js"></script>
<!--<script type="text/javascript" src="//static.criteo.net/js/ld/publishertag.js"></script>-->
<script>window.yaContextCb=window.yaContextCb||[]</script>
<script src="https://yandex.ru/ads/system/context.js" [^]="" async=""></script><script type="text/javascript" language="JavaScript">cRole = ""; langId = 2; cSearch = "Название города или села";</script>
<script type="text/javascript">
// локальное время
var gmtAdd = 10800000;
var sumHour = 0;
var cLocalTime = 'Местное время';
// установки времени
var Dphp = 1788293743;
var gmtServ = 0;
var Djs = Math.round(((D=new Date()).getTime()-D.getTimezoneOffset()*60000)/1000);
// дельта во времени в сек.
var tDelta = Dphp - Djs;
</script>
<style>
#h8c34381aaf77ce0fb634a3b6884f16046b9fd201 {display: inline-block; width: 126px; margin: 79px 0 0 8px;}
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {clear: both;}
@media print {
#ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7 {margin-top: 20px; background: #fff;}
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {display: none !important;}
}
#hf420d4b9b1efdbbe6ef03d062299d020ccae86d6 {float: left; font-size: 12px; display: inline; margin: 5px 0 11px 0;}
</style>
<script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/common.js"></script><script type="text/javascript" charset="UTF-8" src="https://maps.googleapis.com/maps-api-v3/api/js/66/2d/util.js"></script></head>
<body>
<div id="back-wrapper" style="width: 1670px; height: 688px;"></div>
<div id="message-outside" class="message-outside">
<div id="message-outside-content" class="message-outside-content"></div>
<div class="button-container">
<input type="button" class="button-blue" value="OK" onclick="displayBlocks('#message-outside','','',true); return false;">
</div>
</div>
<div id="map-screen-container">
<div id="map-screen-close"></div>
<div id="map-screen"></div>
</div>
<div id="wrapper">
<div id="header-wrapper" class="noprint">
<div id="header">
<table>
<tbody><tr>
<td rowspan="3" style="width: 180px;" class="td-top">
<div id="logo">
<a href="/Погода_в_мире" title=""><img src="/images/ru/logo.png" title="" alt="Погода_в_мире" style="margin: 4px 0 0 -17px"></a>
</div>
</td>
<td class="td-top">
<div id="topMenuContent" style="text-align: left">
<a id="mobile_link" class="pointer" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">Мобильная версия</a>|
<a href="/Погода_в_мире">Главная</a>|
<a href="/docs/about/ru">О сайте</a>|
<a href="/docs/faq/ru">Частые вопросы (FAQ)</a>|
<a href="/docs/contact/ru">Контакты</a>
</div>
</td>
</tr>
<tr>
<td style="height: 31px; vertical-align: middle"><div id="countries"><a href="/Погода_в_Беларуси">Беларусь</a>&nbsp;<a href="/Погода_в_Литве">Литва</a>&nbsp;<a href="/Погода_в_России">Россия</a>&nbsp;<a href="/Погода_в_Украине">Украина</a>&nbsp;<a href="/Погода_в_мире">Все страны</a></div>
<div class="rightNavi noprint">
<div class="intoNavi rss">
<span class="verticalBottom" id="RSS_top">RSS&nbsp; <a href="http://rp5.ru/rss/24530/ru" id="rss_link"><div class="iconrss"></div></a>&nbsp;</span>
</div>
</div>
</td>
</tr>
<tr>
<td style="padding-top: 7px; vertical-align: top">
<div id="telephone" class="buttonSummary" style="margin: 2px 2px 0 0;" onclick="getUrl('http://m.rp5.ru/Погода_в_Змиевке,_Луганская_Народная_Республика', 'desktop'); return false;">
<table><tbody><tr><td style="height: 35px; padding: 0 34px 0 7px; vertical-align: middle; ">
<a class="inDd2Summary inDd2"><span>Мобильная версия</span></a>
</td></tr></tbody></table>
</div>
<div id="informerC" class="buttonSummary">
<div id="appButton" class="inDdSummary" style="font-size: 12px;">
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Приложения</td></tr></tbody></table>
</div>
<div id="appMenu" style="display: none;">
<ul>
<li onclick="top.location.href='/docs/informer/ru'">Информеры</li>
<li onclick="top.location.href='/docs/xml/ru?id=24530'">XML</li>
<li onclick="top.location.href='/docs/sgate/ru'">API частных метеостанций</li>
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
<li onclick="top.location.href='/docs/gadget/ru'">Гаджет для WINDOWS</li>
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7"></div>
<li onclick="window.open('https://play.google.com/store/apps/details?id=ru.rp5.rp5weatherhorizontal', '_blank')">Приложение для Android</li>
<!--<li onclick="top.location.href='/wduck/source/new/app-release-v22.apk'">*.apk-файл</li>-->
<div style="margin: 2px 0 3px 0; border-top: 1px dashed #a7a7a7;"></div>
<li onclick="top.location.href='/Дневник_погоды_в_Сватово'">Дневник погоды для школьников</li>
</ul>
</div>
</div>
<div class="ddListSummary">
<span>
<div class="ddList2">
<div id="unitsButton" class="inDdSummary">
<table><tbody><tr><td style="height: 35px; vertical-align: middle">Единицы измерений</td></tr></tbody></table>
</div>
<div id="unitsMenu" style="text-align: left; display: none;">
<form name="fValues" action="">
<table id="tValues" style="vertical-align: top;">
<tbody><tr>
<td>
<div>Облачность:</div>
<div>
<input type="radio" id="r_cc1" class="radioUnits" name="r_cc" title="проценты (%)" onclick="if(this.checked) {chValue('cc',0,0);}" checked="checked">
<label for="r_cc1">проценты (%)</label>
</div>
<div>
<input type="radio" id="r_cc2" class="radioUnits" name="r_cc" title="1/10" onclick="if(this.checked) {chValue('cc',1,0);}">
<label for="r_cc2">1/10</label>
</div>
<div>
<input type="radio" id="r_cc3" class="radioUnits" name="r_cc" title="окты" onclick="if(this.checked) {chValue('cc',2,0);}">
<label for="r_cc3">окты</label>
</div>
</td>
<td>
<div>Осадки:</div>
<div>
<input type="radio" id="r_pr1" class="radioUnits" name="r_pr" title="миллиметры (мм)" onclick="if(this.checked) {chValue('pr',0,0);}" checked="checked">
<label for="r_pr1">миллиметры (мм) / сантиметры (см)</label>
</div>
<div>
<input type="radio" id="r_pr2" class="radioUnits" name="r_pr" title="дюймы" onclick="if(this.checked) {chValue('pr',1,0);}">
<label for="r_pr2">дюймы</label>
</div>
</td>
<td>
<div>Температура:</div>
<div>
<input type="radio" id="r_t1" class="radioUnits" name="r_t" title="градусы Цельсия (°C)" onclick="if(this.checked) {chValue('t',0,0);}" checked="checked">
<label for="r_t1">градусы Цельсия (°C)</label>
</div>
<div>
<input type="radio" id="r_t2" class="radioUnits" name="r_t" title="градусы Фаренгейта (°F)" onclick="if(this.checked) {chValue('t',1,0);}">
<label for="r_t2">градусы Фаренгейта (°F)</label>
</div>
</td>
</tr>
<tr>
<td colspan="2">
<div>Давление:</div>
<div>
<input type="radio" id="r_p1" class="radioUnits" name="r_p" title="миллиметры ртутного столба (мм рт. ст.)" onclick="if(this.checked) {chValue('p',0,0);}" checked="checked">
<label for="r_p1">миллиметры ртутного столба (мм рт. ст.)</label>
</div>
<div>
<input type="radio" id="r_p2" class="radioUnits" name="r_p" title="дюймы ртутного столба (дюймы рт. ст.)" onclick="if(this.checked) {chValue('p',1,0);}">
<label for="r_p2">дюймы ртутного столба (дюймы рт. ст.)</label>
</div>
<div>
<input type="radio" id="r_p3" class="radioUnits" name="r_p" title="миллибары (мбар)" onclick="if(this.checked) {chValue('p',2,0);}">
<label for="r_p3">миллибары (мбар)</label>
</div>
<div>
<input type="radio" id="r_p4" class="radioUnits" name="r_p" title="гектопаскали (гПа)" onclick="if(this.checked) {chValue('p',3,0);}">
<label for="r_p3">гектопаскали (гПа)</label>
</div>
</td>
<td>
<div>Ветер:</div>
<div>
<input type="radio" id="r_wv1" class="radioUnits" name="r_wv" title="метры в секунду (м/с)" onclick="if(this.checked) {chValue('wv',0,0);}" checked="checked">
<label for="r_wv1">метры в секунду (м/с)</label>
</div>
<div>
<input type="radio" id="r_wv2" class="radioUnits" name="r_wv" title="километры в час (км/ч)" onclick="if(this.checked) {chValue('wv',1,0);}">
<label for="r_wv2">километры в час (км/ч)</label>
</div>
<div>
<input type="radio" id="r_wv3" class="radioUnits" name="r_wv" title="мили в час (мили/час)" onclick="if(this.checked) {chValue('wv',2,0);}">
<label for="r_wv3">мили в час (мили/час)</label>
</div>
<div>
<input type="radio" id="r_wv4" class="radioUnits" name="r_wv" title="узлы" onclick="if(this.checked) {chValue('wv',3,0);}">
<label for="r_wv4">узлы</label>
</div>
<div>
<input type="radio" id="r_wv5" class="radioUnits" name="r_wv" title="Бфрт (Бфрт)" onclick="if(this.checked) {chValue('wv',4,0);}">
<label for="r_wv5">баллы Бофорта (Бфрт)</label>
</div>
</td>
</tr>
<tr>
<td colspan="3">
<div style="margin-bottom: 9px; border-top: 1px dashed #a7a7a7"></div>
<div>Только для страниц «Архив погоды»</div>
<div>Горизонтальная дальность видимости (VV):</div>
<div>
<input type="radio" id="r_vv1" class="radioUnits" name="r_vv" title="километры" onclick="if(this.checked) {chValue('vv',0,0);}" checked="checked">
<label for="r_vv1">километры</label>
</div>
<div>
<input type="radio" id="r_vv2" class="radioUnits" name="r_vv" title="мили" onclick="if(this.checked) {chValue('vv',1,0);}">
<label for="r_vv2">мили</label>
</div>
<div>Высота основания самых низких облаков (H):</div>
<div>
<input type="radio" id="r_h1" class="radioUnits" name="r_h" title="метры" onclick="if(this.checked) {chValue('h',0,0);}" checked="checked">
<label for="r_h1">метры</label>
</div>
<div>
<input type="radio" id="r_h2" class="radioUnits" name="r_h" title="футы" onclick="if(this.checked) {chValue('h',1,0);}">
<label for="r_h2">футы</label>
</div>
<div>Высота снежного покрова (sss):</div>
<div>
<input type="radio" id="r_sss1" class="radioUnits" name="r_sss" title="сантиметры" onclick="if(this.checked) {chValue('sss',0,0);}" checked="checked">
<label for="r_sss1">сантиметры</label>
</div>
<div>
<input type="radio" id="r_sss2" class="radioUnits" name="r_sss" title="дюймы" onclick="if(this.checked) {chValue('sss',1,0);}">
<label for="r_sss2">дюймы</label>
</div>
</td>
</tr>
</tbody></table>
</form>
</div>
</div>
</span>
</div>
<div class="ddListSummary1">
<span>
<div class="ddList1">
<div id="langButton" class="inDdSummary">
<table><tbody><tr><td style="height: 35px; vertical-align: middle;">Language</td></tr></tbody></table>
</div>
<div id="langMenu" style="display: none;">
<ul></ul>
</div>
</div>
</span>
</div>
<div id="search">
<form name="fsearch" autocomplete="off" method="post" action="/docs/search/ru" onsubmit="searchConfirm();">
<input type="text" name="searchStr" id="searchStr" class="search ac_input" onfocus="clearTxt();" onblur="setTxt();" value="змиёвка" autocomplete="off">
<input type="button" id="searchButton" class="searchButton" onclick="searchConfirm();" value="">
<script type="text/javascript">setTxt();</script>
</form>
</div>
</td>
</tr>
</tbody></table>
</div>
</div>
<div id="content-wrapper">
<div id="content">
<div id="hf420d4b9b1efdbbe6ef03d062299d020ccae86d6">
<div class="intoLeftNavi">
<span class="verticalBottom">
<a href="/Погода_в_мире"><nobr>Все страны</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;<a href="/Погода_в_России"><nobr>Россия</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Луганской_Народной_Республике"><nobr>Луганская Народная Республика</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Сватовском_районе"><nobr>Сватовский район</nobr></a>&nbsp;<img src="/images/iconArrow.png" alt="" title="">&nbsp;
<a href="/Погода_в_Змиевке,_Луганская_Народная_Республика"><nobr>Змиевка</nobr></a>
</span>
</div>
</div>
<div id="ha8e4d0cfbc1ac49c913e422d81ab257c227f18a7"><div id="leftNavi" class="pageTitle"> <span class="verticalBottom"><h1>Результат поиска</h1></span></div><div class="clear"></div> <p class="stinfo"><b>Найдено населенных пунктов:</b> 3</p> <table cellspacing="0" cellpadding="0" class="searchResults"> <tbody><tr class="header"> <td> <div class="ImgLeft"></div> <div class="searchPointTitle"> <span class="innerTextHead">Населенный пункт</span> <div> </div></div></td> <td> <div class="searchTitles"> <span class="innerTextHead">Страна</span> </div> </td> <td> <div class="searchTitles"> <span class="innerTextHead">Регион</span> </div> </td> <td style="border-right: none;"> <div class="ImgRight"></div> <div class="searchTitles"> <span class="innerTextHead">Район</span> </div> </td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Луганская Народная Республика</span></td> <td><span class="innerTextResult">Сватовский район</span></td> </tr><tr class="srow1"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Орловская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Орловская область</span></td> <td><span class="innerTextResult">Свердловский район</span></td> </tr><tr class="srow0"> <td class="resBorder"><span class="innerTextResult"><a href="/Погода_в_Змиевке,_Херсонская_область">Змиевка</a></span></td> <td class="resBorder"><span class="innerTextResult">Россия</span></td> <td class="resBorder"><span class="innerTextResult">Херсонская область</span></td> <td><span class="innerTextResult">Бериславский район</span></td> </tr> </tbody></table>
</div>
</div>
<div class="clear"></div>
</div>
</div>
<div id="footer-wrapper" class="noprint">
<div id="footer">
<div id="footer-info">
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Показаны ранее:</b></div><span class="Ajax-PointID" id="6318">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+19</span></span>
<span class="t_1" style="display: none;"><span class="red">+66</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Орле,_Орловская_область">Орел</a></div><br><span class="Ajax-PointID" id="24530">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Змиевке,_Луганская_Народная_Республика">Змиевка</a></div><br>
</div>
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Ближайшие центры:</b></div><span class="Ajax-PointID" id="531">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Сватово,_Луганская_Народная_Республика">Сватово</a></div><br><span class="Ajax-PointID" id="720">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+15</span></span>
<span class="t_1" style="display: none;"><span class="red">+59</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Боровой,_Боровский_район">Боровая</a></div><br><span class="Ajax-PointID" id="735">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Купянске">Купянск</a></div><br><span class="Ajax-PointID" id="518">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Кременной,_Россия">Кременная</a></div><br>
</div>
<div class="footerMenu">
<div class="footer_title"><b style="font-size: 10pt;">Популярные:</b></div><span class="Ajax-PointID" id="488">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+13</span></span>
<span class="t_1" style="display: none;"><span class="red">+55</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Беловодске">Беловодск</a></div><br><span class="Ajax-PointID" id="520">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Луганске,_Луганская_Народная_Республика">Луганск</a></div><br><span class="Ajax-PointID" id="529">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+21</span></span>
<span class="t_1" style="display: none;"><span class="red">+70</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Ровеньках,_Луганская_Народная_Республика">Ровеньки</a></div><br><span class="Ajax-PointID" id="536">
<div class="MainTemp">
<span class="t_0" style="display: inline;"><span class="red">+17</span></span>
<span class="t_1" style="display: none;"><span class="red">+63</span></span>
</div></span> <div class="footer_links"><a href="/Погода_в_Старобельске">Старобельск</a></div><br>
</div>
</div>
<div id="footer-content">
<div id="footer-copyright">Copyright © ООО «Расписание Погоды», 2004-2026</div>
</div>
</div>
</div>
<!-- old code -->
</body></html>
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from tests.common.mock import MockSource
YANDEXWEATHER_MOCK_SOURCE = MockSource(
Path(__file__).parent,
{
"details": "details.html",
"suggest": "suggest.json",
},
)
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
import datetime
import pytest
from gallery.painting.rp5.api import RP5WeatherApi
from tests.common.mock import create_mock_api
from tests.data.rp5 import RP5_MOCK_SOURCE
@pytest.fixture(name="rp5_api", scope="module")
def rp5_api_fixture() -> RP5WeatherApi:
return create_mock_api(RP5WeatherApi, RP5_MOCK_SOURCE)
async def test_search(rp5_api: RP5WeatherApi):
result = await rp5_api.find_locations("test")
assert len(result) == 3
async def test_day(rp5_api: RP5WeatherApi):
result = await rp5_api.get_day("test", datetime.date(2026, 9, 2))
assert len(result.values) == 4
async def test_days(rp5_api: RP5WeatherApi):
result = await rp5_api.get_days("test", 10)
assert len(result.values) == 7
+27
View File
@@ -0,0 +1,27 @@
import datetime
import pytest
from gallery.painting.yandexweather.api import YandexWeatherApi
from tests.common.mock import create_mock_api
from tests.data.yandexweather import YANDEXWEATHER_MOCK_SOURCE
@pytest.fixture(name="yandexweather_api", scope="module")
def yandexweather_api_fixture() -> YandexWeatherApi:
return create_mock_api(YandexWeatherApi, YANDEXWEATHER_MOCK_SOURCE)
async def test_search(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.find_locations("test")
assert len(result) == 5
async def test_day(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.get_day("test", datetime.date.today())
assert len(result.values) == 4
async def test_days(yandexweather_api: YandexWeatherApi):
result = await yandexweather_api.get_days("test", 10)
assert len(result.values) == 10