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.
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/__init__.py +1 -0
- hh_applicant_tool/ai/openai.py +30 -14
- hh_applicant_tool/api/__init__.py +4 -2
- hh_applicant_tool/api/client.py +32 -17
- hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
- hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
- hh_applicant_tool/api/errors.py +8 -2
- hh_applicant_tool/{utils → api}/user_agent.py +1 -1
- hh_applicant_tool/main.py +63 -38
- hh_applicant_tool/operations/apply_similar.py +136 -52
- hh_applicant_tool/operations/authorize.py +97 -28
- hh_applicant_tool/operations/call_api.py +3 -3
- hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -26
- hh_applicant_tool/operations/list_resumes.py +5 -7
- hh_applicant_tool/operations/query.py +5 -3
- hh_applicant_tool/operations/refresh_token.py +9 -2
- hh_applicant_tool/operations/reply_employers.py +80 -40
- hh_applicant_tool/operations/settings.py +2 -2
- hh_applicant_tool/operations/update_resumes.py +5 -4
- hh_applicant_tool/operations/whoami.py +3 -3
- hh_applicant_tool/storage/__init__.py +5 -1
- hh_applicant_tool/storage/facade.py +2 -2
- hh_applicant_tool/storage/models/base.py +9 -4
- hh_applicant_tool/storage/models/contacts.py +42 -0
- hh_applicant_tool/storage/queries/schema.sql +23 -10
- hh_applicant_tool/storage/repositories/base.py +69 -15
- hh_applicant_tool/storage/repositories/contacts.py +5 -10
- hh_applicant_tool/storage/repositories/employers.py +1 -0
- hh_applicant_tool/storage/repositories/errors.py +19 -0
- hh_applicant_tool/storage/repositories/negotiations.py +1 -0
- hh_applicant_tool/storage/repositories/resumes.py +2 -7
- hh_applicant_tool/storage/repositories/settings.py +1 -0
- hh_applicant_tool/storage/repositories/vacancies.py +1 -0
- hh_applicant_tool/storage/utils.py +12 -15
- hh_applicant_tool/utils/__init__.py +3 -3
- hh_applicant_tool/utils/config.py +1 -1
- hh_applicant_tool/utils/log.py +6 -3
- hh_applicant_tool/utils/mixins.py +28 -46
- hh_applicant_tool/utils/string.py +15 -0
- hh_applicant_tool/utils/terminal.py +115 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/METADATA +384 -162
- hh_applicant_tool-1.5.7.dist-info/RECORD +68 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/WHEEL +1 -1
- hh_applicant_tool/storage/models/contact.py +0 -16
- hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
- /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
- /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
- {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
|
|
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 ..
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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.
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
+
message_placeholders = {
|
|
314
351
|
"vacancy_name": vacancy.get("name", ""),
|
|
315
352
|
"employer_name": employer.get("name", ""),
|
|
316
|
-
**
|
|
353
|
+
**placeholders,
|
|
317
354
|
}
|
|
318
355
|
|
|
319
356
|
storage = self.tool.storage
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"Вы получили
|
|
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":
|
|
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 +=
|
|
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
|
-
%
|
|
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
|
-
"
|
|
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
|
|
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/{
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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"🛑
|
|
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
|
-
|
|
183
|
-
|
|
202
|
+
logger.debug(
|
|
203
|
+
f"Ожидание поля логина {self.SELECT_LOGIN_INPUT}"
|
|
204
|
+
)
|
|
184
205
|
await page.wait_for_selector(
|
|
185
|
-
self.
|
|
206
|
+
self.SELECT_LOGIN_INPUT, timeout=self.selector_timeout
|
|
186
207
|
)
|
|
187
|
-
await page.fill(self.
|
|
208
|
+
await page.fill(self.SELECT_LOGIN_INPUT, username)
|
|
188
209
|
logger.debug("Логин введен")
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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.
|
|
264
|
+
f"Клик по кнопке развертывания пароля: {self.SELECT_EXPAND_PASSWORD}"
|
|
242
265
|
)
|
|
243
|
-
await page.click(self.
|
|
266
|
+
await page.click(self.SELECT_EXPAND_PASSWORD)
|
|
267
|
+
|
|
268
|
+
await self._handle_captcha(page)
|
|
244
269
|
|
|
245
|
-
logger.debug(f"Ожидание поля пароля: {self.
|
|
270
|
+
logger.debug(f"Ожидание поля пароля: {self.SELECT_PASSWORD_INPUT}")
|
|
246
271
|
await page.wait_for_selector(
|
|
247
|
-
self.
|
|
272
|
+
self.SELECT_PASSWORD_INPUT, timeout=self.selector_timeout
|
|
248
273
|
)
|
|
249
|
-
await page.fill(self.
|
|
250
|
-
await page.press(self.
|
|
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
|
-
|
|
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.
|
|
286
|
+
f"Ожидание контейнера ввода кода: {self.SELECT_CODE_CONTAINER}"
|
|
259
287
|
)
|
|
288
|
+
|
|
260
289
|
await page.wait_for_selector(
|
|
261
|
-
self.
|
|
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.
|
|
271
|
-
await page.fill(self.
|
|
272
|
-
await page.press(self.
|
|
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,
|
|
43
|
-
args =
|
|
44
|
-
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__ = ["
|
|
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.
|
|
53
|
+
self.args: Namespace = tool.args
|
|
54
|
+
self.clear()
|
|
53
55
|
|
|
54
|
-
def
|
|
55
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
87
|
-
and
|
|
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("✅ Удаление откликов завершено.")
|