Сильно повезло тем, кто никогда не был в состоянии «ищу работу»! Моя история вполне рядовая: в возрасте почти сорока лет я решил «вкатиться в IT» через популярные профессиональные курсы. Учебный процесс меня вдохновлял, и казалось, что впереди меня ожидает очередь из работодателей, стремящихся нанять востребованного специалиста. Но, как оказалось, никто не спешит брать на работу junior-специалистов (эйджизм? Не может быть…).

На баннерах, в рекламных роликах и сообщениях от инфлюенсеров постоянно звучат обещания высоких зарплат и множества свободных вакансий. Я часто натыкался на статьи о дефиците IT-специалистов, подкрепленные графиками и статистикой, которые утверждали, что работодатели ищут, но не могут найти подходящих кандидатов. Но что-то подсказывало мне, что «истина где-то рядом», и ее необходимо найти. Я задал себе вопрос: кого же на самом деле ищут работодатели? И на этот раз я не хотел полагаться на стороннюю информацию – мне хотелось самостоятельно разобраться, проанализировать и сделать выводы. К счастью, у меня были все необходимые навыки для этого – не зря же я учился!

В этом контексте я решил использовать Web Scraper и Python, чтобы собрать данные о вакансиях, проанализировать их и понять. Давайте вместе пройдем этот путь: от сбора информации до анализа и выводов о том, чего же на самом деле хотят работодатели.

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

Для сбора информации с веб-сайта я разработал парсер, использующий инструмент Web Scraper. Этот парсер предназначен для извлечения данных о вакансиях аналитиков данных с платформы HeadHunter. Ниже представлен код, который позволяет собирать информацию в формате JSON. В данном коде предусмотрены селекторы для извлечения различных характеристик вакансий, таких как название должности, имя работодателя, уровень зарплаты, опыт работы, возможность удаленной работы и местоположение. Также предусмотрена возможность получения ссылки на каждую вакансию.

{"_id":"data_analyst_hh_2","startUrl":["https://hh.ru/search/vacancy?text=%D0%90%D0%BD%D0%B0%D0%BB%D0%B8%D1%82%D0%B8%D0%BA+%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D1%85&salary=&ored_clusters=true&experience=noExperience&area=113&hhtmFrom=vacancy_search_list&hhtmFromLabel=vacancy_search_line&page=[0-33]"],"selectors":[{"id":"info","parentSelectors":["_root"],"type":"SelectorElement","selector":"div.vacancy-info--I4f9shQE53f9Luf5lkMw","multiple":true},{"id":"vacancy","parentSelectors":["info"],"type":"SelectorText","selector":".magritte-text_typography-title-4-semibold___vUqki_3-0-12 span","multiple":false,"regex":""},{"id":"employer","parentSelectors":["info"],"type":"SelectorText","selector":".company-name-badges-container--o692jpSdR2R4SR9oXuZJ span.magritte-text___tkzIl_4-2-2","multiple":false,"regex":""},{"id":"salary","parentSelectors":["info"],"type":"SelectorText","selector":".compensation-labels--uUto71l5gcnhU2I8TZmz span","multiple":false,"regex":""},{"id":"experience","parentSelectors":["info"],"type":"SelectorText","selector":".wide-container-magritte--MZDT2K1sum_GdjUzT50m div.magritte-tag__label___YHV-o_3-0-3","multiple":false,"regex":""},{"id":"remote","parentSelectors":["info"],"type":"SelectorText","selector":".wide-container-magritte--MZDT2K1sum_GdjUzT50m div:nth-of-type(2) div","multiple":false,"regex":""},{"id":"place","parentSelectors":["info"],"type":"SelectorText","selector":".wide-container-magritte--MZDT2K1sum_GdjUzT50m .wide-container-magritte--MZDT2K1sum_GdjUzT50m span","multiple":false,"regex":""},{"id":"link","parentSelectors":["info"],"type":"SelectorLink","selector":"a.magritte-link_enable-visited___Biyib_4-2-2","multiple":false,"linkType":"linkFromHref"}]}

Обратите внимание на решение для пагинации - использование прямого указания количества страниц page=[0-33], если будете использовать исправлять нужно будет вручную. Впрочем, разметка сайта регулярно меняется, поэтому парсер может стать неактуальным (этот для разметки августа 2024).

Получил файл со списком вакансий, актуальных 02 августа 2024 по запросу Аналитик данных, переключатель Опыт работы - Нет опыта:

Эксель результат
Эксель результат

Далее подключаем jupyter notebook и начинаем "шевелить" полученный датасет. Здесь буду приводить только некоторые части кода и выводы, проект полностью здесь.

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

# создадим фукцию для вывода общей инфрмации по таблице целиком
def tab_info(df_x, name):
# получение cводной информации по параметрам данных
    print('Сводная информация по параметрам данных', name)
    display(df_x.describe().round(2))
# подсчет количества отсутствующих значений
    print('Количество отсутствующих значений', name)
    display(df_x.isna().sum())
# подсчет доли отсутствующих значений с округлением
    print('Доли отсутствующих значений с округлением', name)
    display(round(df_x.isna().sum() * 100 / len(df_x), 2))
# подсчет количества задублированных записей
    print('Задублированных записей', name)
    display(df_x.duplicated().sum())    
# получение общей информации о данных в таблице
    print('Общая информация о данных в таблице', name)
    display(df_x.info())

Количество строк: 1642. Заполненность по столбцам:

  • vacancy: 1642 заполненных, 100.00%  - название вакансии

  • employer: 1640 заполненных, 99.88% - заработная плата

  • salary: 1005 заполненных, 61.21% - заработная плата

  • experience: 1642 заполненных, 100.00% - опыт

  • remote: 296 заполненных, 18.03% - возможность удаленной работы

  • place: 1642 заполненных, 100.00% - месторасположение вакансии

  • link-href: 1642 заполненных, 100.00% - ссылка на вакансию

Задублированных записей - 37.

Удалим задублированные строки, строки без названия работодателя.

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

# Определяем общее количество строк и количество дубликатов
total_rows = len(df)
duplicate_rows = df.duplicated(subset=['vacancy', 'employer', 'salary', 'experience', 'remote'], keep=False).sum()

# Соотношение дубликатов к общему числу строк
unique_rows = total_rows - duplicate_rows

# Выводим результаты
print(f"Общее количество строк: {total_rows}")
print(f"Количество задублированных строк: {duplicate_rows}")
print(f"Количество уникальных строк: {unique_rows}")

# Данные для круговой диаграммы
labels = ['Уникальные вакансии', 'Задублированные вакансии']
sizes = [unique_rows, duplicate_rows]
colors = ['#66c2a5', '#fc8d62']

# Создаем круговую диаграмму
plt.figure(figsize=(8, 6))
plt.pie(sizes, labels=labels, colors=colors, autopct='%1.1f%%', startangle=90)
plt.title('Соотношение уникальных и задублированных вакансий')
plt.axis('equal')  # Чтобы круг был кругом
plt.show()

Каждая пятая вакансия в поиске оказалась одной и той же! Посмотрим работодателей с такими вакансиями.

# Вывод количества уникальных значений в столбце 'employer'
unique_employers_count = duplicated_df['employer'].nunique()
print(f"Количество уникальных значений в столбце 'employer': {unique_employers_count}")

# Подсчет количества значений в колонке 'employer' и выбор топ-20, сортировка по убыванию
top_employers = duplicated_df['employer'].value_counts().nlargest(20)

# Построение горизонтальной гистограммы
plt.figure(figsize=(10, 6))
bars = plt.barh(top_employers.index[::-1], top_employers.values[::-1], color='skyblue')  # Горизонтальная гистограмма

# Добавление подписей значений
for bar in bars:
    plt.text(bar.get_width(), bar.get_y() + bar.get_height()/2, 
             f'{bar.get_width()}', va='center')

# Добавление заголовка и подписей осей
plt.title('Топ-20 работодателей по количеству задублированных вакансий')
plt.xlabel('Количество ваканский')
plt.ylabel('Работодатель')
plt.grid(axis='x')  # Сетка по оси X

# Показать график
plt.tight_layout()
plt.show()

63 работодателя из 913 имеют вакансии вакансии с разницей только в поле расположения вакансии. Больше все у Яндекс крауд: контент. Удалим такие вакансии. После всех манипуляций осталось: В датасете 1373 строк. В столбце «vacancy» 1373 заполненных значений (100.00% от общего числа) и 1095 уникальных значений (79.75%). В столбце «employer» 1373 заполненных значений (100.00% от общего числа) и 913 уникальных значений (66.50%). В столбце «salary» 830 заполненных значений (60.45% от общего числа) и 376 уникальных значений (27.39%). В столбце «experience» 1373 заполненных значений (100.00% от общего числа) и 1 уникальных значений (0.07%). В столбце «remote» 167 заполненных значений (12.16% от общего числа) и 1 уникальных значений (0.07%). В столбце «place» 1373 заполненных значений (100.00% от общего числа) и 129 уникальных значений (9.40%). В столбце «link-href» 1373 заполненных значений (100.00% от общего числа) и 1373 уникальных значений (100.00%).

vacancy - название вакансии

После удаления символов в названиях вакансий, лемматизации, удаления стоп-слов посмотрим какие слова самые распространенные в названиях вакансий.

# Разбиваем текст на слова и подсчитываем частоты
words = vacancy_without_stopwords.split()
word_counts = Counter(words)

# Получаем 20 самых распространенных слов
top_words = word_counts.most_common(20)

# Подготовка данных для графика
words, counts = zip(*top_words)

# Создание графика
plt.figure(figsize=(10, 5))
bars = plt.bar(words, counts, color='skyblue')
plt.title('Топ 20 слов')
plt.xlabel('Слова')
plt.ylabel('Частота')
plt.xticks(rotation=45)

# Добавление подписей значений над столбцами
for bar in bars:
    yval = bar.get_height()
    plt.text(bar.get_x() + bar.get_width()/2, yval, int(yval), ha='center', va='bottom')

plt.tight_layout()
plt.show()

# Создание облака слов
wordcloud = WordCloud(width=800, height=400, background_color='white').generate_from_frequencies(word_counts)

# Отображение облака слов
plt.figure(figsize=(10, 5))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')  # Отключаем оси
plt.title('Облако слов')
plt.show()

Капитан очевидность: самое распространенное - Аналитик, но в из 1373 строк только 629 упоминаний. Так же часто встречаются: менеджер, бизнес, продажа, системный: так срабатывает поиск, для более четкого выведения вакансий можно искать только в названиях вакансий, а для полного совпадений двух рядом идущих слов взять запрос в кавычки: "Аналитик данных"

employer - название работодателя

Количество уникальных записей в столбце "employer": 913 Посмотрим какие кампании чаще всего встречаются, а значит ищут работников без опыта:

# Подсчет количества значений в колонке 'employer' и выбор топ-10, сортировка по убыванию
top_employers = df_employer['employer'].value_counts().nlargest(30)

# Построение горизонтальной гистограммы
plt.figure(figsize=(10, 6))
bars = plt.barh(top_employers.index[::-1], top_employers.values[::-1], color='skyblue')  # Горизонтальная гистограмма

# Добавление подписей значений
for bar in bars:
    plt.text(bar.get_width(), bar.get_y() + bar.get_height()/2, 
             f'{bar.get_width()}', va='center')

# Добавление заголовка и подписей осей
plt.title('Топ-30 работодателей по количеству вакансий')
plt.xlabel('Количество')
plt.ylabel('Работодатель')
plt.grid(axis='x')  # Сетка по оси X

# Показать график
plt.tight_layout()
plt.show()

БКС начало карьеры, Сбер для экспертов, Магнит, розничная сеть - топы по размещению вакансий для соискателей без опыта, составившие вакансии так, что они попадают в запрос Аналитик данных.

salary - заработная плата

Так как это поле собирается слитой строкой вроде "от 84 000 ₽ до вычета налогов", так же встречаются зарплаты в $, то пришлось потрудиться, что бы нормализовать данные, все танцы с бубнами здесь. В итоге получил таблицу где min - указанная минимальная зарплата, max - максимальная указанная зарплата, только в рублях и только на руки (-13% от до вычета).

Посмотрим распределение величин зарплат:

#Визуализация частотности распределения в столбцах min и max
plt.figure(figsize=(12, 6))

# Гистограмма для столбца min
plt.subplot(1, 2, 1)
plt.hist(df_salary['min'], bins=30, color='blue', alpha=0.7)
plt.title('Распределение min')
plt.xlabel('Значения min')
plt.ylabel('Частота')

# Гистограмма для столбца max
plt.subplot(1, 2, 2)
plt.hist(df_salary['max'], bins=30, color='green', alpha=0.7)
plt.title('Распределение max')
plt.xlabel('Значения max')
plt.ylabel('Частота')

plt.tight_layout()
plt.show()

На графике видны выбросы, ограничим выборку до 200 000. Посмотрим минимумы и максимумы:

# Фильтрация данных
df_salary_min = df_salary[df_salary['min'] < 200000].dropna(subset=['min'])

# Рассчет среднего значения
mean_min = df_salary_min['min'].mean()

# Настройка графика
plt.figure(figsize=(10, 6))

# Использование seaborn для создания гистограммы
sns.histplot(df_salary_min['min'], bins=30, color='blue', kde=True)

# Настройка заголовка и меток
plt.title('Распределение значений в столбце min')
plt.xlabel('Значения min')
plt.ylabel('Частота')

# Добавление вертикальной линии для среднего значения
plt.axvline(mean_min, color='red', linestyle='--', label='Среднее значение')

# Добавление легенды
plt.legend()

# Добавление значений на график
counts, bins = np.histogram(df_salary_min['min'], bins=30)
for count, x in zip(counts, bins):
    plt.text(x, count, str(count), ha='center', va='bottom')

# Добавление текстового значения среднего на график
plt.text(mean_min, max(counts)*0.9, f'Среднее: {mean_min:.2f}', color='red', ha='center')

plt.tight_layout()
plt.show()
# Фильтрация данных
df_salary_max = df_salary[df_salary['max'] < 200000].dropna(subset=['max'])

# Вычисление среднего значения
mean_value = df_salary_max['max'].mean()

# Настройка графика
plt.figure(figsize=(10, 6))

# Использование seaborn для создания гистограммы
sns.histplot(df_salary_max['max'], bins=30, color='green', kde=True)

# Настройка заголовка и меток
plt.title('Распределение значений в столбце max')
plt.xlabel('Значения max')
plt.ylabel('Частота')

# Добавление вертикальной линии для среднего значения
plt.axvline(mean_value, color='red', linestyle='--', label=f'Среднее значение: {mean_value:.2f}')

# Добавление значений на график
counts, bins = np.histogram(df_salary_max['max'], bins=30)
for count, x in zip(counts, bins):
    plt.text(x, count, str(count), ha='center', va='bottom')

# Добавление легенды
plt.legend()

plt.tight_layout()
plt.show()

От 53 000 до 69 000 такова вилка по средней зарплате.

experience - опыт

Так как выбран поиск без опыта, здесь только 1 значение - без опыта.

remote - возможность удаленной работы

Посмотрим берут ли Аналитиков данных на удаленную работу.

# Заменяем пустые значения на "в офисе"
df_remote['remote'] = df_remote['remote'].fillna('в офисе')

# Подсчет значений
count_values = df_remote['remote'].value_counts()

# Определение меток и значений
labels = count_values.index
sizes = count_values.values

# Создание круговой диаграммы
fig1, ax1 = plt.subplots()
ax1.pie(sizes, labels=labels, autopct=lambda p: '{:.0f} ({:.1f}%)'.format(p * sum(sizes) / 100, p), startangle=90)
ax1.axis('equal')  # Рисуем круг

# Заголовок
plt.title('Распределение удалённой работы')
plt.show()

Только 12% предлагают "удаленку". Дальше посмотрим где офисы.

place - месторасположение вакансии

# Подсчет количества упоминаний каждого города и сортировка по убыванию
place_counts = df_place['place'].value_counts().head(20).sort_values()

# Визуализация: Горизонтальная столбчатая диаграмма
plt.figure(figsize=(12, 8))
bars = plt.barh(place_counts.index, place_counts.values, color='lightgreen')  # Изменен цвет на светло-зеленый

# Добавляем подписи значений на столбцы (абсолютные и относительные)
total_mentions = place_counts.sum()
for bar in bars:
    xval = bar.get_width()
    relative_val = (xval / total_mentions) * 100  # Вычисляем относительное значение
    plt.text(xval, bar.get_y() + bar.get_height()/2, f'{int(xval)} ({relative_val:.1f}%)', va='center', ha='left')

plt.title('Топ 20 городов по количеству упоминаний', fontsize=16)
plt.xlabel('Количество упоминаний', fontsize=14)
plt.ylabel('Города', fontsize=14)
plt.grid(axis='x')
plt.tight_layout()
plt.show()

# Визуализация: Облако слов
wordcloud = WordCloud(width=800, height=400, background_color='white').generate(' '.join(df_place['place']))

plt.figure(figsize=(12, 8))
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis('off')  # Убираем оси
plt.title('Облако слов городов', fontsize=16)
plt.show()

Больше половины - в Москве, при 129 оригинальных месторасположений в датасете.

Работодатели по заработной плате

Эта часть получилась невнятной, так как брал всю выборку (может быть был смысл ограничить меньше 200 000).

# Сортируем данные по max_average и min_average и выбираем топ-30
top_30_max = grouped_df.nlargest(30, 'max_average')
top_30_min = grouped_df.nlargest(30, 'min_average')

# Создание графика для max_average
plt.figure(figsize=(12, 10))
plt.barh(top_30_max['employer'], top_30_max['max_average'], color='skyblue')
plt.xlabel('Среднее значение max')
plt.title('Топ 30 работодателей по среднему значению max')
plt.gca().invert_yaxis()  # Инвертируем ось Y для удобства
plt.show()

# Создание графика для min_average
plt.figure(figsize=(12, 10))
plt.barh(top_30_min['employer'], top_30_min['min_average'], color='lightgreen')
plt.xlabel('Среднее значение min')
plt.title('Топ 30 работодателей по среднему значению min')
plt.gca().invert_yaxis()  # Инвертируем ось Y для удобства
plt.show()

Выводы

Таким образом: при поиске на сайте 20% вакансий будет одной и той же с разницей только в городе. При поиске без доп настроек в выборку часто будут входить вакансии из других областей: системный анализ, продажи... от 53 000 до 69 000 - средние значения заработных плат после налогов (на руки). Только 12% удаленных вакансий и больше 50% всех вакансий - в Москве.

Всем, кто в поисках работы - удачи! И мне тоже!

Комментарии (4)


  1. sixxio
    05.08.2024 12:17
    +4

    Один вопрос у меня остался без ответа - зачем парсить hh.ru, когда у них есть приличный API?
    Вы же сами потом уточняете, что ваше решение актуально в данный момент и разметка может поменяться, придется что-то исправлять..
    Понятно, что и в API может многое поменяться, но это будет реже и не так глобально, так как на нем реализованы интеграции, типа HR CRM.


    1. andrey8352 Автор
      05.08.2024 12:17
      +1

      Да, есть и API, но web scraper, мне показался, универсальным инструментом для несложных задач парсинга в целом, поэтому тренировался на нем. И еще момент: браузерный парсер соберет то, что видит пользователь, по API этот момент "под капотом".


      1. select26
        05.08.2024 12:17
        +3

        Ничего подобного - парсер получит то, что разрешено отдавать ботам, если вообще что-то разрешено. 2024 год сейчас. Без Bot protection никуда..


      1. Vic12345
        05.08.2024 12:17

        Ага, хороший инструмент, только не для этого случая. Разработчик, помимо всего прочего, и инструмент должен выбирать с умом, чтобы минимизировать затраты. А чтобы не предполагать, а знать что из под капота отдает API, сделайте аналогичный запрос и сравните количество вакансий.