@nomadamas/k-skill 0.1.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.
Files changed (142) hide show
  1. package/bin/k-skill.js +35 -5
  2. package/package.json +2 -2
  3. package/skills/assembly-bill-vote-search/skill.json +0 -1
  4. package/skills/biz-health-check/instruction.md +2 -2
  5. package/skills/biz-health-check/skill.json +0 -1
  6. package/skills/bok-ecos-stats/instruction.md +8 -8
  7. package/skills/bok-ecos-stats/skill.json +0 -1
  8. package/skills/building-register-search/instruction.md +6 -6
  9. package/skills/building-register-search/scripts/building_register.py +0 -0
  10. package/skills/cheap-gas-nearby/skill.json +0 -1
  11. package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +0 -0
  12. package/skills/corporate-registration-consulting/skill.json +3 -1
  13. package/skills/coupang-product-search/instruction.md +10 -10
  14. package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +0 -0
  15. package/skills/court-auction-notice-search/skill.json +2 -1
  16. package/skills/court-payment-order-assistant/skill.json +1 -1
  17. package/skills/d2b-notice-search/skill.json +2 -1
  18. package/skills/daangn-cars-search/instruction.md +3 -3
  19. package/skills/daangn-cars-search/scripts/daangn_cars.py +0 -0
  20. package/skills/daangn-jobs-search/instruction.md +2 -2
  21. package/skills/daangn-jobs-search/scripts/daangn_jobs.py +0 -0
  22. package/skills/daangn-jobs-search/skill.json +3 -1
  23. package/skills/daangn-realty-search/instruction.md +1 -1
  24. package/skills/daangn-realty-search/scripts/daangn_realty.py +0 -0
  25. package/skills/daangn-used-goods-search/instruction.md +2 -2
  26. package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +0 -0
  27. package/skills/daishin-report-search/skill.json +0 -1
  28. package/skills/danawa-price-search/instruction.md +4 -4
  29. package/skills/danawa-price-search/scripts/danawa_search.py +0 -0
  30. package/skills/ev-charger-nearby/instruction.md +5 -5
  31. package/skills/ev-charger-nearby/skill.json +1 -2
  32. package/skills/express-bus-booking/instruction.md +2 -2
  33. package/skills/express-bus-booking/scripts/kobus_express_booking.py +0 -0
  34. package/skills/express-bus-booking/skill.json +2 -0
  35. package/skills/fine-dust-location/instruction.md +1 -1
  36. package/skills/fine-dust-location/scripts/fine_dust.py +549 -0
  37. package/skills/fine-dust-location/skill.json +7 -1
  38. package/skills/flight-ticket-search/instruction.md +7 -7
  39. package/skills/foresttrip-vacancy/instruction.md +7 -7
  40. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +0 -0
  41. package/skills/fsc-corporate-info/instruction.md +1 -1
  42. package/skills/fsc-corporate-info/skill.json +0 -1
  43. package/skills/g2b-order-plan-search/instruction.md +2 -2
  44. package/skills/g2b-order-plan-search/skill.json +2 -1
  45. package/skills/g2b-sanctioned-supplier/instruction.md +1 -1
  46. package/skills/g2b-sanctioned-supplier/skill.json +0 -1
  47. package/skills/geeknews-search/instruction.md +3 -3
  48. package/skills/geeknews-search/scripts/geeknews_search.py +0 -0
  49. package/skills/gov-overseas-trip-report/instruction.md +12 -12
  50. package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +0 -0
  51. package/skills/han-river-water-level/skill.json +0 -1
  52. package/skills/highway-traffic-status/instruction.md +2 -2
  53. package/skills/highway-traffic-status/skill.json +0 -1
  54. package/skills/household-waste-info/skill.json +0 -1
  55. package/skills/intercity-bus-booking/instruction.md +3 -3
  56. package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +0 -0
  57. package/skills/intercity-bus-booking/skill.json +2 -0
  58. package/skills/iros-registry-automation/instruction.md +2 -2
  59. package/skills/iros-registry-automation/skill.json +2 -1
  60. package/skills/job-posting-match/instruction.md +2 -2
  61. package/skills/job-posting-match/skill.json +1 -1
  62. package/skills/jobkorea-talent-search/instruction.md +1 -1
  63. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +0 -0
  64. package/skills/jobkorea-talent-search/skill.json +2 -1
  65. package/skills/joseon-sillok-search/instruction.md +6 -6
  66. package/skills/k-skill-cleaner/instruction.md +2 -2
  67. package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +0 -0
  68. package/skills/k-skill-setup/instruction.md +1 -1
  69. package/skills/k-skill-setup/scripts/check-setup.sh +29 -0
  70. package/skills/k-skill-setup/skill.json +8 -2
  71. package/skills/kakaotalk-mac/skill.json +1 -1
  72. package/skills/keris-academic-search/instruction.md +4 -4
  73. package/skills/korea-weather/skill.json +0 -1
  74. package/skills/korean-character-count/instruction.md +8 -8
  75. package/skills/korean-heritage-search/instruction.md +5 -5
  76. package/skills/korean-humanizer/instruction.md +1 -1
  77. package/skills/korean-jangbu-for/instruction.md +3 -3
  78. package/skills/korean-jangbu-for/scripts/install.sh +0 -0
  79. package/skills/korean-middle-korean/instruction.md +6 -6
  80. package/skills/korean-middle-korean/scripts/korean_middle_korean.js +0 -0
  81. package/skills/korean-patent-search/instruction.md +4 -4
  82. package/skills/korean-privacy-terms/instruction.md +4 -4
  83. package/skills/korean-privacy-terms/scripts/install.sh +0 -0
  84. package/skills/korean-scholarship-search/instruction.md +10 -10
  85. package/skills/korean-slang-writing/instruction.md +8 -8
  86. package/skills/korean-spell-check/instruction.md +3 -3
  87. package/skills/korean-spell-check/skill.json +0 -1
  88. package/skills/korean-stock-search/skill.json +0 -1
  89. package/skills/korean-transit-route/skill.json +0 -1
  90. package/skills/kosis-stats/instruction.md +13 -26
  91. package/skills/kr-whois-lookup/skill.json +1 -2
  92. package/skills/kstartup-search/instruction.md +8 -8
  93. package/skills/ktx-booking/instruction.md +15 -15
  94. package/skills/ktx-booking/scripts/ktx_booking.py +1316 -0
  95. package/skills/ktx-booking/skill.json +7 -1
  96. package/skills/lck-analytics/instruction.md +4 -4
  97. package/skills/lck-analytics/scripts/_lib.js +0 -0
  98. package/skills/lck-analytics/scripts/analyze-live-game.js +0 -0
  99. package/skills/lck-analytics/scripts/build-match-report.js +0 -0
  100. package/skills/lck-analytics/scripts/sync-oracle.js +0 -0
  101. package/skills/localdata-business-status/instruction.md +2 -2
  102. package/skills/mfds-drug-safety/instruction.md +3 -3
  103. package/skills/mfds-drug-safety/skill.json +0 -1
  104. package/skills/mfds-food-safety/instruction.md +6 -6
  105. package/skills/mfds-food-safety/skill.json +0 -1
  106. package/skills/myrealtrip-search/instruction.md +11 -11
  107. package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +0 -0
  108. package/skills/national-pension-workplace/instruction.md +1 -1
  109. package/skills/national-pension-workplace/skill.json +0 -1
  110. package/skills/naver-ad-performance/instruction.md +7 -7
  111. package/skills/naver-blog-research/instruction.md +4 -4
  112. package/skills/naver-news-search/skill.json +0 -1
  113. package/skills/nhis-care-checkup-search/skill.json +1 -2
  114. package/skills/nts-business-registration/instruction.md +3 -3
  115. package/skills/nts-tax-delinquency/instruction.md +1 -1
  116. package/skills/ohou-today-deal/instruction.md +4 -4
  117. package/skills/ohou-today-deal/scripts/ohou_today_deal.py +0 -0
  118. package/skills/popbill/instruction.md +9 -9
  119. package/skills/real-estate-search/skill.json +0 -1
  120. package/skills/s2b-notice-search/skill.json +2 -1
  121. package/skills/saramin-talent-search/skill.json +2 -1
  122. package/skills/seoul-bike/instruction.md +3 -3
  123. package/skills/seoul-bike/scripts/seoul_bike.py +0 -0
  124. package/skills/seoul-bike/skill.json +0 -1
  125. package/skills/seoul-density/instruction.md +4 -4
  126. package/skills/seoul-density/skill.json +0 -1
  127. package/skills/srt-booking/instruction.md +4 -4
  128. package/skills/subway-lost-property/instruction.md +2 -2
  129. package/skills/subway-lost-property/scripts/subway_lost_property.py +0 -0
  130. package/skills/subway-lost-property/skill.json +1 -1
  131. package/skills/ticket-availability/instruction.md +3 -3
  132. package/skills/ticket-availability/skill.json +2 -1
  133. package/skills/yebigun-training/skill.json +2 -1
  134. package/skills/zipcode-search/instruction.md +2 -2
  135. package/skills/zipcode-search/scripts/zipcode_search.py +0 -0
  136. package/src/assemble.js +148 -4
  137. package/src/execute.js +81 -0
  138. package/templates/action-recruiting.md +5 -0
  139. package/templates/legal.md +6 -0
  140. package/templates/operations.md +3 -0
  141. package/templates/action-communication.md +0 -5
  142. package/templates/hard-boundary.md +0 -3
@@ -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
  }
@@ -57,7 +57,7 @@
57
57
  이 skill은 저장소 내 helper를 직접 실행한다.
58
58
 
59
59
  ```bash
60
- python3 flight-ticket-search/scripts/flight_ticket_search.py --help
60
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- --help
61
61
  ```
62
62
 
63
63
  최초 실행 시 `~/.cache/k-skill/flight-ticket-search/venv`에 `fast-flights==2.2`를 설치하고 그 venv로 재실행한다. 저장소에는 의존성 vendoring이나 API key를 넣지 않는다.
@@ -67,7 +67,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py --help
67
67
  편도:
68
68
 
69
69
  ```bash
70
- python3 flight-ticket-search/scripts/flight_ticket_search.py search \
70
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- search \
71
71
  --from ICN \
72
72
  --to NRT \
73
73
  --date 2026-06-01 \
@@ -80,7 +80,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py search \
80
80
  왕복:
81
81
 
82
82
  ```bash
83
- python3 flight-ticket-search/scripts/flight_ticket_search.py search \
83
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- search \
84
84
  --from ICN \
85
85
  --to NRT \
86
86
  --date 2026-06-01 \
@@ -105,7 +105,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py search \
105
105
  빠른 기본값은 주 1회 샘플링이다.
106
106
 
107
107
  ```bash
108
- python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
108
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- compare-month \
109
109
  --from ICN \
110
110
  --to NRT \
111
111
  --month 2026-06 \
@@ -116,7 +116,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
116
116
  일별 전체 조회가 필요하면 `--sample daily`를 쓴다. 다만 28~31회 요청이 발생하므로 rate limit을 위해 `--sleep`을 1.5초 이상 유지한다.
117
117
 
118
118
  ```bash
119
- python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
119
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- compare-month \
120
120
  --from ICN \
121
121
  --to NRT \
122
122
  --month 2026-06 \
@@ -138,7 +138,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py compare-month \
138
138
  사용자가 "다음주부터 2주간", "6월 1일부터 20일까지"처럼 범위를 주면 날짜 범위 비교를 사용한다.
139
139
 
140
140
  ```bash
141
- python3 flight-ticket-search/scripts/flight_ticket_search.py compare-range \
141
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- compare-range \
142
142
  --from ICN \
143
143
  --to BKK \
144
144
  --start-date 2026-06-01 \
@@ -154,7 +154,7 @@ python3 flight-ticket-search/scripts/flight_ticket_search.py compare-range \
154
154
  연도 비교는 같은 월일을 여러 연도에 대해 조회한다.
155
155
 
156
156
  ```bash
157
- python3 flight-ticket-search/scripts/flight_ticket_search.py compare-years \
157
+ npx -y @nomadamas/k-skill@0 exec flight-ticket-search scripts/flight_ticket_search.py -- compare-years \
158
158
  --from ICN \
159
159
  --to NRT \
160
160
  --years 2026,2027 \
@@ -28,7 +28,7 @@ generic runtime에서 helper는 조회 전용이다. 돌쇠에서는 조회 결
28
28
  ```bash
29
29
  python3 -m pip install playwright
30
30
  python3 -m playwright install chromium
31
- python3 scripts/run_foresttrip_vacancy.py --check-deps
31
+ npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --check-deps
32
32
  ```
33
33
 
34
34
  ## Required environment variables
@@ -91,25 +91,25 @@ API는 `srchDate` 단일 일자만 요청해도 응답에 5일 윈도우를 포
91
91
  전체 자연휴양림에서 특정 날짜 조회:
92
92
 
93
93
  ```bash
94
- python3 scripts/run_foresttrip_vacancy.py --all --text --dates 20260504
94
+ npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --all --text --dates 20260504
95
95
  ```
96
96
 
97
97
  JSON 출력:
98
98
 
99
99
  ```bash
100
- python3 scripts/run_foresttrip_vacancy.py --all --json --dates 20260504
100
+ npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --all --json --dates 20260504
101
101
  ```
102
102
 
103
103
  캠핑/야영만 조회:
104
104
 
105
105
  ```bash
106
- python3 scripts/run_foresttrip_vacancy.py --all --text --dates 20260504 --categories 02
106
+ npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --all --text --dates 20260504 --categories 02
107
107
  ```
108
108
 
109
109
  특정 휴양림명으로 조회:
110
110
 
111
111
  ```bash
112
- python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates 20260504
112
+ npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --forest-name 유명산 --text --dates 20260504
113
113
  ```
114
114
 
115
115
  ### 4. Summarize results conservatively
@@ -154,8 +154,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
154
154
 
155
155
  - `./scripts/validate-skills.sh`
156
156
  - `python3 -m py_compile foresttrip-vacancy/scripts/run_foresttrip_vacancy.py`
157
- - `python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --help`
158
- - `python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --check-deps`
157
+ - `npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --help`
158
+ - `npx -y @nomadamas/k-skill@0 exec foresttrip-vacancy scripts/run_foresttrip_vacancy.py -- --check-deps`
159
159
  - `npm run ci`
160
160
 
161
161
  실제 live smoke는 기여자 또는 이미 숲나들e 계정을 가진 사용자가 선택적으로 수행한다. PR에는 `forests_scanned`, `fetch_failures`, `filter_hits` 같은 비민감 요약만 남기고 계정 정보, 세션 쿠키, 개인 조회 세부 내역은 공유하지 않는다.
@@ -39,7 +39,7 @@
39
39
  ## CLI examples
40
40
 
41
41
  ```bash
42
- python3 fsc-corporate-info/scripts/fsc_corporate_info.py \
42
+ npx -y @nomadamas/k-skill@0 exec fsc-corporate-info scripts/fsc_corporate_info.py -- \
43
43
  --name "삼성전자" --b-no 124-81-00998
44
44
  ```
45
45