hh-applicant-tool 0.6.12__py3-none-any.whl → 1.4.7__py3-none-any.whl
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.
- hh_applicant_tool/__init__.py +1 -0
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/base.py +2 -0
- hh_applicant_tool/ai/openai.py +24 -30
- hh_applicant_tool/api/client.py +82 -98
- hh_applicant_tool/api/errors.py +57 -8
- hh_applicant_tool/constants.py +0 -3
- hh_applicant_tool/datatypes.py +291 -0
- hh_applicant_tool/main.py +236 -82
- hh_applicant_tool/operations/apply_similar.py +268 -348
- hh_applicant_tool/operations/authorize.py +245 -70
- hh_applicant_tool/operations/call_api.py +18 -8
- hh_applicant_tool/operations/check_negotiations.py +102 -0
- hh_applicant_tool/operations/check_proxy.py +30 -0
- hh_applicant_tool/operations/config.py +119 -18
- hh_applicant_tool/operations/install.py +34 -0
- hh_applicant_tool/operations/list_resumes.py +24 -10
- hh_applicant_tool/operations/log.py +77 -0
- hh_applicant_tool/operations/migrate_db.py +65 -0
- hh_applicant_tool/operations/query.py +120 -0
- hh_applicant_tool/operations/refresh_token.py +14 -13
- hh_applicant_tool/operations/reply_employers.py +148 -167
- hh_applicant_tool/operations/settings.py +95 -0
- hh_applicant_tool/operations/uninstall.py +26 -0
- hh_applicant_tool/operations/update_resumes.py +21 -10
- hh_applicant_tool/operations/whoami.py +40 -7
- hh_applicant_tool/storage/__init__.py +4 -0
- hh_applicant_tool/storage/facade.py +24 -0
- hh_applicant_tool/storage/models/__init__.py +0 -0
- hh_applicant_tool/storage/models/base.py +169 -0
- hh_applicant_tool/storage/models/contact.py +16 -0
- hh_applicant_tool/storage/models/employer.py +12 -0
- hh_applicant_tool/storage/models/negotiation.py +16 -0
- hh_applicant_tool/storage/models/resume.py +19 -0
- hh_applicant_tool/storage/models/setting.py +6 -0
- hh_applicant_tool/storage/models/vacancy.py +36 -0
- hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
- hh_applicant_tool/storage/queries/schema.sql +119 -0
- hh_applicant_tool/storage/repositories/__init__.py +0 -0
- hh_applicant_tool/storage/repositories/base.py +176 -0
- hh_applicant_tool/storage/repositories/contacts.py +19 -0
- hh_applicant_tool/storage/repositories/employers.py +13 -0
- hh_applicant_tool/storage/repositories/negotiations.py +12 -0
- hh_applicant_tool/storage/repositories/resumes.py +14 -0
- hh_applicant_tool/storage/repositories/settings.py +34 -0
- hh_applicant_tool/storage/repositories/vacancies.py +8 -0
- hh_applicant_tool/storage/utils.py +49 -0
- hh_applicant_tool/utils/__init__.py +31 -0
- hh_applicant_tool/utils/attrdict.py +6 -0
- hh_applicant_tool/utils/binpack.py +167 -0
- hh_applicant_tool/utils/config.py +55 -0
- hh_applicant_tool/utils/dateutil.py +19 -0
- hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
- hh_applicant_tool/utils/jsonutil.py +61 -0
- hh_applicant_tool/utils/log.py +144 -0
- hh_applicant_tool/utils/misc.py +12 -0
- hh_applicant_tool/utils/mixins.py +220 -0
- hh_applicant_tool/utils/string.py +27 -0
- hh_applicant_tool/utils/terminal.py +19 -0
- hh_applicant_tool/utils/user_agent.py +17 -0
- hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
- hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
- hh_applicant_tool/ai/blackbox.py +0 -55
- hh_applicant_tool/color_log.py +0 -35
- hh_applicant_tool/mixins.py +0 -13
- hh_applicant_tool/operations/clear_negotiations.py +0 -113
- hh_applicant_tool/operations/delete_telemetry.py +0 -30
- hh_applicant_tool/operations/get_employer_contacts.py +0 -293
- hh_applicant_tool/telemetry_client.py +0 -106
- hh_applicant_tool/types.py +0 -45
- hh_applicant_tool/utils.py +0 -104
- hh_applicant_tool-0.6.12.dist-info/METADATA +0 -349
- hh_applicant_tool-0.6.12.dist-info/RECORD +0 -33
- {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .main import HHApplicantTool
|
hh_applicant_tool/__main__.py
CHANGED
hh_applicant_tool/ai/openai.py
CHANGED
|
@@ -1,67 +1,61 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import ClassVar
|
|
2
4
|
|
|
3
5
|
import requests
|
|
4
6
|
|
|
7
|
+
from .base import AIError
|
|
8
|
+
|
|
5
9
|
logger = logging.getLogger(__package__)
|
|
6
10
|
|
|
7
11
|
|
|
8
|
-
class OpenAIError(
|
|
12
|
+
class OpenAIError(AIError):
|
|
9
13
|
pass
|
|
10
14
|
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
@dataclass
|
|
17
|
+
class ChatOpenAI:
|
|
18
|
+
chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
):
|
|
22
|
-
self.token = token
|
|
23
|
-
self.model = model
|
|
24
|
-
self.system_prompt = system_prompt
|
|
25
|
-
self.proxies = proxies
|
|
20
|
+
token: str
|
|
21
|
+
model: str
|
|
22
|
+
system_prompt: str | None = None
|
|
23
|
+
temperature: float = 0.7
|
|
24
|
+
max_completion_tokens: int = 1000
|
|
25
|
+
session: requests.Session = field(default_factory=requests.Session)
|
|
26
26
|
|
|
27
27
|
def default_headers(self) -> dict[str, str]:
|
|
28
28
|
return {
|
|
29
29
|
"Authorization": f"Bearer {self.token}",
|
|
30
|
-
"Content-Type": "application/json",
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
def send_message(self, message: str) -> str:
|
|
34
|
-
|
|
35
33
|
payload = {
|
|
36
34
|
"model": self.model,
|
|
37
35
|
"messages": [
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
"content": self.system_prompt
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"role": "user",
|
|
44
|
-
"content": message
|
|
45
|
-
}
|
|
36
|
+
{"role": "system", "content": self.system_prompt},
|
|
37
|
+
{"role": "user", "content": message},
|
|
46
38
|
],
|
|
47
|
-
"temperature":
|
|
48
|
-
"max_completion_tokens":
|
|
39
|
+
"temperature": self.temperature,
|
|
40
|
+
"max_completion_tokens": self.max_completion_tokens,
|
|
49
41
|
}
|
|
50
42
|
|
|
51
43
|
try:
|
|
52
|
-
response =
|
|
44
|
+
response = self.session.post(
|
|
53
45
|
self.chat_endpoint,
|
|
54
46
|
json=payload,
|
|
55
47
|
headers=self.default_headers(),
|
|
56
|
-
|
|
57
|
-
timeout=30
|
|
48
|
+
timeout=30,
|
|
58
49
|
)
|
|
59
50
|
response.raise_for_status()
|
|
60
51
|
|
|
61
52
|
data = response.json()
|
|
53
|
+
if "error" in data:
|
|
54
|
+
raise OpenAIError(data["error"]["message"])
|
|
55
|
+
|
|
62
56
|
assistant_message = data["choices"][0]["message"]["content"]
|
|
63
57
|
|
|
64
58
|
return assistant_message
|
|
65
59
|
|
|
66
60
|
except requests.exceptions.RequestException as ex:
|
|
67
|
-
raise OpenAIError(f"
|
|
61
|
+
raise OpenAIError(f"Network error: {ex}") from ex
|
hh_applicant_tool/api/client.py
CHANGED
|
@@ -3,31 +3,28 @@ from __future__ import annotations
|
|
|
3
3
|
import dataclasses
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
|
-
import uuid
|
|
7
6
|
import time
|
|
8
7
|
from dataclasses import dataclass
|
|
9
|
-
from functools import partialmethod
|
|
10
|
-
from threading import Lock
|
|
11
|
-
from typing import Any, Literal
|
|
12
|
-
from urllib.parse import urlencode
|
|
13
8
|
from functools import cached_property
|
|
14
|
-
import
|
|
9
|
+
from threading import Lock
|
|
10
|
+
from typing import Any, Literal, TypeVar
|
|
11
|
+
from urllib.parse import urlencode, urljoin
|
|
12
|
+
|
|
15
13
|
import requests
|
|
16
|
-
from requests import
|
|
14
|
+
from requests import Session
|
|
17
15
|
|
|
18
|
-
from ..
|
|
19
|
-
ANDROID_CLIENT_ID,
|
|
20
|
-
ANDROID_CLIENT_SECRET,
|
|
21
|
-
)
|
|
22
|
-
from ..types import AccessToken
|
|
16
|
+
from ..datatypes import AccessToken
|
|
23
17
|
from . import errors
|
|
24
18
|
|
|
25
19
|
__all__ = ("ApiClient", "OAuthClient")
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
DEFAULT_DELAY = 0.334
|
|
28
22
|
|
|
23
|
+
AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
|
|
24
|
+
T = TypeVar("T")
|
|
29
25
|
|
|
30
|
-
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__package__)
|
|
31
28
|
|
|
32
29
|
|
|
33
30
|
# Thread-safe
|
|
@@ -35,51 +32,42 @@ ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
|
|
|
35
32
|
class BaseClient:
|
|
36
33
|
base_url: str
|
|
37
34
|
_: dataclasses.KW_ONLY
|
|
38
|
-
# TODO: сделать генерацию User-Agent'а как в приложении
|
|
39
35
|
user_agent: str | None = None
|
|
40
|
-
proxies: dict | None = None
|
|
41
36
|
session: Session | None = None
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
delay: float = DEFAULT_DELAY
|
|
38
|
+
_previous_request_time: float = 0.0
|
|
44
39
|
|
|
45
40
|
def __post_init__(self) -> None:
|
|
41
|
+
assert self.base_url.endswith("/"), "base_url must end with /"
|
|
46
42
|
self.lock = Lock()
|
|
43
|
+
# logger.debug(f"user agent: {self.user_agent}")
|
|
47
44
|
if not self.session:
|
|
48
|
-
|
|
49
|
-
session.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"x-hh-app-active": "true",
|
|
53
|
-
**self.additional_headers(),
|
|
54
|
-
}
|
|
55
|
-
)
|
|
56
|
-
logger.debug("Default Headers: %r", session.headers)
|
|
45
|
+
logger.debug("create new session")
|
|
46
|
+
self.session = requests.session()
|
|
47
|
+
# if self.proxies:
|
|
48
|
+
# logger.debug(f"client proxies: {self.proxies}")
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
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()})"
|
|
50
|
+
@property
|
|
51
|
+
def proxies(self):
|
|
52
|
+
return self.session.proxies
|
|
67
53
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
def default_headers(self) -> dict[str, str]:
|
|
55
|
+
return {
|
|
56
|
+
"user-agent": self.user_agent
|
|
57
|
+
or "Mozilla/5.0 (+https://github.com/s3rgeym/hh-applicant-tool)",
|
|
58
|
+
"x-hh-app-active": "true",
|
|
59
|
+
}
|
|
72
60
|
|
|
73
61
|
def request(
|
|
74
62
|
self,
|
|
75
|
-
method:
|
|
63
|
+
method: AllowedMethods,
|
|
76
64
|
endpoint: str,
|
|
77
65
|
params: dict[str, Any] | None = None,
|
|
78
66
|
delay: float | None = None,
|
|
79
67
|
**kwargs: Any,
|
|
80
|
-
) ->
|
|
68
|
+
) -> T:
|
|
81
69
|
# Не знаю насколько это "правильно"
|
|
82
|
-
assert method in
|
|
70
|
+
assert method in AllowedMethods.__args__
|
|
83
71
|
params = dict(params or {})
|
|
84
72
|
params.update(kwargs)
|
|
85
73
|
url = self.resolve_url(endpoint)
|
|
@@ -88,76 +76,65 @@ class BaseClient:
|
|
|
88
76
|
if (
|
|
89
77
|
delay := (self.delay if delay is None else delay)
|
|
90
78
|
- time.monotonic()
|
|
91
|
-
+ self.
|
|
79
|
+
+ self._previous_request_time
|
|
92
80
|
) > 0:
|
|
93
81
|
logger.debug("wait %fs before request", delay)
|
|
94
82
|
time.sleep(delay)
|
|
95
83
|
has_body = method in ["POST", "PUT"]
|
|
96
84
|
payload = {"data" if has_body else "params": params}
|
|
85
|
+
# logger.debug(f"request info: {method = }, {url = }, {headers = }, params = {repr(params)[:255]}")
|
|
97
86
|
response = self.session.request(
|
|
98
87
|
method,
|
|
99
88
|
url,
|
|
100
89
|
**payload,
|
|
101
|
-
|
|
90
|
+
headers=self.default_headers(),
|
|
102
91
|
allow_redirects=False,
|
|
103
92
|
)
|
|
104
93
|
try:
|
|
105
|
-
# У этих лошков сервер не отдает Content-Length, а кривое API
|
|
94
|
+
# У этих лошков сервер не отдает Content-Length, а кривое API
|
|
95
|
+
# отдает пустые ответы, например, при отклике на вакансии,
|
|
96
|
+
# и мы не можем узнать содержит ли ответ тело
|
|
106
97
|
# 'Server': 'ddos-guard'
|
|
107
98
|
# ...
|
|
108
99
|
# 'Transfer-Encoding': 'chunked'
|
|
109
100
|
try:
|
|
110
|
-
rv = response.json()
|
|
111
|
-
except json.decoder.JSONDecodeError:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
rv = response.json() if response.text else {}
|
|
102
|
+
except json.decoder.JSONDecodeError as ex:
|
|
103
|
+
raise errors.BadResponse(
|
|
104
|
+
f"Can't decode JSON: {method} {url} ({response.status_code})"
|
|
105
|
+
) from ex
|
|
115
106
|
finally:
|
|
107
|
+
log_url = url
|
|
108
|
+
if params and not has_body:
|
|
109
|
+
encoded_params = urlencode(params)
|
|
110
|
+
log_url += ("?", "&")["?" in url] + encoded_params
|
|
116
111
|
logger.debug(
|
|
117
|
-
"%d
|
|
112
|
+
"%d %s %.1000s",
|
|
118
113
|
response.status_code,
|
|
119
114
|
method,
|
|
120
|
-
|
|
115
|
+
log_url,
|
|
121
116
|
)
|
|
122
|
-
self.
|
|
123
|
-
|
|
124
|
-
assert 300 > response.status_code >= 200
|
|
117
|
+
self._previous_request_time = time.monotonic()
|
|
118
|
+
errors.ApiError.raise_for_status(response, rv)
|
|
119
|
+
assert 300 > response.status_code >= 200, (
|
|
120
|
+
f"Unexpected status code for {method} {url}: {response.status_code}"
|
|
121
|
+
)
|
|
125
122
|
return rv
|
|
126
123
|
|
|
127
|
-
def get(self, *args, **kwargs):
|
|
124
|
+
def get(self, *args, **kwargs) -> T:
|
|
128
125
|
return self.request("GET", *args, **kwargs)
|
|
129
126
|
|
|
130
|
-
def post(self, *args, **kwargs):
|
|
127
|
+
def post(self, *args, **kwargs) -> T:
|
|
131
128
|
return self.request("POST", *args, **kwargs)
|
|
132
129
|
|
|
133
|
-
def put(self, *args, **kwargs):
|
|
130
|
+
def put(self, *args, **kwargs) -> T:
|
|
134
131
|
return self.request("PUT", *args, **kwargs)
|
|
135
132
|
|
|
136
|
-
def delete(self, *args, **kwargs):
|
|
133
|
+
def delete(self, *args, **kwargs) -> T:
|
|
137
134
|
return self.request("DELETE", *args, **kwargs)
|
|
138
135
|
|
|
139
136
|
def resolve_url(self, url: str) -> str:
|
|
140
|
-
return
|
|
141
|
-
|
|
142
|
-
@staticmethod
|
|
143
|
-
def raise_for_status(response: Response, data: dict) -> None:
|
|
144
|
-
match response.status_code:
|
|
145
|
-
case 301 | 302:
|
|
146
|
-
raise errors.Redirect(response, data)
|
|
147
|
-
case 400:
|
|
148
|
-
if errors.ApiError.is_limit_exceeded(data):
|
|
149
|
-
raise errors.LimitExceeded(response=response, data=data)
|
|
150
|
-
raise errors.BadRequest(response, data)
|
|
151
|
-
case 403:
|
|
152
|
-
raise errors.Forbidden(response, data)
|
|
153
|
-
case 404:
|
|
154
|
-
raise errors.ResourceNotFound(response, data)
|
|
155
|
-
case status if 500 > status >= 400:
|
|
156
|
-
raise errors.ClientError(response, data)
|
|
157
|
-
case 502:
|
|
158
|
-
raise errors.BadGateway(response, data)
|
|
159
|
-
case status if status >= 500:
|
|
160
|
-
raise errors.InternalServerError(response, data)
|
|
137
|
+
return urljoin(self.base_url, url.lstrip("/"))
|
|
161
138
|
|
|
162
139
|
|
|
163
140
|
@dataclass
|
|
@@ -165,7 +142,7 @@ class OAuthClient(BaseClient):
|
|
|
165
142
|
client_id: str
|
|
166
143
|
client_secret: str
|
|
167
144
|
_: dataclasses.KW_ONLY
|
|
168
|
-
base_url: str = "https://hh.ru/oauth"
|
|
145
|
+
base_url: str = "https://hh.ru/oauth/"
|
|
169
146
|
state: str = ""
|
|
170
147
|
scope: str = ""
|
|
171
148
|
redirect_uri: str = ""
|
|
@@ -202,9 +179,12 @@ class OAuthClient(BaseClient):
|
|
|
202
179
|
return self.request_access_token("/token", params)
|
|
203
180
|
|
|
204
181
|
def refresh_access_token(self, refresh_token: str) -> AccessToken:
|
|
205
|
-
# refresh_token можно использовать только один раз и только по
|
|
182
|
+
# refresh_token можно использовать только один раз и только по
|
|
183
|
+
# истечению срока действия access_token.
|
|
206
184
|
return self.request_access_token(
|
|
207
|
-
"/token",
|
|
185
|
+
"/token",
|
|
186
|
+
grant_type="refresh_token",
|
|
187
|
+
refresh_token=refresh_token,
|
|
208
188
|
)
|
|
209
189
|
|
|
210
190
|
|
|
@@ -214,43 +194,47 @@ class ApiClient(BaseClient):
|
|
|
214
194
|
access_token: str | None = None
|
|
215
195
|
refresh_token: str | None = None
|
|
216
196
|
access_expires_at: int = 0
|
|
217
|
-
client_id: str = ANDROID_CLIENT_ID
|
|
218
|
-
client_secret: str = ANDROID_CLIENT_SECRET
|
|
219
197
|
_: dataclasses.KW_ONLY
|
|
198
|
+
client_id: str | None = None
|
|
199
|
+
client_secret: str | None = None
|
|
220
200
|
base_url: str = "https://api.hh.ru/"
|
|
221
201
|
|
|
222
202
|
@property
|
|
223
203
|
def is_access_expired(self) -> bool:
|
|
224
|
-
return time.time()
|
|
204
|
+
return time.time() >= (self.access_expires_at or 0)
|
|
225
205
|
|
|
226
206
|
@cached_property
|
|
227
207
|
def oauth_client(self) -> OAuthClient:
|
|
228
208
|
return OAuthClient(
|
|
229
209
|
client_id=self.client_id,
|
|
230
210
|
client_secret=self.client_secret,
|
|
211
|
+
user_agent=self.user_agent,
|
|
231
212
|
session=self.session,
|
|
232
213
|
)
|
|
233
214
|
|
|
234
|
-
def
|
|
215
|
+
def default_headers(
|
|
235
216
|
self,
|
|
236
217
|
) -> dict[str, str]:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
218
|
+
headers = super().default_headers()
|
|
219
|
+
if not self.access_token:
|
|
220
|
+
return headers
|
|
221
|
+
# Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
|
|
222
|
+
assert self.access_token.startswith("USER")
|
|
223
|
+
return headers | {"authorization": f"Bearer {self.access_token}"}
|
|
242
224
|
|
|
243
225
|
# Реализовано автоматическое обновление токена
|
|
244
226
|
def request(
|
|
245
227
|
self,
|
|
246
|
-
method:
|
|
228
|
+
method: AllowedMethods,
|
|
247
229
|
endpoint: str,
|
|
248
230
|
params: dict[str, Any] | None = None,
|
|
249
231
|
delay: float | None = None,
|
|
250
232
|
**kwargs: Any,
|
|
251
|
-
) ->
|
|
233
|
+
) -> T:
|
|
252
234
|
def do_request():
|
|
253
|
-
return BaseClient.request(
|
|
235
|
+
return BaseClient.request(
|
|
236
|
+
self, method, endpoint, params, delay, **kwargs
|
|
237
|
+
)
|
|
254
238
|
|
|
255
239
|
try:
|
|
256
240
|
return do_request()
|
|
@@ -258,14 +242,14 @@ class ApiClient(BaseClient):
|
|
|
258
242
|
except errors.Forbidden as ex:
|
|
259
243
|
if not self.is_access_expired or not self.refresh_token:
|
|
260
244
|
raise ex
|
|
261
|
-
logger.info("try refresh access_token")
|
|
245
|
+
logger.info("try to refresh access_token")
|
|
262
246
|
# Пробуем обновить токен
|
|
263
247
|
self.refresh_access_token()
|
|
264
248
|
# И повторно отправляем запрос
|
|
265
249
|
return do_request()
|
|
266
250
|
|
|
267
251
|
def handle_access_token(self, token: AccessToken) -> None:
|
|
268
|
-
for field in
|
|
252
|
+
for field in ("access_token", "refresh_token", "access_expires_at"):
|
|
269
253
|
if field in token and hasattr(self, field):
|
|
270
254
|
setattr(self, field, token[field])
|
|
271
255
|
|
hh_applicant_tool/api/errors.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import Any, Type
|
|
3
5
|
|
|
4
6
|
from requests import Request, Response
|
|
5
7
|
from requests.adapters import CaseInsensitiveDict
|
|
6
8
|
|
|
7
9
|
__all__ = (
|
|
10
|
+
"BadResponse",
|
|
8
11
|
"ApiError",
|
|
9
12
|
"BadGateway",
|
|
10
13
|
"BadRequest",
|
|
@@ -16,14 +19,18 @@ __all__ = (
|
|
|
16
19
|
)
|
|
17
20
|
|
|
18
21
|
|
|
19
|
-
class
|
|
22
|
+
class BadResponse(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ApiError(BadResponse):
|
|
20
27
|
def __init__(self, response: Response, data: dict[str, Any]) -> None:
|
|
21
28
|
self._response = response
|
|
22
|
-
self.
|
|
29
|
+
self._data = data
|
|
23
30
|
|
|
24
31
|
@property
|
|
25
32
|
def data(self) -> dict:
|
|
26
|
-
return self.
|
|
33
|
+
return self._data
|
|
27
34
|
|
|
28
35
|
@property
|
|
29
36
|
def request(self) -> Request:
|
|
@@ -37,6 +44,10 @@ class ApiError(Exception):
|
|
|
37
44
|
def response_headers(self) -> CaseInsensitiveDict:
|
|
38
45
|
return self._response.headers
|
|
39
46
|
|
|
47
|
+
@property
|
|
48
|
+
def message(self) -> str:
|
|
49
|
+
return self._data.get("description") or str(self._data)
|
|
50
|
+
|
|
40
51
|
# def __getattr__(self, name: str) -> Any:
|
|
41
52
|
# try:
|
|
42
53
|
# return self._raw[name]
|
|
@@ -44,11 +55,33 @@ class ApiError(Exception):
|
|
|
44
55
|
# raise AttributeError(name) from ex
|
|
45
56
|
|
|
46
57
|
def __str__(self) -> str:
|
|
47
|
-
return
|
|
58
|
+
return self.message
|
|
48
59
|
|
|
49
60
|
@staticmethod
|
|
50
|
-
def
|
|
51
|
-
return any(
|
|
61
|
+
def has_error_value(value: str, data: dict) -> bool:
|
|
62
|
+
return any(v.get("value") == value for v in data.get("errors", []))
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def raise_for_status(cls: Type[ApiError], response: Response, data: dict) -> None:
|
|
66
|
+
match response.status_code:
|
|
67
|
+
case status if 300 <= status <= 308:
|
|
68
|
+
raise Redirect(response, data)
|
|
69
|
+
case 400:
|
|
70
|
+
if cls.has_error_value("limit_exceeded", data):
|
|
71
|
+
raise LimitExceeded(response, data)
|
|
72
|
+
raise BadRequest(response, data)
|
|
73
|
+
case 403:
|
|
74
|
+
if cls.has_error_value("captcha_required", data):
|
|
75
|
+
raise CaptchaRequired(response, data)
|
|
76
|
+
raise Forbidden(response, data)
|
|
77
|
+
case 404:
|
|
78
|
+
raise ResourceNotFound(response, data)
|
|
79
|
+
case status if 500 > status >= 400:
|
|
80
|
+
raise ClientError(response, data)
|
|
81
|
+
case 502:
|
|
82
|
+
raise BadGateway(response, data)
|
|
83
|
+
case status if status >= 500:
|
|
84
|
+
raise InternalServerError(response, data)
|
|
52
85
|
|
|
53
86
|
|
|
54
87
|
class Redirect(ApiError):
|
|
@@ -71,6 +104,22 @@ class Forbidden(ClientError):
|
|
|
71
104
|
pass
|
|
72
105
|
|
|
73
106
|
|
|
107
|
+
class CaptchaRequired(ClientError):
|
|
108
|
+
@cached_property
|
|
109
|
+
def captcha_url(self) -> str:
|
|
110
|
+
return next(
|
|
111
|
+
filter(
|
|
112
|
+
lambda v: v["value"] == "captcha_required",
|
|
113
|
+
self._data["errors"],
|
|
114
|
+
),
|
|
115
|
+
{},
|
|
116
|
+
).get("captcha_url")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def message(self) -> str:
|
|
120
|
+
return f"Captcha required: {self.captcha_url}"
|
|
121
|
+
|
|
122
|
+
|
|
74
123
|
class ResourceNotFound(ClientError):
|
|
75
124
|
pass
|
|
76
125
|
|
hh_applicant_tool/constants.py
CHANGED
|
@@ -9,6 +9,3 @@ ANDROID_CLIENT_SECRET = (
|
|
|
9
9
|
# Используется для прямой авторизации. Этот способ мной не используется, так как
|
|
10
10
|
# для отображения капчи все равно нужен webview.
|
|
11
11
|
# K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
|
|
12
|
-
|
|
13
|
-
# Кривой формат, который используют эти долбоебы
|
|
14
|
-
INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|