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.
@@ -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 typing import TextIO
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 complained_employers
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
- complained_employers.add(employer_id)
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
- "Пропускаем вакансию так как достигла лимита заявок: %s",
271
- vacancy["alternate_url"],
432
+ "Останавливаем рассылку откликов, так как достигли лимита, попробуйте через сутки."
272
433
  )
273
- continue
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 BlackboxError as ex:
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 ApiError as ex:
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("hhandroid://"):
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
- # Настройка WebEngineView
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(b"hhandroid", self.hhandroid_handler)
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
- self.web_view.setUrl(QUrl(api_client.oauth_client.authorize_url))
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
- "❗Критиническая Ошибка: PyQt6 не был импортирован, возможно, вы долбоеб и забыли его установить, либо же криворукие разрабы этой либы опять все сломали..."
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["employer"]
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
@@ -33,8 +33,6 @@ class Operation(BaseOperation):
33
33
  "-p",
34
34
  "--show-path",
35
35
  "--path",
36
- type=bool,
37
- default=False,
38
36
  action=argparse.BooleanOptionalAction,
39
37
  help="Вывести полный путь к конфигу",
40
38
  )
@@ -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 == "jsonl":
79
+ if args.format.startswith("json"):
80
80
  import json, sys
81
81
 
82
- for contact in contact_persons:
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
- sys.stdout.write("\n")
85
- sys.stdout.flush()
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
- print(f" {prefix} 🧑 {contact.get('name', 'Имя скрыто')}")
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)