@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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
- python3 scripts/fine_dust.py report --region-hint '서울 강남구' --json
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) 웹 검색 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
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
- - search list: `https://www.gangnamunni.com/search?q=<keyword>`
46
- - parsed payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.hospitals...</script>`
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로 비로그인 검색 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__`의 `props.pageProps.hospitals` 배열에 포함된다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
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/search?q=<keyword>`의 `__NEXT_DATA__` payload를 파싱한다.
81
- 2. payload 없으면 로그인벽, CAPTCHA, 차단, shell 페이지를 실패 모드로 분류한다.
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
  - 앱 전용/로그인 전용 정보는 비로그인 공개 조회만으로 확정할 수 없다.