hh-applicant-tool 0.4.0__tar.gz → 0.4.1__tar.gz

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.

Files changed (26) hide show
  1. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/PKG-INFO +11 -6
  2. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/README.md +10 -5
  3. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/main.py +4 -5
  4. hh_applicant_tool-0.4.1/hh_applicant_tool/mixins.py +13 -0
  5. hh_applicant_tool-0.4.1/hh_applicant_tool/operations/apply_similar.py +296 -0
  6. hh_applicant_tool-0.4.1/hh_applicant_tool/operations/reply_employers.py +154 -0
  7. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/utils.py +8 -0
  8. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/pyproject.toml +1 -1
  9. hh_applicant_tool-0.4.0/hh_applicant_tool/operations/apply_similar.py +0 -443
  10. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/__init__.py +0 -0
  11. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/__main__.py +0 -0
  12. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/api/__init__.py +0 -0
  13. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/api/client.py +0 -0
  14. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/api/errors.py +0 -0
  15. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/color_log.py +0 -0
  16. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/constants.py +0 -0
  17. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/__init__.py +0 -0
  18. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/authorize.py +0 -0
  19. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/call_api.py +0 -0
  20. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/clear_negotiations.py +0 -0
  21. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/list_resumes.py +0 -0
  22. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/refresh_token.py +0 -0
  23. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/update_resumes.py +0 -0
  24. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/operations/whoami.py +0 -0
  25. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/telemetry_client.py +0 -0
  26. {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.4.1}/hh_applicant_tool/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hh-applicant-tool
3
- Version: 0.4.0
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,11 +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
+ Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
288
295
 
289
- Утилита собирает и передает на сервер разработчика следующую информацию:
296
+ Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
290
297
 
291
298
  1. Название вакансии.
292
299
  1. Тип вакансии (открытая/закрытая).
@@ -295,13 +302,11 @@ https://hh.ru/employer/1918903
295
302
  1. Прямая ссылка на вакансию.
296
303
  1. Дата создания вакансии.
297
304
  1. Дата публикации вакансии.
298
- 1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
305
+ 1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
299
306
  1. Название компании.
300
307
  1. Тип компании.
301
308
  1. Описание компании.
302
309
  1. Ссылка на сайт компании.
303
310
  1. Город, в котором находится компания.
304
311
 
305
- **УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
306
-
307
312
 
@@ -221,6 +221,7 @@ https://hh.ru/employer/1918903
221
221
  | **list-resumes** | Список резюме |
222
222
  | **update-resumes** | Обновить все резюме. Аналогично нажатию кнопки «Обновить дату». |
223
223
  | **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в день. На HH есть спам-фильтры, так что лучше не рассылайте отклики со ссылками. |
224
+ | **reply-employers** | Ответить во все чаты с работодателями, где нет ответа либо не прочитали ваш предыдущий ответ |
224
225
  | **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
225
226
  | **call-api** | Вызов произвольного метода API с выводом результата. |
226
227
  | **refresh-token** | Обновляет access_token. |
@@ -263,11 +264,17 @@ https://hh.ru/employer/1918903
263
264
 
264
265
  Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
265
266
 
267
+ Для создания своих плагинов прочитайте документацию:
268
+
269
+ * [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
270
+
271
+ Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
272
+
266
273
  ### Сбор данных
267
274
 
268
- > Данный функционал можно отключить с помощью специльного флага, но ради котят и из-за ненависти к херкам не делайте этого!
275
+ Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
269
276
 
270
- Утилита собирает и передает на сервер разработчика следующую информацию:
277
+ Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
271
278
 
272
279
  1. Название вакансии.
273
280
  1. Тип вакансии (открытая/закрытая).
@@ -276,12 +283,10 @@ https://hh.ru/employer/1918903
276
283
  1. Прямая ссылка на вакансию.
277
284
  1. Дата создания вакансии.
278
285
  1. Дата публикации вакансии.
279
- 1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
286
+ 1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
280
287
  1. Название компании.
281
288
  1. Тип компании.
282
289
  1. Описание компании.
283
290
  1. Ссылка на сайт компании.
284
291
  1. Город, в котором находится компания.
285
292
 
286
- **УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
287
-
@@ -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"
@@ -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
+
@@ -0,0 +1,296 @@
1
+ import argparse
2
+ import logging
3
+ import random
4
+ import time
5
+ from collections import defaultdict
6
+ from typing import TextIO, Tuple
7
+
8
+ from ..api import ApiError, BadRequest
9
+ from ..main import BaseOperation
10
+ from ..main import Namespace as BaseNamespace, get_api
11
+ from ..telemetry_client import TelemetryClient, TelemetryError
12
+ from ..types import ApiListResponse, VacancyItem
13
+ from ..utils import fix_datetime, truncate_string, random_text, parse_interval
14
+ from ..mixins import GetResumeIdMixin
15
+
16
+ logger = logging.getLogger(__package__)
17
+
18
+
19
+ class Namespace(BaseNamespace):
20
+ resume_id: str | None
21
+ message_list: TextIO
22
+ force_message: bool
23
+ apply_interval: Tuple[float, float]
24
+ page_interval: Tuple[float, float]
25
+ order_by: str
26
+ search: str
27
+ dry_run: bool
28
+
29
+
30
+ class Operation(BaseOperation, GetResumeIdMixin):
31
+ """Откликнуться на все подходящие вакансии."""
32
+
33
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
34
+ parser.add_argument("--resume-id", help="Идентефикатор резюме")
35
+ parser.add_argument(
36
+ "--message-list",
37
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
38
+ type=argparse.FileType(),
39
+ )
40
+ parser.add_argument(
41
+ "--force-message",
42
+ help="Всегда отправлять сообщение при отклике",
43
+ default=False,
44
+ action=argparse.BooleanOptionalAction,
45
+ )
46
+ parser.add_argument(
47
+ "--apply-interval",
48
+ help="Интервал перед отправкой откликов в секундах (X, X-Y)",
49
+ default="1-5",
50
+ type=parse_interval,
51
+ )
52
+ parser.add_argument(
53
+ "--page-interval",
54
+ help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
55
+ default="1-3",
56
+ type=parse_interval,
57
+ )
58
+ parser.add_argument(
59
+ "--order-by",
60
+ help="Сортировка вакансий",
61
+ choices=[
62
+ "publication_time",
63
+ "salary_desc",
64
+ "salary_asc",
65
+ "relevance",
66
+ "distance",
67
+ ],
68
+ default="relevance",
69
+ )
70
+ parser.add_argument(
71
+ "--search",
72
+ help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
73
+ type=str,
74
+ default=None,
75
+ )
76
+ parser.add_argument(
77
+ "--dry-run",
78
+ help="Не отправлять отклики, а только выводить параметры запроса",
79
+ default=False,
80
+ action=argparse.BooleanOptionalAction,
81
+ )
82
+
83
+ def run(self, args: Namespace) -> None:
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:
114
+ application_messages = list(
115
+ filter(None, map(str.strip, message_list))
116
+ )
117
+ else:
118
+ application_messages = [
119
+ "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
120
+ "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
121
+ ]
122
+ return application_messages
123
+
124
+ def _apply_similar(self) -> None:
125
+ telemetry_client = TelemetryClient(proxies=self.api.proxies)
126
+ telemetry_data = defaultdict(dict)
127
+
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
+ }
155
+
156
+ me = self.api.get("/me")
157
+
158
+ basic_message_placeholders = {
159
+ "first_name": me.get("first_name", ""),
160
+ "last_name": me.get("last_name", ""),
161
+ "email": me.get("email", ""),
162
+ "phone": me.get("phone", ""),
163
+ }
164
+
165
+ for vacancy in vacancies:
166
+ try:
167
+ message_placeholders = {
168
+ "vacancy_name": vacancy.get("name", ""),
169
+ "employer_name": vacancy.get("employer", {}).get(
170
+ "name", ""
171
+ ),
172
+ **basic_message_placeholders,
173
+ }
174
+
175
+ logger.debug(
176
+ "Вакансия %(vacancy_name)s от %(employer_name)s"
177
+ % message_placeholders
178
+ )
179
+
180
+ if vacancy.get("has_test"):
181
+ print("🚫 Пропускаем тест", vacancy["alternate_url"])
182
+ continue
183
+
184
+ if vacancy.get("archived"):
185
+ print(
186
+ "🚫 Пропускаем вакансию в архиве",
187
+ vacancy["alternate_url"],
188
+ )
189
+ continue
190
+
191
+ relations = vacancy.get("relations", [])
192
+
193
+ if relations:
194
+ print(
195
+ "🚫 Пропускаем вакансию с",
196
+ ["откликом или приглашением", "отказом"]["got_rejection" in relations],
197
+ vacancy["alternate_url"],
198
+ )
199
+ continue
200
+
201
+ employer_id = vacancy.get("employer", {}).get("id")
202
+
203
+ if (
204
+ self.enable_telemetry
205
+ and employer_id
206
+ and employer_id not in telemetry_data["employers"]
207
+ ):
208
+ employer = self.api.get(f"/employers/{employer_id}")
209
+ telemetry_data["employers"][employer_id] = {
210
+ "name": employer.get("name"),
211
+ "type": employer.get("type"),
212
+ "description": employer.get("description"),
213
+ "site_url": employer.get("site_url"),
214
+ "area": employer.get("area", {}).get("name"), # город
215
+ }
216
+
217
+ params = {
218
+ "resume_id": self.resume_id,
219
+ "vacancy_id": vacancy["id"],
220
+ "message": "",
221
+ }
222
+
223
+ if self.force_message or vacancy.get("response_letter_required"):
224
+ msg = params["message"] = (
225
+ random_text(random.choice(self.application_messages))
226
+ % message_placeholders
227
+ )
228
+ logger.debug(msg)
229
+
230
+ if self.dry_run:
231
+ logger.info(
232
+ "Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
233
+ vacancy["alternate_url"],
234
+ params,
235
+ )
236
+ continue
237
+
238
+ # Задержка перед отправкой отклика
239
+ interval = random.uniform(
240
+ self.apply_min_interval, self.apply_max_interval
241
+ )
242
+ time.sleep(interval)
243
+
244
+ res = self.api.post("/negotiations", params)
245
+ assert res == {}
246
+ print(
247
+ "📨 Отправили отклик",
248
+ vacancy["alternate_url"],
249
+ "(",
250
+ truncate_string(vacancy["name"]),
251
+ ")",
252
+ )
253
+ except ApiError as ex:
254
+ logger.error(ex)
255
+ if isinstance(ex, BadRequest) and ex.limit_exceeded:
256
+ break
257
+
258
+ print("📝 Отклики на вакансии разосланы!")
259
+
260
+ if self.enable_telemetry:
261
+ if self.dry_run:
262
+ # С --dry-run можно посмотреть что отправляется
263
+ logger.info('Dry Run: Данные телеметрии для отправки на сервер: %r', telemetry_data)
264
+ return
265
+
266
+ try:
267
+ telemetry_client.send_telemetry("/collect", dict(telemetry_data))
268
+ except TelemetryError as ex:
269
+ logger.error(ex)
270
+
271
+ def _get_vacancies(
272
+ self, per_page: int = 100
273
+ ) -> list[VacancyItem]:
274
+ rv = []
275
+ for page in range(20):
276
+ params = {
277
+ "page": page,
278
+ "per_page": per_page,
279
+ "order_by": self.order_by,
280
+ }
281
+ if self.search:
282
+ params["text"] = self.search
283
+ res: ApiListResponse = self.api.get(
284
+ f"/resumes/{self.resume_id}/similar_vacancies", params
285
+ )
286
+ rv.extend(res["items"])
287
+ if page >= res["pages"] - 1:
288
+ break
289
+
290
+ # Задержка перед получением следующей страницы
291
+ if page > 0:
292
+ interval = random.uniform(self.page_min_interval, self.page_max_interval)
293
+ time.sleep(interval)
294
+
295
+ return rv
296
+
@@ -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
  [tool.poetry]
2
2
  name = "hh-applicant-tool"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = ""
5
5
  authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
6
6
  readme = "README.md"
@@ -1,443 +0,0 @@
1
- import argparse
2
- import logging
3
- import random
4
- import time
5
- from collections import defaultdict
6
- from os import getenv
7
- from typing import TextIO, Tuple
8
-
9
- from ..api import ApiClient, ApiError, BadRequest
10
- from ..main import BaseOperation
11
- from ..main import Namespace as BaseNamespace, get_api
12
- from ..telemetry_client import TelemetryClient, TelemetryError
13
- from ..types import ApiListResponse, VacancyItem
14
- from ..utils import fix_datetime, truncate_string, random_text
15
- from requests import Session
16
-
17
- logger = logging.getLogger(__package__)
18
-
19
-
20
- class Namespace(BaseNamespace):
21
- resume_id: str | None
22
- message_list: TextIO
23
- force_message: bool
24
- apply_interval: Tuple[float, float]
25
- page_interval: Tuple[float, float]
26
- message_interval: Tuple[float, float]
27
- order_by: str
28
- search: str
29
- reply_message: str
30
-
31
-
32
- # gx для открытия (никак не запомню в виме)
33
- # https://api.hh.ru/openapi/redoc
34
- class Operation(BaseOperation):
35
- """Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
36
-
37
- def setup_parser(self, parser: argparse.ArgumentParser) -> None:
38
- parser.add_argument("--resume-id", help="Идентефикатор резюме")
39
- parser.add_argument(
40
- "--message-list",
41
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(vacabcy_name)s",
42
- type=argparse.FileType(),
43
- )
44
- parser.add_argument(
45
- "--force-message",
46
- help="Всегда отправлять сообщение при отклике",
47
- default=False,
48
- action=argparse.BooleanOptionalAction,
49
- )
50
- parser.add_argument(
51
- "--apply-interval",
52
- help="Интервал перед отправкой откликов в секундах (X, X-Y)",
53
- default="1-5",
54
- type=self._parse_interval,
55
- )
56
- parser.add_argument(
57
- "--page-interval",
58
- help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
59
- 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,
67
- )
68
- parser.add_argument(
69
- "--order-by",
70
- help="Сортировка вакансий",
71
- choices=[
72
- "publication_time",
73
- "salary_desc",
74
- "salary_asc",
75
- "relevance",
76
- "distance",
77
- ],
78
- default="relevance",
79
- )
80
- parser.add_argument(
81
- "--search",
82
- help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зарплату",
83
- type=str,
84
- default=None,
85
- )
86
- parser.add_argument(
87
- "--reply-message",
88
- "--reply",
89
- help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
90
- )
91
-
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
- def run(self, args: Namespace) -> None:
102
- self.enable_telemetry = True
103
- if args.disable_telemetry:
104
- print(
105
- "👁️ Телеметрия используется только для сбора данных о работодателях и их вакансиях, персональные данные пользователей не передаются на сервер."
106
- )
107
- if (
108
- input("Вы действительно хотите отключить телеметрию (д/Н)? ")
109
- .lower()
110
- .startswith(("д", "y"))
111
- ):
112
- self.enable_telemetry = False
113
- logger.info("Телеметрия отключена")
114
- 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"],
138
- )
139
-
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
147
-
148
- def _get_application_messages(self, args: Namespace) -> list[str]:
149
- if args.message_list:
150
- application_messages = list(
151
- filter(None, map(str.strip, args.message_list))
152
- )
153
- else:
154
- application_messages = [
155
- "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
156
- "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
157
- ]
158
- return application_messages
159
-
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)
177
- telemetry_data = defaultdict(dict)
178
-
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
- )
188
-
189
- if self.enable_telemetry:
190
- self._collect_vacancy_telemetry(telemetry_data, vacancies)
191
-
192
- me = api.get("/me")
193
-
194
- basic_message_placeholders = {
195
- "first_name": me.get("first_name", ""),
196
- "last_name": me.get("last_name", ""),
197
- "email": me.get("email", ""),
198
- "phone": me.get("phone", ""),
199
- }
200
-
201
- do_apply = True
202
-
203
- for vacancy in vacancies:
204
- try:
205
- if getenv("TEST_TELEMETRY"):
206
- break
207
-
208
- message_placeholders = {
209
- "vacancy_name": vacancy.get("name", ""),
210
- "employer_name": vacancy.get("employer", {}).get(
211
- "name", ""
212
- ),
213
- **basic_message_placeholders,
214
- }
215
-
216
- logger.debug(
217
- "Вакансия %(vacancy_name)s от %(employer_name)s"
218
- % message_placeholders
219
- )
220
-
221
- if vacancy.get("has_test"):
222
- print("🚫 Пропускаем тест", vacancy["alternate_url"])
223
- continue
224
-
225
- if vacancy.get("archived"):
226
- print(
227
- "🚫 Пропускаем вакансию в архиве",
228
- vacancy["alternate_url"],
229
- )
230
-
231
- continue
232
-
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
- "🚫 Пропускаем вакансию с откликом",
298
- vacancy["alternate_url"],
299
- )
300
- continue
301
-
302
- employer_id = vacancy.get("employer", {}).get("id")
303
-
304
- if (
305
- self.enable_telemetry
306
- and employer_id
307
- and employer_id not in telemetry_data["employers"]
308
- and 200 > len(telemetry_data["employers"])
309
- ):
310
- employer = api.get(f"/employers/{employer_id}")
311
- telemetry_data["employers"][employer_id] = {
312
- "name": employer.get("name"),
313
- "type": employer.get("type"),
314
- "description": employer.get("description"),
315
- "site_url": employer.get("site_url"),
316
- "area": employer.get("area", {}).get("name"), # город
317
- }
318
-
319
- if not do_apply:
320
- logger.debug("skip apply similar")
321
- continue
322
-
323
- params = {
324
- "resume_id": resume_id,
325
- "vacancy_id": vacancy["id"],
326
- "message": "",
327
- }
328
-
329
- if force_message or vacancy.get("response_letter_required"):
330
- msg = params["message"] = (
331
- random_text(random.choice(application_messages))
332
- % message_placeholders
333
- )
334
- logger.debug(msg)
335
-
336
- # Задержка перед отправкой отклика
337
- 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,
344
- )
345
- time.sleep(interval)
346
-
347
- res = api.post("/negotiations", params)
348
- assert res == {}
349
- print(
350
- "📨 Отправили отклик",
351
- vacancy["alternate_url"],
352
- "(",
353
- truncate_string(vacancy["name"]),
354
- ")",
355
- )
356
- except ApiError as ex:
357
- logger.error(ex)
358
- if isinstance(ex, BadRequest) and ex.limit_exceeded:
359
- if not reply_message:
360
- break
361
- do_apply = False
362
-
363
- print("📝 Отклики на вакансии разосланы!")
364
-
365
- 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]:
379
- rv = []
380
- for page in range(20):
381
- params = {
382
- "page": page,
383
- "per_page": per_page,
384
- "order_by": order_by,
385
- }
386
- if search:
387
- params["text"] = search
388
- res: ApiListResponse = api.get(
389
- f"/resumes/{resume_id}/similar_vacancies", params
390
- )
391
- rv.extend(res["items"])
392
-
393
- if getenv("TEST_TELEMETRY"):
394
- break
395
-
396
- if page >= res["pages"] - 1:
397
- break
398
-
399
- # Задержка перед получением следующей страницы
400
- if page > 0:
401
- interval = random.uniform(page_min_interval, page_max_interval)
402
- time.sleep(interval)
403
-
404
- 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)