@nomadamas/k-skill 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,150 @@
1
+ # 동물약국·동물용의약품 취급 약국 조회
2
+
3
+ ## What this skill does
4
+
5
+ 홍익메디케어가 운영하는 인증 없는 공개 Streamable HTTP MCP 서버를 직접 호출한다.
6
+
7
+ - 엔드포인트: `https://hkmedi.co.kr/pharmacy-mcp`
8
+ - 지역별 동물약국 목록 조회
9
+ - 제품명·분류·증상 키워드로 동물용의약품 검색
10
+ - 특정 제품을 최근 6개월 안에 홍익메디케어에서 구매한 약국 조회
11
+ - 별도 API key나 `k-skill-proxy` 없이 사용자 머신에서 직접 호출
12
+
13
+ 이 데이터는 민간 유통사인 홍익메디케어의 거래·디렉터리 데이터다. 공공기관의
14
+ 동물약국 인허가 원장이나 전국 모든 유통사의 판매 자료가 아니다.
15
+
16
+ ## When to use
17
+
18
+ - "서울 강남구 동물약국 알려줘"
19
+ - "목포시 동물약국 리스트 찾아줘"
20
+ - "동물용 항생제 제품 뭐가 있어?"
21
+ - "오리더밀 취급하는 서울 약국 찾아줘"
22
+ - "피부 관련 동물약 파는 인천 약국 있어?"
23
+
24
+ ## When not to use
25
+
26
+ - 동물의 증상을 진단하거나 약을 처방·추천해야 하는 요청
27
+ - 용량, 투여 주기, 병용 가능 여부를 결정하는 요청
28
+ - 현재 매장 재고를 확정하거나 구매를 자동화하는 요청
29
+ - 공공기관의 공식 인허가 상태·행정처분 확인이 필요한 요청
30
+
31
+ 동물의 상태가 위급하거나 약물 선택이 필요한 경우 수의사 진료를 우선 안내한다.
32
+
33
+ ## Access path
34
+
35
+ 기본 경로는 bundled helper다.
36
+
37
+ ```bash
38
+ npx -y @nomadamas/k-skill@0 exec animal-pharmacy-search scripts/animal_pharmacy_mcp.py -- tools
39
+ ```
40
+
41
+ helper는 Python 표준 라이브러리만 사용해 MCP `initialize` 후 세션 ID를 유지하며
42
+ `tools/list` 또는 `tools/call`을 실행한다. 서버가 JSON 또는 SSE로 응답해도
43
+ 동일한 JSON 결과로 정규화한다.
44
+
45
+ 직접 MCP 클라이언트에 등록할 수도 있다.
46
+
47
+ ```bash
48
+ claude mcp add --transport http animal-pharmacy https://hkmedi.co.kr/pharmacy-mcp
49
+ codex mcp add animal-pharmacy --url https://hkmedi.co.kr/pharmacy-mcp
50
+ ```
51
+
52
+ ## Tool selection
53
+
54
+ | 사용자 요청 | MCP 도구 | 주요 입력 |
55
+ | --- | --- | --- |
56
+ | 지역별 동물약국 목록 | `find_animal_pharmacies` | `city`, 선택 `gu`, `keyword`, `limit` |
57
+ | 제품명·분류·증상 키워드 검색 | `search_product` | `keyword` |
58
+ | 제품 취급 약국 조회 | `find_pharmacies_by_product` | `item_srl` 또는 `product_name`, 선택 `city`, `gu`, `limit` |
59
+
60
+ ### 지역별 동물약국
61
+
62
+ ```bash
63
+ npx -y @nomadamas/k-skill@0 exec animal-pharmacy-search scripts/animal_pharmacy_mcp.py -- \
64
+ call find_animal_pharmacies \
65
+ --arg city=서울 \
66
+ --arg gu=강남구 \
67
+ --arg limit=5
68
+ ```
69
+
70
+ `result._meta.pharmacies`에서 약국명, 전화번호, 주소, 행정구역, 좌표를 읽는다.
71
+ 사용자가 지역을 주지 않았다면 시·도와 시·군·구를 먼저 묻는다.
72
+
73
+ ### 제품 검색
74
+
75
+ ```bash
76
+ npx -y @nomadamas/k-skill@0 exec animal-pharmacy-search scripts/animal_pharmacy_mcp.py -- \
77
+ call search_product \
78
+ --arg keyword=항생제
79
+ ```
80
+
81
+ 제품명뿐 아니라 서버가 등록한 분류·증상 태그도 검색한다. `keyword`는 최소
82
+ 2글자여야 한다. 결과의 `item_srl`과 `item_name`을 제시하되, 검색 결과를
83
+ 진단·처방·효능 보증으로 해석하지 않는다.
84
+
85
+ ### 제품 취급 약국
86
+
87
+ 제품명이 충분히 구체적이면 `product_name`으로 바로 검색할 수 있다.
88
+
89
+ ```bash
90
+ npx -y @nomadamas/k-skill@0 exec animal-pharmacy-search scripts/animal_pharmacy_mcp.py -- \
91
+ call find_pharmacies_by_product \
92
+ --arg product_name=오리더밀 \
93
+ --arg city=서울 \
94
+ --arg limit=5
95
+ ```
96
+
97
+ 제품명이 모호하거나 동명이 여러 개면 먼저 `search_product`로 `item_srl`을
98
+ 확인한 뒤 정확 조회한다.
99
+
100
+ ```bash
101
+ npx -y @nomadamas/k-skill@0 exec animal-pharmacy-search scripts/animal_pharmacy_mcp.py -- \
102
+ call find_pharmacies_by_product \
103
+ --arg item_srl=4452 \
104
+ --arg city=서울 \
105
+ --arg limit=5
106
+ ```
107
+
108
+ ## Provenance and interpretation
109
+
110
+ `find_pharmacies_by_product` 결과에는 반드시 아래 의미를 함께 전달한다.
111
+
112
+ - 약국은 **최근 6개월 안에 홍익메디케어에서 해당 제품을 구매한 이력** 기준이다.
113
+ - 이 기준은 해당 약국의 과거 취급 근거이지 현재 재고·판매 가능 여부 보장이 아니다.
114
+ - 다른 유통사를 통한 구매나 전국 모든 동물약국을 포괄하지 않을 수 있다.
115
+ - 방문 전에 전화로 제품명과 현재 재고를 확인하도록 안내한다.
116
+
117
+ `find_animal_pharmacies`는 지역 디렉터리이며 제품 취급 여부를 뜻하지 않는다.
118
+ 제품까지 확인하려면 별도로 `find_pharmacies_by_product`를 호출한다.
119
+
120
+ ## Response style
121
+
122
+ - 보통 3~5곳만 약국명, 전화번호, 주소 순으로 정리한다.
123
+ - 제품 검색은 제품명과 `item_srl`을 함께 보여준다.
124
+ - 취급 약국 결과에는 최근 6개월 홍익메디케어 구매 이력 기준임을 한 문장으로 명시한다.
125
+ - 좌표는 사용자가 지도 연결을 원할 때만 보조 정보로 쓴다.
126
+ - 전화번호와 주소는 조회 목적에 필요한 공개 사업장 정보로만 사용한다.
127
+
128
+ ## Failure modes
129
+
130
+ - `406 Not Acceptable` 또는 SSE 요구: `Accept: application/json, text/event-stream`을 모두 보낸다.
131
+ - `keyword must be at least 2 characters`: 2글자 이상의 키워드로 다시 검색한다.
132
+ - 빈 제품 결과: 다른 제품명·분류·증상 키워드를 제안한다.
133
+ - 빈 약국 결과: `gu`를 빼고 시·도 단위로 넓히거나 지역 표기를 확인한다.
134
+ - MCP 세션 오류: 새 `initialize`로 세션을 다시 만든다. 무한 재시도하지 않는다.
135
+ - 연결 실패·5xx: 홍익메디케어 민간 MCP 장애로 보고 현재 조회 불가를 알린다.
136
+ - 현재 재고 확인 요청: MCP 결과만으로 확정하지 않고 약국 전화 확인을 안내한다.
137
+
138
+ ## Privacy
139
+
140
+ - 인증·로그인·개인정보 입력이 없는 공개 조회 전용이다.
141
+ - 사용자나 반려동물의 의료정보를 서버에 전달하지 않는다.
142
+ - 진단·처방·복약 결정을 대신하지 않는다.
143
+
144
+ ## Done when
145
+
146
+ - 지역 목록, 제품 검색, 제품 취급 약국 중 맞는 도구를 선택했다.
147
+ - 실제 MCP 응답의 `_meta` 구조를 기준으로 결과를 정리했다.
148
+ - 제품 취급 약국에는 최근 6개월 홍익메디케어 구매 이력이라는 출처와 한계를 명시했다.
149
+ - 현재 재고는 보장되지 않으므로 방문 전 전화 확인을 안내했다.
150
+ - 진단·처방 없이 조회 결과만 제공했다.
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env python3
2
+ """홍익메디케어 공개 Streamable HTTP MCP 서버를 호출한다."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import sys
10
+ import urllib.error
11
+ import urllib.request
12
+ from typing import Any, Sequence
13
+
14
+ DEFAULT_ENDPOINT = "https://hkmedi.co.kr/pharmacy-mcp"
15
+ DEFAULT_TIMEOUT_SECONDS = 30.0
16
+ PROTOCOL_VERSION = "2025-03-26"
17
+ USER_AGENT = "k-skill-animal-pharmacy/1.0"
18
+
19
+
20
+ class AnimalPharmacyMcpError(RuntimeError):
21
+ """설정 또는 MCP 호출 실패 때 발생한다."""
22
+
23
+
24
+ def parse_json_object(raw: str, *, arg_name: str) -> dict[str, Any]:
25
+ try:
26
+ value = json.loads(raw)
27
+ except json.JSONDecodeError as exc:
28
+ raise argparse.ArgumentTypeError(
29
+ f"{arg_name}은 올바른 JSON이어야 합니다: {exc}"
30
+ ) from exc
31
+ if not isinstance(value, dict):
32
+ raise argparse.ArgumentTypeError(f"{arg_name}은 JSON 객체여야 합니다")
33
+ return value
34
+
35
+
36
+ def parse_positive_float(raw: str) -> float:
37
+ try:
38
+ value = float(raw)
39
+ except ValueError as exc:
40
+ raise argparse.ArgumentTypeError("timeout은 숫자여야 합니다") from exc
41
+ if value <= 0:
42
+ raise argparse.ArgumentTypeError("timeout은 0보다 커야 합니다")
43
+ return value
44
+
45
+
46
+ def parse_kv_pairs(pairs: Sequence[str]) -> dict[str, Any]:
47
+ args: dict[str, Any] = {}
48
+ for pair in pairs:
49
+ if "=" not in pair:
50
+ raise argparse.ArgumentTypeError(
51
+ f"인자 '{pair}'는 key=value 형식이어야 합니다"
52
+ )
53
+ key, raw_value = pair.split("=", 1)
54
+ if not key:
55
+ raise argparse.ArgumentTypeError(
56
+ f"인자 '{pair}'의 key가 비어 있습니다"
57
+ )
58
+ try:
59
+ value = json.loads(raw_value)
60
+ except json.JSONDecodeError:
61
+ value = raw_value
62
+ args[key] = value
63
+ return args
64
+
65
+
66
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
67
+ parser = argparse.ArgumentParser(
68
+ description="홍익메디케어 동물약국 MCP 도구를 호출합니다.",
69
+ formatter_class=argparse.RawDescriptionHelpFormatter,
70
+ epilog=(
71
+ "예시:\n"
72
+ " animal_pharmacy_mcp.py tools\n"
73
+ " animal_pharmacy_mcp.py call find_animal_pharmacies --arg city=서울 --arg gu=강남구\n"
74
+ " animal_pharmacy_mcp.py call search_product --arg keyword=항생제\n"
75
+ " animal_pharmacy_mcp.py call find_pharmacies_by_product --arg product_name=오리더밀 --arg city=서울\n"
76
+ ),
77
+ )
78
+ parser.add_argument(
79
+ "--endpoint",
80
+ default=os.getenv("ANIMAL_PHARMACY_MCP_ENDPOINT", DEFAULT_ENDPOINT),
81
+ help="동물약국 MCP 엔드포인트(기본값: %(default)s).",
82
+ )
83
+ parser.add_argument(
84
+ "--timeout-seconds",
85
+ type=parse_positive_float,
86
+ default=DEFAULT_TIMEOUT_SECONDS,
87
+ help="각 MCP HTTP 요청 제한 시간(기본값: %(default)s초).",
88
+ )
89
+ subparsers = parser.add_subparsers(dest="command", required=True)
90
+ subparsers.add_parser(
91
+ "tools", help="사용 가능한 MCP 도구와 입력 스키마를 JSON으로 출력합니다."
92
+ )
93
+
94
+ call_parser = subparsers.add_parser(
95
+ "call", help="MCP 도구 하나를 호출하고 결과를 JSON으로 출력합니다."
96
+ )
97
+ call_parser.add_argument(
98
+ "tool",
99
+ choices=[
100
+ "find_animal_pharmacies",
101
+ "search_product",
102
+ "find_pharmacies_by_product",
103
+ ],
104
+ help="호출할 동물약국 MCP 도구명.",
105
+ )
106
+ call_parser.add_argument(
107
+ "--json",
108
+ dest="json_args",
109
+ type=lambda raw: parse_json_object(raw, arg_name="--json"),
110
+ default=None,
111
+ help="도구 인자를 JSON 객체로 전달합니다.",
112
+ )
113
+ call_parser.add_argument(
114
+ "--arg",
115
+ dest="kv_args",
116
+ action="append",
117
+ default=[],
118
+ metavar="KEY=VALUE",
119
+ help="도구 인자입니다. 반복 지정할 수 있습니다.",
120
+ )
121
+ return parser.parse_args(argv)
122
+
123
+
124
+ def parse_mcp_response(raw: bytes, content_type: str) -> dict[str, Any]:
125
+ text = raw.decode("utf-8")
126
+ if "text/event-stream" in content_type:
127
+ data_lines = [
128
+ line[6:] for line in text.splitlines() if line.startswith("data: ")
129
+ ]
130
+ if not data_lines:
131
+ raise AnimalPharmacyMcpError("MCP SSE 응답에 data 이벤트가 없습니다")
132
+ text = data_lines[-1]
133
+
134
+ try:
135
+ payload = json.loads(text)
136
+ except json.JSONDecodeError as exc:
137
+ raise AnimalPharmacyMcpError(
138
+ f"MCP 응답이 올바른 JSON이 아닙니다: {exc}"
139
+ ) from exc
140
+ if not isinstance(payload, dict):
141
+ raise AnimalPharmacyMcpError("MCP 응답이 JSON 객체가 아닙니다")
142
+ if "error" in payload:
143
+ error = payload["error"]
144
+ message = error.get("message", str(error)) if isinstance(error, dict) else str(error)
145
+ raise AnimalPharmacyMcpError(message)
146
+ return payload
147
+
148
+
149
+ def post_rpc(
150
+ endpoint: str,
151
+ payload: dict[str, Any],
152
+ *,
153
+ timeout_seconds: float,
154
+ session_id: str | None = None,
155
+ ) -> tuple[dict[str, Any], str | None]:
156
+ headers = {
157
+ "Content-Type": "application/json",
158
+ "Accept": "application/json, text/event-stream",
159
+ "User-Agent": USER_AGENT,
160
+ }
161
+ if session_id:
162
+ headers["Mcp-Session-Id"] = session_id
163
+
164
+ request = urllib.request.Request(
165
+ endpoint,
166
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
167
+ headers=headers,
168
+ method="POST",
169
+ )
170
+ try:
171
+ with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
172
+ parsed = parse_mcp_response(
173
+ response.read(),
174
+ response.headers.get("Content-Type", ""),
175
+ )
176
+ return parsed, response.headers.get("Mcp-Session-Id") or session_id
177
+ except urllib.error.HTTPError as exc:
178
+ detail = exc.read().decode("utf-8", errors="replace")
179
+ raise AnimalPharmacyMcpError(
180
+ f"동물약국 MCP HTTP {exc.code}: {detail}"
181
+ ) from exc
182
+ except urllib.error.URLError as exc:
183
+ raise AnimalPharmacyMcpError(
184
+ f"동물약국 MCP 연결 실패 {endpoint}: {exc.reason}"
185
+ ) from exc
186
+ except TimeoutError as exc:
187
+ raise AnimalPharmacyMcpError(
188
+ f"동물약국 MCP 요청 시간이 {timeout_seconds:g}초를 초과했습니다"
189
+ ) from exc
190
+
191
+
192
+ def initialize(endpoint: str, *, timeout_seconds: float) -> str:
193
+ payload, session_id = post_rpc(
194
+ endpoint,
195
+ {
196
+ "jsonrpc": "2.0",
197
+ "id": 1,
198
+ "method": "initialize",
199
+ "params": {
200
+ "protocolVersion": PROTOCOL_VERSION,
201
+ "capabilities": {},
202
+ "clientInfo": {
203
+ "name": "k-skill-animal-pharmacy",
204
+ "version": "1.0.0",
205
+ },
206
+ },
207
+ },
208
+ timeout_seconds=timeout_seconds,
209
+ )
210
+ if "result" not in payload:
211
+ raise AnimalPharmacyMcpError("MCP initialize 응답에 result가 없습니다")
212
+ if not session_id:
213
+ raise AnimalPharmacyMcpError("MCP initialize 응답에 세션 ID가 없습니다")
214
+ return session_id
215
+
216
+
217
+ def run_mcp(
218
+ endpoint: str,
219
+ command: str,
220
+ tool: str | None = None,
221
+ arguments: dict[str, Any] | None = None,
222
+ *,
223
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
224
+ ) -> Any:
225
+ session_id = initialize(endpoint, timeout_seconds=timeout_seconds)
226
+ if command == "tools":
227
+ payload = {
228
+ "jsonrpc": "2.0",
229
+ "id": 2,
230
+ "method": "tools/list",
231
+ "params": {},
232
+ }
233
+ elif command == "call" and tool:
234
+ payload = {
235
+ "jsonrpc": "2.0",
236
+ "id": 2,
237
+ "method": "tools/call",
238
+ "params": {"name": tool, "arguments": arguments or {}},
239
+ }
240
+ else:
241
+ raise AnimalPharmacyMcpError(f"지원하지 않는 명령입니다: {command}")
242
+
243
+ response, _ = post_rpc(
244
+ endpoint,
245
+ payload,
246
+ timeout_seconds=timeout_seconds,
247
+ session_id=session_id,
248
+ )
249
+ return response.get("result")
250
+
251
+
252
+ def main(argv: Sequence[str] | None = None) -> int:
253
+ args = parse_args(argv)
254
+ tool_args: dict[str, Any] | None = None
255
+ if args.command == "call":
256
+ tool_args = dict(args.json_args or {})
257
+ tool_args.update(parse_kv_pairs(args.kv_args))
258
+
259
+ try:
260
+ result = run_mcp(
261
+ args.endpoint,
262
+ args.command,
263
+ getattr(args, "tool", None),
264
+ tool_args,
265
+ timeout_seconds=args.timeout_seconds,
266
+ )
267
+ except AnimalPharmacyMcpError as exc:
268
+ print(f"animal_pharmacy_mcp.py: {exc}", file=sys.stderr)
269
+ return 2
270
+
271
+ print(json.dumps(result, ensure_ascii=False, indent=2))
272
+ return 0
273
+
274
+
275
+ if __name__ == "__main__":
276
+ raise SystemExit(main())
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "animal-pharmacy-search",
3
+ "description": "홍익메디케어 공개 MCP 서버로 지역별 동물약국 목록, 동물용의약품 검색, 최근 6개월 구매 이력 기반 취급 약국을 조회한다. 조회 전용.",
4
+ "profiles": [
5
+ "lookup"
6
+ ],
7
+ "frontmatter": "name: animal-pharmacy-search\ndescription: 홍익메디케어 공개 MCP 서버로 지역별 동물약국 목록, 동물용의약품 검색, 최근 6개월 구매 이력 기반 취급 약국을 조회한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: health\n locale: ko-KR\n phase: v1"
8
+ }
@@ -0,0 +1,30 @@
1
+ # 소비자 가격·안전 조회
2
+
3
+ ## Draft status
4
+
5
+ 이 스킬은 #390의 **draft scaffold**다. 참가격과 소비자24는 서로 다른
6
+ upstream이다. 소비자24는 `openapiSvcId`(서비스 카탈로그 ID), 서비스별
7
+ 승인 `serviceKey`, 리콜 메뉴 `cntntsId`를 분리해 관리해야 한다.
8
+
9
+ ## Planned v1
10
+
11
+ - 참가격 품목·지역별 가격
12
+ - 소비자24 물품정보
13
+ - 소비자24 전체 신청 서비스의 리콜 정보
14
+ - 서비스별 키 allowlist와 공식 오류 코드 보존
15
+
16
+ ## Credential model
17
+
18
+ `serviceKey`만 secret이며 gpu01 runtime env에 보관한다. `openapiSvcId`와
19
+ `cntntsId`는 공개 매핑이므로 저장소 코드에 둘 수 있다. 최종 env 이름과
20
+ 서비스별 성공 여부는 live probe 후 확정한다.
21
+
22
+ ## Official sources
23
+
24
+ - 참가격: https://www.data.go.kr/dataset/3043385/openapi.do
25
+ - 소비자24 목록: https://www.consumer.go.kr/user/ftc/consumer/openApiSvcUser/120/selectOpenApiSvcList.do
26
+
27
+ ## Draft stop condition
28
+
29
+ 전체 신청 서비스별 키가 실제 성공하는지 확인하고 실패하는 live E2E부터
30
+ 추가한 뒤 proxy route, helper, parser, 문서와 manual QA를 완성한다.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "consumer-price-safety-search",
3
+ "description": "한국소비자원 참가격과 소비자24 물품·리콜정보를 공식 API로 조회한다. 현재는 서비스별 인증키·메뉴 매핑을 검증하는 draft scaffold다.",
4
+ "profiles": ["proxy", "lookup"],
5
+ "frontmatter": "name: consumer-price-safety-search\ndescription: 한국소비자원 참가격과 소비자24 물품·리콜정보를 공식 API로 조회한다. 현재는 서비스별 인증키·메뉴 매핑을 검증하는 draft scaffold다.\nlicense: MIT\nmetadata:\n category: consumer\n locale: ko-KR\n phase: draft"
6
+ }
@@ -0,0 +1,52 @@
1
+ # 정부지원 전수조사
2
+
3
+ ## What this skill does
4
+
5
+ `k-skill-proxy`의 `/v1/government-support/survey`를 호출해 다음 공개 공고를 하나의 스키마로 조사한다.
6
+
7
+ - K-Startup Open API
8
+ - 기업마당
9
+ - 정보통신산업진흥원(NIPA)
10
+ - 한국콘텐츠진흥원(KOCCA)
11
+ - 중소기업기술개발사업 종합관리시스템(SMTECH)
12
+
13
+ 이 스킬은 현재 모집 공고의 제목·분야·주관기관·접수기간·공식 URL을 수집하고, 사용자의 프로젝트 조건과 대조해 `즉시 지원 가능`, `요건 충족 시 가능`, `사업 변형 시 가능`, `부적합`으로 보수적으로 분류한다.
14
+
15
+ ## Required workflow
16
+
17
+ 1. 회사·팀·프로젝트의 업력, 소재지, 업종, 기술, 대표자 연령, 기업 형태, 매출·투자 단계, 원하는 지원 유형을 확인한다.
18
+ 2. 먼저 5개 소스를 모두 조회한다. 사용자가 범위를 좁힌 경우에만 `--sources`를 제한한다.
19
+ 3. 응답의 `complete`와 소스별 `ok`를 확인한다. 하나라도 실패하면 “전수조사 완료”라고 표현하지 말고 누락 소스를 명시한다.
20
+ 4. 제목만으로 자격을 확정하지 않는다. 후보의 공식 상세 URL과 첨부 공고문에서 신청대상·제외조건·지역·업력·중복수혜 제한을 확인한다.
21
+ 5. 결과마다 공식 출처 URL, 접수 마감일, 판정 근거, 추가 확인사항을 표시한다.
22
+ 6. 실제 신청은 공식 사이트에서 진행한다. 최종 제출 직전에는 사용자 승인을 받는다.
23
+
24
+ ## Command
25
+
26
+ ```bash
27
+ npx -y @nomadamas/k-skill@0 exec government-support-survey scripts/run_survey.py -- \
28
+ --sources kstartup bizinfo nipa kocca smtech \
29
+ --keyword "AI 바우처" \
30
+ --max-pages 3
31
+ ```
32
+
33
+ 전체 공고를 넓게 조사할 때는 `--keyword`를 생략한다. 응답이 너무 클 때만 공고 유형별 키워드로 여러 번 나눠 조회한다.
34
+
35
+ ## Output contract
36
+
37
+ - `complete`: 요청한 모든 소스가 정상 조사됐는지
38
+ - `sources`: 소스별 성공 여부, 조회 페이지, 수집 건수, 오류
39
+ - `items`: `source`, `id`, `title`, `field`, `org`, `apply_start`, `apply_end`, `reg_date`, `url`
40
+ - `attribution`: upstream 코드와 공식 데이터 출처, 재배포 범위
41
+
42
+ ## Failure modes
43
+
44
+ - `complete=false`: 일부 소스 실패. 수집된 결과는 부분 결과이며 누락 소스를 수동 확인한다.
45
+ - `401/403` 또는 차단 신호: 우회하지 말고 공식 브라우저 화면으로 전환한다.
46
+ - `0 items parsed; site layout may have changed`: 파서 개편 가능성이므로 해당 소스를 성공 처리하지 않는다.
47
+ - K-Startup 키 미설정: 나머지 공개 포털 결과는 유지하되 K-Startup 누락을 명시한다.
48
+ - CAPTCHA·로그인·본인인증: 자동 우회하지 않는다.
49
+
50
+ ## Legal and redistribution boundary
51
+
52
+ 이 스킬의 조사 로직은 `djfksjd/ir-search`의 MIT 라이선스 구현을 참고해 재작성했으며 저작권·라이선스 고지는 `npx -y @nomadamas/k-skill@0 read government-support-survey references/NOTICE.md`에 보존한다. 정부 포털 공고 원문과 첨부는 포털별 이용조건이 다르므로 k-skill은 이를 미러링하거나 재배포하지 않고 구조화 메타데이터와 공식 링크만 제공한다.
@@ -0,0 +1,30 @@
1
+ # 출처, 라이선스, 재배포 범위
2
+
3
+ ## 참고한 오픈소스
4
+
5
+ - 프로젝트: [djfksjd/ir-search](https://github.com/djfksjd/ir-search)
6
+ - 라이선스: [MIT License](https://github.com/djfksjd/ir-search/blob/main/LICENSE)
7
+ - 원 저작권 표시: `Copyright (c) 2026 ir-search contributors`
8
+
9
+ MIT 라이선스는 사용·수정·병합·배포·재라이선스를 허용하며, 소프트웨어의 복제물 또는 중요한 부분에 저작권 고지와 허가 고지를 포함해야 한다. 이 문서는 해당 attribution을 보존한다.
10
+
11
+ 이번 통합은 upstream 파일을 그대로 복제하지 않고, 공개 포털 접근 경로·정규화 스키마·fail-closed 원칙을 참고해 k-skill-proxy의 Node.js 구현과 k-skill CLI 계약으로 재작성했다.
12
+
13
+ ## 공식 데이터 출처
14
+
15
+ | 소스 | 공식 URL | k-skill 처리 |
16
+ |---|---|---|
17
+ | K-Startup | https://www.data.go.kr/data/15125364/openapi.do | 공공데이터포털 Open API 응답의 공고 메타데이터와 링크 |
18
+ | 기업마당 | https://www.bizinfo.go.kr/ | 공개 모집 목록의 메타데이터와 공식 상세 링크 |
19
+ | NIPA | https://www.nipa.kr/home/2-2 | 공개 사업공고 목록의 메타데이터와 공식 상세 링크 |
20
+ | KOCCA | https://www.kocca.kr/kocca/pims/list.do | 공개 지원공고 목록의 메타데이터와 공식 상세 링크 |
21
+ | SMTECH | https://www.smtech.go.kr/front/ifg/no/notice02_list.do | 공개 R&D 공고 목록의 메타데이터와 공식 상세 링크 |
22
+
23
+ ## 재배포 판단
24
+
25
+ - **오픈소스 코드**: MIT 조건에 따라 attribution을 유지하면 재사용·수정·재배포 가능.
26
+ - **K-Startup Open API 데이터**: 데이터셋 상세 페이지의 이용조건을 따라 사용하며 출처를 표시한다.
27
+ - **각 기관의 공고 원문·첨부파일**: 개별 저작권·공공누리·제3자 권리가 다를 수 있으므로 일괄 재배포 가능하다고 간주하지 않는다.
28
+ - **k-skill-proxy 응답**: 제목, 기관, 날짜, 식별자, 공식 링크 같은 사실 메타데이터만 구조화한다. 공고 본문·첨부파일을 저장하거나 미러링하지 않는다.
29
+
30
+ 공고 내용을 인용하거나 첨부파일을 재배포해야 하는 별도 기능은 각 자료에 표시된 공공누리 유형과 이용약관을 건별 확인한 뒤 구현해야 한다.
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """Government support survey helper. Proxy-first, stdlib only."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
14
+ VALID_SOURCES = ("kstartup", "bizinfo", "nipa", "kocca", "smtech")
15
+
16
+
17
+ def build_url(args: argparse.Namespace) -> str:
18
+ base = (args.proxy_base_url or os.environ.get("KSKILL_PROXY_BASE_URL")
19
+ or DEFAULT_PROXY_BASE_URL).rstrip("/")
20
+ params = {
21
+ "sources": ",".join(args.sources),
22
+ "maxPages": str(args.max_pages),
23
+ "perPage": str(args.per_page),
24
+ }
25
+ if args.keyword:
26
+ params["keyword"] = args.keyword
27
+ return f"{base}/v1/government-support/survey?{urllib.parse.urlencode(params)}"
28
+
29
+
30
+ def parser() -> argparse.ArgumentParser:
31
+ result = argparse.ArgumentParser(
32
+ description="K-Startup·기업마당·NIPA·KOCCA·SMTECH 정부지원 공고 전수조사"
33
+ )
34
+ result.add_argument("--sources", nargs="+", choices=VALID_SOURCES, default=list(VALID_SOURCES))
35
+ result.add_argument("--keyword", default="")
36
+ result.add_argument("--max-pages", type=int, default=1)
37
+ result.add_argument("--per-page", type=int, default=100)
38
+ result.add_argument("--proxy-base-url")
39
+ result.add_argument("--timeout", type=float, default=30)
40
+ result.add_argument("--compact", action="store_true")
41
+ return result
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ args = parser().parse_args(argv)
46
+ if not 1 <= args.max_pages <= 10:
47
+ parser().error("--max-pages must be between 1 and 10")
48
+ if not 1 <= args.per_page <= 100:
49
+ parser().error("--per-page must be between 1 and 100")
50
+ request = urllib.request.Request(
51
+ build_url(args),
52
+ headers={"Accept": "application/json", "User-Agent": "k-skill-government-support-survey/1.0"},
53
+ )
54
+ try:
55
+ with urllib.request.urlopen(request, timeout=args.timeout) as response:
56
+ payload = json.load(response)
57
+ except urllib.error.HTTPError as error:
58
+ body = error.read().decode("utf-8", errors="replace")
59
+ print(body or f"HTTP {error.code}", file=sys.stderr)
60
+ return 2
61
+ except (urllib.error.URLError, TimeoutError) as error:
62
+ print(f"k-skill-proxy request failed: {error}", file=sys.stderr)
63
+ return 2
64
+
65
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=None if args.compact else 2)
66
+ sys.stdout.write("\n")
67
+ return 0 if payload.get("complete") else 2
68
+
69
+
70
+ if __name__ == "__main__":
71
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "government-support-survey",
3
+ "description": "K-Startup·기업마당·NIPA·KOCCA·SMTECH의 공개 정부지원 공고를 k-skill-proxy로 전수조사하고 프로젝트 적합성을 보수적으로 분류한다. 사용자가 정부지원사업, 창업지원, 사업화·R&D·바우처·입주공간 공고를 한 번에 찾아달라고 할 때 사용한다.",
4
+ "profiles": [
5
+ "proxy",
6
+ "browser",
7
+ "action:submission"
8
+ ],
9
+ "frontmatter": "name: government-support-survey\ndescription: K-Startup·기업마당·NIPA·KOCCA·SMTECH의 공개 정부지원 공고를 k-skill-proxy로 전수조사하고 프로젝트 적합성을 보수적으로 분류한다. 사용자가 정부지원사업, 창업지원, 사업화·R&D·바우처·입주공간 공고를 한 번에 찾아달라고 할 때 사용한다."
10
+ }