hh-applicant-tool 0.7.10__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.
Files changed (75) hide show
  1. hh_applicant_tool/__init__.py +1 -0
  2. hh_applicant_tool/__main__.py +1 -1
  3. hh_applicant_tool/ai/base.py +2 -0
  4. hh_applicant_tool/ai/openai.py +23 -33
  5. hh_applicant_tool/api/client.py +50 -64
  6. hh_applicant_tool/api/errors.py +51 -7
  7. hh_applicant_tool/constants.py +0 -3
  8. hh_applicant_tool/datatypes.py +291 -0
  9. hh_applicant_tool/main.py +233 -111
  10. hh_applicant_tool/operations/apply_similar.py +266 -362
  11. hh_applicant_tool/operations/authorize.py +256 -120
  12. hh_applicant_tool/operations/call_api.py +18 -8
  13. hh_applicant_tool/operations/check_negotiations.py +102 -0
  14. hh_applicant_tool/operations/check_proxy.py +30 -0
  15. hh_applicant_tool/operations/config.py +119 -16
  16. hh_applicant_tool/operations/install.py +34 -0
  17. hh_applicant_tool/operations/list_resumes.py +24 -10
  18. hh_applicant_tool/operations/log.py +77 -0
  19. hh_applicant_tool/operations/migrate_db.py +65 -0
  20. hh_applicant_tool/operations/query.py +120 -0
  21. hh_applicant_tool/operations/refresh_token.py +14 -13
  22. hh_applicant_tool/operations/reply_employers.py +148 -167
  23. hh_applicant_tool/operations/settings.py +95 -0
  24. hh_applicant_tool/operations/uninstall.py +26 -0
  25. hh_applicant_tool/operations/update_resumes.py +21 -10
  26. hh_applicant_tool/operations/whoami.py +40 -7
  27. hh_applicant_tool/storage/__init__.py +4 -0
  28. hh_applicant_tool/storage/facade.py +24 -0
  29. hh_applicant_tool/storage/models/__init__.py +0 -0
  30. hh_applicant_tool/storage/models/base.py +169 -0
  31. hh_applicant_tool/storage/models/contact.py +16 -0
  32. hh_applicant_tool/storage/models/employer.py +12 -0
  33. hh_applicant_tool/storage/models/negotiation.py +16 -0
  34. hh_applicant_tool/storage/models/resume.py +19 -0
  35. hh_applicant_tool/storage/models/setting.py +6 -0
  36. hh_applicant_tool/storage/models/vacancy.py +36 -0
  37. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  38. hh_applicant_tool/storage/queries/schema.sql +119 -0
  39. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  40. hh_applicant_tool/storage/repositories/base.py +176 -0
  41. hh_applicant_tool/storage/repositories/contacts.py +19 -0
  42. hh_applicant_tool/storage/repositories/employers.py +13 -0
  43. hh_applicant_tool/storage/repositories/negotiations.py +12 -0
  44. hh_applicant_tool/storage/repositories/resumes.py +14 -0
  45. hh_applicant_tool/storage/repositories/settings.py +34 -0
  46. hh_applicant_tool/storage/repositories/vacancies.py +8 -0
  47. hh_applicant_tool/storage/utils.py +49 -0
  48. hh_applicant_tool/utils/__init__.py +31 -0
  49. hh_applicant_tool/utils/attrdict.py +6 -0
  50. hh_applicant_tool/utils/binpack.py +167 -0
  51. hh_applicant_tool/utils/config.py +55 -0
  52. hh_applicant_tool/utils/dateutil.py +19 -0
  53. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  54. hh_applicant_tool/utils/jsonutil.py +61 -0
  55. hh_applicant_tool/utils/log.py +144 -0
  56. hh_applicant_tool/utils/misc.py +12 -0
  57. hh_applicant_tool/utils/mixins.py +220 -0
  58. hh_applicant_tool/utils/string.py +27 -0
  59. hh_applicant_tool/utils/terminal.py +19 -0
  60. hh_applicant_tool/utils/user_agent.py +17 -0
  61. hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
  62. hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
  63. hh_applicant_tool/ai/blackbox.py +0 -55
  64. hh_applicant_tool/color_log.py +0 -47
  65. hh_applicant_tool/mixins.py +0 -13
  66. hh_applicant_tool/operations/clear_negotiations.py +0 -109
  67. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  68. hh_applicant_tool/operations/get_employer_contacts.py +0 -348
  69. hh_applicant_tool/telemetry_client.py +0 -106
  70. hh_applicant_tool/types.py +0 -45
  71. hh_applicant_tool/utils.py +0 -119
  72. hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
  73. hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
  74. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
  75. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1 @@
1
+ from .main import HHApplicantTool
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
 
3
- from .main import main
3
+ from .hh_applicant import main
4
4
 
5
5
  if __name__ == "__main__":
6
6
  sys.exit(main())
@@ -0,0 +1,2 @@
1
+ class AIError(Exception):
2
+ pass
@@ -1,30 +1,28 @@
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(Exception):
12
+ class OpenAIError(AIError):
9
13
  pass
10
14
 
11
15
 
12
- class OpenAIChat:
13
- chat_endpoint: str = "https://api.openai.com/v1/chat/completions"
16
+ @dataclass
17
+ class ChatOpenAI:
18
+ chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
14
19
 
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()
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)
28
26
 
29
27
  def default_headers(self) -> dict[str, str]:
30
28
  return {
@@ -32,21 +30,14 @@ class OpenAIChat:
32
30
  }
33
31
 
34
32
  def send_message(self, message: str) -> str:
35
-
36
33
  payload = {
37
34
  "model": self.model,
38
35
  "messages": [
39
- {
40
- "role": "system",
41
- "content": self.system_prompt
42
- },
43
- {
44
- "role": "user",
45
- "content": message
46
- }
36
+ {"role": "system", "content": self.system_prompt},
37
+ {"role": "user", "content": message},
47
38
  ],
48
- "temperature": 0.7,
49
- "max_completion_tokens": 1000
39
+ "temperature": self.temperature,
40
+ "max_completion_tokens": self.max_completion_tokens,
50
41
  }
51
42
 
52
43
  try:
@@ -54,18 +45,17 @@ class OpenAIChat:
54
45
  self.chat_endpoint,
55
46
  json=payload,
56
47
  headers=self.default_headers(),
57
- proxies=self.proxies,
58
- timeout=30
48
+ timeout=30,
59
49
  )
60
50
  response.raise_for_status()
61
51
 
62
52
  data = response.json()
63
- if 'error' in data:
64
- raise OpenAIError(data['error']['message'])
65
-
53
+ if "error" in data:
54
+ raise OpenAIError(data["error"]["message"])
55
+
66
56
  assistant_message = data["choices"][0]["message"]["content"]
67
57
 
68
58
  return assistant_message
69
59
 
70
60
  except requests.exceptions.RequestException as ex:
71
- raise OpenAIError(str(ex)) from ex
61
+ raise OpenAIError(f"Network error: {ex}") from ex
@@ -7,21 +7,24 @@ import time
7
7
  from dataclasses import dataclass
8
8
  from functools import cached_property
9
9
  from threading import Lock
10
- from typing import Any, Literal
11
- from urllib.parse import urlencode
10
+ from typing import Any, Literal, TypeVar
11
+ from urllib.parse import urlencode, urljoin
12
12
 
13
13
  import requests
14
- from requests import Response, Session
14
+ from requests import Session
15
15
 
16
- from ..types import AccessToken
16
+ from ..datatypes import AccessToken
17
17
  from . import errors
18
18
 
19
19
  __all__ = ("ApiClient", "OAuthClient")
20
20
 
21
- logger = logging.getLogger(__package__)
21
+ DEFAULT_DELAY = 0.334
22
+
23
+ AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
24
+ T = TypeVar("T")
22
25
 
23
26
 
24
- ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
27
+ logger = logging.getLogger(__package__)
25
28
 
26
29
 
27
30
  # Thread-safe
@@ -30,39 +33,41 @@ class BaseClient:
30
33
  base_url: str
31
34
  _: dataclasses.KW_ONLY
32
35
  user_agent: str | None = None
33
- proxies: dict | None = None
34
36
  session: Session | None = None
35
- previous_request_time: float = 0.0
36
- delay: float = 0.334
37
+ delay: float = DEFAULT_DELAY
38
+ _previous_request_time: float = 0.0
37
39
 
38
40
  def __post_init__(self) -> None:
41
+ assert self.base_url.endswith("/"), "base_url must end with /"
39
42
  self.lock = Lock()
43
+ # logger.debug(f"user agent: {self.user_agent}")
40
44
  if not self.session:
45
+ logger.debug("create new session")
41
46
  self.session = requests.session()
42
- if self.proxies:
43
- logger.debug(f"client proxies: {self.proxies}")
47
+ # if self.proxies:
48
+ # logger.debug(f"client proxies: {self.proxies}")
49
+
50
+ @property
51
+ def proxies(self):
52
+ return self.session.proxies
44
53
 
45
54
  def default_headers(self) -> dict[str, str]:
46
55
  return {
47
- "user-agent": self.user_agent or "Mozilla/5.0",
56
+ "user-agent": self.user_agent
57
+ or "Mozilla/5.0 (+https://github.com/s3rgeym/hh-applicant-tool)",
48
58
  "x-hh-app-active": "true",
49
59
  }
50
-
51
- def additional_headers(
52
- self,
53
- ) -> dict[str, str]:
54
- return {}
55
60
 
56
61
  def request(
57
62
  self,
58
- method: ALLOWED_METHODS,
63
+ method: AllowedMethods,
59
64
  endpoint: str,
60
65
  params: dict[str, Any] | None = None,
61
66
  delay: float | None = None,
62
67
  **kwargs: Any,
63
- ) -> dict:
68
+ ) -> T:
64
69
  # Не знаю насколько это "правильно"
65
- assert method in ALLOWED_METHODS.__args__
70
+ assert method in AllowedMethods.__args__
66
71
  params = dict(params or {})
67
72
  params.update(kwargs)
68
73
  url = self.resolve_url(endpoint)
@@ -71,20 +76,18 @@ class BaseClient:
71
76
  if (
72
77
  delay := (self.delay if delay is None else delay)
73
78
  - time.monotonic()
74
- + self.previous_request_time
79
+ + self._previous_request_time
75
80
  ) > 0:
76
81
  logger.debug("wait %fs before request", delay)
77
82
  time.sleep(delay)
78
83
  has_body = method in ["POST", "PUT"]
79
84
  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]}")
85
+ # logger.debug(f"request info: {method = }, {url = }, {headers = }, params = {repr(params)[:255]}")
82
86
  response = self.session.request(
83
87
  method,
84
88
  url,
85
89
  **payload,
86
- headers=headers,
87
- proxies=self.proxies,
90
+ headers=self.default_headers(),
88
91
  allow_redirects=False,
89
92
  )
90
93
  try:
@@ -102,55 +105,36 @@ class BaseClient:
102
105
  ) from ex
103
106
  finally:
104
107
  log_url = url
105
- if not has_body and params:
106
- log_url += "?" + urlencode(params)
108
+ if params and not has_body:
109
+ encoded_params = urlencode(params)
110
+ log_url += ("?", "&")["?" in url] + encoded_params
107
111
  logger.debug(
108
- "%s %s: %d",
112
+ "%d %s %.1000s",
113
+ response.status_code,
109
114
  method,
110
115
  log_url,
111
- response.status_code,
112
116
  )
113
- self.previous_request_time = time.monotonic()
114
- self.raise_for_status(response, rv)
117
+ self._previous_request_time = time.monotonic()
118
+ errors.ApiError.raise_for_status(response, rv)
115
119
  assert 300 > response.status_code >= 200, (
116
120
  f"Unexpected status code for {method} {url}: {response.status_code}"
117
121
  )
118
122
  return rv
119
123
 
120
- def get(self, *args, **kwargs):
124
+ def get(self, *args, **kwargs) -> T:
121
125
  return self.request("GET", *args, **kwargs)
122
126
 
123
- def post(self, *args, **kwargs):
127
+ def post(self, *args, **kwargs) -> T:
124
128
  return self.request("POST", *args, **kwargs)
125
129
 
126
- def put(self, *args, **kwargs):
130
+ def put(self, *args, **kwargs) -> T:
127
131
  return self.request("PUT", *args, **kwargs)
128
132
 
129
- def delete(self, *args, **kwargs):
133
+ def delete(self, *args, **kwargs) -> T:
130
134
  return self.request("DELETE", *args, **kwargs)
131
135
 
132
136
  def resolve_url(self, url: str) -> str:
133
- return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
134
-
135
- @staticmethod
136
- def raise_for_status(response: Response, data: dict) -> None:
137
- match response.status_code:
138
- case 301 | 302:
139
- raise errors.Redirect(response, data)
140
- case 400:
141
- if errors.ApiError.is_limit_exceeded(data):
142
- raise errors.LimitExceeded(response=response, data=data)
143
- raise errors.BadRequest(response, data)
144
- case 403:
145
- raise errors.Forbidden(response, data)
146
- case 404:
147
- raise errors.ResourceNotFound(response, data)
148
- case status if 500 > status >= 400:
149
- raise errors.ClientError(response, data)
150
- case 502:
151
- raise errors.BadGateway(response, data)
152
- case status if status >= 500:
153
- raise errors.InternalServerError(response, data)
137
+ return urljoin(self.base_url, url.lstrip("/"))
154
138
 
155
139
 
156
140
  @dataclass
@@ -158,7 +142,7 @@ class OAuthClient(BaseClient):
158
142
  client_id: str
159
143
  client_secret: str
160
144
  _: dataclasses.KW_ONLY
161
- base_url: str = "https://hh.ru/oauth"
145
+ base_url: str = "https://hh.ru/oauth/"
162
146
  state: str = ""
163
147
  scope: str = ""
164
148
  redirect_uri: str = ""
@@ -225,30 +209,32 @@ class ApiClient(BaseClient):
225
209
  client_id=self.client_id,
226
210
  client_secret=self.client_secret,
227
211
  user_agent=self.user_agent,
228
- proxies=dict(self.proxies or {}),
229
212
  session=self.session,
230
213
  )
231
214
 
232
- def additional_headers(
215
+ def default_headers(
233
216
  self,
234
217
  ) -> dict[str, str]:
218
+ headers = super().default_headers()
235
219
  if not self.access_token:
236
- return {}
220
+ return headers
237
221
  # Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
238
222
  assert self.access_token.startswith("USER")
239
- return {"authorization": f"Bearer {self.access_token}"}
223
+ return headers | {"authorization": f"Bearer {self.access_token}"}
240
224
 
241
225
  # Реализовано автоматическое обновление токена
242
226
  def request(
243
227
  self,
244
- method: ALLOWED_METHODS,
228
+ method: AllowedMethods,
245
229
  endpoint: str,
246
230
  params: dict[str, Any] | None = None,
247
231
  delay: float | None = None,
248
232
  **kwargs: Any,
249
- ) -> dict:
233
+ ) -> T:
250
234
  def do_request():
251
- return BaseClient.request(self, method, endpoint, params, delay, **kwargs)
235
+ return BaseClient.request(
236
+ self, method, endpoint, params, delay, **kwargs
237
+ )
252
238
 
253
239
  try:
254
240
  return do_request()
@@ -1,5 +1,7 @@
1
- # from copy import deepcopy
2
- from typing import Any
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
@@ -24,11 +26,11 @@ class BadResponse(Exception):
24
26
  class ApiError(BadResponse):
25
27
  def __init__(self, response: Response, data: dict[str, Any]) -> None:
26
28
  self._response = response
27
- self._raw = data
29
+ self._data = data
28
30
 
29
31
  @property
30
32
  def data(self) -> dict:
31
- return self._raw
33
+ return self._data
32
34
 
33
35
  @property
34
36
  def request(self) -> Request:
@@ -42,6 +44,10 @@ class ApiError(BadResponse):
42
44
  def response_headers(self) -> CaseInsensitiveDict:
43
45
  return self._response.headers
44
46
 
47
+ @property
48
+ def message(self) -> str:
49
+ return self._data.get("description") or str(self._data)
50
+
45
51
  # def __getattr__(self, name: str) -> Any:
46
52
  # try:
47
53
  # return self._raw[name]
@@ -49,11 +55,33 @@ class ApiError(BadResponse):
49
55
  # raise AttributeError(name) from ex
50
56
 
51
57
  def __str__(self) -> str:
52
- return str(self._raw)
58
+ return self.message
53
59
 
54
60
  @staticmethod
55
- def is_limit_exceeded(data) -> bool:
56
- return any(x["value"] == "limit_exceeded" for x in data.get("errors", []))
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)
57
85
 
58
86
 
59
87
  class Redirect(ApiError):
@@ -76,6 +104,22 @@ class Forbidden(ClientError):
76
104
  pass
77
105
 
78
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
+
79
123
  class ResourceNotFound(ClientError):
80
124
  pass
81
125
 
@@ -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"