diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 24b7b62..61db670 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -1243,6 +1243,78 @@ async function fetchAsosDaily(stnId: number, startDt: string, endDt: string): Pr return json?.response?.body?.items?.item || []; } +// ── CCTV 기반 도로날씨 (안개) ─────────────────────────────────────────────── +// +// 안개는 ASOS·AWS·Open-Meteo 어디에도 없는 값이라, 도로 CCTV 영상 분석 결과가 유일한 소스다. +// API허브 `typ01/url/cctv_ana_info.php`가 전국 도로 CCTV 지점별로 안개/비/눈 코드를 준다. +// +// 성격이 관측소와 다르다는 걸 감안해야 한다: +// - 지점이 **도로변**에만 있다(2026-08-21 기준 593곳). 가까운 지점이 없는 동네가 많다. +// - **영상 분석이라 주간에만 생산된다**(대략 06~19시). 야간에 값이 없는 건 고장이 아니다. +// - 자료가 **며칠에 걸쳐 뒤늦게 채워진다**. 08-15~17이 한동안 0행이다가 나중에 채워졌고, +// 08-14 행수도 57,247 → 67,937로 늘었다. 그래서 "지금 0행"으로 서비스 종료를 단정하면 안 된다. +const FOG_LABEL: Record = { '00': '없음', '11': '약함', '12': '보통', '13': '짙음' }; + +// 같은 지점이 몇 분 간격으로 반복 보고되므로, 지점별 **가장 최근 행**만 남긴다. +export function pickLatestFogByStation( + rows: string[][], +): Array<{ tm: string; id: string; name: string; lat: number; lon: number; fog: string }> { + const best = new Map(); + for (const r of rows) { + if (r.length < 8) continue; + const [tm, id, name, latS, lonS] = r; + const fog = String(r[7] || '').trim(); + const lat = parseFloat(latS), lon = parseFloat(lonS); + if (!/^\d{12}$/.test(tm) || !id || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const prev = best.get(id); + if (!prev || tm > prev.tm) best.set(id, { tm, id, name: String(name || '').trim(), lat, lon, fog }); + } + return [...best.values()]; +} + +// 안개가 잡힌 지점 중 가장 가까운 곳. maxKm 밖이면 null — 도로변 지점이라 성기게 깔려 있어서 +// ASOS(40km)보다 넉넉히 잡되, 거리를 같이 돌려줘 호출부가 화면에 밝히게 한다. +export function nearestFogReport( + stations: Array<{ tm: string; name: string; lat: number; lon: number; fog: string }>, + lat: number, lon: number, maxKm = 30, +): { name: string; km: number; fog: string; tm: string } | null { + let best: { name: string; km: number; fog: string; tm: string } | null = null; + for (const s of stations) { + if (!FOG_LABEL[s.fog] || s.fog === '00') continue; // 안개 없는 지점은 후보에서 제외 + const km = Math.round(haversineKm(lat, lon, s.lat, s.lon) * 10) / 10; + if (!best || km < best.km) best = { name: s.name, km, fog: s.fog, tm: s.tm }; + } + return best && best.km <= maxKm ? best : null; +} + +// 전국 한 벌을 받아 지점별 최신값만 남긴 것. 위치가 달라도 같은 응답을 쓰므로 캐시한다. +// 자료가 10분 간격으로 갱신되고 조회에 ~3초가 걸려서, 캐시가 없으면 날씨 질문마다 +// 응답이 그만큼 느려진다(실측 7초 → 11초). +let _fogCache: { at: number; stations: ReturnType } | null = null; +const FOG_CACHE_MS = 5 * 60 * 1000; + +async function fetchRoadFog(lat: number, lon: number): Promise { + try { + if (!_fogCache || Date.now() - _fogCache.at > FOG_CACHE_MS) { + const now = new Date(Date.now() + 9 * 3600 * 1000); + const f = (d: Date) => d.toISOString().slice(0, 13).replace(/[-T]/g, '') + d.toISOString().slice(14, 16); + // 30분 창이면 지점 커버리지는 그대로(584/593)면서 응답이 591KB → 87KB로 줄었다. + // 더 넓히면 이미 걷힌 안개를 현재처럼 보고하게 되기도 한다. + const tm1 = f(new Date(now.getTime() - 30 * 60 * 1000)); + const csv = await apiHubFetch(`typ01/url/cctv_ana_info.php?tm1=${tm1}&tm2=${f(now)}&disp=0&help=0`); + const rows = csv.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split(',').map(x => x.trim())); + _fogCache = { at: Date.now(), stations: pickLatestFogByStation(rows) }; + } + if (!_fogCache.stations.length) return null; // 야간이거나 아직 안 채워짐 — 정상 + const hit = nearestFogReport(_fogCache.stations, lat, lon); + if (!hit) return null; // 근처에 안개 없음 → 아무 줄도 안 붙인다 + return `도로 안개: ${FOG_LABEL[hit.fog]} (CCTV ${hit.name} 관측, ${hit.km}km · ` + + `${hit.tm.slice(8, 10)}:${hit.tm.slice(10, 12)} 기준)`; + } catch { + return null; + } +} + const ASOS_BASE = 'http://apis.data.go.kr/1360000/AsosHourlyInfoService'; async function asosFetch(params: Record): Promise { @@ -1509,6 +1581,10 @@ export const weatherKmaTool = { try { aws = await fetchAwsToday(awsStn.id, todayKst); } catch { aws = null; } } + // 안개는 있을 때만 한 줄 붙인다 — 매번 "안개 없음"을 쓰면 소음이 된다. + // 실패하거나 근처에 없으면 null이라 그대로 빠진다. + const fogLine = await fetchRoadFog(lat, lon); + const todayRain = (aws?.rnDay == null && base_date === todayKst) ? await fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10)) : null; @@ -1552,13 +1628,14 @@ export const weatherKmaTool = { `1시간 강수량: ${get('RN1')} mm`, rainLine, ...(episodeLine ? [episodeLine] : []), + ...(fogLine ? [fogLine] : []), `습도: ${get('REH')}%`, `풍향: ${windDirectionKo(vecDeg)} (${get('VEC')}°)`, `풍속: ${get('WSD')} m/s`, '', '(미세먼지/PM2.5/PM10/황사는 이 데이터에 없음 — weather_airkorea 사용)', ]; - return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items, todayRain, aws, episode } }; + return { success: true, stdout: lines.join('\n'), data: { location: displayName, nx, ny, items, todayRain, aws, episode, fogLine } }; } else { const { base_date, base_time } = getKmaBaseDateTime('fcst'); const items = await kmaFetch('getVilageFcst', { base_date, base_time, nx: String(nx), ny: String(ny) }); diff --git a/tests/road-fog.test.ts b/tests/road-fog.test.ts new file mode 100644 index 0000000..b79e88c --- /dev/null +++ b/tests/road-fog.test.ts @@ -0,0 +1,92 @@ +/** + * road-fog.test.ts + * + * CCTV 기반 도로날씨에서 안개 정보를 뽑는 로직. 안개는 ASOS·AWS·Open-Meteo 어디에도 없는 + * 값이라 이 소스가 유일하다(2026-08-21 배선). + * + * 이 API의 성격을 알고 봐야 한다: + * - 같은 지점이 몇 분 간격으로 반복 보고된다 → 지점별 **최신 행만** 남겨야 한다. + * - 지점이 도로변에만 있다(593곳). 가까운 지점이 없는 동네가 많다. + * - 영상 분석이라 주간에만 나온다. 야간 0행은 고장이 아니다. + * - 자료가 며칠에 걸쳐 뒤늦게 채워진다 — 08-15~17이 한동안 0행이다가 나중에 채워졌고, + * 그때 "서비스 종료"로 성급히 판단할 뻔했다. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { pickLatestFogByStation, nearestFogReport } from '../src/tools/weather'; + +// TM,EQP_ID,STN_NM,LAT,LON,HT,WH_CD,FOG_CD,RN_CD,SN_CD +const row = (tm: string, id: string, name: string, lat: string, lon: string, fog: string) => + [tm, id, name, lat, lon, '-99.0', fog, fog, '00', '00']; + +describe('pickLatestFogByStation', () => { + test('같은 지점은 가장 최근 행만 남긴다', () => { + const out = pickLatestFogByStation([ + row('202608211000', 'A', '영광', '35.3', '126.6', '11'), + row('202608211020', 'A', '영광', '35.3', '126.6', '00'), + row('202608211010', 'A', '영광', '35.3', '126.6', '13'), + ]); + assert.equal(out.length, 1); + assert.equal(out[0].fog, '00'); // 10:20이 최신 + assert.equal(out[0].tm, '202608211020'); + }); + + test('서로 다른 지점은 각각 남는다', () => { + const out = pickLatestFogByStation([ + row('202608211000', 'A', '영광', '35.3', '126.6', '11'), + row('202608211000', 'B', '양재', '37.4', '127.0', '12'), + ]); + assert.equal(out.length, 2); + }); + + test('깨진 행은 버린다', () => { + const out = pickLatestFogByStation([ + ['짧은행'], + row('없는시각', 'A', '영광', '35.3', '126.6', '11'), + row('202608211000', 'B', '양재', 'NaN', '127.0', '11'), // 좌표 불량 + row('202608211000', 'C', '정상', '37.4', '127.0', '11'), + ]); + assert.deepEqual(out.map(s => s.name), ['정상']); + }); + + test('빈 입력은 빈 배열 (야간이면 정상)', () => { + assert.deepEqual(pickLatestFogByStation([]), []); + }); +}); + +describe('nearestFogReport', () => { + const S = (name: string, lat: number, lon: number, fog: string) => + ({ tm: '202608211020', name, lat, lon, fog }); + + test('안개 없는 지점(00)은 후보에서 뺀다', () => { + // 코앞에 맑은 지점이 있어도, 안개를 보고한 지점을 골라야 한다. + const hit = nearestFogReport([ + S('바로옆', 37.5665, 126.978, '00'), + S('조금멀리', 37.60, 126.99, '12'), + ], 37.5665, 126.978); + assert.equal(hit?.name, '조금멀리'); + assert.equal(hit?.fog, '12'); + }); + + test('안개 지점 중 가장 가까운 곳을 고른다', () => { + const hit = nearestFogReport([ + S('멀다', 36.0, 127.5, '13'), + S('가깝다', 37.58, 126.99, '11'), + ], 37.5665, 126.978); + assert.equal(hit?.name, '가깝다'); + assert.ok(hit!.km < 5); + }); + + test('maxKm 밖이면 null — 먼 안개를 여기 안개처럼 말하지 않는다', () => { + assert.equal(nearestFogReport([S('부산', 35.18, 129.08, '12')], 37.5665, 126.978), null); + }); + + test('안개 지점이 하나도 없으면 null (줄을 안 붙인다)', () => { + assert.equal(nearestFogReport([S('맑음', 37.57, 126.98, '00')], 37.5665, 126.978), null); + }); + + test('알 수 없는 코드는 무시한다', () => { + assert.equal(nearestFogReport([S('이상', 37.57, 126.98, '99')], 37.5665, 126.978), null); + }); +});