CoderCastrov logo
CoderCastrov
Парсер

Оптимизация ATS, часть 1 - получение данных LinkedIn через парсинг веб-страниц - руководство для начинающих

Оптимизация ATS, часть 1 - получение данных LinkedIn через парсинг веб-страниц - руководство для начинающих
просмотров
14 мин чтение
#Парсер

Мотивация для двухчастной серии статей

Чего ожидать

Содержание

Планируется двухчастная статья: в первой части мы рассмотрим, как получить данные для дальнейшего анализа, например, "корпус" предложений о работе / описаний работы, отражающих текущее состояние дел в интересующей вас области.

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

Таким образом, первая часть в основном является "парсингом веб-страниц" с акцентом на веб-сайтах карьерного развития. Это "трудолюбивая" часть, не являющаяся самостоятельно "аналитической", но предоставляющая основу для дальнейшего анализа.

Вторая часть будет посвящена извлечению искомых инсайтов из собранных данных. Это настоящая "аналитическая" часть. Поскольку мы собираем текстовые данные (например, предложения о работе / профили работы / описания работы), мы будем входить в аналитическую область NLP (обработка естественного языка).

И поскольку нашей целью является выявление определенных паттернов и/или общих черт в большом количестве различных предложений о работе, мы будем использовать техники "извлечения тем". Эти техники в основном предоставляют средство для синтеза тем из коллекции текстов (таких как предложения о работе в виде отдельных документов, создающих вместе "корпус" текстов), создавая кластеры слов с высоким уровнем совместного встречаемости и значимости (указывающих на то, что любой такой найденный кластер обращается к общей, лежащей в основе теме или, другими словами, к более крупным трендам, которые мы ищем).

Если вы уже имели первый контакт с другими методами машинного обучения, это может звучать немного как "кластеризация k-средних", очень популярный алгоритм кластеризации. Есть некоторые сходства, но пожалуйста, будьте внимательны, они НЕ одинаковы.

Прогноз на вторую часть: визуализация из LDA-анализа предложений о работе:

Поддерживающие документы

Статьи будут ссылаться на репозиторий GitHub, поддерживающий две статьи (см. ниже в этой статье). Код работает на октябрь 2022 года. Я специально говорю "на октябрь 2022 года", так как, например, разбор части зависит от структуры HTML-разметки веб-сайтов, с которых собираются данные.

Как вы увидите, структура кода зависит от деталей и соглашений системы управления контентом (CMS), используемой для управления скрапингом сайтов - и, следовательно, от прихотей и целей тех, кто управляет CMS.

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

Однако я постараюсь дать некоторые подсказки, которые позволят вам адаптировать код к любым таким изменениям с небольшим техническим пониманием: если код, который раньше работал без проблем, внезапно перестает работать, оставайтесь спокойными, наблюдайте, что может вызвать проблему и применяйте здравый смысл! Смотрите сообщения об ошибках, обновляйте пакеты в вашей среде... или научитесь игнорировать предупреждения, пока "rien ne va plus".

Парсинг веб-страниц: часть 1

Прежде всего: если вас интересует только "рецепт кода" и не интересует процесс его создания и мыслительный процесс, вы можете сразу перейти к разделу "Код" и скачать соответствующую записную книжку Jupyter с GitHub.

Почему

Я хотел создать инструмент, который позволит мне быть в курсе требований и изменений в моей профессиональной области, то есть в финансовой сфере.

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

Технически гораздо проще иметь большой набор результатов в одном файле, чем просматривать, например, несколько страниц с 25-шаговыми вакансиями.

Результат также может быть использован для автоматизации ATS (систем отслеживания заявок). Поэтому название этой статьи, так как я предполагаю, что у большинства людей это будет более распространенный случай использования, чем мониторинг широких тенденций в их области.

ATS (системы отслеживания заявок) используются компаниями или рекрутерами для автоматического отслеживания поступающих заявок. Они используют алгоритмы машинного обучения для проверки заявок на соответствие открытым позициям. Это означает, что при подаче заявки вы вступаете в своего рода игру в кошки-мышки: чем лучше вы предвидите ожидания компании (отраженные как в описании вакансии, так и в алгоритме, используемом ATS), тем больше вероятность того, что ваша заявка пройдет на следующий этап. Анализ большого количества вакансий поможет вам определить "факторы гигиены", то есть стандартные флажки, которые ваше резюме и сопроводительное письмо ДОЛЖНЫ отметить, чтобы пройти фильтр ATS для желаемой работы. Конкретные детали "что соответствует ATS", конечно, будут различаться в зависимости от области, отрасли и иерархического уровня, на который вы подаете заявку.

Как

Выбранный сайт

Я с самого начала выбрал LinkedIn. Это очевидный выбор, так как он зарекомендовал себя как МЕЖДУНАРОДНЫЙ сайт по поиску работы. Но вы можете рассмотреть и другие сайты по поиску работы / найму персонала. Например, часто существуют специализированные сайты для определенных профессиональных областей или отраслей. Все зависит от того, что вам нужно или что вы ищете. Но LinkedIn - это место, куда стоит обратиться, если вы хотите начать широкий поиск работы, и поэтому стоит написать код для этого (опять же, не затрагивая все потенциальные юридические аспекты). Соответственно, код из этой статьи может служить только вдохновением, если вы ищете контент на других сайтах: в этом случае логика и детали парсинга должны быть адаптированы к структуре и логике этих специализированных сайтов.

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

Технический подход

Парсинг: статические и динамические сайты

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

Два самых популярных пакета для этого в Python - BeautifulSoup и Selenium.

Мне потребовалось некоторое время, чтобы понять, если и как они отличаются. Но, изучив детали, я понял, что:

  • BeautifulSoup прекрасно работает с полными статическими HTML-страницами. Если вы можете вызвать страницу с определенным URL-адресом (подумайте: скопировать и вставить URL-адрес в браузер) - и после этого вся информация, которую вы хотите извлечь (спарсить), находится на этой странице с ее HTML-кодом... вы должны использовать BeautifulSoup. Это автономный пакет, который притворяется браузером, получает полные HTML-страницы и позволяет вам выбирать части из этого кода.

  • Selenium: определенно более сложный (и также требует некоторой начальной настройки). Это не автономный пакет, но он используется для запуска реального браузера, а затем вы управляете браузером с помощью Selenium. Преимущество Selenium проявляется, когда контент с сайтов обслуживается динамически. Это означает, что часть контента, которую вам нужно получить, НЕ доступна непосредственно в исходном HTML-коде, а открывается только при, например, нажатии кнопки или выборе поля из выпадающего списка.

Логика сайта LinkedIn 'поиск работы'

Как вы увидите, сайт LinkedIn динамически обслуживает свой контент, поэтому будет использоваться Selenium. Так как я был полностью новичком в этом, мне нужно было найти некоторые вдохновения. И я нашел их в следующих статьях Medium, которые подробно объясняют многое:

У меня уже был работающий код около 6 месяцев назад, но за это время LinkedIn изменил логику поиска и способ предоставления информации в ответ на URL-запросы и нажатия кнопок, а также ввел фильтры. Текущий код отражает эти изменения и работает на октябрь 2022 года.

Изменение логики поиска: раньше LinkedIn позволял указывать географическое местоположение (город, штат, страну) в поиске ПЛЮС радиус вокруг него. Таким образом, вы могли, например, ввести Дюссельдорф плюс 100 миль и получить все предложения для мест в радиусе около 160 км от Дюссельдорфа (включая множество предложений из Бельгии и Нидерландов). Это было полностью отменено в пользу системы геоID. Любой ручной поиск, отфильтрованный по местоположению, возвращает геоID в URL-адресе страницы результатов, который вы можете использовать для определения местоположения, для которого вы хотите выполнить поиск с помощью вашего кода. Я не полностью понимаю методологию работы системы геоID, но поиск, например, "Германия" или "Франция" всегда показывает один и тот же геоID в URL-адресе. И использование этого геоID в коде надежно дает результаты только из выбранной страны. То же самое относится, если вы приближаетесь к штатам, регионам или отдельным городам. Короче говоря, это работает... но я не могу объяснить на 100%, как работает логика геоID.

Изменение логики предоставления информации: это будет подробно объяснено ниже. Короткая история: раньше подробная информация о вакансиях (так называемые карточки) немедленно загружалась для всех 25 вакансий, обычно отображаемых на одной странице результатов при вызове URL-адреса страницы результатов. Теперь только первые 7 (из 25) карточек результатов загружаются в ответ на вызов URL-адреса. После изменений был добавлен дополнительный шаг "искусственной прокрутки", чтобы принудительно загрузить все 25 карточек и их подробности перед тем, как можно было бы распарсить детали.

Логика фильтра: аналогично геоID, настройки дополнительных фильтров теперь также жестко закодированы в URL-адресе поиска. Это важно, потому что в моем случае меня интересовали только руководящие роли. Это можно установить, например, установив фильтр "опыт работы" ("Berufserfahrung" с немецкими настройками) для отображения только вакансий с уровнем "Директор / Вице-президент" или "C-Level". Мне понадобилось немного интуиции, чтобы понять, что это приводит к части "f_E=5%2C6" в URL-адресе поиска (... и я предполагаю, что "f_E" означает "фильтр уровня занятости"), но это надежно работает.

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

На левой панели (обозначено зеленым) вы видите список вакансий в результате вашего поиска. В терминах CMS LinkedIn это "карточки" (вы можете узнать это, изучив код с помощью известной комбинации клавиш CTRL+SHFT+I).

Первая отдельная карточка обозначена как "2" на изображении. Правая панель отображает детали текущей выбранной карточки (обозначена как "3"). Она содержит детали, которые нас интересуют (и которые мы хотим записать в таблицу данных для дальнейшего анализа).

Наконец, обозначенное как "1", вы видите общее количество результатов, найденных по вашему запросу. Эта цифра важна, так как CMS LinkedIn систематически показывает только 25 карточек из общего результата за раз.

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

  1. Импортировать необходимые пакеты
    • 4b) Определить параметры поиска 4a) Войти в систему 4c) Цикл 1: Пройти через общее количество результатов с шагом 25 результатов на страницу ____Цикл 2: Пройти через 25 карточек текущей страницы результатов ____Прокрутить левую панель с карточками результатов, чтобы убедиться, что все 25 карточек и их подробности загружены (этот шаг стал необходимым из-за последних изменений со стороны LinkedIn) ____Нажать на каждую карточку, чтобы отобразить ее подробности ____Спарсить подробную информацию из деталей карточки 4d) Преобразовать список спарсенных результатов в таблицу данных 4d) Сохранить таблицу данных в файл

Код

1) Импорт пакетов

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
import re as re
import time
import pandas as pd
import random

2) Объявление переменных (глобальных)Настройка опций Selenium и Pandas

options = Options()
options.add_argument("start-maximized")
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None)

Получение комбинации пользователя/пароля для входа в LinkedIn

USERNAME = input("Введите имя пользователя: ")
PASSWORD = input("Введите пароль: ")
print(USERNAME)
print(PASSWORD)

Теперь мы определяем детали поисковой строки. Это важная часть, где устанавливаются критерии фильтрации для поиска работы.- должность (по ключевому слову)- местоположение (по текстовой строке и, что более важно, по идентификатору геолокации; я еще не проверял, что произойдет, если в поиске указан только идентификатор геолокации, без строки — но я предполагаю, что идентификатор геолокации переопределит все остальное)- уровень должности (т.е. выбраны только руководящие роли или также другие типы, такие как временные, начальные и т.д.)

[!CAUTION] Другими словами, это часть кода, в которой вы будете вмешиваться, когда захотите настроить результаты под свои потребности.

Как уже объяснялось выше, вам нужно будет сначала поиграться с ручным поиском, чтобы выяснить, например, идентификатор геолокации или другие фильтры, которые подходят вашим целям.

keywords4search = ['Директор финансов']
s_search_loc = 'Deutschland%2C%20Österreich%20und%20die%20Schweiz'
s_geo_loc = 'geoId=91000006' # 'geoId=91000006' = DACH
s_pos_level = 'f_E=5%2C6' # только директор, вице-президент или уровень C

3) Определения функций

Определение функции "искусственной прокрутки": вызывается URL-адрес поиска. Но после последних изменений в LinkedIn-CMS не все 25 карточек и их детали загружаются сразу. LinkedIn-CMS называет эти карточки "disabled ember-view job-card-container__link job-card-list__title". Код проверяет, сколько из этих элементов было найдено. Если число меньше 25, то Selenium принуждает загрузку дополнительных карточек и их данных путем прокрутки вниз (driver.execute_script("return arguments[0].scrollIntoView();", card_list[len(card_list)-1])) и проверяет, видны ли теперь 25 карточек. Прокрутка повторяется, пока не будет достигнуто значение 25... или пока не будет сделано 7 попыток прокрутки. Последняя проверка выполняется с помощью переменной v_while_cycles. Наблюдая за результатами, полученными кодом, можно проверить результат прокрутки.

Функция возвращает переменную card_list, которая является объектом Selenium, содержащим все 25 карточек И связанные с ними детали карточки.

def f_artificial_scrolling(search_URL): v_while_cycles = 0 card_list_length = 0 card_list_length_old = 0 driver.get(search_URL) card_list = driver.find_elements(By.CLASS_NAME,’.’.join(“disabled ember-view job-card-container__link job-card-list__title”.split())) card_list_length = (len(card_list)) print( f’CONTROL1: card_list_length: value = {card_list_length} and card_list_length_old: value = {card_list_length_old}’) while (card_list_length < 25) and (v_while_cycles < 8): v_while_cycles += 1 driver.execute_script(“return arguments[0].scrollIntoView();”, card_list[len(card_list)-1]) card_list = driver.find_elements(By.CLASS_NAME,’.’.join(“disabled ember-view job-card-container__link job-card-list__title”.split())) card_list_length_old = card_list_length card_list_length = (len(card_list)) print( f’CONTROL2: card_list_length: value = {card_list_length} and card_list_length_old: value = {card_list_length_old}’) return (card_list)

Следующая функция вызывается после щелчка по отдельной карточке: для этой карточки возвращается список, содержащий детали этой карточки. Для каждой детали сначала устанавливается "начальное значение", а затем выполняется блок try/except, который позволяет увидеть из результата, удалось ли коду найти элементы (такие как by.CLASS_NAME) в деталях карточки. Это фактическое скрапинга содержимого!

def f_scrap_position_details(): # Детали, которые нужно спарсить из отдельной карточки aka вакансии JobTitle = 'начальное значение' JobTitleLink = 'начальное значение' CompanyName = 'начальное значение' CompanyLocation = 'начальное значение' CompanySize = 'начальное значение' PositionLevel = 'начальное значение' JobDescDetails = 'начальное значение' try: JobTitle = driver.find_element(By.CLASS_NAME,"jobs-unified-top-card__job-title").text except: JobTitle = "JobTitle не найден" try: JobTitleLink = driver.find_element(By.CLASS_NAME,"jobs-unified-top-card__content — two-pane").find_element(By.TAG_NAME,"a").get_attribute('href') except: JobTitle = "JobTitleLink не найден" try: CompanyName = driver.find_element(By.CLASS_NAME,"jobs-unified-top-card__company-name").find_element(By.TAG_NAME,"a").text except: CompanyName = "CompanyName не найдено" try: CompanyLocation = driver.find_element(By.CLASS_NAME,"jobs-unified-top-card__bullet").text except: CompanyLocation = "CompanyLocation не найдено" JobDescElements = driver.find_element(By.CLASS_NAME,"jobs-description-content__text").find_elements(By.TAG_NAME,"span") for element in JobDescElements: try: if len(element.text) > 50: JobDescDetails = element.text break else: JobDescDetails = "Это не те JobDescDetails, которые вы искали" except: JobDescDetails = "JobDescDetails не найден" try: CompanyInsights = driver.find_elements(By.CLASS_NAME,'.'.join("jobs-unified-top-card__job-insight".split())) PositionLevel = CompanyInsights[0].text CompanySize = CompanyInsights[1].text except: PositionLevel = "PostionLevel не найден" CompanySize = "CompanySize не найден" l_return = [JobTitle, JobTitleLink, CompanyName, CompanyLocation, CompanySize, PositionLevel, JobDescDetails] return (l_return)

4) Фактическая программа

4a) Установка соединения с веб-сайтом с учетными данными

# Используйте драйвер для открытия ссылкиdriver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)driver.get(“[https://www.linkedin.com/uas/login](https://www.linkedin.com/uas/login)")time.sleep(4) # Используйте учетные данные для входа в системуemail=driver.find_element(By.ID, “username”)email.send_keys(USERNAME)password=driver.find_element(By.ID, “password”)password.send_keys(PASSWORD)time.sleep(3)password.send_keys(Keys.RETURN)

4b) Создание searchURL и получение начальной страницы с результатами

[!CAUTION] Обратите внимание, что вы должны изменить конструкцию строки s_searchURL в случае, если вы хотите включить дополнительные переменные (лучше определить их выше), например, другие фильтры, кроме s_pos_level

s_searchURL = f’[https://www.linkedin.com/jobs/search/?&{s_pos_level}&{s_geo_loc}&distance={s_search_distance}&keywords={s_search_pos}&location={s_search_loc}'](https://www.linkedin.com/jobs/search/?%7Bs_pos_level%7D=&%7Bs_geo_loc%7D=&distance=%7Bs_search_distance%7D&keywords=%7Bs_search_pos%7D&location=%7Bs_search_loc%7D%27)
print(s_searchURL)[https://www.linkedin.com/jobs/search/?&f_E=5%2C6&geoId=91000006&distance=15&keywords=Director%20Finance&location=Deutschland%2C%20](https://www.linkedin.com/jobs/search/?f_E=5%2C6&geoId=91000006&distance=15&keywords=Director+Finance&location=Deutschland%2C+)Österreich%20und%20die%20Schweiz
driver.get(s_searchURL)```

Инициализация списка результатов; получение и сохранение основной информации о поиске:- Общее количество результатов- Количество страниц с карточками (по 25 на страницу), по которым должен пройти основной цикл

link = driver.current_url
no_posts = int(driver.find_element(By.CLASS_NAME,’.’.join(“display-flex t-12 t-black — light t-normal”.split())).text.split(‘ ‘)[0])print(f’Для должности {s_search_pos} доступно {no_posts} вакансий.’)Для должности Director%20Finance доступно 892 вакансии.
npages = (no_posts//25)+1print(f’Количество страниц с результатами: {npages}’)Количество страниц с результатами: 36```

4c) Основной цикл

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

for i in range(0, npages*25, 25): print("Страница:",int((i/25)+1),"из",npages) s_searchURL = link+"?start="+str(i) time.sleep(7) loop = 0 card_list = f_artificial_scrolling(s_searchURL) print ('Длина списка карточек на этой странице: '+ str(len(card_list))) while True: try: current_card = card_list[loop] loop+=1 current_card.click() time.sleep(random.randint(1,6)) try: result.append(f_scrap_position_details()) except: pass except: break

df = pd.DataFrame(result, columns=["Название вакансии", "Ссылка на вакансию", "Название компании", "Местоположение компании", "Размер компании", "Уровень должности", "Описание вакансии"])
# Заменить переносы строк из HTML с помощью регулярного выражения
df['Описание вакансии'] = df['Описание вакансии'].replace(r'\s+|\\n', ' ', regex=True)
# Разделить длинную ссылку, чтобы оставить только основную часть, прямую ссылку на вакансию
df[['Прямая ссылка на вакансию', 'мусор']] = df['Ссылка на вакансию'].str.split("?", n=1, expand=True)
df = df.drop(['Ссылка на вакансию', 'мусор'], axis=1)
df.to_excel("_LINKEDIN_JOB_POSTINGS.xlsx")

Конец части 1

И вот ссылка на полный Python Notebook:

ATS/LinkedIn_Scrape_V30b.ipynb at main · syrom/ATS

Contribute to syrom/ATS development by creating an account on GitHub.

github.com

Что дальше

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