@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,1206 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only multi-agency overseas / official-trip report discovery helper.
3
+
4
+ Verified public surfaces only. Does not adjudicate corruption or waste.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import html as html_lib
11
+ import json
12
+ import re
13
+ import sys
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from typing import Any, Callable
19
+
20
+ USER_AGENT = (
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
23
+ )
24
+ DEFAULT_TIMEOUT = 30
25
+
26
+ ProviderFn = Callable[["Client", dict[str, Any]], dict[str, Any]]
27
+
28
+
29
+ class FetchError(RuntimeError):
30
+ def __init__(self, mode: str, message: str, *, url: str | None = None):
31
+ super().__init__(message)
32
+ self.mode = mode
33
+ self.url = url
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Provider:
38
+ id: str
39
+ name: str
40
+ kind: str
41
+ description: str
42
+ list_url: str
43
+ supports_list: bool
44
+ supports_detail: bool
45
+ notes: str
46
+
47
+
48
+ PROVIDERS: dict[str, Provider] = {
49
+ "nec": Provider(
50
+ id="nec",
51
+ name="중앙선거관리위원회",
52
+ kind="agency_board",
53
+ description="공무국외출장보고서 게시판 (첨부 PDF 전량 공개)",
54
+ list_url="https://www.nec.go.kr/site/nec/ex/bbs/List.do?cbIdx=1107",
55
+ supports_list=True,
56
+ supports_detail=True,
57
+ notes="GET pageIndex 페이지네이션. POST pageIndex 는 서버 오류.",
58
+ ),
59
+ "acrc": Provider(
60
+ id="acrc",
61
+ name="국민권익위원회",
62
+ kind="agency_board",
63
+ description="사전정보공개 국외출장 현황 게시판",
64
+ list_url="https://www.acrc.go.kr/board.es?mid=a10502060000&bid=1000",
65
+ supports_list=True,
66
+ supports_detail=True,
67
+ notes="첨부 HWPX는 boardDownload.es 로 제공.",
68
+ ),
69
+ "mpm": Provider(
70
+ id="mpm",
71
+ name="인사혁신처",
72
+ kind="policy_guide",
73
+ description="공무국외출장 제도·BTIS 등록 안내 페이지",
74
+ list_url="https://www.mpm.go.kr/mpm/info/infoService/BizService08/",
75
+ supports_list=False,
76
+ supports_detail=True,
77
+ notes="개별 출장보고서 DB가 아니라 제도 안내. 로그인 필요 시스템(BTIS 등)은 스킬 범위에서 제외.",
78
+ ),
79
+ "mois": Provider(
80
+ id="mois",
81
+ name="행정안전부",
82
+ kind="policy_guide",
83
+ description="위법 공무국외출장 지방자치단체 처리기준 등 정책 자료",
84
+ list_url=(
85
+ "https://www.mois.go.kr/frt/bbs/type001/commonSelectBoardArticle.do"
86
+ "?bbsId=BBSMSTR_000000000016&nttId=127619"
87
+ ),
88
+ supports_list=False,
89
+ supports_detail=True,
90
+ notes="출장 원문이 아니라 처리 기준 예규·첨부.",
91
+ ),
92
+ "open_portal": Provider(
93
+ id="open_portal",
94
+ name="정보공개포털",
95
+ kind="federated_search",
96
+ description="open.go.kr 사전정보 키워드 검색 (다수 기관 문서 메타)",
97
+ list_url="https://www.open.go.kr/othicInfo/infoList/infoList.do",
98
+ supports_list=True,
99
+ supports_detail=False,
100
+ notes="원문 파일 직접 링크보다 기관/문서 메타 검색에 강함.",
101
+ ),
102
+ "daegu_council": Provider(
103
+ id="daegu_council",
104
+ name="대구광역시의회",
105
+ kind="council_board",
106
+ description="공무국외출장 결과 게시판",
107
+ list_url="https://council.daegu.go.kr/kr/bbs?bbs_id=overseas",
108
+ supports_list=True,
109
+ supports_detail=True,
110
+ notes="상세에 /attach/bbs/overseas/*.pdf 및 /kr/bbs/download 링크.",
111
+ ),
112
+ "daejeon_council": Provider(
113
+ id="daejeon_council",
114
+ name="대전광역시의회",
115
+ kind="council_board",
116
+ description="공무국외출장 계획·결과 보고서 게시판",
117
+ list_url="https://council.daejeon.go.kr/svc/inf/TrainingReportList.do",
118
+ supports_list=True,
119
+ supports_detail=True,
120
+ notes="상세 첨부는 /bbs/FileDownLoadProc.do?flSn=.",
121
+ ),
122
+ "gyeonggi_council": Provider(
123
+ id="gyeonggi_council",
124
+ name="경기도의회",
125
+ kind="council_board",
126
+ description="공무원 국외훈련결과보고서 게시판",
127
+ list_url="https://www.ggc.go.kr/site/main/board/training_resrep/list",
128
+ supports_list=True,
129
+ supports_detail=True,
130
+ notes="첨부 /site/main/file/download/uu/<id>.",
131
+ ),
132
+ "gyeongbuk_council": Provider(
133
+ id="gyeongbuk_council",
134
+ name="경상북도의회",
135
+ kind="council_board",
136
+ description="공지사항 중 공무국외출장 계획서 공개 글",
137
+ list_url="https://council.gb.go.kr/kr/bbs?bbs_id=notice",
138
+ supports_list=True,
139
+ supports_detail=True,
140
+ notes="전용 overseas 보드가 아니라 공지 키워드 필터.",
141
+ ),
142
+ }
143
+
144
+
145
+ class Client:
146
+ def __init__(
147
+ self,
148
+ *,
149
+ timeout: int = DEFAULT_TIMEOUT,
150
+ opener: Callable[[urllib.request.Request, int], Any] | None = None,
151
+ ) -> None:
152
+ self.timeout = timeout
153
+ self._opener = opener or self._default_open
154
+
155
+ @staticmethod
156
+ def _default_open(req: urllib.request.Request, timeout: int) -> Any:
157
+ return urllib.request.urlopen(req, timeout=timeout)
158
+
159
+ def fetch_text(self, url: str, *, data: bytes | None = None) -> tuple[str, str]:
160
+ headers = {
161
+ "User-Agent": USER_AGENT,
162
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
163
+ "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
164
+ }
165
+ if data is not None:
166
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
167
+ req = urllib.request.Request(url, data=data, headers=headers)
168
+ try:
169
+ with self._opener(req, self.timeout) as resp:
170
+ raw = resp.read()
171
+ final = resp.geturl()
172
+ ctype = (resp.headers.get("Content-Type") or "").lower()
173
+ except urllib.error.HTTPError as exc:
174
+ raise FetchError("http_error", f"HTTP {exc.code} for {url}", url=url) from exc
175
+ except urllib.error.URLError as exc:
176
+ raise FetchError("network_error", f"network error for {url}: {exc.reason}", url=url) from exc
177
+ except TimeoutError as exc:
178
+ raise FetchError("http timeout or partial response", f"timeout for {url}", url=url) from exc
179
+
180
+ if "html" not in ctype and not raw.lstrip().startswith((b"<!"),) and b"<html" not in raw[:200].lower():
181
+ # allow small HTML without ctype, still try decode
182
+ pass
183
+ try:
184
+ text = raw.decode("utf-8")
185
+ except UnicodeDecodeError:
186
+ text = raw.decode("utf-8", errors="replace")
187
+ if "오류가 발생" in text and "pageIndex" in url:
188
+ raise FetchError("unexpected HTML", f"NEC/board error page for {url}", url=final)
189
+ return text, final
190
+
191
+
192
+ def unescape(value: str) -> str:
193
+ return html_lib.unescape(value)
194
+
195
+
196
+ def absolute(base: str, href: str) -> str:
197
+ return urllib.parse.urljoin(base, unescape(href))
198
+
199
+
200
+ def clean_text(value: str) -> str:
201
+ return re.sub(r"\s+", " ", unescape(re.sub(r"<[^>]+>", " ", value))).strip()
202
+
203
+
204
+
205
+ def item(
206
+ *,
207
+ provider: str,
208
+ title: str,
209
+ detail_url: str | None = None,
210
+ published_at: str | None = None,
211
+ attachments: list[dict[str, str]] | None = None,
212
+ extra: dict[str, Any] | None = None,
213
+ ) -> dict[str, Any]:
214
+ row: dict[str, Any] = {
215
+ "provider": provider,
216
+ "title": title,
217
+ "detailUrl": detail_url,
218
+ "publishedAt": published_at,
219
+ "attachments": attachments or [],
220
+ }
221
+ if extra:
222
+ row.update(extra)
223
+ return row
224
+
225
+
226
+ def parse_nec_list(html: str, *, base: str = "https://www.nec.go.kr") -> dict[str, Any]:
227
+ footer = re.search(
228
+ r'총\s*<em class="count">\s*([^<]+?)\s*</em>\s*\[<em class="now">\s*([^<]+?)\s*</em>/(\d+)페이지]',
229
+ html,
230
+ )
231
+ rows: list[dict[str, Any]] = []
232
+ for li in re.findall(r"<li\s*>[\s\S]*?</li>", html):
233
+ view = re.search(r"View\.do\?cbIdx=1107&bcIdx=(\d+)", li)
234
+ if not view:
235
+ continue
236
+ bc = view.group(1)
237
+ title_m = re.search(r'class="btn_bbsDetail"[^>]*>([\s\S]*?)</a>', li)
238
+ date_m = re.search(r'<span class="date">\s*([^<]+?)\s*</span>', li)
239
+ dl_m = re.search(r'href="(/common/board/Download\.do\?[^"]+)"', li)
240
+ file_m = re.search(r'title="([^"]+?)\s*파일다운로드"', li)
241
+ att: list[dict[str, str]] = []
242
+ if dl_m:
243
+ href = absolute(base, dl_m.group(1))
244
+ name = clean_text(file_m.group(1)) if file_m else href.rsplit("=", 1)[-1]
245
+ typ = "pdf" if name.lower().endswith(".pdf") else "unknown"
246
+ att.append({"title": name, "url": href, "type": typ})
247
+ rows.append(
248
+ item(
249
+ provider="nec",
250
+ title=clean_text(title_m.group(1)) if title_m else f"bcIdx={bc}",
251
+ detail_url=f"{base}/site/nec/ex/bbs/View.do?cbIdx=1107&bcIdx={bc}",
252
+ published_at=clean_text(date_m.group(1)) if date_m else None,
253
+ attachments=att,
254
+ extra={"bcIdx": bc},
255
+ )
256
+ )
257
+ return {
258
+ "totalText": clean_text(footer.group(1)) if footer else None,
259
+ "page": int(footer.group(2)) if footer else None,
260
+ "totalPages": int(footer.group(3)) if footer else None,
261
+ "items": rows,
262
+ }
263
+
264
+
265
+ def list_nec(client: Client, args: dict[str, Any]) -> dict[str, Any]:
266
+ max_pages = int(args.get("max_pages") or 5)
267
+ keyword = (args.get("keyword") or "").strip()
268
+ all_items: list[dict[str, Any]] = []
269
+ pages_meta: list[dict[str, Any]] = []
270
+ # Official pagination is GET pageIndex. POST pageIndex fails with server error page.
271
+ for page in range(1, max_pages + 1):
272
+ url = f"https://www.nec.go.kr/site/nec/ex/bbs/List.do?cbIdx=1107&pageIndex={page}"
273
+ html, final = client.fetch_text(url)
274
+ parsed = parse_nec_list(html)
275
+ pages_meta.append(
276
+ {
277
+ "page": parsed["page"] or page,
278
+ "count": len(parsed["items"]),
279
+ "url": final,
280
+ "totalText": parsed["totalText"],
281
+ "totalPages": parsed["totalPages"],
282
+ }
283
+ )
284
+ if not parsed["items"]:
285
+ break
286
+ all_items.extend(parsed["items"])
287
+ total_pages = parsed["totalPages"] or page
288
+ if page >= total_pages:
289
+ break
290
+ if keyword:
291
+ all_items = [it for it in all_items if keyword in (it.get("title") or "")]
292
+ return {
293
+ "provider": "nec",
294
+ "source": PROVIDERS["nec"].name,
295
+ "sourceUrl": PROVIDERS["nec"].list_url,
296
+ "pagination": {"method": "GET", "param": "pageIndex", "pages": pages_meta},
297
+ "count": len(all_items),
298
+ "items": all_items,
299
+ "notes": [
300
+ "Use GET pageIndex only. POST pageIndex=N returns a server error page.",
301
+ "Decode HTML entities in Download.do URLs before fetching attachments.",
302
+ ],
303
+ }
304
+
305
+
306
+ def parse_acrc_list(html: str) -> list[dict[str, Any]]:
307
+ rows: list[dict[str, Any]] = []
308
+ seen: set[str] = set()
309
+ for m in re.finditer(
310
+ r'href="([^"]*act=view[^"]*list_no=(\d+)[^"]*)"[\s\S]*?>([\s\S]*?)</a>',
311
+ html,
312
+ re.I,
313
+ ):
314
+ list_no = m.group(2)
315
+ if list_no in seen:
316
+ continue
317
+ title = clean_text(m.group(3))
318
+ if not title or title in {"상세보기", "새창", "더보기"}:
319
+ continue
320
+ seen.add(list_no)
321
+ detail = absolute("https://www.acrc.go.kr/", m.group(1))
322
+ if "bid=" not in detail:
323
+ detail = (
324
+ "https://www.acrc.go.kr/board.es?mid=a10502060000&bid=1000"
325
+ f"&act=view&list_no={list_no}"
326
+ )
327
+ rows.append(
328
+ item(
329
+ provider="acrc",
330
+ title=title,
331
+ detail_url=detail,
332
+ extra={"listNo": list_no},
333
+ )
334
+ )
335
+ return rows
336
+
337
+
338
+ def list_acrc(client: Client, args: dict[str, Any]) -> dict[str, Any]:
339
+ max_pages = int(args.get("max_pages") or 3)
340
+ keyword = (args.get("keyword") or "").strip()
341
+ items: list[dict[str, Any]] = []
342
+ for page in range(1, max_pages + 1):
343
+ url = (
344
+ "https://www.acrc.go.kr/board.es?mid=a10502060000&bid=1000"
345
+ f"&nPage={page}"
346
+ )
347
+ html, _ = client.fetch_text(url)
348
+ page_items = parse_acrc_list(html)
349
+ if not page_items:
350
+ break
351
+ items.extend(page_items)
352
+ if keyword:
353
+ items = [it for it in items if keyword in (it.get("title") or "")]
354
+ return {
355
+ "provider": "acrc",
356
+ "source": PROVIDERS["acrc"].name,
357
+ "sourceUrl": PROVIDERS["acrc"].list_url,
358
+ "count": len(items),
359
+ "items": items,
360
+ }
361
+
362
+
363
+ def detail_acrc(client: Client, args: dict[str, Any]) -> dict[str, Any]:
364
+ list_no = str(args.get("id") or "").strip()
365
+ if not list_no:
366
+ raise FetchError("invalid_input", "acrc detail requires --id list_no")
367
+ url = (
368
+ "https://www.acrc.go.kr/board.es?mid=a10502060000&bid=1000"
369
+ f"&act=view&list_no={urllib.parse.quote(list_no)}"
370
+ )
371
+ html, final = client.fetch_text(url)
372
+ title = clean_text(re.search(r"<title[^>]*>([^<]+)</title>", html).group(1)) if re.search(r"<title", html) else None
373
+ attachments: list[dict[str, str]] = []
374
+ for m in re.finditer(r'href="(/boardDownload\.es\?[^"]+)"', html):
375
+ attachments.append(
376
+ {
377
+ "title": "attachment",
378
+ "url": absolute("https://www.acrc.go.kr/", m.group(1)),
379
+ "type": "hwpx",
380
+ }
381
+ )
382
+ # prefer filenameOrg if present
383
+ for m in re.finditer(r"filenameOrg=([^&\"']+)", html):
384
+ if attachments:
385
+ attachments[0]["title"] = urllib.parse.unquote(m.group(1))
386
+ break
387
+ body_bits = []
388
+ for key in ("목적", "기간", "출장", "방문"):
389
+ j = html.find(key)
390
+ if j >= 0:
391
+ body_bits.append(clean_text(html[j : j + 240]))
392
+ return {
393
+ "provider": "acrc",
394
+ "source": PROVIDERS["acrc"].name,
395
+ "facts": {
396
+ "title": title,
397
+ "detailUrl": final,
398
+ "listNo": list_no,
399
+ "attachments": attachments,
400
+ "snippets": body_bits[:5],
401
+ },
402
+ }
403
+
404
+
405
+ def list_open_portal(client: Client, args: dict[str, Any]) -> dict[str, Any]:
406
+ keyword = (args.get("keyword") or "국외출장").strip() or "국외출장"
407
+ page = int(args.get("page") or 1)
408
+ url = (
409
+ "https://www.open.go.kr/othicInfo/infoList/infoList.do?"
410
+ + urllib.parse.urlencode({"mustKeyword": keyword, "pageIndex": str(page)})
411
+ )
412
+ html, final = client.fetch_text(url)
413
+ m = re.search(r"var\s+result\s*=\s*(\{[\s\S]*?\});", html)
414
+ if not m:
415
+ raise FetchError("unexpected HTML", "open.go.kr result JSON not found", url=final)
416
+ data = json.loads(m.group(1))
417
+ items = []
418
+ for row in data.get("rtnList") or []:
419
+ title = row.get("S_INFO_SJ") or row.get("INFO_SJ") or ""
420
+ items.append(
421
+ item(
422
+ provider="open_portal",
423
+ title=title,
424
+ published_at=row.get("P_DATE") or row.get("FRST_REGIST_DT"),
425
+ extra={
426
+ "institution": row.get("PROC_INSTT_NM"),
427
+ "department": row.get("CHRG_DEPT_NM") or row.get("NFLST_CHRG_DEPT_NM"),
428
+ "docNo": row.get("DOC_NO"),
429
+ "registerNo": row.get("PRDCTN_INSTT_REGIST_NO"),
430
+ "keywords": row.get("tma_kwd"),
431
+ "unitJob": row.get("UNIT_JOB_NM"),
432
+ },
433
+ )
434
+ )
435
+ return {
436
+ "provider": "open_portal",
437
+ "source": PROVIDERS["open_portal"].name,
438
+ "sourceUrl": final,
439
+ "keyword": keyword,
440
+ "total": data.get("rtnTotal"),
441
+ "count": len(items),
442
+ "items": items,
443
+ "notes": [
444
+ "Federated metadata search across many institutions.",
445
+ "Does not always expose a direct file URL; use institution portal or Nuri FOI next.",
446
+ ],
447
+ }
448
+
449
+
450
+ def list_daegu(client: Client, args: dict[str, Any]) -> dict[str, Any]:
451
+ page = int(args.get("page") or 1)
452
+ keyword = (args.get("keyword") or "").strip()
453
+ url = f"https://council.daegu.go.kr/kr/bbs?bbs_id=overseas&page={page}"
454
+ html, final = client.fetch_text(url)
455
+ items: list[dict[str, Any]] = []
456
+ for m in re.finditer(
457
+ r"href\s*=\s*(['\"])([^'\"]*reform=view[^'\"]*bbs_id=overseas[^'\"]*)\1([^>]*)>([\s\S]*?)</a>",
458
+ html,
459
+ re.I,
460
+ ):
461
+ href = m.group(2)
462
+ attrs = m.group(3) or ""
463
+ inner = m.group(4) or ""
464
+ title_m = re.search(r"title\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
465
+ title = clean_text(title_m.group(2)) if title_m else clean_text(inner)
466
+ title = title.replace(" 내용보기", "").strip()
467
+ if not title or len(title) < 4:
468
+ continue
469
+ abs_url = absolute("https://council.daegu.go.kr/", href)
470
+ uid_m = re.search(r"uid=([0-9A-Fa-f]+)", abs_url)
471
+ items.append(
472
+ item(
473
+ provider="daegu_council",
474
+ title=title,
475
+ detail_url=abs_url,
476
+ extra={"uid": uid_m.group(1) if uid_m else None},
477
+ )
478
+ )
479
+ # de-dup by detail url
480
+ dedup: dict[str, dict[str, Any]] = {}
481
+ for it in items:
482
+ dedup[it["detailUrl"]] = it
483
+ items = list(dedup.values())
484
+ if keyword:
485
+ items = [it for it in items if keyword in (it.get("title") or "")]
486
+ return {
487
+ "provider": "daegu_council",
488
+ "source": PROVIDERS["daegu_council"].name,
489
+ "sourceUrl": final,
490
+ "count": len(items),
491
+ "items": items,
492
+ }
493
+
494
+
495
+ def detail_daegu(client: Client, args: dict[str, Any]) -> dict[str, Any]:
496
+ detail_url = (args.get("url") or "").strip()
497
+ uid = (args.get("id") or "").strip()
498
+ if not detail_url and uid:
499
+ detail_url = (
500
+ "https://council.daegu.go.kr/kr/bbs?reform=view"
501
+ f"&uid={urllib.parse.quote(uid)}&bbs_id=overseas"
502
+ )
503
+ if not detail_url.startswith("https://council.daegu.go.kr/"):
504
+ raise FetchError("invalid_input", "daegu detail requires official council.daegu.go.kr URL or --id uid")
505
+ html, final = client.fetch_text(detail_url)
506
+ attachments: list[dict[str, str]] = []
507
+ for m in re.finditer(r"href\s*=\s*(['\"])([^'\"]+)\1", html, re.I):
508
+ href = unescape(m.group(2))
509
+ if re.search(r"/attach/bbs/overseas/|/kr/bbs/download", href):
510
+ url = absolute("https://council.daegu.go.kr/", href)
511
+ # filename from surrounding title attr if download link
512
+ typ = "pdf" if url.lower().endswith(".pdf") else "unknown"
513
+ title = url.rsplit("/", 1)[-1]
514
+ # look back small window for title=
515
+ start = max(0, m.start() - 200)
516
+ window = html[start:m.end()+50]
517
+ tm = re.search(r"title\s*=\s*(['\"])(.*?)\1", window, re.I | re.S)
518
+ if tm:
519
+ title = clean_text(tm.group(2)).replace(" 파일 내려받기", "")
520
+ if title.lower().endswith(".pdf"):
521
+ typ = "pdf"
522
+ attachments.append({"title": title, "url": url, "type": typ})
523
+ uniq = {a["url"]: a for a in attachments}
524
+ # Prefer direct pdf attach URL first
525
+ ordered = sorted(uniq.values(), key=lambda a: 0 if a["url"].endswith(".pdf") else 1)
526
+ # extract simple body facts if present
527
+ period = re.search(r"출장기간\s*:\s*([^<\n]+)", html)
528
+ country = re.search(r"출장국가\s*:\s*([^<\n]+)", html)
529
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
530
+ report_title = None
531
+ # attachment human filenames often encode the report title
532
+ for att in ordered:
533
+ name = att.get("title") or ""
534
+ if "결과보고서" in name or "출장" in name:
535
+ report_title = re.sub(r"^[★*\s]+", "", name)
536
+ report_title = report_title.replace(".pdf", "").replace(".hwp", "").replace(".hwpx", "").strip()
537
+ break
538
+ if not report_title:
539
+ tm = re.search(r"title\s*=\s*(['\"])([^'\"]*(?:결과보고서|공무국외)[^'\"]*)\1", html, re.I)
540
+ if tm:
541
+ report_title = clean_text(tm.group(2)).replace(" 파일 내려받기", "").replace(" 내용보기", "")
542
+ if not report_title and title_m:
543
+ report_title = clean_text(title_m.group(1))
544
+ report_title = report_title.split(">")[-1].strip()
545
+ return {
546
+ "provider": "daegu_council",
547
+ "source": PROVIDERS["daegu_council"].name,
548
+ "facts": {
549
+ "title": report_title,
550
+ "detailUrl": final,
551
+ "period": clean_text(period.group(1)) if period else None,
552
+ "country": clean_text(country.group(1)) if country else None,
553
+ "attachments": ordered,
554
+ },
555
+ }
556
+
557
+
558
+ def list_daejeon(client: Client, args: dict[str, Any]) -> dict[str, Any]:
559
+ page = int(args.get("page") or 1)
560
+ keyword = (args.get("keyword") or "").strip()
561
+ url = f"https://council.daejeon.go.kr/svc/inf/TrainingReportList.do?pageNo={page}"
562
+ html, final = client.fetch_text(url)
563
+ items: list[dict[str, Any]] = []
564
+ for m in re.finditer(
565
+ r'href="(/svc/inf/TrainingReportView\.do\?[^"]+)"[^>]*>([\s\S]*?)</a>',
566
+ html,
567
+ ):
568
+ title = clean_text(m.group(2))
569
+ if not title:
570
+ continue
571
+ href = absolute("https://council.daejeon.go.kr", m.group(1))
572
+ sn = re.search(r"bbsSn=(\d+)", href)
573
+ items.append(
574
+ item(
575
+ provider="daejeon_council",
576
+ title=title,
577
+ detail_url=href,
578
+ extra={"bbsSn": sn.group(1) if sn else None},
579
+ )
580
+ )
581
+ if keyword:
582
+ items = [it for it in items if keyword in (it.get("title") or "")]
583
+ return {
584
+ "provider": "daejeon_council",
585
+ "source": PROVIDERS["daejeon_council"].name,
586
+ "sourceUrl": final,
587
+ "count": len(items),
588
+ "items": items,
589
+ }
590
+
591
+
592
+ def detail_daejeon(client: Client, args: dict[str, Any]) -> dict[str, Any]:
593
+ detail_url = (args.get("url") or "").strip()
594
+ bbs_sn = (args.get("id") or "").strip()
595
+ if not detail_url and bbs_sn:
596
+ detail_url = (
597
+ "https://council.daejeon.go.kr/svc/inf/TrainingReportView.do?"
598
+ + urllib.parse.urlencode({"bbsSn": bbs_sn, "pageNo": "1"})
599
+ )
600
+ if not detail_url.startswith("https://council.daejeon.go.kr/"):
601
+ raise FetchError("invalid_input", "daejeon detail requires official URL or --id bbsSn")
602
+ html, final = client.fetch_text(detail_url)
603
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
604
+ attachments: list[dict[str, str]] = []
605
+ for m in re.finditer(r'href="(/bbs/FileDownLoadProc\.do\?flSn=\d+)"', html):
606
+ url = absolute("https://council.daejeon.go.kr", m.group(1))
607
+ attachments.append({"title": f"flSn={urllib.parse.parse_qs(urllib.parse.urlparse(url).query).get('flSn', [''])[0]}", "url": url, "type": "unknown"})
608
+ return {
609
+ "provider": "daejeon_council",
610
+ "source": PROVIDERS["daejeon_council"].name,
611
+ "facts": {
612
+ "title": clean_text(title_m.group(1)) if title_m else None,
613
+ "detailUrl": final,
614
+ "attachments": attachments,
615
+ },
616
+ }
617
+
618
+
619
+ def list_gyeonggi(client: Client, args: dict[str, Any]) -> dict[str, Any]:
620
+ keyword = (args.get("keyword") or "").strip()
621
+ url = PROVIDERS["gyeonggi_council"].list_url
622
+ html, final = client.fetch_text(url)
623
+ items: list[dict[str, Any]] = []
624
+ for m in re.finditer(
625
+ r'href="(/site/main/board/training_resrep/\d+\?[^"]*)"[^>]*>([\s\S]*?)</a>',
626
+ html,
627
+ ):
628
+ title = clean_text(m.group(2))
629
+ if not title:
630
+ continue
631
+ items.append(
632
+ item(
633
+ provider="gyeonggi_council",
634
+ title=title,
635
+ detail_url=absolute("https://www.ggc.go.kr", m.group(1)),
636
+ )
637
+ )
638
+ if keyword:
639
+ items = [it for it in items if keyword in (it.get("title") or "")]
640
+ return {
641
+ "provider": "gyeonggi_council",
642
+ "source": PROVIDERS["gyeonggi_council"].name,
643
+ "sourceUrl": final,
644
+ "count": len(items),
645
+ "items": items,
646
+ }
647
+
648
+
649
+ def detail_gyeonggi(client: Client, args: dict[str, Any]) -> dict[str, Any]:
650
+ detail_url = (args.get("url") or "").strip()
651
+ if not detail_url.startswith("https://www.ggc.go.kr/site/main/board/training_resrep/"):
652
+ raise FetchError("invalid_input", "gyeonggi detail requires official training_resrep URL")
653
+ html, final = client.fetch_text(detail_url)
654
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
655
+ attachments = []
656
+ for m in re.finditer(r'href="(/site/main/file/download/uu/[^"]+)"', html):
657
+ attachments.append(
658
+ {
659
+ "title": "download",
660
+ "url": absolute("https://www.ggc.go.kr", m.group(1)),
661
+ "type": "unknown",
662
+ }
663
+ )
664
+ return {
665
+ "provider": "gyeonggi_council",
666
+ "source": PROVIDERS["gyeonggi_council"].name,
667
+ "facts": {
668
+ "title": clean_text(title_m.group(1)) if title_m else None,
669
+ "detailUrl": final,
670
+ "attachments": attachments,
671
+ },
672
+ }
673
+
674
+
675
+ def list_gyeongbuk(client: Client, args: dict[str, Any]) -> dict[str, Any]:
676
+ keyword = (args.get("keyword") or "국외출장").strip()
677
+ url = PROVIDERS["gyeongbuk_council"].list_url
678
+ html, final = client.fetch_text(url)
679
+ items: list[dict[str, Any]] = []
680
+ for m in re.finditer(
681
+ r"href\s*=\s*(['\"])([^'\"]*reform=view[^'\"]*bbs_id=notice[^'\"]*)\1([^>]*)>",
682
+ html,
683
+ re.I,
684
+ ):
685
+ href = m.group(2)
686
+ attrs = m.group(3) or ""
687
+ title_m = re.search(r"title\s*=\s*(['\"])(.*?)\1", attrs, re.I | re.S)
688
+ title = clean_text(title_m.group(2)) if title_m else ""
689
+ if "출장" not in title:
690
+ continue
691
+ items.append(
692
+ item(
693
+ provider="gyeongbuk_council",
694
+ title=title,
695
+ detail_url=absolute("https://council.gb.go.kr/", href),
696
+ )
697
+ )
698
+ # unique
699
+ dedup = {it["detailUrl"]: it for it in items}
700
+ items = list(dedup.values())
701
+ if keyword:
702
+ items = [it for it in items if keyword in (it.get("title") or "")]
703
+ return {
704
+ "provider": "gyeongbuk_council",
705
+ "source": PROVIDERS["gyeongbuk_council"].name,
706
+ "sourceUrl": final,
707
+ "count": len(items),
708
+ "items": items,
709
+ "notes": ["Filtered notice-board posts that mention 출장."],
710
+ }
711
+
712
+
713
+ def detail_gyeongbuk(client: Client, args: dict[str, Any]) -> dict[str, Any]:
714
+ detail_url = (args.get("url") or "").strip()
715
+ if not detail_url.startswith("https://council.gb.go.kr/"):
716
+ raise FetchError("invalid_input", "gyeongbuk detail requires official council.gb.go.kr URL")
717
+ html, final = client.fetch_text(detail_url)
718
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
719
+ attachments = []
720
+ for m in re.finditer(r'href="([^"]+)"', html):
721
+ href = unescape(m.group(1))
722
+ if re.search(r"/kr/bbs/download|doc\.html\?fn=", href):
723
+ attachments.append(
724
+ {
725
+ "title": href.rsplit("/", 1)[-1][:80],
726
+ "url": absolute("https://council.gb.go.kr/", href),
727
+ "type": "unknown",
728
+ }
729
+ )
730
+ uniq = {a["url"]: a for a in attachments}
731
+ return {
732
+ "provider": "gyeongbuk_council",
733
+ "source": PROVIDERS["gyeongbuk_council"].name,
734
+ "facts": {
735
+ "title": clean_text(title_m.group(1)) if title_m else None,
736
+ "detailUrl": final,
737
+ "attachments": list(uniq.values()),
738
+ },
739
+ }
740
+
741
+
742
+ def detail_mpm(client: Client, args: dict[str, Any]) -> dict[str, Any]:
743
+ html, final = client.fetch_text(PROVIDERS["mpm"].list_url)
744
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
745
+ links = []
746
+ for m in re.finditer(r'href="([^"]+)"[^>]*>([^<]{0,40})', html):
747
+ label = clean_text(m.group(2))
748
+ href = unescape(m.group(1))
749
+ if re.search(r"BTIS|국외출장|open\.go\.kr|여비", label + href, re.I):
750
+ links.append({"label": label or href, "url": absolute("https://www.mpm.go.kr", href)})
751
+ return {
752
+ "provider": "mpm",
753
+ "source": PROVIDERS["mpm"].name,
754
+ "facts": {
755
+ "title": clean_text(title_m.group(1)) if title_m else None,
756
+ "detailUrl": final,
757
+ "relatedLinks": links[:20],
758
+ },
759
+ "notes": [
760
+ "This surface explains the overseas-trip policy and BTIS registration duty.",
761
+ "It is not a bulk public report board.",
762
+ ],
763
+ }
764
+
765
+
766
+ def detail_mois(client: Client, args: dict[str, Any]) -> dict[str, Any]:
767
+ html, final = client.fetch_text(PROVIDERS["mois"].list_url)
768
+ title_m = re.search(r"<title[^>]*>([^<]+)</title>", html)
769
+ attachments = []
770
+ for m in re.finditer(r'href="(/cmm/fms/FileDown\.do\?[^"]+)"', html):
771
+ attachments.append(
772
+ {
773
+ "title": "FileDown",
774
+ "url": absolute("https://www.mois.go.kr", m.group(1)),
775
+ "type": "unknown",
776
+ }
777
+ )
778
+ return {
779
+ "provider": "mois",
780
+ "source": PROVIDERS["mois"].name,
781
+ "facts": {
782
+ "title": clean_text(title_m.group(1)) if title_m else None,
783
+ "detailUrl": final,
784
+ "attachments": attachments,
785
+ },
786
+ "notes": [
787
+ "Pinned verified policy article: processing standard for illegal overseas local-gov trips.",
788
+ ],
789
+ }
790
+
791
+
792
+
793
+ def providers_payload() -> dict[str, Any]:
794
+ return {
795
+ "count": len(PROVIDERS),
796
+ "providers": [
797
+ {
798
+ "id": p.id,
799
+ "name": p.name,
800
+ "kind": p.kind,
801
+ "description": p.description,
802
+ "listUrl": p.list_url,
803
+ "supportsList": p.supports_list,
804
+ "supportsDetail": p.supports_detail,
805
+ "notes": p.notes,
806
+ }
807
+ for p in PROVIDERS.values()
808
+ ],
809
+ }
810
+
811
+
812
+ LIST_HANDLERS: dict[str, ProviderFn] = {
813
+ "nec": list_nec,
814
+ "acrc": list_acrc,
815
+ "open_portal": list_open_portal,
816
+ "daegu_council": list_daegu,
817
+ "daejeon_council": list_daejeon,
818
+ "gyeonggi_council": list_gyeonggi,
819
+ "gyeongbuk_council": list_gyeongbuk,
820
+ }
821
+
822
+ DETAIL_HANDLERS: dict[str, ProviderFn] = {
823
+ "acrc": detail_acrc,
824
+ "daegu_council": detail_daegu,
825
+ "daejeon_council": detail_daejeon,
826
+ "gyeonggi_council": detail_gyeonggi,
827
+ "gyeongbuk_council": detail_gyeongbuk,
828
+ "mpm": detail_mpm,
829
+ "mois": detail_mois,
830
+ }
831
+
832
+
833
+
834
+ DISCOVERY_SEEDS = [
835
+ # Federated metadata (always try first for unknown agency keywords)
836
+ {
837
+ "id": "open_portal",
838
+ "name": "정보공개포털",
839
+ "kind": "federated_search",
840
+ "list_url": "https://www.open.go.kr/othicInfo/infoList/infoList.do",
841
+ "notes": "mustKeyword JSON 메타. 원문 파일 URL은 제한적일 수 있음.",
842
+ },
843
+ # Known high-signal board URL patterns for active expansion
844
+ {
845
+ "id": "seed_nec",
846
+ "name": "중앙선거관리위원회",
847
+ "kind": "agency_board",
848
+ "list_url": "https://www.nec.go.kr/site/nec/ex/bbs/List.do?cbIdx=1107",
849
+ },
850
+ {
851
+ "id": "seed_acrc",
852
+ "name": "국민권익위원회 국외출장 현황",
853
+ "kind": "agency_board",
854
+ "list_url": "https://www.acrc.go.kr/board.es?mid=a10502060000&bid=1000",
855
+ },
856
+ {
857
+ "id": "seed_daegu",
858
+ "name": "대구광역시의회",
859
+ "kind": "council_board",
860
+ "list_url": "https://council.daegu.go.kr/kr/bbs?bbs_id=overseas",
861
+ },
862
+ {
863
+ "id": "seed_daejeon",
864
+ "name": "대전광역시의회",
865
+ "kind": "council_board",
866
+ "list_url": "https://council.daejeon.go.kr/svc/inf/TrainingReportList.do",
867
+ },
868
+ {
869
+ "id": "seed_ggc",
870
+ "name": "경기도의회",
871
+ "kind": "council_board",
872
+ "list_url": "https://www.ggc.go.kr/site/main/board/training_resrep/list",
873
+ },
874
+ {
875
+ "id": "seed_gb",
876
+ "name": "경상북도의회 공지",
877
+ "kind": "council_board",
878
+ "list_url": "https://council.gb.go.kr/kr/bbs?bbs_id=notice",
879
+ },
880
+ ]
881
+
882
+
883
+ def score_discovery_html(html: str, final_url: str) -> dict[str, Any]:
884
+ text = html or ""
885
+ keywords = len(
886
+ re.findall(
887
+ r"국외출장|공무국외|해외출장|국외연수|국외훈련|출장결과|출장보고|해외연수|training_resrep|TrainingReport",
888
+ text,
889
+ re.I,
890
+ )
891
+ )
892
+ # Hard login wall signals only (not global nav "로그인" links)
893
+ hard_login = bool(
894
+ re.search(
895
+ r"(로그인\s*페이지|본인인증|공동인증서|간편인증|password|type=\"password\"|type='password'|SSO\s*Login|로그인이 필요)",
896
+ text,
897
+ re.I,
898
+ )
899
+ ) or (len(text) < 2500 and bool(re.search(r"로그인|login", text, re.I)))
900
+ attach_like = len(re.findall(r"Download|download|FileDown|fileDown|attach|\.pdf|\.hwp", text, re.I))
901
+ view_like = len(
902
+ re.findall(
903
+ r"View\.do|reform=view|act=view|TrainingReportView|training_resrep/\d+|/view",
904
+ text,
905
+ re.I,
906
+ )
907
+ )
908
+ status = "candidate"
909
+ if hard_login and keywords == 0:
910
+ status = "login_walled"
911
+ elif keywords == 0 and view_like == 0:
912
+ status = "low_signal"
913
+ elif keywords > 0 and (attach_like > 0 or view_like > 0 or len(text) > 20000):
914
+ status = "public_board_candidate"
915
+ return {
916
+ "url": final_url,
917
+ "status": status,
918
+ "keywordHits": keywords,
919
+ "attachmentHints": attach_like,
920
+ "viewHints": view_like,
921
+ "loginish": hard_login,
922
+ "title": clean_text(re.search(r"<title[^>]*>([^<]+)</title>", text).group(1)) if re.search(r"<title", text) else None,
923
+ }
924
+
925
+
926
+ def discover_surfaces(client: Client, args: dict[str, Any]) -> dict[str, Any]:
927
+ """Actively probe known seeds + optional user URL/host to find public trip-report boards.
928
+
929
+ Login-walled systems are reported and excluded from recommendUse.
930
+ """
931
+ keyword = (args.get("keyword") or "국외출장").strip() or "국외출장"
932
+ extra_urls = [u.strip() for u in (args.get("urls") or []) if u and u.strip()]
933
+ results = []
934
+ recommend = []
935
+ excluded = []
936
+
937
+ # open portal always, federated
938
+ try:
939
+ open_payload = list_open_portal(client, {"keyword": keyword, "page": 1})
940
+ institutions = {}
941
+ for it in open_payload.get("items") or []:
942
+ inst = it.get("institution") or "unknown"
943
+ institutions[inst] = institutions.get(inst, 0) + 1
944
+ results.append(
945
+ {
946
+ "id": "open_portal",
947
+ "status": "ok",
948
+ "sourceUrl": open_payload.get("sourceUrl"),
949
+ "count": open_payload.get("count"),
950
+ "institutionHistogram": institutions,
951
+ "sampleTitles": [it.get("title") for it in (open_payload.get("items") or [])[:5]],
952
+ }
953
+ )
954
+ recommend.append("open_portal")
955
+ except FetchError as exc:
956
+ results.append({"id": "open_portal", "status": "error", "error": exc.mode, "message": str(exc)})
957
+
958
+ # seed boards
959
+ for seed in DISCOVERY_SEEDS:
960
+ if seed["id"] == "open_portal":
961
+ continue
962
+ try:
963
+ html, final = client.fetch_text(seed["list_url"])
964
+ scored = score_discovery_html(html, final)
965
+ scored.update({"id": seed["id"], "name": seed["name"], "kind": seed["kind"]})
966
+ results.append(scored)
967
+ if scored["status"] == "public_board_candidate":
968
+ recommend.append(seed["id"])
969
+ elif scored["status"] == "login_walled":
970
+ excluded.append({"id": seed["id"], "reason": "login_walled"})
971
+ except FetchError as exc:
972
+ results.append({"id": seed["id"], "status": "error", "error": exc.mode, "message": str(exc), "url": seed["list_url"]})
973
+
974
+ # optional user-supplied public URLs (still no arbitrary JS auth bypass)
975
+ for url in extra_urls:
976
+ host = urllib.parse.urlparse(url).netloc.lower()
977
+ if not host.endswith(".go.kr") and not host.endswith(".or.kr") and "open.go.kr" not in host:
978
+ excluded.append({"url": url, "reason": "host_not_public_gov_like"})
979
+ continue
980
+ try:
981
+ html, final = client.fetch_text(url)
982
+ scored = score_discovery_html(html, final)
983
+ scored.update({"id": f"custom:{host}", "name": host, "kind": "custom_probe"})
984
+ results.append(scored)
985
+ if scored["status"] == "public_board_candidate":
986
+ recommend.append(scored["id"])
987
+ elif scored["status"] == "login_walled":
988
+ excluded.append({"id": scored["id"], "reason": "login_walled"})
989
+ except FetchError as exc:
990
+ results.append({"id": f"custom:{host}", "status": "error", "error": exc.mode, "message": str(exc), "url": url})
991
+
992
+ # map recommend seed ids to built-in providers when available
993
+ seed_to_provider = {
994
+ "seed_nec": "nec",
995
+ "seed_acrc": "acrc",
996
+ "seed_daegu": "daegu_council",
997
+ "seed_daejeon": "daejeon_council",
998
+ "seed_ggc": "gyeonggi_council",
999
+ "seed_gb": "gyeongbuk_council",
1000
+ "open_portal": "open_portal",
1001
+ }
1002
+ use_providers = []
1003
+ for rid in recommend:
1004
+ use_providers.append(seed_to_provider.get(rid, rid))
1005
+
1006
+ return {
1007
+ "keyword": keyword,
1008
+ "results": results,
1009
+ "recommendUse": sorted(set(use_providers)),
1010
+ "excludedLoginWalled": excluded,
1011
+ "expansionHints": [
1012
+ "Unknown ministry/council: start with open_portal keyword search and collect PROC_INSTT_NM histogram.",
1013
+ "From institution names, open that org's 정보공개/사전정보공표/자료실 and look for 공무국외출장|출장결과|국외훈련 boards.",
1014
+ "Accept only read-only public HTML/JSON surfaces. If login/SSO/cert wall appears, mark login_walled and stop.",
1015
+ "When a new public board is found, record list URL, pagination param, detail linker, attachment pattern, then add a provider recipe.",
1016
+ "Never automate CAPTCHA, paid FOI submission, or password entry.",
1017
+ ],
1018
+ "notAdjudication": "Discovery only. Not a corruption determination.",
1019
+ }
1020
+
1021
+
1022
+ def build_parser() -> argparse.ArgumentParser:
1023
+ p = argparse.ArgumentParser(description="Multi-agency overseas trip report discovery")
1024
+ sub = p.add_subparsers(dest="cmd", required=True)
1025
+
1026
+ sub.add_parser("providers", help="List verified providers")
1027
+
1028
+ disc = sub.add_parser("discover", help="Probe public boards / expand unknown agencies")
1029
+ disc.add_argument("--keyword", default="국외출장")
1030
+ disc.add_argument("--urls", default="", help="Comma-separated optional public .go.kr URLs to probe")
1031
+ disc.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
1032
+
1033
+ lp = sub.add_parser("list", help="List items from a provider")
1034
+ lp.add_argument("--provider", required=True, choices=sorted(PROVIDERS))
1035
+ lp.add_argument("--keyword")
1036
+ lp.add_argument("--page", type=int, default=1)
1037
+ lp.add_argument("--max-pages", type=int, default=3)
1038
+ lp.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
1039
+
1040
+ dp = sub.add_parser("detail", help="Fetch one detail/policy surface")
1041
+ dp.add_argument("--provider", required=True, choices=sorted(PROVIDERS))
1042
+ dp.add_argument("--id", help="Provider-specific id (bcIdx/list_no/uid/bbsSn)")
1043
+ dp.add_argument("--url", help="Official detail URL when required")
1044
+ dp.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
1045
+
1046
+ sp = sub.add_parser("search", help="Keyword search across list-capable providers")
1047
+ sp.add_argument("--keyword", required=True)
1048
+ sp.add_argument(
1049
+ "--providers",
1050
+ default="nec,acrc,open_portal,daegu_council,daejeon_council,gyeonggi_council,gyeongbuk_council",
1051
+ help="Comma-separated provider ids",
1052
+ )
1053
+ sp.add_argument("--max-pages", type=int, default=1)
1054
+ sp.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
1055
+ return p
1056
+
1057
+
1058
+ def main(argv: list[str] | None = None) -> int:
1059
+ parser = build_parser()
1060
+ args = parser.parse_args(argv)
1061
+ try:
1062
+ if args.cmd == "providers":
1063
+ print(json.dumps(providers_payload(), ensure_ascii=False, indent=2))
1064
+ return 0
1065
+
1066
+ if args.cmd == "discover":
1067
+ client = Client(timeout=getattr(args, "timeout", DEFAULT_TIMEOUT))
1068
+ urls = [u.strip() for u in (args.urls or "").split(",") if u.strip()]
1069
+ result = discover_surfaces(client, {"keyword": args.keyword, "urls": urls})
1070
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1071
+ return 0
1072
+
1073
+
1074
+ client = Client(timeout=getattr(args, "timeout", DEFAULT_TIMEOUT))
1075
+ if args.cmd == "list":
1076
+ handler = LIST_HANDLERS.get(args.provider)
1077
+ if not handler:
1078
+ if args.provider in DETAIL_HANDLERS:
1079
+ raise FetchError(
1080
+ "unsupported_operation",
1081
+ f"{args.provider} has no list surface; use detail/providers notes",
1082
+ )
1083
+ raise FetchError("unsupported institution", f"unknown provider {args.provider}")
1084
+ result = handler(
1085
+ client,
1086
+ {
1087
+ "keyword": args.keyword,
1088
+ "page": args.page,
1089
+ "max_pages": args.max_pages,
1090
+ },
1091
+ )
1092
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1093
+ return 0
1094
+
1095
+ if args.cmd == "detail":
1096
+ # NEC detail is constructible without extra fetch for URL validation path
1097
+ if args.provider == "nec":
1098
+ bc = (args.id or "").strip()
1099
+ if not bc and args.url:
1100
+ m = re.search(r"bcIdx=(\d+)", args.url)
1101
+ bc = m.group(1) if m else ""
1102
+ if not bc:
1103
+ raise FetchError("invalid_input", "nec detail requires --id bcIdx")
1104
+ detail = f"https://www.nec.go.kr/site/nec/ex/bbs/View.do?cbIdx=1107&bcIdx={bc}"
1105
+ html, final = client.fetch_text(detail)
1106
+ dl = re.findall(r'href="(/common/board/Download\.do\?[^"]+)"', html)
1107
+ file_names = re.findall(r'title="([^"]+?)\s*파일다운로드"', html)
1108
+ attachments = []
1109
+ for i, href in enumerate(dl):
1110
+ name = clean_text(file_names[i]) if i < len(file_names) else "attachment"
1111
+ url = absolute("https://www.nec.go.kr", href)
1112
+ attachments.append(
1113
+ {
1114
+ "title": name,
1115
+ "url": url,
1116
+ "type": "pdf" if name.lower().endswith(".pdf") else "unknown",
1117
+ }
1118
+ )
1119
+ title = None
1120
+ tm = re.search(r'class="tit"[^>]*>([\s\S]*?)</', html)
1121
+ if tm:
1122
+ title = clean_text(tm.group(1))
1123
+ if not title:
1124
+ tm = re.search(r"<title[^>]*>([^<]+)</title>", html)
1125
+ title = clean_text(tm.group(1)) if tm else None
1126
+ result = {
1127
+ "provider": "nec",
1128
+ "source": PROVIDERS["nec"].name,
1129
+ "facts": {
1130
+ "title": title,
1131
+ "detailUrl": final,
1132
+ "bcIdx": bc,
1133
+ "attachments": attachments,
1134
+ },
1135
+ }
1136
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1137
+ return 0
1138
+
1139
+ handler = DETAIL_HANDLERS.get(args.provider)
1140
+ if not handler:
1141
+ raise FetchError("unsupported_operation", f"{args.provider} detail not implemented")
1142
+ result = handler(client, {"id": args.id, "url": args.url})
1143
+ print(json.dumps(result, ensure_ascii=False, indent=2))
1144
+ return 0
1145
+
1146
+ if args.cmd == "search":
1147
+ provider_ids = [x.strip() for x in args.providers.split(",") if x.strip()]
1148
+ results = []
1149
+ errors = []
1150
+ for pid in provider_ids:
1151
+ handler = LIST_HANDLERS.get(pid)
1152
+ if not handler:
1153
+ errors.append({"provider": pid, "error": "no list handler"})
1154
+ continue
1155
+ try:
1156
+ payload = handler(
1157
+ client,
1158
+ {"keyword": args.keyword, "page": 1, "max_pages": args.max_pages},
1159
+ )
1160
+ results.append(
1161
+ {
1162
+ "provider": pid,
1163
+ "count": payload.get("count"),
1164
+ "sourceUrl": payload.get("sourceUrl") or PROVIDERS[pid].list_url,
1165
+ "items": payload.get("items") or [],
1166
+ }
1167
+ )
1168
+ except FetchError as exc:
1169
+ errors.append({"provider": pid, "error": exc.mode, "message": str(exc)})
1170
+ print(
1171
+ json.dumps(
1172
+ {
1173
+ "keyword": args.keyword,
1174
+ "results": results,
1175
+ "errors": errors,
1176
+ "notAdjudication": (
1177
+ "Discovery only. Do not treat matches as findings of waste, illegality, or corruption."
1178
+ ),
1179
+ },
1180
+ ensure_ascii=False,
1181
+ indent=2,
1182
+ )
1183
+ )
1184
+ return 0
1185
+
1186
+ parser.error(f"unknown command {args.cmd}")
1187
+ return 2
1188
+ except FetchError as exc:
1189
+ print(
1190
+ json.dumps(
1191
+ {
1192
+ "ok": False,
1193
+ "failureMode": exc.mode,
1194
+ "error": str(exc),
1195
+ "url": exc.url,
1196
+ },
1197
+ ensure_ascii=False,
1198
+ indent=2,
1199
+ ),
1200
+ file=sys.stderr,
1201
+ )
1202
+ return 1
1203
+
1204
+
1205
+ if __name__ == "__main__":
1206
+ raise SystemExit(main())