hh-applicant-tool 0.3.6__py3-none-any.whl → 0.3.8__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/api/client.py +5 -1
- hh_applicant_tool/main.py +21 -4
- hh_applicant_tool/operations/apply_similar.py +162 -42
- hh_applicant_tool/telemetry_client.py +0 -6
- hh_applicant_tool/utils.py +16 -0
- {hh_applicant_tool-0.3.6.dist-info → hh_applicant_tool-0.3.8.dist-info}/METADATA +110 -88
- {hh_applicant_tool-0.3.6.dist-info → hh_applicant_tool-0.3.8.dist-info}/RECORD +9 -9
- {hh_applicant_tool-0.3.6.dist-info → hh_applicant_tool-0.3.8.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.3.6.dist-info → hh_applicant_tool-0.3.8.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/api/client.py
CHANGED
|
@@ -37,6 +37,7 @@ class BaseClient:
|
|
|
37
37
|
_: dataclasses.KW_ONLY
|
|
38
38
|
# TODO: сделать генерацию User-Agent'а как в приложении
|
|
39
39
|
user_agent: str | None = None
|
|
40
|
+
proxies: dict | None = None
|
|
40
41
|
session: Session | None = None
|
|
41
42
|
previous_request_time: float = 0.0
|
|
42
43
|
delay: float = 0.334
|
|
@@ -47,8 +48,8 @@ class BaseClient:
|
|
|
47
48
|
self.session = session = requests.session()
|
|
48
49
|
session.headers.update(
|
|
49
50
|
{
|
|
50
|
-
**self.additional_headers(),
|
|
51
51
|
"User-Agent": self.user_agent or self.default_user_agent(),
|
|
52
|
+
**self.additional_headers(),
|
|
52
53
|
}
|
|
53
54
|
)
|
|
54
55
|
logger.debug("Default Headers: %r", session.headers)
|
|
@@ -101,10 +102,13 @@ class BaseClient:
|
|
|
101
102
|
logger.debug("wait %fs before request", delay)
|
|
102
103
|
time.sleep(delay)
|
|
103
104
|
has_body = method in ["POST", "PUT"]
|
|
105
|
+
user_agent = self.user_agent or self.default_user_agent()
|
|
106
|
+
logger.debug(f"{user_agent = }")
|
|
104
107
|
response = self.session.request(
|
|
105
108
|
method,
|
|
106
109
|
url,
|
|
107
110
|
**{"data" if has_body else "params": params},
|
|
111
|
+
proxies=self.proxies,
|
|
108
112
|
allow_redirects=False,
|
|
109
113
|
)
|
|
110
114
|
try:
|
hh_applicant_tool/main.py
CHANGED
|
@@ -5,13 +5,13 @@ import logging
|
|
|
5
5
|
import sys
|
|
6
6
|
from abc import ABCMeta, abstractmethod
|
|
7
7
|
from importlib import import_module
|
|
8
|
-
from os import getenv
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
from pkgutil import iter_modules
|
|
11
|
-
from typing import Sequence
|
|
10
|
+
from typing import Sequence, Literal
|
|
12
11
|
from .api import ApiClient
|
|
13
12
|
from .color_log import ColorHandler
|
|
14
13
|
from .utils import Config, get_config_path
|
|
14
|
+
from os import getenv
|
|
15
15
|
|
|
16
16
|
DEFAULT_CONFIG_PATH = (
|
|
17
17
|
get_config_path() / __package__.replace("_", "-") / "config.json"
|
|
@@ -34,6 +34,15 @@ class Namespace(argparse.Namespace):
|
|
|
34
34
|
config: Config
|
|
35
35
|
verbosity: int
|
|
36
36
|
delay: float
|
|
37
|
+
user_agent: str
|
|
38
|
+
proxy_url: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
|
|
42
|
+
return {
|
|
43
|
+
"http": args.config["proxy_url"] or getenv("HTTP_PROXY"),
|
|
44
|
+
"https": args.config["proxy_url"] or getenv("HTTPS_PROXY"),
|
|
45
|
+
}
|
|
37
46
|
|
|
38
47
|
|
|
39
48
|
def get_api(args: Namespace) -> ApiClient:
|
|
@@ -41,8 +50,9 @@ def get_api(args: Namespace) -> ApiClient:
|
|
|
41
50
|
api = ApiClient(
|
|
42
51
|
access_token=token.get("access_token"),
|
|
43
52
|
refresh_token=token.get("refresh_token"),
|
|
44
|
-
user_agent=args.config["user_agent"],
|
|
45
53
|
delay=args.delay,
|
|
54
|
+
user_agent=args.config["user_agent"],
|
|
55
|
+
proxies=get_proxies(args),
|
|
46
56
|
)
|
|
47
57
|
return api
|
|
48
58
|
|
|
@@ -87,6 +97,12 @@ class HHApplicantTool:
|
|
|
87
97
|
default=0.334,
|
|
88
98
|
help="Задержка между запросами к API HH",
|
|
89
99
|
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--user-agent", help="User-Agent для каждого запроса"
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--proxy-url", help="Прокси, используемый для запросов к API"
|
|
105
|
+
)
|
|
90
106
|
subparsers = parser.add_subparsers(help="commands")
|
|
91
107
|
package_dir = Path(__file__).resolve().parent / OPERATIONS
|
|
92
108
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
@@ -94,7 +110,8 @@ class HHApplicantTool:
|
|
|
94
110
|
op: BaseOperation = mod.Operation()
|
|
95
111
|
op_parser = subparsers.add_parser(
|
|
96
112
|
module_name.replace("_", "-"),
|
|
97
|
-
description=op.__doc__,
|
|
113
|
+
description=op.__doc__,
|
|
114
|
+
formatter_class=self.ArgumentFormatter,
|
|
98
115
|
)
|
|
99
116
|
op_parser.set_defaults(run=op.run)
|
|
100
117
|
op.setup_parser(op_parser)
|
|
@@ -9,10 +9,10 @@ from typing import TextIO, Tuple
|
|
|
9
9
|
from ..api import ApiClient, ApiError, BadRequest
|
|
10
10
|
from ..main import BaseOperation
|
|
11
11
|
from ..main import Namespace as BaseNamespace, get_api
|
|
12
|
-
from ..telemetry_client import TelemetryError
|
|
13
|
-
from ..telemetry_client import get_client as get_telemetry_client
|
|
12
|
+
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
14
13
|
from ..types import ApiListResponse, VacancyItem
|
|
15
|
-
from ..utils import fix_datetime,
|
|
14
|
+
from ..utils import fix_datetime, truncate_string, random_text
|
|
15
|
+
from requests import Session
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__package__)
|
|
18
18
|
|
|
@@ -23,8 +23,13 @@ class Namespace(BaseNamespace):
|
|
|
23
23
|
force_message: bool
|
|
24
24
|
apply_interval: Tuple[float, float]
|
|
25
25
|
page_interval: Tuple[float, float]
|
|
26
|
+
message_interval: Tuple[float, float]
|
|
27
|
+
order_by: str
|
|
28
|
+
search: str
|
|
29
|
+
reply_message: str
|
|
26
30
|
|
|
27
31
|
|
|
32
|
+
# gx для открытия (никак не запомню в виме)
|
|
28
33
|
# https://api.hh.ru/openapi/redoc
|
|
29
34
|
class Operation(BaseOperation):
|
|
30
35
|
"""Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
|
|
@@ -33,7 +38,7 @@ class Operation(BaseOperation):
|
|
|
33
38
|
parser.add_argument("--resume-id", help="Идентефикатор резюме")
|
|
34
39
|
parser.add_argument(
|
|
35
40
|
"--message-list",
|
|
36
|
-
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(
|
|
41
|
+
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(vacabcy_name)s",
|
|
37
42
|
type=argparse.FileType(),
|
|
38
43
|
)
|
|
39
44
|
parser.add_argument(
|
|
@@ -44,16 +49,22 @@ class Operation(BaseOperation):
|
|
|
44
49
|
)
|
|
45
50
|
parser.add_argument(
|
|
46
51
|
"--apply-interval",
|
|
47
|
-
help="Интервал
|
|
52
|
+
help="Интервал перед отправкой откликов в секундах (X, X-Y)",
|
|
48
53
|
default="1-5",
|
|
49
54
|
type=self._parse_interval,
|
|
50
55
|
)
|
|
51
56
|
parser.add_argument(
|
|
52
57
|
"--page-interval",
|
|
53
|
-
help="Интервал
|
|
58
|
+
help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
|
|
54
59
|
default="1-3",
|
|
55
60
|
type=self._parse_interval,
|
|
56
61
|
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--message-interval",
|
|
64
|
+
help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
|
|
65
|
+
default="5-10",
|
|
66
|
+
type=self._parse_interval,
|
|
67
|
+
)
|
|
57
68
|
parser.add_argument(
|
|
58
69
|
"--order-by",
|
|
59
70
|
help="Сортировка вакансий",
|
|
@@ -68,10 +79,15 @@ class Operation(BaseOperation):
|
|
|
68
79
|
)
|
|
69
80
|
parser.add_argument(
|
|
70
81
|
"--search",
|
|
71
|
-
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую
|
|
82
|
+
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зарплату",
|
|
72
83
|
type=str,
|
|
73
84
|
default=None,
|
|
74
85
|
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--reply-message",
|
|
88
|
+
"--reply",
|
|
89
|
+
help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
|
|
90
|
+
)
|
|
75
91
|
|
|
76
92
|
@staticmethod
|
|
77
93
|
def _parse_interval(interval: str) -> Tuple[float, float]:
|
|
@@ -89,6 +105,7 @@ class Operation(BaseOperation):
|
|
|
89
105
|
|
|
90
106
|
apply_min_interval, apply_max_interval = args.apply_interval
|
|
91
107
|
page_min_interval, page_max_interval = args.page_interval
|
|
108
|
+
message_min_interval, message_max_interval = args.message_interval
|
|
92
109
|
|
|
93
110
|
self._apply_similar(
|
|
94
111
|
api,
|
|
@@ -99,8 +116,11 @@ class Operation(BaseOperation):
|
|
|
99
116
|
apply_max_interval,
|
|
100
117
|
page_min_interval,
|
|
101
118
|
page_max_interval,
|
|
119
|
+
message_min_interval,
|
|
120
|
+
message_max_interval,
|
|
102
121
|
args.order_by,
|
|
103
122
|
args.search,
|
|
123
|
+
args.reply_message or args.config["reply_message"],
|
|
104
124
|
)
|
|
105
125
|
|
|
106
126
|
def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
|
|
@@ -118,11 +138,8 @@ class Operation(BaseOperation):
|
|
|
118
138
|
)
|
|
119
139
|
else:
|
|
120
140
|
application_messages = [
|
|
121
|
-
"Меня
|
|
122
|
-
"Прошу рассмотреть мою
|
|
123
|
-
"Ваша вакансия %(name)s соответствует моим навыкам и опыту",
|
|
124
|
-
"Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
|
|
125
|
-
"Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
|
|
141
|
+
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
|
|
142
|
+
"{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
|
|
126
143
|
]
|
|
127
144
|
return application_messages
|
|
128
145
|
|
|
@@ -136,10 +153,17 @@ class Operation(BaseOperation):
|
|
|
136
153
|
apply_max_interval: float,
|
|
137
154
|
page_min_interval: float,
|
|
138
155
|
page_max_interval: float,
|
|
156
|
+
message_min_interval: float,
|
|
157
|
+
message_max_interval: float,
|
|
139
158
|
order_by: str,
|
|
140
159
|
search: str | None = None,
|
|
160
|
+
reply_message: str | None = None,
|
|
141
161
|
) -> None:
|
|
142
|
-
|
|
162
|
+
# TODO: вынести куда-нибудь в функцию
|
|
163
|
+
session = Session()
|
|
164
|
+
session.headers["User-Agent"] = "Mozilla/5.0 (HHApplicantTelemetry/1.0)"
|
|
165
|
+
session.proxies = dict(api.session.proxies)
|
|
166
|
+
telemetry_client = TelemetryClient(session=session)
|
|
143
167
|
telemetry_data = defaultdict(dict)
|
|
144
168
|
|
|
145
169
|
vacancies = self._get_vacancies(
|
|
@@ -154,33 +178,124 @@ class Operation(BaseOperation):
|
|
|
154
178
|
|
|
155
179
|
self._collect_vacancy_telemetry(telemetry_data, vacancies)
|
|
156
180
|
|
|
181
|
+
me = api.get("/me")
|
|
182
|
+
|
|
183
|
+
basic_message_placeholders = {
|
|
184
|
+
"first_name": me.get("first_name", ""),
|
|
185
|
+
"last_name": me.get("last_name", ""),
|
|
186
|
+
"email": me.get("email", ""),
|
|
187
|
+
"phone": me.get("phone", ""),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
do_apply = True
|
|
191
|
+
|
|
157
192
|
for vacancy in vacancies:
|
|
158
193
|
try:
|
|
159
194
|
if getenv("TEST_TELEMETRY"):
|
|
160
195
|
break
|
|
161
196
|
|
|
197
|
+
message_placeholders = {
|
|
198
|
+
"vacancy_name": vacancy.get("name", ""),
|
|
199
|
+
"employer_name": vacancy.get("employer", {}).get(
|
|
200
|
+
"name", ""
|
|
201
|
+
),
|
|
202
|
+
**basic_message_placeholders,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
logger.debug(
|
|
206
|
+
"Вакансия %(vacancy_name)s от %(employer_name)s"
|
|
207
|
+
% message_placeholders
|
|
208
|
+
)
|
|
209
|
+
|
|
162
210
|
if vacancy.get("has_test"):
|
|
163
211
|
print("🚫 Пропускаем тест", vacancy["alternate_url"])
|
|
164
212
|
continue
|
|
165
213
|
|
|
214
|
+
if vacancy.get("archived"):
|
|
215
|
+
print(
|
|
216
|
+
"🚫 Пропускаем вакансию в архиве",
|
|
217
|
+
vacancy["alternate_url"],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
continue
|
|
221
|
+
|
|
166
222
|
relations = vacancy.get("relations", [])
|
|
167
223
|
|
|
168
224
|
if relations:
|
|
225
|
+
if "got_rejection" in relations:
|
|
226
|
+
print(
|
|
227
|
+
"🚫 Пропускаем отказ на вакансию",
|
|
228
|
+
vacancy["alternate_url"],
|
|
229
|
+
)
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
if reply_message:
|
|
233
|
+
r = api.get("/negotiations", vacancy_id=vacancy["id"])
|
|
234
|
+
|
|
235
|
+
if len(r["items"]) == 1:
|
|
236
|
+
neg = r["items"][0]
|
|
237
|
+
nid = neg["id"]
|
|
238
|
+
|
|
239
|
+
page: int = 0
|
|
240
|
+
last_message: dict | None = None
|
|
241
|
+
while True:
|
|
242
|
+
r2 = api.get(
|
|
243
|
+
f"/negotiations/{nid}/messages", page=page
|
|
244
|
+
)
|
|
245
|
+
last_message = r2["items"][-1]
|
|
246
|
+
if page + 1 >= r2["pages"]:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
page = r2["pages"] - 1
|
|
250
|
+
|
|
251
|
+
logger.debug(last_message["text"])
|
|
252
|
+
|
|
253
|
+
if last_message["author"][
|
|
254
|
+
"participant_type"
|
|
255
|
+
] == "employer" or not neg.get(
|
|
256
|
+
"viewed_by_opponent"
|
|
257
|
+
):
|
|
258
|
+
message = (
|
|
259
|
+
random_text(reply_message)
|
|
260
|
+
% message_placeholders
|
|
261
|
+
)
|
|
262
|
+
logger.debug(message)
|
|
263
|
+
|
|
264
|
+
time.sleep(
|
|
265
|
+
random.uniform(
|
|
266
|
+
message_min_interval,
|
|
267
|
+
message_max_interval,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
api.post(
|
|
271
|
+
f"/negotiations/{nid}/messages",
|
|
272
|
+
message=message,
|
|
273
|
+
)
|
|
274
|
+
print(
|
|
275
|
+
"📨 Отправили сообщение для привлечения внимания",
|
|
276
|
+
vacancy["alternate_url"],
|
|
277
|
+
)
|
|
278
|
+
continue
|
|
279
|
+
else:
|
|
280
|
+
logger.warning(
|
|
281
|
+
"Приглашение без чата для вакансии: %s",
|
|
282
|
+
vacancy["alternate_url"],
|
|
283
|
+
)
|
|
284
|
+
|
|
169
285
|
print(
|
|
170
|
-
"🚫 Пропускаем
|
|
286
|
+
"🚫 Пропускаем вакансию с откликом",
|
|
171
287
|
vacancy["alternate_url"],
|
|
172
288
|
)
|
|
173
289
|
continue
|
|
174
290
|
|
|
175
|
-
|
|
176
|
-
employer_id = vacancy["employer"]["id"]
|
|
177
|
-
except KeyError:
|
|
178
|
-
logger.warning(
|
|
179
|
-
f"Вакансия без работодателя: {vacancy['alternate_url']}"
|
|
180
|
-
)
|
|
181
|
-
else:
|
|
182
|
-
employer = api.get(f"/employers/{employer_id}")
|
|
291
|
+
employer_id = vacancy.get("employer", {}).get("id")
|
|
183
292
|
|
|
293
|
+
if (
|
|
294
|
+
employer_id
|
|
295
|
+
and employer_id not in telemetry_data["employers"]
|
|
296
|
+
and 200 > len(telemetry_data["employers"])
|
|
297
|
+
):
|
|
298
|
+
employer = api.get(f"/employers/{employer_id}")
|
|
184
299
|
telemetry_data["employers"][employer_id] = {
|
|
185
300
|
"name": employer.get("name"),
|
|
186
301
|
"type": employer.get("type"),
|
|
@@ -189,11 +304,9 @@ class Operation(BaseOperation):
|
|
|
189
304
|
"area": employer.get("area", {}).get("name"), # город
|
|
190
305
|
}
|
|
191
306
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
time.sleep(interval)
|
|
307
|
+
if not do_apply:
|
|
308
|
+
logger.debug("skip apply similar")
|
|
309
|
+
continue
|
|
197
310
|
|
|
198
311
|
params = {
|
|
199
312
|
"resume_id": resume_id,
|
|
@@ -201,19 +314,23 @@ class Operation(BaseOperation):
|
|
|
201
314
|
"message": "",
|
|
202
315
|
}
|
|
203
316
|
|
|
204
|
-
if vacancy.get("response_letter_required"):
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
317
|
+
if force_message or vacancy.get("response_letter_required"):
|
|
318
|
+
msg = params["message"] = (
|
|
319
|
+
random_text(random.choice(application_messages))
|
|
320
|
+
% message_placeholders
|
|
321
|
+
)
|
|
322
|
+
logger.debug(msg)
|
|
323
|
+
|
|
324
|
+
# Задержка перед отправкой отклика
|
|
325
|
+
interval = random.uniform(
|
|
326
|
+
max(apply_min_interval, message_min_interval)
|
|
327
|
+
if params["message"]
|
|
328
|
+
else apply_min_interval,
|
|
329
|
+
max(apply_max_interval, message_max_interval)
|
|
330
|
+
if params["message"]
|
|
331
|
+
else apply_max_interval,
|
|
332
|
+
)
|
|
333
|
+
time.sleep(interval)
|
|
217
334
|
|
|
218
335
|
res = api.post("/negotiations", params)
|
|
219
336
|
assert res == {}
|
|
@@ -225,12 +342,15 @@ class Operation(BaseOperation):
|
|
|
225
342
|
")",
|
|
226
343
|
)
|
|
227
344
|
except ApiError as ex:
|
|
228
|
-
|
|
345
|
+
logger.error(ex)
|
|
229
346
|
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
230
|
-
|
|
347
|
+
if not reply_message:
|
|
348
|
+
break
|
|
349
|
+
do_apply = False
|
|
231
350
|
|
|
232
351
|
print("📝 Отклики на вакансии разосланы!")
|
|
233
352
|
|
|
353
|
+
# Я собираюсь выложить контакты херок в общественный доступ
|
|
234
354
|
self._send_telemetry(telemetry_client, telemetry_data)
|
|
235
355
|
|
|
236
356
|
def _get_vacancies(
|
|
@@ -3,7 +3,6 @@ import json
|
|
|
3
3
|
from urllib.parse import urljoin
|
|
4
4
|
import requests
|
|
5
5
|
from typing import Optional, Dict, Any
|
|
6
|
-
from functools import cache
|
|
7
6
|
import logging
|
|
8
7
|
import base64
|
|
9
8
|
|
|
@@ -64,8 +63,3 @@ class TelemetryClient:
|
|
|
64
63
|
json.JSONDecodeError,
|
|
65
64
|
) as ex:
|
|
66
65
|
raise TelemetryError(str(ex)) from ex
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@cache
|
|
70
|
-
def get_client() -> TelemetryClient:
|
|
71
|
-
return TelemetryClient()
|
hh_applicant_tool/utils.py
CHANGED
|
@@ -10,6 +10,8 @@ from threading import Lock
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
from os import getenv
|
|
12
12
|
from .constants import INVALID_ISO8601_FORMAT
|
|
13
|
+
import re
|
|
14
|
+
import random
|
|
13
15
|
|
|
14
16
|
print_err = partial(print, file=sys.stderr, flush=True)
|
|
15
17
|
|
|
@@ -72,3 +74,17 @@ def fix_datetime(dt: str | None) -> str | None:
|
|
|
72
74
|
if dt is None:
|
|
73
75
|
return None
|
|
74
76
|
return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def random_text(s: str) -> str:
|
|
80
|
+
while (
|
|
81
|
+
s1 := re.sub(
|
|
82
|
+
r"{([^{}]+)}",
|
|
83
|
+
lambda m: random.choice(
|
|
84
|
+
m.group(1).split("|"),
|
|
85
|
+
),
|
|
86
|
+
s,
|
|
87
|
+
)
|
|
88
|
+
) != s:
|
|
89
|
+
s = s1
|
|
90
|
+
return s
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: hh-applicant-tool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -14,7 +14,7 @@ Provides-Extra: qt
|
|
|
14
14
|
Requires-Dist: prettytable (>=3.6.0,<4.0.0)
|
|
15
15
|
Requires-Dist: pyqt6 (>=6.7.1,<7.0.0) ; extra == "qt"
|
|
16
16
|
Requires-Dist: pyqt6-webengine (>=6.7.0,<7.0.0) ; extra == "qt"
|
|
17
|
-
Requires-Dist: requests (>=2.
|
|
17
|
+
Requires-Dist: requests[socks] (>=2.32.3,<3.0.0)
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
20
20
|
## HH Applicant Tool
|
|
@@ -30,19 +30,21 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
<img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
+
### Описание
|
|
34
|
+
|
|
33
35
|
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/+aSjr8qM_AP85ZDBi (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
|
|
34
36
|
|
|
35
37
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
36
38
|
asdf/pyenv/conda и что-то еще...
|
|
37
39
|
|
|
38
|
-
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести
|
|
40
|
+
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
39
41
|
|
|
40
42
|
Пример работы:
|
|
41
43
|
|
|
42
44
|

|
|
43
45
|
|
|
44
46
|
|
|
45
|
-
|
|
47
|
+
### Предыстория
|
|
46
48
|
|
|
47
49
|
Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
|
|
48
50
|
|
|
@@ -54,7 +56,7 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
|
|
|
54
56
|
|
|
55
57
|
Оно работает, хоть и не идеально. Я даже пробовал автоматизировать рассылки через `p[yu]ppeeter`, пока не прочитал [документацию](https://github.com/hhru/api). И не обнаружил, что **API** (интерфейс) содержит все необходимые мне методы. Headhunter позволяет создать свое приложение, но там ручная модерация, и наврядли кто-то разрешит мне создать приложение для спама заявками. Я [декомпилировал](https://gist.github.com/s3rgeym/eee96bbf91b04f7eb46b7449f8884a00) официальное приложение для **Android** и получил **CLIENT_ID** и **CLIENT_SECRET**, необходимые для работы через **API**.
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
### Установка
|
|
58
60
|
|
|
59
61
|
```bash
|
|
60
62
|
# Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
|
|
@@ -91,9 +93,89 @@ $ pipx upgrade hh-applicant-tool
|
|
|
91
93
|
```
|
|
92
94
|
* В случае неудачи вернитесь к первому шагу.
|
|
93
95
|
* Для последующих запусков сначала активируйте виртуальное окружение.
|
|
94
|
-
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
### Авторизация
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
$ hh-applicant-tool -vv authorize
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+

|
|
104
|
+
|
|
105
|
+
> В Windows не забудьте разрешить доступ к сети (Allow access) в всплывающем окне.
|
|
106
|
+
|
|
107
|
+
Проверка авторизации:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
$ hh-applicant-tool whoami
|
|
111
|
+
{
|
|
112
|
+
"auth_type": "applicant",
|
|
113
|
+
"counters": {
|
|
114
|
+
"new_resume_views": 1488,
|
|
115
|
+
"resumes_count": 1,
|
|
116
|
+
"unread_negotiations": 228
|
|
117
|
+
},
|
|
118
|
+
"email": "vasya.pupkin@gmail.com",
|
|
119
|
+
"employer": null,
|
|
120
|
+
"first_name": "Вася",
|
|
121
|
+
"id": "1234567890",
|
|
122
|
+
"is_admin": false,
|
|
123
|
+
"is_anonymous": false,
|
|
124
|
+
"is_applicant": true,
|
|
125
|
+
"is_application": false,
|
|
126
|
+
"is_employer": false,
|
|
127
|
+
"is_in_search": true,
|
|
128
|
+
"last_name": "Пупкин",
|
|
129
|
+
"manager": null,
|
|
130
|
+
"mid_name": null,
|
|
131
|
+
"middle_name": null,
|
|
132
|
+
"negotiations_url": "https://api.hh.ru/negotiations",
|
|
133
|
+
"personal_manager": null,
|
|
134
|
+
"phone": "79012345678",
|
|
135
|
+
"profile_videos": {
|
|
136
|
+
"items": []
|
|
137
|
+
},
|
|
138
|
+
"resumes_url": "https://api.hh.ru/resumes/mine"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
В случае успешной авторизации токены будут сохранены в `config.json`:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"token": {
|
|
147
|
+
"access_token": "...",
|
|
148
|
+
"created_at": 1678151427,
|
|
149
|
+
"expires_in": 1209599,
|
|
150
|
+
"refresh_token": "...",
|
|
151
|
+
"token_type": "bearer"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Токен доступа выдается на две недели. После его нужно обновить:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
$ hh-applicant-tool refresh-token
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Пути до файла config.json
|
|
163
|
+
|
|
164
|
+
| OS | Путь |
|
|
165
|
+
|----------------------------|---------------------------------------------------------------------|
|
|
166
|
+
| **Windows** | `C:\Users\%username%\AppData\Roaming\hh-applicant-tool\config.json` |
|
|
167
|
+
| **macOS** | `~/Library/Application Support/hh-applicant-tool/config.json` |
|
|
168
|
+
| **Linux** | `~/.config/hh-applicant-tool/config.json` |
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
Через конфиг можно задать дополнительные настройки:
|
|
172
|
+
|
|
173
|
+
| Имя атрибута | Описание |
|
|
174
|
+
| `user_agent` | Кастомный юзерагент, передаваемый при кажом запросе, например, `Mozilla/5.0 YablanBrowser` |
|
|
175
|
+
| `proxy_url` | Прокси, используемый для всех запросов, например, `socks5h://127.0.0.1:9050` |
|
|
176
|
+
| `reply_message` | Сообщение для ответа работодателю при отклике на вакансии, см. формат сообщений |
|
|
177
|
+
|
|
178
|
+
### Описание команд
|
|
97
179
|
|
|
98
180
|
```bash
|
|
99
181
|
$ hh-applicant-tool [ GLOBAL_FLAGS ] [ OPERATION [ OPERATION_FLAGS ] ]
|
|
@@ -160,107 +242,47 @@ https://hh.ru/employer/1918903
|
|
|
160
242
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
161
243
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
162
244
|
| **refresh-token** | Обновляет access_token. |
|
|
163
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, сайта фирмы и могут быть удалены по просьбе
|
|
245
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, с сайта фирмы и могут быть удалены только по просьбе уполнамоченного лица. Данная функция готова и будет доступна после 100 ⭐ |
|
|
164
246
|
|
|
165
|
-
|
|
247
|
+
### Формат текста сообщений
|
|
166
248
|
|
|
167
|
-
|
|
168
|
-
$ hh-applicant-tool -vv authorize
|
|
169
|
-
```
|
|
170
|
-
|
|
171
|
-

|
|
172
|
-
|
|
173
|
-
> В Windows не забудьте разрешить доступ к сети (Allow access) в всплывающем окне.
|
|
249
|
+
Команда `apply-similar` поддерживает специальный формат сообщений.
|
|
174
250
|
|
|
251
|
+
Так же в сообщении можно использовать плейсхолдеры:
|
|
175
252
|
|
|
253
|
+
- **`%(vacancy_name)s`**: Название вакансии.
|
|
254
|
+
- **`%(employer_name)s`**: Название работодателя.
|
|
255
|
+
- **`%(first_name)s`**: Имя пользователя.
|
|
256
|
+
- **`%(last_name)s`**: Фамилия пользователя.
|
|
257
|
+
- **`%(email)s`**: Email пользователя.
|
|
258
|
+
- **`%(phone)s`**: Телефон пользователя.
|
|
176
259
|
|
|
177
|
-
|
|
260
|
+
Эти плейсхолдеры могут быть использованы в сообщениях для отклика на вакансии, чтобы динамически подставлять соответствующие данные в текст сообщения. Например:
|
|
178
261
|
|
|
179
|
-
```json
|
|
180
|
-
{
|
|
181
|
-
"token": {
|
|
182
|
-
"access_token": "...",
|
|
183
|
-
"created_at": 1678151427,
|
|
184
|
-
"expires_in": 1209599,
|
|
185
|
-
"refresh_token": "...",
|
|
186
|
-
"token_type": "bearer"
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
262
|
```
|
|
190
|
-
|
|
191
|
-
Через этот файл можно задать кастомный `user_agent`:
|
|
192
|
-
|
|
193
|
-
```json
|
|
194
|
-
{
|
|
195
|
-
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0"
|
|
196
|
-
}
|
|
263
|
+
Меня заинтересовала ваша вакансия %(vacancy_name)s. Прошу рассмотреть мою кандидатуру. С уважением, %(first_name)s %(last_name)s.
|
|
197
264
|
```
|
|
198
265
|
|
|
199
|
-
|
|
266
|
+
Так же можно делать текст уникальным с помощью `{}`. Внутри них через `|` перечисляются варианты, один из которых будет случайно выбран:
|
|
200
267
|
|
|
201
|
-
```bash
|
|
202
|
-
$ hh-applicant-tool whoami
|
|
203
|
-
{
|
|
204
|
-
"auth_type": "applicant",
|
|
205
|
-
"counters": {
|
|
206
|
-
"new_resume_views": 1488,
|
|
207
|
-
"resumes_count": 1,
|
|
208
|
-
"unread_negotiations": 228
|
|
209
|
-
},
|
|
210
|
-
"email": "vasya.pupkin@gmail.com",
|
|
211
|
-
"employer": null,
|
|
212
|
-
"first_name": "Вася",
|
|
213
|
-
"id": "1234567890",
|
|
214
|
-
"is_admin": false,
|
|
215
|
-
"is_anonymous": false,
|
|
216
|
-
"is_applicant": true,
|
|
217
|
-
"is_application": false,
|
|
218
|
-
"is_employer": false,
|
|
219
|
-
"is_in_search": true,
|
|
220
|
-
"last_name": "Пупкин",
|
|
221
|
-
"manager": null,
|
|
222
|
-
"mid_name": null,
|
|
223
|
-
"middle_name": null,
|
|
224
|
-
"negotiations_url": "https://api.hh.ru/negotiations",
|
|
225
|
-
"personal_manager": null,
|
|
226
|
-
"phone": "79012345678",
|
|
227
|
-
"profile_videos": {
|
|
228
|
-
"items": []
|
|
229
|
-
},
|
|
230
|
-
"resumes_url": "https://api.hh.ru/resumes/mine"
|
|
231
|
-
}
|
|
232
268
|
```
|
|
233
|
-
|
|
234
|
-
Токен выдается на две недели:
|
|
235
|
-
|
|
236
|
-
```python
|
|
237
|
-
Python 3.10.9 (main, Dec 19 2022, 17:35:49) [GCC 12.2.0] on linux
|
|
238
|
-
Type "help", "copyright", "credits" or "license" for more information.
|
|
239
|
-
>>> from datetime import datetime, timedelta
|
|
240
|
-
>>> datetime.now() + timedelta(seconds=1209599)
|
|
241
|
-
datetime.datetime(2023, 3, 23, 6, 36, 15, 596290)
|
|
242
|
-
>>>
|
|
269
|
+
{Здоров|Привет}, {как {ты|сам}|что делаешь}?
|
|
243
270
|
```
|
|
244
271
|
|
|
245
|
-
|
|
272
|
+
В итоге получится что-то типа:
|
|
246
273
|
|
|
247
|
-
```bash
|
|
248
|
-
$ hh-applicant-tool refresh-token
|
|
249
274
|
```
|
|
250
|
-
|
|
251
|
-
Удаление хвостов:
|
|
252
|
-
|
|
253
|
-
```bash
|
|
254
|
-
rm -rf ~/.config/hh-applicant-tool
|
|
255
|
-
|
|
256
|
-
# В старых версиях добавлялся обработчик протокола через socat
|
|
257
|
-
rm -f ~/.local/share/applications/hhandroid.desktop
|
|
275
|
+
Привет, как ты?
|
|
258
276
|
```
|
|
259
277
|
|
|
278
|
+
### Написание плагинов
|
|
279
|
+
|
|
260
280
|
Утилита использует систему плагинов. Все они лежат в [operations](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations). Модули расположенные там автоматически добавляются как доступные операции. За основу для своего плагина можно взять [whoami.py](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations/whoami.py).
|
|
261
281
|
|
|
262
282
|
Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
|
|
263
283
|
|
|
284
|
+
### Сбор данных
|
|
285
|
+
|
|
264
286
|
Утилита собирает и передает на сервер разработчика следующую информацию:
|
|
265
287
|
|
|
266
288
|
1. Название вакансии.
|
|
@@ -270,7 +292,7 @@ rm -f ~/.local/share/applications/hhandroid.desktop
|
|
|
270
292
|
1. Прямая ссылка на вакансию.
|
|
271
293
|
1. Дата создания вакансии.
|
|
272
294
|
1. Дата публикации вакансии.
|
|
273
|
-
1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ,
|
|
295
|
+
1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
|
|
274
296
|
1. Название компании.
|
|
275
297
|
1. Тип компании.
|
|
276
298
|
1. Описание компании.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
hh_applicant_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
hh_applicant_tool/__main__.py,sha256=cwKJAAML0RRKT9Qbzcwf07HHcuSd8oh7kx4P1apndWQ,84
|
|
3
3
|
hh_applicant_tool/api/__init__.py,sha256=kgFSHibRaAugN2BA3U1djEa20qgKJUUVouwJzjEB0DU,84
|
|
4
|
-
hh_applicant_tool/api/client.py,sha256=
|
|
4
|
+
hh_applicant_tool/api/client.py,sha256=o_9hDdtCH3xHgBKwqoriNy0wncgUANDR8y--ZxVPQGM,8112
|
|
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=B_kI9MlaT_064r5CL7Pjlzu76QPB-hXCaMpFtB-BOfg,4596
|
|
9
9
|
hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
hh_applicant_tool/operations/apply_similar.py,sha256=
|
|
10
|
+
hh_applicant_tool/operations/apply_similar.py,sha256=9dPvO_3o9rGgaUKxEChgc1cL69YpXuJY8tpP_4iD6TY,17249
|
|
11
11
|
hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
|
|
12
12
|
hh_applicant_tool/operations/call_api.py,sha256=qwPDrWP9tiqHkaYYWYBZtymj9AaxObB86Eny-Bf5q_c,1314
|
|
13
13
|
hh_applicant_tool/operations/clear_negotiations.py,sha256=98Yuw9xP4dN5sUnUKlZxqfhU40TQ-aCjK4vj4LRTeYo,3894
|
|
@@ -15,10 +15,10 @@ hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOv
|
|
|
15
15
|
hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
|
|
16
16
|
hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
|
|
17
17
|
hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
|
|
18
|
-
hh_applicant_tool/telemetry_client.py,sha256=
|
|
18
|
+
hh_applicant_tool/telemetry_client.py,sha256=8R5cdB8971j1rz0v0nhh1TBbqHHD9LYdBUnd5sh-kik,2165
|
|
19
19
|
hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
|
|
20
|
-
hh_applicant_tool/utils.py,sha256=
|
|
21
|
-
hh_applicant_tool-0.3.
|
|
22
|
-
hh_applicant_tool-0.3.
|
|
23
|
-
hh_applicant_tool-0.3.
|
|
24
|
-
hh_applicant_tool-0.3.
|
|
20
|
+
hh_applicant_tool/utils.py,sha256=lHQh94CEwWp14Ty50ecZPcR3YyqLDVlmgmZlrBiBgHQ,2557
|
|
21
|
+
hh_applicant_tool-0.3.8.dist-info/METADATA,sha256=8Y-ENQv2YFch_NRkreNl594ZqtAygTcVdNGTxBcHk8M,20561
|
|
22
|
+
hh_applicant_tool-0.3.8.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
23
|
+
hh_applicant_tool-0.3.8.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
|
|
24
|
+
hh_applicant_tool-0.3.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|