hh-applicant-tool 1.4.7__py3-none-any.whl → 1.5.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 (49) hide show
  1. hh_applicant_tool/__main__.py +1 -1
  2. hh_applicant_tool/ai/__init__.py +1 -0
  3. hh_applicant_tool/ai/openai.py +30 -14
  4. hh_applicant_tool/api/__init__.py +4 -2
  5. hh_applicant_tool/api/client.py +32 -17
  6. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
  7. hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
  8. hh_applicant_tool/api/errors.py +8 -2
  9. hh_applicant_tool/{utils → api}/user_agent.py +1 -1
  10. hh_applicant_tool/main.py +63 -38
  11. hh_applicant_tool/operations/apply_similar.py +136 -52
  12. hh_applicant_tool/operations/authorize.py +97 -28
  13. hh_applicant_tool/operations/call_api.py +3 -3
  14. hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -26
  15. hh_applicant_tool/operations/list_resumes.py +5 -7
  16. hh_applicant_tool/operations/query.py +5 -3
  17. hh_applicant_tool/operations/refresh_token.py +9 -2
  18. hh_applicant_tool/operations/reply_employers.py +80 -40
  19. hh_applicant_tool/operations/settings.py +2 -2
  20. hh_applicant_tool/operations/update_resumes.py +5 -4
  21. hh_applicant_tool/operations/whoami.py +3 -3
  22. hh_applicant_tool/storage/__init__.py +5 -1
  23. hh_applicant_tool/storage/facade.py +2 -2
  24. hh_applicant_tool/storage/models/base.py +9 -4
  25. hh_applicant_tool/storage/models/contacts.py +42 -0
  26. hh_applicant_tool/storage/queries/schema.sql +23 -10
  27. hh_applicant_tool/storage/repositories/base.py +69 -15
  28. hh_applicant_tool/storage/repositories/contacts.py +5 -10
  29. hh_applicant_tool/storage/repositories/employers.py +1 -0
  30. hh_applicant_tool/storage/repositories/errors.py +19 -0
  31. hh_applicant_tool/storage/repositories/negotiations.py +1 -0
  32. hh_applicant_tool/storage/repositories/resumes.py +2 -7
  33. hh_applicant_tool/storage/repositories/settings.py +1 -0
  34. hh_applicant_tool/storage/repositories/vacancies.py +1 -0
  35. hh_applicant_tool/storage/utils.py +12 -15
  36. hh_applicant_tool/utils/__init__.py +3 -3
  37. hh_applicant_tool/utils/config.py +1 -1
  38. hh_applicant_tool/utils/log.py +6 -3
  39. hh_applicant_tool/utils/mixins.py +28 -46
  40. hh_applicant_tool/utils/string.py +15 -0
  41. hh_applicant_tool/utils/terminal.py +115 -0
  42. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/METADATA +384 -162
  43. hh_applicant_tool-1.5.7.dist-info/RECORD +68 -0
  44. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/WHEEL +1 -1
  45. hh_applicant_tool/storage/models/contact.py +0 -16
  46. hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
  47. /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
  48. /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
  49. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/entry_points.txt +0 -0
@@ -4,15 +4,21 @@ 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
15
- from ..utils import bool2str, list2str, rand_text, shorten
14
+ from ..storage.repositories.errors import RepositoryError
15
+ from ..utils.string import (
16
+ bool2str,
17
+ list2str,
18
+ rand_text,
19
+ shorten,
20
+ unescape_string,
21
+ )
16
22
 
17
23
  if TYPE_CHECKING:
18
24
  from ..main import HHApplicantTool
@@ -23,7 +29,7 @@ logger = logging.getLogger(__package__)
23
29
 
24
30
  class Namespace(BaseNamespace):
25
31
  resume_id: str | None
26
- message_list: TextIO
32
+ message_list_path: Path
27
33
  ignore_employers: Path | None
28
34
  force_message: bool
29
35
  use_ai: bool
@@ -62,10 +68,9 @@ class Namespace(BaseNamespace):
62
68
 
63
69
 
64
70
  class Operation(BaseOperation):
65
- """Откликнуться на все подходящие вакансии.
71
+ """Откликнуться на все подходящие вакансии."""
66
72
 
67
- Описание фильтров для поиска вакансий: <https://api.hh.ru/openapi/redoc#tag/Poisk-vakansij-dlya-soiskatelya/operation/get-vacancies-similar-to-resume>
68
- """
73
+ __aliases__ = ("apply",)
69
74
 
70
75
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
71
76
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
@@ -76,9 +81,10 @@ class Operation(BaseOperation):
76
81
  )
77
82
  parser.add_argument(
78
83
  "-L",
84
+ "--message-list-path",
79
85
  "--message-list",
80
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
81
- type=argparse.FileType("r", encoding="utf-8", errors="replace"),
86
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. Символы \\n будут заменены на переносы.", # noqa: E501
87
+ type=Path,
82
88
  )
83
89
  parser.add_argument(
84
90
  "-f",
@@ -243,7 +249,7 @@ class Operation(BaseOperation):
243
249
  self.api_client = tool.api_client
244
250
  args: Namespace = tool.args
245
251
  self.application_messages = self._get_application_messages(
246
- args.message_list
252
+ args.message_list_path
247
253
  )
248
254
  self.area = args.area
249
255
  self.bottom_lat = args.bottom_lat
@@ -268,7 +274,7 @@ class Operation(BaseOperation):
268
274
  self.pre_prompt = args.prompt
269
275
  self.premium = args.premium
270
276
  self.professional_role = args.professional_role
271
- self.resume_id = args.resume_id or tool.first_resume_id()
277
+ self.resume_id = args.resume_id
272
278
  self.right_lng = args.right_lng
273
279
  self.salary = args.salary
274
280
  self.schedule = args.schedule
@@ -283,52 +289,96 @@ class Operation(BaseOperation):
283
289
  )
284
290
  self._apply_similar()
285
291
 
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
292
  def _apply_similar(self) -> None:
293
+ resumes: list[datatypes.Resume] = self.tool.get_resumes()
294
+ try:
295
+ self.tool.storage.resumes.save_batch(resumes)
296
+ except RepositoryError as ex:
297
+ logger.exception(ex)
298
+ resumes = (
299
+ list(filter(lambda x: x["id"] == self.resume_id, resumes))
300
+ if self.resume_id
301
+ else resumes
302
+ )
303
+ # Выбираем только опубликованные
304
+ resumes = list(
305
+ filter(lambda x: x["status"]["id"] == "published", resumes)
306
+ )
307
+ if not resumes:
308
+ logger.warning("У вас нет опубликованных резюме")
309
+ return
310
+
299
311
  me: datatypes.User = self.tool.get_me()
312
+ seen_employers = set()
313
+
314
+ for resume in resumes:
315
+ self._apply_resume(
316
+ resume=resume,
317
+ user=me,
318
+ seen_employers=seen_employers,
319
+ )
320
+
321
+ # Синхронизация откликов
322
+ # for neg in self.tool.get_negotiations():
323
+ # try:
324
+ # self.tool.storage.negotiations.save(neg)
325
+ # except RepositoryError as e:
326
+ # logger.warning(e)
327
+
328
+ print("📝 Отклики на вакансии разосланы!")
300
329
 
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", ""),
330
+ def _apply_resume(
331
+ self,
332
+ resume: datatypes.Resume,
333
+ user: datatypes.User,
334
+ seen_employers: set[str],
335
+ ) -> None:
336
+ logger.info("Начинаю рассылку откликов для резюме: %s", resume["title"])
337
+
338
+ placeholders = {
339
+ "first_name": user.get("first_name") or "",
340
+ "last_name": user.get("last_name") or "",
341
+ "email": user.get("email") or "",
342
+ "phone": user.get("phone") or "",
343
+ "resume_title": resume.get("title") or "",
306
344
  }
307
345
 
308
- seen_employers = set()
309
- for vacancy in self._get_vacancies():
346
+ for vacancy in self._get_similar_vacancies(resume_id=resume["id"]):
310
347
  try:
311
348
  employer = vacancy.get("employer", {})
312
349
 
313
- placeholders = {
350
+ message_placeholders = {
314
351
  "vacancy_name": vacancy.get("name", ""),
315
352
  "employer_name": employer.get("name", ""),
316
- **basic_placeholders,
353
+ **placeholders,
317
354
  }
318
355
 
319
356
  storage = self.tool.storage
320
- storage.vacancies.save(vacancy)
357
+
358
+ try:
359
+ storage.vacancies.save(vacancy)
360
+ except RepositoryError as ex:
361
+ logger.debug(ex)
362
+
321
363
  if employer := vacancy.get("employer"):
322
364
  employer_id = employer.get("id")
323
365
  if employer_id and employer_id not in seen_employers:
324
366
  employer_profile: datatypes.Employer = (
325
367
  self.api_client.get(f"/employers/{employer_id}")
326
368
  )
327
- storage.employers.save(employer_profile)
369
+
370
+ try:
371
+ storage.employers.save(employer_profile)
372
+ except RepositoryError as ex:
373
+ logger.exception(ex)
328
374
 
329
375
  # По факту контакты можно получить только здесь?!
330
376
  if vacancy.get("contacts"):
331
- storage.employer_contacts.save(vacancy)
377
+ try:
378
+ # logger.debug(vacancy)
379
+ storage.vacancy_contacts.save(vacancy)
380
+ except RecursionError as ex:
381
+ logger.exception(ex)
332
382
 
333
383
  if vacancy.get("has_test"):
334
384
  logger.debug(
@@ -363,13 +413,20 @@ class Operation(BaseOperation):
363
413
  )
364
414
  if "got_rejection" in relations:
365
415
  logger.debug(
366
- "Вы получили отказ: %s", vacancy["alternate_url"]
416
+ "Вы получили отказ от %s на резюме %s",
417
+ vacancy["alternate_url"],
418
+ resume["alternate_url"],
419
+ )
420
+ print(
421
+ "⛔ Пришел отказ от",
422
+ vacancy["alternate_url"],
423
+ "на резюме",
424
+ resume["alternate_url"],
367
425
  )
368
- print("⛔ Пришел отказ", vacancy["alternate_url"])
369
426
  continue
370
427
 
371
428
  params = {
372
- "resume_id": self.resume_id,
429
+ "resume_id": resume["id"],
373
430
  "vacancy_id": vacancy_id,
374
431
  "message": "",
375
432
  }
@@ -379,13 +436,19 @@ class Operation(BaseOperation):
379
436
  ):
380
437
  if self.openai_chat:
381
438
  msg = self.pre_prompt + "\n\n"
382
- msg += placeholders["vacancy_name"]
439
+ msg += (
440
+ "Название вакансии: "
441
+ + message_placeholders["vacancy_name"]
442
+ )
443
+ msg += (
444
+ "Мое резюме:" + message_placeholders["resume_title"]
445
+ )
383
446
  logger.debug("prompt: %s", msg)
384
447
  msg = self.openai_chat.send_message(msg)
385
448
  else:
386
- msg = (
449
+ msg = unescape_string(
387
450
  rand_text(random.choice(self.application_messages))
388
- % placeholders
451
+ % message_placeholders
389
452
  )
390
453
 
391
454
  logger.debug(msg)
@@ -400,12 +463,18 @@ class Operation(BaseOperation):
400
463
  )
401
464
  assert res == {}
402
465
  logger.debug(
403
- "Отправили отклик: %s", vacancy["alternate_url"]
466
+ "Откликнулись на %s с резюме %s",
467
+ vacancy["alternate_url"],
468
+ resume["alternate_url"],
404
469
  )
405
470
  print(
406
- "📨 Отправили отклик:",
471
+ "📨 Отправили отклик для резюме",
472
+ resume["alternate_url"],
473
+ "на вакансию",
407
474
  vacancy["alternate_url"],
475
+ "(",
408
476
  shorten(vacancy["name"]),
477
+ ")",
409
478
  )
410
479
  except Redirect:
411
480
  logger.warning(
@@ -414,22 +483,19 @@ class Operation(BaseOperation):
414
483
  except LimitExceeded:
415
484
  logger.info("Достигли лимита на отклики")
416
485
  print("⚠️ Достигли лимита рассылки")
417
- # self.tool.storage.settings.set_value("_")
418
486
  break
419
487
  except ApiError as ex:
420
488
  logger.warning(ex)
421
489
  except (BadResponse, AIError) as ex:
422
490
  logger.error(ex)
423
491
 
424
- print("📝 Отклики на вакансии разосланы!")
425
-
426
492
  def _get_search_params(self, page: int) -> dict:
427
493
  params = {
428
494
  "page": page,
429
495
  "per_page": self.per_page,
430
- "order_by": self.order_by,
431
496
  }
432
-
497
+ if self.order_by:
498
+ params |= {"order_by": self.order_by}
433
499
  if self.search:
434
500
  params["text"] = self.search
435
501
  if self.schedule:
@@ -489,11 +555,11 @@ class Operation(BaseOperation):
489
555
 
490
556
  return params
491
557
 
492
- def _get_vacancies(self) -> Iterator[SearchVacancy]:
558
+ def _get_similar_vacancies(self, resume_id: str) -> Iterator[SearchVacancy]:
493
559
  for page in range(self.total_pages):
494
560
  params = self._get_search_params(page)
495
561
  res: PaginatedItems[SearchVacancy] = self.api_client.get(
496
- f"/resumes/{self.resume_id}/similar_vacancies",
562
+ f"/resumes/{resume_id}/similar_vacancies",
497
563
  params,
498
564
  )
499
565
  if not res["items"]:
@@ -503,3 +569,21 @@ class Operation(BaseOperation):
503
569
 
504
570
  if page >= res["pages"] - 1:
505
571
  return
572
+
573
+ def _get_application_messages(self, path: Path | None) -> list[str]:
574
+ return (
575
+ list(
576
+ filter(
577
+ None,
578
+ map(
579
+ str.strip,
580
+ path.open(encoding="utf-8", errors="replace"),
581
+ ),
582
+ )
583
+ )
584
+ if path
585
+ else [
586
+ "Здравствуйте, меня зовут %(first_name)s. {Меня заинтересовала|Мне понравилась} ваша вакансия «%(vacancy_name)s». Хотелось бы {пообщаться|задать вопросы} о ней.",
587
+ "{Прошу|Предлагаю} рассмотреть {мою кандидатуру|мое резюме «%(resume_title)s»} на вакансию «%(vacancy_name)s». С уважением, %(first_name)s.", # noqa: E501
588
+ ]
589
+ )
@@ -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, print_sixel_mage
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,37 @@ 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
+ )
96
+ parser.add_argument(
97
+ "-s",
98
+ "--use-sixel",
99
+ "--sixel",
100
+ action="store_true",
101
+ help="Использовать sixel protocol для вывода капчи в терминал.",
102
+ )
84
103
 
85
104
  def run(self, tool: HHApplicantTool) -> None:
86
105
  self._args = tool.args
87
106
  try:
88
107
  asyncio.run(self._main(tool))
89
108
  except (KeyboardInterrupt, asyncio.TimeoutError):
109
+ # _executor.shutdown(wait=False, cancel_futures=True)
90
110
  logger.warning("Что-то пошло не так")
91
111
  # os._exit(1)
92
112
  return 1
@@ -139,7 +159,7 @@ class Operation(BaseOperation):
139
159
 
140
160
  # # Блокировка сканирования локальных портов
141
161
  # if any(d in url for d in ["localhost", "127.0.0.1", "::1"]):
142
- # logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
162
+ # logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
143
163
  # return await route.abort()
144
164
 
145
165
  # # Оптимизация трафика в headless
@@ -179,22 +199,24 @@ class Operation(BaseOperation):
179
199
  )
180
200
 
181
201
  if self.is_automated:
182
- # Шаг 1: Логин
183
- logger.debug(f"Ожидание поля логина {self.SEL_LOGIN_INPUT}")
202
+ logger.debug(
203
+ f"Ожидание поля логина {self.SELECT_LOGIN_INPUT}"
204
+ )
184
205
  await page.wait_for_selector(
185
- self.SEL_LOGIN_INPUT, timeout=self.selector_timeout
206
+ self.SELECT_LOGIN_INPUT, timeout=self.selector_timeout
186
207
  )
187
- await page.fill(self.SEL_LOGIN_INPUT, username)
208
+ await page.fill(self.SELECT_LOGIN_INPUT, username)
188
209
  logger.debug("Логин введен")
189
210
 
190
- # Шаг 2: Выбор метода входа
191
- if args.password:
192
- await self._direct_login(page, args.password)
211
+ password = args.password or storage.settings.get_value(
212
+ "auth.password"
213
+ )
214
+ if password:
215
+ await self._direct_login(page, password)
193
216
  else:
194
217
  await self._onetime_code_login(page)
195
218
 
196
- # Шаг 3: Ожидание OAuth кода
197
- logger.debug("Ожидание появления OAuth кода в трафике...")
219
+ logger.debug("Ожидание OAuth-кода...")
198
220
 
199
221
  auth_code = await asyncio.wait_for(
200
222
  code_future, timeout=[None, 30.0][self.is_automated]
@@ -237,37 +259,84 @@ class Operation(BaseOperation):
237
259
 
238
260
  async def _direct_login(self, page, password: str) -> None:
239
261
  logger.info("Вход по паролю...")
262
+
240
263
  logger.debug(
241
- f"Клик по кнопке развертывания пароля: {self.SEL_EXPAND_PASSWORD_BTN}"
264
+ f"Клик по кнопке развертывания пароля: {self.SELECT_EXPAND_PASSWORD}"
242
265
  )
243
- await page.click(self.SEL_EXPAND_PASSWORD_BTN)
266
+ await page.click(self.SELECT_EXPAND_PASSWORD)
267
+
268
+ await self._handle_captcha(page)
244
269
 
245
- logger.debug(f"Ожидание поля пароля: {self.SEL_PASSWORD_INPUT}")
270
+ logger.debug(f"Ожидание поля пароля: {self.SELECT_PASSWORD_INPUT}")
246
271
  await page.wait_for_selector(
247
- self.SEL_PASSWORD_INPUT, timeout=self.selector_timeout
272
+ self.SELECT_PASSWORD_INPUT, timeout=self.selector_timeout
248
273
  )
249
- await page.fill(self.SEL_PASSWORD_INPUT, password)
250
- await page.press(self.SEL_PASSWORD_INPUT, "Enter")
274
+ await page.fill(self.SELECT_PASSWORD_INPUT, password)
275
+ await page.press(self.SELECT_PASSWORD_INPUT, "Enter")
251
276
  logger.debug("Форма с паролем отправлена")
252
277
 
253
278
  async def _onetime_code_login(self, page) -> None:
254
279
  logger.info("Вход по одноразовому коду...")
255
- await page.press(self.SEL_LOGIN_INPUT, "Enter")
280
+
281
+ await page.press(self.SELECT_LOGIN_INPUT, "Enter")
282
+
283
+ await self._handle_captcha(page)
256
284
 
257
285
  logger.debug(
258
- f"Ожидание контейнера ввода кода: {self.SEL_CODE_CONTAINER}"
286
+ f"Ожидание контейнера ввода кода: {self.SELECT_CODE_CONTAINER}"
259
287
  )
288
+
260
289
  await page.wait_for_selector(
261
- self.SEL_CODE_CONTAINER, timeout=self.selector_timeout
290
+ self.SELECT_CODE_CONTAINER, timeout=self.selector_timeout
262
291
  )
263
292
 
264
293
  print("📨 Код был отправлен. Проверьте почту или SMS.")
265
294
  code = (await ainput("📩 Введите полученный код: ")).strip()
266
295
 
267
296
  if not code:
268
- raise RuntimeError("Код подтверждения не может быть пустым")
297
+ raise RuntimeError("Код подтверждения не может быть пустым.")
269
298
 
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")
299
+ logger.debug(f"Ввод кода в {self.SELECT_PIN_CODE_INPUT}")
300
+ await page.fill(self.SELECT_PIN_CODE_INPUT, code)
301
+ await page.press(self.SELECT_PIN_CODE_INPUT, "Enter")
273
302
  logger.debug("Форма с кодом отправлена")
303
+
304
+ async def _handle_captcha(self, page):
305
+ try:
306
+ captcha_element = await page.wait_for_selector(
307
+ self.SELECT_CAPTCHA_IMAGE,
308
+ timeout=self.selector_timeout,
309
+ state="visible",
310
+ )
311
+ except Exception:
312
+ logger.debug("Капчи нет, продолжаем как обычно.")
313
+ return
314
+
315
+ if not (self._args.use_kitty or self._args.use_sixel):
316
+ raise RuntimeError(
317
+ "Требуется ввод капчи!",
318
+ )
319
+
320
+ # box = await captcha_element.bounding_box()
321
+
322
+ # width = int(box["width"])
323
+ # height = int(box["height"])
324
+
325
+ img_bytes = await captcha_element.screenshot()
326
+
327
+ print(
328
+ "Если вы не видите картинку ниже, то ваш терминал не поддерживает"
329
+ " вывод изображений."
330
+ )
331
+ print()
332
+
333
+ if self._args.use_kitty:
334
+ print_kitty_image(img_bytes)
335
+
336
+ if self._args.use_sixel:
337
+ print_sixel_mage(img_bytes)
338
+
339
+ captcha_text = (await ainput("Введите текст с картинки: ")).strip()
340
+
341
+ await page.fill(self.SELECT_CAPTCHA_INPUT, captcha_text)
342
+ 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,32 @@ 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:
55
- storage = self.tool.storage
56
+ def clear(self) -> None:
57
+ blacklisted = set(self.tool.get_blacklisted())
56
58
  for negotiation in self.tool.get_negotiations():
57
59
  vacancy = negotiation["vacancy"]
58
- employer = vacancy.get("employer", {})
59
- employer_id = employer.get("id")
60
60
 
61
61
  # Если работодателя блокируют, то он превращается в null
62
62
  # ХХ позволяет скрывать компанию, когда id нет, а вместо имени "Крупная российская компания"
63
63
  # 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":
64
+ # try:
65
+ # storage.negotiations.save(negotiation)
66
+ # except RepositoryError as e:
67
+ # logger.exception(e)
68
+
69
+ if self.args.older_than:
70
+ updated_at = parse_api_datetime(negotiation["updated_at"])
71
+ # А хз какую временную зону сайт возвращает
72
+ days_passed = (
73
+ dt.datetime.now(updated_at.tzinfo) - updated_at
74
+ ).days
75
+ logger.debug(f"{days_passed = }")
76
+ if days_passed <= self.args.older_than:
77
+ continue
78
+ elif negotiation["state"]["id"] != "discard":
71
79
  continue
72
80
  try:
73
81
  if not self.args.dry_run:
@@ -78,18 +86,24 @@ class Operation(BaseOperation):
78
86
 
79
87
  print(
80
88
  "🗑️ Отменили отклик на вакансию:",
81
- vacancy["name"],
82
89
  vacancy["alternate_url"],
90
+ vacancy["name"],
83
91
  )
84
92
 
93
+ employer = vacancy.get("employer", {})
94
+ employer_id = employer.get("id")
95
+
85
96
  if (
86
- employer_id
87
- and employer_id not in self.args.blacklist_discard
97
+ self.args.blacklist_discard
98
+ and employer
99
+ and employer_id
100
+ and employer_id not in blacklisted
88
101
  ):
89
102
  if not self.args.dry_run:
90
103
  self.tool.api_client.put(
91
104
  f"/employers/blacklisted/{employer_id}"
92
105
  )
106
+ blacklisted.add(employer_id)
93
107
 
94
108
  print(
95
109
  "🚫 Работодатель заблокирован:",
@@ -99,4 +113,4 @@ class Operation(BaseOperation):
99
113
  except ApiError as err:
100
114
  logger.error(err)
101
115
 
102
- print("✅ Синхронизация завершена.")
116
+ print("✅ Удаление откликов завершено.")