@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.
Files changed (350) hide show
  1. package/bin/k-skill.js +60 -0
  2. package/package.json +37 -0
  3. package/skills/assembly-bill-vote-search/instruction.md +111 -0
  4. package/skills/assembly-bill-vote-search/skill.json +10 -0
  5. package/skills/biz-health-check/instruction.md +69 -0
  6. package/skills/biz-health-check/scripts/biz_health_check.py +163 -0
  7. package/skills/biz-health-check/skill.json +10 -0
  8. package/skills/bok-ecos-stats/instruction.md +95 -0
  9. package/skills/bok-ecos-stats/scripts/bok_ecos.py +302 -0
  10. package/skills/bok-ecos-stats/skill.json +10 -0
  11. package/skills/building-register-search/instruction.md +69 -0
  12. package/skills/building-register-search/scripts/building_register.py +309 -0
  13. package/skills/building-register-search/scripts/building_register_xml.py +32 -0
  14. package/skills/building-register-search/skill.json +9 -0
  15. package/skills/bunjang-search/instruction.md +154 -0
  16. package/skills/bunjang-search/skill.json +10 -0
  17. package/skills/catchtable-sniper/instruction.md +270 -0
  18. package/skills/catchtable-sniper/skill.json +9 -0
  19. package/skills/cheap-gas-nearby/instruction.md +107 -0
  20. package/skills/cheap-gas-nearby/skill.json +10 -0
  21. package/skills/corporate-registration-consulting/instruction.md +117 -0
  22. package/skills/corporate-registration-consulting/scripts/fill_official_hwp.py +106 -0
  23. package/skills/corporate-registration-consulting/skill.json +8 -0
  24. package/skills/coupang-product-search/instruction.md +219 -0
  25. package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +146 -0
  26. package/skills/coupang-product-search/skill.json +10 -0
  27. package/skills/court-auction-notice-search/instruction.md +201 -0
  28. package/skills/court-auction-notice-search/skill.json +9 -0
  29. package/skills/court-payment-order-assistant/instruction.md +120 -0
  30. package/skills/court-payment-order-assistant/skill.json +10 -0
  31. package/skills/d2b-notice-search/instruction.md +117 -0
  32. package/skills/d2b-notice-search/skill.json +10 -0
  33. package/skills/daangn-cars-search/instruction.md +91 -0
  34. package/skills/daangn-cars-search/scripts/daangn_cars.py +72 -0
  35. package/skills/daangn-cars-search/skill.json +9 -0
  36. package/skills/daangn-jobs-search/instruction.md +90 -0
  37. package/skills/daangn-jobs-search/scripts/daangn_jobs.py +98 -0
  38. package/skills/daangn-jobs-search/skill.json +9 -0
  39. package/skills/daangn-realty-search/instruction.md +114 -0
  40. package/skills/daangn-realty-search/scripts/daangn_detail_ld.py +76 -0
  41. package/skills/daangn-realty-search/scripts/daangn_realty.py +216 -0
  42. package/skills/daangn-realty-search/scripts/daangn_relay_store.py +174 -0
  43. package/skills/daangn-realty-search/skill.json +8 -0
  44. package/skills/daangn-used-goods-search/instruction.md +90 -0
  45. package/skills/daangn-used-goods-search/scripts/daangn_used_goods.py +80 -0
  46. package/skills/daangn-used-goods-search/skill.json +9 -0
  47. package/skills/daishin-report-search/instruction.md +138 -0
  48. package/skills/daishin-report-search/skill.json +10 -0
  49. package/skills/daiso-product-search/instruction.md +172 -0
  50. package/skills/daiso-product-search/skill.json +8 -0
  51. package/skills/danawa-price-search/instruction.md +183 -0
  52. package/skills/danawa-price-search/scripts/danawa_search.py +354 -0
  53. package/skills/danawa-price-search/skill.json +8 -0
  54. package/skills/delivery-tracking/instruction.md +358 -0
  55. package/skills/delivery-tracking/skill.json +8 -0
  56. package/skills/donation-place-search/instruction.md +129 -0
  57. package/skills/donation-place-search/skill.json +9 -0
  58. package/skills/emergency-room-beds/instruction.md +82 -0
  59. package/skills/emergency-room-beds/skill.json +8 -0
  60. package/skills/ev-charger-nearby/instruction.md +80 -0
  61. package/skills/ev-charger-nearby/scripts/ev_charger.py +222 -0
  62. package/skills/ev-charger-nearby/skill.json +10 -0
  63. package/skills/ev-subsidy-status/instruction.md +165 -0
  64. package/skills/ev-subsidy-status/skill.json +10 -0
  65. package/skills/express-bus-booking/instruction.md +207 -0
  66. package/skills/express-bus-booking/references/kobus-http-flow.md +159 -0
  67. package/skills/express-bus-booking/scripts/kobus_express_booking.py +243 -0
  68. package/skills/express-bus-booking/skill.json +8 -0
  69. package/skills/fine-dust-location/instruction.md +89 -0
  70. package/skills/fine-dust-location/skill.json +9 -0
  71. package/skills/flight-ticket-search/instruction.md +237 -0
  72. package/skills/flight-ticket-search/scripts/flight_ticket_search.py +501 -0
  73. package/skills/flight-ticket-search/skill.json +9 -0
  74. package/skills/foresttrip-vacancy/instruction.md +167 -0
  75. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +549 -0
  76. package/skills/foresttrip-vacancy/skill.json +10 -0
  77. package/skills/fsc-corporate-info/instruction.md +57 -0
  78. package/skills/fsc-corporate-info/scripts/fsc_corporate_info.py +113 -0
  79. package/skills/fsc-corporate-info/skill.json +10 -0
  80. package/skills/g2b-order-plan-search/instruction.md +131 -0
  81. package/skills/g2b-order-plan-search/scripts/g2b_order_plan.py +158 -0
  82. package/skills/g2b-order-plan-search/skill.json +10 -0
  83. package/skills/g2b-sanctioned-supplier/instruction.md +61 -0
  84. package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +114 -0
  85. package/skills/g2b-sanctioned-supplier/skill.json +10 -0
  86. package/skills/gangnamunni-clinic-search/instruction.md +113 -0
  87. package/skills/gangnamunni-clinic-search/skill.json +9 -0
  88. package/skills/geeknews-search/instruction.md +69 -0
  89. package/skills/geeknews-search/scripts/geeknews_search.py +296 -0
  90. package/skills/geeknews-search/skill.json +8 -0
  91. package/skills/gongsijiga-search/instruction.md +128 -0
  92. package/skills/gongsijiga-search/skill.json +9 -0
  93. package/skills/gov-overseas-trip-report/instruction.md +488 -0
  94. package/skills/gov-overseas-trip-report/scripts/gov_overseas_trip_report.py +1206 -0
  95. package/skills/gov-overseas-trip-report/skill.json +9 -0
  96. package/skills/han-river-water-level/instruction.md +83 -0
  97. package/skills/han-river-water-level/skill.json +10 -0
  98. package/skills/highway-traffic-status/instruction.md +88 -0
  99. package/skills/highway-traffic-status/scripts/highway_traffic.py +315 -0
  100. package/skills/highway-traffic-status/skill.json +11 -0
  101. package/skills/hipass-receipt/instruction.md +97 -0
  102. package/skills/hipass-receipt/skill.json +10 -0
  103. package/skills/hola-poke-yeoksam/instruction.md +247 -0
  104. package/skills/hola-poke-yeoksam/skill.json +8 -0
  105. package/skills/household-waste-info/instruction.md +117 -0
  106. package/skills/household-waste-info/skill.json +10 -0
  107. package/skills/housing-official-price/instruction.md +177 -0
  108. package/skills/housing-official-price/skill.json +10 -0
  109. package/skills/hwp/instruction.md +206 -0
  110. package/skills/hwp/skill.json +8 -0
  111. package/skills/intercity-bus-booking/instruction.md +189 -0
  112. package/skills/intercity-bus-booking/references/tmoney-intercity-http-flow.md +126 -0
  113. package/skills/intercity-bus-booking/scripts/intercity_bus_search.py +381 -0
  114. package/skills/intercity-bus-booking/skill.json +8 -0
  115. package/skills/iros-registry-automation/instruction.md +229 -0
  116. package/skills/iros-registry-automation/scripts/iros_pdf_summary.py +249 -0
  117. package/skills/iros-registry-automation/scripts/upstream.pin +1 -0
  118. package/skills/iros-registry-automation/skill.json +9 -0
  119. package/skills/job-posting-match/instruction.md +130 -0
  120. package/skills/job-posting-match/scripts/job_posting_match.py +396 -0
  121. package/skills/job-posting-match/scripts/test_job_posting_match.py +54 -0
  122. package/skills/job-posting-match/skill.json +8 -0
  123. package/skills/jobkorea-talent-search/instruction.md +118 -0
  124. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_models.py +27 -0
  125. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_parse.py +186 -0
  126. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search.py +94 -0
  127. package/skills/jobkorea-talent-search/scripts/jobkorea_talent_search_condition.py +136 -0
  128. package/skills/jobkorea-talent-search/scripts/test_jobkorea_talent_search.py +76 -0
  129. package/skills/jobkorea-talent-search/skill.json +9 -0
  130. package/skills/joseon-sillok-search/instruction.md +76 -0
  131. package/skills/joseon-sillok-search/scripts/sillok_search.py +552 -0
  132. package/skills/joseon-sillok-search/skill.json +8 -0
  133. package/skills/k-dart/instruction.md +406 -0
  134. package/skills/k-dart/skill.json +8 -0
  135. package/skills/k-schoollunch-menu/instruction.md +109 -0
  136. package/skills/k-schoollunch-menu/skill.json +9 -0
  137. package/skills/k-skill-cleaner/instruction.md +80 -0
  138. package/skills/k-skill-cleaner/scripts/k_skill_cleaner.py +410 -0
  139. package/skills/k-skill-cleaner/skill.json +8 -0
  140. package/skills/k-skill-setup/instruction.md +253 -0
  141. package/skills/k-skill-setup/skill.json +11 -0
  142. package/skills/kakao-bar-nearby/instruction.md +76 -0
  143. package/skills/kakao-bar-nearby/skill.json +8 -0
  144. package/skills/kakao-map/instruction.md +176 -0
  145. package/skills/kakao-map/skill.json +10 -0
  146. package/skills/kakaotalk-mac/instruction.md +189 -0
  147. package/skills/kakaotalk-mac/skill.json +8 -0
  148. package/skills/kbl-results/instruction.md +89 -0
  149. package/skills/kbl-results/skill.json +9 -0
  150. package/skills/kbo-results/instruction.md +82 -0
  151. package/skills/kbo-results/skill.json +8 -0
  152. package/skills/keris-academic-search/instruction.md +81 -0
  153. package/skills/keris-academic-search/scripts/keris_academic.py +210 -0
  154. package/skills/keris-academic-search/skill.json +10 -0
  155. package/skills/kleague-results/instruction.md +92 -0
  156. package/skills/kleague-results/skill.json +8 -0
  157. package/skills/kopis-performance-search/instruction.md +109 -0
  158. package/skills/kopis-performance-search/skill.json +10 -0
  159. package/skills/korea-weather/instruction.md +93 -0
  160. package/skills/korea-weather/skill.json +10 -0
  161. package/skills/korean-character-count/instruction.md +87 -0
  162. package/skills/korean-character-count/scripts/korean_character_count.js +268 -0
  163. package/skills/korean-character-count/skill.json +8 -0
  164. package/skills/korean-cinema-search/instruction.md +177 -0
  165. package/skills/korean-cinema-search/skill.json +8 -0
  166. package/skills/korean-heritage-search/instruction.md +106 -0
  167. package/skills/korean-heritage-search/scripts/korean_heritage_search.py +321 -0
  168. package/skills/korean-heritage-search/skill.json +8 -0
  169. package/skills/korean-holiday-calendar/instruction.md +97 -0
  170. package/skills/korean-holiday-calendar/skill.json +9 -0
  171. package/skills/korean-humanizer/instruction.md +389 -0
  172. package/skills/korean-humanizer/references/ai-tell-taxonomy.md +147 -0
  173. package/skills/korean-humanizer/skill.json +8 -0
  174. package/skills/korean-jangbu-for/instruction.md +133 -0
  175. package/skills/korean-jangbu-for/scripts/install.sh +237 -0
  176. package/skills/korean-jangbu-for/scripts/upstream.pin +1 -0
  177. package/skills/korean-jangbu-for/skill.json +8 -0
  178. package/skills/korean-law-search/instruction.md +126 -0
  179. package/skills/korean-law-search/skill.json +10 -0
  180. package/skills/korean-marathon-schedule/instruction.md +111 -0
  181. package/skills/korean-marathon-schedule/skill.json +9 -0
  182. package/skills/korean-middle-korean/instruction.md +79 -0
  183. package/skills/korean-middle-korean/scripts/korean_middle_korean.js +214 -0
  184. package/skills/korean-middle-korean/skill.json +8 -0
  185. package/skills/korean-patent-search/instruction.md +79 -0
  186. package/skills/korean-patent-search/scripts/patent_search.py +409 -0
  187. package/skills/korean-patent-search/skill.json +9 -0
  188. package/skills/korean-privacy-terms/instruction.md +128 -0
  189. package/skills/korean-privacy-terms/scripts/install.sh +108 -0
  190. package/skills/korean-privacy-terms/scripts/upstream.pin +1 -0
  191. package/skills/korean-privacy-terms/skill.json +8 -0
  192. package/skills/korean-scholarship-search/instruction.md +317 -0
  193. package/skills/korean-scholarship-search/references/report-format.md +40 -0
  194. package/skills/korean-scholarship-search/references/school-discovery.md +61 -0
  195. package/skills/korean-scholarship-search/references/search-clues.md +58 -0
  196. package/skills/korean-scholarship-search/references/source-patterns.md +67 -0
  197. package/skills/korean-scholarship-search/scripts/scholarship_filter.py +811 -0
  198. package/skills/korean-scholarship-search/scripts/test_scholarship_filter.py +224 -0
  199. package/skills/korean-scholarship-search/scripts/university_search_plan.py +148 -0
  200. package/skills/korean-scholarship-search/skill.json +8 -0
  201. package/skills/korean-slang-writing/instruction.md +181 -0
  202. package/skills/korean-slang-writing/scripts/_slang_http.py +91 -0
  203. package/skills/korean-slang-writing/scripts/slang_lookup.py +291 -0
  204. package/skills/korean-slang-writing/scripts/slang_search.py +284 -0
  205. package/skills/korean-slang-writing/skill.json +8 -0
  206. package/skills/korean-spell-check/instruction.md +105 -0
  207. package/skills/korean-spell-check/scripts/korean_spell_check.py +523 -0
  208. package/skills/korean-spell-check/skill.json +9 -0
  209. package/skills/korean-stock-search/instruction.md +194 -0
  210. package/skills/korean-stock-search/skill.json +10 -0
  211. package/skills/korean-transit-route/instruction.md +113 -0
  212. package/skills/korean-transit-route/skill.json +10 -0
  213. package/skills/kosis-stats/instruction.md +232 -0
  214. package/skills/kosis-stats/references/kosis-openapi-guide.md +171 -0
  215. package/skills/kosis-stats/scripts/run_kosis_stats.py +896 -0
  216. package/skills/kosis-stats/skill.json +9 -0
  217. package/skills/kr-whois-lookup/instruction.md +107 -0
  218. package/skills/kr-whois-lookup/skill.json +10 -0
  219. package/skills/kstartup-search/instruction.md +186 -0
  220. package/skills/kstartup-search/scripts/run_kstartup.py +424 -0
  221. package/skills/kstartup-search/skill.json +11 -0
  222. package/skills/ktx-booking/instruction.md +244 -0
  223. package/skills/ktx-booking/skill.json +10 -0
  224. package/skills/lck-analytics/instruction.md +192 -0
  225. package/skills/lck-analytics/scripts/_lib.js +103 -0
  226. package/skills/lck-analytics/scripts/analyze-live-game.js +52 -0
  227. package/skills/lck-analytics/scripts/build-match-report.js +44 -0
  228. package/skills/lck-analytics/scripts/sync-oracle.js +50 -0
  229. package/skills/lck-analytics/skill.json +9 -0
  230. package/skills/lh-notice-search/instruction.md +206 -0
  231. package/skills/lh-notice-search/skill.json +10 -0
  232. package/skills/library-book-search/instruction.md +139 -0
  233. package/skills/library-book-search/skill.json +9 -0
  234. package/skills/local-election-candidate-search/instruction.md +77 -0
  235. package/skills/local-election-candidate-search/skill.json +8 -0
  236. package/skills/localdata-business-status/instruction.md +64 -0
  237. package/skills/localdata-business-status/scripts/localdata_business_status.py +206 -0
  238. package/skills/localdata-business-status/skill.json +8 -0
  239. package/skills/lotto-results/instruction.md +80 -0
  240. package/skills/lotto-results/skill.json +8 -0
  241. package/skills/lovebug-report/instruction.md +185 -0
  242. package/skills/lovebug-report/skill.json +9 -0
  243. package/skills/market-kurly-search/instruction.md +125 -0
  244. package/skills/market-kurly-search/skill.json +8 -0
  245. package/skills/mfds-drug-safety/instruction.md +86 -0
  246. package/skills/mfds-drug-safety/scripts/mfds_drug_safety.py +184 -0
  247. package/skills/mfds-drug-safety/skill.json +10 -0
  248. package/skills/mfds-food-safety/instruction.md +126 -0
  249. package/skills/mfds-food-safety/scripts/mfds_food_safety.py +281 -0
  250. package/skills/mfds-food-safety/skill.json +10 -0
  251. package/skills/myrealtrip-search/instruction.md +239 -0
  252. package/skills/myrealtrip-search/scripts/myrealtrip_mcp.py +194 -0
  253. package/skills/myrealtrip-search/scripts/test_myrealtrip_mcp.py +99 -0
  254. package/skills/myrealtrip-search/skill.json +9 -0
  255. package/skills/naming-house/instruction.md +146 -0
  256. package/skills/naming-house/skill.json +8 -0
  257. package/skills/national-pension-workplace/instruction.md +64 -0
  258. package/skills/national-pension-workplace/scripts/national_pension_workplace.py +113 -0
  259. package/skills/national-pension-workplace/skill.json +10 -0
  260. package/skills/naver-ad-performance/instruction.md +108 -0
  261. package/skills/naver-ad-performance/scripts/naver_ad_performance.py +240 -0
  262. package/skills/naver-ad-performance/skill.json +9 -0
  263. package/skills/naver-blog-research/instruction.md +128 -0
  264. package/skills/naver-blog-research/scripts/_naver_http.py +58 -0
  265. package/skills/naver-blog-research/scripts/naver_download_images.py +233 -0
  266. package/skills/naver-blog-research/scripts/naver_read.py +256 -0
  267. package/skills/naver-blog-research/scripts/naver_search.py +192 -0
  268. package/skills/naver-blog-research/skill.json +8 -0
  269. package/skills/naver-news-search/instruction.md +103 -0
  270. package/skills/naver-news-search/skill.json +10 -0
  271. package/skills/naver-shopping-search/instruction.md +94 -0
  272. package/skills/naver-shopping-search/skill.json +11 -0
  273. package/skills/nhis-care-checkup-search/instruction.md +116 -0
  274. package/skills/nhis-care-checkup-search/skill.json +10 -0
  275. package/skills/nts-business-registration/instruction.md +115 -0
  276. package/skills/nts-business-registration/scripts/nts_business_registration.py +215 -0
  277. package/skills/nts-business-registration/skill.json +10 -0
  278. package/skills/nts-tax-delinquency/instruction.md +54 -0
  279. package/skills/nts-tax-delinquency/scripts/nts_tax_delinquency.py +150 -0
  280. package/skills/nts-tax-delinquency/skill.json +8 -0
  281. package/skills/ohou-today-deal/instruction.md +182 -0
  282. package/skills/ohou-today-deal/scripts/ohou_today_deal.py +369 -0
  283. package/skills/ohou-today-deal/skill.json +9 -0
  284. package/skills/olive-young-search/instruction.md +154 -0
  285. package/skills/olive-young-search/skill.json +8 -0
  286. package/skills/parking-lot-search/instruction.md +96 -0
  287. package/skills/parking-lot-search/skill.json +9 -0
  288. package/skills/popbill/instruction.md +146 -0
  289. package/skills/popbill/scripts/popbill_cli.py +284 -0
  290. package/skills/popbill/scripts/popbill_registry.py +54 -0
  291. package/skills/popbill/scripts/popbill_safety.py +37 -0
  292. package/skills/popbill/scripts/popbill_templates.py +71 -0
  293. package/skills/popbill/skill.json +9 -0
  294. package/skills/public-restroom-nearby/instruction.md +89 -0
  295. package/skills/public-restroom-nearby/skill.json +9 -0
  296. package/skills/real-estate-search/instruction.md +172 -0
  297. package/skills/real-estate-search/skill.json +10 -0
  298. package/skills/rhwp-advanced/instruction.md +145 -0
  299. package/skills/rhwp-advanced/skill.json +8 -0
  300. package/skills/rhwp-edit/instruction.md +153 -0
  301. package/skills/rhwp-edit/skill.json +8 -0
  302. package/skills/s2b-notice-search/instruction.md +66 -0
  303. package/skills/s2b-notice-search/skill.json +9 -0
  304. package/skills/saju-fortune/instruction.md +177 -0
  305. package/skills/saju-fortune/skill.json +8 -0
  306. package/skills/saramin-talent-search/instruction.md +119 -0
  307. package/skills/saramin-talent-search/skill.json +9 -0
  308. package/skills/seoul-bike/instruction.md +83 -0
  309. package/skills/seoul-bike/scripts/seoul_bike.py +247 -0
  310. package/skills/seoul-bike/skill.json +10 -0
  311. package/skills/seoul-density/instruction.md +109 -0
  312. package/skills/seoul-density/scripts/seoul_density.py +271 -0
  313. package/skills/seoul-density/skill.json +10 -0
  314. package/skills/seoul-subway-arrival/instruction.md +85 -0
  315. package/skills/seoul-subway-arrival/skill.json +9 -0
  316. package/skills/sh-notice-search/instruction.md +150 -0
  317. package/skills/sh-notice-search/skill.json +9 -0
  318. package/skills/srt-booking/instruction.md +181 -0
  319. package/skills/srt-booking/scripts/srt_booking.py +272 -0
  320. package/skills/srt-booking/scripts/srt_seats.py +156 -0
  321. package/skills/srt-booking/skill.json +20 -0
  322. package/skills/subway-lost-property/instruction.md +93 -0
  323. package/skills/subway-lost-property/scripts/subway_lost_property.py +244 -0
  324. package/skills/subway-lost-property/skill.json +8 -0
  325. package/skills/ticket-availability/instruction.md +175 -0
  326. package/skills/ticket-availability/scripts/ticket_availability.py +430 -0
  327. package/skills/ticket-availability/skill.json +9 -0
  328. package/skills/toss-securities/instruction.md +116 -0
  329. package/skills/toss-securities/skill.json +9 -0
  330. package/skills/used-car-price-search/instruction.md +109 -0
  331. package/skills/used-car-price-search/skill.json +8 -0
  332. package/skills/yebigun-training/instruction.md +177 -0
  333. package/skills/yebigun-training/skill.json +9 -0
  334. package/skills/zipcode-search/instruction.md +159 -0
  335. package/skills/zipcode-search/scripts/zipcode_search.py +150 -0
  336. package/skills/zipcode-search/skill.json +8 -0
  337. package/src/assemble.js +134 -0
  338. package/src/detect.js +18 -0
  339. package/templates/action-account.md +4 -0
  340. package/templates/action-booking.md +5 -0
  341. package/templates/action-commerce.md +5 -0
  342. package/templates/action-communication.md +5 -0
  343. package/templates/action-submission.md +5 -0
  344. package/templates/browser.md +4 -0
  345. package/templates/core.md +5 -0
  346. package/templates/hard-boundary.md +3 -0
  347. package/templates/local.md +2 -0
  348. package/templates/lookup.md +2 -0
  349. package/templates/proxy.md +2 -0
  350. package/templates/vault.md +4 -0
@@ -0,0 +1,811 @@
1
+ #!/usr/bin/env python3
2
+ """Filter normalized Korean scholarship records and estimate eligibility."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from copy import deepcopy
11
+ from datetime import date, datetime, timedelta, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ AMOUNT_KEYS = (
17
+ "annual_krw",
18
+ "per_semester_krw",
19
+ "one_time_krw",
20
+ "monthly_krw",
21
+ "max_krw",
22
+ "min_krw",
23
+ "amount_krw",
24
+ )
25
+ CANONICAL_DEADLINE_STATUSES = {"open", "upcoming", "closed", "unknown"}
26
+ KST = timezone(timedelta(hours=9), name="Asia/Seoul")
27
+ KST_LABEL = "Asia/Seoul (KST)"
28
+
29
+
30
+ def read_payload(path: str | None) -> Any:
31
+ if path:
32
+ return json.loads(Path(path).read_text(encoding="utf8"))
33
+
34
+ raw = sys.stdin.read().strip()
35
+ if not raw:
36
+ raise SystemExit("expected JSON input from --input or stdin")
37
+ return json.loads(raw)
38
+
39
+
40
+ def ensure_records(payload: Any) -> list[dict[str, Any]]:
41
+ if isinstance(payload, list):
42
+ return [item for item in payload if isinstance(item, dict)]
43
+ if isinstance(payload, dict):
44
+ if isinstance(payload.get("items"), list):
45
+ return [item for item in payload["items"] if isinstance(item, dict)]
46
+ return [payload]
47
+ raise SystemExit("input JSON must be an object or an array of objects")
48
+
49
+
50
+ def as_list(value: Any) -> list[Any]:
51
+ if value is None:
52
+ return []
53
+ if isinstance(value, list):
54
+ return value
55
+ return [value]
56
+
57
+
58
+ def normalize_text(value: Any) -> str:
59
+ return str(value or "").strip().lower()
60
+
61
+
62
+ def contains_text(values: list[Any], needle: str) -> bool:
63
+ target = normalize_text(needle)
64
+ return any(target in normalize_text(value) for value in values)
65
+
66
+
67
+ def parse_int(value: Any) -> int | None:
68
+ if value is None or value == "":
69
+ return None
70
+ if isinstance(value, bool):
71
+ return None
72
+ if isinstance(value, int):
73
+ return value
74
+ if isinstance(value, float):
75
+ return int(value)
76
+ try:
77
+ return int(str(value).replace(",", "").strip())
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ def parse_float(value: Any) -> float | None:
83
+ if value is None or value == "":
84
+ return None
85
+ if isinstance(value, bool):
86
+ return None
87
+ try:
88
+ return float(str(value).replace(",", "").strip())
89
+ except ValueError:
90
+ return None
91
+
92
+
93
+ def parse_date(value: Any) -> date | None:
94
+ if not value:
95
+ return None
96
+ text = str(value).strip()
97
+ for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y%m%d"):
98
+ try:
99
+ if fmt == "%Y%m%d" and len(text) != 8:
100
+ continue
101
+ if fmt == "%Y-%m-%d":
102
+ parts = text.split("-")
103
+ if len(parts) == 3:
104
+ return date(int(parts[0]), int(parts[1]), int(parts[2]))
105
+ if fmt == "%Y.%m.%d":
106
+ parts = text.split(".")
107
+ if len(parts) == 3:
108
+ return date(int(parts[0]), int(parts[1]), int(parts[2]))
109
+ if fmt == "%Y/%m/%d":
110
+ parts = text.split("/")
111
+ if len(parts) == 3:
112
+ return date(int(parts[0]), int(parts[1]), int(parts[2]))
113
+ if fmt == "%Y%m%d":
114
+ return date(int(text[0:4]), int(text[4:6]), int(text[6:8]))
115
+ except ValueError:
116
+ continue
117
+ return None
118
+
119
+
120
+ def current_kst_date(now: datetime | None = None) -> date:
121
+ if now is None:
122
+ return datetime.now(KST).date()
123
+ if now.tzinfo is None:
124
+ now = now.replace(tzinfo=timezone.utc)
125
+ return now.astimezone(KST).date()
126
+
127
+
128
+ def resolve_today(value: str | None) -> date:
129
+ parsed = parse_date(value)
130
+ return parsed or current_kst_date()
131
+
132
+
133
+ def extract_org_type(record: dict[str, Any]) -> str:
134
+ organization = record.get("organization") or {}
135
+ return normalize_text(record.get("org_type") or organization.get("type") or "")
136
+
137
+
138
+ def extract_org_name(record: dict[str, Any]) -> str:
139
+ organization = record.get("organization") or {}
140
+ return str(record.get("organization_name") or organization.get("name") or "").strip()
141
+
142
+
143
+ def parse_amount_from_text(text: str) -> list[int]:
144
+ candidates: list[int] = []
145
+ for raw, unit in re.findall(r"([0-9][0-9,]*(?:\.[0-9]+)?)\s*(만원|원)", text):
146
+ try:
147
+ value = float(raw.replace(",", ""))
148
+ except ValueError:
149
+ continue
150
+ multiplier = 10000 if unit == "만원" else 1
151
+ candidates.append(int(value * multiplier))
152
+ return candidates
153
+
154
+
155
+ def normalize_deadline_status(value: Any) -> str | None:
156
+ status = normalize_text(value)
157
+ if status in CANONICAL_DEADLINE_STATUSES:
158
+ return status
159
+ return None
160
+
161
+
162
+ def extract_amount_value(record: dict[str, Any]) -> int | None:
163
+ amount = record.get("amount")
164
+ candidates: list[int] = []
165
+
166
+ if isinstance(amount, dict):
167
+ for key in AMOUNT_KEYS:
168
+ parsed = parse_int(amount.get(key))
169
+ if parsed is not None:
170
+ candidates.append(parsed)
171
+ text = str(amount.get("text") or "")
172
+ candidates.extend(parse_amount_from_text(text))
173
+ else:
174
+ parsed = parse_int(record.get("amount_krw"))
175
+ if parsed is not None:
176
+ candidates.append(parsed)
177
+ if isinstance(amount, str):
178
+ candidates.extend(parse_amount_from_text(amount))
179
+ text = str(record.get("amount_text") or "")
180
+ candidates.extend(parse_amount_from_text(text))
181
+
182
+ return max(candidates) if candidates else None
183
+
184
+
185
+ def infer_deadline_status(record: dict[str, Any], today: date | None = None) -> str:
186
+ today = today or current_kst_date()
187
+ deadline = record.get("deadline") or {}
188
+ start_at = parse_date(deadline.get("start"))
189
+ end_at = parse_date(deadline.get("end"))
190
+
191
+ if end_at and end_at < today:
192
+ return "closed"
193
+ if start_at and start_at > today:
194
+ return "upcoming"
195
+ if end_at and end_at >= today:
196
+ return "open"
197
+ cached_status = normalize_deadline_status(deadline.get("status") or record.get("deadline_status"))
198
+ return cached_status or "unknown"
199
+
200
+
201
+ def deadline_context(record: dict[str, Any], today: date | None = None) -> dict[str, Any]:
202
+ today = today or current_kst_date()
203
+ deadline = record.get("deadline") or {}
204
+ start_at = parse_date(deadline.get("start"))
205
+ end_at = parse_date(deadline.get("end"))
206
+ status = infer_deadline_status(record, today)
207
+ days_until_start = (start_at - today).days if start_at else None
208
+ days_until_end = (end_at - today).days if end_at else None
209
+ return {
210
+ "today": today.isoformat(),
211
+ "start": start_at.isoformat() if start_at else None,
212
+ "end": end_at.isoformat() if end_at else None,
213
+ "status": status,
214
+ "days_until_start": days_until_start,
215
+ "days_until_end": days_until_end,
216
+ }
217
+
218
+
219
+ def get_eligibility(record: dict[str, Any]) -> dict[str, Any]:
220
+ eligibility = record.get("eligibility")
221
+ if isinstance(eligibility, dict):
222
+ return eligibility
223
+ return {}
224
+
225
+
226
+ def extract_department_names(record: dict[str, Any]) -> list[Any]:
227
+ eligibility = get_eligibility(record)
228
+ values = as_list(eligibility.get("department_names"))
229
+ if values:
230
+ return values
231
+ return as_list(eligibility.get("majors"))
232
+
233
+
234
+ def school_match_values(record: dict[str, Any]) -> list[Any]:
235
+ eligibility = get_eligibility(record)
236
+ values: list[Any] = []
237
+ values.extend(as_list(eligibility.get("school_names")))
238
+ values.append(extract_org_name(record))
239
+ return [value for value in values if value]
240
+
241
+
242
+ def department_match_values(record: dict[str, Any]) -> list[Any]:
243
+ values: list[Any] = []
244
+ values.extend(extract_department_names(record))
245
+ values.append(extract_org_name(record))
246
+ values.append(record.get("source_url"))
247
+ values.append(record.get("summary"))
248
+ return [value for value in values if value]
249
+
250
+
251
+ def match_query(record: dict[str, Any], query: str | None) -> bool:
252
+ if not query:
253
+ return True
254
+
255
+ haystacks = [
256
+ record.get("name"),
257
+ extract_org_name(record),
258
+ record.get("summary"),
259
+ record.get("notes"),
260
+ record.get("source_url"),
261
+ record.get("apply_url"),
262
+ ]
263
+ eligibility = get_eligibility(record)
264
+ haystacks.extend(as_list(eligibility.get("majors")))
265
+ haystacks.extend(extract_department_names(record))
266
+ haystacks.extend(as_list(eligibility.get("notes")))
267
+ return contains_text(haystacks, query)
268
+
269
+
270
+ def match_filter(record: dict[str, Any], args: argparse.Namespace) -> tuple[bool, list[str]]:
271
+ reasons: list[str] = []
272
+ eligibility = get_eligibility(record)
273
+ today = resolve_today(getattr(args, "today", None))
274
+
275
+ if not match_query(record, args.q):
276
+ return False, reasons
277
+
278
+ if args.org_type:
279
+ org_type = extract_org_type(record)
280
+ if org_type != normalize_text(args.org_type):
281
+ return False, reasons
282
+ reasons.append(f"org_type={org_type}")
283
+
284
+ context = deadline_context(record, today)
285
+
286
+ if args.deadline_status:
287
+ deadline_status = context["status"]
288
+ if deadline_status != normalize_text(args.deadline_status):
289
+ return False, reasons
290
+ reasons.append(f"deadline_status={deadline_status}")
291
+
292
+ if getattr(args, "only_open_now", False):
293
+ if context["status"] != "open":
294
+ return False, reasons
295
+ reasons.append("only_open_now")
296
+
297
+ upcoming_within_days = getattr(args, "upcoming_within_days", None)
298
+ if upcoming_within_days is not None:
299
+ days_until_start = context["days_until_start"]
300
+ if context["status"] != "upcoming" or days_until_start is None or days_until_start < 0 or days_until_start > upcoming_within_days:
301
+ return False, reasons
302
+ reasons.append(f"upcoming_within_days<={upcoming_within_days}")
303
+
304
+ deadline_within_days = getattr(args, "deadline_within_days", None)
305
+ if deadline_within_days is not None:
306
+ days_until_end = context["days_until_end"]
307
+ if days_until_end is None or days_until_end < 0 or days_until_end > deadline_within_days:
308
+ return False, reasons
309
+ reasons.append(f"deadline_within_days<={deadline_within_days}")
310
+
311
+ if args.school_kind:
312
+ school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
313
+ if school_kinds and normalize_text(args.school_kind) not in school_kinds:
314
+ return False, reasons
315
+ if school_kinds:
316
+ reasons.append(f"school_kind={args.school_kind}")
317
+ else:
318
+ reasons.append("school_kind=?")
319
+
320
+ if args.school_name:
321
+ school_names = school_match_values(record)
322
+ if school_names and not contains_text(school_names, args.school_name):
323
+ return False, reasons
324
+ if school_names:
325
+ reasons.append(f"school_name~={args.school_name}")
326
+ else:
327
+ reasons.append("school_name=?")
328
+
329
+ if args.student_level:
330
+ student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
331
+ if student_levels and normalize_text(args.student_level) not in student_levels:
332
+ return False, reasons
333
+ if student_levels:
334
+ reasons.append(f"student_level={args.student_level}")
335
+ else:
336
+ reasons.append("student_level=?")
337
+
338
+ if args.major:
339
+ majors = as_list(eligibility.get("majors"))
340
+ if majors and not contains_text(majors, args.major):
341
+ return False, reasons
342
+ if majors:
343
+ reasons.append(f"major~={args.major}")
344
+ else:
345
+ reasons.append("major=?")
346
+
347
+ if getattr(args, "department_name", None):
348
+ departments = department_match_values(record)
349
+ if departments and not contains_text(departments, args.department_name):
350
+ return False, reasons
351
+ if departments:
352
+ reasons.append(f"department_name~={args.department_name}")
353
+ else:
354
+ reasons.append("department_name=?")
355
+
356
+ if args.grade_year is not None:
357
+ grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
358
+ grade_years.discard(None)
359
+ if grade_years and args.grade_year not in grade_years:
360
+ return False, reasons
361
+ if grade_years:
362
+ reasons.append(f"grade_year={args.grade_year}")
363
+ else:
364
+ reasons.append("grade_year=?")
365
+
366
+ if args.gpa is not None:
367
+ gpa_min = parse_float(eligibility.get("gpa_min"))
368
+ if gpa_min is not None and args.gpa < gpa_min:
369
+ return False, reasons
370
+ if gpa_min is not None:
371
+ reasons.append(f"gpa>={gpa_min}")
372
+ else:
373
+ reasons.append("gpa=?")
374
+
375
+ if args.income_band is not None:
376
+ income_band_min = parse_int(eligibility.get("income_band_min"))
377
+ income_band_max = parse_int(eligibility.get("income_band_max"))
378
+ income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
379
+ income_bands.discard(None)
380
+
381
+ if income_bands and args.income_band not in income_bands:
382
+ return False, reasons
383
+ if income_band_min is not None and args.income_band < income_band_min:
384
+ return False, reasons
385
+ if income_band_max is not None and args.income_band > income_band_max:
386
+ return False, reasons
387
+ if income_bands or income_band_min is not None or income_band_max is not None:
388
+ reasons.append(f"income_band={args.income_band}")
389
+ else:
390
+ reasons.append("income_band=?")
391
+
392
+ amount_value = extract_amount_value(record)
393
+ if args.min_amount is not None:
394
+ if amount_value is not None and amount_value < args.min_amount:
395
+ return False, reasons
396
+ if amount_value is None:
397
+ if getattr(args, "strict_amount", False):
398
+ return False, reasons
399
+ reasons.append(f"amount>={args.min_amount}?")
400
+ else:
401
+ reasons.append(f"amount>={args.min_amount}")
402
+ if args.max_amount is not None:
403
+ if amount_value is not None and amount_value > args.max_amount:
404
+ return False, reasons
405
+ if amount_value is None:
406
+ if getattr(args, "strict_amount", False):
407
+ return False, reasons
408
+ reasons.append(f"amount<={args.max_amount}?")
409
+ else:
410
+ reasons.append(f"amount<={args.max_amount}")
411
+
412
+ return True, reasons
413
+
414
+
415
+ def command_filter(args: argparse.Namespace) -> int:
416
+ records = ensure_records(read_payload(args.input))
417
+ items: list[dict[str, Any]] = []
418
+ today = resolve_today(getattr(args, "today", None))
419
+
420
+ for record in records:
421
+ matched, reasons = match_filter(record, args)
422
+ if not matched:
423
+ continue
424
+ entry = deepcopy(record)
425
+ entry["_match"] = {
426
+ "amount_krw": extract_amount_value(record),
427
+ "deadline": deadline_context(record, today),
428
+ "deadline_status": infer_deadline_status(record, today),
429
+ "reasons": reasons,
430
+ }
431
+ items.append(entry)
432
+
433
+ payload = {
434
+ "filters": {
435
+ "q": args.q,
436
+ "org_type": args.org_type,
437
+ "school_kind": args.school_kind,
438
+ "school_name": args.school_name,
439
+ "student_level": args.student_level,
440
+ "major": args.major,
441
+ "department_name": args.department_name,
442
+ "grade_year": args.grade_year,
443
+ "gpa": args.gpa,
444
+ "income_band": args.income_band,
445
+ "min_amount": args.min_amount,
446
+ "max_amount": args.max_amount,
447
+ "deadline_status": args.deadline_status,
448
+ "today": args.today,
449
+ "only_open_now": args.only_open_now,
450
+ "upcoming_within_days": args.upcoming_within_days,
451
+ "deadline_within_days": args.deadline_within_days,
452
+ },
453
+ "total": len(items),
454
+ "items": items,
455
+ }
456
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
457
+ sys.stdout.write("\n")
458
+ return 0
459
+
460
+
461
+ def eligibility_result(record: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
462
+ failed: list[str] = []
463
+ passed: list[str] = []
464
+ unknown: list[str] = []
465
+ eligibility = get_eligibility(record)
466
+
467
+ if args.org_type:
468
+ org_type = extract_org_type(record)
469
+ if not org_type:
470
+ unknown.append("org_type=?")
471
+ elif org_type == normalize_text(args.org_type):
472
+ passed.append(f"org_type={org_type}")
473
+ else:
474
+ failed.append(f"org_type mismatch: {org_type or 'unknown'}")
475
+
476
+ if args.school_kind:
477
+ school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
478
+ if school_kinds and normalize_text(args.school_kind) not in school_kinds:
479
+ failed.append(f"school_kind mismatch: {school_kinds}")
480
+ elif school_kinds:
481
+ passed.append(f"school_kind={args.school_kind}")
482
+ else:
483
+ unknown.append("school_kind=?")
484
+
485
+ if args.school_name:
486
+ school_names = school_match_values(record)
487
+ if school_names and not contains_text(school_names, args.school_name):
488
+ failed.append(f"school_name mismatch: {school_names}")
489
+ elif school_names:
490
+ passed.append(f"school_name~={args.school_name}")
491
+ else:
492
+ unknown.append("school_name=?")
493
+
494
+ if args.student_level:
495
+ student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
496
+ if student_levels and normalize_text(args.student_level) not in student_levels:
497
+ failed.append(f"student_level mismatch: {student_levels}")
498
+ elif student_levels:
499
+ passed.append(f"student_level={args.student_level}")
500
+ else:
501
+ unknown.append("student_level=?")
502
+
503
+ if args.major:
504
+ majors = as_list(eligibility.get("majors"))
505
+ if majors and not contains_text(majors, args.major):
506
+ failed.append(f"major mismatch: {majors}")
507
+ elif majors:
508
+ passed.append(f"major~={args.major}")
509
+ else:
510
+ unknown.append("major=?")
511
+
512
+ if getattr(args, "department_name", None):
513
+ departments = department_match_values(record)
514
+ if departments and not contains_text(departments, args.department_name):
515
+ failed.append(f"department_name mismatch: {departments}")
516
+ elif departments:
517
+ passed.append(f"department_name~={args.department_name}")
518
+ else:
519
+ unknown.append("department_name=?")
520
+
521
+ if args.grade_year is not None:
522
+ grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
523
+ grade_years.discard(None)
524
+ if grade_years and args.grade_year not in grade_years:
525
+ failed.append(f"grade_year mismatch: {sorted(grade_years)}")
526
+ elif grade_years:
527
+ passed.append(f"grade_year={args.grade_year}")
528
+ else:
529
+ unknown.append("grade_year=?")
530
+
531
+ if args.gpa is not None:
532
+ gpa_min = parse_float(eligibility.get("gpa_min"))
533
+ if gpa_min is not None and args.gpa < gpa_min:
534
+ failed.append(f"gpa below minimum: {gpa_min}")
535
+ elif gpa_min is not None:
536
+ passed.append(f"gpa>={gpa_min}")
537
+ else:
538
+ unknown.append("gpa=?")
539
+
540
+ if args.income_band is not None:
541
+ income_band_min = parse_int(eligibility.get("income_band_min"))
542
+ income_band_max = parse_int(eligibility.get("income_band_max"))
543
+ income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
544
+ income_bands.discard(None)
545
+
546
+ if income_bands and args.income_band not in income_bands:
547
+ failed.append(f"income_band mismatch: {sorted(income_bands)}")
548
+ elif income_band_min is not None and args.income_band < income_band_min:
549
+ failed.append(f"income_band below minimum: {income_band_min}")
550
+ elif income_band_max is not None and args.income_band > income_band_max:
551
+ failed.append(f"income_band above maximum: {income_band_max}")
552
+ elif income_bands or income_band_min is not None or income_band_max is not None:
553
+ passed.append(f"income_band={args.income_band}")
554
+ else:
555
+ unknown.append("income_band=?")
556
+
557
+ if failed:
558
+ status = "not_eligible"
559
+ elif unknown:
560
+ status = "indeterminate"
561
+ elif passed:
562
+ status = "eligible"
563
+ else:
564
+ status = "indeterminate"
565
+ return {
566
+ "name": record.get("name"),
567
+ "organization_name": extract_org_name(record),
568
+ "organization_type": extract_org_type(record),
569
+ "source_url": record.get("source_url"),
570
+ "apply_url": record.get("apply_url"),
571
+ "status": status,
572
+ "passed": passed,
573
+ "failed": failed,
574
+ "unknown": unknown,
575
+ }
576
+
577
+
578
+ def command_eligibility(args: argparse.Namespace) -> int:
579
+ records = ensure_records(read_payload(args.input))
580
+ results = [eligibility_result(record, args) for record in records]
581
+ payload = {
582
+ "profile": {
583
+ "org_type": args.org_type,
584
+ "school_kind": args.school_kind,
585
+ "school_name": args.school_name,
586
+ "student_level": args.student_level,
587
+ "major": args.major,
588
+ "department_name": args.department_name,
589
+ "grade_year": args.grade_year,
590
+ "gpa": args.gpa,
591
+ "income_band": args.income_band,
592
+ },
593
+ "total": len(results),
594
+ "results": results,
595
+ }
596
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
597
+ sys.stdout.write("\n")
598
+ return 0
599
+
600
+
601
+ def add_common_filters(parser: argparse.ArgumentParser) -> None:
602
+ parser.add_argument("--input", help="JSON file path. If omitted, reads from stdin.")
603
+ parser.add_argument("--q", help="Keyword filter across name, organization, notes, and majors.")
604
+ parser.add_argument("--org-type", help="school|foundation|government|company|local-government|other")
605
+ parser.add_argument("--school-kind", help="highschool|college|university|graduate-school")
606
+ parser.add_argument("--school-name", help="Partial match against supported school names.")
607
+ parser.add_argument("--student-level", help="highschool|undergraduate|graduate|all")
608
+ parser.add_argument("--major", help="Partial match against target major names.")
609
+ parser.add_argument("--department-name", help="Partial match against department/program names.")
610
+ parser.add_argument("--grade-year", type=int, help="Student year, e.g. 1, 2, 3, 4.")
611
+ parser.add_argument("--gpa", type=float, help="Current GPA for eligibility check.")
612
+ parser.add_argument("--income-band", type=int, help="학자금 지원구간 integer, usually 0~10.")
613
+ parser.add_argument(
614
+ "--today",
615
+ help=f"Override current date for deadline filtering/reporting. When omitted or unparsable, defaults to current KST date ({KST_LABEL}), e.g. 2026-04-14.",
616
+ )
617
+
618
+
619
+ def format_krw(value: int | None) -> str:
620
+ if value is None:
621
+ return "미공개"
622
+ if value >= 100000000:
623
+ return f"{value / 100000000:.1f}억 원"
624
+ if value >= 10000:
625
+ return f"{value / 10000:.0f}만 원"
626
+ return f"{value:,}원"
627
+
628
+
629
+ def compact_eligibility_text(record: dict[str, Any]) -> str:
630
+ eligibility = get_eligibility(record)
631
+ chunks: list[str] = []
632
+
633
+ school_names = as_list(eligibility.get("school_names"))
634
+ if school_names:
635
+ chunks.append("학교 " + ", ".join(map(str, school_names[:3])))
636
+
637
+ departments = extract_department_names(record)
638
+ if departments:
639
+ chunks.append("학과/전공 " + ", ".join(map(str, departments[:3])))
640
+
641
+ student_levels = as_list(eligibility.get("student_levels"))
642
+ if student_levels:
643
+ chunks.append("학생구분 " + ", ".join(map(str, student_levels)))
644
+
645
+ grade_years = [str(value) for value in as_list(eligibility.get("grade_years")) if value is not None]
646
+ if grade_years:
647
+ chunks.append("학년 " + ", ".join(grade_years))
648
+
649
+ gpa_min = eligibility.get("gpa_min")
650
+ if gpa_min not in (None, ""):
651
+ chunks.append(f"GPA {gpa_min} 이상")
652
+
653
+ income_band_min = eligibility.get("income_band_min")
654
+ income_band_max = eligibility.get("income_band_max")
655
+ if income_band_min not in (None, "") or income_band_max not in (None, ""):
656
+ if income_band_min not in (None, "") and income_band_max not in (None, ""):
657
+ chunks.append(f"지원구간 {income_band_min}~{income_band_max}")
658
+ elif income_band_max not in (None, ""):
659
+ chunks.append(f"지원구간 {income_band_max} 이하")
660
+ else:
661
+ chunks.append(f"지원구간 {income_band_min} 이상")
662
+
663
+ notes = as_list(eligibility.get("notes"))
664
+ if notes:
665
+ chunks.append(str(notes[0]))
666
+
667
+ return " / ".join(chunks) if chunks else "세부 자격은 공고 원문 확인"
668
+
669
+
670
+ def report_entry(record: dict[str, Any], today: date) -> str:
671
+ match_meta = record.get("_match") if isinstance(record.get("_match"), dict) else {}
672
+ context = match_meta.get("deadline") if isinstance(match_meta.get("deadline"), dict) else deadline_context(record, today)
673
+ amount_text = None
674
+ if isinstance(record.get("amount"), dict):
675
+ amount_text = record["amount"].get("text")
676
+ if not amount_text:
677
+ amount_text = format_krw(extract_amount_value(record))
678
+
679
+ status_label = context["status"]
680
+ if context["days_until_end"] is not None and context["days_until_end"] >= 0:
681
+ status_label = f"{status_label} / D-{context['days_until_end']}"
682
+ elif context["days_until_start"] is not None and context["days_until_start"] >= 0 and context["status"] == "upcoming":
683
+ status_label = f"{status_label} / starts in {context['days_until_start']}d"
684
+
685
+ organization_name = extract_org_name(record) or "기관명 미상"
686
+ organization_type = extract_org_type(record) or "unknown"
687
+ period = f"{context['start'] or '?'} ~ {context['end'] or '?'}"
688
+ source_url = record.get("source_url") or "-"
689
+ apply_url = record.get("apply_url") or "-"
690
+ reasons = match_meta.get("reasons") if isinstance(match_meta.get("reasons"), list) else []
691
+
692
+ lines = [
693
+ f"### {record.get('name') or '장학금명 미상'}",
694
+ f"- 기관: {organization_name} ({organization_type})",
695
+ f"- 금액: {amount_text}",
696
+ f"- 기간: {period}",
697
+ f"- 상태: {status_label}",
698
+ f"- 핵심 조건: {compact_eligibility_text(record)}",
699
+ ]
700
+ if reasons:
701
+ lines.append(f"- 필터 판정: {', '.join(reasons)}")
702
+ lines.extend(
703
+ [
704
+ f"- 공식 공고: {source_url}",
705
+ f"- 신청 링크: {apply_url}",
706
+ ]
707
+ )
708
+ return "\n".join(lines)
709
+
710
+
711
+ def command_report(args: argparse.Namespace) -> int:
712
+ today = resolve_today(args.today)
713
+ records = ensure_records(read_payload(args.input))
714
+ matched: list[dict[str, Any]] = []
715
+
716
+ for record in records:
717
+ ok, reasons = match_filter(record, args)
718
+ if ok:
719
+ entry = deepcopy(record)
720
+ entry["_match"] = {
721
+ "amount_krw": extract_amount_value(record),
722
+ "deadline": deadline_context(record, today),
723
+ "deadline_status": infer_deadline_status(record, today),
724
+ "reasons": reasons,
725
+ }
726
+ matched.append(entry)
727
+
728
+ groups = {"open": [], "upcoming": [], "closed": [], "unknown": []}
729
+ for record in matched:
730
+ status = normalize_deadline_status((record.get("_match") or {}).get("deadline_status")) or "unknown"
731
+ groups[status].append(record)
732
+
733
+ lines = [
734
+ "# 장학금 검색 및 조회 리포트",
735
+ f"- 기준일: {today.isoformat()} ({KST_LABEL})",
736
+ f"- 총 후보 수: {len(matched)}",
737
+ f"- 지금 지원 가능: {len(groups['open'])}",
738
+ f"- 곧 열림: {len(groups['upcoming'])}",
739
+ f"- 마감됨: {len(groups['closed'])}",
740
+ f"- 상태 미확인: {len(groups['unknown'])}",
741
+ "",
742
+ ]
743
+
744
+ sections = [
745
+ ("지금 지원 가능", "open"),
746
+ ("곧 열림", "upcoming"),
747
+ ("마감됨", "closed"),
748
+ ("상태 미확인", "unknown"),
749
+ ]
750
+ for title, key in sections:
751
+ if not groups[key]:
752
+ continue
753
+ lines.append(f"## {title}")
754
+ lines.append("")
755
+ for record in groups[key]:
756
+ lines.append(report_entry(record, today))
757
+ lines.append("")
758
+
759
+ sys.stdout.write("\n".join(lines).rstrip() + "\n")
760
+ return 0
761
+
762
+
763
+ def build_parser() -> argparse.ArgumentParser:
764
+ parser = argparse.ArgumentParser(
765
+ description="Filter normalized Korean scholarship records and estimate eligibility from structured JSON.",
766
+ )
767
+ subparsers = parser.add_subparsers(dest="command", required=True)
768
+
769
+ filter_parser = subparsers.add_parser("filter", help="Filter scholarship records by profile and preference.")
770
+ add_common_filters(filter_parser)
771
+ filter_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
772
+ filter_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
773
+ filter_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
774
+ filter_parser.add_argument("--deadline-status", help="open|upcoming|closed")
775
+ filter_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
776
+ filter_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
777
+ filter_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
778
+ filter_parser.set_defaults(func=command_filter)
779
+
780
+ eligibility_parser = subparsers.add_parser(
781
+ "eligibility",
782
+ help="Return eligible/not_eligible verdicts for each scholarship record.",
783
+ )
784
+ add_common_filters(eligibility_parser)
785
+ eligibility_parser.set_defaults(func=command_eligibility)
786
+
787
+ report_parser = subparsers.add_parser(
788
+ "report",
789
+ help="Render a readable markdown report grouped by open/upcoming/closed based on the current date.",
790
+ )
791
+ add_common_filters(report_parser)
792
+ report_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
793
+ report_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
794
+ report_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
795
+ report_parser.add_argument("--deadline-status", help="open|upcoming|closed")
796
+ report_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
797
+ report_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
798
+ report_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
799
+ report_parser.set_defaults(func=command_report)
800
+
801
+ return parser
802
+
803
+
804
+ def main() -> int:
805
+ parser = build_parser()
806
+ args = parser.parse_args()
807
+ return args.func(args)
808
+
809
+
810
+ if __name__ == "__main__":
811
+ raise SystemExit(main())