diff --git a/package.json b/package.json index 04fa0d8..0da6d7b 100644 --- a/package.json +++ b/package.json @@ -33,5 +33,8 @@ "typescript-eslint": "^8.47.0", "vite": "^7.2.2", "vite-plugin-devtools-json": "^1.0.0" + }, + "dependencies": { + "node-cache": "^5.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a146fb..79f13ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + node-cache: + specifier: ^5.1.2 + version: 5.1.2 devDependencies: '@eslint/compat': specifier: ^1.4.0 @@ -679,6 +683,10 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1034,6 +1042,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-cache@5.1.2: + resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} + engines: {node: '>= 8.0.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1801,6 +1813,8 @@ snapshots: dependencies: clsx: 2.1.1 + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -2133,6 +2147,10 @@ snapshots: natural-compare@1.4.0: {} + node-cache@5.1.2: + dependencies: + clone: 2.1.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 diff --git a/src/lib/clients/weather.ts b/src/lib/clients/weather.ts new file mode 100644 index 0000000..692db58 --- /dev/null +++ b/src/lib/clients/weather.ts @@ -0,0 +1,217 @@ +import NodeCache from "node-cache"; +import crypto from "node:crypto"; + +/** + * User agent. Used to request the API. + */ +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"; + +/** + * Secret key for a API token. + */ +const TOKEN_SECRET = "gravisfrontem"; + +/** + * Response cache. + */ +const cache = new NodeCache({ + // NOTE: 10 minutes + stdTTL: 600, +}); + +/** + * Weather response. + */ +export interface WeatherResponse { + /** + * Current Unix timestamp (seconds). + */ + now: number; + + /** + * Current time in ISO 8601 format. + */ + now_dt: string; + + /** + * Information about the requested location. + */ + info: { + /** + * Latitude of the location. + */ + lat: number; + + /** + * Longitude of the location. + */ + lon: number; + + /** + * Public URL to the Yandex.Weather page. + */ + url: string; + }; + + /** + * Current factual weather information. + */ + fact: { + /** + * Air temperature in °C. + */ + temp: number; + + /** + * "Feels like" temperature in °C. + */ + feels_like: number; + + /** + * Weather condition string (e.g., "clear", "rain"). + */ + condition: string; + + /** + * Icon code used to build an icon URL. + */ + icon?: string; + + /** + * Wind speed in m/s. + */ + wind_speed?: number; + + /** + * Relative humidity (%). + */ + humidity?: number; + + /** + * Atmospheric pressure in mmHg. + */ + pressure_mm?: number; + }; + + /** + * Forecast data for the current date. + */ + forecast: { + /** + * Date of the forecast (YYYY-MM-DD). + */ + date: string; + + /** + * Unix timestamp for the forecast date. + */ + date_ts: number; + + /** + * Local sunrise time (HH:MM), if available. + */ + sunrise?: string; + + /** + * Local sunset time (HH:MM), if available. + */ + sunset?: string; + + /** + * Weather forecast split by parts of the day. + */ + parts: Array<{ + /** + * Name of the day part ("night", "morning", "day", "evening"). + */ + part_name: string; + + /** + * Minimum temperature for this part of day (°C). + */ + temp_min?: number; + + /** + * Maximum temperature for this part of day (°C). + */ + temp_max?: number; + + /** + * Weather condition string for this period. + */ + condition?: string; + + /** + * Icon code for this part of the day. + */ + icon?: string; + + /** + * Wind speed in m/s. + */ + wind_speed?: number; + + /** + * Relative humidity (%). + */ + humidity?: number; + + /** + * Precipitation amount in mm. + */ + prec_mm?: number; + }>; + }; +} + +function generateAuthorizationHeaders() { + const timestamp = Date.now(); + + return { + // NOTE: no one will pay ~$7k/year to have a weather on their personal website + 'x-yandex-weather-client': 'YandexWeatherFront2', + 'x-yandex-weather-token': + crypto.createHash('md5').update(`${TOKEN_SECRET}${timestamp}`).digest('hex'), + 'x-yandex-weather-timestamp': timestamp.toString(), + } +} + +/** + * Fetch the weather information. + * @param latitude Latitude. + * @param longitude Longitude. + * @returns Weather data. + */ +export default async function fetchWeather(latitude: number, longitude: number) { + const key = `${latitude};${longitude}`; + + // NOTE: Get the cached response first + let data = cache.get(key); + if (data !== undefined) { + return data; + } + + // NOTE: Request the weather from Yandex + const response = await fetch( + `https://api.weather.yandex.net/v2/informers?lat=${latitude}&lon=${longitude}`, + { + headers: { + 'user-agent': USER_AGENT, + ...generateAuthorizationHeaders(), + }, + }, + ); + + // NOTE: Handle the error if it did happen + if (!response.ok) { + const { message }: { message: string } = await response.json(); + throw new Error(message); + } + + // NOTE: Cache the response and return it + data = await response.json(); + cache.set(key, data!); + + return data; +}