pyedookit 0.3.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.
- pyedookit/__init__.py +33 -0
- pyedookit/auth.py +385 -0
- pyedookit/client.py +107 -0
- pyedookit/exceptions.py +22 -0
- pyedookit/models.py +66 -0
- pyedookit/parsers/__init__.py +0 -0
- pyedookit/parsers/dates.py +29 -0
- pyedookit/parsers/events.py +84 -0
- pyedookit/parsers/fingerprint.py +26 -0
- pyedookit/parsers/payments.py +82 -0
- pyedookit/parsers/timetable.py +114 -0
- pyedookit/session.py +50 -0
- pyedookit-0.3.0.dist-info/METADATA +111 -0
- pyedookit-0.3.0.dist-info/RECORD +16 -0
- pyedookit-0.3.0.dist-info/WHEEL +4 -0
- pyedookit-0.3.0.dist-info/licenses/LICENSE +21 -0
pyedookit/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .auth import login, refresh
|
|
2
|
+
from .client import CAPTURE_PAGES, EdookitClient
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
AuthError,
|
|
5
|
+
EdookitConnectionError,
|
|
6
|
+
EdookitError,
|
|
7
|
+
ParseError,
|
|
8
|
+
SessionExpired,
|
|
9
|
+
)
|
|
10
|
+
from .models import PORTAL_TZ, Lesson, PaymentRequest, Payments, SchoolEvent, Student, Term
|
|
11
|
+
from .parsers.fingerprint import content_fingerprint
|
|
12
|
+
from .session import Session
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CAPTURE_PAGES",
|
|
16
|
+
"PORTAL_TZ",
|
|
17
|
+
"AuthError",
|
|
18
|
+
"EdookitClient",
|
|
19
|
+
"EdookitConnectionError",
|
|
20
|
+
"EdookitError",
|
|
21
|
+
"Lesson",
|
|
22
|
+
"ParseError",
|
|
23
|
+
"PaymentRequest",
|
|
24
|
+
"Payments",
|
|
25
|
+
"SchoolEvent",
|
|
26
|
+
"Session",
|
|
27
|
+
"SessionExpired",
|
|
28
|
+
"Student",
|
|
29
|
+
"Term",
|
|
30
|
+
"content_fingerprint",
|
|
31
|
+
"login",
|
|
32
|
+
"refresh",
|
|
33
|
+
]
|
pyedookit/auth.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
from yarl import URL
|
|
12
|
+
|
|
13
|
+
from .exceptions import AuthError, EdookitConnectionError
|
|
14
|
+
from .session import Session
|
|
15
|
+
|
|
16
|
+
_PLUS4U_OIDC_BASE = (
|
|
17
|
+
"https://uuidentity.plus4u.net/uu-oidc-maing02/bb977a99f4cc4c37a2afce3fd599d0a7"
|
|
18
|
+
)
|
|
19
|
+
_AUTH_URL = f"{_PLUS4U_OIDC_BASE}/oidc/auth"
|
|
20
|
+
_AUTH_PASSWORD_URL = f"{_PLUS4U_OIDC_BASE}/authPassword/authenticate"
|
|
21
|
+
_REALM_CODE = "uuIdentityPasswordAuthNRealm"
|
|
22
|
+
|
|
23
|
+
# Confirmed against a real portal: the OIDC client_id is per-school, not a
|
|
24
|
+
# single value shared across every Edookit install (third-party docs this
|
|
25
|
+
# was originally inferred from implied otherwise). Each school's /user/login
|
|
26
|
+
# page embeds it in a small inline JS config block, e.g.
|
|
27
|
+
# uu_app_oidc_providers_oidcg02_client_id: "983960dcb7b84b34b9aecc5194381d56"
|
|
28
|
+
# so it has to be scraped fresh per school rather than hardcoded.
|
|
29
|
+
_CLIENT_ID_RE = re.compile(
|
|
30
|
+
r"uu_app_oidc_providers_oidcg02_client_id\s*:\s*['\"]([^'\"]+)['\"]"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_PLUS4U_HOST = "uuidentity.plus4u.net"
|
|
34
|
+
_MAX_REDIRECTS = 5
|
|
35
|
+
|
|
36
|
+
# A school subdomain becomes part of a URL (redirect_uri, portal base) with
|
|
37
|
+
# no other encoding, so reject anything that isn't a bare hostname label -
|
|
38
|
+
# otherwise a value like "evil.com/" would redirect the OIDC flow off-portal.
|
|
39
|
+
_SCHOOL_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
|
40
|
+
|
|
41
|
+
REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
|
42
|
+
|
|
43
|
+
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
|
44
|
+
|
|
45
|
+
# Edookit sends the browser back to one of these paths when a session has
|
|
46
|
+
# expired or was never valid; shared with client.py so "did we get logged
|
|
47
|
+
# out" is checked the same way everywhere.
|
|
48
|
+
LOGIN_MARKERS = ("/user/login", "/user/oidc")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_login_url(url: str) -> bool:
|
|
52
|
+
return any(marker in url for marker in LOGIN_MARKERS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Cookies are tracked per host explicitly rather than via aiohttp's
|
|
56
|
+
# CookieJar: the login flow hops between the plus4u.net and edookit.net
|
|
57
|
+
# hosts, and a plain per-host dict is simpler to reason about (and to test)
|
|
58
|
+
# than relying on the jar's own domain-matching for a flow that only ever
|
|
59
|
+
# touches two hosts.
|
|
60
|
+
CookiesByHost = dict[str, dict[str, str]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True)
|
|
64
|
+
class _FetchResult:
|
|
65
|
+
status: int
|
|
66
|
+
url: str
|
|
67
|
+
text: str
|
|
68
|
+
location: str | None
|
|
69
|
+
is_redirect: bool
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _validate_school(school: str) -> None:
|
|
73
|
+
if not _SCHOOL_RE.match(school):
|
|
74
|
+
raise AuthError(f"{school!r} is not a valid Edookit school subdomain")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _school_host(school: str) -> str:
|
|
78
|
+
return f"{school}.edookit.net"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _school_base(school: str) -> str:
|
|
82
|
+
return f"https://{_school_host(school)}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _callback_url(school: str) -> str:
|
|
86
|
+
return f"{_school_base(school)}/user/oidc-login-callback"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _apply_set_cookies(headers: Iterable[str], jar: dict[str, str]) -> None:
|
|
90
|
+
"""Record Set-Cookie headers into `jar` the way a browser would.
|
|
91
|
+
|
|
92
|
+
Parsed by hand instead of via aiohttp's `resp.cookies`: that uses
|
|
93
|
+
SimpleCookie, which silently drops a whole header when it meets an
|
|
94
|
+
attribute it doesn't know (e.g. `Partitioned`) or an unquoted space or
|
|
95
|
+
quote in the value - and that is how Plus4U's long-lived `uoid.ps`
|
|
96
|
+
cookie was lost, breaking the silent refresh. Values are kept verbatim
|
|
97
|
+
(quotes included) because that is what gets sent back.
|
|
98
|
+
"""
|
|
99
|
+
for header in headers:
|
|
100
|
+
pair, _, attributes = header.partition(";")
|
|
101
|
+
name, sep, value = pair.partition("=")
|
|
102
|
+
name = name.strip()
|
|
103
|
+
if not sep or not name:
|
|
104
|
+
continue
|
|
105
|
+
expired = any(
|
|
106
|
+
attr.strip().lower().replace(" ", "") in ("max-age=0", "max-age=-1")
|
|
107
|
+
for attr in attributes.split(";")
|
|
108
|
+
)
|
|
109
|
+
if expired:
|
|
110
|
+
jar.pop(name, None)
|
|
111
|
+
else:
|
|
112
|
+
jar[name] = value.strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cookie_header(cookies: Mapping[str, str]) -> str:
|
|
116
|
+
return "; ".join(f"{k}={v}" for k, v in cookies.items())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _auth_params(
|
|
120
|
+
school: str, client_id: str, state: str, *, prompt_none: bool = False
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
params = {
|
|
123
|
+
"response_type": "code",
|
|
124
|
+
"client_id": client_id,
|
|
125
|
+
"redirect_uri": _callback_url(school),
|
|
126
|
+
"scope": "openid",
|
|
127
|
+
"state": state,
|
|
128
|
+
}
|
|
129
|
+
if prompt_none:
|
|
130
|
+
params["prompt"] = "none"
|
|
131
|
+
return params
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _discover_client_id(
|
|
135
|
+
http: aiohttp.ClientSession, school: str, cookies_by_host: CookiesByHost
|
|
136
|
+
) -> str:
|
|
137
|
+
"""Scrape the school's own /user/login page for its Plus4U client_id.
|
|
138
|
+
|
|
139
|
+
Routed through `_fetch` (not a bare request) so its Set-Cookie response
|
|
140
|
+
- PHPSESSID etc. - joins the same cookie tracking the rest of the login
|
|
141
|
+
flow uses, rather than being discarded.
|
|
142
|
+
"""
|
|
143
|
+
result = await _fetch(http, "GET", f"{_school_base(school)}/user/login", cookies_by_host)
|
|
144
|
+
match = _CLIENT_ID_RE.search(result.text)
|
|
145
|
+
if not match:
|
|
146
|
+
raise AuthError(f"could not find a Plus4U client_id on {school}'s login page")
|
|
147
|
+
return match.group(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _fetch(
|
|
151
|
+
http: aiohttp.ClientSession,
|
|
152
|
+
method: str,
|
|
153
|
+
url: str,
|
|
154
|
+
cookies_by_host: CookiesByHost,
|
|
155
|
+
*,
|
|
156
|
+
json: dict[str, object] | None = None,
|
|
157
|
+
params: Mapping[str, str] | None = None,
|
|
158
|
+
follow_redirects: bool = False,
|
|
159
|
+
) -> _FetchResult:
|
|
160
|
+
"""Make one request (optionally chasing redirects), updating cookies_by_host in place.
|
|
161
|
+
|
|
162
|
+
Each hop sends only the cookies stored for that hop's own host, and any
|
|
163
|
+
Set-Cookie in the response is recorded under that same host - this is
|
|
164
|
+
what lets the flow move from plus4u.net to <school>.edookit.net without
|
|
165
|
+
leaking cookies to the wrong domain.
|
|
166
|
+
"""
|
|
167
|
+
current_url = url
|
|
168
|
+
current_method = method
|
|
169
|
+
current_json = json
|
|
170
|
+
current_params = params
|
|
171
|
+
|
|
172
|
+
for _ in range(_MAX_REDIRECTS + 1):
|
|
173
|
+
host = URL(current_url).host or ""
|
|
174
|
+
headers: dict[str, str] = {}
|
|
175
|
+
host_cookies = cookies_by_host.get(host)
|
|
176
|
+
if host_cookies:
|
|
177
|
+
headers["Cookie"] = _cookie_header(host_cookies)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
async with http.request(
|
|
181
|
+
current_method,
|
|
182
|
+
current_url,
|
|
183
|
+
params=current_params,
|
|
184
|
+
json=current_json,
|
|
185
|
+
headers=headers,
|
|
186
|
+
allow_redirects=False,
|
|
187
|
+
timeout=REQUEST_TIMEOUT,
|
|
188
|
+
) as resp:
|
|
189
|
+
if set_cookies := resp.headers.getall("Set-Cookie", []):
|
|
190
|
+
_apply_set_cookies(set_cookies, cookies_by_host.setdefault(host, {}))
|
|
191
|
+
|
|
192
|
+
is_redirect = resp.status in REDIRECT_STATUSES
|
|
193
|
+
location = resp.headers.get("Location")
|
|
194
|
+
text = "" if is_redirect else await resp.text()
|
|
195
|
+
result = _FetchResult(
|
|
196
|
+
status=resp.status,
|
|
197
|
+
url=str(resp.url),
|
|
198
|
+
text=text,
|
|
199
|
+
location=location,
|
|
200
|
+
is_redirect=is_redirect,
|
|
201
|
+
)
|
|
202
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
203
|
+
raise EdookitConnectionError(f"request to {host} failed: {err}") from err
|
|
204
|
+
|
|
205
|
+
if not (follow_redirects and is_redirect and location):
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
current_url = str(URL(current_url).join(URL(location)))
|
|
209
|
+
current_method = "GET"
|
|
210
|
+
current_json = None
|
|
211
|
+
current_params = None
|
|
212
|
+
|
|
213
|
+
raise AuthError("too many redirects while following the Plus4U/edookit login flow")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _new_http_session() -> aiohttp.ClientSession:
|
|
217
|
+
# Cookies are tracked manually in `cookies_by_host`, not via aiohttp's own
|
|
218
|
+
# jar, so redirect-following never needs a real one; tests replace this
|
|
219
|
+
# factory with a fake session instead of mocking ClientSession internals.
|
|
220
|
+
return aiohttp.ClientSession(cookie_jar=aiohttp.DummyCookieJar())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@asynccontextmanager
|
|
224
|
+
async def _http_session(http: aiohttp.ClientSession | None) -> AsyncIterator[aiohttp.ClientSession]:
|
|
225
|
+
if http is not None:
|
|
226
|
+
yield http
|
|
227
|
+
return
|
|
228
|
+
async with _new_http_session() as own:
|
|
229
|
+
yield own
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _session_from_cookies(school: str, cookies_by_host: CookiesByHost) -> Session:
|
|
233
|
+
edookit_cookies = cookies_by_host.get(_school_host(school), {})
|
|
234
|
+
plus4u_cookies = cookies_by_host.get(_PLUS4U_HOST, {})
|
|
235
|
+
if not edookit_cookies.get("PHPSESSID"):
|
|
236
|
+
raise AuthError("login flow completed but no edookit session cookie was set")
|
|
237
|
+
return Session(
|
|
238
|
+
school=school,
|
|
239
|
+
edookit_cookies=edookit_cookies,
|
|
240
|
+
plus4u_cookies=plus4u_cookies,
|
|
241
|
+
csrf_token=edookit_cookies.get("uu.app.csrf", ""),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def _finish_callback(
|
|
246
|
+
http: aiohttp.ClientSession,
|
|
247
|
+
callback_url: str,
|
|
248
|
+
cookies_by_host: CookiesByHost,
|
|
249
|
+
school: str,
|
|
250
|
+
) -> Session:
|
|
251
|
+
"""Follow the final OIDC callback redirect chain and build a Session from it.
|
|
252
|
+
|
|
253
|
+
Shared tail of login() and refresh(): both end with "GET the code=
|
|
254
|
+
callback, make sure we didn't land back on the login page, then read the
|
|
255
|
+
cookies that piled up along the way."
|
|
256
|
+
|
|
257
|
+
Confirmed against a real browser trace (HAR): after the callback GET
|
|
258
|
+
resolves (still 200 on /oidc/callback), the browser makes one more GET
|
|
259
|
+
to the portal root, which is what actually settles the session server
|
|
260
|
+
side - skipping it leaves the account looking like it has almost no
|
|
261
|
+
modules enabled (only the dashboard and badges), even though login
|
|
262
|
+
itself succeeded and the session cookies are valid.
|
|
263
|
+
"""
|
|
264
|
+
result = await _fetch(http, "GET", callback_url, cookies_by_host, follow_redirects=True)
|
|
265
|
+
if is_login_url(result.url):
|
|
266
|
+
raise AuthError("edookit rejected the Plus4U authorization code")
|
|
267
|
+
settle = await _fetch(http, "GET", _school_base(school), cookies_by_host, follow_redirects=True)
|
|
268
|
+
if is_login_url(settle.url):
|
|
269
|
+
raise AuthError("session did not settle after the OIDC callback")
|
|
270
|
+
return _session_from_cookies(school, cookies_by_host)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def probe_client_id(school: str) -> str:
|
|
274
|
+
"""Discover a school's Plus4U client_id without logging in.
|
|
275
|
+
|
|
276
|
+
Used by scripts/capture_fixtures.py to show what login()/refresh() will
|
|
277
|
+
use for this school before spending the user's password.
|
|
278
|
+
"""
|
|
279
|
+
_validate_school(school)
|
|
280
|
+
cookies_by_host: CookiesByHost = {}
|
|
281
|
+
async with _new_http_session() as http:
|
|
282
|
+
return await _discover_client_id(http, school, cookies_by_host)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def login(
|
|
286
|
+
school: str,
|
|
287
|
+
username: str,
|
|
288
|
+
password: str,
|
|
289
|
+
*,
|
|
290
|
+
http: aiohttp.ClientSession | None = None,
|
|
291
|
+
) -> Session:
|
|
292
|
+
"""Run the Plus4U OIDC password login and return a usable Session.
|
|
293
|
+
|
|
294
|
+
Raises AuthError if the credentials are rejected or the flow's shape has
|
|
295
|
+
changed (e.g. Edookit or Plus4U altered a redirect), and
|
|
296
|
+
EdookitConnectionError if either host can't be reached.
|
|
297
|
+
|
|
298
|
+
A passed-in `http` session must not keep its own cookies (use
|
|
299
|
+
`aiohttp.DummyCookieJar()`): cookies are tracked per host by hand here,
|
|
300
|
+
and a real jar would also collect and resend them.
|
|
301
|
+
"""
|
|
302
|
+
_validate_school(school)
|
|
303
|
+
cookies_by_host: CookiesByHost = {}
|
|
304
|
+
async with _http_session(http) as http:
|
|
305
|
+
client_id = await _discover_client_id(http, school, cookies_by_host)
|
|
306
|
+
state = secrets.token_urlsafe(16)
|
|
307
|
+
step1 = await _fetch(
|
|
308
|
+
http,
|
|
309
|
+
"GET",
|
|
310
|
+
_AUTH_URL,
|
|
311
|
+
cookies_by_host,
|
|
312
|
+
params=_auth_params(school, client_id, state),
|
|
313
|
+
)
|
|
314
|
+
if not step1.is_redirect or not step1.location:
|
|
315
|
+
raise AuthError(f"expected a redirect from Plus4U auth endpoint, got {step1.status}")
|
|
316
|
+
login_url = URL(step1.location)
|
|
317
|
+
server_state = login_url.query.get("state", state)
|
|
318
|
+
server_client_id = login_url.query.get("clientId", client_id)
|
|
319
|
+
|
|
320
|
+
payload = {
|
|
321
|
+
"clientId": server_client_id,
|
|
322
|
+
"state": server_state,
|
|
323
|
+
"rememberMe": True,
|
|
324
|
+
"realmCode": _REALM_CODE,
|
|
325
|
+
"username": username,
|
|
326
|
+
"password": password,
|
|
327
|
+
}
|
|
328
|
+
step2 = await _fetch(http, "POST", _AUTH_PASSWORD_URL, cookies_by_host, json=payload)
|
|
329
|
+
if step2.status in (400, 401):
|
|
330
|
+
raise AuthError("Plus4U rejected the username or password")
|
|
331
|
+
continue_url = step2.location
|
|
332
|
+
if not continue_url and step2.text:
|
|
333
|
+
try:
|
|
334
|
+
body = _json.loads(step2.text)
|
|
335
|
+
except ValueError:
|
|
336
|
+
body = None
|
|
337
|
+
if isinstance(body, dict):
|
|
338
|
+
continue_url = body.get("redirectUrl")
|
|
339
|
+
if not continue_url:
|
|
340
|
+
raise AuthError("Plus4U password auth did not return a continuation URL")
|
|
341
|
+
|
|
342
|
+
step3 = await _fetch(http, "GET", continue_url, cookies_by_host)
|
|
343
|
+
if not step3.location or "code=" not in step3.location:
|
|
344
|
+
raise AuthError("Plus4U did not return an authorization code")
|
|
345
|
+
|
|
346
|
+
return await _finish_callback(http, step3.location, cookies_by_host, school)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def refresh(session: Session, *, http: aiohttp.ClientSession | None = None) -> Session:
|
|
350
|
+
"""Obtain a fresh edookit session using the stored Plus4U cookies.
|
|
351
|
+
|
|
352
|
+
No password is needed; this is what keeps a long-running Home Assistant
|
|
353
|
+
integration logged in across the ~30-60 minute edookit session lifetime.
|
|
354
|
+
Raises AuthError if the Plus4U cookies have themselves expired (roughly
|
|
355
|
+
every 19 days), which means a full `login()` with the password is needed.
|
|
356
|
+
`http` has the same cookie-jar requirement as in `login()`.
|
|
357
|
+
"""
|
|
358
|
+
_validate_school(session.school)
|
|
359
|
+
# Only the Plus4U cookies: the portal answers /user/login with a redirect
|
|
360
|
+
# to /oidc/logout when it sees the expired edookit session, so the new
|
|
361
|
+
# edookit session is built from scratch, exactly like in login().
|
|
362
|
+
cookies_by_host: CookiesByHost = {_PLUS4U_HOST: dict(session.plus4u_cookies)}
|
|
363
|
+
async with _http_session(http) as http:
|
|
364
|
+
client_id = await _discover_client_id(http, session.school, cookies_by_host)
|
|
365
|
+
state = secrets.token_urlsafe(16)
|
|
366
|
+
step1 = await _fetch(
|
|
367
|
+
http,
|
|
368
|
+
"GET",
|
|
369
|
+
_AUTH_URL,
|
|
370
|
+
cookies_by_host,
|
|
371
|
+
params=_auth_params(session.school, client_id, state, prompt_none=True),
|
|
372
|
+
)
|
|
373
|
+
if not step1.is_redirect or not step1.location or "code=" not in step1.location:
|
|
374
|
+
raise AuthError("Plus4U session expired; full login required")
|
|
375
|
+
|
|
376
|
+
return await _finish_callback(http, step1.location, cookies_by_host, session.school)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def build_headers(session: Session) -> dict[str, str]:
|
|
380
|
+
"""Cookie/CSRF headers for a plain page request against the school's portal."""
|
|
381
|
+
return {
|
|
382
|
+
"Cookie": _cookie_header(session.edookit_cookies),
|
|
383
|
+
"X-CSRF-Token": session.csrf_token,
|
|
384
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
385
|
+
}
|
pyedookit/client.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from . import auth
|
|
6
|
+
from .exceptions import EdookitConnectionError, SessionExpired
|
|
7
|
+
from .models import Lesson, Payments, SchoolEvent, Term
|
|
8
|
+
from .parsers.events import parse_events
|
|
9
|
+
from .parsers.payments import parse_payments
|
|
10
|
+
from .parsers.timetable import parse_terms, parse_timetable
|
|
11
|
+
from .session import Session
|
|
12
|
+
|
|
13
|
+
_TIMETABLE_PATH = "/timetable/"
|
|
14
|
+
_EVENTS_PATH = "/timetable/upcoming"
|
|
15
|
+
_PAYMENTS_PATH = "/payments/"
|
|
16
|
+
|
|
17
|
+
CAPTURE_PAGES: dict[str, str] = {
|
|
18
|
+
"assignments": "/assignments/",
|
|
19
|
+
"exams": "/exams/",
|
|
20
|
+
"inbox": "/overview/updates",
|
|
21
|
+
"consents": "/consents/",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class EdookitClient:
|
|
26
|
+
"""Fetches parsed data from one school's Edookit parent portal.
|
|
27
|
+
|
|
28
|
+
Holds a `Session` that the caller is responsible for persisting and
|
|
29
|
+
refreshing (see `pyedookit.auth.refresh`); this class only detects when
|
|
30
|
+
the current session has gone stale and raises `SessionExpired` so the
|
|
31
|
+
caller can refresh and retry.
|
|
32
|
+
|
|
33
|
+
Pass `http` to reuse an existing `aiohttp.ClientSession` across requests
|
|
34
|
+
instead of opening a new connection per call; otherwise one is created
|
|
35
|
+
and closed per request. Cookies are sent by hand, so give it a
|
|
36
|
+
`aiohttp.DummyCookieJar()` rather than a shared session whose jar would
|
|
37
|
+
also collect the portal's cookies.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, session: Session, http: aiohttp.ClientSession | None = None) -> None:
|
|
41
|
+
self._session = session
|
|
42
|
+
self._http = http
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def session(self) -> Session:
|
|
46
|
+
return self._session
|
|
47
|
+
|
|
48
|
+
async def get_terms(self) -> list[Term]:
|
|
49
|
+
html = await self.fetch_timetable_html()
|
|
50
|
+
return parse_terms(html)
|
|
51
|
+
|
|
52
|
+
async def get_timetable(self, week_offset: int = 0) -> list[Lesson]:
|
|
53
|
+
html = await self.fetch_timetable_html(week_offset)
|
|
54
|
+
return parse_timetable(html)
|
|
55
|
+
|
|
56
|
+
async def fetch_timetable_html(self, week_offset: int = 0) -> str:
|
|
57
|
+
"""Raw `/timetable/` HTML for one week - also used by
|
|
58
|
+
scripts/capture_fixtures.py, so the request shape lives in one place."""
|
|
59
|
+
return await self._get(_TIMETABLE_PATH, params={"familyTimetable-value": week_offset})
|
|
60
|
+
|
|
61
|
+
async def get_events(self) -> list[SchoolEvent]:
|
|
62
|
+
return parse_events(await self._get(_EVENTS_PATH))
|
|
63
|
+
|
|
64
|
+
async def get_payments(self) -> Payments:
|
|
65
|
+
return parse_payments(await self._get(_PAYMENTS_PATH))
|
|
66
|
+
|
|
67
|
+
async def fetch_page(self, name: str) -> str:
|
|
68
|
+
"""Raw HTML of one of `CAPTURE_PAGES`, for pages without a parser yet."""
|
|
69
|
+
try:
|
|
70
|
+
path = CAPTURE_PAGES[name]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise ValueError(f"{name!r} is not one of {sorted(CAPTURE_PAGES)}") from None
|
|
73
|
+
return await self._get(path)
|
|
74
|
+
|
|
75
|
+
async def _get(self, path: str, params: dict[str, int] | None = None) -> str:
|
|
76
|
+
url = f"{self._session.base_url}{path}"
|
|
77
|
+
headers = auth.build_headers(self._session)
|
|
78
|
+
if self._http is not None:
|
|
79
|
+
return await self._request(self._http, url, params, headers)
|
|
80
|
+
async with aiohttp.ClientSession() as http:
|
|
81
|
+
return await self._request(http, url, params, headers)
|
|
82
|
+
|
|
83
|
+
async def _request(
|
|
84
|
+
self,
|
|
85
|
+
http: aiohttp.ClientSession,
|
|
86
|
+
url: str,
|
|
87
|
+
params: dict[str, int] | None,
|
|
88
|
+
headers: dict[str, str],
|
|
89
|
+
) -> str:
|
|
90
|
+
# allow_redirects=False: our Cookie/CSRF headers are set manually, and
|
|
91
|
+
# older aiohttp forwards a manually-set Cookie header to a redirect's
|
|
92
|
+
# target even when that target is a different host - so an expired
|
|
93
|
+
# session's redirect to Plus4U must never be followed with them
|
|
94
|
+
# attached. Any redirect at all means the session isn't good anymore.
|
|
95
|
+
try:
|
|
96
|
+
async with http.get(
|
|
97
|
+
url,
|
|
98
|
+
params=params,
|
|
99
|
+
headers=headers,
|
|
100
|
+
allow_redirects=False,
|
|
101
|
+
timeout=auth.REQUEST_TIMEOUT,
|
|
102
|
+
) as resp:
|
|
103
|
+
if resp.status in auth.REDIRECT_STATUSES or auth.is_login_url(str(resp.url)):
|
|
104
|
+
raise SessionExpired("edookit session has expired")
|
|
105
|
+
return await resp.text()
|
|
106
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
107
|
+
raise EdookitConnectionError(f"request to {url} failed: {err}") from err
|
pyedookit/exceptions.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class EdookitError(Exception):
|
|
2
|
+
"""Base error for all pyedookit failures."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthError(EdookitError):
|
|
6
|
+
"""Credentials were rejected, or the long-lived Plus4U session expired."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SessionExpired(EdookitError):
|
|
10
|
+
"""The short-lived edookit session cookie expired; caller should refresh and retry."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParseError(EdookitError):
|
|
14
|
+
"""A portal page did not have the expected structure.
|
|
15
|
+
|
|
16
|
+
Edookit's markup isn't versioned, so this is the signal a parser needs updating
|
|
17
|
+
rather than a bug in the caller's usage.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EdookitConnectionError(EdookitError):
|
|
22
|
+
"""The portal or Plus4U could not be reached (network error or timeout)."""
|
pyedookit/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
# The portal renders wall-clock times with no zone; every Edookit school is
|
|
9
|
+
# Czech, so parsed times are pinned to Prague rather than left naive.
|
|
10
|
+
PORTAL_TZ = ZoneInfo("Europe/Prague")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Lesson:
|
|
15
|
+
start: datetime
|
|
16
|
+
end: datetime
|
|
17
|
+
subject: str
|
|
18
|
+
teacher: str | None = None
|
|
19
|
+
room: str | None = None
|
|
20
|
+
group: str | None = None
|
|
21
|
+
is_changed: bool = False
|
|
22
|
+
is_cancelled: bool = False
|
|
23
|
+
note: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Term:
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Student:
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SchoolEvent:
|
|
40
|
+
id: str
|
|
41
|
+
title: str
|
|
42
|
+
start: datetime | date
|
|
43
|
+
end: datetime | date
|
|
44
|
+
description: str | None = None
|
|
45
|
+
created_by: str | None = None
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def all_day(self) -> bool:
|
|
49
|
+
return not isinstance(self.start, datetime)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class PaymentRequest:
|
|
54
|
+
title: str
|
|
55
|
+
amount: Decimal
|
|
56
|
+
currency: str
|
|
57
|
+
is_paid: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Payments:
|
|
62
|
+
paid: Decimal
|
|
63
|
+
remaining: Decimal
|
|
64
|
+
overpaid: Decimal
|
|
65
|
+
currency: str
|
|
66
|
+
requests: list[PaymentRequest]
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date, timedelta
|
|
5
|
+
|
|
6
|
+
# The portal writes the year only when it isn't the current one, and uses a
|
|
7
|
+
# word instead of a date for recent days ("Dnes , 8:00 – 8:45"; Včera/Dnes
|
|
8
|
+
# confirmed, Zítra assumed).
|
|
9
|
+
_RELATIVE_DAYS = {"včera": -1, "dnes": 0, "zítra": 1}
|
|
10
|
+
_DAY_RE = re.compile(
|
|
11
|
+
r"(?P<relative>včera|dnes|zítra)\b"
|
|
12
|
+
r"|(?P<day>\d{1,2})\.\s*(?P<month>\d{1,2})\.(?:\s*(?P<year>\d{4}))?",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
TIME_RE = re.compile(r"(\d{1,2}):(\d{2})")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_days(text: str, today: date) -> list[date]:
|
|
19
|
+
days: list[date] = []
|
|
20
|
+
for match in _DAY_RE.finditer(text):
|
|
21
|
+
if relative := match.group("relative"):
|
|
22
|
+
days.append(today + timedelta(days=_RELATIVE_DAYS[relative.lower()]))
|
|
23
|
+
continue
|
|
24
|
+
year = int(match.group("year")) if match.group("year") else today.year
|
|
25
|
+
try:
|
|
26
|
+
days.append(date(year, int(match.group("month")), int(match.group("day"))))
|
|
27
|
+
except ValueError:
|
|
28
|
+
continue
|
|
29
|
+
return days
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ParseError
|
|
10
|
+
from ..models import PORTAL_TZ, SchoolEvent
|
|
11
|
+
from .dates import TIME_RE, find_days
|
|
12
|
+
|
|
13
|
+
_EVENT_ID_RE = re.compile(r"event=(\d+)")
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_events(html: str, today: date | None = None) -> list[SchoolEvent]:
|
|
19
|
+
"""Parse `/timetable/upcoming` ("Mé budoucí události").
|
|
20
|
+
|
|
21
|
+
Confirmed against a real capture: one `ul.table_row` per event inside
|
|
22
|
+
`div.events_table`, after a `gridLabel` header row that shares the class.
|
|
23
|
+
Only a timed event on a single day, or an all-day event over one or more
|
|
24
|
+
days, has been seen; anything else is skipped with a warning.
|
|
25
|
+
"""
|
|
26
|
+
soup = BeautifulSoup(html, "lxml")
|
|
27
|
+
if soup.select_one("section.content") is None:
|
|
28
|
+
raise ParseError("upcoming events page has no content section")
|
|
29
|
+
if today is None:
|
|
30
|
+
today = datetime.now(PORTAL_TZ).date()
|
|
31
|
+
|
|
32
|
+
events: list[SchoolEvent] = []
|
|
33
|
+
for row in soup.select("div.events_table ul.table_row"):
|
|
34
|
+
if "gridLabel" in (row.get("class") or []):
|
|
35
|
+
continue
|
|
36
|
+
event = _parse_row(row, today)
|
|
37
|
+
if event is None:
|
|
38
|
+
_LOGGER.warning("skipping unparseable event row %s", _event_id(row) or "?")
|
|
39
|
+
else:
|
|
40
|
+
events.append(event)
|
|
41
|
+
return events
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _event_id(row: Tag) -> str | None:
|
|
45
|
+
link = row.select_one("li.c2 a[href]")
|
|
46
|
+
match = _EVENT_ID_RE.search(str(link.get("href"))) if link else None
|
|
47
|
+
return match.group(1) if match else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_row(row: Tag, today: date) -> SchoolEvent | None:
|
|
51
|
+
event_id = _event_id(row)
|
|
52
|
+
when = row.select_one("li.c1 b")
|
|
53
|
+
title = row.select_one("li.c2 div.bold")
|
|
54
|
+
if event_id is None or when is None or title is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
text = " ".join(when.get_text(" ", strip=True).split())
|
|
58
|
+
days = find_days(text, today)
|
|
59
|
+
times = [time(int(h), int(m)) for h, m in TIME_RE.findall(text)]
|
|
60
|
+
if not days:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
start: datetime | date
|
|
64
|
+
end: datetime | date
|
|
65
|
+
if times:
|
|
66
|
+
if len(days) != 1 or len(times) != 2:
|
|
67
|
+
return None
|
|
68
|
+
start = datetime.combine(days[0], times[0], tzinfo=PORTAL_TZ)
|
|
69
|
+
end = datetime.combine(days[0], times[1], tzinfo=PORTAL_TZ)
|
|
70
|
+
else:
|
|
71
|
+
start, end = days[0], days[-1]
|
|
72
|
+
|
|
73
|
+
description = " ".join(
|
|
74
|
+
p.get_text(" ", strip=True) for p in row.select("li.c2 > p") if p.get_text(strip=True)
|
|
75
|
+
)
|
|
76
|
+
creator = row.select_one("li.c3 div.smaller p b")
|
|
77
|
+
return SchoolEvent(
|
|
78
|
+
id=event_id,
|
|
79
|
+
title=title.get_text(" ", strip=True),
|
|
80
|
+
start=start,
|
|
81
|
+
end=end,
|
|
82
|
+
description=description or None,
|
|
83
|
+
created_by=creator.get_text(strip=True) if creator else None,
|
|
84
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from bs4 import BeautifulSoup
|
|
7
|
+
|
|
8
|
+
_NUMBER_RE = re.compile(r"\d+")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def content_fingerprint(html: str) -> str | None:
|
|
12
|
+
"""Hash of a page's visible content, stable across counters and timestamps.
|
|
13
|
+
|
|
14
|
+
Used to notice when a page that has only ever been empty gets real items.
|
|
15
|
+
It can't rely on empty-state text disappearing, because some pages keep
|
|
16
|
+
their hidden "no data" block in the HTML even when items exist. Returns
|
|
17
|
+
None for error and maintenance pages, which lack the portal's content
|
|
18
|
+
section and must not count as a change.
|
|
19
|
+
"""
|
|
20
|
+
root = BeautifulSoup(html, "lxml").select_one("section.content")
|
|
21
|
+
if root is None:
|
|
22
|
+
return None
|
|
23
|
+
for tag in root.select("script, style"):
|
|
24
|
+
tag.decompose()
|
|
25
|
+
text = " ".join(root.get_text(" ", strip=True).split())
|
|
26
|
+
return hashlib.sha256(_NUMBER_RE.sub("#", text).encode()).hexdigest()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ParseError
|
|
10
|
+
from ..models import PaymentRequest, Payments
|
|
11
|
+
|
|
12
|
+
_AMOUNT_RE = re.compile(r"(-?\d[\d\s ]*(?:,\d+)?)\s*(\S+)?")
|
|
13
|
+
_CURRENCIES = {"Kč": "CZK", "CZK": "CZK", "€": "EUR", "EUR": "EUR"}
|
|
14
|
+
_DEFAULT_CURRENCY = "CZK"
|
|
15
|
+
|
|
16
|
+
_LOGGER = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_amount(text: str) -> tuple[Decimal, str | None]:
|
|
20
|
+
match = _AMOUNT_RE.search(text)
|
|
21
|
+
if match is None:
|
|
22
|
+
raise ParseError(f"no amount in {text!r}")
|
|
23
|
+
number = re.sub(r"[\s ]", "", match.group(1)).replace(",", ".")
|
|
24
|
+
return Decimal(number), _CURRENCIES.get(match.group(2) or "")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_payments(html: str) -> Payments:
|
|
28
|
+
"""Parse `/payments/` ("Platby") for the whole family.
|
|
29
|
+
|
|
30
|
+
Totals come from the `Everyone` spans because the portal bills parents
|
|
31
|
+
as well as the child. Only paid requests (`div.accepted`) have been seen
|
|
32
|
+
on a real portal, so every other request counts as unpaid and the
|
|
33
|
+
summary's "remaining" total stays the authoritative number.
|
|
34
|
+
"""
|
|
35
|
+
soup = BeautifulSoup(html, "lxml")
|
|
36
|
+
summary = soup.select_one("table.payments-summary.desktop") or soup.select_one(
|
|
37
|
+
"table.payments-summary"
|
|
38
|
+
)
|
|
39
|
+
if summary is None:
|
|
40
|
+
raise ParseError("no payments summary table found")
|
|
41
|
+
|
|
42
|
+
paid, currency = _total(summary, "td.paid span.sumsEveryone")
|
|
43
|
+
remaining, _ = _total(summary, "td.remaining span.sumsEveryone")
|
|
44
|
+
overpaid, _ = _total(summary, "td.overpaymentEveryone span")
|
|
45
|
+
|
|
46
|
+
requests: list[PaymentRequest] = []
|
|
47
|
+
for item in soup.select("div.item.prescriptionEveryone"):
|
|
48
|
+
request = _parse_request(item, currency)
|
|
49
|
+
if request is None:
|
|
50
|
+
_LOGGER.warning("skipping unparseable payment request")
|
|
51
|
+
else:
|
|
52
|
+
requests.append(request)
|
|
53
|
+
return Payments(
|
|
54
|
+
paid=paid, remaining=remaining, overpaid=overpaid, currency=currency, requests=requests
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _total(summary: Tag, selector: str) -> tuple[Decimal, str]:
|
|
59
|
+
cell = summary.select_one(selector)
|
|
60
|
+
if cell is None:
|
|
61
|
+
raise ParseError(f"payments summary has no {selector}")
|
|
62
|
+
amount, currency = parse_amount(cell.get_text(" ", strip=True))
|
|
63
|
+
return amount, currency or _DEFAULT_CURRENCY
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_request(item: Tag, default_currency: str) -> PaymentRequest | None:
|
|
67
|
+
title = item.select_one("div.heading div.text")
|
|
68
|
+
amount_cell = item.select_one("div.column.c4.desktop div.amount") or item.select_one(
|
|
69
|
+
"div.amount"
|
|
70
|
+
)
|
|
71
|
+
if title is None or amount_cell is None:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
amount, currency = parse_amount(amount_cell.get_text(" ", strip=True))
|
|
75
|
+
except ParseError:
|
|
76
|
+
return None
|
|
77
|
+
return PaymentRequest(
|
|
78
|
+
title=title.get_text(" ", strip=True),
|
|
79
|
+
amount=amount,
|
|
80
|
+
currency=currency or default_currency,
|
|
81
|
+
is_paid=item.select_one("div.status-icon div.accepted") is not None,
|
|
82
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
|
|
7
|
+
from bs4 import BeautifulSoup, Tag
|
|
8
|
+
|
|
9
|
+
from ..exceptions import ParseError
|
|
10
|
+
from ..models import PORTAL_TZ, Lesson, Term
|
|
11
|
+
from .dates import find_days
|
|
12
|
+
|
|
13
|
+
_TIME_RANGE_RE = re.compile(r"(\d{1,2}):(\d{2})\s*[-–]\s*(\d{1,2}):(\d{2})")
|
|
14
|
+
_TERM_ID_RE = re.compile(r"termSelector-new=(\d+)")
|
|
15
|
+
_NOTE_PREFIX = "Učivo:"
|
|
16
|
+
|
|
17
|
+
_LOGGER = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_timetable(html: str, today: date | None = None) -> list[Lesson]:
|
|
21
|
+
"""Parse the hidden `.lesson-info` detail blocks on a rendered `/timetable/` page.
|
|
22
|
+
|
|
23
|
+
Confirmed against a real portal capture: the *visible* week grid is an
|
|
24
|
+
absolutely-positioned canvas of `<a class="... lesson ...">` tiles with
|
|
25
|
+
no semantic day/time markup at all (position is encoded as inline CSS
|
|
26
|
+
percentages). Each lesson also gets a hidden
|
|
27
|
+
`<div class="lesson-info" id="lesson-info-{id}">` block, though, with a
|
|
28
|
+
fixed field order and a full date+time already inside it - parsing those
|
|
29
|
+
is far more reliable than reading positions off the canvas, and doesn't
|
|
30
|
+
need a day-column or week_start argument at all since each block already
|
|
31
|
+
carries its own date.
|
|
32
|
+
|
|
33
|
+
Yesterday's and today's lessons carry "Včera"/"Dnes" instead of a date;
|
|
34
|
+
those resolve against `today`, which defaults to the current date in
|
|
35
|
+
`PORTAL_TZ`.
|
|
36
|
+
|
|
37
|
+
Cancelled/substituted lessons could not be confirmed: the captured week
|
|
38
|
+
had none, so `is_changed`/`is_cancelled` are always False for now. Fix
|
|
39
|
+
once a real capture has one - see the parser test file for the exact gap.
|
|
40
|
+
"""
|
|
41
|
+
soup = BeautifulSoup(html, "lxml")
|
|
42
|
+
blocks = soup.select("div.lesson-info")
|
|
43
|
+
if not blocks:
|
|
44
|
+
raise ParseError("no lesson-info blocks found on timetable page")
|
|
45
|
+
|
|
46
|
+
if today is None:
|
|
47
|
+
today = datetime.now(PORTAL_TZ).date()
|
|
48
|
+
lessons = []
|
|
49
|
+
for block in blocks:
|
|
50
|
+
lesson = _parse_lesson_info(block, today)
|
|
51
|
+
if lesson is None:
|
|
52
|
+
_LOGGER.warning("skipping unparseable lesson block %s", block.get("id"))
|
|
53
|
+
else:
|
|
54
|
+
lessons.append(lesson)
|
|
55
|
+
return lessons
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_terms(html: str) -> list[Term]:
|
|
59
|
+
soup = BeautifulSoup(html, "lxml")
|
|
60
|
+
terms: list[Term] = []
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
for link in soup.select('a[href*="termSelector-new"]'):
|
|
63
|
+
href = str(link.get("href") or "")
|
|
64
|
+
match = _TERM_ID_RE.search(href)
|
|
65
|
+
if not match:
|
|
66
|
+
continue
|
|
67
|
+
term_id = match.group(1)
|
|
68
|
+
if term_id in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(term_id)
|
|
71
|
+
terms.append(Term(id=term_id, name=link.get_text(strip=True)))
|
|
72
|
+
return terms
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_lesson_info(block: Tag, today: date) -> Lesson | None:
|
|
76
|
+
fields = block.find_all("div", recursive=False)
|
|
77
|
+
datetime_div = next((f for f in fields if f.select_one(".date_time")), None)
|
|
78
|
+
if datetime_div is None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
text = datetime_div.get_text(" ", strip=True)
|
|
82
|
+
days = find_days(text, today)
|
|
83
|
+
lesson_date = days[0] if days else None
|
|
84
|
+
time_match = _TIME_RANGE_RE.search(text)
|
|
85
|
+
if lesson_date is None or not time_match:
|
|
86
|
+
return None
|
|
87
|
+
sh, sm, eh, em = (int(g) for g in time_match.groups())
|
|
88
|
+
|
|
89
|
+
# Field order after the date/time div is fixed on every lesson observed:
|
|
90
|
+
# "{code} - {group}", full subject name, teacher(s), room, curriculum
|
|
91
|
+
# notes (last one is sometimes absent).
|
|
92
|
+
rest = [f for f in fields if f is not datetime_div]
|
|
93
|
+
code_group = rest[0].get_text(strip=True) if len(rest) > 0 else ""
|
|
94
|
+
subject = rest[1].get_text(strip=True) if len(rest) > 1 else code_group
|
|
95
|
+
teacher = rest[2].get_text(strip=True) if len(rest) > 2 else None
|
|
96
|
+
room = rest[3].get_text(strip=True) if len(rest) > 3 else None
|
|
97
|
+
|
|
98
|
+
note = None
|
|
99
|
+
if len(rest) > 4:
|
|
100
|
+
note_text = rest[4].get_text(" ", strip=True)
|
|
101
|
+
if note_text.startswith(_NOTE_PREFIX):
|
|
102
|
+
note = note_text[len(_NOTE_PREFIX) :].strip() or None
|
|
103
|
+
|
|
104
|
+
_, _, group = code_group.partition(" - ")
|
|
105
|
+
|
|
106
|
+
return Lesson(
|
|
107
|
+
start=datetime.combine(lesson_date, time(sh, sm), tzinfo=PORTAL_TZ),
|
|
108
|
+
end=datetime.combine(lesson_date, time(eh, em), tzinfo=PORTAL_TZ),
|
|
109
|
+
subject=subject or code_group,
|
|
110
|
+
teacher=teacher or None,
|
|
111
|
+
room=room or None,
|
|
112
|
+
group=group or None,
|
|
113
|
+
note=note,
|
|
114
|
+
)
|
pyedookit/session.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class Session:
|
|
10
|
+
"""Everything needed to talk to a school's portal without the password.
|
|
11
|
+
|
|
12
|
+
`edookit_cookies` is short-lived (expires in roughly 30-60 minutes).
|
|
13
|
+
`plus4u_cookies` is long-lived (roughly 19 days) and is what `refresh()`
|
|
14
|
+
uses to obtain a fresh edookit session silently. The password itself is
|
|
15
|
+
never stored here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
school: str
|
|
19
|
+
edookit_cookies: dict[str, str]
|
|
20
|
+
plus4u_cookies: dict[str, str]
|
|
21
|
+
csrf_token: str
|
|
22
|
+
edookit_expires_at: datetime = field(
|
|
23
|
+
default_factory=lambda: datetime.now(UTC) + timedelta(minutes=30)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def base_url(self) -> str:
|
|
28
|
+
return f"https://{self.school}.edookit.net"
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_edookit_expired(self) -> bool:
|
|
32
|
+
"""True once the session is expected to have aged out.
|
|
33
|
+
|
|
34
|
+
A conservative estimate (login/refresh don't know the portal's real
|
|
35
|
+
TTL), meant for a caller to refresh proactively before it bothers
|
|
36
|
+
making a request that's likely to just be redirected to the login
|
|
37
|
+
page - not a substitute for handling `SessionExpired` reactively too.
|
|
38
|
+
"""
|
|
39
|
+
return datetime.now(UTC) >= self.edookit_expires_at
|
|
40
|
+
|
|
41
|
+
def to_json(self) -> str:
|
|
42
|
+
data = asdict(self)
|
|
43
|
+
data["edookit_expires_at"] = self.edookit_expires_at.isoformat()
|
|
44
|
+
return json.dumps(data)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_json(cls, raw: str) -> Session:
|
|
48
|
+
data = json.loads(raw)
|
|
49
|
+
data["edookit_expires_at"] = datetime.fromisoformat(data["edookit_expires_at"])
|
|
50
|
+
return cls(**data)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyedookit
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Async client for the Edookit parent portal (edookit.net)
|
|
5
|
+
Project-URL: Homepage, https://github.com/TFoxik/pyedookit
|
|
6
|
+
Author: tomas.liska
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: aiohttp>=3.9
|
|
11
|
+
Requires-Dist: beautifulsoup4>=4.12
|
|
12
|
+
Requires-Dist: lxml>=5.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# pyedookit
|
|
16
|
+
|
|
17
|
+
Unofficial async Python client for the Edookit parent portal
|
|
18
|
+
(`https://<school>.edookit.net`), used by Czech schools. Built for the
|
|
19
|
+
`ha-edookit` Home Assistant integration but usable standalone.
|
|
20
|
+
|
|
21
|
+
Edookit's official REST API exists but is only enabled per-school by an
|
|
22
|
+
administrator, so this library works the way a parent actually does: it logs
|
|
23
|
+
in through Plus4U (OIDC) and scrapes the portal's server-rendered pages.
|
|
24
|
+
There is no vendor support for this - selectors may break when Edookit
|
|
25
|
+
changes its markup.
|
|
26
|
+
|
|
27
|
+
## Status
|
|
28
|
+
|
|
29
|
+
v0.3.0 - timetable, upcoming school events and payments. Verified end-to-end against a real portal
|
|
30
|
+
(login, session-settling after the OIDC callback, and the timetable parser
|
|
31
|
+
all confirmed working on real data).
|
|
32
|
+
|
|
33
|
+
**Only `week_offset=0` (the current week) is confirmed working.** The
|
|
34
|
+
portal accepts `week_offset=1` ("next week") without error, but returns the
|
|
35
|
+
same week's data back rather than actually advancing - the real
|
|
36
|
+
week-navigation parameter hasn't been identified yet. Since a school's
|
|
37
|
+
weekly timetable is typically the same all year, current-week-only is
|
|
38
|
+
usually enough in practice. See `scripts/capture_fixtures.py` for how to
|
|
39
|
+
capture and inspect real portal HTML if you want to chase this further (a
|
|
40
|
+
browser HAR trace of clicking the forward-week arrow is what's needed).
|
|
41
|
+
|
|
42
|
+
Cancelled/substituted lessons are also unverified: no real capture has had
|
|
43
|
+
one yet, so `Lesson.is_changed`/`is_cancelled` are always `False` for now.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pyedookit
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
import asyncio
|
|
55
|
+
from pyedookit import login, EdookitClient
|
|
56
|
+
|
|
57
|
+
async def main():
|
|
58
|
+
session = await login("zs-example", "parent@example.com", "hunter2")
|
|
59
|
+
client = EdookitClient(session)
|
|
60
|
+
for lesson in await client.get_timetable(week_offset=0):
|
|
61
|
+
print(lesson.start, lesson.subject, lesson.room)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`client.get_events()` returns upcoming school events (`SchoolEvent`, all-day
|
|
67
|
+
events have `date` start/end with an inclusive end), and `client.get_payments()`
|
|
68
|
+
returns the family's payment totals and requests (`Payments`, amounts as
|
|
69
|
+
`Decimal`). Only paid requests have been seen on a real portal so far; a request
|
|
70
|
+
without the "accepted" marker is reported as unpaid.
|
|
71
|
+
|
|
72
|
+
`client.fetch_page(name)` returns the raw HTML of a page that has no parser yet
|
|
73
|
+
(`CAPTURE_PAGES`: assignments, exams, inbox, consents), and
|
|
74
|
+
`content_fingerprint(html)` tells you when such a page's content changed.
|
|
75
|
+
|
|
76
|
+
Persist `session.to_json()` / `Session.from_json()` between runs instead of
|
|
77
|
+
storing the password; use `pyedookit.refresh(session)` to renew the
|
|
78
|
+
short-lived edookit session from the long-lived Plus4U cookie, and fall back
|
|
79
|
+
to `login()` only when `refresh()` raises `AuthError`.
|
|
80
|
+
|
|
81
|
+
Lesson times are timezone-aware (`pyedookit.PORTAL_TZ`, Europe/Prague).
|
|
82
|
+
|
|
83
|
+
Errors all derive from `EdookitError`:
|
|
84
|
+
|
|
85
|
+
| Exception | Meaning | What to do |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `SessionExpired` | short-lived edookit session aged out | `refresh()` and retry |
|
|
88
|
+
| `AuthError` | bad credentials, or the Plus4U cookie expired | full `login()` |
|
|
89
|
+
| `EdookitConnectionError` | network error or 30 s timeout | retry later |
|
|
90
|
+
| `ParseError` | portal markup changed | parser needs updating |
|
|
91
|
+
|
|
92
|
+
`login()`, `refresh()` and `EdookitClient` all accept an optional
|
|
93
|
+
`http: aiohttp.ClientSession` to reuse. Cookies are handled by hand, so
|
|
94
|
+
create that session with `cookie_jar=aiohttp.DummyCookieJar()`; a session
|
|
95
|
+
with a real cookie jar would also store and resend the portal's cookies.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv sync --group dev
|
|
101
|
+
uv run pytest
|
|
102
|
+
uv run ruff check .
|
|
103
|
+
uv run mypy src
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Releasing
|
|
107
|
+
|
|
108
|
+
Bump `version` in `pyproject.toml`, then push a `vX.Y.Z` tag. The
|
|
109
|
+
`Publish to PyPI` workflow tests, builds and uploads using PyPI trusted
|
|
110
|
+
publishing, which has to be configured once on pypi.org for this repository
|
|
111
|
+
and the `pypi` environment.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pyedookit/__init__.py,sha256=OJz_k3hKcHqzUgD-XiqcPoVyaHWGn_oKivy8xyyxF0s,730
|
|
2
|
+
pyedookit/auth.py,sha256=3guqbDtrIVmF02sCVDx7rNcOBYOtpynZpNoVgElRBFw,14712
|
|
3
|
+
pyedookit/client.py,sha256=v6Y_5Pt1JRf1zNrGzctTop6wmUwjko91Y5wtDLTVo9Q,4278
|
|
4
|
+
pyedookit/exceptions.py,sha256=PgNJQ4-4OWDM-fUpzJPmvOmWsRUbUtZsWqDWV0iGO9Y,677
|
|
5
|
+
pyedookit/models.py,sha256=kr2xDLhAAhP8wvTPpdZoa8G3KOu0bmL6oQOeUzakEJI,1391
|
|
6
|
+
pyedookit/session.py,sha256=HzspoMGQnRNXge1XmGYoqgb3qPTSF2gwENVpBBS_EFg,1713
|
|
7
|
+
pyedookit/parsers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
pyedookit/parsers/dates.py,sha256=A0v4FJrUnLTqkkzZX8Xy0OAZmjKB3SBYD6biCx-QGyw,1043
|
|
9
|
+
pyedookit/parsers/events.py,sha256=Qx-L6vLm8D08whKB3xNRuPHjOL1hsTp4FsEqUiKZoQg,2816
|
|
10
|
+
pyedookit/parsers/fingerprint.py,sha256=YvMs7Eeeb6JiNuPeFagFovxehtA_rzG4esh6TejvJe8,927
|
|
11
|
+
pyedookit/parsers/payments.py,sha256=qW4DS-I4dXmmHPRz8wnbCeulWoRAtrO9BB_mtYAbTE0,2961
|
|
12
|
+
pyedookit/parsers/timetable.py,sha256=OxIS18OzHBLgIITL3R11FYqQD50KZ30Rzh5xEzFa5Qk,4324
|
|
13
|
+
pyedookit-0.3.0.dist-info/METADATA,sha256=fwlZpGqoXifd0d_hp4XnbP5Yh2dZ3Ahxjs-xA01i854,4221
|
|
14
|
+
pyedookit-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
pyedookit-0.3.0.dist-info/licenses/LICENSE,sha256=o2p-_Uzf9isg79dSi8fdeTa14Gl1OUe7j59D24Q-L_U,1068
|
|
16
|
+
pyedookit-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomas Liska
|
|
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.
|