hh-applicant-tool 0.5.8__tar.gz → 0.6.0__tar.gz

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.

Potentially problematic release.


This version of hh-applicant-tool might be problematic. Click here for more details.

Files changed (33) hide show
  1. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/PKG-INFO +9 -9
  2. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/README.md +8 -8
  3. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/api/client.py +25 -13
  4. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/main.py +16 -5
  5. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/mixins.py +1 -1
  6. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/apply_similar.py +16 -14
  7. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/authorize.py +3 -5
  8. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/call_api.py +6 -6
  9. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/clear_negotiations.py +6 -6
  10. hh_applicant_tool-0.6.0/hh_applicant_tool/operations/config.py +51 -0
  11. hh_applicant_tool-0.6.0/hh_applicant_tool/operations/delete_telemetry.py +30 -0
  12. hh_applicant_tool-0.6.0/hh_applicant_tool/operations/get_employer_contacts.py +294 -0
  13. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/list_resumes.py +2 -2
  14. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/refresh_token.py +4 -6
  15. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/reply_employers.py +6 -6
  16. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/update_resumes.py +3 -3
  17. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/whoami.py +3 -3
  18. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/telemetry_client.py +14 -2
  19. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/pyproject.toml +4 -2
  20. hh_applicant_tool-0.5.8/hh_applicant_tool/operations/config.py +0 -36
  21. hh_applicant_tool-0.5.8/hh_applicant_tool/operations/get_employer_contacts.py +0 -93
  22. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/__init__.py +0 -0
  23. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/__main__.py +0 -0
  24. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/ai/__init__.py +0 -0
  25. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/ai/blackbox.py +0 -0
  26. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/api/__init__.py +0 -0
  27. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/api/errors.py +0 -0
  28. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/color_log.py +0 -0
  29. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/constants.py +0 -0
  30. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/jsonc.py +0 -0
  31. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/operations/__init__.py +0 -0
  32. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/types.py +0 -0
  33. {hh_applicant_tool-0.5.8 → hh_applicant_tool-0.6.0}/hh_applicant_tool/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hh-applicant-tool
3
- Version: 0.5.8
3
+ Version: 0.6.0
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -30,16 +30,11 @@ Description-Content-Type: text/markdown
30
30
  <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
31
31
  </div>
32
32
 
33
- ### Внимание!!!
34
-
35
- Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
36
-
37
-
38
33
  ### Описание
39
34
 
40
35
  > Утилита для генерации сопроводительного письма может использовать AI
41
36
 
42
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
37
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
43
38
 
44
39
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
45
40
  asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
@@ -53,6 +48,10 @@ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и
53
48
 
54
49
  > Если в веб-интерфейсе выставить фильтры, то они будут применяться в скрипте при отклике на подходящие
55
50
 
51
+ ### Внимание!!!
52
+
53
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/). Оно обладает минимальным функционалом: обновление резюме (одного) и рассылка откликов (чистить их и тп нельзя).
54
+
56
55
  ### Предыстория
57
56
 
58
57
  Долгое время я делал массовые заявки с помощью консоли браузера:
@@ -184,7 +183,7 @@ hh-applicant-tool config -p
184
183
 
185
184
  | Имя атрибута | Описание |
186
185
  | --- | --- |
187
- | `user_agent` | Кастомный юзерагент, передаваемый при кажом запросе, например, `Mozilla/5.0 YablanBrowser` |
186
+ | `user_agent` | Кастомный юзерагент, передаваемый при кажом запросе. |
188
187
  | `proxy_url` | Прокси, используемый для всех запросов, например, `socks5h://127.0.0.1:9050` |
189
188
  | `reply_message` | Сообщение для ответа работодателю при отклике на вакансии, см. формат сообщений |
190
189
 
@@ -257,7 +256,8 @@ https://hh.ru/employer/1918903
257
256
  | **call-api** | Вызов произвольного метода API с выводом результата. |
258
257
  | **refresh-token** | Обновляет access_token. |
259
258
  | **config** | Редактировать конфигурационный файл. |
260
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
259
+ | **get-employer-contacts** | Получить список полученных вами контактов работодателей. Поддерживается так же экспорт в html/jsonl. Если хотите собирать контакты с нескольких акков, то укажите им одинаковый `client_telemetry_id` в конфигах. |
260
+ | **delete-telemetry** | Удадяет телеметрию, если та была включена. |
261
261
 
262
262
  ### Формат текста сообщений
263
263
 
@@ -11,16 +11,11 @@
11
11
  <img src="https://github.com/user-attachments/assets/29d91490-2c83-4e3f-a573-c7a6182a4044" width="500">
12
12
  </div>
13
13
 
14
- ### Внимание!!!
15
-
16
- Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/).
17
-
18
-
19
14
  ### Описание
20
15
 
21
16
  > Утилита для генерации сопроводительного письма может использовать AI
22
17
 
23
- Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита позволяет получить контактные данные работодателя, даже если тот не присылал приглашения, для этого должна быть включена телеметрия, те общедоступные данные работодателей собирают сами пользователи, данные же пользователей не хранятся ни в каком виде. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
18
+ Утилита для успешных волчат и старых волков с опытом, служащая для автоматизации действий на HH.RU таких как рассылка откликов на подходящие вакансии и обновление всех резюме (бесплатный аналог услуги на HH). Утилита сохраняет контактные данные, всех кто вам писал, и позволяет вам по ним проводить поиск (название компании, сайт и тп). Это удобно, так как контакт сохранится даже, если вышлют отказ в дальнейшем. У утилиты есть группа: [HH Resume Automate](https://t.me/hh_resume_automate).
24
19
 
25
20
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
26
21
  asdf/pyenv/conda и что-то еще. В школотронской Manjaro и даже в последних Ubuntu
@@ -34,6 +29,10 @@ asdf/pyenv/conda и что-то еще. В школотронской Manjaro и
34
29
 
35
30
  > Если в веб-интерфейсе выставить фильтры, то они будут применяться в скрипте при отклике на подходящие
36
31
 
32
+ ### Внимание!!!
33
+
34
+ Если для Вас проблема установить данную утилиту, лень разбираться с ее настройкой, то Вы можете установить мое приложение под **Android** [HH Resume Automate](https://github.com/s3rgeym/hh-resume-automate/). Оно обладает минимальным функционалом: обновление резюме (одного) и рассылка откликов (чистить их и тп нельзя).
35
+
37
36
  ### Предыстория
38
37
 
39
38
  Долгое время я делал массовые заявки с помощью консоли браузера:
@@ -165,7 +164,7 @@ hh-applicant-tool config -p
165
164
 
166
165
  | Имя атрибута | Описание |
167
166
  | --- | --- |
168
- | `user_agent` | Кастомный юзерагент, передаваемый при кажом запросе, например, `Mozilla/5.0 YablanBrowser` |
167
+ | `user_agent` | Кастомный юзерагент, передаваемый при кажом запросе. |
169
168
  | `proxy_url` | Прокси, используемый для всех запросов, например, `socks5h://127.0.0.1:9050` |
170
169
  | `reply_message` | Сообщение для ответа работодателю при отклике на вакансии, см. формат сообщений |
171
170
 
@@ -238,7 +237,8 @@ https://hh.ru/employer/1918903
238
237
  | **call-api** | Вызов произвольного метода API с выводом результата. |
239
238
  | **refresh-token** | Обновляет access_token. |
240
239
  | **config** | Редактировать конфигурационный файл. |
241
- | **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. |
240
+ | **get-employer-contacts** | Получить список полученных вами контактов работодателей. Поддерживается так же экспорт в html/jsonl. Если хотите собирать контакты с нескольких акков, то укажите им одинаковый `client_telemetry_id` в конфигах. |
241
+ | **delete-telemetry** | Удадяет телеметрию, если та была включена. |
242
242
 
243
243
  ### Формат текста сообщений
244
244
 
@@ -124,10 +124,17 @@ class BaseClient:
124
124
  assert 300 > response.status_code >= 200
125
125
  return rv
126
126
 
127
- get = partialmethod(request, "GET")
128
- post = partialmethod(request, "POST")
129
- put = partialmethod(request, "PUT")
130
- delete = partialmethod(request, "DELETE")
127
+ def get(self, *args, **kwargs):
128
+ return self.request("GET", *args, **kwargs)
129
+
130
+ def post(self, *args, **kwargs):
131
+ return self.request("POST", *args, **kwargs)
132
+
133
+ def put(self, *args, **kwargs):
134
+ return self.request("PUT", *args, **kwargs)
135
+
136
+ def delete(self, *args, **kwargs):
137
+ return self.request("DELETE", *args, **kwargs)
131
138
 
132
139
  def resolve_url(self, url: str) -> str:
133
140
  return url if "://" in url else f"{self.base_url.rstrip('/')}/{url.lstrip('/')}"
@@ -194,7 +201,7 @@ class OAuthClient(BaseClient):
194
201
  }
195
202
  return self.request_access_token("/token", params)
196
203
 
197
- def refresh_access(self, refresh_token: str) -> AccessToken:
204
+ def refresh_access_token(self, refresh_token: str) -> AccessToken:
198
205
  # refresh_token можно использовать только один раз и только по истечению срока действия access_token.
199
206
  return self.request_access_token(
200
207
  "/token", grant_type="refresh_token", refresh_token=refresh_token
@@ -243,25 +250,30 @@ class ApiClient(BaseClient):
243
250
  **kwargs: Any,
244
251
  ) -> dict:
245
252
  def do_request():
246
- return super().request(method, endpoint, params, delay, **kwargs)
253
+ return BaseClient.request(self, method, endpoint, params, delay, **kwargs)
247
254
 
248
255
  try:
249
256
  return do_request()
250
257
  # TODO: добавить класс для ошибок типа AccessTokenExpired
251
- except errors.ApiError as ex:
252
- if not self.is_access_expired:
258
+ except errors.Forbidden as ex:
259
+ if not self.is_access_expired or not self.refresh_token:
253
260
  raise ex
254
261
  logger.info("try refresh access_token")
255
262
  # Пробуем обновить токен
256
- token = self.oauth_client.refresh_access(self.refresh_token)
257
- self.handle_access_token(token)
263
+ self.refresh_access_token()
258
264
  # И повторно отправляем запрос
259
265
  return do_request()
260
266
 
261
267
  def handle_access_token(self, token: AccessToken) -> None:
262
- for k in ["access_token", "refresh_token", "access_expires_at"]:
263
- if k in token:
264
- setattr(self, k, token[k])
268
+ for field in ["access_token", "refresh_token", "access_expires_at"]:
269
+ if field in token and hasattr(self, field):
270
+ setattr(self, field, token[field])
271
+
272
+ def refresh_access_token(self) -> None:
273
+ if not self.refresh_token:
274
+ raise ValueError("Refresh token required.")
275
+ token = self.oauth_client.refresh_access_token(self.refresh_token)
276
+ self.handle_access_token(token)
265
277
 
266
278
  def get_access_token(self) -> AccessToken:
267
279
  return {
@@ -11,6 +11,7 @@ from typing import Literal, Sequence
11
11
 
12
12
  from .api import ApiClient
13
13
  from .color_log import ColorHandler
14
+ from .telemetry_client import TelemetryClient
14
15
  from .utils import Config, get_config_path
15
16
 
16
17
  DEFAULT_CONFIG_PATH = (
@@ -46,11 +47,12 @@ def get_proxies(args: Namespace) -> dict[Literal["http", "https"], str | None]:
46
47
  }
47
48
 
48
49
 
49
- def get_api(args: Namespace) -> ApiClient:
50
+ def get_api_client(args: Namespace) -> ApiClient:
50
51
  token = args.config.get("token", {})
51
52
  api = ApiClient(
52
53
  access_token=token.get("access_token"),
53
54
  refresh_token=token.get("refresh_token"),
55
+ access_expires_at=token.get("access_expires_at"),
54
56
  delay=args.delay,
55
57
  user_agent=args.config["user_agent"],
56
58
  proxies=get_proxies(args),
@@ -134,10 +136,19 @@ class HHApplicantTool:
134
136
  logger.addHandler(handler)
135
137
  if args.run:
136
138
  try:
137
- api = get_api(args)
138
- if not (res := args.run(api, args)):
139
- # 0 or None = success
140
- args.config.save(token=api.get_access_token())
139
+ if not args.config["telemetry_client_id"]:
140
+ import uuid
141
+
142
+ args.config.save(telemetry_client_id=str(uuid.uuid4()))
143
+ api_client = get_api_client(args)
144
+ telemetry_client = TelemetryClient(
145
+ telemetry_client_id=args.config["telemetry_client_id"],
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)
141
152
  return res
142
153
  except Exception as e:
143
154
  logger.exception(e)
@@ -5,7 +5,7 @@ from .types import ApiListResponse
5
5
  class GetResumeIdMixin:
6
6
  def _get_resume_id(self) -> str:
7
7
  try:
8
- resumes: ApiListResponse = self.api.get("/resumes/mine")
8
+ resumes: ApiListResponse = self.api_client.get("/resumes/mine")
9
9
  return resumes["items"][0]["id"]
10
10
  except (ApiError, KeyError, IndexError) as ex:
11
11
  raise Exception("Не могу получить идентификатор резюме") from ex
@@ -6,13 +6,11 @@ from collections import defaultdict
6
6
  from datetime import datetime, timedelta, timezone
7
7
  from typing import TextIO
8
8
 
9
- from hh_applicant_tool.api.errors import LimitExceeded
10
-
9
+ from ..api.errors import LimitExceeded
11
10
  from ..ai.blackbox import BlackboxChat, BlackboxError
12
11
  from ..api import ApiError, ApiClient
13
12
  from ..main import BaseOperation
14
13
  from ..main import Namespace as BaseNamespace
15
- from ..main import get_api
16
14
  from ..mixins import GetResumeIdMixin
17
15
  from ..telemetry_client import TelemetryClient, TelemetryError
18
16
  from ..types import ApiListResponse, VacancyItem
@@ -109,7 +107,9 @@ class Operation(BaseOperation, GetResumeIdMixin):
109
107
  action=argparse.BooleanOptionalAction,
110
108
  )
111
109
 
112
- def run(self, api: ApiClient, args: Namespace) -> None:
110
+ def run(
111
+ self, args: Namespace, api_client: ApiClient, telemetry_client: TelemetryClient
112
+ ) -> None:
113
113
  self.enable_telemetry = True
114
114
  if args.disable_telemetry:
115
115
  # print(
@@ -126,7 +126,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
126
126
  # logger.info("Спасибо за то что оставили телеметрию включенной!")
127
127
  self.enable_telemetry = False
128
128
 
129
- self.api = api
129
+ self.api_client = api_client
130
+ self.telemetry_client = telemetry_client
130
131
  self.resume_id = args.resume_id or self._get_resume_id()
131
132
  self.application_messages = self._get_application_messages(args.message_list)
132
133
  self.chat = None
@@ -135,7 +136,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
135
136
  self.chat = BlackboxChat(
136
137
  session_id=config["session_id"],
137
138
  chat_payload=config["chat_payload"],
138
- proxies=self.api.proxies or {},
139
+ proxies=self.api_client.proxies or {},
139
140
  )
140
141
 
141
142
  self.pre_prompt = args.pre_prompt
@@ -160,7 +161,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
160
161
  return application_messages
161
162
 
162
163
  def _apply_similar(self) -> None:
163
- telemetry_client = TelemetryClient(proxies=self.api.proxies)
164
+ telemetry_client = self.telemetry_client
164
165
  telemetry_data = defaultdict(dict)
165
166
 
166
167
  vacancies = self._get_vacancies()
@@ -190,7 +191,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
190
191
  # Остальное неинтересно
191
192
  }
192
193
 
193
- me = self.api.get("/me")
194
+ me = self.api_client.get("/me")
194
195
 
195
196
  basic_message_placeholders = {
196
197
  "first_name": me.get("first_name", ""),
@@ -244,7 +245,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
244
245
  > datetime.now(tz=timezone.utc)
245
246
  )
246
247
  ):
247
- employer = self.api.get(f"/employers/{employer_id}")
248
+ employer = self.api_client.get(f"/employers/{employer_id}")
248
249
 
249
250
  employer_data = {
250
251
  "name": employer.get("name"),
@@ -269,9 +270,10 @@ class Operation(BaseOperation, GetResumeIdMixin):
269
270
  response["topic_url"],
270
271
  )
271
272
  else:
272
- print(
273
- "Создание темы для обсуждения работодателя добавлено в очередь..."
274
- )
273
+ # print(
274
+ # "Создание темы для обсуждения работодателя добавлено в очередь..."
275
+ # )
276
+ ...
275
277
  complained_employers.add(employer_id)
276
278
  except TelemetryError as ex:
277
279
  logger.error(ex)
@@ -331,7 +333,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
331
333
  )
332
334
  time.sleep(interval)
333
335
 
334
- res = self.api.post("/negotiations", params)
336
+ res = self.api_client.post("/negotiations", params)
335
337
  assert res == {}
336
338
  print(
337
339
  "📨 Отправили отклик",
@@ -375,7 +377,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
375
377
  }
376
378
  if self.search:
377
379
  params["text"] = self.search
378
- res: ApiListResponse = self.api.get(
380
+ res: ApiListResponse = self.api_client.get(
379
381
  f"/resumes/{self.resume_id}/similar_vacancies", params
380
382
  )
381
383
  rv.extend(res["items"])
@@ -1,6 +1,5 @@
1
1
  import argparse
2
2
  import logging
3
- import time
4
3
  from urllib.parse import parse_qs, urlsplit
5
4
  import sys
6
5
  from typing import Any
@@ -35,9 +34,8 @@ except ImportError:
35
34
  pass
36
35
 
37
36
 
38
- from ..api import ApiClient, OAuthClient
39
- from ..main import BaseOperation, Namespace
40
- from ..utils import Config
37
+ from ..api import ApiClient # noqa: E402
38
+ from ..main import BaseOperation, Namespace # noqa: E402
41
39
 
42
40
  logger = logging.getLogger(__package__)
43
41
 
@@ -86,7 +84,7 @@ class Operation(BaseOperation):
86
84
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
87
85
  pass
88
86
 
89
- def run(self, api_client: ApiClient, args: Namespace) -> None:
87
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
90
88
  if not QT_IMPORTED:
91
89
  print_err(
92
90
  "❗Критиническая Ошибка: PyQt6 не был импортирован, возможно, вы долбоеб и забыли его установить, либо же криворукие разрабы этой либы опять все сломали..."
@@ -22,22 +22,22 @@ class Operation(BaseOperation):
22
22
  """Вызвать произвольный метод API <https://github.com/hhru/api>."""
23
23
 
24
24
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
25
- parser.add_argument("endpoint")
25
+ parser.add_argument("endpoint", help="Путь до эндпоинта API")
26
26
  parser.add_argument(
27
27
  "param",
28
28
  nargs="*",
29
- help="PARAM=VALUE",
29
+ help="Параметры указываются в виде PARAM=VALUE",
30
30
  default=[],
31
31
  )
32
32
  parser.add_argument(
33
33
  "-m", "--method", "--meth", default="GET", help="HTTP Метод"
34
34
  )
35
35
 
36
- def run(self, api: ApiClient, args: Namespace) -> None:
36
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
37
37
  params = dict(x.split("=", 1) for x in args.param)
38
38
  try:
39
- result = api.request(args.method, args.endpoint, params=params)
40
- print(json.dumps(result, ensure_ascii=True))
39
+ result = api_client.request(args.method, args.endpoint, params=params)
40
+ print(json.dumps(result, ensure_ascii=False))
41
41
  except ApiError as ex:
42
- json.dump(ex.data, sys.stderr, ensure_ascii=True)
42
+ json.dump(ex.data, sys.stderr, ensure_ascii=False)
43
43
  return 1
@@ -43,12 +43,12 @@ class Operation(BaseOperation):
43
43
  action=argparse.BooleanOptionalAction,
44
44
  )
45
45
 
46
- def _get_active_negotiations(self, api: ApiClient) -> list[dict]:
46
+ def _get_active_negotiations(self, api_client: ApiClient) -> list[dict]:
47
47
  rv = []
48
48
  page = 0
49
49
  per_page = 100
50
50
  while True:
51
- r: ApiListResponse = api.get(
51
+ r: ApiListResponse = api_client.get(
52
52
  "/negotiations", page=page, per_page=per_page, status="active"
53
53
  )
54
54
  rv.extend(r["items"])
@@ -57,8 +57,8 @@ class Operation(BaseOperation):
57
57
  break
58
58
  return rv
59
59
 
60
- def run(self, api: ApiClient, args: Namespace) -> None:
61
- negotiations = self._get_active_negotiations(api)
60
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
61
+ negotiations = self._get_active_negotiations(api_client)
62
62
  print("Всего активных:", len(negotiations))
63
63
  for item in negotiations:
64
64
  state = item["state"]
@@ -78,7 +78,7 @@ class Operation(BaseOperation):
78
78
  )
79
79
  ):
80
80
  decline_allowed = item.get("decline_allowed") or False
81
- r = api.delete(
81
+ r = api_client.delete(
82
82
  f"/negotiations/active/{item['id']}",
83
83
  with_decline_message=decline_allowed,
84
84
  )
@@ -95,7 +95,7 @@ class Operation(BaseOperation):
95
95
  if is_discard and args.blacklist_discard:
96
96
  employer = vacancy["employer"]
97
97
  try:
98
- r = api.put(f"/employers/blacklisted/{employer['id']}")
98
+ r = api_client.put(f"/employers/blacklisted/{employer['id']}")
99
99
  assert not r
100
100
  print(
101
101
  "🚫 Заблокировали",
@@ -0,0 +1,51 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ from typing import Any
6
+
7
+ from ..main import BaseOperation
8
+ from ..main import Namespace as BaseNamespace
9
+
10
+ logger = logging.getLogger(__package__)
11
+
12
+ EDITOR = os.getenv("EDITOR", "nano")
13
+
14
+
15
+ class Namespace(BaseNamespace):
16
+ show_path: bool
17
+ key: str
18
+
19
+
20
+ def get_value(data: dict[str, Any], path: str) -> Any:
21
+ for key in path.split("."):
22
+ if key not in data:
23
+ return None
24
+ data = data[key]
25
+ return data
26
+
27
+
28
+ class Operation(BaseOperation):
29
+ """Операции с конфигурационным файлом"""
30
+
31
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
32
+ parser.add_argument(
33
+ "-p",
34
+ "--show-path",
35
+ "--path",
36
+ type=bool,
37
+ default=False,
38
+ action=argparse.BooleanOptionalAction,
39
+ help="Вывести полный путь к конфигу",
40
+ )
41
+ parser.add_argument("-k", "--key", help="Вывести отдельное значение из конфига")
42
+
43
+ def run(self, args: Namespace, *_) -> None:
44
+ if args.key:
45
+ print(get_value(args.config, args.key))
46
+ return
47
+ config_path = str(args.config._config_path)
48
+ if args.show_path:
49
+ print(config_path)
50
+ else:
51
+ subprocess.call([EDITOR, config_path])
@@ -0,0 +1,30 @@
1
+ # Этот модуль можно использовать как образец для других
2
+ import argparse
3
+ import logging
4
+
5
+ from ..telemetry_client import TelemetryClient, TelemetryError
6
+
7
+ from ..main import BaseOperation
8
+ from ..main import Namespace as BaseNamespace
9
+ from ..utils import print_err
10
+
11
+ logger = logging.getLogger(__package__)
12
+
13
+
14
+ class Namespace(BaseNamespace):
15
+ pass
16
+
17
+
18
+ class Operation(BaseOperation):
19
+ """Удалить всю телеметрию, сохраненную на сервере."""
20
+
21
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
22
+ pass
23
+
24
+ def run(self, a, b, telemetry_client: TelemetryClient) -> None:
25
+ try:
26
+ telemetry_client.send_telemetry("/delete")
27
+ print("✅ Вся телеметрия, сохраненная на сервере, была успешно удалена!")
28
+ except TelemetryError as ex:
29
+ print_err("❗ Ошибка:", ex)
30
+ return 1
@@ -0,0 +1,294 @@
1
+ import argparse
2
+ import logging
3
+ from os import getenv
4
+ import pathlib
5
+ from ..main import BaseOperation
6
+ from ..main import Namespace as BaseNamespace
7
+ from ..telemetry_client import TelemetryClient
8
+
9
+ logger = logging.getLogger(__package__)
10
+
11
+
12
+ class Namespace(BaseNamespace):
13
+ username: str | None
14
+ password: str | None
15
+ search: str | None
16
+ export: bool
17
+
18
+
19
+ class Operation(BaseOperation):
20
+ """Выведет контакты работодателей, которые высылали вам приглашения"""
21
+
22
+ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
23
+ # parser.add_argument(
24
+ # "-u",
25
+ # "--username",
26
+ # type=str,
27
+ # help="Имя пользователя для аутентификации",
28
+ # default=getenv("AUTH_USERNAME"),
29
+ # )
30
+ # parser.add_argument(
31
+ # "-P",
32
+ # "--password",
33
+ # type=str,
34
+ # help="Пароль для аутентификации",
35
+ # default=getenv("AUTH_PASSWORD"),
36
+ # )
37
+ parser.add_argument(
38
+ "-s",
39
+ "--search",
40
+ type=str,
41
+ default="",
42
+ help="Строка поиска контактов работодателя (email, имя, название компании)",
43
+ )
44
+ parser.add_argument(
45
+ "-p",
46
+ "--page",
47
+ default=1,
48
+ help="Номер страницы в выдаче. Игнорируется при экспорте.",
49
+ )
50
+ parser.add_argument(
51
+ "--export",
52
+ action=argparse.BooleanOptionalAction,
53
+ default=False,
54
+ help="Экспортировать контакты работодателей.",
55
+ )
56
+ parser.add_argument(
57
+ "-f",
58
+ "--format",
59
+ default="html",
60
+ choices=["html", "jsonl"],
61
+ help="Формат вывода",
62
+ )
63
+
64
+ def run(self, args: Namespace, _, telemetry_client: TelemetryClient) -> None:
65
+ if args.export:
66
+ contact_persons = []
67
+ page = 1
68
+ per_page = 100
69
+ while True:
70
+ res = telemetry_client.get_telemetry(
71
+ "/contact/persons",
72
+ {"search": args.search, "per_page": per_page, "page": page},
73
+ )
74
+ assert "contact_persons" in res
75
+ contact_persons += res["contact_persons"]
76
+ if per_page * page >= res["total"]:
77
+ break
78
+ page += 1
79
+ if args.format == "jsonl":
80
+ import json, sys
81
+
82
+ for contact in contact_persons:
83
+ json.dump(contact, sys.stdout, ensure_ascii=False)
84
+ sys.stdout.write("\n")
85
+ sys.stdout.flush()
86
+ else:
87
+ print(generate_html_report(contact_persons))
88
+ return
89
+
90
+ res = telemetry_client.get_telemetry(
91
+ "/contact/persons",
92
+ {"search": args.search, "per_page": 10, "page": args.page},
93
+ )
94
+ if "contact_persons" not in res:
95
+ print("❌", res)
96
+ return 1
97
+
98
+ print(
99
+ "Тут отображаются только данные, собранные с вашего telemetry_client_id. Вы так же можете их удалить с помощью команды delete-telemetry."
100
+ )
101
+ print()
102
+
103
+ print_contacts(res)
104
+
105
+
106
+ def generate_html_report(data: list[dict]) -> str:
107
+ """
108
+ Генерирует HTML-отчет на основе предоставленных данных.
109
+ """
110
+ html_content = """
111
+ <!DOCTYPE html>
112
+ <html lang="ru">
113
+ <head>
114
+ <meta charset="UTF-8">
115
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
116
+ <title>Отчет о сотрудниках и компаниях</title>
117
+ <style>
118
+ body {
119
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
120
+ margin: 20px;
121
+ background-color: #f4f7f6;
122
+ color: #333;
123
+ }
124
+ .container {
125
+ max-width: 900px;
126
+ margin: 20px auto;
127
+ background-color: #ffffff;
128
+ padding: 30px;
129
+ border-radius: 10px;
130
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
131
+ }
132
+ h1 {
133
+ color: #0056b3;
134
+ text-align: center;
135
+ margin-bottom: 30px;
136
+ }
137
+ .person-card {
138
+ background-color: #e9f0f8;
139
+ border: 1px solid #cce5ff;
140
+ border-radius: 8px;
141
+ padding: 20px;
142
+ margin-bottom: 25px;
143
+ transition: transform 0.2s ease-in-out;
144
+ }
145
+ .person-card:hover {
146
+ transform: translateY(-5px);
147
+ }
148
+ .person-card h2 {
149
+ color: #004085;
150
+ margin-top: 0;
151
+ margin-bottom: 10px;
152
+ border-bottom: 2px solid #0056b3;
153
+ padding-bottom: 5px;
154
+ }
155
+ .person-card p {
156
+ margin: 5px 0;
157
+ }
158
+ .person-card strong {
159
+ color: #004085;
160
+ }
161
+ .employer-info {
162
+ background-color: #d1ecf1;
163
+ border-left: 5px solid #007bff;
164
+ padding: 15px;
165
+ margin-top: 15px;
166
+ border-radius: 5px;
167
+ }
168
+ .employer-info h3 {
169
+ color: #0056b3;
170
+ margin-top: 0;
171
+ margin-bottom: 10px;
172
+ }
173
+ ul {
174
+ list-style-type: none;
175
+ padding: 0;
176
+ }
177
+ ul li {
178
+ background-color: #f8fafd;
179
+ padding: 8px 12px;
180
+ margin-bottom: 5px;
181
+ border-radius: 4px;
182
+ border: 1px solid #e0e9f1;
183
+ }
184
+ a {
185
+ color: #007bff;
186
+ text-decoration: none;
187
+ }
188
+ a:hover {
189
+ text-decoration: underline;
190
+ }
191
+ .no-data {
192
+ color: #6c757d;
193
+ font-style: italic;
194
+ }
195
+ </style>
196
+ </head>
197
+ <body>
198
+ <div class="container">
199
+ <h1>Отчет о сотрудниках</h1>
200
+ """
201
+
202
+ for item in data:
203
+ name = item.get("name", "N/A")
204
+ email = item.get("email", "N/A")
205
+ employer = item.get("employer") or {}
206
+
207
+ employer_name = employer.get("name", "N/A")
208
+ employer_area = employer.get("area", "N/A")
209
+ employer_site_url = employer.get("site_url", "")
210
+
211
+ phone_numbers = [
212
+ pn["phone_number"]
213
+ for pn in item.get("phone_numbers", [])
214
+ if "phone_number" in pn
215
+ ]
216
+ telegram_usernames = [
217
+ tu["username"]
218
+ for tu in item.get("telegram_usernames", [])
219
+ if "username" in tu
220
+ ]
221
+
222
+ html_content += f"""
223
+ <div class="person-card">
224
+ <h2>{name}</h2>
225
+ <p><strong>Email:</strong> <a href="mailto:{email}">{email}</a></p>
226
+ """
227
+
228
+ if employer_name != "N/A":
229
+ html_content += f"""
230
+ <div class="employer-info">
231
+ <h3>Работодатель: {employer_name}</h3>
232
+ <p><strong>Город:</strong> {employer_area}</p>
233
+ """
234
+ if employer_site_url:
235
+ html_content += f"""
236
+ <p><strong>Сайт:</strong> <a href="{employer_site_url}" target="_blank">{employer_site_url}</a></p>
237
+ """
238
+ html_content += "</div>" # Закрываем employer-info
239
+ else:
240
+ html_content += (
241
+ '<p class="no-data">Информация о работодателе отсутствует.</p>'
242
+ )
243
+
244
+ if phone_numbers:
245
+ html_content += "<p><strong>Номера телефонов:</strong></p><ul>"
246
+ for phone in phone_numbers:
247
+ html_content += f"<li><a href='tel:{phone}'>{phone}</a></li>"
248
+ html_content += "</ul>"
249
+ else:
250
+ html_content += '<p class="no-data">Номера телефонов отсутствуют.</p>'
251
+
252
+ if telegram_usernames:
253
+ html_content += "<p><strong>Имена пользователей Telegram:</strong></p><ul>"
254
+ for username in telegram_usernames:
255
+ html_content += f"<li><a href='https://t.me/{username}' target='_blank'>@{username}</a></li>"
256
+ html_content += "</ul>"
257
+ else:
258
+ html_content += (
259
+ '<p class="no-data">Имена пользователей Telegram отсутствуют.</p>'
260
+ )
261
+
262
+ html_content += "</div>" # Закрываем person-card
263
+
264
+ html_content += """
265
+ </div>
266
+ </body>
267
+ </html>
268
+ """
269
+ return html_content
270
+
271
+
272
+ def print_contacts(data: dict) -> None:
273
+ """Вывод всех контактов в древовидной структуре."""
274
+ page = data["page"]
275
+ pages = (data["total"] // data["per_page"]) + 1
276
+ print(f"Страница {page}/{pages}:")
277
+ contacts = data.get("contact_persons", [])
278
+ for idx, contact in enumerate(contacts):
279
+ is_last_contact = idx == len(contacts) - 1
280
+ print_contact(contact, is_last_contact)
281
+ print()
282
+
283
+
284
+ def print_contact(contact: dict, is_last_contact: bool) -> None:
285
+ """Вывод информации о конкретном контакте."""
286
+ prefix = "└──" if is_last_contact else "├──"
287
+ print(f" {prefix} 🧑 {contact.get('name', 'Имя скрыто')}")
288
+ prefix2 = " " if is_last_contact else " │ "
289
+ print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
290
+ employer = contact.get("employer") or {}
291
+ print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
292
+ print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
293
+ print(f"{prefix2}└── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
294
+ print(prefix2)
@@ -23,8 +23,8 @@ class Operation(BaseOperation):
23
23
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
24
24
  pass
25
25
 
26
- def run(self, api: ApiClient, _) -> None:
27
- resumes: ApiListResponse = api.get("/resumes/mine")
26
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
27
+ resumes: ApiListResponse = api_client.get("/resumes/mine")
28
28
  t = PrettyTable(field_names=["ID", "Название", "Статус"], align="l", valign="t")
29
29
  t.add_rows(
30
30
  [
@@ -1,8 +1,8 @@
1
1
  # Этот модуль можно использовать как образец для других
2
2
  import argparse
3
3
  import logging
4
-
5
- from ..api import ApiError, ApiClient, OAuthClient
4
+ from typing import Any
5
+ from ..api import ApiError, ApiClient
6
6
  from ..main import BaseOperation
7
7
  from ..main import Namespace as BaseNamespace
8
8
  from ..utils import print_err
@@ -20,11 +20,9 @@ class Operation(BaseOperation):
20
20
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
21
21
  pass
22
22
 
23
- def run(self, api: ApiClient, args: Namespace) -> None:
23
+ def run(self, _, api_client: ApiClient, *args: Any) -> None:
24
24
  try:
25
- oauth: OAuthClient = api.oauth_client
26
- token = oauth.refresh_access(api.refresh_token)
27
- api.handle_access_token(token)
25
+ api_client.refresh_access_token()
28
26
  print("✅ Токен обновлен!")
29
27
  except ApiError as ex:
30
28
  print_err("❗ Ошибка:", ex)
@@ -66,8 +66,8 @@ class Operation(BaseOperation, GetResumeIdMixin):
66
66
  action=argparse.BooleanOptionalAction,
67
67
  )
68
68
 
69
- def run(self, api: ApiClient, args: Namespace) -> None:
70
- self.api = api
69
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
70
+ self.api_client = api_client
71
71
  self.resume_id = self._get_resume_id()
72
72
  self.reply_min_interval, self.reply_max_interval = args.reply_interval
73
73
  self.reply_message = args.reply_message or args.config["reply_message"]
@@ -79,7 +79,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
79
79
  self._reply_chats()
80
80
 
81
81
  def _reply_chats(self) -> None:
82
- me = self.me = self.api.get("/me")
82
+ me = self.me = self.api_client.get("/me")
83
83
 
84
84
  basic_message_placeholders = {
85
85
  "first_name": me.get("first_name", ""),
@@ -123,7 +123,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
123
123
  last_message: dict | None = None
124
124
  message_history: list[str] = []
125
125
  while True:
126
- messages_res = self.api.get(
126
+ messages_res = self.api_client.get(
127
127
  f"/negotiations/{nid}/messages", page=page
128
128
  )
129
129
 
@@ -193,7 +193,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
193
193
  self.reply_max_interval,
194
194
  )
195
195
  )
196
- self.api.post(
196
+ self.api_client.post(
197
197
  f"/negotiations/{nid}/messages",
198
198
  message=message,
199
199
  )
@@ -209,7 +209,7 @@ class Operation(BaseOperation, GetResumeIdMixin):
209
209
  def _get_negotiations(self) -> list[dict]:
210
210
  rv = []
211
211
  for page in range(self.max_pages):
212
- res = self.api.get("/negotiations", page=page, status="active")
212
+ res = self.api_client.get("/negotiations", page=page, status="active")
213
213
  rv.extend(res["items"])
214
214
  if page >= res["pages"] - 1:
215
215
  break
@@ -21,11 +21,11 @@ class Operation(BaseOperation):
21
21
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
22
22
  pass
23
23
 
24
- def run(self, api: ApiClient, args: Namespace) -> None:
25
- resumes: ApiListResponse = api.get("/resumes/mine")
24
+ def run(self, args: Namespace, api_client: ApiClient, *_) -> None:
25
+ resumes: ApiListResponse = api_client.get("/resumes/mine")
26
26
  for resume in resumes["items"]:
27
27
  try:
28
- res = api.post(f"/resumes/{resume['id']}/publish")
28
+ res = api_client.post(f"/resumes/{resume['id']}/publish")
29
29
  assert res == {}
30
30
  print("✅ Обновлено", truncate_string(resume["title"]))
31
31
  except ApiError as ex:
@@ -20,6 +20,6 @@ class Operation(BaseOperation):
20
20
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
21
21
  pass
22
22
 
23
- def run(self, api: ApiClient, args: Namespace) -> None:
24
- result = api.get("/me")
25
- print(json.dumps(result, ensure_ascii=True, indent=2, sort_keys=True))
23
+ def run(self, args: Namespace, api_client: ApiClient, _) -> None:
24
+ result = api_client.get("/me")
25
+ print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import json
2
4
  import logging
3
5
  import os
@@ -6,8 +8,8 @@ import warnings
6
8
  from functools import partialmethod
7
9
  from typing import Any, Dict, Optional
8
10
  from urllib.parse import urljoin
9
-
10
11
  import requests
12
+ from .utils import Config
11
13
 
12
14
  # Сертификат на сервере давно истек, но его обновлять мне лень...
13
15
  warnings.filterwarnings("ignore", message="Unverified HTTPS request")
@@ -29,6 +31,7 @@ class TelemetryClient:
29
31
 
30
32
  def __init__(
31
33
  self,
34
+ telemetry_client_id: str,
32
35
  server_address: Optional[str] = None,
33
36
  *,
34
37
  session: Optional[requests.Session] = None,
@@ -36,6 +39,7 @@ class TelemetryClient:
36
39
  proxies: dict | None = None,
37
40
  delay: Optional[float] = None,
38
41
  ) -> None:
42
+ self.send_telemetry_id = telemetry_client_id
39
43
  self.server_address = os.getenv(
40
44
  "TELEMETRY_SERVER", server_address or self.server_address
41
45
  )
@@ -68,7 +72,10 @@ class TelemetryClient:
68
72
  response = self.session.request(
69
73
  method,
70
74
  url,
71
- headers={"User-Agent": self.user_agent},
75
+ headers={
76
+ "User-Agent": self.user_agent,
77
+ "X-Telemetry-Client-ID": self.send_telemetry_id,
78
+ },
72
79
  proxies=self.proxies,
73
80
  params=data if not has_body else None,
74
81
  json=data if has_body else None,
@@ -92,3 +99,8 @@ class TelemetryClient:
92
99
 
93
100
  get_telemetry = partialmethod(request, "GET")
94
101
  send_telemetry = partialmethod(request, "POST")
102
+
103
+ @classmethod
104
+ def create_from_config(cls, config: Config) -> "TelemetryClient":
105
+ assert "telemetry_client_id" in config
106
+ return cls(config["telemetry_client_id"])
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "hh-applicant-tool"
3
- version = "0.5.8"
3
+ version = "0.6.0"
4
4
  description = ""
5
5
  authors = ["Senior YAML Developer <yamldeveloper@proton.me>"]
6
6
  readme = "README.md"
@@ -17,7 +17,6 @@ pyqt6-webengine = { version = "6.7.0", optional = true }
17
17
  qt = ["pyqt6", "pyqt6-webengine"]
18
18
 
19
19
  [tool.poetry.group.dev.dependencies]
20
- black = "^23.1.0"
21
20
  isort = "^5.12.0"
22
21
  pylint = "^2.16.4"
23
22
  pytest = "^7.2.2"
@@ -36,3 +35,6 @@ hh-applicant-tool = "hh_applicant_tool.main:main"
36
35
  # https://github.com/microsoft/pyright/blob/main/docs/configuration.md
37
36
  # Ошибки показывать он не бросит, но заебывать перестанет
38
37
  typeCheckingMode = "off"
38
+
39
+ [tool.ruff]
40
+ select = ["E", "F", "I", "W"]
@@ -1,36 +0,0 @@
1
- import argparse
2
- import logging
3
- import os
4
- import subprocess
5
-
6
- from ..main import BaseOperation
7
- from ..main import Namespace as BaseNamespace
8
-
9
- logger = logging.getLogger(__package__)
10
-
11
- EDITOR = os.getenv("EDITOR", "nano")
12
-
13
-
14
- class Namespace(BaseNamespace):
15
- print: bool
16
-
17
-
18
- class Operation(BaseOperation):
19
- """Редактировать конфигурационный файл или показать путь до него"""
20
-
21
- def setup_parser(self, parser: argparse.ArgumentParser) -> None:
22
- parser.add_argument(
23
- "-p",
24
- "--print",
25
- type=bool,
26
- default=False,
27
- action=argparse.BooleanOptionalAction,
28
- help="Напечатать путь и выйти",
29
- )
30
-
31
- def run(self, _, args: Namespace) -> None:
32
- config_path = str(args.config._config_path)
33
- if args.print:
34
- print(config_path)
35
- else:
36
- subprocess.call([EDITOR, config_path])
@@ -1,93 +0,0 @@
1
- import argparse
2
- import logging
3
- from os import getenv
4
-
5
- from ..main import BaseOperation
6
- from ..main import Namespace as BaseNamespace
7
- from ..main import get_proxies
8
- from ..telemetry_client import TelemetryClient
9
-
10
- logger = logging.getLogger(__package__)
11
-
12
-
13
- class Namespace(BaseNamespace):
14
- username: str | None = None
15
- password: str | None = None
16
- search: str | None = None
17
-
18
-
19
- class Operation(BaseOperation):
20
- """Выведет контакты работодателя по заданной строке поиска"""
21
-
22
- def setup_parser(self, parser: argparse.ArgumentParser) -> None:
23
- parser.add_argument(
24
- "-u",
25
- "--username",
26
- type=str,
27
- help="Имя пользователя для аутентификации",
28
- default=getenv("AUTH_USERNAME"),
29
- )
30
- parser.add_argument(
31
- "-P",
32
- "--password",
33
- type=str,
34
- help="Пароль для аутентификации",
35
- default=getenv("AUTH_PASSWORD"),
36
- )
37
- parser.add_argument(
38
- "-s",
39
- "--search",
40
- type=str,
41
- default="",
42
- help="Строка поиска для контактов работодателя",
43
- )
44
- parser.add_argument(
45
- "-p",
46
- "--page",
47
- default=1,
48
- help="Номер страницы в выдаче",
49
- )
50
-
51
- def run(self, _, args: Namespace) -> None:
52
- proxies = get_proxies(args)
53
- client = TelemetryClient(proxies=proxies)
54
- auth = (
55
- (args.username, args.password) if args.username and args.password else None
56
- )
57
- # Аутентификация пользователя
58
- results = client.get_telemetry(
59
- "/contact/persons",
60
- {"search": args.search, "per_page": 10, "page": args.page},
61
- auth=auth,
62
- )
63
- if "contact_persons" not in results:
64
- print("❌", results)
65
- return 1
66
-
67
- print("Данная информация была собрана из публичных источников.")
68
- print()
69
-
70
- self._print_contacts(results)
71
-
72
- def _print_contacts(self, data: dict) -> None:
73
- """Вывод всех контактов в древовидной структуре."""
74
- page = data["page"]
75
- pages = (data["total"] // data["per_page"]) + 1
76
- print(f"📋 Контакты ({page}/{pages}):")
77
- contacts = data.get("contact_persons", [])
78
- for idx, contact in enumerate(contacts):
79
- is_last_contact = idx == len(contacts) - 1
80
- self._print_contact(contact, is_last_contact)
81
- print()
82
-
83
- def _print_contact(self, contact: dict, is_last_contact: bool) -> None:
84
- """Вывод информации о конкретном контакте."""
85
- prefix = "└──" if is_last_contact else "├──"
86
- print(f" {prefix} 🧑 Контактное лицо")
87
- prefix2 = " " if is_last_contact else " │ "
88
- print(f"{prefix2}├── 📧 Email: {contact.get('email', 'н/д')}")
89
- employer = contact.get("employer") or {}
90
- print(f"{prefix2}├── 🏢 Работодатель: {employer.get('name', 'н/д')}")
91
- print(f"{prefix2}├── 🏠 Город: {employer.get('area', 'н/д')}")
92
- print(f"{prefix2}└── 🌐 Сайт: {employer.get('site_url', 'н/д')}")
93
- print(prefix2)