hh-applicant-tool 0.6.12__py3-none-any.whl → 1.4.7__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.
Files changed (75) hide show
  1. hh_applicant_tool/__init__.py +1 -0
  2. hh_applicant_tool/__main__.py +1 -1
  3. hh_applicant_tool/ai/base.py +2 -0
  4. hh_applicant_tool/ai/openai.py +24 -30
  5. hh_applicant_tool/api/client.py +82 -98
  6. hh_applicant_tool/api/errors.py +57 -8
  7. hh_applicant_tool/constants.py +0 -3
  8. hh_applicant_tool/datatypes.py +291 -0
  9. hh_applicant_tool/main.py +236 -82
  10. hh_applicant_tool/operations/apply_similar.py +268 -348
  11. hh_applicant_tool/operations/authorize.py +245 -70
  12. hh_applicant_tool/operations/call_api.py +18 -8
  13. hh_applicant_tool/operations/check_negotiations.py +102 -0
  14. hh_applicant_tool/operations/check_proxy.py +30 -0
  15. hh_applicant_tool/operations/config.py +119 -18
  16. hh_applicant_tool/operations/install.py +34 -0
  17. hh_applicant_tool/operations/list_resumes.py +24 -10
  18. hh_applicant_tool/operations/log.py +77 -0
  19. hh_applicant_tool/operations/migrate_db.py +65 -0
  20. hh_applicant_tool/operations/query.py +120 -0
  21. hh_applicant_tool/operations/refresh_token.py +14 -13
  22. hh_applicant_tool/operations/reply_employers.py +148 -167
  23. hh_applicant_tool/operations/settings.py +95 -0
  24. hh_applicant_tool/operations/uninstall.py +26 -0
  25. hh_applicant_tool/operations/update_resumes.py +21 -10
  26. hh_applicant_tool/operations/whoami.py +40 -7
  27. hh_applicant_tool/storage/__init__.py +4 -0
  28. hh_applicant_tool/storage/facade.py +24 -0
  29. hh_applicant_tool/storage/models/__init__.py +0 -0
  30. hh_applicant_tool/storage/models/base.py +169 -0
  31. hh_applicant_tool/storage/models/contact.py +16 -0
  32. hh_applicant_tool/storage/models/employer.py +12 -0
  33. hh_applicant_tool/storage/models/negotiation.py +16 -0
  34. hh_applicant_tool/storage/models/resume.py +19 -0
  35. hh_applicant_tool/storage/models/setting.py +6 -0
  36. hh_applicant_tool/storage/models/vacancy.py +36 -0
  37. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  38. hh_applicant_tool/storage/queries/schema.sql +119 -0
  39. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  40. hh_applicant_tool/storage/repositories/base.py +176 -0
  41. hh_applicant_tool/storage/repositories/contacts.py +19 -0
  42. hh_applicant_tool/storage/repositories/employers.py +13 -0
  43. hh_applicant_tool/storage/repositories/negotiations.py +12 -0
  44. hh_applicant_tool/storage/repositories/resumes.py +14 -0
  45. hh_applicant_tool/storage/repositories/settings.py +34 -0
  46. hh_applicant_tool/storage/repositories/vacancies.py +8 -0
  47. hh_applicant_tool/storage/utils.py +49 -0
  48. hh_applicant_tool/utils/__init__.py +31 -0
  49. hh_applicant_tool/utils/attrdict.py +6 -0
  50. hh_applicant_tool/utils/binpack.py +167 -0
  51. hh_applicant_tool/utils/config.py +55 -0
  52. hh_applicant_tool/utils/dateutil.py +19 -0
  53. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  54. hh_applicant_tool/utils/jsonutil.py +61 -0
  55. hh_applicant_tool/utils/log.py +144 -0
  56. hh_applicant_tool/utils/misc.py +12 -0
  57. hh_applicant_tool/utils/mixins.py +220 -0
  58. hh_applicant_tool/utils/string.py +27 -0
  59. hh_applicant_tool/utils/terminal.py +19 -0
  60. hh_applicant_tool/utils/user_agent.py +17 -0
  61. hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
  62. hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
  63. hh_applicant_tool/ai/blackbox.py +0 -55
  64. hh_applicant_tool/color_log.py +0 -35
  65. hh_applicant_tool/mixins.py +0 -13
  66. hh_applicant_tool/operations/clear_negotiations.py +0 -113
  67. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  68. hh_applicant_tool/operations/get_employer_contacts.py +0 -293
  69. hh_applicant_tool/telemetry_client.py +0 -106
  70. hh_applicant_tool/types.py +0 -45
  71. hh_applicant_tool/utils.py +0 -104
  72. hh_applicant_tool-0.6.12.dist-info/METADATA +0 -349
  73. hh_applicant_tool-0.6.12.dist-info/RECORD +0 -33
  74. {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
  75. {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1 @@
1
+ from .main import HHApplicantTool
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
 
3
- from .main import main
3
+ from .hh_applicant import main
4
4
 
5
5
  if __name__ == "__main__":
6
6
  sys.exit(main())
@@ -0,0 +1,2 @@
1
+ class AIError(Exception):
2
+ pass
@@ -1,67 +1,61 @@
1
1
  import logging
2
+ from dataclasses import dataclass, field
3
+ from typing import ClassVar
2
4
 
3
5
  import requests
4
6
 
7
+ from .base import AIError
8
+
5
9
  logger = logging.getLogger(__package__)
6
10
 
7
11
 
8
- class OpenAIError(Exception):
12
+ class OpenAIError(AIError):
9
13
  pass
10
14
 
11
15
 
12
- class OpenAIChat:
13
- chat_endpoint: str = "https://api.openai.com/v1/chat/completions"
16
+ @dataclass
17
+ class ChatOpenAI:
18
+ chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
14
19
 
15
- def __init__(
16
- self,
17
- token: str,
18
- model: str,
19
- system_prompt: str,
20
- proxies: dict[str, str] = {}
21
- ):
22
- self.token = token
23
- self.model = model
24
- self.system_prompt = system_prompt
25
- self.proxies = proxies
20
+ token: str
21
+ model: str
22
+ system_prompt: str | None = None
23
+ temperature: float = 0.7
24
+ max_completion_tokens: int = 1000
25
+ session: requests.Session = field(default_factory=requests.Session)
26
26
 
27
27
  def default_headers(self) -> dict[str, str]:
28
28
  return {
29
29
  "Authorization": f"Bearer {self.token}",
30
- "Content-Type": "application/json",
31
30
  }
32
31
 
33
32
  def send_message(self, message: str) -> str:
34
-
35
33
  payload = {
36
34
  "model": self.model,
37
35
  "messages": [
38
- {
39
- "role": "system",
40
- "content": self.system_prompt
41
- },
42
- {
43
- "role": "user",
44
- "content": message
45
- }
36
+ {"role": "system", "content": self.system_prompt},
37
+ {"role": "user", "content": message},
46
38
  ],
47
- "temperature": 0.7,
48
- "max_completion_tokens": 1000
39
+ "temperature": self.temperature,
40
+ "max_completion_tokens": self.max_completion_tokens,
49
41
  }
50
42
 
51
43
  try:
52
- response = requests.post(
44
+ response = self.session.post(
53
45
  self.chat_endpoint,
54
46
  json=payload,
55
47
  headers=self.default_headers(),
56
- proxies=self.proxies,
57
- timeout=30
48
+ timeout=30,
58
49
  )
59
50
  response.raise_for_status()
60
51
 
61
52
  data = response.json()
53
+ if "error" in data:
54
+ raise OpenAIError(data["error"]["message"])
55
+
62
56
  assistant_message = data["choices"][0]["message"]["content"]
63
57
 
64
58
  return assistant_message
65
59
 
66
60
  except requests.exceptions.RequestException as ex:
67
- raise OpenAIError(f"OpenAI API Error: {str(ex)}") from ex
61
+ raise OpenAIError(f"Network error: {ex}") from ex
@@ -3,31 +3,28 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import json
5
5
  import logging
6
- import uuid
7
6
  import time
8
7
  from dataclasses import dataclass
9
- from functools import partialmethod
10
- from threading import Lock
11
- from typing import Any, Literal
12
- from urllib.parse import urlencode
13
8
  from functools import cached_property
14
- import random
9
+ from threading import Lock
10
+ from typing import Any, Literal, TypeVar
11
+ from urllib.parse import urlencode, urljoin
12
+
15
13
  import requests
16
- from requests import Response, Session
14
+ from requests import Session
17
15
 
18
- from ..constants import (
19
- ANDROID_CLIENT_ID,
20
- ANDROID_CLIENT_SECRET,
21
- )
22
- from ..types import AccessToken
16
+ from ..datatypes import AccessToken
23
17
  from . import errors
24
18
 
25
19
  __all__ = ("ApiClient", "OAuthClient")
26
20
 
27
- logger = logging.getLogger(__package__)
21
+ DEFAULT_DELAY = 0.334
28
22
 
23
+ AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
24
+ T = TypeVar("T")
29
25
 
30
- ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
26
+
27
+ logger = logging.getLogger(__package__)
31
28
 
32
29
 
33
30
  # Thread-safe
@@ -35,51 +32,42 @@ ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
35
32
  class BaseClient:
36
33
  base_url: str
37
34
  _: dataclasses.KW_ONLY
38
- # TODO: сделать генерацию User-Agent'а как в приложении
39
35
  user_agent: str | None = None
40
- proxies: dict | None = None
41
36
  session: Session | None = None
42
- previous_request_time: float = 0.0
43
- delay: float = 0.334
37
+ delay: float = DEFAULT_DELAY
38
+ _previous_request_time: float = 0.0
44
39
 
45
40
  def __post_init__(self) -> None:
41
+ assert self.base_url.endswith("/"), "base_url must end with /"
46
42
  self.lock = Lock()
43
+ # logger.debug(f"user agent: {self.user_agent}")
47
44
  if not self.session:
48
- self.session = session = requests.session()
49
- session.headers.update(
50
- {
51
- "user-agent": self.user_agent or self.default_user_agent(),
52
- "x-hh-app-active": "true",
53
- **self.additional_headers(),
54
- }
55
- )
56
- logger.debug("Default Headers: %r", session.headers)
45
+ logger.debug("create new session")
46
+ self.session = requests.session()
47
+ # if self.proxies:
48
+ # logger.debug(f"client proxies: {self.proxies}")
57
49
 
58
- def default_user_agent(self) -> str:
59
- devices = "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(
60
- ", "
61
- )
62
- device = random.choice(devices)
63
- minor = random.randint(100, 150)
64
- patch = random.randint(10000, 15000)
65
- android = random.randint(11, 15)
66
- return f"ru.hh.android/7.{minor}.{patch}, Device: {device}, Android OS: {android} (UUID: {uuid.uuid4()})"
50
+ @property
51
+ def proxies(self):
52
+ return self.session.proxies
67
53
 
68
- def additional_headers(
69
- self,
70
- ) -> dict[str, str]:
71
- return {}
54
+ def default_headers(self) -> dict[str, str]:
55
+ return {
56
+ "user-agent": self.user_agent
57
+ or "Mozilla/5.0 (+https://github.com/s3rgeym/hh-applicant-tool)",
58
+ "x-hh-app-active": "true",
59
+ }
72
60
 
73
61
  def request(
74
62
  self,
75
- method: ALLOWED_METHODS,
63
+ method: AllowedMethods,
76
64
  endpoint: str,
77
65
  params: dict[str, Any] | None = None,
78
66
  delay: float | None = None,
79
67
  **kwargs: Any,
80
- ) -> dict:
68
+ ) -> T:
81
69
  # Не знаю насколько это "правильно"
82
- assert method in ALLOWED_METHODS.__args__
70
+ assert method in AllowedMethods.__args__
83
71
  params = dict(params or {})
84
72
  params.update(kwargs)
85
73
  url = self.resolve_url(endpoint)
@@ -88,76 +76,65 @@ class BaseClient:
88
76
  if (
89
77
  delay := (self.delay if delay is None else delay)
90
78
  - time.monotonic()
91
- + self.previous_request_time
79
+ + self._previous_request_time
92
80
  ) > 0:
93
81
  logger.debug("wait %fs before request", delay)
94
82
  time.sleep(delay)
95
83
  has_body = method in ["POST", "PUT"]
96
84
  payload = {"data" if has_body else "params": params}
85
+ # logger.debug(f"request info: {method = }, {url = }, {headers = }, params = {repr(params)[:255]}")
97
86
  response = self.session.request(
98
87
  method,
99
88
  url,
100
89
  **payload,
101
- proxies=self.proxies,
90
+ headers=self.default_headers(),
102
91
  allow_redirects=False,
103
92
  )
104
93
  try:
105
- # У этих лошков сервер не отдает Content-Length, а кривое API отдает пустые ответы, например, при отклике на вакансии, и мы не можем узнать содержит ли ответ тело
94
+ # У этих лошков сервер не отдает Content-Length, а кривое API
95
+ # отдает пустые ответы, например, при отклике на вакансии,
96
+ # и мы не можем узнать содержит ли ответ тело
106
97
  # 'Server': 'ddos-guard'
107
98
  # ...
108
99
  # 'Transfer-Encoding': 'chunked'
109
100
  try:
110
- rv = response.json()
111
- except json.decoder.JSONDecodeError:
112
- # if response.status_code not in [201, 204]:
113
- # raise
114
- rv = {}
101
+ rv = response.json() if response.text else {}
102
+ except json.decoder.JSONDecodeError as ex:
103
+ raise errors.BadResponse(
104
+ f"Can't decode JSON: {method} {url} ({response.status_code})"
105
+ ) from ex
115
106
  finally:
107
+ log_url = url
108
+ if params and not has_body:
109
+ encoded_params = urlencode(params)
110
+ log_url += ("?", "&")["?" in url] + encoded_params
116
111
  logger.debug(
117
- "%d %-6s %s",
112
+ "%d %s %.1000s",
118
113
  response.status_code,
119
114
  method,
120
- url + ("?" + urlencode(params) if not has_body and params else ""),
115
+ log_url,
121
116
  )
122
- self.previous_request_time = time.monotonic()
123
- self.raise_for_status(response, rv)
124
- assert 300 > response.status_code >= 200
117
+ self._previous_request_time = time.monotonic()
118
+ errors.ApiError.raise_for_status(response, rv)
119
+ assert 300 > response.status_code >= 200, (
120
+ f"Unexpected status code for {method} {url}: {response.status_code}"
121
+ )
125
122
  return rv
126
123
 
127
- def get(self, *args, **kwargs):
124
+ def get(self, *args, **kwargs) -> T:
128
125
  return self.request("GET", *args, **kwargs)
129
126
 
130
- def post(self, *args, **kwargs):
127
+ def post(self, *args, **kwargs) -> T:
131
128
  return self.request("POST", *args, **kwargs)
132
129
 
133
- def put(self, *args, **kwargs):
130
+ def put(self, *args, **kwargs) -> T:
134
131
  return self.request("PUT", *args, **kwargs)
135
132
 
136
- def delete(self, *args, **kwargs):
133
+ def delete(self, *args, **kwargs) -> T:
137
134
  return self.request("DELETE", *args, **kwargs)
138
135
 
139
136
  def resolve_url(self, url: str) -> str:
140
- return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
141
-
142
- @staticmethod
143
- def raise_for_status(response: Response, data: dict) -> None:
144
- match response.status_code:
145
- case 301 | 302:
146
- raise errors.Redirect(response, data)
147
- case 400:
148
- if errors.ApiError.is_limit_exceeded(data):
149
- raise errors.LimitExceeded(response=response, data=data)
150
- raise errors.BadRequest(response, data)
151
- case 403:
152
- raise errors.Forbidden(response, data)
153
- case 404:
154
- raise errors.ResourceNotFound(response, data)
155
- case status if 500 > status >= 400:
156
- raise errors.ClientError(response, data)
157
- case 502:
158
- raise errors.BadGateway(response, data)
159
- case status if status >= 500:
160
- raise errors.InternalServerError(response, data)
137
+ return urljoin(self.base_url, url.lstrip("/"))
161
138
 
162
139
 
163
140
  @dataclass
@@ -165,7 +142,7 @@ class OAuthClient(BaseClient):
165
142
  client_id: str
166
143
  client_secret: str
167
144
  _: dataclasses.KW_ONLY
168
- base_url: str = "https://hh.ru/oauth"
145
+ base_url: str = "https://hh.ru/oauth/"
169
146
  state: str = ""
170
147
  scope: str = ""
171
148
  redirect_uri: str = ""
@@ -202,9 +179,12 @@ class OAuthClient(BaseClient):
202
179
  return self.request_access_token("/token", params)
203
180
 
204
181
  def refresh_access_token(self, refresh_token: str) -> AccessToken:
205
- # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
182
+ # refresh_token можно использовать только один раз и только по
183
+ # истечению срока действия access_token.
206
184
  return self.request_access_token(
207
- "/token", grant_type="refresh_token", refresh_token=refresh_token
185
+ "/token",
186
+ grant_type="refresh_token",
187
+ refresh_token=refresh_token,
208
188
  )
209
189
 
210
190
 
@@ -214,43 +194,47 @@ class ApiClient(BaseClient):
214
194
  access_token: str | None = None
215
195
  refresh_token: str | None = None
216
196
  access_expires_at: int = 0
217
- client_id: str = ANDROID_CLIENT_ID
218
- client_secret: str = ANDROID_CLIENT_SECRET
219
197
  _: dataclasses.KW_ONLY
198
+ client_id: str | None = None
199
+ client_secret: str | None = None
220
200
  base_url: str = "https://api.hh.ru/"
221
201
 
222
202
  @property
223
203
  def is_access_expired(self) -> bool:
224
- return time.time() > self.access_expires_at
204
+ return time.time() >= (self.access_expires_at or 0)
225
205
 
226
206
  @cached_property
227
207
  def oauth_client(self) -> OAuthClient:
228
208
  return OAuthClient(
229
209
  client_id=self.client_id,
230
210
  client_secret=self.client_secret,
211
+ user_agent=self.user_agent,
231
212
  session=self.session,
232
213
  )
233
214
 
234
- def additional_headers(
215
+ def default_headers(
235
216
  self,
236
217
  ) -> dict[str, str]:
237
- return (
238
- {"authorization": f"Bearer {self.access_token}"}
239
- if self.access_token
240
- else {}
241
- )
218
+ headers = super().default_headers()
219
+ if not self.access_token:
220
+ return headers
221
+ # Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
222
+ assert self.access_token.startswith("USER")
223
+ return headers | {"authorization": f"Bearer {self.access_token}"}
242
224
 
243
225
  # Реализовано автоматическое обновление токена
244
226
  def request(
245
227
  self,
246
- method: ALLOWED_METHODS,
228
+ method: AllowedMethods,
247
229
  endpoint: str,
248
230
  params: dict[str, Any] | None = None,
249
231
  delay: float | None = None,
250
232
  **kwargs: Any,
251
- ) -> dict:
233
+ ) -> T:
252
234
  def do_request():
253
- return BaseClient.request(self, method, endpoint, params, delay, **kwargs)
235
+ return BaseClient.request(
236
+ self, method, endpoint, params, delay, **kwargs
237
+ )
254
238
 
255
239
  try:
256
240
  return do_request()
@@ -258,14 +242,14 @@ class ApiClient(BaseClient):
258
242
  except errors.Forbidden as ex:
259
243
  if not self.is_access_expired or not self.refresh_token:
260
244
  raise ex
261
- logger.info("try refresh access_token")
245
+ logger.info("try to refresh access_token")
262
246
  # Пробуем обновить токен
263
247
  self.refresh_access_token()
264
248
  # И повторно отправляем запрос
265
249
  return do_request()
266
250
 
267
251
  def handle_access_token(self, token: AccessToken) -> None:
268
- for field in ["access_token", "refresh_token", "access_expires_at"]:
252
+ for field in ("access_token", "refresh_token", "access_expires_at"):
269
253
  if field in token and hasattr(self, field):
270
254
  setattr(self, field, token[field])
271
255
 
@@ -1,10 +1,13 @@
1
- # from copy import deepcopy
2
- from typing import Any
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from typing import Any, Type
3
5
 
4
6
  from requests import Request, Response
5
7
  from requests.adapters import CaseInsensitiveDict
6
8
 
7
9
  __all__ = (
10
+ "BadResponse",
8
11
  "ApiError",
9
12
  "BadGateway",
10
13
  "BadRequest",
@@ -16,14 +19,18 @@ __all__ = (
16
19
  )
17
20
 
18
21
 
19
- class ApiError(Exception):
22
+ class BadResponse(Exception):
23
+ pass
24
+
25
+
26
+ class ApiError(BadResponse):
20
27
  def __init__(self, response: Response, data: dict[str, Any]) -> None:
21
28
  self._response = response
22
- self._raw = data
29
+ self._data = data
23
30
 
24
31
  @property
25
32
  def data(self) -> dict:
26
- return self._raw
33
+ return self._data
27
34
 
28
35
  @property
29
36
  def request(self) -> Request:
@@ -37,6 +44,10 @@ class ApiError(Exception):
37
44
  def response_headers(self) -> CaseInsensitiveDict:
38
45
  return self._response.headers
39
46
 
47
+ @property
48
+ def message(self) -> str:
49
+ return self._data.get("description") or str(self._data)
50
+
40
51
  # def __getattr__(self, name: str) -> Any:
41
52
  # try:
42
53
  # return self._raw[name]
@@ -44,11 +55,33 @@ class ApiError(Exception):
44
55
  # raise AttributeError(name) from ex
45
56
 
46
57
  def __str__(self) -> str:
47
- return str(self._raw)
58
+ return self.message
48
59
 
49
60
  @staticmethod
50
- def is_limit_exceeded(data) -> bool:
51
- return any(x["value"] == "limit_exceeded" for x in data.get("errors", []))
61
+ def has_error_value(value: str, data: dict) -> bool:
62
+ return any(v.get("value") == value for v in data.get("errors", []))
63
+
64
+ @classmethod
65
+ def raise_for_status(cls: Type[ApiError], response: Response, data: dict) -> None:
66
+ match response.status_code:
67
+ case status if 300 <= status <= 308:
68
+ raise Redirect(response, data)
69
+ case 400:
70
+ if cls.has_error_value("limit_exceeded", data):
71
+ raise LimitExceeded(response, data)
72
+ raise BadRequest(response, data)
73
+ case 403:
74
+ if cls.has_error_value("captcha_required", data):
75
+ raise CaptchaRequired(response, data)
76
+ raise Forbidden(response, data)
77
+ case 404:
78
+ raise ResourceNotFound(response, data)
79
+ case status if 500 > status >= 400:
80
+ raise ClientError(response, data)
81
+ case 502:
82
+ raise BadGateway(response, data)
83
+ case status if status >= 500:
84
+ raise InternalServerError(response, data)
52
85
 
53
86
 
54
87
  class Redirect(ApiError):
@@ -71,6 +104,22 @@ class Forbidden(ClientError):
71
104
  pass
72
105
 
73
106
 
107
+ class CaptchaRequired(ClientError):
108
+ @cached_property
109
+ def captcha_url(self) -> str:
110
+ return next(
111
+ filter(
112
+ lambda v: v["value"] == "captcha_required",
113
+ self._data["errors"],
114
+ ),
115
+ {},
116
+ ).get("captcha_url")
117
+
118
+ @property
119
+ def message(self) -> str:
120
+ return f"Captcha required: {self.captcha_url}"
121
+
122
+
74
123
  class ResourceNotFound(ClientError):
75
124
  pass
76
125
 
@@ -9,6 +9,3 @@ ANDROID_CLIENT_SECRET = (
9
9
  # Используется для прямой авторизации. Этот способ мной не используется, так как
10
10
  # для отображения капчи все равно нужен webview.
11
11
  # K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
12
-
13
- # Кривой формат, который используют эти долбоебы
14
- INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"