references/admin-dong-place-map.json
{"mapping_version":"kma_admin_dong_grid_20260325","source":"ASAC-DBT/domains/traffic_weather/seeds/weather/weather_place_grid_mapping.csv","generated_at":"2026-08-09","locations":[{"admin_dong":"청운효자동","gu":"종로구","place_id":"seoul_admd_1111051500"},{"admin_dong":"사직동","gu":"종로구","place_id":"seoul_admd_1111053000"},{"admin_dong":"삼청동","gu":"종로구","place_id":"seoul_admd_1111054000"},{"admin_dong":"부암동","gu":"종로구","place_id":"seoul_admd_1111055000"},{"admin_dong":"평창동","gu":"종로구","place_id":"seoul_admd_1111056000"},{"admin_dong":"무악동","gu":"종로구","place_id":"seoul_admd_1111057000"},{"admin_dong":"교남동","gu":"종로구","place_id":"seoul_admd_1111058000"},{"admin_dong":"가회동","gu":"종로구","place_id":"seoul_admd_1111060000"},{"admin_dong":"종로1.2.3.4가동","gu":"종로구","place_id":"seoul_admd_1111061500"},{"admin_dong":"종로5.6가동","gu":"종로구","place_id":"seoul_admd_1111063000"},{"admin_dong":"이화동","gu":"종로구","place_id":"seoul_admd_1111064000"},{"admin_dong":"혜화동","gu":"종로구","place_id":"seoul_admd_1111065000"},{"admin_dong":"창신제1동","gu":"종로구","place_id":"seoul_admd_1111067000"},{"admin_dong":"창신제2동","gu":"종로구","place_id":"seoul_admd_1111068000"},{"admin_dong":"창신제3동","gu":"종로구","place_id":"seoul_admd_1111069000"},{"admin_dong":"숭인제1동","gu":"종로구","place_id":"seoul_admd_1111070000"},{"admin_dong":"숭인제2동","gu":"종로구","place_id":"seoul_admd_1111071000"},{"admin_dong":"소공동","gu":"중구","place_id":"seoul_admd_1114052000"},{"admin_dong":"회현동","gu":"중구","place_id":"seoul_admd_1114054000"},{"admin_dong":"명동","gu":"중구","place_id":"seoul_admd_1114055000"},{"admin_dong":"필동","gu":"중구","place_id":"seoul_admd_1114057000"},{"admin_dong":"장충동","gu":"중구","place_id":"seoul_admd_1114058000"},{"admin_dong":"광희동","gu":"중구","place_id":"seoul_admd_1114059000"},{"admin_dong":"을지로동","gu":"중구","place_id":"seoul_admd_1114060500"},{"admin_dong":"신당동","gu":"중구","place_id":"seoul_admd_1114061500"},{"admin_dong":"다산동","gu":"중구","place_id":"seoul_admd_1114062500"},{"admin_dong":"약수동","gu":"중구","place_id":"seoul_admd_1114063500"},{"admin_dong":"청구동","gu":"중구","place_id":"seoul_admd_1114064500"},{"admin_dong":"신당제5동","gu":"중구","place_id":"seoul_admd_1114065000"},{"admin_dong":"동화동","gu":"중구","place_id":"seoul_admd_1114066500"},{"admin_dong":"황학동","gu":"중구","place_id":"seoul_admd_1114067000"},{"admin_dong":"중림동","gu":"중구","place_id":"seoul_admd_1114068000"},{"admin_dong":"후암동","gu":"용산구","place_id":"seoul_admd_1117051000"},{"admin_dong":"용산2가동","gu":"용산구","place_id":"seoul_admd_1117052000"},{"admin_dong":"남영동","gu":"용산구","place_id":"seoul_admd_1117053000"},{"admin_dong":"청파동","gu":"용산구","place_id":"seoul_admd_1117055500"},{"admin_dong":"원효로제1동","gu":"용산구","place_id":"seoul_admd_1117056000"},{"admin_dong":"원효로제2동","gu":"용산구","place_id":"seoul_admd_1117057000"},{"admin_dong":"효창동","gu":"용산구","place_id":"seoul_admd_1117058000"},{"admin_dong":"용문동","gu":"용산구","place_id":"seoul_admd_1117059000"},{"admin_dong":"한강로동","gu":"용산구","place_id":"seoul_admd_1117062500"},{"admin_dong":"이촌제1동","gu":"용산구","place_id":"seoul_admd_1117063000"},{"admin_dong":"이촌제2동","gu":"용산구","place_id":"seoul_admd_1117064000"},{"admin_dong":"이태원제1동","gu":"용산구","place_id":"seoul_admd_1117065000"},{"admin_dong":"이태원제2동","gu":"용산구","place_id":"seoul_admd_1117066000"},{"admin_dong":"한남동","gu":"용산구","place_id":"seoul_admd_1117068500"},{"admin_dong":"서빙고동","gu":"용산구","place_id":"seoul_admd_1117069000"},{"admin_dong":"보광동","gu":"용산구","place_id":"seoul_admd_1117070000"},{"admin_dong":"왕십리제2동","gu":"성동구","place_id":"seoul_admd_1120052000"},{"admin_dong":"왕십리도선동","gu":"성동구","place_id":"seoul_admd_1120053500"},{"admin_dong":"마장동","gu":"성동구","place_id":"seoul_admd_1120054000"},{"admin_dong":"사근동","gu":"성동구","place_id":"seoul_admd_1120055000"},{"admin_dong":"행당제1동","gu":"성동구","place_id":"seoul_admd_1120056000"},{"admin_dong":"행당제2동","gu":"성동구","place_id":"seoul_admd_1120057000"},{"admin_dong":"응봉동","gu":"성동구","place_id":"seoul_admd_1120058000"},{"admin_dong":"금호1가동","gu":"성동구","place_id":"seoul_admd_1120059000"},{"admin_dong":"금호2.3가동","gu":"성동구","place_id":"seoul_admd_1120061500"},{"admin_dong":"금호4가동","gu":"성동구","place_id":"seoul_admd_1120062000"},{"admin_dong":"옥수동","gu":"성동구","place_id":"seoul_admd_1120064500"},{"admin_dong":"성수1가제1동","gu":"성동구","place_id":"seoul_admd_1120065000"},{"admin_dong":"성수1가제2동","gu":"성동구","place_id":"seoul_admd_1120066000"},{"admin_dong":"성수2가제1동","gu":"성동구","place_id":"seoul_admd_1120067000"},{"admin_dong":"성수2가제3동","gu":"성동구","place_id":"seoul_admd_1120069000"},{"admin_dong":"송정동","gu":"성동구","place_id":"seoul_admd_1120072000"},{"admin_dong":"용답동","gu":"성동구","place_id":"seoul_admd_1120079000"},{"admin_dong":"화양동","gu":"광진구","place_id":"seoul_admd_1121571000"},{"admin_dong":"군자동","gu":"광진구","place_id":"seoul_admd_1121573000"},{"admin_dong":"중곡제1동","gu":"광진구","place_id":"seoul_admd_1121574000"},{"admin_dong":"중곡제2동","gu":"광진구","place_id":"seoul_admd_1121575000"},{"admin_dong":"중곡제3동","gu":"광진구","place_id":"seoul_admd_1121576000"},{"admin_dong":"중곡제4동","gu":"광진구","place_id":"seoul_admd_1121577000"},{"admin_dong":"능동","gu":"광진구","place_id":"seoul_admd_1121578000"},{"admin_dong":"광장동","gu":"광진구","place_id":"seoul_admd_1121581000"},{"admin_dong":"자양제1동","gu":"광진구","place_id":"seoul_admd_1121582000"},{"admin_dong":"자양제2동","gu":"광진구","place_id":"seoul_admd_1121583000"},{"admin_dong":"자양제3동","gu":"광진구","place_id":"seoul_admd_1121584000"},{"admin_dong":"자양제4동","gu":"광진구","place_id":"seoul_admd_1121584700"},{"admin_dong":"구의제1동","gu":"광진구","place_id":"seoul_admd_1121585000"},{"admin_dong":"구의제2동","gu":"광진구","place_id":"seoul_admd_1121586000"},{"admin_dong":"구의제3동","gu":"광진구","place_id":"seoul_admd_1121587000"},{"admin_dong":"신설동","gu":"동대문구","place_id":"seoul_admd_1123051500"},{"admin_dong":"용두동","gu":"동대문구","place_id":"seoul_admd_1123053300"},{"admin_dong":"제기동","gu":"동대문구","place_id":"seoul_admd_1123054500"},{"admin_dong":"전농제1동","gu":"동대문구","place_id":"seoul_admd_1123056000"},{"admin_dong":"전농제2동","gu":"동대문구","place_id":"seoul_admd_1123057000"},{"admin_dong":"답십리제1동","gu":"동대문구","place_id":"seoul_admd_1123060000"},{"admin_dong":"답십리제2동","gu":"동대문구","place_id":"seoul_admd_1123061000"},{"admin_dong":"장안제1동","gu":"동대문구","place_id":"seoul_admd_1123065000"},{"admin_dong":"장안제2동","gu":"동대문구","place_id":"seoul_admd_1123066000"},{"admin_dong":"청량리동","gu":"동대문구","place_id":"seoul_admd_1123070500"},{"admin_dong":"회기동","gu":"동대문구","place_id":"seoul_admd_1123071000"},{"admin_dong":"휘경제1동","gu":"동대문구","place_id":"seoul_admd_1123072000"},{"admin_dong":"휘경제2동","gu":"동대문구","place_id":"seoul_admd_1123073000"},{"admin_dong":"이문제1동","gu":"동대문구","place_id":"seoul_admd_1123074000"},{"admin_dong":"이문제2동","gu":"동대문구","place_id":"seoul_admd_1123075000"},{"admin_dong":"면목제2동","gu":"중랑구","place_id":"seoul_admd_1126052000"},{"admin_dong":"면목제4동","gu":"중랑구","place_id":"seoul_admd_1126054000"},{"admin_dong":"면목제5동","gu":"중랑구","place_id":"seoul_admd_1126055000"},{"admin_dong":"면목본동","gu":"중랑구","place_id":"seoul_admd_1126056500"},{"admin_dong":"면목제7동","gu":"중랑구","place_id":"seoul_admd_1126057000"},{"admin_dong":"면목제3.8동","gu":"중랑구","place_id":"seoul_admd_1126057500"},{"admin_dong":"상봉제1동","gu":"중랑구","place_id":"seoul_admd_1126058000"},{"admin_dong":"상봉제2동","gu":"중랑구","place_id":"seoul_admd_1126059000"},{"admin_dong":"중화제1동","gu":"중랑구","place_id":"seoul_admd_1126060000"},{"admin_dong":"중화제2동","gu":"중랑구","place_id":"seoul_admd_1126061000"},{"admin_dong":"묵제1동","gu":"중랑구","place_id":"seoul_admd_1126062000"},{"admin_dong":"묵제2동","gu":"중랑구","place_id":"seoul_admd_1126063000"},{"admin_dong":"망우본동","gu":"중랑구","place_id":"seoul_admd_1126065500"},{"admin_dong":"망우제3동","gu":"중랑구","place_id":"seoul_admd_1126066000"},{"admin_dong":"신내1동","gu":"중랑구","place_id":"seoul_admd_1126068000"},{"admin_dong":"신내2동","gu":"중랑구","place_id":"seoul_admd_1126069000"},{"admin_dong":"성북동","gu":"성북구","place_id":"seoul_admd_1129052500"},{"admin_dong":"삼선동","gu":"성북구","place_id":"seoul_admd_1129055500"},{"admin_dong":"동선동","gu":"성북구","place_id":"seoul_admd_1129057500"},{"admin_dong":"돈암제1동","gu":"성북구","place_id":"seoul_admd_1129058000"},{"admin_dong":"돈암제2동","gu":"성북구","place_id":"seoul_admd_1129059000"},{"admin_dong":"안암동","gu":"성북구","place_id":"seoul_admd_1129060000"},{"admin_dong":"보문동","gu":"성북구","place_id":"seoul_admd_1129061000"},{"admin_dong":"정릉제1동","gu":"성북구","place_id":"seoul_admd_1129062000"},{"admin_dong":"정릉제2동","gu":"성북구","place_id":"seoul_admd_1129063000"},{"admin_dong":"정릉제3동","gu":"성북구","place_id":"seoul_admd_1129064000"},{"admin_dong":"정릉제4동","gu":"성북구","place_id":"seoul_admd_1129065000"},{"admin_dong":"길음제1동","gu":"성북구","place_id":"seoul_admd_1129066000"},{"admin_dong":"길음제2동","gu":"성북구","place_id":"seoul_admd_1129068500"},{"admin_dong":"종암동","gu":"성북구","place_id":"seoul_admd_1129070500"},{"admin_dong":"월곡제1동","gu":"성북구","place_id":"seoul_admd_1129071500"},{"admin_dong":"월곡제2동","gu":"성북구","place_id":"seoul_admd_1129072500"},{"admin_dong":"장위제1동","gu":"성북구","place_id":"seoul_admd_1129076000"},{"admin_dong":"장위제2동","gu":"성북구","place_id":"seoul_admd_1129077000"},{"admin_dong":"장위제3동","gu":"성북구","place_id":"seoul_admd_1129078000"},{"admin_dong":"석관동","gu":"성북구","place_id":"seoul_admd_1129081000"},{"admin_dong":"삼양동","gu":"강북구","place_id":"seoul_admd_1130553400"},{"admin_dong":"미아동","gu":"강북구","place_id":"seoul_admd_1130553500"},{"admin_dong":"송중동","gu":"강북구","place_id":"seoul_admd_1130554500"},{"admin_dong":"송천동","gu":"강북구","place_id":"seoul_admd_1130555500"},{"admin_dong":"삼각산동","gu":"강북구","place_id":"seoul_admd_1130557500"},{"admin_dong":"번1동","gu":"강북구","place_id":"seoul_admd_1130559500"},{"admin_dong":"번2동","gu":"강북구","place_id":"seoul_admd_1130560300"},{"admin_dong":"번3동","gu":"강북구","place_id":"seoul_admd_1130560800"},{"admin_dong":"수유1동","gu":"강북구","place_id":"seoul_admd_1130561500"},{"admin_dong":"수유2동","gu":"강북구","place_id":"seoul_admd_1130562500"},{"admin_dong":"수유3동","gu":"강북구","place_id":"seoul_admd_1130563500"},{"admin_dong":"우이동","gu":"강북구","place_id":"seoul_admd_1130564500"},{"admin_dong":"인수동","gu":"강북구","place_id":"seoul_admd_1130566000"},{"admin_dong":"창제1동","gu":"도봉구","place_id":"seoul_admd_1132051100"},{"admin_dong":"창제2동","gu":"도봉구","place_id":"seoul_admd_1132051200"},{"admin_dong":"창제3동","gu":"도봉구","place_id":"seoul_admd_1132051300"},{"admin_dong":"창제4동","gu":"도봉구","place_id":"seoul_admd_1132051400"},{"admin_dong":"창제5동","gu":"도봉구","place_id":"seoul_admd_1132051500"},{"admin_dong":"도봉제1동","gu":"도봉구","place_id":"seoul_admd_1132052100"},{"admin_dong":"도봉제2동","gu":"도봉구","place_id":"seoul_admd_1132052200"},{"admin_dong":"쌍문제1동","gu":"도봉구","place_id":"seoul_admd_1132066000"},{"admin_dong":"쌍문제2동","gu":"도봉구","place_id":"seoul_admd_1132067000"},{"admin_dong":"쌍문제3동","gu":"도봉구","place_id":"seoul_admd_1132068000"},{"admin_dong":"쌍문제4동","gu":"도봉구","place_id":"seoul_admd_1132068100"},{"admin_dong":"방학제1동","gu":"도봉구","place_id":"seoul_admd_1132069000"},{"admin_dong":"방학제2동","gu":"도봉구","place_id":"seoul_admd_1132070000"},{"admin_dong":"방학제3동","gu":"도봉구","place_id":"seoul_admd_1132071000"},{"admin_dong":"월계1동","gu":"노원구","place_id":"seoul_admd_1135056000"},{"admin_dong":"월계2동","gu":"노원구","place_id":"seoul_admd_1135057000"},{"admin_dong":"월계3동","gu":"노원구","place_id":"seoul_admd_1135058000"},{"admin_dong":"공릉1동","gu":"노원구","place_id":"seoul_admd_1135059500"},{"admin_dong":"공릉2동","gu":"노원구","place_id":"seoul_admd_1135060000"},{"admin_dong":"하계1동","gu":"노원구","place_id":"seoul_admd_1135061100"},{"admin_dong":"하계2동","gu":"노원구","place_id":"seoul_admd_1135061200"},{"admin_dong":"중계본동","gu":"노원구","place_id":"seoul_admd_1135061900"},{"admin_dong":"중계1동","gu":"노원구","place_id":"seoul_admd_1135062100"},{"admin_dong":"중계4동","gu":"노원구","place_id":"seoul_admd_1135062400"},{"admin_dong":"중계2.3동","gu":"노원구","place_id":"seoul_admd_1135062500"},{"admin_dong":"상계1동","gu":"노원구","place_id":"seoul_admd_1135063000"},{"admin_dong":"상계2동","gu":"노원구","place_id":"seoul_admd_1135064000"},{"admin_dong":"상계3.4동","gu":"노원구","place_id":"seoul_admd_1135066500"},{"admin_dong":"상계5동","gu":"노원구","place_id":"seoul_admd_1135067000"},{"admin_dong":"상계6.7동","gu":"노원구","place_id":"seoul_admd_1135069500"},{"admin_dong":"상계8동","gu":"노원구","place_id":"seoul_admd_1135070000"},{"admin_dong":"상계9동","gu":"노원구","place_id":"seoul_admd_1135071000"},{"admin_dong":"상계10동","gu":"노원구","place_id":"seoul_admd_1135072000"},{"admin_dong":"녹번동","gu":"은평구","place_id":"seoul_admd_1138051000"},{"admin_dong":"불광제1동","gu":"은평구","place_id":"seoul_admd_1138052000"},{"admin_dong":"불광제2동","gu":"은평구","place_id":"seoul_admd_1138053000"},{"admin_dong":"갈현제1동","gu":"은평구","place_id":"seoul_admd_1138055100"},{"admin_dong":"갈현제2동","gu":"은평구","place_id":"seoul_admd_1138055200"},{"admin_dong":"구산동","gu":"은평구","place_id":"seoul_admd_1138056000"},{"admin_dong":"대조동","gu":"은평구","place_id":"seoul_admd_1138057000"},{"admin_dong":"응암제1동","gu":"은평구","place_id":"seoul_admd_1138058000"},{"admin_dong":"응암제2동","gu":"은평구","place_id":"seoul_admd_1138059000"},{"admin_dong":"응암제3동","gu":"은평구","place_id":"seoul_admd_1138060000"},{"admin_dong":"역촌동","gu":"은평구","place_id":"seoul_admd_1138062500"},{"admin_dong":"신사제1동","gu":"은평구","place_id":"seoul_admd_1138063100"},{"admin_dong":"신사제2동","gu":"은평구","place_id":"seoul_admd_1138063200"},{"admin_dong":"증산동","gu":"은평구","place_id":"seoul_admd_1138064000"},{"admin_dong":"수색동","gu":"은평구","place_id":"seoul_admd_1138065000"},{"admin_dong":"진관동","gu":"은평구","place_id":"seoul_admd_1138069000"},{"admin_dong":"천연동","gu":"서대문구","place_id":"seoul_admd_1141052000"},{"admin_dong":"북아현동","gu":"서대문구","place_id":"seoul_admd_1141055500"},{"admin_dong":"충현동","gu":"서대문구","place_id":"seoul_admd_1141056500"},{"admin_dong":"신촌동","gu":"서대문구","place_id":"seoul_admd_1141058500"},{"admin_dong":"연희동","gu":"서대문구","place_id":"seoul_admd_1141061500"},{"admin_dong":"홍제제1동","gu":"서대문구","place_id":"seoul_admd_1141062000"},{"admin_dong":"홍제제3동","gu":"서대문구","place_id":"seoul_admd_1141064000"},{"admin_dong":"홍제제2동","gu":"서대문구","place_id":"seoul_admd_1141065500"},{"admin_dong":"홍은제1동","gu":"서대문구","place_id":"seoul_admd_1141066000"},{"admin_dong":"홍은제2동","gu":"서대문구","place_id":"seoul_admd_1141068500"},{"admin_dong":"남가좌제1동","gu":"서대문구","place_id":"seoul_admd_1141069000"},{"admin_dong":"남가좌제2동","gu":"서대문구","place_id":"seoul_admd_1141070000"},{"admin_dong":"북가좌제1동","gu":"서대문구","place_id":"seoul_admd_1141071000"},{"admin_dong":"북가좌제2동","gu":"서대문구","place_id":"seoul_admd_1141072000"},{"admin_dong":"아현동","gu":"마포구","place_id":"seoul_admd_1144055500"},{"admin_dong":"공덕동","gu":"마포구","place_id":"seoul_admd_1144056500"},{"admin_dong":"도화동","gu":"마포구","place_id":"seoul_admd_1144058500"},{"admin_dong":"용강동","gu":"마포구","place_id":"seoul_admd_1144059000"},{"admin_dong":"대흥동","gu":"마포구","place_id":"seoul_admd_1144060000"},{"admin_dong":"염리동","gu":"마포구","place_id":"seoul_admd_1144061000"},{"admin_dong":"신수동","gu":"마포구","place_id":"seoul_admd_1144063000"},{"admin_dong":"서강동","gu":"마포구","place_id":"seoul_admd_1144065500"},{"admin_dong":"서교동","gu":"마포구","place_id":"seoul_admd_1144066000"},{"admin_dong":"합정동","gu":"마포구","place_id":"seoul_admd_1144068000"},{"admin_dong":"망원제1동","gu":"마포구","place_id":"seoul_admd_1144069000"},{"admin_dong":"망원제2동","gu":"마포구","place_id":"seoul_admd_1144070000"},{"admin_dong":"연남동","gu":"마포구","place_id":"seoul_admd_1144071000"},{"admin_dong":"성산제1동","gu":"마포구","place_id":"seoul_admd_1144072000"},{"admin_dong":"성산제2동","gu":"마포구","place_id":"seoul_admd_1144073000"},{"admin_dong":"상암동","gu":"마포구","place_id":"seoul_admd_1144074000"},{"admin_dong":"목1동","gu":"양천구","place_id":"seoul_admd_1147051000"},{"admin_dong":"목2동","gu":"양천구","place_id":"seoul_admd_1147052000"},{"admin_dong":"목3동","gu":"양천구","place_id":"seoul_admd_1147053000"},{"admin_dong":"목4동","gu":"양천구","place_id":"seoul_admd_1147054000"},{"admin_dong":"목5동","gu":"양천구","place_id":"seoul_admd_1147055000"},{"admin_dong":"신월1동","gu":"양천구","place_id":"seoul_admd_1147056000"},{"admin_dong":"신월2동","gu":"양천구","place_id":"seoul_admd_1147057000"},{"admin_dong":"신월3동","gu":"양천구","place_id":"seoul_admd_1147058000"},{"admin_dong":"신월4동","gu":"양천구","place_id":"seoul_admd_1147059000"},{"admin_dong":"신월5동","gu":"양천구","place_id":"seoul_admd_1147060000"},{"admin_dong":"신월6동","gu":"양천구","place_id":"seoul_admd_1147061000"},{"admin_dong":"신월7동","gu":"양천구","place_id":"seoul_admd_1147061100"},{"admin_dong":"신정1동","gu":"양천구","place_id":"seoul_admd_1147062000"},{"admin_dong":"신정2동","gu":"양천구","place_id":"seoul_admd_1147063000"},{"admin_dong":"신정3동","gu":"양천구","place_id":"seoul_admd_1147064000"},{"admin_dong":"신정4동","gu":"양천구","place_id":"seoul_admd_1147065000"},{"admin_dong":"신정6동","gu":"양천구","place_id":"seoul_admd_1147067000"},{"admin_dong":"신정7동","gu":"양천구","place_id":"seoul_admd_1147068000"},{"admin_dong":"염창동","gu":"강서구","place_id":"seoul_admd_1150051000"},{"admin_dong":"등촌제1동","gu":"강서구","place_id":"seoul_admd_1150052000"},{"admin_dong":"등촌제2동","gu":"강서구","place_id":"seoul_admd_1150053000"},{"admin_dong":"등촌제3동","gu":"강서구","place_id":"seoul_admd_1150053500"},{"admin_dong":"화곡제1동","gu":"강서구","place_id":"seoul_admd_1150054000"},{"admin_dong":"화곡제2동","gu":"강서구","place_id":"seoul_admd_1150055000"},{"admin_dong":"화곡제3동","gu":"강서구","place_id":"seoul_admd_1150056000"},{"admin_dong":"화곡제4동","gu":"강서구","place_id":"seoul_admd_1150057000"},{"admin_dong":"화곡본동","gu":"강서구","place_id":"seoul_admd_1150059000"},{"admin_dong":"화곡제6동","gu":"강서구","place_id":"seoul_admd_1150059100"},{"admin_dong":"화곡제8동","gu":"강서구","place_id":"seoul_admd_1150059300"},{"admin_dong":"가양제1동","gu":"강서구","place_id":"seoul_admd_1150060300"},{"admin_dong":"가양제2동","gu":"강서구","place_id":"seoul_admd_1150060400"},{"admin_dong":"가양제3동","gu":"강서구","place_id":"seoul_admd_1150060500"},{"admin_dong":"발산제1동","gu":"강서구","place_id":"seoul_admd_1150061100"},{"admin_dong":"우장산동","gu":"강서구","place_id":"seoul_admd_1150061500"},{"admin_dong":"공항동","gu":"강서구","place_id":"seoul_admd_1150062000"},{"admin_dong":"방화제1동","gu":"강서구","place_id":"seoul_admd_1150063000"},{"admin_dong":"방화제2동","gu":"강서구","place_id":"seoul_admd_1150064000"},{"admin_dong":"방화제3동","gu":"강서구","place_id":"seoul_admd_1150064100"},{"admin_dong":"신도림동","gu":"구로구","place_id":"seoul_admd_1153051000"},{"admin_dong":"구로제1동","gu":"구로구","place_id":"seoul_admd_1153052000"},{"admin_dong":"구로제2동","gu":"구로구","place_id":"seoul_admd_1153053000"},{"admin_dong":"구로제3동","gu":"구로구","place_id":"seoul_admd_1153054000"},{"admin_dong":"구로제4동","gu":"구로구","place_id":"seoul_admd_1153055000"},{"admin_dong":"구로제5동","gu":"구로구","place_id":"seoul_admd_1153056000"},{"admin_dong":"가리봉동","gu":"구로구","place_id":"seoul_admd_1153059500"},{"admin_dong":"고척제1동","gu":"구로구","place_id":"seoul_admd_1153072000"},{"admin_dong":"고척제2동","gu":"구로구","place_id":"seoul_admd_1153073000"},{"admin_dong":"개봉제1동","gu":"구로구","place_id":"seoul_admd_1153074000"},{"admin_dong":"개봉제2동","gu":"구로구","place_id":"seoul_admd_1153075000"},{"admin_dong":"개봉제3동","gu":"구로구","place_id":"seoul_admd_1153076000"},{"admin_dong":"오류제1동","gu":"구로구","place_id":"seoul_admd_1153077000"},{"admin_dong":"오류제2동","gu":"구로구","place_id":"seoul_admd_1153078000"},{"admin_dong":"수궁동","gu":"구로구","place_id":"seoul_admd_1153079000"},{"admin_dong":"항동","gu":"구로구","place_id":"seoul_admd_1153080000"},{"admin_dong":"가산동","gu":"금천구","place_id":"seoul_admd_1154551000"},{"admin_dong":"독산제1동","gu":"금천구","place_id":"seoul_admd_1154561000"},{"admin_dong":"독산제2동","gu":"금천구","place_id":"seoul_admd_1154562000"},{"admin_dong":"독산제3동","gu":"금천구","place_id":"seoul_admd_1154563000"},{"admin_dong":"독산제4동","gu":"금천구","place_id":"seoul_admd_1154564000"},{"admin_dong":"시흥제1동","gu":"금천구","place_id":"seoul_admd_1154567000"},{"admin_dong":"시흥제2동","gu":"금천구","place_id":"seoul_admd_1154568000"},{"admin_dong":"시흥제3동","gu":"금천구","place_id":"seoul_admd_1154569000"},{"admin_dong":"시흥제4동","gu":"금천구","place_id":"seoul_admd_1154570000"},{"admin_dong":"시흥제5동","gu":"금천구","place_id":"seoul_admd_1154571000"},{"admin_dong":"영등포본동","gu":"영등포구","place_id":"seoul_admd_1156051500"},{"admin_dong":"영등포동","gu":"영등포구","place_id":"seoul_admd_1156053500"},{"admin_dong":"여의동","gu":"영등포구","place_id":"seoul_admd_1156054000"},{"admin_dong":"당산제1동","gu":"영등포구","place_id":"seoul_admd_1156055000"},{"admin_dong":"당산제2동","gu":"영등포구","place_id":"seoul_admd_1156056000"},{"admin_dong":"도림동","gu":"영등포구","place_id":"seoul_admd_1156058500"},{"admin_dong":"문래동","gu":"영등포구","place_id":"seoul_admd_1156060500"},{"admin_dong":"양평제1동","gu":"영등포구","place_id":"seoul_admd_1156061000"},{"admin_dong":"양평제2동","gu":"영등포구","place_id":"seoul_admd_1156062000"},{"admin_dong":"신길제1동","gu":"영등포구","place_id":"seoul_admd_1156063000"},{"admin_dong":"신길제3동","gu":"영등포구","place_id":"seoul_admd_1156065000"},{"admin_dong":"신길제4동","gu":"영등포구","place_id":"seoul_admd_1156066000"},{"admin_dong":"신길제5동","gu":"영등포구","place_id":"seoul_admd_1156067000"},{"admin_dong":"신길제6동","gu":"영등포구","place_id":"seoul_admd_1156068000"},{"admin_dong":"신길제7동","gu":"영등포구","place_id":"seoul_admd_1156069000"},{"admin_dong":"대림제1동","gu":"영등포구","place_id":"seoul_admd_1156070000"},{"admin_dong":"대림제2동","gu":"영등포구","place_id":"seoul_admd_1156071000"},{"admin_dong":"대림제3동","gu":"영등포구","place_id":"seoul_admd_1156072000"},{"admin_dong":"노량진제1동","gu":"동작구","place_id":"seoul_admd_1159051000"},{"admin_dong":"노량진제2동","gu":"동작구","place_id":"seoul_admd_1159052000"},{"admin_dong":"상도제1동","gu":"동작구","place_id":"seoul_admd_1159053000"},{"admin_dong":"상도제2동","gu":"동작구","place_id":"seoul_admd_1159054000"},{"admin_dong":"상도제3동","gu":"동작구","place_id":"seoul_admd_1159055000"},{"admin_dong":"상도제4동","gu":"동작구","place_id":"seoul_admd_1159056000"},{"admin_dong":"흑석동","gu":"동작구","place_id":"seoul_admd_1159060500"},{"admin_dong":"사당제1동","gu":"동작구","place_id":"seoul_admd_1159062000"},{"admin_dong":"사당제2동","gu":"동작구","place_id":"seoul_admd_1159063000"},{"admin_dong":"사당제3동","gu":"동작구","place_id":"seoul_admd_1159064000"},{"admin_dong":"사당제4동","gu":"동작구","place_id":"seoul_admd_1159065000"},{"admin_dong":"사당제5동","gu":"동작구","place_id":"seoul_admd_1159065100"},{"admin_dong":"대방동","gu":"동작구","place_id":"seoul_admd_1159066000"},{"admin_dong":"신대방제1동","gu":"동작구","place_id":"seoul_admd_1159067000"},{"admin_dong":"신대방제2동","gu":"동작구","place_id":"seoul_admd_1159068000"},{"admin_dong":"보라매동","gu":"관악구","place_id":"seoul_admd_1162052500"},{"admin_dong":"청림동","gu":"관악구","place_id":"seoul_admd_1162054500"},{"admin_dong":"성현동","gu":"관악구","place_id":"seoul_admd_1162056500"},{"admin_dong":"행운동","gu":"관악구","place_id":"seoul_admd_1162057500"},{"admin_dong":"낙성대동","gu":"관악구","place_id":"seoul_admd_1162058500"},{"admin_dong":"청룡동","gu":"관악구","place_id":"seoul_admd_1162059500"},{"admin_dong":"은천동","gu":"관악구","place_id":"seoul_admd_1162060500"},{"admin_dong":"중앙동","gu":"관악구","place_id":"seoul_admd_1162061500"},{"admin_dong":"인헌동","gu":"관악구","place_id":"seoul_admd_1162062500"},{"admin_dong":"남현동","gu":"관악구","place_id":"seoul_admd_1162063000"},{"admin_dong":"서원동","gu":"관악구","place_id":"seoul_admd_1162064500"},{"admin_dong":"신원동","gu":"관악구","place_id":"seoul_admd_1162065500"},{"admin_dong":"서림동","gu":"관악구","place_id":"seoul_admd_1162066500"},{"admin_dong":"신사동","gu":"관악구","place_id":"seoul_admd_1162068500"},{"admin_dong":"신림동","gu":"관악구","place_id":"seoul_admd_1162069500"},{"admin_dong":"난향동","gu":"관악구","place_id":"seoul_admd_1162071500"},{"admin_dong":"조원동","gu":"관악구","place_id":"seoul_admd_1162072500"},{"admin_dong":"대학동","gu":"관악구","place_id":"seoul_admd_1162073500"},{"admin_dong":"삼성동","gu":"관악구","place_id":"seoul_admd_1162074500"},{"admin_dong":"미성동","gu":"관악구","place_id":"seoul_admd_1162076500"},{"admin_dong":"난곡동","gu":"관악구","place_id":"seoul_admd_1162077500"},{"admin_dong":"서초1동","gu":"서초구","place_id":"seoul_admd_1165051000"},{"admin_dong":"서초2동","gu":"서초구","place_id":"seoul_admd_1165052000"},{"admin_dong":"서초3동","gu":"서초구","place_id":"seoul_admd_1165053000"},{"admin_dong":"서초4동","gu":"서초구","place_id":"seoul_admd_1165053100"},{"admin_dong":"잠원동","gu":"서초구","place_id":"seoul_admd_1165054000"},{"admin_dong":"반포본동","gu":"서초구","place_id":"seoul_admd_1165055000"},{"admin_dong":"반포1동","gu":"서초구","place_id":"seoul_admd_1165056000"},{"admin_dong":"반포2동","gu":"서초구","place_id":"seoul_admd_1165057000"},{"admin_dong":"반포3동","gu":"서초구","place_id":"seoul_admd_1165058000"},{"admin_dong":"반포4동","gu":"서초구","place_id":"seoul_admd_1165058100"},{"admin_dong":"방배본동","gu":"서초구","place_id":"seoul_admd_1165059000"},{"admin_dong":"방배1동","gu":"서초구","place_id":"seoul_admd_1165060000"},{"admin_dong":"방배2동","gu":"서초구","place_id":"seoul_admd_1165061000"},{"admin_dong":"방배3동","gu":"서초구","place_id":"seoul_admd_1165062000"},{"admin_dong":"방배4동","gu":"서초구","place_id":"seoul_admd_1165062100"},{"admin_dong":"양재1동","gu":"서초구","place_id":"seoul_admd_1165065100"},{"admin_dong":"양재2동","gu":"서초구","place_id":"seoul_admd_1165065200"},{"admin_dong":"내곡동","gu":"서초구","place_id":"seoul_admd_1165066000"},{"admin_dong":"신사동","gu":"강남구","place_id":"seoul_admd_1168051000"},{"admin_dong":"논현1동","gu":"강남구","place_id":"seoul_admd_1168052100"},{"admin_dong":"논현2동","gu":"강남구","place_id":"seoul_admd_1168053100"},{"admin_dong":"압구정동","gu":"강남구","place_id":"seoul_admd_1168054500"},{"admin_dong":"청담동","gu":"강남구","place_id":"seoul_admd_1168056500"},{"admin_dong":"삼성1동","gu":"강남구","place_id":"seoul_admd_1168058000"},{"admin_dong":"삼성2동","gu":"강남구","place_id":"seoul_admd_1168059000"},{"admin_dong":"대치1동","gu":"강남구","place_id":"seoul_admd_1168060000"},{"admin_dong":"대치2동","gu":"강남구","place_id":"seoul_admd_1168061000"},{"admin_dong":"대치4동","gu":"강남구","place_id":"seoul_admd_1168063000"},{"admin_dong":"역삼1동","gu":"강남구","place_id":"seoul_admd_1168064000"},{"admin_dong":"역삼2동","gu":"강남구","place_id":"seoul_admd_1168065000"},{"admin_dong":"도곡1동","gu":"강남구","place_id":"seoul_admd_1168065500"},{"admin_dong":"도곡2동","gu":"강남구","place_id":"seoul_admd_1168065600"},{"admin_dong":"개포1동","gu":"강남구","place_id":"seoul_admd_1168066000"},{"admin_dong":"개포2동","gu":"강남구","place_id":"seoul_admd_1168067000"},{"admin_dong":"개포3동","gu":"강남구","place_id":"seoul_admd_1168067500"},{"admin_dong":"개포4동","gu":"강남구","place_id":"seoul_admd_1168069000"},{"admin_dong":"세곡동","gu":"강남구","place_id":"seoul_admd_1168070000"},{"admin_dong":"일원본동","gu":"강남구","place_id":"seoul_admd_1168072000"},{"admin_dong":"일원1동","gu":"강남구","place_id":"seoul_admd_1168073000"},{"admin_dong":"수서동","gu":"강남구","place_id":"seoul_admd_1168075000"},{"admin_dong":"풍납1동","gu":"송파구","place_id":"seoul_admd_1171051000"},{"admin_dong":"풍납2동","gu":"송파구","place_id":"seoul_admd_1171052000"},{"admin_dong":"거여1동","gu":"송파구","place_id":"seoul_admd_1171053100"},{"admin_dong":"거여2동","gu":"송파구","place_id":"seoul_admd_1171053200"},{"admin_dong":"마천1동","gu":"송파구","place_id":"seoul_admd_1171054000"},{"admin_dong":"마천2동","gu":"송파구","place_id":"seoul_admd_1171055000"},{"admin_dong":"방이1동","gu":"송파구","place_id":"seoul_admd_1171056100"},{"admin_dong":"방이2동","gu":"송파구","place_id":"seoul_admd_1171056200"},{"admin_dong":"오륜동","gu":"송파구","place_id":"seoul_admd_1171056600"},{"admin_dong":"오금동","gu":"송파구","place_id":"seoul_admd_1171057000"},{"admin_dong":"송파1동","gu":"송파구","place_id":"seoul_admd_1171058000"},{"admin_dong":"송파2동","gu":"송파구","place_id":"seoul_admd_1171059000"},{"admin_dong":"석촌동","gu":"송파구","place_id":"seoul_admd_1171060000"},{"admin_dong":"삼전동","gu":"송파구","place_id":"seoul_admd_1171061000"},{"admin_dong":"가락본동","gu":"송파구","place_id":"seoul_admd_1171062000"},{"admin_dong":"가락1동","gu":"송파구","place_id":"seoul_admd_1171063100"},{"admin_dong":"가락2동","gu":"송파구","place_id":"seoul_admd_1171063200"},{"admin_dong":"문정1동","gu":"송파구","place_id":"seoul_admd_1171064100"},{"admin_dong":"문정2동","gu":"송파구","place_id":"seoul_admd_1171064200"},{"admin_dong":"장지동","gu":"송파구","place_id":"seoul_admd_1171064600"},{"admin_dong":"위례동","gu":"송파구","place_id":"seoul_admd_1171064700"},{"admin_dong":"잠실본동","gu":"송파구","place_id":"seoul_admd_1171065000"},{"admin_dong":"잠실2동","gu":"송파구","place_id":"seoul_admd_1171067000"},{"admin_dong":"잠실3동","gu":"송파구","place_id":"seoul_admd_1171068000"},{"admin_dong":"잠실4동","gu":"송파구","place_id":"seoul_admd_1171069000"},{"admin_dong":"잠실6동","gu":"송파구","place_id":"seoul_admd_1171071000"},{"admin_dong":"잠실7동","gu":"송파구","place_id":"seoul_admd_1171072000"},{"admin_dong":"강일동","gu":"강동구","place_id":"seoul_admd_1174051500"},{"admin_dong":"상일제1동","gu":"강동구","place_id":"seoul_admd_1174052500"},{"admin_dong":"상일제2동","gu":"강동구","place_id":"seoul_admd_1174052600"},{"admin_dong":"명일제1동","gu":"강동구","place_id":"seoul_admd_1174053000"},{"admin_dong":"명일제2동","gu":"강동구","place_id":"seoul_admd_1174054000"},{"admin_dong":"고덕제1동","gu":"강동구","place_id":"seoul_admd_1174055000"},{"admin_dong":"고덕제2동","gu":"강동구","place_id":"seoul_admd_1174056000"},{"admin_dong":"암사제1동","gu":"강동구","place_id":"seoul_admd_1174057000"},{"admin_dong":"암사제2동","gu":"강동구","place_id":"seoul_admd_1174058000"},{"admin_dong":"암사제3동","gu":"강동구","place_id":"seoul_admd_1174059000"},{"admin_dong":"천호제1동","gu":"강동구","place_id":"seoul_admd_1174060000"},{"admin_dong":"천호제2동","gu":"강동구","place_id":"seoul_admd_1174061000"},{"admin_dong":"천호제3동","gu":"강동구","place_id":"seoul_admd_1174062000"},{"admin_dong":"성내제1동","gu":"강동구","place_id":"seoul_admd_1174064000"},{"admin_dong":"성내제2동","gu":"강동구","place_id":"seoul_admd_1174065000"},{"admin_dong":"성내제3동","gu":"강동구","place_id":"seoul_admd_1174066000"},{"admin_dong":"길동","gu":"강동구","place_id":"seoul_admd_1174068500"},{"admin_dong":"둔촌제1동","gu":"강동구","place_id":"seoul_admd_1174069000"},{"admin_dong":"둔촌제2동","gu":"강동구","place_id":"seoul_admd_1174070000"}]}
scripts/seoul_weather_risk.py
#!/usr/bin/env python3
"""Read-only HTTPS client for the ASK Seoul seoul-weather-risk skill API."""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import re
import sys
import unicodedata
from dataclasses import dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urlparse
from urllib.request import HTTPRedirectHandler, Request, build_opener
SKILL_BUNDLE_ID = "seoul-weather-risk"
PROXY_BASE_URL_ENV = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
PROXY_DISABLED_VALUES = frozenset({"off", "false", "0", "disable", "disabled", "none"})
PROXY_ROUTE_ROOT = "/v1/ask-seoul/weather-risk"
LOCATION_MAPPING_PATH = pathlib.Path(__file__).resolve().parents[1] / "references" / "admin-dong-place-map.json"
LOCATION_MAPPING_VERSION = "kma_admin_dong_grid_20260325"
LOCATION_MAPPING_SIZE = 427
EXACT_PRODUCT_IDS = frozenset({
"weather_place_risk_window",
})
STATUS_CODES = {
401: "unauthorized",
403: "forbidden",
404: "unknown_product",
409: "cursor_expired",
422: "query_window_unavailable",
429: "rate_limited",
503: "product_not_ready",
}
WINDOW_INSTANT_LENGTH = len("YYYY-MM-DD HH:MM:SS")
QUERY_WINDOW_DETAIL_FIELDS = (
"requested_from_at",
"requested_to_at",
"available_from_at",
"available_to_at",
"publication_id",
)
class SkillError(RuntimeError):
def __init__(self, code: str, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
class _NoRedirect(HTTPRedirectHandler):
"""Do not silently move a read request to an unreviewed proxy origin."""
def redirect_request(self, _req: Request, _fp: Any, _code: int, _msg: str, _headers: Any, _newurl: str) -> None:
return None
@dataclass(frozen=True)
class ApiConfig:
base_url: str
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _api_config(environ: dict[str, str] | None = None) -> ApiConfig:
values = os.environ if environ is None else environ
configured = values.get(PROXY_BASE_URL_ENV, "").strip()
if configured.casefold() in PROXY_DISABLED_VALUES:
raise SkillError("proxy_disabled", f"{PROXY_BASE_URL_ENV}가 비활성화되어 있습니다.")
base_url = (configured if configured and configured != "replace-me" else DEFAULT_PROXY_BASE_URL).rstrip("/")
parsed = urlparse(base_url)
local_http = parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
if (parsed.scheme != "https" and not local_http) or not parsed.netloc or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise SkillError("invalid_proxy_base_url", "k-skill proxy base URL은 HTTPS origin이어야 합니다.")
if parsed.username or parsed.password:
raise SkillError("invalid_proxy_base_url", "k-skill proxy base URL에 사용자 정보는 포함할 수 없습니다.")
return ApiConfig(base_url=base_url)
def _error_payload(raw: bytes) -> dict[str, Any]:
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
return payload if isinstance(payload, dict) else {}
def _problem_error(status: int, raw: bytes, headers: Any) -> SkillError:
problem = _error_payload(raw)
code = problem.get("code") if isinstance(problem.get("code"), str) else STATUS_CODES.get(status, "api_error")
raw_detail = problem.get("detail")
message = raw_detail if isinstance(raw_detail, str) else problem.get("title")
if not isinstance(message, str) or not message:
message = f"ASK Seoul API가 HTTP {status} 응답을 반환했습니다."
details: dict[str, Any] = {"status": status}
for name in ("type", "title", "product_id", "blockers", "request_id"):
if name in problem:
details[name] = problem[name]
if isinstance(raw_detail, dict):
details["detail"] = raw_detail
for name in QUERY_WINDOW_DETAIL_FIELDS:
value = raw_detail.get(name)
if isinstance(value, str) and value:
details[name] = value
retry_after = headers.get("Retry-After") if headers else None
if retry_after:
details["retry_after"] = retry_after
return SkillError(code, message, details)
def _request_json(config: ApiConfig, path: str, query: dict[str, str] | None = None) -> dict[str, Any]:
url = f"{config.base_url}{path}"
if query:
url = f"{url}?{urlencode(query)}"
headers = {
"Accept": "application/json",
"User-Agent": "k-skill-seoul-weather-risk/1",
}
request = Request(url, headers=headers)
try:
with build_opener(_NoRedirect).open(request, timeout=15) as response:
raw = response.read()
content_type = response.headers.get_content_type()
except HTTPError as exc:
# HTTPError is also a file object. Close it after reading so repeated
# typed failures do not leak a response handle into the caller's stderr.
raw = exc.read()
try:
error = _problem_error(exc.code, raw, exc.headers)
finally:
exc.close()
raise error from exc
except URLError as exc:
raise SkillError("network_error", "ASK Seoul API에 연결할 수 없습니다.") from exc
if content_type != "application/json":
raise SkillError("malformed_response", "ASK Seoul API 성공 응답의 Content-Type이 JSON이 아닙니다.")
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SkillError("malformed_response", "ASK Seoul API 성공 응답이 JSON 객체가 아닙니다.") from exc
if not isinstance(payload, dict):
raise SkillError("malformed_response", "ASK Seoul API 성공 응답이 JSON 객체가 아닙니다.")
return payload
def _contract_error(message: str) -> SkillError:
return SkillError("response_contract_invalid", message)
def _require(value: dict[str, Any], name: str, expected: type | tuple[type, ...]) -> Any:
item = value.get(name)
if not isinstance(item, expected):
raise _contract_error(f"ASK Seoul API 응답의 {name} 계약이 올바르지 않습니다.")
return item
def _validate_bundle(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("bundle_id") != SKILL_BUNDLE_ID:
raise _contract_error("ASK Seoul API 응답의 bundle_id가 일치하지 않습니다.")
_require(payload, "registration_ready", bool)
products = _require(payload, "products", list)
ids: list[str] = []
for product in products:
if not isinstance(product, dict):
raise _contract_error("ASK Seoul API bundle의 product 항목이 객체가 아닙니다.")
product_id = _require(product, "product_id", str)
_require(product, "registration_ready", bool)
blockers = _require(product, "blockers", list)
if not all(isinstance(blocker, str) for blocker in blockers):
raise _contract_error("ASK Seoul API bundle의 blockers 계약이 올바르지 않습니다.")
publication_id = product.get("publication_id")
if publication_id is not None and not isinstance(publication_id, str):
raise _contract_error("ASK Seoul API bundle의 publication_id 계약이 올바르지 않습니다.")
ids.append(product_id)
if set(ids) != EXACT_PRODUCT_IDS or len(ids) != len(EXACT_PRODUCT_IDS):
raise _contract_error("ASK Seoul API bundle의 제품 목록이 이 스킬의 단일 제품과 다릅니다.")
return payload
def _validate_product(payload: dict[str, Any], product_id: str) -> dict[str, Any]:
if payload.get("bundle_id") != SKILL_BUNDLE_ID or payload.get("product_id") != product_id:
raise _contract_error("ASK Seoul API product 응답의 bundle_id 또는 product_id가 일치하지 않습니다.")
_require(payload, "registration_ready", bool)
blockers = _require(payload, "blockers", list)
if not all(isinstance(blocker, str) for blocker in blockers):
raise _contract_error("ASK Seoul API product 응답의 blockers 계약이 올바르지 않습니다.")
publication_id = payload.get("publication_id")
if publication_id is not None and not isinstance(publication_id, str):
raise _contract_error("ASK Seoul API product 응답의 publication_id 계약이 올바르지 않습니다.")
metadata = _require(payload, "metadata", dict)
columns = metadata.get("columns", [])
if not isinstance(columns, list) or any(not isinstance(column, dict) or not isinstance(column.get("name"), str) for column in columns):
raise _contract_error("ASK Seoul API product metadata.columns 계약이 올바르지 않습니다.")
return payload
def _validate_data(payload: dict[str, Any], product_id: str, requested_limit: int) -> dict[str, Any]:
if payload.get("bundle_id") != SKILL_BUNDLE_ID or payload.get("product_id") != product_id:
raise _contract_error("ASK Seoul API data 응답의 bundle_id 또는 product_id가 일치하지 않습니다.")
publication_id = _require(payload, "publication_id", str)
if not publication_id:
raise _contract_error("ASK Seoul API data 응답의 publication_id가 비어 있습니다.")
row_count = _require(payload, "row_count", int)
limit = _require(payload, "limit", int)
has_more = _require(payload, "has_more", bool)
rows = _require(payload, "rows", list)
next_cursor = payload.get("next_cursor")
if row_count < 0 or row_count != len(rows) or limit != requested_limit or not 1 <= limit <= 500:
raise _contract_error("ASK Seoul API data page의 row_count 또는 limit 계약이 올바르지 않습니다.")
if next_cursor is not None and not isinstance(next_cursor, str):
raise _contract_error("ASK Seoul API data page의 next_cursor 계약이 올바르지 않습니다.")
if has_more != (next_cursor is not None):
raise _contract_error("ASK Seoul API data page의 has_more와 next_cursor가 일치하지 않습니다.")
if not all(isinstance(row, dict) for row in rows):
raise _contract_error("ASK Seoul API data page의 rows 계약이 올바르지 않습니다.")
return payload
def _filters(values: list[str]) -> dict[str, str]:
parsed: dict[str, str] = {}
for value in values:
if "=" not in value:
raise SkillError("invalid_filter", "필터는 column=value 형식이어야 합니다.")
name, expected = value.split("=", 1)
name = name.strip()
if not name or name in parsed:
raise SkillError("invalid_filter", "필터 이름은 비어 있거나 중복될 수 없습니다.")
parsed[name] = expected
return parsed
def _time_bound(value: str, edge: str) -> str:
if (
len(value) == len("YYYY-MM-DD")
and value[4] == "-"
and value[7] == "-"
and value[:4].isdigit()
and value[5:7].isdigit()
and value[8:10].isdigit()
):
suffix = "00:00:00" if edge == "from" else "23:59:59"
return f"{value} {suffix}"
return value
def _window_sort_key(value: str) -> str | None:
text = " ".join(value.strip().replace("T", " ", 1).split())
if len(text) < WINDOW_INSTANT_LENGTH:
return None
key = text[:WINDOW_INSTANT_LENGTH]
if (
key[4] == "-"
and key[7] == "-"
and key[10] == " "
and key[13] == ":"
and key[16] == ":"
and key[:4].isdigit()
and key[5:7].isdigit()
and key[8:10].isdigit()
and key[11:13].isdigit()
and key[14:16].isdigit()
and key[17:19].isdigit()
):
return key
return None
def _intersect_query_window(
requested_from: str,
requested_to: str,
available_from: str,
available_to: str,
) -> tuple[str, str] | None:
req_from = _window_sort_key(requested_from)
req_to = _window_sort_key(requested_to)
avail_from = _window_sort_key(available_from)
avail_to = _window_sort_key(available_to)
if req_from is None or req_to is None or avail_from is None or avail_to is None:
return None
clipped_from = max(req_from, avail_from)
clipped_to = min(req_to, avail_to)
if clipped_from > clipped_to:
return None
return clipped_from, clipped_to
def _query_window_bounds(query: dict[str, str], error: SkillError) -> tuple[str, str, str, str] | None:
if error.details.get("status") != 422:
return None
available_from = error.details.get("available_from_at")
available_to = error.details.get("available_to_at")
requested_from = error.details.get("requested_from_at") or query.get("from")
requested_to = error.details.get("requested_to_at") or query.get("to")
if not all(isinstance(value, str) and value for value in (requested_from, requested_to, available_from, available_to)):
return None
return requested_from, requested_to, available_from, available_to
def _retry_query_after_unavailable_window(query: dict[str, str], error: SkillError) -> dict[str, str] | None:
bounds = _query_window_bounds(query, error)
if bounds is None or query.get("cursor"):
return None
clipped = _intersect_query_window(*bounds)
if clipped is None:
return None
clipped_from, clipped_to = clipped
if query.get("from") == clipped_from and query.get("to") == clipped_to:
return None
return {**query, "from": clipped_from, "to": clipped_to}
def _normalize_location_name(value: str) -> str:
return " ".join(unicodedata.normalize("NFC", value).strip().split())
def _location_mapping_error(message: str) -> SkillError:
return SkillError("location_mapping_invalid", message)
def _load_location_mapping(path: pathlib.Path = LOCATION_MAPPING_PATH) -> tuple[str, list[dict[str, str]]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise _location_mapping_error("행정동 매핑 reference를 읽을 수 없습니다.") from exc
if not isinstance(payload, dict) or payload.get("mapping_version") != LOCATION_MAPPING_VERSION:
raise _location_mapping_error("행정동 매핑 버전이 지원 계약과 다릅니다.")
if not isinstance(payload.get("source"), str) or not payload["source"]:
raise _location_mapping_error("행정동 매핑의 source가 올바르지 않습니다.")
if not isinstance(payload.get("generated_at"), str) or not payload["generated_at"]:
raise _location_mapping_error("행정동 매핑의 generated_at이 올바르지 않습니다.")
locations = payload.get("locations")
if not isinstance(locations, list) or len(locations) != LOCATION_MAPPING_SIZE:
raise _location_mapping_error("행정동 매핑 행 수가 지원 계약과 다릅니다.")
normalized: list[dict[str, str]] = []
place_ids: set[str] = set()
location_keys: set[tuple[str, str]] = set()
for row in locations:
if not isinstance(row, dict) or set(row) != {"admin_dong", "gu", "place_id"}:
raise _location_mapping_error("행정동 매핑 행의 필드 계약이 올바르지 않습니다.")
if not all(isinstance(row[name], str) and row[name].strip() for name in ("admin_dong", "gu", "place_id")):
raise _location_mapping_error("행정동 매핑 행에 비어 있거나 문자열이 아닌 값이 있습니다.")
admin_dong = _normalize_location_name(row["admin_dong"])
gu = _normalize_location_name(row["gu"])
place_id = row["place_id"].strip()
if not place_id.startswith("seoul_admd_") or len(place_id) != len("seoul_admd_") + 10 or not place_id.removeprefix("seoul_admd_").isdigit():
raise _location_mapping_error("행정동 매핑의 place_id 형식이 올바르지 않습니다.")
if place_id in place_ids or (admin_dong, gu) in location_keys:
raise _location_mapping_error("행정동 매핑에 중복된 place_id 또는 행정동·자치구가 있습니다.")
place_ids.add(place_id)
location_keys.add((admin_dong, gu))
normalized.append({"admin_dong": admin_dong, "gu": gu, "place_id": place_id})
return payload["mapping_version"], normalized
def _alias_keys(canonical: str) -> set[str]:
compact = re.sub(r"\s+", "", canonical)
without_je = re.sub(r"제(?=\d)", "", compact)
return {
compact,
without_je,
compact.replace(".", "·"),
compact.replace(".", ""),
without_je.replace(".", "·"),
without_je.replace(".", ""),
}
def _location_indexes(locations: list[dict[str, str]]) -> tuple[dict[str, list[dict[str, str]]], dict[str, list[dict[str, str]]]]:
canonical_index: dict[str, list[dict[str, str]]] = {}
alias_index: dict[str, list[dict[str, str]]] = {}
for row in locations:
canonical_index.setdefault(row["admin_dong"], []).append(row)
for alias in _alias_keys(row["admin_dong"]):
alias_index.setdefault(alias, []).append(row)
return canonical_index, alias_index
def _resolve_admin_dong(admin_dong: str, gu: str | None = None) -> dict[str, str]:
normalized_dong = _normalize_location_name(admin_dong)
normalized_gu = _normalize_location_name(gu) if gu is not None else None
_version, locations = _load_location_mapping()
canonical_index, alias_index = _location_indexes(locations)
candidates = canonical_index.get(normalized_dong)
if candidates is None:
candidates = alias_index.get(re.sub(r"\s+", "", normalized_dong))
if not candidates:
raise SkillError("unknown_admin_dong", f"지원하는 서울 행정동이 아닙니다: {normalized_dong}")
candidates = list(candidates)
if normalized_gu is not None:
known_gus = {row["gu"] for row in locations}
if normalized_gu not in known_gus:
raise SkillError("unknown_gu", f"지원하는 서울 자치구가 아닙니다: {normalized_gu}")
candidates = [row for row in candidates if row["gu"] == normalized_gu]
if not candidates:
raise SkillError("unknown_admin_dong", f"{normalized_gu}의 지원 행정동이 아닙니다: {normalized_dong}")
candidates.sort(key=lambda item: (item["gu"], item["place_id"]))
if len(candidates) > 1:
raise SkillError(
"ambiguous_admin_dong",
"동명이므로 자치구를 함께 입력해야 합니다.",
{"candidates": candidates},
)
return candidates[0]
def _validate_product_id(product_id: str) -> None:
if product_id not in EXACT_PRODUCT_IDS:
raise SkillError("unknown_product", f"지원하지 않는 product_id입니다: {product_id}")
def _bundle(config: ApiConfig) -> dict[str, Any]:
return _validate_bundle(_request_json(config, f"{PROXY_ROUTE_ROOT}/bundle"))
def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
_validate_product_id(product_id)
return _validate_product(_request_json(config, f"{PROXY_ROUTE_ROOT}/product"), product_id)
def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
_validate_product_id(product_id)
try:
payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", query)
except SkillError as exc:
retry_query = _retry_query_after_unavailable_window(query, exc)
if retry_query is None:
bounds = _query_window_bounds(query, exc)
if bounds is not None and _intersect_query_window(*bounds) is None:
raise SkillError(
"query_window_unavailable",
"요청한 조회 구간이 현재 제공 가능한 예보 window와 겹치지 않습니다.",
exc.details,
) from exc
raise
payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", retry_query)
return _validate_data(payload, product_id, limit)
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="seoul-weather-risk ASK Seoul HTTPS client")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("preflight", help="live API 환경 설정 확인(네트워크 호출 없음)")
commands.add_parser("catalog", help="weather_place_risk_window bundle 조회")
describe = commands.add_parser("describe", help="제품 metadata 조회")
describe.add_argument("--product-id", required=True)
query = commands.add_parser("query", help="제품 data page 조회")
query.add_argument("--fast", action="store_true", help="bundle/product metadata 확인을 생략하고 data만 조회")
query.add_argument("--product-id", required=True)
query.add_argument("--filter", action="append", default=[], metavar="COLUMN=VALUE")
query.add_argument("--admin-dong", help="서울 행정동 이름")
query.add_argument("--gu", help="동명이명 해소용 서울 자치구 이름")
query.add_argument("--from", dest="from_value")
query.add_argument("--to", dest="to_value")
query.add_argument("--limit", type=int, default=100)
query.add_argument("--cursor")
return parser
def run(argv: list[str]) -> int:
args = _parser().parse_args(argv)
try:
config = _api_config()
if args.command == "preflight":
result = {
"status": "ok",
"mode": "hosted_proxy",
"live_network": False,
"proxy_base_url_configured": True,
}
elif args.command == "catalog":
result = _bundle(config)
elif args.command == "describe":
_bundle(config)
result = _detail(config, args.product_id)
else:
if not 1 <= args.limit <= 500:
raise SkillError("invalid_limit", "limit은 1부터 500 사이여야 합니다.")
if args.fast and args.filter:
raise SkillError("fast_query_filter_unsupported", "--fast에서는 --filter를 사용할 수 없습니다.")
detail: dict[str, Any] | None = None
if not args.fast:
_bundle(config)
detail = _detail(config, args.product_id)
filters = _filters(args.filter)
if args.gu is not None and args.admin_dong is None:
raise SkillError("invalid_location_input", "--gu는 --admin-dong과 함께 사용해야 합니다.")
if args.admin_dong is not None:
if not _normalize_location_name(args.admin_dong):
raise SkillError("invalid_location_input", "--admin-dong은 비어 있을 수 없습니다.")
if args.gu is not None and not _normalize_location_name(args.gu):
raise SkillError("invalid_location_input", "--gu는 비어 있을 수 없습니다.")
if "place_id" in filters:
raise SkillError(
"conflicting_location_input",
"--admin-dong과 place_id 필터는 동시에 사용할 수 없습니다.",
)
resolved = _resolve_admin_dong(args.admin_dong, args.gu)
filters["place_id"] = resolved["place_id"]
if detail is not None:
allowed_columns = {column["name"] for column in detail["metadata"].get("columns", [])}
unknown = sorted(set(filters) - allowed_columns)
if unknown:
raise SkillError("unknown_filter", f"공개 projection에 없는 필터입니다: {', '.join(unknown)}")
request_query = {**filters, "limit": str(args.limit)}
if args.from_value is not None:
request_query["from"] = _time_bound(args.from_value, "from")
if args.to_value is not None:
request_query["to"] = _time_bound(args.to_value, "to")
if args.cursor is not None:
request_query["cursor"] = args.cursor
result = _data(config, args.product_id, request_query, args.limit)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except SkillError as exc:
error = {"code": exc.code, "message": exc.message}
if exc.details:
error["details"] = exc.details
print(json.dumps({"error": error}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(run(sys.argv[1:]))
tests/test_seoul_weather_risk.py
import contextlib
import importlib.util
import io
import json
import os
import pathlib
import re
import sys
import tempfile
import threading
import unittest
import unicodedata
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
ROOT = pathlib.Path(__file__).resolve().parents[2]
MODULE_PATH = ROOT / "seoul-weather-risk" / "scripts" / "seoul_weather_risk.py"
SPEC = importlib.util.spec_from_file_location("seoul_weather_risk", MODULE_PATH)
seoul_weather_risk = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = seoul_weather_risk
SPEC.loader.exec_module(seoul_weather_risk)
PRODUCT_ID = "weather_place_risk_window"
PRODUCT_IDS = sorted(seoul_weather_risk.EXACT_PRODUCT_IDS)
def bundle(product_ids=PRODUCT_IDS):
return {
"bundle_id": "seoul-weather-risk",
"registration_ready": True,
"products": [
{"product_id": product_id, "publication_id": "publication-1", "registration_ready": True, "blockers": []}
for product_id in product_ids
],
}
def detail(product_id=PRODUCT_ID):
return {
"bundle_id": "seoul-weather-risk",
"product_id": product_id,
"publication_id": "publication-1",
"registration_ready": True,
"blockers": [],
"metadata": {
"columns": [
{"name": "place_id", "type": "string"},
{"name": "forecast_at", "type": "string"},
{"name": "risk_labels", "type": "string"},
]
},
}
def data(product_id=PRODUCT_ID, limit=100):
return {
"bundle_id": "seoul-weather-risk",
"product_id": product_id,
"publication_id": "publication-1",
"row_count": 1,
"limit": limit,
"has_more": False,
"next_cursor": None,
"rows": [{"place_id": "place-a", "forecast_at": "2026-08-05T09:00:00+09:00", "risk_labels": "폭염후보"}],
}
class MockApi:
def __init__(self):
self.responses = {}
self.requests = []
self.server = ThreadingHTTPServer(("127.0.0.1", 0), self.handler())
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
def handler(self):
api = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
api.requests.append({"path": parsed.path, "query": query, "authorization": self.headers.get("Authorization")})
entry = api.responses.get(parsed.path, (404, "application/problem+json", {"code": "unknown_product", "detail": "not found"}, {}))
status, content_type, body, headers = entry(query) if callable(entry) else entry
encoded = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
for name, value in headers.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(encoded)
def log_message(self, *_args):
return
return Handler
@property
def base_url(self):
return f"http://127.0.0.1:{self.server.server_port}/"
def start(self):
self.thread.start()
return self
def close(self):
self.server.shutdown()
self.server.server_close()
self.thread.join()
class QueryWindowClipTests(unittest.TestCase):
def test_window_sort_key_normalizes_iso_and_timezone_suffix(self):
self.assertEqual(
seoul_weather_risk._window_sort_key("2026-08-24T17:00:00+09:00"),
"2026-08-24 17:00:00",
)
self.assertEqual(
seoul_weather_risk._window_sort_key("2026-08-24 17:00:00"),
"2026-08-24 17:00:00",
)
def test_intersect_query_window_clips_to_overlap(self):
self.assertEqual(
seoul_weather_risk._intersect_query_window(
"2026-08-24 00:00:00",
"2026-08-24 23:59:59",
"2026-08-24 17:00:00",
"2026-08-27 00:00:00",
),
("2026-08-24 17:00:00", "2026-08-24 23:59:59"),
)
def test_intersect_query_window_returns_none_when_ranges_do_not_overlap(self):
self.assertIsNone(
seoul_weather_risk._intersect_query_window(
"2026-08-11 00:00:00",
"2026-08-17 23:59:59",
"2026-08-24 17:00:00",
"2026-08-27 00:00:00",
)
)
class ApiClientTests(unittest.TestCase):
def setUp(self):
self.api = MockApi().start()
self.proxy_base_url_env = "KSKILL_PROXY_BASE_URL"
self.previous = {name: os.environ.get(name) for name in (self.proxy_base_url_env, "KSKILL_SEOUL_WEATHER_RISK_API_KEY")}
os.environ[self.proxy_base_url_env] = self.api.base_url
os.environ["KSKILL_SEOUL_WEATHER_RISK_API_KEY"] = "legacy-user-key-must-not-be-used"
self.api.responses = {
"/v1/ask-seoul/weather-risk/bundle": (200, "application/json", bundle(), {}),
"/v1/ask-seoul/weather-risk/product": (200, "application/json", detail(), {}),
"/v1/ask-seoul/weather-risk/data": (200, "application/json", data(), {}),
}
def tearDown(self):
self.api.close()
for name, value in self.previous.items():
if value is None:
os.environ.pop(name, None)
else:
os.environ[name] = value
def test_query_uses_narrow_proxy_paths_without_user_bearer_auth(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run([
"query", "--product-id", PRODUCT_ID, "--filter", "place_id=place-a",
"--from", "2026-08-01", "--to", "2026-08-05", "--limit", "100", "--cursor", "cursor-1",
])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout.getvalue())["row_count"], 1)
self.assertEqual([request["path"] for request in self.api.requests], [
"/v1/ask-seoul/weather-risk/bundle",
"/v1/ask-seoul/weather-risk/product",
"/v1/ask-seoul/weather-risk/data",
])
query = self.api.requests[-1]["query"]
self.assertEqual(query, {
"place_id": ["place-a"],
"from": ["2026-08-01 00:00:00"],
"to": ["2026-08-05 23:59:59"],
"limit": ["100"],
"cursor": ["cursor-1"],
})
self.assertTrue(all(request["authorization"] is None for request in self.api.requests))
def test_fast_query_uses_only_data_route_without_metadata_round_trips(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run([
"query", "--fast", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--from", "2026-08-05", "--to", "2026-08-05", "--limit", "100",
])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout.getvalue())["row_count"], 1)
self.assertEqual([request["path"] for request in self.api.requests], [
"/v1/ask-seoul/weather-risk/data",
])
self.assertEqual(self.api.requests[0]["query"], {
"place_id": ["seoul_admd_1171065000"],
"from": ["2026-08-05 00:00:00"],
"to": ["2026-08-05 23:59:59"],
"limit": ["100"],
})
self.assertIsNone(self.api.requests[0]["authorization"])
def test_local_direct_settings_are_ignored_and_hosted_proxy_remains_the_only_route(self):
with patch.dict(os.environ, {
"KSKILL_LOCAL_DIRECT": "1",
"ASK_SEOUL_SKILL_API_BASE_URL": self.api.base_url,
"MARKETPLACE_API_KEY": "legacy-marketplace-key-must-not-be-used",
}, clear=False):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run(["catalog"])
self.assertEqual(code, 0)
self.assertEqual([request["path"] for request in self.api.requests], [
"/v1/ask-seoul/weather-risk/bundle",
])
self.assertIsNone(self.api.requests[0]["authorization"])
self.assertNotIn("legacy-marketplace-key-must-not-be-used", stdout.getvalue())
def test_query_keeps_explicit_datetime_bounds_unchanged(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run([
"query", "--product-id", PRODUCT_ID, "--filter", "place_id=place-a",
"--from", "2026-08-01 09:00:00", "--to", "2026-08-01 18:00:00", "--limit", "100",
])
self.assertEqual(code, 0)
self.assertEqual(self.api.requests[-1]["query"], {
"place_id": ["place-a"],
"from": ["2026-08-01 09:00:00"],
"to": ["2026-08-01 18:00:00"],
"limit": ["100"],
})
def test_query_clips_calendar_day_to_available_window(self):
def data_response(query):
if query.get("from") == ["2026-08-24 00:00:00"] and query.get("to") == ["2026-08-24 23:59:59"]:
return (422, "application/problem+json", {
"title": "query window unavailable",
"status": 422,
"detail": {
"requested_from_at": "2026-08-24 00:00:00",
"requested_to_at": "2026-08-24 23:59:59",
"available_from_at": "2026-08-24 17:00:00",
"available_to_at": "2026-08-27 00:00:00",
"publication_id": "publication-1",
},
}, {})
if query.get("from") == ["2026-08-24 17:00:00"] and query.get("to") == ["2026-08-24 23:59:59"]:
return (200, "application/json", data(), {})
return (500, "application/json", {"error": "unexpected query"}, {})
self.api.responses["/v1/ask-seoul/weather-risk/data"] = data_response
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run([
"query", "--fast", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--from", "2026-08-24", "--to", "2026-08-24", "--limit", "100",
])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout.getvalue())["row_count"], 1)
self.assertEqual([request["path"] for request in self.api.requests], [
"/v1/ask-seoul/weather-risk/data",
"/v1/ask-seoul/weather-risk/data",
])
self.assertEqual(self.api.requests[0]["query"]["from"], ["2026-08-24 00:00:00"])
self.assertEqual(self.api.requests[0]["query"]["to"], ["2026-08-24 23:59:59"])
self.assertEqual(self.api.requests[1]["query"], {
"place_id": ["seoul_admd_1171065000"],
"from": ["2026-08-24 17:00:00"],
"to": ["2026-08-24 23:59:59"],
"limit": ["100"],
})
def test_query_window_without_overlap_fails_with_available_bounds(self):
self.api.responses["/v1/ask-seoul/weather-risk/data"] = (
422,
"application/problem+json",
{
"title": "query window unavailable",
"status": 422,
"request_id": "req-window",
"detail": {
"requested_from_at": "2026-08-11 00:00:00",
"requested_to_at": "2026-08-17 23:59:59",
"available_from_at": "2026-08-24 17:00:00",
"available_to_at": "2026-08-27 00:00:00",
"publication_id": "publication-1",
},
},
{},
)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run([
"query", "--fast", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--from", "2026-08-11", "--to", "2026-08-17",
])
error = json.loads(stderr.getvalue())["error"]
self.assertEqual(code, 2)
self.assertEqual(error["code"], "query_window_unavailable")
self.assertIn("겹치지 않습니다", error["message"])
self.assertEqual(error["details"]["available_from_at"], "2026-08-24 17:00:00")
self.assertEqual(error["details"]["available_to_at"], "2026-08-27 00:00:00")
self.assertEqual(error["details"]["request_id"], "req-window")
self.assertEqual(len(self.api.requests), 1)
def test_query_window_problem_without_available_bounds_does_not_retry(self):
self.api.responses["/v1/ask-seoul/weather-risk/data"] = (
422,
"application/problem+json",
{"title": "query window unavailable", "detail": "safe problem detail", "code": "query_window_unavailable"},
{},
)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run([
"query", "--fast", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--from", "2026-08-24", "--to", "2026-08-24",
])
error = json.loads(stderr.getvalue())["error"]
self.assertEqual(code, 2)
self.assertEqual(error["code"], "query_window_unavailable")
self.assertEqual(error["message"], "safe problem detail")
self.assertEqual(len(self.api.requests), 1)
def test_paged_query_does_not_retry_unavailable_window(self):
self.api.responses["/v1/ask-seoul/weather-risk/data"] = (
422,
"application/problem+json",
{
"title": "query window unavailable",
"status": 422,
"detail": {
"requested_from_at": "2026-08-24 00:00:00",
"requested_to_at": "2026-08-24 23:59:59",
"available_from_at": "2026-08-24 17:00:00",
"available_to_at": "2026-08-27 00:00:00",
},
},
{},
)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run([
"query", "--fast", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--from", "2026-08-24", "--to", "2026-08-24",
"--cursor", "cursor-1",
])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "query_window_unavailable")
self.assertEqual(len(self.api.requests), 1)
def test_query_maps_admin_dong_to_place_id_before_proxy_request(self):
self.api.responses["/v1/ask-seoul/weather-risk/data"] = (
200, "application/json", data(limit=1), {},
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run([
"query", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--limit", "1",
])
self.assertEqual(code, 0)
query = self.api.requests[-1]["query"]
self.assertEqual(query, {
"place_id": ["seoul_admd_1171065000"],
"limit": ["1"],
})
self.assertNotIn("admin_dong", query)
self.assertNotIn("gu", query)
def test_query_rejects_gu_without_admin_dong(self):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run([
"query", "--product-id", PRODUCT_ID, "--gu", "송파구",
])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "invalid_location_input")
self.assertEqual(len(self.api.requests), 2)
def test_query_rejects_admin_dong_with_place_id_filter(self):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run([
"query", "--product-id", PRODUCT_ID,
"--admin-dong", "잠실본동", "--filter", "place_id=place-a",
])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "conflicting_location_input")
self.assertEqual(len(self.api.requests), 2)
def test_bundle_single_product_drift_fails_closed(self):
self.api.responses["/v1/ask-seoul/weather-risk/bundle"] = (200, "application/json", bundle([]), {})
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run(["catalog"])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "response_contract_invalid")
def test_malformed_success_response_fails_closed(self):
self.api.responses["/v1/ask-seoul/weather-risk/bundle"] = (200, "application/json", b"not-json", {})
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run(["catalog"])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "malformed_response")
def test_http_problem_statuses_are_typed_and_preserve_safe_details(self):
endpoint = "/v1/ask-seoul/weather-risk/bundle"
cases = {
401: "api_key_missing",
403: "api_key_forbidden",
404: "unknown_product",
409: "cursor_expired",
429: "rate_limited",
503: "product_not_ready",
}
for status, expected_code in cases.items():
with self.subTest(status=status):
headers = {"Retry-After": "60"} if status == 429 else {}
self.api.responses[endpoint] = (status, "application/problem+json", {
"title": "API failure", "detail": "safe problem detail", "code": expected_code,
"request_id": "req-1", "product_id": PRODUCT_ID,
}, headers)
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run(["catalog"])
error = json.loads(stderr.getvalue())["error"]
self.assertEqual(code, 2)
self.assertEqual(error["code"], expected_code)
self.assertEqual(error["details"]["status"], status)
self.assertEqual(error["details"]["request_id"], "req-1")
if status == 429:
self.assertEqual(error["details"]["retry_after"], "60")
def test_disabled_proxy_never_echoes_legacy_user_credentials(self):
os.environ[self.proxy_base_url_env] = "off"
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run(["preflight"])
self.assertEqual(code, 2)
self.assertNotIn("legacy-user-key-must-not-be-used", stderr.getvalue())
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "proxy_disabled")
def test_non_https_base_url_is_rejected_except_loopback_mock(self):
with self.assertRaisesRegex(seoul_weather_risk.SkillError, "HTTPS"):
seoul_weather_risk._api_config({
self.proxy_base_url_env: "http://example.test",
})
config = seoul_weather_risk._api_config({
self.proxy_base_url_env: self.api.base_url,
})
self.assertFalse(config.base_url.endswith("/"))
with self.assertRaisesRegex(seoul_weather_risk.SkillError, "origin"):
seoul_weather_risk._api_config({
self.proxy_base_url_env: "https://api.example.test/untrusted-path",
})
def test_redirect_is_not_followed_through_proxy_client(self):
endpoint = "/v1/ask-seoul/weather-risk/bundle"
self.api.responses[endpoint] = (302, "application/problem+json", {"detail": "redirect blocked"}, {
"Location": f"{self.api.base_url}redirect-target",
})
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
code = seoul_weather_risk.run(["catalog"])
self.assertEqual(code, 2)
self.assertEqual(json.loads(stderr.getvalue())["error"]["code"], "api_error")
self.assertEqual([request["path"] for request in self.api.requests], [endpoint])
class LocationMappingTests(unittest.TestCase):
def test_admin_dong_reference_has_expected_version_and_unique_place_ids(self):
mapping_path = ROOT / "seoul-weather-risk" / "references" / "admin-dong-place-map.json"
payload = json.loads(mapping_path.read_text(encoding="utf-8"))
self.assertEqual(payload["mapping_version"], "kma_admin_dong_grid_20260325")
self.assertEqual(len(payload["locations"]), 427)
self.assertEqual(len({row["place_id"] for row in payload["locations"]}), 427)
self.assertEqual(
sorted(row["gu"] for row in payload["locations"] if row["admin_dong"] == "신사동"),
["강남구", "관악구"],
)
def test_resolve_admin_dong_returns_canonical_place_id(self):
resolved = seoul_weather_risk._resolve_admin_dong(" 잠실본동 ")
self.assertEqual(resolved, {
"admin_dong": "잠실본동",
"gu": "송파구",
"place_id": "seoul_admd_1171065000",
})
def test_resolve_admin_dong_normalizes_unicode_nfc(self):
resolved = seoul_weather_risk._resolve_admin_dong(unicodedata.normalize("NFD", "잠실본동"))
self.assertEqual(resolved["place_id"], "seoul_admd_1171065000")
def test_resolve_admin_dong_normalizes_internal_whitespace(self):
resolved = seoul_weather_risk._resolve_admin_dong(unicodedata.normalize("NFD", " 잠실 본동 "))
self.assertEqual(resolved, {
"admin_dong": "잠실본동",
"gu": "송파구",
"place_id": "seoul_admd_1171065000",
})
def test_resolve_admin_dong_recognizes_map_derived_je_alias(self):
resolved = seoul_weather_risk._resolve_admin_dong("성수2가3동")
self.assertEqual(resolved, {
"admin_dong": "성수2가제3동",
"gu": "성동구",
"place_id": "seoul_admd_1120069000",
})
def test_resolve_admin_dong_resolves_every_map_derived_je_variant_with_gu(self):
_version, locations = seoul_weather_risk._load_location_mapping()
je_rows = [row for row in locations if "제" in row["admin_dong"]]
self.assertTrue(je_rows)
for row in je_rows:
with self.subTest(row=row):
alias = re.sub(r"제(?=\d)", "", row["admin_dong"])
self.assertEqual(seoul_weather_risk._resolve_admin_dong(alias, row["gu"]), row)
def test_resolve_admin_dong_does_not_omit_non_numeric_je(self):
self.assertEqual(
seoul_weather_risk._resolve_admin_dong("제기동", "동대문구")["place_id"],
"seoul_admd_1123054500",
)
with self.assertRaises(seoul_weather_risk.SkillError) as raised:
seoul_weather_risk._resolve_admin_dong("기동", "동대문구")
self.assertEqual(raised.exception.code, "unknown_admin_dong")
def test_resolve_admin_dong_accepts_numeric_punctuation_aliases(self):
expected = "seoul_admd_1111061500"
self.assertEqual(
seoul_weather_risk._resolve_admin_dong("종로1.2.3.4가동")["place_id"],
expected,
)
self.assertEqual(
seoul_weather_risk._resolve_admin_dong("종로1·2·3·4가동")["place_id"],
expected,
)
self.assertEqual(
seoul_weather_risk._resolve_admin_dong("종로1234가동")["place_id"],
expected,
)
def test_resolve_admin_dong_resolves_every_dot_variant_with_gu(self):
_version, locations = seoul_weather_risk._load_location_mapping()
dotted_rows = [row for row in locations if "." in row["admin_dong"]]
self.assertTrue(dotted_rows)
for row in dotted_rows:
with self.subTest(row=row, variant="canonical"):
self.assertEqual(seoul_weather_risk._resolve_admin_dong(row["admin_dong"], row["gu"]), row)
with self.subTest(row=row, variant="middle_dot"):
self.assertEqual(seoul_weather_risk._resolve_admin_dong(row["admin_dong"].replace(".", "·"), row["gu"]), row)
with self.subTest(row=row, variant="omitted_dot"):
self.assertEqual(seoul_weather_risk._resolve_admin_dong(row["admin_dong"].replace(".", ""), row["gu"]), row)
def test_alias_keys_generate_combined_je_and_punctuation_variants(self):
_version, locations = seoul_weather_risk._load_location_mapping()
row = next(row for row in locations if row["admin_dong"] == "면목제3.8동")
aliases = seoul_weather_risk._alias_keys(row["admin_dong"])
self.assertTrue({"면목3·8동", "면목38동"}.issubset(aliases))
def test_resolve_admin_dong_resolves_every_generated_map_alias_with_gu(self):
_version, locations = seoul_weather_risk._load_location_mapping()
checked_aliases = 0
for row in locations:
for alias in seoul_weather_risk._alias_keys(row["admin_dong"]):
if alias == row["admin_dong"]:
continue
with self.subTest(row=row, alias=alias):
self.assertEqual(seoul_weather_risk._resolve_admin_dong(alias, row["gu"]), row)
checked_aliases += 1
self.assertGreater(checked_aliases, 0)
def test_location_indexes_retain_colliding_aliases_and_resolve_exact_first(self):
alias_source = {"admin_dong": "예제1동", "gu": "가구", "place_id": "seoul_admd_0000000001"}
canonical_match = {"admin_dong": "예1동", "gu": "나구", "place_id": "seoul_admd_0000000002"}
locations = [alias_source, canonical_match]
canonical_index, alias_index = seoul_weather_risk._location_indexes(locations)
self.assertEqual(canonical_index["예1동"], [canonical_match])
self.assertEqual(alias_index["예1동"], locations)
with patch.object(seoul_weather_risk, "_load_location_mapping", return_value=("test", locations)):
self.assertEqual(seoul_weather_risk._resolve_admin_dong("예1동"), canonical_match)
with self.assertRaises(seoul_weather_risk.SkillError) as raised:
seoul_weather_risk._resolve_admin_dong("예 1동")
self.assertEqual(raised.exception.code, "ambiguous_admin_dong")
self.assertEqual(raised.exception.details["candidates"], locations)
self.assertEqual(seoul_weather_risk._resolve_admin_dong("예 1동", "가구"), alias_source)
def test_resolve_admin_dong_requires_gu_for_duplicate_name(self):
with self.assertRaises(seoul_weather_risk.SkillError) as raised:
seoul_weather_risk._resolve_admin_dong("신사동")
self.assertEqual(raised.exception.code, "ambiguous_admin_dong")
self.assertEqual(raised.exception.details["candidates"], [
{"admin_dong": "신사동", "gu": "강남구", "place_id": "seoul_admd_1168051000"},
{"admin_dong": "신사동", "gu": "관악구", "place_id": "seoul_admd_1162068500"},
])
def test_resolve_admin_dong_uses_gu_to_disambiguate(self):
resolved = seoul_weather_risk._resolve_admin_dong("신사동", "강남구")
self.assertEqual(resolved["place_id"], "seoul_admd_1168051000")
def test_resolve_admin_dong_rejects_unknown_or_broad_dong_and_gu(self):
with self.assertRaises(seoul_weather_risk.SkillError) as unknown_dong:
seoul_weather_risk._resolve_admin_dong("없는동")
self.assertEqual(unknown_dong.exception.code, "unknown_admin_dong")
for admin_dong in ("성수동", "종로"):
with self.subTest(admin_dong=admin_dong):
with self.assertRaises(seoul_weather_risk.SkillError) as broad_dong:
seoul_weather_risk._resolve_admin_dong(admin_dong)
self.assertEqual(broad_dong.exception.code, "unknown_admin_dong")
with self.assertRaises(seoul_weather_risk.SkillError) as unknown_gu:
seoul_weather_risk._resolve_admin_dong("잠실본동", "없는구")
self.assertEqual(unknown_gu.exception.code, "unknown_gu")
with self.assertRaises(seoul_weather_risk.SkillError) as wrong_gu:
seoul_weather_risk._resolve_admin_dong("잠실본동", "강남구")
self.assertEqual(wrong_gu.exception.code, "unknown_admin_dong")
def test_load_location_mapping_rejects_invalid_reference(self):
with tempfile.TemporaryDirectory() as directory:
invalid_path = pathlib.Path(directory) / "mapping.json"
invalid_path.write_text(json.dumps({
"mapping_version": "wrong-version",
"locations": [],
}), encoding="utf-8")
with self.assertRaises(seoul_weather_risk.SkillError) as raised:
seoul_weather_risk._load_location_mapping(invalid_path)
self.assertEqual(raised.exception.code, "location_mapping_invalid")
class CliTests(unittest.TestCase):
def test_preflight_is_user_secret_free_and_offline(self):
proxy_base_url_env = "KSKILL_PROXY_BASE_URL"
previous = {name: os.environ.get(name) for name in (proxy_base_url_env, "KSKILL_SEOUL_WEATHER_RISK_API_KEY")}
self.addCleanup(lambda: [os.environ.pop(name, None) if value is None else os.environ.__setitem__(name, value) for name, value in previous.items()])
os.environ[proxy_base_url_env] = "https://proxy.example.test/"
os.environ["KSKILL_SEOUL_WEATHER_RISK_API_KEY"] = "legacy-user-key-must-not-be-used"
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
code = seoul_weather_risk.run(["preflight"])
self.assertEqual(code, 0)
payload = json.loads(stdout.getvalue())
self.assertEqual(payload["mode"], "hosted_proxy")
self.assertFalse(payload["live_network"])
self.assertTrue(payload["proxy_base_url_configured"])
self.assertEqual(set(payload), {"status", "mode", "live_network", "proxy_base_url_configured"})
self.assertNotIn("legacy-user-key-must-not-be-used", stdout.getvalue())
def test_parser_has_no_credential_or_base_url_option(self):
parser = seoul_weather_risk._parser()
option_strings = {option for action in parser._actions for option in action.option_strings}
self.assertFalse(any("key" in option.lower() or "url" in option.lower() for option in option_strings))
if __name__ == "__main__":
unittest.main()