hh-applicant-tool 0.3.9__py3-none-any.whl → 0.4.1__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,7 +3,6 @@ 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
8
7
  from pathlib import Path
9
8
  from pkgutil import iter_modules
@@ -14,17 +13,17 @@ from .utils import Config, get_config_path
14
13
  from os import getenv
15
14
 
16
15
  DEFAULT_CONFIG_PATH = (
17
- get_config_path() / __package__.replace("_", "-") / "config.json"
16
+ get_config_path() / (__package__ or '').replace("_", "-") / "config.json"
18
17
  )
19
18
 
20
19
  logger = logging.getLogger(__package__)
21
20
 
22
21
 
23
- class BaseOperation(metaclass=ABCMeta):
22
+ class BaseOperation:
24
23
  def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
25
24
 
26
- @abstractmethod
27
- def run(self, args: argparse.Namespace) -> None | int: ...
25
+ def run(self, args: argparse.Namespace) -> None | int:
26
+ raise NotImplementedError()
28
27
 
29
28
 
30
29
  OPERATIONS = "operations"
@@ -36,6 +35,7 @@ class Namespace(argparse.Namespace):
36
35
  delay: float
37
36
  user_agent: str
38
37
  proxy_url: str
38
+ disable_telemetry: bool
39
39
 
40
40
 
41
41
  def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
@@ -103,6 +103,12 @@ class HHApplicantTool:
103
103
  parser.add_argument(
104
104
  "--proxy-url", help="Прокси, используемый для запросов к API"
105
105
  )
106
+ parser.add_argument(
107
+ "--disable-telemetry",
108
+ default=False,
109
+ action=argparse.BooleanOptionalAction,
110
+ help="Отключить телеметрию",
111
+ )
106
112
  subparsers = parser.add_subparsers(help="commands")
107
113
  package_dir = Path(__file__).resolve().parent / OPERATIONS
108
114
  for _, module_name, _ in iter_modules([str(package_dir)]):
@@ -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,15 @@ import logging
3
3
  import random
4
4
  import time
5
5
  from collections import defaultdict
6
- from os import getenv
7
6
  from typing import TextIO, Tuple
8
7
 
9
- from ..api import ApiClient, ApiError, BadRequest
8
+ from ..api import ApiError, BadRequest
10
9
  from ..main import BaseOperation
11
10
  from ..main import Namespace as BaseNamespace, get_api
12
11
  from ..telemetry_client import TelemetryClient, TelemetryError
13
12
  from ..types import ApiListResponse, VacancyItem
14
- from ..utils import fix_datetime, truncate_string, random_text
15
- from requests import Session
13
+ from ..utils import fix_datetime, truncate_string, random_text, parse_interval
14
+ from ..mixins import GetResumeIdMixin
16
15
 
17
16
  logger = logging.getLogger(__package__)
18
17
 
@@ -23,22 +22,19 @@ class Namespace(BaseNamespace):
23
22
  force_message: bool
24
23
  apply_interval: Tuple[float, float]
25
24
  page_interval: Tuple[float, float]
26
- message_interval: Tuple[float, float]
27
25
  order_by: str
28
26
  search: str
29
- reply_message: str
27
+ dry_run: bool
30
28
 
31
29
 
32
- # gx для открытия (никак не запомню в виме)
33
- # https://api.hh.ru/openapi/redoc
34
- class Operation(BaseOperation):
35
- """Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
30
+ class Operation(BaseOperation, GetResumeIdMixin):
31
+ """Откликнуться на все подходящие вакансии."""
36
32
 
37
33
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
38
34
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
39
35
  parser.add_argument(
40
36
  "--message-list",
41
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(vacabcy_name)s",
37
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
42
38
  type=argparse.FileType(),
43
39
  )
44
40
  parser.add_argument(
@@ -51,19 +47,13 @@ class Operation(BaseOperation):
51
47
  "--apply-interval",
52
48
  help="Интервал перед отправкой откликов в секундах (X, X-Y)",
53
49
  default="1-5",
54
- type=self._parse_interval,
50
+ type=parse_interval,
55
51
  )
56
52
  parser.add_argument(
57
53
  "--page-interval",
58
54
  help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
59
55
  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,
56
+ type=parse_interval,
67
57
  )
68
58
  parser.add_argument(
69
59
  "--order-by",
@@ -79,62 +69,50 @@ class Operation(BaseOperation):
79
69
  )
80
70
  parser.add_argument(
81
71
  "--search",
82
- help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зарплату",
72
+ help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
83
73
  type=str,
84
74
  default=None,
85
75
  )
86
76
  parser.add_argument(
87
- "--reply-message",
88
- "--reply",
89
- help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
77
+ "--dry-run",
78
+ help="Не отправлять отклики, а только выводить параметры запроса",
79
+ default=False,
80
+ action=argparse.BooleanOptionalAction,
90
81
  )
91
82
 
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
83
  def run(self, args: Namespace) -> None:
102
- api = get_api(args)
103
- resume_id = self._get_resume_id(args, api)
104
- application_messages = self._get_application_messages(args)
105
-
106
- apply_min_interval, apply_max_interval = args.apply_interval
107
- page_min_interval, page_max_interval = args.page_interval
108
- message_min_interval, message_max_interval = args.message_interval
109
-
110
- self._apply_similar(
111
- api,
112
- resume_id,
113
- args.force_message,
114
- application_messages,
115
- apply_min_interval,
116
- apply_max_interval,
117
- page_min_interval,
118
- page_max_interval,
119
- message_min_interval,
120
- message_max_interval,
121
- args.order_by,
122
- args.search,
123
- args.reply_message or args.config["reply_message"],
124
- )
125
-
126
- def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
127
- if not (
128
- resume_id := args.resume_id or args.config["default_resume_id"]
129
- ):
130
- resumes: ApiListResponse = api.get("/resumes/mine")
131
- resume_id = resumes["items"][0]["id"]
132
- return resume_id
133
-
134
- def _get_application_messages(self, args: Namespace) -> list[str]:
135
- if args.message_list:
84
+ self.enable_telemetry = True
85
+ if args.disable_telemetry:
86
+ print(
87
+ "👁️ Телеметрия используется только для сбора данных о работодателях и их вакансиях, персональные данные пользователей не передаются на сервер."
88
+ )
89
+ if (
90
+ input("Вы действительно хотите отключить телеметрию (д/Н)? ")
91
+ .lower()
92
+ .startswith(("д", "y"))
93
+ ):
94
+ self.enable_telemetry = False
95
+ logger.info("Телеметрия отключена.")
96
+ else:
97
+ logger.info("Спасибо за то что оставили телеметрию включенной!")
98
+
99
+ self.api = get_api(args)
100
+ self.resume_id = args.resume_id or self._get_resume_id()
101
+ self.application_messages = self._get_application_messages(args.message_list)
102
+
103
+ self.apply_min_interval, self.apply_max_interval = args.apply_interval
104
+ self.page_min_interval, self.page_max_interval = args.page_interval
105
+
106
+ self.force_message = args.force_message
107
+ self.order_by = args.order_by
108
+ self.search = args.search
109
+ self.dry_run = args.dry_run
110
+ self._apply_similar()
111
+
112
+ def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
113
+ if message_list:
136
114
  application_messages = list(
137
- filter(None, map(str.strip, args.message_list))
115
+ filter(None, map(str.strip, message_list))
138
116
  )
139
117
  else:
140
118
  application_messages = [
@@ -143,38 +121,39 @@ class Operation(BaseOperation):
143
121
  ]
144
122
  return application_messages
145
123
 
146
- def _apply_similar(
147
- self,
148
- api: ApiClient,
149
- resume_id: str,
150
- force_message: bool,
151
- application_messages: list[str],
152
- apply_min_interval: float,
153
- apply_max_interval: float,
154
- page_min_interval: float,
155
- page_max_interval: float,
156
- message_min_interval: float,
157
- message_max_interval: float,
158
- order_by: str,
159
- search: str | None = None,
160
- reply_message: str | None = None,
161
- ) -> None:
162
- telemetry_client = TelemetryClient(proxies=api.proxies)
124
+ def _apply_similar(self) -> None:
125
+ telemetry_client = TelemetryClient(proxies=self.api.proxies)
163
126
  telemetry_data = defaultdict(dict)
164
127
 
165
- vacancies = self._get_vacancies(
166
- api,
167
- resume_id,
168
- page_min_interval,
169
- page_max_interval,
170
- per_page=100,
171
- order_by=order_by,
172
- search=search,
173
- )
174
-
175
- self._collect_vacancy_telemetry(telemetry_data, vacancies)
128
+ vacancies = self._get_vacancies()
129
+
130
+ if self.enable_telemetry:
131
+ for vacancy in vacancies:
132
+ vacancy_id = vacancy["id"]
133
+ telemetry_data["vacancies"][vacancy_id] = {
134
+ "name": vacancy.get("name"),
135
+ "type": vacancy.get("type", {}).get("id"), # open/closed
136
+ "area": vacancy.get("area", {}).get("name"), # город
137
+ "salary": vacancy.get("salary"), # from, to, currency, gross
138
+ "direct_url": vacancy.get(
139
+ "alternate_url"
140
+ ), # ссылка на вакансию
141
+ "created_at": fix_datetime(
142
+ vacancy.get("created_at")
143
+ ), # будем вычислять говно-вакансии, которые по полгода висят
144
+ "published_at": fix_datetime(vacancy.get("published_at")),
145
+ "contacts": vacancy.get(
146
+ "contacts"
147
+ ), # пиздорванки там телеграм для связи указывают
148
+ # HH с точки зрения перфикциониста — кусок говна, где кривые
149
+ # форматы даты, у вакансий может не быть работодателя...
150
+ "employer_id": int(vacancy["employer"]["id"])
151
+ if "employer" in vacancy and "id" in vacancy["employer"]
152
+ else None,
153
+ # Остальное неинтересно
154
+ }
176
155
 
177
- me = api.get("/me")
156
+ me = self.api.get("/me")
178
157
 
179
158
  basic_message_placeholders = {
180
159
  "first_name": me.get("first_name", ""),
@@ -183,13 +162,8 @@ class Operation(BaseOperation):
183
162
  "phone": me.get("phone", ""),
184
163
  }
185
164
 
186
- do_apply = True
187
-
188
165
  for vacancy in vacancies:
189
166
  try:
190
- if getenv("TEST_TELEMETRY"):
191
- break
192
-
193
167
  message_placeholders = {
194
168
  "vacancy_name": vacancy.get("name", ""),
195
169
  "employer_name": vacancy.get("employer", {}).get(
@@ -212,74 +186,14 @@ class Operation(BaseOperation):
212
186
  "🚫 Пропускаем вакансию в архиве",
213
187
  vacancy["alternate_url"],
214
188
  )
215
-
216
189
  continue
217
190
 
218
191
  relations = vacancy.get("relations", [])
219
192
 
220
193
  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
-
281
194
  print(
282
- "🚫 Пропускаем вакансию с откликом",
195
+ "🚫 Пропускаем вакансию с",
196
+ ["откликом или приглашением", "отказом"]["got_rejection" in relations],
283
197
  vacancy["alternate_url"],
284
198
  )
285
199
  continue
@@ -287,11 +201,11 @@ class Operation(BaseOperation):
287
201
  employer_id = vacancy.get("employer", {}).get("id")
288
202
 
289
203
  if (
290
- employer_id
204
+ self.enable_telemetry
205
+ and employer_id
291
206
  and employer_id not in telemetry_data["employers"]
292
- and 200 > len(telemetry_data["employers"])
293
207
  ):
294
- employer = api.get(f"/employers/{employer_id}")
208
+ employer = self.api.get(f"/employers/{employer_id}")
295
209
  telemetry_data["employers"][employer_id] = {
296
210
  "name": employer.get("name"),
297
211
  "type": employer.get("type"),
@@ -300,35 +214,34 @@ class Operation(BaseOperation):
300
214
  "area": employer.get("area", {}).get("name"), # город
301
215
  }
302
216
 
303
- if not do_apply:
304
- logger.debug("skip apply similar")
305
- continue
306
-
307
217
  params = {
308
- "resume_id": resume_id,
218
+ "resume_id": self.resume_id,
309
219
  "vacancy_id": vacancy["id"],
310
220
  "message": "",
311
221
  }
312
222
 
313
- if force_message or vacancy.get("response_letter_required"):
223
+ if self.force_message or vacancy.get("response_letter_required"):
314
224
  msg = params["message"] = (
315
- random_text(random.choice(application_messages))
225
+ random_text(random.choice(self.application_messages))
316
226
  % message_placeholders
317
227
  )
318
228
  logger.debug(msg)
319
229
 
230
+ if self.dry_run:
231
+ logger.info(
232
+ "Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
233
+ vacancy["alternate_url"],
234
+ params,
235
+ )
236
+ continue
237
+
320
238
  # Задержка перед отправкой отклика
321
239
  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,
240
+ self.apply_min_interval, self.apply_max_interval
328
241
  )
329
242
  time.sleep(interval)
330
243
 
331
- res = api.post("/negotiations", params)
244
+ res = self.api.post("/negotiations", params)
332
245
  assert res == {}
333
246
  print(
334
247
  "📨 Отправили отклик",
@@ -340,87 +253,44 @@ class Operation(BaseOperation):
340
253
  except ApiError as ex:
341
254
  logger.error(ex)
342
255
  if isinstance(ex, BadRequest) and ex.limit_exceeded:
343
- if not reply_message:
344
- break
345
- do_apply = False
256
+ break
346
257
 
347
258
  print("📝 Отклики на вакансии разосланы!")
348
259
 
349
- # Я собираюсь выложить контакты херок в общественный доступ
350
- self._send_telemetry(telemetry_client, telemetry_data)
260
+ if self.enable_telemetry:
261
+ if self.dry_run:
262
+ # С --dry-run можно посмотреть что отправляется
263
+ logger.info('Dry Run: Данные телеметрии для отправки на сервер: %r', telemetry_data)
264
+ return
351
265
 
266
+ try:
267
+ telemetry_client.send_telemetry("/collect", dict(telemetry_data))
268
+ except TelemetryError as ex:
269
+ logger.error(ex)
270
+
352
271
  def _get_vacancies(
353
- self,
354
- api: ApiClient,
355
- resume_id: str,
356
- page_min_interval: float,
357
- page_max_interval: float,
358
- per_page: int,
359
- order_by: str,
360
- search: str | None = None,
272
+ self, per_page: int = 100
361
273
  ) -> list[VacancyItem]:
362
274
  rv = []
363
275
  for page in range(20):
364
276
  params = {
365
277
  "page": page,
366
278
  "per_page": per_page,
367
- "order_by": order_by,
279
+ "order_by": self.order_by,
368
280
  }
369
- if search:
370
- params["text"] = search
371
- res: ApiListResponse = api.get(
372
- f"/resumes/{resume_id}/similar_vacancies", params
281
+ if self.search:
282
+ params["text"] = self.search
283
+ res: ApiListResponse = self.api.get(
284
+ f"/resumes/{self.resume_id}/similar_vacancies", params
373
285
  )
374
286
  rv.extend(res["items"])
375
-
376
- if getenv("TEST_TELEMETRY"):
377
- break
378
-
379
287
  if page >= res["pages"] - 1:
380
288
  break
381
289
 
382
290
  # Задержка перед получением следующей страницы
383
291
  if page > 0:
384
- interval = random.uniform(page_min_interval, page_max_interval)
292
+ interval = random.uniform(self.page_min_interval, self.page_max_interval)
385
293
  time.sleep(interval)
386
294
 
387
295
  return rv
388
296
 
389
- def _collect_vacancy_telemetry(
390
- self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
391
- ) -> None:
392
- for vacancy in vacancies:
393
- vacancy_id = vacancy["id"]
394
- telemetry_data["vacancies"][vacancy_id] = {
395
- "name": vacancy.get("name"),
396
- "type": vacancy.get("type", {}).get("id"), # open/closed
397
- "area": vacancy.get("area", {}).get("name"), # город
398
- "salary": vacancy.get("salary"), # from, to, currency, gross
399
- "direct_url": vacancy.get(
400
- "alternate_url"
401
- ), # ссылка на вакансию
402
- "created_at": fix_datetime(
403
- vacancy.get("created_at")
404
- ), # будем вычислять говно-вакансии, которые по полгода висят
405
- "published_at": fix_datetime(vacancy.get("published_at")),
406
- "contacts": vacancy.get(
407
- "contacts"
408
- ), # пиздорванки там телеграм для связи указывают
409
- # HH с точки зрения перфикциониста — кусок говна, где кривые
410
- # форматы даты, у вакансий может не быть работодателя...
411
- "employer_id": int(vacancy["employer"]["id"])
412
- if "employer" in vacancy and "id" in vacancy["employer"]
413
- else None,
414
- # Остальное неинтересно
415
- }
416
-
417
- def _send_telemetry(
418
- self, telemetry_client, telemetry_data: defaultdict
419
- ) -> None:
420
- try:
421
- res = telemetry_client.send_telemetry(
422
- "/collect", dict(telemetry_data)
423
- )
424
- logger.debug(res)
425
- except TelemetryError as ex:
426
- logger.error(ex)
@@ -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
@@ -88,3 +88,11 @@ def random_text(s: str) -> str:
88
88
  ) != s:
89
89
  s = s1
90
90
  return s
91
+
92
+ def parse_interval(interval: str) -> tuple[float, float]:
93
+ """Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
94
+ if "-" in interval:
95
+ min_interval, max_interval = map(float, interval.split("-"))
96
+ else:
97
+ min_interval = max_interval = float(interval)
98
+ 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.3.9
3
+ Version: 0.4.1
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -240,6 +240,7 @@ 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. |
@@ -282,9 +283,17 @@ 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
 
286
+ Для создания своих плагинов прочитайте документацию:
287
+
288
+ * [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
289
+
290
+ Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
291
+
285
292
  ### Сбор данных
286
293
 
287
- Утилита собирает и передает на сервер разработчика следующую информацию:
294
+ Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
295
+
296
+ Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
288
297
 
289
298
  1. Название вакансии.
290
299
  1. Тип вакансии (открытая/закрытая).
@@ -293,15 +302,11 @@ https://hh.ru/employer/1918903
293
302
  1. Прямая ссылка на вакансию.
294
303
  1. Дата создания вакансии.
295
304
  1. Дата публикации вакансии.
296
- 1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
305
+ 1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
297
306
  1. Название компании.
298
307
  1. Тип компании.
299
308
  1. Описание компании.
300
- 1. Ссылка на сайт компании.
309
+ 1. Ссылка на сайт компании.
301
310
  1. Город, в котором находится компания.
302
311
 
303
- [Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
304
-
305
- !!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ (IP ТОЖЕ НЕ СОХРАНЯЕТ) — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ)
306
-
307
312
 
@@ -5,20 +5,22 @@ 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=B_kI9MlaT_064r5CL7Pjlzu76QPB-hXCaMpFtB-BOfg,4596
8
+ hh_applicant_tool/main.py,sha256=DhnyINELRlp4i9ENlwDmzgU-C23ngy-hYlKXScivPIg,4797
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=8L0Im0bPRxn-tsteI9X4D5gPpr6PemuzK0x-IszrOis,17021
11
+ hh_applicant_tool/operations/apply_similar.py,sha256=X3OLYzMnRXI7_v6w2i3RxpDDHUj-Yf5DAJB7KCSAmWA,12348
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
14
15
  hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOvAULnLkLkyUh7gxw,1134
15
16
  hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
17
+ hh_applicant_tool/operations/reply_employers.py,sha256=wwDcI9YeZGUwadWQYFBwNpXb8qSAejaJ4KAuQTfFIuk,5686
16
18
  hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
17
19
  hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
18
20
  hh_applicant_tool/telemetry_client.py,sha256=nNNr1drXY9Z01u5tJX---BXxBg1y06nJpNbhU45DmE0,2239
19
21
  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.3.9.dist-info/METADATA,sha256=FbQA5oxUmGEGhuUehYcci7vMrUmiad0a8OUqPOHqsG8,20575
22
- hh_applicant_tool-0.3.9.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
23
- hh_applicant_tool-0.3.9.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
24
- hh_applicant_tool-0.3.9.dist-info/RECORD,,
22
+ hh_applicant_tool/utils.py,sha256=XFdQUOUm1DHJhVLRDLbXabOXtwfQAuk8Mqd-TTqNdgc,3017
23
+ hh_applicant_tool-0.4.1.dist-info/METADATA,sha256=XhxhXDFl5Q2Lp6TNfS9YY0oHwoZxt1PouUTOWcLGE00,20967
24
+ hh_applicant_tool-0.4.1.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
25
+ hh_applicant_tool-0.4.1.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
26
+ hh_applicant_tool-0.4.1.dist-info/RECORD,,