hh-applicant-tool 0.6.3__py3-none-any.whl → 0.7.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hh_applicant_tool/ai/openai.py +71 -0
- hh_applicant_tool/api/client.py +48 -50
- hh_applicant_tool/api/errors.py +6 -1
- hh_applicant_tool/color_log.py +12 -0
- hh_applicant_tool/main.py +44 -12
- hh_applicant_tool/operations/apply_similar.py +242 -20
- hh_applicant_tool/operations/authorize.py +52 -13
- hh_applicant_tool/operations/clear_negotiations.py +5 -5
- hh_applicant_tool/operations/config.py +0 -2
- hh_applicant_tool/operations/get_employer_contacts.py +62 -7
- hh_applicant_tool/operations/reply_employers.py +3 -2
- hh_applicant_tool/telemetry_client.py +1 -1
- hh_applicant_tool/types.py +1 -0
- hh_applicant_tool/utils.py +17 -2
- hh_applicant_tool-0.7.10.dist-info/METADATA +452 -0
- hh_applicant_tool-0.7.10.dist-info/RECORD +33 -0
- {hh_applicant_tool-0.6.3.dist-info → hh_applicant_tool-0.7.10.dist-info}/WHEEL +1 -1
- hh_applicant_tool-0.6.3.dist-info/METADATA +0 -333
- hh_applicant_tool-0.6.3.dist-info/RECORD +0 -32
- {hh_applicant_tool-0.6.3.dist-info → hh_applicant_tool-0.7.10.dist-info}/entry_points.txt +0 -0
|
@@ -4,11 +4,13 @@ import random
|
|
|
4
4
|
import time
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
|
-
from
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TextIO
|
|
8
9
|
|
|
10
|
+
from ..ai.blackbox import BlackboxChat
|
|
11
|
+
from ..ai.openai import OpenAIChat
|
|
12
|
+
from ..api import ApiClient, BadResponse
|
|
9
13
|
from ..api.errors import LimitExceeded
|
|
10
|
-
from ..ai.blackbox import BlackboxChat, BlackboxError
|
|
11
|
-
from ..api import ApiError, ApiClient
|
|
12
14
|
from ..main import BaseOperation
|
|
13
15
|
from ..main import Namespace as BaseNamespace
|
|
14
16
|
from ..mixins import GetResumeIdMixin
|
|
@@ -28,6 +30,7 @@ logger = logging.getLogger(__package__)
|
|
|
28
30
|
class Namespace(BaseNamespace):
|
|
29
31
|
resume_id: str | None
|
|
30
32
|
message_list: TextIO
|
|
33
|
+
ignore_employers: Path | None
|
|
31
34
|
force_message: bool
|
|
32
35
|
use_ai: bool
|
|
33
36
|
pre_prompt: str
|
|
@@ -35,11 +38,47 @@ class Namespace(BaseNamespace):
|
|
|
35
38
|
page_interval: tuple[float, float]
|
|
36
39
|
order_by: str
|
|
37
40
|
search: str
|
|
41
|
+
schedule: str
|
|
38
42
|
dry_run: bool
|
|
43
|
+
# Пошли доп фильтры, которых не было
|
|
44
|
+
experience: str
|
|
45
|
+
employment: list[str] | None
|
|
46
|
+
area: list[str] | None
|
|
47
|
+
metro: list[str] | None
|
|
48
|
+
professional_role: list[str] | None
|
|
49
|
+
industry: list[str] | None
|
|
50
|
+
employer_id: list[str] | None
|
|
51
|
+
excluded_employer_id: list[str] | None
|
|
52
|
+
currency: str | None
|
|
53
|
+
salary: int | None
|
|
54
|
+
only_with_salary: bool
|
|
55
|
+
label: list[str] | None
|
|
56
|
+
period: int | None
|
|
57
|
+
date_from: str | None
|
|
58
|
+
date_to: str | None
|
|
59
|
+
top_lat: float | None
|
|
60
|
+
bottom_lat: float | None
|
|
61
|
+
left_lng: float | None
|
|
62
|
+
right_lng: float | None
|
|
63
|
+
sort_point_lat: float | None
|
|
64
|
+
sort_point_lng: float | None
|
|
65
|
+
no_magic: bool
|
|
66
|
+
premium: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _bool(v: bool) -> str:
|
|
70
|
+
return str(v).lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _join_list(items: list[Any] | None) -> str:
|
|
74
|
+
return ",".join(f"{v}" for v in items) if items else ""
|
|
39
75
|
|
|
40
76
|
|
|
41
77
|
class Operation(BaseOperation, GetResumeIdMixin):
|
|
42
|
-
"""Откликнуться на все подходящие вакансии.
|
|
78
|
+
"""Откликнуться на все подходящие вакансии.
|
|
79
|
+
|
|
80
|
+
Описание фильтров для поиска вакансий: <https://api.hh.ru/openapi/redoc#tag/Poisk-vakansij-dlya-soiskatelya/operation/get-vacancies-similar-to-resume>
|
|
81
|
+
"""
|
|
43
82
|
|
|
44
83
|
def setup_parser(self, parser: argparse.ArgumentParser) -> None:
|
|
45
84
|
parser.add_argument("--resume-id", help="Идентефикатор резюме")
|
|
@@ -49,6 +88,12 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
49
88
|
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
|
|
50
89
|
type=argparse.FileType("r", encoding="utf-8", errors="replace"),
|
|
51
90
|
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--ignore-employers",
|
|
93
|
+
help="Путь к файлу со списком ID игнорируемых работодателей (по одному ID на строку)",
|
|
94
|
+
type=Path,
|
|
95
|
+
default=None,
|
|
96
|
+
)
|
|
52
97
|
parser.add_argument(
|
|
53
98
|
"-f",
|
|
54
99
|
"--force-message",
|
|
@@ -100,12 +145,79 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
100
145
|
type=str,
|
|
101
146
|
default=None,
|
|
102
147
|
)
|
|
148
|
+
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--schedule",
|
|
151
|
+
help="Тип графика. Возможные значения: fullDay, shift, flexible, remote, flyInFlyOut для полного дня, сменного графика, гибкого графика, удаленной работы и вахтового метода",
|
|
152
|
+
type=str,
|
|
153
|
+
default=None,
|
|
154
|
+
)
|
|
103
155
|
parser.add_argument(
|
|
104
156
|
"--dry-run",
|
|
105
157
|
help="Не отправлять отклики, а только выводить параметры запроса",
|
|
106
158
|
default=False,
|
|
107
159
|
action=argparse.BooleanOptionalAction,
|
|
108
160
|
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--experience",
|
|
163
|
+
help="Уровень опыта работы в вакансии. Возможные значения: noExperience, between1And3, between3And6, moreThan6",
|
|
164
|
+
type=str,
|
|
165
|
+
default=None,
|
|
166
|
+
)
|
|
167
|
+
parser.add_argument(
|
|
168
|
+
"--employment", nargs="+", help="Тип занятости (employment)"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument("--area", nargs="+", help="Регион (area id)")
|
|
171
|
+
parser.add_argument("--metro", nargs="+", help="Станции метро (metro id)")
|
|
172
|
+
parser.add_argument("--professional-role", nargs="+", help="Проф. роль (id)")
|
|
173
|
+
parser.add_argument("--industry", nargs="+", help="Индустрия (industry id)")
|
|
174
|
+
parser.add_argument("--employer-id", nargs="+", help="ID работодателей")
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--excluded-employer-id", nargs="+", help="Исключить работодателей"
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument("--currency", help="Код валюты (RUR, USD, EUR)")
|
|
179
|
+
parser.add_argument("--salary", type=int, help="Минимальная зарплата")
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--only-with-salary", default=False, action=argparse.BooleanOptionalAction
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument("--label", nargs="+", help="Метки вакансий (label)")
|
|
184
|
+
parser.add_argument("--period", type=int, help="Искать вакансии за N дней")
|
|
185
|
+
parser.add_argument("--date-from", help="Дата публикации с (YYYY-MM-DD)")
|
|
186
|
+
parser.add_argument("--date-to", help="Дата публикации по (YYYY-MM-DD)")
|
|
187
|
+
parser.add_argument("--top-lat", type=float, help="Гео: верхняя широта")
|
|
188
|
+
parser.add_argument("--bottom-lat", type=float, help="Гео: нижняя широта")
|
|
189
|
+
parser.add_argument("--left-lng", type=float, help="Гео: левая долгота")
|
|
190
|
+
parser.add_argument("--right-lng", type=float, help="Гео: правая долгота")
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--sort-point-lat",
|
|
193
|
+
type=float,
|
|
194
|
+
help="Координата lat для сортировки по расстоянию",
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--sort-point-lng",
|
|
198
|
+
type=float,
|
|
199
|
+
help="Координата lng для сортировки по расстоянию",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--no-magic",
|
|
203
|
+
action="store_true",
|
|
204
|
+
help="Отключить авторазбор текста запроса",
|
|
205
|
+
)
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--premium",
|
|
208
|
+
default=False,
|
|
209
|
+
action=argparse.BooleanOptionalAction,
|
|
210
|
+
help="Только премиум вакансии",
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--search-field", nargs="+", help="Поля поиска (name, company_name и т.п.)"
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--clusters",
|
|
217
|
+
action=argparse.BooleanOptionalAction,
|
|
218
|
+
help="Включить кластеры (по умолчанию None)",
|
|
219
|
+
)
|
|
220
|
+
# parser.add_argument("--describe-arguments", action=argparse.BooleanOptionalAction, help="Вернуть описание параметров запроса")
|
|
109
221
|
|
|
110
222
|
def run(
|
|
111
223
|
self, args: Namespace, api_client: ApiClient, telemetry_client: TelemetryClient
|
|
@@ -130,6 +242,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
130
242
|
self.telemetry_client = telemetry_client
|
|
131
243
|
self.resume_id = args.resume_id or self._get_resume_id()
|
|
132
244
|
self.application_messages = self._get_application_messages(args.message_list)
|
|
245
|
+
self.ignored_employers = self._get_ignored_employers(args.ignore_employers)
|
|
133
246
|
self.chat = None
|
|
134
247
|
|
|
135
248
|
if config := args.config.get("blackbox"):
|
|
@@ -138,6 +251,19 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
138
251
|
chat_payload=config["chat_payload"],
|
|
139
252
|
proxies=self.api_client.proxies or {},
|
|
140
253
|
)
|
|
254
|
+
elif config := args.config.get("openai"):
|
|
255
|
+
model = "gpt-5.1"
|
|
256
|
+
system_prompt = "Напиши сопроводительное письмо для отклика на эту вакансию. Не используй placeholder'ы, твой ответ будет отправлен без обработки."
|
|
257
|
+
if "model" in config.keys():
|
|
258
|
+
model = config["model"]
|
|
259
|
+
if "system_prompt" in config.keys():
|
|
260
|
+
system_prompt = config["system_prompt"]
|
|
261
|
+
self.chat = OpenAIChat(
|
|
262
|
+
token=config["token"],
|
|
263
|
+
model=model,
|
|
264
|
+
system_prompt=system_prompt,
|
|
265
|
+
proxies=self.api_client.proxies or {},
|
|
266
|
+
)
|
|
141
267
|
|
|
142
268
|
self.pre_prompt = args.pre_prompt
|
|
143
269
|
|
|
@@ -147,7 +273,34 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
147
273
|
self.force_message = args.force_message
|
|
148
274
|
self.order_by = args.order_by
|
|
149
275
|
self.search = args.search
|
|
276
|
+
self.schedule = args.schedule
|
|
150
277
|
self.dry_run = args.dry_run
|
|
278
|
+
self.experience = args.experience
|
|
279
|
+
self.search_field = args.search_field
|
|
280
|
+
self.employment = args.employment
|
|
281
|
+
self.area = args.area
|
|
282
|
+
self.metro = args.metro
|
|
283
|
+
self.professional_role = args.professional_role
|
|
284
|
+
self.industry = args.industry
|
|
285
|
+
self.employer_id = args.employer_id
|
|
286
|
+
self.excluded_employer_id = args.excluded_employer_id
|
|
287
|
+
self.currency = args.currency
|
|
288
|
+
self.salary = args.salary
|
|
289
|
+
self.only_with_salary = args.only_with_salary
|
|
290
|
+
self.label = args.label
|
|
291
|
+
self.period = args.period
|
|
292
|
+
self.date_from = args.date_from
|
|
293
|
+
self.date_to = args.date_to
|
|
294
|
+
self.top_lat = args.top_lat
|
|
295
|
+
self.bottom_lat = args.bottom_lat
|
|
296
|
+
self.left_lng = args.left_lng
|
|
297
|
+
self.right_lng = args.right_lng
|
|
298
|
+
self.sort_point_lat = args.sort_point_lat
|
|
299
|
+
self.sort_point_lng = args.sort_point_lng
|
|
300
|
+
self.clusters = args.clusters
|
|
301
|
+
# self.describe_arguments = args.describe_arguments
|
|
302
|
+
self.no_magic = args.no_magic
|
|
303
|
+
self.premium = args.premium
|
|
151
304
|
self._apply_similar()
|
|
152
305
|
|
|
153
306
|
def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
|
|
@@ -160,6 +313,16 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
160
313
|
]
|
|
161
314
|
return application_messages
|
|
162
315
|
|
|
316
|
+
def _get_ignored_employers(self, file_path: Path | None) -> set[str]:
|
|
317
|
+
ignored = set()
|
|
318
|
+
if file_path is not None:
|
|
319
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
320
|
+
for line in f:
|
|
321
|
+
if clean_id := line.strip():
|
|
322
|
+
ignored.add(int(clean_id))
|
|
323
|
+
logger.info("Загружено %d ID игнорируемых работодателей", len(ignored))
|
|
324
|
+
return ignored
|
|
325
|
+
|
|
163
326
|
def _apply_similar(self) -> None:
|
|
164
327
|
telemetry_client = self.telemetry_client
|
|
165
328
|
telemetry_data = defaultdict(dict)
|
|
@@ -201,7 +364,6 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
201
364
|
}
|
|
202
365
|
|
|
203
366
|
do_apply = True
|
|
204
|
-
complained_employers = set()
|
|
205
367
|
|
|
206
368
|
for vacancy in vacancies:
|
|
207
369
|
try:
|
|
@@ -231,13 +393,13 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
231
393
|
continue
|
|
232
394
|
|
|
233
395
|
relations = vacancy.get("relations", [])
|
|
234
|
-
employer_id = vacancy.get("employer", {}).get("id")
|
|
396
|
+
employer_id = int(vacancy.get("employer", {}).get("id", 0))
|
|
235
397
|
|
|
236
398
|
if (
|
|
237
399
|
self.enable_telemetry
|
|
238
400
|
and employer_id
|
|
239
401
|
and employer_id not in telemetry_data["employers"]
|
|
240
|
-
and employer_id not in
|
|
402
|
+
and employer_id not in self.ignored_employers
|
|
241
403
|
and (
|
|
242
404
|
not relations
|
|
243
405
|
or parse_invalid_datetime(vacancy["created_at"])
|
|
@@ -260,17 +422,16 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
260
422
|
% employer_id
|
|
261
423
|
)
|
|
262
424
|
|
|
263
|
-
|
|
425
|
+
self.ignored_employers.add(employer_id)
|
|
264
426
|
|
|
265
427
|
elif do_apply:
|
|
266
428
|
telemetry_data["employers"][employer_id] = employer_data
|
|
267
429
|
|
|
268
430
|
if not do_apply:
|
|
269
431
|
logger.debug(
|
|
270
|
-
"
|
|
271
|
-
vacancy["alternate_url"],
|
|
432
|
+
"Останавливаем рассылку откликов, так как достигли лимита, попробуйте через сутки."
|
|
272
433
|
)
|
|
273
|
-
|
|
434
|
+
break
|
|
274
435
|
|
|
275
436
|
if relations:
|
|
276
437
|
logger.debug(
|
|
@@ -292,7 +453,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
292
453
|
msg += message_placeholders["vacancy_name"]
|
|
293
454
|
logger.debug(msg)
|
|
294
455
|
msg = self.chat.send_message(msg)
|
|
295
|
-
except
|
|
456
|
+
except Exception as ex:
|
|
296
457
|
logger.error(ex)
|
|
297
458
|
continue
|
|
298
459
|
else:
|
|
@@ -330,7 +491,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
330
491
|
except LimitExceeded:
|
|
331
492
|
print("⚠️ Достигли лимита рассылки")
|
|
332
493
|
do_apply = False
|
|
333
|
-
except
|
|
494
|
+
except BadResponse as ex:
|
|
334
495
|
logger.error(ex)
|
|
335
496
|
|
|
336
497
|
print("📝 Отклики на вакансии разосланы!")
|
|
@@ -352,16 +513,77 @@ class Operation(BaseOperation, GetResumeIdMixin):
|
|
|
352
513
|
except TelemetryError as ex:
|
|
353
514
|
logger.error(ex)
|
|
354
515
|
|
|
516
|
+
def _get_search_params(self, page: int, per_page: int) -> dict:
|
|
517
|
+
params = {
|
|
518
|
+
"page": page,
|
|
519
|
+
"per_page": per_page,
|
|
520
|
+
"order_by": self.order_by,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if self.search:
|
|
524
|
+
params["text"] = self.search
|
|
525
|
+
if self.schedule:
|
|
526
|
+
params["schedule"] = self.schedule
|
|
527
|
+
if self.experience:
|
|
528
|
+
params["experience"] = self.experience
|
|
529
|
+
if self.currency:
|
|
530
|
+
params["currency"] = self.currency
|
|
531
|
+
if self.salary:
|
|
532
|
+
params["salary"] = self.salary
|
|
533
|
+
if self.period:
|
|
534
|
+
params["period"] = self.period
|
|
535
|
+
if self.date_from:
|
|
536
|
+
params["date_from"] = self.date_from
|
|
537
|
+
if self.date_to:
|
|
538
|
+
params["date_to"] = self.date_to
|
|
539
|
+
if self.top_lat:
|
|
540
|
+
params["top_lat"] = self.top_lat
|
|
541
|
+
if self.bottom_lat:
|
|
542
|
+
params["bottom_lat"] = self.bottom_lat
|
|
543
|
+
if self.left_lng:
|
|
544
|
+
params["left_lng"] = self.left_lng
|
|
545
|
+
if self.right_lng:
|
|
546
|
+
params["right_lng"] = self.right_lng
|
|
547
|
+
if self.sort_point_lat:
|
|
548
|
+
params["sort_point_lat"] = self.sort_point_lat
|
|
549
|
+
if self.sort_point_lng:
|
|
550
|
+
params["sort_point_lng"] = self.sort_point_lng
|
|
551
|
+
if self.search_field:
|
|
552
|
+
params["search_field"] = _join_list(self.search_field)
|
|
553
|
+
if self.employment:
|
|
554
|
+
params["employment"] = _join_list(self.employment)
|
|
555
|
+
if self.area:
|
|
556
|
+
params["area"] = _join_list(self.area)
|
|
557
|
+
if self.metro:
|
|
558
|
+
params["metro"] = _join_list(self.metro)
|
|
559
|
+
if self.professional_role:
|
|
560
|
+
params["professional_role"] = _join_list(self.professional_role)
|
|
561
|
+
if self.industry:
|
|
562
|
+
params["industry"] = _join_list(self.industry)
|
|
563
|
+
if self.employer_id:
|
|
564
|
+
params["employer_id"] = _join_list(self.employer_id)
|
|
565
|
+
if self.excluded_employer_id:
|
|
566
|
+
params["excluded_employer_id"] = _join_list(self.excluded_employer_id)
|
|
567
|
+
if self.label:
|
|
568
|
+
params["label"] = _join_list(self.label)
|
|
569
|
+
if self.only_with_salary is not None:
|
|
570
|
+
params["only_with_salary"] = _bool(self.only_with_salary)
|
|
571
|
+
if self.clusters is not None:
|
|
572
|
+
params["clusters"] = _bool(self.clusters)
|
|
573
|
+
if self.no_magic is not None:
|
|
574
|
+
params["no_magic"] = _bool(self.no_magic)
|
|
575
|
+
if self.premium is not None:
|
|
576
|
+
params["premium"] = _bool(self.premium)
|
|
577
|
+
# if self.responses_count_enabled is not None:
|
|
578
|
+
# params["responses_count_enabled"] = _bool(self.responses_count_enabled)
|
|
579
|
+
|
|
580
|
+
return params
|
|
581
|
+
|
|
355
582
|
def _get_vacancies(self, per_page: int = 100) -> list[VacancyItem]:
|
|
356
583
|
rv = []
|
|
584
|
+
# API отдает только 2000 результатов
|
|
357
585
|
for page in range(20):
|
|
358
|
-
params =
|
|
359
|
-
"page": page,
|
|
360
|
-
"per_page": per_page,
|
|
361
|
-
"order_by": self.order_by,
|
|
362
|
-
}
|
|
363
|
-
if self.search:
|
|
364
|
-
params["text"] = self.search
|
|
586
|
+
params = self._get_search_params(page, per_page)
|
|
365
587
|
res: ApiListResponse = self.api_client.get(
|
|
366
588
|
f"/resumes/{self.resume_id}/similar_vacancies", params
|
|
367
589
|
)
|
|
@@ -5,17 +5,26 @@ import sys
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
from ..utils import print_err
|
|
7
7
|
|
|
8
|
+
from ..api import ApiClient # noqa: E402
|
|
9
|
+
from ..main import BaseOperation, Namespace # noqa: E402
|
|
10
|
+
|
|
11
|
+
HH_ANDROID_SCHEME = "hhandroid"
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__package__)
|
|
8
14
|
|
|
9
15
|
QT_IMPORTED = False
|
|
10
16
|
|
|
11
17
|
try:
|
|
12
18
|
from PyQt6.QtCore import QUrl
|
|
13
19
|
from PyQt6.QtWidgets import QApplication, QMainWindow
|
|
20
|
+
from PyQt6.QtWebEngineCore import QWebEngineUrlScheme
|
|
14
21
|
from PyQt6.QtWebEngineCore import QWebEngineUrlSchemeHandler
|
|
15
22
|
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
23
|
+
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
16
24
|
|
|
17
25
|
QT_IMPORTED = True
|
|
18
|
-
except ImportError:
|
|
26
|
+
except ImportError as ex:
|
|
27
|
+
logger.debug(ex)
|
|
19
28
|
# Заглушки чтобы на сервере не нужно было ставить сотни мегабайт qt-говна
|
|
20
29
|
|
|
21
30
|
class QUrl:
|
|
@@ -34,11 +43,6 @@ except ImportError:
|
|
|
34
43
|
pass
|
|
35
44
|
|
|
36
45
|
|
|
37
|
-
from ..api import ApiClient # noqa: E402
|
|
38
|
-
from ..main import BaseOperation, Namespace # noqa: E402
|
|
39
|
-
|
|
40
|
-
logger = logging.getLogger(__package__)
|
|
41
|
-
|
|
42
46
|
|
|
43
47
|
class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
|
|
44
48
|
def __init__(self, parent: "WebViewWindow") -> None:
|
|
@@ -47,25 +51,51 @@ class HHAndroidUrlSchemeHandler(QWebEngineUrlSchemeHandler):
|
|
|
47
51
|
|
|
48
52
|
def requestStarted(self, info: Any) -> None:
|
|
49
53
|
url = info.requestUrl().toString()
|
|
50
|
-
if url.startswith("
|
|
54
|
+
if url.startswith(f"{HH_ANDROID_SCHEME}://"):
|
|
51
55
|
self.parent.handle_redirect_uri(url)
|
|
52
56
|
|
|
57
|
+
def register_hhandroid_scheme() -> None:
|
|
58
|
+
scheme = QWebEngineUrlScheme(HH_ANDROID_SCHEME.encode())
|
|
59
|
+
scheme.setSyntax(QWebEngineUrlScheme.Syntax.Path)
|
|
60
|
+
scheme.setFlags(
|
|
61
|
+
QWebEngineUrlScheme.Flag.SecureScheme |
|
|
62
|
+
QWebEngineUrlScheme.Flag.LocalScheme |
|
|
63
|
+
QWebEngineUrlScheme.Flag.LocalAccessAllowed |
|
|
64
|
+
QWebEngineUrlScheme.Flag.CorsEnabled
|
|
65
|
+
)
|
|
66
|
+
QWebEngineUrlScheme.registerScheme(scheme)
|
|
67
|
+
|
|
53
68
|
|
|
54
69
|
class WebViewWindow(QMainWindow):
|
|
55
70
|
def __init__(self, api_client: ApiClient) -> None:
|
|
56
71
|
super().__init__()
|
|
57
72
|
self.api_client = api_client
|
|
58
|
-
|
|
73
|
+
|
|
59
74
|
self.web_view = QWebEngineView()
|
|
75
|
+
self.web_view.settings().setUnknownUrlSchemePolicy(
|
|
76
|
+
QWebEngineSettings.UnknownUrlSchemePolicy.AllowAllUnknownUrlSchemes
|
|
77
|
+
)
|
|
78
|
+
|
|
60
79
|
self.setCentralWidget(self.web_view)
|
|
61
80
|
self.setWindowTitle("Авторизация на HH.RU")
|
|
62
81
|
self.hhandroid_handler = HHAndroidUrlSchemeHandler(self)
|
|
63
|
-
|
|
82
|
+
|
|
64
83
|
profile = self.web_view.page().profile()
|
|
65
|
-
profile.installUrlSchemeHandler(
|
|
66
|
-
|
|
84
|
+
profile.installUrlSchemeHandler(HH_ANDROID_SCHEME.encode(), self.hhandroid_handler)
|
|
85
|
+
|
|
86
|
+
self.web_view.page().acceptNavigationRequest = self._filter_http_requests
|
|
87
|
+
|
|
67
88
|
self.resize(480, 800)
|
|
68
|
-
|
|
89
|
+
oauth_url = api_client.oauth_client.authorize_url
|
|
90
|
+
logger.debug(f"{oauth_url = }")
|
|
91
|
+
self.web_view.setUrl(QUrl(oauth_url))
|
|
92
|
+
|
|
93
|
+
def _filter_http_requests(self, url: QUrl, _type, is_main_frame):
|
|
94
|
+
"""Блокирует любые переходы по протоколу HTTP"""
|
|
95
|
+
if url.scheme().lower() == "http":
|
|
96
|
+
logger.warning(f"🚫 Заблокирован небезопасный запрос: {url.toString()}")
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
69
99
|
|
|
70
100
|
def handle_redirect_uri(self, redirect_uri: str) -> None:
|
|
71
101
|
logger.debug(f"handle redirect uri: {redirect_uri}")
|
|
@@ -87,10 +117,19 @@ class Operation(BaseOperation):
|
|
|
87
117
|
def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
|
|
88
118
|
if not QT_IMPORTED:
|
|
89
119
|
print_err(
|
|
90
|
-
"
|
|
120
|
+
"❗Ошибка: PyQt6 не был импортирован, возможно, вы забыли его установить, либо же это ошибка самой библиотеки."
|
|
91
121
|
)
|
|
92
122
|
sys.exit(1)
|
|
93
123
|
|
|
124
|
+
proxies = api_client.proxies
|
|
125
|
+
if proxy_url := proxies.get("https"):
|
|
126
|
+
import os
|
|
127
|
+
|
|
128
|
+
qtwebengine_chromium_flags = f"--proxy-server={proxy_url}"
|
|
129
|
+
logger.debug(f"set {qtwebengine_chromium_flags = }")
|
|
130
|
+
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = qtwebengine_chromium_flags
|
|
131
|
+
|
|
132
|
+
register_hhandroid_scheme()
|
|
94
133
|
app = QApplication(sys.argv)
|
|
95
134
|
window = WebViewWindow(api_client=api_client)
|
|
96
135
|
window.show()
|
|
@@ -16,6 +16,7 @@ logger = logging.getLogger(__package__)
|
|
|
16
16
|
class Namespace(BaseNamespace):
|
|
17
17
|
older_than: int
|
|
18
18
|
blacklist_discard: bool
|
|
19
|
+
all: bool
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class Operation(BaseOperation):
|
|
@@ -30,16 +31,12 @@ class Operation(BaseOperation):
|
|
|
30
31
|
)
|
|
31
32
|
parser.add_argument(
|
|
32
33
|
"--all",
|
|
33
|
-
type=bool,
|
|
34
|
-
default=False,
|
|
35
34
|
action=argparse.BooleanOptionalAction,
|
|
36
35
|
help="Удалить все отклики в тч с приглашениями",
|
|
37
36
|
)
|
|
38
37
|
parser.add_argument(
|
|
39
38
|
"--blacklist-discard",
|
|
40
39
|
help="Если установлен, то заблокирует работодателя в случае отказа, чтобы его вакансии не отображались в возможных",
|
|
41
|
-
type=bool,
|
|
42
|
-
default=False,
|
|
43
40
|
action=argparse.BooleanOptionalAction,
|
|
44
41
|
)
|
|
45
42
|
|
|
@@ -93,7 +90,10 @@ class Operation(BaseOperation):
|
|
|
93
90
|
")",
|
|
94
91
|
)
|
|
95
92
|
if is_discard and args.blacklist_discard:
|
|
96
|
-
employer = vacancy
|
|
93
|
+
employer = vacancy.get("employer", {})
|
|
94
|
+
if not employer or 'id' not in employer:
|
|
95
|
+
# Работодатель удален или скрыт
|
|
96
|
+
continue
|
|
97
97
|
try:
|
|
98
98
|
r = api_client.put(f"/employers/blacklisted/{employer['id']}")
|
|
99
99
|
assert not r
|
|
@@ -57,7 +57,7 @@ class Operation(BaseOperation):
|
|
|
57
57
|
"-f",
|
|
58
58
|
"--format",
|
|
59
59
|
default="html",
|
|
60
|
-
choices=["html", "jsonl"],
|
|
60
|
+
choices=["html", "json", "jsonl"],
|
|
61
61
|
help="Формат вывода",
|
|
62
62
|
)
|
|
63
63
|
|
|
@@ -76,13 +76,26 @@ class Operation(BaseOperation):
|
|
|
76
76
|
if per_page * page >= res["total"]:
|
|
77
77
|
break
|
|
78
78
|
page += 1
|
|
79
|
-
if args.format
|
|
79
|
+
if args.format.startswith("json"):
|
|
80
80
|
import json, sys
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
is_json = args.format == "json"
|
|
83
|
+
total_contacts = len(contact_persons)
|
|
84
|
+
|
|
85
|
+
if is_json:
|
|
86
|
+
sys.stdout.write("[")
|
|
87
|
+
|
|
88
|
+
for index, contact in enumerate(contact_persons):
|
|
89
|
+
if is_json and index > 0:
|
|
90
|
+
sys.stdout.write(",")
|
|
91
|
+
|
|
83
92
|
json.dump(contact, sys.stdout, ensure_ascii=False)
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
|
|
94
|
+
if not is_json:
|
|
95
|
+
sys.stdout.write("\n")
|
|
96
|
+
|
|
97
|
+
if is_json:
|
|
98
|
+
sys.stdout.write("]\n")
|
|
86
99
|
else:
|
|
87
100
|
print(generate_html_report(contact_persons))
|
|
88
101
|
return
|
|
@@ -192,6 +205,17 @@ def generate_html_report(data: list[dict]) -> str:
|
|
|
192
205
|
color: #6c757d;
|
|
193
206
|
font-style: italic;
|
|
194
207
|
}
|
|
208
|
+
.scam-warning {
|
|
209
|
+
background-color: #f8d7da;
|
|
210
|
+
color: #721c24;
|
|
211
|
+
border: 1px solid #f5c6cb;
|
|
212
|
+
padding: 10px;
|
|
213
|
+
border-radius: 5px;
|
|
214
|
+
margin-bottom: 15px;
|
|
215
|
+
font-weight: bold;
|
|
216
|
+
text-align: center;
|
|
217
|
+
text-transform: uppercase;
|
|
218
|
+
}
|
|
195
219
|
</style>
|
|
196
220
|
</head>
|
|
197
221
|
<body>
|
|
@@ -219,8 +243,12 @@ def generate_html_report(data: list[dict]) -> str:
|
|
|
219
243
|
if "username" in tu
|
|
220
244
|
]
|
|
221
245
|
|
|
246
|
+
html_content += '<div class="person-card">'
|
|
247
|
+
|
|
248
|
+
if item.get("is_scam"):
|
|
249
|
+
html_content += '<div class="scam-warning">⚠️ ВНИМАНИЕ: Подозрение на мошенничество!</div>'
|
|
250
|
+
|
|
222
251
|
html_content += f"""\
|
|
223
|
-
<div class="person-card">
|
|
224
252
|
<h2>{name}</h2>
|
|
225
253
|
<p><strong>Email:</strong> <a href="mailto:{email}">{email}</a></p>
|
|
226
254
|
"""
|
|
@@ -282,12 +310,39 @@ def print_contacts(data: dict) -> None:
|
|
|
282
310
|
|
|
283
311
|
def print_contact(contact: dict, is_last_contact: bool) -> None:
|
|
284
312
|
"""Вывод информации о конкретном контакте."""
|
|
313
|
+
is_scam = contact.get("is_scam", False)
|
|
285
314
|
prefix = "└──" if is_last_contact else "├──"
|
|
286
|
-
|
|
315
|
+
scam_label = " ⚠️ [МОШЕННИК]" if is_scam else ""
|
|
316
|
+
|
|
317
|
+
print(f" {prefix} 🧑 {contact.get('name', 'Имя скрыто')}{scam_label}")
|
|
318
|
+
|
|
287
319
|
prefix2 = " " if is_last_contact else " │ "
|
|
320
|
+
|
|
288
321
|
print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
|
|
322
|
+
|
|
323
|
+
# 📞 Телефоны (вложенный список)
|
|
324
|
+
phones = contact.get("phone_numbers") or []
|
|
325
|
+
print(f"{prefix2}├── 📞 Телефоны:")
|
|
326
|
+
if phones:
|
|
327
|
+
for i, phone in enumerate(phones):
|
|
328
|
+
p = "└──" if i == len(phones) - 1 else "├──"
|
|
329
|
+
print(f"{prefix2}│ {p} {phone['phone_number']}")
|
|
330
|
+
else:
|
|
331
|
+
print(f"{prefix2}│ └── н/д")
|
|
332
|
+
|
|
333
|
+
# 💬 Telegram (вложенный список)
|
|
334
|
+
telegram_usernames = contact.get("telegram_usernames") or []
|
|
335
|
+
print(f"{prefix2}├── 💬 Telegram:")
|
|
336
|
+
if telegram_usernames:
|
|
337
|
+
for i, tg in enumerate(telegram_usernames):
|
|
338
|
+
p = "└──" if i == len(telegram_usernames) - 1 else "├──"
|
|
339
|
+
print(f"{prefix2}│ {p} {tg['username']}")
|
|
340
|
+
else:
|
|
341
|
+
print(f"{prefix2}│ └── н/д")
|
|
342
|
+
|
|
289
343
|
employer = contact.get("employer") or {}
|
|
290
344
|
print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
|
|
291
345
|
print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
|
|
292
346
|
print(f"{prefix2}└── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
|
|
347
|
+
|
|
293
348
|
print(prefix2)
|