hh-applicant-tool 1.4.7__py3-none-any.whl → 1.4.12__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 (46) hide show
  1. hh_applicant_tool/__main__.py +1 -1
  2. hh_applicant_tool/ai/openai.py +2 -2
  3. hh_applicant_tool/api/__init__.py +4 -2
  4. hh_applicant_tool/api/client.py +23 -12
  5. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
  6. hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
  7. hh_applicant_tool/api/errors.py +8 -2
  8. hh_applicant_tool/{utils → api}/user_agent.py +1 -1
  9. hh_applicant_tool/main.py +12 -13
  10. hh_applicant_tool/operations/apply_similar.py +125 -47
  11. hh_applicant_tool/operations/authorize.py +82 -25
  12. hh_applicant_tool/operations/call_api.py +3 -3
  13. hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -25
  14. hh_applicant_tool/operations/list_resumes.py +5 -7
  15. hh_applicant_tool/operations/query.py +3 -1
  16. hh_applicant_tool/operations/reply_employers.py +80 -40
  17. hh_applicant_tool/operations/settings.py +2 -2
  18. hh_applicant_tool/operations/update_resumes.py +5 -4
  19. hh_applicant_tool/operations/whoami.py +1 -1
  20. hh_applicant_tool/storage/__init__.py +5 -1
  21. hh_applicant_tool/storage/facade.py +2 -2
  22. hh_applicant_tool/storage/models/base.py +4 -4
  23. hh_applicant_tool/storage/models/contacts.py +28 -0
  24. hh_applicant_tool/storage/queries/schema.sql +22 -9
  25. hh_applicant_tool/storage/repositories/base.py +69 -15
  26. hh_applicant_tool/storage/repositories/contacts.py +5 -10
  27. hh_applicant_tool/storage/repositories/employers.py +1 -0
  28. hh_applicant_tool/storage/repositories/errors.py +19 -0
  29. hh_applicant_tool/storage/repositories/negotiations.py +1 -0
  30. hh_applicant_tool/storage/repositories/resumes.py +2 -7
  31. hh_applicant_tool/storage/repositories/settings.py +1 -0
  32. hh_applicant_tool/storage/repositories/vacancies.py +1 -0
  33. hh_applicant_tool/storage/utils.py +6 -15
  34. hh_applicant_tool/utils/__init__.py +3 -3
  35. hh_applicant_tool/utils/config.py +1 -1
  36. hh_applicant_tool/utils/log.py +4 -1
  37. hh_applicant_tool/utils/mixins.py +20 -19
  38. hh_applicant_tool/utils/terminal.py +13 -0
  39. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/METADATA +197 -140
  40. hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
  41. hh_applicant_tool/storage/models/contact.py +0 -16
  42. hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
  43. /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
  44. /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
  45. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
  46. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
 
3
- from .hh_applicant import main
3
+ from . import main
4
4
 
5
5
  if __name__ == "__main__":
6
6
  sys.exit(main())
@@ -24,7 +24,7 @@ class ChatOpenAI:
24
24
  max_completion_tokens: int = 1000
25
25
  session: requests.Session = field(default_factory=requests.Session)
26
26
 
27
- def default_headers(self) -> dict[str, str]:
27
+ def _default_headers(self) -> dict[str, str]:
28
28
  return {
29
29
  "Authorization": f"Bearer {self.token}",
30
30
  }
@@ -44,7 +44,7 @@ class ChatOpenAI:
44
44
  response = self.session.post(
45
45
  self.chat_endpoint,
46
46
  json=payload,
47
- headers=self.default_headers(),
47
+ headers=self._default_headers(),
48
48
  timeout=30,
49
49
  )
50
50
  response.raise_for_status()
@@ -1,3 +1,5 @@
1
1
  """See <https://github.com/hhru/api>"""
2
- from .client import *
3
- from .errors import *
2
+
3
+ from .client import * # noqa: F403
4
+ from .datatypes import * # noqa: F403
5
+ from .errors import * # noqa: F403
@@ -13,11 +13,18 @@ from urllib.parse import urlencode, urljoin
13
13
  import requests
14
14
  from requests import Session
15
15
 
16
- from ..datatypes import AccessToken
17
16
  from . import errors
17
+ from .client_keys import (
18
+ ANDROID_CLIENT_ID,
19
+ ANDROID_CLIENT_SECRET,
20
+ )
21
+ from .datatypes import AccessToken
22
+ from .user_agent import generate_android_useragent
18
23
 
19
24
  __all__ = ("ApiClient", "OAuthClient")
20
25
 
26
+ HH_API_URL = "https://api.hh.ru/"
27
+ HH_OAUTH_URL = "https://hh.ru/oauth/"
21
28
  DEFAULT_DELAY = 0.334
22
29
 
23
30
  AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
@@ -38,7 +45,7 @@ class BaseClient:
38
45
  _previous_request_time: float = 0.0
39
46
 
40
47
  def __post_init__(self) -> None:
41
- assert self.base_url.endswith("/"), "base_url must end with /"
48
+ assert self.base_url.endswith("/"), "base_url must ends with /"
42
49
  self.lock = Lock()
43
50
  # logger.debug(f"user agent: {self.user_agent}")
44
51
  if not self.session:
@@ -51,10 +58,9 @@ class BaseClient:
51
58
  def proxies(self):
52
59
  return self.session.proxies
53
60
 
54
- def default_headers(self) -> dict[str, str]:
61
+ def _default_headers(self) -> dict[str, str]:
55
62
  return {
56
- "user-agent": self.user_agent
57
- or "Mozilla/5.0 (+https://github.com/s3rgeym/hh-applicant-tool)",
63
+ "user-agent": self.user_agent or generate_android_useragent(),
58
64
  "x-hh-app-active": "true",
59
65
  }
60
66
 
@@ -87,7 +93,7 @@ class BaseClient:
87
93
  method,
88
94
  url,
89
95
  **payload,
90
- headers=self.default_headers(),
96
+ headers=self._default_headers(),
91
97
  allow_redirects=False,
92
98
  )
93
99
  try:
@@ -139,14 +145,19 @@ class BaseClient:
139
145
 
140
146
  @dataclass
141
147
  class OAuthClient(BaseClient):
142
- client_id: str
143
- client_secret: str
148
+ client_id: str | None = None
149
+ client_secret: str | None = None
144
150
  _: dataclasses.KW_ONLY
145
- base_url: str = "https://hh.ru/oauth/"
151
+ base_url: str = HH_OAUTH_URL
146
152
  state: str = ""
147
153
  scope: str = ""
148
154
  redirect_uri: str = ""
149
155
 
156
+ def __post_init__(self) -> None:
157
+ super().__post_init__()
158
+ self.client_id = self.client_id or ANDROID_CLIENT_ID
159
+ self.client_secret = self.client_secret or ANDROID_CLIENT_SECRET
160
+
150
161
  @property
151
162
  def authorize_url(self) -> str:
152
163
  params = dict(
@@ -197,7 +208,7 @@ class ApiClient(BaseClient):
197
208
  _: dataclasses.KW_ONLY
198
209
  client_id: str | None = None
199
210
  client_secret: str | None = None
200
- base_url: str = "https://api.hh.ru/"
211
+ base_url: str = HH_API_URL
201
212
 
202
213
  @property
203
214
  def is_access_expired(self) -> bool:
@@ -212,10 +223,10 @@ class ApiClient(BaseClient):
212
223
  session=self.session,
213
224
  )
214
225
 
215
- def default_headers(
226
+ def _default_headers(
216
227
  self,
217
228
  ) -> dict[str, str]:
218
- headers = super().default_headers()
229
+ headers = super()._default_headers()
219
230
  if not self.access_token:
220
231
  return headers
221
232
  # Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
@@ -1,6 +1,6 @@
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
-
3
- ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
1
+ ANDROID_CLIENT_ID = (
2
+ "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
3
+ )
4
4
 
5
5
  ANDROID_CLIENT_SECRET = (
6
6
  "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
@@ -32,6 +32,8 @@ class PaginatedItems(TypedDict, Generic[Item]):
32
32
  arguments: Optional[Any]
33
33
  fixes: Optional[Any]
34
34
  suggests: Optional[Any]
35
+ # Это выглядит как глюк. Я нейронке скармливал выхлоп апи, а она писала эти
36
+ # типы
35
37
  alternate_url: str
36
38
 
37
39
 
@@ -46,7 +46,11 @@ class ApiError(BadResponse):
46
46
 
47
47
  @property
48
48
  def message(self) -> str:
49
- return self._data.get("description") or str(self._data)
49
+ return (
50
+ self._data.get("error_description")
51
+ or self._data.get("description")
52
+ or str(self._data)
53
+ )
50
54
 
51
55
  # def __getattr__(self, name: str) -> Any:
52
56
  # try:
@@ -62,7 +66,9 @@ class ApiError(BadResponse):
62
66
  return any(v.get("value") == value for v in data.get("errors", []))
63
67
 
64
68
  @classmethod
65
- def raise_for_status(cls: Type[ApiError], response: Response, data: dict) -> None:
69
+ def raise_for_status(
70
+ cls: Type[ApiError], response: Response, data: dict
71
+ ) -> None:
66
72
  match response.status_code:
67
73
  case status if 300 <= status <= 308:
68
74
  raise Redirect(response, data)
@@ -2,7 +2,7 @@ import random
2
2
  import uuid
3
3
 
4
4
 
5
- def hh_android_useragent() -> str:
5
+ def generate_android_useragent() -> str:
6
6
  """Generates Android App User-Agent"""
7
7
  devices = (
8
8
  "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(", ")
hh_applicant_tool/main.py CHANGED
@@ -17,9 +17,8 @@ from typing import Any, Iterable
17
17
  import requests
18
18
  import urllib3
19
19
 
20
- from . import datatypes, utils
21
- from .api import ApiClient
22
- from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
20
+ from . import utils
21
+ from .api import ApiClient, datatypes
23
22
  from .storage import StorageFacade
24
23
  from .utils.log import setup_logger
25
24
  from .utils.mixins import MegaTool
@@ -205,13 +204,13 @@ class HHApplicantTool(MegaTool):
205
204
  config = self.config
206
205
  token = config.get("token", {})
207
206
  api = ApiClient(
208
- client_id=config.get("client_id", ANDROID_CLIENT_ID),
209
- client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
207
+ client_id=config.get("client_id"),
208
+ client_secret=config.get("client_id"),
210
209
  access_token=token.get("access_token"),
211
210
  refresh_token=token.get("refresh_token"),
212
211
  access_expires_at=token.get("access_expires_at"),
213
212
  delay=args.delay,
214
- user_agent=config["user_agent"] or utils.hh_android_useragent(),
213
+ user_agent=config.get("user_agent"),
215
214
  session=self.session,
216
215
  )
217
216
  return api
@@ -219,13 +218,12 @@ class HHApplicantTool(MegaTool):
219
218
  def get_me(self) -> datatypes.User:
220
219
  return self.api_client.get("/me")
221
220
 
222
- def get_resumes(self) -> datatypes.PaginatedItems[datatypes.Resume]:
223
- return self.api_client.get("/resumes/mine")
221
+ def get_resumes(self) -> list[datatypes.Resume]:
222
+ return self.api_client.get("/resumes/mine")["items"]
224
223
 
225
- def first_resume_id(self):
226
- resumes = self.api_client.get("/resumes/mine")
227
- assert len(resumes["items"]), "Empty resume list"
228
- return resumes["items"][0]["id"]
224
+ def first_resume_id(self) -> str:
225
+ resumes = self.get_resumes()
226
+ return resumes[0]["id"]
229
227
 
230
228
  def get_blacklisted(self) -> list[str]:
231
229
  rv = []
@@ -305,9 +303,10 @@ class HHApplicantTool(MegaTool):
305
303
  return 2
306
304
  finally:
307
305
  try:
308
- self.check_system()
306
+ self._check_system()
309
307
  except Exception:
310
308
  pass
309
+ # raise
311
310
 
312
311
  def _parse_args(self, argv) -> None:
313
312
  self._parser = self._create_parser()
@@ -4,14 +4,14 @@ import argparse
4
4
  import logging
5
5
  import random
6
6
  from pathlib import Path
7
- from typing import TYPE_CHECKING, Iterator, TextIO
7
+ from typing import TYPE_CHECKING, Iterator
8
8
 
9
- from .. import datatypes
10
9
  from ..ai.base import AIError
11
- from ..api import BadResponse, Redirect
10
+ from ..api import BadResponse, Redirect, datatypes
11
+ from ..api.datatypes import PaginatedItems, SearchVacancy
12
12
  from ..api.errors import ApiError, LimitExceeded
13
- from ..datatypes import PaginatedItems, SearchVacancy
14
13
  from ..main import BaseNamespace, BaseOperation
14
+ from ..storage.repositories.errors import RepositoryError
15
15
  from ..utils import bool2str, list2str, rand_text, shorten
16
16
 
17
17
  if TYPE_CHECKING:
@@ -23,7 +23,7 @@ logger = logging.getLogger(__package__)
23
23
 
24
24
  class Namespace(BaseNamespace):
25
25
  resume_id: str | None
26
- message_list: TextIO
26
+ message_list_path: Path
27
27
  ignore_employers: Path | None
28
28
  force_message: bool
29
29
  use_ai: bool
@@ -62,10 +62,9 @@ class Namespace(BaseNamespace):
62
62
 
63
63
 
64
64
  class Operation(BaseOperation):
65
- """Откликнуться на все подходящие вакансии.
65
+ """Откликнуться на все подходящие вакансии."""
66
66
 
67
- Описание фильтров для поиска вакансий: <https://api.hh.ru/openapi/redoc#tag/Poisk-vakansij-dlya-soiskatelya/operation/get-vacancies-similar-to-resume>
68
- """
67
+ __aliases__ = ("apply",)
69
68
 
70
69
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
71
70
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
@@ -76,9 +75,10 @@ class Operation(BaseOperation):
76
75
  )
77
76
  parser.add_argument(
78
77
  "-L",
78
+ "--message-list-path",
79
79
  "--message-list",
80
80
  help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
81
- type=argparse.FileType("r", encoding="utf-8", errors="replace"),
81
+ type=Path,
82
82
  )
83
83
  parser.add_argument(
84
84
  "-f",
@@ -243,7 +243,7 @@ class Operation(BaseOperation):
243
243
  self.api_client = tool.api_client
244
244
  args: Namespace = tool.args
245
245
  self.application_messages = self._get_application_messages(
246
- args.message_list
246
+ args.message_list_path
247
247
  )
248
248
  self.area = args.area
249
249
  self.bottom_lat = args.bottom_lat
@@ -268,7 +268,7 @@ class Operation(BaseOperation):
268
268
  self.pre_prompt = args.prompt
269
269
  self.premium = args.premium
270
270
  self.professional_role = args.professional_role
271
- self.resume_id = args.resume_id or tool.first_resume_id()
271
+ self.resume_id = args.resume_id
272
272
  self.right_lng = args.right_lng
273
273
  self.salary = args.salary
274
274
  self.schedule = args.schedule
@@ -283,52 +283,96 @@ class Operation(BaseOperation):
283
283
  )
284
284
  self._apply_similar()
285
285
 
286
- def _get_application_messages(
287
- self, message_list: TextIO | None
288
- ) -> list[str]:
289
- return (
290
- list(filter(None, map(str.strip, message_list)))
291
- if message_list
292
- else [
293
- "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
294
- "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s", # noqa: E501
295
- ]
296
- )
297
-
298
286
  def _apply_similar(self) -> None:
287
+ resumes: list[datatypes.Resume] = self.tool.get_resumes()
288
+ try:
289
+ self.tool.storage.resumes.save_batch(resumes)
290
+ except RepositoryError as ex:
291
+ logger.exception(ex)
292
+ resumes = (
293
+ list(filter(lambda x: x["id"] == self.resume_id, resumes))
294
+ if self.resume_id
295
+ else resumes
296
+ )
297
+ # Выбираем только опубликованные
298
+ resumes = list(
299
+ filter(lambda x: x["status"]["id"] == "published", resumes)
300
+ )
301
+ if not resumes:
302
+ logger.warning("У вас нет опубликованных резюме")
303
+ return
304
+
299
305
  me: datatypes.User = self.tool.get_me()
306
+ seen_employers = set()
300
307
 
301
- basic_placeholders = {
302
- "first_name": me.get("first_name", ""),
303
- "last_name": me.get("last_name", ""),
304
- "email": me.get("email", ""),
305
- "phone": me.get("phone", ""),
308
+ for resume in resumes:
309
+ self._apply_resume(
310
+ resume=resume,
311
+ user=me,
312
+ seen_employers=seen_employers,
313
+ )
314
+
315
+ # Синхронизация откликов
316
+ # for neg in self.tool.get_negotiations():
317
+ # try:
318
+ # self.tool.storage.negotiations.save(neg)
319
+ # except RepositoryError as e:
320
+ # logger.warning(e)
321
+
322
+ print("📝 Отклики на вакансии разосланы!")
323
+
324
+ def _apply_resume(
325
+ self,
326
+ resume: datatypes.Resume,
327
+ user: datatypes.User,
328
+ seen_employers: set[str],
329
+ ) -> None:
330
+ logger.info("Начинаю рассылку откликов для резюме: %s", resume["title"])
331
+
332
+ placeholders = {
333
+ "first_name": user.get("first_name") or "",
334
+ "last_name": user.get("last_name") or "",
335
+ "email": user.get("email") or "",
336
+ "phone": user.get("phone") or "",
337
+ "resume_title": resume.get("title") or "",
306
338
  }
307
339
 
308
- seen_employers = set()
309
- for vacancy in self._get_vacancies():
340
+ for vacancy in self._get_similar_vacancies(resume_id=resume["id"]):
310
341
  try:
311
342
  employer = vacancy.get("employer", {})
312
343
 
313
- placeholders = {
344
+ message_placeholders = {
314
345
  "vacancy_name": vacancy.get("name", ""),
315
346
  "employer_name": employer.get("name", ""),
316
- **basic_placeholders,
347
+ **placeholders,
317
348
  }
318
349
 
319
350
  storage = self.tool.storage
320
- storage.vacancies.save(vacancy)
351
+
352
+ try:
353
+ storage.vacancies.save(vacancy)
354
+ except RepositoryError as ex:
355
+ logger.debug(ex)
356
+
321
357
  if employer := vacancy.get("employer"):
322
358
  employer_id = employer.get("id")
323
359
  if employer_id and employer_id not in seen_employers:
324
360
  employer_profile: datatypes.Employer = (
325
361
  self.api_client.get(f"/employers/{employer_id}")
326
362
  )
327
- storage.employers.save(employer_profile)
363
+
364
+ try:
365
+ storage.employers.save(employer_profile)
366
+ except RepositoryError as ex:
367
+ logger.exception(ex)
328
368
 
329
369
  # По факту контакты можно получить только здесь?!
330
370
  if vacancy.get("contacts"):
331
- storage.employer_contacts.save(vacancy)
371
+ try:
372
+ # logger.debug(vacancy)
373
+ storage.vacancy_contacts.save(vacancy)
374
+ except RecursionError as ex:
375
+ logger.exception(ex)
332
376
 
333
377
  if vacancy.get("has_test"):
334
378
  logger.debug(
@@ -363,13 +407,20 @@ class Operation(BaseOperation):
363
407
  )
364
408
  if "got_rejection" in relations:
365
409
  logger.debug(
366
- "Вы получили отказ: %s", vacancy["alternate_url"]
410
+ "Вы получили отказ от %s на резюме %s",
411
+ vacancy["alternate_url"],
412
+ resume["alternate_url"],
413
+ )
414
+ print(
415
+ "⛔ Пришел отказ от",
416
+ vacancy["alternate_url"],
417
+ "на резюме",
418
+ resume["alternate_url"],
367
419
  )
368
- print("⛔ Пришел отказ", vacancy["alternate_url"])
369
420
  continue
370
421
 
371
422
  params = {
372
- "resume_id": self.resume_id,
423
+ "resume_id": resume["id"],
373
424
  "vacancy_id": vacancy_id,
374
425
  "message": "",
375
426
  }
@@ -379,13 +430,19 @@ class Operation(BaseOperation):
379
430
  ):
380
431
  if self.openai_chat:
381
432
  msg = self.pre_prompt + "\n\n"
382
- msg += placeholders["vacancy_name"]
433
+ msg += (
434
+ "Название вакансии: "
435
+ + message_placeholders["vacancy_name"]
436
+ )
437
+ msg += (
438
+ "Мое резюме:" + message_placeholders["resume_title"]
439
+ )
383
440
  logger.debug("prompt: %s", msg)
384
441
  msg = self.openai_chat.send_message(msg)
385
442
  else:
386
443
  msg = (
387
444
  rand_text(random.choice(self.application_messages))
388
- % placeholders
445
+ % message_placeholders
389
446
  )
390
447
 
391
448
  logger.debug(msg)
@@ -400,12 +457,18 @@ class Operation(BaseOperation):
400
457
  )
401
458
  assert res == {}
402
459
  logger.debug(
403
- "Отправили отклик: %s", vacancy["alternate_url"]
460
+ "Откликнулись на %s с резюме %s",
461
+ vacancy["alternate_url"],
462
+ resume["alternate_url"],
404
463
  )
405
464
  print(
406
- "📨 Отправили отклик:",
465
+ "📨 Отправили отклик для резюме",
466
+ resume["alternate_url"],
467
+ "на вакансию",
407
468
  vacancy["alternate_url"],
469
+ "(",
408
470
  shorten(vacancy["name"]),
471
+ ")",
409
472
  )
410
473
  except Redirect:
411
474
  logger.warning(
@@ -414,15 +477,12 @@ class Operation(BaseOperation):
414
477
  except LimitExceeded:
415
478
  logger.info("Достигли лимита на отклики")
416
479
  print("⚠️ Достигли лимита рассылки")
417
- # self.tool.storage.settings.set_value("_")
418
480
  break
419
481
  except ApiError as ex:
420
482
  logger.warning(ex)
421
483
  except (BadResponse, AIError) as ex:
422
484
  logger.error(ex)
423
485
 
424
- print("📝 Отклики на вакансии разосланы!")
425
-
426
486
  def _get_search_params(self, page: int) -> dict:
427
487
  params = {
428
488
  "page": page,
@@ -489,11 +549,11 @@ class Operation(BaseOperation):
489
549
 
490
550
  return params
491
551
 
492
- def _get_vacancies(self) -> Iterator[SearchVacancy]:
552
+ def _get_similar_vacancies(self, resume_id: str) -> Iterator[SearchVacancy]:
493
553
  for page in range(self.total_pages):
494
554
  params = self._get_search_params(page)
495
555
  res: PaginatedItems[SearchVacancy] = self.api_client.get(
496
- f"/resumes/{self.resume_id}/similar_vacancies",
556
+ f"/resumes/{resume_id}/similar_vacancies",
497
557
  params,
498
558
  )
499
559
  if not res["items"]:
@@ -503,3 +563,21 @@ class Operation(BaseOperation):
503
563
 
504
564
  if page >= res["pages"] - 1:
505
565
  return
566
+
567
+ def _get_application_messages(self, path: Path | None) -> list[str]:
568
+ return (
569
+ list(
570
+ filter(
571
+ None,
572
+ map(
573
+ str.strip,
574
+ path.open(encoding="utf-8", errors="replace"),
575
+ ),
576
+ )
577
+ )
578
+ if path
579
+ else [
580
+ "Здравствуйте, меня зовут %(first_name)s. {Меня заинтересовала|Мне понравилась} ваша вакансия «%(vacancy_name)s». Хотелось бы {пообщаться|задать вопросы} о ней.",
581
+ "{Прошу|Предлагаю} рассмотреть {мою кандидатуру|мое резюме «%(resume_title)s»} на вакансию «%(vacancy_name)s». С уважением, %(first_name)s.", # noqa: E501
582
+ ]
583
+ )