instruction.md
# Ticket Availability
## What this skill does
YES24 (`ticket.yes24.com`) 와 인터파크 (`tickets.interpark.com`) 의 공개 BFF JSON / Ajax endpoint 를 단일 HTTP 요청으로 호출해 공연 일정과 등급별 잔여석 수를 정규화한다.
- 공연 URL 또는 `platform:id` 표기로 입력을 받는다.
- 일정 (날짜·시간·회차) 조회.
- 등급별 잔여석 수 조회 (등급명, 잔여수, YES24의 경우 노출가).
- 좌석맵 / 좌석 선택 / 예매 / 결제 / 로그인 세션 접근은 하지 않는다.
- CloakBrowser, Playwright, fingerprint spoofing, CAPTCHA 우회를 사용하지 않는다 (`httpx` only).
## When to use
- "오늘 인터파크 ○○ 공연 잔여석 있어?"
- "YES24 콘서트 ID 58026 일정 알려줘"
- "이 공연 R석 몇 자리 남았어?"
- "공연 URL 줄게, 회차별 잔여석 확인해줘"
## When not to use
- 예매·결제·취소·환불 처리 — **공연법 §4조의2 (2023.9 시행) 매크로 입장권 부정구매·판매 금지** 대상이며 이 스킬은 의도적으로 예매를 지원하지 않는다.
- 좌석 선택, 좌석맵 시각화, 특정 좌석 번호 확인 — 잔여 *수* 만 노출한다.
- 회원 등급별 우선 예매, 쿠폰가, 카드사 할인가 — 공개 endpoint 만 사용한다.
- 차단 우회, CAPTCHA 우회, headless 감지 우회 — `httpx` 한 호출로 안 되면 실패 모드로 처리하고 종료한다.
## Required inputs
공연 URL 또는 `platform:id` 표기가 없으면 먼저 물어본다.
권장 질문:
> 확인하실 공연의 YES24 또는 인터파크 URL을 알려주세요.
> 예: `https://tickets.interpark.com/goods/26000541`
> `https://ticket.yes24.com/Perf/58026`
## Prerequisites
- Python 3.9+
- `httpx` (표준 패키지)
설치:
```bash
pip install httpx
```
## Workflow
### 1. URL 파싱
| 입력 | 매칭 |
|---|---|
| `https://tickets.interpark.com/goods/<goods_code>` | platform=interpark |
| `https://ticket.yes24.com/Perf/<perf_id>` | platform=yes24 |
| `https://ticket.yes24.com/New/Perf/Detail/View/<perf_id>` | platform=yes24 |
| `yes24:<id>` / `interpark:<id>` | shorthand |
### 2. 일정 조회 (`schedule`)
```bash
npx -y @nomadamas/k-skill@0 exec ticket-availability scripts/ticket_availability.py -- schedule "https://tickets.interpark.com/goods/26000541"
```
응답 — Interpark:
```json
{
"platform": "interpark",
"id": "26000541",
"schedule": [
{"date": "2026-05-13", "time": "14:30", "play_seq": "055"},
{"date": "2026-05-14", "time": "19:30", "play_seq": "057"}
]
}
```
응답 — YES24:
```json
{
"platform": "yes24",
"id": "58026",
"schedule": [
{"date": "2026-05-16", "time_label": "1회", "id_time": "1432397"}
]
}
```
YES24 는 기본 3주 윈도우. 6개월 전체는 `--all-dates` 추가.
### 3. 잔여석 조회 (`seats`)
```bash
npx -y @nomadamas/k-skill@0 exec ticket-availability scripts/ticket_availability.py -- seats "interpark:26000541"
```
응답:
```json
{
"platform": "interpark",
"id": "26000541",
"seats": {
"2026-05-13|14:30|055": {
"date": "2026-05-13", "time": "14:30", "play_seq": "055",
"seats": [
{"grade": "VIP석", "remain": 150},
{"grade": "R석", "remain": 36},
{"grade": "S석", "remain": 82},
{"grade": "A석", "remain": 71}
]
}
}
}
```
YES24 응답은 등급별 `price` (노출가) 도 포함:
```json
{"grade": "전석", "price": "110,000원", "remain": 2}
```
### 4. 헬스체크 (`health`)
```bash
npx -y @nomadamas/k-skill@0 exec ticket-availability scripts/ticket_availability.py -- health
```
응답:
```json
{"yes24": {"status": 200, "ok": true}, "interpark": {"status": 200, "ok": true}}
```
## Output format
기본 출력은 들여쓰기 JSON. 파이프/스크립트용은 `--compact` 추가 (한 줄 JSON).
## Endpoints used
이 스킬이 호출하는 공개 endpoint 만:
| Platform | Method | URL |
|---|---|---|
| YES24 | POST | `https://ticket.yes24.com/New/Perf/Sale/Ajax/axPerfDay.aspx` |
| YES24 | POST | `https://ticket.yes24.com/NEw/Perf/Detail/Ajax/axPerfPlayTime.aspx` |
| YES24 | POST | `https://ticket.yes24.com/New/Perf/Detail/Ajax/axPerfRemainSeat.aspx` |
| Interpark | GET | `https://api-ticketfront.interpark.com/v1/goods/<id>/playSeq` |
| Interpark | GET | `https://api-ticketfront.interpark.com/v1/goods/<id>/playSeq/PlaySeq/<seq>/REMAINSEAT` |
전부 비로그인 / 무인증. 헤더는 `User-Agent` + `Referer` + JSON `Accept` 만.
## Failure modes
- **YES24 `schedule` 결과 빈 배열**: 공연 ID 가 유효하지만 향후 3주(또는 6개월) 내 일정이 없음. ID 자체가 잘못된 경우와 구분되지 않으므로, 사용자에게 `--all-dates` 또는 다른 ID 확인을 안내한다.
- **Interpark `data: []`**: goods_code 가 지나갔거나 아직 오픈 전 / 비공개. 다른 ID 확인을 안내한다.
- **HTTP 4xx/5xx**: 차단/일시 장애. 우회 시도하지 않고 `http error` 출력 후 종료.
- **JSON 스키마 변경**: YES24 axPerfRemainSeat 는 HTML 응답을 정규식으로 파싱 — 사이트 갱신 시 영향 가능. `remain` 0 으로 잘못 보고될 수 있어 사용자에게 "조회 시각 기준" 이라고 표기.
- **공연 매진**: API 는 `remain: 0` 반환. 매진 표시.
## Response style
- 잔여석은 "조회 시각 기준" 으로 표현한다 (실시간 변동).
- "매크로", "선점", "오픈런", "자동 예매" 표현 금지 — 이 스킬은 조회 전용.
- 잔여석 수치 + 등급명 만 인용. 좌석 번호 / 좌석 위치는 노출하지 않는다.
- "지금 사라" 같은 행위 유도 금지 — 사용자가 직접 페이지에서 결제.
## Notes
- 본 스킬은 의도적으로 **예매 / 결제 / 좌석선택 / 로그인 자동화** 를 포함하지 않는다. 매크로를 통한 입장권 부정구매·판매는 공연법 §4조의2 (2023.9.22 시행) 에 의해 형사처벌 대상.
- 시크릿 / 키 / 로그인 세션 일체 사용하지 않는다.
- Rate limit: `seats` 명령은 회차별 순차 호출 — Interpark 0.3s, YES24 0.4s 간격. 100회차 짜리 공연이면 약 30s ~ 40s 소요. 짧은 모니터링 루프에 넣지 말 것.
## Done when
- 공연 URL 또는 `platform:id` 가 확인되었다.
- 일정 또는 잔여석 결과 JSON 을 반환하거나, 빈 결과 사유를 설명했다.
- 예매 / 결제 / 좌석 선택 기능을 자동화하지 않았다.
- 조회 시각 기준임을 안내했다.
scripts/ticket_availability.py
#!/usr/bin/env python3
"""ticket-availability — YES24 / 인터파크 공연 일정 + 잔여석 조회 CLI.
조회 전용. 예매·결제·로그인 자동화 없음.
공연법 §4조의2 (매크로 입장권 부정구매·판매 금지) 비적용.
Usage:
ticket-availability schedule <url>
ticket-availability seats <url> [--all-dates]
ticket-availability health
Supported URLs:
YES24: https://ticket.yes24.com/Perf/<perf_id>
https://ticket.yes24.com/New/Perf/Detail/View/<perf_id>
yes24:<perf_id>
인터파크: https://tickets.interpark.com/goods/<goods_code>
interpark:<goods_code>
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime
from typing import Any
try:
import httpx
except ModuleNotFoundError: # pragma: no cover - depends on user environment
httpx = None
class MissingHttpxError(RuntimeError):
"""Raised when the optional httpx runtime dependency is unavailable."""
def _require_httpx():
if httpx is None:
raise MissingHttpxError(
"Python package 'httpx' is required. Install it with: python3 -m pip install httpx"
)
return httpx
HTTPX_HTTP_ERROR = (
getattr(httpx, "HTTPError", MissingHttpxError) if httpx else MissingHttpxError
)
# ── URL Parsing ───────────────────────────────────────────────────────────────
def parse_url(url: str) -> tuple[str, str]:
"""Return (platform, id). Accepts full URL or `platform:id` shorthand."""
if url.startswith("yes24:"):
return "yes24", url[6:]
if url.startswith("interpark:"):
return "interpark", url[10:]
m = re.search(
r"yes24\.com/(?:[Nn]ew/)?[Pp]erf/(?:[Dd]etail/)?(?:[Vv]iew/)?(\d+)", url
)
if m:
return "yes24", m.group(1)
m = re.search(r"interpark\.com/goods/(\d+)", url, re.IGNORECASE)
if m:
return "interpark", m.group(1)
if re.fullmatch(r"\d+", url):
raise ValueError(
f"플랫폼을 명시하세요: yes24:{url} 또는 interpark:{url}"
)
raise ValueError(f"URL을 인식할 수 없습니다: {url}")
def _fmt_date(d: str) -> str:
if d and len(d) == 8 and d.isdigit():
return f"{d[:4]}-{d[4:6]}-{d[6:]}"
return d
def _fmt_time(t: str) -> str:
if t and len(t) == 4 and t.isdigit():
return f"{t[:2]}:{t[2:]}"
return t
# ── HTTP Setup ────────────────────────────────────────────────────────────────
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"Chrome/124.0.0.0 Safari/537.36"
)
HEADERS_YES24 = {
"User-Agent": UA,
"Referer": "https://ticket.yes24.com/",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
}
HEADERS_INTERPARK = {
"User-Agent": UA,
"Referer": "https://tickets.interpark.com/",
"Accept": "application/json",
}
YES24_BASE = "https://ticket.yes24.com"
INTERPARK_BASE = "https://api-ticketfront.interpark.com"
# ── YES24 Client ──────────────────────────────────────────────────────────────
class Yes24Client:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_YES24, timeout=20, follow_redirects=True
)
def _dates(self, perf_id: str, month_count: int) -> list[str]:
now = datetime.now()
months: list[str] = []
for delta in range(month_count):
month = now.month + delta
year = now.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
months.append(f"{year:04d}-{month:02d}")
dates: list[str] = []
cutoff = now.strftime("%Y%m%d")
for month_str in months:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx",
data={
"pGetMode": "days",
"pIdPerf": perf_id,
"pPerfMonth": month_str,
"pIdCode": "",
"pIsMania": "0",
},
)
r.raise_for_status()
text = r.text.strip().strip(",")
if not text:
continue
for raw in text.split(","):
d = raw.strip()
if not d:
continue
normalized = d.replace("-", "")
if normalized >= cutoff:
dates.append(normalized)
return sorted(set(dates))
def get_dates(self, perf_id: str) -> list[str]:
"""Available dates within ~3 weeks (fast)."""
return self._dates(perf_id, month_count=3)
def get_all_dates(self, perf_id: str) -> list[str]:
"""Available dates across 6 months (full schedule)."""
return self._dates(perf_id, month_count=6)
def get_slots(self, perf_id: str, perf_day: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/NEw/Perf/Detail/Ajax/axPerfPlayTime.aspx",
data={"IdPerf": perf_id, "PerfDay": perf_day},
)
r.raise_for_status()
html = r.text
slots: list[dict] = []
seen: set[str] = set()
for m in re.finditer(r"idTime='(\d+)'", html):
id_time = m.group(1)
if id_time in seen:
continue
seen.add(id_time)
ctx_start = max(0, m.start() - 200)
ctx = html[ctx_start : m.end() + 200]
time_m = re.search(r"(\d{1,2}:\d{2}|\d[회]|[12]\d{3}회)", ctx)
label = time_m.group(0) if time_m else id_time
slots.append({"idTime": id_time, "label": label})
return slots
def get_seats(self, id_time: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Detail/Ajax/axPerfRemainSeat.aspx",
data={"Type": "calendar", "IdTime": id_time, "IdLock": "0"},
)
r.raise_for_status()
html = r.text
seats: list[dict] = []
for m in re.finditer(
r"<dt>([^<]+)</dt>\s*<dd>([^<]*)<span[^>]*>\(잔여:(\d+)석\)</span>",
html,
):
seats.append(
{
"grade": m.group(1).strip(),
"price": m.group(2).strip().rstrip(",").strip(),
"remain": int(m.group(3)),
}
)
if not seats:
for i, m in enumerate(re.finditer(r"\(잔여:(\d+)석\)", html)):
seats.append({"grade": f"좌석{i+1}", "price": "", "remain": int(m.group(1))})
return seats
def schedule(self, perf_id: str, all_dates: bool) -> list[dict]:
"""Schedule = dates × slots flattened. No seat lookup."""
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
out: list[dict] = []
for d in dates:
for slot in self.get_slots(perf_id, d):
out.append(
{
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
}
)
return out
def all_seats(self, perf_id: str, all_dates: bool) -> dict:
result: dict = {}
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
for d in dates:
for slot in self.get_slots(perf_id, d):
seats = self.get_seats(slot["idTime"])
key = f"{_fmt_date(d)}|{slot['label']}"
result[key] = {
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
"seats": seats,
}
time.sleep(0.4)
return result
# ── Interpark Client ──────────────────────────────────────────────────────────
class InterparkClient:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_INTERPARK, timeout=20, follow_redirects=True
)
def get_schedule(self, goods_code: str) -> list[dict]:
now = datetime.now()
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq",
params={
"goodsCode": goods_code,
"isBookableDate": "true",
"page": "1",
"pageSize": "200",
"startDate": now.strftime("%Y%m%d"),
"endDate": f"{now.year + 1}{now.month:02d}{now.day:02d}",
},
)
r.raise_for_status()
data = r.json()
if isinstance(data, list):
return data
return data.get("response", {}).get("data") or data.get("data") or []
def get_seats(self, goods_code: str, play_seq: str) -> list[dict]:
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq/PlaySeq/{play_seq}/REMAINSEAT"
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict):
return (
data.get("remainSeat")
or (data.get("data") or {}).get("remainSeat")
or data.get("response", {}).get("remainSeat")
or []
)
return []
def schedule(self, goods_code: str) -> list[dict]:
out: list[dict] = []
for item in self.get_schedule(goods_code):
out.append(
{
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": item.get("playSeq", ""),
}
)
return out
def all_seats(self, goods_code: str) -> dict:
result: dict = {}
for item in self.get_schedule(goods_code):
seq = item.get("playSeq", "")
if not seq:
continue
seats_raw = self.get_seats(goods_code, seq)
normalized = [
{
"grade": s.get("seatGradeName", s.get("seatGrade", "")),
"remain": int(s.get("remainCnt", 0)),
}
for s in seats_raw
]
key = f"{_fmt_date(item.get('playDate', ''))}|{_fmt_time(item.get('playTime', ''))}|{seq}"
result[key] = {
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": seq,
"seats": normalized,
}
time.sleep(0.3)
return result
# ── CLI ───────────────────────────────────────────────────────────────────────
def _dump(obj: Any, compact: bool) -> str:
if compact:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
return json.dumps(obj, ensure_ascii=False, indent=2)
def cmd_schedule(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().schedule(pid, all_dates=args.all_dates)
else:
out = InterparkClient().schedule(pid)
print(_dump({"platform": platform, "id": pid, "schedule": out}, args.compact))
return 0
def cmd_seats(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().all_seats(pid, all_dates=args.all_dates)
else:
out = InterparkClient().all_seats(pid)
print(_dump({"platform": platform, "id": pid, "seats": out}, args.compact))
return 0
def cmd_health(args: argparse.Namespace) -> int:
http = _require_httpx()
results: dict = {}
for name, url in [
("yes24",
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx"),
("interpark",
f"{INTERPARK_BASE}/v1/goods/00000000/playSeq"),
]:
try:
if name == "yes24":
r = http.post(url, headers=HEADERS_YES24,
data={"pGetMode": "days", "pIdPerf": "0",
"pPerfMonth": "2000-01", "pIdCode": "",
"pIsMania": "0"}, timeout=10)
else:
r = http.get(url, headers=HEADERS_INTERPARK,
params={"goodsCode": "00000000",
"isBookableDate": "true",
"page": "1", "pageSize": "1",
"startDate": "20000101",
"endDate": "20000102"},
timeout=10)
results[name] = {"status": r.status_code, "ok": r.status_code < 500}
except Exception as e:
results[name] = {"status": 0, "ok": False, "error": str(e)}
print(_dump(results, args.compact))
return 0 if all(v.get("ok") for v in results.values()) else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="ticket-availability",
description="YES24 / 인터파크 공연 일정 + 잔여석 조회 (조회 전용)",
)
sub = parser.add_subparsers(dest="cmd", required=True)
def _common(p: argparse.ArgumentParser) -> None:
p.add_argument("--compact", action="store_true",
help="One-line JSON (기본: 들여쓰기 출력)")
p_sch = sub.add_parser("schedule", help="공연 일정 조회")
p_sch.add_argument("url", help="공연 URL 또는 platform:id")
p_sch.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_sch)
p_sch.set_defaults(func=cmd_schedule)
p_st = sub.add_parser("seats", help="등급별 잔여석 조회 (전 일정)")
p_st.add_argument("url", help="공연 URL 또는 platform:id")
p_st.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_st)
p_st.set_defaults(func=cmd_seats)
p_h = sub.add_parser("health", help="API endpoint reachability check")
_common(p_h)
p_h.set_defaults(func=cmd_health)
args = parser.parse_args(argv)
try:
return args.func(args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except MissingHttpxError as e:
print(f"dependency error: {e}", file=sys.stderr)
return 4
except HTTPX_HTTP_ERROR as e:
print(f"http error: {e}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: ticket-availability
description: YES24 / 인터파크 공연의 공개 일정 + 등급별 잔여석을 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음). 돌쇠에서는 가능한 가장 가까운 합법적 공식 단계까지 준비한다.
license: MIT
metadata:
category: lifestyle
subcategory: ticket
locale: ko-KR
phase: v1
---
# ticket-availability
<!-- 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 ticket-availability
```
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 ticket-availability
```
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/ticket-availability/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 ticket-availability 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.