hh-applicant-tool 0.3.1__py3-none-any.whl → 0.3.3__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 +3 -3
- hh_applicant_tool/operations/apply_similar.py +129 -65
- hh_applicant_tool/telemetry_client.py +73 -0
- hh_applicant_tool/utils.py +32 -6
- {hh_applicant_tool-0.3.1.dist-info → hh_applicant_tool-0.3.3.dist-info}/METADATA +38 -15
- {hh_applicant_tool-0.3.1.dist-info → hh_applicant_tool-0.3.3.dist-info}/RECORD +8 -7
- {hh_applicant_tool-0.3.1.dist-info → hh_applicant_tool-0.3.3.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.3.1.dist-info → hh_applicant_tool-0.3.3.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/main.py
CHANGED
|
@@ -11,10 +11,10 @@ from pkgutil import iter_modules
|
|
|
11
11
|
from typing import Sequence
|
|
12
12
|
|
|
13
13
|
from .color_log import ColorHandler
|
|
14
|
-
from .utils import Config
|
|
14
|
+
from .utils import Config, get_config_path
|
|
15
15
|
|
|
16
16
|
DEFAULT_CONFIG_PATH = (
|
|
17
|
-
|
|
17
|
+
get_config_path()
|
|
18
18
|
/ __package__.replace("_", "-")
|
|
19
19
|
/ "config.json"
|
|
20
20
|
)
|
|
@@ -42,7 +42,7 @@ class HHApplicantTool:
|
|
|
42
42
|
|
|
43
43
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
44
44
|
|
|
45
|
-
Группа поддержки: <https://t.me
|
|
45
|
+
Группа поддержки: <https://t.me/+aSjr8qM_AP85ZDBi>
|
|
46
46
|
"""
|
|
47
47
|
|
|
48
48
|
def create_parser(self) -> argparse.ArgumentParser:
|
|
@@ -2,13 +2,17 @@ import argparse
|
|
|
2
2
|
import logging
|
|
3
3
|
import random
|
|
4
4
|
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from os import getenv
|
|
5
7
|
from typing import TextIO, Tuple
|
|
6
8
|
|
|
7
9
|
from ..api import ApiClient, ApiError, BadRequest
|
|
8
10
|
from ..main import BaseOperation
|
|
9
11
|
from ..main import Namespace as BaseNamespace
|
|
12
|
+
from ..telemetry_client import TelemetryError
|
|
13
|
+
from ..telemetry_client import get_client as get_telemetry_client
|
|
10
14
|
from ..types import ApiListResponse, VacancyItem
|
|
11
|
-
from ..utils import print_err, truncate_string
|
|
15
|
+
from ..utils import fix_datetime, print_err, truncate_string
|
|
12
16
|
|
|
13
17
|
logger = logging.getLogger(__package__)
|
|
14
18
|
|
|
@@ -65,11 +69,32 @@ class Operation(BaseOperation):
|
|
|
65
69
|
access_token=args.config["token"]["access_token"],
|
|
66
70
|
user_agent=args.config["user_agent"],
|
|
67
71
|
)
|
|
72
|
+
resume_id = self._get_resume_id(args, api)
|
|
73
|
+
application_messages = self._get_application_messages(args)
|
|
74
|
+
|
|
75
|
+
apply_min_interval, apply_max_interval = args.apply_interval
|
|
76
|
+
page_min_interval, page_max_interval = args.page_interval
|
|
77
|
+
|
|
78
|
+
self._apply_similar(
|
|
79
|
+
api,
|
|
80
|
+
resume_id,
|
|
81
|
+
args.force_message,
|
|
82
|
+
application_messages,
|
|
83
|
+
apply_min_interval,
|
|
84
|
+
apply_max_interval,
|
|
85
|
+
page_min_interval,
|
|
86
|
+
page_max_interval,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
|
|
68
90
|
if not (
|
|
69
91
|
resume_id := args.resume_id or args.config["default_resume_id"]
|
|
70
92
|
):
|
|
71
93
|
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
72
94
|
resume_id = resumes["items"][0]["id"]
|
|
95
|
+
return resume_id
|
|
96
|
+
|
|
97
|
+
def _get_application_messages(self, args: Namespace) -> list[str]:
|
|
73
98
|
if args.message_list:
|
|
74
99
|
application_messages = list(
|
|
75
100
|
filter(None, map(str.strip, args.message_list))
|
|
@@ -82,47 +107,7 @@ class Operation(BaseOperation):
|
|
|
82
107
|
"Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
|
|
83
108
|
"Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
|
|
84
109
|
]
|
|
85
|
-
|
|
86
|
-
apply_min_interval, apply_max_interval = args.apply_interval
|
|
87
|
-
page_min_interval, page_max_interval = args.page_interval
|
|
88
|
-
|
|
89
|
-
self._apply_similar(
|
|
90
|
-
api,
|
|
91
|
-
resume_id,
|
|
92
|
-
args.force_message,
|
|
93
|
-
application_messages,
|
|
94
|
-
apply_min_interval,
|
|
95
|
-
apply_max_interval,
|
|
96
|
-
page_min_interval,
|
|
97
|
-
page_max_interval,
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
def _get_vacancies(
|
|
101
|
-
self,
|
|
102
|
-
api: ApiClient,
|
|
103
|
-
resume_id: str,
|
|
104
|
-
page_min_interval: float,
|
|
105
|
-
page_max_interval: float,
|
|
106
|
-
) -> list[VacancyItem]:
|
|
107
|
-
rv = []
|
|
108
|
-
per_page = 100
|
|
109
|
-
for page in range(20):
|
|
110
|
-
res: ApiListResponse = api.get(
|
|
111
|
-
f"/resumes/{resume_id}/similar_vacancies",
|
|
112
|
-
page=page,
|
|
113
|
-
per_page=per_page,
|
|
114
|
-
order_by="relevance",
|
|
115
|
-
)
|
|
116
|
-
rv.extend(res["items"])
|
|
117
|
-
if page >= res["pages"] - 1:
|
|
118
|
-
break
|
|
119
|
-
|
|
120
|
-
# Задержка перед получением следующей страницы
|
|
121
|
-
if page > 0:
|
|
122
|
-
interval = random.uniform(page_min_interval, page_max_interval)
|
|
123
|
-
time.sleep(interval)
|
|
124
|
-
|
|
125
|
-
return rv
|
|
110
|
+
return application_messages
|
|
126
111
|
|
|
127
112
|
def _apply_similar(
|
|
128
113
|
self,
|
|
@@ -135,30 +120,43 @@ class Operation(BaseOperation):
|
|
|
135
120
|
page_min_interval: float,
|
|
136
121
|
page_max_interval: float,
|
|
137
122
|
) -> None:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
123
|
+
telemetry_client = get_telemetry_client()
|
|
124
|
+
telemetry_data = defaultdict(dict)
|
|
125
|
+
|
|
126
|
+
vacancies = self._get_vacancies(
|
|
127
|
+
api, resume_id, page_min_interval, page_max_interval, per_page=100
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
self._collect_vacancy_telemetry(telemetry_data, vacancies)
|
|
131
|
+
|
|
132
|
+
for vacancy in vacancies:
|
|
142
133
|
try:
|
|
143
|
-
if
|
|
144
|
-
|
|
134
|
+
if getenv("TEST_TELEMETRY"):
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if vacancy["has_test"]:
|
|
138
|
+
print("🚫 Пропускаем тест", vacancy["alternate_url"])
|
|
145
139
|
continue
|
|
146
140
|
|
|
147
|
-
relations =
|
|
148
|
-
|
|
149
|
-
# Там черезжопно нужно хеш отклика получать чтобы его отменить
|
|
150
|
-
# if "got_response" in relations:
|
|
151
|
-
# # Тупая пизда ее даже не рассматривала
|
|
152
|
-
# print(
|
|
153
|
-
# "Отменяем заявку чтобы отправить ее снова",
|
|
154
|
-
# item["alternate_url"],
|
|
155
|
-
# )
|
|
156
|
-
# api.delete(f"/negotiations/active/{item['id']}")
|
|
157
|
-
# elif relations:
|
|
141
|
+
relations = vacancy.get("relations", [])
|
|
142
|
+
|
|
158
143
|
if relations:
|
|
159
|
-
print(
|
|
144
|
+
print(
|
|
145
|
+
"🚫 Пропускаем ответ на заявку",
|
|
146
|
+
vacancy["alternate_url"],
|
|
147
|
+
)
|
|
160
148
|
continue
|
|
161
149
|
|
|
150
|
+
employer_id = vacancy["employer"]["id"]
|
|
151
|
+
employer = api.get(f"/employers/{employer_id}")
|
|
152
|
+
|
|
153
|
+
telemetry_data["employers"][employer_id] = {
|
|
154
|
+
"name": employer.get("name"),
|
|
155
|
+
"type": employer.get("type"),
|
|
156
|
+
"description": employer.get("description"),
|
|
157
|
+
"site_url": employer.get("site_url"),
|
|
158
|
+
"area": employer.get("area", {}).get("name"), # город
|
|
159
|
+
}
|
|
162
160
|
# Задержка перед отправкой отклика
|
|
163
161
|
interval = random.uniform(
|
|
164
162
|
apply_min_interval, apply_max_interval
|
|
@@ -167,10 +165,10 @@ class Operation(BaseOperation):
|
|
|
167
165
|
|
|
168
166
|
params = {
|
|
169
167
|
"resume_id": resume_id,
|
|
170
|
-
"vacancy_id":
|
|
168
|
+
"vacancy_id": vacancy["id"],
|
|
171
169
|
"message": (
|
|
172
|
-
random.choice(application_messages) %
|
|
173
|
-
if force_message or
|
|
170
|
+
random.choice(application_messages) % vacancy
|
|
171
|
+
if force_message or vacancy["response_letter_required"]
|
|
174
172
|
else ""
|
|
175
173
|
),
|
|
176
174
|
}
|
|
@@ -179,9 +177,9 @@ class Operation(BaseOperation):
|
|
|
179
177
|
assert res == {}
|
|
180
178
|
print(
|
|
181
179
|
"📨 Отправили отклик",
|
|
182
|
-
|
|
180
|
+
vacancy["alternate_url"],
|
|
183
181
|
"(",
|
|
184
|
-
truncate_string(
|
|
182
|
+
truncate_string(vacancy["name"]),
|
|
185
183
|
")",
|
|
186
184
|
)
|
|
187
185
|
except ApiError as ex:
|
|
@@ -190,3 +188,69 @@ class Operation(BaseOperation):
|
|
|
190
188
|
break
|
|
191
189
|
|
|
192
190
|
print("📝 Отклики на вакансии разосланы!")
|
|
191
|
+
|
|
192
|
+
self._send_telemetry(telemetry_client, telemetry_data)
|
|
193
|
+
|
|
194
|
+
def _get_vacancies(
|
|
195
|
+
self,
|
|
196
|
+
api: ApiClient,
|
|
197
|
+
resume_id: str,
|
|
198
|
+
page_min_interval: float,
|
|
199
|
+
page_max_interval: float,
|
|
200
|
+
per_page: int,
|
|
201
|
+
) -> list[VacancyItem]:
|
|
202
|
+
rv = []
|
|
203
|
+
for page in range(20):
|
|
204
|
+
res: ApiListResponse = api.get(
|
|
205
|
+
f"/resumes/{resume_id}/similar_vacancies",
|
|
206
|
+
page=page,
|
|
207
|
+
per_page=per_page,
|
|
208
|
+
order_by="relevance",
|
|
209
|
+
)
|
|
210
|
+
rv.extend(res["items"])
|
|
211
|
+
|
|
212
|
+
if getenv("TEST_TELEMETRY"):
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if page >= res["pages"] - 1:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
# Задержка перед получением следующей страницы
|
|
219
|
+
if page > 0:
|
|
220
|
+
interval = random.uniform(page_min_interval, page_max_interval)
|
|
221
|
+
time.sleep(interval)
|
|
222
|
+
|
|
223
|
+
return rv
|
|
224
|
+
|
|
225
|
+
def _collect_vacancy_telemetry(
|
|
226
|
+
self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
|
|
227
|
+
) -> None:
|
|
228
|
+
for vacancy in vacancies:
|
|
229
|
+
vacancy_id = vacancy["id"]
|
|
230
|
+
telemetry_data["vacancies"][vacancy_id] = {
|
|
231
|
+
"name": vacancy.get("name"),
|
|
232
|
+
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
233
|
+
"area": vacancy.get("area", {}).get("name"), # город
|
|
234
|
+
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
235
|
+
"direct_url": vacancy.get(
|
|
236
|
+
"alternate_url"
|
|
237
|
+
), # ссылка на вакансию
|
|
238
|
+
"created_at": fix_datetime(
|
|
239
|
+
vacancy.get("created_at")
|
|
240
|
+
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
241
|
+
"published_at": fix_datetime(vacancy.get("published_at")),
|
|
242
|
+
"contacts": vacancy.get(
|
|
243
|
+
"contacts"
|
|
244
|
+
), # пиздорванки там телеграм для связи указывают
|
|
245
|
+
"employer_id": int(vacancy["employer"]["id"]),
|
|
246
|
+
# Остальное неинтересно
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def _send_telemetry(
|
|
250
|
+
self, telemetry_client, telemetry_data: defaultdict
|
|
251
|
+
) -> None:
|
|
252
|
+
try:
|
|
253
|
+
res = telemetry_client.send_telemetry("/collect", dict(telemetry_data))
|
|
254
|
+
logger.debug(res)
|
|
255
|
+
except TelemetryError as ex:
|
|
256
|
+
logger.error(ex)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
import requests
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
from functools import cache
|
|
7
|
+
import logging
|
|
8
|
+
import base64
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__package__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TelemetryError(Exception):
|
|
14
|
+
"""Исключение, возникающее при ошибках в работе TelemetryClient."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TelemetryClient:
|
|
20
|
+
"""Клиент для отправки телеметрии на сервер."""
|
|
21
|
+
|
|
22
|
+
server_address = base64.b64decode(
|
|
23
|
+
"aHR0cDovLzMxLjEzMS4yNTEuMTA3OjU0MTU2"
|
|
24
|
+
).decode()
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
server_address: Optional[str] = None,
|
|
29
|
+
session: Optional[requests.Session] = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Инициализация клиента.
|
|
33
|
+
|
|
34
|
+
:param server_address: Адрес сервера для отправки телеметрии.
|
|
35
|
+
:param session: Сессия для повторного использования соединения.
|
|
36
|
+
"""
|
|
37
|
+
self.session = session or requests.Session()
|
|
38
|
+
self.server_address = os.getenv(
|
|
39
|
+
"TELEMETRY_SERVER", server_address or self.server_address
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def send_telemetry(
|
|
43
|
+
self, endpoint: str, data: Dict[str, Any]
|
|
44
|
+
) -> Dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Отправка телеметрии на сервер.
|
|
47
|
+
|
|
48
|
+
:param endpoint: Конечная точка на сервере.
|
|
49
|
+
:param data: Данные для отправки.
|
|
50
|
+
:return: Ответ сервера в формате JSON.
|
|
51
|
+
:raises TelemetryError: Если произошла ошибка при отправке или декодировании JSON.
|
|
52
|
+
"""
|
|
53
|
+
url = urljoin(self.server_address, endpoint)
|
|
54
|
+
logger.debug(data)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
response = self.session.post(url, json=data)
|
|
58
|
+
# response.raise_for_status()
|
|
59
|
+
result = response.json()
|
|
60
|
+
if "error" in result:
|
|
61
|
+
raise TelemetryError(result)
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
except (
|
|
65
|
+
requests.exceptions.RequestException,
|
|
66
|
+
json.JSONDecodeError,
|
|
67
|
+
) as ex:
|
|
68
|
+
raise TelemetryError(str(ex)) from ex
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@cache
|
|
72
|
+
def get_client() -> TelemetryClient:
|
|
73
|
+
return TelemetryClient()
|
hh_applicant_tool/utils.py
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
5
|
+
import platform
|
|
4
6
|
import sys
|
|
5
7
|
from functools import partial
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from threading import Lock
|
|
8
10
|
from typing import Any
|
|
11
|
+
from os import getenv
|
|
12
|
+
from .constants import INVALID_ISO8601_FORMAT
|
|
9
13
|
|
|
10
14
|
print_err = partial(print, file=sys.stderr, flush=True)
|
|
11
15
|
|
|
12
16
|
|
|
17
|
+
def get_config_path() -> Path:
|
|
18
|
+
match platform.system():
|
|
19
|
+
case "Windows":
|
|
20
|
+
return Path(getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
21
|
+
case "Darwin": # macOS
|
|
22
|
+
return Path.home() / "Library" / "Application Support"
|
|
23
|
+
case _: # Linux and etc
|
|
24
|
+
return Path(getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
25
|
+
|
|
26
|
+
|
|
13
27
|
class AttrDict(dict):
|
|
14
28
|
__getattr__ = dict.get
|
|
15
29
|
__setattr__ = dict.__setitem__
|
|
@@ -18,8 +32,8 @@ class AttrDict(dict):
|
|
|
18
32
|
|
|
19
33
|
# TODO: добавить defaults
|
|
20
34
|
class Config(dict):
|
|
21
|
-
def __init__(self, config_path: str | Path):
|
|
22
|
-
self._config_path = Path(config_path)
|
|
35
|
+
def __init__(self, config_path: str | Path | None = None):
|
|
36
|
+
self._config_path = Path(config_path or get_config_path())
|
|
23
37
|
self._lock = Lock()
|
|
24
38
|
self.load()
|
|
25
39
|
|
|
@@ -37,12 +51,24 @@ class Config(dict):
|
|
|
37
51
|
self._config_path.parent.mkdir(exist_ok=True, parents=True)
|
|
38
52
|
with self._lock:
|
|
39
53
|
with self._config_path.open("w+") as fp:
|
|
40
|
-
json.dump(
|
|
41
|
-
self, fp, ensure_ascii=True, indent=2, sort_keys=True
|
|
42
|
-
)
|
|
54
|
+
json.dump(self, fp, ensure_ascii=True, indent=2, sort_keys=True)
|
|
43
55
|
|
|
44
56
|
__getitem__ = dict.get
|
|
45
57
|
|
|
46
58
|
|
|
47
59
|
def truncate_string(s: str, limit: int = 75, ellipsis: str = "…") -> str:
|
|
48
60
|
return s[:limit] + bool(s[limit:]) * ellipsis
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def hash_with_salt(data: str, salt: str = "HorsePenis") -> str:
|
|
64
|
+
# Объединяем данные и соль
|
|
65
|
+
salted_data = data + salt
|
|
66
|
+
# Вычисляем хеш SHA-256
|
|
67
|
+
hashed_data = hashlib.sha256(salted_data.encode()).hexdigest()
|
|
68
|
+
return hashed_data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fix_datetime(dt: str | None) -> str | None:
|
|
72
|
+
if dt is None:
|
|
73
|
+
return None
|
|
74
|
+
return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: hh-applicant-tool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -17,7 +17,7 @@ Requires-Dist: pyqt6-webengine (>=6.7.0,<7.0.0) ; extra == "qt"
|
|
|
17
17
|
Requires-Dist: requests (>=2.28.2,<3.0.0)
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
## HH Applicant Tool
|
|
21
21
|
|
|
22
22
|
> ! Наложен мораторий на доработки/переработки. Разработка будет возобновлена после 100 звезд 💫
|
|
23
23
|
|
|
@@ -29,24 +29,24 @@ Description-Content-Type: text/markdown
|
|
|
29
29
|
[]()
|
|
30
30
|
|
|
31
31
|
<div align="center">
|
|
32
|
-
<img src="https://github.com/user-attachments/assets/
|
|
32
|
+
<img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
|
|
33
33
|
</div>
|
|
34
34
|
|
|
35
|
-
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me
|
|
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. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
41
|
+
|
|
40
42
|
Пример работы:
|
|
41
43
|
|
|
42
44
|

|
|
43
45
|
|
|
44
46
|
|
|
45
|
-
Данная утилита написана для Linux, но будет работать и в Windows, но в WSL она не работает, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
46
|
-
|
|
47
47
|
Предыстория.
|
|
48
48
|
|
|
49
|
-
Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я
|
|
49
|
+
Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
|
|
50
50
|
|
|
51
51
|
Долгое время я делал массовые заявки с помощью консоли браузера:
|
|
52
52
|
|
|
@@ -59,11 +59,15 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
|
|
|
59
59
|
Установка:
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
# Версия с поддержкой авторизации через
|
|
62
|
+
# Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
|
|
63
|
+
# Можно использовать обычный pip
|
|
63
64
|
$ pipx install 'hh-applicant-tool[qt]'
|
|
64
65
|
|
|
65
66
|
# Если хочется использовать самую последнюю версию, то можно установить ее через git
|
|
66
67
|
$ pipx install git+https://github.com/s3rgeym/hh-applicant-tool
|
|
68
|
+
|
|
69
|
+
# Для обновления до новой версии
|
|
70
|
+
$ pipx upgrade 'hh-applicant-tool'
|
|
67
71
|
```
|
|
68
72
|
|
|
69
73
|
Использование:
|
|
@@ -142,7 +146,7 @@ $ hh-applicant-tool -vv authorize
|
|
|
142
146
|
|
|
143
147
|

|
|
144
148
|
|
|
145
|
-
В случае успешной авторизации токены будут сохранены `~/.config/hh-applicant-tool/config.json`:
|
|
149
|
+
В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
|
|
146
150
|
|
|
147
151
|
```json
|
|
148
152
|
{
|
|
@@ -199,8 +203,6 @@ $ hh-applicant-tool whoami
|
|
|
199
203
|
}
|
|
200
204
|
```
|
|
201
205
|
|
|
202
|
-
Далее идут заметки для разработчиков...
|
|
203
|
-
|
|
204
206
|
Токен выдается на две недели:
|
|
205
207
|
|
|
206
208
|
```python
|
|
@@ -212,11 +214,11 @@ datetime.datetime(2023, 3, 23, 6, 36, 15, 596290)
|
|
|
212
214
|
>>>
|
|
213
215
|
```
|
|
214
216
|
|
|
215
|
-
После нужно вызвать `refresh-token
|
|
216
|
-
|
|
217
|
-

|
|
217
|
+
После нужно вызвать `refresh-token`:
|
|
218
218
|
|
|
219
|
-
|
|
219
|
+
```bash
|
|
220
|
+
$ hh-applicant-tool refresh-token
|
|
221
|
+
```
|
|
220
222
|
|
|
221
223
|
Удаление хвостов:
|
|
222
224
|
|
|
@@ -231,3 +233,24 @@ rm -f ~/.local/share/applications/hhandroid.desktop
|
|
|
231
233
|
|
|
232
234
|
Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
|
|
233
235
|
|
|
236
|
+
Утилита собирает и передает на сервер разработчика следующую ифнормацию:
|
|
237
|
+
|
|
238
|
+
1. Название вакансии.
|
|
239
|
+
1. Тип вакансии (открытая/закрытая).
|
|
240
|
+
1. Город, в котором размещена вакансия.
|
|
241
|
+
1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
|
|
242
|
+
1. Прямая ссылка на вакансию.
|
|
243
|
+
1. Дата создания вакансии.
|
|
244
|
+
1. Дата публикации вакансии.
|
|
245
|
+
1. Контактная информация хрюши (ее телефон, email и тп).
|
|
246
|
+
1. Название компании.
|
|
247
|
+
1. Тип компании.
|
|
248
|
+
1. Описание компании.
|
|
249
|
+
1. Ссылка на сайт компании.
|
|
250
|
+
1. Город, в котором находится компания.
|
|
251
|
+
|
|
252
|
+
[Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
|
|
253
|
+
|
|
254
|
+
!!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ (IP ТОЖЕ НЕ СОХРАНЯЕТ) — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ)
|
|
255
|
+
|
|
256
|
+
|
|
@@ -5,9 +5,9 @@ hh_applicant_tool/api/client.py,sha256=z_YMsd5zL4-1_aIbkEKqm_1m_mZkm3BMxlAQuCoNj
|
|
|
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=YdNz0CF4swJ9OO5vVpOZ39RP-05xSU0Ghw4Y6BhISoE,468
|
|
8
|
-
hh_applicant_tool/main.py,sha256=
|
|
8
|
+
hh_applicant_tool/main.py,sha256=XLKsd8mpCKuFoP3ESMpgJKdGL3m6tOQ8kKrOzCN-dwA,3075
|
|
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=DNvYGbcnyV5LwjIHCOOXWtFEAUgpJHyZFG4Iy2doDJ0,10188
|
|
11
11
|
hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
|
|
12
12
|
hh_applicant_tool/operations/call_api.py,sha256=oWAfvy4LwbsQ8HsgI_3en3sMTlu3ZWU7NzpxssrUNSU,1472
|
|
13
13
|
hh_applicant_tool/operations/clear_negotiations.py,sha256=5ybdJMUfV9XYxnn2y4zvWdS_ZE8yXAbFJoXOMyqetw8,4041
|
|
@@ -15,9 +15,10 @@ hh_applicant_tool/operations/list_resumes.py,sha256=HYxjDALrWl_YBIitDldhhEs-WO_0
|
|
|
15
15
|
hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
|
|
16
16
|
hh_applicant_tool/operations/update_resumes.py,sha256=FKtwEL7i0vaOxLEn1nARi72SuMYB5VjN6Fihe6rlt-Y,1196
|
|
17
17
|
hh_applicant_tool/operations/whoami.py,sha256=kdLQ_FjYzpJPKxFlocxf7vgXhW1zocb0bd5IAWmsQuA,861
|
|
18
|
+
hh_applicant_tool/telemetry_client.py,sha256=TlsNKlclPyJqLPO0xHkHKBIhT8bmgx1ZBup4PjE8w5E,2296
|
|
18
19
|
hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
|
|
19
|
-
hh_applicant_tool/utils.py,sha256=
|
|
20
|
-
hh_applicant_tool-0.3.
|
|
21
|
-
hh_applicant_tool-0.3.
|
|
22
|
-
hh_applicant_tool-0.3.
|
|
23
|
-
hh_applicant_tool-0.3.
|
|
20
|
+
hh_applicant_tool/utils.py,sha256=wXcxs5IinK7PH6ipCCpngw7faUiboEpi9a8j1wDEPKw,2282
|
|
21
|
+
hh_applicant_tool-0.3.3.dist-info/METADATA,sha256=W8nPWVupEP0MLc9K-VOs9E8cEbAMH31jf8gFR0drlF0,15881
|
|
22
|
+
hh_applicant_tool-0.3.3.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
23
|
+
hh_applicant_tool-0.3.3.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
|
|
24
|
+
hh_applicant_tool-0.3.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|