
Korean Scholarship Search
- 2.8k installs
- 7k repo stars
- Updated August 2, 2026
- nomadamas/k-skill
korean-scholarship-search is an agent skill that Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector source.
About
사용자에게는 장학금 검색 및 조회 라는 이름으로 안내하고 skill slug는 korean scholarship search 이다 한국장학재단 대학 재단 기업 지자체 공공기관의 공식 장학 공고 를 최신 기준으로 검색하고 아래 항목을 정리한다 장학금명 운영기관명 기관 유형 school foundation government company local government other 지원 금액 등록금 생활비 구분 신청 기간 지원 조건 지원 자격 학자금 지원구간 소득구간 조건 공식 공고 링크 신청 링크 특정 학교가 주어지면 그 학교의 본부 학생지원처 단과대 학과 전공 대학원 공지를 전수 탐색하려고 시도한다 학교가 주어지지 않으면 ac kr 전체를 기준으로 전국 대학 장학 공고를 넓게 찾는다 The korean scholarship search skill documents workflows prerequisites and usage patterns grounded in its repository SKILL md Agents should follow the documented steps respect safety and permission notes and cite only capabilities described in the source It triggers on phrases matching the skill description and integrates with the agent toolchain for the tasks outlined in the documentation
- description: Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-
- 사용자에게는 `장학금 검색 및 조회` 라는 이름으로 안내하고, skill slug는 `korean-scholarship-search` 이다.
- 한국장학재단, 대학, 재단, 기업, 지자체/공공기관의 **공식 장학 공고**를 최신 기준으로 검색하고 아래 항목을 정리한다.
- See SKILL.md for korean-scholarship-search operational details.
- See SKILL.md for korean-scholarship-search operational details.
Korean Scholarship Search by the numbers
- 2,765 all-time installs (skills.sh)
- +243 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #294 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
korean-scholarship-search capabilities & compatibility
- Capabilities
- description: search korean scholarship announcem · 사용자에게는 `장학금 검색 및 조회` 라는 이름으로 안내하고, skill slug는 ` · 한국장학재단, 대학, 재단, 기업, 지자체/공공기관의 **공식 장학 공고**를 최신 기 · see skill.md for korean scholarship search opera
- Use cases
- orchestration
What korean-scholarship-search says it does
description: Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by school, in
사용자에게는 `장학금 검색 및 조회` 라는 이름으로 안내하고, skill slug는 `korean-scholarship-search` 이다.
한국장학재단, 대학, 재단, 기업, 지자체/공공기관의 **공식 장학 공고**를 최신 기준으로 검색하고 아래 항목을 정리한다.
npx skills add https://github.com/nomadamas/k-skill --skill korean-scholarship-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.8k |
|---|---|
| repo stars | ★ 7k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 2, 2026 |
| Repository | nomadamas/k-skill ↗ |
What does korean-scholarship-search help with and when should an agent load it?
Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by school, income band, st
Who is it for?
Developers using korean-scholarship-search as documented in the skill repository.
Skip if: Skip when the task falls outside the korean-scholarship-search documented scope.
When should I use this skill?
Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by school, income band, st
What you get
Agent actions aligned with the korean-scholarship-search SKILL.md workflow and documented deliverables.
Files
장학금 검색 및 조회
사용자에게는 장학금 검색 및 조회 라는 이름으로 안내하고, skill slug는 korean-scholarship-search 이다.
What this skill does
한국장학재단, 대학, 재단, 기업, 지자체/공공기관의 공식 장학 공고를 최신 기준으로 검색하고 아래 항목을 정리한다.
이 스킬은 공식 공고 우선 이다.
- 장학금명
- 운영기관명 / 기관 유형 (
school,foundation,government,company,local-government,other) - 지원 금액 / 등록금·생활비 구분
- 신청 기간
- 지원 조건 / 지원 자격
- 학자금 지원구간(소득구간) 조건
- 공식 공고 링크 / 신청 링크
특정 학교가 주어지면 그 학교의 본부, 학생지원처, 단과대, 학과/전공, 대학원 공지를 전수 탐색하려고 시도한다. 학교가 주어지지 않으면 *.ac.kr 전체를 기준으로 전국 대학 장학 공고를 넓게 찾는다.
필요하면 동봉된 helper(scripts/scholarship_filter.py)로 사용자 조건에 맞게 후처리 필터링하고, 지원 가능 여부를 빠르게 판정하고, KST(Asia/Seoul) 현재 날짜 기준 readable report를 만든다. --today 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준일로 사용한다.
Works in both Claude Code and Codex
- 이 스킬은 특정 에이전트 전용이 아니다.
- Claude Code에서도 사용 가능하고, Codex에서도 사용 가능하다.
- 핵심은 에이전트가 최신 웹 검색을 할 수 있어야 한다는 점이다.
- 장학금 마감일과 자격은 자주 바뀌므로 항상 fresh search 를 우선한다.
When to use
- "한국 장학금 전부 찾아줘"
- "서울대 학부생이 지원 가능한 재단 장학금 찾아줘"
- "생활비 200만원 이상 주는 장학금만 골라줘"
- "학교 장학금 말고 민간재단 장학금만 보고 싶어"
- "학자금 지원구간 5구간 이하 대상 장학금만 정리해줘"
- "컴퓨터공학과 대학원생 장학금 링크까지 정리해줘"
- "내 조건으로 지원 가능한지 같이 판정해줘"
When not to use
- 장학금 신청서 직접 제출/자동 접수
- 비공개 커뮤니티/로그인 뒤에서만 보이는 모집공고 수집
- 법률 자문이나 합격 보장 판단
Source priority
항상 아래 우선순위를 따른다.
1. 한국장학재단 공식 페이지 (kosaf.go.kr) 2. 대학 공식 도메인 (*.ac.kr)의 학생지원처/장학공지/학사공지 3. 공공기관/지자체/재단 공식 페이지 (*.go.kr, *.or.kr, 공식 재단 도메인) 4. 기업 공식 CSR/재단/채용·공지 페이지 5. 비공식 모음글/블로그/커뮤니티는 lead source 로만 사용하고, 공식 공고로 교차검증되지 않으면 제외
소스별 검색 패턴은 references/source-patterns.md 를 보고, 검색 누락을 줄이려면 references/search-clues.md 의 키워드와 제목 단서를 같이 쓴다.
Inputs
- 사용자 프로필
- 학교명 / 학교 유형
- 학부/대학원/고등학생 여부
- 학년
- 전공
- GPA 또는 백분위
- 학자금 지원구간
- 선호 조건
- 기관 유형 (
school,foundation,government,company,local-government) - 최소/최대 금액
- 등록금형 / 생활비형
- 마감 상태 (
open,upcoming) - 특정 지역 / 특정 학교 / 특정 재단
사용자가 필터링을 원하지만 핵심 입력이 비어 있으면, 한 번에 1~3개만 짧게 보강 질문한다.
Prerequisites
- 최신 웹 검색 가능 환경
- 인터넷 연결
- 선택:
python33.8+ (scripts/scholarship_filter.pyhelper 사용 시)
Workflow
1. 검색 범위를 먼저 정한다
- 사용자가 "전체"를 원하면 학교/재단/공공기관을 다 포함해 넓게 찾는다.
- 사용자가 "재단만", "학교 공고만", "생활비만" 같은 제약을 주면 그 제약부터 적용한다.
- 날짜 관련 표현은 반드시 절대 날짜로 정리한다.
2. 공식 소스를 병렬 탐색한다
최소한 아래 3축을 본다.
- 한국장학재단 공식 장학 페이지
- 사용자 학교 또는 관련 대학군의 공식 장학 공지
- 재단/기업/공공기관 공식 공고
검색 제목이 장학금 이 아닐 수 있으니 장학생 모집, 외부 장학 추천, 등록금 감면, 생활비 지원, 학업장려비, 추천장학, 근로장학, 성적우수 장학 도 같이 본다.
대표 검색 예시:
site:kosaf.go.kr 장학금 {키워드}site:{학교도메인} 장학 공고site:*.ac.kr 장학 공고 {학교명} {전공}site:*.or.kr 장학생 선발 {키워드}site:*.go.kr 장학금 공고 {지역명}
2-1. 학교/학과 완전 탐색 모드
사용자가 특정 학교를 주면 아래 순서를 빠뜨리지 않는다.
1. 학교 대표 도메인 확인 2. 학생지원처 / 장학팀 / 학사공지 게시판 확인 3. 단과대학 공지 확인 4. 학과 / 전공 / 대학원 과정 홈페이지 공지 확인 5. 첨부 PDF/HWP가 있으면 같이 열어 조건을 확인 6. 교내 장학과 외부 추천 장학을 분리해서 정리
학교 완전 탐색 체크리스트는 references/school-discovery.md 를 본다.
학교별 search plan을 만들 때는:
python3 scripts/university_search_plan.py \
--school-name "부산대학교" \
--department "컴퓨터공학과" \
--year 2026전국 대학 sweep query를 만들 때는:
python3 scripts/university_search_plan.py --nationwide --year 20263. 각 후보를 정규화한다
후보마다 최소한 아래 필드를 채운다.
{
"name": "장학금명",
"organization": {
"name": "운영기관명",
"type": "foundation"
},
"source_url": "https://official.example.com/notice/123",
"apply_url": "https://official.example.com/apply",
"amount": {
"text": "학기당 250만 원",
"per_semester_krw": 2500000,
"category": "living"
},
"deadline": {
"start": "2026-04-01",
"end": "2026-04-20",
"status": "open"
},
"eligibility": {
"student_levels": ["undergraduate"],
"school_names": ["서울대학교"],
"school_kinds": ["university"],
"majors": ["컴퓨터공학", "소프트웨어"],
"grade_years": [2, 3, 4],
"gpa_min": 3.0,
"income_band_min": 0,
"income_band_max": 8,
"notes": ["직전학기 12학점 이상"]
},
"verified_at": "2026-04-14",
"source_kind": "official"
}4. helper로 필터링하거나 지원 가능 여부를 본다
여러 장학금 후보를 JSON으로 정리한 뒤:
python3 scripts/scholarship_filter.py filter \
--input scholarships.json \
--org-type foundation \
--student-level undergraduate \
--income-band 5 \
--min-amount 2000000지원 가능 여부 판정:
python3 scripts/scholarship_filter.py eligibility \
--input scholarships.json \
--school-name "서울대학교" \
--student-level undergraduate \
--grade-year 2 \
--gpa 3.5 \
--income-band 5KST 기준 현재 날짜로 열린 공고만 readable 하게 보기:
python3 scripts/scholarship_filter.py report \
--input scholarships.json \
--today 2026-04-14 \
--only-open-now \
--school-name "서울대학교"마감 임박 공고만 보기:
python3 scripts/scholarship_filter.py report \
--input scholarships.json \
--today 2026-04-14 \
--deadline-within-days 75. 사용자에게는 compact하게 보여준다
- 상위 매칭 장학금부터 정리
- 장학금명 / 기관 / 금액 / 신청기간 / 핵심 조건 / 링크
- 필터 불일치 이유가 있으면 한 줄로 설명
- "지원 가능", "조건 일부 미확인", "현재 조건으로는 불일치"를 짧게 표시
기본 출력 form은 아래 순서를 따른다.
1. 요약 블록: 총 후보 수 / 열린 공고 수 / 곧 마감 수 2. 지금 지원 가능 3. 곧 열림 4. 조건은 맞지만 마감됨
각 항목은 이 형식으로 정리한다.
- 장학금명
- 기관명 / 기관 유형
- 금액
- 신청기간 + KST 기준 현재 날짜 상태 (
open,upcoming,closed,D-3) - 학교/학과/학년/성적/지원구간 핵심 조건
- 공식 공고 링크
- 신청 링크
더 자세한 form 규칙은 references/report-format.md 를 본다.
Response policy
- 공식 공고 링크와 신청 링크를 반드시 남긴다.
- 금액이 숫자로 안 보이면 원문 텍스트를 그대로 남기고, 추정 금액은 임의로 만들지 않는다.
- 블로그/카페/홍보글만 발견되면 공식 공고를 다시 찾고, 못 찾으면 "미검증" 으로 표시한다.
- 장학금 마감일은 반드시 절대 날짜로 적는다.
- 사용자가 "내 조건으로 걸러줘" 라고 하면 금액, 학교/재단 여부, 학자금 지원구간, 학부/대학원 여부를 우선 필터로 사용한다.
- 학자금 지원구간은 한국장학재단 표기를 기준으로
0~10정수 또는 범위로 정규화한다.
Keep the answer compact
- 총 후보 수
- 필터 후 남은 후보 수
- 상위 5~10개만 표 또는 리스트
- 각 항목마다 공식 링크 1개 이상
- 필요 시 "추가로 더 좁힐 수 있는 필터" 2~3개 제안
Ready-to-paste prompts
1. 전체 탐색
장학금 검색 및 조회 스킬을 사용해서 지금 신청 가능하거나 곧 열리는 한국 장학금 공고를 찾아줘. 한국장학재단, 전국 대학교, 재단, 기업, 공공기관 공식 공고만 포함하고, KST 기준 현재 날짜로 열린 공고와 곧 열릴 공고를 나눠서 보여줘. 각 항목마다 장학금명, 기관명, 기관 유형, 지원 금액, 신청 기간, 핵심 자격, 학자금 지원구간 조건, 공식 공고 링크, 신청 링크를 가독성 좋은 섹션형 form으로 정리해줘.2. 사용자 조건 기반 필터링
장학금 검색 및 조회 스킬을 사용해서 내 조건에 맞는 장학금만 찾아줘.
조건:
- 학교: 서울대학교
- 학생 구분: 학부생
- 학년: 2학년
- 전공: 컴퓨터공학
- 학자금 지원구간: 5구간
- 최소 금액: 200만원
- 기관 유형: 재단
공식 공고만 포함하고, KST 기준 현재 날짜로 마감 여부도 고려해서 지원 가능 여부를 가능/불확실/불가로 표시해줘.3. 학교 장학 vs 재단 장학 비교
장학금 검색 및 조회 스킬을 사용해서 연세대학교 학부생이 지원할 수 있는 교내 장학금과 민간재단 장학금을 나눠서 보여줘. 학교 본부 장학공지, 단과대, 학과 홈페이지 장학 공지를 모두 확인하고, 금액, 신청 기간, 소득구간 조건, 성적 조건, 공식 링크를 같이 정리하고 어느 쪽이 내 조건에 더 맞는지 짧게 비교해줘.4. 지원 가능 여부만 빠르게 보기
장학금 검색 및 조회 스킬을 사용해서 아래 프로필로 지원 가능성이 있는 장학금만 골라줘.
- 학교: 고려대학교
- 학생 구분: 대학원생
- 전공: 전산학
- GPA: 3.8/4.5
- 학자금 지원구간: 4구간
- 원하는 유형: 생활비 장학금
각 항목마다 왜 맞는지 또는 어떤 조건이 불확실한지 한 줄씩 붙여줘.Done when
- 공식 공고 중심으로 후보를 모았다.
- 금액, 자격, 지원구간, 신청기간, 링크를 정리했다.
- 사용자가 준 조건으로 필터링했다.
- 지원 가능 여부를 빠르게 판단했다.
- 비공식 출처는 공식 페이지로 검증했거나 제외했다.
Notes
- 한국장학재단 공식 장학·지원구간 표면은
references/source-patterns.md참고 - 학교별 장학 공지는 HTML 구조가 제각각이므로, 공고 제목/본문/첨부를 같이 읽어야 한다.
- 장학금 "조건" 과 "우대사항" 을 섞지 않는다.
- "등록금 전액" 같은 비정액 장학금은
amount.text를 유지하고 수치가 없으면*_krw는 비워둔다.
Report Format
최종 답변은 changelog처럼 나열하지 말고, 아래 형식을 기본으로 한다.
1. Header summary
- 검색 기준일
- 검색 범위
- 총 후보 수
- 지금 지원 가능 수
- 곧 열릴 공고 수
- 마감된 공고 수
2. Grouping
1. 지금 지원 가능 2. 곧 열림 3. 조건은 맞지만 마감됨 4. 검증 부족 / 미확인
3. Entry format
각 항목은 아래 순서를 유지한다.
- 장학금명
- 기관명 / 기관 유형
- 금액
- 신청기간
- 상태 (
open,upcoming,closed,D-3) - 핵심 자격
- 지원 가능 여부 (
가능,불확실,불가) - 공식 공고 링크
- 신청 링크
4. Readability rules
- 한 항목에 문장을 너무 길게 쓰지 않는다.
- 핵심 조건은
/로 짧게 끊는다. - 금액이 불명확하면
금액 미공개라고 적고 공고 원문 링크를 남긴다. - 확실하지 않은 정보는
미확인으로 표시한다.
School Discovery Checklist
특정 학교를 주면 아래 표면을 순서대로 확인한다.
이 절차는 서울대 전용이 아니라 전국 모든 *.ac.kr 대학에 공통 적용한다.
1. 학교 본부
- 학생지원처
- 장학팀 / 장학복지팀
- 학사공지
- 일반공지
검색 예시:
site:{school-domain} 장학 공지site:{school-domain} 학생지원처 장학site:{school-domain} 학사공지 장학
2. 단과대학
학교명 + 단과대 이름으로 다시 좁힌다.
- 공과대학
- 인문대학
- 사회과학대학
- 경영대학
- 대학원
검색 예시:
site:{school-domain} 장학 공과대학site:{school-domain} 장학생 모집 대학원
3. 학과 / 전공 / 협동과정
학과 홈페이지가 별도 서브도메인 또는 하위 경로일 수 있다.
- 학과 공지사항
- 학부 공지
- 대학원 공지
- 외부 장학 추천 공지
검색 예시:
site:{school-domain} "{department-name}" 장학site:{school-domain} "{department-name}" 공지 장학생site:{school-domain} "{department-name}" 외부 장학
4. 첨부파일
- PDF, HWP, DOCX 첨부가 있으면 열어 본다.
- 자격/성적/지원구간/금액이 첨부에만 있는 경우가 많다.
5. 정리 원칙
교내 장학학과 장학외부 추천 장학
이 세 묶음으로 나눠 보여주면 가독성이 좋아진다.
Search Clues
장학금 공고는 학교/기관마다 제목이 제각각이라서 장학금 한 단어만으로는 누락이 많다.
1. Core keywords
- 장학
- 장학금
- 장학생
- 장학생 선발
- 장학생 모집
- 장학 안내
- 장학 공고
- 장학 신청
2. University-specific clues
- 교내 장학
- 학과 장학
- 외부 장학
- 외부 장학 추천
- 추천장학
- 등록금 감면
- 등록금 면제
- 생활비 지원
- 학업장려비
- 근로장학
- 성적우수 장학
- 신입생 장학
- 계속장학생
- 복지 장학
3. Organization-specific clues
- 재단: 장학생 선발, 장학생 모집, 지원사업, 교육지원
- 기업: 인재육성, 장학생 모집, CSR 장학, 사회공헌 장학
- 지자체: 대학생 장학금, 지역인재 장학금, 주민등록 요건, 거주요건
- 한국장학재단: 국가장학금, 푸른등대, 대통령과학장학금, 국가우수장학금, 국가근로장학금
4. Attachment clues
본문보다 첨부파일에 핵심 조건이 있는 경우가 많다.
- 모집요강
- 선발요강
- 신청서식
- 제출서류
- 추천서
- 개인정보동의서
5. Query expansion examples
site:*.ac.kr 외부 장학 추천site:*.ac.kr 등록금 감면site:*.ac.kr 생활비 지원 장학site:*.ac.kr 장학생 선발site:*.or.kr 장학생 모집site:*.go.kr 지역인재 장학금
Scholarship Source Patterns
한국 장학금 검색은 항상 공식 공고 우선 이다.
1. Source priority
1. 한국장학재단 (kosaf.go.kr) 2. 대학 공식 장학/학사/학생지원 공지 (*.ac.kr) 3. 지자체/공공기관 (*.go.kr, *.or.kr) 4. 재단/기업 공식 공고 5. 비공식 모음글은 lead source 로만 사용
2. Search query templates
다음 쿼리를 현재 날짜 기준으로 조합한다. 추가 키워드 단서는 search-clues.md 를 같이 본다.
site:kosaf.go.kr 장학금 {키워드}site:kosaf.go.kr 푸른등대 {키워드}site:*.ac.kr 장학 공고 {학교명}site:*.ac.kr 장학생 모집 {학교명} {전공}site:*.or.kr 장학생 선발 {키워드}site:*.go.kr 장학금 공고 {지역명}site:{재단도메인} 장학생 모집
특정 학교 완전 탐색이 필요하면 school-discovery.md 절차를 같이 따른다.
3. What to extract from each notice
- 장학금명
- 운영기관명
- 기관 유형
- 공고일 / 신청 시작일 / 마감일
- 지원 금액 / 등록금형 / 생활비형 / 혼합형
- 지원 대상: 학교, 학부/대학원, 학년, 전공, 지역
- 성적 조건
- 학자금 지원구간 조건
- 제출서류
- 신청 방식
- 공식 공고 링크
- 공식 신청 링크
4. Organization type normalization
school: 대학/대학원/고교 교내 장학foundation: 민간재단, 복지재단, 교육재단government: 중앙정부/공공기관local-government: 광역·기초지자체company: 기업/기업재단/CSRother: 위 분류가 애매한 경우
5. Eligibility normalization
가능하면 아래 필드로 구조화한다.
student_levels:highschool,undergraduate,graduate,allschool_kinds:highschool,college,university,graduate-schoolgrade_years: 숫자 배열majors: 문자열 배열gpa_min: 4.5 또는 100점 기준이 섞여 있으면 원문도 같이 남긴다income_band_min,income_band_maxnotes: 구조화가 애매한 자격 조건
6. Verification rule
- 비공식 요약 페이지는 링크 탐색용으로만 본다.
- 최종 결과에는 공식 공고 링크를 반드시 포함한다.
- 공고 날짜가 오래됐으면 최신 회차/학기 공고를 다시 찾는다.
#!/usr/bin/env python3
"""Filter normalized Korean scholarship records and estimate eligibility."""
from __future__ import annotations
import argparse
import json
import re
import sys
from copy import deepcopy
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
AMOUNT_KEYS = (
"annual_krw",
"per_semester_krw",
"one_time_krw",
"monthly_krw",
"max_krw",
"min_krw",
"amount_krw",
)
CANONICAL_DEADLINE_STATUSES = {"open", "upcoming", "closed", "unknown"}
KST = timezone(timedelta(hours=9), name="Asia/Seoul")
KST_LABEL = "Asia/Seoul (KST)"
def read_payload(path: str | None) -> Any:
if path:
return json.loads(Path(path).read_text(encoding="utf8"))
raw = sys.stdin.read().strip()
if not raw:
raise SystemExit("expected JSON input from --input or stdin")
return json.loads(raw)
def ensure_records(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
if isinstance(payload.get("items"), list):
return [item for item in payload["items"] if isinstance(item, dict)]
return [payload]
raise SystemExit("input JSON must be an object or an array of objects")
def as_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def normalize_text(value: Any) -> str:
return str(value or "").strip().lower()
def contains_text(values: list[Any], needle: str) -> bool:
target = normalize_text(needle)
return any(target in normalize_text(value) for value in values)
def parse_int(value: Any) -> int | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
try:
return int(str(value).replace(",", "").strip())
except ValueError:
return None
def parse_float(value: Any) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
try:
return float(str(value).replace(",", "").strip())
except ValueError:
return None
def parse_date(value: Any) -> date | None:
if not value:
return None
text = str(value).strip()
for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y%m%d"):
try:
if fmt == "%Y%m%d" and len(text) != 8:
continue
if fmt == "%Y-%m-%d":
parts = text.split("-")
if len(parts) == 3:
return date(int(parts[0]), int(parts[1]), int(parts[2]))
if fmt == "%Y.%m.%d":
parts = text.split(".")
if len(parts) == 3:
return date(int(parts[0]), int(parts[1]), int(parts[2]))
if fmt == "%Y/%m/%d":
parts = text.split("/")
if len(parts) == 3:
return date(int(parts[0]), int(parts[1]), int(parts[2]))
if fmt == "%Y%m%d":
return date(int(text[0:4]), int(text[4:6]), int(text[6:8]))
except ValueError:
continue
return None
def current_kst_date(now: datetime | None = None) -> date:
if now is None:
return datetime.now(KST).date()
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return now.astimezone(KST).date()
def resolve_today(value: str | None) -> date:
parsed = parse_date(value)
return parsed or current_kst_date()
def extract_org_type(record: dict[str, Any]) -> str:
organization = record.get("organization") or {}
return normalize_text(record.get("org_type") or organization.get("type") or "")
def extract_org_name(record: dict[str, Any]) -> str:
organization = record.get("organization") or {}
return str(record.get("organization_name") or organization.get("name") or "").strip()
def parse_amount_from_text(text: str) -> list[int]:
candidates: list[int] = []
for raw, unit in re.findall(r"([0-9][0-9,]*(?:\.[0-9]+)?)\s*(만원|원)", text):
try:
value = float(raw.replace(",", ""))
except ValueError:
continue
multiplier = 10000 if unit == "만원" else 1
candidates.append(int(value * multiplier))
return candidates
def normalize_deadline_status(value: Any) -> str | None:
status = normalize_text(value)
if status in CANONICAL_DEADLINE_STATUSES:
return status
return None
def extract_amount_value(record: dict[str, Any]) -> int | None:
amount = record.get("amount")
candidates: list[int] = []
if isinstance(amount, dict):
for key in AMOUNT_KEYS:
parsed = parse_int(amount.get(key))
if parsed is not None:
candidates.append(parsed)
text = str(amount.get("text") or "")
candidates.extend(parse_amount_from_text(text))
else:
parsed = parse_int(record.get("amount_krw"))
if parsed is not None:
candidates.append(parsed)
if isinstance(amount, str):
candidates.extend(parse_amount_from_text(amount))
text = str(record.get("amount_text") or "")
candidates.extend(parse_amount_from_text(text))
return max(candidates) if candidates else None
def infer_deadline_status(record: dict[str, Any], today: date | None = None) -> str:
today = today or current_kst_date()
deadline = record.get("deadline") or {}
start_at = parse_date(deadline.get("start"))
end_at = parse_date(deadline.get("end"))
if end_at and end_at < today:
return "closed"
if start_at and start_at > today:
return "upcoming"
if end_at and end_at >= today:
return "open"
cached_status = normalize_deadline_status(deadline.get("status") or record.get("deadline_status"))
return cached_status or "unknown"
def deadline_context(record: dict[str, Any], today: date | None = None) -> dict[str, Any]:
today = today or current_kst_date()
deadline = record.get("deadline") or {}
start_at = parse_date(deadline.get("start"))
end_at = parse_date(deadline.get("end"))
status = infer_deadline_status(record, today)
days_until_start = (start_at - today).days if start_at else None
days_until_end = (end_at - today).days if end_at else None
return {
"today": today.isoformat(),
"start": start_at.isoformat() if start_at else None,
"end": end_at.isoformat() if end_at else None,
"status": status,
"days_until_start": days_until_start,
"days_until_end": days_until_end,
}
def get_eligibility(record: dict[str, Any]) -> dict[str, Any]:
eligibility = record.get("eligibility")
if isinstance(eligibility, dict):
return eligibility
return {}
def extract_department_names(record: dict[str, Any]) -> list[Any]:
eligibility = get_eligibility(record)
values = as_list(eligibility.get("department_names"))
if values:
return values
return as_list(eligibility.get("majors"))
def school_match_values(record: dict[str, Any]) -> list[Any]:
eligibility = get_eligibility(record)
values: list[Any] = []
values.extend(as_list(eligibility.get("school_names")))
values.append(extract_org_name(record))
return [value for value in values if value]
def department_match_values(record: dict[str, Any]) -> list[Any]:
values: list[Any] = []
values.extend(extract_department_names(record))
values.append(extract_org_name(record))
values.append(record.get("source_url"))
values.append(record.get("summary"))
return [value for value in values if value]
def match_query(record: dict[str, Any], query: str | None) -> bool:
if not query:
return True
haystacks = [
record.get("name"),
extract_org_name(record),
record.get("summary"),
record.get("notes"),
record.get("source_url"),
record.get("apply_url"),
]
eligibility = get_eligibility(record)
haystacks.extend(as_list(eligibility.get("majors")))
haystacks.extend(extract_department_names(record))
haystacks.extend(as_list(eligibility.get("notes")))
return contains_text(haystacks, query)
def match_filter(record: dict[str, Any], args: argparse.Namespace) -> tuple[bool, list[str]]:
reasons: list[str] = []
eligibility = get_eligibility(record)
today = resolve_today(getattr(args, "today", None))
if not match_query(record, args.q):
return False, reasons
if args.org_type:
org_type = extract_org_type(record)
if org_type != normalize_text(args.org_type):
return False, reasons
reasons.append(f"org_type={org_type}")
context = deadline_context(record, today)
if args.deadline_status:
deadline_status = context["status"]
if deadline_status != normalize_text(args.deadline_status):
return False, reasons
reasons.append(f"deadline_status={deadline_status}")
if getattr(args, "only_open_now", False):
if context["status"] != "open":
return False, reasons
reasons.append("only_open_now")
upcoming_within_days = getattr(args, "upcoming_within_days", None)
if upcoming_within_days is not None:
days_until_start = context["days_until_start"]
if context["status"] != "upcoming" or days_until_start is None or days_until_start < 0 or days_until_start > upcoming_within_days:
return False, reasons
reasons.append(f"upcoming_within_days<={upcoming_within_days}")
deadline_within_days = getattr(args, "deadline_within_days", None)
if deadline_within_days is not None:
days_until_end = context["days_until_end"]
if days_until_end is None or days_until_end < 0 or days_until_end > deadline_within_days:
return False, reasons
reasons.append(f"deadline_within_days<={deadline_within_days}")
if args.school_kind:
school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
if school_kinds and normalize_text(args.school_kind) not in school_kinds:
return False, reasons
if school_kinds:
reasons.append(f"school_kind={args.school_kind}")
else:
reasons.append("school_kind=?")
if args.school_name:
school_names = school_match_values(record)
if school_names and not contains_text(school_names, args.school_name):
return False, reasons
if school_names:
reasons.append(f"school_name~={args.school_name}")
else:
reasons.append("school_name=?")
if args.student_level:
student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
if student_levels and normalize_text(args.student_level) not in student_levels:
return False, reasons
if student_levels:
reasons.append(f"student_level={args.student_level}")
else:
reasons.append("student_level=?")
if args.major:
majors = as_list(eligibility.get("majors"))
if majors and not contains_text(majors, args.major):
return False, reasons
if majors:
reasons.append(f"major~={args.major}")
else:
reasons.append("major=?")
if getattr(args, "department_name", None):
departments = department_match_values(record)
if departments and not contains_text(departments, args.department_name):
return False, reasons
if departments:
reasons.append(f"department_name~={args.department_name}")
else:
reasons.append("department_name=?")
if args.grade_year is not None:
grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
grade_years.discard(None)
if grade_years and args.grade_year not in grade_years:
return False, reasons
if grade_years:
reasons.append(f"grade_year={args.grade_year}")
else:
reasons.append("grade_year=?")
if args.gpa is not None:
gpa_min = parse_float(eligibility.get("gpa_min"))
if gpa_min is not None and args.gpa < gpa_min:
return False, reasons
if gpa_min is not None:
reasons.append(f"gpa>={gpa_min}")
else:
reasons.append("gpa=?")
if args.income_band is not None:
income_band_min = parse_int(eligibility.get("income_band_min"))
income_band_max = parse_int(eligibility.get("income_band_max"))
income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
income_bands.discard(None)
if income_bands and args.income_band not in income_bands:
return False, reasons
if income_band_min is not None and args.income_band < income_band_min:
return False, reasons
if income_band_max is not None and args.income_band > income_band_max:
return False, reasons
if income_bands or income_band_min is not None or income_band_max is not None:
reasons.append(f"income_band={args.income_band}")
else:
reasons.append("income_band=?")
amount_value = extract_amount_value(record)
if args.min_amount is not None:
if amount_value is not None and amount_value < args.min_amount:
return False, reasons
if amount_value is None:
if getattr(args, "strict_amount", False):
return False, reasons
reasons.append(f"amount>={args.min_amount}?")
else:
reasons.append(f"amount>={args.min_amount}")
if args.max_amount is not None:
if amount_value is not None and amount_value > args.max_amount:
return False, reasons
if amount_value is None:
if getattr(args, "strict_amount", False):
return False, reasons
reasons.append(f"amount<={args.max_amount}?")
else:
reasons.append(f"amount<={args.max_amount}")
return True, reasons
def command_filter(args: argparse.Namespace) -> int:
records = ensure_records(read_payload(args.input))
items: list[dict[str, Any]] = []
today = resolve_today(getattr(args, "today", None))
for record in records:
matched, reasons = match_filter(record, args)
if not matched:
continue
entry = deepcopy(record)
entry["_match"] = {
"amount_krw": extract_amount_value(record),
"deadline": deadline_context(record, today),
"deadline_status": infer_deadline_status(record, today),
"reasons": reasons,
}
items.append(entry)
payload = {
"filters": {
"q": args.q,
"org_type": args.org_type,
"school_kind": args.school_kind,
"school_name": args.school_name,
"student_level": args.student_level,
"major": args.major,
"department_name": args.department_name,
"grade_year": args.grade_year,
"gpa": args.gpa,
"income_band": args.income_band,
"min_amount": args.min_amount,
"max_amount": args.max_amount,
"deadline_status": args.deadline_status,
"today": args.today,
"only_open_now": args.only_open_now,
"upcoming_within_days": args.upcoming_within_days,
"deadline_within_days": args.deadline_within_days,
},
"total": len(items),
"items": items,
}
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
def eligibility_result(record: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
failed: list[str] = []
passed: list[str] = []
unknown: list[str] = []
eligibility = get_eligibility(record)
if args.org_type:
org_type = extract_org_type(record)
if not org_type:
unknown.append("org_type=?")
elif org_type == normalize_text(args.org_type):
passed.append(f"org_type={org_type}")
else:
failed.append(f"org_type mismatch: {org_type or 'unknown'}")
if args.school_kind:
school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
if school_kinds and normalize_text(args.school_kind) not in school_kinds:
failed.append(f"school_kind mismatch: {school_kinds}")
elif school_kinds:
passed.append(f"school_kind={args.school_kind}")
else:
unknown.append("school_kind=?")
if args.school_name:
school_names = school_match_values(record)
if school_names and not contains_text(school_names, args.school_name):
failed.append(f"school_name mismatch: {school_names}")
elif school_names:
passed.append(f"school_name~={args.school_name}")
else:
unknown.append("school_name=?")
if args.student_level:
student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
if student_levels and normalize_text(args.student_level) not in student_levels:
failed.append(f"student_level mismatch: {student_levels}")
elif student_levels:
passed.append(f"student_level={args.student_level}")
else:
unknown.append("student_level=?")
if args.major:
majors = as_list(eligibility.get("majors"))
if majors and not contains_text(majors, args.major):
failed.append(f"major mismatch: {majors}")
elif majors:
passed.append(f"major~={args.major}")
else:
unknown.append("major=?")
if getattr(args, "department_name", None):
departments = department_match_values(record)
if departments and not contains_text(departments, args.department_name):
failed.append(f"department_name mismatch: {departments}")
elif departments:
passed.append(f"department_name~={args.department_name}")
else:
unknown.append("department_name=?")
if args.grade_year is not None:
grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
grade_years.discard(None)
if grade_years and args.grade_year not in grade_years:
failed.append(f"grade_year mismatch: {sorted(grade_years)}")
elif grade_years:
passed.append(f"grade_year={args.grade_year}")
else:
unknown.append("grade_year=?")
if args.gpa is not None:
gpa_min = parse_float(eligibility.get("gpa_min"))
if gpa_min is not None and args.gpa < gpa_min:
failed.append(f"gpa below minimum: {gpa_min}")
elif gpa_min is not None:
passed.append(f"gpa>={gpa_min}")
else:
unknown.append("gpa=?")
if args.income_band is not None:
income_band_min = parse_int(eligibility.get("income_band_min"))
income_band_max = parse_int(eligibility.get("income_band_max"))
income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
income_bands.discard(None)
if income_bands and args.income_band not in income_bands:
failed.append(f"income_band mismatch: {sorted(income_bands)}")
elif income_band_min is not None and args.income_band < income_band_min:
failed.append(f"income_band below minimum: {income_band_min}")
elif income_band_max is not None and args.income_band > income_band_max:
failed.append(f"income_band above maximum: {income_band_max}")
elif income_bands or income_band_min is not None or income_band_max is not None:
passed.append(f"income_band={args.income_band}")
else:
unknown.append("income_band=?")
if failed:
status = "not_eligible"
elif unknown:
status = "indeterminate"
elif passed:
status = "eligible"
else:
status = "indeterminate"
return {
"name": record.get("name"),
"organization_name": extract_org_name(record),
"organization_type": extract_org_type(record),
"source_url": record.get("source_url"),
"apply_url": record.get("apply_url"),
"status": status,
"passed": passed,
"failed": failed,
"unknown": unknown,
}
def command_eligibility(args: argparse.Namespace) -> int:
records = ensure_records(read_payload(args.input))
results = [eligibility_result(record, args) for record in records]
payload = {
"profile": {
"org_type": args.org_type,
"school_kind": args.school_kind,
"school_name": args.school_name,
"student_level": args.student_level,
"major": args.major,
"department_name": args.department_name,
"grade_year": args.grade_year,
"gpa": args.gpa,
"income_band": args.income_band,
},
"total": len(results),
"results": results,
}
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
def add_common_filters(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--input", help="JSON file path. If omitted, reads from stdin.")
parser.add_argument("--q", help="Keyword filter across name, organization, notes, and majors.")
parser.add_argument("--org-type", help="school|foundation|government|company|local-government|other")
parser.add_argument("--school-kind", help="highschool|college|university|graduate-school")
parser.add_argument("--school-name", help="Partial match against supported school names.")
parser.add_argument("--student-level", help="highschool|undergraduate|graduate|all")
parser.add_argument("--major", help="Partial match against target major names.")
parser.add_argument("--department-name", help="Partial match against department/program names.")
parser.add_argument("--grade-year", type=int, help="Student year, e.g. 1, 2, 3, 4.")
parser.add_argument("--gpa", type=float, help="Current GPA for eligibility check.")
parser.add_argument("--income-band", type=int, help="학자금 지원구간 integer, usually 0~10.")
parser.add_argument(
"--today",
help=f"Override current date for deadline filtering/reporting. When omitted or unparsable, defaults to current KST date ({KST_LABEL}), e.g. 2026-04-14.",
)
def format_krw(value: int | None) -> str:
if value is None:
return "미공개"
if value >= 100000000:
return f"{value / 100000000:.1f}억 원"
if value >= 10000:
return f"{value / 10000:.0f}만 원"
return f"{value:,}원"
def compact_eligibility_text(record: dict[str, Any]) -> str:
eligibility = get_eligibility(record)
chunks: list[str] = []
school_names = as_list(eligibility.get("school_names"))
if school_names:
chunks.append("학교 " + ", ".join(map(str, school_names[:3])))
departments = extract_department_names(record)
if departments:
chunks.append("학과/전공 " + ", ".join(map(str, departments[:3])))
student_levels = as_list(eligibility.get("student_levels"))
if student_levels:
chunks.append("학생구분 " + ", ".join(map(str, student_levels)))
grade_years = [str(value) for value in as_list(eligibility.get("grade_years")) if value is not None]
if grade_years:
chunks.append("학년 " + ", ".join(grade_years))
gpa_min = eligibility.get("gpa_min")
if gpa_min not in (None, ""):
chunks.append(f"GPA {gpa_min} 이상")
income_band_min = eligibility.get("income_band_min")
income_band_max = eligibility.get("income_band_max")
if income_band_min not in (None, "") or income_band_max not in (None, ""):
if income_band_min not in (None, "") and income_band_max not in (None, ""):
chunks.append(f"지원구간 {income_band_min}~{income_band_max}")
elif income_band_max not in (None, ""):
chunks.append(f"지원구간 {income_band_max} 이하")
else:
chunks.append(f"지원구간 {income_band_min} 이상")
notes = as_list(eligibility.get("notes"))
if notes:
chunks.append(str(notes[0]))
return " / ".join(chunks) if chunks else "세부 자격은 공고 원문 확인"
def report_entry(record: dict[str, Any], today: date) -> str:
match_meta = record.get("_match") if isinstance(record.get("_match"), dict) else {}
context = match_meta.get("deadline") if isinstance(match_meta.get("deadline"), dict) else deadline_context(record, today)
amount_text = None
if isinstance(record.get("amount"), dict):
amount_text = record["amount"].get("text")
if not amount_text:
amount_text = format_krw(extract_amount_value(record))
status_label = context["status"]
if context["days_until_end"] is not None and context["days_until_end"] >= 0:
status_label = f"{status_label} / D-{context['days_until_end']}"
elif context["days_until_start"] is not None and context["days_until_start"] >= 0 and context["status"] == "upcoming":
status_label = f"{status_label} / starts in {context['days_until_start']}d"
organization_name = extract_org_name(record) or "기관명 미상"
organization_type = extract_org_type(record) or "unknown"
period = f"{context['start'] or '?'} ~ {context['end'] or '?'}"
source_url = record.get("source_url") or "-"
apply_url = record.get("apply_url") or "-"
reasons = match_meta.get("reasons") if isinstance(match_meta.get("reasons"), list) else []
lines = [
f"### {record.get('name') or '장학금명 미상'}",
f"- 기관: {organization_name} ({organization_type})",
f"- 금액: {amount_text}",
f"- 기간: {period}",
f"- 상태: {status_label}",
f"- 핵심 조건: {compact_eligibility_text(record)}",
]
if reasons:
lines.append(f"- 필터 판정: {', '.join(reasons)}")
lines.extend(
[
f"- 공식 공고: {source_url}",
f"- 신청 링크: {apply_url}",
]
)
return "\n".join(lines)
def command_report(args: argparse.Namespace) -> int:
today = resolve_today(args.today)
records = ensure_records(read_payload(args.input))
matched: list[dict[str, Any]] = []
for record in records:
ok, reasons = match_filter(record, args)
if ok:
entry = deepcopy(record)
entry["_match"] = {
"amount_krw": extract_amount_value(record),
"deadline": deadline_context(record, today),
"deadline_status": infer_deadline_status(record, today),
"reasons": reasons,
}
matched.append(entry)
groups = {"open": [], "upcoming": [], "closed": [], "unknown": []}
for record in matched:
status = normalize_deadline_status((record.get("_match") or {}).get("deadline_status")) or "unknown"
groups[status].append(record)
lines = [
"# 장학금 검색 및 조회 리포트",
f"- 기준일: {today.isoformat()} ({KST_LABEL})",
f"- 총 후보 수: {len(matched)}",
f"- 지금 지원 가능: {len(groups['open'])}",
f"- 곧 열림: {len(groups['upcoming'])}",
f"- 마감됨: {len(groups['closed'])}",
f"- 상태 미확인: {len(groups['unknown'])}",
"",
]
sections = [
("지금 지원 가능", "open"),
("곧 열림", "upcoming"),
("마감됨", "closed"),
("상태 미확인", "unknown"),
]
for title, key in sections:
if not groups[key]:
continue
lines.append(f"## {title}")
lines.append("")
for record in groups[key]:
lines.append(report_entry(record, today))
lines.append("")
sys.stdout.write("\n".join(lines).rstrip() + "\n")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Filter normalized Korean scholarship records and estimate eligibility from structured JSON.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
filter_parser = subparsers.add_parser("filter", help="Filter scholarship records by profile and preference.")
add_common_filters(filter_parser)
filter_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
filter_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
filter_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
filter_parser.add_argument("--deadline-status", help="open|upcoming|closed")
filter_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
filter_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
filter_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
filter_parser.set_defaults(func=command_filter)
eligibility_parser = subparsers.add_parser(
"eligibility",
help="Return eligible/not_eligible verdicts for each scholarship record.",
)
add_common_filters(eligibility_parser)
eligibility_parser.set_defaults(func=command_eligibility)
report_parser = subparsers.add_parser(
"report",
help="Render a readable markdown report grouped by open/upcoming/closed based on the current date.",
)
add_common_filters(report_parser)
report_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
report_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
report_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
report_parser.add_argument("--deadline-status", help="open|upcoming|closed")
report_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
report_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
report_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
report_parser.set_defaults(func=command_report)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())
import importlib.util
import json
import subprocess
import sys
from argparse import Namespace
from datetime import date, datetime, timezone
from pathlib import Path
import unittest
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
FILTER_PATH = SCRIPT_DIR / "scholarship_filter.py"
PLANNER_PATH = SCRIPT_DIR / "university_search_plan.py"
def load_module(module_name: str, path: Path):
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
scholarship_filter = load_module("scholarship_filter", FILTER_PATH)
university_search_plan = load_module("university_search_plan", PLANNER_PATH)
class DeadlineStatusTest(unittest.TestCase):
def test_current_kst_date_uses_korea_calendar_day(self):
now = datetime(2026, 4, 15, 16, 30, tzinfo=timezone.utc)
today = scholarship_filter.current_kst_date(now)
self.assertEqual(today, date(2026, 4, 16))
def test_resolve_today_falls_back_to_kst_when_missing_or_invalid(self):
with mock.patch.object(scholarship_filter, "current_kst_date", return_value=date(2026, 4, 16)):
self.assertEqual(scholarship_filter.resolve_today(None), date(2026, 4, 16))
self.assertEqual(scholarship_filter.resolve_today("not-a-date"), date(2026, 4, 16))
def test_infer_deadline_status_overrides_stale_cached_status_with_dates(self):
record = {
"deadline": {
"status": "open",
"start": "2026-04-01",
"end": "2026-04-14",
}
}
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
self.assertEqual(status, "closed")
def test_infer_deadline_status_returns_unknown_for_noncanonical_cached_value_without_dates(self):
record = {"deadline": {"status": "d-3"}}
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
self.assertEqual(status, "unknown")
def test_infer_deadline_status_treats_end_date_equal_to_today_as_open(self):
record = {"deadline": {"end": "2026-04-15"}}
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
self.assertEqual(status, "open")
def test_report_does_not_crash_on_noncanonical_status_and_counts_unknown(self):
payload = json.dumps([{"name": "x", "deadline": {"status": "d-3"}}], ensure_ascii=False)
result = subprocess.run(
[sys.executable, str(FILTER_PATH), "report", "--today", "2026-04-15"],
input=payload,
text=True,
capture_output=True,
check=True,
)
self.assertIn("- 기준일: 2026-04-15 (Asia/Seoul (KST))", result.stdout)
self.assertIn("- 상태 미확인: 1", result.stdout)
self.assertIn("## 상태 미확인", result.stdout)
class AmountHandlingTest(unittest.TestCase):
def test_extract_amount_value_uses_amount_fields_and_ignores_irrelevant_notes(self):
from_text = scholarship_filter.extract_amount_value({"amount": {"text": "생활비 250만원 지급"}})
ignored_notes = scholarship_filter.extract_amount_value(
{
"amount": {"text": "등록금 전액"},
"notes": ["작년에는 500만원 특별지원"],
}
)
self.assertEqual(from_text, 2500000)
self.assertIsNone(ignored_notes)
def test_match_filter_keeps_text_only_amount_by_default_and_marks_it_unknown(self):
args = Namespace(
q=None,
org_type=None,
school_kind=None,
school_name=None,
student_level=None,
major=None,
department_name=None,
grade_year=None,
gpa=None,
income_band=None,
min_amount=2000000,
max_amount=None,
strict_amount=False,
deadline_status=None,
today="2026-04-15",
only_open_now=False,
upcoming_within_days=None,
deadline_within_days=None,
)
matched, reasons = scholarship_filter.match_filter({"amount": {"text": "등록금 전액"}}, args)
self.assertTrue(matched)
self.assertIn("amount>=2000000?", reasons)
def test_match_filter_can_drop_text_only_amount_in_strict_mode(self):
args = Namespace(
q=None,
org_type=None,
school_kind=None,
school_name=None,
student_level=None,
major=None,
department_name=None,
grade_year=None,
gpa=None,
income_band=None,
min_amount=2000000,
max_amount=None,
strict_amount=True,
deadline_status=None,
today="2026-04-15",
only_open_now=False,
upcoming_within_days=None,
deadline_within_days=None,
)
matched, reasons = scholarship_filter.match_filter({"amount": {"text": "등록금 전액"}}, args)
self.assertFalse(matched)
self.assertEqual(reasons, [])
class SparseFieldPolicyTest(unittest.TestCase):
def test_eligibility_returns_indeterminate_when_profile_fields_are_missing(self):
args = Namespace(
org_type=None,
school_kind="university",
school_name="서울대학교",
student_level="undergraduate",
major=None,
department_name=None,
grade_year=None,
gpa=None,
income_band=5,
)
result = scholarship_filter.eligibility_result({"name": "테스트 장학금"}, args)
self.assertEqual(result["status"], "indeterminate")
self.assertEqual(result["failed"], [])
self.assertEqual(
result["unknown"],
["school_kind=?", "school_name=?", "student_level=?", "income_band=?"],
)
def test_school_name_filter_does_not_match_urls_any_more(self):
args = Namespace(
q=None,
org_type=None,
school_kind=None,
school_name="SNU",
student_level=None,
major=None,
department_name=None,
grade_year=None,
gpa=None,
income_band=None,
min_amount=None,
max_amount=None,
strict_amount=False,
deadline_status=None,
today="2026-04-15",
only_open_now=False,
upcoming_within_days=None,
deadline_within_days=None,
)
record = {
"organization": {"name": "한국장학재단"},
"source_url": "https://www.kosaf.go.kr/snu-notice",
}
matched, reasons = scholarship_filter.match_filter(record, args)
self.assertFalse(matched)
self.assertEqual(reasons, [])
class UniversitySearchPlanTest(unittest.TestCase):
def test_school_domain_suppresses_broad_ac_kr_fallback_queries(self):
payload = university_search_plan.build_school_queries(
school_name="서울대학교",
school_domain="snu.ac.kr",
departments=["컴퓨터공학부"],
colleges=[],
year=2026,
)
queries = payload["search_queries"]
self.assertTrue(any(query.startswith("site:snu.ac.kr ") for query in queries))
self.assertFalse(any('site:*.ac.kr "서울대학교"' in query for query in queries))
if __name__ == "__main__":
unittest.main()
#!/usr/bin/env python3
"""Generate exhaustive scholarship search queries for any Korean university."""
from __future__ import annotations
import argparse
import json
from datetime import date
def build_school_queries(
school_name: str,
school_domain: str | None,
departments: list[str],
colleges: list[str],
year: int,
) -> dict[str, object]:
if school_domain:
domain_targets = [f"site:{school_domain}"]
else:
domain_targets = [f"site:*.ac.kr \"{school_name}\""]
base_suffixes = [
f"{year} 장학 공고",
f"{year} 교내 장학",
f"{year} 외부 장학",
f"{year} 학생지원처 장학",
f"{year} 학사공지 장학",
"장학 공고",
"교내 장학",
"외부 장학 추천",
"학생지원처 장학",
"학사공지 장학",
]
queries: list[str] = []
for target in domain_targets:
for suffix in base_suffixes:
queries.append(f"{target} {suffix}")
for college in colleges:
queries.append(f"{target} \"{college}\" 장학")
queries.append(f"{target} \"{college}\" 외부 장학")
queries.append(f"{target} \"{college}\" 장학생 모집")
for department in departments:
queries.append(f"{target} \"{department}\" 장학")
queries.append(f"{target} \"{department}\" 외부 장학")
queries.append(f"{target} \"{department}\" 공지 장학생")
queries.append(f"{target} \"{department}\" 대학원 장학")
url_hints = [
"/scholarship",
"/student/scholarship",
"/notice",
"/bbs",
"/board",
"/undergraduate/notice",
"/graduate/notice",
"/academics/undergraduate/scholarship",
"/academics/graduate/scholarship",
]
checklist = [
"학교 대표 장학공지",
"학생지원처 / 장학팀",
"학사공지 / 일반공지",
"단과대 공지",
"학과 / 전공 공지",
"대학원 공지",
"첨부 PDF/HWP",
]
return {
"scope": "school",
"school_name": school_name,
"school_domain": school_domain,
"year": year,
"departments": departments,
"colleges": colleges,
"coverage_checklist": checklist,
"search_queries": queries,
"url_hints": url_hints,
}
def build_nationwide_queries(year: int) -> dict[str, object]:
queries = [
f"site:*.ac.kr {year} 장학 공고",
f"site:*.ac.kr {year} 교내 장학",
f"site:*.ac.kr {year} 외부 장학 추천",
f"site:*.ac.kr {year} 학과 장학",
f"site:*.ac.kr {year} 대학원 장학",
"site:*.ac.kr 장학 공고",
"site:*.ac.kr 교내 장학",
"site:*.ac.kr 외부 장학 추천",
"site:*.ac.kr 학과 장학",
"site:*.ac.kr 대학원 장학",
]
return {
"scope": "nationwide-universities",
"year": year,
"search_queries": queries,
"coverage_notes": [
"공개된 *.ac.kr 장학 공지 중심",
"학교 본부 -> 단과대 -> 학과 순서로 수집",
"검색엔진에 노출되지 않은 게시판은 누락 가능",
"첨부 PDF/HWP 확인 필요",
],
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generate exhaustive official scholarship search queries for a Korean university or for nationwide university coverage.",
)
parser.add_argument("--school-name", help="University name, e.g. 서울대학교.")
parser.add_argument("--school-domain", help="Official university domain, e.g. snu.ac.kr.")
parser.add_argument("--department", action="append", default=[], help="Department or program name. Repeatable.")
parser.add_argument("--college", action="append", default=[], help="College/faculty name. Repeatable.")
parser.add_argument("--nationwide", action="store_true", help="Generate search queries for all Korean universities.")
parser.add_argument("--year", type=int, default=date.today().year, help="Target year for notice search.")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.nationwide:
payload = build_nationwide_queries(args.year)
else:
if not args.school_name:
raise SystemExit("--school-name is required unless --nationwide is used")
payload = build_school_queries(
school_name=args.school_name,
school_domain=args.school_domain,
departments=args.department,
colleges=args.college,
year=args.year,
)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Related skills
FAQ
What is korean-scholarship-search?
Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by sch
When should I use korean-scholarship-search?
Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by sch
Is korean-scholarship-search safe to install?
Review the Security Audits panel on this page before installing in production.