hh-applicant-tool 0.3.1__tar.gz → 0.3.3__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 (24) hide show
  1. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/PKG-INFO +38 -15
  2. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/README.md +37 -14
  3. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/main.py +3 -3
  4. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/apply_similar.py +129 -65
  5. hh_applicant_tool-0.3.3/hh_applicant_tool/telemetry_client.py +73 -0
  6. hh_applicant_tool-0.3.3/hh_applicant_tool/utils.py +74 -0
  7. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/pyproject.toml +1 -1
  8. hh_applicant_tool-0.3.1/hh_applicant_tool/utils.py +0 -48
  9. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/__init__.py +0 -0
  10. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/__main__.py +0 -0
  11. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/api/__init__.py +0 -0
  12. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/api/client.py +0 -0
  13. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/api/errors.py +0 -0
  14. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/color_log.py +0 -0
  15. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/constants.py +0 -0
  16. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/__init__.py +0 -0
  17. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/authorize.py +0 -0
  18. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/call_api.py +0 -0
  19. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/clear_negotiations.py +0 -0
  20. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/list_resumes.py +0 -0
  21. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/refresh_token.py +0 -0
  22. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/update_resumes.py +0 -0
  23. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/hh_applicant_tool/operations/whoami.py +0 -0
  24. {hh_applicant_tool-0.3.1 → hh_applicant_tool-0.3.3}/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.3.1
3
+ Version: 0.3.3
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -17,7 +17,7 @@ Requires-Dist: pyqt6-webengine (>=6.7.0,<7.0.0) ; extra == "qt"
17
17
  Requires-Dist: requests (>=2.28.2,<3.0.0)
18
18
  Description-Content-Type: text/markdown
19
19
 
20
- # HH Applicant Tool
20
+ ## HH Applicant Tool
21
21
 
22
22
  > ! Наложен мораторий на доработки/переработки. Разработка будет возобновлена после 100 звезд 💫
23
23
 
@@ -29,24 +29,24 @@ Description-Content-Type: text/markdown
29
29
  [![Total Downloads](https://static.pepy.tech/badge/hh-applicant-tool)]()
30
30
 
31
31
  <div align="center">
32
- <img src="https://github.com/user-attachments/assets/9bfce763-1359-471f-8b0b-ad0b7d21bd1c" width="500">
32
+ <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
33
33
  </div>
34
34
 
35
- Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/vaitishniki (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
35
+ Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/+aSjr8qM_AP85ZDBi (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
36
36
 
37
37
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
38
38
  asdf/pyenv/conda и что-то еще...
39
39
 
40
+ Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
41
+
40
42
  Пример работы:
41
43
 
42
44
  ![image](https://github.com/user-attachments/assets/55ab24ba-5325-40b4-9bd9-69ebcbc011c4)
43
45
 
44
46
 
45
- Данная утилита написана для Linux, но будет работать и в Windows, но в WSL она не работает, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
46
-
47
47
  Предыстория.
48
48
 
49
- Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я перенял эту порочную практику. Мне уже просто лень читать весь этот бред, что пишут в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое. Поэтому тупло спамлю в надежде на идеальную работу.
49
+ Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
50
50
 
51
51
  Долгое время я делал массовые заявки с помощью консоли браузера:
52
52
 
@@ -59,11 +59,15 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
59
59
  Установка:
60
60
 
61
61
  ```bash
62
- # Версия с поддержкой авторизации через всплывающее окно
62
+ # Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
63
+ # Можно использовать обычный pip
63
64
  $ pipx install 'hh-applicant-tool[qt]'
64
65
 
65
66
  # Если хочется использовать самую последнюю версию, то можно установить ее через git
66
67
  $ pipx install git+https://github.com/s3rgeym/hh-applicant-tool
68
+
69
+ # Для обновления до новой версии
70
+ $ pipx upgrade 'hh-applicant-tool'
67
71
  ```
68
72
 
69
73
  Использование:
@@ -142,7 +146,7 @@ $ hh-applicant-tool -vv authorize
142
146
 
143
147
  ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
144
148
 
145
- В случае успешной авторизации токены будут сохранены `~/.config/hh-applicant-tool/config.json`:
149
+ В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
146
150
 
147
151
  ```json
148
152
  {
@@ -199,8 +203,6 @@ $ hh-applicant-tool whoami
199
203
  }
200
204
  ```
201
205
 
202
- Далее идут заметки для разработчиков...
203
-
204
206
  Токен выдается на две недели:
205
207
 
206
208
  ```python
@@ -212,11 +214,11 @@ datetime.datetime(2023, 3, 23, 6, 36, 15, 596290)
212
214
  >>>
213
215
  ```
214
216
 
215
- После нужно вызвать `refresh-token`.
216
-
217
- ![](https://user-images.githubusercontent.com/12753171/222870516-b29f2417-d11a-4122-8291-7d440a422a31.png)
217
+ После нужно вызвать `refresh-token`:
218
218
 
219
- При авторизации можно указать `redirect_uri`, но любые адреса кроме того, что с протоколом `hhandroid`, будут приводить к ошибке. Поэтому и нужно добавление обработчика кастомного протокола. Через расширения браузера это не сделать, но можно программно перехватить редирект... Использование АВТОРИЗАЦИИ ДЛЯ САЙТОВ в мобильном приложении выглядит странной, так как десктопные и мобильные приложения обычно авторизуются напрямую, но у чуваков свое понимание не только протокола OAuth...
219
+ ```bash
220
+ $ hh-applicant-tool refresh-token
221
+ ```
220
222
 
221
223
  Удаление хвостов:
222
224
 
@@ -231,3 +233,24 @@ rm -f ~/.local/share/applications/hhandroid.desktop
231
233
 
232
234
  Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
233
235
 
236
+ Утилита собирает и передает на сервер разработчика следующую ифнормацию:
237
+
238
+ 1. Название вакансии.
239
+ 1. Тип вакансии (открытая/закрытая).
240
+ 1. Город, в котором размещена вакансия.
241
+ 1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
242
+ 1. Прямая ссылка на вакансию.
243
+ 1. Дата создания вакансии.
244
+ 1. Дата публикации вакансии.
245
+ 1. Контактная информация хрюши (ее телефон, email и тп).
246
+ 1. Название компании.
247
+ 1. Тип компании.
248
+ 1. Описание компании.
249
+ 1. Ссылка на сайт компании.
250
+ 1. Город, в котором находится компания.
251
+
252
+ [Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
253
+
254
+ !!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ (IP ТОЖЕ НЕ СОХРАНЯЕТ) — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ)
255
+
256
+
@@ -1,4 +1,4 @@
1
- # HH Applicant Tool
1
+ ## HH Applicant Tool
2
2
 
3
3
  > ! Наложен мораторий на доработки/переработки. Разработка будет возобновлена после 100 звезд 💫
4
4
 
@@ -10,24 +10,24 @@
10
10
  [![Total Downloads](https://static.pepy.tech/badge/hh-applicant-tool)]()
11
11
 
12
12
  <div align="center">
13
- <img src="https://github.com/user-attachments/assets/9bfce763-1359-471f-8b0b-ad0b7d21bd1c" width="500">
13
+ <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
14
14
  </div>
15
15
 
16
- Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/vaitishniki (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
16
+ Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Поддержка осуществляется строго в группе https://t.me/+aSjr8qM_AP85ZDBi (в ней разрешены мат, п*рнография, оскорбления всех участников кроме админа, а так же слив любой информации про хуевых работодателей и нерадивых херок).
17
17
 
18
18
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
19
19
  asdf/pyenv/conda и что-то еще...
20
20
 
21
+ Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
22
+
21
23
  Пример работы:
22
24
 
23
25
  ![image](https://github.com/user-attachments/assets/55ab24ba-5325-40b4-9bd9-69ebcbc011c4)
24
26
 
25
27
 
26
- Данная утилита написана для Linux, но будет работать и в Windows, но в WSL она не работает, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
27
-
28
28
  Предыстория.
29
29
 
30
- Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я перенял эту порочную практику. Мне уже просто лень читать весь этот бред, что пишут в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое. Поэтому тупло спамлю в надежде на идеальную работу.
30
+ Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
31
31
 
32
32
  Долгое время я делал массовые заявки с помощью консоли браузера:
33
33
 
@@ -40,11 +40,15 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
40
40
  Установка:
41
41
 
42
42
  ```bash
43
- # Версия с поддержкой авторизации через всплывающее окно
43
+ # Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
44
+ # Можно использовать обычный pip
44
45
  $ pipx install 'hh-applicant-tool[qt]'
45
46
 
46
47
  # Если хочется использовать самую последнюю версию, то можно установить ее через git
47
48
  $ pipx install git+https://github.com/s3rgeym/hh-applicant-tool
49
+
50
+ # Для обновления до новой версии
51
+ $ pipx upgrade 'hh-applicant-tool'
48
52
  ```
49
53
 
50
54
  Использование:
@@ -123,7 +127,7 @@ $ hh-applicant-tool -vv authorize
123
127
 
124
128
  ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
125
129
 
126
- В случае успешной авторизации токены будут сохранены `~/.config/hh-applicant-tool/config.json`:
130
+ В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
127
131
 
128
132
  ```json
129
133
  {
@@ -180,8 +184,6 @@ $ hh-applicant-tool whoami
180
184
  }
181
185
  ```
182
186
 
183
- Далее идут заметки для разработчиков...
184
-
185
187
  Токен выдается на две недели:
186
188
 
187
189
  ```python
@@ -193,11 +195,11 @@ datetime.datetime(2023, 3, 23, 6, 36, 15, 596290)
193
195
  >>>
194
196
  ```
195
197
 
196
- После нужно вызвать `refresh-token`.
197
-
198
- ![](https://user-images.githubusercontent.com/12753171/222870516-b29f2417-d11a-4122-8291-7d440a422a31.png)
198
+ После нужно вызвать `refresh-token`:
199
199
 
200
- При авторизации можно указать `redirect_uri`, но любые адреса кроме того, что с протоколом `hhandroid`, будут приводить к ошибке. Поэтому и нужно добавление обработчика кастомного протокола. Через расширения браузера это не сделать, но можно программно перехватить редирект... Использование АВТОРИЗАЦИИ ДЛЯ САЙТОВ в мобильном приложении выглядит странной, так как десктопные и мобильные приложения обычно авторизуются напрямую, но у чуваков свое понимание не только протокола OAuth...
200
+ ```bash
201
+ $ hh-applicant-tool refresh-token
202
+ ```
201
203
 
202
204
  Удаление хвостов:
203
205
 
@@ -211,3 +213,24 @@ rm -f ~/.local/share/applications/hhandroid.desktop
211
213
  Утилита использует систему плагинов. Все они лежат в [operations](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations). Модули расположенные там автоматически добавляются как доступные операции. За основу для своего плагина можно взять [whoami.py](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations/whoami.py).
212
214
 
213
215
  Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
216
+
217
+ Утилита собирает и передает на сервер разработчика следующую ифнормацию:
218
+
219
+ 1. Название вакансии.
220
+ 1. Тип вакансии (открытая/закрытая).
221
+ 1. Город, в котором размещена вакансия.
222
+ 1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
223
+ 1. Прямая ссылка на вакансию.
224
+ 1. Дата создания вакансии.
225
+ 1. Дата публикации вакансии.
226
+ 1. Контактная информация хрюши (ее телефон, email и тп).
227
+ 1. Название компании.
228
+ 1. Тип компании.
229
+ 1. Описание компании.
230
+ 1. Ссылка на сайт компании.
231
+ 1. Город, в котором находится компания.
232
+
233
+ [Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
234
+
235
+ !!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ (IP ТОЖЕ НЕ СОХРАНЯЕТ) — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ)
236
+
@@ -11,10 +11,10 @@ from pkgutil import iter_modules
11
11
  from typing import Sequence
12
12
 
13
13
  from .color_log import ColorHandler
14
- from .utils import Config
14
+ from .utils import Config, get_config_path
15
15
 
16
16
  DEFAULT_CONFIG_PATH = (
17
- Path(getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
17
+ get_config_path()
18
18
  / __package__.replace("_", "-")
19
19
  / "config.json"
20
20
  )
@@ -42,7 +42,7 @@ class HHApplicantTool:
42
42
 
43
43
  Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
44
44
 
45
- Группа поддержки: <https://t.me/vaitishniki>
45
+ Группа поддержки: <https://t.me/+aSjr8qM_AP85ZDBi>
46
46
  """
47
47
 
48
48
  def create_parser(self) -> argparse.ArgumentParser:
@@ -2,13 +2,17 @@ import argparse
2
2
  import logging
3
3
  import random
4
4
  import time
5
+ from collections import defaultdict
6
+ from os import getenv
5
7
  from typing import TextIO, Tuple
6
8
 
7
9
  from ..api import ApiClient, ApiError, BadRequest
8
10
  from ..main import BaseOperation
9
11
  from ..main import Namespace as BaseNamespace
12
+ from ..telemetry_client import TelemetryError
13
+ from ..telemetry_client import get_client as get_telemetry_client
10
14
  from ..types import ApiListResponse, VacancyItem
11
- from ..utils import print_err, truncate_string
15
+ from ..utils import fix_datetime, print_err, truncate_string
12
16
 
13
17
  logger = logging.getLogger(__package__)
14
18
 
@@ -65,11 +69,32 @@ class Operation(BaseOperation):
65
69
  access_token=args.config["token"]["access_token"],
66
70
  user_agent=args.config["user_agent"],
67
71
  )
72
+ resume_id = self._get_resume_id(args, api)
73
+ application_messages = self._get_application_messages(args)
74
+
75
+ apply_min_interval, apply_max_interval = args.apply_interval
76
+ page_min_interval, page_max_interval = args.page_interval
77
+
78
+ self._apply_similar(
79
+ api,
80
+ resume_id,
81
+ args.force_message,
82
+ application_messages,
83
+ apply_min_interval,
84
+ apply_max_interval,
85
+ page_min_interval,
86
+ page_max_interval,
87
+ )
88
+
89
+ def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
68
90
  if not (
69
91
  resume_id := args.resume_id or args.config["default_resume_id"]
70
92
  ):
71
93
  resumes: ApiListResponse = api.get("/resumes/mine")
72
94
  resume_id = resumes["items"][0]["id"]
95
+ return resume_id
96
+
97
+ def _get_application_messages(self, args: Namespace) -> list[str]:
73
98
  if args.message_list:
74
99
  application_messages = list(
75
100
  filter(None, map(str.strip, args.message_list))
@@ -82,47 +107,7 @@ class Operation(BaseOperation):
82
107
  "Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
83
108
  "Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
84
109
  ]
85
-
86
- apply_min_interval, apply_max_interval = args.apply_interval
87
- page_min_interval, page_max_interval = args.page_interval
88
-
89
- self._apply_similar(
90
- api,
91
- resume_id,
92
- args.force_message,
93
- application_messages,
94
- apply_min_interval,
95
- apply_max_interval,
96
- page_min_interval,
97
- page_max_interval,
98
- )
99
-
100
- def _get_vacancies(
101
- self,
102
- api: ApiClient,
103
- resume_id: str,
104
- page_min_interval: float,
105
- page_max_interval: float,
106
- ) -> list[VacancyItem]:
107
- rv = []
108
- per_page = 100
109
- for page in range(20):
110
- res: ApiListResponse = api.get(
111
- f"/resumes/{resume_id}/similar_vacancies",
112
- page=page,
113
- per_page=per_page,
114
- order_by="relevance",
115
- )
116
- rv.extend(res["items"])
117
- if page >= res["pages"] - 1:
118
- break
119
-
120
- # Задержка перед получением следующей страницы
121
- if page > 0:
122
- interval = random.uniform(page_min_interval, page_max_interval)
123
- time.sleep(interval)
124
-
125
- return rv
110
+ return application_messages
126
111
 
127
112
  def _apply_similar(
128
113
  self,
@@ -135,30 +120,43 @@ class Operation(BaseOperation):
135
120
  page_min_interval: float,
136
121
  page_max_interval: float,
137
122
  ) -> None:
138
- item: VacancyItem
139
- for item in self._get_vacancies(
140
- api, resume_id, page_min_interval, page_max_interval
141
- ):
123
+ telemetry_client = get_telemetry_client()
124
+ telemetry_data = defaultdict(dict)
125
+
126
+ vacancies = self._get_vacancies(
127
+ api, resume_id, page_min_interval, page_max_interval, per_page=100
128
+ )
129
+
130
+ self._collect_vacancy_telemetry(telemetry_data, vacancies)
131
+
132
+ for vacancy in vacancies:
142
133
  try:
143
- if item["has_test"]:
144
- print("Пропускаем тест", item["alternate_url"])
134
+ if getenv("TEST_TELEMETRY"):
135
+ break
136
+
137
+ if vacancy["has_test"]:
138
+ print("🚫 Пропускаем тест", vacancy["alternate_url"])
145
139
  continue
146
140
 
147
- relations = item.get("relations", [])
148
-
149
- # Там черезжопно нужно хеш отклика получать чтобы его отменить
150
- # if "got_response" in relations:
151
- # # Тупая пизда ее даже не рассматривала
152
- # print(
153
- # "Отменяем заявку чтобы отправить ее снова",
154
- # item["alternate_url"],
155
- # )
156
- # api.delete(f"/negotiations/active/{item['id']}")
157
- # elif relations:
141
+ relations = vacancy.get("relations", [])
142
+
158
143
  if relations:
159
- print("Пропускаем ответ на заявку", item["alternate_url"])
144
+ print(
145
+ "🚫 Пропускаем ответ на заявку",
146
+ vacancy["alternate_url"],
147
+ )
160
148
  continue
161
149
 
150
+ employer_id = vacancy["employer"]["id"]
151
+ employer = api.get(f"/employers/{employer_id}")
152
+
153
+ telemetry_data["employers"][employer_id] = {
154
+ "name": employer.get("name"),
155
+ "type": employer.get("type"),
156
+ "description": employer.get("description"),
157
+ "site_url": employer.get("site_url"),
158
+ "area": employer.get("area", {}).get("name"), # город
159
+ }
162
160
  # Задержка перед отправкой отклика
163
161
  interval = random.uniform(
164
162
  apply_min_interval, apply_max_interval
@@ -167,10 +165,10 @@ class Operation(BaseOperation):
167
165
 
168
166
  params = {
169
167
  "resume_id": resume_id,
170
- "vacancy_id": item["id"],
168
+ "vacancy_id": vacancy["id"],
171
169
  "message": (
172
- random.choice(application_messages) % item
173
- if force_message or item["response_letter_required"]
170
+ random.choice(application_messages) % vacancy
171
+ if force_message or vacancy["response_letter_required"]
174
172
  else ""
175
173
  ),
176
174
  }
@@ -179,9 +177,9 @@ class Operation(BaseOperation):
179
177
  assert res == {}
180
178
  print(
181
179
  "📨 Отправили отклик",
182
- item["alternate_url"],
180
+ vacancy["alternate_url"],
183
181
  "(",
184
- truncate_string(item["name"]),
182
+ truncate_string(vacancy["name"]),
185
183
  ")",
186
184
  )
187
185
  except ApiError as ex:
@@ -190,3 +188,69 @@ class Operation(BaseOperation):
190
188
  break
191
189
 
192
190
  print("📝 Отклики на вакансии разосланы!")
191
+
192
+ self._send_telemetry(telemetry_client, telemetry_data)
193
+
194
+ def _get_vacancies(
195
+ self,
196
+ api: ApiClient,
197
+ resume_id: str,
198
+ page_min_interval: float,
199
+ page_max_interval: float,
200
+ per_page: int,
201
+ ) -> list[VacancyItem]:
202
+ rv = []
203
+ for page in range(20):
204
+ res: ApiListResponse = api.get(
205
+ f"/resumes/{resume_id}/similar_vacancies",
206
+ page=page,
207
+ per_page=per_page,
208
+ order_by="relevance",
209
+ )
210
+ rv.extend(res["items"])
211
+
212
+ if getenv("TEST_TELEMETRY"):
213
+ break
214
+
215
+ if page >= res["pages"] - 1:
216
+ break
217
+
218
+ # Задержка перед получением следующей страницы
219
+ if page > 0:
220
+ interval = random.uniform(page_min_interval, page_max_interval)
221
+ time.sleep(interval)
222
+
223
+ return rv
224
+
225
+ def _collect_vacancy_telemetry(
226
+ self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
227
+ ) -> None:
228
+ for vacancy in vacancies:
229
+ vacancy_id = vacancy["id"]
230
+ telemetry_data["vacancies"][vacancy_id] = {
231
+ "name": vacancy.get("name"),
232
+ "type": vacancy.get("type", {}).get("id"), # open/closed
233
+ "area": vacancy.get("area", {}).get("name"), # город
234
+ "salary": vacancy.get("salary"), # from, to, currency, gross
235
+ "direct_url": vacancy.get(
236
+ "alternate_url"
237
+ ), # ссылка на вакансию
238
+ "created_at": fix_datetime(
239
+ vacancy.get("created_at")
240
+ ), # будем вычислять говно-вакансии, которые по полгода висят
241
+ "published_at": fix_datetime(vacancy.get("published_at")),
242
+ "contacts": vacancy.get(
243
+ "contacts"
244
+ ), # пиздорванки там телеграм для связи указывают
245
+ "employer_id": int(vacancy["employer"]["id"]),
246
+ # Остальное неинтересно
247
+ }
248
+
249
+ def _send_telemetry(
250
+ self, telemetry_client, telemetry_data: defaultdict
251
+ ) -> None:
252
+ try:
253
+ res = telemetry_client.send_telemetry("/collect", dict(telemetry_data))
254
+ logger.debug(res)
255
+ except TelemetryError as ex:
256
+ logger.error(ex)
@@ -0,0 +1,73 @@
1
+ import os
2
+ import json
3
+ from urllib.parse import urljoin
4
+ import requests
5
+ from typing import Optional, Dict, Any
6
+ from functools import cache
7
+ import logging
8
+ import base64
9
+
10
+ logger = logging.getLogger(__package__)
11
+
12
+
13
+ class TelemetryError(Exception):
14
+ """Исключение, возникающее при ошибках в работе TelemetryClient."""
15
+
16
+ pass
17
+
18
+
19
+ class TelemetryClient:
20
+ """Клиент для отправки телеметрии на сервер."""
21
+
22
+ server_address = base64.b64decode(
23
+ "aHR0cDovLzMxLjEzMS4yNTEuMTA3OjU0MTU2"
24
+ ).decode()
25
+
26
+ def __init__(
27
+ self,
28
+ server_address: Optional[str] = None,
29
+ session: Optional[requests.Session] = None,
30
+ ) -> None:
31
+ """
32
+ Инициализация клиента.
33
+
34
+ :param server_address: Адрес сервера для отправки телеметрии.
35
+ :param session: Сессия для повторного использования соединения.
36
+ """
37
+ self.session = session or requests.Session()
38
+ self.server_address = os.getenv(
39
+ "TELEMETRY_SERVER", server_address or self.server_address
40
+ )
41
+
42
+ def send_telemetry(
43
+ self, endpoint: str, data: Dict[str, Any]
44
+ ) -> Dict[str, Any]:
45
+ """
46
+ Отправка телеметрии на сервер.
47
+
48
+ :param endpoint: Конечная точка на сервере.
49
+ :param data: Данные для отправки.
50
+ :return: Ответ сервера в формате JSON.
51
+ :raises TelemetryError: Если произошла ошибка при отправке или декодировании JSON.
52
+ """
53
+ url = urljoin(self.server_address, endpoint)
54
+ logger.debug(data)
55
+
56
+ try:
57
+ response = self.session.post(url, json=data)
58
+ # response.raise_for_status()
59
+ result = response.json()
60
+ if "error" in result:
61
+ raise TelemetryError(result)
62
+ return result
63
+
64
+ except (
65
+ requests.exceptions.RequestException,
66
+ json.JSONDecodeError,
67
+ ) as ex:
68
+ raise TelemetryError(str(ex)) from ex
69
+
70
+
71
+ @cache
72
+ def get_client() -> TelemetryClient:
73
+ return TelemetryClient()
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+ from datetime import datetime
3
+ import hashlib
4
+ import json
5
+ import platform
6
+ import sys
7
+ from functools import partial
8
+ from pathlib import Path
9
+ from threading import Lock
10
+ from typing import Any
11
+ from os import getenv
12
+ from .constants import INVALID_ISO8601_FORMAT
13
+
14
+ print_err = partial(print, file=sys.stderr, flush=True)
15
+
16
+
17
+ def get_config_path() -> Path:
18
+ match platform.system():
19
+ case "Windows":
20
+ return Path(getenv("APPDATA", Path.home() / "AppData" / "Roaming"))
21
+ case "Darwin": # macOS
22
+ return Path.home() / "Library" / "Application Support"
23
+ case _: # Linux and etc
24
+ return Path(getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
25
+
26
+
27
+ class AttrDict(dict):
28
+ __getattr__ = dict.get
29
+ __setattr__ = dict.__setitem__
30
+ __delattr__ = dict.__delitem__
31
+
32
+
33
+ # TODO: добавить defaults
34
+ class Config(dict):
35
+ def __init__(self, config_path: str | Path | None = None):
36
+ self._config_path = Path(config_path or get_config_path())
37
+ self._lock = Lock()
38
+ self.load()
39
+
40
+ def load(self) -> None:
41
+ if self._config_path.exists():
42
+ with self._lock:
43
+ with self._config_path.open() as f:
44
+ try:
45
+ self.update(json.load(f))
46
+ except ValueError:
47
+ pass
48
+
49
+ def save(self, *args: Any, **kwargs: Any) -> None:
50
+ self.update(*args, **kwargs)
51
+ self._config_path.parent.mkdir(exist_ok=True, parents=True)
52
+ with self._lock:
53
+ with self._config_path.open("w+") as fp:
54
+ json.dump(self, fp, ensure_ascii=True, indent=2, sort_keys=True)
55
+
56
+ __getitem__ = dict.get
57
+
58
+
59
+ def truncate_string(s: str, limit: int = 75, ellipsis: str = "…") -> str:
60
+ return s[:limit] + bool(s[limit:]) * ellipsis
61
+
62
+
63
+ def hash_with_salt(data: str, salt: str = "HorsePenis") -> str:
64
+ # Объединяем данные и соль
65
+ salted_data = data + salt
66
+ # Вычисляем хеш SHA-256
67
+ hashed_data = hashlib.sha256(salted_data.encode()).hexdigest()
68
+ return hashed_data
69
+
70
+
71
+ def fix_datetime(dt: str | None) -> str | None:
72
+ if dt is None:
73
+ return None
74
+ return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "hh-applicant-tool"
3
- version = "0.3.1"
3
+ version = "0.3.3"
4
4
  description = ""
5
5
  authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
6
6
  readme = "README.md"
@@ -1,48 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import sys
5
- from functools import partial
6
- from pathlib import Path
7
- from threading import Lock
8
- from typing import Any
9
-
10
- print_err = partial(print, file=sys.stderr, flush=True)
11
-
12
-
13
- class AttrDict(dict):
14
- __getattr__ = dict.get
15
- __setattr__ = dict.__setitem__
16
- __delattr__ = dict.__delitem__
17
-
18
-
19
- # TODO: добавить defaults
20
- class Config(dict):
21
- def __init__(self, config_path: str | Path):
22
- self._config_path = Path(config_path)
23
- self._lock = Lock()
24
- self.load()
25
-
26
- def load(self) -> None:
27
- if self._config_path.exists():
28
- with self._lock:
29
- with self._config_path.open() as f:
30
- try:
31
- self.update(json.load(f))
32
- except ValueError:
33
- pass
34
-
35
- def save(self, *args: Any, **kwargs: Any) -> None:
36
- self.update(*args, **kwargs)
37
- self._config_path.parent.mkdir(exist_ok=True, parents=True)
38
- with self._lock:
39
- with self._config_path.open("w+") as fp:
40
- json.dump(
41
- self, fp, ensure_ascii=True, indent=2, sort_keys=True
42
- )
43
-
44
- __getitem__ = dict.get
45
-
46
-
47
- def truncate_string(s: str, limit: int = 75, ellipsis: str = "…") -> str:
48
- return s[:limit] + bool(s[limit:]) * ellipsis