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