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.
@@ -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)