Merge pull request #302 from NomaDamas/dev

Release: dev → main
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-06-06 12:07:27 +09:00 committed by GitHub
commit 1efef285ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2467 additions and 703 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": patch
---
Archive unsupported Naver Map and Blue Ribbon proxy support. The proxy no longer registers `/v1/naver-map/*` or `/v1/blue-ribbon/nearby`, and the unsupported skill/package code is preserved under `legacy/` for a future revival if operational blockers are resolved.

View file

@ -0,0 +1,13 @@
{
"name": "k-skill",
"owner": {
"name": "NomaDamas"
},
"plugins": [
{
"name": "k-skill",
"source": "./",
"description": "한국인을 위한 90+ Agent Skill 번들 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화"
}
]
}

101
.claude-plugin/plugin.json Normal file
View file

@ -0,0 +1,101 @@
{
"name": "k-skill",
"description": "한국인을 위한 90+ Agent Skill 모음 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화",
"version": "1.0.0",
"author": {
"name": "NomaDamas"
},
"homepage": "https://github.com/NomaDamas/k-skill",
"repository": "https://github.com/NomaDamas/k-skill",
"license": "MIT",
"skills": [
"./bunjang-search",
"./catchtable-sniper",
"./cheap-gas-nearby",
"./corporate-registration-consulting",
"./coupang-product-search",
"./court-auction-notice-search",
"./daangn-cars-search",
"./daangn-jobs-search",
"./daangn-realty-search",
"./daangn-used-goods-search",
"./daishin-report-search",
"./daiso-product-search",
"./danawa-price-search",
"./delivery-tracking",
"./donation-place-search",
"./emergency-room-beds",
"./express-bus-booking",
"./fine-dust-location",
"./flight-ticket-search",
"./foresttrip-vacancy",
"./gangnamunni-clinic-search",
"./geeknews-search",
"./gongsijiga-search",
"./han-river-water-level",
"./hipass-receipt",
"./hola-poke-yeoksam",
"./household-waste-info",
"./hwp",
"./intercity-bus-booking",
"./iros-registry-automation",
"./joseon-sillok-search",
"./k-dart",
"./k-schoollunch-menu",
"./k-skill-cleaner",
"./k-skill-setup",
"./kakao-bar-nearby",
"./kakao-map",
"./kakaotalk-mac",
"./kbl-results",
"./kbo-results",
"./kleague-results",
"./korea-weather",
"./korean-character-count",
"./korean-cinema-search",
"./korean-jangbu-for",
"./korean-law-search",
"./korean-marathon-schedule",
"./korean-middle-korean",
"./korean-patent-search",
"./korean-privacy-terms",
"./korean-scholarship-search",
"./korean-slang-writing",
"./korean-spell-check",
"./korean-stock-search",
"./korean-transit-route",
"./kosis-stats",
"./kstartup-search",
"./ktx-booking",
"./lck-analytics",
"./lh-notice-search",
"./library-book-search",
"./local-election-candidate-search",
"./lotto-results",
"./market-kurly-search",
"./mfds-drug-safety",
"./mfds-food-safety",
"./myrealtrip-search",
"./naver-blog-research",
"./naver-news-search",
"./naver-shopping-search",
"./nts-business-registration",
"./ohou-today-deal",
"./olive-young-search",
"./parking-lot-search",
"./public-restroom-nearby",
"./real-estate-search",
"./rhwp-advanced",
"./rhwp-edit",
"./seoul-bike",
"./seoul-density",
"./seoul-subway-arrival",
"./sh-notice-search",
"./srt-booking",
"./subway-lost-property",
"./ticket-availability",
"./toss-securities",
"./used-car-price-search",
"./zipcode-search"
]
}

View file

@ -79,7 +79,6 @@ jobs:
SEOUL_OPEN_API_KEY=SEOUL_OPEN_API_KEY:latest
HRFCO_OPEN_API_KEY=HRFCO_OPEN_API_KEY:latest
OPINET_API_KEY=OPINET_API_KEY:latest
BLUE_RIBBON_SESSION_ID=BLUE_RIBBON_SESSION_ID:latest
DATA_GO_KR_API_KEY=DATA_GO_KR_API_KEY:latest
KEDU_INFO_KEY=KEDU_INFO_KEY:latest
DATA4LIBRARY_AUTH_KEY=DATA4LIBRARY_AUTH_KEY:latest
@ -126,15 +125,10 @@ jobs:
if not data.get('ok'):
print('Health response is not ok:', data)
sys.exit(1)
# naverMapConfigured is expected to be false until NCP Maps keys are provisioned
KNOWN_UNCONFIGURED = {'naverMapConfigured'}
missing = [k for k, v in data.get('upstreams', {}).items() if k.endswith('Configured') and v is not True and k not in KNOWN_UNCONFIGURED]
missing = [k for k, v in data.get('upstreams', {}).items() if k.endswith('Configured') and v is not True]
if missing:
print('Upstreams not configured:', missing)
sys.exit(1)
skipped = [k for k in KNOWN_UNCONFIGURED if not data.get('upstreams', {}).get(k)]
if skipped:
print(f'Note: {skipped} not yet configured (expected, see docs/features/naver-map-route.md)')
print('Health OK. All upstreams configured.')
"

1
.gitignore vendored
View file

@ -9,5 +9,6 @@ node_modules/
__pycache__/
dist/
.sisyphus/
.omo/
.agents/

View file

@ -23,7 +23,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 할 수 있는 일 | 스킬 이름 | 설명 | 사용자 로그인 | 문서 |
| --- | --- | --- | --- | --- |
| SRT 예매 | `srt-booking` | SRT 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 호차별 좌석번호·콘센트 좌석 확인, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
@ -33,7 +33,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 카카오맵 장소·자동차 길찾기 | `kakao-map` | Kakao Local 키워드/카테고리/좌표↔주소 변환 + Kakao Mobility 자동차 길찾기(거리·소요시간·통행료·예상 택시요금) | 불필요 | [카카오맵 가이드](docs/features/kakao-map.md) |
| 네이버맵 자동차 길찾기 | `naver-map-route` | `/route`·`/이동루트` 수동 입력 기반 NCP Maps Directions 5 자동차 경로, 지오코딩, 역지오코딩 조회 (mock 기본, live opt-in) | 불필요 | [네이버맵 길찾기 가이드](docs/features/naver-map-route.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
@ -82,10 +81,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 레이아웃·IR 디버깅 | `rhwp-advanced` | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 강남언니 병원 조회 | `gangnamunni-clinic-search` | 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 링크 조회 | 불필요 | [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
@ -112,13 +110,16 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
>
> **블루리본 측이 `www.bluer.co.kr` 에 자동화 접근 전면 차단을 적용해 스킬이 더 이상 동작하지 않습니다.**
>
> - 브라우저·`curl`·Playwright·TLS impersonation 등 가능한 우회를 모두 검증했지만 nginx 단에서 403이 반환되며, 같은 가구 공인 IP로도 특정 장비만 차단되는 상황이 관측되었습니다.
> - 유료 회원권 보유자도 접근이 막히는 사례가 확인되었습니다. 복구 여부와 일정은 블루리본 측 정책에 전적으로 달려 있어 이 레포에서 대응할 수 있는 범위를 벗어났습니다.
> - 해당 스킬 디렉토리(`blue-ribbon-nearby/`)와 관련 프록시 라우트는 히스토리 보존을 위해 당분간 남겨두지만, **새 프로젝트에서는 해당 스킬을 사용하지 마세요.** 차단이 해제되는 날이 오면 이 안내를 제거하고 재검증하겠습니다.
## Claude Code 플러그인으로 설치
[Claude Code](https://claude.com/claude-code)에서는 마켓플레이스로 전체 스킬을 한 번에 설치할 수 있습니다.
```
/plugin marketplace add NomaDamas/k-skill
/plugin install k-skill@k-skill
```
설치하면 스킬이 `/k-skill:<스킬 이름>` 네임스페이스로 호출됩니다 (예: `/k-skill:lotto-results`). 개별 디렉토리를 직접 복사하는 수동 설치나 다른 에이전트 설치는 [설치 방법](docs/install.md)을 참고하세요.
## 처음 시작하는 순서
@ -154,7 +155,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
- [카카오맵 가이드](docs/features/kakao-map.md)
- [네이버맵 길찾기 가이드](docs/features/naver-map-route.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
@ -202,7 +202,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [HWP 문서 조회/변환](docs/features/hwp.md)
- [HWP 문서 편집](docs/features/rhwp-edit.md)
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)

View file

@ -13,7 +13,7 @@
| 유형 | 설명 | 예시 |
|------|------|------|
| **SKILL.md 전용** | 문서만으로 동작 (에이전트가 bash/python 직접 실행) | `kakaotalk-mac`, `srt-booking` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `blue-ribbon-nearby` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `daiso-product-search` |
| **프록시 경유** | `k-skill-proxy`가 upstream API 키를 보관하고 HTTP로 중계 | `seoul-subway-arrival`, `fine-dust-location` |
| **Python 스크립트** | `scripts/`의 Python 파일 직접 실행 | `korean-spell-check`, `sillok-search` |

View file

@ -117,7 +117,7 @@ gcloud projects add-iam-policy-binding "$PROJECT_ID" \
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
for s in \
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_KEY \
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET; do
gcloud secrets add-iam-policy-binding "$s" \
@ -157,7 +157,7 @@ gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \
```bash
KEYS=(
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_KEY
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET
)

View file

@ -80,6 +80,7 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유
3. helper로 read-only 월별예약조회 endpoint를 실행한다.
4. helper가 로그인 세션, CSRF, 공식 휴양림 ID 목록을 확보한다.
5. 날짜, 휴양림명, 객실/시설명, 숙박/야영 구분, 정원 중심으로 요약한다.
6. 응답 정제: API가 `srchDate` 기준 최대 5일 윈도우를 반환할 수 있어 helper가 요청 범위 밖 `useDt`, 운영자 보유분("예비" 포함 객실), 같은 객실 중복 행을 자동 제거한다.
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 구현은 로그인 세션/CSRF 확보를 필수 전제로 둔다.
@ -136,6 +137,8 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --date
- aggressive polling은 피한다.
- 조회 결과는 시점 차이로 숲나들e 화면과 달라질 수 있다.
- 로그인 실패 시 계정 정보 또는 숲나들e 정책 변경을 먼저 확인한다.
- API가 요청 날짜보다 넓은 5일 윈도우를 반환해도 출력에는 요청 범위(`today``last_day`) 안의 행만 포함된다.
- "예비" 표기가 있는 객실은 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 결과에서 자동 제외된다.
## 흔한 문제 해결

View file

@ -27,7 +27,7 @@ python3 scripts/k_skill_cleaner.py \
--skills-root . \
--scan-default-logs \
--days 90 \
--never-use blue-ribbon-nearby,lotto-results \
--never-use lotto-results \
--keep k-skill-setup,k-skill-cleaner
```

View file

@ -4,6 +4,8 @@
- KTX/Korail 열차 조회
- 좌석 가능 여부 확인
- 호차별 남은 좌석번호 확인
- 콘센트 꿀팁 좌석 필터링
- 예약 진행
- 예약 내역 확인
- 예약 취소
@ -35,6 +37,7 @@
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 좌석 우선
- 조회 결과에서 복사한 `train_id`
## 왜 helper 를 쓰는가
@ -54,8 +57,9 @@
2. `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` 가 없으면 credential resolution order에 따라 확보한다.
3. helper 로 먼저 열차를 조회한다.
4. 후보 열차의 `index`, `train_id`, 출발/도착 시각, KTX 여부, 좌석 여부를 보여준다.
5. 대상 열차가 명확할 때만 예약한다.
6. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
5. 사용자가 좌석번호, 호차별 잔여석, 콘센트 꿀팁 좌석을 물으면 `seats` 로 상세 좌석을 먼저 확인한다.
6. 대상 열차가 명확할 때만 예약한다.
7. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
## 예시
@ -69,6 +73,43 @@ python3 scripts/ktx_booking.py search 서울 부산 20260328 090000 --limit 5
응답 JSON 의 `train_id` 는 검색 시점의 정확한 열차를 가리키는 stable selector 다. 예약할 때는 이 값을 그대로 복사해서 쓴다. 같은 열차가 더 이상 조회되지 않으면 helper 가 실패하고 새로 조회하게 만든다.
상세 좌석 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
```
남은 좌석번호만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안에서는 콘센트 힌트가 있는 좌석을 먼저, 같은 조건에서는 순방향 좌석을 먼저 반환한다.
특정 호차의 남은 좌석만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 좌석부터 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
특실 좌석을 확인하려면 `--room special`, KTX 외 열차를 조회했다면 `search` 와 같은 `--train-type` 을 함께 넘긴다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--train-id <train_id> \
--train-type itx-cheongchun \
--available-only
```
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
예약:
```bash

View file

@ -86,7 +86,6 @@ npx --yes skills add <owner/repo> \
--skill olive-young-search \
--skill korean-cinema-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \

View file

@ -32,7 +32,6 @@
- 근처 가장 싼 주유소 찾기 스킬 출시
- 근처 공중화장실 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시

View file

@ -10,7 +10,6 @@
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다. 같은 host의 `/search/keyword.json`, `/search/category.json`, `/geo/coord2address.json`, `/geo/coord2regioncode.json``kakao-map` 스킬용 `/v1/kakao-map/*` 라우트가 직접 중계한다.
- Kakao Mobility Directions endpoint: https://apis-navi.kakaomobility.com/v1/directions — `k-skill-proxy``/v1/kakao-mobility/directions`가 운영자 `KAKAO_REST_API_KEY``Authorization: KakaoAK ...` 헤더로 주입해 자동차 길찾기를 중계한다.
- NAVER Cloud Platform Maps Console: https://www.ncloud.com/product/applicationService/maps — `k-skill-proxy``/v1/naver-map/directions`, `/v1/naver-map/geocode`, `/v1/naver-map/reverse-geocode`가 각각 `https://maps.apigw.ntruss.com/map-direction/v1/driving`, `/map-geocode/v2/geocode`, `/map-reversegeocode/v2/gc` 로 중계한다. 운영자 `NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET` 가 필요하다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do
@ -156,9 +155,6 @@
- 당근알바 검색 Remix data route: https://www.daangn.com/kr/jobs/?_data=routes/kr.jobs._index
- 당근중고차 검색 Remix data route: https://www.daangn.com/kr/cars/?_data=routes/kr.cars._index
- 당근부동산 상세 페이지: https://realty.daangn.com/articles/<id>
- 블루리본 메인: https://www.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
- 조선왕조실록 메인: https://sillok.history.go.kr
@ -210,6 +206,14 @@
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
- 공공데이터포털 데이터셋(창업진흥원 K-Startup 조회서비스): https://www.data.go.kr/data/15125364/openapi.do
- K-Startup Open API base URL: https://apis.data.go.kr/B552735/kisedKstartupService01 — `k-skill-proxy``/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics` 가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01` 로 중계한다 (returnType=json 고정, ServiceKey 서버 측 주입)
- K-Startup 공식 포털: https://www.k-startup.go.kr — 응답의 `detl_pg_url` 가 가리키는 사용자 진입점
### 지자체/유관기관 참고 사이트 (보조 소스)
- **서울시 창업플러스**: https://seoulstartup.go.kr
- **경기도 창업진흥원**: https://g-startup.kr
- **부산시 스타트업 허브**: https://busanstartup.kr
- **광주창업파크**: https://startup.gwangju.kr
- **대구창업진흥원**: https://daegu-startup.kr
- **중소기업진흥공단**: https://smbs.or.kr
- **기술보증기금**: https://koreatech.or.kr
- **KOTRA**: https://www.kotra.or.kr
- **중소벤처기업금융공단**: https://www.sbc.or.kr

View file

@ -100,6 +100,8 @@ python3 -m playwright install chromium
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 helper는 로그인 세션/CSRF 확보를 필수 전제로 둔다.
API는 `srchDate` 단일 일자만 요청해도 응답에 5일 윈도우를 포함할 수 있다. helper는 요청 범위(`today``last_day`) 밖 `useDt` 행을 자동 제거하므로 사용자에게는 요청한 날짜의 빈자리만 노출된다.
전체 자연휴양림에서 특정 날짜 조회:
```bash
@ -138,6 +140,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
결과가 없으면 "조회 시점 기준 예약 가능 객실 없음"이라고 말한다. 실제 예약 가능 여부는 숲나들e 화면에서 재확인될 수 있음을 덧붙인다.
`goodsNm`에 "예비"가 포함된 객실은 운영자가 보유하는 내부용 자리로, 사용자 예약 화면에는 노출되지 않는다. helper는 이 객실들을 결과에서 자동 제외한다. 같은 `(휴양림, 날짜, 객실명)` 조합의 중복 행도 dedup된다.
## Done when
- 요청 날짜와 조회 범위가 명확하다.
@ -152,6 +156,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
- Playwright browser 미설치: `python3 -m playwright install chromium`
- fetch failure 일부 발생: 결과와 실패 개수를 함께 보고하고, 필요하면 `--refresh-session` 으로 1회 재조회
- 숲나들e 표면 변경: helper의 login/session bootstrap 또는 parser 점검 필요
- "(예비)" 객실이 결과에 안 나옴: 정상 동작이다. 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 의도적으로 제외된다.
- 사용자 화면 객실 수와 helper 결과가 다름: 같은 객실의 중복 행이 dedup되었거나, 요청 범위 밖 `useDt`가 제거됐을 가능성이 높다. raw API 응답을 확인하려면 helper 로직을 우회해서 직접 호출 필요.
## Maintainer review notes

View file

@ -30,6 +30,7 @@ DEFAULT_CONCURRENCY = 4
MAX_CONCURRENCY = 5
DEFAULT_WEEK_RANGE = 1
CATEGORY_CODES = {"01", "02"}
RESERVE_ROOM_MARKER = "예비"
@dataclass
@ -159,8 +160,8 @@ def check_dependencies(*, launch_browser: bool = True) -> None:
if sys.version_info < (3, 9):
raise SystemExit("python 3.9+ is required")
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright"
@ -209,8 +210,8 @@ def save_session_cache(path: Path, session: Session) -> None:
def bootstrap_session(*, forest_id: str, forest_pw: str, ttl_sec: int = 600) -> Session:
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright "
@ -379,7 +380,18 @@ def fetch_one(
def is_available(row: dict[str, Any]) -> bool:
return row.get("rsrvtAvail") == "Y" and row.get("rsrvtCnt") == 0
count_value = row.get("rsrvtCnt")
if count_value is None:
return False
try:
reserved_count = int(count_value)
except ValueError:
return False
return row.get("rsrvtAvail") == "Y" and reserved_count == 0
def is_reserve_room(row: dict[str, Any]) -> bool:
return RESERVE_ROOM_MARKER in (row.get("goodsNm") or "")
def normalize_row(row: dict[str, Any], forests: dict[str, str]) -> dict[str, Any]:
@ -443,11 +455,27 @@ def collect_results(
for row in data:
if not is_available(row):
continue
if is_reserve_room(row):
continue
use_dt = row.get("useDt") or ""
if use_dt < today or use_dt > last_day:
continue
normalized = normalize_row(row, session.forests)
normalized["source_category"] = category
if date_filter is not None and normalized["use_dt"] not in date_filter:
continue
rows.append(normalized)
seen: set[tuple[str, str, str, str]] = set()
deduped: list[dict[str, Any]] = []
for row in rows:
key = (row["forest_id"], row["use_dt"], row["source_category"], row["name"])
if key in seen:
continue
seen.add(key)
deduped.append(row)
rows = deduped
grouped: dict[str, dict[str, list[dict[str, Any]]]] = {}
for row in sorted(rows, key=lambda item: (item["forest"], item["use_dt"], item["name"])):
grouped.setdefault(row["forest"], {}).setdefault(row["use_dt"], []).append(row)

View file

View file

@ -0,0 +1,167 @@
[
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A2",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "고로쇠1",
"goodsId": "GID-C",
"insttArea": "60㎡",
"mxmmAccptCnt": "10",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260514",
"dywkDtTpcd": "목",
"goodsNm": "동백3",
"goodsId": "GID-D",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "중산막2",
"goodsId": "GID-E",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "동백3",
"goodsId": "GID-F",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,62 @@
[
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "206호 쑥부쟁이방",
"goodsId": "GID-G1",
"insttArea": "37㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "201호 배꽃방(예비용)",
"goodsId": "GID-G2",
"insttArea": "30㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "편백나무2호(예비용)",
"goodsId": "GID-G3",
"insttArea": "28㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "은행나무방(예비용)",
"goodsId": "GID-G4",
"insttArea": "22㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "트리하우스",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,348 @@
import importlib.util
import io
import json
import sys
import unittest
from contextlib import redirect_stdout
from datetime import datetime
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "run_foresttrip_vacancy.py"
FIXTURES_DIR = SCRIPT_DIR / "fixtures"
def load_helper():
spec = importlib.util.spec_from_file_location("run_foresttrip_vacancy", HELPER_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load helper from {HELPER_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules["run_foresttrip_vacancy"] = module
spec.loader.exec_module(module)
return module
helper = load_helper()
def load_fixture(name):
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
GEOJE_ROWS = load_fixture("geoje_window.json")
GUJAEBONG_ROWS = load_fixture("gujaebong_window.json")
GEOJE_FOREST_ID = "ID02030059"
GEOJE_FOREST_NAME = "[공립](거제시)거제자연휴양림"
GUJAEBONG_FOREST_ID = "ID02030072"
GUJAEBONG_FOREST_NAME = "[공립](하동군)구재봉자연휴양림"
FIXED_NOW = datetime(2026, 5, 12, 0, 0, 0)
def make_session(forests):
return helper.Session(
cookies={},
csrf="dummy-csrf",
user_agent="test-ua",
forests=forests,
expires_at=FIXED_NOW.timestamp() + 3600,
)
def stub_fetch(rows):
def _stub(*, forest_id, category, **_):
matched = [r for r in rows if r.get("insttId") == forest_id]
return forest_id, category, matched, None
return _stub
def run_collect(session, targets, rows, *, dates=None, week_range=None, categories=("01",)):
with mock.patch.object(helper, "fetch_one", side_effect=stub_fetch(rows)):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
return helper.collect_results(
session=session,
targets=targets,
categories=categories,
dates=tuple(dates) if dates else None,
week_range=week_range,
concurrency=1,
)
class IsReserveRoomTest(unittest.TestCase):
def test_parens_with_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "201호 배꽃방(예비용)"}))
def test_parens_prefix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "(예비) 201호"}))
def test_predicate_with_simple_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "편백나무2호(예비용)"}))
def test_normal_room_passes(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": "동백1"}))
def test_empty_name(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": ""}))
def test_missing_name_key(self):
self.assertFalse(helper.is_reserve_room({}))
class IsAvailableTest(unittest.TestCase):
def test_y_and_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 0}))
def test_y_and_string_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "0"}))
def test_y_but_already_booked(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 1}))
def test_y_but_string_booked_count(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "1"}))
def test_not_available(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "N", "rsrvtCnt": 0}))
class CollectResultsFilterTest(unittest.TestCase):
def setUp(self):
self.session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
self.targets = {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}
def test_geoje_5_13_three_unique_rooms_after_dedup_and_reserve_filter(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
self.assertEqual(payload["filter_hits"], 3)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"동백1", "해송2", "고로쇠1"})
def test_geoje_5_16_returns_zero_when_only_reserved_or_booked(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260516"])
self.assertEqual(payload["filter_hits"], 0)
self.assertEqual(payload["results"], [])
def test_geoje_5_17_two_rooms(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260517"])
self.assertEqual(payload["filter_hits"], 2)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"중산막2", "동백3"})
def test_dates_outside_request_filtered_out(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
observed_dates = {
room["use_dt"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(observed_dates, {"20260513"})
def test_reserve_rooms_excluded_across_all_dates(self):
payload = run_collect(
self.session, self.targets, GEOJE_ROWS,
dates=["20260513", "20260516", "20260517"],
)
for forest in payload["results"]:
for date in forest["dates"]:
for room in date["rooms"]:
self.assertNotIn("예비", room["name"])
def test_dedup_collapses_duplicate_room_with_different_goods_id(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
donbaek_count = sum(
1
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
if room["name"] == "동백1"
)
self.assertEqual(donbaek_count, 1)
def test_dedup_keeps_same_room_name_from_distinct_categories(self):
rows_by_category = {
"01": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "숙박",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
"02": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "야영",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
}
def fetch_category(*, forest_id, category, **_):
return forest_id, category, rows_by_category[category], None
with mock.patch.object(helper, "fetch_one", side_effect=fetch_category):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
payload = helper.collect_results(
session=self.session,
targets=self.targets,
categories=("01", "02"),
dates=("20260513",),
week_range=None,
concurrency=1,
)
self.assertEqual(payload["filter_hits"], 2)
observed = [
(room["name"], room["category"])
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(observed, [("같은이름", "숙박"), ("같은이름", "야영")])
class StrictUseDtGateTest(unittest.TestCase):
"""Bug 1 regression: API returns 5-day window even when single-day requested."""
def test_useDt_before_today_blocked_even_if_available(self):
past_row = dict(GEOJE_ROWS[0])
past_row["useDt"] = "20260101"
rows = [past_row]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
def test_useDt_after_last_day_blocked(self):
far_future = dict(GEOJE_ROWS[0])
far_future["useDt"] = "20300101"
rows = [far_future]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
class PrintTextTest(unittest.TestCase):
"""print_text is the user-facing output path — guard against format regressions."""
def test_renders_forest_and_rooms(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260513"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
output = buffer.getvalue()
self.assertIn(GEOJE_FOREST_NAME, output)
self.assertIn("20260513", output)
self.assertIn("slot(s)", output)
self.assertIn("동백1", output)
def test_empty_results_message(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260516"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
self.assertIn("(no available rooms at lookup time)", buffer.getvalue())
class MainOutputTest(unittest.TestCase):
def run_main(self, argv, payload):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
buffer = io.StringIO()
with mock.patch.object(sys, "argv", ["run_foresttrip_vacancy.py", *argv]):
with mock.patch.object(helper, "get_session", return_value=session):
with mock.patch.object(helper, "resolve_targets", return_value={GEOJE_FOREST_ID: GEOJE_FOREST_NAME}):
with mock.patch.object(helper, "collect_results", return_value=payload):
with redirect_stdout(buffer):
exit_code = helper.main()
return exit_code, buffer.getvalue()
def test_main_text_output_returns_success_when_fetches_succeed(self):
payload = run_collect(
make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME}),
{GEOJE_FOREST_ID: GEOJE_FOREST_NAME},
GEOJE_ROWS,
dates=["20260513"],
)
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--text"], payload)
self.assertEqual(exit_code, 0)
self.assertIn("ForestTrip Vacancy Lookup", output)
self.assertIn("filter_hits: 3", output)
self.assertIn("동백1", output)
def test_main_json_output_reports_failure_and_returns_nonzero(self):
payload = {
"forests_scanned": 1,
"filter_hits": 0,
"fetch_failures": 1,
"failures": [{"forest_id": GEOJE_FOREST_ID, "category": "01", "error": "http_401"}],
"concurrency": 1,
"date_range": {"from": "20260512", "to": "20260513"},
"results": [],
}
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--json"], payload)
self.assertEqual(exit_code, 1)
rendered = json.loads(output)
self.assertEqual(rendered["fetch_failures"], 1)
self.assertEqual(rendered["failures"][0]["error"], "http_401")
class GroundTruthTest(unittest.TestCase):
"""Anchored to user-verified counts from foresttrip.go.kr on 2026-05-12.
Fixtures are simplified; tests assert the per-(forest, date) shape matches."""
def test_gujaebong_5_16_one_room_named_쑥부쟁이방(self):
session = make_session({GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME})
payload = run_collect(
session, {GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME}, GUJAEBONG_ROWS,
dates=["20260516"],
)
self.assertEqual(payload["filter_hits"], 1)
names = [
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(names, ["206호 쑥부쟁이방"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,6 +1,6 @@
---
name: ktx-booking
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, or reservation status.
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, reservation status, remaining seat numbers, car-by-car seats, or power-outlet/good-seat tips.
license: MIT
metadata:
category: travel
@ -12,7 +12,7 @@ metadata:
## What this skill does
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 예약, 예약 확인, 취소를 처리한다.
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 호차별 좌석번호 확인, 예약, 예약 확인, 취소를 처리한다.
최근 Korail 앱의 Dynapath anti-bot 체크 때문에 원본 `korail2` 0.4.0 예제만으로는 `MACRO ERROR` 가 날 수 있다. 이 스킬은 helper 가 `x-dynapath-m-token`, `Sid`, 최신 app version(`250601002`)을 붙여 실제 예매 흐름을 복구하는 것을 전제로 한다.
@ -22,6 +22,10 @@ metadata:
- "코레일 예약 확인해줘"
- "KTX 취소해줘"
- "오전 9시 이후 KTX 중 제일 빠른 거 잡아줘"
- "KTX 남은 좌석 번호 확인해줘"
- "이 열차 콘센트 있는 꿀팁 좌석부터 보여줘"
- "KTX 5호차 남은 자리만 봐줘"
- "예약하기 전에 호차별 좌석 확인해줘"
- "N카드로 할인 열차 찾아줘"
- "내 N카드 목록 보여줘"
- "N카드 할인 적용해서 예약해줘"
@ -59,6 +63,7 @@ metadata:
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 꿀팁 좌석 우선
- 조회 결과에서 복사한 `train_id`
## Workflow
@ -108,7 +113,62 @@ python3 scripts/ktx_booking.py search 남춘천 용산 20260503 150000 --train-t
- 일반실/특실 가능 여부
- 예약 대기 가능 여부
### 4. Reserve only after the target train is unambiguous
### 4. Inspect detailed seats when the user asks for good seats
`search` 의 좌석 가능 여부는 열차 단위 플래그다. 사용자가 "남은 좌석 번호", "호차별 좌석", "콘센트", "꿀팁 좌석", "창측/순방향 자리", "예약 전에 자리 확인"처럼 구체적인 좌석을 물으면 예약 전에 `seats` 를 호출한다.
기본 상세 좌석 조회:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
```
일반실/특실은 `--room` 으로 나눈다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --room special
```
남은 좌석번호만 보고 싶으면 `--available-only` 를 쓴다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안의 좌석은 콘센트 힌트가 있는 좌석(`direct`, `adjacent`)을 먼저, 같은 조건에서는 순방향 좌석을 먼저 보여준다.
특정 호차만 확인하려면 `--car-no` 를 쓴다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 자리부터 확인하려면 `--power-only` 를 붙인다. 응답의 `power_outlet``direct`, `adjacent`, `none` 중 하나다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
`seats``search` 와 같은 `--train-type` 을 넘겨야 한다. ITX-청춘 등 KTX 외 열차를 조회했다면 상세 좌석 조회에도 같은 값을 사용한다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--train-id <train_id> \
--train-type itx-cheongchun \
--available-only
```
상세 좌석 응답을 보여줄 때는 사용자 의도에 맞춰 아래를 우선 요약한다.
- 호차별 `remaining_seats`, `available_seat_count`
- 남은 좌석 번호 (`available_seats`)
- 좌석별 `direction`, `position`, `seat_type`
- 콘센트 힌트 (`power_outlet`)
- 문 근처 여부 (`near_door`)
이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계의 `reserve` 로만 진행한다.
### 5. Reserve only after the target train is unambiguous
조회 결과의 `train_id` 를 고른 뒤에만 예약한다. 이 값은 helper 가 열차 번호/운행일/시각/역 코드를 묶어 만든 stable selector 이므로, 재조회 시 같은 열차가 아직 있으면 그대로 잡고 없으면 실패한다.
@ -125,7 +185,7 @@ python3 scripts/ktx_booking.py reserve 남춘천 용산 20260503 150000 --train-
응답에는 예약번호, 운임, 구입기한이 포함된다. **결제는 자동화하지 않는다.**
좌석이 없을 때는 조회 단계에서 `--include-waiting-list` 를 켜고 예약 단계에서 `--try-waiting` 으로 예약 대기까지 시도할 수 있다.
### 4-1. N-card discounted reservation
### 5-1. N-card discounted reservation
N카드 할인을 적용하려면 먼저 보유 N카드 목록을 조회해 카드 번호를 확인한다.
@ -151,7 +211,7 @@ python3 scripts/ktx_booking.py reserve 대전 서울 20260512 100000 \
N카드 기능은 `korail2-ncard` 패키지가 필요하다. 없으면 해당 커맨드 실행 시 설치 안내가 출력된다.
### 5. Inspect or cancel
### 6. Inspect or cancel
취소는 대상 예약을 다시 조회해 식별한 뒤에만 진행한다.
@ -166,6 +226,7 @@ python3 scripts/ktx_booking.py cancel <reservation_id>
## Done when
- 조회면 열차 후보가 정리되어 있다
- 좌석 상세 확인이면 호차별 남은 좌석번호와 필요한 꿀팁 조건이 정리되어 있다
- 예약이면 예약 결과와 제한 시간이 확인되어 있다
- 취소면 어떤 예약을 취소했는지 남아 있다

12
legacy/README.md Normal file
View file

@ -0,0 +1,12 @@
# Legacy Unsupported Code
This directory preserves unsupported skills and helper code that are not part of the default k-skill install, plugin manifest, Manus bundles, npm workspaces, proxy route surface, or README feature list.
Archived items:
- `unsupported-skills/blue-ribbon-nearby/` - Blue Ribbon nearby skill. The upstream blocks automation/premium access in ways this repository cannot currently support.
- `unsupported-skills/naver-map-route/` - Naver Map route skill. NCP Maps operational prerequisites are not currently available for the hosted proxy.
- `unsupported-packages/blue-ribbon-nearby/` - Former npm workspace package retained for future revival.
- `unsupported-proxy/bluer.js` and `unsupported-proxy/naver-map.js` - Former proxy helper modules retained for future revival.
To revive one of these surfaces, move the code back into the normal repo layout, restore docs/tests/proxy routes or workspace metadata, and rerun `npm run ci` plus live/manual QA.

38
package-lock.json generated
View file

@ -560,10 +560,6 @@
"node": ">=4"
}
},
"node_modules/blue-ribbon-nearby": {
"resolved": "packages/blue-ribbon-nearby",
"link": true
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
@ -1744,16 +1740,6 @@
"node": ">= 8"
}
},
"packages/blue-ribbon-nearby": {
"version": "0.2.3",
"license": "MIT",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"rebrowser-playwright": ">=1.0.0"
}
},
"packages/cheap-gas-nearby": {
"version": "0.4.0",
"license": "MIT",
@ -1762,7 +1748,7 @@
}
},
"packages/court-auction-notice-search": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"bin": {
"court-auction-notice-search": "bin/court-auction-notice-search.js"
@ -1776,7 +1762,7 @@
}
},
"packages/daishin-report-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"daishin-report-search": "src/cli.js"
@ -1786,28 +1772,28 @@
}
},
"packages/daiso-product-search": {
"version": "0.2.0",
"version": "0.6.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/donation-place-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/emergency-room-beds": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"gangnamunni-clinic-search": "src/cli.js"
@ -1817,7 +1803,7 @@
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1844,7 +1830,7 @@
}
},
"packages/k-skill-proxy": {
"version": "0.2.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"fastify": "^5.3.3"
@ -1888,7 +1874,7 @@
}
},
"packages/korean-marathon-schedule": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"korean-marathon-schedule": "src/cli.js"
@ -1905,7 +1891,7 @@
}
},
"packages/local-election-candidate-search": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"bin": {
"local-election-candidate-search": "src/cli.js"
@ -1936,7 +1922,7 @@
}
},
"packages/sh-notice-search": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"bin": {
"sh-notice-search": "src/cli.js"
@ -1946,7 +1932,7 @@
}
},
"packages/toss-securities": {
"version": "0.3.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -10,10 +10,12 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py 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/nts_business_registration.py scripts/test_nts_business_registration.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 nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.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-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py 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/nts_business_registration.py scripts/test_nts_business_registration.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 nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.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-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner 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_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.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 parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --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 && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner 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_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.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 kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --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 && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -30,9 +30,6 @@
- `GET /v1/kakao-map/coord2address` — Kakao Local 좌표→도로명/지번 주소(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2region` — Kakao Local 좌표→행정구역(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
- `GET /v1/naver-map/directions` — NCP Maps Directions 5 자동차 길찾기(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/geocode` — NCP Maps 주소→좌표 지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/naver-map/reverse-geocode` — NCP Maps 좌표→주소 역지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
@ -73,7 +70,6 @@
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET` — NAVER Cloud Platform Maps subaccount 키. `naver-map/*` 라우트의 **운영 가능 여부**를 결정한다. 키가 없으면 라우트는 `503 upstream_not_configured` 를 돌려준다.
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-map.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -1,7 +1,6 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
const {
@ -28,14 +27,6 @@ const {
normalizeKakaoKeywordSearchQuery,
normalizeKakaoMobilityDirectionsQuery
} = require("./kakao-map");
const {
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery
} = require("./naver-map");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const {
@ -184,7 +175,6 @@ function buildConfig(env = process.env) {
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
data4libraryAuthKey: trimOrNull(env.DATA4LIBRARY_AUTH_KEY),
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
@ -194,8 +184,6 @@ function buildConfig(env = process.env) {
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
naverMapClientId: trimOrNull(env.NAVER_MAP_CLIENT_ID),
naverMapClientSecret: trimOrNull(env.NAVER_MAP_CLIENT_SECRET),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -1041,27 +1029,6 @@ function normalizeNeisSchoolSearchQuery(query) {
};
}
function normalizeBlueRibbonNearbyQuery(query) {
const latitude = parseFloatValue(query.latitude ?? query.lat);
const longitude = parseFloatValue(query.longitude ?? query.lng);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("Provide latitude and longitude.");
}
const distanceMeters = parseInteger(query.distanceMeters ?? query.distance, 1000);
if (distanceMeters <= 0 || distanceMeters > 5000) {
throw new Error("distanceMeters must be between 1 and 5000.");
}
const limit = parseInteger(query.limit, 10);
if (limit <= 0 || limit > 50) {
throw new Error("limit must be between 1 and 50.");
}
return { latitude, longitude, distanceMeters, limit };
}
function normalizeRealEstateQuery(query) {
const lawdCd = trimOrNull(query.lawd_cd ?? query.lawdCd);
if (!lawdCd || !/^\d{5}$/.test(lawdCd)) {
@ -1897,7 +1864,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
app.get("/health", async () => {
const naverSearchKeysPresent = Boolean(config.naverSearchClientId && config.naverSearchClientSecret);
const naverMapKeysPresent = Boolean(config.naverMapClientId && config.naverMapClientSecret);
return {
ok: true,
service: config.proxyName,
@ -1905,7 +1871,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
@ -1922,7 +1887,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent,
naverMapConfigured: naverMapKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey),
kstartupConfigured: Boolean(config.molitApiKey)
},
@ -2568,67 +2532,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/blue-ribbon/nearby", async (request, reply) => {
let normalized;
try {
normalized = normalizeBlueRibbonNearbyQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
if (!config.blueRibbonSessionId) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "BLUE_RIBBON_SESSION_ID is not configured on the proxy server."
};
}
const cacheKey = makeCacheKey({
route: "blue-ribbon-nearby",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const result = await proxyBlueRibbonNearbyRequest({
...normalized,
sessionId: config.blueRibbonSessionId
});
const payload = {
...result,
query: normalized,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/opinet/around", async (request, reply) => {
let normalized;
@ -4271,79 +4174,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
}
async function handleNaverMapRoute({
request,
reply,
route,
normalize,
fetcher,
cacheKeyExtra = {}
}) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({ route, ...normalized, ...cacheKeyExtra });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
let result;
try {
result = await fetcher({
...normalized,
clientId: config.naverMapClientId,
clientSecret: config.naverMapClientSecret
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
const payload = {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
if (error.upstreamStatusCode) {
payload.upstream = {
status_code: error.upstreamStatusCode
};
if (error.upstreamBodySnippet) {
payload.upstream.body_snippet = error.upstreamBodySnippet;
}
}
return payload;
}
const payload = {
...result.body,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
reply.code(result.statusCode);
reply.header("content-type", "application/json; charset=utf-8");
return payload;
}
app.get("/v1/kakao-map/search/keyword", async (request, reply) => handleKakaoLocalEndpointRoute({
request,
reply,
@ -4440,31 +4270,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/naver-map/directions", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-directions",
normalize: normalizeNaverMapDirectionsQuery,
fetcher: fetchNaverMapDirections
}));
app.get("/v1/naver-map/geocode", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-geocode",
normalize: normalizeNaverMapGeocodeQuery,
fetcher: fetchNaverMapGeocode
}));
app.get("/v1/naver-map/reverse-geocode", async (request, reply) => handleNaverMapRoute({
request,
reply,
route: "naver-map-reverse-geocode",
normalize: normalizeNaverMapReverseGeocodeQuery,
fetcher: fetchNaverMapReverseGeocode
}));
async function handleData4LibraryRoute({
request,
reply,
@ -5070,7 +4875,6 @@ module.exports = {
createMemoryCache,
isFailureResponse,
makeCacheKey,
normalizeBlueRibbonNearbyQuery,
normalizeData4LibraryBookDetailQuery,
normalizeData4LibraryBookExistsQuery,
normalizeData4LibraryBookSearchQuery,
@ -5083,9 +4887,6 @@ module.exports = {
normalizeKakaoCategorySearchQuery,
normalizeKakaoCoordToAddressQuery,
normalizeKakaoMobilityDirectionsQuery,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery,
normalizeKmaForecastQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
@ -5120,9 +4921,6 @@ module.exports = {
proxyKstartupRequest,
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulBikeRealtimeRequest,

View file

@ -1131,380 +1131,33 @@ test("Kakao Map health endpoint reflects kakaoMapConfigured and kakaoMobilityCon
assert.equal(on.json().upstreams.kakaoMobilityConfigured, true);
});
test("Naver Map directions endpoint returns 503 when proxy lacks Naver Map keys", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called when keys are missing");
};
const app = buildServer({ env: {} });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /NAVER_MAP_CLIENT_ID/);
});
test("Naver Map directions endpoint injects server-side Naver keys and caches successful responses", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
code: 0,
message: "found_route",
route: {
trafast: [
{ summary: { distance: 12345, duration: 600000, tollFare: 1000, taxiFare: 0, fuelPrice: 1500 } }
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "server-naver-id",
NAVER_MAP_CLIENT_SECRET: "server-naver-secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979&option=trafast";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().code, 0);
assert.equal(first.json().proxy.cache.hit, false);
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(calls.length, 1, "second request should be served from proxy cache");
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-direction/v1/driving");
assert.equal(parsed.searchParams.get("start"), "126.9706,37.5559");
assert.equal(parsed.searchParams.get("goal"), "127.0276,37.4979");
assert.equal(parsed.searchParams.get("option"), "trafast");
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "server-naver-id");
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "server-naver-secret");
});
test("Naver Map directions endpoint validates coordinate input shape", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called for invalid input");
};
test("unsupported Naver Map and Blue Ribbon proxy routes are not registered", async (t) => {
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
NAVER_MAP_CLIENT_SECRET: "secret",
BLUE_RIBBON_SESSION_ID: "session"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const bad = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=not-coords&goal=127.0,37.5"
});
assert.equal(bad.statusCode, 400);
assert.equal(bad.json().error, "bad_request");
const missing = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5"
});
assert.equal(missing.statusCode, 400);
assert.equal(missing.json().error, "bad_request");
const outOfRange = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=999,37.5&goal=127,37.5"
});
assert.equal(outOfRange.statusCode, 400);
const badOption = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5&option=fastest"
});
assert.equal(badOption.statusCode, 400);
});
test("Naver Map directions endpoint surfaces upstream semantic failures as 502 without caching", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(
JSON.stringify({ code: 5, message: "no_route_found" }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_semantic_error");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 502);
assert.equal(calls.length, 2, "semantic failures must not be cached");
});
test("Naver Map endpoints sanitize upstream auth errors as 503 without leaking the body", async (t) => {
const originalFetch = global.fetch;
let upstreamStatus = 401;
global.fetch = async () => new Response(`Authentication Failed: ${upstreamStatus} secret diagnostic`, {
status: upstreamStatus,
headers: { "content-type": "text/plain" }
});
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const cases = [
{
upstreamStatus: 401,
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
},
{
upstreamStatus: 403,
url: "/v1/naver-map/geocode?q=%EC%84%9C%EC%9A%B8%EC%97%AD"
},
{
upstreamStatus: 401,
url: "/v1/naver-map/reverse-geocode?coords=126.9,37.5"
}
];
for (const testCase of cases) {
upstreamStatus = testCase.upstreamStatus;
const response = await app.inject({ method: "GET", url: testCase.url });
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_error");
assert.equal(body.upstream.status_code, testCase.upstreamStatus);
const serialized = JSON.stringify(body);
assert.ok(!serialized.includes("Authentication Failed"));
assert.ok(!serialized.includes("secret diagnostic"));
assert.equal(body.upstream.body_snippet, undefined);
}
});
test("Naver Map endpoints preserve upstream 429 for caller backoff", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("quota exceeded diagnostic", {
status: 429,
headers: { "content-type": "text/plain" }
});
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const cases = [
const urls = [
"/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5",
"/v1/naver-map/geocode?q=%EC%84%9C%EC%9A%B8%EC%97%AD",
"/v1/naver-map/reverse-geocode?coords=126.9,37.5"
"/v1/naver-map/reverse-geocode?coords=126.9,37.5",
"/v1/blue-ribbon/nearby?lat=37.5&lng=127.0"
];
for (const url of cases) {
for (const url of urls) {
const response = await app.inject({ method: "GET", url });
assert.equal(response.statusCode, 429);
const body = response.json();
assert.equal(body.error, "upstream_error");
assert.equal(body.upstream.status_code, 429);
assert.equal(body.upstream.body_snippet, "quota exceeded diagnostic");
assert.equal(response.statusCode, 404, `${url} should be archived, not served by the proxy`);
}
});
test("Naver Map directions endpoint keeps non-auth upstream snippets for diagnostics", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("Transient upstream diagnostic", {
status: 500,
headers: { "content-type": "text/plain" }
});
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
});
assert.equal(response.statusCode, 502);
const body = response.json();
assert.equal(body.upstream.status_code, 500);
assert.equal(body.upstream.body_snippet, "Transient upstream diagnostic");
});
test("Naver Map geocode endpoint injects Naver keys and forwards query, count, and language", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
status: "OK",
meta: { totalCount: 1, page: 1, count: 1 },
addresses: [
{
roadAddress: "서울특별시 중구 한강대로 405",
jibunAddress: "서울특별시 중구 봉래동2가 122",
x: "126.9706",
y: "37.5559"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "geo-id",
NAVER_MAP_CLIENT_SECRET: "geo-secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-map/geocode?q=" + encodeURIComponent("서울역") + "&count=5"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().status, "OK");
assert.equal(response.json().addresses[0].x, "126.9706");
const parsed = new URL(calls[0].url);
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-geocode/v2/geocode");
assert.equal(parsed.searchParams.get("query"), "서울역");
assert.equal(parsed.searchParams.get("count"), "5");
assert.equal(parsed.searchParams.get("language"), "kor");
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "geo-id");
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "geo-secret");
});
test("Naver Map reverse-geocode endpoint validates coords and orders", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
throw new Error("upstream should not be called");
};
const app = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const missing = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode"
});
assert.equal(missing.statusCode, 400);
const badOrder = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&orders=banana"
});
assert.equal(badOrder.statusCode, 400);
const xmlOutput = await app.inject({
method: "GET",
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&output=xml"
});
assert.equal(xmlOutput.statusCode, 400);
assert.equal(xmlOutput.json().error, "bad_request");
assert.match(xmlOutput.json().message, /output as json/);
});
test("Naver Map health endpoint reflects naverMapConfigured flag", async (t) => {
const appOff = buildServer({ env: {} });
t.after(async () => {
await appOff.close();
});
const offResponse = await appOff.inject({ method: "GET", url: "/health" });
assert.equal(offResponse.json().upstreams.naverMapConfigured, false);
const appOn = buildServer({
env: {
NAVER_MAP_CLIENT_ID: "id",
NAVER_MAP_CLIENT_SECRET: "secret"
}
});
t.after(async () => {
await appOn.close();
});
const onResponse = await appOn.inject({ method: "GET", url: "/health" });
assert.equal(onResponse.json().upstreams.naverMapConfigured, true);
const health = await app.inject({ method: "GET", url: "/health" });
assert.equal(Object.hasOwn(health.json().upstreams, "naverMapConfigured"), false);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {

View file

@ -40,6 +40,7 @@ const EXCLUDED_DIRS = new Set([
".idea",
"docs",
"dist",
"legacy",
"node_modules",
"packages",
"python-packages",

View file

@ -0,0 +1,180 @@
#!/usr/bin/env node
/**
* Generate / refresh the Claude Code plugin manifest's `skills` list.
*
* This repo is a flat collection of `<skill-name>/SKILL.md` directories at the
* repo root (NOT under a `skills/` folder), because the npm workspaces +
* changesets release pipeline depends on that layout. A Claude Code plugin can
* still expose them by listing each skill directory in the `skills` array of
* `.claude-plugin/plugin.json` (the field accepts custom directory paths in
* addition to the default `skills/` dir).
*
* Skill discovery mirrors scripts/validate-skills.sh and
* scripts/build-manus-bundle.js. This script writes the sorted `skills` array
* into `.claude-plugin/plugin.json` while preserving every other field.
*
* Usage:
* node scripts/generate-plugin-manifest.js # write/update plugin.json
* node scripts/generate-plugin-manifest.js --check # exit 1 if out of date
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.resolve(__dirname, "..");
// Root-level directories that are never skills. Superset of the exclusion
// lists in scripts/validate-skills.sh and scripts/build-manus-bundle.js so
// that test fixtures under tools/ never leak in. Dot-directories are excluded
// unconditionally below; they are listed here only for documentation.
const EXCLUDED_DIRS = new Set([
".git",
".github",
".codex",
".claude",
".omc",
".omx",
".ouroboros",
".changeset",
".cursor",
".vscode",
".sisyphus",
".idea",
"docs",
"dist",
"legacy",
"node_modules",
"packages",
"python-packages",
"scripts",
"examples",
"tools",
]);
// Skills that exist on disk but must not ship in the plugin (e.g. upstream
// blocked automation and the skill no longer works).
const EXCLUDED_SKILLS = new Set(["blue-ribbon-nearby", "naver-map-route"]);
// Identity fields used when the manifest does not exist yet. Existing values
// are never overwritten; only missing keys are backfilled.
const DEFAULT_MANIFEST = {
name: "k-skill",
description:
"한국인을 위한 90+ Agent Skill 모음 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화",
version: "1.0.0",
author: { name: "NomaDamas" },
homepage: "https://github.com/NomaDamas/k-skill",
repository: "https://github.com/NomaDamas/k-skill",
license: "MIT",
skills: [],
};
function manifestPathFor(root) {
return path.join(root, ".claude-plugin", "plugin.json");
}
/**
* Discover skill directories (those containing a SKILL.md) directly under
* `root`, returning sorted plugin-relative paths like `./lotto-results`.
*/
function discoverSkillPaths(root) {
const entries = fs.readdirSync(root, { withFileTypes: true });
const skills = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
if (EXCLUDED_DIRS.has(entry.name)) continue;
if (EXCLUDED_SKILLS.has(entry.name)) continue;
const skillMd = path.join(root, entry.name, "SKILL.md");
if (fs.existsSync(skillMd)) {
skills.push(`./${entry.name}`);
}
}
skills.sort();
return skills;
}
/** Build the manifest object, preserving existing fields and refreshing skills. */
function buildManifest(root) {
const manifestPath = manifestPathFor(root);
let manifest = { ...DEFAULT_MANIFEST };
if (fs.existsSync(manifestPath)) {
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
for (const [key, value] of Object.entries(DEFAULT_MANIFEST)) {
if (key === "skills") continue;
if (manifest[key] === undefined) manifest[key] = value;
}
}
manifest.skills = discoverSkillPaths(root);
return manifest;
}
function serialize(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
/**
* Core entry point usable from tests.
* @returns {{ ok: boolean, manifest: object, current: string, next: string, written?: boolean }}
*/
function run({ root = repoRoot, check = false } = {}) {
const manifestPath = manifestPathFor(root);
const manifest = buildManifest(root);
const next = serialize(manifest);
const current = fs.existsSync(manifestPath) ? fs.readFileSync(manifestPath, "utf8") : "";
if (check) {
return { ok: current === next, manifest, current, next };
}
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
fs.writeFileSync(manifestPath, next);
return { ok: true, manifest, current, next, written: true };
}
function main() {
const check = process.argv.includes("--check");
const result = run({ check });
const count = result.manifest.skills.length;
if (check) {
if (!result.ok) {
console.error(
"plugin.json is out of date. Run `node scripts/generate-plugin-manifest.js` and commit the result.",
);
let currentSkills = [];
try {
currentSkills = result.current ? JSON.parse(result.current).skills || [] : [];
} catch {
/* malformed current manifest; treat as empty for the diff */
}
const nextSkills = result.manifest.skills;
const added = nextSkills.filter((s) => !currentSkills.includes(s));
const removed = currentSkills.filter((s) => !nextSkills.includes(s));
if (added.length) console.error(` + ${added.join(", ")}`);
if (removed.length) console.error(` - ${removed.join(", ")}`);
process.exit(1);
}
console.log(`plugin.json is up to date (${count} skills).`);
return;
}
console.log(`Wrote .claude-plugin/plugin.json with ${count} skills.`);
}
if (require.main === module) {
main();
}
module.exports = {
EXCLUDED_DIRS,
EXCLUDED_SKILLS,
DEFAULT_MANIFEST,
discoverSkillPaths,
buildManifest,
serialize,
run,
manifestPathFor,
};

View file

@ -103,7 +103,7 @@ except ModuleNotFoundError as exc:
class _FallbackKorailModule:
EMAIL_REGEX = re.compile(r".+@.+")
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
PHONE_NUMBER_REGEX = re.compile(r"(\d{3})-(\d{3,4})-(\d{4})")
korail_mod = _FallbackKorailModule()
else:
@ -127,11 +127,15 @@ DEFAULT_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 13; SM-S928N Build/UP1A.23
DYNAPATH_PATHS = [
"/classes/com.korail.mobile.certification.TicketReservation",
"/classes/com.korail.mobile.nonMember.NonMemTicket",
"/classes/com.korail.mobile.research.TrainResearch",
"/classes/com.korail.mobile.research.ResidualSeatsResearch.do",
"/classes/com.korail.mobile.seatMovie.ScheduleView",
"/classes/com.korail.mobile.seatMovie.ScheduleViewSpecial",
"/classes/com.korail.mobile.trn.prcFare.do",
"/classes/com.korail.mobile.login.Login",
]
KORAIL_CARS_INFO = "https://smart.letskorail.com:443/classes/com.korail.mobile.research.TrainResearch"
KORAIL_CAR_DETAIL = "https://smart.letskorail.com:443/classes/com.korail.mobile.research.ResidualSeatsResearch.do"
RESERVE_OPTION_MAP = {
"general-first": ReserveOption.GENERAL_FIRST,
"general-only": ReserveOption.GENERAL_ONLY,
@ -163,6 +167,43 @@ TRAIN_ID_FIELDS = (
"arr_code",
)
PHONE_NUMBER_DIGITS_REGEX = re.compile(r"^01\d{8,9}$")
ROOM_CLASS_MAP = {
"general": "1",
"special": "2",
}
ROOM_CLASS_NAME = {
"1": "일반실",
"2": "특실",
}
SEAT_DIRECTION_NAME = {
"009": "순방향",
"010": "역방향",
}
SEAT_POSITION_NAME = {
"011": "1인",
"012": "창측",
"013": "내측",
}
SEAT_TYPE_NAME = {
"015": "일반석",
"018": "2층석",
"019": "유아동반석",
"021": "휠체어석",
"023": "4인동반석",
"027": "4인석",
"028": "전동휠체어석",
"032": "자전거",
"052": "대피도우미",
}
POWER_OUTLET_ROWS = {1, 3, 5, 7, 10, 12, 14, 15}
POWER_OUTLET_DIRECT_COLUMNS = {"A", "D"}
POWER_OUTLET_ADJACENT_COLUMNS = {"B", "C"}
def is_phone_login_id(korail_id: str) -> bool:
return bool(korail_mod.PHONE_NUMBER_REGEX.fullmatch(korail_id) or PHONE_NUMBER_DIGITS_REGEX.fullmatch(korail_id))
def ensure_runtime_dependencies() -> None:
missing: list[str] = []
@ -334,7 +375,7 @@ class PatchedKorail(Korail):
if korail_mod.EMAIL_REGEX.match(korail_id):
input_flag = "5"
elif korail_mod.PHONE_NUMBER_REGEX.match(korail_id):
elif is_phone_login_id(korail_id):
input_flag = "4"
else:
input_flag = "2"
@ -364,7 +405,7 @@ class PatchedKorail(Korail):
self.logined = False
return False
def search_train(
def search_train_details(
self,
dep: str,
arr: str,
@ -424,17 +465,98 @@ class PatchedKorail(Korail):
response = self._session.post(korail_mod.KORAIL_SEARCH_SCHEDULE, params=payload, headers=headers)
data = json.loads(response.text)
if self._result_check(data):
trains = [korail_mod.Train(info) for info in data["trn_infos"]["trn_info"]]
trains = [train for train in trains if train.dep_name == dep and train.arr_name == arr]
train_infos = data["trn_infos"]["trn_info"]
if isinstance(train_infos, dict):
train_infos = [train_infos]
details = [(korail_mod.Train(info), info) for info in train_infos]
details = [(train, info) for train, info in details if train.dep_name == dep and train.arr_name == arr]
filters = [lambda train: train.has_seat()]
if include_no_seats:
filters.append(lambda train: not train.has_seat())
if include_waiting_list:
filters.append(lambda train: train.has_waiting_list())
trains = [train for train in trains if any(check(train) for check in filters)]
if not trains:
details = [(train, info) for train, info in details if any(check(train) for check in filters)]
if not details:
raise NoResultsError()
return trains
return details
def search_train(
self,
dep: str,
arr: str,
date: str | None = None,
time_value: str | None = None,
train_type: str = TrainType.ALL,
passengers: list[Passenger] | None = None,
include_no_seats: bool = False,
include_waiting_list: bool = False,
):
return [
train
for train, _ in self.search_train_details(
dep,
arr,
date,
time_value,
train_type=train_type,
passengers=passengers,
include_no_seats=include_no_seats,
include_waiting_list=include_waiting_list,
)
]
def train_cars(self, raw_train: dict[str, object], passenger_count: int = 1, room_class: str = "1") -> list[dict[str, object]]:
payload = self._seat_lookup_payload(raw_train, passenger_count, room_class)
headers, sid = self._auth_headers_and_sid(KORAIL_CARS_INFO)
if sid:
payload["Sid"] = sid
response = self._session.post(KORAIL_CARS_INFO, data=payload, headers=headers)
data = json.loads(response.text)
if self._result_check(data):
cars = data.get("srcar_infos", {}).get("srcar_info", [])
if isinstance(cars, dict):
cars = [cars]
return cars
return []
def car_seats(
self,
raw_train: dict[str, object],
car_no: str,
passenger_count: int = 1,
room_class: str = "1",
) -> dict[str, object]:
payload = self._seat_lookup_payload(raw_train, passenger_count, room_class)
payload["txtSrcarNo"] = car_no
headers, sid = self._auth_headers_and_sid(KORAIL_CAR_DETAIL)
if sid:
payload["Sid"] = sid
response = self._session.post(KORAIL_CAR_DETAIL, data=payload, headers=headers)
data = json.loads(response.text)
if self._result_check(data):
return data
return {}
def _seat_lookup_payload(self, raw_train: dict[str, object], passenger_count: int, room_class: str) -> dict[str, object]:
return {
"Device": self._device,
"Version": self._version,
"Key": self._key,
"txtArvRsStnCd": raw_train.get("h_arv_rs_stn_cd", ""),
"txtArvStnRunOrdr": raw_train.get("h_arv_stn_run_ordr", ""),
"txtDptDt": raw_train.get("h_dpt_dt", ""),
"txtDptRsStnCd": raw_train.get("h_dpt_rs_stn_cd", ""),
"txtDptStnRunOrdr": raw_train.get("h_dpt_stn_run_ordr", ""),
"txtGdNo": "",
"txtMenuId": "11",
"txtPsrmClCd": room_class,
"txtRunDt": raw_train.get("h_run_dt", ""),
"txtSeatAttCd": "015",
"txtTotPsgCnt": str(passenger_count),
"txtTrnClsfCd": raw_train.get("h_trn_clsf_cd", ""),
"txtTrnGpCd": raw_train.get("h_trn_gp_cd", ""),
"txtTrnNo": raw_train.get("h_trn_no", ""),
}
def reserve(self, train, passengers=None, option=ReserveOption.GENERAL_FIRST, try_waiting=False):
reserving_seat = True
@ -609,6 +731,14 @@ def find_train_by_id(trains, train_id: str):
return None
def find_train_detail_by_id(details, train_id: str):
expected = parse_train_id(train_id)
for train, raw_train in details:
if build_train_id_payload(train) == expected:
return train, raw_train
return None
def normalize_train(train, index: int) -> dict[str, object]:
return {
"index": index,
@ -628,6 +758,88 @@ def normalize_train(train, index: int) -> dict[str, object]:
}
def parse_seat_label(seat_label: str) -> tuple[int | None, str]:
match = re.match(r"^(\d+)([A-Za-z])$", seat_label or "")
if not match:
return None, ""
return int(match.group(1)), match.group(2).upper()
def power_outlet_match(seat_label: str) -> str:
row, column = parse_seat_label(seat_label)
if row not in POWER_OUTLET_ROWS:
return "none"
if column in POWER_OUTLET_DIRECT_COLUMNS:
return "direct"
if column in POWER_OUTLET_ADJACENT_COLUMNS:
return "adjacent"
return "none"
def normalize_seat(raw_seat: dict[str, object]) -> dict[str, object]:
seat_label = str(raw_seat.get("h_con_seat_no", ""))
return {
"seat": seat_label,
"seat_no": str(raw_seat.get("h_seat_no", "")),
"available": raw_seat.get("h_sale_psb_flg") == "Y",
"direction": SEAT_DIRECTION_NAME.get(str(raw_seat.get("h_for_rev_dir_dv", "")), str(raw_seat.get("h_for_rev_dir_dv", ""))),
"position": SEAT_POSITION_NAME.get(str(raw_seat.get("h_sigl_win_in_dv", "")), str(raw_seat.get("h_sigl_win_in_dv", ""))),
"seat_type": SEAT_TYPE_NAME.get(str(raw_seat.get("h_dmd_seat_att", "")), str(raw_seat.get("h_dmd_seat_att", ""))),
"near_door": raw_seat.get("h_door_nbor_flg") == "Y",
"power_outlet": power_outlet_match(seat_label),
}
def validate_raw_seat(raw_seat: dict[str, object]) -> None:
required_fields = ("h_con_seat_no", "h_seat_no", "h_sale_psb_flg")
if any(raw_seat.get(field) in (None, "") for field in required_fields):
raise ValueError("seat row is missing required fields")
def parse_nonnegative_int_field(raw: object, field_name: str) -> int:
text = "" if raw is None else str(raw)
if not text.isdigit():
raise ValueError(f"{field_name} is not a non-negative integer")
return int(text)
def normalize_car(raw_car: object) -> dict[str, object]:
if not isinstance(raw_car, dict):
raise ValueError("car row is not an object")
return {
"car_no": parse_nonnegative_int_field(raw_car.get("h_srcar_no"), "h_srcar_no"),
"car_no_raw": str(raw_car.get("h_srcar_no", "")),
"room_class": ROOM_CLASS_NAME.get(str(raw_car.get("h_psrm_cl_cd", "")), str(raw_car.get("h_psrm_cl_nm", ""))),
"room_class_code": str(raw_car.get("h_psrm_cl_cd", "")),
"total_seats": parse_nonnegative_int_field(raw_car.get("h_seat_cnt"), "h_seat_cnt"),
"remaining_seats": parse_nonnegative_int_field(raw_car.get("h_rest_seat_cnt"), "h_rest_seat_cnt"),
}
def car_center_priority(car: dict[str, object], car_numbers: list[int]) -> tuple[float, int]:
car_no = int(car["car_no"])
if not car_numbers:
return (0.0, car_no)
center = (min(car_numbers) + max(car_numbers)) / 2
return (abs(car_no - center), car_no)
def sort_cars_for_booking(cars: list[dict[str, object]]) -> list[dict[str, object]]:
car_numbers = [int(car["car_no"]) for car in cars]
return sorted(cars, key=lambda car: car_center_priority(car, car_numbers))
def seat_preference_key(seat: dict[str, object]) -> tuple[int, int, int, str]:
power_rank = {"direct": 0, "adjacent": 1, "none": 2}.get(str(seat.get("power_outlet")), 2)
direction_rank = 0 if seat.get("direction") == "순방향" else 1
row, column = parse_seat_label(str(seat.get("seat", "")))
return (power_rank, direction_rank, row if row is not None else 999, column)
def sort_seats_for_booking(seats: list[dict[str, object]]) -> list[dict[str, object]]:
return sorted(seats, key=seat_preference_key)
def mask_identifier(value: object, visible: int = 4) -> str:
text = str(value or "")
if not text:
@ -699,6 +911,96 @@ def command_search(args: argparse.Namespace) -> None:
})
def command_seats(args: argparse.Namespace) -> None:
client = build_client()
passengers = parse_passengers(args)
passenger_count = sum(passenger.count for passenger in Passenger.reduce(passengers))
details = client.search_train_details(
args.dep,
args.arr,
args.date,
args.time,
train_type=TRAIN_TYPE_MAP[args.train_type],
passengers=passengers,
include_no_seats=True,
include_waiting_list=True,
)
match = find_train_detail_by_id(details, args.train_id)
if match is None:
raise SystemExit(TRAIN_ID_STALE_MESSAGE)
train, raw_train = match
room_class = ROOM_CLASS_MAP[args.room]
seat_car_unavailable = f"seat car data is unavailable for {args.room}; retry search or choose another train"
try:
cars = [normalize_car(car) for car in client.train_cars(raw_train, passenger_count, room_class)]
except (TypeError, ValueError, AttributeError) as exc:
raise SystemExit(seat_car_unavailable) from exc
if not cars:
raise SystemExit(seat_car_unavailable)
if args.car_no is not None:
cars = [car for car in cars if car["car_no"] == args.car_no]
if not cars:
raise SystemExit(f"car_no {args.car_no} is not available for {args.room}")
else:
cars = sort_cars_for_booking(cars)
car_payloads: list[dict[str, object]] = []
for car in cars:
raw = client.car_seats(raw_train, str(car["car_no_raw"]), passenger_count, room_class)
seat_infos = raw.get("seat_infos") if isinstance(raw, dict) else None
seat_detail_unavailable = (
f"seat detail data is unavailable for car_no {car['car_no']}; "
"retry search or choose another train"
)
if not isinstance(seat_infos, dict):
raise SystemExit(seat_detail_unavailable)
if "seat_info" not in seat_infos:
raise SystemExit(seat_detail_unavailable)
raw_seats = seat_infos["seat_info"]
if isinstance(raw_seats, dict):
raw_seats = [raw_seats]
if not isinstance(raw_seats, list):
raise SystemExit(seat_detail_unavailable)
if any(not isinstance(seat, dict) for seat in raw_seats):
raise SystemExit(seat_detail_unavailable)
try:
for raw_seat in raw_seats:
validate_raw_seat(raw_seat)
except ValueError as exc:
raise SystemExit(seat_detail_unavailable) from exc
remaining_seats = car["remaining_seats"]
if not isinstance(remaining_seats, int):
raise SystemExit(seat_detail_unavailable)
if not raw_seats and remaining_seats > 0:
raise SystemExit(seat_detail_unavailable)
all_seats = [normalize_seat(seat) for seat in raw_seats if seat.get("h_con_seat_no") != "0A"]
if not all_seats and remaining_seats > 0:
raise SystemExit(seat_detail_unavailable)
seats = sort_seats_for_booking(all_seats)
if args.available_only:
seats = [seat for seat in seats if seat["available"]]
if args.power_only:
seats = [seat for seat in seats if seat["power_outlet"] != "none"]
available_seats = [seat for seat in seats if seat["available"]]
seats = seats[: args.limit]
car_payload = dict(car)
car_payload["available_seat_count"] = len(available_seats)
car_payload["available_seats"] = [seat["seat"] for seat in available_seats]
car_payload["shown_seat_count"] = len(seats)
car_payload["seats"] = seats
car_payloads.append(car_payload)
print_json({
"train": normalize_train(train, 1),
"room": args.room,
"passenger_count": passenger_count,
"available_only": args.available_only,
"power_only": args.power_only,
"cars": car_payloads,
})
def ensure_ncard_available() -> None:
if not _NCARD_AVAILABLE:
raise SystemExit(
@ -863,6 +1165,33 @@ def build_parser() -> argparse.ArgumentParser:
search_parser.add_argument("--include-waiting-list", action="store_true", help="예약 대기 가능 열차도 포함")
search_parser.set_defaults(func=command_search)
seats_parser = subparsers.add_parser("seats", help="조회 결과 중 하나의 호차별 좌석번호를 조회합니다")
add_common_trip_args(seats_parser)
seats_parser.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
seats_parser.add_argument(
"--room",
choices=sorted(ROOM_CLASS_MAP),
default="general",
help="좌석을 조회할 객실 등급 (기본 general)",
)
seats_parser.add_argument(
"--train-type",
choices=sorted(TRAIN_TYPE_MAP),
default="ktx",
help="재조회할 열차 종류 — search 단계에서 사용한 값과 동일하게 지정 (기본 ktx)",
)
seats_parser.add_argument("--car-no", type=int, default=None, help="특정 호차만 조회")
seats_parser.add_argument(
"--available-only",
"--remaining-only",
dest="available_only",
action="store_true",
help="예약 가능한/남은 좌석만 출력",
)
seats_parser.add_argument("--power-only", action="store_true", help="콘센트 꿀팁 좌석(direct/adjacent)만 출력")
seats_parser.add_argument("--limit", type=int, default=100, help="호차별 출력할 최대 좌석 수")
seats_parser.set_defaults(func=command_seats)
reserve_parser = subparsers.add_parser("reserve", help="조회 결과 중 하나를 예약합니다")
add_common_trip_args(reserve_parser)
reserve_parser.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")

View file

@ -1621,58 +1621,48 @@ test("kleague-results package README stays aligned with the official K League JS
assert.match(packageReadme, /FC서울/);
});
test("repository docs advertise the blue-ribbon-nearby skill across the documented surfaces", () => {
test("unsupported naver map and blue ribbon skills are archived outside default docs", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md");
const legacyReadmePath = path.join(repoRoot, "legacy", "README.md");
const blueRibbonSkillPath = path.join(repoRoot, "legacy", "unsupported-skills", "blue-ribbon-nearby", "SKILL.md");
const naverMapSkillPath = path.join(repoRoot, "legacy", "unsupported-skills", "naver-map-route", "SKILL.md");
const blueRibbonDocPath = path.join(
repoRoot,
"legacy",
"unsupported-skills",
"blue-ribbon-nearby",
"docs",
"features",
"blue-ribbon-nearby.md",
);
const naverMapDocPath = path.join(
repoRoot,
"legacy",
"unsupported-skills",
"naver-map-route",
"docs",
"features",
"naver-map-route.md",
);
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/blue-ribbon-nearby.md to exist");
assert.match(readme, /\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \|/);
assert.match(readme, /\[근처 블루리본 맛집 가이드\]\(docs\/features\/blue-ribbon-nearby\.md\)/);
assert.match(readme, /블루리본 측이 `www\.bluer\.co\.kr` 에 자동화 접근 전면 차단/);
assert.match(install, /--skill blue-ribbon-nearby/);
assert.match(roadmap, /근처 블루리본 맛집 스킬 출시/);
assert.match(sources, /블루리본 지역 검색: https:\/\/www\.bluer\.co\.kr\/search\/zone/);
assert.match(sources, /블루리본 주변 맛집 JSON: https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
});
assert.ok(fs.existsSync(legacyReadmePath), "expected legacy/README.md to explain archived unsupported skills");
assert.ok(fs.existsSync(blueRibbonSkillPath), "expected blue-ribbon-nearby SKILL.md to be preserved under legacy");
assert.ok(fs.existsSync(naverMapSkillPath), "expected naver-map-route SKILL.md to be preserved under legacy");
assert.ok(fs.existsSync(blueRibbonDocPath), "expected blue-ribbon-nearby feature doc to be preserved under legacy");
assert.ok(fs.existsSync(naverMapDocPath), "expected naver-map-route feature doc to be preserved under legacy");
assert.ok(!fs.existsSync(path.join(repoRoot, "blue-ribbon-nearby", "SKILL.md")));
assert.ok(!fs.existsSync(path.join(repoRoot, "naver-map-route", "SKILL.md")));
assert.ok(!fs.existsSync(path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md")));
assert.ok(!fs.existsSync(path.join(repoRoot, "docs", "features", "naver-map-route.md")));
test("blue-ribbon-nearby skill documents mandatory location prompting and official Blue Ribbon nearby search flow", () => {
const skillPath = path.join(repoRoot, "blue-ribbon-nearby", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected blue-ribbon-nearby/SKILL.md to exist");
const skill = read(path.join("blue-ribbon-nearby", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "blue-ribbon-nearby.md"));
assert.match(skill, /^name: blue-ribbon-nearby$/m);
assert.match(skill, /^description: .*근처 맛집.*블루리본.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /반드시.*현재 위치/u);
assert.match(doc, /맛집.*기본적으로.*blue-ribbon-nearby|맛집.*기본적으로.*블루리본/u);
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
assert.match(doc, /zone2Lat/);
assert.match(doc, /zone2Lng/);
assert.match(doc, /isAround=true/);
assert.match(doc, /ribbon=true/);
assert.match(doc, /위도|경도|동네|역명/u);
assert.match(doc, /blue-ribbon-nearby|근처 블루리본 맛집/u);
for (const doc of [readme, install, sources]) {
assert.doesNotMatch(doc, /blue-ribbon-nearby|naver-map-route/);
assert.doesNotMatch(doc, /근처 블루리본 맛집|네이버맵 길찾기|네이버맵 자동차 길찾기/);
}
});
test("blue-ribbon-nearby package README stays aligned with the location-first and official-surface guidance", () => {
const packageReadme = read(path.join("packages", "blue-ribbon-nearby", "README.md"));
assert.match(packageReadme, /먼저 현재 위치를 묻/u);
assert.match(packageReadme, /코엑스.*삼성동\/대치동/u);
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
assert.match(packageReadme, /searchNearbyByLocationQuery/);
});
test("repository docs advertise the kakao-bar-nearby skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -4045,21 +4035,20 @@ test("README skill table includes inline-code skill names for every documented r
}
});
test("README skill table strikes through the deprecated blue-ribbon-nearby skill name (issue #165)", () => {
const readme = read("README.md");
test("legacy blue ribbon package is not part of npm workspaces or pack dry run", () => {
const packageJson = readJson("package.json");
const packageLock = readJson("package-lock.json");
const packScript = packageJson.scripts["pack:dry-run"];
assert.match(
readme,
/\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \| ~~`blue-ribbon-nearby`~~ \|/,
"expected the deprecated blue-ribbon-nearby row to keep the strikethrough on its skill-name cell as well",
);
assert.doesNotMatch(packScript, /workspace blue-ribbon-nearby(?:\s|$)/);
assert.ok(!fs.existsSync(path.join(repoRoot, "packages", "blue-ribbon-nearby", "package.json")));
assert.ok(fs.existsSync(path.join(repoRoot, "legacy", "unsupported-packages", "blue-ribbon-nearby", "package.json")));
assert.ok(!Object.hasOwn(packageLock.packages, "packages/blue-ribbon-nearby"));
assert.ok(!Object.hasOwn(packageLock.packages, "node_modules/blue-ribbon-nearby"));
});
test("README skill table skill-name column entries match real on-disk skill directories (issue #165)", () => {
const allEntries = [
...README_SKILL_NAME_COLUMN_MAPPING.map(([, skillName]) => skillName),
"blue-ribbon-nearby",
];
const allEntries = README_SKILL_NAME_COLUMN_MAPPING.map(([, skillName]) => skillName);
for (const skillName of allEntries) {
const skillFile = path.join(repoRoot, skillName, "SKILL.md");

View file

@ -60,6 +60,7 @@ test("EXCLUDED_DIRS stays in lockstep with validate-skills.sh exclusions", () =>
"python-packages",
"scripts",
"examples",
"legacy",
];
for (const dir of required) {
assert.ok(
@ -70,6 +71,15 @@ test("EXCLUDED_DIRS stays in lockstep with validate-skills.sh exclusions", () =>
}
});
test("legacy folder is not treated as a Manus skill bundle source", () => {
const { EXCLUDED_DIRS, discoverSkills } = require("./build-manus-bundle.js");
assert.ok(EXCLUDED_DIRS.has("legacy"));
assert.ok(!discoverSkills().includes("legacy"));
assert.ok(!discoverSkills().includes("blue-ribbon-nearby"));
assert.ok(!discoverSkills().includes("naver-map-route"));
});
test("docs/install-manus.md documents both the GitHub URL path and the .skill bundle path", () => {
const doc = fs.readFileSync(path.join(repoRoot, "docs", "install-manus.md"), "utf8");
assert.match(doc, /tree\/main\//, "must explain per-skill folder URL pattern");

View file

@ -0,0 +1,117 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
discoverSkillPaths,
buildManifest,
serialize,
run,
manifestPathFor,
EXCLUDED_SKILLS,
} = require("./generate-plugin-manifest.js");
/** Create a throwaway repo-like tree and return its root path. */
function makeFixtureRoot(layout) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-manifest-"));
for (const [relPath, contents] of Object.entries(layout)) {
const full = path.join(root, relPath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, contents);
}
return root;
}
const SKILL_FM = "---\nname: x\ndescription: y\n---\n";
test("discoverSkillPaths returns sorted ./-prefixed dirs that contain SKILL.md", () => {
const root = makeFixtureRoot({
"lotto-results/SKILL.md": SKILL_FM,
"ktx-booking/SKILL.md": SKILL_FM,
"not-a-skill/README.md": "no skill here",
"top-level-file.md": "ignored",
});
assert.deepEqual(discoverSkillPaths(root), ["./ktx-booking", "./lotto-results"]);
});
test("discoverSkillPaths excludes infrastructure dirs and nested fixtures", () => {
const root = makeFixtureRoot({
"lotto-results/SKILL.md": SKILL_FM,
// Excluded root dirs that happen to contain a SKILL.md somewhere.
"packages/k-lotto/SKILL.md": SKILL_FM,
"scripts/SKILL.md": SKILL_FM,
"tools/k-skill-qa-bot/test/fixtures/skills/kbo-results/SKILL.md": SKILL_FM,
"docs/SKILL.md": SKILL_FM,
// Dot-directory must be skipped regardless of contents.
".github/SKILL.md": SKILL_FM,
});
assert.deepEqual(discoverSkillPaths(root), ["./lotto-results"]);
});
test("discoverSkillPaths drops deprecated EXCLUDED_SKILLS", () => {
assert.ok(EXCLUDED_SKILLS.has("blue-ribbon-nearby"));
const root = makeFixtureRoot({
"blue-ribbon-nearby/SKILL.md": SKILL_FM,
"lotto-results/SKILL.md": SKILL_FM,
});
assert.deepEqual(discoverSkillPaths(root), ["./lotto-results"]);
});
test("discoverSkillPaths ignores legacy skills even when they contain SKILL.md", () => {
const root = makeFixtureRoot({
"legacy/blue-ribbon-nearby/SKILL.md": SKILL_FM,
"legacy/naver-map-route/SKILL.md": SKILL_FM,
"naver-map-route/SKILL.md": SKILL_FM,
"lotto-results/SKILL.md": SKILL_FM,
});
assert.ok(EXCLUDED_SKILLS.has("naver-map-route"));
assert.deepEqual(discoverSkillPaths(root), ["./lotto-results"]);
});
test("buildManifest backfills identity fields and preserves author overrides", () => {
const root = makeFixtureRoot({ "lotto-results/SKILL.md": SKILL_FM });
// Pre-seed a manifest with a custom description that must survive.
fs.mkdirSync(path.join(root, ".claude-plugin"), { recursive: true });
fs.writeFileSync(
manifestPathFor(root),
serialize({ name: "k-skill", description: "custom desc", skills: [] }),
);
const manifest = buildManifest(root);
assert.equal(manifest.description, "custom desc"); // not clobbered
assert.equal(manifest.license, "MIT"); // backfilled from default
assert.deepEqual(manifest.skills, ["./lotto-results"]); // always refreshed
});
test("run --check passes when manifest matches, fails after drift", () => {
const root = makeFixtureRoot({ "lotto-results/SKILL.md": SKILL_FM });
// First write, then a check should agree.
const written = run({ root });
assert.equal(written.written, true);
assert.equal(run({ root, check: true }).ok, true);
// Add a new skill on disk -> check must now report drift.
fs.mkdirSync(path.join(root, "ktx-booking"));
fs.writeFileSync(path.join(root, "ktx-booking", "SKILL.md"), SKILL_FM);
assert.equal(run({ root, check: true }).ok, false);
});
test("run writes deterministic, trailing-newline JSON", () => {
const root = makeFixtureRoot({ "lotto-results/SKILL.md": SKILL_FM });
run({ root });
const raw = fs.readFileSync(manifestPathFor(root), "utf8");
assert.ok(raw.endsWith("\n"));
assert.equal(raw, serialize(buildManifest(root)));
});
test("marketplace manifest uses Claude validator-supported top-level keys", () => {
const marketplacePath = path.join(__dirname, "..", ".claude-plugin", "marketplace.json");
const marketplace = JSON.parse(fs.readFileSync(marketplacePath, "utf8"));
assert.deepEqual(Object.keys(marketplace).sort(), ["name", "owner", "plugins"]);
});

View file

@ -100,12 +100,29 @@ class FakeNCard:
class FakeClient:
def __init__(self, trains, search_handler=None, ncards=None, ncard_trains=None):
def __init__(
self,
trains,
search_handler=None,
ncards=None,
ncard_trains=None,
train_details=None,
cars=None,
seats_by_car=None,
seat_payloads_by_car=None,
):
self._trains = trains
self._search_handler = search_handler
self._ncards = ncards or []
self._ncard_trains = ncard_trains or []
self._train_details = train_details
self._cars = cars or []
self._seats_by_car = seats_by_car or {}
self._seat_payloads_by_car = seat_payloads_by_car or {}
self.search_calls = []
self.search_detail_calls = []
self.train_car_calls = []
self.car_seat_calls = []
self.reserved_train = None
self.reserved_passengers = None
@ -115,6 +132,31 @@ class FakeClient:
return list(self._search_handler(*args, **kwargs))
return list(self._trains)
def search_train_details(self, *args, **kwargs):
self.search_detail_calls.append(kwargs)
if self._train_details is not None:
return list(self._train_details)
return [(train, {}) for train in self._trains]
def train_cars(self, raw_train, passenger_count=1, room_class="1"):
self.train_car_calls.append({
"raw_train": raw_train,
"passenger_count": passenger_count,
"room_class": room_class,
})
return list(self._cars)
def car_seats(self, raw_train, car_no, passenger_count=1, room_class="1"):
self.car_seat_calls.append({
"raw_train": raw_train,
"car_no": car_no,
"passenger_count": passenger_count,
"room_class": room_class,
})
if car_no in self._seat_payloads_by_car:
return self._seat_payloads_by_car[car_no]
return {"seat_infos": {"seat_info": list(self._seats_by_car.get(car_no, []))}}
def reserve(self, train, **kwargs):
self.reserved_train = train
self.reserved_passengers = kwargs.get("passengers")
@ -173,6 +215,32 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(args.train_id, "ktx:v1:test")
self.assertEqual(args.train_type, "ktx")
def test_build_parser_accepts_seats_filters(self):
args = ktx_booking.build_parser().parse_args([
"seats",
"서울",
"부산",
"20260328",
"090000",
"--train-id",
"ktx:v1:test",
"--room",
"special",
"--car-no",
"5",
"--available-only",
"--power-only",
"--limit",
"10",
])
self.assertEqual(args.train_id, "ktx:v1:test")
self.assertEqual(args.room, "special")
self.assertEqual(args.car_no, 5)
self.assertTrue(args.available_only)
self.assertTrue(args.power_only)
self.assertEqual(args.limit, 10)
def test_build_parser_defaults_search_train_type_to_ktx(self):
args = ktx_booking.build_parser().parse_args([
"search",
@ -207,8 +275,89 @@ class KtxBookingTests(unittest.TestCase):
"--train-type",
train_type,
])
seats_args = parser.parse_args([
"seats",
"서울",
"부산",
"20260328",
"090000",
"--train-id",
"ktx:v1:test",
"--train-type",
train_type,
])
self.assertEqual(search_args.train_type, train_type)
self.assertEqual(reserve_args.train_type, train_type)
self.assertEqual(seats_args.train_type, train_type)
def test_normalize_car_and_seat_maps_korail_codes(self):
car = ktx_booking.normalize_car({
"h_srcar_no": "05",
"h_psrm_cl_cd": "1",
"h_psrm_cl_nm": "ignored",
"h_seat_cnt": "48",
"h_rest_seat_cnt": "7",
})
seat = ktx_booking.normalize_seat({
"h_con_seat_no": "7A",
"h_seat_no": "007001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
"h_door_nbor_flg": "Y",
})
self.assertEqual(car["car_no"], 5)
self.assertEqual(car["room_class"], "일반실")
self.assertEqual(car["remaining_seats"], 7)
self.assertEqual(seat["seat"], "7A")
self.assertTrue(seat["available"])
self.assertEqual(seat["direction"], "순방향")
self.assertEqual(seat["position"], "창측")
self.assertEqual(seat["seat_type"], "일반석")
self.assertTrue(seat["near_door"])
self.assertEqual(seat["power_outlet"], "direct")
def test_power_outlet_match_distinguishes_direct_adjacent_and_none(self):
self.assertEqual(ktx_booking.power_outlet_match("1A"), "direct")
self.assertEqual(ktx_booking.power_outlet_match("1B"), "adjacent")
self.assertEqual(ktx_booking.power_outlet_match("2A"), "none")
self.assertEqual(ktx_booking.power_outlet_match("bad"), "none")
def test_booking_priority_sorts_middle_cars_before_end_cars(self):
cars = [
{"car_no": 1},
{"car_no": 8},
{"car_no": 2},
{"car_no": 7},
{"car_no": 3},
{"car_no": 6},
{"car_no": 4},
{"car_no": 5},
]
sorted_cars = ktx_booking.sort_cars_for_booking(cars)
self.assertEqual([car["car_no"] for car in sorted_cars], [4, 5, 3, 6, 2, 7, 1, 8])
def test_booking_priority_sorts_power_outlet_before_forward_direction(self):
seats = [
{"seat": "2A", "power_outlet": "none", "direction": "순방향"},
{"seat": "1C", "power_outlet": "adjacent", "direction": "역방향"},
{"seat": "1A", "power_outlet": "direct", "direction": "역방향"},
{"seat": "3A", "power_outlet": "direct", "direction": "순방향"},
]
sorted_seats = ktx_booking.sort_seats_for_booking(seats)
self.assertEqual([seat["seat"] for seat in sorted_seats], ["3A", "1A", "1C", "2A"])
def test_is_phone_login_id_accepts_digits_only_mobile_numbers(self):
self.assertTrue(ktx_booking.is_phone_login_id("01012345678"))
self.assertTrue(ktx_booking.is_phone_login_id("0101234567"))
self.assertFalse(ktx_booking.is_phone_login_id("1234567890"))
self.assertFalse(ktx_booking.is_phone_login_id("user@example.com"))
def test_command_search_replays_selected_train_type(self):
selected = FakeTrain(
@ -316,6 +465,713 @@ class KtxBookingTests(unittest.TestCase):
self.assertTrue(client.search_calls[-1]["include_waiting_list"])
self.assertIs(client.reserved_train, waiting_only)
def test_command_seats_returns_available_power_seats_for_selected_car(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[
{
"h_srcar_no": "04",
"h_psrm_cl_cd": "1",
"h_seat_cnt": "48",
"h_rest_seat_cnt": "9",
},
{
"h_srcar_no": "05",
"h_psrm_cl_cd": "1",
"h_seat_cnt": "48",
"h_rest_seat_cnt": "3",
},
],
seats_by_car={
"05": [
{
"h_con_seat_no": "1A",
"h_seat_no": "001001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "1B",
"h_seat_no": "001002",
"h_sale_psb_flg": "N",
"h_for_rev_dir_dv": "010",
"h_sigl_win_in_dv": "013",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "2A",
"h_seat_no": "002001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "0A",
"h_seat_no": "000000",
"h_sale_psb_flg": "Y",
},
],
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=2,
children=1,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=5,
available_only=True,
power_only=True,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["room"], "general")
self.assertEqual(result["passenger_count"], 3)
self.assertTrue(result["available_only"])
self.assertTrue(result["power_only"])
self.assertEqual(len(result["cars"]), 1)
self.assertEqual(result["cars"][0]["car_no"], 5)
self.assertEqual(result["cars"][0]["remaining_seats"], 3)
self.assertEqual(result["cars"][0]["available_seat_count"], 1)
self.assertEqual(result["cars"][0]["available_seats"], ["1A"])
self.assertEqual(result["cars"][0]["shown_seat_count"], 1)
self.assertEqual(result["cars"][0]["seats"][0]["seat"], "1A")
self.assertEqual(result["cars"][0]["seats"][0]["power_outlet"], "direct")
self.assertEqual(client.search_detail_calls[-1]["train_type"], ktx_booking.TRAIN_TYPE_MAP["ktx"])
self.assertTrue(client.search_detail_calls[-1]["include_no_seats"])
self.assertTrue(client.search_detail_calls[-1]["include_waiting_list"])
self.assertEqual(client.train_car_calls[-1]["passenger_count"], 3)
self.assertEqual(client.train_car_calls[-1]["room_class"], "1")
self.assertEqual(client.car_seat_calls[-1]["car_no"], "05")
def test_command_seats_explores_middle_cars_first(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[
{"h_srcar_no": "01", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "08", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "04", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
],
seats_by_car={
car_no: [{"h_con_seat_no": "1A", "h_seat_no": "001001", "h_sale_psb_flg": "Y"}]
for car_no in ("01", "04", "05", "08")
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual([car["car_no"] for car in result["cars"]], [4, 5, 1, 8])
self.assertEqual([call["car_no"] for call in client.car_seat_calls], ["04", "05", "01", "08"])
def test_command_seats_outputs_available_seats_by_booking_preference(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "4"}],
seats_by_car={
"05": [
{
"h_con_seat_no": "2A",
"h_seat_no": "002001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "1C",
"h_seat_no": "001003",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "010",
"h_sigl_win_in_dv": "013",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "1A",
"h_seat_no": "001001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "010",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "3A",
"h_seat_no": "003001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
],
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=True,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
car = result["cars"][0]
self.assertEqual(car["available_seats"], ["3A", "1A", "1C", "2A"])
self.assertEqual([seat["seat"] for seat in car["seats"]], ["3A", "1A", "1C", "2A"])
def test_command_seats_available_summary_matches_power_filter(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "4"}],
seats_by_car={
"05": [
{
"h_con_seat_no": "1A",
"h_seat_no": "001001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "2C",
"h_seat_no": "002003",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "013",
"h_dmd_seat_att": "015",
},
],
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=True,
power_only=True,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
car = json.loads(output.getvalue())["cars"][0]
self.assertEqual(car["available_seat_count"], 1)
self.assertEqual(car["available_seats"], ["1A"])
self.assertEqual([seat["seat"] for seat in car["seats"]], ["1A"])
def test_command_seats_supports_special_room_and_stale_train_error(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
other = FakeTrain(train_no="011", dep_time="093000", arr_time="120000", label="other")
client = FakeClient(
[],
train_details=[(other, {"h_trn_no": "011"})],
cars=[{"h_srcar_no": "01", "h_psrm_cl_cd": "2", "h_seat_cnt": "30", "h_rest_seat_cnt": "1"}],
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="special",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("train_id", str(exc.exception))
def test_command_seats_fails_when_requested_car_is_not_available(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "04", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=5,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("car_no 5", str(exc.exception))
def test_command_seats_fails_when_seat_payload_is_malformed(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "4"}],
)
client.car_seats = lambda *args, **kwargs: {"seat_infos": None}
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_seat_info_key_is_missing(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
)
client.car_seats = lambda *args, **kwargs: {"seat_infos": {}}
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_remaining_seats_have_empty_seat_info(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
seat_payloads_by_car={"05": {"seat_infos": {"seat_info": []}}},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_seat_info_contains_non_object_entries(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
for bad_entry in ["bad", None]:
with self.subTest(bad_entry=bad_entry):
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
seat_payloads_by_car={"05": {"seat_infos": {"seat_info": [bad_entry]}}},
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_remaining_seats_have_only_sentinel_seat_info(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
seat_payloads_by_car={
"05": {
"seat_infos": {
"seat_info": [
{"h_con_seat_no": "0A", "h_seat_no": "000000", "h_sale_psb_flg": "N"},
],
},
},
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_fails_when_seat_info_object_is_missing_required_fields(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
malformed_rows = [
{},
{"h_seat_no": "001001", "h_sale_psb_flg": "Y"},
{"h_con_seat_no": "1A", "h_sale_psb_flg": "Y"},
{"h_con_seat_no": "1A", "h_seat_no": "001001"},
]
for row in malformed_rows:
with self.subTest(row=row):
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"}],
seat_payloads_by_car={"05": {"seat_infos": {"seat_info": [row]}}},
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat detail data is unavailable", str(exc.exception))
def test_command_seats_allows_empty_seat_info_when_no_remaining_seats(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[{"h_srcar_no": 5, "h_psrm_cl_cd": "1", "h_seat_cnt": 48, "h_rest_seat_cnt": 0}],
seat_payloads_by_car={"5": {"seat_infos": {"seat_info": []}}},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
car = json.loads(output.getvalue())["cars"][0]
self.assertEqual(car["remaining_seats"], 0)
self.assertEqual(car["available_seat_count"], 0)
self.assertEqual(car["seats"], [])
def test_command_seats_fails_when_car_metadata_is_malformed(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
malformed_cars = [
"bad",
{"h_srcar_no": "bad", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "9"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "bad", "h_rest_seat_cnt": "9"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "bad"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_rest_seat_cnt": "9"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48"},
]
for raw_car in malformed_cars:
with self.subTest(raw_car=raw_car):
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[raw_car],
seats_by_car={"05": [{"h_con_seat_no": "1A", "h_seat_no": "001001", "h_sale_psb_flg": "Y"}]},
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat car data is unavailable", str(exc.exception))
def test_command_seats_fails_when_car_data_is_unavailable(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, {"h_trn_no": "009"})],
cars=[],
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
with patch.object(ktx_booking, "build_client", return_value=client):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
ktx_booking.command_seats(args)
self.assertIn("seat car data is unavailable", str(exc.exception))
def test_seat_research_endpoints_use_dynapath_sid_boundary(self):
class FakeEngine:
def __init__(self):
self.calls = []
def generate_token(self, device_id, timestamp_ms, nonce):
self.calls.append((device_id, timestamp_ms, nonce))
return "dynapath-token"
client = ktx_booking.PatchedKorail.__new__(ktx_booking.PatchedKorail)
client._engine = FakeEngine()
client._device_id = "device-id"
client._generate_sid = lambda timestamp_ms: f"sid-{timestamp_ms}"
for url in (ktx_booking.KORAIL_CARS_INFO, ktx_booking.KORAIL_CAR_DETAIL):
with self.subTest(url=url):
with patch.object(ktx_booking.time, "time", return_value=1234.567):
with patch.object(ktx_booking.random, "choices", return_value=list("ABCD")):
headers, sid = client._auth_headers_and_sid(url)
self.assertEqual(headers["x-dynapath-m-token"], "dynapath-token")
self.assertEqual(sid, "sid-1234567")
self.assertEqual(
client._engine.calls,
[("device-id", 1234567, "ABCD"), ("device-id", 1234567, "ABCD")],
)
def test_build_parser_has_ncard_commands(self):
parser = ktx_booking.build_parser()
help_text = parser.format_help()

View file

@ -36,10 +36,12 @@ while IFS= read -r -d '' skill_dir; do
fi
done < <(
find "$root" -mindepth 1 -maxdepth 1 -type d \
! -name '.*' \
! -name .git \
! -name .github \
! -name .codex \
! -name .claude \
! -name .agents \
! -name .omx \
! -name .ouroboros \
! -name .changeset \
@ -47,8 +49,10 @@ done < <(
! -name .vscode \
! -name .sisyphus \
! -name .idea \
! -name .venv \
! -name dist \
! -name docs \
! -name legacy \
! -name node_modules \
! -name packages \
! -name python-packages \