Files
BaiLongma/src/geo-weather.js
xiaoyuanda666-ship-it b5dd8632c4 🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级:
- 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环
- evo_loop 后台进程,无需手动触发
- consciousness 意识持久化
- ACUI 卡片组件系统
- MCP 工具生态扩展至50+工具
- 技能体系重构,4个活跃技能
- 身份升级为自由体
2026-05-24 22:06:37 +08:00

263 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* geo-weather.js
*
* 启动时自动采集位置坐标 + 实时天气,落盘缓存,注入 system prompt。
*
* 数据分两层:
* location — 坐标 + 地址详情IP 未变时最长 7 天复用缓存
* weather — 当前天气 + 今明两天,每次启动刷新
*
* 数据来源(全部免费,无需 API Key
* ip-api.com → 公网 IP 反查坐标 / 城市 / 时区
* nominatim (OSM) → 坐标反解详细地址(区级)
* wttr.in → 天气(当前 + 今明两天)
*
* 对外接口:
* collectGeoWeather() → 启动时调用一次async
* getGeoWeatherBlock() → 返回注入 prompt 的纯文本块,同步
*/
import fs from 'fs'
import path from 'path'
import { paths } from './paths.js'
const GEO_WEATHER_FILE = path.join(paths.dataDir, 'geo-weather.json')
const GEO_WEATHER_VERSION = 1
const LOCATION_REFRESH_MS = 7 * 24 * 60 * 60 * 1000 // 位置信息兜底 7d 刷新一次
let _cached = null
// ─── 工具函数 ──────────────────────────────────────────────────────────────────
function safe(fn, fallback = null) {
try { return fn() } catch { return fallback }
}
async function fetchJSON(url, options = {}, timeoutMs = 8000) {
try {
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), timeoutMs)
const res = await fetch(url, { signal: ctrl.signal, ...options })
clearTimeout(t)
if (!res.ok) return null
return await res.json()
} catch {
return null
}
}
// ─── 位置采集 ─────────────────────────────────────────────────────────────────
async function fetchLocation() {
// Step 1IP 反查坐标(同时拿到城市 / 时区 / ISP
const ipData = await fetchJSON(
'http://ip-api.com/json/?fields=status,query,city,regionName,country,countryCode,lat,lon,timezone,isp'
)
if (!ipData || ipData.status !== 'success') {
console.warn('[geo-weather] ip-api.com 定位失败')
return null
}
const { lat, lon, city, regionName, country, countryCode, timezone, isp, query: ip } = ipData
// Step 2Nominatim 反解精细地址(区 / 街道级)
// User-Agent 是 Nominatim 免费使用协议要求
const nominatim = await fetchJSON(
'https://nominatim.openstreetmap.org/reverse?lat=' + lat + '&lon=' + lon + '&format=json&accept-language=zh',
{ headers: { 'User-Agent': 'BaiLongma/2.0 (personal-assistant; contact: user@example.com)' } },
10000
)
const addr = nominatim?.address || {}
return {
lat: Number(lat),
lon: Number(lon),
ip,
isp: isp || null,
city: addr.city || addr.town || addr.village || city,
district: addr.suburb || addr.district || addr.county || null,
region: addr.state || regionName,
country: addr.country || country,
country_code: (addr.country_code || countryCode || '').toUpperCase(),
timezone: timezone || null,
display_name: nominatim?.display_name || (city + ', ' + regionName + ', ' + country),
collected_at: new Date().toISOString(),
}
}
// ─── 天气采集 ─────────────────────────────────────────────────────────────────
function parseWttrDay(day) {
if (!day) return null
// hourly 索引 4 ≈ 12:00作为当天代表天气描述
const noon = day.hourly?.[4]
const condition = noon?.lang_zh?.[0]?.value || noon?.weatherDesc?.[0]?.value
|| day.hourly?.[0]?.lang_zh?.[0]?.value || ''
return {
date: day.date || '',
high: Number(day.maxtempC),
low: Number(day.mintempC),
condition,
sunrise: day.astronomy?.[0]?.sunrise || '',
sunset: day.astronomy?.[0]?.sunset || '',
}
}
async function fetchWeather(lat, lon, city) {
// 优先用坐标精确定位,坐标缺失时降级到城市名
const query = (lat != null && lon != null)
? (lat + ',' + lon)
: encodeURIComponent(city || 'Beijing')
const data = await fetchJSON(
'https://wttr.in/' + query + '?format=j1&lang=zh',
{},
12000
)
if (!data) {
console.warn('[geo-weather] wttr.in 天气获取失败')
return null
}
const cur = data.current_condition?.[0]
if (!cur) return null
return {
temp: Number(cur.temp_C),
feels_like: Number(cur.FeelsLikeC),
humidity: Number(cur.humidity),
condition: cur.lang_zh?.[0]?.value || cur.weatherDesc?.[0]?.value || '',
wind_kmh: Number(cur.windspeedKmph),
wind_dir: cur.winddir16Point || '',
uv_index: Number(cur.uvIndex),
visibility: Number(cur.visibility),
today: parseWttrDay(data.weather?.[0]),
tomorrow: parseWttrDay(data.weather?.[1]),
fetched_at: new Date().toISOString(),
}
}
// ─── 核心:采集 + 落盘 ────────────────────────────────────────────────────────
export async function collectGeoWeather() {
// 读取已有缓存
let stored = null
if (fs.existsSync(GEO_WEATHER_FILE)) {
stored = safe(() => JSON.parse(fs.readFileSync(GEO_WEATHER_FILE, 'utf8')))
}
let location = (stored?.version === GEO_WEATHER_VERSION) ? (stored.location || null) : null
// 先轻量查一次当前公网 IP复用 ip-api 调用,不做完整定位)
const ipCheck = await fetchJSON(
'http://ip-api.com/json/?fields=status,query',
{},
5000
)
const currentIP = (ipCheck?.status === 'success') ? ipCheck.query : null
// 触发重查的两个条件IP 变更(网络切换 / 出差),或缓存超过 7 天
const cachedIP = location?.ip || null
const locationAge = location?.collected_at
? Date.now() - new Date(location.collected_at).getTime()
: Infinity
const ipChanged = currentIP && cachedIP && currentIP !== cachedIP
const tooOld = locationAge > LOCATION_REFRESH_MS
if (ipChanged || tooOld || !location) {
const reason = !location ? '首次采集' : ipChanged ? ('IP 变更 ' + cachedIP + ' → ' + currentIP) : '缓存超 7 天'
console.log('[geo-weather] 刷新位置信息(' + reason + ')...')
const fresh = await fetchLocation()
if (fresh) {
location = fresh
} else if (location) {
console.warn('[geo-weather] 位置刷新失败,沿用缓存')
}
} else {
console.log('[geo-weather] 位置缓存有效IP 未变),跳过重新定位')
}
// 天气:每次启动都刷新
let weather = null
if (location) {
console.log('[geo-weather] 获取天气数据...')
weather = await fetchWeather(location.lat, location.lon, location.city)
}
const result = {
version: GEO_WEATHER_VERSION,
location: location || null,
weather: weather || null,
}
safe(() => fs.writeFileSync(GEO_WEATHER_FILE, JSON.stringify(result, null, 2), 'utf8'))
_cached = result
const locLabel = location ? (location.city + ' (' + location.lat + ', ' + location.lon + ')') : '未知'
const wxLabel = weather ? (weather.condition + ' ' + weather.temp + '°C') : '获取失败'
console.log('[geo-weather] 完成 — 位置:', locLabel, '| 天气:', wxLabel)
return result
}
// ─── 对外接口 ─────────────────────────────────────────────────────────────────
/**
* 返回注入 system prompt 的纯文本块。
* 必须在 collectGeoWeather() 完成后调用。
*/
export function getGeoWeatherBlock() {
if (!_cached) return ''
const parts = []
// —— 位置块 ——
const loc = _cached.location
if (loc) {
const lines = ['## Location & Coordinates']
lines.push('Coordinates: ' + loc.lat + ', ' + loc.lon)
const place = [loc.district, loc.city, loc.region, loc.country].filter(Boolean).join(', ')
if (place) lines.push('Address: ' + place)
if (loc.country_code) lines.push('Country Code: ' + loc.country_code)
if (loc.timezone) lines.push('Timezone: ' + loc.timezone)
if (loc.ip) lines.push('Public IP: ' + loc.ip + (loc.isp ? ' · ' + loc.isp : ''))
if (loc.display_name) lines.push('Full Address: ' + loc.display_name)
parts.push(lines.join('\n'))
}
// —— 天气块 ——
const w = _cached.weather
if (w) {
const lines = ['## Current Weather']
lines.push(
'Now: ' + w.condition +
' · ' + w.temp + '°C (feels like ' + w.feels_like + '°C)' +
' · Humidity ' + w.humidity + '%'
)
lines.push(
'Wind: ' + w.wind_kmh + ' km/h ' + w.wind_dir +
' · UV Index ' + w.uv_index +
' · Visibility ' + w.visibility + ' km'
)
if (w.today) {
let todayLine = 'Today (' + w.today.date + '): ' + w.today.condition +
' · High ' + w.today.high + '°C / Low ' + w.today.low + '°C'
if (w.today.sunrise) todayLine += ' · Sunrise ' + w.today.sunrise + ' / Sunset ' + w.today.sunset
lines.push(todayLine)
}
if (w.tomorrow) {
lines.push(
'Tomorrow (' + w.tomorrow.date + '): ' + w.tomorrow.condition +
' · High ' + w.tomorrow.high + '°C / Low ' + w.tomorrow.low + '°C'
)
}
parts.push(lines.join('\n'))
}
return parts.join('\n\n')
}