tp-common 0.0.1__py3-none-any.whl → 0.0.2__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.
@@ -1,585 +1,585 @@
1
- import asyncio
2
- import json as json_lib
3
- import logging
4
- import time
5
- from datetime import UTC, datetime
6
- from typing import Any, Literal, TypeVar
7
- from urllib.parse import urlencode
8
-
9
- import aiohttp
10
- from aiohttp import (
11
- ClientConnectionError,
12
- ClientConnectorDNSError,
13
- ClientHttpProxyError,
14
- ClientProxyConnectionError,
15
- ServerTimeoutError,
16
- )
17
-
18
- from tp_common.base.base_client.client_exceptions import (
19
- BaseClientException,
20
- ClientConnectionException,
21
- ClientDNSException,
22
- ClientProxyException,
23
- ClientResponseErrorException,
24
- ClientTimeoutException,
25
- )
26
-
27
- T = TypeVar("T", bound="BaseClient")
28
-
29
-
30
- class BaseClient:
31
- """
32
- Базовый HTTP клиент для работы с внешними API.
33
-
34
- Поддерживает:
35
- - Переиспользование сессии
36
- - Прокси
37
- - Cookies
38
- - Имитацию браузера
39
- - Логирование (с автоматическим созданием логгера при необходимости)
40
- - Переопределение базового URL для тестирования
41
-
42
- Args:
43
- cookies: Строка с cookies в формате "name=value; name2=value2"
44
- proxy: URL прокси-сервера
45
- logger: Внешний логгер (если не передан, создается автоматически)
46
- base_url: Базовый URL для запросов (обязательный параметр)
47
- """
48
-
49
- DEFAULT_TIMEOUT = 30.0 # секунды
50
-
51
- def __init__(
52
- self,
53
- base_url: str,
54
- cookies: str | None = None,
55
- proxy: str | None = None,
56
- logger: logging.Logger | None = None,
57
- ):
58
- self._raw_cookies = cookies
59
- self._proxy: str | None = proxy
60
- self._session: aiohttp.ClientSession | None = None
61
- self._logger = logger or self._create_logger()
62
- self._base_url = base_url
63
-
64
- def _create_logger(self) -> logging.Logger:
65
- """Создает логгер на основе имени класса, если не передан внешний."""
66
- class_name = self.__class__.__name__
67
- module_name = self.__class__.__module__
68
- logger_name = f"{module_name}.{class_name}"
69
- return logging.getLogger(logger_name)
70
-
71
- @property
72
- def logger(self) -> logging.Logger:
73
- """Возвращает логгер для использования в наследниках."""
74
- return self._logger
75
-
76
- @logger.setter
77
- def logger(self, logger: logging.Logger) -> None:
78
- self._logger = logger
79
-
80
- async def __aenter__(self: T) -> T:
81
- """Инициализация сессии при входе в контекстный менеджер"""
82
- _ = await self._get_session()
83
- return self
84
-
85
- async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
86
- """Закрытие сессии при выходе из контекстного менеджера"""
87
- await self.close()
88
-
89
- async def _get_session(self) -> aiohttp.ClientSession:
90
- """Получает или создает переиспользуемую сессию"""
91
- if self._session is None or self._session.closed:
92
- timeout = aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
93
- connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
94
-
95
- self._session = aiohttp.ClientSession(
96
- timeout=timeout,
97
- connector=connector,
98
- cookies=self._prepare_cookies_dict(),
99
- )
100
- return self._session
101
-
102
- async def close(self) -> None:
103
- """Закрывает HTTP сессию"""
104
- if self._session and not self._session.closed:
105
- await self._session.close()
106
- self._session = None
107
-
108
- def _get_default_headers(self) -> dict[str, str]:
109
- """Возвращает заголовки по умолчанию для имитации браузера"""
110
- return {
111
- "accept": "*/*",
112
- "accept-language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
113
- "sec-ch-ua": '"Chromium";v="122"',
114
- "sec-ch-ua-mobile": "?0",
115
- "sec-fetch-dest": "empty",
116
- "sec-fetch-mode": "cors",
117
- "sec-fetch-site": "same-origin",
118
- "referer": f"{self._base_url}/",
119
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
120
- }
121
-
122
- @property
123
- def cookies(self) -> str | None:
124
- """Возвращает текущие cookies"""
125
- return self._raw_cookies
126
-
127
- @cookies.setter
128
- def cookies(self, cookies: str) -> None:
129
- """Устанавливает новые cookies (требует пересоздания сессии)"""
130
- self._raw_cookies = cookies
131
- # Сразу обнуляем _session, чтобы следующий _get_session() создал новую сессию
132
- # с новыми cookies; старую сессию закрываем в фоне (иначе гонка: запрос
133
- # может получить уже закрываемую сессию → "Connector is closed")
134
- old_session = self._session
135
- self._session = None
136
- if old_session and not old_session.closed:
137
-
138
- async def _close_old() -> None:
139
- await old_session.close()
140
-
141
- asyncio.create_task(_close_old())
142
-
143
- @property
144
- def proxy(self) -> str | None:
145
- """Возвращает текущий прокси"""
146
- return self._proxy
147
-
148
- @proxy.setter
149
- def proxy(self, proxy: str) -> None:
150
- """Устанавливает новый прокси"""
151
- self._proxy = proxy
152
-
153
- def _prepare_cookies_dict(self) -> dict[str, str] | None:
154
- """Подготавливает словарь cookies из строки для aiohttp"""
155
- if not self._raw_cookies:
156
- return None
157
-
158
- cookies_dict: dict[str, str] = {}
159
-
160
- # Парсим строку cookies в формате "name=value; name2=value2"
161
- for cookie_str in self._raw_cookies.split(";"):
162
- cookie_str = cookie_str.strip()
163
- if not cookie_str:
164
- continue
165
- if "=" in cookie_str:
166
- name, value = cookie_str.split("=", 1)
167
- cookies_dict[name.strip()] = value.strip()
168
-
169
- return cookies_dict if cookies_dict else None
170
-
171
- def extract_cookies_from_session(self) -> str | None:
172
- """
173
- Извлекает cookies из текущей сессии в строку формата 'name=value; name2=value2'.
174
-
175
- Returns:
176
- Строка с кукисами в формате 'name=value; name2=value2' или None
177
- """
178
- if not self._session or self._session.closed:
179
- return self._raw_cookies
180
-
181
- cookies_list: list[str] = []
182
- for cookie in self._session.cookie_jar:
183
- cookies_list.append(f"{cookie.key}={cookie.value}")
184
-
185
- if not cookies_list:
186
- return self._raw_cookies
187
-
188
- return "; ".join(cookies_list)
189
-
190
- def _build_url(self, uri: str, params: str | dict[str, Any] | None = None) -> str:
191
- """Строит полный URL из базового адреса, URI и параметров"""
192
- url = f"{self._base_url}/{uri.lstrip('/')}"
193
-
194
- if params and isinstance(params, dict):
195
- # Фильтруем None значения и конвертируем все значения в строки для urlencode
196
- filtered_params: dict[str, str] = {}
197
- for key, val in params.items():
198
- if val is not None:
199
- # Для datetime объектов используем ISO формат с Z и миллисекундами
200
- if isinstance(val, datetime):
201
- # Формат: 2026-01-20T00:00:00.000Z
202
- # Если datetime имеет timezone, конвертируем в UTC, иначе используем как есть
203
- if val.tzinfo is not None:
204
- # Конвертируем в UTC
205
- val_utc = val.astimezone(UTC)
206
- filtered_params[key] = (
207
- val_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
208
- )
209
- else:
210
- # Naive datetime - используем как есть и добавляем Z
211
- filtered_params[key] = (
212
- val.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
213
- )
214
- else:
215
- filtered_params[key] = str(val)
216
- if filtered_params:
217
- # Используем urlencode для правильного кодирования параметров URL
218
- query_string = urlencode(filtered_params, doseq=False)
219
- url += "?" + query_string
220
- elif params and isinstance(params, str):
221
- url += f"?{params}"
222
-
223
- return url
224
-
225
- def _merge_headers(self, custom_headers: dict[str, Any] | None) -> dict[str, str]:
226
- """Объединяет заголовки по умолчанию с пользовательскими"""
227
- headers = self._get_default_headers()
228
- if custom_headers:
229
- headers.update(custom_headers)
230
- return headers
231
-
232
- def _prepare_payload(
233
- self, json: dict[str, Any] | None, data: Any | None
234
- ) -> dict[str, Any] | list[Any] | str | None:
235
- """Подготавливает payload для логирования"""
236
- if json is not None:
237
- return json
238
- if data is not None:
239
- if isinstance(data, str):
240
- try:
241
- return json_lib.loads(data)
242
- except (json_lib.JSONDecodeError, TypeError):
243
- return None
244
- elif isinstance(data, (dict, list)):
245
- return data
246
- return None
247
- return None
248
-
249
- def _prepare_response(self, response_text: str) -> dict[str, Any] | list[Any] | str:
250
- """Подготавливает response для логирования: пытается распарсить JSON, иначе возвращает текст"""
251
- if not response_text:
252
- return response_text
253
- try:
254
- return json_lib.loads(response_text)
255
- except (json_lib.JSONDecodeError, TypeError):
256
- return response_text
257
-
258
- def _decode_response_body(
259
- self, body_bytes: bytes, response: aiohttp.ClientResponse
260
- ) -> str:
261
- """
262
- Декодирует тело ответа в строку с учётом кодировки из заголовков
263
- и резервных кодировок при ошибке UTF-8 (например, cp1251 для российских API).
264
- """
265
- encodings_to_try: list[str | tuple[str, str]] = []
266
- if response.charset:
267
- encodings_to_try.append(response.charset)
268
- encodings_to_try.append("utf-8")
269
- encodings_to_try.append("cp1251") # Windows-1251, типично для российских API
270
- encodings_to_try.append(("iso-8859-1", "replace"))
271
- for entry in encodings_to_try:
272
- if isinstance(entry, tuple):
273
- encoding, errors = entry
274
- else:
275
- encoding, errors = entry, "strict"
276
- try:
277
- return body_bytes.decode(encoding, errors=errors)
278
- except (UnicodeDecodeError, LookupError):
279
- continue
280
- return body_bytes.decode("iso-8859-1", errors="replace")
281
-
282
- async def _make_request(
283
- self,
284
- method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
285
- uri: str,
286
- headers: dict[str, Any] | None = None,
287
- params: str | dict[str, Any] | None = None,
288
- json: dict[str, Any] | None = None,
289
- data: Any | None = None,
290
- retry_on_proxy_error: bool = False,
291
- max_retries: int = 5,
292
- ) -> str:
293
- """
294
- Базовый метод для выполнения HTTP запросов. Возвращает только строку.
295
-
296
- Args:
297
- method: HTTP метод
298
- uri: URI endpoint (будет добавлен к base_url)
299
- headers: Дополнительные заголовки
300
- params: Query параметры
301
- json: JSON тело запроса (для POST/PUT/PATCH)
302
- data: Данные тела запроса (альтернатива json)
303
- retry_on_proxy_error: Повторять ли запрос при ошибках прокси
304
- max_retries: Максимальное количество попыток при retry_on_proxy_error
305
-
306
- Returns:
307
- Текст ответа как строка
308
-
309
- Raises:
310
- ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
311
- ClientTimeoutException: При таймауте соединения
312
- ClientProxyException: При ошибке прокси
313
- ClientConnectionException: При ошибке соединения
314
- ClientDNSException: При ошибке DNS
315
- """
316
- url = self._build_url(uri, params)
317
- merged_headers = self._merge_headers(headers)
318
- session = await self._get_session()
319
-
320
- # Определяем kwargs для запроса
321
- request_kwargs: dict[str, Any] = {
322
- "url": url,
323
- "headers": merged_headers,
324
- "proxy": self._proxy,
325
- }
326
-
327
- if json is not None:
328
- request_kwargs["json"] = json
329
- elif data is not None:
330
- request_kwargs["data"] = data
331
-
332
- payload = self._prepare_payload(json, data)
333
- amount_failed = 0
334
-
335
- while True:
336
- response_body_text = ""
337
- start_time = time.perf_counter()
338
- try:
339
- async with session.request(method, **request_kwargs) as response:
340
- body_bytes = await response.read()
341
- response_body_text = self._decode_response_body(
342
- body_bytes, response
343
- )
344
- end_time = time.perf_counter()
345
- duration_ms = int((end_time - start_time) * 1000)
346
-
347
- response_data = self._prepare_response(response_body_text)
348
-
349
- self._logger.info(
350
- "HTTP запрос выполнен",
351
- extra={
352
- "url": url,
353
- "method": method,
354
- "status_code": response.status,
355
- "payload": payload,
356
- "response": response_data,
357
- "duration": duration_ms,
358
- },
359
- )
360
-
361
- if response.status >= 400:
362
- raise ClientResponseErrorException(
363
- f"HTTP {response.status} error: {response.reason}",
364
- url=url,
365
- status_code=response.status,
366
- response_body=response_body_text,
367
- )
368
-
369
- return response_body_text
370
-
371
- except (ClientHttpProxyError, ClientProxyConnectionError) as e:
372
- if not retry_on_proxy_error:
373
- self._logger.error(
374
- "Ошибка прокси при HTTP запросе",
375
- extra={
376
- "method": method,
377
- "url": url,
378
- "proxy": self._proxy,
379
- "error": str(e),
380
- },
381
- )
382
- raise ClientProxyException(
383
- f"Proxy error: {str(e)}",
384
- url=url,
385
- proxy=self._proxy,
386
- ) from e
387
-
388
- amount_failed += 1
389
- if amount_failed >= max_retries:
390
- self._logger.error(
391
- "Ошибка прокси после исчерпания попыток",
392
- extra={
393
- "method": method,
394
- "url": url,
395
- "proxy": self._proxy,
396
- "retries": amount_failed,
397
- "error": str(e),
398
- },
399
- )
400
- raise ClientProxyException(
401
- f"Proxy error after {max_retries} retries: {str(e)}",
402
- url=url,
403
- proxy=self._proxy,
404
- ) from e
405
-
406
- self._logger.warning(
407
- "Повтор запроса после ошибки прокси",
408
- extra={
409
- "method": method,
410
- "url": url,
411
- "proxy": self._proxy,
412
- "retry": amount_failed,
413
- "max_retries": max_retries,
414
- },
415
- )
416
- continue
417
-
418
- except (TimeoutError, ServerTimeoutError) as e:
419
- # self._logger.error(
420
- # "Таймаут HTTP запроса",
421
- # extra={
422
- # "method": method,
423
- # "url": url,
424
- # "timeout": self.DEFAULT_TIMEOUT,
425
- # "error": str(e),
426
- # },
427
- # )
428
- raise ClientTimeoutException(
429
- f"Request timeout after {self.DEFAULT_TIMEOUT}s: {str(e)}",
430
- url=url,
431
- ) from e
432
-
433
- except ClientConnectorDNSError as e:
434
- self._logger.error(
435
- "Ошибка DNS при HTTP запросе",
436
- extra={
437
- "method": method,
438
- "url": url,
439
- "error": str(e),
440
- },
441
- )
442
- raise ClientDNSException(
443
- f"DNS resolution failed: {str(e)}",
444
- url=url,
445
- ) from e
446
-
447
- except ClientConnectionError as e:
448
- self._logger.error(
449
- "Ошибка соединения при HTTP запросе",
450
- extra={
451
- "method": method,
452
- "url": url,
453
- "error": str(e),
454
- },
455
- )
456
- raise ClientConnectionException(
457
- f"Connection error: {str(e)}",
458
- url=url,
459
- ) from e
460
- except ClientResponseErrorException as e:
461
- raise e
462
- except Exception as e:
463
- # Неожиданные ошибки
464
- self._logger.error(
465
- "Неожиданная ошибка HTTP запроса",
466
- extra={
467
- "method": method,
468
- "url": url,
469
- "error_type": type(e).__name__,
470
- "error": str(e),
471
- "response": response_body_text or "",
472
- },
473
- )
474
- raise BaseClientException(
475
- f"Неожиданная HTTP-ошибка: {str(e)}",
476
- url=url,
477
- ) from e
478
-
479
- async def request_text(
480
- self,
481
- method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
482
- uri: str,
483
- headers: dict[str, Any] | None = None,
484
- params: str | dict[str, Any] | None = None,
485
- json: dict[str, Any] | None = None,
486
- data: Any | None = None,
487
- retry_on_proxy_error: bool = False,
488
- max_retries: int = 5,
489
- ) -> str:
490
- """
491
- Выполняет HTTP запрос и возвращает ответ как строку.
492
-
493
- Используется для получения HTML, XML или других текстовых форматов.
494
-
495
- Args:
496
- method: HTTP метод
497
- uri: URI endpoint (будет добавлен к base_url)
498
- headers: Дополнительные заголовки
499
- params: Query параметры
500
- json: JSON тело запроса (для POST/PUT/PATCH)
501
- data: Данные тела запроса (альтернатива json)
502
- retry_on_proxy_error: Повторять ли запрос при ошибках прокси
503
- max_retries: Максимальное количество попыток при retry_on_proxy_error
504
-
505
- Returns:
506
- Текст ответа как строка
507
-
508
- Raises:
509
- ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
510
- ClientTimeoutException: При таймауте соединения
511
- ClientProxyException: При ошибке прокси
512
- ClientConnectionException: При ошибке соединения
513
- ClientDNSException: При ошибке DNS
514
- """
515
- return await self._make_request(
516
- method=method,
517
- uri=uri,
518
- headers=headers,
519
- params=params,
520
- json=json,
521
- data=data,
522
- retry_on_proxy_error=retry_on_proxy_error,
523
- max_retries=max_retries,
524
- )
525
-
526
- async def request_json(
527
- self,
528
- method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
529
- uri: str,
530
- headers: dict[str, Any] | None = None,
531
- params: str | dict[str, Any] | None = None,
532
- json: dict[str, Any] | None = None,
533
- data: Any | None = None,
534
- retry_on_proxy_error: bool = False,
535
- max_retries: int = 5,
536
- ) -> dict[str, Any] | list[Any]:
537
- """
538
- Выполняет HTTP запрос и возвращает ответ как распарсенный JSON.
539
-
540
- Args:
541
- method: HTTP метод
542
- uri: URI endpoint (будет добавлен к base_url)
543
- headers: Дополнительные заголовки
544
- params: Query параметры
545
- json: JSON тело запроса (для POST/PUT/PATCH)
546
- data: Данные тела запроса (альтернатива json)
547
- retry_on_proxy_error: Повторять ли запрос при ошибках прокси
548
- max_retries: Максимальное количество попыток при retry_on_proxy_error
549
-
550
- Returns:
551
- Распарсенный JSON (dict или list)
552
-
553
- Raises:
554
- ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
555
- ClientTimeoutException: При таймауте соединения
556
- ClientProxyException: При ошибке прокси
557
- ClientConnectionException: При ошибке соединения
558
- ClientDNSException: При ошибке DNS
559
- json.JSONDecodeError: Если ответ не является валидным JSON
560
- """
561
- response_text = await self._make_request(
562
- method=method,
563
- uri=uri,
564
- headers=headers,
565
- params=params,
566
- json=json,
567
- data=data,
568
- retry_on_proxy_error=retry_on_proxy_error,
569
- max_retries=max_retries,
570
- )
571
-
572
- try:
573
- return json_lib.loads(response_text)
574
- except json_lib.JSONDecodeError as e:
575
- url = self._build_url(uri, params)
576
- self._logger.error(
577
- "Не удалось распарсить ответ как JSON",
578
- extra={
579
- "method": method,
580
- "url": url,
581
- "response": response_text,
582
- "error": str(e),
583
- },
584
- )
585
- raise
1
+ import asyncio
2
+ import json as json_lib
3
+ import logging
4
+ import time
5
+ from datetime import UTC, datetime
6
+ from typing import Any, Literal, TypeVar
7
+ from urllib.parse import urlencode
8
+
9
+ import aiohttp
10
+ from aiohttp import (
11
+ ClientConnectionError,
12
+ ClientConnectorDNSError,
13
+ ClientHttpProxyError,
14
+ ClientProxyConnectionError,
15
+ ServerTimeoutError,
16
+ )
17
+
18
+ from tp_common.base.base_client.client_exceptions import (
19
+ BaseClientException,
20
+ ClientConnectionException,
21
+ ClientDNSException,
22
+ ClientProxyException,
23
+ ClientResponseErrorException,
24
+ ClientTimeoutException,
25
+ )
26
+
27
+ T = TypeVar("T", bound="BaseClient")
28
+
29
+
30
+ class BaseClient:
31
+ """
32
+ Базовый HTTP клиент для работы с внешними API.
33
+
34
+ Поддерживает:
35
+ - Переиспользование сессии
36
+ - Прокси
37
+ - Cookies
38
+ - Имитацию браузера
39
+ - Логирование (с автоматическим созданием логгера при необходимости)
40
+ - Переопределение базового URL для тестирования
41
+
42
+ Args:
43
+ cookies: Строка с cookies в формате "name=value; name2=value2"
44
+ proxy: URL прокси-сервера
45
+ logger: Внешний логгер (если не передан, создается автоматически)
46
+ base_url: Базовый URL для запросов (обязательный параметр)
47
+ """
48
+
49
+ DEFAULT_TIMEOUT = 30.0 # секунды
50
+
51
+ def __init__(
52
+ self,
53
+ base_url: str,
54
+ cookies: str | None = None,
55
+ proxy: str | None = None,
56
+ logger: logging.Logger | None = None,
57
+ ):
58
+ self._raw_cookies = cookies
59
+ self._proxy: str | None = proxy
60
+ self._session: aiohttp.ClientSession | None = None
61
+ self._logger = logger or self._create_logger()
62
+ self._base_url = base_url
63
+
64
+ def _create_logger(self) -> logging.Logger:
65
+ """Создает логгер на основе имени класса, если не передан внешний."""
66
+ class_name = self.__class__.__name__
67
+ module_name = self.__class__.__module__
68
+ logger_name = f"{module_name}.{class_name}"
69
+ return logging.getLogger(logger_name)
70
+
71
+ @property
72
+ def logger(self) -> logging.Logger:
73
+ """Возвращает логгер для использования в наследниках."""
74
+ return self._logger
75
+
76
+ @logger.setter
77
+ def logger(self, logger: logging.Logger) -> None:
78
+ self._logger = logger
79
+
80
+ async def __aenter__(self: T) -> T:
81
+ """Инициализация сессии при входе в контекстный менеджер"""
82
+ _ = await self._get_session()
83
+ return self
84
+
85
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
86
+ """Закрытие сессии при выходе из контекстного менеджера"""
87
+ await self.close()
88
+
89
+ async def _get_session(self) -> aiohttp.ClientSession:
90
+ """Получает или создает переиспользуемую сессию"""
91
+ if self._session is None or self._session.closed:
92
+ timeout = aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
93
+ connector = aiohttp.TCPConnector(limit=100, limit_per_host=30)
94
+
95
+ self._session = aiohttp.ClientSession(
96
+ timeout=timeout,
97
+ connector=connector,
98
+ cookies=self._prepare_cookies_dict(),
99
+ )
100
+ return self._session
101
+
102
+ async def close(self) -> None:
103
+ """Закрывает HTTP сессию"""
104
+ if self._session and not self._session.closed:
105
+ await self._session.close()
106
+ self._session = None
107
+
108
+ def _get_default_headers(self) -> dict[str, str]:
109
+ """Возвращает заголовки по умолчанию для имитации браузера"""
110
+ return {
111
+ "accept": "*/*",
112
+ "accept-language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
113
+ "sec-ch-ua": '"Chromium";v="122"',
114
+ "sec-ch-ua-mobile": "?0",
115
+ "sec-fetch-dest": "empty",
116
+ "sec-fetch-mode": "cors",
117
+ "sec-fetch-site": "same-origin",
118
+ "referer": f"{self._base_url}/",
119
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
120
+ }
121
+
122
+ @property
123
+ def cookies(self) -> str | None:
124
+ """Возвращает текущие cookies"""
125
+ return self._raw_cookies
126
+
127
+ @cookies.setter
128
+ def cookies(self, cookies: str) -> None:
129
+ """Устанавливает новые cookies (требует пересоздания сессии)"""
130
+ self._raw_cookies = cookies
131
+ # Сразу обнуляем _session, чтобы следующий _get_session() создал новую сессию
132
+ # с новыми cookies; старую сессию закрываем в фоне (иначе гонка: запрос
133
+ # может получить уже закрываемую сессию → "Connector is closed")
134
+ old_session = self._session
135
+ self._session = None
136
+ if old_session and not old_session.closed:
137
+
138
+ async def _close_old() -> None:
139
+ await old_session.close()
140
+
141
+ asyncio.create_task(_close_old())
142
+
143
+ @property
144
+ def proxy(self) -> str | None:
145
+ """Возвращает текущий прокси"""
146
+ return self._proxy
147
+
148
+ @proxy.setter
149
+ def proxy(self, proxy: str) -> None:
150
+ """Устанавливает новый прокси"""
151
+ self._proxy = proxy
152
+
153
+ def _prepare_cookies_dict(self) -> dict[str, str] | None:
154
+ """Подготавливает словарь cookies из строки для aiohttp"""
155
+ if not self._raw_cookies:
156
+ return None
157
+
158
+ cookies_dict: dict[str, str] = {}
159
+
160
+ # Парсим строку cookies в формате "name=value; name2=value2"
161
+ for cookie_str in self._raw_cookies.split(";"):
162
+ cookie_str = cookie_str.strip()
163
+ if not cookie_str:
164
+ continue
165
+ if "=" in cookie_str:
166
+ name, value = cookie_str.split("=", 1)
167
+ cookies_dict[name.strip()] = value.strip()
168
+
169
+ return cookies_dict if cookies_dict else None
170
+
171
+ def extract_cookies_from_session(self) -> str | None:
172
+ """
173
+ Извлекает cookies из текущей сессии в строку формата 'name=value; name2=value2'.
174
+
175
+ Returns:
176
+ Строка с кукисами в формате 'name=value; name2=value2' или None
177
+ """
178
+ if not self._session or self._session.closed:
179
+ return self._raw_cookies
180
+
181
+ cookies_list: list[str] = []
182
+ for cookie in self._session.cookie_jar:
183
+ cookies_list.append(f"{cookie.key}={cookie.value}")
184
+
185
+ if not cookies_list:
186
+ return self._raw_cookies
187
+
188
+ return "; ".join(cookies_list)
189
+
190
+ def _build_url(self, uri: str, params: str | dict[str, Any] | None = None) -> str:
191
+ """Строит полный URL из базового адреса, URI и параметров"""
192
+ url = f"{self._base_url}/{uri.lstrip('/')}"
193
+
194
+ if params and isinstance(params, dict):
195
+ # Фильтруем None значения и конвертируем все значения в строки для urlencode
196
+ filtered_params: dict[str, str] = {}
197
+ for key, val in params.items():
198
+ if val is not None:
199
+ # Для datetime объектов используем ISO формат с Z и миллисекундами
200
+ if isinstance(val, datetime):
201
+ # Формат: 2026-01-20T00:00:00.000Z
202
+ # Если datetime имеет timezone, конвертируем в UTC, иначе используем как есть
203
+ if val.tzinfo is not None:
204
+ # Конвертируем в UTC
205
+ val_utc = val.astimezone(UTC)
206
+ filtered_params[key] = (
207
+ val_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
208
+ )
209
+ else:
210
+ # Naive datetime - используем как есть и добавляем Z
211
+ filtered_params[key] = (
212
+ val.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
213
+ )
214
+ else:
215
+ filtered_params[key] = str(val)
216
+ if filtered_params:
217
+ # Используем urlencode для правильного кодирования параметров URL
218
+ query_string = urlencode(filtered_params, doseq=False)
219
+ url += "?" + query_string
220
+ elif params and isinstance(params, str):
221
+ url += f"?{params}"
222
+
223
+ return url
224
+
225
+ def _merge_headers(self, custom_headers: dict[str, Any] | None) -> dict[str, str]:
226
+ """Объединяет заголовки по умолчанию с пользовательскими"""
227
+ headers = self._get_default_headers()
228
+ if custom_headers:
229
+ headers.update(custom_headers)
230
+ return headers
231
+
232
+ def _prepare_payload(
233
+ self, json: dict[str, Any] | None, data: Any | None
234
+ ) -> dict[str, Any] | list[Any] | str | None:
235
+ """Подготавливает payload для логирования"""
236
+ if json is not None:
237
+ return json
238
+ if data is not None:
239
+ if isinstance(data, str):
240
+ try:
241
+ return json_lib.loads(data)
242
+ except (json_lib.JSONDecodeError, TypeError):
243
+ return None
244
+ elif isinstance(data, (dict, list)):
245
+ return data
246
+ return None
247
+ return None
248
+
249
+ def _prepare_response(self, response_text: str) -> dict[str, Any] | list[Any] | str:
250
+ """Подготавливает response для логирования: пытается распарсить JSON, иначе возвращает текст"""
251
+ if not response_text:
252
+ return response_text
253
+ try:
254
+ return json_lib.loads(response_text)
255
+ except (json_lib.JSONDecodeError, TypeError):
256
+ return response_text
257
+
258
+ def _decode_response_body(
259
+ self, body_bytes: bytes, response: aiohttp.ClientResponse
260
+ ) -> str:
261
+ """
262
+ Декодирует тело ответа в строку с учётом кодировки из заголовков
263
+ и резервных кодировок при ошибке UTF-8 (например, cp1251 для российских API).
264
+ """
265
+ encodings_to_try: list[str | tuple[str, str]] = []
266
+ if response.charset:
267
+ encodings_to_try.append(response.charset)
268
+ encodings_to_try.append("utf-8")
269
+ encodings_to_try.append("cp1251") # Windows-1251, типично для российских API
270
+ encodings_to_try.append(("iso-8859-1", "replace"))
271
+ for entry in encodings_to_try:
272
+ if isinstance(entry, tuple):
273
+ encoding, errors = entry
274
+ else:
275
+ encoding, errors = entry, "strict"
276
+ try:
277
+ return body_bytes.decode(encoding, errors=errors)
278
+ except (UnicodeDecodeError, LookupError):
279
+ continue
280
+ return body_bytes.decode("iso-8859-1", errors="replace")
281
+
282
+ async def _make_request(
283
+ self,
284
+ method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
285
+ uri: str,
286
+ headers: dict[str, Any] | None = None,
287
+ params: str | dict[str, Any] | None = None,
288
+ json: dict[str, Any] | None = None,
289
+ data: Any | None = None,
290
+ retry_on_proxy_error: bool = False,
291
+ max_retries: int = 5,
292
+ ) -> str:
293
+ """
294
+ Базовый метод для выполнения HTTP запросов. Возвращает только строку.
295
+
296
+ Args:
297
+ method: HTTP метод
298
+ uri: URI endpoint (будет добавлен к base_url)
299
+ headers: Дополнительные заголовки
300
+ params: Query параметры
301
+ json: JSON тело запроса (для POST/PUT/PATCH)
302
+ data: Данные тела запроса (альтернатива json)
303
+ retry_on_proxy_error: Повторять ли запрос при ошибках прокси
304
+ max_retries: Максимальное количество попыток при retry_on_proxy_error
305
+
306
+ Returns:
307
+ Текст ответа как строка
308
+
309
+ Raises:
310
+ ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
311
+ ClientTimeoutException: При таймауте соединения
312
+ ClientProxyException: При ошибке прокси
313
+ ClientConnectionException: При ошибке соединения
314
+ ClientDNSException: При ошибке DNS
315
+ """
316
+ url = self._build_url(uri, params)
317
+ merged_headers = self._merge_headers(headers)
318
+ session = await self._get_session()
319
+
320
+ # Определяем kwargs для запроса
321
+ request_kwargs: dict[str, Any] = {
322
+ "url": url,
323
+ "headers": merged_headers,
324
+ "proxy": self._proxy,
325
+ }
326
+
327
+ if json is not None:
328
+ request_kwargs["json"] = json
329
+ elif data is not None:
330
+ request_kwargs["data"] = data
331
+
332
+ payload = self._prepare_payload(json, data)
333
+ amount_failed = 0
334
+
335
+ while True:
336
+ response_body_text = ""
337
+ start_time = time.perf_counter()
338
+ try:
339
+ async with session.request(method, **request_kwargs) as response:
340
+ body_bytes = await response.read()
341
+ response_body_text = self._decode_response_body(
342
+ body_bytes, response
343
+ )
344
+ end_time = time.perf_counter()
345
+ duration_ms = int((end_time - start_time) * 1000)
346
+
347
+ response_data = self._prepare_response(response_body_text)
348
+
349
+ self._logger.info(
350
+ "HTTP запрос выполнен",
351
+ extra={
352
+ "url": url,
353
+ "method": method,
354
+ "status_code": response.status,
355
+ "payload": payload,
356
+ "response": response_data,
357
+ "duration": duration_ms,
358
+ },
359
+ )
360
+
361
+ if response.status >= 400:
362
+ raise ClientResponseErrorException(
363
+ f"HTTP {response.status} error: {response.reason}",
364
+ url=url,
365
+ status_code=response.status,
366
+ response_body=response_body_text,
367
+ )
368
+
369
+ return response_body_text
370
+
371
+ except (ClientHttpProxyError, ClientProxyConnectionError) as e:
372
+ if not retry_on_proxy_error:
373
+ self._logger.error(
374
+ "Ошибка прокси при HTTP запросе",
375
+ extra={
376
+ "method": method,
377
+ "url": url,
378
+ "proxy": self._proxy,
379
+ "error": str(e),
380
+ },
381
+ )
382
+ raise ClientProxyException(
383
+ f"Proxy error: {str(e)}",
384
+ url=url,
385
+ proxy=self._proxy,
386
+ ) from e
387
+
388
+ amount_failed += 1
389
+ if amount_failed >= max_retries:
390
+ self._logger.error(
391
+ "Ошибка прокси после исчерпания попыток",
392
+ extra={
393
+ "method": method,
394
+ "url": url,
395
+ "proxy": self._proxy,
396
+ "retries": amount_failed,
397
+ "error": str(e),
398
+ },
399
+ )
400
+ raise ClientProxyException(
401
+ f"Proxy error after {max_retries} retries: {str(e)}",
402
+ url=url,
403
+ proxy=self._proxy,
404
+ ) from e
405
+
406
+ self._logger.warning(
407
+ "Повтор запроса после ошибки прокси",
408
+ extra={
409
+ "method": method,
410
+ "url": url,
411
+ "proxy": self._proxy,
412
+ "retry": amount_failed,
413
+ "max_retries": max_retries,
414
+ },
415
+ )
416
+ continue
417
+
418
+ except (TimeoutError, ServerTimeoutError) as e:
419
+ # self._logger.error(
420
+ # "Таймаут HTTP запроса",
421
+ # extra={
422
+ # "method": method,
423
+ # "url": url,
424
+ # "timeout": self.DEFAULT_TIMEOUT,
425
+ # "error": str(e),
426
+ # },
427
+ # )
428
+ raise ClientTimeoutException(
429
+ f"Request timeout after {self.DEFAULT_TIMEOUT}s: {str(e)}",
430
+ url=url,
431
+ ) from e
432
+
433
+ except ClientConnectorDNSError as e:
434
+ self._logger.error(
435
+ "Ошибка DNS при HTTP запросе",
436
+ extra={
437
+ "method": method,
438
+ "url": url,
439
+ "error": str(e),
440
+ },
441
+ )
442
+ raise ClientDNSException(
443
+ f"DNS resolution failed: {str(e)}",
444
+ url=url,
445
+ ) from e
446
+
447
+ except ClientConnectionError as e:
448
+ self._logger.error(
449
+ "Ошибка соединения при HTTP запросе",
450
+ extra={
451
+ "method": method,
452
+ "url": url,
453
+ "error": str(e),
454
+ },
455
+ )
456
+ raise ClientConnectionException(
457
+ f"Connection error: {str(e)}",
458
+ url=url,
459
+ ) from e
460
+ except ClientResponseErrorException as e:
461
+ raise e
462
+ except Exception as e:
463
+ # Неожиданные ошибки
464
+ self._logger.error(
465
+ "Неожиданная ошибка HTTP запроса",
466
+ extra={
467
+ "method": method,
468
+ "url": url,
469
+ "error_type": type(e).__name__,
470
+ "error": str(e),
471
+ "response": response_body_text or "",
472
+ },
473
+ )
474
+ raise BaseClientException(
475
+ f"Неожиданная HTTP-ошибка: {str(e)}",
476
+ url=url,
477
+ ) from e
478
+
479
+ async def request_text(
480
+ self,
481
+ method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
482
+ uri: str,
483
+ headers: dict[str, Any] | None = None,
484
+ params: str | dict[str, Any] | None = None,
485
+ json: dict[str, Any] | None = None,
486
+ data: Any | None = None,
487
+ retry_on_proxy_error: bool = False,
488
+ max_retries: int = 5,
489
+ ) -> str:
490
+ """
491
+ Выполняет HTTP запрос и возвращает ответ как строку.
492
+
493
+ Используется для получения HTML, XML или других текстовых форматов.
494
+
495
+ Args:
496
+ method: HTTP метод
497
+ uri: URI endpoint (будет добавлен к base_url)
498
+ headers: Дополнительные заголовки
499
+ params: Query параметры
500
+ json: JSON тело запроса (для POST/PUT/PATCH)
501
+ data: Данные тела запроса (альтернатива json)
502
+ retry_on_proxy_error: Повторять ли запрос при ошибках прокси
503
+ max_retries: Максимальное количество попыток при retry_on_proxy_error
504
+
505
+ Returns:
506
+ Текст ответа как строка
507
+
508
+ Raises:
509
+ ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
510
+ ClientTimeoutException: При таймауте соединения
511
+ ClientProxyException: При ошибке прокси
512
+ ClientConnectionException: При ошибке соединения
513
+ ClientDNSException: При ошибке DNS
514
+ """
515
+ return await self._make_request(
516
+ method=method,
517
+ uri=uri,
518
+ headers=headers,
519
+ params=params,
520
+ json=json,
521
+ data=data,
522
+ retry_on_proxy_error=retry_on_proxy_error,
523
+ max_retries=max_retries,
524
+ )
525
+
526
+ async def request_json(
527
+ self,
528
+ method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"],
529
+ uri: str,
530
+ headers: dict[str, Any] | None = None,
531
+ params: str | dict[str, Any] | None = None,
532
+ json: dict[str, Any] | None = None,
533
+ data: Any | None = None,
534
+ retry_on_proxy_error: bool = False,
535
+ max_retries: int = 5,
536
+ ) -> dict[str, Any] | list[Any]:
537
+ """
538
+ Выполняет HTTP запрос и возвращает ответ как распарсенный JSON.
539
+
540
+ Args:
541
+ method: HTTP метод
542
+ uri: URI endpoint (будет добавлен к base_url)
543
+ headers: Дополнительные заголовки
544
+ params: Query параметры
545
+ json: JSON тело запроса (для POST/PUT/PATCH)
546
+ data: Данные тела запроса (альтернатива json)
547
+ retry_on_proxy_error: Повторять ли запрос при ошибках прокси
548
+ max_retries: Максимальное количество попыток при retry_on_proxy_error
549
+
550
+ Returns:
551
+ Распарсенный JSON (dict или list)
552
+
553
+ Raises:
554
+ ClientResponseErrorException: При неуспешном HTTP статусе (>=400)
555
+ ClientTimeoutException: При таймауте соединения
556
+ ClientProxyException: При ошибке прокси
557
+ ClientConnectionException: При ошибке соединения
558
+ ClientDNSException: При ошибке DNS
559
+ json.JSONDecodeError: Если ответ не является валидным JSON
560
+ """
561
+ response_text = await self._make_request(
562
+ method=method,
563
+ uri=uri,
564
+ headers=headers,
565
+ params=params,
566
+ json=json,
567
+ data=data,
568
+ retry_on_proxy_error=retry_on_proxy_error,
569
+ max_retries=max_retries,
570
+ )
571
+
572
+ try:
573
+ return json_lib.loads(response_text)
574
+ except json_lib.JSONDecodeError as e:
575
+ url = self._build_url(uri, params)
576
+ self._logger.error(
577
+ "Не удалось распарсить ответ как JSON",
578
+ extra={
579
+ "method": method,
580
+ "url": url,
581
+ "response": response_text,
582
+ "error": str(e),
583
+ },
584
+ )
585
+ raise