@nomadamas/k-skill 0.2.0 → 0.2.2
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/gangnamunni-clinic-search/instruction.md +16 -8
- package/skills/hankookilbo-news/instruction.md +143 -0
- package/skills/hankookilbo-news/skill.json +8 -0
- 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/kosis-stats/instruction.md +24 -3
- package/skills/kosis-stats/references/kosis-openapi-guide.md +5 -1
- package/skills/kosis-stats/scripts/run_kosis_stats.py +1 -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/skills/naver-blog-research/scripts/naver_search.py +6 -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
|
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## What this skill does
|
|
4
4
|
|
|
5
|
-
강남언니(Gangnam Unni) 웹
|
|
5
|
+
강남언니(Gangnam Unni) 웹 병원 목록 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
|
|
6
6
|
|
|
7
7
|
- 키워드로 병원 후보를 검색한다.
|
|
8
|
-
- 공개 검색 결과에 포함된 평점,
|
|
8
|
+
- 공개 검색 결과에 포함된 평점, 리뷰 수, 지역/역, 거리, 공개 이미지, 병원 링크를 정리한다.
|
|
9
9
|
- 예약, 상담, 결제, 리뷰 작성, 앱 로그인 등 사용자 계정이 필요한 액션은 하지 않는다.
|
|
10
10
|
|
|
11
11
|
## When to use
|
|
@@ -42,11 +42,13 @@
|
|
|
42
42
|
|
|
43
43
|
## Public Gangnam Unni surface
|
|
44
44
|
|
|
45
|
-
-
|
|
46
|
-
-
|
|
45
|
+
- primary hospital list: `https://www.gangnamunni.com/hospitals?q=<keyword>`
|
|
46
|
+
- primary payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.dehydratedState.queries[*].state.data.pages[*].data...</script>`
|
|
47
|
+
- query selector: `queryKey[0] === "infinite-search-hospitals"`
|
|
48
|
+
- same-payload legacy field fallback: 이전 `/search?q=<keyword>` HTML에서 쓰던 `props.pageProps.hospitals`
|
|
47
49
|
- public hospital URL: `https://www.gangnamunni.com/hospitals/<id>`
|
|
48
50
|
|
|
49
|
-
Discovery result: `curl`/Node fetch로 비로그인
|
|
51
|
+
Discovery result: `curl`/Node fetch로 `/hospitals?q=` 비로그인 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__`의 react-query `dehydratedState`에 포함된다. 파서가 이전 `/search?q=` HTML의 `pageProps.hospitals`도 읽지만, `searchClinics`가 `/search`를 두 번째로 요청하지는 않는다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
|
|
50
52
|
|
|
51
53
|
## Workflow
|
|
52
54
|
|
|
@@ -71,15 +73,20 @@ npx gangnamunni-clinic-search "강남 성형외과" --limit 5
|
|
|
71
73
|
|
|
72
74
|
- `name`: 병원명
|
|
73
75
|
- `rating`, `ratingCount`, `reviewCount`: 공개 검색 페이지에 포함된 평점/리뷰 지표
|
|
76
|
+
- `integratedReviewCount`, `eventCount`: 통합 리뷰 수와 공개 이벤트 수
|
|
77
|
+
- `district`, `subwayStation`, `distanceWithUnit`: 공개 지역/역/거리 정보. upstream이 값을 주지 않으면 필드가 생략될 수 있다.
|
|
78
|
+
- `latitude`, `longitude`: upstream 병원 객체에 공개 좌표가 있을 때만 숫자로 반환한다. 현재 `/hospitals?q=` 응답에는 없을 수 있다.
|
|
74
79
|
- `languages`: 공개 지원 언어
|
|
75
80
|
- `url`: 강남언니 공개 병원 페이지
|
|
76
81
|
- `profileImage`, `mainImage`: 공개 이미지 URL
|
|
77
82
|
|
|
78
83
|
### 3. Fallback order
|
|
79
84
|
|
|
80
|
-
1. 기본: `https://www.gangnamunni.com/
|
|
81
|
-
2. payload
|
|
82
|
-
3.
|
|
85
|
+
1. 기본: `https://www.gangnamunni.com/hospitals?q=<keyword>`의 `__NEXT_DATA__`에서 `infinite-search-hospitals` query의 모든 `pages[*].data`를 합친다.
|
|
86
|
+
2. 입력 payload 자체에 이전 구조의 `props.pageProps.hospitals`가 있으면 같은 payload 안에서 fallback으로 읽는다. `/search`를 추가 요청하지 않는다.
|
|
87
|
+
3. payload가 없으면 로그인벽, CAPTCHA, 차단, 빈 HTML shell을 실패 모드로 분류한다.
|
|
88
|
+
4. `pageProps.totalLength` 또는 dehydrated page의 `recordsTotal`이 0보다 큰데 파싱 가능한 병원이 하나도 없으면 `failureMode: "empty-shell"`과 구조 변경 경고를 반환한다.
|
|
89
|
+
5. 검색 결과가 너무 적거나 앱 전용 정보가 필요하면 자동화를 멈추고 사용자가 공식 앱/웹에서 직접 확인하도록 안내한다.
|
|
83
90
|
|
|
84
91
|
### 4. Respond safely
|
|
85
92
|
|
|
@@ -101,6 +108,7 @@ npx gangnamunni-clinic-search "강남 성형외과" --limit 5
|
|
|
101
108
|
|
|
102
109
|
- 검색어가 너무 넓거나 강남언니가 병원 후보를 공개 payload에 일부만 넣을 수 있다.
|
|
103
110
|
- 강남언니 웹 구조가 바뀌면 `__NEXT_DATA__` 경로가 깨질 수 있다.
|
|
111
|
+
- 사이트가 결과 수를 보고하지만 병원 항목을 파싱할 수 없으면 `empty-shell` 실패로 보고한다.
|
|
104
112
|
- 로그인 필요, CAPTCHA, 접근 차단, 빈 HTML shell은 자동 우회하지 않고 실패로 보고한다.
|
|
105
113
|
- 평점, 리뷰 수, 노출 순서는 시점에 따라 달라진다.
|
|
106
114
|
- 앱 전용/로그인 전용 정보는 비로그인 공개 조회만으로 확정할 수 없다.
|