hh-applicant-tool 0.5.7__py3-none-any.whl → 0.5.9__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.

Potentially problematic release.


This version of hh-applicant-tool might be problematic. Click here for more details.

@@ -10,7 +10,8 @@ from functools import partialmethod
10
10
  from threading import Lock
11
11
  from typing import Any, Literal
12
12
  from urllib.parse import urlencode
13
-
13
+ from functools import cached_property
14
+ import random
14
15
  import requests
15
16
  from requests import Response, Session
16
17
 
@@ -47,14 +48,22 @@ class BaseClient:
47
48
  self.session = session = requests.session()
48
49
  session.headers.update(
49
50
  {
50
- "User-Agent": self.user_agent or self.default_user_agent(),
51
+ "user-agent": self.user_agent or self.default_user_agent(),
52
+ "x-hh-app-active": "true",
51
53
  **self.additional_headers(),
52
54
  }
53
55
  )
54
56
  logger.debug("Default Headers: %r", session.headers)
55
57
 
56
58
  def default_user_agent(self) -> str:
57
- return f"ru.hh.android/7.122.11395, Device: 23053RN02Y, Android OS: 13 (UUID: {uuid.uuid4()})"
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()})"
58
67
 
59
68
  def additional_headers(
60
69
  self,
@@ -65,7 +74,7 @@ class BaseClient:
65
74
  self,
66
75
  method: ALLOWED_METHODS,
67
76
  endpoint: str,
68
- params: dict | None = None,
77
+ params: dict[str, Any] | None = None,
69
78
  delay: float | None = None,
70
79
  **kwargs: Any,
71
80
  ) -> dict:
@@ -84,10 +93,11 @@ class BaseClient:
84
93
  logger.debug("wait %fs before request", delay)
85
94
  time.sleep(delay)
86
95
  has_body = method in ["POST", "PUT"]
96
+ payload = {"data" if has_body else "params": params}
87
97
  response = self.session.request(
88
98
  method,
89
99
  url,
90
- **{"data" if has_body else "params": params},
100
+ **payload,
91
101
  proxies=self.proxies,
92
102
  allow_redirects=False,
93
103
  )
@@ -107,29 +117,27 @@ class BaseClient:
107
117
  "%d %-6s %s",
108
118
  response.status_code,
109
119
  method,
110
- url
111
- + (
112
- "?" + urlencode(params)
113
- if not has_body and params
114
- else ""
115
- ),
120
+ url + ("?" + urlencode(params) if not has_body and params else ""),
116
121
  )
117
122
  self.previous_request_time = time.monotonic()
118
123
  self.raise_for_status(response, rv)
119
124
  assert 300 > response.status_code >= 200
120
125
  return rv
121
126
 
122
- get = partialmethod(request, "GET")
123
- post = partialmethod(request, "POST")
124
- put = partialmethod(request, "PUT")
125
- delete = partialmethod(request, "DELETE")
127
+ def get(self, *args, **kwargs):
128
+ return self.request("GET", *args, **kwargs)
129
+
130
+ def post(self, *args, **kwargs):
131
+ return self.request("POST", *args, **kwargs)
132
+
133
+ def put(self, *args, **kwargs):
134
+ return self.request("PUT", *args, **kwargs)
135
+
136
+ def delete(self, *args, **kwargs):
137
+ return self.request("DELETE", *args, **kwargs)
126
138
 
127
139
  def resolve_url(self, url: str) -> str:
128
- return (
129
- url
130
- if "://" in url
131
- else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
132
- )
140
+ return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
133
141
 
134
142
  @staticmethod
135
143
  def raise_for_status(response: Response, data: dict) -> None:
@@ -137,6 +145,8 @@ class BaseClient:
137
145
  case 301 | 302:
138
146
  raise errors.Redirect(response, data)
139
147
  case 400:
148
+ if errors.ApiError.is_limit_exceeded(data):
149
+ raise errors.LimitExceeded(response=response, data=data)
140
150
  raise errors.BadRequest(response, data)
141
151
  case 403:
142
152
  raise errors.Forbidden(response, data)
@@ -152,8 +162,8 @@ class BaseClient:
152
162
 
153
163
  @dataclass
154
164
  class OAuthClient(BaseClient):
155
- client_id: str = ANDROID_CLIENT_ID
156
- client_secret: str = ANDROID_CLIENT_SECRET
165
+ client_id: str
166
+ client_secret: str
157
167
  _: dataclasses.KW_ONLY
158
168
  base_url: str = "https://hh.ru/oauth"
159
169
  state: str = ""
@@ -172,6 +182,16 @@ class OAuthClient(BaseClient):
172
182
  params_qs = urlencode({k: v for k, v in params.items() if v})
173
183
  return self.resolve_url(f"/authorize?{params_qs}")
174
184
 
185
+ def request_access_token(
186
+ self, endpoint: str, params: dict[str, Any] | None = None, **kw: Any
187
+ ) -> AccessToken:
188
+ tok = self.post(endpoint, params, **kw)
189
+ return {
190
+ "access_token": tok.get("access_token"),
191
+ "refresh_token": tok.get("refresh_token"),
192
+ "access_expires_at": int(time.time()) + tok.pop("expires_in", 0),
193
+ }
194
+
175
195
  def authenticate(self, code: str) -> AccessToken:
176
196
  params = {
177
197
  "client_id": self.client_id,
@@ -179,11 +199,11 @@ class OAuthClient(BaseClient):
179
199
  "code": code,
180
200
  "grant_type": "authorization_code",
181
201
  }
182
- return self.post("/token", params)
202
+ return self.request_access_token("/token", params)
183
203
 
184
- def refresh_access(self, refresh_token: str) -> AccessToken:
204
+ def refresh_access_token(self, refresh_token: str) -> AccessToken:
185
205
  # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
186
- return self.post(
206
+ return self.request_access_token(
187
207
  "/token", grant_type="refresh_token", refresh_token=refresh_token
188
208
  )
189
209
 
@@ -193,32 +213,71 @@ class ApiClient(BaseClient):
193
213
  # Например, для просмотра информации о компании токен не нужен
194
214
  access_token: str | None = None
195
215
  refresh_token: str | None = None
216
+ access_expires_at: int = 0
217
+ client_id: str = ANDROID_CLIENT_ID
218
+ client_secret: str = ANDROID_CLIENT_SECRET
196
219
  _: dataclasses.KW_ONLY
197
220
  base_url: str = "https://api.hh.ru/"
198
- # oauth_client: OAuthClient | None = None
199
221
 
200
- # def __post_init__(self) -> None:
201
- # super().__post_init__()
202
- # self.oauth_client = self.oauth_client or OAuthClient(
203
- # session=self.session
204
- # )
222
+ @property
223
+ def is_access_expired(self) -> bool:
224
+ return time.time() > self.access_expires_at
225
+
226
+ @cached_property
227
+ def oauth_client(self) -> OAuthClient:
228
+ return OAuthClient(
229
+ client_id=self.client_id,
230
+ client_secret=self.client_secret,
231
+ session=self.session,
232
+ )
205
233
 
206
234
  def additional_headers(
207
235
  self,
208
236
  ) -> dict[str, str]:
209
237
  return (
210
- {"Authorization": f"Bearer {self.access_token}"}
238
+ {"authorization": f"Bearer {self.access_token}"}
211
239
  if self.access_token
212
240
  else {}
213
241
  )
214
242
 
215
- # def refresh_access(self) -> AccessToken:
216
- # tok = self.oauth_client.refresh_access(self.refresh_token)
217
- # (
218
- # self.access_token,
219
- # self.refresh_access,
220
- # ) = (
221
- # tok["access_token"],
222
- # tok["refresh_token"],
223
- # )
224
- # return tok
243
+ # Реализовано автоматическое обновление токена
244
+ def request(
245
+ self,
246
+ method: ALLOWED_METHODS,
247
+ endpoint: str,
248
+ params: dict[str, Any] | None = None,
249
+ delay: float | None = None,
250
+ **kwargs: Any,
251
+ ) -> dict:
252
+ def do_request():
253
+ return BaseClient.request(self, method, endpoint, params, delay, **kwargs)
254
+
255
+ try:
256
+ return do_request()
257
+ # TODO: добавить класс для ошибок типа AccessTokenExpired
258
+ except errors.Forbidden as ex:
259
+ if not self.is_access_expired or not self.refresh_token:
260
+ raise ex
261
+ logger.info("try refresh access_token")
262
+ # Пробуем обновить токен
263
+ self.refresh_access_token()
264
+ # И повторно отправляем запрос
265
+ return do_request()
266
+
267
+ def handle_access_token(self, token: AccessToken) -> None:
268
+ for field in ["access_token", "refresh_token", "access_expires_at"]:
269
+ if field in token and hasattr(self, field):
270
+ setattr(self, field, token[field])
271
+
272
+ def refresh_access_token(self) -> None:
273
+ if not self.refresh_token:
274
+ raise ValueError("Refresh token required.")
275
+ token = self.oauth_client.refresh_access_token(self.refresh_token)
276
+ self.handle_access_token(token)
277
+
278
+ def get_access_token(self) -> AccessToken:
279
+ return {
280
+ "access_token": self.access_token,
281
+ "refresh_token": self.refresh_token,
282
+ "access_expires_at": self.access_expires_at,
283
+ }
@@ -37,15 +37,19 @@ class ApiError(Exception):
37
37
  def response_headers(self) -> CaseInsensitiveDict:
38
38
  return self._response.headers
39
39
 
40
- # def __getattr__(self, name: str) -> Any:
41
- # try:
42
- # return self._raw[name]
43
- # except KeyError as ex:
44
- # raise AttributeError(name) from ex
40
+ # def __getattr__(self, name: str) -> Any:
41
+ # try:
42
+ # return self._raw[name]
43
+ # except KeyError as ex:
44
+ # raise AttributeError(name) from ex
45
45
 
46
46
  def __str__(self) -> str:
47
47
  return str(self._raw)
48
48
 
49
+ @staticmethod
50
+ def is_limit_exceeded(data) -> bool:
51
+ return any(x["value"] == "limit_exceeded" for x in data.get("errors", []))
52
+
49
53
 
50
54
  class Redirect(ApiError):
51
55
  pass
@@ -56,9 +60,11 @@ class ClientError(ApiError):
56
60
 
57
61
 
58
62
  class BadRequest(ClientError):
59
- @property
60
- def limit_exceeded(self) -> bool:
61
- return any(x["value"] == "limit_exceeded" for x in self._raw["errors"])
63
+ pass
64
+
65
+
66
+ class LimitExceeded(ClientError):
67
+ pass
62
68
 
63
69
 
64
70
  class Forbidden(ClientError):
@@ -1,12 +1,14 @@
1
- USER_AGENT_TEMPLATE = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Mobile Safari/537.36"
1
+ # USER_AGENT_TEMPLATE = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Mobile Safari/537.36"
2
2
 
3
- ANDROID_CLIENT_ID = (
4
- "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
5
- )
3
+ ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
6
4
 
7
5
  ANDROID_CLIENT_SECRET = (
8
6
  "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
9
7
  )
10
8
 
9
+ # Используется для прямой авторизации. Этот способ мной не используется, так как
10
+ # для отображения капчи все равно нужен webview.
11
+ # K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
12
+
11
13
  # Кривой формат, который используют эти долбоебы
12
14
  INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
@@ -1,16 +1,19 @@
1
+ # Unused
2
+ """Парсер JSON с комментариями"""
3
+
1
4
  import re
2
5
  import enum
3
6
  from dataclasses import dataclass
4
7
  import ast
5
8
  from typing import Any, Iterator
6
- from collections import OrderedDict
9
+ # from collections import OrderedDict
7
10
 
8
11
 
9
12
  class TokenType(enum.Enum):
10
13
  WHITESPACE = r"\s+"
11
14
  COMMENT = r"//.*|/\*[\s\S]*?\*/"
12
15
  NUMBER = r"-?\d+(?:\.\d+)?"
13
- STRING = r'"(?:\\"|[^"]+)*"'
16
+ STRING = r'"(?:\\.|[^"]+)*"'
14
17
  KEYWORD = r"null|true|false"
15
18
  OPEN_CURLY = r"\{"
16
19
  CLOSE_CURLY = r"\}"
@@ -42,7 +45,8 @@ class JSONCParser:
42
45
  lambda t: t.token_type not in [TokenType.COMMENT, TokenType.WHITESPACE],
43
46
  tokenize(s),
44
47
  )
45
- self.next_token = None
48
+ self.token: Token
49
+ self.next_token: Token | None = None
46
50
  self.advance()
47
51
  result = self.parse_value()
48
52
  self.expect(TokenType.EOF)
@@ -91,6 +95,7 @@ class JSONCParser:
91
95
  raise SyntaxError(f"Unexpected token: {self.token.token_type.name}")
92
96
 
93
97
  def advance(self):
98
+ assert self.next_token is not None
94
99
  self.token, self.next_token = (
95
100
  self.next_token,
96
101
  next(self.token_it, Token(TokenType.EOF, "")),
@@ -98,7 +103,7 @@ class JSONCParser:
98
103
  # print(f"{self.token =}, {self.next_token =}")
99
104
 
100
105
  def match(self, token_type: TokenType) -> bool:
101
- if self.next_token.token_type == token_type:
106
+ if self.next_token is not None and self.next_token.token_type == token_type:
102
107
  self.advance()
103
108
  return True
104
109
  return False
@@ -106,7 +111,7 @@ class JSONCParser:
106
111
  def expect(self, token_type: TokenType):
107
112
  if not self.match(token_type):
108
113
  raise SyntaxError(
109
- f"Expected {token_type.name}, got {self.next_token.token_type.name}"
114
+ f"Expected {token_type.name}, got {self.next_token.token_type.name if self.next_token else '???'}"
110
115
  )
111
116
 
112
117
 
hh_applicant_tool/main.py CHANGED
@@ -12,6 +12,8 @@ from typing import Literal, Sequence
12
12
  from .api import ApiClient
13
13
  from .color_log import ColorHandler
14
14
  from .utils import Config, get_config_path
15
+ from .telemetry_client import TelemetryClient
16
+
15
17
 
16
18
  DEFAULT_CONFIG_PATH = (
17
19
  get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
@@ -46,11 +48,12 @@ def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
46
48
  }
47
49
 
48
50
 
49
- def get_api(args: Namespace) -> ApiClient:
51
+ def get_api_client(args: Namespace) -> ApiClient:
50
52
  token = args.config.get("token", {})
51
53
  api = ApiClient(
52
54
  access_token=token.get("access_token"),
53
55
  refresh_token=token.get("refresh_token"),
56
+ access_expires_at=token.get("access_expires_at"),
54
57
  delay=args.delay,
55
58
  user_agent=args.config["user_agent"],
56
59
  proxies=get_proxies(args),
@@ -134,7 +137,20 @@ class HHApplicantTool:
134
137
  logger.addHandler(handler)
135
138
  if args.run:
136
139
  try:
137
- return args.run(args)
140
+ if not args.config["telemetry_client_id"]:
141
+ import uuid
142
+
143
+ args.config.save(telemetry_client_id=str(uuid.uuid4()))
144
+ api_client = get_api_client(args)
145
+ telemetry_client = TelemetryClient(
146
+ telemetry_client_id=args.config["telemetry_client_id"],
147
+ proxies=api_client.proxies.copy(),
148
+ )
149
+ # 0 or None = success
150
+ res = args.run(args, api_client, telemetry_client)
151
+ if (token := api_client.get_access_token()) != args.config["token"]:
152
+ args.config.save(token=token)
153
+ return res
138
154
  except Exception as e:
139
155
  logger.exception(e)
140
156
  return 1
@@ -5,7 +5,7 @@ from .types import ApiListResponse
5
5
  class GetResumeIdMixin:
6
6
  def _get_resume_id(self) -> str:
7
7
  try:
8
- resumes: ApiListResponse = self.api.get("/resumes/mine")
8
+ resumes: ApiListResponse = self.api_client.get("/resumes/mine")
9
9
  return resumes["items"][0]["id"]
10
10
  except (ApiError, KeyError, IndexError) as ex:
11
11
  raise Exception("Не могу получить идентификатор резюме") from ex
@@ -4,19 +4,23 @@ import random
4
4
  import time
5
5
  from collections import defaultdict
6
6
  from datetime import datetime, timedelta, timezone
7
- from typing import TextIO, Tuple
7
+ from typing import TextIO
8
8
 
9
+ from ..api.errors import LimitExceeded
9
10
  from ..ai.blackbox import BlackboxChat, BlackboxError
10
- from ..api import ApiError, BadRequest
11
+ from ..api import ApiError, ApiClient
11
12
  from ..main import BaseOperation
12
13
  from ..main import Namespace as BaseNamespace
13
- from ..main import get_api
14
14
  from ..mixins import GetResumeIdMixin
15
15
  from ..telemetry_client import TelemetryClient, TelemetryError
16
16
  from ..types import ApiListResponse, VacancyItem
17
- from ..utils import (fix_datetime, parse_interval, parse_invalid_datetime,
18
- random_text, truncate_string)
19
- from hh_applicant_tool.ai import blackbox
17
+ from ..utils import (
18
+ fix_datetime,
19
+ parse_interval,
20
+ parse_invalid_datetime,
21
+ random_text,
22
+ truncate_string,
23
+ )
20
24
 
21
25
  logger = logging.getLogger(__package__)
22
26
 
@@ -43,7 +47,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
43
47
  "-L",
44
48
  "--message-list",
45
49
  help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
46
- type=argparse.FileType('r', encoding='utf-8', errors='replace'),
50
+ type=argparse.FileType("r", encoding="utf-8", errors="replace"),
47
51
  )
48
52
  parser.add_argument(
49
53
  "-f",
@@ -103,7 +107,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
103
107
  action=argparse.BooleanOptionalAction,
104
108
  )
105
109
 
106
- def run(self, args: Namespace) -> None:
110
+ def run(
111
+ self, args: Namespace, api_client: ApiClient, telemetry_client: TelemetryClient
112
+ ) -> None:
107
113
  self.enable_telemetry = True
108
114
  if args.disable_telemetry:
109
115
  # print(
@@ -120,18 +126,17 @@ class Operation(BaseOperation, GetResumeIdMixin):
120
126
  # logger.info("Спасибо за то что оставили телеметрию включенной!")
121
127
  self.enable_telemetry = False
122
128
 
123
- self.api = get_api(args)
129
+ self.api_client = api_client
130
+ self.telemetry_client = telemetry_client
124
131
  self.resume_id = args.resume_id or self._get_resume_id()
125
- self.application_messages = self._get_application_messages(
126
- args.message_list
127
- )
132
+ self.application_messages = self._get_application_messages(args.message_list)
128
133
  self.chat = None
129
134
 
130
135
  if config := args.config.get("blackbox"):
131
136
  self.chat = BlackboxChat(
132
137
  session_id=config["session_id"],
133
138
  chat_payload=config["chat_payload"],
134
- proxies=self.api.proxies or {},
139
+ proxies=self.api_client.proxies or {},
135
140
  )
136
141
 
137
142
  self.pre_prompt = args.pre_prompt
@@ -145,13 +150,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
145
150
  self.dry_run = args.dry_run
146
151
  self._apply_similar()
147
152
 
148
- def _get_application_messages(
149
- self, message_list: TextIO | None
150
- ) -> list[str]:
153
+ def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
151
154
  if message_list:
152
- application_messages = list(
153
- filter(None, map(str.strip, message_list))
154
- )
155
+ application_messages = list(filter(None, map(str.strip, message_list)))
155
156
  else:
156
157
  application_messages = [
157
158
  "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
@@ -160,7 +161,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
160
161
  return application_messages
161
162
 
162
163
  def _apply_similar(self) -> None:
163
- telemetry_client = TelemetryClient(proxies=self.api.proxies)
164
+ telemetry_client = self.telemetry_client
164
165
  telemetry_data = defaultdict(dict)
165
166
 
166
167
  vacancies = self._get_vacancies()
@@ -172,12 +173,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
172
173
  "name": vacancy.get("name"),
173
174
  "type": vacancy.get("type", {}).get("id"), # open/closed
174
175
  "area": vacancy.get("area", {}).get("name"), # город
175
- "salary": vacancy.get(
176
- "salary"
177
- ), # from, to, currency, gross
178
- "direct_url": vacancy.get(
179
- "alternate_url"
180
- ), # ссылка на вакансию
176
+ "salary": vacancy.get("salary"), # from, to, currency, gross
177
+ "direct_url": vacancy.get("alternate_url"), # ссылка на вакансию
181
178
  "created_at": fix_datetime(
182
179
  vacancy.get("created_at")
183
180
  ), # будем вычислять говно-вакансии, которые по полгода висят
@@ -194,7 +191,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
194
191
  # Остальное неинтересно
195
192
  }
196
193
 
197
- me = self.api.get("/me")
194
+ me = self.api_client.get("/me")
198
195
 
199
196
  basic_message_placeholders = {
200
197
  "first_name": me.get("first_name", ""),
@@ -210,9 +207,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
210
207
  try:
211
208
  message_placeholders = {
212
209
  "vacancy_name": vacancy.get("name", ""),
213
- "employer_name": vacancy.get("employer", {}).get(
214
- "name", ""
215
- ),
210
+ "employer_name": vacancy.get("employer", {}).get("name", ""),
216
211
  **basic_message_placeholders,
217
212
  }
218
213
 
@@ -250,7 +245,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
250
245
  > datetime.now(tz=timezone.utc)
251
246
  )
252
247
  ):
253
- employer = self.api.get(f"/employers/{employer_id}")
248
+ employer = self.api_client.get(f"/employers/{employer_id}")
254
249
 
255
250
  employer_data = {
256
251
  "name": employer.get("name"),
@@ -275,9 +270,10 @@ class Operation(BaseOperation, GetResumeIdMixin):
275
270
  response["topic_url"],
276
271
  )
277
272
  else:
278
- print(
279
- "Создание темы для обсуждения работодателя добавлено в очередь..."
280
- )
273
+ # print(
274
+ # "Создание темы для обсуждения работодателя добавлено в очередь..."
275
+ # )
276
+ ...
281
277
  complained_employers.add(employer_id)
282
278
  except TelemetryError as ex:
283
279
  logger.error(ex)
@@ -304,9 +300,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
304
300
  "message": "",
305
301
  }
306
302
 
307
- if self.force_message or vacancy.get(
308
- "response_letter_required"
309
- ):
303
+ if self.force_message or vacancy.get("response_letter_required"):
310
304
  if self.chat:
311
305
  try:
312
306
  msg = self.pre_prompt + "\n\n"
@@ -318,9 +312,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
318
312
  continue
319
313
  else:
320
314
  msg = (
321
- random_text(
322
- random.choice(self.application_messages)
323
- )
315
+ random_text(random.choice(self.application_messages))
324
316
  % message_placeholders
325
317
  )
326
318
 
@@ -341,7 +333,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
341
333
  )
342
334
  time.sleep(interval)
343
335
 
344
- res = self.api.post("/negotiations", params)
336
+ res = self.api_client.post("/negotiations", params)
345
337
  assert res == {}
346
338
  print(
347
339
  "📨 Отправили отклик",
@@ -350,10 +342,11 @@ class Operation(BaseOperation, GetResumeIdMixin):
350
342
  truncate_string(vacancy["name"]),
351
343
  ")",
352
344
  )
345
+ except LimitExceeded:
346
+ print("⚠️ Достигли лимита рассылки")
347
+ do_apply = False
353
348
  except ApiError as ex:
354
349
  logger.error(ex)
355
- if isinstance(ex, BadRequest) and ex.limit_exceeded:
356
- do_apply = False
357
350
 
358
351
  print("📝 Отклики на вакансии разосланы!")
359
352
 
@@ -384,7 +377,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
384
377
  }
385
378
  if self.search:
386
379
  params["text"] = self.search
387
- res: ApiListResponse = self.api.get(
380
+ res: ApiListResponse = self.api_client.get(
388
381
  f"/resumes/{self.resume_id}/similar_vacancies", params
389
382
  )
390
383
  rv.extend(res["items"])