hh-applicant-tool 0.6.3__py3-none-any.whl → 0.7.10__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/ai/openai.py +71 -0
- hh_applicant_tool/api/client.py +48 -50
- hh_applicant_tool/api/errors.py +6 -1
- hh_applicant_tool/color_log.py +12 -0
- hh_applicant_tool/main.py +44 -12
- hh_applicant_tool/operations/apply_similar.py +242 -20
- hh_applicant_tool/operations/authorize.py +52 -13
- hh_applicant_tool/operations/clear_negotiations.py +5 -5
- hh_applicant_tool/operations/config.py +0 -2
- hh_applicant_tool/operations/get_employer_contacts.py +62 -7
- hh_applicant_tool/operations/reply_employers.py +3 -2
- hh_applicant_tool/telemetry_client.py +1 -1
- hh_applicant_tool/types.py +1 -0
- hh_applicant_tool/utils.py +17 -2
- hh_applicant_tool-0.7.10.dist-info/METADATA +452 -0
- hh_applicant_tool-0.7.10.dist-info/RECORD +33 -0
- {hh_applicant_tool-0.6.3.dist-info → hh_applicant_tool-0.7.10.dist-info}/WHEEL +1 -1
- hh_applicant_tool-0.6.3.dist-info/METADATA +0 -333
- hh_applicant_tool-0.6.3.dist-info/RECORD +0 -32
- {hh_applicant_tool-0.6.3.dist-info → hh_applicant_tool-0.7.10.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__package__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAIError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OpenAIChat:
|
|
13
|
+
chat_endpoint: str = "https://api.openai.com/v1/chat/completions"
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
token: str,
|
|
18
|
+
model: str,
|
|
19
|
+
system_prompt: str,
|
|
20
|
+
proxies: dict[str, str] | None = None,
|
|
21
|
+
session: requests.Session | None = None
|
|
22
|
+
):
|
|
23
|
+
self.token = token
|
|
24
|
+
self.model = model
|
|
25
|
+
self.system_prompt = system_prompt
|
|
26
|
+
self.proxies = proxies
|
|
27
|
+
self.session = session or requests.session()
|
|
28
|
+
|
|
29
|
+
def default_headers(self) -> dict[str, str]:
|
|
30
|
+
return {
|
|
31
|
+
"Authorization": f"Bearer {self.token}",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def send_message(self, message: str) -> str:
|
|
35
|
+
|
|
36
|
+
payload = {
|
|
37
|
+
"model": self.model,
|
|
38
|
+
"messages": [
|
|
39
|
+
{
|
|
40
|
+
"role": "system",
|
|
41
|
+
"content": self.system_prompt
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"role": "user",
|
|
45
|
+
"content": message
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"temperature": 0.7,
|
|
49
|
+
"max_completion_tokens": 1000
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = self.session.post(
|
|
54
|
+
self.chat_endpoint,
|
|
55
|
+
json=payload,
|
|
56
|
+
headers=self.default_headers(),
|
|
57
|
+
proxies=self.proxies,
|
|
58
|
+
timeout=30
|
|
59
|
+
)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
|
|
62
|
+
data = response.json()
|
|
63
|
+
if 'error' in data:
|
|
64
|
+
raise OpenAIError(data['error']['message'])
|
|
65
|
+
|
|
66
|
+
assistant_message = data["choices"][0]["message"]["content"]
|
|
67
|
+
|
|
68
|
+
return assistant_message
|
|
69
|
+
|
|
70
|
+
except requests.exceptions.RequestException as ex:
|
|
71
|
+
raise OpenAIError(str(ex)) from ex
|
hh_applicant_tool/api/client.py
CHANGED
|
@@ -3,22 +3,16 @@ 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
|
|
8
|
+
from functools import cached_property
|
|
10
9
|
from threading import Lock
|
|
11
10
|
from typing import Any, Literal
|
|
12
11
|
from urllib.parse import urlencode
|
|
13
|
-
|
|
14
|
-
import random
|
|
12
|
+
|
|
15
13
|
import requests
|
|
16
14
|
from requests import Response, Session
|
|
17
15
|
|
|
18
|
-
from ..constants import (
|
|
19
|
-
ANDROID_CLIENT_ID,
|
|
20
|
-
ANDROID_CLIENT_SECRET,
|
|
21
|
-
)
|
|
22
16
|
from ..types import AccessToken
|
|
23
17
|
from . import errors
|
|
24
18
|
|
|
@@ -35,7 +29,6 @@ ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
|
|
|
35
29
|
class BaseClient:
|
|
36
30
|
base_url: str
|
|
37
31
|
_: dataclasses.KW_ONLY
|
|
38
|
-
# TODO: сделать генерацию User-Agent'а как в приложении
|
|
39
32
|
user_agent: str | None = None
|
|
40
33
|
proxies: dict | None = None
|
|
41
34
|
session: Session | None = None
|
|
@@ -45,26 +38,16 @@ class BaseClient:
|
|
|
45
38
|
def __post_init__(self) -> None:
|
|
46
39
|
self.lock = Lock()
|
|
47
40
|
if not self.session:
|
|
48
|
-
self.session =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"user-agent": self.user_agent or self.default_user_agent(),
|
|
52
|
-
"x-hh-app-active": "true",
|
|
53
|
-
**self.additional_headers(),
|
|
54
|
-
}
|
|
55
|
-
)
|
|
56
|
-
logger.debug("Default Headers: %r", session.headers)
|
|
57
|
-
|
|
58
|
-
def default_user_agent(self) -> str:
|
|
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()})"
|
|
41
|
+
self.session = requests.session()
|
|
42
|
+
if self.proxies:
|
|
43
|
+
logger.debug(f"client proxies: {self.proxies}")
|
|
67
44
|
|
|
45
|
+
def default_headers(self) -> dict[str, str]:
|
|
46
|
+
return {
|
|
47
|
+
"user-agent": self.user_agent or "Mozilla/5.0",
|
|
48
|
+
"x-hh-app-active": "true",
|
|
49
|
+
}
|
|
50
|
+
|
|
68
51
|
def additional_headers(
|
|
69
52
|
self,
|
|
70
53
|
) -> dict[str, str]:
|
|
@@ -94,34 +77,44 @@ class BaseClient:
|
|
|
94
77
|
time.sleep(delay)
|
|
95
78
|
has_body = method in ["POST", "PUT"]
|
|
96
79
|
payload = {"data" if has_body else "params": params}
|
|
80
|
+
headers = self.default_headers() | self.additional_headers()
|
|
81
|
+
logger.debug(f"request info: {method = }, {url = }, {headers = }, params = {repr(params)[:255]}")
|
|
97
82
|
response = self.session.request(
|
|
98
83
|
method,
|
|
99
84
|
url,
|
|
100
85
|
**payload,
|
|
86
|
+
headers=headers,
|
|
101
87
|
proxies=self.proxies,
|
|
102
88
|
allow_redirects=False,
|
|
103
89
|
)
|
|
104
90
|
try:
|
|
105
|
-
# У этих лошков сервер не отдает Content-Length, а кривое API
|
|
91
|
+
# У этих лошков сервер не отдает Content-Length, а кривое API
|
|
92
|
+
# отдает пустые ответы, например, при отклике на вакансии,
|
|
93
|
+
# и мы не можем узнать содержит ли ответ тело
|
|
106
94
|
# 'Server': 'ddos-guard'
|
|
107
95
|
# ...
|
|
108
96
|
# 'Transfer-Encoding': 'chunked'
|
|
109
97
|
try:
|
|
110
|
-
rv = response.json()
|
|
111
|
-
except json.decoder.JSONDecodeError:
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
98
|
+
rv = response.json() if response.text else {}
|
|
99
|
+
except json.decoder.JSONDecodeError as ex:
|
|
100
|
+
raise errors.BadResponse(
|
|
101
|
+
f"Can't decode JSON: {method} {url} ({response.status_code})"
|
|
102
|
+
) from ex
|
|
115
103
|
finally:
|
|
104
|
+
log_url = url
|
|
105
|
+
if not has_body and params:
|
|
106
|
+
log_url += "?" + urlencode(params)
|
|
116
107
|
logger.debug(
|
|
117
|
-
"%
|
|
118
|
-
response.status_code,
|
|
108
|
+
"%s %s: %d",
|
|
119
109
|
method,
|
|
120
|
-
|
|
110
|
+
log_url,
|
|
111
|
+
response.status_code,
|
|
121
112
|
)
|
|
122
113
|
self.previous_request_time = time.monotonic()
|
|
123
114
|
self.raise_for_status(response, rv)
|
|
124
|
-
assert 300 > response.status_code >= 200
|
|
115
|
+
assert 300 > response.status_code >= 200, (
|
|
116
|
+
f"Unexpected status code for {method} {url}: {response.status_code}"
|
|
117
|
+
)
|
|
125
118
|
return rv
|
|
126
119
|
|
|
127
120
|
def get(self, *args, **kwargs):
|
|
@@ -202,9 +195,12 @@ class OAuthClient(BaseClient):
|
|
|
202
195
|
return self.request_access_token("/token", params)
|
|
203
196
|
|
|
204
197
|
def refresh_access_token(self, refresh_token: str) -> AccessToken:
|
|
205
|
-
# refresh_token можно использовать только один раз и только по
|
|
198
|
+
# refresh_token можно использовать только один раз и только по
|
|
199
|
+
# истечению срока действия access_token.
|
|
206
200
|
return self.request_access_token(
|
|
207
|
-
"/token",
|
|
201
|
+
"/token",
|
|
202
|
+
grant_type="refresh_token",
|
|
203
|
+
refresh_token=refresh_token,
|
|
208
204
|
)
|
|
209
205
|
|
|
210
206
|
|
|
@@ -214,31 +210,33 @@ class ApiClient(BaseClient):
|
|
|
214
210
|
access_token: str | None = None
|
|
215
211
|
refresh_token: str | None = None
|
|
216
212
|
access_expires_at: int = 0
|
|
217
|
-
client_id: str = ANDROID_CLIENT_ID
|
|
218
|
-
client_secret: str = ANDROID_CLIENT_SECRET
|
|
219
213
|
_: dataclasses.KW_ONLY
|
|
214
|
+
client_id: str | None = None
|
|
215
|
+
client_secret: str | None = None
|
|
220
216
|
base_url: str = "https://api.hh.ru/"
|
|
221
217
|
|
|
222
218
|
@property
|
|
223
219
|
def is_access_expired(self) -> bool:
|
|
224
|
-
return time.time()
|
|
220
|
+
return time.time() >= (self.access_expires_at or 0)
|
|
225
221
|
|
|
226
222
|
@cached_property
|
|
227
223
|
def oauth_client(self) -> OAuthClient:
|
|
228
224
|
return OAuthClient(
|
|
229
225
|
client_id=self.client_id,
|
|
230
226
|
client_secret=self.client_secret,
|
|
227
|
+
user_agent=self.user_agent,
|
|
228
|
+
proxies=dict(self.proxies or {}),
|
|
231
229
|
session=self.session,
|
|
232
230
|
)
|
|
233
231
|
|
|
234
232
|
def additional_headers(
|
|
235
233
|
self,
|
|
236
234
|
) -> dict[str, str]:
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
if not self.access_token:
|
|
236
|
+
return {}
|
|
237
|
+
# Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
|
|
238
|
+
assert self.access_token.startswith("USER")
|
|
239
|
+
return {"authorization": f"Bearer {self.access_token}"}
|
|
242
240
|
|
|
243
241
|
# Реализовано автоматическое обновление токена
|
|
244
242
|
def request(
|
|
@@ -258,14 +256,14 @@ class ApiClient(BaseClient):
|
|
|
258
256
|
except errors.Forbidden as ex:
|
|
259
257
|
if not self.is_access_expired or not self.refresh_token:
|
|
260
258
|
raise ex
|
|
261
|
-
logger.info("try refresh access_token")
|
|
259
|
+
logger.info("try to refresh access_token")
|
|
262
260
|
# Пробуем обновить токен
|
|
263
261
|
self.refresh_access_token()
|
|
264
262
|
# И повторно отправляем запрос
|
|
265
263
|
return do_request()
|
|
266
264
|
|
|
267
265
|
def handle_access_token(self, token: AccessToken) -> None:
|
|
268
|
-
for field in
|
|
266
|
+
for field in ("access_token", "refresh_token", "access_expires_at"):
|
|
269
267
|
if field in token and hasattr(self, field):
|
|
270
268
|
setattr(self, field, token[field])
|
|
271
269
|
|
hh_applicant_tool/api/errors.py
CHANGED
|
@@ -5,6 +5,7 @@ from requests import Request, Response
|
|
|
5
5
|
from requests.adapters import CaseInsensitiveDict
|
|
6
6
|
|
|
7
7
|
__all__ = (
|
|
8
|
+
"BadResponse",
|
|
8
9
|
"ApiError",
|
|
9
10
|
"BadGateway",
|
|
10
11
|
"BadRequest",
|
|
@@ -16,7 +17,11 @@ __all__ = (
|
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
class
|
|
20
|
+
class BadResponse(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(BadResponse):
|
|
20
25
|
def __init__(self, response: Response, data: dict[str, Any]) -> None:
|
|
21
26
|
self._response = response
|
|
22
27
|
self._raw = data
|
hh_applicant_tool/color_log.py
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import enum
|
|
2
2
|
import logging
|
|
3
3
|
from enum import auto
|
|
4
|
+
import os, sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
if sys.platform == "win32":
|
|
8
|
+
import ctypes
|
|
9
|
+
kernel32 = ctypes.windll.kernel32
|
|
10
|
+
# 0x0004 = ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
|
11
|
+
# Берем дескриптор стандартного вывода (stdout)
|
|
12
|
+
handle = kernel32.GetStdHandle(-11)
|
|
13
|
+
mode = ctypes.c_uint()
|
|
14
|
+
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
15
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
4
16
|
|
|
5
17
|
|
|
6
18
|
class Color(enum.Enum):
|
hh_applicant_tool/main.py
CHANGED
|
@@ -11,8 +11,9 @@ from typing import Literal, Sequence
|
|
|
11
11
|
|
|
12
12
|
from .api import ApiClient
|
|
13
13
|
from .color_log import ColorHandler
|
|
14
|
+
from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
|
|
14
15
|
from .telemetry_client import TelemetryClient
|
|
15
|
-
from .utils import Config, get_config_path
|
|
16
|
+
from .utils import Config, android_user_agent, get_config_path
|
|
16
17
|
|
|
17
18
|
DEFAULT_CONFIG_PATH = (
|
|
18
19
|
get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
|
|
@@ -24,7 +25,7 @@ logger = logging.getLogger(__package__)
|
|
|
24
25
|
class BaseOperation:
|
|
25
26
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
|
|
26
27
|
|
|
27
|
-
def run(self, args: argparse.Namespace) -> None | int:
|
|
28
|
+
def run(self, args: argparse.Namespace, api_client: ApiClient, telemetry_client: TelemetryClient) -> None | int:
|
|
28
29
|
raise NotImplementedError()
|
|
29
30
|
|
|
30
31
|
|
|
@@ -40,21 +41,38 @@ class Namespace(argparse.Namespace):
|
|
|
40
41
|
disable_telemetry: bool
|
|
41
42
|
|
|
42
43
|
|
|
43
|
-
def get_proxies(args: Namespace) -> dict[
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
def get_proxies(args: Namespace) -> dict[str, str]:
|
|
45
|
+
proxy_url = args.proxy_url or args.config.get("proxy_url")
|
|
46
|
+
|
|
47
|
+
if proxy_url:
|
|
48
|
+
return {
|
|
49
|
+
"http": proxy_url,
|
|
50
|
+
"https": proxy_url,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
proxies = {}
|
|
54
|
+
http_env = getenv("HTTP_PROXY") or getenv("http_proxy")
|
|
55
|
+
https_env = getenv("HTTPS_PROXY") or getenv("https_proxy") or http_env
|
|
56
|
+
|
|
57
|
+
if http_env:
|
|
58
|
+
proxies["http"] = http_env
|
|
59
|
+
if https_env:
|
|
60
|
+
proxies["https"] = https_env
|
|
61
|
+
|
|
62
|
+
return proxies
|
|
48
63
|
|
|
49
64
|
|
|
50
65
|
def get_api_client(args: Namespace) -> ApiClient:
|
|
51
|
-
|
|
66
|
+
config = args.config
|
|
67
|
+
token = config.get("token", {})
|
|
52
68
|
api = ApiClient(
|
|
69
|
+
client_id=config.get("client_id", ANDROID_CLIENT_ID),
|
|
70
|
+
client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
|
|
53
71
|
access_token=token.get("access_token"),
|
|
54
72
|
refresh_token=token.get("refresh_token"),
|
|
55
73
|
access_expires_at=token.get("access_expires_at"),
|
|
56
74
|
delay=args.delay,
|
|
57
|
-
user_agent=
|
|
75
|
+
user_agent=config["user_agent"] or android_user_agent(),
|
|
58
76
|
proxies=get_proxies(args),
|
|
59
77
|
)
|
|
60
78
|
return api
|
|
@@ -65,7 +83,7 @@ class HHApplicantTool:
|
|
|
65
83
|
|
|
66
84
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
67
85
|
|
|
68
|
-
Группа поддержки: <https://t.me/
|
|
86
|
+
Группа поддержки: <https://t.me/hh_applicant_tool>
|
|
69
87
|
"""
|
|
70
88
|
|
|
71
89
|
class ArgumentFormatter(
|
|
@@ -115,8 +133,22 @@ class HHApplicantTool:
|
|
|
115
133
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
116
134
|
mod = import_module(f"{__package__}.{OPERATIONS}.{module_name}")
|
|
117
135
|
op: BaseOperation = mod.Operation()
|
|
136
|
+
# 1. Разбиваем имя модуля на части
|
|
137
|
+
words = module_name.split("_")
|
|
138
|
+
|
|
139
|
+
# 2. Формируем варианты имен
|
|
140
|
+
kebab_name = "-".join(words) # call-api
|
|
141
|
+
|
|
142
|
+
# camelCase: первое слово маленькими, остальные с большой
|
|
143
|
+
camel_case_name = words[0] + "".join(word.title() for word in words[1:])
|
|
144
|
+
|
|
145
|
+
# flatcase: всё слитно и в нижнем регистре
|
|
146
|
+
flat_name = "".join(words) # callapi
|
|
147
|
+
|
|
118
148
|
op_parser = subparsers.add_parser(
|
|
119
|
-
|
|
149
|
+
kebab_name,
|
|
150
|
+
# Добавляем остальные варианты в псевдонимы
|
|
151
|
+
aliases=[camel_case_name, flat_name],
|
|
120
152
|
description=op.__doc__,
|
|
121
153
|
formatter_class=self.ArgumentFormatter,
|
|
122
154
|
)
|
|
@@ -154,7 +186,7 @@ class HHApplicantTool:
|
|
|
154
186
|
logger.warning("Interrupted by user")
|
|
155
187
|
return 1
|
|
156
188
|
except Exception as e:
|
|
157
|
-
logger.exception(e)
|
|
189
|
+
logger.exception(e, exc_info=log_level <= logging.DEBUG)
|
|
158
190
|
return 1
|
|
159
191
|
parser.print_help(file=sys.stderr)
|
|
160
192
|
return 2
|