hh-applicant-tool 0.7.10__py3-none-any.whl → 1.4.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hh_applicant_tool/__init__.py +1 -0
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/base.py +2 -0
- hh_applicant_tool/ai/openai.py +23 -33
- hh_applicant_tool/api/client.py +50 -64
- hh_applicant_tool/api/errors.py +51 -7
- hh_applicant_tool/constants.py +0 -3
- hh_applicant_tool/datatypes.py +291 -0
- hh_applicant_tool/main.py +233 -111
- hh_applicant_tool/operations/apply_similar.py +266 -362
- hh_applicant_tool/operations/authorize.py +256 -120
- hh_applicant_tool/operations/call_api.py +18 -8
- hh_applicant_tool/operations/check_negotiations.py +102 -0
- hh_applicant_tool/operations/check_proxy.py +30 -0
- hh_applicant_tool/operations/config.py +119 -16
- hh_applicant_tool/operations/install.py +34 -0
- hh_applicant_tool/operations/list_resumes.py +24 -10
- hh_applicant_tool/operations/log.py +77 -0
- hh_applicant_tool/operations/migrate_db.py +65 -0
- hh_applicant_tool/operations/query.py +120 -0
- hh_applicant_tool/operations/refresh_token.py +14 -13
- hh_applicant_tool/operations/reply_employers.py +148 -167
- hh_applicant_tool/operations/settings.py +95 -0
- hh_applicant_tool/operations/uninstall.py +26 -0
- hh_applicant_tool/operations/update_resumes.py +21 -10
- hh_applicant_tool/operations/whoami.py +40 -7
- hh_applicant_tool/storage/__init__.py +4 -0
- hh_applicant_tool/storage/facade.py +24 -0
- hh_applicant_tool/storage/models/__init__.py +0 -0
- hh_applicant_tool/storage/models/base.py +169 -0
- hh_applicant_tool/storage/models/contact.py +16 -0
- hh_applicant_tool/storage/models/employer.py +12 -0
- hh_applicant_tool/storage/models/negotiation.py +16 -0
- hh_applicant_tool/storage/models/resume.py +19 -0
- hh_applicant_tool/storage/models/setting.py +6 -0
- hh_applicant_tool/storage/models/vacancy.py +36 -0
- hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
- hh_applicant_tool/storage/queries/schema.sql +119 -0
- hh_applicant_tool/storage/repositories/__init__.py +0 -0
- hh_applicant_tool/storage/repositories/base.py +176 -0
- hh_applicant_tool/storage/repositories/contacts.py +19 -0
- hh_applicant_tool/storage/repositories/employers.py +13 -0
- hh_applicant_tool/storage/repositories/negotiations.py +12 -0
- hh_applicant_tool/storage/repositories/resumes.py +14 -0
- hh_applicant_tool/storage/repositories/settings.py +34 -0
- hh_applicant_tool/storage/repositories/vacancies.py +8 -0
- hh_applicant_tool/storage/utils.py +49 -0
- hh_applicant_tool/utils/__init__.py +31 -0
- hh_applicant_tool/utils/attrdict.py +6 -0
- hh_applicant_tool/utils/binpack.py +167 -0
- hh_applicant_tool/utils/config.py +55 -0
- hh_applicant_tool/utils/dateutil.py +19 -0
- hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
- hh_applicant_tool/utils/jsonutil.py +61 -0
- hh_applicant_tool/utils/log.py +144 -0
- hh_applicant_tool/utils/misc.py +12 -0
- hh_applicant_tool/utils/mixins.py +220 -0
- hh_applicant_tool/utils/string.py +27 -0
- hh_applicant_tool/utils/terminal.py +19 -0
- hh_applicant_tool/utils/user_agent.py +17 -0
- hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
- hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
- hh_applicant_tool/ai/blackbox.py +0 -55
- hh_applicant_tool/color_log.py +0 -47
- hh_applicant_tool/mixins.py +0 -13
- hh_applicant_tool/operations/clear_negotiations.py +0 -109
- hh_applicant_tool/operations/delete_telemetry.py +0 -30
- hh_applicant_tool/operations/get_employer_contacts.py +0 -348
- hh_applicant_tool/telemetry_client.py +0 -106
- hh_applicant_tool/types.py +0 -45
- hh_applicant_tool/utils.py +0 -119
- hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
- hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
- {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
|
@@ -1,137 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
4
|
+
import asyncio
|
|
2
5
|
import logging
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
3
9
|
from urllib.parse import parse_qs, urlsplit
|
|
4
|
-
import sys
|
|
5
|
-
from typing import Any
|
|
6
|
-
from ..utils import print_err
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
from
|
|
11
|
+
try:
|
|
12
|
+
from playwright.async_api import async_playwright
|
|
13
|
+
except ImportError:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
from ..main import BaseOperation
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..main import HHApplicantTool
|
|
20
|
+
|
|
10
21
|
|
|
11
22
|
HH_ANDROID_SCHEME = "hhandroid"
|
|
12
23
|
|
|
13
|
-
logger = logging.getLogger(
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
_executor = ThreadPoolExecutor()
|
|
14
26
|
|
|
15
|
-
QT_IMPORTED = False
|
|
16
27
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
from PyQt6.QtWebEngineCore import QWebEngineUrlScheme
|
|
21
|
-
from PyQt6.QtWebEngineCore import QWebEngineUrlSchemeHandler
|
|
22
|
-
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
23
|
-
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
24
|
-
|
|
25
|
-
QT_IMPORTED = True
|
|
26
|
-
except ImportError as ex:
|
|
27
|
-
logger.debug(ex)
|
|
28
|
-
# Заглушки чтобы на сервере не нужно было ставить сотни мегабайт qt-говна
|
|
29
|
-
|
|
30
|
-
class QUrl:
|
|
31
|
-
pass
|
|
32
|
-
|
|
33
|
-
class QApplication:
|
|
34
|
-
pass
|
|
35
|
-
|
|
36
|
-
class QMainWindow:
|
|
37
|
-
pass
|
|
38
|
-
|
|
39
|
-
class QWebEngineUrlSchemeHandler:
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
class QWebEngineView:
|
|
43
|
-
pass
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
|
|
48
|
-
def __init__(self, parent: "WebViewWindow") -> None:
|
|
49
|
-
super().__init__()
|
|
50
|
-
self.parent = parent
|
|
51
|
-
|
|
52
|
-
def requestStarted(self, info: Any) -> None:
|
|
53
|
-
url = info.requestUrl().toString()
|
|
54
|
-
if url.startswith(f"{HH_ANDROID_SCHEME}://"):
|
|
55
|
-
self.parent.handle_redirect_uri(url)
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
class WebViewWindow(QMainWindow):
|
|
70
|
-
def __init__(self, api_client: ApiClient) -> None:
|
|
71
|
-
super().__init__()
|
|
72
|
-
self.api_client = api_client
|
|
73
|
-
|
|
74
|
-
self.web_view = QWebEngineView()
|
|
75
|
-
self.web_view.settings().setUnknownUrlSchemePolicy(
|
|
76
|
-
QWebEngineSettings.UnknownUrlSchemePolicy.AllowAllUnknownUrlSchemes
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
self.setCentralWidget(self.web_view)
|
|
80
|
-
self.setWindowTitle("Авторизация на HH.RU")
|
|
81
|
-
self.hhandroid_handler = HHAndroidUrlSchemeHandler(self)
|
|
82
|
-
|
|
83
|
-
profile = self.web_view.page().profile()
|
|
84
|
-
profile.installUrlSchemeHandler(HH_ANDROID_SCHEME.encode(), self.hhandroid_handler)
|
|
85
|
-
|
|
86
|
-
self.web_view.page().acceptNavigationRequest = self._filter_http_requests
|
|
87
|
-
|
|
88
|
-
self.resize(480, 800)
|
|
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
|
|
99
|
-
|
|
100
|
-
def handle_redirect_uri(self, redirect_uri: str) -> None:
|
|
101
|
-
logger.debug(f"handle redirect uri: {redirect_uri}")
|
|
102
|
-
sp = urlsplit(redirect_uri)
|
|
103
|
-
code = parse_qs(sp.query).get("code", [None])[0]
|
|
104
|
-
if code:
|
|
105
|
-
token = self.api_client.oauth_client.authenticate(code)
|
|
106
|
-
self.api_client.handle_access_token(token)
|
|
107
|
-
print("🔓 Авторизация прошла успешно!")
|
|
108
|
-
self.close()
|
|
28
|
+
async def ainput(prompt: str) -> str:
|
|
29
|
+
loop = asyncio.get_running_loop()
|
|
30
|
+
return await loop.run_in_executor(_executor, input, prompt)
|
|
109
31
|
|
|
110
32
|
|
|
111
33
|
class Operation(BaseOperation):
|
|
112
|
-
"""
|
|
34
|
+
"""Авторизация через Playwright"""
|
|
35
|
+
|
|
36
|
+
__aliases__: list = ["auth", "authen", "authenticate"]
|
|
37
|
+
|
|
38
|
+
# Селекторы
|
|
39
|
+
SEL_LOGIN_INPUT = 'input[data-qa="login-input-username"]'
|
|
40
|
+
SEL_EXPAND_PASSWORD_BTN = 'button[data-qa="expand-login-by_password"]'
|
|
41
|
+
SEL_PASSWORD_INPUT = 'input[data-qa="login-input-password"]'
|
|
42
|
+
SEL_CODE_CONTAINER = 'div[data-qa="account-login-code-input"]'
|
|
43
|
+
SEL_PIN_CODE_INPUT = 'input[data-qa="magritte-pincode-input-field"]'
|
|
44
|
+
|
|
45
|
+
def __init__(self, *args, **kwargs):
|
|
46
|
+
super().__init__(*args, **kwargs)
|
|
47
|
+
self._args = None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_headless(self) -> bool:
|
|
51
|
+
"""Свойство, определяющее режим работы браузера"""
|
|
52
|
+
return not self._args.no_headless and self.is_automated
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_automated(self) -> bool:
|
|
56
|
+
return not self._args.manual
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def selector_timeout(self) -> int | None:
|
|
60
|
+
"""Вспомогательное свойство для таймаутов: None если headless, иначе 500мс"""
|
|
61
|
+
return None if self.is_headless else 5000
|
|
113
62
|
|
|
114
63
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
115
|
-
|
|
64
|
+
parser.add_argument(
|
|
65
|
+
"username",
|
|
66
|
+
nargs="?",
|
|
67
|
+
help="Email или телефон",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--password",
|
|
71
|
+
"-p",
|
|
72
|
+
help="Пароль для входа (если не указать, то вход будет по одноразовому коду)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--no-headless",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Показать окно браузера для отладки (отключает headless режим).",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--manual",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Ручной режим ввода кредов, редирект будет перехвачен.",
|
|
83
|
+
)
|
|
116
84
|
|
|
117
|
-
def run(self,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
85
|
+
def run(self, tool: HHApplicantTool) -> None:
|
|
86
|
+
self._args = tool.args
|
|
87
|
+
try:
|
|
88
|
+
asyncio.run(self._main(tool))
|
|
89
|
+
except (KeyboardInterrupt, asyncio.TimeoutError):
|
|
90
|
+
logger.warning("Что-то пошло не так")
|
|
91
|
+
# os._exit(1)
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
async def _main(self, tool: HHApplicantTool) -> None:
|
|
95
|
+
args = tool.args
|
|
96
|
+
api_client = tool.api_client
|
|
97
|
+
storage = tool.storage
|
|
98
|
+
|
|
99
|
+
if self.is_automated:
|
|
100
|
+
username = (
|
|
101
|
+
args.username
|
|
102
|
+
or storage.settings.get_value("auth.username")
|
|
103
|
+
or (await ainput("👤 Введите email или телефон: "))
|
|
104
|
+
).strip()
|
|
105
|
+
|
|
106
|
+
if not username:
|
|
107
|
+
raise RuntimeError("Empty username")
|
|
108
|
+
|
|
109
|
+
logger.debug(f"authenticate with: {username}")
|
|
123
110
|
|
|
124
111
|
proxies = api_client.proxies
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
112
|
+
proxy_url = proxies.get("https")
|
|
113
|
+
|
|
114
|
+
chromium_args: list[str] = []
|
|
115
|
+
if proxy_url:
|
|
116
|
+
chromium_args.append(f"--proxy-server={proxy_url}")
|
|
117
|
+
logger.debug(f"Используется прокси: {proxy_url}")
|
|
118
|
+
|
|
119
|
+
if self.is_headless:
|
|
120
|
+
logger.debug("Headless режим активен")
|
|
121
|
+
|
|
122
|
+
async with async_playwright() as pw:
|
|
123
|
+
logger.debug("Запуск браузера...")
|
|
124
|
+
|
|
125
|
+
browser = await pw.chromium.launch(
|
|
126
|
+
headless=self.is_headless,
|
|
127
|
+
args=chromium_args,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json
|
|
132
|
+
android_device = pw.devices["Galaxy A55"]
|
|
133
|
+
context = await browser.new_context(**android_device)
|
|
134
|
+
page = await context.new_page()
|
|
135
|
+
|
|
136
|
+
# async def route_handler(route):
|
|
137
|
+
# req = route.request
|
|
138
|
+
# url = req.url.lower()
|
|
139
|
+
|
|
140
|
+
# # Блокировка сканирования локальных портов
|
|
141
|
+
# if any(d in url for d in ["localhost", "127.0.0.1", "::1"]):
|
|
142
|
+
# logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
|
|
143
|
+
# return await route.abort()
|
|
144
|
+
|
|
145
|
+
# # Оптимизация трафика в headless
|
|
146
|
+
# if is_headless and req.resource_type in [
|
|
147
|
+
# "image",
|
|
148
|
+
# "stylesheet",
|
|
149
|
+
# "font",
|
|
150
|
+
# "media",
|
|
151
|
+
# ]:
|
|
152
|
+
# return await route.abort()
|
|
153
|
+
|
|
154
|
+
# await route.continue_()
|
|
155
|
+
|
|
156
|
+
# почему-то добавление этого обработчика вешает все
|
|
157
|
+
# await page.route("**/*", route_handler)
|
|
158
|
+
|
|
159
|
+
code_future: asyncio.Future[str | None] = asyncio.Future()
|
|
160
|
+
|
|
161
|
+
def handle_request(request):
|
|
162
|
+
url = request.url
|
|
163
|
+
if url.startswith(f"{HH_ANDROID_SCHEME}://"):
|
|
164
|
+
logger.info(f"Перехвачен OAuth redirect: {url}")
|
|
165
|
+
if not code_future.done():
|
|
166
|
+
sp = urlsplit(url)
|
|
167
|
+
code = parse_qs(sp.query).get("code", [None])[0]
|
|
168
|
+
code_future.set_result(code)
|
|
169
|
+
|
|
170
|
+
page.on("request", handle_request)
|
|
171
|
+
|
|
172
|
+
logger.debug(
|
|
173
|
+
f"Переход на страницу OAuth: {api_client.oauth_client.authorize_url}"
|
|
174
|
+
)
|
|
175
|
+
await page.goto(
|
|
176
|
+
api_client.oauth_client.authorize_url,
|
|
177
|
+
timeout=30000,
|
|
178
|
+
wait_until="load",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if self.is_automated:
|
|
182
|
+
# Шаг 1: Логин
|
|
183
|
+
logger.debug(f"Ожидание поля логина {self.SEL_LOGIN_INPUT}")
|
|
184
|
+
await page.wait_for_selector(
|
|
185
|
+
self.SEL_LOGIN_INPUT, timeout=self.selector_timeout
|
|
186
|
+
)
|
|
187
|
+
await page.fill(self.SEL_LOGIN_INPUT, username)
|
|
188
|
+
logger.debug("Логин введен")
|
|
189
|
+
|
|
190
|
+
# Шаг 2: Выбор метода входа
|
|
191
|
+
if args.password:
|
|
192
|
+
await self._direct_login(page, args.password)
|
|
193
|
+
else:
|
|
194
|
+
await self._onetime_code_login(page)
|
|
195
|
+
|
|
196
|
+
# Шаг 3: Ожидание OAuth кода
|
|
197
|
+
logger.debug("Ожидание появления OAuth кода в трафике...")
|
|
198
|
+
|
|
199
|
+
auth_code = await asyncio.wait_for(
|
|
200
|
+
code_future, timeout=[None, 30.0][self.is_automated]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
page.remove_listener("request", handle_request)
|
|
204
|
+
|
|
205
|
+
logger.debug("Код получен, пробуем получить токен...")
|
|
206
|
+
token = await asyncio.to_thread(
|
|
207
|
+
api_client.oauth_client.authenticate,
|
|
208
|
+
auth_code,
|
|
209
|
+
)
|
|
210
|
+
api_client.handle_access_token(token)
|
|
211
|
+
|
|
212
|
+
print("🔓 Авторизация прошла успешно!")
|
|
213
|
+
|
|
214
|
+
# Сохраняем логин и пароль
|
|
215
|
+
if self.is_automated:
|
|
216
|
+
storage.settings.set_value("auth.username", username)
|
|
217
|
+
if args.password:
|
|
218
|
+
storage.settings.set_value(
|
|
219
|
+
"auth.password", args.password
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
storage.settings.set_value("auth.last_login", datetime.now())
|
|
223
|
+
|
|
224
|
+
# storage.settings.set_value(
|
|
225
|
+
# "auth.access_token", token["access_token"]
|
|
226
|
+
# )
|
|
227
|
+
# storage.settings.set_value(
|
|
228
|
+
# "auth.refresh_token", token["refresh_token"]
|
|
229
|
+
# )
|
|
230
|
+
# storage.settings.set_value(
|
|
231
|
+
# "auth.refresh_token", token["expires_in"]
|
|
232
|
+
# )
|
|
233
|
+
|
|
234
|
+
finally:
|
|
235
|
+
logger.debug("Закрытие браузера")
|
|
236
|
+
await browser.close()
|
|
237
|
+
|
|
238
|
+
async def _direct_login(self, page, password: str) -> None:
|
|
239
|
+
logger.info("Вход по паролю...")
|
|
240
|
+
logger.debug(
|
|
241
|
+
f"Клик по кнопке развертывания пароля: {self.SEL_EXPAND_PASSWORD_BTN}"
|
|
242
|
+
)
|
|
243
|
+
await page.click(self.SEL_EXPAND_PASSWORD_BTN)
|
|
244
|
+
|
|
245
|
+
logger.debug(f"Ожидание поля пароля: {self.SEL_PASSWORD_INPUT}")
|
|
246
|
+
await page.wait_for_selector(
|
|
247
|
+
self.SEL_PASSWORD_INPUT, timeout=self.selector_timeout
|
|
248
|
+
)
|
|
249
|
+
await page.fill(self.SEL_PASSWORD_INPUT, password)
|
|
250
|
+
await page.press(self.SEL_PASSWORD_INPUT, "Enter")
|
|
251
|
+
logger.debug("Форма с паролем отправлена")
|
|
252
|
+
|
|
253
|
+
async def _onetime_code_login(self, page) -> None:
|
|
254
|
+
logger.info("Вход по одноразовому коду...")
|
|
255
|
+
await page.press(self.SEL_LOGIN_INPUT, "Enter")
|
|
256
|
+
|
|
257
|
+
logger.debug(
|
|
258
|
+
f"Ожидание контейнера ввода кода: {self.SEL_CODE_CONTAINER}"
|
|
259
|
+
)
|
|
260
|
+
await page.wait_for_selector(
|
|
261
|
+
self.SEL_CODE_CONTAINER, timeout=self.selector_timeout
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
print("📨 Код был отправлен. Проверьте почту или SMS.")
|
|
265
|
+
code = (await ainput("📩 Введите полученный код: ")).strip()
|
|
266
|
+
|
|
267
|
+
if not code:
|
|
268
|
+
raise RuntimeError("Код подтверждения не может быть пустым")
|
|
269
|
+
|
|
270
|
+
logger.debug(f"Ввод кода в {self.SEL_PIN_CODE_INPUT}")
|
|
271
|
+
await page.fill(self.SEL_PIN_CODE_INPUT, code)
|
|
272
|
+
await page.press(self.SEL_PIN_CODE_INPUT, "Enter")
|
|
273
|
+
logger.debug("Форма с кодом отправлена")
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import argparse
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..api import ApiError
|
|
10
|
+
from ..main import BaseNamespace, BaseOperation
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..main import HHApplicantTool
|
|
6
14
|
|
|
7
|
-
from ..api import ApiClient, ApiError
|
|
8
|
-
from ..main import BaseOperation
|
|
9
|
-
from ..main import Namespace as BaseNamespace
|
|
10
15
|
|
|
11
16
|
logger = logging.getLogger(__package__)
|
|
12
17
|
|
|
@@ -15,12 +20,13 @@ class Namespace(BaseNamespace):
|
|
|
15
20
|
method: str
|
|
16
21
|
endpoint: str
|
|
17
22
|
params: list[str]
|
|
18
|
-
pretty_print: bool
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
class Operation(BaseOperation):
|
|
22
26
|
"""Вызвать произвольный метод API <https://github.com/hhru/api>."""
|
|
23
27
|
|
|
28
|
+
__aliases__ = ("api",)
|
|
29
|
+
|
|
24
30
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
25
31
|
parser.add_argument("endpoint", help="Путь до эндпоинта API")
|
|
26
32
|
parser.add_argument(
|
|
@@ -30,13 +36,17 @@ class Operation(BaseOperation):
|
|
|
30
36
|
default=[],
|
|
31
37
|
)
|
|
32
38
|
parser.add_argument(
|
|
33
|
-
"-m", "--method", "--meth", default="GET", help="HTTP Метод"
|
|
39
|
+
"-m", "--method", "--meth", "-X", default="GET", help="HTTP Метод"
|
|
34
40
|
)
|
|
35
41
|
|
|
36
|
-
def run(self,
|
|
42
|
+
def run(self, applicant_tool: HHApplicantTool) -> None:
|
|
43
|
+
args = applicant_tool.args
|
|
44
|
+
api_client = applicant_tool.api_client
|
|
37
45
|
params = dict(x.split("=", 1) for x in args.param)
|
|
38
46
|
try:
|
|
39
|
-
result = api_client.request(
|
|
47
|
+
result = api_client.request(
|
|
48
|
+
args.method, args.endpoint, params=params
|
|
49
|
+
)
|
|
40
50
|
print(json.dumps(result, ensure_ascii=False))
|
|
41
51
|
except ApiError as ex:
|
|
42
52
|
json.dump(ex.data, sys.stderr, ensure_ascii=False)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from ..api.errors import ApiError
|
|
8
|
+
from ..datatypes import NegotiationStateId
|
|
9
|
+
from ..main import BaseNamespace, BaseOperation
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ..main import HHApplicantTool
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__package__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Namespace(BaseNamespace):
|
|
18
|
+
cleanup: bool
|
|
19
|
+
blacklist_discard: bool
|
|
20
|
+
dry_run: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Operation(BaseOperation):
|
|
24
|
+
"""Проверяет и синхронизирует отклики с локальной базой и опционально удаляет отказы."""
|
|
25
|
+
|
|
26
|
+
__aliases__ = ["sync-negotiations"]
|
|
27
|
+
|
|
28
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--cleanup",
|
|
31
|
+
"--clean",
|
|
32
|
+
action=argparse.BooleanOptionalAction,
|
|
33
|
+
help="Удалить отклики с отказами",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-b",
|
|
37
|
+
"--blacklist-discard",
|
|
38
|
+
"--blacklist",
|
|
39
|
+
action=argparse.BooleanOptionalAction,
|
|
40
|
+
help="Блокировать работодателя за отказ",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-n",
|
|
44
|
+
"--dry-run",
|
|
45
|
+
action=argparse.BooleanOptionalAction,
|
|
46
|
+
help="Тестовый запуск без реального удаления",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def run(self, tool: HHApplicantTool) -> None:
|
|
50
|
+
self.tool = tool
|
|
51
|
+
self.args = tool.args
|
|
52
|
+
self._sync()
|
|
53
|
+
|
|
54
|
+
def _sync(self) -> None:
|
|
55
|
+
storage = self.tool.storage
|
|
56
|
+
for negotiation in self.tool.get_negotiations():
|
|
57
|
+
vacancy = negotiation["vacancy"]
|
|
58
|
+
employer = vacancy.get("employer", {})
|
|
59
|
+
employer_id = employer.get("id")
|
|
60
|
+
|
|
61
|
+
# Если работодателя блокируют, то он превращается в null
|
|
62
|
+
# ХХ позволяет скрывать компанию, когда id нет, а вместо имени "Крупная российская компания"
|
|
63
|
+
# sqlite3.IntegrityError: NOT NULL constraint failed: negotiations.employer_id
|
|
64
|
+
if employer_id:
|
|
65
|
+
storage.negotiations.save(negotiation)
|
|
66
|
+
|
|
67
|
+
state_id: NegotiationStateId = negotiation["state"]["id"]
|
|
68
|
+
if not self.args.cleanup:
|
|
69
|
+
continue
|
|
70
|
+
if state_id != "discard":
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
if not self.args.dry_run:
|
|
74
|
+
self.tool.api_client.delete(
|
|
75
|
+
f"/negotiations/active/{negotiation['id']}",
|
|
76
|
+
with_decline_message=True,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
print(
|
|
80
|
+
"🗑️ Отменили отклик на вакансию:",
|
|
81
|
+
vacancy["name"],
|
|
82
|
+
vacancy["alternate_url"],
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
employer_id
|
|
87
|
+
and employer_id not in self.args.blacklist_discard
|
|
88
|
+
):
|
|
89
|
+
if not self.args.dry_run:
|
|
90
|
+
self.tool.api_client.put(
|
|
91
|
+
f"/employers/blacklisted/{employer_id}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
print(
|
|
95
|
+
"🚫 Работодатель заблокирован:",
|
|
96
|
+
employer["name"],
|
|
97
|
+
employer["alternate_url"],
|
|
98
|
+
)
|
|
99
|
+
except ApiError as err:
|
|
100
|
+
logger.error(err)
|
|
101
|
+
|
|
102
|
+
print("✅ Синхронизация завершена.")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Этот модуль можно использовать как образец для других
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from ..main import BaseNamespace, BaseOperation
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..main import HHApplicantTool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__package__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Namespace(BaseNamespace):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Operation(BaseOperation):
|
|
22
|
+
"""Проверить прокси"""
|
|
23
|
+
|
|
24
|
+
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def run(self, applicant_tool: HHApplicantTool) -> None:
|
|
28
|
+
session = applicant_tool.session
|
|
29
|
+
assert session.proxies, "Прокси не заданы"
|
|
30
|
+
print(session.get("https://icanhazip.com").text)
|