hh-applicant-tool 0.3.5__py3-none-any.whl → 0.3.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hh-applicant-tool might be problematic. Click here for more details.
- hh_applicant_tool/api/client.py +28 -4
- hh_applicant_tool/constants.py +1 -1
- hh_applicant_tool/main.py +32 -8
- hh_applicant_tool/operations/apply_similar.py +158 -45
- hh_applicant_tool/operations/call_api.py +3 -7
- hh_applicant_tool/operations/clear_negotiations.py +2 -6
- hh_applicant_tool/operations/list_resumes.py +2 -6
- hh_applicant_tool/operations/update_resumes.py +2 -6
- hh_applicant_tool/operations/whoami.py +2 -6
- hh_applicant_tool/telemetry_client.py +0 -2
- hh_applicant_tool/utils.py +15 -0
- {hh_applicant_tool-0.3.5.dist-info → hh_applicant_tool-0.3.7.dist-info}/METADATA +117 -95
- hh_applicant_tool-0.3.7.dist-info/RECORD +24 -0
- hh_applicant_tool-0.3.5.dist-info/RECORD +0 -24
- {hh_applicant_tool-0.3.5.dist-info → hh_applicant_tool-0.3.7.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.3.5.dist-info → hh_applicant_tool-0.3.7.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/api/client.py
CHANGED
|
@@ -12,11 +12,12 @@ from urllib.parse import urlencode
|
|
|
12
12
|
|
|
13
13
|
import requests
|
|
14
14
|
from requests import Response, Session
|
|
15
|
+
import random
|
|
15
16
|
|
|
16
17
|
from ..constants import (
|
|
17
18
|
ANDROID_CLIENT_ID,
|
|
18
19
|
ANDROID_CLIENT_SECRET,
|
|
19
|
-
|
|
20
|
+
USER_AGENT_TEMPLATE,
|
|
20
21
|
)
|
|
21
22
|
from ..types import AccessToken
|
|
22
23
|
from . import errors
|
|
@@ -38,6 +39,7 @@ class BaseClient:
|
|
|
38
39
|
user_agent: str | None = None
|
|
39
40
|
session: Session | None = None
|
|
40
41
|
previous_request_time: float = 0.0
|
|
42
|
+
delay: float = 0.334
|
|
41
43
|
|
|
42
44
|
def __post_init__(self) -> None:
|
|
43
45
|
self.lock = Lock()
|
|
@@ -46,11 +48,31 @@ class BaseClient:
|
|
|
46
48
|
session.headers.update(
|
|
47
49
|
{
|
|
48
50
|
**self.additional_headers(),
|
|
49
|
-
"User-Agent": self.user_agent or
|
|
51
|
+
"User-Agent": self.user_agent or self.default_user_agent(),
|
|
50
52
|
}
|
|
51
53
|
)
|
|
52
54
|
logger.debug("Default Headers: %r", session.headers)
|
|
53
55
|
|
|
56
|
+
def default_user_agent(self) -> str:
|
|
57
|
+
return USER_AGENT_TEMPLATE % (
|
|
58
|
+
random.choice(["8.0", "8.1", "9", "10", "11", "12"]),
|
|
59
|
+
random.choice(
|
|
60
|
+
[
|
|
61
|
+
"SM-G998B", # Samsung Galaxy S21 Ultra
|
|
62
|
+
"Pixel 6", # Google Pixel 6
|
|
63
|
+
"Mi 11", # Xiaomi Mi 11
|
|
64
|
+
"OnePlus 9", # OnePlus 9
|
|
65
|
+
"P40", # Huawei P40
|
|
66
|
+
"LG G8", # LG G8
|
|
67
|
+
"Xperia 1 II", # Sony Xperia 1 II
|
|
68
|
+
"Moto G Power", # Motorola Moto G Power
|
|
69
|
+
"HTC U12+", # HTC U12+
|
|
70
|
+
"ROG Phone 5", # Asus ROG Phone 5
|
|
71
|
+
]
|
|
72
|
+
),
|
|
73
|
+
random.randint(88, 130),
|
|
74
|
+
)
|
|
75
|
+
|
|
54
76
|
def additional_headers(
|
|
55
77
|
self,
|
|
56
78
|
) -> dict[str, str]:
|
|
@@ -61,7 +83,7 @@ class BaseClient:
|
|
|
61
83
|
method: ALLOWED_METHODS,
|
|
62
84
|
endpoint: str,
|
|
63
85
|
params: dict | None = None,
|
|
64
|
-
delay: float =
|
|
86
|
+
delay: float | None = None,
|
|
65
87
|
**kwargs: Any,
|
|
66
88
|
) -> dict:
|
|
67
89
|
# Не знаю насколько это "правильно"
|
|
@@ -72,7 +94,9 @@ class BaseClient:
|
|
|
72
94
|
with self.lock:
|
|
73
95
|
# На серваке какая-то анти-DDOS система
|
|
74
96
|
if (
|
|
75
|
-
delay := delay
|
|
97
|
+
delay := (self.delay if delay is None else delay)
|
|
98
|
+
- time.monotonic()
|
|
99
|
+
+ self.previous_request_time
|
|
76
100
|
) > 0:
|
|
77
101
|
logger.debug("wait %fs before request", delay)
|
|
78
102
|
time.sleep(delay)
|
hh_applicant_tool/constants.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
USER_AGENT_TEMPLATE = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Mobile Safari/537.36"
|
|
2
2
|
|
|
3
3
|
ANDROID_CLIENT_ID = (
|
|
4
4
|
"HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
|
hh_applicant_tool/main.py
CHANGED
|
@@ -9,14 +9,12 @@ from os import getenv
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from pkgutil import iter_modules
|
|
11
11
|
from typing import Sequence
|
|
12
|
-
|
|
12
|
+
from .api import ApiClient
|
|
13
13
|
from .color_log import ColorHandler
|
|
14
14
|
from .utils import Config, get_config_path
|
|
15
15
|
|
|
16
16
|
DEFAULT_CONFIG_PATH = (
|
|
17
|
-
get_config_path()
|
|
18
|
-
/ __package__.replace("_", "-")
|
|
19
|
-
/ "config.json"
|
|
17
|
+
get_config_path() / __package__.replace("_", "-") / "config.json"
|
|
20
18
|
)
|
|
21
19
|
|
|
22
20
|
logger = logging.getLogger(__package__)
|
|
@@ -35,6 +33,18 @@ OPERATIONS = "operations"
|
|
|
35
33
|
class Namespace(argparse.Namespace):
|
|
36
34
|
config: Config
|
|
37
35
|
verbosity: int
|
|
36
|
+
delay: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_api(args: Namespace) -> ApiClient:
|
|
40
|
+
token = args.config.get("token", {})
|
|
41
|
+
api = ApiClient(
|
|
42
|
+
access_token=token.get("access_token"),
|
|
43
|
+
refresh_token=token.get("refresh_token"),
|
|
44
|
+
user_agent=args.config["user_agent"],
|
|
45
|
+
delay=args.delay,
|
|
46
|
+
)
|
|
47
|
+
return api
|
|
38
48
|
|
|
39
49
|
|
|
40
50
|
class HHApplicantTool:
|
|
@@ -45,32 +55,46 @@ class HHApplicantTool:
|
|
|
45
55
|
Группа поддержки: <https://t.me/+aSjr8qM_AP85ZDBi>
|
|
46
56
|
"""
|
|
47
57
|
|
|
58
|
+
class ArgumentFormatter(
|
|
59
|
+
argparse.ArgumentDefaultsHelpFormatter,
|
|
60
|
+
argparse.RawDescriptionHelpFormatter,
|
|
61
|
+
):
|
|
62
|
+
pass
|
|
63
|
+
|
|
48
64
|
def create_parser(self) -> argparse.ArgumentParser:
|
|
49
65
|
parser = argparse.ArgumentParser(
|
|
50
66
|
description=self.__doc__,
|
|
51
|
-
formatter_class=
|
|
67
|
+
formatter_class=self.ArgumentFormatter,
|
|
52
68
|
)
|
|
53
69
|
parser.add_argument(
|
|
54
70
|
"-c",
|
|
55
71
|
"--config",
|
|
56
|
-
help="
|
|
72
|
+
help="Путь до файла конфигурации",
|
|
57
73
|
type=Config,
|
|
58
74
|
default=Config(DEFAULT_CONFIG_PATH),
|
|
59
75
|
)
|
|
60
76
|
parser.add_argument(
|
|
61
77
|
"-v",
|
|
62
78
|
"--verbosity",
|
|
63
|
-
help="
|
|
79
|
+
help="При использовании от одного и более раз увеличивает количество отладочной информации в выводе",
|
|
64
80
|
action="count",
|
|
65
81
|
default=0,
|
|
66
82
|
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-d",
|
|
85
|
+
"--delay",
|
|
86
|
+
type=float,
|
|
87
|
+
default=0.334,
|
|
88
|
+
help="Задержка между запросами к API HH",
|
|
89
|
+
)
|
|
67
90
|
subparsers = parser.add_subparsers(help="commands")
|
|
68
91
|
package_dir = Path(__file__).resolve().parent / OPERATIONS
|
|
69
92
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
70
93
|
mod = import_module(f"{__package__}.{OPERATIONS}.{module_name}")
|
|
71
94
|
op: BaseOperation = mod.Operation()
|
|
72
95
|
op_parser = subparsers.add_parser(
|
|
73
|
-
module_name.replace("_", "-"),
|
|
96
|
+
module_name.replace("_", "-"),
|
|
97
|
+
description=op.__doc__, formatter_class=self.ArgumentFormatter
|
|
74
98
|
)
|
|
75
99
|
op_parser.set_defaults(run=op.run)
|
|
76
100
|
op.setup_parser(op_parser)
|
|
@@ -8,11 +8,11 @@ from typing import TextIO, Tuple
|
|
|
8
8
|
|
|
9
9
|
from ..api import ApiClient, ApiError, BadRequest
|
|
10
10
|
from ..main import BaseOperation
|
|
11
|
-
from ..main import Namespace as BaseNamespace
|
|
11
|
+
from ..main import Namespace as BaseNamespace, get_api
|
|
12
12
|
from ..telemetry_client import TelemetryError
|
|
13
13
|
from ..telemetry_client import get_client as get_telemetry_client
|
|
14
14
|
from ..types import ApiListResponse, VacancyItem
|
|
15
|
-
from ..utils import fix_datetime, print_err, truncate_string
|
|
15
|
+
from ..utils import fix_datetime, print_err, truncate_string, random_text
|
|
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]:
|
|
@@ -83,16 +99,13 @@ class Operation(BaseOperation):
|
|
|
83
99
|
return min(min_interval, max_interval), max(min_interval, max_interval)
|
|
84
100
|
|
|
85
101
|
def run(self, args: Namespace) -> None:
|
|
86
|
-
|
|
87
|
-
api = ApiClient(
|
|
88
|
-
access_token=args.config["token"]["access_token"],
|
|
89
|
-
user_agent=args.config["user_agent"],
|
|
90
|
-
)
|
|
102
|
+
api = get_api(args)
|
|
91
103
|
resume_id = self._get_resume_id(args, api)
|
|
92
104
|
application_messages = self._get_application_messages(args)
|
|
93
105
|
|
|
94
106
|
apply_min_interval, apply_max_interval = args.apply_interval
|
|
95
107
|
page_min_interval, page_max_interval = args.page_interval
|
|
108
|
+
message_min_interval, message_max_interval = args.message_interval
|
|
96
109
|
|
|
97
110
|
self._apply_similar(
|
|
98
111
|
api,
|
|
@@ -103,8 +116,11 @@ class Operation(BaseOperation):
|
|
|
103
116
|
apply_max_interval,
|
|
104
117
|
page_min_interval,
|
|
105
118
|
page_max_interval,
|
|
119
|
+
message_min_interval,
|
|
120
|
+
message_max_interval,
|
|
106
121
|
args.order_by,
|
|
107
122
|
args.search,
|
|
123
|
+
args.reply_message or args.config["reply_message"],
|
|
108
124
|
)
|
|
109
125
|
|
|
110
126
|
def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
|
|
@@ -122,11 +138,8 @@ class Operation(BaseOperation):
|
|
|
122
138
|
)
|
|
123
139
|
else:
|
|
124
140
|
application_messages = [
|
|
125
|
-
"Меня
|
|
126
|
-
"Прошу рассмотреть мою
|
|
127
|
-
"Ваша вакансия %(name)s соответствует моим навыкам и опыту",
|
|
128
|
-
"Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
|
|
129
|
-
"Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
|
|
141
|
+
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
|
|
142
|
+
"{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
|
|
130
143
|
]
|
|
131
144
|
return application_messages
|
|
132
145
|
|
|
@@ -140,8 +153,11 @@ class Operation(BaseOperation):
|
|
|
140
153
|
apply_max_interval: float,
|
|
141
154
|
page_min_interval: float,
|
|
142
155
|
page_max_interval: float,
|
|
156
|
+
message_min_interval: float,
|
|
157
|
+
message_max_interval: float,
|
|
143
158
|
order_by: str,
|
|
144
159
|
search: str | None = None,
|
|
160
|
+
reply_message: str | None = None,
|
|
145
161
|
) -> None:
|
|
146
162
|
telemetry_client = get_telemetry_client()
|
|
147
163
|
telemetry_data = defaultdict(dict)
|
|
@@ -158,33 +174,124 @@ class Operation(BaseOperation):
|
|
|
158
174
|
|
|
159
175
|
self._collect_vacancy_telemetry(telemetry_data, vacancies)
|
|
160
176
|
|
|
177
|
+
me = api.get("/me")
|
|
178
|
+
|
|
179
|
+
basic_message_placeholders = {
|
|
180
|
+
"first_name": me.get("first_name", ""),
|
|
181
|
+
"last_name": me.get("last_name", ""),
|
|
182
|
+
"email": me.get("email", ""),
|
|
183
|
+
"phone": me.get("phone", ""),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
do_apply = True
|
|
187
|
+
|
|
161
188
|
for vacancy in vacancies:
|
|
162
189
|
try:
|
|
163
190
|
if getenv("TEST_TELEMETRY"):
|
|
164
191
|
break
|
|
165
192
|
|
|
193
|
+
message_placeholders = {
|
|
194
|
+
"vacancy_name": vacancy.get("name", ""),
|
|
195
|
+
"employer_name": vacancy.get("employer", {}).get(
|
|
196
|
+
"name", ""
|
|
197
|
+
),
|
|
198
|
+
**basic_message_placeholders,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Вакансия %(vacancy_name)s от %(employer_name)s"
|
|
203
|
+
% message_placeholders
|
|
204
|
+
)
|
|
205
|
+
|
|
166
206
|
if vacancy.get("has_test"):
|
|
167
207
|
print("🚫 Пропускаем тест", vacancy["alternate_url"])
|
|
168
208
|
continue
|
|
169
209
|
|
|
210
|
+
if vacancy.get("archived"):
|
|
211
|
+
print(
|
|
212
|
+
"🚫 Пропускаем вакансию в архиве",
|
|
213
|
+
vacancy["alternate_url"],
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
continue
|
|
217
|
+
|
|
170
218
|
relations = vacancy.get("relations", [])
|
|
171
219
|
|
|
172
220
|
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
|
+
|
|
173
281
|
print(
|
|
174
|
-
"🚫 Пропускаем
|
|
282
|
+
"🚫 Пропускаем вакансию с откликом",
|
|
175
283
|
vacancy["alternate_url"],
|
|
176
284
|
)
|
|
177
285
|
continue
|
|
178
286
|
|
|
179
|
-
|
|
180
|
-
employer_id = vacancy["employer"]["id"]
|
|
181
|
-
except IndexError:
|
|
182
|
-
logger.warning(
|
|
183
|
-
f"Вакансия без работодателя: {vacancy['alternate_url']}"
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
employer = api.get(f"/employers/{employer_id}")
|
|
287
|
+
employer_id = vacancy.get("employer", {}).get("id")
|
|
187
288
|
|
|
289
|
+
if (
|
|
290
|
+
employer_id
|
|
291
|
+
and employer_id not in telemetry_data["employers"]
|
|
292
|
+
and 200 > len(telemetry_data["employers"])
|
|
293
|
+
):
|
|
294
|
+
employer = api.get(f"/employers/{employer_id}")
|
|
188
295
|
telemetry_data["employers"][employer_id] = {
|
|
189
296
|
"name": employer.get("name"),
|
|
190
297
|
"type": employer.get("type"),
|
|
@@ -193,11 +300,9 @@ class Operation(BaseOperation):
|
|
|
193
300
|
"area": employer.get("area", {}).get("name"), # город
|
|
194
301
|
}
|
|
195
302
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
200
|
-
time.sleep(interval)
|
|
303
|
+
if not do_apply:
|
|
304
|
+
logger.debug("skip apply similar")
|
|
305
|
+
continue
|
|
201
306
|
|
|
202
307
|
params = {
|
|
203
308
|
"resume_id": resume_id,
|
|
@@ -205,19 +310,23 @@ class Operation(BaseOperation):
|
|
|
205
310
|
"message": "",
|
|
206
311
|
}
|
|
207
312
|
|
|
208
|
-
if vacancy.get("response_letter_required"):
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
313
|
+
if force_message or vacancy.get("response_letter_required"):
|
|
314
|
+
msg = params["message"] = (
|
|
315
|
+
random_text(random.choice(application_messages))
|
|
316
|
+
% message_placeholders
|
|
317
|
+
)
|
|
318
|
+
logger.debug(msg)
|
|
319
|
+
|
|
320
|
+
# Задержка перед отправкой отклика
|
|
321
|
+
interval = random.uniform(
|
|
322
|
+
max(apply_min_interval, message_min_interval)
|
|
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,
|
|
328
|
+
)
|
|
329
|
+
time.sleep(interval)
|
|
221
330
|
|
|
222
331
|
res = api.post("/negotiations", params)
|
|
223
332
|
assert res == {}
|
|
@@ -229,12 +338,16 @@ class Operation(BaseOperation):
|
|
|
229
338
|
")",
|
|
230
339
|
)
|
|
231
340
|
except ApiError as ex:
|
|
232
|
-
|
|
341
|
+
logger.error(ex)
|
|
233
342
|
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
234
|
-
|
|
343
|
+
if not reply_message:
|
|
344
|
+
break
|
|
345
|
+
do_apply = False
|
|
235
346
|
|
|
236
347
|
print("📝 Отклики на вакансии разосланы!")
|
|
237
348
|
|
|
349
|
+
# Я собираюсь задеанонить всех хрюш яндексов и прочей хуеты, которую
|
|
350
|
+
# считаю вселенским злом, так что телеметирию не трогайте
|
|
238
351
|
self._send_telemetry(telemetry_client, telemetry_data)
|
|
239
352
|
|
|
240
353
|
def _get_vacancies(
|
|
@@ -4,8 +4,8 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
-
from ..api import
|
|
8
|
-
from ..main import BaseOperation
|
|
7
|
+
from ..api import ApiError
|
|
8
|
+
from ..main import BaseOperation, get_api
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__package__)
|
|
@@ -34,11 +34,7 @@ class Operation(BaseOperation):
|
|
|
34
34
|
)
|
|
35
35
|
|
|
36
36
|
def run(self, args: Namespace) -> None:
|
|
37
|
-
|
|
38
|
-
api = ApiClient(
|
|
39
|
-
access_token=args.config["token"]["access_token"],
|
|
40
|
-
user_agent=args.config["user_agent"],
|
|
41
|
-
)
|
|
37
|
+
api = get_api(args)
|
|
42
38
|
params = dict(x.split("=", 1) for x in args.param)
|
|
43
39
|
try:
|
|
44
40
|
result = api.request(args.method, args.endpoint, params=params)
|
|
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
5
5
|
|
|
6
6
|
from ..api import ApiClient, ClientError
|
|
7
7
|
from ..constants import INVALID_ISO8601_FORMAT
|
|
8
|
-
from ..main import BaseOperation
|
|
8
|
+
from ..main import BaseOperation, get_api
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
10
|
from ..types import ApiListResponse
|
|
11
11
|
from ..utils import print_err, truncate_string
|
|
@@ -51,11 +51,7 @@ class Operation(BaseOperation):
|
|
|
51
51
|
return rv
|
|
52
52
|
|
|
53
53
|
def run(self, args: Namespace) -> None:
|
|
54
|
-
|
|
55
|
-
api = ApiClient(
|
|
56
|
-
access_token=args.config["token"]["access_token"],
|
|
57
|
-
user_agent=args.config["user_agent"],
|
|
58
|
-
)
|
|
54
|
+
api = get_api(args)
|
|
59
55
|
negotiations = self._get_active_negotiations(api)
|
|
60
56
|
print("Всего активных:", len(negotiations))
|
|
61
57
|
for item in negotiations:
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
from prettytable import PrettyTable
|
|
6
6
|
|
|
7
7
|
from ..api import ApiClient
|
|
8
|
-
from ..main import BaseOperation
|
|
8
|
+
from ..main import BaseOperation, get_api
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
10
|
from ..types import ApiListResponse
|
|
11
11
|
from ..utils import truncate_string
|
|
@@ -24,11 +24,7 @@ class Operation(BaseOperation):
|
|
|
24
24
|
pass
|
|
25
25
|
|
|
26
26
|
def run(self, args: Namespace) -> None:
|
|
27
|
-
|
|
28
|
-
api = ApiClient(
|
|
29
|
-
access_token=args.config["token"]["access_token"],
|
|
30
|
-
user_agent=args.config["user_agent"],
|
|
31
|
-
)
|
|
27
|
+
api = get_api(args)
|
|
32
28
|
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
33
29
|
t = PrettyTable(
|
|
34
30
|
field_names=["ID", "Название", "Статус"], align="l", valign="t"
|
|
@@ -3,7 +3,7 @@ import argparse
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
from ..api import ApiClient, ApiError
|
|
6
|
-
from ..main import BaseOperation
|
|
6
|
+
from ..main import BaseOperation, get_api
|
|
7
7
|
from ..main import Namespace as BaseNamespace
|
|
8
8
|
from ..types import ApiListResponse
|
|
9
9
|
from ..utils import print_err, truncate_string
|
|
@@ -22,11 +22,7 @@ class Operation(BaseOperation):
|
|
|
22
22
|
pass
|
|
23
23
|
|
|
24
24
|
def run(self, args: Namespace) -> None:
|
|
25
|
-
|
|
26
|
-
api = ApiClient(
|
|
27
|
-
access_token=args.config["token"]["access_token"],
|
|
28
|
-
user_agent=args.config["user_agent"],
|
|
29
|
-
)
|
|
25
|
+
api = get_api(args)
|
|
30
26
|
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
31
27
|
for resume in resumes["items"]:
|
|
32
28
|
try:
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
from ..api import ApiClient
|
|
7
|
-
from ..main import BaseOperation
|
|
7
|
+
from ..main import BaseOperation, get_api
|
|
8
8
|
from ..main import Namespace as BaseNamespace
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__package__)
|
|
@@ -21,10 +21,6 @@ class Operation(BaseOperation):
|
|
|
21
21
|
pass
|
|
22
22
|
|
|
23
23
|
def run(self, args: Namespace) -> None:
|
|
24
|
-
|
|
25
|
-
api = ApiClient(
|
|
26
|
-
access_token=args.config["token"]["access_token"],
|
|
27
|
-
user_agent=args.config["user_agent"],
|
|
28
|
-
)
|
|
24
|
+
api = get_api(args)
|
|
29
25
|
result = api.get("/me")
|
|
30
26
|
print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True))
|
|
@@ -51,8 +51,6 @@ class TelemetryClient:
|
|
|
51
51
|
:raises TelemetryError: Если произошла ошибка при отправке или декодировании JSON.
|
|
52
52
|
"""
|
|
53
53
|
url = urljoin(self.server_address, endpoint)
|
|
54
|
-
logger.debug(data)
|
|
55
|
-
|
|
56
54
|
try:
|
|
57
55
|
response = self.session.post(url, json=data)
|
|
58
56
|
# response.raise_for_status()
|
hh_applicant_tool/utils.py
CHANGED
|
@@ -10,6 +10,7 @@ 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, random
|
|
13
14
|
|
|
14
15
|
print_err = partial(print, file=sys.stderr, flush=True)
|
|
15
16
|
|
|
@@ -72,3 +73,17 @@ def fix_datetime(dt: str | None) -> str | None:
|
|
|
72
73
|
if dt is None:
|
|
73
74
|
return None
|
|
74
75
|
return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def random_text(s: str) -> str:
|
|
79
|
+
while (
|
|
80
|
+
s1 := re.sub(
|
|
81
|
+
r"{([^{}]+)}",
|
|
82
|
+
lambda m: random.choice(
|
|
83
|
+
m.group(1).split("|"),
|
|
84
|
+
),
|
|
85
|
+
s,
|
|
86
|
+
)
|
|
87
|
+
) != s:
|
|
88
|
+
s = s1
|
|
89
|
+
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.7
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -19,8 +19,6 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
|
|
20
20
|
## HH Applicant Tool
|
|
21
21
|
|
|
22
|
-
> ! Наложен мораторий на доработки/переработки. Разработка будет возобновлена после 100 звезд 💫
|
|
23
|
-
|
|
24
22
|

|
|
25
23
|
[]()
|
|
26
24
|
[]()
|
|
@@ -32,19 +30,21 @@ Description-Content-Type: text/markdown
|
|
|
32
30
|
<img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
|
|
33
31
|
</div>
|
|
34
32
|
|
|
33
|
+
### Описание
|
|
34
|
+
|
|
35
35
|
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/+aSjr8qM_AP85ZDBi (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
|
|
36
36
|
|
|
37
37
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
38
38
|
asdf/pyenv/conda и что-то еще...
|
|
39
39
|
|
|
40
|
-
Данная утилита написана для 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` токены из официального приложения и добавить их в конфиг.
|
|
41
41
|
|
|
42
42
|
Пример работы:
|
|
43
43
|
|
|
44
44
|

|
|
45
45
|
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
### Предыстория
|
|
48
48
|
|
|
49
49
|
Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
|
|
50
50
|
|
|
@@ -56,7 +56,7 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
|
|
|
56
56
|
|
|
57
57
|
Оно работает, хоть и не идеально. Я даже пробовал автоматизировать рассылки через `p[yu]ppeeter`, пока не прочитал [документацию](https://github.com/hhru/api). И не обнаружил, что **API** (интерфейс) содержит все необходимые мне методы. Headhunter позволяет создать свое приложение, но там ручная модерация, и наврядли кто-то разрешит мне создать приложение для спама заявками. Я [декомпилировал](https://gist.github.com/s3rgeym/eee96bbf91b04f7eb46b7449f8884a00) официальное приложение для **Android** и получил **CLIENT_ID** и **CLIENT_SECRET**, необходимые для работы через **API**.
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
### Установка
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
62
|
# Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
|
|
@@ -72,18 +72,18 @@ $ pipx upgrade hh-applicant-tool
|
|
|
72
72
|
|
|
73
73
|
Отдельно я распишу процесс установки в **Windows** в подробностях:
|
|
74
74
|
|
|
75
|
-
* Для начала поставьте Python 3 любым удобным способом.
|
|
76
|
-
* Запустите
|
|
75
|
+
* Для начала поставьте последнюю версию **Python 3** любым удобным способом.
|
|
76
|
+
* Запустите **Terminal** или **PowerShell** от Администратора и выполните:
|
|
77
77
|
```ps
|
|
78
78
|
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Unrestricted
|
|
79
79
|
```
|
|
80
|
-
Без
|
|
80
|
+
Данная политика разрешает текущему пользователю (от которого зашли) запускать скрипты. Без нее не будут работать виртуальные окружения.
|
|
81
81
|
* Создайте и активируйте виртуальное окружение:
|
|
82
82
|
```ps
|
|
83
83
|
PS> python -m pip venv hh-applicant-venv
|
|
84
84
|
PS> .\hh-applicant-venv\Scripts\activate
|
|
85
85
|
```
|
|
86
|
-
* Поставьте все пакеты в виртуальное окружение `hh-applicant-
|
|
86
|
+
* Поставьте все пакеты в виртуальное окружение `hh-applicant-venv`:
|
|
87
87
|
```ps
|
|
88
88
|
(hh-applicant-venv) PS> pip install hh-applicant-tool[qt]
|
|
89
89
|
```
|
|
@@ -91,11 +91,92 @@ $ pipx upgrade hh-applicant-tool
|
|
|
91
91
|
```ps
|
|
92
92
|
(hh-applicant-venv) PS> hh-applicant-tool -h
|
|
93
93
|
```
|
|
94
|
-
* В случае
|
|
95
|
-
|
|
94
|
+
* В случае неудачи вернитесь к первому шагу.
|
|
95
|
+
* Для последующих запусков сначала активируйте виртуальное окружение.
|
|
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
|
+
Через этот файл, например, можно задать кастомный `user_agent`:
|
|
96
172
|
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"user_agent": "Mozilla/5.0 YablanBrowser"
|
|
176
|
+
}
|
|
177
|
+
```
|
|
97
178
|
|
|
98
|
-
|
|
179
|
+
### Описание команд
|
|
99
180
|
|
|
100
181
|
```bash
|
|
101
182
|
$ hh-applicant-tool [ GLOBAL_FLAGS ] [ OPERATION [ OPERATION_FLAGS ] ]
|
|
@@ -158,111 +239,52 @@ https://hh.ru/employer/1918903
|
|
|
158
239
|
| **whoami** | Выводит информацию об авторизованном пользователе |
|
|
159
240
|
| **list-resumes** | Список резюме |
|
|
160
241
|
| **update-resumes** | Обновить все резюме. Аналогично нажатию кнопки «Обновить дату». |
|
|
161
|
-
| **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в
|
|
242
|
+
| **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в день. На HH есть спам-фильтры, так что лучше не рассылайте отклики со ссылками. |
|
|
162
243
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
163
244
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
164
245
|
| **refresh-token** | Обновляет access_token. |
|
|
246
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, с сайта фирмы и могут быть удалены только по просьбе уполнамоченного лица. Данная функция готова и будет доступна после 100 ⭐ |
|
|
165
247
|
|
|
166
|
-
|
|
248
|
+
### Формат текста сообщений
|
|
167
249
|
|
|
168
|
-
|
|
169
|
-
$ hh-applicant-tool -vv authorize
|
|
170
|
-
```
|
|
250
|
+
Команда `apply-similar` поддерживает специальный формат сообщений.
|
|
171
251
|
|
|
172
|
-
|
|
252
|
+
Так же в сообщении можно использовать плейсхолдеры:
|
|
173
253
|
|
|
174
|
-
|
|
254
|
+
- **`%(vacancy_name)s`**: Название вакансии.
|
|
255
|
+
- **`%(employer_name)s`**: Название работодателя.
|
|
256
|
+
- **`%(first_name)s`**: Имя пользователя.
|
|
257
|
+
- **`%(last_name)s`**: Фамилия пользователя.
|
|
258
|
+
- **`%(email)s`**: Email пользователя.
|
|
259
|
+
- **`%(phone)s`**: Телефон пользователя.
|
|
175
260
|
|
|
261
|
+
Эти плейсхолдеры могут быть использованы в сообщениях для отклика на вакансии, чтобы динамически подставлять соответствующие данные в текст сообщения. Например:
|
|
176
262
|
|
|
177
|
-
|
|
178
|
-
В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
|
|
179
|
-
|
|
180
|
-
```json
|
|
181
|
-
{
|
|
182
|
-
"token": {
|
|
183
|
-
"access_token": "...",
|
|
184
|
-
"created_at": 1678151427,
|
|
185
|
-
"expires_in": 1209599,
|
|
186
|
-
"refresh_token": "...",
|
|
187
|
-
"token_type": "bearer"
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
263
|
```
|
|
191
|
-
|
|
192
|
-
Через этот файл можно задать кастомный `user_agent`:
|
|
193
|
-
|
|
194
|
-
```json
|
|
195
|
-
{
|
|
196
|
-
"user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/110.0"
|
|
197
|
-
}
|
|
264
|
+
"Меня заинтересовала ваша вакансия %(vacancy_name)s. Прошу рассмотреть мою кандидатуру. С уважением, %(first_name)s %(last_name)s."
|
|
198
265
|
```
|
|
199
266
|
|
|
200
|
-
|
|
267
|
+
Так же можно делать текст уникальным с помощью `{}`. Внутри них через `|` перечисляются варианты, один из которых будет случайно выбран:
|
|
201
268
|
|
|
202
|
-
```bash
|
|
203
|
-
$ hh-applicant-tool whoami
|
|
204
|
-
{
|
|
205
|
-
"auth_type": "applicant",
|
|
206
|
-
"counters": {
|
|
207
|
-
"new_resume_views": 1488,
|
|
208
|
-
"resumes_count": 1,
|
|
209
|
-
"unread_negotiations": 228
|
|
210
|
-
},
|
|
211
|
-
"email": "vasya.pupkin@gmail.com",
|
|
212
|
-
"employer": null,
|
|
213
|
-
"first_name": "Вася",
|
|
214
|
-
"id": "1234567890",
|
|
215
|
-
"is_admin": false,
|
|
216
|
-
"is_anonymous": false,
|
|
217
|
-
"is_applicant": true,
|
|
218
|
-
"is_application": false,
|
|
219
|
-
"is_employer": false,
|
|
220
|
-
"is_in_search": true,
|
|
221
|
-
"last_name": "Пупкин",
|
|
222
|
-
"manager": null,
|
|
223
|
-
"mid_name": null,
|
|
224
|
-
"middle_name": null,
|
|
225
|
-
"negotiations_url": "https://api.hh.ru/negotiations",
|
|
226
|
-
"personal_manager": null,
|
|
227
|
-
"phone": "79012345678",
|
|
228
|
-
"profile_videos": {
|
|
229
|
-
"items": []
|
|
230
|
-
},
|
|
231
|
-
"resumes_url": "https://api.hh.ru/resumes/mine"
|
|
232
|
-
}
|
|
233
269
|
```
|
|
234
|
-
|
|
235
|
-
Токен выдается на две недели:
|
|
236
|
-
|
|
237
|
-
```python
|
|
238
|
-
Python 3.10.9 (main, Dec 19 2022, 17:35:49) [GCC 12.2.0] on linux
|
|
239
|
-
Type "help", "copyright", "credits" or "license" for more information.
|
|
240
|
-
>>> from datetime import datetime, timedelta
|
|
241
|
-
>>> datetime.now() + timedelta(seconds=1209599)
|
|
242
|
-
datetime.datetime(2023, 3, 23, 6, 36, 15, 596290)
|
|
243
|
-
>>>
|
|
270
|
+
{Здоров|Привет}, {как {ты|сам}|что делаешь}?
|
|
244
271
|
```
|
|
245
272
|
|
|
246
|
-
|
|
273
|
+
В итоге получится что-то типа:
|
|
247
274
|
|
|
248
|
-
```bash
|
|
249
|
-
$ hh-applicant-tool refresh-token
|
|
250
275
|
```
|
|
251
|
-
|
|
252
|
-
Удаление хвостов:
|
|
253
|
-
|
|
254
|
-
```bash
|
|
255
|
-
rm -rf ~/.config/hh-applicant-tool
|
|
256
|
-
|
|
257
|
-
# В старых версиях добавлялся обработчик протокола через socat
|
|
258
|
-
rm -f ~/.local/share/applications/hhandroid.desktop
|
|
276
|
+
Привет, как ты?
|
|
259
277
|
```
|
|
260
278
|
|
|
279
|
+
### Написание плагинов
|
|
280
|
+
|
|
261
281
|
Утилита использует систему плагинов. Все они лежат в [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).
|
|
262
282
|
|
|
263
283
|
Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
|
|
264
284
|
|
|
265
|
-
|
|
285
|
+
### Сбор данных
|
|
286
|
+
|
|
287
|
+
Утилита собирает и передает на сервер разработчика следующую информацию:
|
|
266
288
|
|
|
267
289
|
1. Название вакансии.
|
|
268
290
|
1. Тип вакансии (открытая/закрытая).
|
|
@@ -271,7 +293,7 @@ rm -f ~/.local/share/applications/hhandroid.desktop
|
|
|
271
293
|
1. Прямая ссылка на вакансию.
|
|
272
294
|
1. Дата создания вакансии.
|
|
273
295
|
1. Дата публикации вакансии.
|
|
274
|
-
1. Контактная информация
|
|
296
|
+
1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
|
|
275
297
|
1. Название компании.
|
|
276
298
|
1. Тип компании.
|
|
277
299
|
1. Описание компании.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
hh_applicant_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
hh_applicant_tool/__main__.py,sha256=cwKJAAML0RRKT9Qbzcwf07HHcuSd8oh7kx4P1apndWQ,84
|
|
3
|
+
hh_applicant_tool/api/__init__.py,sha256=kgFSHibRaAugN2BA3U1djEa20qgKJUUVouwJzjEB0DU,84
|
|
4
|
+
hh_applicant_tool/api/client.py,sha256=c0XBEQIS-kPi2JeS9TmgcO8ZyOjV6HsgiwZRcKUOQCI,7927
|
|
5
|
+
hh_applicant_tool/api/errors.py,sha256=0SoWKnsSUA0K2YgQA8GwGhe-pRMwtfK94MR6_MgbORQ,1722
|
|
6
|
+
hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3s,823
|
|
7
|
+
hh_applicant_tool/constants.py,sha256=lpgKkP2chWgTnBXvzxbSPWpKcfzp8VxMTlkssFcQhH4,469
|
|
8
|
+
hh_applicant_tool/main.py,sha256=sL9eSWUkOz-NJbkq8PxRluvXUh5AqLVi7qmrdN1YAKY,3996
|
|
9
|
+
hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
hh_applicant_tool/operations/apply_similar.py,sha256=SbSrZPbQkOcDqbuV8mqX9lMRsvBYLwq3xVnpQcbM3Q0,17166
|
|
11
|
+
hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
|
|
12
|
+
hh_applicant_tool/operations/call_api.py,sha256=qwPDrWP9tiqHkaYYWYBZtymj9AaxObB86Eny-Bf5q_c,1314
|
|
13
|
+
hh_applicant_tool/operations/clear_negotiations.py,sha256=98Yuw9xP4dN5sUnUKlZxqfhU40TQ-aCjK4vj4LRTeYo,3894
|
|
14
|
+
hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOvAULnLkLkyUh7gxw,1134
|
|
15
|
+
hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
|
|
16
|
+
hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
|
|
17
|
+
hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
|
|
18
|
+
hh_applicant_tool/telemetry_client.py,sha256=1jgbc8oMfLhbEi2pTA2fF0pKlHSWekHY3oEJCDI8Uas,2268
|
|
19
|
+
hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
|
|
20
|
+
hh_applicant_tool/utils.py,sha256=DKD1b4mItuUugP6aV2vEoO59cIU2mJp5twc8WdvaSA4,2551
|
|
21
|
+
hh_applicant_tool-0.3.7.dist-info/METADATA,sha256=hYTuJoXxmRrCEnvcvdJz4AJEFKm6nHL6o2udu0Bi1bI,20109
|
|
22
|
+
hh_applicant_tool-0.3.7.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
23
|
+
hh_applicant_tool-0.3.7.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
|
|
24
|
+
hh_applicant_tool-0.3.7.dist-info/RECORD,,
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
hh_applicant_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
hh_applicant_tool/__main__.py,sha256=cwKJAAML0RRKT9Qbzcwf07HHcuSd8oh7kx4P1apndWQ,84
|
|
3
|
-
hh_applicant_tool/api/__init__.py,sha256=kgFSHibRaAugN2BA3U1djEa20qgKJUUVouwJzjEB0DU,84
|
|
4
|
-
hh_applicant_tool/api/client.py,sha256=z_YMsd5zL4-1_aIbkEKqm_1m_mZkm3BMxlAQuCoNj2Y,7040
|
|
5
|
-
hh_applicant_tool/api/errors.py,sha256=0SoWKnsSUA0K2YgQA8GwGhe-pRMwtfK94MR6_MgbORQ,1722
|
|
6
|
-
hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3s,823
|
|
7
|
-
hh_applicant_tool/constants.py,sha256=YdNz0CF4swJ9OO5vVpOZ39RP-05xSU0Ghw4Y6BhISoE,468
|
|
8
|
-
hh_applicant_tool/main.py,sha256=XLKsd8mpCKuFoP3ESMpgJKdGL3m6tOQ8kKrOzCN-dwA,3075
|
|
9
|
-
hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
-
hh_applicant_tool/operations/apply_similar.py,sha256=RUV-hVyZZGEBBM7sfi7Ssg5c4plyc2JNETlL5PoDpPY,12875
|
|
11
|
-
hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
|
|
12
|
-
hh_applicant_tool/operations/call_api.py,sha256=oWAfvy4LwbsQ8HsgI_3en3sMTlu3ZWU7NzpxssrUNSU,1472
|
|
13
|
-
hh_applicant_tool/operations/clear_negotiations.py,sha256=5ybdJMUfV9XYxnn2y4zvWdS_ZE8yXAbFJoXOMyqetw8,4041
|
|
14
|
-
hh_applicant_tool/operations/list_resumes.py,sha256=HYxjDALrWl_YBIitDldhhEs-WO_0e_2FGw586MSdsuE,1281
|
|
15
|
-
hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
|
|
16
|
-
hh_applicant_tool/operations/update_resumes.py,sha256=FKtwEL7i0vaOxLEn1nARi72SuMYB5VjN6Fihe6rlt-Y,1196
|
|
17
|
-
hh_applicant_tool/operations/whoami.py,sha256=kdLQ_FjYzpJPKxFlocxf7vgXhW1zocb0bd5IAWmsQuA,861
|
|
18
|
-
hh_applicant_tool/telemetry_client.py,sha256=TlsNKlclPyJqLPO0xHkHKBIhT8bmgx1ZBup4PjE8w5E,2296
|
|
19
|
-
hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
|
|
20
|
-
hh_applicant_tool/utils.py,sha256=wXcxs5IinK7PH6ipCCpngw7faUiboEpi9a8j1wDEPKw,2282
|
|
21
|
-
hh_applicant_tool-0.3.5.dist-info/METADATA,sha256=qUVl-yMhKOXWP6AhIIwdu5AFYX05npx9lQvsRMXYbx8,17247
|
|
22
|
-
hh_applicant_tool-0.3.5.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
23
|
-
hh_applicant_tool-0.3.5.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
|
|
24
|
-
hh_applicant_tool-0.3.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|