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
|
@@ -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,22 +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 .color_log import ColorHandler
|
|
14
22
|
from .constants import ANDROID_CLIENT_ID, ANDROID_CLIENT_SECRET
|
|
15
|
-
from .
|
|
16
|
-
from .utils import
|
|
23
|
+
from .storage import StorageFacade
|
|
24
|
+
from .utils.log import setup_logger
|
|
25
|
+
from .utils.mixins import MegaTool
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
DEFAULT_CONFIG_DIR = utils.get_config_path() / (__package__ or "").replace(
|
|
28
|
+
"_", "-"
|
|
20
29
|
)
|
|
30
|
+
DEFAULT_CONFIG_FILENAME = "config.json"
|
|
31
|
+
DEFAULT_LOG_FILENAME = "log.txt"
|
|
32
|
+
DEFAULT_DATABASE_FILENAME = "data"
|
|
33
|
+
DEFAULT_PROFILE_ID = "."
|
|
21
34
|
|
|
22
35
|
logger = logging.getLogger(__package__)
|
|
23
36
|
|
|
@@ -25,15 +38,19 @@ logger = logging.getLogger(__package__)
|
|
|
25
38
|
class BaseOperation:
|
|
26
39
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None: ...
|
|
27
40
|
|
|
28
|
-
def run(
|
|
41
|
+
def run(
|
|
42
|
+
self,
|
|
43
|
+
tool: HHApplicantTool,
|
|
44
|
+
) -> None | int:
|
|
29
45
|
raise NotImplementedError()
|
|
30
46
|
|
|
31
47
|
|
|
32
48
|
OPERATIONS = "operations"
|
|
33
49
|
|
|
34
50
|
|
|
35
|
-
class
|
|
36
|
-
|
|
51
|
+
class BaseNamespace(argparse.Namespace):
|
|
52
|
+
profile_id: str
|
|
53
|
+
config_dir: Path
|
|
37
54
|
verbosity: int
|
|
38
55
|
delay: float
|
|
39
56
|
user_agent: str
|
|
@@ -41,44 +58,7 @@ class Namespace(argparse.Namespace):
|
|
|
41
58
|
disable_telemetry: bool
|
|
42
59
|
|
|
43
60
|
|
|
44
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def get_api_client(args: Namespace) -> ApiClient:
|
|
66
|
-
config = args.config
|
|
67
|
-
token = config.get("token", {})
|
|
68
|
-
api = ApiClient(
|
|
69
|
-
client_id=config.get("client_id", ANDROID_CLIENT_ID),
|
|
70
|
-
client_secret=config.get("client_id", ANDROID_CLIENT_SECRET),
|
|
71
|
-
access_token=token.get("access_token"),
|
|
72
|
-
refresh_token=token.get("refresh_token"),
|
|
73
|
-
access_expires_at=token.get("access_expires_at"),
|
|
74
|
-
delay=args.delay,
|
|
75
|
-
user_agent=config["user_agent"] or android_user_agent(),
|
|
76
|
-
proxies=get_proxies(args),
|
|
77
|
-
)
|
|
78
|
-
return api
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
class HHApplicantTool:
|
|
61
|
+
class HHApplicantTool(MegaTool):
|
|
82
62
|
"""Утилита для автоматизации действий соискателя на сайте hh.ru.
|
|
83
63
|
|
|
84
64
|
Исходники и предложения: <https://github.com/s3rgeym/hh-applicant-tool>
|
|
@@ -92,63 +72,58 @@ class HHApplicantTool:
|
|
|
92
72
|
):
|
|
93
73
|
pass
|
|
94
74
|
|
|
95
|
-
def
|
|
75
|
+
def _create_parser(self) -> argparse.ArgumentParser:
|
|
96
76
|
parser = argparse.ArgumentParser(
|
|
97
77
|
description=self.__doc__,
|
|
98
78
|
formatter_class=self.ArgumentFormatter,
|
|
99
79
|
)
|
|
100
|
-
parser.add_argument(
|
|
101
|
-
"-c",
|
|
102
|
-
"--config",
|
|
103
|
-
help="Путь до файла конфигурации",
|
|
104
|
-
type=Config,
|
|
105
|
-
default=Config(DEFAULT_CONFIG_PATH),
|
|
106
|
-
)
|
|
107
80
|
parser.add_argument(
|
|
108
81
|
"-v",
|
|
109
82
|
"--verbosity",
|
|
110
|
-
help="При использовании от одного и более раз увеличивает количество отладочной информации в выводе",
|
|
83
|
+
help="При использовании от одного и более раз увеличивает количество отладочной информации в выводе", # noqa: E501
|
|
111
84
|
action="count",
|
|
112
85
|
default=0,
|
|
113
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
|
+
)
|
|
114
101
|
parser.add_argument(
|
|
115
102
|
"-d",
|
|
116
103
|
"--delay",
|
|
117
104
|
type=float,
|
|
118
|
-
default=0.
|
|
119
|
-
help="Задержка между запросами к API HH",
|
|
105
|
+
default=0.654,
|
|
106
|
+
help="Задержка между запросами к API HH по умолчанию",
|
|
120
107
|
)
|
|
121
|
-
parser.add_argument("--user-agent", help="User-Agent для каждого запроса")
|
|
122
108
|
parser.add_argument(
|
|
123
|
-
"--
|
|
109
|
+
"--user-agent",
|
|
110
|
+
help="User-Agent для каждого запроса",
|
|
124
111
|
)
|
|
125
112
|
parser.add_argument(
|
|
126
|
-
"--
|
|
127
|
-
|
|
128
|
-
action=argparse.BooleanOptionalAction,
|
|
129
|
-
help="Отключить телеметрию",
|
|
113
|
+
"--proxy-url",
|
|
114
|
+
help="Прокси, используемый для запросов и авторизации",
|
|
130
115
|
)
|
|
131
116
|
subparsers = parser.add_subparsers(help="commands")
|
|
132
117
|
package_dir = Path(__file__).resolve().parent / OPERATIONS
|
|
133
118
|
for _, module_name, _ in iter_modules([str(package_dir)]):
|
|
119
|
+
if module_name.startswith("_"):
|
|
120
|
+
continue
|
|
134
121
|
mod = import_module(f"{__package__}.{OPERATIONS}.{module_name}")
|
|
135
122
|
op: BaseOperation = mod.Operation()
|
|
136
|
-
|
|
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
|
-
|
|
123
|
+
kebab_name = module_name.replace("_", "-")
|
|
148
124
|
op_parser = subparsers.add_parser(
|
|
149
125
|
kebab_name,
|
|
150
|
-
|
|
151
|
-
aliases=[camel_case_name, flat_name],
|
|
126
|
+
aliases=getattr(op, "__aliases__", []),
|
|
152
127
|
description=op.__doc__,
|
|
153
128
|
formatter_class=self.ArgumentFormatter,
|
|
154
129
|
)
|
|
@@ -157,40 +132,187 @@ class HHApplicantTool:
|
|
|
157
132
|
parser.set_defaults(run=None)
|
|
158
133
|
return parser
|
|
159
134
|
|
|
160
|
-
def
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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:
|
|
170
307
|
try:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
proxies=api_client.proxies.copy(),
|
|
179
|
-
)
|
|
180
|
-
# 0 or None = success
|
|
181
|
-
res = args.run(args, api_client, telemetry_client)
|
|
182
|
-
if (token := api_client.get_access_token()) != args.config["token"]:
|
|
183
|
-
args.config.save(token=token)
|
|
184
|
-
return res
|
|
185
|
-
except KeyboardInterrupt:
|
|
186
|
-
logger.warning("Interrupted by user")
|
|
187
|
-
return 1
|
|
188
|
-
except Exception as e:
|
|
189
|
-
logger.exception(e, exc_info=log_level <= logging.DEBUG)
|
|
190
|
-
return 1
|
|
191
|
-
parser.print_help(file=sys.stderr)
|
|
192
|
-
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())
|
|
193
315
|
|
|
194
316
|
|
|
195
317
|
def main(argv: Sequence[str] | None = None) -> None | int:
|
|
196
|
-
return HHApplicantTool().run(
|
|
318
|
+
return HHApplicantTool(argv).run()
|