@nomadamas/k-skill 0.2.2 → 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.
- package/package.json +1 -1
- package/skills/bunjang-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/coupang-product-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daangn-cars-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daangn-jobs-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daangn-realty-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daangn-used-goods-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daishin-report-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/daiso-product-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/danawa-price-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/delivery-tracking/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/express-bus-booking/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/flight-ticket-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/foresttrip-vacancy/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/intercity-bus-booking/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/job-posting-match/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/jobkorea-talent-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kakao-bar-nearby/instruction.md +73 -31
- package/skills/kakao-bar-nearby/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kakao-bar-nearby/skill.json +4 -2
- package/skills/kakao-map/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kakaotalk-mac/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kbl-results/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kbo-results/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/kleague-results/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/korean-cinema-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/korean-transit-route/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/ktx-booking/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/lck-analytics/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/lotto-results/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/market-kurly-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/myrealtrip-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/naver-ad-performance/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/naver-blog-research/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/naver-news-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/naver-shopping-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/ohou-today-deal/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/olive-young-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/popbill/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/saramin-talent-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- 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/srt-booking/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/store-longevity-radar/instruction.md +91 -0
- package/skills/store-longevity-radar/scripts/store_longevity_radar.py +230 -0
- package/skills/store-longevity-radar/skill.json +8 -0
- package/skills/ticket-availability/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/toss-securities/instruction.md +9 -34
- package/skills/toss-securities/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/toss-securities/skill.json +2 -2
- package/skills/used-car-price-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/yebigun-training/instruction.md +1 -1
- package/skills/catchtable-sniper/instruction.md +0 -270
- package/skills/catchtable-sniper/skill.json +0 -9
- package/skills/gangnamunni-clinic-search/instruction.md +0 -121
- package/skills/gangnamunni-clinic-search/skill.json +0 -9
- package/skills/hipass-receipt/instruction.md +0 -97
- package/skills/hipass-receipt/skill.json +0 -10
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# 상표 사용 법적 고지 — `srt-booking`
|
|
2
|
+
|
|
3
|
+
이 스킬에서 `SRT` 및 `SR` 명칭은 열차 조회·예약·취소의 **대상 철도 서비스와 운영기관**을 식별하기 위해 사용한다. k-skill의 출처나 운영주체를 SR로 표시하려는 사용이 아니다.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 운송계약, API·서비스 약관, 개인정보 또는 예약·결제 흐름의 적법성을 판단하지 않는다.
|
|
8
|
+
|
|
9
|
+
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 장수 점포 레이더 (상가업소 시계열)
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
공공데이터포털 **「소상공인시장진흥공단_상가(상권)정보」** 공개 파일(비회원 다운로드 가능)을 이용해 두 가지를 한다.
|
|
6
|
+
|
|
7
|
+
1. `current` — 최신 분기 스냅샷(전국 17개 시도 zip)에서 **업종코드/상호 키워드로 점포 전수**를 뽑는다. 스냅샷에 존재 = 해당 시점 영업 중으로 취급되는 데이터다.
|
|
8
|
+
2. `match` — 사용자가 가진 **과거 스냅샷 CSV**(예: 2019년 국가중점데이터 배포본)와 상호·좌표를 매칭해 **"과거에도 존재했고 지금도 존재하는" 장수 점포**를 추출한다.
|
|
9
|
+
|
|
10
|
+
기본 업종은 문구·완구(`G21302` 문구/회화용품 소매업, `G21306` 장난감 소매업)이며 옵션으로 어떤 업종이든 지정할 수 있다.
|
|
11
|
+
|
|
12
|
+
## Honest limitations (반드시 사용자에게 고지)
|
|
13
|
+
|
|
14
|
+
- 이 데이터에는 **사업자등록번호·개업일이 없다.** 산출되는 것은 "최초 관측 시점 하한"(그 시점에 이미 존재)이지 개업일이 아니다.
|
|
15
|
+
- 상호 변경·이전한 점포는 매칭에서 빠진다(과소집계). 동명 상호는 좌표 거리(기본 150m)로 구분하지만 단정하지 않는다.
|
|
16
|
+
- 폐업 확정은 사업자등록번호 확보 후 `nts-business-registration`(국세청 상태조회)으로 하고, 인허가 업종이면 `localdata-business-status`로 인허가일자(업력)를 본다.
|
|
17
|
+
|
|
18
|
+
## Design principles
|
|
19
|
+
|
|
20
|
+
- 점수·등급 같은 해석 라벨을 만들지 않는다. 스냅샷 존재 사실 + 매칭 방법만 담는다.
|
|
21
|
+
- 무인증 공개 파일 서버이므로 프록시를 거치지 않고 사용자 머신에서 직접 받는다.
|
|
22
|
+
- 최신 zip은 수백 MB이므로 1일 로컬 캐시(`~/.cache/k-skill/store-longevity-radar/`)한다. 반복 다운로드하지 않는다.
|
|
23
|
+
|
|
24
|
+
## When to use
|
|
25
|
+
|
|
26
|
+
- "지금도 영업 중인 오래된 문방구/철물점/레코드점 리스트 뽑아줘"
|
|
27
|
+
- "이 업종 전국 점포 전수 데이터 줘 (주소·좌표 포함)"
|
|
28
|
+
- "10년 전에도 있었고 아직 있는 가게 찾아줘" (과거 스냅샷 CSV 필요)
|
|
29
|
+
|
|
30
|
+
## Prerequisites
|
|
31
|
+
|
|
32
|
+
- 인터넷 연결, `python3` (stdlib만 사용)
|
|
33
|
+
- `scripts/store_longevity_radar.py` helper
|
|
34
|
+
- `match`를 쓰려면 과거 스냅샷 CSV 파일 (사용자가 보유하거나 별도 확보; 과거분은 공공데이터포털에서 최신 분기만 배포되므로 강의·연구용 미러 등에서 구한다)
|
|
35
|
+
|
|
36
|
+
## Credential requirements
|
|
37
|
+
|
|
38
|
+
- 없음. 무인증 공개 파일 다운로드다.
|
|
39
|
+
|
|
40
|
+
## Inputs
|
|
41
|
+
|
|
42
|
+
- `--code`: 상권업종소분류코드 (반복 지정, 기본 `G21302` `G21306`). 다른 업종을 `match`할 때 코드체계가 바뀌었다면 현재 코드와 과거 코드를 모두 반복 지정한다.
|
|
43
|
+
- `--keyword`: 상호 키워드 (반복 지정, 기본 문구/문방구/완구/장난감 — 업종코드 밖 점포 보완용)
|
|
44
|
+
- `--sido`: 시도명 필터 (예: `서울`, `부산`; 생략 시 전국)
|
|
45
|
+
- `--zip`: 이미 받아둔 최신 zip 경로 (생략 시 자동 다운로드+캐시)
|
|
46
|
+
- `--old-csv`: (`match` 전용) 과거 스냅샷 CSV, 반복 지정. `'|'`/`','` 구분자 자동 감지
|
|
47
|
+
- `--max-dist`: (`match` 전용) 동일 상호 허용 좌표 거리(m), 기본 150
|
|
48
|
+
- `--out`, `--format`: 출력 파일/형식 (csv 기본, json 가능)
|
|
49
|
+
|
|
50
|
+
## CLI examples
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 전국 문구·완구 현재 전수 → CSV
|
|
54
|
+
npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_radar.py -- \
|
|
55
|
+
current --out 전국_문구완구.csv
|
|
56
|
+
|
|
57
|
+
# 서울·부산만, 과거 2019 스냅샷과 매칭해 장수 점포 추출
|
|
58
|
+
npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_radar.py -- \
|
|
59
|
+
match --sido 서울 --sido 부산 --old-csv 상가업소정보_201912_01.csv --out 장수점포.csv
|
|
60
|
+
|
|
61
|
+
# 다른 업종 예: 철물점 상호 키워드로
|
|
62
|
+
npx -y @nomadamas/k-skill@0 exec store-longevity-radar scripts/store_longevity_radar.py -- \
|
|
63
|
+
current --code NONE --keyword 철물 --out 전국_철물점.csv
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Workflow
|
|
67
|
+
|
|
68
|
+
1. `current`부터 실행해 대상 업종 전수를 확보한다. zip 자동 다운로드는 수 분 걸릴 수 있음을 사용자에게 알린다.
|
|
69
|
+
2. 과거 스냅샷 CSV가 있으면 `match`로 장수 점포를 추출한다. 기본 문구·완구 코드는 helper가 2022년 이전 `D08A01`/`D04A01`/`D04A02`를 자동 포함한다. 다른 업종은 현재 코드와 과거 코드를 `--code`로 함께 지정한다.
|
|
70
|
+
3. 결과 전달 시 위 Honest limitations를 함께 요약한다.
|
|
71
|
+
4. 후속 확인이 필요하면 `nts-business-registration`(폐업 확정), `localdata-business-status`(인허가 업력), `kakao-map`(전화번호·현재 등재)을 안내한다.
|
|
72
|
+
|
|
73
|
+
## Failure modes
|
|
74
|
+
|
|
75
|
+
- 데이터셋 페이지에서 파일 ID 발견 실패 → `unavailable` + 수동 확인 URL 출력 (분기 개편 시 페이지 구조 변경 가능).
|
|
76
|
+
- 공공데이터포털 접속/다운로드 timeout 또는 HTTP 실패 → `unavailable` + 원인 + 수동 확인 URL 출력.
|
|
77
|
+
- 다운로드 중단 → `.part` 파일만 남고 캐시로 승격되지 않음. 재실행하면 이어서 새로 받는다.
|
|
78
|
+
- `match`에 과거 CSV 미지정 → argparse 에러. 과거분 확보 방법을 사용자에게 안내한다.
|
|
79
|
+
- 0건 매칭: 업종코드가 스냅샷 코드체계와 다를 수 있다. `--keyword`만으로 재시도한다.
|
|
80
|
+
|
|
81
|
+
## Official surfaces
|
|
82
|
+
|
|
83
|
+
- 데이터셋: <https://www.data.go.kr/data/15083033/fileData.do>
|
|
84
|
+
- 다운로드: `https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=<FILE_ID>&fileDetailSn=1` (무인증)
|
|
85
|
+
- 관련 스킬: `nts-business-registration`, `localdata-business-status`, `kakao-map`
|
|
86
|
+
|
|
87
|
+
## Done when
|
|
88
|
+
|
|
89
|
+
- `current` 결과 건수와 출력 파일 경로가 보고되었다.
|
|
90
|
+
- `match` 사용 시 매칭 방법(상가업소번호/상호+좌표)별 건수가 보고되었다.
|
|
91
|
+
- 개업일이 아닌 "최초 관측 시점 하한"임이 사용자에게 고지되었다.
|