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.

@@ -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__, formatter_class=self.ArgumentFormatter
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, print_err, truncate_string
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="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(name)s",
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="Интервал между отправкой откликов в секундах (X, X-Y)",
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="Интервал между получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
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
- "Меня заинтересовала ваша вакансия %(name)s",
122
- "Прошу рассмотреть мою жалкую кандидатуру на вакансию %(name)s",
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
- telemetry_client = get_telemetry_client()
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
- try:
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
- interval = random.uniform(
194
- apply_min_interval, apply_max_interval
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
- message_template = random.choice(application_messages)
206
-
207
- try:
208
- params["message"] = message_template % vacancy
209
- except TypeError as ex:
210
- # TypeError: not enough arguments for format string
211
- # API HH все кривое, иногда нет идентификатора работодателя, иногда у вакансии нет названия.
212
- # И это типа рашн хайлоад, где из-за дрочки на аджайл слепили кривую говнину.
213
- logger.error(
214
- f"Ошибка форматирования шаблона сообщения {template_message!r} для {vacancy = }"
215
- )
216
- continue
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
- print_err("❗ Ошибка:", ex)
345
+ logger.error(ex)
229
346
  if isinstance(ex, BadRequest) and ex.limit_exceeded:
230
- break
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()
@@ -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.6
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.28.2,<3.0.0)
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. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (`C:\Users\%username%\AppData\Roaming\hh-applicant-tool\config.json` — в Windows) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
40
+ Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
39
41
 
40
42
  Пример работы:
41
43
 
42
44
  ![image](https://github.com/user-attachments/assets/55ab24ba-5325-40b4-9bd9-69ebcbc011c4)
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
+ ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
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** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, сайта фирмы и могут быть удалены по просьбе упал_намоченный лицо. Данная функция готова и будет доступна после 100 ⭐ |
245
+ | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, с сайта фирмы и могут быть удалены только по просьбе уполнамоченного лица. Данная функция готова и будет доступна после 100 ⭐ |
164
246
 
165
- Авторизуемся:
247
+ ### Формат текста сообщений
166
248
 
167
- ```bash
168
- $ hh-applicant-tool -vv authorize
169
- ```
170
-
171
- ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
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
- В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
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
- После нужно вызвать `refresh-token`:
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. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, хранящаеся в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля (может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и росписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова).
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=c0XBEQIS-kPi2JeS9TmgcO8ZyOjV6HsgiwZRcKUOQCI,7927
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=sL9eSWUkOz-NJbkq8PxRluvXUh5AqLVi7qmrdN1YAKY,3996
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=fNiQlxjdRzaG2w-mWhZoaCo8OyAp71NM3PJamyTGyi8,12726
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=1jgbc8oMfLhbEi2pTA2fF0pKlHSWekHY3oEJCDI8Uas,2268
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=wXcxs5IinK7PH6ipCCpngw7faUiboEpi9a8j1wDEPKw,2282
21
- hh_applicant_tool-0.3.6.dist-info/METADATA,sha256=gxwj6cYpX-LDDSgoEQeIGAkeBuxrsSelggU47MOg4hw,18509
22
- hh_applicant_tool-0.3.6.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
23
- hh_applicant_tool-0.3.6.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
24
- hh_applicant_tool-0.3.6.dist-info/RECORD,,
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,,