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.
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/openai.py +2 -2
- hh_applicant_tool/api/__init__.py +4 -2
- hh_applicant_tool/api/client.py +23 -12
- 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 +12 -13
- hh_applicant_tool/operations/apply_similar.py +125 -47
- hh_applicant_tool/operations/authorize.py +82 -25
- hh_applicant_tool/operations/call_api.py +3 -3
- hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -25
- hh_applicant_tool/operations/list_resumes.py +5 -7
- hh_applicant_tool/operations/query.py +3 -1
- 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 +1 -1
- hh_applicant_tool/storage/__init__.py +5 -1
- hh_applicant_tool/storage/facade.py +2 -2
- hh_applicant_tool/storage/models/base.py +4 -4
- hh_applicant_tool/storage/models/contacts.py +28 -0
- hh_applicant_tool/storage/queries/schema.sql +22 -9
- 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 +6 -15
- hh_applicant_tool/utils/__init__.py +3 -3
- hh_applicant_tool/utils/config.py +1 -1
- hh_applicant_tool/utils/log.py +4 -1
- hh_applicant_tool/utils/mixins.py +20 -19
- hh_applicant_tool/utils/terminal.py +13 -0
- {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.4.12.dist-info}/METADATA +197 -140
- hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
- 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.4.12.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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,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
|
-
|
|
183
|
-
|
|
195
|
+
logger.debug(
|
|
196
|
+
f"Ожидание поля логина {self.SELECT_LOGIN_INPUT}"
|
|
197
|
+
)
|
|
184
198
|
await page.wait_for_selector(
|
|
185
|
-
self.
|
|
199
|
+
self.SELECT_LOGIN_INPUT, timeout=self.selector_timeout
|
|
186
200
|
)
|
|
187
|
-
await page.fill(self.
|
|
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
|
-
|
|
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.
|
|
254
|
+
f"Клик по кнопке развертывания пароля: {self.SELECT_EXPAND_PASSWORD}"
|
|
242
255
|
)
|
|
243
|
-
await page.click(self.
|
|
256
|
+
await page.click(self.SELECT_EXPAND_PASSWORD)
|
|
257
|
+
|
|
258
|
+
await self._handle_captcha(page)
|
|
244
259
|
|
|
245
|
-
logger.debug(f"Ожидание поля пароля: {self.
|
|
260
|
+
logger.debug(f"Ожидание поля пароля: {self.SELECT_PASSWORD_INPUT}")
|
|
246
261
|
await page.wait_for_selector(
|
|
247
|
-
self.
|
|
262
|
+
self.SELECT_PASSWORD_INPUT, timeout=self.selector_timeout
|
|
248
263
|
)
|
|
249
|
-
await page.fill(self.
|
|
250
|
-
await page.press(self.
|
|
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
|
-
|
|
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.
|
|
276
|
+
f"Ожидание контейнера ввода кода: {self.SELECT_CODE_CONTAINER}"
|
|
259
277
|
)
|
|
278
|
+
|
|
260
279
|
await page.wait_for_selector(
|
|
261
|
-
self.
|
|
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.
|
|
271
|
-
await page.fill(self.
|
|
272
|
-
await page.press(self.
|
|
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,
|
|
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,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.
|
|
53
|
+
self.args: Namespace = tool.args
|
|
54
|
+
self.clear()
|
|
53
55
|
|
|
54
|
-
def
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
87
|
-
and
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
46
|
+
__aliases__ = ["reply-empls", "reply-chats", "reall"]
|
|
46
47
|
|
|
47
48
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
48
|
-
parser.add_argument(
|
|
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.
|
|
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.
|
|
112
|
-
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
154
|
+
for negotiation in self.tool.get_negotiations():
|
|
126
155
|
try:
|
|
127
|
-
|
|
156
|
+
# try:
|
|
157
|
+
# self.tool.storage.negotiations.save(negotiation)
|
|
158
|
+
# except RepositoryError as e:
|
|
159
|
+
# logger.exception(e)
|
|
128
160
|
|
|
129
|
-
if
|
|
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
|
|
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
|
-
|
|
193
|
+
placeholders = {
|
|
152
194
|
"vacancy_name": vacancy.get("name", ""),
|
|
153
195
|
"employer_name": employer.get("name", ""),
|
|
154
|
-
|
|
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
|
-
%
|
|
202
|
+
% placeholders
|
|
160
203
|
)
|
|
161
204
|
|
|
162
205
|
page: int = 0
|
|
163
|
-
last_message:
|
|
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
|
-
).
|
|
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) %
|
|
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"Вакансия: {
|
|
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
|
-
|
|
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("-" *
|
|
294
|
+
print("-" * 40)
|
|
295
|
+
print("Активное резюме:", resume.get("title") or "")
|
|
255
296
|
print(
|
|
256
|
-
"
|
|
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
|
-
|
|
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
|
|
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:
|
|
35
|
-
|
|
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",
|