hh-applicant-tool 0.3.2__py3-none-any.whl → 0.3.4__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.

Potentially problematic release.


This version of hh-applicant-tool might be problematic. Click here for more details.

@@ -9,12 +9,10 @@ from typing import TextIO, Tuple
9
9
  from ..api import ApiClient, ApiError, BadRequest
10
10
  from ..main import BaseOperation
11
11
  from ..main import Namespace as BaseNamespace
12
+ from ..telemetry_client import TelemetryError
13
+ from ..telemetry_client import get_client as get_telemetry_client
12
14
  from ..types import ApiListResponse, VacancyItem
13
- from ..utils import print_err, truncate_string, fix_datetime
14
- from ..telemetry_client import (
15
- get_client as get_telemetry_client,
16
- TelemetryError,
17
- )
15
+ from ..utils import fix_datetime, print_err, truncate_string
18
16
 
19
17
  logger = logging.getLogger(__package__)
20
18
 
@@ -71,11 +69,32 @@ class Operation(BaseOperation):
71
69
  access_token=args.config["token"]["access_token"],
72
70
  user_agent=args.config["user_agent"],
73
71
  )
72
+ resume_id = self._get_resume_id(args, api)
73
+ application_messages = self._get_application_messages(args)
74
+
75
+ apply_min_interval, apply_max_interval = args.apply_interval
76
+ page_min_interval, page_max_interval = args.page_interval
77
+
78
+ self._apply_similar(
79
+ api,
80
+ resume_id,
81
+ args.force_message,
82
+ application_messages,
83
+ apply_min_interval,
84
+ apply_max_interval,
85
+ page_min_interval,
86
+ page_max_interval,
87
+ )
88
+
89
+ def _get_resume_id(self, args: Namespace, api: ApiClient) -> str:
74
90
  if not (
75
91
  resume_id := args.resume_id or args.config["default_resume_id"]
76
92
  ):
77
93
  resumes: ApiListResponse = api.get("/resumes/mine")
78
94
  resume_id = resumes["items"][0]["id"]
95
+ return resume_id
96
+
97
+ def _get_application_messages(self, args: Namespace) -> list[str]:
79
98
  if args.message_list:
80
99
  application_messages = list(
81
100
  filter(None, map(str.strip, args.message_list))
@@ -88,51 +107,7 @@ class Operation(BaseOperation):
88
107
  "Хочу присоединиться к вашей успешной команде лидеров рынка в качестве %(name)s",
89
108
  "Мое резюме содержит все баззворды, указанные в вашей вакансии %(name)s",
90
109
  ]
91
-
92
- apply_min_interval, apply_max_interval = args.apply_interval
93
- page_min_interval, page_max_interval = args.page_interval
94
-
95
- self._apply_similar(
96
- api,
97
- resume_id,
98
- args.force_message,
99
- application_messages,
100
- apply_min_interval,
101
- apply_max_interval,
102
- page_min_interval,
103
- page_max_interval,
104
- )
105
-
106
- def _get_vacancies(
107
- self,
108
- api: ApiClient,
109
- resume_id: str,
110
- page_min_interval: float,
111
- page_max_interval: float,
112
- ) -> list[VacancyItem]:
113
- rv = []
114
- per_page = 100
115
- for page in range(20):
116
- res: ApiListResponse = api.get(
117
- f"/resumes/{resume_id}/similar_vacancies",
118
- page=page,
119
- per_page=per_page,
120
- order_by="relevance",
121
- )
122
- rv.extend(res["items"])
123
-
124
- if getenv("TEST_TELEMETRY"):
125
- break
126
-
127
- if page >= res["pages"] - 1:
128
- break
129
-
130
- # Задержка перед получением следующей страницы
131
- if page > 0:
132
- interval = random.uniform(page_min_interval, page_max_interval)
133
- time.sleep(interval)
134
-
135
- return rv
110
+ return application_messages
136
111
 
137
112
  def _apply_similar(
138
113
  self,
@@ -145,74 +120,50 @@ class Operation(BaseOperation):
145
120
  page_min_interval: float,
146
121
  page_max_interval: float,
147
122
  ) -> None:
148
- item: VacancyItem
149
-
150
- # Телеметрия не включает ваши персональные данные, она нужна для сбора информации о работодателях и их вакансиях
151
123
  telemetry_client = get_telemetry_client()
152
124
  telemetry_data = defaultdict(dict)
153
125
 
154
- for item in self._get_vacancies(
155
- api, resume_id, page_min_interval, page_max_interval
156
- ):
157
- try:
158
- # Информация о вакансии
159
- vacancy_id = item["id"]
160
-
161
- telemetry_data["vacancies"][vacancy_id] = {
162
- "name": item.get("name"),
163
- "type": item.get("type", {}).get("id"), # open/closed
164
- "area": item.get("area", {}).get("name"), # город
165
- "salary": item.get("salary"), # from, to, currency, gross
166
- "direct_url": item.get(
167
- "alternate_url"
168
- ), # ссылка на вакансию
169
- "created_at": fix_datetime(
170
- item.get("created_at")
171
- ), # будем вычислять говно-вакансии, которые по полгода висят
172
- "published_at": fix_datetime(item.get("published_at")),
173
- "contacts": item.get(
174
- "contacts"
175
- ), # пиздорванки там телеграм для связи указывают
176
- # Остальное неинтересно
177
- }
178
-
179
- employer_id = item["employer"][
180
- "id"
181
- ] # меня интересуют только название и ссылка на сайт
182
-
183
- # так еще эмулируем какое-то иное действие нежели набор однотипных
184
- employer = api.get(f"/employers/{employer_id}")
126
+ vacancies = self._get_vacancies(
127
+ api, resume_id, page_min_interval, page_max_interval, per_page=100
128
+ )
185
129
 
186
- telemetry_data["employers"][employer_id] = {
187
- "name": employer.get("name"),
188
- "type": employer.get("type"),
189
- "description": employer.get("description"),
190
- "site_url": employer.get("site_url"),
191
- "area": employer.get("area", {}).get("name"), # город
192
- }
130
+ self._collect_vacancy_telemetry(telemetry_data, vacancies)
193
131
 
132
+ for vacancy in vacancies:
133
+ try:
194
134
  if getenv("TEST_TELEMETRY"):
195
135
  break
196
136
 
197
- if item["has_test"]:
198
- print("Пропускаем тест", item["alternate_url"])
137
+ if vacancy.get("has_test"):
138
+ print("🚫 Пропускаем тест", vacancy["alternate_url"])
199
139
  continue
200
140
 
201
- relations = item.get("relations", [])
202
-
203
- # Там черезжопно нужно хеш отклика получать чтобы его отменить
204
- # if "got_response" in relations:
205
- # # Тупая пизда ее даже не рассматривала
206
- # print(
207
- # "Отменяем заявку чтобы отправить ее снова",
208
- # item["alternate_url"],
209
- # )
210
- # api.delete(f"/negotiations/active/{item['id']}")
211
- # elif relations:
141
+ relations = vacancy.get("relations", [])
142
+
212
143
  if relations:
213
- print("Пропускаем ответ на заявку", item["alternate_url"])
144
+ print(
145
+ "🚫 Пропускаем ответ на заявку",
146
+ vacancy["alternate_url"],
147
+ )
214
148
  continue
215
149
 
150
+ try:
151
+ employer_id = vacancy["employer"]["id"]
152
+ except IndexError:
153
+ logger.warning(
154
+ f"Вакансия без работодателя: {vacancy['alternate_url']}"
155
+ )
156
+ else:
157
+ employer = api.get(f"/employers/{employer_id}")
158
+
159
+ telemetry_data["employers"][employer_id] = {
160
+ "name": employer.get("name"),
161
+ "type": employer.get("type"),
162
+ "description": employer.get("description"),
163
+ "site_url": employer.get("site_url"),
164
+ "area": employer.get("area", {}).get("name"), # город
165
+ }
166
+
216
167
  # Задержка перед отправкой отклика
217
168
  interval = random.uniform(
218
169
  apply_min_interval, apply_max_interval
@@ -221,10 +172,10 @@ class Operation(BaseOperation):
221
172
 
222
173
  params = {
223
174
  "resume_id": resume_id,
224
- "vacancy_id": item["id"],
175
+ "vacancy_id": vacancy["id"],
225
176
  "message": (
226
- random.choice(application_messages) % item
227
- if force_message or item["response_letter_required"]
177
+ random.choice(application_messages) % vacancy
178
+ if force_message or vacancy["response_letter_required"]
228
179
  else ""
229
180
  ),
230
181
  }
@@ -233,9 +184,9 @@ class Operation(BaseOperation):
233
184
  assert res == {}
234
185
  print(
235
186
  "📨 Отправили отклик",
236
- item["alternate_url"],
187
+ vacancy["alternate_url"],
237
188
  "(",
238
- truncate_string(item["name"]),
189
+ truncate_string(vacancy["name"]),
239
190
  ")",
240
191
  )
241
192
  except ApiError as ex:
@@ -245,8 +196,74 @@ class Operation(BaseOperation):
245
196
 
246
197
  print("📝 Отклики на вакансии разосланы!")
247
198
 
248
- # Отправляем telemetry_data
199
+ self._send_telemetry(telemetry_client, telemetry_data)
200
+
201
+ def _get_vacancies(
202
+ self,
203
+ api: ApiClient,
204
+ resume_id: str,
205
+ page_min_interval: float,
206
+ page_max_interval: float,
207
+ per_page: int,
208
+ ) -> list[VacancyItem]:
209
+ rv = []
210
+ for page in range(20):
211
+ res: ApiListResponse = api.get(
212
+ f"/resumes/{resume_id}/similar_vacancies",
213
+ page=page,
214
+ per_page=per_page,
215
+ order_by="relevance",
216
+ )
217
+ rv.extend(res["items"])
218
+
219
+ if getenv("TEST_TELEMETRY"):
220
+ break
221
+
222
+ if page >= res["pages"] - 1:
223
+ break
224
+
225
+ # Задержка перед получением следующей страницы
226
+ if page > 0:
227
+ interval = random.uniform(page_min_interval, page_max_interval)
228
+ time.sleep(interval)
229
+
230
+ return rv
231
+
232
+ def _collect_vacancy_telemetry(
233
+ self, telemetry_data: defaultdict, vacancies: list[VacancyItem]
234
+ ) -> None:
235
+ for vacancy in vacancies:
236
+ vacancy_id = vacancy["id"]
237
+ telemetry_data["vacancies"][vacancy_id] = {
238
+ "name": vacancy.get("name"),
239
+ "type": vacancy.get("type", {}).get("id"), # open/closed
240
+ "area": vacancy.get("area", {}).get("name"), # город
241
+ "salary": vacancy.get("salary"), # from, to, currency, gross
242
+ "direct_url": vacancy.get(
243
+ "alternate_url"
244
+ ), # ссылка на вакансию
245
+ "created_at": fix_datetime(
246
+ vacancy.get("created_at")
247
+ ), # будем вычислять говно-вакансии, которые по полгода висят
248
+ "published_at": fix_datetime(vacancy.get("published_at")),
249
+ "contacts": vacancy.get(
250
+ "contacts"
251
+ ), # пиздорванки там телеграм для связи указывают
252
+ # HH с точки зрения перфикциониста — кусок говна, где кривые
253
+ # форматы даты, у вакансий может не быть работодателя...
254
+ "employer_id": int(vacancy["employer"]["id"])
255
+ if "employer" in vacancy and "id" in vacancy["employer"]
256
+ else None,
257
+ # Остальное неинтересно
258
+ }
259
+
260
+ def _send_telemetry(
261
+ self, telemetry_client, telemetry_data: defaultdict
262
+ ) -> None:
249
263
  try:
250
- telemetry_client.send_telemetry("/collect", dict(telemetry_data))
251
- except TelemetryError as err:
252
- logger.error("Не могу отправить телеметрию")
264
+ res = telemetry_client.send_telemetry(
265
+ "/collect", dict(telemetry_data)
266
+ )
267
+ logger.debug(res)
268
+ except TelemetryError as ex:
269
+ logger.error(ex)
@@ -19,7 +19,9 @@ class TelemetryError(Exception):
19
19
  class TelemetryClient:
20
20
  """Клиент для отправки телеметрии на сервер."""
21
21
 
22
- server_address = base64.b64decode('aHR0cDovLzMxLjEzMS4yNTEuMTA3OjU0MTU2').decode()
22
+ server_address = base64.b64decode(
23
+ "aHR0cDovLzMxLjEzMS4yNTEuMTA3OjU0MTU2"
24
+ ).decode()
23
25
 
24
26
  def __init__(
25
27
  self,
@@ -53,8 +55,12 @@ class TelemetryClient:
53
55
 
54
56
  try:
55
57
  response = self.session.post(url, json=data)
56
- response.raise_for_status()
57
- return response.json()
58
+ # response.raise_for_status()
59
+ result = response.json()
60
+ if "error" in result:
61
+ raise TelemetryError(result)
62
+ return result
63
+
58
64
  except (
59
65
  requests.exceptions.RequestException,
60
66
  json.JSONDecodeError,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: hh-applicant-tool
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary:
5
5
  Author: Senior YAML Developer
6
6
  Author-email: yamldeveloper@proton.me
@@ -37,13 +37,13 @@ Description-Content-Type: text/markdown
37
37
  Работает с Python >= 3.10. Нужную версию Python можно поставить через
38
38
  asdf/pyenv/conda и что-то еще...
39
39
 
40
+ Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, и в Windows, но с WSL не будет, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
41
+
40
42
  Пример работы:
41
43
 
42
44
  ![image](https://github.com/user-attachments/assets/55ab24ba-5325-40b4-9bd9-69ebcbc011c4)
43
45
 
44
46
 
45
- Данная утилита написана для Linux, но будет работать и на Ga..Mac OS, в Windows, но в WSL она не работает, так как для авторизации требуются оконный сервер X11 либо Wayland — только прямая установка пакета через pip в Windows. После авторизации вы можете перенести `~/.config/hh-applicant-tool/config.json` (я не знаю, где винда что хранит) на сервер и запускать утилиту через systemd или cron. Столь странный процесс связан с тем, что на странице авторизации запускается море скриптов, которые шифруют данные на клиенте перед отправкой на сервер, а так же выполняется куча запросов чтобы проверить не бот ли ты. Хорошо, что после авторизации никаких проверок по факту нет, даже айпи не проверяется на соответсвие тому с какого была авторизация. В этой лапше мне лень разбираться. Так же при наличии рутованного телефона можно вытащить `access` и `refresh` токены из официального приложения и добавить их в конфиг.
46
-
47
47
  Предыстория.
48
48
 
49
49
  Был один знакомый знакомого, который работал хрюшей. Этот чувак не заморачивался с чтением резюме, а тупо скриптами рассылал предложения о работе... Бывают, конечно, филологини, которые не могут отличить Java от JavaScript, но я думаю, что <s>в значительном числе случаев, тут имеют место такие вот рассылки</s> они просто идиотки... И я тупо стал спамить как они. Мне уже было просто лень читать весь этот бред, что пишут долбоебы в описании вакансий. Там стандартное ООП, алгоритмы и прочая хуета... Вроде все подходят, а вроде хз — все не мое.
@@ -60,10 +60,14 @@ $$('[data-qa="vacancy-serp__vacancy_response"]').forEach((el) => el.click());
60
60
 
61
61
  ```bash
62
62
  # Версия с поддержкой авторизации через запуск окна с браузером (эта версия очень много весит)
63
+ # Можно использовать обычный pip
63
64
  $ pipx install 'hh-applicant-tool[qt]'
64
65
 
65
66
  # Если хочется использовать самую последнюю версию, то можно установить ее через git
66
67
  $ pipx install git+https://github.com/s3rgeym/hh-applicant-tool
68
+
69
+ # Для обновления до новой версии
70
+ $ pipx upgrade 'hh-applicant-tool'
67
71
  ```
68
72
 
69
73
  Использование:
@@ -142,7 +146,7 @@ $ hh-applicant-tool -vv authorize
142
146
 
143
147
  ![image](https://github.com/user-attachments/assets/88961e31-4ea3-478f-8c43-914d6785bc3b)
144
148
 
145
- В случае успешной авторизации токены будут сохранены `~/.config/hh-applicant-tool/config.json`:
149
+ В случае успешной авторизации токены будут сохранены в `~/.config/hh-applicant-tool/config.json`:
146
150
 
147
151
  ```json
148
152
  {
@@ -231,8 +235,6 @@ rm -f ~/.local/share/applications/hhandroid.desktop
231
235
 
232
236
  Утилита собирает и передает на сервер разработчика следующую ифнормацию:
233
237
 
234
- Утилита собирает следующую информацию:
235
-
236
238
  1. Название вакансии.
237
239
  1. Тип вакансии (открытая/закрытая).
238
240
  1. Город, в котором размещена вакансия.
@@ -247,6 +249,8 @@ rm -f ~/.local/share/applications/hhandroid.desktop
247
249
  1. Ссылка на сайт компании.
248
250
  1. Город, в котором находится компания.
249
251
 
250
- !!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ — ТОЛЬКО ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ
252
+ [Исходники сервера](https://gist.github.com/s3rgeym/b9fb04ef529a511326413c1090597ac5)
253
+
254
+ !!! УТИЛИТА НЕ СОБИРАЕТ НИКАКИХ ПЕРСОНАЛЬНЫХ ДАННЫХ ПОЛЬЗОВАТЕЛЕЙ (IP ТОЖЕ НЕ СОХРАНЯЕТ) — ТОЛЬКО ДАННЫЕ ВСЯКИХ РАБОТАДАТЕЛЕЙ И ИХ ОВЧАРОК. ТАК ЖЕ Я ОБЕЩАЮ, ЧТО УТИЛИТА ВСЕГДА БУДЕТ БЕСПЛАТНОЙ, ВСЕ КТО ЕЮ ПЫТАЮТСЯ ТОРГОВАТЬ — УЕБКИ И У НИХ ПОЧЕРНЕЕТ И ОТВАЛИТСЯ ХУЙ. ЕДИНСТВЕННАЯ ПЛАТА ЗА ЕЕ ИСПОЛЬЗОВАНИЕ — ЭТО ПОМОЩЬ В ПАРСИНГЕ САЙТА HEADHUNTER (МЕНЯ ИНТЕРЕСУЕТ ЕГО БАЗА КОМПАНИЙ)
251
255
 
252
256
 
@@ -7,7 +7,7 @@ hh_applicant_tool/color_log.py,sha256=gN6j1Ayy1G7qOMI_e3WvfYw_ublzeQbKgsVLhqGg_3
7
7
  hh_applicant_tool/constants.py,sha256=YdNz0CF4swJ9OO5vVpOZ39RP-05xSU0Ghw4Y6BhISoE,468
8
8
  hh_applicant_tool/main.py,sha256=XLKsd8mpCKuFoP3ESMpgJKdGL3m6tOQ8kKrOzCN-dwA,3075
9
9
  hh_applicant_tool/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- hh_applicant_tool/operations/apply_similar.py,sha256=HEeNKELm1mK5oNwRxFTUByrK2-IMjmgsjPXSvzcVt-I,10504
10
+ hh_applicant_tool/operations/apply_similar.py,sha256=V8oLm200m_QCtnjgJ0gY86hBKwec1GDUP8R0Cm5yv_U,10838
11
11
  hh_applicant_tool/operations/authorize.py,sha256=TyUTCSOGwSYVJMEd5vSI981LRRI-RZf8hnlVYhtRVwA,3184
12
12
  hh_applicant_tool/operations/call_api.py,sha256=oWAfvy4LwbsQ8HsgI_3en3sMTlu3ZWU7NzpxssrUNSU,1472
13
13
  hh_applicant_tool/operations/clear_negotiations.py,sha256=5ybdJMUfV9XYxnn2y4zvWdS_ZE8yXAbFJoXOMyqetw8,4041
@@ -15,10 +15,10 @@ hh_applicant_tool/operations/list_resumes.py,sha256=HYxjDALrWl_YBIitDldhhEs-WO_0
15
15
  hh_applicant_tool/operations/refresh_token.py,sha256=hYTmzBzJFSsb-LDO2_w0Y30WVWsctIH7vTzirkLwzWo,1267
16
16
  hh_applicant_tool/operations/update_resumes.py,sha256=FKtwEL7i0vaOxLEn1nARi72SuMYB5VjN6Fihe6rlt-Y,1196
17
17
  hh_applicant_tool/operations/whoami.py,sha256=kdLQ_FjYzpJPKxFlocxf7vgXhW1zocb0bd5IAWmsQuA,861
18
- hh_applicant_tool/telemetry_client.py,sha256=0AdLzE37_1gbFsXjvBSm6PD5vfsdInEiCTpLMp3afsk,2172
18
+ hh_applicant_tool/telemetry_client.py,sha256=TlsNKlclPyJqLPO0xHkHKBIhT8bmgx1ZBup4PjE8w5E,2296
19
19
  hh_applicant_tool/types.py,sha256=q3yaIcq-UOkPzjxws0OFa4w9fTty-yx79_dic70_dUM,843
20
20
  hh_applicant_tool/utils.py,sha256=wXcxs5IinK7PH6ipCCpngw7faUiboEpi9a8j1wDEPKw,2282
21
- hh_applicant_tool-0.3.2.dist-info/METADATA,sha256=6xG0Rkcb6sAgziizY146FNdiAXlyYP9smfAoqHTaXWw,15451
22
- hh_applicant_tool-0.3.2.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
23
- hh_applicant_tool-0.3.2.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
24
- hh_applicant_tool-0.3.2.dist-info/RECORD,,
21
+ hh_applicant_tool-0.3.4.dist-info/METADATA,sha256=0z_iJf0g4i9SGWTLmNd9zlOEGrWV5JonQwoyviXhykM,15881
22
+ hh_applicant_tool-0.3.4.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
23
+ hh_applicant_tool-0.3.4.dist-info/entry_points.txt,sha256=Vb7M2YaYLMtKYJZh8chIrXZApMzSRFT1-rQw-U9r10g,65
24
+ hh_applicant_tool-0.3.4.dist-info/RECORD,,