feat: 날씨에 도로 안개 추가 (CCTV 기반 도로날씨) — 안개 있을 때만 표시

안개는 ASOS·AWS·Open-Meteo 어디에도 없는 값이라 도로 CCTV 영상 분석이 유일한
소스다. 전국 593개 도로 지점에서 안개/비/눈 코드가 온다.

**앞선 판단 정정**: 08-15~18에 이 API가 0행이라 서비스 종료를 의심했는데,
소급 반영이 늦었던 것이었다. 지금 조회하면 08/17이 83,478행으로 채워져 있고
08/14도 57,247 → 67,937행으로 늘었다. "지금 0행"으로 종료를 단정하면 안 된다.

안개가 있을 때만 한 줄 붙인다 — 매번 "안개 없음"을 쓰면 소음이 된다:
  도로 안개: 보통 (CCTV 양재 관측, 13km · 11:20 기준)

어느 지점에서 몇 km 떨어진 값인지 밝혀 대표성 판단은 사용자에게 남긴다.
30km 밖이면 표시하지 않는다(도로변에만 있어 성기게 깔려 있다).

성능: 처음엔 응답이 7초 → 11초로 느려졌다. 조회 창을 90분에서 30분으로 줄이고
(지점 커버리지 584/593로 사실상 동일, 591KB → 87KB) 전국 한 벌을 5분 캐시해
구미 기준 7.1s → 2.7s로 되돌렸다.

같은 지점이 몇 분 간격으로 반복 보고되므로 지점별 최신 행만 남긴다.
야간에는 영상 분석이 없어 데이터가 안 나온다 — 안개 줄이 없는 게 정상이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-21 11:54:10 +09:00
co-authored by Claude Opus 5
parent a9040b7222
commit 4ac9919449
2 changed files with 170 additions and 1 deletions
+78 -1
View File
@@ -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<string, string> = { '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<string, { tm: string; id: string; name: string; lat: number; lon: number; fog: string }>();
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<typeof pickLatestFogByStation> } | null = null;
const FOG_CACHE_MS = 5 * 60 * 1000;
async function fetchRoadFog(lat: number, lon: number): Promise<string | null> {
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<string, string>): Promise<any[]> {
@@ -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) });
+92
View File
@@ -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);
});
});