hh-applicant-tool 0.6.12__py3-none-any.whl → 0.7.10__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.
@@ -17,17 +17,18 @@ class OpenAIChat:
17
17
  token: str,
18
18
  model: str,
19
19
  system_prompt: str,
20
- proxies: dict[str, str] = {}
20
+ proxies: dict[str, str] | None = None,
21
+ session: requests.Session | None = None
21
22
  ):
22
23
  self.token = token
23
24
  self.model = model
24
25
  self.system_prompt = system_prompt
25
26
  self.proxies = proxies
27
+ self.session = session or requests.session()
26
28
 
27
29
  def default_headers(self) -> dict[str, str]:
28
30
  return {
29
31
  "Authorization": f"Bearer {self.token}",
30
- "Content-Type": "application/json",
31
32
  }
32
33
 
33
34
  def send_message(self, message: str) -> str:
@@ -49,7 +50,7 @@ class OpenAIChat:
49
50
  }
50
51
 
51
52
  try:
52
- response = requests.post(
53
+ response = self.session.post(
53
54
  self.chat_endpoint,
54
55
  json=payload,
55
56
  headers=self.default_headers(),
@@ -59,9 +60,12 @@ class OpenAIChat:
59
60
  response.raise_for_status()
60
61
 
61
62
  data = response.json()
63
+ if 'error' in data:
64
+ raise OpenAIError(data['error']['message'])
65
+
62
66
  assistant_message = data["choices"][0]["message"]["content"]
63
67
 
64
68
  return assistant_message
65
69
 
66
70
  except requests.exceptions.RequestException as ex:
67
- raise OpenAIError(f"OpenAI API Error: {str(ex)}") from ex
71
+ raise OpenAIError(str(ex)) from ex
@@ -3,22 +3,16 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import json
5
5
  import logging
6
- import uuid
7
6
  import time
8
7
  from dataclasses import dataclass
9
- from functools import partialmethod
8
+ from functools import cached_property
10
9
  from threading import Lock
11
10
  from typing import Any, Literal
12
11
  from urllib.parse import urlencode
13
- from functools import cached_property
14
- import random
12
+
15
13
  import requests
16
14
  from requests import Response, Session
17
15
 
18
- from ..constants import (
19
- ANDROID_CLIENT_ID,
20
- ANDROID_CLIENT_SECRET,
21
- )
22
16
  from ..types import AccessToken
23
17
  from . import errors
24
18
 
@@ -35,7 +29,6 @@ ALLOWED_METHODS = Literal["GET", "POST", "PUT", "DELETE"]
35
29
  class BaseClient:
36
30
  base_url: str
37
31
  _: dataclasses.KW_ONLY
38
- # TODO: сделать генерацию User-Agent'а как в приложении
39
32
  user_agent: str | None = None
40
33
  proxies: dict | None = None
41
34
  session: Session | None = None
@@ -45,26 +38,16 @@ class BaseClient:
45
38
  def __post_init__(self) -> None:
46
39
  self.lock = Lock()
47
40
  if not self.session:
48
- self.session = session = requests.session()
49
- session.headers.update(
50
- {
51
- "user-agent": self.user_agent or self.default_user_agent(),
52
- "x-hh-app-active": "true",
53
- **self.additional_headers(),
54
- }
55
- )
56
- logger.debug("Default Headers: %r", session.headers)
57
-
58
- def default_user_agent(self) -> str:
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()})"
41
+ self.session = requests.session()
42
+ if self.proxies:
43
+ logger.debug(f"client proxies: {self.proxies}")
67
44
 
45
+ def default_headers(self) -> dict[str, str]:
46
+ return {
47
+ "user-agent": self.user_agent or "Mozilla/5.0",
48
+ "x-hh-app-active": "true",
49
+ }
50
+
68
51
  def additional_headers(
69
52
  self,
70
53
  ) -> dict[str, str]:
@@ -94,34 +77,44 @@ class BaseClient:
94
77
  time.sleep(delay)
95
78
  has_body = method in ["POST", "PUT"]
96
79
  payload = {"data" if has_body else "params": params}
80
+ headers = self.default_headers() | self.additional_headers()
81
+ logger.debug(f"request info: {method = }, {url = }, {headers = }, params = {repr(params)[:255]}")
97
82
  response = self.session.request(
98
83
  method,
99
84
  url,
100
85
  **payload,
86
+ headers=headers,
101
87
  proxies=self.proxies,
102
88
  allow_redirects=False,
103
89
  )
104
90
  try:
105
- # У этих лошков сервер не отдает Content-Length, а кривое API отдает пустые ответы, например, при отклике на вакансии, и мы не можем узнать содержит ли ответ тело
91
+ # У этих лошков сервер не отдает Content-Length, а кривое API
92
+ # отдает пустые ответы, например, при отклике на вакансии,
93
+ # и мы не можем узнать содержит ли ответ тело
106
94
  # 'Server': 'ddos-guard'
107
95
  # ...
108
96
  # 'Transfer-Encoding': 'chunked'
109
97
  try:
110
- rv = response.json()
111
- except json.decoder.JSONDecodeError:
112
- # if response.status_code not in [201, 204]:
113
- # raise
114
- rv = {}
98
+ rv = response.json() if response.text else {}
99
+ except json.decoder.JSONDecodeError as ex:
100
+ raise errors.BadResponse(
101
+ f"Can't decode JSON: {method} {url} ({response.status_code})"
102
+ ) from ex
115
103
  finally:
104
+ log_url = url
105
+ if not has_body and params:
106
+ log_url += "?" + urlencode(params)
116
107
  logger.debug(
117
- "%d %-6s %s",
118
- response.status_code,
108
+ "%s %s: %d",
119
109
  method,
120
- url + ("?" + urlencode(params) if not has_body and params else ""),
110
+ log_url,
111
+ response.status_code,
121
112
  )
122
113
  self.previous_request_time = time.monotonic()
123
114
  self.raise_for_status(response, rv)
124
- assert 300 > response.status_code >= 200
115
+ assert 300 > response.status_code >= 200, (
116
+ f"Unexpected status code for {method} {url}: {response.status_code}"
117
+ )
125
118
  return rv
126
119
 
127
120
  def get(self, *args, **kwargs):
@@ -202,9 +195,12 @@ class OAuthClient(BaseClient):
202
195
  return self.request_access_token("/token", params)
203
196
 
204
197
  def refresh_access_token(self, refresh_token: str) -> AccessToken:
205
- # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
198
+ # refresh_token можно использовать только один раз и только по
199
+ # истечению срока действия access_token.
206
200
  return self.request_access_token(
207
- "/token", grant_type="refresh_token", refresh_token=refresh_token
201
+ "/token",
202
+ grant_type="refresh_token",
203
+ refresh_token=refresh_token,
208
204
  )
209
205
 
210
206
 
@@ -214,31 +210,33 @@ class ApiClient(BaseClient):
214
210
  access_token: str | None = None
215
211
  refresh_token: str | None = None
216
212
  access_expires_at: int = 0
217
- client_id: str = ANDROID_CLIENT_ID
218
- client_secret: str = ANDROID_CLIENT_SECRET
219
213
  _: dataclasses.KW_ONLY
214
+ client_id: str | None = None
215
+ client_secret: str | None = None
220
216
  base_url: str = "https://api.hh.ru/"
221
217
 
222
218
  @property
223
219
  def is_access_expired(self) -> bool:
224
- return time.time() > self.access_expires_at
220
+ return time.time() >= (self.access_expires_at or 0)
225
221
 
226
222
  @cached_property
227
223
  def oauth_client(self) -> OAuthClient:
228
224
  return OAuthClient(
229
225
  client_id=self.client_id,
230
226
  client_secret=self.client_secret,
227
+ user_agent=self.user_agent,
228
+ proxies=dict(self.proxies or {}),
231
229
  session=self.session,
232
230
  )
233
231
 
234
232
  def additional_headers(
235
233
  self,
236
234
  ) -> dict[str, str]:
237
- return (
238
- {"authorization": f"Bearer {self.access_token}"}
239
- if self.access_token
240
- else {}
241
- )
235
+ if not self.access_token:
236
+ return {}
237
+ # Это очень интересно, что access token'ы начинаются с USER, т.е. API может содержать какую-то уязвимость, связанную с этим
238
+ assert self.access_token.startswith("USER")
239
+ return {"authorization": f"Bearer {self.access_token}"}
242
240
 
243
241
  # Реализовано автоматическое обновление токена
244
242
  def request(
@@ -258,14 +256,14 @@ class ApiClient(BaseClient):
258
256
  except errors.Forbidden as ex:
259
257
  if not self.is_access_expired or not self.refresh_token:
260
258
  raise ex
261
- logger.info("try refresh access_token")
259
+ logger.info("try to refresh access_token")
262
260
  # Пробуем обновить токен
263
261
  self.refresh_access_token()
264
262
  # И повторно отправляем запрос
265
263
  return do_request()
266
264
 
267
265
  def handle_access_token(self, token: AccessToken) -> None:
268
- for field in ["access_token", "refresh_token", "access_expires_at"]:
266
+ for field in ("access_token", "refresh_token", "access_expires_at"):
269
267
  if field in token and hasattr(self, field):
270
268
  setattr(self, field, token[field])
271
269
 
@@ -5,6 +5,7 @@ from requests import Request, Response
5
5
  from requests.adapters import CaseInsensitiveDict
6
6
 
7
7
  __all__ = (
8
+ "BadResponse",
8
9
  "ApiError",
9
10
  "BadGateway",
10
11
  "BadRequest",
@@ -16,7 +17,11 @@ __all__ = (
16
17
  )
17
18
 
18
19
 
19
- class ApiError(Exception):
20
+ class BadResponse(Exception):
21
+ pass
22
+
23
+
24
+ class ApiError(BadResponse):
20
25
  def __init__(self, response: Response, data: dict[str, Any]) -> None:
21
26
  self._response = response
22
27
  self._raw = data
@@ -1,6 +1,18 @@
1
1
  import enum
2
2
  import logging
3
3
  from enum import auto
4
+ import os, sys
5
+
6
+
7
+ if sys.platform == "win32":
8
+ import ctypes
9
+ kernel32 = ctypes.windll.kernel32
10
+ # 0x0004 = ENABLE_VIRTUAL_TERMINAL_PROCESSING
11
+ # Берем дескриптор стандартного вывода (stdout)
12
+ handle = kernel32.GetStdHandle(-11)
13
+ mode = ctypes.c_uint()
14
+ kernel32.GetConsoleMode(handle, ctypes.byref(mode))
15
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
4
16
 
5
17
 
6
18
  class Color(enum.Enum):
hh_applicant_tool/main.py CHANGED
@@ -11,8 +11,9 @@ from typing import Literal, Sequence
11
11
 
12
12
  from .api import ApiClient
13
13
  from .color_log import ColorHandler
14
+ from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
14
15
  from .telemetry_client import TelemetryClient
15
- from .utils import Config, get_config_path
16
+ from .utils import Config, android_user_agent, get_config_path
16
17
 
17
18
  DEFAULT_CONFIG_PATH = (
18
19
  get_config_path() / (__package__ or "").replace("_", "-") / "config.json"
@@ -24,7 +25,7 @@ logger = logging.getLogger(__package__)
24
25
  class BaseOperation:
25
26
  def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
26
27
 
27
- def run(self, args: argparse.Namespace) -> None | int:
28
+ def run(self, args: argparse.Namespace, api_client: ApiClient, telemetry_client: TelemetryClient) -> None | int:
28
29
  raise NotImplementedError()
29
30
 
30
31
 
@@ -40,21 +41,38 @@ class Namespace(argparse.Namespace):
40
41
  disable_telemetry: bool
41
42
 
42
43
 
43
- def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
44
- return {
45
- "http": args.config["proxy_url"] or getenv("HTTP_PROXY"),
46
- "https": args.config["proxy_url"] or getenv("HTTPS_PROXY"),
47
- }
44
+ def get_proxies(args: Namespace) -> dict[str, str]:
45
+ proxy_url = args.proxy_url or args.config.get("proxy_url")
46
+
47
+ if proxy_url:
48
+ return {
49
+ "http": proxy_url,
50
+ "https": proxy_url,
51
+ }
52
+
53
+ proxies = {}
54
+ http_env = getenv("HTTP_PROXY") or getenv("http_proxy")
55
+ https_env = getenv("HTTPS_PROXY") or getenv("https_proxy") or http_env
56
+
57
+ if http_env:
58
+ proxies["http"] = http_env
59
+ if https_env:
60
+ proxies["https"] = https_env
61
+
62
+ return proxies
48
63
 
49
64
 
50
65
  def get_api_client(args: Namespace) -> ApiClient:
51
- token = args.config.get("token", {})
66
+ config = args.config
67
+ token = config.get("token", {})
52
68
  api = ApiClient(
69
+ client_id=config.get("client_id", ANDROID_CLIENT_ID),
70
+ client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
53
71
  access_token=token.get("access_token"),
54
72
  refresh_token=token.get("refresh_token"),
55
73
  access_expires_at=token.get("access_expires_at"),
56
74
  delay=args.delay,
57
- user_agent=args.config["user_agent"],
75
+ user_agent=config["user_agent"] or android_user_agent(),
58
76
  proxies=get_proxies(args),
59
77
  )
60
78
  return api
@@ -65,7 +83,7 @@ class HHApplicantTool:
65
83
 
66
84
  Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
67
85
 
68
- Группа поддержки: <https://t.me/otzyvy_headhunter>
86
+ Группа поддержки: <https://t.me/hh_applicant_tool>
69
87
  """
70
88
 
71
89
  class ArgumentFormatter(
@@ -115,8 +133,22 @@ class HHApplicantTool:
115
133
  for _, module_name, _ in iter_modules([str(package_dir)]):
116
134
  mod = import_module(f"{__package__}.{OPERATIONS}.{module_name}")
117
135
  op: BaseOperation = mod.Operation()
136
+ # 1. Разбиваем имя модуля на части
137
+ words = module_name.split("_")
138
+
139
+ # 2. Формируем варианты имен
140
+ kebab_name = "-".join(words) # call-api
141
+
142
+ # camelCase: первое слово маленькими, остальные с большой
143
+ camel_case_name = words[0] + "".join(word.title() for word in words[1:])
144
+
145
+ # flatcase: всё слитно и в нижнем регистре
146
+ flat_name = "".join(words) # callapi
147
+
118
148
  op_parser = subparsers.add_parser(
119
- module_name.replace("_", "-"),
149
+ kebab_name,
150
+ # Добавляем остальные варианты в псевдонимы
151
+ aliases=[camel_case_name, flat_name],
120
152
  description=op.__doc__,
121
153
  formatter_class=self.ArgumentFormatter,
122
154
  )
@@ -154,7 +186,7 @@ class HHApplicantTool:
154
186
  logger.warning("Interrupted by user")
155
187
  return 1
156
188
  except Exception as e:
157
- logger.exception(e)
189
+ logger.exception(e, exc_info=log_level <= logging.DEBUG)
158
190
  return 1
159
191
  parser.print_help(file=sys.stderr)
160
192
  return 2
@@ -4,11 +4,12 @@ import random
4
4
  import time
5
5
  from collections import defaultdict
6
6
  from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
7
8
  from typing import Any, TextIO
8
9
 
9
10
  from ..ai.blackbox import BlackboxChat
10
11
  from ..ai.openai import OpenAIChat
11
- from ..api import ApiClient, ApiError
12
+ from ..api import ApiClient, BadResponse
12
13
  from ..api.errors import LimitExceeded
13
14
  from ..main import BaseOperation
14
15
  from ..main import Namespace as BaseNamespace
@@ -29,6 +30,7 @@ logger = logging.getLogger(__package__)
29
30
  class Namespace(BaseNamespace):
30
31
  resume_id: str | None
31
32
  message_list: TextIO
33
+ ignore_employers: Path | None
32
34
  force_message: bool
33
35
  use_ai: bool
34
36
  pre_prompt: str
@@ -86,6 +88,12 @@ class Operation(BaseOperation, GetResumeIdMixin):
86
88
  help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
87
89
  type=argparse.FileType("r", encoding="utf-8", errors="replace"),
88
90
  )
91
+ parser.add_argument(
92
+ "--ignore-employers",
93
+ help="Путь к файлу со списком ID игнорируемых работодателей (по одному ID на строку)",
94
+ type=Path,
95
+ default=None,
96
+ )
89
97
  parser.add_argument(
90
98
  "-f",
91
99
  "--force-message",
@@ -192,8 +200,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
192
200
  )
193
201
  parser.add_argument(
194
202
  "--no-magic",
195
- default=False,
196
- action=argparse.BooleanOptionalAction,
203
+ action="store_true",
197
204
  help="Отключить авторазбор текста запроса",
198
205
  )
199
206
  parser.add_argument(
@@ -235,6 +242,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
235
242
  self.telemetry_client = telemetry_client
236
243
  self.resume_id = args.resume_id or self._get_resume_id()
237
244
  self.application_messages = self._get_application_messages(args.message_list)
245
+ self.ignored_employers = self._get_ignored_employers(args.ignore_employers)
238
246
  self.chat = None
239
247
 
240
248
  if config := args.config.get("blackbox"):
@@ -305,6 +313,16 @@ class Operation(BaseOperation, GetResumeIdMixin):
305
313
  ]
306
314
  return application_messages
307
315
 
316
+ def _get_ignored_employers(self, file_path: Path | None) -> set[str]:
317
+ ignored = set()
318
+ if file_path is not None:
319
+ with file_path.open("r", encoding="utf-8") as f:
320
+ for line in f:
321
+ if clean_id := line.strip():
322
+ ignored.add(int(clean_id))
323
+ logger.info("Загружено %d ID игнорируемых работодателей", len(ignored))
324
+ return ignored
325
+
308
326
  def _apply_similar(self) -> None:
309
327
  telemetry_client = self.telemetry_client
310
328
  telemetry_data = defaultdict(dict)
@@ -346,7 +364,6 @@ class Operation(BaseOperation, GetResumeIdMixin):
346
364
  }
347
365
 
348
366
  do_apply = True
349
- complained_employers = set()
350
367
 
351
368
  for vacancy in vacancies:
352
369
  try:
@@ -376,13 +393,13 @@ class Operation(BaseOperation, GetResumeIdMixin):
376
393
  continue
377
394
 
378
395
  relations = vacancy.get("relations", [])
379
- employer_id = vacancy.get("employer", {}).get("id")
396
+ employer_id = int(vacancy.get("employer", {}).get("id", 0))
380
397
 
381
398
  if (
382
399
  self.enable_telemetry
383
400
  and employer_id
384
401
  and employer_id not in telemetry_data["employers"]
385
- and employer_id not in complained_employers
402
+ and employer_id not in self.ignored_employers
386
403
  and (
387
404
  not relations
388
405
  or parse_invalid_datetime(vacancy["created_at"])
@@ -405,7 +422,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
405
422
  % employer_id
406
423
  )
407
424
 
408
- complained_employers.add(employer_id)
425
+ self.ignored_employers.add(employer_id)
409
426
 
410
427
  elif do_apply:
411
428
  telemetry_data["employers"][employer_id] = employer_data
@@ -474,7 +491,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
474
491
  except LimitExceeded:
475
492
  print("⚠️ Достигли лимита рассылки")
476
493
  do_apply = False
477
- except ApiError as ex:
494
+ except BadResponse as ex:
478
495
  logger.error(ex)
479
496
 
480
497
  print("📝 Отклики на вакансии разосланы!")
@@ -582,4 +599,3 @@ class Operation(BaseOperation, GetResumeIdMixin):
582
599
  time.sleep(interval)
583
600
 
584
601
  return rv
585
-
@@ -5,17 +5,26 @@ import sys
5
5
  from typing import Any
6
6
  from ..utils import print_err
7
7
 
8
+ from ..api import ApiClient # noqa: E402
9
+ from ..main import BaseOperation, Namespace # noqa: E402
10
+
11
+ HH_ANDROID_SCHEME = "hhandroid"
12
+
13
+ logger = logging.getLogger(__package__)
8
14
 
9
15
  QT_IMPORTED = False
10
16
 
11
17
  try:
12
18
  from PyQt6.QtCore import QUrl
13
19
  from PyQt6.QtWidgets import QApplication, QMainWindow
20
+ from PyQt6.QtWebEngineCore import QWebEngineUrlScheme
14
21
  from PyQt6.QtWebEngineCore import QWebEngineUrlSchemeHandler
15
22
  from PyQt6.QtWebEngineWidgets import QWebEngineView
23
+ from PyQt6.QtWebEngineCore import QWebEngineSettings
16
24
 
17
25
  QT_IMPORTED = True
18
- except ImportError:
26
+ except ImportError as ex:
27
+ logger.debug(ex)
19
28
  # Заглушки чтобы на сервере не нужно было ставить сотни мегабайт qt-говна
20
29
 
21
30
  class QUrl:
@@ -34,11 +43,6 @@ except ImportError:
34
43
  pass
35
44
 
36
45
 
37
- from ..api import ApiClient # noqa: E402
38
- from ..main import BaseOperation, Namespace # noqa: E402
39
-
40
- logger = logging.getLogger(__package__)
41
-
42
46
 
43
47
  class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
44
48
  def __init__(self, parent: "WebViewWindow") -> None:
@@ -47,25 +51,51 @@ class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
47
51
 
48
52
  def requestStarted(self, info: Any) -> None:
49
53
  url = info.requestUrl().toString()
50
- if url.startswith("hhandroid://"):
54
+ if url.startswith(f"{HH_ANDROID_SCHEME}://"):
51
55
  self.parent.handle_redirect_uri(url)
52
56
 
57
+ def register_hhandroid_scheme() -> None:
58
+ scheme = QWebEngineUrlScheme(HH_ANDROID_SCHEME.encode())
59
+ scheme.setSyntax(QWebEngineUrlScheme.Syntax.Path)
60
+ scheme.setFlags(
61
+ QWebEngineUrlScheme.Flag.SecureScheme |
62
+ QWebEngineUrlScheme.Flag.LocalScheme |
63
+ QWebEngineUrlScheme.Flag.LocalAccessAllowed |
64
+ QWebEngineUrlScheme.Flag.CorsEnabled
65
+ )
66
+ QWebEngineUrlScheme.registerScheme(scheme)
67
+
53
68
 
54
69
  class WebViewWindow(QMainWindow):
55
70
  def __init__(self, api_client: ApiClient) -> None:
56
71
  super().__init__()
57
72
  self.api_client = api_client
58
- # Настройка WebEngineView
73
+
59
74
  self.web_view = QWebEngineView()
75
+ self.web_view.settings().setUnknownUrlSchemePolicy(
76
+ QWebEngineSettings.UnknownUrlSchemePolicy.AllowAllUnknownUrlSchemes
77
+ )
78
+
60
79
  self.setCentralWidget(self.web_view)
61
80
  self.setWindowTitle("Авторизация на HH.RU")
62
81
  self.hhandroid_handler = HHAndroidUrlSchemeHandler(self)
63
- # Установка перехватчика запросов и обработчика кастомной схемы
82
+
64
83
  profile = self.web_view.page().profile()
65
- profile.installUrlSchemeHandler(b"hhandroid", self.hhandroid_handler)
66
- # Настройки окна для мобильного вида
84
+ profile.installUrlSchemeHandler(HH_ANDROID_SCHEME.encode(), self.hhandroid_handler)
85
+
86
+ self.web_view.page().acceptNavigationRequest = self._filter_http_requests
87
+
67
88
  self.resize(480, 800)
68
- self.web_view.setUrl(QUrl(api_client.oauth_client.authorize_url))
89
+ oauth_url = api_client.oauth_client.authorize_url
90
+ logger.debug(f"{oauth_url = }")
91
+ self.web_view.setUrl(QUrl(oauth_url))
92
+
93
+ def _filter_http_requests(self, url: QUrl, _type, is_main_frame):
94
+ """Блокирует любые переходы по протоколу HTTP"""
95
+ if url.scheme().lower() == "http":
96
+ logger.warning(f"🚫 Заблокирован небезопасный запрос: {url.toString()}")
97
+ return False
98
+ return True
69
99
 
70
100
  def handle_redirect_uri(self, redirect_uri: str) -> None:
71
101
  logger.debug(f"handle redirect uri: {redirect_uri}")
@@ -87,10 +117,19 @@ class Operation(BaseOperation):
87
117
  def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
88
118
  if not QT_IMPORTED:
89
119
  print_err(
90
- "❗Критиническая Ошибка: PyQt6 не был импортирован, возможно, вы долбоеб и забыли его установить, либо же криворукие разрабы этой либы опять все сломали..."
120
+ "❗Ошибка: PyQt6 не был импортирован, возможно, вы забыли его установить, либо же это ошибка самой библиотеки."
91
121
  )
92
122
  sys.exit(1)
93
123
 
124
+ proxies = api_client.proxies
125
+ if proxy_url := proxies.get("https"):
126
+ import os
127
+
128
+ qtwebengine_chromium_flags = f"--proxy-server={proxy_url}"
129
+ logger.debug(f"set {qtwebengine_chromium_flags = }")
130
+ os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = qtwebengine_chromium_flags
131
+
132
+ register_hhandroid_scheme()
94
133
  app = QApplication(sys.argv)
95
134
  window = WebViewWindow(api_client=api_client)
96
135
  window.show()
@@ -31,16 +31,12 @@ class Operation(BaseOperation):
31
31
  )
32
32
  parser.add_argument(
33
33
  "--all",
34
- type=bool,
35
- default=False,
36
34
  action=argparse.BooleanOptionalAction,
37
35
  help="Удалить все отклики в тч с приглашениями",
38
36
  )
39
37
  parser.add_argument(
40
38
  "--blacklist-discard",
41
39
  help="Если установлен, то заблокирует работодателя в случае отказа, чтобы его вакансии не отображались в возможных",
42
- type=bool,
43
- default=False,
44
40
  action=argparse.BooleanOptionalAction,
45
41
  )
46
42
 
@@ -33,8 +33,6 @@ class Operation(BaseOperation):
33
33
  "-p",
34
34
  "--show-path",
35
35
  "--path",
36
- type=bool,
37
- default=False,
38
36
  action=argparse.BooleanOptionalAction,
39
37
  help="Вывести полный путь к конфигу",
40
38
  )