iclub-api 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.
- iclub_api/__init__.py +6 -0
- iclub_api/browser.py +193 -0
- iclub_api/client.py +526 -0
- iclub_api/py.typed +0 -0
- iclub_api/transport.py +184 -0
- iclub_api-0.1.0.dist-info/METADATA +67 -0
- iclub_api-0.1.0.dist-info/RECORD +10 -0
- iclub_api-0.1.0.dist-info/WHEEL +5 -0
- iclub_api-0.1.0.dist-info/licenses/LICENSE +674 -0
- iclub_api-0.1.0.dist-info/top_level.txt +1 -0
iclub_api/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Unofficial iClub Python SDK. No relationship to the iClub operators."""
|
|
2
|
+
from .client import IClub
|
|
3
|
+
from .transport import IClubError, AuthenticationError, TransportError, SignalRTransport
|
|
4
|
+
|
|
5
|
+
__all__ = ["IClub", "IClubError", "AuthenticationError", "TransportError", "SignalRTransport"]
|
|
6
|
+
__version__ = "0.1.0"
|
iclub_api/browser.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Optional Playwright support for functions not covered by the observed hubs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
from .transport import BASE_URL, IClubError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _visible_error(page):
|
|
12
|
+
return page.locator("[role='alert'], .mat-mdc-snack-bar-label, .mat-mdc-form-field-error, .toast-message").first
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class IClubBrowser:
|
|
16
|
+
def __init__(self, *, headless: bool = True):
|
|
17
|
+
self.headless = headless
|
|
18
|
+
self._playwright: Any = None
|
|
19
|
+
self._browser: Any = None
|
|
20
|
+
self._context: Any = None
|
|
21
|
+
self._page: Any = None
|
|
22
|
+
self._qr_page: Any = None
|
|
23
|
+
self._lock = asyncio.Lock()
|
|
24
|
+
|
|
25
|
+
async def _start(self):
|
|
26
|
+
try:
|
|
27
|
+
from playwright.async_api import async_playwright
|
|
28
|
+
except ImportError as exc:
|
|
29
|
+
raise IClubError("Install the optional browser extra: pip install iclub_api[browser]", code="BROWSER_MISSING") from exc
|
|
30
|
+
self._playwright = await async_playwright().start()
|
|
31
|
+
self._browser = await self._playwright.chromium.launch(headless=self.headless)
|
|
32
|
+
self._context = await self._browser.new_context()
|
|
33
|
+
self._page = await self._context.new_page()
|
|
34
|
+
await self._page.goto(BASE_URL + "/#/zoznam-posilnovni", wait_until="domcontentloaded")
|
|
35
|
+
english = self._page.get_by_role("button", name=re.compile("English", re.I))
|
|
36
|
+
if await english.is_visible():
|
|
37
|
+
await english.click()
|
|
38
|
+
await self._page.keyboard.press("Escape")
|
|
39
|
+
return self._page
|
|
40
|
+
|
|
41
|
+
async def _select_gym(self, name: str):
|
|
42
|
+
page = self._page
|
|
43
|
+
picker = page.locator("input[role='combobox']").first
|
|
44
|
+
await picker.wait_for(state="visible", timeout=15000)
|
|
45
|
+
await picker.click()
|
|
46
|
+
option = page.get_by_role("option", name=name, exact=True)
|
|
47
|
+
if await option.count() != 1:
|
|
48
|
+
await picker.fill(name)
|
|
49
|
+
option = page.get_by_role("option", name=name, exact=True)
|
|
50
|
+
if await option.count() != 1:
|
|
51
|
+
raise IClubError("Gym option is missing or ambiguous", code="GYM_AMBIGUOUS")
|
|
52
|
+
await option.first.wait_for(state="visible", timeout=15000)
|
|
53
|
+
await option.first.click()
|
|
54
|
+
choose = page.locator("button, [role='button']").filter(has_text=re.compile("Choose a sport ground|Vybrať športovisko|Open|Otvoriť", re.I)).first
|
|
55
|
+
if await choose.is_visible():
|
|
56
|
+
await choose.click()
|
|
57
|
+
try:
|
|
58
|
+
await page.locator("input[type='password']").first.wait_for(state="visible", timeout=20000)
|
|
59
|
+
except Exception:
|
|
60
|
+
# Preserve gym selection from the preceding flow; no fallback if it was not established.
|
|
61
|
+
raise IClubError("Gym authentication page did not load", code="BROWSER_GYM_NAVIGATION_FAILED")
|
|
62
|
+
return page
|
|
63
|
+
|
|
64
|
+
async def login(self, email: str, password: str, gym_name: str):
|
|
65
|
+
if self._context:
|
|
66
|
+
await self.close()
|
|
67
|
+
page = await self._start()
|
|
68
|
+
await self._select_gym(gym_name)
|
|
69
|
+
await page.locator("input[formcontrolname='Email'], input[formcontrolname='email'], input[type='email'], input[placeholder='Email']").first.fill(email)
|
|
70
|
+
password_input = page.locator("input[type='password']").first
|
|
71
|
+
await password_input.fill(password)
|
|
72
|
+
login = page.get_by_role("button", name=re.compile(r"^(Login|Prihlásiť)$", re.I)).last
|
|
73
|
+
if await login.is_visible():
|
|
74
|
+
await login.click()
|
|
75
|
+
else:
|
|
76
|
+
await password_input.press("Enter")
|
|
77
|
+
try:
|
|
78
|
+
await page.wait_for_url(re.compile("#/hlavny-panel"), timeout=20000)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise IClubError("iClub browser login was rejected or page changed", code="BROWSER_LOGIN_FAILED") from exc
|
|
81
|
+
|
|
82
|
+
async def get_live_qr_code(self) -> dict[str, Any]:
|
|
83
|
+
if self._context is None:
|
|
84
|
+
raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
|
|
85
|
+
async with self._lock:
|
|
86
|
+
if self._qr_page is None or self._qr_page.is_closed():
|
|
87
|
+
self._qr_page = await self._context.new_page()
|
|
88
|
+
await self._qr_page.goto(BASE_URL + "/#/hlavny-panel", wait_until="domcontentloaded")
|
|
89
|
+
await self._qr_page.locator(".qrcode canvas").first.wait_for(state="visible", timeout=15000)
|
|
90
|
+
data = await self._qr_page.locator(".qrcode canvas").first.evaluate("canvas => canvas.toDataURL('image/png')")
|
|
91
|
+
return {"qrCodeDataUrl": data}
|
|
92
|
+
|
|
93
|
+
async def reset_qr_page(self):
|
|
94
|
+
if self._qr_page and not self._qr_page.is_closed():
|
|
95
|
+
await self._qr_page.close()
|
|
96
|
+
self._qr_page = None
|
|
97
|
+
|
|
98
|
+
async def prepare_credit_deposit(self, amount: float) -> dict[str, str]:
|
|
99
|
+
if not self._page:
|
|
100
|
+
raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
|
|
101
|
+
async with self._lock:
|
|
102
|
+
page = self._page
|
|
103
|
+
await page.goto(BASE_URL + "/#/profil", wait_until="domcontentloaded")
|
|
104
|
+
previous = page.url
|
|
105
|
+
pages = set(self._context.pages)
|
|
106
|
+
trigger = page.locator("a,button,[role='button']").filter(has_text=re.compile(r"Add credit|Dobitie kreditu|Dobitie|Credit", re.I)).first
|
|
107
|
+
if not await trigger.is_visible():
|
|
108
|
+
raise IClubError("Account credit top-up button not available", code="TOPUP_UNAVAILABLE")
|
|
109
|
+
await trigger.click()
|
|
110
|
+
await page.wait_for_timeout(1000)
|
|
111
|
+
popup = next((p for p in self._context.pages if p not in pages and not p.is_closed()), None)
|
|
112
|
+
target = popup or page
|
|
113
|
+
amount_input = target.locator("input[type='number'], input[inputmode='decimal'], input[formcontrolname*='amount' i], input[placeholder*='amount' i]").first
|
|
114
|
+
if await amount_input.is_visible():
|
|
115
|
+
await amount_input.fill(str(amount))
|
|
116
|
+
cont = target.locator("button, [role='button'], a").filter(has_text=re.compile("Continue|Next|Pay|Proceed|Top up|Dobitie|Add credit", re.I)).first
|
|
117
|
+
if await cont.is_visible():
|
|
118
|
+
await cont.click()
|
|
119
|
+
await target.wait_for_timeout(1200)
|
|
120
|
+
url = target.url
|
|
121
|
+
if url == previous:
|
|
122
|
+
raise IClubError("No payment redirect was detected", code="TOPUP_FLOW_UNAVAILABLE")
|
|
123
|
+
return {"message": "Opened official iClub top-up flow; no payment made by SDK", "redirectUrl": url}
|
|
124
|
+
|
|
125
|
+
async def reserve_event(self, *, name: str, start_date: str, trainer: str | None = None) -> dict[str, Any]:
|
|
126
|
+
"""Fail closed unless calendar DOM identifies exactly one matching event."""
|
|
127
|
+
if not self._page:
|
|
128
|
+
raise IClubError("Browser not authenticated", code="BROWSER_NOT_AUTHENTICATED")
|
|
129
|
+
if not name or not start_date:
|
|
130
|
+
raise ValueError("Both event name and start_date are required")
|
|
131
|
+
async with self._lock:
|
|
132
|
+
page = self._page
|
|
133
|
+
await page.goto(BASE_URL + "/#/rezervacie/rozvrh", wait_until="domcontentloaded")
|
|
134
|
+
await page.locator(".fc-event, [class*='fc-event']").first.wait_for(timeout=10000)
|
|
135
|
+
expected = datetime.fromisoformat(start_date.replace("Z", "+00:00")).strftime("%H:%M")
|
|
136
|
+
events = page.locator(".fc-event")
|
|
137
|
+
matches = []
|
|
138
|
+
for i in range(await events.count()):
|
|
139
|
+
element = events.nth(i)
|
|
140
|
+
if not await element.is_visible():
|
|
141
|
+
continue
|
|
142
|
+
value = " ".join((await element.inner_text()).split())
|
|
143
|
+
if name.casefold() in value.casefold() and expected in value and (not trainer or trainer.casefold() in value.casefold()):
|
|
144
|
+
matches.append(element)
|
|
145
|
+
if len(matches) != 1:
|
|
146
|
+
raise IClubError(f"Expected one matching calendar event, found {len(matches)}; booking cancelled", code="BOOKING_EVENT_AMBIGUOUS")
|
|
147
|
+
await matches[0].click()
|
|
148
|
+
dialog = page.get_by_role("dialog")
|
|
149
|
+
await dialog.wait_for(timeout=10000)
|
|
150
|
+
buttons = dialog.get_by_role("button")
|
|
151
|
+
eligible = []
|
|
152
|
+
for i in range(await buttons.count()):
|
|
153
|
+
b = buttons.nth(i)
|
|
154
|
+
label = " ".join((await b.inner_text()).split())
|
|
155
|
+
if await b.is_enabled() and re.search(r"reserve|book|rezerv", label, re.I) and not re.search(r"cancel|zruš", label, re.I):
|
|
156
|
+
eligible.append((b, label))
|
|
157
|
+
if len(eligible) != 1:
|
|
158
|
+
raise IClubError("No unique explicit reservation action; booking cancelled", code="BOOKING_UNAVAILABLE")
|
|
159
|
+
await eligible[0][0].click()
|
|
160
|
+
return {"message": "Reservation action submitted", "action": eligible[0][1], "confirmed": False}
|
|
161
|
+
|
|
162
|
+
async def register(self, *, gym_name: str, gender: str, first_name: str, last_name: str, phone: str, email: str,
|
|
163
|
+
accept_privacy_policy: bool, accept_terms: bool, agree_newsletter: bool = False):
|
|
164
|
+
if not accept_privacy_policy or not accept_terms:
|
|
165
|
+
raise ValueError("Required consent must be explicitly granted")
|
|
166
|
+
if gender not in ("man", "woman"):
|
|
167
|
+
raise ValueError("gender must be 'man' or 'woman'")
|
|
168
|
+
page = await self._start()
|
|
169
|
+
await self._select_gym(gym_name)
|
|
170
|
+
await page.get_by_text(re.compile("Sign Up", re.I)).last.click()
|
|
171
|
+
await page.wait_for_url(re.compile("#/ucet/registracia"), timeout=20000)
|
|
172
|
+
await page.locator("#mat-radio-1-input" if gender == "woman" else "#mat-radio-0-input").check()
|
|
173
|
+
await page.locator("input[placeholder='Name']").fill(first_name)
|
|
174
|
+
await page.locator("input[placeholder='Surname']").fill(last_name)
|
|
175
|
+
await page.locator("input[placeholder='0912 123 456']").fill(phone)
|
|
176
|
+
await page.locator("input[placeholder='Email']").fill(email)
|
|
177
|
+
checkboxes = page.locator("input[type='checkbox']")
|
|
178
|
+
await checkboxes.nth(0).check()
|
|
179
|
+
if agree_newsletter:
|
|
180
|
+
await checkboxes.nth(1).check()
|
|
181
|
+
await checkboxes.nth(2).check()
|
|
182
|
+
await page.get_by_role("button", name=re.compile("^Sign Up$", re.I)).last.click()
|
|
183
|
+
await page.wait_for_url(re.compile("#/ucet/prihlasenie"), timeout=20000)
|
|
184
|
+
return {"message": "Registration submitted. Verify email if requested by iClub."}
|
|
185
|
+
|
|
186
|
+
async def close(self):
|
|
187
|
+
if self._context:
|
|
188
|
+
await self._context.close()
|
|
189
|
+
if self._browser:
|
|
190
|
+
await self._browser.close()
|
|
191
|
+
if self._playwright:
|
|
192
|
+
await self._playwright.stop()
|
|
193
|
+
self._context = self._browser = self._playwright = self._page = self._qr_page = None
|