Skip to content

Commit

Permalink
добавил сообщение для ответа в чаты при отклике на подходящие
Browse files Browse the repository at this point in the history
  • Loading branch information
s3rgeym committed Nov 14, 2024
1 parent e214937 commit 101f1bb
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 40 deletions.
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,38 @@ https://hh.ru/employer/1918903
| **refresh-token** | Обновляет access_token. |
| **get-employer-contacts** | Получить список контактов работодателя, даже если тот не высылал приглашения. Контакты получаются строго из публичного доступа, например, с сайта фирмы и могут быть удалены только по просьбе уполнамоченного лица. Данная функция готова и будет доступна после 100 ⭐ |

### Система плагинов
### Формат текста сообщений

Команда `apply-similar` поддерживает специальный формат сообщений.

Так же в сообщении можно использовать плейсхолдеры:

- **`%(vacancy_name)s`**: Название вакансии.
- **`%(employer_name)s`**: Название работодателя.
- **`%(first_name)s`**: Имя пользователя.
- **`%(last_name)s`**: Фамилия пользователя.
- **`%(email)s`**: Email пользователя.
- **`%(phone)s`**: Телефон пользователя.

Эти плейсхолдеры могут быть использованы в сообщениях для отклика на вакансии, чтобы динамически подставлять соответствующие данные в текст сообщения. Например:

```
"Меня заинтересовала ваша вакансия %(vacancy_name)s. Прошу рассмотреть мою кандидатуру. С уважением, %(first_name)s %(last_name)s."
```

Так же можно делать текст уникальным с помощью `{}`. Внутри них через `|` перечисляются варианты, один из которых будет случайно выбран:

```
{Здоров|Привет}, {как {ты|сам}|что делаешь}?
```

В итоге получится что-то типа:

```
Привет, как ты?
```

### Написание плагинов

Утилита использует систему плагинов. Все они лежат в [operations](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations). Модули расположенные там автоматически добавляются как доступные операции. За основу для своего плагина можно взять [whoami.py](https://github.com/s3rgeym/hh-applicant-tool/tree/main/hh_applicant_tool/operations/whoami.py).

Expand Down
195 changes: 156 additions & 39 deletions hh_applicant_tool/operations/apply_similar.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from ..telemetry_client import TelemetryError
from ..telemetry_client import get_client as get_telemetry_client
from ..types import ApiListResponse, VacancyItem
from ..utils import fix_datetime, print_err, truncate_string
from ..utils import fix_datetime, print_err, truncate_string, random_text

logger = logging.getLogger(__package__)

Expand All @@ -23,8 +23,13 @@ class Namespace(BaseNamespace):
force_message: bool
apply_interval: Tuple[float, float]
page_interval: Tuple[float, float]
message_interval: Tuple[float, float]
order_by: str
search: str
reply_message: str


# gx для открытия (никак не запомню в виме)
# https://api.hh.ru/openapi/redoc
class Operation(BaseOperation):
"""Откликнуться на все подходящие вакансии. По умолчанию применяются значения, которые были отмечены галочками в форме для поиска на сайте"""
Expand All @@ -33,7 +38,7 @@ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument("--resume-id", help="Идентефикатор резюме")
parser.add_argument(
"--message-list",
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(name)s",
help="Путь до файла, где хранятся сообщения для отклика на вакансии. Каждое сообщение — с новой строки. В сообщения можно использовать плейсхолдеры типа %%(vacabcy_name)s",
type=argparse.FileType(),
)
parser.add_argument(
Expand All @@ -44,16 +49,22 @@ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
"--apply-interval",
help="Интервал между отправкой откликов в секундах (X, X-Y)",
help="Интервал перед отправкой откликов в секундах (X, X-Y)",
default="1-5",
type=self._parse_interval,
)
parser.add_argument(
"--page-interval",
help="Интервал между получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
help="Интервал перед получением следующей страницы рекомендованных вакансий в секундах (X, X-Y)",
default="1-3",
type=self._parse_interval,
)
parser.add_argument(
"--message-interval",
help="Интервал перед отправкой сообщения в секундах (X, X-Y)",
default="5-10",
type=self._parse_interval,
)
parser.add_argument(
"--order-by",
help="Сортировка вакансий",
Expand All @@ -68,10 +79,15 @@ def setup_parser(self, parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
"--search",
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зряплату",
help="Строка поиска для фильтрации вакансий, например, 'москва бухгалтер 100500', те можно и город указать, и ожидаемую зарплату",
type=str,
default=None,
)
parser.add_argument(
"--reply-message",
"--reply",
help="Отправить сообщение во все чаты, где ожидают ответа либо не прочитали ответ",
)

@staticmethod
def _parse_interval(interval: str) -> Tuple[float, float]:
Expand All @@ -89,6 +105,7 @@ def run(self, args: Namespace) -> None:

apply_min_interval, apply_max_interval = args.apply_interval
page_min_interval, page_max_interval = args.page_interval
message_min_interval, message_max_interval = args.message_interval

self._apply_similar(
api,
Expand All @@ -99,8 +116,11 @@ def run(self, args: Namespace) -> None:
apply_max_interval,
page_min_interval,
page_max_interval,
message_min_interval,
message_max_interval,
args.order_by,
args.search,
args.reply_message or args.config["reply_message"],
)

def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
Expand All @@ -118,11 +138,8 @@ def _get_application_messages(self, args: Namespace) -> list[str]:
)
else:
application_messages = [
"Меня заинтересовала ваша вакансия %(name)s",
"Прошу рассмотреть мою жалкую кандидатуру на вакансию %(name)s",
"Ваша вакансия %(name)s соответствует моим навыкам и опыту",
"Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
"Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
"{Меня заинтересовала|Мне понравилась} ваша вакансия %(vacancy_name)s",
"{Прошу рассмотреть|Предлагаю рассмотреть} {мою кандидатуру|мое резюме} на вакансию %(vacancy_name)s",
]
return application_messages

Expand All @@ -136,8 +153,11 @@ def _apply_similar(
apply_max_interval: float,
page_min_interval: float,
page_max_interval: float,
message_min_interval: float,
message_max_interval: float,
order_by: str,
search: str | None = None,
reply_message: str | None = None,
) -> None:
telemetry_client = get_telemetry_client()
telemetry_data = defaultdict(dict)
Expand All @@ -154,33 +174,124 @@ def _apply_similar(

self._collect_vacancy_telemetry(telemetry_data, vacancies)

me = api.get("/me")

basic_message_placeholders = {
"first_name": me.get("first_name", ""),
"last_name": me.get("last_name", ""),
"email": me.get("email", ""),
"phone": me.get("phone", ""),
}

do_apply = True

for vacancy in vacancies:
try:
if getenv("TEST_TELEMETRY"):
break

message_placeholders = {
"vacancy_name": vacancy.get("name", ""),
"employer_name": vacancy.get("employer", {}).get(
"name", ""
),
**basic_message_placeholders,
}

logger.debug(
"Вакансия %(vacancy_name)s от %(employer_name)s"
% message_placeholders
)

if vacancy.get("has_test"):
print("🚫 Пропускаем тест", vacancy["alternate_url"])
continue

if vacancy.get("archived"):
print(
"🚫 Пропускаем вакансию в архиве",
vacancy["alternate_url"],
)

continue

relations = vacancy.get("relations", [])

if relations:
if "got_rejection" in relations:
print(
"🚫 Пропускаем отказ на вакансию",
vacancy["alternate_url"],
)
continue

if reply_message:
r = api.get("/negotiations", vacancy_id=vacancy["id"])

if len(r["items"]) == 1:
neg = r["items"][0]
nid = neg["id"]

page: int = 0
last_message: dict | None = None
while True:
r2 = api.get(
f"/negotiations/{nid}/messages", page=page
)
last_message = r2["items"][-1]
if page + 1 >= r2["pages"]:
break

page = r2["pages"] - 1

logger.debug(last_message["text"])

if last_message["author"][
"participant_type"
] == "employer" or not neg.get(
"viewed_by_opponent"
):
message = (
random_text(reply_message)
% message_placeholders
)
logger.debug(message)

time.sleep(
random.uniform(
message_min_interval,
message_max_interval,
)
)
api.post(
f"/negotiations/{nid}/messages",
message=message,
)
print(
"📨 Отправили сообщение для привлечения внимания",
vacancy["alternate_url"],
)
continue
else:
logger.warning(
"Приглашение без чата для вакансии: %s",
vacancy["alternate_url"],
)

print(
"🚫 Пропускаем ответ на заявку",
"🚫 Пропускаем вакансию с откликом",
vacancy["alternate_url"],
)
continue

try:
employer_id = vacancy["employer"]["id"]
except KeyError:
logger.warning(
f"Вакансия без работодателя: {vacancy['alternate_url']}"
)
else:
employer = api.get(f"/employers/{employer_id}")
employer_id = vacancy.get("employer", {}).get("id")

if (
employer_id
and employer_id not in telemetry_data["employers"]
and 200 > len(telemetry_data["employers"])
):
employer = api.get(f"/employers/{employer_id}")
telemetry_data["employers"][employer_id] = {
"name": employer.get("name"),
"type": employer.get("type"),
Expand All @@ -189,31 +300,33 @@ def _apply_similar(
"area": employer.get("area", {}).get("name"), # город
}

# Задержка перед отправкой отклика
interval = random.uniform(
apply_min_interval, apply_max_interval
)
time.sleep(interval)
if not do_apply:
logger.debug("skip apply similar")
continue

params = {
"resume_id": resume_id,
"vacancy_id": vacancy["id"],
"message": "",
}

if vacancy.get("response_letter_required"):
message_template = random.choice(application_messages)

try:
params["message"] = message_template % vacancy
except TypeError as ex:
# TypeError: not enough arguments for format string
# API HH все кривое, иногда нет идентификатора работодателя, иногда у вакансии нет названия.
# И это типа рашн хайлоад, где из-за дрочки на аджайл слепили кривую говнину.
logger.error(
f"Ошибка форматирования шаблона сообщения {template_message!r} для {vacancy = }"
)
continue
if force_message or vacancy.get("response_letter_required"):
msg = params["message"] = (
random_text(random.choice(application_messages))
% message_placeholders
)
logger.debug(msg)

# Задержка перед отправкой отклика
interval = random.uniform(
max(apply_min_interval, message_min_interval)
if params["message"]
else apply_min_interval,
max(apply_max_interval, message_max_interval)
if params["message"]
else apply_max_interval,
)
time.sleep(interval)

res = api.post("/negotiations", params)
assert res == {}
Expand All @@ -225,12 +338,16 @@ def _apply_similar(
")",
)
except ApiError as ex:
print_err("❗ Ошибка:", ex)
logger.error(ex)
if isinstance(ex, BadRequest) and ex.limit_exceeded:
break
if not reply_message:
break
do_apply = False

print("📝 Отклики на вакансии разосланы!")

# Я собираюсь задеанонить всех хрюш яндексов и прочей хуеты, которую
# считаю вселенским злом, так что телеметирию не трогайте
self._send_telemetry(telemetry_client, telemetry_data)

def _get_vacancies(
Expand Down
15 changes: 15 additions & 0 deletions hh_applicant_tool/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import Any
from os import getenv
from .constants import INVALID_ISO8601_FORMAT
import re, random

print_err = partial(print, file=sys.stderr, flush=True)

Expand Down Expand Up @@ -72,3 +73,17 @@ def fix_datetime(dt: str | None) -> str | None:
if dt is None:
return None
return datetime.strptime(dt, INVALID_ISO8601_FORMAT).isoformat()


def random_text(s: str) -> str:
while (
s1 := re.sub(
r"{([^{}]+)}",
lambda m: random.choice(
m.group(1).split("|"),
),
s,
)
) != s:
s = s1
return s

0 comments on commit 101f1bb

Please sign in to comment.