hh-applicant-tool 0.5.6__tar.gz → 0.5.8__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.5.6 → hh_applicant_tool-0.5.8}/PKG-INFO +17 -11
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/README.md +16 -10
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/client.py +84 -55
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/errors.py +14 -8
- hh_applicant_tool-0.5.8/hh_applicant_tool/constants.py +14 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/jsonc.py +10 -5
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/main.py +5 -1
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/apply_similar.py +25 -34
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/authorize.py +8 -16
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/call_api.py +3 -4
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/clear_negotiations.py +5 -9
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/config.py +1 -1
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/get_employer_contacts.py +12 -22
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/list_resumes.py +3 -6
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/refresh_token.py +5 -16
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/reply_employers.py +8 -7
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/update_resumes.py +2 -3
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/whoami.py +2 -3
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/telemetry_client.py +1 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/pyproject.toml +8 -1
- hh_applicant_tool-0.5.6/hh_applicant_tool/constants.py +0 -12
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/__init__.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/__main__.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/ai/__init__.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/ai/blackbox.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/__init__.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/color_log.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/mixins.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/__init__.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/types.py +0 -0
- {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: hh-applicant-tool
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.8
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Senior YAML Developer
|
|
6
6
|
Author-email: yamldeveloper@proton.me
|
|
@@ -19,9 +19,9 @@ Description-Content-Type: text/markdown
|
|
|
19
19
|
|
|
20
20
|
## HH Applicant Tool
|
|
21
21
|
|
|
22
|
-

|
|
23
|
-
[]()
|
|
24
|
-
[]()
|
|
22
|
+

|
|
23
|
+
[]()
|
|
24
|
+
[]()
|
|
25
25
|
[]()
|
|
26
26
|
[]()
|
|
27
27
|
[]()
|
|
@@ -30,14 +30,20 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
<img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
|
+
### Внимание!!!
|
|
34
|
+
|
|
35
|
+
Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
|
|
36
|
+
|
|
37
|
+
|
|
33
38
|
### Описание
|
|
34
39
|
|
|
35
40
|
> Утилита для генерации сопроводительного письма может использовать AI
|
|
36
41
|
|
|
37
|
-
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал
|
|
42
|
+
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
|
|
38
43
|
|
|
39
44
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
40
|
-
asdf/pyenv/conda и что-то еще.
|
|
45
|
+
asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
|
|
46
|
+
версия Python новее.
|
|
41
47
|
|
|
42
48
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
43
49
|
|
|
@@ -251,7 +257,7 @@ https://hh.ru/employer/1918903
|
|
|
251
257
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
252
258
|
| **refresh-token** | Обновляет access_token. |
|
|
253
259
|
| **config** | Редактировать конфигурационный файл. |
|
|
254
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
260
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
|
|
255
261
|
|
|
256
262
|
### Формат текста сообщений
|
|
257
263
|
|
|
@@ -286,10 +292,10 @@ https://hh.ru/employer/1918903
|
|
|
286
292
|
|
|
287
293
|
### Использование AI для генерации сопроводительного письма
|
|
288
294
|
|
|
289
|
-
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
290
|
-
* В первом сообщении опишите свой опыт и тп.
|
|
291
|
-
* Далее откройте devtools, нажав `F12`.
|
|
292
|
-
* Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
|
|
295
|
+
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
296
|
+
* В первом сообщении опишите свой опыт и тп.
|
|
297
|
+
* Далее откройте devtools, нажав `F12`.
|
|
298
|
+
* Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
|
|
293
299
|
* Запустите редактирование конфига:
|
|
294
300
|
```sh
|
|
295
301
|
hh-applicant-tool config
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
## HH Applicant Tool
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-
[]()
|
|
5
|
-
[]()
|
|
3
|
+

|
|
4
|
+
[]()
|
|
5
|
+
[]()
|
|
6
6
|
[]()
|
|
7
7
|
[]()
|
|
8
8
|
[]()
|
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
<img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
+
### Внимание!!!
|
|
15
|
+
|
|
16
|
+
Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
|
|
17
|
+
|
|
18
|
+
|
|
14
19
|
### Описание
|
|
15
20
|
|
|
16
21
|
> Утилита для генерации сопроводительного письма может использовать AI
|
|
17
22
|
|
|
18
|
-
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал
|
|
23
|
+
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
|
|
19
24
|
|
|
20
25
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
21
|
-
asdf/pyenv/conda и что-то еще.
|
|
26
|
+
asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
|
|
27
|
+
версия Python новее.
|
|
22
28
|
|
|
23
29
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
24
30
|
|
|
@@ -232,7 +238,7 @@ https://hh.ru/employer/1918903
|
|
|
232
238
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
233
239
|
| **refresh-token** | Обновляет access_token. |
|
|
234
240
|
| **config** | Редактировать конфигурационный файл. |
|
|
235
|
-
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения.
|
|
241
|
+
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
|
|
236
242
|
|
|
237
243
|
### Формат текста сообщений
|
|
238
244
|
|
|
@@ -267,10 +273,10 @@ https://hh.ru/employer/1918903
|
|
|
267
273
|
|
|
268
274
|
### Использование AI для генерации сопроводительного письма
|
|
269
275
|
|
|
270
|
-
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
271
|
-
* В первом сообщении опишите свой опыт и тп.
|
|
272
|
-
* Далее откройте devtools, нажав `F12`.
|
|
273
|
-
* Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
|
|
276
|
+
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
277
|
+
* В первом сообщении опишите свой опыт и тп.
|
|
278
|
+
* Далее откройте devtools, нажав `F12`.
|
|
279
|
+
* Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
|
|
274
280
|
* Запустите редактирование конфига:
|
|
275
281
|
```sh
|
|
276
282
|
hh-applicant-tool config
|
|
@@ -3,21 +3,21 @@ from __future__ import annotations
|
|
|
3
3
|
import dataclasses
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
+
import uuid
|
|
6
7
|
import time
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from functools import partialmethod
|
|
9
10
|
from threading import Lock
|
|
10
11
|
from typing import Any, Literal
|
|
11
12
|
from urllib.parse import urlencode
|
|
12
|
-
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
import random
|
|
13
15
|
import requests
|
|
14
16
|
from requests import Response, Session
|
|
15
|
-
import random
|
|
16
17
|
|
|
17
18
|
from ..constants import (
|
|
18
19
|
ANDROID_CLIENT_ID,
|
|
19
20
|
ANDROID_CLIENT_SECRET,
|
|
20
|
-
USER_AGENT_TEMPLATE,
|
|
21
21
|
)
|
|
22
22
|
from ..types import AccessToken
|
|
23
23
|
from . import errors
|
|
@@ -48,31 +48,22 @@ class BaseClient:
|
|
|
48
48
|
self.session = session = requests.session()
|
|
49
49
|
session.headers.update(
|
|
50
50
|
{
|
|
51
|
-
"
|
|
51
|
+
"user-agent": self.user_agent or self.default_user_agent(),
|
|
52
|
+
"x-hh-app-active": "true",
|
|
52
53
|
**self.additional_headers(),
|
|
53
54
|
}
|
|
54
55
|
)
|
|
55
56
|
logger.debug("Default Headers: %r", session.headers)
|
|
56
57
|
|
|
57
58
|
def default_user_agent(self) -> str:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
random.choice(
|
|
61
|
-
[
|
|
62
|
-
"SM-G998B", # Samsung Galaxy S21 Ultra
|
|
63
|
-
"Pixel 6", # Google Pixel 6
|
|
64
|
-
"Mi 11", # Xiaomi Mi 11
|
|
65
|
-
"OnePlus 9", # OnePlus 9
|
|
66
|
-
"P40", # Huawei P40
|
|
67
|
-
"LG G8", # LG G8
|
|
68
|
-
"Xperia 1 II", # Sony Xperia 1 II
|
|
69
|
-
"Moto G Power", # Motorola Moto G Power
|
|
70
|
-
"HTC U12+", # HTC U12+
|
|
71
|
-
"ROG Phone 5", # Asus ROG Phone 5
|
|
72
|
-
]
|
|
73
|
-
),
|
|
74
|
-
random.randint(88, 130),
|
|
59
|
+
devices = "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(
|
|
60
|
+
", "
|
|
75
61
|
)
|
|
62
|
+
device = random.choice(devices)
|
|
63
|
+
minor = random.randint(100, 150)
|
|
64
|
+
patch = random.randint(10000, 15000)
|
|
65
|
+
android = random.randint(11, 15)
|
|
66
|
+
return f"ru.hh.android/7.{minor}.{patch}, Device: {device}, Android OS: {android} (UUID: {uuid.uuid4()})"
|
|
76
67
|
|
|
77
68
|
def additional_headers(
|
|
78
69
|
self,
|
|
@@ -83,7 +74,7 @@ class BaseClient:
|
|
|
83
74
|
self,
|
|
84
75
|
method: ALLOWED_METHODS,
|
|
85
76
|
endpoint: str,
|
|
86
|
-
params: dict | None = None,
|
|
77
|
+
params: dict[str, Any] | None = None,
|
|
87
78
|
delay: float | None = None,
|
|
88
79
|
**kwargs: Any,
|
|
89
80
|
) -> dict:
|
|
@@ -102,10 +93,11 @@ class BaseClient:
|
|
|
102
93
|
logger.debug("wait %fs before request", delay)
|
|
103
94
|
time.sleep(delay)
|
|
104
95
|
has_body = method in ["POST", "PUT"]
|
|
96
|
+
payload = {"data" if has_body else "params": params}
|
|
105
97
|
response = self.session.request(
|
|
106
98
|
method,
|
|
107
99
|
url,
|
|
108
|
-
**
|
|
100
|
+
**payload,
|
|
109
101
|
proxies=self.proxies,
|
|
110
102
|
allow_redirects=False,
|
|
111
103
|
)
|
|
@@ -125,12 +117,7 @@ class BaseClient:
|
|
|
125
117
|
"%d %-6s %s",
|
|
126
118
|
response.status_code,
|
|
127
119
|
method,
|
|
128
|
-
url
|
|
129
|
-
+ (
|
|
130
|
-
"?" + urlencode(params)
|
|
131
|
-
if not has_body and params
|
|
132
|
-
else ""
|
|
133
|
-
),
|
|
120
|
+
url + ("?" + urlencode(params) if not has_body and params else ""),
|
|
134
121
|
)
|
|
135
122
|
self.previous_request_time = time.monotonic()
|
|
136
123
|
self.raise_for_status(response, rv)
|
|
@@ -143,11 +130,7 @@ class BaseClient:
|
|
|
143
130
|
delete = partialmethod(request, "DELETE")
|
|
144
131
|
|
|
145
132
|
def resolve_url(self, url: str) -> str:
|
|
146
|
-
return (
|
|
147
|
-
url
|
|
148
|
-
if "://" in url
|
|
149
|
-
else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
|
|
150
|
-
)
|
|
133
|
+
return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
|
|
151
134
|
|
|
152
135
|
@staticmethod
|
|
153
136
|
def raise_for_status(response: Response, data: dict) -> None:
|
|
@@ -155,6 +138,8 @@ class BaseClient:
|
|
|
155
138
|
case 301 | 302:
|
|
156
139
|
raise errors.Redirect(response, data)
|
|
157
140
|
case 400:
|
|
141
|
+
if errors.ApiError.is_limit_exceeded(data):
|
|
142
|
+
raise errors.LimitExceeded(response=response, data=data)
|
|
158
143
|
raise errors.BadRequest(response, data)
|
|
159
144
|
case 403:
|
|
160
145
|
raise errors.Forbidden(response, data)
|
|
@@ -170,8 +155,8 @@ class BaseClient:
|
|
|
170
155
|
|
|
171
156
|
@dataclass
|
|
172
157
|
class OAuthClient(BaseClient):
|
|
173
|
-
client_id: str
|
|
174
|
-
client_secret: str
|
|
158
|
+
client_id: str
|
|
159
|
+
client_secret: str
|
|
175
160
|
_: dataclasses.KW_ONLY
|
|
176
161
|
base_url: str = "https://hh.ru/oauth"
|
|
177
162
|
state: str = ""
|
|
@@ -190,6 +175,16 @@ class OAuthClient(BaseClient):
|
|
|
190
175
|
params_qs = urlencode({k: v for k, v in params.items() if v})
|
|
191
176
|
return self.resolve_url(f"/authorize?{params_qs}")
|
|
192
177
|
|
|
178
|
+
def request_access_token(
|
|
179
|
+
self, endpoint: str, params: dict[str, Any] | None = None, **kw: Any
|
|
180
|
+
) -> AccessToken:
|
|
181
|
+
tok = self.post(endpoint, params, **kw)
|
|
182
|
+
return {
|
|
183
|
+
"access_token": tok.get("access_token"),
|
|
184
|
+
"refresh_token": tok.get("refresh_token"),
|
|
185
|
+
"access_expires_at": int(time.time()) + tok.pop("expires_in", 0),
|
|
186
|
+
}
|
|
187
|
+
|
|
193
188
|
def authenticate(self, code: str) -> AccessToken:
|
|
194
189
|
params = {
|
|
195
190
|
"client_id": self.client_id,
|
|
@@ -197,11 +192,11 @@ class OAuthClient(BaseClient):
|
|
|
197
192
|
"code": code,
|
|
198
193
|
"grant_type": "authorization_code",
|
|
199
194
|
}
|
|
200
|
-
return self.
|
|
195
|
+
return self.request_access_token("/token", params)
|
|
201
196
|
|
|
202
197
|
def refresh_access(self, refresh_token: str) -> AccessToken:
|
|
203
198
|
# refresh_token можно использовать только один раз и только по истечению срока действия access_token.
|
|
204
|
-
return self.
|
|
199
|
+
return self.request_access_token(
|
|
205
200
|
"/token", grant_type="refresh_token", refresh_token=refresh_token
|
|
206
201
|
)
|
|
207
202
|
|
|
@@ -211,32 +206,66 @@ class ApiClient(BaseClient):
|
|
|
211
206
|
# Например, для просмотра информации о компании токен не нужен
|
|
212
207
|
access_token: str | None = None
|
|
213
208
|
refresh_token: str | None = None
|
|
209
|
+
access_expires_at: int = 0
|
|
210
|
+
client_id: str = ANDROID_CLIENT_ID
|
|
211
|
+
client_secret: str = ANDROID_CLIENT_SECRET
|
|
214
212
|
_: dataclasses.KW_ONLY
|
|
215
213
|
base_url: str = "https://api.hh.ru/"
|
|
216
|
-
# oauth_client: OAuthClient | None = None
|
|
217
214
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
215
|
+
@property
|
|
216
|
+
def is_access_expired(self) -> bool:
|
|
217
|
+
return time.time() > self.access_expires_at
|
|
218
|
+
|
|
219
|
+
@cached_property
|
|
220
|
+
def oauth_client(self) -> OAuthClient:
|
|
221
|
+
return OAuthClient(
|
|
222
|
+
client_id=self.client_id,
|
|
223
|
+
client_secret=self.client_secret,
|
|
224
|
+
session=self.session,
|
|
225
|
+
)
|
|
223
226
|
|
|
224
227
|
def additional_headers(
|
|
225
228
|
self,
|
|
226
229
|
) -> dict[str, str]:
|
|
227
230
|
return (
|
|
228
|
-
{"
|
|
231
|
+
{"authorization": f"Bearer {self.access_token}"}
|
|
229
232
|
if self.access_token
|
|
230
233
|
else {}
|
|
231
234
|
)
|
|
232
235
|
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
236
|
+
# Реализовано автоматическое обновление токена
|
|
237
|
+
def request(
|
|
238
|
+
self,
|
|
239
|
+
method: ALLOWED_METHODS,
|
|
240
|
+
endpoint: str,
|
|
241
|
+
params: dict[str, Any] | None = None,
|
|
242
|
+
delay: float | None = None,
|
|
243
|
+
**kwargs: Any,
|
|
244
|
+
) -> dict:
|
|
245
|
+
def do_request():
|
|
246
|
+
return super().request(method, endpoint, params, delay, **kwargs)
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
return do_request()
|
|
250
|
+
# TODO: добавить класс для ошибок типа AccessTokenExpired
|
|
251
|
+
except errors.ApiError as ex:
|
|
252
|
+
if not self.is_access_expired:
|
|
253
|
+
raise ex
|
|
254
|
+
logger.info("try refresh access_token")
|
|
255
|
+
# Пробуем обновить токен
|
|
256
|
+
token = self.oauth_client.refresh_access(self.refresh_token)
|
|
257
|
+
self.handle_access_token(token)
|
|
258
|
+
# И повторно отправляем запрос
|
|
259
|
+
return do_request()
|
|
260
|
+
|
|
261
|
+
def handle_access_token(self, token: AccessToken) -> None:
|
|
262
|
+
for k in ["access_token", "refresh_token", "access_expires_at"]:
|
|
263
|
+
if k in token:
|
|
264
|
+
setattr(self, k, token[k])
|
|
265
|
+
|
|
266
|
+
def get_access_token(self) -> AccessToken:
|
|
267
|
+
return {
|
|
268
|
+
"access_token": self.access_token,
|
|
269
|
+
"refresh_token": self.refresh_token,
|
|
270
|
+
"access_expires_at": self.access_expires_at,
|
|
271
|
+
}
|
|
@@ -37,15 +37,19 @@ class ApiError(Exception):
|
|
|
37
37
|
def response_headers(self) -> CaseInsensitiveDict:
|
|
38
38
|
return self._response.headers
|
|
39
39
|
|
|
40
|
-
# def __getattr__(self, name: str) -> Any:
|
|
41
|
-
# try:
|
|
42
|
-
# return self._raw[name]
|
|
43
|
-
# except KeyError as ex:
|
|
44
|
-
# raise AttributeError(name) from ex
|
|
40
|
+
# def __getattr__(self, name: str) -> Any:
|
|
41
|
+
# try:
|
|
42
|
+
# return self._raw[name]
|
|
43
|
+
# except KeyError as ex:
|
|
44
|
+
# raise AttributeError(name) from ex
|
|
45
45
|
|
|
46
46
|
def __str__(self) -> str:
|
|
47
47
|
return str(self._raw)
|
|
48
48
|
|
|
49
|
+
@staticmethod
|
|
50
|
+
def is_limit_exceeded(data) -> bool:
|
|
51
|
+
return any(x["value"] == "limit_exceeded" for x in data.get("errors", []))
|
|
52
|
+
|
|
49
53
|
|
|
50
54
|
class Redirect(ApiError):
|
|
51
55
|
pass
|
|
@@ -56,9 +60,11 @@ class ClientError(ApiError):
|
|
|
56
60
|
|
|
57
61
|
|
|
58
62
|
class BadRequest(ClientError):
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LimitExceeded(ClientError):
|
|
67
|
+
pass
|
|
62
68
|
|
|
63
69
|
|
|
64
70
|
class Forbidden(ClientError):
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# USER_AGENT_TEMPLATE = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Mobile Safari/537.36"
|
|
2
|
+
|
|
3
|
+
ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
|
|
4
|
+
|
|
5
|
+
ANDROID_CLIENT_SECRET = (
|
|
6
|
+
"V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
# Используется для прямой авторизации. Этот способ мной не используется, так как
|
|
10
|
+
# для отображения капчи все равно нужен webview.
|
|
11
|
+
# K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
|
|
12
|
+
|
|
13
|
+
# Кривой формат, который используют эти долбоебы
|
|
14
|
+
INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
# Unused
|
|
2
|
+
"""Парсер JSON с комментариями"""
|
|
3
|
+
|
|
1
4
|
import re
|
|
2
5
|
import enum
|
|
3
6
|
from dataclasses import dataclass
|
|
4
7
|
import ast
|
|
5
8
|
from typing import Any, Iterator
|
|
6
|
-
from collections import OrderedDict
|
|
9
|
+
# from collections import OrderedDict
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
class TokenType(enum.Enum):
|
|
10
13
|
WHITESPACE = r"\s+"
|
|
11
14
|
COMMENT = r"//.*|/\*[\s\S]*?\*/"
|
|
12
15
|
NUMBER = r"-?\d+(?:\.\d+)?"
|
|
13
|
-
STRING = r'"(
|
|
16
|
+
STRING = r'"(?:\\.|[^"]+)*"'
|
|
14
17
|
KEYWORD = r"null|true|false"
|
|
15
18
|
OPEN_CURLY = r"\{"
|
|
16
19
|
CLOSE_CURLY = r"\}"
|
|
@@ -42,7 +45,8 @@ class JSONCParser:
|
|
|
42
45
|
lambda t: t.token_type not in [TokenType.COMMENT, TokenType.WHITESPACE],
|
|
43
46
|
tokenize(s),
|
|
44
47
|
)
|
|
45
|
-
self.
|
|
48
|
+
self.token: Token
|
|
49
|
+
self.next_token: Token | None = None
|
|
46
50
|
self.advance()
|
|
47
51
|
result = self.parse_value()
|
|
48
52
|
self.expect(TokenType.EOF)
|
|
@@ -91,6 +95,7 @@ class JSONCParser:
|
|
|
91
95
|
raise SyntaxError(f"Unexpected token: {self.token.token_type.name}")
|
|
92
96
|
|
|
93
97
|
def advance(self):
|
|
98
|
+
assert self.next_token is not None
|
|
94
99
|
self.token, self.next_token = (
|
|
95
100
|
self.next_token,
|
|
96
101
|
next(self.token_it, Token(TokenType.EOF, "")),
|
|
@@ -98,7 +103,7 @@ class JSONCParser:
|
|
|
98
103
|
# print(f"{self.token =}, {self.next_token =}")
|
|
99
104
|
|
|
100
105
|
def match(self, token_type: TokenType) -> bool:
|
|
101
|
-
if self.next_token.token_type == token_type:
|
|
106
|
+
if self.next_token is not None and self.next_token.token_type == token_type:
|
|
102
107
|
self.advance()
|
|
103
108
|
return True
|
|
104
109
|
return False
|
|
@@ -106,7 +111,7 @@ class JSONCParser:
|
|
|
106
111
|
def expect(self, token_type: TokenType):
|
|
107
112
|
if not self.match(token_type):
|
|
108
113
|
raise SyntaxError(
|
|
109
|
-
f"Expected {token_type.name}, got {self.next_token.token_type.name}"
|
|
114
|
+
f"Expected {token_type.name}, got {self.next_token.token_type.name if self.next_token else '???'}"
|
|
110
115
|
)
|
|
111
116
|
|
|
112
117
|
|
|
@@ -134,7 +134,11 @@ class HHApplicantTool:
|
|
|
134
134
|
logger.addHandler(handler)
|
|
135
135
|
if args.run:
|
|
136
136
|
try:
|
|
137
|
-
|
|
137
|
+
api = get_api(args)
|
|
138
|
+
if not (res := args.run(api, args)):
|
|
139
|
+
# 0 or None = success
|
|
140
|
+
args.config.save(token=api.get_access_token())
|
|
141
|
+
return res
|
|
138
142
|
except Exception as e:
|
|
139
143
|
logger.exception(e)
|
|
140
144
|
return 1
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/apply_similar.py
RENAMED
|
@@ -4,19 +4,25 @@ import random
|
|
|
4
4
|
import time
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
|
-
from typing import TextIO
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from hh_applicant_tool.api.errors import LimitExceeded
|
|
8
10
|
|
|
9
11
|
from ..ai.blackbox import BlackboxChat, BlackboxError
|
|
10
|
-
from ..api import ApiError,
|
|
12
|
+
from ..api import ApiError, ApiClient
|
|
11
13
|
from ..main import BaseOperation
|
|
12
14
|
from ..main import Namespace as BaseNamespace
|
|
13
15
|
from ..main import get_api
|
|
14
16
|
from ..mixins import GetResumeIdMixin
|
|
15
17
|
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
16
18
|
from ..types import ApiListResponse, VacancyItem
|
|
17
|
-
from ..utils import (
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
from ..utils import (
|
|
20
|
+
fix_datetime,
|
|
21
|
+
parse_interval,
|
|
22
|
+
parse_invalid_datetime,
|
|
23
|
+
random_text,
|
|
24
|
+
truncate_string,
|
|
25
|
+
)
|
|
20
26
|
|
|
21
27
|
logger = logging.getLogger(__package__)
|
|
22
28
|
|
|
@@ -43,7 +49,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
43
49
|
"-L",
|
|
44
50
|
"--message-list",
|
|
45
51
|
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
|
|
46
|
-
type=argparse.FileType(
|
|
52
|
+
type=argparse.FileType("r", encoding="utf-8", errors="replace"),
|
|
47
53
|
)
|
|
48
54
|
parser.add_argument(
|
|
49
55
|
"-f",
|
|
@@ -103,7 +109,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
103
109
|
action=argparse.BooleanOptionalAction,
|
|
104
110
|
)
|
|
105
111
|
|
|
106
|
-
def run(self, args: Namespace) -> None:
|
|
112
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
107
113
|
self.enable_telemetry = True
|
|
108
114
|
if args.disable_telemetry:
|
|
109
115
|
# print(
|
|
@@ -120,11 +126,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
120
126
|
# logger.info("Спасибо за то что оставили телеметрию включенной!")
|
|
121
127
|
self.enable_telemetry = False
|
|
122
128
|
|
|
123
|
-
self.api =
|
|
129
|
+
self.api = api
|
|
124
130
|
self.resume_id = args.resume_id or self._get_resume_id()
|
|
125
|
-
self.application_messages = self._get_application_messages(
|
|
126
|
-
args.message_list
|
|
127
|
-
)
|
|
131
|
+
self.application_messages = self._get_application_messages(args.message_list)
|
|
128
132
|
self.chat = None
|
|
129
133
|
|
|
130
134
|
if config := args.config.get("blackbox"):
|
|
@@ -145,13 +149,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
145
149
|
self.dry_run = args.dry_run
|
|
146
150
|
self._apply_similar()
|
|
147
151
|
|
|
148
|
-
def _get_application_messages(
|
|
149
|
-
self, message_list: TextIO | None
|
|
150
|
-
) -> list[str]:
|
|
152
|
+
def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
|
|
151
153
|
if message_list:
|
|
152
|
-
application_messages = list(
|
|
153
|
-
filter(None, map(str.strip, message_list))
|
|
154
|
-
)
|
|
154
|
+
application_messages = list(filter(None, map(str.strip, message_list)))
|
|
155
155
|
else:
|
|
156
156
|
application_messages = [
|
|
157
157
|
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
|
|
@@ -172,12 +172,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
172
172
|
"name": vacancy.get("name"),
|
|
173
173
|
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
174
174
|
"area": vacancy.get("area", {}).get("name"), # город
|
|
175
|
-
"salary": vacancy.get(
|
|
176
|
-
|
|
177
|
-
), # from, to, currency, gross
|
|
178
|
-
"direct_url": vacancy.get(
|
|
179
|
-
"alternate_url"
|
|
180
|
-
), # ссылка на вакансию
|
|
175
|
+
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
176
|
+
"direct_url": vacancy.get("alternate_url"), # ссылка на вакансию
|
|
181
177
|
"created_at": fix_datetime(
|
|
182
178
|
vacancy.get("created_at")
|
|
183
179
|
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
@@ -210,9 +206,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
210
206
|
try:
|
|
211
207
|
message_placeholders = {
|
|
212
208
|
"vacancy_name": vacancy.get("name", ""),
|
|
213
|
-
"employer_name": vacancy.get("employer", {}).get(
|
|
214
|
-
"name", ""
|
|
215
|
-
),
|
|
209
|
+
"employer_name": vacancy.get("employer", {}).get("name", ""),
|
|
216
210
|
**basic_message_placeholders,
|
|
217
211
|
}
|
|
218
212
|
|
|
@@ -304,9 +298,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
304
298
|
"message": "",
|
|
305
299
|
}
|
|
306
300
|
|
|
307
|
-
if self.force_message or vacancy.get(
|
|
308
|
-
"response_letter_required"
|
|
309
|
-
):
|
|
301
|
+
if self.force_message or vacancy.get("response_letter_required"):
|
|
310
302
|
if self.chat:
|
|
311
303
|
try:
|
|
312
304
|
msg = self.pre_prompt + "\n\n"
|
|
@@ -318,9 +310,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
318
310
|
continue
|
|
319
311
|
else:
|
|
320
312
|
msg = (
|
|
321
|
-
random_text(
|
|
322
|
-
random.choice(self.application_messages)
|
|
323
|
-
)
|
|
313
|
+
random_text(random.choice(self.application_messages))
|
|
324
314
|
% message_placeholders
|
|
325
315
|
)
|
|
326
316
|
|
|
@@ -350,10 +340,11 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
350
340
|
truncate_string(vacancy["name"]),
|
|
351
341
|
")",
|
|
352
342
|
)
|
|
343
|
+
except LimitExceeded:
|
|
344
|
+
print("⚠️ Достигли лимита рассылки")
|
|
345
|
+
do_apply = False
|
|
353
346
|
except ApiError as ex:
|
|
354
347
|
logger.error(ex)
|
|
355
|
-
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
356
|
-
do_apply = False
|
|
357
348
|
|
|
358
349
|
print("📝 Отклики на вакансии разосланы!")
|
|
359
350
|
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/authorize.py
RENAMED
|
@@ -35,7 +35,7 @@ except ImportError:
|
|
|
35
35
|
pass
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
from ..api import OAuthClient
|
|
38
|
+
from ..api import ApiClient, OAuthClient
|
|
39
39
|
from ..main import BaseOperation, Namespace
|
|
40
40
|
from ..utils import Config
|
|
41
41
|
|
|
@@ -54,10 +54,9 @@ class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
class WebViewWindow(QMainWindow):
|
|
57
|
-
def __init__(self,
|
|
57
|
+
def __init__(self, api_client: ApiClient) -> None:
|
|
58
58
|
super().__init__()
|
|
59
|
-
self.
|
|
60
|
-
self.config = config
|
|
59
|
+
self.api_client = api_client
|
|
61
60
|
# Настройка WebEngineView
|
|
62
61
|
self.web_view = QWebEngineView()
|
|
63
62
|
self.setCentralWidget(self.web_view)
|
|
@@ -68,16 +67,15 @@ class WebViewWindow(QMainWindow):
|
|
|
68
67
|
profile.installUrlSchemeHandler(b"hhandroid", self.hhandroid_handler)
|
|
69
68
|
# Настройки окна для мобильного вида
|
|
70
69
|
self.resize(480, 800)
|
|
71
|
-
self.web_view.setUrl(QUrl(
|
|
70
|
+
self.web_view.setUrl(QUrl(api_client.oauth_client.authorize_url))
|
|
72
71
|
|
|
73
72
|
def handle_redirect_uri(self, redirect_uri: str) -> None:
|
|
74
73
|
logger.debug(f"handle redirect uri: {redirect_uri}")
|
|
75
74
|
sp = urlsplit(redirect_uri)
|
|
76
75
|
code = parse_qs(sp.query).get("code", [None])[0]
|
|
77
76
|
if code:
|
|
78
|
-
token = self.oauth_client.authenticate(code)
|
|
79
|
-
|
|
80
|
-
self.config.save(token=dict(token, created_at=int(time.time())))
|
|
77
|
+
token = self.api_client.oauth_client.authenticate(code)
|
|
78
|
+
self.api_client.handle_access_token(token)
|
|
81
79
|
print("🔓 Авторизация прошла успешно!")
|
|
82
80
|
self.close()
|
|
83
81
|
|
|
@@ -88,21 +86,15 @@ class Operation(BaseOperation):
|
|
|
88
86
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
89
87
|
pass
|
|
90
88
|
|
|
91
|
-
def run(self, args: Namespace) -> None:
|
|
89
|
+
def run(self, api_client: ApiClient, args: Namespace) -> None:
|
|
92
90
|
if not QT_IMPORTED:
|
|
93
91
|
print_err(
|
|
94
92
|
"❗Критиническая Ошибка: PyQt6 не был импортирован, возможно, вы долбоеб и забыли его установить, либо же криворукие разрабы этой либы опять все сломали..."
|
|
95
93
|
)
|
|
96
94
|
sys.exit(1)
|
|
97
95
|
|
|
98
|
-
oauth = OAuthClient(
|
|
99
|
-
user_agent=(args.config["oauth_user_agent"] or args.config["user_agent"]),
|
|
100
|
-
)
|
|
101
|
-
|
|
102
96
|
app = QApplication(sys.argv)
|
|
103
|
-
window = WebViewWindow(
|
|
104
|
-
oauth.authorize_url, oauth_client=oauth, config=args.config
|
|
105
|
-
)
|
|
97
|
+
window = WebViewWindow(api_client=api_client)
|
|
106
98
|
window.show()
|
|
107
99
|
|
|
108
100
|
app.exec()
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/call_api.py
RENAMED
|
@@ -4,8 +4,8 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
-
from ..api import ApiError
|
|
8
|
-
from ..main import BaseOperation
|
|
7
|
+
from ..api import ApiError, ApiClient
|
|
8
|
+
from ..main import BaseOperation
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__package__)
|
|
@@ -33,8 +33,7 @@ class Operation(BaseOperation):
|
|
|
33
33
|
"-m", "--method", "--meth", default="GET", help="HTTP Метод"
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
def run(self, args: Namespace) -> None:
|
|
37
|
-
api = get_api(args)
|
|
36
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
38
37
|
params = dict(x.split("=", 1) for x in args.param)
|
|
39
38
|
try:
|
|
40
39
|
result = api.request(args.method, args.endpoint, params=params)
|
|
@@ -7,7 +7,6 @@ from ..api import ApiClient, ClientError
|
|
|
7
7
|
from ..constants import INVALID_ISO8601_FORMAT
|
|
8
8
|
from ..main import BaseOperation
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
|
-
from ..main import get_api
|
|
11
10
|
from ..types import ApiListResponse
|
|
12
11
|
from ..utils import print_err, truncate_string
|
|
13
12
|
|
|
@@ -58,8 +57,7 @@ class Operation(BaseOperation):
|
|
|
58
57
|
break
|
|
59
58
|
return rv
|
|
60
59
|
|
|
61
|
-
def run(self, args: Namespace) -> None:
|
|
62
|
-
api = get_api(args)
|
|
60
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
63
61
|
negotiations = self._get_active_negotiations(api)
|
|
64
62
|
print("Всего активных:", len(negotiations))
|
|
65
63
|
for item in negotiations:
|
|
@@ -69,16 +67,14 @@ class Operation(BaseOperation):
|
|
|
69
67
|
# hidden True
|
|
70
68
|
is_discard = state["id"] == "discard"
|
|
71
69
|
if not item["hidden"] and (
|
|
72
|
-
args.all
|
|
70
|
+
args.all
|
|
73
71
|
or is_discard
|
|
74
72
|
or (
|
|
75
73
|
state["id"] == "response"
|
|
76
|
-
and (
|
|
77
|
-
|
|
78
|
-
).replace(tzinfo=timezone.utc)
|
|
79
|
-
> datetime.strptime(
|
|
80
|
-
item["updated_at"], INVALID_ISO8601_FORMAT
|
|
74
|
+
and (datetime.utcnow() - timedelta(days=args.older_than)).replace(
|
|
75
|
+
tzinfo=timezone.utc
|
|
81
76
|
)
|
|
77
|
+
> datetime.strptime(item["updated_at"], INVALID_ISO8601_FORMAT)
|
|
82
78
|
)
|
|
83
79
|
):
|
|
84
80
|
decline_allowed = item.get("decline_allowed") or False
|
|
@@ -48,13 +48,11 @@ class Operation(BaseOperation):
|
|
|
48
48
|
help="Номер страницы в выдаче",
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
-
def run(self, args: Namespace) -> None:
|
|
51
|
+
def run(self, _, args: Namespace) -> None:
|
|
52
52
|
proxies = get_proxies(args)
|
|
53
53
|
client = TelemetryClient(proxies=proxies)
|
|
54
54
|
auth = (
|
|
55
|
-
(args.username, args.password)
|
|
56
|
-
if args.username and args.password
|
|
57
|
-
else None
|
|
55
|
+
(args.username, args.password) if args.username and args.password else None
|
|
58
56
|
)
|
|
59
57
|
# Аутентификация пользователя
|
|
60
58
|
results = client.get_telemetry(
|
|
@@ -62,6 +60,13 @@ class Operation(BaseOperation):
|
|
|
62
60
|
{"search": args.search, "per_page": 10, "page": args.page},
|
|
63
61
|
auth=auth,
|
|
64
62
|
)
|
|
63
|
+
if "contact_persons" not in results:
|
|
64
|
+
print("❌", results)
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
print("Данная информация была собрана из публичных источников.")
|
|
68
|
+
print()
|
|
69
|
+
|
|
65
70
|
self._print_contacts(results)
|
|
66
71
|
|
|
67
72
|
def _print_contacts(self, data: dict) -> None:
|
|
@@ -78,26 +83,11 @@ class Operation(BaseOperation):
|
|
|
78
83
|
def _print_contact(self, contact: dict, is_last_contact: bool) -> None:
|
|
79
84
|
"""Вывод информации о конкретном контакте."""
|
|
80
85
|
prefix = "└──" if is_last_contact else "├──"
|
|
81
|
-
print(f" {prefix} 🧑
|
|
86
|
+
print(f" {prefix} 🧑 Контактное лицо")
|
|
82
87
|
prefix2 = " " if is_last_contact else " │ "
|
|
83
88
|
print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
|
|
84
89
|
employer = contact.get("employer") or {}
|
|
85
90
|
print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
|
|
86
91
|
print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
|
|
87
|
-
print(f"{prefix2}
|
|
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']}")
|
|
92
|
+
print(f"{prefix2}└── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
|
|
93
|
+
print(prefix2)
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/list_resumes.py
RENAMED
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
from prettytable import PrettyTable
|
|
6
6
|
|
|
7
7
|
from ..api import ApiClient
|
|
8
|
-
from ..main import BaseOperation
|
|
8
|
+
from ..main import BaseOperation
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
10
|
from ..types import ApiListResponse
|
|
11
11
|
from ..utils import truncate_string
|
|
@@ -23,12 +23,9 @@ class Operation(BaseOperation):
|
|
|
23
23
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
24
24
|
pass
|
|
25
25
|
|
|
26
|
-
def run(self,
|
|
27
|
-
api = get_api(args)
|
|
26
|
+
def run(self, api: ApiClient, _) -> None:
|
|
28
27
|
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
29
|
-
t = PrettyTable(
|
|
30
|
-
field_names=["ID", "Название", "Статус"], align="l", valign="t"
|
|
31
|
-
)
|
|
28
|
+
t = PrettyTable(field_names=["ID", "Название", "Статус"], align="l", valign="t")
|
|
32
29
|
t.add_rows(
|
|
33
30
|
[
|
|
34
31
|
(
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/refresh_token.py
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import argparse
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
|
-
from ..api import ApiError, OAuthClient
|
|
5
|
+
from ..api import ApiError, ApiClient, OAuthClient
|
|
6
6
|
from ..main import BaseOperation
|
|
7
7
|
from ..main import Namespace as BaseNamespace
|
|
8
8
|
from ..utils import print_err
|
|
@@ -20,22 +20,11 @@ class Operation(BaseOperation):
|
|
|
20
20
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
21
21
|
pass
|
|
22
22
|
|
|
23
|
-
def run(self, args: Namespace) -> None:
|
|
24
|
-
if (
|
|
25
|
-
not args.config["token"]
|
|
26
|
-
or not args.config["token"]["refresh_token"]
|
|
27
|
-
):
|
|
28
|
-
print_err("❗ Необходим refresh_token!")
|
|
29
|
-
return 1
|
|
23
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
30
24
|
try:
|
|
31
|
-
oauth =
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
or args.config["user_agent"]
|
|
35
|
-
),
|
|
36
|
-
)
|
|
37
|
-
token = oauth.refresh_access(args.config["token"]["refresh_token"])
|
|
38
|
-
args.config.save(token=token)
|
|
25
|
+
oauth: OAuthClient = api.oauth_client
|
|
26
|
+
token = oauth.refresh_access(api.refresh_token)
|
|
27
|
+
api.handle_access_token(token)
|
|
39
28
|
print("✅ Токен обновлен!")
|
|
40
29
|
except ApiError as ex:
|
|
41
30
|
print_err("❗ Ошибка:", ex)
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/reply_employers.py
RENAMED
|
@@ -4,10 +4,9 @@ import random
|
|
|
4
4
|
import time
|
|
5
5
|
from typing import Tuple
|
|
6
6
|
|
|
7
|
-
from ..api import ApiError
|
|
7
|
+
from ..api import ApiError, ApiClient
|
|
8
8
|
from ..main import BaseOperation
|
|
9
9
|
from ..main import Namespace as BaseNamespace
|
|
10
|
-
from ..main import get_api
|
|
11
10
|
from ..mixins import GetResumeIdMixin
|
|
12
11
|
from ..utils import parse_interval, random_text
|
|
13
12
|
|
|
@@ -67,8 +66,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
67
66
|
action=argparse.BooleanOptionalAction,
|
|
68
67
|
)
|
|
69
68
|
|
|
70
|
-
def run(self, args: Namespace) -> None:
|
|
71
|
-
self.api =
|
|
69
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
70
|
+
self.api = api
|
|
72
71
|
self.resume_id = self._get_resume_id()
|
|
73
72
|
self.reply_min_interval, self.reply_max_interval = args.reply_interval
|
|
74
73
|
self.reply_message = args.reply_message or args.config["reply_message"]
|
|
@@ -160,10 +159,12 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
160
159
|
print("💼", message_placeholders["vacancy_name"])
|
|
161
160
|
print("📅", vacancy["created_at"])
|
|
162
161
|
if salary:
|
|
163
|
-
salary_from = salary.get("from")or "-"
|
|
164
|
-
salary_to = salary.get("to")or "-"
|
|
162
|
+
salary_from = salary.get("from") or "-"
|
|
163
|
+
salary_to = salary.get("to") or "-"
|
|
165
164
|
salary_currency = salary.get("currency")
|
|
166
|
-
print(
|
|
165
|
+
print(
|
|
166
|
+
"💵 от", salary_from, "до", salary_to, salary_currency
|
|
167
|
+
)
|
|
167
168
|
print("")
|
|
168
169
|
print("Последние сообщения:")
|
|
169
170
|
for msg in (
|
{hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/update_resumes.py
RENAMED
|
@@ -3,7 +3,7 @@ import argparse
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
from ..api import ApiClient, ApiError
|
|
6
|
-
from ..main import BaseOperation
|
|
6
|
+
from ..main import BaseOperation
|
|
7
7
|
from ..main import Namespace as BaseNamespace
|
|
8
8
|
from ..types import ApiListResponse
|
|
9
9
|
from ..utils import print_err, truncate_string
|
|
@@ -21,8 +21,7 @@ class Operation(BaseOperation):
|
|
|
21
21
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
22
22
|
pass
|
|
23
23
|
|
|
24
|
-
def run(self, args: Namespace) -> None:
|
|
25
|
-
api = get_api(args)
|
|
24
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
26
25
|
resumes: ApiListResponse = api.get("/resumes/mine")
|
|
27
26
|
for resume in resumes["items"]:
|
|
28
27
|
try:
|
|
@@ -4,7 +4,7 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
|
|
6
6
|
from ..api import ApiClient
|
|
7
|
-
from ..main import BaseOperation
|
|
7
|
+
from ..main import BaseOperation
|
|
8
8
|
from ..main import Namespace as BaseNamespace
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__package__)
|
|
@@ -20,7 +20,6 @@ class Operation(BaseOperation):
|
|
|
20
20
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
21
21
|
pass
|
|
22
22
|
|
|
23
|
-
def run(self, args: Namespace) -> None:
|
|
24
|
-
api = get_api(args)
|
|
23
|
+
def run(self, api: ApiClient, args: Namespace) -> None:
|
|
25
24
|
result = api.get("/me")
|
|
26
25
|
print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "hh-applicant-tool"
|
|
3
|
-
version = "0.5.
|
|
3
|
+
version = "0.5.8"
|
|
4
4
|
description = ""
|
|
5
5
|
authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
|
|
6
6
|
readme = "README.md"
|
|
@@ -29,3 +29,10 @@ build-backend = "poetry.core.masonry.api"
|
|
|
29
29
|
|
|
30
30
|
[tool.poetry.scripts]
|
|
31
31
|
hh-applicant-tool = "hh_applicant_tool.main:main"
|
|
32
|
+
|
|
33
|
+
# Он заебал агриться на старый код, который был написан до появления агрессивной
|
|
34
|
+
# проверки типов в pyright
|
|
35
|
+
[tool.pyright]
|
|
36
|
+
# https://github.com/microsoft/pyright/blob/main/docs/configuration.md
|
|
37
|
+
# Ошибки показывать он не бросит, но заебывать перестанет
|
|
38
|
+
typeCheckingMode = "off"
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
USER_AGENT_TEMPLATE = "Mozilla/5.0 (Linux; Android %s; %s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Mobile Safari/537.36"
|
|
2
|
-
|
|
3
|
-
ANDROID_CLIENT_ID = (
|
|
4
|
-
"HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
|
|
5
|
-
)
|
|
6
|
-
|
|
7
|
-
ANDROID_CLIENT_SECRET = (
|
|
8
|
-
"V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
# Кривой формат, который используют эти долбоебы
|
|
12
|
-
INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
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.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|