From be295c8f0589dd40089d6533cd70e309cd25fd79 Mon Sep 17 00:00:00 2001 From: "azykov@mail.ru" Date: Thu, 9 Jul 2026 10:32:22 +0300 Subject: [PATCH] gismeteo weather support --- src/mcpTools/gismeteo_weather.ts | 87 ++++++++ src/mcpTools/index.ts | 1 + src/utils/gismeteoLocationCache.ts | 63 ++++++ src/utils/gismeteoReqest.ts | 336 +++++++++++++++++++++++++++++ 4 files changed, 487 insertions(+) create mode 100644 src/mcpTools/gismeteo_weather.ts create mode 100644 src/utils/gismeteoLocationCache.ts create mode 100644 src/utils/gismeteoReqest.ts diff --git a/src/mcpTools/gismeteo_weather.ts b/src/mcpTools/gismeteo_weather.ts new file mode 100644 index 0000000..aea4151 --- /dev/null +++ b/src/mcpTools/gismeteo_weather.ts @@ -0,0 +1,87 @@ +import z from "zod"; +import { ToolDef } from "../types" +import { GismeteoHttpRequest } from "../utils/gismeteoReqest"; +import { gismeteoLocationCache } from "../utils/gismeteoLocationCache"; + +export const weatherFindCitiesByNameTool: ToolDef = { + title: "weather-find-locations-by-name", + config: { + description: "Find location (city, place or airport) id for futher forecast requests by its full or partial name", + inputSchema: { + nameStartsWith: z.string().describe("String location name starts with"), + language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"), + count: z.number().default(3).describe("Limit number of items to return"), + }, + }, + handler: async (args) => { + + try { + const locations = await gismeteoLocationCache(args.language).getLocationsByName(args.nameStartsWith, args.count); + return { content: [{ type: "text", text: JSON.stringify(locations), }] }; + } catch (err) { + return { + isError: true, + content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }], + }; + } + }, +}; + +export const weatherGetWeatherByLocationIdTool: ToolDef = { + title: "weather-get-weather-by-location-id", + config: { + description: `Maximum authority weather forecast source with full forecast results. +All data is based on GisMeteo.ru source. +Get weather data by location id. Use weather-find-cities-by-name to find the id`, + inputSchema: { + locationId: z.string().describe("Location id to get weather data for"), + language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"), + }, + }, + handler: async (args) => { + + try { + const request = new GismeteoHttpRequest({ lang: args.language }); + const data = await request.getWeatherDataForLocations(args.locationId); + return { content: [{ type: "text", text: JSON.stringify(data.weather.location), }] }; + } catch (err) { + return { + isError: true, + content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }], + }; + } + }, +}; + +export const weatherGetWeatherByLocationNameTool: ToolDef = { + title: "weather-get-weather-by-location-name", + config: { + description: `Maximum authority weather forecast source with full forecast results. +All data is based on GisMeteo.ru source. +Get weather data by location (city, place or airport) name.`, + inputSchema: { + locationNameStartsWith: z.string().describe("Location name to get weather data for. All locations starting with this will be returned"), + language: z.string().default('ru').describe("Language 2-letter code, e.g. ru, en"), + count: z.number().default(1).describe("Limit number of items to return"), + }, + }, + handler: async (args) => { + + try { + const locations = await gismeteoLocationCache(args.language).getLocationsByName(args.locationNameStartsWith, args.count); + + const request = new GismeteoHttpRequest({ lang: args.language }); + + const data = (await Promise.all( + locations.map((loc) => request.getWeatherDataForLocations(loc.id)) + )) + .map((response) => response.weather.location) + return { content: [{ type: "text", text: JSON.stringify(data), }] }; + } catch (err) { + return { + isError: true, + content: [{ type: "text", text: `ping failed:\n${(err as Error).message ?? String(err)}` }], + }; + } + }, +}; diff --git a/src/mcpTools/index.ts b/src/mcpTools/index.ts index 2615c41..8271184 100644 --- a/src/mcpTools/index.ts +++ b/src/mcpTools/index.ts @@ -2,3 +2,4 @@ export * from './ping'; // export * from './listTools'; // export * from './invokeTool'; export * from './testAddRemoveTool'; +export * from './gismeteo_weather'; diff --git a/src/utils/gismeteoLocationCache.ts b/src/utils/gismeteoLocationCache.ts new file mode 100644 index 0000000..efc740a --- /dev/null +++ b/src/utils/gismeteoLocationCache.ts @@ -0,0 +1,63 @@ +import { GismeteoHttpRequest, GismeteoLocation } from "./gismeteoReqest"; + +function normalizeName(name: string, locale: Intl.LocalesArgument): string { + return name + .toLocaleLowerCase(locale) + .replace(/ё/g, 'е') + .replace(/\s+/g, ' ') + .trim(); +} + +export class GismeteoLocationCache { + public readonly language: string; + + private readonly cache: GismeteoLocation[] = []; + + constructor(language: string) { + this.language = language; + } + + private getCachedLocations(nameStartsWith: string): GismeteoLocation[] { + return this.cache + .filter((loc) => normalizeName(loc.name).startsWith(normalizeName(nameStartsWith))); + } + + // gismeteo returns different structures for different requests: + // getLocationsByName contains n: string, getWeatherDataForLocations contans name: string + private cacheLocation(location: GismeteoLocation<{ n: string }>) { + const normalized = { + ...location, + n: undefined, + name: location.n, + } + + const idx = this.cache.findIndex((loc) => loc.id === location.id); + if (idx >= 0) + this.cache[idx] = normalized; + else + this.cache.push(normalized); + } + + public async getLocationsByName(nameStartsWith: string, count: number = 1): Promise { + + let cached = this.getCachedLocations(nameStartsWith); + if (cached.length < count) { + const request = new GismeteoHttpRequest({ lang: this.language }); + const data = await request.getLocationsByName(nameStartsWith, count, false); + for (const location of data.document.item) + this.cacheLocation(location); + + cached = this.getCachedLocations(nameStartsWith); + } + + return cached.slice(0, count); + } +} + +const cachesByLanguage: Record = {}; +export function gismeteoLocationCache(language: string) { + if (!cachesByLanguage[language]) + cachesByLanguage[language] = new GismeteoLocationCache(language); + + return cachesByLanguage[language]; +} \ No newline at end of file diff --git a/src/utils/gismeteoReqest.ts b/src/utils/gismeteoReqest.ts new file mode 100644 index 0000000..772f482 --- /dev/null +++ b/src/utils/gismeteoReqest.ts @@ -0,0 +1,336 @@ +import { XMLParser } from "fast-xml-parser"; + +// Порт ru.gismeteo.Gismeteonetworking.GismeteoHttpRequest в TypeScript/Node.js. + +const SERVICE_URL = "https://services.gismeteo.net/inform-service/inf_android/"; +const USER_AGENT = "Gismeteo Android v2"; +const DEFAULT_TIMEOUT_MS = 30_000; + +const CATEGORY_CITIES = "cities"; +const CATEGORY_FORECAST = "forecast"; + +const HEADER_PARAM_UTC_TIMESTAMP = "x-gismeteo-timestamp"; + +const PARAM_CELLID = "cellid"; +const PARAM_CITY = "city"; +const PARAM_COUNT = "count"; +const PARAM_DOMAIN = "domain"; +const PARAM_ID = "id"; +const PARAM_LAC = "lac"; +const PARAM_LANG = "lang"; +const PARAM_LAT = "lat"; +const PARAM_LAT_LNG = "lat_lng"; +const PARAM_LNG = "lng"; +const PARAM_MCC = "mcc"; +const PARAM_MNC = "mnc"; +const PARAM_MODE = "mode"; +const PARAM_NOCACHE = "nocache"; +const PARAM_REAL_DISTANCE = "real_distance"; +const PARAM_SEARCH_ALL = "search_all"; +const PARAM_STARTSWITH = "startsWith"; +const PARAM_WITH_FACTS = "with_facts"; +const PARAM_WITH_TZONE = "with_tzone"; + +export enum GismeteoErrorCode { + UNKNOWN_HOST = 8, + MALFORMED_URL_OR_IO_ERROR = 9, + TIMEOUT = 10, + BAD_RESPONSE = 5, +} + +export class GismeteoError extends Error { + constructor(public readonly code: GismeteoErrorCode, message?: string) { + super(message ?? `GismeteoError(${code})`); + this.name = "GismeteoError"; + } +} + +export interface GismeteoHttpRequestListener { + onUpdateUTCDate(utcMillis: number): void; +} + +export type GismeteoHttpRequestDeviceInfo = { + manufacturer: string; + device: string; + osVersion: string; +} + +export interface GismeteoHttpRequestOptions { + lang: string; + timeoutMs?: number; + listener?: GismeteoHttpRequestListener; + deviceInfo?: Partial; + /** + * В оригинале domain=ua добавлялся, если системная локаль устройства + * содержала "ua". В Node-окружении нет "локали устройства" в этом смысле, + * так что решение делегируется вызывающему коду. + */ + isUaDomainParamRequired?: boolean; +} + +export type GismeteoLocationFact = { + values: { + t: number; + tflt: number; + tcflt: number; + p: number; + ws: number; + wd: number; + hum: number; + hi: number; + cl: number; + pt: number; + pr: number; + ts: number; + water_t: number; + icon: number; + descr: number; + grade: number; + }; + valid: string; + tod: number; + risem: number; + setm: number; + durm: number; + sunrise: number; + sunset: number; +}; + +export type GismeteoLocation = TAdditionalData & { + fact: GismeteoLocationFact; + id: number; + lat: number; + lng: number; + tzone: number; + country_id: number; + country_name: string; + disctrict_id: number; + district_name: string; + kind: 'T' | 'A' | string; +} + +export type GismeteoGetLocationsByNameResponse = { + document: { + item: GismeteoLocation<{ + n: string; + }>[]; + } +} + +export type GismeteoGetWeatherDataForLocationsResponse = { + weather: { + location: GismeteoLocation<{ + day: { + [x: string]: unknown; + }[], + name: string; + name_r: string; + cur_time: string; + nowcast_url: string; + nowcast_teaser: true; + }>; + }; +} + +export class GismeteoHttpRequest { + private readonly timeoutMs: number; + private readonly listener?: GismeteoHttpRequestListener; + private readonly deviceInfo: GismeteoHttpRequestDeviceInfo; + private readonly isUaDomainParamRequired: boolean; + private readonly lang: string; + + constructor(options: GismeteoHttpRequestOptions = { lang: 'ru' }) { + this.lang = options.lang; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.listener = options.listener; + this.deviceInfo = { + // Samsung S23 by default + manufacturer: 'samsung', + device: 'dm3q', + osVersion: '14', + ...options.deviceInfo, + }; + this.isUaDomainParamRequired = options.isUaDomainParamRequired ?? false; + } + + private getUserAgent(): string { + const { manufacturer, device, osVersion } = this.deviceInfo; + return `${USER_AGENT}, ${manufacturer} ${device}, ${osVersion}`; + } + + public async getLocationsByName( + startsWith: string, + count: number, + withFacts: boolean, + ): Promise { + const params: Record = { + [PARAM_STARTSWITH]: startsWith, // кодирование делает URLSearchParams в prepareUrlString + [PARAM_SEARCH_ALL]: "1", + [PARAM_LANG]: this.lang, + [PARAM_WITH_FACTS]: withFacts ? "1" : "0", + [PARAM_LAT_LNG]: "1", + [PARAM_WITH_TZONE]: "1", + [PARAM_COUNT]: String(count), + }; + + return this.sendRequest(params, CATEGORY_CITIES); + } + + async getLocationsByCoordinates( + lat: number, + lng: number, + count: number, + withFacts: boolean, + realDistance: boolean, + ): Promise { + const params: Record = { + [PARAM_LAT]: String(lat), + [PARAM_LNG]: String(lng), + [PARAM_LAT_LNG]: "1", + [PARAM_COUNT]: String(count), + [PARAM_LANG]: this.lang, + [PARAM_WITH_FACTS]: withFacts ? "1" : "0", + }; + if (realDistance) + params[PARAM_REAL_DISTANCE] = "1"; + + return this.sendRequest(params, CATEGORY_CITIES); + } + + async getLocationsByCellInfo( + mnc: number, + mcc: number, + lac: number, + cellId: number, + count: number, + withFacts: boolean, + realDistance: boolean, + ): Promise { + const params: Record = { + [PARAM_MNC]: String(mnc), + [PARAM_MCC]: String(mcc), + [PARAM_LAC]: String(lac), + [PARAM_CELLID]: String(cellId), + [PARAM_COUNT]: String(count), + [PARAM_LANG]: this.lang, + [PARAM_WITH_FACTS]: withFacts ? "1" : "0", + }; + if (realDistance) + params[PARAM_REAL_DISTANCE] = "1"; + + return this.sendRequest(params, CATEGORY_CITIES); + } + + async getLocationsByIP(withFacts: boolean, count: number): Promise { + const params: Record = { + [PARAM_MODE]: "ip", + [PARAM_NOCACHE]: "1", + [PARAM_LANG]: this.lang, + [PARAM_LAT_LNG]: "1", + [PARAM_WITH_FACTS]: withFacts ? "1" : "0", + [PARAM_COUNT]: String(count), + }; + + return this.sendRequest(params, CATEGORY_CITIES); + } + + async getLocalizedNamesForLocations(ids: number[]): Promise { + const params: Record = { + [PARAM_ID]: ids.join(","), + [PARAM_LANG]: this.lang, + }; + + return this.sendRequest(params, CATEGORY_CITIES); + } + + async getWeatherDataForLocations(cityId: number): Promise { + const params: Record = { + [PARAM_CITY]: String(cityId), + [PARAM_LANG]: this.lang, + }; + + return this.sendRequest(params, CATEGORY_FORECAST); + } + + // static getWeatherDataForLocationsURL( + // cityId: number, + // lang: string, + // isDomainParamRequired = false, + // ): string { + // const params: Record = { + // [PARAM_CITY]: String(cityId), + // [PARAM_LANG]: lang, + // }; + + // return GismeteoHttpRequest.prepareUrlString(params, CATEGORY_FORECAST); + // } + + private static prepareUrlString(params: Record, category: string): string { + const search = new URLSearchParams(params).toString(); + const url = `${SERVICE_URL}${category}/?${search}`; + // console.debug("Gismeteo.GismeteoHttpRequest", url); + return url; + } + + private async sendRequest(params: Record, category: string): Promise { + + if (this.isUaDomainParamRequired) + params[PARAM_DOMAIN] = "ua"; + + const url = GismeteoHttpRequest.prepareUrlString(params, category); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + let response: Response; + try { + response = await fetch(url, { + method: "GET", + headers: { "User-Agent": this.getUserAgent() }, + signal: controller.signal, + // cache: "no-store", // fetch has no built-in cache + }); + } catch (e) { + const err = e as Error; + if (err.name === "AbortError") { + throw new GismeteoError(GismeteoErrorCode.TIMEOUT, err.message); + } + // fetch кидает TypeError на DNS-ошибки/сетевые сбои — оригинал различал + // UnknownHostException отдельно, здесь для простоты сводим к IO_ERROR, + // если нужно точное различие — парсить err.cause.code === "ENOTFOUND". + const cause = (err as { cause?: { code?: string } }).cause; + if (cause?.code === "ENOTFOUND") { + throw new GismeteoError(GismeteoErrorCode.UNKNOWN_HOST, err.message); + } + throw new GismeteoError(GismeteoErrorCode.MALFORMED_URL_OR_IO_ERROR, err.message); + } finally { + clearTimeout(timeout); + } + + const utcTimestampHeader = response.headers.get(HEADER_PARAM_UTC_TIMESTAMP); + if (this.listener && utcTimestampHeader !== null) { + const seconds = parseInt(utcTimestampHeader, 10) || 0; + this.listener.onUpdateUTCDate(seconds * 1000); + } + + if (response.status !== 200) { + throw new GismeteoError(GismeteoErrorCode.BAD_RESPONSE, `HTTP ${response.status}`); + } + + const responseXml = await response.text(); + + const parser = new XMLParser({ + ignoreAttributes: false, // атрибуты (t=, p=, ws= и т.д.) — это почти все данные в этом XML + attributeNamePrefix: "", // без префикса @_ — атрибуты будут как обычные поля + parseAttributeValue: true, // "11" -> 11 (число), "1783486385" -> число и т.п. + isArray: (tagName) => ["day", "forecast", "item"].includes(tagName), + }); + console.log(JSON.stringify(params), url, JSON.stringify(responseXml)); + + const responseObj = parser.parse(responseXml); + + // console.log(JSON.stringify(params), JSON.stringify(responseObj)); + + return responseObj; + } +}