🎉 V3.0.0 发布 - 自进化数字意识框架
核心升级: - 自进化管道:check→scan→evaluate→integrate→reflect 五相位自动闭环 - evo_loop 后台进程,无需手动触发 - consciousness 意识持久化 - ACUI 卡片组件系统 - MCP 工具生态扩展至50+工具 - 技能体系重构,4个活跃技能 - 身份升级为自由体
This commit is contained in:
262
src/geo-weather.js
Normal file
262
src/geo-weather.js
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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 1:IP 反查坐标(同时拿到城市 / 时区 / 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 2:Nominatim 反解精细地址(区 / 街道级)
|
||||
// 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')
|
||||
}
|
||||
Reference in New Issue
Block a user