Парсинг вакансий и резюме с HH

В статье я рассмотрю легальный способ получения данных по вакансиям и резюме с Head Hanter.

Полученные данные НЕ будут содержать ФИО и контактных данных -- для их получения вы можете воспользоваться платными сервисами самого HH. На выходе мы получим ровно те данные, которые даёт официальный сайт, только автоматическим способом.

Наша цель - создать систему, которая будет регулярно отслеживать сайт hh.ru и присылать уведомления в Telegram. Причем, мы решим сразу две задачи:

- Для того, кто ищет работника: Мониторить появление новых резюме по ключевому запросу (например, «Python разработчик»).

- Для того, кто ищет работу: Мониторить появление новых вакансий по нескольким IT-специальностям (Python, Java, Frontend).

В этой статье мы будем использовать только бесплатные и легальные способы получения информации.

Для парсинга Вакансий мы будем использовать официальное, документированное API hh.ru.

Для парсинга Резюме используем парсинг с помощью requests и BeautifulSoup.

Для автоматизации ежедневного выполнения кода будет использован сервис Cron Jobs от облака для простого хостинга Amvera, в котором мы запустим наш скрипт всего одной командой в IDE "git push amvera master".

Парсинг вакансий c hh

Наш скрипт будет содержать основной python файл, файл с библиотеками requirements.txt и конфигурацию.

Полный код вы можете найти по ссылке в GitHub.

Код содержит комментарии, приведу только отрывки по получению данных и их фильтрацией.

Нам потребуются переменные

API\_ID= API\_HASH= TELETHON\_SESSION\_STRING= DEST\_CHANNEL= HH\_CONTACT= OUTPUT\_PATH=

Получение данных с пагинацией мы осуществим так:

def fetch_page(session: requests.Session, page: int, per_page: int) -> Optional[Dict[str, Any]]: """Получает одну страницу вакансий""" params = { "text": SEARCH_TEXT, "per_page": per_page, "page": page, "search_field": "name", "order_by": "publication_time", "only_with_salary": "false" } backoff = INITIAL_BACKOFF for attempt in range(1, MAX_PAGE_ATTEMPTS + 1): try: logger.info(f"GET page={page} per_page={per_page} attempt={attempt}") r = session.get(API_URL, params=params, timeout=TIMEOUT) if r.status_code == 400: logger.error(f"400 Bad Request: {r.text[:512]}") # Упрощаем запрос при ошибке params_simple = {"text": "python", "per_page": per_page, "page": page} r = session.get(API_URL, params=params_simple, timeout=TIMEOUT) r.raise_for_status() return {"json": r.json()} if r.status_code == 429: # Обработка ограничения запросов ra = r.headers.get("Retry-After") try: wait = float(ra) if ra and ra.isdigit() else backoff except Exception: wait = backoff logger.warning("429 Too Many Requests. Retry-After=%s. Waiting %.1fs", ra, wait) time.sleep(wait) backoff = min(backoff * 2, 60) continue r.raise_for_status() return {"json": r.json()} except requests.exceptions.RequestException as e: logger.warning("RequestException on page %s (attempt %s): %s", page, attempt, e) if attempt == MAX_PAGE_ATTEMPTS: logger.error("Failed to fetch page %s after %s attempts", page, MAX_PAGE_ATTEMPTS) return None time.sleep(backoff) backoff = min(backoff * 2, 60) return None

А фильтрацию так:

def filter_vacancies(vacancies: List[Dict], period_start: datetime, period_end: datetime) -> List[Dict]: """Фильтрует вакансии по периоду и городам""" filtered = [] for vacancy in vacancies: try: # Фильтр по периоду published_at = parse_date(vacancy['published_at']) if not (period_start <= published_at <= period_end): continue # Фильтр по городу area_name = vacancy.get('area', {}).get('name', '') if not area_name or not is_target_city(area_name): continue filtered.append(vacancy) except Exception as e: logger.warning(f"Error filtering vacancy {vacancy.get('id', 'unknown')}: {e}") continue # Сортируем от новых к старым filtered.sort(key=lambda v: parse_date(v['published_at']), reverse=True) return filtered

На выходе мы получим примерно такой отчет:

Парсинг вакансий и резюме с HH

Парсинг резюме с hh

После рассмотрения парсинга вакансий перейдем к анализу рынка кандидатов.
Следует отметить, что HH предоставляет возможность получения данных через API, но для этого требуется написать заявление на сайте или можно купить эту услугу. Поэтому предлагаю воспользоваться парсингом, тем более мы не будем получать данные, тарифицируемые площадкой -- а соберем только то, что доступно на сайте.

Общая схема работы

- Поиск резюме

- Загрузка страницы поиска по разработчикам Python

- Извлечение данных

- Парсинг ссылок, заголовков и контекста

- Проверка уникальности

- Сохранение в базу данных SQLite

- Формирование отчета

- Статистика и список новых резюме

- Отправка в Telegram*

- Автоматическая рассылка уведомлений

Полный скрипт доступен по ссылке.

Отмечу, что нам потребуются следующие переменые.

API\_ID=your\_telegram\_api\_id API\_HASH=your\_telegram\_api\_hash TELETHON\_SESSION\_STRING=your\_session\_string DEST\_CHANNEL=@your\_channel AMVERA\_DATA\_DIR=/data

Нужно вручную выставить необходимые фильтры на hh.ru и скопировать ссылку в код.

json \# URL поиска URL = "https://hh.ru/search/resume?area=1&area=2&exp\_period=all\_time&logic=normal&no\_magic=true&ored\_clusters=true&pos=full\_text&search\_period=3&text=Python+%D1%80%D0%B0%D0%B7%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D1%87%D0%B8%D0%BA&order\_by=publication\_time"
Парсинг вакансий и резюме с HH

Извлекаем резюме мы так

def extract_resumes(html: str, base_url: str) -> list: """Извлечение резюме из HTML""" soup = BeautifulSoup(html, "lxml") anchors = soup.find_all("a", href=True) seen = set() results = [] for a in anchors: href = a["href"] if "/resume/" not in href: continue full = urljoin(base_url, href.split("?")[0]) if full in seen: continue seen.add(full) title = a.get_text(strip=True) if not title: title = a.find_parent().get_text(" ", strip=True)[:120] parent = a.find_parent() context = "" if parent: for sub_a in parent.find_all("a"): sub_a.extract() context = parent.get_text(" ", strip=True) context = re.sub(r"\s+", " ", context).strip() results.append({ "title": title, "url": full, "context": context[:800], }) # Альтернативный поиск если основной не сработал if not results: cards = soup.find_all(attrs={"data-qa": re.compile("resume-serp__resume|serp-item")}) for c in cards: a = c.find("a", href=True) if not a: continue href = a["href"] full = urljoin(base_url, href.split("?")[0]) if full in seen: continue seen.add(full) title = a.get_text(strip=True) or c.get_text(" ", strip=True)[:120] context = c.get_text(" ", strip=True) results.append({"title": title, "url": full, "context": context[:800]}) return results

И если все хорошо, то получим вот такой отчет в ТГ.

Парсинг вакансий и резюме с HH

Запуск на хостинге по расписанию через git push

Как и говорилось в начале статьи, запустим мы наш код буквально за пару минут "одной" командой в IDE или просто перетянув файлы в интерфейсе Amvera Cloud. Сделаем это бесплатно, воспользовавшись промобалансом.

Для этого создадим проект с типом Cron Jobs (или Приложение, если хотите непрерывной работы).

Задаем параметры запуска.
Задаем параметры запуска.

Загрузим код через git push

Парсинг вакансий и резюме с HH

Или перетянем код в интерфейсе

Парсинг вакансий и резюме с HH

Далее ждем пару минут сборку и наш проект в Amvera запущен!

Итог нашего парсинга резюме и вакансий с HH

Мы написали простой скрипт получения данных резюме и вакансий с HH. Он собирает только данные, легально предоставляемые работной платформой.

И развернули наш код в облаке Amvera за две минуты, просто выполнив команду git push amvera master.

2
1
3 комментария