hh-applicant-tool 0.5.6__tar.gz → 0.5.8__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 (31) hide show
  1. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/PKG-INFO +17 -11
  2. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/README.md +16 -10
  3. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/client.py +84 -55
  4. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/errors.py +14 -8
  5. hh_applicant_tool-0.5.8/hh_applicant_tool/constants.py +14 -0
  6. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/jsonc.py +10 -5
  7. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/main.py +5 -1
  8. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/apply_similar.py +25 -34
  9. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/authorize.py +8 -16
  10. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/call_api.py +3 -4
  11. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/clear_negotiations.py +5 -9
  12. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/config.py +1 -1
  13. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/get_employer_contacts.py +12 -22
  14. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/list_resumes.py +3 -6
  15. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/refresh_token.py +5 -16
  16. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/reply_employers.py +8 -7
  17. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/update_resumes.py +2 -3
  18. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/whoami.py +2 -3
  19. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/telemetry_client.py +1 -0
  20. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/pyproject.toml +8 -1
  21. hh_applicant_tool-0.5.6/hh_applicant_tool/constants.py +0 -12
  22. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/__init__.py +0 -0
  23. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/__main__.py +0 -0
  24. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/ai/__init__.py +0 -0
  25. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/ai/blackbox.py +0 -0
  26. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/api/__init__.py +0 -0
  27. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/color_log.py +0 -0
  28. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/mixins.py +0 -0
  29. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/operations/__init__.py +0 -0
  30. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/hh_applicant_tool/types.py +0 -0
  31. {hh_applicant_tool-0.5.6 → hh_applicant_tool-0.5.8}/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.6
3
+ Version: 0.5.8
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)]()
@@ -30,14 +30,20 @@ Description-Content-Type: text/markdown
30
30
  <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
31
31
  </div>
32
32
 
33
+ ### Внимание!!!
34
+
35
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
36
+
37
+
33
38
  ### Описание
34
39
 
35
40
  > Утилита для генерации сопроводительного письма может использовать AI
36
41
 
37
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения. Для этого собираются данные о работодателях и их вакансиях (персональные данные пользователя не передаются ни в каком виде, а данные работодателя сами по себе персональными данными не являются, так как часто указаны на сайтах и так же доступны неограниченному кругу лиц на hh). Отправку этих данных на сервер разработчика можно отключить. У утилиты есть группа, с которой я еще не придумал, что делать: [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там сейчас постятся ссылки для отзывов на отказников.
42
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
38
43
 
39
44
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
40
- asdf/pyenv/conda и что-то еще.
45
+ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
46
+ версия Python новее.
41
47
 
42
48
  Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
43
49
 
@@ -251,7 +257,7 @@ https://hh.ru/employer/1918903
251
257
  | **call-api** | Вызов произвольного метода API с выводом результата. |
252
258
  | **refresh-token** | Обновляет access_token. |
253
259
  | **config** | Редактировать конфигурационный файл. |
254
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. В группе есть бесплатный бот с тем же функционалом. |
260
+ | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
255
261
 
256
262
  ### Формат текста сообщений
257
263
 
@@ -286,10 +292,10 @@ https://hh.ru/employer/1918903
286
292
 
287
293
  ### Использование AI для генерации сопроводительного письма
288
294
 
289
- * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
290
- * В первом сообщении опишите свой опыт и тп.
291
- * Далее откройте devtools, нажав `F12`.
292
- * Во вкладке `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`.
293
299
  * Запустите редактирование конфига:
294
300
  ```sh
295
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)]()
@@ -11,14 +11,20 @@
11
11
  <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
12
12
  </div>
13
13
 
14
+ ### Внимание!!!
15
+
16
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
17
+
18
+
14
19
  ### Описание
15
20
 
16
21
  > Утилита для генерации сопроводительного письма может использовать AI
17
22
 
18
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения. Для этого собираются данные о работодателях и их вакансиях (персональные данные пользователя не передаются ни в каком виде, а данные работодателя сами по себе персональными данными не являются, так как часто указаны на сайтах и так же доступны неограниченному кругу лиц на hh). Отправку этих данных на сервер разработчика можно отключить. У утилиты есть группа, с которой я еще не придумал, что делать: [Отзывы о работодателях с HH.RU](https://t.me/otzyvy_headhunter). Там сейчас постятся ссылки для отзывов на отказников.
23
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
19
24
 
20
25
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
21
- asdf/pyenv/conda и что-то еще.
26
+ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
27
+ версия Python новее.
22
28
 
23
29
  Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести конфиг на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
24
30
 
@@ -232,7 +238,7 @@ https://hh.ru/employer/1918903
232
238
  | **call-api** | Вызов произвольного метода API с выводом результата. |
233
239
  | **refresh-token** | Обновляет access_token. |
234
240
  | **config** | Редактировать конфигурационный файл. |
235
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. В группе есть бесплатный бот с тем же функционалом. |
241
+ | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
236
242
 
237
243
  ### Формат текста сообщений
238
244
 
@@ -267,10 +273,10 @@ https://hh.ru/employer/1918903
267
273
 
268
274
  ### Использование AI для генерации сопроводительного письма
269
275
 
270
- * Перейдите на сайт [blackbox.ai](https://www.blackbox.ai) и создайте чат.
271
- * В первом сообщении опишите свой опыт и тп.
272
- * Далее откройте devtools, нажав `F12`.
273
- * Во вкладке `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`.
274
280
  * Запустите редактирование конфига:
275
281
  ```sh
276
282
  hh-applicant-tool config
@@ -3,21 +3,21 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import json
5
5
  import logging
6
+ import uuid
6
7
  import time
7
8
  from dataclasses import dataclass
8
9
  from functools import partialmethod
9
10
  from threading import Lock
10
11
  from typing import Any, Literal
11
12
  from urllib.parse import urlencode
12
-
13
+ from functools import cached_property
14
+ import random
13
15
  import requests
14
16
  from requests import Response, Session
15
- import random
16
17
 
17
18
  from ..constants import (
18
19
  ANDROID_CLIENT_ID,
19
20
  ANDROID_CLIENT_SECRET,
20
- USER_AGENT_TEMPLATE,
21
21
  )
22
22
  from ..types import AccessToken
23
23
  from . import errors
@@ -48,31 +48,22 @@ class BaseClient:
48
48
  self.session = session = requests.session()
49
49
  session.headers.update(
50
50
  {
51
- "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",
52
53
  **self.additional_headers(),
53
54
  }
54
55
  )
55
56
  logger.debug("Default Headers: %r", session.headers)
56
57
 
57
58
  def default_user_agent(self) -> str:
58
- return USER_AGENT_TEMPLATE % (
59
- random.choice(["8.0", "8.1", "9", "10", "11", "12"]),
60
- random.choice(
61
- [
62
- "SM-G998B", # Samsung Galaxy S21 Ultra
63
- "Pixel 6", # Google Pixel 6
64
- "Mi 11", # Xiaomi Mi 11
65
- "OnePlus 9", # OnePlus 9
66
- "P40", # Huawei P40
67
- "LG G8", # LG G8
68
- "Xperia 1 II", # Sony Xperia 1 II
69
- "Moto G Power", # Motorola Moto G Power
70
- "HTC U12+", # HTC U12+
71
- "ROG Phone 5", # Asus ROG Phone 5
72
- ]
73
- ),
74
- random.randint(88, 130),
59
+ devices = "23053RN02A, 23053RN02Y, 23053RN02I, 23053RN02L, 23077RABDC".split(
60
+ ", "
75
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()})"
76
67
 
77
68
  def additional_headers(
78
69
  self,
@@ -83,7 +74,7 @@ class BaseClient:
83
74
  self,
84
75
  method: ALLOWED_METHODS,
85
76
  endpoint: str,
86
- params: dict | None = None,
77
+ params: dict[str, Any] | None = None,
87
78
  delay: float | None = None,
88
79
  **kwargs: Any,
89
80
  ) -> dict:
@@ -102,10 +93,11 @@ class BaseClient:
102
93
  logger.debug("wait %fs before request", delay)
103
94
  time.sleep(delay)
104
95
  has_body = method in ["POST", "PUT"]
96
+ payload = {"data" if has_body else "params": params}
105
97
  response = self.session.request(
106
98
  method,
107
99
  url,
108
- **{"data" if has_body else "params": params},
100
+ **payload,
109
101
  proxies=self.proxies,
110
102
  allow_redirects=False,
111
103
  )
@@ -125,12 +117,7 @@ class BaseClient:
125
117
  "%d %-6s %s",
126
118
  response.status_code,
127
119
  method,
128
- url
129
- + (
130
- "?" + urlencode(params)
131
- if not has_body and params
132
- else ""
133
- ),
120
+ url + ("?" + urlencode(params) if not has_body and params else ""),
134
121
  )
135
122
  self.previous_request_time = time.monotonic()
136
123
  self.raise_for_status(response, rv)
@@ -143,11 +130,7 @@ class BaseClient:
143
130
  delete = partialmethod(request, "DELETE")
144
131
 
145
132
  def resolve_url(self, url: str) -> str:
146
- return (
147
- url
148
- if "://" in url
149
- else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
150
- )
133
+ return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
151
134
 
152
135
  @staticmethod
153
136
  def raise_for_status(response: Response, data: dict) -> None:
@@ -155,6 +138,8 @@ class BaseClient:
155
138
  case 301 | 302:
156
139
  raise errors.Redirect(response, data)
157
140
  case 400:
141
+ if errors.ApiError.is_limit_exceeded(data):
142
+ raise errors.LimitExceeded(response=response, data=data)
158
143
  raise errors.BadRequest(response, data)
159
144
  case 403:
160
145
  raise errors.Forbidden(response, data)
@@ -170,8 +155,8 @@ class BaseClient:
170
155
 
171
156
  @dataclass
172
157
  class OAuthClient(BaseClient):
173
- client_id: str = ANDROID_CLIENT_ID
174
- client_secret: str = ANDROID_CLIENT_SECRET
158
+ client_id: str
159
+ client_secret: str
175
160
  _: dataclasses.KW_ONLY
176
161
  base_url: str = "https://hh.ru/oauth"
177
162
  state: str = ""
@@ -190,6 +175,16 @@ class OAuthClient(BaseClient):
190
175
  params_qs = urlencode({k: v for k, v in params.items() if v})
191
176
  return self.resolve_url(f"/authorize?{params_qs}")
192
177
 
178
+ def request_access_token(
179
+ self, endpoint: str, params: dict[str, Any] | None = None, **kw: Any
180
+ ) -> AccessToken:
181
+ tok = self.post(endpoint, params, **kw)
182
+ return {
183
+ "access_token": tok.get("access_token"),
184
+ "refresh_token": tok.get("refresh_token"),
185
+ "access_expires_at": int(time.time()) + tok.pop("expires_in", 0),
186
+ }
187
+
193
188
  def authenticate(self, code: str) -> AccessToken:
194
189
  params = {
195
190
  "client_id": self.client_id,
@@ -197,11 +192,11 @@ class OAuthClient(BaseClient):
197
192
  "code": code,
198
193
  "grant_type": "authorization_code",
199
194
  }
200
- return self.post("/token", params)
195
+ return self.request_access_token("/token", params)
201
196
 
202
197
  def refresh_access(self, refresh_token: str) -> AccessToken:
203
198
  # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
204
- return self.post(
199
+ return self.request_access_token(
205
200
  "/token", grant_type="refresh_token", refresh_token=refresh_token
206
201
  )
207
202
 
@@ -211,32 +206,66 @@ class ApiClient(BaseClient):
211
206
  # Например, для просмотра информации о компании токен не нужен
212
207
  access_token: str | None = None
213
208
  refresh_token: str | None = None
209
+ access_expires_at: int = 0
210
+ client_id: str = ANDROID_CLIENT_ID
211
+ client_secret: str = ANDROID_CLIENT_SECRET
214
212
  _: dataclasses.KW_ONLY
215
213
  base_url: str = "https://api.hh.ru/"
216
- # oauth_client: OAuthClient | None = None
217
214
 
218
- # def __post_init__(self) -> None:
219
- # super().__post_init__()
220
- # self.oauth_client = self.oauth_client or OAuthClient(
221
- # session=self.session
222
- # )
215
+ @property
216
+ def is_access_expired(self) -> bool:
217
+ return time.time() > self.access_expires_at
218
+
219
+ @cached_property
220
+ def oauth_client(self) -> OAuthClient:
221
+ return OAuthClient(
222
+ client_id=self.client_id,
223
+ client_secret=self.client_secret,
224
+ session=self.session,
225
+ )
223
226
 
224
227
  def additional_headers(
225
228
  self,
226
229
  ) -> dict[str, str]:
227
230
  return (
228
- {"Authorization": f"Bearer {self.access_token}"}
231
+ {"authorization": f"Bearer {self.access_token}"}
229
232
  if self.access_token
230
233
  else {}
231
234
  )
232
235
 
233
- # def refresh_access(self) -> AccessToken:
234
- # tok = self.oauth_client.refresh_access(self.refresh_token)
235
- # (
236
- # self.access_token,
237
- # self.refresh_access,
238
- # ) = (
239
- # tok["access_token"],
240
- # tok["refresh_token"],
241
- # )
242
- # return tok
236
+ # Реализовано автоматическое обновление токена
237
+ def request(
238
+ self,
239
+ method: ALLOWED_METHODS,
240
+ endpoint: str,
241
+ params: dict[str, Any] | None = None,
242
+ delay: float | None = None,
243
+ **kwargs: Any,
244
+ ) -> dict:
245
+ def do_request():
246
+ return super().request(method, endpoint, params, delay, **kwargs)
247
+
248
+ try:
249
+ return do_request()
250
+ # TODO: добавить класс для ошибок типа AccessTokenExpired
251
+ except errors.ApiError as ex:
252
+ if not self.is_access_expired:
253
+ raise ex
254
+ logger.info("try refresh access_token")
255
+ # Пробуем обновить токен
256
+ token = self.oauth_client.refresh_access(self.refresh_token)
257
+ self.handle_access_token(token)
258
+ # И повторно отправляем запрос
259
+ return do_request()
260
+
261
+ def handle_access_token(self, token: AccessToken) -> None:
262
+ for k in ["access_token", "refresh_token", "access_expires_at"]:
263
+ if k in token:
264
+ setattr(self, k, token[k])
265
+
266
+ def get_access_token(self) -> AccessToken:
267
+ return {
268
+ "access_token": self.access_token,
269
+ "refresh_token": self.refresh_token,
270
+ "access_expires_at": self.access_expires_at,
271
+ }
@@ -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
 
@@ -134,7 +134,11 @@ class HHApplicantTool:
134
134
  logger.addHandler(handler)
135
135
  if args.run:
136
136
  try:
137
- return args.run(args)
137
+ api = get_api(args)
138
+ if not (res := args.run(api, args)):
139
+ # 0 or None = success
140
+ args.config.save(token=api.get_access_token())
141
+ return res
138
142
  except Exception as e:
139
143
  logger.exception(e)
140
144
  return 1
@@ -4,19 +4,25 @@ import random
4
4
  import time
5
5
  from collections import defaultdict
6
6
  from datetime import datetime, timedelta, timezone
7
- from typing import TextIO, Tuple
7
+ from typing import TextIO
8
+
9
+ from hh_applicant_tool.api.errors import LimitExceeded
8
10
 
9
11
  from ..ai.blackbox import BlackboxChat, BlackboxError
10
- from ..api import ApiError, BadRequest
12
+ from ..api import ApiError, ApiClient
11
13
  from ..main import BaseOperation
12
14
  from ..main import Namespace as BaseNamespace
13
15
  from ..main import get_api
14
16
  from ..mixins import GetResumeIdMixin
15
17
  from ..telemetry_client import TelemetryClient, TelemetryError
16
18
  from ..types import ApiListResponse, VacancyItem
17
- from ..utils import (fix_datetime, parse_interval, parse_invalid_datetime,
18
- random_text, truncate_string)
19
- from hh_applicant_tool.ai import blackbox
19
+ from ..utils import (
20
+ fix_datetime,
21
+ parse_interval,
22
+ parse_invalid_datetime,
23
+ random_text,
24
+ truncate_string,
25
+ )
20
26
 
21
27
  logger = logging.getLogger(__package__)
22
28
 
@@ -43,7 +49,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
43
49
  "-L",
44
50
  "--message-list",
45
51
  help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
46
- type=argparse.FileType('r', encoding='utf-8', errors='replace'),
52
+ type=argparse.FileType("r", encoding="utf-8", errors="replace"),
47
53
  )
48
54
  parser.add_argument(
49
55
  "-f",
@@ -103,7 +109,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
103
109
  action=argparse.BooleanOptionalAction,
104
110
  )
105
111
 
106
- def run(self, args: Namespace) -> None:
112
+ def run(self, api: ApiClient, args: Namespace) -> None:
107
113
  self.enable_telemetry = True
108
114
  if args.disable_telemetry:
109
115
  # print(
@@ -120,11 +126,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
120
126
  # logger.info("Спасибо за то что оставили телеметрию включенной!")
121
127
  self.enable_telemetry = False
122
128
 
123
- self.api = get_api(args)
129
+ self.api = api
124
130
  self.resume_id = args.resume_id or self._get_resume_id()
125
- self.application_messages = self._get_application_messages(
126
- args.message_list
127
- )
131
+ self.application_messages = self._get_application_messages(args.message_list)
128
132
  self.chat = None
129
133
 
130
134
  if config := args.config.get("blackbox"):
@@ -145,13 +149,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
145
149
  self.dry_run = args.dry_run
146
150
  self._apply_similar()
147
151
 
148
- def _get_application_messages(
149
- self, message_list: TextIO | None
150
- ) -> list[str]:
152
+ def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
151
153
  if message_list:
152
- application_messages = list(
153
- filter(None, map(str.strip, message_list))
154
- )
154
+ application_messages = list(filter(None, map(str.strip, message_list)))
155
155
  else:
156
156
  application_messages = [
157
157
  "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
@@ -172,12 +172,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
172
172
  "name": vacancy.get("name"),
173
173
  "type": vacancy.get("type", {}).get("id"), # open/closed
174
174
  "area": vacancy.get("area", {}).get("name"), # город
175
- "salary": vacancy.get(
176
- "salary"
177
- ), # from, to, currency, gross
178
- "direct_url": vacancy.get(
179
- "alternate_url"
180
- ), # ссылка на вакансию
175
+ "salary": vacancy.get("salary"), # from, to, currency, gross
176
+ "direct_url": vacancy.get("alternate_url"), # ссылка на вакансию
181
177
  "created_at": fix_datetime(
182
178
  vacancy.get("created_at")
183
179
  ), # будем вычислять говно-вакансии, которые по полгода висят
@@ -210,9 +206,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
210
206
  try:
211
207
  message_placeholders = {
212
208
  "vacancy_name": vacancy.get("name", ""),
213
- "employer_name": vacancy.get("employer", {}).get(
214
- "name", ""
215
- ),
209
+ "employer_name": vacancy.get("employer", {}).get("name", ""),
216
210
  **basic_message_placeholders,
217
211
  }
218
212
 
@@ -304,9 +298,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
304
298
  "message": "",
305
299
  }
306
300
 
307
- if self.force_message or vacancy.get(
308
- "response_letter_required"
309
- ):
301
+ if self.force_message or vacancy.get("response_letter_required"):
310
302
  if self.chat:
311
303
  try:
312
304
  msg = self.pre_prompt + "\n\n"
@@ -318,9 +310,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
318
310
  continue
319
311
  else:
320
312
  msg = (
321
- random_text(
322
- random.choice(self.application_messages)
323
- )
313
+ random_text(random.choice(self.application_messages))
324
314
  % message_placeholders
325
315
  )
326
316
 
@@ -350,10 +340,11 @@ class Operation(BaseOperation, GetResumeIdMixin):
350
340
  truncate_string(vacancy["name"]),
351
341
  ")",
352
342
  )
343
+ except LimitExceeded:
344
+ print("⚠️ Достигли лимита рассылки")
345
+ do_apply = False
353
346
  except ApiError as ex:
354
347
  logger.error(ex)
355
- if isinstance(ex, BadRequest) and ex.limit_exceeded:
356
- do_apply = False
357
348
 
358
349
  print("📝 Отклики на вакансии разосланы!")
359
350
 
@@ -35,7 +35,7 @@ except ImportError:
35
35
  pass
36
36
 
37
37
 
38
- from ..api import OAuthClient
38
+ from ..api import ApiClient, OAuthClient
39
39
  from ..main import BaseOperation, Namespace
40
40
  from ..utils import Config
41
41
 
@@ -54,10 +54,9 @@ class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
54
54
 
55
55
 
56
56
  class WebViewWindow(QMainWindow):
57
- def __init__(self, url: str, oauth_client: OAuthClient, config: Config) -> None:
57
+ def __init__(self, api_client: ApiClient) -> None:
58
58
  super().__init__()
59
- self.oauth_client = oauth_client
60
- self.config = config
59
+ self.api_client = api_client
61
60
  # Настройка WebEngineView
62
61
  self.web_view = QWebEngineView()
63
62
  self.setCentralWidget(self.web_view)
@@ -68,16 +67,15 @@ class WebViewWindow(QMainWindow):
68
67
  profile.installUrlSchemeHandler(b"hhandroid", self.hhandroid_handler)
69
68
  # Настройки окна для мобильного вида
70
69
  self.resize(480, 800)
71
- self.web_view.setUrl(QUrl(url))
70
+ self.web_view.setUrl(QUrl(api_client.oauth_client.authorize_url))
72
71
 
73
72
  def handle_redirect_uri(self, redirect_uri: str) -> None:
74
73
  logger.debug(f"handle redirect uri: {redirect_uri}")
75
74
  sp = urlsplit(redirect_uri)
76
75
  code = parse_qs(sp.query).get("code", [None])[0]
77
76
  if code:
78
- token = self.oauth_client.authenticate(code)
79
- logger.debug("Сохраняем токен")
80
- self.config.save(token=dict(token, created_at=int(time.time())))
77
+ token = self.api_client.oauth_client.authenticate(code)
78
+ self.api_client.handle_access_token(token)
81
79
  print("🔓 Авторизация прошла успешно!")
82
80
  self.close()
83
81
 
@@ -88,21 +86,15 @@ class Operation(BaseOperation):
88
86
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
89
87
  pass
90
88
 
91
- def run(self, args: Namespace) -> None:
89
+ def run(self, api_client: ApiClient, args: Namespace) -> None:
92
90
  if not QT_IMPORTED:
93
91
  print_err(
94
92
  "❗Критиническая Ошибка: PyQt6 не был импортирован, возможно, вы долбоеб и забыли его установить, либо же криворукие разрабы этой либы опять все сломали..."
95
93
  )
96
94
  sys.exit(1)
97
95
 
98
- oauth = OAuthClient(
99
- user_agent=(args.config["oauth_user_agent"] or args.config["user_agent"]),
100
- )
101
-
102
96
  app = QApplication(sys.argv)
103
- window = WebViewWindow(
104
- oauth.authorize_url, oauth_client=oauth, config=args.config
105
- )
97
+ window = WebViewWindow(api_client=api_client)
106
98
  window.show()
107
99
 
108
100
  app.exec()
@@ -4,8 +4,8 @@ import json
4
4
  import logging
5
5
  import sys
6
6
 
7
- from ..api import ApiError
8
- from ..main import BaseOperation, get_api
7
+ from ..api import ApiError, ApiClient
8
+ from ..main import BaseOperation
9
9
  from ..main import Namespace as BaseNamespace
10
10
 
11
11
  logger = logging.getLogger(__package__)
@@ -33,8 +33,7 @@ class Operation(BaseOperation):
33
33
  "-m", "--method", "--meth", default="GET", help="HTTP Метод"
34
34
  )
35
35
 
36
- def run(self, args: Namespace) -> None:
37
- api = get_api(args)
36
+ def run(self, api: ApiClient, args: Namespace) -> None:
38
37
  params = dict(x.split("=", 1) for x in args.param)
39
38
  try:
40
39
  result = api.request(args.method, args.endpoint, params=params)
@@ -7,7 +7,6 @@ from ..api import ApiClient, ClientError
7
7
  from ..constants import INVALID_ISO8601_FORMAT
8
8
  from ..main import BaseOperation
9
9
  from ..main import Namespace as BaseNamespace
10
- from ..main import get_api
11
10
  from ..types import ApiListResponse
12
11
  from ..utils import print_err, truncate_string
13
12
 
@@ -58,8 +57,7 @@ class Operation(BaseOperation):
58
57
  break
59
58
  return rv
60
59
 
61
- def run(self, args: Namespace) -> None:
62
- api = get_api(args)
60
+ def run(self, api: ApiClient, args: Namespace) -> None:
63
61
  negotiations = self._get_active_negotiations(api)
64
62
  print("Всего активных:", len(negotiations))
65
63
  for item in negotiations:
@@ -69,16 +67,14 @@ class Operation(BaseOperation):
69
67
  # hidden True
70
68
  is_discard = state["id"] == "discard"
71
69
  if not item["hidden"] and (
72
- args.all
70
+ args.all
73
71
  or is_discard
74
72
  or (
75
73
  state["id"] == "response"
76
- and (
77
- datetime.utcnow() - timedelta(days=args.older_than)
78
- ).replace(tzinfo=timezone.utc)
79
- > datetime.strptime(
80
- item["updated_at"], INVALID_ISO8601_FORMAT
74
+ and (datetime.utcnow() - timedelta(days=args.older_than)).replace(
75
+ tzinfo=timezone.utc
81
76
  )
77
+ > datetime.strptime(item["updated_at"], INVALID_ISO8601_FORMAT)
82
78
  )
83
79
  ):
84
80
  decline_allowed = item.get("decline_allowed") or False
@@ -28,7 +28,7 @@ class Operation(BaseOperation):
28
28
  help="Напечатать путь и выйти",
29
29
  )
30
30
 
31
- def run(self, args: Namespace) -> None:
31
+ def run(self, _, args: Namespace) -> None:
32
32
  config_path = str(args.config._config_path)
33
33
  if args.print:
34
34
  print(config_path)
@@ -48,13 +48,11 @@ class Operation(BaseOperation):
48
48
  help="Номер страницы в выдаче",
49
49
  )
50
50
 
51
- def run(self, args: Namespace) -> None:
51
+ def run(self, _, args: Namespace) -> None:
52
52
  proxies = get_proxies(args)
53
53
  client = TelemetryClient(proxies=proxies)
54
54
  auth = (
55
- (args.username, args.password)
56
- if args.username and args.password
57
- else None
55
+ (args.username, args.password) if args.username and args.password else None
58
56
  )
59
57
  # Аутентификация пользователя
60
58
  results = client.get_telemetry(
@@ -62,6 +60,13 @@ class Operation(BaseOperation):
62
60
  {"search": args.search, "per_page": 10, "page": args.page},
63
61
  auth=auth,
64
62
  )
63
+ if "contact_persons" not in results:
64
+ print("❌", results)
65
+ return 1
66
+
67
+ print("Данная информация была собрана из публичных источников.")
68
+ print()
69
+
65
70
  self._print_contacts(results)
66
71
 
67
72
  def _print_contacts(self, data: dict) -> None:
@@ -78,26 +83,11 @@ class Operation(BaseOperation):
78
83
  def _print_contact(self, contact: dict, is_last_contact: bool) -> None:
79
84
  """Вывод информации о конкретном контакте."""
80
85
  prefix = "└──" if is_last_contact else "├──"
81
- print(f" {prefix} 🧑 {contact.get('name', 'н/д')}")
86
+ print(f" {prefix} 🧑 Контактное лицо")
82
87
  prefix2 = " " if is_last_contact else " │ "
83
88
  print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
84
89
  employer = contact.get("employer") or {}
85
90
  print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
86
91
  print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
87
- print(f"{prefix2}├── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
88
-
89
- phones = contact["phone_numbers"] or [{"phone_number": "(нет номеров)"}]
90
- print(f"{prefix2}├── 📞 Телефоны:")
91
- last_phone = len(phones) - 1
92
- for i, phone in enumerate(phones):
93
- sub_prefix = "└──" if i == last_phone else "├──"
94
- print(f"{prefix2}│ {sub_prefix} {phone['phone_number']}")
95
-
96
- telegrams = contact["telegram_usernames"] or [
97
- {"username": "(нет аккаунтов)"}
98
- ]
99
- print(f"{prefix2}└── 📱 Telegram:")
100
- last_telegram = len(telegrams) - 1
101
- for i, telegram in enumerate(telegrams):
102
- sub_prefix = "└──" if i == last_telegram else "├──"
103
- print(f"{prefix2} {sub_prefix} {telegram['username']}")
92
+ print(f"{prefix2}└── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
93
+ print(prefix2)
@@ -5,7 +5,7 @@ import logging
5
5
  from prettytable import PrettyTable
6
6
 
7
7
  from ..api import ApiClient
8
- from ..main import BaseOperation, get_api
8
+ from ..main import BaseOperation
9
9
  from ..main import Namespace as BaseNamespace
10
10
  from ..types import ApiListResponse
11
11
  from ..utils import truncate_string
@@ -23,12 +23,9 @@ class Operation(BaseOperation):
23
23
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
24
24
  pass
25
25
 
26
- def run(self, args: Namespace) -> None:
27
- api = get_api(args)
26
+ def run(self, api: ApiClient, _) -> None:
28
27
  resumes: ApiListResponse = api.get("/resumes/mine")
29
- t = PrettyTable(
30
- field_names=["ID", "Название", "Статус"], align="l", valign="t"
31
- )
28
+ t = PrettyTable(field_names=["ID", "Название", "Статус"], align="l", valign="t")
32
29
  t.add_rows(
33
30
  [
34
31
  (
@@ -2,7 +2,7 @@
2
2
  import argparse
3
3
  import logging
4
4
 
5
- from ..api import ApiError, OAuthClient
5
+ from ..api import ApiError, ApiClient, OAuthClient
6
6
  from ..main import BaseOperation
7
7
  from ..main import Namespace as BaseNamespace
8
8
  from ..utils import print_err
@@ -20,22 +20,11 @@ class Operation(BaseOperation):
20
20
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
21
21
  pass
22
22
 
23
- def run(self, args: Namespace) -> None:
24
- if (
25
- not args.config["token"]
26
- or not args.config["token"]["refresh_token"]
27
- ):
28
- print_err("❗ Необходим refresh_token!")
29
- return 1
23
+ def run(self, api: ApiClient, args: Namespace) -> None:
30
24
  try:
31
- oauth = OAuthClient(
32
- user_agent=(
33
- args.config["oauth_user_agent"]
34
- or args.config["user_agent"]
35
- ),
36
- )
37
- token = oauth.refresh_access(args.config["token"]["refresh_token"])
38
- args.config.save(token=token)
25
+ oauth: OAuthClient = api.oauth_client
26
+ token = oauth.refresh_access(api.refresh_token)
27
+ api.handle_access_token(token)
39
28
  print("✅ Токен обновлен!")
40
29
  except ApiError as ex:
41
30
  print_err("❗ Ошибка:", ex)
@@ -4,10 +4,9 @@ import random
4
4
  import time
5
5
  from typing import Tuple
6
6
 
7
- from ..api import ApiError
7
+ from ..api import ApiError, ApiClient
8
8
  from ..main import BaseOperation
9
9
  from ..main import Namespace as BaseNamespace
10
- from ..main import get_api
11
10
  from ..mixins import GetResumeIdMixin
12
11
  from ..utils import parse_interval, random_text
13
12
 
@@ -67,8 +66,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
67
66
  action=argparse.BooleanOptionalAction,
68
67
  )
69
68
 
70
- def run(self, args: Namespace) -> None:
71
- self.api = get_api(args)
69
+ def run(self, api: ApiClient, args: Namespace) -> None:
70
+ self.api = api
72
71
  self.resume_id = self._get_resume_id()
73
72
  self.reply_min_interval, self.reply_max_interval = args.reply_interval
74
73
  self.reply_message = args.reply_message or args.config["reply_message"]
@@ -160,10 +159,12 @@ class Operation(BaseOperation, GetResumeIdMixin):
160
159
  print("💼", message_placeholders["vacancy_name"])
161
160
  print("📅", vacancy["created_at"])
162
161
  if salary:
163
- salary_from = salary.get("from")or "-"
164
- salary_to = salary.get("to")or "-"
162
+ salary_from = salary.get("from") or "-"
163
+ salary_to = salary.get("to") or "-"
165
164
  salary_currency = salary.get("currency")
166
- print("💵 от", salary_from, "до", salary_to, salary_currency)
165
+ print(
166
+ "💵 от", salary_from, "до", salary_to, salary_currency
167
+ )
167
168
  print("")
168
169
  print("Последние сообщения:")
169
170
  for msg in (
@@ -3,7 +3,7 @@ import argparse
3
3
  import logging
4
4
 
5
5
  from ..api import ApiClient, ApiError
6
- from ..main import BaseOperation, get_api
6
+ from ..main import BaseOperation
7
7
  from ..main import Namespace as BaseNamespace
8
8
  from ..types import ApiListResponse
9
9
  from ..utils import print_err, truncate_string
@@ -21,8 +21,7 @@ class Operation(BaseOperation):
21
21
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
22
22
  pass
23
23
 
24
- def run(self, args: Namespace) -> None:
25
- api = get_api(args)
24
+ def run(self, api: ApiClient, args: Namespace) -> None:
26
25
  resumes: ApiListResponse = api.get("/resumes/mine")
27
26
  for resume in resumes["items"]:
28
27
  try:
@@ -4,7 +4,7 @@ import json
4
4
  import logging
5
5
 
6
6
  from ..api import ApiClient
7
- from ..main import BaseOperation, get_api
7
+ from ..main import BaseOperation
8
8
  from ..main import Namespace as BaseNamespace
9
9
 
10
10
  logger = logging.getLogger(__package__)
@@ -20,7 +20,6 @@ class Operation(BaseOperation):
20
20
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
21
21
  pass
22
22
 
23
- def run(self, args: Namespace) -> None:
24
- api = get_api(args)
23
+ def run(self, api: ApiClient, args: Namespace) -> None:
25
24
  result = api.get("/me")
26
25
  print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True))
@@ -9,6 +9,7 @@ from urllib.parse import urljoin
9
9
 
10
10
  import requests
11
11
 
12
+ # Сертификат на сервере давно истек, но его обновлять мне лень...
12
13
  warnings.filterwarnings("ignore", message="Unverified HTTPS request")
13
14
 
14
15
  logger = logging.getLogger(__package__)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "hh-applicant-tool"
3
- version = "0.5.6"
3
+ version = "0.5.8"
4
4
  description = ""
5
5
  authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
6
6
  readme = "README.md"
@@ -29,3 +29,10 @@ build-backend = "poetry.core.masonry.api"
29
29
 
30
30
  [tool.poetry.scripts]
31
31
  hh-applicant-tool = "hh_applicant_tool.main:main"
32
+
33
+ # Он заебал агриться на старый код, который был написан до появления агрессивной
34
+ # проверки типов в pyright
35
+ [tool.pyright]
36
+ # https://github.com/microsoft/pyright/blob/main/docs/configuration.md
37
+ # Ошибки показывать он не бросит, но заебывать перестанет
38
+ typeCheckingMode = "off"
@@ -1,12 +0,0 @@
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 = (
4
- "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
5
- )
6
-
7
- ANDROID_CLIENT_SECRET = (
8
- "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
9
- )
10
-
11
- # Кривой формат, который используют эти долбоебы
12
- INVALID_ISO8601_FORMAT = "%Y-%m-%dT%H:%M:%S%z"