hh-applicant-tool 1.4.12__py3-none-any.whl → 1.5.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/__main__.py +1 -1
- hh_applicant_tool/ai/__init__.py +1 -0
- hh_applicant_tool/ai/openai.py +28 -12
- hh_applicant_tool/api/client.py +11 -7
- hh_applicant_tool/main.py +57 -31
- hh_applicant_tool/operations/apply_similar.py +12 -6
- hh_applicant_tool/operations/authorize.py +24 -12
- hh_applicant_tool/operations/clear_negotiations.py +0 -1
- hh_applicant_tool/operations/query.py +2 -2
- hh_applicant_tool/operations/refresh_token.py +9 -2
- hh_applicant_tool/operations/whoami.py +2 -2
- hh_applicant_tool/storage/models/base.py +5 -0
- hh_applicant_tool/storage/models/contacts.py +15 -1
- hh_applicant_tool/storage/queries/schema.sql +1 -1
- hh_applicant_tool/storage/utils.py +7 -1
- hh_applicant_tool/utils/log.py +2 -2
- hh_applicant_tool/utils/mixins.py +9 -28
- hh_applicant_tool/utils/string.py +15 -0
- hh_applicant_tool/utils/terminal.py +102 -0
- {hh_applicant_tool-1.4.12.dist-info → hh_applicant_tool-1.5.7.dist-info}/METADATA +247 -82
- {hh_applicant_tool-1.4.12.dist-info → hh_applicant_tool-1.5.7.dist-info}/RECORD +23 -23
- {hh_applicant_tool-1.4.12.dist-info → hh_applicant_tool-1.5.7.dist-info}/WHEEL +1 -1
- {hh_applicant_tool-1.4.12.dist-info → hh_applicant_tool-1.5.7.dist-info}/entry_points.txt +0 -0
hh_applicant_tool/__main__.py
CHANGED
hh_applicant_tool/ai/__init__.py
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .openai import ChatOpenAI, OpenAIError
|
hh_applicant_tool/ai/openai.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
from dataclasses import dataclass, field
|
|
3
|
-
from typing import ClassVar
|
|
2
|
+
from dataclasses import KW_ONLY, dataclass, field
|
|
4
3
|
|
|
5
4
|
import requests
|
|
6
5
|
|
|
@@ -9,43 +8,60 @@ from .base import AIError
|
|
|
9
8
|
logger = logging.getLogger(__package__)
|
|
10
9
|
|
|
11
10
|
|
|
11
|
+
DEFAULT_COMPLETION_ENDPOINT = "https://api.openai.com/v1/chat/completions"
|
|
12
|
+
|
|
13
|
+
|
|
12
14
|
class OpenAIError(AIError):
|
|
13
15
|
pass
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
@dataclass
|
|
17
19
|
class ChatOpenAI:
|
|
18
|
-
chat_endpoint: ClassVar[str] = "https://api.openai.com/v1/chat/completions"
|
|
19
|
-
|
|
20
20
|
token: str
|
|
21
|
-
|
|
21
|
+
_: KW_ONLY
|
|
22
22
|
system_prompt: str | None = None
|
|
23
|
+
timeout: float = 15.0
|
|
23
24
|
temperature: float = 0.7
|
|
24
25
|
max_completion_tokens: int = 1000
|
|
26
|
+
model: str | None = None
|
|
27
|
+
completion_endpoint: str = None
|
|
25
28
|
session: requests.Session = field(default_factory=requests.Session)
|
|
26
29
|
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
self.completion_endpoint = (
|
|
32
|
+
self.completion_endpoint or DEFAULT_COMPLETION_ENDPOINT
|
|
33
|
+
)
|
|
34
|
+
|
|
27
35
|
def _default_headers(self) -> dict[str, str]:
|
|
28
36
|
return {
|
|
29
37
|
"Authorization": f"Bearer {self.token}",
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
def send_message(self, message: str) -> str:
|
|
41
|
+
messages = []
|
|
42
|
+
|
|
43
|
+
# Добавляем системный промпт только если он не пустой и не None
|
|
44
|
+
if self.system_prompt:
|
|
45
|
+
messages.append({"role": "system", "content": self.system_prompt})
|
|
46
|
+
|
|
47
|
+
# Пользовательское сообщение всегда обязательно
|
|
48
|
+
messages.append({"role": "user", "content": message})
|
|
49
|
+
|
|
33
50
|
payload = {
|
|
34
|
-
"
|
|
35
|
-
"messages": [
|
|
36
|
-
{"role": "system", "content": self.system_prompt},
|
|
37
|
-
{"role": "user", "content": message},
|
|
38
|
-
],
|
|
51
|
+
"messages": messages,
|
|
39
52
|
"temperature": self.temperature,
|
|
40
53
|
"max_completion_tokens": self.max_completion_tokens,
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
if self.model:
|
|
57
|
+
payload["model"] = self.model
|
|
58
|
+
|
|
43
59
|
try:
|
|
44
60
|
response = self.session.post(
|
|
45
|
-
self.
|
|
61
|
+
self.completion_endpoint,
|
|
46
62
|
json=payload,
|
|
47
63
|
headers=self._default_headers(),
|
|
48
|
-
timeout=
|
|
64
|
+
timeout=self.timeout,
|
|
49
65
|
)
|
|
50
66
|
response.raise_for_status()
|
|
51
67
|
|
hh_applicant_tool/api/client.py
CHANGED
|
@@ -13,19 +13,20 @@ from urllib.parse import urlencode, urljoin
|
|
|
13
13
|
import requests
|
|
14
14
|
from requests import Session
|
|
15
15
|
|
|
16
|
+
from hh_applicant_tool.api.user_agent import generate_android_useragent
|
|
17
|
+
|
|
16
18
|
from . import errors
|
|
17
19
|
from .client_keys import (
|
|
18
20
|
ANDROID_CLIENT_ID,
|
|
19
21
|
ANDROID_CLIENT_SECRET,
|
|
20
22
|
)
|
|
21
23
|
from .datatypes import AccessToken
|
|
22
|
-
from .user_agent import generate_android_useragent
|
|
23
24
|
|
|
24
25
|
__all__ = ("ApiClient", "OAuthClient")
|
|
25
26
|
|
|
26
27
|
HH_API_URL = "https://api.hh.ru/"
|
|
27
28
|
HH_OAUTH_URL = "https://hh.ru/oauth/"
|
|
28
|
-
DEFAULT_DELAY = 0.
|
|
29
|
+
DEFAULT_DELAY = 0.345
|
|
29
30
|
|
|
30
31
|
AllowedMethods = Literal["GET", "POST", "PUT", "DELETE"]
|
|
31
32
|
T = TypeVar("T")
|
|
@@ -41,18 +42,21 @@ class BaseClient:
|
|
|
41
42
|
_: dataclasses.KW_ONLY
|
|
42
43
|
user_agent: str | None = None
|
|
43
44
|
session: Session | None = None
|
|
44
|
-
delay: float =
|
|
45
|
+
delay: float | None = None
|
|
45
46
|
_previous_request_time: float = 0.0
|
|
46
47
|
|
|
47
48
|
def __post_init__(self) -> None:
|
|
48
49
|
assert self.base_url.endswith("/"), "base_url must ends with /"
|
|
49
|
-
self.
|
|
50
|
+
self.delay = self.delay or DEFAULT_DELAY
|
|
51
|
+
self.user_agent = self.user_agent or generate_android_useragent()
|
|
52
|
+
|
|
50
53
|
# logger.debug(f"user agent: {self.user_agent}")
|
|
54
|
+
|
|
51
55
|
if not self.session:
|
|
52
56
|
logger.debug("create new session")
|
|
53
57
|
self.session = requests.session()
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
|
|
59
|
+
self.lock = Lock()
|
|
56
60
|
|
|
57
61
|
@property
|
|
58
62
|
def proxies(self):
|
|
@@ -60,7 +64,7 @@ class BaseClient:
|
|
|
60
64
|
|
|
61
65
|
def _default_headers(self) -> dict[str, str]:
|
|
62
66
|
return {
|
|
63
|
-
"user-agent": self.user_agent
|
|
67
|
+
"user-agent": self.user_agent,
|
|
64
68
|
"x-hh-app-active": "true",
|
|
65
69
|
}
|
|
66
70
|
|
hh_applicant_tool/main.py
CHANGED
|
@@ -17,8 +17,7 @@ from typing import Any, Iterable
|
|
|
17
17
|
import requests
|
|
18
18
|
import urllib3
|
|
19
19
|
|
|
20
|
-
from . import utils
|
|
21
|
-
from .api import ApiClient, datatypes
|
|
20
|
+
from . import ai, api, utils
|
|
22
21
|
from .storage import StorageFacade
|
|
23
22
|
from .utils.log import setup_logger
|
|
24
23
|
from .utils.mixins import MegaTool
|
|
@@ -29,7 +28,6 @@ DEFAULT_CONFIG_DIR = utils.get_config_path() / (__package__ or "").replace(
|
|
|
29
28
|
DEFAULT_CONFIG_FILENAME = "config.json"
|
|
30
29
|
DEFAULT_LOG_FILENAME = "log.txt"
|
|
31
30
|
DEFAULT_DATABASE_FILENAME = "data"
|
|
32
|
-
DEFAULT_PROFILE_ID = "."
|
|
33
31
|
|
|
34
32
|
logger = logging.getLogger(__package__)
|
|
35
33
|
|
|
@@ -54,7 +52,6 @@ class BaseNamespace(argparse.Namespace):
|
|
|
54
52
|
delay: float
|
|
55
53
|
user_agent: str
|
|
56
54
|
proxy_url: str
|
|
57
|
-
disable_telemetry: bool
|
|
58
55
|
|
|
59
56
|
|
|
60
57
|
class HHApplicantTool(MegaTool):
|
|
@@ -89,19 +86,18 @@ class HHApplicantTool(MegaTool):
|
|
|
89
86
|
"--config",
|
|
90
87
|
help="Путь до директории с конфигом",
|
|
91
88
|
type=Path,
|
|
92
|
-
default=
|
|
89
|
+
default=None,
|
|
93
90
|
)
|
|
94
91
|
parser.add_argument(
|
|
95
92
|
"--profile-id",
|
|
96
93
|
"--profile",
|
|
97
|
-
help="Используемый профиль — подкаталог в --config-dir",
|
|
98
|
-
default=DEFAULT_PROFILE_ID,
|
|
94
|
+
help="Используемый профиль — подкаталог в --config-dir. Так же можно передать через переменную окружения HH_PROFILE_ID.",
|
|
99
95
|
)
|
|
100
96
|
parser.add_argument(
|
|
101
97
|
"-d",
|
|
98
|
+
"--api-delay",
|
|
102
99
|
"--delay",
|
|
103
100
|
type=float,
|
|
104
|
-
default=0.654,
|
|
105
101
|
help="Задержка между запросами к API HH по умолчанию",
|
|
106
102
|
)
|
|
107
103
|
parser.add_argument(
|
|
@@ -168,14 +164,20 @@ class HHApplicantTool(MegaTool):
|
|
|
168
164
|
session.verify = False
|
|
169
165
|
|
|
170
166
|
if proxies := self._get_proxies():
|
|
171
|
-
logger.info("Use proxies: %r", proxies)
|
|
167
|
+
logger.info("Use proxies for requests: %r", proxies)
|
|
172
168
|
session.proxies = proxies
|
|
173
169
|
|
|
174
170
|
return session
|
|
175
171
|
|
|
176
|
-
@
|
|
172
|
+
@cached_property
|
|
177
173
|
def config_path(self) -> Path:
|
|
178
|
-
return (
|
|
174
|
+
return (
|
|
175
|
+
(
|
|
176
|
+
self.args.config_dir
|
|
177
|
+
or Path(getenv("CONFIG_DIR", DEFAULT_CONFIG_DIR))
|
|
178
|
+
)
|
|
179
|
+
/ (self.args.profile_id or getenv("HH_PROFILE_ID", "."))
|
|
180
|
+
).resolve()
|
|
179
181
|
|
|
180
182
|
@cached_property
|
|
181
183
|
def config(self) -> utils.Config:
|
|
@@ -199,36 +201,35 @@ class HHApplicantTool(MegaTool):
|
|
|
199
201
|
return StorageFacade(self.db)
|
|
200
202
|
|
|
201
203
|
@cached_property
|
|
202
|
-
def api_client(self) -> ApiClient:
|
|
204
|
+
def api_client(self) -> api.client.ApiClient:
|
|
203
205
|
args = self.args
|
|
204
206
|
config = self.config
|
|
205
207
|
token = config.get("token", {})
|
|
206
|
-
api
|
|
208
|
+
return api.client.ApiClient(
|
|
207
209
|
client_id=config.get("client_id"),
|
|
208
210
|
client_secret=config.get("client_id"),
|
|
209
211
|
access_token=token.get("access_token"),
|
|
210
212
|
refresh_token=token.get("refresh_token"),
|
|
211
213
|
access_expires_at=token.get("access_expires_at"),
|
|
212
|
-
delay=args.
|
|
213
|
-
user_agent=config.get("user_agent"),
|
|
214
|
+
delay=args.api_delay or config.get("api_delay"),
|
|
215
|
+
user_agent=args.user_agent or config.get("user_agent"),
|
|
214
216
|
session=self.session,
|
|
215
217
|
)
|
|
216
|
-
return api
|
|
217
218
|
|
|
218
|
-
def get_me(self) -> datatypes.User:
|
|
219
|
+
def get_me(self) -> api.datatypes.User:
|
|
219
220
|
return self.api_client.get("/me")
|
|
220
221
|
|
|
221
|
-
def get_resumes(self) -> list[datatypes.Resume]:
|
|
222
|
+
def get_resumes(self) -> list[api.datatypes.Resume]:
|
|
222
223
|
return self.api_client.get("/resumes/mine")["items"]
|
|
223
224
|
|
|
224
225
|
def first_resume_id(self) -> str:
|
|
225
|
-
|
|
226
|
-
return
|
|
226
|
+
resume = self.get_resumes()[0]
|
|
227
|
+
return resume["id"]
|
|
227
228
|
|
|
228
229
|
def get_blacklisted(self) -> list[str]:
|
|
229
230
|
rv = []
|
|
230
231
|
for page in count():
|
|
231
|
-
r: datatypes.PaginatedItems[datatypes.EmployerShort] = (
|
|
232
|
+
r: api.datatypes.PaginatedItems[api.datatypes.EmployerShort] = (
|
|
232
233
|
self.api_client.get("/employers/blacklisted", page=page)
|
|
233
234
|
)
|
|
234
235
|
rv += [item["id"] for item in r["items"]]
|
|
@@ -238,7 +239,7 @@ class HHApplicantTool(MegaTool):
|
|
|
238
239
|
|
|
239
240
|
def get_negotiations(
|
|
240
241
|
self, status: str = "active"
|
|
241
|
-
) -> Iterable[datatypes.Negotiation]:
|
|
242
|
+
) -> Iterable[api.datatypes.Negotiation]:
|
|
242
243
|
for page in count():
|
|
243
244
|
r: dict[str, Any] = self.api_client.get(
|
|
244
245
|
"/negotiations",
|
|
@@ -257,6 +258,8 @@ class HHApplicantTool(MegaTool):
|
|
|
257
258
|
if page + 1 >= r.get("pages", 0):
|
|
258
259
|
break
|
|
259
260
|
|
|
261
|
+
# TODO: добавить еще методов или те удалить?
|
|
262
|
+
|
|
260
263
|
def save_token(self) -> bool:
|
|
261
264
|
if self.api_client.access_token != self.config.get("token", {}).get(
|
|
262
265
|
"access_token"
|
|
@@ -265,6 +268,20 @@ class HHApplicantTool(MegaTool):
|
|
|
265
268
|
return True
|
|
266
269
|
return False
|
|
267
270
|
|
|
271
|
+
def get_openai_chat(self, system_prompt: str) -> ai.ChatOpenAI:
|
|
272
|
+
c = self.config.get("openai", {})
|
|
273
|
+
if not (token := c.get("token")):
|
|
274
|
+
raise ValueError("Токен для OpenAI не задан")
|
|
275
|
+
return ai.ChatOpenAI(
|
|
276
|
+
token=token,
|
|
277
|
+
model=c.get("model"),
|
|
278
|
+
temperature=c.get("temperature", 0.7),
|
|
279
|
+
max_completion_tokens=c.get("max_completion_tokens", 1000),
|
|
280
|
+
system_prompt=system_prompt,
|
|
281
|
+
completion_endpoint=c.get("completion_endpoint"),
|
|
282
|
+
session=self.session,
|
|
283
|
+
)
|
|
284
|
+
|
|
268
285
|
def run(self) -> None | int:
|
|
269
286
|
verbosity_level = max(
|
|
270
287
|
logging.DEBUG,
|
|
@@ -273,19 +290,25 @@ class HHApplicantTool(MegaTool):
|
|
|
273
290
|
|
|
274
291
|
setup_logger(logger, verbosity_level, self.log_file)
|
|
275
292
|
|
|
276
|
-
|
|
277
|
-
|
|
293
|
+
logger.debug("Путь до профиля: %s", self.config_path)
|
|
294
|
+
|
|
295
|
+
utils.setup_terminal()
|
|
278
296
|
|
|
279
297
|
try:
|
|
280
298
|
if self.args.run:
|
|
281
299
|
try:
|
|
282
|
-
|
|
283
|
-
if self.save_token():
|
|
284
|
-
logger.info("Токен был обновлен.")
|
|
285
|
-
return res
|
|
300
|
+
return self.args.run(self)
|
|
286
301
|
except KeyboardInterrupt:
|
|
287
302
|
logger.warning("Выполнение прервано пользователем!")
|
|
288
|
-
|
|
303
|
+
except api.errors.CaptchaRequired as ex:
|
|
304
|
+
logger.error(f"Требуется ввод капчи: {ex.captcha_url}")
|
|
305
|
+
except api.errors.InternalServerError:
|
|
306
|
+
logger.error(
|
|
307
|
+
"Сервер HH.RU не смог обработать запрос из-за высокой"
|
|
308
|
+
" нагрузки или по иной причине"
|
|
309
|
+
)
|
|
310
|
+
except api.errors.Forbidden:
|
|
311
|
+
logger.error("Требуется авторизация")
|
|
289
312
|
except sqlite3.Error as ex:
|
|
290
313
|
logger.exception(ex)
|
|
291
314
|
|
|
@@ -295,10 +318,13 @@ class HHApplicantTool(MegaTool):
|
|
|
295
318
|
f"Возможно база данных повреждена, попробуйте выполнить команду:\n\n" # noqa: E501
|
|
296
319
|
f" {script_name} migrate-db"
|
|
297
320
|
)
|
|
298
|
-
return 1
|
|
299
321
|
except Exception as e:
|
|
300
322
|
logger.exception(e)
|
|
301
|
-
|
|
323
|
+
finally:
|
|
324
|
+
# Токен мог автоматически обновиться
|
|
325
|
+
if self.save_token():
|
|
326
|
+
logger.info("Токен был сохранен после обновления.")
|
|
327
|
+
return 1
|
|
302
328
|
self._parser.print_help(file=sys.stderr)
|
|
303
329
|
return 2
|
|
304
330
|
finally:
|
|
@@ -12,7 +12,13 @@ from ..api.datatypes import PaginatedItems, SearchVacancy
|
|
|
12
12
|
from ..api.errors import ApiError, LimitExceeded
|
|
13
13
|
from ..main import BaseNamespace, BaseOperation
|
|
14
14
|
from ..storage.repositories.errors import RepositoryError
|
|
15
|
-
from ..utils import
|
|
15
|
+
from ..utils.string import (
|
|
16
|
+
bool2str,
|
|
17
|
+
list2str,
|
|
18
|
+
rand_text,
|
|
19
|
+
shorten,
|
|
20
|
+
unescape_string,
|
|
21
|
+
)
|
|
16
22
|
|
|
17
23
|
if TYPE_CHECKING:
|
|
18
24
|
from ..main import HHApplicantTool
|
|
@@ -77,7 +83,7 @@ class Operation(BaseOperation):
|
|
|
77
83
|
"-L",
|
|
78
84
|
"--message-list-path",
|
|
79
85
|
"--message-list",
|
|
80
|
-
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
|
|
86
|
+
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. Символы \\n будут заменены на переносы.", # noqa: E501
|
|
81
87
|
type=Path,
|
|
82
88
|
)
|
|
83
89
|
parser.add_argument(
|
|
@@ -412,7 +418,7 @@ class Operation(BaseOperation):
|
|
|
412
418
|
resume["alternate_url"],
|
|
413
419
|
)
|
|
414
420
|
print(
|
|
415
|
-
"⛔
|
|
421
|
+
"⛔ Пришел отказ от",
|
|
416
422
|
vacancy["alternate_url"],
|
|
417
423
|
"на резюме",
|
|
418
424
|
resume["alternate_url"],
|
|
@@ -440,7 +446,7 @@ class Operation(BaseOperation):
|
|
|
440
446
|
logger.debug("prompt: %s", msg)
|
|
441
447
|
msg = self.openai_chat.send_message(msg)
|
|
442
448
|
else:
|
|
443
|
-
msg = (
|
|
449
|
+
msg = unescape_string(
|
|
444
450
|
rand_text(random.choice(self.application_messages))
|
|
445
451
|
% message_placeholders
|
|
446
452
|
)
|
|
@@ -487,9 +493,9 @@ class Operation(BaseOperation):
|
|
|
487
493
|
params = {
|
|
488
494
|
"page": page,
|
|
489
495
|
"per_page": self.per_page,
|
|
490
|
-
"order_by": self.order_by,
|
|
491
496
|
}
|
|
492
|
-
|
|
497
|
+
if self.order_by:
|
|
498
|
+
params |= {"order_by": self.order_by}
|
|
493
499
|
if self.search:
|
|
494
500
|
params["text"] = self.search
|
|
495
501
|
if self.schedule:
|
|
@@ -14,7 +14,7 @@ except ImportError:
|
|
|
14
14
|
pass
|
|
15
15
|
|
|
16
16
|
from ..main import BaseOperation
|
|
17
|
-
from ..utils.terminal import print_kitty_image
|
|
17
|
+
from ..utils.terminal import print_kitty_image, print_sixel_mage
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from ..main import HHApplicantTool
|
|
@@ -91,7 +91,14 @@ class Operation(BaseOperation):
|
|
|
91
91
|
"--use-kitty",
|
|
92
92
|
"--kitty",
|
|
93
93
|
action="store_true",
|
|
94
|
-
help="Использовать kitty protocol для вывода
|
|
94
|
+
help="Использовать kitty protocol для вывода капчи в терминал.",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"-s",
|
|
98
|
+
"--use-sixel",
|
|
99
|
+
"--sixel",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help="Использовать sixel protocol для вывода капчи в терминал.",
|
|
95
102
|
)
|
|
96
103
|
|
|
97
104
|
def run(self, tool: HHApplicantTool) -> None:
|
|
@@ -152,7 +159,7 @@ class Operation(BaseOperation):
|
|
|
152
159
|
|
|
153
160
|
# # Блокировка сканирования локальных портов
|
|
154
161
|
# if any(d in url for d in ["localhost", "127.0.0.1", "::1"]):
|
|
155
|
-
# logger.debug(f"🛑
|
|
162
|
+
# logger.debug(f"🛑 Блокировка запроса на локальный порт: {url}")
|
|
156
163
|
# return await route.abort()
|
|
157
164
|
|
|
158
165
|
# # Оптимизация трафика в headless
|
|
@@ -201,8 +208,11 @@ class Operation(BaseOperation):
|
|
|
201
208
|
await page.fill(self.SELECT_LOGIN_INPUT, username)
|
|
202
209
|
logger.debug("Логин введен")
|
|
203
210
|
|
|
204
|
-
|
|
205
|
-
|
|
211
|
+
password = args.password or storage.settings.get_value(
|
|
212
|
+
"auth.password"
|
|
213
|
+
)
|
|
214
|
+
if password:
|
|
215
|
+
await self._direct_login(page, password)
|
|
206
216
|
else:
|
|
207
217
|
await self._onetime_code_login(page)
|
|
208
218
|
|
|
@@ -302,14 +312,11 @@ class Operation(BaseOperation):
|
|
|
302
312
|
logger.debug("Капчи нет, продолжаем как обычно.")
|
|
303
313
|
return
|
|
304
314
|
|
|
305
|
-
if not self._args.use_kitty:
|
|
315
|
+
if not (self._args.use_kitty or self._args.use_sixel):
|
|
306
316
|
raise RuntimeError(
|
|
307
|
-
"
|
|
308
|
-
"Работает не во всех терминалах!",
|
|
317
|
+
"Требуется ввод капчи!",
|
|
309
318
|
)
|
|
310
319
|
|
|
311
|
-
logger.info("Обнаружена капча!")
|
|
312
|
-
|
|
313
320
|
# box = await captcha_element.bounding_box()
|
|
314
321
|
|
|
315
322
|
# width = int(box["width"])
|
|
@@ -319,10 +326,15 @@ class Operation(BaseOperation):
|
|
|
319
326
|
|
|
320
327
|
print(
|
|
321
328
|
"Если вы не видите картинку ниже, то ваш терминал не поддерживает"
|
|
322
|
-
"
|
|
329
|
+
" вывод изображений."
|
|
323
330
|
)
|
|
324
331
|
print()
|
|
325
|
-
|
|
332
|
+
|
|
333
|
+
if self._args.use_kitty:
|
|
334
|
+
print_kitty_image(img_bytes)
|
|
335
|
+
|
|
336
|
+
if self._args.use_sixel:
|
|
337
|
+
print_sixel_mage(img_bytes)
|
|
326
338
|
|
|
327
339
|
captcha_text = (await ainput("Введите текст с картинки: ")).strip()
|
|
328
340
|
|
|
@@ -68,7 +68,7 @@ class Operation(BaseOperation):
|
|
|
68
68
|
writer.writerows(cursor.fetchall())
|
|
69
69
|
|
|
70
70
|
if tool.args.output:
|
|
71
|
-
print(f"✅
|
|
71
|
+
print(f"✅ Exported to {tool.args.output.name}")
|
|
72
72
|
return
|
|
73
73
|
|
|
74
74
|
rows = cursor.fetchmany(MAX_RESULTS + 1)
|
|
@@ -93,7 +93,7 @@ class Operation(BaseOperation):
|
|
|
93
93
|
print(f"Rows affected: {cursor.rowcount}")
|
|
94
94
|
|
|
95
95
|
except sqlite3.Error as ex:
|
|
96
|
-
print(f"❌
|
|
96
|
+
print(f"❌ SQL Error: {ex}")
|
|
97
97
|
return 1
|
|
98
98
|
|
|
99
99
|
if initial_sql := tool.args.sql:
|
|
@@ -26,5 +26,12 @@ class Operation(BaseOperation):
|
|
|
26
26
|
pass
|
|
27
27
|
|
|
28
28
|
def run(self, tool: HHApplicantTool) -> None:
|
|
29
|
-
tool.api_client.
|
|
30
|
-
|
|
29
|
+
if tool.api_client.is_access_expired:
|
|
30
|
+
tool.api_client.refresh_access_token()
|
|
31
|
+
if not tool.save_token():
|
|
32
|
+
print("⚠️ Токен не был обновлен!")
|
|
33
|
+
return 1
|
|
34
|
+
print("✅ Токен успешно обновлен.")
|
|
35
|
+
else:
|
|
36
|
+
# logger.debug("Токен валиден, игнорируем обновление.")
|
|
37
|
+
print("ℹ️ Токен не истек, обновление не требуется.")
|
|
@@ -53,6 +53,6 @@ class Operation(BaseOperation):
|
|
|
53
53
|
print(
|
|
54
54
|
f"🆔 {result['id']} {full_name or 'Анонимный аккаунт'} "
|
|
55
55
|
f"[ 📄 {counters['resumes_count']} "
|
|
56
|
-
f"| 👁️
|
|
57
|
-
f"| ✉️
|
|
56
|
+
f"| 👁️ {fmt_plus(counters['new_resume_views'])} "
|
|
57
|
+
f"| ✉️ {fmt_plus(counters['unread_negotiations'])} ]"
|
|
58
58
|
)
|
|
@@ -13,12 +13,15 @@ MISSING = object()
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def mapped(
|
|
16
|
+
*,
|
|
17
|
+
skip_src: bool = False,
|
|
16
18
|
path: str | None = None,
|
|
17
19
|
transform: Callable[[Any], Any] | None = None,
|
|
18
20
|
store_json: bool = False,
|
|
19
21
|
**kwargs: Any,
|
|
20
22
|
):
|
|
21
23
|
metadata = kwargs.get("metadata", {})
|
|
24
|
+
metadata.setdefault("skip_src", skip_src)
|
|
22
25
|
metadata.setdefault("path", path)
|
|
23
26
|
metadata.setdefault("transform", transform)
|
|
24
27
|
metadata.setdefault("store_json", store_json)
|
|
@@ -89,6 +92,8 @@ class BaseModel:
|
|
|
89
92
|
kwargs = {}
|
|
90
93
|
for f in fields(cls):
|
|
91
94
|
if from_source:
|
|
95
|
+
if f.metadata.get("skip_src") and f.name in data:
|
|
96
|
+
continue
|
|
92
97
|
if path := f.metadata.get("path"):
|
|
93
98
|
found = True
|
|
94
99
|
v = data
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
1
3
|
from .base import BaseModel, mapped
|
|
2
4
|
|
|
3
5
|
|
|
4
6
|
# Из вакансии извлекается
|
|
5
7
|
class VacancyContactsModel(BaseModel):
|
|
6
|
-
id
|
|
8
|
+
# При вызове from_api на вакансии нужно игнорировать ее id
|
|
9
|
+
id: str = mapped(
|
|
10
|
+
skip_src=True,
|
|
11
|
+
default_factory=lambda: secrets.token_hex(16),
|
|
12
|
+
)
|
|
7
13
|
vacancy_id: int = mapped(path="id")
|
|
8
14
|
|
|
9
15
|
vacancy_name: str = mapped(path="name")
|
|
@@ -26,3 +32,11 @@ class VacancyContactsModel(BaseModel):
|
|
|
26
32
|
),
|
|
27
33
|
default=None,
|
|
28
34
|
)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
self.vacancy_salary_from = (
|
|
38
|
+
self.vacancy_salary_from or self.vacancy_salary_to or 0
|
|
39
|
+
)
|
|
40
|
+
self.vacancy_salary_to = (
|
|
41
|
+
self.vacancy_salary_to or self.vacancy_salary_from or 0
|
|
42
|
+
)
|
|
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS employers (
|
|
|
16
16
|
);
|
|
17
17
|
/* ===================== contacts ===================== */
|
|
18
18
|
CREATE TABLE IF NOT EXISTS vacancy_contacts (
|
|
19
|
-
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
19
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))) NOT NULL,
|
|
20
20
|
vacancy_id INTEGER NOT NULL,
|
|
21
21
|
-- Все это избыточные поля
|
|
22
22
|
vacancy_alternate_url TEXT,
|
|
@@ -13,10 +13,16 @@ logger: logging.Logger = logging.getLogger(__package__)
|
|
|
13
13
|
|
|
14
14
|
def init_db(conn: sqlite3.Connection) -> None:
|
|
15
15
|
"""Создает схему БД"""
|
|
16
|
+
changes_before = conn.total_changes
|
|
17
|
+
|
|
16
18
|
conn.executescript(
|
|
17
19
|
(QUERIES_PATH / "schema.sql").read_text(encoding="utf-8")
|
|
18
20
|
)
|
|
19
|
-
|
|
21
|
+
|
|
22
|
+
if conn.total_changes > changes_before:
|
|
23
|
+
logger.info("Применена схема бд")
|
|
24
|
+
# else:
|
|
25
|
+
# logger.debug("База данных не изменилась.")
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
def list_migrations() -> list[str]:
|
hh_applicant_tool/utils/log.py
CHANGED
|
@@ -121,9 +121,9 @@ TS_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")
|
|
|
121
121
|
def collect_traceback_logs(
|
|
122
122
|
fp: TextIO,
|
|
123
123
|
after_dt: datetime,
|
|
124
|
-
|
|
124
|
+
maxlines: int = 1000,
|
|
125
125
|
) -> str:
|
|
126
|
-
error_lines = deque(maxlen=
|
|
126
|
+
error_lines = deque(maxlen=maxlines)
|
|
127
127
|
prev_line = ""
|
|
128
128
|
log_dt = None
|
|
129
129
|
collecting_traceback = False
|