instruction.md
# Express Bus Booking (KOBUS)
## Overview
Use this skill for Korean 고속버스 / KOBUS timetable lookup and reservation assistance. The preferred workflow is HTTP/API-first: resolve routes, query timetables, inspect remaining seats and fares, and only use browser automation when endpoint discovery or an official web-only step blocks progress.
This skill is intentionally separate from 시외버스. KOBUS terminal codes, route structures, and checkout pages differ from Tmoney 시외버스. Use `intercity-bus-booking` for 시외버스/Tmoney flows.
## When to Use
- The user asks for `고속버스 예매`, `고속버스 시간표`, `고속버스 예약`, `프리미엄 고속버스`, or `우등 고속버스`.
- The route is clearly a KOBUS 고속버스 route or the user names KOBUS/코버스/고속버스통합예매.
- The user wants assisted reservation up to seat selection, temporary hold, or a payment-entry link.
Do **not** use this for:
- 시외버스/Tmoney routes — use `intercity-bus-booking`.
- Final card submission or payment without explicit, narrowly scoped confirmation.
- Blind browser automation before trying the official HTTP flow.
## Core Principles
- Use official KOBUS surfaces: `https://www.kobus.co.kr`.
- Keep a cookie jar and reuse referers. Stateless one-off POSTs are less reliable.
- Prefer desktop User-Agent and HTTP/1.1 during probing if the server behaves differently under HTTP/2.
- Default scope is assisted manual checkout: lookup, candidate presentation, seat-stage readiness, temporary seat hold, and official payment-entry page handoff.
- Do not submit card fields, agree to terms, or complete payment unless the user explicitly confirms that exact action.
## Known HTTP Flow
See `npx -y @nomadamas/k-skill@0 read express-bus-booking references/kobus-http-flow.md` for session-proven endpoint details and parameter examples.
### 1. Start a Session
Fetch either the main page or route/search page with cookies enabled:
```text
GET https://www.kobus.co.kr/main.do
GET https://www.kobus.co.kr/mrs/rotinf.do
```
Use a realistic desktop User-Agent and a cookie jar.
### 2. Resolve Route / Terminal Candidates
```text
POST /mrs/readRotLinInf.ajax
```
The response is JSON and can include `rotInfList`, `tfrInfList`, `len`, and `codeYn`. Prefer code-confirmed routes over display-name matching because terminal names are not always unique.
### 3. Query Timetable
```text
POST /mrs/alcnSrch.do
```
Typical fields include:
```text
deprCd=010
arvlCd=700
pathDvs=sngl
pathStep=1
deprDtm=YYYYMMDD
busClsCd=0
rtrpChc=1
timeLinkMin=00
timeLinkMax=23
```
Parse the returned HTML for schedule rows/cards and `fnSatsChc(...)` onclick arguments. Respect `mrsPsbYn=N` or any equivalent booking-disabled marker.
### 4. Enter Seat / Fare Stage
```text
POST /mrs/satschc.do
```
Send the original search form fields plus selected values from `fnSatsChc(...)`, commonly including:
```text
deprTime
alcnDeprTime
alcnDeprTrmlNo
alcnArvlTrmlNo
indVBusClsCd
cacmCd
dcDvsCd
prvtBbizEmpAcmtRt
chldSftySatsYn
dsprSatsYn
```
The response should include `form#satsChcFrm` and hidden fare/seat values such as `adltFee`, `rmnSatsNum`, `totSatsNum`, and terminal/time fields.
### 5. Temporary Seat Hold
```text
POST /mrs/setPcpy.ajax
```
Submit the `satsChcFrm` hidden fields plus selected seat/count fields. A successful response includes:
```text
MSG_CD=S0000
pcpyNoAll
satsNoAll
ESTM_AMT
DC_AMT
TISSU_AMT
```
Treat the hold as short-lived. If testing, if the user does not proceed, or if a new seat is chosen, release it explicitly.
### 6. Release Temporary Hold
```text
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
npx -y @nomadamas/k-skill@0 exec 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:
1. Create the temporary hold server-side via `setPcpy.ajax`.
2. Generate a short helper page that auto-submits a POST form to the official KOBUS checkout endpoint.
3. Send the helper link to the user.
4. The user completes card/payment fields manually on the official KOBUS page.
Desktop action:
```text
https://www.kobus.co.kr/mrs/stplcfmpym.do
```
Mobile-friendly action:
```text
https://www.kobus.co.kr/mrs/stplcfmpym.do?keep=/mrs/pay
```
The POST body should include the original seat form fields plus returned hold values such as `pcpyNoAll`, `satsNoAll`, `estmAmt`, `dcAmt`, `tissuAmt`, and `nonMbrsYn=Y` when using a non-member checkout flow.
## Mobile Redirect Caveat
KOBUS common JavaScript can redirect narrow/mobile screens to `/mblIdx.do` unless `location.href` contains a mobile-allowed path fragment such as `/mrs/pay`. In testing, posting to:
```text
/mrs/stplcfmpym.do?keep=/mrs/pay
```
preserved the same checkout POST body while making the final browser URL contain `/mrs/pay`, avoiding the client-side mobile-main redirect condition. Use this variant for Discord/mobile users, but still verify with the user because in-app browsers may add their own quirks.
If mobile still lands on the homepage, recommend opening the helper link in a normal external browser or desktop browser. Do not keep retrying holds indefinitely; cancel stale holds.
## Suggested Output Format
Keep candidate lists concise and actionable:
```text
고속버스 서울경부 → 부산 / 2026-05-09
1. 00:30 심야우등 / 천일고속 / 잔여 10석 / 성인 47,600원
2. 13:50 우등 / ...
```
When a hold/checkout helper is created, state that the next step opens the official KOBUS payment page and that payment remains manual.
For Discord/mobile, provide links as normal text links, not fenced code blocks, so the user can tap them directly.
## Common Pitfalls
1. **Mixing KOBUS and Tmoney codes.** KOBUS terminal codes are not Tmoney 시외버스 terminal codes.
2. **Assuming a cart exists.** KOBUS does not expose a shopping-cart style hold list in the tested web flow. The realistic UX is temporary seat hold plus checkout-entry handoff.
3. **Ignoring hold cleanup.** Always cancel test holds or abandoned holds with `cancPcpy.ajax`.
4. **Treating lookup success as payment permission.** Lookup, seat-stage entry, and temporary hold are not authorization to submit payment.
5. **Mobile homepage redirects.** Use the `?keep=/mrs/pay` helper action for mobile; otherwise KOBUS JS may send the user to `/mblIdx.do`.
6. **Browser automation too early.** Try direct HTTP first. Use browser tooling only to discover changed endpoints or verify final user-facing behavior.
## Verification Checklist
- [ ] Route/terminal codes were resolved from KOBUS, not guessed.
- [ ] Timetable was queried with cookies and a realistic User-Agent.
- [ ] Candidate output includes date, departure/arrival terminals, time, class/operator when available, fare, and remaining seats.
- [ ] Seat-stage response contains expected hidden fields before attempting a hold.
- [ ] Temporary hold success was confirmed with `MSG_CD=S0000` before sending a checkout helper link.
- [ ] Stale/test holds were cancelled with `cancPcpy.ajax`.
- [ ] Payment/card fields were left for the user unless they explicitly confirmed otherwise.
- [ ] Mobile helper links use the `/mrs/pay` marker variant when sent to mobile-heavy platforms.
references/DISCLAIMER.md
# DISCLAIMER — `express-bus-booking`
## 비제휴 및 상표 사용
이 스킬은 이 문서에서 언급하는 상표권자나 서비스 운영사의 공식 기능 또는 공식 지원 도구가 아닙니다. 해당 권리자·운영사와 공식 제휴, 후원, 승인, 인증 또는 협업한 사실이 전혀 없습니다. 제3자 상표와 서비스명은 스킬의 기능, 조회 대상 또는 호환 대상을 정확히 설명하기 위해 필요한 범위에서만 사용하며, 각 상표의 권리는 해당 권리자에게 있습니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 타인의 표장을 자기 상품의 출처표시가 아니라 상품의 기능 또는 적용 기종을 설명하기 위해 사용하고, 실제 표시 태양도 상표 사용으로 인식될 수 없는 경우에는 상표권 침해가 아니라고 판시했습니다. 다만 이는 제3자 상표 사용을 포괄적으로 허용한다는 뜻이 아니며, [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 실제 거래계의 인식에 따라 출처 혼동 여부를 개별적으로 판단해야 합니다.
## 공개정보에 대한 자동 접근
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)(야놀자 정보 수집 사건)은 서비스 제공자가 접근권한을 제한했는지를 보호조치·이용약관 등 객관적으로 드러난 사정을 종합해 판단해야 한다고 판시했습니다. 그 사건에서는 별도 보호조치 없이 일반 이용자가 접근할 수 있었고 이미 공개된 정보를 수집한 사정 등에 비추어 정보통신망 침입을 인정하지 않았습니다. 따라서 공개되어 누구나 얻을 수 있는 정보를 기계적으로 수집했다는 사정만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아닙니다.
그러나 위 판결은 크롤링을 일반적으로 허용한 판결이 아닙니다. 접근통제나 명시적 제한을 우회하는 행위, 데이터베이스의 전부·상당한 부분을 복제하거나 반복적·체계적으로 수집하여 같은 결과를 내는 행위는 별도로 판단됩니다. 특히 [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)는 데이터베이스제작자의 권리를 보호하며, 정보처리에 현실적 장애를 일으켜 영업을 방해하면 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)에 따른 책임이 문제될 수 있습니다.
## 필수 사용 제한
1. 공개정보 자동 수집 기능은 반드시 개인의 정보 조회용으로만 사용합니다.
2. 조직적·체계적·대량 크롤링, 별도 데이터베이스 구축, 재판매 또는 원본 콘텐츠 재배포에 사용하지 않습니다.
3. 특정 브랜드나 업체의 정상적인 영업·서비스를 방해하지 않도록 요청 횟수와 범위를 최소화하며, 공격적 polling이나 병렬 대량 호출을 하지 않습니다.
4. 로그인·유료벽·CAPTCHA·접근통제·rate limit·IP 차단을 우회하거나 차단 회피를 위해 계정·IP·헤더를 변경하지 않습니다. 접근 거부나 차단 신호가 확인되면 즉시 중단합니다.
5. 적용되는 이용약관, robots 지침, API 조건, 저작권·데이터베이스권·개인정보 보호 의무를 별도로 준수합니다.
이 문서는 일반적인 위험 경계를 알리기 위한 것이며, 개별 행위의 적법성을 보증하거나 법률 자문을 제공하지 않습니다.
references/kobus-http-flow.md
# KOBUS HTTP/API Probe Notes
Session-proven on 2026-05-08. Goal: avoid browser automation where possible.
## Base
```text
https://www.kobus.co.kr
```
Use a desktop User-Agent, HTTP/1.1 if needed, a cookie jar, and referers.
## Tested Flow
### Route / Terminal Candidates
```text
POST /mrs/readRotLinInf.ajax
```
Observed JSON keys:
```text
tfrLen
tfrInfList
len
codeYn
rotInfList
```
One probe returned about 1,208 route records in `rotInfList`.
### Timetable
```text
POST /mrs/alcnSrch.do
```
Example tested route/date:
```text
서울경부(010) -> 부산(700), 2026-05-09
```
Observed result:
```text
42 schedule links/cards
25 selectable seat snippets
first selectable: 00:30 / 천일고속 / 심야우등 / 10 seats
```
Typical POST fields:
```text
deprCd=010
arvlCd=700
pathDvs=sngl
pathStep=1
deprDtm=YYYYMMDD
busClsCd=0
rtrpChc=1
timeLinkMin=00
timeLinkMax=23
```
Parse `fnSatsChc(...)` onclick values for the next step. Example:
```text
fnSatsChc('20260509','003000','003000','010','700','3','07','0','Y','N','010','700','N','N','N','N')
```
### Seat / Fare Stage
```text
POST /mrs/satschc.do
```
Send the original `alcnSrchFrm` hidden fields plus selected values from `fnSatsChc(...)`, including values such as:
```text
deprTime=003000
alcnDeprTime=003000
alcnDeprTrmlNo=010
alcnArvlTrmlNo=700
indVBusClsCd=3
cacmCd=07
dcDvsCd=0
prvtBbizEmpAcmtRt=N
chldSftySatsYn=N
dsprSatsYn=N
```
Observed response contained `form#satsChcFrm` and hidden values:
```json
{
"deprTime": "003000",
"alcnDeprTrmlNo": "010",
"alcnArvlTrmlNo": "700",
"adltFee": "47600",
"rmnSatsNum": "10",
"totSatsNum": "28"
}
```
### Temporary Hold
```text
POST /mrs/setPcpy.ajax
```
Observed successful response markers:
```text
MSG_CD=S0000
pcpyNoAll
satsNoAll
ESTM_AMT
DC_AMT
TISSU_AMT
```
### Hold Cancellation
```text
POST /mrs/cancPcpy.ajax
```
Observed success marker:
```text
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
POST /mrs/stplcfmpym.do
```
The POST body must include the selected schedule/seat form values plus temporary hold identifiers and fare amounts. A helper page can auto-submit this form to the official KOBUS endpoint.
For mobile browsers, use:
```text
POST /mrs/stplcfmpym.do?keep=/mrs/pay
```
This preserves the POST body while placing `/mrs/pay` in `location.href`, which avoids a KOBUS client-side mobile redirect condition observed in the common JavaScript.
## Interpretation
- Login was not required for route lookup, timetable lookup, seat-selection-page entry, temporary hold, or checkout-entry page display in the tested flow.
- Page HTML can include login or `grecaptchaToken` forms, but these did not block the tested lookup/seat-stage path.
- Final payment should remain a manual, explicitly confirmed stage.
- KOBUS mobile behavior is less stable than desktop because common JavaScript can redirect narrow screens to the mobile main page.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `express-bus-booking`
이 스킬에서 `KOBUS` 명칭은 고속버스 시간표·좌석·예매 흐름의 **대상 공식 서비스**를 식별하기 위해 사용한다. k-skill의 출처나 운영주체를 KOBUS 운영기관으로 표시하려는 사용이 아니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 저작권, 데이터베이스권, 부정경쟁방지법, 약관·계약, 개인정보 또는 접근 방식의 적법성을 판단하지 않는다.
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
scripts/kobus_express_booking.py
#!/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"
# Only real calls carry quoted arguments. The KOBUS response also embeds an
# HTML-commented template `<!-- fnSatsChc(deprTime,alcnDeprTime,...) -->` whose
# bare identifiers must never be parsed as a schedule, so the regex requires an
# opening quote right after `(`.
FN_SATS_RE = re.compile(r"fnSatsChc\(\s*('.*?)\)", re.DOTALL)
ARG_RE = re.compile(r"'([^']*)'")
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
# seat_stage_fields() reads args[1..8], args[12] and args[13]; anything shorter
# is an incomplete template and is not a bookable schedule.
MIN_SATS_ARGS = 14
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 parse_schedules(body: str) -> list[Schedule]:
"""Parse bookable schedules from the KOBUS search response.
HTML comments and inline templates are stripped first so the commented
`fnSatsChc(...)` prototype never becomes a schedule, and any call whose
quoted argument list is shorter than MIN_SATS_ARGS is dropped as incomplete.
"""
visible = HTML_COMMENT_RE.sub(" ", body)
schedules: list[Schedule] = []
for m in FN_SATS_RE.finditer(visible):
args = ARG_RE.findall(m.group(1))
if len(args) < MIN_SATS_ARGS:
continue
context = strip_tags(visible[max(0, m.start() - 900) : m.start() + 900])
departure = args[1][:2] + ":" + args[1][2:4] if len(args[1]) >= 4 else None
schedules.append(
Schedule(
index=len(schedules) + 1,
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 schedules
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,
)
return body, parse_schedules(body)
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:
if not schedules:
raise SystemExit("no KOBUS schedules were parsed for this route; cannot hold a seat")
if not 1 <= args.select_index <= len(schedules):
raise SystemExit(
f"--select-index {args.select_index} is out of range: parsed {len(schedules)} schedule(s) "
f"(valid range 1-{len(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())
skill.json
{
"name": "express-bus-booking",
"description": "Search and assist Korean 고속버스/KOBUS bookings using official HTTP/API-first flows; use for 고속버스 예매, 시간표, 좌석 조회, 임시 선점, and official checkout-entry handoff. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
"profiles": [
"vault",
"browser",
"action:booking"
],
"frontmatter": "name: express-bus-booking\ndescription: Search and assist Korean 고속버스/KOBUS bookings using official HTTP/API-first flows; use for 고속버스 예매, 시간표, 좌석 조회, 임시 선점, and official checkout-entry handoff. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: express-bus-booking
description: Search and assist Korean 고속버스/KOBUS bookings using official HTTP/API-first flows; use for 고속버스 예매, 시간표, 좌석 조회, 임시 선점, and official checkout-entry handoff. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.
license: MIT
metadata:
category: travel
locale: ko-KR
phase: v1
---
# express-bus-booking
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct express-bus-booking
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files express-bus-booking
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/express-bus-booking/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read express-bus-booking references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.
tests/__init__.py
tests/test_kobus_express_booking.py
"""Regression tests for the KOBUS timetable parser (issue #698).
The upstream KOBUS search response embeds an HTML-commented `fnSatsChc(...)`
prototype next to each real `onclick="fnSatsChc('...')"` call. Parsing the
comment template produced phantom schedules with an empty `raw_args`, which
doubled the reported count and later crashed the seat-hold stage with
`IndexError: list index out of range`.
"""
import importlib.util
import io
import json
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "kobus_express_booking.py"
def load_helper():
spec = importlib.util.spec_from_file_location("kobus_express_booking", HELPER_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load helper from {HELPER_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules["kobus_express_booking"] = module
spec.loader.exec_module(module)
return module
helper = load_helper()
REAL_ARGS = ("'010000'", "'0100'", "'021'", "'500'", "'1'", "'0'", "'0'", "'0'",
"'0'", "'0'", "'Y'", "'N'", "'Y'", "'N'", "'0'", "'0'")
REAL_CALL = "onclick=\"fnSatsChc(" + ",".join(REAL_ARGS) + ")\""
SECOND_ARGS = ("'023000'", "'0230'", "'021'", "'500'", "'1'", "'0'", "'0'", "'0'",
"'0'", "'0'", "'Y'", "'N'", "'Y'", "'N'", "'0'", "'0'")
SECOND_CALL = "onclick=\"fnSatsChc(" + ",".join(SECOND_ARGS) + ")\""
BODY = f"""<html><body>
<script type="text/javascript">
function fnSatsChc(deprTime,alcnDeprTime,alcnDeprTrmlNo,alcnArvlTrmlNo,indVBusClsCd,cacmCd,dcDvsCd,prvtBbizEmpAcmtRt,chldSftySatsYn,dsprSatsYn){{ return true; }}
</script>
<!-- fnSatsChc(deprTime,alcnDeprTime,alcnDeprTrmlNo,alcnArvlTrmlNo,indVBusClsCd,cacmCd) -->
<tr><td>(주)금호고속</td><td>우등</td><td><a href="#" {REAL_CALL}>01:00</a></td></tr>
<tr><td>동부고속</td><td>고속</td><td><a href="#" {SECOND_CALL}>02:30</a></td></tr>
</body></html>
"""
COMMENTED_REAL_CALL = ("<!-- <a href=\"#\" onclick=\"fnSatsChc('030000','0300','021','500',"
"'1','0','0','0','0','0','Y','N','Y','N','0','0')\">03:00</a> -->")
INCOMPLETE_BODY = """<html><body>
<a href="#" onclick="fnSatsChc('030000','0300','021')">03:00</a>
</body></html>
"""
def run_main(argv, *, schedules_body):
buffer = io.StringIO()
with mock.patch.object(sys, "argv", ["kobus_express_booking.py", *argv]):
with mock.patch.object(helper, "opener", return_value=object()):
with mock.patch.object(
helper,
"search",
return_value=(schedules_body, helper.parse_schedules(schedules_body)),
):
with redirect_stdout(buffer):
exit_code = helper.main()
return exit_code, buffer.getvalue()
class ParseSchedulesTest(unittest.TestCase):
def test_counts_only_real_quoted_calls(self):
schedules = helper.parse_schedules(BODY)
self.assertEqual(len(schedules), 2)
def test_comment_template_is_not_parsed(self):
schedules = helper.parse_schedules(BODY)
self.assertTrue(all(schedule.raw_args for schedule in schedules))
self.assertTrue(all(schedule.departure_time for schedule in schedules))
def test_commented_real_looking_call_is_ignored(self):
schedules = helper.parse_schedules(BODY + COMMENTED_REAL_CALL)
self.assertEqual(len(schedules), 2)
self.assertNotIn("03:00", [schedule.departure_time for schedule in schedules])
def test_incomplete_argument_list_is_dropped(self):
self.assertEqual(helper.parse_schedules(INCOMPLETE_BODY), [])
def test_indices_are_contiguous_after_drops(self):
schedules = helper.parse_schedules(BODY + INCOMPLETE_BODY)
self.assertEqual([schedule.index for schedule in schedules], [1, 2])
def test_departure_time_and_context_are_resolved(self):
first, second = helper.parse_schedules(BODY)
self.assertEqual(first.departure_time, "01:00")
self.assertEqual(second.departure_time, "02:30")
self.assertIn("금호", first.company)
for schedule in (first, second):
self.assertIn(schedule.bus_class, {"심야우등", "우등", "프리미엄", "고속"})
def test_schedules_have_full_argument_payload(self):
for schedule in helper.parse_schedules(BODY):
self.assertGreaterEqual(len(schedule.raw_args), helper.MIN_SATS_ARGS)
class SelectIndexTest(unittest.TestCase):
def test_no_schedules_hold_raises_system_exit(self):
with self.assertRaises(SystemExit) as ctx:
run_main(
["--depart-code", "021", "--arrive-code", "500", "--date", "20261010",
"--hold-first-seat", "--select-index", "1"],
schedules_body="<html><body></body></html>",
)
self.assertIn("no KOBUS schedules", str(ctx.exception))
def test_out_of_range_select_index_raises_system_exit(self):
with self.assertRaises(SystemExit) as ctx:
run_main(
["--depart-code", "021", "--arrive-code", "500", "--date", "20261010",
"--hold-first-seat", "--select-index", "3"],
schedules_body=BODY,
)
message = str(ctx.exception)
self.assertIn("out of range", message)
self.assertIn("1-2", message)
def test_zero_select_index_raises_system_exit(self):
with self.assertRaises(SystemExit):
run_main(
["--depart-code", "021", "--arrive-code", "500", "--date", "20261010",
"--hold-seat", "1", "--select-index", "0"],
schedules_body=BODY,
)
def test_lookup_without_hold_does_not_validate_index(self):
exit_code, output = run_main(
["--depart-code", "021", "--arrive-code", "500", "--date", "20261010"],
schedules_body=BODY,
)
self.assertEqual(exit_code, 0)
payload = json.loads(output)
self.assertEqual(payload["count"], 2)
if __name__ == "__main__":
unittest.main()