hh-applicant-tool 0.3.9__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 +11 -5
- hh_applicant_tool/mixins.py +13 -0
- hh_applicant_tool/operations/apply_similar.py +111 -241
- hh_applicant_tool/operations/reply_employers.py +154 -0
- hh_applicant_tool/utils.py +8 -0
- {hh_applicant_tool-0.3.9.dist-info → hh_applicant_tool-0.4.1.dist-info}/METADATA +13 -8
- {hh_applicant_tool-0.3.9.dist-info → hh_applicant_tool-0.4.1.dist-info}/RECORD +9 -7
- {hh_applicant_tool-0.3.9.dist-info → hh_applicant_tool-0.4.1.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.3.9.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"
|
|
@@ -36,6 +35,7 @@ class Namespace(argparse.Namespace):
|
|
|
36
35
|
delay: float
|
|
37
36
|
user_agent: str
|
|
38
37
|
proxy_url: str
|
|
38
|
+
disable_telemetry: bool
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
|
|
@@ -103,6 +103,12 @@ class HHApplicantTool:
|
|
|
103
103
|
parser.add_argument(
|
|
104
104
|
"--proxy-url", help="Прокси, используемый для запросов к API"
|
|
105
105
|
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--disable-telemetry",
|
|
108
|
+
default=False,
|
|
109
|
+
action=argparse.BooleanOptionalAction,
|
|
110
|
+
help="Отключить телеметрию",
|
|
111
|
+
)
|
|
106
112
|
subparsers = parser.add_subparsers(help="commands")
|
|
107
113
|
package_dir = Path(__file__).resolve().parent / OPERATIONS
|
|
108
114
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
@@ -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,62 +69,50 @@ 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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return resume_id
|
|
133
|
-
|
|
134
|
-
def _get_application_messages(self, args: Namespace) -> list[str]:
|
|
135
|
-
if args.message_list:
|
|
84
|
+
self.enable_telemetry = True
|
|
85
|
+
if args.disable_telemetry:
|
|
86
|
+
print(
|
|
87
|
+
"👁️ Телеметрия используется только для сбора данных о работодателях и их вакансиях, персональные данные пользователей не передаются на сервер."
|
|
88
|
+
)
|
|
89
|
+
if (
|
|
90
|
+
input("Вы действительно хотите отключить телеметрию (д/Н)? ")
|
|
91
|
+
.lower()
|
|
92
|
+
.startswith(("д", "y"))
|
|
93
|
+
):
|
|
94
|
+
self.enable_telemetry = False
|
|
95
|
+
logger.info("Телеметрия отключена.")
|
|
96
|
+
else:
|
|
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:
|
|
136
114
|
application_messages = list(
|
|
137
|
-
filter(None, map(str.strip,
|
|
115
|
+
filter(None, map(str.strip, message_list))
|
|
138
116
|
)
|
|
139
117
|
else:
|
|
140
118
|
application_messages = [
|
|
@@ -143,38 +121,39 @@ class Operation(BaseOperation):
|
|
|
143
121
|
]
|
|
144
122
|
return application_messages
|
|
145
123
|
|
|
146
|
-
def _apply_similar(
|
|
147
|
-
self
|
|
148
|
-
api: ApiClient,
|
|
149
|
-
resume_id: str,
|
|
150
|
-
force_message: bool,
|
|
151
|
-
application_messages: list[str],
|
|
152
|
-
apply_min_interval: float,
|
|
153
|
-
apply_max_interval: float,
|
|
154
|
-
page_min_interval: float,
|
|
155
|
-
page_max_interval: float,
|
|
156
|
-
message_min_interval: float,
|
|
157
|
-
message_max_interval: float,
|
|
158
|
-
order_by: str,
|
|
159
|
-
search: str | None = None,
|
|
160
|
-
reply_message: str | None = None,
|
|
161
|
-
) -> None:
|
|
162
|
-
telemetry_client = TelemetryClient(proxies=api.proxies)
|
|
124
|
+
def _apply_similar(self) -> None:
|
|
125
|
+
telemetry_client = TelemetryClient(proxies=self.api.proxies)
|
|
163
126
|
telemetry_data = defaultdict(dict)
|
|
164
127
|
|
|
165
|
-
vacancies = self._get_vacancies(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
128
|
+
vacancies = self._get_vacancies()
|
|
129
|
+
|
|
130
|
+
if self.enable_telemetry:
|
|
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
|
+
}
|
|
176
155
|
|
|
177
|
-
me = api.get("/me")
|
|
156
|
+
me = self.api.get("/me")
|
|
178
157
|
|
|
179
158
|
basic_message_placeholders = {
|
|
180
159
|
"first_name": me.get("first_name", ""),
|
|
@@ -183,13 +162,8 @@ class Operation(BaseOperation):
|
|
|
183
162
|
"phone": me.get("phone", ""),
|
|
184
163
|
}
|
|
185
164
|
|
|
186
|
-
do_apply = True
|
|
187
|
-
|
|
188
165
|
for vacancy in vacancies:
|
|
189
166
|
try:
|
|
190
|
-
if getenv("TEST_TELEMETRY"):
|
|
191
|
-
break
|
|
192
|
-
|
|
193
167
|
message_placeholders = {
|
|
194
168
|
"vacancy_name": vacancy.get("name", ""),
|
|
195
169
|
"employer_name": vacancy.get("employer", {}).get(
|
|
@@ -212,74 +186,14 @@ class Operation(BaseOperation):
|
|
|
212
186
|
"🚫 Пропускаем вакансию в архиве",
|
|
213
187
|
vacancy["alternate_url"],
|
|
214
188
|
)
|
|
215
|
-
|
|
216
189
|
continue
|
|
217
190
|
|
|
218
191
|
relations = vacancy.get("relations", [])
|
|
219
192
|
|
|
220
193
|
if relations:
|
|
221
|
-
if "got_rejection" in relations:
|
|
222
|
-
print(
|
|
223
|
-
"🚫 Пропускаем отказ на вакансию",
|
|
224
|
-
vacancy["alternate_url"],
|
|
225
|
-
)
|
|
226
|
-
continue
|
|
227
|
-
|
|
228
|
-
if reply_message:
|
|
229
|
-
r = api.get("/negotiations", vacancy_id=vacancy["id"])
|
|
230
|
-
|
|
231
|
-
if len(r["items"]) == 1:
|
|
232
|
-
neg = r["items"][0]
|
|
233
|
-
nid = neg["id"]
|
|
234
|
-
|
|
235
|
-
page: int = 0
|
|
236
|
-
last_message: dict | None = None
|
|
237
|
-
while True:
|
|
238
|
-
r2 = api.get(
|
|
239
|
-
f"/negotiations/{nid}/messages", page=page
|
|
240
|
-
)
|
|
241
|
-
last_message = r2["items"][-1]
|
|
242
|
-
if page + 1 >= r2["pages"]:
|
|
243
|
-
break
|
|
244
|
-
|
|
245
|
-
page = r2["pages"] - 1
|
|
246
|
-
|
|
247
|
-
logger.debug(last_message["text"])
|
|
248
|
-
|
|
249
|
-
if last_message["author"][
|
|
250
|
-
"participant_type"
|
|
251
|
-
] == "employer" or not neg.get(
|
|
252
|
-
"viewed_by_opponent"
|
|
253
|
-
):
|
|
254
|
-
message = (
|
|
255
|
-
random_text(reply_message)
|
|
256
|
-
% message_placeholders
|
|
257
|
-
)
|
|
258
|
-
logger.debug(message)
|
|
259
|
-
|
|
260
|
-
time.sleep(
|
|
261
|
-
random.uniform(
|
|
262
|
-
message_min_interval,
|
|
263
|
-
message_max_interval,
|
|
264
|
-
)
|
|
265
|
-
)
|
|
266
|
-
api.post(
|
|
267
|
-
f"/negotiations/{nid}/messages",
|
|
268
|
-
message=message,
|
|
269
|
-
)
|
|
270
|
-
print(
|
|
271
|
-
"📨 Отправили сообщение для привлечения внимания",
|
|
272
|
-
vacancy["alternate_url"],
|
|
273
|
-
)
|
|
274
|
-
continue
|
|
275
|
-
else:
|
|
276
|
-
logger.warning(
|
|
277
|
-
"Приглашение без чата для вакансии: %s",
|
|
278
|
-
vacancy["alternate_url"],
|
|
279
|
-
)
|
|
280
|
-
|
|
281
194
|
print(
|
|
282
|
-
"🚫 Пропускаем вакансию с
|
|
195
|
+
"🚫 Пропускаем вакансию с",
|
|
196
|
+
["откликом или приглашением", "отказом"]["got_rejection" in relations],
|
|
283
197
|
vacancy["alternate_url"],
|
|
284
198
|
)
|
|
285
199
|
continue
|
|
@@ -287,11 +201,11 @@ class Operation(BaseOperation):
|
|
|
287
201
|
employer_id = vacancy.get("employer", {}).get("id")
|
|
288
202
|
|
|
289
203
|
if (
|
|
290
|
-
|
|
204
|
+
self.enable_telemetry
|
|
205
|
+
and employer_id
|
|
291
206
|
and employer_id not in telemetry_data["employers"]
|
|
292
|
-
and 200 > len(telemetry_data["employers"])
|
|
293
207
|
):
|
|
294
|
-
employer = api.get(f"/employers/{employer_id}")
|
|
208
|
+
employer = self.api.get(f"/employers/{employer_id}")
|
|
295
209
|
telemetry_data["employers"][employer_id] = {
|
|
296
210
|
"name": employer.get("name"),
|
|
297
211
|
"type": employer.get("type"),
|
|
@@ -300,35 +214,34 @@ class Operation(BaseOperation):
|
|
|
300
214
|
"area": employer.get("area", {}).get("name"), # город
|
|
301
215
|
}
|
|
302
216
|
|
|
303
|
-
if not do_apply:
|
|
304
|
-
logger.debug("skip apply similar")
|
|
305
|
-
continue
|
|
306
|
-
|
|
307
217
|
params = {
|
|
308
|
-
"resume_id": resume_id,
|
|
218
|
+
"resume_id": self.resume_id,
|
|
309
219
|
"vacancy_id": vacancy["id"],
|
|
310
220
|
"message": "",
|
|
311
221
|
}
|
|
312
222
|
|
|
313
|
-
if force_message or vacancy.get("response_letter_required"):
|
|
223
|
+
if self.force_message or vacancy.get("response_letter_required"):
|
|
314
224
|
msg = params["message"] = (
|
|
315
|
-
random_text(random.choice(application_messages))
|
|
225
|
+
random_text(random.choice(self.application_messages))
|
|
316
226
|
% message_placeholders
|
|
317
227
|
)
|
|
318
228
|
logger.debug(msg)
|
|
319
229
|
|
|
230
|
+
if self.dry_run:
|
|
231
|
+
logger.info(
|
|
232
|
+
"Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
|
|
233
|
+
vacancy["alternate_url"],
|
|
234
|
+
params,
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
|
|
320
238
|
# Задержка перед отправкой отклика
|
|
321
239
|
interval = random.uniform(
|
|
322
|
-
|
|
323
|
-
if params["message"]
|
|
324
|
-
else apply_min_interval,
|
|
325
|
-
max(apply_max_interval, message_max_interval)
|
|
326
|
-
if params["message"]
|
|
327
|
-
else apply_max_interval,
|
|
240
|
+
self.apply_min_interval, self.apply_max_interval
|
|
328
241
|
)
|
|
329
242
|
time.sleep(interval)
|
|
330
243
|
|
|
331
|
-
res = api.post("/negotiations", params)
|
|
244
|
+
res = self.api.post("/negotiations", params)
|
|
332
245
|
assert res == {}
|
|
333
246
|
print(
|
|
334
247
|
"📨 Отправили отклик",
|
|
@@ -340,87 +253,44 @@ class Operation(BaseOperation):
|
|
|
340
253
|
except ApiError as ex:
|
|
341
254
|
logger.error(ex)
|
|
342
255
|
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
343
|
-
|
|
344
|
-
break
|
|
345
|
-
do_apply = False
|
|
256
|
+
break
|
|
346
257
|
|
|
347
258
|
print("📝 Отклики на вакансии разосланы!")
|
|
348
259
|
|
|
349
|
-
|
|
350
|
-
|
|
260
|
+
if self.enable_telemetry:
|
|
261
|
+
if self.dry_run:
|
|
262
|
+
# С --dry-run можно посмотреть что отправляется
|
|
263
|
+
logger.info('Dry Run: Данные телеметрии для отправки на сервер: %r', telemetry_data)
|
|
264
|
+
return
|
|
351
265
|
|
|
266
|
+
try:
|
|
267
|
+
telemetry_client.send_telemetry("/collect", dict(telemetry_data))
|
|
268
|
+
except TelemetryError as ex:
|
|
269
|
+
logger.error(ex)
|
|
270
|
+
|
|
352
271
|
def _get_vacancies(
|
|
353
|
-
|
|
354
|
-
api: ApiClient,
|
|
355
|
-
resume_id: str,
|
|
356
|
-
page_min_interval: float,
|
|
357
|
-
page_max_interval: float,
|
|
358
|
-
per_page: int,
|
|
359
|
-
order_by: str,
|
|
360
|
-
search: str | None = None,
|
|
272
|
+
self, per_page: int = 100
|
|
361
273
|
) -> list[VacancyItem]:
|
|
362
274
|
rv = []
|
|
363
275
|
for page in range(20):
|
|
364
276
|
params = {
|
|
365
277
|
"page": page,
|
|
366
278
|
"per_page": per_page,
|
|
367
|
-
"order_by": order_by,
|
|
279
|
+
"order_by": self.order_by,
|
|
368
280
|
}
|
|
369
|
-
if search:
|
|
370
|
-
params["text"] = search
|
|
371
|
-
res: ApiListResponse = api.get(
|
|
372
|
-
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
|
|
373
285
|
)
|
|
374
286
|
rv.extend(res["items"])
|
|
375
|
-
|
|
376
|
-
if getenv("TEST_TELEMETRY"):
|
|
377
|
-
break
|
|
378
|
-
|
|
379
287
|
if page >= res["pages"] - 1:
|
|
380
288
|
break
|
|
381
289
|
|
|
382
290
|
# Задержка перед получением следующей страницы
|
|
383
291
|
if page > 0:
|
|
384
|
-
interval = random.uniform(page_min_interval, page_max_interval)
|
|
292
|
+
interval = random.uniform(self.page_min_interval, self.page_max_interval)
|
|
385
293
|
time.sleep(interval)
|
|
386
294
|
|
|
387
295
|
return rv
|
|
388
296
|
|
|
389
|
-
def _collect_vacancy_telemetry(
|
|
390
|
-
self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
|
|
391
|
-
) -> None:
|
|
392
|
-
for vacancy in vacancies:
|
|
393
|
-
vacancy_id = vacancy["id"]
|
|
394
|
-
telemetry_data["vacancies"][vacancy_id] = {
|
|
395
|
-
"name": vacancy.get("name"),
|
|
396
|
-
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
397
|
-
"area": vacancy.get("area", {}).get("name"), # город
|
|
398
|
-
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
399
|
-
"direct_url": vacancy.get(
|
|
400
|
-
"alternate_url"
|
|
401
|
-
), # ссылка на вакансию
|
|
402
|
-
"created_at": fix_datetime(
|
|
403
|
-
vacancy.get("created_at")
|
|
404
|
-
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
405
|
-
"published_at": fix_datetime(vacancy.get("published_at")),
|
|
406
|
-
"contacts": vacancy.get(
|
|
407
|
-
"contacts"
|
|
408
|
-
), # пиздорванки там телеграм для связи указывают
|
|
409
|
-
# HH с точки зрения перфикциониста — кусок говна, где кривые
|
|
410
|
-
# форматы даты, у вакансий может не быть работодателя...
|
|
411
|
-
"employer_id": int(vacancy["employer"]["id"])
|
|
412
|
-
if "employer" in vacancy and "id" in vacancy["employer"]
|
|
413
|
-
else None,
|
|
414
|
-
# Остальное неинтересно
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
def _send_telemetry(
|
|
418
|
-
self, telemetry_client, telemetry_data: defaultdict
|
|
419
|
-
) -> None:
|
|
420
|
-
try:
|
|
421
|
-
res = telemetry_client.send_telemetry(
|
|
422
|
-
"/collect", dict(telemetry_data)
|
|
423
|
-
)
|
|
424
|
-
logger.debug(res)
|
|
425
|
-
except TelemetryError as ex:
|
|
426
|
-
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.
|
|
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,9 +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
|
+
Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
|
|
295
|
+
|
|
296
|
+
Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
|
|
288
297
|
|
|
289
298
|
1. Название вакансии.
|
|
290
299
|
1. Тип вакансии (открытая/закрытая).
|
|
@@ -293,15 +302,11 @@ https://hh.ru/employer/1918903
|
|
|
293
302
|
1. Прямая ссылка на вакансию.
|
|
294
303
|
1. Дата создания вакансии.
|
|
295
304
|
1. Дата публикации вакансии.
|
|
296
|
-
1. Контактная информация работодателя,
|
|
305
|
+
1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
|
|
297
306
|
1. Название компании.
|
|
298
307
|
1. Тип компании.
|
|
299
308
|
1. Описание компании.
|
|
300
|
-
1. Ссылка на сайт компании.
|
|
309
|
+
1. Ссылка на сайт компании.
|
|
301
310
|
1. Город, в котором находится компания.
|
|
302
311
|
|
|
303
|
-
[Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
|
|
304
|
-
|
|
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.
|
|
22
|
-
hh_applicant_tool-0.
|
|
23
|
-
hh_applicant_tool-0.
|
|
24
|
-
hh_applicant_tool-0.
|
|
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
|