hh-applicant-tool 0.5.7__py3-none-any.whl → 0.5.8__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.

Potentially problematic release.


This version of hh-applicant-tool might be problematic. Click here for more details.

@@ -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,12 +117,7 @@ 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)
@@ -125,11 +130,7 @@ class BaseClient:
125
130
  delete = partialmethod(request, "DELETE")
126
131
 
127
132
  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
- )
133
+ return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
133
134
 
134
135
  @staticmethod
135
136
  def raise_for_status(response: Response, data: dict) -> None:
@@ -137,6 +138,8 @@ class BaseClient:
137
138
  case 301 | 302:
138
139
  raise errors.Redirect(response, data)
139
140
  case 400:
141
+ if errors.ApiError.is_limit_exceeded(data):
142
+ raise errors.LimitExceeded(response=response, data=data)
140
143
  raise errors.BadRequest(response, data)
141
144
  case 403:
142
145
  raise errors.Forbidden(response, data)
@@ -152,8 +155,8 @@ class BaseClient:
152
155
 
153
156
  @dataclass
154
157
  class OAuthClient(BaseClient):
155
- client_id: str = ANDROID_CLIENT_ID
156
- client_secret: str = ANDROID_CLIENT_SECRET
158
+ client_id: str
159
+ client_secret: str
157
160
  _: dataclasses.KW_ONLY
158
161
  base_url: str = "https://hh.ru/oauth"
159
162
  state: str = ""
@@ -172,6 +175,16 @@ class OAuthClient(BaseClient):
172
175
  params_qs = urlencode({k: v for k, v in params.items() if v})
173
176
  return self.resolve_url(f"/authorize?{params_qs}")
174
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
+
175
188
  def authenticate(self, code: str) -> AccessToken:
176
189
  params = {
177
190
  "client_id": self.client_id,
@@ -179,11 +192,11 @@ class OAuthClient(BaseClient):
179
192
  "code": code,
180
193
  "grant_type": "authorization_code",
181
194
  }
182
- return self.post("/token", params)
195
+ return self.request_access_token("/token", params)
183
196
 
184
197
  def refresh_access(self, refresh_token: str) -> AccessToken:
185
198
  # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
186
- return self.post(
199
+ return self.request_access_token(
187
200
  "/token", grant_type="refresh_token", refresh_token=refresh_token
188
201
  )
189
202
 
@@ -193,32 +206,66 @@ class ApiClient(BaseClient):
193
206
  # Например, для просмотра информации о компании токен не нужен
194
207
  access_token: str | None = None
195
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
196
212
  _: dataclasses.KW_ONLY
197
213
  base_url: str = "https://api.hh.ru/"
198
- # oauth_client: OAuthClient | None = None
199
214
 
200
- # def __post_init__(self) -> None:
201
- # super().__post_init__()
202
- # self.oauth_client = self.oauth_client or OAuthClient(
203
- # session=self.session
204
- # )
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
+ )
205
226
 
206
227
  def additional_headers(
207
228
  self,
208
229
  ) -> dict[str, str]:
209
230
  return (
210
- {"Authorization": f"Bearer {self.access_token}"}
231
+ {"authorization": f"Bearer {self.access_token}"}
211
232
  if self.access_token
212
233
  else {}
213
234
  )
214
235
 
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
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):
@@ -1,12 +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"
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
2
 
3
- ANDROID_CLIENT_ID = (
4
- "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
5
- )
3
+ ANDROID_CLIENT_ID = "HIOMIAS39CA9DICTA7JIO64LQKQJF5AGIK74G9ITJKLNEDAOH5FHS5G1JI7FOEGD"
6
4
 
7
5
  ANDROID_CLIENT_SECRET = (
8
6
  "V9M870DE342BGHFRUJ5FTCGCUA1482AN0DI8C5TFI9ULMA89H10N60NOP8I4JMVS"
9
7
  )
10
8
 
9
+ # Используется для прямой авторизации. Этот способ мной не используется, так как
10
+ # для отображения капчи все равно нужен webview.
11
+ # K811HJNKQA8V1UN53I6PN1J1CMAD2L1M3LU6LPAU849BCT031KDSSM485FDPJ6UF
12
+
11
13
  # Кривой формат, который используют эти долбоебы
12
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
 
hh_applicant_tool/main.py CHANGED
@@ -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
  Metadata-Version: 2.3
2
2
  Name: hh-applicant-tool
3
- Version: 0.5.7
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)]()
@@ -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
@@ -0,0 +1,31 @@
1
+ hh_applicant_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ hh_applicant_tool/__main__.py,sha256=cwKJAAML0RRKT9Qbzcwf07HHcuSd8oh7kx4P1apndWQ,84
3
+ hh_applicant_tool/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ hh_applicant_tool/ai/blackbox.py,sha256=vqkEpsX7q7bgX49dmmifYJmrbuz_WBg5u9M9J9XdQlI,1670
5
+ hh_applicant_tool/api/__init__.py,sha256=kgFSHibRaAugN2BA3U1djEa20qgKJUUVouwJzjEB0DU,84
6
+ hh_applicant_tool/api/client.py,sha256=hQyqze4Bq45AlYQU07_NvQNF-Jtv0CGmue0jBH7bsv4,9443
7
+ hh_applicant_tool/api/errors.py,sha256=Rd1XE2OTtZDa3GDqght2LtOnTHWtOx7Zsow87nn4x4A,1807
8
+ hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3s,823
9
+ hh_applicant_tool/constants.py,sha256=KV_jowi21ToMp8yqF1vWolnVZb8nAC3rYRkcFJ71m-Q,759
10
+ hh_applicant_tool/jsonc.py,sha256=QNS4gRHfi7SAeOFnffAIuhH7auC4Y4HAkmH12eX5PkI,4002
11
+ hh_applicant_tool/main.py,sha256=VhzJZ1pzTCwMSZbUHIjFAkkVO8hb-2VWHF81HVjc25g,4963
12
+ hh_applicant_tool/mixins.py,sha256=66LmyYSsDfhrpUwoAONjzrd5aoXqaZVoQ-zXhyYbYMk,418
13
+ hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ hh_applicant_tool/operations/apply_similar.py,sha256=iL4vVYVKLUCkIUMG_PZkQbZVt-jL8K5cX2r38xF67YM,16399
15
+ hh_applicant_tool/operations/authorize.py,sha256=NwhT16o4W7r0SoXlqcOVYsASpP5l8UFw6-T6VhuPX3U,3348
16
+ hh_applicant_tool/operations/call_api.py,sha256=-k4Vkmc5XbrvjfW4-0m25godmGaX-8K9i5oXPLIq-X8,1304
17
+ hh_applicant_tool/operations/clear_negotiations.py,sha256=JUqr3ualnLEU8kfxEcsS6xOrkUBNLsyXl8E7aVA_DLU,4283
18
+ hh_applicant_tool/operations/config.py,sha256=K7-F-cmLMZLz1pTjyLaBquTaZZ6duniDTftBE9JbJ3o,962
19
+ hh_applicant_tool/operations/get_employer_contacts.py,sha256=KWnJ85vu2vrXHPsZPDAKZCnKDLgGeiuxuHBpt1Ji_l0,3514
20
+ hh_applicant_tool/operations/list_resumes.py,sha256=Mdv4fQE7_U6e2hWFUmwnX2BMGVBMu_Ry85zparLADig,1077
21
+ hh_applicant_tool/operations/refresh_token.py,sha256=0u5vpYX7UgriUBoySaAa1ls-ejjRWJw0buSSw4MGBEo,927
22
+ hh_applicant_tool/operations/reply_employers.py,sha256=BrzglK5LGbOVN-qWW3DxUnEm6YvBK-Whp_NU1SOBa7c,8716
23
+ hh_applicant_tool/operations/update_resumes.py,sha256=gNRfRcrhTVs7F1vlR-cjQVhQ9G-1KOH2I3la53NWvPE,1028
24
+ hh_applicant_tool/operations/whoami.py,sha256=PHpTLbJhBN5Lo4V1b1pR6vMXiFiuZc82zPkp-nRUy04,693
25
+ hh_applicant_tool/telemetry_client.py,sha256=EZ81tZS_dnZ5oBtjUrUvxIL8_idhbkX52XHx3N3_i3w,3377
26
+ hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
27
+ hh_applicant_tool/utils.py,sha256=3T4A2AykGqTwtGAttmYplIjHwFl3pNAcbWIVuA-OheQ,3080
28
+ hh_applicant_tool-0.5.8.dist-info/METADATA,sha256=DUx-cE_2ISFQBhyFVskoFkGjTh1gGZ3VCmIvBZYKqKQ,19993
29
+ hh_applicant_tool-0.5.8.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
30
+ hh_applicant_tool-0.5.8.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
31
+ hh_applicant_tool-0.5.8.dist-info/RECORD,,
@@ -1,31 +0,0 @@
1
- hh_applicant_tool/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- hh_applicant_tool/__main__.py,sha256=cwKJAAML0RRKT9Qbzcwf07HHcuSd8oh7kx4P1apndWQ,84
3
- hh_applicant_tool/ai/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- hh_applicant_tool/ai/blackbox.py,sha256=vqkEpsX7q7bgX49dmmifYJmrbuz_WBg5u9M9J9XdQlI,1670
5
- hh_applicant_tool/api/__init__.py,sha256=kgFSHibRaAugN2BA3U1djEa20qgKJUUVouwJzjEB0DU,84
6
- hh_applicant_tool/api/client.py,sha256=S6T2_QenuM3VsjzPpFuUduQP8ReMMSGynWZUmewlQ5s,7353
7
- hh_applicant_tool/api/errors.py,sha256=0SoWKnsSUA0K2YgQA8GwGhe-pRMwtfK94MR6_MgbORQ,1722
8
- hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3s,823
9
- hh_applicant_tool/constants.py,sha256=lpgKkP2chWgTnBXvzxbSPWpKcfzp8VxMTlkssFcQhH4,469
10
- hh_applicant_tool/jsonc.py,sha256=Vni5ksSgoJBBNApS9EyMzD2jVUxYMFa2q1f8bUPH2OY,3791
11
- hh_applicant_tool/main.py,sha256=j4kG7H0qxJebGwVs3fjc4udOk7rVvffsB2114UgI9e8,4776
12
- hh_applicant_tool/mixins.py,sha256=66LmyYSsDfhrpUwoAONjzrd5aoXqaZVoQ-zXhyYbYMk,418
13
- hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- hh_applicant_tool/operations/apply_similar.py,sha256=IYpf8PabOJF6vmbsKYir1NgW0Y2XviBanIGjsbz9AD4,16649
15
- hh_applicant_tool/operations/authorize.py,sha256=e0V3uo9daCa4zZDb4vxoiQqtmSIRV04wzGSqCN280q0,3599
16
- hh_applicant_tool/operations/call_api.py,sha256=qwPDrWP9tiqHkaYYWYBZtymj9AaxObB86Eny-Bf5q_c,1314
17
- hh_applicant_tool/operations/clear_negotiations.py,sha256=GEsL1snxz8TJ3i7Nz6LkP4FZOzRBdAofX3xf3Ywj64k,4369
18
- hh_applicant_tool/operations/config.py,sha256=PJpPPDJaQwqyZ_T55Mlf0oAoeNeMUG_yJoz605XZGxI,959
19
- hh_applicant_tool/operations/get_employer_contacts.py,sha256=7BEzEnNAp87RlOP6HX0LR6cbtud2FuKCKK5sq6fINq8,4066
20
- hh_applicant_tool/operations/list_resumes.py,sha256=XBrVFTnl45BUtuvjVm70h_CXZrOvAULnLkLkyUh7gxw,1134
21
- hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
22
- hh_applicant_tool/operations/reply_employers.py,sha256=_kINrCKAPQBIjHSbeI9I5ycNPkSyvMTMDFrM7B-6lcA,8662
23
- hh_applicant_tool/operations/update_resumes.py,sha256=gGxMYMoT9GqJjwn4AgrOAEJCZu4sdhaV0VmPr3jG-q8,1049
24
- hh_applicant_tool/operations/whoami.py,sha256=sg0r7m6oKkpMEmGt4QdtYdS-1gf-1KKdnk32yblbRJs,714
25
- hh_applicant_tool/telemetry_client.py,sha256=wYLbKnx3sOmESFHqjLt-0Gww1O3lJiXFYdWnsorIhK8,3261
26
- hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
27
- hh_applicant_tool/utils.py,sha256=3T4A2AykGqTwtGAttmYplIjHwFl3pNAcbWIVuA-OheQ,3080
28
- hh_applicant_tool-0.5.7.dist-info/METADATA,sha256=4KSWg3IUcn4Pz64j35b91EtuNxnoGi_57KVqZTbLHQw,20931
29
- hh_applicant_tool-0.5.7.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
30
- hh_applicant_tool-0.5.7.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
31
- hh_applicant_tool-0.5.7.dist-info/RECORD,,