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 './listTools';
|
||||||
// export * from './invokeTool';
|
// export * from './invokeTool';
|
||||||
export * from './testAddRemoveTool';
|
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