hh-applicant-tool 0.4.0__py3-none-any.whl → 0.4.1__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.
Potentially problematic release.
This version of hh-applicant-tool might be problematic. Click here for more details.
- hh_applicant_tool/main.py +4 -5
- hh_applicant_tool/mixins.py +13 -0
- hh_applicant_tool/operations/apply_similar.py +94 -241
- hh_applicant_tool/operations/reply_employers.py +154 -0
- hh_applicant_tool/utils.py +8 -0
- {hh_applicant_tool-0.4.0.dist-info → hh_applicant_tool-0.4.1.dist-info}/METADATA +11 -6
- {hh_applicant_tool-0.4.0.dist-info → hh_applicant_tool-0.4.1.dist-info}/RECORD +9 -7
- {hh_applicant_tool-0.4.0.dist-info → hh_applicant_tool-0.4.1.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.4.0.dist-info → hh_applicant_tool-0.4.1.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/main.py
CHANGED
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
|
-
from abc import ABCMeta, abstractmethod
|
|
7
6
|
from importlib import import_module
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from pkgutil import iter_modules
|
|
@@ -14,17 +13,17 @@ from .utils import Config, get_config_path
|
|
|
14
13
|
from os import getenv
|
|
15
14
|
|
|
16
15
|
DEFAULT_CONFIG_PATH = (
|
|
17
|
-
get_config_path() / __package__.replace("_", "-") / "config.json"
|
|
16
|
+
get_config_path() / (__package__ or '').replace("_", "-") / "config.json"
|
|
18
17
|
)
|
|
19
18
|
|
|
20
19
|
logger = logging.getLogger(__package__)
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
class BaseOperation
|
|
22
|
+
class BaseOperation:
|
|
24
23
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
def run(self, args: argparse.Namespace) -> None | int:
|
|
26
|
+
raise NotImplementedError()
|
|
28
27
|
|
|
29
28
|
|
|
30
29
|
OPERATIONS = "operations"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .api import ApiError
|
|
2
|
+
from .types import ApiListResponse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GetResumeIdMixin:
|
|
6
|
+
def _get_resume_id(self) -> str:
|
|
7
|
+
try:
|
|
8
|
+
resumes: ApiListResponse = self.api.get("/resumes/mine")
|
|
9
|
+
return resumes["items"][0]["id"]
|
|
10
|
+
except (ApiError, KeyError, IndexError) as ex:
|
|
11
|
+
raise Exception("Не могу получить идентификатор резюме") from ex
|
|
12
|
+
|
|
13
|
+
|
|
@@ -3,16 +3,15 @@ import logging
|
|
|
3
3
|
import random
|
|
4
4
|
import time
|
|
5
5
|
from collections import defaultdict
|
|
6
|
-
from os import getenv
|
|
7
6
|
from typing import TextIO, Tuple
|
|
8
7
|
|
|
9
|
-
from ..api import
|
|
8
|
+
from ..api import ApiError, BadRequest
|
|
10
9
|
from ..main import BaseOperation
|
|
11
10
|
from ..main import Namespace as BaseNamespace, get_api
|
|
12
11
|
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
13
12
|
from ..types import ApiListResponse, VacancyItem
|
|
14
|
-
from ..utils import fix_datetime, truncate_string, random_text
|
|
15
|
-
from
|
|
13
|
+
from ..utils import fix_datetime, truncate_string, random_text, parse_interval
|
|
14
|
+
from ..mixins import GetResumeIdMixin
|
|
16
15
|
|
|
17
16
|
logger = logging.getLogger(__package__)
|
|
18
17
|
|
|
@@ -23,22 +22,19 @@ class Namespace(BaseNamespace):
|
|
|
23
22
|
force_message: bool
|
|
24
23
|
apply_interval: Tuple[float, float]
|
|
25
24
|
page_interval: Tuple[float, float]
|
|
26
|
-
message_interval: Tuple[float, float]
|
|
27
25
|
order_by: str
|
|
28
26
|
search: str
|
|
29
|
-
|
|
27
|
+
dry_run: bool
|
|
30
28
|
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class Operation(BaseOperation):
|
|
35
|
-
"""Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
|
|
30
|
+
class Operation(BaseOperation, GetResumeIdMixin):
|
|
31
|
+
"""Откликнуться на все подходящие вакансии."""
|
|
36
32
|
|
|
37
33
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
38
34
|
parser.add_argument("--resume-id", help="Идентефикатор резюме")
|
|
39
35
|
parser.add_argument(
|
|
40
36
|
"--message-list",
|
|
41
|
-
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.
|
|
37
|
+
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
|
|
42
38
|
type=argparse.FileType(),
|
|
43
39
|
)
|
|
44
40
|
parser.add_argument(
|
|
@@ -51,19 +47,13 @@ class Operation(BaseOperation):
|
|
|
51
47
|
"--apply-interval",
|
|
52
48
|
help="Интервал перед отправкой откликов в секундах (X, X-Y)",
|
|
53
49
|
default="1-5",
|
|
54
|
-
type=
|
|
50
|
+
type=parse_interval,
|
|
55
51
|
)
|
|
56
52
|
parser.add_argument(
|
|
57
53
|
"--page-interval",
|
|
58
54
|
help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
|
|
59
55
|
default="1-3",
|
|
60
|
-
type=
|
|
61
|
-
)
|
|
62
|
-
parser.add_argument(
|
|
63
|
-
"--message-interval",
|
|
64
|
-
help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
|
|
65
|
-
default="5-10",
|
|
66
|
-
type=self._parse_interval,
|
|
56
|
+
type=parse_interval,
|
|
67
57
|
)
|
|
68
58
|
parser.add_argument(
|
|
69
59
|
"--order-by",
|
|
@@ -79,25 +69,17 @@ class Operation(BaseOperation):
|
|
|
79
69
|
)
|
|
80
70
|
parser.add_argument(
|
|
81
71
|
"--search",
|
|
82
|
-
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'
|
|
72
|
+
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
|
|
83
73
|
type=str,
|
|
84
74
|
default=None,
|
|
85
75
|
)
|
|
86
76
|
parser.add_argument(
|
|
87
|
-
"--
|
|
88
|
-
"
|
|
89
|
-
|
|
77
|
+
"--dry-run",
|
|
78
|
+
help="Не отправлять отклики, а только выводить параметры запроса",
|
|
79
|
+
default=False,
|
|
80
|
+
action=argparse.BooleanOptionalAction,
|
|
90
81
|
)
|
|
91
82
|
|
|
92
|
-
@staticmethod
|
|
93
|
-
def _parse_interval(interval: str) -> Tuple[float, float]:
|
|
94
|
-
"""Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
|
|
95
|
-
if "-" in interval:
|
|
96
|
-
min_interval, max_interval = map(float, interval.split("-"))
|
|
97
|
-
else:
|
|
98
|
-
min_interval = max_interval = float(interval)
|
|
99
|
-
return min(min_interval, max_interval), max(min_interval, max_interval)
|
|
100
|
-
|
|
101
83
|
def run(self, args: Namespace) -> None:
|
|
102
84
|
self.enable_telemetry = True
|
|
103
85
|
if args.disable_telemetry:
|
|
@@ -110,45 +92,27 @@ class Operation(BaseOperation):
|
|
|
110
92
|
.startswith(("д", "y"))
|
|
111
93
|
):
|
|
112
94
|
self.enable_telemetry = False
|
|
113
|
-
logger.info("Телеметрия
|
|
95
|
+
logger.info("Телеметрия отключена.")
|
|
114
96
|
else:
|
|
115
|
-
logger.info("
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
self.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
page_max_interval,
|
|
133
|
-
message_min_interval,
|
|
134
|
-
message_max_interval,
|
|
135
|
-
args.order_by,
|
|
136
|
-
args.search,
|
|
137
|
-
args.reply_message or args.config["reply_message"],
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
|
|
141
|
-
if not (
|
|
142
|
-
resume_id := args.resume_id or args.config["default_resume_id"]
|
|
143
|
-
):
|
|
144
|
-
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
145
|
-
resume_id = resumes["items"][0]["id"]
|
|
146
|
-
return resume_id
|
|
147
|
-
|
|
148
|
-
def _get_application_messages(self, args: Namespace) -> list[str]:
|
|
149
|
-
if args.message_list:
|
|
97
|
+
logger.info("Спасибо за то что оставили телеметрию включенной!")
|
|
98
|
+
|
|
99
|
+
self.api = get_api(args)
|
|
100
|
+
self.resume_id = args.resume_id or self._get_resume_id()
|
|
101
|
+
self.application_messages = self._get_application_messages(args.message_list)
|
|
102
|
+
|
|
103
|
+
self.apply_min_interval, self.apply_max_interval = args.apply_interval
|
|
104
|
+
self.page_min_interval, self.page_max_interval = args.page_interval
|
|
105
|
+
|
|
106
|
+
self.force_message = args.force_message
|
|
107
|
+
self.order_by = args.order_by
|
|
108
|
+
self.search = args.search
|
|
109
|
+
self.dry_run = args.dry_run
|
|
110
|
+
self._apply_similar()
|
|
111
|
+
|
|
112
|
+
def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
|
|
113
|
+
if message_list:
|
|
150
114
|
application_messages = list(
|
|
151
|
-
filter(None, map(str.strip,
|
|
115
|
+
filter(None, map(str.strip, message_list))
|
|
152
116
|
)
|
|
153
117
|
else:
|
|
154
118
|
application_messages = [
|
|
@@ -157,39 +121,39 @@ class Operation(BaseOperation):
|
|
|
157
121
|
]
|
|
158
122
|
return application_messages
|
|
159
123
|
|
|
160
|
-
def _apply_similar(
|
|
161
|
-
self
|
|
162
|
-
api: ApiClient,
|
|
163
|
-
resume_id: str,
|
|
164
|
-
force_message: bool,
|
|
165
|
-
application_messages: list[str],
|
|
166
|
-
apply_min_interval: float,
|
|
167
|
-
apply_max_interval: float,
|
|
168
|
-
page_min_interval: float,
|
|
169
|
-
page_max_interval: float,
|
|
170
|
-
message_min_interval: float,
|
|
171
|
-
message_max_interval: float,
|
|
172
|
-
order_by: str,
|
|
173
|
-
search: str | None,
|
|
174
|
-
reply_message: str | None,
|
|
175
|
-
) -> None:
|
|
176
|
-
telemetry_client = TelemetryClient(proxies=api.proxies)
|
|
124
|
+
def _apply_similar(self) -> None:
|
|
125
|
+
telemetry_client = TelemetryClient(proxies=self.api.proxies)
|
|
177
126
|
telemetry_data = defaultdict(dict)
|
|
178
127
|
|
|
179
|
-
vacancies = self._get_vacancies(
|
|
180
|
-
api,
|
|
181
|
-
resume_id,
|
|
182
|
-
page_min_interval,
|
|
183
|
-
page_max_interval,
|
|
184
|
-
per_page=100,
|
|
185
|
-
order_by=order_by,
|
|
186
|
-
search=search,
|
|
187
|
-
)
|
|
128
|
+
vacancies = self._get_vacancies()
|
|
188
129
|
|
|
189
130
|
if self.enable_telemetry:
|
|
190
|
-
|
|
131
|
+
for vacancy in vacancies:
|
|
132
|
+
vacancy_id = vacancy["id"]
|
|
133
|
+
telemetry_data["vacancies"][vacancy_id] = {
|
|
134
|
+
"name": vacancy.get("name"),
|
|
135
|
+
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
136
|
+
"area": vacancy.get("area", {}).get("name"), # город
|
|
137
|
+
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
138
|
+
"direct_url": vacancy.get(
|
|
139
|
+
"alternate_url"
|
|
140
|
+
), # ссылка на вакансию
|
|
141
|
+
"created_at": fix_datetime(
|
|
142
|
+
vacancy.get("created_at")
|
|
143
|
+
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
144
|
+
"published_at": fix_datetime(vacancy.get("published_at")),
|
|
145
|
+
"contacts": vacancy.get(
|
|
146
|
+
"contacts"
|
|
147
|
+
), # пиздорванки там телеграм для связи указывают
|
|
148
|
+
# HH с точки зрения перфикциониста — кусок говна, где кривые
|
|
149
|
+
# форматы даты, у вакансий может не быть работодателя...
|
|
150
|
+
"employer_id": int(vacancy["employer"]["id"])
|
|
151
|
+
if "employer" in vacancy and "id" in vacancy["employer"]
|
|
152
|
+
else None,
|
|
153
|
+
# Остальное неинтересно
|
|
154
|
+
}
|
|
191
155
|
|
|
192
|
-
me = api.get("/me")
|
|
156
|
+
me = self.api.get("/me")
|
|
193
157
|
|
|
194
158
|
basic_message_placeholders = {
|
|
195
159
|
"first_name": me.get("first_name", ""),
|
|
@@ -198,13 +162,8 @@ class Operation(BaseOperation):
|
|
|
198
162
|
"phone": me.get("phone", ""),
|
|
199
163
|
}
|
|
200
164
|
|
|
201
|
-
do_apply = True
|
|
202
|
-
|
|
203
165
|
for vacancy in vacancies:
|
|
204
166
|
try:
|
|
205
|
-
if getenv("TEST_TELEMETRY"):
|
|
206
|
-
break
|
|
207
|
-
|
|
208
167
|
message_placeholders = {
|
|
209
168
|
"vacancy_name": vacancy.get("name", ""),
|
|
210
169
|
"employer_name": vacancy.get("employer", {}).get(
|
|
@@ -227,74 +186,14 @@ class Operation(BaseOperation):
|
|
|
227
186
|
"🚫 Пропускаем вакансию в архиве",
|
|
228
187
|
vacancy["alternate_url"],
|
|
229
188
|
)
|
|
230
|
-
|
|
231
189
|
continue
|
|
232
190
|
|
|
233
191
|
relations = vacancy.get("relations", [])
|
|
234
192
|
|
|
235
193
|
if relations:
|
|
236
|
-
if "got_rejection" in relations:
|
|
237
|
-
print(
|
|
238
|
-
"🚫 Пропускаем отказ на вакансию",
|
|
239
|
-
vacancy["alternate_url"],
|
|
240
|
-
)
|
|
241
|
-
continue
|
|
242
|
-
|
|
243
|
-
if reply_message:
|
|
244
|
-
r = api.get("/negotiations", vacancy_id=vacancy["id"])
|
|
245
|
-
|
|
246
|
-
if len(r["items"]) == 1:
|
|
247
|
-
neg = r["items"][0]
|
|
248
|
-
nid = neg["id"]
|
|
249
|
-
|
|
250
|
-
page: int = 0
|
|
251
|
-
last_message: dict | None = None
|
|
252
|
-
while True:
|
|
253
|
-
r2 = api.get(
|
|
254
|
-
f"/negotiations/{nid}/messages", page=page
|
|
255
|
-
)
|
|
256
|
-
last_message = r2["items"][-1]
|
|
257
|
-
if page + 1 >= r2["pages"]:
|
|
258
|
-
break
|
|
259
|
-
|
|
260
|
-
page = r2["pages"] - 1
|
|
261
|
-
|
|
262
|
-
logger.debug(last_message["text"])
|
|
263
|
-
|
|
264
|
-
if last_message["author"][
|
|
265
|
-
"participant_type"
|
|
266
|
-
] == "employer" or not neg.get(
|
|
267
|
-
"viewed_by_opponent"
|
|
268
|
-
):
|
|
269
|
-
message = (
|
|
270
|
-
random_text(reply_message)
|
|
271
|
-
% message_placeholders
|
|
272
|
-
)
|
|
273
|
-
logger.debug(message)
|
|
274
|
-
|
|
275
|
-
time.sleep(
|
|
276
|
-
random.uniform(
|
|
277
|
-
message_min_interval,
|
|
278
|
-
message_max_interval,
|
|
279
|
-
)
|
|
280
|
-
)
|
|
281
|
-
api.post(
|
|
282
|
-
f"/negotiations/{nid}/messages",
|
|
283
|
-
message=message,
|
|
284
|
-
)
|
|
285
|
-
print(
|
|
286
|
-
"📨 Отправили сообщение для привлечения внимания",
|
|
287
|
-
vacancy["alternate_url"],
|
|
288
|
-
)
|
|
289
|
-
continue
|
|
290
|
-
else:
|
|
291
|
-
logger.warning(
|
|
292
|
-
"Приглашение без чата для вакансии: %s",
|
|
293
|
-
vacancy["alternate_url"],
|
|
294
|
-
)
|
|
295
|
-
|
|
296
194
|
print(
|
|
297
|
-
"🚫 Пропускаем вакансию с
|
|
195
|
+
"🚫 Пропускаем вакансию с",
|
|
196
|
+
["откликом или приглашением", "отказом"]["got_rejection" in relations],
|
|
298
197
|
vacancy["alternate_url"],
|
|
299
198
|
)
|
|
300
199
|
continue
|
|
@@ -305,9 +204,8 @@ class Operation(BaseOperation):
|
|
|
305
204
|
self.enable_telemetry
|
|
306
205
|
and employer_id
|
|
307
206
|
and employer_id not in telemetry_data["employers"]
|
|
308
|
-
and 200 > len(telemetry_data["employers"])
|
|
309
207
|
):
|
|
310
|
-
employer = api.get(f"/employers/{employer_id}")
|
|
208
|
+
employer = self.api.get(f"/employers/{employer_id}")
|
|
311
209
|
telemetry_data["employers"][employer_id] = {
|
|
312
210
|
"name": employer.get("name"),
|
|
313
211
|
"type": employer.get("type"),
|
|
@@ -316,35 +214,34 @@ class Operation(BaseOperation):
|
|
|
316
214
|
"area": employer.get("area", {}).get("name"), # город
|
|
317
215
|
}
|
|
318
216
|
|
|
319
|
-
if not do_apply:
|
|
320
|
-
logger.debug("skip apply similar")
|
|
321
|
-
continue
|
|
322
|
-
|
|
323
217
|
params = {
|
|
324
|
-
"resume_id": resume_id,
|
|
218
|
+
"resume_id": self.resume_id,
|
|
325
219
|
"vacancy_id": vacancy["id"],
|
|
326
220
|
"message": "",
|
|
327
221
|
}
|
|
328
222
|
|
|
329
|
-
if force_message or vacancy.get("response_letter_required"):
|
|
223
|
+
if self.force_message or vacancy.get("response_letter_required"):
|
|
330
224
|
msg = params["message"] = (
|
|
331
|
-
random_text(random.choice(application_messages))
|
|
225
|
+
random_text(random.choice(self.application_messages))
|
|
332
226
|
% message_placeholders
|
|
333
227
|
)
|
|
334
228
|
logger.debug(msg)
|
|
335
229
|
|
|
230
|
+
if self.dry_run:
|
|
231
|
+
logger.info(
|
|
232
|
+
"Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
|
|
233
|
+
vacancy["alternate_url"],
|
|
234
|
+
params,
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
|
|
336
238
|
# Задержка перед отправкой отклика
|
|
337
239
|
interval = random.uniform(
|
|
338
|
-
|
|
339
|
-
if params["message"]
|
|
340
|
-
else apply_min_interval,
|
|
341
|
-
max(apply_max_interval, message_max_interval)
|
|
342
|
-
if params["message"]
|
|
343
|
-
else apply_max_interval,
|
|
240
|
+
self.apply_min_interval, self.apply_max_interval
|
|
344
241
|
)
|
|
345
242
|
time.sleep(interval)
|
|
346
243
|
|
|
347
|
-
res = api.post("/negotiations", params)
|
|
244
|
+
res = self.api.post("/negotiations", params)
|
|
348
245
|
assert res == {}
|
|
349
246
|
print(
|
|
350
247
|
"📨 Отправили отклик",
|
|
@@ -356,88 +253,44 @@ class Operation(BaseOperation):
|
|
|
356
253
|
except ApiError as ex:
|
|
357
254
|
logger.error(ex)
|
|
358
255
|
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
359
|
-
|
|
360
|
-
break
|
|
361
|
-
do_apply = False
|
|
256
|
+
break
|
|
362
257
|
|
|
363
258
|
print("📝 Отклики на вакансии разосланы!")
|
|
364
259
|
|
|
365
260
|
if self.enable_telemetry:
|
|
366
|
-
|
|
367
|
-
|
|
261
|
+
if self.dry_run:
|
|
262
|
+
# С --dry-run можно посмотреть что отправляется
|
|
263
|
+
logger.info('Dry Run: Данные телеметрии для отправки на сервер: %r', telemetry_data)
|
|
264
|
+
return
|
|
368
265
|
|
|
266
|
+
try:
|
|
267
|
+
telemetry_client.send_telemetry("/collect", dict(telemetry_data))
|
|
268
|
+
except TelemetryError as ex:
|
|
269
|
+
logger.error(ex)
|
|
270
|
+
|
|
369
271
|
def _get_vacancies(
|
|
370
|
-
|
|
371
|
-
api: ApiClient,
|
|
372
|
-
resume_id: str,
|
|
373
|
-
page_min_interval: float,
|
|
374
|
-
page_max_interval: float,
|
|
375
|
-
per_page: int,
|
|
376
|
-
order_by: str,
|
|
377
|
-
search: str | None = None,
|
|
272
|
+
self, per_page: int = 100
|
|
378
273
|
) -> list[VacancyItem]:
|
|
379
274
|
rv = []
|
|
380
275
|
for page in range(20):
|
|
381
276
|
params = {
|
|
382
277
|
"page": page,
|
|
383
278
|
"per_page": per_page,
|
|
384
|
-
"order_by": order_by,
|
|
279
|
+
"order_by": self.order_by,
|
|
385
280
|
}
|
|
386
|
-
if search:
|
|
387
|
-
params["text"] = search
|
|
388
|
-
res: ApiListResponse = api.get(
|
|
389
|
-
f"/resumes/{resume_id}/similar_vacancies", params
|
|
281
|
+
if self.search:
|
|
282
|
+
params["text"] = self.search
|
|
283
|
+
res: ApiListResponse = self.api.get(
|
|
284
|
+
f"/resumes/{self.resume_id}/similar_vacancies", params
|
|
390
285
|
)
|
|
391
286
|
rv.extend(res["items"])
|
|
392
|
-
|
|
393
|
-
if getenv("TEST_TELEMETRY"):
|
|
394
|
-
break
|
|
395
|
-
|
|
396
287
|
if page >= res["pages"] - 1:
|
|
397
288
|
break
|
|
398
289
|
|
|
399
290
|
# Задержка перед получением следующей страницы
|
|
400
291
|
if page > 0:
|
|
401
|
-
interval = random.uniform(page_min_interval, page_max_interval)
|
|
292
|
+
interval = random.uniform(self.page_min_interval, self.page_max_interval)
|
|
402
293
|
time.sleep(interval)
|
|
403
294
|
|
|
404
295
|
return rv
|
|
405
296
|
|
|
406
|
-
def _collect_vacancy_telemetry(
|
|
407
|
-
self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
|
|
408
|
-
) -> None:
|
|
409
|
-
for vacancy in vacancies:
|
|
410
|
-
vacancy_id = vacancy["id"]
|
|
411
|
-
telemetry_data["vacancies"][vacancy_id] = {
|
|
412
|
-
"name": vacancy.get("name"),
|
|
413
|
-
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
414
|
-
"area": vacancy.get("area", {}).get("name"), # город
|
|
415
|
-
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
416
|
-
"direct_url": vacancy.get(
|
|
417
|
-
"alternate_url"
|
|
418
|
-
), # ссылка на вакансию
|
|
419
|
-
"created_at": fix_datetime(
|
|
420
|
-
vacancy.get("created_at")
|
|
421
|
-
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
422
|
-
"published_at": fix_datetime(vacancy.get("published_at")),
|
|
423
|
-
"contacts": vacancy.get(
|
|
424
|
-
"contacts"
|
|
425
|
-
), # пиздорванки там телеграм для связи указывают
|
|
426
|
-
# HH с точки зрения перфикциониста — кусок говна, где кривые
|
|
427
|
-
# форматы даты, у вакансий может не быть работодателя...
|
|
428
|
-
"employer_id": int(vacancy["employer"]["id"])
|
|
429
|
-
if "employer" in vacancy and "id" in vacancy["employer"]
|
|
430
|
-
else None,
|
|
431
|
-
# Остальное неинтересно
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
def _send_telemetry(
|
|
435
|
-
self, telemetry_client, telemetry_data: defaultdict
|
|
436
|
-
) -> None:
|
|
437
|
-
try:
|
|
438
|
-
res = telemetry_client.send_telemetry(
|
|
439
|
-
"/collect", dict(telemetry_data)
|
|
440
|
-
)
|
|
441
|
-
logger.debug(res)
|
|
442
|
-
except TelemetryError as ex:
|
|
443
|
-
logger.error(ex)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
from ..api import ApiError
|
|
8
|
+
from ..main import BaseOperation
|
|
9
|
+
from ..main import Namespace as BaseNamespace, get_api
|
|
10
|
+
from ..utils import parse_interval, random_text
|
|
11
|
+
from ..mixins import GetResumeIdMixin
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__package__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Namespace(BaseNamespace):
|
|
17
|
+
reply_message: str
|
|
18
|
+
reply_interval: Tuple[float, float]
|
|
19
|
+
max_pages: int
|
|
20
|
+
dry_run: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Operation(BaseOperation, GetResumeIdMixin):
|
|
24
|
+
"""Ответ всем работодателям."""
|
|
25
|
+
|
|
26
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"reply_message",
|
|
29
|
+
help="Сообщение для отправки во все чаты с работодателями, где ожидают ответа либо не прочитали ответ",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument('--resume-id', help="Идентификатор резюме")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--reply-interval",
|
|
34
|
+
help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
|
|
35
|
+
default="5-10",
|
|
36
|
+
type=parse_interval,
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--reply-message",
|
|
40
|
+
"--reply",
|
|
41
|
+
help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument('--max-pages', type=int, default=25, help='Максимальное количество страниц для проверки')
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--dry-run",
|
|
46
|
+
help="Не отправлять сообщения, а только выводить параметры запроса",
|
|
47
|
+
default=False,
|
|
48
|
+
action=argparse.BooleanOptionalAction,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def run(self, args: Namespace) -> None:
|
|
52
|
+
self.api = get_api(args)
|
|
53
|
+
self.resume_id = self._get_resume_id()
|
|
54
|
+
self.reply_min_interval, self.reply_max_interval = args.reply_interval
|
|
55
|
+
self.reply_message = args.reply_message
|
|
56
|
+
self.max_pages = args.max_pages
|
|
57
|
+
self.dry_run = args.dry_run
|
|
58
|
+
logger.debug(f'{self.reply_message = }')
|
|
59
|
+
self._reply_chats()
|
|
60
|
+
|
|
61
|
+
def _reply_chats(self) -> None:
|
|
62
|
+
me =self.me= self.api.get("/me")
|
|
63
|
+
|
|
64
|
+
basic_message_placeholders = {
|
|
65
|
+
"first_name": me.get("first_name", ""),
|
|
66
|
+
"last_name": me.get("last_name", ""),
|
|
67
|
+
"email": me.get("email", ""),
|
|
68
|
+
"phone": me.get("phone", ""),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for negotiation in self._get_negotiations():
|
|
72
|
+
try:
|
|
73
|
+
# Пропускаем другие резюме
|
|
74
|
+
if self.resume_id != negotiation['resume']['id']:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
nid = negotiation["id"]
|
|
78
|
+
vacancy = negotiation["vacancy"]
|
|
79
|
+
|
|
80
|
+
message_placeholders = {
|
|
81
|
+
"vacancy_name": vacancy.get("name", ""),
|
|
82
|
+
"employer_name": vacancy.get("employer", {}).get(
|
|
83
|
+
"name", ""
|
|
84
|
+
),
|
|
85
|
+
**basic_message_placeholders,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
logger.debug(
|
|
89
|
+
"Вакансия %(vacancy_name)s от %(employer_name)s"
|
|
90
|
+
% message_placeholders
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
page: int = 0
|
|
94
|
+
last_message: dict | None = None
|
|
95
|
+
while True:
|
|
96
|
+
messages_res = self.api.get(
|
|
97
|
+
f"/negotiations/{nid}/messages", page=page
|
|
98
|
+
)
|
|
99
|
+
last_message = messages_res["items"][-1]
|
|
100
|
+
if page + 1 >= messages_res["pages"]:
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
page = messages_res["pages"] - 1
|
|
104
|
+
|
|
105
|
+
logger.debug(last_message["text"])
|
|
106
|
+
|
|
107
|
+
if last_message["author"][
|
|
108
|
+
"participant_type"
|
|
109
|
+
] == "employer" or not negotiation.get(
|
|
110
|
+
"viewed_by_opponent"
|
|
111
|
+
):
|
|
112
|
+
message = (
|
|
113
|
+
random_text(self.reply_message)
|
|
114
|
+
% message_placeholders
|
|
115
|
+
)
|
|
116
|
+
logger.debug(message)
|
|
117
|
+
|
|
118
|
+
if self.dry_run:
|
|
119
|
+
logger.info(
|
|
120
|
+
"Dry Run: Отправка сообщения в чат по вакансии %s: %s",
|
|
121
|
+
vacancy["alternate_url"],
|
|
122
|
+
message,
|
|
123
|
+
)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
time.sleep(
|
|
127
|
+
random.uniform(
|
|
128
|
+
self.reply_min_interval,
|
|
129
|
+
self.reply_max_interval,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
self.api.post(
|
|
133
|
+
f"/negotiations/{nid}/messages",
|
|
134
|
+
message=message,
|
|
135
|
+
)
|
|
136
|
+
print(
|
|
137
|
+
"📨 Отправили сообщение для",
|
|
138
|
+
vacancy["alternate_url"],
|
|
139
|
+
)
|
|
140
|
+
except ApiError as ex:
|
|
141
|
+
logger.error(ex)
|
|
142
|
+
|
|
143
|
+
print("📝 Сообщения разосланы!")
|
|
144
|
+
|
|
145
|
+
def _get_negotiations(self) -> list[dict]:
|
|
146
|
+
rv = []
|
|
147
|
+
for page in range(self.max_pages):
|
|
148
|
+
res = self.api.get("/negotiations", page=page, status='active')
|
|
149
|
+
rv.extend(res["items"])
|
|
150
|
+
if page >= res["pages"] - 1:
|
|
151
|
+
break
|
|
152
|
+
page += 1
|
|
153
|
+
|
|
154
|
+
return rv
|
hh_applicant_tool/utils.py
CHANGED
|
@@ -88,3 +88,11 @@ def random_text(s: str) -> str:
|
|
|
88
88
|
) != s:
|
|
89
89
|
s = s1
|
|
90
90
|
return s
|
|
91
|
+
|
|
92
|
+
def parse_interval(interval: str) -> tuple[float, float]:
|
|
93
|
+
"""Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
|
|
94
|
+
if "-" in interval:
|
|
95
|
+
min_interval, max_interval = map(float, interval.split("-"))
|
|
96
|
+
else:
|
|
97
|
+
min_interval = max_interval = float(interval)
|
|
98
|
+
return min(min_interval, max_interval), max(min_interval, max_interval)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: hh-applicant-tool
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -240,6 +240,7 @@ https://hh.ru/employer/1918903
|
|
|
240
240
|
| **list-resumes** | Список резюме |
|
|
241
241
|
| **update-resumes** | Обновить все резюме. Аналогично нажатию кнопки «Обновить дату». |
|
|
242
242
|
| **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в день. На HH есть спам-фильтры, так что лучше не рассылайте отклики со ссылками. |
|
|
243
|
+
| **reply-employers** | Ответить во все чаты с работодателями, где нет ответа либо не прочитали ваш предыдущий ответ |
|
|
243
244
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
244
245
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
245
246
|
| **refresh-token** | Обновляет access_token. |
|
|
@@ -282,11 +283,17 @@ https://hh.ru/employer/1918903
|
|
|
282
283
|
|
|
283
284
|
Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
|
|
284
285
|
|
|
286
|
+
Для создания своих плагинов прочитайте документацию:
|
|
287
|
+
|
|
288
|
+
* [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
|
|
289
|
+
|
|
290
|
+
Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
|
|
291
|
+
|
|
285
292
|
### Сбор данных
|
|
286
293
|
|
|
287
|
-
|
|
294
|
+
Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
|
|
288
295
|
|
|
289
|
-
Утилита собирает и передает на сервер разработчика
|
|
296
|
+
Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
|
|
290
297
|
|
|
291
298
|
1. Название вакансии.
|
|
292
299
|
1. Тип вакансии (открытая/закрытая).
|
|
@@ -295,13 +302,11 @@ https://hh.ru/employer/1918903
|
|
|
295
302
|
1. Прямая ссылка на вакансию.
|
|
296
303
|
1. Дата создания вакансии.
|
|
297
304
|
1. Дата публикации вакансии.
|
|
298
|
-
1. Контактная информация работодателя,
|
|
305
|
+
1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
|
|
299
306
|
1. Название компании.
|
|
300
307
|
1. Тип компании.
|
|
301
308
|
1. Описание компании.
|
|
302
309
|
1. Ссылка на сайт компании.
|
|
303
310
|
1. Город, в котором находится компания.
|
|
304
311
|
|
|
305
|
-
**УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
|
|
306
|
-
|
|
307
312
|
|
|
@@ -5,20 +5,22 @@ hh_applicant_tool/api/client.py,sha256=um9NX22hNOtSuPCobCKf1anIFp-jiZlIXm4BuqN-L
|
|
|
5
5
|
hh_applicant_tool/api/errors.py,sha256=0SoWKnsSUA0K2YgQA8GwGhe-pRMwtfK94MR6_MgbORQ,1722
|
|
6
6
|
hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3s,823
|
|
7
7
|
hh_applicant_tool/constants.py,sha256=lpgKkP2chWgTnBXvzxbSPWpKcfzp8VxMTlkssFcQhH4,469
|
|
8
|
-
hh_applicant_tool/main.py,sha256=
|
|
8
|
+
hh_applicant_tool/main.py,sha256=DhnyINELRlp4i9ENlwDmzgU-C23ngy-hYlKXScivPIg,4797
|
|
9
|
+
hh_applicant_tool/mixins.py,sha256=66LmyYSsDfhrpUwoAONjzrd5aoXqaZVoQ-zXhyYbYMk,418
|
|
9
10
|
hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
hh_applicant_tool/operations/apply_similar.py,sha256=
|
|
11
|
+
hh_applicant_tool/operations/apply_similar.py,sha256=X3OLYzMnRXI7_v6w2i3RxpDDHUj-Yf5DAJB7KCSAmWA,12348
|
|
11
12
|
hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
|
|
12
13
|
hh_applicant_tool/operations/call_api.py,sha256=qwPDrWP9tiqHkaYYWYBZtymj9AaxObB86Eny-Bf5q_c,1314
|
|
13
14
|
hh_applicant_tool/operations/clear_negotiations.py,sha256=98Yuw9xP4dN5sUnUKlZxqfhU40TQ-aCjK4vj4LRTeYo,3894
|
|
14
15
|
hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOvAULnLkLkyUh7gxw,1134
|
|
15
16
|
hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
|
|
17
|
+
hh_applicant_tool/operations/reply_employers.py,sha256=wwDcI9YeZGUwadWQYFBwNpXb8qSAejaJ4KAuQTfFIuk,5686
|
|
16
18
|
hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
|
|
17
19
|
hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
|
|
18
20
|
hh_applicant_tool/telemetry_client.py,sha256=nNNr1drXY9Z01u5tJX---BXxBg1y06nJpNbhU45DmE0,2239
|
|
19
21
|
hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
|
|
20
|
-
hh_applicant_tool/utils.py,sha256=
|
|
21
|
-
hh_applicant_tool-0.4.
|
|
22
|
-
hh_applicant_tool-0.4.
|
|
23
|
-
hh_applicant_tool-0.4.
|
|
24
|
-
hh_applicant_tool-0.4.
|
|
22
|
+
hh_applicant_tool/utils.py,sha256=XFdQUOUm1DHJhVLRDLbXabOXtwfQAuk8Mqd-TTqNdgc,3017
|
|
23
|
+
hh_applicant_tool-0.4.1.dist-info/METADATA,sha256=XhxhXDFl5Q2Lp6TNfS9YY0oHwoZxt1PouUTOWcLGE00,20967
|
|
24
|
+
hh_applicant_tool-0.4.1.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
25
|
+
hh_applicant_tool-0.4.1.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
|
|
26
|
+
hh_applicant_tool-0.4.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|