Анализ HR вакансий. Часть 3. Парсим HR вакансии
В двух предыдущих статьях [1][2] мы с вами развернули базу данных, установили R (Python) и получили OAuth токен для обращения по API к данным hh.ru [3]. Таким образом мы готовы к тому, чтобы собрать вакансии и положить их в базу.
Вакансии можно парсить разными способами, к примеру, по поиску текса в названии: “HR аналитик”. Неплохой способ, если мы хотим найти какую-то одну специальность, но в целой профессиональной области названий может быть сколь угодно много и перечисление всех вариантов крайне неэффективно. Именно поэтому мы будем собирать вакансии по профобластям (специализациям) HR.
И всё было бы хорошо, если бы не одно досадное ограничение API hh.ru, которое лично меня огорчает больше всего – это возможность забирать только первые 2000 элементов в какой-либо категории. В свою очередь каждая страница тоже имеет ограничение в 100 элементов. То есть наш лимит на одну категорию – это просмотр 20 страниц, на каждой из которой может быть максимум 100 вакансий [4].
Оценим активные вакансии по профобластям HR на момент написания статьи:
Очевидно, что три специализации превышают 2000 элементов, но мы хотим собрать максимально всё, следовательно нам нужно изобрести такой алгоритм, который будет дробить профобласти на ещё меньшие категории. К примеру, мы можем разбить профобласти по регионам, но оказывается, что в Москве может быть превышение этого лимита даже внутри одной профобласти! Что ж, тогда Москву мы будем дробить ещё на категории опыта.
В чем главная коллизия? В том, что чем больше итераций, тем медленнее загрузка данных. Давайте нарисуем допустимый вариант алгоритма в виде блок-схемы, который призван сэкономить время за счёт снижения числа итераций.
Засучив рукав начинаем реализовывать логику на уровне кода. Пример в статье будет дан на R. На моем GitHub выложен полный код на R [5] и его адаптация для Python [6].
Подключаем все необходимые для работы библиотеки. В первый раз может понадобиться их установка командой install.packages(‘название_библиотеки’)
для R и pip install ‘название_библиотеки’
, если вы предпочитаете Python.
# Библиотеки ---- library(httr) #для работы с HTTP library(urltools) #для работы с URL library(jsonlite) #JSON парсер library(purrr) #для применения семейства map() library(furrr) #параллельные вычисления library(tidyverse) #вселенная инструментов tidyverse library(RPostgres) #коннектор PostgreSQL
Далее определим параметры – это векторы из id, по которым мы будем итерировать: профобласти, категории опыта и регионы России. Последние мы соберем через API hh.ru, так как искать id 89 регионов и писать их руками совсем не хочется.
## Профессиональные области HR prof_id <- c(17, #бизнес-тренер 38, #директор по персоналу 69, #менеджер по персоналу. Больше 2000 ! 117, #специалист по кадрам. Больше 2000 ! 118, #специалист по подбору персонала. Больше 2000 ! 153, #менеджер по компенсациям и льготам 171) #руководитель отдела персонала ## Регионы areas_url <- "https://api.hh.ru/areas" #путь к API hh.ru с регионами и городами areas <- content(GET(areas_url), as = "parsed") #парсим содержимое areas_id <- map_chr(areas[[1]]$areas, "id") #извлекаем список id российских регионов ## Опыт работы exp_id <- c('noExperience', #нет опыта 'between1And3', #от 1 до 3 лет 'between3And6', #от 3 до 6 лет 'moreThan6') #более 6 летрсонала
На блок-схеме я оперировал словом «цикл», но циклы мы писать не будем, чтобы не огрести дополнительного хейта от сообщества разработчиков на R. Будем же мы использовать мощь этого языка – итеративное применение функций к векторам с помощью библиотеки purr
. Но для начала нужно определить сами функции.
Вы помните, что за один раз с одной страницы мы можем взять только 100 элементов, верхняя граница равна 2000. Следовательно, нам нужна функция, которая распарсит эти 100 элементов (вакансий) из массива JSON, сложит их во временный датафрейм (таблицу), подклеит к общему результату, очистится и повторит все эти шаги вновь.
Напишем такую функцию get_vacancies_inter()
. В теле функции я описываю все поля, которые будут извлекаться из массива данных, забираемых по API, а также местам комментирую почему нужен тот или иной способ.
#Функция для сбора вакансий в промежуточные датафреймы get_vacancies_inter <- function(vacancies){ data.frame( id = vacancies$items$id, #id вакансии name = vacancies$items$name, #название вакансии area_id = vacancies$items$area$id, #id региона area_name = vacancies$items$area$name, #название города professional_roles_id = sapply(vacancies$items$professional_roles, "[[", 1), #извлекакам id проф. роли professional_roles_name = sapply(vacancies$items$professional_roles, "[[", 2), #извлекакам название проф. роли. #Используем функцию sapply(), так как данные вложены в лист employer_id = vacancies$items$employer$id, #id работодателя employer_name = vacancies$items$employer$name, #название компании работодателя snippet_requirement = vacancies$items$snippet$requirement, #требования к кандидату snippet_responsibility = vacancies$items$snippet$responsibility, #описание обязанностей experience = vacancies$items$experience$name, #требуемый опыт employment = vacancies$items$employment$name, #тип занятости salary_from = ifelse("salary" %in% names(vacancies$items) && "from" %in% names(vacancies$items$salary), vacancies$items$salary$from, NA), #нижняя граница оплаты труда, salary_to = ifelse("salary" %in% names(vacancies$items) && "to" %in% names(vacancies$items$salary), vacancies$items$salary$to, NA), #верхняя граница оплаты труда salary_currency = ifelse("salary" %in% names(vacancies$items) && "currency" %in% names(vacancies$items$salary), vacancies$items$salary$currency, NA), #валюта salary_gross = ifelse("salary" %in% names(vacancies$items) && "gross" %in% names(vacancies$items$salary), vacancies$items$salary$gross, NA), #признак того, что зарплата указана до вычета налогов. #Зарпалата указана не у всех вакансий, поэтому чтобы не получить ошибку мы используем конструкцию ЕСЛИ ТО, которая проверяет наличие данных о зп. created_at = vacancies$items$created_at, #дата создания вакансии published_at = vacancies$items$published_at, #дата публикации вакансии url = vacancies$items$alternate_url, #ссылка на вакансию на сайте hh.ru stringsAsFactors = FALSE #НЕ превращать текстовые поля в факторы ) }
Вторая и последняя функция, которая нам понадобится get_vacancies_result()
– выполняет всю оставшуюся работу.
#Итоговая функция по сбору вакансий в один датафрейм get_vacancies_result <- function(page, prof_id, area_id = NULL, exp_id = NULL) { #Реализуем логику, отраженную на блок-схеме if (prof_id %in% c(17, 38, 153, 171)) { query_list = list(professional_role = prof_id, per_page = 100, page = page) } else if (area_id == 1) { query_list = list(professional_role = prof_id, area = area_id, experience = exp_id, per_page = 100, page = page) } else { query_list = list(professional_role = prof_id, area = area_id, per_page = 100, page = page) } #Парсим результат по заданному условию, используем для аутентификации OAuth токен response <- GET(url = vacancies_url, add_headers(Authorization = paste("Bearer", access_token)), query = query_list #если бы вы хотели осуществлять поиск по тексту в названии вакансии # query = list(text = "hr аналитик", search_field = "name", per_page = 100, page = page)) ) #Извлекаем данные из JSON в лист vacancies <- fromJSON(rawToChar(response$content)) #Чтобы избежать ошибки, на тот случай, когда страниц менее 20, пишем такую проверку if (length(vacancies$items) == 0) { return(NULL) } #Соединям промежуточные датафреймы в один итоговый #Делаем это с интревалом в 0.5 сек, чтобы не получить бан со стороны API vacancies_df_inter <- get_vacancies_inter(vacancies) Sys.sleep(0.5) #Возвращаем результат return(vacancies_df_inter) }
Теперь ранее указанные условия: профобласти, регионы, опыт превратим в сетки параметров помноженные на 20 страниц (нумерация от 0), по которым мы будем вызывать написанные функции в соответствии с нашей блок-схемой.
### 1. Профобласти до 2000 элементов params_prof_only <- expand_grid( page = 0:19, prof_id = prof_id[prof_id %in% c('17', '38', '153', '171')] ) ### 2. Профобласти более 2000 элементов, только Москва params_area_exp <- expand_grid( page = 0:19, prof_id = prof_id[!prof_id %in% c('17', '38', '153', '171')], area_id = areas_id[areas_id == 1], exp_id = exp_id ) ### 3. Профобласти более 2000 элементов, остальные регионы params_area <- expand_grid( page = 0:19, prof_id = prof_id[!prof_id %in% c('17', '38', '153', '171')], area_id = areas_id[areas_id != 1] )
Если вызвать наши функции последовательно ко всем трём сценариям, то это займет ~27 минут времени в R. Иными словами за целую минуту мы соберем менее 1000 вакансий. Это непозволительно медленно! И тут на помощь приходит пакет furr
, который предоставляет мощь параллельных вычислений и вместо ~27 минут уходит всего ~7.
Наконец-то мы готовы использовать наши функции. Помните, что понадобится OAuth токен, полученный через приложение, которое мы делали в предыдущей статье [2].
# Собираем вакансии ##### access_token <- 'XXXX' #ваш OAuth токен vacancies_url <- 'https://api.hh.ru/vacancies' #путь к вакансиям API hh.ru ### Запуск plan(multisession) #параллельные вычисления start.time <- Sys.time() #время начала ### 1. Профобласти до 2000 элементов df_prof_only <- params_prof_only %>% future_pmap_dfr(get_vacancies_result) ### 2. Профобласти более 2000 элементов, только Москва df_area_exp <- params_area_exp %>% future_pmap_dfr(get_vacancies_result) ### 3. Профобласти более 2000 элементов, остальные регионы df_area <- params_area %>% future_pmap_dfr(get_vacancies_result) #Считаем сколько ушло времени end.time <- Sys.time() time.taken <- end.time - start.time print(time.taken) ### Собираем всё в итоговый датасет vacancies_df <- rbind(df_prof_only, df_area_exp, df_area)
На этом этапе стоит проверить, что все отработало верно и количество записанных строк равно ожидаемому по Таблице 1. В R можно воспользоваться для этого командой nrow(vacancies_df)
.
Вакансии собраны, теперь их нужно записать в нашу базу данных, которую мы создали в первой статье [1].
## Устанавливаем коннект с базой connection <- dbConnect(RPostgres::Postgres(), dbname = 'h0h1_about_hr_analytics', #имя вашей базы host = 'localhost', port = 5432, user = 'postgres', password = 'XXXX' #пароль от вашей базы ) ## По умолчанию все поля текстовые. Исправляем: числа, как числа и даты, как даты vacancies_df$id <- as.integer(vacancies_df$id) vacancies_df$area_id <- as.integer(vacancies_df$area_id) vacancies_df$professional_roles_id <- as.integer(vacancies_df$professional_roles_id) vacancies_df$employer_id <- as.integer(vacancies_df$employer_id) vacancies_df$created_at <- as.Date(vacancies_df$created_at) vacancies_df$published_at <- as.Date(vacancies_df$published_at) ## Записываем таблицу dbWriteTable(connection, "vacancies_hr", vacancies_df, overwrite = TRUE) ## Индексируем основные поля для повышения скорости работы будущего дашборда dbExecute(connection, "CREATE INDEX field_indexes ON vacancies_hr (published_at, name, area_name, professional_roles_name, employer_name);") ## Разрываем соединение dbDisconnect(connection)
Давайте откроем DBeaver и удостоверимся в результате со стороны базы. Нажимаем F3 чтобы открыть окно для написания SQL инструкции и смотрим на первые 10 строк в таблице.
Вакансии собраны, в следующей статье мы поговорим про регуляризацию – или как заставить этот скрипт запускаться самостоятельно.
- Анализ HR вакансий. Часть 1. Разворачиваем базу данных
- Анализ HR вакансий. Часть 2. Знакомство с HeadHunter API
- https://hh.ru/
- https://api.hh.ru/openapi/redoc#tag/Sohranennye-poiski-rezyume/operation/create-saved-resume-search
- https://github.com/alexander-botvin/h0h1_about_hr_analytics/blob/main/HR%20vacancies%20(hh.ru)/hr_vacancies.R
- https://github.com/alexander-botvin/h0h1_about_hr_analytics/blob/main/HR%20vacancies%20(hh.ru)/hr_vacancies.py