gismeteo weather support

This commit is contained in:
azykov@mail.ru
2026-07-09 10:32:22 +03:00
parent bc5b3572df
commit be295c8f05
4 changed files with 487 additions and 0 deletions
+87
View File
@@ -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)}` }],
};
}
},
};
+1
View File
@@ -2,3 +2,4 @@ export * from './ping';
// export * from './listTools'; // export * from './listTools';
// export * from './invokeTool'; // export * from './invokeTool';
export * from './testAddRemoveTool'; export * from './testAddRemoveTool';
export * from './gismeteo_weather';
+63
View File
@@ -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<GismeteoLocation[]> {
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<string, GismeteoLocationCache> = {};
export function gismeteoLocationCache(language: string) {
if (!cachesByLanguage[language])
cachesByLanguage[language] = new GismeteoLocationCache(language);
return cachesByLanguage[language];
}
+336
View File
@@ -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<GismeteoHttpRequestDeviceInfo>;
/**
* В оригинале 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 = { name: string }> = 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<GismeteoGetLocationsByNameResponse> {
const params: Record<string, string> = {
[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<string> {
const params: Record<string, string> = {
[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<string> {
const params: Record<string, string> = {
[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<string> {
const params: Record<string, string> = {
[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<string> {
const params: Record<string, string> = {
[PARAM_ID]: ids.join(","),
[PARAM_LANG]: this.lang,
};
return this.sendRequest(params, CATEGORY_CITIES);
}
async getWeatherDataForLocations(cityId: number): Promise<GismeteoGetWeatherDataForLocationsResponse> {
const params: Record<string, string> = {
[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<string, string> = {
// [PARAM_CITY]: String(cityId),
// [PARAM_LANG]: lang,
// };
// return GismeteoHttpRequest.prepareUrlString(params, CATEGORY_FORECAST);
// }
private static prepareUrlString(params: Record<string, string>, 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<T>(params: Record<string, string>, category: string): Promise<T> {
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;
}
}