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 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
+ )
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ heytelecom-install = heytelecom.installer:main
@@ -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