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.
- 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 +25 -35
- hh_applicant_tool/api/__init__.py +4 -2
- hh_applicant_tool/api/client.py +65 -68
- hh_applicant_tool/{constants.py → api/client_keys.py} +3 -6
- hh_applicant_tool/api/datatypes.py +293 -0
- hh_applicant_tool/api/errors.py +57 -7
- hh_applicant_tool/api/user_agent.py +17 -0
- hh_applicant_tool/main.py +234 -113
- hh_applicant_tool/operations/apply_similar.py +353 -371
- hh_applicant_tool/operations/authorize.py +313 -120
- hh_applicant_tool/operations/call_api.py +18 -8
- hh_applicant_tool/operations/check_proxy.py +30 -0
- hh_applicant_tool/operations/clear_negotiations.py +90 -82
- hh_applicant_tool/operations/config.py +119 -16
- hh_applicant_tool/operations/install.py +34 -0
- hh_applicant_tool/operations/list_resumes.py +23 -11
- hh_applicant_tool/operations/log.py +77 -0
- hh_applicant_tool/operations/migrate_db.py +65 -0
- hh_applicant_tool/operations/query.py +122 -0
- hh_applicant_tool/operations/refresh_token.py +14 -13
- hh_applicant_tool/operations/reply_employers.py +201 -180
- hh_applicant_tool/operations/settings.py +95 -0
- hh_applicant_tool/operations/uninstall.py +26 -0
- hh_applicant_tool/operations/update_resumes.py +23 -11
- hh_applicant_tool/operations/whoami.py +40 -7
- hh_applicant_tool/storage/__init__.py +8 -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/contacts.py +28 -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 +132 -0
- hh_applicant_tool/storage/repositories/__init__.py +0 -0
- hh_applicant_tool/storage/repositories/base.py +230 -0
- hh_applicant_tool/storage/repositories/contacts.py +14 -0
- hh_applicant_tool/storage/repositories/employers.py +14 -0
- hh_applicant_tool/storage/repositories/errors.py +19 -0
- hh_applicant_tool/storage/repositories/negotiations.py +13 -0
- hh_applicant_tool/storage/repositories/resumes.py +9 -0
- hh_applicant_tool/storage/repositories/settings.py +35 -0
- hh_applicant_tool/storage/repositories/vacancies.py +9 -0
- hh_applicant_tool/storage/utils.py +40 -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/date.py +19 -0
- hh_applicant_tool/utils/json.py +61 -0
- hh_applicant_tool/{jsonc.py → utils/jsonc.py} +12 -6
- hh_applicant_tool/utils/log.py +147 -0
- hh_applicant_tool/utils/misc.py +12 -0
- hh_applicant_tool/utils/mixins.py +221 -0
- hh_applicant_tool/utils/string.py +27 -0
- hh_applicant_tool/utils/terminal.py +32 -0
- hh_applicant_tool-1.4.12.dist-info/METADATA +685 -0
- hh_applicant_tool-1.4.12.dist-info/RECORD +68 -0
- hh_applicant_tool/ai/blackbox.py +0 -55
- hh_applicant_tool/color_log.py +0 -47
- hh_applicant_tool/mixins.py +0 -13
- hh_applicant_tool/operations/delete_telemetry.py +0 -30
- hh_applicant_tool/operations/get_employer_contacts.py +0 -348
- hh_applicant_tool/telemetry_client.py +0 -106
- hh_applicant_tool/types.py +0 -45
- hh_applicant_tool/utils.py +0 -119
- hh_applicant_tool-0.7.10.dist-info/METADATA +0 -452
- hh_applicant_tool-0.7.10.dist-info/RECORD +0 -33
- {hh_applicant_tool-0.7.10.dist-info → hh_applicant_tool-1.4.12.dist-info}/WHEEL +0 -0
- {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
|
|
9
|
-
|
|
10
|
-
from ..ai.
|
|
11
|
-
from ..
|
|
12
|
-
from ..api import
|
|
13
|
-
from ..api.errors import LimitExceeded
|
|
14
|
-
from ..main import BaseOperation
|
|
15
|
-
from ..
|
|
16
|
-
from ..
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
from ..
|
|
20
|
-
|
|
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
|
-
|
|
26
|
+
message_list_path: Path
|
|
33
27
|
ignore_employers: Path | None
|
|
34
28
|
force_message: bool
|
|
35
29
|
use_ai: bool
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
"
|
|
87
|
-
"
|
|
88
|
-
|
|
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
|
-
"
|
|
93
|
-
|
|
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
|
-
"--
|
|
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
|
-
"--
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
107
|
+
"--total-pages",
|
|
108
|
+
"--pages",
|
|
109
|
+
help="Количество обрабатываемых страниц поиска", # noqa: E501
|
|
110
|
+
default=20,
|
|
111
|
+
type=int,
|
|
123
112
|
)
|
|
124
113
|
parser.add_argument(
|
|
125
|
-
"--page
|
|
126
|
-
help="
|
|
127
|
-
default=
|
|
128
|
-
type=
|
|
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
|
-
|
|
143
|
-
"--
|
|
144
|
-
help="
|
|
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="Тип
|
|
152
|
+
help="Тип графика (fullDay, shift, flexible, remote, flyInFlyOut)",
|
|
152
153
|
type=str,
|
|
153
|
-
default=None,
|
|
154
154
|
)
|
|
155
|
-
|
|
156
|
-
"--
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
"--
|
|
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
|
-
|
|
168
|
-
"--
|
|
190
|
+
search_params_group.add_argument(
|
|
191
|
+
"--period", type=int, help="Искать вакансии за N дней"
|
|
169
192
|
)
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
216
|
+
search_params_group.add_argument(
|
|
197
217
|
"--sort-point-lng",
|
|
198
218
|
type=float,
|
|
199
219
|
help="Координата lng для сортировки по расстоянию",
|
|
200
220
|
)
|
|
201
|
-
|
|
221
|
+
search_params_group.add_argument(
|
|
202
222
|
"--no-magic",
|
|
203
223
|
action="store_true",
|
|
204
224
|
help="Отключить авторазбор текста запроса",
|
|
205
225
|
)
|
|
206
|
-
|
|
226
|
+
search_params_group.add_argument(
|
|
207
227
|
"--premium",
|
|
208
228
|
default=False,
|
|
209
229
|
action=argparse.BooleanOptionalAction,
|
|
210
230
|
help="Только премиум вакансии",
|
|
211
231
|
)
|
|
212
|
-
|
|
213
|
-
"--search-field",
|
|
214
|
-
|
|
215
|
-
|
|
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,
|
|
239
|
+
self,
|
|
240
|
+
tool: HHApplicantTool,
|
|
224
241
|
) -> None:
|
|
225
|
-
self.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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.
|
|
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.
|
|
295
|
-
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
|
|
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.
|
|
301
|
-
|
|
302
|
-
self.
|
|
303
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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":
|
|
373
|
-
**
|
|
346
|
+
"employer_name": employer.get("name", ""),
|
|
347
|
+
**placeholders,
|
|
374
348
|
}
|
|
375
349
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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.
|
|
385
|
+
logger.debug(
|
|
390
386
|
"Пропускаем вакансию в архиве: %s",
|
|
391
387
|
vacancy["alternate_url"],
|
|
392
388
|
)
|
|
393
389
|
continue
|
|
394
390
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
428
|
-
telemetry_data["employers"][employer_id] = employer_data
|
|
399
|
+
vacancy_id = vacancy["id"]
|
|
429
400
|
|
|
430
|
-
|
|
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":
|
|
445
|
-
"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(
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
494
|
-
except
|
|
495
|
-
logger.
|
|
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
|
|
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"] =
|
|
522
|
+
params["search_field"] = list2str(self.search_field)
|
|
553
523
|
if self.employment:
|
|
554
|
-
params["employment"] =
|
|
524
|
+
params["employment"] = list2str(self.employment)
|
|
555
525
|
if self.area:
|
|
556
|
-
params["area"] =
|
|
526
|
+
params["area"] = list2str(self.area)
|
|
557
527
|
if self.metro:
|
|
558
|
-
params["metro"] =
|
|
528
|
+
params["metro"] = list2str(self.metro)
|
|
559
529
|
if self.professional_role:
|
|
560
|
-
params["professional_role"] =
|
|
530
|
+
params["professional_role"] = list2str(self.professional_role)
|
|
561
531
|
if self.industry:
|
|
562
|
-
params["industry"] =
|
|
532
|
+
params["industry"] = list2str(self.industry)
|
|
563
533
|
if self.employer_id:
|
|
564
|
-
params["employer_id"] =
|
|
534
|
+
params["employer_id"] = list2str(self.employer_id)
|
|
565
535
|
if self.excluded_employer_id:
|
|
566
|
-
params["excluded_employer_id"] =
|
|
536
|
+
params["excluded_employer_id"] = list2str(self.excluded_employer_id)
|
|
567
537
|
if self.label:
|
|
568
|
-
params["label"] =
|
|
569
|
-
if self.only_with_salary
|
|
570
|
-
params["only_with_salary"] =
|
|
571
|
-
if self.clusters
|
|
572
|
-
|
|
573
|
-
if self.no_magic
|
|
574
|
-
params["no_magic"] =
|
|
575
|
-
if self.premium
|
|
576
|
-
params["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"] =
|
|
548
|
+
# params["responses_count_enabled"] = bool2str(self.responses_count_enabled)
|
|
579
549
|
|
|
580
550
|
return params
|
|
581
551
|
|
|
582
|
-
def
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
559
|
+
if not res["items"]:
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
yield from res["items"]
|
|
563
|
+
|
|
591
564
|
if page >= res["pages"] - 1:
|
|
592
|
-
|
|
565
|
+
return
|
|
593
566
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
+
)
|