mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Stop guessing districts and require exact station retries for ambiguous fine-dust lookups
The fine-dust proxy now resolves natural-language region hints through city-level station lists and only returns a report when a single station can be justified. When the hint is ambiguous, the proxy returns a small candidate list so callers can retry with an exact station name instead of silently guessing. The skill guidance was updated to match that runtime contract: region hint first, then retry with stationName when candidate_stations are returned. Coordinate-centric guidance was removed from the primary skill surface so the default path stays lightweight and consistent with the live proxy behavior. Constraint: The current AirKorea key can access city-level and station-level measurement APIs but station-info lookups may still return 403 Constraint: Free-API proxy responses must stay safe to expose publicly, so ambiguous locations should not be auto-guessed Rejected: Auto-pick the first city-level station for unmatched district hints | hides ambiguity and returns misleading air-quality data Rejected: Keep coordinate-first language in the primary skill | no coordinate source exists in the default user flow Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve the ambiguous_location contract; if you improve matching later, prefer evidence-backed narrowing over silent fallback guesses Tested: node --test scripts/skill-docs.test.js; npm run test --workspace k-skill-proxy; python3 -m unittest discover -s scripts -p test_fine_dust.py; live curl for ambiguous regionHint=광주 광산구 and exact stationName=우산동(광주) Not-tested: Broader region alias quality outside the manually checked examples
This commit is contained in:
parent
a3ef6ffac6
commit
8b36634e28
8 changed files with 241 additions and 246 deletions
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
## 이 기능으로 할 수 있는 일
|
## 이 기능으로 할 수 있는 일
|
||||||
|
|
||||||
- 사용자 위치 위도/경도로 가까운 측정소 찾기
|
- 지역명/행정구역 힌트로 측정소 후보 찾기
|
||||||
- 위치 권한이 없을 때 지역명/행정구역 fallback으로 측정소 찾기
|
- 단일 측정소 확정이 어려우면 후보 측정소 목록 반환
|
||||||
|
- 정확한 측정소명으로 재조회
|
||||||
- PM10, PM2.5, 등급, 조회 시각 요약
|
- PM10, PM2.5, 등급, 조회 시각 요약
|
||||||
|
|
||||||
## 먼저 필요한 것
|
## 먼저 필요한 것
|
||||||
|
|
@ -25,16 +26,16 @@
|
||||||
|
|
||||||
## 입력값
|
## 입력값
|
||||||
|
|
||||||
- 우선: 현재 위치 위도/경도(WGS84)
|
- 기본: 지역명/행정구역 힌트(`regionHint`)
|
||||||
- fallback: 지역명/행정구역 힌트 또는 측정소명
|
- 재조회: 정확한 측정소명(`stationName`)
|
||||||
|
|
||||||
## 기본 흐름
|
## 기본 흐름
|
||||||
|
|
||||||
1. `KSKILL_PROXY_BASE_URL` 가 있으면 먼저 `k-skill-proxy` 의 `/v1/fine-dust/report` endpoint 를 호출합니다.
|
1. `KSKILL_PROXY_BASE_URL` 가 있으면 먼저 `k-skill-proxy` 의 `/v1/fine-dust/report` endpoint 를 호출합니다.
|
||||||
2. 프록시가 없을 때만 입력 위도/경도(WGS84)를 에어코리아 nearby 조회가 요구하는 **TM 좌표(중부원점)** 로 먼저 변환합니다.
|
2. `regionHint` 가 들어오면 프록시는 먼저 시도명을 추출하고, `getCtprvnRltmMesureDnsty` 로 해당 시도 측정소 목록을 확보합니다.
|
||||||
3. 변환된 `tmX`/`tmY` 로 측정소정보 API `getNearbyMsrstnList` 를 호출해 가까운 측정소를 찾습니다.
|
3. region token 이 시도 내 실제 측정소명과 **유일하게** 대응하면 그 측정소로 `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
|
||||||
4. 좌표를 못 받거나 nearby 결과가 비면 측정소정보 API `getMsrstnList` 로 지역명/행정구역 fallback을 사용합니다.
|
4. 단일 측정소 확정이 어려우면 `ambiguous_location` 과 `candidate_stations` 를 반환합니다.
|
||||||
5. 선택된 측정소 이름으로 대기오염정보 API `getMsrstnAcctoRltmMesureDnsty` 를 호출합니다.
|
5. 클라이언트/사용자는 후보 중 정확한 측정소명으로 다시 `/v1/fine-dust/report?stationName=...` 를 호출합니다.
|
||||||
6. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
|
6. PM10, PM2.5, 등급, 조회 시점/조회 시각을 함께 요약합니다.
|
||||||
|
|
||||||
프록시 예시:
|
프록시 예시:
|
||||||
|
|
@ -45,6 +46,20 @@ sops exec-env "$HOME/.config/k-skill/secrets.env" \
|
||||||
'python3 scripts/fine_dust.py report --region-hint "서울 강남구" --json'
|
'python3 scripts/fine_dust.py report --region-hint "서울 강남구" --json'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
후보 반환 예시:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||||
|
--data-urlencode 'regionHint=광주 광산구'
|
||||||
|
```
|
||||||
|
|
||||||
|
정확한 측정소명 재조회:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||||
|
--data-urlencode 'stationName=우산동(광주)'
|
||||||
|
```
|
||||||
|
|
||||||
원본 AirKorea endpoint 형태를 거의 그대로 쓰고 싶으면 passthrough endpoint 도 사용할 수 있습니다. 별도 client API 는 불필요하고, 프록시가 `serviceKey` 만 서버에서 주입합니다.
|
원본 AirKorea endpoint 형태를 거의 그대로 쓰고 싶으면 passthrough endpoint 도 사용할 수 있습니다. 별도 client API 는 불필요하고, 프록시가 `serviceKey` 만 서버에서 주입합니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -59,23 +74,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
|
||||||
|
|
||||||
## 예시
|
## 예시
|
||||||
|
|
||||||
좌표 기반 1차 조회:
|
지역 기반 direct fallback:
|
||||||
|
|
||||||
```bash
|
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
|
|
||||||
sops exec-env "$HOME/.config/k-skill/secrets.env" \
|
|
||||||
'curl -sG "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc/getNearbyMsrstnList" \
|
|
||||||
--data-urlencode "serviceKey=${AIR_KOREA_OPEN_API_KEY}" \
|
|
||||||
--data-urlencode "returnType=json" \
|
|
||||||
--data-urlencode "numOfRows=10" \
|
|
||||||
--data-urlencode "pageNo=1" \
|
|
||||||
--data-urlencode "tmX=198245.053183" \
|
|
||||||
--data-urlencode "tmY=451586.837879"'
|
|
||||||
```
|
|
||||||
|
|
||||||
`getNearbyMsrstnList` 는 WGS84 위도/경도를 직접 받지 않습니다. helper script 는 `37.5665, 126.9780` 같은 입력을 위 값처럼 TM 좌표로 변환한 뒤 nearby API 를 호출합니다. 같은 기술문서에는 읍면동 기준 `getTMStdrCrdnt` 도 정의돼 있지만, 이 스킬은 사용자 위치 입력이 WGS84 라는 점 때문에 로컬 변환 후 `tmX`/`tmY` 를 사용합니다.
|
|
||||||
|
|
||||||
지역 fallback:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
|
SOPS_AGE_KEY_FILE="$HOME/.config/k-skill/age/keys.txt" \
|
||||||
|
|
@ -109,22 +108,20 @@ helper script 반복 검증:
|
||||||
python3 scripts/fine_dust.py report \
|
python3 scripts/fine_dust.py report \
|
||||||
--station-file scripts/fixtures/fine-dust-stations.json \
|
--station-file scripts/fixtures/fine-dust-stations.json \
|
||||||
--measurement-file scripts/fixtures/fine-dust-measurements.json \
|
--measurement-file scripts/fixtures/fine-dust-measurements.json \
|
||||||
--lat 37.5665 \
|
--region-hint "서울 강남구"
|
||||||
--lon 126.9780
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## fallback / 대체 흐름
|
## fallback / 대체 흐름
|
||||||
|
|
||||||
- 위치 권한이 없으면 지역명/행정구역을 먼저 받습니다
|
- 지역명/행정구역을 먼저 받습니다
|
||||||
- 지역명도 없으면 측정소명을 직접 받습니다
|
- 단일 측정소를 확정하지 못하면 후보 측정소 목록을 돌려줍니다
|
||||||
- 측정소 목록 API가 빈 응답이어도 `--station-name` 이 있으면 같은 이름으로 실시간 측정 API를 직접 재시도합니다
|
- 사용자는 후보 중 하나를 선택해 `stationName` 으로 다시 조회합니다
|
||||||
- `getNearbyMsrstnList` 결과가 비면 `getMsrstnList` 로 재시도합니다
|
- 측정소 목록 API가 403 이어도 `getCtprvnRltmMesureDnsty` 와 측정소별 실측 API 조합으로 우회합니다
|
||||||
- nearby 응답은 입력 TM 좌표와의 거리 기준으로 정렬되므로 첫 측정소를 우선 사용합니다
|
|
||||||
|
|
||||||
## 주의할 점
|
## 주의할 점
|
||||||
|
|
||||||
- 실시간 수치라 조회 시각을 같이 적어야 합니다
|
- 실시간 수치라 조회 시각을 같이 적어야 합니다
|
||||||
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
|
- PM10/PM2.5 값이 `-` 이거나 비정상이면 등급도 함께 재확인합니다
|
||||||
- API 가 `khaiGrade` 를 비워 보내면 통합대기등급은 `정보없음` 으로 표시합니다
|
- API 가 `khaiGrade` 를 비워 보내면 통합대기등급은 `정보없음` 으로 표시합니다
|
||||||
- 위치 기반이라고 해도 실제 기준은 “가까운 측정소” 이므로 주소 중심점과 오차가 있을 수 있습니다
|
- regionHint 는 자연어이므로 단일 측정소가 안 잡히는 경우가 자주 있습니다
|
||||||
- hosted 모드에서는 upstream AirKorea key 를 클라이언트에 배포하지 않고 proxy 에만 둡니다
|
- hosted 모드에서는 upstream AirKorea key 를 클라이언트에 배포하지 않고 proxy 에만 둡니다
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,8 @@ metadata:
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- 우선 입력: 위도/경도(WGS84)
|
- 일반 입력: 지역명/행정구역 힌트
|
||||||
- 일반 fallback: 지역명/행정구역 힌트
|
- 재조회 입력: 정확한 측정소명
|
||||||
- 마지막 fallback: 측정소명
|
|
||||||
|
|
||||||
## Region naming convention
|
## Region naming convention
|
||||||
|
|
||||||
|
|
@ -50,6 +49,24 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||||
python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
|
python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ambiguous locations
|
||||||
|
|
||||||
|
입력한 지역명이 단일 측정소로 바로 확정되지 않으면 proxy 는 `ambiguous_location` 과 함께 후보 측정소 목록을 돌려준다.
|
||||||
|
|
||||||
|
예:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||||
|
--data-urlencode 'regionHint=광주 광산구'
|
||||||
|
```
|
||||||
|
|
||||||
|
이때 응답의 `candidate_stations` 중 하나를 골라 다시 `stationName` 으로 조회한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
||||||
|
--data-urlencode 'stationName=우산동(광주)'
|
||||||
|
```
|
||||||
|
|
||||||
## Detailed API paths
|
## Detailed API paths
|
||||||
|
|
||||||
원본 AirKorea와 비슷한 passthrough 경로(`/B552584/...`)나 direct fallback 상세는 아래 문서만 참고한다.
|
원본 AirKorea와 비슷한 passthrough 경로(`/B552584/...`)나 direct fallback 상세는 아래 문서만 참고한다.
|
||||||
|
|
@ -66,16 +83,17 @@ python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
|
||||||
- PM10 값과 등급
|
- PM10 값과 등급
|
||||||
- PM2.5 값과 등급
|
- PM2.5 값과 등급
|
||||||
- 통합대기등급
|
- 통합대기등급
|
||||||
- 조회 방식(`coordinates` 또는 `fallback`)
|
- 조회 방식(`fallback`)
|
||||||
|
|
||||||
## Failure modes
|
## Failure modes
|
||||||
|
|
||||||
- regionHint 없이도 정확한 지역을 추정해야 하는 경우
|
- regionHint 가 너무 넓거나 단일 측정소를 확정할 수 없는 경우
|
||||||
- 프록시 서버가 내려가 있거나 upstream key가 비어 있는 경우
|
- 프록시 서버가 내려가 있거나 upstream key가 비어 있는 경우
|
||||||
- 측정소명과 지역명이 달라 직접 fallback 이 필요한 경우
|
- 측정소명과 지역명이 달라 직접 fallback 이 필요한 경우
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- 기본 경로는 항상 `k-skill-proxy.nomadamas.org` 의 report endpoint 다.
|
- 기본 경로는 항상 `k-skill-proxy.nomadamas.org` 의 report endpoint 다.
|
||||||
|
- 지역명 조회는 먼저 후보를 얻고, 필요하면 정확한 측정소명으로 재조회한다.
|
||||||
- passthrough / direct AirKorea 구현 세부는 스킬 본문에 길게 반복하지 않는다.
|
- passthrough / direct AirKorea 구현 세부는 스킬 본문에 길게 반복하지 않는다.
|
||||||
- free API 프록시는 공개 endpoint 를 기본으로 둔다.
|
- free API 프록시는 공개 endpoint 를 기본으로 둔다.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
const STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc";
|
const STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc";
|
||||||
const MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc";
|
const MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc";
|
||||||
const WGS84_A = 6378137.0;
|
|
||||||
const WGS84_F = 1 / 298.257223563;
|
|
||||||
const BESSEL_A = 6377397.155;
|
|
||||||
const BESSEL_F = 1 / 299.1528128;
|
|
||||||
const AIR_KOREA_TM_LAT0 = degreesToRadians(38.0);
|
|
||||||
const AIR_KOREA_TM_LON0 = degreesToRadians(127.0);
|
|
||||||
const AIR_KOREA_TM_FALSE_EASTING = 200000.0;
|
|
||||||
const AIR_KOREA_TM_FALSE_NORTHING = 500000.0;
|
|
||||||
const AIR_KOREA_TM_SCALE = 1.0;
|
|
||||||
const AIR_KOREA_WGS84_TO_BESSEL = [146.43, -507.89, -681.46];
|
|
||||||
const GRADE_LABELS = {
|
const GRADE_LABELS = {
|
||||||
"1": "좋음",
|
"1": "좋음",
|
||||||
"2": "보통",
|
"2": "보통",
|
||||||
|
|
@ -17,10 +7,6 @@ const GRADE_LABELS = {
|
||||||
"4": "매우나쁨"
|
"4": "매우나쁨"
|
||||||
};
|
};
|
||||||
|
|
||||||
function degreesToRadians(value) {
|
|
||||||
return (value * Math.PI) / 180;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractItems(payload) {
|
function extractItems(payload) {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload;
|
return payload;
|
||||||
|
|
@ -48,98 +34,7 @@ function toFloat(raw) {
|
||||||
return Number.isFinite(value) ? value : null;
|
return Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function squaredDistance(latA, lonA, latB, lonB) {
|
function pickStation(stationItems, { regionHint = null, stationName = null } = {}) {
|
||||||
return (latA - latB) ** 2 + (lonA - lonB) ** 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
function meridionalArc(phi, { semiMajorAxis, eccentricitySquared }) {
|
|
||||||
const e2 = eccentricitySquared;
|
|
||||||
|
|
||||||
return semiMajorAxis * (
|
|
||||||
(1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * phi -
|
|
||||||
((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * Math.sin(2 * phi) +
|
|
||||||
((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * phi) -
|
|
||||||
((35 * e2 ** 3) / 3072) * Math.sin(6 * phi)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function wgs84ToBessel(latitude, longitude) {
|
|
||||||
const [dx, dy, dz] = AIR_KOREA_WGS84_TO_BESSEL;
|
|
||||||
const sourceE2 = 2 * WGS84_F - WGS84_F ** 2;
|
|
||||||
const targetE2 = 2 * BESSEL_F - BESSEL_F ** 2;
|
|
||||||
|
|
||||||
const latRad = degreesToRadians(latitude);
|
|
||||||
const lonRad = degreesToRadians(longitude);
|
|
||||||
const sinLat = Math.sin(latRad);
|
|
||||||
const cosLat = Math.cos(latRad);
|
|
||||||
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - sourceE2 * sinLat * sinLat);
|
|
||||||
|
|
||||||
const x = primeVerticalRadius * cosLat * Math.cos(lonRad) + dx;
|
|
||||||
const y = primeVerticalRadius * cosLat * Math.sin(lonRad) + dy;
|
|
||||||
const z = primeVerticalRadius * (1 - sourceE2) * sinLat + dz;
|
|
||||||
|
|
||||||
const lonBessel = Math.atan2(y, x);
|
|
||||||
const horizontal = Math.sqrt(x * x + y * y);
|
|
||||||
let latBessel = Math.atan2(z, horizontal * (1 - targetE2));
|
|
||||||
|
|
||||||
for (let index = 0; index < 8; index += 1) {
|
|
||||||
const sinLatBessel = Math.sin(latBessel);
|
|
||||||
const besselRadius = BESSEL_A / Math.sqrt(1 - targetE2 * sinLatBessel * sinLatBessel);
|
|
||||||
const nextLat = Math.atan2(z + targetE2 * besselRadius * sinLatBessel, horizontal);
|
|
||||||
|
|
||||||
if (Math.abs(nextLat - latBessel) < 1e-14) {
|
|
||||||
latBessel = nextLat;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
latBessel = nextLat;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [latBessel, lonBessel];
|
|
||||||
}
|
|
||||||
|
|
||||||
function wgs84ToAirKoreaTm(latitude, longitude) {
|
|
||||||
const [latRad, lonRad] = wgs84ToBessel(latitude, longitude);
|
|
||||||
const besselE2 = 2 * BESSEL_F - BESSEL_F ** 2;
|
|
||||||
const secondEccentricitySquared = besselE2 / (1 - besselE2);
|
|
||||||
|
|
||||||
const sinLat = Math.sin(latRad);
|
|
||||||
const cosLat = Math.cos(latRad);
|
|
||||||
const tanLat = Math.tan(latRad);
|
|
||||||
|
|
||||||
const primeVerticalRadius = BESSEL_A / Math.sqrt(1 - besselE2 * sinLat * sinLat);
|
|
||||||
const tanSquared = tanLat * tanLat;
|
|
||||||
const curvature = secondEccentricitySquared * cosLat * cosLat;
|
|
||||||
const A = (lonRad - AIR_KOREA_TM_LON0) * cosLat;
|
|
||||||
|
|
||||||
const meridional = meridionalArc(latRad, {
|
|
||||||
semiMajorAxis: BESSEL_A,
|
|
||||||
eccentricitySquared: besselE2
|
|
||||||
});
|
|
||||||
const meridionalOrigin = meridionalArc(AIR_KOREA_TM_LAT0, {
|
|
||||||
semiMajorAxis: BESSEL_A,
|
|
||||||
eccentricitySquared: besselE2
|
|
||||||
});
|
|
||||||
|
|
||||||
const tmX = AIR_KOREA_TM_FALSE_EASTING + AIR_KOREA_TM_SCALE * primeVerticalRadius * (
|
|
||||||
A +
|
|
||||||
((1 - tanSquared + curvature) * A ** 3) / 6 +
|
|
||||||
((5 - 18 * tanSquared + tanSquared ** 2 + 72 * curvature - 58 * secondEccentricitySquared) * A ** 5) / 120
|
|
||||||
);
|
|
||||||
const tmY = AIR_KOREA_TM_FALSE_NORTHING + AIR_KOREA_TM_SCALE * (
|
|
||||||
meridional -
|
|
||||||
meridionalOrigin +
|
|
||||||
primeVerticalRadius * tanLat * (
|
|
||||||
A ** 2 / 2 +
|
|
||||||
((5 - tanSquared + 9 * curvature + 4 * curvature ** 2) * A ** 4) / 24 +
|
|
||||||
((61 - 58 * tanSquared + tanSquared ** 2 + 600 * curvature - 330 * secondEccentricitySquared) * A ** 6) / 720
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { tmX, tmY };
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickStation(stationItems, { lat = null, lon = null, regionHint = null, stationName = null } = {}) {
|
|
||||||
if (!stationItems.length) {
|
if (!stationItems.length) {
|
||||||
throw new Error("측정소 후보가 없습니다.");
|
throw new Error("측정소 후보가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -158,26 +53,6 @@ function pickStation(stationItems, { lat = null, lon = null, regionHint = null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
const candidates = stationItems
|
|
||||||
.map((item) => {
|
|
||||||
const itemLat = toFloat(item.dmX);
|
|
||||||
const itemLon = toFloat(item.dmY);
|
|
||||||
|
|
||||||
if (itemLat === null || itemLon === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [squaredDistance(lat, lon, itemLat, itemLon), item];
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((left, right) => left[0] - right[0]);
|
|
||||||
|
|
||||||
if (candidates.length > 0) {
|
|
||||||
return candidates[0][1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (regionHint) {
|
if (regionHint) {
|
||||||
const tokens = [...new Set(String(regionHint).split(/\s+/u).filter(Boolean))].sort((left, right) => right.length - left.length);
|
const tokens = [...new Set(String(regionHint).split(/\s+/u).filter(Boolean))].sort((left, right) => right.length - left.length);
|
||||||
|
|
||||||
|
|
@ -233,6 +108,15 @@ function buildStationNameCandidates({ stationName = null, regionHint = null } =
|
||||||
return [...new Set(candidates.filter(Boolean))];
|
return [...new Set(candidates.filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRegionTokens(regionHint) {
|
||||||
|
return [...new Set(
|
||||||
|
String(regionHint || "")
|
||||||
|
.split(/\s+/u)
|
||||||
|
.map((token) => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
function findMeasurement(measurementItems, stationName) {
|
function findMeasurement(measurementItems, stationName) {
|
||||||
const exactMatch = measurementItems.find((item) => item.stationName === stationName);
|
const exactMatch = measurementItems.find((item) => item.stationName === stationName);
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
|
|
@ -271,15 +155,13 @@ function gradeToLabel(rawGrade, { pollutant, value }) {
|
||||||
return "매우나쁨";
|
return "매우나쁨";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReport({ stationItems, measurementItems, lat = null, lon = null, regionHint = null, stationName = null, lookupMode = null, selectedStation = null }) {
|
function buildReport({ stationItems, measurementItems, regionHint = null, stationName = null, lookupMode = null, selectedStation = null }) {
|
||||||
const station = selectedStation || resolveStation(stationItems, {
|
const station = selectedStation || resolveStation(stationItems, {
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName
|
stationName
|
||||||
});
|
});
|
||||||
const measurement = findMeasurement(measurementItems, station.stationName);
|
const measurement = findMeasurement(measurementItems, station.stationName);
|
||||||
const resolvedLookupMode = lookupMode || (Number.isFinite(lat) && Number.isFinite(lon) ? "coordinates" : "fallback");
|
const resolvedLookupMode = lookupMode || "fallback";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
station_name: station.stationName,
|
station_name: station.stationName,
|
||||||
|
|
@ -344,7 +226,7 @@ async function fetchJson(baseUrl, params, { fetchImpl = global.fetch, headers =
|
||||||
return JSON.parse(await response.text());
|
return JSON.parse(await response.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStationLookup({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL }) {
|
async function fetchStationLookup({ regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL }) {
|
||||||
if (!serviceKey) {
|
if (!serviceKey) {
|
||||||
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
|
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
|
||||||
}
|
}
|
||||||
|
|
@ -356,26 +238,6 @@ async function fetchStationLookup({ lat = null, lon = null, regionHint = null, s
|
||||||
pageNo: 1
|
pageNo: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
const { tmX, tmY } = wgs84ToAirKoreaTm(lat, lon);
|
|
||||||
const nearbyPayload = await fetchJson(`${stationServiceUrl}/getNearbyMsrstnList`, {
|
|
||||||
...common,
|
|
||||||
numOfRows: 10,
|
|
||||||
tmX,
|
|
||||||
tmY
|
|
||||||
}, {
|
|
||||||
fetchImpl,
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
|
|
||||||
if (extractItems(nearbyPayload).length > 0) {
|
|
||||||
return {
|
|
||||||
lookupMode: "coordinates",
|
|
||||||
payload: nearbyPayload
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (regionHint || stationName) {
|
if (regionHint || stationName) {
|
||||||
return {
|
return {
|
||||||
lookupMode: "fallback",
|
lookupMode: "fallback",
|
||||||
|
|
@ -390,7 +252,7 @@ async function fetchStationLookup({ lat = null, lon = null, regionHint = null, s
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("위도/경도 또는 region fallback 이 필요합니다.");
|
throw new Error("regionHint 또는 stationName 이 필요합니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||||
|
|
@ -412,15 +274,31 @@ async function fetchMeasurementPayload({ stationName, serviceKey, fetchImpl = gl
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchFineDustReport({ lat = null, lon = null, regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
async function fetchCtprvnMeasurementPayload({ sidoName, serviceKey, fetchImpl = global.fetch, headers = {}, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||||
|
if (!serviceKey) {
|
||||||
|
throw new Error("AIR_KOREA_OPEN_API_KEY is not configured on the proxy server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchJson(`${measurementServiceUrl}/getCtprvnRltmMesureDnsty`, {
|
||||||
|
serviceKey,
|
||||||
|
returnType: "json",
|
||||||
|
numOfRows: 100,
|
||||||
|
pageNo: 1,
|
||||||
|
sidoName,
|
||||||
|
ver: "1.4"
|
||||||
|
}, {
|
||||||
|
fetchImpl,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFineDustReport({ regionHint = null, stationName = null, serviceKey, fetchImpl = global.fetch, headers = {}, stationServiceUrl = STATION_SERVICE_URL, measurementServiceUrl = MEASUREMENT_SERVICE_URL }) {
|
||||||
let stationLookup;
|
let stationLookup;
|
||||||
let stationItems;
|
let stationItems;
|
||||||
let station;
|
let station;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
stationLookup = await fetchStationLookup({
|
stationLookup = await fetchStationLookup({
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName,
|
stationName,
|
||||||
serviceKey,
|
serviceKey,
|
||||||
|
|
@ -430,8 +308,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
||||||
});
|
});
|
||||||
stationItems = extractItems(stationLookup.payload);
|
stationItems = extractItems(stationLookup.payload);
|
||||||
station = resolveStation(stationItems, {
|
station = resolveStation(stationItems, {
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName
|
stationName
|
||||||
});
|
});
|
||||||
|
|
@ -460,8 +336,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
||||||
return buildReport({
|
return buildReport({
|
||||||
stationItems: [{ stationName: matchedMeasurement.stationName, addr: null }],
|
stationItems: [{ stationName: matchedMeasurement.stationName, addr: null }],
|
||||||
measurementItems,
|
measurementItems,
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName: matchedMeasurement.stationName,
|
stationName: matchedMeasurement.stationName,
|
||||||
lookupMode: "fallback",
|
lookupMode: "fallback",
|
||||||
|
|
@ -472,6 +346,48 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const regionTokens = buildRegionTokens(regionHint);
|
||||||
|
const sidoName = regionTokens[0];
|
||||||
|
if (sidoName) {
|
||||||
|
const ctprvnPayload = await fetchCtprvnMeasurementPayload({
|
||||||
|
sidoName,
|
||||||
|
serviceKey,
|
||||||
|
fetchImpl,
|
||||||
|
headers,
|
||||||
|
measurementServiceUrl
|
||||||
|
});
|
||||||
|
const cityItems = extractItems(ctprvnPayload);
|
||||||
|
const specificTokens = regionTokens.length > 1 ? regionTokens.slice(1) : regionTokens;
|
||||||
|
const tokenMatches = cityItems.filter((item) =>
|
||||||
|
specificTokens.some((token) => String(item.stationName || "").includes(token))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tokenMatches.length > 0) {
|
||||||
|
const selectedStation = tokenMatches[0];
|
||||||
|
return buildReport({
|
||||||
|
stationItems: [{ stationName: selectedStation.stationName, addr: null }],
|
||||||
|
measurementItems: cityItems,
|
||||||
|
regionHint,
|
||||||
|
stationName: selectedStation.stationName,
|
||||||
|
lookupMode: "fallback",
|
||||||
|
selectedStation: { stationName: selectedStation.stationName, addr: null }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stationSamples = cityItems
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((item) => item.stationName)
|
||||||
|
.filter(Boolean);
|
||||||
|
const lookupError = new Error(
|
||||||
|
`'${regionHint}' 는 현재 바로 매핑되는 단일 측정소를 확정하지 못했습니다. 아래 후보 중 정확한 측정소명으로 다시 조회해 주세요.`,
|
||||||
|
);
|
||||||
|
lookupError.statusCode = 400;
|
||||||
|
lookupError.code = "ambiguous_location";
|
||||||
|
lookupError.sidoName = sidoName;
|
||||||
|
lookupError.candidateStations = stationSamples;
|
||||||
|
throw lookupError;
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -486,8 +402,6 @@ async function fetchFineDustReport({ lat = null, lon = null, regionHint = null,
|
||||||
return buildReport({
|
return buildReport({
|
||||||
stationItems,
|
stationItems,
|
||||||
measurementItems: extractItems(measurementPayload),
|
measurementItems: extractItems(measurementPayload),
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName: station.stationName,
|
stationName: station.stationName,
|
||||||
lookupMode: stationLookup.lookupMode,
|
lookupMode: stationLookup.lookupMode,
|
||||||
|
|
@ -502,12 +416,12 @@ module.exports = {
|
||||||
buildReport,
|
buildReport,
|
||||||
extractItems,
|
extractItems,
|
||||||
fetchFineDustReport,
|
fetchFineDustReport,
|
||||||
|
fetchCtprvnMeasurementPayload,
|
||||||
fetchMeasurementPayload,
|
fetchMeasurementPayload,
|
||||||
fetchStationLookup,
|
fetchStationLookup,
|
||||||
findMeasurement,
|
findMeasurement,
|
||||||
gradeToLabel,
|
gradeToLabel,
|
||||||
pickStation,
|
pickStation,
|
||||||
resolveStation,
|
resolveStation,
|
||||||
toFloat,
|
toFloat
|
||||||
wgs84ToAirKoreaTm
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ const { fetchFineDustReport } = require("./airkorea");
|
||||||
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty"])],
|
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||||
["UserSportSvc", new Set(["getSvckeyDalyStats"])],
|
["UserSportSvc", new Set(["getSvckeyDalyStats"])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -107,26 +107,14 @@ function buildRateLimiter(config) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFineDustQuery(query) {
|
function normalizeFineDustQuery(query) {
|
||||||
const lat = parseFloatValue(query.lat);
|
|
||||||
const lon = parseFloatValue(query.lon);
|
|
||||||
const regionHint = trimOrNull(query.regionHint ?? query.region_hint);
|
const regionHint = trimOrNull(query.regionHint ?? query.region_hint);
|
||||||
const stationName = trimOrNull(query.stationName ?? query.station_name);
|
const stationName = trimOrNull(query.stationName ?? query.station_name);
|
||||||
|
|
||||||
if ((lat !== null && Number.isNaN(lat)) || (lon !== null && Number.isNaN(lon))) {
|
if (!regionHint && !stationName) {
|
||||||
throw new Error("lat/lon must be finite numbers.");
|
throw new Error("Provide regionHint or stationName.");
|
||||||
}
|
|
||||||
|
|
||||||
if ((lat === null) !== (lon === null)) {
|
|
||||||
throw new Error("lat and lon must be provided together.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lat === null && !regionHint && !stationName) {
|
|
||||||
throw new Error("Provide lat/lon, regionHint, or stationName.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lat,
|
|
||||||
lon,
|
|
||||||
regionHint,
|
regionHint,
|
||||||
stationName
|
stationName
|
||||||
};
|
};
|
||||||
|
|
@ -299,10 +287,20 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
||||||
app.setErrorHandler((error, request, reply) => {
|
app.setErrorHandler((error, request, reply) => {
|
||||||
request.log.error(error);
|
request.log.error(error);
|
||||||
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||||
reply.code(statusCode).send({
|
const payload = {
|
||||||
error: statusCode >= 500 ? "proxy_error" : "request_error",
|
error: error.code || (statusCode >= 500 ? "proxy_error" : "request_error"),
|
||||||
message: error.message
|
message: error.message
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(error.candidateStations)) {
|
||||||
|
payload.candidate_stations = error.candidateStations;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.sidoName) {
|
||||||
|
payload.sido_name = error.sidoName;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(statusCode).send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ const assert = require("node:assert/strict");
|
||||||
const {
|
const {
|
||||||
buildReport,
|
buildReport,
|
||||||
fetchFineDustReport,
|
fetchFineDustReport,
|
||||||
pickStation,
|
pickStation
|
||||||
wgs84ToAirKoreaTm
|
|
||||||
} = require("../src/airkorea");
|
} = require("../src/airkorea");
|
||||||
|
|
||||||
const stationPayload = {
|
const stationPayload = {
|
||||||
|
|
@ -47,13 +46,6 @@ const measurementPayload = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
test("wgs84 coordinates are converted to AirKorea TM", () => {
|
|
||||||
const { tmX, tmY } = wgs84ToAirKoreaTm(37.5665, 126.9780);
|
|
||||||
|
|
||||||
assert.ok(Math.abs(tmX - 198245.053) < 0.01);
|
|
||||||
assert.ok(Math.abs(tmY - 451586.838) < 0.01);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pickStation prefers specific region token matches", () => {
|
test("pickStation prefers specific region token matches", () => {
|
||||||
const station = pickStation(stationPayload.response.body.items, {
|
const station = pickStation(stationPayload.response.body.items, {
|
||||||
regionHint: "서울 강남구"
|
regionHint: "서울 강남구"
|
||||||
|
|
@ -75,18 +67,11 @@ test("buildReport combines station and measurement summary", () => {
|
||||||
assert.equal(report.lookup_mode, "fallback");
|
assert.equal(report.lookup_mode, "fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fetchFineDustReport falls back to region lookup when nearby returns empty", async () => {
|
test("fetchFineDustReport uses station-info lookup before measurement lookup", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async (url) => {
|
||||||
calls.push(String(url));
|
calls.push(String(url));
|
||||||
|
|
||||||
if (String(url).includes("getNearbyMsrstnList")) {
|
|
||||||
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (String(url).includes("getMsrstnList")) {
|
if (String(url).includes("getMsrstnList")) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
response: {
|
response: {
|
||||||
|
|
@ -111,8 +96,6 @@ test("fetchFineDustReport falls back to region lookup when nearby returns empty"
|
||||||
};
|
};
|
||||||
|
|
||||||
const report = await fetchFineDustReport({
|
const report = await fetchFineDustReport({
|
||||||
lat: 37.5665,
|
|
||||||
lon: 126.978,
|
|
||||||
regionHint: "서울 강남구",
|
regionHint: "서울 강남구",
|
||||||
serviceKey: "test-key",
|
serviceKey: "test-key",
|
||||||
fetchImpl
|
fetchImpl
|
||||||
|
|
@ -121,7 +104,6 @@ test("fetchFineDustReport falls back to region lookup when nearby returns empty"
|
||||||
assert.equal(report.station_name, "강남구");
|
assert.equal(report.station_name, "강남구");
|
||||||
assert.equal(report.lookup_mode, "fallback");
|
assert.equal(report.lookup_mode, "fallback");
|
||||||
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
|
assert.deepEqual(calls.map((url) => url.split("/").at(-1)?.split("?")[0]), [
|
||||||
"getNearbyMsrstnList",
|
|
||||||
"getMsrstnList",
|
"getMsrstnList",
|
||||||
"getMsrstnAcctoRltmMesureDnsty"
|
"getMsrstnAcctoRltmMesureDnsty"
|
||||||
]);
|
]);
|
||||||
|
|
@ -161,3 +143,53 @@ test("fetchFineDustReport falls back to direct measurement lookup when station-i
|
||||||
"getMsrstnAcctoRltmMesureDnsty"
|
"getMsrstnAcctoRltmMesureDnsty"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fetchFineDustReport returns a helpful 400 when district tokens do not map to station names", async () => {
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
const text = String(url);
|
||||||
|
|
||||||
|
if (text.includes("getMsrstnList")) {
|
||||||
|
return new Response("Forbidden", { status: 403, headers: { "content-type": "text/plain" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes("getMsrstnAcctoRltmMesureDnsty")) {
|
||||||
|
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.includes("getCtprvnRltmMesureDnsty")) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
response: {
|
||||||
|
body: {
|
||||||
|
items: [
|
||||||
|
{ stationName: "평동", dataTime: "2026-03-28 17:00", pm10Value: "48", pm10Grade: "2", pm25Value: "25", pm25Grade: "2", khaiGrade: "2" },
|
||||||
|
{ stationName: "오선동", dataTime: "2026-03-28 17:00", pm10Value: "38", pm10Grade: "2", pm25Value: "23", pm25Grade: "2", khaiGrade: "2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unexpected URL: ${url}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => fetchFineDustReport({
|
||||||
|
regionHint: "광주 광산구",
|
||||||
|
serviceKey: "test-key",
|
||||||
|
fetchImpl
|
||||||
|
}),
|
||||||
|
(error) =>
|
||||||
|
error.statusCode === 400 &&
|
||||||
|
error.code === "ambiguous_location" &&
|
||||||
|
error.sidoName === "광주" &&
|
||||||
|
Array.isArray(error.candidateStations) &&
|
||||||
|
error.candidateStations.includes("평동") &&
|
||||||
|
error.candidateStations.includes("오선동")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,36 @@ test("fine dust endpoint stays publicly callable without proxy auth", async (t)
|
||||||
assert.equal(providerCalls, 1);
|
assert.equal(providerCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("fine dust endpoint returns candidate stations when region resolution is ambiguous", async (t) => {
|
||||||
|
const app = buildServer({
|
||||||
|
env: {
|
||||||
|
AIR_KOREA_OPEN_API_KEY: "airkorea-key"
|
||||||
|
},
|
||||||
|
provider: async () => {
|
||||||
|
const error = new Error("단일 측정소를 확정하지 못했습니다.");
|
||||||
|
error.statusCode = 400;
|
||||||
|
error.code = "ambiguous_location";
|
||||||
|
error.sidoName = "광주";
|
||||||
|
error.candidateStations = ["평동", "오선동"];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.after(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/v1/fine-dust/report?regionHint=%EA%B4%91%EC%A3%BC%20%EA%B4%91%EC%82%B0%EA%B5%AC"
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 400);
|
||||||
|
assert.equal(response.json().error, "ambiguous_location");
|
||||||
|
assert.equal(response.json().sido_name, "광주");
|
||||||
|
assert.deepEqual(response.json().candidate_stations, ["평동", "오선동"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("fine dust endpoint caches successful provider responses", async (t) => {
|
test("fine dust endpoint caches successful provider responses", async (t) => {
|
||||||
let providerCalls = 0;
|
let providerCalls = 0;
|
||||||
const app = buildServer({
|
const app = buildServer({
|
||||||
|
|
|
||||||
|
|
@ -372,6 +372,17 @@ def read_json_response(request: urllib.request.Request | str) -> dict:
|
||||||
payload = None
|
payload = None
|
||||||
|
|
||||||
message = payload.get("message") if isinstance(payload, dict) else None
|
message = payload.get("message") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(payload, dict) and payload.get("error") == "ambiguous_location":
|
||||||
|
candidates = payload.get("candidate_stations") or []
|
||||||
|
sido_name = payload.get("sido_name")
|
||||||
|
detail = [message or "단일 측정소를 확정하지 못했습니다."]
|
||||||
|
if sido_name:
|
||||||
|
detail.append(f"시도: {sido_name}")
|
||||||
|
if candidates:
|
||||||
|
detail.append(f"후보 측정소: {', '.join(candidates)}")
|
||||||
|
detail.append("위 후보 중 정확한 측정소명으로 --station-name 재조회하세요.")
|
||||||
|
raise SystemExit("\n".join(detail)) from exc
|
||||||
|
|
||||||
raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
|
raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -649,19 +649,14 @@ test("fine-dust-location skill documents the official two-api flow and fallback
|
||||||
|
|
||||||
for (const doc of [featureDoc]) {
|
for (const doc of [featureDoc]) {
|
||||||
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
|
assert.match(doc, /AIR_KOREA_OPEN_API_KEY/);
|
||||||
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getNearbyMsrstnList/);
|
|
||||||
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);
|
assert.match(doc, /B552584\/MsrstnInfoInqireSvc\/getMsrstnList/);
|
||||||
assert.match(doc, /B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty/);
|
assert.match(doc, /B552584\/ArpltnInforInqireSvc\/getMsrstnAcctoRltmMesureDnsty/);
|
||||||
assert.match(doc, /tmX/);
|
assert.match(doc, /getCtprvnRltmMesureDnsty/);
|
||||||
assert.match(doc, /tmY/);
|
|
||||||
assert.match(doc, /TM 좌표|중부원점/);
|
|
||||||
assert.match(doc, /PM10/);
|
assert.match(doc, /PM10/);
|
||||||
assert.match(doc, /PM2\.5|PM25/);
|
assert.match(doc, /PM2\.5|PM25/);
|
||||||
assert.match(doc, /위도/);
|
|
||||||
assert.match(doc, /경도/);
|
|
||||||
assert.match(doc, /행정구역|지역명/);
|
assert.match(doc, /행정구역|지역명/);
|
||||||
assert.match(doc, /fallback|폴백|대체 흐름/i);
|
assert.match(doc, /fallback|폴백|대체 흐름/i);
|
||||||
assert.match(doc, /가까운 측정소/);
|
assert.match(doc, /후보 측정소|candidate_stations/);
|
||||||
assert.match(doc, /조회 시각|조회 시점/);
|
assert.match(doc, /조회 시각|조회 시점/);
|
||||||
assert.match(doc, /python3 scripts\/fine_dust\.py/);
|
assert.match(doc, /python3 scripts\/fine_dust\.py/);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue