feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬 + 프록시 라우트 4종 (#259)

* feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬과 프록시 라우트 추가

공공데이터포털 dataset 15125364 (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 의
4개 endpoint 를 k-skill-proxy 경유로 조회하는 스킬을 추가한다.

- 신규 라우트: GET /v1/kstartup/{business-info,announcements,contents,statistics}
  - 각각 getBusinessInformation01/getAnnouncementInformation01/getContentInformation01/
    getStatisticalInformation01 으로 중계
  - ServiceKey 는 서버 측 DATA_GO_KR_API_KEY 로 주입, returnType=json 강제
  - 정상 응답만 캐시, data.go.kr 에러 envelope (resultCode != "00", errMsg 등) 은 캐시 우회
- helper: kstartup-search/scripts/run_kstartup.py (stdlib only)
  - 일반 조회는 hosted proxy 사용 → 사용자 키 불필요
  - --direct 옵션은 사용자가 본인 KSKILL_KSTARTUP_API_KEY (혹은 DATA_GO_KR_API_KEY) 로
    upstream 직접 호출 + --dry-run 시 키 redact
- 입력 검증: page/perPage 정수·범위, YYYYMMDD 날짜 + 시작일 ≤ 종료일, Y/N 대문자화,
  텍스트 필드 길이 상한, biz_yr 4자리
- 테스트: k-skill-proxy 서버 테스트 10건 신규 (normalizer, 라우트, 캐시 분리,
  returnType=json 강제, 503/400/502, 키 누수 회귀), Python unittest 13건
- 문서: SKILL.md, docs/features/kstartup-search.md, README 표/리스트,
  docs/sources.md, .changeset/kstartup-search.md (k-skill-proxy minor)

* docs(kstartup-search): docs/setup·security·k-skill-setup·proxy README 에 K-Startup 항목 추가

seoul-density · KOSIS · NTS 선례와 동일한 위치·문구로 다음을 보강한다.

- docs/setup.md: dotenv 예시에 KSKILL_KSTARTUP_API_KEY 추가, credential 표에 K-Startup 행 추가, "다음에 볼 문서" 리스트 추가
- docs/security-and-secrets.md: standard variable names 에 KSKILL_KSTARTUP_API_KEY 추가, hosted proxy 사용 스킬 목록·proxy 운영 prose 에 K-Startup 추가, dotenv 예시 추가
- k-skill-setup/SKILL.md: credential resolution prose 와 시크릿 요약 표에 K-Startup 안내 추가
- packages/k-skill-proxy/README.md: 라우트 목록에 /v1/kstartup/{business-info,announcements,contents,statistics} 추가
- docs/features/k-skill-proxy.md: 라우트 목록에 같은 4개 추가

* fix(kstartup-search): strict calendar-date validation in Python helper

validate_yyyymmdd() previously only checked month in [1,12] and day in [1,31],
which accepted impossible dates like 20240230 or 20240431 in --direct mode.
The proxy-side normalizer in packages/k-skill-proxy/src/kstartup.js already
uses Date.UTC() to reject such inputs, so this aligns the --direct path with
the proxy path and eliminates validator drift.

Uses datetime.date(year, month, day) and raises HelperError on ValueError.

Adds regression test covering impossible calendar dates (Feb 30, Apr 31,
month 13, day 0) and the leap-year boundary (2024-02-29 valid, 2023-02-29
not).

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>
This commit is contained in:
배기민 2026-05-18 11:43:33 +09:00 committed by GitHub
commit 540e80b804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1408 additions and 6 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).

View file

@ -40,6 +40,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
@ -154,6 +155,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)

View file

@ -36,6 +36,10 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/data4library/book-detail` (도서관 정보나루 도서 상세 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` (도서 소장 도서관 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` (도서관별 도서 소장여부, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/kstartup/business-info` (창업진흥원 K-Startup 통합공고 지원사업 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/announcements` (창업진흥원 K-Startup 지원사업 공고 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/contents` (창업진흥원 K-Startup 창업 콘텐츠 정보, `DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/statistics` (창업진흥원 K-Startup 통계보고서 정보, `DATA_GO_KR_API_KEY`)
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수

View file

@ -0,0 +1,60 @@
# 창업진흥원 K-Startup 조회 가이드
공공데이터포털 데이터셋 `15125364` (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 기반 4개 endpoint를 `k-skill-proxy` 경유로 조회한다. **조회 전용** 이며 사업 신청·결제·계좌 연결은 자동화하지 않는다.
스킬 이름: `kstartup-search`
호출 helper: `kstartup-search/scripts/run_kstartup.py`
## 어떤 데이터를 조회하나
| 서브커맨드 | upstream operation | 설명 |
| --- | --- | --- |
| `business-info` | `getBusinessInformation01` | 통합공고 지원사업 정보 (예산, 규모, 수행기관, 사업절차, 문의처) |
| `announcements` | `getAnnouncementInformation01` | 지원사업 공고 정보 (공고명, 접수기간, 지역, 신청대상, 모집진행여부 등) |
| `contents` | `getContentInformation01` | 창업관련 콘텐츠 (공지·뉴스·우수사례) |
| `statistics` | `getStatisticalInformation01` | 창업관련 통계보고서 |
`announcements` 가 가장 활용도 높다. 지역·대상·기간·모집 진행 여부로 필터링해 답변할 공고 후보를 좁히고, 자세한 신청 절차는 응답의 `detl_pg_url` 로 사용자가 K-Startup 사이트에서 직접 확인한다.
> **주의**: `supt_regin`은 라이브 호출에서 upstream이 서버 측에서 적용하지 않는 사례가 관측됐다 (서울만 요청해도 타 지역 공고가 섞여 돌아온다). 지역 필터가 중요한 답변이라면 helper가 받은 응답 JSON을 client에서 `supt_regin` 으로 한 번 더 거른다.
## 사용자 시크릿
- 일반 조회는 hosted proxy(`https://k-skill-proxy.nomadamas.org`)가 K-Startup 인증키를 서버 측에서 주입한다. 사용자에게 키를 요구하지 않는다.
- `--direct` 사용 시에만 `KSKILL_KSTARTUP_API_KEY` (또는 `DATA_GO_KR_API_KEY` fallback) 가 필요하다.
- 자세한 credential resolution order 는 [공통 설정 가이드](../setup.md) 와 [보안/시크릿 정책](../security-and-secrets.md) 참고.
## 예시
```bash
# 서울 모집 중 공고 5건 (hosted proxy 사용, 사용자 키 불필요)
python3 kstartup-search/scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
# 2024년 사업화 분야 통합공고
python3 kstartup-search/scripts/run_kstartup.py business-info \
--biz-yr 2024 --biz-category-cd cmrczn_Tab3
# 정책/공지 콘텐츠 dry-run (인증 호출 없이 URL 검증만)
python3 kstartup-search/scripts/run_kstartup.py contents \
--clss-cd notice_matr --per-page 10 --dry-run
# 본인 키로 직접 호출
python3 kstartup-search/scripts/run_kstartup.py announcements \
--supt-regin 부산광역시 --direct
```
## 실패 모드 요약
- `400 bad_request`: 잘못된 날짜/Y·N/페이지 범위, 시작일 > 종료일 등 입력 검증 실패.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없거나 `15125364` 활용신청이 미승인 상태.
- `502 upstream_error`: data.go.kr이 `resultCode != "00"` 또는 `errMsg` 를 반환 (API 키 미등록·만료·IP 미등록·요청 초과 등).
- 빈 `data` 배열: 필터에 맞는 공고나 콘텐츠가 없는 경우 → 키워드·지역·대상 범위를 완화한다.
- 데이터 갱신 주기: 공식 서비스설계서는 **일 1회**, 공공데이터포털 dataset 메타데이터에는 "실시간" 으로 표기돼 있다. 두 표면이 일치하지 않으니 분 단위 마감 시계열에는 쓰지 말고, 최종 마감·접수 상태는 응답의 `detl_pg_url` 에서 직접 확인한다.
## 한도와 출처
- 일 호출 한도: 개발계정 10,000, 운영계정 활용사례 등록 시 증가 가능.
- 라이선스: 이용허락범위 제한 없음 (data.go.kr 명시).
- 공식 표면: `https://www.data.go.kr/data/15125364/openapi.do`
- 서비스 URL: `https://apis.data.go.kr/B552735/kisedKstartupService01`

View file

@ -28,6 +28,8 @@ KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. direct/bigdata 또는 proxy 서버 운영 때만 필요.
KSKILL_KOSIS_API_KEY=replace-me
# 일반 K-Startup 조회는 hosted proxy 사용. --direct 호출 때만 필요.
KSKILL_KSTARTUP_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -36,7 +38,7 @@ KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `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 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크, 창업진흥원 K-Startup 조회도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -70,6 +72,7 @@ KSKILL_PROXY_BASE_URL=
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
- `KSKILL_KOSIS_API_KEY` (KOSIS `bigdata`/`--direct`, 또는 proxy 서버 `KOSIS_API_KEY` 대체 env)
- `KSKILL_KSTARTUP_API_KEY` (창업진흥원 K-Startup `--direct` 호출용. 일반 조회는 hosted proxy의 `DATA_GO_KR_API_KEY` 가 처리)
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
@ -77,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
- `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 운영자 문맥에서만 서버에 넣는다. 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`)를 사용한다.
`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` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `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

@ -28,6 +28,8 @@ KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# KOSIS 일반 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
# 창업진흥원 K-Startup 일반 조회는 hosted proxy 사용. --direct 때만 채운다.
KSKILL_KSTARTUP_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -95,6 +97,7 @@ bash scripts/check-setup.sh
| 도서관 도서 조회 | 사용자 시크릿 불필요 (프록시에 `DATA4LIBRARY_AUTH_KEY`가 설정된 hosted/self-host 사용) |
| 의약품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
| 식품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`가 설정된 hosted/self-host 사용) |
| 창업진흥원 K-Startup 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용; `--direct` 호출 때만 `KSKILL_KSTARTUP_API_KEY`) |
## 다음에 볼 문서
@ -120,6 +123,7 @@ bash scripts/check-setup.sh
- [도서관 도서 조회 가이드](features/library-book-search.md)
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
- [창업진흥원 K-Startup 조회 가이드](features/kstartup-search.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -203,3 +203,7 @@
- 도서관 정보나루 도서 상세 endpoint: https://data4library.kr/api/srchDtlList
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
- 공공데이터포털 데이터셋(창업진흥원 K-Startup 조회서비스): https://www.data.go.kr/data/15125364/openapi.do
- K-Startup Open API base URL: https://apis.data.go.kr/B552735/kisedKstartupService01 — `k-skill-proxy``/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics` 가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01` 로 중계한다 (returnType=json 고정, ServiceKey 서버 측 주입)
- K-Startup 공식 포털: https://www.k-startup.go.kr — 응답의 `detl_pg_url` 가 가리키는 사용자 진입점

View file

@ -101,6 +101,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 호출하고, `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 는 프록시 서버에서만 주입/관리하므로 사용자 쪽에 둘 필요가 없다.
창업진흥원 K-Startup 조회는 `k-skill-proxy``/v1/kstartup/*` 라우트를 호출하고, `ServiceKey`(`DATA_GO_KR_API_KEY`)는 프록시 서버에서만 주입/관리하므로 일반 조회는 사용자 쪽에 키가 필요 없다. `--direct` 호출을 쓸 때만 `KSKILL_KSTARTUP_API_KEY` 를 채운다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
@ -123,6 +125,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 도서관 도서 조회: 사용자 시크릿 불필요 (`DATA4LIBRARY_AUTH_KEY`는 proxy 서버만)
- 의약품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만)
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
- 창업진흥원 K-Startup 조회: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만; `--direct` 호출 때만 `KSKILL_KSTARTUP_API_KEY`)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
- 서울 실시간 혼잡도: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)

192
kstartup-search/SKILL.md Normal file
View file

@ -0,0 +1,192 @@
---
name: kstartup-search
description: 공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 창업진흥원 K-Startup 조회
## What this skill does
공공데이터포털의 **창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스** (`kisedKstartupService01`, dataset `15125364`)를 `k-skill-proxy` 경유로 호출해 다음 4개 endpoint를 조회한다.
- `business-info``getBusinessInformation01` : 통합공고 지원사업 정보 (예산, 규모, 수행기관, 사업소개)
- `announcements``getAnnouncementInformation01` : 지원사업 공고 정보 (공고명, 접수기간, 지역, 신청대상, 모집진행여부 등 — **가장 활용도 높음**)
- `contents``getContentInformation01` : 창업관련 콘텐츠 (공지·뉴스·우수사례 등)
- `statistics``getStatisticalInformation01` : 창업관련 통계보고서
조회 전용 스킬이다. 사업 신청·지원금 청구·콘텐츠 게시 같은 쓰기 동작은 다루지 않는다.
## When to use
- "이번 달 마감 예정인 청년 창업지원 공고 찾아줘"
- "서울 소재 모집 진행 중인 1인 창조기업 지원사업 알려줘"
- "K-Startup에서 사업화 단계 통합공고 사업 목록 뽑아줘"
- "창업진흥원 최신 통계보고서 5건 보여줘"
## When not to use
- 사업 신청·결제·자동 지원·계좌 연계 같은 쓰기 동작 (지원 화면은 사용자가 K-Startup 웹에서 직접 진행한다)
- K-Startup 외부 사이트(중기부, 창조경제혁신센터, 지자체 단독 공고) 조회 — 통합공고에 등록된 일부만 K-Startup API로 노출된다
- 마감일·모집 상태를 분 단위로 추적해야 하는 작업 — 데이터 갱신은 공식 서비스설계서 기준 **일 1회**다 (공공데이터포털 dataset 메타데이터에는 "실시간"으로 표기되지만 두 표면이 일치하지 않는다)
## Prerequisites
- 인터넷 연결
- `python3` (stdlib only)
- 설치된 스킬 안의 `scripts/run_kstartup.py`
- hosted/self-host `k-skill-proxy``/v1/kstartup/*` 라우트 접근 가능 (4개)
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org`.
- `KSKILL_KSTARTUP_API_KEY``--direct`로 K-Startup을 직접 호출할 때만 필요. 공공데이터포털에서 `창업진흥원_K-Startup(사업소개,사업공고, 콘텐츠 등)_조회서비스` (`15125364`) 활용신청이 본인 계정으로 승인돼 있어야 한다(자동승인, 무료).
- 프록시 운영자는 `DATA_GO_KR_API_KEY` 환경변수에 같은 조건의 키를 두고 활용신청을 추가해 둔다.
### Credential resolution order (`--direct` 전용)
1. 이미 환경변수에 있으면 그대로 사용한다.
2. 에이전트 vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)에서 꺼내 환경변수로 주입.
3. `~/.config/k-skill/secrets.env` (plain dotenv, 권한 `0600`).
4. 아무것도 없으면 사용자에게 묻고 2 또는 3에 저장.
일반 조회 helper는 proxy URL만 읽고, K-Startup 인증키는 프록시 서버에서만 주입한다. `--direct` 호출에서만 `KSKILL_KSTARTUP_API_KEY`를 읽는다.
## Inputs
서브커맨드: `business-info`, `announcements`, `contents`, `statistics`.
공통 옵션:
- `--page N` (기본 1, ≥ 1)
- `--per-page N` (기본 10, 1100)
- `--text` 사람용 요약 / `--json` 구조화 결과(기본)
- `--dry-run` 인증키 없이 요청 URL/파라미터만 출력
- `--timeout N` HTTP 타임아웃 초 (기본 30)
- `--proxy-base-url URL` 기본 hosted proxy 대신 self-host/alternate proxy
- `--direct` proxy 우회, `KSKILL_KSTARTUP_API_KEY`로 직접 호출
서브커맨드별 필터:
- `business-info`
- `--biz-yr 2024` (사업 연도, 4자리)
- `--biz-category-cd cmrczn_Tab3` (사업 구분 코드)
- `--supt-biz-titl-nm "1인 창조기업"` (사업 명)
- `announcements`
- `--biz-pbanc-nm "키워드"` (지원 사업 공고 명)
- `--supt-regin 서울특별시` (지역명. **K-Startup upstream이 이 필터를 서버 측에서 적용하지 않는 사례가 있다** — 응답을 받은 뒤 client에서 `supt_regin` 으로 한 번 더 거른다)
- `--supt-biz-clsfc 사업화` (지원 분야)
- `--pbanc-rcpt-bgng-dt 20240101` / `--pbanc-rcpt-end-dt 20241231` (공고 접수 시작/종료, YYYYMMDD)
- `--aply-trgt 일반인,예비창업자` (신청 대상)
- `--biz-enyy 예비창업자,1년미만` (창업 기간)
- `--biz-trgt-age "만 20세 이상 ~ 만 39세 이하"` (대상 연령)
- `--rcrt-prgs-yn Y|N` (모집진행여부)
- `--intg-pbanc-yn Y|N` (통합 공고 여부)
- `contents`
- `--clss-cd notice_matr` (콘텐츠 구분 코드: notice_matr 등)
- `--titl-nm "공모전"` (제목 키워드)
- `statistics`
- `--titl-nm "창업기업 실태조사"` (통계 자료 명)
- `--file-nm "PDF"` (파일 명/내용 키워드)
## Workflow
### 1. Ensure proxy access is available
일반 조회는 기본 hosted `k-skill-proxy`를 사용하므로 사용자 K-Startup 키가 필요 없다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다. `--direct`가 필요할 때만 `KSKILL_KSTARTUP_API_KEY`를 credential resolution order에 따라 확보한다.
### 2. Pick the right operation
- 마감 임박/지역 필터/대상별 공고 추천 → `announcements`
- 사업의 전반적 소개·예산 규모 → `business-info`
- 정책 공지·우수사례 → `contents`
- 보고서/통계 데이터 → `statistics`
### 3. Fetch a small bounded slice first
`--per-page 10` 정도로 먼저 한 페이지를 받아 응답 스키마를 확인한 뒤, 필터를 좁히거나 페이지를 넘긴다.
```bash
python3 scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
```
### 4. Filter on the client side for richer questions
API는 단순 필드 매칭만 지원하고, **그중 `supt_regin` 같은 일부 필터는 upstream이 서버 측에서 적용하지 않는 사례가 관측된다.** `--supt-regin 서울특별시`로 호출해도 타 지역 공고가 섞여 돌아오는 경우가 있어서, `supt_regin`·`aply_trgt`·`biz_enyy` 처럼 답변 정확도가 중요한 필드는 helper가 받은 응답 JSON을 client에서 한 번 더 거른다. `pbanc_rcpt_end_dt``YYYY-MM-DD HH:MM:SS` 문자열이라 KST 기준으로 직접 비교한다. "이번 주 마감", "30대 대상", "특정 키워드 포함" 같은 복합 조건도 client에서 마저 처리한다.
### 5. Cite the source
응답을 요약할 때는 endpoint 이름, 호출 page/perPage, 응답의 `pbanc_sn` 또는 `detl_pg_url`을 함께 적는다. 상세는 https://www.k-startup.go.kr 의 해당 URL로 안내한다.
## CLI examples
```bash
# 서울 모집 중 공고 5건
python3 scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text
# 2024년 사업화 분야 통합공고
python3 scripts/run_kstartup.py business-info \
--biz-yr 2024 --biz-category-cd cmrczn_Tab3 --json
# 정책·공지 최신 콘텐츠
python3 scripts/run_kstartup.py contents \
--clss-cd notice_matr --per-page 10 --text
# 창업기업 실태조사 통계보고서
python3 scripts/run_kstartup.py statistics \
--titl-nm "창업기업 실태조사" --per-page 5 --json
# 인증키 없이 dry-run 으로 요청 점검
python3 scripts/run_kstartup.py announcements \
--supt-regin 부산광역시 --dry-run
```
## Direct proxy examples
```bash
curl -fsS "$KSKILL_PROXY_BASE_URL/v1/kstartup/announcements?supt_regin=$(python3 -c 'import urllib.parse;print(urllib.parse.quote(\"서울특별시\"))')&rcrt_prgs_yn=Y&perPage=5"
```
## Failure modes
- `400 bad_request`: 잘못된 날짜(`YYYYMMDD` 아님), 잘못된 `Y/N`, perPage 범위 초과, 시작일 > 종료일 → 메시지대로 입력 보정.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY`가 없거나 해당 데이터셋 활용신청이 미승인.
- `502 upstream_error`: data.go.kr 응답이 `resultCode != "00"` 또는 `errMsg`/`SERVICE_KEY_IS_NOT_REGISTERED_ERROR` 등 인증/한도 오류.
- data.go.kr 에러 코드: 10(잘못된 파라미터), 20(접근거부), 22(요청제한 초과), 30(미등록 키), 31(만료), 32(미등록 IP).
- `502 upstream_invalid_response`: data.go.kr이 JSON 대신 HTML/XML 본문을 보낸 경우(점검·차단 등). `upstream_body` 앞 500자가 함께 반환된다.
- 빈 `data` 배열: 필터에 일치하는 공고/콘텐츠 없음. 키워드/지역/대상 범위를 완화한다.
- 일 갱신 1회(서비스설계서 기준): 같은 날 같은 공고의 마감일·상태가 갱신되지 않을 수 있으므로, 마감/접수 상태는 응답의 `detl_pg_url` 페이지에서 최종 확인한다.
## Done when
- 사용자가 찾는 endpoint (`business-info` / `announcements` / `contents` / `statistics`)를 골랐다.
- 작은 슬라이스로 첫 페이지를 받아 응답 스키마/필드를 확인했다.
- 필터를 좁히거나 클라이언트에서 후처리해 답변에 필요한 핵심 행만 남겼다.
- 결과에 출처(endpoint, page/perPage, `detl_pg_url` 또는 `pbanc_sn`)를 명시했다.
## Maintainer review notes
K-Startup 인증키 없이도 다음 검증이 가능하다.
- `./scripts/validate-skills.sh`
- `python3 -m py_compile kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py`
- `python3 kstartup-search/scripts/run_kstartup.py --help`
- `python3 kstartup-search/scripts/run_kstartup.py announcements --supt-regin 서울특별시 --dry-run`
- `PYTHONPATH=kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_*.py' -v`
- `node --test packages/k-skill-proxy/test/server.test.js` (K-Startup 라우트 5개 신규 케이스 포함)
- `npm run ci`
라이브 스모크는 hosted proxy 환경에 `DATA_GO_KR_API_KEY` 가 설정되고 `15125364` 활용신청이 승인된 뒤에 수행한다.
## Safety notes
- 조회 전용 스킬. 사업 신청·계좌 연결·결제 자동화는 하지 않는다.
- 응답에 K-Startup 사이트 URL이 있으면 그대로 안내하고, 실제 신청은 사용자가 브라우저에서 직접 진행한다.
- 인증키는 프록시 서버에서만 다루며, `--dry-run` 시에도 helper는 `<DRY-RUN>`로 대체한다.

View file

@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""K-Startup (data.go.kr 15125364) CLI helper for the kstartup-search skill.
조회 전용. 일반 호출은 k-skill-proxy 경유, `--direct` 사용자 API 키로 직접 호출.
stdlib only (urllib, json, argparse, ssl).
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterable, List, Optional, Tuple
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
KSTARTUP_UPSTREAM_BASE_URL = "https://apis.data.go.kr/B552735/kisedKstartupService01"
DEFAULT_SECRETS_PATH = os.path.expanduser("~/.config/k-skill/secrets.env")
OPERATIONS: Dict[str, Dict[str, Any]] = {
"business-info": {
"path": "getBusinessInformation01",
"allowed": ("biz_category_cd", "supt_biz_titl_nm", "biz_yr"),
},
"announcements": {
"path": "getAnnouncementInformation01",
"allowed": (
"intg_pbanc_yn", "intg_pbanc_biz_nm", "biz_pbanc_nm",
"supt_biz_clsfc", "aply_trgt_ctnt", "supt_regin",
"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt",
"aply_trgt", "biz_enyy", "biz_trgt_age", "prfn_matr",
"rcrt_prgs_yn",
),
},
"contents": {
"path": "getContentInformation01",
"allowed": ("clss_cd", "titl_nm"),
},
"statistics": {
"path": "getStatisticalInformation01",
"allowed": ("titl_nm", "file_nm"),
},
}
YN_FIELDS = {"intg_pbanc_yn", "rcrt_prgs_yn"}
DATE_FIELDS = {"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt"}
class HelperError(RuntimeError):
"""User-facing CLI error."""
def load_secrets(path: str = DEFAULT_SECRETS_PATH) -> Dict[str, str]:
"""Read dotenv-like secrets file. Returns {} if missing."""
data: Dict[str, str] = {}
if not os.path.exists(path):
return data
try:
with open(path, "r", encoding="utf-8") as fh:
for raw_line in fh:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
value = value[1:-1]
if value.startswith("'") and value.endswith("'") and len(value) >= 2:
value = value[1:-1]
if key:
data[key] = value
except OSError:
return data
return data
def resolve_api_key(args: argparse.Namespace) -> Optional[str]:
"""`--direct` 전용 API 키 해석. env > secrets file 순서."""
env_key = os.environ.get("KSKILL_KSTARTUP_API_KEY") or os.environ.get("DATA_GO_KR_API_KEY")
if env_key:
return env_key.strip() or None
secrets = load_secrets(args.secrets_path or DEFAULT_SECRETS_PATH)
return (secrets.get("KSKILL_KSTARTUP_API_KEY") or secrets.get("DATA_GO_KR_API_KEY") or "").strip() or None
def validate_yyyymmdd(value: str, field: str) -> str:
digits = "".join(c for c in value if c.isdigit())
if len(digits) != 8:
raise HelperError(f"{field} must be YYYYMMDD (got: {value!r})")
year = int(digits[0:4])
month = int(digits[4:6])
day = int(digits[6:8])
try:
datetime.date(year, month, day)
except ValueError as exc:
raise HelperError(f"{field} must be a valid YYYYMMDD date (got: {value!r})") from exc
return digits
def build_query(args: argparse.Namespace, operation: str) -> Dict[str, Any]:
if operation not in OPERATIONS:
raise HelperError(f"Unknown operation: {operation}")
if args.page < 1:
raise HelperError("--page must be >= 1")
if args.per_page < 1 or args.per_page > 100:
raise HelperError("--per-page must be in [1, 100]")
query: Dict[str, Any] = {
"page": args.page,
"perPage": args.per_page,
"returnType": "json",
}
for field in OPERATIONS[operation]["allowed"]:
attr = field.lower()
raw = getattr(args, attr, None)
if raw is None or str(raw).strip() == "":
continue
value = str(raw).strip()
if field in DATE_FIELDS:
value = validate_yyyymmdd(value, field)
elif field in YN_FIELDS:
upper = value.upper()
if upper not in {"Y", "N"}:
raise HelperError(f"{field} must be Y or N (got: {value!r})")
value = upper
elif field == "biz_yr":
if not (len(value) == 4 and value.isdigit()):
raise HelperError(f"biz_yr must be 4 digits (got: {value!r})")
query[field] = value
if (
operation == "announcements"
and query.get("pbanc_rcpt_bgng_dt")
and query.get("pbanc_rcpt_end_dt")
and query["pbanc_rcpt_bgng_dt"] > query["pbanc_rcpt_end_dt"]
):
raise HelperError("pbanc_rcpt_bgng_dt must be <= pbanc_rcpt_end_dt")
return query
def encode_query(query: Dict[str, Any]) -> str:
pairs: List[Tuple[str, str]] = [(k, str(v)) for k, v in query.items()]
return urllib.parse.urlencode(pairs, doseq=False, safe="")
def build_url(operation: str, query: Dict[str, Any], *, direct: bool, api_key: Optional[str], proxy_base_url: str) -> str:
if direct:
if not api_key:
raise HelperError(
"KSKILL_KSTARTUP_API_KEY (또는 DATA_GO_KR_API_KEY) 가 없습니다. "
"공공데이터포털 15125364 활용신청 후 키를 발급받아 환경변수나 ~/.config/k-skill/secrets.env 에 두세요."
)
path = OPERATIONS[operation]["path"]
with_key = dict(query)
with_key["ServiceKey"] = api_key
return f"{KSTARTUP_UPSTREAM_BASE_URL}/{path}?{encode_query(with_key)}"
base = proxy_base_url.rstrip("/")
return f"{base}/v1/kstartup/{operation}?{encode_query(query)}"
def http_get(url: str, *, timeout: int) -> Tuple[int, str, str]:
headers = {
"accept": "application/json",
"user-agent": "k-skill/kstartup-search",
}
request = urllib.request.Request(url, headers=headers, method="GET")
context = ssl.create_default_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=context) as response:
body = response.read().decode("utf-8", errors="replace")
return response.status, response.headers.get("content-type", ""), body
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
return exc.code, exc.headers.get("content-type", "") if exc.headers else "", body
except urllib.error.URLError as exc:
raise HelperError(f"network error: {exc.reason}") from exc
def summarise(operation: str, payload: Dict[str, Any]) -> str:
items: Iterable[Dict[str, Any]] = []
if isinstance(payload, dict):
data = payload.get("data") or payload.get("items")
if isinstance(data, list):
items = data
elif isinstance(payload.get("response"), dict):
response = payload["response"]
body = response.get("body") or {}
items = body.get("items") or []
items = list(items or [])
if not items:
return "[summary] 매칭되는 항목이 없습니다. 필터를 완화하거나 페이지를 넘기세요."
lines = [f"[summary] operation={operation} count={len(items)} (page={payload.get('query', {}).get('page', payload.get('page'))} perPage={payload.get('query', {}).get('perPage', payload.get('perPage'))})"]
for index, item in enumerate(items, start=1):
title = (
item.get("biz_pbanc_nm")
or item.get("supt_biz_titl_nm")
or item.get("titl_nm")
or item.get("intg_pbanc_biz_nm")
or "(제목 없음)"
)
region = item.get("supt_regin") or item.get("biz_category_cd") or item.get("clss_cd") or ""
period = ""
if item.get("pbanc_rcpt_bgng_dt") or item.get("pbanc_rcpt_end_dt"):
period = f" {item.get('pbanc_rcpt_bgng_dt','?')} ~ {item.get('pbanc_rcpt_end_dt','?')}"
url = item.get("detl_pg_url") or ""
lines.append(f" {index:>2}. {title} {region}{period}")
if url:
lines.append(f"{url}")
return "\n".join(lines)
def _add_filter_args(parser: argparse.ArgumentParser, operation: str) -> None:
allowed = OPERATIONS[operation]["allowed"]
for field in allowed:
flag = "--" + field.replace("_", "-").lower()
parser.add_argument(flag, dest=field.lower(), default=None,
help=f"K-Startup field: {field}")
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="run_kstartup.py",
description="창업진흥원 K-Startup Open API (data.go.kr 15125364) 조회 helper",
)
subparsers = parser.add_subparsers(dest="operation", required=True)
for operation in OPERATIONS:
sub = subparsers.add_parser(operation, help=f"K-Startup {operation} endpoint")
sub.add_argument("--page", type=int, default=1)
sub.add_argument("--per-page", dest="per_page", type=int, default=10)
format_group = sub.add_mutually_exclusive_group()
format_group.add_argument("--text", action="store_true", help="사람용 요약")
format_group.add_argument("--json", action="store_true", help="구조화 JSON 출력 (기본)")
sub.add_argument("--dry-run", action="store_true", dest="dry_run",
help="요청 URL/파라미터만 출력, 네트워크 호출 없음")
sub.add_argument("--timeout", type=int, default=30)
sub.add_argument("--proxy-base-url", default=os.environ.get("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY_BASE_URL))
sub.add_argument("--direct", action="store_true",
help="proxy 우회, KSKILL_KSTARTUP_API_KEY 로 직접 호출")
sub.add_argument("--secrets-path", default=DEFAULT_SECRETS_PATH,
help=f"--direct 시 secrets 파일 경로 (기본 {DEFAULT_SECRETS_PATH})")
_add_filter_args(sub, operation)
return parser
def run(argv: Optional[List[str]] = None) -> int:
parser = make_parser()
args = parser.parse_args(argv)
operation = args.operation
try:
query = build_query(args, operation)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 2
if args.dry_run:
if args.direct:
preview = build_url(operation, query, direct=True, api_key="<DRY-RUN>", proxy_base_url=args.proxy_base_url)
else:
preview = build_url(operation, query, direct=False, api_key=None, proxy_base_url=args.proxy_base_url)
preview = preview.replace(os.environ.get("KSKILL_KSTARTUP_API_KEY", ""), "<DRY-RUN>") if os.environ.get("KSKILL_KSTARTUP_API_KEY") else preview
preview = preview.replace(os.environ.get("DATA_GO_KR_API_KEY", ""), "<DRY-RUN>") if os.environ.get("DATA_GO_KR_API_KEY") else preview
result = {"operation": operation, "url": preview, "query": query, "direct": bool(args.direct)}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
api_key = resolve_api_key(args) if args.direct else None
try:
url = build_url(operation, query, direct=args.direct, api_key=api_key, proxy_base_url=args.proxy_base_url)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 3
try:
status, content_type, body = http_get(url, timeout=args.timeout)
except HelperError as exc:
print(f"[error] {exc}", file=sys.stderr)
return 4
payload: Any
try:
payload = json.loads(body) if body else {}
except json.JSONDecodeError:
print(f"[error] upstream returned non-JSON content-type={content_type!r} status={status}", file=sys.stderr)
print(body[:500])
return 5
if not isinstance(payload, dict):
payload = {"raw": payload}
payload.setdefault("query", query)
if args.text:
print(summarise(operation, payload))
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
if status >= 400:
return 6
return 0
if __name__ == "__main__":
raise SystemExit(run())

View file

View file

@ -0,0 +1,190 @@
"""Unit tests for kstartup-search helper.
stdlib unittest only; runs without DATA_GO_KR_API_KEY or network access.
"""
import argparse
import json
import os
import sys
import unittest
from io import StringIO
from unittest import mock
SCRIPT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts")
sys.path.insert(0, SCRIPT_DIR)
import run_kstartup # noqa: E402
def make_args(operation: str, **overrides):
defaults = {
"operation": operation,
"page": 1,
"per_page": 10,
"text": False,
"json": False,
"dry_run": True,
"timeout": 30,
"proxy_base_url": "https://example.test",
"direct": False,
"secrets_path": "/tmp/__nonexistent__.env",
}
for field in run_kstartup.OPERATIONS[operation]["allowed"]:
defaults[field.lower()] = None
defaults.update(overrides)
return argparse.Namespace(**defaults)
class BuildQueryTests(unittest.TestCase):
def test_announcements_normalizes_dates_and_yn(self):
args = make_args(
"announcements",
pbanc_rcpt_bgng_dt="2024-01-01",
pbanc_rcpt_end_dt="2024-12-31",
rcrt_prgs_yn="y",
supt_regin="서울특별시",
)
query = run_kstartup.build_query(args, "announcements")
self.assertEqual(query["pbanc_rcpt_bgng_dt"], "20240101")
self.assertEqual(query["pbanc_rcpt_end_dt"], "20241231")
self.assertEqual(query["rcrt_prgs_yn"], "Y")
self.assertEqual(query["supt_regin"], "서울특별시")
self.assertEqual(query["returnType"], "json")
self.assertEqual(query["page"], 1)
self.assertEqual(query["perPage"], 10)
def test_business_info_requires_4digit_year(self):
args = make_args("business-info", biz_yr="24")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "business-info")
def test_announcements_rejects_inverted_date_range(self):
args = make_args(
"announcements",
pbanc_rcpt_bgng_dt="20240601",
pbanc_rcpt_end_dt="20240101",
)
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
def test_announcements_rejects_impossible_calendar_date(self):
# Calendar-impossible dates (Feb 30, Apr 31, month 13, day 0) must be
# rejected by the Python helper so `--direct` mode does not drift from
# the proxy-side Date.UTC() validation in kstartup.js.
impossible_values = ["20240230", "20240431", "20241301", "20240100"]
for value in impossible_values:
args = make_args("announcements", pbanc_rcpt_bgng_dt=value)
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
# Leap-day boundary: 2024-02-29 is valid (leap), 2023-02-29 is not.
args_leap_ok = make_args("announcements", pbanc_rcpt_bgng_dt="20240229")
query = run_kstartup.build_query(args_leap_ok, "announcements")
self.assertEqual(query["pbanc_rcpt_bgng_dt"], "20240229")
args_leap_bad = make_args("announcements", pbanc_rcpt_bgng_dt="20230229")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args_leap_bad, "announcements")
def test_invalid_yn_raises(self):
args = make_args("announcements", rcrt_prgs_yn="maybe")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(args, "announcements")
def test_per_page_bounds(self):
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(make_args("announcements", per_page=0), "announcements")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_query(make_args("announcements", per_page=101), "announcements")
def test_contents_filter_passthrough(self):
args = make_args("contents", clss_cd="notice_matr", titl_nm="공모전")
query = run_kstartup.build_query(args, "contents")
self.assertEqual(query["clss_cd"], "notice_matr")
self.assertEqual(query["titl_nm"], "공모전")
class BuildUrlTests(unittest.TestCase):
def test_proxy_url(self):
args = make_args("announcements", supt_regin="서울특별시", rcrt_prgs_yn="Y")
query = run_kstartup.build_query(args, "announcements")
url = run_kstartup.build_url("announcements", query, direct=False, api_key=None, proxy_base_url=args.proxy_base_url)
self.assertTrue(url.startswith("https://example.test/v1/kstartup/announcements?"))
self.assertIn("rcrt_prgs_yn=Y", url)
self.assertNotIn("ServiceKey", url, "proxy URL must never carry ServiceKey client-side")
def test_direct_url_includes_service_key(self):
args = make_args("statistics", direct=True, titl_nm="창업기업 실태조사")
query = run_kstartup.build_query(args, "statistics")
url = run_kstartup.build_url("statistics", query, direct=True, api_key="dummy-key", proxy_base_url=args.proxy_base_url)
self.assertIn("apis.data.go.kr/B552735/kisedKstartupService01/getStatisticalInformation01", url)
self.assertIn("ServiceKey=dummy-key", url)
def test_direct_without_key_raises(self):
args = make_args("contents", direct=True)
query = run_kstartup.build_query(args, "contents")
with self.assertRaises(run_kstartup.HelperError):
run_kstartup.build_url("contents", query, direct=True, api_key=None, proxy_base_url=args.proxy_base_url)
class SecretsLoaderTests(unittest.TestCase):
def test_returns_empty_when_missing(self):
self.assertEqual(run_kstartup.load_secrets("/tmp/__nonexistent_kstartup__.env"), {})
def test_parses_dotenv(self):
path = "/tmp/__kstartup_test_secrets__.env"
with open(path, "w", encoding="utf-8") as fh:
fh.write("# comment\nKSKILL_KSTARTUP_API_KEY=abc\nDATA_GO_KR_API_KEY=\"xyz\"\nEMPTY=\n")
try:
data = run_kstartup.load_secrets(path)
self.assertEqual(data["KSKILL_KSTARTUP_API_KEY"], "abc")
self.assertEqual(data["DATA_GO_KR_API_KEY"], "xyz")
self.assertEqual(data["EMPTY"], "")
finally:
os.unlink(path)
class DryRunIntegrationTests(unittest.TestCase):
def test_dry_run_outputs_proxy_url(self):
buf = StringIO()
with mock.patch.object(sys, "stdout", buf):
rc = run_kstartup.run([
"announcements",
"--supt-regin", "서울특별시",
"--rcrt-prgs-yn", "Y",
"--per-page", "5",
"--dry-run",
"--proxy-base-url", "https://example.test",
])
self.assertEqual(rc, 0)
out = buf.getvalue()
payload = json.loads(out)
self.assertEqual(payload["operation"], "announcements")
self.assertTrue(payload["url"].startswith("https://example.test/v1/kstartup/announcements?"))
self.assertEqual(payload["query"]["rcrt_prgs_yn"], "Y")
self.assertNotIn("ServiceKey", payload["url"])
def test_dry_run_direct_redacts_key(self):
buf = StringIO()
env = dict(os.environ)
env["KSKILL_KSTARTUP_API_KEY"] = "super-secret"
with mock.patch.dict(os.environ, env, clear=True):
with mock.patch.object(sys, "stdout", buf):
rc = run_kstartup.run([
"contents",
"--clss-cd", "notice_matr",
"--direct",
"--dry-run",
])
self.assertEqual(rc, 0)
payload = json.loads(buf.getvalue())
self.assertTrue(
"ServiceKey=<DRY-RUN>" in payload["url"]
or "ServiceKey=%3CDRY-RUN%3E" in payload["url"],
f"redacted ServiceKey not found in {payload['url']!r}",
)
self.assertNotIn("super-secret", payload["url"])
if __name__ == "__main__":
unittest.main()

View file

@ -10,9 +10,9 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -25,6 +25,10 @@
- `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/kstartup/business-info` — 창업진흥원 K-Startup 통합공고 지원사업 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/announcements` — 창업진흥원 K-Startup 지원사업 공고 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/contents` — 창업진흥원 K-Startup 창업 콘텐츠 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/kstartup/statistics` — 창업진흥원 K-Startup 통계보고서 정보(`DATA_GO_KR_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`)

View file

@ -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/krx-stock.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.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/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.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/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.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/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,261 @@
const KSTARTUP_UPSTREAM_BASE_URL = "https://apis.data.go.kr/B552735/kisedKstartupService01";
const KSTARTUP_OPERATIONS = new Map([
["business-info", { path: "getBusinessInformation01", allowed: new Set(["page", "perPage", "returnType", "biz_category_cd", "supt_biz_titl_nm", "biz_yr"]) }],
["announcements", {
path: "getAnnouncementInformation01",
allowed: new Set([
"page", "perPage", "returnType",
"intg_pbanc_yn", "intg_pbanc_biz_nm", "biz_pbanc_nm",
"supt_biz_clsfc", "aply_trgt_ctnt", "supt_regin",
"pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt",
"aply_trgt", "biz_enyy", "biz_trgt_age", "prfn_matr",
"rcrt_prgs_yn"
])
}],
["contents", { path: "getContentInformation01", allowed: new Set(["page", "perPage", "returnType", "clss_cd", "titl_nm"]) }],
["statistics", { path: "getStatisticalInformation01", allowed: new Set(["page", "perPage", "returnType", "titl_nm", "file_nm"]) }]
]);
const KSTARTUP_INTEGER_FIELDS = new Set(["page", "perPage"]);
const KSTARTUP_DATE_FIELDS = new Set(["pbanc_rcpt_bgng_dt", "pbanc_rcpt_end_dt"]);
const KSTARTUP_YN_FIELDS = new Set(["intg_pbanc_yn", "rcrt_prgs_yn"]);
const KSTARTUP_TEXT_FIELD_LIMITS = {
supt_biz_titl_nm: 300,
intg_pbanc_biz_nm: 300,
biz_pbanc_nm: 300,
supt_biz_clsfc: 100,
aply_trgt_ctnt: 300,
supt_regin: 200,
aply_trgt: 200,
biz_enyy: 200,
biz_trgt_age: 200,
prfn_matr: 200,
biz_category_cd: 50,
clss_cd: 50,
titl_nm: 300,
file_nm: 1000
};
const KSTARTUP_MAX_PER_PAGE = 100;
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizeKstartupYear(value) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
if (!/^\d{4}$/.test(raw)) {
throw new Error("Provide biz_yr as a 4-digit year (e.g. 2024).");
}
return raw;
}
function normalizeKstartupDate(value, field) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{8}$/.test(normalized)) {
throw new Error(`Provide ${field} as YYYYMMDD.`);
}
const year = Number.parseInt(normalized.slice(0, 4), 10);
const month = Number.parseInt(normalized.slice(4, 6), 10);
const day = Number.parseInt(normalized.slice(6, 8), 10);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error(`Provide ${field} as a valid YYYYMMDD date.`);
}
return normalized;
}
function normalizeKstartupYn(value, field) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const upper = raw.toUpperCase();
if (upper !== "Y" && upper !== "N") {
throw new Error(`Provide ${field} as Y or N.`);
}
return upper;
}
function normalizeKstartupInteger(value, field, { min, max }) {
const raw = trimOrNull(value);
if (raw === null) {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || String(parsed) !== raw.replace(/^\+/, "")) {
throw new Error(`Provide ${field} as a positive integer.`);
}
if (parsed < min) {
throw new Error(`Provide ${field} >= ${min}.`);
}
if (max !== undefined && parsed > max) {
throw new Error(`Provide ${field} <= ${max}.`);
}
return parsed;
}
function normalizeKstartupText(value, field) {
const raw = trimOrNull(value);
if (raw === null) {
return null;
}
const maxLength = KSTARTUP_TEXT_FIELD_LIMITS[field];
if (maxLength && raw.length > maxLength) {
throw new Error(`Provide ${field} up to ${maxLength} characters.`);
}
return raw;
}
function normalizeKstartupReturnType() {
// K-Startup proxy forces returnType=json so callers cannot ask for XML
// through the proxy. Use --direct mode to fetch XML directly.
return "json";
}
function normalizeKstartupQuery(operation, query = {}) {
const definition = KSTARTUP_OPERATIONS.get(operation);
if (!definition) {
throw new Error(`Unknown K-Startup operation: ${operation}`);
}
const normalized = {};
const page = normalizeKstartupInteger(query.page, "page", { min: 1 });
normalized.page = page === null ? 1 : page;
const perPage = normalizeKstartupInteger(query.perPage ?? query.per_page, "perPage", { min: 1, max: KSTARTUP_MAX_PER_PAGE });
normalized.perPage = perPage === null ? 10 : perPage;
normalized.returnType = normalizeKstartupReturnType();
for (const field of definition.allowed) {
if (field === "page" || field === "perPage" || field === "returnType") {
continue;
}
const raw = query[field] ?? query[field.toLowerCase()];
let value = null;
if (field === "biz_yr") {
value = normalizeKstartupYear(raw);
} else if (KSTARTUP_DATE_FIELDS.has(field)) {
value = normalizeKstartupDate(raw, field);
} else if (KSTARTUP_YN_FIELDS.has(field)) {
value = normalizeKstartupYn(raw, field);
} else if (KSTARTUP_INTEGER_FIELDS.has(field)) {
value = normalizeKstartupInteger(raw, field, { min: 1 });
} else {
value = normalizeKstartupText(raw, field);
}
if (value !== null && value !== undefined) {
normalized[field] = value;
}
}
if (
normalized.pbanc_rcpt_bgng_dt &&
normalized.pbanc_rcpt_end_dt &&
normalized.pbanc_rcpt_bgng_dt > normalized.pbanc_rcpt_end_dt
) {
throw new Error("Provide pbanc_rcpt_bgng_dt earlier than or equal to pbanc_rcpt_end_dt.");
}
return normalized;
}
async function proxyKstartupRequest({ operation, query, serviceKey, fetchImpl = global.fetch }) {
if (!serviceKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server."
})
};
}
const definition = KSTARTUP_OPERATIONS.get(operation);
if (!definition) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That K-Startup route is not exposed by this proxy."
})
};
}
const url = new URL(`${KSTARTUP_UPSTREAM_BASE_URL}/${definition.path}`);
url.searchParams.set("ServiceKey", serviceKey);
for (const [key, value] of Object.entries(query || {})) {
if (value === undefined || value === null || value === "" || key === "ServiceKey") {
continue;
}
url.searchParams.set(key, String(value));
}
// Always force JSON regardless of upstream defaults or caller overrides.
url.searchParams.set("returnType", "json");
const response = await fetchImpl(url, {
method: "GET",
headers: {
accept: "application/json",
"user-agent": "k-skill-proxy/kstartup"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function isKstartupErrorBody(body) {
const text = String(body || "").trim();
if (!text) {
return true;
}
if (/<errMsg>|<returnAuthMsg>|SERVICE_KEY_IS_NOT_REGISTERED|LIMITED_NUMBER_OF_SERVICE_REQUESTS|DEADLINE_HAS_EXPIRED|SERVICE_ACCESS_DENIED/i.test(text)) {
return true;
}
if (!(text.startsWith("{") || text.startsWith("["))) {
return false;
}
try {
const payload = JSON.parse(text);
if (!payload || typeof payload !== "object") {
return false;
}
if (payload.error || payload.errMsg || payload.returnAuthMsg) {
return true;
}
if (payload.response && payload.response.header) {
const code = String(payload.response.header.resultCode ?? "").trim();
return code && code !== "00";
}
return false;
} catch {
return false;
}
}
module.exports = {
KSTARTUP_OPERATIONS,
KSTARTUP_UPSTREAM_BASE_URL,
normalizeKstartupQuery,
proxyKstartupRequest,
isKstartupErrorBody
};

View file

@ -27,6 +27,11 @@ const {
normalizeNtsBusinessValidateQuery,
proxyNtsBusinessRequest
} = require("./nts-business");
const {
isKstartupErrorBody,
normalizeKstartupQuery,
proxyKstartupRequest
} = require("./kstartup");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
@ -1607,7 +1612,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey)
ntsBusinessConfigured: Boolean(config.molitApiKey),
kstartupConfigured: Boolean(config.molitApiKey)
},
auth: {
tokenRequired: false
@ -2939,6 +2945,155 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply
}));
async function handleKstartupRoute({ operation, route, request, reply }) {
let normalized;
try {
normalized = normalizeKstartupQuery(operation, request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
normalized.returnType = "json";
const cacheKey = makeCacheKey({ route, ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let upstream;
try {
upstream = await proxyKstartupRequest({
operation,
query: normalized,
serviceKey: config.molitApiKey
});
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: "K-Startup upstream request failed.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (upstream.statusCode === 503) {
reply.code(503);
let upstreamPayload = null;
try { upstreamPayload = JSON.parse(upstream.body); } catch { upstreamPayload = null; }
return {
error: upstreamPayload?.error || "upstream_not_configured",
message: upstreamPayload?.message || "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let parsed = null;
try {
parsed = JSON.parse(upstream.body);
} catch {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
error: "upstream_invalid_response",
message: "K-Startup upstream did not return valid JSON.",
upstream_status: upstream.statusCode,
upstream_body: upstream.body.slice(0, 500),
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (upstream.statusCode < 200 || upstream.statusCode >= 300 || isKstartupErrorBody(upstream.body)) {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
...parsed,
error: parsed?.error || "upstream_error",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
}
const payload = {
...parsed,
query: normalized,
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/kstartup/business-info", async (request, reply) => handleKstartupRoute({
operation: "business-info",
route: "kstartup-business-info",
request,
reply
}));
app.get("/v1/kstartup/announcements", async (request, reply) => handleKstartupRoute({
operation: "announcements",
route: "kstartup-announcements",
request,
reply
}));
app.get("/v1/kstartup/contents", async (request, reply) => handleKstartupRoute({
operation: "contents",
route: "kstartup-contents",
request,
reply
}));
app.get("/v1/kstartup/statistics", async (request, reply) => handleKstartupRoute({
operation: "statistics",
route: "kstartup-statistics",
request,
reply
}));
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -4145,6 +4300,7 @@ module.exports = {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKstartupQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
@ -4169,6 +4325,7 @@ module.exports = {
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
proxyKosisRequest,
proxyKstartupRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulCityDataRequest,

View file

@ -15,6 +15,7 @@ const {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKstartupQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyAirKoreaRequest,
@ -4650,3 +4651,204 @@ test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set"
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.lhNoticeConfigured, true);
});
test("K-Startup normalizer enforces enums, ranges, and date order", () => {
const normalized = normalizeKstartupQuery("announcements", {
page: "2",
perPage: "20",
supt_regin: "서울특별시",
pbanc_rcpt_bgng_dt: "2024-01-01",
pbanc_rcpt_end_dt: "2024-12-31",
rcrt_prgs_yn: "y",
biz_yr: "2024"
});
assert.equal(normalized.page, 2);
assert.equal(normalized.perPage, 20);
assert.equal(normalized.supt_regin, "서울특별시");
assert.equal(normalized.pbanc_rcpt_bgng_dt, "20240101");
assert.equal(normalized.pbanc_rcpt_end_dt, "20241231");
assert.equal(normalized.rcrt_prgs_yn, "Y");
assert.equal(normalized.returnType, "json");
assert.equal(normalized.biz_yr, undefined, "biz_yr is not in announcements allowlist");
const businessInfo = normalizeKstartupQuery("business-info", { biz_yr: "2024", biz_category_cd: "cmrczn_Tab3" });
assert.equal(businessInfo.biz_yr, "2024");
assert.equal(businessInfo.biz_category_cd, "cmrczn_Tab3");
assert.throws(() => normalizeKstartupQuery("announcements", { rcrt_prgs_yn: "maybe" }), /rcrt_prgs_yn/);
assert.throws(() => normalizeKstartupQuery("announcements", { pbanc_rcpt_bgng_dt: "20241301" }), /pbanc_rcpt_bgng_dt/);
assert.throws(() => normalizeKstartupQuery("announcements", {
pbanc_rcpt_bgng_dt: "20240601", pbanc_rcpt_end_dt: "20240101"
}), /earlier than or equal/);
assert.throws(() => normalizeKstartupQuery("announcements", { perPage: "0" }), /perPage/);
assert.throws(() => normalizeKstartupQuery("announcements", { perPage: "101" }), /perPage/);
assert.throws(() => normalizeKstartupQuery("unknown-op", {}), /Unknown K-Startup operation/);
assert.throws(() => normalizeKstartupQuery("business-info", { biz_yr: "24" }), /biz_yr/);
});
test("K-Startup announcements route proxies GET with server-side ServiceKey", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({
currentCount: 1,
matchCount: 1,
data: [{ biz_pbanc_nm: "테스트 공고", supt_regin: "서울특별시" }],
page: 1, perPage: 10, totalCount: 1
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y"
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].biz_pbanc_nm, "테스트 공고");
assert.equal(body.proxy.cache.hit, false);
assert.equal(body.query.rcrt_prgs_yn, "Y");
const upstreamUrl = new URL(calls[0].url);
assert.equal(upstreamUrl.origin + upstreamUrl.pathname,
"https://apis.data.go.kr/B552735/kisedKstartupService01/getAnnouncementInformation01");
assert.equal(upstreamUrl.searchParams.get("ServiceKey"), "data-go-key");
assert.equal(upstreamUrl.searchParams.get("rcrt_prgs_yn"), "Y");
assert.equal(upstreamUrl.searchParams.get("returnType"), "json");
const cached = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y"
});
assert.equal(cached.statusCode, 200);
assert.equal(cached.json().proxy.cache.hit, true);
assert.equal(calls.length, 1, "second call must come from cache, not upstream");
});
test("K-Startup route reports 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer({ env: {} });
t.after(async () => { await app.close(); });
const response = await app.inject({ method: "GET", url: "/v1/kstartup/business-info?page=1" });
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_not_configured");
assert.match(body.message, /DATA_GO_KR_API_KEY/);
});
test("K-Startup route returns 400 for invalid params before hitting upstream", async (t) => {
const originalFetch = global.fetch;
let called = false;
global.fetch = async () => { called = true; return new Response("{}", { status: 200 }); };
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/announcements?pbanc_rcpt_bgng_dt=2024-13-01"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.equal(called, false, "must not call upstream on bad request");
});
test("K-Startup route surfaces data.go.kr error envelopes without caching", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
response: { header: { resultCode: "30", resultMsg: "SERVICE_KEY_IS_NOT_REGISTERED_ERROR" } }
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const first = await app.inject({ method: "GET", url: "/v1/kstartup/contents?page=1" });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_error");
const second = await app.inject({ method: "GET", url: "/v1/kstartup/contents?page=1" });
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "upstream error responses must not be cached");
});
test("K-Startup unknown operation returns 404 via proxyKstartupRequest", async () => {
const { proxyKstartupRequest } = require("../src/kstartup");
const result = await proxyKstartupRequest({ operation: "bogus", query: {}, serviceKey: "k" });
assert.equal(result.statusCode, 404);
assert.match(result.body, /not_found/);
});
test("health endpoint reports kstartupConfigured when DATA_GO_KR_API_KEY is set", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-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.kstartupConfigured, true);
});
test("K-Startup cache keys partition by query so distinct filters trigger distinct upstream calls", async (t) => {
const originalFetch = global.fetch;
const upstreamCalls = [];
global.fetch = async (url) => {
upstreamCalls.push(String(url));
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("부산광역시") });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") + "&rcrt_prgs_yn=Y" });
await app.inject({ method: "GET", url: "/v1/kstartup/announcements?supt_regin=" + encodeURIComponent("서울특별시") });
assert.equal(upstreamCalls.length, 3,
"3 distinct queries must call upstream 3 times; the 4th repeats query #1 and must hit the cache");
});
test("K-Startup proxy forces returnType=json even when caller asks for xml", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify({ data: [] }), {
status: 200, headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/kstartup/contents?returnType=xml&clss_cd=notice_matr"
});
assert.equal(response.statusCode, 200);
const upstreamUrl = new URL(calls[0]);
assert.equal(upstreamUrl.searchParams.get("returnType"), "json",
"proxy must rewrite returnType to json regardless of client input");
});
test("K-Startup integer fields reject non-numeric input before upstream call", async (t) => {
const originalFetch = global.fetch;
let called = false;
global.fetch = async () => { called = true; return new Response("{}", { status: 200 }); };
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
for (const bad of ["abc", "0", "-1"]) {
const response = await app.inject({ method: "GET", url: "/v1/kstartup/announcements?page=" + bad });
assert.equal(response.statusCode, 400, `page=${bad} must 400`);
}
assert.equal(called, false, "upstream must not be called for any invalid integer input");
});