hh-applicant-tool 0.5.7__tar.gz → 0.5.9__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.7 → hh_applicant_tool-0.5.9}/PKG-INFO +13 -22
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/README.md +12 -21
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/client.py +101 -42
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/errors.py +14 -8
- hh_applicant_tool-0.5.9/hh_applicant_tool/constants.py +14 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/jsonc.py +10 -5
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/main.py +18 -2
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/mixins.py +1 -1
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/apply_similar.py +37 -44
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/authorize.py +9 -19
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/call_api.py +8 -9
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/clear_negotiations.py +10 -14
- hh_applicant_tool-0.5.9/hh_applicant_tool/operations/config.py +51 -0
- hh_applicant_tool-0.5.9/hh_applicant_tool/operations/delete_telemetry.py +30 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/get_employer_contacts.py +15 -31
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/list_resumes.py +4 -7
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/refresh_token.py +4 -17
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/reply_employers.py +12 -11
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/update_resumes.py +4 -5
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/whoami.py +4 -5
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/telemetry_client.py +15 -2
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/pyproject.toml +11 -2
- hh_applicant_tool-0.5.7/hh_applicant_tool/constants.py +0 -12
- hh_applicant_tool-0.5.7/hh_applicant_tool/operations/config.py +0 -36
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/__init__.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/__main__.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/ai/__init__.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/ai/blackbox.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/__init__.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/color_log.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/__init__.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/types.py +0 -0
- {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/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.9
|
|
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
|
[]()
|
|
@@ -32,27 +32,18 @@ Description-Content-Type: text/markdown
|
|
|
32
32
|
|
|
33
33
|
### Внимание!!!
|
|
34
34
|
|
|
35
|
-
Если для Вас проблема
|
|
36
|
-
|
|
37
|
-

|
|
38
|
-
|
|
39
|
-
Что может приложение?
|
|
40
|
-
|
|
41
|
-
- Рассылать отклики с сопроводительным.
|
|
42
|
-
- Автоматически поднимать резюме.
|
|
43
|
-
- Все это работает в фоне, те запустили, свернули и забыли до перезагрузки...
|
|
44
|
-
|
|
45
|
-
[Скачать его можно тут](https://t.me/hh_resume_automate).
|
|
35
|
+
Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
|
|
46
36
|
|
|
47
37
|
|
|
48
38
|
### Описание
|
|
49
39
|
|
|
50
40
|
> Утилита для генерации сопроводительного письма может использовать AI
|
|
51
41
|
|
|
52
|
-
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита
|
|
42
|
+
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
|
|
53
43
|
|
|
54
44
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
55
|
-
asdf/pyenv/conda и что-то еще.
|
|
45
|
+
asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
|
|
46
|
+
версия Python новее.
|
|
56
47
|
|
|
57
48
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
58
49
|
|
|
@@ -266,7 +257,7 @@ https://hh.ru/employer/1918903
|
|
|
266
257
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
267
258
|
| **refresh-token** | Обновляет access_token. |
|
|
268
259
|
| **config** | Редактировать конфигурационный файл. |
|
|
269
|
-
| **get-employer-contacts** | Получить список контактов
|
|
260
|
+
| **get-employer-contacts** | Получить список контактов работадателей. |
|
|
270
261
|
|
|
271
262
|
### Формат текста сообщений
|
|
272
263
|
|
|
@@ -301,10 +292,10 @@ https://hh.ru/employer/1918903
|
|
|
301
292
|
|
|
302
293
|
### Использование AI для генерации сопроводительного письма
|
|
303
294
|
|
|
304
|
-
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
305
|
-
* В первом сообщении опишите свой опыт и тп.
|
|
306
|
-
* Далее откройте devtools, нажав `F12`.
|
|
307
|
-
* Во вкладке `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`.
|
|
308
299
|
* Запустите редактирование конфига:
|
|
309
300
|
```sh
|
|
310
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
|
[]()
|
|
@@ -13,27 +13,18 @@
|
|
|
13
13
|
|
|
14
14
|
### Внимание!!!
|
|
15
15
|
|
|
16
|
-
Если для Вас проблема
|
|
17
|
-
|
|
18
|
-

|
|
19
|
-
|
|
20
|
-
Что может приложение?
|
|
21
|
-
|
|
22
|
-
- Рассылать отклики с сопроводительным.
|
|
23
|
-
- Автоматически поднимать резюме.
|
|
24
|
-
- Все это работает в фоне, те запустили, свернули и забыли до перезагрузки...
|
|
25
|
-
|
|
26
|
-
[Скачать его можно тут](https://t.me/hh_resume_automate).
|
|
16
|
+
Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
|
|
27
17
|
|
|
28
18
|
|
|
29
19
|
### Описание
|
|
30
20
|
|
|
31
21
|
> Утилита для генерации сопроводительного письма может использовать AI
|
|
32
22
|
|
|
33
|
-
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита
|
|
23
|
+
Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
|
|
34
24
|
|
|
35
25
|
Работает с Python >= 3.10. Нужную версию Python можно поставить через
|
|
36
|
-
asdf/pyenv/conda и что-то еще.
|
|
26
|
+
asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
|
|
27
|
+
версия Python новее.
|
|
37
28
|
|
|
38
29
|
Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
|
|
39
30
|
|
|
@@ -247,7 +238,7 @@ https://hh.ru/employer/1918903
|
|
|
247
238
|
| **call-api** | Вызов произвольного метода API с выводом результата. |
|
|
248
239
|
| **refresh-token** | Обновляет access_token. |
|
|
249
240
|
| **config** | Редактировать конфигурационный файл. |
|
|
250
|
-
| **get-employer-contacts** | Получить список контактов
|
|
241
|
+
| **get-employer-contacts** | Получить список контактов работадателей. |
|
|
251
242
|
|
|
252
243
|
### Формат текста сообщений
|
|
253
244
|
|
|
@@ -282,10 +273,10 @@ https://hh.ru/employer/1918903
|
|
|
282
273
|
|
|
283
274
|
### Использование AI для генерации сопроводительного письма
|
|
284
275
|
|
|
285
|
-
* Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
|
|
286
|
-
* В первом сообщении опишите свой опыт и тп.
|
|
287
|
-
* Далее откройте devtools, нажав `F12`.
|
|
288
|
-
* Во вкладке `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`.
|
|
289
280
|
* Запустите редактирование конфига:
|
|
290
281
|
```sh
|
|
291
282
|
hh-applicant-tool config
|
|
@@ -10,7 +10,8 @@ from functools import partialmethod
|
|
|
10
10
|
from threading import Lock
|
|
11
11
|
from typing import Any, Literal
|
|
12
12
|
from urllib.parse import urlencode
|
|
13
|
-
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
import random
|
|
14
15
|
import requests
|
|
15
16
|
from requests import Response, Session
|
|
16
17
|
|
|
@@ -47,14 +48,22 @@ class BaseClient:
|
|
|
47
48
|
self.session = session = requests.session()
|
|
48
49
|
session.headers.update(
|
|
49
50
|
{
|
|
50
|
-
"
|
|
51
|
+
"user-agent": self.user_agent or self.default_user_agent(),
|
|
52
|
+
"x-hh-app-active": "true",
|
|
51
53
|
**self.additional_headers(),
|
|
52
54
|
}
|
|
53
55
|
)
|
|
54
56
|
logger.debug("Default Headers: %r", session.headers)
|
|
55
57
|
|
|
56
58
|
def default_user_agent(self) -> str:
|
|
57
|
-
|
|
59
|
+
devices = "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(
|
|
60
|
+
", "
|
|
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()})"
|
|
58
67
|
|
|
59
68
|
def additional_headers(
|
|
60
69
|
self,
|
|
@@ -65,7 +74,7 @@ class BaseClient:
|
|
|
65
74
|
self,
|
|
66
75
|
method: ALLOWED_METHODS,
|
|
67
76
|
endpoint: str,
|
|
68
|
-
params: dict | None = None,
|
|
77
|
+
params: dict[str, Any] | None = None,
|
|
69
78
|
delay: float | None = None,
|
|
70
79
|
**kwargs: Any,
|
|
71
80
|
) -> dict:
|
|
@@ -84,10 +93,11 @@ class BaseClient:
|
|
|
84
93
|
logger.debug("wait %fs before request", delay)
|
|
85
94
|
time.sleep(delay)
|
|
86
95
|
has_body = method in ["POST", "PUT"]
|
|
96
|
+
payload = {"data" if has_body else "params": params}
|
|
87
97
|
response = self.session.request(
|
|
88
98
|
method,
|
|
89
99
|
url,
|
|
90
|
-
**
|
|
100
|
+
**payload,
|
|
91
101
|
proxies=self.proxies,
|
|
92
102
|
allow_redirects=False,
|
|
93
103
|
)
|
|
@@ -107,29 +117,27 @@ class BaseClient:
|
|
|
107
117
|
"%d %-6s %s",
|
|
108
118
|
response.status_code,
|
|
109
119
|
method,
|
|
110
|
-
url
|
|
111
|
-
+ (
|
|
112
|
-
"?" + urlencode(params)
|
|
113
|
-
if not has_body and params
|
|
114
|
-
else ""
|
|
115
|
-
),
|
|
120
|
+
url + ("?" + urlencode(params) if not has_body and params else ""),
|
|
116
121
|
)
|
|
117
122
|
self.previous_request_time = time.monotonic()
|
|
118
123
|
self.raise_for_status(response, rv)
|
|
119
124
|
assert 300 > response.status_code >= 200
|
|
120
125
|
return rv
|
|
121
126
|
|
|
122
|
-
get
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
127
|
+
def get(self, *args, **kwargs):
|
|
128
|
+
return self.request("GET", *args, **kwargs)
|
|
129
|
+
|
|
130
|
+
def post(self, *args, **kwargs):
|
|
131
|
+
return self.request("POST", *args, **kwargs)
|
|
132
|
+
|
|
133
|
+
def put(self, *args, **kwargs):
|
|
134
|
+
return self.request("PUT", *args, **kwargs)
|
|
135
|
+
|
|
136
|
+
def delete(self, *args, **kwargs):
|
|
137
|
+
return self.request("DELETE", *args, **kwargs)
|
|
126
138
|
|
|
127
139
|
def resolve_url(self, url: str) -> str:
|
|
128
|
-
return (
|
|
129
|
-
url
|
|
130
|
-
if "://" in url
|
|
131
|
-
else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
|
|
132
|
-
)
|
|
140
|
+
return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
|
|
133
141
|
|
|
134
142
|
@staticmethod
|
|
135
143
|
def raise_for_status(response: Response, data: dict) -> None:
|
|
@@ -137,6 +145,8 @@ class BaseClient:
|
|
|
137
145
|
case 301 | 302:
|
|
138
146
|
raise errors.Redirect(response, data)
|
|
139
147
|
case 400:
|
|
148
|
+
if errors.ApiError.is_limit_exceeded(data):
|
|
149
|
+
raise errors.LimitExceeded(response=response, data=data)
|
|
140
150
|
raise errors.BadRequest(response, data)
|
|
141
151
|
case 403:
|
|
142
152
|
raise errors.Forbidden(response, data)
|
|
@@ -152,8 +162,8 @@ class BaseClient:
|
|
|
152
162
|
|
|
153
163
|
@dataclass
|
|
154
164
|
class OAuthClient(BaseClient):
|
|
155
|
-
client_id: str
|
|
156
|
-
client_secret: str
|
|
165
|
+
client_id: str
|
|
166
|
+
client_secret: str
|
|
157
167
|
_: dataclasses.KW_ONLY
|
|
158
168
|
base_url: str = "https://hh.ru/oauth"
|
|
159
169
|
state: str = ""
|
|
@@ -172,6 +182,16 @@ class OAuthClient(BaseClient):
|
|
|
172
182
|
params_qs = urlencode({k: v for k, v in params.items() if v})
|
|
173
183
|
return self.resolve_url(f"/authorize?{params_qs}")
|
|
174
184
|
|
|
185
|
+
def request_access_token(
|
|
186
|
+
self, endpoint: str, params: dict[str, Any] | None = None, **kw: Any
|
|
187
|
+
) -> AccessToken:
|
|
188
|
+
tok = self.post(endpoint, params, **kw)
|
|
189
|
+
return {
|
|
190
|
+
"access_token": tok.get("access_token"),
|
|
191
|
+
"refresh_token": tok.get("refresh_token"),
|
|
192
|
+
"access_expires_at": int(time.time()) + tok.pop("expires_in", 0),
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
def authenticate(self, code: str) -> AccessToken:
|
|
176
196
|
params = {
|
|
177
197
|
"client_id": self.client_id,
|
|
@@ -179,11 +199,11 @@ class OAuthClient(BaseClient):
|
|
|
179
199
|
"code": code,
|
|
180
200
|
"grant_type": "authorization_code",
|
|
181
201
|
}
|
|
182
|
-
return self.
|
|
202
|
+
return self.request_access_token("/token", params)
|
|
183
203
|
|
|
184
|
-
def
|
|
204
|
+
def refresh_access_token(self, refresh_token: str) -> AccessToken:
|
|
185
205
|
# refresh_token можно использовать только один раз и только по истечению срока действия access_token.
|
|
186
|
-
return self.
|
|
206
|
+
return self.request_access_token(
|
|
187
207
|
"/token", grant_type="refresh_token", refresh_token=refresh_token
|
|
188
208
|
)
|
|
189
209
|
|
|
@@ -193,32 +213,71 @@ class ApiClient(BaseClient):
|
|
|
193
213
|
# Например, для просмотра информации о компании токен не нужен
|
|
194
214
|
access_token: str | None = None
|
|
195
215
|
refresh_token: str | None = None
|
|
216
|
+
access_expires_at: int = 0
|
|
217
|
+
client_id: str = ANDROID_CLIENT_ID
|
|
218
|
+
client_secret: str = ANDROID_CLIENT_SECRET
|
|
196
219
|
_: dataclasses.KW_ONLY
|
|
197
220
|
base_url: str = "https://api.hh.ru/"
|
|
198
|
-
# oauth_client: OAuthClient | None = None
|
|
199
221
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
222
|
+
@property
|
|
223
|
+
def is_access_expired(self) -> bool:
|
|
224
|
+
return time.time() > self.access_expires_at
|
|
225
|
+
|
|
226
|
+
@cached_property
|
|
227
|
+
def oauth_client(self) -> OAuthClient:
|
|
228
|
+
return OAuthClient(
|
|
229
|
+
client_id=self.client_id,
|
|
230
|
+
client_secret=self.client_secret,
|
|
231
|
+
session=self.session,
|
|
232
|
+
)
|
|
205
233
|
|
|
206
234
|
def additional_headers(
|
|
207
235
|
self,
|
|
208
236
|
) -> dict[str, str]:
|
|
209
237
|
return (
|
|
210
|
-
{"
|
|
238
|
+
{"authorization": f"Bearer {self.access_token}"}
|
|
211
239
|
if self.access_token
|
|
212
240
|
else {}
|
|
213
241
|
)
|
|
214
242
|
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
243
|
+
# Реализовано автоматическое обновление токена
|
|
244
|
+
def request(
|
|
245
|
+
self,
|
|
246
|
+
method: ALLOWED_METHODS,
|
|
247
|
+
endpoint: str,
|
|
248
|
+
params: dict[str, Any] | None = None,
|
|
249
|
+
delay: float | None = None,
|
|
250
|
+
**kwargs: Any,
|
|
251
|
+
) -> dict:
|
|
252
|
+
def do_request():
|
|
253
|
+
return BaseClient.request(self, method, endpoint, params, delay, **kwargs)
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
return do_request()
|
|
257
|
+
# TODO: добавить класс для ошибок типа AccessTokenExpired
|
|
258
|
+
except errors.Forbidden as ex:
|
|
259
|
+
if not self.is_access_expired or not self.refresh_token:
|
|
260
|
+
raise ex
|
|
261
|
+
logger.info("try refresh access_token")
|
|
262
|
+
# Пробуем обновить токен
|
|
263
|
+
self.refresh_access_token()
|
|
264
|
+
# И повторно отправляем запрос
|
|
265
|
+
return do_request()
|
|
266
|
+
|
|
267
|
+
def handle_access_token(self, token: AccessToken) -> None:
|
|
268
|
+
for field in ["access_token", "refresh_token", "access_expires_at"]:
|
|
269
|
+
if field in token and hasattr(self, field):
|
|
270
|
+
setattr(self, field, token[field])
|
|
271
|
+
|
|
272
|
+
def refresh_access_token(self) -> None:
|
|
273
|
+
if not self.refresh_token:
|
|
274
|
+
raise ValueError("Refresh token required.")
|
|
275
|
+
token = self.oauth_client.refresh_access_token(self.refresh_token)
|
|
276
|
+
self.handle_access_token(token)
|
|
277
|
+
|
|
278
|
+
def get_access_token(self) -> AccessToken:
|
|
279
|
+
return {
|
|
280
|
+
"access_token": self.access_token,
|
|
281
|
+
"refresh_token": self.refresh_token,
|
|
282
|
+
"access_expires_at": self.access_expires_at,
|
|
283
|
+
}
|
|
@@ -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
|
|
|
@@ -12,6 +12,8 @@ from typing import Literal, Sequence
|
|
|
12
12
|
from .api import ApiClient
|
|
13
13
|
from .color_log import ColorHandler
|
|
14
14
|
from .utils import Config, get_config_path
|
|
15
|
+
from .telemetry_client import TelemetryClient
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
DEFAULT_CONFIG_PATH = (
|
|
17
19
|
get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
|
|
@@ -46,11 +48,12 @@ def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
|
|
49
|
-
def
|
|
51
|
+
def get_api_client(args: Namespace) -> ApiClient:
|
|
50
52
|
token = args.config.get("token", {})
|
|
51
53
|
api = ApiClient(
|
|
52
54
|
access_token=token.get("access_token"),
|
|
53
55
|
refresh_token=token.get("refresh_token"),
|
|
56
|
+
access_expires_at=token.get("access_expires_at"),
|
|
54
57
|
delay=args.delay,
|
|
55
58
|
user_agent=args.config["user_agent"],
|
|
56
59
|
proxies=get_proxies(args),
|
|
@@ -134,7 +137,20 @@ class HHApplicantTool:
|
|
|
134
137
|
logger.addHandler(handler)
|
|
135
138
|
if args.run:
|
|
136
139
|
try:
|
|
137
|
-
|
|
140
|
+
if not args.config["telemetry_client_id"]:
|
|
141
|
+
import uuid
|
|
142
|
+
|
|
143
|
+
args.config.save(telemetry_client_id=str(uuid.uuid4()))
|
|
144
|
+
api_client = get_api_client(args)
|
|
145
|
+
telemetry_client = TelemetryClient(
|
|
146
|
+
telemetry_client_id=args.config["telemetry_client_id"],
|
|
147
|
+
proxies=api_client.proxies.copy(),
|
|
148
|
+
)
|
|
149
|
+
# 0 or None = success
|
|
150
|
+
res = args.run(args, api_client, telemetry_client)
|
|
151
|
+
if (token := api_client.get_access_token()) != args.config["token"]:
|
|
152
|
+
args.config.save(token=token)
|
|
153
|
+
return res
|
|
138
154
|
except Exception as e:
|
|
139
155
|
logger.exception(e)
|
|
140
156
|
return 1
|
|
@@ -5,7 +5,7 @@ from .types import ApiListResponse
|
|
|
5
5
|
class GetResumeIdMixin:
|
|
6
6
|
def _get_resume_id(self) -> str:
|
|
7
7
|
try:
|
|
8
|
-
resumes: ApiListResponse = self.
|
|
8
|
+
resumes: ApiListResponse = self.api_client.get("/resumes/mine")
|
|
9
9
|
return resumes["items"][0]["id"]
|
|
10
10
|
except (ApiError, KeyError, IndexError) as ex:
|
|
11
11
|
raise Exception("Не могу получить идентификатор резюме") from ex
|