feat: ASOS 지점 좌표를 공식 목록으로 교체 + 좌표 입력도 관측값 사용

API허브 지점정보(stn_inf.php?inf=SFC)가 승인돼서 97개 지점의 공식
위경도를 받았다. 이걸로 nearestAsosStation을 붙여, 이름으로 못 맞히는
위치(좌표 입력, ASOS 없는 소도시)도 40km 이내 최근접 관측소를 쓴다.
이제 좌표든 이름이든 전부 기상청 실측이고, Open-Meteo 추정은 40km 안에
관측소가 없을 때(독도·먼바다)만 나온다.

같은 서울을 이름과 좌표로 물었을 때 사상 누적이 갈리던 비대칭도 이걸로
사라졌다. 최근접으로 잡은 경우엔 "기상청 산청 관측 (13.6km)"처럼 거리를
같이 내보내, 그 지점이 대표성이 있는지는 사용자가 판단하게 둔다.

앞서 지점명 지오코딩으로 좌표를 만들려던 시도가 왜 틀렸는지도 공식
좌표로 확인됐다 — 홍성·보령·밀양이 북한 동명 지역, 남원이 제주도,
남해가 충청으로 잡혔던 것들이 전부 제자리를 찾았다.

지점 표는 정적으로 박았다. 관측소 좌표는 거의 안 바뀌는데 런타임에
API허브를 타면 별도 키에 매 요청이 묶인다. 갱신은 주석의 URL을 다시
받아 표만 갈아끼우면 된다.

apiHubFetch/getKmaApiHubKey를 같이 넣었다. 키는 vault의 kma.apihub_key
(config.json 평문 아님, data.go.kr 키와 별개 계정). 인코딩이 응답 종류마다
달라서 여기 가둬뒀다 — 자료 본문은 EUC-KR인데 에러는 UTF-8 JSON이라,
에러까지 EUC-KR로 읽으면 "활용신청이 필요합니다"가 깨져 원인을 못 읽는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-16 13:22:21 +09:00
co-authored by Claude Opus 5
parent 4c0e19a8ff
commit 79bdcd4054
2 changed files with 158 additions and 32 deletions
+114 -31
View File
@@ -109,6 +109,43 @@ function getKmaApiKey(): string | undefined {
return undefined;
}
// 기상청 API허브(apihub.kma.go.kr)는 data.go.kr과 **다른 계정·다른 키**다. 지점정보뿐 아니라
// AWS 매분자료·기후통계·적설·자외선 등이 여기 있고, API별로 또 활용신청이 필요하다.
// 현재 런타임에서 쓰는 곳은 없다 — ASOS 지점 좌표는 정적 표로 박혀 있다(ASOS_STATIONS 주석).
// 이 접근자는 그 표를 갱신하거나 허브의 다른 자료를 붙일 때 쓰는 진입점이다.
export function getKmaApiHubKey(): string | undefined {
try {
const cm = getConfig();
const resolved = cm.resolveSecret((cm.getConfig() as any).kma?.apihub_key);
if (resolved) return resolved;
return getVault(cm.getConfigDir()).get('kma.apihub_key', 'weather:apihub')?.expose();
} catch {}
return undefined;
}
// API허브 공통 페치. 두 가지가 매번 발목을 잡아서 여기 가둬둔다:
// 1) 응답이 EUC-KR이다. UTF-8로 읽으면 한글 지점명이 전부 깨진다.
// 2) 미승인 API는 200이 아니라 403 + JSON으로 "활용신청이 필요한 API 입니다"를 준다.
// 키가 틀리면 401 "유효한 인증키가 아닙니다"라 둘을 구분해서 알려줘야 헛짚지 않는다.
export async function apiHubFetch(pathAndQuery: string): Promise<string> {
const key = getKmaApiHubKey();
if (!key) throw new Error('API허브 키가 없습니다. vault에 kma.apihub_key를 등록하세요 (apihub.kma.go.kr에서 발급, data.go.kr 키와 별개).');
const sep = pathAndQuery.includes('?') ? '&' : '?';
const res = await fetch(`https://apihub.kma.go.kr/api/${pathAndQuery}${sep}authKey=${encodeURIComponent(key)}`,
{ signal: AbortSignal.timeout(30_000) });
const buf = await res.arrayBuffer();
// 인코딩이 응답 종류마다 다르다: 자료 본문은 EUC-KR인데 에러는 UTF-8 JSON이다.
// 에러까지 EUC-KR로 읽으면 "활용신청이 필요합니다"가 깨져서 원인을 못 읽는다.
const utf8 = new TextDecoder('utf-8').decode(buf);
if (utf8.trimStart().startsWith('{')) {
let msg = utf8.slice(0, 200);
try { msg = JSON.parse(utf8)?.result?.message || msg; } catch {}
throw new Error(`API허브 ${res.status}: ${msg}`);
}
if (!res.ok) throw new Error(`API허브 ${res.status}: ${utf8.slice(0, 200)}`);
return new TextDecoder('euc-kr').decode(buf);
}
function getAirKoreaApiKey(): string | undefined {
try {
const cm = getConfig();
@@ -880,35 +917,53 @@ export function findRainEpisode(
};
}
// ASOS(종관기상관측) 지점 목록. 지점정보 API가 없어서(NO_OPENAPI_SERVICE_ERROR) 일자료
// 엔드포인트로 stnIds 90~300을 전수조사해 실제 응답에서 뽑았다 — 2026-08-16 기준 97개.
// ASOS(종관기상관측) 지점 목록 — [지점번호, 지점명, 위도, 경도]. 97개.
//
// 좌표가 없는 건 의도적이다. 지점명을 지오코딩해 최근접 지점을 고르는 방식을 만들다 폐기했다:
// 지오코더가 홍성/보령/밀양을 북한 동명 지역으로, 남원을 제주도로 돌려줬다. 좌표를
// 검증하려고 ASOS 관측기온 vs Open-Meteo 기온을 대조해봤지만 좌표가 정확한 서울조차
// Δ3.1°C라(도시열섬·모델편차) 판별자가 못 된다. 이름으로만 맞히면 이런 오배치가 구조적으로
// 불가능하고, 못 맞히면 조용히 틀리는 대신 Open-Meteo로 명시적으로 물러난다.
const ASOS_STATIONS: Array<[number, string]> = [
[90, '속초'], [93, '북춘천'], [95, '철원'], [98, '동두천'], [99, '파주'],
[100, '대관령'], [101, '춘천'], [102, '백령도'], [104, '북강릉'], [105, '강릉'],
[106, '동해'], [108, '서울'], [112, '인천'], [114, '원주'], [115, '울릉도'],
[119, '수원'], [121, '영월'], [127, '충주'], [129, '서산'], [130, '울진'],
[131, '청주'], [133, '대전'], [135, '추풍령'], [136, '안동'], [137, '상주'],
[138, '포항'], [140, '군산'], [143, '대구'], [146, '전주'], [152, '울산'],
[155, '창원'], [156, '광주'], [159, '부산'], [162, '통영'], [165, '목포'],
[168, '여수'], [169, '흑산도'], [170, '완도'], [172, '고창'], [174, '순천'],
[177, '홍성'], [181, '서청주'], [184, '제주'], [185, '고산'], [188, '성산'],
[189, '서귀포'], [192, '진주'], [201, '강화'], [202, '양평'], [203, '이천'],
[211, '인제'], [212, '홍천'], [216, '태백'], [217, '정선군'], [221, '제천'],
[226, '보은'], [232, '천안'], [235, '보령'], [236, '부여'], [238, '금산'],
[239, '세종'], [243, '부안'], [244, '임실'], [245, '정읍'], [247, '남원'],
[248, '장수'], [251, '고창군'], [252, '영광군'], [253, '김해시'], [254, '순창군'],
[255, '북창원'], [257, '양산시'], [258, '보성군'], [259, '강진군'], [260, '장흥'],
[261, '해남'], [262, '고흥'], [263, '의령군'], [264, '함양군'], [266, '광양시'],
[268, '진도군'], [271, '봉화'], [272, '영주'], [273, '문경'], [276, '청송군'],
[277, '영덕'], [278, '의성'], [279, '구미'], [281, '영천'], [283, '경주시'],
[284, '거창'], [285, '합천'], [288, '밀양'], [289, '산청'], [294, '거제'],
[295, '남해'], [296, '북부산'],
// 출처는 기상청 API허브 `typ01/url/stn_inf.php?inf=SFC` (2026-08-16 수신). 응답이 EUC-KR이라
// UTF-8로 읽으면 지점명이 깨지고, 컬럼 순서가 LON→LAT이라 뒤집어 읽기 쉽다.
//
// 정적 표로 박아둔 건 의도적이다. 관측소 좌표는 거의 안 바뀌는데 런타임에 API허브를 타면
// 별도 인증키(data.go.kr 키와 다른 계정)에 매 요청이 묶인다. 갱신이 필요하면 위 URL을
// 다시 받아 이 표만 갈아끼우면 된다.
//
// 좌표를 지오코딩으로 만들려던 앞선 시도는 폐기됐다: 지오코더가 홍성·보령·밀양을 북한
// 동명 지역으로, 남원을 제주도로, 남해를 충청으로 돌려줬다. 공식 좌표와 대조해 전부 확인됨.
// (검증에 Open-Meteo 기온을 쓰려던 것도 실패했다 — 좌표가 정확한 서울조차 Δ3.1°C라
// 도시열섬·모델편차에 묻혀 판별자가 못 된다. ASOS 실측끼리 비교해야 갈린다.)
const ASOS_STATIONS: Array<[number, string, number, number]> = [
[90, '속초', 38.2508, 128.5647], [93, '북춘천', 37.9474, 127.7544], [95, '철원', 38.1479, 127.3042],
[98, '동두천', 37.9019, 127.0607], [99, '파주', 37.8859, 126.7665], [100, '대관령', 37.6771, 128.7183],
[101, '춘천', 37.9026, 127.7357], [102, '백령도', 37.9740, 124.7124], [104, '북강릉', 37.8046, 128.8553],
[105, '강릉', 37.7515, 128.8910], [106, '동해', 37.5071, 129.1243], [108, '서울', 37.5714, 126.9658],
[112, '인천', 37.4777, 126.6249], [114, '원주', 37.3375, 127.9466], [115, '울릉도', 37.4813, 130.8986],
[119, '수원', 37.2575, 126.9830], [121, '영월', 37.1813, 128.4574], [127, '충주', 36.9704, 127.9525],
[129, '서산', 36.7766, 126.4939], [130, '울진', 36.9918, 129.4128], [131, '청주', 36.6392, 127.4407],
[133, '대전', 36.3720, 127.3721], [135, '추풍령', 36.2203, 127.9946], [136, '안동', 36.5729, 128.7073],
[137, '상주', 36.4084, 128.1574], [138, '포항', 36.0320, 129.3800], [140, '군산', 36.0053, 126.7613],
[143, '대구', 35.8780, 128.6530], [146, '전주', 35.8409, 127.1172], [152, '울산', 35.5824, 129.3347],
[155, '창원', 35.1702, 128.5728], [156, '광주', 35.1729, 126.8916], [159, '부산', 35.1047, 129.0320],
[162, '통영', 34.8454, 128.4356], [165, '목포', 34.8173, 126.3815], [168, '여수', 34.7393, 127.7406],
[169, '흑산도', 34.6872, 125.4510], [170, '완도', 34.3959, 126.7018], [172, '고창', 35.3482, 126.5990],
[174, '순천', 35.0204, 127.3694], [177, '홍성', 36.6576, 126.6877], [181, '서청주', 36.6399, 127.3846],
[184, '제주', 33.5141, 126.5297], [185, '고산', 33.2938, 126.1628], [188, '성산', 33.3868, 126.8802],
[189, '서귀포', 33.2462, 126.5653], [192, '진주', 35.1638, 128.0400], [201, '강화', 37.7074, 126.4463],
[202, '양평', 37.4886, 127.4945], [203, '이천', 37.2640, 127.4842], [211, '인제', 38.0599, 128.1681],
[212, '홍천', 37.6836, 127.8804], [216, '태백', 37.1704, 128.9893], [217, '정선군', 37.3807, 128.6731],
[221, '제천', 37.1593, 128.1943], [226, '보은', 36.4876, 127.7341], [232, '천안', 36.7622, 127.2928],
[235, '보령', 36.3272, 126.5574], [236, '부여', 36.2724, 126.9208], [238, '금산', 36.1056, 127.4818],
[239, '세종', 36.4852, 127.2444], [243, '부안', 35.7296, 126.7166], [244, '임실', 35.6120, 127.2856],
[245, '정읍', 35.5633, 126.8390], [247, '남원', 35.4213, 127.3965], [248, '장수', 35.6570, 127.5203],
[251, '고창군', 35.4235, 126.6923], [252, '영광군', 35.2837, 126.4778], [253, '김해시', 35.2298, 128.8907],
[254, '순창군', 35.3713, 127.1286], [255, '북창원', 35.2266, 128.6726], [257, '양산시', 35.3074, 129.0201],
[258, '보성군', 34.7634, 127.2123], [259, '강진군', 34.6446, 126.7841], [260, '장흥', 34.6889, 126.9195],
[261, '해남', 34.5538, 126.5691], [262, '고흥', 34.6183, 127.2757], [263, '의령군', 35.3226, 128.2881],
[264, '함양군', 35.5114, 127.7454], [266, '광양시', 34.9434, 127.6914], [268, '진도군', 34.4730, 126.2585],
[271, '봉화', 36.9436, 128.9145], [272, '영주', 36.8718, 128.5169], [273, '문경', 36.6273, 128.1488],
[276, '청송군', 36.4351, 129.0401], [277, '영덕', 36.5334, 129.4093], [278, '의성', 36.3561, 128.6886],
[279, '구미', 36.1305, 128.3205], [281, '영천', 35.9774, 128.9514], [283, '경주시', 35.8175, 129.2012],
[284, '거창', 35.6674, 127.9099], [285, '합천', 35.5650, 128.1699], [288, '밀양', 35.4915, 128.7441],
[289, '산청', 35.4130, 127.8791], [294, '거제', 34.8882, 128.6046], [295, '남해', 34.8166, 127.9264],
[296, '북부산', 35.2178, 128.9602],
];
// "부산광역시" → "부산", "김해시" → "김해". 지점명 쪽도 같은 함수를 태워서 양쪽을 맞춘다.
@@ -928,6 +983,28 @@ export function findAsosStation(location: string): { id: number; name: string }
return null;
}
// 이름으로 못 맞힌 위치(좌표 입력, ASOS 없는 소도시)를 위한 최근접 지점 선택.
// maxKm 밖이면 null — 멀리 있는 관측소를 조용히 빌려 쓰면 "관측값"이라는 라벨이 거짓말이 된다.
// 40km는 우리 ASOS 망(97개) 간격을 보고 잡은 값이고, 넘으면 Open-Meteo 추정으로 물러난다.
// 호출부가 거리를 같이 출력하므로 사용자가 대표성을 직접 판단할 수 있다.
export function nearestAsosStation(
lat: number, lon: number, maxKm = 40,
): { id: number; name: string; km: number } | null {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const toRad = (d: number) => d * Math.PI / 180;
let best: { id: number; name: string; km: number } | null = null;
for (const [id, name, sLat, sLon] of ASOS_STATIONS) {
// 하버사인. 한반도 규모에선 평면 근사로도 충분하지만, 위도에 따라 경도 1도의 거리가
// 달라지는 걸 잘못 다루면 동서로 20% 가까이 어긋나서 그냥 정식으로 계산한다.
const dLat = toRad(sLat - lat), dLon = toRad(sLon - lon);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat)) * Math.cos(toRad(sLat)) * Math.sin(dLon / 2) ** 2;
const km = 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
if (!best || km < best.km) best = { id, name, km: Math.round(km * 10) / 10 };
}
return best && best.km <= maxKm ? best : null;
}
// ASOS 시간자료의 rn 인코딩: ""=무강수, "0.0"=미량(관측은 됐으나 0.05mm 미만), "1.0"=1.0mm.
// ""와 "0.0"을 같게 뭉개면 이슬비로 이어지던 비가 소강으로 잡혀 사상이 끊긴다.
export function normalizeAsosHour(tm: string, rn: string | null | undefined): { time: string; mm: number; wet: boolean } {
@@ -1152,13 +1229,19 @@ export const weatherKmaTool = {
? await fetchKmaTodayRain(nx, ny, base_date, parseInt(base_time.slice(0, 2), 10))
: null;
// 지점명이 맞으면 ASOS+초단기실황으로 전부 관측값, 아니면 Open-Meteo 추정으로 물러난다.
const station = findAsosStation(location) ?? findAsosStation(displayName);
// 이름으로 지점을 맞히고, 안 되면 좌표로 최근접 지점(40km 이내)을 잡는다.
// 둘 다 실패해야 Open-Meteo 추정으로 물러난다.
const named = findAsosStation(location) ?? findAsosStation(displayName);
const near = named ? null : nearestAsosStation(lat, lon);
const station = named ?? near;
const observed = station && todayRain
? await fetchKmaRainEpisode(station.id, todayKst, todayRain.perHour)
: null;
const episode = observed ?? await fetchRainEpisode(lat, lon);
const episodeSource = observed ? `기상청 ${station!.name} 관측` : 'Open-Meteo 추정';
// 최근접으로 잡았으면 거리를 밝힌다 — 그 지점이 대표성이 있는지는 사용자가 판단할 몫이다.
const episodeSource = observed
? `기상청 ${station!.name} 관측${near ? ` (${near.km}km)` : ''}`
: 'Open-Meteo 추정';
const rainLine = todayRain
? `오늘 누적 강수량: ${todayRain.mm} mm (00시~${String(todayRain.throughHour).padStart(2, '0')}시 실측 합산`
+44 -1
View File
@@ -14,7 +14,7 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { findAsosStation, normalizeAsosHour } from '../src/tools/weather';
import { findAsosStation, nearestAsosStation, normalizeAsosHour } from '../src/tools/weather';
describe('findAsosStation — 지점명 매칭', () => {
test('정확히 일치하는 지점', () => {
@@ -47,6 +47,49 @@ describe('findAsosStation — 지점명 매칭', () => {
});
});
describe('nearestAsosStation — 좌표에서 최근접 지점', () => {
// 좌표는 2026-08-16에 기상청 API허브 stn_inf.php(공식)로 교체했다. 그 전엔 지점명을
// 지오코딩해 만들려다 홍성·보령·밀양이 북한, 남원이 제주도로 나와서 폐기했었다.
test('지점이 바로 있는 도시는 몇 km 안에 잡힌다', () => {
const seoul = nearestAsosStation(37.5665, 126.978);
assert.equal(seoul?.name, '서울');
assert.ok(seoul!.km < 5, `서울 ${seoul!.km}km — 너무 멀다`);
const gumi = nearestAsosStation(36.1195, 128.3446);
assert.equal(gumi?.name, '구미');
assert.ok(gumi!.km < 5);
});
test('지점이 없는 도시는 이웃 지점으로 넘어간다', () => {
// 시흥·용인엔 ASOS가 없다 — 각각 인천·수원이 가장 가깝다.
assert.equal(nearestAsosStation(37.3800, 126.8029)?.name, '인천');
assert.equal(nearestAsosStation(37.2411, 127.1776)?.name, '수원');
});
test('공식 좌표라서 지오코더가 틀렸던 지점도 제자리에 있다', () => {
// 예전 지오코딩은 남해를 36.44,127.41(충청)로 봤다. 공식은 34.82,127.93.
assert.equal(nearestAsosStation(34.8376, 127.8925)?.name, '남해');
// 홍성/보령/밀양은 북한 동명 지역으로 튀었던 것들.
assert.equal(nearestAsosStation(36.6576, 126.6877)?.name, '홍성');
assert.equal(nearestAsosStation(35.4915, 128.7441)?.name, '밀양');
});
test('maxKm 밖이면 null — 먼 관측소를 "관측값"으로 빌려 쓰지 않는다', () => {
assert.equal(nearestAsosStation(37.2429, 131.8664), null); // 독도
assert.equal(nearestAsosStation(34.3, 129.3), null); // 대마도 근해
});
test('maxKm를 넓히면 같은 좌표도 잡힌다', () => {
const far = nearestAsosStation(37.2429, 131.8664, 200);
assert.equal(far?.name, '울릉도');
});
test('좌표가 숫자가 아니면 null', () => {
assert.equal(nearestAsosStation(NaN, 127), null);
assert.equal(nearestAsosStation(37, NaN), null);
});
});
describe('normalizeAsosHour — rn 인코딩', () => {
test('""는 무강수 (결측 아님)', () => {
assert.deepEqual(normalizeAsosHour('2026-08-15 03:00', ''), { time: '2026-08-15T03:00', mm: 0, wet: false });