hh-applicant-tool 0.6.12__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 +24 -30
- hh_applicant_tool/api/client.py +82 -98
- hh_applicant_tool/api/errors.py +57 -8
- hh_applicant_tool/constants.py +0 -3
- hh_applicant_tool/datatypes.py +291 -0
- hh_applicant_tool/main.py +236 -82
- hh_applicant_tool/operations/apply_similar.py +268 -348
- hh_applicant_tool/operations/authorize.py +245 -70
- 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 -18
- 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 -35
- hh_applicant_tool/mixins.py +0 -13
- hh_applicant_tool/operations/clear_negotiations.py +0 -113
- hh_applicant_tool/operations/delete_telemetry.py +0 -30
- hh_applicant_tool/operations/get_employer_contacts.py +0 -293
- hh_applicant_tool/telemetry_client.py +0 -106
- hh_applicant_tool/types.py +0 -45
- hh_applicant_tool/utils.py +0 -104
- hh_applicant_tool-0.6.12.dist-info/METADATA +0 -349
- hh_applicant_tool-0.6.12.dist-info/RECORD +0 -33
- {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
- {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Generic, List, Literal, Optional, TypedDict, TypeVar
|
|
4
|
+
|
|
5
|
+
NegotiationStateId = Literal[
|
|
6
|
+
"discard", # отказ
|
|
7
|
+
"interview", # собес
|
|
8
|
+
"response", # отклик
|
|
9
|
+
"invitation", # приглашение
|
|
10
|
+
"hired", # выход на работу
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AccessToken(TypedDict):
|
|
15
|
+
access_token: str
|
|
16
|
+
refresh_token: str
|
|
17
|
+
expires_in: int
|
|
18
|
+
token_type: Literal["bearer"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Item = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PaginatedItems(TypedDict, Generic[Item]):
|
|
25
|
+
items: list[Item]
|
|
26
|
+
found: int
|
|
27
|
+
page: int
|
|
28
|
+
pages: int
|
|
29
|
+
per_page: int
|
|
30
|
+
# Это не все поля
|
|
31
|
+
clusters: Optional[Any]
|
|
32
|
+
arguments: Optional[Any]
|
|
33
|
+
fixes: Optional[Any]
|
|
34
|
+
suggests: Optional[Any]
|
|
35
|
+
alternate_url: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IdName(TypedDict):
|
|
39
|
+
id: str
|
|
40
|
+
name: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Snippet(TypedDict):
|
|
44
|
+
requirement: Optional[str]
|
|
45
|
+
responsibility: Optional[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ManagerActivity(TypedDict):
|
|
49
|
+
last_activity_at: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Salary = TypedDict(
|
|
53
|
+
"Salary",
|
|
54
|
+
{
|
|
55
|
+
"from": Optional[int],
|
|
56
|
+
"to": Optional[int],
|
|
57
|
+
"currency": str,
|
|
58
|
+
"gross": bool,
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
SalaryRange = TypedDict(
|
|
63
|
+
"SalaryRange",
|
|
64
|
+
{
|
|
65
|
+
"from": Optional[int],
|
|
66
|
+
"to": Optional[int],
|
|
67
|
+
"currency": str,
|
|
68
|
+
"gross": bool,
|
|
69
|
+
"mode": IdName,
|
|
70
|
+
"frequency": IdName,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
LogoUrls = TypedDict(
|
|
76
|
+
"LogoUrls",
|
|
77
|
+
{
|
|
78
|
+
"original": str,
|
|
79
|
+
"90": str,
|
|
80
|
+
"240": str,
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EmployerShort(TypedDict):
|
|
86
|
+
id: str
|
|
87
|
+
name: str
|
|
88
|
+
url: str
|
|
89
|
+
alternate_url: str
|
|
90
|
+
logo_urls: Optional[LogoUrls]
|
|
91
|
+
vacancies_url: str
|
|
92
|
+
accredited_it_employer: bool
|
|
93
|
+
trusted: bool
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SearchEmployer(EmployerShort):
|
|
97
|
+
country_id: Optional[int]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NegotiationEmployer(EmployerShort):
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class VacancyShort(TypedDict):
|
|
105
|
+
id: str
|
|
106
|
+
premium: bool
|
|
107
|
+
name: str
|
|
108
|
+
department: Optional[dict]
|
|
109
|
+
has_test: bool
|
|
110
|
+
# HH API fields
|
|
111
|
+
response_letter_required: bool
|
|
112
|
+
area: IdName
|
|
113
|
+
salary: Optional[Salary]
|
|
114
|
+
salary_range: Optional[SalaryRange]
|
|
115
|
+
type: IdName
|
|
116
|
+
address: Optional[dict]
|
|
117
|
+
response_url: Optional[str]
|
|
118
|
+
sort_point_distance: Optional[float]
|
|
119
|
+
published_at: str
|
|
120
|
+
created_at: str
|
|
121
|
+
archived: bool
|
|
122
|
+
apply_alternate_url: str
|
|
123
|
+
show_contacts: bool
|
|
124
|
+
benefits: List[Any]
|
|
125
|
+
insider_interview: Optional[dict]
|
|
126
|
+
url: str
|
|
127
|
+
alternate_url: str
|
|
128
|
+
professional_roles: List[IdName]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class NegotiationVacancy(VacancyShort):
|
|
132
|
+
employer: NegotiationEmployer
|
|
133
|
+
show_logo_in_search: Optional[bool]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SearchVacancy(VacancyShort):
|
|
137
|
+
employer: SearchEmployer
|
|
138
|
+
relations: List[Any]
|
|
139
|
+
experimental_modes: List[str]
|
|
140
|
+
manager_activity: Optional[ManagerActivity]
|
|
141
|
+
snippet: Snippet
|
|
142
|
+
contacts: Optional[dict]
|
|
143
|
+
schedule: IdName
|
|
144
|
+
working_days: List[Any]
|
|
145
|
+
working_time_intervals: List[Any]
|
|
146
|
+
working_time_modes: List[Any]
|
|
147
|
+
accept_temporary: bool
|
|
148
|
+
fly_in_fly_out_duration: List[Any]
|
|
149
|
+
work_format: List[IdName]
|
|
150
|
+
working_hours: List[IdName]
|
|
151
|
+
work_schedule_by_days: List[IdName]
|
|
152
|
+
accept_labor_contract: bool
|
|
153
|
+
civil_law_contracts: List[Any]
|
|
154
|
+
night_shifts: bool
|
|
155
|
+
accept_incomplete_resumes: bool
|
|
156
|
+
experience: IdName
|
|
157
|
+
employment: IdName
|
|
158
|
+
employment_form: IdName
|
|
159
|
+
internship: bool
|
|
160
|
+
adv_response_url: Optional[str]
|
|
161
|
+
is_adv_vacancy: bool
|
|
162
|
+
adv_context: Optional[dict]
|
|
163
|
+
allow_chat_with_manager: bool
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Phone(TypedDict):
|
|
167
|
+
country: str
|
|
168
|
+
city: str
|
|
169
|
+
number: str
|
|
170
|
+
formatted: str
|
|
171
|
+
comment: Optional[str]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ContactData(TypedDict):
|
|
175
|
+
name: Optional[str]
|
|
176
|
+
email: Optional[str]
|
|
177
|
+
phones: List[Phone]
|
|
178
|
+
call_tracking_enabled: bool
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ResumeShort(TypedDict):
|
|
182
|
+
id: str
|
|
183
|
+
title: str
|
|
184
|
+
url: str
|
|
185
|
+
alternate_url: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ResumeCounters(TypedDict):
|
|
189
|
+
total_views: int
|
|
190
|
+
new_views: int
|
|
191
|
+
invitations: int
|
|
192
|
+
new_invitations: int
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Resume(ResumeShort):
|
|
196
|
+
status: IdName
|
|
197
|
+
created_at: str
|
|
198
|
+
updated_at: str
|
|
199
|
+
can_publish_or_update: bool
|
|
200
|
+
counters: ResumeCounters
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class UserCounters(TypedDict):
|
|
204
|
+
resumes_count: int
|
|
205
|
+
new_resume_views: int
|
|
206
|
+
unread_negotiations: int
|
|
207
|
+
# ... and more
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class User(TypedDict):
|
|
211
|
+
id: int
|
|
212
|
+
first_name: str
|
|
213
|
+
last_name: str
|
|
214
|
+
middle_name: Optional[str]
|
|
215
|
+
email: Optional[str]
|
|
216
|
+
phone: Optional[str]
|
|
217
|
+
is_applicant: bool
|
|
218
|
+
is_employer: bool
|
|
219
|
+
is_admin: bool
|
|
220
|
+
is_anonymous: bool
|
|
221
|
+
is_application: bool
|
|
222
|
+
counters: UserCounters
|
|
223
|
+
# ... and more
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class Message(TypedDict):
|
|
227
|
+
id: str
|
|
228
|
+
text: str
|
|
229
|
+
author: dict # Could be more specific, e.g. Participant(TypedDict)
|
|
230
|
+
created_at: str
|
|
231
|
+
viewed_by_opponent: bool
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Counters(TypedDict):
|
|
235
|
+
messages: int
|
|
236
|
+
unread_messages: int
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ChatStates(TypedDict):
|
|
240
|
+
# response_reminder_state: {"allowed": bool}
|
|
241
|
+
response_reminder_state: dict[str, bool]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class NegotiaionState(IdName):
|
|
245
|
+
id: NegotiationStateId
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Negotiation(TypedDict):
|
|
249
|
+
id: str
|
|
250
|
+
state: IdName
|
|
251
|
+
created_at: str
|
|
252
|
+
updated_at: str
|
|
253
|
+
resume: ResumeShort
|
|
254
|
+
viewed_by_opponent: bool
|
|
255
|
+
has_updates: bool
|
|
256
|
+
messages_url: str
|
|
257
|
+
url: str
|
|
258
|
+
counters: Counters
|
|
259
|
+
chat_states: ChatStates
|
|
260
|
+
source: str
|
|
261
|
+
chat_id: int
|
|
262
|
+
messaging_status: str
|
|
263
|
+
decline_allowed: bool
|
|
264
|
+
read: bool
|
|
265
|
+
has_new_messages: bool
|
|
266
|
+
applicant_question_state: bool
|
|
267
|
+
hidden: bool
|
|
268
|
+
vacancy: NegotiationVacancy
|
|
269
|
+
tags: List[Any]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class EmployerApplicantServices(TypedDict):
|
|
273
|
+
target_employer: dict[str, int]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Employer(EmployerShort):
|
|
277
|
+
has_divisions: bool
|
|
278
|
+
type: str
|
|
279
|
+
description: Optional[str]
|
|
280
|
+
site_url: str
|
|
281
|
+
relations: List[Any]
|
|
282
|
+
area: IdName
|
|
283
|
+
country_code: str
|
|
284
|
+
industries: List[Any]
|
|
285
|
+
is_identified_by_esia: bool
|
|
286
|
+
badges: List[Any]
|
|
287
|
+
branded_description: Optional[str]
|
|
288
|
+
branding: Optional[dict]
|
|
289
|
+
insider_interviews: List[Any]
|
|
290
|
+
open_vacancies: int
|
|
291
|
+
applicant_services: EmployerApplicantServices
|
hh_applicant_tool/main.py
CHANGED
|
@@ -2,21 +2,35 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import logging
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
5
7
|
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from functools import cached_property
|
|
6
10
|
from importlib import import_module
|
|
11
|
+
from itertools import count
|
|
7
12
|
from os import getenv
|
|
8
13
|
from pathlib import Path
|
|
9
14
|
from pkgutil import iter_modules
|
|
10
|
-
from typing import
|
|
15
|
+
from typing import Any, Iterable
|
|
11
16
|
|
|
17
|
+
import requests
|
|
18
|
+
import urllib3
|
|
19
|
+
|
|
20
|
+
from . import datatypes, utils
|
|
12
21
|
from .api import ApiClient
|
|
13
|
-
from .
|
|
14
|
-
from .
|
|
15
|
-
from .utils import
|
|
22
|
+
from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
|
|
23
|
+
from .storage import StorageFacade
|
|
24
|
+
from .utils.log import setup_logger
|
|
25
|
+
from .utils.mixins import MegaTool
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
DEFAULT_CONFIG_DIR = utils.get_config_path() / (__package__ or "").replace(
|
|
28
|
+
"_", "-"
|
|
19
29
|
)
|
|
30
|
+
DEFAULT_CONFIG_FILENAME = "config.json"
|
|
31
|
+
DEFAULT_LOG_FILENAME = "log.txt"
|
|
32
|
+
DEFAULT_DATABASE_FILENAME = "data"
|
|
33
|
+
DEFAULT_PROFILE_ID = "."
|
|
20
34
|
|
|
21
35
|
logger = logging.getLogger(__package__)
|
|
22
36
|
|
|
@@ -24,15 +38,19 @@ logger = logging.getLogger(__package__)
|
|
|
24
38
|
class BaseOperation:
|
|
25
39
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
|
|
26
40
|
|
|
27
|
-
def run(
|
|
41
|
+
def run(
|
|
42
|
+
self,
|
|
43
|
+
tool: HHApplicantTool,
|
|
44
|
+
) -> None | int:
|
|
28
45
|
raise NotImplementedError()
|
|
29
46
|
|
|
30
47
|
|
|
31
48
|
OPERATIONS = "operations"
|
|
32
49
|
|
|
33
50
|
|
|
34
|
-
class
|
|
35
|
-
|
|
51
|
+
class BaseNamespace(argparse.Namespace):
|
|
52
|
+
profile_id: str
|
|
53
|
+
config_dir: Path
|
|
36
54
|
verbosity: int
|
|
37
55
|
delay: float
|
|
38
56
|
user_agent: str
|
|
@@ -40,32 +58,12 @@ class Namespace(argparse.Namespace):
|
|
|
40
58
|
disable_telemetry: bool
|
|
41
59
|
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
"http": args.config["proxy_url"] or getenv("HTTP_PROXY"),
|
|
46
|
-
"https": args.config["proxy_url"] or getenv("HTTPS_PROXY"),
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def get_api_client(args: Namespace) -> ApiClient:
|
|
51
|
-
token = args.config.get("token", {})
|
|
52
|
-
api = ApiClient(
|
|
53
|
-
access_token=token.get("access_token"),
|
|
54
|
-
refresh_token=token.get("refresh_token"),
|
|
55
|
-
access_expires_at=token.get("access_expires_at"),
|
|
56
|
-
delay=args.delay,
|
|
57
|
-
user_agent=args.config["user_agent"],
|
|
58
|
-
proxies=get_proxies(args),
|
|
59
|
-
)
|
|
60
|
-
return api
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class HHApplicantTool:
|
|
61
|
+
class HHApplicantTool(MegaTool):
|
|
64
62
|
"""Утилита для автоматизации действий соискателя на сайте hh.ru.
|
|
65
63
|
|
|
66
64
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
67
65
|
|
|
68
|
-
Группа поддержки: <https://t.me/
|
|
66
|
+
Группа поддержки: <https://t.me/hh_applicant_tool>
|
|
69
67
|
"""
|
|
70
68
|
|
|
71
69
|
class ArgumentFormatter(
|
|
@@ -74,49 +72,58 @@ class HHApplicantTool:
|
|
|
74
72
|
):
|
|
75
73
|
pass
|
|
76
74
|
|
|
77
|
-
def
|
|
75
|
+
def _create_parser(self) -> argparse.ArgumentParser:
|
|
78
76
|
parser = argparse.ArgumentParser(
|
|
79
77
|
description=self.__doc__,
|
|
80
78
|
formatter_class=self.ArgumentFormatter,
|
|
81
79
|
)
|
|
82
|
-
parser.add_argument(
|
|
83
|
-
"-c",
|
|
84
|
-
"--config",
|
|
85
|
-
help="Путь до файла конфигурации",
|
|
86
|
-
type=Config,
|
|
87
|
-
default=Config(DEFAULT_CONFIG_PATH),
|
|
88
|
-
)
|
|
89
80
|
parser.add_argument(
|
|
90
81
|
"-v",
|
|
91
82
|
"--verbosity",
|
|
92
|
-
help="При использовании от одного и более раз увеличивает количество отладочной информации в выводе",
|
|
83
|
+
help="При использовании от одного и более раз увеличивает количество отладочной информации в выводе", # noqa: E501
|
|
93
84
|
action="count",
|
|
94
85
|
default=0,
|
|
95
86
|
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"-c",
|
|
89
|
+
"--config-dir",
|
|
90
|
+
"--config",
|
|
91
|
+
help="Путь до директории с конфигом",
|
|
92
|
+
type=Path,
|
|
93
|
+
default=DEFAULT_CONFIG_DIR,
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--profile-id",
|
|
97
|
+
"--profile",
|
|
98
|
+
help="Используемый профиль — подкаталог в --config-dir",
|
|
99
|
+
default=DEFAULT_PROFILE_ID,
|
|
100
|
+
)
|
|
96
101
|
parser.add_argument(
|
|
97
102
|
"-d",
|
|
98
103
|
"--delay",
|
|
99
104
|
type=float,
|
|
100
|
-
default=0.
|
|
101
|
-
help="Задержка между запросами к API HH",
|
|
105
|
+
default=0.654,
|
|
106
|
+
help="Задержка между запросами к API HH по умолчанию",
|
|
102
107
|
)
|
|
103
|
-
parser.add_argument("--user-agent", help="User-Agent для каждого запроса")
|
|
104
108
|
parser.add_argument(
|
|
105
|
-
"--
|
|
109
|
+
"--user-agent",
|
|
110
|
+
help="User-Agent для каждого запроса",
|
|
106
111
|
)
|
|
107
112
|
parser.add_argument(
|
|
108
|
-
"--
|
|
109
|
-
|
|
110
|
-
action=argparse.BooleanOptionalAction,
|
|
111
|
-
help="Отключить телеметрию",
|
|
113
|
+
"--proxy-url",
|
|
114
|
+
help="Прокси, используемый для запросов и авторизации",
|
|
112
115
|
)
|
|
113
116
|
subparsers = parser.add_subparsers(help="commands")
|
|
114
117
|
package_dir = Path(__file__).resolve().parent / OPERATIONS
|
|
115
118
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
119
|
+
if module_name.startswith("_"):
|
|
120
|
+
continue
|
|
116
121
|
mod = import_module(f"{__package__}.{OPERATIONS}.{module_name}")
|
|
117
122
|
op: BaseOperation = mod.Operation()
|
|
123
|
+
kebab_name = module_name.replace("_", "-")
|
|
118
124
|
op_parser = subparsers.add_parser(
|
|
119
|
-
|
|
125
|
+
kebab_name,
|
|
126
|
+
aliases=getattr(op, "__aliases__", []),
|
|
120
127
|
description=op.__doc__,
|
|
121
128
|
formatter_class=self.ArgumentFormatter,
|
|
122
129
|
)
|
|
@@ -125,40 +132,187 @@ class HHApplicantTool:
|
|
|
125
132
|
parser.set_defaults(run=None)
|
|
126
133
|
return parser
|
|
127
134
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
def __init__(self, argv: Sequence[str] | None):
|
|
136
|
+
self._parse_args(argv)
|
|
137
|
+
|
|
138
|
+
# Создаем путь до конфига
|
|
139
|
+
self.config_path.mkdir(
|
|
140
|
+
parents=True,
|
|
141
|
+
exist_ok=True,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _get_proxies(self) -> dict[str, str]:
|
|
145
|
+
proxy_url = self.args.proxy_url or self.config.get("proxy_url")
|
|
146
|
+
|
|
147
|
+
if proxy_url:
|
|
148
|
+
return {
|
|
149
|
+
"http": proxy_url,
|
|
150
|
+
"https": proxy_url,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
proxies = {}
|
|
154
|
+
http_env = getenv("HTTP_PROXY") or getenv("http_proxy")
|
|
155
|
+
https_env = getenv("HTTPS_PROXY") or getenv("https_proxy") or http_env
|
|
156
|
+
|
|
157
|
+
if http_env:
|
|
158
|
+
proxies["http"] = http_env
|
|
159
|
+
if https_env:
|
|
160
|
+
proxies["https"] = https_env
|
|
161
|
+
|
|
162
|
+
return proxies
|
|
163
|
+
|
|
164
|
+
@cached_property
|
|
165
|
+
def session(self) -> requests.Session:
|
|
166
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
167
|
+
|
|
168
|
+
session = requests.session()
|
|
169
|
+
session.verify = False
|
|
170
|
+
|
|
171
|
+
if proxies := self._get_proxies():
|
|
172
|
+
logger.info("Use proxies: %r", proxies)
|
|
173
|
+
session.proxies = proxies
|
|
174
|
+
|
|
175
|
+
return session
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def config_path(self) -> Path:
|
|
179
|
+
return (self.args.config_dir / self.args.profile_id).resolve()
|
|
180
|
+
|
|
181
|
+
@cached_property
|
|
182
|
+
def config(self) -> utils.Config:
|
|
183
|
+
return utils.Config(self.config_path / DEFAULT_CONFIG_FILENAME)
|
|
184
|
+
|
|
185
|
+
@cached_property
|
|
186
|
+
def log_file(self) -> Path:
|
|
187
|
+
return self.config_path / DEFAULT_LOG_FILENAME
|
|
188
|
+
|
|
189
|
+
@cached_property
|
|
190
|
+
def db_path(self) -> Path:
|
|
191
|
+
return self.config_path / DEFAULT_DATABASE_FILENAME
|
|
192
|
+
|
|
193
|
+
@cached_property
|
|
194
|
+
def db(self) -> sqlite3.Connection:
|
|
195
|
+
conn = sqlite3.connect(self.db_path)
|
|
196
|
+
return conn
|
|
197
|
+
|
|
198
|
+
@cached_property
|
|
199
|
+
def storage(self) -> StorageFacade:
|
|
200
|
+
return StorageFacade(self.db)
|
|
201
|
+
|
|
202
|
+
@cached_property
|
|
203
|
+
def api_client(self) -> ApiClient:
|
|
204
|
+
args = self.args
|
|
205
|
+
config = self.config
|
|
206
|
+
token = config.get("token", {})
|
|
207
|
+
api = ApiClient(
|
|
208
|
+
client_id=config.get("client_id", ANDROID_CLIENT_ID),
|
|
209
|
+
client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
|
|
210
|
+
access_token=token.get("access_token"),
|
|
211
|
+
refresh_token=token.get("refresh_token"),
|
|
212
|
+
access_expires_at=token.get("access_expires_at"),
|
|
213
|
+
delay=args.delay,
|
|
214
|
+
user_agent=config["user_agent"] or utils.hh_android_useragent(),
|
|
215
|
+
session=self.session,
|
|
216
|
+
)
|
|
217
|
+
return api
|
|
218
|
+
|
|
219
|
+
def get_me(self) -> datatypes.User:
|
|
220
|
+
return self.api_client.get("/me")
|
|
221
|
+
|
|
222
|
+
def get_resumes(self) -> datatypes.PaginatedItems[datatypes.Resume]:
|
|
223
|
+
return self.api_client.get("/resumes/mine")
|
|
224
|
+
|
|
225
|
+
def first_resume_id(self):
|
|
226
|
+
resumes = self.api_client.get("/resumes/mine")
|
|
227
|
+
assert len(resumes["items"]), "Empty resume list"
|
|
228
|
+
return resumes["items"][0]["id"]
|
|
229
|
+
|
|
230
|
+
def get_blacklisted(self) -> list[str]:
|
|
231
|
+
rv = []
|
|
232
|
+
for page in count():
|
|
233
|
+
r: datatypes.PaginatedItems[datatypes.EmployerShort] = (
|
|
234
|
+
self.api_client.get("/employers/blacklisted", page=page)
|
|
235
|
+
)
|
|
236
|
+
rv += [item["id"] for item in r["items"]]
|
|
237
|
+
if page + 1 >= r["pages"]:
|
|
238
|
+
break
|
|
239
|
+
return rv
|
|
240
|
+
|
|
241
|
+
def get_negotiations(
|
|
242
|
+
self, status: str = "active"
|
|
243
|
+
) -> Iterable[datatypes.Negotiation]:
|
|
244
|
+
for page in count():
|
|
245
|
+
r: dict[str, Any] = self.api_client.get(
|
|
246
|
+
"/negotiations",
|
|
247
|
+
page=page,
|
|
248
|
+
per_page=100,
|
|
249
|
+
status=status,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
items = r.get("items", [])
|
|
253
|
+
|
|
254
|
+
if not items:
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
yield from items
|
|
258
|
+
|
|
259
|
+
if page + 1 >= r.get("pages", 0):
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
def save_token(self) -> bool:
|
|
263
|
+
if self.api_client.access_token != self.config.get("token", {}).get(
|
|
264
|
+
"access_token"
|
|
265
|
+
):
|
|
266
|
+
self.config.save(token=self.api_client.get_access_token())
|
|
267
|
+
return True
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
def run(self) -> None | int:
|
|
271
|
+
verbosity_level = max(
|
|
272
|
+
logging.DEBUG,
|
|
273
|
+
logging.WARNING - self.args.verbosity * 10,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
setup_logger(logger, verbosity_level, self.log_file)
|
|
277
|
+
|
|
278
|
+
if sys.platform == "win32":
|
|
279
|
+
utils.setup_terminal()
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
if self.args.run:
|
|
283
|
+
try:
|
|
284
|
+
res = self.args.run(self)
|
|
285
|
+
if self.save_token():
|
|
286
|
+
logger.info("Токен был обновлен.")
|
|
287
|
+
return res
|
|
288
|
+
except KeyboardInterrupt:
|
|
289
|
+
logger.warning("Выполнение прервано пользователем!")
|
|
290
|
+
return 1
|
|
291
|
+
except sqlite3.Error as ex:
|
|
292
|
+
logger.exception(ex)
|
|
293
|
+
|
|
294
|
+
script_name = sys.argv[0].split(os.sep)[-1]
|
|
295
|
+
|
|
296
|
+
logger.warning(
|
|
297
|
+
f"Возможно база данных повреждена, попробуйте выполнить команду:\n\n" # noqa: E501
|
|
298
|
+
f" {script_name} migrate-db"
|
|
299
|
+
)
|
|
300
|
+
return 1
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.exception(e)
|
|
303
|
+
return 1
|
|
304
|
+
self._parser.print_help(file=sys.stderr)
|
|
305
|
+
return 2
|
|
306
|
+
finally:
|
|
138
307
|
try:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
proxies=api_client.proxies.copy(),
|
|
147
|
-
)
|
|
148
|
-
# 0 or None = success
|
|
149
|
-
res = args.run(args, api_client, telemetry_client)
|
|
150
|
-
if (token := api_client.get_access_token()) != args.config["token"]:
|
|
151
|
-
args.config.save(token=token)
|
|
152
|
-
return res
|
|
153
|
-
except KeyboardInterrupt:
|
|
154
|
-
logger.warning("Interrupted by user")
|
|
155
|
-
return 1
|
|
156
|
-
except Exception as e:
|
|
157
|
-
logger.exception(e)
|
|
158
|
-
return 1
|
|
159
|
-
parser.print_help(file=sys.stderr)
|
|
160
|
-
return 2
|
|
308
|
+
self.check_system()
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
def _parse_args(self, argv) -> None:
|
|
313
|
+
self._parser = self._create_parser()
|
|
314
|
+
self.args = self._parser.parse_args(argv, namespace=BaseNamespace())
|
|
161
315
|
|
|
162
316
|
|
|
163
317
|
def main(argv: Sequence[str] | None = None) -> None | int:
|
|
164
|
-
return HHApplicantTool().run(
|
|
318
|
+
return HHApplicantTool(argv).run()
|