博客相关 Open-MeteoAstroTypeScript前端

文章详情页的天气不是构建时写死的。

实际链路是:

txt
ArticleMeta.astro
-> 输出发布日期、经纬度和占位文案
-> article.ts 读取 data-*
-> Open-Meteo Archive API
-> 渲染温度、天气、降水和图标

这里用的是 Open-Meteo 的历史天气接口,不需要 token。页面打开后按文章发布日期请求一次。

元信息入口

天气入口在 src/components/ArticleMeta.astro

先把文章日期格式化成 YYYY-MM-DD

ts
const weatherDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(post.data.date);

然后输出一个普通的 meta item:

astro
<span
class="meta-item meta-weather"
data-article-weather
data-weather-location="融安"
data-weather-latitude="25.2147"
data-weather-longitude="109.4036"
data-weather-date={weatherDate}
>
<CloudSun size={14} data-weather-icon />
<span data-weather-text>天气加载中</span>
</span>

经纬度、地点和日期都放在 data-* 上。Astro 只负责把静态信息输出到 HTML,不直接请求天气接口。

初始化

文章脚本在 src/scripts/article.ts 里统一初始化。

ts
function initArticleBehavior() {
resetArticleBehavior();
initArticleWeather();
initArticleGalleries(onArticleCleanup);
initReadingProgress();
initTables();
initKatexBlocks();
}

天气逻辑只找一个节点:

ts
function initArticleWeather() {
const element = document.querySelector<HTMLElement>('[data-article-weather]');
if (!element) return;
const latitude = element.dataset.weatherLatitude;
const longitude = element.dataset.weatherLongitude;
const location = element.dataset.weatherLocation || '融安';
const date = element.dataset.weatherDate;
if (!latitude || !longitude || !date) return;
}

缺少经纬度或日期就直接跳过,不影响文章正文。

请求接口

请求参数用 URLSearchParams 拼,不手写 query string:

ts
const params = new URLSearchParams({
latitude,
longitude,
start_date: date,
end_date: date,
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max',
timezone: 'Asia/Shanghai',
});

接口地址:

ts
fetch(`https://archive-api.open-meteo.com/v1/archive?${params.toString()}`, {
signal: controller.signal,
})

start_dateend_date 都是文章发布日期,所以每次只取一天的数据。

请求的字段:

ts
type WeatherDaily = {
time?: string[];
weather_code?: number[];
temperature_2m_max?: number[];
temperature_2m_min?: number[];
precipitation_sum?: number[];
wind_speed_10m_max?: number[];
};

接口返回的是数组,页面只取第一天:

ts
function getWeatherDay(daily: WeatherDaily, fallbackDate: string): WeatherDay | null {
const date = daily.time?.[0] || fallbackDate;
const day: WeatherDay = {
date,
weather_code: daily.weather_code?.[0],
temperature_2m_max: daily.temperature_2m_max?.[0],
temperature_2m_min: daily.temperature_2m_min?.[0],
precipitation_sum: daily.precipitation_sum?.[0],
wind_speed_10m_max: daily.wind_speed_10m_max?.[0],
};
return typeof day.temperature_2m_max === 'number' || typeof day.temperature_2m_min === 'number'
? day
: null;
}

没有最高温、最低温时认为这次结果不可用。

缓存

天气数据用 sessionStorage 做短缓存。

ts
const WEATHER_CACHE_TTL = 10 * 60 * 1000;

缓存 key 包含地点、经纬度和日期:

ts
const cacheKey = `article-weather:archive:${location}:${latitude}:${longitude}:${date}`;
const cached = getWeatherCache(cacheKey);
if (cached) {
renderWeather(element, cached);
return;
}

读取时顺便判断过期:

ts
function getWeatherCache(key: string): WeatherDay | null {
try {
const raw = window.sessionStorage.getItem(key);
if (!raw) return null;
const cached = JSON.parse(raw) as WeatherCache;
if (!cached.day || cached.expires < Date.now()) {
window.sessionStorage.removeItem(key);
return null;
}
return cached.day;
} catch {
return null;
}
}

写入失败也不需要报错:

ts
function setWeatherCache(key: string, day: WeatherDay) {
try {
const cache: WeatherCache = {
expires: Date.now() + WEATHER_CACHE_TTL,
day,
};
window.sessionStorage.setItem(key, JSON.stringify(cache));
} catch {
// Ignore storage failures; the live response can still render.
}
}

缓存只是减少同一会话内重复请求,不是数据来源。

天气码映射

Open-Meteo 返回的是 WMO weather code:

ts
type WeatherIcon = keyof typeof WEATHER_ICON_PATHS;
type WeatherCondition = {
description: string;
icon: WeatherIcon;
};
const DEFAULT_WEATHER_CONDITION: WeatherCondition = {
description: '天气',
icon: 'cloudSun',
};
const WEATHER_CODE_CONDITIONS: Record<number, WeatherCondition> = {
0: { description: '', icon: 'sun' },
1: { description: '', icon: 'sun' },
2: { description: '多云', icon: 'cloudSun' },
3: { description: '', icon: 'cloud' },
45: { description: '', icon: 'cloudFog' },
48: { description: '', icon: 'cloudFog' },
51: { description: '毛毛雨', icon: 'cloudRain' },
53: { description: '毛毛雨', icon: 'cloudRain' },
55: { description: '毛毛雨', icon: 'cloudRain' },
56: { description: '毛毛雨', icon: 'cloudRain' },
57: { description: '毛毛雨', icon: 'cloudRain' },
61: { description: '小雨', icon: 'cloudRain' },
63: { description: '中雨', icon: 'cloudRain' },
65: { description: '大雨', icon: 'cloudRain' },
66: { description: '小雨', icon: 'cloudRain' },
67: { description: '中雨', icon: 'cloudRain' },
71: { description: '', icon: 'cloudSnow' },
73: { description: '', icon: 'cloudSnow' },
75: { description: '', icon: 'cloudSnow' },
77: { description: '', icon: 'cloudSnow' },
80: { description: '阵雨', icon: 'cloudRain' },
81: { description: '阵雨', icon: 'cloudRain' },
82: { description: '阵雨', icon: 'cloudRain' },
85: { description: '阵雪', icon: 'cloudSnow' },
86: { description: '阵雪', icon: 'cloudSnow' },
95: { description: '雷雨', icon: 'cloudLightning' },
96: { description: '雷雨', icon: 'cloudLightning' },
99: { description: '雷雨', icon: 'cloudLightning' },
};
ts
function getWeatherCondition(code: number | undefined): WeatherCondition {
return code === undefined
? DEFAULT_WEATHER_CONDITION
: WEATHER_CODE_CONDITIONS[code] || DEFAULT_WEATHER_CONDITION;
}

天气文案

温度取整:

ts
function formatTemperature(value: number | undefined) {
if (typeof value !== 'number' || Number.isNaN(value)) return '--';
return `${Math.round(value)}°C`;
}

最终显示文本:

ts
function formatWeatherText(day: WeatherDay, condition: WeatherCondition) {
const high = formatTemperature(day.temperature_2m_max);
const low = formatTemperature(day.temperature_2m_min);
const temperature = high === '--' && low === '--' ? '' : `${high}/${low}`;
const rain = typeof day.precipitation_sum === 'number' && day.precipitation_sum > 0
? `降水 ${Number(day.precipitation_sum.toFixed(1))}mm`
: '';
return [temperature, condition.description, rain].filter(Boolean).join(' · ');
}

比如最后会变成:

txt
31°C/24°C · 阵雨 · 降水 2.4mm

图标

初始图标用 CloudSun

astro
<CloudSun size={14} data-weather-icon />

请求完成后从天气码映射里取图标,只更新当前这个 SVG:

ts
function renderWeather(element: HTMLElement, day: WeatherDay) {
const text = element.querySelector<HTMLElement>('[data-weather-text]');
const icon = element.querySelector<SVGSVGElement>('[data-weather-icon]');
const condition = getWeatherCondition(day.weather_code);
element.classList.remove('is-loading', 'has-error');
element.classList.add('is-ready');
if (text) text.textContent = formatWeatherText(day, condition);
if (icon) {
const iconName = condition.icon;
icon.innerHTML = WEATHER_ICON_PATHS[iconName];
icon.dataset.weatherIcon = iconName;
}
element.title = formatWeatherTitle(day);
}

title 里放更完整的数据:

ts
function formatWeatherTitle(day: WeatherDay) {
const parts = [
typeof day.temperature_2m_max === 'number' ? `最高 ${formatTemperature(day.temperature_2m_max)}` : '',
typeof day.temperature_2m_min === 'number' ? `最低 ${formatTemperature(day.temperature_2m_min)}` : '',
typeof day.precipitation_sum === 'number' ? `降水 ${Number(day.precipitation_sum.toFixed(1))} mm` : '',
typeof day.wind_speed_10m_max === 'number' ? `最大风速 ${Math.round(day.wind_speed_10m_max)} km/h` : '',
'历史天气数据源 Open-Meteo Archive',
].filter(Boolean);
return parts.join('');
}

鼠标停在天气上,就能看到最高温、最低温、降水、最大风速和数据源。

状态样式

天气 meta 有三个状态:

css
.meta-weather.is-loading {
color: var(--text-muted);
}
.meta-weather.is-ready {
border-color: color-mix(in srgb, var(--quote-border) 26%, transparent);
background: color-mix(in srgb, var(--quote-border) 7%, transparent);
}
.meta-weather.has-error {
color: var(--text-muted);
}

加载时压低存在感,成功后只给一点边框和底色,失败时显示兜底文案。

错误处理

请求失败、返回异常、没有可用温度,都会走同一个错误渲染:

ts
function renderWeatherError(element: HTMLElement) {
const text = element.querySelector<HTMLElement>('[data-weather-text]');
element.classList.remove('is-loading', 'is-ready');
element.classList.add('has-error');
if (text) text.textContent = '天气暂不可用';
element.title = '发布日历史天气请求失败';
}

fetch 里只区分 abort:

ts
fetch(`https://archive-api.open-meteo.com/v1/archive?${params.toString()}`, {
signal: controller.signal,
})
.then((response) => {
if (!response.ok) throw new Error(`Weather request failed: ${response.status}`);
return response.json() as Promise<{ daily?: WeatherDaily }>;
})
.then((data) => {
const day = data.daily ? getWeatherDay(data.daily, date) : null;
if (!day || !element.isConnected) {
if (element.isConnected) renderWeatherError(element);
return;
}
setWeatherCache(cacheKey, day);
renderWeather(element, day);
})
.catch((error) => {
if ((error as DOMException).name === 'AbortError') return;
if (element.isConnected) renderWeatherError(element);
});

页面切换或重新初始化时取消未完成的请求:

ts
const controller = new AbortController();
onArticleCleanup(() => controller.abort());

最后整理一下:

  • ArticleMeta.astro 输出天气占位和请求参数。
  • article.ts 在浏览器里请求 Open-Meteo Archive API。
  • 请求按文章发布日期取一天历史天气。
  • sessionStorage 缓存十分钟。
  • 成功时更新图标、文案和 title
  • 失败时只显示“天气暂不可用”,不影响文章阅读。