Files
nomadamas__k-skill/intercity-bus-booking/scripts/intercity_bus_search.py
Jeffrey (Dongkyu) Kim 34127550fa Let intercity booking helper create temporary seat holds
Extend the Tmoney intercity helper from read-only timetable lookup to the browser-equivalent seat-stage and temporary hold flow, saving the official card-information page and cancel/back fields while still avoiding card submission.

Constraint: readPcpySats.do creates a live sats_Pcpy_Id hold, so abandoned test holds must be released with the official pcpyCanc=C back flow.

Rejected: Automating final payment | card submission is irreversible and remains a manual user action.

Confidence: high

Scope-risk: narrow

Directive: Treat holds as short-lived; hand off immediately and cancel abandoned holds.

Tested: python3 -m py_compile intercity-bus-booking/scripts/intercity_bus_search.py ~/.agents/skills/intercity-bus-booking/scripts/intercity_bus_search.py; live --hold-first-seat for 동서울→속초 20260520 produced sats_Pcpy_Id and card-info page; posted cancel/back fields and verified timetable remained 24/28; ./scripts/validate-skills.sh; node --test scripts/skill-docs.test.js; npm run lint

Not-tested: card info entry, final payment, mixed passenger hold payloads
2026-05-13 15:43:26 +09:00

382 lines
15 KiB
Python
Executable File

#!/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())