Make intercity timetable lookup follow Tmoney form contract

Add a read-only timetable helper that starts a cookie-backed Tmoney session, submits the hidden browser fields required by readAlcnList.do, and parses readSasFeeInf schedule rows into JSON.

Constraint: Tmoney returns a generic errorCont page unless bef_Aft_Dvs and req_Rec_Num are posted with the timetable form.

Rejected: Browser automation-first lookup | official HTTP flow works when the browser-submitted hidden fields are included.

Confidence: high

Scope-risk: narrow

Directive: Do not automate final card submission or payment from this skill without explicit user confirmation.

Tested: python3 -m py_compile intercity-bus-booking/scripts/intercity_bus_search.py; python3 intercity-bus-booking/scripts/intercity_bus_search.py --depart-code 0511601 --arrive-code 2482701 --depart-name 동서울 --arrive-name 속초 --date 20260520 --limit 1; rsync to ~/.claude/skills and ~/.agents/skills; ./scripts/validate-skills.sh; node --test scripts/skill-docs.test.js; npm run lint

Not-tested: temporary seat hold, cancellation, card entry, and payment flows
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-13 14:49:22 +09:00
commit b4aae5b295
5 changed files with 272 additions and 8 deletions

View file

@ -24,15 +24,29 @@
## 기본 흐름
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으로 출력한다. 임시 좌석 선점이나 결제 단계는 수행하지 않는다.
## 주의할 점
- 결제 자동화는 포함하지 않는다. 공식 페이지의 결제 직전 단계까지 보조하는 assisted checkout 흐름이다.
- 티머니 시외버스 터미널 코드는 KOBUS 고속버스 코드와 다르므로 혼용하지 않는다.
- 일부 표면은 `txbus` 계열 URL과 연결될 수 있지만, 검증된 기본 URL은 `intercitybus.tmoney.co.kr` 이다.
- stateless POST보다 쿠키와 referer를 유지하는 흐름이 안정적이다.
- `bef_Aft_Dvs` 또는 `req_Rec_Num`을 누락하면 실제 배차가 있어도 `errorCont`가 포함된 일반 오류 페이지가 반환될 수 있다.

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,21 @@ 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. It intentionally does not create temporary holds or submit payment data.
## Checkout-Entry Link Helper
A helper-served HTML page can auto-submit a POST form directly to:
@ -145,13 +164,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
@ -113,5 +118,6 @@ 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.
- Terminal codes are Tmoney-specific and must not be mixed with KOBUS codes.

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Search Tmoney intercity-bus timetables through the official read-only flow.
This helper intentionally stops at timetable parsing. It does not create seat holds,
submit card data, or perform payment.
"""
from __future__ import annotations
import argparse
import html
import http.cookiejar
import json
import re
import ssl
import sys
import urllib.parse
import urllib.request
from dataclasses import dataclass, asdict
from typing import Iterable
BASE_URL = "https://intercitybus.tmoney.co.kr"
ENTRY_PATH = "/otck/trmlInfEnty.do"
TIMETABLE_PATH = "/otck/readAlcnList.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"'((?:\\'|[^'])*)'")
@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]
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 _open(opener: urllib.request.OpenerDirector, request: urllib.request.Request, timeout: int) -> str:
# urllib opener.open does not accept context; HTTPS context must be installed in handler.
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 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,
) -> tuple[str, list[Schedule]]:
opener = build_opener()
entry_req = urllib.request.Request(
f"{BASE_URL}{ENTRY_PATH}",
headers={"User-Agent": DEFAULT_UA},
method="GET",
)
_open(opener, entry_req, 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",
}
req = _post_opener_request(f"{BASE_URL}{TIMETABLE_PATH}", fields)
body = _open(opener, req, timeout)
return body, parse_schedules(body)
def _post_opener_request(url: str, data: dict[str, str]) -> urllib.request.Request:
encoded = urllib.parse.urlencode(data).encode("utf-8")
return urllib.request.Request(
url,
data=encoded,
headers={
"User-Agent": DEFAULT_UA,
"Referer": f"{BASE_URL}{ENTRY_PATH}",
"Content-Type": "application/x-www-form-urlencoded",
},
method="POST",
)
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 main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Search Tmoney intercity-bus timetable")
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)
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")
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 = {
"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 0 if schedules else 2
if __name__ == "__main__":
sys.exit(main())

View file

@ -10,7 +10,7 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/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 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",
"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 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 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 && 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",