Ajout de données météo
This commit is contained in:
169
tools/add_weather.js
Normal file
169
tools/add_weather.js
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
const { extractRawDate, readFrontmatter, writeFrontmatter } = require("./lib/weather/frontmatter");
|
||||
const { resolveArticleDate } = require("./lib/weather/time");
|
||||
const { fetchWeather, hasConfiguredProvider, mergeWeather } = require("./lib/weather/providers");
|
||||
const { loadWeatherConfig } = require("./lib/weather/config");
|
||||
|
||||
const CONTENT_ROOT = path.resolve("content");
|
||||
|
||||
async function collectMarkdownFiles(rootDir) {
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(rootDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(fullPath);
|
||||
files.push(...nested);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
if (entry.name === "_index.md") continue;
|
||||
|
||||
files.push(fullPath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function resolveTargets(args) {
|
||||
if (args.length === 0) {
|
||||
return collectMarkdownFiles(CONTENT_ROOT);
|
||||
}
|
||||
|
||||
const targets = new Set();
|
||||
|
||||
for (const input of args) {
|
||||
const resolved = path.resolve(input);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolved);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const nested = await collectMarkdownFiles(resolved);
|
||||
nested.forEach((file) => targets.add(file));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stat.isFile()) {
|
||||
if (!resolved.endsWith(".md")) continue;
|
||||
if (path.basename(resolved) === "_index.md") continue;
|
||||
targets.add(resolved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Skipping ${input}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(targets);
|
||||
}
|
||||
|
||||
async function processFile(filePath, config, { force = false } = {}) {
|
||||
const frontmatter = await readFrontmatter(filePath);
|
||||
|
||||
if (!frontmatter) {
|
||||
return { status: "no-frontmatter" };
|
||||
}
|
||||
|
||||
const existingWeather = frontmatter.doc.has("weather") ? frontmatter.doc.get("weather") : null;
|
||||
|
||||
if (existingWeather && !force) {
|
||||
return { status: "already-set" };
|
||||
}
|
||||
|
||||
const dateValue = frontmatter.doc.get("date");
|
||||
if (!dateValue) {
|
||||
return { status: "no-date" };
|
||||
}
|
||||
|
||||
const rawDate = extractRawDate(frontmatter.frontmatterText);
|
||||
const targetDate = resolveArticleDate(dateValue, rawDate, config);
|
||||
|
||||
if (!targetDate) {
|
||||
return { status: "invalid-date" };
|
||||
}
|
||||
|
||||
const weather = await fetchWeather(targetDate, config);
|
||||
|
||||
let finalWeather = {};
|
||||
if (existingWeather && typeof existingWeather === "object") {
|
||||
finalWeather = JSON.parse(JSON.stringify(existingWeather));
|
||||
}
|
||||
|
||||
if (weather) {
|
||||
const added = mergeWeather(finalWeather, weather, weather.source?.[0]);
|
||||
|
||||
if (!added && Object.keys(finalWeather).length === 0) {
|
||||
finalWeather = weather;
|
||||
}
|
||||
}
|
||||
|
||||
frontmatter.doc.set("weather", finalWeather);
|
||||
|
||||
await writeFrontmatter(filePath, frontmatter.doc, frontmatter.body);
|
||||
|
||||
return {
|
||||
status: weather ? "updated" : "empty",
|
||||
sources: weather?.source || [],
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliArgs = process.argv.slice(2);
|
||||
const force = cliArgs.includes("--force") || cliArgs.includes("-f");
|
||||
const pathArgs = cliArgs.filter((arg) => arg !== "--force" && arg !== "-f");
|
||||
|
||||
const config = loadWeatherConfig();
|
||||
if (!hasConfiguredProvider(config)) {
|
||||
console.error("No weather provider configured. Update tools/config.json (weather.providers) before running this script.");
|
||||
process.exit(1);
|
||||
}
|
||||
const files = await resolveTargets(pathArgs);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No matching markdown files found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = path.relative(process.cwd(), file);
|
||||
|
||||
try {
|
||||
const result = await processFile(file, config, { force });
|
||||
|
||||
switch (result.status) {
|
||||
case "updated":
|
||||
updated += 1;
|
||||
console.log(`✔ Added weather to ${relativePath} (${result.sources.join(", ") || "unknown source"})`);
|
||||
break;
|
||||
case "empty":
|
||||
updated += 1;
|
||||
console.log(`• Added empty weather to ${relativePath}`);
|
||||
break;
|
||||
default:
|
||||
skipped += 1;
|
||||
console.log(`↷ Skipped ${relativePath} (${result.status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
skipped += 1;
|
||||
console.error(`✖ Failed ${relativePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nSummary: ${updated} updated, ${skipped} skipped.`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -15,5 +15,83 @@
|
||||
"userAgent": null,
|
||||
"enableCookies": true,
|
||||
"cookieJar": "tools/cache/curl_cookies.txt"
|
||||
},
|
||||
"weather": {
|
||||
"timezone": "Europe/Paris",
|
||||
"defaultHour": 12,
|
||||
"defaultMinute": 0,
|
||||
"windowMinutes": 60,
|
||||
"precipitationThreshold": 0.1,
|
||||
"providers": {
|
||||
"influxdb": {
|
||||
"url": "",
|
||||
"org": "Dern",
|
||||
"bucket": "weather",
|
||||
"token": "==",
|
||||
"windowMinutes": 60,
|
||||
"precipitationThreshold": 0.1,
|
||||
"sensors": {
|
||||
"temperature": {
|
||||
"measurement": "°C",
|
||||
"field": "value",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_temperature"
|
||||
}
|
||||
},
|
||||
"humidity": {
|
||||
"measurement": "%",
|
||||
"field": "value",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_humidite_relative"
|
||||
}
|
||||
},
|
||||
"pressure": {
|
||||
"measurement": "hPa",
|
||||
"field": "value",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_pression_atmospherique"
|
||||
}
|
||||
},
|
||||
"illuminance": {
|
||||
"measurement": "lx",
|
||||
"field": "value",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_luminance"
|
||||
}
|
||||
},
|
||||
"precipitations": {
|
||||
"measurement": "mm/h",
|
||||
"field": "value",
|
||||
"threshold": 0.1,
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_precipitations"
|
||||
}
|
||||
},
|
||||
"wind_speed": {
|
||||
"measurement": "km/h",
|
||||
"field": "value",
|
||||
"unit": "km/h",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_vitesse_du_vent"
|
||||
}
|
||||
},
|
||||
"wind_direction": {
|
||||
"measurement": "°",
|
||||
"field": "value",
|
||||
"tags": {
|
||||
"entity_id": "station_meteo_bresser_exterieur_direction_du_vent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"openMeteo": {
|
||||
"latitude": ,
|
||||
"longitude": ,
|
||||
"timezone": "Europe/Paris",
|
||||
"pressureOffset": 40,
|
||||
"windowMinutes": 90,
|
||||
"precipitationThreshold": 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
tools/lib/weather/config.js
Normal file
60
tools/lib/weather/config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const DEFAULT_WEATHER_CONFIG = {
|
||||
timezone: "Europe/Paris",
|
||||
defaultHour: 12,
|
||||
defaultMinute: 0,
|
||||
windowMinutes: 60,
|
||||
precipitationThreshold: 0.1,
|
||||
providers: {
|
||||
influxdb: {
|
||||
windowMinutes: 60,
|
||||
},
|
||||
openMeteo: {
|
||||
timezone: null,
|
||||
pressureOffset: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function loadWeatherConfig(configPath = path.resolve(__dirname, "..", "..", "config.json")) {
|
||||
let raw = {};
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch (error) {
|
||||
console.error(`Unable to read weather config at ${configPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const weather = raw.weather || {};
|
||||
|
||||
const providers = {
|
||||
...DEFAULT_WEATHER_CONFIG.providers,
|
||||
...(weather.providers || {}),
|
||||
};
|
||||
|
||||
providers.influxdb = {
|
||||
...DEFAULT_WEATHER_CONFIG.providers.influxdb,
|
||||
...(weather.providers?.influxdb || {}),
|
||||
};
|
||||
|
||||
providers.openMeteo = {
|
||||
...DEFAULT_WEATHER_CONFIG.providers.openMeteo,
|
||||
timezone: weather.providers?.openMeteo?.timezone || weather.timezone || DEFAULT_WEATHER_CONFIG.timezone,
|
||||
...(weather.providers?.openMeteo || {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...DEFAULT_WEATHER_CONFIG,
|
||||
...weather,
|
||||
providers,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WEATHER_CONFIG,
|
||||
loadWeatherConfig,
|
||||
};
|
||||
18
tools/lib/weather/constants.js
Normal file
18
tools/lib/weather/constants.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const WEATHER_FIELDS = [
|
||||
"temperature",
|
||||
"humidity",
|
||||
"pressure",
|
||||
"illuminance",
|
||||
"precipitations",
|
||||
"wind_speed",
|
||||
"wind_direction",
|
||||
];
|
||||
|
||||
function hasValue(value) {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WEATHER_FIELDS,
|
||||
hasValue,
|
||||
};
|
||||
41
tools/lib/weather/frontmatter.js
Normal file
41
tools/lib/weather/frontmatter.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const fs = require("fs/promises");
|
||||
const YAML = require("yaml");
|
||||
|
||||
const FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/;
|
||||
|
||||
async function readFrontmatter(filePath) {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
const match = raw.match(FRONTMATTER_PATTERN);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const frontmatterText = match[1];
|
||||
const doc = YAML.parseDocument(frontmatterText);
|
||||
|
||||
if (doc.errors.length) {
|
||||
const [error] = doc.errors;
|
||||
throw new Error(`Invalid frontmatter in ${filePath}: ${error.message}`);
|
||||
}
|
||||
|
||||
const body = raw.slice(match[0].length);
|
||||
|
||||
return { doc, body, frontmatterText };
|
||||
}
|
||||
|
||||
async function writeFrontmatter(filePath, doc, body) {
|
||||
const serialized = doc.toString().trimEnd();
|
||||
const nextContent = `---\n${serialized}\n---\n${body}`;
|
||||
|
||||
await fs.writeFile(filePath, nextContent, "utf8");
|
||||
}
|
||||
|
||||
function extractRawDate(frontmatterText) {
|
||||
const match = frontmatterText.match(/^date:\s*(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractRawDate,
|
||||
readFrontmatter,
|
||||
writeFrontmatter,
|
||||
};
|
||||
58
tools/lib/weather/providers/index.js
Normal file
58
tools/lib/weather/providers/index.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const { WEATHER_FIELDS, hasValue } = require("../constants");
|
||||
const { createInfluxProvider } = require("./influxdb");
|
||||
const { createOpenMeteoProvider } = require("./open-meteo");
|
||||
|
||||
function mergeWeather(target, addition, providerName) {
|
||||
let added = false;
|
||||
|
||||
for (const field of WEATHER_FIELDS) {
|
||||
if (!hasValue(addition[field])) continue;
|
||||
if (hasValue(target[field])) continue;
|
||||
|
||||
target[field] = addition[field];
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (added) {
|
||||
const existing = target.source || [];
|
||||
const nextSources = Array.from(new Set([...existing, ...(addition.source || [providerName])].filter(Boolean)));
|
||||
target.source = nextSources;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function buildProviders(config) {
|
||||
const providers = [];
|
||||
const influx = createInfluxProvider(config.providers?.influxdb, config);
|
||||
const openMeteo = createOpenMeteoProvider(config.providers?.openMeteo, config);
|
||||
|
||||
if (influx) providers.push(influx);
|
||||
if (openMeteo) providers.push(openMeteo);
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
async function fetchWeather(targetDateTime, config) {
|
||||
const providers = buildProviders(config);
|
||||
const weather = {};
|
||||
|
||||
for (const provider of providers) {
|
||||
const result = await provider.fetch({ target: targetDateTime });
|
||||
|
||||
if (!result) continue;
|
||||
|
||||
mergeWeather(weather, result, provider.name);
|
||||
|
||||
const complete = WEATHER_FIELDS.every((field) => hasValue(weather[field]));
|
||||
if (complete) break;
|
||||
}
|
||||
|
||||
return Object.keys(weather).length > 0 ? weather : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchWeather,
|
||||
hasConfiguredProvider: (config) => buildProviders(config).length > 0,
|
||||
mergeWeather,
|
||||
};
|
||||
137
tools/lib/weather/providers/influxdb.js
Normal file
137
tools/lib/weather/providers/influxdb.js
Normal file
@@ -0,0 +1,137 @@
|
||||
const { InfluxDB } = require("@influxdata/influxdb-client");
|
||||
const { DateTime } = require("luxon");
|
||||
const { buildTimeWindow } = require("../time");
|
||||
const { hasValue } = require("../constants");
|
||||
|
||||
function escapeValue(value) {
|
||||
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function buildFluxQuery(bucket, sensor, window) {
|
||||
const startIso = window.start.toUTC().toISO();
|
||||
const stopIso = window.end.toUTC().toISO();
|
||||
const filters = [];
|
||||
|
||||
if (sensor.measurement) filters.push(`r._measurement == "${escapeValue(sensor.measurement)}"`);
|
||||
if (sensor.field) filters.push(`r._field == "${escapeValue(sensor.field)}"`);
|
||||
|
||||
if (sensor.tags) {
|
||||
for (const [key, value] of Object.entries(sensor.tags)) {
|
||||
filters.push(`r.${key} == "${escapeValue(value)}"`);
|
||||
}
|
||||
}
|
||||
|
||||
let query = `from(bucket: "${escapeValue(bucket)}")\n |> range(start: time(v: "${startIso}"), stop: time(v: "${stopIso}"))`;
|
||||
|
||||
if (filters.length > 0) {
|
||||
query += `\n |> filter(fn: (r) => ${filters.join(" and ")})`;
|
||||
}
|
||||
|
||||
query += "\n |> keep(columns: [\"_time\", \"_value\"])";
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function pickNearestRow(rows, target) {
|
||||
let closest = null;
|
||||
let smallestDiff = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!hasValue(row._time)) continue;
|
||||
|
||||
const rowTime = DateTime.fromISO(row._time);
|
||||
if (!rowTime.isValid) continue;
|
||||
|
||||
const diff = Math.abs(rowTime.diff(target).as("milliseconds"));
|
||||
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
closest = { time: rowTime, value: row._value };
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
function normalizeValue(key, rawValue, sensor, precipitationThreshold) {
|
||||
if (!hasValue(rawValue)) return null;
|
||||
|
||||
let value = typeof rawValue === "number" ? rawValue : Number(rawValue);
|
||||
|
||||
if (Number.isNaN(value)) return null;
|
||||
|
||||
if (sensor.multiplier && Number.isFinite(sensor.multiplier)) {
|
||||
value *= sensor.multiplier;
|
||||
}
|
||||
|
||||
if (sensor.offset && Number.isFinite(sensor.offset)) {
|
||||
value += sensor.offset;
|
||||
}
|
||||
|
||||
if (key === "wind_speed" && sensor.unit === "mps") {
|
||||
value *= 3.6;
|
||||
}
|
||||
|
||||
if (key === "precipitations") {
|
||||
const threshold = Number.isFinite(sensor.threshold) ? sensor.threshold : precipitationThreshold;
|
||||
|
||||
if (!Number.isFinite(threshold)) return null;
|
||||
|
||||
return value > threshold;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function createInfluxProvider(config = {}, globalConfig = {}) {
|
||||
const { url, token, org, bucket, sensors } = config;
|
||||
|
||||
if (!url || !token || !org || !bucket || !sensors || Object.keys(sensors).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryApi = new InfluxDB({ url, token }).getQueryApi(org);
|
||||
const windowMinutes = config.windowMinutes ?? globalConfig.windowMinutes ?? 60;
|
||||
const precipitationThreshold = config.precipitationThreshold ?? globalConfig.precipitationThreshold ?? 0.1;
|
||||
|
||||
async function fetch({ target }) {
|
||||
const window = buildTimeWindow(target, windowMinutes);
|
||||
const weather = {};
|
||||
const contributed = new Set();
|
||||
|
||||
for (const [key, sensor] of Object.entries(sensors)) {
|
||||
const query = buildFluxQuery(bucket, sensor, window);
|
||||
|
||||
try {
|
||||
const rows = await queryApi.collectRows(query);
|
||||
const nearest = pickNearestRow(rows, target);
|
||||
|
||||
if (!nearest) continue;
|
||||
|
||||
const value = normalizeValue(key, nearest.value, sensor, precipitationThreshold);
|
||||
|
||||
if (!hasValue(value)) continue;
|
||||
|
||||
weather[key] = value;
|
||||
contributed.add("influxdb");
|
||||
} catch (error) {
|
||||
console.error(`InfluxDB error for ${key}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(weather).length === 0) return null;
|
||||
|
||||
weather.source = Array.from(contributed);
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "influxdb",
|
||||
fetch,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInfluxProvider,
|
||||
};
|
||||
157
tools/lib/weather/providers/open-meteo.js
Normal file
157
tools/lib/weather/providers/open-meteo.js
Normal file
@@ -0,0 +1,157 @@
|
||||
const { fetch: httpFetch } = require("undici");
|
||||
const { DateTime } = require("luxon");
|
||||
const { hasValue } = require("../constants");
|
||||
const { buildTimeWindow } = require("../time");
|
||||
|
||||
function isConfigured(config) {
|
||||
return config && Number.isFinite(config.latitude) && Number.isFinite(config.longitude);
|
||||
}
|
||||
|
||||
function normalizeTarget(target, zone) {
|
||||
if (!target) return null;
|
||||
if (DateTime.isDateTime(target)) return target.setZone(zone || undefined);
|
||||
if (target instanceof Date) return DateTime.fromJSDate(target, { zone });
|
||||
if (typeof target === "string") return DateTime.fromISO(target, { zone });
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectNearestHour(hourly, target, timezone, windowMinutes) {
|
||||
if (!hourly || !Array.isArray(hourly.time) || hourly.time.length === 0) return null;
|
||||
|
||||
const window = buildTimeWindow(target, windowMinutes);
|
||||
let nearest = null;
|
||||
let smallestDiff = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let i = 0; i < hourly.time.length; i++) {
|
||||
const timeValue = DateTime.fromISO(hourly.time[i], { zone: timezone });
|
||||
if (!timeValue.isValid) continue;
|
||||
|
||||
if (timeValue < window.start || timeValue > window.end) continue;
|
||||
|
||||
const diff = Math.abs(timeValue.diff(target).as("milliseconds"));
|
||||
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
nearest = {
|
||||
time: timeValue,
|
||||
temperature_2m: hourly.temperature_2m?.[i],
|
||||
relative_humidity_2m: hourly.relative_humidity_2m?.[i],
|
||||
surface_pressure: hourly.surface_pressure?.[i],
|
||||
shortwave_radiation: hourly.shortwave_radiation?.[i],
|
||||
precipitation: hourly.precipitation?.[i],
|
||||
wind_speed_10m: hourly.wind_speed_10m?.[i],
|
||||
wind_direction_10m: hourly.wind_direction_10m?.[i],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function createOpenMeteoProvider(config = {}, globalConfig = {}) {
|
||||
if (!isConfigured(config)) return null;
|
||||
|
||||
const windowMinutes = config.windowMinutes ?? globalConfig.windowMinutes ?? 60;
|
||||
const precipitationThreshold = config.precipitationThreshold ?? globalConfig.precipitationThreshold ?? 0.1;
|
||||
const pressureOffset = Number.isFinite(config.pressureOffset) ? config.pressureOffset : 0;
|
||||
const timezone = config.timezone || globalConfig.timezone || "UTC";
|
||||
|
||||
async function fetchData({ target }) {
|
||||
const normalized = normalizeTarget(target, timezone);
|
||||
|
||||
if (!normalized || !normalized.isValid) return null;
|
||||
|
||||
const isFuture = normalized > DateTime.now().setZone(timezone).plus({ days: 1 });
|
||||
const baseUrl = isFuture ? "https://api.open-meteo.com/v1/forecast" : "https://archive-api.open-meteo.com/v1/archive";
|
||||
const params = new URLSearchParams({
|
||||
latitude: config.latitude.toString(),
|
||||
longitude: config.longitude.toString(),
|
||||
timezone,
|
||||
start_date: normalized.toISODate(),
|
||||
end_date: normalized.toISODate(),
|
||||
hourly: [
|
||||
"temperature_2m",
|
||||
"relative_humidity_2m",
|
||||
"surface_pressure",
|
||||
"shortwave_radiation",
|
||||
"precipitation",
|
||||
"wind_speed_10m",
|
||||
"wind_direction_10m",
|
||||
].join(","),
|
||||
});
|
||||
|
||||
if (isFuture) {
|
||||
params.set("forecast_days", "16");
|
||||
params.set("past_days", "7");
|
||||
}
|
||||
|
||||
const url = `${baseUrl}?${params.toString()}`;
|
||||
let json;
|
||||
|
||||
try {
|
||||
const response = await httpFetch(url);
|
||||
|
||||
if (!response || typeof response.ok === "undefined") {
|
||||
const sanitizedUrl = url
|
||||
.replace(/(latitude=)[^&]+/, "$1***")
|
||||
.replace(/(longitude=)[^&]+/, "$1***");
|
||||
console.error("Open-Meteo unexpected response", {
|
||||
type: typeof response,
|
||||
ctor: response?.constructor?.name,
|
||||
url: sanitizedUrl,
|
||||
});
|
||||
throw new Error("Invalid response object");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let details = "";
|
||||
try {
|
||||
details = await response.text();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
const suffix = details ? `: ${details}` : "";
|
||||
throw new Error(`HTTP ${response.status}${suffix}`);
|
||||
}
|
||||
|
||||
json = await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Open-Meteo error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!json?.hourly) return null;
|
||||
|
||||
const nearest = selectNearestHour(json.hourly, target, timezone, windowMinutes);
|
||||
if (!nearest) return null;
|
||||
|
||||
const weather = {};
|
||||
|
||||
if (hasValue(nearest.temperature_2m)) weather.temperature = nearest.temperature_2m;
|
||||
if (hasValue(nearest.relative_humidity_2m)) weather.humidity = nearest.relative_humidity_2m;
|
||||
if (hasValue(nearest.surface_pressure)) weather.pressure = nearest.surface_pressure + pressureOffset;
|
||||
if (hasValue(nearest.shortwave_radiation)) weather.illuminance = nearest.shortwave_radiation;
|
||||
|
||||
if (hasValue(nearest.precipitation)) {
|
||||
weather.precipitations = nearest.precipitation > precipitationThreshold;
|
||||
}
|
||||
|
||||
if (hasValue(nearest.wind_speed_10m)) weather.wind_speed = nearest.wind_speed_10m;
|
||||
if (hasValue(nearest.wind_direction_10m)) weather.wind_direction = nearest.wind_direction_10m;
|
||||
|
||||
if (Object.keys(weather).length === 0) return null;
|
||||
|
||||
weather.source = ["open-meteo"];
|
||||
|
||||
return weather;
|
||||
}
|
||||
|
||||
return {
|
||||
name: "open-meteo",
|
||||
fetch: fetchData,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOpenMeteoProvider,
|
||||
};
|
||||
46
tools/lib/weather/time.js
Normal file
46
tools/lib/weather/time.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const { DateTime } = require("luxon");
|
||||
|
||||
function hasExplicitTime(rawDate) {
|
||||
if (!rawDate) return false;
|
||||
const cleaned = rawDate.replace(/^['"]|['"]$/g, "");
|
||||
return /\d{2}:\d{2}/.test(cleaned);
|
||||
}
|
||||
|
||||
function resolveArticleDate(dateValue, rawDate, { timezone = "Europe/Paris", defaultHour = 12, defaultMinute = 0 } = {}) {
|
||||
const hasTime = hasExplicitTime(rawDate);
|
||||
const zone = timezone || "UTC";
|
||||
|
||||
let parsed;
|
||||
|
||||
if (typeof dateValue === "string") {
|
||||
parsed = DateTime.fromISO(dateValue, { zone });
|
||||
|
||||
if (!parsed.isValid) {
|
||||
parsed = DateTime.fromRFC2822(dateValue, { zone });
|
||||
}
|
||||
} else if (dateValue instanceof Date) {
|
||||
parsed = DateTime.fromJSDate(dateValue, { zone });
|
||||
}
|
||||
|
||||
if (!parsed || !parsed.isValid) return null;
|
||||
|
||||
if (!hasTime) {
|
||||
parsed = parsed.set({ hour: defaultHour, minute: defaultMinute, second: 0, millisecond: 0 });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildTimeWindow(target, windowMinutes = 60) {
|
||||
const minutes = Number.isFinite(windowMinutes) ? windowMinutes : 60;
|
||||
return {
|
||||
start: target.minus({ minutes }),
|
||||
end: target.plus({ minutes }),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTimeWindow,
|
||||
hasExplicitTime,
|
||||
resolveArticleDate,
|
||||
};
|
||||
Reference in New Issue
Block a user