@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.
- package/bin/k-skill.js +35 -5
- package/package.json +2 -2
- package/skills/assembly-bill-vote-search/skill.json +0 -1
- package/skills/biz-health-check/instruction.md +2 -2
- package/skills/biz-health-check/skill.json +0 -1
- package/skills/bok-ecos-stats/instruction.md +8 -8
- package/skills/bok-ecos-stats/skill.json +0 -1
- package/skills/building-register-search/instruction.md +6 -6
- package/skills/building-register-search/scripts/building_register.py +0 -0
- package/skills/cheap-gas-nearby/skill.json +0 -1
- package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +0 -0
- package/skills/corporate-registration-consulting/skill.json +3 -1
- package/skills/coupang-product-search/instruction.md +10 -10
- package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +0 -0
- package/skills/court-auction-notice-search/skill.json +2 -1
- package/skills/court-payment-order-assistant/skill.json +1 -1
- package/skills/d2b-notice-search/skill.json +2 -1
- package/skills/daangn-cars-search/instruction.md +3 -3
- package/skills/daangn-cars-search/scripts/daangn_cars.py +0 -0
- package/skills/daangn-jobs-search/instruction.md +2 -2
- package/skills/daangn-jobs-search/scripts/daangn_jobs.py +0 -0
- package/skills/daangn-jobs-search/skill.json +3 -1
- package/skills/daangn-realty-search/instruction.md +1 -1
- package/skills/daangn-realty-search/scripts/daangn_realty.py +0 -0
- package/skills/daangn-used-goods-search/instruction.md +2 -2
- package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +0 -0
- package/skills/daishin-report-search/skill.json +0 -1
- package/skills/danawa-price-search/instruction.md +4 -4
- package/skills/danawa-price-search/scripts/danawa_search.py +0 -0
- package/skills/ev-charger-nearby/instruction.md +5 -5
- package/skills/ev-charger-nearby/skill.json +1 -2
- package/skills/express-bus-booking/instruction.md +2 -2
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +0 -0
- package/skills/express-bus-booking/skill.json +2 -0
- 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/flight-ticket-search/instruction.md +7 -7
- package/skills/foresttrip-vacancy/instruction.md +7 -7
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +0 -0
- package/skills/fsc-corporate-info/instruction.md +1 -1
- package/skills/fsc-corporate-info/skill.json +0 -1
- package/skills/g2b-order-plan-search/instruction.md +2 -2
- package/skills/g2b-order-plan-search/skill.json +2 -1
- package/skills/g2b-sanctioned-supplier/instruction.md +1 -1
- package/skills/g2b-sanctioned-supplier/skill.json +0 -1
- package/skills/geeknews-search/instruction.md +3 -3
- package/skills/geeknews-search/scripts/geeknews_search.py +0 -0
- package/skills/gov-overseas-trip-report/instruction.md +12 -12
- package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +0 -0
- package/skills/han-river-water-level/skill.json +0 -1
- package/skills/highway-traffic-status/instruction.md +2 -2
- package/skills/highway-traffic-status/skill.json +0 -1
- package/skills/household-waste-info/skill.json +0 -1
- package/skills/intercity-bus-booking/instruction.md +3 -3
- package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +0 -0
- package/skills/intercity-bus-booking/skill.json +2 -0
- package/skills/iros-registry-automation/instruction.md +2 -2
- package/skills/iros-registry-automation/skill.json +2 -1
- package/skills/job-posting-match/instruction.md +2 -2
- package/skills/job-posting-match/skill.json +1 -1
- package/skills/jobkorea-talent-search/instruction.md +1 -1
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +0 -0
- package/skills/jobkorea-talent-search/skill.json +2 -1
- package/skills/joseon-sillok-search/instruction.md +6 -6
- package/skills/k-skill-cleaner/instruction.md +2 -2
- package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +0 -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 +8 -2
- package/skills/kakaotalk-mac/skill.json +1 -1
- package/skills/keris-academic-search/instruction.md +4 -4
- package/skills/korea-weather/skill.json +0 -1
- package/skills/korean-character-count/instruction.md +8 -8
- package/skills/korean-heritage-search/instruction.md +5 -5
- package/skills/korean-humanizer/instruction.md +1 -1
- package/skills/korean-jangbu-for/instruction.md +3 -3
- package/skills/korean-jangbu-for/scripts/install.sh +0 -0
- package/skills/korean-middle-korean/instruction.md +6 -6
- package/skills/korean-middle-korean/scripts/korean_middle_korean.js +0 -0
- package/skills/korean-patent-search/instruction.md +4 -4
- package/skills/korean-privacy-terms/instruction.md +4 -4
- package/skills/korean-privacy-terms/scripts/install.sh +0 -0
- package/skills/korean-scholarship-search/instruction.md +10 -10
- package/skills/korean-slang-writing/instruction.md +8 -8
- package/skills/korean-spell-check/instruction.md +3 -3
- package/skills/korean-spell-check/skill.json +0 -1
- package/skills/korean-stock-search/skill.json +0 -1
- package/skills/korean-transit-route/skill.json +0 -1
- package/skills/kosis-stats/instruction.md +13 -26
- package/skills/kr-whois-lookup/skill.json +1 -2
- package/skills/kstartup-search/instruction.md +8 -8
- 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/lck-analytics/instruction.md +4 -4
- package/skills/lck-analytics/scripts/_lib.js +0 -0
- package/skills/lck-analytics/scripts/analyze-live-game.js +0 -0
- package/skills/lck-analytics/scripts/build-match-report.js +0 -0
- package/skills/lck-analytics/scripts/sync-oracle.js +0 -0
- package/skills/localdata-business-status/instruction.md +2 -2
- package/skills/mfds-drug-safety/instruction.md +3 -3
- package/skills/mfds-drug-safety/skill.json +0 -1
- package/skills/mfds-food-safety/instruction.md +6 -6
- package/skills/mfds-food-safety/skill.json +0 -1
- package/skills/myrealtrip-search/instruction.md +11 -11
- package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +0 -0
- package/skills/national-pension-workplace/instruction.md +1 -1
- package/skills/national-pension-workplace/skill.json +0 -1
- package/skills/naver-ad-performance/instruction.md +7 -7
- package/skills/naver-blog-research/instruction.md +4 -4
- package/skills/naver-news-search/skill.json +0 -1
- package/skills/nhis-care-checkup-search/skill.json +1 -2
- package/skills/nts-business-registration/instruction.md +3 -3
- package/skills/nts-tax-delinquency/instruction.md +1 -1
- package/skills/ohou-today-deal/instruction.md +4 -4
- package/skills/ohou-today-deal/scripts/ohou_today_deal.py +0 -0
- package/skills/popbill/instruction.md +9 -9
- package/skills/real-estate-search/skill.json +0 -1
- package/skills/s2b-notice-search/skill.json +2 -1
- package/skills/saramin-talent-search/skill.json +2 -1
- package/skills/seoul-bike/instruction.md +3 -3
- package/skills/seoul-bike/scripts/seoul_bike.py +0 -0
- package/skills/seoul-bike/skill.json +0 -1
- package/skills/seoul-density/instruction.md +4 -4
- package/skills/seoul-density/skill.json +0 -1
- package/skills/srt-booking/instruction.md +4 -4
- package/skills/subway-lost-property/instruction.md +2 -2
- package/skills/subway-lost-property/scripts/subway_lost_property.py +0 -0
- package/skills/subway-lost-property/skill.json +1 -1
- package/skills/ticket-availability/instruction.md +3 -3
- package/skills/ticket-availability/skill.json +2 -1
- package/skills/yebigun-training/skill.json +2 -1
- package/skills/zipcode-search/instruction.md +2 -2
- package/skills/zipcode-search/scripts/zipcode_search.py +0 -0
- package/src/assemble.js +148 -4
- package/src/execute.js +81 -0
- package/templates/action-recruiting.md +5 -0
- package/templates/legal.md +6 -0
- package/templates/operations.md +3 -0
- package/templates/action-communication.md +0 -5
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- `
|
|
158
|
-
- `
|
|
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` 같은 비민감 요약만 남기고 계정 정보, 세션 쿠키, 개인 조회 세부 내역은 공유하지 않는다.
|
|
File without changes
|