heytelecom 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.
- heytelecom/__init__.py +22 -0
- heytelecom/client.py +500 -0
- heytelecom/installer.py +57 -0
- heytelecom/models.py +132 -0
- heytelecom/parsers.py +101 -0
- heytelecom-0.1.0.dist-info/METADATA +300 -0
- heytelecom-0.1.0.dist-info/RECORD +11 -0
- heytelecom-0.1.0.dist-info/WHEEL +5 -0
- heytelecom-0.1.0.dist-info/entry_points.txt +2 -0
- heytelecom-0.1.0.dist-info/licenses/LICENSE +21 -0
- heytelecom-0.1.0.dist-info/top_level.txt +1 -0
heytelecom/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hey Telecom Python Library
|
|
3
|
+
|
|
4
|
+
A Python library for interacting with Hey Telecom accounts using Playwright.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
from .client import HeyTelecomClient
|
|
10
|
+
from .models import Product, Contract, UsageData, Invoice, AccountData
|
|
11
|
+
from .installer import install_playwright, ensure_playwright_installed
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"HeyTelecomClient",
|
|
15
|
+
"Product",
|
|
16
|
+
"Contract",
|
|
17
|
+
"UsageData",
|
|
18
|
+
"Invoice",
|
|
19
|
+
"AccountData",
|
|
20
|
+
"install_playwright",
|
|
21
|
+
"ensure_playwright_installed",
|
|
22
|
+
]
|
heytelecom/client.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Hey Telecom client for accessing mobile usage information."""
|
|
2
|
+
import time
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
|
+
from playwright.sync_api import sync_playwright, Browser, Page, BrowserContext
|
|
5
|
+
|
|
6
|
+
from .models import Product, Contract, UsageData, Invoice, AccountData
|
|
7
|
+
from .parsers import (
|
|
8
|
+
parse_data_amount, parse_price, parse_date, parse_period,
|
|
9
|
+
parse_minutes, parse_sms_count, parse_last_update, is_unlimited
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HeyTelecomClient:
|
|
14
|
+
"""Client for interacting with Hey Telecom account."""
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://ecare.heytelecom.be"
|
|
17
|
+
AUTH_URL = "https://auth.heytelecom.be"
|
|
18
|
+
|
|
19
|
+
def __init__(self, email: Optional[str] = None, password: Optional[str] = None,
|
|
20
|
+
user_data_dir: str = "hey_browser_data", auto_install: bool = True):
|
|
21
|
+
"""
|
|
22
|
+
Initialize Hey Telecom client.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
email: Email address for login (optional if already logged in)
|
|
26
|
+
password: Password for login (optional if already logged in)
|
|
27
|
+
user_data_dir: Directory to store browser session data
|
|
28
|
+
auto_install: Automatically install Playwright chromium if not found (default: True)
|
|
29
|
+
|
|
30
|
+
Note:
|
|
31
|
+
Browser always runs in headless mode (no GUI).
|
|
32
|
+
"""
|
|
33
|
+
self.email = email
|
|
34
|
+
self.password = password
|
|
35
|
+
self.user_data_dir = user_data_dir
|
|
36
|
+
self.auto_install = auto_install
|
|
37
|
+
self.headless = True # Always run headless
|
|
38
|
+
self._playwright = None
|
|
39
|
+
self._browser: Optional[BrowserContext] = None
|
|
40
|
+
self._page: Optional[Page] = None
|
|
41
|
+
|
|
42
|
+
def __enter__(self):
|
|
43
|
+
"""Context manager entry."""
|
|
44
|
+
self.connect()
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
48
|
+
"""Context manager exit."""
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
def connect(self):
|
|
52
|
+
"""Start browser and create page."""
|
|
53
|
+
# Ensure Playwright chromium is installed before connecting
|
|
54
|
+
if self.auto_install:
|
|
55
|
+
from .installer import ensure_playwright_installed
|
|
56
|
+
ensure_playwright_installed()
|
|
57
|
+
|
|
58
|
+
self._playwright = sync_playwright().start()
|
|
59
|
+
self._browser = self._playwright.chromium.launch_persistent_context(
|
|
60
|
+
user_data_dir=self.user_data_dir,
|
|
61
|
+
headless=self.headless
|
|
62
|
+
)
|
|
63
|
+
self._page = self._browser.new_page()
|
|
64
|
+
|
|
65
|
+
def close(self):
|
|
66
|
+
"""Close browser and cleanup."""
|
|
67
|
+
if self._browser:
|
|
68
|
+
self._browser.close()
|
|
69
|
+
if self._playwright:
|
|
70
|
+
self._playwright.stop()
|
|
71
|
+
|
|
72
|
+
def _handle_cookie_popup(self):
|
|
73
|
+
"""Check for and handle cookie popup if present."""
|
|
74
|
+
if not self._page:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
reject_button = self._page.locator('button#onetrust-reject-all-handler')
|
|
79
|
+
if reject_button.count() > 0:
|
|
80
|
+
reject_button.click()
|
|
81
|
+
time.sleep(0.5) # Reduced from 1 second
|
|
82
|
+
return True
|
|
83
|
+
except:
|
|
84
|
+
pass
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
def _wait_for_page_load(self, timeout: int = 30000):
|
|
88
|
+
"""Wait for page to load completely."""
|
|
89
|
+
if not self._page:
|
|
90
|
+
raise RuntimeError("Browser not connected. Call connect() first.")
|
|
91
|
+
|
|
92
|
+
time.sleep(1) # Minimum 1 second (reduced from 2)
|
|
93
|
+
|
|
94
|
+
# Wait for all spinners to disappear
|
|
95
|
+
try:
|
|
96
|
+
start_time = time.time()
|
|
97
|
+
while time.time() - start_time < timeout / 1000:
|
|
98
|
+
spinners = self._page.locator('svg.p-progress-spinner')
|
|
99
|
+
if spinners.count() == 0:
|
|
100
|
+
return True
|
|
101
|
+
time.sleep(0.2) # Check every 200ms (reduced from 300ms)
|
|
102
|
+
return False
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def _check_logged_in(self) -> bool:
|
|
107
|
+
"""Check if user is logged in."""
|
|
108
|
+
if not self._page:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
mijn_account = self._page.locator('span.p-menuitem-text.ng-star-inserted.button-label:has-text("Mijn account")')
|
|
112
|
+
return mijn_account.count() > 0
|
|
113
|
+
|
|
114
|
+
def login(self):
|
|
115
|
+
"""
|
|
116
|
+
Login to Hey Telecom account.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If email or password not provided
|
|
120
|
+
RuntimeError: If login fails
|
|
121
|
+
"""
|
|
122
|
+
if not self._page:
|
|
123
|
+
raise RuntimeError("Browser not connected. Call connect() first.")
|
|
124
|
+
|
|
125
|
+
# Navigate to products page
|
|
126
|
+
self._page.goto(f"{self.BASE_URL}/nl/mijn-producten")
|
|
127
|
+
self._wait_for_page_load()
|
|
128
|
+
|
|
129
|
+
# Check if already logged in (before handling cookies)
|
|
130
|
+
if self._check_logged_in():
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
if not self.email or not self.password:
|
|
134
|
+
raise ValueError("Email and password required for login")
|
|
135
|
+
|
|
136
|
+
# Wait for redirect to auth page
|
|
137
|
+
self._page.wait_for_url(f"**{self.AUTH_URL}/**", timeout=10000)
|
|
138
|
+
|
|
139
|
+
# Click on "Inloggen via E-mail" button
|
|
140
|
+
email_login_btn = self._page.locator('a#Login_loginByEmail')
|
|
141
|
+
email_login_btn.wait_for(state="visible", timeout=10000)
|
|
142
|
+
email_login_btn.click()
|
|
143
|
+
self._wait_for_page_load()
|
|
144
|
+
|
|
145
|
+
# Fill in email
|
|
146
|
+
email_input = self._page.locator('input#Login_byEmail_emailAddress')
|
|
147
|
+
email_input.wait_for(state="visible", timeout=10000)
|
|
148
|
+
email_input.fill(self.email)
|
|
149
|
+
|
|
150
|
+
# Fill in password
|
|
151
|
+
password_input = self._page.locator('input#Login_byEmail_password')
|
|
152
|
+
password_input.wait_for(state="visible", timeout=10000)
|
|
153
|
+
password_input.fill(self.password)
|
|
154
|
+
|
|
155
|
+
# Click login button
|
|
156
|
+
login_btn = self._page.locator('button#Login_byEmail_login')
|
|
157
|
+
login_btn.click()
|
|
158
|
+
self._wait_for_page_load()
|
|
159
|
+
|
|
160
|
+
# Check for error message
|
|
161
|
+
error_msg = self._page.locator('div.error_msgs:has-text("Verkeerde gebruikersnaam en/of wachtwoord")')
|
|
162
|
+
if error_msg.count() > 0:
|
|
163
|
+
raise RuntimeError("Login failed: Wrong username and/or password")
|
|
164
|
+
|
|
165
|
+
# Wait for successful login
|
|
166
|
+
try:
|
|
167
|
+
self._page.wait_for_selector('span.p-menuitem-text.ng-star-inserted.button-label:has-text("Mijn account")', timeout=10000)
|
|
168
|
+
except:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
self._wait_for_page_load()
|
|
172
|
+
|
|
173
|
+
# Verify login
|
|
174
|
+
if not self._check_logged_in():
|
|
175
|
+
raise RuntimeError("Login verification failed")
|
|
176
|
+
|
|
177
|
+
def get_products(self) -> List[Product]:
|
|
178
|
+
"""
|
|
179
|
+
Get all products from the account.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of Product objects
|
|
183
|
+
"""
|
|
184
|
+
if not self._page:
|
|
185
|
+
raise RuntimeError("Browser not connected. Call connect() first.")
|
|
186
|
+
|
|
187
|
+
# Navigate to products page
|
|
188
|
+
self._page.goto(f"{self.BASE_URL}/nl/mijn-producten")
|
|
189
|
+
self._wait_for_page_load()
|
|
190
|
+
|
|
191
|
+
# Handle cookie popup
|
|
192
|
+
self._handle_cookie_popup()
|
|
193
|
+
time.sleep(1)
|
|
194
|
+
|
|
195
|
+
self._page.wait_for_selector('li.iris-products__item', timeout=10000)
|
|
196
|
+
|
|
197
|
+
products = self._page.locator('li.iris-products__item')
|
|
198
|
+
product_count = products.count()
|
|
199
|
+
|
|
200
|
+
if product_count == 0:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
# Collect product data
|
|
204
|
+
products_data = []
|
|
205
|
+
for i in range(product_count):
|
|
206
|
+
product = products.nth(i)
|
|
207
|
+
product_data = self._extract_product_basic_info(product)
|
|
208
|
+
identifier = self._get_product_identifier(product)
|
|
209
|
+
products_data.append({
|
|
210
|
+
"identifier": identifier,
|
|
211
|
+
"data": product_data
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
# Extract usage data for each product
|
|
215
|
+
for product_info in products_data:
|
|
216
|
+
identifier = product_info["identifier"]
|
|
217
|
+
product_data = product_info["data"]
|
|
218
|
+
|
|
219
|
+
# Find product again (in case page reloaded)
|
|
220
|
+
product = self._find_product_by_identifier(identifier)
|
|
221
|
+
if not product:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Extract usage
|
|
225
|
+
consumption_link = product.locator('a.iris-products__link[data-event_category="MyProducts"]')
|
|
226
|
+
if consumption_link.count() > 0:
|
|
227
|
+
consumption_link.click()
|
|
228
|
+
usage_data = self._extract_usage_data()
|
|
229
|
+
if usage_data:
|
|
230
|
+
product_data["usage"] = usage_data
|
|
231
|
+
|
|
232
|
+
# Go back using browser back (faster than full page reload)
|
|
233
|
+
self._page.go_back()
|
|
234
|
+
self._wait_for_page_load()
|
|
235
|
+
self._page.wait_for_selector('li.iris-products__item', timeout=10000)
|
|
236
|
+
|
|
237
|
+
# Convert to Product objects
|
|
238
|
+
return [self._create_product_object(p["data"]) for p in products_data]
|
|
239
|
+
|
|
240
|
+
def get_latest_invoice(self) -> Optional[Invoice]:
|
|
241
|
+
"""
|
|
242
|
+
Get the latest invoice.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Invoice object or None if no invoice found
|
|
246
|
+
"""
|
|
247
|
+
if not self._page:
|
|
248
|
+
raise RuntimeError("Browser not connected. Call connect() first.")
|
|
249
|
+
|
|
250
|
+
self._page.goto(f"{self.BASE_URL}/nl/mijn-facturen")
|
|
251
|
+
self._wait_for_page_load()
|
|
252
|
+
|
|
253
|
+
# Handle cookie popup (reduced wait time)
|
|
254
|
+
self._handle_cookie_popup()
|
|
255
|
+
time.sleep(0.5)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
self._page.wait_for_selector('lib-obe-latest-invoice section.iris-invoice', timeout=10000)
|
|
259
|
+
except:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
invoice_section = self._page.locator('lib-obe-latest-invoice section.iris-invoice')
|
|
263
|
+
if invoice_section.count() == 0:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
invoice_data = {}
|
|
267
|
+
|
|
268
|
+
# Extract amount
|
|
269
|
+
amount_element = invoice_section.locator('p.iris-invoice__main-data-title:has-text("Bedrag")').locator('xpath=following-sibling::p').first
|
|
270
|
+
if amount_element.count() > 0:
|
|
271
|
+
amount_text = amount_element.inner_text().strip()
|
|
272
|
+
invoice_data["amount_eur"] = parse_price(amount_text)
|
|
273
|
+
|
|
274
|
+
# Extract status
|
|
275
|
+
status_element = invoice_section.locator('p.iris-invoice__main-data-title:has-text("Status")').locator('xpath=following-sibling::p').first
|
|
276
|
+
if status_element.count() > 0:
|
|
277
|
+
status_text = status_element.inner_text().strip()
|
|
278
|
+
invoice_data["status"] = status_text
|
|
279
|
+
invoice_data["paid"] = status_text.lower() == "betaald"
|
|
280
|
+
|
|
281
|
+
# Extract date
|
|
282
|
+
date_element = invoice_section.locator('p.iris-invoice__main-data-title:has-text("Datum")').locator('xpath=following-sibling::p').first
|
|
283
|
+
if date_element.count() > 0:
|
|
284
|
+
date_text = date_element.inner_text().strip()
|
|
285
|
+
invoice_data["date"] = parse_date(date_text)
|
|
286
|
+
|
|
287
|
+
# Extract due date
|
|
288
|
+
due_date_element = invoice_section.locator('p.iris-invoice__main-data-title:has-text("Vervaldatum")').locator('xpath=following-sibling::p').first
|
|
289
|
+
if due_date_element.count() > 0:
|
|
290
|
+
due_date_text = due_date_element.inner_text().strip()
|
|
291
|
+
invoice_data["due_date"] = parse_date(due_date_text)
|
|
292
|
+
|
|
293
|
+
# Generate invoice ID
|
|
294
|
+
if invoice_data.get("date"):
|
|
295
|
+
invoice_data["invoice_id"] = f"INV-{invoice_data['date'].replace('-', '')}"
|
|
296
|
+
|
|
297
|
+
return Invoice(**invoice_data)
|
|
298
|
+
|
|
299
|
+
def get_account_data(self) -> AccountData:
|
|
300
|
+
"""
|
|
301
|
+
Get all account data including products and latest invoice.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
AccountData object with all information
|
|
305
|
+
"""
|
|
306
|
+
products = self.get_products()
|
|
307
|
+
invoice = self.get_latest_invoice()
|
|
308
|
+
|
|
309
|
+
return AccountData(
|
|
310
|
+
products=products,
|
|
311
|
+
latest_invoice=invoice
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _extract_product_basic_info(self, product) -> Dict[str, Any]:
|
|
315
|
+
"""Extract basic product information."""
|
|
316
|
+
product_data = {}
|
|
317
|
+
|
|
318
|
+
# Phone number
|
|
319
|
+
phone_number = product.locator('span.iris-products__details-tariff-number')
|
|
320
|
+
if phone_number.count() > 0:
|
|
321
|
+
product_data["phone_number"] = phone_number.inner_text()
|
|
322
|
+
|
|
323
|
+
# Tariff name
|
|
324
|
+
tariff_name = product.locator('span.iris-products__details-tariff-name')
|
|
325
|
+
if tariff_name.count() > 0:
|
|
326
|
+
product_data["tariff"] = tariff_name.inner_text()
|
|
327
|
+
|
|
328
|
+
# Easy Switch number
|
|
329
|
+
easy_switch = product.locator('span.iris-products__details-info-title:has-text("Nummer Easy Switch")').locator('xpath=following-sibling::span')
|
|
330
|
+
if easy_switch.count() > 0:
|
|
331
|
+
product_data["easy_switch_number"] = easy_switch.inner_text()
|
|
332
|
+
|
|
333
|
+
# Contract start date
|
|
334
|
+
start_date_label = product.locator('span.iris-products__details-info-title:has-text("Begindatum contract")')
|
|
335
|
+
if start_date_label.count() > 0:
|
|
336
|
+
start_date_value = start_date_label.locator('xpath=following-sibling::span').first
|
|
337
|
+
start_date_text = start_date_value.inner_text().strip()
|
|
338
|
+
if start_date_text:
|
|
339
|
+
product_data["contract_start_date"] = parse_date(start_date_text)
|
|
340
|
+
|
|
341
|
+
# Price
|
|
342
|
+
price_label = product.locator('span.iris-products__details-info-title:has-text("Prijs")')
|
|
343
|
+
if price_label.count() > 0:
|
|
344
|
+
price_value = price_label.locator('xpath=following-sibling::span').first
|
|
345
|
+
price_text = price_value.inner_text()
|
|
346
|
+
product_data["price_per_month_eur"] = parse_price(price_text)
|
|
347
|
+
|
|
348
|
+
return product_data
|
|
349
|
+
|
|
350
|
+
def _extract_usage_data(self) -> Optional[Dict[str, Any]]:
|
|
351
|
+
"""Extract usage data from detailed usage page."""
|
|
352
|
+
try:
|
|
353
|
+
self._page.wait_for_url("**/gedetailleerd-gebruik**", timeout=10000)
|
|
354
|
+
except:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
# Only wait for page load (selector check is redundant)
|
|
358
|
+
self._wait_for_page_load()
|
|
359
|
+
|
|
360
|
+
if "gedetailleerd-gebruik" not in self._page.url:
|
|
361
|
+
return None
|
|
362
|
+
|
|
363
|
+
usage_data = {}
|
|
364
|
+
|
|
365
|
+
# Extract date range
|
|
366
|
+
date_range = self._page.locator('p.iris-consumption__main-date-range')
|
|
367
|
+
if date_range.count() > 0:
|
|
368
|
+
period_text = date_range.inner_text()
|
|
369
|
+
usage_data["period"] = parse_period(period_text)
|
|
370
|
+
|
|
371
|
+
# Extract Data usage (mobile)
|
|
372
|
+
data_block = self._page.locator('div#consumption-data')
|
|
373
|
+
if data_block.count() > 0:
|
|
374
|
+
usage_data["data"] = self._extract_data_usage(data_block)
|
|
375
|
+
|
|
376
|
+
# Extract Data usage (internet)
|
|
377
|
+
fix_block = self._page.locator('div#consumption-fix')
|
|
378
|
+
if fix_block.count() > 0:
|
|
379
|
+
usage_data["data"] = self._extract_data_usage(fix_block)
|
|
380
|
+
|
|
381
|
+
# Extract Calls usage
|
|
382
|
+
calls_block = self._page.locator('div#consumption-calls')
|
|
383
|
+
if calls_block.count() > 0:
|
|
384
|
+
calls_limit = calls_block.locator('span.iris-consumption__main-data-limit')
|
|
385
|
+
calls_usage = calls_block.locator('span.iris-consumption__main-data-usage strong')
|
|
386
|
+
calls_update = calls_block.locator('span.iris-consumption__main-data-update')
|
|
387
|
+
|
|
388
|
+
if calls_limit.count() > 0 and calls_usage.count() > 0:
|
|
389
|
+
limit_text = calls_limit.inner_text()
|
|
390
|
+
used_text = calls_usage.inner_text()
|
|
391
|
+
usage_data["calls"] = {
|
|
392
|
+
"used": parse_minutes(used_text),
|
|
393
|
+
"unlimited": is_unlimited(limit_text),
|
|
394
|
+
"last_update": parse_last_update(calls_update.inner_text() if calls_update.count() > 0 else None)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# Extract SMS/MMS usage
|
|
398
|
+
sms_block = self._page.locator('div#consumption-sms')
|
|
399
|
+
if sms_block.count() > 0:
|
|
400
|
+
sms_limit = sms_block.locator('span.iris-consumption__main-data-limit')
|
|
401
|
+
sms_usage = sms_block.locator('span.iris-consumption__main-data-usage strong')
|
|
402
|
+
sms_update = sms_block.locator('span.iris-consumption__main-data-update')
|
|
403
|
+
|
|
404
|
+
if sms_limit.count() > 0 and sms_usage.count() > 0:
|
|
405
|
+
limit_text = sms_limit.inner_text()
|
|
406
|
+
used_text = sms_usage.inner_text()
|
|
407
|
+
usage_data["sms_mms"] = {
|
|
408
|
+
"used": parse_sms_count(used_text),
|
|
409
|
+
"unlimited": is_unlimited(limit_text),
|
|
410
|
+
"last_update": parse_last_update(sms_update.inner_text() if sms_update.count() > 0 else None)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return usage_data
|
|
414
|
+
|
|
415
|
+
def _extract_data_usage(self, block) -> Dict[str, Any]:
|
|
416
|
+
"""Extract data usage from a block."""
|
|
417
|
+
data_limit = block.locator('span.iris-consumption__main-data-limit')
|
|
418
|
+
data_usage = block.locator('span.iris-consumption__main-data-usage strong')
|
|
419
|
+
data_update = block.locator('span.iris-consumption__main-data-update')
|
|
420
|
+
|
|
421
|
+
if data_limit.count() > 0 and data_usage.count() > 0:
|
|
422
|
+
limit_text = data_limit.inner_text()
|
|
423
|
+
used_text = data_usage.inner_text()
|
|
424
|
+
return {
|
|
425
|
+
"used": parse_data_amount(used_text),
|
|
426
|
+
"limit": parse_data_amount(limit_text),
|
|
427
|
+
"unlimited": is_unlimited(limit_text),
|
|
428
|
+
"last_update": parse_last_update(data_update.inner_text() if data_update.count() > 0 else None)
|
|
429
|
+
}
|
|
430
|
+
return {}
|
|
431
|
+
|
|
432
|
+
def _get_product_identifier(self, product):
|
|
433
|
+
"""Get unique identifier for a product."""
|
|
434
|
+
# Try phone number
|
|
435
|
+
phone_number = product.locator('span.iris-products__details-tariff-number')
|
|
436
|
+
if phone_number.count() > 0:
|
|
437
|
+
return ("phone", phone_number.inner_text())
|
|
438
|
+
|
|
439
|
+
# Try Easy Switch number
|
|
440
|
+
easy_switch = product.locator('span.iris-products__details-info-title:has-text("Nummer Easy Switch")').locator('xpath=following-sibling::span')
|
|
441
|
+
if easy_switch.count() > 0:
|
|
442
|
+
return ("easy_switch", easy_switch.inner_text())
|
|
443
|
+
|
|
444
|
+
# Fallback to tariff name
|
|
445
|
+
tariff_name = product.locator('span.iris-products__details-tariff-name')
|
|
446
|
+
if tariff_name.count() > 0:
|
|
447
|
+
return ("tariff", tariff_name.inner_text())
|
|
448
|
+
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
def _find_product_by_identifier(self, identifier):
|
|
452
|
+
"""Find product by identifier."""
|
|
453
|
+
if not identifier:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
products = self._page.locator('li.iris-products__item')
|
|
457
|
+
for i in range(products.count()):
|
|
458
|
+
product = products.nth(i)
|
|
459
|
+
current_id = self._get_product_identifier(product)
|
|
460
|
+
if current_id and current_id == identifier:
|
|
461
|
+
return product
|
|
462
|
+
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
def _create_product_object(self, data: Dict[str, Any]) -> Product:
|
|
466
|
+
"""Create Product object from data dictionary."""
|
|
467
|
+
# Determine product type and ID
|
|
468
|
+
if "phone_number" in data:
|
|
469
|
+
product_type = "mobile"
|
|
470
|
+
phone_clean = data["phone_number"].replace(" ", "")
|
|
471
|
+
product_id = f"mobile_{phone_clean}"
|
|
472
|
+
elif "easy_switch_number" in data:
|
|
473
|
+
product_type = "internet"
|
|
474
|
+
product_id = f"internet_{data['easy_switch_number']}"
|
|
475
|
+
else:
|
|
476
|
+
product_type = "unknown"
|
|
477
|
+
product_id = f"unknown_{data.get('tariff', 'product')}"
|
|
478
|
+
|
|
479
|
+
# Create contract if exists
|
|
480
|
+
contract = None
|
|
481
|
+
if "contract_start_date" in data or "price_per_month_eur" in data:
|
|
482
|
+
contract = Contract(
|
|
483
|
+
start_date=data.get("contract_start_date"),
|
|
484
|
+
price_per_month_eur=data.get("price_per_month_eur")
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
# Create usage if exists
|
|
488
|
+
usage = None
|
|
489
|
+
if "usage" in data:
|
|
490
|
+
usage = UsageData(**data["usage"])
|
|
491
|
+
|
|
492
|
+
return Product(
|
|
493
|
+
product_id=product_id,
|
|
494
|
+
product_type=product_type,
|
|
495
|
+
phone_number=data.get("phone_number"),
|
|
496
|
+
easy_switch_number=data.get("easy_switch_number"),
|
|
497
|
+
tariff=data.get("tariff"),
|
|
498
|
+
contract=contract,
|
|
499
|
+
usage=usage
|
|
500
|
+
)
|
heytelecom/installer.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Playwright installation utilities."""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def install_playwright():
|
|
7
|
+
"""
|
|
8
|
+
Install Playwright Chromium browser with dependencies for headless operation.
|
|
9
|
+
|
|
10
|
+
Always installs chromium with --with-deps and --only-shell flags.
|
|
11
|
+
|
|
12
|
+
Raises:
|
|
13
|
+
RuntimeError: If installation fails
|
|
14
|
+
"""
|
|
15
|
+
cmd = ["playwright", "install", "chromium", "--with-deps", "--only-shell"]
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
print("Installing Playwright chromium...")
|
|
19
|
+
print(f"Running: {' '.join(cmd)}")
|
|
20
|
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
21
|
+
print(result.stdout)
|
|
22
|
+
print("Successfully installed Playwright chromium")
|
|
23
|
+
except subprocess.CalledProcessError as e:
|
|
24
|
+
print(f"Installation failed: {e.stderr}")
|
|
25
|
+
raise RuntimeError(f"Failed to install Playwright: {e}")
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
print("Playwright command not found. Make sure playwright is installed.")
|
|
28
|
+
print("Try: pip install playwright")
|
|
29
|
+
raise RuntimeError("Playwright not found in PATH")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_playwright_installed():
|
|
33
|
+
"""
|
|
34
|
+
Check if Playwright chromium browser is installed and install if needed.
|
|
35
|
+
|
|
36
|
+
Only installs if chromium is not already available.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
# Try to import playwright
|
|
40
|
+
from playwright.sync_api import sync_playwright
|
|
41
|
+
|
|
42
|
+
# Try to launch browser to check if installed
|
|
43
|
+
try:
|
|
44
|
+
with sync_playwright() as p:
|
|
45
|
+
browser_instance = p.chromium.launch(headless=True)
|
|
46
|
+
browser_instance.close()
|
|
47
|
+
print("Playwright chromium is already installed")
|
|
48
|
+
except Exception:
|
|
49
|
+
print("Playwright chromium not found, installing...")
|
|
50
|
+
install_playwright()
|
|
51
|
+
except ImportError:
|
|
52
|
+
raise RuntimeError("Playwright package not installed. Install with: pip install playwright")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
"""Allow running as a script for installation."""
|
|
57
|
+
install_playwright()
|
heytelecom/models.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Data models for Hey Telecom."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class UsageData:
|
|
9
|
+
"""Represents usage data for a product."""
|
|
10
|
+
period: Optional[Dict[str, str]] = None
|
|
11
|
+
data: Optional[Dict[str, Any]] = None
|
|
12
|
+
calls: Optional[Dict[str, Any]] = None
|
|
13
|
+
sms_mms: Optional[Dict[str, Any]] = None
|
|
14
|
+
|
|
15
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
16
|
+
"""Convert to dictionary, excluding None values."""
|
|
17
|
+
result = {}
|
|
18
|
+
if self.period:
|
|
19
|
+
result["period"] = self.period
|
|
20
|
+
if self.data:
|
|
21
|
+
result["data"] = self.data
|
|
22
|
+
if self.calls:
|
|
23
|
+
result["calls"] = self.calls
|
|
24
|
+
if self.sms_mms:
|
|
25
|
+
result["sms_mms"] = self.sms_mms
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Contract:
|
|
31
|
+
"""Represents contract information."""
|
|
32
|
+
start_date: Optional[str] = None
|
|
33
|
+
price_per_month_eur: Optional[float] = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
36
|
+
"""Convert to dictionary, excluding None values."""
|
|
37
|
+
result = {}
|
|
38
|
+
if self.start_date:
|
|
39
|
+
result["start_date"] = self.start_date
|
|
40
|
+
if self.price_per_month_eur is not None:
|
|
41
|
+
result["price_per_month_eur"] = self.price_per_month_eur
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Product:
|
|
47
|
+
"""Represents a telecom product (mobile or internet)."""
|
|
48
|
+
product_id: str
|
|
49
|
+
product_type: str
|
|
50
|
+
phone_number: Optional[str] = None
|
|
51
|
+
easy_switch_number: Optional[str] = None
|
|
52
|
+
tariff: Optional[str] = None
|
|
53
|
+
contract: Optional[Contract] = None
|
|
54
|
+
usage: Optional[UsageData] = None
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
57
|
+
"""Convert to dictionary, excluding None values."""
|
|
58
|
+
result = {
|
|
59
|
+
"id": self.product_id,
|
|
60
|
+
"type": self.product_type
|
|
61
|
+
}
|
|
62
|
+
if self.phone_number:
|
|
63
|
+
result["phone_number"] = self.phone_number
|
|
64
|
+
if self.easy_switch_number:
|
|
65
|
+
result["easy_switch_number"] = self.easy_switch_number
|
|
66
|
+
if self.tariff:
|
|
67
|
+
result["tariff"] = self.tariff
|
|
68
|
+
if self.contract:
|
|
69
|
+
contract_dict = self.contract.to_dict()
|
|
70
|
+
if contract_dict:
|
|
71
|
+
result["contract"] = contract_dict
|
|
72
|
+
if self.usage:
|
|
73
|
+
usage_dict = self.usage.to_dict()
|
|
74
|
+
if usage_dict:
|
|
75
|
+
result["usage"] = usage_dict
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Invoice:
|
|
81
|
+
"""Represents an invoice."""
|
|
82
|
+
invoice_id: Optional[str] = None
|
|
83
|
+
amount_eur: Optional[float] = None
|
|
84
|
+
status: Optional[str] = None
|
|
85
|
+
paid: bool = False
|
|
86
|
+
date: Optional[str] = None
|
|
87
|
+
due_date: Optional[str] = None
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
90
|
+
"""Convert to dictionary, excluding None values."""
|
|
91
|
+
result = {}
|
|
92
|
+
if self.invoice_id:
|
|
93
|
+
result["invoice_id"] = self.invoice_id
|
|
94
|
+
if self.amount_eur is not None:
|
|
95
|
+
result["amount_eur"] = self.amount_eur
|
|
96
|
+
if self.status:
|
|
97
|
+
result["status"] = self.status.lower()
|
|
98
|
+
if self.paid is not None:
|
|
99
|
+
result["paid"] = self.paid
|
|
100
|
+
if self.date:
|
|
101
|
+
result["date"] = self.date
|
|
102
|
+
if self.due_date:
|
|
103
|
+
result["due_date"] = self.due_date
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class AccountData:
|
|
109
|
+
"""Represents account-level data."""
|
|
110
|
+
provider: str = "hey!"
|
|
111
|
+
last_sync: Optional[str] = None
|
|
112
|
+
products: list[Product] = field(default_factory=list)
|
|
113
|
+
latest_invoice: Optional[Invoice] = None
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
116
|
+
"""Convert to dictionary format."""
|
|
117
|
+
result = {
|
|
118
|
+
"provider": self.provider,
|
|
119
|
+
"account": {
|
|
120
|
+
"last_sync": self.last_sync or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
121
|
+
},
|
|
122
|
+
"products": [product.to_dict() for product in self.products]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if self.latest_invoice:
|
|
126
|
+
invoice_dict = self.latest_invoice.to_dict()
|
|
127
|
+
if invoice_dict:
|
|
128
|
+
result["billing"] = {
|
|
129
|
+
"latest_invoice": invoice_dict
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result
|
heytelecom/parsers.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Parsing utilities for Hey Telecom data."""
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_data_amount(text):
|
|
7
|
+
"""Parse data amount like '2.25 GB' to numeric GB value."""
|
|
8
|
+
if not text:
|
|
9
|
+
return None
|
|
10
|
+
match = re.search(r'([\d.]+)\s*(GB|MB|TB)', text, re.IGNORECASE)
|
|
11
|
+
if match:
|
|
12
|
+
value = float(match.group(1))
|
|
13
|
+
unit = match.group(2).upper()
|
|
14
|
+
if unit == 'MB':
|
|
15
|
+
value = value / 1024 # Convert to GB
|
|
16
|
+
elif unit == 'TB':
|
|
17
|
+
value = value * 1024 # Convert to GB
|
|
18
|
+
return round(value, 2)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_price(text):
|
|
23
|
+
"""Parse price like '5 €/maand' to numeric value."""
|
|
24
|
+
if not text:
|
|
25
|
+
return None
|
|
26
|
+
match = re.search(r'([\d.]+)\s*€', text)
|
|
27
|
+
if match:
|
|
28
|
+
return float(match.group(1))
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_date(text):
|
|
33
|
+
"""Parse date like '04.04.2025' or '20/10/2025' to ISO format."""
|
|
34
|
+
if not text:
|
|
35
|
+
return None
|
|
36
|
+
# Try DD.MM.YYYY format
|
|
37
|
+
match = re.search(r'(\d{2})\.(\d{2})\.(\d{4})', text)
|
|
38
|
+
if match:
|
|
39
|
+
day, month, year = match.groups()
|
|
40
|
+
return f"{year}-{month}-{day}"
|
|
41
|
+
# Try DD/MM/YYYY format
|
|
42
|
+
match = re.search(r'(\d{2})/(\d{2})/(\d{4})', text)
|
|
43
|
+
if match:
|
|
44
|
+
day, month, year = match.groups()
|
|
45
|
+
return f"{year}-{month}-{day}"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_period(text):
|
|
50
|
+
"""Parse period like 'Van 11/10/2025 tot 11/11/2025' to start and end dates."""
|
|
51
|
+
if not text:
|
|
52
|
+
return None
|
|
53
|
+
match = re.search(r'(\d{2}/\d{2}/\d{4})\s*tot\s*(\d{2}/\d{2}/\d{4})', text)
|
|
54
|
+
if match:
|
|
55
|
+
start_str, end_str = match.groups()
|
|
56
|
+
return {
|
|
57
|
+
"start": parse_date(start_str),
|
|
58
|
+
"end": parse_date(end_str)
|
|
59
|
+
}
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_minutes(text):
|
|
64
|
+
"""Parse minutes like '5 minuten' to numeric value."""
|
|
65
|
+
if not text:
|
|
66
|
+
return None
|
|
67
|
+
match = re.search(r'([\d.]+)\s*min', text, re.IGNORECASE)
|
|
68
|
+
if match:
|
|
69
|
+
return float(match.group(1))
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_sms_count(text):
|
|
74
|
+
"""Parse SMS count like '0 sms/mms' to numeric value."""
|
|
75
|
+
if not text:
|
|
76
|
+
return None
|
|
77
|
+
match = re.search(r'([\d.]+)\s*sms', text, re.IGNORECASE)
|
|
78
|
+
if match:
|
|
79
|
+
return int(match.group(1))
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_last_update(text):
|
|
84
|
+
"""Parse last update like 'Laatste update : 03/11 17:54' to ISO datetime."""
|
|
85
|
+
if not text:
|
|
86
|
+
return None
|
|
87
|
+
match = re.search(r'(\d{2}/\d{2})\s*(\d{2}:\d{2})', text)
|
|
88
|
+
if match:
|
|
89
|
+
date_part, time_part = match.groups()
|
|
90
|
+
day, month = date_part.split('/')
|
|
91
|
+
# Assume current year
|
|
92
|
+
year = datetime.now().year
|
|
93
|
+
return f"{year}-{month}-{day}T{time_part}:00"
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_unlimited(text):
|
|
98
|
+
"""Check if a limit is unlimited."""
|
|
99
|
+
if not text:
|
|
100
|
+
return False
|
|
101
|
+
return 'onbeperkt' in text.lower() or 'unlimited' in text.lower()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: heytelecom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library for interacting with Hey Telecom accounts
|
|
5
|
+
Author-email: CoderMauro <mauro.druwel@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/maurodruwel/heytelecom
|
|
7
|
+
Project-URL: Repository, https://github.com/maurodruwel/heytelecom
|
|
8
|
+
Keywords: hey,telecom,hey!,belgium,mobile,internet
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: playwright
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# HeyTelecom Python Library
|
|
30
|
+
|
|
31
|
+
<img width="1280" height="668" alt="HeyTelecom Banner" src="https://github.com/user-attachments/assets/d141fb44-e5c3-48af-a2b0-42ba4d2c510f" />
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<img alt="PyPI" src="https://img.shields.io/pypi/v/heytelecom"/>
|
|
35
|
+
<img alt="Python Version" src="https://img.shields.io/pypi/pyversions/heytelecom"/>
|
|
36
|
+
<img alt="License" src="https://img.shields.io/github/license/MauroDruwel/HeyTelecom"/>
|
|
37
|
+
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/MauroDruwel/HeyTelecom"/>
|
|
38
|
+
<img alt="GitHub last commit" src="https://img.shields.io/github/last-commit/MauroDruwel/HeyTelecom"/>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<a href="#installation">Installation</a> -
|
|
43
|
+
<a href="#quick-start">Quick Start</a> -
|
|
44
|
+
<a href="#features">Features</a> -
|
|
45
|
+
<a href="#api-reference">API Reference</a> -
|
|
46
|
+
<a href="https://github.com/MauroDruwel/HeyTelecom/issues">Bug Reports</a>
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
A Python library for interacting with [Hey Telecom](https://ecare.heytelecom.be) (hey!) accounts using Playwright for web automation. Automatically extract your mobile/internet usage, invoices, and account information.
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- 🔐 Automatic login with session persistence
|
|
54
|
+
- 📱 Retrieve mobile and internet product information
|
|
55
|
+
- 📊 Get usage data (data, calls, SMS/MMS)
|
|
56
|
+
- 💰 Fetch invoice information
|
|
57
|
+
- 🎭 Built on Playwright for reliable web automation (headless only)
|
|
58
|
+
- 🔧 Object-oriented API for easy integration
|
|
59
|
+
- 🚀 Chromium browser with automatic dependency installation
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install heytelecom
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from heytelecom import HeyTelecomClient
|
|
71
|
+
|
|
72
|
+
with HeyTelecomClient(email="your@email.com", password="your_password") as client:
|
|
73
|
+
client.login()
|
|
74
|
+
account_data = client.get_account_data()
|
|
75
|
+
|
|
76
|
+
import json
|
|
77
|
+
print(json.dumps(account_data.to_dict(), indent=2))
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API Reference
|
|
81
|
+
|
|
82
|
+
### HeyTelecomClient
|
|
83
|
+
|
|
84
|
+
Main client class for interacting with Hey Telecom.
|
|
85
|
+
|
|
86
|
+
#### Constructor
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
HeyTelecomClient(
|
|
90
|
+
email: Optional[str] = None,
|
|
91
|
+
password: Optional[str] = None,
|
|
92
|
+
user_data_dir: str = "hey_browser_data",
|
|
93
|
+
auto_install: bool = True
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Parameters:**
|
|
98
|
+
- `email`: Email address for login (optional if using saved session)
|
|
99
|
+
- `password`: Password for login (optional if using saved session)
|
|
100
|
+
- `user_data_dir`: Directory to store browser session data (default: "hey_browser_data")
|
|
101
|
+
- `auto_install`: Automatically install Playwright chromium if not found (default: True)
|
|
102
|
+
|
|
103
|
+
**Note:** Browser always runs in headless mode (no GUI) for reliable automation.
|
|
104
|
+
|
|
105
|
+
**Auto-Installation:**
|
|
106
|
+
When `auto_install=True` (default), the client automatically checks if Playwright chromium is installed when connecting. The check works by attempting to launch chromium - if it fails, the installer runs `playwright install chromium --with-deps --only-shell`. Set `auto_install=False` to disable this behavior and handle installation manually.
|
|
107
|
+
|
|
108
|
+
#### Methods
|
|
109
|
+
|
|
110
|
+
- `login()`: Login to Hey Telecom account
|
|
111
|
+
- `get_products()`: Get list of all products (mobile and internet)
|
|
112
|
+
- `get_latest_invoice()`: Get the latest invoice
|
|
113
|
+
- `get_account_data()`: Get complete account data including products and invoice
|
|
114
|
+
|
|
115
|
+
### Data Models
|
|
116
|
+
|
|
117
|
+
#### Product
|
|
118
|
+
- `product_id`: Unique product identifier
|
|
119
|
+
- `product_type`: Type of product ("mobile" or "internet")
|
|
120
|
+
- `phone_number`: Phone number (for mobile products)
|
|
121
|
+
- `easy_switch_number`: Easy Switch number (for internet products)
|
|
122
|
+
- `tariff`: Tariff/plan name
|
|
123
|
+
- `contract`: Contract information (Contract object)
|
|
124
|
+
- `usage`: Usage data (UsageData object)
|
|
125
|
+
|
|
126
|
+
#### Contract
|
|
127
|
+
- `start_date`: Contract start date (ISO format)
|
|
128
|
+
- `price_per_month_eur`: Monthly price in EUR
|
|
129
|
+
|
|
130
|
+
#### UsageData
|
|
131
|
+
- `period`: Billing period with start and end dates
|
|
132
|
+
- `data`: Data usage information (used, limit, unlimited)
|
|
133
|
+
- `calls`: Call usage information (used, unlimited)
|
|
134
|
+
- `sms_mms`: SMS/MMS usage information (used, unlimited)
|
|
135
|
+
|
|
136
|
+
#### Invoice
|
|
137
|
+
- `invoice_id`: Invoice identifier
|
|
138
|
+
- `amount_eur`: Invoice amount in EUR
|
|
139
|
+
- `status`: Invoice status
|
|
140
|
+
- `paid`: Whether invoice is paid (boolean)
|
|
141
|
+
- `date`: Invoice date (ISO format)
|
|
142
|
+
- `due_date`: Invoice due date (ISO format)
|
|
143
|
+
|
|
144
|
+
#### AccountData
|
|
145
|
+
- `provider`: Provider name (always "hey!")
|
|
146
|
+
- `last_sync`: Last synchronization timestamp
|
|
147
|
+
- `products`: List of Product objects
|
|
148
|
+
- `latest_invoice`: Latest Invoice object
|
|
149
|
+
|
|
150
|
+
## Example Output
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"provider": "hey!",
|
|
155
|
+
"account": {
|
|
156
|
+
"last_sync": "2025-11-09T15:30:00"
|
|
157
|
+
},
|
|
158
|
+
"products": [
|
|
159
|
+
{
|
|
160
|
+
"id": "mobile_0412345678",
|
|
161
|
+
"type": "mobile",
|
|
162
|
+
"phone_number": "0412 34 56 78",
|
|
163
|
+
"tariff": "Hey! Mobile Plus",
|
|
164
|
+
"contract": {
|
|
165
|
+
"start_date": "2024-01-15",
|
|
166
|
+
"price_per_month_eur": 15.0
|
|
167
|
+
},
|
|
168
|
+
"usage": {
|
|
169
|
+
"period": {
|
|
170
|
+
"start": "2025-10-11",
|
|
171
|
+
"end": "2025-11-11"
|
|
172
|
+
},
|
|
173
|
+
"data": {
|
|
174
|
+
"used": 2.5,
|
|
175
|
+
"limit": 10.0,
|
|
176
|
+
"unlimited": false,
|
|
177
|
+
"last_update": "2025-11-09T14:30:00"
|
|
178
|
+
},
|
|
179
|
+
"calls": {
|
|
180
|
+
"used": 45.0,
|
|
181
|
+
"unlimited": true,
|
|
182
|
+
"last_update": "2025-11-09T14:30:00"
|
|
183
|
+
},
|
|
184
|
+
"sms_mms": {
|
|
185
|
+
"used": 12,
|
|
186
|
+
"unlimited": true,
|
|
187
|
+
"last_update": "2025-11-09T14:30:00"
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
"billing": {
|
|
193
|
+
"latest_invoice": {
|
|
194
|
+
"invoice_id": "INV-20251101",
|
|
195
|
+
"amount_eur": 15.0,
|
|
196
|
+
"status": "betaald",
|
|
197
|
+
"paid": true,
|
|
198
|
+
"date": "2025-11-01",
|
|
199
|
+
"due_date": "2025-11-15"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Project Structure
|
|
206
|
+
|
|
207
|
+
```
|
|
208
|
+
heytestdev/
|
|
209
|
+
├── src/
|
|
210
|
+
│ └── heytelecom/
|
|
211
|
+
│ ├── __init__.py # Package initialization and exports
|
|
212
|
+
│ ├── client.py # Main HeyTelecomClient class
|
|
213
|
+
│ ├── models.py # Data models (Product, Invoice, etc.)
|
|
214
|
+
│ ├── parsers.py # Parsing utilities
|
|
215
|
+
│ └── installer.py # Playwright installation utilities
|
|
216
|
+
├── README.md # This file
|
|
217
|
+
├── pyproject.toml # Package configuration
|
|
218
|
+
└── .gitignore # Git ignore rules
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Requirements
|
|
222
|
+
|
|
223
|
+
- Python 3.8+
|
|
224
|
+
- playwright
|
|
225
|
+
|
|
226
|
+
## Advanced Usage
|
|
227
|
+
|
|
228
|
+
### Using Saved Sessions
|
|
229
|
+
|
|
230
|
+
After the first login, the browser session is saved. You can use the library without credentials:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from heytelecom import HeyTelecomClient
|
|
234
|
+
|
|
235
|
+
with HeyTelecomClient() as client:
|
|
236
|
+
account_data = client.get_account_data()
|
|
237
|
+
print(f"Found {len(account_data.products)} products")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Getting Specific Data
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from heytelecom import HeyTelecomClient
|
|
244
|
+
|
|
245
|
+
with HeyTelecomClient() as client:
|
|
246
|
+
# Get only products
|
|
247
|
+
products = client.get_products()
|
|
248
|
+
for product in products:
|
|
249
|
+
print(f"Product: {product.tariff}")
|
|
250
|
+
print(f"Type: {product.product_type}")
|
|
251
|
+
if product.usage:
|
|
252
|
+
print(f"Usage: {product.usage.to_dict()}")
|
|
253
|
+
|
|
254
|
+
# Get only latest invoice
|
|
255
|
+
invoice = client.get_latest_invoice()
|
|
256
|
+
if invoice:
|
|
257
|
+
print(f"Invoice: {invoice.amount_eur} EUR")
|
|
258
|
+
print(f"Status: {invoice.status}")
|
|
259
|
+
print(f"Paid: {invoice.paid}")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Manual Playwright Installation
|
|
263
|
+
|
|
264
|
+
Playwright chromium is automatically installed on first use. If you prefer manual installation:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Manual installation (optional)
|
|
268
|
+
playwright install chromium --with-deps --only-shell
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Or use the built-in installer:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
from heytelecom import ensure_playwright_installed
|
|
275
|
+
ensure_playwright_installed()
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Disabling Auto-Installation
|
|
279
|
+
|
|
280
|
+
If you prefer to handle Playwright installation manually:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from heytelecom import HeyTelecomClient
|
|
284
|
+
|
|
285
|
+
with HeyTelecomClient(auto_install=False) as client:
|
|
286
|
+
account_data = client.get_account_data()
|
|
287
|
+
print(account_data.to_dict())
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Contributing
|
|
291
|
+
|
|
292
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT License
|
|
297
|
+
|
|
298
|
+
## Support
|
|
299
|
+
|
|
300
|
+
For issues and questions, please open an issue on the GitHub repository.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
heytelecom/__init__.py,sha256=TjZMEikcnt3dvPbi01mEJI9aIP4Dc75anvjv1-obU0I,498
|
|
2
|
+
heytelecom/client.py,sha256=TJFECovbdwM5p-6B7NVMszUslbjWhrB0bTmELGoOuw4,19978
|
|
3
|
+
heytelecom/installer.py,sha256=RcfcoDjm5xpTZxR7RYkHXCDwGaMMJRUvPZgDHbfuoj8,2004
|
|
4
|
+
heytelecom/models.py,sha256=x5LtT_Vrzm_QXVw77WAwlpfVUFkX085ekrIwZ-a8zqM,4125
|
|
5
|
+
heytelecom/parsers.py,sha256=0BHxLwB3xiETmKv_DDf7VmY4OGsWK1PMKNhGwNMsE_k,2858
|
|
6
|
+
heytelecom-0.1.0.dist-info/licenses/LICENSE,sha256=tBT_MoFfp4ubN-UVljOcouWni-9sAh_lEpaUBK3_Fg4,1069
|
|
7
|
+
heytelecom-0.1.0.dist-info/METADATA,sha256=zSB_iIPL7YzBQJKRMvgZ_xL8KwJ4OFEX9TC6slIbXzI,9068
|
|
8
|
+
heytelecom-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
heytelecom-0.1.0.dist-info/entry_points.txt,sha256=E8fXx94HXplLQus2KthwqUC-f6-SqWjzYJDfznLp0i4,65
|
|
10
|
+
heytelecom-0.1.0.dist-info/top_level.txt,sha256=478u-02JpuoYZjP0Gg6wbKoxPt8TG8ZiE0c_uMGp2sY,11
|
|
11
|
+
heytelecom-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mauro Druwel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
heytelecom
|