mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
feat(ticket-availability): YES24·인터파크 공연 일정·잔여석 조회 (조회 전용)
- 공개 endpoint (YES24 axPerfDay/PlayTime/RemainSeat, 인터파크 playSeq/REMAINSEAT) 만 단일 HTTP 호출 - httpx only, CloakBrowser/Playwright 없음, 로그인·시크릿·쿠키 없음 - 예매·결제·좌석 선택·자동화 의도적 제외 (공연법 §4조의2 매크로 부정구매 형사처벌) - 20 unit test (mocked httpx) + validate-skills.sh PASS - README + docs/features 가이드 추가
This commit is contained in:
parent
fc8edd61df
commit
83079cd4c8
7 changed files with 1353 additions and 2 deletions
|
|
@ -67,6 +67,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 토스증권 조회 | `toss-securities` | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||
| 하이패스 영수증 발급 | `hipass-receipt` | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
|
||||
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
|
||||
| 공연 일정·잔여석 조회 | `ticket-availability` | YES24·인터파크 공연의 회차별 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음) | 불필요 | [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md) |
|
||||
| 로또 당첨 확인 | `lotto-results` | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
|
||||
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
|
||||
|
|
@ -165,6 +166,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
|
||||
- [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md)
|
||||
- [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md)
|
||||
- [로또 당첨 확인](docs/features/lotto-results.md)
|
||||
- [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md)
|
||||
- [법인등기 신청 컨설팅](docs/features/corporate-registration-consulting.md)
|
||||
|
|
|
|||
145
docs/features/ticket-availability.md
Normal file
145
docs/features/ticket-availability.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# 공연 일정·잔여석 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- YES24 (`ticket.yes24.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
|
||||
- 인터파크 (`tickets.interpark.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
|
||||
- 공연 URL 또는 `platform:id` 표기 (`yes24:58026`, `interpark:26000541`) 로 입력
|
||||
- 회차별 등급명·잔여수 (YES24 는 노출가 포함) 를 JSON 으로 정리
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 완료
|
||||
- `python3` (3.9 이상) 와 `httpx` 패키지
|
||||
- 인터넷 연결
|
||||
|
||||
`httpx` 설치:
|
||||
|
||||
```bash
|
||||
pip install httpx
|
||||
```
|
||||
|
||||
## v1 범위
|
||||
|
||||
이 기능은 **공개 endpoint / 조회 전용** 범위로 제공된다.
|
||||
|
||||
- YES24 의 `axPerfDay.aspx`, `axPerfPlayTime.aspx`, `axPerfRemainSeat.aspx` 와 인터파크의 `api-ticketfront.interpark.com/v1/goods/<id>/playSeq` 만 호출한다.
|
||||
- 회차 단위 일정·등급별 잔여석 *수* 만 정규화한다.
|
||||
- 예매·결제·취소·환불·좌석 선택·로그인 자동화는 **의도적으로 포함하지 않는다**. 매크로를 이용한 입장권 부정구매·판매는 공연법 §4조의2 (2023.9.22 시행) 에 따라 형사처벌 대상이다.
|
||||
- 차단 우회, CAPTCHA 우회, fingerprint spoofing, headless 감지 우회는 사용하지 않는다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 공연 URL 또는 `platform:id` 를 받아온다.
|
||||
2. 일정만 필요하면 `schedule`, 등급별 잔여석까지 필요하면 `seats` 를 호출한다.
|
||||
3. 결과 JSON 에서 회차별 날짜·시각·등급·잔여수를 정리하고 "조회 시각 기준" 임을 함께 안내한다.
|
||||
4. 사용자가 페이지에서 직접 결제하도록 안내한다 — 스킬이 결제·예매 흐름을 대신하지 않는다.
|
||||
|
||||
## 예시
|
||||
|
||||
### 일정 조회 (인터파크)
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py schedule "https://tickets.interpark.com/goods/26000541"
|
||||
```
|
||||
|
||||
응답 (요약):
|
||||
|
||||
```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, 기본 3주 윈도우)
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py schedule "https://ticket.yes24.com/Perf/58026"
|
||||
```
|
||||
|
||||
6개월 전체:
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py schedule "yes24:58026" --all-dates
|
||||
```
|
||||
|
||||
### 등급별 잔여석 조회
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py seats "interpark:26000541"
|
||||
```
|
||||
|
||||
응답 (요약, 회차당 1개 키):
|
||||
|
||||
```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 응답은 회차별 `time_label` (예: `1회`, `2회`) 와 등급별 `price` (노출가, 예: `110,000원`) 가 함께 들어온다.
|
||||
|
||||
### 헬스체크
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py health
|
||||
```
|
||||
|
||||
응답:
|
||||
|
||||
```json
|
||||
{
|
||||
"yes24": {"status": 200, "ok": true},
|
||||
"interpark": {"status": 200, "ok": true}
|
||||
}
|
||||
```
|
||||
|
||||
### 한 줄 JSON (파이프용)
|
||||
|
||||
```bash
|
||||
python3 scripts/ticket_availability.py seats "interpark:26000541" --compact
|
||||
```
|
||||
|
||||
## 출력에서 확인할 점
|
||||
|
||||
- `platform` 이 `yes24` 또는 `interpark` 인지
|
||||
- `schedule[].date`, `time` 또는 `time_label` 이 채워졌는지
|
||||
- `seats[<key>].seats[].grade` 와 `remain` 이 채워졌는지
|
||||
- 잔여 0 인 등급이 매진된 등급인지 (조회 시각 기준이라 실시간 변동 가능)
|
||||
|
||||
## 실패 모드
|
||||
|
||||
- **빈 `schedule`**: 공연 ID 가 유효하지만 향후 3주 (또는 6개월) 내 일정이 없을 때. `--all-dates` 또는 다른 ID 확인을 안내한다.
|
||||
- **인터파크 `data: []`**: goods_code 가 지나간 공연이거나 오픈 전 / 비공개. 다른 ID 확인을 안내한다.
|
||||
- **HTTP 4xx/5xx**: 차단·일시 장애. 우회 시도하지 않고 `http error` 메시지를 그대로 반환한다.
|
||||
- **HTML 응답 스키마 변경**: YES24 `axPerfRemainSeat.aspx` 는 HTML 정규식 파싱이라 사이트 갱신 시 영향 가능. 잔여 0 으로 잘못 보고될 가능성이 있어 "조회 시각 기준" 임을 명시한다.
|
||||
- **rate-limit**: `seats` 명령은 회차별로 순차 호출한다 (Interpark 0.3s, YES24 0.4s 간격). 100 회차 짜리 공연이면 30 ~ 40 초 소요. 짧은 모니터링 루프에 넣지 말 것.
|
||||
|
||||
## 보안·법적 주의
|
||||
|
||||
- 본 스킬은 **조회 전용** 이다. 시크릿·로그인 세션·자동 예매·자동 결제·좌석 선택을 일체 포함하지 않는다.
|
||||
- 공연법 §4조의2 (2023.9.22 시행): 매크로 프로그램을 이용한 입장권 부정구매·판매는 형사처벌 대상. 이 스킬은 의도적으로 그 경로를 막아두었다.
|
||||
- 등급별 잔여 *수치* 만 인용하고, 좌석 번호·좌석 위치는 노출하지 않는다.
|
||||
|
||||
## 참고
|
||||
|
||||
- v1 은 비로그인 / 공개 endpoint / 단일 HTTP 호출 범위다.
|
||||
- 헤더는 `User-Agent` + `Referer` + JSON `Accept` 만 사용한다 (`Cookie`, `Authorization` 없음).
|
||||
- `httpx` 외 외부 의존성은 없다.
|
||||
|
|
@ -9,9 +9,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.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 && 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 scripts/ticket_availability.py scripts/test_ticket_availability.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",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.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",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.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 scripts.test_ticket_availability && 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",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
210
scripts/test_ticket_availability.py
Normal file
210
scripts/test_ticket_availability.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import json
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from scripts.ticket_availability import (
|
||||
HEADERS_INTERPARK,
|
||||
HEADERS_YES24,
|
||||
INTERPARK_BASE,
|
||||
YES24_BASE,
|
||||
InterparkClient,
|
||||
Yes24Client,
|
||||
_fmt_date,
|
||||
_fmt_time,
|
||||
parse_url,
|
||||
)
|
||||
|
||||
|
||||
class ParseUrlTest(unittest.TestCase):
|
||||
def test_yes24_full_url(self):
|
||||
self.assertEqual(
|
||||
parse_url("https://ticket.yes24.com/Perf/58026"),
|
||||
("yes24", "58026"),
|
||||
)
|
||||
|
||||
def test_yes24_detail_view_url(self):
|
||||
self.assertEqual(
|
||||
parse_url("https://ticket.yes24.com/New/Perf/Detail/View/58026"),
|
||||
("yes24", "58026"),
|
||||
)
|
||||
|
||||
def test_yes24_shorthand(self):
|
||||
self.assertEqual(parse_url("yes24:58026"), ("yes24", "58026"))
|
||||
|
||||
def test_interpark_full_url(self):
|
||||
self.assertEqual(
|
||||
parse_url("https://tickets.interpark.com/goods/26000541"),
|
||||
("interpark", "26000541"),
|
||||
)
|
||||
|
||||
def test_interpark_shorthand(self):
|
||||
self.assertEqual(
|
||||
parse_url("interpark:26000541"), ("interpark", "26000541")
|
||||
)
|
||||
|
||||
def test_bare_digits_requires_platform_prefix(self):
|
||||
with self.assertRaisesRegex(ValueError, "플랫폼"):
|
||||
parse_url("26000541")
|
||||
|
||||
def test_unrecognized_url_raises(self):
|
||||
with self.assertRaisesRegex(ValueError, "인식할 수 없습니다"):
|
||||
parse_url("https://example.com/foo")
|
||||
|
||||
|
||||
class FormatHelpersTest(unittest.TestCase):
|
||||
def test_fmt_date_yyyymmdd(self):
|
||||
self.assertEqual(_fmt_date("20260513"), "2026-05-13")
|
||||
|
||||
def test_fmt_date_passes_through_non_yyyymmdd(self):
|
||||
self.assertEqual(_fmt_date("2026-05-13"), "2026-05-13")
|
||||
self.assertEqual(_fmt_date(""), "")
|
||||
|
||||
def test_fmt_time_hhmm(self):
|
||||
self.assertEqual(_fmt_time("1430"), "14:30")
|
||||
|
||||
def test_fmt_time_passes_through_non_hhmm(self):
|
||||
self.assertEqual(_fmt_time("14:30"), "14:30")
|
||||
self.assertEqual(_fmt_time(""), "")
|
||||
|
||||
|
||||
class Yes24ClientTest(unittest.TestCase):
|
||||
def test_get_dates_normalizes_dashed_response_and_filters_past(self):
|
||||
client = Yes24Client.__new__(Yes24Client)
|
||||
client.http = mock.Mock()
|
||||
client.http.post.return_value = mock.Mock(
|
||||
text="2099-12-16,2099-12-17,",
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
dates = client._dates("58026", month_count=1)
|
||||
|
||||
self.assertEqual(dates, ["20991216", "20991217"])
|
||||
called_url = client.http.post.call_args.args[0]
|
||||
self.assertIn("axPerfDay.aspx", called_url)
|
||||
|
||||
def test_get_dates_filters_dates_before_today(self):
|
||||
client = Yes24Client.__new__(Yes24Client)
|
||||
client.http = mock.Mock()
|
||||
client.http.post.return_value = mock.Mock(
|
||||
text="1999-01-01,2099-12-16,",
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
dates = client._dates("58026", month_count=1)
|
||||
|
||||
self.assertEqual(dates, ["20991216"])
|
||||
|
||||
def test_get_seats_parses_remain_count(self):
|
||||
client = Yes24Client.__new__(Yes24Client)
|
||||
client.http = mock.Mock()
|
||||
client.http.post.return_value = mock.Mock(
|
||||
text='<dt>R석</dt><dd>110,000원<span class="">(잔여:5석)</span></dd>'
|
||||
'<dt>S석</dt><dd>80,000원<span>(잔여:12석)</span></dd>',
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
seats = client.get_seats("1432397")
|
||||
|
||||
self.assertEqual(
|
||||
seats,
|
||||
[
|
||||
{"grade": "R석", "price": "110,000원", "remain": 5},
|
||||
{"grade": "S석", "price": "80,000원", "remain": 12},
|
||||
],
|
||||
)
|
||||
|
||||
def test_get_seats_fallback_when_no_dt_dd_structure(self):
|
||||
client = Yes24Client.__new__(Yes24Client)
|
||||
client.http = mock.Mock()
|
||||
client.http.post.return_value = mock.Mock(
|
||||
text="<span>(잔여:2석)</span>",
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
seats = client.get_seats("1432397")
|
||||
|
||||
self.assertEqual(seats, [{"grade": "좌석1", "price": "", "remain": 2}])
|
||||
|
||||
|
||||
class InterparkClientTest(unittest.TestCase):
|
||||
def test_get_schedule_returns_data_field(self):
|
||||
client = InterparkClient.__new__(InterparkClient)
|
||||
client.http = mock.Mock()
|
||||
client.http.get.return_value = mock.Mock(
|
||||
json=lambda: {
|
||||
"common": {"message": "success"},
|
||||
"data": [
|
||||
{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}
|
||||
],
|
||||
},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
result = client.get_schedule("26000541")
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
[{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}],
|
||||
)
|
||||
called_url = client.http.get.call_args.args[0]
|
||||
self.assertIn("/v1/goods/26000541/playSeq", called_url)
|
||||
|
||||
def test_get_seats_extracts_remain_seat(self):
|
||||
client = InterparkClient.__new__(InterparkClient)
|
||||
client.http = mock.Mock()
|
||||
client.http.get.return_value = mock.Mock(
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"remainSeat": [
|
||||
{"seatGradeName": "VIP석", "remainCnt": 150},
|
||||
{"seatGradeName": "R석", "remainCnt": 36},
|
||||
]
|
||||
}
|
||||
},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
seats = client.get_seats("26000541", "055")
|
||||
|
||||
self.assertEqual(
|
||||
seats,
|
||||
[
|
||||
{"seatGradeName": "VIP석", "remainCnt": 150},
|
||||
{"seatGradeName": "R석", "remainCnt": 36},
|
||||
],
|
||||
)
|
||||
|
||||
def test_schedule_normalizes_date_and_time_format(self):
|
||||
client = InterparkClient.__new__(InterparkClient)
|
||||
client.http = mock.Mock()
|
||||
client.http.get.return_value = mock.Mock(
|
||||
json=lambda: {
|
||||
"data": [
|
||||
{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}
|
||||
],
|
||||
},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
|
||||
out = client.schedule("26000541")
|
||||
|
||||
self.assertEqual(
|
||||
out,
|
||||
[{"date": "2026-05-13", "time": "14:30", "play_seq": "055"}],
|
||||
)
|
||||
|
||||
|
||||
class EndpointSafetyTest(unittest.TestCase):
|
||||
def test_no_login_or_auth_headers(self):
|
||||
for hdr in (HEADERS_YES24, HEADERS_INTERPARK):
|
||||
self.assertNotIn("Cookie", hdr)
|
||||
self.assertNotIn("Authorization", hdr)
|
||||
self.assertNotIn("X-Auth-Token", hdr)
|
||||
|
||||
def test_bases_are_known_public_hosts(self):
|
||||
self.assertEqual(YES24_BASE, "https://ticket.yes24.com")
|
||||
self.assertEqual(INTERPARK_BASE, "https://api-ticketfront.interpark.com")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
404
scripts/ticket_availability.py
Normal file
404
scripts/ticket_availability.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
#!/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
|
||||
|
||||
import httpx
|
||||
|
||||
# ── 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:
|
||||
self.http = httpx.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:
|
||||
self.http = httpx.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:
|
||||
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 = httpx.post(url, headers=HEADERS_YES24,
|
||||
data={"pGetMode": "days", "pIdPerf": "0",
|
||||
"pPerfMonth": "2000-01", "pIdCode": "",
|
||||
"pIsMania": "0"}, timeout=10)
|
||||
else:
|
||||
r = httpx.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 httpx.HTTPError as e:
|
||||
print(f"http error: {e}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
186
ticket-availability/SKILL.md
Normal file
186
ticket-availability/SKILL.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
---
|
||||
name: ticket-availability
|
||||
description: YES24 / 인터파크 공연의 공개 일정 + 등급별 잔여석을 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음).
|
||||
license: MIT
|
||||
metadata:
|
||||
category: lifestyle
|
||||
subcategory: ticket
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# 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
|
||||
python3 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
|
||||
python3 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
|
||||
python3 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 을 반환하거나, 빈 결과 사유를 설명했다.
|
||||
- 예매 / 결제 / 좌석 선택 기능을 자동화하지 않았다.
|
||||
- 조회 시각 기준임을 안내했다.
|
||||
404
ticket-availability/scripts/ticket_availability.py
Normal file
404
ticket-availability/scripts/ticket_availability.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
#!/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
|
||||
|
||||
import httpx
|
||||
|
||||
# ── 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:
|
||||
self.http = httpx.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:
|
||||
self.http = httpx.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:
|
||||
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 = httpx.post(url, headers=HEADERS_YES24,
|
||||
data={"pGetMode": "days", "pIdPerf": "0",
|
||||
"pPerfMonth": "2000-01", "pIdCode": "",
|
||||
"pIsMania": "0"}, timeout=10)
|
||||
else:
|
||||
r = httpx.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 httpx.HTTPError as e:
|
||||
print(f"http error: {e}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue