hh-applicant-tool 0.4.1__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.1 → hh_applicant_tool-0.5.0}/PKG-INFO +4 -25
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/README.md +3 -24
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/main.py +7 -5
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/apply_similar.py +91 -31
- hh_applicant_tool-0.5.0/hh_applicant_tool/operations/get_employer_contacts.py +103 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/telemetry_client.py +27 -6
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/utils.py +21 -13
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/pyproject.toml +2 -1
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/__init__.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/__main__.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/__init__.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/client.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/api/errors.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/color_log.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/constants.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/mixins.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/__init__.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/authorize.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/call_api.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/clear_negotiations.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/list_resumes.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/refresh_token.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/reply_employers.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/update_resumes.py +0 -0
- {hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/whoami.py +0 -0
- {hh_applicant_tool-0.4.1 → 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
|
|
|
@@ -244,7 +244,7 @@ https://hh.ru/employer/1918903
|
|
|
244
244
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
245
245
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
246
246
|
| **refresh-token** | Обновляет access_token. |
|
|
247
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
247
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Это функционал для избранных, но в группе есть бесплатный бот с тем же функционалом. |
|
|
248
248
|
|
|
249
249
|
### Формат текста сообщений
|
|
250
250
|
|
|
@@ -289,24 +289,3 @@ https://hh.ru/employer/1918903
|
|
|
289
289
|
|
|
290
290
|
Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
|
|
291
291
|
|
|
292
|
-
### Сбор данных
|
|
293
|
-
|
|
294
|
-
Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
|
|
295
|
-
|
|
296
|
-
Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
|
|
297
|
-
|
|
298
|
-
1. Название вакансии.
|
|
299
|
-
1. Тип вакансии (открытая/закрытая).
|
|
300
|
-
1. Город, в котором размещена вакансия.
|
|
301
|
-
1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
|
|
302
|
-
1. Прямая ссылка на вакансию.
|
|
303
|
-
1. Дата создания вакансии.
|
|
304
|
-
1. Дата публикации вакансии.
|
|
305
|
-
1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
|
|
306
|
-
1. Название компании.
|
|
307
|
-
1. Тип компании.
|
|
308
|
-
1. Описание компании.
|
|
309
|
-
1. Ссылка на сайт компании.
|
|
310
|
-
1. Город, в котором находится компания.
|
|
311
|
-
|
|
312
|
-
|
|
@@ -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
|
|
|
@@ -225,7 +225,7 @@ https://hh.ru/employer/1918903
|
|
|
225
225
|
| **clear-negotiations** | Удаляет отказы и отменяет заявки, которые долго висят |
|
|
226
226
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
227
227
|
| **refresh-token** | Обновляет access_token. |
|
|
228
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
228
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Это функционал для избранных, но в группе есть бесплатный бот с тем же функционалом. |
|
|
229
229
|
|
|
230
230
|
### Формат текста сообщений
|
|
231
231
|
|
|
@@ -269,24 +269,3 @@ https://hh.ru/employer/1918903
|
|
|
269
269
|
* [HH.RU OpenAPI](https://api.hh.ru/openapi/redoc)
|
|
270
270
|
|
|
271
271
|
Для тестирования запросов к API используйте команду `call-api` и `jq` для вывода JSON в удобочитаемом формате.
|
|
272
|
-
|
|
273
|
-
### Сбор данных
|
|
274
|
-
|
|
275
|
-
Сбор и передачу данных о вакансиях и работодателях можно отключить, но так никакие персональные данные пользователя не передаются, то можно ничего не менять. Так как утилита бесплатна, то передачу вышеуказанной телеметрии можете рассматривать как плату за пользование.
|
|
276
|
-
|
|
277
|
-
Утилита по умолчанию собирает и передает на сервер разработчика следующие данные:
|
|
278
|
-
|
|
279
|
-
1. Название вакансии.
|
|
280
|
-
1. Тип вакансии (открытая/закрытая).
|
|
281
|
-
1. Город, в котором размещена вакансия.
|
|
282
|
-
1. Информация о зарплате (минимальная, максимальная, валюта, указана ли зарплата до вычета налогов).
|
|
283
|
-
1. Прямая ссылка на вакансию.
|
|
284
|
-
1. Дата создания вакансии.
|
|
285
|
-
1. Дата публикации вакансии.
|
|
286
|
-
1. Контактная информация работодателя, указанная в вакансии с целью сохранения email, телефона и юзернейма аккаунта Telegram. Все эти данные вместе и каждое в частности не являются персональными данными на что есть [определение Верховного Суда](https://base.garant.ru/407421338/), так что я не вижу препятствий для того чтобы в дальнейшем утилита любому желающему давала возможность получить контакты любого работодателя, например, email и телегу. Тем более желающих послать нахуй тупую пизду, выкатившую немотивированный отказ, всегда будет предостаточно.
|
|
287
|
-
1. Название компании.
|
|
288
|
-
1. Тип компании.
|
|
289
|
-
1. Описание компании.
|
|
290
|
-
1. Ссылка на сайт компании.
|
|
291
|
-
1. Город, в котором находится компания.
|
|
292
|
-
|
|
@@ -4,23 +4,25 @@ import argparse
|
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
6
|
from importlib import import_module
|
|
7
|
+
from os import getenv
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from pkgutil import iter_modules
|
|
9
|
-
from typing import
|
|
10
|
+
from typing import Literal, Sequence
|
|
11
|
+
|
|
10
12
|
from .api import ApiClient
|
|
11
13
|
from .color_log import ColorHandler
|
|
12
14
|
from .utils import Config, get_config_path
|
|
13
|
-
from os import getenv
|
|
14
15
|
|
|
15
16
|
DEFAULT_CONFIG_PATH = (
|
|
16
|
-
get_config_path() / (__package__ or
|
|
17
|
+
get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__package__)
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class BaseOperation:
|
|
23
|
-
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
...
|
|
24
26
|
|
|
25
27
|
def run(self, args: argparse.Namespace) -> None | int:
|
|
26
28
|
raise NotImplementedError()
|
|
@@ -62,7 +64,7 @@ class HHApplicantTool:
|
|
|
62
64
|
|
|
63
65
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
64
66
|
|
|
65
|
-
Группа поддержки: <https://t.me
|
|
67
|
+
Группа поддержки: <https://t.me/otzyvy_headhunter>
|
|
66
68
|
"""
|
|
67
69
|
|
|
68
70
|
class ArgumentFormatter(
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/apply_similar.py
RENAMED
|
@@ -3,15 +3,18 @@ import logging
|
|
|
3
3
|
import random
|
|
4
4
|
import time
|
|
5
5
|
from collections import defaultdict
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
6
7
|
from typing import TextIO, Tuple
|
|
7
8
|
|
|
8
9
|
from ..api import ApiError, BadRequest
|
|
9
10
|
from ..main import BaseOperation
|
|
10
|
-
from ..main import Namespace as BaseNamespace
|
|
11
|
+
from ..main import Namespace as BaseNamespace
|
|
12
|
+
from ..main import get_api
|
|
13
|
+
from ..mixins import GetResumeIdMixin
|
|
11
14
|
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
12
15
|
from ..types import ApiListResponse, VacancyItem
|
|
13
|
-
from ..utils import fix_datetime,
|
|
14
|
-
|
|
16
|
+
from ..utils import (fix_datetime, parse_interval, parse_invalid_datetime,
|
|
17
|
+
random_text, truncate_string)
|
|
15
18
|
|
|
16
19
|
logger = logging.getLogger(__package__)
|
|
17
20
|
|
|
@@ -95,10 +98,12 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
95
98
|
logger.info("Телеметрия отключена.")
|
|
96
99
|
else:
|
|
97
100
|
logger.info("Спасибо за то что оставили телеметрию включенной!")
|
|
98
|
-
|
|
101
|
+
|
|
99
102
|
self.api = get_api(args)
|
|
100
103
|
self.resume_id = args.resume_id or self._get_resume_id()
|
|
101
|
-
self.application_messages = self._get_application_messages(
|
|
104
|
+
self.application_messages = self._get_application_messages(
|
|
105
|
+
args.message_list
|
|
106
|
+
)
|
|
102
107
|
|
|
103
108
|
self.apply_min_interval, self.apply_max_interval = args.apply_interval
|
|
104
109
|
self.page_min_interval, self.page_max_interval = args.page_interval
|
|
@@ -109,7 +114,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
109
114
|
self.dry_run = args.dry_run
|
|
110
115
|
self._apply_similar()
|
|
111
116
|
|
|
112
|
-
def _get_application_messages(
|
|
117
|
+
def _get_application_messages(
|
|
118
|
+
self, message_list: TextIO | None
|
|
119
|
+
) -> list[str]:
|
|
113
120
|
if message_list:
|
|
114
121
|
application_messages = list(
|
|
115
122
|
filter(None, map(str.strip, message_list))
|
|
@@ -134,7 +141,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
134
141
|
"name": vacancy.get("name"),
|
|
135
142
|
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
136
143
|
"area": vacancy.get("area", {}).get("name"), # город
|
|
137
|
-
"salary": vacancy.get(
|
|
144
|
+
"salary": vacancy.get(
|
|
145
|
+
"salary"
|
|
146
|
+
), # from, to, currency, gross
|
|
138
147
|
"direct_url": vacancy.get(
|
|
139
148
|
"alternate_url"
|
|
140
149
|
), # ссылка на вакансию
|
|
@@ -150,6 +159,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
150
159
|
"employer_id": int(vacancy["employer"]["id"])
|
|
151
160
|
if "employer" in vacancy and "id" in vacancy["employer"]
|
|
152
161
|
else None,
|
|
162
|
+
# "relations": vacancy.get("relations", []),
|
|
153
163
|
# Остальное неинтересно
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -162,6 +172,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
162
172
|
"phone": me.get("phone", ""),
|
|
163
173
|
}
|
|
164
174
|
|
|
175
|
+
do_apply = True
|
|
176
|
+
complained_employers = set()
|
|
177
|
+
|
|
165
178
|
for vacancy in vacancies:
|
|
166
179
|
try:
|
|
167
180
|
message_placeholders = {
|
|
@@ -178,41 +191,81 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
178
191
|
)
|
|
179
192
|
|
|
180
193
|
if vacancy.get("has_test"):
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if vacancy.get("archived"):
|
|
185
|
-
print(
|
|
186
|
-
"🚫 Пропускаем вакансию в архиве",
|
|
194
|
+
logger.debug(
|
|
195
|
+
"Пропускаем вакансию с тестом: %s",
|
|
187
196
|
vacancy["alternate_url"],
|
|
188
197
|
)
|
|
189
198
|
continue
|
|
190
199
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
print(
|
|
195
|
-
"🚫 Пропускаем вакансию с",
|
|
196
|
-
["откликом или приглашением", "отказом"]["got_rejection" in relations],
|
|
200
|
+
if vacancy.get("archived"):
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Пропускаем вакансию в архиве: %s",
|
|
197
203
|
vacancy["alternate_url"],
|
|
198
204
|
)
|
|
199
205
|
continue
|
|
200
206
|
|
|
207
|
+
relations = vacancy.get("relations", [])
|
|
201
208
|
employer_id = vacancy.get("employer", {}).get("id")
|
|
202
209
|
|
|
203
210
|
if (
|
|
204
211
|
self.enable_telemetry
|
|
205
212
|
and employer_id
|
|
206
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
|
+
)
|
|
207
221
|
):
|
|
208
222
|
employer = self.api.get(f"/employers/{employer_id}")
|
|
209
|
-
|
|
223
|
+
|
|
224
|
+
employer_data = {
|
|
210
225
|
"name": employer.get("name"),
|
|
211
226
|
"type": employer.get("type"),
|
|
212
227
|
"description": employer.get("description"),
|
|
213
228
|
"site_url": employer.get("site_url"),
|
|
214
229
|
"area": employer.get("area", {}).get("name"), # город
|
|
215
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
|
|
216
269
|
|
|
217
270
|
params = {
|
|
218
271
|
"resume_id": self.resume_id,
|
|
@@ -220,7 +273,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
220
273
|
"message": "",
|
|
221
274
|
}
|
|
222
275
|
|
|
223
|
-
if self.force_message or vacancy.get(
|
|
276
|
+
if self.force_message or vacancy.get(
|
|
277
|
+
"response_letter_required"
|
|
278
|
+
):
|
|
224
279
|
msg = params["message"] = (
|
|
225
280
|
random_text(random.choice(self.application_messages))
|
|
226
281
|
% message_placeholders
|
|
@@ -229,7 +284,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
229
284
|
|
|
230
285
|
if self.dry_run:
|
|
231
286
|
logger.info(
|
|
232
|
-
|
|
287
|
+
"Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
|
|
233
288
|
vacancy["alternate_url"],
|
|
234
289
|
params,
|
|
235
290
|
)
|
|
@@ -253,24 +308,28 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
253
308
|
except ApiError as ex:
|
|
254
309
|
logger.error(ex)
|
|
255
310
|
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
256
|
-
|
|
311
|
+
do_apply = False
|
|
257
312
|
|
|
258
313
|
print("📝 Отклики на вакансии разосланы!")
|
|
259
314
|
|
|
260
315
|
if self.enable_telemetry:
|
|
261
316
|
if self.dry_run:
|
|
262
317
|
# С --dry-run можно посмотреть что отправляется
|
|
263
|
-
logger.info(
|
|
318
|
+
logger.info(
|
|
319
|
+
"Dry Run: Данные телеметрии для отправки на сервер: %r",
|
|
320
|
+
telemetry_data,
|
|
321
|
+
)
|
|
264
322
|
return
|
|
265
323
|
|
|
266
324
|
try:
|
|
267
|
-
telemetry_client.send_telemetry(
|
|
325
|
+
response = telemetry_client.send_telemetry(
|
|
326
|
+
"/collect", dict(telemetry_data)
|
|
327
|
+
)
|
|
328
|
+
logger.debug(response)
|
|
268
329
|
except TelemetryError as ex:
|
|
269
330
|
logger.error(ex)
|
|
270
|
-
|
|
271
|
-
def _get_vacancies(
|
|
272
|
-
self, per_page: int = 100
|
|
273
|
-
) -> list[VacancyItem]:
|
|
331
|
+
|
|
332
|
+
def _get_vacancies(self, per_page: int = 100) -> list[VacancyItem]:
|
|
274
333
|
rv = []
|
|
275
334
|
for page in range(20):
|
|
276
335
|
params = {
|
|
@@ -289,8 +348,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
289
348
|
|
|
290
349
|
# Задержка перед получением следующей страницы
|
|
291
350
|
if page > 0:
|
|
292
|
-
interval = random.uniform(
|
|
351
|
+
interval = random.uniform(
|
|
352
|
+
self.page_min_interval, self.page_max_interval
|
|
353
|
+
)
|
|
293
354
|
time.sleep(interval)
|
|
294
355
|
|
|
295
356
|
return rv
|
|
296
|
-
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
from os import getenv
|
|
4
|
+
|
|
5
|
+
from ..main import BaseOperation
|
|
6
|
+
from ..main import Namespace as BaseNamespace
|
|
7
|
+
from ..main import get_proxies
|
|
8
|
+
from ..telemetry_client import TelemetryClient
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__package__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Namespace(BaseNamespace):
|
|
14
|
+
username: str | None = None
|
|
15
|
+
password: str | None = None
|
|
16
|
+
search: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Operation(BaseOperation):
|
|
20
|
+
"""Выведет контакты работодателя по заданной строке поиска"""
|
|
21
|
+
|
|
22
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-u",
|
|
25
|
+
"--username",
|
|
26
|
+
type=str,
|
|
27
|
+
help="Имя пользователя для аутентификации",
|
|
28
|
+
default=getenv("AUTH_USERNAME"),
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-P",
|
|
32
|
+
"--password",
|
|
33
|
+
type=str,
|
|
34
|
+
help="Пароль для аутентификации",
|
|
35
|
+
default=getenv("AUTH_PASSWORD"),
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"-s",
|
|
39
|
+
"--search",
|
|
40
|
+
type=str,
|
|
41
|
+
default="",
|
|
42
|
+
help="Строка поиска для контактов работодателя",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"-p",
|
|
46
|
+
"--page",
|
|
47
|
+
default=1,
|
|
48
|
+
help="Номер страницы в выдаче",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def run(self, args: Namespace) -> None:
|
|
52
|
+
proxies = get_proxies(args)
|
|
53
|
+
client = TelemetryClient(proxies=proxies)
|
|
54
|
+
auth = (
|
|
55
|
+
(args.username, args.password)
|
|
56
|
+
if args.username and args.password
|
|
57
|
+
else None
|
|
58
|
+
)
|
|
59
|
+
# Аутентификация пользователя
|
|
60
|
+
results = client.get_telemetry(
|
|
61
|
+
"/contact/persons",
|
|
62
|
+
{"search": args.search, "per_page": 10, "page": args.page},
|
|
63
|
+
auth=auth,
|
|
64
|
+
)
|
|
65
|
+
self._print_contacts(results)
|
|
66
|
+
|
|
67
|
+
def _print_contacts(self, data: dict) -> None:
|
|
68
|
+
"""Вывод всех контактов в древовидной структуре."""
|
|
69
|
+
page = data["page"]
|
|
70
|
+
pages = (data["total"] // data["per_page"]) + 1
|
|
71
|
+
print(f"📋 Контакты ({page}/{pages}):")
|
|
72
|
+
contacts = data.get("contact_persons", [])
|
|
73
|
+
for idx, contact in enumerate(contacts):
|
|
74
|
+
is_last_contact = idx == len(contacts) - 1
|
|
75
|
+
self._print_contact(contact, is_last_contact)
|
|
76
|
+
print()
|
|
77
|
+
|
|
78
|
+
def _print_contact(self, contact: dict, is_last_contact: bool) -> None:
|
|
79
|
+
"""Вывод информации о конкретном контакте."""
|
|
80
|
+
prefix = "└──" if is_last_contact else "├──"
|
|
81
|
+
print(f" {prefix} 🧑 {contact.get('name', 'н/д')}")
|
|
82
|
+
prefix2 = " " if is_last_contact else " │ "
|
|
83
|
+
print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
|
|
84
|
+
employer = contact.get("employer") or {}
|
|
85
|
+
print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
|
|
86
|
+
print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
|
|
87
|
+
print(f"{prefix2}├── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
|
|
88
|
+
|
|
89
|
+
phones = contact["phone_numbers"] or [{"phone_number": "(нет номеров)"}]
|
|
90
|
+
print(f"{prefix2}├── 📞 Телефоны:")
|
|
91
|
+
last_phone = len(phones) - 1
|
|
92
|
+
for i, phone in enumerate(phones):
|
|
93
|
+
sub_prefix = "└──" if i == last_phone else "├──"
|
|
94
|
+
print(f"{prefix2}│ {sub_prefix} {phone['phone_number']}")
|
|
95
|
+
|
|
96
|
+
telegrams = contact["telegram_usernames"] or [
|
|
97
|
+
{"username": "(нет аккаунтов)"}
|
|
98
|
+
]
|
|
99
|
+
print(f"{prefix2}└── 📱 Telegram:")
|
|
100
|
+
last_telegram = len(telegrams) - 1
|
|
101
|
+
for i, telegram in enumerate(telegrams):
|
|
102
|
+
sub_prefix = "└──" if i == last_telegram else "├──"
|
|
103
|
+
print(f"{prefix2} {sub_prefix} {telegram['username']}")
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import json
|
|
3
|
-
from urllib.parse import urljoin
|
|
4
|
-
import requests
|
|
5
|
-
from typing import Optional, Dict, Any
|
|
6
2
|
import logging
|
|
7
|
-
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
8
5
|
import warnings
|
|
6
|
+
from functools import partialmethod
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import requests
|
|
9
11
|
|
|
10
|
-
warnings.filterwarnings(
|
|
12
|
+
warnings.filterwarnings("ignore", message="Unverified HTTPS request")
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger(__package__)
|
|
13
15
|
|
|
@@ -22,6 +24,7 @@ class TelemetryClient:
|
|
|
22
24
|
"""Клиент для отправки телеметрии на сервер."""
|
|
23
25
|
|
|
24
26
|
server_address: str = "https://hh-applicant-tool.mooo.com:54157/"
|
|
27
|
+
default_delay: float = 0.334 # Задержка по умолчанию в секундах
|
|
25
28
|
|
|
26
29
|
def __init__(
|
|
27
30
|
self,
|
|
@@ -30,6 +33,7 @@ class TelemetryClient:
|
|
|
30
33
|
session: Optional[requests.Session] = None,
|
|
31
34
|
user_agent: str = "Mozilla/5.0 (HHApplicantTelemetry/1.0)",
|
|
32
35
|
proxies: dict | None = None,
|
|
36
|
+
delay: Optional[float] = None,
|
|
33
37
|
) -> None:
|
|
34
38
|
self.server_address = os.getenv(
|
|
35
39
|
"TELEMETRY_SERVER", server_address or self.server_address
|
|
@@ -37,16 +41,28 @@ class TelemetryClient:
|
|
|
37
41
|
self.session = session or requests.Session()
|
|
38
42
|
self.user_agent = user_agent
|
|
39
43
|
self.proxies = proxies
|
|
44
|
+
self.delay = delay if delay is not None else self.default_delay
|
|
45
|
+
self.last_request_time = time.monotonic() # Время последнего запроса
|
|
40
46
|
|
|
41
47
|
def request(
|
|
42
48
|
self,
|
|
43
49
|
method: str,
|
|
44
50
|
endpoint: str,
|
|
45
51
|
data: Dict[str, Any] | None = None,
|
|
52
|
+
**kwargs: Any,
|
|
46
53
|
) -> Dict[str, Any]:
|
|
47
54
|
method = method.upper()
|
|
48
55
|
url = urljoin(self.server_address, endpoint)
|
|
49
56
|
has_body = method in ["POST", "PUT", "PATCH"]
|
|
57
|
+
|
|
58
|
+
# Вычисляем время, прошедшее с последнего запроса
|
|
59
|
+
current_time = time.monotonic()
|
|
60
|
+
time_since_last_request = current_time - self.last_request_time
|
|
61
|
+
|
|
62
|
+
# Если прошло меньше времени, чем задержка, ждем оставшееся время
|
|
63
|
+
if time_since_last_request < self.delay:
|
|
64
|
+
time.sleep(self.delay - time_since_last_request)
|
|
65
|
+
|
|
50
66
|
try:
|
|
51
67
|
response = self.session.request(
|
|
52
68
|
method,
|
|
@@ -56,6 +72,7 @@ class TelemetryClient:
|
|
|
56
72
|
params=data if not has_body else None,
|
|
57
73
|
json=data if has_body else None,
|
|
58
74
|
verify=False, # Игнорирование истекшего сертификата
|
|
75
|
+
**kwargs,
|
|
59
76
|
)
|
|
60
77
|
# response.raise_for_status()
|
|
61
78
|
result = response.json()
|
|
@@ -68,5 +85,9 @@ class TelemetryClient:
|
|
|
68
85
|
json.JSONDecodeError,
|
|
69
86
|
) as ex:
|
|
70
87
|
raise TelemetryError(str(ex)) from ex
|
|
88
|
+
finally:
|
|
89
|
+
# Обновляем время последнего запроса
|
|
90
|
+
self.last_request_time = time.monotonic()
|
|
71
91
|
|
|
92
|
+
get_telemetry = partialmethod(request, "GET")
|
|
72
93
|
send_telemetry = partialmethod(request, "POST")
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
5
|
import platform
|
|
6
|
+
import random
|
|
7
|
+
import re
|
|
6
8
|
import sys
|
|
9
|
+
from datetime import datetime
|
|
7
10
|
from functools import partial
|
|
11
|
+
from os import getenv
|
|
8
12
|
from pathlib import Path
|
|
9
13
|
from threading import Lock
|
|
10
14
|
from typing import Any
|
|
11
|
-
|
|
15
|
+
|
|
12
16
|
from .constants import INVALID_ISO8601_FORMAT
|
|
13
|
-
import re
|
|
14
|
-
import random
|
|
15
17
|
|
|
16
18
|
print_err = partial(print, file=sys.stderr, flush=True)
|
|
17
19
|
|
|
@@ -53,7 +55,13 @@ class Config(dict):
|
|
|
53
55
|
self._config_path.parent.mkdir(exist_ok=True, parents=True)
|
|
54
56
|
with self._lock:
|
|
55
57
|
with self._config_path.open("w+") as fp:
|
|
56
|
-
json.dump(
|
|
58
|
+
json.dump(
|
|
59
|
+
self,
|
|
60
|
+
fp,
|
|
61
|
+
ensure_ascii=True,
|
|
62
|
+
indent=2,
|
|
63
|
+
sort_keys=True,
|
|
64
|
+
)
|
|
57
65
|
|
|
58
66
|
__getitem__ = dict.get
|
|
59
67
|
|
|
@@ -62,18 +70,17 @@ def truncate_string(s: str, limit: int = 75, ellipsis: str = "…") -> str:
|
|
|
62
70
|
return s[:limit] + bool(s[limit:]) * ellipsis
|
|
63
71
|
|
|
64
72
|
|
|
65
|
-
def
|
|
66
|
-
# Объединяем данные и соль
|
|
67
|
-
salted_data = data + salt
|
|
73
|
+
def make_hash(data: str) -> str:
|
|
68
74
|
# Вычисляем хеш SHA-256
|
|
69
|
-
|
|
70
|
-
|
|
75
|
+
return hashlib.sha256(data.encode()).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_invalid_datetime(dt: str) -> datetime:
|
|
79
|
+
return datetime.strptime(dt, INVALID_ISO8601_FORMAT)
|
|
71
80
|
|
|
72
81
|
|
|
73
82
|
def fix_datetime(dt: str | None) -> str | None:
|
|
74
|
-
if dt is None
|
|
75
|
-
return None
|
|
76
|
-
return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()
|
|
83
|
+
return parse_invalid_datetime(dt).isoformat() if dt is not None else None
|
|
77
84
|
|
|
78
85
|
|
|
79
86
|
def random_text(s: str) -> str:
|
|
@@ -89,6 +96,7 @@ def random_text(s: str) -> str:
|
|
|
89
96
|
s = s1
|
|
90
97
|
return s
|
|
91
98
|
|
|
99
|
+
|
|
92
100
|
def parse_interval(interval: str) -> tuple[float, float]:
|
|
93
101
|
"""Парсит строку интервала и возвращает кортеж с минимальным и максимальным значениями."""
|
|
94
102
|
if "-" in interval:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "hh-applicant-tool"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.5.0"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -21,6 +21,7 @@ black = "^23.1.0"
|
|
|
21
21
|
isort = "^5.12.0"
|
|
22
22
|
pylint = "^2.16.4"
|
|
23
23
|
pytest = "^7.2.2"
|
|
24
|
+
ruff = "^0.7.4"
|
|
24
25
|
|
|
25
26
|
[build-system]
|
|
26
27
|
requires = ["poetry-core"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/__init__.py
RENAMED
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/authorize.py
RENAMED
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/call_api.py
RENAMED
|
File without changes
|
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/list_resumes.py
RENAMED
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/refresh_token.py
RENAMED
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/reply_employers.py
RENAMED
|
File without changes
|
{hh_applicant_tool-0.4.1 → hh_applicant_tool-0.5.0}/hh_applicant_tool/operations/update_resumes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|