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.

@@ -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
- DEFAULT_USER_AGENT,
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 DEFAULT_USER_AGENT,
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 = 0.34,
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 - time.monotonic() + self.previous_request_time
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)
@@ -1,4 +1,4 @@
1
- DEFAULT_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
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=argparse.RawDescriptionHelpFormatter,
67
+ formatter_class=self.ArgumentFormatter,
52
68
  )
53
69
  parser.add_argument(
54
70
  "-c",
55
71
  "--config",
56
- help="config path",
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="increase verbosity",
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("_", "-"), description=op.__doc__
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="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(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]:
@@ -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
- assert args.config["token"]
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
- "Меня заинтересовала ваша вакансия %(name)s",
126
- "Прошу рассмотреть мою жалкую кандидатуру на вакансию %(name)s",
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
- try:
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
- interval = random.uniform(
198
- apply_min_interval, apply_max_interval
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
- message_template = random.choice(application_messages)
210
-
211
- try:
212
- params["message"] = template_message % vacancy
213
- except TypeError as ex:
214
- # TypeError: not enough arguments for format string
215
- # API HH все кривое, иногда нет идентификатора работодателя, иногда у вакансии нет названия.
216
- # И это типа рашн хайлоад, где из-за дрочки на аджайл слепили кривую говнину.
217
- logger.error(
218
- f"Ошибка форматирования шаблона сообщения {template_message!r} для {vacancy = }"
219
- )
220
- continue
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
- print_err("❗ Ошибка:", ex)
341
+ logger.error(ex)
233
342
  if isinstance(ex, BadRequest) and ex.limit_exceeded:
234
- break
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 ApiClient, ApiError
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
- assert args.config["token"]
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
- assert args.config["token"]
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
- assert args.config["token"]
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
- assert args.config["token"]
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
- assert args.config["token"]
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()
@@ -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.5
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
  ![Publish to PyPI](https://github.com/s3rgeym/hh-applicant-tool/actions/workflows/publish.yml/badge.svg)
25
23
  [![PyPi Version](https://img.shields.io/pypi/v/hh-applicant-tool)]()
26
24
  [![Python Versions](https://img.shields.io/pypi/pyversions/hh-applicant-tool.svg)]()
@@ -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. После авторизации вы можете перенести `~/.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` токены из официального приложения и добавить их в конфиг.
41
41
 
42
42
  Пример работы:
43
43
 
44
44
  ![image](https://github.com/user-attachments/assets/55ab24ba-5325-40b4-9bd9-69ebcbc011c4)
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-tool`:
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
+ ![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
+ Через этот файл, например, можно задать кастомный `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
- ```bash
169
- $ hh-applicant-tool -vv authorize
170
- ```
250
+ Команда `apply-similar` поддерживает специальный формат сообщений.
171
251
 
172
- ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
252
+ Так же в сообщении можно использовать плейсхолдеры:
173
253
 
174
- > В Windows не забудьте разрешить доступ к сети (Allow access) в всплывающем окне.
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
- После нужно вызвать `refresh-token`:
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. Контактная информация хрюши (ее телефон, email и тп).
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,,