hh-applicant-tool 0.7.10__py3-none-any.whl → 1.4.12__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 (76) 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 +25 -35
  5. hh_applicant_tool/api/__init__.py +4 -2
  6. hh_applicant_tool/api/client.py +65 -68
  7. hh_applicant_tool/{constants.py → api/client_keys.py} +3 -6
  8. hh_applicant_tool/api/datatypes.py +293 -0
  9. hh_applicant_tool/api/errors.py +57 -7
  10. hh_applicant_tool/api/user_agent.py +17 -0
  11. hh_applicant_tool/main.py +234 -113
  12. hh_applicant_tool/operations/apply_similar.py +353 -371
  13. hh_applicant_tool/operations/authorize.py +313 -120
  14. hh_applicant_tool/operations/call_api.py +18 -8
  15. hh_applicant_tool/operations/check_proxy.py +30 -0
  16. hh_applicant_tool/operations/clear_negotiations.py +90 -82
  17. hh_applicant_tool/operations/config.py +119 -16
  18. hh_applicant_tool/operations/install.py +34 -0
  19. hh_applicant_tool/operations/list_resumes.py +23 -11
  20. hh_applicant_tool/operations/log.py +77 -0
  21. hh_applicant_tool/operations/migrate_db.py +65 -0
  22. hh_applicant_tool/operations/query.py +122 -0
  23. hh_applicant_tool/operations/refresh_token.py +14 -13
  24. hh_applicant_tool/operations/reply_employers.py +201 -180
  25. hh_applicant_tool/operations/settings.py +95 -0
  26. hh_applicant_tool/operations/uninstall.py +26 -0
  27. hh_applicant_tool/operations/update_resumes.py +23 -11
  28. hh_applicant_tool/operations/whoami.py +40 -7
  29. hh_applicant_tool/storage/__init__.py +8 -0
  30. hh_applicant_tool/storage/facade.py +24 -0
  31. hh_applicant_tool/storage/models/__init__.py +0 -0
  32. hh_applicant_tool/storage/models/base.py +169 -0
  33. hh_applicant_tool/storage/models/contacts.py +28 -0
  34. hh_applicant_tool/storage/models/employer.py +12 -0
  35. hh_applicant_tool/storage/models/negotiation.py +16 -0
  36. hh_applicant_tool/storage/models/resume.py +19 -0
  37. hh_applicant_tool/storage/models/setting.py +6 -0
  38. hh_applicant_tool/storage/models/vacancy.py +36 -0
  39. hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
  40. hh_applicant_tool/storage/queries/schema.sql +132 -0
  41. hh_applicant_tool/storage/repositories/__init__.py +0 -0
  42. hh_applicant_tool/storage/repositories/base.py +230 -0
  43. hh_applicant_tool/storage/repositories/contacts.py +14 -0
  44. hh_applicant_tool/storage/repositories/employers.py +14 -0
  45. hh_applicant_tool/storage/repositories/errors.py +19 -0
  46. hh_applicant_tool/storage/repositories/negotiations.py +13 -0
  47. hh_applicant_tool/storage/repositories/resumes.py +9 -0
  48. hh_applicant_tool/storage/repositories/settings.py +35 -0
  49. hh_applicant_tool/storage/repositories/vacancies.py +9 -0
  50. hh_applicant_tool/storage/utils.py +40 -0
  51. hh_applicant_tool/utils/__init__.py +31 -0
  52. hh_applicant_tool/utils/attrdict.py +6 -0
  53. hh_applicant_tool/utils/binpack.py +167 -0
  54. hh_applicant_tool/utils/config.py +55 -0
  55. hh_applicant_tool/utils/date.py +19 -0
  56. hh_applicant_tool/utils/json.py +61 -0
  57. hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
  58. hh_applicant_tool/utils/log.py +147 -0
  59. hh_applicant_tool/utils/misc.py +12 -0
  60. hh_applicant_tool/utils/mixins.py +221 -0
  61. hh_applicant_tool/utils/string.py +27 -0
  62. hh_applicant_tool/utils/terminal.py +32 -0
  63. hh_applicant_tool-1.4.12.dist-info/METADATA +685 -0
  64. hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
  65. hh_applicant_tool/ai/blackbox.py +0 -55
  66. hh_applicant_tool/color_log.py +0 -47
  67. hh_applicant_tool/mixins.py +0 -13
  68. hh_applicant_tool/operations/delete_telemetry.py +0 -30
  69. hh_applicant_tool/operations/get_employer_contacts.py +0 -348
  70. hh_applicant_tool/telemetry_client.py +0 -106
  71. hh_applicant_tool/types.py +0 -45
  72. hh_applicant_tool/utils.py +0 -119
  73. hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
  74. hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
  75. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
  76. {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/entry_points.txt +0 -0
@@ -1,41 +1,34 @@
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
8
+
9
+ from ..ai.base import AIError
10
+ from ..api import BadResponse, Redirect, datatypes
11
+ from ..api.datatypes import PaginatedItems, SearchVacancy
12
+ from ..api.errors import ApiError, LimitExceeded
13
+ from ..main import BaseNamespace, BaseOperation
14
+ from ..storage.repositories.errors import RepositoryError
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
 
29
23
 
30
24
  class Namespace(BaseNamespace):
31
25
  resume_id: str | None
32
- message_list: TextIO
26
+ message_list_path: Path
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,70 +57,79 @@ 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 ""
64
+ class Operation(BaseOperation):
65
+ """Откликнуться на все подходящие вакансии."""
75
66
 
76
-
77
- class Operation(BaseOperation, GetResumeIdMixin):
78
- """Откликнуться на все подходящие вакансии.
79
-
80
- Описание фильтров для поиска вакансий: <https://api.hh.ru/openapi/redoc#tag/Poisk-vakansij-dlya-soiskatelya/operation/get-vacancies-similar-to-resume>
81
- """
67
+ __aliases__ = ("apply",)
82
68
 
83
69
  def setup_parser(self, parser: argparse.ArgumentParser) -> None:
84
70
  parser.add_argument("--resume-id", help="Идентефикатор резюме")
85
71
  parser.add_argument(
86
- "-L",
87
- "--message-list",
88
- help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.",
89
- type=argparse.FileType("r", encoding="utf-8", errors="replace"),
72
+ "--search",
73
+ help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500'", # noqa: E501
74
+ type=str,
90
75
  )
91
76
  parser.add_argument(
92
- "--ignore-employers",
93
- help="Путь к файлу со списком ID игнорируемых работодателей (по одному ID на строку)",
77
+ "-L",
78
+ "--message-list-path",
79
+ "--message-list",
80
+ help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки.", # noqa: E501
94
81
  type=Path,
95
- default=None,
96
82
  )
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,240 @@ 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 и т.п.)"
214
- )
215
- parser.add_argument(
216
- "--clusters",
217
- action=argparse.BooleanOptionalAction,
218
- help="Включить кластеры (по умолчанию None)",
232
+ search_params_group.add_argument(
233
+ "--search-field",
234
+ nargs="+",
235
+ help="Поля поиска (name, company_name и т.п.)",
219
236
  )
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_path
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
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 = [
311
- "{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
312
- "{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
313
- ]
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
325
-
326
286
  def _apply_similar(self) -> None:
327
- telemetry_client = self.telemetry_client
328
- telemetry_data = defaultdict(dict)
287
+ resumes: list[datatypes.Resume] = self.tool.get_resumes()
288
+ try:
289
+ self.tool.storage.resumes.save_batch(resumes)
290
+ except RepositoryError as ex:
291
+ logger.exception(ex)
292
+ resumes = (
293
+ list(filter(lambda x: x["id"] == self.resume_id, resumes))
294
+ if self.resume_id
295
+ else resumes
296
+ )
297
+ # Выбираем только опубликованные
298
+ resumes = list(
299
+ filter(lambda x: x["status"]["id"] == "published", resumes)
300
+ )
301
+ if not resumes:
302
+ logger.warning("У вас нет опубликованных резюме")
303
+ return
304
+
305
+ me: datatypes.User = self.tool.get_me()
306
+ seen_employers = set()
307
+
308
+ for resume in resumes:
309
+ self._apply_resume(
310
+ resume=resume,
311
+ user=me,
312
+ seen_employers=seen_employers,
313
+ )
329
314
 
330
- vacancies = self._get_vacancies()
315
+ # Синхронизация откликов
316
+ # for neg in self.tool.get_negotiations():
317
+ # try:
318
+ # self.tool.storage.negotiations.save(neg)
319
+ # except RepositoryError as e:
320
+ # logger.warning(e)
331
321
 
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
- }
356
-
357
- me = self.api_client.get("/me")
322
+ print("📝 Отклики на вакансии разосланы!")
358
323
 
359
- basic_message_placeholders = {
360
- "first_name": me.get("first_name", ""),
361
- "last_name": me.get("last_name", ""),
362
- "email": me.get("email", ""),
363
- "phone": me.get("phone", ""),
324
+ def _apply_resume(
325
+ self,
326
+ resume: datatypes.Resume,
327
+ user: datatypes.User,
328
+ seen_employers: set[str],
329
+ ) -> None:
330
+ logger.info("Начинаю рассылку откликов для резюме: %s", resume["title"])
331
+
332
+ placeholders = {
333
+ "first_name": user.get("first_name") or "",
334
+ "last_name": user.get("last_name") or "",
335
+ "email": user.get("email") or "",
336
+ "phone": user.get("phone") or "",
337
+ "resume_title": resume.get("title") or "",
364
338
  }
365
339
 
366
- do_apply = True
367
-
368
- for vacancy in vacancies:
340
+ for vacancy in self._get_similar_vacancies(resume_id=resume["id"]):
369
341
  try:
342
+ employer = vacancy.get("employer", {})
343
+
370
344
  message_placeholders = {
371
345
  "vacancy_name": vacancy.get("name", ""),
372
- "employer_name": vacancy.get("employer", {}).get("name", ""),
373
- **basic_message_placeholders,
346
+ "employer_name": employer.get("name", ""),
347
+ **placeholders,
374
348
  }
375
349
 
376
- logger.debug(
377
- "Вакансия %(vacancy_name)s от %(employer_name)s"
378
- % message_placeholders
379
- )
350
+ storage = self.tool.storage
351
+
352
+ try:
353
+ storage.vacancies.save(vacancy)
354
+ except RepositoryError as ex:
355
+ logger.debug(ex)
356
+
357
+ if employer := vacancy.get("employer"):
358
+ employer_id = employer.get("id")
359
+ if employer_id and employer_id not in seen_employers:
360
+ employer_profile: datatypes.Employer = (
361
+ self.api_client.get(f"/employers/{employer_id}")
362
+ )
363
+
364
+ try:
365
+ storage.employers.save(employer_profile)
366
+ except RepositoryError as ex:
367
+ logger.exception(ex)
368
+
369
+ # По факту контакты можно получить только здесь?!
370
+ if vacancy.get("contacts"):
371
+ try:
372
+ # logger.debug(vacancy)
373
+ storage.vacancy_contacts.save(vacancy)
374
+ except RecursionError as ex:
375
+ logger.exception(ex)
380
376
 
381
377
  if vacancy.get("has_test"):
382
378
  logger.debug(
@@ -386,137 +382,111 @@ class Operation(BaseOperation, GetResumeIdMixin):
386
382
  continue
387
383
 
388
384
  if vacancy.get("archived"):
389
- logger.warning(
385
+ logger.debug(
390
386
  "Пропускаем вакансию в архиве: %s",
391
387
  vacancy["alternate_url"],
392
388
  )
393
389
  continue
394
390
 
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)
391
+ if redirect_url := vacancy.get("response_url"):
392
+ logger.debug(
393
+ "Пропускаем вакансию %s с перенаправлением: %s",
394
+ vacancy["alternate_url"],
395
+ redirect_url,
408
396
  )
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)
397
+ continue
426
398
 
427
- elif do_apply:
428
- telemetry_data["employers"][employer_id] = employer_data
399
+ vacancy_id = vacancy["id"]
429
400
 
430
- if not do_apply:
431
- logger.debug(
432
- "Останавливаем рассылку откликов, так как достигли лимита, попробуйте через сутки."
433
- )
434
- break
401
+ relations = vacancy.get("relations", [])
435
402
 
436
403
  if relations:
437
404
  logger.debug(
438
405
  "Пропускаем вакансию с откликом: %s",
439
406
  vacancy["alternate_url"],
440
407
  )
408
+ if "got_rejection" in relations:
409
+ logger.debug(
410
+ "Вы получили отказ от %s на резюме %s",
411
+ vacancy["alternate_url"],
412
+ resume["alternate_url"],
413
+ )
414
+ print(
415
+ "⛔ Пришел отказ от",
416
+ vacancy["alternate_url"],
417
+ "на резюме",
418
+ resume["alternate_url"],
419
+ )
441
420
  continue
442
421
 
443
422
  params = {
444
- "resume_id": self.resume_id,
445
- "vacancy_id": vacancy["id"],
423
+ "resume_id": resume["id"],
424
+ "vacancy_id": vacancy_id,
446
425
  "message": "",
447
426
  }
448
427
 
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
428
+ if self.force_message or vacancy.get(
429
+ "response_letter_required"
430
+ ):
431
+ if self.openai_chat:
432
+ msg = self.pre_prompt + "\n\n"
433
+ msg += (
434
+ "Название вакансии: "
435
+ + message_placeholders["vacancy_name"]
436
+ )
437
+ msg += (
438
+ "Мое резюме:" + message_placeholders["resume_title"]
439
+ )
440
+ logger.debug("prompt: %s", msg)
441
+ msg = self.openai_chat.send_message(msg)
459
442
  else:
460
443
  msg = (
461
- random_text(random.choice(self.application_messages))
444
+ rand_text(random.choice(self.application_messages))
462
445
  % message_placeholders
463
446
  )
464
447
 
465
448
  logger.debug(msg)
466
449
  params["message"] = msg
467
450
 
468
- if self.dry_run:
469
- logger.info(
470
- "Dry Run: Отправка отклика на вакансию %s с параметрами: %s",
451
+ try:
452
+ if not self.dry_run:
453
+ res = self.api_client.post(
454
+ "/negotiations",
455
+ params,
456
+ delay=random.uniform(1, 3),
457
+ )
458
+ assert res == {}
459
+ logger.debug(
460
+ "Откликнулись на %s с резюме %s",
461
+ vacancy["alternate_url"],
462
+ resume["alternate_url"],
463
+ )
464
+ print(
465
+ "📨 Отправили отклик для резюме",
466
+ resume["alternate_url"],
467
+ "на вакансию",
471
468
  vacancy["alternate_url"],
472
- params,
469
+ "(",
470
+ shorten(vacancy["name"]),
471
+ ")",
472
+ )
473
+ except Redirect:
474
+ logger.warning(
475
+ f"Игнорирую перенаправление на форму: {vacancy['alternate_url']}" # noqa: E501
473
476
  )
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
477
  except LimitExceeded:
478
+ logger.info("Достигли лимита на отклики")
492
479
  print("⚠️ Достигли лимита рассылки")
493
- do_apply = False
494
- except BadResponse as ex:
495
- logger.error(ex)
496
-
497
- print("📝 Отклики на вакансии разосланы!")
498
-
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:
480
+ break
481
+ except ApiError as ex:
482
+ logger.warning(ex)
483
+ except (BadResponse, AIError) as ex:
514
484
  logger.error(ex)
515
485
 
516
- def _get_search_params(self, page: int, per_page: int) -> dict:
486
+ def _get_search_params(self, page: int) -> dict:
517
487
  params = {
518
488
  "page": page,
519
- "per_page": per_page,
489
+ "per_page": self.per_page,
520
490
  "order_by": self.order_by,
521
491
  }
522
492
 
@@ -549,53 +519,65 @@ class Operation(BaseOperation, GetResumeIdMixin):
549
519
  if self.sort_point_lng:
550
520
  params["sort_point_lng"] = self.sort_point_lng
551
521
  if self.search_field:
552
- params["search_field"] = _join_list(self.search_field)
522
+ params["search_field"] = list2str(self.search_field)
553
523
  if self.employment:
554
- params["employment"] = _join_list(self.employment)
524
+ params["employment"] = list2str(self.employment)
555
525
  if self.area:
556
- params["area"] = _join_list(self.area)
526
+ params["area"] = list2str(self.area)
557
527
  if self.metro:
558
- params["metro"] = _join_list(self.metro)
528
+ params["metro"] = list2str(self.metro)
559
529
  if self.professional_role:
560
- params["professional_role"] = _join_list(self.professional_role)
530
+ params["professional_role"] = list2str(self.professional_role)
561
531
  if self.industry:
562
- params["industry"] = _join_list(self.industry)
532
+ params["industry"] = list2str(self.industry)
563
533
  if self.employer_id:
564
- params["employer_id"] = _join_list(self.employer_id)
534
+ params["employer_id"] = list2str(self.employer_id)
565
535
  if self.excluded_employer_id:
566
- params["excluded_employer_id"] = _join_list(self.excluded_employer_id)
536
+ params["excluded_employer_id"] = list2str(self.excluded_employer_id)
567
537
  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)
538
+ params["label"] = list2str(self.label)
539
+ if self.only_with_salary:
540
+ params["only_with_salary"] = bool2str(self.only_with_salary)
541
+ # if self.clusters:
542
+ # params["clusters"] = bool2str(self.clusters)
543
+ if self.no_magic:
544
+ params["no_magic"] = bool2str(self.no_magic)
545
+ if self.premium:
546
+ params["premium"] = bool2str(self.premium)
577
547
  # if self.responses_count_enabled is not None:
578
- # params["responses_count_enabled"] = _bool(self.responses_count_enabled)
548
+ # params["responses_count_enabled"] = bool2str(self.responses_count_enabled)
579
549
 
580
550
  return params
581
551
 
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
552
+ def _get_similar_vacancies(self, resume_id: str) -> Iterator[SearchVacancy]:
553
+ for page in range(self.total_pages):
554
+ params = self._get_search_params(page)
555
+ res: PaginatedItems[SearchVacancy] = self.api_client.get(
556
+ f"/resumes/{resume_id}/similar_vacancies",
557
+ params,
589
558
  )
590
- rv.extend(res["items"])
559
+ if not res["items"]:
560
+ return
561
+
562
+ yield from res["items"]
563
+
591
564
  if page >= res["pages"] - 1:
592
- break
565
+ return
593
566
 
594
- # Задержка перед получением следующей страницы
595
- if page > 0:
596
- interval = random.uniform(
597
- self.page_min_interval, self.page_max_interval
567
+ def _get_application_messages(self, path: Path | None) -> list[str]:
568
+ return (
569
+ list(
570
+ filter(
571
+ None,
572
+ map(
573
+ str.strip,
574
+ path.open(encoding="utf-8", errors="replace"),
575
+ ),
598
576
  )
599
- time.sleep(interval)
600
-
601
- return rv
577
+ )
578
+ if path
579
+ else [
580
+ "Здравствуйте, меня зовут %(first_name)s. {Меня заинтересовала|Мне понравилась} ваша вакансия «%(vacancy_name)s». Хотелось бы {пообщаться|задать вопросы} о ней.",
581
+ "{Прошу|Предлагаю} рассмотреть {мою кандидатуру|мое резюме «%(resume_title)s»} на вакансию «%(vacancy_name)s». С уважением, %(first_name)s.", # noqa: E501
582
+ ]
583
+ )