@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,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Match Korean job postings against a job seeker's resume/profile.
|
|
3
|
+
|
|
4
|
+
Reads public JobKorea and Saramin search result pages only. It does not log in,
|
|
5
|
+
submit applications, save postings, or mutate any account state.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import html
|
|
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 asdict, dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
USER_AGENT = "Mozilla/5.0 (compatible; k-skill job-posting-match/1.0; +https://github.com/NomaDamas/k-skill)"
|
|
22
|
+
|
|
23
|
+
ROLE_TERMS = [
|
|
24
|
+
"퍼포먼스 마케터", "그로스 마케터", "CRM 마케터", "콘텐츠 마케터", "브랜드 마케터", "마케팅 매니저",
|
|
25
|
+
"백엔드 개발자", "프론트엔드 개발자", "풀스택 개발자", "데이터 분석가", "데이터 엔지니어", "프로덕트 매니저",
|
|
26
|
+
"서비스 기획자", "프로덕트 디자이너", "UI/UX 디자이너", "영업", "회계", "인사", "채용", "운영", "MD",
|
|
27
|
+
]
|
|
28
|
+
TOOL_TERMS = [
|
|
29
|
+
"GA4", "Google Ads", "Meta Ads", "Facebook Ads", "SQL", "Python", "Java", "Spring", "React", "Vue",
|
|
30
|
+
"Node.js", "TypeScript", "JavaScript", "AWS", "GCP", "Kubernetes", "Docker", "Looker Studio", "Tableau",
|
|
31
|
+
"Amplitude", "Braze", "CRM", "SEO", "ASO", "Figma", "Notion", "Excel", "PowerPoint",
|
|
32
|
+
]
|
|
33
|
+
INDUSTRY_TERMS = ["커머스", "이커머스", "B2C", "B2B", "SaaS", "핀테크", "에듀테크", "헬스케어", "게임", "콘텐츠", "플랫폼", "스타트업"]
|
|
34
|
+
NEGATIVE_DEFAULTS = ["보험영업", "대출영업", "텔레마케팅", "TM", "방문판매", "다단계"]
|
|
35
|
+
ACTION_LABELS = {"스크랩", "관심공고", "즉시지원", "입사지원", "홈페이지 지원", "저장", "공유", "닫기", "더보기"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Posting:
|
|
40
|
+
source: str
|
|
41
|
+
title: str
|
|
42
|
+
company: str = ""
|
|
43
|
+
url: str = ""
|
|
44
|
+
location: str = ""
|
|
45
|
+
career: str = ""
|
|
46
|
+
deadline: str = ""
|
|
47
|
+
summary: str = ""
|
|
48
|
+
score: int = 0
|
|
49
|
+
reasons: list[str] = field(default_factory=list)
|
|
50
|
+
cautions: list[str] = field(default_factory=list)
|
|
51
|
+
matched_terms: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def clean_text(value: str) -> str:
|
|
55
|
+
value = re.sub(r"<script\b.*?</script>", " ", value, flags=re.I | re.S)
|
|
56
|
+
value = re.sub(r"<style\b.*?</style>", " ", value, flags=re.I | re.S)
|
|
57
|
+
value = re.sub(r"<[^>]+>", " ", value)
|
|
58
|
+
value = html.unescape(value)
|
|
59
|
+
value = re.sub(r"\s+", " ", value).strip()
|
|
60
|
+
for label in ACTION_LABELS:
|
|
61
|
+
value = re.sub(rf"\b{re.escape(label)}\b", " ", value)
|
|
62
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def fetch(url: str) -> str:
|
|
66
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept-Language": "ko-KR,ko;q=0.9"})
|
|
67
|
+
with urllib.request.urlopen(req, timeout=25) as response:
|
|
68
|
+
raw = response.read()
|
|
69
|
+
charset = response.headers.get_content_charset() or "utf-8"
|
|
70
|
+
return raw.decode(charset, "replace")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def absolute_url(source: str, href: str) -> str:
|
|
74
|
+
href = html.unescape(href)
|
|
75
|
+
base = "https://www.jobkorea.co.kr" if source == "jobkorea" else "https://www.saramin.co.kr"
|
|
76
|
+
return urllib.parse.urljoin(base, href)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def attribute_text(value: object) -> str:
|
|
80
|
+
return value if isinstance(value, str) else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def split_keywords(text: str) -> list[str]:
|
|
84
|
+
found: list[str] = []
|
|
85
|
+
lower = text.lower()
|
|
86
|
+
for term in ROLE_TERMS + TOOL_TERMS + INDUSTRY_TERMS:
|
|
87
|
+
if term.lower() in lower and term not in found:
|
|
88
|
+
found.append(term)
|
|
89
|
+
# Add a few meaningful free tokens, but avoid single Korean syllables and common words.
|
|
90
|
+
stop = {"경력", "경험", "담당", "업무", "지원", "희망", "사용", "가능", "이력서", "자기소개서", "서울", "경기"}
|
|
91
|
+
for token in re.findall(r"[A-Za-z][A-Za-z0-9.+#-]{1,}|[가-힣]{2,}", text):
|
|
92
|
+
if token in stop or token in found:
|
|
93
|
+
continue
|
|
94
|
+
if len(found) >= 16:
|
|
95
|
+
break
|
|
96
|
+
found.append(token)
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_queries(resume_text: str, explicit: list[str]) -> list[str]:
|
|
101
|
+
if explicit:
|
|
102
|
+
return [q.strip() for q in explicit if q.strip()]
|
|
103
|
+
terms = split_keywords(resume_text)
|
|
104
|
+
roles = [t for t in terms if t in ROLE_TERMS]
|
|
105
|
+
tools = [t for t in terms if t in TOOL_TERMS]
|
|
106
|
+
industries = [t for t in terms if t in INDUSTRY_TERMS]
|
|
107
|
+
queries: list[str] = []
|
|
108
|
+
if roles:
|
|
109
|
+
queries.append(" ".join((roles[:1] + tools[:2])[:3]))
|
|
110
|
+
if len(roles) > 1:
|
|
111
|
+
queries.append(" ".join((roles[1:2] + tools[:1])[:2]))
|
|
112
|
+
if industries and roles:
|
|
113
|
+
queries.append(f"{industries[0]} {roles[0]}")
|
|
114
|
+
if not queries and terms:
|
|
115
|
+
queries.append(" ".join(terms[:3]))
|
|
116
|
+
return list(dict.fromkeys(q for q in queries if q))[:3]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_jobkorea(markup: str, limit: int) -> list[Posting]:
|
|
120
|
+
postings: list[Posting] = []
|
|
121
|
+
seen: set[str] = set()
|
|
122
|
+
try:
|
|
123
|
+
from bs4 import BeautifulSoup # type: ignore
|
|
124
|
+
soup = BeautifulSoup(markup, "html.parser")
|
|
125
|
+
anchors = soup.find_all("a", href=re.compile(r"/Recruit/GI_Read/", re.I))
|
|
126
|
+
for a in anchors:
|
|
127
|
+
url = absolute_url("jobkorea", attribute_text(a.get("href")))
|
|
128
|
+
job_id = re.search(r"/Recruit/GI_Read/(\d+)", url)
|
|
129
|
+
key = job_id.group(1) if job_id else url
|
|
130
|
+
text = clean_text(a.get_text(" "))
|
|
131
|
+
if not text or key in seen or len(text) < 4:
|
|
132
|
+
continue
|
|
133
|
+
container = a.find_parent(attrs={"data-sentry-component": "CardJob"}) or a.find_parent("div")
|
|
134
|
+
blob = clean_text(container.get_text(" ") if container else text)
|
|
135
|
+
company = ""
|
|
136
|
+
same_links = container.find_all("a", href=True) if container else []
|
|
137
|
+
for link in same_links:
|
|
138
|
+
lt = clean_text(link.get_text(" "))
|
|
139
|
+
if lt and lt != text and len(lt) <= 40 and not re.search(r"채용|모집|마케터|개발자|담당|매니저", lt):
|
|
140
|
+
company = lt
|
|
141
|
+
break
|
|
142
|
+
postings.append(Posting(source="jobkorea", title=text, company=company, url=url, summary=blob[:700]))
|
|
143
|
+
seen.add(key)
|
|
144
|
+
if len(postings) >= limit:
|
|
145
|
+
break
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
if postings:
|
|
149
|
+
return postings
|
|
150
|
+
|
|
151
|
+
for m in re.finditer(r'<a\b[^>]*href=["\']([^"\']*/Recruit/GI_Read/\d+[^"\']*)["\'][^>]*>(.*?)</a>', markup, re.I | re.S):
|
|
152
|
+
url = absolute_url("jobkorea", m.group(1))
|
|
153
|
+
key = re.search(r"/Recruit/GI_Read/(\d+)", url)
|
|
154
|
+
key_s = key.group(1) if key else url
|
|
155
|
+
title = clean_text(m.group(2))
|
|
156
|
+
if not title or key_s in seen:
|
|
157
|
+
continue
|
|
158
|
+
postings.append(Posting(source="jobkorea", title=title, url=url, summary=title))
|
|
159
|
+
seen.add(key_s)
|
|
160
|
+
if len(postings) >= limit:
|
|
161
|
+
break
|
|
162
|
+
return postings
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def parse_saramin(markup: str, limit: int) -> list[Posting]:
|
|
166
|
+
postings: list[Posting] = []
|
|
167
|
+
seen: set[str] = set()
|
|
168
|
+
try:
|
|
169
|
+
from bs4 import BeautifulSoup # type: ignore
|
|
170
|
+
soup = BeautifulSoup(markup, "html.parser")
|
|
171
|
+
for item in soup.select("div.item_recruit, div.list_item, div.item_recruit_sri"):
|
|
172
|
+
a = item.select_one('h2.job_tit a[href*="/zf_user/jobs/relay/view"], a[href*="rec_idx="]')
|
|
173
|
+
if not a:
|
|
174
|
+
continue
|
|
175
|
+
url = absolute_url("saramin", attribute_text(a.get("href")))
|
|
176
|
+
rec = re.search(r"rec_idx=(\d+)", url)
|
|
177
|
+
key = rec.group(1) if rec else url
|
|
178
|
+
if key in seen:
|
|
179
|
+
continue
|
|
180
|
+
title = clean_text(attribute_text(a.get("title")) or a.get_text(" "))
|
|
181
|
+
company_el = item.select_one(".corp_name a, .corp_name, .company_nm a, .company_nm")
|
|
182
|
+
condition = clean_text(" ".join(x.get_text(" ") for x in item.select(".job_condition span, .job_sector, .area, .career, .education")))
|
|
183
|
+
deadline_el = item.select_one(".date, .deadlines, .support_detail .date")
|
|
184
|
+
company = clean_text(company_el.get_text(" ")) if company_el else ""
|
|
185
|
+
deadline = clean_text(deadline_el.get_text(" ")) if deadline_el else ""
|
|
186
|
+
blob = clean_text(item.get_text(" "))
|
|
187
|
+
postings.append(Posting(source="saramin", title=title, company=company, url=url, career=condition, deadline=deadline, summary=blob[:700]))
|
|
188
|
+
seen.add(key)
|
|
189
|
+
if len(postings) >= limit:
|
|
190
|
+
break
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
if postings:
|
|
194
|
+
return postings
|
|
195
|
+
|
|
196
|
+
blocks = re.split(r'<div[^>]+class=["\'][^"\']*item_recruit', markup, flags=re.I)
|
|
197
|
+
for block in blocks[1:]:
|
|
198
|
+
m = re.search(r'<a\b[^>]*href=["\']([^"\']*rec_idx=\d+[^"\']*)["\'][^>]*(?:title=["\']([^"\']+)["\'])?[^>]*>(.*?)</a>', block, re.I | re.S)
|
|
199
|
+
if not m:
|
|
200
|
+
continue
|
|
201
|
+
url = absolute_url("saramin", m.group(1))
|
|
202
|
+
key = re.search(r"rec_idx=(\d+)", url)
|
|
203
|
+
key_s = key.group(1) if key else url
|
|
204
|
+
if key_s in seen:
|
|
205
|
+
continue
|
|
206
|
+
title = clean_text(m.group(2) or m.group(3))
|
|
207
|
+
company_match = re.search(r'class=["\'][^"\']*corp_name[^"\']*["\'][^>]*>\s*(?:<a[^>]*>)?(.*?)(?:</a>)?\s*</', block, re.I | re.S)
|
|
208
|
+
company = clean_text(company_match.group(1)) if company_match else ""
|
|
209
|
+
postings.append(Posting(source="saramin", title=title, company=company, url=url, summary=clean_text(block)[:700]))
|
|
210
|
+
seen.add(key_s)
|
|
211
|
+
if len(postings) >= limit:
|
|
212
|
+
break
|
|
213
|
+
return postings
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def search_jobkorea(query: str, limit: int) -> list[Posting]:
|
|
217
|
+
url = "https://www.jobkorea.co.kr/Search/?" + urllib.parse.urlencode({"stext": query})
|
|
218
|
+
markup = fetch(url)
|
|
219
|
+
if "잡코리아" not in markup and "JobKorea" not in markup:
|
|
220
|
+
raise RuntimeError("잡코리아가 예상과 다른 응답을 반환했습니다")
|
|
221
|
+
return parse_jobkorea(markup, limit)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def search_saramin(query: str, limit: int) -> list[Posting]:
|
|
225
|
+
url = "https://www.saramin.co.kr/zf_user/search/recruit?" + urllib.parse.urlencode({"searchword": query})
|
|
226
|
+
markup = fetch(url)
|
|
227
|
+
if "사람인" not in markup and "Saramin" not in markup:
|
|
228
|
+
raise RuntimeError("사람인이 예상과 다른 응답을 반환했습니다")
|
|
229
|
+
return parse_saramin(markup, limit)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def detect_career_years(text: str) -> int | None:
|
|
233
|
+
years = [int(x) for x in re.findall(r"(\d{1,2})\s*년", text)]
|
|
234
|
+
return max(years) if years else None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def negative_term_matches(term: str, haystack: str) -> bool:
|
|
238
|
+
normalized = term.strip().lower()
|
|
239
|
+
if not normalized:
|
|
240
|
+
return False
|
|
241
|
+
if re.fullmatch(r"[a-z0-9]{1,3}", normalized):
|
|
242
|
+
return re.search(rf"(?<![a-z0-9]){re.escape(normalized)}(?![a-z0-9])", haystack) is not None
|
|
243
|
+
return normalized in haystack
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def score_posting(posting: Posting, resume_text: str, desired_locations: list[str], negative_terms: list[str], career_years: int | None) -> Posting:
|
|
247
|
+
haystack = " ".join([posting.title, posting.company, posting.location, posting.career, posting.summary]).lower()
|
|
248
|
+
resume_terms = split_keywords(resume_text)
|
|
249
|
+
matched = [term for term in resume_terms if term.lower() in haystack]
|
|
250
|
+
role_matches = [t for t in matched if t in ROLE_TERMS]
|
|
251
|
+
tool_matches = [t for t in matched if t in TOOL_TERMS]
|
|
252
|
+
industry_matches = [t for t in matched if t in INDUSTRY_TERMS]
|
|
253
|
+
|
|
254
|
+
score = 35
|
|
255
|
+
score += min(25, len(role_matches) * 12 + max(0, len(matched) - len(role_matches)) * 2)
|
|
256
|
+
score += min(20, len(tool_matches) * 5)
|
|
257
|
+
score += min(10, len(industry_matches) * 5)
|
|
258
|
+
|
|
259
|
+
reasons: list[str] = []
|
|
260
|
+
cautions: list[str] = []
|
|
261
|
+
if role_matches:
|
|
262
|
+
reasons.append("직무 키워드 일치: " + ", ".join(role_matches[:3]))
|
|
263
|
+
if tool_matches:
|
|
264
|
+
reasons.append("도구/스킬 일치: " + ", ".join(tool_matches[:5]))
|
|
265
|
+
if industry_matches:
|
|
266
|
+
reasons.append("산업/도메인 힌트 일치: " + ", ".join(industry_matches[:3]))
|
|
267
|
+
if desired_locations:
|
|
268
|
+
loc_hits = [loc for loc in desired_locations if loc.lower() in haystack]
|
|
269
|
+
if loc_hits:
|
|
270
|
+
score += 10
|
|
271
|
+
reasons.append("희망 지역 일치: " + ", ".join(loc_hits))
|
|
272
|
+
else:
|
|
273
|
+
cautions.append("희망 지역은 목록 요약에서 확인되지 않음")
|
|
274
|
+
if career_years is not None:
|
|
275
|
+
if "신입" in haystack and career_years >= 3:
|
|
276
|
+
score -= 15
|
|
277
|
+
cautions.append("신입 공고일 가능성이 있어 경력과 맞지 않을 수 있음")
|
|
278
|
+
elif "경력" in haystack or re.search(r"\d+\s*년", haystack):
|
|
279
|
+
score += 5
|
|
280
|
+
reasons.append("경력 조건이 있는 공고로 보임")
|
|
281
|
+
bad_hits = [term for term in negative_terms if negative_term_matches(term, haystack)]
|
|
282
|
+
if bad_hits:
|
|
283
|
+
score -= 30
|
|
284
|
+
cautions.append("제외 조건 감지: " + ", ".join(bad_hits[:5]))
|
|
285
|
+
if not reasons:
|
|
286
|
+
cautions.append("이력서 키워드와 직접 일치하는 근거가 적어 낮은 신뢰도의 후보")
|
|
287
|
+
|
|
288
|
+
posting.score = max(0, min(100, score))
|
|
289
|
+
posting.reasons = reasons
|
|
290
|
+
posting.cautions = cautions
|
|
291
|
+
posting.matched_terms = matched[:12]
|
|
292
|
+
return posting
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def dedupe(postings: Iterable[Posting]) -> list[Posting]:
|
|
296
|
+
out: list[Posting] = []
|
|
297
|
+
seen: set[str] = set()
|
|
298
|
+
for p in postings:
|
|
299
|
+
key = re.sub(r"[?#].*$", "", p.url) or (p.source + p.title + p.company)
|
|
300
|
+
if key in seen:
|
|
301
|
+
continue
|
|
302
|
+
seen.add(key)
|
|
303
|
+
out.append(p)
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def print_markdown(postings: list[Posting], queries: list[str], args: argparse.Namespace) -> None:
|
|
308
|
+
print("# 이력서 기반 채용공고 매칭 결과\n")
|
|
309
|
+
print(f"- 검색어: {', '.join(queries)}")
|
|
310
|
+
print(f"- 조회 소스: {', '.join(args.source)}")
|
|
311
|
+
print(f"- 결과 수: {len(postings)}")
|
|
312
|
+
print("- 주의: 공개 검색 결과 요약 기반 추천입니다. 지원 전 원문 공고에서 마감일·연봉·근무지·자격요건을 직접 확인하세요.")
|
|
313
|
+
print("- 안전 경계: 로그인, 스크랩, 입사지원, 개인정보 입력, 메시지 발송은 자동 수행하지 않았습니다.\n")
|
|
314
|
+
for idx, p in enumerate(postings, 1):
|
|
315
|
+
print(f"## {idx}. [{p.source}] {p.title}")
|
|
316
|
+
if p.company:
|
|
317
|
+
print(f"- 회사: {p.company}")
|
|
318
|
+
print(f"- 점수: {p.score}/100")
|
|
319
|
+
print(f"- URL: {p.url}")
|
|
320
|
+
if p.reasons:
|
|
321
|
+
print("- 추천 이유: " + "; ".join(p.reasons))
|
|
322
|
+
if p.cautions:
|
|
323
|
+
print("- 주의점: " + "; ".join(p.cautions))
|
|
324
|
+
if p.matched_terms:
|
|
325
|
+
print("- 매칭 키워드: " + ", ".join(p.matched_terms))
|
|
326
|
+
print("- 지원 전략: 공고 제목/요약과 일치하는 경험을 이력서 상단 요약과 최근 경력 bullet에 먼저 배치하세요.")
|
|
327
|
+
print()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
331
|
+
parser = argparse.ArgumentParser(description="Match JobKorea/Saramin public job postings against a resume")
|
|
332
|
+
src = parser.add_mutually_exclusive_group()
|
|
333
|
+
src.add_argument("--resume-text", help="이력서/경력요약 텍스트")
|
|
334
|
+
src.add_argument("--resume-file", help="이력서/경력요약 텍스트 파일")
|
|
335
|
+
parser.add_argument("--keyword", "-k", action="append", default=[], help="검색어. 없으면 이력서에서 자동 생성")
|
|
336
|
+
parser.add_argument("--location", action="append", default=[], help="희망 지역. 여러 번 지정 가능")
|
|
337
|
+
parser.add_argument("--negative", action="append", default=[], help="제외 키워드. 여러 번 지정 가능")
|
|
338
|
+
parser.add_argument("--career-years", type=int, help="경력 연차. 없으면 이력서 텍스트에서 추정")
|
|
339
|
+
parser.add_argument("--source", action="append", choices=["jobkorea", "saramin"], help="조회 소스. 기본: 둘 다")
|
|
340
|
+
parser.add_argument("--per-source", type=int, default=10, help="검색어별/소스별 가져올 공고 수")
|
|
341
|
+
parser.add_argument("--limit", type=int, default=10, help="최종 출력 공고 수")
|
|
342
|
+
parser.add_argument("--json", action="store_true", help="JSON 출력")
|
|
343
|
+
return parser
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def run(argv: list[str] | None = None) -> int:
|
|
347
|
+
parser = build_parser()
|
|
348
|
+
args = parser.parse_args(argv)
|
|
349
|
+
if args.resume_file:
|
|
350
|
+
resume_text = Path(args.resume_file).read_text(encoding="utf-8")
|
|
351
|
+
else:
|
|
352
|
+
resume_text = args.resume_text or ""
|
|
353
|
+
if not resume_text and not args.keyword:
|
|
354
|
+
parser.error("--resume-text/--resume-file 또는 --keyword 중 하나는 필요합니다")
|
|
355
|
+
args.source = args.source or ["jobkorea", "saramin"]
|
|
356
|
+
career_years = args.career_years if args.career_years is not None else detect_career_years(resume_text)
|
|
357
|
+
queries = build_queries(resume_text, args.keyword)
|
|
358
|
+
negative_terms = list(dict.fromkeys(args.negative + NEGATIVE_DEFAULTS))
|
|
359
|
+
|
|
360
|
+
collected: list[Posting] = []
|
|
361
|
+
errors: list[str] = []
|
|
362
|
+
for query in queries:
|
|
363
|
+
if "jobkorea" in args.source:
|
|
364
|
+
try:
|
|
365
|
+
collected.extend(search_jobkorea(query, args.per_source))
|
|
366
|
+
except Exception as exc: # noqa: BLE001 - CLI should continue with other source
|
|
367
|
+
errors.append(f"jobkorea:{query}: {exc}")
|
|
368
|
+
if "saramin" in args.source:
|
|
369
|
+
try:
|
|
370
|
+
collected.extend(search_saramin(query, args.per_source))
|
|
371
|
+
except Exception as exc: # noqa: BLE001
|
|
372
|
+
errors.append(f"saramin:{query}: {exc}")
|
|
373
|
+
|
|
374
|
+
scored = [score_posting(p, resume_text or " ".join(queries), args.location, negative_terms, career_years) for p in dedupe(collected)]
|
|
375
|
+
scored.sort(key=lambda p: p.score, reverse=True)
|
|
376
|
+
result = scored[: args.limit]
|
|
377
|
+
if args.json:
|
|
378
|
+
print(json.dumps({"queries": queries, "errors": errors, "postings": [asdict(p) for p in result]}, ensure_ascii=False, indent=2))
|
|
379
|
+
else:
|
|
380
|
+
print_markdown(result, queries, args)
|
|
381
|
+
if errors:
|
|
382
|
+
print("## 조회 경고")
|
|
383
|
+
for error in errors:
|
|
384
|
+
print(f"- {error}")
|
|
385
|
+
return 0 if result else 1
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
try:
|
|
390
|
+
raise SystemExit(run())
|
|
391
|
+
except urllib.error.HTTPError as exc:
|
|
392
|
+
print(f"HTTP error: {exc.code} {exc.reason}", file=sys.stderr)
|
|
393
|
+
raise SystemExit(2)
|
|
394
|
+
except (urllib.error.URLError, RuntimeError) as exc:
|
|
395
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
396
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from job_posting_match import NEGATIVE_DEFAULTS, Posting, build_queries, parse_jobkorea, parse_saramin, score_posting
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
JOBKOREA_FIXTURE = '\n<div data-sentry-component="CardJob">\n <a href="https://www.jobkorea.co.kr/Recruit/GI_Read/111?x=1">퍼포먼스 마케터 채용</a>\n <a href="https://www.jobkorea.co.kr/Recruit/GI_Read/111?x=1">커머스A</a>\n <p>서울 강남구 경력 3년 GA4 Google Ads Meta Ads 커머스</p>\n</div>\n<div data-sentry-component="CardJob">\n <a href="https://www.jobkorea.co.kr/Recruit/GI_Read/222?x=1">보험영업 담당자</a>\n <a href="https://www.jobkorea.co.kr/Recruit/GI_Read/222?x=1">보험B</a>\n <p>대출영업 TM 신입</p>\n</div>\n'
|
|
7
|
+
|
|
8
|
+
SARANIM_FIXTURE = '\n<div class="item_recruit">\n <div class="corp_name"><a>뷰티커머스</a></div>\n <h2 class="job_tit"><a title="화장품 브랜드 퍼포먼스 마케터" href="/zf_user/jobs/relay/view?rec_idx=333&searchword=x"><span>화장품 브랜드 <b>퍼포먼스 마케터</b></span></a></h2>\n <div class="job_condition"><span>서울</span><span>경력 5년</span></div>\n <div class="job_sector">GA4 CRM Meta Ads 커머스</div>\n</div>\n<div class="item_recruit">\n <div class="corp_name"><a>무관회사</a></div>\n <h2 class="job_tit"><a title="총무 담당자" href="/zf_user/jobs/relay/view?rec_idx=444"><span>총무 담당자</span></a></h2>\n <div class="job_condition"><span>부산</span><span>신입</span></div>\n</div>\n'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JobPostingMatchTest(unittest.TestCase):
|
|
12
|
+
def test_build_queries_from_resume(self):
|
|
13
|
+
queries = build_queries("퍼포먼스 마케터 5년 GA4 Meta Ads SQL 커머스", [])
|
|
14
|
+
self.assertTrue(any("퍼포먼스 마케터" in q for q in queries))
|
|
15
|
+
self.assertLessEqual(len(queries), 3)
|
|
16
|
+
|
|
17
|
+
def test_parse_jobkorea_cards_and_dedupe(self):
|
|
18
|
+
postings = parse_jobkorea(JOBKOREA_FIXTURE, 10)
|
|
19
|
+
self.assertEqual(len(postings), 2)
|
|
20
|
+
self.assertEqual(postings[0].title, "퍼포먼스 마케터 채용")
|
|
21
|
+
self.assertIn("GI_Read/111", postings[0].url)
|
|
22
|
+
|
|
23
|
+
def test_parse_saramin_cards(self):
|
|
24
|
+
postings = parse_saramin(SARANIM_FIXTURE, 10)
|
|
25
|
+
self.assertEqual(len(postings), 2)
|
|
26
|
+
self.assertEqual(postings[0].company, "뷰티커머스")
|
|
27
|
+
self.assertIn("rec_idx=333", postings[0].url)
|
|
28
|
+
|
|
29
|
+
def test_score_prefers_matching_posting_and_penalizes_negative(self):
|
|
30
|
+
resume = "퍼포먼스 마케터 5년 GA4 Google Ads Meta Ads SQL 커머스 서울"
|
|
31
|
+
good, bad = parse_jobkorea(JOBKOREA_FIXTURE, 10)
|
|
32
|
+
good = score_posting(good, resume, ["서울"], ["보험영업", "대출영업"], 5)
|
|
33
|
+
bad = score_posting(bad, resume, ["서울"], ["보험영업", "대출영업"], 5)
|
|
34
|
+
self.assertGreater(good.score, bad.score)
|
|
35
|
+
self.assertTrue(good.reasons)
|
|
36
|
+
self.assertTrue(any("제외 조건" in c for c in bad.cautions))
|
|
37
|
+
|
|
38
|
+
def test_score_does_not_match_short_latin_negative_inside_words(self):
|
|
39
|
+
resume = "프론트엔드 개발자 5년 React TypeScript HTML CSS 서울"
|
|
40
|
+
posting = Posting(
|
|
41
|
+
source="fixture",
|
|
42
|
+
title="프론트엔드 개발자",
|
|
43
|
+
company="A",
|
|
44
|
+
summary="React TypeScript HTML CSS 서울 경력 5년",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
scored = score_posting(posting, resume, ["서울"], NEGATIVE_DEFAULTS, 5)
|
|
48
|
+
|
|
49
|
+
self.assertEqual(scored.score, 84)
|
|
50
|
+
self.assertFalse(any("TM" in caution for caution in scored.cautions))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
unittest.main()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "job-posting-match",
|
|
3
|
+
"description": "한국 구직자의 이력서·경력요약과 희망조건을 바탕으로 잡코리아·사람인 공개 채용공고를 검색하고 적합도와 지원 전략을 정리합니다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
|
|
4
|
+
"profiles": [
|
|
5
|
+
"action:communication"
|
|
6
|
+
],
|
|
7
|
+
"frontmatter": "name: job-posting-match\ndescription: 한국 구직자의 이력서·경력요약과 희망조건을 바탕으로 잡코리아·사람인 공개 채용공고를 검색하고 적합도와 지원 전략을 정리합니다. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: recruiting\n locale: ko-KR\n phase: v1"
|
|
8
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# jobkorea-talent-search
|
|
2
|
+
|
|
3
|
+
## Use when
|
|
4
|
+
|
|
5
|
+
- 사용자가 잡코리아에서 후보를 찾아달라고 요청한다.
|
|
6
|
+
- 기업회원 로그인 세션에서 마스킹 이력서/목록을 비교해야 한다.
|
|
7
|
+
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
|
|
8
|
+
|
|
9
|
+
## Hard boundaries
|
|
10
|
+
|
|
11
|
+
Allowed:
|
|
12
|
+
- 잡코리아 기업회원 브라우저 세션 열기 및 검색 필터 입력
|
|
13
|
+
- 현재 보이는 마스킹 목록/이력서/프로필 읽기
|
|
14
|
+
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
|
|
15
|
+
|
|
16
|
+
Never do without explicit user handoff/confirmation:
|
|
17
|
+
- 유료 이력서 열람, 마스킹 해제, 연락처 확인
|
|
18
|
+
- 포지션 제안 발송, 스크랩, 메모 저장, 후보 상태 변경
|
|
19
|
+
- 결제/유료 크레딧 사용
|
|
20
|
+
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
|
|
21
|
+
- 후보 개인정보 장기 저장 또는 대량 수집
|
|
22
|
+
|
|
23
|
+
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
|
|
24
|
+
|
|
25
|
+
## Primary access
|
|
26
|
+
|
|
27
|
+
Open:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
https://www.jobkorea.co.kr/corp/person/find
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
If not logged in, pause and show:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
잡코리아 인재검색은 경력 상세/포트폴리오/마스킹 이력서 확인을 위해 기업회원 로그인이 필요합니다.
|
|
37
|
+
제가 브라우저로 잡코리아 기업 인재검색 페이지를 열어둘게요.
|
|
38
|
+
열린 브라우저에서 직접 로그인해 주세요. 비밀번호나 인증정보는 저에게 알려주지 마세요.
|
|
39
|
+
로그인이 끝나면 “로그인했어”라고 알려주시면, 같은 브라우저 세션에서 검색을 이어가겠습니다.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Resume only in the same browser session after the user confirms login.
|
|
43
|
+
|
|
44
|
+
## Input normalization
|
|
45
|
+
|
|
46
|
+
Extract or infer:
|
|
47
|
+
|
|
48
|
+
- role_title
|
|
49
|
+
- must_have / nice_to_have
|
|
50
|
+
- negative_keywords
|
|
51
|
+
- career min/max
|
|
52
|
+
- location/work_area
|
|
53
|
+
- role-specific evaluation signals
|
|
54
|
+
- limit / requested Top N
|
|
55
|
+
|
|
56
|
+
Do not block on missing details when a reasonable first search is possible.
|
|
57
|
+
|
|
58
|
+
## Workflow
|
|
59
|
+
|
|
60
|
+
1. Open the primary URL and verify corporate login.
|
|
61
|
+
2. Ask the user to log in manually only when required; never handle credentials.
|
|
62
|
+
3. Apply filters: keyword, 직무/스킬, 지역, 경력, recent activity/update, exclusions when supported.
|
|
63
|
+
4. Build a candidate pool from visible rows.
|
|
64
|
+
5. Before final Top N, open normal resume/detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
|
|
65
|
+
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary, portfolio links if visible, recent activity.
|
|
66
|
+
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
|
|
67
|
+
8. Return Korean shortlist with direct URL per recommended candidate.
|
|
68
|
+
|
|
69
|
+
If detail pages are inaccessible or paid-walled, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
|
|
70
|
+
|
|
71
|
+
## No-login fallback
|
|
72
|
+
|
|
73
|
+
Use only when the user cannot or will not log in. It is low-confidence because it cannot inspect resume details.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python3 jobkorea-talent-search/scripts/jobkorea_talent_search.py --keyword "퍼포먼스 마케터 GA4" --work-area "서울" --career-min 3 --career-max 7 --limit 20
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## URL extraction guidance
|
|
80
|
+
|
|
81
|
+
Every recommended candidate needs a direct JobKorea resume/profile URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
|
|
82
|
+
|
|
83
|
+
## Output shape
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
잡코리아 인재 shortlist
|
|
87
|
+
|
|
88
|
+
검색 조건
|
|
89
|
+
- 포지션: ...
|
|
90
|
+
- 필수/우대/제외 조건: ...
|
|
91
|
+
- 경력/지역: ...
|
|
92
|
+
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
|
|
93
|
+
|
|
94
|
+
유료 열람 추천 Top N
|
|
95
|
+
1. 후보 A
|
|
96
|
+
- 점수: 88/100
|
|
97
|
+
- 근거: ...
|
|
98
|
+
- 보이는 경력/성과: ...
|
|
99
|
+
- 리스크: ...
|
|
100
|
+
- 추천 액션: 채용 담당자가 유료 열람 검토
|
|
101
|
+
- URL: ...
|
|
102
|
+
|
|
103
|
+
보류 후보
|
|
104
|
+
- ...
|
|
105
|
+
|
|
106
|
+
검색 한계
|
|
107
|
+
- 마스킹/현재 표시 정보만 분석했음
|
|
108
|
+
- 연락처/실명/비공개 정보는 열람하지 않음
|
|
109
|
+
- 유료 액션은 실행하지 않음
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Failure modes
|
|
113
|
+
|
|
114
|
+
- Login/2FA required: open the page and let the user complete it manually.
|
|
115
|
+
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to low-confidence fallback.
|
|
116
|
+
- Paid wall/contact wall: stop and mark as manual paid review needed.
|
|
117
|
+
- Empty results: adjust keywords, career, region, update/relevance filters.
|
|
118
|
+
- UI changed: rediscover the visible form/data flow before updating scripts.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
BASE_URL: Final = "https://www.jobkorea.co.kr"
|
|
7
|
+
FIND_PATH: Final = "/corp/person/find"
|
|
8
|
+
AJAX_PATH: Final = "/corp/person/detailsearchajax"
|
|
9
|
+
DEFAULT_UA: Final = (
|
|
10
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
11
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Candidate:
|
|
17
|
+
rno: str
|
|
18
|
+
url: str
|
|
19
|
+
name: str = ""
|
|
20
|
+
meta: str = ""
|
|
21
|
+
career: str = ""
|
|
22
|
+
education: str = ""
|
|
23
|
+
locations: str = ""
|
|
24
|
+
salary: str = ""
|
|
25
|
+
skills: str = ""
|
|
26
|
+
badges: str = ""
|
|
27
|
+
raw_summary: str = ""
|