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
@@ -14,6 +14,7 @@ except ImportError:
14
14
  pass
15
15
 
16
16
  from ..main import BaseOperation
17
+ from ..utils.terminal import print_kitty_image
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from ..main import HHApplicantTool
@@ -36,11 +37,13 @@ class Operation(BaseOperation):
36
37
  __aliases__: list = ["auth", "authen", "authenticate"]
37
38
 
38
39
  # Селекторы
39
- SEL_LOGIN_INPUT = 'input[data-qa="login-input-username"]'
40
- SEL_EXPAND_PASSWORD_BTN = 'button[data-qa="expand-login-by_password"]'
41
- SEL_PASSWORD_INPUT = 'input[data-qa="login-input-password"]'
42
- SEL_CODE_CONTAINER = 'div[data-qa="account-login-code-input"]'
43
- SEL_PIN_CODE_INPUT = 'input[data-qa="magritte-pincode-input-field"]'
40
+ SELECT_LOGIN_INPUT = 'input[data-qa="login-input-username"]'
41
+ SELECT_EXPAND_PASSWORD = 'button[data-qa="expand-login-by_password"]'
42
+ SELECT_PASSWORD_INPUT = 'input[data-qa="login-input-password"]'
43
+ SELECT_CODE_CONTAINER = 'div[data-qa="account-login-code-input"]'
44
+ SELECT_PIN_CODE_INPUT = 'input[data-qa="magritte-pincode-input-field"]'
45
+ SELECT_CAPTCHA_IMAGE = 'img[data-qa="account-captcha-picture"]'
46
+ SELECT_CAPTCHA_INPUT = 'input[data-qa="account-captcha-input"]'
44
47
 
45
48
  def __init__(self, *args, **kwargs):
46
49
  super().__init__(*args, **kwargs)
@@ -73,20 +76,30 @@ class Operation(BaseOperation):
73
76
  )
74
77
  parser.add_argument(
75
78
  "--no-headless",
79
+ "-n",
76
80
  action="store_true",
77
81
  help="Показать окно браузера для отладки (отключает headless режим).",
78
82
  )
79
83
  parser.add_argument(
84
+ "-m",
80
85
  "--manual",
81
86
  action="store_true",
82
87
  help="Ручной режим ввода кредов, редирект будет перехвачен.",
83
88
  )
89
+ parser.add_argument(
90
+ "-k",
91
+ "--use-kitty",
92
+ "--kitty",
93
+ action="store_true",
94
+ help="Использовать kitty protocol для вывода изображения в терминал. Гуглите поддерживает ли ваш терминал его",
95
+ )
84
96
 
85
97
  def run(self, tool: HHApplicantTool) -> None:
86
98
  self._args = tool.args
87
99
  try:
88
100
  asyncio.run(self._main(tool))
89
101
  except (KeyboardInterrupt, asyncio.TimeoutError):
102
+ # _executor.shutdown(wait=False, cancel_futures=True)
90
103
  logger.warning("Что-то пошло не так")
91
104
  # os._exit(1)
92
105
  return 1
@@ -179,22 +192,21 @@ class Operation(BaseOperation):
179
192
  )
180
193
 
181
194
  if self.is_automated:
182
- # Шаг 1: Логин
183
- logger.debug(f"Ожидание поля логина {self.SEL_LOGIN_INPUT}")
195
+ logger.debug(
196
+ f"Ожидание поля логина {self.SELECT_LOGIN_INPUT}"
197
+ )
184
198
  await page.wait_for_selector(
185
- self.SEL_LOGIN_INPUT, timeout=self.selector_timeout
199
+ self.SELECT_LOGIN_INPUT, timeout=self.selector_timeout
186
200
  )
187
- await page.fill(self.SEL_LOGIN_INPUT, username)
201
+ await page.fill(self.SELECT_LOGIN_INPUT, username)
188
202
  logger.debug("Логин введен")
189
203
 
190
- # Шаг 2: Выбор метода входа
191
204
  if args.password:
192
205
  await self._direct_login(page, args.password)
193
206
  else:
194
207
  await self._onetime_code_login(page)
195
208
 
196
- # Шаг 3: Ожидание OAuth кода
197
- logger.debug("Ожидание появления OAuth кода в трафике...")
209
+ logger.debug("Ожидание OAuth-кода...")
198
210
 
199
211
  auth_code = await asyncio.wait_for(
200
212
  code_future, timeout=[None, 30.0][self.is_automated]
@@ -237,37 +249,82 @@ class Operation(BaseOperation):
237
249
 
238
250
  async def _direct_login(self, page, password: str) -> None:
239
251
  logger.info("Вход по паролю...")
252
+
240
253
  logger.debug(
241
- f"Клик по кнопке развертывания пароля: {self.SEL_EXPAND_PASSWORD_BTN}"
254
+ f"Клик по кнопке развертывания пароля: {self.SELECT_EXPAND_PASSWORD}"
242
255
  )
243
- await page.click(self.SEL_EXPAND_PASSWORD_BTN)
256
+ await page.click(self.SELECT_EXPAND_PASSWORD)
257
+
258
+ await self._handle_captcha(page)
244
259
 
245
- logger.debug(f"Ожидание поля пароля: {self.SEL_PASSWORD_INPUT}")
260
+ logger.debug(f"Ожидание поля пароля: {self.SELECT_PASSWORD_INPUT}")
246
261
  await page.wait_for_selector(
247
- self.SEL_PASSWORD_INPUT, timeout=self.selector_timeout
262
+ self.SELECT_PASSWORD_INPUT, timeout=self.selector_timeout
248
263
  )
249
- await page.fill(self.SEL_PASSWORD_INPUT, password)
250
- await page.press(self.SEL_PASSWORD_INPUT, "Enter")
264
+ await page.fill(self.SELECT_PASSWORD_INPUT, password)
265
+ await page.press(self.SELECT_PASSWORD_INPUT, "Enter")
251
266
  logger.debug("Форма с паролем отправлена")
252
267
 
253
268
  async def _onetime_code_login(self, page) -> None:
254
269
  logger.info("Вход по одноразовому коду...")
255
- await page.press(self.SEL_LOGIN_INPUT, "Enter")
270
+
271
+ await page.press(self.SELECT_LOGIN_INPUT, "Enter")
272
+
273
+ await self._handle_captcha(page)
256
274
 
257
275
  logger.debug(
258
- f"Ожидание контейнера ввода кода: {self.SEL_CODE_CONTAINER}"
276
+ f"Ожидание контейнера ввода кода: {self.SELECT_CODE_CONTAINER}"
259
277
  )
278
+
260
279
  await page.wait_for_selector(
261
- self.SEL_CODE_CONTAINER, timeout=self.selector_timeout
280
+ self.SELECT_CODE_CONTAINER, timeout=self.selector_timeout
262
281
  )
263
282
 
264
283
  print("📨 Код был отправлен. Проверьте почту или SMS.")
265
284
  code = (await ainput("📩 Введите полученный код: ")).strip()
266
285
 
267
286
  if not code:
268
- raise RuntimeError("Код подтверждения не может быть пустым")
287
+ raise RuntimeError("Код подтверждения не может быть пустым.")
269
288
 
270
- logger.debug(f"Ввод кода в {self.SEL_PIN_CODE_INPUT}")
271
- await page.fill(self.SEL_PIN_CODE_INPUT, code)
272
- await page.press(self.SEL_PIN_CODE_INPUT, "Enter")
289
+ logger.debug(f"Ввод кода в {self.SELECT_PIN_CODE_INPUT}")
290
+ await page.fill(self.SELECT_PIN_CODE_INPUT, code)
291
+ await page.press(self.SELECT_PIN_CODE_INPUT, "Enter")
273
292
  logger.debug("Форма с кодом отправлена")
293
+
294
+ async def _handle_captcha(self, page):
295
+ try:
296
+ captcha_element = await page.wait_for_selector(
297
+ self.SELECT_CAPTCHA_IMAGE,
298
+ timeout=self.selector_timeout,
299
+ state="visible",
300
+ )
301
+ except Exception:
302
+ logger.debug("Капчи нет, продолжаем как обычно.")
303
+ return
304
+
305
+ if not self._args.use_kitty:
306
+ raise RuntimeError(
307
+ "Используйте флаг --use-kitty/-k для вывода капчи в терминал."
308
+ "Работает не во всех терминалах!",
309
+ )
310
+
311
+ logger.info("Обнаружена капча!")
312
+
313
+ # box = await captcha_element.bounding_box()
314
+
315
+ # width = int(box["width"])
316
+ # height = int(box["height"])
317
+
318
+ img_bytes = await captcha_element.screenshot()
319
+
320
+ print(
321
+ "Если вы не видите картинку ниже, то ваш терминал не поддерживает"
322
+ " kitty protocol."
323
+ )
324
+ print()
325
+ print_kitty_image(img_bytes)
326
+
327
+ captcha_text = (await ainput("Введите текст с картинки: ")).strip()
328
+
329
+ await page.fill(self.SELECT_CAPTCHA_INPUT, captcha_text)
330
+ await page.press(self.SELECT_CAPTCHA_INPUT, "Enter")
@@ -39,9 +39,9 @@ class Operation(BaseOperation):
39
39
  "-m", "--method", "--meth", "-X", default="GET", help="HTTP Метод"
40
40
  )
41
41
 
42
- def run(self, applicant_tool: HHApplicantTool) -> None:
43
- args = applicant_tool.args
44
- api_client = applicant_tool.api_client
42
+ def run(self, tool: HHApplicantTool) -> None:
43
+ args = tool.args
44
+ api_client = tool.api_client
45
45
  params = dict(x.split("=", 1) for x in args.param)
46
46
  try:
47
47
  result = api_client.request(
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import datetime as dt
4
5
  import logging
5
6
  from typing import TYPE_CHECKING
6
7
 
7
8
  from ..api.errors import ApiError
8
- from ..datatypes import NegotiationStateId
9
9
  from ..main import BaseNamespace, BaseOperation
10
+ from ..utils.date import parse_api_datetime
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from ..main import HHApplicantTool
@@ -17,21 +18,16 @@ logger = logging.getLogger(__package__)
17
18
  class Namespace(BaseNamespace):
18
19
  cleanup: bool
19
20
  blacklist_discard: bool
21
+ older_than: int
20
22
  dry_run: bool
21
23
 
22
24
 
23
25
  class Operation(BaseOperation):
24
- """Проверяет и синхронизирует отклики с локальной базой и опционально удаляет отказы."""
26
+ """Удаляет отказы либо старые отклики."""
25
27
 
26
- __aliases__ = ["sync-negotiations"]
28
+ __aliases__ = ["clear-negotiations", "delete-negotiations"]
27
29
 
28
30
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
29
- parser.add_argument(
30
- "--cleanup",
31
- "--clean",
32
- action=argparse.BooleanOptionalAction,
33
- help="Удалить отклики с отказами",
34
- )
35
31
  parser.add_argument(
36
32
  "-b",
37
33
  "--blacklist-discard",
@@ -39,6 +35,12 @@ class Operation(BaseOperation):
39
35
  action=argparse.BooleanOptionalAction,
40
36
  help="Блокировать работодателя за отказ",
41
37
  )
38
+ parser.add_argument(
39
+ "-o",
40
+ "--older-than",
41
+ type=int,
42
+ help="С флагом --clean удаляет любые отклики старше N дней",
43
+ )
42
44
  parser.add_argument(
43
45
  "-n",
44
46
  "--dry-run",
@@ -48,26 +50,33 @@ class Operation(BaseOperation):
48
50
 
49
51
  def run(self, tool: HHApplicantTool) -> None:
50
52
  self.tool = tool
51
- self.args = tool.args
52
- self._sync()
53
+ self.args: Namespace = tool.args
54
+ self.clear()
53
55
 
54
- def _sync(self) -> None:
56
+ def clear(self) -> None:
57
+ blacklisted = set(self.tool.get_blacklisted())
55
58
  storage = self.tool.storage
56
59
  for negotiation in self.tool.get_negotiations():
57
60
  vacancy = negotiation["vacancy"]
58
- employer = vacancy.get("employer", {})
59
- employer_id = employer.get("id")
60
61
 
61
62
  # Если работодателя блокируют, то он превращается в null
62
63
  # ХХ позволяет скрывать компанию, когда id нет, а вместо имени "Крупная российская компания"
63
64
  # sqlite3.IntegrityError: NOT NULL constraint failed: negotiations.employer_id
64
- if employer_id:
65
- storage.negotiations.save(negotiation)
66
-
67
- state_id: NegotiationStateId = negotiation["state"]["id"]
68
- if not self.args.cleanup:
69
- continue
70
- if state_id != "discard":
65
+ # try:
66
+ # storage.negotiations.save(negotiation)
67
+ # except RepositoryError as e:
68
+ # logger.exception(e)
69
+
70
+ if self.args.older_than:
71
+ updated_at = parse_api_datetime(negotiation["updated_at"])
72
+ # А хз какую временную зону сайт возвращает
73
+ days_passed = (
74
+ dt.datetime.now(updated_at.tzinfo) - updated_at
75
+ ).days
76
+ logger.debug(f"{days_passed = }")
77
+ if days_passed <= self.args.older_than:
78
+ continue
79
+ elif negotiation["state"]["id"] != "discard":
71
80
  continue
72
81
  try:
73
82
  if not self.args.dry_run:
@@ -78,18 +87,24 @@ class Operation(BaseOperation):
78
87
 
79
88
  print(
80
89
  "🗑️ Отменили отклик на вакансию:",
81
- vacancy["name"],
82
90
  vacancy["alternate_url"],
91
+ vacancy["name"],
83
92
  )
84
93
 
94
+ employer = vacancy.get("employer", {})
95
+ employer_id = employer.get("id")
96
+
85
97
  if (
86
- employer_id
87
- and employer_id not in self.args.blacklist_discard
98
+ self.args.blacklist_discard
99
+ and employer
100
+ and employer_id
101
+ and employer_id not in blacklisted
88
102
  ):
89
103
  if not self.args.dry_run:
90
104
  self.tool.api_client.put(
91
105
  f"/employers/blacklisted/{employer_id}"
92
106
  )
107
+ blacklisted.add(employer_id)
93
108
 
94
109
  print(
95
110
  "🚫 Работодатель заблокирован:",
@@ -99,4 +114,4 @@ class Operation(BaseOperation):
99
114
  except ApiError as err:
100
115
  logger.error(err)
101
116
 
102
- print("✅ Синхронизация завершена.")
117
+ print("✅ Удаление откликов завершено.")
@@ -6,12 +6,12 @@ from typing import TYPE_CHECKING
6
6
 
7
7
  from prettytable import PrettyTable
8
8
 
9
- from ..datatypes import PaginatedItems
9
+ from ..api.datatypes import PaginatedItems
10
10
  from ..main import BaseNamespace, BaseOperation
11
11
  from ..utils.string import shorten
12
12
 
13
13
  if TYPE_CHECKING:
14
- from .. import datatypes
14
+ from ..api import datatypes
15
15
  from ..main import HHApplicantTool
16
16
 
17
17
 
@@ -32,9 +32,8 @@ class Operation(BaseOperation):
32
32
 
33
33
  def run(self, tool: HHApplicantTool) -> None:
34
34
  resumes: PaginatedItems[datatypes.Resume] = tool.get_resumes()
35
- storage = tool.storage
36
- for resume in resumes["items"]:
37
- storage.resumes.save(resume)
35
+ logger.debug(resumes)
36
+ tool.storage.resumes.save_batch(resumes)
38
37
 
39
38
  t = PrettyTable(
40
39
  field_names=["ID", "Название", "Статус"], align="l", valign="t"
@@ -46,8 +45,7 @@ class Operation(BaseOperation):
46
45
  shorten(x["title"]),
47
46
  x["status"]["name"].title(),
48
47
  )
49
- for x in resumes["items"]
48
+ for x in resumes
50
49
  ]
51
50
  )
52
51
  print(t)
53
- print(f"\nНайдено резюме: {resumes['found']}")
@@ -88,7 +88,9 @@ class Operation(BaseOperation):
88
88
  )
89
89
  else:
90
90
  tool.db.commit()
91
- print(f"OK. Rows affected: {cursor.rowcount}")
91
+
92
+ if cursor.rowcount > 0:
93
+ print(f"Rows affected: {cursor.rowcount}")
92
94
 
93
95
  except sqlite3.Error as ex:
94
96
  print(f"❌ SQL Error: {ex}")
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import logging
5
5
  import random
6
+ from datetime import datetime
6
7
  from typing import TYPE_CHECKING
7
8
 
8
- from .. import datatypes
9
9
  from ..ai.base import AIError
10
- from ..api import ApiError
10
+ from ..api import ApiError, datatypes
11
11
  from ..main import BaseNamespace, BaseOperation
12
- from ..utils.dateutil import parse_api_datetime
12
+ from ..utils.date import parse_api_datetime
13
13
  from ..utils.string import rand_text
14
14
 
15
15
  if TYPE_CHECKING:
@@ -37,21 +37,30 @@ class Namespace(BaseNamespace):
37
37
  use_ai: bool
38
38
  first_prompt: str
39
39
  prompt: str
40
+ period: int
40
41
 
41
42
 
42
43
  class Operation(BaseOperation):
43
44
  """Ответ всем работодателям."""
44
45
 
45
- __aliases__ = ["reply-chats", "reply-all"]
46
+ __aliases__ = ["reply-empls", "reply-chats", "reall"]
46
47
 
47
48
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
48
- parser.add_argument("--resume-id", help="Идентификатор резюме")
49
+ parser.add_argument(
50
+ "--resume-id",
51
+ help="Идентификатор резюме. Если не указан, то просматриваем чаты для всех резюме",
52
+ )
49
53
  parser.add_argument(
50
54
  "-m",
51
55
  "--reply-message",
52
56
  "--reply",
53
57
  help="Отправить сообщение во все чаты. Если не передать сообщение, то нужно будет вводить его в интерактивном режиме.", # noqa: E501
54
58
  )
59
+ parser.add_argument(
60
+ "--period",
61
+ type=int,
62
+ help="Игнорировать отклики, которые не обновлялись больше N дней",
63
+ )
55
64
  parser.add_argument(
56
65
  "-p",
57
66
  "--max-pages",
@@ -92,7 +101,7 @@ class Operation(BaseOperation):
92
101
 
93
102
  def run(self, tool: HHApplicantTool) -> None:
94
103
  args: Namespace = tool.args
95
- self.applicant_tool = tool
104
+ self.tool = tool
96
105
  self.api_client = tool.api_client
97
106
  self.resume_id = tool.first_resume_id()
98
107
  self.reply_message = args.reply_message or tool.config.get(
@@ -106,27 +115,60 @@ class Operation(BaseOperation):
106
115
  self.openai_chat = (
107
116
  tool.get_openai_chat(args.first_prompt) if args.use_ai else None
108
117
  )
118
+ self.period = args.period
109
119
 
110
120
  logger.debug(f"{self.reply_message = }")
111
- self.reply_chats()
112
-
113
- def reply_chats(self) -> None:
114
- blacklisted = self.applicant_tool.get_blacklisted()
115
- logger.debug(f"{blacklisted = }")
116
- me: datatypes.User = self.applicant_tool.get_me()
117
-
118
- basic_message_placeholders = {
119
- "first_name": me.get("first_name", ""),
120
- "last_name": me.get("last_name", ""),
121
- "email": me.get("email", ""),
122
- "phone": me.get("phone", ""),
121
+ self.reply_employers()
122
+
123
+ def reply_employers(self):
124
+ blacklist = set(self.tool.get_blacklisted())
125
+ me: datatypes.User = self.tool.get_me()
126
+ resumes = self.tool.get_resumes()
127
+ resumes = (
128
+ list(filter(lambda x: x["id"] == self.resume_id, resumes))
129
+ if self.resume_id
130
+ else resumes
131
+ )
132
+ resumes = list(
133
+ filter(
134
+ lambda resume: resume["status"]["id"] == "published", resumes
135
+ )
136
+ )
137
+ self._reply_chats(user=me, resumes=resumes, blacklist=blacklist)
138
+
139
+ def _reply_chats(
140
+ self,
141
+ user: datatypes.User,
142
+ resumes: list[datatypes.Resume],
143
+ blacklist: set[str],
144
+ ) -> None:
145
+ resume_map = {r["id"]: r for r in resumes}
146
+
147
+ base_placeholders = {
148
+ "first_name": user.get("first_name") or "",
149
+ "last_name": user.get("last_name") or "",
150
+ "email": user.get("email") or "",
151
+ "phone": user.get("phone") or "",
123
152
  }
124
153
 
125
- for negotiation in self.applicant_tool.get_negotiations():
154
+ for negotiation in self.tool.get_negotiations():
126
155
  try:
127
- self.applicant_tool.storage.negotiations.save(negotiation)
156
+ # try:
157
+ # self.tool.storage.negotiations.save(negotiation)
158
+ # except RepositoryError as e:
159
+ # logger.exception(e)
128
160
 
129
- if self.resume_id != negotiation["resume"]["id"]:
161
+ if not (resume := resume_map.get(negotiation["resume"]["id"])):
162
+ continue
163
+
164
+ updated_at = parse_api_datetime(negotiation["updated_at"])
165
+
166
+ # Пропуск откликов, которые не обновлялись более N дней (при просмотре они обновляются вроде)
167
+ if (
168
+ self.period
169
+ and (datetime().now(updated_at.tzinfo) - updated_at).days
170
+ > self.period
171
+ ):
130
172
  continue
131
173
 
132
174
  state_id = negotiation["state"]["id"]
@@ -141,26 +183,27 @@ class Operation(BaseOperation):
141
183
  employer = vacancy.get("employer") or {}
142
184
  salary = vacancy.get("salary") or {}
143
185
 
144
- if employer.get("id") in blacklisted:
186
+ if employer.get("id") in blacklist:
145
187
  print(
146
188
  "🚫 Пропускаем заблокированного работодателя",
147
189
  employer.get("alternate_url"),
148
190
  )
149
191
  continue
150
192
 
151
- message_placeholders = {
193
+ placeholders = {
152
194
  "vacancy_name": vacancy.get("name", ""),
153
195
  "employer_name": employer.get("name", ""),
154
- **basic_message_placeholders,
196
+ "resume_title": resume.get("title") or "",
197
+ **base_placeholders,
155
198
  }
156
199
 
157
200
  logger.debug(
158
201
  "Вакансия %(vacancy_name)s от %(employer_name)s"
159
- % message_placeholders
202
+ % placeholders
160
203
  )
161
204
 
162
205
  page: int = 0
163
- last_message: dict | None = None
206
+ last_message: datatypes.Message | None = None
164
207
  message_history: list[str] = []
165
208
  while True:
166
209
  messages_res: datatypes.PaginatedItems[
@@ -183,7 +226,8 @@ class Operation(BaseOperation):
183
226
  )
184
227
  message_date = parse_api_datetime(
185
228
  message.get("created_at")
186
- ).isoformat()
229
+ ).strftime("%d.%m.%Y %H:%M:%S")
230
+
187
231
  message_history.append(
188
232
  f"[ {message_date} ] {author}: {message['text']}"
189
233
  )
@@ -205,13 +249,13 @@ class Operation(BaseOperation):
205
249
  send_message = ""
206
250
  if self.reply_message:
207
251
  send_message = (
208
- rand_text(self.reply_message) % message_placeholders
252
+ rand_text(self.reply_message) % placeholders
209
253
  )
210
254
  logger.debug(f"Template message: {send_message}")
211
255
  elif self.openai_chat:
212
256
  try:
213
257
  ai_query = (
214
- f"Вакансия: {message_placeholders['vacancy_name']}\n"
258
+ f"Вакансия: {placeholders['vacancy_name']}\n"
215
259
  f"История переписки:\n"
216
260
  + "\n".join(message_history[-10:])
217
261
  + f"\n\nИнструкция: {self.pre_prompt}"
@@ -226,12 +270,8 @@ class Operation(BaseOperation):
226
270
  )
227
271
  continue
228
272
  else:
229
- print(
230
- "\n🏢",
231
- message_placeholders["employer_name"],
232
- "| 💼",
233
- message_placeholders["vacancy_name"],
234
- )
273
+ print("🏢", placeholders["employer_name"])
274
+ print("💼", placeholders["vacancy_name"])
235
275
  if salary:
236
276
  print(
237
277
  "💵 от",
@@ -251,9 +291,10 @@ class Operation(BaseOperation):
251
291
  print(msg)
252
292
 
253
293
  try:
254
- print("-" * 10)
294
+ print("-" * 40)
295
+ print("Активное резюме:", resume.get("title") or "")
255
296
  print(
256
- "Команды: /ban, /cancel <опционально сообщение для отмены>"
297
+ "/ban, /cancel необязательное сообщение для отмены"
257
298
  )
258
299
  send_message = input("Ваше сообщение: ").strip()
259
300
  except EOFError:
@@ -264,13 +305,12 @@ class Operation(BaseOperation):
264
305
  continue
265
306
 
266
307
  if send_message.startswith("/ban"):
267
- self.applicant_tool.storage.employers.save(employer)
268
308
  self.api_client.put(
269
309
  f"/employers/blacklisted/{employer['id']}"
270
310
  )
271
- blacklisted.append(employer["id"])
311
+ blacklist.add(employer["id"])
272
312
  print(
273
- "🚫 Работодатель в ЧС",
313
+ "🚫 Работодатель заблокирован",
274
314
  employer.get("alternate_url"),
275
315
  )
276
316
  continue
@@ -7,8 +7,8 @@ from typing import TYPE_CHECKING
7
7
 
8
8
  from prettytable import PrettyTable
9
9
 
10
+ from .. import utils
10
11
  from ..main import BaseNamespace, BaseOperation
11
- from ..utils import jsonutil
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  from ..main import HHApplicantTool
@@ -28,7 +28,7 @@ class Namespace(BaseNamespace):
28
28
 
29
29
  def parse_value(v):
30
30
  try:
31
- return jsonutil.loads(v)
31
+ return utils.json.loads(v)
32
32
  except json.JSONDecodeError:
33
33
  return v
34
34
 
@@ -5,8 +5,7 @@ import argparse
5
5
  import logging
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- from ..api import ApiError
9
- from ..datatypes import PaginatedItems
8
+ from ..api import ApiError, datatypes
10
9
  from ..main import BaseNamespace, BaseOperation
11
10
  from ..utils import print_err
12
11
  from ..utils.string import shorten
@@ -31,8 +30,10 @@ class Operation(BaseOperation):
31
30
  pass
32
31
 
33
32
  def run(self, tool: HHApplicantTool) -> None:
34
- resumes: PaginatedItems = tool.get_resumes()
35
- for resume in resumes["items"]:
33
+ resumes: list[datatypes.Resume] = tool.get_resumes()
34
+ # Там вызов API меняет поля
35
+ # tool.storage.resumes.save_batch(resumes)
36
+ for resume in resumes:
36
37
  try:
37
38
  res = tool.api_client.post(
38
39
  f"/resumes/{resume['id']}/publish",
@@ -5,7 +5,7 @@ import argparse
5
5
  import logging
6
6
  from typing import TYPE_CHECKING
7
7
 
8
- from .. import datatypes
8
+ from ..api import datatypes
9
9
  from ..main import BaseNamespace, BaseOperation
10
10
 
11
11
  if TYPE_CHECKING: