hh-applicant-tool 0.4.0__py3-none-any.whl → 0.5.0__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 CHANGED
@@ -3,28 +3,29 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import logging
5
5
  import sys
6
- from abc import ABCMeta, abstractmethod
7
6
  from importlib import import_module
7
+ from os import getenv
8
8
  from pathlib import Path
9
9
  from pkgutil import iter_modules
10
- from typing import Sequence, Literal
10
+ from typing import Literal, Sequence
11
+
11
12
  from .api import ApiClient
12
13
  from .color_log import ColorHandler
13
14
  from .utils import Config, get_config_path
14
- from os import getenv
15
15
 
16
16
  DEFAULT_CONFIG_PATH = (
17
- get_config_path() / __package__.replace("_", "-") / "config.json"
17
+ get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
18
18
  )
19
19
 
20
20
  logger = logging.getLogger(__package__)
21
21
 
22
22
 
23
- class BaseOperation(metaclass=ABCMeta):
24
- def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
23
+ class BaseOperation:
24
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
25
+ ...
25
26
 
26
- @abstractmethod
27
- def run(self, args: argparse.Namespace) -> None | int: ...
27
+ def run(self, args: argparse.Namespace) -> None | int:
28
+ raise NotImplementedError()
28
29
 
29
30
 
30
31
  OPERATIONS = "operations"
@@ -63,7 +64,7 @@ class HHApplicantTool:
63
64
 
64
65
  Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
65
66
 
66
- Группа поддержки: <https://t.me/+aSjr8qM_AP85ZDBi>
67
+ Группа поддержки: <https://t.me/otzyvy_headhunter>
67
68
  """
68
69
 
69
70
  class ArgumentFormatter(
@@ -0,0 +1,13 @@
1
+ from .api import ApiError
2
+ from .types import ApiListResponse
3
+
4
+
5
+ class GetResumeIdMixin:
6
+ def _get_resume_id(self) -> str:
7
+ try:
8
+ resumes: ApiListResponse = self.api.get("/resumes/mine")
9
+ return resumes["items"][0]["id"]
10
+ except (ApiError, KeyError, IndexError) as ex:
11
+ raise Exception("Не могу получить идентификатор резюме") from ex
12
+
13
+
@@ -3,16 +3,18 @@ import logging
3
3
  import random
4
4
  import time
5
5
  from collections import defaultdict
6
- from os import getenv
6
+ from datetime import datetime, timedelta, timezone
7
7
  from typing import TextIO, Tuple
8
8
 
9
- from ..api import ApiClient, ApiError, BadRequest
9
+ from ..api import ApiError, BadRequest
10
10
  from ..main import BaseOperation
11
- from ..main import Namespace as BaseNamespace, get_api
11
+ from ..main import Namespace as BaseNamespace
12
+ from ..main import get_api
13
+ from ..mixins import GetResumeIdMixin
12
14
  from ..telemetry_client import TelemetryClient, TelemetryError
13
15
  from ..types import ApiListResponse, VacancyItem
14
- from ..utils import fix_datetime, truncate_string, random_text
15
- from requests import Session
16
+ from ..utils import (fix_datetime, parse_interval, parse_invalid_datetime,
17
+ random_text, truncate_string)
16
18
 
17
19
  logger = logging.getLogger(__package__)
18
20
 
@@ -23,22 +25,19 @@ class Namespace(BaseNamespace):
23
25
  force_message: bool
24
26
  apply_interval: Tuple[float, float]
25
27
  page_interval: Tuple[float, float]
26
- message_interval: Tuple[float, float]
27
28
  order_by: str
28
29
  search: str
29
- reply_message: str
30
+ dry_run: bool
30
31
 
31
32
 
32
- # gx для открытия (никак не запомню в виме)
33
- # https://api.hh.ru/openapi/redoc
34
- class Operation(BaseOperation):
35
- """Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
33
+ class Operation(BaseOperation, GetResumeIdMixin):
34
+ """Откликнуться на все подходящие вакансии."""
36
35
 
37
36
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
38
37
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
39
38
  parser.add_argument(
40
39
  "--message-list",
41
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(vacabcy_name)s",
40
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
42
41
  type=argparse.FileType(),
43
42
  )
44
43
  parser.add_argument(
@@ -51,19 +50,13 @@ class Operation(BaseOperation):
51
50
  "--apply-interval",
52
51
  help="Интервал перед отправкой откликов в секундах (X, X-Y)",
53
52
  default="1-5",
54
- type=self._parse_interval,
53
+ type=parse_interval,
55
54
  )
56
55
  parser.add_argument(
57
56
  "--page-interval",
58
57
  help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
59
58
  default="1-3",
60
- type=self._parse_interval,
61
- )
62
- parser.add_argument(
63
- "--message-interval",
64
- help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
65
- default="5-10",
66
- type=self._parse_interval,
59
+ type=parse_interval,
67
60
  )
68
61
  parser.add_argument(
69
62
  "--order-by",
@@ -79,25 +72,17 @@ class Operation(BaseOperation):
79
72
  )
80
73
  parser.add_argument(
81
74
  "--search",
82
- help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зарплату",
75
+ help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
83
76
  type=str,
84
77
  default=None,
85
78
  )
86
79
  parser.add_argument(
87
- "--reply-message",
88
- "--reply",
89
- help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
80
+ "--dry-run",
81
+ help="Не отправлять отклики, а только выводить параметры запроса",
82
+ default=False,
83
+ action=argparse.BooleanOptionalAction,
90
84
  )
91
85
 
92
- @staticmethod
93
- def _parse_interval(interval: str) -> Tuple[float, float]:
94
- """Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
95
- if "-" in interval:
96
- min_interval, max_interval = map(float, interval.split("-"))
97
- else:
98
- min_interval = max_interval = float(interval)
99
- return min(min_interval, max_interval), max(min_interval, max_interval)
100
-
101
86
  def run(self, args: Namespace) -> None:
102
87
  self.enable_telemetry = True
103
88
  if args.disable_telemetry:
@@ -110,45 +95,31 @@ class Operation(BaseOperation):
110
95
  .startswith(("д", "y"))
111
96
  ):
112
97
  self.enable_telemetry = False
113
- logger.info("Телеметрия отключена")
98
+ logger.info("Телеметрия отключена.")
114
99
  else:
115
- logger.info("Телеметрия включена")
116
- api = get_api(args)
117
- resume_id = self._get_resume_id(args, api)
118
- application_messages = self._get_application_messages(args)
119
-
120
- apply_min_interval, apply_max_interval = args.apply_interval
121
- page_min_interval, page_max_interval = args.page_interval
122
- message_min_interval, message_max_interval = args.message_interval
123
-
124
- self._apply_similar(
125
- api,
126
- resume_id,
127
- args.force_message,
128
- application_messages,
129
- apply_min_interval,
130
- apply_max_interval,
131
- page_min_interval,
132
- page_max_interval,
133
- message_min_interval,
134
- message_max_interval,
135
- args.order_by,
136
- args.search,
137
- args.reply_message or args.config["reply_message"],
100
+ logger.info("Спасибо за то что оставили телеметрию включенной!")
101
+
102
+ self.api = get_api(args)
103
+ self.resume_id = args.resume_id or self._get_resume_id()
104
+ self.application_messages = self._get_application_messages(
105
+ args.message_list
138
106
  )
139
107
 
140
- def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
141
- if not (
142
- resume_id := args.resume_id or args.config["default_resume_id"]
143
- ):
144
- resumes: ApiListResponse = api.get("/resumes/mine")
145
- resume_id = resumes["items"][0]["id"]
146
- return resume_id
108
+ self.apply_min_interval, self.apply_max_interval = args.apply_interval
109
+ self.page_min_interval, self.page_max_interval = args.page_interval
147
110
 
148
- def _get_application_messages(self, args: Namespace) -> list[str]:
149
- if args.message_list:
111
+ self.force_message = args.force_message
112
+ self.order_by = args.order_by
113
+ self.search = args.search
114
+ self.dry_run = args.dry_run
115
+ self._apply_similar()
116
+
117
+ def _get_application_messages(
118
+ self, message_list: TextIO | None
119
+ ) -> list[str]:
120
+ if message_list:
150
121
  application_messages = list(
151
- filter(None, map(str.strip, args.message_list))
122
+ filter(None, map(str.strip, message_list))
152
123
  )
153
124
  else:
154
125
  application_messages = [
@@ -157,39 +128,42 @@ class Operation(BaseOperation):
157
128
  ]
158
129
  return application_messages
159
130
 
160
- def _apply_similar(
161
- self,
162
- api: ApiClient,
163
- resume_id: str,
164
- force_message: bool,
165
- application_messages: list[str],
166
- apply_min_interval: float,
167
- apply_max_interval: float,
168
- page_min_interval: float,
169
- page_max_interval: float,
170
- message_min_interval: float,
171
- message_max_interval: float,
172
- order_by: str,
173
- search: str | None,
174
- reply_message: str | None,
175
- ) -> None:
176
- telemetry_client = TelemetryClient(proxies=api.proxies)
131
+ def _apply_similar(self) -> None:
132
+ telemetry_client = TelemetryClient(proxies=self.api.proxies)
177
133
  telemetry_data = defaultdict(dict)
178
134
 
179
- vacancies = self._get_vacancies(
180
- api,
181
- resume_id,
182
- page_min_interval,
183
- page_max_interval,
184
- per_page=100,
185
- order_by=order_by,
186
- search=search,
187
- )
135
+ vacancies = self._get_vacancies()
188
136
 
189
137
  if self.enable_telemetry:
190
- self._collect_vacancy_telemetry(telemetry_data, vacancies)
138
+ for vacancy in vacancies:
139
+ vacancy_id = vacancy["id"]
140
+ telemetry_data["vacancies"][vacancy_id] = {
141
+ "name": vacancy.get("name"),
142
+ "type": vacancy.get("type", {}).get("id"), # open/closed
143
+ "area": vacancy.get("area", {}).get("name"), # город
144
+ "salary": vacancy.get(
145
+ "salary"
146
+ ), # from, to, currency, gross
147
+ "direct_url": vacancy.get(
148
+ "alternate_url"
149
+ ), # ссылка на вакансию
150
+ "created_at": fix_datetime(
151
+ vacancy.get("created_at")
152
+ ), # будем вычислять говно-вакансии, которые по полгода висят
153
+ "published_at": fix_datetime(vacancy.get("published_at")),
154
+ "contacts": vacancy.get(
155
+ "contacts"
156
+ ), # пиздорванки там телеграм для связи указывают
157
+ # HH с точки зрения перфикциониста — кусок говна, где кривые
158
+ # форматы даты, у вакансий может не быть работодателя...
159
+ "employer_id": int(vacancy["employer"]["id"])
160
+ if "employer" in vacancy and "id" in vacancy["employer"]
161
+ else None,
162
+ # "relations": vacancy.get("relations", []),
163
+ # Остальное неинтересно
164
+ }
191
165
 
192
- me = api.get("/me")
166
+ me = self.api.get("/me")
193
167
 
194
168
  basic_message_placeholders = {
195
169
  "first_name": me.get("first_name", ""),
@@ -199,12 +173,10 @@ class Operation(BaseOperation):
199
173
  }
200
174
 
201
175
  do_apply = True
176
+ complained_employers = set()
202
177
 
203
178
  for vacancy in vacancies:
204
179
  try:
205
- if getenv("TEST_TELEMETRY"):
206
- break
207
-
208
180
  message_placeholders = {
209
181
  "vacancy_name": vacancy.get("name", ""),
210
182
  "employer_name": vacancy.get("employer", {}).get(
@@ -219,132 +191,112 @@ class Operation(BaseOperation):
219
191
  )
220
192
 
221
193
  if vacancy.get("has_test"):
222
- print("🚫 Пропускаем тест", vacancy["alternate_url"])
223
- continue
224
-
225
- if vacancy.get("archived"):
226
- print(
227
- "🚫 Пропускаем вакансию в архиве",
194
+ logger.debug(
195
+ "Пропускаем вакансию с тестом: %s",
228
196
  vacancy["alternate_url"],
229
197
  )
230
-
231
198
  continue
232
199
 
233
- relations = vacancy.get("relations", [])
234
-
235
- if relations:
236
- if "got_rejection" in relations:
237
- print(
238
- "🚫 Пропускаем отказ на вакансию",
239
- vacancy["alternate_url"],
240
- )
241
- continue
242
-
243
- if reply_message:
244
- r = api.get("/negotiations", vacancy_id=vacancy["id"])
245
-
246
- if len(r["items"]) == 1:
247
- neg = r["items"][0]
248
- nid = neg["id"]
249
-
250
- page: int = 0
251
- last_message: dict | None = None
252
- while True:
253
- r2 = api.get(
254
- f"/negotiations/{nid}/messages", page=page
255
- )
256
- last_message = r2["items"][-1]
257
- if page + 1 >= r2["pages"]:
258
- break
259
-
260
- page = r2["pages"] - 1
261
-
262
- logger.debug(last_message["text"])
263
-
264
- if last_message["author"][
265
- "participant_type"
266
- ] == "employer" or not neg.get(
267
- "viewed_by_opponent"
268
- ):
269
- message = (
270
- random_text(reply_message)
271
- % message_placeholders
272
- )
273
- logger.debug(message)
274
-
275
- time.sleep(
276
- random.uniform(
277
- message_min_interval,
278
- message_max_interval,
279
- )
280
- )
281
- api.post(
282
- f"/negotiations/{nid}/messages",
283
- message=message,
284
- )
285
- print(
286
- "📨 Отправили сообщение для привлечения внимания",
287
- vacancy["alternate_url"],
288
- )
289
- continue
290
- else:
291
- logger.warning(
292
- "Приглашение без чата для вакансии: %s",
293
- vacancy["alternate_url"],
294
- )
295
-
296
- print(
297
- "🚫 Пропускаем вакансию с откликом",
200
+ if vacancy.get("archived"):
201
+ logger.warning(
202
+ "Пропускаем вакансию в архиве: %s",
298
203
  vacancy["alternate_url"],
299
204
  )
300
205
  continue
301
206
 
207
+ relations = vacancy.get("relations", [])
302
208
  employer_id = vacancy.get("employer", {}).get("id")
303
209
 
304
210
  if (
305
211
  self.enable_telemetry
306
212
  and employer_id
307
213
  and employer_id not in telemetry_data["employers"]
308
- and 200 > len(telemetry_data["employers"])
214
+ and employer_id not in complained_employers
215
+ and (
216
+ not relations
217
+ or parse_invalid_datetime(vacancy["created_at"])
218
+ + timedelta(days=7)
219
+ > datetime.now(tz=timezone.utc)
220
+ )
309
221
  ):
310
- employer = api.get(f"/employers/{employer_id}")
311
- telemetry_data["employers"][employer_id] = {
222
+ employer = self.api.get(f"/employers/{employer_id}")
223
+
224
+ employer_data = {
312
225
  "name": employer.get("name"),
313
226
  "type": employer.get("type"),
314
227
  "description": employer.get("description"),
315
228
  "site_url": employer.get("site_url"),
316
229
  "area": employer.get("area", {}).get("name"), # город
317
230
  }
231
+ if "got_rejection" in relations:
232
+ try:
233
+ print(
234
+ "🚨 Вы получили отказ от https://hh.ru/employer/%s"
235
+ % employer_id
236
+ )
237
+ response = telemetry_client.send_telemetry(
238
+ f"/employers/{employer_id}/complaint",
239
+ employer_data,
240
+ )
241
+ if "topic_url" in response:
242
+ print(
243
+ "Ссылка на обсуждение работодателя:",
244
+ response["topic_url"],
245
+ )
246
+ else:
247
+ print(
248
+ "Создание темы для обсуждения работодателя добавлено в очередь..."
249
+ )
250
+ complained_employers.add(employer_id)
251
+ except TelemetryError as ex:
252
+ logger.error(ex)
253
+ elif do_apply:
254
+ telemetry_data["employers"][employer_id] = employer_data
318
255
 
319
256
  if not do_apply:
320
- logger.debug("skip apply similar")
257
+ logger.debug(
258
+ "Проопускаем вакансию так как достигла лимита заявок: %s",
259
+ vacancy["alternate_url"],
260
+ )
261
+ continue
262
+
263
+ if relations:
264
+ logger.debug(
265
+ "Пропускаем вакансию с откликом: %s",
266
+ vacancy["alternate_url"],
267
+ )
321
268
  continue
322
269
 
323
270
  params = {
324
- "resume_id": resume_id,
271
+ "resume_id": self.resume_id,
325
272
  "vacancy_id": vacancy["id"],
326
273
  "message": "",
327
274
  }
328
275
 
329
- if force_message or vacancy.get("response_letter_required"):
276
+ if self.force_message or vacancy.get(
277
+ "response_letter_required"
278
+ ):
330
279
  msg = params["message"] = (
331
- random_text(random.choice(application_messages))
280
+ random_text(random.choice(self.application_messages))
332
281
  % message_placeholders
333
282
  )
334
283
  logger.debug(msg)
335
284
 
285
+ if self.dry_run:
286
+ logger.info(
287
+ "Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
288
+ vacancy["alternate_url"],
289
+ params,
290
+ )
291
+ continue
292
+
336
293
  # Задержка перед отправкой отклика
337
294
  interval = random.uniform(
338
- max(apply_min_interval, message_min_interval)
339
- if params["message"]
340
- else apply_min_interval,
341
- max(apply_max_interval, message_max_interval)
342
- if params["message"]
343
- else apply_max_interval,
295
+ self.apply_min_interval, self.apply_max_interval
344
296
  )
345
297
  time.sleep(interval)
346
298
 
347
- res = api.post("/negotiations", params)
299
+ res = self.api.post("/negotiations", params)
348
300
  assert res == {}
349
301
  print(
350
302
  "📨 Отправили отклик",
@@ -356,88 +308,49 @@ class Operation(BaseOperation):
356
308
  except ApiError as ex:
357
309
  logger.error(ex)
358
310
  if isinstance(ex, BadRequest) and ex.limit_exceeded:
359
- if not reply_message:
360
- break
361
311
  do_apply = False
362
312
 
363
313
  print("📝 Отклики на вакансии разосланы!")
364
314
 
365
315
  if self.enable_telemetry:
366
- # Я собираюсь выложить контакты херок в общественный доступ
367
- self._send_telemetry(telemetry_client, telemetry_data)
368
-
369
- def _get_vacancies(
370
- self,
371
- api: ApiClient,
372
- resume_id: str,
373
- page_min_interval: float,
374
- page_max_interval: float,
375
- per_page: int,
376
- order_by: str,
377
- search: str | None = None,
378
- ) -> list[VacancyItem]:
316
+ if self.dry_run:
317
+ # С --dry-run можно посмотреть что отправляется
318
+ logger.info(
319
+ "Dry Run: Данные телеметрии для отправки на сервер: %r",
320
+ telemetry_data,
321
+ )
322
+ return
323
+
324
+ try:
325
+ response = telemetry_client.send_telemetry(
326
+ "/collect", dict(telemetry_data)
327
+ )
328
+ logger.debug(response)
329
+ except TelemetryError as ex:
330
+ logger.error(ex)
331
+
332
+ def _get_vacancies(self, per_page: int = 100) -> list[VacancyItem]:
379
333
  rv = []
380
334
  for page in range(20):
381
335
  params = {
382
336
  "page": page,
383
337
  "per_page": per_page,
384
- "order_by": order_by,
338
+ "order_by": self.order_by,
385
339
  }
386
- if search:
387
- params["text"] = search
388
- res: ApiListResponse = api.get(
389
- f"/resumes/{resume_id}/similar_vacancies", params
340
+ if self.search:
341
+ params["text"] = self.search
342
+ res: ApiListResponse = self.api.get(
343
+ f"/resumes/{self.resume_id}/similar_vacancies", params
390
344
  )
391
345
  rv.extend(res["items"])
392
-
393
- if getenv("TEST_TELEMETRY"):
394
- break
395
-
396
346
  if page >= res["pages"] - 1:
397
347
  break
398
348
 
399
349
  # Задержка перед получением следующей страницы
400
350
  if page > 0:
401
- interval = random.uniform(page_min_interval, page_max_interval)
351
+ interval = random.uniform(
352
+ self.page_min_interval, self.page_max_interval
353
+ )
402
354
  time.sleep(interval)
403
355
 
404
356
  return rv
405
-
406
- def _collect_vacancy_telemetry(
407
- self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
408
- ) -> None:
409
- for vacancy in vacancies:
410
- vacancy_id = vacancy["id"]
411
- telemetry_data["vacancies"][vacancy_id] = {
412
- "name": vacancy.get("name"),
413
- "type": vacancy.get("type", {}).get("id"), # open/closed
414
- "area": vacancy.get("area", {}).get("name"), # город
415
- "salary": vacancy.get("salary"), # from, to, currency, gross
416
- "direct_url": vacancy.get(
417
- "alternate_url"
418
- ), # ссылка на вакансию
419
- "created_at": fix_datetime(
420
- vacancy.get("created_at")
421
- ), # будем вычислять говно-вакансии, которые по полгода висят
422
- "published_at": fix_datetime(vacancy.get("published_at")),
423
- "contacts": vacancy.get(
424
- "contacts"
425
- ), # пиздорванки там телеграм для связи указывают
426
- # HH с точки зрения перфикциониста — кусок говна, где кривые
427
- # форматы даты, у вакансий может не быть работодателя...
428
- "employer_id": int(vacancy["employer"]["id"])
429
- if "employer" in vacancy and "id" in vacancy["employer"]
430
- else None,
431
- # Остальное неинтересно
432
- }
433
-
434
- def _send_telemetry(
435
- self, telemetry_client, telemetry_data: defaultdict
436
- ) -> None:
437
- try:
438
- res = telemetry_client.send_telemetry(
439
- "/collect", dict(telemetry_data)
440
- )
441
- logger.debug(res)
442
- except TelemetryError as ex:
443
- logger.error(ex)
@@ -0,0 +1,103 @@
1
+ import argparse
2
+ import logging
3
+ from os import getenv
4
+
5
+ from ..main import BaseOperation
6
+ from ..main import Namespace as BaseNamespace
7
+ from ..main import get_proxies
8
+ from ..telemetry_client import TelemetryClient
9
+
10
+ logger = logging.getLogger(__package__)
11
+
12
+
13
+ class Namespace(BaseNamespace):
14
+ username: str | None = None
15
+ password: str | None = None
16
+ search: str | None = None
17
+
18
+
19
+ class Operation(BaseOperation):
20
+ """Выведет контакты работодателя по заданной строке поиска"""
21
+
22
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
23
+ parser.add_argument(
24
+ "-u",
25
+ "--username",
26
+ type=str,
27
+ help="Имя пользователя для аутентификации",
28
+ default=getenv("AUTH_USERNAME"),
29
+ )
30
+ parser.add_argument(
31
+ "-P",
32
+ "--password",
33
+ type=str,
34
+ help="Пароль для аутентификации",
35
+ default=getenv("AUTH_PASSWORD"),
36
+ )
37
+ parser.add_argument(
38
+ "-s",
39
+ "--search",
40
+ type=str,
41
+ default="",
42
+ help="Строка поиска для контактов работодателя",
43
+ )
44
+ parser.add_argument(
45
+ "-p",
46
+ "--page",
47
+ default=1,
48
+ help="Номер страницы в выдаче",
49
+ )
50
+
51
+ def run(self, args: Namespace) -> None:
52
+ proxies = get_proxies(args)
53
+ client = TelemetryClient(proxies=proxies)
54
+ auth = (
55
+ (args.username, args.password)
56
+ if args.username and args.password
57
+ else None
58
+ )
59
+ # Аутентификация пользователя
60
+ results = client.get_telemetry(
61
+ "/contact/persons",
62
+ {"search": args.search, "per_page": 10, "page": args.page},
63
+ auth=auth,
64
+ )
65
+ self._print_contacts(results)
66
+
67
+ def _print_contacts(self, data: dict) -> None:
68
+ """Вывод всех контактов в древовидной структуре."""
69
+ page = data["page"]
70
+ pages = (data["total"] // data["per_page"]) + 1
71
+ print(f"📋 Контакты ({page}/{pages}):")
72
+ contacts = data.get("contact_persons", [])
73
+ for idx, contact in enumerate(contacts):
74
+ is_last_contact = idx == len(contacts) - 1
75
+ self._print_contact(contact, is_last_contact)
76
+ print()
77
+
78
+ def _print_contact(self, contact: dict, is_last_contact: bool) -> None:
79
+ """Вывод информации о конкретном контакте."""
80
+ prefix = "└──" if is_last_contact else "├──"
81
+ print(f" {prefix} 🧑 {contact.get('name', 'н/д')}")
82
+ prefix2 = " " if is_last_contact else " │ "
83
+ print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
84
+ employer = contact.get("employer") or {}
85
+ print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
86
+ print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
87
+ print(f"{prefix2}├── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
88
+
89
+ phones = contact["phone_numbers"] or [{"phone_number": "(нет номеров)"}]
90
+ print(f"{prefix2}├── 📞 Телефоны:")
91
+ last_phone = len(phones) - 1
92
+ for i, phone in enumerate(phones):
93
+ sub_prefix = "└──" if i == last_phone else "├──"
94
+ print(f"{prefix2}│ {sub_prefix} {phone['phone_number']}")
95
+
96
+ telegrams = contact["telegram_usernames"] or [
97
+ {"username": "(нет аккаунтов)"}
98
+ ]
99
+ print(f"{prefix2}└── 📱 Telegram:")
100
+ last_telegram = len(telegrams) - 1
101
+ for i, telegram in enumerate(telegrams):
102
+ sub_prefix = "└──" if i == last_telegram else "├──"
103
+ print(f"{prefix2} {sub_prefix} {telegram['username']}")
@@ -0,0 +1,154 @@
1
+ import argparse
2
+ import logging
3
+ import random
4
+ import time
5
+ from typing import Tuple
6
+
7
+ from ..api import ApiError
8
+ from ..main import BaseOperation
9
+ from ..main import Namespace as BaseNamespace, get_api
10
+ from ..utils import parse_interval, random_text
11
+ from ..mixins import GetResumeIdMixin
12
+
13
+ logger = logging.getLogger(__package__)
14
+
15
+
16
+ class Namespace(BaseNamespace):
17
+ reply_message: str
18
+ reply_interval: Tuple[float, float]
19
+ max_pages: int
20
+ dry_run: bool
21
+
22
+
23
+ class Operation(BaseOperation, GetResumeIdMixin):
24
+ """Ответ всем работодателям."""
25
+
26
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
27
+ parser.add_argument(
28
+ "reply_message",
29
+ help="Сообщение для отправки во все чаты с работодателями, где ожидают ответа либо не прочитали ответ",
30
+ )
31
+ parser.add_argument('--resume-id', help="Идентификатор резюме")
32
+ parser.add_argument(
33
+ "--reply-interval",
34
+ help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
35
+ default="5-10",
36
+ type=parse_interval,
37
+ )
38
+ parser.add_argument(
39
+ "--reply-message",
40
+ "--reply",
41
+ help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
42
+ )
43
+ parser.add_argument('--max-pages', type=int, default=25, help='Максимальное количество страниц для проверки')
44
+ parser.add_argument(
45
+ "--dry-run",
46
+ help="Не отправлять сообщения, а только выводить параметры запроса",
47
+ default=False,
48
+ action=argparse.BooleanOptionalAction,
49
+ )
50
+
51
+ def run(self, args: Namespace) -> None:
52
+ self.api = get_api(args)
53
+ self.resume_id = self._get_resume_id()
54
+ self.reply_min_interval, self.reply_max_interval = args.reply_interval
55
+ self.reply_message = args.reply_message
56
+ self.max_pages = args.max_pages
57
+ self.dry_run = args.dry_run
58
+ logger.debug(f'{self.reply_message = }')
59
+ self._reply_chats()
60
+
61
+ def _reply_chats(self) -> None:
62
+ me =self.me= self.api.get("/me")
63
+
64
+ basic_message_placeholders = {
65
+ "first_name": me.get("first_name", ""),
66
+ "last_name": me.get("last_name", ""),
67
+ "email": me.get("email", ""),
68
+ "phone": me.get("phone", ""),
69
+ }
70
+
71
+ for negotiation in self._get_negotiations():
72
+ try:
73
+ # Пропускаем другие резюме
74
+ if self.resume_id != negotiation['resume']['id']:
75
+ continue
76
+
77
+ nid = negotiation["id"]
78
+ vacancy = negotiation["vacancy"]
79
+
80
+ message_placeholders = {
81
+ "vacancy_name": vacancy.get("name", ""),
82
+ "employer_name": vacancy.get("employer", {}).get(
83
+ "name", ""
84
+ ),
85
+ **basic_message_placeholders,
86
+ }
87
+
88
+ logger.debug(
89
+ "Вакансия %(vacancy_name)s от %(employer_name)s"
90
+ % message_placeholders
91
+ )
92
+
93
+ page: int = 0
94
+ last_message: dict | None = None
95
+ while True:
96
+ messages_res = self.api.get(
97
+ f"/negotiations/{nid}/messages", page=page
98
+ )
99
+ last_message = messages_res["items"][-1]
100
+ if page + 1 >= messages_res["pages"]:
101
+ break
102
+
103
+ page = messages_res["pages"] - 1
104
+
105
+ logger.debug(last_message["text"])
106
+
107
+ if last_message["author"][
108
+ "participant_type"
109
+ ] == "employer" or not negotiation.get(
110
+ "viewed_by_opponent"
111
+ ):
112
+ message = (
113
+ random_text(self.reply_message)
114
+ % message_placeholders
115
+ )
116
+ logger.debug(message)
117
+
118
+ if self.dry_run:
119
+ logger.info(
120
+ "Dry Run: Отправка сообщения в чат по вакансии %s: %s",
121
+ vacancy["alternate_url"],
122
+ message,
123
+ )
124
+ continue
125
+
126
+ time.sleep(
127
+ random.uniform(
128
+ self.reply_min_interval,
129
+ self.reply_max_interval,
130
+ )
131
+ )
132
+ self.api.post(
133
+ f"/negotiations/{nid}/messages",
134
+ message=message,
135
+ )
136
+ print(
137
+ "📨 Отправили сообщение для",
138
+ vacancy["alternate_url"],
139
+ )
140
+ except ApiError as ex:
141
+ logger.error(ex)
142
+
143
+ print("📝 Сообщения разосланы!")
144
+
145
+ def _get_negotiations(self) -> list[dict]:
146
+ rv = []
147
+ for page in range(self.max_pages):
148
+ res = self.api.get("/negotiations", page=page, status='active')
149
+ rv.extend(res["items"])
150
+ if page >= res["pages"] - 1:
151
+ break
152
+ page += 1
153
+
154
+ return rv
@@ -1,13 +1,15 @@
1
- import os
2
1
  import json
3
- from urllib.parse import urljoin
4
- import requests
5
- from typing import Optional, Dict, Any
6
2
  import logging
7
- from functools import partialmethod
3
+ import os
4
+ import time
8
5
  import warnings
6
+ from functools import partialmethod
7
+ from typing import Any, Dict, Optional
8
+ from urllib.parse import urljoin
9
+
10
+ import requests
9
11
 
10
- warnings.filterwarnings('ignore', message='Unverified HTTPS request')
12
+ warnings.filterwarnings("ignore", message="Unverified HTTPS request")
11
13
 
12
14
  logger = logging.getLogger(__package__)
13
15
 
@@ -22,6 +24,7 @@ class TelemetryClient:
22
24
  """Клиент для отправки телеметрии на сервер."""
23
25
 
24
26
  server_address: str = "https://hh-applicant-tool.mooo.com:54157/"
27
+ default_delay: float = 0.334 # Задержка по умолчанию в секундах
25
28
 
26
29
  def __init__(
27
30
  self,
@@ -30,6 +33,7 @@ class TelemetryClient:
30
33
  session: Optional[requests.Session] = None,
31
34
  user_agent: str = "Mozilla/5.0 (HHApplicantTelemetry/1.0)",
32
35
  proxies: dict | None = None,
36
+ delay: Optional[float] = None,
33
37
  ) -> None:
34
38
  self.server_address = os.getenv(
35
39
  "TELEMETRY_SERVER", server_address or self.server_address
@@ -37,16 +41,28 @@ class TelemetryClient:
37
41
  self.session = session or requests.Session()
38
42
  self.user_agent = user_agent
39
43
  self.proxies = proxies
44
+ self.delay = delay if delay is not None else self.default_delay
45
+ self.last_request_time = time.monotonic() # Время последнего запроса
40
46
 
41
47
  def request(
42
48
  self,
43
49
  method: str,
44
50
  endpoint: str,
45
51
  data: Dict[str, Any] | None = None,
52
+ **kwargs: Any,
46
53
  ) -> Dict[str, Any]:
47
54
  method = method.upper()
48
55
  url = urljoin(self.server_address, endpoint)
49
56
  has_body = method in ["POST", "PUT", "PATCH"]
57
+
58
+ # Вычисляем время, прошедшее с последнего запроса
59
+ current_time = time.monotonic()
60
+ time_since_last_request = current_time - self.last_request_time
61
+
62
+ # Если прошло меньше времени, чем задержка, ждем оставшееся время
63
+ if time_since_last_request < self.delay:
64
+ time.sleep(self.delay - time_since_last_request)
65
+
50
66
  try:
51
67
  response = self.session.request(
52
68
  method,
@@ -56,6 +72,7 @@ class TelemetryClient:
56
72
  params=data if not has_body else None,
57
73
  json=data if has_body else None,
58
74
  verify=False, # Игнорирование истекшего сертификата
75
+ **kwargs,
59
76
  )
60
77
  # response.raise_for_status()
61
78
  result = response.json()
@@ -68,5 +85,9 @@ class TelemetryClient:
68
85
  json.JSONDecodeError,
69
86
  ) as ex:
70
87
  raise TelemetryError(str(ex)) from ex
88
+ finally:
89
+ # Обновляем время последнего запроса
90
+ self.last_request_time = time.monotonic()
71
91
 
92
+ get_telemetry = partialmethod(request, "GET")
72
93
  send_telemetry = partialmethod(request, "POST")
@@ -1,17 +1,19 @@
1
1
  from __future__ import annotations
2
- from datetime import datetime
2
+
3
3
  import hashlib
4
4
  import json
5
5
  import platform
6
+ import random
7
+ import re
6
8
  import sys
9
+ from datetime import datetime
7
10
  from functools import partial
11
+ from os import getenv
8
12
  from pathlib import Path
9
13
  from threading import Lock
10
14
  from typing import Any
11
- from os import getenv
15
+
12
16
  from .constants import INVALID_ISO8601_FORMAT
13
- import re
14
- import random
15
17
 
16
18
  print_err = partial(print, file=sys.stderr, flush=True)
17
19
 
@@ -53,7 +55,13 @@ class Config(dict):
53
55
  self._config_path.parent.mkdir(exist_ok=True, parents=True)
54
56
  with self._lock:
55
57
  with self._config_path.open("w+") as fp:
56
- json.dump(self, fp, ensure_ascii=True, indent=2, sort_keys=True)
58
+ json.dump(
59
+ self,
60
+ fp,
61
+ ensure_ascii=True,
62
+ indent=2,
63
+ sort_keys=True,
64
+ )
57
65
 
58
66
  __getitem__ = dict.get
59
67
 
@@ -62,18 +70,17 @@ def truncate_string(s: str, limit: int = 75, ellipsis: str = "…") -> str:
62
70
  return s[:limit] + bool(s[limit:]) * ellipsis
63
71
 
64
72
 
65
- def hash_with_salt(data: str, salt: str = "HorsePenis") -> str:
66
- # Объединяем данные и соль
67
- salted_data = data + salt
73
+ def make_hash(data: str) -> str:
68
74
  # Вычисляем хеш SHA-256
69
- hashed_data = hashlib.sha256(salted_data.encode()).hexdigest()
70
- return hashed_data
75
+ return hashlib.sha256(data.encode()).hexdigest()
76
+
77
+
78
+ def parse_invalid_datetime(dt: str) -> datetime:
79
+ return datetime.strptime(dt, INVALID_ISO8601_FORMAT)
71
80
 
72
81
 
73
82
  def fix_datetime(dt: str | None) -> str | None:
74
- if dt is None:
75
- return None
76
- return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
83
+ return parse_invalid_datetime(dt).isoformat() if dt is not None else None
77
84
 
78
85
 
79
86
  def random_text(s: str) -> str:
@@ -88,3 +95,12 @@ def random_text(s: str) -> str:
88
95
  ) != s:
89
96
  s = s1
90
97
  return s
98
+
99
+
100
+ def parse_interval(interval: str) -> tuple[float, float]:
101
+ """Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
102
+ if "-" in interval:
103
+ min_interval, max_interval = map(float, interval.split("-"))
104
+ else:
105
+ min_interval = max_interval = float(interval)
106
+ return min(min_interval, max_interval), max(min_interval, max_interval)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hh-applicant-tool
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -32,10 +32,10 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  ### Описание
34
34
 
35
- Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/+aSjr8qM_AP85ZDBi (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
35
+ Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Но данная утилита больше чем просто спамилка отзывами, вы так же выступаете в роли тайного агента, и если в списке подходящих вакансий встречается отказ, она возвращает ссылку на обсуждение работодателя в группе [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там вы можете написать отзыв о работодателе и почитать чужие. Обсуждения без отзывов удаляются. Данные на сервер передаются анонимно. Для этого собираются данные о работодателях и их вакансиях. Никакие персональные данные пользователя утилиты под которыми, вы авторизуетесь никуда не отправляются — только работодателей и рекрутеров. Отправку этих данных можно отключить (флаг `--disable-telemetry`), но тогда вы не получите ссылку на обсуждение, а так же не сможете пожаловаться на неадекватного мудака, выкатившего отказ после "небольшого" тестового задания на недельку. Через сайты на таких жаловаться бесполезно: владелец сайта за деньги отзывы удаляет, или мудак его запугает и жалоб в РКН накидает, а последний всегда найдет за что сайт заблокировать. Единственное место где можно написать отзыв — это **Telegram**.
36
36
 
37
37
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
38
- asdf/pyenv/conda и что-то еще...
38
+ asdf/pyenv/conda и что-то еще.
39
39
 
40
40
  Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
41
41
 
@@ -240,10 +240,11 @@ https://hh.ru/employer/1918903
240
240
  | **list-resumes** | Список резюме |
241
241
  | **update-resumes** | Обновить все резюме. Аналогично нажатию кнопки «Обновить дату». |
242
242
  | **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в день. На HH есть спам-фильтры, так что лучше не рассылайте отклики со ссылками. |
243
+ | **reply-employers** | Ответить во все чаты с работодателями, где нет ответа либо не прочитали ваш предыдущий ответ |
243
244
  | **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
244
245
  | **call-api** | Вызов произвольного метода API с выводом результата. |
245
246
  | **refresh-token** | Обновляет access_token. |
246
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, с сайта фирмы и могут быть удалены только по просьбе уполнамоченного лица. Данная функция готова и будет доступна после 100 ⭐ |
247
+ | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Это функционал для избранных, но в группе есть бесплатный бот с тем же функционалом. |
247
248
 
248
249
  ### Формат текста сообщений
249
250
 
@@ -282,26 +283,9 @@ https://hh.ru/employer/1918903
282
283
 
283
284
  Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
284
285
 
285
- ### Сбор данных
286
+ Для создания своих плагинов прочитайте документацию:
286
287
 
287
- > Данный функционал можно отключить с помощью специльного флага, но ради котят и из-за ненависти к херкам не делайте этого!
288
-
289
- Утилита собирает и передает на сервер разработчика следующую информацию:
290
-
291
- 1. Название вакансии.
292
- 1. Тип вакансии (открытая/закрытая).
293
- 1. Город, в котором размещена вакансия.
294
- 1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
295
- 1. Прямая ссылка на вакансию.
296
- 1. Дата создания вакансии.
297
- 1. Дата публикации вакансии.
298
- 1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
299
- 1. Название компании.
300
- 1. Тип компании.
301
- 1. Описание компании.
302
- 1. Ссылка на сайт компании.
303
- 1. Город, в котором находится компания.
304
-
305
- **УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
288
+ * [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
306
289
 
290
+ Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
307
291
 
@@ -5,20 +5,23 @@ hh_applicant_tool/api/client.py,sha256=um9NX22hNOtSuPCobCKf1anIFp-jiZlIXm4BuqN-L
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=0PeUmWSyKAhAtTK1w5HDwywSbPckOK4oOXWDKo2oxnM,4836
8
+ hh_applicant_tool/main.py,sha256=z_SAW7cV83P5mVEkuddSzETUGLqocsNyEKlA6HBHjQ0,4806
9
+ hh_applicant_tool/mixins.py,sha256=66LmyYSsDfhrpUwoAONjzrd5aoXqaZVoQ-zXhyYbYMk,418
9
10
  hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- hh_applicant_tool/operations/apply_similar.py,sha256=AwEyG8VdlcbF1JCb2B2EvKiXGa6GtBY5VwHe1O0TPY0,17944
11
+ hh_applicant_tool/operations/apply_similar.py,sha256=iMHbuqNYogL-cfP_RYxKTkWnWQDRWgiY0YACWw3Glzw,14811
11
12
  hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
12
13
  hh_applicant_tool/operations/call_api.py,sha256=qwPDrWP9tiqHkaYYWYBZtymj9AaxObB86Eny-Bf5q_c,1314
13
14
  hh_applicant_tool/operations/clear_negotiations.py,sha256=98Yuw9xP4dN5sUnUKlZxqfhU40TQ-aCjK4vj4LRTeYo,3894
15
+ hh_applicant_tool/operations/get_employer_contacts.py,sha256=7BEzEnNAp87RlOP6HX0LR6cbtud2FuKCKK5sq6fINq8,4066
14
16
  hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOvAULnLkLkyUh7gxw,1134
15
17
  hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
18
+ hh_applicant_tool/operations/reply_employers.py,sha256=wwDcI9YeZGUwadWQYFBwNpXb8qSAejaJ4KAuQTfFIuk,5686
16
19
  hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
17
20
  hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
18
- hh_applicant_tool/telemetry_client.py,sha256=nNNr1drXY9Z01u5tJX---BXxBg1y06nJpNbhU45DmE0,2239
21
+ hh_applicant_tool/telemetry_client.py,sha256=wYLbKnx3sOmESFHqjLt-0Gww1O3lJiXFYdWnsorIhK8,3261
19
22
  hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
20
- hh_applicant_tool/utils.py,sha256=lHQh94CEwWp14Ty50ecZPcR3YyqLDVlmgmZlrBiBgHQ,2557
21
- hh_applicant_tool-0.4.0.dist-info/METADATA,sha256=XqFSSxWsouQbKDxRO8hYLLF17HaCphCu3EAgmlnM2GE,20770
22
- hh_applicant_tool-0.4.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
23
- hh_applicant_tool-0.4.0.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
24
- hh_applicant_tool-0.4.0.dist-info/RECORD,,
23
+ hh_applicant_tool/utils.py,sha256=vjSRbwU8mduFgORcyO2sQj-2B6klzQCtg_CFVCsgCo4,3067
24
+ hh_applicant_tool-0.5.0.dist-info/METADATA,sha256=xoA2y1wx6s-oTOS8GMw-9o78NpUy34SuLaf85w8eGos,19969
25
+ hh_applicant_tool-0.5.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
26
+ hh_applicant_tool-0.5.0.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
27
+ hh_applicant_tool-0.5.0.dist-info/RECORD,,