instruction.md
# Unified Railway Timetable Lookup
## What this skill does
`railway-timetable`은 코레일 공식 통합 시간표에서 KTX 계열 고속철도 운행 정보를 조회한다.
- KTX: 한국철도공사 공식 공개 XLSX 통합 계획 시간표
모든 경로는 조회 전용이다. 로그인·credential, 예약·예약대기·좌석 선점·결제·취소·자동 재조회는 실행하지 않는다.
## Commands
```bash
npx -y @nomadamas/k-skill@0 exec railway-timetable scripts/railway_timetable.py -- \
search --dep 서울 --arr 부산 --date 20260904 \
--time 0600 --time-limit 1200 --limit 5
```
```bash
npx -y @nomadamas/k-skill@0 exec railway-timetable scripts/railway_timetable.py -- source
```
## Output
- `count`, `trains[]`, `date`
- 각 열차의 `operator`, `train_no`, `train_type`, `dep`, `arr`, `dep_time`, `arr_time`
- 공식 `source`와 예약 진입 URL
결과는 코레일 통합 공개 계획 시간표이며 실제 운휴·지연, 예약 성공, 좌석 선점을 보장하지 않는다.
## Sources and failure modes
- 코레일: 공개 게시판의 XLSX를 읽는다. 게시판·파일 장애, 적용 시간표 없음, 형식 변경, 구간 결과 없음이 발생할 수 있다.
- CAPTCHA·인증·접근 차단은 우회하지 않고 코레일 공식 페이지를 안내한다.
## Hard boundaries
- 회원 로그인·credential 요청 금지
- 예약·결제·취소·정확한 좌석 선택·자동 polling 금지
- 내부 모바일 API, anti-bot, CAPTCHA, 인증 통제 우회 금지
references/AUTOMATION-LEGAL-STATEMENT.md
# 철도 통합 시간표 조회 관련 고지
- KTX 계열 코레일 통합 시간표는 공개 XLSX 계획 시간표만 읽는다.
- 예약·예약대기·좌석 선점·결제·취소·자동 재조회는 실행하지 않는다.
- CAPTCHA, 본인인증, anti-bot, 접근 제한을 우회하지 않는다.
references/DISCLAIMER.md
# DISCLAIMER — `railway-timetable`
이 스킬은 한국철도공사·코레일 또는 다른 철도 운영사의 공식 기능이나 공식 지원 도구가 아니며, 제휴·후원·승인·인증 또는 협업한 사실이 없습니다. 명칭과 상표는 조회 대상 서비스를 식별하기 위해서만 사용합니다.
공개 정보의 개인적 조회는 비조직적 용도로 제한한다. 체계적·대량 수집, 데이터베이스 구축, 접근통제·차단 우회, 서비스 운영을 방해하는 행위에 사용하지 않는다.
관련 기준으로 대법원 2005도1637 및 2021도1533 판결, 정보통신망법 제48조와 저작권법 제93조를 참고한다. 이 문서는 개별 사실관계에 대한 법률 자문이나 위법성 판단이 아니다.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `railway-timetable`
`KTX`, `코레일`, `Korail` 명칭은 조회 대상 열차·운영기관을 식별하기 위해서만 사용한다. k-skill이 해당 운영기관의 공식 제품이거나 승인받았다는 의미가 아니다.
scripts/ktx_backend.py
#!/usr/bin/env -S uv run --locked --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["openpyxl==3.1.5"]
# ///
"""Read official Korail timetable files without login or reservation actions."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from datetime import date as calendar_date
from datetime import time
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from ktx_timetable import parse_timetable_rows
from openpyxl import load_workbook
BOARD_URL = (
"https://www.korail.com/com/userBoard.do"
"?schBcid=ticketTable&mode=list&page=1&schStr=KTX&bdCode=&Device=BH&Version=999999999"
)
FILE_BASE_URL = "https://www.korail.com/file/cubedata/COMMON/"
BOOKING_URL = "https://www.korail.com/ticket/search"
USER_AGENT = "k-skill/ktx-readonly (+https://github.com/NomaDamas/k-skill)"
KTX_TITLE = re.compile(
r"(?:KTX|경부선|호남선|전라선|경전선|동해선|강릉선|중앙선|중부내륙선).*(?:시간표|시각표)"
)
EFFECTIVE_DATE = re.compile(r"(20\d{2})[.\s년]+(\d{1,2})[.\s월]+(\d{1,2})")
@dataclass(frozen=True)
class TimetableSource:
title: str
published_at: str
download_url: str
source_url: str = BOARD_URL
def fetch_json(url: str, timeout: float = 20.0) -> dict[str, Any]:
request = Request(url, headers={"User-Agent": USER_AGENT})
try:
with urlopen(request, timeout=timeout) as response:
return json.load(response)
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Korail official timetable index unavailable: {exc}") from exc
def download_bytes(url: str, timeout: float = 30.0) -> bytes:
request = Request(url, headers={"User-Agent": USER_AGENT, "Referer": BOOKING_URL})
try:
with urlopen(request, timeout=timeout) as response:
return response.read()
except (HTTPError, URLError, TimeoutError) as exc:
raise RuntimeError(f"Korail official timetable file unavailable: {exc}") from exc
def timetable_candidates(payload: dict[str, Any]) -> list[TimetableSource]:
candidates: list[TimetableSource] = []
for item in payload.get("boardList", []):
title = str(item.get("bdTitle", "")).strip()
file_ids = item.get("fileId") or []
if not KTX_TITLE.search(title) or not file_ids:
continue
file_id = str(file_ids[0]).lstrip("/")
if not file_id.lower().endswith((".xlsx", ".xlsm")):
continue
candidates.append(
TimetableSource(
title=title,
published_at=str(item.get("regdt", "")),
download_url=FILE_BASE_URL + file_id,
)
)
return candidates
def choose_latest_timetable(payload: dict[str, Any]) -> TimetableSource:
candidates = timetable_candidates(payload)
if not candidates:
raise RuntimeError("Korail published no readable KTX timetable attachment")
return max(candidates, key=lambda source: (source.published_at, source.title))
def effective_date(source: TimetableSource) -> str:
match = EFFECTIVE_DATE.search(source.title)
if match is None:
return source.published_at.replace("-", "")
year, month, day = match.groups()
return f"{year}{int(month):02d}{int(day):02d}"
def choose_timetable_for_date(payload: dict[str, Any], date: str) -> TimetableSource:
candidates = timetable_candidates(payload)
applicable = [source for source in candidates if effective_date(source) <= date]
if not applicable:
raise RuntimeError(f"Korail published no KTX timetable applicable to {date}")
return max(applicable, key=lambda source: (effective_date(source), source.published_at))
def load_workbook_bytes(content: bytes):
try:
return load_workbook(BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise RuntimeError(f"Korail timetable workbook could not be parsed: {exc}") from exc
def search_public_timetable(
*,
dep: str,
arr: str,
date: str,
earliest: str,
latest: str,
limit: int,
) -> dict[str, Any]:
validate_date(date)
start = validate_time(earliest)
end = validate_time(latest)
if start > end:
raise ValueError("--time must not be later than --time-limit")
source = choose_timetable_for_date(fetch_json(BOARD_URL), date)
workbook = load_workbook_bytes(download_bytes(source.download_url))
requested_date = calendar_date(int(date[:4]), int(date[4:6]), int(date[6:8]))
trains: list[dict[str, str]] = []
route_found = False
for sheet_name in workbook.sheetnames:
worksheet = workbook[sheet_name]
sheet_trains, sheet_route_found = parse_timetable_rows(
worksheet.iter_rows(values_only=True),
dep=dep,
arr=arr,
requested_date=requested_date,
earliest=start,
latest=end,
)
trains.extend(sheet_trains)
route_found = route_found or sheet_route_found
if not route_found:
raise RuntimeError(f"Korail timetable station pair not found: {dep} -> {arr}")
unique = {(train["train_no"], train["dep_time"], train["arr_time"]): train for train in trains}
ordered = sorted(unique.values(), key=lambda train: (train["dep_time"], train["train_no"]))[:limit]
return {
"count": len(ordered),
"trains": ordered,
"date": date,
"schedule_note": "공개 운행계획 기준이며 실시간 잔여석·운휴·지연 정보가 아닙니다.",
"source": {"operator": "한국철도공사", **asdict(source)},
"booking_url": BOOKING_URL,
}
def validate_date(value: str) -> str:
if not re.fullmatch(r"\d{8}", value):
raise ValueError("date must use YYYYMMDD")
try:
calendar_date(int(value[:4]), int(value[4:6]), int(value[6:8]))
except ValueError as exc:
raise ValueError("date must use a valid YYYYMMDD value") from exc
return value
def validate_time(value: str) -> str:
if not re.fullmatch(r"\d{4}", value):
raise ValueError("time must use HHMM")
try:
time.fromisoformat(f"{value[:2]}:{value[2:]}")
except ValueError as exc:
raise ValueError("time must use a valid HHMM value") from exc
return f"{value[:2]}:{value[2:]}"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Korail KTX official timetable lookup (read-only)")
commands = parser.add_subparsers(dest="command", required=True)
search = commands.add_parser("search", help="search a published KTX operating timetable")
search.add_argument("--dep", required=True)
search.add_argument("--arr", required=True)
search.add_argument("--date", required=True, help="YYYYMMDD")
search.add_argument("--time", default="0000", help="earliest departure, HHMM")
search.add_argument("--time-limit", default="2359", help="latest departure, HHMM")
search.add_argument("--limit", type=int, default=10)
commands.add_parser("source", help="show the current official timetable source")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "source":
print(json.dumps(asdict(choose_latest_timetable(fetch_json(BOARD_URL))), ensure_ascii=False, indent=2))
return 0
if args.limit < 1 or args.limit > 50:
raise ValueError("--limit must be between 1 and 50")
result = search_public_timetable(
dep=args.dep,
arr=args.arr,
date=args.date,
earliest=args.time,
latest=args.time_limit,
limit=args.limit,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (RuntimeError, ValueError) as exc:
parser.error(str(exc))
return 2
if __name__ == "__main__":
sys.exit(main())
scripts/ktx_backend.py.lock
version = 1
revision = 1
requires-python = ">=3.11"
[manifest]
requirements = [{ name = "openpyxl", specifier = "==3.1.5" }]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
]
scripts/ktx_timetable.py
"""Parse direction-aware KTX timetable sections from official workbooks."""
from __future__ import annotations
import re
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import date as calendar_date
from datetime import datetime, time
TIME_VALUE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
TRAIN_NUMBER = re.compile(r"^\d{1,4}$")
WEEKDAY_NAMES = "월화수목금토일"
@dataclass(frozen=True, slots=True)
class TimetableSection:
train_index: int
type_index: int
dep_index: int
arr_index: int
note_index: int
def normalize_station(value: object) -> str:
return re.sub(r"\s+", "", str(value or "")).replace("역", "")
def normalize_time(value: object) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.strftime("%H:%M")
if isinstance(value, time):
return None if value == time.min else value.strftime("%H:%M")
text = str(value).strip()
if TIME_VALUE.fullmatch(text):
return text
if re.fullmatch(r"\d{3,4}", text):
return f"{int(text) // 100:02d}:{int(text) % 100:02d}"
return None
def header_sections(values: list[str], dep: str, arr: str) -> list[TimetableSection]:
starts = [index for index, value in enumerate(values) if value == "열차번호"]
sections: list[TimetableSection] = []
for position, start in enumerate(starts):
end = starts[position + 1] if position + 1 < len(starts) else len(values)
dep_indices = [index for index in range(start, end) if values[index] == dep]
arr_indices = [index for index in range(start, end) if values[index] == arr]
if not dep_indices or not arr_indices:
continue
dep_index = dep_indices[0]
arr_index = arr_indices[0]
if dep_index >= arr_index:
continue
type_indices = [index for index in range(start, end) if values[index] == "편성"]
note_indices = [index for index in range(start, end) if values[index] == "비고"]
sections.append(
TimetableSection(
train_index=start,
type_index=type_indices[0] if type_indices else -1,
dep_index=dep_index,
arr_index=arr_index,
note_index=note_indices[0] if note_indices else -1,
)
)
return sections
def runs_on_date(value: object, requested_date: calendar_date) -> bool:
text = re.sub(r"\s+", "", str(value or ""))
if not text or "매일" in text:
return True
if text == "평일":
return requested_date.weekday() < 5
compact = re.sub(r"[,./·~\-]", "", text)
if re.fullmatch(r"[월화수목금토일]+", compact):
return WEEKDAY_NAMES[requested_date.weekday()] in compact
return True
def parse_timetable_rows(
rows: Iterable[Iterable[object]],
*,
dep: str,
arr: str,
requested_date: calendar_date,
earliest: str,
latest: str,
) -> tuple[list[dict[str, str]], bool]:
dep_name = normalize_station(dep)
arr_name = normalize_station(arr)
sections: list[TimetableSection] = []
route_found = False
results: list[dict[str, str]] = []
for raw_row in rows:
row = list(raw_row)
normalized = [normalize_station(value) for value in row]
if "열차번호" in normalized:
sections = header_sections(normalized, dep_name, arr_name)
route_found = route_found or bool(sections)
continue
for section in sections:
indices = (
section.train_index,
section.type_index,
section.dep_index,
section.arr_index,
section.note_index,
)
if max(indices) >= len(row):
continue
train_no = str(row[section.train_index] or "").strip()
if not TRAIN_NUMBER.fullmatch(train_no):
continue
train_type = (
str(row[section.type_index] or "").strip().upper()
if section.type_index >= 0
else "KTX"
)
if section.note_index >= 0 and not runs_on_date(row[section.note_index], requested_date):
continue
dep_time = normalize_time(row[section.dep_index])
arr_time = normalize_time(row[section.arr_index])
if dep_time is None or arr_time is None or not earliest <= dep_time <= latest:
continue
results.append(
{
"train_no": train_no,
"train_type": train_type,
"dep": dep,
"arr": arr,
"dep_time": dep_time,
"arr_time": arr_time,
}
)
return results, route_found
scripts/railway_timetable.py
#!/usr/bin/env -S uv run --locked --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["openpyxl==3.1.5"]
# ///
"""Read-only Korail integrated railway timetable lookup."""
from __future__ import annotations
import argparse
import json
import sys
from typing import Any
import ktx_backend
def source_info() -> dict[str, Any]:
return {
"mode": "plan",
"transport": "Korail integrated timetable",
"operator": "한국철도공사",
"endpoint": ktx_backend.BOARD_URL,
"authentication": "none",
"mutation": "none; timetable lookup only",
"booking_url": ktx_backend.BOOKING_URL,
}
def search(
*,
dep: str,
arr: str,
date: str,
earliest: str,
latest: str,
limit: int,
) -> dict[str, Any]:
result = ktx_backend.search_public_timetable(
dep=dep,
arr=arr,
date=date,
earliest=earliest,
latest=latest,
limit=limit,
)
return {
**result,
"schedule_note": "코레일 통합 공개 운행계획 기준이며 실시간 운휴·지연·잔여석 정보가 아닙니다.",
"source": {**result["source"], "transport": "Korail integrated timetable"},
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Korail integrated read-only railway timetable lookup",
)
commands = parser.add_subparsers(dest="command", required=True)
search_parser = commands.add_parser("search", help="search railway timetables")
search_parser.add_argument("--dep", required=True)
search_parser.add_argument("--arr", required=True)
search_parser.add_argument("--date", required=True, help="YYYYMMDD")
search_parser.add_argument("--time", default="0000", help="earliest departure, HHMM")
search_parser.add_argument("--time-limit", default="2359", help="latest departure, HHMM")
search_parser.add_argument("--limit", type=int, default=10)
commands.add_parser("source", help="show read-only timetable sources")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "source":
print(json.dumps(source_info(), ensure_ascii=False, indent=2))
return 0
if args.limit < 1 or args.limit > 50:
raise ValueError("--limit must be between 1 and 50")
result = search(
dep=args.dep,
arr=args.arr,
date=args.date,
earliest=args.time,
latest=args.time_limit,
limit=args.limit,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (RuntimeError, ValueError) as exc:
parser.error(str(exc))
return 2
if __name__ == "__main__":
sys.exit(main())
scripts/railway_timetable.py.lock
version = 1
revision = 1
requires-python = ">=3.11"
[manifest]
requirements = [{ name = "openpyxl", specifier = "==3.1.5" }]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
]
[[package]]
name = "openpyxl"
version = "3.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "et-xmlfile" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
]
skill.json
{
"name": "railway-timetable",
"description": "Read-only Korail integrated railway timetable lookup covering KTX-family trains. No reservation, payment, cancellation, or seat holding.",
"profiles": [
"lookup"
],
"frontmatter": "name: railway-timetable\ndescription: Read-only Korail integrated railway timetable lookup covering KTX-family trains. No reservation, payment, cancellation, or seat holding.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: railway-timetable
description: Read-only Korail integrated railway timetable lookup covering KTX-family trains. No reservation, payment, cancellation, or seat holding.
license: MIT
metadata:
category: travel
locale: ko-KR
phase: v1
---
# railway-timetable
<!-- 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 railway-timetable
```
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 railway-timetable
```
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/railway-timetable/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 railway-timetable 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.