@nomadamas/k-skill 0.2.3 → 0.2.4

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.
@@ -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
+ }
@@ -2,10 +2,7 @@
2
2
 
3
3
  ## What this skill does
4
4
 
5
- 토스증권 **조회 전용(read-only)** 흐름을 실행한다. 경로가 있다.
6
-
7
- 1. **공식 Open API (권장)** — 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 호출.
8
- 2. **tossctl fallback** — 공식 credentials가 없을 때 `JungHoonGhae/tossinvest-cli` 의 `tossctl` 을 사용.
5
+ 토스증권 **공식 Open API 전용 조회(read-only)** 흐름을 실행한다. 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 직접 호출한다.
9
6
 
10
7
  조회 항목:
11
8
 
@@ -13,7 +10,6 @@
13
10
  - 시세(현재가/호가/체결/상하한가/캔들) / 종목 정보 / 매수 유의사항
14
11
  - 환율 / 장 운영 캘린더(KR·US)
15
12
  - 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
16
- - (tossctl fallback) 계좌 요약, 포트폴리오 비중, 관심종목
17
13
 
18
14
  ## When to use
19
15
 
@@ -22,7 +18,7 @@
22
18
  - "대기중 주문 조회해줘"
23
19
  - "원달러 환율 알려줘"
24
20
 
25
- ## 1. Prefer the official Open API
21
+ ## Use only the official Open API
26
22
 
27
23
  ### Prerequisites
28
24
 
@@ -36,11 +32,10 @@
36
32
  | `TOSSINVEST_CLIENT_ID` | client id (필수) |
37
33
  | `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
38
34
  | `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
39
- | `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
40
35
 
41
36
  ### Workflow
42
37
 
43
- helper는 내부적으로 `POST /oauth2/token` 으로 토큰을 발급(Client Credentials)받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다.
38
+ helper는 내부적으로 `POST /oauth2/token` 으로 토큰을 발급(Client Credentials)받아 `Authorization: Bearer` 로 호출한다. API origin은 `https://openapi.tossinvest.com` 으로 고정되며 다른 host로 변경할 수 없다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다.
44
39
 
45
40
  ```js
46
41
  const {
@@ -70,31 +65,11 @@ main().catch((error) => {
70
65
  - `401` 은 토큰을 1회 재발급해 재시도한다.
71
66
  - `client_secret`/토큰은 에러 메시지에서 마스킹된다.
72
67
 
73
- ## 2. tossctl fallback
74
-
75
- 공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다.
76
-
77
- ### Install `tossctl` first when missing
78
-
79
- ```bash
80
- brew tap JungHoonGhae/tossinvest-cli
81
- brew install tossctl
82
- tossctl doctor
83
- tossctl auth doctor
84
- tossctl auth login
85
- ```
86
-
87
- 로그인 세션이 없으면 먼저 위 흐름을 끝낸다. 다른 비공식 크롤링이나 임의 HTTP 재구현으로 우회하지 않는다.
88
-
89
- 지원하는 read-only 명령:
90
-
91
- - `tossctl account summary --output json`
92
- - `tossctl portfolio positions --output json`
93
- - `tossctl quote get TSLA --output json`
94
- - `tossctl watchlist list --output json`
95
- - `tossctl orders completed --market all --output json`
68
+ ## Official-only boundary
96
69
 
97
- 패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listWatchlist` 등)도 그대로 있다.
70
+ - 공식 API credentials가 없으면 `TossCredentialsError` 종료하고 필요한 환경변수를 안내한다.
71
+ - 공식 API가 제공하지 않는 기능은 지원하지 않는다고 명확히 답한다.
72
+ - 비공식 CLI, 로그인 세션 재사용, 크롤링, 임의 HTTP 호출로 우회하지 않는다.
98
73
 
99
74
  ## Answer conservatively
100
75
 
@@ -104,7 +79,7 @@ tossctl auth login
104
79
 
105
80
  ## Done when
106
81
 
107
- - 공식 API credentials(또는 tossctl 로그인) 상태가 확인되었다.
82
+ - 공식 API credentials 상태가 확인되었다.
108
83
  - 요청에 맞는 read-only 호출을 실행했다.
109
84
  - 결과를 한국어로 짧게 정리했다.
110
85
 
@@ -112,5 +87,5 @@ tossctl auth login
112
87
 
113
88
  - 공식 API credentials(`TOSSINVEST_CLIENT_ID`/`SECRET`)가 없으면 `TossCredentialsError` 로 명확히 실패한다.
114
89
  - 계좌·자산·주문조회 helper에 `X-Tossinvest-Account` 가 없으면 네트워크 호출 전에 실패한다.
115
- - tossctl fallback은 `auth login` 전이면 계좌/포트폴리오 조회가 실패할 있다.
90
+ - 공식 API가 지원하지 않는 요청은 비공식 경로로 우회하지 않고 지원 불가로 종료한다.
116
91
  - 계좌/주문 정보는 민감하므로 출력 범위를 과도하게 넓히지 않는다.
@@ -1,6 +1,6 @@
1
1
  # 상표 사용 법적 고지 — `toss-securities`
2
2
 
3
- 이 스킬에서 `토스증권` 및 `Toss Securities` 명칭은 계좌·보유주식·시세·주문 조회의 **대상 증권 서비스**를 식별하기 위해 사용한다. k-skill 또는 fallback CLI의 출처를 토스증권으로 표시하려는 사용이 아니다.
3
+ 이 스킬에서 `토스증권` 및 `Toss Securities` 명칭은 계좌·보유주식·시세·주문 조회의 **대상 증권 서비스**를 식별하기 위해 사용한다. k-skill의 출처를 토스증권으로 표시하려는 사용이 아니다.
4
4
 
5
5
  대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
6
6
 
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "toss-securities",
3
- "description": "토스증권 조회형 질문을 공식 Open API(OAuth2)로 우선 처리하고, 공식 credentials가 없으면 tossinvest-cli의 tossctl을 fallback으로 써서 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
3
+ "description": "토스증권 공식 Open API(OAuth2)로 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다. 공식 credentials가 없거나 공식 API가 지원하지 않는 기능은 비공식 경로로 우회하지 않는다.",
4
4
  "profiles": [
5
5
  "vault",
6
6
  "action:account"
7
7
  ],
8
- "frontmatter": "name: toss-securities\ndescription: 토스증권 조회형 질문을 공식 Open API(OAuth2)로 우선 처리하고, 공식 credentials가 없으면 tossinvest-cli의 tossctl을 fallback으로 써서 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: finance\n locale: ko-KR\n phase: v1"
8
+ "frontmatter": "name: toss-securities\ndescription: 토스증권 공식 Open API(OAuth2)로 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다. 공식 credentials가 없거나 공식 API가 지원하지 않는 기능은 비공식 경로로 우회하지 않는다.\nlicense: MIT\nmetadata:\n category: finance\n locale: ko-KR\n phase: v1"
9
9
  }
@@ -11,7 +11,7 @@
11
11
  - 필요하면 로컬에 연도별 기록을 추가로 저장(`record`)하고 비교(`diff`)할 수도 있다 (사이트가 더 오래된 기록을 보여주지 않게 되거나, 메모를 남기고 싶을 때를 위한 보조 기능).
12
12
  - 범용 페이지 조회(`inspect`)로 아직 분류 안 된 다른 페이지 구조를 확인할 수도 있다.
13
13
 
14
- `hipass-receipt`와 동일한 설계 원칙을 따른다: **로그인은 항상 사용자가 직접 하고, 이 스킬은 로그인된 세션에서 조회만 한다.**
14
+ 설계 원칙은 명확하다: **로그인은 항상 사용자가 직접 하고, 이 스킬은 로그인된 세션에서 조회만 한다.**
15
15
 
16
16
  ## Hard limits
17
17