Merge pull request #188 from seolcoding/feature/foresttrip-vacancy

 feat: 자연휴양림 빈 객실 조회 스킬 추가
This commit is contained in:
Donghun Seol 2026-04-30 01:28:57 +09:00 committed by GitHub
commit c51bcef87f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 864 additions and 1 deletions

View file

@ -22,6 +22,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) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
@ -112,6 +113,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [SRT 예매](docs/features/srt-booking.md)
- [KTX 예매](docs/features/ktx-booking.md)
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)

View file

@ -0,0 +1,147 @@
# 자연휴양림 빈 객실 조회 가이드
대상 사이트는 숲나들e 공식 사이트 `https://foresttrip.go.kr/index.jsp` 이다. 이 기능은 해당 사이트의 자연휴양림 예약 가능 객실 **조회 자동화**만 수행한다.
## 이 기능으로 할 수 있는 일
- 숲나들e/자연휴양림 예약 가능 객실 조회
- 특정 날짜 또는 여러 날짜 기준 조회
- 전체 자연휴양림 또는 휴양림명/ID 기준 조회
- 숙박/야영 카테고리별 조회
- JSON 또는 사람이 읽기 좋은 텍스트 출력
이 기능은 **조회 전용 자동화**이다. 예약 신청, 결제, 캡차 처리, 대기열 우회, 반복 스나이핑은 하지 않는다.
## 먼저 필요한 것
- Python 3.9+
- Playwright Chromium
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
```bash
python3 -m pip install playwright
python3 -m playwright install chromium
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --check-deps
```
`--check-deps` 는 숲나들e 로그인이나 네트워크 조회를 수행하지 않고, 로컬 Python/Playwright Chromium 준비 상태만 확인한다.
## 필요한 환경변수
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
선택:
- 없음
### Credential resolution order
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
helper는 `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` 환경변수를 읽는다. secret vault나 `secrets.env` 는 에이전트/사용자가 값을 꺼내 실행 환경에 주입하기 위한 저장 위치이며, helper가 임의로 계정 정보를 다른 곳에 저장하지 않는다.
## 처음 실행 순서
처음 쓰는 사용자는 의존성 확인 후 환경변수를 현재 shell에만 주입해서 1개 휴양림으로 먼저 조회한다.
```bash
export KSKILL_FORESTTRIP_ID="your-foresttrip-id"
export KSKILL_FORESTTRIP_PASSWORD="your-foresttrip-password"
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --check-deps
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates 20260504
```
성공 여부를 먼저 보려면 전체 조회보다 `--forest-name` 또는 `--forest-id` 로 범위를 좁혀 실행한다. JSON 결과가 필요하면 같은 조건에 `--json` 을 사용한다.
## 입력값
- 날짜: `YYYYMMDD`
- 여러 날짜: `YYYYMMDD,YYYYMMDD`
- 조회 범위: 전체 자연휴양림, 휴양림 ID, 휴양림명 부분 일치
- 카테고리:
- `01`: 숙박
- `02`: 야영/캠핑
- `01,02`: 숙박 + 야영/캠핑
- 고급 옵션:
- `--week-range N`: `--dates` 를 생략했을 때만 오늘부터 N주 조회
- `--concurrency N`: 병렬 조회 worker 수, 1-5 범위
- `--session-cache PATH`: 로그인 세션 캐시 경로 override
## 기본 흐름
1. `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` 를 확보한다.
2. 필요한 경우 `python3 -m pip install playwright``python3 -m playwright install chromium` 을 실행한다.
3. helper로 read-only 월별예약조회 endpoint를 실행한다.
4. helper가 로그인 세션, CSRF, 공식 휴양림 ID 목록을 확보한다.
5. 날짜, 휴양림명, 객실/시설명, 숙박/야영 구분, 정원 중심으로 요약한다.
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 구현은 로그인 세션/CSRF 확보를 필수 전제로 둔다.
## 검증 방식
메인테이너가 별도 숲나들e 계정을 새로 만들 필요는 없다.
- CI/리뷰 검증: `./scripts/validate-skills.sh`, `python3 -m py_compile ...`, `--help`, `--check-deps` 로 진행한다.
- 실제 조회 검증: 기여자 또는 이미 숲나들e 계정을 가진 사용자가 개인 계정으로 선택 실행한다.
- PR에는 실제 조회 결과의 `forests_scanned`, `fetch_failures`, `filter_hits` 같은 비민감 요약값만 기록하고, 계정 정보와 세션 쿠키는 공유하지 않는다.
## 예시
전체 자연휴양림에서 하루 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --dates 20260504
```
JSON으로 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --json --dates 20260504
```
여러 날짜 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --dates 20260504,20260505
```
야영/캠핑만 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --dates 20260504 --categories 02
```
휴양림명으로 좁혀 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates 20260504
```
로그인 세션 캐시를 무시하고 새로 조회:
```bash
python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --dates 20260504 --refresh-session
```
## 주의할 점
- 예약 자동화가 아니다.
- 결제, 캡차 처리, 대기열 우회는 하지 않는다.
- aggressive polling은 피한다.
- 조회 결과는 시점 차이로 숲나들e 화면과 달라질 수 있다.
- 로그인 실패 시 계정 정보 또는 숲나들e 정책 변경을 먼저 확인한다.
## 흔한 문제 해결
- `Playwright browser missing`: `python3 -m playwright install chromium` 을 실행한다.
- `Missing KSKILL_FORESTTRIP_ID` 또는 `Missing KSKILL_FORESTTRIP_PASSWORD`: 환경변수가 현재 shell에 주입됐는지 확인한다.
- 로그인 실패: 숲나들e 웹사이트에서 같은 계정으로 직접 로그인되는지 먼저 확인한다.
- 날짜/카테고리/출력 옵션 오류: helper가 로그인 전에 argparse error로 중단하므로 메시지에 맞춰 값을 고친다.
- JSON 대신 HTML 안내 페이지가 반환됨: 세션/CSRF가 없거나 만료된 상태일 수 있으므로 `--refresh-session` 으로 1회 재조회한다.
- 일부 휴양림 fetch failure: 성공한 결과와 실패 개수를 함께 보고하고, 반복 polling으로 보정하지 않는다.

View file

@ -47,6 +47,7 @@ npx --yes skills add <owner/repo> \
--skill hwp \
--skill rhwp-edit \
--skill rhwp-advanced \
--skill foresttrip-vacancy \
--skill kbo-results \
--skill kbl-results \
--skill kleague-results \
@ -101,6 +102,7 @@ npx --yes skills add <owner/repo> \
--skill k-skill-setup \
--skill srt-booking \
--skill ktx-booking \
--skill foresttrip-vacancy \
--skill korean-law-search \
--skill real-estate-search \
--skill mfds-drug-safety \

View file

@ -24,6 +24,8 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -61,6 +63,8 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_SRT_PASSWORD`
- `KSKILL_KTX_ID`
- `KSKILL_KTX_PASSWORD`
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
## Credential resolution order
@ -24,6 +24,8 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -69,6 +71,7 @@ bash scripts/check-setup.sh
| --- | --- |
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
| 자연휴양림 빈 객실 조회 | `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` |
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
@ -90,6 +93,7 @@ bash scripts/check-setup.sh
- [SRT 예매 가이드](features/srt-booking.md)
- [KTX 예매 가이드](features/ktx-booking.md)
- [자연휴양림 빈 객실 조회 가이드](features/foresttrip-vacancy.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [한국 날씨 조회 가이드](features/korea-weather.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)

View file

@ -6,6 +6,10 @@
- `SRTrain` / `ryanking13/SRT`: https://github.com/ryanking13/SRT
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 숲나들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
- 숲나들e 월별 예약 가능 정보 JSON endpoint: https://www.foresttrip.go.kr/rep/or/selectRsrvtAvailInfoListForMonthRsrvtSmpl.do
- `kbo-game`: https://github.com/vkehfdl1/kbo-game
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team

View file

@ -2,6 +2,8 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me

174
foresttrip-vacancy/SKILL.md Normal file
View file

@ -0,0 +1,174 @@
---
name: foresttrip-vacancy
description: Look up available Korean national forest recreation lodging or camping slots on foresttrip.go.kr. Use when the user asks for 숲나들e or 자연휴양림 빈 객실/빈자리 조회, not for booking.
license: MIT
metadata:
category: travel
locale: ko-KR
phase: v1.5
---
# Foresttrip Vacancy
## What this skill does
숲나들e 공식 사이트(`https://foresttrip.go.kr/index.jsp`)에서 자연휴양림 예약 가능 객실을 날짜 기준으로 조회 자동화한다.
이 스킬은 **조회 전용 자동화**이다. 예약 신청, 결제, 캡차 처리, 대기열 우회, 반복 스나이핑은 범위에 포함하지 않는다.
## When to use
- "이번 주말 자연휴양림 빈 객실 있어?"
- "숲나들e 2026년 5월 4일 예약 가능한 곳 조회해줘"
- "자연휴양림 빈자리 전체 조회해줘"
- "관심 휴양림 중 예약 가능한 객실만 알려줘"
## When not to use
- 예약 신청이나 결제까지 자동화해야 하는 경우
- 캡차를 풀거나 대기열을 우회해야 하는 경우
- 계정 정보를 채팅창에 직접 넣으려는 경우
- aggressive polling, 스나이핑, 반복 예약 시도가 필요한 경우
## Prerequisites
- Python 3.9+
- Playwright Chromium browser
```bash
python3 -m pip install playwright
python3 -m playwright install chromium
python3 scripts/run_foresttrip_vacancy.py --check-deps
```
## Required environment variables
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
Optional:
- none
### Credential resolution order
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
Helper 자체는 `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` 환경변수만 읽는다. vault나 `secrets.env` 를 사용하는 경우에도 실행 전에 해당 값을 환경변수로 주입한다.
## Inputs
- 날짜: `YYYYMMDD`, 여러 날짜면 comma-separated `YYYYMMDD,YYYYMMDD`
- 조회 범위:
- `--all`: 전체 자연휴양림 조회
- `--forest-id`: 특정 `insttId` 조회
- `--forest-name`: 공식 휴양림명 부분 일치 조회
- 출력 형식:
- `--text`: 사람용 요약
- `--json`: 구조화 결과
- 선택 필터:
- `--categories 01`: 숙박
- `--categories 02`: 야영/캠핑
- `--categories 01,02`: 숙박 + 야영/캠핑
- 고급 실행 옵션:
- `--week-range N`: `--dates` 를 생략했을 때만 오늘부터 N주 범위를 조회
- `--concurrency N`: 병렬 조회 worker 수, 1-5 범위
- `--session-cache PATH`: 로그인 세션 캐시 경로 override
## Workflow
### 1. Ensure credentials are available
`KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` 가 설정되어 있는지 확인한다. 없으면 credential resolution order에 따라 확보한다.
시크릿이 없다는 이유로 대체 사이트, 캡차 우회, 비공식 예약 경로를 찾지 않는다.
### 2. Install runtime dependencies when missing
```bash
python3 -m pip install playwright
python3 -m playwright install chromium
```
### 3. Run a vacancy lookup
이 스킬의 helper를 통해 조회한다. Helper는 Playwright로 숲나들e에 로그인해 CSRF/cookie와 공식 휴양림 ID 목록을 얻은 뒤, 월별예약조회 JSON endpoint만 호출한다.
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 helper는 로그인 세션/CSRF 확보를 필수 전제로 둔다.
전체 자연휴양림에서 특정 날짜 조회:
```bash
python3 scripts/run_foresttrip_vacancy.py --all --text --dates 20260504
```
JSON 출력:
```bash
python3 scripts/run_foresttrip_vacancy.py --all --json --dates 20260504
```
캠핑/야영만 조회:
```bash
python3 scripts/run_foresttrip_vacancy.py --all --text --dates 20260504 --categories 02
```
특정 휴양림명으로 조회:
```bash
python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates 20260504
```
### 4. Summarize results conservatively
응답은 아래 항목 중심으로 짧게 정리한다.
- 조회 날짜
- 조회 범위
- 예약 가능한 휴양림명
- 객실/시설명
- 숙박/야영 구분
- 정원 또는 수용 인원
- fetch failure가 있으면 실패 개수
결과가 없으면 "조회 시점 기준 예약 가능 객실 없음"이라고 말한다. 실제 예약 가능 여부는 숲나들e 화면에서 재확인될 수 있음을 덧붙인다.
## Done when
- 요청 날짜와 조회 범위가 명확하다.
- read-only 월별예약조회 helper를 최소 1회 실행했다.
- 빈 객실이 있으면 날짜/휴양림/객실을 정리했다.
- 빈 객실이 없으면 없다고 명확히 말했다.
- 예약/결제/대기열 우회는 시도하지 않았다.
## Failure modes
- 로그인 실패: `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` 확인
- Playwright browser 미설치: `python3 -m playwright install chromium`
- fetch failure 일부 발생: 결과와 실패 개수를 함께 보고하고, 필요하면 `--refresh-session` 으로 1회 재조회
- 숲나들e 표면 변경: helper의 login/session bootstrap 또는 parser 점검 필요
## Maintainer review notes
메인테이너가 이 스킬을 검토하기 위해 숲나들e 계정을 새로 만들 필요는 없다.
계정 없이 가능한 검증:
- `./scripts/validate-skills.sh`
- `python3 -m py_compile foresttrip-vacancy/scripts/run_foresttrip_vacancy.py`
- `python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --help`
- `python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --check-deps`
- `npm run ci`
실제 live smoke는 기여자 또는 이미 숲나들e 계정을 가진 사용자가 선택적으로 수행한다. PR에는 `forests_scanned`, `fetch_failures`, `filter_hits` 같은 비민감 요약만 남기고 계정 정보, 세션 쿠키, 개인 조회 세부 내역은 공유하지 않는다.
## Safety notes
- 조회 전용 스킬이다.
- 예약, 결제, 캡차 처리, 대기열 우회, 공격적인 반복 조회를 하지 않는다.
- 계정 시크릿은 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다.

View file

@ -0,0 +1,521 @@
#!/usr/bin/env python3
"""Read-only foresttrip.go.kr vacancy lookup helper.
The script logs in with Playwright to obtain a CSRF token and session cookies,
extracts forest IDs from the official monthly reservation status page, then
queries the read-only monthly availability JSON endpoint.
It intentionally does not click booking buttons, submit reservation forms,
handle payment, solve captcha, or bypass queues.
"""
from __future__ import annotations
import argparse
import concurrent.futures
import json
import os
import sys
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
LOGIN_URL = "https://www.foresttrip.go.kr/com/login.do"
RSRVT_PAGE = "https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do"
POST_URL = "https://www.foresttrip.go.kr/rep/or/selectRsrvtAvailInfoListForMonthRsrvtSmpl.do"
DEFAULT_CONCURRENCY = 4
MAX_CONCURRENCY = 5
DEFAULT_WEEK_RANGE = 1
CATEGORY_CODES = {"01", "02"}
@dataclass
class Session:
cookies: dict[str, str]
csrf: str
user_agent: str
forests: dict[str, str]
expires_at: float
def parse_csv(value: str) -> list[str]:
return [part.strip() for part in value.split(",") if part.strip()]
def parse_categories(value: str) -> tuple[str, ...]:
categories = parse_csv(value)
if not categories:
raise argparse.ArgumentTypeError("must include at least one category code")
invalid = [category for category in categories if category not in CATEGORY_CODES]
if invalid:
raise argparse.ArgumentTypeError(
"unknown category code(s): "
+ ", ".join(invalid)
+ " (allowed: 01=lodging, 02=camping)"
)
return tuple(dict.fromkeys(categories))
def parse_dates(value: str) -> tuple[str, ...]:
dates = parse_csv(value)
if not dates:
raise argparse.ArgumentTypeError("must include at least one YYYYMMDD date")
today = datetime.now().date()
normalized: list[str] = []
for raw_date in dates:
try:
parsed = datetime.strptime(raw_date, "%Y%m%d").date()
except ValueError as exc:
raise argparse.ArgumentTypeError(f"invalid YYYYMMDD date: {raw_date}") from exc
if parsed.strftime("%Y%m%d") != raw_date:
raise argparse.ArgumentTypeError(f"invalid YYYYMMDD date: {raw_date}")
if parsed < today:
raise argparse.ArgumentTypeError(f"date is in the past: {raw_date}")
normalized.append(raw_date)
return tuple(sorted(dict.fromkeys(normalized)))
def parse_concurrency(value: str) -> int:
try:
concurrency = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer") from exc
if not 1 <= concurrency <= MAX_CONCURRENCY:
raise argparse.ArgumentTypeError(f"must be between 1 and {MAX_CONCURRENCY}")
return concurrency
def parse_week_range(value: str) -> int:
try:
week_range = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer") from exc
if week_range < 1:
raise argparse.ArgumentTypeError("must be at least 1")
return week_range
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read-only foresttrip.go.kr vacancy lookup.",
)
target = parser.add_argument_group("target selection")
target.add_argument("--all", action="store_true", help="Scan all extracted forest IDs.")
target.add_argument(
"--forest-id",
action="append",
help="ForestTrip insttId. Can be passed multiple times or comma-separated.",
)
target.add_argument(
"--forest-name",
action="append",
help="Substring to match against official forest names.",
)
output = parser.add_mutually_exclusive_group()
output.add_argument("--json", action="store_true", help="Print JSON output.")
output.add_argument("--text", action="store_true", help="Print human-readable output.")
parser.add_argument("--dates", type=parse_dates, help="Comma-separated YYYYMMDD dates.")
parser.add_argument(
"--categories",
type=parse_categories,
default=("01", "02"),
help="Comma-separated category codes: 01=lodging, 02=camping.",
)
parser.add_argument(
"--concurrency",
type=parse_concurrency,
default=DEFAULT_CONCURRENCY,
help=f"Parallel POST workers, 1-{MAX_CONCURRENCY}.",
)
parser.add_argument("--week-range", type=parse_week_range, help="Weeks ahead to scan when --dates is omitted.")
parser.add_argument("--refresh-session", action="store_true", help="Ignore session cache.")
parser.add_argument("--check-deps", action="store_true", help="Check Python and Playwright runtime dependencies.")
parser.add_argument(
"--session-cache",
default="~/.cache/k-skill/foresttrip-vacancy/session.json",
help="Session cache path.",
)
args = parser.parse_args()
if args.all and (args.forest_id or args.forest_name):
parser.error("--all cannot be combined with --forest-id or --forest-name")
if args.dates and args.week_range is not None:
parser.error("--week-range cannot be combined with --dates; the lookup range is derived from --dates")
return args
def require_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise SystemExit(f"missing required environment variable: {name}")
return value
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
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright"
) from exc
if not launch_browser:
return
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
browser.close()
except PlaywrightError as exc:
raise SystemExit(
"playwright chromium browser is required. Install with: "
"python3 -m playwright install chromium"
) from exc
def load_session_cache(path: Path) -> Session | None:
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
if time.time() > float(data.get("expires_at", 0)):
return None
return Session(
cookies=dict(data["cookies"]),
csrf=str(data["csrf"]),
user_agent=str(data["user_agent"]),
forests=dict(data["forests"]),
expires_at=float(data["expires_at"]),
)
except Exception:
return None
def save_session_cache(path: Path, session: Session) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(session), ensure_ascii=False), encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
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
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright "
"&& python3 -m playwright install chromium"
) from exc
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except PlaywrightError as exc:
raise SystemExit(
"playwright chromium browser is required. Install with: "
"python3 -m playwright install chromium"
) from exc
page = browser.new_page()
page.goto(LOGIN_URL)
page.fill("#mmberId", forest_id)
page.fill("#gnrlMmberPssrd", forest_pw)
page.click("input.loginBtn")
page.wait_for_load_state("networkidle")
page.goto(RSRVT_PAGE)
page.wait_for_load_state("networkidle")
csrf_locator = page.locator('input[name="_csrf"]')
if csrf_locator.count() == 0:
browser.close()
raise SystemExit("login succeeded page did not expose a CSRF token")
csrf = csrf_locator.first.get_attribute("value") or ""
forests: dict[str, str] = {}
regions = page.evaluate(
"""
() => Array.from(document.querySelector('#srchSido').options)
.slice(1)
.map(o => ({ value: o.value, text: o.textContent.trim() }))
"""
)
for region in regions:
value = region.get("value")
if not value:
continue
page.select_option("#srchSido", value=value)
page.wait_for_timeout(500)
options = page.evaluate(
"""
() => Array.from(document.querySelector('#srchInstt').options)
.slice(1)
.map(o => ({ value: o.value, text: o.textContent.trim() }))
"""
)
for opt in options:
fid = str(opt.get("value") or "").strip()
name = str(opt.get("text") or "").strip()
if fid and name:
forests[fid] = name
cookies = {cookie["name"]: cookie["value"] for cookie in page.context.cookies()}
user_agent = page.evaluate("() => navigator.userAgent")
browser.close()
if not csrf or not cookies:
raise SystemExit("failed to bootstrap foresttrip session")
if not forests:
raise SystemExit("failed to extract forest list from reservation page")
return Session(
cookies=cookies,
csrf=csrf,
user_agent=user_agent,
forests=forests,
expires_at=time.time() + ttl_sec,
)
def get_session(args: argparse.Namespace) -> Session:
cache_path = Path(args.session_cache).expanduser()
if not args.refresh_session:
cached = load_session_cache(cache_path)
if cached is not None:
return cached
session = bootstrap_session(
forest_id=require_env("KSKILL_FORESTTRIP_ID"),
forest_pw=require_env("KSKILL_FORESTTRIP_PASSWORD"),
)
save_session_cache(cache_path, session)
return session
def split_csv(values: list[str] | None) -> list[str]:
out: list[str] = []
for value in values or []:
out.extend(part.strip() for part in value.split(",") if part.strip())
return out
def resolve_targets(args: argparse.Namespace, forests: dict[str, str]) -> dict[str, str]:
if args.all:
return dict(sorted(forests.items(), key=lambda item: item[1]))
requested_ids = split_csv(args.forest_id)
requested_names = split_csv(args.forest_name)
targets: dict[str, str] = {}
for fid in requested_ids:
targets[fid] = forests.get(fid, fid)
for needle in requested_names:
matches = {
fid: name
for fid, name in forests.items()
if needle.replace(" ", "") in name.replace(" ", "")
}
targets.update(matches)
if not targets:
raise SystemExit("choose a target with --all, --forest-id, or --forest-name")
return dict(sorted(targets.items(), key=lambda item: item[1]))
def build_headers(session: Session) -> dict[str, str]:
cookie_header = "; ".join(f"{k}={v}" for k, v in session.cookies.items())
return {
"Accept": "application/json, text/javascript, */*; q=0.01",
"Accept-Language": "ko-KR,ko;q=0.9",
"Content-Type": "application/json; charset=UTF-8",
"Cookie": cookie_header,
"Origin": "https://www.foresttrip.go.kr",
"Referer": RSRVT_PAGE,
"User-Agent": session.user_agent,
"X-CSRF-Token": session.csrf,
"X-Requested-With": "XMLHttpRequest",
}
def fetch_one(
*,
session: Session,
forest_id: str,
category: str,
today: str,
last_day: str,
) -> tuple[str, str, list[dict[str, Any]] | None, str | None]:
payload = {
"insttId": forest_id,
"upperGoodsClsscCd": category,
"srchDate": today,
"lastDay": last_day,
"inqurSctin": "02",
}
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
POST_URL,
data=body,
headers=build_headers(session),
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
if response.status != 200:
return forest_id, category, None, f"http_{response.status}"
data = json.loads(response.read().decode("utf-8"))
if isinstance(data, list):
return forest_id, category, data, None
return forest_id, category, None, "unexpected_payload"
except urllib.error.HTTPError as exc:
return forest_id, category, None, f"http_{exc.code}"
except Exception as exc:
return forest_id, category, None, str(exc)
def is_available(row: dict[str, Any]) -> bool:
return row.get("rsrvtAvail") == "Y" and row.get("rsrvtCnt") == 0
def normalize_row(row: dict[str, Any], forests: dict[str, str]) -> dict[str, Any]:
instt_id = str(row.get("insttId") or "")
return {
"forest_id": instt_id,
"forest": forests.get(instt_id, row.get("insttNm") or instt_id),
"use_dt": row.get("useDt") or "",
"day": row.get("dywkDtTpcd"),
"name": row.get("goodsNm") or "",
"area": row.get("insttArea"),
"capacity": row.get("mxmmAccptCnt"),
"category": row.get("goodsClsscNm"),
"region": row.get("insttAreaNm"),
"waiting_possible": row.get("wtngPssblYn"),
}
def collect_results(
*,
session: Session,
targets: dict[str, str],
categories: tuple[str, ...],
dates: tuple[str, ...] | None,
week_range: int | None,
concurrency: int,
) -> dict[str, Any]:
now = datetime.now()
today = now.strftime("%Y%m%d")
last_day = (
max(dates)
if dates
else (now + timedelta(weeks=week_range or DEFAULT_WEEK_RANGE)).strftime("%Y%m%d")
)
date_filter = set(dates) if dates else None
failures: list[dict[str, str]] = []
rows: list[dict[str, Any]] = []
jobs = [
(forest_id, category)
for forest_id in targets
for category in categories
]
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
futures = [
pool.submit(
fetch_one,
session=session,
forest_id=forest_id,
category=category,
today=today,
last_day=last_day,
)
for forest_id, category in jobs
]
for future in concurrent.futures.as_completed(futures):
forest_id, category, data, error = future.result()
if error is not None or data is None:
failures.append({"forest_id": forest_id, "category": category, "error": error or "unknown"})
continue
for row in data:
if not is_available(row):
continue
normalized = normalize_row(row, session.forests)
if date_filter is not None and normalized["use_dt"] not in date_filter:
continue
rows.append(normalized)
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)
return {
"forests_scanned": len(targets),
"filter_hits": len(rows),
"fetch_failures": len(failures),
"failures": failures[:20],
"concurrency": concurrency,
"date_range": {"from": today, "to": last_day},
"results": [
{
"forest": forest_name,
"dates": [
{"use_dt": use_dt, "rooms": rooms}
for use_dt, rooms in sorted(rows_by_date.items())
],
}
for forest_name, rows_by_date in sorted(grouped.items())
],
}
def print_text(payload: dict[str, Any]) -> None:
print("=== ForestTrip Vacancy Lookup ===")
print(
f"filter_hits: {payload['filter_hits']} "
f"fetch_failures: {payload['fetch_failures']} "
f"forests_scanned: {payload['forests_scanned']}"
)
if not payload["results"]:
print("(no available rooms at lookup time)")
return
for forest in payload["results"]:
print(f"\n{forest['forest']}")
for date_group in forest["dates"]:
rooms = date_group["rooms"]
print(f" {date_group['use_dt']} - {len(rooms)} slot(s)")
for room in rooms[:8]:
capacity = room["capacity"] if room["capacity"] is not None else "?"
area = room["area"] if room["area"] is not None else "?"
print(f" - {room['name']} / {room['category']} / {area}sqm / max {capacity}")
def main() -> int:
args = parse_args()
if args.check_deps:
check_dependencies()
print("foresttrip-vacancy dependencies look ready")
return 0
session = get_session(args)
targets = resolve_targets(args, session.forests)
payload = collect_results(
session=session,
targets=targets,
categories=args.categories,
dates=args.dates,
week_range=args.week_range,
concurrency=args.concurrency,
)
if args.text and not args.json:
print_text(payload)
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0 if payload["fetch_failures"] == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -67,6 +67,8 @@ KSKILL_SRT_ID=replace-me
KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
@ -110,6 +112,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 자연휴양림 빈 객실 조회: `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD`
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)