From d1bc3790931b2dd2bae1a0b3d0817e07ddfb0cef Mon Sep 17 00:00:00 2001 From: kim Date: Sun, 16 Aug 2026 14:04:02 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=9C=EA=B5=AD=20=EC=A7=80=EB=AA=85?= =?UTF-8?q?=EC=9D=84=20=EA=B4=80=EC=B8=A1=EC=A7=80=EC=A0=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=EB=A1=9C=20=ED=91=BC=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=A7=80=EC=98=A4=EC=BD=94=EB=8D=94=EA=B0=80=2066=EA=B0=9C=20?= =?UTF-8?q?=EC=A4=91=2044=EA=B0=9C=EB=A5=BC=20=ED=8B=80=EB=A0=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "독도 날씨"가 전남 고흥 관측소로 잡히길래 파봤더니 한 건이 아니었다. KR_CITY_COORDS에 없는 ASOS 지명 66개를 기상청 공식 좌표와 대조한 결과 44개가 틀렸다 — 25개는 Open-Meteo 지오코더가 아예 못 찾고, 19개는 엉뚱한 좌표를 줬다. 홍성 724km / 고산 629km / 보령 551km / 밀양 459km / 서산 / 홍천 → 전부 북한 동명 지역 동해 408km / 남원 246km / 제천 214km / 상주 188km / 남해 187km → 사람들이 실제로 묻는 도시들 이제 ASOS 97 + AWS 745 지점 이름표를 지오코딩 소스로 쓴다. 순서는 손으로 넣은 표 → 지점 이름표 → 지오코더이고, 한글이 섞인 입력만 이름표를 탄다(영문·해외 지명은 종전 경로 그대로). 지점 좌표는 기상청 발표값이라 이 문제가 구조적으로 안 생기고, 날씨를 묻는 맥락에서는 "그 지명의 관측소 위치"가 사실상 정답이다. 고친 뒤 ASOS 97개 전부 공식 좌표 20km 이내로 들어왔다(수정 전 53/97). 같은 이름이 100km 넘게 떨어져 둘씩 있는 3건(진안·옥천·가산)은 이름표에서 일부러 제외했다 — 조용히 한쪽을 고르면 틀렸을 때 드러나지 않는다. 대신 널리 통하는 행정구역 쪽을 KR_CITY_COORDS에 못박았다. 안 그러면 넘어간 지오코더가 옥천을 북한(39.94, 124.53)으로 보낸다. 버린 쪽은 전부 경기도의 동명 지점(진안 439, 옥천 449, 가산 473). 미해결로 남긴 것: 'New York'이 네브래스카의 York로 간다. 영문·해외 지명 쪽 지오코더 문제라 이번 변경 경로와 무관하다(한글 없는 입력은 이름표를 안 탄다). Co-Authored-By: Claude Opus 5 --- src/tools/weather.ts | 56 ++++++++++++++++++++ tests/korean-place-lookup.test.ts | 88 +++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tests/korean-place-lookup.test.ts diff --git a/src/tools/weather.ts b/src/tools/weather.ts index 76454cc..79797ae 100644 --- a/src/tools/weather.ts +++ b/src/tools/weather.ts @@ -208,6 +208,13 @@ export const KR_CITY_COORDS: Record = { // No error was ever raised; the user just got someone else's weather. // Any mid-size Korean city the geocoder cannot pin down belongs in this table. '구미': [36.1195, 128.3446], 'gumi': [36.1195, 128.3446], + // 2026-08-16: 관측지점 이름표를 지오코딩 소스로 쓰면서 남은 동명 3건. 같은 이름의 지점이 + // 100km 넘게 떨어져 둘씩 있어서 이름표는 "모호"로 넘기는데, 그러면 지오코더로 새고 + // 옥천은 북한(39.94, 124.53)으로 간다. 널리 통하는 행정구역 쪽을 여기에 못박는다. + // (버린 쪽은 전부 경기도의 동명 지점 — 진안 439, 옥천 449, 가산 473) + '진안': [35.7604, 127.4374], 'jinan': [35.7604, 127.4374], + '옥천': [36.3000, 127.5965], 'okcheon': [36.3000, 127.5965], + '가산': [36.0956, 128.5001], '김해': [35.2285, 128.8894], 'gimhae': [35.2285, 128.8894], '안양': [37.3943, 126.9568], 'anyang': [37.3943, 126.9568], '부천': [37.5034, 126.7660], 'bucheon': [37.5034, 126.7660], @@ -302,12 +309,61 @@ export async function locationNotFoundError(location: string, toolName: string): }; } +// 기상청 관측지점 이름 → 좌표. ASOS 97개 + AWS 745개에서 만든다. +// +// 왜 필요한가: Open-Meteo 지오코더는 한국 지명에 거의 못 쓴다. 2026-08-16에 ASOS 지명 +// 66개(KR_CITY_COORDS에 없는 것)를 공식 좌표와 대조했더니 **44개가 틀렸다** — +// 25개는 아예 조회 실패, 19개는 엉뚱한 좌표. 홍성·보령·서산·홍천·밀양·고산은 북한 동명 +// 지역으로, 동해는 408km, 제천 214km, 상주 188km 떨어진 곳으로 갔다. 전부 사람들이 +// 실제로 묻는 도시다. +// +// 지점 좌표는 기상청이 발표한 값이라 이 문제가 구조적으로 안 생긴다. 게다가 날씨를 묻는 +// 맥락에선 "그 지명의 관측소 위치"가 사실상 정답이다. +// +// 같은 이름이 멀리 떨어져 둘 이상 있으면(739개 중 진안·옥천·가산 3건) 어느 쪽인지 모르므로 +// 쓰지 않고 넘긴다 — 조용히 한쪽을 고르면 틀렸을 때 드러나지 않는다. +let stationPlaceIndex: Map | null = null; +function getStationPlaceIndex(): Map { + if (stationPlaceIndex) return stationPlaceIndex; + const groups = new Map>(); + for (const s of [...ASOS_STATIONS, ...AWS_STATIONS]) { + const k = stripAdmin(s[1]); + if (!k) continue; + const list = groups.get(k); + if (list) list.push(s); else groups.set(k, [s]); + } + const index = new Map(); + for (const [k, list] of groups) { + let spread = 0; + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + spread = Math.max(spread, haversineKm(list[i][2], list[i][3], list[j][2], list[j][3])); + } + } + if (spread > 20) continue; // 모호 — 지오코더로 넘긴다 + index.set(k, { lat: list[0][2], lon: list[0][3] }); + } + stationPlaceIndex = index; + return index; +} + +export function lookupKoreanPlace(name: string): { lat: number; lon: number } | null { + const k = stripAdmin(String(name || '')); + return k ? (getStationPlaceIndex().get(k) ?? null) : null; +} + export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> { const key = name.trim().toLowerCase(); if (KR_CITY_COORDS[key]) { const [lat, lon] = KR_CITY_COORDS[key]; return { lat, lon, displayName: name }; } + // 손으로 넣은 표 다음, 지오코더보다 먼저. 한글이 섞인 입력만 본다 — 영문/외국 지명은 + // 지점명과 부딪힐 일이 없고 지오코더가 제대로 처리한다. + if (/[가-힣]/.test(name)) { + const stn = lookupKoreanPlace(name); + if (stn) return { lat: stn.lat, lon: stn.lon, displayName: name }; + } const { city, countryCode } = parseExplicitCountry(name); const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`; // Open-Meteo geocoding throws a transient network/5xx blip now and then that diff --git a/tests/korean-place-lookup.test.ts b/tests/korean-place-lookup.test.ts new file mode 100644 index 0000000..ce3b5c3 --- /dev/null +++ b/tests/korean-place-lookup.test.ts @@ -0,0 +1,88 @@ +/** + * korean-place-lookup.test.ts + * + * 한국 지명 → 좌표를 기상청 관측지점 이름표(ASOS 97 + AWS 745)로 푼다. + * + * 계기는 "독도 날씨"가 전남 고흥 관측소로 잡힌 것이었는데, 파보니 한 건이 아니었다. + * KR_CITY_COORDS에 없는 ASOS 지명 66개를 공식 좌표와 대조하니 **44개가 틀렸다** — + * 25개는 Open-Meteo 지오코더가 아예 못 찾고, 19개는 엉뚱한 좌표를 줬다: + * + * 홍성 724km / 고산 629km / 보령 551km / 밀양 459km → 전부 북한 동명 지역 + * 동해 408km · 제천 214km · 상주 188km · 남원 246km → 사람들이 실제로 묻는 도시들 + * + * 지점 좌표는 기상청 발표값이라 이 문제가 구조적으로 안 생기고, 날씨 맥락에선 + * "그 지명의 관측소 위치"가 사실상 정답이다. 고친 뒤 ASOS 97개 전부 20km 이내로 들어왔다. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { lookupKoreanPlace } from '../src/tools/weather'; + +// 기상청 공식 좌표(stn_inf.php)와의 거리 +function km(a: { lat: number; lon: number }, lat: number, lon: number): number { + const R = (d: number) => d * Math.PI / 180; + const dLat = R(lat - a.lat), dLon = R(lon - a.lon); + const q = Math.sin(dLat / 2) ** 2 + Math.cos(R(a.lat)) * Math.cos(R(lat)) * Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.asin(Math.sqrt(q)); +} + +describe('lookupKoreanPlace — 지점 이름표로 한국 지명 풀기', () => { + test('지오코더가 북한으로 보내던 지명들이 제자리에 온다', () => { + // 좌: 공식 관측지점 좌표. 우: 예전 지오코더가 주던 곳. + const cases: Array<[string, number, number]> = [ + ['홍성', 36.6576, 126.6877], // → 42.71, 129.83 (조선민주주의인민공화국) + ['보령', 36.3272, 126.5574], // → 40.88, 129.08 + ['밀양', 35.4915, 128.7441], // → 38.85, 125.75 + ]; + for (const [name, lat, lon] of cases) { + const got = lookupKoreanPlace(name); + assert.ok(got, `${name} 조회 실패`); + assert.ok(km(got, lat, lon) < 5, `${name} ${km(got, lat, lon).toFixed(0)}km 어긋남`); + } + }); + + test('수백 km 어긋나던 도시들', () => { + const cases: Array<[string, number, number]> = [ + ['동해', 37.5071, 129.1243], // 예전 408km + ['제천', 37.1593, 128.1943], // 예전 214km + ['남원', 35.4213, 127.3965], // 예전 246km + ['남해', 34.8166, 127.9264], // 예전 187km + ]; + for (const [name, lat, lon] of cases) { + const got = lookupKoreanPlace(name); + assert.ok(got && km(got, lat, lon) < 5, `${name} 어긋남`); + } + }); + + test('독도 — 이 작업의 발단', () => { + // AWS 96번 독도가 실재하는데 지오코더는 전남을 줬다. + const got = lookupKoreanPlace('독도'); + assert.ok(got); + assert.ok(km(got, 37.2395, 131.8698) < 5); + }); + + test('지오코더가 아예 못 찾던 이름들도 잡힌다', () => { + for (const n of ['흑산도', '태백', '보은', '의성', '산청', '정읍']) { + assert.ok(lookupKoreanPlace(n), `${n} 조회 실패`); + } + }); + + test('행정 접미사를 붙여도 같은 곳', () => { + const a = lookupKoreanPlace('양산'); + const b = lookupKoreanPlace('양산시'); + assert.deepEqual(a, b); + }); + + test('멀리 떨어진 동명은 쓰지 않는다 (조용히 한쪽을 고르면 안 됨)', () => { + // 진안·옥천·가산은 100km 넘게 떨어진 동명 지점이 둘씩 있다. 이름표는 넘기고, + // 널리 통하는 행정구역 쪽은 KR_CITY_COORDS가 따로 못박는다. + for (const n of ['진안', '옥천', '가산']) { + assert.equal(lookupKoreanPlace(n), null, `${n}은 모호로 걸러져야 한다`); + } + }); + + test('한글이 아닌 이름은 이름표에 없다', () => { + assert.equal(lookupKoreanPlace('London'), null); + assert.equal(lookupKoreanPlace(''), null); + }); +});