hh-applicant-tool 0.7.10__py3-none-any.whl → 1.4.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. hh_applicant_tool/__init__.py +1 -0
  2. hh_applicant_tool/__main__.py +1 -1
  3. hh_applicant_tool/ai/base.py +2 -0
  4. hh_applicant_tool/ai/openai.py +23 -33
  5. hh_applicant_tool/api/client.py +50 -64
  6. hh_applicant_tool/api/errors.py +51 -7
  7. hh_applicant_tool/constants.py +0 -3
  8. hh_applicant_tool/datatypes.py +291 -0
  9. hh_applicant_tool/main.py +233 -111
  10. hh_applicant_tool/operations/apply_similar.py +266 -362
  11. hh_applicant_tool/operations/authorize.py +256 -120
  12. hh_applicant_tool/operations/call_api.py +18 -8
  13. hh_applicant_tool/operations/check_negotiations.py +102 -0
  14. hh_applicant_tool/operations/check_proxy.py +30 -0
  15. hh_applicant_tool/operations/config.py +119 -16
  16. hh_applicant_tool/operations/install.py +34 -0
  17. hh_applicant_tool/operations/list_resumes.py +24 -10
  18. hh_applicant_tool/operations/log.py +77 -0
  19. hh_applicant_tool/operations/migrate_db.py +65 -0
  20. hh_applicant_tool/operations/query.py +120 -0
  21. hh_applicant_tool/operations/refresh_token.py +14 -13
  22. hh_applicant_tool/operations/reply_employers.py +148 -167
  23. hh_applicant_tool/operations/settings.py +95 -0
  24. hh_applicant_tool/operations/uninstall.py +26 -0
  25. hh_applicant_tool/operations/update_resumes.py +21 -10
  26. hh_applicant_tool/operations/whoami.py +40 -7
  27. hh_applicant_tool/storage/__init__.py +4 -0
  28. hh_applicant_tool/storage/facade.py +24 -0
  29. hh_applicant_tool/storage/models/__init__.py +0 -0
  30. hh_applicant_tool/storage/models/base.py +169 -0
  31. hh_applicant_tool/storage/models/contact.py +16 -0
  32. hh_applicant_tool/storage/models/employer.py +12 -0
  33. hh_applicant_tool/storage/models/negotiation.py +16 -0
  34. hh_applicant_tool/storage/models/resume.py +19 -0
  35. hh_applicant_tool/storage/models/setting.py +6 -0
  36. hh_applicant_tool/storage/models/vacancy.py +36 -0
  37. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  38. hh_applicant_tool/storage/queries/schema.sql +119 -0
  39. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  40. hh_applicant_tool/storage/repositories/base.py +176 -0
  41. hh_applicant_tool/storage/repositories/contacts.py +19 -0
  42. hh_applicant_tool/storage/repositories/employers.py +13 -0
  43. hh_applicant_tool/storage/repositories/negotiations.py +12 -0
  44. hh_applicant_tool/storage/repositories/resumes.py +14 -0
  45. hh_applicant_tool/storage/repositories/settings.py +34 -0
  46. hh_applicant_tool/storage/repositories/vacancies.py +8 -0
  47. hh_applicant_tool/storage/utils.py +49 -0
  48. hh_applicant_tool/utils/__init__.py +31 -0
  49. hh_applicant_tool/utils/attrdict.py +6 -0
  50. hh_applicant_tool/utils/binpack.py +167 -0
  51. hh_applicant_tool/utils/config.py +55 -0
  52. hh_applicant_tool/utils/dateutil.py +19 -0
  53. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  54. hh_applicant_tool/utils/jsonutil.py +61 -0
  55. hh_applicant_tool/utils/log.py +144 -0
  56. hh_applicant_tool/utils/misc.py +12 -0
  57. hh_applicant_tool/utils/mixins.py +220 -0
  58. hh_applicant_tool/utils/string.py +27 -0
  59. hh_applicant_tool/utils/terminal.py +19 -0
  60. hh_applicant_tool/utils/user_agent.py +17 -0
  61. hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
  62. hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
  63. hh_applicant_tool/ai/blackbox.py +0 -55
  64. hh_applicant_tool/color_log.py +0 -47
  65. hh_applicant_tool/mixins.py +0 -13
  66. hh_applicant_tool/operations/clear_negotiations.py +0 -109
  67. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  68. hh_applicant_tool/operations/get_employer_contacts.py +0 -348
  69. hh_applicant_tool/telemetry_client.py +0 -106
  70. hh_applicant_tool/types.py +0 -45
  71. hh_applicant_tool/utils.py +0 -119
  72. hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
  73. hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
  74. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
  75. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.7.dist-info}/entry_points.txt +0 -0
@@ -1,28 +1,22 @@
1
+ from __future__ import annotations
2
+
1
3
  import argparse
2
4
  import logging
3
5
  import random
4
- import time
5
- from collections import defaultdict
6
- from datetime import datetime, timedelta, timezone
7
6
  from pathlib import Path
8
- from typing import Any, TextIO
9
-
10
- from ..ai.blackbox import BlackboxChat
11
- from ..ai.openai import OpenAIChat
12
- from ..api import ApiClient, BadResponse
13
- from ..api.errors import LimitExceeded
14
- from ..main import BaseOperation
15
- from ..main import Namespace as BaseNamespace
16
- from ..mixins import GetResumeIdMixin
17
- from ..telemetry_client import TelemetryClient, TelemetryError
18
- from ..types import ApiListResponse, VacancyItem
19
- from ..utils import (
20
- fix_datetime,
21
- parse_interval,
22
- parse_invalid_datetime,
23
- random_text,
24
- truncate_string,
25
- )
7
+ from typing import TYPE_CHECKING, Iterator, TextIO
8
+
9
+ from .. import datatypes
10
+ from ..ai.base import AIError
11
+ from ..api import BadResponse, Redirect
12
+ from ..api.errors import ApiError, LimitExceeded
13
+ from ..datatypes import PaginatedItems, SearchVacancy
14
+ from ..main import BaseNamespace, BaseOperation
15
+ from ..utils import bool2str, list2str, rand_text, shorten
16
+
17
+ if TYPE_CHECKING:
18
+ from ..main import HHApplicantTool
19
+
26
20
 
27
21
  logger = logging.getLogger(__package__)
28
22
 
@@ -33,9 +27,8 @@ class Namespace(BaseNamespace):
33
27
  ignore_employers: Path | None
34
28
  force_message: bool
35
29
  use_ai: bool
36
- pre_prompt: str
37
- apply_interval: tuple[float, float]
38
- page_interval: tuple[float, float]
30
+ first_prompt: str
31
+ prompt: str
39
32
  order_by: str
40
33
  search: str
41
34
  schedule: str
@@ -64,17 +57,11 @@ class Namespace(BaseNamespace):
64
57
  sort_point_lng: float | None
65
58
  no_magic: bool
66
59
  premium: bool
60
+ per_page: int
61
+ total_pages: int
67
62
 
68
63
 
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 ""
75
-
76
-
77
- class Operation(BaseOperation, GetResumeIdMixin):
64
+ class Operation(BaseOperation):
78
65
  """Откликнуться на все подходящие вакансии.
79
66
 
80
67
  Описание фильтров для поиска вакансий: <https://api.hh.ru/openapi/redoc#tag/Poisk-vakansij-dlya-soiskatelya/operation/get-vacancies-similar-to-resume>
@@ -82,52 +69,67 @@ class Operation(BaseOperation, GetResumeIdMixin):
82
69
 
83
70
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
84
71
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
72
+ parser.add_argument(
73
+ "--search",
74
+ help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'", # noqa: E501
75
+ type=str,
76
+ )
85
77
  parser.add_argument(
86
78
  "-L",
87
79
  "--message-list",
88
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
80
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
89
81
  type=argparse.FileType("r", encoding="utf-8", errors="replace"),
90
82
  )
91
- parser.add_argument(
92
- "--ignore-employers",
93
- help="Путь к файлу со списком ID игнорируемых работодателей (по одному ID на строку)",
94
- type=Path,
95
- default=None,
96
- )
97
83
  parser.add_argument(
98
84
  "-f",
99
85
  "--force-message",
100
86
  "--force",
101
87
  help="Всегда отправлять сообщение при отклике",
102
- default=False,
103
88
  action=argparse.BooleanOptionalAction,
104
89
  )
105
90
  parser.add_argument(
106
91
  "--use-ai",
107
92
  "--ai",
108
93
  help="Использовать AI для генерации сообщений",
109
- default=False,
110
94
  action=argparse.BooleanOptionalAction,
111
95
  )
112
96
  parser.add_argument(
113
- "--pre-prompt",
97
+ "--first-prompt",
98
+ help="Начальный помпт чата для генерации сопроводительного письма",
99
+ default="Напиши сопроводительное письмо для отклика на эту вакансию. Не используй placeholder'ы, твой ответ будет отправлен без обработки.", # noqa: E501
100
+ )
101
+ parser.add_argument(
114
102
  "--prompt",
115
- help="Добавочный промпт для генерации сопроводительного письма",
116
- default="Сгенерируй сопроводительное письмо не более 5-7 предложений от моего имени для вакансии",
103
+ help="Промпт для генерации сопроводительного письма",
104
+ default="Сгенерируй сопроводительное письмо не более 5-7 предложений от моего имени для вакансии", # noqa: E501
117
105
  )
118
106
  parser.add_argument(
119
- "--apply-interval",
120
- help="Интервал перед отправкой откликов в секундах (X, X-Y)",
121
- default="1-5",
122
- type=parse_interval,
107
+ "--total-pages",
108
+ "--pages",
109
+ help="Количество обрабатываемых страниц поиска", # noqa: E501
110
+ default=20,
111
+ type=int,
123
112
  )
124
113
  parser.add_argument(
125
- "--page-interval",
126
- help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
127
- default="1-3",
128
- type=parse_interval,
114
+ "--per-page",
115
+ help="Сколько должно быть результатов на странице", # noqa: E501
116
+ default=100,
117
+ type=int,
129
118
  )
130
119
  parser.add_argument(
120
+ "--dry-run",
121
+ help="Не отправлять отклики, а только выводить информацию",
122
+ action=argparse.BooleanOptionalAction,
123
+ )
124
+
125
+ # Дальше идут параметры в точности соответствующие параметрам запроса
126
+ # при поиске подходящих вакансий
127
+ search_params_group = parser.add_argument_group(
128
+ "Параметры поиска вакансий",
129
+ "Эти параметры напрямую соответствуют фильтрам поиска HeadHunter API",
130
+ )
131
+
132
+ search_params_group.add_argument(
131
133
  "--order-by",
132
134
  help="Сортировка вакансий",
133
135
  choices=[
@@ -137,246 +139,196 @@ class Operation(BaseOperation, GetResumeIdMixin):
137
139
  "relevance",
138
140
  "distance",
139
141
  ],
140
- default="relevance",
142
+ # default="relevance",
141
143
  )
142
- parser.add_argument(
143
- "--search",
144
- help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'",
144
+ search_params_group.add_argument(
145
+ "--experience",
146
+ help="Уровень опыта работы (noExperience, between1And3, between3And6, moreThan6)",
145
147
  type=str,
146
148
  default=None,
147
149
  )
148
-
149
- parser.add_argument(
150
+ search_params_group.add_argument(
150
151
  "--schedule",
151
- help="Тип графика. Возможные значения: fullDay, shift, flexible, remote, flyInFlyOut для полного дня, сменного графика, гибкого графика, удаленной работы и вахтового метода",
152
+ help="Тип графика (fullDay, shift, flexible, remote, flyInFlyOut)",
152
153
  type=str,
153
- default=None,
154
154
  )
155
- parser.add_argument(
156
- "--dry-run",
157
- help="Не отправлять отклики, а только выводить параметры запроса",
155
+ search_params_group.add_argument(
156
+ "--employment", nargs="+", help="Тип занятости"
157
+ )
158
+ search_params_group.add_argument(
159
+ "--area", nargs="+", help="Регион (area id)"
160
+ )
161
+ search_params_group.add_argument(
162
+ "--metro", nargs="+", help="Станции метро (metro id)"
163
+ )
164
+ search_params_group.add_argument(
165
+ "--professional-role", nargs="+", help="Проф. роль (id)"
166
+ )
167
+ search_params_group.add_argument(
168
+ "--industry", nargs="+", help="Индустрия (industry id)"
169
+ )
170
+ search_params_group.add_argument(
171
+ "--employer-id", nargs="+", help="ID работодателей"
172
+ )
173
+ search_params_group.add_argument(
174
+ "--excluded-employer-id", nargs="+", help="Исключить работодателей"
175
+ )
176
+ search_params_group.add_argument(
177
+ "--currency", help="Код валюты (RUR, USD, EUR)"
178
+ )
179
+ search_params_group.add_argument(
180
+ "--salary", type=int, help="Минимальная зарплата"
181
+ )
182
+ search_params_group.add_argument(
183
+ "--only-with-salary",
158
184
  default=False,
159
185
  action=argparse.BooleanOptionalAction,
160
186
  )
161
- parser.add_argument(
162
- "--experience",
163
- help="Уровень опыта работы в вакансии. Возможные значения: noExperience, between1And3, between3And6, moreThan6",
164
- type=str,
165
- default=None,
187
+ search_params_group.add_argument(
188
+ "--label", nargs="+", help="Метки вакансий (label)"
166
189
  )
167
- parser.add_argument(
168
- "--employment", nargs="+", help="Тип занятости (employment)"
190
+ search_params_group.add_argument(
191
+ "--period", type=int, help="Искать вакансии за N дней"
169
192
  )
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="Исключить работодателей"
193
+ search_params_group.add_argument(
194
+ "--date-from", help="Дата публикации с (YYYY-MM-DD)"
177
195
  )
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(
196
+ search_params_group.add_argument(
197
+ "--date-to", help="Дата публикации по (YYYY-MM-DD)"
198
+ )
199
+ search_params_group.add_argument(
200
+ "--top-lat", type=float, help="Гео: верхняя широта"
201
+ )
202
+ search_params_group.add_argument(
203
+ "--bottom-lat", type=float, help="Гео: нижняя широта"
204
+ )
205
+ search_params_group.add_argument(
206
+ "--left-lng", type=float, help="Гео: левая долгота"
207
+ )
208
+ search_params_group.add_argument(
209
+ "--right-lng", type=float, help="Гео: правая долгота"
210
+ )
211
+ search_params_group.add_argument(
192
212
  "--sort-point-lat",
193
213
  type=float,
194
214
  help="Координата lat для сортировки по расстоянию",
195
215
  )
196
- parser.add_argument(
216
+ search_params_group.add_argument(
197
217
  "--sort-point-lng",
198
218
  type=float,
199
219
  help="Координата lng для сортировки по расстоянию",
200
220
  )
201
- parser.add_argument(
221
+ search_params_group.add_argument(
202
222
  "--no-magic",
203
223
  action="store_true",
204
224
  help="Отключить авторазбор текста запроса",
205
225
  )
206
- parser.add_argument(
226
+ search_params_group.add_argument(
207
227
  "--premium",
208
228
  default=False,
209
229
  action=argparse.BooleanOptionalAction,
210
230
  help="Только премиум вакансии",
211
231
  )
212
- parser.add_argument(
213
- "--search-field", nargs="+", help="Поля поиска (name, company_name и т.п.)"
232
+ search_params_group.add_argument(
233
+ "--search-field",
234
+ nargs="+",
235
+ help="Поля поиска (name, company_name и т.п.)",
214
236
  )
215
- parser.add_argument(
216
- "--clusters",
217
- action=argparse.BooleanOptionalAction,
218
- help="Включить кластеры (по умолчанию None)",
219
- )
220
- # parser.add_argument("--describe-arguments", action=argparse.BooleanOptionalAction, help="Вернуть описание параметров запроса")
221
237
 
222
238
  def run(
223
- self, args: Namespace, api_client: ApiClient, telemetry_client: TelemetryClient
239
+ self,
240
+ tool: HHApplicantTool,
224
241
  ) -> None:
225
- self.enable_telemetry = True
226
- if args.disable_telemetry:
227
- # print(
228
- # "👁️ Телеметрия используется только для сбора данных о работодателях и их вакансиях, персональные данные пользователей не передаются на сервер."
229
- # )
230
- # if (
231
- # input("Вы действительно хотите отключить телеметрию (д/Н)? ")
232
- # .lower()
233
- # .startswith(("д", "y"))
234
- # ):
235
- # self.enable_telemetry = False
236
- # logger.info("Телеметрия отключена.")
237
- # else:
238
- # logger.info("Спасибо за то что оставили телеметрию включенной!")
239
- self.enable_telemetry = False
240
-
241
- self.api_client = api_client
242
- self.telemetry_client = telemetry_client
243
- self.resume_id = args.resume_id or self._get_resume_id()
244
- self.application_messages = self._get_application_messages(args.message_list)
245
- self.ignored_employers = self._get_ignored_employers(args.ignore_employers)
246
- self.chat = None
247
-
248
- if config := args.config.get("blackbox"):
249
- self.chat = BlackboxChat(
250
- session_id=config["session_id"],
251
- chat_payload=config["chat_payload"],
252
- proxies=self.api_client.proxies or {},
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
- )
267
-
268
- self.pre_prompt = args.pre_prompt
269
-
270
- self.apply_min_interval, self.apply_max_interval = args.apply_interval
271
- self.page_min_interval, self.page_max_interval = args.page_interval
272
-
273
- self.force_message = args.force_message
274
- self.order_by = args.order_by
275
- self.search = args.search
276
- self.schedule = args.schedule
277
- self.dry_run = args.dry_run
278
- self.experience = args.experience
279
- self.search_field = args.search_field
280
- self.employment = args.employment
242
+ self.tool = tool
243
+ self.api_client = tool.api_client
244
+ args: Namespace = tool.args
245
+ self.application_messages = self._get_application_messages(
246
+ args.message_list
247
+ )
281
248
  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
249
+ self.bottom_lat = args.bottom_lat
287
250
  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
251
  self.date_from = args.date_from
293
252
  self.date_to = args.date_to
294
- self.top_lat = args.top_lat
295
- self.bottom_lat = args.bottom_lat
253
+ self.dry_run = args.dry_run
254
+ self.employer_id = args.employer_id
255
+ self.employment = args.employment
256
+ self.excluded_employer_id = args.excluded_employer_id
257
+ self.experience = args.experience
258
+ self.force_message = args.force_message
259
+ self.industry = args.industry
260
+ self.label = args.label
296
261
  self.left_lng = args.left_lng
262
+ self.metro = args.metro
263
+ self.no_magic = args.no_magic
264
+ self.only_with_salary = args.only_with_salary
265
+ self.order_by = args.order_by
266
+ self.per_page = args.per_page
267
+ self.period = args.period
268
+ self.pre_prompt = args.prompt
269
+ self.premium = args.premium
270
+ self.professional_role = args.professional_role
271
+ self.resume_id = args.resume_id or tool.first_resume_id()
297
272
  self.right_lng = args.right_lng
273
+ self.salary = args.salary
274
+ self.schedule = args.schedule
275
+ self.search = args.search
276
+ self.search_field = args.search_field
298
277
  self.sort_point_lat = args.sort_point_lat
299
278
  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
279
+ self.top_lat = args.top_lat
280
+ self.total_pages = args.total_pages
281
+ self.openai_chat = (
282
+ tool.get_openai_chat(args.first_prompt) if args.use_ai else None
283
+ )
304
284
  self._apply_similar()
305
285
 
306
- def _get_application_messages(self, message_list: TextIO | None) -> list[str]:
307
- if message_list:
308
- application_messages = list(filter(None, map(str.strip, message_list)))
309
- else:
310
- application_messages = [
286
+ def _get_application_messages(
287
+ self, message_list: TextIO | None
288
+ ) -> list[str]:
289
+ return (
290
+ list(filter(None, map(str.strip, message_list)))
291
+ if message_list
292
+ else [
311
293
  "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
312
- "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
294
+ "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s", # noqa: E501
313
295
  ]
314
- return application_messages
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
296
+ )
325
297
 
326
298
  def _apply_similar(self) -> None:
327
- telemetry_client = self.telemetry_client
328
- telemetry_data = defaultdict(dict)
329
-
330
- vacancies = self._get_vacancies()
331
-
332
- if self.enable_telemetry:
333
- for vacancy in vacancies:
334
- vacancy_id = vacancy["id"]
335
- telemetry_data["vacancies"][vacancy_id] = {
336
- "name": vacancy.get("name"),
337
- "type": vacancy.get("type", {}).get("id"), # open/closed
338
- "area": vacancy.get("area", {}).get("name"), # город
339
- "salary": vacancy.get("salary"), # from, to, currency, gross
340
- "direct_url": vacancy.get("alternate_url"), # ссылка на вакансию
341
- "created_at": fix_datetime(
342
- vacancy.get("created_at")
343
- ), # будем вычислять говно-вакансии, которые по полгода висят
344
- "published_at": fix_datetime(vacancy.get("published_at")),
345
- "contacts": vacancy.get(
346
- "contacts"
347
- ), # пиздорванки там телеграм для связи указывают
348
- # HH с точки зрения перфикциониста — кусок говна, где кривые
349
- # форматы даты, у вакансий может не быть работодателя...
350
- "employer_id": int(vacancy["employer"]["id"])
351
- if "employer" in vacancy and "id" in vacancy["employer"]
352
- else None,
353
- # "relations": vacancy.get("relations", []),
354
- # Остальное неинтересно
355
- }
299
+ me: datatypes.User = self.tool.get_me()
356
300
 
357
- me = self.api_client.get("/me")
358
-
359
- basic_message_placeholders = {
301
+ basic_placeholders = {
360
302
  "first_name": me.get("first_name", ""),
361
303
  "last_name": me.get("last_name", ""),
362
304
  "email": me.get("email", ""),
363
305
  "phone": me.get("phone", ""),
364
306
  }
365
307
 
366
- do_apply = True
367
-
368
- for vacancy in vacancies:
308
+ seen_employers = set()
309
+ for vacancy in self._get_vacancies():
369
310
  try:
370
- message_placeholders = {
311
+ employer = vacancy.get("employer", {})
312
+
313
+ placeholders = {
371
314
  "vacancy_name": vacancy.get("name", ""),
372
- "employer_name": vacancy.get("employer", {}).get("name", ""),
373
- **basic_message_placeholders,
315
+ "employer_name": employer.get("name", ""),
316
+ **basic_placeholders,
374
317
  }
375
318
 
376
- logger.debug(
377
- "Вакансия %(vacancy_name)s от %(employer_name)s"
378
- % message_placeholders
379
- )
319
+ storage = self.tool.storage
320
+ storage.vacancies.save(vacancy)
321
+ if employer := vacancy.get("employer"):
322
+ employer_id = employer.get("id")
323
+ if employer_id and employer_id not in seen_employers:
324
+ employer_profile: datatypes.Employer = (
325
+ self.api_client.get(f"/employers/{employer_id}")
326
+ )
327
+ storage.employers.save(employer_profile)
328
+
329
+ # По факту контакты можно получить только здесь?!
330
+ if vacancy.get("contacts"):
331
+ storage.employer_contacts.save(vacancy)
380
332
 
381
333
  if vacancy.get("has_test"):
382
334
  logger.debug(
@@ -386,137 +338,95 @@ class Operation(BaseOperation, GetResumeIdMixin):
386
338
  continue
387
339
 
388
340
  if vacancy.get("archived"):
389
- logger.warning(
341
+ logger.debug(
390
342
  "Пропускаем вакансию в архиве: %s",
391
343
  vacancy["alternate_url"],
392
344
  )
393
345
  continue
394
346
 
395
- relations = vacancy.get("relations", [])
396
- employer_id = int(vacancy.get("employer", {}).get("id", 0))
397
-
398
- if (
399
- self.enable_telemetry
400
- and employer_id
401
- and employer_id not in telemetry_data["employers"]
402
- and employer_id not in self.ignored_employers
403
- and (
404
- not relations
405
- or parse_invalid_datetime(vacancy["created_at"])
406
- + timedelta(days=7)
407
- > datetime.now(tz=timezone.utc)
347
+ if redirect_url := vacancy.get("response_url"):
348
+ logger.debug(
349
+ "Пропускаем вакансию %s с перенаправлением: %s",
350
+ vacancy["alternate_url"],
351
+ redirect_url,
408
352
  )
409
- ):
410
- employer = self.api_client.get(f"/employers/{employer_id}")
411
-
412
- employer_data = {
413
- "name": employer.get("name"),
414
- "type": employer.get("type"),
415
- "description": employer.get("description"),
416
- "site_url": employer.get("site_url"),
417
- "area": employer.get("area", {}).get("name"), # город
418
- }
419
- if "got_rejection" in relations:
420
- print(
421
- "🚨 Вы получили отказ от https://hh.ru/employer/%s"
422
- % employer_id
423
- )
424
-
425
- self.ignored_employers.add(employer_id)
353
+ continue
426
354
 
427
- elif do_apply:
428
- telemetry_data["employers"][employer_id] = employer_data
355
+ vacancy_id = vacancy["id"]
429
356
 
430
- if not do_apply:
431
- logger.debug(
432
- "Останавливаем рассылку откликов, так как достигли лимита, попробуйте через сутки."
433
- )
434
- break
357
+ relations = vacancy.get("relations", [])
435
358
 
436
359
  if relations:
437
360
  logger.debug(
438
361
  "Пропускаем вакансию с откликом: %s",
439
362
  vacancy["alternate_url"],
440
363
  )
364
+ if "got_rejection" in relations:
365
+ logger.debug(
366
+ "Вы получили отказ: %s", vacancy["alternate_url"]
367
+ )
368
+ print("⛔ Пришел отказ", vacancy["alternate_url"])
441
369
  continue
442
370
 
443
371
  params = {
444
372
  "resume_id": self.resume_id,
445
- "vacancy_id": vacancy["id"],
373
+ "vacancy_id": vacancy_id,
446
374
  "message": "",
447
375
  }
448
376
 
449
- if self.force_message or vacancy.get("response_letter_required"):
450
- if self.chat:
451
- try:
452
- msg = self.pre_prompt + "\n\n"
453
- msg += message_placeholders["vacancy_name"]
454
- logger.debug(msg)
455
- msg = self.chat.send_message(msg)
456
- except Exception as ex:
457
- logger.error(ex)
458
- continue
377
+ if self.force_message or vacancy.get(
378
+ "response_letter_required"
379
+ ):
380
+ if self.openai_chat:
381
+ msg = self.pre_prompt + "\n\n"
382
+ msg += placeholders["vacancy_name"]
383
+ logger.debug("prompt: %s", msg)
384
+ msg = self.openai_chat.send_message(msg)
459
385
  else:
460
386
  msg = (
461
- random_text(random.choice(self.application_messages))
462
- % message_placeholders
387
+ rand_text(random.choice(self.application_messages))
388
+ % placeholders
463
389
  )
464
390
 
465
391
  logger.debug(msg)
466
392
  params["message"] = msg
467
393
 
468
- if self.dry_run:
469
- logger.info(
470
- "Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
394
+ try:
395
+ if not self.dry_run:
396
+ res = self.api_client.post(
397
+ "/negotiations",
398
+ params,
399
+ delay=random.uniform(1, 3),
400
+ )
401
+ assert res == {}
402
+ logger.debug(
403
+ "Отправили отклик: %s", vacancy["alternate_url"]
404
+ )
405
+ print(
406
+ "📨 Отправили отклик:",
471
407
  vacancy["alternate_url"],
472
- params,
408
+ shorten(vacancy["name"]),
409
+ )
410
+ except Redirect:
411
+ logger.warning(
412
+ f"Игнорирую перенаправление на форму: {vacancy['alternate_url']}" # noqa: E501
473
413
  )
474
- continue
475
-
476
- # Задержка перед отправкой отклика
477
- interval = random.uniform(
478
- self.apply_min_interval, self.apply_max_interval
479
- )
480
- time.sleep(interval)
481
-
482
- res = self.api_client.post("/negotiations", params)
483
- assert res == {}
484
- print(
485
- "📨 Отправили отклик",
486
- vacancy["alternate_url"],
487
- "(",
488
- truncate_string(vacancy["name"]),
489
- ")",
490
- )
491
414
  except LimitExceeded:
415
+ logger.info("Достигли лимита на отклики")
492
416
  print("⚠️ Достигли лимита рассылки")
493
- do_apply = False
494
- except BadResponse as ex:
417
+ # self.tool.storage.settings.set_value("_")
418
+ break
419
+ except ApiError as ex:
420
+ logger.warning(ex)
421
+ except (BadResponse, AIError) as ex:
495
422
  logger.error(ex)
496
423
 
497
424
  print("📝 Отклики на вакансии разосланы!")
498
425
 
499
- if self.enable_telemetry:
500
- if self.dry_run:
501
- # С --dry-run можно посмотреть что отправляется
502
- logger.info(
503
- "Dry Run: Данные телеметрии для отправки на сервер: %r",
504
- telemetry_data,
505
- )
506
- return
507
-
508
- try:
509
- response = telemetry_client.send_telemetry(
510
- "/collect", dict(telemetry_data)
511
- )
512
- logger.debug(response)
513
- except TelemetryError as ex:
514
- logger.error(ex)
515
-
516
- def _get_search_params(self, page: int, per_page: int) -> dict:
426
+ def _get_search_params(self, page: int) -> dict:
517
427
  params = {
518
428
  "page": page,
519
- "per_page": per_page,
429
+ "per_page": self.per_page,
520
430
  "order_by": self.order_by,
521
431
  }
522
432
 
@@ -549,53 +459,47 @@ class Operation(BaseOperation, GetResumeIdMixin):
549
459
  if self.sort_point_lng:
550
460
  params["sort_point_lng"] = self.sort_point_lng
551
461
  if self.search_field:
552
- params["search_field"] = _join_list(self.search_field)
462
+ params["search_field"] = list2str(self.search_field)
553
463
  if self.employment:
554
- params["employment"] = _join_list(self.employment)
464
+ params["employment"] = list2str(self.employment)
555
465
  if self.area:
556
- params["area"] = _join_list(self.area)
466
+ params["area"] = list2str(self.area)
557
467
  if self.metro:
558
- params["metro"] = _join_list(self.metro)
468
+ params["metro"] = list2str(self.metro)
559
469
  if self.professional_role:
560
- params["professional_role"] = _join_list(self.professional_role)
470
+ params["professional_role"] = list2str(self.professional_role)
561
471
  if self.industry:
562
- params["industry"] = _join_list(self.industry)
472
+ params["industry"] = list2str(self.industry)
563
473
  if self.employer_id:
564
- params["employer_id"] = _join_list(self.employer_id)
474
+ params["employer_id"] = list2str(self.employer_id)
565
475
  if self.excluded_employer_id:
566
- params["excluded_employer_id"] = _join_list(self.excluded_employer_id)
476
+ params["excluded_employer_id"] = list2str(self.excluded_employer_id)
567
477
  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)
478
+ params["label"] = list2str(self.label)
479
+ if self.only_with_salary:
480
+ params["only_with_salary"] = bool2str(self.only_with_salary)
481
+ # if self.clusters:
482
+ # params["clusters"] = bool2str(self.clusters)
483
+ if self.no_magic:
484
+ params["no_magic"] = bool2str(self.no_magic)
485
+ if self.premium:
486
+ params["premium"] = bool2str(self.premium)
577
487
  # if self.responses_count_enabled is not None:
578
- # params["responses_count_enabled"] = _bool(self.responses_count_enabled)
488
+ # params["responses_count_enabled"] = bool2str(self.responses_count_enabled)
579
489
 
580
490
  return params
581
491
 
582
- def _get_vacancies(self, per_page: int = 100) -> list[VacancyItem]:
583
- rv = []
584
- # API отдает только 2000 результатов
585
- for page in range(20):
586
- params = self._get_search_params(page, per_page)
587
- res: ApiListResponse = self.api_client.get(
588
- f"/resumes/{self.resume_id}/similar_vacancies", params
492
+ def _get_vacancies(self) -> Iterator[SearchVacancy]:
493
+ for page in range(self.total_pages):
494
+ params = self._get_search_params(page)
495
+ res: PaginatedItems[SearchVacancy] = self.api_client.get(
496
+ f"/resumes/{self.resume_id}/similar_vacancies",
497
+ params,
589
498
  )
590
- rv.extend(res["items"])
591
- if page >= res["pages"] - 1:
592
- break
499
+ if not res["items"]:
500
+ return
593
501
 
594
- # Задержка перед получением следующей страницы
595
- if page > 0:
596
- interval = random.uniform(
597
- self.page_min_interval, self.page_max_interval
598
- )
599
- time.sleep(interval)
502
+ yield from res["items"]
600
503
 
601
- return rv
504
+ if page >= res["pages"] - 1:
505
+ return