hh-applicant-tool 1.4.7__py3-none-any.whl → 1.5.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 (49) hide show
  1. hh_applicant_tool/__main__.py +1 -1
  2. hh_applicant_tool/ai/__init__.py +1 -0
  3. hh_applicant_tool/ai/openai.py +30 -14
  4. hh_applicant_tool/api/__init__.py +4 -2
  5. hh_applicant_tool/api/client.py +32 -17
  6. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -3
  7. hh_applicant_tool/{datatypes.py → api/datatypes.py} +2 -0
  8. hh_applicant_tool/api/errors.py +8 -2
  9. hh_applicant_tool/{utils → api}/user_agent.py +1 -1
  10. hh_applicant_tool/main.py +63 -38
  11. hh_applicant_tool/operations/apply_similar.py +136 -52
  12. hh_applicant_tool/operations/authorize.py +97 -28
  13. hh_applicant_tool/operations/call_api.py +3 -3
  14. hh_applicant_tool/operations/{check_negotiations.py → clear_negotiations.py} +40 -26
  15. hh_applicant_tool/operations/list_resumes.py +5 -7
  16. hh_applicant_tool/operations/query.py +5 -3
  17. hh_applicant_tool/operations/refresh_token.py +9 -2
  18. hh_applicant_tool/operations/reply_employers.py +80 -40
  19. hh_applicant_tool/operations/settings.py +2 -2
  20. hh_applicant_tool/operations/update_resumes.py +5 -4
  21. hh_applicant_tool/operations/whoami.py +3 -3
  22. hh_applicant_tool/storage/__init__.py +5 -1
  23. hh_applicant_tool/storage/facade.py +2 -2
  24. hh_applicant_tool/storage/models/base.py +9 -4
  25. hh_applicant_tool/storage/models/contacts.py +42 -0
  26. hh_applicant_tool/storage/queries/schema.sql +23 -10
  27. hh_applicant_tool/storage/repositories/base.py +69 -15
  28. hh_applicant_tool/storage/repositories/contacts.py +5 -10
  29. hh_applicant_tool/storage/repositories/employers.py +1 -0
  30. hh_applicant_tool/storage/repositories/errors.py +19 -0
  31. hh_applicant_tool/storage/repositories/negotiations.py +1 -0
  32. hh_applicant_tool/storage/repositories/resumes.py +2 -7
  33. hh_applicant_tool/storage/repositories/settings.py +1 -0
  34. hh_applicant_tool/storage/repositories/vacancies.py +1 -0
  35. hh_applicant_tool/storage/utils.py +12 -15
  36. hh_applicant_tool/utils/__init__.py +3 -3
  37. hh_applicant_tool/utils/config.py +1 -1
  38. hh_applicant_tool/utils/log.py +6 -3
  39. hh_applicant_tool/utils/mixins.py +28 -46
  40. hh_applicant_tool/utils/string.py +15 -0
  41. hh_applicant_tool/utils/terminal.py +115 -0
  42. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/METADATA +384 -162
  43. hh_applicant_tool-1.5.7.dist-info/RECORD +68 -0
  44. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/WHEEL +1 -1
  45. hh_applicant_tool/storage/models/contact.py +0 -16
  46. hh_applicant_tool-1.4.7.dist-info/RECORD +0 -67
  47. /hh_applicant_tool/utils/{dateutil.py → date.py} +0 -0
  48. /hh_applicant_tool/utils/{jsonutil.py → json.py} +0 -0
  49. {hh_applicant_tool-1.4.7.dist-info → hh_applicant_tool-1.5.7.dist-info}/entry_points.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import sys
2
2
 
3
- from .hh_applicant import main
3
+ from .main import main
4
4
 
5
5
  if __name__ == "__main__":
6
6
  sys.exit(main())
@@ -0,0 +1 @@
1
+ from .openai import ChatOpenAI, OpenAIError
@@ -1,6 +1,5 @@
1
1
  import logging
2
- from dataclasses import dataclass, field
3
- from typing import ClassVar
2
+ from dataclasses import KW_ONLY, dataclass, field
4
3
 
5
4
  import requests
6
5
 
@@ -9,43 +8,60 @@ from .base import AIError
9
8
  logger = logging.getLogger(__package__)
10
9
 
11
10
 
11
+ DEFAULT_COMPLETION_ENDPOINT = "https://api.openai.com/v1/chat/completions"
12
+
13
+
12
14
  class OpenAIError(AIError):
13
15
  pass
14
16
 
15
17
 
16
18
  @dataclass
17
19
  class ChatOpenAI:
18
- chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
19
-
20
20
  token: str
21
- model: str
21
+ _: KW_ONLY
22
22
  system_prompt: str | None = None
23
+ timeout: float = 15.0
23
24
  temperature: float = 0.7
24
25
  max_completion_tokens: int = 1000
26
+ model: str | None = None
27
+ completion_endpoint: str = None
25
28
  session: requests.Session = field(default_factory=requests.Session)
26
29
 
27
- def default_headers(self) -> dict[str, str]:
30
+ def __post_init__(self) -> None:
31
+ self.completion_endpoint = (
32
+ self.completion_endpoint or DEFAULT_COMPLETION_ENDPOINT
33
+ )
34
+
35
+ def _default_headers(self) -> dict[str, str]:
28
36
  return {
29
37
  "Authorization": f"Bearer {self.token}",
30
38
  }
31
39
 
32
40
  def send_message(self, message: str) -> str:
41
+ messages = []
42
+
43
+ # Добавляем системный промпт только если он не пустой и не None
44
+ if self.system_prompt:
45
+ messages.append({"role": "system", "content": self.system_prompt})
46
+
47
+ # Пользовательское сообщение всегда обязательно
48
+ messages.append({"role": "user", "content": message})
49
+
33
50
  payload = {
34
- "model": self.model,
35
- "messages": [
36
- {"role": "system", "content": self.system_prompt},
37
- {"role": "user", "content": message},
38
- ],
51
+ "messages": messages,
39
52
  "temperature": self.temperature,
40
53
  "max_completion_tokens": self.max_completion_tokens,
41
54
  }
42
55
 
56
+ if self.model:
57
+ payload["model"] = self.model
58
+
43
59
  try:
44
60
  response = self.session.post(
45
- self.chat_endpoint,
61
+ self.completion_endpoint,
46
62
  json=payload,
47
- headers=self.default_headers(),
48
- timeout=30,
63
+ headers=self._default_headers(),
64
+ timeout=self.timeout,
49
65
  )
50
66
  response.raise_for_status()
51
67
 
@@ -1,3 +1,5 @@
1
1
  """See <https://github.com/hhru/api>"""
2
- from .client import *
3
- from .errors import *
2
+
3
+ from .client import * # noqa: F403
4
+ from .datatypes import * # noqa: F403
5
+ from .errors import * # noqa: F403
@@ -13,12 +13,20 @@ from urllib.parse import urlencode, urljoin
13
13
  import requests
14
14
  from requests import Session
15
15
 
16
- from ..datatypes import AccessToken
16
+ from hh_applicant_tool.api.user_agent import generate_android_useragent
17
+
17
18
  from . import errors
19
+ from .client_keys import (
20
+ ANDROID_CLIENT_ID,
21
+ ANDROID_CLIENT_SECRET,
22
+ )
23
+ from .datatypes import AccessToken
18
24
 
19
25
  __all__ = ("ApiClient", "OAuthClient")
20
26
 
21
- DEFAULT_DELAY = 0.334
27
+ HH_API_URL = "https://api.hh.ru/"
28
+ HH_OAUTH_URL = "https://hh.ru/oauth/"
29
+ DEFAULT_DELAY = 0.345
22
30
 
23
31
  AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
24
32
  T = TypeVar("T")
@@ -34,27 +42,29 @@ class BaseClient:
34
42
  _: dataclasses.KW_ONLY
35
43
  user_agent: str | None = None
36
44
  session: Session | None = None
37
- delay: float = DEFAULT_DELAY
45
+ delay: float | None = None
38
46
  _previous_request_time: float = 0.0
39
47
 
40
48
  def __post_init__(self) -> None:
41
- assert self.base_url.endswith("/"), "base_url must end with /"
42
- self.lock = Lock()
49
+ assert self.base_url.endswith("/"), "base_url must ends with /"
50
+ self.delay = self.delay or DEFAULT_DELAY
51
+ self.user_agent = self.user_agent or generate_android_useragent()
52
+
43
53
  # logger.debug(f"user agent: {self.user_agent}")
54
+
44
55
  if not self.session:
45
56
  logger.debug("create new session")
46
57
  self.session = requests.session()
47
- # if self.proxies:
48
- # logger.debug(f"client proxies: {self.proxies}")
58
+
59
+ self.lock = Lock()
49
60
 
50
61
  @property
51
62
  def proxies(self):
52
63
  return self.session.proxies
53
64
 
54
- def default_headers(self) -> dict[str, str]:
65
+ def _default_headers(self) -> dict[str, str]:
55
66
  return {
56
- "user-agent": self.user_agent
57
- or "Mozilla/5.0 (+https://github.com/s3rgeym/hh-applicant-tool)",
67
+ "user-agent": self.user_agent,
58
68
  "x-hh-app-active": "true",
59
69
  }
60
70
 
@@ -87,7 +97,7 @@ class BaseClient:
87
97
  method,
88
98
  url,
89
99
  **payload,
90
- headers=self.default_headers(),
100
+ headers=self._default_headers(),
91
101
  allow_redirects=False,
92
102
  )
93
103
  try:
@@ -139,14 +149,19 @@ class BaseClient:
139
149
 
140
150
  @dataclass
141
151
  class OAuthClient(BaseClient):
142
- client_id: str
143
- client_secret: str
152
+ client_id: str | None = None
153
+ client_secret: str | None = None
144
154
  _: dataclasses.KW_ONLY
145
- base_url: str = "https://hh.ru/oauth/"
155
+ base_url: str = HH_OAUTH_URL
146
156
  state: str = ""
147
157
  scope: str = ""
148
158
  redirect_uri: str = ""
149
159
 
160
+ def __post_init__(self) -> None:
161
+ super().__post_init__()
162
+ self.client_id = self.client_id or ANDROID_CLIENT_ID
163
+ self.client_secret = self.client_secret or ANDROID_CLIENT_SECRET
164
+
150
165
  @property
151
166
  def authorize_url(self) -> str:
152
167
  params = dict(
@@ -197,7 +212,7 @@ class ApiClient(BaseClient):
197
212
  _: dataclasses.KW_ONLY
198
213
  client_id: str | None = None
199
214
  client_secret: str | None = None
200
- base_url: str = "https://api.hh.ru/"
215
+ base_url: str = HH_API_URL
201
216
 
202
217
  @property
203
218
  def is_access_expired(self) -> bool:
@@ -212,10 +227,10 @@ class ApiClient(BaseClient):
212
227
  session=self.session,
213
228
  )
214
229
 
215
- def default_headers(
230
+ def _default_headers(
216
231
  self,
217
232
  ) -> dict[str, str]:
218
- headers = super().default_headers()
233
+ headers = super()._default_headers()
219
234
  if not self.access_token:
220
235
  return headers
221
236
  # Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
@@ -1,6 +1,6 @@
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
-
3
- ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
1
+ ANDROID_CLIENT_ID = (
2
+ "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
3
+ )
4
4
 
5
5
  ANDROID_CLIENT_SECRET = (
6
6
  "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
@@ -32,6 +32,8 @@ class PaginatedItems(TypedDict, Generic[Item]):
32
32
  arguments: Optional[Any]
33
33
  fixes: Optional[Any]
34
34
  suggests: Optional[Any]
35
+ # Это выглядит как глюк. Я нейронке скармливал выхлоп апи, а она писала эти
36
+ # типы
35
37
  alternate_url: str
36
38
 
37
39
 
@@ -46,7 +46,11 @@ class ApiError(BadResponse):
46
46
 
47
47
  @property
48
48
  def message(self) -> str:
49
- return self._data.get("description") or str(self._data)
49
+ return (
50
+ self._data.get("error_description")
51
+ or self._data.get("description")
52
+ or str(self._data)
53
+ )
50
54
 
51
55
  # def __getattr__(self, name: str) -> Any:
52
56
  # try:
@@ -62,7 +66,9 @@ class ApiError(BadResponse):
62
66
  return any(v.get("value") == value for v in data.get("errors", []))
63
67
 
64
68
  @classmethod
65
- def raise_for_status(cls: Type[ApiError], response: Response, data: dict) -> None:
69
+ def raise_for_status(
70
+ cls: Type[ApiError], response: Response, data: dict
71
+ ) -> None:
66
72
  match response.status_code:
67
73
  case status if 300 <= status <= 308:
68
74
  raise Redirect(response, data)
@@ -2,7 +2,7 @@ import random
2
2
  import uuid
3
3
 
4
4
 
5
- def hh_android_useragent() -> str:
5
+ def generate_android_useragent() -> str:
6
6
  """Generates Android App User-Agent"""
7
7
  devices = (
8
8
  "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(", ")
hh_applicant_tool/main.py CHANGED
@@ -17,9 +17,7 @@ from typing import Any, Iterable
17
17
  import requests
18
18
  import urllib3
19
19
 
20
- from . import datatypes, utils
21
- from .api import ApiClient
22
- from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
20
+ from . import ai, api, utils
23
21
  from .storage import StorageFacade
24
22
  from .utils.log import setup_logger
25
23
  from .utils.mixins import MegaTool
@@ -30,7 +28,6 @@ DEFAULT_CONFIG_DIR = utils.get_config_path() / (__package__ or "").replace(
30
28
  DEFAULT_CONFIG_FILENAME = "config.json"
31
29
  DEFAULT_LOG_FILENAME = "log.txt"
32
30
  DEFAULT_DATABASE_FILENAME = "data"
33
- DEFAULT_PROFILE_ID = "."
34
31
 
35
32
  logger = logging.getLogger(__package__)
36
33
 
@@ -55,7 +52,6 @@ class BaseNamespace(argparse.Namespace):
55
52
  delay: float
56
53
  user_agent: str
57
54
  proxy_url: str
58
- disable_telemetry: bool
59
55
 
60
56
 
61
57
  class HHApplicantTool(MegaTool):
@@ -90,19 +86,18 @@ class HHApplicantTool(MegaTool):
90
86
  "--config",
91
87
  help="Путь до директории с конфигом",
92
88
  type=Path,
93
- default=DEFAULT_CONFIG_DIR,
89
+ default=None,
94
90
  )
95
91
  parser.add_argument(
96
92
  "--profile-id",
97
93
  "--profile",
98
- help="Используемый профиль — подкаталог в --config-dir",
99
- default=DEFAULT_PROFILE_ID,
94
+ help="Используемый профиль — подкаталог в --config-dir. Так же можно передать через переменную окружения HH_PROFILE_ID.",
100
95
  )
101
96
  parser.add_argument(
102
97
  "-d",
98
+ "--api-delay",
103
99
  "--delay",
104
100
  type=float,
105
- default=0.654,
106
101
  help="Задержка между запросами к API HH по умолчанию",
107
102
  )
108
103
  parser.add_argument(
@@ -169,14 +164,20 @@ class HHApplicantTool(MegaTool):
169
164
  session.verify = False
170
165
 
171
166
  if proxies := self._get_proxies():
172
- logger.info("Use proxies: %r", proxies)
167
+ logger.info("Use proxies for requests: %r", proxies)
173
168
  session.proxies = proxies
174
169
 
175
170
  return session
176
171
 
177
- @property
172
+ @cached_property
178
173
  def config_path(self) -> Path:
179
- return (self.args.config_dir / self.args.profile_id).resolve()
174
+ return (
175
+ (
176
+ self.args.config_dir
177
+ or Path(getenv("CONFIG_DIR", DEFAULT_CONFIG_DIR))
178
+ )
179
+ / (self.args.profile_id or getenv("HH_PROFILE_ID", "."))
180
+ ).resolve()
180
181
 
181
182
  @cached_property
182
183
  def config(self) -> utils.Config:
@@ -200,37 +201,35 @@ class HHApplicantTool(MegaTool):
200
201
  return StorageFacade(self.db)
201
202
 
202
203
  @cached_property
203
- def api_client(self) -> ApiClient:
204
+ def api_client(self) -> api.client.ApiClient:
204
205
  args = self.args
205
206
  config = self.config
206
207
  token = config.get("token", {})
207
- api = ApiClient(
208
- client_id=config.get("client_id", ANDROID_CLIENT_ID),
209
- client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
208
+ return api.client.ApiClient(
209
+ client_id=config.get("client_id"),
210
+ client_secret=config.get("client_id"),
210
211
  access_token=token.get("access_token"),
211
212
  refresh_token=token.get("refresh_token"),
212
213
  access_expires_at=token.get("access_expires_at"),
213
- delay=args.delay,
214
- user_agent=config["user_agent"] or utils.hh_android_useragent(),
214
+ delay=args.api_delay or config.get("api_delay"),
215
+ user_agent=args.user_agent or config.get("user_agent"),
215
216
  session=self.session,
216
217
  )
217
- return api
218
218
 
219
- def get_me(self) -> datatypes.User:
219
+ def get_me(self) -> api.datatypes.User:
220
220
  return self.api_client.get("/me")
221
221
 
222
- def get_resumes(self) -> datatypes.PaginatedItems[datatypes.Resume]:
223
- return self.api_client.get("/resumes/mine")
222
+ def get_resumes(self) -> list[api.datatypes.Resume]:
223
+ return self.api_client.get("/resumes/mine")["items"]
224
224
 
225
- def first_resume_id(self):
226
- resumes = self.api_client.get("/resumes/mine")
227
- assert len(resumes["items"]), "Empty resume list"
228
- return resumes["items"][0]["id"]
225
+ def first_resume_id(self) -> str:
226
+ resume = self.get_resumes()[0]
227
+ return resume["id"]
229
228
 
230
229
  def get_blacklisted(self) -> list[str]:
231
230
  rv = []
232
231
  for page in count():
233
- r: datatypes.PaginatedItems[datatypes.EmployerShort] = (
232
+ r: api.datatypes.PaginatedItems[api.datatypes.EmployerShort] = (
234
233
  self.api_client.get("/employers/blacklisted", page=page)
235
234
  )
236
235
  rv += [item["id"] for item in r["items"]]
@@ -240,7 +239,7 @@ class HHApplicantTool(MegaTool):
240
239
 
241
240
  def get_negotiations(
242
241
  self, status: str = "active"
243
- ) -> Iterable[datatypes.Negotiation]:
242
+ ) -> Iterable[api.datatypes.Negotiation]:
244
243
  for page in count():
245
244
  r: dict[str, Any] = self.api_client.get(
246
245
  "/negotiations",
@@ -259,6 +258,8 @@ class HHApplicantTool(MegaTool):
259
258
  if page + 1 >= r.get("pages", 0):
260
259
  break
261
260
 
261
+ # TODO: добавить еще методов или те удалить?
262
+
262
263
  def save_token(self) -> bool:
263
264
  if self.api_client.access_token != self.config.get("token", {}).get(
264
265
  "access_token"
@@ -267,6 +268,20 @@ class HHApplicantTool(MegaTool):
267
268
  return True
268
269
  return False
269
270
 
271
+ def get_openai_chat(self, system_prompt: str) -> ai.ChatOpenAI:
272
+ c = self.config.get("openai", {})
273
+ if not (token := c.get("token")):
274
+ raise ValueError("Токен для OpenAI не задан")
275
+ return ai.ChatOpenAI(
276
+ token=token,
277
+ model=c.get("model"),
278
+ temperature=c.get("temperature", 0.7),
279
+ max_completion_tokens=c.get("max_completion_tokens", 1000),
280
+ system_prompt=system_prompt,
281
+ completion_endpoint=c.get("completion_endpoint"),
282
+ session=self.session,
283
+ )
284
+
270
285
  def run(self) -> None | int:
271
286
  verbosity_level = max(
272
287
  logging.DEBUG,
@@ -275,19 +290,25 @@ class HHApplicantTool(MegaTool):
275
290
 
276
291
  setup_logger(logger, verbosity_level, self.log_file)
277
292
 
278
- if sys.platform == "win32":
279
- utils.setup_terminal()
293
+ logger.debug("Путь до профиля: %s", self.config_path)
294
+
295
+ utils.setup_terminal()
280
296
 
281
297
  try:
282
298
  if self.args.run:
283
299
  try:
284
- res = self.args.run(self)
285
- if self.save_token():
286
- logger.info("Токен был обновлен.")
287
- return res
300
+ return self.args.run(self)
288
301
  except KeyboardInterrupt:
289
302
  logger.warning("Выполнение прервано пользователем!")
290
- return 1
303
+ except api.errors.CaptchaRequired as ex:
304
+ logger.error(f"Требуется ввод капчи: {ex.captcha_url}")
305
+ except api.errors.InternalServerError:
306
+ logger.error(
307
+ "Сервер HH.RU не смог обработать запрос из-за высокой"
308
+ " нагрузки или по иной причине"
309
+ )
310
+ except api.errors.Forbidden:
311
+ logger.error("Требуется авторизация")
291
312
  except sqlite3.Error as ex:
292
313
  logger.exception(ex)
293
314
 
@@ -297,17 +318,21 @@ class HHApplicantTool(MegaTool):
297
318
  f"Возможно база данных повреждена, попробуйте выполнить команду:\n\n" # noqa: E501
298
319
  f" {script_name} migrate-db"
299
320
  )
300
- return 1
301
321
  except Exception as e:
302
322
  logger.exception(e)
303
- return 1
323
+ finally:
324
+ # Токен мог автоматически обновиться
325
+ if self.save_token():
326
+ logger.info("Токен был сохранен после обновления.")
327
+ return 1
304
328
  self._parser.print_help(file=sys.stderr)
305
329
  return 2
306
330
  finally:
307
331
  try:
308
- self.check_system()
332
+ self._check_system()
309
333
  except Exception:
310
334
  pass
335
+ # raise
311
336
 
312
337
  def _parse_args(self, argv) -> None:
313
338
  self._parser = self._create_parser()