jw-weather
Version:
Reader for various weather APIs
475 lines (422 loc) • 18.2 kB
JavaScript
/*
ver 2.1.3
- Refactored to use async/await and Promises, removing callback complexity.
- Replaced custom emitter with Node.js's built-in EventEmitter.
- Centralized provider configurations for improved maintainability.
- Removed 'jw-gate' dependency.
- Implemented https.Agent with keepAlive for performance.
- Added API key redaction from error messages for security.
- Fixed bug: WeatherBit provider used hard-coded coordinates.
- Fixed bug: OpenWeatherMap parser had incorrect temperature assignments.
- Corrected data types: Temperatures and humidity are now numbers, not strings.
ver 2.1.2
-minor bugfix
ver 2.1.1
-remove hard-coded key to weatherbit
ver 2.1.0
-Add OpenWetherMap support
-Add WeatherBit support
-Complete rewrite
-require jw-gate
ver 1.0.2
-includes celsius option
*/
// Node.js built-in modules
const https = require('https');
const { EventEmitter } = require('events');
// Use a single agent for all requests to enable connection reuse, improving performance.
const keepAliveAgent = new https.Agent({ keepAlive: true });
/**
* Converts a temperature from Fahrenheit to Celsius.
* @param {number} tempFahrenheit - Temperature in Fahrenheit.
* @returns {number} Temperature in Celsius.
*/
function toCelsius(tempFahrenheit) {
return (tempFahrenheit - 32) * (5 / 9);
}
/**
* Creates a standard daily forecast object.
* @returns {object} A daily forecast object with null properties.
*/
function getDailyObject() {
return {
tempHigh: null,
tempLow: null,
humidity: null,
condition: null,
feelsLikeHigh: null,
feelsLikeLow: null,
sunrise: null,
sunset: null,
icon: null
};
}
/**
* Fetches data from a URL and returns it as a promise.
* @param {string} url - The URL to fetch.
* @param {string} apiKey - The API key to redact from potential errors.
* @returns {Promise<object>} A promise that resolves with the parsed JSON body.
*/
function getAPIData(url, apiKey) {
return new Promise((resolve, reject) => {
const request = https.get(url, { agent: keepAliveAgent }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`Request Failed. Status Code: ${res.statusCode}`));
}
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (err) {
reject(new Error('Failed to parse API response.'));
}
});
});
request.on('error', (err) => {
// Sanitize the error message to avoid logging the API key
const sanitizedError = new Error(err.message.replace(apiKey, '[REDACTED_KEY]'));
sanitizedError.stack = err.stack;
reject(sanitizedError);
});
});
}
// --- Provider-specific Parsing Logic ---
// Note: These functions now accept `apiData` as an argument and assume `this` is the `service` instance.
function parseDarkSky(apiData) {
try {
const weather = apiData.Forecast;
if (weather.error) {
throw new Error(weather.error);
}
this.raw = weather;
this.temp = weather.currently.temperature;
this.feelsLike = weather.currently.apparentTemperature;
this.currentCondition = weather.currently.summary;
this.humidity = weather.currently.humidity;
this.forecastTime = new Date(weather.currently.time * 1000);
this.sunrise = new Date(weather.daily.data[0].sunriseTime * 1000);
this.sunset = new Date(weather.daily.data[0].sunsetTime * 1000);
this.icon = weather.currently.icon;
if (this.options.celsius) {
this.temp = toCelsius(this.temp);
this.feelsLike = toCelsius(this.feelsLike);
}
this.forecast = [];
for (let i = 0; i < 5; i++) {
const dayData = weather.daily.data[i];
const day = getDailyObject();
day.condition = dayData.summary;
day.feelsLikeHigh = dayData.apparentTemperatureHigh;
day.feelsLikeLow = dayData.apparentTemperatureLow;
day.humidity = dayData.humidity;
day.icon = dayData.icon;
day.sunrise = new Date(dayData.sunriseTime * 1000);
day.sunset = new Date(dayData.sunsetTime * 1000);
day.tempHigh = dayData.temperatureHigh;
day.tempLow = dayData.temperatureLow;
if (this.options.celsius) {
day.feelsLikeHigh = toCelsius(day.feelsLikeHigh);
day.feelsLikeLow = toCelsius(day.feelsLikeLow);
day.tempHigh = toCelsius(day.tempHigh);
day.tempLow = toCelsius(day.tempLow);
}
this.forecast.push(day);
}
} catch (err) {
this.error = err;
throw err; // Re-throw to be caught by the calling async function
}
}
function parseAccuweather(apiData) {
try {
const CurrentConditions = apiData.CurrentConditions[0];
const Forecast = apiData.Forecast;
this.raw = apiData;
this.temp = CurrentConditions.Temperature.Imperial.Value;
this.feelsLike = CurrentConditions.RealFeelTemperature.Imperial.Value;
this.currentCondition = CurrentConditions.WeatherText;
this.humidity = CurrentConditions.RelativeHumidity / 100;
this.forecastTime = new Date(CurrentConditions.LocalObservationDateTime);
this.sunrise = new Date(Forecast.DailyForecasts[0].Sun.Rise);
this.sunset = new Date(Forecast.DailyForecasts[0].Sun.Set);
this.icon = CurrentConditions.WeatherIcon;
if (this.options.celsius) {
this.temp = toCelsius(this.temp);
this.feelsLike = toCelsius(this.feelsLike);
}
this.forecast = [];
for (let i = 0; i < 5; i++) {
const dayData = Forecast.DailyForecasts[i];
const day = getDailyObject();
day.condition = dayData.Day.ShortPhrase;
day.feelsLikeHigh = dayData.RealFeelTemperature.Maximum.Value;
day.feelsLikeLow = dayData.RealFeelTemperature.Minimum.Value;
day.humidity = null; // AccuWeather does not provide this in the 5-day forecast
day.icon = dayData.Day.Icon;
day.sunrise = new Date(dayData.Sun.Rise);
day.sunset = new Date(dayData.Sun.Set);
day.tempHigh = dayData.Temperature.Maximum.Value;
day.tempLow = dayData.Temperature.Minimum.Value;
if (this.options.celsius) {
day.feelsLikeHigh = toCelsius(day.feelsLikeHigh);
day.feelsLikeLow = toCelsius(day.feelsLikeLow);
day.tempHigh = toCelsius(day.tempHigh);
day.tempLow = toCelsius(day.tempLow);
}
this.forecast.push(day);
}
} catch (err) {
this.error = err;
throw err;
}
}
function parseOpenWeatherMap(apiData) {
try {
const CurrentConditions = apiData.CurrentConditions;
const Forecast = apiData.Forecast;
this.raw = apiData;
this.temp = CurrentConditions.main.temp;
this.feelsLike = CurrentConditions.main.feels_like;
this.currentCondition = CurrentConditions.weather[0].description;
this.humidity = CurrentConditions.main.humidity / 100;
this.forecastTime = new Date(CurrentConditions.dt * 1000);
this.sunrise = new Date(CurrentConditions.sys.sunrise * 1000);
this.sunset = new Date(CurrentConditions.sys.sunset * 1000);
this.icon = CurrentConditions.weather[0].icon;
if (this.options.celsius) {
this.temp = toCelsius(this.temp);
this.feelsLike = toCelsius(this.feelsLike);
}
this.forecast = [];
// NOTE: OpenWeatherMap free tier returns a 3-hour forecast. The original
// code took the first 5 entries (a 15-hour forecast). This behavior is
// preserved to avoid a breaking change.
for (let i = 0; i < 5; i++) {
const forecastItem = Forecast.list[i];
const day = getDailyObject();
day.condition = forecastItem.weather[0].description;
day.tempHigh = forecastItem.main.temp_max;
day.tempLow = forecastItem.main.temp_min;
day.feelsLikeHigh = day.tempHigh; // Fallback, `feels_like` not provided per temp in forecast
day.feelsLikeLow = day.tempLow; // Fallback
day.humidity = forecastItem.main.humidity / 100;
day.icon = forecastItem.weather[0].icon;
if (this.options.celsius) {
day.feelsLikeHigh = toCelsius(day.feelsLikeHigh);
day.feelsLikeLow = toCelsius(day.feelsLikeLow);
day.tempHigh = toCelsius(day.tempHigh);
day.tempLow = toCelsius(day.tempLow);
}
this.forecast.push(day);
}
} catch (err) {
this.error = err;
throw err;
}
}
function parseWeatherBit(apiData) {
try {
const CurrentConditions = apiData.CurrentConditions.data[0];
const Forecast = apiData.Forecast.data;
this.raw = apiData;
this.temp = CurrentConditions.temp;
this.feelsLike = CurrentConditions.app_temp;
this.currentCondition = CurrentConditions.weather.description;
this.humidity = CurrentConditions.rh / 100;
this.forecastTime = new Date(CurrentConditions.ts * 1000);
this.sunrise = new Date(Forecast[0].sunrise_ts * 1000);
this.sunset = new Date(Forecast[0].sunset_ts * 1000);
this.icon = CurrentConditions.weather.icon;
if (this.options.celsius) {
this.temp = toCelsius(this.temp);
this.feelsLike = toCelsius(this.feelsLike);
}
this.forecast = [];
for (let i = 0; i < 5; i++) {
const dayData = Forecast[i];
const day = getDailyObject();
day.condition = dayData.weather.description;
day.feelsLikeHigh = dayData.app_max_temp;
day.feelsLikeLow = dayData.app_min_temp;
day.humidity = dayData.rh / 100;
day.icon = dayData.weather.icon;
day.sunrise = new Date(dayData.sunrise_ts * 1000);
day.sunset = new Date(dayData.sunset_ts * 1000);
day.tempHigh = dayData.max_temp;
day.tempLow = dayData.low_temp;
if (this.options.celsius) {
day.feelsLikeHigh = toCelsius(day.feelsLikeHigh);
day.feelsLikeLow = toCelsius(day.feelsLikeLow);
day.tempHigh = toCelsius(day.tempHigh);
day.tempLow = toCelsius(day.tempLow);
}
this.forecast.push(day);
}
} catch (err) {
this.error = err;
throw err;
}
}
// --- Central Provider Configuration ---
const PROVIDER_CONFIG = {
'darksky': {
urls: {
Forecast: 'https://api.darksky.net/forecast/[KEY]/[LAT],[LONG]?exclude=["minutely","flags","alerts"]'
},
parser: parseDarkSky,
requiresLocationLookup: false,
},
'accuweather': {
urls: {
CurrentConditions: 'https://dataservice.accuweather.com/currentconditions/v1/[LOCATION]?apikey=[KEY]&details=true',
Forecast: 'https://dataservice.accuweather.com/forecasts/v1/daily/5day/[LOCATION]?apikey=[KEY]&details=true'
},
parser: parseAccuweather,
requiresLocationLookup: true,
},
'openweathermap': {
urls: {
CurrentConditions: 'https://api.openweathermap.org/data/2.5/weather?units=imperial&lat=[LAT]&lon=[LONG]&appid=[KEY]',
Forecast: 'https://api.openweathermap.org/data/2.5/forecast?units=imperial&lat=[LAT]&lon=[LONG]&appid=[KEY]'
},
parser: parseOpenWeatherMap,
requiresLocationLookup: false,
},
'weatherbit': {
urls: {
// BUG FIX: Corrected hard-coded lat/lon with placeholders
CurrentConditions: 'https://api.weatherbit.io/v2.0/current?lat=[LAT]&lon=[LONG]&units=I&key=[KEY]',
Forecast: 'https://api.weatherbit.io/v2.0/forecast/daily?lat=[LAT]&lon=[LONG]&days=5&units=I&key=[KEY]',
},
parser: parseWeatherBit,
requiresLocationLookup: false,
}
};
/**
* Creates a service for various online weather APIs
*
* @param {Object} options
* @param {String} options.key - API Key
* @param {'darksky'|'accuweather'|'openweathermap'|'weatherbit'} options.provider - The weather provider to use
* @param {Number} options.latitude - The latitude
* @param {Number} options.longitude - the longitude
* @param {boolean} [options.celsius=false] - Temperature in celsius
*/
class service extends EventEmitter {
constructor(options) {
super();
this.raw = {};
this.lastUpdate = null;
this.forecastTime = null;
this.temp = null;
this.humidity = null;
this.currentCondition = null;
this.icon = null;
this.feelsLike = null;
this.sunrise = null;
this.sunset = null;
this.error = null;
this.forecast = [];
this.options = options;
this.locationKey = null; // For AccuWeather
this.ready = false;
// A promise that resolves when startup tasks are complete.
// This replaces the complex `runUpdateWhenReady` logic.
this._readyPromise = this._startup();
}
/**
* @private
*/
async _startup() {
try {
if (!this.options || typeof this.options.key !== 'string' ||
typeof this.options.latitude !== 'number' || typeof this.options.longitude !== 'number') {
throw new Error('Invalid startup options. Must provide key, latitude, and longitude.');
}
this.options.provider = this.options.provider.toLowerCase();
const providerConfig = PROVIDER_CONFIG[this.options.provider];
if (!providerConfig) {
throw new Error(`Unsupported provider: ${this.options.provider}`);
}
if (providerConfig.requiresLocationLookup) {
this.locationKey = await this._accuweatherLocationLookup();
}
this.ready = true;
// Use setImmediate to ensure 'ready' event fires after the constructor has finished.
setImmediate(() => this.emit('ready'));
} catch (err) {
this.error = err;
setImmediate(() => this.emit('error', err.message));
}
}
/**
* @private
* @returns {Promise<string>} A promise that resolves with the location key.
*/
async _accuweatherLocationLookup() {
const { key, latitude, longitude } = this.options;
const url = `https://dataservice.accuweather.com/locations/v1/cities/geoposition/search?apikey=${key}&q=${latitude},${longitude}`;
const res = await getAPIData(url, key);
if (!res || !res.Key) {
throw new Error('AccuWeather location lookup failed to return a valid Key.');
}
return res.Key;
}
/**
* Updates the weather data by fetching from the configured provider.
* @param {Function} [callback] - Optional: A callback(err) to be executed upon completion.
*/
async update(callback) {
try {
await this._readyPromise;
if (!this.ready) {
throw new Error('Service is not ready. Startup may have failed.');
}
const providerConfig = PROVIDER_CONFIG[this.options.provider];
const apiPromises = Object.entries(providerConfig.urls).map(([name, urlTemplate]) => {
const url = urlTemplate.replace('[KEY]', this.options.key)
.replace('[LAT]', this.options.latitude)
.replace('[LONG]', this.options.longitude)
.replace('[LOCATION]', this.locationKey);
return getAPIData(url, this.options.key).then(data => ({ name, data }));
});
const results = await Promise.all(apiPromises);
const apiData = results.reduce((acc, { name, data }) => {
acc[name] = data;
return acc;
}, {});
// Run the parser, binding `this` to the service instance
providerConfig.parser.call(this, apiData);
this.lastUpdate = new Date().toString();
this.error = null; // Clear previous errors on success
if (typeof callback === 'function') {
callback(null);
}
} catch (err) {
this.error = err;
this.emit('error', err.message);
if (typeof callback === 'function') {
callback(err);
}
}
}
/**
* @returns {Object} The complete weather data object.
*/
fullWeather() {
return {
lastUpdate: this.lastUpdate,
forecastTime: this.forecastTime,
temp: this.temp,
feelsLike: this.feelsLike,
humidity: this.humidity,
currentCondition: this.currentCondition,
icon: this.icon,
sunrise: this.sunrise,
sunset: this.sunset,
forecast: this.forecast
};
}
}
exports.service = service;