mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
commit
8169d1d443
16 changed files with 1619 additions and 7 deletions
|
|
@ -26,6 +26,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한강 수위 정보 조회 | 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) |
|
||||
| 한국 주식 정보 조회 | `k-skill-proxy` 경유로 KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
|
||||
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
|
||||
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
|
|
@ -76,6 +77,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
|
||||
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
|
||||
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
|
||||
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
|
||||
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /v1/han-river/water-level`
|
||||
- `GET /v1/korean-stock/search`
|
||||
- `GET /v1/korean-stock/base-info`
|
||||
- `GET /v1/korean-stock/trade-info`
|
||||
- `GET /v1/opinet/around`
|
||||
- `GET /v1/opinet/detail`
|
||||
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
|
||||
|
|
@ -34,6 +37,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
- `SEOUL_OPEN_API_KEY=...`
|
||||
- `HRFCO_OPEN_API_KEY=...`
|
||||
- `OPINET_API_KEY=...`
|
||||
- `KRX_API_KEY=...`
|
||||
- `KSKILL_PROXY_PORT=4020`
|
||||
|
||||
## 프로덕션 배포 구조
|
||||
|
|
@ -122,6 +126,23 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/detail' \
|
|||
--data-urlencode 'id=A0009905'
|
||||
```
|
||||
|
||||
한국 주식 검색 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
한국 주식 기본정보 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
AirKorea passthrough endpoint:
|
||||
|
||||
```bash
|
||||
|
|
@ -137,5 +158,6 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
|
|||
## 주의할 점
|
||||
|
||||
- upstream key는 프록시 서버에서만 관리합니다.
|
||||
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
|
||||
- client 쪽에는 upstream API key를 배포하지 않습니다.
|
||||
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.
|
||||
|
|
|
|||
81
docs/features/korean-stock-search.md
Normal file
81
docs/features/korean-stock-search.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 한국 주식 정보 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- KRX 상장 종목 검색 (`/v1/korean-stock/search`)
|
||||
- 종목 기본정보 조회 (`/v1/korean-stock/base-info`)
|
||||
- 종목 일별 시세 조회 (`/v1/korean-stock/trade-info`)
|
||||
- 종목명이 모호할 때 시장/종목코드 후보를 먼저 좁히기
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 이다.
|
||||
사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
|
||||
|
||||
upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 이 레포의 기본 사용법은 로컬 MCP 설치가 아니라 **proxy first** 다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
없음. 인터넷 연결만 있으면 된다.
|
||||
|
||||
## 추천 조회 순서
|
||||
|
||||
1. 종목명이 애매하면 `/v1/korean-stock/search?q=...` 로 후보를 먼저 찾는다.
|
||||
2. 후보에서 `market`, `code` 를 확인한다.
|
||||
3. 기본 정보가 필요하면 `/v1/korean-stock/base-info` 를 호출한다.
|
||||
4. 가격/거래량이 필요하면 `/v1/korean-stock/trade-info` 를 호출한다.
|
||||
5. 휴장일이면 `bas_dd` 를 최근 영업일로 다시 지정한다.
|
||||
|
||||
## 검색 예시
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
## 기본정보 예시
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
## 일별 시세 예시
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
## 응답 해석 팁
|
||||
|
||||
- `code` 는 보통 6자리 단축코드다.
|
||||
- `standard_code` 는 KRX 표준코드다.
|
||||
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
|
||||
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
|
||||
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
|
||||
|
||||
## 답변 템플릿 권장
|
||||
|
||||
- 종목명 / 시장 / 종목코드
|
||||
- 기준일
|
||||
- 종가 / 등락률 / 거래량 / 시가총액
|
||||
- 필요하면 상장일 / 액면가 / 상장주식수
|
||||
- 마지막 한 줄: `KRX 공식 데이터 기준이며 투자 조언은 아닙니다.`
|
||||
|
||||
## 에러/제약
|
||||
|
||||
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
|
||||
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
|
||||
- upstream KRX 오류는 502
|
||||
- 기준일에 종목을 찾지 못하면 404 `not_found`
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- 원본 MCP 서버(참고용): `https://github.com/jjlabsio/korea-stock-mcp`
|
||||
- 공식 KRX Open API: `https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`
|
||||
|
|
@ -53,6 +53,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill kakaotalk-mac \
|
||||
--skill korean-law-search \
|
||||
--skill real-estate-search \
|
||||
--skill korean-stock-search \
|
||||
--skill joseon-sillok-search \
|
||||
--skill cheap-gas-nearby \
|
||||
--skill fine-dust-location \
|
||||
|
|
@ -100,6 +101,27 @@ korean-law list
|
|||
|
||||
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
|
||||
|
||||
`korean-stock-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `KRX_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`. 자세한 사용법은 [한국 주식 정보 조회 가이드](features/korean-stock-search.md)를 본다.
|
||||
|
||||
### `korean-stock-search` proxy quickstart
|
||||
|
||||
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
|
||||
|
||||
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
|
||||
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
|
||||
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
### `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) 를 그대로 사용한다.
|
||||
|
|
@ -250,6 +272,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
|
|||
- `fine-dust-location`
|
||||
- `korean-law-search`
|
||||
- `real-estate-search`
|
||||
- `korean-stock-search`
|
||||
- `cheap-gas-nearby`
|
||||
|
||||
관련 문서:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
- 한강 수위 정보 조회 스킬 출시
|
||||
- 한국 법령 검색 스킬 출시
|
||||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ AIR_KOREA_OPEN_API_KEY=replace-me
|
|||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
```
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
|
|
@ -62,8 +62,9 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
|||
- `KSKILL_KTX_PASSWORD`
|
||||
- `LAW_OC`
|
||||
- `AIR_KOREA_OPEN_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`)를 경유하므로 사용자 쪽 키가 불필요하다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 이 값이 없으면 기본 hosted path(`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`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 공통 설정 가이드
|
||||
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
|
|
@ -41,6 +41,8 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
|||
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
|
||||
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
## 확인
|
||||
|
|
@ -65,6 +67,7 @@ bash scripts/check-setup.sh
|
|||
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
|
||||
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
|
||||
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
|
||||
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
|
|
@ -79,6 +82,7 @@ bash scripts/check-setup.sh
|
|||
- [한강 수위 정보 가이드](features/han-river-water-level.md)
|
||||
- [한국 법령 검색 가이드](features/korean-law-search.md)
|
||||
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
|
||||
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
|
||||
- [보안/시크릿 정책](security-and-secrets.md)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
|
||||
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
|
||||
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
|
||||
- korea-stock-mcp: https://github.com/jjlabsio/korea-stock-mcp
|
||||
- KRX OPEN API 메인: https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd
|
||||
- KRX 종목 기본정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info
|
||||
- KRX 일별 매매정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd
|
||||
- MOLIT 아파트 매매 실거래가 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade
|
||||
- MOLIT 아파트 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent
|
||||
- MOLIT 오피스텔 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
|
||||
|
||||
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
|
||||
|
||||
|
||||
|
|
@ -96,6 +98,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
|
||||
|
|
|
|||
202
korean-stock-search/SKILL.md
Normal file
202
korean-stock-search/SKILL.md
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
---
|
||||
name: korean-stock-search
|
||||
description: Use k-skill-proxy to search Korean listed stocks, inspect KRX base information, and fetch daily trade snapshots without asking the user to issue a KRX API key.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: finance
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Korean Stock Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 로 요청해서 KRX 상장 종목 검색, 종목 기본정보, 일별 시세를 조회한다.
|
||||
|
||||
upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 사용자는 `KRX_API_KEY` 를 발급받거나 로컬 MCP 서버를 설치할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "삼성전자 종목코드랑 시장구분 찾아줘"
|
||||
- "005930 기본정보 보여줘"
|
||||
- "SK하이닉스 20260404 종가/거래량 알려줘"
|
||||
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 미국/일본/가상자산 같은 비한국 주식 조회
|
||||
- 실시간 체결/호가/분봉 조회
|
||||
- 재무제표/공시 원문 분석 (이 스킬 범위 밖)
|
||||
- 투자 자문/매수 추천
|
||||
|
||||
## Inputs
|
||||
|
||||
- `q`: 종목명 또는 종목코드 검색어 (`search` endpoint)
|
||||
- `market`: `KOSPI` | `KOSDAQ` | `KONEX`
|
||||
- `code`: 종목코드 (보통 6자리 단축코드, 예: `005930`)
|
||||
- `bas_dd`: 기준일 `YYYYMMDD` (없으면 KST 오늘 날짜 기본값, 휴장일이면 최근 영업일로 다시 시도)
|
||||
- `limit`: 검색 결과 수 (기본 10, 최대 20)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
없음. 사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
|
||||
|
||||
## Default path
|
||||
|
||||
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
### 종목 검색
|
||||
|
||||
```http
|
||||
GET /v1/korean-stock/search?q={검색어}&bas_dd={YYYYMMDD}
|
||||
```
|
||||
|
||||
### 종목 기본정보
|
||||
|
||||
```http
|
||||
GET /v1/korean-stock/base-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
|
||||
```
|
||||
|
||||
### 종목 일별 시세
|
||||
|
||||
```http
|
||||
GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
|
||||
```
|
||||
|
||||
## Example requests
|
||||
|
||||
종목 검색:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
종목 기본정보:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
종목 일별 시세:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
## Response shape
|
||||
|
||||
### 검색 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"market": "KOSPI",
|
||||
"code": "005930",
|
||||
"standard_code": "KR7005930003",
|
||||
"name": "삼성전자",
|
||||
"short_name": "삼성전자",
|
||||
"english_name": "Samsung Electronics",
|
||||
"listed_at": "1975-06-11"
|
||||
}
|
||||
],
|
||||
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
### 기본정보 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"market": "KOSPI",
|
||||
"code": "005930",
|
||||
"standard_code": "KR7005930003",
|
||||
"name": "삼성전자",
|
||||
"short_name": "삼성전자",
|
||||
"english_name": "Samsung Electronics",
|
||||
"security_group": "주권",
|
||||
"section_type": "대형주",
|
||||
"stock_certificate_type": "보통주",
|
||||
"par_value": 100,
|
||||
"listed_shares": 5969782550
|
||||
},
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
### 일별 시세 응답
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"market": "KOSPI",
|
||||
"code": "005930",
|
||||
"standard_code": "KR7005930003",
|
||||
"base_date": "20260404",
|
||||
"name": "삼성전자",
|
||||
"close_price": 84000,
|
||||
"change_price": 1000,
|
||||
"fluctuation_rate": 1.2,
|
||||
"open_price": 83000,
|
||||
"high_price": 84500,
|
||||
"low_price": 82800,
|
||||
"trading_volume": 12345678,
|
||||
"trading_value": 1030000000000,
|
||||
"market_cap": 500000000000000
|
||||
},
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
|
||||
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
|
||||
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
|
||||
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
|
||||
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
|
||||
|
||||
## Keep the answer compact
|
||||
|
||||
- 종목명 / 시장 / 종목코드
|
||||
- 기준일
|
||||
- 종가 / 등락률 / 거래량 / 시가총액
|
||||
- 필요할 때만 상장일 / 상장주식수 / 액면가
|
||||
- 여러 후보가 나오면 상위 3~5개만 보여주고 사용자가 고르게 한다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
|
||||
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
|
||||
- upstream KRX 응답 오류면 502 응답
|
||||
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
|
||||
|
||||
## Done when
|
||||
|
||||
- 검색어가 모호하면 `search` 로 후보를 먼저 좁혔다.
|
||||
- 필요한 경우 `base-info` 와 `trade-info` 를 호출해 핵심 수치를 정리했다.
|
||||
- 사용자가 `KRX_API_KEY` 없이도 조회 가능하다는 점을 유지했다.
|
||||
- KRX 공식 데이터 기준임을 짧게 남겼다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`
|
||||
- 공식 데이터 출처: KRX Open API (`https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`)
|
||||
- 이 스킬은 read-only 조회 전용이다.
|
||||
|
|
@ -8,12 +8,16 @@
|
|||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /v1/han-river/water-level`
|
||||
- `GET /v1/korean-stock/search`
|
||||
- `GET /v1/korean-stock/base-info`
|
||||
- `GET /v1/korean-stock/trade-info`
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
|
||||
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
|
||||
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
|
||||
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
|
|
@ -44,7 +48,15 @@ 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` 으로 최신 수위/유량을 조회합니다.
|
||||
한국 주식 검색 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
```
|
||||
|
||||
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
||||
|
||||
## PM2 실행
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
300
packages/k-skill-proxy/src/krx-stock.js
Normal file
300
packages/k-skill-proxy/src/krx-stock.js
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
const KRX_MARKETS = ["KOSPI", "KOSDAQ", "KONEX"];
|
||||
|
||||
const KRX_BASE_INFO_URLS = {
|
||||
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info",
|
||||
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_isu_base_info",
|
||||
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_isu_base_info"
|
||||
};
|
||||
|
||||
const KRX_TRADE_INFO_URLS = {
|
||||
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd",
|
||||
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_bydd_trd",
|
||||
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_bydd_trd"
|
||||
};
|
||||
|
||||
function buildUrl(baseUrl, params) {
|
||||
const url = new URL(baseUrl);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function parseNumber(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).replace(/,/g, "").trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatYmd(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!/^\d{8}$/.test(raw)) {
|
||||
return raw || null;
|
||||
}
|
||||
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function getCurrentKstDate() {
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
});
|
||||
|
||||
return formatter.format(new Date()).replace(/-/g, "");
|
||||
}
|
||||
|
||||
function normalizeBaseItem(item, market) {
|
||||
const normalized = {
|
||||
market,
|
||||
code: item.ISU_SRT_CD || item.ISU_CD || null,
|
||||
standard_code: item.ISU_CD || null,
|
||||
short_code: item.ISU_SRT_CD || item.ISU_CD || null,
|
||||
name: item.ISU_NM || null,
|
||||
name_ko: item.ISU_NM || null,
|
||||
short_name: item.ISU_ABBRV || null,
|
||||
name_abbr: item.ISU_ABBRV || null,
|
||||
english_name: item.ISU_ENG_NM || null,
|
||||
name_en: item.ISU_ENG_NM || null,
|
||||
listed_at: formatYmd(item.LIST_DD),
|
||||
market_name: item.MKT_TP_NM || market,
|
||||
security_group: item.SECUGRP_NM || null,
|
||||
section_type: item.SECT_TP_NM || null,
|
||||
sector_type: item.SECT_TP_NM || null,
|
||||
stock_certificate_type: item.KIND_STKCERT_TP_NM || null,
|
||||
stock_kind: item.KIND_STKCERT_TP_NM || null,
|
||||
par_value: parseNumber(item.PARVAL),
|
||||
listed_shares: parseNumber(item.LIST_SHRS)
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTradeItem(item, market, baseItem = null) {
|
||||
const normalized = {
|
||||
market,
|
||||
code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
|
||||
short_code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
|
||||
standard_code: item.ISU_CD || baseItem?.standard_code || null,
|
||||
base_date: item.BAS_DD || null,
|
||||
date: formatYmd(item.BAS_DD),
|
||||
name: item.ISU_NM || baseItem?.name || null,
|
||||
name_ko: item.ISU_NM || baseItem?.name || null,
|
||||
market_name: item.MKT_NM || baseItem?.market_name || market,
|
||||
section_type: item.SECT_TP_NM || baseItem?.section_type || null,
|
||||
sector_type: item.SECT_TP_NM || baseItem?.section_type || null,
|
||||
close_price: parseNumber(item.TDD_CLSPRC),
|
||||
change_price: parseNumber(item.CMPPREVDD_PRC),
|
||||
fluctuation_rate: parseNumber(item.FLUC_RT),
|
||||
change_rate: parseNumber(item.FLUC_RT),
|
||||
open_price: parseNumber(item.TDD_OPNPRC),
|
||||
high_price: parseNumber(item.TDD_HGPRC),
|
||||
low_price: parseNumber(item.TDD_LWPRC),
|
||||
trading_volume: parseNumber(item.ACC_TRDVOL),
|
||||
volume: parseNumber(item.ACC_TRDVOL),
|
||||
trading_value: parseNumber(item.ACC_TRDVAL),
|
||||
traded_value: parseNumber(item.ACC_TRDVAL),
|
||||
market_cap: parseNumber(item.MKTCAP),
|
||||
listed_shares: parseNumber(item.LIST_SHRS || baseItem?.listed_shares)
|
||||
};
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function matchesCodes(item, codes) {
|
||||
if (!codes?.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return codes.some((code) => code === item.ISU_CD || code === item.ISU_SRT_CD);
|
||||
}
|
||||
|
||||
async function krxRequest(url, apiKey, fetchImpl = global.fetch) {
|
||||
if (!apiKey) {
|
||||
const error = new Error("KRX_API_KEY is not configured on the proxy server.");
|
||||
error.code = "upstream_not_configured";
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
AUTH_KEY: apiKey
|
||||
},
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`KRX API HTTP 오류 (status: ${response.status}): ${response.statusText}`);
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload.OutBlock_1)) {
|
||||
const error = new Error("KRX API 오류: 응답에 OutBlock_1 배열이 없습니다.");
|
||||
error.code = "krx_api_error";
|
||||
error.statusCode = 502;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload.OutBlock_1;
|
||||
}
|
||||
|
||||
async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [], apiKey, fetchImpl = global.fetch }) {
|
||||
const items = await krxRequest(buildUrl(KRX_BASE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
|
||||
return items
|
||||
.filter((item) => matchesCodes(item, codeList))
|
||||
.map((item) => normalizeBaseItem(item, market));
|
||||
}
|
||||
|
||||
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
|
||||
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
|
||||
|
||||
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
|
||||
if (directlyMatched.length > 0) {
|
||||
return directlyMatched.map((item) => normalizeTradeItem(item, market));
|
||||
}
|
||||
|
||||
const baseItems = await fetchBaseInfo({ market, basDd, codeList, apiKey, fetchImpl });
|
||||
if (baseItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const standardCodes = new Set(baseItems.map((item) => item.standard_code).filter(Boolean));
|
||||
const baseByStandardCode = new Map(baseItems.map((item) => [item.standard_code, item]));
|
||||
|
||||
return tradeItems
|
||||
.filter((item) => standardCodes.has(item.ISU_CD) || baseByStandardCode.has(item.ISU_CD))
|
||||
.map((item) => normalizeTradeItem(item, market, baseByStandardCode.get(item.ISU_CD)));
|
||||
}
|
||||
|
||||
function tokenize(query) {
|
||||
return String(query)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function scoreSearchMatch(item, query) {
|
||||
const raw = String(query).trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const haystacks = [
|
||||
item.code,
|
||||
item.standard_code,
|
||||
item.name,
|
||||
item.short_name,
|
||||
item.english_name
|
||||
].map((value) => String(value || "").toLowerCase());
|
||||
|
||||
if (haystacks.some((value) => value === raw)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
const tokens = tokenize(raw);
|
||||
if (tokens.length > 0 && tokens.every((token) => haystacks.some((value) => value.includes(token)))) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
|
||||
return `krx-base-info:${market}:${basDd}`;
|
||||
}
|
||||
|
||||
async function fetchBaseInfoSnapshot({
|
||||
market,
|
||||
basDd,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch,
|
||||
cache = null,
|
||||
cacheTtlMs = 0
|
||||
}) {
|
||||
const cacheKey = cache ? buildBaseInfoSnapshotCacheKey({ market, basDd }) : null;
|
||||
if (cacheKey) {
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const items = await fetchBaseInfo({ market, basDd, apiKey, fetchImpl });
|
||||
|
||||
if (cacheKey) {
|
||||
cache.set(cacheKey, items, cacheTtlMs);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function searchStocks({
|
||||
query,
|
||||
basDd = getCurrentKstDate(),
|
||||
market = null,
|
||||
limit = 10,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch,
|
||||
cache = null,
|
||||
cacheTtlMs = 0
|
||||
}) {
|
||||
const markets = market ? [market] : KRX_MARKETS;
|
||||
const settledResults = await Promise.allSettled(markets.map(async (entryMarket) => ({
|
||||
market: entryMarket,
|
||||
items: await fetchBaseInfoSnapshot({
|
||||
market: entryMarket,
|
||||
basDd,
|
||||
apiKey,
|
||||
fetchImpl,
|
||||
cache,
|
||||
cacheTtlMs
|
||||
})
|
||||
})));
|
||||
|
||||
const successfulResults = settledResults
|
||||
.filter((result) => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
|
||||
if (successfulResults.length === 0) {
|
||||
const firstFailure = settledResults.find((result) => result.status === "rejected");
|
||||
throw firstFailure?.reason || new Error("KRX search failed for every market.");
|
||||
}
|
||||
|
||||
return {
|
||||
items: successfulResults
|
||||
.flatMap(({ market: entryMarket, items }) =>
|
||||
items
|
||||
.map((item) => ({ ...item, market: item.market || entryMarket, score: scoreSearchMatch(item, query) }))
|
||||
.filter((item) => item.score >= 0)
|
||||
)
|
||||
.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name, "ko"))
|
||||
.slice(0, limit)
|
||||
.map(({ score, ...item }) => item)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KRX_MARKETS,
|
||||
fetchBaseInfo,
|
||||
fetchTradeInfo,
|
||||
getCurrentKstDate,
|
||||
searchStocks
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ const Fastify = require("fastify");
|
|||
const { fetchFineDustReport } = require("./airkorea");
|
||||
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
|
||||
const { fetchWaterLevelReport } = require("./hrfco");
|
||||
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
|
||||
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
||||
const { searchRegionCode } = require("./region-lookup");
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
|
|
@ -51,6 +52,7 @@ function buildConfig(env = process.env) {
|
|||
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
|
||||
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
|
||||
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
|
||||
krxApiKey: trimOrNull(env.KRX_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)
|
||||
|
|
@ -91,7 +93,7 @@ function buildRateLimiter(config) {
|
|||
const state = new Map();
|
||||
|
||||
return function rateLimit(request, reply) {
|
||||
const key = trimOrNull(request.headers["cf-connecting-ip"]) || request.ip || "unknown";
|
||||
const key = request.ip || "unknown";
|
||||
const now = Date.now();
|
||||
const current = state.get(key);
|
||||
|
||||
|
|
@ -236,6 +238,68 @@ function normalizeHanRiverWaterLevelQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeKrxMarket(value) {
|
||||
const normalized = trimOrNull(value)?.toUpperCase();
|
||||
if (!normalized || !KRX_MARKETS.includes(normalized)) {
|
||||
throw new Error(`Provide market as one of: ${KRX_MARKETS.join(", ")}.`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeKoreanStockDate(value) {
|
||||
const normalized = trimOrNull(value) || getCurrentKstDate();
|
||||
if (!/^\d{8}$/.test(normalized)) {
|
||||
throw new Error("Provide bas_dd/date as YYYYMMDD.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeKoreanStockCodes(value) {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const codes = values
|
||||
.flatMap((entry) => String(entry || "").split(","))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (codes.length === 0) {
|
||||
throw new Error("Provide code/stockCode/codes.");
|
||||
}
|
||||
|
||||
return [...new Set(codes)];
|
||||
}
|
||||
|
||||
function normalizeKoreanStockSearchQuery(query) {
|
||||
const q = trimOrNull(query.q ?? query.query);
|
||||
if (!q) {
|
||||
throw new Error("Provide q/query.");
|
||||
}
|
||||
|
||||
const basDd = normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date);
|
||||
const marketValue = trimOrNull(query.market);
|
||||
const limit = parseInteger(query.limit, 10);
|
||||
|
||||
if (limit < 1 || limit > 20) {
|
||||
throw new Error("limit must be between 1 and 20.");
|
||||
}
|
||||
|
||||
return {
|
||||
q,
|
||||
basDd,
|
||||
market: marketValue ? normalizeKrxMarket(marketValue) : null,
|
||||
limit
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeKoreanStockLookupQuery(query) {
|
||||
return {
|
||||
market: normalizeKrxMarket(query.market),
|
||||
code: normalizeKoreanStockCodes(query.code ?? query.codes ?? query.codeList ?? query.stockCode ?? query.stock_code)[0],
|
||||
basDd: normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function isAllowedAirKoreaRoute(service, operation) {
|
||||
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
|
||||
|
|
@ -437,7 +501,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
|
||||
hrfcoConfigured: Boolean(config.hrfcoApiKey),
|
||||
opinetConfigured: Boolean(config.opinetApiKey),
|
||||
molitConfigured: Boolean(config.molitApiKey)
|
||||
molitConfigured: Boolean(config.molitApiKey),
|
||||
krxConfigured: Boolean(config.krxApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -993,6 +1058,283 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/korean-stock/search", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeKoreanStockSearchQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "korean-stock-search",
|
||||
q: normalized.q.toLowerCase(),
|
||||
basDd: normalized.basDd,
|
||||
market: normalized.market,
|
||||
limit: normalized.limit
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.krxApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "KRX_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
const result = await searchStocks({
|
||||
query: normalized.q,
|
||||
basDd: normalized.basDd,
|
||||
market: normalized.market,
|
||||
limit: normalized.limit,
|
||||
apiKey: config.krxApiKey,
|
||||
cache,
|
||||
cacheTtlMs: config.cacheTtlMs
|
||||
});
|
||||
items = result.items;
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
items,
|
||||
query: {
|
||||
q: normalized.q,
|
||||
bas_dd: normalized.basDd,
|
||||
market: normalized.market,
|
||||
limit: normalized.limit
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/korean-stock/base-info", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeKoreanStockLookupQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "korean-stock-base-info",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.krxApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "KRX_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = await fetchBaseInfo({
|
||||
market: normalized.market,
|
||||
basDd: normalized.basDd,
|
||||
codeList: [normalized.code],
|
||||
apiKey: config.krxApiKey
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: `기준일 ${normalized.basDd} 에 ${normalized.market} 시장 종목 ${normalized.code} 을(를) 찾지 못했습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
items,
|
||||
item: items[0],
|
||||
query: {
|
||||
market: normalized.market,
|
||||
code: normalized.code,
|
||||
codes: [normalized.code],
|
||||
bas_dd: normalized.basDd
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/korean-stock/trade-info", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeKoreanStockLookupQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "korean-stock-trade-info",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.krxApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "KRX_API_KEY is not configured on the proxy server.",
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
items = await fetchTradeInfo({
|
||||
market: normalized.market,
|
||||
basDd: normalized.basDd,
|
||||
codeList: [normalized.code],
|
||||
apiKey: config.krxApiKey
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: `기준일 ${normalized.basDd} 에 ${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
items,
|
||||
item: items[0],
|
||||
query: {
|
||||
market: normalized.market,
|
||||
code: normalized.code,
|
||||
codes: [normalized.code],
|
||||
bas_dd: normalized.basDd
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -1035,6 +1377,8 @@ module.exports = {
|
|||
normalizeBlueRibbonNearbyQuery,
|
||||
normalizeFineDustQuery,
|
||||
normalizeHanRiverWaterLevelQuery,
|
||||
normalizeKoreanStockLookupQuery,
|
||||
normalizeKoreanStockSearchQuery,
|
||||
normalizeOpinetAroundQuery,
|
||||
normalizeOpinetDetailQuery,
|
||||
normalizeRealEstateQuery,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,511 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
|
|||
assert.equal(body.ok, true);
|
||||
assert.equal(body.auth.tokenRequired, false);
|
||||
assert.equal(body.upstreams.airKoreaConfigured, false);
|
||||
assert.equal(body.upstreams.krxConfigured, false);
|
||||
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
|
||||
});
|
||||
|
||||
test("health endpoint reports KRX upstream status when configured", async (t) => {
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/health"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().upstreams.krxConfigured, true);
|
||||
});
|
||||
|
||||
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push({ url: text, headers: options.headers });
|
||||
|
||||
if (text.includes("stk_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
ISU_ABBRV: "삼성전자",
|
||||
ISU_ENG_NM: "Samsung Electronics",
|
||||
LIST_DD: "19750611",
|
||||
MKT_TP_NM: "KOSPI",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "대형주",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "100",
|
||||
LIST_SHRS: "5969782550"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-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/korean-stock/search?q=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&bas_dd=20260404"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?query=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&date=20260404&limit=10"
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(fetchCalls.length, 3);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
assert.equal(first.json().items[0].market, "KOSPI");
|
||||
assert.equal(first.json().items[0].code, "005930");
|
||||
assert.equal(first.json().items[0].name, "삼성전자");
|
||||
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
|
||||
assert.match(fetchCalls[0].url, /basDd=20260404/);
|
||||
assert.equal(fetchCalls[0].headers.AUTH_KEY, "krx-key");
|
||||
});
|
||||
|
||||
test("korean stock search rate limit does not trust spoofed cf-connecting-ip on direct requests", async (t) => {
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KSKILL_PROXY_RATE_LIMIT_MAX: "1"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const first = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
|
||||
headers: {
|
||||
"cf-connecting-ip": "1.1.1.1"
|
||||
}
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
|
||||
headers: {
|
||||
"cf-connecting-ip": "2.2.2.2"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 503);
|
||||
assert.equal(first.json().error, "upstream_not_configured");
|
||||
assert.equal(second.statusCode, 429);
|
||||
assert.equal(second.json().error, "rate_limited");
|
||||
});
|
||||
|
||||
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push({ url: text, headers: options.headers });
|
||||
|
||||
if (text.includes("stk_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
ISU_ABBRV: "삼성전자",
|
||||
ISU_ENG_NM: "Samsung Electronics",
|
||||
LIST_DD: "19750611",
|
||||
MKT_TP_NM: "KOSPI",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "대형주",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "100",
|
||||
LIST_SHRS: "5969782550"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("ksq_isu_base_info")) {
|
||||
return new Response("boom", {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error"
|
||||
});
|
||||
}
|
||||
|
||||
if (text.includes("knx_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().items.length, 1);
|
||||
assert.equal(response.json().items[0].market, "KOSPI");
|
||||
assert.equal(response.json().items[0].code, "005930");
|
||||
assert.equal(response.json().items[0].name, "삼성전자");
|
||||
assert.equal(fetchCalls.length, 3);
|
||||
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
|
||||
});
|
||||
|
||||
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push({ url: text, headers: options.headers });
|
||||
|
||||
if (text.includes("stk_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
ISU_ABBRV: "삼성전자",
|
||||
ISU_ENG_NM: "Samsung Electronics",
|
||||
LIST_DD: "19750611",
|
||||
MKT_TP_NM: "KOSPI",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "대형주",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "100",
|
||||
LIST_SHRS: "5969782550"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("ksq_isu_base_info") || text.includes("knx_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key",
|
||||
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const byKoreanName = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
|
||||
});
|
||||
const byEnglishName = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=Samsung&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(byKoreanName.statusCode, 200);
|
||||
assert.equal(byEnglishName.statusCode, 200);
|
||||
assert.equal(byKoreanName.json().items[0].code, "005930");
|
||||
assert.equal(byEnglishName.json().items[0].code, "005930");
|
||||
assert.equal(fetchCalls.length, 3);
|
||||
});
|
||||
|
||||
test("korean stock base-info endpoint returns 503 when proxy server lacks KRX API key", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("korean stock base-info endpoint normalizes upstream KRX fields", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl;
|
||||
let calledHeaders;
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calledUrl = String(url);
|
||||
calledHeaders = options.headers;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
ISU_ABBRV: "삼성전자",
|
||||
ISU_ENG_NM: "Samsung Electronics",
|
||||
LIST_DD: "19750611",
|
||||
MKT_TP_NM: "KOSPI",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "대형주",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "100",
|
||||
LIST_SHRS: "5969782550"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
|
||||
assert.match(calledUrl, /stk_isu_base_info/);
|
||||
assert.match(calledUrl, /basDd=20260404/);
|
||||
assert.equal(calledHeaders.AUTH_KEY, "krx-key");
|
||||
assert.equal(response.json().item.code, "005930");
|
||||
assert.equal(response.json().item.name, "삼성전자");
|
||||
assert.equal(response.json().item.listed_shares, 5969782550);
|
||||
});
|
||||
|
||||
test("korean stock trade-info endpoint caches successful responses", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
let calledUrl;
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls += 1;
|
||||
calledUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
BAS_DD: "20260404",
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
MKT_NM: "KOSPI",
|
||||
SECT_TP_NM: "대형주",
|
||||
TDD_CLSPRC: "84000",
|
||||
CMPPREVDD_PRC: "1000",
|
||||
FLUC_RT: "1.20",
|
||||
TDD_OPNPRC: "83000",
|
||||
TDD_HGPRC: "84500",
|
||||
TDD_LWPRC: "82800",
|
||||
ACC_TRDVOL: "12345678",
|
||||
ACC_TRDVAL: "1030000000000",
|
||||
MKTCAP: "500000000000000",
|
||||
LIST_SHRS: "5969782550"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-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/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/trade-info?market=KOSPI&stockCode=005930&date=20260404"
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
assert.equal(first.json().item.close_price, 84000);
|
||||
assert.equal(first.json().item.trading_value, 1030000000000);
|
||||
});
|
||||
|
||||
test("korean stock trade-info endpoint does not relabel an unmatched single-row upstream response", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push(text);
|
||||
|
||||
if (text.includes("stk_bydd_trd")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
BAS_DD: "20260404",
|
||||
ISU_CD: "KR7000660001",
|
||||
ISU_NM: "하이트진로",
|
||||
MKT_NM: "KOSPI",
|
||||
SECT_TP_NM: "중형주",
|
||||
TDD_CLSPRC: "21000"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("stk_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 404);
|
||||
assert.equal(response.json().error, "not_found");
|
||||
assert.equal(fetchCalls.length, 2);
|
||||
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
|
||||
});
|
||||
|
||||
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
let providerCalls = 0;
|
||||
const app = buildServer({
|
||||
|
|
@ -603,6 +1105,48 @@ const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
|||
</body>
|
||||
</response>`;
|
||||
|
||||
const SAMPLE_KRX_BASE_INFO = {
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
ISU_ABBRV: "삼성전자",
|
||||
ISU_ENG_NM: "Samsung Electronics",
|
||||
LIST_DD: "19750611",
|
||||
MKT_TP_NM: "KOSPI",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "전기전자",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "100",
|
||||
LIST_SHRS: "5,919,638,922"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const SAMPLE_KRX_TRADE_INFO = {
|
||||
OutBlock_1: [
|
||||
{
|
||||
BAS_DD: "20260404",
|
||||
ISU_CD: "KR7005930003",
|
||||
ISU_SRT_CD: "005930",
|
||||
ISU_NM: "삼성전자",
|
||||
MKT_NM: "KOSPI",
|
||||
SECT_TP_NM: "전기전자",
|
||||
TDD_CLSPRC: "85,000",
|
||||
CMPPREVDD_PRC: "1,200",
|
||||
FLUC_RT: "1.43",
|
||||
TDD_OPNPRC: "84,100",
|
||||
TDD_HGPRC: "85,400",
|
||||
TDD_LWPRC: "83,900",
|
||||
ACC_TRDVOL: "12,345,678",
|
||||
ACC_TRDVAL: "1,045,678,900,000",
|
||||
MKTCAP: "503,169,308,370,000",
|
||||
LIST_SHRS: "5,919,638,922"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
test("real estate region-code endpoint returns matching codes", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
|
|
|
|||
|
|
@ -1467,6 +1467,75 @@ test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the korean-stock-search skill and proxy-backed KRX approach", () => {
|
||||
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 setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-stock-search.md");
|
||||
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
|
||||
const skillPath = path.join(repoRoot, "korean-stock-search", "SKILL.md");
|
||||
const skill = read(path.join("korean-stock-search", "SKILL.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
const packageJson = readJson("package.json");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-stock-search.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected korean-stock-search/SKILL.md to exist");
|
||||
|
||||
assert.match(readme, /\| 한국 주식 정보 조회 \|/);
|
||||
assert.match(readme, /\[한국 주식 정보 조회 가이드\]\(docs\/features\/korean-stock-search\.md\)/);
|
||||
assert.match(install, /--skill korean-stock-search/);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(doc, /\/v1\/korean-stock\/search/);
|
||||
assert.match(doc, /\/v1\/korean-stock\/base-info/);
|
||||
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
|
||||
assert.match(doc, /KRX_API_KEY/);
|
||||
assert.match(doc, /사용자.*KRX_API_KEY.*(불필요|준비할 필요가 없)/u);
|
||||
assert.doesNotMatch(doc, /packages\/korean-stock-search/);
|
||||
assert.doesNotMatch(doc, /python-packages\/korean-stock-search/);
|
||||
}
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /KRX_API_KEY/);
|
||||
}
|
||||
|
||||
for (const doc of [proxyReadme, proxyDoc]) {
|
||||
assert.match(doc, /\/v1\/korean-stock\/search/);
|
||||
assert.match(doc, /\/v1\/korean-stock\/base-info/);
|
||||
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
|
||||
}
|
||||
|
||||
assert.match(sources, /korea-stock-mcp: https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
|
||||
assert.match(roadmap, /한국 주식 정보 조회 스킬 출시/);
|
||||
assert.ok(
|
||||
!packageJson.workspaces.some((workspace) => workspace.includes("korean-stock-search")),
|
||||
"expected no repo workspace to be added for korean-stock-search",
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-stock-search")), false);
|
||||
});
|
||||
|
||||
test("korean-stock-search skill stays proxy-first and does not require local MCP install", () => {
|
||||
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
|
||||
const skill = read(path.join("korean-stock-search", "SKILL.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/korean-stock/);
|
||||
assert.match(doc, /curl/);
|
||||
assert.match(doc, /proxy.*서버.*KRX_API_KEY|KRX_API_KEY.*proxy.*서버/u);
|
||||
assert.doesNotMatch(doc, /npx\s+(?:-y|--yes)\s+korea-stock-mcp/);
|
||||
assert.doesNotMatch(doc, /codex mcp add/);
|
||||
assert.doesNotMatch(doc, /claude_desktop_config\.json/);
|
||||
assert.doesNotMatch(doc, /DART_API_KEY/);
|
||||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue