Route shared key APIs through the proxy

Move KOSIS general lookups and Kakao Local geocoding behind k-skill-proxy so users do not need to manage those API keys for common skill flows. Keep KOSIS bigdata/direct calls user-keyed because userStatsId is account-specific.

Constraint: Free API proxy policy allows proxying upstreams that require API keys while keeping routes narrow, cache-backed, and public.

Rejected: Proxy ODsay transit routing | Basic quota is low, time-limited, and IP-whitelist-bound, so centralizing it would create quota and operations risk.

Confidence: high

Scope-risk: moderate

Directive: Keep KOSIS bigdata direct unless a per-user credential design is added; do not route broad Kakao surfaces without explicit allowlists and rate limits.

Tested: npm run ci; local KOSIS proxy smoke via /v1/kosis/search and /v1/kosis/meta; local Kakao proxy smoke via /v1/kakao-local/geocode q=서울역.

Not-tested: Production proxy deployment after main merge/cron update.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-13 16:31:29 +09:00
commit 49bf262bb9
16 changed files with 819 additions and 75 deletions

View file

@ -53,7 +53,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 필요 | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | `korean-patent-search` | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | `cheap-gas-nearby` | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |

View file

@ -196,6 +196,33 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
--data-urlencode 'limit=5'
```
KOSIS 통계 조회 endpoint (`KOSIS_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/search' \
--data-urlencode 'q=1인 가구' \
--data-urlencode 'limit=3'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/meta' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'metaType=ITM'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/data' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'prdSe=Y' \
--data-urlencode 'start=2020' \
--data-urlencode 'end=2023' \
--data-urlencode 'objL1=ALL'
```
Kakao Local geocoding endpoint (`KAKAO_REST_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kakao-local/geocode' \
--data-urlencode 'q=서울역' \
--data-urlencode 'limit=1'
```
도서관 정보나루 도서 검색 endpoint (`DATA4LIBRARY_AUTH_KEY` 필요):

View file

@ -12,14 +12,13 @@
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- ODsay Server API Key 발급 및 호출 IP 화이트리스트 등록: https://lab.odsay.com
- Kakao REST API Key 발급 (지도/로컬 서비스 활성화): https://developers.kakao.com
- Kakao Local geocoding은 기본 hosted `k-skill-proxy` 경유. 사용자 쪽 Kakao 키는 불필요하며, self-host proxy 운영자만 Kakao REST API Key를 발급해 서버에 설정한다: https://developers.kakao.com
## 필요한 환경변수
- `ODSAY_API_KEY` — ODsay LIVE API Server 키
- `KAKAO_REST_API_KEY` — Kakao Local REST API 키
두 값 모두 `~/.config/k-skill/secrets.env` 에 저장하거나 환경변수로 주입한다.
`ODSAY_API_KEY` `~/.config/k-skill/secrets.env` 에 저장하거나 환경변수로 주입한다. 별도 self-host proxy를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL` 을 설정한다.
## 입력값
@ -29,7 +28,7 @@
## 기본 흐름
1. 출발지/도착지를 Kakao Local API(`address.json``keyword.json`)로 geocoding하여 좌표를 확보한다.
1. 출발지/도착지를 `k-skill-proxy``/v1/kakao-local/geocode`로 geocoding하여 좌표를 확보한다. Proxy 내부에서 Kakao Local `address.json``keyword.json` 순서로 시도한다.
2. ODsay `searchPubTransPathT`에 출발/도착 좌표와 옵션을 전달한다.
3. 응답의 `result.path[]`를 3개 이내로 정리한다.
4. 각 경로의 `subPath[]``trafficType`별로 표시하며, 첫/끝 도보 구간을 반드시 포함한다.
@ -49,17 +48,15 @@ curl -s "https://api.odsay.com/v1/api/searchPubTransPathT?apiKey=${KEY}&SX=126.9
```python
import os, urllib.parse, urllib.request, json
H = {'Authorization': 'KakaoAK ' + os.environ['KAKAO_REST_API_KEY']}
PROXY = os.environ.get('KSKILL_PROXY_BASE_URL', 'https://k-skill-proxy.nomadamas.org').rstrip('/')
def geocode(q):
for ep, name in [('address', 'address_name'), ('keyword', 'place_name')]:
url = f'https://dapi.kakao.com/v2/local/search/{ep}.json?query=' + urllib.parse.quote(q)
req = urllib.request.Request(url, headers=H)
with urllib.request.urlopen(req, timeout=10) as resp:
d = json.loads(resp.read())
if d.get('documents'):
doc = d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get(name) or doc['address_name']
url = PROXY + '/v1/kakao-local/geocode?q=' + urllib.parse.quote(q)
with urllib.request.urlopen(url, timeout=10) as resp:
d = json.loads(resp.read())
if d.get('documents'):
doc = d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get('place_name') or doc.get('address_name')
return None
sx, sy, s_name = geocode('서울역')
@ -70,6 +67,6 @@ ex, ey, e_name = geocode('강남역')
## 주의할 점
- ODsay Server 키는 **호출 IP 화이트리스트 등록이 필수**이다. 등록되지 않은 IP에서는 `error` 응답이 반환된다.
- 묣료 일일 한도는 5,000건이다. `searchPubTransPathT``searchStation` 호출이 합산된다.
- 현재 ODsay 공식 Basic 상품 기준 무료 체험은 일 1,000건(6개월)이다. `searchPubTransPathT``searchStation` 호출이 합산된다.
- 한국 외 좌표는 지원하지 않는다.
- 카카오맵/네이버지도 directions API는 대중교통 라우팅을 공개하지 않으므로 사용하지 말 것.

View file

@ -15,7 +15,8 @@
## 먼저 필요한 것
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- 일반 `search`/`meta`/`data`: 기본 hosted `k-skill-proxy` 접근
- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
@ -25,28 +26,24 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
## 필요한 환경변수
- `KSKILL_KOSIS_API_KEY`
- 일반 `search`/`meta`/`data`: 없음
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 사용
- `KSKILL_KOSIS_API_KEY``bigdata` 또는 `--direct` 전용
선택:
- 없음
### Credential resolution order
### Credential resolution order (`bigdata` 또는 `--direct` 전용)
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
helper는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일 읽는다.
일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일 읽는다.
## 처음 실행 순서
처음 쓰는 사용자는 키 발급 후 검색 → 메타 → 작은 슬라이스 순으로 점검한다.
처음 쓰는 사용자는 proxy 기반 검색 → 메타 → 작은 슬라이스 순으로 점검한다. 사용자 KOSIS 키는 일반 조회에 필요 없다.
```bash
export KSKILL_KOSIS_API_KEY="your-kosis-api-key"
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
python3 kosis-stats/scripts/run_kosis_stats.py meta --table-id DT_1JC1501 --text
python3 kosis-stats/scripts/run_kosis_stats.py data \
@ -84,10 +81,12 @@ python3 kosis-stats/scripts/run_kosis_stats.py data \
- `--text` / `--json` (기본 JSON)
- `--dry-run` (인증키 없이 URL/파라미터만 출력)
- `--timeout N` (기본 30)
- `--proxy-base-url URL` (기본 hosted proxy 대신 self-host/alternate proxy 사용)
- `--direct` (proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출)
## 기본 흐름
1. `KSKILL_KOSIS_API_KEY` 를 확보한다.
1. 일반 조회는 기본 hosted proxy를 사용한다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다.
2. `search` 로 후보 통계표를 본다.
3. `meta` 로 분류·단위·주기를 확인한다.
4. `data` 로 작은 슬라이스를 먼저 받는다.
@ -96,10 +95,10 @@ python3 kosis-stats/scripts/run_kosis_stats.py data \
## 검증 방식
메인테이너가 별도 KOSIS 인증키를 새로 발급받을 필요는 없다.
메인테이너가 일반 조회를 검토하기 위해 별도 KOSIS 인증키를 새로 발급받을 필요는 없다.
- CI/리뷰 검증: `./scripts/validate-skills.sh`, `python3 -m py_compile ...`, `--help`, `--dry-run`, 단위 테스트(`python3 -m unittest discover -s kosis-stats/tests`).
- 실제 조회 검증: 기여자 또는 이미 KOSIS 키를 가진 사용자가 개인 키로 선택 실행한다.
- 실제 direct 조회 검증: 기여자 또는 이미 KOSIS 키를 가진 사용자가 `--direct`로 선택 실행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다.
- PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남긴다. 인증키와 개인 조회 세부 내역은 공유하지 않는다.
## 예시
@ -168,7 +167,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py search --query "인구" --dry-run
## 흔한 문제 해결
- `missing required environment variable: KSKILL_KOSIS_API_KEY`: 환경변수가 현재 shell에 주입됐는지 확인한다. 없다면 https://kosis.kr/openapi/ 에서 발급한다.
- `missing required environment variable: KSKILL_KOSIS_API_KEY`: `bigdata` 또는 `--direct` 호출에서만 발생한다. 환경변수가 현재 shell에 주입됐는지 확인한다. 없다면 https://kosis.kr/openapi/ 에서 발급한다.
- `KOSIS error 10` (인증키 누락) / `11` (만료): 키를 재확인하거나 갱신한다. `bigdata` 호출에서 `11` 이 뜨면 해당 `userStatsId` 가 본인 KOSIS 계정에 등록되어 있지 않을 가능성이 높다.
- `KOSIS error 20` (필수 분류 누락): 표마다 필수 차원 수가 다르다. `meta --table-id <ID> --meta-type OBJ` 로 차원 수를 확인하고(OBJ가 비어 있으면 `--meta-type ITM`), `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 모두 지정한 뒤 재호출한다. 예: `data --table-id DT_1J22001 --prd-se M --start 202401 --end 202401 --obj-l 1=ALL` → 코드 20 → meta 확인 → `--obj-l 1=T10 --obj-l 2=0` 추가 → 성공.
- `KOSIS error 21` (잘못된 요청 변수): `org_id`/`tbl_id`/`prdSe`/`startPrdDe` 형식과 분류 인덱스를 재확인한다. 표에 존재하지 않는 `objL3=ALL` 같은 인덱스는 거부된다. tblId 의심 시 `search --query <키워드>` 로 정확한 ID를 다시 찾는다.

View file

@ -324,10 +324,9 @@ python3 scripts/patent_search.py --query "배터리"
python3 scripts/scholarship_filter.py report --input scholarships.json --today 2026-04-14 --only-open-now
```
국가데이터처 KOSIS 통계 조회 helper는 설치된 `kosis-stats` skill 안의 `scripts/run_kosis_stats.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
국가데이터처 KOSIS 통계 조회 helper는 설치된 `kosis-stats` skill 안의 `scripts/run_kosis_stats.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다. 일반 `search`/`meta`/`data`는 기본 hosted proxy를 쓰므로 사용자 KOSIS 키가 필요 없다.
```bash
export KSKILL_KOSIS_API_KEY=your-kosis-api-key
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
```

View file

@ -26,14 +26,17 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. direct/bigdata 또는 proxy 서버 운영 때만 필요.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 필요.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -66,13 +69,14 @@ KSKILL_PROXY_BASE_URL=
- `KSKILL_KTX_PASSWORD`
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
- `KSKILL_KOSIS_API_KEY`
- `KSKILL_KOSIS_API_KEY` (KOSIS `bigdata`/`--direct`, 또는 proxy 서버 `KOSIS_API_KEY` 대체 env)
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KAKAO_REST_API_KEY`
- `KRX_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` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, 국가데이터처 KOSIS 통계 조회용 `KSKILL_KOSIS_API_KEY` (https://kosis.kr/openapi/ 에서 무료 발급), 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, KOSIS `bigdata`/`--direct` 조회용 `KSKILL_KOSIS_API_KEY` (https://kosis.kr/openapi/ 에서 무료 발급), 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처/KOSIS/Kakao upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다. KOSIS 일반 조회와 Kakao Local geocoding도 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY`·`KOSIS_API_KEY`·`KAKAO_REST_API_KEY` 등은 서버에 설정되어 있어야 한다).
## Credential resolution order
@ -26,10 +26,13 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# KOSIS 일반 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
EOF
chmod 0600 ~/.config/k-skill/secrets.env
@ -37,7 +40,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.

View file

@ -7,7 +7,8 @@
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 모든 helper 호출은 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do`, `/statisticsBigData.do` 를 사용한다 (HTTPS 전용, 2026-03-05 시행)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do

View file

@ -4,8 +4,11 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=

View file

@ -22,6 +22,7 @@ metadata:
- 환경변수 `ODSAY_API_KEY` 가 있으면 사용. 없으면 `~/.config/k-skill/secrets.env` 에서 로드.
- ODsay Server 키는 호출 IP 화이트리스트 등록 필수. 발급은 https://lab.odsay.com
- Kakao Local geocoding은 기본 hosted `k-skill-proxy` 경유로 호출하므로 사용자 쪽 `KAKAO_REST_API_KEY` 는 불필요하다. self-host proxy 운영자만 `KAKAO_REST_API_KEY` 를 서버에 설정한다.
## Inputs
@ -29,25 +30,23 @@ metadata:
### Geocoding (필수 선행 단계)
`KAKAO_REST_API_KEY` 사용. 두 엔드포인트를 순서대로 시도:
기본 hosted proxy를 사용한다. Proxy가 Kakao Local REST API 키를 서버에서만 주입하고, caller `apiKey` 는 무시한다.
1. `https://dapi.kakao.com/v2/local/search/address.json?query=<주소>` — 도로명/지번 주소
2. 결과 없으면 `https://dapi.kakao.com/v2/local/search/keyword.json?query=<장소명>` — 상호명/랜드마크
1. `https://k-skill-proxy.nomadamas.org/v1/kakao-local/geocode?q=<주소/장소명>`
2. proxy 내부 fallback: Kakao Local `address.json` → 결과 없으면 `keyword.json`
헤더: `Authorization: KakaoAK <KAKAO_REST_API_KEY>`. 응답 `documents[0].x`(경도), `.y`(위도) 사용.
응답 `documents[0].x`(경도), `.y`(위도) 사용.
```python
import os, urllib.parse, urllib.request, json
H={'Authorization':'KakaoAK '+os.environ['KAKAO_REST_API_KEY']}
PROXY=os.environ.get('KSKILL_PROXY_BASE_URL','https://k-skill-proxy.nomadamas.org').rstrip('/')
def geocode(q):
for ep,name in [('address','address_name'),('keyword','place_name')]:
url=f'https://dapi.kakao.com/v2/local/search/{ep}.json?query='+urllib.parse.quote(q)
req=urllib.request.Request(url,headers=H)
with urllib.request.urlopen(req,timeout=10) as resp:
d=json.loads(resp.read())
if d.get('documents'):
doc=d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get(name) or doc['address_name']
url=PROXY+'/v1/kakao-local/geocode?q='+urllib.parse.quote(q)
with urllib.request.urlopen(url,timeout=10) as resp:
d=json.loads(resp.read())
if d.get('documents'):
doc=d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get('place_name') or doc.get('address_name')
return None
```
@ -109,7 +108,7 @@ curl -s "https://api.odsay.com/v1/api/searchStation?apiKey=${KEY}&stationName=
## Limits
- 무료 일 5,000건. `searchPubTransPathT` + `searchStation` 호출이 합산되니 한 질문당 호출 최소화.
- 현재 ODsay 공식 Basic 상품 기준 무료 체험은 일 1,000건(6개월)이다. `searchPubTransPathT` + `searchStation` 호출이 합산되니 한 질문당 호출 최소화.
- 응답에 `error` 키 있으면 즉시 사용자에게 표시(ApiKey/IP 문제 진단에 유용).
- 한국 외 좌표는 지원 안 함.
@ -118,7 +117,7 @@ curl -s "https://api.odsay.com/v1/api/searchStation?apiKey=${KEY}&stationName=
- ODsay `error` 응답: `msg` 필드를 그대로 사용자에게 표시하고, ApiKey 미등록 또는 IP 화이트리스트 누락 가능성을 안내한다.
- Kakao geocoding 결과 없음: 주소/장소명을 다시 확인하거나 더 구체적인 표현을 요청한다.
- 좌표는 있으나 ODsay 경로 없음: 대중교통 미개통 지역, 도보 가능 거리, 또는 해상/공항 구간일 수 있다. 사용자에게 확인한다.
- quota 초과: 일일 5,000건 한도 도달 시 추가 호출을 중단하고 사용자에게 알린다.
- quota 초과: 일일 한도 도달 시 추가 호출을 중단하고 사용자에게 알린다.
## Don'ts

View file

@ -40,7 +40,8 @@ metadata:
## Prerequisites
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- 일반 `search`/`meta`/`data`: `k-skill-proxy`의 KOSIS route가 있는 hosted/self-host 프록시에 접근 가능할 것
- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
```bash
python3 kosis-stats/scripts/run_kosis_stats.py --help
@ -48,11 +49,13 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
## Required environment variables
- `KSKILL_KOSIS_API_KEY` — KOSIS Open API 인증키
- 일반 `search`/`meta`/`data`: 없음. 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted proxy를 사용한다.
- `KSKILL_KOSIS_API_KEY``bigdata` 또는 `--direct`로 KOSIS를 직접 호출할 때만 필요하다.
발급 절차와 호출 한도, 에러 코드 등 자세한 내용은 [`references/kosis-openapi-guide.md`](references/kosis-openapi-guide.md) 참고.
### Credential resolution order
### Credential resolution order (`bigdata` 또는 `--direct` 전용)
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
@ -60,7 +63,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만 읽는다.
일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일을 읽는다.
## Inputs
@ -72,6 +75,8 @@ Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만
- `--json`: 구조화 결과 (기본값)
- `--dry-run`: 인증키 없이 요청 URL/파라미터만 출력
- `--timeout N`: HTTP 타임아웃 초 단위 (기본 30)
- `--proxy-base-url URL`: 기본 hosted proxy 대신 self-host/alternate proxy 사용
- `--direct`: proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출
서브커맨드별 입력:
@ -97,11 +102,11 @@ Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만
## Workflow
### 1. Ensure credentials are available
### 1. Ensure proxy access is available
`KSKILL_KOSIS_API_KEY` 가 설정되어 있는지 확인한다. 없으면 credential resolution order에 따라 확보한다.
일반 `search`/`meta`/`data` 는 기본 hosted `k-skill-proxy`를 사용하므로 사용자 KOSIS 키가 필요 없다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다.
시크릿이 없다는 이유로 다른 통계 사이트나 비공식 경로를 찾지 않는다.
`bigdata` 또는 `--direct`가 필요할 때만 `KSKILL_KOSIS_API_KEY` 를 credential resolution order에 따라 확보한다. 시크릿이 없다는 이유로 다른 통계 사이트나 비공식 경로를 찾지 않는다.
### 2. Search for candidate tables
@ -161,7 +166,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
## Failure modes
- `KSKILL_KOSIS_API_KEY` 누락: 발급 안내 메시지와 함께 종료(exit 1)
- `KSKILL_KOSIS_API_KEY` 누락: `bigdata` 또는 `--direct` 호출에서만 발급 안내 메시지와 함께 종료(exit 1)
- KOSIS 에러 코드 `10`/`11`: 인증키 누락/만료 → 키 점검. `bigdata` 에서 `11` 이 나오면 `userStatsId` 가 본인 KOSIS 계정에 등록된 것이 아닐 가능성이 크다.
- 코드 `20`: 필수 분류 누락 → `meta --meta-type OBJ` (또는 비어 있으면 `ITM`) 으로 필요한 차원 수와 코드를 확인하고 `--obj-l 1=... --obj-l 2=...` 모두 지정 후 재시도
- 코드 `21`: 잘못된 요청 변수 → `org_id`/`tbl_id`/기간 형식 재확인. tblId 의심 시 `search` 로 정확한 ID 다시 찾기
@ -181,6 +186,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
## Maintainer review notes
메인테이너가 이 스킬을 검토하기 위해 KOSIS 인증키를 새로 발급받을 필요는 없다.
일반 조회는 `k-skill-proxy`가 KOSIS 인증키를 서버 쪽에서 주입한다. `bigdata``--direct`만 개인 KOSIS 키가 필요하다.
키 없이 가능한 검증:
@ -191,11 +197,11 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
- `PYTHONPATH=kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_*.py' -v`
- `npm run ci`
실제 live smoke는 기여자 또는 이미 KOSIS 키가 있는 사용자가 선택적으로 수행한다. PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남기고 인증키와 개인 조회 세부 내역은 공유하지 않는다.
실제 direct live smoke는 기여자 또는 이미 KOSIS 키가 있는 사용자가 선택적으로 수행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다. PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남기고 인증키와 개인 조회 세부 내역은 공유하지 않는다.
## Safety notes
- 조회 전용 스킬이다.
- 사용자별 통계자료(`userStatsId`) 등록, 데이터 수정, KOSIS 웹 자동화는 하지 않는다.
- 인증키는 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다.
- 일반 조회 인증키는 proxy 서버에서만 다룬다. direct/bigdata 인증키는 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다.
- 응답 JSON에 인증키가 echo 되지 않도록 helper는 `--dry-run` 시에도 키를 `<DRY-RUN>` 으로 대체한다.

View file

@ -30,6 +30,8 @@ SEARCH_URL = "https://kosis.kr/openapi/statisticsSearch.do"
META_URL = "https://kosis.kr/openapi/statisticsData.do"
DATA_URL = "https://kosis.kr/openapi/Param/statisticsParameterData.do"
BIGDATA_URL = "https://kosis.kr/openapi/statisticsBigData.do"
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
DEFAULT_TIMEOUT = 30
PRD_SE_VALUES = {"M", "Q", "S", "Y", "F", "IR"}
@ -104,6 +106,18 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Print the request URL and parameters without calling KOSIS.",
)
parser.add_argument(
"--proxy-base-url",
help=(
"k-skill-proxy base URL for search/meta/data "
f"(default {DEFAULT_PROXY_BASE_URL}; override with {PROXY_BASE_URL_ENV_VAR})."
),
)
parser.add_argument(
"--direct",
action="store_true",
help="Call KOSIS directly with KSKILL_KOSIS_API_KEY instead of k-skill-proxy.",
)
output = parser.add_mutually_exclusive_group()
output.add_argument("--json", action="store_true", help="Print JSON output.")
output.add_argument("--text", action="store_true", help="Print human-readable output.")
@ -235,6 +249,19 @@ def resolve_api_key(
)
def resolve_proxy_base_url(
explicit_base_url: str | None = None,
env: dict[str, str] | None = None,
) -> str:
env_map = env if env is not None else os.environ
candidate = (explicit_base_url or env_map.get(PROXY_BASE_URL_ENV_VAR) or "").strip()
if candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise SystemExit(f"{PROXY_BASE_URL_ENV_VAR} is disabled; pass --direct to use KSKILL_KOSIS_API_KEY.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def parse_obj_l(values: list[str]) -> dict[str, str]:
objs: dict[str, str] = {}
for raw in values:
@ -492,11 +519,30 @@ def cite_endpoint(command: str) -> str:
}[command]
def should_use_proxy(args: argparse.Namespace) -> bool:
return args.command in {"search", "meta", "data"} and not args.direct
def proxy_endpoint(command: str, base_url: str) -> str:
path = {
"search": "/v1/kosis/search",
"meta": "/v1/kosis/meta",
"data": "/v1/kosis/data",
}[command]
return f"{base_url.rstrip('/')}{path}"
def params_without_api_key(params: dict[str, str]) -> dict[str, str]:
return {key: value for key, value in params.items() if key != "apiKey"}
def run(args: argparse.Namespace) -> int:
use_json = args.json or not args.text
if args.dry_run:
api_key = "<DRY-RUN>"
use_proxy = should_use_proxy(args)
if use_proxy or args.dry_run:
api_key = "<PROXY>" if use_proxy else "<DRY-RUN>"
else:
api_key = resolve_api_key()
@ -508,16 +554,31 @@ def run(args: argparse.Namespace) -> int:
}[args.command]
base = cite_endpoint(args.command)
params = builder(api_key, args)
url = build_url(base, params)
if use_proxy:
call_base = proxy_endpoint(args.command, resolve_proxy_base_url(args.proxy_base_url))
call_params = params_without_api_key(params)
else:
call_base = base
call_params = params
url = build_url(call_base, call_params)
if args.dry_run:
redacted = dict(params)
redacted["apiKey"] = "<DRY-RUN>"
redacted = dict(call_params)
if not use_proxy and "apiKey" in redacted:
redacted["apiKey"] = "<DRY-RUN>"
if use_json:
print(json.dumps({"endpoint": base, "params": redacted, "url": build_url(base, redacted)}, ensure_ascii=False, indent=2))
print(json.dumps({
"endpoint": call_base,
"upstream_endpoint": base,
"via_proxy": use_proxy,
"params": redacted,
"url": build_url(call_base, redacted)
}, ensure_ascii=False, indent=2))
else:
print(f"endpoint: {base}")
print(f"url: {build_url(base, redacted)}")
print(f"endpoint: {call_base}")
print(f"upstream_endpoint: {base}")
print(f"via_proxy: {str(use_proxy).lower()}")
print(f"url: {build_url(call_base, redacted)}")
for key, value in redacted.items():
print(f" {key}={value}")
return 0
@ -539,6 +600,8 @@ def run(args: argparse.Namespace) -> int:
else:
print(render_text(args.command, payload))
print(f"\nsource: {base}")
if use_proxy:
print(f"via: {call_base}")
return 0

View file

@ -96,6 +96,22 @@ class CredentialResolutionTest(unittest.TestCase):
self.assertIn("KSKILL_KOSIS_API_KEY", message)
self.assertIn("kosis.kr/openapi", message)
def test_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(
helper.resolve_proxy_base_url(env={}),
"https://k-skill-proxy.nomadamas.org"
)
def test_proxy_base_url_env_override_is_trimmed(self):
self.assertEqual(
helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "https://proxy.example/"}),
"https://proxy.example"
)
def test_proxy_base_url_can_be_disabled_for_direct_mode(self):
with self.assertRaises(SystemExit):
helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "off"})
class UrlBuilderTest(unittest.TestCase):
def test_search_params_include_required_fields(self):
@ -334,15 +350,29 @@ class DryRunTest(unittest.TestCase):
self.assertEqual(rc, 0)
fetch_mock.assert_not_called()
out = buf.getvalue()
self.assertIn("<DRY-RUN>", out)
self.assertIn('"via_proxy": true', out)
self.assertNotIn("apiKey", json.dumps(json.loads(out)["params"]))
self.assertIn("/v1/kosis/search", out)
self.assertIn("statisticsSearch.do", out)
def test_direct_dry_run_redacts_api_key(self):
args = helper.parse_args(["search", "--query", "인구", "--dry-run", "--direct", "--json"])
with mock.patch.object(helper, "fetch_text") as fetch_mock:
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)
self.assertEqual(rc, 0)
fetch_mock.assert_not_called()
out = buf.getvalue()
self.assertIn("<DRY-RUN>", out)
self.assertIn('"via_proxy": false', out)
self.assertIn("apiKey", out)
class RunIntegrationTest(unittest.TestCase):
def test_run_search_text_renders_fixture_payload(self):
args = helper.parse_args(["search", "--query", "1인 가구", "--text"])
with mock.patch.object(helper, "resolve_api_key", return_value="KEY"), \
mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")):
with mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")) as fetch_mock:
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)
@ -350,6 +380,8 @@ class RunIntegrationTest(unittest.TestCase):
out = buf.getvalue()
self.assertIn("DT_1JC1501", out)
self.assertIn("statisticsSearch.do", out)
self.assertIn("/v1/kosis/search", fetch_mock.call_args.args[0])
self.assertNotIn("apiKey=", fetch_mock.call_args.args[0])
def test_run_returns_2_on_kosis_error(self):
args = helper.parse_args(["data", "--table-id", "DT_X",
@ -364,7 +396,7 @@ class RunIntegrationTest(unittest.TestCase):
@unittest.skipUnless(os.getenv("KSKILL_KOSIS_API_KEY"), "live KOSIS test skipped without KSKILL_KOSIS_API_KEY")
class LiveKosisSmokeTest(unittest.TestCase):
def test_live_search_returns_list(self):
args = helper.parse_args(["search", "--query", "인구", "--result-count", "1", "--json"])
args = helper.parse_args(["search", "--query", "인구", "--result-count", "1", "--json", "--direct"])
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)

View file

@ -18,6 +18,10 @@
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
- `GET /v1/naver-news/search` — 네이버 검색 Open API 뉴스 검색(`news.json`) 기반 최신 뉴스 기사 제목/요약/링크/발행시각 조회(`NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 필요)
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
@ -47,7 +51,9 @@
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao Local REST API 키 (`kakao-local/geocode`)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
@ -205,6 +211,33 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
KOSIS 통계 조회 예시 (`KOSIS_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/search' \
--data-urlencode 'q=1인 가구' \
--data-urlencode 'limit=3'
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/meta' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'metaType=ITM'
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/data' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'prdSe=Y' \
--data-urlencode 'start=2020' \
--data-urlencode 'end=2023' \
--data-urlencode 'objL1=ALL'
```
Kakao Local geocoding 예시 (`KAKAO_REST_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/kakao-local/geocode' \
--data-urlencode 'q=서울역' \
--data-urlencode 'limit=1'
```
## PM2 실행

View file

@ -28,6 +28,8 @@ const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-cod
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
const KOSIS_OPEN_API_BASE_URL = "https://kosis.kr/openapi";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@ -159,8 +161,10 @@ function buildConfig(env = process.env) {
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
data4libraryAuthKey: trimOrNull(env.DATA4LIBRARY_AUTH_KEY),
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
kakaoRestApiKey: trimOrNull(env.KAKAO_REST_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
@ -476,6 +480,126 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeKosisSearchQuery(query) {
const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q);
if (!searchNm) {
throw new Error("Provide query.");
}
return {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm,
resultCount: parseBoundedPositiveInteger(query.resultCount ?? query.result_count ?? query.limit, {
defaultValue: 20,
min: 1,
max: 5000,
label: "resultCount"
}),
startCount: parseBoundedPositiveInteger(query.startCount ?? query.start_count ?? query.start, {
defaultValue: 1,
min: 1,
max: 1000000,
label: "startCount"
})
};
}
function normalizeKosisMetaQuery(query) {
const orgId = trimOrNull(query.orgId ?? query.org_id) || "101";
const tblId = trimOrNull(query.tblId ?? query.tableId ?? query.table_id ?? query.tbl_id);
const type = (trimOrNull(query.type ?? query.metaType ?? query.meta_type) || "TBL").toUpperCase();
if (!/^\d+$/.test(orgId)) {
throw new Error("Provide valid orgId.");
}
if (!tblId) {
throw new Error("Provide tableId.");
}
if (!["TBL", "ITM", "OBJ"].includes(type)) {
throw new Error("metaType must be TBL, ITM, or OBJ.");
}
return {
method: "getMeta",
type,
format: "json",
jsonVD: "Y",
orgId,
tblId
};
}
function normalizeKosisDataQuery(query) {
const orgId = trimOrNull(query.orgId ?? query.org_id) || "101";
const tblId = trimOrNull(query.tblId ?? query.tableId ?? query.table_id ?? query.tbl_id);
const itmId = trimOrNull(query.itmId ?? query.itemId ?? query.item_id ?? query.itm_id) || "ALL";
const prdSe = (trimOrNull(query.prdSe ?? query.prd_se) || "").toUpperCase();
const startPrdDe = trimOrNull(query.startPrdDe ?? query.start_prd_de ?? query.start);
const endPrdDe = trimOrNull(query.endPrdDe ?? query.end_prd_de ?? query.end);
if (!/^\d+$/.test(orgId)) {
throw new Error("Provide valid orgId.");
}
if (!tblId) {
throw new Error("Provide tableId.");
}
if (!["M", "Q", "S", "Y", "F", "IR"].includes(prdSe)) {
throw new Error("prdSe must be one of M, Q, S, Y, F, IR.");
}
if (!startPrdDe || !endPrdDe) {
throw new Error("Provide start and end periods.");
}
const normalized = {
method: "getList",
format: "json",
jsonVD: "Y",
orgId,
tblId,
itmId,
prdSe,
startPrdDe,
endPrdDe
};
for (let index = 1; index <= 8; index += 1) {
const value = trimOrNull(query[`objL${index}`] ?? query[`obj_l${index}`]);
if (value) {
normalized[`objL${index}`] = value;
}
}
if (!Object.keys(normalized).some((key) => /^objL\d+$/.test(key))) {
normalized.objL1 = "ALL";
}
return normalized;
}
function normalizeKakaoLocalGeocodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
return {
query: q,
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 5,
min: 1,
max: 15,
label: "size"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
})
};
}
function normalizeKmaForecastQuery(query, now = new Date()) {
const rawNx = parseInteger(query.nx, Number.NaN);
const rawNy = parseInteger(query.ny, Number.NaN);
@ -1033,6 +1157,154 @@ async function proxyData4LibraryRequest({
};
}
async function proxyKosisRequest({
operation,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
search: "statisticsSearch.do",
meta: "statisticsData.do",
data: "Param/statisticsParameterData.do"
};
const path = paths[operation];
if (!path) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That KOSIS route is not exposed by this proxy."
})
};
}
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KOSIS_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${KOSIS_OPEN_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
url.searchParams.set("apiKey", apiKey);
const response = await fetchImpl(url, {
headers: {
"user-agent": "k-skill-proxy/kosis"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
async function proxyKakaoLocalRequest({
endpoint,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
address: "search/address.json",
keyword: "search/keyword.json"
};
const path = paths[endpoint];
if (!path) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That Kakao Local route is not exposed by this proxy."
})
};
}
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KAKAO_REST_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${KAKAO_LOCAL_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
const response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
"user-agent": "k-skill-proxy/kakao-local"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function hasKakaoLocalDocuments(body) {
try {
const payload = JSON.parse(String(body || ""));
return Array.isArray(payload.documents) && payload.documents.length > 0;
} catch {
return false;
}
}
function isSuccessfulJsonResponse(upstream) {
return upstream.statusCode >= 200 && upstream.statusCode < 300 && upstream.contentType.includes("json");
}
function isKosisErrorBody(body) {
const text = String(body || "").trim();
if (!text) {
return true;
}
if (/<error>\s*<err>/i.test(text)) {
return true;
}
if (!(text.startsWith("{") || text.startsWith("["))) {
return false;
}
try {
const payload = JSON.parse(text.replace(/([{,])\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":'));
return Boolean(payload && !Array.isArray(payload) && typeof payload === "object" && (payload.err || payload.errCode || payload.error));
} catch {
return false;
}
}
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
@ -1284,6 +1556,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey),
kakaoLocalConfigured: Boolean(config.kakaoRestApiKey),
kosisConfigured: Boolean(config.kosisApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent
@ -1432,6 +1706,115 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
async function handleKosisRoute({ operation, normalize, cacheRoute, request, reply }) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: cacheRoute,
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
reply.code(cached.statusCode);
reply.header("content-type", cached.contentType);
return cached.body;
}
const upstream = await proxyKosisRequest({
operation,
params: normalized,
apiKey: config.kosisApiKey
});
if (upstream.statusCode >= 200 && upstream.statusCode < 300 && !isKosisErrorBody(upstream.body)) {
cache.set(cacheKey, upstream, config.cacheTtlMs);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
return upstream.body;
}
app.get("/v1/kosis/search", async (request, reply) => handleKosisRoute({
operation: "search",
normalize: normalizeKosisSearchQuery,
cacheRoute: "kosis-search",
request,
reply
}));
app.get("/v1/kosis/meta", async (request, reply) => handleKosisRoute({
operation: "meta",
normalize: normalizeKosisMetaQuery,
cacheRoute: "kosis-meta",
request,
reply
}));
app.get("/v1/kosis/data", async (request, reply) => handleKosisRoute({
operation: "data",
normalize: normalizeKosisDataQuery,
cacheRoute: "kosis-data",
request,
reply
}));
app.get("/v1/kakao-local/geocode", async (request, reply) => {
let normalized;
try {
normalized = normalizeKakaoLocalGeocodeQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "kakao-local-geocode",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
reply.code(cached.statusCode);
reply.header("content-type", cached.contentType);
return cached.body;
}
const address = await proxyKakaoLocalRequest({
endpoint: "address",
params: normalized,
apiKey: config.kakaoRestApiKey
});
const upstream = isSuccessfulJsonResponse(address) && !hasKakaoLocalDocuments(address.body)
? await proxyKakaoLocalRequest({
endpoint: "keyword",
params: normalized,
apiKey: config.kakaoRestApiKey
})
: address;
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, upstream, config.cacheTtlMs);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
return upstream.body;
});
app.get("/v1/korea-weather/forecast", async (request, reply) => {
let normalized;
@ -3453,7 +3836,11 @@ module.exports = {
normalizeData4LibraryLibrarySearchQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeKmaForecastQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
@ -3470,9 +3857,11 @@ module.exports = {
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyNeisSchoolMealRequest,
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
proxyKosisRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulSubwayRequest,

View file

@ -11,9 +11,15 @@ const {
normalizeData4LibraryBookSearchQuery,
normalizeData4LibraryLibrariesByBookQuery,
normalizeData4LibraryLibrarySearchQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyKosisRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
@ -193,6 +199,189 @@ test("health endpoint reports KRX upstream status when configured", async (t) =>
assert.equal(response.json().upstreams.krxConfigured, true);
});
test("KOSIS normalizers map public query aliases to upstream params", () => {
assert.deepEqual(normalizeKosisSearchQuery({ q: "인구", limit: "3" }), {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm: "인구",
resultCount: 3,
startCount: 1
});
assert.deepEqual(normalizeKosisMetaQuery({ table_id: "DT_1IN0001", meta_type: "itm" }), {
method: "getMeta",
type: "ITM",
format: "json",
jsonVD: "Y",
orgId: "101",
tblId: "DT_1IN0001"
});
assert.deepEqual(normalizeKosisDataQuery({
tableId: "DT_1JC1501",
prd_se: "y",
start: "2023",
end: "2024",
objL2: "00"
}), {
method: "getList",
format: "json",
jsonVD: "Y",
orgId: "101",
tblId: "DT_1JC1501",
itmId: "ALL",
prdSe: "Y",
startPrdDe: "2023",
endPrdDe: "2024",
objL2: "00"
});
});
test("KOSIS proxy injects the server-side key without accepting client apiKey", async () => {
const calls = [];
const upstream = await proxyKosisRequest({
operation: "search",
apiKey: "server-kosis-key",
params: {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm: "인구",
resultCount: 1,
startCount: 1,
apiKey: "client-supplied-key"
},
fetchImpl: async (url) => {
calls.push(String(url));
return new Response("[]", {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(calls.length, 1);
const url = new URL(calls[0]);
assert.equal(url.origin + url.pathname, "https://kosis.kr/openapi/statisticsSearch.do");
assert.equal(url.searchParams.get("apiKey"), "server-kosis-key");
assert.equal(url.searchParams.get("searchNm"), "인구");
});
test("KOSIS search endpoint stays public and caches successful upstream responses", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify([{ TBL_ID: "DT_1JC1501", TBL_NM: "1인 가구 비율" }]), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KOSIS_API_KEY: "server-kosis-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/kosis/search?q=${encodeURIComponent("1인 가구")}&limit=1&apiKey=client-key`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json()[0].TBL_ID, "DT_1JC1501");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json()[0].TBL_NM, "1인 가구 비율");
assert.equal(calls.length, 1, "second request should be served from proxy cache");
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), "server-kosis-key");
});
test("Kakao Local geocode normalizer maps public aliases to upstream params", () => {
assert.deepEqual(normalizeKakaoLocalGeocodeQuery({ q: "서울역", limit: "3" }), {
query: "서울역",
size: 3,
page: 1
});
});
test("Kakao Local proxy injects the server-side REST API key", async () => {
const calls = [];
const upstream = await proxyKakaoLocalRequest({
endpoint: "address",
apiKey: "server-kakao-key",
params: {
query: "서울역",
size: 1,
apiKey: "client-supplied-key"
},
fetchImpl: async (url, options) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(JSON.stringify({ documents: [] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(calls.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.origin + url.pathname, "https://dapi.kakao.com/v2/local/search/address.json");
assert.equal(url.searchParams.get("query"), "서울역");
assert.equal(url.searchParams.get("apiKey"), null);
assert.equal(calls[0].headers.authorization, "KakaoAK server-kakao-key");
});
test("Kakao Local geocode endpoint falls back from address to keyword and caches", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
const path = new URL(url).pathname;
if (path.endsWith("/search/address.json")) {
return new Response(JSON.stringify({ documents: [] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
return new Response(JSON.stringify({ documents: [{ place_name: "서울역", x: "126.9706", y: "37.5559" }] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KAKAO_REST_API_KEY: "server-kakao-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/kakao-local/geocode?q=${encodeURIComponent("서울역")}&limit=1&apiKey=client-key`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().documents[0].place_name, "서울역");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json().documents[0].x, "126.9706");
assert.equal(calls.length, 2, "second request should be served from proxy cache");
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];