v4.3.19-7: NASA EONET/Worldview 위성사진 기능 + weather-app 멀티탭 뷰어
- eonet_events/satellite_snapshot 도구 추가 (키 불필요 공개 API) - weather-app.html: 위성사진 채팅 이미지 렌더링, 지도/사진 멀티탭 센터패널, 확대/축소·드래그팬, localStorage+히스토리 이중 복원으로 새로고침 시 탭 유지(하드리셋으로 localStorage가 지워져도 대화 이력에서 재구성)
This commit is contained in:
@@ -22,6 +22,7 @@ import { audioTranscribeTool } from './audio-transcribe.js';
|
||||
import { pythonEvalTool } from './python.js';
|
||||
import { sqliteTool } from './sqlite.js';
|
||||
import { weatherSearchTool, weatherAirPollutionTool, weatherOpenMeteoTool, weatherKmaTool, weatherAirKoreaTool, weatherNasaPowerTool, weatherEra5Tool, weatherCdsTool, weatherCmip6Tool, weatherMapScreenshotTool } from './weather.js';
|
||||
import { eonetEventsTool, satelliteSnapshotTool } from './satellite.js';
|
||||
import { newsSearchTool } from './news.js';
|
||||
import { emailListTool, emailReadTool, emailSendTool, emailSearchTool, emailDeleteTool } from './email.js';
|
||||
import { kakaoSendTool } from './kakao.js';
|
||||
@@ -223,6 +224,9 @@ class ToolRegistry {
|
||||
this.registerSafe(weatherCdsTool);
|
||||
this.registerSafe(weatherCmip6Tool);
|
||||
this.registerSafe(weatherMapScreenshotTool);
|
||||
// NASA EONET (natural event tracker) + Worldview Snapshots (satellite imagery) — both public, no API key
|
||||
this.registerSafe(eonetEventsTool);
|
||||
this.registerSafe(satelliteSnapshotTool);
|
||||
// News (NewsData.io — real headlines with real publish dates, for news queries)
|
||||
this.registerSafe(newsSearchTool);
|
||||
// Email tools (IMAP + SMTP)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ToolResult } from '../types.js';
|
||||
import { getWorkspacePath } from '../config/paths.js';
|
||||
import { buildImageMarkdown } from './image.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
|
||||
// Both NASA EONET (event metadata) and Worldview Snapshots (satellite imagery)
|
||||
// are fully public — no API key, no registration, no NASA Earthdata login.
|
||||
// Verified 2026-07-26.
|
||||
|
||||
function httpsGetJson(url: string, timeoutMs = 20_000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (c: Buffer) => { raw += c.toString(); });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(raw)); }
|
||||
catch { reject(new Error(`JSON parse error: ${raw.slice(0, 150)}`)); }
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error(`요청 타임아웃 (${timeoutMs / 1000}초)`)); });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function httpsGetBuffer(url: string, timeoutMs = 60_000): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
||||
if ((res.statusCode || 0) >= 400) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error(`요청 타임아웃 (${timeoutMs / 1000}초)`)); });
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ── eonet_events ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EONET_CATEGORIES: Record<string, string> = {
|
||||
wildfires: '산불', severeStorms: '폭풍/태풍', volcanoes: '화산',
|
||||
seaLakeIce: '해빙/호수얼음', drought: '가뭄', dustHaze: '황사/연무',
|
||||
earthquakes: '지진', floods: '홍수', landslides: '산사태',
|
||||
manmade: '인위적 사고', snow: '폭설', tempExtremes: '이상고온/한파', waterColor: '수질이상',
|
||||
};
|
||||
|
||||
export const eonetEventsTool = {
|
||||
name: 'eonet_events',
|
||||
description: 'NASA EONET에서 현재 진행 중이거나 최근 종료된 전 세계 자연재해/이벤트(산불, 태풍, 화산, 지진, 홍수, 산사태, 가뭄, 폭설 등 13종) 목록을 가져온다. 각 이벤트는 좌표(coordinates)가 포함되어 있어 satellite_snapshot 호출 시 lat/lon으로 바로 쓸 수 있다.',
|
||||
schema: {
|
||||
category: '필터할 카테고리(영문 코드, 예: wildfires, severeStorms). 생략하면 전체.',
|
||||
status: '"open"(진행중, 기본값) 또는 "closed"(종료됨)',
|
||||
days: '최근 며칠 이내 이벤트만 볼지 (기본 20, 최대 60)',
|
||||
limit: '최대 반환 개수 (기본 15, 최대 50)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: { type: 'string', description: 'EONET category id, e.g. wildfires, severeStorms, volcanoes, earthquakes, floods, landslides, drought, snow' },
|
||||
status: { type: 'string', enum: ['open', 'closed'], description: 'open (default) or closed' },
|
||||
days: { type: 'number', description: 'Only events from the last N days (default 20, max 60)' },
|
||||
limit: { type: 'number', description: 'Max events to return (default 15, max 50)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const days = Math.min(Math.max(Number(args?.days) || 20, 1), 60);
|
||||
const limit = Math.min(Math.max(Number(args?.limit) || 15, 1), 50);
|
||||
const status = args?.status === 'closed' ? 'closed' : 'open';
|
||||
const params = new URLSearchParams({ status, days: String(days), limit: String(limit) });
|
||||
if (args?.category) params.set('category', String(args.category));
|
||||
try {
|
||||
const data = await httpsGetJson(`https://eonet.gsfc.nasa.gov/api/v3/events?${params.toString()}`);
|
||||
const events = Array.isArray(data?.events) ? data.events : [];
|
||||
if (!events.length) return { success: true, stdout: '조건에 맞는 이벤트가 없습니다.' };
|
||||
const lines = events.map((e: any) => {
|
||||
const cat = e.categories?.[0]?.id || '';
|
||||
const catLabel = EONET_CATEGORIES[cat] || cat;
|
||||
const geom = Array.isArray(e.geometry) ? e.geometry[e.geometry.length - 1] : null;
|
||||
const coords = geom?.coordinates;
|
||||
const lon = Array.isArray(coords) ? coords[0] : (Array.isArray(coords?.[0]) ? coords[0][0]?.[0] : undefined);
|
||||
const lat = Array.isArray(coords) ? coords[1] : (Array.isArray(coords?.[0]) ? coords[0][0]?.[1] : undefined);
|
||||
const date = geom?.date ? String(geom.date).slice(0, 10) : '';
|
||||
const coordStr = (typeof lat === 'number' && typeof lon === 'number') ? `lat=${lat.toFixed(2)}, lon=${lon.toFixed(2)}` : '좌표없음(폴리곤)';
|
||||
return `- [${catLabel}] ${e.title} — ${date} — ${coordStr} (id: ${e.id})`;
|
||||
});
|
||||
return { success: true, stdout: `${events.length}개 이벤트:\n${lines.join('\n')}` };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `EONET 조회 실패: ${err.message}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── satellite_snapshot ────────────────────────────────────────────────────────
|
||||
|
||||
const SNAPSHOT_LAYERS: Record<string, string> = {
|
||||
true_color: 'MODIS_Terra_CorrectedReflectance_TrueColor,Coastlines',
|
||||
thermal: 'MODIS_Terra_CorrectedReflectance_TrueColor,MODIS_Terra_Thermal_Anomalies_All,Coastlines',
|
||||
night: 'VIIRS_SNPP_DayNightBand_ENCC,Coastlines',
|
||||
};
|
||||
|
||||
export const satelliteSnapshotTool = {
|
||||
name: 'satellite_snapshot',
|
||||
description: 'NASA Worldview Snapshots로 특정 위경도 주변의 실제 위성사진을 받아 워크스페이스에 저장하고 채팅에 표시한다. eonet_events가 반환한 좌표를 그대로 쓸 수 있다. 오늘 날짜 이미지는 아직 처리 전일 수 있으니 date를 생략하면 자동으로 어제 날짜를 쓴다.',
|
||||
schema: {
|
||||
lat: '중심 위도 (필수)',
|
||||
lon: '중심 경도 (필수)',
|
||||
radius_deg: '중심에서 각 방향으로 몇 도 범위를 보여줄지 (기본 3, 값이 작을수록 확대)',
|
||||
date: 'YYYY-MM-DD (생략시 어제 날짜, UTC 기준)',
|
||||
layer: '"true_color"(기본, 실제 색상), "thermal"(화재/열이상 표시), "night"(야간 조명)',
|
||||
},
|
||||
jsonSchema: {
|
||||
type: 'object',
|
||||
required: ['lat', 'lon'],
|
||||
properties: {
|
||||
lat: { type: 'number', description: 'Center latitude' },
|
||||
lon: { type: 'number', description: 'Center longitude' },
|
||||
radius_deg: { type: 'number', description: 'Half-width of the view in degrees (default 3)' },
|
||||
date: { type: 'string', description: 'YYYY-MM-DD, UTC. Defaults to yesterday.' },
|
||||
layer: { type: 'string', enum: ['true_color', 'thermal', 'night'], description: 'Imagery preset (default true_color)' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (args: any): Promise<ToolResult> => {
|
||||
const lat = Number(args?.lat);
|
||||
const lon = Number(args?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return { success: false, error: 'lat/lon이 필요합니다' };
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return { success: false, error: 'lat/lon 범위가 올바르지 않습니다' };
|
||||
const radius = Math.min(Math.max(Number(args?.radius_deg) || 3, 0.5), 20);
|
||||
const layerKey = SNAPSHOT_LAYERS[String(args?.layer)] ? String(args.layer) : 'true_color';
|
||||
const layers = SNAPSHOT_LAYERS[layerKey];
|
||||
let date = String(args?.date || '').trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
date = yesterday.toISOString().slice(0, 10);
|
||||
}
|
||||
const bbox = [
|
||||
Math.max(lat - radius, -90), Math.max(lon - radius, -180),
|
||||
Math.min(lat + radius, 90), Math.min(lon + radius, 180),
|
||||
].join(',');
|
||||
const url = `https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&TIME=${date}&BBOX=${bbox}&CRS=EPSG:4326&LAYERS=${layers}&FORMAT=image/jpeg&WIDTH=800&HEIGHT=800`;
|
||||
try {
|
||||
const buf = await httpsGetBuffer(url);
|
||||
if (buf.length < 1000) return { success: false, error: '위성사진을 받지 못했습니다 (해당 날짜/좌표에 이미지 없음일 수 있음)' };
|
||||
const workspacePath = getWorkspacePath(args);
|
||||
const outDir = path.join(workspacePath, 'satellite-images');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
const filePath = path.join(outDir, `satellite-${lat.toFixed(2)}_${lon.toFixed(2)}-${date}-${Date.now()}.jpg`);
|
||||
fs.writeFileSync(filePath, buf);
|
||||
return {
|
||||
success: true,
|
||||
stdout: `위성사진 (${date}, lat=${lat}, lon=${lon}, ${layerKey}):\n\n${buildImageMarkdown(filePath, workspacePath)}`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `위성사진 요청 실패: ${err.message}` };
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user