@nomadamas/k-skill 0.4.3 → 0.5.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 +1 -1
- package/skills/animal-pharmacy-search/instruction.md +150 -0
- package/skills/animal-pharmacy-search/scripts/animal_pharmacy_mcp.py +276 -0
- package/skills/animal-pharmacy-search/skill.json +8 -0
- package/skills/consumer-price-safety-search/instruction.md +30 -0
- package/skills/consumer-price-safety-search/skill.json +6 -0
- package/skills/government-support-survey/instruction.md +52 -0
- package/skills/government-support-survey/references/NOTICE.md +30 -0
- package/skills/government-support-survey/scripts/run_survey.py +71 -0
- package/skills/government-support-survey/skill.json +10 -0
- package/skills/kamis-food-price/instruction.md +66 -0
- package/skills/kamis-food-price/scripts/run_kamis.py +110 -0
- package/skills/kamis-food-price/skill.json +9 -0
- package/skills/korean-cinema-search/instruction.md +7 -1
- package/skills/mofa-travel-safety/instruction.md +62 -0
- package/skills/mofa-travel-safety/scripts/run_mofa_travel_safety.py +57 -0
- package/skills/mofa-travel-safety/skill.json +9 -0
- package/skills/seoul-weather-risk/instruction.md +4 -1
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +99 -3
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/templates/browser.md +1 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# KAMIS 농수축산물 가격 조회
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
한국농수산식품유통공사 KAMIS의 `dailyPriceByCategoryList`를
|
|
6
|
+
`k-skill-proxy`의 좁은 read-only route로 호출한다. 농산물·축산물·수산물의
|
|
7
|
+
부류별 도매/소매 가격과 전일, 1주일, 1개월, 1년, 평년 비교값을 반환한다.
|
|
8
|
+
|
|
9
|
+
이 skill은 쇼핑몰 최저가나 투자 추천이 아니라 공식 유통가격 조회용이다.
|
|
10
|
+
|
|
11
|
+
## Inputs
|
|
12
|
+
|
|
13
|
+
- `p_productclscode`: `01` 소매, `02` 도매. 기본 `01`
|
|
14
|
+
- `p_itemcategorycode`: `100` 식량작물, `200` 채소류, `300` 특용작물,
|
|
15
|
+
`400` 과일류, `500` 축산물, `600` 수산물. 기본 `100`
|
|
16
|
+
- `p_countycode`: 지역 코드(예: `1101` 서울). 생략하면 전체 지역
|
|
17
|
+
- `p_regday`: `YYYY-MM-DD`, 생략하면 최근 조사일
|
|
18
|
+
- `p_convert_kg_yn`: `Y` 또는 `N`. 기본 `N`
|
|
19
|
+
|
|
20
|
+
사용자가 문서식 별칭인 `p_product_cls_code`, `p_country_code`,
|
|
21
|
+
`p_item_category_code`를 주어도 proxy가 실제 upstream 계약명으로 정규화한다.
|
|
22
|
+
|
|
23
|
+
## Workflow
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
|
27
|
+
curl -fsS --get "$BASE/v1/kamis/food-price/daily-category" \
|
|
28
|
+
--data-urlencode "p_productclscode=01" \
|
|
29
|
+
--data-urlencode "p_itemcategorycode=200" \
|
|
30
|
+
--data-urlencode "p_countycode=1101" \
|
|
31
|
+
--data-urlencode "p_convert_kg_yn=N"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
응답의 `items` 각 항목에서 `item_name`, `kind_name`, `rank`, `unit`,
|
|
35
|
+
`dpr1`(조회일), `dpr2`(1일 전), `dpr3`(1주일 전), `dpr5`(1개월 전),
|
|
36
|
+
`dpr6`(1년 전), `dpr7`(평년)을 확인한다. 가격 문자열의 쉼표와 `-`를
|
|
37
|
+
숫자로 바꿀 때는 빈 값 여부를 먼저 보존한다.
|
|
38
|
+
|
|
39
|
+
## Access path and credentials
|
|
40
|
+
|
|
41
|
+
일반 사용자는 hosted `k-skill-proxy`를 사용하므로 KAMIS 키가 필요 없다.
|
|
42
|
+
proxy 운영자는 KAMIS에서 발급받은 `KAMIS_API_KEY`를 서버 runtime env에
|
|
43
|
+
보관한다. upstream의 `p_cert_id`는 별도 API key가 아니라 요청자 ID
|
|
44
|
+
파라미터이며, 현재 proxy는 live 계약에서 검증한 `TEST` 값을 사용한다.
|
|
45
|
+
키를 URL, 로그, 저장소, 사용자 응답에 노출하지 않는다.
|
|
46
|
+
|
|
47
|
+
공식 문서:
|
|
48
|
+
<https://www.kamis.or.kr/customer/reference/openapi_list.do?action=detail&boardno=1>
|
|
49
|
+
|
|
50
|
+
실제 upstream 경로는 `xml.do`이지만 `p_returntype=json`을 사용한다. 이는
|
|
51
|
+
KAMIS의 공식 계약이며 URL을 `json.do`로 바꾸지 않는다.
|
|
52
|
+
|
|
53
|
+
## Failure modes
|
|
54
|
+
|
|
55
|
+
- `400 bad_request`: 잘못된 날짜, 지역 코드, 부류 코드, 도소매 값
|
|
56
|
+
- `503 upstream_not_configured`: proxy runtime에 `KAMIS_API_KEY` 없음
|
|
57
|
+
- `400` 또는 `502 upstream_error`: KAMIS code `200`, `900`, 네트워크/상류 오류
|
|
58
|
+
- `items: []`, upstream code `001`: 해당 조건의 가격 없음
|
|
59
|
+
- KAMIS가 JSON 대신 점검 HTML/XML을 반환하면 `upstream_invalid_response`
|
|
60
|
+
- 가격은 조사 시점의 공식 데이터이며 구매 보장·법률 판단·투자 조언이 아니다
|
|
61
|
+
|
|
62
|
+
## Done when
|
|
63
|
+
|
|
64
|
+
- 조회 조건과 `query`가 응답에 남아 있다.
|
|
65
|
+
- 각 가격 항목의 조회일과 비교 기간을 구분했다.
|
|
66
|
+
- 답변에 KAMIS 원문 출처와 조회 조건을 함께 적었다.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read KAMIS prices through the hosted proxy, with an optional direct mode."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROXY = "https://k-skill-proxy.nomadamas.org"
|
|
12
|
+
UPSTREAM = "https://www.kamis.or.kr/service/price/xml.do"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def secrets(path):
|
|
16
|
+
values = {}
|
|
17
|
+
try:
|
|
18
|
+
with open(path, encoding="utf-8") as stream:
|
|
19
|
+
for line in stream:
|
|
20
|
+
line = line.strip()
|
|
21
|
+
if line and not line.startswith("#") and "=" in line:
|
|
22
|
+
key, value = line.split("=", 1)
|
|
23
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
24
|
+
except OSError:
|
|
25
|
+
pass
|
|
26
|
+
return values
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def query(args):
|
|
30
|
+
if args.product_class not in {"01", "02"}:
|
|
31
|
+
raise ValueError("--product-class must be 01 or 02")
|
|
32
|
+
if args.category not in {"100", "200", "300", "400", "500", "600"}:
|
|
33
|
+
raise ValueError("--category must be 100, 200, 300, 400, 500, or 600")
|
|
34
|
+
if args.convert_kg not in {"Y", "N"}:
|
|
35
|
+
raise ValueError("--convert-kg must be Y or N")
|
|
36
|
+
result = {
|
|
37
|
+
"p_productclscode": args.product_class,
|
|
38
|
+
"p_itemcategorycode": args.category,
|
|
39
|
+
"p_convert_kg_yn": args.convert_kg,
|
|
40
|
+
"p_returntype": "json",
|
|
41
|
+
}
|
|
42
|
+
if args.county:
|
|
43
|
+
if not args.county.isdigit() or len(args.county) != 4:
|
|
44
|
+
raise ValueError("--county must be a four-digit KAMIS code")
|
|
45
|
+
result["p_countycode"] = args.county
|
|
46
|
+
if args.date:
|
|
47
|
+
if len(args.date) != 10 or args.date[4] != "-" or args.date[7] != "-":
|
|
48
|
+
raise ValueError("--date must be YYYY-MM-DD")
|
|
49
|
+
result["p_regday"] = args.date
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
parser = argparse.ArgumentParser(description="KAMIS 농수축산물 가격 조회")
|
|
55
|
+
parser.add_argument("--product-class", default="01")
|
|
56
|
+
parser.add_argument("--category", default="100")
|
|
57
|
+
parser.add_argument("--county")
|
|
58
|
+
parser.add_argument("--date")
|
|
59
|
+
parser.add_argument("--convert-kg", default="N")
|
|
60
|
+
parser.add_argument("--text", action="store_true")
|
|
61
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
62
|
+
parser.add_argument("--direct", action="store_true")
|
|
63
|
+
parser.add_argument("--proxy-base-url", default=os.getenv("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY))
|
|
64
|
+
parser.add_argument("--secrets-path", default=os.path.expanduser("~/.config/k-skill/secrets.env"))
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
key = None
|
|
67
|
+
try:
|
|
68
|
+
params = query(args)
|
|
69
|
+
except ValueError as error:
|
|
70
|
+
print(f"[error] {error}", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
if args.direct:
|
|
74
|
+
local = secrets(args.secrets_path)
|
|
75
|
+
key = os.getenv("KSKILL_KAMIS_API_KEY") or local.get("KSKILL_KAMIS_API_KEY")
|
|
76
|
+
if not key:
|
|
77
|
+
print("[error] --direct requires KSKILL_KAMIS_API_KEY", file=sys.stderr)
|
|
78
|
+
return 3
|
|
79
|
+
assert key is not None
|
|
80
|
+
params = {**params, "action": "dailyPriceByCategoryList", "p_cert_key": key, "p_cert_id": "TEST"}
|
|
81
|
+
url = f"{UPSTREAM}?{urllib.parse.urlencode(params)}"
|
|
82
|
+
else:
|
|
83
|
+
url = f"{args.proxy_base_url.rstrip('/')}/v1/kamis/food-price/daily-category?{urllib.parse.urlencode(params)}"
|
|
84
|
+
|
|
85
|
+
if args.dry_run:
|
|
86
|
+
display_params = {**params}
|
|
87
|
+
if args.direct:
|
|
88
|
+
display_params["p_cert_key"] = "<redacted>"
|
|
89
|
+
display_url = url.replace(key, "<redacted>") if args.direct and key is not None else url
|
|
90
|
+
print(json.dumps({"url": display_url, "query": display_params}, ensure_ascii=False, indent=2))
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"accept": "application/json"}), timeout=30) as response:
|
|
95
|
+
payload = json.loads(response.read())
|
|
96
|
+
except (OSError, ValueError, urllib.error.HTTPError) as error:
|
|
97
|
+
print(f"[error] KAMIS request failed: {error}", file=sys.stderr)
|
|
98
|
+
return 4
|
|
99
|
+
|
|
100
|
+
if args.text:
|
|
101
|
+
items = payload.get("items", payload.get("data", {}).get("item", []))
|
|
102
|
+
for item in items:
|
|
103
|
+
print(f"{item.get('item_name', '?')} {item.get('kind_name', '')} {item.get('dpr1', '-')}{item.get('unit', '')}")
|
|
104
|
+
else:
|
|
105
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kamis-food-price",
|
|
3
|
+
"description": "KAMIS 농수축산물 유통 Open API를 k-skill-proxy 경유로 호출해 부류별 도소매 가격과 전일·주간·월간·연간 비교값을 조회한다. 조회 전용.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"proxy",
|
|
6
|
+
"lookup"
|
|
7
|
+
],
|
|
8
|
+
"frontmatter": "name: kamis-food-price\ndescription: KAMIS 농수축산물 유통 Open API를 k-skill-proxy 경유로 호출해 부류별 도소매 가격과 전일·주간·월간·연간 비교값을 조회한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: food\n locale: ko-KR\n phase: v1"
|
|
9
|
+
}
|
|
@@ -136,6 +136,12 @@ CGV는 시간표 중심으로 본다.
|
|
|
136
136
|
npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
+
CGV 공개 endpoint가 `503 CGV_UPSTREAM_UNAVAILABLE`을 반환하면 CGV 공식 웹사이트
|
|
140
|
+
(`https://www.cgv.co.kr`)를 브라우저 사용 스킬로 대체 조회한다. 이때도 공개 상영정보
|
|
141
|
+
조회만 수행하고, 로그인·예매·결제는 진행하지 않는다. 공식 웹사이트에서 비정상 접속
|
|
142
|
+
차단, CAPTCHA, 브라우저 검증 또는 유사한 접근 제한이 표시되면 이를 우회하지 말고
|
|
143
|
+
CGV 조회를 중단한다.
|
|
144
|
+
|
|
139
145
|
메가박스와 롯데시네마는 잔여석 endpoint를 사용할 수 있다.
|
|
140
146
|
|
|
141
147
|
```bash
|
|
@@ -165,7 +171,7 @@ npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YY
|
|
|
165
171
|
|
|
166
172
|
## Failure modes
|
|
167
173
|
|
|
168
|
-
-
|
|
174
|
+
- CGV 공개 endpoint가 CGV 원본 서비스의 봇 차단·접속 제한을 받으면 `503 CGV_UPSTREAM_UNAVAILABLE`을 반환할 수 있다. 이 응답은 skill 또는 CLI 입력 오류가 아니라 upstream 접근 실패이므로 CGV 공식 웹사이트를 브라우저 사용 스킬로 대체 조회한다. 공식 웹사이트에서도 차단, CAPTCHA, 브라우저 검증 또는 유사한 접근 제한이 표시되면 우회하지 말고 중단한다.
|
|
169
175
|
- 지역 키워드가 넓으면 다른 지점이 섞일 수 있다.
|
|
170
176
|
- 시간표와 잔여석은 시점에 따라 달라진다.
|
|
171
177
|
- 일부 체인은 상영작, 시간표, 잔여석 endpoint의 입력값이 다르므로 theaterId, movieId가 있으면 그 값을 우선 사용한다.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# 외교부 해외안전·여행경보 조회
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
외교부 `국가·지역별 여행경보 목록 조회(0404 대륙정보)` API를
|
|
6
|
+
`k-skill-proxy`의 좁은 read-only route로 조회한다. 국가명 또는 ISO 2자리
|
|
7
|
+
코드로 공식 여행경보 단계, 지역 유형, 경보 내용, 작성일과 공식 지도 URL을
|
|
8
|
+
확인한다.
|
|
9
|
+
|
|
10
|
+
이 skill은 자체적인 안전/위험 점수나 여행 허가 판단을 만들지 않는다.
|
|
11
|
+
응답된 외교부 공식 경보와 원문 링크를 사실 그대로 요약한다.
|
|
12
|
+
|
|
13
|
+
## Inputs
|
|
14
|
+
|
|
15
|
+
- `country_iso_alp2`: ISO 2자리 국가코드(예: `RU`)
|
|
16
|
+
- `country_nm`: 한글 국가명(예: `러시아`)
|
|
17
|
+
- `page`: 기본 `1`
|
|
18
|
+
- `perPage`: 기본 `10`, 최대 `100`
|
|
19
|
+
|
|
20
|
+
국가명과 ISO 코드는 동시에 주지 않는다.
|
|
21
|
+
|
|
22
|
+
## Workflow
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
|
26
|
+
curl -fsS --get "$BASE/v1/mofa-travel-safety/travel-alerts" \
|
|
27
|
+
--data-urlencode "country_iso_alp2=RU" \
|
|
28
|
+
--data-urlencode "perPage=10"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
응답 `items`의 `country_nm`, `country_iso_alp2`, `alarm_lvl`,
|
|
32
|
+
`region_ty`, `remark`, `written_dt`, `dang_map_download_url`을 원문 기준으로
|
|
33
|
+
요약한다. `alarm_lvl` 숫자의 의미는 답변에서 임의로 재분류하지 말고
|
|
34
|
+
외교부 원문/0404 설명을 함께 제시한다.
|
|
35
|
+
|
|
36
|
+
## Access path and credentials
|
|
37
|
+
|
|
38
|
+
일반 사용자는 hosted `k-skill-proxy`를 사용하므로 data.go.kr 키가 필요
|
|
39
|
+
없다. proxy 운영자는 승인된 `DATA_GO_KR_API_KEY`를 서버 runtime env에
|
|
40
|
+
보관한다. 키는 URL, 로그, 저장소, 사용자 응답에 노출하지 않는다.
|
|
41
|
+
|
|
42
|
+
공식 API:
|
|
43
|
+
<https://www.data.go.kr/data/15095500/openapi.do>
|
|
44
|
+
|
|
45
|
+
실제 upstream:
|
|
46
|
+
`https://apis.data.go.kr/1262000/TravelAlarmService0404/getTravelAlarm0404List`
|
|
47
|
+
|
|
48
|
+
## Fallback and failure modes
|
|
49
|
+
|
|
50
|
+
- 먼저 proxy route를 사용하고, 실패하면 공식 data.go.kr API 문서와 0404
|
|
51
|
+
원문 링크만 안내한다. 임의의 안전 판정을 대신하지 않는다.
|
|
52
|
+
- `400 bad_request`: 잘못된 ISO 코드, 국가명, 페이지 값
|
|
53
|
+
- `503 upstream_not_configured`: proxy runtime에 data.go.kr 키 없음
|
|
54
|
+
- `502 upstream_error`: 외교부/data.go.kr 인증·quota·상류 장애
|
|
55
|
+
- `upstream_invalid_response`: JSON 대신 XML/HTML 오류 응답
|
|
56
|
+
- `items: []`: 해당 조건에 맞는 국가/지역 기록 없음
|
|
57
|
+
|
|
58
|
+
## Done when
|
|
59
|
+
|
|
60
|
+
- 국가명 또는 ISO 코드와 조회 시각을 답변에 적었다.
|
|
61
|
+
- `alarm_lvl`, `region_ty`, `remark`를 공식 필드명과 함께 제시했다.
|
|
62
|
+
- 원문 API 문서와 0404 공식 링크를 함께 제공했다.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read MOFA 0404 travel alerts through the hosted proxy."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROXY = "https://k-skill-proxy.nomadamas.org"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv=None):
|
|
15
|
+
parser = argparse.ArgumentParser(description="외교부 국가별 여행경보 조회")
|
|
16
|
+
parser.add_argument("--country-iso")
|
|
17
|
+
parser.add_argument("--country-name")
|
|
18
|
+
parser.add_argument("--page", type=int, default=1)
|
|
19
|
+
parser.add_argument("--per-page", type=int, default=10)
|
|
20
|
+
parser.add_argument("--text", action="store_true")
|
|
21
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
22
|
+
parser.add_argument("--proxy-base-url", default=os.getenv("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY))
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
if args.country_iso and args.country_name:
|
|
25
|
+
print("[error] use either --country-iso or --country-name", file=sys.stderr)
|
|
26
|
+
return 2
|
|
27
|
+
if not 1 <= args.page or not 1 <= args.per_page <= 100:
|
|
28
|
+
print("[error] invalid page or per-page", file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
params = {"page": args.page, "perPage": args.per_page}
|
|
31
|
+
if args.country_iso:
|
|
32
|
+
if len(args.country_iso) != 2 or not args.country_iso.isalpha():
|
|
33
|
+
print("[error] --country-iso must be two letters", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
params["country_iso_alp2"] = args.country_iso.upper()
|
|
36
|
+
if args.country_name:
|
|
37
|
+
params["country_nm"] = args.country_name
|
|
38
|
+
url = f"{args.proxy_base_url.rstrip('/')}/v1/mofa-travel-safety/travel-alerts?{urllib.parse.urlencode(params)}"
|
|
39
|
+
if args.dry_run:
|
|
40
|
+
print(json.dumps({"url": url, "query": params}, ensure_ascii=False, indent=2))
|
|
41
|
+
return 0
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"accept": "application/json"}), timeout=30) as response:
|
|
44
|
+
payload = json.loads(response.read())
|
|
45
|
+
except (OSError, ValueError, urllib.error.HTTPError) as error:
|
|
46
|
+
print(f"[error] MOFA request failed: {error}", file=sys.stderr)
|
|
47
|
+
return 3
|
|
48
|
+
if args.text:
|
|
49
|
+
for item in payload.get("items", []):
|
|
50
|
+
print(f"{item.get('country_nm')} ({item.get('country_iso_alp2')}): level={item.get('alarm_lvl')} region={item.get('region_ty')}")
|
|
51
|
+
else:
|
|
52
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mofa-travel-safety",
|
|
3
|
+
"description": "외교부 0404 국가·지역별 여행경보 Open API를 k-skill-proxy 경유로 조회해 국가별 공식 경보 단계와 원문 정보를 제공한다. 조회 전용.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"proxy",
|
|
6
|
+
"lookup"
|
|
7
|
+
],
|
|
8
|
+
"frontmatter": "name: mofa-travel-safety\ndescription: 외교부 0404 국가·지역별 여행경보 Open API를 k-skill-proxy 경유로 조회해 국가별 공식 경보 단계와 원문 정보를 제공한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1"
|
|
9
|
+
}
|
|
@@ -51,6 +51,8 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
|
|
|
51
51
|
|
|
52
52
|
`--filter`가 필요하거나 게시 계약을 점검해야 할 때는 `--fast`를 빼고 full-contract query를 사용한다. fast query가 `product_not_ready` 또는 계약 오류를 반환하면 fixture나 추정값으로 대체하지 말고 아래 진단 흐름을 수행한다.
|
|
53
53
|
|
|
54
|
+
`--from`/`--to`에 날짜만 넣으면 그날 `00:00:00`–`23:59:59`로 확장한다. ASK 서울 serving window가 자정부터 열려 있지 않아 `422 query_window_unavailable`이 오면 helper는 요청 구간과 `available_from_at`/`available_to_at`의 교집합으로 한 번만 재시도한다. 교집합이 없으면 그 에러의 available window를 보여주고 중단한다. 없는 시간대를 추정 데이터로 채우지 않는다.
|
|
55
|
+
|
|
54
56
|
### Contract diagnostics (only when needed)
|
|
55
57
|
|
|
56
58
|
1. 환경 설정만 확인한다. 이 명령은 네트워크를 호출하지 않는다.
|
|
@@ -99,6 +101,7 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
|
|
|
99
101
|
- `location_mapping_invalid`: bundled 행정동 reference의 버전·스키마·행 수 계약 오류
|
|
100
102
|
- `proxy_disabled`, `invalid_proxy_base_url`: proxy 환경 설정 오류
|
|
101
103
|
- `unauthorized`/`api_key_missing`(401), `forbidden`/`api_key_forbidden`(403), `unknown_product`(404)
|
|
102
|
-
- `cursor_expired`(409), `rate_limited`(429), `product_not_ready`(503)
|
|
104
|
+
- `cursor_expired`(409), `query_window_unavailable`(422), `rate_limited`(429), `product_not_ready`(503)
|
|
105
|
+
- `query_window_unavailable`: 요청한 `--from`/`--to`가 현재 제공 가능한 예보 window와 겹치지 않음. `details.available_from_at`/`available_to_at`를 확인한다. 겹치는 구간이면 helper가 이미 한 번 재시도한 뒤의 결과다.
|
|
103
106
|
- `upstream_not_configured`(503): proxy 운영 환경에 ASK Seoul 전용 서비스 키 또는 origin이 설정되지 않음
|
|
104
107
|
- `response_contract_invalid`, `malformed_response`: 단일 제품 계약 또는 API 응답 계약 drift
|
|
@@ -33,9 +33,18 @@ STATUS_CODES = {
|
|
|
33
33
|
403: "forbidden",
|
|
34
34
|
404: "unknown_product",
|
|
35
35
|
409: "cursor_expired",
|
|
36
|
+
422: "query_window_unavailable",
|
|
36
37
|
429: "rate_limited",
|
|
37
38
|
503: "product_not_ready",
|
|
38
39
|
}
|
|
40
|
+
WINDOW_INSTANT_LENGTH = len("YYYY-MM-DD HH:MM:SS")
|
|
41
|
+
QUERY_WINDOW_DETAIL_FIELDS = (
|
|
42
|
+
"requested_from_at",
|
|
43
|
+
"requested_to_at",
|
|
44
|
+
"available_from_at",
|
|
45
|
+
"available_to_at",
|
|
46
|
+
"publication_id",
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
|
|
41
50
|
class SkillError(RuntimeError):
|
|
@@ -89,13 +98,20 @@ def _error_payload(raw: bytes) -> dict[str, Any]:
|
|
|
89
98
|
def _problem_error(status: int, raw: bytes, headers: Any) -> SkillError:
|
|
90
99
|
problem = _error_payload(raw)
|
|
91
100
|
code = problem.get("code") if isinstance(problem.get("code"), str) else STATUS_CODES.get(status, "api_error")
|
|
92
|
-
|
|
101
|
+
raw_detail = problem.get("detail")
|
|
102
|
+
message = raw_detail if isinstance(raw_detail, str) else problem.get("title")
|
|
93
103
|
if not isinstance(message, str) or not message:
|
|
94
104
|
message = f"ASK Seoul API가 HTTP {status} 응답을 반환했습니다."
|
|
95
|
-
details = {"status": status}
|
|
105
|
+
details: dict[str, Any] = {"status": status}
|
|
96
106
|
for name in ("type", "title", "product_id", "blockers", "request_id"):
|
|
97
107
|
if name in problem:
|
|
98
108
|
details[name] = problem[name]
|
|
109
|
+
if isinstance(raw_detail, dict):
|
|
110
|
+
details["detail"] = raw_detail
|
|
111
|
+
for name in QUERY_WINDOW_DETAIL_FIELDS:
|
|
112
|
+
value = raw_detail.get(name)
|
|
113
|
+
if isinstance(value, str) and value:
|
|
114
|
+
details[name] = value
|
|
99
115
|
retry_after = headers.get("Retry-After") if headers else None
|
|
100
116
|
if retry_after:
|
|
101
117
|
details["retry_after"] = retry_after
|
|
@@ -238,6 +254,72 @@ def _time_bound(value: str, edge: str) -> str:
|
|
|
238
254
|
return value
|
|
239
255
|
|
|
240
256
|
|
|
257
|
+
def _window_sort_key(value: str) -> str | None:
|
|
258
|
+
text = " ".join(value.strip().replace("T", " ", 1).split())
|
|
259
|
+
if len(text) < WINDOW_INSTANT_LENGTH:
|
|
260
|
+
return None
|
|
261
|
+
key = text[:WINDOW_INSTANT_LENGTH]
|
|
262
|
+
if (
|
|
263
|
+
key[4] == "-"
|
|
264
|
+
and key[7] == "-"
|
|
265
|
+
and key[10] == " "
|
|
266
|
+
and key[13] == ":"
|
|
267
|
+
and key[16] == ":"
|
|
268
|
+
and key[:4].isdigit()
|
|
269
|
+
and key[5:7].isdigit()
|
|
270
|
+
and key[8:10].isdigit()
|
|
271
|
+
and key[11:13].isdigit()
|
|
272
|
+
and key[14:16].isdigit()
|
|
273
|
+
and key[17:19].isdigit()
|
|
274
|
+
):
|
|
275
|
+
return key
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _intersect_query_window(
|
|
280
|
+
requested_from: str,
|
|
281
|
+
requested_to: str,
|
|
282
|
+
available_from: str,
|
|
283
|
+
available_to: str,
|
|
284
|
+
) -> tuple[str, str] | None:
|
|
285
|
+
req_from = _window_sort_key(requested_from)
|
|
286
|
+
req_to = _window_sort_key(requested_to)
|
|
287
|
+
avail_from = _window_sort_key(available_from)
|
|
288
|
+
avail_to = _window_sort_key(available_to)
|
|
289
|
+
if req_from is None or req_to is None or avail_from is None or avail_to is None:
|
|
290
|
+
return None
|
|
291
|
+
clipped_from = max(req_from, avail_from)
|
|
292
|
+
clipped_to = min(req_to, avail_to)
|
|
293
|
+
if clipped_from > clipped_to:
|
|
294
|
+
return None
|
|
295
|
+
return clipped_from, clipped_to
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _query_window_bounds(query: dict[str, str], error: SkillError) -> tuple[str, str, str, str] | None:
|
|
299
|
+
if error.details.get("status") != 422:
|
|
300
|
+
return None
|
|
301
|
+
available_from = error.details.get("available_from_at")
|
|
302
|
+
available_to = error.details.get("available_to_at")
|
|
303
|
+
requested_from = error.details.get("requested_from_at") or query.get("from")
|
|
304
|
+
requested_to = error.details.get("requested_to_at") or query.get("to")
|
|
305
|
+
if not all(isinstance(value, str) and value for value in (requested_from, requested_to, available_from, available_to)):
|
|
306
|
+
return None
|
|
307
|
+
return requested_from, requested_to, available_from, available_to
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _retry_query_after_unavailable_window(query: dict[str, str], error: SkillError) -> dict[str, str] | None:
|
|
311
|
+
bounds = _query_window_bounds(query, error)
|
|
312
|
+
if bounds is None or query.get("cursor"):
|
|
313
|
+
return None
|
|
314
|
+
clipped = _intersect_query_window(*bounds)
|
|
315
|
+
if clipped is None:
|
|
316
|
+
return None
|
|
317
|
+
clipped_from, clipped_to = clipped
|
|
318
|
+
if query.get("from") == clipped_from and query.get("to") == clipped_to:
|
|
319
|
+
return None
|
|
320
|
+
return {**query, "from": clipped_from, "to": clipped_to}
|
|
321
|
+
|
|
322
|
+
|
|
241
323
|
def _normalize_location_name(value: str) -> str:
|
|
242
324
|
return " ".join(unicodedata.normalize("NFC", value).strip().split())
|
|
243
325
|
|
|
@@ -358,7 +440,21 @@ def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
|
|
|
358
440
|
|
|
359
441
|
def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
|
|
360
442
|
_validate_product_id(product_id)
|
|
361
|
-
|
|
443
|
+
try:
|
|
444
|
+
payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", query)
|
|
445
|
+
except SkillError as exc:
|
|
446
|
+
retry_query = _retry_query_after_unavailable_window(query, exc)
|
|
447
|
+
if retry_query is None:
|
|
448
|
+
bounds = _query_window_bounds(query, exc)
|
|
449
|
+
if bounds is not None and _intersect_query_window(*bounds) is None:
|
|
450
|
+
raise SkillError(
|
|
451
|
+
"query_window_unavailable",
|
|
452
|
+
"요청한 조회 구간이 현재 제공 가능한 예보 window와 겹치지 않습니다.",
|
|
453
|
+
exc.details,
|
|
454
|
+
) from exc
|
|
455
|
+
raise
|
|
456
|
+
payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", retry_query)
|
|
457
|
+
return _validate_data(payload, product_id, limit)
|
|
362
458
|
|
|
363
459
|
|
|
364
460
|
def _parser() -> argparse.ArgumentParser:
|
package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc
CHANGED
|
Binary file
|
package/templates/browser.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
<!-- mode:dolshoi -->
|
|
2
2
|
- Use the built-in browser tool backed by CloakBrowser first. Fall back to `k-skill-browser-runtime` providers only when CloakBrowser is unavailable.
|
|
3
3
|
<!-- mode:generic -->
|
|
4
|
-
- Use `k-skill-browser-runtime` (provider `auto
|
|
4
|
+
- Use `k-skill-browser-runtime` (provider `auto`) for logged-in or rendered-page automation. On macOS it tries Aside Browser first, then BrowserOS CDP, then user-launched Chrome/Chromium CDP; on other platforms it tries BrowserOS CDP, then Aside Browser, then user-launched Chrome/Chromium CDP. Do not launch or close the user's browser, and never solve CAPTCHA, identity proofing, or e-signature flows.
|