switch2 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {switch2-0.1.0 → switch2-0.1.2}/PKG-INFO +1 -1
- {switch2-0.1.0 → switch2-0.1.2}/pyproject.toml +1 -1
- {switch2-0.1.0 → switch2-0.1.2}/switch2/__init__.py +6 -0
- switch2-0.1.2/switch2/api.py +566 -0
- {switch2-0.1.0 → switch2-0.1.2}/switch2.egg-info/PKG-INFO +1 -1
- switch2-0.1.2/tests/test_api.py +393 -0
- switch2-0.1.0/switch2/api.py +0 -289
- switch2-0.1.0/tests/test_api.py +0 -148
- {switch2-0.1.0 → switch2-0.1.2}/README.md +0 -0
- {switch2-0.1.0 → switch2-0.1.2}/setup.cfg +0 -0
- {switch2-0.1.0 → switch2-0.1.2}/switch2.egg-info/SOURCES.txt +0 -0
- {switch2-0.1.0 → switch2-0.1.2}/switch2.egg-info/dependency_links.txt +0 -0
- {switch2-0.1.0 → switch2-0.1.2}/switch2.egg-info/requires.txt +0 -0
- {switch2-0.1.0 → switch2-0.1.2}/switch2.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"""Standalone client library for the Switch2 energy portal."""
|
|
2
2
|
|
|
3
3
|
from .api import (
|
|
4
|
+
AccountBalance,
|
|
4
5
|
Bill,
|
|
6
|
+
BillCharge,
|
|
7
|
+
BillDetail,
|
|
5
8
|
CustomerInfo,
|
|
6
9
|
MeterReading,
|
|
7
10
|
Switch2ApiClient,
|
|
@@ -11,7 +14,10 @@ from .api import (
|
|
|
11
14
|
)
|
|
12
15
|
|
|
13
16
|
__all__ = [
|
|
17
|
+
"AccountBalance",
|
|
14
18
|
"Bill",
|
|
19
|
+
"BillCharge",
|
|
20
|
+
"BillDetail",
|
|
15
21
|
"CustomerInfo",
|
|
16
22
|
"MeterReading",
|
|
17
23
|
"Switch2ApiClient",
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""API client for the Switch2 energy portal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
from bs4 import BeautifulSoup, Tag
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://my.switch2.co.uk"
|
|
15
|
+
LOGIN_URL = f"{BASE_URL}/Login"
|
|
16
|
+
METER_HISTORY_URL = f"{BASE_URL}/MeterReadings/History"
|
|
17
|
+
BILL_HISTORY_URL = f"{BASE_URL}/Credit/BillHistory"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Switch2AuthError(Exception):
|
|
21
|
+
"""Raised when authentication fails."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Switch2ConnectionError(Exception):
|
|
25
|
+
"""Raised when a connection error occurs."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CustomerInfo:
|
|
30
|
+
"""Customer information from the Switch2 portal."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
account_number: str
|
|
34
|
+
address: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MeterReading:
|
|
39
|
+
"""A single meter reading."""
|
|
40
|
+
|
|
41
|
+
date: datetime
|
|
42
|
+
amount: float
|
|
43
|
+
unit: str
|
|
44
|
+
reading_type: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Bill:
|
|
49
|
+
"""A single bill."""
|
|
50
|
+
|
|
51
|
+
date: datetime
|
|
52
|
+
amount: float
|
|
53
|
+
detail_url: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class BillCharge:
|
|
58
|
+
"""A single line item on a bill."""
|
|
59
|
+
|
|
60
|
+
description: str
|
|
61
|
+
units: str
|
|
62
|
+
charge: float
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class BillDetail:
|
|
67
|
+
"""Detailed breakdown of a bill."""
|
|
68
|
+
|
|
69
|
+
invoice_number: str
|
|
70
|
+
date_of_issue: datetime
|
|
71
|
+
period_from: datetime
|
|
72
|
+
period_to: datetime
|
|
73
|
+
consumption_charges: list[BillCharge]
|
|
74
|
+
other_charges: list[BillCharge]
|
|
75
|
+
total_excl_vat: float
|
|
76
|
+
vat: float
|
|
77
|
+
total: float
|
|
78
|
+
previous_balance: float
|
|
79
|
+
payments_received: float
|
|
80
|
+
balance: float
|
|
81
|
+
download_url: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AccountBalance:
|
|
86
|
+
"""Current account balance from the Switch2 portal."""
|
|
87
|
+
|
|
88
|
+
balance: float
|
|
89
|
+
last_updated: datetime
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Switch2Data:
|
|
94
|
+
"""All data fetched from the Switch2 portal."""
|
|
95
|
+
|
|
96
|
+
customer: CustomerInfo
|
|
97
|
+
readings: list[MeterReading]
|
|
98
|
+
registers: dict[str, str] # register_id -> register_name
|
|
99
|
+
bills: list[Bill]
|
|
100
|
+
account_balance: AccountBalance | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_attr(tag: Tag, attr: str, default: str = "") -> str:
|
|
104
|
+
"""Get a string attribute from a BeautifulSoup tag, handling list values."""
|
|
105
|
+
value = tag.get(attr, default)
|
|
106
|
+
if isinstance(value, list):
|
|
107
|
+
return str(value[0]) if value else default
|
|
108
|
+
return str(value) if value is not None else default
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Switch2ApiClient:
|
|
112
|
+
"""Client to interact with the Switch2 web portal."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, email: str, password: str) -> None:
|
|
115
|
+
self._email = email
|
|
116
|
+
self._password = password
|
|
117
|
+
self._session: aiohttp.ClientSession | None = None
|
|
118
|
+
|
|
119
|
+
async def _ensure_session(self) -> aiohttp.ClientSession:
|
|
120
|
+
if self._session is None or self._session.closed:
|
|
121
|
+
self._session = aiohttp.ClientSession()
|
|
122
|
+
return self._session
|
|
123
|
+
|
|
124
|
+
async def close(self) -> None:
|
|
125
|
+
"""Close the underlying HTTP session."""
|
|
126
|
+
if self._session and not self._session.closed:
|
|
127
|
+
await self._session.close()
|
|
128
|
+
|
|
129
|
+
async def authenticate(self) -> tuple[CustomerInfo, BeautifulSoup]:
|
|
130
|
+
"""Log in to the Switch2 portal and return customer info and dashboard soup."""
|
|
131
|
+
session = await self._ensure_session()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
# Step 1: GET the login page to get any CSRF tokens
|
|
135
|
+
async with session.get(LOGIN_URL) as resp:
|
|
136
|
+
if resp.status != 200:
|
|
137
|
+
raise Switch2ConnectionError(
|
|
138
|
+
f"Failed to load login page: HTTP {resp.status}"
|
|
139
|
+
)
|
|
140
|
+
html = await resp.text()
|
|
141
|
+
|
|
142
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
143
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
144
|
+
form_data: dict[str, str] = {}
|
|
145
|
+
if token_input:
|
|
146
|
+
form_data["__RequestVerificationToken"] = _get_attr(
|
|
147
|
+
token_input, "value"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Step 2: POST login credentials
|
|
151
|
+
form_data["UserName"] = self._email
|
|
152
|
+
form_data["Password"] = self._password
|
|
153
|
+
|
|
154
|
+
async with session.post(
|
|
155
|
+
LOGIN_URL, data=form_data, allow_redirects=True
|
|
156
|
+
) as resp:
|
|
157
|
+
if resp.status != 200:
|
|
158
|
+
raise Switch2ConnectionError(
|
|
159
|
+
f"Login request failed: HTTP {resp.status}"
|
|
160
|
+
)
|
|
161
|
+
html = await resp.text()
|
|
162
|
+
|
|
163
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
164
|
+
customer = _parse_customer_info(soup)
|
|
165
|
+
if not customer.name:
|
|
166
|
+
raise Switch2AuthError("Login failed: no customer info returned")
|
|
167
|
+
|
|
168
|
+
return customer, soup
|
|
169
|
+
|
|
170
|
+
except aiohttp.ClientError as err:
|
|
171
|
+
raise Switch2ConnectionError(
|
|
172
|
+
f"Connection error during login: {err}"
|
|
173
|
+
) from err
|
|
174
|
+
|
|
175
|
+
async def fetch_bill_detail(self, bill: Bill) -> BillDetail:
|
|
176
|
+
"""Fetch and parse the detail page for a bill.
|
|
177
|
+
|
|
178
|
+
The caller must be authenticated before calling this method.
|
|
179
|
+
"""
|
|
180
|
+
if not bill.detail_url:
|
|
181
|
+
raise ValueError("Bill has no detail URL")
|
|
182
|
+
session = await self._ensure_session()
|
|
183
|
+
try:
|
|
184
|
+
async with session.get(bill.detail_url) as resp:
|
|
185
|
+
if resp.status != 200:
|
|
186
|
+
raise Switch2ConnectionError(
|
|
187
|
+
f"Failed to load bill detail: HTTP {resp.status}"
|
|
188
|
+
)
|
|
189
|
+
html = await resp.text()
|
|
190
|
+
except aiohttp.ClientError as err:
|
|
191
|
+
raise Switch2ConnectionError(
|
|
192
|
+
f"Connection error fetching bill detail: {err}"
|
|
193
|
+
) from err
|
|
194
|
+
return _parse_bill_detail(BeautifulSoup(html, "html.parser"))
|
|
195
|
+
|
|
196
|
+
async def fetch_data(self) -> Switch2Data:
|
|
197
|
+
"""Authenticate and fetch all meter data."""
|
|
198
|
+
customer, dashboard_soup = await self.authenticate()
|
|
199
|
+
account_balance = _parse_account_balance(dashboard_soup)
|
|
200
|
+
session = await self._ensure_session()
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# GET meter readings history page
|
|
204
|
+
async with session.get(METER_HISTORY_URL) as resp:
|
|
205
|
+
if resp.status != 200:
|
|
206
|
+
raise Switch2ConnectionError(
|
|
207
|
+
f"Failed to load meter history: HTTP {resp.status}"
|
|
208
|
+
)
|
|
209
|
+
html = await resp.text()
|
|
210
|
+
|
|
211
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
212
|
+
|
|
213
|
+
# Parse available registers
|
|
214
|
+
registers: dict[str, str] = {}
|
|
215
|
+
register_select = soup.select_one("#RegisterId")
|
|
216
|
+
if register_select:
|
|
217
|
+
for option in register_select.select("option"):
|
|
218
|
+
reg_id = option.get("value", "")
|
|
219
|
+
if reg_id:
|
|
220
|
+
registers[str(reg_id)] = option.text.strip()
|
|
221
|
+
|
|
222
|
+
# Try to get all readings by POSTing with a large page size
|
|
223
|
+
token_input = soup.select_one('input[name="__RequestVerificationToken"]')
|
|
224
|
+
selected_register = soup.select_one("#RegisterId option[selected]")
|
|
225
|
+
|
|
226
|
+
if token_input and selected_register:
|
|
227
|
+
form_data = {
|
|
228
|
+
"__RequestVerificationToken": _get_attr(token_input, "value"),
|
|
229
|
+
"Page": "1",
|
|
230
|
+
"TotalPages": "1",
|
|
231
|
+
"RegisterId": _get_attr(selected_register, "value"),
|
|
232
|
+
"PageSize": "1000000",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async with session.post(
|
|
236
|
+
METER_HISTORY_URL, data=form_data, allow_redirects=True
|
|
237
|
+
) as resp:
|
|
238
|
+
if resp.status == 200:
|
|
239
|
+
html = await resp.text()
|
|
240
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
241
|
+
|
|
242
|
+
readings = _parse_readings(soup)
|
|
243
|
+
|
|
244
|
+
# Fetch bill history
|
|
245
|
+
async with session.get(BILL_HISTORY_URL) as resp:
|
|
246
|
+
if resp.status != 200:
|
|
247
|
+
raise Switch2ConnectionError(
|
|
248
|
+
f"Failed to load bill history: HTTP {resp.status}"
|
|
249
|
+
)
|
|
250
|
+
html = await resp.text()
|
|
251
|
+
|
|
252
|
+
bills = _parse_bills(BeautifulSoup(html, "html.parser"))
|
|
253
|
+
|
|
254
|
+
return Switch2Data(
|
|
255
|
+
customer=customer,
|
|
256
|
+
readings=readings,
|
|
257
|
+
registers=registers,
|
|
258
|
+
bills=bills,
|
|
259
|
+
account_balance=account_balance,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
except aiohttp.ClientError as err:
|
|
263
|
+
raise Switch2ConnectionError(
|
|
264
|
+
f"Connection error fetching data: {err}"
|
|
265
|
+
) from err
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_date(text: str) -> datetime:
|
|
269
|
+
"""Parse a date string like '27th February 2026'."""
|
|
270
|
+
# Strip ordinal suffixes (1st, 2nd, 3rd, 4th, etc.)
|
|
271
|
+
for suffix in ("st ", "nd ", "rd ", "th "):
|
|
272
|
+
text = text.replace(suffix, " ")
|
|
273
|
+
for fmt in ("%d %B %Y", "%d %b %Y"):
|
|
274
|
+
try:
|
|
275
|
+
return datetime.strptime(text, fmt)
|
|
276
|
+
except ValueError:
|
|
277
|
+
continue
|
|
278
|
+
raise ValueError(f"Cannot parse date: {text!r}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _parse_customer_info(soup: BeautifulSoup) -> CustomerInfo:
|
|
282
|
+
"""Extract customer info from the dashboard page."""
|
|
283
|
+
name_el = soup.select_one(".customer-info-name")
|
|
284
|
+
acn_el = soup.select_one(".customer-info-account-number")
|
|
285
|
+
addr_el = soup.select_one(".customer-info-address")
|
|
286
|
+
|
|
287
|
+
return CustomerInfo(
|
|
288
|
+
name=name_el.text.strip() if name_el else "",
|
|
289
|
+
account_number=acn_el.text.strip() if acn_el else "",
|
|
290
|
+
address=addr_el.text.strip() if addr_el else "",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_readings(soup: BeautifulSoup) -> list[MeterReading]:
|
|
295
|
+
"""Extract meter readings from the history page."""
|
|
296
|
+
readings = []
|
|
297
|
+
rows = soup.select(".meter-reading-history-table-data-row.desktop-layout")
|
|
298
|
+
for row in rows:
|
|
299
|
+
date_el = row.select_one(".meter-reading-history-table-data-date-row-item")
|
|
300
|
+
amount_el = row.select_one(".meter-reading-history-table-data-amount-row-item")
|
|
301
|
+
type_el = row.select_one(".meter-reading-history-table-data-type-row-item")
|
|
302
|
+
if date_el and amount_el:
|
|
303
|
+
try:
|
|
304
|
+
date = _parse_date(date_el.text.strip())
|
|
305
|
+
# Parse amount and unit (e.g. "8551 kWh" -> 8551, "kWh")
|
|
306
|
+
amount_parts = amount_el.text.strip().split()
|
|
307
|
+
amount = float(amount_parts[0])
|
|
308
|
+
unit = amount_parts[1] if len(amount_parts) > 1 else ""
|
|
309
|
+
reading_type = type_el.text.strip() if type_el else ""
|
|
310
|
+
readings.append(
|
|
311
|
+
MeterReading(
|
|
312
|
+
date=date,
|
|
313
|
+
amount=amount,
|
|
314
|
+
unit=unit,
|
|
315
|
+
reading_type=reading_type,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
except (ValueError, TypeError) as err:
|
|
319
|
+
_LOGGER.debug("Failed to parse meter reading row: %s", err)
|
|
320
|
+
|
|
321
|
+
return readings
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_bills(soup: BeautifulSoup) -> list[Bill]:
|
|
325
|
+
"""Extract bills from the bill history page."""
|
|
326
|
+
bills = []
|
|
327
|
+
rows = soup.select(".bill-history-table-data-row")
|
|
328
|
+
for row in rows:
|
|
329
|
+
date_el = row.select_one(".bill-history-table-data-row-text-item")
|
|
330
|
+
amount_el = row.select_one(
|
|
331
|
+
".bill-history-table-data-row-item-right"
|
|
332
|
+
".bill-history-table-data-row-text-item"
|
|
333
|
+
)
|
|
334
|
+
link_el = row.select_one("a.bill-history-view-bill-button")
|
|
335
|
+
if date_el and amount_el:
|
|
336
|
+
try:
|
|
337
|
+
date = _parse_date(date_el.text.strip())
|
|
338
|
+
# Parse amount (e.g. "£172.26" -> 172.26)
|
|
339
|
+
amount_text = amount_el.text.strip().lstrip("£").replace(",", "")
|
|
340
|
+
amount = float(amount_text)
|
|
341
|
+
detail_url = ""
|
|
342
|
+
if link_el:
|
|
343
|
+
href = _get_attr(link_el, "href")
|
|
344
|
+
detail_url = f"{BASE_URL}{href}" if href else ""
|
|
345
|
+
bills.append(Bill(date=date, amount=amount, detail_url=detail_url))
|
|
346
|
+
except (ValueError, TypeError) as err:
|
|
347
|
+
_LOGGER.debug("Failed to parse bill row: %s", err)
|
|
348
|
+
|
|
349
|
+
return bills
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _parse_currency(text: str) -> float:
|
|
353
|
+
"""Parse a currency string like '£172.26' or '-£205.13'."""
|
|
354
|
+
text = text.strip()
|
|
355
|
+
negative = text.startswith("-")
|
|
356
|
+
text = text.lstrip("-").lstrip("£").lstrip("\xa3").replace(",", "")
|
|
357
|
+
value = float(text)
|
|
358
|
+
return -value if negative else value
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parse_account_balance(soup: BeautifulSoup) -> AccountBalance | None:
|
|
362
|
+
"""Extract the current account balance from the dashboard page."""
|
|
363
|
+
amount_el = soup.select_one(".dashboard-credit-amount-desktop")
|
|
364
|
+
if not amount_el:
|
|
365
|
+
return None
|
|
366
|
+
balance = _parse_currency(amount_el.text)
|
|
367
|
+
updated_el = soup.select_one(".dashboard-credit-lastUpdated")
|
|
368
|
+
if not updated_el:
|
|
369
|
+
return None
|
|
370
|
+
updated_text = updated_el.text.strip()
|
|
371
|
+
# Text is like "Last updated 27/02/2026 10:13"
|
|
372
|
+
if updated_text.lower().startswith("last updated"):
|
|
373
|
+
updated_text = updated_text[len("last updated") :].strip()
|
|
374
|
+
last_updated = datetime.strptime(updated_text, "%d/%m/%Y %H:%M")
|
|
375
|
+
return AccountBalance(balance=balance, last_updated=last_updated)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _parse_bill_charges(
|
|
379
|
+
soup: BeautifulSoup, container_selector: str
|
|
380
|
+
) -> list[BillCharge]:
|
|
381
|
+
"""Extract charge line items from desktop-layout rows."""
|
|
382
|
+
charges: list[BillCharge] = []
|
|
383
|
+
rows = soup.select(f"{container_selector} .bill-table-row-desktop .bill-table-row")
|
|
384
|
+
for row in rows:
|
|
385
|
+
desc_el = row.select_one(".bill-table-row-item-left")
|
|
386
|
+
units_el = row.select_one(".bill-table-row-item")
|
|
387
|
+
charge_el = row.select_one(".bill-table-row-item-right")
|
|
388
|
+
if desc_el and charge_el:
|
|
389
|
+
charges.append(
|
|
390
|
+
BillCharge(
|
|
391
|
+
description=desc_el.text.strip(),
|
|
392
|
+
units=units_el.text.strip() if units_el else "",
|
|
393
|
+
charge=_parse_currency(charge_el.text),
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
return charges
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _parse_bill_detail(soup: BeautifulSoup) -> BillDetail:
|
|
400
|
+
"""Extract bill detail from the bill detail page."""
|
|
401
|
+
# Header: invoice number and date of issue
|
|
402
|
+
header_rows = soup.select(".bill-header-row")
|
|
403
|
+
invoice_number = ""
|
|
404
|
+
date_of_issue_text = ""
|
|
405
|
+
for row in header_rows:
|
|
406
|
+
label_el = row.select_one(".bill-header-row-item")
|
|
407
|
+
value_el = row.select_one(".bill-header-row-item-right")
|
|
408
|
+
if label_el and value_el:
|
|
409
|
+
label = label_el.text.strip().rstrip(":")
|
|
410
|
+
if label == "Invoice Number":
|
|
411
|
+
invoice_number = value_el.text.strip()
|
|
412
|
+
elif label == "Date of issue":
|
|
413
|
+
date_of_issue_text = value_el.text.strip()
|
|
414
|
+
|
|
415
|
+
date_of_issue = _parse_date(date_of_issue_text)
|
|
416
|
+
|
|
417
|
+
# Period: from/to dates
|
|
418
|
+
from_el = soup.select_one(".bill-table-row-item-left")
|
|
419
|
+
to_el = soup.select_one(".bill-table-row-item-dateto")
|
|
420
|
+
period_from_text = ""
|
|
421
|
+
period_to_text = ""
|
|
422
|
+
if from_el:
|
|
423
|
+
# Text is like "From: 27th January 2026"
|
|
424
|
+
period_from_text = from_el.text.strip()
|
|
425
|
+
if period_from_text.lower().startswith("from:"):
|
|
426
|
+
period_from_text = period_from_text[5:].strip()
|
|
427
|
+
if to_el:
|
|
428
|
+
period_to_text = to_el.text.strip()
|
|
429
|
+
if period_to_text.lower().startswith("to:"):
|
|
430
|
+
period_to_text = period_to_text[3:].strip()
|
|
431
|
+
|
|
432
|
+
period_from = _parse_date(period_from_text)
|
|
433
|
+
period_to = _parse_date(period_to_text)
|
|
434
|
+
|
|
435
|
+
# Consumption charges (desktop layout rows within BillItemsContainer)
|
|
436
|
+
consumption_charges = _parse_bill_charges(
|
|
437
|
+
soup, "#BillItemsContainer > .bill-table-row:first-of-type"
|
|
438
|
+
)
|
|
439
|
+
# If the CSS child selector doesn't match, fall back to looking at
|
|
440
|
+
# desktop rows that come before .other-charges-table-row
|
|
441
|
+
if not consumption_charges:
|
|
442
|
+
items_container = soup.select_one("#BillItemsContainer")
|
|
443
|
+
if items_container:
|
|
444
|
+
consumption_rows: list[Tag] = []
|
|
445
|
+
for child in items_container.children:
|
|
446
|
+
if not isinstance(child, Tag):
|
|
447
|
+
continue
|
|
448
|
+
if "other-charges-table-row" in child.get("class", []):
|
|
449
|
+
break
|
|
450
|
+
desktop = child.select_one(".bill-table-row-desktop .bill-table-row")
|
|
451
|
+
if desktop:
|
|
452
|
+
consumption_rows.append(desktop)
|
|
453
|
+
elif "bill-table-row-desktop" in child.get("class", []):
|
|
454
|
+
inner = child.select_one(".bill-table-row")
|
|
455
|
+
if inner:
|
|
456
|
+
consumption_rows.append(inner)
|
|
457
|
+
for row in consumption_rows:
|
|
458
|
+
desc_el = row.select_one(".bill-table-row-item-left")
|
|
459
|
+
units_el = row.select_one(".bill-table-row-item")
|
|
460
|
+
charge_el = row.select_one(".bill-table-row-item-right")
|
|
461
|
+
if desc_el and charge_el:
|
|
462
|
+
consumption_charges.append(
|
|
463
|
+
BillCharge(
|
|
464
|
+
description=desc_el.text.strip(),
|
|
465
|
+
units=(units_el.text.strip() if units_el else ""),
|
|
466
|
+
charge=_parse_currency(charge_el.text),
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# Other charges
|
|
471
|
+
other_charges: list[BillCharge] = []
|
|
472
|
+
other_header = soup.select_one(".other-charges-table-row")
|
|
473
|
+
if other_header:
|
|
474
|
+
sibling = other_header.next_sibling
|
|
475
|
+
while sibling:
|
|
476
|
+
if isinstance(sibling, Tag):
|
|
477
|
+
if "bill-table-row-desktop" in sibling.get("class", []):
|
|
478
|
+
charge_row = sibling.select_one(".bill-table-row")
|
|
479
|
+
if charge_row:
|
|
480
|
+
desc_el = charge_row.select_one(".bill-table-row-item-left")
|
|
481
|
+
units_el = charge_row.select_one(".bill-table-row-item")
|
|
482
|
+
charge_el = charge_row.select_one(".bill-table-row-item-right")
|
|
483
|
+
if desc_el and charge_el:
|
|
484
|
+
other_charges.append(
|
|
485
|
+
BillCharge(
|
|
486
|
+
description=desc_el.text.strip(),
|
|
487
|
+
units=(units_el.text.strip() if units_el else ""),
|
|
488
|
+
charge=_parse_currency(charge_el.text),
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
elif "bill-table-row-narrow" not in sibling.get("class", []):
|
|
492
|
+
break
|
|
493
|
+
sibling = sibling.next_sibling
|
|
494
|
+
|
|
495
|
+
# Totals
|
|
496
|
+
totals_container = soup.select_one("#BillTotalsCollapsibleContent")
|
|
497
|
+
total_excl_vat = 0.0
|
|
498
|
+
vat = 0.0
|
|
499
|
+
total_rows = (
|
|
500
|
+
totals_container.select(".bill-total-table-row") if totals_container else []
|
|
501
|
+
)
|
|
502
|
+
for row in total_rows:
|
|
503
|
+
label_el = row.select_one(".bill-total-table-row-item-left")
|
|
504
|
+
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
505
|
+
if label_el and value_el:
|
|
506
|
+
label = label_el.text.strip()
|
|
507
|
+
if label.startswith("Total charges excluding VAT"):
|
|
508
|
+
total_excl_vat = _parse_currency(value_el.text)
|
|
509
|
+
elif label.startswith("VAT"):
|
|
510
|
+
vat = _parse_currency(value_el.text)
|
|
511
|
+
|
|
512
|
+
# Bill total from the collapsible header
|
|
513
|
+
total = 0.0
|
|
514
|
+
total_header = soup.select_one(
|
|
515
|
+
"#BillTotalsContainer > .collapsible-header .bill-total-table-row-item-right"
|
|
516
|
+
)
|
|
517
|
+
if total_header:
|
|
518
|
+
total = _parse_currency(total_header.text)
|
|
519
|
+
|
|
520
|
+
# Account balance
|
|
521
|
+
balance_container = soup.select_one("#AccountBalanceCollapsibleContent")
|
|
522
|
+
previous_balance = 0.0
|
|
523
|
+
payments_received = 0.0
|
|
524
|
+
balance_rows = (
|
|
525
|
+
balance_container.select(".bill-total-table-row") if balance_container else []
|
|
526
|
+
)
|
|
527
|
+
for row in balance_rows:
|
|
528
|
+
label_el = row.select_one(".bill-total-table-row-item-left")
|
|
529
|
+
value_el = row.select_one(".bill-total-table-row-item-right")
|
|
530
|
+
if label_el and value_el:
|
|
531
|
+
label = label_el.text.strip()
|
|
532
|
+
if label == "Previous account balance":
|
|
533
|
+
previous_balance = _parse_currency(value_el.text)
|
|
534
|
+
elif label == "Payments received":
|
|
535
|
+
payments_received = _parse_currency(value_el.text)
|
|
536
|
+
|
|
537
|
+
balance = 0.0
|
|
538
|
+
balance_header = soup.select_one(
|
|
539
|
+
"#AccountBalanceContainer > .collapsible-header"
|
|
540
|
+
" .bill-total-table-row-item-right"
|
|
541
|
+
)
|
|
542
|
+
if balance_header:
|
|
543
|
+
balance = _parse_currency(balance_header.text)
|
|
544
|
+
|
|
545
|
+
# Download URL
|
|
546
|
+
download_url = ""
|
|
547
|
+
download_el = soup.select_one("#DownloadBillButton")
|
|
548
|
+
if download_el:
|
|
549
|
+
href = _get_attr(download_el, "href")
|
|
550
|
+
download_url = f"{BASE_URL}{href}" if href else ""
|
|
551
|
+
|
|
552
|
+
return BillDetail(
|
|
553
|
+
invoice_number=invoice_number,
|
|
554
|
+
date_of_issue=date_of_issue,
|
|
555
|
+
period_from=period_from,
|
|
556
|
+
period_to=period_to,
|
|
557
|
+
consumption_charges=consumption_charges,
|
|
558
|
+
other_charges=other_charges,
|
|
559
|
+
total_excl_vat=total_excl_vat,
|
|
560
|
+
vat=vat,
|
|
561
|
+
total=total,
|
|
562
|
+
previous_balance=previous_balance,
|
|
563
|
+
payments_received=payments_received,
|
|
564
|
+
balance=balance,
|
|
565
|
+
download_url=download_url,
|
|
566
|
+
)
|