@nomadamas/k-skill 0.2.0 → 0.2.1
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/bin/k-skill.js +0 -0
- package/package.json +1 -1
- package/skills/fine-dust-location/instruction.md +1 -1
- package/skills/fine-dust-location/scripts/fine_dust.py +549 -0
- package/skills/fine-dust-location/skill.json +7 -1
- package/skills/k-skill-setup/instruction.md +1 -1
- package/skills/k-skill-setup/scripts/check-setup.sh +29 -0
- package/skills/k-skill-setup/skill.json +7 -1
- package/skills/ktx-booking/instruction.md +15 -15
- package/skills/ktx-booking/scripts/ktx_booking.py +1316 -0
- package/skills/ktx-booking/skill.json +7 -1
- package/src/assemble.js +1 -0
package/bin/k-skill.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -36,7 +36,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
|
|
36
36
|
스크립트 helper 도 같은 report endpoint 를 기본 경로로 사용한다.
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
|
|
39
|
+
npx -y @nomadamas/k-skill@0 exec fine-dust-location scripts/fine_dust.py -- report --region-hint '서울 강남구' --json
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
## Ambiguous locations
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from math import atan2, cos, radians, sin, sqrt, tan
|
|
13
|
+
|
|
14
|
+
STATION_SERVICE_URL = "http://apis.data.go.kr/B552584/MsrstnInfoInqireSvc"
|
|
15
|
+
MEASUREMENT_SERVICE_URL = "http://apis.data.go.kr/B552584/ArpltnInforInqireSvc"
|
|
16
|
+
SECRET_NAME = "AIR_KOREA_OPEN_API_KEY"
|
|
17
|
+
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
|
|
18
|
+
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
|
19
|
+
PROXY_DOWN_MSG = "설정된 k-skill-proxy 서버가 응답하지 않습니다. 잠시 후 재시도하거나 운영자에게 문의하세요."
|
|
20
|
+
PROXY_KEY_NOT_CONFIGURED_MSG = "k-skill-proxy에 필요한 API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요."
|
|
21
|
+
WGS84_A = 6378137.0
|
|
22
|
+
WGS84_F = 1 / 298.257223563
|
|
23
|
+
BESSEL_A = 6377397.155
|
|
24
|
+
BESSEL_F = 1 / 299.1528128
|
|
25
|
+
AIR_KOREA_TM_LAT0 = radians(38.0)
|
|
26
|
+
AIR_KOREA_TM_LON0 = radians(127.0)
|
|
27
|
+
AIR_KOREA_TM_FALSE_EASTING = 200000.0
|
|
28
|
+
AIR_KOREA_TM_FALSE_NORTHING = 500000.0
|
|
29
|
+
AIR_KOREA_TM_SCALE = 1.0
|
|
30
|
+
AIR_KOREA_WGS84_TO_BESSEL = (146.43, -507.89, -681.46)
|
|
31
|
+
GRADE_LABELS = {
|
|
32
|
+
"1": "좋음",
|
|
33
|
+
"2": "보통",
|
|
34
|
+
"3": "나쁨",
|
|
35
|
+
"4": "매우나쁨",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
description="Summarize Air Korea PM10/PM2.5 data from location or fallback hints.",
|
|
42
|
+
)
|
|
43
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
report = subparsers.add_parser("report", help="build a PM10/PM2.5 report")
|
|
46
|
+
report.add_argument("--lat", type=float, help="WGS84 latitude")
|
|
47
|
+
report.add_argument("--lon", type=float, help="WGS84 longitude")
|
|
48
|
+
report.add_argument("--region-hint", help="fallback region/administrative-area hint")
|
|
49
|
+
report.add_argument("--station-name", help="explicit station name fallback")
|
|
50
|
+
report.add_argument("--station-file", help="offline station JSON fixture")
|
|
51
|
+
report.add_argument("--measurement-file", help="offline measurement JSON fixture")
|
|
52
|
+
report.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
53
|
+
return parser.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_json_file(path: str | os.PathLike[str]) -> dict:
|
|
57
|
+
return json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def extract_items(payload: dict | list) -> list[dict]:
|
|
61
|
+
if isinstance(payload, list):
|
|
62
|
+
return payload
|
|
63
|
+
|
|
64
|
+
response = payload.get("response", {})
|
|
65
|
+
body = response.get("body", {})
|
|
66
|
+
items = body.get("items", [])
|
|
67
|
+
|
|
68
|
+
if isinstance(items, dict):
|
|
69
|
+
return [items]
|
|
70
|
+
if isinstance(items, list):
|
|
71
|
+
return items
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def to_float(raw: object) -> float | None:
|
|
76
|
+
if raw in (None, "", "-"):
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return float(str(raw))
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def squared_distance(lat_a: float, lon_a: float, lat_b: float, lon_b: float) -> float:
|
|
85
|
+
return (lat_a - lat_b) ** 2 + (lon_a - lon_b) ** 2
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def meridional_arc(phi: float, *, semi_major_axis: float, eccentricity_squared: float) -> float:
|
|
89
|
+
e2 = eccentricity_squared
|
|
90
|
+
return semi_major_axis * (
|
|
91
|
+
(1 - e2 / 4 - 3 * e2**2 / 64 - 5 * e2**3 / 256) * phi
|
|
92
|
+
- (3 * e2 / 8 + 3 * e2**2 / 32 + 45 * e2**3 / 1024) * sin(2 * phi)
|
|
93
|
+
+ (15 * e2**2 / 256 + 45 * e2**3 / 1024) * sin(4 * phi)
|
|
94
|
+
- (35 * e2**3 / 3072) * sin(6 * phi)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def wgs84_to_bessel(lat: float, lon: float) -> tuple[float, float]:
|
|
99
|
+
dx, dy, dz = AIR_KOREA_WGS84_TO_BESSEL
|
|
100
|
+
source_e2 = 2 * WGS84_F - WGS84_F**2
|
|
101
|
+
target_e2 = 2 * BESSEL_F - BESSEL_F**2
|
|
102
|
+
|
|
103
|
+
lat_rad = radians(lat)
|
|
104
|
+
lon_rad = radians(lon)
|
|
105
|
+
sin_lat = sin(lat_rad)
|
|
106
|
+
cos_lat = cos(lat_rad)
|
|
107
|
+
prime_vertical_radius = WGS84_A / sqrt(1 - source_e2 * sin_lat * sin_lat)
|
|
108
|
+
|
|
109
|
+
x = prime_vertical_radius * cos_lat * cos(lon_rad) + dx
|
|
110
|
+
y = prime_vertical_radius * cos_lat * sin(lon_rad) + dy
|
|
111
|
+
z = prime_vertical_radius * (1 - source_e2) * sin_lat + dz
|
|
112
|
+
|
|
113
|
+
lon_bessel = atan2(y, x)
|
|
114
|
+
horizontal = sqrt(x * x + y * y)
|
|
115
|
+
lat_bessel = atan2(z, horizontal * (1 - target_e2))
|
|
116
|
+
|
|
117
|
+
for _ in range(8):
|
|
118
|
+
sin_lat_bessel = sin(lat_bessel)
|
|
119
|
+
bessel_radius = BESSEL_A / sqrt(1 - target_e2 * sin_lat_bessel * sin_lat_bessel)
|
|
120
|
+
next_lat = atan2(z + target_e2 * bessel_radius * sin_lat_bessel, horizontal)
|
|
121
|
+
if abs(next_lat - lat_bessel) < 1e-14:
|
|
122
|
+
lat_bessel = next_lat
|
|
123
|
+
break
|
|
124
|
+
lat_bessel = next_lat
|
|
125
|
+
|
|
126
|
+
return lat_bessel, lon_bessel
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def wgs84_to_air_korea_tm(lat: float, lon: float) -> tuple[float, float]:
|
|
130
|
+
lat_rad, lon_rad = wgs84_to_bessel(lat, lon)
|
|
131
|
+
bessel_e2 = 2 * BESSEL_F - BESSEL_F**2
|
|
132
|
+
second_eccentricity_squared = bessel_e2 / (1 - bessel_e2)
|
|
133
|
+
|
|
134
|
+
sin_lat = sin(lat_rad)
|
|
135
|
+
cos_lat = cos(lat_rad)
|
|
136
|
+
tan_lat = tan(lat_rad)
|
|
137
|
+
|
|
138
|
+
prime_vertical_radius = BESSEL_A / sqrt(1 - bessel_e2 * sin_lat * sin_lat)
|
|
139
|
+
tan_squared = tan_lat * tan_lat
|
|
140
|
+
curvature = second_eccentricity_squared * cos_lat * cos_lat
|
|
141
|
+
A = (lon_rad - AIR_KOREA_TM_LON0) * cos_lat
|
|
142
|
+
|
|
143
|
+
meridional = meridional_arc(lat_rad, semi_major_axis=BESSEL_A, eccentricity_squared=bessel_e2)
|
|
144
|
+
meridional_origin = meridional_arc(
|
|
145
|
+
AIR_KOREA_TM_LAT0,
|
|
146
|
+
semi_major_axis=BESSEL_A,
|
|
147
|
+
eccentricity_squared=bessel_e2,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
tm_x = AIR_KOREA_TM_FALSE_EASTING + AIR_KOREA_TM_SCALE * prime_vertical_radius * (
|
|
151
|
+
A
|
|
152
|
+
+ (1 - tan_squared + curvature) * A**3 / 6
|
|
153
|
+
+ (5 - 18 * tan_squared + tan_squared**2 + 72 * curvature - 58 * second_eccentricity_squared) * A**5 / 120
|
|
154
|
+
)
|
|
155
|
+
tm_y = AIR_KOREA_TM_FALSE_NORTHING + AIR_KOREA_TM_SCALE * (
|
|
156
|
+
meridional
|
|
157
|
+
- meridional_origin
|
|
158
|
+
+ prime_vertical_radius
|
|
159
|
+
* tan_lat
|
|
160
|
+
* (
|
|
161
|
+
A**2 / 2
|
|
162
|
+
+ (5 - tan_squared + 9 * curvature + 4 * curvature**2) * A**4 / 24
|
|
163
|
+
+ (61 - 58 * tan_squared + tan_squared**2 + 600 * curvature - 330 * second_eccentricity_squared)
|
|
164
|
+
* A**6
|
|
165
|
+
/ 720
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return tm_x, tm_y
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def pick_station(
|
|
172
|
+
station_items: list[dict],
|
|
173
|
+
*,
|
|
174
|
+
lat: float | None = None,
|
|
175
|
+
lon: float | None = None,
|
|
176
|
+
region_hint: str | None = None,
|
|
177
|
+
station_name: str | None = None,
|
|
178
|
+
) -> dict:
|
|
179
|
+
if not station_items:
|
|
180
|
+
raise SystemExit("측정소 후보가 없습니다.")
|
|
181
|
+
|
|
182
|
+
if station_name:
|
|
183
|
+
exact_match = next((item for item in station_items if item.get("stationName") == station_name), None)
|
|
184
|
+
if exact_match:
|
|
185
|
+
return exact_match
|
|
186
|
+
partial_match = next(
|
|
187
|
+
(
|
|
188
|
+
item
|
|
189
|
+
for item in station_items
|
|
190
|
+
if station_name in str(item.get("stationName", "")) or station_name in str(item.get("addr", ""))
|
|
191
|
+
),
|
|
192
|
+
None,
|
|
193
|
+
)
|
|
194
|
+
if partial_match:
|
|
195
|
+
return partial_match
|
|
196
|
+
|
|
197
|
+
if lat is not None and lon is not None:
|
|
198
|
+
candidates = []
|
|
199
|
+
for item in station_items:
|
|
200
|
+
item_lat = to_float(item.get("dmX"))
|
|
201
|
+
item_lon = to_float(item.get("dmY"))
|
|
202
|
+
if item_lat is None or item_lon is None:
|
|
203
|
+
continue
|
|
204
|
+
candidates.append((squared_distance(lat, lon, item_lat, item_lon), item))
|
|
205
|
+
if candidates:
|
|
206
|
+
candidates.sort(key=lambda pair: pair[0])
|
|
207
|
+
return candidates[0][1]
|
|
208
|
+
|
|
209
|
+
if region_hint:
|
|
210
|
+
tokens = sorted({token for token in region_hint.split() if token}, key=len, reverse=True)
|
|
211
|
+
for token in tokens:
|
|
212
|
+
station_name_match = next(
|
|
213
|
+
(item for item in station_items if token in str(item.get("stationName", ""))),
|
|
214
|
+
None,
|
|
215
|
+
)
|
|
216
|
+
if station_name_match:
|
|
217
|
+
return station_name_match
|
|
218
|
+
|
|
219
|
+
address_match = next(
|
|
220
|
+
(item for item in station_items if token in str(item.get("addr", ""))),
|
|
221
|
+
None,
|
|
222
|
+
)
|
|
223
|
+
if address_match:
|
|
224
|
+
return address_match
|
|
225
|
+
|
|
226
|
+
return station_items[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def resolve_station(
|
|
230
|
+
station_items: list[dict],
|
|
231
|
+
*,
|
|
232
|
+
lat: float | None = None,
|
|
233
|
+
lon: float | None = None,
|
|
234
|
+
region_hint: str | None = None,
|
|
235
|
+
station_name: str | None = None,
|
|
236
|
+
) -> dict:
|
|
237
|
+
if station_items:
|
|
238
|
+
return pick_station(
|
|
239
|
+
station_items,
|
|
240
|
+
lat=lat,
|
|
241
|
+
lon=lon,
|
|
242
|
+
region_hint=region_hint,
|
|
243
|
+
station_name=station_name,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if station_name:
|
|
247
|
+
return {"stationName": station_name, "addr": None}
|
|
248
|
+
|
|
249
|
+
raise SystemExit("측정소 후보가 없습니다.")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def find_measurement(measurement_items: list[dict], station_name: str) -> dict:
|
|
253
|
+
exact_match = next((item for item in measurement_items if item.get("stationName") == station_name), None)
|
|
254
|
+
if exact_match:
|
|
255
|
+
return exact_match
|
|
256
|
+
|
|
257
|
+
partial_match = next(
|
|
258
|
+
(item for item in measurement_items if station_name in str(item.get("stationName", ""))),
|
|
259
|
+
None,
|
|
260
|
+
)
|
|
261
|
+
if partial_match:
|
|
262
|
+
return partial_match
|
|
263
|
+
|
|
264
|
+
raise SystemExit(f"측정값 응답에서 측정소 '{station_name}' 를 찾지 못했습니다.")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def grade_to_label(raw_grade: object, *, pollutant: str, value: object) -> str:
|
|
268
|
+
raw_text = str(raw_grade) if raw_grade not in (None, "") else ""
|
|
269
|
+
if raw_text in GRADE_LABELS:
|
|
270
|
+
return GRADE_LABELS[raw_text]
|
|
271
|
+
|
|
272
|
+
numeric_value = to_float(value)
|
|
273
|
+
if numeric_value is None:
|
|
274
|
+
return "정보없음"
|
|
275
|
+
|
|
276
|
+
thresholds = {
|
|
277
|
+
"pm10": [(30, "좋음"), (80, "보통"), (150, "나쁨")],
|
|
278
|
+
"pm25": [(15, "좋음"), (35, "보통"), (75, "나쁨")],
|
|
279
|
+
}[pollutant]
|
|
280
|
+
|
|
281
|
+
for threshold, label in thresholds:
|
|
282
|
+
if numeric_value <= threshold:
|
|
283
|
+
return label
|
|
284
|
+
return "매우나쁨"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_report(
|
|
288
|
+
*,
|
|
289
|
+
station_items: list[dict],
|
|
290
|
+
measurement_items: list[dict],
|
|
291
|
+
lat: float | None = None,
|
|
292
|
+
lon: float | None = None,
|
|
293
|
+
region_hint: str | None = None,
|
|
294
|
+
station_name: str | None = None,
|
|
295
|
+
lookup_mode: str | None = None,
|
|
296
|
+
selected_station: dict | None = None,
|
|
297
|
+
) -> dict:
|
|
298
|
+
station = selected_station or resolve_station(
|
|
299
|
+
station_items,
|
|
300
|
+
lat=lat,
|
|
301
|
+
lon=lon,
|
|
302
|
+
region_hint=region_hint,
|
|
303
|
+
station_name=station_name,
|
|
304
|
+
)
|
|
305
|
+
measurement = find_measurement(measurement_items, station["stationName"])
|
|
306
|
+
|
|
307
|
+
resolved_lookup_mode = lookup_mode or ("coordinates" if lat is not None and lon is not None else "fallback")
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
"station_name": station["stationName"],
|
|
311
|
+
"station_address": station.get("addr"),
|
|
312
|
+
"lookup_mode": resolved_lookup_mode,
|
|
313
|
+
"measured_at": measurement.get("dataTime"),
|
|
314
|
+
"pm10": {
|
|
315
|
+
"value": str(measurement.get("pm10Value", "-")),
|
|
316
|
+
"grade": grade_to_label(
|
|
317
|
+
measurement.get("pm10Grade"),
|
|
318
|
+
pollutant="pm10",
|
|
319
|
+
value=measurement.get("pm10Value"),
|
|
320
|
+
),
|
|
321
|
+
},
|
|
322
|
+
"pm25": {
|
|
323
|
+
"value": str(measurement.get("pm25Value", "-")),
|
|
324
|
+
"grade": grade_to_label(
|
|
325
|
+
measurement.get("pm25Grade"),
|
|
326
|
+
pollutant="pm25",
|
|
327
|
+
value=measurement.get("pm25Value"),
|
|
328
|
+
),
|
|
329
|
+
},
|
|
330
|
+
"khai_grade": "정보없음"
|
|
331
|
+
if measurement.get("khaiGrade") in (None, "")
|
|
332
|
+
else grade_to_label(
|
|
333
|
+
measurement.get("khaiGrade"),
|
|
334
|
+
pollutant="pm10",
|
|
335
|
+
value=measurement.get("pm10Value"),
|
|
336
|
+
),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def build_missing_secret_message() -> str:
|
|
341
|
+
return (
|
|
342
|
+
f"이 작업에는 {SECRET_NAME} 환경변수가 필요합니다.\n"
|
|
343
|
+
"환경변수가 설정되어 있지 않으면 ~/.config/k-skill/secrets.env 에 추가하거나\n"
|
|
344
|
+
"에이전트의 secret vault에서 주입해 주세요."
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def get_required_secret() -> str:
|
|
349
|
+
value = os.environ.get(SECRET_NAME)
|
|
350
|
+
if not value or value == "replace-me":
|
|
351
|
+
raise SystemExit(build_missing_secret_message())
|
|
352
|
+
return value
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def get_proxy_base_url() -> str | None:
|
|
356
|
+
value = os.environ.get(PROXY_BASE_URL_NAME)
|
|
357
|
+
if value and value.lower() in {"off", "false", "0", "disable", "disabled", "none"}:
|
|
358
|
+
return None
|
|
359
|
+
if value and value != "replace-me":
|
|
360
|
+
return value.rstrip("/")
|
|
361
|
+
return DEFAULT_PROXY_BASE_URL
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def read_json_response(request: urllib.request.Request | str) -> dict:
|
|
365
|
+
try:
|
|
366
|
+
with urllib.request.urlopen(request, timeout=20) as response:
|
|
367
|
+
return json.load(response)
|
|
368
|
+
except urllib.error.HTTPError as exc:
|
|
369
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
370
|
+
try:
|
|
371
|
+
payload = json.loads(body)
|
|
372
|
+
except json.JSONDecodeError:
|
|
373
|
+
payload = None
|
|
374
|
+
|
|
375
|
+
if exc.code == 503 and isinstance(payload, dict) and payload.get("error") == "upstream_not_configured":
|
|
376
|
+
raise SystemExit(PROXY_KEY_NOT_CONFIGURED_MSG) from exc
|
|
377
|
+
|
|
378
|
+
message = payload.get("message") if isinstance(payload, dict) else None
|
|
379
|
+
if isinstance(payload, dict) and payload.get("error") == "ambiguous_location":
|
|
380
|
+
candidates = payload.get("candidate_stations") or []
|
|
381
|
+
sido_name = payload.get("sido_name")
|
|
382
|
+
detail = [message or "단일 측정소를 확정하지 못했습니다."]
|
|
383
|
+
if sido_name:
|
|
384
|
+
detail.append(f"시도: {sido_name}")
|
|
385
|
+
if candidates:
|
|
386
|
+
detail.append(f"후보 측정소: {', '.join(candidates)}")
|
|
387
|
+
detail.append("위 후보 중 정확한 측정소명으로 --station-name 재조회하세요.")
|
|
388
|
+
raise SystemExit("\n".join(detail)) from exc
|
|
389
|
+
|
|
390
|
+
raise SystemExit(message or f"요청이 실패했습니다: HTTP {exc.code}") from exc
|
|
391
|
+
except urllib.error.URLError as exc:
|
|
392
|
+
raise SystemExit(f"{PROXY_DOWN_MSG} (상세: {exc.reason})") from exc
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def fetch_json(url: str, params: dict[str, object]) -> dict:
|
|
396
|
+
query = urllib.parse.urlencode({key: value for key, value in params.items() if value is not None})
|
|
397
|
+
request_url = f"{url}?{query}"
|
|
398
|
+
return read_json_response(request_url)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def fetch_proxy_report(args: argparse.Namespace) -> dict | None:
|
|
402
|
+
base_url = get_proxy_base_url()
|
|
403
|
+
if not base_url or args.station_file or args.measurement_file:
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
params: dict[str, object] = {}
|
|
407
|
+
if args.lat is not None:
|
|
408
|
+
params["lat"] = args.lat
|
|
409
|
+
if args.lon is not None:
|
|
410
|
+
params["lon"] = args.lon
|
|
411
|
+
if args.region_hint:
|
|
412
|
+
params["regionHint"] = args.region_hint
|
|
413
|
+
if args.station_name:
|
|
414
|
+
params["stationName"] = args.station_name
|
|
415
|
+
|
|
416
|
+
query = urllib.parse.urlencode(params)
|
|
417
|
+
request = urllib.request.Request(f"{base_url}/v1/fine-dust/report?{query}")
|
|
418
|
+
return read_json_response(request)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def fetch_station_lookup(args: argparse.Namespace) -> tuple[dict, str]:
|
|
422
|
+
if args.station_file:
|
|
423
|
+
return load_json_file(args.station_file), "coordinates" if args.lat is not None and args.lon is not None else "fallback"
|
|
424
|
+
|
|
425
|
+
service_key = get_required_secret()
|
|
426
|
+
common = {
|
|
427
|
+
"serviceKey": service_key,
|
|
428
|
+
"returnType": "json",
|
|
429
|
+
"numOfRows": 50,
|
|
430
|
+
"pageNo": 1,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if args.lat is not None and args.lon is not None:
|
|
434
|
+
tm_x, tm_y = wgs84_to_air_korea_tm(args.lat, args.lon)
|
|
435
|
+
nearby_payload = fetch_json(
|
|
436
|
+
f"{STATION_SERVICE_URL}/getNearbyMsrstnList",
|
|
437
|
+
{
|
|
438
|
+
**common,
|
|
439
|
+
"numOfRows": 10,
|
|
440
|
+
"tmX": tm_x,
|
|
441
|
+
"tmY": tm_y,
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
if extract_items(nearby_payload):
|
|
445
|
+
return nearby_payload, "coordinates"
|
|
446
|
+
|
|
447
|
+
if args.region_hint or args.station_name:
|
|
448
|
+
return (
|
|
449
|
+
fetch_json(
|
|
450
|
+
f"{STATION_SERVICE_URL}/getMsrstnList",
|
|
451
|
+
{
|
|
452
|
+
**common,
|
|
453
|
+
"addr": args.region_hint,
|
|
454
|
+
"stationName": args.station_name,
|
|
455
|
+
},
|
|
456
|
+
),
|
|
457
|
+
"fallback",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
raise SystemExit("위도/경도 또는 region fallback 이 필요합니다.")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def fetch_station_payload(args: argparse.Namespace) -> dict:
|
|
464
|
+
payload, _ = fetch_station_lookup(args)
|
|
465
|
+
return payload
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def fetch_measurement_payload(args: argparse.Namespace, station_name: str) -> dict:
|
|
469
|
+
if args.measurement_file:
|
|
470
|
+
return load_json_file(args.measurement_file)
|
|
471
|
+
|
|
472
|
+
service_key = get_required_secret()
|
|
473
|
+
return fetch_json(
|
|
474
|
+
f"{MEASUREMENT_SERVICE_URL}/getMsrstnAcctoRltmMesureDnsty",
|
|
475
|
+
{
|
|
476
|
+
"serviceKey": service_key,
|
|
477
|
+
"returnType": "json",
|
|
478
|
+
"numOfRows": 100,
|
|
479
|
+
"pageNo": 1,
|
|
480
|
+
"stationName": station_name,
|
|
481
|
+
"dataTerm": "DAILY",
|
|
482
|
+
"ver": "1.4",
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def render_text(report: dict) -> str:
|
|
488
|
+
return "\n".join(
|
|
489
|
+
[
|
|
490
|
+
f"측정소: {report['station_name']}",
|
|
491
|
+
f"주소: {report['station_address'] or '-'}",
|
|
492
|
+
f"조회 시각: {report['measured_at']}",
|
|
493
|
+
f"조회 방식: {report['lookup_mode']}",
|
|
494
|
+
f"PM10: {report['pm10']['value']} ({report['pm10']['grade']})",
|
|
495
|
+
f"PM2.5: {report['pm25']['value']} ({report['pm25']['grade']})",
|
|
496
|
+
f"통합대기등급: {report['khai_grade']}",
|
|
497
|
+
],
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def command_report(args: argparse.Namespace) -> None:
|
|
502
|
+
proxy_report = fetch_proxy_report(args)
|
|
503
|
+
if proxy_report is not None:
|
|
504
|
+
if args.json:
|
|
505
|
+
print(json.dumps(proxy_report, ensure_ascii=False, indent=2))
|
|
506
|
+
return
|
|
507
|
+
|
|
508
|
+
print(render_text(proxy_report))
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
station_payload, lookup_mode = fetch_station_lookup(args)
|
|
512
|
+
station_items = extract_items(station_payload)
|
|
513
|
+
station = resolve_station(
|
|
514
|
+
station_items,
|
|
515
|
+
lat=args.lat,
|
|
516
|
+
lon=args.lon,
|
|
517
|
+
region_hint=args.region_hint,
|
|
518
|
+
station_name=args.station_name,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
measurement_payload = fetch_measurement_payload(args, station["stationName"])
|
|
522
|
+
report = build_report(
|
|
523
|
+
station_items=station_items,
|
|
524
|
+
measurement_items=extract_items(measurement_payload),
|
|
525
|
+
lat=args.lat,
|
|
526
|
+
lon=args.lon,
|
|
527
|
+
region_hint=args.region_hint,
|
|
528
|
+
station_name=station["stationName"],
|
|
529
|
+
lookup_mode=lookup_mode,
|
|
530
|
+
selected_station=station,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
if args.json:
|
|
534
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
print(render_text(report))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def main(argv: list[str] | None = None) -> int:
|
|
541
|
+
args = parse_args(argv)
|
|
542
|
+
if args.command == "report":
|
|
543
|
+
command_report(args)
|
|
544
|
+
return 0
|
|
545
|
+
raise SystemExit(f"unsupported command: {args.command}")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
sys.exit(main())
|
|
@@ -5,5 +5,11 @@
|
|
|
5
5
|
"proxy",
|
|
6
6
|
"lookup"
|
|
7
7
|
],
|
|
8
|
-
"frontmatter": "name: fine-dust-location\ndescription: 에어코리아 기반 미세먼지/초미세먼지를 지역명 또는 위치 힌트로 조회한다. 기본 경로는 k-skill-proxy의 report endpoint다.\nlicense: MIT\nmetadata:\n category: utility\n locale: ko-KR\n phase: v1"
|
|
8
|
+
"frontmatter": "name: fine-dust-location\ndescription: 에어코리아 기반 미세먼지/초미세먼지를 지역명 또는 위치 힌트로 조회한다. 기본 경로는 k-skill-proxy의 report endpoint다.\nlicense: MIT\nmetadata:\n category: utility\n locale: ko-KR\n phase: v1",
|
|
9
|
+
"bundle": [
|
|
10
|
+
{
|
|
11
|
+
"from": "scripts/fine_dust.py",
|
|
12
|
+
"to": "scripts/fine_dust.py"
|
|
13
|
+
}
|
|
14
|
+
]
|
|
9
15
|
}
|
|
@@ -167,7 +167,7 @@ else
|
|
|
167
167
|
fi
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
-
repo 전체를 clone받은 경우에는 같은 검증을 `
|
|
170
|
+
repo 전체를 clone받은 경우에는 같은 검증을 `npx -y @nomadamas/k-skill@0 exec k-skill-setup scripts/check-setup.sh --` 로 실행해도 된다.
|
|
171
171
|
|
|
172
172
|
### 3. Offer scheduled update checks
|
|
173
173
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
secrets_file="${1:-$HOME/.config/k-skill/secrets.env}"
|
|
5
|
+
|
|
6
|
+
missing=0
|
|
7
|
+
|
|
8
|
+
if [[ ! -f "$secrets_file" ]]; then
|
|
9
|
+
echo "missing secrets file: $secrets_file"
|
|
10
|
+
missing=1
|
|
11
|
+
else
|
|
12
|
+
perms=$(stat -f '%Lp' "$secrets_file" 2>/dev/null || stat -c '%a' "$secrets_file" 2>/dev/null)
|
|
13
|
+
if [[ "$perms" != "600" ]]; then
|
|
14
|
+
echo "insecure permissions on $secrets_file: $perms (expected 600)"
|
|
15
|
+
missing=1
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if [[ "$missing" -ne 0 ]]; then
|
|
20
|
+
cat <<EOF
|
|
21
|
+
next steps:
|
|
22
|
+
1. create ~/.config/k-skill/secrets.env with your credentials
|
|
23
|
+
2. chmod 0600 ~/.config/k-skill/secrets.env
|
|
24
|
+
3. run this check again
|
|
25
|
+
EOF
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
echo "k-skill setup looks usable"
|
|
@@ -7,5 +7,11 @@
|
|
|
7
7
|
"browser",
|
|
8
8
|
"operations"
|
|
9
9
|
],
|
|
10
|
-
"frontmatter": "name: k-skill-setup\ndescription: After installing the full k-skill bundle, configure and verify the shared cross-platform setup, then optionally wire update checks and GitHub starring with explicit user consent.\nlicense: MIT\nmetadata:\n category: setup\n locale: ko-KR\n phase: v1"
|
|
10
|
+
"frontmatter": "name: k-skill-setup\ndescription: After installing the full k-skill bundle, configure and verify the shared cross-platform setup, then optionally wire update checks and GitHub starring with explicit user consent.\nlicense: MIT\nmetadata:\n category: setup\n locale: ko-KR\n phase: v1",
|
|
11
|
+
"bundle": [
|
|
12
|
+
{
|
|
13
|
+
"from": "scripts/check-setup.sh",
|
|
14
|
+
"to": "scripts/check-setup.sh"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
11
17
|
}
|