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.
- hh_applicant_tool/__init__.py +1 -0
- hh_applicant_tool/__main__.py +1 -1
- hh_applicant_tool/ai/base.py +2 -0
- hh_applicant_tool/ai/openai.py +24 -30
- hh_applicant_tool/api/client.py +82 -98
- hh_applicant_tool/api/errors.py +57 -8
- hh_applicant_tool/constants.py +0 -3
- hh_applicant_tool/datatypes.py +291 -0
- hh_applicant_tool/main.py +236 -82
- hh_applicant_tool/operations/apply_similar.py +268 -348
- hh_applicant_tool/operations/authorize.py +245 -70
- hh_applicant_tool/operations/call_api.py +18 -8
- hh_applicant_tool/operations/check_negotiations.py +102 -0
- hh_applicant_tool/operations/check_proxy.py +30 -0
- hh_applicant_tool/operations/config.py +119 -18
- hh_applicant_tool/operations/install.py +34 -0
- hh_applicant_tool/operations/list_resumes.py +24 -10
- hh_applicant_tool/operations/log.py +77 -0
- hh_applicant_tool/operations/migrate_db.py +65 -0
- hh_applicant_tool/operations/query.py +120 -0
- hh_applicant_tool/operations/refresh_token.py +14 -13
- hh_applicant_tool/operations/reply_employers.py +148 -167
- hh_applicant_tool/operations/settings.py +95 -0
- hh_applicant_tool/operations/uninstall.py +26 -0
- hh_applicant_tool/operations/update_resumes.py +21 -10
- hh_applicant_tool/operations/whoami.py +40 -7
- hh_applicant_tool/storage/__init__.py +4 -0
- hh_applicant_tool/storage/facade.py +24 -0
- hh_applicant_tool/storage/models/__init__.py +0 -0
- hh_applicant_tool/storage/models/base.py +169 -0
- hh_applicant_tool/storage/models/contact.py +16 -0
- hh_applicant_tool/storage/models/employer.py +12 -0
- hh_applicant_tool/storage/models/negotiation.py +16 -0
- hh_applicant_tool/storage/models/resume.py +19 -0
- hh_applicant_tool/storage/models/setting.py +6 -0
- hh_applicant_tool/storage/models/vacancy.py +36 -0
- hh_applicant_tool/storage/queries/migrations/.gitkeep +0 -0
- hh_applicant_tool/storage/queries/schema.sql +119 -0
- hh_applicant_tool/storage/repositories/__init__.py +0 -0
- hh_applicant_tool/storage/repositories/base.py +176 -0
- hh_applicant_tool/storage/repositories/contacts.py +19 -0
- hh_applicant_tool/storage/repositories/employers.py +13 -0
- hh_applicant_tool/storage/repositories/negotiations.py +12 -0
- hh_applicant_tool/storage/repositories/resumes.py +14 -0
- hh_applicant_tool/storage/repositories/settings.py +34 -0
- hh_applicant_tool/storage/repositories/vacancies.py +8 -0
- hh_applicant_tool/storage/utils.py +49 -0
- hh_applicant_tool/utils/__init__.py +31 -0
- hh_applicant_tool/utils/attrdict.py +6 -0
- hh_applicant_tool/utils/binpack.py +167 -0
- hh_applicant_tool/utils/config.py +55 -0
- hh_applicant_tool/utils/dateutil.py +19 -0
- hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
- hh_applicant_tool/utils/jsonutil.py +61 -0
- hh_applicant_tool/utils/log.py +144 -0
- hh_applicant_tool/utils/misc.py +12 -0
- hh_applicant_tool/utils/mixins.py +220 -0
- hh_applicant_tool/utils/string.py +27 -0
- hh_applicant_tool/utils/terminal.py +19 -0
- hh_applicant_tool/utils/user_agent.py +17 -0
- hh_applicant_tool-1.4.7.dist-info/METADATA +628 -0
- hh_applicant_tool-1.4.7.dist-info/RECORD +67 -0
- hh_applicant_tool/ai/blackbox.py +0 -55
- hh_applicant_tool/color_log.py +0 -35
- hh_applicant_tool/mixins.py +0 -13
- hh_applicant_tool/operations/clear_negotiations.py +0 -113
- hh_applicant_tool/operations/delete_telemetry.py +0 -30
- hh_applicant_tool/operations/get_employer_contacts.py +0 -293
- hh_applicant_tool/telemetry_client.py +0 -106
- hh_applicant_tool/types.py +0 -45
- hh_applicant_tool/utils.py +0 -104
- hh_applicant_tool-0.6.12.dist-info/METADATA +0 -349
- hh_applicant_tool-0.6.12.dist-info/RECORD +0 -33
- {hh_applicant_tool-0.6.12.dist-info → hh_applicant_tool-1.4.7.dist-info}/WHEEL +0 -0
- {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
|
|
5
|
-
from
|
|
6
|
-
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
from ..
|
|
10
|
-
from ..
|
|
11
|
-
from ..
|
|
12
|
-
from ..
|
|
13
|
-
from ..
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
from ..
|
|
17
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
"--
|
|
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
|
-
"--
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
"--total-pages",
|
|
108
|
+
"--pages",
|
|
109
|
+
help="Количество обрабатываемых страниц поиска", # noqa: E501
|
|
110
|
+
default=20,
|
|
111
|
+
type=int,
|
|
115
112
|
)
|
|
116
113
|
parser.add_argument(
|
|
117
|
-
"--page
|
|
118
|
-
help="
|
|
119
|
-
default=
|
|
120
|
-
type=
|
|
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
|
-
|
|
135
|
-
"--
|
|
136
|
-
help="
|
|
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="Тип
|
|
152
|
+
help="Тип графика (fullDay, shift, flexible, remote, flyInFlyOut)",
|
|
144
153
|
type=str,
|
|
145
|
-
default=None,
|
|
146
154
|
)
|
|
147
|
-
|
|
148
|
-
"--
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
"--
|
|
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
|
-
|
|
160
|
-
"--
|
|
190
|
+
search_params_group.add_argument(
|
|
191
|
+
"--period", type=int, help="Искать вакансии за N дней"
|
|
161
192
|
)
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
216
|
+
search_params_group.add_argument(
|
|
189
217
|
"--sort-point-lng",
|
|
190
218
|
type=float,
|
|
191
219
|
help="Координата lng для сортировки по расстоянию",
|
|
192
220
|
)
|
|
193
|
-
|
|
221
|
+
search_params_group.add_argument(
|
|
194
222
|
"--no-magic",
|
|
195
|
-
|
|
196
|
-
action=argparse.BooleanOptionalAction,
|
|
223
|
+
action="store_true",
|
|
197
224
|
help="Отключить авторазбор текста запроса",
|
|
198
225
|
)
|
|
199
|
-
|
|
226
|
+
search_params_group.add_argument(
|
|
200
227
|
"--premium",
|
|
201
228
|
default=False,
|
|
202
229
|
action=argparse.BooleanOptionalAction,
|
|
203
230
|
help="Только премиум вакансии",
|
|
204
231
|
)
|
|
205
|
-
|
|
206
|
-
"--search-field",
|
|
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,
|
|
239
|
+
self,
|
|
240
|
+
tool: HHApplicantTool,
|
|
217
241
|
) -> None:
|
|
218
|
-
self.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
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.
|
|
287
|
-
self.
|
|
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.
|
|
293
|
-
|
|
294
|
-
self.
|
|
295
|
-
|
|
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(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
296
|
+
)
|
|
307
297
|
|
|
308
298
|
def _apply_similar(self) -> None:
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
for vacancy in vacancies:
|
|
308
|
+
seen_employers = set()
|
|
309
|
+
for vacancy in self._get_vacancies():
|
|
352
310
|
try:
|
|
353
|
-
|
|
311
|
+
employer = vacancy.get("employer", {})
|
|
312
|
+
|
|
313
|
+
placeholders = {
|
|
354
314
|
"vacancy_name": vacancy.get("name", ""),
|
|
355
|
-
"employer_name":
|
|
356
|
-
**
|
|
315
|
+
"employer_name": employer.get("name", ""),
|
|
316
|
+
**basic_placeholders,
|
|
357
317
|
}
|
|
358
318
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
341
|
+
logger.debug(
|
|
373
342
|
"Пропускаем вакансию в архиве: %s",
|
|
374
343
|
vacancy["alternate_url"],
|
|
375
344
|
)
|
|
376
345
|
continue
|
|
377
346
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
411
|
-
telemetry_data["employers"][employer_id] = employer_data
|
|
355
|
+
vacancy_id = vacancy["id"]
|
|
412
356
|
|
|
413
|
-
|
|
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":
|
|
373
|
+
"vacancy_id": vacancy_id,
|
|
429
374
|
"message": "",
|
|
430
375
|
}
|
|
431
376
|
|
|
432
|
-
if self.force_message or vacancy.get(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
445
|
-
%
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"] =
|
|
462
|
+
params["search_field"] = list2str(self.search_field)
|
|
536
463
|
if self.employment:
|
|
537
|
-
params["employment"] =
|
|
464
|
+
params["employment"] = list2str(self.employment)
|
|
538
465
|
if self.area:
|
|
539
|
-
params["area"] =
|
|
466
|
+
params["area"] = list2str(self.area)
|
|
540
467
|
if self.metro:
|
|
541
|
-
params["metro"] =
|
|
468
|
+
params["metro"] = list2str(self.metro)
|
|
542
469
|
if self.professional_role:
|
|
543
|
-
params["professional_role"] =
|
|
470
|
+
params["professional_role"] = list2str(self.professional_role)
|
|
544
471
|
if self.industry:
|
|
545
|
-
params["industry"] =
|
|
472
|
+
params["industry"] = list2str(self.industry)
|
|
546
473
|
if self.employer_id:
|
|
547
|
-
params["employer_id"] =
|
|
474
|
+
params["employer_id"] = list2str(self.employer_id)
|
|
548
475
|
if self.excluded_employer_id:
|
|
549
|
-
params["excluded_employer_id"] =
|
|
476
|
+
params["excluded_employer_id"] = list2str(self.excluded_employer_id)
|
|
550
477
|
if self.label:
|
|
551
|
-
params["label"] =
|
|
552
|
-
if self.only_with_salary
|
|
553
|
-
params["only_with_salary"] =
|
|
554
|
-
if self.clusters
|
|
555
|
-
|
|
556
|
-
if self.no_magic
|
|
557
|
-
params["no_magic"] =
|
|
558
|
-
if self.premium
|
|
559
|
-
params["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"] =
|
|
488
|
+
# params["responses_count_enabled"] = bool2str(self.responses_count_enabled)
|
|
562
489
|
|
|
563
490
|
return params
|
|
564
491
|
|
|
565
|
-
def _get_vacancies(self
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
502
|
+
yield from res["items"]
|
|
585
503
|
|
|
504
|
+
if page >= res["pages"] - 1:
|
|
505
|
+
return
|