hh-applicant-tool 0.4.0__tar.gz → 0.5.0__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.
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/PKG-INFO +8 -24
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/README.md +7 -23
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/main.py +10 -9
- hh_applicant_tool-0.5.0/hh_applicant_tool/mixins.py +13 -0
- hh_applicant_tool-0.5.0/hh_applicant_tool/operations/apply_similar.py +356 -0
- hh_applicant_tool-0.5.0/hh_applicant_tool/operations/get_employer_contacts.py +103 -0
- hh_applicant_tool-0.5.0/hh_applicant_tool/operations/reply_employers.py +154 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/telemetry_client.py +27 -6
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/utils.py +29 -13
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/pyproject.toml +2 -1
- hh_applicant_tool-0.4.0/hh_applicant_tool/operations/apply_similar.py +0 -443
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/__init__.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/__main__.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/__init__.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/client.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/errors.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/color_log.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/constants.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/__init__.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/authorize.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/call_api.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/clear_negotiations.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/list_resumes.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/refresh_token.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/update_resumes.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/whoami.py +0 -0
- {hh_applicant_tool-0.4.0 → hh_applicant_tool-0.5.0}/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
|
+
Version: 0.5.0
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -32,10 +32,10 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
### Описание
|
|
34
34
|
|
|
35
|
-
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме.
|
|
35
|
+
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Но данная утилита больше чем просто спамилка отзывами, вы так же выступаете в роли тайного агента, и если в списке подходящих вакансий встречается отказ, она возвращает ссылку на обсуждение работодателя в группе [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там вы можете написать отзыв о работодателе и почитать чужие. Обсуждения без отзывов удаляются. Данные на сервер передаются анонимно. Для этого собираются данные о работодателях и их вакансиях. Никакие персональные данные пользователя утилиты под которыми, вы авторизуетесь никуда не отправляются — только работодателей и рекрутеров. Отправку этих данных можно отключить (флаг `--disable-telemetry`), но тогда вы не получите ссылку на обсуждение, а так же не сможете пожаловаться на неадекватного мудака, выкатившего отказ после "небольшого" тестового задания на недельку. Через сайты на таких жаловаться бесполезно: владелец сайта за деньги отзывы удаляет, или мудак его запугает и жалоб в РКН накидает, а последний всегда найдет за что сайт заблокировать. Единственное место где можно написать отзыв — это **Telegram**.
|
|
36
36
|
|
|
37
37
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
38
|
-
asdf/pyenv/conda и что-то
|
|
38
|
+
asdf/pyenv/conda и что-то еще.
|
|
39
39
|
|
|
40
40
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
41
41
|
|
|
@@ -240,10 +240,11 @@ https://hh.ru/employer/1918903
|
|
|
240
240
|
| **list-resumes** | Список резюме |
|
|
241
241
|
| **update-resumes** | Обновить все резюме. Аналогично нажатию кнопки «Обновить дату». |
|
|
242
242
|
| **apply-similar** | Откликнуться на все подходящие вакансии. Лимит = 200 в день. На HH есть спам-фильтры, так что лучше не рассылайте отклики со ссылками. |
|
|
243
|
+
| **reply-employers** | Ответить во все чаты с работодателями, где нет ответа либо не прочитали ваш предыдущий ответ |
|
|
243
244
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
244
245
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
245
246
|
| **refresh-token** | Обновляет access_token. |
|
|
246
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
247
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Это функционал для избранных, но в группе есть бесплатный бот с тем же функционалом. |
|
|
247
248
|
|
|
248
249
|
### Формат текста сообщений
|
|
249
250
|
|
|
@@ -282,26 +283,9 @@ https://hh.ru/employer/1918903
|
|
|
282
283
|
|
|
283
284
|
Отдельные замечания у меня к API HH. Оно пиздец какое кривое. Например, при создании заявки возвращается пустой ответ либо редирект, хотя по логике должен возвраться созданный объект. Так же в ответах сервера нет `Content-Length`. Из-за этого нельзя узнать есть тело у ответа сервера нужно его пробовать прочитать. Я так понял там какой-то прокси оборачивает все запросы и отдает всегда `Transfer-Encoding: Chunked`. А еще он возвращает 502 ошибку, когда бекенд на Java падает либо долго отвечает (таймаут)? А вот [язык запросов](https://hh.ru/article/1175) мне понравился. Можно что-то типа этого использовать `NOT (!ID:123 OR !ID:456 OR !ID:789)` что бы отсеить какие-то вакансии.
|
|
284
285
|
|
|
285
|
-
|
|
286
|
+
Для создания своих плагинов прочитайте документацию:
|
|
286
287
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
Утилита собирает и передает на сервер разработчика следующую информацию:
|
|
290
|
-
|
|
291
|
-
1. Название вакансии.
|
|
292
|
-
1. Тип вакансии (открытая/закрытая).
|
|
293
|
-
1. Город, в котором размещена вакансия.
|
|
294
|
-
1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
|
|
295
|
-
1. Прямая ссылка на вакансию.
|
|
296
|
-
1. Дата создания вакансии.
|
|
297
|
-
1. Дата публикации вакансии.
|
|
298
|
-
1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
|
|
299
|
-
1. Название компании.
|
|
300
|
-
1. Тип компании.
|
|
301
|
-
1. Описание компании.
|
|
302
|
-
1. Ссылка на сайт компании.
|
|
303
|
-
1. Город, в котором находится компания.
|
|
304
|
-
|
|
305
|
-
**УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
|
|
288
|
+
* [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
|
|
306
289
|
|
|
290
|
+
Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
|
|
307
291
|
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
|
|
14
14
|
### Описание
|
|
15
15
|
|
|
16
|
-
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме.
|
|
16
|
+
Утилита для успешных волчат, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме. Но данная утилита больше чем просто спамилка отзывами, вы так же выступаете в роли тайного агента, и если в списке подходящих вакансий встречается отказ, она возвращает ссылку на обсуждение работодателя в группе [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там вы можете написать отзыв о работодателе и почитать чужие. Обсуждения без отзывов удаляются. Данные на сервер передаются анонимно. Для этого собираются данные о работодателях и их вакансиях. Никакие персональные данные пользователя утилиты под которыми, вы авторизуетесь никуда не отправляются — только работодателей и рекрутеров. Отправку этих данных можно отключить (флаг `--disable-telemetry`), но тогда вы не получите ссылку на обсуждение, а так же не сможете пожаловаться на неадекватного мудака, выкатившего отказ после "небольшого" тестового задания на недельку. Через сайты на таких жаловаться бесполезно: владелец сайта за деньги отзывы удаляет, или мудак его запугает и жалоб в РКН накидает, а последний всегда найдет за что сайт заблокировать. Единственное место где можно написать отзыв — это **Telegram**.
|
|
17
17
|
|
|
18
18
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
19
|
-
asdf/pyenv/conda и что-то
|
|
19
|
+
asdf/pyenv/conda и что-то еще.
|
|
20
20
|
|
|
21
21
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
22
22
|
|
|
@@ -221,10 +221,11 @@ 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. |
|
|
227
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
228
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Это функционал для избранных, но в группе есть бесплатный бот с тем же функционалом. |
|
|
228
229
|
|
|
229
230
|
### Формат текста сообщений
|
|
230
231
|
|
|
@@ -263,25 +264,8 @@ 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
|
|
|
266
|
-
|
|
267
|
+
Для создания своих плагинов прочитайте документацию:
|
|
267
268
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
Утилита собирает и передает на сервер разработчика следующую информацию:
|
|
271
|
-
|
|
272
|
-
1. Название вакансии.
|
|
273
|
-
1. Тип вакансии (открытая/закрытая).
|
|
274
|
-
1. Город, в котором размещена вакансия.
|
|
275
|
-
1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
|
|
276
|
-
1. Прямая ссылка на вакансию.
|
|
277
|
-
1. Дата создания вакансии.
|
|
278
|
-
1. Дата публикации вакансии.
|
|
279
|
-
1. Контактная информация работодателя, которую он или его сотрудники сами выложили в общественный доступ, которая в дальнейшем _может_ храниться в строго обезличенной форме с соблюдением законов РФ, GDPR и американского экспортного контроля. Данная информация может быть удалена при письменном запросе в утвержденной Законом форме с оригинальными печатями фирмы и подписью генерального директора и/или по требованию РКН, прокуратуры или лично Адама Кадырова.
|
|
280
|
-
1. Название компании.
|
|
281
|
-
1. Тип компании.
|
|
282
|
-
1. Описание компании.
|
|
283
|
-
1. Ссылка на сайт компании.
|
|
284
|
-
1. Город, в котором находится компания.
|
|
285
|
-
|
|
286
|
-
**УТИЛИТА НЕ ПЕРЕДАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. СЕРВЕР НЕ ХРАНИТ IP ОТПРАВИТЕЛЯ. ЛОГИ НА СЕРВЕРЕ НЕ ВЕДУТСЯ. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ, И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ).**
|
|
269
|
+
* [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
|
|
287
270
|
|
|
271
|
+
Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
|
|
@@ -3,28 +3,29 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
|
-
from abc import ABCMeta, abstractmethod
|
|
7
6
|
from importlib import import_module
|
|
7
|
+
from os import getenv
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from pkgutil import iter_modules
|
|
10
|
-
from typing import
|
|
10
|
+
from typing import Literal, Sequence
|
|
11
|
+
|
|
11
12
|
from .api import ApiClient
|
|
12
13
|
from .color_log import ColorHandler
|
|
13
14
|
from .utils import Config, get_config_path
|
|
14
|
-
from os import getenv
|
|
15
15
|
|
|
16
16
|
DEFAULT_CONFIG_PATH = (
|
|
17
|
-
get_config_path() / __package__.replace("_", "-") / "config.json"
|
|
17
|
+
get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
|
|
18
18
|
)
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger(__package__)
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
class BaseOperation
|
|
24
|
-
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
23
|
+
class BaseOperation:
|
|
24
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
...
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
def run(self, args: argparse.Namespace) -> None | int:
|
|
28
|
+
raise NotImplementedError()
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
OPERATIONS = "operations"
|
|
@@ -63,7 +64,7 @@ class HHApplicantTool:
|
|
|
63
64
|
|
|
64
65
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
65
66
|
|
|
66
|
-
Группа поддержки: <https://t.me
|
|
67
|
+
Группа поддержки: <https://t.me/otzyvy_headhunter>
|
|
67
68
|
"""
|
|
68
69
|
|
|
69
70
|
class ArgumentFormatter(
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .api import ApiError
|
|
2
|
+
from .types import ApiListResponse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GetResumeIdMixin:
|
|
6
|
+
def _get_resume_id(self) -> str:
|
|
7
|
+
try:
|
|
8
|
+
resumes: ApiListResponse = self.api.get("/resumes/mine")
|
|
9
|
+
return resumes["items"][0]["id"]
|
|
10
|
+
except (ApiError, KeyError, IndexError) as ex:
|
|
11
|
+
raise Exception("Не могу получить идентификатор резюме") from ex
|
|
12
|
+
|
|
13
|
+
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import random
|
|
4
|
+
import time
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import TextIO, Tuple
|
|
8
|
+
|
|
9
|
+
from ..api import ApiError, BadRequest
|
|
10
|
+
from ..main import BaseOperation
|
|
11
|
+
from ..main import Namespace as BaseNamespace
|
|
12
|
+
from ..main import get_api
|
|
13
|
+
from ..mixins import GetResumeIdMixin
|
|
14
|
+
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
15
|
+
from ..types import ApiListResponse, VacancyItem
|
|
16
|
+
from ..utils import (fix_datetime, parse_interval, parse_invalid_datetime,
|
|
17
|
+
random_text, truncate_string)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__package__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Namespace(BaseNamespace):
|
|
23
|
+
resume_id: str | None
|
|
24
|
+
message_list: TextIO
|
|
25
|
+
force_message: bool
|
|
26
|
+
apply_interval: Tuple[float, float]
|
|
27
|
+
page_interval: Tuple[float, float]
|
|
28
|
+
order_by: str
|
|
29
|
+
search: str
|
|
30
|
+
dry_run: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Operation(BaseOperation, GetResumeIdMixin):
|
|
34
|
+
"""Откликнуться на все подходящие вакансии."""
|
|
35
|
+
|
|
36
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
37
|
+
parser.add_argument("--resume-id", help="Идентефикатор резюме")
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--message-list",
|
|
40
|
+
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
|
|
41
|
+
type=argparse.FileType(),
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--force-message",
|
|
45
|
+
help="Всегда отправлять сообщение при отклике",
|
|
46
|
+
default=False,
|
|
47
|
+
action=argparse.BooleanOptionalAction,
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--apply-interval",
|
|
51
|
+
help="Интервал перед отправкой откликов в секундах (X, X-Y)",
|
|
52
|
+
default="1-5",
|
|
53
|
+
type=parse_interval,
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--page-interval",
|
|
57
|
+
help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
|
|
58
|
+
default="1-3",
|
|
59
|
+
type=parse_interval,
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--order-by",
|
|
63
|
+
help="Сортировка вакансий",
|
|
64
|
+
choices=[
|
|
65
|
+
"publication_time",
|
|
66
|
+
"salary_desc",
|
|
67
|
+
"salary_asc",
|
|
68
|
+
"relevance",
|
|
69
|
+
"distance",
|
|
70
|
+
],
|
|
71
|
+
default="relevance",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--search",
|
|
75
|
+
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
|
|
76
|
+
type=str,
|
|
77
|
+
default=None,
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--dry-run",
|
|
81
|
+
help="Не отправлять отклики, а только выводить параметры запроса",
|
|
82
|
+
default=False,
|
|
83
|
+
action=argparse.BooleanOptionalAction,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def run(self, args: Namespace) -> None:
|
|
87
|
+
self.enable_telemetry = True
|
|
88
|
+
if args.disable_telemetry:
|
|
89
|
+
print(
|
|
90
|
+
"👁️ Телеметрия используется только для сбора данных о работодателях и их вакансиях, персональные данные пользователей не передаются на сервер."
|
|
91
|
+
)
|
|
92
|
+
if (
|
|
93
|
+
input("Вы действительно хотите отключить телеметрию (д/Н)? ")
|
|
94
|
+
.lower()
|
|
95
|
+
.startswith(("д", "y"))
|
|
96
|
+
):
|
|
97
|
+
self.enable_telemetry = False
|
|
98
|
+
logger.info("Телеметрия отключена.")
|
|
99
|
+
else:
|
|
100
|
+
logger.info("Спасибо за то что оставили телеметрию включенной!")
|
|
101
|
+
|
|
102
|
+
self.api = get_api(args)
|
|
103
|
+
self.resume_id = args.resume_id or self._get_resume_id()
|
|
104
|
+
self.application_messages = self._get_application_messages(
|
|
105
|
+
args.message_list
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self.apply_min_interval, self.apply_max_interval = args.apply_interval
|
|
109
|
+
self.page_min_interval, self.page_max_interval = args.page_interval
|
|
110
|
+
|
|
111
|
+
self.force_message = args.force_message
|
|
112
|
+
self.order_by = args.order_by
|
|
113
|
+
self.search = args.search
|
|
114
|
+
self.dry_run = args.dry_run
|
|
115
|
+
self._apply_similar()
|
|
116
|
+
|
|
117
|
+
def _get_application_messages(
|
|
118
|
+
self, message_list: TextIO | None
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
if message_list:
|
|
121
|
+
application_messages = list(
|
|
122
|
+
filter(None, map(str.strip, message_list))
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
application_messages = [
|
|
126
|
+
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
|
|
127
|
+
"{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
|
|
128
|
+
]
|
|
129
|
+
return application_messages
|
|
130
|
+
|
|
131
|
+
def _apply_similar(self) -> None:
|
|
132
|
+
telemetry_client = TelemetryClient(proxies=self.api.proxies)
|
|
133
|
+
telemetry_data = defaultdict(dict)
|
|
134
|
+
|
|
135
|
+
vacancies = self._get_vacancies()
|
|
136
|
+
|
|
137
|
+
if self.enable_telemetry:
|
|
138
|
+
for vacancy in vacancies:
|
|
139
|
+
vacancy_id = vacancy["id"]
|
|
140
|
+
telemetry_data["vacancies"][vacancy_id] = {
|
|
141
|
+
"name": vacancy.get("name"),
|
|
142
|
+
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
143
|
+
"area": vacancy.get("area", {}).get("name"), # город
|
|
144
|
+
"salary": vacancy.get(
|
|
145
|
+
"salary"
|
|
146
|
+
), # from, to, currency, gross
|
|
147
|
+
"direct_url": vacancy.get(
|
|
148
|
+
"alternate_url"
|
|
149
|
+
), # ссылка на вакансию
|
|
150
|
+
"created_at": fix_datetime(
|
|
151
|
+
vacancy.get("created_at")
|
|
152
|
+
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
153
|
+
"published_at": fix_datetime(vacancy.get("published_at")),
|
|
154
|
+
"contacts": vacancy.get(
|
|
155
|
+
"contacts"
|
|
156
|
+
), # пиздорванки там телеграм для связи указывают
|
|
157
|
+
# HH с точки зрения перфикциониста — кусок говна, где кривые
|
|
158
|
+
# форматы даты, у вакансий может не быть работодателя...
|
|
159
|
+
"employer_id": int(vacancy["employer"]["id"])
|
|
160
|
+
if "employer" in vacancy and "id" in vacancy["employer"]
|
|
161
|
+
else None,
|
|
162
|
+
# "relations": vacancy.get("relations", []),
|
|
163
|
+
# Остальное неинтересно
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
me = self.api.get("/me")
|
|
167
|
+
|
|
168
|
+
basic_message_placeholders = {
|
|
169
|
+
"first_name": me.get("first_name", ""),
|
|
170
|
+
"last_name": me.get("last_name", ""),
|
|
171
|
+
"email": me.get("email", ""),
|
|
172
|
+
"phone": me.get("phone", ""),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
do_apply = True
|
|
176
|
+
complained_employers = set()
|
|
177
|
+
|
|
178
|
+
for vacancy in vacancies:
|
|
179
|
+
try:
|
|
180
|
+
message_placeholders = {
|
|
181
|
+
"vacancy_name": vacancy.get("name", ""),
|
|
182
|
+
"employer_name": vacancy.get("employer", {}).get(
|
|
183
|
+
"name", ""
|
|
184
|
+
),
|
|
185
|
+
**basic_message_placeholders,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logger.debug(
|
|
189
|
+
"Вакансия %(vacancy_name)s от %(employer_name)s"
|
|
190
|
+
% message_placeholders
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if vacancy.get("has_test"):
|
|
194
|
+
logger.debug(
|
|
195
|
+
"Пропускаем вакансию с тестом: %s",
|
|
196
|
+
vacancy["alternate_url"],
|
|
197
|
+
)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if vacancy.get("archived"):
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Пропускаем вакансию в архиве: %s",
|
|
203
|
+
vacancy["alternate_url"],
|
|
204
|
+
)
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
relations = vacancy.get("relations", [])
|
|
208
|
+
employer_id = vacancy.get("employer", {}).get("id")
|
|
209
|
+
|
|
210
|
+
if (
|
|
211
|
+
self.enable_telemetry
|
|
212
|
+
and employer_id
|
|
213
|
+
and employer_id not in telemetry_data["employers"]
|
|
214
|
+
and employer_id not in complained_employers
|
|
215
|
+
and (
|
|
216
|
+
not relations
|
|
217
|
+
or parse_invalid_datetime(vacancy["created_at"])
|
|
218
|
+
+ timedelta(days=7)
|
|
219
|
+
> datetime.now(tz=timezone.utc)
|
|
220
|
+
)
|
|
221
|
+
):
|
|
222
|
+
employer = self.api.get(f"/employers/{employer_id}")
|
|
223
|
+
|
|
224
|
+
employer_data = {
|
|
225
|
+
"name": employer.get("name"),
|
|
226
|
+
"type": employer.get("type"),
|
|
227
|
+
"description": employer.get("description"),
|
|
228
|
+
"site_url": employer.get("site_url"),
|
|
229
|
+
"area": employer.get("area", {}).get("name"), # город
|
|
230
|
+
}
|
|
231
|
+
if "got_rejection" in relations:
|
|
232
|
+
try:
|
|
233
|
+
print(
|
|
234
|
+
"🚨 Вы получили отказ от https://hh.ru/employer/%s"
|
|
235
|
+
% employer_id
|
|
236
|
+
)
|
|
237
|
+
response = telemetry_client.send_telemetry(
|
|
238
|
+
f"/employers/{employer_id}/complaint",
|
|
239
|
+
employer_data,
|
|
240
|
+
)
|
|
241
|
+
if "topic_url" in response:
|
|
242
|
+
print(
|
|
243
|
+
"Ссылка на обсуждение работодателя:",
|
|
244
|
+
response["topic_url"],
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
print(
|
|
248
|
+
"Создание темы для обсуждения работодателя добавлено в очередь..."
|
|
249
|
+
)
|
|
250
|
+
complained_employers.add(employer_id)
|
|
251
|
+
except TelemetryError as ex:
|
|
252
|
+
logger.error(ex)
|
|
253
|
+
elif do_apply:
|
|
254
|
+
telemetry_data["employers"][employer_id] = employer_data
|
|
255
|
+
|
|
256
|
+
if not do_apply:
|
|
257
|
+
logger.debug(
|
|
258
|
+
"Проопускаем вакансию так как достигла лимита заявок: %s",
|
|
259
|
+
vacancy["alternate_url"],
|
|
260
|
+
)
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
if relations:
|
|
264
|
+
logger.debug(
|
|
265
|
+
"Пропускаем вакансию с откликом: %s",
|
|
266
|
+
vacancy["alternate_url"],
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
params = {
|
|
271
|
+
"resume_id": self.resume_id,
|
|
272
|
+
"vacancy_id": vacancy["id"],
|
|
273
|
+
"message": "",
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if self.force_message or vacancy.get(
|
|
277
|
+
"response_letter_required"
|
|
278
|
+
):
|
|
279
|
+
msg = params["message"] = (
|
|
280
|
+
random_text(random.choice(self.application_messages))
|
|
281
|
+
% message_placeholders
|
|
282
|
+
)
|
|
283
|
+
logger.debug(msg)
|
|
284
|
+
|
|
285
|
+
if self.dry_run:
|
|
286
|
+
logger.info(
|
|
287
|
+
"Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
|
|
288
|
+
vacancy["alternate_url"],
|
|
289
|
+
params,
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
# Задержка перед отправкой отклика
|
|
294
|
+
interval = random.uniform(
|
|
295
|
+
self.apply_min_interval, self.apply_max_interval
|
|
296
|
+
)
|
|
297
|
+
time.sleep(interval)
|
|
298
|
+
|
|
299
|
+
res = self.api.post("/negotiations", params)
|
|
300
|
+
assert res == {}
|
|
301
|
+
print(
|
|
302
|
+
"📨 Отправили отклик",
|
|
303
|
+
vacancy["alternate_url"],
|
|
304
|
+
"(",
|
|
305
|
+
truncate_string(vacancy["name"]),
|
|
306
|
+
")",
|
|
307
|
+
)
|
|
308
|
+
except ApiError as ex:
|
|
309
|
+
logger.error(ex)
|
|
310
|
+
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
311
|
+
do_apply = False
|
|
312
|
+
|
|
313
|
+
print("📝 Отклики на вакансии разосланы!")
|
|
314
|
+
|
|
315
|
+
if self.enable_telemetry:
|
|
316
|
+
if self.dry_run:
|
|
317
|
+
# С --dry-run можно посмотреть что отправляется
|
|
318
|
+
logger.info(
|
|
319
|
+
"Dry Run: Данные телеметрии для отправки на сервер: %r",
|
|
320
|
+
telemetry_data,
|
|
321
|
+
)
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
response = telemetry_client.send_telemetry(
|
|
326
|
+
"/collect", dict(telemetry_data)
|
|
327
|
+
)
|
|
328
|
+
logger.debug(response)
|
|
329
|
+
except TelemetryError as ex:
|
|
330
|
+
logger.error(ex)
|
|
331
|
+
|
|
332
|
+
def _get_vacancies(self, per_page: int = 100) -> list[VacancyItem]:
|
|
333
|
+
rv = []
|
|
334
|
+
for page in range(20):
|
|
335
|
+
params = {
|
|
336
|
+
"page": page,
|
|
337
|
+
"per_page": per_page,
|
|
338
|
+
"order_by": self.order_by,
|
|
339
|
+
}
|
|
340
|
+
if self.search:
|
|
341
|
+
params["text"] = self.search
|
|
342
|
+
res: ApiListResponse = self.api.get(
|
|
343
|
+
f"/resumes/{self.resume_id}/similar_vacancies", params
|
|
344
|
+
)
|
|
345
|
+
rv.extend(res["items"])
|
|
346
|
+
if page >= res["pages"] - 1:
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
# Задержка перед получением следующей страницы
|
|
350
|
+
if page > 0:
|
|
351
|
+
interval = random.uniform(
|
|
352
|
+
self.page_min_interval, self.page_max_interval
|
|
353
|
+
)
|
|
354
|
+
time.sleep(interval)
|
|
355
|
+
|
|
356
|
+
return rv
|