@nomadamas/k-skill 0.2.3 → 0.2.5
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/k-skill-cleaner/instruction.md +1 -1
- package/skills/k-skill-setup/instruction.md +114 -157
- package/skills/k-skill-setup/skill.json +2 -2
- package/skills/kakao-bar-nearby/instruction.md +73 -31
- package/skills/kakao-bar-nearby/skill.json +4 -2
- package/skills/seoul-weather-risk/instruction.md +111 -0
- package/skills/seoul-weather-risk/references/admin-dong-place-map.json +1 -0
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +503 -0
- package/skills/seoul-weather-risk/skill.json +9 -0
- package/skills/store-longevity-radar/instruction.md +7 -3
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/skills/store-longevity-radar/scripts/store_longevity_download.py +136 -0
- package/skills/store-longevity-radar/scripts/store_longevity_radar.py +1 -51
- package/skills/toss-securities/instruction.md +9 -34
- package/skills/toss-securities/references/TRADEMARK-LEGAL-STATEMENT.md +1 -1
- package/skills/toss-securities/skill.json +2 -2
- package/skills/yebigun-training/instruction.md +1 -1
- package/skills/catchtable-sniper/instruction.md +0 -270
- package/skills/catchtable-sniper/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/catchtable-sniper/skill.json +0 -9
- package/skills/hipass-receipt/instruction.md +0 -97
- package/skills/hipass-receipt/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/hipass-receipt/skill.json +0 -10
- package/skills/used-car-price-search/instruction.md +0 -109
- package/skills/used-car-price-search/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/used-car-price-search/skill.json +0 -8
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only HTTPS client for the ASK Seoul seoul-weather-risk skill API."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import unicodedata
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.error import HTTPError, URLError
|
|
16
|
+
from urllib.parse import urlencode, urlparse
|
|
17
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SKILL_BUNDLE_ID = "seoul-weather-risk"
|
|
21
|
+
PROXY_BASE_URL_ENV = "KSKILL_PROXY_BASE_URL"
|
|
22
|
+
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
|
23
|
+
LOCAL_DIRECT_ENV = "KSKILL_LOCAL_DIRECT"
|
|
24
|
+
SKILL_API_BASE_URL_ENV = "ASK_SEOUL_SKILL_API_BASE_URL"
|
|
25
|
+
MARKETPLACE_API_KEY_ENV = "MARKETPLACE_API_KEY"
|
|
26
|
+
PROXY_DISABLED_VALUES = frozenset({"off", "false", "0", "disable", "disabled", "none"})
|
|
27
|
+
LOCAL_DIRECT_ENABLED_VALUES = frozenset({"1", "true", "on", "yes"})
|
|
28
|
+
LOCAL_DIRECT_DOTENV_NAMES = frozenset({
|
|
29
|
+
LOCAL_DIRECT_ENV,
|
|
30
|
+
SKILL_API_BASE_URL_ENV,
|
|
31
|
+
MARKETPLACE_API_KEY_ENV,
|
|
32
|
+
})
|
|
33
|
+
PROXY_ROUTE_ROOT = "/v1/ask-seoul/weather-risk"
|
|
34
|
+
LOCATION_MAPPING_PATH = pathlib.Path(__file__).resolve().parents[1] / "references" / "admin-dong-place-map.json"
|
|
35
|
+
LOCATION_MAPPING_VERSION = "kma_admin_dong_grid_20260325"
|
|
36
|
+
LOCATION_MAPPING_SIZE = 427
|
|
37
|
+
EXACT_PRODUCT_IDS = frozenset({
|
|
38
|
+
"weather_place_risk_window",
|
|
39
|
+
})
|
|
40
|
+
STATUS_CODES = {
|
|
41
|
+
401: "unauthorized",
|
|
42
|
+
403: "forbidden",
|
|
43
|
+
404: "unknown_product",
|
|
44
|
+
409: "cursor_expired",
|
|
45
|
+
429: "rate_limited",
|
|
46
|
+
503: "product_not_ready",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SkillError(RuntimeError):
|
|
51
|
+
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
|
|
52
|
+
super().__init__(message)
|
|
53
|
+
self.code = code
|
|
54
|
+
self.message = message
|
|
55
|
+
self.details = details or {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
59
|
+
"""Do not silently move a read request to an unreviewed proxy origin."""
|
|
60
|
+
|
|
61
|
+
def redirect_request(self, _req: Request, _fp: Any, _code: int, _msg: str, _headers: Any, _newurl: str) -> None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ApiConfig:
|
|
67
|
+
base_url: str
|
|
68
|
+
mode: str = "hosted_proxy"
|
|
69
|
+
bearer_token: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _json(value: Any) -> str:
|
|
73
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _local_direct_config(values: dict[str, str]) -> ApiConfig:
|
|
77
|
+
base_url = values.get(SKILL_API_BASE_URL_ENV, "").strip()
|
|
78
|
+
bearer_token = values.get(MARKETPLACE_API_KEY_ENV, "").strip()
|
|
79
|
+
if not base_url or base_url == "replace-me":
|
|
80
|
+
raise SkillError("local_direct_not_configured", "local direct API base URL이 필요합니다.")
|
|
81
|
+
if not bearer_token or bearer_token == "replace-me":
|
|
82
|
+
raise SkillError("local_direct_not_configured", "local direct Marketplace API key가 필요합니다.")
|
|
83
|
+
|
|
84
|
+
parsed = urlparse(base_url)
|
|
85
|
+
local_http = parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
|
|
86
|
+
if (parsed.scheme != "https" and not local_http) or not parsed.netloc or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
87
|
+
raise SkillError("invalid_local_direct_base_url", "local direct API base URL은 HTTPS origin이어야 합니다.")
|
|
88
|
+
if parsed.username or parsed.password:
|
|
89
|
+
raise SkillError("invalid_local_direct_base_url", "local direct API base URL에 사용자 정보는 포함할 수 없습니다.")
|
|
90
|
+
return ApiConfig(base_url=base_url.rstrip("/"), mode="local_direct", bearer_token=bearer_token)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _current_directory_dotenv() -> dict[str, str]:
|
|
94
|
+
path = pathlib.Path.cwd() / ".env"
|
|
95
|
+
try:
|
|
96
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
97
|
+
except OSError:
|
|
98
|
+
return {}
|
|
99
|
+
|
|
100
|
+
values: dict[str, str] = {}
|
|
101
|
+
for line in lines:
|
|
102
|
+
stripped = line.strip()
|
|
103
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
104
|
+
continue
|
|
105
|
+
name, value = stripped.split("=", 1)
|
|
106
|
+
name = name.strip()
|
|
107
|
+
if name in LOCAL_DIRECT_DOTENV_NAMES:
|
|
108
|
+
values[name] = value.strip().strip('"').strip("'")
|
|
109
|
+
return values
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _api_config(environ: dict[str, str] | None = None) -> ApiConfig:
|
|
113
|
+
values = ({**_current_directory_dotenv(), **os.environ} if environ is None else environ)
|
|
114
|
+
if values.get(LOCAL_DIRECT_ENV, "").strip().casefold() in LOCAL_DIRECT_ENABLED_VALUES:
|
|
115
|
+
return _local_direct_config(values)
|
|
116
|
+
configured = values.get(PROXY_BASE_URL_ENV, "").strip()
|
|
117
|
+
if configured.casefold() in PROXY_DISABLED_VALUES:
|
|
118
|
+
raise SkillError("proxy_disabled", f"{PROXY_BASE_URL_ENV}가 비활성화되어 있습니다.")
|
|
119
|
+
base_url = (configured if configured and configured != "replace-me" else DEFAULT_PROXY_BASE_URL).rstrip("/")
|
|
120
|
+
|
|
121
|
+
parsed = urlparse(base_url)
|
|
122
|
+
local_http = parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
|
|
123
|
+
if (parsed.scheme != "https" and not local_http) or not parsed.netloc or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
|
124
|
+
raise SkillError("invalid_proxy_base_url", "k-skill proxy base URL은 HTTPS origin이어야 합니다.")
|
|
125
|
+
if parsed.username or parsed.password:
|
|
126
|
+
raise SkillError("invalid_proxy_base_url", "k-skill proxy base URL에 사용자 정보는 포함할 수 없습니다.")
|
|
127
|
+
return ApiConfig(base_url=base_url)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _error_payload(raw: bytes) -> dict[str, Any]:
|
|
131
|
+
try:
|
|
132
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
133
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
134
|
+
return {}
|
|
135
|
+
return payload if isinstance(payload, dict) else {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _problem_error(status: int, raw: bytes, headers: Any) -> SkillError:
|
|
139
|
+
problem = _error_payload(raw)
|
|
140
|
+
code = problem.get("code") if isinstance(problem.get("code"), str) else STATUS_CODES.get(status, "api_error")
|
|
141
|
+
message = problem.get("detail") if isinstance(problem.get("detail"), str) else problem.get("title")
|
|
142
|
+
if not isinstance(message, str) or not message:
|
|
143
|
+
message = f"ASK Seoul API가 HTTP {status} 응답을 반환했습니다."
|
|
144
|
+
details = {"status": status}
|
|
145
|
+
for name in ("type", "title", "product_id", "blockers", "request_id"):
|
|
146
|
+
if name in problem:
|
|
147
|
+
details[name] = problem[name]
|
|
148
|
+
retry_after = headers.get("Retry-After") if headers else None
|
|
149
|
+
if retry_after:
|
|
150
|
+
details["retry_after"] = retry_after
|
|
151
|
+
return SkillError(code, message, details)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _request_json(config: ApiConfig, path: str, query: dict[str, str] | None = None) -> dict[str, Any]:
|
|
155
|
+
url = f"{config.base_url}{path}"
|
|
156
|
+
if query:
|
|
157
|
+
url = f"{url}?{urlencode(query)}"
|
|
158
|
+
headers = {
|
|
159
|
+
"Accept": "application/json",
|
|
160
|
+
"User-Agent": "k-skill-seoul-weather-risk/1",
|
|
161
|
+
}
|
|
162
|
+
if config.bearer_token:
|
|
163
|
+
headers["Authorization"] = f"Bearer {config.bearer_token}"
|
|
164
|
+
request = Request(url, headers=headers)
|
|
165
|
+
try:
|
|
166
|
+
with build_opener(_NoRedirect).open(request, timeout=15) as response:
|
|
167
|
+
raw = response.read()
|
|
168
|
+
content_type = response.headers.get_content_type()
|
|
169
|
+
except HTTPError as exc:
|
|
170
|
+
# HTTPError is also a file object. Close it after reading so repeated
|
|
171
|
+
# typed failures do not leak a response handle into the caller's stderr.
|
|
172
|
+
raw = exc.read()
|
|
173
|
+
try:
|
|
174
|
+
error = _problem_error(exc.code, raw, exc.headers)
|
|
175
|
+
finally:
|
|
176
|
+
exc.close()
|
|
177
|
+
raise error from exc
|
|
178
|
+
except URLError as exc:
|
|
179
|
+
raise SkillError("network_error", "ASK Seoul API에 연결할 수 없습니다.") from exc
|
|
180
|
+
|
|
181
|
+
if content_type != "application/json":
|
|
182
|
+
raise SkillError("malformed_response", "ASK Seoul API 성공 응답의 Content-Type이 JSON이 아닙니다.")
|
|
183
|
+
try:
|
|
184
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
185
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
186
|
+
raise SkillError("malformed_response", "ASK Seoul API 성공 응답이 JSON 객체가 아닙니다.") from exc
|
|
187
|
+
if not isinstance(payload, dict):
|
|
188
|
+
raise SkillError("malformed_response", "ASK Seoul API 성공 응답이 JSON 객체가 아닙니다.")
|
|
189
|
+
return payload
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _contract_error(message: str) -> SkillError:
|
|
193
|
+
return SkillError("response_contract_invalid", message)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _require(value: dict[str, Any], name: str, expected: type | tuple[type, ...]) -> Any:
|
|
197
|
+
item = value.get(name)
|
|
198
|
+
if not isinstance(item, expected):
|
|
199
|
+
raise _contract_error(f"ASK Seoul API 응답의 {name} 계약이 올바르지 않습니다.")
|
|
200
|
+
return item
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _validate_bundle(payload: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
if payload.get("bundle_id") != SKILL_BUNDLE_ID:
|
|
205
|
+
raise _contract_error("ASK Seoul API 응답의 bundle_id가 일치하지 않습니다.")
|
|
206
|
+
_require(payload, "registration_ready", bool)
|
|
207
|
+
products = _require(payload, "products", list)
|
|
208
|
+
ids: list[str] = []
|
|
209
|
+
for product in products:
|
|
210
|
+
if not isinstance(product, dict):
|
|
211
|
+
raise _contract_error("ASK Seoul API bundle의 product 항목이 객체가 아닙니다.")
|
|
212
|
+
product_id = _require(product, "product_id", str)
|
|
213
|
+
_require(product, "registration_ready", bool)
|
|
214
|
+
blockers = _require(product, "blockers", list)
|
|
215
|
+
if not all(isinstance(blocker, str) for blocker in blockers):
|
|
216
|
+
raise _contract_error("ASK Seoul API bundle의 blockers 계약이 올바르지 않습니다.")
|
|
217
|
+
publication_id = product.get("publication_id")
|
|
218
|
+
if publication_id is not None and not isinstance(publication_id, str):
|
|
219
|
+
raise _contract_error("ASK Seoul API bundle의 publication_id 계약이 올바르지 않습니다.")
|
|
220
|
+
ids.append(product_id)
|
|
221
|
+
if set(ids) != EXACT_PRODUCT_IDS or len(ids) != len(EXACT_PRODUCT_IDS):
|
|
222
|
+
raise _contract_error("ASK Seoul API bundle의 제품 목록이 이 스킬의 단일 제품과 다릅니다.")
|
|
223
|
+
return payload
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_product(payload: dict[str, Any], product_id: str) -> dict[str, Any]:
|
|
227
|
+
if payload.get("bundle_id") != SKILL_BUNDLE_ID or payload.get("product_id") != product_id:
|
|
228
|
+
raise _contract_error("ASK Seoul API product 응답의 bundle_id 또는 product_id가 일치하지 않습니다.")
|
|
229
|
+
_require(payload, "registration_ready", bool)
|
|
230
|
+
blockers = _require(payload, "blockers", list)
|
|
231
|
+
if not all(isinstance(blocker, str) for blocker in blockers):
|
|
232
|
+
raise _contract_error("ASK Seoul API product 응답의 blockers 계약이 올바르지 않습니다.")
|
|
233
|
+
publication_id = payload.get("publication_id")
|
|
234
|
+
if publication_id is not None and not isinstance(publication_id, str):
|
|
235
|
+
raise _contract_error("ASK Seoul API product 응답의 publication_id 계약이 올바르지 않습니다.")
|
|
236
|
+
metadata = _require(payload, "metadata", dict)
|
|
237
|
+
columns = metadata.get("columns", [])
|
|
238
|
+
if not isinstance(columns, list) or any(not isinstance(column, dict) or not isinstance(column.get("name"), str) for column in columns):
|
|
239
|
+
raise _contract_error("ASK Seoul API product metadata.columns 계약이 올바르지 않습니다.")
|
|
240
|
+
return payload
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _validate_data(payload: dict[str, Any], product_id: str, requested_limit: int) -> dict[str, Any]:
|
|
244
|
+
if payload.get("bundle_id") != SKILL_BUNDLE_ID or payload.get("product_id") != product_id:
|
|
245
|
+
raise _contract_error("ASK Seoul API data 응답의 bundle_id 또는 product_id가 일치하지 않습니다.")
|
|
246
|
+
publication_id = _require(payload, "publication_id", str)
|
|
247
|
+
if not publication_id:
|
|
248
|
+
raise _contract_error("ASK Seoul API data 응답의 publication_id가 비어 있습니다.")
|
|
249
|
+
row_count = _require(payload, "row_count", int)
|
|
250
|
+
limit = _require(payload, "limit", int)
|
|
251
|
+
has_more = _require(payload, "has_more", bool)
|
|
252
|
+
rows = _require(payload, "rows", list)
|
|
253
|
+
next_cursor = payload.get("next_cursor")
|
|
254
|
+
if row_count < 0 or row_count != len(rows) or limit != requested_limit or not 1 <= limit <= 500:
|
|
255
|
+
raise _contract_error("ASK Seoul API data page의 row_count 또는 limit 계약이 올바르지 않습니다.")
|
|
256
|
+
if next_cursor is not None and not isinstance(next_cursor, str):
|
|
257
|
+
raise _contract_error("ASK Seoul API data page의 next_cursor 계약이 올바르지 않습니다.")
|
|
258
|
+
if has_more != (next_cursor is not None):
|
|
259
|
+
raise _contract_error("ASK Seoul API data page의 has_more와 next_cursor가 일치하지 않습니다.")
|
|
260
|
+
if not all(isinstance(row, dict) for row in rows):
|
|
261
|
+
raise _contract_error("ASK Seoul API data page의 rows 계약이 올바르지 않습니다.")
|
|
262
|
+
return payload
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _filters(values: list[str]) -> dict[str, str]:
|
|
266
|
+
parsed: dict[str, str] = {}
|
|
267
|
+
for value in values:
|
|
268
|
+
if "=" not in value:
|
|
269
|
+
raise SkillError("invalid_filter", "필터는 column=value 형식이어야 합니다.")
|
|
270
|
+
name, expected = value.split("=", 1)
|
|
271
|
+
name = name.strip()
|
|
272
|
+
if not name or name in parsed:
|
|
273
|
+
raise SkillError("invalid_filter", "필터 이름은 비어 있거나 중복될 수 없습니다.")
|
|
274
|
+
parsed[name] = expected
|
|
275
|
+
return parsed
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _time_bound(value: str, edge: str) -> str:
|
|
279
|
+
if (
|
|
280
|
+
len(value) == len("YYYY-MM-DD")
|
|
281
|
+
and value[4] == "-"
|
|
282
|
+
and value[7] == "-"
|
|
283
|
+
and value[:4].isdigit()
|
|
284
|
+
and value[5:7].isdigit()
|
|
285
|
+
and value[8:10].isdigit()
|
|
286
|
+
):
|
|
287
|
+
suffix = "00:00:00" if edge == "from" else "23:59:59"
|
|
288
|
+
return f"{value} {suffix}"
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _normalize_location_name(value: str) -> str:
|
|
293
|
+
return " ".join(unicodedata.normalize("NFC", value).strip().split())
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _location_mapping_error(message: str) -> SkillError:
|
|
297
|
+
return SkillError("location_mapping_invalid", message)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_location_mapping(path: pathlib.Path = LOCATION_MAPPING_PATH) -> tuple[str, list[dict[str, str]]]:
|
|
301
|
+
try:
|
|
302
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
303
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
304
|
+
raise _location_mapping_error("행정동 매핑 reference를 읽을 수 없습니다.") from exc
|
|
305
|
+
|
|
306
|
+
if not isinstance(payload, dict) or payload.get("mapping_version") != LOCATION_MAPPING_VERSION:
|
|
307
|
+
raise _location_mapping_error("행정동 매핑 버전이 지원 계약과 다릅니다.")
|
|
308
|
+
if not isinstance(payload.get("source"), str) or not payload["source"]:
|
|
309
|
+
raise _location_mapping_error("행정동 매핑의 source가 올바르지 않습니다.")
|
|
310
|
+
if not isinstance(payload.get("generated_at"), str) or not payload["generated_at"]:
|
|
311
|
+
raise _location_mapping_error("행정동 매핑의 generated_at이 올바르지 않습니다.")
|
|
312
|
+
|
|
313
|
+
locations = payload.get("locations")
|
|
314
|
+
if not isinstance(locations, list) or len(locations) != LOCATION_MAPPING_SIZE:
|
|
315
|
+
raise _location_mapping_error("행정동 매핑 행 수가 지원 계약과 다릅니다.")
|
|
316
|
+
|
|
317
|
+
normalized: list[dict[str, str]] = []
|
|
318
|
+
place_ids: set[str] = set()
|
|
319
|
+
location_keys: set[tuple[str, str]] = set()
|
|
320
|
+
for row in locations:
|
|
321
|
+
if not isinstance(row, dict) or set(row) != {"admin_dong", "gu", "place_id"}:
|
|
322
|
+
raise _location_mapping_error("행정동 매핑 행의 필드 계약이 올바르지 않습니다.")
|
|
323
|
+
if not all(isinstance(row[name], str) and row[name].strip() for name in ("admin_dong", "gu", "place_id")):
|
|
324
|
+
raise _location_mapping_error("행정동 매핑 행에 비어 있거나 문자열이 아닌 값이 있습니다.")
|
|
325
|
+
|
|
326
|
+
admin_dong = _normalize_location_name(row["admin_dong"])
|
|
327
|
+
gu = _normalize_location_name(row["gu"])
|
|
328
|
+
place_id = row["place_id"].strip()
|
|
329
|
+
if not place_id.startswith("seoul_admd_") or len(place_id) != len("seoul_admd_") + 10 or not place_id.removeprefix("seoul_admd_").isdigit():
|
|
330
|
+
raise _location_mapping_error("행정동 매핑의 place_id 형식이 올바르지 않습니다.")
|
|
331
|
+
if place_id in place_ids or (admin_dong, gu) in location_keys:
|
|
332
|
+
raise _location_mapping_error("행정동 매핑에 중복된 place_id 또는 행정동·자치구가 있습니다.")
|
|
333
|
+
|
|
334
|
+
place_ids.add(place_id)
|
|
335
|
+
location_keys.add((admin_dong, gu))
|
|
336
|
+
normalized.append({"admin_dong": admin_dong, "gu": gu, "place_id": place_id})
|
|
337
|
+
|
|
338
|
+
return payload["mapping_version"], normalized
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _alias_keys(canonical: str) -> set[str]:
|
|
342
|
+
compact = re.sub(r"\s+", "", canonical)
|
|
343
|
+
without_je = re.sub(r"제(?=\d)", "", compact)
|
|
344
|
+
return {
|
|
345
|
+
compact,
|
|
346
|
+
without_je,
|
|
347
|
+
compact.replace(".", "·"),
|
|
348
|
+
compact.replace(".", ""),
|
|
349
|
+
without_je.replace(".", "·"),
|
|
350
|
+
without_je.replace(".", ""),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _location_indexes(locations: list[dict[str, str]]) -> tuple[dict[str, list[dict[str, str]]], dict[str, list[dict[str, str]]]]:
|
|
355
|
+
canonical_index: dict[str, list[dict[str, str]]] = {}
|
|
356
|
+
alias_index: dict[str, list[dict[str, str]]] = {}
|
|
357
|
+
for row in locations:
|
|
358
|
+
canonical_index.setdefault(row["admin_dong"], []).append(row)
|
|
359
|
+
for alias in _alias_keys(row["admin_dong"]):
|
|
360
|
+
alias_index.setdefault(alias, []).append(row)
|
|
361
|
+
return canonical_index, alias_index
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _resolve_admin_dong(admin_dong: str, gu: str | None = None) -> dict[str, str]:
|
|
365
|
+
normalized_dong = _normalize_location_name(admin_dong)
|
|
366
|
+
normalized_gu = _normalize_location_name(gu) if gu is not None else None
|
|
367
|
+
_version, locations = _load_location_mapping()
|
|
368
|
+
|
|
369
|
+
canonical_index, alias_index = _location_indexes(locations)
|
|
370
|
+
candidates = canonical_index.get(normalized_dong)
|
|
371
|
+
if candidates is None:
|
|
372
|
+
candidates = alias_index.get(re.sub(r"\s+", "", normalized_dong))
|
|
373
|
+
if not candidates:
|
|
374
|
+
raise SkillError("unknown_admin_dong", f"지원하는 서울 행정동이 아닙니다: {normalized_dong}")
|
|
375
|
+
|
|
376
|
+
candidates = list(candidates)
|
|
377
|
+
|
|
378
|
+
if normalized_gu is not None:
|
|
379
|
+
known_gus = {row["gu"] for row in locations}
|
|
380
|
+
if normalized_gu not in known_gus:
|
|
381
|
+
raise SkillError("unknown_gu", f"지원하는 서울 자치구가 아닙니다: {normalized_gu}")
|
|
382
|
+
candidates = [row for row in candidates if row["gu"] == normalized_gu]
|
|
383
|
+
if not candidates:
|
|
384
|
+
raise SkillError("unknown_admin_dong", f"{normalized_gu}의 지원 행정동이 아닙니다: {normalized_dong}")
|
|
385
|
+
|
|
386
|
+
candidates.sort(key=lambda item: (item["gu"], item["place_id"]))
|
|
387
|
+
if len(candidates) > 1:
|
|
388
|
+
raise SkillError(
|
|
389
|
+
"ambiguous_admin_dong",
|
|
390
|
+
"동명이므로 자치구를 함께 입력해야 합니다.",
|
|
391
|
+
{"candidates": candidates},
|
|
392
|
+
)
|
|
393
|
+
return candidates[0]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _validate_product_id(product_id: str) -> None:
|
|
397
|
+
if product_id not in EXACT_PRODUCT_IDS:
|
|
398
|
+
raise SkillError("unknown_product", f"지원하지 않는 product_id입니다: {product_id}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _bundle(config: ApiConfig) -> dict[str, Any]:
|
|
402
|
+
if config.mode == "local_direct":
|
|
403
|
+
return _validate_bundle(_request_json(config, f"/skill/v1/bundles/{SKILL_BUNDLE_ID}"))
|
|
404
|
+
return _validate_bundle(_request_json(config, f"{PROXY_ROUTE_ROOT}/bundle"))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
|
|
408
|
+
_validate_product_id(product_id)
|
|
409
|
+
if config.mode == "local_direct":
|
|
410
|
+
return _validate_product(_request_json(config, f"/skill/v1/products/{product_id}"), product_id)
|
|
411
|
+
return _validate_product(_request_json(config, f"{PROXY_ROUTE_ROOT}/product"), product_id)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
|
|
415
|
+
_validate_product_id(product_id)
|
|
416
|
+
if config.mode == "local_direct":
|
|
417
|
+
return _validate_data(_request_json(config, f"/skill/v1/products/{product_id}/data", query), product_id, limit)
|
|
418
|
+
return _validate_data(_request_json(config, f"{PROXY_ROUTE_ROOT}/data", query), product_id, limit)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _parser() -> argparse.ArgumentParser:
|
|
422
|
+
parser = argparse.ArgumentParser(description="seoul-weather-risk ASK Seoul HTTPS client")
|
|
423
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
424
|
+
commands.add_parser("preflight", help="live API 환경 설정 확인(네트워크 호출 없음)")
|
|
425
|
+
commands.add_parser("catalog", help="weather_place_risk_window bundle 조회")
|
|
426
|
+
|
|
427
|
+
describe = commands.add_parser("describe", help="제품 metadata 조회")
|
|
428
|
+
describe.add_argument("--product-id", required=True)
|
|
429
|
+
|
|
430
|
+
query = commands.add_parser("query", help="제품 data page 조회")
|
|
431
|
+
query.add_argument("--product-id", required=True)
|
|
432
|
+
query.add_argument("--filter", action="append", default=[], metavar="COLUMN=VALUE")
|
|
433
|
+
query.add_argument("--admin-dong", help="서울 행정동 이름")
|
|
434
|
+
query.add_argument("--gu", help="동명이명 해소용 서울 자치구 이름")
|
|
435
|
+
query.add_argument("--from", dest="from_value")
|
|
436
|
+
query.add_argument("--to", dest="to_value")
|
|
437
|
+
query.add_argument("--limit", type=int, default=100)
|
|
438
|
+
query.add_argument("--cursor")
|
|
439
|
+
return parser
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def run(argv: list[str]) -> int:
|
|
443
|
+
args = _parser().parse_args(argv)
|
|
444
|
+
try:
|
|
445
|
+
config = _api_config()
|
|
446
|
+
if args.command == "preflight":
|
|
447
|
+
result = {
|
|
448
|
+
"status": "ok",
|
|
449
|
+
"mode": config.mode,
|
|
450
|
+
"live_network": False,
|
|
451
|
+
"user_api_key_required": config.mode == "local_direct",
|
|
452
|
+
"proxy_base_url_configured": config.mode == "hosted_proxy",
|
|
453
|
+
"local_direct_base_url_configured": config.mode == "local_direct",
|
|
454
|
+
}
|
|
455
|
+
elif args.command == "catalog":
|
|
456
|
+
result = _bundle(config)
|
|
457
|
+
elif args.command == "describe":
|
|
458
|
+
_bundle(config)
|
|
459
|
+
result = _detail(config, args.product_id)
|
|
460
|
+
else:
|
|
461
|
+
if not 1 <= args.limit <= 500:
|
|
462
|
+
raise SkillError("invalid_limit", "limit은 1부터 500 사이여야 합니다.")
|
|
463
|
+
_bundle(config)
|
|
464
|
+
detail = _detail(config, args.product_id)
|
|
465
|
+
filters = _filters(args.filter)
|
|
466
|
+
if args.gu is not None and args.admin_dong is None:
|
|
467
|
+
raise SkillError("invalid_location_input", "--gu는 --admin-dong과 함께 사용해야 합니다.")
|
|
468
|
+
if args.admin_dong is not None:
|
|
469
|
+
if not _normalize_location_name(args.admin_dong):
|
|
470
|
+
raise SkillError("invalid_location_input", "--admin-dong은 비어 있을 수 없습니다.")
|
|
471
|
+
if args.gu is not None and not _normalize_location_name(args.gu):
|
|
472
|
+
raise SkillError("invalid_location_input", "--gu는 비어 있을 수 없습니다.")
|
|
473
|
+
if "place_id" in filters:
|
|
474
|
+
raise SkillError(
|
|
475
|
+
"conflicting_location_input",
|
|
476
|
+
"--admin-dong과 place_id 필터는 동시에 사용할 수 없습니다.",
|
|
477
|
+
)
|
|
478
|
+
resolved = _resolve_admin_dong(args.admin_dong, args.gu)
|
|
479
|
+
filters["place_id"] = resolved["place_id"]
|
|
480
|
+
allowed_columns = {column["name"] for column in detail["metadata"].get("columns", [])}
|
|
481
|
+
unknown = sorted(set(filters) - allowed_columns)
|
|
482
|
+
if unknown:
|
|
483
|
+
raise SkillError("unknown_filter", f"공개 projection에 없는 필터입니다: {', '.join(unknown)}")
|
|
484
|
+
request_query = {**filters, "limit": str(args.limit)}
|
|
485
|
+
if args.from_value is not None:
|
|
486
|
+
request_query["from"] = _time_bound(args.from_value, "from")
|
|
487
|
+
if args.to_value is not None:
|
|
488
|
+
request_query["to"] = _time_bound(args.to_value, "to")
|
|
489
|
+
if args.cursor is not None:
|
|
490
|
+
request_query["cursor"] = args.cursor
|
|
491
|
+
result = _data(config, args.product_id, request_query, args.limit)
|
|
492
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
493
|
+
return 0
|
|
494
|
+
except SkillError as exc:
|
|
495
|
+
error = {"code": exc.code, "message": exc.message}
|
|
496
|
+
if exc.details:
|
|
497
|
+
error["details"] = exc.details
|
|
498
|
+
print(json.dumps({"error": error}, ensure_ascii=False), file=sys.stderr)
|
|
499
|
+
return 2
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
sys.exit(run(sys.argv[1:]))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seoul-weather-risk",
|
|
3
|
+
"description": "서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 기본 경로에는 사용자 API Key와 place_id 입력이 필요 없으며, 등록 전에는 명시적으로 local-direct 검증을 할 수 있다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"proxy",
|
|
6
|
+
"lookup"
|
|
7
|
+
],
|
|
8
|
+
"frontmatter": "name: seoul-weather-risk\ndescription: 서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 기본 경로에는 사용자 API Key와 place_id 입력이 필요 없으며, 등록 전에는 명시적으로 local-direct 검증을 할 수 있다.\nlicense: MIT\nmetadata:\n category: public-data\n locale: ko-KR\n phase: live-client"
|
|
9
|
+
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
## Design principles
|
|
19
19
|
|
|
20
20
|
- 점수·등급 같은 해석 라벨을 만들지 않는다. 스냅샷 존재 사실 + 매칭 방법만 담는다.
|
|
21
|
-
- 무인증 공개 파일
|
|
21
|
+
- 무인증 공개 파일 서버를 사용자 머신에서 먼저 직접 호출한다. 직접 경로가 timeout/차단되면 GitHub Actions가 검증해 R2에 저장한 공개 미러를 fallback으로 사용한다.
|
|
22
|
+
- 미러 ZIP은 `latest.json`의 크기·SHA-256과 ZIP CRC/CSV 존재 검증을 모두 통과해야 캐시로 승격한다. 미러는 API 프록시가 아니라 공개 원본의 검증된 객체 복제본이다.
|
|
22
23
|
- 최신 zip은 수백 MB이므로 1일 로컬 캐시(`~/.cache/k-skill/store-longevity-radar/`)한다. 반복 다운로드하지 않는다.
|
|
23
24
|
|
|
24
25
|
## When to use
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
- `--old-csv`: (`match` 전용) 과거 스냅샷 CSV, 반복 지정. `'|'`/`','` 구분자 자동 감지
|
|
47
48
|
- `--max-dist`: (`match` 전용) 동일 상호 허용 좌표 거리(m), 기본 150
|
|
48
49
|
- `--out`, `--format`: 출력 파일/형식 (csv 기본, json 가능)
|
|
50
|
+
- `KSKILL_STORE_LONGEVITY_MIRROR_MANIFEST_URL`: 검증 미러 manifest override. 기본 `https://pub-c974105a1e4840bcaa264cb2a55d99a1.r2.dev/store-longevity-radar/latest.json`
|
|
49
51
|
|
|
50
52
|
## CLI examples
|
|
51
53
|
|
|
@@ -65,7 +67,7 @@ npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_r
|
|
|
65
67
|
|
|
66
68
|
## Workflow
|
|
67
69
|
|
|
68
|
-
1. `current`부터 실행해 대상 업종 전수를 확보한다. zip 자동 다운로드는 수 분 걸릴 수 있음을 사용자에게 알린다.
|
|
70
|
+
1. `current`부터 실행해 대상 업종 전수를 확보한다. zip 자동 다운로드는 수 분 걸릴 수 있음을 사용자에게 알린다. 원본 직접 다운로드를 먼저 시도하고 실패하면 크기·SHA-256이 명시된 검증 미러로 자동 전환한다.
|
|
69
71
|
2. 과거 스냅샷 CSV가 있으면 `match`로 장수 점포를 추출한다. 기본 문구·완구 코드는 helper가 2022년 이전 `D08A01`/`D04A01`/`D04A02`를 자동 포함한다. 다른 업종은 현재 코드와 과거 코드를 `--code`로 함께 지정한다.
|
|
70
72
|
3. 결과 전달 시 위 Honest limitations를 함께 요약한다.
|
|
71
73
|
4. 후속 확인이 필요하면 `nts-business-registration`(폐업 확정), `localdata-business-status`(인허가 업력), `kakao-map`(전화번호·현재 등재)을 안내한다.
|
|
@@ -73,7 +75,8 @@ npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_r
|
|
|
73
75
|
## Failure modes
|
|
74
76
|
|
|
75
77
|
- 데이터셋 페이지에서 파일 ID 발견 실패 → `unavailable` + 수동 확인 URL 출력 (분기 개편 시 페이지 구조 변경 가능).
|
|
76
|
-
- 공공데이터포털 접속/다운로드 timeout 또는 HTTP 실패 →
|
|
78
|
+
- 공공데이터포털 접속/다운로드 timeout 또는 HTTP 실패 → 검증 미러 fallback.
|
|
79
|
+
- 미러 manifest/ZIP 접근 실패, 크기·SHA-256 불일치, ZIP 손상 → 캐시 미승격 + 직접/미러 양쪽 원인을 담은 `unavailable`.
|
|
77
80
|
- 다운로드 중단 → `.part` 파일만 남고 캐시로 승격되지 않음. 재실행하면 이어서 새로 받는다.
|
|
78
81
|
- `match`에 과거 CSV 미지정 → argparse 에러. 과거분 확보 방법을 사용자에게 안내한다.
|
|
79
82
|
- 0건 매칭: 업종코드가 스냅샷 코드체계와 다를 수 있다. `--keyword`만으로 재시도한다.
|
|
@@ -82,6 +85,7 @@ npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_r
|
|
|
82
85
|
|
|
83
86
|
- 데이터셋: <https://www.data.go.kr/data/15083033/fileData.do>
|
|
84
87
|
- 다운로드: `https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=<FILE_ID>&fileDetailSn=1` (무인증)
|
|
88
|
+
- 검증 미러 manifest: <https://pub-c974105a1e4840bcaa264cb2a55d99a1.r2.dev/store-longevity-radar/latest.json>
|
|
85
89
|
- 관련 스킬: `nts-business-registration`, `localdata-business-status`, `kakao-map`
|
|
86
90
|
|
|
87
91
|
## Done when
|