Restore PR #67 reviewability after syncing feature/#54 with dev

Merged origin/dev into feature/#54 and resolved the documentation/test conflicts by keeping both the cheap-gas-nearby Opinet guidance and the newer han-river/olive-young rollout docs from dev. The merged docs and regression suite now cover both feature lanes so the branch can be re-reviewed without dropping either set of release notes or setup requirements.

Constraint: PR head had to absorb dev without merging the PR itself
Rejected: Favor one side of the conflicted docs/tests | would silently drop shipped guidance from the other branch
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep shared setup/install/security docs additive when multiple feature branches introduce new skills concurrently
Tested: npm run ci; node --test packages/cheap-gas-nearby/test/index.test.js; offline fixture smoke for searchCheapGasStationsByLocationQuery('서울역')
Not-tested: Live Opinet/API-key-backed network verification
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-06 01:07:47 +09:00
commit 22ff61b714
26 changed files with 1835 additions and 56 deletions

View file

@ -0,0 +1,5 @@
---
"blue-ribbon-nearby": patch
---
Handle Blue Ribbon `PREMIUM_REQUIRED` nearby responses with a domain error and document the current premium gate on live nearby results.

View file

@ -0,0 +1,26 @@
# PRD: Issue 56 - Han River water-level proxy endpoint
## Goal
한강홍수통제소(HRFCO) Open API의 `waterlevel/info` + `waterlevel/list/10M``k-skill-proxy` 의 공개 read-only endpoint로 감싸서, 최종 사용자가 별도 ServiceKey 없이 한강 수위·유량을 조회할 수 있게 한다.
## User story
- 사용자는 "한강대교 지금 수위 어때?"처럼 관측소명 또는 관측소코드로 현재 수위와 유량을 빠르게 확인하고 싶다.
- 에이전트는 proxy가 주는 현재 관측값과 기준 수위를 요약해 답변한다.
## Scope
- `k-skill-proxy` 에 HRFCO waterlevel summary endpoint 추가
- proxy 환경변수/README/가이드 문서 반영
- 신규 han-river-water-level skill 및 기능 문서 추가
- 문서 회귀 테스트 + proxy server tests 추가
## Non-goals
- `rainfall` / `dam` / `bo` / `fldfct` 전체 확장
- private/auth-required proxy 도입
- 지도 기반 위치 추천 또는 관측소 선택 UX 고도화
## Acceptance criteria
1. proxy server 가 공개 read-only endpoint 로 HRFCO 현재 수위/유량을 요약 제공한다.
2. upstream HRFCO ServiceKey 는 proxy 서버 환경변수로만 관리된다.
3. endpoint 는 관측소명/관측소코드 기준 최신 관측시각, 수위, 유량, 기준수위를 포함한 JSON 을 반환한다.
4. 신규 skill/docs 는 hosted proxy 기본 경로와 무-key client workflow 를 문서화한다.
5. 로컬 테스트 및 최소 1회 실제 서버 실행/요청 검증을 완료한다.

View file

@ -0,0 +1,11 @@
# Test Spec: Issue 56 - Han River water-level proxy endpoint
## Regression coverage
1. `packages/k-skill-proxy/test/server.test.js` 에 HRFCO endpoint allowlist/serviceKey injection/public access/cache/ambiguous station assertions 추가
2. `scripts/skill-docs.test.js` 에 신규 han-river-water-level skill/docs/README/setup/sources/roadmap 노출면 검증 추가
3. root `npm test` 와 proxy workspace tests 가 모두 통과
## Manual verification
1. `node packages/k-skill-proxy/src/server.js` 로 로컬 서버 기동
2. health 와 새 HRFCO endpoint 를 실제 HTTP 요청으로 확인
3. station name / station code / 잘못된 입력에 대한 보수적 응답 확인

View file

@ -23,6 +23,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 프록시 URL 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 관측소 기준 현재 수위·유량·기준수위 확인 | 프록시 URL 필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
@ -32,10 +33,11 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 블루리본 서베이 공식 표면으로 근처 블루리본 맛집 검색 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 Blue Ribbon 공식 zone 매칭 + nearby premium gate 상태 설명 (`/restaurants/map` 는 2026-04-05 기준 premium-only) | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| 근처 술집 조회 | 현재 위치(서울역/강남/사당 등)를 먼저 확인한 뒤 카카오맵 기준으로 영업 상태·메뉴·좌석·전화번호가 포함된 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 공식 우체국 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 올리브영 검색 | upstream [`daiso`](https://www.npmjs.com/package/daiso) CLI / [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 기반으로 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | [coupang-mcp](https://github.com/uju777/coupang-mcp) 서버 경유로 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 중고차 가격 조회 | 주요 렌터카 업체 비교 후 SK렌터카 다이렉트 타고BUY inventory snapshot 기준으로 인수가/월 렌트료 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
@ -69,6 +71,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
@ -82,6 +85,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)

View file

@ -1,6 +1,6 @@
---
name: blue-ribbon-nearby
description: Use when the user asks for nearby restaurants or 근처 맛집. Always ask the user's current location first, then search official 블루리본 Blue Ribbon Survey ribbon restaurants near that location.
description: Use when the user asks for nearby restaurants or 근처 맛집 and wants 블루리본 picks. Always ask the user's current location first, then resolve the official Blue Ribbon zone and explain that live nearby results may currently be premium-gated.
license: MIT
metadata:
category: food
@ -12,12 +12,13 @@ metadata:
## What this skill does
유저가 알려준 현재 위치를 기준으로 블루리본 서베이 공식 검색 표면에서 **근처 블루리본 맛집**만 추려서 보여준다.
유저가 알려준 현재 위치를 기준으로 블루리본 서베이 공식 zone 을 찾고, 가능하면 **근처 블루리본 맛집**을 보여준다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 위치 문자열은 공식 `zone` 목록으로 매칭하고, 가능하면 주변 JSON endpoint 로 좁혀서 찾는다.
- 좌표를 직접 받으면 더 정확한 nearby 검색을 할 수 있다.
- 단, **2026-04-05 기준** `https://www.bluer.co.kr/restaurants/map` 는 공개 요청에 `403 {"error":"PREMIUM_REQUIRED"}` 를 반환할 수 있다. 이 경우 live nearby 결과 대신 제한사항을 설명한다.
## When to use
@ -92,17 +93,28 @@ metadata:
```js
const { searchNearbyByLocationQuery } = require("blue-ribbon-nearby");
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
try {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
console.log(result.anchor);
console.log(result.items);
console.log(result.anchor);
console.log(result.items);
} catch (error) {
if (error.code === "premium_required") {
console.log("Blue Ribbon nearby live results are currently premium-only.");
return;
}
throw error;
}
```
내부적으로는 `ribbon=true`, `ribbonType=RIBBON_THREE,RIBBON_TWO,RIBBON_ONE`, `isAround=true`, `sort=distance`, `zone2Lat`, `zone2Lng` 같은 파라미터를 사용한다.
`error.code === "premium_required"` 이면 zone 매칭은 성공했지만 Blue Ribbon 쪽 live nearby 결과가 현재 premium gate 뒤에 있다는 뜻이다. 이 계약은 location query 뿐 아니라 좌표 기반 `searchNearbyByCoordinates()` entrypoint 에도 동일하게 적용된다.
### 4. Respond with a short restaurant summary
보통 3~5개만 짧게 정리한다.
@ -116,7 +128,7 @@ console.log(result.items);
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 공식 Blue Ribbon nearby 결과를 최소 1개 이상 찾았거나, 찾지 못한 이유와 다음 질문을 제시했다.
- 공식 Blue Ribbon nearby 결과를 최소 1개 이상 찾았거나, 현재 premium gate 때문에 결과를 가져올 수 없다는 이유와 다음 질문을 제시했다.
- 결과를 거리순으로 짧게 정리했다.
## Failure modes
@ -124,6 +136,7 @@ console.log(result.items);
- 위치 문자열이 공식 zone 과 잘 매칭되지 않을 수 있다.
- 같은 키워드가 여러 상권에 걸치면 추가 확인이 필요하다.
- Blue Ribbon 사이트가 구조/파라미터를 바꾸면 zone 파싱 또는 nearby endpoint 가 깨질 수 있다.
- 현재는 `/restaurants/map` 자체가 premium gate 뒤로 이동했을 수 있으므로, 결과 대신 제한사항 설명이 필요할 수 있다.
## Notes

View file

@ -2,10 +2,13 @@
## 이 기능으로 할 수 있는 일
- 유저가 알려준 현재 위치 근처의 블루리본 맛집 검색
- 유저가 알려준 현재 위치를 공식 Blue Ribbon zone 으로 매칭
- 공식 Blue Ribbon zone 목록으로 동네/역명 매칭
- 좌표 기반 nearby 검색
- 거리순 상위 결과 정리
- 좌표 기반 nearby 요청 파라미터 구성
- live nearby premium gate 감지 및 설명
> [!WARNING]
> **2026-04-05 기준** `https://www.bluer.co.kr/restaurants/map` 이 공개 요청에 `403 {"error":"PREMIUM_REQUIRED"}` 를 반환합니다. 따라서 현재 이 기능은 zone 매칭까지는 가능하지만, live nearby 결과는 `premium_required` 에러로 종료될 수 있습니다.
## 먼저 필요한 것
@ -59,7 +62,8 @@
- 공식 zone 이름이 아닌 대표 랜드마크는 먼저 nearest zone alias 로 확장합니다. 예: `코엑스``삼성동/대치동`
3. 좌표를 받으면 nearby bounding box 를 계산합니다.
4. 공식 `/restaurants/map` endpoint 를 `isAround=true`, `ribbon=true`, `ribbonType=RIBBON_THREE,RIBBON_TWO,RIBBON_ONE`, `sort=distance` 로 조회합니다.
5. 거리순 상위 결과를 3~5개 정리합니다.
5. 현재 upstream 이 `403 {"error":"PREMIUM_REQUIRED"}` 를 반환하면 `premium_required` 도메인 에러로 승격해 원인을 설명합니다.
6. live nearby 결과가 실제로 열려 있을 때만 거리순 상위 결과를 3~5개 정리합니다.
## Node.js 예시
@ -67,13 +71,22 @@
const { searchNearbyByLocationQuery } = require("blue-ribbon-nearby");
async function main() {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
try {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
console.log(result.anchor);
console.log(result.items);
console.log(result.anchor);
console.log(result.items);
} catch (error) {
if (error.code === "premium_required") {
console.error("Blue Ribbon live nearby results are currently premium-only.");
return;
}
throw error;
}
}
main().catch((error) => {
@ -82,9 +95,25 @@ main().catch((error) => {
});
```
## 검증된 live smoke 예시
## 현재 live 상태
아래 값은 **2026-03-27**`광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출해 확인한 결과 일부입니다.
**2026-04-05** 현재 `광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출하면 아래와 같은 에러가 납니다.
```json
{
"code": "premium_required",
"statusCode": 403,
"upstreamError": "PREMIUM_REQUIRED"
}
```
같은 날짜에 광화문 좌표로 `searchNearbyByCoordinates()` 를 호출해도 동일한 `premium_required` 에러 계약이 확인됩니다.
이 remap 은 `403 {"error":"PREMIUM_REQUIRED"}` 인 nearby 응답에만 적용되며, 다른 upstream 실패는 기존 generic request error 형태를 유지합니다.
## 과거 live smoke 예시
아래 값은 **2026-03-27** 에는 실제 호출로 확인됐던 결과 일부입니다. 현재는 upstream 정책 변경으로 재현되지 않습니다.
```json
{
@ -123,6 +152,6 @@ main().catch((error) => {
## 주의할 점
- Blue Ribbon 사이트는 browser-like 요청 헤더가 없으면 403 이 나올 수 있습니다.
- Blue Ribbon 사이트는 현재 browser-like 요청 헤더가 있어도 `/restaurants/map` 에 대해 `403 {"error":"PREMIUM_REQUIRED"}` 를 반환할 수 있습니다.
- 검색 페이지의 zone 목록이 바뀌면 매칭 결과도 바뀔 수 있습니다.
- 좌표 없이 너무 넓은 지역명만 받으면 상권 후보가 많아질 수 있습니다.

View file

@ -0,0 +1,113 @@
# 한강 수위 정보 가이드
## 이 기능으로 할 수 있는 일
- 한강홍수통제소(HRFCO) 관측소명/관측소코드 기준 현재 수위 확인
- 현재 유량(`FW`) 같이 확인
- 관심/주의/경보/심각 기준수위 같이 확인
- 별도 사용자 `ServiceKey` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
## 필요한 환경변수
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자는 별도 HRFCO `ServiceKey` 를 준비하지 않는다. 대신 `KSKILL_PROXY_BASE_URL``/v1/han-river/water-level` route 가 실제로 배포된 proxy를 가리켜야 한다. upstream key는 proxy 서버에서만 `HRFCO_OPEN_API_KEY` 로 관리한다.
### Proxy resolution order
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용합니다.
2. **없으면** 사용자/운영자에게 self-host 또는 배포 확인이 끝난 proxy URL 을 먼저 확보합니다.
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key를 서버 쪽에만 설정합니다.
## 입력값
- 기본: 관측소명/교량명 (`stationName`)
- 대체: 관측소코드 (`stationCode`)
예: `한강대교`, `잠수교`, `1018683`
## 기본 흐름
1. client/skill 은 `KSKILL_PROXY_BASE_URL` 아래 `/v1/han-river/water-level` endpoint 를 호출한다.
2. proxy 는 HRFCO `waterlevel/info.json` 을 읽어 관측소명 → `WLOBSCD` 를 해석한다.
3. 해석된 `WLOBSCD``waterlevel/list/10M/{WLOBSCD}.json` 최신 10분 자료를 조회한다.
4. 관측시각, 수위(`WL`), 유량(`FW`), 기준수위 메타데이터를 요약해서 반환한다.
5. 관측소명이 여러 개에 걸리면 `ambiguous_station` + `candidate_stations` 를 반환한다.
## 예시
proxy URL 이 준비된 뒤 조회:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
관측소코드 직접 조회:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationCode=1018683'
```
애매한 관측소명 예시:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationName=한강'
```
예상 응답 예시:
```json
{
"station_code": "1018683",
"station_name": "한강대교",
"agency_name": "한강홍수통제소",
"address": "서울특별시 용산구 한강대교",
"observed_at": "2026-04-05T19:00:00+09:00",
"water_level": {
"value_m": 0.66,
"unit": "m"
},
"flow_rate": {
"value_cms": 208.58,
"unit": "m^3/s"
},
"thresholds": {
"interest_level_m": 5.5,
"warning_level_m": 8,
"alarm_level_m": 10,
"serious_level_m": 11,
"plan_flood_level_m": 13
},
"special_report_station": true
}
```
## fallback / 대체 흐름
- public hosted route 배포 확인이 끝나기 전에는 self-host proxy 또는 이미 route가 올라와 있는 공유 proxy URL 을 `KSKILL_PROXY_BASE_URL` 로 넣는다.
- 배포 확인이 끝나면 hosted 기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/han-river/water-level` 이 된다.
- self-host 운영자는 서버 쪽에만 `HRFCO_OPEN_API_KEY` 를 넣는다.
- 사용자/client 쪽 secrets 파일에는 HRFCO key 를 넣지 않는다.
## 주의할 점
- HRFCO 레퍼런스는 이 데이터를 원시자료로 설명하므로 조회 시각을 함께 적는다.
- 기본 endpoint 는 현재값 중심이라 기간별 시계열은 직접 노출하지 않는다.
- 관측소명이 너무 넓으면 `candidate_stations` 로 좁힌 뒤 다시 조회한다.
- 최신 자료는 보통 10분 단위지만 관측소별 수집 지연이 있을 수 있다.
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시한다.
## 참고 표면
- 공식 레퍼런스: `https://www.hrfco.go.kr/web/openapiPage/reference.do`
- 인증키 안내: `https://www.hrfco.go.kr/web/openapiPage/certifyKey.do`
- 정책: `https://www.hrfco.go.kr/web/openapi/policy.do`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -12,11 +12,12 @@
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래 둘입니다.
현재 기본 엔드포인트는 아래와 같습니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
@ -29,6 +30,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `AIR_KOREA_OPEN_API_KEY=...`
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## PM2 + cloudflared
@ -63,6 +65,15 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한강 수위 정보 endpoint:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
이 endpoint 는 내부적으로 HRFCO `waterlevel/info.json` 으로 관측소를 찾고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 10분 수위/유량을 가져옵니다.
AirKorea passthrough endpoint:
```bash
@ -80,3 +91,4 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
- upstream key는 프록시 서버에서만 관리합니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 한강 수위 route도 local/self-host 또는 배포 확인이 끝난 proxy URL 로 검증합니다.

View file

@ -0,0 +1,146 @@
# 올리브영 검색 가이드
## 이 기능으로 할 수 있는 일
원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 사용해 올리브영 **매장 검색, 상품 검색, 재고 확인**을 한다.
- `/api/oliveyoung/stores` 로 매장 검색
- `/api/oliveyoung/products` 로 상품 검색
- `/api/oliveyoung/inventory` 로 재고 확인
- `npx --yes daiso health` 로 endpoint health 확인
## 가장 중요한 규칙
이 기능은 upstream 원본을 그대로 쓴다.
`k-skill` 안에 별도 올리브영 수집기를 추가하지 않고, **MCP 서버를 Claude Code에 직접 설치하지 않고 CLI로 먼저 검증하는 경로**를 기본으로 둔다.
즉, 원본 서버 코드를 이 저장소에 **vendoring 하지 않고** skill/docs 가이드만 유지한다.
즉 기본 경로는 아래 둘 중 하나다.
1. `npx --yes daiso ...`
2. `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build`
## 먼저 필요한 것
- 인터넷 연결
- `node` 20 권장
- `npx` 또는 `npm`
- 필요하면 `git`
2026-04-05 기준 upstream `package.json``engines.node``>=20 <21` 이다.
로컬 Node 22 환경에서도 smoke test는 통과했지만 `EBADENGINE` 경고가 있었으므로, 운영 가이드는 Node 20 LTS 중심으로 적는다.
## 가장 빠른 시작: npx CLI
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g daiso
export NODE_PATH="$(npm root -g)"
daiso health
```
## 원본 저장소 clone fallback
public endpoint 재시도, 버전 고정, 원본 확인이 필요하면 아래처럼 clone 후 build 결과물 `dist/bin.js``node` 로 직접 실행한다.
clone checkout 안에서는 `npx daiso ...``Permission denied` 로 실패할 수 있으므로 이 경로를 기본으로 적는다.
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
## 입력값 권장 순서
1. **지역/매장 키워드**
- 예: `명동`, `강남역`, `성수`
2. **상품 키워드**
- 예: `선크림`, `립밤`, `마스크팩`
재고 질문인데 지역/매장 키워드가 없으면 먼저 지역을 보강한다.
상품 종류를 묻는 경우에는 먼저 `/api/oliveyoung/products` 로 후보를 보여주고, 재고 확인이 필요할 때 `/api/oliveyoung/inventory` 로 내려간다.
## 기본 흐름
### 1. health 확인
```bash
npx --yes daiso health
```
### 2. 매장 검색
```bash
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
```
### 3. 상품 검색
```bash
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
```
응답에서는 `goodsNumber`, `goodsName`, `priceToPay`, `imageUrl`, `inStock` 를 먼저 본다.
### 4. 재고 확인
```bash
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
재고 응답에서는 `inventory.products[].storeInventory.stores[]` 안의 아래 필드를 우선 해석한다.
- `stockLabel` (`재고 9개 이상`, `품절`, `미판매` 등)
- `remainQuantity`
- `stockStatus`
- `storeName`
## 응답 정리 원칙
- 매장 후보가 많으면 상위 2~3개만 먼저 제시한다.
- 상품 후보가 많으면 가격, 이미지 URL, `inStock` 여부를 붙여 상위 3~5개만 요약한다.
- 재고는 `재고 있음 / 품절 / 미판매` 를 매장별로 분리해서 쓴다.
- `imageUrl` 이 있으면 query string(`?l=ko`)을 지우지 않는다.
- 공개 endpoint 특성상 방문 직전 재확인을 권한다.
## 라이브 확인 메모
2026-04-05 기준 아래 흐름을 실제로 실행해 응답을 확인했다.
- `npx --yes daiso health``status: ok`, endpoint `https://mcp.aka.page/mcp`
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 3 --json`
- `명동타임워크점`, `명동2가점`, `올리브영 명동 타운` 등 매장 후보 확인
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 3 --json`
- `totalCount: 435`, `imageUrl`, `priceToPay`, `inStock` 포함 상품 후보 확인
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 3 --json`
- `stockLabel: 재고 9개 이상 / 품절 / 미판매`, `remainQuantity`, `storeName` 확인
같은 날짜에 public `npx --yes daiso get /api/oliveyoung/stores ...` 는 한 차례 `Zyte API 호출 실패: 503 Service Unavailable` 를 반환했다. 그래서 문서 기본 경로는 여전히 CLI first 이지만, **재시도 또는 clone fallback** 을 함께 안내한다.
## 제한사항
- public endpoint는 upstream 수집 인프라 상태에 따라 간헐적 5xx/503이 날 수 있다.
- 넓은 지역 키워드는 먼 지점까지 섞일 수 있다.
- 재고 수량은 실시간 100% 보장값이 아니다.
- 주문/결제 자동화는 다루지 않는다.
## 참고 링크
- 원본 repo: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- Olive Young stores API: `https://mcp.aka.page/api/oliveyoung/stores`
- Olive Young products API: `https://mcp.aka.page/api/oliveyoung/products`
- Olive Young inventory API: `https://mcp.aka.page/api/oliveyoung/inventory`

View file

@ -55,7 +55,9 @@ npx --yes skills add <owner/repo> \
--skill joseon-sillok-search \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
--skill daiso-product-search \
--skill olive-young-search \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
@ -112,6 +114,37 @@ codex mcp add real-estate \
shared HTTP가 필요하면 upstream Docker guide 대로 서버를 한 번 띄워 Docker의 `restart: unless-stopped` 재시작 정책에 맡긴 뒤 Cloudflare Tunnel 도메인(`https://real-estate-mcp.example.com/mcp`)을 붙인다. macOS에서는 `launchd` 에 서버/터널을 함께 넣지 말고 long-running 프로세스인 `cloudflared tunnel run real-estate-mcp` 만 자동 실행한다. 자세한 흐름은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
### `olive-young-search` upstream CLI quickstart
`olive-young-search` 는 upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) / npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용한다.
- 기본 경로는 **MCP 서버 직접 설치가 아니라 CLI first** 다.
- 가장 빠른 smoke test 는 `npx --yes daiso health`
- 재고/매장/상품 조회는 `npx --yes daiso get /api/oliveyoung/...`
- public endpoint는 upstream 수집 상태에 따라 간헐적인 `5xx/503` 이 날 수 있으니 먼저 한두 번 재시도한다.
- 반복 사용이면 `npm install -g daiso`
- 재시도 후에도 불안정하거나 버전 고정/원본 확인이 필요하면 `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build` clone fallback으로 전환한 뒤 `node dist/bin.js ...` 로 실행한다. clone checkout 안에서 `npx daiso ...``Permission denied` 로 실패할 수 있다.
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
clone fallback 예시:
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
로컬 저장소에서 바로 전체 설치 테스트:
```bash
@ -146,7 +179,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp daiso
export NODE_PATH="$(npm root -g)"
```

View file

@ -12,6 +12,7 @@
- 로또 당첨번호
- 서울 지하철 도착 정보
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
- 한국 부동산 실거래가 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
@ -21,6 +22,7 @@
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 중고차 가격 조회 스킬 출시
- 한국어 맞춤법 검사 스킬 출시

View file

@ -68,6 +68,6 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `AIR_KOREA_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `OPINET_API_KEY` 는 한국석유공사 Opinet Open API 인증키로, 근처 가장 싼 주유소 찾기 skill/package 의 공식 nearby 가격 조회에 사용한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `OPINET_API_KEY` 는 한국석유공사 Opinet Open API 인증키로, 근처 가장 싼 주유소 찾기 skill/package 의 공식 nearby 가격 조회에 사용한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY` 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한강 수위 route가 실제 배포된 proxy URL 로만 넣는다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 부동산 실거래가 조회의 로컬/self-host 경로용 `DATA_GO_KR_API_KEY`, 근처 가장 싼 주유소 찾기용 `OPINET_API_KEY`, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 부동산 실거래가 조회의 로컬/self-host 경로용 `DATA_GO_KR_API_KEY`, 근처 가장 싼 주유소 찾기용 `OPINET_API_KEY`, self-host 프록시 운영용 서울 지하철/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
## Credential resolution order
@ -35,7 +35,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지만 쓴다면 이 값을 비워 두고 skill 기본 hosted path를 그대로 써도 된다.
서울 지하철 도착정보와 한강 수위 정보도 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지만 쓴다면 이 값을 비워 두고 skill 기본 hosted path를 그대로 써도 된다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
@ -71,6 +71,7 @@ bash scripts/check-setup.sh
| 한국 부동산 실거래가 조회 (공유 URL) | 사용자 시크릿 불필요, 대신 운영자가 self-host + Cloudflare Tunnel + launchd/systemd 를 준비 |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요, 대신 self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
## 다음에 볼 문서
@ -78,6 +79,7 @@ bash scripts/check-setup.sh
- [KTX 예매 가이드](features/ktx-booking.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)

View file

@ -35,6 +35,12 @@
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- olive-young / multi-retail upstream repo (`hmmhmmhm/daiso-mcp`): https://github.com/hmmhmmhm/daiso-mcp
- olive-young CLI package (`daiso`): https://www.npmjs.com/package/daiso
- olive-young stores API: https://mcp.aka.page/api/oliveyoung/stores
- olive-young products API: https://mcp.aka.page/api/oliveyoung/products
- olive-young inventory API: https://mcp.aka.page/api/oliveyoung/inventory
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
- 블루리본 메인: https://www.bluer.co.kr/
@ -52,6 +58,10 @@
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
- 한강홍수통제소 Open API 레퍼런스: https://www.hrfco.go.kr/web/openapiPage/reference.do
- 한강홍수통제소 Open API 인증키 안내: https://www.hrfco.go.kr/web/openapiPage/certifyKey.do
- 한강홍수통제소 Open API 정책: https://www.hrfco.go.kr/web/openapi/policy.do
- 한강홍수통제소 API base: https://api.hrfco.go.kr
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail

View file

@ -0,0 +1,98 @@
---
name: han-river-water-level
description: 한강홍수통제소 기반 현재 수위/유량을 관측소명 또는 관측소코드로 조회한다. self-host 또는 배포 확인이 끝난 k-skill-proxy의 han-river water-level endpoint를 기본 경로로 쓴다.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Han River Water Level
## What this skill does
한강홍수통제소(HRFCO) 관측소의 현재 수위와 유량을 `k-skill-proxy` 경유로 요약한다. public hosted route 배포 확인이 끝나기 전에는 self-host 또는 이미 `/v1/han-river/water-level` 이 올라와 있는 `KSKILL_PROXY_BASE_URL` 을 사용한다.
## When to use
- "한강대교 지금 수위 어때?"
- "잠수교 유량 알려줘"
- "1018683 관측소 현재 값 보여줘"
## Inputs
- 기본 입력: 관측소명/교량명(`stationName`)
- 대체 입력: 관측소코드(`stationCode`)
## Prerequisites
- optional: `jq`
- self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
## Required environment variables
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자는 별도 HRFCO `ServiceKey` 를 준비할 필요가 없다. 대신 `/v1/han-river/water-level` route 가 실제로 올라와 있는 proxy URL 을 `KSKILL_PROXY_BASE_URL` 로 받아야 한다. upstream key는 proxy 서버에서만 주입한다.
### Proxy resolution order
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용한다.
2. **없으면** 사용자/운영자에게 self-host 또는 배포 확인이 끝난 proxy URL 을 먼저 확보한다.
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key를 서버 쪽에만 설정한다.
## Example requests
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
관측소코드로 바로 조회해도 된다.
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationCode=1018683'
```
## Keep the answer compact
응답에는 아래만 먼저 정리한다.
- 관측소명 / 관측소코드
- 관측 시각
- 현재 수위(m)
- 현재 유량(m^3/s)
- 기준 수위(관심/주의/경보/심각) 중 값이 있는 항목
## Ambiguous station names
입력이 너무 넓으면 proxy 는 `ambiguous_station` 과 함께 `candidate_stations` 를 돌려준다.
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/han-river/water-level' \
--data-urlencode 'stationName=한강'
```
이때는 후보 중 하나를 골라 다시 `stationName` 또는 `stationCode` 로 조회한다.
## Detailed API paths
구현 세부는 아래 문서만 참고한다.
- `docs/features/han-river-water-level.md`
- `docs/features/k-skill-proxy.md`
## Failure modes
- 관측소명이 너무 넓어서 여러 관측소가 동시에 잡히는 경우
- 잘못된 관측소코드/관측소명으로 station lookup 이 실패하는 경우
- 프록시 서버에 `HRFCO_OPEN_API_KEY` 가 비어 있는 경우
- 실시간 자료 갱신 지연으로 최신 10분 자료가 비어 있는 경우
## Notes
- 배포 확인이 끝나면 hosted 기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/han-river/water-level` 이다.
- upstream 은 `waterlevel/info.json` 으로 관측소 메타데이터를 찾고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신값을 조회한다.
- 결과는 원시자료 기반이므로 조회 시각을 함께 적는다.

164
olive-young-search/SKILL.md Normal file
View file

@ -0,0 +1,164 @@
---
name: olive-young-search
description: upstream daiso CLI를 사용해 올리브영 매장 검색, 상품 검색, 재고 확인을 조회한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Olive Young Search
## What this skill does
upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용해 **올리브영 매장 검색, 상품 검색, 재고 확인** 흐름을 안내한다.
이 저장소는 원본 MCP 서버 코드를 vendoring 하지 않는다. 대신 **MCP 서버를 Claude Code에 직접 설치하지 않고 CLI 형태로 먼저 확인하는 경로**를 기본값으로 둔다.
핵심 조회 경로:
- 매장 검색: `/api/oliveyoung/stores`
- 상품 검색: `/api/oliveyoung/products`
- 재고 확인: `/api/oliveyoung/inventory`
- health check: `npx --yes daiso health`
## When to use
- "명동 근처 올리브영 매장 찾아줘"
- "올리브영 선크림 어떤 거 있나 보여줘"
- "명동 근처 올리브영에서 선크림 재고 확인해줘"
- "올리브영 검색용 CLI 붙여줘"
## When not to use
- 로그인, 주문, 장바구니, 결제 자동화
- 올리브영 계정/세션이 필요한 private 기능
- upstream 서버 코드를 이 저장소 안에 복사해서 유지하려는 경우
## Prerequisites
- 인터넷 연결
- `node` 20 권장 (`hmmhmmhm/daiso-mcp` 2026-04-05 기준 `engines.node``>=20 <21`)
- `npx` 또는 `npm`
- 필요하면 `git`
Node 22에서도 로컬 smoke test는 성공했지만 `EBADENGINE` 경고가 보여서, **안정 경로는 Node 20 LTS** 로 본다.
## Preferred setup: CLI first, not direct MCP install
가장 빠른 경로는 MCP 연결부터 하지 않고 upstream CLI로 공개 endpoint를 확인하는 것이다.
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g daiso
export NODE_PATH="$(npm root -g)"
daiso health
```
## Fallback: clone the original repository and run the same CLI locally
public endpoint 재시도나 버전 고정이 필요하면 원본 저장소를 clone 해서 build 결과물 `dist/bin.js``node` 로 직접 실행한다.
clone checkout 안에서는 `npx daiso ...``Permission denied` 로 실패할 수 있으므로, local fallback은 아래 경로를 기본으로 둔다.
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
즉, 이 스킬의 기본 원칙은 **원본 `hmmhmmhm/daiso-mcp`를 설치/실행해서 쓰고, `k-skill`에는 가이드만 추가하는 것**이다.
## Required inputs
### 1. Store or area keyword first when place context is missing
- 권장 질문: `어느 지역/매장을 기준으로 볼까요? 예: 명동, 강남역, 성수`
- 재고 질문인데 지역이 없으면 먼저 지역/매장 키워드를 받는다.
### 2. Product keyword first when inventory is requested
- 권장 질문: `찾을 상품 키워드도 알려주세요. 예: 선크림, 립밤, 마스크팩`
- 상품 종류를 묻는 경우에도 키워드를 너무 넓게 받지 않는다.
## Workflow
### 1. Check server health
```bash
npx --yes daiso health
```
### 2. Resolve nearby stores
```bash
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
```
매장 후보가 여러 개면 상위 2~3개만 요약하고 다시 확인받는다.
### 3. Resolve product candidates
```bash
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
```
상품 후보가 많으면 `goodsNumber`, 가격, 이미지 URL, `inStock` 여부를 함께 짧게 정리한다.
### 4. Check inventory for the chosen area/store keyword
```bash
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
응답의 `inventory.products[].storeInventory.stores[]` 안에서 다음 값을 우선 본다.
- `stockLabel`
- `remainQuantity`
- `stockStatus`
- `storeName`
### 5. Respond conservatively
최종 응답은 아래 순서로 짧게 정리한다.
- 기준 지역/매장 키워드
- 상위 매장 후보
- 상품 후보 또는 선택 상품
- 재고 있는 매장 / 품절 / 미판매 구분
- 필요하면 `imageUrl` 참고 링크
- 공개 endpoint 특성상 재고는 실시간 100% 보장값이 아니므로 방문 직전 재확인을 권장
## Done when
- `hmmhmmhm/daiso-mcp` 원본 repo와 `daiso` CLI 사용 경로를 명시했다.
- MCP 서버를 직접 설치하는 대신 CLI first 흐름을 제시했다.
- 매장 검색 → 상품 검색 → 재고 확인 순서를 따랐다.
- `/api/oliveyoung/stores`, `/api/oliveyoung/products`, `/api/oliveyoung/inventory` 중 필요한 호출을 실제로 안내했다.
- 재고 결과를 매장별 `stockLabel` 중심으로 요약했다.
## Failure modes
- public endpoint는 upstream 내부 수집 경로(Zyte 의존) 사정으로 간헐적인 5xx/503을 줄 수 있다.
- 지역 키워드가 너무 넓으면 멀리 떨어진 동명이점 매장이 섞일 수 있다.
- 인기 상품은 검색 결과가 많아 상위 몇 개만 먼저 확인받는 편이 안전하다.
- 재고 수량은 시점 차이로 실제 방문 시 달라질 수 있다.
## Notes
- 원본 프로젝트: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- 이 저장소는 upstream 코드를 vendoring 하지 않고 skill/docs만 유지한다.

View file

@ -1,6 +1,9 @@
# blue-ribbon-nearby
Blue Ribbon Survey 공식 표면을 사용해 근처 블루리본 맛집을 찾는 Node.js 패키지입니다.
Blue Ribbon Survey 공식 표면을 사용해 위치 문자열을 공식 zone 으로 매칭하고, 가능할 때 근처 블루리본 맛집을 조회하는 Node.js 패키지입니다.
> [!WARNING]
> **2026-04-05 기준** Blue Ribbon의 `/restaurants/map` endpoint 가 공개 요청에 `403 {"error":"PREMIUM_REQUIRED"}` 를 반환합니다. 이 패키지는 zone 매칭까지는 계속 지원하지만, live nearby 결과는 현재 `premium_required` 도메인 에러로 명시적으로 실패합니다.
## 설치
@ -31,19 +34,31 @@ npm install
패키지는 먼저 `search/zone` 에서 가장 가까운 공식 zone 을 찾고, 그다음 `/restaurants/map` nearby 검색으로 블루리본 인증 맛집만 추립니다. 이때 `zone1`, `zone2`, `zone2Lat`, `zone2Lng`, `isAround=true`, `ribbon=true` 를 사용해 주변 결과만 다시 조회합니다.
다만 현재 `/restaurants/map` 는 공개 호출에서 premium gate 가 걸려 있으므로, live nearby 조회는 아래처럼 `premium_required` 에러를 던질 수 있습니다.
## 사용 예시
```js
const { searchNearbyByLocationQuery } = require("blue-ribbon-nearby");
async function main() {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
try {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
});
console.log(result.anchor);
console.log(result.items);
console.log(result.anchor);
console.log(result.items);
} catch (error) {
if (error.code === "premium_required") {
console.error("Blue Ribbon nearby live results are currently premium-gated.");
console.error(error.message);
return;
}
throw error;
}
}
main().catch((error) => {
@ -52,9 +67,23 @@ main().catch((error) => {
});
```
## Live smoke snapshot
## Current live status
2026-03-27 에 `광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출했을 때 상위 결과 예시는 아래와 같았습니다.
**2026-04-05** 에 `광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출하면 현재는 아래와 같은 도메인 에러가 반환됩니다.
```json
{
"code": "premium_required",
"statusCode": 403,
"upstreamError": "PREMIUM_REQUIRED"
}
```
같은 날짜에 `searchNearbyByCoordinates()` 로 광화문 좌표를 직접 넣어도 동일한 `premium_required` 계약이 반환됩니다.
## Historical snapshot
2026-03-27 에는 아래처럼 live nearby 결과가 내려왔습니다. 현재는 upstream 정책 변경으로 이 스냅샷을 재현할 수 없습니다.
```json
{
@ -78,3 +107,11 @@ main().catch((error) => {
- `buildNearbySearchParams(options)`
- `searchNearbyByLocationQuery(locationQuery, options?)`
- `searchNearbyByCoordinates(options)`
`searchNearbyByLocationQuery()` / `searchNearbyByCoordinates()``/restaurants/map``403 {"error":"PREMIUM_REQUIRED"}` 를 반환하면 아래 속성을 가진 에러를 던집니다.
- `error.code === "premium_required"`
- `error.statusCode === 403`
- `error.upstreamError === "PREMIUM_REQUIRED"`
다른 upstream 오류는 계속 일반 `Blue Ribbon request failed ...` 에러로 남고, 가능하면 `statusCode``upstreamError` 만 함께 노출됩니다.

View file

@ -22,6 +22,52 @@ const RESTAURANTS_MAP_URL = `${BASE_URL}/restaurants/map`;
const DEFAULT_DISTANCE_METERS = 1000;
const DEFAULT_RIBBON_TYPES = "RIBBON_THREE,RIBBON_TWO,RIBBON_ONE";
async function readErrorPayload(response) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("json")) {
try {
return await response.json();
} catch {
return null;
}
}
try {
return await response.text();
} catch {
return null;
}
}
function createRequestError(response, url, payload) {
if (
response.status === 403 &&
url.startsWith(RESTAURANTS_MAP_URL) &&
payload &&
payload.error === "PREMIUM_REQUIRED"
) {
const error = new Error(
"Blue Ribbon nearby results are currently premium-gated by bluer.co.kr. Zone matching still works, but live nearby restaurant data now requires official premium access.",
);
error.code = "premium_required";
error.statusCode = response.status;
error.upstreamError = payload.error;
error.upstreamUrl = url;
return error;
}
const error = new Error(`Blue Ribbon request failed with ${response.status} for ${url}`);
error.statusCode = response.status;
error.upstreamUrl = url;
if (payload && typeof payload === "object" && typeof payload.error === "string") {
error.upstreamError = payload.error;
}
return error;
}
async function request(url, options = {}, responseType = "text") {
const fetchImpl = options.fetchImpl || global.fetch;
@ -39,7 +85,7 @@ async function request(url, options = {}, responseType = "text") {
});
if (!response.ok) {
throw new Error(`Blue Ribbon request failed with ${response.status} for ${url}`);
throw createRequestError(response, url, await readErrorPayload(response));
}
return responseType === "json" ? response.json() : response.text();

View file

@ -8,6 +8,7 @@ const {
findZoneMatches,
normalizeNearbyItem,
parseZoneCatalogHtml,
searchNearbyByCoordinates,
searchNearbyByLocationQuery
} = require("../src/index");
@ -20,6 +21,10 @@ const landmarkZoneHtml = `
<a href="/search?query=&zone1=${encodeURIComponent("서울 강남")}&zone2=${encodeURIComponent("삼성동/대치동")}&zone2Lat=37.511310&zone2Lng=127.059330">삼성동/대치동</a>
<a href="/search?query=&zone1=${encodeURIComponent("서울 강북")}&zone2=${encodeURIComponent("남대문/서울역/후암동")}&zone2Lat=37.555000&zone2Lng=126.972000">남대문/서울역/후암동</a>
`;
const gwanghwamunCoordinates = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseZoneCatalogHtml extracts official zone anchors and coordinates", () => {
const zones = parseZoneCatalogHtml(zoneHtml);
@ -68,10 +73,7 @@ test("buildNearbySearchParams encodes the official nearby ribbon query for a mat
});
test("normalizeNearbyItem exposes the public restaurant summary with computed distance", () => {
const item = normalizeNearbyItem(mapPayload.items[0], {
latitude: 37.57371315593711,
longitude: 126.97833785777944
});
const item = normalizeNearbyItem(mapPayload.items[0], gwanghwamunCoordinates);
assert.equal(item.id, 29209);
assert.equal(item.name, "유유안");
@ -87,14 +89,14 @@ test("searchNearbyByLocationQuery resolves documented landmark aliases like 코
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(true, landmarkZoneHtml, "text/html");
return makeResponse(200, landmarkZoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(true, mapPayload, "application/json");
return makeResponse(200, mapPayload, "application/json");
}
return makeResponse(false, "not found", "text/plain");
return makeResponse(404, "not found", "text/plain");
};
try {
@ -117,14 +119,14 @@ test("searchNearbyByLocationQuery resolves a zone match, fetches the official ne
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(true, zoneHtml, "text/html");
return makeResponse(200, zoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(true, mapPayload, "application/json");
return makeResponse(200, mapPayload, "application/json");
}
return makeResponse(false, "not found", "text/plain");
return makeResponse(404, "not found", "text/plain");
};
try {
@ -149,9 +151,110 @@ test("searchNearbyByLocationQuery resolves a zone match, fetches the official ne
}
});
function makeResponse(ok, body, contentType) {
test("searchNearbyByLocationQuery surfaces PREMIUM_REQUIRED with a stable domain error", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(200, zoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "PREMIUM_REQUIRED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
}),
assertPremiumRequiredError
);
} finally {
global.fetch = originalFetch;
}
});
test("searchNearbyByCoordinates surfaces PREMIUM_REQUIRED with the same domain error", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "PREMIUM_REQUIRED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByCoordinates({
...gwanghwamunCoordinates,
distanceMeters: 1000,
limit: 5
}),
assertPremiumRequiredError
);
} finally {
global.fetch = originalFetch;
}
});
test("searchNearbyByCoordinates keeps non-premium upstream failures generic", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "ACCESS_DENIED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByCoordinates({
...gwanghwamunCoordinates,
distanceMeters: 1000,
limit: 5
}),
assertGenericRequestError
);
} finally {
global.fetch = originalFetch;
}
});
function assertPremiumRequiredError(error) {
return (
error.statusCode === 403 &&
error.code === "premium_required" &&
error.upstreamError === "PREMIUM_REQUIRED" &&
error.upstreamUrl.includes("/restaurants/map") &&
/premium/i.test(error.message)
);
}
function assertGenericRequestError(error) {
return (
error.statusCode === 403 &&
error.code === undefined &&
error.upstreamError === "ACCESS_DENIED" &&
error.upstreamUrl.includes("/restaurants/map") &&
/request failed/i.test(error.message)
);
}
function makeResponse(status, body, contentType) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: ok ? 200 : 500,
status,
headers: {
"content-type": contentType
}

View file

@ -1,17 +1,19 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회와 서울 지하철 실시간 도착정보를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
@ -35,6 +37,15 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한강 수위 정보 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다.
## PM2 실행
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 환경변수로 다시 올라옵니다.

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,241 @@
const HRFCO_API_BASE_URL = "https://api.hrfco.go.kr";
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function parseNumber(value) {
const trimmed = trimOrNull(value);
if (trimmed === null) {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeToken(value) {
return trimOrNull(value)?.toLowerCase().replace(/\s+/g, "") || null;
}
function extractStationNameVariants(station) {
const baseName = trimOrNull(station?.obsnm);
if (!baseName) {
return [];
}
const variants = new Set([baseName]);
for (const match of baseName.matchAll(/\(([^()]+)\)/g)) {
const inner = trimOrNull(match[1]);
if (inner) {
variants.add(inner);
}
}
return [...variants];
}
function buildError({ message, statusCode, code, candidateStations = null }) {
const error = new Error(message);
error.statusCode = statusCode;
error.code = code;
if (candidateStations) {
error.candidateStations = candidateStations;
}
return error;
}
function formatObservedAt(ymdhm) {
const raw = trimOrNull(ymdhm);
if (!raw || !/^\d{12}$/.test(raw)) {
return null;
}
const year = raw.slice(0, 4);
const month = raw.slice(4, 6);
const day = raw.slice(6, 8);
const hour = raw.slice(8, 10);
const minute = raw.slice(10, 12);
return `${year}-${month}-${day}T${hour}:${minute}:00+09:00`;
}
function dedupeStations(stations) {
return [...new Map(stations.map((station) => [station.wlobscd, station])).values()];
}
function pickWaterLevelStation(stationItems, { stationName = null, stationCode = null } = {}) {
const normalizedCode = trimOrNull(stationCode);
const normalizedName = normalizeToken(stationName);
const stations = Array.isArray(stationItems) ? stationItems : [];
if (normalizedCode) {
const byCode = stations.find((station) => trimOrNull(station.wlobscd) === normalizedCode);
if (!byCode) {
throw buildError({
message: "No HRFCO water-level station matched that stationCode.",
statusCode: 404,
code: "station_not_found"
});
}
return byCode;
}
if (!normalizedName) {
throw buildError({
message: "Provide stationName or stationCode.",
statusCode: 400,
code: "bad_request"
});
}
const exactMatches = dedupeStations(
stations.filter((station) =>
extractStationNameVariants(station)
.map((value) => normalizeToken(value))
.includes(normalizedName)
)
);
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
throw buildError({
message: "Multiple HRFCO water-level stations matched that stationName.",
statusCode: 400,
code: "ambiguous_station",
candidateStations: exactMatches.map((station) => station.obsnm).slice(0, 10)
});
}
const partialMatches = dedupeStations(
stations.filter((station) => {
const fields = [...extractStationNameVariants(station), station.addr, station.etcaddr]
.map((value) => normalizeToken(value))
.filter(Boolean);
return fields.some((field) => field.includes(normalizedName));
})
);
if (partialMatches.length === 0) {
throw buildError({
message: "No HRFCO water-level station matched that stationName.",
statusCode: 404,
code: "station_not_found"
});
}
if (partialMatches.length > 1) {
throw buildError({
message: "Multiple HRFCO water-level stations matched that stationName.",
statusCode: 400,
code: "ambiguous_station",
candidateStations: partialMatches.map((station) => station.obsnm).slice(0, 10)
});
}
return partialMatches[0];
}
function buildWaterLevelReport({ stationItems, measurementItems, stationName = null, stationCode = null }) {
const station = pickWaterLevelStation(stationItems, { stationName, stationCode });
const measurement = (Array.isArray(measurementItems) ? measurementItems : []).find(
(item) => trimOrNull(item.wlobscd) === trimOrNull(station.wlobscd)
);
if (!measurement) {
throw buildError({
message: "No current HRFCO water-level measurement was available for that station.",
statusCode: 404,
code: "measurement_not_found"
});
}
return {
station_code: station.wlobscd,
station_name: trimOrNull(station.obsnm),
agency_name: trimOrNull(station.agcnm),
address: [trimOrNull(station.addr), trimOrNull(station.etcaddr)].filter(Boolean).join(" ") || null,
observed_at: formatObservedAt(measurement.ymdhm),
observed_at_raw: trimOrNull(measurement.ymdhm),
water_level: {
value_m: parseNumber(measurement.wl),
unit: "m"
},
flow_rate: {
value_cms: parseNumber(measurement.fw),
unit: "m^3/s"
},
thresholds: {
interest_level_m: parseNumber(station.attwl),
warning_level_m: parseNumber(station.wrnwl),
alarm_level_m: parseNumber(station.almwl),
serious_level_m: parseNumber(station.srswl),
plan_flood_level_m: parseNumber(station.pfh)
},
special_report_station: trimOrNull(station.fstnyn) === "Y",
source: {
provider: "hrfco",
hydro_type: "waterlevel",
time_type: "10M"
}
};
}
async function fetchJson(url, { fetchImpl = global.fetch } = {}) {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
throw buildError({
message: `HRFCO upstream request failed with status ${response.status}.`,
statusCode: 502,
code: "upstream_error"
});
}
return response.json();
}
async function fetchWaterLevelStations({ serviceKey, fetchImpl = global.fetch }) {
const url = new URL(`${HRFCO_API_BASE_URL}/${serviceKey}/waterlevel/info.json`);
const payload = await fetchJson(url, { fetchImpl });
return Array.isArray(payload.content) ? payload.content : [];
}
async function fetchLatestWaterLevel({ serviceKey, stationCode, fetchImpl = global.fetch }) {
const url = new URL(`${HRFCO_API_BASE_URL}/${serviceKey}/waterlevel/list/10M/${stationCode}.json`);
const payload = await fetchJson(url, { fetchImpl });
return Array.isArray(payload.content) ? payload.content : [];
}
async function fetchWaterLevelReport({ stationName = null, stationCode = null, serviceKey, fetchImpl = global.fetch }) {
const stations = await fetchWaterLevelStations({ serviceKey, fetchImpl });
const station = pickWaterLevelStation(stations, { stationName, stationCode });
const measurements = await fetchLatestWaterLevel({
serviceKey,
stationCode: station.wlobscd,
fetchImpl
});
return buildWaterLevelReport({
stationItems: stations,
measurementItems: measurements,
stationCode: station.wlobscd
});
}
module.exports = {
HRFCO_API_BASE_URL,
buildWaterLevelReport,
fetchLatestWaterLevel,
fetchWaterLevelReport,
fetchWaterLevelStations,
formatObservedAt,
pickWaterLevelStation
};

View file

@ -1,6 +1,7 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const { fetchWaterLevelReport } = require("./hrfco");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const ALLOWED_AIRKOREA_ROUTES = new Map([
@ -42,6 +43,7 @@ function buildConfig(env = process.env) {
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -142,6 +144,20 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeHanRiverWaterLevelQuery(query) {
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
const stationCode = trimOrNull(query.stationCode ?? query.station_code ?? query.wlobscd);
if (!stationName && !stationCode) {
throw new Error("Provide stationName or stationCode.");
}
return {
stationName,
stationCode
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
}
@ -228,6 +244,54 @@ async function proxySeoulSubwayRequest({
};
}
async function proxyHrfcoWaterLevelRequest({
stationName = null,
stationCode = null,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "HRFCO_OPEN_API_KEY is not configured on the proxy server."
})
};
}
try {
const report = await fetchWaterLevelReport({
stationName,
stationCode,
serviceKey: apiKey,
fetchImpl
});
return {
statusCode: 200,
contentType: "application/json; charset=utf-8",
body: JSON.stringify(report)
};
} catch (error) {
const payload = {
error: error.code || "proxy_error",
message: error.message
};
if (Array.isArray(error.candidateStations)) {
payload.candidate_stations = error.candidateStations;
}
return {
statusCode: error.statusCode && error.statusCode >= 400 ? error.statusCode : 502,
contentType: "application/json; charset=utf-8",
body: JSON.stringify(payload)
};
}
}
function buildServer({ env = process.env, provider = null } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
@ -259,7 +323,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey)
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey)
},
auth: {
tokenRequired: false
@ -401,6 +466,67 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/han-river/water-level", async (request, reply) => {
let normalized;
try {
normalized = normalizeHanRiverWaterLevelQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "han-river-water-level",
stationName: normalized.stationName?.toLowerCase() || null,
stationCode: normalized.stationCode || null
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyHrfcoWaterLevelRequest({
...normalized,
apiKey: config.hrfcoApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
@ -441,8 +567,10 @@ module.exports = {
buildConfig,
buildServer,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyHrfcoWaterLevelRequest,
proxySeoulSubwayRequest,
startServer
};

View file

@ -0,0 +1,121 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildWaterLevelReport,
fetchWaterLevelReport,
pickWaterLevelStation
} = require("../src/hrfco");
const stationItems = [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교",
attwl: "5.5",
wrnwl: "8.0",
almwl: "10.0",
srswl: "11.0",
pfh: "13.0",
fstnyn: "Y"
},
{
wlobscd: "1018680",
obsnm: "한강철교",
agcnm: "한강홍수통제소",
addr: "서울특별시 동작구",
etcaddr: "한강철교"
}
];
const measurementItems = [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
];
test("pickWaterLevelStation prefers exact station code matches", () => {
const station = pickWaterLevelStation(stationItems, {
stationCode: "1018683"
});
assert.equal(station.obsnm, "한강대교");
});
test("pickWaterLevelStation returns candidate stations for ambiguous names", () => {
assert.throws(
() => pickWaterLevelStation(stationItems, { stationName: "한강" }),
(error) =>
error.code === "ambiguous_station" &&
Array.isArray(error.candidateStations) &&
error.candidateStations.includes("한강대교") &&
error.candidateStations.includes("한강철교")
);
});
test("pickWaterLevelStation matches parenthetical station aliases before broader partial matches", () => {
const station = pickWaterLevelStation(
[
{ wlobscd: "1005697", obsnm: "원주시(남한강대교)" },
{ wlobscd: "1018683", obsnm: "서울시(한강대교)" }
],
{ stationName: "한강대교" }
);
assert.equal(station.wlobscd, "1018683");
});
test("buildWaterLevelReport combines station metadata and latest measurement", () => {
const report = buildWaterLevelReport({
stationItems,
measurementItems,
stationName: "한강대교"
});
assert.equal(report.station_name, "한강대교");
assert.equal(report.station_code, "1018683");
assert.deepEqual(report.water_level, { value_m: 0.66, unit: "m" });
assert.deepEqual(report.flow_rate, { value_cms: 208.58, unit: "m^3/s" });
assert.equal(report.thresholds.warning_level_m, 8);
assert.equal(report.special_report_station, true);
});
test("fetchWaterLevelReport uses station info lookup before latest measurement lookup", async () => {
const calls = [];
const report = await fetchWaterLevelReport({
stationName: "한강대교",
serviceKey: "test-key",
fetchImpl: async (url) => {
const text = String(url);
calls.push(text);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(JSON.stringify({ content: stationItems }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(JSON.stringify({ content: measurementItems }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
}
});
assert.equal(report.station_name, "한강대교");
assert.equal(report.flow_rate.value_cms, 208.58);
assert.deepEqual(calls.map((url) => url.split("/").slice(-3).join("/")), [
"test-key/waterlevel/info.json",
"list/10M/1018683.json"
]);
});

View file

@ -1,7 +1,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildServer, proxyAirKoreaRequest, proxySeoulSubwayRequest } = require("../src/server");
const {
buildServer,
proxyAirKoreaRequest,
proxySeoulSubwayRequest,
proxyHrfcoWaterLevelRequest
} = require("../src/server");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
@ -322,3 +327,262 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교",
attwl: "5.5",
wrnwl: "8.0",
almwl: "10.0",
srswl: "11.0",
pfh: "13.0",
fstnyn: "Y"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().station_name, "한강대교");
assert.equal(response.json().water_level.value_m, 0.66);
assert.equal(response.json().flow_rate.value_cms, 208.58);
assert.equal(response.json().proxy.cache.hit, false);
assert.match(fetchCalls[0], /\/waterlevel\/info\.json$/);
assert.match(fetchCalls[1], /\/waterlevel\/list\/10M\/1018683\.json$/);
});
test("han river water-level endpoint caches normalized station queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?station=%20%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90%20"
});
const second = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 2);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("han river water-level endpoint returns ambiguous candidates for broad station names", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" },
{ wlobscd: "1018680", obsnm: "한강철교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_station");
assert.deepEqual(response.json().candidate_stations, ["한강대교", "한강철교"]);
});
test("han river water-level endpoint returns 503 when proxy server lacks HRFCO API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyHrfcoWaterLevelRequest injects API key and resolves station code path", async () => {
let calledUrls = [];
const result = await proxyHrfcoWaterLevelRequest({
stationName: "한강대교",
apiKey: "test-hrfco-key",
fetchImpl: async (url) => {
calledUrls.push(String(url));
if (String(url).endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
});
assert.equal(result.statusCode, 200);
assert.equal(JSON.parse(result.body).station_code, "1018683");
assert.match(calledUrls[0], /\/test-hrfco-key\/waterlevel\/info\.json$/);
assert.match(calledUrls[1], /\/test-hrfco-key\/waterlevel\/list\/10M\/1018683\.json$/);
});

View file

@ -19,6 +19,47 @@ function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findSection(doc, heading) {
const escaped = escapeRegex(heading);
const match = doc.match(new RegExp(`${escaped}[\\s\\S]*?(?=\\n## |\\n### |$)`));
assert.ok(match, `expected section headed by "${heading}"`);
return match[0];
}
function assertOliveYoungCloneFallbackCommands(doc, label) {
assert.match(doc, /node dist\/bin\.js health/, `${label} should document the runnable local health command`);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/stores --keyword 명동 --limit 5 --json/,
`${label} should document the runnable local store lookup command`,
);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/products --keyword 선크림 --size 5 --json/,
`${label} should document the runnable local product lookup command`,
);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json/,
`${label} should document the runnable local inventory lookup command`,
);
assert.doesNotMatch(doc, /^\s*npx daiso\b/m, `${label} should not publish broken clone-local npx commands`);
}
function assertOliveYoungCloneFallbackShorthand(doc, label) {
assert.match(
doc,
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && cd daiso-mcp && npm install && npm run build/,
`${label} should include a runnable shorthand that changes into the clone before install/build`,
);
assert.doesNotMatch(
doc,
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && npm install && npm run build/,
`${label} should not publish the broken shorthand that skips cd daiso-mcp`,
);
}
function extractQuotedEntries(block, indent) {
return block
.split("\n")
@ -678,6 +719,76 @@ test("daiso-product-search docs record the shipped feature and official sources"
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("repository docs advertise the olive-young-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "olive-young-search.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/olive-young-search.md to exist");
assert.match(readme, /\| 올리브영 검색 \|/);
assert.match(readme, /\[올리브영 검색 가이드\]\(docs\/features\/olive-young-search\.md\)/);
assert.match(install, /--skill olive-young-search/);
assert.match(install, /npm install -g .* daiso/);
assert.match(roadmap, /올리브영 검색 스킬 출시/);
assert.match(sources, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/daiso/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/stores/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/products/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/inventory/);
});
test("olive-young install docs warn about intermittent public endpoint failures and direct users to retry or clone fallback", () => {
const install = read(path.join("docs", "install.md"));
const quickstart = findSection(install, "### `olive-young-search` upstream CLI quickstart");
assert.match(install, /olive-young-search/);
assert.match(install, /5xx\/503/);
assert.match(install, /재시도|retry/i);
assert.match(install, /clone fallback|git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/i);
assertOliveYoungCloneFallbackShorthand(quickstart, "olive-young install quickstart");
assertOliveYoungCloneFallbackCommands(quickstart, "olive-young install quickstart");
});
test("olive-young-search skill documents the upstream daiso CLI flow for stores, products, and inventory", () => {
const skillPath = path.join(repoRoot, "olive-young-search", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "olive-young-search.md"));
assert.ok(fs.existsSync(skillPath), "expected olive-young-search/SKILL.md to exist");
const skill = read(path.join("olive-young-search", "SKILL.md"));
const featureTop = findSection(featureDoc, "## 가장 중요한 규칙");
const featureFallback = findSection(featureDoc, "## 원본 저장소 clone fallback");
const skillFallback = findSection(skill, "## Fallback: clone the original repository and run the same CLI locally");
assert.match(skill, /^name: olive-young-search$/m);
assert.match(skill, /^description: .*올리브영.*매장.*상품.*재고.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /hmmhmmhm\/daiso-mcp/);
assert.match(doc, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
assert.match(doc, /npm install -g daiso|npx --yes daiso|npx daiso/);
assert.match(doc, /git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/);
assert.match(doc, /npm install/);
assert.match(doc, /npm run build/);
assert.match(doc, /MCP 서버를 .*직접 설치.*않고.*CLI/u);
assert.match(doc, /매장 검색/);
assert.match(doc, /상품 검색/);
assert.match(doc, /재고 확인/);
assert.match(doc, /\/api\/oliveyoung\/stores/);
assert.match(doc, /\/api\/oliveyoung\/products/);
assert.match(doc, /\/api\/oliveyoung\/inventory/);
assert.match(doc, /vendoring 하지 않/);
}
assertOliveYoungCloneFallbackShorthand(featureTop, "olive-young feature guide shorthand");
for (const fallbackDoc of [featureFallback, skillFallback]) {
assertOliveYoungCloneFallbackCommands(fallbackDoc, "olive-young clone fallback docs");
}
});
test("repository docs advertise the coupang-product-search skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1346,3 +1457,52 @@ test("cheap-gas-nearby skill docs require location-first prompts and official Op
assert.match(doc, /카카오맵|Kakao Map/);
}
});
test("repository docs advertise the han-river-water-level skill and rollout-pending proxy workflow", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "han-river-water-level.md");
const featureDoc = read(path.join("docs", "features", "han-river-water-level.md"));
const skillPath = path.join(repoRoot, "han-river-water-level", "SKILL.md");
const skill = read(path.join("han-river-water-level", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/han-river-water-level.md to exist");
assert.ok(fs.existsSync(skillPath), "expected han-river-water-level/SKILL.md to exist");
assert.match(readme, /\| 한강 수위 정보 조회 \|/);
assert.match(readme, /\[한강 수위 정보 가이드\]\(docs\/features\/han-river-water-level\.md\)/);
assert.match(install, /--skill han-river-water-level/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /\/v1\/han-river\/water-level/);
assert.match(doc, /stationName|station_code|stationCode/);
assert.match(doc, /수위|유량/);
assert.match(doc, /ServiceKey|API key/);
assert.match(doc, /candidate_stations|ambiguous_station/);
assert.match(doc, /KSKILL_PROXY_BASE_URL|self-host|로컬 proxy/);
assert.match(doc, /배포 확인이 끝나기 전|배포 전|pending deployment/);
}
assert.doesNotMatch(skill, /기본적으로 `https:\/\/k-skill-proxy\.nomadamas\.org\/v1\/han-river\/water-level`/);
assert.doesNotMatch(featureDoc, /기본 hosted 조회:/);
for (const doc of [proxyDoc, proxyReadme]) {
assert.match(doc, /\/v1\/han-river\/water-level/);
assert.match(doc, /HRFCO_OPEN_API_KEY/);
assert.match(doc, /waterlevel\/info\.json/);
assert.match(doc, /waterlevel\/list\/10M/);
}
assert.match(setup, /한강 수위 정보 조회 \| 사용자 시크릿 불필요/);
assert.match(setup, /한강 수위 정보도 hosted public route rollout 이 끝나기 전까지 .*KSKILL_PROXY_BASE_URL/);
assert.match(security, /KSKILL_PROXY_BASE_URL.*서울 지하철.*한강 수위.*route가 실제 배포된 proxy URL/);
assert.match(sources, /hrfco\.go\.kr\/web\/openapiPage\/reference\.do/);
assert.match(sources, /api\.hrfco\.go\.kr/);
assert.match(roadmap, /한강 수위 정보 조회 스킬 출시/);
});