@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,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())
|