Unblock ticket availability PR against current dev

Preserve the ticket availability checks while keeping the newer manus bundle CI additions from dev.\n\nConstraint: PR #234 was non-mergeable because root package scripts changed on both head and dev.\nRejected: Taking either side wholesale | would drop either ticket availability validation or manus bundle validation.\nConfidence: high\nScope-risk: narrow\nDirective: Keep root lint/test script additions additive when merging independent skills.\nTested: node JSON parse for package.json; git diff --check.\nNot-tested: Full npm run ci pending after merge commit.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-14 00:38:28 +09:00
commit 45dcfd0897
30 changed files with 2095 additions and 84 deletions

91
.github/workflows/manus-bundle.yml vendored Normal file
View file

@ -0,0 +1,91 @@
name: Publish Manus bundle
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "*/SKILL.md"
- "*/scripts/**"
- "*/references/**"
- "*/templates/**"
- "scripts/build-manus-bundle.js"
- "scripts/validate-skills.sh"
- ".github/workflows/manus-bundle.yml"
permissions:
contents: write
concurrency:
group: manus-bundle-latest
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
env:
RELEASE_TAG: manus-bundle-latest
RELEASE_TITLE: "Manus bundle (rolling)"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Build .skill bundle
run: npm run build:manus-bundle
- name: Verify build output
run: |
set -euo pipefail
test -f dist/manus/k-skill-manus-all.zip
test -f dist/manus/INDEX.md
count=$(ls dist/manus/*.skill | wc -l)
if [ "$count" -lt 1 ]; then
echo "no .skill files produced" >&2
exit 1
fi
echo "built $count .skill files"
- name: Publish rolling release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
notes=$(cat <<EOF
Auto-built Manus.ai-compatible \`.skill\` bundle for every skill in k-skill.
- **\`k-skill-manus-all.zip\`** — combined download containing every \`.skill\` file plus \`INDEX.md\`. Unzip, then drag-drop individual \`.skill\` files into Manus.
- **\`INDEX.md\`** — human-readable listing of every bundled skill.
Manus accepts one skill per upload (\`.skill\`/\`.zip\`/folder). See [\`docs/install-manus.md\`](https://github.com/${GITHUB_REPOSITORY}/blob/main/docs/install-manus.md) for the full flow.
This is a rolling pre-release that is overwritten on every push to \`main\`. Built from commit \`${GITHUB_SHA}\`.
EOF
)
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes "$notes" \
--prerelease
else
gh release create "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "$RELEASE_TITLE" \
--notes "$notes" \
--prerelease \
--target "$GITHUB_SHA"
fi
gh release upload "$RELEASE_TAG" \
--repo "$GITHUB_REPOSITORY" \
--clobber \
dist/manus/k-skill-manus-all.zip \
dist/manus/INDEX.md

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ node_modules/
*.plaintext
.venv/
__pycache__/
dist/
.sisyphus/

View file

@ -53,7 +53,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 필요 | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | `korean-patent-search` | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | `cheap-gas-nearby` | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
@ -115,6 +115,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| --- | --- |
| [설치 방법](docs/install.md) | 패키지 설치, 선택 설치, 로컬 테스트 방법 |
| [기여 가이드](CONTRIBUTING.md) | 외부 기여자를 위한 소통, PR 대상 브랜치, 스킬 문서, Changesets, 프록시 정책 |
| [Manus.ai 에서 가져오기](docs/install-manus.md) | Manus.ai 에서 개별 스킬 폴더 URL 가져오기 또는 `npm run build:manus-bundle` 로 빌드한 `.skill` 파일을 드래그-드롭으로 업로드하는 방법 |
| [공통 설정 가이드](docs/setup.md) | credential resolution order, 기본 secrets 파일 준비 |
| [보안/시크릿 정책](docs/security-and-secrets.md) | 인증 정보 저장 원칙, 금지 패턴, 표준 환경변수 이름 |
| [k-skill 프록시 서버 가이드](docs/features/k-skill-proxy.md) | 무료 API를 프록시 서버로 바로 호출하는 방법 |

View file

@ -24,15 +24,45 @@
## 기본 흐름
1. 쿠키 jar를 만들고 티머니 시외버스 페이지를 열어 세션을 시작한다.
2. `POST /otck/readAlcnList.do` 로 배차를 조회한다.
2. `POST /otck/readAlcnList.do` 로 배차를 조회한다. 이때 브라우저 JS가 붙이는 `bef_Aft_Dvs=D`, `req_Rec_Num=10`을 반드시 같이 보낸다.
3. 결과의 `readSasFeeInf(...)` 인자를 파싱해 후보를 정리한다.
4. 선택 후보는 `POST /otck/readSatsFee.do` 로 좌석/요금 단계 진입을 확인한다.
5. 사용자가 원하면 `POST /otck/readPcpySats.do` 로 공식 카드정보 입력 페이지에 진입하도록 handoff한다.
6. 뒤로가기/취소성 이동으로 좌석 선택 단계에 복귀해 임시 선점을 해제할 수 있는지 확인한다.
## read-only 조회 helper
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520
```
이 helper는 쿠키 세션을 시작하고 공식 배차 조회 POST를 수행한 뒤 출발시각, 운수사, 등급, 요금, 잔여/총 좌석을 JSON으로 출력한다. 기본은 read-only이며, `--hold-seat` 또는 `--hold-first-seat`를 주면 좌석/요금 단계에 진입해 `readPcpySats.do`로 임시 좌석 선점을 만들고 공식 카드정보 입력 HTML과 cancel/back 필드를 저장한다. 결제 정보 입력·제출은 수행하지 않는다.
### 임시 선점 예시
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520 \
--select-index 1 \
--hold-first-seat \
--output-dir /tmp/tmoney-hold
```
성공 조건은 JSON의 `hold.success=true`, `hold.hold_id` 존재, 저장된 HTML에 `카드정보 입력` 표시가 있는 것이다. 라이브 응답 페이지에는 정확한 만료 카운트다운 문구가 노출되지 않았으므로, 선점 후 결제는 즉시 진행하게 안내하고 방치된 선점은 저장된 cancel/back 필드로 해제한다.
## 주의할 점
- 결제 자동화는 포함하지 않는다. 공식 페이지의 결제 직전 단계까지 보조하는 assisted checkout 흐름이다.
- 티머니 시외버스 터미널 코드는 KOBUS 고속버스 코드와 다르므로 혼용하지 않는다.
- 일부 표면은 `txbus` 계열 URL과 연결될 수 있지만, 검증된 기본 URL은 `intercitybus.tmoney.co.kr` 이다.
- stateless POST보다 쿠키와 referer를 유지하는 흐름이 안정적이다.
- `bef_Aft_Dvs` 또는 `req_Rec_Num`을 누락하면 실제 배차가 있어도 `errorCont`가 포함된 일반 오류 페이지가 반환될 수 있다.

View file

@ -196,6 +196,33 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
--data-urlencode 'limit=5'
```
KOSIS 통계 조회 endpoint (`KOSIS_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/search' \
--data-urlencode 'q=1인 가구' \
--data-urlencode 'limit=3'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/meta' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'metaType=ITM'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kosis/data' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'prdSe=Y' \
--data-urlencode 'start=2020' \
--data-urlencode 'end=2023' \
--data-urlencode 'objL1=ALL'
```
Kakao Local geocoding endpoint (`KAKAO_REST_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/kakao-local/geocode' \
--data-urlencode 'q=서울역' \
--data-urlencode 'limit=1'
```
도서관 정보나루 도서 검색 endpoint (`DATA4LIBRARY_AUTH_KEY` 필요):

View file

@ -12,14 +12,13 @@
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- ODsay Server API Key 발급 및 호출 IP 화이트리스트 등록: https://lab.odsay.com
- Kakao REST API Key 발급 (지도/로컬 서비스 활성화): https://developers.kakao.com
- Kakao Local geocoding은 기본 hosted `k-skill-proxy` 경유. 사용자 쪽 Kakao 키는 불필요하며, self-host proxy 운영자만 Kakao REST API Key를 발급해 서버에 설정한다: https://developers.kakao.com
## 필요한 환경변수
- `ODSAY_API_KEY` — ODsay LIVE API Server 키
- `KAKAO_REST_API_KEY` — Kakao Local REST API 키
두 값 모두 `~/.config/k-skill/secrets.env` 에 저장하거나 환경변수로 주입한다.
`ODSAY_API_KEY` `~/.config/k-skill/secrets.env` 에 저장하거나 환경변수로 주입한다. 별도 self-host proxy를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL` 을 설정한다.
## 입력값
@ -29,7 +28,7 @@
## 기본 흐름
1. 출발지/도착지를 Kakao Local API(`address.json``keyword.json`)로 geocoding하여 좌표를 확보한다.
1. 출발지/도착지를 `k-skill-proxy``/v1/kakao-local/geocode`로 geocoding하여 좌표를 확보한다. Proxy 내부에서 Kakao Local `address.json``keyword.json` 순서로 시도한다.
2. ODsay `searchPubTransPathT`에 출발/도착 좌표와 옵션을 전달한다.
3. 응답의 `result.path[]`를 3개 이내로 정리한다.
4. 각 경로의 `subPath[]``trafficType`별로 표시하며, 첫/끝 도보 구간을 반드시 포함한다.
@ -49,17 +48,15 @@ curl -s "https://api.odsay.com/v1/api/searchPubTransPathT?apiKey=${KEY}&SX=126.9
```python
import os, urllib.parse, urllib.request, json
H = {'Authorization': 'KakaoAK ' + os.environ['KAKAO_REST_API_KEY']}
PROXY = os.environ.get('KSKILL_PROXY_BASE_URL', 'https://k-skill-proxy.nomadamas.org').rstrip('/')
def geocode(q):
for ep, name in [('address', 'address_name'), ('keyword', 'place_name')]:
url = f'https://dapi.kakao.com/v2/local/search/{ep}.json?query=' + urllib.parse.quote(q)
req = urllib.request.Request(url, headers=H)
with urllib.request.urlopen(req, timeout=10) as resp:
d = json.loads(resp.read())
if d.get('documents'):
doc = d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get(name) or doc['address_name']
url = PROXY + '/v1/kakao-local/geocode?q=' + urllib.parse.quote(q)
with urllib.request.urlopen(url, timeout=10) as resp:
d = json.loads(resp.read())
if d.get('documents'):
doc = d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get('place_name') or doc.get('address_name')
return None
sx, sy, s_name = geocode('서울역')
@ -70,6 +67,6 @@ ex, ey, e_name = geocode('강남역')
## 주의할 점
- ODsay Server 키는 **호출 IP 화이트리스트 등록이 필수**이다. 등록되지 않은 IP에서는 `error` 응답이 반환된다.
- 묣료 일일 한도는 5,000건이다. `searchPubTransPathT``searchStation` 호출이 합산된다.
- 현재 ODsay 공식 Basic 상품 기준 무료 체험은 일 1,000건(6개월)이다. `searchPubTransPathT``searchStation` 호출이 합산된다.
- 한국 외 좌표는 지원하지 않는다.
- 카카오맵/네이버지도 directions API는 대중교통 라우팅을 공개하지 않으므로 사용하지 말 것.

View file

@ -15,7 +15,8 @@
## 먼저 필요한 것
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- 일반 `search`/`meta`/`data`: 기본 hosted `k-skill-proxy` 접근
- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
@ -25,28 +26,24 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
## 필요한 환경변수
- `KSKILL_KOSIS_API_KEY`
- 일반 `search`/`meta`/`data`: 없음
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 사용
- `KSKILL_KOSIS_API_KEY``bigdata` 또는 `--direct` 전용
선택:
- 없음
### Credential resolution order
### Credential resolution order (`bigdata` 또는 `--direct` 전용)
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_KOSIS_API_KEY` 환경변수와 위 secrets 파일 읽는다.
일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일 읽는다.
## 처음 실행 순서
처음 쓰는 사용자는 키 발급 후 검색 → 메타 → 작은 슬라이스 순으로 점검한다.
처음 쓰는 사용자는 proxy 기반 검색 → 메타 → 작은 슬라이스 순으로 점검한다. 사용자 KOSIS 키는 일반 조회에 필요 없다.
```bash
export KSKILL_KOSIS_API_KEY="your-kosis-api-key"
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
python3 kosis-stats/scripts/run_kosis_stats.py meta --table-id DT_1JC1501 --text
python3 kosis-stats/scripts/run_kosis_stats.py data \
@ -84,10 +81,12 @@ python3 kosis-stats/scripts/run_kosis_stats.py data \
- `--text` / `--json` (기본 JSON)
- `--dry-run` (인증키 없이 URL/파라미터만 출력)
- `--timeout N` (기본 30)
- `--proxy-base-url URL` (기본 hosted proxy 대신 self-host/alternate proxy 사용)
- `--direct` (proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출)
## 기본 흐름
1. `KSKILL_KOSIS_API_KEY` 를 확보한다.
1. 일반 조회는 기본 hosted proxy를 사용한다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다.
2. `search` 로 후보 통계표를 본다.
3. `meta` 로 분류·단위·주기를 확인한다.
4. `data` 로 작은 슬라이스를 먼저 받는다.
@ -96,10 +95,10 @@ python3 kosis-stats/scripts/run_kosis_stats.py data \
## 검증 방식
메인테이너가 별도 KOSIS 인증키를 새로 발급받을 필요는 없다.
메인테이너가 일반 조회를 검토하기 위해 별도 KOSIS 인증키를 새로 발급받을 필요는 없다.
- CI/리뷰 검증: `./scripts/validate-skills.sh`, `python3 -m py_compile ...`, `--help`, `--dry-run`, 단위 테스트(`python3 -m unittest discover -s kosis-stats/tests`).
- 실제 조회 검증: 기여자 또는 이미 KOSIS 키를 가진 사용자가 개인 키로 선택 실행한다.
- 실제 direct 조회 검증: 기여자 또는 이미 KOSIS 키를 가진 사용자가 `--direct`로 선택 실행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다.
- PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남긴다. 인증키와 개인 조회 세부 내역은 공유하지 않는다.
## 예시
@ -168,7 +167,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py search --query "인구" --dry-run
## 흔한 문제 해결
- `missing required environment variable: KSKILL_KOSIS_API_KEY`: 환경변수가 현재 shell에 주입됐는지 확인한다. 없다면 https://kosis.kr/openapi/ 에서 발급한다.
- `missing required environment variable: KSKILL_KOSIS_API_KEY`: `bigdata` 또는 `--direct` 호출에서만 발생한다. 환경변수가 현재 shell에 주입됐는지 확인한다. 없다면 https://kosis.kr/openapi/ 에서 발급한다.
- `KOSIS error 10` (인증키 누락) / `11` (만료): 키를 재확인하거나 갱신한다. `bigdata` 호출에서 `11` 이 뜨면 해당 `userStatsId` 가 본인 KOSIS 계정에 등록되어 있지 않을 가능성이 높다.
- `KOSIS error 20` (필수 분류 누락): 표마다 필수 차원 수가 다르다. `meta --table-id <ID> --meta-type OBJ` 로 차원 수를 확인하고(OBJ가 비어 있으면 `--meta-type ITM`), `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 모두 지정한 뒤 재호출한다. 예: `data --table-id DT_1J22001 --prd-se M --start 202401 --end 202401 --obj-l 1=ALL` → 코드 20 → meta 확인 → `--obj-l 1=T10 --obj-l 2=0` 추가 → 성공.
- `KOSIS error 21` (잘못된 요청 변수): `org_id`/`tbl_id`/`prdSe`/`startPrdDe` 형식과 분류 인덱스를 재확인한다. 표에 존재하지 않는 `objL3=ALL` 같은 인덱스는 거부된다. tblId 의심 시 `search --query <키워드>` 로 정확한 ID를 다시 찾는다.

125
docs/install-manus.md Normal file
View file

@ -0,0 +1,125 @@
# Manus.ai 에서 k-skill 사용하기
Manus.ai 는 스킬을 가져오는 두 가지 공식 경로를 제공한다. k-skill 의 모든 스킬은 이미 Manus 가 요구하는 포맷(루트 디렉토리 + `SKILL.md` + YAML frontmatter `name` / `description`)을 만족하므로, 변환 없이 둘 다 사용할 수 있다.
| 방법 | 언제 쓰면 좋은가 | 한 번에 등록되는 스킬 수 |
| --- | --- | --- |
| **A. GitHub URL 가져오기** | 원하는 스킬이 1~3 개 정도일 때 | 1 |
| **B. `.skill` 파일 업로드** | 여러 스킬을 한꺼번에 받아두고 골라서 올리고 싶을 때 | 1 (드래그-드롭은 동시에 여러 개 선택 가능) |
> Manus 는 **하나의 아카이브로 여러 스킬을 한꺼번에 등록하는 기능은 공식 지원하지 않는다.** 어느 경로든 "스킬 한 개 = 업로드 한 번" 이다. 다만 방법 B 의 드래그-드롭 업로드는 여러 `.skill` 파일을 한 번에 선택해 빠르게 반복할 수 있다.
---
## 방법 A — GitHub URL 가져오기
### TL;DR
❌ 저장소 루트 URL 은 동작하지 않는다 (루트에는 `SKILL.md` 가 없다).
```
https://github.com/NomaDamas/k-skill
```
✅ 가져오려는 **개별 스킬 폴더** URL 을 붙여 넣는다.
```
https://github.com/NomaDamas/k-skill/tree/main/<skill-name>
```
예시:
```
https://github.com/NomaDamas/k-skill/tree/main/mfds-food-safety
https://github.com/NomaDamas/k-skill/tree/main/srt-booking
https://github.com/NomaDamas/k-skill/tree/main/korea-weather
https://github.com/NomaDamas/k-skill/tree/main/real-estate-search
```
각 스킬 폴더에는 Manus 가 요구하는 `SKILL.md` 가 루트에 존재하고, 필요하면 `scripts/`, `references/`, `templates/` 같은 부속 리소스가 같이 들어 있다.
### 절차
1. Manus 에서 **"+ 추가"** 또는 **스킬 가져오기** 화면을 연다.
2. **GitHub 탭**을 선택한다.
3. URL 입력란에 위 형식의 **스킬 폴더 URL** 을 붙여 넣는다.
4. **가져오기** 버튼을 누른다.
5. 추가로 쓰고 싶은 스킬은 폴더 단위로 같은 절차를 반복한다.
---
## 방법 B — `.skill` 번들 업로드 (여러 스킬을 빠르게)
GitHub URL 을 한 번에 하나씩 붙여 넣는 게 귀찮다면, 미리 빌드된 `.skill` 파일들을 한꺼번에 받아 두고 Manus 의 파일 업로드로 드래그-드롭하는 게 더 빠르다.
### 빠른 경로 — 미리 빌드된 번들 다운로드 (권장)
`main` 에 변경이 들어올 때마다 GitHub Actions 가 자동으로 모든 스킬을 패키징해서 rolling pre-release `manus-bundle-latest` 에 올린다. 클론도 빌드도 필요 없다.
- **합본 (권장)**: <https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/k-skill-manus-all.zip>
- **스킬 목록 (어떤 게 들어 있는지 미리 확인)**: <https://github.com/NomaDamas/k-skill/releases/download/manus-bundle-latest/INDEX.md>
- **릴리스 페이지**: <https://github.com/NomaDamas/k-skill/releases/tag/manus-bundle-latest>
> 위 URL 은 매 `main` 푸시마다 같은 자리에서 새 번들로 교체된다. 항상 최신 상태가 보장된다.
업로드 절차:
1. `k-skill-manus-all.zip` 을 받아 압축을 푼다. 한 폴더에 `<skill-name>.skill` 파일들이 펼쳐진다.
2. Manus 에서 **스킬 업로드 / 파일 추가** 화면을 연다.
3. 원하는 `<skill-name>.skill` 파일을 드래그-드롭하거나 파일 선택으로 업로드한다. 파일 선택 다이얼로그에서 여러 파일을 한꺼번에 골라도 된다.
4. Manus 가 파일 하나당 스킬 하나씩 등록한다.
### 직접 빌드 (개발자용)
저장소를 수정 중이거나 main 에 아직 머지되지 않은 변경을 테스트하고 싶다면 로컬에서 직접 빌드한다.
```bash
git clone https://github.com/NomaDamas/k-skill.git
cd k-skill
npm install
npm run build:manus-bundle
```
빌드가 끝나면 다음 산출물이 생긴다.
```
dist/manus/
├── <skill-name>.skill # 스킬 1개당 .skill 파일 1개 (총 60+ 개)
├── k-skill-manus-all.zip # 위 .skill 파일들을 한 번에 받기 위한 편의 번들
└── INDEX.md # 포함된 스킬 목록과 설명
```
> `.skill` 파일은 사실상 ZIP 아카이브이며, 내부에는 단일 최상위 폴더 `<skill-name>/`(SKILL.md + 보조 리소스)가 들어 있다. 이 레이아웃은 Anthropic 의 공식 [skill-creator packager](https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py) 와 동일하다.
직접 빌드에 필요한 것:
- Node.js 18+
- 시스템 `zip` 명령 (macOS 와 대부분의 Linux 배포판은 기본 설치, Ubuntu 에서 누락 시 `sudo apt-get install -y zip`)
---
## 호환성 메모
- k-skill 의 모든 스킬은 `name`, `description` 을 YAML frontmatter 최상위에 두고 있다. 이 두 필드는 Manus 가 요구하는 **유일한 필수 필드**이므로 호환성을 위해 추가로 수정할 항목이 없다.
- 기존 `license`, `metadata.category`, `metadata.locale`, `metadata.phase` 같은 필드는 Manus 가 인식하지 않더라도 무시되며, Claude Code / Codex / OpenCode 등 다른 코딩 에이전트에서는 그대로 사용된다.
- `scripts/`, `references/`, `templates/` 같은 보조 디렉토리는 Manus 의 progressive disclosure 규칙과 동일하게 동작한다.
---
## 사용자 인증과 프록시
Manus 환경에서 k-skill 을 쓸 때도 본 저장소의 **사용자 로그인 / 시크릿 정책**을 그대로 따른다.
- "사용자 로그인 필요" 로 표시된 스킬(예: `srt-booking`, `ktx-booking`, `toss-securities`)은 Manus 세션 안에서 사용자가 직접 자격 증명을 제공해야 한다.
- "불필요" 로 표시된 스킬은 공개 API 또는 운영자가 관리하는 `k-skill-proxy` 를 그대로 사용한다. Manus 측에서 별도 키를 받지 않는다.
- 자세한 정책은 [`docs/security-and-secrets.md`](security-and-secrets.md) 와 [`docs/features/k-skill-proxy.md`](features/k-skill-proxy.md) 참고.
---
## 출처
- Manus 공식 도움말 (업로드/공유 방법): <https://help.manus.im/en/articles/14753565-how-to-share-and-use-skills-in-manus>
- Manus 스킬 문서: <https://manus.im/docs/features/skills>
- Manus 공개 API (스킬 목록): <https://open.manus.ai/docs/v2/list-skills>
- `.skill` 패키징 레퍼런스 (Anthropic skill-creator): <https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py>
- 폴더별 import 모노레포 예시: <https://github.com/WebWakaHub/manus-agency-skills>

View file

@ -324,10 +324,9 @@ python3 scripts/patent_search.py --query "배터리"
python3 scripts/scholarship_filter.py report --input scholarships.json --today 2026-04-14 --only-open-now
```
국가데이터처 KOSIS 통계 조회 helper는 설치된 `kosis-stats` skill 안의 `scripts/run_kosis_stats.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
국가데이터처 KOSIS 통계 조회 helper는 설치된 `kosis-stats` skill 안의 `scripts/run_kosis_stats.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다. 일반 `search`/`meta`/`data`는 기본 hosted proxy를 쓰므로 사용자 KOSIS 키가 필요 없다.
```bash
export KSKILL_KOSIS_API_KEY=your-kosis-api-key
python3 kosis-stats/scripts/run_kosis_stats.py search --query "1인 가구" --text
```

View file

@ -26,14 +26,17 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. direct/bigdata 또는 proxy 서버 운영 때만 필요.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 필요.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보와 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -66,13 +69,14 @@ KSKILL_PROXY_BASE_URL=
- `KSKILL_KTX_PASSWORD`
- `KSKILL_FORESTTRIP_ID`
- `KSKILL_FORESTTRIP_PASSWORD`
- `KSKILL_KOSIS_API_KEY`
- `KSKILL_KOSIS_API_KEY` (KOSIS `bigdata`/`--direct`, 또는 proxy 서버 `KOSIS_API_KEY` 대체 env)
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KAKAO_REST_API_KEY`
- `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 자연휴양림 빈 객실 조회, 국가데이터처 KOSIS 통계 조회용 `KSKILL_KOSIS_API_KEY` (https://kosis.kr/openapi/ 에서 무료 발급), 한국 법령 검색의 로컬 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 예매, 자연휴양림 빈 객실 조회, KOSIS `bigdata`/`--direct` 조회용 `KSKILL_KOSIS_API_KEY` (https://kosis.kr/openapi/ 에서 무료 발급), 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처/KOSIS/Kakao upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다. KOSIS 일반 조회와 Kakao Local geocoding도 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY`·`KOSIS_API_KEY`·`KAKAO_REST_API_KEY` 등은 서버에 설정되어 있어야 한다).
## Credential resolution order
@ -26,10 +26,13 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# KOSIS 일반 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
EOF
chmod 0600 ~/.config/k-skill/secrets.env
@ -37,7 +40,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
서울 지하철 도착정보, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.

View file

@ -7,7 +7,8 @@
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 모든 helper 호출은 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do`, `/statisticsBigData.do` 를 사용한다 (HTTPS 전용, 2026-03-05 시행)
- 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` 순서로 중계한다.
- 숲나들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

View file

@ -4,8 +4,11 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
# Kakao Local geocoding은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=

View file

@ -131,6 +131,22 @@ POST /mrs/cancPcpy.ajax
Use the same relevant form fields plus the returned `pcpyNoAll` and `satsNoAll`. A successful response returns `MSG_CD=S0000`.
## Helper Script
Use the bundled helper for KOBUS lookup and optional temporary holds:
```bash
python3 express-bus-booking/scripts/kobus_express_booking.py \
--depart-code 021 \
--arrive-code 500 \
--date 20260520 \
--select-index 1 \
--hold-first-seat \
--output-dir /tmp/kobus-hold
```
For Seoul to Gwangju, the verified KOBUS route is `센트럴시티(서울)` code `021` to `광주(유·스퀘어)` code `500`. A successful hold returns `MSG_CD=S0000`, `pcpyNoAll`, `satsNoAll`, fare amounts, and saves a local auto-submit helper for the official KOBUS payment-information page. Final card entry and payment remain manual. Cancel abandoned holds with `/mrs/cancPcpy.ajax` using the saved cancel fields.
## Checkout-Entry Link Helper
A plain official checkout URL is not enough because KOBUS expects a POST body containing the selected schedule, seat, fare, and hold identifiers. The practical user-facing pattern is:

View file

@ -133,6 +133,8 @@ Observed success marker:
MSG_CD=S0000
```
2026-05-13 서울→광주 re-verification: `센트럴시티(서울)(021) -> 광주(유·스퀘어)(500)`, 2026-05-20 00:45 중앙고속 심야우등, seat 1. `/mrs/setPcpy.ajax` returned `MSG_CD=S0000`, `pcpyNoAll`, `satsNoAll=01`, `TISSU_AMT=36900`; `/mrs/stplcfmpym.do?keep=/mrs/pay` rendered the official payment-information page; `/mrs/cancPcpy.ajax` returned `MSG_CD=S0000`.
### Checkout Entry
```text

View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""KOBUS timetable lookup and temporary hold helper.
Default mode searches timetables. With --hold-first-seat or --hold-seat it creates
a temporary seat hold through /mrs/setPcpy.ajax and saves a local auto-submit
HTML helper for the official KOBUS payment-information page. It never submits
card fields or final payment.
"""
from __future__ import annotations
import argparse
import html
import http.cookiejar
import json
import re
import ssl
import sys
import tempfile
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
BASE_URL = "https://www.kobus.co.kr"
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/125 Safari/537.36"
FN_SATS_RE = re.compile(r"fnSatsChc\((.*?)\)", re.DOTALL)
ARG_RE = re.compile(r"'([^']*)'")
FORM_RE = re.compile(r"<form\b([^>]*)>(.*?)</form>", re.DOTALL | re.IGNORECASE)
INPUT_RE = re.compile(r"<input\b([^>]+)>", re.DOTALL | re.IGNORECASE)
ATTR_RE = re.compile(r"([\w:-]+)=[\"']([^\"']*)[\"']")
SEAT_RE = re.compile(r'<input\b([^>]*name=["\']seatBoxDtl["\'][^>]*)>', re.DOTALL | re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>")
@dataclass
class Schedule:
index: int
departure_time: str | None
company: str | None
bus_class: str | None
remaining_text: str | None
raw_args: list[str]
@dataclass
class Hold:
success: bool
pcpy_no_all: str | None
sats_no_all: str | None
seat: str | None
estm_amt: str | None
dc_amt: str | None
tissu_amt: str | None
checkout_helper_path: str | None
checkout_response_path: str | None
cancel_fields_path: str | None
raw_response: dict[str, object]
def opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
ctx = ssl._create_unverified_context()
try:
ctx.set_ciphers("DEFAULT@SECLEVEL=1")
except ssl.SSLError:
pass
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ctx),
)
def request(url: str, data: dict[str, str] | list[tuple[str, str]] | None = None, referer: str | None = None) -> urllib.request.Request:
headers = {"User-Agent": UA}
if referer:
headers["Referer"] = referer
if data is None:
return urllib.request.Request(url, headers=headers, method="GET")
headers["Content-Type"] = "application/x-www-form-urlencoded"
return urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), headers=headers, method="POST")
def open_text(op: urllib.request.OpenerDirector, req: urllib.request.Request, timeout: int) -> str:
with op.open(req, timeout=timeout) as resp:
return resp.read().decode(resp.headers.get_content_charset() or "utf-8", errors="replace")
def attrs(fragment: str) -> dict[str, str]:
return {k.lower(): html.unescape(v) for k, v in ATTR_RE.findall(fragment)}
def strip_tags(s: str) -> str:
return re.sub(r"\s+", " ", html.unescape(TAG_RE.sub(" ", s))).strip()
def parse_form(body: str, form_id: str) -> list[tuple[str, str]]:
for attr_text, form_body in FORM_RE.findall(body):
a = attrs(attr_text)
if a.get("id") == form_id or a.get("name") == form_id:
fields = []
for input_text in INPUT_RE.findall(form_body):
ia = attrs(input_text)
if ia.get("name"):
fields.append((ia["name"], ia.get("value", "")))
return fields
return []
def search(op: urllib.request.OpenerDirector, depart: str, arrive: str, date: str, timeout: int) -> tuple[str, list[Schedule]]:
open_text(op, request(f"{BASE_URL}/main.do"), timeout)
body = open_text(
op,
request(
f"{BASE_URL}/mrs/alcnSrch.do",
{
"deprCd": depart,
"arvlCd": arrive,
"pathDvs": "sngl",
"pathStep": "1",
"deprDtm": date,
"busClsCd": "0",
"rtrpChc": "1",
"timeLinkMin": "00",
"timeLinkMax": "23",
},
f"{BASE_URL}/main.do",
),
timeout,
)
schedules: list[Schedule] = []
for idx, m in enumerate(FN_SATS_RE.finditer(body), 1):
args = ARG_RE.findall(m.group(1))
context = strip_tags(body[max(0, m.start() - 900) : m.start() + 900])
departure = args[1][:2] + ":" + args[1][2:4] if len(args) > 1 and len(args[1]) >= 4 else None
schedules.append(
Schedule(
index=idx,
departure_time=departure,
company=(re.search(r"\((?:주|유)\)[^\s]+|[가-힣]+고속", context) or [None])[0],
bus_class=(re.search(r"심야우등|우등|프리미엄|고속", context) or [None])[0],
remaining_text=(re.search(r"잔여\s*\d+석|\d+\s*/\s*\d+", context) or [None])[0],
raw_args=args,
)
)
return body, schedules
def seat_stage_fields(search_form: list[tuple[str, str]], schedule: Schedule) -> list[tuple[str, str]]:
a = schedule.raw_args
values = dict(search_form)
updates = {
"deprTime": a[1],
"alcnDeprTime": a[2],
"alcnDeprTrmlNo": a[3],
"alcnArvlTrmlNo": a[4],
"indVBusClsCd": a[5],
"cacmCd": a[6],
"dcDvsCd": a[7],
"prvtBbizEmpAcmtRt": a[8],
"chldSftySatsYn": a[12],
"dsprSatsYn": a[13],
}
return [(k, updates.get(k, v)) for k, v in search_form]
def hold(op: urllib.request.OpenerDirector, alcn_body: str, schedule: Schedule, seat: str | None, out: Path, timeout: int) -> Hold:
search_form = parse_form(alcn_body, "alcnSrchFrm")
seat_body = open_text(op, request(f"{BASE_URL}/mrs/satschc.do", seat_stage_fields(search_form, schedule), f"{BASE_URL}/mrs/alcnSrch.do"), timeout)
fields = parse_form(seat_body, "satsChcFrm")
field_map = dict(fields)
seats = []
for input_text in SEAT_RE.findall(seat_body):
a = attrs(input_text)
if "disabled" not in input_text and a.get("value"):
seats.append(a["value"])
selected = seat or (seats[0] if seats else None)
if not selected:
raise RuntimeError("No selectable KOBUS seat found")
def set_field(items: list[tuple[str, str]], key: str, val: str) -> list[tuple[str, str]]:
return [(k, val if k == key else v) for k, v in items]
for key, val in {
"selSeatNum": selected,
"selSeatCnt": "1",
"selAdltCnt": "1",
"selAdltDcCnt": "0",
"prmmDcDvsCd": field_map.get("prmmDcDvsCd") or "0",
}.items():
fields = set_field(fields, key, val)
raw = json.loads(open_text(op, request(f"{BASE_URL}/mrs/setPcpy.ajax", fields, f"{BASE_URL}/mrs/satschc.do"), timeout))
success = raw.get("MSG_CD") == "S0000"
if not success:
return Hold(False, None, None, selected, None, None, None, None, None, None, raw)
for key, val in {
"satsNoAll": str(raw.get("satsNoAll", "")),
"pcpyNoAll": str(raw.get("pcpyNoAll", "")),
"estmAmt": str(raw.get("ESTM_AMT", "")),
"dcAmt": str(raw.get("DC_AMT", "")),
"tissuAmt": str(raw.get("TISSU_AMT", "")),
"nonMbrsYn": "Y",
}.items():
fields = set_field(fields, key, val)
out.mkdir(parents=True, exist_ok=True)
checkout = open_text(op, request(f"{BASE_URL}/mrs/stplcfmpym.do?keep=/mrs/pay", fields, f"{BASE_URL}/mrs/satschc.do"), timeout)
checkout_path = out / "kobus-checkout-response.html"
checkout_path.write_text(checkout)
helper_path = out / "kobus-payment-autosubmit.html"
inputs = "\n".join(f'<input type="hidden" name="{html.escape(k)}" value="{html.escape(v, quote=True)}">' for k, v in fields)
helper_path.write_text(f'<!doctype html><meta charset="utf-8"><p>공식 KOBUS 결제정보 입력 페이지로 이동합니다. 결제는 직접 진행하세요.</p><form id="f" method="post" action="{BASE_URL}/mrs/stplcfmpym.do?keep=/mrs/pay">{inputs}</form><script>document.getElementById("f").submit();</script>')
cancel_path = out / "kobus-cancel-fields.txt"
cancel_path.write_text("\n".join(f"{k}={v}" for k, v in fields))
return Hold(True, str(raw.get("pcpyNoAll")), str(raw.get("satsNoAll")), selected, str(raw.get("ESTM_AMT")), str(raw.get("DC_AMT")), str(raw.get("TISSU_AMT")), str(helper_path), str(checkout_path), str(cancel_path), raw)
def main(argv: Iterable[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--depart-code", required=True)
p.add_argument("--arrive-code", required=True)
p.add_argument("--date", required=True)
p.add_argument("--select-index", type=int, default=1)
p.add_argument("--hold-first-seat", action="store_true")
p.add_argument("--hold-seat")
p.add_argument("--output-dir")
p.add_argument("--limit", type=int, default=20)
p.add_argument("--timeout", type=int, default=20)
args = p.parse_args(argv)
op = opener()
body, schedules = search(op, args.depart_code, args.arrive_code, args.date, args.timeout)
result: dict[str, object] = {"route": {"depart_code": args.depart_code, "arrive_code": args.arrive_code, "date": args.date}, "count": len(schedules), "items": [asdict(s) for s in schedules[: args.limit]]}
if (args.hold_first_seat or args.hold_seat) and schedules:
out = Path(args.output_dir) if args.output_dir else Path(tempfile.mkdtemp(prefix="kobus-hold-"))
result["hold"] = asdict(hold(op, body, schedules[args.select_index - 1], args.hold_seat, out, args.timeout))
result["payment_note"] = "Opened/saved the official KOBUS payment-information page; final card entry/payment remains manual."
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -68,8 +68,12 @@ ic=0
iv=0
depr_Dt=YYYYMMDD
depr_Time=000000
bef_Aft_Dvs=D
req_Rec_Num=10
```
`bef_Aft_Dvs` and `req_Rec_Num` are required hidden fields from the browser JavaScript `readAlcnListEntry(...)`. If they are omitted, Tmoney can return a generic error page with no schedules.
Parse schedule buttons/rows. The next-stage parameters are often embedded in `readSasFeeInf(...)` onclick arguments.
### 3. Enter Fare / Seat-Count Stage
@ -111,6 +115,39 @@ rtrp_Depr_Dt
A successful response lands on the official `카드정보 입력` page and includes a temporary seat hold identifier such as `sats_Pcpy_Id`.
## Timetable Helper
For read-only timetable lookup, use the bundled helper before attempting browser automation:
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520
```
The helper starts a cookie-backed session, posts the browser-required timetable fields, parses `readSasFeeInf(...)`, and prints JSON with departure time, company, class, fares, and remaining/total seats. By default it is read-only. With `--hold-seat <seatNo>` or `--hold-first-seat`, it enters `readSatsFee.do`, posts `readPcpySats.do`, and saves the official Tmoney card-information HTML page plus cancel/back fields. It still never submits card data or final payment.
### Temporary Hold Helper
To create a temporary hold and save the official card-information page:
```bash
python3 intercity-bus-booking/scripts/intercity_bus_search.py \
--depart-code 0511601 \
--arrive-code 2482701 \
--depart-name 동서울 \
--arrive-name 속초 \
--date 20260520 \
--select-index 1 \
--hold-first-seat \
--output-dir /tmp/tmoney-hold
```
Success requires `hold.success=true`, a `sats_Pcpy_Id`, and the saved page containing `카드정보 입력`. The saved cancel fields can be posted back to `/otck/readSatsFee.do` with `pcpyCanc=C` to abandon the hold. Live probes did not expose an exact countdown on the card-information page; treat the hold as short-lived and have the user complete payment immediately.
## Checkout-Entry Link Helper
A helper-served HTML page can auto-submit a POST form directly to:
@ -145,13 +182,15 @@ When a checkout-entry helper is created, say that it opens the official Tmoney c
1. **Mixing terminal code systems.** Tmoney 시외버스 codes are not KOBUS codes.
2. **Assuming checkout-entry equals final payment.** `readPcpySats.do` can open the card-information page, but final payment remains a separate manual step.
3. **Replaying stale hold payloads.** A repeated POST for the same route/seat can fail or create confusing results. Generate a fresh seat-stage payload for real use.
4. **Skipping cancellation/back flow.** Use the official cancellation/back form (`pcpyCanc=C` via `readSatsFee.do` when available) for abandoned holds.
5. **Overusing browser automation.** Use browser only for endpoint discovery or visual verification after HTTP probing.
3. **Omitting hidden timetable fields.** `readAlcnList.do` needs `bef_Aft_Dvs=D` and `req_Rec_Num=10`; without them it may return a generic `errorCont` page of about 13 KB instead of schedule rows.
4. **Replaying stale hold payloads.** A repeated POST for the same route/seat can fail or create confusing results. Generate a fresh seat-stage payload for real use.
5. **Skipping cancellation/back flow.** Use the official cancellation/back form (`pcpyCanc=C` via `readSatsFee.do` when available) for abandoned holds.
6. **Overusing browser automation.** Use browser only for endpoint discovery or visual verification after HTTP probing.
## Verification Checklist
- [ ] Route/terminal codes were resolved from Tmoney 시외버스, not guessed or copied from KOBUS.
- [ ] Timetable POST included `bef_Aft_Dvs=D` and `req_Rec_Num=10`.
- [ ] Timetable response was parsed for schedule rows/buttons and next-stage parameters.
- [ ] Fare/seat-stage response contains `form#readPcpySats` and expected hidden fields.
- [ ] Checkout-entry response contains `카드정보 입력` and a hold identifier such as `sats_Pcpy_Id` before reporting success.

View file

@ -1,6 +1,6 @@
# Tmoney 시외버스 HTTP/API Probe Notes
Session-proven on 2026-05-08. Goal: avoid browser automation where possible.
Session-proven on 2026-05-08 and re-verified on 2026-05-13. Goal: avoid browser automation where possible.
## Base
@ -24,10 +24,11 @@ Example tested route/date:
동서울(0511601) -> 속초(2482701), 2026-05-09
```
Observed result:
Observed results:
```text
14 reservation buttons/schedules
2026-05-09: 14 reservation buttons/schedules
2026-05-20: 20 readSasFeeInf schedule buttons, first departure 06:05 금강고속 우등, 24/28 seats
```
Typical POST fields:
@ -43,8 +44,12 @@ ic=0
iv=0
depr_Dt=YYYYMMDD
depr_Time=000000
bef_Aft_Dvs=D
req_Rec_Num=10
```
`bef_Aft_Dvs=D` and `req_Rec_Num=10` are not optional. They are appended by the site JavaScript (`readAlcnListEntry(bef_Aft_Dvs, req_Rec_Num)`) before the browser submits `#onewayInfo`. Omitting them returned a generic error page (`errorCont`, about 13,770 bytes) with no `readSasFeeInf(...)` schedules in live probing.
The next-stage values are embedded in `readSasFeeInf(...)` onclick calls. Example prefix:
```text
@ -99,6 +104,8 @@ Observed success markers:
sats_Pcpy_Id
```
Re-verified on 2026-05-13 with 동서울 -> 속초, 2026-05-20, 06:05 금강고속 우등, seat 1. `readPcpySats.do` returned `카드정보 입력` and `sats_Pcpy_Id=SP...`. Posting the resulting cancel/back fields with `pcpyCanc=C` to `/otck/readSatsFee.do` returned to the seat-selection page and subsequent timetable lookup still showed 24/28 seats.
### Cancellation / Back Flow
A POST back to `/otck/readSatsFee.do` with `pcpyCanc=C` and the hold fields returned to seat selection and appeared to release the temporary hold in testing.
@ -113,5 +120,7 @@ A POST back to `/otck/readSatsFee.do` with `pcpyCanc=C` and the hold fields retu
- Login was not required for timetable lookup, fare/seat-stage entry, or card-information page entry in the tested flow.
- CAPTCHA was not observed in the tested flow.
- A generic `errorCont` response usually means the posted form contract is incomplete, not necessarily that the route is unavailable; first verify `bef_Aft_Dvs` and `req_Rec_Num`.
- Payment/card-info submission is separate and should not be automated without explicit confirmation.
- The live card-information page did not expose an exact countdown/expiry text in probes. Treat temporary holds as short-lived: hand off immediately, and post the cancel/back fields for abandoned holds.
- Terminal codes are Tmoney-specific and must not be mixed with KOBUS codes.

View file

@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""Search and optionally hold Tmoney intercity-bus seats through official flows.
Default mode is read-only timetable parsing. With --hold-seat, the helper performs
Tmoney's temporary seat-hold POST and saves the official card-information page.
It never submits card fields or final payment.
"""
from __future__ import annotations
import argparse
import html
import http.cookiejar
import json
import re
import ssl
import sys
import tempfile
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
BASE_URL = "https://intercitybus.tmoney.co.kr"
ENTRY_PATH = "/otck/trmlInfEnty.do"
TIMETABLE_PATH = "/otck/readAlcnList.do"
SEAT_STAGE_PATH = "/otck/readSatsFee.do"
HOLD_PATH = "/otck/readPcpySats.do"
DEFAULT_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125 Safari/537.36"
)
ROW_RE = re.compile(r"<tr>\s*(.*?)readSasFeeInf\((.*?)\).*?</tr>", re.DOTALL | re.IGNORECASE)
TD_WRAP_RE = re.compile(r'<div class="td_wrap1">(.*?)</div>', re.DOTALL | re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>")
ARG_RE = re.compile(r"'((?:\\'|[^'])*)'")
FORM_RE = re.compile(r"<form\b([^>]*)>(.*?)</form>", re.DOTALL | re.IGNORECASE)
INPUT_RE = re.compile(r"<input\b([^>]+)>", re.DOTALL | re.IGNORECASE)
ATTR_RE = re.compile(r"([\w:-]+)=[\"']([^\"']*)[\"']")
SEAT_RE = re.compile(r"<li([^>]*)>\s*<a[^>]*>.*?<span>(\d+)</span>", re.DOTALL | re.IGNORECASE)
@dataclass
class Schedule:
departure_time: str | None
company: str | None
duration: str | None
bus_class: str | None
adult_fare: str | None
child_fare: str | None
student_fare: str | None
remaining_seats: int | None
total_seats: int | None
raw_args: list[str]
@dataclass
class HoldResult:
success: bool
hold_id: str | None
seat: str
card_page_path: str | None
cancel_fields_path: str | None
markers: dict[str, int]
failure_message: str | None = None
def _ssl_context() -> ssl.SSLContext:
# Tmoney has historically required curl -k in probes on some machines.
# Keep this helper resilient while limiting it to the official host.
return ssl._create_unverified_context() # noqa: SLF001
def _strip(value: str) -> str:
value = re.sub(r"<!--.*?-->", "", value, flags=re.DOTALL)
value = TAG_RE.sub("", value)
return html.unescape(value).replace("\xa0", " ").strip()
def _attrs(fragment: str) -> dict[str, str]:
return {k.lower(): html.unescape(v) for k, v in ATTR_RE.findall(fragment)}
def _open(opener: urllib.request.OpenerDirector, request: urllib.request.Request, timeout: int) -> str:
with opener.open(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
return response.read().decode(charset, errors="replace")
def build_opener() -> urllib.request.OpenerDirector:
jar = http.cookiejar.CookieJar()
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=_ssl_context()),
)
def _request(url: str, data: list[tuple[str, str]] | dict[str, str] | None = None, referer: str | None = None) -> urllib.request.Request:
headers = {"User-Agent": DEFAULT_UA}
if referer:
headers["Referer"] = referer
if data is None:
return urllib.request.Request(url, headers=headers, method="GET")
headers["Content-Type"] = "application/x-www-form-urlencoded"
encoded = urllib.parse.urlencode(data).encode("utf-8")
return urllib.request.Request(url, data=encoded, headers=headers, method="POST")
def search_timetable(
depart_code: str,
arrive_code: str,
depart_name: str,
arrive_name: str,
date: str,
time: str = "000000",
adults: int = 1,
students: int = 0,
children: int = 0,
veterans: int = 0,
timeout: int = 20,
opener: urllib.request.OpenerDirector | None = None,
) -> tuple[urllib.request.OpenerDirector, str, list[Schedule]]:
opener = opener or build_opener()
_open(opener, _request(f"{BASE_URL}{ENTRY_PATH}"), timeout)
fields = {
"depr_Trml_Cd": depart_code,
"arvl_Trml_Cd": arrive_code,
"depr_Trml_Nm": depart_name,
"arvl_Trml_Nm": arrive_name,
"ig": str(adults),
"im": str(students),
"ic": str(children),
"iv": str(veterans),
"depr_Dt": date,
"depr_Time": time,
# Required by the browser JS readAlcnListEntry(). Missing either field
# returns a generic error page with no schedule rows.
"bef_Aft_Dvs": "D",
"req_Rec_Num": "10",
}
body = _open(opener, _request(f"{BASE_URL}{TIMETABLE_PATH}", fields, f"{BASE_URL}{ENTRY_PATH}"), timeout)
return opener, body, parse_schedules(body)
def parse_schedules(body: str) -> list[Schedule]:
schedules: list[Schedule] = []
for row_html, arg_text in ROW_RE.findall(body):
args = [a.replace("\\'", "'") for a in ARG_RE.findall(arg_text)]
cells = [_strip(x) for x in TD_WRAP_RE.findall(row_html)]
departure = cells[0] if len(cells) > 0 else (args[8][:2] + ":" + args[8][2:4] if len(args) > 8 else None)
company_cell = cells[1] if len(cells) > 1 else None
company = args[11] if len(args) > 11 else None
duration = None
if company_cell and company and company_cell.startswith(company):
duration = company_cell[len(company):].strip() or None
elif company_cell:
duration = company_cell
bus_class = args[12] if len(args) > 12 else (cells[2] if len(cells) > 2 else None)
remaining = int(args[16]) if len(args) > 16 and args[16].isdigit() else None
total = int(args[17]) if len(args) > 17 and args[17].isdigit() else None
schedules.append(
Schedule(
departure_time=departure,
company=company,
duration=duration,
bus_class=bus_class,
adult_fare=cells[3] if len(cells) > 3 else None,
child_fare=cells[4] if len(cells) > 4 else None,
student_fare=cells[5] if len(cells) > 5 else None,
remaining_seats=remaining,
total_seats=total,
raw_args=args,
)
)
return schedules
def _seat_stage_fields(schedule: Schedule, search_time: str) -> dict[str, str]:
a = schedule.raw_args
if len(a) < 21:
raise ValueError("schedule raw_args does not contain the expected readSasFeeInf payload")
return {
"atl_Depr_Dt_S1": a[2],
"atl_Depr_Time_S1": search_time,
"rot_Id": a[0],
"rot_Sqno": a[1],
"alcn_Dt": a[2],
"alcn_Sqno": a[3],
"depr_Trml_Cd": a[4],
"arvl_Trml_Cd": a[5],
"depr_Trml_Nm": a[6],
"arvl_Trml_Nm": a[7],
"depr_Time": a[8],
"bus_Cacm_Cd": a[9],
"bus_Cls_Cd": a[10],
"bus_Cacm_Nm": a[11],
"bus_Cls_Nm": a[12],
"ig": a[13],
"im": a[14],
"ic": a[15],
"rmn_Scnt": a[16],
"sats_Num": a[17],
"atl_Depr_Dt": a[18],
"atl_Depr_Time": a[19],
"dc_Psb_Yn": a[20],
}
def _form_fields(body: str, form_id: str) -> list[tuple[str, str]]:
for attrs_text, form_body in FORM_RE.findall(body):
attrs = _attrs(attrs_text)
if attrs.get("id") == form_id or attrs.get("name") == form_id:
fields: list[tuple[str, str]] = []
for input_text in INPUT_RE.findall(form_body):
input_attrs = _attrs(input_text)
name = input_attrs.get("name")
if name:
fields.append((name, input_attrs.get("value", "")))
return fields
return []
def _available_seats(seat_stage_body: str) -> list[str]:
seats: list[str] = []
for li_attrs, seat_no in SEAT_RE.findall(seat_stage_body):
classes = _attrs(li_attrs).get("class", "")
if "disabled" not in classes.split():
seats.append(seat_no)
return seats
def hold_seat(
opener: urllib.request.OpenerDirector,
schedule: Schedule,
search_time: str,
seat: str | None,
output_dir: Path,
timeout: int = 20,
) -> tuple[str, list[str], HoldResult]:
seat_stage_body = _open(
opener,
_request(f"{BASE_URL}{SEAT_STAGE_PATH}", _seat_stage_fields(schedule, search_time), f"{BASE_URL}{TIMETABLE_PATH}"),
timeout,
)
available = _available_seats(seat_stage_body)
selected = seat or (available[0] if available else "")
if not selected:
return seat_stage_body, available, HoldResult(False, None, "", None, None, {}, "No selectable seat was found")
if selected not in available:
return seat_stage_body, available, HoldResult(False, None, selected, None, None, {}, f"Seat {selected} is not selectable")
fields = _form_fields(seat_stage_body, "readPcpySats")
if not fields:
return seat_stage_body, available, HoldResult(False, None, selected, None, None, {}, "No readPcpySats form found")
# Mirror pcpySats() in /js/tckmrs/readSatsInfo.js for a normal adult-only hold.
field_map = dict(fields)
fields.extend(
[
("pcpy_Num", "1"),
("sats_No", selected),
("rtrp_Depr_Dt", ""),
("bus_Tck_Knd_Cd", field_map.get("ig_Knd_Cd", "IG00")),
("cty_Bus_Dc_Knd_Cd", "Z"),
("dcrt_Dvs_Cd", "0"),
]
)
hold_body = _open(opener, _request(f"{BASE_URL}{HOLD_PATH}", fields, f"{BASE_URL}{SEAT_STAGE_PATH}"), timeout)
markers = {k: hold_body.count(k) for k in ["카드정보 입력", "sats_Pcpy_Id", "이미 발매된 좌석", "발행을 실패", "errorCont"]}
hold_ids = re.findall(r'name=["\']sats_Pcpy_Id["\'][^>]*value=["\']([^"\']+)', hold_body)
success = bool(hold_ids and markers["카드정보 입력"] and not markers["errorCont"])
output_dir.mkdir(parents=True, exist_ok=True)
card_path = output_dir / "tmoney-intercity-card-info.html"
card_path.write_text(hold_body)
cancel_fields = _form_fields(hold_body, "alcnInfo") or _form_fields(hold_body, "onwayInfo")
cancel_path = output_dir / "tmoney-intercity-cancel-fields.txt"
if cancel_fields:
cancel_path.write_text("\n".join(f"{k}={v}" for k, v in cancel_fields))
else:
cancel_path = None # type: ignore[assignment]
failure = None if success else _strip(hold_body[hold_body.find("[처리결과]") : hold_body.find("[처리결과]") + 500]) or "Hold did not reach card-information page"
return seat_stage_body, available, HoldResult(
success=success,
hold_id=hold_ids[0] if hold_ids else None,
seat=selected,
card_page_path=str(card_path),
cancel_fields_path=str(cancel_path) if cancel_path else None,
markers=markers,
failure_message=failure,
)
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Search Tmoney intercity-bus timetable and optionally create a temporary seat hold")
parser.add_argument("--depart-code", required=True)
parser.add_argument("--arrive-code", required=True)
parser.add_argument("--depart-name", required=True)
parser.add_argument("--arrive-name", required=True)
parser.add_argument("--date", required=True, help="YYYYMMDD")
parser.add_argument("--time", default="000000", help="HHMMSS, default 000000")
parser.add_argument("--adults", type=int, default=1)
parser.add_argument("--students", type=int, default=0)
parser.add_argument("--children", type=int, default=0)
parser.add_argument("--veterans", type=int, default=0)
parser.add_argument("--timeout", type=int, default=20)
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--select-index", type=int, default=1, help="1-based schedule index for --hold-seat")
parser.add_argument("--hold-seat", help="Temporarily hold this seat number and save the official card-info page")
parser.add_argument("--hold-first-seat", action="store_true", help="Hold the first selectable seat for the selected schedule")
parser.add_argument("--output-dir", help="Directory for saved hold/card page files; defaults to a temp directory")
args = parser.parse_args(argv)
if not re.fullmatch(r"\d{8}", args.date):
parser.error("--date must be YYYYMMDD")
if not re.fullmatch(r"\d{6}", args.time):
parser.error("--time must be HHMMSS")
if args.students or args.children or args.veterans:
parser.error("seat holding currently supports adult-only payloads; use search mode for mixed passenger counts")
if args.select_index < 1:
parser.error("--select-index must be 1 or greater")
opener, body, schedules = search_timetable(
depart_code=args.depart_code,
arrive_code=args.arrive_code,
depart_name=args.depart_name,
arrive_name=args.arrive_name,
date=args.date,
time=args.time,
adults=args.adults,
students=args.students,
children=args.children,
veterans=args.veterans,
timeout=args.timeout,
)
result: dict[str, object] = {
"route": {
"depart_code": args.depart_code,
"arrive_code": args.arrive_code,
"depart_name": args.depart_name,
"arrive_name": args.arrive_name,
"date": args.date,
"time": args.time,
},
"count": len(schedules),
"items": [asdict(s) for s in schedules[: args.limit]],
"failure_mode": None,
}
if not schedules:
result["failure_mode"] = (
"No readSasFeeInf schedule rows found. Check terminal codes/date, sold-out/no-service state, "
"or whether Tmoney returned its generic error page."
)
result["error_page_marker_count"] = body.count("errorCont")
print(json.dumps(result, ensure_ascii=False, indent=2))
return 2
if args.hold_seat or args.hold_first_seat:
if args.select_index > len(schedules):
parser.error(f"--select-index {args.select_index} exceeds schedule count {len(schedules)}")
output_dir = Path(args.output_dir) if args.output_dir else Path(tempfile.mkdtemp(prefix="tmoney-intercity-hold-"))
_, available, hold = hold_seat(opener, schedules[args.select_index - 1], args.time, args.hold_seat, output_dir, args.timeout)
result["selected_schedule"] = asdict(schedules[args.select_index - 1])
result["available_seats"] = available
result["hold"] = asdict(hold)
result["payment_window_note"] = (
"The live card-information page did not expose an exact countdown/expiry text in probes. "
"Treat the hold as short-lived and complete payment immediately; use the saved cancel fields to release abandoned holds."
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if hold.success else 3
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -22,6 +22,7 @@ metadata:
- 환경변수 `ODSAY_API_KEY` 가 있으면 사용. 없으면 `~/.config/k-skill/secrets.env` 에서 로드.
- ODsay Server 키는 호출 IP 화이트리스트 등록 필수. 발급은 https://lab.odsay.com
- Kakao Local geocoding은 기본 hosted `k-skill-proxy` 경유로 호출하므로 사용자 쪽 `KAKAO_REST_API_KEY` 는 불필요하다. self-host proxy 운영자만 `KAKAO_REST_API_KEY` 를 서버에 설정한다.
## Inputs
@ -29,25 +30,23 @@ metadata:
### Geocoding (필수 선행 단계)
`KAKAO_REST_API_KEY` 사용. 두 엔드포인트를 순서대로 시도:
기본 hosted proxy를 사용한다. Proxy가 Kakao Local REST API 키를 서버에서만 주입하고, caller `apiKey` 는 무시한다.
1. `https://dapi.kakao.com/v2/local/search/address.json?query=<주소>` — 도로명/지번 주소
2. 결과 없으면 `https://dapi.kakao.com/v2/local/search/keyword.json?query=<장소명>` — 상호명/랜드마크
1. `https://k-skill-proxy.nomadamas.org/v1/kakao-local/geocode?q=<주소/장소명>`
2. proxy 내부 fallback: Kakao Local `address.json` → 결과 없으면 `keyword.json`
헤더: `Authorization: KakaoAK <KAKAO_REST_API_KEY>`. 응답 `documents[0].x`(경도), `.y`(위도) 사용.
응답 `documents[0].x`(경도), `.y`(위도) 사용.
```python
import os, urllib.parse, urllib.request, json
H={'Authorization':'KakaoAK '+os.environ['KAKAO_REST_API_KEY']}
PROXY=os.environ.get('KSKILL_PROXY_BASE_URL','https://k-skill-proxy.nomadamas.org').rstrip('/')
def geocode(q):
for ep,name in [('address','address_name'),('keyword','place_name')]:
url=f'https://dapi.kakao.com/v2/local/search/{ep}.json?query='+urllib.parse.quote(q)
req=urllib.request.Request(url,headers=H)
with urllib.request.urlopen(req,timeout=10) as resp:
d=json.loads(resp.read())
if d.get('documents'):
doc=d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get(name) or doc['address_name']
url=PROXY+'/v1/kakao-local/geocode?q='+urllib.parse.quote(q)
with urllib.request.urlopen(url,timeout=10) as resp:
d=json.loads(resp.read())
if d.get('documents'):
doc=d['documents'][0]
return float(doc['x']), float(doc['y']), doc.get('place_name') or doc.get('address_name')
return None
```
@ -109,7 +108,7 @@ curl -s "https://api.odsay.com/v1/api/searchStation?apiKey=${KEY}&stationName=
## Limits
- 무료 일 5,000건. `searchPubTransPathT` + `searchStation` 호출이 합산되니 한 질문당 호출 최소화.
- 현재 ODsay 공식 Basic 상품 기준 무료 체험은 일 1,000건(6개월)이다. `searchPubTransPathT` + `searchStation` 호출이 합산되니 한 질문당 호출 최소화.
- 응답에 `error` 키 있으면 즉시 사용자에게 표시(ApiKey/IP 문제 진단에 유용).
- 한국 외 좌표는 지원 안 함.
@ -118,7 +117,7 @@ curl -s "https://api.odsay.com/v1/api/searchStation?apiKey=${KEY}&stationName=
- ODsay `error` 응답: `msg` 필드를 그대로 사용자에게 표시하고, ApiKey 미등록 또는 IP 화이트리스트 누락 가능성을 안내한다.
- Kakao geocoding 결과 없음: 주소/장소명을 다시 확인하거나 더 구체적인 표현을 요청한다.
- 좌표는 있으나 ODsay 경로 없음: 대중교통 미개통 지역, 도보 가능 거리, 또는 해상/공항 구간일 수 있다. 사용자에게 확인한다.
- quota 초과: 일일 5,000건 한도 도달 시 추가 호출을 중단하고 사용자에게 알린다.
- quota 초과: 일일 한도 도달 시 추가 호출을 중단하고 사용자에게 알린다.
## Don'ts

View file

@ -40,7 +40,8 @@ metadata:
## Prerequisites
- Python 3.9+ (stdlib only, 외부 패키지 없음)
- KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
- 일반 `search`/`meta`/`data`: `k-skill-proxy`의 KOSIS route가 있는 hosted/self-host 프록시에 접근 가능할 것
- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청)
```bash
python3 kosis-stats/scripts/run_kosis_stats.py --help
@ -48,11 +49,13 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
## Required environment variables
- `KSKILL_KOSIS_API_KEY` — KOSIS Open API 인증키
- 일반 `search`/`meta`/`data`: 없음. 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted proxy를 사용한다.
- `KSKILL_KOSIS_API_KEY``bigdata` 또는 `--direct`로 KOSIS를 직접 호출할 때만 필요하다.
발급 절차와 호출 한도, 에러 코드 등 자세한 내용은 [`references/kosis-openapi-guide.md`](references/kosis-openapi-guide.md) 참고.
### Credential resolution order
### Credential resolution order (`bigdata` 또는 `--direct` 전용)
1. **이미 환경변수에 있으면** 그대로 사용한다.
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
@ -60,7 +63,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py --help
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만 읽는다.
일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일을 읽는다.
## Inputs
@ -72,6 +75,8 @@ Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만
- `--json`: 구조화 결과 (기본값)
- `--dry-run`: 인증키 없이 요청 URL/파라미터만 출력
- `--timeout N`: HTTP 타임아웃 초 단위 (기본 30)
- `--proxy-base-url URL`: 기본 hosted proxy 대신 self-host/alternate proxy 사용
- `--direct`: proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출
서브커맨드별 입력:
@ -97,11 +102,11 @@ Helper 자체는 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일만
## Workflow
### 1. Ensure credentials are available
### 1. Ensure proxy access is available
`KSKILL_KOSIS_API_KEY` 가 설정되어 있는지 확인한다. 없으면 credential resolution order에 따라 확보한다.
일반 `search`/`meta`/`data` 는 기본 hosted `k-skill-proxy`를 사용하므로 사용자 KOSIS 키가 필요 없다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다.
시크릿이 없다는 이유로 다른 통계 사이트나 비공식 경로를 찾지 않는다.
`bigdata` 또는 `--direct`가 필요할 때만 `KSKILL_KOSIS_API_KEY` 를 credential resolution order에 따라 확보한다. 시크릿이 없다는 이유로 다른 통계 사이트나 비공식 경로를 찾지 않는다.
### 2. Search for candidate tables
@ -161,7 +166,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
## Failure modes
- `KSKILL_KOSIS_API_KEY` 누락: 발급 안내 메시지와 함께 종료(exit 1)
- `KSKILL_KOSIS_API_KEY` 누락: `bigdata` 또는 `--direct` 호출에서만 발급 안내 메시지와 함께 종료(exit 1)
- KOSIS 에러 코드 `10`/`11`: 인증키 누락/만료 → 키 점검. `bigdata` 에서 `11` 이 나오면 `userStatsId` 가 본인 KOSIS 계정에 등록된 것이 아닐 가능성이 크다.
- 코드 `20`: 필수 분류 누락 → `meta --meta-type OBJ` (또는 비어 있으면 `ITM`) 으로 필요한 차원 수와 코드를 확인하고 `--obj-l 1=... --obj-l 2=...` 모두 지정 후 재시도
- 코드 `21`: 잘못된 요청 변수 → `org_id`/`tbl_id`/기간 형식 재확인. tblId 의심 시 `search` 로 정확한 ID 다시 찾기
@ -181,6 +186,7 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
## Maintainer review notes
메인테이너가 이 스킬을 검토하기 위해 KOSIS 인증키를 새로 발급받을 필요는 없다.
일반 조회는 `k-skill-proxy`가 KOSIS 인증키를 서버 쪽에서 주입한다. `bigdata``--direct`만 개인 KOSIS 키가 필요하다.
키 없이 가능한 검증:
@ -191,11 +197,11 @@ python3 kosis-stats/scripts/run_kosis_stats.py bigdata \
- `PYTHONPATH=kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_*.py' -v`
- `npm run ci`
실제 live smoke는 기여자 또는 이미 KOSIS 키가 있는 사용자가 선택적으로 수행한다. PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남기고 인증키와 개인 조회 세부 내역은 공유하지 않는다.
실제 direct live smoke는 기여자 또는 이미 KOSIS 키가 있는 사용자가 선택적으로 수행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다. PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남기고 인증키와 개인 조회 세부 내역은 공유하지 않는다.
## Safety notes
- 조회 전용 스킬이다.
- 사용자별 통계자료(`userStatsId`) 등록, 데이터 수정, KOSIS 웹 자동화는 하지 않는다.
- 인증키는 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다.
- 일반 조회 인증키는 proxy 서버에서만 다룬다. direct/bigdata 인증키는 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다.
- 응답 JSON에 인증키가 echo 되지 않도록 helper는 `--dry-run` 시에도 키를 `<DRY-RUN>` 으로 대체한다.

View file

@ -30,6 +30,8 @@ SEARCH_URL = "https://kosis.kr/openapi/statisticsSearch.do"
META_URL = "https://kosis.kr/openapi/statisticsData.do"
DATA_URL = "https://kosis.kr/openapi/Param/statisticsParameterData.do"
BIGDATA_URL = "https://kosis.kr/openapi/statisticsBigData.do"
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
DEFAULT_TIMEOUT = 30
PRD_SE_VALUES = {"M", "Q", "S", "Y", "F", "IR"}
@ -104,6 +106,18 @@ def _add_common_flags(parser: argparse.ArgumentParser) -> None:
action="store_true",
help="Print the request URL and parameters without calling KOSIS.",
)
parser.add_argument(
"--proxy-base-url",
help=(
"k-skill-proxy base URL for search/meta/data "
f"(default {DEFAULT_PROXY_BASE_URL}; override with {PROXY_BASE_URL_ENV_VAR})."
),
)
parser.add_argument(
"--direct",
action="store_true",
help="Call KOSIS directly with KSKILL_KOSIS_API_KEY instead of k-skill-proxy.",
)
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.")
@ -235,6 +249,19 @@ def resolve_api_key(
)
def resolve_proxy_base_url(
explicit_base_url: str | None = None,
env: dict[str, str] | None = None,
) -> str:
env_map = env if env is not None else os.environ
candidate = (explicit_base_url or env_map.get(PROXY_BASE_URL_ENV_VAR) or "").strip()
if candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise SystemExit(f"{PROXY_BASE_URL_ENV_VAR} is disabled; pass --direct to use KSKILL_KOSIS_API_KEY.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def parse_obj_l(values: list[str]) -> dict[str, str]:
objs: dict[str, str] = {}
for raw in values:
@ -492,11 +519,30 @@ def cite_endpoint(command: str) -> str:
}[command]
def should_use_proxy(args: argparse.Namespace) -> bool:
return args.command in {"search", "meta", "data"} and not args.direct
def proxy_endpoint(command: str, base_url: str) -> str:
path = {
"search": "/v1/kosis/search",
"meta": "/v1/kosis/meta",
"data": "/v1/kosis/data",
}[command]
return f"{base_url.rstrip('/')}{path}"
def params_without_api_key(params: dict[str, str]) -> dict[str, str]:
return {key: value for key, value in params.items() if key != "apiKey"}
def run(args: argparse.Namespace) -> int:
use_json = args.json or not args.text
if args.dry_run:
api_key = "<DRY-RUN>"
use_proxy = should_use_proxy(args)
if use_proxy or args.dry_run:
api_key = "<PROXY>" if use_proxy else "<DRY-RUN>"
else:
api_key = resolve_api_key()
@ -508,16 +554,31 @@ def run(args: argparse.Namespace) -> int:
}[args.command]
base = cite_endpoint(args.command)
params = builder(api_key, args)
url = build_url(base, params)
if use_proxy:
call_base = proxy_endpoint(args.command, resolve_proxy_base_url(args.proxy_base_url))
call_params = params_without_api_key(params)
else:
call_base = base
call_params = params
url = build_url(call_base, call_params)
if args.dry_run:
redacted = dict(params)
redacted["apiKey"] = "<DRY-RUN>"
redacted = dict(call_params)
if not use_proxy and "apiKey" in redacted:
redacted["apiKey"] = "<DRY-RUN>"
if use_json:
print(json.dumps({"endpoint": base, "params": redacted, "url": build_url(base, redacted)}, ensure_ascii=False, indent=2))
print(json.dumps({
"endpoint": call_base,
"upstream_endpoint": base,
"via_proxy": use_proxy,
"params": redacted,
"url": build_url(call_base, redacted)
}, ensure_ascii=False, indent=2))
else:
print(f"endpoint: {base}")
print(f"url: {build_url(base, redacted)}")
print(f"endpoint: {call_base}")
print(f"upstream_endpoint: {base}")
print(f"via_proxy: {str(use_proxy).lower()}")
print(f"url: {build_url(call_base, redacted)}")
for key, value in redacted.items():
print(f" {key}={value}")
return 0
@ -539,6 +600,8 @@ def run(args: argparse.Namespace) -> int:
else:
print(render_text(args.command, payload))
print(f"\nsource: {base}")
if use_proxy:
print(f"via: {call_base}")
return 0

View file

@ -96,6 +96,22 @@ class CredentialResolutionTest(unittest.TestCase):
self.assertIn("KSKILL_KOSIS_API_KEY", message)
self.assertIn("kosis.kr/openapi", message)
def test_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(
helper.resolve_proxy_base_url(env={}),
"https://k-skill-proxy.nomadamas.org"
)
def test_proxy_base_url_env_override_is_trimmed(self):
self.assertEqual(
helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "https://proxy.example/"}),
"https://proxy.example"
)
def test_proxy_base_url_can_be_disabled_for_direct_mode(self):
with self.assertRaises(SystemExit):
helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "off"})
class UrlBuilderTest(unittest.TestCase):
def test_search_params_include_required_fields(self):
@ -334,15 +350,29 @@ class DryRunTest(unittest.TestCase):
self.assertEqual(rc, 0)
fetch_mock.assert_not_called()
out = buf.getvalue()
self.assertIn("<DRY-RUN>", out)
self.assertIn('"via_proxy": true', out)
self.assertNotIn("apiKey", json.dumps(json.loads(out)["params"]))
self.assertIn("/v1/kosis/search", out)
self.assertIn("statisticsSearch.do", out)
def test_direct_dry_run_redacts_api_key(self):
args = helper.parse_args(["search", "--query", "인구", "--dry-run", "--direct", "--json"])
with mock.patch.object(helper, "fetch_text") as fetch_mock:
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)
self.assertEqual(rc, 0)
fetch_mock.assert_not_called()
out = buf.getvalue()
self.assertIn("<DRY-RUN>", out)
self.assertIn('"via_proxy": false', out)
self.assertIn("apiKey", out)
class RunIntegrationTest(unittest.TestCase):
def test_run_search_text_renders_fixture_payload(self):
args = helper.parse_args(["search", "--query", "1인 가구", "--text"])
with mock.patch.object(helper, "resolve_api_key", return_value="KEY"), \
mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")):
with mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")) as fetch_mock:
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)
@ -350,6 +380,8 @@ class RunIntegrationTest(unittest.TestCase):
out = buf.getvalue()
self.assertIn("DT_1JC1501", out)
self.assertIn("statisticsSearch.do", out)
self.assertIn("/v1/kosis/search", fetch_mock.call_args.args[0])
self.assertNotIn("apiKey=", fetch_mock.call_args.args[0])
def test_run_returns_2_on_kosis_error(self):
args = helper.parse_args(["data", "--table-id", "DT_X",
@ -364,7 +396,7 @@ class RunIntegrationTest(unittest.TestCase):
@unittest.skipUnless(os.getenv("KSKILL_KOSIS_API_KEY"), "live KOSIS test skipped without KSKILL_KOSIS_API_KEY")
class LiveKosisSmokeTest(unittest.TestCase):
def test_live_search_returns_list(self):
args = helper.parse_args(["search", "--query", "인구", "--result-count", "1", "--json"])
args = helper.parse_args(["search", "--query", "인구", "--result-count", "1", "--json", "--direct"])
buf = io.StringIO()
with redirect_stdout(buf):
rc = helper.run(args)

View file

@ -9,9 +9,10 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/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/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.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 danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"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/build-manus-bundle.js scripts/test_build_manus_bundle.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/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.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 danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_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_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_ticket_availability && 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' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.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_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_ticket_availability && 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' && 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",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -18,6 +18,10 @@
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
- `GET /v1/naver-news/search` — 네이버 검색 Open API 뉴스 검색(`news.json`) 기반 최신 뉴스 기사 제목/요약/링크/발행시각 조회(`NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 필요)
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
@ -47,7 +51,9 @@
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao Local REST API 키 (`kakao-local/geocode`)
- `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"`로 표시
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
@ -205,6 +211,33 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
KOSIS 통계 조회 예시 (`KOSIS_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/search' \
--data-urlencode 'q=1인 가구' \
--data-urlencode 'limit=3'
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/meta' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'metaType=ITM'
curl -fsS --get 'http://127.0.0.1:4020/v1/kosis/data' \
--data-urlencode 'tableId=DT_1JC1501' \
--data-urlencode 'prdSe=Y' \
--data-urlencode 'start=2020' \
--data-urlencode 'end=2023' \
--data-urlencode 'objL1=ALL'
```
Kakao Local geocoding 예시 (`KAKAO_REST_API_KEY` 필요, caller `apiKey`는 무시하고 서버 쪽 키를 주입):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/kakao-local/geocode' \
--data-urlencode 'q=서울역' \
--data-urlencode 'limit=1'
```
## PM2 실행

View file

@ -28,6 +28,8 @@ const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-cod
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
const KOSIS_OPEN_API_BASE_URL = "https://kosis.kr/openapi";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@ -159,8 +161,10 @@ function buildConfig(env = process.env) {
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
data4libraryAuthKey: trimOrNull(env.DATA4LIBRARY_AUTH_KEY),
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
kakaoRestApiKey: trimOrNull(env.KAKAO_REST_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
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),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
@ -476,6 +480,126 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeKosisSearchQuery(query) {
const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q);
if (!searchNm) {
throw new Error("Provide query.");
}
return {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm,
resultCount: parseBoundedPositiveInteger(query.resultCount ?? query.result_count ?? query.limit, {
defaultValue: 20,
min: 1,
max: 5000,
label: "resultCount"
}),
startCount: parseBoundedPositiveInteger(query.startCount ?? query.start_count ?? query.start, {
defaultValue: 1,
min: 1,
max: 1000000,
label: "startCount"
})
};
}
function normalizeKosisMetaQuery(query) {
const orgId = trimOrNull(query.orgId ?? query.org_id) || "101";
const tblId = trimOrNull(query.tblId ?? query.tableId ?? query.table_id ?? query.tbl_id);
const type = (trimOrNull(query.type ?? query.metaType ?? query.meta_type) || "TBL").toUpperCase();
if (!/^\d+$/.test(orgId)) {
throw new Error("Provide valid orgId.");
}
if (!tblId) {
throw new Error("Provide tableId.");
}
if (!["TBL", "ITM", "OBJ"].includes(type)) {
throw new Error("metaType must be TBL, ITM, or OBJ.");
}
return {
method: "getMeta",
type,
format: "json",
jsonVD: "Y",
orgId,
tblId
};
}
function normalizeKosisDataQuery(query) {
const orgId = trimOrNull(query.orgId ?? query.org_id) || "101";
const tblId = trimOrNull(query.tblId ?? query.tableId ?? query.table_id ?? query.tbl_id);
const itmId = trimOrNull(query.itmId ?? query.itemId ?? query.item_id ?? query.itm_id) || "ALL";
const prdSe = (trimOrNull(query.prdSe ?? query.prd_se) || "").toUpperCase();
const startPrdDe = trimOrNull(query.startPrdDe ?? query.start_prd_de ?? query.start);
const endPrdDe = trimOrNull(query.endPrdDe ?? query.end_prd_de ?? query.end);
if (!/^\d+$/.test(orgId)) {
throw new Error("Provide valid orgId.");
}
if (!tblId) {
throw new Error("Provide tableId.");
}
if (!["M", "Q", "S", "Y", "F", "IR"].includes(prdSe)) {
throw new Error("prdSe must be one of M, Q, S, Y, F, IR.");
}
if (!startPrdDe || !endPrdDe) {
throw new Error("Provide start and end periods.");
}
const normalized = {
method: "getList",
format: "json",
jsonVD: "Y",
orgId,
tblId,
itmId,
prdSe,
startPrdDe,
endPrdDe
};
for (let index = 1; index <= 8; index += 1) {
const value = trimOrNull(query[`objL${index}`] ?? query[`obj_l${index}`]);
if (value) {
normalized[`objL${index}`] = value;
}
}
if (!Object.keys(normalized).some((key) => /^objL\d+$/.test(key))) {
normalized.objL1 = "ALL";
}
return normalized;
}
function normalizeKakaoLocalGeocodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
return {
query: q,
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 5,
min: 1,
max: 15,
label: "size"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
})
};
}
function normalizeKmaForecastQuery(query, now = new Date()) {
const rawNx = parseInteger(query.nx, Number.NaN);
const rawNy = parseInteger(query.ny, Number.NaN);
@ -1033,6 +1157,154 @@ async function proxyData4LibraryRequest({
};
}
async function proxyKosisRequest({
operation,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
search: "statisticsSearch.do",
meta: "statisticsData.do",
data: "Param/statisticsParameterData.do"
};
const path = paths[operation];
if (!path) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That KOSIS route is not exposed by this proxy."
})
};
}
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KOSIS_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${KOSIS_OPEN_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
url.searchParams.set("apiKey", apiKey);
const response = await fetchImpl(url, {
headers: {
"user-agent": "k-skill-proxy/kosis"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
async function proxyKakaoLocalRequest({
endpoint,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
address: "search/address.json",
keyword: "search/keyword.json"
};
const path = paths[endpoint];
if (!path) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That Kakao Local route is not exposed by this proxy."
})
};
}
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KAKAO_REST_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${KAKAO_LOCAL_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
const response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
"user-agent": "k-skill-proxy/kakao-local"
},
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
function hasKakaoLocalDocuments(body) {
try {
const payload = JSON.parse(String(body || ""));
return Array.isArray(payload.documents) && payload.documents.length > 0;
} catch {
return false;
}
}
function isSuccessfulJsonResponse(upstream) {
return upstream.statusCode >= 200 && upstream.statusCode < 300 && upstream.contentType.includes("json");
}
function isKosisErrorBody(body) {
const text = String(body || "").trim();
if (!text) {
return true;
}
if (/<error>\s*<err>/i.test(text)) {
return true;
}
if (!(text.startsWith("{") || text.startsWith("["))) {
return false;
}
try {
const payload = JSON.parse(text.replace(/([{,])\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":'));
return Boolean(payload && !Array.isArray(payload) && typeof payload === "object" && (payload.err || payload.errCode || payload.error));
} catch {
return false;
}
}
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
@ -1284,6 +1556,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey),
kakaoLocalConfigured: Boolean(config.kakaoRestApiKey),
kosisConfigured: Boolean(config.kosisApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent
@ -1432,6 +1706,115 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
async function handleKosisRoute({ operation, normalize, cacheRoute, request, reply }) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: cacheRoute,
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
reply.code(cached.statusCode);
reply.header("content-type", cached.contentType);
return cached.body;
}
const upstream = await proxyKosisRequest({
operation,
params: normalized,
apiKey: config.kosisApiKey
});
if (upstream.statusCode >= 200 && upstream.statusCode < 300 && !isKosisErrorBody(upstream.body)) {
cache.set(cacheKey, upstream, config.cacheTtlMs);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
return upstream.body;
}
app.get("/v1/kosis/search", async (request, reply) => handleKosisRoute({
operation: "search",
normalize: normalizeKosisSearchQuery,
cacheRoute: "kosis-search",
request,
reply
}));
app.get("/v1/kosis/meta", async (request, reply) => handleKosisRoute({
operation: "meta",
normalize: normalizeKosisMetaQuery,
cacheRoute: "kosis-meta",
request,
reply
}));
app.get("/v1/kosis/data", async (request, reply) => handleKosisRoute({
operation: "data",
normalize: normalizeKosisDataQuery,
cacheRoute: "kosis-data",
request,
reply
}));
app.get("/v1/kakao-local/geocode", async (request, reply) => {
let normalized;
try {
normalized = normalizeKakaoLocalGeocodeQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "kakao-local-geocode",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
reply.code(cached.statusCode);
reply.header("content-type", cached.contentType);
return cached.body;
}
const address = await proxyKakaoLocalRequest({
endpoint: "address",
params: normalized,
apiKey: config.kakaoRestApiKey
});
const upstream = isSuccessfulJsonResponse(address) && !hasKakaoLocalDocuments(address.body)
? await proxyKakaoLocalRequest({
endpoint: "keyword",
params: normalized,
apiKey: config.kakaoRestApiKey
})
: address;
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, upstream, config.cacheTtlMs);
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
return upstream.body;
});
app.get("/v1/korea-weather/forecast", async (request, reply) => {
let normalized;
@ -3453,7 +3836,11 @@ module.exports = {
normalizeData4LibraryLibrarySearchQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeKmaForecastQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
@ -3470,9 +3857,11 @@ module.exports = {
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyNeisSchoolMealRequest,
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
proxyKosisRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulSubwayRequest,

View file

@ -11,9 +11,15 @@ const {
normalizeData4LibraryBookSearchQuery,
normalizeData4LibraryLibrariesByBookQuery,
normalizeData4LibraryLibrarySearchQuery,
normalizeKakaoLocalGeocodeQuery,
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyKosisRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
@ -193,6 +199,189 @@ test("health endpoint reports KRX upstream status when configured", async (t) =>
assert.equal(response.json().upstreams.krxConfigured, true);
});
test("KOSIS normalizers map public query aliases to upstream params", () => {
assert.deepEqual(normalizeKosisSearchQuery({ q: "인구", limit: "3" }), {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm: "인구",
resultCount: 3,
startCount: 1
});
assert.deepEqual(normalizeKosisMetaQuery({ table_id: "DT_1IN0001", meta_type: "itm" }), {
method: "getMeta",
type: "ITM",
format: "json",
jsonVD: "Y",
orgId: "101",
tblId: "DT_1IN0001"
});
assert.deepEqual(normalizeKosisDataQuery({
tableId: "DT_1JC1501",
prd_se: "y",
start: "2023",
end: "2024",
objL2: "00"
}), {
method: "getList",
format: "json",
jsonVD: "Y",
orgId: "101",
tblId: "DT_1JC1501",
itmId: "ALL",
prdSe: "Y",
startPrdDe: "2023",
endPrdDe: "2024",
objL2: "00"
});
});
test("KOSIS proxy injects the server-side key without accepting client apiKey", async () => {
const calls = [];
const upstream = await proxyKosisRequest({
operation: "search",
apiKey: "server-kosis-key",
params: {
method: "getList",
format: "json",
jsonVD: "Y",
searchNm: "인구",
resultCount: 1,
startCount: 1,
apiKey: "client-supplied-key"
},
fetchImpl: async (url) => {
calls.push(String(url));
return new Response("[]", {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(calls.length, 1);
const url = new URL(calls[0]);
assert.equal(url.origin + url.pathname, "https://kosis.kr/openapi/statisticsSearch.do");
assert.equal(url.searchParams.get("apiKey"), "server-kosis-key");
assert.equal(url.searchParams.get("searchNm"), "인구");
});
test("KOSIS search endpoint stays public and caches successful upstream responses", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(JSON.stringify([{ TBL_ID: "DT_1JC1501", TBL_NM: "1인 가구 비율" }]), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KOSIS_API_KEY: "server-kosis-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/kosis/search?q=${encodeURIComponent("1인 가구")}&limit=1&apiKey=client-key`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json()[0].TBL_ID, "DT_1JC1501");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json()[0].TBL_NM, "1인 가구 비율");
assert.equal(calls.length, 1, "second request should be served from proxy cache");
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), "server-kosis-key");
});
test("Kakao Local geocode normalizer maps public aliases to upstream params", () => {
assert.deepEqual(normalizeKakaoLocalGeocodeQuery({ q: "서울역", limit: "3" }), {
query: "서울역",
size: 3,
page: 1
});
});
test("Kakao Local proxy injects the server-side REST API key", async () => {
const calls = [];
const upstream = await proxyKakaoLocalRequest({
endpoint: "address",
apiKey: "server-kakao-key",
params: {
query: "서울역",
size: 1,
apiKey: "client-supplied-key"
},
fetchImpl: async (url, options) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(JSON.stringify({ documents: [] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(calls.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.origin + url.pathname, "https://dapi.kakao.com/v2/local/search/address.json");
assert.equal(url.searchParams.get("query"), "서울역");
assert.equal(url.searchParams.get("apiKey"), null);
assert.equal(calls[0].headers.authorization, "KakaoAK server-kakao-key");
});
test("Kakao Local geocode endpoint falls back from address to keyword and caches", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
const path = new URL(url).pathname;
if (path.endsWith("/search/address.json")) {
return new Response(JSON.stringify({ documents: [] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
return new Response(JSON.stringify({ documents: [{ place_name: "서울역", x: "126.9706", y: "37.5559" }] }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
KAKAO_REST_API_KEY: "server-kakao-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/kakao-local/geocode?q=${encodeURIComponent("서울역")}&limit=1&apiKey=client-key`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().documents[0].place_name, "서울역");
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(second.json().documents[0].x, "126.9706");
assert.equal(calls.length, 2, "second request should be served from proxy cache");
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];

View file

@ -0,0 +1,220 @@
#!/usr/bin/env node
/**
* Build Manus.ai-compatible bundles for k-skill.
*
* Manus accepts ONE skill per upload (`.skill`/`.zip`/folder) and offers no
* multi-skill bulk import path, so this script emits one `.skill` per skill
* plus a single combined download archive.
*
* Each `.skill` archive contains a single top-level `<skill-name>/` folder
* that matches the layout produced by the public Anthropic skill-creator
* packager (https://github.com/anthropics/skills/blob/main/skills/skill-creator/scripts/package_skill.py).
* That nested layout is load-bearing: flattening it breaks Manus import.
*
* Skill discovery mirrors `scripts/validate-skills.sh`. Requires the system
* `zip` command (preinstalled on macOS and GitHub Actions ubuntu-latest).
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const distDir = path.join(repoRoot, "dist", "manus");
// Directories at the repo root that are NEVER skills, mirroring
// scripts/validate-skills.sh's exclusion list.
const EXCLUDED_DIRS = new Set([
".git",
".github",
".codex",
".claude",
".omx",
".ouroboros",
".changeset",
".cursor",
".vscode",
".sisyphus",
".idea",
"docs",
"dist",
"node_modules",
"packages",
"python-packages",
"scripts",
"examples",
]);
function ensureZipAvailable() {
const probe = spawnSync("zip", ["-v"], { stdio: "ignore" });
if (probe.error || probe.status !== 0) {
console.error(
"ERROR: the `zip` command is required to build Manus bundles.\n" +
" - macOS: preinstalled.\n" +
" - Debian/Ubuntu: sudo apt-get install -y zip\n" +
" - Windows: install via WSL or Git Bash, or use 7-Zip and zip the folders manually.",
);
process.exit(2);
}
}
function discoverSkills() {
const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
const skills = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (EXCLUDED_DIRS.has(entry.name)) continue;
if (entry.name.startsWith(".")) continue;
const skillMd = path.join(repoRoot, entry.name, "SKILL.md");
if (fs.existsSync(skillMd)) {
skills.push(entry.name);
}
}
skills.sort();
return skills;
}
function readSkillMeta(skillName) {
const skillMd = path.join(repoRoot, skillName, "SKILL.md");
const raw = fs.readFileSync(skillMd, "utf8");
const match = raw.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { name: skillName, description: "" };
const fm = match[1];
const grab = (key) => {
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
};
return {
name: grab("name") || skillName,
description: grab("description"),
};
}
function rimrafSync(target) {
if (!fs.existsSync(target)) return;
fs.rmSync(target, { recursive: true, force: true });
}
function buildSkillArchive(skillName) {
const archivePath = path.join(distDir, `${skillName}.skill`);
rimrafSync(archivePath);
// zip is run from the repo root and asked to add the whole `<skillName>/`
// folder; the resulting archive therefore has `<skillName>/SKILL.md` etc. at
// its root, which matches the public Anthropic packager layout.
const result = spawnSync(
"zip",
[
"-r",
"-q",
"-X", // strip extra file attributes for reproducible archives
archivePath,
skillName,
"-x",
`${skillName}/node_modules/*`,
"-x",
`${skillName}/__pycache__/*`,
"-x",
`${skillName}/*/__pycache__/*`,
"-x",
`${skillName}/.DS_Store`,
"-x",
`${skillName}/*/.DS_Store`,
],
{ cwd: repoRoot, stdio: ["ignore", "inherit", "inherit"] },
);
if (result.status !== 0) {
throw new Error(`zip failed for ${skillName} (exit ${result.status})`);
}
return archivePath;
}
function buildAllInOneArchive(skillNames) {
// Bundle all the .skill files together so users can download a single
// release asset and then drag-drop the individual .skill files into Manus.
const allInOne = path.join(distDir, "k-skill-manus-all.zip");
rimrafSync(allInOne);
const relativeNames = skillNames.map((s) => `${s}.skill`);
relativeNames.push("INDEX.md");
const result = spawnSync("zip", ["-q", "-X", "-j", allInOne, ...relativeNames.map((n) => path.join(distDir, n))], {
cwd: distDir,
stdio: ["ignore", "inherit", "inherit"],
});
if (result.status !== 0) {
throw new Error(`zip failed for k-skill-manus-all.zip (exit ${result.status})`);
}
return allInOne;
}
function writeIndex(skillMetas) {
const lines = [];
lines.push("# k-skill — Manus.ai 가져오기용 번들");
lines.push("");
lines.push("이 폴더에는 NomaDamas/k-skill 의 모든 스킬이 Manus.ai 호환 `.skill` 아카이브로 빌드되어 있다.");
lines.push("");
lines.push("## 사용 방법");
lines.push("");
lines.push("1. Manus.ai 에서 **스킬 업로드** 화면을 연다.");
lines.push("2. 원하는 `<skill-name>.skill` 파일을 드래그-드롭하거나 파일 선택으로 업로드한다.");
lines.push("3. 한 번의 업로드는 한 개의 스킬을 등록한다. 필요한 스킬만큼 반복한다.");
lines.push("");
lines.push("`.skill` 파일은 사실상 ZIP 아카이브이며, 내부에는 단일 최상위 폴더 `<skill-name>/`(SKILL.md + 보조 리소스)가 들어 있다.");
lines.push("");
lines.push("Manus.ai 는 **하나의 아카이브로 여러 스킬을 한꺼번에 등록하는 기능을 공식 지원하지 않는다.** `k-skill-manus-all.zip` 은 단순히 모든 `.skill` 파일을 한 번에 받기 위한 편의 번들이다. 압축을 풀면 N개의 `.skill` 파일이 나오며 그 파일들을 Manus 에 하나씩 업로드해야 한다.");
lines.push("");
lines.push("## 포함된 스킬");
lines.push("");
lines.push("| 스킬 이름 | 설명 | 파일 |");
lines.push("| --- | --- | --- |");
for (const meta of skillMetas) {
const desc = (meta.description || "").replace(/\|/g, "\\|");
lines.push(`| \`${meta.name}\` | ${desc} | \`${meta.name}.skill\` |`);
}
lines.push("");
lines.push(`${skillMetas.length}개 스킬.`);
lines.push("");
fs.writeFileSync(path.join(distDir, "INDEX.md"), lines.join("\n"));
}
function main() {
ensureZipAvailable();
rimrafSync(distDir);
fs.mkdirSync(distDir, { recursive: true });
const skills = discoverSkills();
if (skills.length === 0) {
console.error("ERROR: no skills with SKILL.md found at repo root.");
process.exit(1);
}
const metas = [];
for (const skill of skills) {
process.stdout.write(`packing ${skill}.skill ... `);
buildSkillArchive(skill);
metas.push(readSkillMeta(skill));
process.stdout.write("ok\n");
}
writeIndex(metas);
buildAllInOneArchive(skills);
console.log("");
console.log(`built ${skills.length} .skill files in ${path.relative(repoRoot, distDir)}/`);
console.log(`combined download: ${path.relative(repoRoot, path.join(distDir, "k-skill-manus-all.zip"))}`);
}
if (require.main === module) {
try {
main();
} catch (err) {
console.error(err.message || err);
process.exit(1);
}
}
module.exports = {
EXCLUDED_DIRS,
discoverSkills,
readSkillMeta,
};

View file

@ -0,0 +1,104 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const repoRoot = path.resolve(__dirname, "..");
const buildScript = path.join(__dirname, "build-manus-bundle.js");
test("build-manus-bundle script exists and is executable as a Node module", () => {
assert.ok(fs.existsSync(buildScript), "build-manus-bundle.js must exist");
const checked = spawnSync(process.execPath, ["--check", buildScript], { encoding: "utf8" });
assert.equal(checked.status, 0, `node --check failed: ${checked.stderr}`);
});
test("discoverSkills finds every root-level skill with a SKILL.md and matches validate-skills.sh", () => {
const { discoverSkills, EXCLUDED_DIRS } = require("./build-manus-bundle.js");
const skills = discoverSkills();
assert.ok(skills.length >= 50, `expected at least 50 skills, got ${skills.length}`);
for (const name of skills) {
assert.ok(
fs.existsSync(path.join(repoRoot, name, "SKILL.md")),
`discovered skill ${name} must have a SKILL.md`,
);
assert.ok(!EXCLUDED_DIRS.has(name), `${name} must not be an excluded tooling dir`);
}
const validatorOutput = spawnSync(path.join(__dirname, "validate-skills.sh"), [], {
cwd: repoRoot,
encoding: "utf8",
});
assert.equal(validatorOutput.status, 0, `validate-skills.sh failed: ${validatorOutput.stderr}`);
});
test("readSkillMeta extracts name and description from YAML frontmatter", () => {
const { readSkillMeta } = require("./build-manus-bundle.js");
const sample = readSkillMeta("mfds-food-safety");
assert.equal(sample.name, "mfds-food-safety");
assert.ok(sample.description.length > 0, "description must be non-empty");
});
test("EXCLUDED_DIRS stays in lockstep with validate-skills.sh exclusions", () => {
const { EXCLUDED_DIRS } = require("./build-manus-bundle.js");
const validator = fs.readFileSync(path.join(__dirname, "validate-skills.sh"), "utf8");
const required = [
".git",
".github",
".codex",
".claude",
".changeset",
"docs",
"node_modules",
"packages",
"python-packages",
"scripts",
"examples",
];
for (const dir of required) {
assert.ok(
validator.includes(`! -name ${dir}`),
`validate-skills.sh must exclude ${dir} (or this list needs updating)`,
);
assert.ok(EXCLUDED_DIRS.has(dir), `EXCLUDED_DIRS must also skip ${dir}`);
}
});
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");
assert.match(doc, /\.skill/, "must document the .skill file flow");
assert.match(doc, /build:manus-bundle/, "must reference the npm build script");
});
test("docs/install-manus.md advertises the rolling release download URL", () => {
const doc = fs.readFileSync(path.join(repoRoot, "docs", "install-manus.md"), "utf8");
assert.match(
doc,
/releases\/download\/manus-bundle-latest\/k-skill-manus-all\.zip/,
"must link to the stable rolling-release download URL",
);
assert.match(
doc,
/releases\/tag\/manus-bundle-latest/,
"must link to the rolling-release page",
);
});
test("manus-bundle workflow exists, targets main, and publishes the expected assets", () => {
const wfPath = path.join(repoRoot, ".github", "workflows", "manus-bundle.yml");
assert.ok(fs.existsSync(wfPath), "manus-bundle.yml workflow must exist");
const wf = fs.readFileSync(wfPath, "utf8");
assert.match(wf, /branches:\s*\n\s*-\s*main/, "workflow must trigger on push to main");
assert.match(wf, /npm run build:manus-bundle/, "workflow must invoke the build script");
assert.match(wf, /manus-bundle-latest/, "workflow must use the stable rolling tag");
assert.match(wf, /k-skill-manus-all\.zip/, "workflow must upload the combined archive");
assert.match(wf, /--prerelease/, "rolling release must be marked as prerelease");
assert.match(wf, /contents:\s*write/, "workflow needs write permission to publish releases");
});

View file

@ -45,6 +45,9 @@ done < <(
! -name .changeset \
! -name .cursor \
! -name .vscode \
! -name .sisyphus \
! -name .idea \
! -name dist \
! -name docs \
! -name node_modules \
! -name packages \