hh-applicant-tool 0.5.7__tar.gz → 0.5.9__tar.gz

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.

Files changed (33) hide show
  1. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/PKG-INFO +13 -22
  2. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/README.md +12 -21
  3. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/client.py +101 -42
  4. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/errors.py +14 -8
  5. hh_applicant_tool-0.5.9/hh_applicant_tool/constants.py +14 -0
  6. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/jsonc.py +10 -5
  7. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/main.py +18 -2
  8. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/mixins.py +1 -1
  9. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/apply_similar.py +37 -44
  10. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/authorize.py +9 -19
  11. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/call_api.py +8 -9
  12. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/clear_negotiations.py +10 -14
  13. hh_applicant_tool-0.5.9/hh_applicant_tool/operations/config.py +51 -0
  14. hh_applicant_tool-0.5.9/hh_applicant_tool/operations/delete_telemetry.py +30 -0
  15. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/get_employer_contacts.py +15 -31
  16. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/list_resumes.py +4 -7
  17. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/refresh_token.py +4 -17
  18. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/reply_employers.py +12 -11
  19. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/update_resumes.py +4 -5
  20. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/whoami.py +4 -5
  21. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/telemetry_client.py +15 -2
  22. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/pyproject.toml +11 -2
  23. hh_applicant_tool-0.5.7/hh_applicant_tool/constants.py +0 -12
  24. hh_applicant_tool-0.5.7/hh_applicant_tool/operations/config.py +0 -36
  25. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/__init__.py +0 -0
  26. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/__main__.py +0 -0
  27. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/ai/__init__.py +0 -0
  28. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/ai/blackbox.py +0 -0
  29. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/api/__init__.py +0 -0
  30. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/color_log.py +0 -0
  31. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/operations/__init__.py +0 -0
  32. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/types.py +0 -0
  33. {hh_applicant_tool-0.5.7 → hh_applicant_tool-0.5.9}/hh_applicant_tool/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hh-applicant-tool
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -19,9 +19,9 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  ## HH Applicant Tool
21
21
 
22
- ![Publish to PyPI](https://github.com/s3rgeym/hh-applicant-tool/actions/workflows/publish.yml/badge.svg)
23
- [![PyPi Version](https://img.shields.io/pypi/v/hh-applicant-tool)]()
24
- [![Python Versions](https://img.shields.io/pypi/pyversions/hh-applicant-tool.svg)]()
22
+ ![Publish to PyPI](https://github.com/s3rgeym/hh-applicant-tool/actions/workflows/publish.yml/badge.svg)
23
+ [![PyPi Version](https://img.shields.io/pypi/v/hh-applicant-tool)]()
24
+ [![Python Versions](https://img.shields.io/pypi/pyversions/hh-applicant-tool.svg)]()
25
25
  [![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/s3rgeym/hh-applicant-tool)]()
26
26
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/hh-applicant-tool)]()
27
27
  [![Total Downloads](https://static.pepy.tech/badge/hh-applicant-tool)]()
@@ -32,27 +32,18 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  ### Внимание!!!
34
34
 
35
- Если для Вас проблема даже установить данную утилиту, лень разбираться с ее настройкой, то Вы можете попробовать мое приложение под **Android**.
36
-
37
- ![image](https://github.com/user-attachments/assets/4dde1926-b2ba-439f-9d00-6aabc92744a0)
38
-
39
- Что может приложение?
40
-
41
- - Рассылать отклики с сопроводительным.
42
- - Автоматически поднимать резюме.
43
- - Все это работает в фоне, те запустили, свернули и забыли до перезагрузки...
44
-
45
- [Скачать его можно тут](https://t.me/hh_resume_automate).
35
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
46
36
 
47
37
 
48
38
  ### Описание
49
39
 
50
40
  > Утилита для генерации сопроводительного письма может использовать AI
51
41
 
52
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения. Для этого собираются данные о работодателях и их вакансиях (персональные данные пользователя не передаются ни в каком виде, а данные работодателя сами по себе персональными данными не являются, так как часто указаны на сайтах и так же доступны неограниченному кругу лиц на hh). Отправку этих данных на сервер разработчика можно отключить. У утилиты есть группа, с которой я еще не придумал, что делать: [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там сейчас постятся ссылки для отзывов на отказников.
42
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
53
43
 
54
44
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
55
- asdf/pyenv/conda и что-то еще.
45
+ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
46
+ версия Python новее.
56
47
 
57
48
  Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
58
49
 
@@ -266,7 +257,7 @@ https://hh.ru/employer/1918903
266
257
  | **call-api** | Вызов произвольного метода API с выводом результата. |
267
258
  | **refresh-token** | Обновляет access_token. |
268
259
  | **config** | Редактировать конфигурационный файл. |
269
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. В группе есть бесплатный бот с тем же функционалом. |
260
+ | **get-employer-contacts** | Получить список контактов работадателей. |
270
261
 
271
262
  ### Формат текста сообщений
272
263
 
@@ -301,10 +292,10 @@ https://hh.ru/employer/1918903
301
292
 
302
293
  ### Использование AI для генерации сопроводительного письма
303
294
 
304
- * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
305
- * В первом сообщении опишите свой опыт и тп.
306
- * Далее откройте devtools, нажав `F12`.
307
- * Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
295
+ * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
296
+ * В первом сообщении опишите свой опыт и тп.
297
+ * Далее откройте devtools, нажав `F12`.
298
+ * Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
308
299
  * Запустите редактирование конфига:
309
300
  ```sh
310
301
  hh-applicant-tool config
@@ -1,8 +1,8 @@
1
1
  ## HH Applicant Tool
2
2
 
3
- ![Publish to PyPI](https://github.com/s3rgeym/hh-applicant-tool/actions/workflows/publish.yml/badge.svg)
4
- [![PyPi Version](https://img.shields.io/pypi/v/hh-applicant-tool)]()
5
- [![Python Versions](https://img.shields.io/pypi/pyversions/hh-applicant-tool.svg)]()
3
+ ![Publish to PyPI](https://github.com/s3rgeym/hh-applicant-tool/actions/workflows/publish.yml/badge.svg)
4
+ [![PyPi Version](https://img.shields.io/pypi/v/hh-applicant-tool)]()
5
+ [![Python Versions](https://img.shields.io/pypi/pyversions/hh-applicant-tool.svg)]()
6
6
  [![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/s3rgeym/hh-applicant-tool)]()
7
7
  [![PyPI - Downloads](https://img.shields.io/pypi/dm/hh-applicant-tool)]()
8
8
  [![Total Downloads](https://static.pepy.tech/badge/hh-applicant-tool)]()
@@ -13,27 +13,18 @@
13
13
 
14
14
  ### Внимание!!!
15
15
 
16
- Если для Вас проблема даже установить данную утилиту, лень разбираться с ее настройкой, то Вы можете попробовать мое приложение под **Android**.
17
-
18
- ![image](https://github.com/user-attachments/assets/4dde1926-b2ba-439f-9d00-6aabc92744a0)
19
-
20
- Что может приложение?
21
-
22
- - Рассылать отклики с сопроводительным.
23
- - Автоматически поднимать резюме.
24
- - Все это работает в фоне, те запустили, свернули и забыли до перезагрузки...
25
-
26
- [Скачать его можно тут](https://t.me/hh_resume_automate).
16
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
27
17
 
28
18
 
29
19
  ### Описание
30
20
 
31
21
  > Утилита для генерации сопроводительного письма может использовать AI
32
22
 
33
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения. Для этого собираются данные о работодателях и их вакансиях (персональные данные пользователя не передаются ни в каком виде, а данные работодателя сами по себе персональными данными не являются, так как часто указаны на сайтах и так же доступны неограниченному кругу лиц на hh). Отправку этих данных на сервер разработчика можно отключить. У утилиты есть группа, с которой я еще не придумал, что делать: [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там сейчас постятся ссылки для отзывов на отказников.
23
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
34
24
 
35
25
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
36
- asdf/pyenv/conda и что-то еще.
26
+ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
27
+ версия Python новее.
37
28
 
38
29
  Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
39
30
 
@@ -247,7 +238,7 @@ https://hh.ru/employer/1918903
247
238
  | **call-api** | Вызов произвольного метода API с выводом результата. |
248
239
  | **refresh-token** | Обновляет access_token. |
249
240
  | **config** | Редактировать конфигурационный файл. |
250
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. В группе есть бесплатный бот с тем же функционалом. |
241
+ | **get-employer-contacts** | Получить список контактов работадателей. |
251
242
 
252
243
  ### Формат текста сообщений
253
244
 
@@ -282,10 +273,10 @@ https://hh.ru/employer/1918903
282
273
 
283
274
  ### Использование AI для генерации сопроводительного письма
284
275
 
285
- * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
286
- * В первом сообщении опишите свой опыт и тп.
287
- * Далее откройте devtools, нажав `F12`.
288
- * Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
276
+ * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
277
+ * В первом сообщении опишите свой опыт и тп.
278
+ * Далее откройте devtools, нажав `F12`.
279
+ * Во вкладке `Network` последним должен быть POST-запрос на `https://www.blackbox.ai/api/chat`.
289
280
  * Запустите редактирование конфига:
290
281
  ```sh
291
282
  hh-applicant-tool config
@@ -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
- "User-Agent": self.user_agent or self.default_user_agent(),
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
- return f"ru.hh.android/7.122.11395, Device: 23053RN02Y, Android OS: 13 (UUID: {uuid.uuid4()})"
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
- **{"data" if has_body else "params": params},
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 = partialmethod(request, "GET")
123
- post = partialmethod(request, "POST")
124
- put = partialmethod(request, "PUT")
125
- delete = partialmethod(request, "DELETE")
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 = ANDROID_CLIENT_ID
156
- client_secret: str = ANDROID_CLIENT_SECRET
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.post("/token", params)
202
+ return self.request_access_token("/token", params)
183
203
 
184
- def refresh_access(self, refresh_token: str) -> AccessToken:
204
+ def refresh_access_token(self, refresh_token: str) -> AccessToken:
185
205
  # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
186
- return self.post(
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
- # def __post_init__(self) -> None:
201
- # super().__post_init__()
202
- # self.oauth_client = self.oauth_client or OAuthClient(
203
- # session=self.session
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
- {"Authorization": f"Bearer {self.access_token}"}
238
+ {"authorization": f"Bearer {self.access_token}"}
211
239
  if self.access_token
212
240
  else {}
213
241
  )
214
242
 
215
- # def refresh_access(self) -> AccessToken:
216
- # tok = self.oauth_client.refresh_access(self.refresh_token)
217
- # (
218
- # self.access_token,
219
- # self.refresh_access,
220
- # ) = (
221
- # tok["access_token"],
222
- # tok["refresh_token"],
223
- # )
224
- # return tok
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
+ }
@@ -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
- @property
60
- def limit_exceeded(self) -> bool:
61
- return any(x["value"] == "limit_exceeded" for x in self._raw["errors"])
63
+ pass
64
+
65
+
66
+ class LimitExceeded(ClientError):
67
+ pass
62
68
 
63
69
 
64
70
  class Forbidden(ClientError):
@@ -0,0 +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"
2
+
3
+ ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
4
+
5
+ ANDROID_CLIENT_SECRET = (
6
+ "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
7
+ )
8
+
9
+ # Используется для прямой авторизации. Этот способ мной не используется, так как
10
+ # для отображения капчи все равно нужен webview.
11
+ # K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
12
+
13
+ # Кривой формат, который используют эти долбоебы
14
+ INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
@@ -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.next_token = None
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
 
@@ -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 get_api(args: Namespace) -> ApiClient:
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
- return args.run(args)
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
@@ -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.api.get("/resumes/mine")
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