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.
@@ -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
@@ -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 partialmethod
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
- from functools import cached_property
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 = session = requests.session()
49
- session.headers.update(
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
- # if response.status_code not in [201, 204]:
113
- # raise
114
- rv = {}
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
- "%d %-6s %s",
118
- response.status_code,
108
+ "%s %s: %d",
119
109
  method,
120
- url + ("?" + urlencode(params) if not has_body and params else ""),
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 можно использовать только один раз и только по истечению срока действия access_token.
198
+ # refresh_token можно использовать только один раз и только по
199
+ # истечению срока действия access_token.
206
200
  return self.request_access_token(
207
- "/token", grant_type="refresh_token", refresh_token=refresh_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() > self.access_expires_at
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
- return (
238
- {"authorization": f"Bearer {self.access_token}"}
239
- if self.access_token
240
- else {}
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 ["access_token", "refresh_token", "access_expires_at"]:
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
 
@@ -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 ApiError(Exception):
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
@@ -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[Literal["http", "https"], str | None]:
44
- return {
45
- "http": args.config["proxy_url"] or getenv("HTTP_PROXY"),
46
- "https": args.config["proxy_url"] or getenv("HTTPS_PROXY"),
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
- token = args.config.get("token", {})
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=args.config["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/otzyvy_headhunter>
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
- module_name.replace("_", "-"),
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