В современном мире анализ рынка труда становится критически важным как для соискателей, ищущих актуальные возможности, так и для компаний, изучающих конкурентную среду. Для решения этой задачи были выбраны два ключевых ресурса — HH.ru и SuperJob.

HH.ru и Superjob

Эти платформы не случайно стали лидерами в русскоязычном сегменте: они охватывают более 80% вакансий в СНГ, обеспечивают структурированные данные и регулярно обновляют контент. Однако их архитектура и подход к представлению информации различаются, что делает парсинг одновременно сложным и интересным с технической точки зрения.

Выбор C# и WPF

Что касается выбора технологий, то C# и WPF оказались идеальным тандемом для реализации проекта. C#, как язык с богатой экосистемой библиотек (например, AngleSharp для парсинга или HttpClient для запросов), предоставляет инструменты для эффективной работы с сетевыми ресурсами и обработки больших объемов данных. Кроме того, его строгая типизация и поддержка асинхронного программирования минимизируют риски ошибок при работе с внешними API и нестабильными соединениями.

WPF, в свою очередь, позволил создать интуитивно понятный desktop-интерфейс для визуализации результатов. В отличие от веб-решений или кросс-платформенных, WPF предлагает глубокую интеграцию с ОС Windows, высокую производительность при работе с графикой и гибкость в кастомизации — ключевые факторы для приложения, где важна скорость отклика и удобство фильтрации данных.

Почему не Python или JavaScript?

Хотя эти языки часто используются для парсинга, C# выигрывает за счет многопоточности, безопасности типов и удобства сопровождения кода в долгосрочной перспективе. Кроме того, WPF дает возможность упаковать проект в автономный EXE-файл, что упрощает распространение среди пользователей, не требующих установки дополнительных runtime-сред.

В этой статье мы разберем, как объединить мощь C# для бэкенда и элегантность WPF для фронтенда, чтобы создать инструмент, который не только собирает данные, но и превращает их в ценную информацию.

Подготовка к разработке: изучаем сайты, выбираем инструменты и настраиваем проект

Изучение структуры HH.ru и SuperJob

Парсинг начинается с понимания того, как устроены целевые сайты. Оба ресурса имеют свои особенности:

Название сайта

HH.RU

SuperJob

HTML‑разметка

Вакансии хранятся в блоках с классом magritte-redesign

Вакансии хранятся в блоках с классомf-test-search-result-item

Поиск вакансий

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

Для указания наименований или фильтрации по городам, состоящих из нескольких слов, в адресной строке необходимо заменить пробелы на %20

Выбор библиотек и инструментов

Для парсинга на C# вам понадобятся следующие сторонние библиотеки:

  • AngleSharp - одна из популярных библиотек для парсинга HTML

  • Newtonsoft.Json - полезная библиотека, если данные доступны через API

  • Epplus - библиотека, позволяющая генерировать Excel файлы

Для получения городов и населенных пунктов России в статье будет использоваться API от HH.ru.

Структура проекта

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

MainWindow.xaml
MainWindow.xaml
<Window x:Class="SearchJobParser.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:SearchJobParser"
        mc:Ignorable="d"
        Title="Парсер сайта поиска работ" Icon="Resources/search.ico" MaxHeight="350" MinHeight="350" MaxWidth="500" MinWidth="500">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="0.05*"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="0.05*"/>
        </Grid.RowDefinitions>
        <Frame x:Name="mainFrame" Grid.Row="1"/>
        <Grid Grid.Row="0" Style="{StaticResource rowBorder}"/>
        <Grid Grid.Row="2" Style="{StaticResource rowBorder}"/>
        
    </Grid>
</Window>
MainWindow.xaml.cs
using SearchJobParser.Class.Service;
using SearchJobParser.Pages;
using System.Windows;

namespace SearchJobParser
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
            mainFrame.Navigate(new mainPage());
            mainFrame.NavigationUIVisibility = System.Windows.Navigation.NavigationUIVisibility.Hidden;
            Manager.mainFrame = mainFrame;
        }
    }
}

Для отображения стилей приложения используется следующий файл App.xaml:

App.xaml
<Application x:Class="SearchJobParser.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:SearchJobParser"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
        <Style TargetType="Border">
            <Setter Property="CornerRadius" Value="5"/>
        </Style>
        <Style TargetType="TextBox">
            <Setter Property="Width" Value="300"/>
            <Setter Property="Height" Value="27"/>
            <Setter Property="Margin" Value="0 10 0 0"/>
            <Setter Property="FontSize" Value="16"/>
        </Style>
        <Style TargetType="Button">
            <Setter Property="Width" Value="100"/>
            <Setter Property="Height" Value="30"/>
            <Setter Property="Margin" Value="5 15 0 0"/>
            <Setter Property="ItemsControl.Background" Value="PeachPuff"/>
        </Style>
        <Style TargetType="TextBlock">
            <Setter Property="FontSize" Value="16"/>
        </Style>
        <Style x:Key="rowBorder">
            <Setter Property="ItemsControl.Background" Value="Peru"/>
        </Style>
        <Style x:Key="preview">
            <Setter Property="ItemsControl.Height" Value="30"/>
            <Setter Property="ItemsControl.Width" Value="30"/>
            <Setter Property="ItemsControl.Margin" Value="400 -25 0 0"/>
        </Style>
        <Style x:Key="JobTitle">
            <Setter Property="ItemsControl.FontWeight" Value="Bold"/>
            <Setter Property="ItemsControl.FontSize" Value="20"/>
            <Setter Property="ItemsControl.Margin" Value="5 5 0 0"/>
            <Setter Property="ItemsControl.MaxWidth" Value="300"/>
        </Style>
        <Style x:Key="main">
            <Setter Property="ItemsControl.Background" Value="FloralWhite"/>
        </Style>
        <Style x:Key="status">
            <Setter Property="ItemsControl.Margin" Value="0 15 0 0"/>
            <Setter Property="ItemsControl.FontSize" Value="16"/>
        </Style>
        <Style x:Key="cities">
            <Setter Property="ItemsControl.Width" Value="330"/>
            <Setter Property="ItemsControl.Height" Value="27"/>
            <Setter Property="ItemsControl.Margin" Value="20 5 0 0"/>
        </Style>
        <Style x:Key="jobs">
            <Setter Property="ItemsControl.Width" Value="150"/>
            <Setter Property="ItemsControl.Height" Value="27"/>
            <Setter Property="ItemsControl.Margin" Value="120 5 0 0"/>
        </Style>
        <Style x:Key="text">
            <Setter Property="ItemsControl.Margin" Value="140 2.5 0 0"/>
            <Setter Property="ItemsControl.FontSize" Value="14"/>
        </Style>
        <Style TargetType="WrapPanel">
            <Setter Property="HorizontalAlignment" Value="Center"/>
        </Style>
        <Style x:Key="Img">
            <Setter Property="ItemsControl.Width" Value="20"/>
            <Setter Property="ItemsControl.Height" Value="20"/>
            <Setter Property="ItemsControl.Margin" Value="0 0 5 0"/>
            <Setter Property="ItemsControl.HorizontalAlignment" Value="Left"/>
        </Style>
    </Application.Resources>
</Application>

Для хранения созданы следующие директории:

  • Pages - директория, где хранятся страницы

  • Class - директория, где хранится бизнес логика приложения

  • Class/Engine - директория, где хранится логика парсера

  • Class/Model - директория, где хранятся классы для парсинга

  • Class/Service - директория, где хранится иная логика

  • Resources - директория, где хранятся иконки и изображения для приложения

Разработка

Директория Pages

Приложение использует три ключевые страницы, реализующие полный цикл работы: от поиска вакансий до детального просмотра. Эти страницы — «лицо» приложения. Они показывают, как связать парсинг, бизнес-логику и UX в единый продукт. Код демонстрирует ключевые принципы:

  • Инкапсуляция (каждая страница решает свою задачу),

  • Reactive UI (события мыши, динамическое обновление),

  • Масштабируемость (легко добавить поддержку нового сайта).

    В директории находятся следующие файлы:

  • mainPage.xaml - страница, которая производит сбор параметров для парсинга (профессия, город, сайт)

  • resultPage.xaml - страница, которая отображает результат парсинга данных

  • exrtaInfoPage.xaml - страница, которая отображает подробную информацию о вакансии

СтраницаmainPage.xaml отображается при открытии приложения. При клике на ComboBox загружаются регионы России через API HH.ru. Если выбран регион с городами, появляется второй ComboBox для уточнения.

При выборе Москвы или Санкт-Петербурга в API города и населенные пункты отсутствуют и перенесены в ЛО и МО.

При переходе resultPage.xaml происходит отображение распарсенных данных в ListBox. При нажатии на кнопку данные сохраняются и экспортируются в Excel.

Двойной клик по вакансии открывает страницу extraInfoPage. Кнопка «Открыть» запускает системный браузер. Логотип HH.ru/SuperJob подгружается в зависимости от JobSiteName.

Pages
mainPage.xaml
mainPage.xaml
<Page x:Class="SearchJobParser.Pages.mainPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
      xmlns:local="clr-namespace:SearchJobParser.Pages"
      mc:Ignorable="d" 
      d:DesignHeight="280" d:DesignWidth="500"
      Title="mainPage">
    <Grid>
        <StackPanel Grid.Row="1" Style="{StaticResource main}">
            <TextBlock x:Name="tbStatus" Style="{StaticResource status}"/>
            <TextBox Name="tbxSearch"/>
            <WrapPanel>
                <TextBlock Text="Выбор города" Style="{StaticResource text}"/>
                <ComboBox Name="cmbParentCities" Style="{StaticResource cities}"/>
                <TextBlock Text="Выбор пригорода" Style="{StaticResource text}"Name="tbChildCities"/>
                <ComboBox Name="cmbChildCities" Style="{StaticResource cities}"/>
                <TextBlock Text="Выбор сайта" Style="{StaticResource text}"/>
                <ComboBox Name="cmbJobSite" Style="{StaticResource jobs}"/>
            </WrapPanel>
            <Button x:Name="btnSearch" Content="Поиск"/>
        </StackPanel>
    </Grid>
</Page>
mainPage.xaml.cs
using SearchJobParser.Class.Engine;
using SearchJobParser.Class.Model;
using SearchJobParser.Class.Service;
using System.Windows;
using System.Windows.Controls;

namespace SearchJobParser.Pages
{
    /// <summary>
    /// Interaction logic for mainPage.xaml
    /// </summary>
    public partial class mainPage : Page
    {
        private ResourceCities instance = new ResourceCities();
        private EngineParser parsing;
        private string[] parentCities = null;
        private string[] childCities = null;
        private string city = null;
        private string job = null;
        private string link = null;

        public mainPage()
        {
            InitializeComponent();
            cmbJobSite.ItemsSource = new string[] { "HH.RU", 
"SuperJob" };
            cmbJobSite.SelectedIndex = 0;
            cmbChildCities.Visibility = Visibility.Hidden;
            tbChildCities.Visibility = Visibility.Hidden;
            tbxSearch.Text = "Впиши професию";
            tbxSearch.MouseEnter += (sender, e) => tbxSearch.Text = tbxSearch.Text == "Впиши професию" ? "" : tbxSearch.Text;
            tbxSearch.MouseLeave += (sender, e) => tbxSearch.Text = tbxSearch.Text == "" ? "Впиши професию" : tbxSearch.Text;
            cmbParentCities.PreviewMouseDown += (sender, e) => GetParentCities();
            cmbParentCities.SelectionChanged += (sender, e) => GetChildCities(cmbParentCities.SelectedValue.ToString());
            btnSearch.Click += (sender, e) =>
            {
                if (tbxSearch.Text != "Впиши професию" && tbxSearch.Text != "" && cmbParentCities.SelectedIndex > -1 && (cmbChildCities.Visibility == Visibility.Hidden || cmbChildCities.Visibility == Visibility.Visible && cmbChildCities.SelectedIndex > -1))
                {
                    city = GetSelectedCity();
                    job = GetJobTitle();
                    if (cmbJobSite.SelectedIndex == (int)JobSiteName.HHRU)
                    {
                        parsing = new EngineHH();
                        link = $"https://hh.ru/search/vacancy?text={job}+{city}&from=suggest_post&salary=&ored_clusters=true&only_with_salary=true&hhtmFrom=vacancy_search_list&hhtmFromLabel=vacancy_search_line";
                    }
                    else
                    {
                        parsing = new EngineSJ();
                        link = $"https://russia.superjob.ru/vacancy/search/?keywords={job}%20{city}&payment_defined=1&click_from=facet";
                    }
                    parsing.ParsingVacancies(link);
                    parsing.ParsingNotification += (senderNotice, eNotice) =>
                    {
                        if (eNotice == "Все спарсил")
                            Manager.mainFrame.Navigate(new resultPage(parsing.GetParsingData()));
                        else
                            tbStatus.Text = eNotice;
                    };
                }
                else
                    MessageBox.Show("Проверьте, что все заполнили", "Проверка", MessageBoxButton.OK, MessageBoxImage.Warning);
            };
        }
        private void GetParentCities()
        {
            parentCities = instance.GetCities();
            if (parentCities != null)
                cmbParentCities.ItemsSource = parentCities;
            else
                MessageBox.Show("Произошла ошибка с соединением, проверьте соединение или переоткройте меню выбора", "Ошибка соединения", MessageBoxButton.OK, MessageBoxImage.Error);
        }
        private string GetJobTitle() => tbxSearch.Text.Replace("++", "%2B%2B").Replace("#", "%23").Replace("/", "%2F").Replace(" ", "+");

        private string GetSelectedCity()
        {
            if (cmbParentCities.SelectedValue.ToString() == "Москва" || cmbParentCities.SelectedValue.ToString() == "Санкт-Петербург")
                return cmbParentCities.SelectedValue.ToString();
            else
            {
                string[] temp = cmbChildCities.SelectedValue.ToString().Split('(');
                return temp[0].Trim();
            }
        }
        private void GetChildCities(string childCity)
        {
            childCities = instance.GetChildCities(childCity);
            if (childCities != null)
            {
                cmbChildCities.Visibility = Visibility.Visible;
                tbChildCities.Visibility = Visibility.Visible;
                cmbChildCities.ItemsSource = childCities;
            }
            else
            {
                cmbChildCities.Visibility = Visibility.Hidden;
                tbChildCities.Visibility = Visibility.Hidden;
                cmbChildCities.ItemsSource = null;
            }
        }
    }
}
resultPage.xaml
resultPage.xaml
<Page x:Class="SearchJobParser.Pages.resultPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
      xmlns:local="clr-namespace:SearchJobParser.Pages"
      mc:Ignorable="d" 
      d:DesignHeight="280" d:DesignWidth="500"
      Title="resultPage">
    <Grid>
        <StackPanel Style="{StaticResource main}">
            <ScrollViewer Name="scroll" CanContentScroll="True" Height="150">
                <ListBox x:Name="lbxJobs"/>
            </ScrollViewer>
            <WrapPanel>
                <Button Name="btnBack">
                    <WrapPanel>
                        <Image Source="/Resources/back.png" Style="{StaticResource Img}"/>
                        <TextBlock Text="Назад"/>
                    </WrapPanel>
                </Button>
                <Button Name="btnExport">
                    <WrapPanel>
                        <Image Source="/Resources/report.png" Style="{StaticResource Img}"/>
                        <TextBlock Text="Экспорт"/>
                    </WrapPanel>
                </Button>
            </WrapPanel>
        </StackPanel>
    </Grid>
</Page>
resultPage.xaml.cs
using SearchJobParser.Class.Model;
using SearchJobParser.Class.Service;
using System.Collections.Generic;
using System.Windows;
using System.Windows.Controls;

namespace SearchJobParser.Pages
{
    /// <summary>
    /// Interaction logic for resultPage.xaml
    /// </summary>
    public partial class resultPage : Page
    {
        private static List<ParseData> syncData;
        private ExcelMaker excel = new ExcelMaker();
        public resultPage(object data)
        {
            InitializeComponent();
            btnBack.Click += (sender, e) => Manager.mainFrame.GoBack();
            btnExport.Click += (sender, e) =>
            {
                if(syncData?.Count > 0)
                {
                    excel.SaveData(ref syncData);
                    MessageBox.Show("Спарсенные данные экспортированы в excel файл", "Экспорт данных", MessageBoxButton.OK, MessageBoxImage.Information);
                } 
                else
                    MessageBox.Show("Нет данных", "Экспорт данных", MessageBoxButton.OK, MessageBoxImage.Warning);
            };
            if (data is List<ParseData>)
            {
                syncData = data as List<ParseData>;
                foreach (ParseData item in (data as List<ParseData>))
                    lbxJobs.Items.Add($"{item.JobTitle}\n{item.Salary}\n{item.Format} {item.Worktime}");
            } 
            else
                lbxJobs.Items.Add("пусто");
            lbxJobs.MouseDoubleClick += (sender, e) => Manager.mainFrame.Navigate(new extraInfoPage(syncData[lbxJobs.SelectedIndex]));

        }
    }
}
exrtaInfoPage.xaml
exrtaInfoPage.xaml
<Page x:Class="SearchJobParser.Pages.extraInfoPage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
      xmlns:local="clr-namespace:SearchJobParser.Pages"
      mc:Ignorable="d" 
      d:DesignHeight="280" d:DesignWidth="500"
      Title="extraInfoPage">
    <Grid>
        <StackPanel Style="{StaticResource main}">
            <WrapPanel>
                <TextBlock x:Name="tbJobTitle" Style="{StaticResource JobTitle}"/>
                <Image Name="imgJibSite" Style="{StaticResource preview}"/>
            </WrapPanel>
            <ScrollViewer  Name="scroll" CanContentScroll="True" Height="150" Margin="5">
                <ListBox x:Name="lbxDescription"/>
            </ScrollViewer>
            <WrapPanel>
                <Button Name="btnBack">
                    <WrapPanel>
                        <Image Source="/Resources/back.png" Style="{StaticResource Img}"/>
                        <TextBlock Text="Назад"/>
                    </WrapPanel>
                </Button>
                <Button Name="btnOpenVacancy">
                    <WrapPanel>
                        <Image Source="/Resources/cursor.png" Style="{StaticResource Img}"/>
                        <TextBlock Text="Открыть"/>
                    </WrapPanel>
                </Button>
            </WrapPanel>
        </StackPanel>
    </Grid>
</Page>
exrtaInfoPage.xaml.cs
using SearchJobParser.Class.Model;
using SearchJobParser.Class.Service;
using System;
using System.Diagnostics;
using System.Windows.Controls;
using System.Windows.Media.Imaging;

namespace SearchJobParser.Pages
{
    /// <summary>
    /// Interaction logic for extraInfoPage.xaml
    /// </summary>
    public partial class extraInfoPage : Page
    {
        public extraInfoPage(object data)
        {
            InitializeComponent();
            btnBack.Click += (sender, e) => Manager.mainFrame.GoBack();
            btnOpenVacancy.Click += (sender, e) =>
            {
                Process.Start(new ProcessStartInfo
                {
                    FileName = (data as ParseData).Link,
                    WindowStyle = ProcessWindowStyle.Hidden,
                    UseShellExecute = true
                });
            };
            tbJobTitle.Text = (data as ParseData).JobTitle;
            Uri uri = new Uri((data as ParseData).SiteName == JobSiteName.HHRU? "/Resources/hh.png" : "/Resources/superjob.png", UriKind.Relative);
            imgJibSite.Source = new BitmapImage(uri);
            lbxDescription.Items.Add($"{(data as ParseData).Salary}\n{(data as ParseData).Experience}\n{(data as ParseData).Format}\n{(data as ParseData).Description}");
        }
    }
}

Директория Class/Model

Для работы с API HH.ru и парсингом вакансий была создана система классов, которые выполняют две ключевые задачи:

  • Структурирование данных (вакансии, регионы, источники)

  • Типизация для минимизации ошибок.

Эти классы — фундамент приложения. Они превращают сырые данные (HTML, JSON) в строго типизированные объекты C#, с которыми удобно работать в WPF.

API HH.ru возвращает регионы в виде иерархии: Страна → Регионы → Города. При запросе к API HH.ru, ответ десериализуется в объекты Country и Area, образуя древовидную структуру. Это позволяет удобно отображать регионы в выпадающем списке WPF.

Чтобы избежать «магических строк» и ошибок в коде, используются enum’ы. Зачем это нужно:

  • JobSiteName помогает определить, с какого сайта пришла вакансия — это критично, так как у HH.ru и SuperJob разная структура данных.

  • MessageType стандартизирует логирование: можно быстро фильтровать ошибки в файле логов.

Класс ParseData инкапсулирует все данные о вакансии, которые извлекаются при парсинге.

Почему такая архитектура?

  • Чистота кода: отделение моделей от логики парсера и UI (WPF) соответствует принципам SOLID.

  • Легкая расширяемость: чтобы добавить новый сайт (например, LinkedIn), достаточно расширить JobSiteName и создать новый парсер.

  • Безопасность: строгая типизация через enum’ы предотвращает ошибки вроде JobSiteName = "SuperJob" (который можно опечатать).

Class/Model
Area
namespace SearchJobParser.Class.Model
{
    internal class Area
    {
        public string Name;
        public Area[] Areas;
        public Area(string name, Area[] areas)
        {
            Name = name;
            Areas = areas;
        }
    }
}
Country
namespace SearchJobParser.Class.Model
{
    internal class Country
    {
        public string Name;
        public Area[] Area;
        public Country(string name, Area[] areas)
        {
            Name = name;
            Area = areas;
        }
    }
}
CountryType
namespace SearchJobParser.Class.Model
{
    public enum CountryType
    {
        Russia = 0,
        Ukraine = 1,
        kazakhstan = 2,
        Belarus = 5
    }
}
JobSiteName
namespace SearchJobParser.Class.Model
{
    internal enum JobSiteName
    {
        HHRU = 0,
        SuperJob = 1
    }
}
MessageType
namespace SearchJobParser.Class.Model
{
    enum MessageType
    {
        INFO = 0,
        WARN = 1,
        ERR = 2
    }
}
ParseData
namespace SearchJobParser.Class.Model
{
    internal class ParseData
    {
        public string JobTitle { get; private set; }
        public string Salary { get;private set; }
        public string Experience { get; private set; }
        public string Format { get; private set; }
        public string  Worktime { get; private set; }
        public string Link { get;private set; }
        public JobSiteName SiteName { get; private set; }
        public string Description { get; private set; }

        public ParseData(string jobTitle, string salary, string experience, string format, string link, JobSiteName siteName = JobSiteName.HHRU, string worktime = null, string description = null)
        {
            JobTitle = jobTitle;
            Salary = salary;
            Experience = experience;
            Format = format;
            Worktime = worktime;
            Link = link;
            SiteName = siteName;
            Description = description;
        }
    }
}

Директория Class/Service

Эти классы отвечают за вспомогательные задачи: логирование, экспорт данных, навигацию и работу с API. Они связывают модели данных (ParseData, Area, Country) с пользовательским интерфейсом WPF.

Класс ExcelMaker превращает спарсенные данные в отчёт.

Ключевые особенности:

  • Автоматическое именование файлов (например, report_20-05-2025.xlsx).

  • Обработка отсутствующих данных (поле Format заменяется на "Пусто").

  • Поддержка мультиплатформенности (не требует установленного Excel).

Класс Logger записывает логи в файл log.log с контролем размера.

Фичи:

  • Ротация логов: при превышении 1 МБ создается архивный файл (например, log_05-10-2025.log).

  • ПотокобезопасностьSemaphoreSlim предотвращает конфликты при параллельной записи.

  • Типы сообщенийINFOWARNERR для фильтрации.

Класс Manager для управления окнами через Frame.

Зачем это нужно:

  • Позволяет переключаться между страницами

  • Централизует управление интерфейсом, упрощая масштабирование.

Класс ResourceCities получает список городов России с HH.ru и кэширует их.

Логика работы:

  1. Запрос к API

  2. Десериализация ответа в объекты Country и Area.

  3. Кэширование: данные сохраняются в поле areas для повторного использования.

Почему такая архитектура?

  • Разделение ответственности: каждый класс решает одну задачу (логирование ≠ экспорт ≠ навигация).

  • Легкость тестированияExcelMaker или Logger можно проверить отдельно от WPF.

  • Масштабируемость: чтобы добавить экспорт в PDF, достаточно создать новый сервисный класс, не трогая парсер.

Class/Service
ResourceCities
using Newtonsoft.Json;
using SearchJobParser.Class.Model;
using System.Collections.Generic;
using System.IO;
using System.Net;

namespace SearchJobParser.Class.Service
{
    internal class ResourceCities
    {
        private Logger logger = new Logger();
        private static Area[] areas;
        public ResourceCities() => areas = ParsedCities();
        public string[] GetCities()
        {
            List<string> cities = areas?.Length > 0 ? new List<string>(areas.Length) : null;
            if (areas != null)
            {
                foreach (Area city in areas)
                    cities.Add(city.Name);
            } else
                areas = ParsedCities();
            return cities?.Count > 0 ? cities.ToArray() : null;
        }
        public string[] GetChildCities(in string parentCities)
        {
            List<string> childCities = null; 
            foreach (Area area in areas)
            {
                if (area.Name == parentCities)
                {
                    childCities = area?.Areas.Length > 0 ? new List<string>(areas.Length) : null;
                    foreach (Area child in area.Areas)
                        childCities.Add(child.Name);
                }
                else
                    continue;
            }
            return childCities?.Count > 0? childCities.ToArray() : null;
        }
        private Area[] ParsedCities()
        {
            string url = "https://api.hh.ru/areas";
            string result = "";
            try
            {
                HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
                HttpWebResponse response = (HttpWebResponse)request.GetResponse();
                using (StreamReader reader = new StreamReader(response.GetResponseStream()))
                    result = reader.ReadToEnd();
                Country[] countries = JsonConvert.DeserializeObject<Country[]>(result);
                return countries[(int)CountryType.Russia].Area;
            }
            catch (WebException ex)
            {
                logger.WriteLog(ex.Message, MessageType.ERR);
            }
            return null;
        }
    }
}
Manager
using System.Windows.Controls;

namespace SearchJobParser.Class.Service
{
    internal class Manager
    {
        public static Frame mainFrame { get; set; }
    }
}
Logger
using SearchJobParser.Class.Model;
using System;
using System.IO;
using System.Threading;

namespace SearchJobParser.Class.Service
{
    internal class Logger
    {
        private string fileName = "log.log";
        private const int mbSize = 1000000;
        private static readonly SemaphoreSlim lockFile = new SemaphoreSlim(1, 1);

        public void WriteLog(string message, MessageType type = MessageType.INFO)
        {
            CheckSize();
            lockFile.Wait();
            using (StreamWriter writer = new StreamWriter(fileName, true))
                 writer.WriteLineAsync($"|{type}| {DateTime.Now} {message}");
            lockFile.Release();
            
        }
        private void CheckSize()
        {
            FileInfo file = new FileInfo(fileName);
            if (file.Exists)
            {
                if (file.Length > mbSize)
                {
                    file.CopyTo($"log_{DateTime.Today.ToString("MM-dd-yyyy")}.log", false);
                    file.Delete();
                }
            } else
                file.Create().Close();
        }
    }
}
ExcelMaker
using OfficeOpenXml;
using SearchJobParser.Class.Model;
using System;
using System.Collections.Generic;
using System.IO;

namespace SearchJobParser.Class.Service
{
    internal class ExcelMaker
    {
        public void SaveData(ref List<ParseData> data)
        {
            ExcelPackage.License.SetNonCommercialOrganization("My Noncommercial organization");
            string path = $@"report_{DateTime.Now.ToShortDateString()}.xlsx";
            FileInfo excelFile = new FileInfo(path);
            using (ExcelPackage excel = new ExcelPackage(excelFile))
            {
                excel.Workbook.Worksheets.Add("отчет");
                ExcelWorksheet worksheetClient = excel.Workbook.Worksheets["отчет"];
                string[] header = new string[] { "Должность", "Сайт", "Зарплата", "Опыт", "Формат", "Ссылка" };
                for (int i = 1; i < header.Length + 1; i++)
                    worksheetClient.Cells[1, i].Value = header[i - 1];
                for (int i = 0; i < data.Count; i++)
                {
                    worksheetClient.Cells[i + 2, 1].Value = data[i].JobTitle;
                    worksheetClient.Cells[i + 2, 2].Value = data[i].SiteName == JobSiteName.HHRU ? "HH.RU" : "SuperJob";
                    worksheetClient.Cells[i + 2, 3].Value = data[i].Salary;
                    worksheetClient.Cells[i + 2, 4].Value = data[i].Experience;
                    worksheetClient.Cells[i + 2, 5].Value = data[i].Format?.Trim() == null? "Пусто" : data[i]?.Format;
                    worksheetClient.Cells[i + 2, 6].Value = data[i].Link;
                }
                excel.SaveAs(excelFile);
            }
        }
    }
}

Директория Class/Engine

Эти классы реализуют всю логику сбора данных с сайтов. Их архитектура построена вокруг трех принципов:

  • Абстракция (общий базовый класс)

  • Асинхронность (работа с сетью без блокировок UI)

  • Безопасность (ограничение параллельных запросов)

Базовый класс EngineParser - каркас для всех парсеров. Ограничение через Semaphore предотвращает блокировку сайта.

Что это дает:

  • Общие ресурсы: HTTP-клиент, парсер HTML, семафор для контроля нагрузки.

  • Единый интерфейс: все парсеры реализуют ParsingVacancies().

  • Событийная модель: уведомляет UI о прогрессе через ParsingNotification.

Класс EngineHH - логика для парсинга сайта HH.ru.

Класс EngineSJ - логика для парсинга сайта SuperJob. Для обхода защиты добавляется User-Agent для имитации браузера.

Почему такая архитектура?

  • Асинхронность: Методы async/await обеспечивают отзывчивость UI WPF во время сетевых запросов.

  • Паттерн «Шаблонный метод»: Базовый класс задает структуру, дочерние — реализуют специфику сайтов.

  • Логирование: Все ошибки и успешные операции записываются через общий Logger

Class/Engine
EngineParser
using AngleSharp.Html.Parser;
using SearchJobParser.Class.Model;
using SearchJobParser.Class.Service;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;

namespace SearchJobParser.Class.Engine
{
    abstract class EngineParser
    {
        private Logger logger = new Logger();
        private protected static Semaphore semaphore = new Semaphore(5, 10, "sync");
        private protected HttpClient client = new HttpClient();
        private protected HtmlParser parser = new HtmlParser();
        private protected static List<ParseData> dataList;
        public event EventHandler<string> ParsingNotification;
        abstract public void ParsingVacancies(string link);
        public List<ParseData> GetParsingData() => dataList;
        public virtual void OnParsingSite(string result) => ParsingNotification?.Invoke(this, result);
        private protected void WriteLogs(string message, MessageType type = MessageType.INFO) => logger.WriteLog(message, type);
    }
}
EngineHH
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using SearchJobParser.Class.Model;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;

namespace SearchJobParser.Class.Engine
{
    internal class EngineHH : EngineParser
    {
        public async override void ParsingVacancies(string link)
        {
            string html = await client.GetStringAsync(link);
            IHtmlDocument document = parser.ParseDocument(html);
            IHtmlCollection<IElement> elements = document.Body.QuerySelectorAll(".magritte-redesign");
            OnParsingSite($"Найдено на HH.RU вакансий: {elements.Length - 1}");
            dataList = new List<ParseData>(elements.Length - 1);
            for (int i = 1; i < elements.Length; i++)
            {
                link = elements[i].QuerySelector("a").GetAttribute("href");
                await Task.Run(() => ParsingPages(link));
            }
            OnParsingSite($"Все спарсил");
        }
        private async void ParsingPages(string link)
        {
            semaphore.WaitOne();
            HttpClient http = new HttpClient();
            string position = null;
            string salary = null;
            string experience = null;
            string worktime = null;
            string format = null;
            StringBuilder description = new StringBuilder();
            try
            {
                string html = await http.GetStringAsync(link);
                IHtmlDocument document = parser.ParseDocument(html);
                IElement element = document.Body.QuerySelector(".vacancy-title");
                position = element.QuerySelector("h1")?.TextContent;
                salary = element.QuerySelector("span")?.TextContent;
                IHtmlCollection<IElement> elements = document.Body.QuerySelectorAll("p");
                for (int i = 0; i < elements.Length; i++)
                {
                    if (elements[i].TextContent.Contains("Опыт работы:"))
                        experience = elements[i].TextContent;
                    else if (elements[i].TextContent.Contains("Формат работы:"))
                        format = elements[i].TextContent;
                    else if (elements[i].TextContent.Contains("График:"))
                    {
                        foreach (IElement elementSpan in elements[i].ParentElement.ChildNodes.QuerySelectorAll("span"))
                        {
                            if (elementSpan.TextContent.Contains("занятость"))
                                worktime = elementSpan.TextContent;
                            else continue;
                        }
                    }
                    else
                        description.AppendLine(elements[i].TextContent.Trim());
                }
                dataList.Add(new ParseData(jobTitle: position, salary: salary,experience: experience, format: format, link: link, worktime: worktime, description: description.ToString()));
                WriteLogs($"Полученны следующие данные:\nПозиция: {position}\nЗарплата: {salary}\n{experience}\n{worktime}\ncсылка: {link}");
                OnParsingSite($"Пропарсил {link}");
            }
            catch (Exception e)
            {
                WriteLogs(e.Message, MessageType.WARN);
            }
            finally
            {
                semaphore.Release();
            }
        }
    }
}
EngineSJ
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using SearchJobParser.Class.Model;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;

namespace SearchJobParser.Class.Engine
{
    internal class EngineSJ : EngineParser
    {
        public async override void ParsingVacancies(string link)
        {
            client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
            string html = await client.GetStringAsync(link);
            IHtmlDocument document = parser.ParseDocument(html);
            IHtmlCollection<IElement> elements = document.Body.QuerySelectorAll(".f-test-search-result-item");
            OnParsingSite($"Найдено на HH.RU вакансий: {elements.Length}");
            dataList = new List<ParseData>(elements.Length);
            for (int i = 1; i < elements.Length; i++)
            {
                if (elements[i].QuerySelector("a")?.GetAttribute("href") == null)
                    continue;
                else
                {
                    link = $"https://russia.superjob.ru{elements[i].QuerySelector("a")?.GetAttribute("href")}";
                    await Task.Run(() => ParsingPages(link));
                }
            }
            OnParsingSite($"Все спарсил");
        }
        private async void ParsingPages(string link)
        {
            semaphore.WaitOne();
            HttpClient http = new HttpClient();
            string position = null;
            string salary = null;
            string experience = null;
            string format = null;
            string description = null;
            try
            {
                http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
                string html = await http.GetStringAsync(link);
                IHtmlDocument document = parser.ParseDocument(html);
                IElement mainElement = document.Body.QuerySelector("h1");
                position = mainElement.TextContent;
                salary = mainElement.ParentElement.QuerySelector("span").TextContent;
                IElement experienceElement = document.Body.QuerySelector("#app > div > div > div > div > div > div > div > div > div > div > div:nth-child(1) > div > div:nth-child(4) > div > div:nth-child(1) > div > div > span:nth-child(1)");
                experience = experienceElement.TextContent;
                IElement formatElement = document.Body.QuerySelector("#app > div > div > div > div > div > div > div > div > div > div > div:nth-child(1) > div > div:nth-child(4) > div > div:nth-child(1) > div > div > span:nth-child(3)");
                format = formatElement?.TextContent;
                IElement descElement = document.Body.QuerySelector("#app > div > div > div > div > div > div > div > div > div > div > div:nth-child(1) > div > div:nth-child(4) > div > div:nth-child(2)");
                description = descElement?.TextContent;
                dataList.Add(new ParseData(jobTitle: position, salary: salary, experience: experience, format: format, link: link, siteName: JobSiteName.SuperJob, description: description));
                WriteLogs($"Полученны следующие данные:\nПозиция: {position}\nЗарплата: {salary}\n{experience}\ncсылка: {link}");
                OnParsingSite($"Пропарсил {link}");
            }
            catch (Exception e)
            {
                WriteLogs(e.Message, MessageType.WARN);
            }
            finally
            {
                semaphore.Release();
            }
        }
    }
}

Тестирование и интерфейс

Для проверки работы приложения будет взята должность "C# разработчик" и родной город автора - Санкт-Петербург. Сайт для парсинга выбран HH.ru.

Поиск C# разработчика из Санкт-Петербурга
Поиск C# разработчика из Санкт-Петербурга

Результат парсинга представлен ниже скриншотом

Получение списка вакансий
Получение списка вакансий

Далее будет рассмотрена подробно одна из вакансий.

Более подробная информация о вакансии
Более подробная информация о вакансии

Следующее, что будет рассмотрено должность "Инженер" из города Черняховск из Калининградской области на сайте HH.ru.

Поиск инженера из Черняховск
Поиск инженера из Черняховск
Результат распарсенных вакансий
Результат распарсенных вакансий
Более подробная информация о вакансии
Более подробная информация о вакансии

Следующее, что будет рассмотрено должность "Инженер" из города Фурманов из Ивановской области на сайте SuperJob и нажата кнопка Экспорт.

Поиск инженеров из Фурманова
Поиск инженеров из Фурманова
Получение списка вакансий из Фурманова
Получение списка вакансий из Фурманова
Получение более подробной информации
Получение более подробной информации
Экспорт данных
Экспорт данных

Заключение

Разработка парсера вакансий — это не только технический вызов, но и возможность глубоко погрузиться в экосистему современных IT-инструментов. Наш проект объединил несколько ключевых компонентов:

  1. Гибкая архитектура
    Благодаря разделению на модели (ParseDataArea), сервисы (ExcelMakerLogger) и движки (EngineHHEngineSJ), код остаётся читаемым и легко расширяемым. Добавить поддержку нового сайта? Достаточно унаследовать EngineParser и реализовать парсинг.

  2. Мощь C# и WPF

    • Асинхронность обеспечила плавную работу интерфейса даже при массовом парсинге.

    • Строгая типизация свела к минимуму runtime-ошибки.

    • WPF позволил создать desktop-приложение с продвинутым UI без зависимости от веб-технологий.

  3. Практическая ценность
    Приложение решает реальные задачи: анализ зарплат, поиск работы, конкурентная разведка. Экспорт в Excel и детальный просмотр делают его полезным как для соискателей, так и для HR-специалистов.

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


  1. gerneMan
    22.05.2025 07:45

    Интересная статья, не знал, что у hh есть api. Было бы прикольней парсить вакансии с хабра, а также например визуализировать график по вакансиям. Я так понимаю пагинацию не учитывает автор, поэтому добавил бы ее


    1. pnmv
      22.05.2025 07:45

      на хабре, те же самые вакансии, просто их меньше.


  1. chdvl
    22.05.2025 07:45

    Спасибо за идею, но сам Бог велел запилить сие чудо на модном Питоне...

    И нафига в этой задаче особая многопоточность, хотя она и в Питоне есть?


    1. PB_Academy Автор
      22.05.2025 07:45

      Изначально была идея реализация парсинга вакансий с пагинацией, поэтому и была реализована многопоточность. Статей про парсинг на python много на Хабре, а на другом ЯП встречаются редко, поэтому и было реализовано.


  1. pnmv
    22.05.2025 07:45

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

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


    1. PB_Academy Автор
      22.05.2025 07:45

      Когда работал на Nissan часто употреблялось в команде спарсить данные