xtb-api-python 0.5.2__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.
- xtb_api/__init__.py +70 -0
- xtb_api/__main__.py +154 -0
- xtb_api/auth/__init__.py +5 -0
- xtb_api/auth/auth_manager.py +321 -0
- xtb_api/auth/browser_auth.py +316 -0
- xtb_api/auth/cas_client.py +543 -0
- xtb_api/client.py +444 -0
- xtb_api/exceptions.py +56 -0
- xtb_api/grpc/__init__.py +25 -0
- xtb_api/grpc/client.py +329 -0
- xtb_api/grpc/proto.py +239 -0
- xtb_api/grpc/types.py +14 -0
- xtb_api/instruments.py +132 -0
- xtb_api/py.typed +0 -0
- xtb_api/types/__init__.py +6 -0
- xtb_api/types/enums.py +92 -0
- xtb_api/types/instrument.py +45 -0
- xtb_api/types/trading.py +139 -0
- xtb_api/types/websocket.py +164 -0
- xtb_api/utils.py +62 -0
- xtb_api/ws/__init__.py +3 -0
- xtb_api/ws/parsers.py +161 -0
- xtb_api/ws/ws_client.py +905 -0
- xtb_api_python-0.5.2.dist-info/METADATA +257 -0
- xtb_api_python-0.5.2.dist-info/RECORD +28 -0
- xtb_api_python-0.5.2.dist-info/WHEEL +4 -0
- xtb_api_python-0.5.2.dist-info/entry_points.txt +2 -0
- xtb_api_python-0.5.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Browser-based CAS authentication for XTB xStation5.
|
|
2
|
+
|
|
3
|
+
Uses Playwright to bypass Akamai WAF that blocks REST CAS API from datacenter IPs.
|
|
4
|
+
Launches headless Chromium with stealth patches, performs login via the Angular web app,
|
|
5
|
+
and intercepts the TGT from network responses.
|
|
6
|
+
|
|
7
|
+
Browser is used ONLY for authentication — trading uses WebSocket API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from xtb_api.types.websocket import (
|
|
18
|
+
CASError,
|
|
19
|
+
CASLoginResult,
|
|
20
|
+
CASLoginSuccess,
|
|
21
|
+
CASLoginTwoFactorRequired,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from playwright.async_api import Browser, Page, Playwright, Response
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
LOGIN_URL = "https://xstation5.xtb.com/"
|
|
30
|
+
PAGE_LOAD_TIMEOUT = 30_000 # 30s
|
|
31
|
+
OTP_WAIT_TIMEOUT = 300 # 5 min
|
|
32
|
+
|
|
33
|
+
# Anti-detection scripts injected before page load
|
|
34
|
+
_STEALTH_JS = """
|
|
35
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
36
|
+
window.chrome = { runtime: {} };
|
|
37
|
+
const _origQuery = window.navigator.permissions.query;
|
|
38
|
+
window.navigator.permissions.query = (p) => (
|
|
39
|
+
p.name === 'notifications'
|
|
40
|
+
? Promise.resolve({ state: Notification.permission })
|
|
41
|
+
: _origQuery(p)
|
|
42
|
+
);
|
|
43
|
+
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
44
|
+
Object.defineProperty(navigator, 'languages', { get: () => ['pl-PL', 'pl', 'en-US', 'en'] });
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BrowserCASAuth:
|
|
49
|
+
"""Browser-based CAS authentication using Playwright.
|
|
50
|
+
|
|
51
|
+
Launches headless Chromium with stealth patches, fills the login form on
|
|
52
|
+
xStation5, and intercepts the TGT from CAS network responses.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_browser: Browser | None
|
|
56
|
+
_page: Page | None
|
|
57
|
+
_playwright: Playwright | None
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, headless: bool = True) -> None:
|
|
60
|
+
self._headless = headless
|
|
61
|
+
self._tgt: str | None = None
|
|
62
|
+
self._tgt_event = asyncio.Event()
|
|
63
|
+
self._two_factor_detected = asyncio.Event()
|
|
64
|
+
self._browser = None
|
|
65
|
+
self._page = None
|
|
66
|
+
self._playwright = None
|
|
67
|
+
self._login_ticket: str | None = None
|
|
68
|
+
self._two_factor_info: dict | None = None
|
|
69
|
+
|
|
70
|
+
async def login(self, email: str, password: str) -> CASLoginResult:
|
|
71
|
+
"""Launch browser, navigate to xStation5, fill login form, and submit.
|
|
72
|
+
|
|
73
|
+
Returns CASLoginSuccess if TGT obtained directly, or
|
|
74
|
+
CASLoginTwoFactorRequired if 2FA is needed (call submit_otp() next).
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
from playwright.async_api import async_playwright
|
|
78
|
+
except ImportError as e:
|
|
79
|
+
raise CASError(
|
|
80
|
+
"BROWSER_AUTH_MISSING_DEPENDENCY",
|
|
81
|
+
"Playwright is required for browser auth. "
|
|
82
|
+
"Install with: pip install playwright && playwright install chromium",
|
|
83
|
+
) from e
|
|
84
|
+
|
|
85
|
+
needs_cleanup = True
|
|
86
|
+
self._playwright = await async_playwright().start()
|
|
87
|
+
try:
|
|
88
|
+
from playwright.async_api import Error as _PlaywrightError
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
self._browser = await self._playwright.chromium.launch(
|
|
92
|
+
headless=self._headless,
|
|
93
|
+
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
|
94
|
+
)
|
|
95
|
+
except _PlaywrightError as e:
|
|
96
|
+
msg = str(e)
|
|
97
|
+
if "Executable doesn't exist" in msg:
|
|
98
|
+
raise CASError(
|
|
99
|
+
"BROWSER_CHROMIUM_MISSING",
|
|
100
|
+
"Chromium browser not found. The xtb-api-python library requires a "
|
|
101
|
+
"Chromium install for XTB authentication.\n\n"
|
|
102
|
+
"Run:\n playwright install chromium\n\n"
|
|
103
|
+
"See https://github.com/liskeee/xtb-api-python#post-install-setup "
|
|
104
|
+
"for details.",
|
|
105
|
+
) from e
|
|
106
|
+
raise
|
|
107
|
+
context = await self._browser.new_context(
|
|
108
|
+
user_agent=(
|
|
109
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
110
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
111
|
+
),
|
|
112
|
+
locale="pl-PL",
|
|
113
|
+
timezone_id="Europe/Warsaw",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Inject stealth scripts before any page loads
|
|
117
|
+
await context.add_init_script(_STEALTH_JS)
|
|
118
|
+
|
|
119
|
+
self._page = await context.new_page()
|
|
120
|
+
assert self._browser is not None
|
|
121
|
+
assert self._page is not None
|
|
122
|
+
assert self._playwright is not None
|
|
123
|
+
|
|
124
|
+
# Set up response interceptor for TGT / 2FA
|
|
125
|
+
self._page.on("response", self._on_response)
|
|
126
|
+
|
|
127
|
+
# Set consistent fingerprint to avoid "new device" emails.
|
|
128
|
+
# Must match the fingerprint generated by CAS REST client.
|
|
129
|
+
import hashlib as _hashlib
|
|
130
|
+
|
|
131
|
+
_fp = _hashlib.sha256(b"xStation5/2.94.1 (Linux x86_64)").hexdigest().upper()
|
|
132
|
+
await context.add_init_script(f"""
|
|
133
|
+
try {{
|
|
134
|
+
localStorage.setItem('deviceFingerprint', '{_fp}');
|
|
135
|
+
localStorage.setItem('fingerprint', '{_fp}');
|
|
136
|
+
}} catch(e) {{}}
|
|
137
|
+
""")
|
|
138
|
+
|
|
139
|
+
# Navigate to login page
|
|
140
|
+
logger.info("Navigating to %s", LOGIN_URL)
|
|
141
|
+
await self._page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=PAGE_LOAD_TIMEOUT)
|
|
142
|
+
|
|
143
|
+
# New xStation5 UI uses generic textbox inputs instead of named inputs.
|
|
144
|
+
# Wait for the email field (first textbox) to appear.
|
|
145
|
+
email_input = self._page.get_by_role("textbox").first
|
|
146
|
+
await email_input.wait_for(state="visible", timeout=PAGE_LOAD_TIMEOUT)
|
|
147
|
+
logger.info("Login form found")
|
|
148
|
+
|
|
149
|
+
# Type credentials with human-like delays (avoids bot detection)
|
|
150
|
+
await email_input.click()
|
|
151
|
+
# Clear any pre-filled value before typing
|
|
152
|
+
await email_input.fill("")
|
|
153
|
+
await self._page.keyboard.type(email, delay=30)
|
|
154
|
+
|
|
155
|
+
password_input = self._page.get_by_role("textbox").nth(1)
|
|
156
|
+
await password_input.click()
|
|
157
|
+
await password_input.fill("")
|
|
158
|
+
await self._page.keyboard.type(password, delay=30)
|
|
159
|
+
|
|
160
|
+
await self._page.wait_for_timeout(300)
|
|
161
|
+
|
|
162
|
+
# Submit form — new UI uses a button labeled "Login"
|
|
163
|
+
submit = self._page.get_by_role("button", name="Login")
|
|
164
|
+
if await submit.is_visible(timeout=3000):
|
|
165
|
+
await submit.click()
|
|
166
|
+
else:
|
|
167
|
+
await password_input.press("Enter")
|
|
168
|
+
|
|
169
|
+
logger.info("Login form submitted")
|
|
170
|
+
|
|
171
|
+
# Wait for either TGT or 2FA detection
|
|
172
|
+
tgt_task = asyncio.create_task(self._tgt_event.wait())
|
|
173
|
+
two_fa_task = asyncio.create_task(self._two_factor_detected.wait())
|
|
174
|
+
|
|
175
|
+
done, pending = await asyncio.wait(
|
|
176
|
+
{tgt_task, two_fa_task},
|
|
177
|
+
timeout=PAGE_LOAD_TIMEOUT / 1000,
|
|
178
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
179
|
+
)
|
|
180
|
+
for task in pending:
|
|
181
|
+
task.cancel()
|
|
182
|
+
|
|
183
|
+
if self._tgt:
|
|
184
|
+
await self.close()
|
|
185
|
+
return CASLoginSuccess(
|
|
186
|
+
tgt=self._tgt,
|
|
187
|
+
expires_at=time.time() + 8 * 3600,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if self._two_factor_detected.is_set():
|
|
191
|
+
info = self._two_factor_info or {}
|
|
192
|
+
login_ticket = self._login_ticket or "browser-2fa"
|
|
193
|
+
# Keep browser open for OTP submission
|
|
194
|
+
needs_cleanup = False
|
|
195
|
+
return CASLoginTwoFactorRequired(
|
|
196
|
+
login_ticket=login_ticket,
|
|
197
|
+
session_id=login_ticket,
|
|
198
|
+
two_factor_auth_type=info.get("twoFactorAuthType", "SMS"),
|
|
199
|
+
methods=[info.get("twoFactorAuthType", "SMS")],
|
|
200
|
+
expires_at=time.time() + OTP_WAIT_TIMEOUT,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
raise CASError("BROWSER_AUTH_TIMEOUT", "Login timed out — no TGT or 2FA detected")
|
|
204
|
+
except BaseException:
|
|
205
|
+
if needs_cleanup:
|
|
206
|
+
await self.close()
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
async def submit_otp(self, code: str) -> CASLoginResult:
|
|
210
|
+
"""Fill OTP code into the 2FA form in the browser and wait for TGT.
|
|
211
|
+
|
|
212
|
+
The 2FA component is `XS6-TWO-FACTOR-AUTHENTICATION` — a web component
|
|
213
|
+
with Shadow DOM. Playwright's get_by_placeholder/get_by_role auto-pierce
|
|
214
|
+
shadow DOM, so no manual shadowRoot traversal is needed.
|
|
215
|
+
"""
|
|
216
|
+
if not self._page:
|
|
217
|
+
raise CASError("BROWSER_AUTH_NO_PAGE", "Browser page not available — call login() first")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
logger.info("Submitting OTP code via browser")
|
|
221
|
+
page = self._page
|
|
222
|
+
|
|
223
|
+
# Wait for 2FA modal to appear
|
|
224
|
+
await page.wait_for_timeout(2000)
|
|
225
|
+
|
|
226
|
+
# Playwright auto-pierces shadow DOM for these locators
|
|
227
|
+
otp_input = page.get_by_placeholder("Wprowadź kod tutaj")
|
|
228
|
+
await otp_input.wait_for(state="visible", timeout=10000)
|
|
229
|
+
await otp_input.fill(code)
|
|
230
|
+
|
|
231
|
+
# Click submit button
|
|
232
|
+
submit_btn = page.get_by_role("button", name="Weryfikacja")
|
|
233
|
+
await submit_btn.click()
|
|
234
|
+
|
|
235
|
+
logger.info("OTP submitted, waiting for TGT...")
|
|
236
|
+
|
|
237
|
+
# Wait for TGT from network response interceptor
|
|
238
|
+
try:
|
|
239
|
+
await asyncio.wait_for(self._tgt_event.wait(), timeout=30)
|
|
240
|
+
except TimeoutError as e:
|
|
241
|
+
raise CASError("BROWSER_AUTH_OTP_TIMEOUT", "Timed out waiting for TGT after OTP submission") from e
|
|
242
|
+
|
|
243
|
+
tgt = self._tgt
|
|
244
|
+
if not tgt:
|
|
245
|
+
raise CASError("BROWSER_AUTH_NO_TGT", "OTP submitted but no TGT received")
|
|
246
|
+
|
|
247
|
+
return CASLoginSuccess(
|
|
248
|
+
tgt=tgt,
|
|
249
|
+
expires_at=time.time() + 8 * 3600,
|
|
250
|
+
)
|
|
251
|
+
finally:
|
|
252
|
+
await self.close()
|
|
253
|
+
|
|
254
|
+
async def close(self) -> None:
|
|
255
|
+
"""Clean up browser resources."""
|
|
256
|
+
try:
|
|
257
|
+
if self._browser:
|
|
258
|
+
await self._browser.close()
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
try:
|
|
262
|
+
if self._playwright:
|
|
263
|
+
await self._playwright.stop()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
self._browser = None
|
|
267
|
+
self._page = None
|
|
268
|
+
self._playwright = None
|
|
269
|
+
|
|
270
|
+
async def _on_response(self, response: Response) -> None:
|
|
271
|
+
"""Intercept network responses to extract TGT."""
|
|
272
|
+
try:
|
|
273
|
+
url = response.url
|
|
274
|
+
|
|
275
|
+
if "v2/tickets" in url and "serviceTicket" not in url:
|
|
276
|
+
ct = response.headers.get("content-type", "")
|
|
277
|
+
if "json" not in ct:
|
|
278
|
+
return
|
|
279
|
+
try:
|
|
280
|
+
body = await response.json()
|
|
281
|
+
except Exception:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
login_phase = body.get("loginPhase")
|
|
285
|
+
ticket = body.get("ticket") or body.get("tgt")
|
|
286
|
+
|
|
287
|
+
if login_phase == "TGT_CREATED" and ticket and ticket.startswith("TGT-"):
|
|
288
|
+
logger.info("TGT intercepted from v2/tickets response")
|
|
289
|
+
self._tgt = ticket
|
|
290
|
+
self._tgt_event.set()
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
if login_phase == "TWO_FACTOR_REQUIRED":
|
|
294
|
+
self._login_ticket = body.get("ticket") or body.get("loginTicket") or body.get("sessionId") or ""
|
|
295
|
+
self._two_factor_info = body
|
|
296
|
+
self._two_factor_detected.set()
|
|
297
|
+
ticket_preview = self._login_ticket[:30] if self._login_ticket else "?"
|
|
298
|
+
logger.info("2FA required — login_ticket: %s", ticket_preview)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
# Check for CASTGT cookie in any response
|
|
302
|
+
headers = response.headers
|
|
303
|
+
set_cookie = headers.get("set-cookie", "")
|
|
304
|
+
if "CASTGT=" in set_cookie:
|
|
305
|
+
for part in set_cookie.split(";"):
|
|
306
|
+
part = part.strip()
|
|
307
|
+
if part.startswith("CASTGT="):
|
|
308
|
+
tgt = part[len("CASTGT=") :]
|
|
309
|
+
if tgt.startswith("TGT-"):
|
|
310
|
+
logger.info("TGT intercepted from CASTGT cookie")
|
|
311
|
+
self._tgt = tgt
|
|
312
|
+
self._tgt_event.set()
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
except Exception as e:
|
|
316
|
+
logger.debug("Error intercepting response: %s", e)
|