mosdiary 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.
- mosdiary/__init__.py +3 -0
- mosdiary/client.py +671 -0
- mosdiary/enums.py +91 -0
- mosdiary/exceptions.py +80 -0
- mosdiary/types/__init__.py +82 -0
- mosdiary/types/homework.py +72 -0
- mosdiary/types/lessons.py +108 -0
- mosdiary/types/marks.py +270 -0
- mosdiary/types/profile.py +439 -0
- mosdiary/types/schedule_events.py +259 -0
- mosdiary-0.1.0.dist-info/METADATA +31 -0
- mosdiary-0.1.0.dist-info/RECORD +15 -0
- mosdiary-0.1.0.dist-info/WHEEL +5 -0
- mosdiary-0.1.0.dist-info/licenses/LICENSE +674 -0
- mosdiary-0.1.0.dist-info/top_level.txt +1 -0
mosdiary/__init__.py
ADDED
mosdiary/client.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
from asyncio import sleep, TimeoutError
|
|
2
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
3
|
+
from datetime import date
|
|
4
|
+
from html import unescape
|
|
5
|
+
from inspect import isawaitable
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from re import DOTALL, IGNORECASE, search
|
|
8
|
+
from time import time
|
|
9
|
+
from types import TracebackType
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from aiohttp import (
|
|
13
|
+
ClientConnectionError,
|
|
14
|
+
ClientResponse,
|
|
15
|
+
ClientSession,
|
|
16
|
+
ClientSSLError,
|
|
17
|
+
ServerFingerprintMismatch,
|
|
18
|
+
)
|
|
19
|
+
from pydantic import ValidationError
|
|
20
|
+
from yarl import URL
|
|
21
|
+
|
|
22
|
+
from .exceptions import (
|
|
23
|
+
AuthenticationRequiredException,
|
|
24
|
+
InvalidLoginParameterException,
|
|
25
|
+
InvalidResponseException,
|
|
26
|
+
InvalidVerificationCodeException,
|
|
27
|
+
LoginTokenMissingException,
|
|
28
|
+
QRLoginExpiredException,
|
|
29
|
+
QRLoginInitializationException,
|
|
30
|
+
QRLoginStateException,
|
|
31
|
+
TwoFactorRequiredException,
|
|
32
|
+
VerificationAttemptsExhaustedException,
|
|
33
|
+
VerificationCodeExpiredException,
|
|
34
|
+
)
|
|
35
|
+
from .enums import ScheduleEventType
|
|
36
|
+
from .types.profile import UserInfo, Family, ProfileDeatail
|
|
37
|
+
from .types.homework import Homework
|
|
38
|
+
from .types.marks import Mark, SubjectMark
|
|
39
|
+
from .types.lessons import LessonDay
|
|
40
|
+
from .types.schedule_events import ScheduleEvent, parse_schedule_event
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
ScheduleEventInclude = Literal[
|
|
44
|
+
'marks',
|
|
45
|
+
'homework',
|
|
46
|
+
'absence_reason_id',
|
|
47
|
+
'health_status',
|
|
48
|
+
'nonattendance_reason_id',
|
|
49
|
+
]
|
|
50
|
+
ALL_SCHEDULE_EVENT_INCLUDES: tuple[ScheduleEventInclude, ...] = (
|
|
51
|
+
'marks',
|
|
52
|
+
'homework',
|
|
53
|
+
'absence_reason_id',
|
|
54
|
+
'health_status',
|
|
55
|
+
'nonattendance_reason_id',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MOSDiaryClient:
|
|
60
|
+
def __init__(self, aupd_token: str | None = None, timeout: float = 30, user_agent: str | None = None):
|
|
61
|
+
"""
|
|
62
|
+
Клиент МЭШ
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
aupd_token (`str | None`): Токен (кука) авторизации
|
|
66
|
+
timeout (`float`): Тайм-аут запросов в секундах.
|
|
67
|
+
user_agent (`str | None`): User-Agent для запросов.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
None: Создаёт клиент без выполнения сетевых запросов.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
self.aupd_token = aupd_token
|
|
74
|
+
"""Токен (кука) авторизации"""
|
|
75
|
+
self.timeout = timeout
|
|
76
|
+
self.user_agent = user_agent
|
|
77
|
+
|
|
78
|
+
self.id: int | None = None
|
|
79
|
+
"""ID аккаунта МЭШ"""
|
|
80
|
+
|
|
81
|
+
self.uid: str | None = None
|
|
82
|
+
"""UID персоны МЭШ"""
|
|
83
|
+
|
|
84
|
+
self._session: ClientSession | None = None
|
|
85
|
+
|
|
86
|
+
async def __aenter__(self) -> 'MOSDiaryClient':
|
|
87
|
+
"""Открыть HTTP-сессию клиента.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
MOSDiaryClient: Текущий экземпляр клиента.
|
|
91
|
+
"""
|
|
92
|
+
self._ensure_session()
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Закрыть HTTP-сессию при выходе из контекстного менеджера.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
None
|
|
101
|
+
"""
|
|
102
|
+
await self.close()
|
|
103
|
+
|
|
104
|
+
def _ensure_session(self) -> ClientSession:
|
|
105
|
+
"""Вернуть действующую HTTP-сессию, создав её при необходимости.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
ClientSession: Открытая сессия `aiohttp`.
|
|
109
|
+
"""
|
|
110
|
+
if self._session is None or self._session.closed:
|
|
111
|
+
headers = {'User-Agent': self.user_agent} if self.user_agent is not None else None
|
|
112
|
+
self._session = ClientSession(headers=headers)
|
|
113
|
+
|
|
114
|
+
return self._session
|
|
115
|
+
|
|
116
|
+
async def close(self) -> None:
|
|
117
|
+
"""Закрыть HTTP-сессию клиента.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
None
|
|
121
|
+
"""
|
|
122
|
+
if self._session is None:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
session = self._session
|
|
126
|
+
self._session = None
|
|
127
|
+
if not session.closed:
|
|
128
|
+
await session.close()
|
|
129
|
+
|
|
130
|
+
async def _send_request(
|
|
131
|
+
self,
|
|
132
|
+
method: Literal['GET', 'POST'],
|
|
133
|
+
endpoint: str,
|
|
134
|
+
return_type: Literal['dict', 'response', 'bool', 'text'] = 'dict',
|
|
135
|
+
use_auth: bool = True,
|
|
136
|
+
**kwargs,
|
|
137
|
+
) -> dict | ClientResponse | bool | str:
|
|
138
|
+
"""Отправить HTTP-запрос к API МЭШ.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
dict | ClientResponse | bool | str: JSON-объект, необработанный
|
|
142
|
+
ответ, признак успешного статуса или текст ответа - в
|
|
143
|
+
зависимости от `return_type`.
|
|
144
|
+
"""
|
|
145
|
+
if return_type not in ('dict', 'response', 'bool', 'text'):
|
|
146
|
+
raise ValueError(f'Неизвестный тип возврата: {return_type}')
|
|
147
|
+
|
|
148
|
+
url = (
|
|
149
|
+
'https://school.mos.ru/api/' + endpoint
|
|
150
|
+
if not endpoint.startswith('http')
|
|
151
|
+
else endpoint
|
|
152
|
+
)
|
|
153
|
+
headers = dict(kwargs.pop('headers', {}) or {})
|
|
154
|
+
cookies = dict(kwargs.pop('cookies', {}) or {})
|
|
155
|
+
if use_auth and self.aupd_token is not None:
|
|
156
|
+
cookies['aupd_token'] = self.aupd_token
|
|
157
|
+
headers.setdefault('Authorization', f'Bearer {self.aupd_token}')
|
|
158
|
+
headers.setdefault('Accept', 'application/json, text/plain, */*')
|
|
159
|
+
headers.setdefault('X-mes-subsystem', 'familyweb')
|
|
160
|
+
elif use_auth and self.aupd_token is None:
|
|
161
|
+
raise AuthenticationRequiredException('Этот метод требует авторизации. Заполните поле aupd_token в MOSDiaryClient.')
|
|
162
|
+
|
|
163
|
+
retry_number = 0
|
|
164
|
+
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
response = await self._ensure_session().request(
|
|
168
|
+
method,
|
|
169
|
+
url,
|
|
170
|
+
headers=headers,
|
|
171
|
+
cookies=cookies,
|
|
172
|
+
timeout=self.timeout,
|
|
173
|
+
**kwargs,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if return_type == 'response':
|
|
177
|
+
return response
|
|
178
|
+
|
|
179
|
+
async with response:
|
|
180
|
+
if return_type == 'bool':
|
|
181
|
+
return 200 <= response.status < 300
|
|
182
|
+
|
|
183
|
+
response.raise_for_status()
|
|
184
|
+
if return_type == 'text':
|
|
185
|
+
return await response.text()
|
|
186
|
+
|
|
187
|
+
data = await response.json()
|
|
188
|
+
if not isinstance(data, dict):
|
|
189
|
+
raise InvalidResponseException('Ответ API не является JSON-объектом')
|
|
190
|
+
return data
|
|
191
|
+
except (ClientSSLError, ServerFingerprintMismatch):
|
|
192
|
+
raise
|
|
193
|
+
except (ClientConnectionError, TimeoutError):
|
|
194
|
+
# POST could already have been processed, and a streamed body
|
|
195
|
+
# could already be consumed. Neither can be safely replayed.
|
|
196
|
+
if (
|
|
197
|
+
method != 'GET'
|
|
198
|
+
or 'data' in kwargs
|
|
199
|
+
or retry_number >= 2
|
|
200
|
+
):
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
await sleep(0.5 * 2 ** retry_number)
|
|
204
|
+
retry_number += 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def logout(self)-> bool:
|
|
208
|
+
"""
|
|
209
|
+
Завершает сессию, сбрасывает `self.aupd_token`, `self.id` и `self.uid`
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
bool:
|
|
213
|
+
Выполнен ли запрос успешно
|
|
214
|
+
"""
|
|
215
|
+
success = await self._send_request('GET', 'https://school.mos.ru/v3/auth/logout', return_type='bool')
|
|
216
|
+
if success:
|
|
217
|
+
self.id = None
|
|
218
|
+
self.uid = None
|
|
219
|
+
self.aupd_token = None
|
|
220
|
+
await self.close()
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def refresh_session(self, role_id: int = 1)-> str:
|
|
226
|
+
"""
|
|
227
|
+
Обновить `aupd_token` в текущей авторизованной HTTP-сессии.
|
|
228
|
+
|
|
229
|
+
Требует cookies, созданные QR-входом в этом же экземпляре клиента;
|
|
230
|
+
одного сохранённого `aupd_token` недостаточно.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
role_id (`int = 1`): Ваш ID роли. 1 - ученик
|
|
234
|
+
Returns:
|
|
235
|
+
str:
|
|
236
|
+
Токен авторизации
|
|
237
|
+
"""
|
|
238
|
+
aupd_token = await self._send_request(
|
|
239
|
+
'GET',
|
|
240
|
+
'https://school.mos.ru/v2/token/refresh',
|
|
241
|
+
return_type='text',
|
|
242
|
+
params={'roleId': role_id, 'subsystem': 2},
|
|
243
|
+
headers={
|
|
244
|
+
'Accept': 'application/json, text/plain, */*',
|
|
245
|
+
'CrossDomain': 'true',
|
|
246
|
+
'Referer': 'https://school.mos.ru/auth/callback'
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
self.aupd_token = aupd_token
|
|
250
|
+
return aupd_token
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def start_qr_login(self, return_type: Literal['link', 'qr', 'link&qr'] = 'link')-> tuple[str, int] | tuple[bytes, int] | tuple[str, bytes, int]:
|
|
254
|
+
"""
|
|
255
|
+
Начать вход по QR-коду. После используйте `wait_qr_login(expires)`
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
return_type (`Literal`): Вернуть ссылку, PNG-изображение QR-кода
|
|
259
|
+
либо ссылку и изображение.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
tuple[str, int] | tuple[bytes, int] | tuple[str, bytes, int]:
|
|
263
|
+
Запрошенные данные и время истечения QR-кода в Unix time.
|
|
264
|
+
"""
|
|
265
|
+
if return_type not in ('link', 'qr', 'link&qr'):
|
|
266
|
+
raise InvalidLoginParameterException(f'Неизвестный тип возвращаемого результата: {return_type}')
|
|
267
|
+
|
|
268
|
+
response = await self._send_request(
|
|
269
|
+
'GET',
|
|
270
|
+
'https://school.mos.ru/v3/auth/sudir/login',
|
|
271
|
+
return_type='response',
|
|
272
|
+
use_auth=False,
|
|
273
|
+
)
|
|
274
|
+
async with response:
|
|
275
|
+
response.raise_for_status()
|
|
276
|
+
html = await response.text()
|
|
277
|
+
|
|
278
|
+
match = search(r'url=([^"\'>]+)', html, IGNORECASE)
|
|
279
|
+
if match is None:
|
|
280
|
+
raise QRLoginInitializationException('МЭШ не вернул ссылку для авторизации')
|
|
281
|
+
|
|
282
|
+
response = await self._send_request(
|
|
283
|
+
'GET',
|
|
284
|
+
unescape(match.group(1)),
|
|
285
|
+
return_type='response',
|
|
286
|
+
use_auth=False,
|
|
287
|
+
headers={'Referer': 'https://school.mos.ru/'}
|
|
288
|
+
)
|
|
289
|
+
async with response:
|
|
290
|
+
response.raise_for_status()
|
|
291
|
+
|
|
292
|
+
data = await self._poll_qr_login()
|
|
293
|
+
if data.get('command') != 'showQRCode':
|
|
294
|
+
raise QRLoginInitializationException('МЭШ не создал QR-код для входа')
|
|
295
|
+
|
|
296
|
+
link = data.get('link')
|
|
297
|
+
expires = data.get('expires')
|
|
298
|
+
if not isinstance(link, str) or not isinstance(expires, int):
|
|
299
|
+
raise QRLoginInitializationException('МЭШ вернул некорректные данные QR-кода')
|
|
300
|
+
|
|
301
|
+
if return_type == 'link':
|
|
302
|
+
return link, expires
|
|
303
|
+
|
|
304
|
+
from segno import make_qr
|
|
305
|
+
with BytesIO() as image:
|
|
306
|
+
make_qr(link, error='m', boost_error=False).save(image, kind='png', scale=4)
|
|
307
|
+
qr = image.getvalue()
|
|
308
|
+
|
|
309
|
+
if return_type == 'link&qr':
|
|
310
|
+
return link, qr, expires
|
|
311
|
+
return qr, expires
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def _poll_qr_login(self)-> dict:
|
|
315
|
+
"""Получить текущее состояние QR-входа.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
dict: Ответ протокола QR-авторизации МЭШ.
|
|
319
|
+
"""
|
|
320
|
+
return await self._send_request(
|
|
321
|
+
'GET',
|
|
322
|
+
'https://login.mos.ru/sps/login/methods/headless/qrCode/pull',
|
|
323
|
+
use_auth=False,
|
|
324
|
+
headers={
|
|
325
|
+
'Accept': 'application/json, text/javascript, */*; q=0.01',
|
|
326
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def wait_qr_login(
|
|
332
|
+
self,
|
|
333
|
+
expires: int,
|
|
334
|
+
get_code: Callable[[], str | Awaitable[str]] | None = None,
|
|
335
|
+
verification_method: Literal['sms', 'email', 'flash_call'] = 'flash_call',
|
|
336
|
+
trust_browser: bool = False,
|
|
337
|
+
)-> tuple[str, str]:
|
|
338
|
+
"""
|
|
339
|
+
Дождаться подтверждения QR-входа и вернуть токены AUPD.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
expires (`int`): Время истечения QR-кода в Unix time.
|
|
343
|
+
get_code (`Callable | None`): Функция, возвращающая код второго фактора. Может быть асинхронной.
|
|
344
|
+
verification_method (`Literal`): Получить код по SMS, почте или звонком.
|
|
345
|
+
trust_browser (`bool`): Не запрашивать второй фактор при следующих входах из этой сессии.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
tuple[str, str]: Пара `(aupd_token, aupd_refresh_token)`
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
method_name = {
|
|
352
|
+
'flash_call': 'flashCall',
|
|
353
|
+
'email': 'email',
|
|
354
|
+
'sms': 'sms',
|
|
355
|
+
}[verification_method]
|
|
356
|
+
except KeyError:
|
|
357
|
+
raise InvalidLoginParameterException(f'Неизвестный способ подтверждения: {verification_method}') from None
|
|
358
|
+
|
|
359
|
+
while time() < expires:
|
|
360
|
+
data = await self._poll_qr_login()
|
|
361
|
+
command = data.get('command')
|
|
362
|
+
|
|
363
|
+
if command == 'needComplete':
|
|
364
|
+
break
|
|
365
|
+
if command == 'needRefresh':
|
|
366
|
+
raise QRLoginStateException(command, data.get('cause', 'unknown'))
|
|
367
|
+
if command not in ('showQRCode', 'askForConfirm'):
|
|
368
|
+
raise QRLoginStateException(command)
|
|
369
|
+
|
|
370
|
+
await sleep(1)
|
|
371
|
+
else:
|
|
372
|
+
raise QRLoginExpiredException('Срок действия QR-кода истёк')
|
|
373
|
+
|
|
374
|
+
cookie_jar = self._ensure_session().cookie_jar
|
|
375
|
+
headers = {'Origin': 'https://login.mos.ru'}
|
|
376
|
+
origin_cookie = cookie_jar.filter_cookies(
|
|
377
|
+
URL('https://login.mos.ru/sps/login/methods/password')
|
|
378
|
+
).get('origin')
|
|
379
|
+
if origin_cookie is not None and '|' in origin_cookie.value:
|
|
380
|
+
headers['Referer'] = (
|
|
381
|
+
'https://login.mos.ru/sps/login/methods/password?bo='
|
|
382
|
+
f'{origin_cookie.value.split("|", 1)[1]}'
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
response = await self._send_request(
|
|
386
|
+
'POST',
|
|
387
|
+
'https://login.mos.ru/sps/login/methods/qrCode/complete',
|
|
388
|
+
return_type='response',
|
|
389
|
+
use_auth=False,
|
|
390
|
+
data={},
|
|
391
|
+
headers=headers
|
|
392
|
+
)
|
|
393
|
+
async with response:
|
|
394
|
+
response.raise_for_status()
|
|
395
|
+
|
|
396
|
+
if response.url.path.startswith('/sps/login/methods2/'):
|
|
397
|
+
if get_code is None:
|
|
398
|
+
raise TwoFactorRequiredException('Mos ID требует второй фактор. Передайте get_code в wait_qr_login() и повторите вход по новому QR-коду')
|
|
399
|
+
|
|
400
|
+
back_url = response.url.query.get('bo')
|
|
401
|
+
if back_url is None:
|
|
402
|
+
raise QRLoginStateException('missing_back_url')
|
|
403
|
+
|
|
404
|
+
if response.url.name != method_name:
|
|
405
|
+
referer = str(response.url)
|
|
406
|
+
response = await self._send_request(
|
|
407
|
+
'GET',
|
|
408
|
+
str(URL(f'https://login.mos.ru/sps/login/methods2/{method_name}').with_query(bo=back_url)),
|
|
409
|
+
return_type='response',
|
|
410
|
+
use_auth=False,
|
|
411
|
+
headers={'Referer': referer},
|
|
412
|
+
)
|
|
413
|
+
async with response:
|
|
414
|
+
response.raise_for_status()
|
|
415
|
+
|
|
416
|
+
if verification_method == 'flash_call':
|
|
417
|
+
referer = str(response.url)
|
|
418
|
+
response = await self._send_request(
|
|
419
|
+
'POST',
|
|
420
|
+
str(URL('https://login.mos.ru/sps/login/method/flashCall').with_query(bo=back_url)),
|
|
421
|
+
return_type='response',
|
|
422
|
+
use_auth=False,
|
|
423
|
+
data={},
|
|
424
|
+
headers={
|
|
425
|
+
'Origin': 'https://login.mos.ru',
|
|
426
|
+
'Referer': referer,
|
|
427
|
+
},
|
|
428
|
+
)
|
|
429
|
+
async with response:
|
|
430
|
+
response.raise_for_status()
|
|
431
|
+
|
|
432
|
+
code_field = 'code'
|
|
433
|
+
code_length = 4
|
|
434
|
+
code_url = URL('https://login.mos.ru/sps/login/method/flashCall/code').with_query(bo=back_url)
|
|
435
|
+
code_paths = (
|
|
436
|
+
'/sps/login/method/flashCall',
|
|
437
|
+
'/sps/login/method/flashCall/code',
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
code_field = f'{method_name}-code'
|
|
441
|
+
code_length = 6
|
|
442
|
+
code_url = URL(f'https://login.mos.ru/sps/login/methods/{method_name}').with_query(bo=back_url)
|
|
443
|
+
code_paths = (
|
|
444
|
+
f'/sps/login/methods2/{method_name}',
|
|
445
|
+
f'/sps/login/methods/{method_name}'
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
while response.url.path in code_paths:
|
|
449
|
+
code = get_code()
|
|
450
|
+
if isawaitable(code):
|
|
451
|
+
code = await code
|
|
452
|
+
if (
|
|
453
|
+
not isinstance(code, str)
|
|
454
|
+
or len(code) != code_length
|
|
455
|
+
or not code.isdigit()
|
|
456
|
+
):
|
|
457
|
+
raise InvalidVerificationCodeException(f'Код подтверждения должен состоять из {code_length} цифр')
|
|
458
|
+
|
|
459
|
+
referer = str(response.url)
|
|
460
|
+
response = await self._send_request(
|
|
461
|
+
'POST',
|
|
462
|
+
str(code_url),
|
|
463
|
+
return_type='response',
|
|
464
|
+
use_auth=False,
|
|
465
|
+
data={code_field: code},
|
|
466
|
+
headers={
|
|
467
|
+
'Origin': 'https://login.mos.ru',
|
|
468
|
+
'Referer': referer,
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
async with response:
|
|
472
|
+
response.raise_for_status()
|
|
473
|
+
if response.url.path in code_paths:
|
|
474
|
+
config = search(
|
|
475
|
+
r'var vrfCodeConf\s*=\s*(\{.*?\});',
|
|
476
|
+
await response.text(),
|
|
477
|
+
DOTALL,
|
|
478
|
+
)
|
|
479
|
+
if config is not None:
|
|
480
|
+
error = search(r'"error"\s*:\s*"([^"]+)"', config[1])
|
|
481
|
+
ttl = search(r'"ttl"\s*:\s*(\d+)', config[1])
|
|
482
|
+
attempts = search(r'"attemptsLeft"\s*:\s*(\d+)', config[1])
|
|
483
|
+
if (
|
|
484
|
+
(error is not None and search(r'ист[её]к|просроч', error[1], IGNORECASE))
|
|
485
|
+
or (ttl is not None and int(ttl[1]) == 0)
|
|
486
|
+
):
|
|
487
|
+
raise VerificationCodeExpiredException('Срок действия кода подтверждения истёк')
|
|
488
|
+
if attempts is not None and int(attempts[1]) == 0:
|
|
489
|
+
raise VerificationAttemptsExhaustedException('Попытки ввода кода подтверждения исчерпаны')
|
|
490
|
+
|
|
491
|
+
if response.url.path == '/sps/login/ur/askToTrust':
|
|
492
|
+
response = await self._send_request(
|
|
493
|
+
'POST',
|
|
494
|
+
str(response.url),
|
|
495
|
+
return_type='response',
|
|
496
|
+
use_auth=False,
|
|
497
|
+
data={'action': 'trust' if trust_browser else 'do_not_trust'},
|
|
498
|
+
headers={
|
|
499
|
+
'Origin': 'https://login.mos.ru',
|
|
500
|
+
'Referer': str(response.url),
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
async with response:
|
|
504
|
+
response.raise_for_status()
|
|
505
|
+
|
|
506
|
+
token_cookie = None
|
|
507
|
+
refresh_cookie = None
|
|
508
|
+
for redirect_response in (*response.history, response):
|
|
509
|
+
token_cookie = redirect_response.cookies.get('aupd_token') or token_cookie
|
|
510
|
+
refresh_cookie = redirect_response.cookies.get('aupd_refresh_token') or refresh_cookie
|
|
511
|
+
|
|
512
|
+
for cookie in cookie_jar:
|
|
513
|
+
if cookie.key == 'aupd_token':
|
|
514
|
+
token_cookie = cookie
|
|
515
|
+
elif cookie.key == 'aupd_refresh_token':
|
|
516
|
+
refresh_cookie = cookie
|
|
517
|
+
|
|
518
|
+
if token_cookie is None or refresh_cookie is None:
|
|
519
|
+
cookie_names = sorted(cookie.key for cookie in cookie_jar)
|
|
520
|
+
raise LoginTokenMissingException(f'МЭШ не вернул токены после авторизации. Последний адрес: {response.url.with_query(None)}; cookies: {cookie_names}')
|
|
521
|
+
|
|
522
|
+
aupd_token = token_cookie.value
|
|
523
|
+
aupd_refresh_token = refresh_cookie.value
|
|
524
|
+
self.aupd_token = aupd_token
|
|
525
|
+
return aupd_token, aupd_refresh_token
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
async def get_detail_info(self)-> ProfileDeatail:
|
|
529
|
+
"""Получить подробную информацию о текущем пользователе.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
ProfileDeatail: Подробные данные профиля.
|
|
533
|
+
"""
|
|
534
|
+
return ProfileDeatail.model_validate(await self._send_request('GET', 'https://school.mos.ru/v3/userinfo'))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def get_family_info(self)-> Family:
|
|
538
|
+
"""Получить информацию о себе, как о члене семьи.
|
|
539
|
+
|
|
540
|
+
При вызове автоматически заполняет `self.id`.
|
|
541
|
+
|
|
542
|
+
Returns:
|
|
543
|
+
Family: Данные профиля, детей и представителей семьи.
|
|
544
|
+
"""
|
|
545
|
+
resp = await self._send_request('GET', 'family/web/v1/profile')
|
|
546
|
+
self.id = resp.get('profile')['id']
|
|
547
|
+
return Family.model_validate(resp)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
async def get_base_info(self)-> UserInfo:
|
|
551
|
+
"""Получить базовую информацию о текущем пользователе.
|
|
552
|
+
|
|
553
|
+
При вызове автоматически заполняет `self.uid`.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
UserInfo: Основные данные учётной записи МЭШ.
|
|
557
|
+
"""
|
|
558
|
+
info = UserInfo.model_validate(await self._send_request('GET', 'https://school.mos.ru/v1/oauth/userinfo'))
|
|
559
|
+
self.uid = info.person_uid
|
|
560
|
+
return info
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
async def get_lessons(self, dates: list[date])-> list[LessonDay]:
|
|
564
|
+
"""Получить расписание уроков на указанные даты.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
list[LessonDay]: Расписание, сгруппированное по дням.
|
|
568
|
+
"""
|
|
569
|
+
if self.id is None:
|
|
570
|
+
await self.get_family_info()
|
|
571
|
+
response = await self._send_request(
|
|
572
|
+
'GET', 'family/web/v1/schedule/short',
|
|
573
|
+
params={
|
|
574
|
+
'student_id': self.id,
|
|
575
|
+
'dates': ','.join(lesson_date.isoformat() for lesson_date in dates)
|
|
576
|
+
}
|
|
577
|
+
)
|
|
578
|
+
return [LessonDay.model_validate(day) for day in response['payload']]
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def get_schedule_events(
|
|
582
|
+
self,
|
|
583
|
+
from_date: date,
|
|
584
|
+
to_date: date,
|
|
585
|
+
include: Sequence[ScheduleEventInclude] | None = None,
|
|
586
|
+
source_types: Sequence[ScheduleEventType] | None = None,
|
|
587
|
+
)-> list[ScheduleEvent]:
|
|
588
|
+
"""Получить события расписания за указанный период.
|
|
589
|
+
|
|
590
|
+
По умолчанию в `include` и `source_types` передаются все
|
|
591
|
+
поддерживаемые значения.
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
list[ScheduleEvent]: Уроки и другие поддерживаемые события периода.
|
|
595
|
+
"""
|
|
596
|
+
if self.id is None:
|
|
597
|
+
await self.get_family_info()
|
|
598
|
+
if self.uid is None:
|
|
599
|
+
await self.get_base_info()
|
|
600
|
+
|
|
601
|
+
selected_includes = ALL_SCHEDULE_EVENT_INCLUDES if include is None else include
|
|
602
|
+
selected_source_types = tuple(ScheduleEventType) if source_types is None else source_types
|
|
603
|
+
|
|
604
|
+
response = await self._send_request(
|
|
605
|
+
'GET', 'eventcalendar/v1/api/events',
|
|
606
|
+
params={
|
|
607
|
+
'person_ids': self.uid,
|
|
608
|
+
'begin_date': from_date.isoformat(),
|
|
609
|
+
'end_date': to_date.isoformat(),
|
|
610
|
+
'expand': ','.join(selected_includes),
|
|
611
|
+
'source_types': ','.join(selected_source_types),
|
|
612
|
+
},
|
|
613
|
+
headers={
|
|
614
|
+
'Profile-Id': str(self.id),
|
|
615
|
+
'X-Mes-Role': 'student',
|
|
616
|
+
'X-mes-subsystem': 'familyweb'
|
|
617
|
+
}
|
|
618
|
+
)
|
|
619
|
+
try:
|
|
620
|
+
schedule_events = response['response']
|
|
621
|
+
if not isinstance(schedule_events, list):
|
|
622
|
+
raise TypeError('Поле response не является списком')
|
|
623
|
+
return [parse_schedule_event(schedule_event) for schedule_event in schedule_events]
|
|
624
|
+
except (KeyError, TypeError, ValidationError, ValueError) as error:
|
|
625
|
+
raise InvalidResponseException(
|
|
626
|
+
'МЭШ вернул некорректный список событий расписания'
|
|
627
|
+
) from error
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
async def get_homework(self, from_date: date, to_date: date)-> list[Homework]:
|
|
631
|
+
"""
|
|
632
|
+
Получить Д/З
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
from_date (`date`): От даты
|
|
636
|
+
to_date (`date`): До даты
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
list[Homework]: Домашние задания за указанный период.
|
|
640
|
+
"""
|
|
641
|
+
if self.id is None:
|
|
642
|
+
await self.get_family_info()
|
|
643
|
+
|
|
644
|
+
return [Homework.model_validate(homework) for homework in (await self._send_request('GET', f'family/web/v1/homeworks?from={from_date}&to={to_date}&student_id={self.id}'))['payload']]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
async def get_marks(self)-> list[SubjectMark]:
|
|
648
|
+
"""Получить оценки по всем предметам.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
list[SubjectMark]: Сводные оценки и учебные периоды по предметам.
|
|
652
|
+
"""
|
|
653
|
+
if self.id is None:
|
|
654
|
+
await self.get_family_info()
|
|
655
|
+
return [SubjectMark.model_validate(mark) for mark in (await self._send_request('GET', f'family/web/v1/subject_marks?student_id={self.id}'))['payload']]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
async def get_date_marks(self, from_date: date, to_date: date)-> list[Mark]:
|
|
659
|
+
"""
|
|
660
|
+
Получить оценки по дате
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
from_date (`date`): От даты
|
|
664
|
+
to_date (`date`): До даты
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
list[Mark]: Оценки, выставленные за указанный период.
|
|
668
|
+
"""
|
|
669
|
+
if self.id is None:
|
|
670
|
+
await self.get_family_info()
|
|
671
|
+
return [Mark.model_validate(mark) for mark in (await self._send_request('GET', f'family/web/v1/marks?student_id={self.id}&from={from_date}&to={to_date}'))['payload']]
|