Skip to content
Open

Task #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ LOGGING_LEVEL=DEBUG

# ключи для доступа к API
# https://apilayer.com/marketplace/geo-api
API_KEY_APILAYER=
API_KEY_APILAYER=ERNjwL3lfk38GxU3LlyH9bqmM3oqb2c2
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Настоящие ключи в .env.sample не добавляйте, если только это не пример ;)

# https://openweathermap.org/price#weather
API_KEY_OPENWEATHER=
API_KEY_OPENWEATHER=1c9fddce098864eb1395202eed9aabbc
# https://newsapi.org/account
API_KEY_NEWSAPI=04fc313c0d2e48abbe6987bacf7eab66

# время актуальности данных о странах (в секундах)
CACHE_TTL_COUNTRY=31_536_000
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ Install the appropriate software:
To access the API, visit the appropriate resources and obtain an access token:
- APILayer – Geography API (https://apilayer.com/marketplace/geo-api)
- OpenWeather – Weather Free Plan (https://openweathermap.org/price#weather)
- APINews - News API (https://newsapi.org)

Set received access tokens as environment variable values (in `.env` file):
- `API_KEY_APILAYER` – for APILayer access token
- `API_KEY_OPENWEATHER` – for OpenWeather access token
- `API_KEY_NEWSAPI` - for APINews access token

2. Build the container using Docker Compose:
```shell
Expand All @@ -62,6 +64,7 @@ Install the appropriate software:
- `CACHE_TTL_COUNTRY` (country data up-to-date time in seconds)
- `CACHE_TTL_CURRENCY_RATES` (currency rates data up-to-date time in seconds)
- `CACHE_TTL_WEATHER` (weather data up-to-date time in seconds)
- `CACHE_TTL_NEWS` (news data up-to-date time in seconds)

5. After collecting all the data, you can query the country information by executing the command:
```shell
Expand Down
3 changes: 3 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@

* APILayer — Geography API (https://apilayer.com/marketplace/geo-api)
* OpenWeather – Weather Free Plan (https://openweathermap.org/price#weather)
* APINews - News API (https://newsapi.org)

Задайте полученные токены доступа в качестве значений переменных окружения (в файле `.env`):

* `API_KEY_APILAYER` – для токена доступа к APILayer
* `API_KEY_OPENWEATHER` – для токена доступа к OpenWeather
* `API_KEY_NEWSAPI` - для токена доступа к APINews

2. Соберите Docker-контейнер с помощью Docker Compose:
.. code-block:: console
Expand Down Expand Up @@ -92,6 +94,7 @@
* `CACHE_TTL_COUNTRY` (время актуальности данных о странах)
* `CACHE_TTL_CURRENCY_RATES` (время актуальности данных о курсах валют)
* `CACHE_TTL_WEATHER` (время актуальности данных о погоде)
* `CACHE_TTL_NEWS` (время актуальности данных о новостях)

Значение для этих переменных указывается в секундах (они определяются в файле `.env`).

Expand Down
39 changes: 39 additions & 0 deletions src/clients/news.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Функции для взаимодействия с внешним новостным сервисом.
"""

from http import HTTPStatus
from typing import Optional

import aiohttp

from clients.base import BaseClient
from logger import trace_config
from settings import API_KEY_NEWSAPI, NEWS_COUNT


class NewsClient(BaseClient):
"""
Реализация функций для взаимодействия с внешним новостным сервисом.
"""

async def get_base_url(self) -> str:
return "https://newsapi.org/v2/everything"

async def _request(self, endpoint: str) -> Optional[dict]:
async with aiohttp.ClientSession(trace_configs=[trace_config]) as session:
async with session.get(endpoint) as response:
if response.status == HTTPStatus.OK:
return await response.json()

return None

async def get_news(self, location: str) -> Optional[dict]:
"""
Получение новостей по локации
:param location: город или страна
:return: данные о городе или стране
"""
return await self._request(
f"{await self.get_base_url()}?q={location}&apiKey={API_KEY_NEWSAPI}&pageSize={NEWS_COUNT}"
)
88 changes: 86 additions & 2 deletions src/collectors/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,21 @@
from clients.currency import CurrencyClient
from clients.weather import WeatherClient
from collectors.base import BaseCollector
from clients.news import NewsClient
from collectors.models import (
LocationDTO,
CountryDTO,
CurrencyRatesDTO,
CurrencyInfoDTO,
WeatherInfoDTO,
NewsInfoDTO,
)
from settings import (
MEDIA_PATH,
CACHE_TTL_COUNTRY,
CACHE_TTL_CURRENCY_RATES,
CACHE_TTL_WEATHER,
CACHE_TTL_NEWS,
)


Expand Down Expand Up @@ -103,6 +106,9 @@ async def read(cls) -> Optional[list[CountryDTO]]:
population=item["population"],
subregion=item["subregion"],
timezones=item["timezones"],
square=item["area"],
geographical_latitude=item["latitude"],
geographical_longitude=item["longitude"],
)
)

Expand Down Expand Up @@ -203,8 +209,8 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]:
"""
Чтение данных из кэша.

:param location:
:return:
:param location: Название локации
:return: None, если кэш пуст, WeatherInfoDTO иначе
"""

filename = f"{location.capital}_{location.alpha2code}".lower()
Expand All @@ -217,13 +223,90 @@ async def read(cls, location: LocationDTO) -> Optional[WeatherInfoDTO]:
temp=result["main"]["temp"],
pressure=result["main"]["pressure"],
humidity=result["main"]["humidity"],
visibility=result["visibility"],
wind_speed=result["wind"]["speed"],
description=result["weather"][0]["description"],
offset_seconds=result["timezone"],
timezone=result["timezone"],
)

return None


class NewsCollector(BaseCollector):
"""
Сбор информации о новостях.
"""

def __init__(self) -> None:
self.client = NewsClient()

@staticmethod
async def get_file_path(filename: str = "", **kwargs: Any) -> str:
return f"{MEDIA_PATH}/news/{filename}.json"

@staticmethod
async def get_cache_ttl() -> int:
return CACHE_TTL_NEWS

async def collect(
self, locations: FrozenSet[LocationDTO] = frozenset(), **kwargs: Any
) -> None:
"""
Сбор информации о новостях.
:param locations: Страна или город, для сбора новостей.
:return:
"""

target_dir_path = f"{MEDIA_PATH}/news"
# если целевой директории еще не существует, то она создается
if not await aiofiles.os.path.exists(target_dir_path):
await aiofiles.os.mkdir(target_dir_path)

for location in locations:
filename = f"{location.capital}_{location.alpha2code}".lower()
if not await self.cache_invalid(filename=filename):
continue

# если кэш уже невалиден, то актуализируем его
result = await self.client.get_news(location.capital)
if not result:
continue

result_str = json.dumps(result)
async with aiofiles.open(
await self.get_file_path(filename), mode="w"
) as file:
await file.write(result_str)

@classmethod
async def read(cls, location: LocationDTO) -> list[NewsInfoDTO] | None:
"""
Чтение данных из кэша.
:param location: Страна и/или город для которых нужно получить новости.
:return:
"""

filename = f"{location.capital}_{location.alpha2code}".lower()
async with aiofiles.open(await cls.get_file_path(filename), mode="r") as file:
content = await file.read()

result = json.loads(content)
if not result:
return None
return [
NewsInfoDTO(
source=article["source"]["name"],
title=article["title"],
description=article["description"],
url=article["url"],
published_at=article["publishedAt"],
content=article["content"],
)
for article in result["articles"]
]


class Collectors:
@staticmethod
async def gather() -> tuple:
Expand All @@ -238,6 +321,7 @@ def collect() -> None:
try:
results = loop.run_until_complete(Collectors.gather())
loop.run_until_complete(WeatherCollector().collect(results[1]))
loop.run_until_complete(NewsCollector().collect(results[1]))
loop.run_until_complete(loop.shutdown_asyncgens())

finally:
Expand Down
33 changes: 33 additions & 0 deletions src/collectors/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from pydantic import Field, BaseModel
from datetime import datetime


class HashableBaseModel(BaseModel):
Expand Down Expand Up @@ -93,6 +94,9 @@ class CountryDTO(BaseModel):
timezones=[
"UTC+02:00",
],
square=50453.1,
geographical_latitude=45.23,
geographical_longitude=15.35,
)
"""

Expand All @@ -106,6 +110,9 @@ class CountryDTO(BaseModel):
population: int
subregion: str
timezones: list[str]
square: float | None
geographical_latitude: float | None
geographical_longitude: float | None


class CurrencyRatesDTO(BaseModel):
Expand Down Expand Up @@ -143,11 +150,36 @@ class WeatherInfoDTO(BaseModel):
)
"""

offset_seconds: int
timezone: int
temp: float
pressure: int
humidity: int
wind_speed: float
description: str
visibility: int


class NewsInfoDTO(BaseModel):
"""
Модель данных новостей.
.. code-block::
NewsDTO(
source="CNN",
title="The latest news about the coronavirus pandemic",
description="The latest news about the coronavirus pandemic",
url="https://www.cnn.com/world/live-news/coronavirus-pandemic-09-14-21-intl/index.html",
published_at="2021-09-14T20:00:00Z",
content="The latest news about the coronavirus pandemic"
)
"""

source: str
title: str
description: str
url: str
published_at: datetime
content: str


class LocationInfoDTO(BaseModel):
Expand Down Expand Up @@ -201,3 +233,4 @@ class LocationInfoDTO(BaseModel):
location: CountryDTO
weather: WeatherInfoDTO
currency_rates: dict[str, float]
news: list[NewsInfoDTO] | None
16 changes: 15 additions & 1 deletion src/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
from collectors.collector import (
CountryCollector,
CurrencyRatesCollector,
NewsCollector,
WeatherCollector,
)
from collectors.models import (
CountryDTO,
CurrencyInfoDTO,
LocationDTO,
LocationInfoDTO,
NewsInfoDTO,
WeatherInfoDTO,
)

Expand All @@ -33,16 +35,20 @@ async def find(self, location: str) -> Optional[LocationInfoDTO]:
"""

country = await self.find_country(location)
location_dto = LocationDTO(
capital=country.capital, alpha2code=country.alpha2code
)
if country:
weather = await self.get_weather(
LocationDTO(capital=country.capital, alpha2code=country.alpha2code)
)
currency_rates = await self.get_currency_rates(country.currencies)

news = await self.get_news(location_dto)
return LocationInfoDTO(
location=country,
weather=weather,
currency_rates=currency_rates,
news=news,
)

return None
Expand Down Expand Up @@ -118,3 +124,11 @@ async def _match(search: str, country: CountryDTO) -> bool:
return True

return False

async def get_news(self, location: LocationDTO) -> list[NewsInfoDTO] | None:
"""
Получение новостей по стране.
:param location:
:return:
"""
return await NewsCollector.read(location=location)
Loading