
Kstartup Search
- 1.8k installs
- 7k repo stars
- Updated August 2, 2026
- nomadamas/k-skill
kstartup-search provides documented workflows for 공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
About
The kstartup-search skill 공공데이터포털 창업진흥원 K-Startup Open API 15125364 로 통합 공고 사업 정보 지원사업 공고 창업 콘텐츠 통계보고서를 k-skill-proxy 경유로 조회한다 검색 전용 창업진흥원 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로 노출된다 마감일 모집 상태를 분
- `business-info` → `getBusinessInformation01` : 통합공고 지원사업 정보 (예산, 규모, 수행기관, 사업소개)
- `announcements` → `getAnnouncementInformation01` : 지원사업 공고 정보 (공고명, 접수기간, 지역, 신청대상, 모집진행여부 등 - **가장 활용도 높음**)
- `contents` → `getContentInformation01` : 창업관련 콘텐츠 (공지·뉴스·우수사례 등)
- `statistics` → `getStatisticalInformation01` : 창업관련 통계보고서
- "이번 달 마감 예정인 청년 창업지원 공고 찾아줘"
Kstartup Search by the numbers
- 1,769 all-time installs (skills.sh)
- +238 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #59 of 910 Databases skills by installs in the Skillselion catalog
- Data as of Aug 6, 2026 (Skillselion catalog sync)
kstartup-search capabilities & compatibility
- Capabilities
- `business info` → `getbusinessinformation01` : 통 · `announcements` → `getannouncementinformation01` · `contents` → `getcontentinformation01` : 창업관련 콘텐 · `statistics` → `getstatisticalinformation01` : 창 · "이번 달 마감 예정인 청년 창업지원 공고 찾아줘"
- Use cases
- documentation
What kstartup-search says it does
# 창업진흥원 K-Startup 조회 ## What this skill does 공공데이터포털의 **창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스** (`kisedKstartupService01`, dataset `15125364`)를 `k-skill-proxy` 경유로 호출해 다음 4개 endpoint를 조회한다.
사업 신청·지원금 청구·콘텐츠 게시 같은 쓰기 동작은 다루지 않는다.
npx skills add https://github.com/nomadamas/k-skill --skill kstartup-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 7k |
| Last updated | August 2, 2026 |
| Repository | nomadamas/k-skill ↗ |
How do I use kstartup-search for the task described in its SKILL.md triggers?
공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
Who is it for?
Teams invoking kstartup-search when the user request matches documented triggers and prerequisites.
Skip if: Skip when cached docs are missing, the request is a negative trigger, or another sibling skill owns the workflow.
When should I use this skill?
공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
What you get
Step-by-step guidance grounded in kstartup-search documentation and reference files.
- Grant announcement listings
- Business program info summaries
- Startup statistics excerpts
By the numbers
- Queries 4 K-Startup API endpoints via dataset 15125364
- Covers business-info, announcements, contents, and statistics endpoint groups
Files
창업진흥원 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·별도 프록시를 쓸 때만 설정. 비우면 기본 hostedhttps://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, 1–100)--text사람용 요약 /--json구조화 결과(기본)--dry-run인증키 없이 요청 URL/파라미터만 출력--timeout NHTTP 타임아웃 초 (기본 30)--proxy-base-url URL기본 hosted proxy 대신 self-host/alternate proxy--directproxy 우회,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 정도로 먼저 한 페이지를 받아 응답 스키마를 확인한 뒤, 필터를 좁히거나 페이지를 넘긴다.
python3 scripts/run_kstartup.py announcements \
--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 5 --text4. Filter on the client side for richer questions
API는 단순 필드 매칭만 지원하고, 그중 `supt_regin` 같은 일부 필터는 upstream이 서버 측에서 적용하지 않는 사례가 관측된다. --supt-regin 서울특별시로 호출해도 타 지역 공고가 섞여 돌아오는 경우가 있어서, supt_regin·aply_trgt·biz_enyy 필드는 helper가 받은 응답을 client에서 한 번 더 거른다.
- 응답
supt_regin은 upstream이 축약형(서울,경기,충북)으로 돌려준다. helper는 사용자가--supt-regin 서울특별시같은 표준 광역지자체명을 줘도 17개 광역시·도(+전국) 매핑 테이블로 자동 정규화해 매치한다. - client filter가 적용되면 응답 JSON에
client_filter: {fields, upstream_returned, after_filter}블록이 함께 붙는다.upstream_returned는 같지만after_filter가 작으면 첫 페이지로는 부족하니--page를 늘려 추가 페이지를 받는다. - 쉼표로 여러 값을 주면 AND 매치다 (
--aply-trgt 예비창업자,1년미만→ 두 토큰 모두 row에 있어야 통과). pbanc_rcpt_end_dt는YYYYMMDD문자열이라 KST 기준으로 직접 비교한다. "이번 주 마감", "30대 대상", "특정 키워드 포함" 같은 복합 조건은 helper가 안 거르므로 응답 JSON에서 agent가 직접 처리한다.
5. Cite the source
응답을 요약할 때는 endpoint 이름, 호출 page/perPage, 응답의 pbanc_sn 또는 detl_pg_url을 함께 적는다. 상세는 https://www.k-startup.go.kr 의 해당 URL로 안내한다.
CLI examples
# 서울 모집 중 공고 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-runDirect proxy examples
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.shpython3 -m py_compile kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.pypython3 kstartup-search/scripts/run_kstartup.py --helppython3 kstartup-search/scripts/run_kstartup.py announcements --supt-regin 서울특별시 --dry-runPYTHONPATH=kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_*.py' -vnode --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>로 대체한다.
#!/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"}
# Fields where the K-Startup upstream is observed to ignore the server-side
# filter and return non-matching rows. SKILL.md L121 promises that the helper
# re-applies these filters on the client side after receiving the response.
#
# - supt_regin: upstream returns mixed regions even when supt_regin is set.
# - aply_trgt: upstream returns rows whose aply_trgt does not contain the
# requested target (e.g. asking for "예비창업자" returns rows
# with only "일반인,일반기업").
# - biz_enyy: upstream returns rows whose biz_enyy does not include the
# requested founding period bucket.
#
# Matching policy: substring match against the comma-separated list inside
# each row's field. Multiple requested values (comma-separated by the user)
# are AND-joined: every requested token must appear somewhere in the row.
# This mirrors how the K-Startup web UI narrows results.
CLIENT_FILTER_FIELDS = {"supt_regin", "aply_trgt", "biz_enyy"}
REGION_SHORTNAME = {
"서울특별시": "서울", "서울시": "서울", "서울": "서울",
"부산광역시": "부산", "부산시": "부산", "부산": "부산",
"대구광역시": "대구", "대구시": "대구", "대구": "대구",
"인천광역시": "인천", "인천시": "인천", "인천": "인천",
"광주광역시": "광주", "광주시": "광주", "광주": "광주",
"대전광역시": "대전", "대전시": "대전", "대전": "대전",
"울산광역시": "울산", "울산시": "울산", "울산": "울산",
"세종특별자치시": "세종", "세종시": "세종", "세종": "세종",
"경기도": "경기", "경기": "경기",
"강원특별자치도": "강원", "강원도": "강원", "강원": "강원",
"충청북도": "충북", "충북": "충북",
"충청남도": "충남", "충남": "충남",
"전북특별자치도": "전북", "전라북도": "전북", "전북": "전북",
"전라남도": "전남", "전남": "전남",
"경상북도": "경북", "경북": "경북",
"경상남도": "경남", "경남": "경남",
"제주특별자치도": "제주", "제주도": "제주", "제주": "제주",
"전국": "전국",
}
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 _normalise_filter_token(field: str, token: str) -> str:
if field == "supt_regin":
return REGION_SHORTNAME.get(token, token)
return token
def _row_matches_token(row: Dict[str, Any], field: str, token: str) -> bool:
raw = row.get(field)
if raw is None:
return False
haystack = str(raw)
needle = _normalise_filter_token(field, token)
return needle in haystack
def _row_matches_field(row: Dict[str, Any], field: str, requested: str) -> bool:
tokens = [t.strip() for t in requested.split(",") if t.strip()]
if not tokens:
return True
return all(_row_matches_token(row, field, token) for token in tokens)
def apply_client_filters(
payload: Dict[str, Any],
args: argparse.Namespace,
operation: str,
) -> Dict[str, Any]:
if operation != "announcements":
return payload
requested: Dict[str, str] = {}
for field in CLIENT_FILTER_FIELDS:
value = getattr(args, field, None)
if value is None:
continue
text = str(value).strip()
if text:
requested[field] = text
if not requested:
return payload
data = payload.get("data")
if not isinstance(data, list):
return payload
upstream_count = len(data)
filtered = [
row for row in data
if isinstance(row, dict)
and all(_row_matches_field(row, field, value) for field, value in requested.items())
]
payload["data"] = filtered
payload["currentCount"] = len(filtered)
payload["client_filter"] = {
"fields": requested,
"upstream_returned": upstream_count,
"after_filter": len(filtered),
"note": "Applied after upstream response because K-Startup ignores some server-side filters.",
}
return payload
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)
payload = apply_client_filters(payload, args, operation)
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())
"""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"])
class ClientFilterTests(unittest.TestCase):
@staticmethod
def _payload(rows):
return {
"currentCount": len(rows),
"data": list(rows),
"totalCount": 999,
"page": 1,
"perPage": len(rows),
}
def test_supt_regin_drops_other_regions(self):
payload = self._payload([
{"biz_pbanc_nm": "서울 청년창업", "supt_regin": "서울"},
{"biz_pbanc_nm": "경북 모집", "supt_regin": "경북"},
{"biz_pbanc_nm": "충북 K-바이오", "supt_regin": "충북"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(result["currentCount"], 1)
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "서울 청년창업")
self.assertEqual(result["client_filter"]["upstream_returned"], 3)
self.assertEqual(result["client_filter"]["after_filter"], 1)
self.assertEqual(result["client_filter"]["fields"]["supt_regin"], "서울특별시")
def test_supt_regin_normalises_long_official_names(self):
rows = [
("서울특별시", "서울"),
("부산광역시", "부산"),
("경기도", "경기"),
("강원특별자치도", "강원"),
("전북특별자치도", "전북"),
("제주특별자치도", "제주"),
("세종특별자치시", "세종"),
]
for long_name, short_name in rows:
payload = self._payload([
{"biz_pbanc_nm": "match", "supt_regin": short_name},
{"biz_pbanc_nm": "other", "supt_regin": "전국"},
])
args = make_args("announcements", supt_regin=long_name)
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(
[row["biz_pbanc_nm"] for row in result["data"]],
["match"],
f"long name {long_name!r} should match upstream short form {short_name!r}",
)
def test_supt_regin_short_form_also_works(self):
payload = self._payload([
{"biz_pbanc_nm": "match", "supt_regin": "서울"},
{"biz_pbanc_nm": "other", "supt_regin": "경기"},
])
args = make_args("announcements", supt_regin="서울")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match"])
def test_supt_regin_handles_nationwide_rows_explicitly(self):
payload = self._payload([
{"biz_pbanc_nm": "전국 공모", "supt_regin": "전국"},
{"biz_pbanc_nm": "서울 공모", "supt_regin": "서울특별시"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["서울 공모"])
def test_aply_trgt_substring_match_in_comma_list(self):
payload = self._payload([
{"biz_pbanc_nm": "예비창업자 대상", "aply_trgt": "일반인,일반기업,예비창업자"},
{"biz_pbanc_nm": "일반 대상", "aply_trgt": "일반인,일반기업"},
])
args = make_args("announcements", aply_trgt="예비창업자")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(len(result["data"]), 1)
self.assertEqual(result["data"][0]["biz_pbanc_nm"], "예비창업자 대상")
def test_multiple_filters_are_anded(self):
payload = self._payload([
{"biz_pbanc_nm": "ok", "supt_regin": "서울특별시", "aply_trgt": "예비창업자"},
{"biz_pbanc_nm": "wrong-region", "supt_regin": "경기도", "aply_trgt": "예비창업자"},
{"biz_pbanc_nm": "wrong-target", "supt_regin": "서울특별시", "aply_trgt": "일반인"},
])
args = make_args(
"announcements",
supt_regin="서울특별시",
aply_trgt="예비창업자",
)
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["ok"])
def test_comma_separated_request_requires_all_tokens(self):
payload = self._payload([
{"biz_pbanc_nm": "match-all", "biz_enyy": "예비창업자,1년미만,2년미만"},
{"biz_pbanc_nm": "missing-one", "biz_enyy": "예비창업자"},
])
args = make_args("announcements", biz_enyy="예비창업자,1년미만")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["match-all"])
def test_no_client_filter_args_is_passthrough(self):
payload = self._payload([{"biz_pbanc_nm": "x", "supt_regin": "전국"}])
args = make_args("announcements")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual(result["currentCount"], 1)
self.assertNotIn("client_filter", result)
def test_non_announcements_operations_are_passthrough(self):
payload = self._payload([{"titl_nm": "공모전 공지"}])
args = make_args("contents")
result = run_kstartup.apply_client_filters(payload, args, "contents")
self.assertEqual(result["currentCount"], 1)
self.assertNotIn("client_filter", result)
def test_empty_filter_value_is_treated_as_unset(self):
payload = self._payload([{"supt_regin": "경기도"}])
args = make_args("announcements", supt_regin=" ")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertNotIn("client_filter", result)
def test_missing_field_in_row_is_not_matched(self):
payload = self._payload([
{"biz_pbanc_nm": "has-field", "supt_regin": "서울특별시"},
{"biz_pbanc_nm": "no-field"},
])
args = make_args("announcements", supt_regin="서울특별시")
result = run_kstartup.apply_client_filters(payload, args, "announcements")
self.assertEqual([row["biz_pbanc_nm"] for row in result["data"]], ["has-field"])
if __name__ == "__main__":
unittest.main()
Related skills
How it compares
Pick kstartup-search over manual portal browsing when you need structured grant announcement data from the official K-Startup API inside an agent workflow.
FAQ
What does kstartup-search do?
공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
When should I use kstartup-search?
공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다. 검색 전용.
What are common prerequisites?
--- name: kstartup-search description: 공공데이터포털 창업진흥원 K-Startup Open API(15125364)로 통합 공고 사업 정보·지원사업 공고·창업 콘텐츠·통계보고서를 k-skill-proxy 경유로 조회한다.