init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
192
lib/weather/providers/weatherapi.ts
Normal file
192
lib/weather/providers/weatherapi.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import { toSeverity } from "../describe";
|
||||
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, readKey, round1 } from "../http";
|
||||
import type { Condition, WeatherProvider } from "../types";
|
||||
|
||||
/**
|
||||
* WeatherAPI.com — one request returns current, daily, hourly and alerts.
|
||||
* The free plan returns 3 days; paid plans return up to 14. We ask for 14
|
||||
* and use whatever comes back.
|
||||
* Docs: https://www.weatherapi.com/docs/
|
||||
*/
|
||||
|
||||
interface WapiCondition {
|
||||
code: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface WapiHour {
|
||||
time_epoch: number;
|
||||
temp_c: number;
|
||||
feelslike_c: number;
|
||||
is_day: 0 | 1;
|
||||
condition: WapiCondition;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
gust_kph: number;
|
||||
humidity: number;
|
||||
chance_of_rain: number;
|
||||
chance_of_snow: number;
|
||||
precip_mm: number;
|
||||
uv: number;
|
||||
}
|
||||
|
||||
interface WapiDay {
|
||||
date: string; // "2026-10-21"
|
||||
day: {
|
||||
maxtemp_c: number;
|
||||
mintemp_c: number;
|
||||
maxwind_kph: number;
|
||||
totalprecip_mm: number;
|
||||
avghumidity: number;
|
||||
daily_chance_of_rain: number;
|
||||
daily_chance_of_snow: number;
|
||||
uv: number;
|
||||
condition: WapiCondition;
|
||||
};
|
||||
astro: { sunrise: string; sunset: string }; // "07:31 AM", local time
|
||||
hour: WapiHour[];
|
||||
}
|
||||
|
||||
interface WapiResponse {
|
||||
location: { tz_id: string; localtime_epoch: number; localtime: string };
|
||||
current: {
|
||||
last_updated_epoch: number;
|
||||
temp_c: number;
|
||||
feelslike_c: number;
|
||||
humidity: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
gust_kph: number;
|
||||
pressure_mb: number;
|
||||
dewpoint_c?: number;
|
||||
vis_km: number;
|
||||
uv: number;
|
||||
cloud: number;
|
||||
precip_mm: number;
|
||||
is_day: 0 | 1;
|
||||
condition: WapiCondition;
|
||||
};
|
||||
forecast: { forecastday: WapiDay[] };
|
||||
alerts?: {
|
||||
alert: { headline: string; event: string; severity: string; effective: string; expires: string; desc: string; instruction: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
// Condition codes: https://www.weatherapi.com/docs/weather_conditions.json
|
||||
const STORM_CODES = [1087, 1273, 1276, 1279, 1282];
|
||||
const FOG_CODES = [1030, 1135, 1147];
|
||||
const CLOUD_CODES = [1003, 1006, 1009];
|
||||
const SNOW_CODES = [1066, 1069, 1072, 1114, 1117, 1204, 1207, 1210, 1213, 1216, 1219, 1222, 1225, 1237, 1249, 1252, 1255, 1258, 1261, 1264];
|
||||
|
||||
function toCondition(code: number): Condition {
|
||||
if (code === 1000) return "clear";
|
||||
if (STORM_CODES.includes(code)) return "storm";
|
||||
if (FOG_CODES.includes(code)) return "fog";
|
||||
if (CLOUD_CODES.includes(code)) return "clouds";
|
||||
if (SNOW_CODES.includes(code)) return "snow";
|
||||
return "rain"; // everything left is some flavour of rain or drizzle
|
||||
}
|
||||
|
||||
/**
|
||||
* Sunrise comes as "07:31 AM" in local time. To turn that into a real instant
|
||||
* we need the location's UTC offset, which we work out from `localtime`
|
||||
* (the local wall-clock time) versus `localtime_epoch` (the true instant).
|
||||
*/
|
||||
function utcOffsetSeconds(location: WapiResponse["location"]): number {
|
||||
const wallClockAsUtc = Date.parse(`${location.localtime.replace(" ", "T")}:00Z`) / 1000;
|
||||
return Math.round((wallClockAsUtc - location.localtime_epoch) / 60) * 60;
|
||||
}
|
||||
|
||||
function localTimeToIso(date: string, clock: string, offsetSeconds: number): string | null {
|
||||
const match = clock.match(/(\d+):(\d+)\s*(AM|PM)/i);
|
||||
if (!match) return null; // e.g. "No sunrise" near the poles
|
||||
let hours = Number(match[1]) % 12;
|
||||
if (match[3].toUpperCase() === "PM") hours += 12;
|
||||
const wallClockAsUtc = Date.parse(`${date}T00:00:00Z`) + (hours * 60 + Number(match[2])) * 60_000;
|
||||
return new Date(wallClockAsUtc - offsetSeconds * 1000).toISOString();
|
||||
}
|
||||
|
||||
export const weatherApi: WeatherProvider = {
|
||||
id: "weatherapi",
|
||||
label: "WeatherAPI",
|
||||
envKey: "WEATHERAPI_API_KEY",
|
||||
signupUrl: "https://www.weatherapi.com/signup.aspx",
|
||||
// Set WEATHERAPI_PLAN=paid in .env.local if your plan includes 14 days.
|
||||
limits: () => ({ forecastDays: process.env.WEATHERAPI_PLAN === "paid" ? 14 : 3, hourlyStepHours: 1 }),
|
||||
|
||||
async getForecast(lat, lon) {
|
||||
const key = readKey("WEATHERAPI_API_KEY");
|
||||
const url = `https://api.weatherapi.com/v1/forecast.json?key=${key}&q=${lat},${lon}&days=${MAX_DAYS}&aqi=no&alerts=yes`;
|
||||
const data = await fetchJson<WapiResponse>(url, "WeatherAPI");
|
||||
|
||||
const now = data.current;
|
||||
const offset = utcOffsetSeconds(data.location);
|
||||
const days = data.forecast.forecastday;
|
||||
|
||||
const hourly = days.flatMap((day) =>
|
||||
day.hour.map((h) => ({
|
||||
time: fromUnix(h.time_epoch),
|
||||
tempC: round1(h.temp_c),
|
||||
feelsLikeC: round1(h.feelslike_c),
|
||||
condition: toCondition(h.condition.code),
|
||||
isDay: h.is_day === 1,
|
||||
precipChance: Math.max(h.chance_of_rain, h.chance_of_snow),
|
||||
precipMm: maybe(h.precip_mm),
|
||||
windKph: round1(h.wind_kph),
|
||||
gustKph: maybe(h.gust_kph),
|
||||
windDeg: h.wind_degree,
|
||||
humidity: h.humidity,
|
||||
uvIndex: maybe(h.uv),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
timeZone: data.location.tz_id,
|
||||
hourlyStepHours: 1,
|
||||
current: {
|
||||
time: fromUnix(now.last_updated_epoch),
|
||||
tempC: round1(now.temp_c),
|
||||
feelsLikeC: round1(now.feelslike_c),
|
||||
condition: toCondition(now.condition.code),
|
||||
description: now.condition.text.toLowerCase(),
|
||||
isDay: now.is_day === 1,
|
||||
humidity: now.humidity,
|
||||
windKph: round1(now.wind_kph),
|
||||
gustKph: maybe(now.gust_kph),
|
||||
windDeg: now.wind_degree,
|
||||
pressureHpa: maybe(now.pressure_mb),
|
||||
dewPointC: maybe(now.dewpoint_c),
|
||||
visibilityKm: maybe(now.vis_km),
|
||||
uvIndex: maybe(now.uv),
|
||||
cloudCover: now.cloud,
|
||||
precipMm: maybe(now.precip_mm),
|
||||
},
|
||||
hourly: nextHours(hourly, MAX_HOURS),
|
||||
daily: days.map((day) => ({
|
||||
time: `${day.date}T12:00:00.000Z`, // midday UTC lands on the right date almost everywhere
|
||||
highC: round1(day.day.maxtemp_c),
|
||||
lowC: round1(day.day.mintemp_c),
|
||||
condition: toCondition(day.day.condition.code),
|
||||
summary: day.day.condition.text,
|
||||
precipChance: Math.max(day.day.daily_chance_of_rain, day.day.daily_chance_of_snow),
|
||||
precipMm: maybe(day.day.totalprecip_mm),
|
||||
windKph: maybe(day.day.maxwind_kph),
|
||||
gustKph: null,
|
||||
windDeg: null,
|
||||
uvIndexMax: maybe(day.day.uv),
|
||||
humidity: maybe(day.day.avghumidity),
|
||||
sunrise: localTimeToIso(day.date, day.astro.sunrise, offset),
|
||||
sunset: localTimeToIso(day.date, day.astro.sunset, offset),
|
||||
})),
|
||||
alerts: (data.alerts?.alert ?? []).map((a) => ({
|
||||
title: a.headline || a.event,
|
||||
severity: toSeverity(a.severity),
|
||||
source: "WeatherAPI",
|
||||
starts: a.effective || null,
|
||||
ends: a.expires || null,
|
||||
description: a.desc,
|
||||
instruction: a.instruction || null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue