iflow-mcp_gmen1057-headhunter-mcp-server 0.1.0__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_client.py ADDED
@@ -0,0 +1,573 @@
1
+ """HeadHunter API Client.
2
+
3
+ This module provides a client for interacting with the HeadHunter job search API.
4
+ The client supports both public API endpoints (for searching vacancies and
5
+ retrieving public information) and authenticated endpoints (for managing user
6
+ resumes and applications via OAuth).
7
+
8
+ The client handles:
9
+ - Vacancy search with various filters and pagination
10
+ - Detailed vacancy and employer information retrieval
11
+ - Similar vacancies discovery
12
+ - Reference data access (areas, dictionaries)
13
+ - OAuth authentication and token management
14
+ - User operations (applications, resumes) with proper authentication
15
+ - Automatic token refresh for authenticated requests
16
+
17
+ Authentication:
18
+ The client supports two types of authentication:
19
+ 1. App token authentication for public API calls
20
+ 2. OAuth access token authentication for user-specific operations
21
+
22
+ Required environment variables:
23
+ - HH_CLIENT_ID: OAuth client ID (for authentication)
24
+ - HH_CLIENT_SECRET: OAuth client secret (for authentication)
25
+ - HH_APP_TOKEN: Application token for public API calls (optional)
26
+ - HH_REDIRECT_URI: OAuth redirect URI (for authentication)
27
+ - HH_ACCESS_TOKEN: User access token (optional, for authenticated calls)
28
+ - HH_REFRESH_TOKEN: Refresh token (optional, for token renewal)
29
+ """
30
+
31
+ import httpx
32
+ import os
33
+ from typing import Optional, Dict, Any, List
34
+ from datetime import datetime, timedelta
35
+
36
+
37
+ class HHClient:
38
+ """HeadHunter API client for job search and application management.
39
+
40
+ This client provides a comprehensive interface to the HeadHunter API,
41
+ supporting both public operations (vacancy search, employer information)
42
+ and authenticated operations (resume management, job applications).
43
+
44
+ The client automatically handles authentication headers, token management,
45
+ and provides convenient methods for all supported HeadHunter API endpoints.
46
+
47
+ Attributes:
48
+ BASE_URL (str): Base URL for HeadHunter API endpoints
49
+ client_id (str): OAuth client ID from environment variables
50
+ client_secret (str): OAuth client secret from environment variables
51
+ app_token (str): Application token for public API calls
52
+ redirect_uri (str): OAuth redirect URI from environment variables
53
+ access_token (str): Current user access token for authenticated calls
54
+ refresh_token (str): Refresh token for automatic token renewal
55
+ token_expires_at (datetime): Expiration time for the current access token
56
+ """
57
+
58
+ BASE_URL = "https://api.hh.ru"
59
+
60
+ def __init__(self):
61
+ """Initialize the HeadHunter client with configuration from environment variables.
62
+
63
+ Loads OAuth credentials, tokens, and other configuration from environment
64
+ variables. The client can work in two modes:
65
+ 1. Public mode: Using app token for public API calls
66
+ 2. Authenticated mode: Using access token for user-specific operations
67
+
68
+ Environment variables loaded:
69
+ - HH_CLIENT_ID: OAuth client ID
70
+ - HH_CLIENT_SECRET: OAuth client secret
71
+ - HH_APP_TOKEN: Application token for public calls
72
+ - HH_REDIRECT_URI: OAuth redirect URI
73
+ - HH_ACCESS_TOKEN: User access token (optional)
74
+ - HH_REFRESH_TOKEN: Refresh token for token renewal (optional)
75
+ """
76
+ self.client_id = os.getenv("HH_CLIENT_ID")
77
+ self.client_secret = os.getenv("HH_CLIENT_SECRET")
78
+ self.app_token = os.getenv("HH_APP_TOKEN")
79
+ self.redirect_uri = os.getenv("HH_REDIRECT_URI")
80
+ self.access_token: Optional[str] = os.getenv("HH_ACCESS_TOKEN")
81
+ self.refresh_token: Optional[str] = os.getenv("HH_REFRESH_TOKEN")
82
+ self.token_expires_at: Optional[datetime] = None
83
+
84
+ def _get_headers(self, authenticated: bool = False) -> Dict[str, str]:
85
+ """Generate HTTP headers for HeadHunter API requests.
86
+
87
+ Creates appropriate headers including User-Agent and authorization
88
+ based on the authentication mode. Uses either OAuth access token
89
+ for authenticated requests or app token for public requests.
90
+
91
+ Args:
92
+ authenticated (bool): Whether to use authenticated headers with
93
+ access token. If False, uses app token for public API calls.
94
+ Defaults to False.
95
+
96
+ Returns:
97
+ Dict[str, str]: Dictionary containing HTTP headers including:
98
+ - User-Agent: Required by HeadHunter API
99
+ - HH-User-Agent: Application identifier
100
+ - Authorization: Bearer token (access token or app token)
101
+ """
102
+ headers = {
103
+ "User-Agent": "JobHunter/1.0 (jhunterpro.ru)",
104
+ "HH-User-Agent": "JobHunter/1.0 (jhunterpro.ru)",
105
+ }
106
+
107
+ if authenticated and self.access_token:
108
+ headers["Authorization"] = f"Bearer {self.access_token}"
109
+ elif not authenticated and self.app_token:
110
+ headers["Authorization"] = f"Bearer {self.app_token}"
111
+
112
+ return headers
113
+
114
+ async def search_vacancies(
115
+ self,
116
+ text: Optional[str] = None,
117
+ area: Optional[int] = None,
118
+ experience: Optional[str] = None,
119
+ employment: Optional[str] = None,
120
+ schedule: Optional[str] = None,
121
+ salary: Optional[int] = None,
122
+ only_with_salary: bool = False,
123
+ per_page: int = 20,
124
+ page: int = 0,
125
+ ) -> Dict[str, Any]:
126
+ """Search for job vacancies with various filters.
127
+
128
+ Performs a search for job vacancies on HeadHunter with optional filtering
129
+ by location, experience level, employment type, schedule, salary, and more.
130
+ Supports pagination for handling large result sets.
131
+
132
+ This is a public API method that doesn't require authentication.
133
+
134
+ Args:
135
+ text (Optional[str]): Search query text (job title, keywords, skills).
136
+ Can include company names, technologies, etc.
137
+ area (Optional[int]): Region ID for location filtering.
138
+ Use get_areas() to find available region IDs.
139
+ Examples: 1=Moscow, 2=St.Petersburg, 113=Russia.
140
+ experience (Optional[str]): Required experience level.
141
+ Valid values: "noExperience", "between1And3", "between3And6", "moreThan6".
142
+ employment (Optional[str]): Employment type.
143
+ Valid values: "full", "part", "project", "volunteer", "probation".
144
+ schedule (Optional[str]): Work schedule type.
145
+ Valid values: "fullDay", "shift", "flexible", "remote", "flyInFlyOut".
146
+ salary (Optional[int]): Minimum salary filter in rubles.
147
+ only_with_salary (bool): If True, show only vacancies with specified salary.
148
+ Defaults to False.
149
+ per_page (int): Number of results per page (max 100). Defaults to 20.
150
+ page (int): Page number for pagination (0-indexed). Defaults to 0.
151
+
152
+ Returns:
153
+ Dict[str, Any]: Search results containing:
154
+ - found (int): Total number of matching vacancies
155
+ - items (List[Dict]): List of vacancy objects with basic info
156
+ - pages (int): Total number of pages available
157
+ - per_page (int): Results per page
158
+ - page (int): Current page number
159
+
160
+ Raises:
161
+ httpx.HTTPError: If the API request fails or returns an error status.
162
+ """
163
+ params = {
164
+ "text": text,
165
+ "area": area,
166
+ "experience": experience,
167
+ "employment": employment,
168
+ "schedule": schedule,
169
+ "salary": salary,
170
+ "only_with_salary": only_with_salary,
171
+ "per_page": per_page,
172
+ "page": page,
173
+ }
174
+ params = {k: v for k, v in params.items() if v is not None}
175
+
176
+ async with httpx.AsyncClient() as client:
177
+ response = await client.get(
178
+ f"{self.BASE_URL}/vacancies", params=params, headers=self._get_headers()
179
+ )
180
+ response.raise_for_status()
181
+ return response.json()
182
+
183
+ async def get_vacancy(self, vacancy_id: str) -> Dict[str, Any]:
184
+ """Get detailed information about a specific vacancy.
185
+
186
+ Retrieves comprehensive information about a job vacancy including
187
+ description, requirements, salary, employer details, and other metadata.
188
+
189
+ This is a public API method that doesn't require authentication.
190
+
191
+ Args:
192
+ vacancy_id (str): The unique identifier of the vacancy to retrieve.
193
+ Can be obtained from search results.
194
+
195
+ Returns:
196
+ Dict[str, Any]: Detailed vacancy information including:
197
+ - id (str): Vacancy ID
198
+ - name (str): Vacancy title
199
+ - description (str): Full job description (HTML)
200
+ - employer (Dict): Employer/company information
201
+ - salary (Dict): Salary range and currency
202
+ - area (Dict): Location information
203
+ - experience (Dict): Required experience level
204
+ - employment (Dict): Employment type
205
+ - schedule (Dict): Work schedule
206
+ - key_skills (List[Dict]): Required skills
207
+ - alternate_url (str): Web URL for the vacancy
208
+
209
+ Raises:
210
+ httpx.HTTPError: If the vacancy doesn't exist or API request fails.
211
+ """
212
+ async with httpx.AsyncClient() as client:
213
+ response = await client.get(
214
+ f"{self.BASE_URL}/vacancies/{vacancy_id}", headers=self._get_headers()
215
+ )
216
+ response.raise_for_status()
217
+ return response.json()
218
+
219
+ async def get_employer(self, employer_id: str) -> Dict[str, Any]:
220
+ """Get detailed information about an employer/company.
221
+
222
+ Retrieves comprehensive information about a company including
223
+ description, industry, location, website, and other details.
224
+
225
+ This is a public API method that doesn't require authentication.
226
+
227
+ Args:
228
+ employer_id (str): The unique identifier of the employer.
229
+ Can be obtained from vacancy data or search results.
230
+
231
+ Returns:
232
+ Dict[str, Any]: Detailed employer information including:
233
+ - id (str): Employer ID
234
+ - name (str): Company name
235
+ - description (str): Company description
236
+ - site_url (str): Company website URL
237
+ - area (Dict): Company location
238
+ - industries (List[Dict]): Company industries
239
+ - type (str): Company type (agency, employer, etc.)
240
+ - logo_urls (Dict): Company logo URLs in different sizes
241
+ - alternate_url (str): HeadHunter page URL for the employer
242
+
243
+ Raises:
244
+ httpx.HTTPError: If the employer doesn't exist or API request fails.
245
+ """
246
+ async with httpx.AsyncClient() as client:
247
+ response = await client.get(
248
+ f"{self.BASE_URL}/employers/{employer_id}", headers=self._get_headers()
249
+ )
250
+ response.raise_for_status()
251
+ return response.json()
252
+
253
+ async def get_similar_vacancies(self, vacancy_id: str) -> Dict[str, Any]:
254
+ """Get similar vacancies for a specific vacancy.
255
+
256
+ Retrieves a list of job vacancies that are similar to the specified
257
+ vacancy based on HeadHunter's recommendation algorithm.
258
+
259
+ This is a public API method that doesn't require authentication.
260
+
261
+ Args:
262
+ vacancy_id (str): The unique identifier of the reference vacancy
263
+ for which to find similar positions.
264
+
265
+ Returns:
266
+ Dict[str, Any]: Similar vacancies data containing:
267
+ - items (List[Dict]): List of similar vacancy objects with:
268
+ - id (str): Vacancy ID
269
+ - name (str): Vacancy title
270
+ - employer (Dict): Basic employer information
271
+ - area (Dict): Location information
272
+ - alternate_url (str): Web URL for the vacancy
273
+
274
+ Raises:
275
+ httpx.HTTPError: If the vacancy doesn't exist or API request fails.
276
+ """
277
+ async with httpx.AsyncClient() as client:
278
+ response = await client.get(
279
+ f"{self.BASE_URL}/vacancies/{vacancy_id}/similar_vacancies",
280
+ headers=self._get_headers(),
281
+ )
282
+ response.raise_for_status()
283
+ return response.json()
284
+
285
+ async def get_areas(self) -> List[Dict[str, Any]]:
286
+ """Get list of all available regions/areas for filtering.
287
+
288
+ Retrieves the hierarchical list of all geographical areas available
289
+ on HeadHunter. This includes countries, regions, cities, and districts
290
+ with their unique IDs that can be used for vacancy filtering.
291
+
292
+ This is a public API method that doesn't require authentication.
293
+
294
+ Returns:
295
+ List[Dict[str, Any]]: List of area objects in hierarchical structure:
296
+ - id (str): Area ID for use in search filters
297
+ - name (str): Area name (e.g., "Moscow", "Russia")
298
+ - areas (List[Dict]): Sub-areas (cities within regions, etc.)
299
+
300
+ Common area IDs:
301
+ - 1: Moscow
302
+ - 2: St. Petersburg
303
+ - 113: Russia (all regions)
304
+
305
+ Raises:
306
+ httpx.HTTPError: If the API request fails.
307
+ """
308
+ async with httpx.AsyncClient() as client:
309
+ response = await client.get(
310
+ f"{self.BASE_URL}/areas", headers=self._get_headers()
311
+ )
312
+ response.raise_for_status()
313
+ return response.json()
314
+
315
+ async def get_dictionaries(self) -> Dict[str, Any]:
316
+ """Get all filter dictionaries from HeadHunter.
317
+
318
+ Retrieves all reference dictionaries used for filtering vacancies,
319
+ including experience levels, employment types, work schedules,
320
+ industries, and other categorization data.
321
+
322
+ This is a public API method that doesn't require authentication.
323
+
324
+ Returns:
325
+ Dict[str, Any]: Dictionary containing all filter categories:
326
+ - experience (List[Dict]): Experience level options
327
+ ("noExperience", "between1And3", etc.)
328
+ - employment (List[Dict]): Employment type options
329
+ ("full", "part", "project", etc.)
330
+ - schedule (List[Dict]): Work schedule options
331
+ ("fullDay", "remote", "flexible", etc.)
332
+ - industries (List[Dict]): Industry categories
333
+ - currency (List[Dict]): Available currencies
334
+ - education_level (List[Dict]): Education requirements
335
+ - language (List[Dict]): Language requirements
336
+ - And other categorical data used in vacancy filtering
337
+
338
+ Raises:
339
+ httpx.HTTPError: If the API request fails.
340
+ """
341
+ async with httpx.AsyncClient() as client:
342
+ response = await client.get(
343
+ f"{self.BASE_URL}/dictionaries", headers=self._get_headers()
344
+ )
345
+ response.raise_for_status()
346
+ return response.json()
347
+
348
+ def set_tokens(self, access_token: str, refresh_token: str, expires_in: int):
349
+ """Set OAuth tokens and calculate expiration time.
350
+
351
+ Updates the client's authentication tokens and calculates the
352
+ expiration time for the access token. This method is typically
353
+ called after successful OAuth authentication or token refresh.
354
+
355
+ Args:
356
+ access_token (str): OAuth access token for authenticated API calls.
357
+ refresh_token (str): Refresh token for renewing expired access tokens.
358
+ expires_in (int): Token lifetime in seconds from now.
359
+ """
360
+ self.access_token = access_token
361
+ self.refresh_token = refresh_token
362
+ self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
363
+
364
+ async def refresh_access_token(self) -> bool:
365
+ """Refresh the access token using the refresh token.
366
+
367
+ Attempts to refresh the OAuth access token using the stored refresh token.
368
+ If successful, updates the client's tokens and expiration time.
369
+
370
+ This method is useful for maintaining authentication when the access
371
+ token expires, avoiding the need for full re-authentication.
372
+
373
+ Returns:
374
+ bool: True if token refresh was successful and new tokens were set,
375
+ False if refresh failed (no refresh token or API error).
376
+
377
+ Note:
378
+ Requires a valid refresh token to be set in the client.
379
+ If refresh fails, the user will need to re-authenticate via OAuth.
380
+ """
381
+ if not self.refresh_token:
382
+ return False
383
+
384
+ async with httpx.AsyncClient() as client:
385
+ response = await client.post(
386
+ "https://hh.ru/oauth/token",
387
+ data={
388
+ "grant_type": "refresh_token",
389
+ "refresh_token": self.refresh_token,
390
+ },
391
+ headers=self._get_headers(),
392
+ )
393
+
394
+ if response.status_code == 200:
395
+ data = response.json()
396
+ self.set_tokens(
397
+ data["access_token"], data["refresh_token"], data["expires_in"]
398
+ )
399
+ return True
400
+ return False
401
+
402
+ async def apply_to_vacancy(
403
+ self, vacancy_id: str, resume_id: str, letter: Optional[str] = None
404
+ ) -> Dict[str, Any]:
405
+ """Submit a job application to a specific vacancy.
406
+
407
+ Submits an application for a job vacancy using one of the user's
408
+ resumes. Optionally includes a cover letter with the application.
409
+
410
+ This method requires OAuth authentication. The user must have
411
+ authorized the application and have a valid access token.
412
+
413
+ Args:
414
+ vacancy_id (str): The unique identifier of the vacancy to apply to.
415
+ Must be a valid, active vacancy ID.
416
+ resume_id (str): The ID of the user's resume to use for the application.
417
+ Must be one of the user's own resumes.
418
+ letter (Optional[str]): Cover letter text to include with the
419
+ application. Optional but recommended for better results.
420
+
421
+ Returns:
422
+ Dict[str, Any]: Application response containing:
423
+ - id (str): Application/negotiation ID
424
+ - created_at (str): Application submission timestamp
425
+ - updated_at (str): Last update timestamp
426
+ - state (Dict): Application status information
427
+ - vacancy (Dict): Applied vacancy basic information
428
+
429
+ Raises:
430
+ Exception: If no access token is available (authentication required).
431
+ httpx.HTTPError: If the application fails (vacancy not found,
432
+ resume not accessible, already applied, etc.).
433
+ """
434
+ if not self.access_token:
435
+ raise Exception("Authentication required. User must authorize via OAuth.")
436
+
437
+ data = {"vacancy_id": vacancy_id, "resume_id": resume_id}
438
+ if letter:
439
+ data["letter"] = letter
440
+
441
+ async with httpx.AsyncClient() as client:
442
+ response = await client.post(
443
+ f"{self.BASE_URL}/negotiations",
444
+ json=data,
445
+ headers=self._get_headers(authenticated=True),
446
+ )
447
+ response.raise_for_status()
448
+ return response.json()
449
+
450
+ async def get_negotiations(
451
+ self, per_page: int = 20, page: int = 0
452
+ ) -> Dict[str, Any]:
453
+ """Get user's application history and status.
454
+
455
+ Retrieves a paginated list of all job applications (negotiations)
456
+ submitted by the authenticated user, including their current status
457
+ and related vacancy information.
458
+
459
+ This method requires OAuth authentication. The user must have
460
+ authorized the application and have a valid access token.
461
+
462
+ Args:
463
+ per_page (int): Number of applications to return per page (max 100).
464
+ Defaults to 20.
465
+ page (int): Page number for pagination (0-indexed). Defaults to 0.
466
+
467
+ Returns:
468
+ Dict[str, Any]: Applications data containing:
469
+ - found (int): Total number of user's applications
470
+ - items (List[Dict]): List of application objects with:
471
+ - id (str): Application ID
472
+ - created_at (str): Application submission date
473
+ - state (Dict): Current application status
474
+ - vacancy (Dict): Applied vacancy information
475
+ - pages (int): Total number of pages available
476
+ - per_page (int): Results per page
477
+ - page (int): Current page number
478
+
479
+ Raises:
480
+ Exception: If no access token is available (authentication required).
481
+ httpx.HTTPError: If the API request fails.
482
+ """
483
+ if not self.access_token:
484
+ raise Exception("Authentication required.")
485
+
486
+ params = {"per_page": per_page, "page": page}
487
+
488
+ async with httpx.AsyncClient() as client:
489
+ response = await client.get(
490
+ f"{self.BASE_URL}/negotiations",
491
+ params=params,
492
+ headers=self._get_headers(authenticated=True),
493
+ )
494
+ response.raise_for_status()
495
+ return response.json()
496
+
497
+ async def get_resumes(self) -> Dict[str, Any]:
498
+ """Get list of user's resumes.
499
+
500
+ Retrieves all resumes belonging to the authenticated user,
501
+ including their status, title, publication status, and basic metadata.
502
+
503
+ This method requires OAuth authentication. The user must have
504
+ authorized the application and have a valid access token.
505
+
506
+ Returns:
507
+ Dict[str, Any]: User's resumes data containing:
508
+ - items (List[Dict]): List of resume objects with:
509
+ - id (str): Resume ID
510
+ - title (str): Resume title
511
+ - status (Dict): Publication status information
512
+ - updated_at (str): Last update timestamp
513
+ - created_at (str): Creation timestamp
514
+ - views_count (int): Number of views by employers
515
+ - url (str): Direct resume URL
516
+
517
+ Raises:
518
+ Exception: If no access token is available (authentication required).
519
+ httpx.HTTPError: If the API request fails.
520
+ """
521
+ if not self.access_token:
522
+ raise Exception("Authentication required.")
523
+
524
+ async with httpx.AsyncClient() as client:
525
+ response = await client.get(
526
+ f"{self.BASE_URL}/resumes/mine",
527
+ headers=self._get_headers(authenticated=True),
528
+ )
529
+ response.raise_for_status()
530
+ return response.json()
531
+
532
+ async def get_resume(self, resume_id: str) -> Dict[str, Any]:
533
+ """Get detailed information about a specific resume.
534
+
535
+ Retrieves comprehensive information about a user's resume including
536
+ personal information, experience, education, skills, and other details.
537
+
538
+ This method requires OAuth authentication. The user must have
539
+ authorized the application and have a valid access token.
540
+
541
+ Args:
542
+ resume_id (str): The unique identifier of the resume to retrieve.
543
+ Must be one of the user's own resumes.
544
+
545
+ Returns:
546
+ Dict[str, Any]: Detailed resume information including:
547
+ - id (str): Resume ID
548
+ - title (str): Resume title
549
+ - status (Dict): Publication status information
550
+ - experience (List[Dict]): Work experience entries
551
+ - education (List[Dict]): Education entries
552
+ - skills (str): Skills description
553
+ - contact (List[Dict]): Contact information
554
+ - personal (Dict): Personal information
555
+ - updated_at (str): Last update timestamp
556
+ - created_at (str): Creation timestamp
557
+ - views_count (int): Number of views by employers
558
+
559
+ Raises:
560
+ Exception: If no access token is available (authentication required).
561
+ httpx.HTTPError: If the resume doesn't exist, is not accessible,
562
+ or API request fails.
563
+ """
564
+ if not self.access_token:
565
+ raise Exception("Authentication required.")
566
+
567
+ async with httpx.AsyncClient() as client:
568
+ response = await client.get(
569
+ f"{self.BASE_URL}/resumes/{resume_id}",
570
+ headers=self._get_headers(authenticated=True),
571
+ )
572
+ response.raise_for_status()
573
+ return response.json()