gismeteo weather support
This commit is contained in:
@@ -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)}` }],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from './ping';
|
||||
// export * from './listTools';
|
||||
// export * from './invokeTool';
|
||||
export * from './testAddRemoveTool';
|
||||
export * from './gismeteo_weather';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user