hh-applicant-tool 0.5.7__py3-none-any.whl → 0.5.9__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.
Potentially problematic release.
This version of hh-applicant-tool might be problematic. Click here for more details.
- hh_applicant_tool/api/client.py +101 -42
- hh_applicant_tool/api/errors.py +14 -8
- hh_applicant_tool/constants.py +6 -4
- hh_applicant_tool/jsonc.py +10 -5
- hh_applicant_tool/main.py +18 -2
- hh_applicant_tool/mixins.py +1 -1
- hh_applicant_tool/operations/apply_similar.py +37 -44
- hh_applicant_tool/operations/authorize.py +9 -19
- hh_applicant_tool/operations/call_api.py +8 -9
- hh_applicant_tool/operations/clear_negotiations.py +10 -14
- hh_applicant_tool/operations/config.py +21 -6
- hh_applicant_tool/operations/delete_telemetry.py +30 -0
- hh_applicant_tool/operations/get_employer_contacts.py +15 -31
- hh_applicant_tool/operations/list_resumes.py +4 -7
- hh_applicant_tool/operations/refresh_token.py +4 -17
- hh_applicant_tool/operations/reply_employers.py +12 -11
- hh_applicant_tool/operations/update_resumes.py +4 -5
- hh_applicant_tool/operations/whoami.py +4 -5
- hh_applicant_tool/telemetry_client.py +15 -2
- {hh_applicant_tool-0.5.7.dist-info → hh_applicant_tool-0.5.9.dist-info}/METADATA +13 -22
- hh_applicant_tool-0.5.9.dist-info/RECORD +32 -0
- hh_applicant_tool-0.5.7.dist-info/RECORD +0 -31
- {hh_applicant_tool-0.5.7.dist-info → hh_applicant_tool-0.5.9.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.5.7.dist-info → hh_applicant_tool-0.5.9.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/api/client.py
CHANGED
|
@@ -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
|
+
}
|
hh_applicant_tool/api/errors.py
CHANGED
|
@@ -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):
|
hh_applicant_tool/constants.py
CHANGED
|
@@ -1,12 +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"
|
|
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
2
|
|
|
3
|
-
ANDROID_CLIENT_ID =
|
|
4
|
-
"HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
|
|
5
|
-
)
|
|
3
|
+
ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
|
|
6
4
|
|
|
7
5
|
ANDROID_CLIENT_SECRET = (
|
|
8
6
|
"V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
|
|
9
7
|
)
|
|
10
8
|
|
|
9
|
+
# Используется для прямой авторизации. Этот способ мной не используется, так как
|
|
10
|
+
# для отображения капчи все равно нужен webview.
|
|
11
|
+
# K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
|
|
12
|
+
|
|
11
13
|
# Кривой формат, который используют эти долбоебы
|
|
12
14
|
INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
hh_applicant_tool/jsonc.py
CHANGED
|
@@ -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
|
|
hh_applicant_tool/main.py
CHANGED
|
@@ -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
|
hh_applicant_tool/mixins.py
CHANGED
|
@@ -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
|
|
@@ -4,19 +4,23 @@ 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
8
|
|
|
9
|
+
from ..api.errors import LimitExceeded
|
|
9
10
|
from ..ai.blackbox import BlackboxChat, BlackboxError
|
|
10
|
-
from ..api import ApiError,
|
|
11
|
+
from ..api import ApiError, ApiClient
|
|
11
12
|
from ..main import BaseOperation
|
|
12
13
|
from ..main import Namespace as BaseNamespace
|
|
13
|
-
from ..main import get_api
|
|
14
14
|
from ..mixins import GetResumeIdMixin
|
|
15
15
|
from ..telemetry_client import TelemetryClient, TelemetryError
|
|
16
16
|
from ..types import ApiListResponse, VacancyItem
|
|
17
|
-
from ..utils import (
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
from ..utils import (
|
|
18
|
+
fix_datetime,
|
|
19
|
+
parse_interval,
|
|
20
|
+
parse_invalid_datetime,
|
|
21
|
+
random_text,
|
|
22
|
+
truncate_string,
|
|
23
|
+
)
|
|
20
24
|
|
|
21
25
|
logger = logging.getLogger(__package__)
|
|
22
26
|
|
|
@@ -43,7 +47,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
43
47
|
"-L",
|
|
44
48
|
"--message-list",
|
|
45
49
|
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
|
|
46
|
-
type=argparse.FileType(
|
|
50
|
+
type=argparse.FileType("r", encoding="utf-8", errors="replace"),
|
|
47
51
|
)
|
|
48
52
|
parser.add_argument(
|
|
49
53
|
"-f",
|
|
@@ -103,7 +107,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
103
107
|
action=argparse.BooleanOptionalAction,
|
|
104
108
|
)
|
|
105
109
|
|
|
106
|
-
def run(
|
|
110
|
+
def run(
|
|
111
|
+
self, args: Namespace, api_client: ApiClient, telemetry_client: TelemetryClient
|
|
112
|
+
) -> None:
|
|
107
113
|
self.enable_telemetry = True
|
|
108
114
|
if args.disable_telemetry:
|
|
109
115
|
# print(
|
|
@@ -120,18 +126,17 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
120
126
|
# logger.info("Спасибо за то что оставили телеметрию включенной!")
|
|
121
127
|
self.enable_telemetry = False
|
|
122
128
|
|
|
123
|
-
self.
|
|
129
|
+
self.api_client = api_client
|
|
130
|
+
self.telemetry_client = telemetry_client
|
|
124
131
|
self.resume_id = args.resume_id or self._get_resume_id()
|
|
125
|
-
self.application_messages = self._get_application_messages(
|
|
126
|
-
args.message_list
|
|
127
|
-
)
|
|
132
|
+
self.application_messages = self._get_application_messages(args.message_list)
|
|
128
133
|
self.chat = None
|
|
129
134
|
|
|
130
135
|
if config := args.config.get("blackbox"):
|
|
131
136
|
self.chat = BlackboxChat(
|
|
132
137
|
session_id=config["session_id"],
|
|
133
138
|
chat_payload=config["chat_payload"],
|
|
134
|
-
proxies=self.
|
|
139
|
+
proxies=self.api_client.proxies or {},
|
|
135
140
|
)
|
|
136
141
|
|
|
137
142
|
self.pre_prompt = args.pre_prompt
|
|
@@ -145,13 +150,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
145
150
|
self.dry_run = args.dry_run
|
|
146
151
|
self._apply_similar()
|
|
147
152
|
|
|
148
|
-
def _get_application_messages(
|
|
149
|
-
self, message_list: TextIO | None
|
|
150
|
-
) -> list[str]:
|
|
153
|
+
def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
|
|
151
154
|
if message_list:
|
|
152
|
-
application_messages = list(
|
|
153
|
-
filter(None, map(str.strip, message_list))
|
|
154
|
-
)
|
|
155
|
+
application_messages = list(filter(None, map(str.strip, message_list)))
|
|
155
156
|
else:
|
|
156
157
|
application_messages = [
|
|
157
158
|
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
|
|
@@ -160,7 +161,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
160
161
|
return application_messages
|
|
161
162
|
|
|
162
163
|
def _apply_similar(self) -> None:
|
|
163
|
-
telemetry_client =
|
|
164
|
+
telemetry_client = self.telemetry_client
|
|
164
165
|
telemetry_data = defaultdict(dict)
|
|
165
166
|
|
|
166
167
|
vacancies = self._get_vacancies()
|
|
@@ -172,12 +173,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
172
173
|
"name": vacancy.get("name"),
|
|
173
174
|
"type": vacancy.get("type", {}).get("id"), # open/closed
|
|
174
175
|
"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
|
-
), # ссылка на вакансию
|
|
176
|
+
"salary": vacancy.get("salary"), # from, to, currency, gross
|
|
177
|
+
"direct_url": vacancy.get("alternate_url"), # ссылка на вакансию
|
|
181
178
|
"created_at": fix_datetime(
|
|
182
179
|
vacancy.get("created_at")
|
|
183
180
|
), # будем вычислять говно-вакансии, которые по полгода висят
|
|
@@ -194,7 +191,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
194
191
|
# Остальное неинтересно
|
|
195
192
|
}
|
|
196
193
|
|
|
197
|
-
me = self.
|
|
194
|
+
me = self.api_client.get("/me")
|
|
198
195
|
|
|
199
196
|
basic_message_placeholders = {
|
|
200
197
|
"first_name": me.get("first_name", ""),
|
|
@@ -210,9 +207,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
210
207
|
try:
|
|
211
208
|
message_placeholders = {
|
|
212
209
|
"vacancy_name": vacancy.get("name", ""),
|
|
213
|
-
"employer_name": vacancy.get("employer", {}).get(
|
|
214
|
-
"name", ""
|
|
215
|
-
),
|
|
210
|
+
"employer_name": vacancy.get("employer", {}).get("name", ""),
|
|
216
211
|
**basic_message_placeholders,
|
|
217
212
|
}
|
|
218
213
|
|
|
@@ -250,7 +245,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
250
245
|
> datetime.now(tz=timezone.utc)
|
|
251
246
|
)
|
|
252
247
|
):
|
|
253
|
-
employer = self.
|
|
248
|
+
employer = self.api_client.get(f"/employers/{employer_id}")
|
|
254
249
|
|
|
255
250
|
employer_data = {
|
|
256
251
|
"name": employer.get("name"),
|
|
@@ -275,9 +270,10 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
275
270
|
response["topic_url"],
|
|
276
271
|
)
|
|
277
272
|
else:
|
|
278
|
-
print(
|
|
279
|
-
|
|
280
|
-
)
|
|
273
|
+
# print(
|
|
274
|
+
# "Создание темы для обсуждения работодателя добавлено в очередь..."
|
|
275
|
+
# )
|
|
276
|
+
...
|
|
281
277
|
complained_employers.add(employer_id)
|
|
282
278
|
except TelemetryError as ex:
|
|
283
279
|
logger.error(ex)
|
|
@@ -304,9 +300,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
304
300
|
"message": "",
|
|
305
301
|
}
|
|
306
302
|
|
|
307
|
-
if self.force_message or vacancy.get(
|
|
308
|
-
"response_letter_required"
|
|
309
|
-
):
|
|
303
|
+
if self.force_message or vacancy.get("response_letter_required"):
|
|
310
304
|
if self.chat:
|
|
311
305
|
try:
|
|
312
306
|
msg = self.pre_prompt + "\n\n"
|
|
@@ -318,9 +312,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
318
312
|
continue
|
|
319
313
|
else:
|
|
320
314
|
msg = (
|
|
321
|
-
random_text(
|
|
322
|
-
random.choice(self.application_messages)
|
|
323
|
-
)
|
|
315
|
+
random_text(random.choice(self.application_messages))
|
|
324
316
|
% message_placeholders
|
|
325
317
|
)
|
|
326
318
|
|
|
@@ -341,7 +333,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
341
333
|
)
|
|
342
334
|
time.sleep(interval)
|
|
343
335
|
|
|
344
|
-
res = self.
|
|
336
|
+
res = self.api_client.post("/negotiations", params)
|
|
345
337
|
assert res == {}
|
|
346
338
|
print(
|
|
347
339
|
"📨 Отправили отклик",
|
|
@@ -350,10 +342,11 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
350
342
|
truncate_string(vacancy["name"]),
|
|
351
343
|
")",
|
|
352
344
|
)
|
|
345
|
+
except LimitExceeded:
|
|
346
|
+
print("⚠️ Достигли лимита рассылки")
|
|
347
|
+
do_apply = False
|
|
353
348
|
except ApiError as ex:
|
|
354
349
|
logger.error(ex)
|
|
355
|
-
if isinstance(ex, BadRequest) and ex.limit_exceeded:
|
|
356
|
-
do_apply = False
|
|
357
350
|
|
|
358
351
|
print("📝 Отклики на вакансии разосланы!")
|
|
359
352
|
|
|
@@ -384,7 +377,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
384
377
|
}
|
|
385
378
|
if self.search:
|
|
386
379
|
params["text"] = self.search
|
|
387
|
-
res: ApiListResponse = self.
|
|
380
|
+
res: ApiListResponse = self.api_client.get(
|
|
388
381
|
f"/resumes/{self.resume_id}/similar_vacancies", params
|
|
389
382
|
)
|
|
390
383
|
rv.extend(res["items"])
|