Парсим HH.ru на Python

Эта статья описывает техническую реализацию ответа на вопрос к статье «Какие бывают аналитики». Поэтому выводов здесь не будет. А в этой статье мы:

Облако биграмм, составленных по названиям вакансий

Используем API HH.ru

HeadHunter имеет официальный API, что позволяет получать информацию по вакансия в удобном формате, без необходимости парсить web-страницы сайта. Чтобы получить вакансии достаточно выполнить http-запрос методом get по адресу https://api.hh.ru/vacancies и передать параметры для фильтра. Ответ вернется в формате JSON, который мы сохраним себе в виде файла для дальнейшей работы.

Каждый файл представляет из себя страницу, как если бы Вы переключались в веб-интерфейсе по постраничной навигации, содержащую названия вакансий и ссылки на страницы вакансий. Давайте получим их (см. пояснения в комментариях кода):

# Библиотека для работы с HTTP-запросами. Будем использовать ее для обращения к API HH
import requests

# Пакет для удобной работы с данными в формате json
import json

# Модуль для работы со значением времени
import time

# Модуль для работы с операционной системой. Будем использовать для работы с файлами
import os

 
def getPage(page = 0):
    """
    Создаем метод для получения страницы со списком вакансий.
    Аргументы:
        page - Индекс страницы, начинается с 0. Значение по умолчанию 0, т.е. первая страница
    """
    
    # Справочник для параметров GET-запроса
    params = {
        'text': 'NAME:Аналитик', # Текст фильтра. В имени должно быть слово "Аналитик"
        'area': 1, # Поиск ощуществляется по вакансиям города Москва
        'page': page, # Индекс страницы поиска на HH
        'per_page': 100 # Кол-во вакансий на 1 странице
    }
    
    
    req = requests.get('https://api.hh.ru/vacancies', params) # Посылаем запрос к API
    data = req.content.decode() # Декодируем его ответ, чтобы Кириллица отображалась корректно
    req.close()
    return data


# Считываем первые 2000 вакансий
for page in range(0, 20):
    
    # Преобразуем текст ответа запроса в справочник Python
    jsObj = json.loads(getPage(page))
    
    # Сохраняем файлы в папку {путь до текущего документа со скриптом}\docs\pagination
    # Определяем количество файлов в папке для сохранения документа с ответом запроса
    # Полученное значение используем для формирования имени документа
    nextFileName = './docs/pagination/{}.json'.format(len(os.listdir('./docs/pagination')))
    
    # Создаем новый документ, записываем в него ответ запроса, после закрываем
    f = open(nextFileName, mode='w', encoding='utf8')
    f.write(json.dumps(jsObj, ensure_ascii=False))
    f.close()
    
    # Проверка на последнюю страницу, если вакансий меньше 2000
    if (jsObj['pages'] - page) <= 1:
        break
    
    # Необязательная задержка, но чтобы не нагружать сервисы hh, оставим. 5 сек мы может подождать
    time.sleep(0.25)
    
print('Старницы поиска собраны')

После получения страниц со списком вакансий получим детальную информацию по каждой вакансии. Для этого разберем JSON в полученных документах и для каждой вакансии обратимся к API по готовой ссылке https://api.hh.ru/vacancies/{id вакансии}?host=hh.ru.

Пример json-данных со списком вакансий

Результатом работы следующего скрипта будет являться папка vacancies, наполненная файлами с информацией по вакансиям:

import json
import os
import requests
import time

# Получаем перечень ранее созданных файлов со списком вакансий и проходимся по нему в цикле 
for fl in os.listdir('./docs/pagination'):
    
    # Открываем файл, читаем его содержимое, закрываем файл
    f = open('./docs/pagination/{}'.format(fl), encoding='utf8')
    jsonText = f.read()
    f.close()
    
    # Преобразуем полученный текст в объект справочника
    jsonObj = json.loads(jsonText)
    
    # Получаем и проходимся по непосредственно списку вакансий
    for v in jsonObj['items']:
        
        # Обращаемся к API и получаем детальную информацию по конкретной вакансии
        req = requests.get(v['url'])
        data = req.content.decode()
        req.close()
        
        # Создаем файл в формате json с идентификатором вакансии в качестве названия
        # Записываем в него ответ запроса и закрываем файл
        fileName = './docs/vacancies/{}.json'.format(v['id'])
        f = open(fileName, mode='w', encoding='utf8')
        f.write(data)
        f.close()
        
        time.sleep(0.25)
        
print('Вакансии собраны')

На этом этапе работа с API HH.ru завершается.

Сохраняем вакансии в базу данных

Теперь сохраним некоторую информацию по вакансиям в БД. Этот шаг можно не выполнять и реализовать работу с данными сразу из файлов, но лично мне нравиться работать с SQL и «покрутить» данные именно в SQL. Тем более в дальнейшем их будет проще получать.

У меня локально установлена СУБД PostgreSQL, поэтому для подключения к БД из Python должен быть установлен драйвер psycopg2.

Создадим 2 таблицы следующей структуры:

Структура таблиц в БД для хранения вакансий

В таблице vacancies будут храниться идентификатор вакансии, ее наименование и описание. Первичный ключ (PK) – id.
В таблице skills идентификатор вакансии и наименование навыка, требуемого для этой вакансии. PK – все столбцы.

Следующий скрипт заполнит эти таблицы:

# Библиотека для анализа данных, представляющая данные в табличном виде называемом DataFrame
# Вся мощь данной библиотеки нам здесь не понадобиться, с ее помощью мы положим
# данные в БД. Можно было бы написать простые insert-ы
import pandas as pd

import json
import os

# Библиотека для работы с СУБД
from sqlalchemy import engine as sql

# Модуль для работы с отображением вывода Jupyter
from IPython import display

# Создаем списки для столбцов таблицы vacancies
IDs = [] # Список идентификаторов вакансий
names = [] # Список наименований вакансий
descriptions = [] # Список описаний вакансий

# Создаем списки для столбцов таблицы skills
skills_vac = [] # Список идентификаторов вакансий
skills_name = [] # Список названий навыков

# В выводе будем отображать прогресс
# Для этого узнаем общее количество файлов, которые надо обработать
# Счетчик обработанных файлов установим в ноль
cnt_docs = len(os.listdir('./docs/vacancies'))
i = 0

# Проходимся по всем файлам в папке vacancies
for fl in os.listdir('./docs/vacancies'):
    
    # Открываем, читаем и закрываем файл
    f = open('./docs/vacancies/{}'.format(fl), encoding='utf8')
    jsonText = f.read()
    f.close()
    
    # Текст файла переводим в справочник
    jsonObj = json.loads(jsonText)
    
    # Заполняем списки для таблиц
    IDs.append(jsonObj['id'])
    names.append(jsonObj['name'])
    descriptions.append(jsonObj['description'])
    
    # Т.к. навыки хранятся в виде массива, то проходимся по нему циклом
    for skl in jsonObj['key_skills']:
        skills_vac.append(jsonObj['id'])
        skills_name.append(skl['name'])
    
    # Увеличиваем счетчик обработанных файлов на 1, очищаем вывод ячейки и выводим прогресс
    i += 1
    display.clear_output(wait=True)
    display.display('Готово {} из {}'.format(i, cnt_docs))


# Создадим соединение с БД
eng = sql.create_engine('postgresql://{Пользователь}:{Пароль}@{Сервер}:{Port}/{База данных}')
conn = eng.connect()

# Создаем пандосовский датафрейм, который затем сохраняем в БД в таблицу vacancies
df = pd.DataFrame({'id': IDs, 'name': names, 'description': descriptions})
df.to_sql('vacancies', conn, schema='public', if_exists='append', index=False)

# Тоже самое, но для таблицы skills
df = pd.DataFrame({'vacancy': skills_vac, 'skill': skills_name})
df.to_sql('skills', conn, schema='public', if_exists='append', index=False)

# Закрываем соединение с БД
conn.close()

# Выводим сообщение об окончании программы
display.clear_output(wait=True)
display.display('Вакансии загружены в БД')

Строим отчет

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

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

import sqlalchemy
conn = sqlalchemy.create_engine('postgresql://{Пользователь}:{Пароль}@{Сервер}:{Port}/{База данных}').connect()

import pandas as pd

# Загружаем наименования вакансий
sql = 'select name from public.vacancies'    
vacancies_name = pd.read_sql(sql, conn).name

# Загружаем навыки по вакансиям
sql = """
    select
        v.name,
        skill
    from
        public.skills s
        join public.vacancies v
            on v.id = s.vacancy
"""

skills = pd.read_sql(sql, conn)

# Закрываем соединение с БД
conn.close()

# sklearn - библиотека, содержащая набор инструментов для машинного обучения.
# feature_extraction.text извлекает признаки из текста, которые затем можно будет 
# использовать в моделировании. В нашем случае моделирование не требуется. Нам нужно 
# получить биграммы и их оценку
from sklearn.feature_extraction.text import TfidfTransformer, CountVectorizer

# Получим матрицу встрчаемости биграмм (токенов) для каждой вакансии
# В качестве стоп-слов установим грейды сотрудников, т.е. нам 
# не важно Старший аналитик данных или Младший, нам важно, что он аналитик данных
# Также исключаем биграммы, которые встречаются реже, чем в 1-ой тысячной всех вакансий
cv=CountVectorizer(
    ngram_range=(2,2), 
    stop_words=['ведущий', 'главный', 'младший', 'эксперт', 'стажер', 'старший', 
                'middle', 'junior', 'senior'],
    min_df=0.001
)
word_vector=cv.fit_transform(vacancies_name)

# Рассчитаем меру idf (обратная частота документа)
# Чем выше мера для конкретного токена, тем реже он встречается
idf = TfidfTransformer().fit(word_vector)

# Строим датафрейм с оценкой idf для каждого токена. Далее он понадобится для построения облака 
df_idf = pd.DataFrame(idf.idf_, index=cv.get_feature_names(), columns=["idf"])

# Строим датафрейм из матрицы частоты токенов, где в качестве строк вакансии,
# токены в качестве столбцов. Далее используем его для получения списка вакансий, для
# которых встречается конкретный токен
pivot = pd.DataFrame(
            word_vector.toarray(), 
            columns=cv.get_feature_names(), 
            index=vacancies_name.values
        )

Теперь непосредственно строим отчет:

# Импортируем виджеты. Они позволяют более гибко управлять выводом jupyter и 
# создавать интерактивные элементы
import ipywidgets as widget

# Импортируем модуль вывода jupyter
from IPython import display

# Библиотека для визуализации данных
import matplotlib.pyplot as plt

# Модуль математических функций
import math

################################################

def click_back(b):
    """
       Метод, который вызывается при клике на кнопку "Назад".
       Метод скрывает информацию о перечне вакансий из левого сайдбара и
       диаграмму популярности навыков из правого сайдбара, а также отображает
       в центральной части шаблона облако токенов.
    """
    app_layout.left_sidebar = None
    app_layout.right_sidebar = None
    app_layout.center = out_words

################################################

def print_words(x):
    """
        Метод, который вызывается при изменении значения слайдера.
        Метод отрисовывает в формате html облако токенов
        
        Аргумент "x" принимает справочник, который содержит состояние слайдера. Используем
        x.new, чтобы получить новое состояние слайдера.
    """
    
    # Получаем датафрейм, отсортированный в обратном порядке по мере idf, 
    # ограниченный диапазоном значений, заданным слайдером
    ds = df_idf.sort_values(by='idf', ascending=False)[x.new[0]: x.new[1]]
    
    # Получаем максимальное и минимальное значения idf. Будем использовать их для установки
    # размера шрифта токена в облаке
    mx = df_idf['idf'].max()
    mn = df_idf['idf'].min()
    
    # Задаем общие css-стили для облака токенов
    tags = """<style>
    .tagword{
        border:1px solid #bee5eb;
        padding:1px 5px;
        display:block;
        border-radius:4px;
        color:#0c5460;
        background:#d1ecf1;
        line-height:normal;
        cursor:pointer}
    .tagword:hover{
        background:#c1dce1}
    .tag-wrapper{
        float:left;
        margin:0 5px 5px 0}
    </style>"""
    
    # Пробегаемся по токенам, выбранным с помощью слайдера
    for r in ds.sort_index().itertuples():
        
        # Масштабируем значения idf от 0 до 1 и переворачиваем их, 
        # чтобы максимальное значение стало минимальным.
        if mx > mn:
            
            # Задаем размер шрифта и высоту токена в облаке
            fs = int( (((r.idf - mn) / (mx - mn)) * -1 + 1 ) * 30 + 10 )
            hd = math.ceil(fs / 10) * 10 + 8
        else:
            fs = 40
        
        # Добавляем токен в облако в формате html. Токену назначаем функцию tag_click для
        # события клика. Сама функция описана ниже за пределами данного метода
        tag_tmpl = """<div class="tag-wrapper" style="height:{height}px">
            <span onclick="tag_click(this)" class="tagword" style="font-size:{size}px">{name}</span>
        </div>"""
        tags += tag_tmpl.format(name=r.Index, size=fs, height=hd)
    
    # Отрисовываем облако токенов 
    click_back(None)
    out_words.clear_output(wait=True)
    with out_words:
        display.display_html(tags, raw=True)
        
################################################
        
def get_detail(w):
    """
        Метод отрисовывает детелизацию по токену, а именно перечень вакансий, 
        содержащих переданный токен, и топ-20 самых востребованных навыков по этим вакансиям.
        
        Аргумент "w" принимает значение токена, по которому кликнули
    """
    
    # Выводим пандосовский датафрейм со списком вакансий, содержащих токен
    out_details_vac.clear_output(wait=True)
    vacs = pivot[pivot[w] > 0].sort_index().index.unique()
    with out_details_vac:
        display.display_html(
                pd.DataFrame(data=vacs, columns=['Список вакансий:']).to_html(index=False), raw=True
        )
    
    # Выводим горизонтальный столбчатый график самых популярных навыков по выбранным вакансиям
    out_details_skl.clear_output(wait=True)
    skill_pvt = skills \
                .query('name in @vacs') \
                .groupby(by='skill', as_index=False) \
                .agg({'name':'count'}) \
                .sort_values(by='name', ascending=False).head(20)
    fig_h = skill_pvt.shape[0] / 2.5    # Корректируем размер графика на 
                                        # основании количества получившихся столбцов
    with out_details_skl:
        skill_pvt.sort_values(by='name').plot.barh(
                        x='skill', 
                        y='name', 
                        figsize=(5, fig_h), 
                        legend=None, 
                        title='ТОП20 Навыков'
        )
        plt.show()
    
    
    # Скрываем облако токенов и выводим детализацию для токена
    app_layout.center = None
    app_layout.left_sidebar = out_details_vac
    app_layout.right_sidebar = out_details_skl

################################################

# Создаем несколько виджетов вывода
out_header = widget.Output() # Сюда будем выводить виджет слайдера и кнопку возврата
out_words = widget.Output() # Сюда будем выводить облако
out_details_vac = widget.Output() # Сюда будем выводить перечень вакансий, содержащих токен
out_details_skl = widget.Output() # Сюда будем выводить график навыков по вакансиям, содержащим токен

# Создаем виджет макета. Весь отчет по сути располагается в данном макете.
# Пока только с выводом в центр виджета с облаком
app_layout = widget.AppLayout(
    header=None,
    left_sidebar=None,
    center=out_words,
    right_sidebar=None,
    footer=None,
    pane_heights=[1, 5, '60px']
)

# Создаем виджет слайдера, для указания диапазона рассматриваемых токенов
sld = widget.IntRangeSlider(
    value=[0, 0],
    min=0,
    max=df_idf['idf'].count(),
    step=1,
    description='Частота:',
    layout = {'width': '100%'}
)
# Назначаем слайдеру метод, который будет вызываться при изменении значения слайдера
sld.observe(print_words, names='value')

# Создаем виджет кнопки
bck_btn = widget.Button(
    description='Назад',
    button_style='info'
)
# Назначаем кнопке метод, который будет вызываться по событию клика по ней
bck_btn.on_click(click_back)

# В формате html подготовим строку, которая создаст функцию на языке javascript
# Данную функцию будем вызывать по событию клика по токену в облаке
html = """
<script>
    function tag_click(e){
        var kernel = Jupyter.notebook.kernel
        func = 'get_detail("' + e.innerText + '")'
        kernel.execute(func)
    }
</script>"""

# Выводим в виджет вывода виджеты слайдера и кнопки
with out_header:
    display.display(sld)
    display.display(bck_btn)

display.display_html(html, raw=True) # Отображаем в ячейке ранее подготовленный html c js-функцией
display.display(out_header) # Отображаем виджет, содержащий слайдер и кнопку
display.display(app_layout) # Отображаем виджет макета

# Задаем новые значения диапазона слайдера, чтобы отобразить ТОП-30 самых популярных токенов
# Данная строка спровоцирует изменения, что в свою очередь вызовет установленный метод print_words,
# который формирует облако
sld.value = [sld.max - 30, sld.max]

В результате у меня получилось облако, которые можно увидеть на картинке в начале статьи. По нему мы сразу видим токены, которые явно выделяются на фоне остальных.

Затем мы можем получить детализацию по токену, кликнув на него, чтобы узнать точные наименования вакансий, которые данный токен содержат, а также увидим самые востребованные по данным вакансиям навыки:

Детализация отчета со списком вакансий и графиком навыков

Заключение

Мне было интересно построить такой отчет в питоне, особенно интересно было узнать, что jupyter позволяет выводить содержимое в формате html с поддержкой стилей и js, а это значит, что можно строить даже самые причудливые визуализации для своих исследований. Но не стоит сразу сильно разгоняться, сначала попробуйте поискать готовые библиотеки.

Виджеты тоже оказались очень интересным инструментом, позволяющим строить макеты и поддерживать интерактивность.

Работу с токенами можно немного усложнить, например, привести все слова к нормальной форме с помощью библиотеки pymorphy2 и избавиться от предлогов, союзов и частиц. Можно перевести английские наименования вакансий на русский, чтобы рассматривать их как одинаковые. Для себя я посчитал это излишним, но Вы можете попробовать.

Если материалы office-menu.ru Вам помогли, то поддержите, пожалуйста, проект, чтобы я мог развивать его дальше.

Добавить комментарий