@nomadamas/k-skill 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/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/ktx-booking/instruction.md +38 -221
- package/skills/ktx-booking/references/AUTOMATION-LEGAL-STATEMENT.md +10 -0
- package/skills/ktx-booking/scripts/ktx_booking.py +176 -1274
- package/skills/ktx-booking/scripts/ktx_booking.py.lock +27 -0
- package/skills/ktx-booking/scripts/ktx_timetable.py +141 -0
- package/skills/ktx-booking/skill.json +11 -5
- package/skills/seoul-weather-risk/instruction.md +28 -35
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +15 -68
- package/skills/seoul-weather-risk/skill.json +2 -2
- package/skills/srt-booking/instruction.md +48 -154
- package/skills/srt-booking/references/AUTOMATION-LEGAL-STATEMENT.md +25 -0
- package/skills/srt-booking/scripts/srt_booking.py +134 -235
- package/skills/srt-booking/scripts/srt_booking.py.lock +207 -0
- package/skills/srt-booking/skill.json +5 -7
- 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/src/assemble.js +1 -1
- package/src/execute.js +13 -3
- package/skills/srt-booking/scripts/srt_seats.py +0 -156
- 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,27 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 1
|
|
3
|
+
requires-python = ">=3.11"
|
|
4
|
+
|
|
5
|
+
[manifest]
|
|
6
|
+
requirements = [{ name = "openpyxl", specifier = "==3.1.5" }]
|
|
7
|
+
|
|
8
|
+
[[package]]
|
|
9
|
+
name = "et-xmlfile"
|
|
10
|
+
version = "2.0.0"
|
|
11
|
+
source = { registry = "https://pypi.org/simple" }
|
|
12
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
|
|
13
|
+
wheels = [
|
|
14
|
+
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "openpyxl"
|
|
19
|
+
version = "3.1.5"
|
|
20
|
+
source = { registry = "https://pypi.org/simple" }
|
|
21
|
+
dependencies = [
|
|
22
|
+
{ name = "et-xmlfile" },
|
|
23
|
+
]
|
|
24
|
+
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
|
|
25
|
+
wheels = [
|
|
26
|
+
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
|
|
27
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Parse direction-aware KTX timetable sections from official workbooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date as calendar_date
|
|
9
|
+
from datetime import datetime, time
|
|
10
|
+
|
|
11
|
+
TIME_VALUE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
|
12
|
+
TRAIN_NUMBER = re.compile(r"^\d{1,4}$")
|
|
13
|
+
WEEKDAY_NAMES = "월화수목금토일"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class TimetableSection:
|
|
18
|
+
train_index: int
|
|
19
|
+
type_index: int
|
|
20
|
+
dep_index: int
|
|
21
|
+
arr_index: int
|
|
22
|
+
note_index: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_station(value: object) -> str:
|
|
26
|
+
return re.sub(r"\s+", "", str(value or "")).replace("역", "")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_time(value: object) -> str | None:
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if isinstance(value, datetime):
|
|
33
|
+
return value.strftime("%H:%M")
|
|
34
|
+
if isinstance(value, time):
|
|
35
|
+
return None if value == time.min else value.strftime("%H:%M")
|
|
36
|
+
text = str(value).strip()
|
|
37
|
+
if TIME_VALUE.fullmatch(text):
|
|
38
|
+
return text
|
|
39
|
+
if re.fullmatch(r"\d{3,4}", text):
|
|
40
|
+
return f"{int(text) // 100:02d}:{int(text) % 100:02d}"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def header_sections(values: list[str], dep: str, arr: str) -> list[TimetableSection]:
|
|
45
|
+
starts = [index for index, value in enumerate(values) if value == "열차번호"]
|
|
46
|
+
sections: list[TimetableSection] = []
|
|
47
|
+
for position, start in enumerate(starts):
|
|
48
|
+
end = starts[position + 1] if position + 1 < len(starts) else len(values)
|
|
49
|
+
dep_indices = [index for index in range(start, end) if values[index] == dep]
|
|
50
|
+
arr_indices = [index for index in range(start, end) if values[index] == arr]
|
|
51
|
+
if not dep_indices or not arr_indices:
|
|
52
|
+
continue
|
|
53
|
+
dep_index = dep_indices[0]
|
|
54
|
+
arr_index = arr_indices[0]
|
|
55
|
+
if dep_index >= arr_index:
|
|
56
|
+
continue
|
|
57
|
+
type_indices = [index for index in range(start, end) if values[index] == "편성"]
|
|
58
|
+
note_indices = [index for index in range(start, end) if values[index] == "비고"]
|
|
59
|
+
sections.append(
|
|
60
|
+
TimetableSection(
|
|
61
|
+
train_index=start,
|
|
62
|
+
type_index=type_indices[0] if type_indices else -1,
|
|
63
|
+
dep_index=dep_index,
|
|
64
|
+
arr_index=arr_index,
|
|
65
|
+
note_index=note_indices[0] if note_indices else -1,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return sections
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def runs_on_date(value: object, requested_date: calendar_date) -> bool:
|
|
72
|
+
text = re.sub(r"\s+", "", str(value or ""))
|
|
73
|
+
if not text or "매일" in text:
|
|
74
|
+
return True
|
|
75
|
+
if text == "평일":
|
|
76
|
+
return requested_date.weekday() < 5
|
|
77
|
+
compact = re.sub(r"[,./·~\-]", "", text)
|
|
78
|
+
if re.fullmatch(r"[월화수목금토일]+", compact):
|
|
79
|
+
return WEEKDAY_NAMES[requested_date.weekday()] in compact
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_timetable_rows(
|
|
84
|
+
rows: Iterable[Iterable[object]],
|
|
85
|
+
*,
|
|
86
|
+
dep: str,
|
|
87
|
+
arr: str,
|
|
88
|
+
requested_date: calendar_date,
|
|
89
|
+
earliest: str,
|
|
90
|
+
latest: str,
|
|
91
|
+
) -> tuple[list[dict[str, str]], bool]:
|
|
92
|
+
dep_name = normalize_station(dep)
|
|
93
|
+
arr_name = normalize_station(arr)
|
|
94
|
+
sections: list[TimetableSection] = []
|
|
95
|
+
route_found = False
|
|
96
|
+
results: list[dict[str, str]] = []
|
|
97
|
+
|
|
98
|
+
for raw_row in rows:
|
|
99
|
+
row = list(raw_row)
|
|
100
|
+
normalized = [normalize_station(value) for value in row]
|
|
101
|
+
if "열차번호" in normalized:
|
|
102
|
+
sections = header_sections(normalized, dep_name, arr_name)
|
|
103
|
+
route_found = route_found or bool(sections)
|
|
104
|
+
continue
|
|
105
|
+
for section in sections:
|
|
106
|
+
indices = (
|
|
107
|
+
section.train_index,
|
|
108
|
+
section.type_index,
|
|
109
|
+
section.dep_index,
|
|
110
|
+
section.arr_index,
|
|
111
|
+
section.note_index,
|
|
112
|
+
)
|
|
113
|
+
if max(indices) >= len(row):
|
|
114
|
+
continue
|
|
115
|
+
train_no = str(row[section.train_index] or "").strip()
|
|
116
|
+
if not TRAIN_NUMBER.fullmatch(train_no):
|
|
117
|
+
continue
|
|
118
|
+
train_type = (
|
|
119
|
+
str(row[section.type_index] or "").strip().upper()
|
|
120
|
+
if section.type_index >= 0
|
|
121
|
+
else "KTX"
|
|
122
|
+
)
|
|
123
|
+
if "KTX" not in train_type:
|
|
124
|
+
continue
|
|
125
|
+
if section.note_index >= 0 and not runs_on_date(row[section.note_index], requested_date):
|
|
126
|
+
continue
|
|
127
|
+
dep_time = normalize_time(row[section.dep_index])
|
|
128
|
+
arr_time = normalize_time(row[section.arr_index])
|
|
129
|
+
if dep_time is None or arr_time is None or not earliest <= dep_time <= latest:
|
|
130
|
+
continue
|
|
131
|
+
results.append(
|
|
132
|
+
{
|
|
133
|
+
"train_no": train_no,
|
|
134
|
+
"train_type": train_type,
|
|
135
|
+
"dep": dep,
|
|
136
|
+
"arr": arr,
|
|
137
|
+
"dep_time": dep_time,
|
|
138
|
+
"arr_time": arr_time,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
return results, route_found
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ktx-booking",
|
|
3
|
-
"description": "
|
|
3
|
+
"description": "Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.",
|
|
4
4
|
"profiles": [
|
|
5
|
-
"
|
|
6
|
-
"browser",
|
|
7
|
-
"action:booking"
|
|
5
|
+
"lookup"
|
|
8
6
|
],
|
|
9
|
-
"frontmatter": "name: ktx-booking\ndescription:
|
|
7
|
+
"frontmatter": "name: ktx-booking\ndescription: Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1",
|
|
10
8
|
"bundle": [
|
|
11
9
|
{
|
|
12
10
|
"from": "scripts/ktx_booking.py",
|
|
13
11
|
"to": "scripts/ktx_booking.py"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"from": "scripts/ktx_timetable.py",
|
|
15
|
+
"to": "scripts/ktx_timetable.py"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"from": "scripts/ktx_booking.py.lock",
|
|
19
|
+
"to": "scripts/ktx_booking.py.lock"
|
|
14
20
|
}
|
|
15
21
|
]
|
|
16
22
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## What this skill does
|
|
4
4
|
|
|
5
|
-
서울 행정동 이름을 정규 `place_id`로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대 단일 제품(`weather_place_risk_window`)을 읽기 전용으로 탐색한다. 기본 helper는 hosted `k-skill-proxy
|
|
5
|
+
서울 행정동 이름을 정규 `place_id`로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대 단일 제품(`weather_place_risk_window`)을 읽기 전용으로 탐색한다. 기본 helper는 hosted `k-skill-proxy`만 호출하며, 사용자 API Key나 현재 작업 폴더의 `.env`를 읽지 않는다. 실패 또는 미준비 상태를 fixture나 추정값으로 대체하지 않는다.
|
|
6
6
|
|
|
7
7
|
## Product
|
|
8
8
|
|
|
@@ -26,58 +26,52 @@
|
|
|
26
26
|
|
|
27
27
|
## Workflow
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
### Standard user query (fast path)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- preflight
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
2. bundle에서 이 제품의 준비 상태를 확인한다.
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- catalog
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
3. 제품의 grain, 기본키, 시간축, 공개 column 및 증거 metadata를 확인한다.
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- describe --product-id weather_place_risk_window
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
4. 행정동 이름과 `1..500` 범위의 limit으로 data page를 조회한다.
|
|
31
|
+
사용자가 오늘 위험 시간대를 묻는 기본 경로는 `query --fast` 한 번만 실행한다. 이 경로는 bundled 행정동 매핑과 날짜·limit 검증을 유지하면서 hosted data route만 한 번 호출하므로 bundle·product metadata 왕복을 생략한다. `--fast`에서는 `--filter`를 사용하지 않고 `--admin-dong`, `--gu`, 날짜, `--limit`, `--cursor`만 사용한다.
|
|
48
32
|
|
|
49
33
|
```bash
|
|
50
|
-
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query \
|
|
34
|
+
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query --fast \
|
|
51
35
|
--product-id weather_place_risk_window \
|
|
52
36
|
--admin-dong 잠실본동 \
|
|
53
|
-
--from 2026-08-
|
|
54
|
-
--to 2026-08-
|
|
37
|
+
--from 2026-08-12 \
|
|
38
|
+
--to 2026-08-12 \
|
|
55
39
|
--limit 100
|
|
56
40
|
```
|
|
57
41
|
|
|
58
|
-
동명이명인 `신사동`은 자치구를 확인한 뒤
|
|
42
|
+
동명이명인 `신사동`은 자치구를 확인한 뒤 fast path에도 `--gu`를 함께 전달한다.
|
|
59
43
|
|
|
60
44
|
```bash
|
|
61
|
-
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query \
|
|
45
|
+
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query --fast \
|
|
62
46
|
--product-id weather_place_risk_window \
|
|
63
47
|
--admin-dong 신사동 \
|
|
64
48
|
--gu 강남구 \
|
|
65
49
|
--limit 100
|
|
66
50
|
```
|
|
67
51
|
|
|
68
|
-
|
|
52
|
+
`--filter`가 필요하거나 게시 계약을 점검해야 할 때는 `--fast`를 빼고 full-contract query를 사용한다. fast query가 `product_not_ready` 또는 계약 오류를 반환하면 fixture나 추정값으로 대체하지 말고 아래 진단 흐름을 수행한다.
|
|
53
|
+
|
|
54
|
+
### Contract diagnostics (only when needed)
|
|
69
55
|
|
|
70
|
-
|
|
56
|
+
1. 환경 설정만 확인한다. 이 명령은 네트워크를 호출하지 않는다.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- preflight
|
|
60
|
+
```
|
|
71
61
|
|
|
72
|
-
|
|
62
|
+
2. bundle에서 이 제품의 준비 상태를 확인한다.
|
|
73
63
|
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
64
|
+
```bash
|
|
65
|
+
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- catalog
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
3. 제품의 grain, 기본키, 시간축, 공개 column 및 증거 metadata를 확인한다.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- describe --product-id weather_place_risk_window
|
|
78
72
|
```
|
|
79
73
|
|
|
80
|
-
|
|
74
|
+
진단 응답의 `registration_ready`, `publication_id`, `blockers`를 확인한다. fast/full data 응답의 `next_cursor`는 같은 제품의 다음 page에만 그대로 재사용하며 publication이 바뀌면 cursor는 `409`로 만료된다.
|
|
81
75
|
|
|
82
76
|
## Boundaries
|
|
83
77
|
|
|
@@ -85,9 +79,9 @@ MARKETPLACE_API_KEY=<기존 로컬 Marketplace 키>
|
|
|
85
79
|
- 알 수 없는 제품이나 필터를 추측해 보정하지 않는다.
|
|
86
80
|
- 행정동 이름을 fuzzy match하거나 모호한 후보 중 하나로 임의 선택하지 않는다. 생활권·통칭 또는 부분 이름(예: `성수동`)도 행정동으로 추측하지 않는다. helper는 로컬 reference에서 `place_id`를 해석하고 proxy에는 행정동·자치구 문자열을 보내지 않는다.
|
|
87
81
|
- 기본 proxy origin은 `https://k-skill-proxy.nomadamas.org`이다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL`을 HTTPS origin으로 설정한다. 값은 명령행 인수, 문서, 로그에 넣지 않는다.
|
|
88
|
-
- hosted-proxy 모드에서는 사용자 API Key와 `Authorization` 헤더를 사용하지 않는다. ASK Seoul 전용 서비스 키는 proxy 운영 환경에만 두며, Marketplace의 `k-skill-proxy:seoul-weather-risk` principal에 `skill:seoul-weather-risk:read` scope로 등록한다. 이 scope는 bundle·product·data 읽기만 허용하고 다른 Marketplace API를 거부한다.
|
|
82
|
+
- hosted-proxy 모드에서는 사용자 API Key와 `Authorization` 헤더를 사용하지 않는다. ASK Seoul 전용 서비스 키는 proxy 운영 환경에만 두며, Marketplace의 `k-skill-proxy:seoul-weather-risk` principal에 `skill:seoul-weather-risk:read` scope로 등록한다. 이 scope는 bundle·product·data 읽기만 허용하고 다른 Marketplace API를 거부한다. 어떤 모드에서도 키를 출력·로그·skill 파일에 넣지 않는다.
|
|
89
83
|
- proxy는 bundle, 단일 product, 그 data 조회만 노출한다. `table name`, SQL, join, sort, aggregate 및 비허용 query field는 upstream으로 전달하지 않는다.
|
|
90
|
-
- `/
|
|
84
|
+
- `/v1/ask-seoul/weather-risk/bundle`의 제품 집합이 `weather_place_risk_window` 단일 제품과 다르면 응답 계약 오류로 중단한다.
|
|
91
85
|
- live 실패를 fixture나 synthetic 결과로 대체하지 않는다.
|
|
92
86
|
- 이 제품은 예보값 임계치 기반 참고 정보이며 기상청 공식 특보를 대체하지 않는다는 점을 응답에서 명확히 한다.
|
|
93
87
|
|
|
@@ -104,7 +98,6 @@ MARKETPLACE_API_KEY=<기존 로컬 Marketplace 키>
|
|
|
104
98
|
- `ambiguous_admin_dong`: 동명이거나 별칭 후보가 충돌해 `--gu`가 필요함. `details.candidates`에서 가능한 자치구를 확인한다.
|
|
105
99
|
- `location_mapping_invalid`: bundled 행정동 reference의 버전·스키마·행 수 계약 오류
|
|
106
100
|
- `proxy_disabled`, `invalid_proxy_base_url`: proxy 환경 설정 오류
|
|
107
|
-
- `local_direct_not_configured`, `invalid_local_direct_base_url`: local-direct 환경 설정 오류
|
|
108
101
|
- `unauthorized`/`api_key_missing`(401), `forbidden`/`api_key_forbidden`(403), `unknown_product`(404)
|
|
109
102
|
- `cursor_expired`(409), `rate_limited`(429), `product_not_ready`(503)
|
|
110
103
|
- `upstream_not_configured`(503): proxy 운영 환경에 ASK Seoul 전용 서비스 키 또는 origin이 설정되지 않음
|
|
@@ -20,16 +20,7 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
|
20
20
|
SKILL_BUNDLE_ID = "seoul-weather-risk"
|
|
21
21
|
PROXY_BASE_URL_ENV = "KSKILL_PROXY_BASE_URL"
|
|
22
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
23
|
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
24
|
PROXY_ROUTE_ROOT = "/v1/ask-seoul/weather-risk"
|
|
34
25
|
LOCATION_MAPPING_PATH = pathlib.Path(__file__).resolve().parents[1] / "references" / "admin-dong-place-map.json"
|
|
35
26
|
LOCATION_MAPPING_VERSION = "kma_admin_dong_grid_20260325"
|
|
@@ -65,54 +56,14 @@ class _NoRedirect(HTTPRedirectHandler):
|
|
|
65
56
|
@dataclass(frozen=True)
|
|
66
57
|
class ApiConfig:
|
|
67
58
|
base_url: str
|
|
68
|
-
mode: str = "hosted_proxy"
|
|
69
|
-
bearer_token: str | None = None
|
|
70
59
|
|
|
71
60
|
|
|
72
61
|
def _json(value: Any) -> str:
|
|
73
62
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
74
63
|
|
|
75
64
|
|
|
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
65
|
def _api_config(environ: dict[str, str] | None = None) -> ApiConfig:
|
|
113
|
-
values =
|
|
114
|
-
if values.get(LOCAL_DIRECT_ENV, "").strip().casefold() in LOCAL_DIRECT_ENABLED_VALUES:
|
|
115
|
-
return _local_direct_config(values)
|
|
66
|
+
values = os.environ if environ is None else environ
|
|
116
67
|
configured = values.get(PROXY_BASE_URL_ENV, "").strip()
|
|
117
68
|
if configured.casefold() in PROXY_DISABLED_VALUES:
|
|
118
69
|
raise SkillError("proxy_disabled", f"{PROXY_BASE_URL_ENV}가 비활성화되어 있습니다.")
|
|
@@ -159,8 +110,6 @@ def _request_json(config: ApiConfig, path: str, query: dict[str, str] | None = N
|
|
|
159
110
|
"Accept": "application/json",
|
|
160
111
|
"User-Agent": "k-skill-seoul-weather-risk/1",
|
|
161
112
|
}
|
|
162
|
-
if config.bearer_token:
|
|
163
|
-
headers["Authorization"] = f"Bearer {config.bearer_token}"
|
|
164
113
|
request = Request(url, headers=headers)
|
|
165
114
|
try:
|
|
166
115
|
with build_opener(_NoRedirect).open(request, timeout=15) as response:
|
|
@@ -399,22 +348,16 @@ def _validate_product_id(product_id: str) -> None:
|
|
|
399
348
|
|
|
400
349
|
|
|
401
350
|
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
351
|
return _validate_bundle(_request_json(config, f"{PROXY_ROUTE_ROOT}/bundle"))
|
|
405
352
|
|
|
406
353
|
|
|
407
354
|
def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
|
|
408
355
|
_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
356
|
return _validate_product(_request_json(config, f"{PROXY_ROUTE_ROOT}/product"), product_id)
|
|
412
357
|
|
|
413
358
|
|
|
414
359
|
def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
|
|
415
360
|
_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
361
|
return _validate_data(_request_json(config, f"{PROXY_ROUTE_ROOT}/data", query), product_id, limit)
|
|
419
362
|
|
|
420
363
|
|
|
@@ -428,6 +371,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
428
371
|
describe.add_argument("--product-id", required=True)
|
|
429
372
|
|
|
430
373
|
query = commands.add_parser("query", help="제품 data page 조회")
|
|
374
|
+
query.add_argument("--fast", action="store_true", help="bundle/product metadata 확인을 생략하고 data만 조회")
|
|
431
375
|
query.add_argument("--product-id", required=True)
|
|
432
376
|
query.add_argument("--filter", action="append", default=[], metavar="COLUMN=VALUE")
|
|
433
377
|
query.add_argument("--admin-dong", help="서울 행정동 이름")
|
|
@@ -446,11 +390,9 @@ def run(argv: list[str]) -> int:
|
|
|
446
390
|
if args.command == "preflight":
|
|
447
391
|
result = {
|
|
448
392
|
"status": "ok",
|
|
449
|
-
"mode":
|
|
393
|
+
"mode": "hosted_proxy",
|
|
450
394
|
"live_network": False,
|
|
451
|
-
"
|
|
452
|
-
"proxy_base_url_configured": config.mode == "hosted_proxy",
|
|
453
|
-
"local_direct_base_url_configured": config.mode == "local_direct",
|
|
395
|
+
"proxy_base_url_configured": True,
|
|
454
396
|
}
|
|
455
397
|
elif args.command == "catalog":
|
|
456
398
|
result = _bundle(config)
|
|
@@ -460,8 +402,12 @@ def run(argv: list[str]) -> int:
|
|
|
460
402
|
else:
|
|
461
403
|
if not 1 <= args.limit <= 500:
|
|
462
404
|
raise SkillError("invalid_limit", "limit은 1부터 500 사이여야 합니다.")
|
|
463
|
-
|
|
464
|
-
|
|
405
|
+
if args.fast and args.filter:
|
|
406
|
+
raise SkillError("fast_query_filter_unsupported", "--fast에서는 --filter를 사용할 수 없습니다.")
|
|
407
|
+
detail: dict[str, Any] | None = None
|
|
408
|
+
if not args.fast:
|
|
409
|
+
_bundle(config)
|
|
410
|
+
detail = _detail(config, args.product_id)
|
|
465
411
|
filters = _filters(args.filter)
|
|
466
412
|
if args.gu is not None and args.admin_dong is None:
|
|
467
413
|
raise SkillError("invalid_location_input", "--gu는 --admin-dong과 함께 사용해야 합니다.")
|
|
@@ -477,10 +423,11 @@ def run(argv: list[str]) -> int:
|
|
|
477
423
|
)
|
|
478
424
|
resolved = _resolve_admin_dong(args.admin_dong, args.gu)
|
|
479
425
|
filters["place_id"] = resolved["place_id"]
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
426
|
+
if detail is not None:
|
|
427
|
+
allowed_columns = {column["name"] for column in detail["metadata"].get("columns", [])}
|
|
428
|
+
unknown = sorted(set(filters) - allowed_columns)
|
|
429
|
+
if unknown:
|
|
430
|
+
raise SkillError("unknown_filter", f"공개 projection에 없는 필터입니다: {', '.join(unknown)}")
|
|
484
431
|
request_query = {**filters, "limit": str(args.limit)}
|
|
485
432
|
if args.from_value is not None:
|
|
486
433
|
request_query["from"] = _time_bound(args.from_value, "from")
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seoul-weather-risk",
|
|
3
|
-
"description": "서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다.
|
|
3
|
+
"description": "서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 사용자 API Key와 place_id 입력은 필요하지 않다.",
|
|
4
4
|
"profiles": [
|
|
5
5
|
"proxy",
|
|
6
6
|
"lookup"
|
|
7
7
|
],
|
|
8
|
-
"frontmatter": "name: seoul-weather-risk\ndescription: 서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다.
|
|
8
|
+
"frontmatter": "name: seoul-weather-risk\ndescription: 서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 사용자 API Key와 place_id 입력은 필요하지 않다.\nlicense: MIT\nmetadata:\n category: public-data\n locale: ko-KR\n phase: live-client"
|
|
9
9
|
}
|