pykorail 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pykorail/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ """pykorail — 코레일(KTX) 스마트 예매 비공식 Python 클라이언트.
2
+
3
+ ::
4
+
5
+ from pykorail import AdultPassenger, ChildPassenger, Korail
6
+ from pykorail.device import profile_by_id, random_profile
7
+
8
+ profile = profile_by_id(saved_id) or random_profile()
9
+ with Korail.logged_in("me@example.com", "password", device_profile=profile) as korail:
10
+ trains = korail.trains.search("서울", "부산", passengers=[AdultPassenger(2), ChildPassenger(1)])
11
+ reservation = korail.reservations.create(trains[0])
12
+
13
+ API 는 리소스별로 나뉘어 있습니다 — ``korail.stations`` / ``korail.trains`` /
14
+ ``korail.reservations`` / ``korail.tickets``. 로그인·로그아웃·연결 정리만
15
+ :class:`~pykorail.client.Korail` 본체에 있습니다.
16
+
17
+ 하위 모듈:
18
+ - :mod:`pykorail.device` — 기기 프로파일 카탈로그 (주입용)
19
+ - :mod:`pykorail.exceptions` — 예외 계층
20
+ - :mod:`pykorail.models` — 불변 응답 모델
21
+ - :mod:`pykorail.options` — 조회·예매 옵션 코드
22
+ - :mod:`pykorail.resources` — 리소스 구현
23
+ - :mod:`pykorail.auth` — DynaPath 서명, NetFunnel 대기열
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from importlib import metadata
29
+
30
+ from pykorail import _compat as _compat # 최소 파이썬 버전 강제 — 다른 임포트보다 먼저
31
+ from pykorail.auth import NetFunnelHelper
32
+ from pykorail.client import Korail
33
+ from pykorail.device import DeviceProfile, DeviceProfileLike, profile_by_id, random_profile
34
+ from pykorail.exceptions import (
35
+ KorailError,
36
+ LoginFailedError,
37
+ NeedToLoginError,
38
+ NetFunnelError,
39
+ NoResultsError,
40
+ PastDepartureError,
41
+ PykorailError,
42
+ SoldOutError,
43
+ StationNotFoundError,
44
+ TransportError,
45
+ )
46
+ from pykorail.models import (
47
+ AdultPassenger,
48
+ Card,
49
+ ChildPassenger,
50
+ Disability1To3Passenger,
51
+ Disability4To6Passenger,
52
+ Passenger,
53
+ Reservation,
54
+ Schedule,
55
+ Seat,
56
+ SeniorPassenger,
57
+ Station,
58
+ Ticket,
59
+ ToddlerPassenger,
60
+ Train,
61
+ )
62
+ from pykorail.options import ReserveOption, ReserveOptionCode, TrainType, TrainTypeCode
63
+
64
+
65
+ def _resolve_version() -> str:
66
+ """설치된 배포 메타데이터에서 버전을 읽습니다.
67
+
68
+ 버전의 유일한 출처는 **git 태그**입니다 (hatch-vcs). 소스에 숫자를 박아 두면
69
+ 태그와 어긋나므로 여기서 하드코딩하지 않습니다. 빌드 훅이 `_version.py` 를
70
+ 만들어 두므로 설치 후에는 git 없이도 읽힙니다.
71
+ """
72
+ try:
73
+ from pykorail._version import __version__ as built_version
74
+ except ImportError:
75
+ pass
76
+ else:
77
+ return built_version
78
+
79
+ try:
80
+ return metadata.version("pykorail")
81
+ except metadata.PackageNotFoundError:
82
+ # 설치하지 않고 소스 트리에서 바로 임포트한 경우.
83
+ return "0.0.0+unknown"
84
+
85
+
86
+ __version__ = _resolve_version()
87
+
88
+ __all__ = [
89
+ "AdultPassenger",
90
+ "Card",
91
+ "ChildPassenger",
92
+ "DeviceProfile",
93
+ "DeviceProfileLike",
94
+ "Disability1To3Passenger",
95
+ "Disability4To6Passenger",
96
+ "Korail",
97
+ "KorailError",
98
+ "LoginFailedError",
99
+ "NeedToLoginError",
100
+ "NetFunnelError",
101
+ "NetFunnelHelper",
102
+ "NoResultsError",
103
+ "Passenger",
104
+ "PastDepartureError",
105
+ "PykorailError",
106
+ "Reservation",
107
+ "ReserveOption",
108
+ "ReserveOptionCode",
109
+ "Schedule",
110
+ "Seat",
111
+ "SeniorPassenger",
112
+ "SoldOutError",
113
+ "Station",
114
+ "StationNotFoundError",
115
+ "Ticket",
116
+ "ToddlerPassenger",
117
+ "Train",
118
+ "TrainType",
119
+ "TrainTypeCode",
120
+ "TransportError",
121
+ "__version__",
122
+ "profile_by_id",
123
+ "random_profile",
124
+ ]
pykorail/_compat.py ADDED
@@ -0,0 +1,19 @@
1
+ """파이썬 최소 버전 강제.
2
+
3
+ ``requires-python`` 은 패키지 매니저를 통해 설치할 때만 걸립니다. 소스를 그대로
4
+ 복사해 오거나 ``PYTHONPATH`` 로 끌어다 쓰는 경우를 대비해 임포트 시점에도 막습니다.
5
+ 빈 슬롯 데이터클래스·``X | Y`` 런타임 유니언 등 3.10 문법을 실제로 쓰기 때문에,
6
+ 낮은 버전에서는 알아보기 힘든 ``SyntaxError`` 대신 명확한 메시지를 냅니다.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ MIN_PYTHON = (3, 10)
14
+
15
+ if sys.version_info < MIN_PYTHON: # pragma: no cover - 낮은 버전에서만 실행
16
+ raise RuntimeError(
17
+ f"pykorail 은 파이썬 {'.'.join(map(str, MIN_PYTHON))} 이상이 필요합니다 "
18
+ f"(현재 {'.'.join(map(str, sys.version_info[:3]))})."
19
+ )
pykorail/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
pykorail/api.py ADDED
@@ -0,0 +1,112 @@
1
+ """요청/응답 계층 — 리소스들이 공유하는 저수준 클라이언트.
2
+
3
+ :class:`~pykorail.client.Korail` 과 각 리소스가 이 객체 하나를 나눠 씁니다.
4
+ HTTP 왕복·서명·에러 변환처럼 "어느 리소스에서나 똑같은 일"만 담고, 엔드포인트별
5
+ 폼 필드는 리소스 쪽에 둡니다.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pykorail.constants import API_KEY, APP_VERSION, DEVICE
16
+ from pykorail.exceptions import TransportError, error_for_code
17
+
18
+ if TYPE_CHECKING:
19
+ from pykorail.auth.signer import RequestSigner
20
+ from pykorail.transport import HttpSession, Response
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class Account:
27
+ """로그인 세션 상태.
28
+
29
+ 여러 리소스가 읽고(``mbCrdNo``) 로그인만 쓰기 때문에, 클라이언트와 리소스가
30
+ 같은 인스턴스를 공유합니다.
31
+ """
32
+
33
+ logined: bool = False
34
+ membership_number: str | None = None
35
+ name: str | None = None
36
+ email: str | None = None
37
+ phone_number: str | None = None
38
+
39
+ def clear(self) -> None:
40
+ self.logined = False
41
+ self.membership_number = None
42
+ self.name = None
43
+ self.email = None
44
+ self.phone_number = None
45
+
46
+
47
+ class ApiClient:
48
+ """서명·전송·응답 해석을 담당합니다."""
49
+
50
+ def __init__(self, session: HttpSession, signer: RequestSigner, verbose: bool = False) -> None:
51
+ self._session = session
52
+ self._signer = signer
53
+ self.verbose = verbose
54
+ self.account = Account()
55
+
56
+ # ------------------------------------------------------------------- 전송
57
+ def sign(self, url: str) -> tuple[dict[str, str], str | None]:
58
+ """``url`` 에 필요한 ``(헤더, Sid)``. 서명 대상이 아니면 ``({}, None)``."""
59
+ return self._signer.sign(url)
60
+
61
+ def get(
62
+ self,
63
+ url: str,
64
+ *,
65
+ params: dict[str, Any] | None = None,
66
+ headers: dict[str, str] | None = None,
67
+ ) -> dict[str, Any]:
68
+ return self._parse(self._session.get(url, **_kwargs(params=params, headers=headers)))
69
+
70
+ def post(
71
+ self,
72
+ url: str,
73
+ *,
74
+ data: dict[str, Any] | None = None,
75
+ params: dict[str, Any] | None = None,
76
+ headers: dict[str, str] | None = None,
77
+ ) -> dict[str, Any]:
78
+ return self._parse(self._session.post(url, **_kwargs(data=data, params=params, headers=headers)))
79
+
80
+ def close(self) -> None:
81
+ self._session.close()
82
+
83
+ # ------------------------------------------------------------------- 해석
84
+ def base_payload(self) -> dict[str, Any]:
85
+ """거의 모든 요청에 실리는 앱 신원 필드."""
86
+ return {"Device": DEVICE, "Version": APP_VERSION, "Key": API_KEY}
87
+
88
+ @staticmethod
89
+ def check(payload: dict[str, Any]) -> None:
90
+ """``strResult=FAIL`` 이면 코드에 맞는 예외를 던집니다."""
91
+ if payload.get("strResult") == "FAIL":
92
+ raise error_for_code(payload.get("h_msg_cd"), payload.get("h_msg_txt"))
93
+
94
+ def _parse(self, response: Response) -> dict[str, Any]:
95
+ if self.verbose:
96
+ logger.debug("%s", response.text)
97
+ try:
98
+ parsed = json.loads(response.text)
99
+ except json.JSONDecodeError as exc:
100
+ raise TransportError(f"코레일 응답을 JSON 으로 읽지 못했습니다: {response.text[:200]!r}") from exc
101
+ if not isinstance(parsed, dict):
102
+ raise TransportError(f"코레일 응답이 객체가 아닙니다: {type(parsed).__name__}")
103
+ return parsed
104
+
105
+
106
+ def _kwargs(**candidates: Any) -> dict[str, Any]:
107
+ """``None`` 인 인자를 빼고 넘깁니다.
108
+
109
+ ``post(url)`` 과 ``post(url, data=None)`` 은 라이브러리에 따라 다르게 처리될 수
110
+ 있어(빈 바디 vs 바디 없음), 앱이 보내는 모양을 유지하려면 아예 안 넘겨야 합니다.
111
+ """
112
+ return {key: value for key, value in candidates.items() if value is not None}
@@ -0,0 +1,9 @@
1
+ """인증·서명 계층 — DynaPath 토큰, ``Sid``, NetFunnel 대기열."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pykorail.auth.dynapath import DynaPathMasterEngine
6
+ from pykorail.auth.netfunnel import NetFunnelHelper
7
+ from pykorail.auth.signer import RequestSigner
8
+
9
+ __all__ = ["DynaPathMasterEngine", "NetFunnelHelper", "RequestSigner"]
@@ -0,0 +1,179 @@
1
+ """DynaPath 요청 서명 (``x-dynapath-m-token``).
2
+
3
+ 코레일 앱이 예매 계열 엔드포인트에 붙이는 무결성 토큰을 재현합니다. 알고리즘은
4
+ 앱에서 그대로 옮긴 것이라 **바이트 단위로 같아야** 서버가 받아 줍니다 — 변수
5
+ 이름은 읽기 좋게 바꿨지만 연산 순서·상수는 손대지 마세요.
6
+
7
+ 토큰이 광고하는 기기(``os=``·``dm=``)는 User-Agent 가 광고하는 기기와 반드시
8
+ 같아야 합니다. :class:`~pykorail.client.Korail` 이 같은 프로파일로 둘 다 채웁니다.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from typing import TYPE_CHECKING, ClassVar, Final
15
+
16
+ if TYPE_CHECKING:
17
+ from pykorail.device import DeviceProfileLike
18
+
19
+ #: 인코딩 알파벳. 순서가 곧 알고리즘의 일부입니다.
20
+ _TABLE: Final = "3FE9jgRD4KdCyuawklqGJYmvfMn15P7US8XbxeLQtWT6OicBAopINs2Vh0HZrz"
21
+
22
+ _RADIX: Final = 161 # 청크를 하나의 정수로 접을 때의 진법
23
+ _MODULUS: Final = 30 # 출력 자릿수 진법 (= 커스텀 테이블 길이)
24
+ _CHUNK: Final = 2 # 한 번에 접는 코드포인트 수
25
+
26
+
27
+ class DynaPathMasterEngine:
28
+ """DynaPath 토큰 생성기.
29
+
30
+ 인스턴스 하나가 "앱 실행 한 번"에 대응합니다 — 생성 시각을 ``it=`` 필드로
31
+ 서명에 담기 때문에, 세션마다 새로 만들지 말고 클라이언트와 수명을 맞추세요.
32
+ """
33
+
34
+ APP_ID: ClassVar[str] = "com.korail.talk"
35
+ AS_VALUE: ClassVar[str] = "%5B38ff229cb34c7dda8e28220a2d750cce%5D"
36
+ DEVICE_MODEL: ClassVar[str] = "SM-S928N"
37
+ OS_VERSION: ClassVar[str] = "13"
38
+ OS_TYPE: ClassVar[str] = "Android"
39
+ SDK_VERSION: ClassVar[str] = "v1"
40
+
41
+ def __init__(self, device_model: str | None = None, os_version: str | None = None) -> None:
42
+ self.app_start_ts = str(int(time.time() * 1000))
43
+ # 기본값을 클래스 상수와 같게 둬 미주입 시 서명이 바이트 단위로 동일합니다.
44
+ self.device_model = device_model or self.DEVICE_MODEL
45
+ self.os_version = os_version or self.OS_VERSION
46
+
47
+ @classmethod
48
+ def from_profile(cls, profile: DeviceProfileLike | None) -> DynaPathMasterEngine:
49
+ """기기 프로파일로 엔진을 만듭니다. ``None`` 이면 클래스 기본값을 씁니다."""
50
+ if profile is None:
51
+ return cls()
52
+ return cls(device_model=profile.model, os_version=profile.android)
53
+
54
+ # ------------------------------------------------------------ 인코딩 원시연산
55
+ @staticmethod
56
+ def _to_code_units(data: str) -> list[int]:
57
+ """문자열을 앱 고유의 가변길이 7비트 코드 유닛으로 펼칩니다.
58
+
59
+ UTF-8 이 아닙니다 — 연속 바이트가 7비트를 쓰고 선행 바이트의 상위 비트
60
+ 패턴도 다릅니다. 서로게이트(0xD800~0xDFFF)는 앱과 마찬가지로 버립니다.
61
+ """
62
+ result: list[int] = []
63
+ for char in data:
64
+ cp = ord(char)
65
+ if cp < 128:
66
+ result.append(cp)
67
+ elif cp < 2048:
68
+ result.append(128 | ((cp >> 7) & 15))
69
+ result.append(cp & 127)
70
+ elif cp >= 262144:
71
+ result.append(160)
72
+ result.append((cp >> 14) & 127)
73
+ result.append((cp >> 7) & 127)
74
+ result.append(cp & 127)
75
+ elif (63488 & cp) != 55296:
76
+ result.append(((cp >> 14) & 15) | 144)
77
+ result.append((cp >> 7) & 127)
78
+ result.append(cp & 127)
79
+ return result
80
+
81
+ @staticmethod
82
+ def _derive_key(key_str: str) -> int:
83
+ """키 문자열을 커스텀 테이블 셔플에 쓸 큰 정수로 접습니다.
84
+
85
+ 각 문자마다 최상위 세트 비트를 찾아 그 두 배를 진법으로 삼습니다.
86
+ 코드포인트가 0 이면 16 회 탐색이 모두 실패해 진법이 0 이 되고 누산값이
87
+ 초기화되는데, 앱과 동작을 맞추기 위해 그대로 둡니다.
88
+ """
89
+ accumulator = 0
90
+ for char in key_str:
91
+ cp = ord(char)
92
+ high_bit = 32768
93
+ for _ in range(16):
94
+ if (high_bit & cp) != 0:
95
+ break
96
+ high_bit >>= 1
97
+ accumulator = (accumulator * (high_bit << 1)) + cp
98
+ return accumulator
99
+
100
+ @staticmethod
101
+ def _pick_unused_char(base_table: str, position: int, used: str) -> str:
102
+ """``used`` 에 아직 없는 문자 중 ``position`` 번째를 고릅니다."""
103
+ seen = 0
104
+ for char in base_table:
105
+ if char not in used:
106
+ if seen == position:
107
+ return char
108
+ seen += 1
109
+ return " "
110
+
111
+ @classmethod
112
+ def _build_table(cls, seed: int, size: int, base_table: str) -> str:
113
+ """``seed`` 로 ``base_table`` 에서 길이 ``size`` 의 커스텀 알파벳을 뽑습니다.
114
+
115
+ 팩토리얼 진법(Lehmer code) 방식이라 같은 seed 는 항상 같은 알파벳을 냅니다.
116
+ """
117
+ picked = ""
118
+ remaining = seed
119
+ for i in range(size):
120
+ divisor = size - i
121
+ picked += cls._pick_unused_char(base_table, remaining % divisor, picked)
122
+ remaining //= divisor
123
+ return picked
124
+
125
+ @classmethod
126
+ def _encode(cls, data: str, table: str) -> str:
127
+ """코드 유닛을 ``_CHUNK`` 개씩 묶어 ``_MODULUS`` 진수 자릿수로 펼칩니다."""
128
+ units = cls._to_code_units(data)
129
+ out: list[str] = []
130
+ digits = [0] * (_CHUNK + 1)
131
+
132
+ idx = 0
133
+ tail = len(units) % _CHUNK
134
+ body_end = len(units) - tail
135
+
136
+ while idx < body_end:
137
+ value = 0
138
+ for _ in range(_CHUNK):
139
+ value = (value * _RADIX) + units[idx]
140
+ idx += 1
141
+ for i in range(_CHUNK + 1):
142
+ digits[i] = value % _MODULUS
143
+ value //= _MODULUS
144
+ for i in range(_CHUNK, -1, -1):
145
+ out.append(table[digits[i]])
146
+
147
+ if tail > 0:
148
+ value = 0
149
+ for _ in range(tail):
150
+ value = (value * _RADIX) + units[idx]
151
+ idx += 1
152
+ for i in range(tail + 1):
153
+ digits[i] = value % _MODULUS
154
+ value //= _MODULUS
155
+ for i in range(tail, -1, -1):
156
+ out.append(table[digits[i]])
157
+
158
+ return "".join(out)
159
+
160
+ # ------------------------------------------------------------------- 공개 API
161
+ def generate_token(self, device_id: str, ts: int, rand: str) -> str:
162
+ """``x-dynapath-m-token`` 헤더 값을 만듭니다.
163
+
164
+ Args:
165
+ device_id: 앱이 들고 다니는 기기 식별자.
166
+ ts: 요청 시각 (epoch 밀리초).
167
+ rand: 요청마다 새로 뽑는 4자 영대문자·숫자 논스.
168
+ """
169
+ payload = (
170
+ f"ai={self.APP_ID}&di={device_id}&as={self.AS_VALUE}&"
171
+ f"su=false&dbg=false&emu=false&hk=false&it={self.app_start_ts}&"
172
+ f"ts={ts}&rt=0&os={self.os_version}&dm={self.device_model}&st={self.OS_TYPE}&sv={self.SDK_VERSION}"
173
+ )
174
+
175
+ dyn_key = f"{self.SDK_VERSION}+{rand}+{ts}"
176
+ key_part = self._encode(dyn_key, _TABLE)
177
+ custom_table = self._build_table(self._derive_key(dyn_key), _MODULUS, _TABLE)
178
+ body_part = self._encode(payload, custom_table)
179
+ return f"bEeEP{_TABLE[len(key_part)]}{key_part}{body_part}"
@@ -0,0 +1,140 @@
1
+ """NetFunnel 대기열 게이트 (``nf.letskorail.com``).
2
+
3
+ 접속 폭주 때 코레일 앞단에 서는 대기열입니다. 통과 티켓(key)을 받아 두면
4
+ 잠깐 재사용할 수 있어 캐시합니다.
5
+
6
+ **클라이언트는 이 모듈을 쓰지 않습니다 — 의도된 것입니다.** 대기열은 코레일 웹
7
+ 프런트가 통과하는 관문이고, 이 패키지가 쓰는 스마트 앱 엔드포인트
8
+ (``smart.letskorail.com``)는 대기열 뒤에 있지 않습니다. 명절 예매처럼 앱 경로에도
9
+ 대기열이 붙는 상황을 만나면 직접 꺼내 쓸 수 있도록 공개 유틸리티로 남겨 둡니다::
10
+
11
+ from pykorail import NetFunnelHelper
12
+
13
+ key = NetFunnelHelper().run() # 통과할 때까지 블로킹
14
+
15
+ :class:`~pykorail.client.Korail` 에 자동으로 엮지 않은 이유는, 필요 없는 상황에서
16
+ 매 요청마다 외부 게이트를 때리는 비용과 실패 지점이 생기기 때문입니다.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from typing import ClassVar, Final
24
+
25
+ from pykorail.exceptions import NetFunnelError
26
+ from pykorail.transport import create_session
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ NETFUNNEL_URL: Final = "http://nf.letskorail.com/ts.wseq"
31
+
32
+ NETFUNNEL_HEADERS: Final[dict[str, str]] = {
33
+ "Host": "nf.letskorail.com",
34
+ "Connection": "Keep-Alive",
35
+ "User-Agent": "Apache-HttpClient/UNAVAILABLE (java 1.4)",
36
+ }
37
+
38
+
39
+ class NetFunnelHelper:
40
+ """대기열을 통과해 티켓(key)을 얻습니다.
41
+
42
+ :meth:`run` 이 유일한 진입점입니다. 대기 줄이 있으면 통과할 때까지 1초 간격으로
43
+ 폴링하므로 **블로킹**됩니다.
44
+ """
45
+
46
+ WAIT_STATUS_PASS: ClassVar[str] = "200"
47
+ WAIT_STATUS_FAIL: ClassVar[str] = "201"
48
+ ALREADY_COMPLETED: ClassVar[str] = "502"
49
+
50
+ OP_CODE: ClassVar[dict[str, str]] = {
51
+ "getTidchkEnter": "5101",
52
+ "chkEnter": "5002",
53
+ "setComplete": "5004",
54
+ }
55
+
56
+ #: 티켓 재사용 시간(초). 서버 만료보다 짧게 잡아 아슬아슬한 재사용을 피합니다.
57
+ CACHE_TTL: ClassVar[float] = 50.0
58
+
59
+ def __init__(self) -> None:
60
+ self._session = create_session(NETFUNNEL_HEADERS)
61
+ self._cached_key: str | None = None
62
+ self._last_fetch_time = 0.0
63
+
64
+ def run(self) -> str | None:
65
+ """통과 티켓을 돌려줍니다. 캐시가 살아 있으면 재사용합니다.
66
+
67
+ Raises:
68
+ NetFunnelError: 대기열 통과에 실패했습니다. 캐시는 비워집니다.
69
+ """
70
+ now = time.time()
71
+ if self._is_cache_valid(now):
72
+ return self._cached_key
73
+
74
+ try:
75
+ status, self._cached_key, nwait = self._start()
76
+ self._last_fetch_time = now
77
+
78
+ while status == self.WAIT_STATUS_FAIL:
79
+ logger.debug("현재 %s명 대기중", nwait)
80
+ time.sleep(1)
81
+ status, self._cached_key, nwait = self._check()
82
+
83
+ status, _, _ = self._complete()
84
+ if status in (self.WAIT_STATUS_PASS, self.ALREADY_COMPLETED):
85
+ return self._cached_key
86
+
87
+ self.clear()
88
+ raise NetFunnelError("Failed to complete NetFunnel")
89
+
90
+ except NetFunnelError:
91
+ self.clear()
92
+ raise
93
+ except Exception as exc:
94
+ self.clear()
95
+ raise NetFunnelError(str(exc)) from exc
96
+
97
+ def clear(self) -> None:
98
+ """캐시된 티켓을 버립니다."""
99
+ self._cached_key = None
100
+ self._last_fetch_time = 0.0
101
+
102
+ # ------------------------------------------------------------------- 내부
103
+ def _start(self) -> tuple[str | None, str | None, str | None]:
104
+ return self._make_request("getTidchkEnter")
105
+
106
+ def _check(self) -> tuple[str | None, str | None, str | None]:
107
+ return self._make_request("chkEnter")
108
+
109
+ def _complete(self) -> tuple[str | None, str | None, str | None]:
110
+ return self._make_request("setComplete")
111
+
112
+ def _make_request(self, operation: str) -> tuple[str | None, str | None, str | None]:
113
+ params = self._build_params(self.OP_CODE[operation])
114
+ parsed = self._parse(self._session.get(NETFUNNEL_URL, params=params).text)
115
+ return parsed.get("status"), parsed.get("key"), parsed.get("nwait")
116
+
117
+ def _build_params(self, opcode: str, key: str | None = None) -> dict[str, str]:
118
+ params: dict[str, str] = {"opcode": opcode}
119
+
120
+ if opcode in (self.OP_CODE["getTidchkEnter"], self.OP_CODE["chkEnter"]):
121
+ params.update({"sid": "service_1", "aid": "act_8"})
122
+ if opcode == self.OP_CODE["chkEnter"]:
123
+ params.update({"key": key or self._cached_key or "", "ttl": "1"})
124
+ elif opcode == self.OP_CODE["setComplete"]:
125
+ params["key"] = key or self._cached_key or ""
126
+
127
+ return params
128
+
129
+ @staticmethod
130
+ def _parse(response: str) -> dict[str, str]:
131
+ status, _, params_str = response.partition(":")
132
+ if not params_str:
133
+ raise NetFunnelError("Failed to parse NetFunnel response")
134
+
135
+ parsed = dict(param.split("=", 1) for param in params_str.split("&") if "=" in param)
136
+ parsed["status"] = status
137
+ return parsed
138
+
139
+ def _is_cache_valid(self, now: float) -> bool:
140
+ return bool(self._cached_key) and (now - self._last_fetch_time) < self.CACHE_TTL
@@ -0,0 +1,51 @@
1
+ """요청 서명 — 어떤 경로에 어떤 인증 재료를 붙일지 결정합니다."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import string
7
+ from time import time
8
+ from typing import TYPE_CHECKING
9
+
10
+ from pykorail.auth.dynapath import DynaPathMasterEngine
11
+ from pykorail.constants import DEVICE, DEVICE_ID, DYNAPATH_PATHS, SID_KEY
12
+ from pykorail.crypto import encrypt_sid
13
+
14
+ if TYPE_CHECKING:
15
+ from pykorail.device import DeviceProfileLike
16
+
17
+ _NONCE_ALPHABET = string.ascii_uppercase + string.digits
18
+
19
+
20
+ class RequestSigner:
21
+ """DynaPath 서명이 필요한 요청에 헤더와 ``Sid`` 를 만들어 줍니다.
22
+
23
+ 엔진 인스턴스를 들고 있으므로 클라이언트당 하나만 두고 재사용하세요 —
24
+ 엔진 생성 시각이 서명에 들어갑니다.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ profile: DeviceProfileLike | None = None,
30
+ device: str = DEVICE,
31
+ device_id: str = DEVICE_ID,
32
+ sid_key: bytes = SID_KEY,
33
+ ) -> None:
34
+ self._engine = DynaPathMasterEngine.from_profile(profile)
35
+ self._device = device
36
+ self._device_id = device_id
37
+ self._sid_key = sid_key
38
+
39
+ def sign(self, url: str) -> tuple[dict[str, str], str | None]:
40
+ """``url`` 에 필요한 ``(헤더, Sid)`` 를 만듭니다.
41
+
42
+ 서명 대상이 아닌 경로면 ``({}, None)`` 을 돌려줍니다. 토큰과 ``Sid`` 는
43
+ 같은 타임스탬프로 만들어야 서버가 짝을 맞춰 검증할 수 있습니다.
44
+ """
45
+ if not any(path in url for path in DYNAPATH_PATHS):
46
+ return {}, None
47
+
48
+ ts = int(time() * 1000)
49
+ nonce = "".join(random.choices(_NONCE_ALPHABET, k=4))
50
+ token = self._engine.generate_token(self._device_id, ts, nonce)
51
+ return {"x-dynapath-m-token": token}, encrypt_sid(self._device, ts, self._sid_key)