Реалістичне зображення комп'ютерного екрану на сучасному робочому столі, що показує інтерфейс програми ContentChackerWpfApp

Детальний аналіз коду програми для сканування веб-сайтів на платформі WPF з використанням Entity Framework Core

Даний проект реалізований на платформі .NET з використанням Windows Presentation Foundation (WPF) та Entity Framework Core. Він призначений для сканування веб-сайтів, збору інформації про сторінки та їхні метадані, а також збереження цих даних у локальній базі даних. Основні функції включають повне сканування сайту, завантаження інформації про сайти, сторінки та посилання з бази даних, а також можливість продовження або зупинки сканування.

Завантажити ContentCheckerWpfApp.zip для Widows

проект ContentCheckerWpfApp на GitHub

пост на linkedin

Основна таблиця ContentChackerWpfApp з завантаженими сайтами
Основна таблиця ContentChackerWpfApp з завантаженими сторінками сайту
Основна таблиця ContentChackerWpfApp з завантаженими посиланнями сайту

Загальна структура проекту

Проект складається з кількох ключових компонентів:

  1. Головне вікно (MainWindow.xaml та MainWindow.xaml.cs): Це основний інтерфейс користувача, який забезпечує доступ до функцій сканування сайту, перегляду та видалення даних.
  2. Діалоги (WindowInputText.xaml та WindowComboBoxSelect.xaml): Використовуються для введення даних та вибору елементів під час взаємодії з користувачем.
  3. Сканер сайтів (SiteScanner.cs): Це основний клас, який відповідає за процес сканування сайту, збирання даних про сторінки, метадані та посилання.
  4. Допоміжні класи та методи (UriHelper.cs, HtmlHelper.cs): Набір статичних методів для роботи з URI, HTML-документами та іншими допоміжними завданнями.
  5. Модель даних (Site.cs, Page.cs, Link.cs):
  6. Це об'єкти, що представляють сайти, сторінки та посилання в базі даних.
  7. Контекст бази даних (LocalContext.cs): Використовується для управління базою даних, збереження та отримання даних.

Детальний розбір коду

Головне вікно програми (MainWindow.xaml та MainWindow.xaml.cs)

Це вікно містить елементи керування для взаємодії з програмою, такі як меню, таблиця даних (DataGrid) та статусбар (StatusBar).

XAML-код MainWindow.xaml:
<Window x:Class="ContentChackerWpfApp.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:ContentChackerWpfApp"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <Menu>
            <MenuItem Header="Scan">
                <Separator />
                <MenuItem x:Name="MIFullSiteScan" Header="Full site scan" Click="MIFullSiteScan_Click"/>
                <MenuItem x:Name="MIStopScan" Header="Stop scan" Click="MIStopScan_Click" />
                <MenuItem x:Name="MIContinueScan" Header="Start scan" Click="MIContinueScan_Click"/>
            </MenuItem>
            <MenuItem Header="Data">
                <MenuItem x:Name="MILoadSites" Header="LoadSites" Click="MILoadSites_Click" />
                <MenuItem x:Name="MILoadPages" Header="LoadPages" Click="MILoadPages_Click" />
                <MenuItem x:Name="MILoadLinks" Header="LoadLinks" Click="MILoadLinks_Click" />
                <Separator />
                <MenuItem x:Name="MIDeleteSite" Header="Delete Site" Click="MIDeleteSite_Click"  />
            </MenuItem>
        </Menu>
        <DataGrid x:Name="dataGrid" Grid.Row="1"/>
        <StatusBar Grid.Row="2">
            <StatusBarItem x:Name="SBStatus"/>
        </StatusBar>
    </Grid>
</Window>
C# код MainWindow.xaml.cs:
using ContentChackerWpfApp.Data;
using ContentChackerWpfApp.Dialogs;
using ContentChackerWpfApp.Models.DB;
using Microsoft.EntityFrameworkCore;
using System.Windows;
namespace ContentChackerWpfApp
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        SiteScanner? scanner = null;
        private async void MIFullSiteScan_Click(object sender, RoutedEventArgs e)
        {
            var w = new WindowInputText();
            w.TXT.Text = w.Title = "Input site address";
            if (w.ShowDialog() != true) return;
            string siteurl = w.TXTInput.Text;
            scanner?.StopScan();
            scanner = new SiteScanner(siteurl);
            scanner.LogDelegate += OnLog;
            await scanner?.LoadSite();
            scanner?.StartScan();
        }

        void OnLog(object sender, string message)
        {
            Application.Current.Dispatcher.Invoke(() => { SBStatus.Content = message; });
        }

        private void MIStopScan_Click(object sender, RoutedEventArgs e)
        {
            scanner?.StopScan();
        }

        private async void MIContinueScan_Click(object sender, RoutedEventArgs e)
        {
            scanner?.StopScan();
            var w = new WindowComboBoxSelect();
            using var context = new LocalContext();
            w.CMBSelect.ItemsSource = await context.Sites.ToListAsync();
            w.TXT.Text = w.Title = "Select site to continue scan";
            if (w.ShowDialog() != true) return;
            if (w.CMBSelect.SelectedItem is Site site)
            {
                string url = await context.Pages.Where(x => x.SiteId == site.Id)
                    .Where(x => x.StatusCode == 200)
                    .Where(x => x.Scanned == null)
                    .Where(x => !string.IsNullOrEmpty(x.AbsoluteUrl))
                    .Select(x => x.AbsoluteUrl).FirstOrDefaultAsync();
                if (string.IsNullOrEmpty(url)) url = site.CurrentPage;
                OnLog(this, $"Try continue scan {url}");
                await Task.Delay(1000);
                scanner = new(url);
                scanner.LogDelegate += OnLog;
                scanner.LoadSite();
                scanner.ContinueScan();
            }
        }

        private async void MILoadSites_Click(object sender, RoutedEventArgs e)
        {
            using var context = new LocalContext();
            dataGrid.ItemsSource = await context.Sites.ToListAsync();
        }

        private async void MILoadPages_Click(object sender, RoutedEventArgs e)
        {
            using var context = new LocalContext();
            dataGrid.ItemsSource = await context.Pages.ToListAsync();
        }

        private async void MILoadLinks_Click(object sender, RoutedEventArgs e)
        {
            using var context = new LocalContext();
            dataGrid.ItemsSource = await context.Links.ToListAsync();
        }

        private async void MIDeleteSite_Click(object sender, RoutedEventArgs e)
        {
            scanner?.StopScan();
            var w = new WindowComboBoxSelect();
            using var context = new LocalContext();
            w.CMBSelect.ItemsSource = await context.Sites.ToListAsync();
            w.TXT.Text = w.Title = "Select site to delete";
            if (w.ShowDialog() != true) return;
            if (w.CMBSelect.SelectedItem is Site site)
            {
                try
                {
                    OnLog(this, "Try Delete site....");
                    await context.Entry(site).Collection(x => x.Links).LoadAsync();
                    await context.Entry(site).Collection(x => x.Pages).LoadAsync();
                    context.RemoveRange(site.Links);
                    context.RemoveRange(site.Pages);
                    context.Remove(site);
                    await context.SaveChangesAsync();
                    OnLog(this, "Site deleted");
                }
                catch (Exception ex) { MessageBox.Show(ex.Message); }
            }
        }
    }
}

Опис основних функцій

Ініціалізація сканера сайту

В класі MainWindow відбувається ініціалізація сканера сайту, який буде обробляти введену користувачем адресу сайту. При натисканні на пункт меню "Full site scan", користувачеві пропонується ввести адресу сайту. Далі створюється екземпляр класу SiteScanner, який відповідає за весь процес сканування.

Завантаження сайту та початок сканування

Метод LoadSite в класі SiteScanner завантажує початкові дані сайту, такі як URL, хост, схема, порт, та абсолютний URI. Далі викликається метод StartScan, який починає сканування всього сайту, проходячи по всім доступним сторінкам.

Зупинка та продовження сканування

Методи StopScan та ContinueScan дозволяють відповідно зупинити сканування та продовжити його з того місця, де було зупинено. Це особливо корисно, якщо сканування великого сайту займає багато часу і потрібно робити паузи.

Взаємодія з базою даних

Клас LocalContext, що успадковується від DbContext, відповідає за роботу з базою даних. Він визначає три основні таблиці: Sites, Pages та Links, які зберігають відповідно дані про сайти, сторінки та посилання. Методи GetSite, AddPage та GetPage забезпечують отримання та додавання даних у базу.

Робота з URI та HTML

Для зручності роботи з URI та HTML-документами використовуються допоміжні класи UriHelper та HtmlHelper.

UriHelper

Цей клас включає методи для створення URI, перевірки чи належить URL до певного домену, та отримання абсолютного URI з базового і відносного.


public static class UriHelper
{
    public static string GetSiteUri(string url)
    {
        if (string.IsNullOrWhiteSpace(url)) return string.Empty;
        url = url.ToLower();
        var uri = UriHelper.CreateUri(url);
        if (uri == null) return string.Empty;
        var Host = uri.Host;
        var Scheme = uri.Scheme;
        var Port = string.IsNullOrEmpty(uri.Port.ToString()) ? "" : $":{uri.Port}";
        var AbsoluteUri = @$"{Scheme}://{Host}{Port}";
        return AbsoluteUri;
    }

    public static Uri? CreateUri(string url)
    {
        if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri? uriResult))
            return uriResult;
        return null;
    }

    public static bool IsUrlBelongsToSite(string url, string siteDomain)
    {
        if (Uri.TryCreate(url, UriKind.Absolute, out Uri uriResult))
            return uriResult.Host.EndsWith(siteDomain, StringComparison.OrdinalIgnoreCase);
        return false;
    }

    public static bool IsAbsolute(string url)
    {
        if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri uriResult))
            return uriResult.IsAbsoluteUri;
        return false;
    }

    public static Uri GetAbsoluteUri(string baseUri, string relativeUri)
    {
        Uri baseUriObj = new Uri(baseUri);
        Uri absoluteUri = new Uri(baseUriObj, relativeUri);
        return absoluteUri;
    }
}

HtmlHelper

Цей клас надає методи для витягування важливої інформації з HTML-документів, такої як заголовок сторінки, мета-опис, основне зображення, канонічний лінк та посилання.

public static class HtmlHelper
{
    public static string GetTitle(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//title");
        return HtmlEntity.DeEntitize(node?.InnerText) ?? string.Empty;
    }

    public static string GetMetaDescription(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@name='description']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }

    public static string GetOgSiteName(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:site_name']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }

    public static string GetCanonicalLink(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//link[@rel='canonical']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("href", string.Empty)) : string.Empty;
    }

    public static string GetOgUrl(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:url']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }

    public static string GetOgImage(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:image']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }

    public static List GetLinks(HtmlDocument html)
    {
        var res= new List();
        var linkNodes = html.DocumentNode.SelectNodes("//a[@href]");
        if (linkNodes != null)
        {
            foreach (var linkNode in linkNodes)
            {
                var link= new VMlink();
                string rel = linkNode.GetAttributeValue("rel", string.Empty);
                link.NoFollow = rel.Split(' ', StringSplitOptions.RemoveEmptyEntries)
                                     .Contains("nofollow", StringComparer.OrdinalIgnoreCase);
                link.Href = linkNode.GetAttributeValue("href", string.Empty);
                link.Text = (HtmlEntity.DeEntitize(linkNode.InnerText)).Replace("\n", "").Replace("\t", "").Replace("\r", "");
                res.Add(link);
            }
        }
        return res;
    }
}

Моделі даних

У проекті використовується Entity Framework Core для роботи з базою даних. Основні моделі включають Site, Page та Link.

Модель Site

Ця модель представляє сайт, що сканується. Вона містить інформацію про URL, хост, абсолютний URI та інші параметри сайту. Крім того, вона має колекції сторінок та посилань, що належать до сайту.

public class Site
{
    [Key]
    public int Id { get; set; }
    public string Url { get; set; } = string.Empty;
    public string Host { get; set; } = string.Empty;
    public string AbsoluteUri {  get; set; } = string.Empty;
    public string Scheme { get; set; } = string.Empty;
    public string Port { get; set; } = string.Empty;
    public string CurrentPage { get; set; } = string.Empty;
    public virtual ObservableCollection Pages { get; set; } = new();
    public virtual ObservableCollection Links { get; set; } = new();

    public Site(string url)
    {
        Url = url.ToLower();
        var uri = UriHelper.CreateUri(url);
        if (uri == null) return;
        Host = uri.Host;
        Scheme = uri.Scheme;
        Port = string.IsNullOrEmpty(uri.Port.ToString())?"":$":{uri.Port}";
        AbsoluteUri = UriHelper.GetSiteUri(url);
    }

    public override string ToString()
    {
        return AbsoluteUri;
    }
}

Модель Page

Ця модель представляє сторінку сайту і містить інформацію про її шлях та параметри, метадані, статус код, дату сканування та посилання, пов'язані зі сторінкою.

public class Page : INotifyPropertyChanged
{
    [Key]
    public int Id { get; set; }
    public string PathAndQuary { get => _PathAndQuary; set { _PathAndQuary = value; OnPropertyChanged(); } } 
    public string AbsoluteUrl { get => _AbsoluteUrl; set { _AbsoluteUrl = value; OnPropertyChanged(); } } 
    public string Title { get => _Title; set { _Title = value; OnPropertyChanged(); } } 
    public string Description { get => _Description; set { _Description = value; OnPropertyChanged(); } } 
    public string CanonicalLink { get => _CanonicalLink; set { _CanonicalLink = value; OnPropertyChanged(); } } 
    public string OgSiteName { get => _OgSiteName; set { _OgSiteName = value; OnPropertyChanged(); } } 
    public string OgUrl { get => _OgUrl; set { _OgUrl = value; OnPropertyChanged(); } } 
    public string OgImage { get => _OgImage; set { _OgImage = value; OnPropertyChanged(); } } 
    public int StatusCode { get => _StatusCode; set { _StatusCode = value; OnPropertyChanged(); } }
    public DateTime? Scanned { get => _Scanned; set { _Scanned = value; OnPropertyChanged(); } }
    public int SiteId { get; set; }
    [ForeignKey("SiteId")]
    public virtual Site? Site { get; set; }

    private string _PathAndQuary { get; set; } = string.Empty;
    private string _AbsoluteUrl { get; set; } = string.Empty;
    private string _Title { get; set; } = string.Empty;
    private string _Description { get; set; } = string.Empty;
    private string _CanonicalLink { get; set; } = string.Empty;
    private string _OgSiteName { get; set; } = string.Empty;
    private string _OgUrl { get; set; } = string.Empty;
    private string _OgImage { get; set; } = string.Empty;
    private int _StatusCode { get; set; }
    public DateTime? _Scanned { get; set; } = null;

    public virtual ObservableCollection Links { get; set; } = new();

    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

Модель Link

Ця модель представляє посилання на сторінці та містить інформацію про саму сторінку та сайт, до яких воно належить.

public class Link
{
    [Key]
    public int Id { get; set; }
    public string Href { get; set; } = string.Empty;
    public string Text { get; set; } = string.Empty;
    public bool NoFollow { get; set; } = false;
    public int PageId { get; set; }
    public int LinkStatus { get; set; }
    [ForeignKey("PageLinkId")]
    public virtual Page? Page { get; set; }
    public int SiteId { get; set; }
    [ForeignKey("SiteLinkId")]
    public virtual Site? Site { get; set; }
}

Взаємодія з користувачем через діалоги

Для зручної взаємодії з користувачем програма використовує кілька діалогових вікон, що дозволяють вводити дані та вибирати елементи.

Вікно для введення тексту (WindowInputText.xaml та WindowInputText.xaml.cs)

Це вікно дозволяє користувачу вводити текстову інформацію, яка потім використовується в процесі сканування.

XAML-код WindowInputText.xaml:
<Window x:Class="ContentChackerWpfApp.Dialogs.WindowInputText"
        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:ContentChackerWpfApp.Dialogs"
        mc:Ignorable="d"
        Title="WindowInputText" Height="200" Width="300">
    <Grid Margin="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <DockPanel LastChildFill="False">
            <TextBlock x:Name="TXT" DockPanel.Dock="Top"/>
            <TextBox x:Name="TXTInput" DockPanel.Dock="Top" AcceptsTab="True" AcceptsReturn="True" TextWrapping="Wrap" MinLines="3"/>
        </DockPanel>
        <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Center">
            <Button x:Name="BTNOk" Content="OK" Width="50" Click="BTNOk_Click" IsDefault="True"/>
            <Button x:Name="BTNCancell" Content="Cancel" Width="50" Margin="50,0,0,0" IsCancel="True" Click="BTNCancell_Click"/>
        </StackPanel>
    </Grid>
</Window>
C# код WindowInputText.xaml.cs:
using System.Windows;

namespace ContentChackerWpfApp.Dialogs
{
    public partial class WindowInputText : Window
    {
        public WindowInputText()
        {
            InitializeComponent();
        }

        private void BTNOk_Click(object sender, RoutedEventArgs e)
        {
            this.DialogResult = true; Close();
        }

        private void BTNCancell_Click(object sender, RoutedEventArgs e) => Close();
    }
}

Вікно для вибору з комбо-боксу (WindowComboBoxSelect.xaml та WindowComboBoxSelect.xaml.cs)

Це вікно дозволяє користувачеві вибирати елементи з комбо-боксу, що полегшує вибір сайту або сторінки для подальшого сканування чи видалення.

XAML-код WindowComboBoxSelect.xaml:
<Window x:Class="ContentChackerWpfApp.Dialogs.WindowComboBoxSelect"
        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:ContentChackerWpfApp.Dialogs"
        mc:Ignorable="d"
        Title="WindowComboBoxSelect" Height="200" Width="300">
    <Grid Margin="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="*"/>
            <RowDefinition Height="auto"/>
        </Grid.RowDefinitions>
        <DockPanel LastChildFill="False">
            <TextBlock x:Name="TXT" DockPanel.Dock="Top"/>
            <ComboBox x:Name="CMBSelect" DockPanel.Dock="Top"/>
        </DockPanel>
        <StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Center">
            <Button x:Name="BTNOk" Content="OK" Width="50" Click="BTNOk_Click" IsDefault="True"/>
            <Button x:Name="BTNCancell" Content="Cancel" Width="50" Margin="50,0,0,0" IsCancel="True" Click="BTNCancell_Click"/>
        </StackPanel>
    </Grid>
</Window>

C# код WindowComboBoxSelect.xaml.cs:

using System.Windows;

namespace ContentChackerWpfApp.Dialogs
{
    public partial class WindowComboBoxSelect : Window
    {
        public WindowComboBoxSelect()
        {
            InitializeComponent();
        }

        private void BTNOk_Click(object sender, RoutedEventArgs e)
        {
            this.DialogResult = true; Close();
        }

        private void BTNCancell_Click(object sender, RoutedEventArgs e) => Close();
    }
}

Сканер сайтів: SiteScanner

Клас SiteScanner відіграє ключову роль у проекті, оскільки він відповідає за сканування веб-сайтів та збирання інформації про сторінки, метадані та посилання.

Основні функції класу SiteScanner

  • Ініціалізація сканера:
  • Конструктор класу приймає URL сайту, який потрібно сканувати, і зберігає його в полі CurrentUrl.
  • Параметр LogDelegate використовується для передачі повідомлень про статус сканування до інтерфейсу користувача.
  • Метод LoadSite():
  • Цей метод завантажує дані про сайт, включаючи його URL, хост, схему, порт та абсолютний URI. Він використовує допоміжний клас UriHelper для отримання та обробки URI сайту.

  • Метод StartScan():
  • Викликається для початку сканування всього сайту.

    Метод спочатку перевіряє, чи були завантажені дані сайту, якщо ні, то завантажує їх за допомогою LoadSite(). Далі виконується сканування стартової сторінки за допомогою методу ScanPage().

  • Метод ScanPage():
  • Цей метод сканує окрему сторінку сайту.

    Виконує HTTP-запит для отримання HTML-контенту сторінки.

    Використовує HtmlHelper для витягування метаданих зі сторінки (заголовок, опис, основне зображення тощо).

    Зберігає інформацію про сторінку та посилання в базі даних.

  • Метод StopScan():
  • Використовується для зупинки сканування. Це корисно, коли сканування займає тривалий час і користувач хоче тимчасово перервати процес.

  • Метод ContinueScan():
  • Продовжує сканування сайту з тієї сторінки, на якій було зупинено процес. Завантажує дані сайту з бази даних та виконує сканування за допомогою ScanPage().

using ContentCheckerWpfApp.Data;
using ContentCheckerWpfApp.Models.DB;
using System.Net.Http;
using HtmlAgilityPack;
using HtmlDocument = HtmlAgilityPack.HtmlDocument;

namespace ContentCheckerWpfApp
{
    public delegate void LogDelegate(object sender, string message);
    public class SiteScanner
    {
        public Site? CurrentSite { get; private set; }
        public LogDelegate? LogDelegate { get; set; }
        public string CurrentUrl { get; private set; } = string.Empty;
        public bool Stop { get; set; } = false;
        public SiteScanner(string url)
        {
            CurrentUrl = url;
        }

        public async Task LoadSite()
        {
            OnLog($"{DateTime.Now} Loading site {CurrentUrl}");
            var uri = UriHelper.GetSiteUri(CurrentUrl);
            CurrentSite = await GetSite(uri);
        }

        public async Task StartScan()
        {
            Stop = false;
            OnLog($"{DateTime.Now} Start Scan {CurrentUrl}");
            if (CurrentSite == null) await LoadSite();
            using var context = new LocalContext();
            context.Attach(CurrentSite);
            await ScanPage(context, CurrentSite, CurrentUrl);
            OnLog($"{DateTime.Now} Full Scan {CurrentUrl}");

        }

        public async Task GetSite(string url)
        {
            url = UriHelper.GetSiteUri(url);
            OnLog($"{DateTime.Now} Loading Site {url}");
            using var context = new LocalContext();
            return await context.GetSite(url);
        }

        public async Task ContinueScan()
        {
            Stop = false;
            OnLog($"{DateTime.Now} Continue Site {CurrentUrl}");
            CurrentSite =await GetSite(CurrentUrl);
            using var context = new LocalContext();
            context.Attach(CurrentSite);
            await ScanPage(context, CurrentSite, CurrentSite.CurrentPage, continuescan:true);
            OnLog($"{DateTime.Now} Full Scan");
        }

        public void StopScan()
        {
            OnLog($"{DateTime.Now} Stopping scan Site {CurrentUrl}");
            Stop = true;
        }

        public async Task ScanPage(LocalContext context, Site site, string path, bool rescan = false, bool scanlinks = true, bool continuescan=false)
        {
            if (Stop) return null;
            OnLog($"{DateTime.Now} Scan Page {path}");
            try
            {
                var uri = UriHelper.CreateUri(path);
                if (uri == null) return null;
                if (uri.IsAbsoluteUri)
                {
                    if (!UriHelper.IsUrlBelongsToSite(path, site.Host))
                    {
                        OnLog($"{DateTime.Now} Page {path} Not Belongs To Site");
                        return null;
                    }
                    uri = UriHelper.CreateUri(uri.PathAndQuery);
                }
                if (!continuescan && !rescan)
                {
                    if (site.Pages.FirstOrDefault(x => x.PathAndQuary == uri.OriginalString && x.Scanned!=null) != null)
                    {
                        OnLog($"{DateTime.Now} Allready scanned {path}");
                        return null;
                    }
                }
                if (continuescan) continuescan = false;
                var uripage = UriHelper.GetAbsoluteUri(site.AbsoluteUri, uri.OriginalString);
                var page = site.Pages.FirstOrDefault(x => x.PathAndQuary == uri.OriginalString);
                if (page == null) page = await context.AddPage(site, uri.OriginalString);
                if (page == null)
                {
                    OnLog($"{DateTime.Now} Error path {path} ");
                    return null;
                }
                page.AbsoluteUrl = uripage.AbsoluteUri;
                site.CurrentPage = page.AbsoluteUrl;
                using HttpClient client = new HttpClient();
                HttpResponseMessage response = await client.GetAsync(page.AbsoluteUrl);
                page.StatusCode = (int)response.StatusCode;
                await context.SaveChangesAsync();
                if (response.IsSuccessStatusCode)
                {
                    string pageContent = await response.Content.ReadAsStringAsync();
                    HtmlDocument doc = new HtmlDocument();
                    doc.LoadHtml(pageContent);
                    page.Title = HtmlHelper.GetTitle(doc);
                    page.Description = HtmlHelper.GetMetaDescription(doc);
                    page.OgSiteName = HtmlHelper.GetOgSiteName(doc);
                    page.CanonicalLink = HtmlHelper.GetCanonicalLink(doc);
                    page.OgUrl = HtmlHelper.GetOgUrl(doc);
                    page.OgImage = HtmlHelper.GetOgImage(doc);
                    page.Scanned = DateTime.Now;
                    if (scanlinks)
                    {
                        var links = HtmlHelper.GetLinks(doc);
                        page.Links.Clear();
                        foreach (var item in links) 
                        {
                            if (Stop) return null;
                            Link link = new Link() { Href=item.Href, Text=item.Text, NoFollow=item.NoFollow
                            , PageId=page.Id, Page=page, SiteId=site.Id, Site=site};
                            if (site.Links.FirstOrDefault(x => x.Href == link.Href) != null) continue;
                            context.Add(link);
                            page.Links.Add(link);
                            site.Links.Add(link);
                            await context.SaveChangesAsync();
                            context.Update(page);
                            context.Update(site);
                            await context.SaveChangesAsync();
                            if (!link.NoFollow)
                            {
                                    OnLog($"{DateTime.Now} Scan link {link.Href}");
                                   await ScanPage(context, site, link.Href, rescan, scanlinks);

                            }
                            else { OnLog($"{DateTime.Now} NoFollow link {link.Href}"); }
                        }
                    }
                    
                }
                else OnLog($"{DateTime.Now} Error code {response.StatusCode} read {path} ");
                context.Update(page);
                context.Update(site);
                await context.SaveChangesAsync();
                OnLog($"{DateTime.Now} Page scanned {page.PathAndQuary}");
                return page;
            }
            catch (Exception ex)
            {
                OnLog($"{DateTime.Now} Exception scanning Page {path}:\n{ex.Message}");
                return null;
            }
        }

        public void OnLog(string message) => LogDelegate?.Invoke(this, message);

    }

    public static class UriHelper
    {
        public static string GetSiteUri(string url)
        {
            if (string.IsNullOrWhiteSpace(url)) return string.Empty;
            url = url.ToLower();
            var uri = UriHelper.CreateUri(url);
            if (uri == null) return string.Empty;
            var Host = uri.Host;
            var Scheme = uri.Scheme;
            var Port = string.IsNullOrEmpty(uri.Port.ToString()) ? "" : $":{uri.Port}";
            var AbsoluteUri = @$"{Scheme}://{Host}{Port}";
            return AbsoluteUri;
        }
        public static Uri? CreateUri(string url)
        {
            if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri? uriResult))
                return uriResult;
            return null;
        }

        public static bool IsUrlBelongsToSite(string url, string siteDomain)
        {
            if (Uri.TryCreate(url, UriKind.Absolute, out Uri uriResult))
                return uriResult.Host.EndsWith(siteDomain, StringComparison.OrdinalIgnoreCase);
            return false;
        }

        public static bool IsAbsolute(string url)
        {
            if (Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out Uri uriResult))
                return uriResult.IsAbsoluteUri;
            return false;
        }

        public static Uri GetAbsoluteUri(string baseUri, string relativeUri)
        {
            Uri baseUriObj = new Uri(baseUri);
            Uri absoluteUri = new Uri(baseUriObj, relativeUri);
            return absoluteUri;
        }
    }
}

public static class HtmlHelper
{
    public static string GetTitle(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//title");
        return HtmlEntity.DeEntitize(node?.InnerText) ?? string.Empty;
    }
    public static string GetMetaDescription(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@name='description']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }
    public static string GetOgSiteName(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:site_name']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }
    public static string GetCanonicalLink(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//link[@rel='canonical']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("href", string.Empty)) : string.Empty;
    }
    public static string GetOgUrl(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:url']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }
    public static string GetOgImage(HtmlDocument html)
    {
        var node = html.DocumentNode.SelectSingleNode("//meta[@property='og:image']");
        return node != null ? HtmlEntity.DeEntitize(node.GetAttributeValue("content", string.Empty)) : string.Empty;
    }

    public static List GetLinks(HtmlDocument html)
    {
        var res= new List();
        var linkNodes = html.DocumentNode.SelectNodes("//a[@href]");
        if (linkNodes != null)
        {
            foreach (var linkNode in linkNodes)
            {
                var link= new VMlink();
                string rel = linkNode.GetAttributeValue("rel", string.Empty);
                link.NoFollow = rel.Split(' ', StringSplitOptions.RemoveEmptyEntries)
                                     .Contains("nofollow", StringComparer.OrdinalIgnoreCase);
                link.Href = linkNode.GetAttributeValue("href", string.Empty);
                link.Text = (HtmlEntity.DeEntitize(linkNode.InnerText)).Replace("\n", "").Replace("\t", "").Replace("\r", "");
                res.Add(link);
            }
        }
        return res;
    }
}

public class VMlink
{
    public string Href { get; set; } = string.Empty;
    public string Text { get; set; } = string.Empty;
    public bool NoFollow { get; set; }=false;
}

Контекст бази даних: LocalContext

Клас LocalContext є контекстом бази даних, який успадковується від DbContext і використовується для взаємодії з базою даних. Він забезпечує збереження та отримання даних про сайти, сторінки та посилання, що були зібрані під час сканування.

Основні функції класу LocalContext

  • Визначення таблиць:
  • Клас LocalContext містить властивості DbSet, що відповідають таблицям у базі даних: Sites, Pages, Links.

    Ці властивості дозволяють взаємодіяти з відповідними таблицями, виконуючи CRUD операції.

    Конструктор:

    Конструктор класу виконує ініціалізацію бази даних, забезпечуючи її створення за допомогою Database.EnsureCreated().

  • Метод OnConfiguring():
  • Використовується для налаштування підключення до бази даних. У прикладі використовується локальний SQL Server (localdb) для зберігання даних.

  • Метод GetSite():
  • Цей метод використовується для отримання або створення запису про сайт у базі даних. Якщо сайт з таким URI не знайдено, створюється новий запис.

  • Метод AddPage():
  • Додає нову сторінку до сайту в базі даних. Якщо сайт ще не прив'язаний до контексту бази даних, його додають або приєднують.

  • Метод GetPage():
  • Метод, який отримує сторінку сайту з бази даних за певним шляхом та параметрами.

Приклад коду LocalContext
public class LocalContext : DbContext
{
    public DbSet Links { get; set; }
    public DbSet Sites { get; set; }
    public DbSet Pages { get; set; }

    public LocalContext()
    {
        Database.EnsureCreated();
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        base.OnConfiguring(optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=ContentCheckerDBV1;Trusted_Connection=True;"));
    }

    public async Task GetSite(string siteUri)
    {
        try
        {
            var site = await Sites.FirstOrDefaultAsync(x => x.AbsoluteUri.ToLower() == siteUri.ToLower());

            if (site == null)
            {
                site = new Site(siteUri);
                Add(site);
                await SaveChangesAsync();
            }
            await Entry(site).Collection(x => x.Links).LoadAsync();
            await Entry(site).Collection(x => x.Pages).LoadAsync();
            return site;
        }
        catch (Exception ex)
        {
            var e = ex; return null;
        }
    }

    public async Task AddPage(Site site, string path)
    {
        if (site == null) return null;
        if (!Entry(site).IsKeySet)
            Add(site);
        else
            Attach(site);

        var page = new Page() { PathAndQuary = path, SiteId = site.Id, Site = site };
        Add(page);
        site.Pages.Add(page);
        await SaveChangesAsync();
        return page;
    }
}

Висновок

Проект ContentChackerWpfApp є потужним інструментом для сканування веб-сайтів та збору інформації про їхню структуру та вміст. Завдяки використанню WPF та Entity Framework Core, він пропонує зручний інтерфейс користувача та надійну роботу з базою даних. Кожен з модулів та класів проекту виконує свою чітко визначену функцію, що забезпечує ефективність та масштабованість програми.

Ця стаття описує структуру та логіку роботи програми, забезпечуючи розуміння її ключових компонентів та функцій. Вона може бути корисною як для розробників, які прагнуть зрозуміти, як організовано сканування сайтів, так і для тих, хто хоче вдосконалити свої навички роботи з WPF та Entity Framework Core.

;