mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
commit
f65262b783
40 changed files with 4525 additions and 38 deletions
5
.changeset/bright-penguins-tickle.md
Normal file
5
.changeset/bright-penguins-tickle.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"public-restroom-nearby": minor
|
||||
---
|
||||
|
||||
Add the first official public-restroom nearby lookup package and skill/docs set.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
266
docs/features/hola-poke-yeoksam.md
Normal file
266
docs/features/hola-poke-yeoksam.md
Normal 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`
|
||||
|
|
@ -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'
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
156
docs/features/korean-scholarship-search.md
Normal file
156
docs/features/korean-scholarship-search.md
Normal 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점 만점인지 공고마다 다르므로 원문 기준을 같이 적는다.
|
||||
- 장학금은 학기별/연도별로 반복되더라도 조건과 마감일이 달라질 수 있으니, 과거 공고를 최신 공고로 착각하지 않는다.
|
||||
- 학자금 지원구간 관련 설명은 한국장학재단 기준을 우선 참고한다.
|
||||
|
|
@ -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`
|
||||
|
||||
## 참고 링크
|
||||
|
|
|
|||
145
docs/features/public-restroom-nearby.md
Normal file
145
docs/features/public-restroom-nearby.md
Normal 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 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.
|
||||
|
|
@ -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`가 배포된 경우 사용자 시크릿 불필요)
|
||||
|
||||
관련 문서:
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@
|
|||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 장학금 검색 및 조회 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 근처 공중화장실 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
|
|
@ -31,6 +33,7 @@
|
|||
- 다이소 상품 조회 스킬 출시
|
||||
- 마켓컬리 상품 조회 스킬 출시
|
||||
- 올리브영 검색 스킬 출시
|
||||
- 올라포케 역삼 포케 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 번개장터 검색 스킬 출시
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
257
hola-poke-yeoksam/SKILL.md
Normal 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번만 응모한다.
|
||||
329
korean-scholarship-search/SKILL.md
Normal file
329
korean-scholarship-search/SKILL.md
Normal 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` 는 비워둔다.
|
||||
40
korean-scholarship-search/references/report-format.md
Normal file
40
korean-scholarship-search/references/report-format.md
Normal 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
|
||||
|
||||
- 한 항목에 문장을 너무 길게 쓰지 않는다.
|
||||
- 핵심 조건은 `/` 로 짧게 끊는다.
|
||||
- 금액이 불명확하면 `금액 미공개` 라고 적고 공고 원문 링크를 남긴다.
|
||||
- 확실하지 않은 정보는 `미확인` 으로 표시한다.
|
||||
61
korean-scholarship-search/references/school-discovery.md
Normal file
61
korean-scholarship-search/references/school-discovery.md
Normal 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. 정리 원칙
|
||||
|
||||
- `교내 장학`
|
||||
- `학과 장학`
|
||||
- `외부 추천 장학`
|
||||
|
||||
이 세 묶음으로 나눠 보여주면 가독성이 좋아진다.
|
||||
58
korean-scholarship-search/references/search-clues.md
Normal file
58
korean-scholarship-search/references/search-clues.md
Normal 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 지역인재 장학금`
|
||||
67
korean-scholarship-search/references/source-patterns.md
Normal file
67
korean-scholarship-search/references/source-patterns.md
Normal 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
|
||||
|
||||
- 비공식 요약 페이지는 링크 탐색용으로만 본다.
|
||||
- 최종 결과에는 공식 공고 링크를 반드시 포함한다.
|
||||
- 공고 날짜가 오래됐으면 최신 회차/학기 공고를 다시 찾는다.
|
||||
811
korean-scholarship-search/scripts/scholarship_filter.py
Normal file
811
korean-scholarship-search/scripts/scholarship_filter.py
Normal 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())
|
||||
224
korean-scholarship-search/scripts/test_scholarship_filter.py
Normal file
224
korean-scholarship-search/scripts/test_scholarship_filter.py
Normal 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()
|
||||
148
korean-scholarship-search/scripts/university_search_plan.py
Normal file
148
korean-scholarship-search/scripts/university_search_plan.py
Normal 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())
|
||||
|
|
@ -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
21
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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} 의 일별 시세를 찾지 못했습니다. 휴장일이거나 데이터가 아직 없을 수 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
7
packages/public-restroom-nearby/CHANGELOG.md
Normal file
7
packages/public-restroom-nearby/CHANGELOG.md
Normal 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.
|
||||
138
packages/public-restroom-nearby/README.md
Normal file
138
packages/public-restroom-nearby/README.md
Normal 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?)`
|
||||
32
packages/public-restroom-nearby/package.json
Normal file
32
packages/public-restroom-nearby/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
220
packages/public-restroom-nearby/src/index.js
Normal file
220
packages/public-restroom-nearby/src/index.js
Normal 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
|
||||
};
|
||||
408
packages/public-restroom-nearby/src/parse.js
Normal file
408
packages/public-restroom-nearby/src/parse.js
Normal 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(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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
|
||||
};
|
||||
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"summary": {
|
||||
"confirm_id": "1001",
|
||||
"name": "광화문",
|
||||
"address": {
|
||||
"disp": "서울특별시 종로구 세종대로 172"
|
||||
},
|
||||
"point": {
|
||||
"lat": 37.57103,
|
||||
"lon": 126.97679
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal file
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal 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>
|
||||
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal file
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal 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
|
||||
|
283
packages/public-restroom-nearby/test/index.test.js
Normal file
283
packages/public-restroom-nearby/test/index.test.js
Normal 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");
|
||||
}
|
||||
};
|
||||
}
|
||||
95
public-restroom-nearby/SKILL.md
Normal file
95
public-restroom-nearby/SKILL.md
Normal 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 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.
|
||||
138
scripts/fixtures/hola-poke-yeoksam-contract-smoke.json
Normal file
138
scripts/fixtures/hola-poke-yeoksam-contract-smoke.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue