Парсим HH.ru на Python
Эта статья описывает техническую реализацию ответа на вопрос к статье «Какие бывают аналитики». Поэтому выводов здесь не будет. А в этой статье мы:
- Получим с помощью API 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.
Результатом работы следующего скрипта будет являться папка 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 Вам помогли, то поддержите, пожалуйста, проект, чтобы я мог развивать его дальше.
Комментарии
Несколько вариантов возможны.
1. Select на id, с последующей проверкой количества возвращенных строк.
2. Уникальный индекс в БД, который не позволит добавить дубль и вернет ошибку. Ошибку можно уже обработать. Можно также использовать оператор вставки UPSERT.
У hh есть описание языка поисковых запросов. Рекомендую Вам обратиться к нему.
С чем может быть связана ошибка
max=df_idf['idf'].count(),
NameError: name 'df_idf' is not defined
Соответствующая библиотека подключена.
Вы пропустили определение датафрейма df_idf.
а последний блок кода с непосредственно й отрисовкой данных, он записывается в отдельный файл? или он идет после того как собрали биграммы?
и все это одним питоновским файлом? или все таки в разных?
Заранее спасибо за ответ
Подскажите пожалуйста, столкнулся с ошибкой при попытке заполнить таблицы в Базе Данных
выдает ошибку:
ValueError: invalid literal for int() with base 10: '{5432}'
строка обращения к БД:
eng = sql.create_engine('postgresql://{postgres}:{**********}@{PostgreSQL14}:{5432}/{Vacancies_HH}')
не могу понять где ошибся... Заранее благодарю за помощь
Ошибка вам сообщает, что строка подключения неверно составлена. Уберите фигурные скобки.
File "nn4.py", line 18, in
for v in jsonObj['items']:
KeyError: 'items'" во втором блоке кода? те там где заполняем vacancies
Отсюда вопрос как вывести информацию по id вакансии? не пойму как правильно передать параметр id методом get, так как в документации тупо id передаётся и всё.
Может я вопрос не так понял. В статье есть пример обращения к api, для получения вакансии - https://api.hh.ru/vacancies/{id вакансии}?host=hh.ru.
Т.е. id вакансии не get-параметром передается, а частью пути в url.
Я готов платить он не те конские деньги, как там сейчас.
Мне нужно несколько сотен резюме.
Причин, почему не работает функция, может быть много. Но конкретно в Вашем случае и на Вашей машине поможет разобраться консоль в инструментах разработчика браузера (f12). Если ошибка в JavaScript, то она там будет видна.
С радостью дарю вам сотку.