@nomadamas/k-skill 0.1.0
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 +60 -0
- package/package.json +37 -0
- package/skills/assembly-bill-vote-search/instruction.md +111 -0
- package/skills/assembly-bill-vote-search/skill.json +10 -0
- package/skills/biz-health-check/instruction.md +69 -0
- package/skills/biz-health-check/scripts/biz_health_check.py +163 -0
- package/skills/biz-health-check/skill.json +10 -0
- package/skills/bok-ecos-stats/instruction.md +95 -0
- package/skills/bok-ecos-stats/scripts/bok_ecos.py +302 -0
- package/skills/bok-ecos-stats/skill.json +10 -0
- package/skills/building-register-search/instruction.md +69 -0
- package/skills/building-register-search/scripts/building_register.py +309 -0
- package/skills/building-register-search/scripts/building_register_xml.py +32 -0
- package/skills/building-register-search/skill.json +9 -0
- package/skills/bunjang-search/instruction.md +154 -0
- package/skills/bunjang-search/skill.json +10 -0
- package/skills/catchtable-sniper/instruction.md +270 -0
- package/skills/catchtable-sniper/skill.json +9 -0
- package/skills/cheap-gas-nearby/instruction.md +107 -0
- package/skills/cheap-gas-nearby/skill.json +10 -0
- package/skills/corporate-registration-consulting/instruction.md +117 -0
- package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +106 -0
- package/skills/corporate-registration-consulting/skill.json +8 -0
- package/skills/coupang-product-search/instruction.md +219 -0
- package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +146 -0
- package/skills/coupang-product-search/skill.json +10 -0
- package/skills/court-auction-notice-search/instruction.md +201 -0
- package/skills/court-auction-notice-search/skill.json +9 -0
- package/skills/court-payment-order-assistant/instruction.md +120 -0
- package/skills/court-payment-order-assistant/skill.json +10 -0
- package/skills/d2b-notice-search/instruction.md +117 -0
- package/skills/d2b-notice-search/skill.json +10 -0
- package/skills/daangn-cars-search/instruction.md +91 -0
- package/skills/daangn-cars-search/scripts/daangn_cars.py +72 -0
- package/skills/daangn-cars-search/skill.json +9 -0
- package/skills/daangn-jobs-search/instruction.md +90 -0
- package/skills/daangn-jobs-search/scripts/daangn_jobs.py +98 -0
- package/skills/daangn-jobs-search/skill.json +9 -0
- package/skills/daangn-realty-search/instruction.md +114 -0
- package/skills/daangn-realty-search/scripts/daangn_detail_ld.py +76 -0
- package/skills/daangn-realty-search/scripts/daangn_realty.py +216 -0
- package/skills/daangn-realty-search/scripts/daangn_relay_store.py +174 -0
- package/skills/daangn-realty-search/skill.json +8 -0
- package/skills/daangn-used-goods-search/instruction.md +90 -0
- package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +80 -0
- package/skills/daangn-used-goods-search/skill.json +9 -0
- package/skills/daishin-report-search/instruction.md +138 -0
- package/skills/daishin-report-search/skill.json +10 -0
- package/skills/daiso-product-search/instruction.md +172 -0
- package/skills/daiso-product-search/skill.json +8 -0
- package/skills/danawa-price-search/instruction.md +183 -0
- package/skills/danawa-price-search/scripts/danawa_search.py +354 -0
- package/skills/danawa-price-search/skill.json +8 -0
- package/skills/delivery-tracking/instruction.md +358 -0
- package/skills/delivery-tracking/skill.json +8 -0
- package/skills/donation-place-search/instruction.md +129 -0
- package/skills/donation-place-search/skill.json +9 -0
- package/skills/emergency-room-beds/instruction.md +82 -0
- package/skills/emergency-room-beds/skill.json +8 -0
- package/skills/ev-charger-nearby/instruction.md +80 -0
- package/skills/ev-charger-nearby/scripts/ev_charger.py +222 -0
- package/skills/ev-charger-nearby/skill.json +10 -0
- package/skills/ev-subsidy-status/instruction.md +165 -0
- package/skills/ev-subsidy-status/skill.json +10 -0
- package/skills/express-bus-booking/instruction.md +207 -0
- package/skills/express-bus-booking/references/kobus-http-flow.md +159 -0
- package/skills/express-bus-booking/scripts/kobus_express_booking.py +243 -0
- package/skills/express-bus-booking/skill.json +8 -0
- package/skills/fine-dust-location/instruction.md +89 -0
- package/skills/fine-dust-location/skill.json +9 -0
- package/skills/flight-ticket-search/instruction.md +237 -0
- package/skills/flight-ticket-search/scripts/flight_ticket_search.py +501 -0
- package/skills/flight-ticket-search/skill.json +9 -0
- package/skills/foresttrip-vacancy/instruction.md +167 -0
- package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +549 -0
- package/skills/foresttrip-vacancy/skill.json +10 -0
- package/skills/fsc-corporate-info/instruction.md +57 -0
- package/skills/fsc-corporate-info/scripts/fsc_corporate_info.py +113 -0
- package/skills/fsc-corporate-info/skill.json +10 -0
- package/skills/g2b-order-plan-search/instruction.md +131 -0
- package/skills/g2b-order-plan-search/scripts/g2b_order_plan.py +158 -0
- package/skills/g2b-order-plan-search/skill.json +10 -0
- package/skills/g2b-sanctioned-supplier/instruction.md +61 -0
- package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +114 -0
- package/skills/g2b-sanctioned-supplier/skill.json +10 -0
- package/skills/gangnamunni-clinic-search/instruction.md +113 -0
- package/skills/gangnamunni-clinic-search/skill.json +9 -0
- package/skills/geeknews-search/instruction.md +69 -0
- package/skills/geeknews-search/scripts/geeknews_search.py +296 -0
- package/skills/geeknews-search/skill.json +8 -0
- package/skills/gongsijiga-search/instruction.md +128 -0
- package/skills/gongsijiga-search/skill.json +9 -0
- package/skills/gov-overseas-trip-report/instruction.md +488 -0
- package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +1206 -0
- package/skills/gov-overseas-trip-report/skill.json +9 -0
- package/skills/han-river-water-level/instruction.md +83 -0
- package/skills/han-river-water-level/skill.json +10 -0
- package/skills/highway-traffic-status/instruction.md +88 -0
- package/skills/highway-traffic-status/scripts/highway_traffic.py +315 -0
- package/skills/highway-traffic-status/skill.json +11 -0
- package/skills/hipass-receipt/instruction.md +97 -0
- package/skills/hipass-receipt/skill.json +10 -0
- package/skills/hola-poke-yeoksam/instruction.md +247 -0
- package/skills/hola-poke-yeoksam/skill.json +8 -0
- package/skills/household-waste-info/instruction.md +117 -0
- package/skills/household-waste-info/skill.json +10 -0
- package/skills/housing-official-price/instruction.md +177 -0
- package/skills/housing-official-price/skill.json +10 -0
- package/skills/hwp/instruction.md +206 -0
- package/skills/hwp/skill.json +8 -0
- package/skills/intercity-bus-booking/instruction.md +189 -0
- package/skills/intercity-bus-booking/references/tmoney-intercity-http-flow.md +126 -0
- package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +381 -0
- package/skills/intercity-bus-booking/skill.json +8 -0
- package/skills/iros-registry-automation/instruction.md +229 -0
- package/skills/iros-registry-automation/scripts/iros_pdf_summary.py +249 -0
- package/skills/iros-registry-automation/scripts/upstream.pin +1 -0
- package/skills/iros-registry-automation/skill.json +9 -0
- package/skills/job-posting-match/instruction.md +130 -0
- package/skills/job-posting-match/scripts/job_posting_match.py +396 -0
- package/skills/job-posting-match/scripts/test_job_posting_match.py +54 -0
- package/skills/job-posting-match/skill.json +8 -0
- package/skills/jobkorea-talent-search/instruction.md +118 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_models.py +27 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_parse.py +186 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +94 -0
- package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search_condition.py +136 -0
- package/skills/jobkorea-talent-search/scripts/test_jobkorea_talent_search.py +76 -0
- package/skills/jobkorea-talent-search/skill.json +9 -0
- package/skills/joseon-sillok-search/instruction.md +76 -0
- package/skills/joseon-sillok-search/scripts/sillok_search.py +552 -0
- package/skills/joseon-sillok-search/skill.json +8 -0
- package/skills/k-dart/instruction.md +406 -0
- package/skills/k-dart/skill.json +8 -0
- package/skills/k-schoollunch-menu/instruction.md +109 -0
- package/skills/k-schoollunch-menu/skill.json +9 -0
- package/skills/k-skill-cleaner/instruction.md +80 -0
- package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +410 -0
- package/skills/k-skill-cleaner/skill.json +8 -0
- package/skills/k-skill-setup/instruction.md +253 -0
- package/skills/k-skill-setup/skill.json +11 -0
- package/skills/kakao-bar-nearby/instruction.md +76 -0
- package/skills/kakao-bar-nearby/skill.json +8 -0
- package/skills/kakao-map/instruction.md +176 -0
- package/skills/kakao-map/skill.json +10 -0
- package/skills/kakaotalk-mac/instruction.md +189 -0
- package/skills/kakaotalk-mac/skill.json +8 -0
- package/skills/kbl-results/instruction.md +89 -0
- package/skills/kbl-results/skill.json +9 -0
- package/skills/kbo-results/instruction.md +82 -0
- package/skills/kbo-results/skill.json +8 -0
- package/skills/keris-academic-search/instruction.md +81 -0
- package/skills/keris-academic-search/scripts/keris_academic.py +210 -0
- package/skills/keris-academic-search/skill.json +10 -0
- package/skills/kleague-results/instruction.md +92 -0
- package/skills/kleague-results/skill.json +8 -0
- package/skills/kopis-performance-search/instruction.md +109 -0
- package/skills/kopis-performance-search/skill.json +10 -0
- package/skills/korea-weather/instruction.md +93 -0
- package/skills/korea-weather/skill.json +10 -0
- package/skills/korean-character-count/instruction.md +87 -0
- package/skills/korean-character-count/scripts/korean_character_count.js +268 -0
- package/skills/korean-character-count/skill.json +8 -0
- package/skills/korean-cinema-search/instruction.md +177 -0
- package/skills/korean-cinema-search/skill.json +8 -0
- package/skills/korean-heritage-search/instruction.md +106 -0
- package/skills/korean-heritage-search/scripts/korean_heritage_search.py +321 -0
- package/skills/korean-heritage-search/skill.json +8 -0
- package/skills/korean-holiday-calendar/instruction.md +97 -0
- package/skills/korean-holiday-calendar/skill.json +9 -0
- package/skills/korean-humanizer/instruction.md +389 -0
- package/skills/korean-humanizer/references/ai-tell-taxonomy.md +147 -0
- package/skills/korean-humanizer/skill.json +8 -0
- package/skills/korean-jangbu-for/instruction.md +133 -0
- package/skills/korean-jangbu-for/scripts/install.sh +237 -0
- package/skills/korean-jangbu-for/scripts/upstream.pin +1 -0
- package/skills/korean-jangbu-for/skill.json +8 -0
- package/skills/korean-law-search/instruction.md +126 -0
- package/skills/korean-law-search/skill.json +10 -0
- package/skills/korean-marathon-schedule/instruction.md +111 -0
- package/skills/korean-marathon-schedule/skill.json +9 -0
- package/skills/korean-middle-korean/instruction.md +79 -0
- package/skills/korean-middle-korean/scripts/korean_middle_korean.js +214 -0
- package/skills/korean-middle-korean/skill.json +8 -0
- package/skills/korean-patent-search/instruction.md +79 -0
- package/skills/korean-patent-search/scripts/patent_search.py +409 -0
- package/skills/korean-patent-search/skill.json +9 -0
- package/skills/korean-privacy-terms/instruction.md +128 -0
- package/skills/korean-privacy-terms/scripts/install.sh +108 -0
- package/skills/korean-privacy-terms/scripts/upstream.pin +1 -0
- package/skills/korean-privacy-terms/skill.json +8 -0
- package/skills/korean-scholarship-search/instruction.md +317 -0
- package/skills/korean-scholarship-search/references/report-format.md +40 -0
- package/skills/korean-scholarship-search/references/school-discovery.md +61 -0
- package/skills/korean-scholarship-search/references/search-clues.md +58 -0
- package/skills/korean-scholarship-search/references/source-patterns.md +67 -0
- package/skills/korean-scholarship-search/scripts/scholarship_filter.py +811 -0
- package/skills/korean-scholarship-search/scripts/test_scholarship_filter.py +224 -0
- package/skills/korean-scholarship-search/scripts/university_search_plan.py +148 -0
- package/skills/korean-scholarship-search/skill.json +8 -0
- package/skills/korean-slang-writing/instruction.md +181 -0
- package/skills/korean-slang-writing/scripts/_slang_http.py +91 -0
- package/skills/korean-slang-writing/scripts/slang_lookup.py +291 -0
- package/skills/korean-slang-writing/scripts/slang_search.py +284 -0
- package/skills/korean-slang-writing/skill.json +8 -0
- package/skills/korean-spell-check/instruction.md +105 -0
- package/skills/korean-spell-check/scripts/korean_spell_check.py +523 -0
- package/skills/korean-spell-check/skill.json +9 -0
- package/skills/korean-stock-search/instruction.md +194 -0
- package/skills/korean-stock-search/skill.json +10 -0
- package/skills/korean-transit-route/instruction.md +113 -0
- package/skills/korean-transit-route/skill.json +10 -0
- package/skills/kosis-stats/instruction.md +232 -0
- package/skills/kosis-stats/references/kosis-openapi-guide.md +171 -0
- package/skills/kosis-stats/scripts/run_kosis_stats.py +896 -0
- package/skills/kosis-stats/skill.json +9 -0
- package/skills/kr-whois-lookup/instruction.md +107 -0
- package/skills/kr-whois-lookup/skill.json +10 -0
- package/skills/kstartup-search/instruction.md +186 -0
- package/skills/kstartup-search/scripts/run_kstartup.py +424 -0
- package/skills/kstartup-search/skill.json +11 -0
- package/skills/ktx-booking/instruction.md +244 -0
- package/skills/ktx-booking/skill.json +10 -0
- package/skills/lck-analytics/instruction.md +192 -0
- package/skills/lck-analytics/scripts/_lib.js +103 -0
- package/skills/lck-analytics/scripts/analyze-live-game.js +52 -0
- package/skills/lck-analytics/scripts/build-match-report.js +44 -0
- package/skills/lck-analytics/scripts/sync-oracle.js +50 -0
- package/skills/lck-analytics/skill.json +9 -0
- package/skills/lh-notice-search/instruction.md +206 -0
- package/skills/lh-notice-search/skill.json +10 -0
- package/skills/library-book-search/instruction.md +139 -0
- package/skills/library-book-search/skill.json +9 -0
- package/skills/local-election-candidate-search/instruction.md +77 -0
- package/skills/local-election-candidate-search/skill.json +8 -0
- package/skills/localdata-business-status/instruction.md +64 -0
- package/skills/localdata-business-status/scripts/localdata_business_status.py +206 -0
- package/skills/localdata-business-status/skill.json +8 -0
- package/skills/lotto-results/instruction.md +80 -0
- package/skills/lotto-results/skill.json +8 -0
- package/skills/lovebug-report/instruction.md +185 -0
- package/skills/lovebug-report/skill.json +9 -0
- package/skills/market-kurly-search/instruction.md +125 -0
- package/skills/market-kurly-search/skill.json +8 -0
- package/skills/mfds-drug-safety/instruction.md +86 -0
- package/skills/mfds-drug-safety/scripts/mfds_drug_safety.py +184 -0
- package/skills/mfds-drug-safety/skill.json +10 -0
- package/skills/mfds-food-safety/instruction.md +126 -0
- package/skills/mfds-food-safety/scripts/mfds_food_safety.py +281 -0
- package/skills/mfds-food-safety/skill.json +10 -0
- package/skills/myrealtrip-search/instruction.md +239 -0
- package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +194 -0
- package/skills/myrealtrip-search/scripts/test_myrealtrip_mcp.py +99 -0
- package/skills/myrealtrip-search/skill.json +9 -0
- package/skills/naming-house/instruction.md +146 -0
- package/skills/naming-house/skill.json +8 -0
- package/skills/national-pension-workplace/instruction.md +64 -0
- package/skills/national-pension-workplace/scripts/national_pension_workplace.py +113 -0
- package/skills/national-pension-workplace/skill.json +10 -0
- package/skills/naver-ad-performance/instruction.md +108 -0
- package/skills/naver-ad-performance/scripts/naver_ad_performance.py +240 -0
- package/skills/naver-ad-performance/skill.json +9 -0
- package/skills/naver-blog-research/instruction.md +128 -0
- package/skills/naver-blog-research/scripts/_naver_http.py +58 -0
- package/skills/naver-blog-research/scripts/naver_download_images.py +233 -0
- package/skills/naver-blog-research/scripts/naver_read.py +256 -0
- package/skills/naver-blog-research/scripts/naver_search.py +192 -0
- package/skills/naver-blog-research/skill.json +8 -0
- package/skills/naver-news-search/instruction.md +103 -0
- package/skills/naver-news-search/skill.json +10 -0
- package/skills/naver-shopping-search/instruction.md +94 -0
- package/skills/naver-shopping-search/skill.json +11 -0
- package/skills/nhis-care-checkup-search/instruction.md +116 -0
- package/skills/nhis-care-checkup-search/skill.json +10 -0
- package/skills/nts-business-registration/instruction.md +115 -0
- package/skills/nts-business-registration/scripts/nts_business_registration.py +215 -0
- package/skills/nts-business-registration/skill.json +10 -0
- package/skills/nts-tax-delinquency/instruction.md +54 -0
- package/skills/nts-tax-delinquency/scripts/nts_tax_delinquency.py +150 -0
- package/skills/nts-tax-delinquency/skill.json +8 -0
- package/skills/ohou-today-deal/instruction.md +182 -0
- package/skills/ohou-today-deal/scripts/ohou_today_deal.py +369 -0
- package/skills/ohou-today-deal/skill.json +9 -0
- package/skills/olive-young-search/instruction.md +154 -0
- package/skills/olive-young-search/skill.json +8 -0
- package/skills/parking-lot-search/instruction.md +96 -0
- package/skills/parking-lot-search/skill.json +9 -0
- package/skills/popbill/instruction.md +146 -0
- package/skills/popbill/scripts/popbill_cli.py +284 -0
- package/skills/popbill/scripts/popbill_registry.py +54 -0
- package/skills/popbill/scripts/popbill_safety.py +37 -0
- package/skills/popbill/scripts/popbill_templates.py +71 -0
- package/skills/popbill/skill.json +9 -0
- package/skills/public-restroom-nearby/instruction.md +89 -0
- package/skills/public-restroom-nearby/skill.json +9 -0
- package/skills/real-estate-search/instruction.md +172 -0
- package/skills/real-estate-search/skill.json +10 -0
- package/skills/rhwp-advanced/instruction.md +145 -0
- package/skills/rhwp-advanced/skill.json +8 -0
- package/skills/rhwp-edit/instruction.md +153 -0
- package/skills/rhwp-edit/skill.json +8 -0
- package/skills/s2b-notice-search/instruction.md +66 -0
- package/skills/s2b-notice-search/skill.json +9 -0
- package/skills/saju-fortune/instruction.md +177 -0
- package/skills/saju-fortune/skill.json +8 -0
- package/skills/saramin-talent-search/instruction.md +119 -0
- package/skills/saramin-talent-search/skill.json +9 -0
- package/skills/seoul-bike/instruction.md +83 -0
- package/skills/seoul-bike/scripts/seoul_bike.py +247 -0
- package/skills/seoul-bike/skill.json +10 -0
- package/skills/seoul-density/instruction.md +109 -0
- package/skills/seoul-density/scripts/seoul_density.py +271 -0
- package/skills/seoul-density/skill.json +10 -0
- package/skills/seoul-subway-arrival/instruction.md +85 -0
- package/skills/seoul-subway-arrival/skill.json +9 -0
- package/skills/sh-notice-search/instruction.md +150 -0
- package/skills/sh-notice-search/skill.json +9 -0
- package/skills/srt-booking/instruction.md +181 -0
- package/skills/srt-booking/scripts/srt_booking.py +272 -0
- package/skills/srt-booking/scripts/srt_seats.py +156 -0
- package/skills/srt-booking/skill.json +20 -0
- package/skills/subway-lost-property/instruction.md +93 -0
- package/skills/subway-lost-property/scripts/subway_lost_property.py +244 -0
- package/skills/subway-lost-property/skill.json +8 -0
- package/skills/ticket-availability/instruction.md +175 -0
- package/skills/ticket-availability/scripts/ticket_availability.py +430 -0
- package/skills/ticket-availability/skill.json +9 -0
- package/skills/toss-securities/instruction.md +116 -0
- package/skills/toss-securities/skill.json +9 -0
- package/skills/used-car-price-search/instruction.md +109 -0
- package/skills/used-car-price-search/skill.json +8 -0
- package/skills/yebigun-training/instruction.md +177 -0
- package/skills/yebigun-training/skill.json +9 -0
- package/skills/zipcode-search/instruction.md +159 -0
- package/skills/zipcode-search/scripts/zipcode_search.py +150 -0
- package/skills/zipcode-search/skill.json +8 -0
- package/src/assemble.js +134 -0
- package/src/detect.js +18 -0
- package/templates/action-account.md +4 -0
- package/templates/action-booking.md +5 -0
- package/templates/action-commerce.md +5 -0
- package/templates/action-communication.md +5 -0
- package/templates/action-submission.md +5 -0
- package/templates/browser.md +4 -0
- package/templates/core.md +5 -0
- package/templates/hard-boundary.md +3 -0
- package/templates/local.md +2 -0
- package/templates/lookup.md +2 -0
- package/templates/proxy.md +2 -0
- package/templates/vault.md +4 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single-entrypoint CLI for the seoul-bike skill.
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
nearby --lat LAT --lon LON — find realtime Seoul Bike stations near coordinates
|
|
6
|
+
search KEYWORD — search station names in realtime availability page(s)
|
|
7
|
+
realtime — fetch raw realtime station availability
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
22
|
+
reconfigure = getattr(_stream, "reconfigure", None)
|
|
23
|
+
if reconfigure is not None:
|
|
24
|
+
try:
|
|
25
|
+
reconfigure(encoding="utf-8")
|
|
26
|
+
except (OSError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
TIMEOUT_SEC = 15
|
|
30
|
+
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
|
|
31
|
+
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
|
32
|
+
PROXY_DOWN_MSG = "설정된 k-skill-proxy 서버가 응답하지 않습니다. 잠시 후 재시도하거나 운영자에게 문의하세요."
|
|
33
|
+
PROXY_KEY_NOT_CONFIGURED_MSG = "k-skill-proxy에 필요한 API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_proxy_base_url() -> str:
|
|
37
|
+
value = os.environ.get(PROXY_BASE_URL_NAME)
|
|
38
|
+
if value and value.strip() and value.strip() != "replace-me":
|
|
39
|
+
return value.strip().rstrip("/")
|
|
40
|
+
return DEFAULT_PROXY_BASE_URL
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fetch_json(path: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
query = urllib.parse.urlencode(params)
|
|
45
|
+
url = f"{get_proxy_base_url()}{path}?{query}"
|
|
46
|
+
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-bike"})
|
|
47
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
|
48
|
+
raw = resp.read().decode("utf-8")
|
|
49
|
+
return json.loads(raw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _to_int(value: Any) -> int | None:
|
|
53
|
+
if value in (None, ""):
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return int(float(value))
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_realtime_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
rack_total = _to_int(row.get("rackTotCnt") or row.get("rack_total_count"))
|
|
63
|
+
available = _to_int(row.get("parkingBikeTotCnt") or row.get("available_bikes"))
|
|
64
|
+
empty_docks = None if rack_total is None or available is None else max(0, rack_total - available)
|
|
65
|
+
return {
|
|
66
|
+
"station_id": row.get("stationId") or row.get("station_id"),
|
|
67
|
+
"station_name": row.get("stationName") or row.get("station_name"),
|
|
68
|
+
"rack_total_count": rack_total,
|
|
69
|
+
"available_bikes": available,
|
|
70
|
+
"empty_docks": empty_docks,
|
|
71
|
+
"shared_percent": _to_int(row.get("shared") or row.get("shared_percent")),
|
|
72
|
+
"latitude": row.get("stationLatitude") or row.get("latitude"),
|
|
73
|
+
"longitude": row.get("stationLongitude") or row.get("longitude"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def realtime_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
78
|
+
status = payload.get("rentBikeStatus") or {}
|
|
79
|
+
rows = status.get("row") or []
|
|
80
|
+
return rows if isinstance(rows, list) else []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def filter_realtime_rows(payload: dict[str, Any], keyword: str, limit: int) -> list[dict[str, Any]]:
|
|
84
|
+
normalized_keyword = keyword.strip().lower()
|
|
85
|
+
matches: list[dict[str, Any]] = []
|
|
86
|
+
for row in realtime_rows(payload):
|
|
87
|
+
station_name = str(row.get("stationName") or row.get("station_name") or "")
|
|
88
|
+
if normalized_keyword in station_name.lower():
|
|
89
|
+
matches.append(normalize_realtime_row(row))
|
|
90
|
+
if len(matches) >= limit:
|
|
91
|
+
break
|
|
92
|
+
return matches
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_station(item: dict[str, Any]) -> str:
|
|
96
|
+
distance = item.get("distance_m")
|
|
97
|
+
distance_text = f", 거리 {distance}m" if distance is not None else ""
|
|
98
|
+
bikes = item.get("available_bikes")
|
|
99
|
+
docks = item.get("empty_docks")
|
|
100
|
+
bikes_text = "알 수 없음" if bikes is None else f"{bikes}대"
|
|
101
|
+
docks_text = "알 수 없음" if docks is None else f"{docks}개"
|
|
102
|
+
return f"- {item.get('station_name')}: 대여 가능 {bikes_text}, 빈 거치대 {docks_text}{distance_text}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_nearby(payload: dict[str, Any]) -> list[str]:
|
|
106
|
+
query = payload.get("query") or {}
|
|
107
|
+
lines = [
|
|
108
|
+
f"따릉이 주변 대여소 {payload.get('count', 0)}곳",
|
|
109
|
+
f"기준 좌표: {query.get('latitude')}, {query.get('longitude')} / 반경 {query.get('radius_m')}m",
|
|
110
|
+
]
|
|
111
|
+
for item in payload.get("items") or []:
|
|
112
|
+
lines.append(format_station(item))
|
|
113
|
+
requested_at = (payload.get("proxy") or {}).get("requested_at")
|
|
114
|
+
if requested_at:
|
|
115
|
+
lines.append(f"조회 시각: {requested_at}")
|
|
116
|
+
return lines
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_nearby(args: argparse.Namespace) -> int:
|
|
120
|
+
payload = fetch_json(
|
|
121
|
+
"/v1/seoul-bike/nearby",
|
|
122
|
+
{"lat": args.lat, "lon": args.lon, "radius_m": args.radius_m, "limit": args.limit},
|
|
123
|
+
)
|
|
124
|
+
if args.json:
|
|
125
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
126
|
+
sys.stdout.write("\n")
|
|
127
|
+
else:
|
|
128
|
+
print("\n".join(format_nearby(payload)))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fetch_realtime_payload(start_index: int = 1, end_index: int = 1000) -> dict[str, Any]:
|
|
133
|
+
rows: list[dict[str, Any]] = []
|
|
134
|
+
current_start = start_index
|
|
135
|
+
page_size = max(1, end_index - start_index + 1)
|
|
136
|
+
requested_at = None
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
current_end = current_start + page_size - 1
|
|
140
|
+
payload = fetch_json(
|
|
141
|
+
"/v1/seoul-bike/realtime",
|
|
142
|
+
{"startIndex": current_start, "endIndex": current_end},
|
|
143
|
+
)
|
|
144
|
+
if requested_at is None:
|
|
145
|
+
requested_at = (payload.get("proxy") or {}).get("requested_at")
|
|
146
|
+
page_rows = realtime_rows(payload)
|
|
147
|
+
rows.extend(page_rows)
|
|
148
|
+
|
|
149
|
+
total_count = _to_int((payload.get("rentBikeStatus") or {}).get("list_total_count"))
|
|
150
|
+
if total_count is None or current_end >= total_count or not page_rows:
|
|
151
|
+
break
|
|
152
|
+
current_start = current_end + 1
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"rentBikeStatus": {"row": rows},
|
|
156
|
+
"proxy": {"requested_at": requested_at},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fetch_realtime_pages(start_index: int = 1, end_index: int = 1000) -> list[dict[str, Any]]:
|
|
161
|
+
return realtime_rows(fetch_realtime_payload(start_index, end_index))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
165
|
+
payload = fetch_realtime_payload(args.start_index, args.end_index)
|
|
166
|
+
matches = filter_realtime_rows(payload, args.keyword, args.limit)
|
|
167
|
+
if args.json:
|
|
168
|
+
json.dump({"keyword": args.keyword, "count": len(matches), "items": matches, "proxy": payload.get("proxy")}, sys.stdout, ensure_ascii=False, indent=2)
|
|
169
|
+
sys.stdout.write("\n")
|
|
170
|
+
else:
|
|
171
|
+
if not matches:
|
|
172
|
+
print(f"'{args.keyword}'와 일치하는 따릉이 대여소가 없습니다.", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
print(f"따릉이 대여소 검색: {args.keyword}")
|
|
175
|
+
for item in matches:
|
|
176
|
+
print(format_station(item))
|
|
177
|
+
requested_at = (payload.get("proxy") or {}).get("requested_at")
|
|
178
|
+
if requested_at:
|
|
179
|
+
print(f"조회 시각: {requested_at}")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_realtime(args: argparse.Namespace) -> int:
|
|
184
|
+
payload = fetch_json(
|
|
185
|
+
"/v1/seoul-bike/realtime",
|
|
186
|
+
{"startIndex": args.start_index, "endIndex": args.end_index},
|
|
187
|
+
)
|
|
188
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
189
|
+
sys.stdout.write("\n")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
194
|
+
parser = argparse.ArgumentParser(description="서울 따릉이 실시간 대여소 조회")
|
|
195
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
196
|
+
|
|
197
|
+
nearby = sub.add_parser("nearby", help="좌표 주변 대여소 조회")
|
|
198
|
+
nearby.add_argument("--lat", required=True, type=float)
|
|
199
|
+
nearby.add_argument("--lon", required=True, type=float)
|
|
200
|
+
nearby.add_argument("--radius-m", type=int, default=500)
|
|
201
|
+
nearby.add_argument("--limit", type=int, default=10)
|
|
202
|
+
nearby.add_argument("--json", action="store_true")
|
|
203
|
+
nearby.set_defaults(func=cmd_nearby)
|
|
204
|
+
|
|
205
|
+
search = sub.add_parser("search", help="실시간 대여소 이름 검색")
|
|
206
|
+
search.add_argument("keyword")
|
|
207
|
+
search.add_argument("--start-index", type=int, default=1)
|
|
208
|
+
search.add_argument("--end-index", type=int, default=1000, help="page size end index for the first realtime page; search continues through all pages")
|
|
209
|
+
search.add_argument("--limit", type=int, default=10)
|
|
210
|
+
search.add_argument("--json", action="store_true")
|
|
211
|
+
search.set_defaults(func=cmd_search)
|
|
212
|
+
|
|
213
|
+
realtime = sub.add_parser("realtime", help="실시간 대여소 원문 JSON 조회")
|
|
214
|
+
realtime.add_argument("--start-index", type=int, default=1)
|
|
215
|
+
realtime.add_argument("--end-index", type=int, default=1000)
|
|
216
|
+
realtime.set_defaults(func=cmd_realtime)
|
|
217
|
+
return parser
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main(argv: list[str] | None = None) -> int:
|
|
221
|
+
parser = build_parser()
|
|
222
|
+
args = parser.parse_args(argv)
|
|
223
|
+
try:
|
|
224
|
+
return args.func(args)
|
|
225
|
+
except urllib.error.HTTPError as exc:
|
|
226
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
227
|
+
try:
|
|
228
|
+
payload = json.loads(body)
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
payload = None
|
|
231
|
+
if exc.code == 503 and isinstance(payload, dict) and payload.get("error") == "upstream_not_configured":
|
|
232
|
+
print(PROXY_KEY_NOT_CONFIGURED_MSG, file=sys.stderr)
|
|
233
|
+
elif isinstance(payload, dict) and payload.get("message"):
|
|
234
|
+
print(str(payload["message"]), file=sys.stderr)
|
|
235
|
+
else:
|
|
236
|
+
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
except urllib.error.URLError as exc:
|
|
239
|
+
print(f"{PROXY_DOWN_MSG} (상세: {exc.reason})", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
except json.JSONDecodeError as exc:
|
|
242
|
+
print(f"API 응답 JSON 파싱 실패: {exc}", file=sys.stderr)
|
|
243
|
+
return 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seoul-bike",
|
|
3
|
+
"description": "서울 따릉이 실시간 대여소의 대여 가능 자전거와 빈 거치대를 좌표 주변 또는 대여소 이름으로 조회한다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"proxy",
|
|
6
|
+
"vault",
|
|
7
|
+
"lookup"
|
|
8
|
+
],
|
|
9
|
+
"frontmatter": "name: seoul-bike\ndescription: 서울 따릉이 실시간 대여소의 대여 가능 자전거와 빈 거치대를 좌표 주변 또는 대여소 이름으로 조회한다.\nlicense: MIT\nmetadata:\n category: transit\n locale: ko-KR\n phase: v1"
|
|
10
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Seoul Density
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
서울 실시간 도시데이터 API(data.seoul.go.kr)를 호출해 121개 핫스팟의 **현재 혼잡도 단계**(여유 / 보통 / 약간 붐빔 / 붐빔)와 **추정 인구 범위**를 반환한다.
|
|
6
|
+
|
|
7
|
+
데이터는 KT·SKT 통신 신호 기반 추계치이며, 5분 주기로 갱신되나 호출 시점 기준 약 15분 전 값이다.
|
|
8
|
+
|
|
9
|
+
## When to use
|
|
10
|
+
|
|
11
|
+
- "지금 강남역 얼마나 붐벼?"
|
|
12
|
+
- "홍대 지금 인파 어때?"
|
|
13
|
+
- "명동 지금 사람 많아?"
|
|
14
|
+
- "여의도한강공원 지금 여유로워?"
|
|
15
|
+
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
별도 API 키 발급 없이 그대로 쓸 수 있다. 모든 호출은 **k-skill-proxy 경유**다.
|
|
19
|
+
|
|
20
|
+
- 기본 프록시 URL: `https://k-skill-proxy.nomadamas.org` — 프록시 서버가 `SEOUL_OPEN_API_KEY`를 보유하고 있어 사용자는 키 없이 호출만 하면 된다.
|
|
21
|
+
- `KSKILL_PROXY_BASE_URL` 환경변수로 프록시 주소를 바꿀 수 있다(예: 별도 self-host 프록시).
|
|
22
|
+
|
|
23
|
+
## Single entrypoint
|
|
24
|
+
|
|
25
|
+
이 스킬의 모든 동작은 **단일 진입점**을 통한다. OS·CWD에 관계없이 동일하게 동작하도록 절대 경로 + Python launcher fallback을 사용한다:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# macOS / Linux / Git-bash
|
|
29
|
+
python3 "$SKILL_DIR/scripts/seoul_density.py" <subcommand> [args]
|
|
30
|
+
|
|
31
|
+
# Windows (PowerShell): py 런처 또는 python
|
|
32
|
+
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" <subcommand> [args]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`$SKILL_DIR`은 이 SKILL.md가 위치한 디렉토리다(`~/.claude/skills/seoul-density` 또는 레포의 `seoul-density/`). 호출 예시는 아래 Workflow 참조.
|
|
36
|
+
|
|
37
|
+
첫 사용 시 `Bash(python3 *seoul_density.py:*)` (또는 PowerShell 환경에서 `PowerShell(py -3 *seoul_density.py*)`) 패턴 한 번만 승인하면 이후 호출은 모두 자동 허용된다. 외부 dependency는 없고 Python 표준 라이브러리만 사용한다.
|
|
38
|
+
|
|
39
|
+
### Subcommands
|
|
40
|
+
|
|
41
|
+
| 명령 | 설명 |
|
|
42
|
+
|------|------|
|
|
43
|
+
| `list [--json]` | 지원 121개 장소 목록 (카테고리별) |
|
|
44
|
+
| `match <키워드> [--limit N] [--json]` | 사용자 입력 → 지원 장소명 매칭 |
|
|
45
|
+
| `query <장소명> [--json]` | 실시간 혼잡도/인구 조회 (사람이 읽는 요약 또는 JSON) |
|
|
46
|
+
|
|
47
|
+
## Workflow
|
|
48
|
+
|
|
49
|
+
### 1. 모호한 입력은 match로 후보 확인 (선택)
|
|
50
|
+
|
|
51
|
+
사용자가 "홍대 인파"처럼 모호하게 말하면 먼저 후보를 확인한다.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python3 "$SKILL_DIR/scripts/seoul_density.py" match "홍대" --json
|
|
55
|
+
# → ["홍대 관광특구", "홍대입구역(2호선)"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
후보가 1개면 바로 `query`로 넘어가도 되고(스크립트가 자동 매칭), 여러 개면 어느 쪽인지 사용자에게 확인한다.
|
|
59
|
+
|
|
60
|
+
### 2. 혼잡도 조회
|
|
61
|
+
|
|
62
|
+
키워드 1개만 매칭되면 자동으로 보정한다.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# macOS / Linux / Git-bash
|
|
66
|
+
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역"
|
|
67
|
+
|
|
68
|
+
# Windows PowerShell
|
|
69
|
+
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" query "강남역"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
출력 예시:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
장소: 강남역
|
|
76
|
+
혼잡도: 약간 붐빔
|
|
77
|
+
인구 추정: 24000~26000명
|
|
78
|
+
기준 시각: 2026-05-14 09:30
|
|
79
|
+
상황: 사람이 몰려있을 수 있어요
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
기계적 후처리가 필요하면 `--json` 플래그를 쓴다:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역" --json
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
자동 매칭을 끄고 싶으면 `--no-auto`를 쓴다.
|
|
89
|
+
|
|
90
|
+
## Done when
|
|
91
|
+
|
|
92
|
+
- 장소명, 혼잡도 단계, 추정 인구 범위(최소~최대), 기준 시각, 혼잡도 메시지를 사용자에게 전달했다.
|
|
93
|
+
|
|
94
|
+
## Failure modes
|
|
95
|
+
|
|
96
|
+
| 상황 | 동작 |
|
|
97
|
+
|------|------|
|
|
98
|
+
| 프록시 정상 응답 | 별도 키 불필요, 즉시 결과 반환 |
|
|
99
|
+
| 지원하지 않는 장소명 (`exit 1`) | `match` 결과로 후보 제안 |
|
|
100
|
+
| 프록시 HTTP/네트워크 오류 (`exit 1`) | stderr에 사유 출력, `KSKILL_PROXY_BASE_URL` 점검 또는 5분 후 재시도 안내 |
|
|
101
|
+
| 새벽 01~05시 빈 응답 | 실시간 데이터 미제공 시간대임을 안내 |
|
|
102
|
+
| 일일 할당량 초과 | 다음 날 재시도 안내 |
|
|
103
|
+
|
|
104
|
+
## Notes
|
|
105
|
+
|
|
106
|
+
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
|
|
107
|
+
- 데이터는 호출 시점 기준 **약 15분 전** 값.
|
|
108
|
+
- 단일 진입점 외에 `curl`, `python3 -c`, `source` 같은 inline 명령을 직접 실행하지 말 것. 그렇게 하면 사용자가 매번 별도 승인을 받아야 한다.
|
|
109
|
+
- 새 카테고리/장소가 추가되면 `seoul-density/scripts/seoul_density.py`의 `AREAS` 딕셔너리만 갱신한다.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Single-entrypoint CLI for the seoul-density skill.
|
|
2
|
+
|
|
3
|
+
All skill operations route through `python3 seoul-density/scripts/seoul_density.py <subcommand>`
|
|
4
|
+
so users only have to approve one Bash pattern on first use.
|
|
5
|
+
|
|
6
|
+
Subcommands:
|
|
7
|
+
list — print supported area names grouped by category
|
|
8
|
+
match <keyword> — fuzzy-match a user keyword to a supported area name
|
|
9
|
+
query <area-name> [--json] — fetch and summarize real-time density for the area
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import difflib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import urllib.parse
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
25
|
+
reconfigure = getattr(_stream, "reconfigure", None)
|
|
26
|
+
if reconfigure is not None:
|
|
27
|
+
try:
|
|
28
|
+
reconfigure(encoding="utf-8")
|
|
29
|
+
except (OSError, ValueError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
AREAS: dict[str, list[str]] = {
|
|
34
|
+
"고궁·문화유산": [
|
|
35
|
+
"경복궁", "광화문·덕수궁", "보신각", "서울 암사동 유적", "창덕궁·종묘",
|
|
36
|
+
],
|
|
37
|
+
"관광특구": [
|
|
38
|
+
"강남 MICE 관광특구", "동대문 관광특구", "명동 관광특구", "이태원 관광특구",
|
|
39
|
+
"잠실 관광특구", "종로·청계 관광특구", "홍대 관광특구",
|
|
40
|
+
],
|
|
41
|
+
"공원": [
|
|
42
|
+
"강서한강공원", "고척돔", "광나루한강공원", "광화문광장",
|
|
43
|
+
"국립중앙박물관·용산가족공원", "난지한강공원", "남산공원", "노들섬",
|
|
44
|
+
"뚝섬한강공원", "망원한강공원", "반포한강공원", "보라매공원",
|
|
45
|
+
"북서울꿈의숲", "서대문독립공원", "서리풀공원·몽마르뜨공원", "서울대공원",
|
|
46
|
+
"서울숲공원", "송현녹지광장", "아차산", "안양천", "양화한강공원",
|
|
47
|
+
"어린이대공원", "여의도한강공원", "여의서로", "올림픽공원", "월드컵공원",
|
|
48
|
+
"응봉산", "이촌한강공원", "잠실종합운동장", "잠실한강공원", "잠원한강공원",
|
|
49
|
+
"청계산", "홍제폭포",
|
|
50
|
+
],
|
|
51
|
+
"발달상권": [
|
|
52
|
+
"가락시장", "가로수길", "광장(전통)시장", "김포공항", "남대문시장", "노량진",
|
|
53
|
+
"덕수궁길·정동길", "북창동 먹자골목", "북촌한옥마을", "서촌", "성수카페거리",
|
|
54
|
+
"송리단길·호수단길", "신촌 스타광장", "압구정로데오거리", "여의도", "연남동",
|
|
55
|
+
"영등포 타임스퀘어", "용리단길", "이태원 앤틱가구거리", "익선동", "인사동",
|
|
56
|
+
"잠실롯데타워·석촌호수", "창동 신경제 중심지", "청담동 명품거리",
|
|
57
|
+
"청량리 제기동 일대 전통시장", "해방촌·경리단길", "DDP(동대문디자인플라자)",
|
|
58
|
+
"DMC(디지털미디어시티)",
|
|
59
|
+
],
|
|
60
|
+
"인구밀집지역": [
|
|
61
|
+
"가산디지털단지역", "강남역", "건대입구역", "고덕역", "고속터미널역", "교대역",
|
|
62
|
+
"구로디지털단지역", "구로역", "군자역", "대림역", "동대문역", "뚝섬역",
|
|
63
|
+
"미아사거리역", "발산역", "사당역", "삼각지역", "서울대입구역",
|
|
64
|
+
"서울식물원·마곡나루역", "서울역", "성신여대입구역", "선릉역", "시의회 앞",
|
|
65
|
+
"수유역", "신논현역·논현역", "신도림역", "신림역", "신촌·이대역", "쌍문역",
|
|
66
|
+
"신정네거리역", "역삼역", "연신내역", "양재역", "왕십리역", "용산역",
|
|
67
|
+
"오목교역·목동운동장", "잠실새내역", "잠실역", "장지역", "장한평역", "천호역",
|
|
68
|
+
"총신대입구(이수)역", "충정로역", "합정역", "혜화역", "홍대입구역(2호선)",
|
|
69
|
+
"회기역",
|
|
70
|
+
],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
TIMEOUT_SEC = 10
|
|
75
|
+
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
|
|
76
|
+
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
|
|
77
|
+
PROXY_DOWN_MSG = "설정된 k-skill-proxy 서버가 응답하지 않습니다. 잠시 후 재시도하거나 운영자에게 문의하세요."
|
|
78
|
+
PROXY_KEY_NOT_CONFIGURED_MSG = "k-skill-proxy에 필요한 API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요."
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def all_areas() -> list[str]:
|
|
82
|
+
return [name for group in AREAS.values() for name in group]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
86
|
+
if args.json:
|
|
87
|
+
json.dump(AREAS, sys.stdout, ensure_ascii=False, indent=2)
|
|
88
|
+
sys.stdout.write("\n")
|
|
89
|
+
return 0
|
|
90
|
+
for category, names in AREAS.items():
|
|
91
|
+
print(f"## {category} ({len(names)}곳)")
|
|
92
|
+
print(", ".join(names))
|
|
93
|
+
print()
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize(text: str) -> str:
|
|
98
|
+
"""Strip whitespace and common location suffixes for loose matching."""
|
|
99
|
+
cleaned = "".join(ch for ch in text if not ch.isspace())
|
|
100
|
+
for suffix in ("관광특구", "한강공원", "공원", "시장", "역", "거리", "광장"):
|
|
101
|
+
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
|
|
102
|
+
cleaned = cleaned[: -len(suffix)]
|
|
103
|
+
break
|
|
104
|
+
return cleaned
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fuzzy_match(keyword: str, limit: int = 5) -> list[str]:
|
|
108
|
+
names = all_areas()
|
|
109
|
+
keyword = keyword.strip()
|
|
110
|
+
if not keyword:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
exact = [n for n in names if keyword in n]
|
|
114
|
+
if exact:
|
|
115
|
+
return exact[:limit]
|
|
116
|
+
|
|
117
|
+
contained = [n for n in names if n in keyword]
|
|
118
|
+
if contained:
|
|
119
|
+
return contained[:limit]
|
|
120
|
+
|
|
121
|
+
norm_kw = _normalize(keyword)
|
|
122
|
+
if norm_kw:
|
|
123
|
+
loose = [n for n in names if norm_kw and (norm_kw in _normalize(n) or _normalize(n) in norm_kw)]
|
|
124
|
+
if loose:
|
|
125
|
+
return loose[:limit]
|
|
126
|
+
|
|
127
|
+
return difflib.get_close_matches(keyword, names, n=limit, cutoff=0.3)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_match(args: argparse.Namespace) -> int:
|
|
131
|
+
matches = fuzzy_match(args.keyword, limit=args.limit)
|
|
132
|
+
if not matches:
|
|
133
|
+
print(f"'{args.keyword}'와 일치하는 지원 장소가 없습니다.", file=sys.stderr)
|
|
134
|
+
print("'python3 seoul-density/scripts/seoul_density.py list' 로 전체 목록을 확인하세요.", file=sys.stderr)
|
|
135
|
+
return 1
|
|
136
|
+
if args.json:
|
|
137
|
+
json.dump(matches, sys.stdout, ensure_ascii=False)
|
|
138
|
+
sys.stdout.write("\n")
|
|
139
|
+
else:
|
|
140
|
+
for name in matches:
|
|
141
|
+
print(name)
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_proxy_base_url() -> str:
|
|
146
|
+
value = os.environ.get(PROXY_BASE_URL_NAME)
|
|
147
|
+
if value and value != "replace-me":
|
|
148
|
+
return value.rstrip("/")
|
|
149
|
+
return DEFAULT_PROXY_BASE_URL
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def fetch_density_via_proxy(area: str) -> dict[str, Any]:
|
|
153
|
+
base_url = get_proxy_base_url()
|
|
154
|
+
query = urllib.parse.urlencode({"area": area})
|
|
155
|
+
url = f"{base_url}/v1/seoul-density/citydata?{query}"
|
|
156
|
+
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-density"})
|
|
157
|
+
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
|
|
158
|
+
raw = resp.read().decode("utf-8")
|
|
159
|
+
return json.loads(raw)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def summarize(payload: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
result = payload.get("RESULT") or {}
|
|
164
|
+
code = result.get("RESULT.CODE")
|
|
165
|
+
message = result.get("RESULT.MESSAGE", "")
|
|
166
|
+
if code and code != "INFO-000":
|
|
167
|
+
raise RuntimeError(f"API 오류: {code} {message}".strip())
|
|
168
|
+
|
|
169
|
+
rows = payload.get("SeoulRtd.citydata_ppltn") or []
|
|
170
|
+
if not rows:
|
|
171
|
+
raise RuntimeError("인구 데이터가 없습니다. 장소명을 'match' 서브커맨드로 확인하세요.")
|
|
172
|
+
|
|
173
|
+
row = rows[0]
|
|
174
|
+
return {
|
|
175
|
+
"area": row.get("AREA_NM"),
|
|
176
|
+
"congestion_level": row.get("AREA_CONGEST_LVL"),
|
|
177
|
+
"population_min": row.get("AREA_PPLTN_MIN"),
|
|
178
|
+
"population_max": row.get("AREA_PPLTN_MAX"),
|
|
179
|
+
"as_of": row.get("PPLTN_TIME"),
|
|
180
|
+
"message": row.get("AREA_CONGEST_MSG"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_query(args: argparse.Namespace) -> int:
|
|
185
|
+
area = args.area.strip()
|
|
186
|
+
if area not in all_areas():
|
|
187
|
+
suggestions = fuzzy_match(area, limit=3)
|
|
188
|
+
if len(suggestions) == 1 and getattr(args, "auto", True):
|
|
189
|
+
print(f"'{area}' → '{suggestions[0]}' 로 자동 매칭", file=sys.stderr)
|
|
190
|
+
area = suggestions[0]
|
|
191
|
+
else:
|
|
192
|
+
hint = (
|
|
193
|
+
f" 가까운 후보: {', '.join(suggestions)}" if suggestions else ""
|
|
194
|
+
)
|
|
195
|
+
print(f"지원하지 않는 장소: {area}{hint}", file=sys.stderr)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
payload = fetch_density_via_proxy(area)
|
|
200
|
+
summary = summarize(payload)
|
|
201
|
+
except urllib.error.HTTPError as exc:
|
|
202
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
203
|
+
try:
|
|
204
|
+
payload = json.loads(body)
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
payload = None
|
|
207
|
+
if exc.code == 503 and isinstance(payload, dict) and payload.get("error") == "upstream_not_configured":
|
|
208
|
+
print(PROXY_KEY_NOT_CONFIGURED_MSG, file=sys.stderr)
|
|
209
|
+
elif isinstance(payload, dict) and payload.get("message"):
|
|
210
|
+
print(str(payload["message"]), file=sys.stderr)
|
|
211
|
+
else:
|
|
212
|
+
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
except urllib.error.URLError as exc:
|
|
215
|
+
print(f"{PROXY_DOWN_MSG} (상세: {exc.reason})", file=sys.stderr)
|
|
216
|
+
return 1
|
|
217
|
+
except (RuntimeError, json.JSONDecodeError) as exc:
|
|
218
|
+
print(str(exc), file=sys.stderr)
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
if args.json:
|
|
222
|
+
json.dump(summary, sys.stdout, ensure_ascii=False, indent=2)
|
|
223
|
+
sys.stdout.write("\n")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
print(f"장소: {summary['area']}")
|
|
227
|
+
print(f"혼잡도: {summary['congestion_level']}")
|
|
228
|
+
print(f"인구 추정: {summary['population_min']}~{summary['population_max']}명")
|
|
229
|
+
print(f"기준 시각: {summary['as_of'] or '알 수 없음'}")
|
|
230
|
+
print(f"상황: {summary['message']}")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
235
|
+
parser = argparse.ArgumentParser(
|
|
236
|
+
prog="seoul_density",
|
|
237
|
+
description="서울 실시간 도시데이터(혼잡도/인구) 단일 진입점 CLI",
|
|
238
|
+
)
|
|
239
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
240
|
+
|
|
241
|
+
p_list = sub.add_parser("list", help="지원 장소 목록 출력")
|
|
242
|
+
p_list.add_argument("--json", action="store_true")
|
|
243
|
+
p_list.set_defaults(func=cmd_list)
|
|
244
|
+
|
|
245
|
+
p_match = sub.add_parser("match", help="키워드 → 지원 장소명 매칭")
|
|
246
|
+
p_match.add_argument("keyword")
|
|
247
|
+
p_match.add_argument("--limit", type=int, default=5)
|
|
248
|
+
p_match.add_argument("--json", action="store_true")
|
|
249
|
+
p_match.set_defaults(func=cmd_match)
|
|
250
|
+
|
|
251
|
+
p_query = sub.add_parser("query", help="장소 혼잡도 조회")
|
|
252
|
+
p_query.add_argument("area", help="지원 장소명 (목록은 'list' 참조)")
|
|
253
|
+
p_query.add_argument("--json", action="store_true")
|
|
254
|
+
p_query.add_argument(
|
|
255
|
+
"--no-auto",
|
|
256
|
+
dest="auto",
|
|
257
|
+
action="store_false",
|
|
258
|
+
help="후보가 1개뿐이어도 자동 매칭하지 않음",
|
|
259
|
+
)
|
|
260
|
+
p_query.set_defaults(func=cmd_query, auto=True)
|
|
261
|
+
|
|
262
|
+
return parser
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main(argv: list[str] | None = None) -> int:
|
|
266
|
+
args = build_parser().parse_args(argv)
|
|
267
|
+
return args.func(args)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
sys.exit(main())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seoul-density",
|
|
3
|
+
"description": "서울 주요 121개 핫스팟 장소의 실시간 혼잡도와 인구 현황을 조회한다. 지금 강남역이 얼마나 붐비는지, 홍대 인파가 얼마나 되는지 물어볼 때 사용한다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"proxy",
|
|
6
|
+
"vault",
|
|
7
|
+
"lookup"
|
|
8
|
+
],
|
|
9
|
+
"frontmatter": "name: seoul-density\ndescription: 서울 주요 121개 핫스팟 장소의 실시간 혼잡도와 인구 현황을 조회한다. 지금 강남역이 얼마나 붐비는지, 홍대 인파가 얼마나 되는지 물어볼 때 사용한다.\nlicense: MIT\nmetadata:\n category: utility\n locale: ko-KR\n phase: v1"
|
|
10
|
+
}
|