Keep Feature/#119 mergeable with latest dev additions

Merged origin/dev into feature/#119 and reconciled the install/test contract around the kordoc-based HWP workflow so the branch keeps the public-restroom and proxy updates from dev without regressing the new HWP docs contract.

Constraint: PR #125 must stay on feature/#119 and remain reviewable without self-merging\nRejected: Reintroduce @ohah/hwpjs install guidance | conflicts with the kordoc-first contract under test\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep docs/install.md and scripts/skill-docs.test.js aligned whenever global install guidance changes\nTested: node --test scripts/skill-docs.test.js; npm run ci; temp-dir kordoc markdownToHwpx roundtrip back to Markdown\nNot-tested: GitHub-side mergeability checks before remote push
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-16 15:22:30 +09:00
commit f65262b783
40 changed files with 4525 additions and 38 deletions

View file

@ -0,0 +1,5 @@
---
"public-restroom-nearby": minor
---
Add the first official public-restroom nearby lookup package and skill/docs set.

View file

@ -31,6 +31,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
@ -39,6 +40,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| 근처 공중화장실 찾기 | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
@ -52,6 +54,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 다이소 상품 조회 | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 올라포케 역삼 포케 | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
| 택배 배송조회 | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 번개장터 검색 | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
@ -101,6 +104,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
@ -109,6 +113,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
@ -122,6 +127,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [번개장터 검색 가이드](docs/features/bunjang-search.md)

View file

@ -0,0 +1,266 @@
# 올라포케 역삼 포케 가이드
## 이 기능으로 할 수 있는 일
- 올라포케 역삼점 메뉴 조회 (`get_menu`)
- 위치·영업시간·배달 반경·단체주문 링크 조회 (`get_shop_info`)
- 즉석 래플형 이벤트 참여 (`enter_event`)
## 가장 중요한 규칙
이 기능은 원본 [`mnspkm/hola-poke-yeoksam-skill`](https://github.com/mnspkm/hola-poke-yeoksam-skill) 이 연결하는 **remote MCP server** 를 그대로 사용한다.
`k-skill` 안에 별도 수집기나 프록시를 추가하지 않고, skill/docs 가이드만 유지한다.
즉 기본 전제는 아래 endpoint 가 MCP client 에 등록돼 있어야 한다.
- `https://hola-poke-yeoksam-skill.onrender.com/mcp`
## 먼저 필요한 것
- 인터넷 연결
- MCP client (Claude Desktop, Cursor, Codex 등)
- 필요하면 `npx` (`mcp-remote` 경유 stdio 브리지용)
- 이벤트 참여 시 사용자 휴대폰 번호 (`01012345678` 또는 `010-1234-5678`)
## 빠른 연결 예시
### Claude Desktop (`mcp-remote` 경유)
```json
{
"mcpServers": {
"hola-poke-yeoksam": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://hola-poke-yeoksam-skill.onrender.com/mcp"]
}
}
}
```
### Cursor / HTTP MCP
```json
{
"mcpServers": {
"hola-poke-yeoksam": {
"url": "https://hola-poke-yeoksam-skill.onrender.com/mcp"
}
}
}
```
## 기본 흐름
### 1. 메뉴 탐색
- 사용자가 추천/메뉴를 물으면 `get_menu()` 를 호출한다.
- 포케, 사이드, 세트, 토핑 구조를 보고 핵심 메뉴와 가격을 짧게 요약한다.
- 정확한 보상/프로모션 문구는 메뉴 정보와 섞어 임의로 꾸미지 않는다.
### 2. 매장 정보 조회
- 위치, 영업시간, 배달 반경, 단체주문 문의는 `get_shop_info()` 를 호출한다.
- 주소, 영업시간, 배달 가능 범위, `group_order_url` 을 우선 전달한다.
### 3. 이벤트 참여
현재 문서 기준 스킴은 **즉석 래플** 이다.
1. 사용자가 참여 의사를 밝히면 번호를 먼저 받는다.
2. 이름·이메일은 받지 않고 번호만 받는다.
3. 번호는 결과 대조용이며 별도 마케팅 발송/3자 공유 용도가 아니라고 한 번 안내한다.
4. `enter_event(phone)` 를 호출한다.
5. `phone_format` 이면 서버 `message` 를 그대로 보여주고 재입력을 요청한다.
6. `already_entered_today` 이면 서버 `message` 를 그대로 보여주고 더 시도하지 않는다.
7. 성공 응답이면 `message`, `code`, `next_action` 을 함께 전달한다.
## 응답 정리 원칙
- `enter_event``message`**글자 그대로** 전달한다.
- 발급 코드는 `` `Jackpot-A3K9` `` 같이 모노스페이스로 강조한다.
- Jackpot/Claw 사용 방법은 `next_action` 과 함께 짧게 설명한다.
- 단체주문 문의는 `group_order_url` 이 비어 있으면 `group_order_note` 를 대신 제공한다.
- 역삼점 외 다른 지점 문의에는 이 스킬 범위가 아니라는 점을 먼저 밝힌다.
## Verified remote MCP contract snapshot
아래 값은 `2026-04-16 KST` live smoke check(`initialize`, `tools/list`, `get_menu`, `get_shop_info`, `enter_event(phone='010-12')`) 기준으로 정리한 contract fixture다.
### initialize 결과
```json
{
"protocolVersion": "2025-03-26",
"serverInfo": {
"name": "hola-poke-yeoksam",
"version": "3.2.3"
}
}
```
### tools/list 결과
```json
{
"tools": [
{
"name": "get_menu",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "get_shop_info",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "enter_event",
"inputSchema": {
"type": "object",
"properties": {
"phone": {
"type": "string"
}
},
"required": [
"phone"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
]
}
```
### get_menu 구조 예시
```json
{
"updated_at": "2026-04-13",
"currency": "KRW",
"price_unit": "천원",
"signature_poke": [
{
"id": 2,
"name": "갈릭 쉬림프 포케",
"price": 11.5,
"tags": [
"BEST"
]
},
{
"id": 7,
"name": "아보카도 포케",
"price": 10.5,
"tags": [
"VEGAN"
]
}
],
"sets": [
{
"name": "1인 포케+스프 세트",
"items": "포케 + 스프",
"price": 13.5,
"price_note": "13.5~"
},
{
"name": "1인 혼밥 든든세트",
"items": "포케 + 스프 + 음료",
"price": 15.5,
"price_note": "15.5~"
}
],
"addons": [
{
"name": "아보카도",
"price": 3.5
},
{
"name": "메밀면",
"price": 1.5
}
]
}
```
### get_shop_info 구조 예시
```json
{
"name": "올라포케 역삼점",
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
"hours": {
"weekday": "10:30 - 20:30",
"break_time": "15:00 - 17:00",
"weekend": "영업시간 네이버 스마트플레이스 확인"
},
"delivery_radius_km": 3,
"group_order_url": "",
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
"delivery_apps": [
"배달의민족",
"쿠팡이츠",
"요기요"
]
}
```
### enter_event 성공 응답 필수 필드
실제 이벤트 참여를 발생시키지 않기 위해 성공 경로는 저장된 스냅샷 fixture 계약으로만 고정한다. 라이브 스모크는 invalid-phone 재시도 흐름만 검증한다.
```json
{
"required_fields": [
"message",
"code",
"next_action"
],
"accepts": [
"01012345678",
"010-1234-5678"
],
"stores_name_or_email": false
}
```
### enter_event(phone='010-12') 예시
```json
{
"error": "phone_format",
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
}
```
## 제한사항
- 역삼점 전용이다.
- 주문/결제/배달앱 자동화는 하지 않는다.
- 단체주문 자동 예약을 대신 실행하지 않는다.
- 이벤트 스킴은 시기별로 바뀔 수 있으므로 현재 혜택 조건의 진실 소스는 서버 `message` 다.
- 동일 번호는 하루 1번만 응모 가능하므로 반복 요청을 강행하지 않는다.
## 참고 링크
- 원본 repo: `https://github.com/mnspkm/hola-poke-yeoksam-skill`
- remote MCP endpoint: `https://hola-poke-yeoksam-skill.onrender.com/mcp`

View file

@ -190,7 +190,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
한국 주식 기본정보 endpoint:
@ -199,7 +199,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```

View file

@ -0,0 +1,156 @@
# 장학금 검색 및 조회 가이드
## 이 기능으로 할 수 있는 일
- 한국장학재단, 전국 대학교, 재단, 기업, 공공기관의 최신 장학 공고 검색
- 장학금별 금액, 지원 자격, 학자금 지원구간, 신청 기간, 링크 정리
- 사용자 조건(학교, 학부/대학원, 전공, 학과, 금액, 기관 유형) 기반 필터링
- KST(`Asia/Seoul`) 현재 날짜 기준으로 `지금 지원 가능`, `곧 열림`, `마감됨` 구분
- 정규화된 JSON 목록에 대해 지원 가능 여부 빠른 판정
- readable markdown report 출력
## 가장 중요한 규칙
이 기능은 **공식 공고 우선** 이다.
- `kosaf.go.kr`, `*.ac.kr`, `*.go.kr`, `*.or.kr`, 공식 재단/기업 도메인을 우선 본다.
- 블로그/커뮤니티/모음글은 lead source 로만 쓰고, 공식 공고로 검증되지 않으면 결과에서 제외한다.
- 신청 기간은 반드시 절대 날짜로 적는다.
- 최종 결과에는 공식 공고 링크와 신청 링크를 함께 남긴다.
## 먼저 필요한 것
- 인터넷 연결
- 최신 웹 검색 가능 에이전트
- 선택: `python3` 3.8+ (`scripts/scholarship_filter.py` helper 사용 시)
## 추천 검색 순서
1. 한국장학재단에서 전국 단위 장학/지원구간 관련 공고를 먼저 본다.
2. 사용자 학교가 있으면 해당 학교 공식 장학 공지를 본다.
3. 재단/기업/공공기관 공식 페이지를 추가로 찾는다.
4. 각 공고에서 금액, 자격, 지원구간, 기간, 신청 링크를 정규화한다.
5. 필요하면 helper로 필터링하고 지원 가능 여부를 본다.
특정 학교를 지정하면 학교 본부 장학공지, 학생지원처, 단과대, 학과/전공 홈페이지 공지를 순서대로 확인한다. 학교를 지정하지 않으면 전국 대학 `*.ac.kr` 공고를 넓게 탐색한다.
검색할 때는 `장학금` 만 보지 말고 `장학생 모집`, `외부 장학 추천`, `등록금 감면`, `생활비 지원`, `학업장려비`, `추천장학`, `근로장학` 같은 제목 단서도 같이 본다.
## 공식 표면 예시
- 한국장학재단 푸른등대 기부장학금: `https://www.kosaf.go.kr/ko/scholar.do?pg=scholarship05_11_01`
- 한국장학재단 학자금 지원구간 산정절차: `https://www.kosaf.go.kr/ko/tuition.do?pg=tuition04_09_01&type=tuition`
- 한국장학재단 학자금 지원구간 경곗값: `https://www.kosaf.go.kr/ko/tuition.do?naviParam=JH%2C01%2C01%2C03&pg=tuition04_09_07`
- 삼성꿈장학재단: `https://www.sdream.or.kr/w/web60gV`
- 대학 장학 공지: 각 대학 공식 `*.ac.kr` 학생지원처/장학공지
## helper 사용 예시
정규화된 장학금 JSON 파일(`scholarships.json`)이 있다고 가정:
### 재단 장학금만 + 학부생 + 5구간 이하 + 200만원 이상
```bash
python3 scripts/scholarship_filter.py filter \
--input scholarships.json \
--org-type foundation \
--student-level undergraduate \
--income-band 5 \
--min-amount 2000000
```
### 내 조건으로 지원 가능 여부 판정
```bash
python3 scripts/scholarship_filter.py eligibility \
--input scholarships.json \
--school-name "서울대학교" \
--student-level undergraduate \
--grade-year 2 \
--gpa 3.5 \
--income-band 5
```
KST 기준 readable report:
```bash
python3 scripts/scholarship_filter.py report \
--input scholarships.json \
--today 2026-04-14 \
--only-open-now
```
학교별 exhaustive query plan 생성:
```bash
python3 scripts/university_search_plan.py \
--school-name "부산대학교" \
--department "컴퓨터공학과" \
--year 2026
```
전국 대학 sweep query 생성:
```bash
python3 scripts/university_search_plan.py --nationwide --year 2026
```
## 바로 쓸 프롬프트 예시
```text
장학금 검색 및 조회 스킬을 사용해서 지금 신청 가능하거나 곧 열리는 한국 장학금 공고를 찾아줘. 한국장학재단, 전국 대학교, 재단, 기업, 공공기관 공식 공고만 포함하고, KST 기준 현재 날짜로 열린 공고와 곧 열릴 공고를 나눠서 가독성 좋은 form으로 정리해줘.
```
```text
장학금 검색 및 조회 스킬을 사용해서 내 조건에 맞는 장학금만 찾아줘.
- 학교: 서울대학교
- 학생 구분: 학부생
- 학년: 2학년
- 전공: 컴퓨터공학
- 학과: 컴퓨터공학부
- 학자금 지원구간: 5구간
- 최소 금액: 200만원
- 기관 유형: 재단
지원 가능 여부도 같이 표시해줘.
```
## 결과 form 권장
1. 요약: 총 후보 수 / 열린 공고 수 / 곧 열릴 공고 수
2. `지금 지원 가능`
3. `곧 열림`
4. `마감됨`
각 항목은 아래 순서로 보여주면 읽기 좋다.
- 장학금명
- 기관 / 기관 유형
- 금액
- 신청기간
- KST 기준 현재 날짜 상태
- 핵심 조건
- 공식 공고 링크
- 신청 링크
조건이 불명확한 항목은 숨기지 말고 `미확인` 으로 남긴다.
## 답변 템플릿 권장
- 장학금명
- 운영기관 / 기관 유형
- 지원 금액
- 신청 기간
- 핵심 자격 요건
- 학자금 지원구간 조건
- 공식 공고 링크
- 신청 링크
- 내 조건 기준 간단 판정
## 주의 사항
- `scripts/scholarship_filter.py` 에서 `--today` 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준으로 마감 상태를 계산한다.
- "등록금 전액" 같이 금액이 정액이 아닌 공고는 원문 텍스트를 그대로 유지한다.
- 성적 조건이 4.5 만점인지 100점 만점인지 공고마다 다르므로 원문 기준을 같이 적는다.
- 장학금은 학기별/연도별로 반복되더라도 조건과 마감일이 달라질 수 있으니, 과거 공고를 최신 공고로 착각하지 않는다.
- 학자금 지원구간 관련 설명은 한국장학재단 기준을 우선 참고한다.

View file

@ -31,7 +31,7 @@ upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 기본정보 예시
@ -40,7 +40,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 일별 시세 예시
@ -49,7 +49,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## 응답 해석 팁
@ -59,6 +59,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
- 일부 시장 upstream 이 실패하면 검색 응답에 `upstream.degraded=true``failed_markets` 가 붙을 수 있다.
## 답변 템플릿 권장
@ -72,7 +73,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
- upstream KRX 오류는 502
- 검색 중 일부 시장 upstream 이 실패하면 200 이지만 `upstream.degraded=true` / `failed_markets` 가 함께 온다.
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502
- 기준일에 종목을 찾지 못하면 404 `not_found`
## 참고 링크

View file

@ -0,0 +1,145 @@
# 근처 공중화장실 찾기 가이드
## 이 기능으로 할 수 있는 일
- 현재 위치 기준 근처 공중화장실 / 개방화장실 검색
- 동네/역명/랜드마크를 Kakao Map anchor 로 변환한 뒤 nearby 계산
- 공식 `공중화장실정보` 표준데이터 기반 거리순 요약
- 개방시간, 주소, 지도 링크까지 함께 정리
## 가장 먼저 할 일
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
권장 질문 예시:
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.
```
## 입력값
- 동네/상권: `광화문`, `성수동`, `해운대`
- 역명/랜드마크: `서울역`, `강남역`, `코엑스`
- 좌표: `37.57103, 126.97679`
위치 문자열은 Kakao Map anchor 검색으로 **WGS84 좌표**를 잡고, anchor 주소에서 추론한 시도 코드가 있으면 해당 지역 CSV만 내려받습니다.
## 공식 표면
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
공식 CSV에는 화장실명, 주소, 위·경도, 남녀/장애인 화장실 수, 개방시간, 기저귀교환대, 비상벨 등이 담겨 있습니다.
## 기본 흐름
1. 유저에게 현재 위치를 먼저 묻습니다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
3. anchor 주소에서 서울/경기/부산 같은 시도 정보를 추론합니다.
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬합니다.
5. 가장 가까운 3~5개만 짧게 응답합니다.
## Node.js 예시
```js
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3
});
for (const item of result.items) {
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
반경 제한이 필요하면 `maxDistanceMeters` 옵션으로 100m 같은 거리 캡을 줄 수 있습니다.
```js
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3,
maxDistanceMeters: 100
});
console.log(`100m 이내 결과 수: ${result.meta.total}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Offline smoke example
fixture 기반 검증:
```bash
node --test packages/public-restroom-nearby/test/index.test.js
```
## 검증된 live smoke 예시
아래 값은 **2026-04-16**`광화문`, `limit=3` 로 실제 호출해 확인한 결과 일부입니다.
```json
{
"anchor": {
"name": "광화문",
"address": "서울 종로구 사직로 161 (세종로)"
},
"meta": {
"region": {
"name": "서울특별시"
}
},
"items": [
{
"name": "세종로공영주차장",
"type": "개방화장실",
"openTimeDetail": "00~24"
},
{
"name": "종로구청화장실",
"type": "개방화장실",
"openTimeDetail": "평일9시간(09:00~18:00)"
},
{
"name": "세종문화회관 화장실",
"type": "개방화장실",
"openTimeDetail": "08~22"
}
]
}
```
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 으로 확인했을 때는 `meta.total = 0` 이었습니다.
## 운영 팁
- 좌표를 직접 받으면 anchor 검색을 생략해 더 빠르게 nearby 계산을 할 수 있습니다.
- 화장실이 너무 많이 잡히는 지역이면 `maxDistanceMeters` 로 100m, 300m 같은 거리 캡을 먼저 걸어두세요.
- CSV는 공개 표준데이터이므로 **실시간 잠금/점검 상태는 보장하지 않습니다**. 개방시간 위주로만 안내하세요.
- 넓은 질의(예: `강남`)는 기준점이 흔들릴 수 있으니 필요하면 역명/동 이름으로 한 번 더 좁히세요.
- 지도 링크가 필요하면 `item.mapUrl` 을 함께 전달하면 됩니다.
## 주의할 점
- 데이터는 공식 공개 CSV지만 실시간 availability API는 아닙니다.
- CSV 인코딩은 CP949 계열일 수 있어 직접 구현할 때 디코딩 처리가 필요합니다.
- Kakao Map anchor 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.

View file

@ -54,6 +54,7 @@ npx --yes skills add <owner/repo> \
--skill kakaotalk-mac \
--skill korean-law-search \
--skill real-estate-search \
--skill korean-scholarship-search \
--skill korean-stock-search \
--skill household-waste-info \
--skill mfds-drug-safety \
@ -62,6 +63,7 @@ npx --yes skills add <owner/repo> \
--skill korean-patent-search \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill public-restroom-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
--skill subway-lost-property \
@ -69,6 +71,7 @@ npx --yes skills add <owner/repo> \
--skill daiso-product-search \
--skill market-kurly-search \
--skill olive-young-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
@ -119,6 +122,8 @@ korean-law list
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
`korean-scholarship-search` 는 스킬 이름 `장학금 검색 및 조회` 로 동작한다. 별도 API key 없이 최신 웹 검색과 공식 공고 확인으로 장학금을 찾고, 한국장학재단·전국 대학교 본부·단과대·학과·재단·기업·공공기관 공고를 모아 금액/지원자격/지원구간/공식 링크를 정리한다. 설치된 helper `python3 scripts/scholarship_filter.py` 로 사용자 조건 필터링, KST(`Asia/Seoul`) 현재 날짜 기준 마감 상태 분류, readable report, 지원 가능 여부 확인을 할 수 있고, `python3 scripts/university_search_plan.py` 로 학교별 또는 전국 대학 검색 쿼리 팩을 만들 수 있다. 자세한 사용법은 [장학금 검색 및 조회 가이드](features/korean-scholarship-search.md)를 본다.
`korean-stock-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `KRX_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`. 자세한 사용법은 [한국 주식 정보 조회 가이드](features/korean-stock-search.md)를 본다.
`household-waste-info` 는 별도 설치 없이 `k-skill-proxy``/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서만 원본 API(`apis.data.go.kr/1741000/household_waste_info/info`)로 주입한다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 자세한 사용법은 [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)를 본다.
@ -127,19 +132,19 @@ korean-law list
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260408'`
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
@ -255,7 +260,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
export NODE_PATH="$(npm root -g)"
```
@ -291,6 +296,12 @@ export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
```
장학금 검색 및 조회 helper는 설치된 `korean-scholarship-search` skill 안의 `scripts/scholarship_filter.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다. `--today` 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준으로 마감 상태를 계산한다.
```bash
python3 scripts/scholarship_filter.py report --input scholarships.json --today 2026-04-14 --only-open-now
```
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
@ -330,6 +341,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
- `korean-stock-search`
- `household-waste-info`
- `cheap-gas-nearby`
- `public-restroom-nearby`
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)
관련 문서:

View file

@ -20,10 +20,12 @@
- 한국 부동산 실거래가 조회 스킬 출시
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
- 장학금 검색 및 조회 스킬 출시
- 한국 주식 정보 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 한국 특허 정보 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 근처 공중화장실 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
@ -31,6 +33,7 @@
- 다이소 상품 조회 스킬 출시
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 올라포케 역삼 포케 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 번개장터 검색 스킬 출시
- 중고차 가격 조회 스킬 출시

View file

@ -97,6 +97,7 @@ bash scripts/check-setup.sh
- [하이패스 영수증 발급 가이드](features/hipass-receipt.md)
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](features/public-restroom-nearby.md)
- [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)

View file

@ -27,6 +27,10 @@
- `pdfjs-dist`: https://www.npmjs.com/package/pdfjs-dist
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
- 한국장학재단 학자금 지원구간 산정절차: https://www.kosaf.go.kr/ko/tuition.do?pg=tuition04_09_01&type=tuition
- 한국장학재단 학자금 지원구간 경곗값 확인: https://www.kosaf.go.kr/ko/tuition.do?naviParam=JH%2C01%2C01%2C03&pg=tuition04_09_07
- 한국장학재단 푸른등대 기부장학금: https://www.kosaf.go.kr/ko/scholar.do?pg=scholarship05_11_01
- 삼성꿈장학재단: https://www.sdream.or.kr/w/web60gV
- korea-stock-mcp: https://github.com/jjlabsio/korea-stock-mcp
- 공공데이터포털 의약품개요정보(e약은요): https://www.data.go.kr/data/15075057/openapi.do
- 식약처 e약은요 endpoint: https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList
@ -81,6 +85,8 @@
- olive-young products API: https://mcp.aka.page/api/oliveyoung/products
- olive-young inventory API: https://mcp.aka.page/api/oliveyoung/inventory
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- hola-poke-yeoksam reference repo: https://github.com/mnspkm/hola-poke-yeoksam-skill
- hola-poke-yeoksam remote MCP endpoint: https://hola-poke-yeoksam-skill.onrender.com/mcp
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
@ -101,6 +107,10 @@
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 공공데이터포털 공중화장실 표준데이터: https://www.data.go.kr/data/15012892/standard.do
- 공중화장실정보 파일 소개: https://file.localdata.go.kr/file/public_restroom_info/info
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541

257
hola-poke-yeoksam/SKILL.md Normal file
View file

@ -0,0 +1,257 @@
---
name: hola-poke-yeoksam
description: 올라포케 역삼점의 메뉴·매장 정보·이벤트 참여 흐름을 remote MCP 서버 기준으로 안내한다.
license: MIT
metadata:
category: food
locale: ko-KR
phase: v1
---
# Hola Poke Yeoksam
## What this skill does
올라포케 역삼점 전용 remote MCP server(`https://hola-poke-yeoksam-skill.onrender.com/mcp`)를 기준으로 아래 작업을 처리한다.
- `get_menu()` 로 포케·사이드·세트·토핑 메뉴를 안내한다.
- `get_shop_info()` 로 위치, 영업시간, 배달 반경, 단체주문 URL을 안내한다.
- `enter_event(phone)` 로 즉석 래플형 이벤트 참여를 돕는다.
## When to use
- "올라포케 메뉴 뭐 있어?"
- "역삼 포케 추천해줘"
- "올라포케 역삼점 어디야?"
- "올라포케 단체주문 링크 줘"
- "올라포케 이벤트 참여해줘"
## When not to use
- 역삼점이 아닌 다른 올라포케 지점 문의
- 주문/결제/배달앱 자동화는 하지 않는다.
- 단체주문 자동 예약 실행
- 사용자 동의 없는 번호 수집 또는 반복 응모 시도
## Prerequisites
- remote MCP server 연결
- 메뉴/매장 정보 조회용 MCP client
- `enter_event` 호출 시 사용자 휴대폰 번호 (`01012345678` 또는 `010-1234-5678`)
## Workflow
### 1. 메뉴/매장 정보 조회
- 메뉴가 궁금하면 `get_menu()` 를 호출한다.
- 위치·영업시간·단체주문 문의는 `get_shop_info()` 를 호출한다.
- 응답은 메뉴명, 가격, 주소, 영업시간, URL 같은 핵심 정보 위주로 짧게 정리한다.
### 2. 이벤트 참여
현재 스킴은 **즉석 래플** 이다. 식사 주문 시 쓸 수 있는 혜택 코드가 발급될 수 있고, 동일 번호는 하루 1번만 응모할 수 있다.
1. 사용자가 참여 의사를 밝히면 휴대폰 번호를 먼저 받는다.
2. 이름·이메일은 받지 않고 번호만 받는다.
3. 번호는 결과 대조용이며 별도 마케팅 발송/3자 공유 용도가 아니라고 한 번 고지한다.
4. `enter_event(phone)` 를 호출한다.
5. `phone_format` 이면 서버 `message` 를 그대로 보여주고 다시 받는다.
6. `already_entered_today` 이면 서버 `message` 를 그대로 보여주고 더 이상 재시도하지 않는다.
7. 정상 응답이면 `message`, `code`, `next_action` 을 함께 전달한다.
### 3. 응답 원칙
- `enter_event``message` 는 글자 그대로 전달한다.
- 발급 코드는 `` `Jackpot-A3K9` `` 같은 모노스페이스로 강조한다.
- Jackpot/Claw 사용 경로는 `next_action` 과 함께 안내한다.
- 단체주문 문의는 `get_shop_info()``group_order_url` 이 비어 있으면 `group_order_note` 를 대신 안내한다.
## Remote MCP setup note
이 스킬은 자체 수집기를 vendoring 하지 않는다. 원본 참고 repo와 동일하게 아래 remote MCP endpoint 를 붙여 사용하는 전제다.
- endpoint: `https://hola-poke-yeoksam-skill.onrender.com/mcp`
- reference repo: `https://github.com/mnspkm/hola-poke-yeoksam-skill`
## Verified remote MCP contract snapshot
아래 값은 `2026-04-16 KST` live smoke check(`initialize`, `tools/list`, `get_menu`, `get_shop_info`, `enter_event(phone='010-12')`) 기준으로 정리한 contract fixture다.
### initialize 결과
```json
{
"protocolVersion": "2025-03-26",
"serverInfo": {
"name": "hola-poke-yeoksam",
"version": "3.2.3"
}
}
```
### tools/list 결과
```json
{
"tools": [
{
"name": "get_menu",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "get_shop_info",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "enter_event",
"inputSchema": {
"type": "object",
"properties": {
"phone": {
"type": "string"
}
},
"required": [
"phone"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
]
}
```
### get_menu 구조 예시
```json
{
"updated_at": "2026-04-13",
"currency": "KRW",
"price_unit": "천원",
"signature_poke": [
{
"id": 2,
"name": "갈릭 쉬림프 포케",
"price": 11.5,
"tags": [
"BEST"
]
},
{
"id": 7,
"name": "아보카도 포케",
"price": 10.5,
"tags": [
"VEGAN"
]
}
],
"sets": [
{
"name": "1인 포케+스프 세트",
"items": "포케 + 스프",
"price": 13.5,
"price_note": "13.5~"
},
{
"name": "1인 혼밥 든든세트",
"items": "포케 + 스프 + 음료",
"price": 15.5,
"price_note": "15.5~"
}
],
"addons": [
{
"name": "아보카도",
"price": 3.5
},
{
"name": "메밀면",
"price": 1.5
}
]
}
```
### get_shop_info 구조 예시
```json
{
"name": "올라포케 역삼점",
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
"hours": {
"weekday": "10:30 - 20:30",
"break_time": "15:00 - 17:00",
"weekend": "영업시간 네이버 스마트플레이스 확인"
},
"delivery_radius_km": 3,
"group_order_url": "",
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
"delivery_apps": [
"배달의민족",
"쿠팡이츠",
"요기요"
]
}
```
### enter_event 성공 응답 필수 필드
실제 이벤트 참여를 발생시키지 않기 위해 성공 경로는 저장된 스냅샷 fixture 계약으로만 고정한다. 라이브 스모크는 invalid-phone 재시도 흐름만 검증한다.
```json
{
"required_fields": [
"message",
"code",
"next_action"
],
"accepts": [
"01012345678",
"010-1234-5678"
],
"stores_name_or_email": false
}
```
### enter_event(phone='010-12') 예시
```json
{
"error": "phone_format",
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
}
```
## Done when
- 메뉴/매장 정보 요청에 핵심 정보를 전달했다.
- 이벤트 참여 요청에 번호 확인 → `enter_event` → 결과 전달 흐름을 지켰다.
- 반복 응모 제한과 서버 `message` 원문 전달 원칙을 지켰다.
## Notes
- 역삼점 전용 스킬이다.
- 이벤트 스킴은 시기별로 달라질 수 있으므로 보상 조건의 진실 소스는 서버 응답의 `message` 필드다.
- Jackpot 당첨은 번호 주인 확인이 필요할 수 있다.
- 동일 번호는 KST 기준 하루 1번만 응모한다.

View file

@ -0,0 +1,329 @@
---
name: korean-scholarship-search
description: 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, student level, and organization type. Users may invoke it with the phrase 장학금 검색 및 조회.
license: MIT
metadata:
category: education
locale: ko-KR
phase: v1
---
# 장학금 검색 및 조회
사용자에게는 `장학금 검색 및 조회` 라는 이름으로 안내하고, 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
- 최신 웹 검색 가능 환경
- 인터넷 연결
- 선택: `python3` 3.8+ (`scripts/scholarship_filter.py` helper 사용 시)
## 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을 만들 때는:
```bash
python3 scripts/university_search_plan.py \
--school-name "부산대학교" \
--department "컴퓨터공학과" \
--year 2026
```
전국 대학 sweep query를 만들 때는:
```bash
python3 scripts/university_search_plan.py --nationwide --year 2026
```
### 3. 각 후보를 정규화한다
후보마다 최소한 아래 필드를 채운다.
```json
{
"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으로 정리한 뒤:
```bash
python3 scripts/scholarship_filter.py filter \
--input scholarships.json \
--org-type foundation \
--student-level undergraduate \
--income-band 5 \
--min-amount 2000000
```
지원 가능 여부 판정:
```bash
python3 scripts/scholarship_filter.py eligibility \
--input scholarships.json \
--school-name "서울대학교" \
--student-level undergraduate \
--grade-year 2 \
--gpa 3.5 \
--income-band 5
```
KST 기준 현재 날짜로 열린 공고만 readable 하게 보기:
```bash
python3 scripts/scholarship_filter.py report \
--input scholarships.json \
--today 2026-04-14 \
--only-open-now \
--school-name "서울대학교"
```
마감 임박 공고만 보기:
```bash
python3 scripts/scholarship_filter.py report \
--input scholarships.json \
--today 2026-04-14 \
--deadline-within-days 7
```
### 5. 사용자에게는 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. 전체 탐색
```text
장학금 검색 및 조회 스킬을 사용해서 지금 신청 가능하거나 곧 열리는 한국 장학금 공고를 찾아줘. 한국장학재단, 전국 대학교, 재단, 기업, 공공기관 공식 공고만 포함하고, KST 기준 현재 날짜로 열린 공고와 곧 열릴 공고를 나눠서 보여줘. 각 항목마다 장학금명, 기관명, 기관 유형, 지원 금액, 신청 기간, 핵심 자격, 학자금 지원구간 조건, 공식 공고 링크, 신청 링크를 가독성 좋은 섹션형 form으로 정리해줘.
```
### 2. 사용자 조건 기반 필터링
```text
장학금 검색 및 조회 스킬을 사용해서 내 조건에 맞는 장학금만 찾아줘.
조건:
- 학교: 서울대학교
- 학생 구분: 학부생
- 학년: 2학년
- 전공: 컴퓨터공학
- 학자금 지원구간: 5구간
- 최소 금액: 200만원
- 기관 유형: 재단
공식 공고만 포함하고, KST 기준 현재 날짜로 마감 여부도 고려해서 지원 가능 여부를 가능/불확실/불가로 표시해줘.
```
### 3. 학교 장학 vs 재단 장학 비교
```text
장학금 검색 및 조회 스킬을 사용해서 연세대학교 학부생이 지원할 수 있는 교내 장학금과 민간재단 장학금을 나눠서 보여줘. 학교 본부 장학공지, 단과대, 학과 홈페이지 장학 공지를 모두 확인하고, 금액, 신청 기간, 소득구간 조건, 성적 조건, 공식 링크를 같이 정리하고 어느 쪽이 내 조건에 더 맞는지 짧게 비교해줘.
```
### 4. 지원 가능 여부만 빠르게 보기
```text
장학금 검색 및 조회 스킬을 사용해서 아래 프로필로 지원 가능성이 있는 장학금만 골라줘.
- 학교: 고려대학교
- 학생 구분: 대학원생
- 전공: 전산학
- GPA: 3.8/4.5
- 학자금 지원구간: 4구간
- 원하는 유형: 생활비 장학금
각 항목마다 왜 맞는지 또는 어떤 조건이 불확실한지 한 줄씩 붙여줘.
```
## Done when
- 공식 공고 중심으로 후보를 모았다.
- 금액, 자격, 지원구간, 신청기간, 링크를 정리했다.
- 사용자가 준 조건으로 필터링했다.
- 지원 가능 여부를 빠르게 판단했다.
- 비공식 출처는 공식 페이지로 검증했거나 제외했다.
## Notes
- 한국장학재단 공식 장학·지원구간 표면은 `references/source-patterns.md` 참고
- 학교별 장학 공지는 HTML 구조가 제각각이므로, 공고 제목/본문/첨부를 같이 읽어야 한다.
- 장학금 "조건" 과 "우대사항" 을 섞지 않는다.
- "등록금 전액" 같은 비정액 장학금은 `amount.text` 를 유지하고 수치가 없으면 `*_krw` 는 비워둔다.

View file

@ -0,0 +1,40 @@
# Report Format
최종 답변은 changelog처럼 나열하지 말고, 아래 형식을 기본으로 한다.
## 1. Header summary
- 검색 기준일
- 검색 범위
- 총 후보 수
- 지금 지원 가능 수
- 곧 열릴 공고 수
- 마감된 공고 수
## 2. Grouping
1. 지금 지원 가능
2. 곧 열림
3. 조건은 맞지만 마감됨
4. 검증 부족 / 미확인
## 3. Entry format
각 항목은 아래 순서를 유지한다.
- 장학금명
- 기관명 / 기관 유형
- 금액
- 신청기간
- 상태 (`open`, `upcoming`, `closed`, `D-3`)
- 핵심 자격
- 지원 가능 여부 (`가능`, `불확실`, `불가`)
- 공식 공고 링크
- 신청 링크
## 4. Readability rules
- 한 항목에 문장을 너무 길게 쓰지 않는다.
- 핵심 조건은 `/` 로 짧게 끊는다.
- 금액이 불명확하면 `금액 미공개` 라고 적고 공고 원문 링크를 남긴다.
- 확실하지 않은 정보는 `미확인` 으로 표시한다.

View file

@ -0,0 +1,61 @@
# 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. 정리 원칙
- `교내 장학`
- `학과 장학`
- `외부 추천 장학`
이 세 묶음으로 나눠 보여주면 가독성이 좋아진다.

View file

@ -0,0 +1,58 @@
# 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 지역인재 장학금`

View file

@ -0,0 +1,67 @@
# 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`: 기업/기업재단/CSR
- `other`: 위 분류가 애매한 경우
## 5. Eligibility normalization
가능하면 아래 필드로 구조화한다.
- `student_levels`: `highschool`, `undergraduate`, `graduate`, `all`
- `school_kinds`: `highschool`, `college`, `university`, `graduate-school`
- `grade_years`: 숫자 배열
- `majors`: 문자열 배열
- `gpa_min`: 4.5 또는 100점 기준이 섞여 있으면 원문도 같이 남긴다
- `income_band_min`, `income_band_max`
- `notes`: 구조화가 애매한 자격 조건
## 6. Verification rule
- 비공식 요약 페이지는 링크 탐색용으로만 본다.
- 최종 결과에는 공식 공고 링크를 반드시 포함한다.
- 공고 날짜가 오래됐으면 최신 회차/학기 공고를 다시 찾는다.

View file

@ -0,0 +1,811 @@
#!/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())

View file

@ -0,0 +1,224 @@
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()

View file

@ -0,0 +1,148 @@
#!/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())

View file

@ -20,7 +20,7 @@ upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
- "삼성전자 종목코드랑 시장구분 찾아줘"
- "005930 기본정보 보여줘"
- "SK하이닉스 20260404 종가/거래량 알려줘"
- "SK하이닉스 20260408 종가/거래량 알려줘"
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
## When not to use
@ -75,7 +75,7 @@ GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
종목 기본정보:
@ -84,7 +84,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
종목 일별 시세:
@ -93,7 +93,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
--data-urlencode 'market=KOSPI' \
--data-urlencode 'code=005930' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
## Response shape
@ -113,7 +113,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"listed_at": "1975-06-11"
}
],
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
"query": { "q": "삼성전자", "bas_dd": "20260408", "limit": 10 },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -135,7 +135,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"par_value": 100,
"listed_shares": 5969782550
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -148,7 +148,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"base_date": "20260404",
"base_date": "20260408",
"name": "삼성전자",
"close_price": 84000,
"change_price": 1000,
@ -160,7 +160,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
"trading_value": 1030000000000,
"market_cap": 500000000000000
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
@ -168,8 +168,9 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
## Response policy
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
- 일부 시장 upstream 이 실패하면 `upstream.degraded=true``failed_markets` 를 보고 부분 장애 여부를 함께 설명한다.
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다. 이 경우 `trade-info` 는 502 대신 `not_found` 로 끝날 수 있다.
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
@ -185,7 +186,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
- upstream KRX 응답 오류면 502 응답
- 검색 중 일부 시장 upstream 이 실패하면 200 응답이지만 `upstream.degraded=true``failed_markets` 를 함께 반환할 수 있다.
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502 응답
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
## Done when

21
package-lock.json generated
View file

@ -1287,6 +1287,10 @@
],
"license": "MIT"
},
"node_modules/public-restroom-nearby": {
"resolved": "packages/public-restroom-nearby",
"link": true
},
"node_modules/quansync": {
"version": "0.2.11",
"dev": true,
@ -1687,7 +1691,7 @@
}
},
"packages/blue-ribbon-nearby": {
"version": "0.2.2",
"version": "0.2.3",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1697,7 +1701,7 @@
}
},
"packages/cheap-gas-nearby": {
"version": "0.3.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1711,7 +1715,7 @@
}
},
"packages/hipass-receipt": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"dependencies": {
"playwright-core": "^1.52.0"
@ -1755,13 +1759,20 @@
}
},
"packages/lck-analytics": {
"version": "0.3.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/market-kurly-search": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/public-restroom-nearby": {
"version": "0.1.0",
"license": "MIT",
"engines": {
@ -1776,7 +1787,7 @@
}
},
"packages/used-car-price-search": {
"version": "0.4.0",
"version": "0.5.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -9,10 +9,11 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -116,7 +116,7 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/food-safety/search' \
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
--data-urlencode 'bas_dd=20260408'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.

View file

@ -165,6 +165,10 @@ async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
if (tradeItems.length === 0) {
return [];
}
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
if (directlyMatched.length > 0) {
return directlyMatched.map((item) => normalizeTradeItem(item, market));
@ -221,6 +225,14 @@ function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
return `krx-base-info:${market}:${basDd}`;
}
function serializeKrxError(error) {
return {
code: error?.code || "proxy_error",
status_code: error?.statusCode || 502,
message: error?.message || "Unknown KRX upstream error."
};
}
async function fetchBaseInfoSnapshot({
market,
basDd,
@ -272,13 +284,20 @@ async function searchStocks({
const successfulResults = settledResults
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
const failedResults = settledResults
.map((result, index) => ({ result, market: markets[index] }))
.filter(({ result }) => result.status === "rejected")
.map(({ result, market }) => ({
market,
...serializeKrxError(result.reason)
}));
if (successfulResults.length === 0) {
const firstFailure = settledResults.find((result) => result.status === "rejected");
throw firstFailure?.reason || new Error("KRX search failed for every market.");
}
return {
const payload = {
items: successfulResults
.flatMap(({ market: entryMarket, items }) =>
items
@ -289,6 +308,17 @@ async function searchStocks({
.slice(0, limit)
.map(({ score, ...item }) => item)
};
if (failedResults.length > 0) {
payload.upstream = {
degraded: true,
requested_markets: markets,
successful_markets: successfulResults.map(({ market: entryMarket }) => entryMarket),
failed_markets: failedResults
};
}
return payload;
}
module.exports = {

View file

@ -2020,9 +2020,9 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
};
}
let items;
let result;
try {
const result = await searchStocks({
result = await searchStocks({
query: normalized.q,
basDd: normalized.basDd,
market: normalized.market,
@ -2031,7 +2031,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
cache,
cacheTtlMs: config.cacheTtlMs
});
items = result.items;
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
@ -2041,7 +2040,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
}
const payload = {
items,
items: result.items,
query: {
q: normalized.q,
bas_dd: normalized.basDd,
@ -2058,7 +2057,13 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
if (result.upstream) {
payload.upstream = result.upstream;
}
if (!result.upstream?.degraded) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
@ -2442,7 +2447,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다. 휴장일이거나 데이터가 아직 없을 수 있습니다.`
};
}

View file

@ -172,7 +172,7 @@ test("korean stock search rate limit does not trust spoofed cf-connecting-ip on
assert.equal(second.json().error, "rate_limited");
});
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
test("korean stock search surfaces degraded upstream metadata when another market fails", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
@ -249,10 +249,124 @@ test("korean stock search returns healthy market results when another market ups
assert.equal(response.json().items[0].market, "KOSPI");
assert.equal(response.json().items[0].code, "005930");
assert.equal(response.json().items[0].name, "삼성전자");
assert.equal(response.json().upstream.degraded, true);
assert.deepEqual(response.json().upstream.requested_markets, ["KOSPI", "KOSDAQ", "KONEX"]);
assert.deepEqual(response.json().upstream.successful_markets, ["KOSPI", "KONEX"]);
assert.deepEqual(response.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(fetchCalls.length, 3);
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock search does not cache degraded responses and retries a recovered market", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
let kosdaqAttempts = 0;
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info") || text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
kosdaqAttempts += 1;
if (kosdaqAttempts === 1) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7196170005",
ISU_SRT_CD: "196170",
ISU_NM: "알테오젠",
ISU_ABBRV: "알테오젠",
ISU_ENG_NM: "Alteogen",
LIST_DD: "20140509",
MKT_TP_NM: "KOSDAQ",
SECUGRP_NM: "주권",
SECT_TP_NM: "제약",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "500",
LIST_SHRS: "53470829"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().items.length, 0);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(first.json().upstream.degraded, true);
assert.deepEqual(first.json().upstream.failed_markets, [
{
market: "KOSDAQ",
code: "upstream_error",
status_code: 502,
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
}
]);
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, false);
assert.equal(second.json().items.length, 1);
assert.equal(second.json().items[0].market, "KOSDAQ");
assert.equal(second.json().items[0].code, "196170");
assert.equal(kosdaqAttempts, 2);
assert.equal(fetchCalls.length, 4);
});
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
@ -538,6 +652,56 @@ test("korean stock trade-info endpoint does not relabel an unmatched single-row
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock trade-info endpoint treats empty trade snapshots as not_found without base-info fallback", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.includes("stk_bydd_trd")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
throw new Error("base-info fallback should not run for empty trade snapshots");
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 404);
assert.equal(response.json().error, "not_found");
assert.match(response.json().message, /휴장일이거나 데이터가 아직 없을 수 있습니다/);
assert.deepEqual(fetchCalls, [
"https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd?basDd=20260404"
]);
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
let providerCalls = 0;
const app = buildServer({

View file

@ -0,0 +1,7 @@
# public-restroom-nearby
## 0.1.0
### Minor Changes
- Initial release: official public-restroom nearby lookup package for Korean location queries.

View file

@ -0,0 +1,138 @@
# public-restroom-nearby
공식 `공중화장실정보` 표준데이터와 Kakao Map anchor 검색을 사용해 근처 공중화장실/개방화장실을 찾는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install public-restroom-nearby
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 유저 위치는 자동으로 추적하지 않습니다.
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
- 화장실 데이터는 공식 `공중화장실정보` CSV를 직접 사용합니다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 구하고, 가능하면 해당 시도 CSV로 좁혀서 조회합니다.
## 공식 표면
- 공공데이터포털 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## 사용 예시
```js
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3
});
for (const item of result.items) {
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
좌표를 직접 받은 경우:
```js
const { searchNearbyPublicRestroomsByCoordinates } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57103,
longitude: 126.97679,
limit: 3
});
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
거리 제한이 필요하면 `maxDistanceMeters` 를 함께 넘겨서 반경 바깥 결과를 잘라낼 수 있습니다.
```js
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3,
maxDistanceMeters: 100
});
console.log(`100m 이내 결과 수: ${result.meta.total}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Live smoke snapshot
2026-04-16 에 `광화문`, `limit=3` 로 실제 호출했을 때 상위 결과 예시:
```json
{
"anchor": {
"name": "광화문",
"address": "서울 종로구 사직로 161 (세종로)"
},
"meta": {
"region": {
"name": "서울특별시"
}
},
"items": [
{
"name": "세종로공영주차장",
"type": "개방화장실"
},
{
"name": "종로구청화장실",
"type": "개방화장실"
},
{
"name": "세종문화회관 화장실",
"type": "개방화장실"
}
]
}
```
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 로 확인했을 때는 `meta.total = 0` 으로 100m 이내 결과만 남도록 동작했습니다.
## 공개 API
- `parseCoordinateQuery(locationQuery)`
- `inferRegion(address)`
- `buildDatasetDownloadUrl(options?)`
- `normalizePublicRestroomRows(csvText, origin, options?)`
- `searchNearbyPublicRestroomsByCoordinates(options)`
- `searchNearbyPublicRestroomsByLocationQuery(locationQuery, options?)`

View file

@ -0,0 +1,32 @@
{
"name": "public-restroom-nearby",
"version": "0.1.0",
"description": "Official public restroom standard-data client for nearby restroom lookup from a user-provided Korean location",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"restroom",
"toilet",
"public-restroom"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,220 @@
const {
buildDatasetDownloadUrl,
decodeDatasetBuffer,
extractDistrict,
inferRegion,
normalizeAnchorPanel,
normalizePublicRestroomRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates
} = require("./parse");
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
const DEFAULT_BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
};
const DEFAULT_PANEL_HEADERS = {
...DEFAULT_BROWSER_HEADERS,
accept: "application/json, text/plain, */*",
appVersion: "6.6.0",
origin: "https://place.map.kakao.com",
pf: "PC",
referer: "https://place.map.kakao.com/"
};
async function request(url, options = {}, responseType = "text") {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const response = await fetchImpl(url, {
headers: {
...(options.headerSet || DEFAULT_BROWSER_HEADERS),
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
const error = new Error(`Request failed with ${response.status} for ${url}`);
error.status = response.status;
error.url = url;
throw error;
}
if (responseType === "json") {
return response.json();
}
if (responseType === "buffer") {
return Buffer.from(await response.arrayBuffer());
}
return response.text();
}
async function fetchSearchResults(query, options = {}) {
const url = new URL(SEARCH_VIEW_URL);
url.searchParams.set("q", String(query || "").trim());
return request(url.toString(), options, "text");
}
async function fetchPlacePanel(confirmId, options = {}) {
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
}
function isRecoverablePlacePanelError(error) {
const status = Number(error?.status);
return Number.isInteger(status) && status >= 400 && status < 600;
}
async function resolveAnchor(locationQuery, options = {}) {
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
for (const candidate of rankedCandidates) {
let anchorPanel;
try {
anchorPanel = await fetchPlacePanel(candidate.id, options);
} catch (error) {
if (isRecoverablePlacePanelError(error)) {
continue;
}
throw error;
}
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
return {
anchor,
candidates: rankedCandidates
};
}
}
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
}
async function fetchDatasetCsv(options = {}) {
const datasetUrl = buildDatasetDownloadUrl(options);
const buffer = await request(
datasetUrl,
{
...options,
headers: {
referer: "https://file.localdata.go.kr/file/public_restroom_info/info",
...(options.headers || {})
}
},
"buffer",
);
return {
datasetUrl,
csvText: decodeDatasetBuffer(buffer)
};
}
function normalizeLimit(limit) {
if (limit === undefined || limit === null) {
return 5;
}
const parsed = Number(limit);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("limit must be a positive number.");
}
return parsed;
}
async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
const latitude = Number(options.latitude);
const longitude = Number(options.longitude);
const limit = normalizeLimit(options.limit);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const dataset = await fetchDatasetCsv(options);
const allItems = normalizePublicRestroomRows(dataset.csvText, { latitude, longitude }, {
maxDistanceMeters: options.maxDistanceMeters,
preferredDistrict: options.preferredDistrict
});
return {
anchor: {
name: options.anchorName || "입력 좌표",
address: options.anchorAddress || null,
latitude,
longitude
},
items: allItems.slice(0, limit),
meta: {
total: allItems.length,
limit,
datasetUrl: dataset.datasetUrl,
region: options.region || null
}
};
}
async function searchNearbyPublicRestroomsByLocationQuery(locationQuery, options = {}) {
const coordinateQuery = parseCoordinateQuery(locationQuery);
if (coordinateQuery) {
return searchNearbyPublicRestroomsByCoordinates({
...options,
...coordinateQuery,
anchorName: String(locationQuery || "").trim()
});
}
const { anchor, candidates } = await resolveAnchor(locationQuery, options);
const region = inferRegion(anchor.address);
const result = await searchNearbyPublicRestroomsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude,
orgCode: options.orgCode || region?.orgCode,
region,
preferredDistrict: options.preferredDistrict || extractDistrict(anchor.address),
anchorName: anchor.name,
anchorAddress: anchor.address
});
return {
...result,
anchor,
candidates,
meta: {
...result.meta,
region
}
};
}
module.exports = {
buildDatasetDownloadUrl,
inferRegion,
normalizePublicRestroomRows,
parseCoordinateQuery,
searchNearbyPublicRestroomsByCoordinates,
searchNearbyPublicRestroomsByLocationQuery
};

View file

@ -0,0 +1,408 @@
const { TextDecoder } = require("node:util");
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
const TAG_PATTERN = /<[^>]+>/g;
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
const REGION_ENTRIES = [
["서울특별시", "6110000_ALL", ["서울특별시", "서울"]],
["부산광역시", "6260000_ALL", ["부산광역시", "부산"]],
["대구광역시", "6270000_ALL", ["대구광역시", "대구"]],
["인천광역시", "6280000_ALL", ["인천광역시", "인천"]],
["광주광역시", "6290000_ALL", ["광주광역시", "광주"]],
["대전광역시", "6300000_ALL", ["대전광역시", "대전"]],
["울산광역시", "6310000_ALL", ["울산광역시", "울산"]],
["세종특별자치시", "5690000_ALL", ["세종특별자치시", "세종"]],
["경기도", "6410000_ALL", ["경기도", "경기"]],
["강원특별자치도", "6530000_ALL", ["강원특별자치도", "강원도", "강원"]],
["충청북도", "6430000_ALL", ["충청북도", "충북"]],
["충청남도", "6440000_ALL", ["충청남도", "충남"]],
["전북특별자치도", "6540000_ALL", ["전북특별자치도", "전라북도", "전북"]],
["전라남도", "6460000_ALL", ["전라남도", "전남"]],
["경상북도", "6470000_ALL", ["경상북도", "경북"]],
["경상남도", "6480000_ALL", ["경상남도", "경남"]],
["제주특별자치도", "6500000_ALL", ["제주특별자치도", "제주도", "제주"]],
];
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripTags(value) {
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "");
}
function extractAttribute(fragment, name) {
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
return match ? decodeHtml(match[1]).trim() : "";
}
function extractInnerText(fragment, className) {
const match = fragment.match(
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
);
return match ? stripTags(match[1]) : "";
}
function parseSearchResultsHtml(html) {
const items = [];
let match;
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
const fragment = match[1];
const id = extractAttribute(fragment, "data-id");
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
if (!id || !name) {
continue;
}
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
.map((entry) => stripTags(entry[1]))
.filter(Boolean);
items.push({
id,
name,
category: extractInnerText(fragment, "txt_ginfo"),
address: addressMatches.at(-1) || ""
});
}
return items;
}
function scoreAnchorCandidate(query, item) {
const normalizedQuery = normalizeText(query);
const normalizedName = normalizeText(item.name);
const normalizedAddress = normalizeText(item.address);
let score = 0;
if (!normalizedQuery) {
return score;
}
if (normalizedName === normalizedQuery) {
score += 1000;
}
if (normalizedName.startsWith(normalizedQuery)) {
score += 800;
}
if (normalizedName.includes(normalizedQuery)) {
score += 600;
}
if (normalizedAddress.includes(normalizedQuery)) {
score += 100;
}
return score;
}
function rankAnchorCandidates(query, items) {
return [...(items || [])].sort((left, right) => {
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.name.localeCompare(right.name, "ko");
});
}
function normalizeAnchorPanel(panel, searchItem = {}) {
const summary = panel.summary || {};
return {
id: String(summary.confirm_id || searchItem.id || ""),
name: summary.name || searchItem.name || "",
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
address: summary.address?.disp || searchItem.address || "",
latitude: toNumber(summary.point?.lat),
longitude: toNumber(summary.point?.lon),
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
function parseCoordinateQuery(locationQuery) {
const match = String(locationQuery || "")
.trim()
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
if (!match) {
return null;
}
return {
latitude: Number(match[1]),
longitude: Number(match[2])
};
}
function inferRegion(value) {
const normalized = normalizeText(value);
for (const [name, orgCode, aliases] of REGION_ENTRIES) {
for (const alias of aliases) {
if (normalized.startsWith(normalizeText(alias))) {
return { name, orgCode };
}
}
}
return null;
}
function buildDatasetDownloadUrl(options = {}) {
const url = new URL("https://file.localdata.go.kr/file/download/public_restroom_info/info");
if (options.orgCode) {
url.searchParams.set("orgCode", options.orgCode);
}
return url.toString();
}
function decodeDatasetBuffer(buffer) {
const asUtf8 = Buffer.from(buffer).toString("utf8");
if (asUtf8.includes("개방자치단체코드") && asUtf8.includes("화장실명")) {
return asUtf8;
}
return new TextDecoder("euc-kr").decode(buffer);
}
function parseCsv(csvText) {
const rows = [];
let row = [];
let value = "";
let inQuotes = false;
const text = String(csvText || "");
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const nextCharacter = text[index + 1];
if (inQuotes) {
if (character === '"' && nextCharacter === '"') {
value += '"';
index += 1;
} else if (character === '"') {
inQuotes = false;
} else {
value += character;
}
continue;
}
if (character === '"') {
inQuotes = true;
continue;
}
if (character === ",") {
row.push(value);
value = "";
continue;
}
if (character === "\n") {
row.push(value.replace(/\r$/u, ""));
rows.push(row);
row = [];
value = "";
continue;
}
value += character;
}
if (value || row.length > 0) {
row.push(value.replace(/\r$/u, ""));
rows.push(row);
}
const [headerRow, ...dataRows] = rows.filter((entry) => entry.some((cell) => cell !== ""));
if (!headerRow || headerRow.length === 0) {
return [];
}
return dataRows.map((cells) => {
const record = {};
for (let index = 0; index < headerRow.length; index += 1) {
record[headerRow[index]] = cells[index] ?? "";
}
return record;
});
}
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(String(value).replace(/,/g, ""));
return Number.isFinite(parsed) ? parsed : null;
}
function toBooleanYesNo(value) {
return String(value || "").trim().toUpperCase() === "Y";
}
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
const earthRadiusMeters = 6371008.8;
const toRadians = (value) => (value * Math.PI) / 180;
const deltaLatitude = toRadians(latitudeB - latitudeA);
const deltaLongitude = toRadians(longitudeB - longitudeA);
const originLatitude = toRadians(latitudeA);
const targetLatitude = toRadians(latitudeB);
const value =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function buildMapUrl(name, latitude, longitude) {
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
}
function extractDistrict(address) {
const match = String(address || "")
.trim()
.match(/^(?:\S+)\s+(\S+(?:구|군|시))/u);
return match ? match[1] : null;
}
function normalizePublicRestroomRows(csvText, origin, options = {}) {
const latitude = Number(origin?.latitude);
const longitude = Number(origin?.longitude);
const limit = options.limit ?? null;
const maxDistanceMeters = Number.isFinite(Number(options.maxDistanceMeters))
? Number(options.maxDistanceMeters)
: null;
const preferredDistrict = String(options.preferredDistrict || "").trim() || null;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("normalizePublicRestroomRows requires finite origin coordinates.");
}
const items = parseCsv(csvText)
.map((row) => {
const itemLatitude = toNumber(row["WGS84위도"]);
const itemLongitude = toNumber(row["WGS84경도"]);
if (!Number.isFinite(itemLatitude) || !Number.isFinite(itemLongitude)) {
return null;
}
const distanceMeters = haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude);
const roadAddress = String(row["소재지도로명주소"] || "").trim();
const lotAddress = String(row["소재지지번주소"] || "").trim();
const address = roadAddress || lotAddress;
return {
id: String(row["관리번호"] || "").trim(),
name: String(row["화장실명"] || "").trim(),
type: String(row["구분명"] || "").trim(),
address,
roadAddress: roadAddress || null,
lotAddress: lotAddress || null,
latitude: itemLatitude,
longitude: itemLongitude,
distanceMeters,
phone: String(row["전화번호"] || "").trim() || null,
managementAgency: String(row["관리기관명"] || "").trim() || null,
openTimeCategory: String(row["개방시간"] || "").trim() || null,
openTimeDetail: String(row["개방시간상세"] || "").trim() || null,
hasEmergencyBell: toBooleanYesNo(row["비상벨설치여부"]),
hasBabyChangingTable: toBooleanYesNo(row["기저귀교환대유무"]),
hasAccessibleFacility:
(toNumber(row["남성용-장애인용대변기수"]) || 0) +
(toNumber(row["남성용-장애인용소변기수"]) || 0) +
(toNumber(row["여성용-장애인용대변기수"]) || 0) >
0,
mapUrl: buildMapUrl(String(row["화장실명"] || "").trim(), itemLatitude, itemLongitude)
};
})
.filter(Boolean)
.filter((item) => (maxDistanceMeters === null ? true : item.distanceMeters <= maxDistanceMeters))
.sort((left, right) => {
if (preferredDistrict) {
const leftMatchesDistrict = extractDistrict(left.address) === preferredDistrict;
const rightMatchesDistrict = extractDistrict(right.address) === preferredDistrict;
if (leftMatchesDistrict !== rightMatchesDistrict) {
return leftMatchesDistrict ? -1 : 1;
}
}
if (left.distanceMeters !== right.distanceMeters) {
return left.distanceMeters - right.distanceMeters;
}
if (left.type !== right.type) {
return left.type.localeCompare(right.type, "ko");
}
return left.name.localeCompare(right.name, "ko");
});
const dedupedItems = [];
const seen = new Set();
for (const item of items) {
const key = [item.name, item.address, item.latitude, item.longitude, item.type].join("::");
if (seen.has(key)) {
continue;
}
seen.add(key);
dedupedItems.push(item);
}
if (limit === null) {
return dedupedItems;
}
return dedupedItems.slice(0, limit);
}
module.exports = {
buildDatasetDownloadUrl,
decodeDatasetBuffer,
extractDistrict,
inferRegion,
normalizeAnchorPanel,
normalizePublicRestroomRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates
};

View file

@ -0,0 +1,13 @@
{
"summary": {
"confirm_id": "1001",
"name": "광화문",
"address": {
"disp": "서울특별시 종로구 세종대로 172"
},
"point": {
"lat": 37.57103,
"lon": 126.97679
}
}
}

View file

@ -0,0 +1,7 @@
<ul>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_ginfo">역사유적지</span>
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
</li>
</ul>

View file

@ -0,0 +1,4 @@
개방자치단체코드,관리번호,구분명,근거법령명,화장실명,소재지도로명주소,소재지지번주소,남성용-대변기수,남성용-소변기수,남성용-장애인용대변기수,남성용-장애인용소변기수,남성용-어린이용대변기수,남성용-어린이용소변기수,여성용-대변기수,여성용-장애인용대변기수,여성용-어린이용대변기수,관리기관명,전화번호,개방시간,개방시간상세,설치연월,WGS84위도,WGS84경도,화장실소유구분명,오물처리방식,안전관리시설설치대상여부,비상벨설치여부,비상벨설치장소,화장실입구CCTV설치유무,기저귀교환대유무,기저귀교환대장소,리모델링연월,데이터기준일자,데이터갱신구분,데이터갱신시점,최종수정시점
3000000,202530000000100842,개방화장실,법제3조제16호-영제3조제1항제1호,종로문화체육센터,서울특별시 종로구 인왕산로1길 21,서울특별시 종로구 사직동 284-1,2,3,1,1,1,1,7,1,1,종로구시설관리공단 건강사업부,027329393,정시,09:00~18:00,,37.57428,126.96468,공공기관-지방공공기관(지방공기업/지방출자출연기관),수세식,Y,Y,장애인화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
3000000,202530000000100863,공중화장실,법제3조제16호-영제3조제1항제1호,통인시장 고객만족센터,서울특별시 종로구 자하문로 15길 18,서울특별시 종로구 통인동 10-3,2,3,1,0,0,0,4,1,0,통인시장 상인회,027220911,정시,11:00~16:00,201002,37.58077,126.96995,공공기관-지방자치단체,수세식,Y,Y,장애인화장실+남자화장실+여자화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:28:39,2025-11-10 09:45:40
3000000,202530000000100830,개방화장실,법제3조제16호-영제3조제1항제1호,보건소,서울특별시 종로구 자하문로19길 36,서울특별시 종로구 옥인동 45-30,7,7,0,0,0,0,10,0,0,종로보건소,0221483520,정시,평일9시간(09:00~18:00),,37.58177,126.96926,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
1 개방자치단체코드 관리번호 구분명 근거법령명 화장실명 소재지도로명주소 소재지지번주소 남성용-대변기수 남성용-소변기수 남성용-장애인용대변기수 남성용-장애인용소변기수 남성용-어린이용대변기수 남성용-어린이용소변기수 여성용-대변기수 여성용-장애인용대변기수 여성용-어린이용대변기수 관리기관명 전화번호 개방시간 개방시간상세 설치연월 WGS84위도 WGS84경도 화장실소유구분명 오물처리방식 안전관리시설설치대상여부 비상벨설치여부 비상벨설치장소 화장실입구CCTV설치유무 기저귀교환대유무 기저귀교환대장소 리모델링연월 데이터기준일자 데이터갱신구분 데이터갱신시점 최종수정시점
2 3000000 202530000000100842 개방화장실 법제3조제16호-영제3조제1항제1호 종로문화체육센터 서울특별시 종로구 인왕산로1길 21 서울특별시 종로구 사직동 284-1 2 3 1 1 1 1 7 1 1 종로구시설관리공단 건강사업부 027329393 정시 09:00~18:00 37.57428 126.96468 공공기관-지방공공기관(지방공기업/지방출자출연기관) 수세식 Y Y 장애인화장실 N Y 여자화장실 2024-12-31 I 2026-03-24 03:27:16 2025-11-10 09:45:40
3 3000000 202530000000100863 공중화장실 법제3조제16호-영제3조제1항제1호 통인시장 고객만족센터 서울특별시 종로구 자하문로 15길 18 서울특별시 종로구 통인동 10-3 2 3 1 0 0 0 4 1 0 통인시장 상인회 027220911 정시 11:00~16:00 201002 37.58077 126.96995 공공기관-지방자치단체 수세식 Y Y 장애인화장실+남자화장실+여자화장실 N Y 여자화장실 2024-12-31 I 2026-03-24 03:28:39 2025-11-10 09:45:40
4 3000000 202530000000100830 개방화장실 법제3조제16호-영제3조제1항제1호 보건소 서울특별시 종로구 자하문로19길 36 서울특별시 종로구 옥인동 45-30 7 7 0 0 0 0 10 0 0 종로보건소 0221483520 정시 평일9시간(09:00~18:00) 37.58177 126.96926 공공기관-지방자치단체 수세식 Y N N N 2024-12-31 I 2026-03-24 03:27:16 2025-11-10 09:45:40

View file

@ -0,0 +1,283 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildDatasetDownloadUrl,
inferRegion,
normalizePublicRestroomRows,
parseCoordinateQuery,
searchNearbyPublicRestroomsByCoordinates,
searchNearbyPublicRestroomsByLocationQuery
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const csvFixture = fs.readFileSync(path.join(fixturesDir, "public-restrooms-seoul.csv"), "utf8");
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
latitude: 37.573713,
longitude: 126.978338
});
assert.equal(parseCoordinateQuery("광화문"), null);
});
test("inferRegion maps Korean region names to the official localdata orgCode", () => {
assert.deepEqual(inferRegion("서울특별시 종로구 세종대로"), {
name: "서울특별시",
orgCode: "6110000_ALL"
});
assert.deepEqual(inferRegion("경기도 성남시 분당구"), {
name: "경기도",
orgCode: "6410000_ALL"
});
assert.equal(inferRegion("미상 주소"), null);
});
test("buildDatasetDownloadUrl defaults to the nationwide CSV and supports regional narrowing", () => {
assert.equal(
buildDatasetDownloadUrl(),
"https://file.localdata.go.kr/file/download/public_restroom_info/info"
);
assert.equal(
buildDatasetDownloadUrl({ orgCode: "6110000_ALL" }),
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
);
});
test("normalizePublicRestroomRows keeps useful restroom metadata and sorts by distance", () => {
const items = normalizePublicRestroomRows(csvFixture, {
latitude: 37.57371315593711,
longitude: 126.97833785777944
});
assert.equal(items.length, 3);
assert.deepEqual(
items.map((item) => [item.id, item.name, item.type, item.address]),
[
["202530000000100863", "통인시장 고객만족센터", "공중화장실", "서울특별시 종로구 자하문로 15길 18"],
["202530000000100830", "보건소", "개방화장실", "서울특별시 종로구 자하문로19길 36"],
["202530000000100842", "종로문화체육센터", "개방화장실", "서울특별시 종로구 인왕산로1길 21"]
]
);
assert.ok(items[0].distanceMeters < items[1].distanceMeters);
const cultureCenter = items.find((item) => item.id === "202530000000100842");
assert.equal(cultureCenter.openTimeDetail, "09:00~18:00");
assert.equal(
cultureCenter.mapUrl,
"https://map.kakao.com/link/map/%EC%A2%85%EB%A1%9C%EB%AC%B8%ED%99%94%EC%B2%B4%EC%9C%A1%EC%84%BC%ED%84%B0,37.57428,126.96468"
);
assert.equal(cultureCenter.hasBabyChangingTable, true);
assert.equal(cultureCenter.hasEmergencyBell, true);
});
test("normalizePublicRestroomRows collapses identical restroom rows from the official CSV", () => {
const duplicatedCsv = `${csvFixture.trim()}\n${csvFixture.trim().split("\n")[1]}\n`;
const items = normalizePublicRestroomRows(duplicatedCsv, {
latitude: 37.57371315593711,
longitude: 126.97833785777944
});
assert.equal(items.length, 3);
assert.equal(
items.filter((item) => item.id === "202530000000100842").length,
1
);
});
test("normalizePublicRestroomRows can prefer the anchor district over suspicious cross-district coordinates", () => {
const weightedCsv = `${csvFixture.trim()}\n3000000,999999999999999999,개방화장실,법제3조제16호-영제3조제1항제1호,멀리있는구청,서울특별시 서대문구 통일로 1,서울특별시 서대문구 냉천동 1,1,1,0,0,0,0,1,0,0,테스트기관,0212345678,정시,09:00~18:00,,37.57372,126.97834,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40\n`;
const items = normalizePublicRestroomRows(weightedCsv, {
latitude: 37.57371315593711,
longitude: 126.97833785777944
}, {
preferredDistrict: "종로구"
});
assert.notEqual(items[0].name, "멀리있는구청");
assert.equal(items[0].address.includes("종로구"), true);
});
test("searchNearbyPublicRestroomsByCoordinates queries the official CSV and returns nearest normalized items", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return makeResponse(Buffer.from(csvFixture, "utf8"));
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 2,
fetchImpl
});
assert.equal(result.items.length, 2);
assert.equal(result.items[0].name, "통인시장 고객만족센터");
assert.equal(result.meta.datasetUrl, "https://file.localdata.go.kr/file/download/public_restroom_info/info");
assert.deepEqual(calls, ["https://file.localdata.go.kr/file/download/public_restroom_info/info"]);
});
test("searchNearbyPublicRestroomsByCoordinates forwards maxDistanceMeters to the CSV normalization path", async () => {
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 5,
maxDistanceMeters: 100,
fetchImpl: async () => makeResponse(Buffer.from(csvFixture, "utf8"))
});
assert.equal(result.items.length, 0);
assert.equal(result.meta.total, 0);
});
test("searchNearbyPublicRestroomsByLocationQuery resolves a Kakao anchor, narrows to the regional CSV, and returns nearest restrooms", async () => {
const calls = [];
const fetchImpl = async (url) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 2,
fetchImpl
});
assert.equal(result.anchor.name, "광화문");
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
assert.equal(result.meta.region.name, "서울특별시");
assert.equal(result.items.length, 2);
assert.equal(result.items[0].name, "종로문화체육센터");
assert.deepEqual(calls, [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001",
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
]);
});
test("searchNearbyPublicRestroomsByLocationQuery falls through to later Kakao candidates when a panel request fails", async () => {
const calls = [];
const multiCandidateSearchHtml = `
<ul>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_ginfo">역사유적지</span>
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
</li>
<li class="search_item base" data-id="1002" data-title="광화문광장">
<strong class="tit_g">광화문광장</strong>
<span class="txt_ginfo">광장</span>
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
</li>
</ul>
`;
const fallbackPanel = {
summary: {
...anchorPanel.summary,
confirm_id: "1002",
name: "광화문광장"
}
};
const fetchImpl = async (url) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(multiCandidateSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return { ok: false, status: 500 };
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1002") {
return makeResponse(fallbackPanel, "application/json");
}
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 2,
fetchImpl
});
assert.equal(result.anchor.id, "1002");
assert.equal(result.anchor.name, "광화문광장");
assert.equal(result.items.length, 2);
assert.deepEqual(calls, [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001",
"https://place-api.map.kakao.com/places/panel3/1002",
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
]);
});
test("searchNearbyPublicRestroomsByLocationQuery still surfaces non-HTTP Kakao panel errors", async () => {
const fetchImpl = async (url) => {
const resolved = String(url);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
throw new Error("socket hang up");
}
throw new Error(`unexpected url: ${resolved}`);
};
await assert.rejects(
searchNearbyPublicRestroomsByLocationQuery("광화문", {
fetchImpl
}),
/socket hang up/
);
});
function makeResponse(body, contentType = "text/csv;charset=UTF-8") {
return {
ok: true,
status: 200,
headers: {
get(name) {
if (String(name).toLowerCase() === "content-type") {
return contentType;
}
return null;
}
},
async text() {
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
},
async json() {
return typeof body === "string" ? JSON.parse(body) : body;
},
async arrayBuffer() {
return Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
}
};
}

View file

@ -0,0 +1,95 @@
---
name: public-restroom-nearby
description: Use when the user asks for nearby public/open restrooms or 근처 화장실. Always ask the user's current location first, then use the official nationwide public-restroom standard dataset plus Kakao anchor resolution.
license: MIT
metadata:
category: convenience
locale: ko-KR
phase: v1
---
# Public Restroom Nearby
## What this skill does
유저가 알려준 현재 위치를 기준으로 **근처 공중화장실 / 개방화장실** 을 찾는다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 화장실 데이터는 공식 `공중화장실정보` 표준데이터를 사용한다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡고, 가능한 경우 해당 시도 데이터만 좁혀서 조회한다.
- 좌표를 직접 받으면 바로 nearby 계산으로 들어간다.
## When to use
- "근처 화장실 찾아줘"
- "서울역 근처 공중화장실 있어?"
- "광화문 주변 개방화장실 몇 군데만 보여줘"
- "지금 여기서 가까운 화장실 지도 링크 줘"
## Mandatory first question
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
- 권장 질문: `현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.`
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
## Official surfaces
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Workflow
1. 유저에게 반드시 현재 위치를 묻는다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보한다.
3. anchor 주소에서 시도(서울/경기/부산 등)를 추론할 수 있으면 해당 지역 CSV로 좁힌다.
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬한다.
5. 보통 3~5개만 짧게 정리하고, 필요하면 지도 링크(`map.kakao.com/link/map/...`)를 같이 준다.
## Responding
결과는 보통 아래 필드를 포함해 짧게 정리한다.
- 화장실명
- 구분명(공중화장실 / 개방화장실)
- 거리
- 주소
- 개방시간/상세
- 지도 링크
## Node.js example
```js
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 공식 데이터 기반으로 최소 1개 이상 nearby restroom 을 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
- 가장 가까운 결과를 3~5개 이내로 정리했다.
## Failure modes
- Kakao Map anchor 가 애매하면 위치 기준점이 흔들릴 수 있다.
- 공개 표준데이터는 실시간 점유/잠금 상태를 주지 않으므로 개방시간 중심으로만 안내해야 한다.
- CSV 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.

View file

@ -0,0 +1,138 @@
{
"verified_at": "2026-04-16 KST",
"endpoint": "https://hola-poke-yeoksam-skill.onrender.com/mcp",
"initialize": {
"protocolVersion": "2025-03-26",
"serverInfo": {
"name": "hola-poke-yeoksam",
"version": "3.2.3"
}
},
"tools_list": {
"tools": [
{
"name": "get_menu",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "get_shop_info",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
},
{
"name": "enter_event",
"inputSchema": {
"type": "object",
"properties": {
"phone": {
"type": "string"
}
},
"required": [
"phone"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
}
}
]
},
"get_menu": {
"updated_at": "2026-04-13",
"currency": "KRW",
"price_unit": "천원",
"signature_poke": [
{
"id": 2,
"name": "갈릭 쉬림프 포케",
"price": 11.5,
"tags": [
"BEST"
]
},
{
"id": 7,
"name": "아보카도 포케",
"price": 10.5,
"tags": [
"VEGAN"
]
}
],
"sets": [
{
"name": "1인 포케+스프 세트",
"items": "포케 + 스프",
"price": 13.5,
"price_note": "13.5~"
},
{
"name": "1인 혼밥 든든세트",
"items": "포케 + 스프 + 음료",
"price": 15.5,
"price_note": "15.5~"
}
],
"addons": [
{
"name": "아보카도",
"price": 3.5
},
{
"name": "메밀면",
"price": 1.5
}
]
},
"get_shop_info": {
"name": "올라포케 역삼점",
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
"hours": {
"weekday": "10:30 - 20:30",
"break_time": "15:00 - 17:00",
"weekend": "영업시간 네이버 스마트플레이스 확인"
},
"delivery_radius_km": 3,
"group_order_url": "",
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
"delivery_apps": [
"배달의민족",
"쿠팡이츠",
"요기요"
]
},
"enter_event_success_contract": {
"required_fields": [
"message",
"code",
"next_action"
],
"accepts": [
"01012345678",
"010-1234-5678"
],
"stores_name_or_email": false
},
"enter_event_invalid_phone": {
"error": "phone_format",
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
}
}

View file

@ -254,10 +254,34 @@ test("repository docs advertise the used-car-price-search skill", () => {
assert.match(install, /--skill used-car-price-search/);
assert.match(
install,
/npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
/npm install -g kordoc pdfjs-dist kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp/,
);
});
test("repository docs advertise the public-restroom-nearby skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "public-restroom-nearby.md");
const skillPath = path.join(repoRoot, "public-restroom-nearby", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/public-restroom-nearby.md to exist");
assert.ok(fs.existsSync(skillPath), "expected public-restroom-nearby/SKILL.md to exist");
assert.match(readme, /\| 근처 공중화장실 찾기 \|/);
assert.match(readme, /\[근처 공중화장실 찾기 가이드\]\(docs\/features\/public-restroom-nearby\.md\)/);
assert.match(install, /--skill public-restroom-nearby/);
assert.match(install, /npm install -g .*public-restroom-nearby/);
});
test("public-restroom-nearby docs describe the maxDistanceMeters distance cap", () => {
const featureDoc = read(path.join("docs", "features", "public-restroom-nearby.md"));
const packageReadme = read(path.join("packages", "public-restroom-nearby", "README.md"));
assert.match(featureDoc, /maxDistanceMeters/);
assert.match(featureDoc, /100m/);
assert.match(packageReadme, /maxDistanceMeters/);
assert.match(packageReadme, /100m/);
});
test("repository docs advertise the lck-analytics skill and package", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1146,6 +1170,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace market-kurly-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
});
@ -1825,6 +1850,237 @@ test("repository docs advertise the real-estate-search skill and proxy-based app
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "real-estate-search")), false);
});
test("repository docs advertise the korean-scholarship-search skill and official-source workflow", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-scholarship-search.md");
const featureDoc = read(path.join("docs", "features", "korean-scholarship-search.md"));
const skillPath = path.join(repoRoot, "korean-scholarship-search", "SKILL.md");
const skill = read(path.join("korean-scholarship-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
const searchCluesPath = path.join(repoRoot, "korean-scholarship-search", "references", "search-clues.md");
const reportFormatPath = path.join(repoRoot, "korean-scholarship-search", "references", "report-format.md");
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-scholarship-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-scholarship-search/SKILL.md to exist");
assert.ok(fs.existsSync(helperPath), "expected korean-scholarship-search/scripts/scholarship_filter.py to exist");
assert.ok(fs.existsSync(plannerPath), "expected korean-scholarship-search/scripts/university_search_plan.py to exist");
assert.ok(fs.existsSync(searchCluesPath), "expected korean-scholarship-search/references/search-clues.md to exist");
assert.ok(fs.existsSync(reportFormatPath), "expected korean-scholarship-search/references/report-format.md to exist");
assert.match(readme, /\| 장학금 검색 및 조회 \|/);
assert.match(readme, /\[장학금 검색 및 조회 가이드\]\(docs\/features\/korean-scholarship-search\.md\)/);
assert.match(install, /--skill korean-scholarship-search/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /장학금 검색 및 조회/);
assert.match(doc, /kosaf\.go\.kr/);
assert.match(doc, /\*\.ac\.kr/);
assert.match(doc, /전국 대학교|전국 대학/);
assert.match(doc, /공식 공고 우선/);
assert.match(doc, /학자금 지원구간/);
assert.match(doc, /scholarship_filter\.py/);
assert.match(doc, /university_search_plan\.py/);
assert.match(doc, /학과/);
assert.match(doc, /외부 장학 추천|등록금 감면|생활비 지원/);
}
assert.match(sources, /한국장학재단 학자금 지원구간 산정절차/);
assert.match(sources, /한국장학재단 푸른등대 기부장학금/);
assert.match(sources, /삼성꿈장학재단/);
assert.match(roadmap, /장학금 검색 및 조회 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-scholarship-search")),
"expected no repo workspace to be added for korean-scholarship-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-scholarship-search")), false);
});
test("korean-scholarship-search helper filters normalized records, renders reports, and returns eligibility verdicts", () => {
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-scholarship-"));
try {
const inputPath = path.join(tempRoot, "scholarships.json");
fs.writeFileSync(
inputPath,
JSON.stringify(
[
{
name: "테스트재단 생활비 장학금",
organization: { name: "테스트재단", type: "foundation" },
source_url: "https://foundation.example.com/notice/1",
apply_url: "https://foundation.example.com/apply/1",
amount: { text: "학기당 250만 원", per_semester_krw: 2500000, category: "living" },
eligibility: {
student_levels: ["undergraduate"],
school_kinds: ["university"],
school_names: ["서울대학교", "연세대학교"],
department_names: ["컴퓨터공학부"],
grade_years: [2, 3, 4],
gpa_min: 3.2,
income_band_min: 0,
income_band_max: 6,
},
deadline: { start: "2026-04-01", end: "2026-04-16" },
},
{
name: "교내 성적우수 장학금",
organization: { name: "샘플대학교", type: "school" },
source_url: "https://sample.ac.kr/notice/2",
apply_url: "https://sample.ac.kr/apply/2",
amount: { text: "등록금 전액", category: "tuition" },
eligibility: {
student_levels: ["undergraduate"],
school_kinds: ["university"],
school_names: ["샘플대학교"],
grade_years: [1],
gpa_min: 4.0,
income_band_min: 0,
income_band_max: 10,
},
deadline: { start: "2026-05-01", end: "2026-05-20" },
},
],
null,
2,
),
"utf8",
);
const helpText = childProcess.execFileSync("python3", [helperPath, "--help"], {
cwd: repoRoot,
encoding: "utf8",
});
assert.match(helpText, /Filter normalized Korean scholarship records/);
assert.match(helpText, /\bfilter\b/);
assert.match(helpText, /\beligibility\b/);
assert.match(helpText, /\breport\b/);
const plannerHelpText = childProcess.execFileSync("python3", [plannerPath, "--help"], {
cwd: repoRoot,
encoding: "utf8",
});
assert.match(plannerHelpText, /nationwide/i);
assert.match(plannerHelpText, /school-name/);
const filtered = JSON.parse(
childProcess.execFileSync(
"python3",
[
helperPath,
"filter",
"--input",
inputPath,
"--org-type",
"foundation",
"--student-level",
"undergraduate",
"--department-name",
"컴퓨터공학부",
"--income-band",
"4",
"--min-amount",
"2000000",
"--today",
"2026-04-14",
"--deadline-within-days",
"7",
],
{ cwd: repoRoot, encoding: "utf8" },
),
);
assert.equal(filtered.total, 1);
assert.equal(filtered.items[0].name, "테스트재단 생활비 장학금");
assert.equal(filtered.items[0]._match.amount_krw, 2500000);
assert.equal(filtered.items[0]._match.deadline.status, "open");
assert.equal(filtered.items[0]._match.deadline.days_until_end, 2);
const report = childProcess.execFileSync(
"python3",
[
helperPath,
"report",
"--input",
inputPath,
"--today",
"2026-04-14",
"--only-open-now",
],
{ cwd: repoRoot, encoding: "utf8" },
);
assert.match(report, /# 장학금 검색 및 조회 리포트/);
assert.match(report, /## 지금 지원 가능/);
assert.match(report, /테스트재단 생활비 장학금/);
assert.match(report, /D-2/);
const plannerPayload = JSON.parse(
childProcess.execFileSync(
"python3",
[
plannerPath,
"--school-name",
"부산대학교",
"--department",
"컴퓨터공학과",
"--year",
"2026",
],
{ cwd: repoRoot, encoding: "utf8" },
),
);
assert.equal(plannerPayload.scope, "school");
assert.equal(plannerPayload.school_name, "부산대학교");
assert.match(plannerPayload.search_queries.join("\n"), /컴퓨터공학과/);
const nationwidePayload = JSON.parse(
childProcess.execFileSync(
"python3",
[plannerPath, "--nationwide", "--year", "2026"],
{ cwd: repoRoot, encoding: "utf8" },
),
);
assert.equal(nationwidePayload.scope, "nationwide-universities");
assert.match(nationwidePayload.search_queries.join("\n"), /site:\*\.ac\.kr 2026 장학 공고/);
const eligibility = JSON.parse(
childProcess.execFileSync(
"python3",
[
helperPath,
"eligibility",
"--input",
inputPath,
"--school-name",
"서울대학교",
"--student-level",
"undergraduate",
"--grade-year",
"2",
"--gpa",
"3.5",
"--income-band",
"4",
],
{ cwd: repoRoot, encoding: "utf8" },
),
);
assert.equal(eligibility.total, 2);
assert.equal(eligibility.results[0].status, "eligible");
assert.equal(eligibility.results[1].status, "not_eligible");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
const skill = read(path.join("real-estate-search", "SKILL.md"));
@ -2245,3 +2501,74 @@ test("docs/setup.md and k-skill-setup document hosted school lunch proxy flow",
"client secrets example must not encourage KEDU_INFO_KEY (proxy server only)",
);
});
test("repository docs advertise the hola-poke-yeoksam skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "hola-poke-yeoksam.md");
const skillPath = path.join(repoRoot, "hola-poke-yeoksam", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hola-poke-yeoksam.md to exist");
assert.ok(fs.existsSync(skillPath), "expected hola-poke-yeoksam/SKILL.md to exist");
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
assert.match(readme, /\| 올라포케 역삼 포케 \|/);
assert.match(readme, /\[올라포케 역삼 포케 가이드\]\(docs\/features\/hola-poke-yeoksam\.md\)/);
assert.match(install, /--skill hola-poke-yeoksam/);
assert.match(sources, /mnspkm\/hola-poke-yeoksam-skill/);
assert.match(roadmap, /올라포케 역삼 포케 스킬 출시/);
});
test("hola-poke-yeoksam docs pin the verified remote MCP contract snapshot and phone-only event flow", () => {
const fixture = readJson(path.join("scripts", "fixtures", "hola-poke-yeoksam-contract-smoke.json"));
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
const snapshotLabels = [
["initialize 결과", "initialize", "initialize snapshot"],
["tools/list 결과", "tools_list", "tools/list snapshot"],
["get_menu 구조 예시", "get_menu", "get_menu snapshot"],
["get_shop_info 구조 예시", "get_shop_info", "get_shop_info snapshot"],
["enter_event(phone='010-12') 예시", "enter_event_invalid_phone", "invalid-phone snapshot"],
["enter_event 성공 응답 필수 필드", "enter_event_success_contract", "success-contract snapshot"],
];
assert.match(skill, /^name: hola-poke-yeoksam$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /올라포케 역삼점/);
assert.match(doc, /get_menu/);
assert.match(doc, /get_shop_info/);
assert.match(doc, /enter_event/);
assert.match(doc, /이름(?:·|\/)?이메일.*받지 않/);
assert.match(doc, /already_entered_today/);
assert.match(doc, /message.*글자 그대로/);
assert.match(doc, /주문\/결제\/배달앱 자동화는 하지 않/);
assert.match(doc, /성공 경로는.*(?:fixture|스냅샷|recorded)/i);
assert.match(doc, /라이브 스모크.*invalid-phone|invalid-phone.*라이브 스모크/i);
assert.match(doc, /01012345678|010-1234-5678/);
assert.match(doc, /hola-poke-yeoksam-skill\.onrender\.com\/mcp/);
for (const [label, key, message] of snapshotLabels) {
assert.equal(
findJsonFenceTextAfterLabel(doc, label),
JSON.stringify(fixture[key], null, 2),
`${message} must stay byte-aligned with the checked-in fixture`,
);
}
}
assert.deepEqual(
fixture.tools_list.tools.map((tool) => tool.name),
["get_menu", "get_shop_info", "enter_event"],
"tools/list fixture must pin the expected remote tool roster",
);
assert.equal(fixture.get_shop_info.group_order_url, "");
assert.match(fixture.get_shop_info.group_order_note, /단체주문|네이버페이/);
assert.deepEqual(fixture.enter_event_success_contract.required_fields, ["message", "code", "next_action"]);
assert.equal(fixture.enter_event_invalid_phone.error, "phone_format");
assert.match(fixture.enter_event_invalid_phone.message, /01012345678|010-1234-5678/);
});

View file

@ -38,6 +38,7 @@ done < <(
find "$root" -mindepth 1 -maxdepth 1 -type d \
! -name .git \
! -name .github \
! -name .codex \
! -name .claude \
! -name .omx \
! -name .ouroboros \