Добавьте файлы проекта.

This commit is contained in:
Курнат Андрей
2026-04-04 10:52:30 +03:00
parent 9b34a92f15
commit 5a55bc5f4c
30 changed files with 3446 additions and 0 deletions

172
App.xaml Normal file
View File

@@ -0,0 +1,172 @@
<Application x:Class="CRAWLER.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
<Application.Resources>
<LinearGradientBrush x:Key="AppWindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FFDDE7F0" Offset="0" />
<GradientStop Color="#FFC7D4E2" Offset="1" />
</LinearGradientBrush>
<SolidColorBrush x:Key="AppPanelBrush" Color="#FFF1F5FA" />
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#FFFDFEFF" />
<SolidColorBrush x:Key="AppAccentBrush" Color="#FF4F7498" />
<SolidColorBrush x:Key="AppBorderBrush" Color="#FFA7B7C8" />
<SolidColorBrush x:Key="AppTextBrush" Color="#FF253748" />
<SolidColorBrush x:Key="AppMutedTextBrush" Color="#FF697988" />
<SolidColorBrush x:Key="AppPrimaryButtonBrush" Color="#FF5D84A9" />
<SolidColorBrush x:Key="AppPrimaryButtonHoverBrush" Color="#FF6A92B8" />
<SolidColorBrush x:Key="AppPrimaryButtonPressedBrush" Color="#FF496B8B" />
<SolidColorBrush x:Key="AppButtonBrush" Color="#FFF7FAFD" />
<SolidColorBrush x:Key="AppButtonHoverBrush" Color="#FFE8F0F8" />
<SolidColorBrush x:Key="AppButtonPressedBrush" Color="#FFD7E5F2" />
<SolidColorBrush x:Key="AppSelectionBrush" Color="#FFD9E7F3" />
<SolidColorBrush x:Key="AppWarningBrush" Color="#FFB06C45" />
<SolidColorBrush x:Key="AppSuccessBrush" Color="#FF468468" />
<SolidColorBrush x:Key="AppDangerBrush" Color="#FFC26565" />
<Style TargetType="{x:Type Window}">
<Setter Property="Background" Value="{StaticResource AppWindowBackgroundBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
<Setter Property="FontFamily" Value="Segoe UI" />
</Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
</Style>
<Style TargetType="{x:Type GroupBox}">
<Setter Property="Background" Value="{StaticResource AppPanelBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppAccentBrush}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type GroupBox}">
<Grid SnapsToDevicePixels="True">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="1"
Margin="0,3,0,0"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
CornerRadius="4">
<ContentPresenter ContentSource="Content" />
</Border>
<Border Grid.Row="0"
Margin="12,0,12,0"
Padding="8,0"
HorizontalAlignment="Left"
Background="{StaticResource AppWindowBackgroundBrush}">
<ContentPresenter ContentSource="Header"
RecognizesAccessKey="True" />
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Background" Value="{StaticResource AppButtonBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
<Setter Property="Padding" Value="10,5" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
CornerRadius="4">
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AppButtonHoverBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AppButtonPressedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.55" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="PrimaryActionButtonStyle" TargetType="{x:Type Button}" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Background" Value="{StaticResource AppPrimaryButtonBrush}" />
<Setter Property="BorderBrush" Value="#FF466B8C" />
<Setter Property="Foreground" Value="#FFF8FBFE" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type Button}">
<Border x:Name="Root"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1"
CornerRadius="4">
<ContentPresenter Margin="{TemplateBinding Padding}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
RecognizesAccessKey="True" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AppPrimaryButtonHoverBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Root" Property="Background" Value="{StaticResource AppPrimaryButtonPressedBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Root" Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Background" Value="{StaticResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
<Setter Property="Padding" Value="6,4" />
</Style>
<Style TargetType="{x:Type ComboBox}">
<Setter Property="Background" Value="{StaticResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
</Style>
<Style TargetType="{x:Type DataGrid}">
<Setter Property="Background" Value="{StaticResource AppSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppTextBrush}" />
<Setter Property="GridLinesVisibility" Value="Horizontal" />
<Setter Property="HeadersVisibility" Value="Column" />
<Setter Property="CanUserAddRows" Value="False" />
<Setter Property="CanUserDeleteRows" Value="False" />
<Setter Property="AlternatingRowBackground" Value="#FFF6FAFD" />
<Setter Property="SelectionUnit" Value="FullRow" />
<Setter Property="SelectionMode" Value="Single" />
<Setter Property="RowHeaderWidth" Value="0" />
</Style>
<Style TargetType="{x:Type StatusBar}">
<Setter Property="Background" Value="{StaticResource AppPanelBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AppBorderBrush}" />
<Setter Property="Foreground" Value="{StaticResource AppMutedTextBrush}" />
</Style>
</Application.Resources>
</Application>

153
App.xaml.cs Normal file
View File

@@ -0,0 +1,153 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Markup;
using System.Windows.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace CRAWLER;
public partial class App : Application
{
private IHost _host;
public App()
{
ApplyRussianCulture();
RegisterGlobalExceptionHandlers();
}
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
try
{
_host = AppHost.Create();
await _host.StartAsync().ConfigureAwait(true);
MainWindow = _host.Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "CRAWLER", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(-1);
}
}
protected override async void OnExit(ExitEventArgs e)
{
try
{
if (_host != null)
{
try
{
await _host.StopAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
}
finally
{
_host.Dispose();
}
}
}
catch (Exception ex)
{
ShowUnhandledException(ex, true);
}
finally
{
UnregisterGlobalExceptionHandlers();
base.OnExit(e);
}
}
private static void ApplyRussianCulture()
{
var culture = new CultureInfo("ru-RU");
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
FrameworkElement.LanguageProperty.OverrideMetadata(
typeof(FrameworkElement),
new FrameworkPropertyMetadata(XmlLanguage.GetLanguage(culture.IetfLanguageTag)));
}
private void RegisterGlobalExceptionHandlers()
{
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnCurrentDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
private void UnregisterGlobalExceptionHandlers()
{
DispatcherUnhandledException -= OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException -= OnCurrentDomainUnhandledException;
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
}
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
ShowUnhandledException(e.Exception, false);
e.Handled = true;
}
private void OnCurrentDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is Exception exception)
{
ShowUnhandledException(exception, e.IsTerminating);
return;
}
MessageBox.Show(
e.ExceptionObject == null ? "Произошла необработанная ошибка." : e.ExceptionObject.ToString(),
e.IsTerminating ? "CRAWLER - критическая ошибка" : "CRAWLER",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
private void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
ShowUnhandledException(e.Exception, false);
e.SetObserved();
}
private static void ShowUnhandledException(Exception exception, bool isCritical)
{
var actualException = UnwrapException(exception);
var message = string.IsNullOrWhiteSpace(actualException.Message)
? actualException.ToString()
: actualException.Message;
MessageBox.Show(
message,
isCritical ? "CRAWLER - критическая ошибка" : "CRAWLER",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
private static Exception UnwrapException(Exception exception)
{
if (exception is AggregateException aggregateException)
{
var flattened = aggregateException.Flatten();
if (flattened.InnerExceptions.Count == 1)
{
return UnwrapException(flattened.InnerExceptions[0]);
}
return flattened;
}
return exception;
}
}

47
AppHost.cs Normal file
View File

@@ -0,0 +1,47 @@
using System;
using CRAWLER.Parsing;
using CRAWLER.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace CRAWLER;
internal static class AppHost
{
public static IHost Create()
{
return Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
{
config.Sources.Clear();
config.SetBasePath(AppContext.BaseDirectory);
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
config.AddEnvironmentVariables();
})
.ConfigureServices((context, services) =>
{
services.AddSingleton<IDatabaseConnectionFactory, SqlServerConnectionFactory>();
services.AddSingleton<DatabaseInitializer>();
services.AddSingleton<InstrumentRepository>();
services.AddSingleton<CatalogPageParser>();
services.AddSingleton<DetailPageParser>();
services.AddSingleton<KtoPoveritClient>();
services.AddSingleton<PdfStorageService>();
services.AddSingleton<InstrumentCatalogService>();
services.AddSingleton<IPdfOpener, PdfShellService>();
services.AddSingleton<IFilePickerService, FilePickerService>();
services.AddTransient<MainWindow>(provider => new MainWindow(
provider.GetRequiredService<InstrumentCatalogService>(),
provider.GetRequiredService<IPdfOpener>(),
provider.GetRequiredService<IFilePickerService>()));
})
.UseDefaultServiceProvider((_, options) =>
{
options.ValidateOnBuild = true;
options.ValidateScopes = true;
})
.Build();
}
}

10
AssemblyInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

37
CRAWLER.csproj Normal file
View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<Compile Remove="obj-temp-*\**\*.cs;bin-temp-*\**\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="SampleData\catalog-page-sample.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="SampleData\detail-page-sample.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

25
CRAWLER.sln Normal file
View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.4.11626.88 stable
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CRAWLER", "CRAWLER.csproj", "{99A6FD50-529F-431C-8F74-7F37BBA2948E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{99A6FD50-529F-431C-8F74-7F37BBA2948E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{99A6FD50-529F-431C-8F74-7F37BBA2948E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{99A6FD50-529F-431C-8F74-7F37BBA2948E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{99A6FD50-529F-431C-8F74-7F37BBA2948E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {607CB39E-759D-4950-B3DE-F3008151509A}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,12 @@
namespace CRAWLER.Configuration;
internal sealed class CrawlerOptions
{
public string BaseUrl { get; set; } = "https://www.ktopoverit.ru";
public string CatalogPathFormat { get; set; } = "/poverka/gosreestr_sredstv_izmereniy?page={0}";
public int RequestDelayMilliseconds { get; set; } = 350;
public int DefaultPagesToScan { get; set; } = 1;
public string PdfStoragePath { get; set; } = "%LOCALAPPDATA%\\CRAWLER\\PdfStore";
public int TimeoutSeconds { get; set; } = 30;
public string UserAgent { get; set; } = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) CRAWLER/1.0";
}

View File

@@ -0,0 +1,19 @@
namespace CRAWLER.Configuration;
internal sealed class DatabaseOptions
{
public string ApplicationName { get; set; } = "CRAWLER";
public int CommandTimeoutSeconds { get; set; } = 60;
public int ConnectRetryCount { get; set; } = 3;
public int ConnectRetryIntervalSeconds { get; set; } = 5;
public int ConnectTimeoutSeconds { get; set; } = 15;
public string Database { get; set; } = "CRAWLER";
public bool Encrypt { get; set; }
public bool IntegratedSecurity { get; set; } = true;
public bool MultipleActiveResultSets { get; set; } = true;
public bool Pooling { get; set; } = true;
public int MaxPoolSize { get; set; } = 100;
public int MinPoolSize { get; set; }
public string Server { get; set; } = @".\SQLEXPRESS";
public bool TrustServerCertificate { get; set; } = true;
}

View File

@@ -0,0 +1,175 @@
<Window x:Class="CRAWLER.Dialogs.EditInstrumentWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{Binding WindowTitle}"
Height="820"
Width="980"
MinHeight="680"
MinWidth="860"
WindowStartupLocation="CenterOwner"
ResizeMode="CanResize">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TabControl Grid.Row="0">
<TabItem Header="Основное">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="210" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Номер в госреестре" />
<TextBox Grid.Row="0" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.RegistryNumber, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Наименование" />
<TextBox Grid.Row="1" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.Name, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="2" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Тип" />
<TextBox Grid.Row="2" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.TypeDesignation, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="3" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Производитель" />
<TextBox Grid.Row="3" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.Manufacturer, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" />
<TextBlock Grid.Row="4" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="МПИ" />
<TextBox Grid.Row="4" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.VerificationInterval, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="5" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Срок/зав. номер" />
<TextBox Grid.Row="5" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.CertificateOrSerialNumber, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="6" Grid.Column="0" Margin="0,0,8,8" VerticalAlignment="Center" Text="Ссылка на источник" />
<TextBox Grid.Row="6" Grid.Column="1" Margin="0,0,0,8" Text="{Binding Draft.DetailUrl, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="7" Grid.Column="0" Margin="0,0,8,0" VerticalAlignment="Center" Text="Источник записи" />
<TextBox Grid.Row="7" Grid.Column="1" Text="{Binding Draft.SourceSystem, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</ScrollViewer>
</TabItem>
<TabItem Header="Текстовые поля">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8">
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Поверка партии" />
<TextBox Margin="0,0,0,10" Text="{Binding Draft.AllowsBatchVerification, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Периодическая поверка" />
<TextBox Margin="0,0,0,10" Text="{Binding Draft.HasPeriodicVerification, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Сведения о типе" />
<TextBox Margin="0,0,0,10" Text="{Binding Draft.TypeInfo, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Назначение" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.Purpose, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Описание" />
<TextBox Margin="0,0,0,10" MinHeight="70" Text="{Binding Draft.Description, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Программное обеспечение" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.Software, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Метрологические и технические характеристики" />
<TextBox Margin="0,0,0,10" MinHeight="70" Text="{Binding Draft.MetrologicalCharacteristics, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Комплектность" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.Completeness, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Поверка" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.Verification, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Нормативные и технические документы" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.RegulatoryDocuments, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Заявитель" />
<TextBox Margin="0,0,0,10" MinHeight="60" Text="{Binding Draft.Applicant, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Испытательный центр" />
<TextBox MinHeight="60" Text="{Binding Draft.TestCenter, UpdateSourceTrigger=PropertyChanged}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="PDF">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Margin="0,0,0,8"
Text="PDF, уже сохранённые у записи, показаны ниже. Новые файлы будут скопированы при сохранении." />
<GroupBox Grid.Row="1" Header="Уже привязанные PDF">
<Grid Margin="8">
<DataGrid ItemsSource="{Binding ExistingAttachments}"
IsReadOnly="True"
Height="180">
<DataGrid.Columns>
<DataGridTextColumn Width="160" Binding="{Binding Kind}" Header="Тип" />
<DataGridTextColumn Width="200" Binding="{Binding Title}" Header="Заголовок" />
<DataGridTextColumn Width="*" Binding="{Binding LocalPath}" Header="Локальный путь" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</GroupBox>
<GroupBox Grid.Row="2" Margin="0,12,0,0" Header="Очередь новых PDF">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
Margin="0,0,0,8"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Click="BrowsePdfButton_Click"
Content="Добавить PDF..." />
<Button Click="RemovePendingPdfButton_Click"
Content="Убрать из очереди" />
</StackPanel>
<DataGrid Grid.Row="1"
ItemsSource="{Binding PendingPdfFiles}"
SelectedItem="{Binding SelectedPendingPdf, Mode=TwoWay}"
IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Width="220" Binding="{Binding DisplayName}" Header="Файл" />
<DataGridTextColumn Width="*" Binding="{Binding SourcePath}" Header="Исходный путь" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</GroupBox>
</Grid>
</TabItem>
</TabControl>
<StackPanel Grid.Row="1"
Margin="0,12,0,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Click="SaveButton_Click"
Style="{StaticResource PrimaryActionButtonStyle}"
Content="Сохранить" />
<Button Click="CancelButton_Click"
Content="Отмена" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,46 @@
using System.Windows;
using CRAWLER.Services;
using CRAWLER.ViewModels;
namespace CRAWLER.Dialogs;
public partial class EditInstrumentWindow : Window
{
private readonly IFilePickerService _filePickerService;
internal EditInstrumentWindow(EditInstrumentWindowViewModel viewModel, IFilePickerService filePickerService)
{
InitializeComponent();
ViewModel = viewModel;
_filePickerService = filePickerService;
DataContext = ViewModel;
}
internal EditInstrumentWindowViewModel ViewModel { get; }
private void BrowsePdfButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.AddPendingFiles(_filePickerService.PickPdfFiles(true));
}
private void RemovePendingPdfButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.RemovePendingSelected();
}
private void SaveButton_Click(object sender, RoutedEventArgs e)
{
if (!ViewModel.Validate(out var errorMessage))
{
MessageBox.Show(errorMessage, "CRAWLER", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
DialogResult = true;
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}

View File

@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows.Input;
namespace CRAWLER.Infrastructure;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
internal sealed class RelayCommand : ICommand
{
private readonly Action<object> _execute;
private readonly Predicate<object> _canExecute;
public RelayCommand(Action<object> execute, Predicate<object> canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter)
{
return _canExecute == null || _canExecute(parameter);
}
public void Execute(object parameter)
{
_execute(parameter);
}
public void RaiseCanExecuteChanged()
{
var handler = CanExecuteChanged;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
}
internal sealed class AsyncRelayCommand : ICommand
{
private readonly Func<object, Task> _executeAsync;
private readonly Predicate<object> _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> executeAsync, Func<bool> canExecute = null)
: this(_ => executeAsync(), canExecute == null ? null : new Predicate<object>(_ => canExecute()))
{
}
public AsyncRelayCommand(Func<object, Task> executeAsync, Predicate<object> canExecute = null)
{
_executeAsync = executeAsync ?? throw new ArgumentNullException(nameof(executeAsync));
_canExecute = canExecute;
}
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter)
{
return !_isExecuting && (_canExecute == null || _canExecute(parameter));
}
public async void Execute(object parameter)
{
await ExecuteAsync(parameter);
}
public async Task ExecuteAsync(object parameter = null)
{
if (!CanExecute(parameter))
{
return;
}
try
{
_isExecuting = true;
RaiseCanExecuteChanged();
await _executeAsync(parameter);
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged()
{
var handler = CanExecuteChanged;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
}

268
MainWindow.xaml Normal file
View File

@@ -0,0 +1,268 @@
<Window x:Class="CRAWLER.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"
mc:Ignorable="d"
Title="CRAWLER"
Height="920"
Width="1560"
MinHeight="760"
MinWidth="1240"
WindowState="Maximized"
Loaded="Window_Loaded">
<Window.Resources>
<Style x:Key="GhostGridSplitterStyle" TargetType="GridSplitter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="ShowsPreview" Value="True" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#D7DADF" />
<Setter Property="BorderBrush" Value="#BCC1C7" />
<Setter Property="BorderThickness" Value="1" />
</Trigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
Margin="0,0,0,12"
Padding="12"
Background="{StaticResource AppPanelBrush}"
BorderBrush="{StaticResource AppBorderBrush}"
BorderThickness="1"
CornerRadius="4">
<DockPanel LastChildFill="False">
<StackPanel DockPanel.Dock="Left"
Orientation="Horizontal"
VerticalAlignment="Center">
<TextBlock Margin="0,0,8,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="Поиск" />
<TextBox Width="320"
Margin="0,0,10,0"
VerticalAlignment="Center"
KeyDown="SearchTextBox_KeyDown"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}" />
<Button Margin="0,0,8,0"
Click="RefreshButton_Click"
Content="Обновить список" />
<TextBlock Margin="12,0,8,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="Страниц для сайта" />
<TextBox Width="64"
Margin="0,0,10,0"
VerticalAlignment="Center"
Text="{Binding PagesToScan, UpdateSourceTrigger=PropertyChanged}" />
<Button Margin="0,0,8,0"
Style="{StaticResource PrimaryActionButtonStyle}"
Click="SyncButton_Click"
Content="Обновить с сайта" />
</StackPanel>
<StackPanel DockPanel.Dock="Right"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Margin="0,0,8,0"
Click="AddButton_Click"
Content="Добавить" />
<Button Margin="0,0,8,0"
Click="EditButton_Click"
Content="Изменить" />
<Button Click="DeleteButton_Click"
Content="Удалить" />
</StackPanel>
</DockPanel>
</Border>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2.2*" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="1.6*" />
</Grid.ColumnDefinitions>
<GroupBox Grid.Column="0" Header="Реестр средств измерений">
<Grid Margin="8">
<DataGrid x:Name="InstrumentGrid"
ItemsSource="{Binding Instruments}"
SelectedItem="{Binding SelectedSummary, Mode=TwoWay}"
IsReadOnly="True"
MouseDoubleClick="InstrumentGrid_MouseDoubleClick">
<DataGrid.Columns>
<DataGridTextColumn Width="120" Binding="{Binding RegistryNumber}" Header="Госреестр" />
<DataGridTextColumn Width="2*" Binding="{Binding Name}" Header="Наименование" />
<DataGridTextColumn Width="160" Binding="{Binding TypeDesignation}" Header="Тип" />
<DataGridTextColumn Width="2*" Binding="{Binding Manufacturer}" Header="Производитель" />
<DataGridTextColumn Width="120" Binding="{Binding VerificationInterval}" Header="МПИ" />
<DataGridTextColumn Width="110" Binding="{Binding SourceSystem}" Header="Источник" />
<DataGridTextColumn Width="140" Binding="{Binding UpdatedAt, StringFormat=dd.MM.yyyy HH:mm}" Header="Обновлено" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</GroupBox>
<GridSplitter Grid.Column="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"
Style="{StaticResource GhostGridSplitterStyle}"
Cursor="SizeWE" />
<GroupBox Grid.Column="2" Header="Карточка записи">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DockPanel Grid.Row="0" Margin="0,0,0,8" LastChildFill="False">
<TextBlock DockPanel.Dock="Left"
FontSize="16"
FontWeight="SemiBold"
Text="{Binding SelectedInstrument.Name}" />
<Button DockPanel.Dock="Right"
Padding="8,4"
Click="OpenSourceButton_Click"
Content="Открыть источник" />
</DockPanel>
<TabControl Grid.Row="1">
<TabItem Header="Общее">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="180" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Номер в госреестре" />
<TextBox Grid.Row="0" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.RegistryNumber}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Тип" />
<TextBox Grid.Row="1" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.TypeDesignation}" />
<TextBlock Grid.Row="2" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Производитель" />
<TextBox Grid.Row="2" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.Manufacturer}" TextWrapping="Wrap" />
<TextBlock Grid.Row="3" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="МПИ" />
<TextBox Grid.Row="3" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.VerificationInterval}" />
<TextBlock Grid.Row="4" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Срок/зав. номер" />
<TextBox Grid.Row="4" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.CertificateOrSerialNumber}" />
<TextBlock Grid.Row="5" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Поверка партии" />
<TextBox Grid.Row="5" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.AllowsBatchVerification}" />
<TextBlock Grid.Row="6" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Периодическая поверка" />
<TextBox Grid.Row="6" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.HasPeriodicVerification}" />
<TextBlock Grid.Row="7" Grid.Column="0" Margin="0,0,8,6" VerticalAlignment="Center" Text="Источник" />
<TextBox Grid.Row="7" Grid.Column="1" Margin="0,0,0,6" IsReadOnly="True" Text="{Binding SelectedInstrument.SourceSystem}" />
<TextBlock Grid.Row="8" Grid.Column="0" Margin="0,0,8,0" VerticalAlignment="Top" Text="Ссылка" />
<TextBox Grid.Row="8" Grid.Column="1" IsReadOnly="True" Text="{Binding SelectedInstrument.DetailUrl}" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" />
</Grid>
</ScrollViewer>
</TabItem>
<TabItem Header="Текстовые поля">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8">
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Назначение" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.Purpose}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Описание" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="70" Text="{Binding SelectedInstrument.Description}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Программное обеспечение" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.Software}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Метрологические и технические характеристики" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="70" Text="{Binding SelectedInstrument.MetrologicalCharacteristics}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Комплектность" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.Completeness}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Поверка" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.Verification}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Нормативные и технические документы" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.RegulatoryDocuments}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Заявитель" />
<TextBox Margin="0,0,0,10" IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.Applicant}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
<TextBlock Margin="0,0,0,4" FontWeight="SemiBold" Text="Испытательный центр" />
<TextBox IsReadOnly="True" MinHeight="60" Text="{Binding SelectedInstrument.TestCenter}" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Auto" />
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="PDF">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0"
Margin="0,0,0,8"
Orientation="Horizontal">
<Button Margin="0,0,8,0"
Click="AddPdfButton_Click"
Content="Добавить PDF вручную" />
<Button Margin="0,0,8,0"
Click="OpenAttachmentButton_Click"
Content="Открыть выбранный PDF" />
<Button Click="RemoveAttachmentButton_Click"
Content="Удалить привязку" />
</StackPanel>
<DataGrid x:Name="AttachmentGrid"
Grid.Row="1"
ItemsSource="{Binding SelectedInstrument.Attachments}"
IsReadOnly="True">
<DataGrid.Columns>
<DataGridTextColumn Width="160" Binding="{Binding Kind}" Header="Тип файла" />
<DataGridTextColumn Width="180" Binding="{Binding Title}" Header="Заголовок" />
<DataGridTextColumn Width="*" Binding="{Binding LocalPath}" Header="Локальный путь" />
<DataGridCheckBoxColumn Width="100" Binding="{Binding IsManual}" Header="Ручной" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>
</TabControl>
</Grid>
</GroupBox>
</Grid>
<StatusBar Grid.Row="2" Margin="0,12,0,0">
<StatusBarItem>
<TextBlock Text="{Binding StatusText}" />
</StatusBarItem>
</StatusBar>
</Grid>
</Window>

188
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,188 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
using CRAWLER.Models;
using CRAWLER.Services;
using CRAWLER.ViewModels;
namespace CRAWLER;
public partial class MainWindow : Window
{
private readonly IFilePickerService _filePickerService;
private readonly MainWindowViewModel _viewModel;
internal MainWindow(
InstrumentCatalogService catalogService,
IPdfOpener pdfOpener,
IFilePickerService filePickerService)
{
InitializeComponent();
_filePickerService = filePickerService;
_viewModel = new MainWindowViewModel(catalogService, pdfOpener);
DataContext = _viewModel;
}
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
await ExecuteUiAsync(_viewModel.InitializeAsync);
}
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
{
await ExecuteUiAsync(() => _viewModel.RefreshAsync());
}
private async void SyncButton_Click(object sender, RoutedEventArgs e)
{
await ExecuteUiAsync(async () =>
{
var result = await _viewModel.SyncAsync();
MessageBox.Show(
$"Обработано страниц: {result.PagesScanned}\n" +
$"Обработано записей: {result.ProcessedItems}\n" +
$"Добавлено: {result.AddedRecords}\n" +
$"Обновлено: {result.UpdatedRecords}\n" +
$"Пропущено страниц: {result.FailedPages}\n" +
$"Пропущено записей: {result.FailedItems}\n" +
$"Карточек без деталей: {result.SkippedDetailRequests}\n" +
$"Скачано PDF: {result.DownloadedPdfFiles}\n" +
$"Ошибок PDF: {result.FailedPdfFiles}",
"CRAWLER",
MessageBoxButton.OK,
MessageBoxImage.Information);
});
}
private async void AddButton_Click(object sender, RoutedEventArgs e)
{
var dialog = new Dialogs.EditInstrumentWindow(
new EditInstrumentWindowViewModel(_viewModel.CreateNewDraft(), true),
_filePickerService)
{
Owner = this
};
if (dialog.ShowDialog() == true)
{
await ExecuteUiAsync(() => _viewModel.SaveAsync(dialog.ViewModel.Draft, dialog.ViewModel.GetPendingPaths()));
}
}
private async void EditButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedInstrument == null)
{
return;
}
var dialog = new Dialogs.EditInstrumentWindow(
new EditInstrumentWindowViewModel(_viewModel.CreateDraftFromSelected(), false),
_filePickerService)
{
Owner = this
};
if (dialog.ShowDialog() == true)
{
await ExecuteUiAsync(() => _viewModel.SaveAsync(dialog.ViewModel.Draft, dialog.ViewModel.GetPendingPaths()));
}
}
private async void DeleteButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedInstrument == null)
{
return;
}
var answer = MessageBox.Show(
$"Удалить запись \"{_viewModel.SelectedInstrument.Name}\"?",
"CRAWLER",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (answer == MessageBoxResult.Yes)
{
await ExecuteUiAsync(_viewModel.DeleteSelectedAsync);
}
}
private async void AddPdfButton_Click(object sender, RoutedEventArgs e)
{
if (_viewModel.SelectedInstrument == null)
{
return;
}
var paths = _filePickerService.PickPdfFiles(true);
if (paths.Count == 0)
{
return;
}
await ExecuteUiAsync(() => _viewModel.AddAttachmentsToSelectedAsync(paths));
}
private void OpenAttachmentButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.OpenAttachment(AttachmentGrid.SelectedItem as PdfAttachment);
}
private async void RemoveAttachmentButton_Click(object sender, RoutedEventArgs e)
{
var attachment = AttachmentGrid.SelectedItem as PdfAttachment;
if (attachment == null)
{
return;
}
var answer = MessageBox.Show(
$"Удалить привязку к PDF \"{attachment.DisplayName}\"?",
"CRAWLER",
MessageBoxButton.YesNo,
MessageBoxImage.Warning);
if (answer == MessageBoxResult.Yes)
{
await ExecuteUiAsync(() => _viewModel.RemoveAttachmentAsync(attachment));
}
}
private void OpenSourceButton_Click(object sender, RoutedEventArgs e)
{
_viewModel.OpenSourceUrl();
}
private async void InstrumentGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (_viewModel.SelectedInstrument != null)
{
EditButton_Click(sender, e);
}
await Task.CompletedTask;
}
private async void SearchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
e.Handled = true;
await ExecuteUiAsync(() => _viewModel.RefreshAsync());
}
}
private async Task ExecuteUiAsync(Func<Task> action)
{
try
{
await action();
}
catch (Exception ex)
{
MessageBox.Show(ex.Message, "CRAWLER", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}

150
Models/InstrumentModels.cs Normal file
View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace CRAWLER.Models;
internal sealed class InstrumentSummary
{
public long Id { get; set; }
public string RegistryNumber { get; set; }
public string Name { get; set; }
public string TypeDesignation { get; set; }
public string Manufacturer { get; set; }
public string VerificationInterval { get; set; }
public string SourceSystem { get; set; }
public DateTime UpdatedAt { get; set; }
}
internal sealed class InstrumentRecord
{
public long Id { get; set; }
public string RegistryNumber { get; set; }
public string Name { get; set; }
public string TypeDesignation { get; set; }
public string Manufacturer { get; set; }
public string VerificationInterval { get; set; }
public string CertificateOrSerialNumber { get; set; }
public string AllowsBatchVerification { get; set; }
public string HasPeriodicVerification { get; set; }
public string TypeInfo { get; set; }
public string Purpose { get; set; }
public string Description { get; set; }
public string Software { get; set; }
public string MetrologicalCharacteristics { get; set; }
public string Completeness { get; set; }
public string Verification { get; set; }
public string RegulatoryDocuments { get; set; }
public string Applicant { get; set; }
public string TestCenter { get; set; }
public string DetailUrl { get; set; }
public string SourceSystem { get; set; } = "Manual";
public DateTime? LastImportedAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public List<PdfAttachment> Attachments { get; set; } = new List<PdfAttachment>();
public InstrumentRecord Clone()
{
var copy = (InstrumentRecord)MemberwiseClone();
copy.Attachments = new List<PdfAttachment>();
foreach (var attachment in Attachments)
{
copy.Attachments.Add(attachment.Clone());
}
return copy;
}
}
internal sealed class PdfAttachment
{
public long Id { get; set; }
public long InstrumentId { get; set; }
public string Kind { get; set; }
public string Title { get; set; }
public string SourceUrl { get; set; }
public string LocalPath { get; set; }
public bool IsManual { get; set; }
public DateTime CreatedAt { get; set; }
public string DisplayName
{
get
{
if (!string.IsNullOrWhiteSpace(Title))
{
return Title;
}
if (!string.IsNullOrWhiteSpace(LocalPath))
{
return Path.GetFileName(LocalPath);
}
return SourceUrl ?? string.Empty;
}
}
public PdfAttachment Clone()
{
return (PdfAttachment)MemberwiseClone();
}
}
internal sealed class PendingPdfFile
{
public string SourcePath { get; set; }
public string DisplayName { get; set; }
}
internal sealed class CatalogListItem
{
public string RegistryNumber { get; set; }
public string Name { get; set; }
public string TypeDesignation { get; set; }
public string Manufacturer { get; set; }
public string VerificationInterval { get; set; }
public string CertificateOrSerialNumber { get; set; }
public string DetailUrl { get; set; }
public string DescriptionTypePdfUrl { get; set; }
public string MethodologyPdfUrl { get; set; }
}
internal sealed class ParsedInstrumentDetails
{
public string RegistryNumber { get; set; }
public string Name { get; set; }
public string TypeDesignation { get; set; }
public string Manufacturer { get; set; }
public string VerificationInterval { get; set; }
public string CertificateOrSerialNumber { get; set; }
public string AllowsBatchVerification { get; set; }
public string HasPeriodicVerification { get; set; }
public string TypeInfo { get; set; }
public string Purpose { get; set; }
public string Description { get; set; }
public string Software { get; set; }
public string MetrologicalCharacteristics { get; set; }
public string Completeness { get; set; }
public string Verification { get; set; }
public string RegulatoryDocuments { get; set; }
public string Applicant { get; set; }
public string TestCenter { get; set; }
public string DescriptionTypePdfUrl { get; set; }
public string MethodologyPdfUrl { get; set; }
}
internal sealed class SyncResult
{
public int PagesScanned { get; set; }
public int ProcessedItems { get; set; }
public int AddedRecords { get; set; }
public int UpdatedRecords { get; set; }
public int DownloadedPdfFiles { get; set; }
public int FailedPdfFiles { get; set; }
public int FailedPages { get; set; }
public int FailedItems { get; set; }
public int SkippedDetailRequests { get; set; }
}

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using CRAWLER.Models;
using HtmlAgilityPack;
namespace CRAWLER.Parsing;
internal sealed class CatalogPageParser
{
public IReadOnlyList<CatalogListItem> Parse(string html, string baseUrl)
{
var document = new HtmlDocument();
document.LoadHtml(html ?? string.Empty);
var items = new List<CatalogListItem>();
var nodes = document.DocumentNode.SelectNodes("//div[contains(concat(' ', normalize-space(@class), ' '), ' sivreestre ')]");
if (nodes == null)
{
return items;
}
foreach (var node in nodes)
{
var item = new CatalogListItem
{
RegistryNumber = ReadBlockValue(node, "№ в госреестре"),
Name = ReadBlockValue(node, "Наименование"),
TypeDesignation = ReadBlockValue(node, "Тип"),
Manufacturer = ReadBlockValue(node, "Производитель"),
VerificationInterval = ReadBlockValue(node, "МПИ"),
CertificateOrSerialNumber = ReadBlockValue(node, "Cвидетельство завод. номер")
?? ReadBlockValue(node, "Свидетельство завод. номер"),
DetailUrl = ReadDetailUrl(node, baseUrl),
DescriptionTypePdfUrl = ReadPdfUrl(node, "/prof/opisanie/", baseUrl),
MethodologyPdfUrl = ReadPdfUrl(node, "/prof/metodiki/", baseUrl)
};
if (!string.IsNullOrWhiteSpace(item.RegistryNumber) || !string.IsNullOrWhiteSpace(item.Name))
{
items.Add(item);
}
}
return items;
}
private static string ReadBlockValue(HtmlNode root, string header)
{
var block = FindBlockByHeader(root, header);
return HtmlParsingHelpers.ExtractNodeTextExcludingChildParagraph(block);
}
private static HtmlNode FindBlockByHeader(HtmlNode root, string header)
{
var blocks = root.SelectNodes("./div");
if (blocks == null)
{
return null;
}
foreach (var block in blocks)
{
var paragraph = block.SelectSingleNode("./p");
var label = HtmlParsingHelpers.NormalizeLabel(paragraph?.InnerText);
if (string.Equals(label, header, StringComparison.OrdinalIgnoreCase))
{
return block;
}
}
return null;
}
private static string ReadDetailUrl(HtmlNode root, string baseUrl)
{
var link = root.SelectSingleNode(".//div[contains(@class,'resulttable6')]/a[1]")
?? root.SelectSingleNode(".//div[contains(@class,'resulttable4')]/a[1]");
return HtmlParsingHelpers.MakeAbsoluteUrl(baseUrl, link?.GetAttributeValue("href", null));
}
private static string ReadPdfUrl(HtmlNode root, string marker, string baseUrl)
{
var link = root.SelectSingleNode($".//a[contains(@href,'{marker}')]");
return HtmlParsingHelpers.MakeAbsoluteUrl(baseUrl, link?.GetAttributeValue("href", null));
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using CRAWLER.Models;
using HtmlAgilityPack;
namespace CRAWLER.Parsing;
internal sealed class DetailPageParser
{
public ParsedInstrumentDetails Parse(string html, string baseUrl)
{
var document = new HtmlDocument();
document.LoadHtml(html ?? string.Empty);
var rows = document.DocumentNode.SelectNodes("//table[contains(@class,'resulttable1')]//tr");
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (rows != null)
{
foreach (var row in rows)
{
var cells = row.SelectNodes("./td");
if (cells == null || cells.Count < 2)
{
continue;
}
var label = HtmlParsingHelpers.NormalizeLabel(cells[0].InnerText);
var value = HtmlParsingHelpers.NormalizeWhitespace(cells[1].InnerText);
if (!string.IsNullOrWhiteSpace(label))
{
values[label] = value;
}
}
}
return new ParsedInstrumentDetails
{
RegistryNumber = Get(values, "Номер в госреестре"),
Name = Get(values, "Наименование"),
TypeDesignation = Get(values, "Обозначение типа"),
Manufacturer = Get(values, "Производитель"),
VerificationInterval = Get(values, "Межповерочный интервал (МПИ)"),
CertificateOrSerialNumber = Get(values, "Срок свидетельства или заводской номер"),
AllowsBatchVerification = Get(values, "Допускается поверка партии"),
HasPeriodicVerification = Get(values, "Наличие периодической поверки"),
TypeInfo = Get(values, "Сведения о типе"),
Purpose = Get(values, "Назначение"),
Description = Get(values, "Описание"),
Software = Get(values, "Программное обеспечение"),
MetrologicalCharacteristics = Get(values, "Метрологические и технические характеристики"),
Completeness = Get(values, "Комплектность"),
Verification = Get(values, "Поверка"),
RegulatoryDocuments = Get(values, "Нормативные и технические документы"),
Applicant = Get(values, "Заявитель"),
TestCenter = Get(values, "Испытательный центр"),
DescriptionTypePdfUrl = HtmlParsingHelpers.MakeAbsoluteUrl(baseUrl, document.DocumentNode.SelectSingleNode("//a[contains(@href,'/prof/opisanie/')]")?.GetAttributeValue("href", null)),
MethodologyPdfUrl = HtmlParsingHelpers.MakeAbsoluteUrl(baseUrl, document.DocumentNode.SelectSingleNode("//a[contains(@href,'/prof/metodiki/')]")?.GetAttributeValue("href", null))
};
}
private static string Get(IDictionary<string, string> values, string key)
{
return values.TryGetValue(key, out var value) ? value : null;
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Text.RegularExpressions;
using HtmlAgilityPack;
namespace CRAWLER.Parsing;
internal static class HtmlParsingHelpers
{
public static string NormalizeWhitespace(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var decoded = HtmlEntity.DeEntitize(value);
decoded = decoded.Replace('\u00A0', ' ');
decoded = Regex.Replace(decoded, @"\s+", " ");
return decoded.Trim();
}
public static string NormalizeLabel(string value)
{
return NormalizeWhitespace(value)
?.Replace(" :", ":")
.Trim(':', ' ');
}
public static string MakeAbsoluteUrl(string baseUrl, string urlOrPath)
{
if (string.IsNullOrWhiteSpace(urlOrPath))
{
return null;
}
if (Uri.TryCreate(urlOrPath, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.ToString();
}
var baseUri = new Uri(baseUrl.TrimEnd('/') + "/");
return new Uri(baseUri, urlOrPath.TrimStart('/')).ToString();
}
public static string ExtractNodeTextExcludingChildParagraph(HtmlNode node)
{
if (node == null)
{
return null;
}
var clone = node.CloneNode(true);
var paragraphs = clone.SelectNodes("./p");
if (paragraphs != null)
{
foreach (var paragraph in paragraphs)
{
paragraph.Remove();
}
}
return NormalizeWhitespace(clone.InnerText);
}
}

View File

@@ -0,0 +1,31 @@
<div class="resulttable11">
<div class="sivreestre">
<div class="resulttable4">
<p>№ в госреестре</p>
<a href="/poverka/gosreestr_sredstv_izmereniy/1427888/Raskhodomery-schetchiki_elektromagnitnyye">97957-26</a>
</div>
<div class="resulttable6">
<p>Наименование</p>
<a href="/poverka/gosreestr_sredstv_izmereniy/1427888/Raskhodomery-schetchiki_elektromagnitnyye">Расходомеры-счетчики электромагнитные</a>
</div>
<div class="resulttable4">
<p>Тип</p>Счетовод
</div>
<div class="resulttable4">
<p>Описание типа</p>
<a rel="nofollow" target="_blank" href="/prof/opisanie/97957-26.pdf">Скачать</a>
<p>Методики поверки</p>
<a rel="nofollow" target="_blank" href="/prof/metodiki/97957-26.pdf">Скачать</a>
</div>
<div class="resulttable4">
<p>МПИ</p>60 мес.
</div>
<div class="resulttable4">
<p>Cвидетельство<br>завод. номер</p>00033, 00032, 00031, 0003010.03.2031
</div>
<div class="resulttable6">
<p>Производитель</p>
<a href="/poverka?view=gosreestr_sredstv_izmereniy&amp;izgotov=test">Общество с ограниченной ответственностью «Производственная фирма «Гидродинамика»</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
<table class="resulttable1">
<tr><td class="resulttable2">Номер в госреестре</td><td class="resulttable"><div class="alllinks"><div class="onelink">97957-26</div></div></td></tr>
<tr><td class="resulttable2">Наименование</td><td class="resulttable">Расходомеры-счетчики электромагнитные</td></tr>
<tr><td class="resulttable2">Обозначение типа</td><td class="resulttable">Счетовод</td></tr>
<tr><td class="resulttable2">Производитель</td><td class="resulttable">Общество с ограниченной ответственностью «Производственная фирма «Гидродинамика»</td></tr>
<tr><td class="resulttable2">Описание типа</td><td class="resulttable"><a rel="nofollow" target="_blank" href="/prof/opisanie/97957-26.pdf">Скачать</a></td></tr>
<tr><td class="resulttable2">Методика поверки</td><td class="resulttable"><a rel="nofollow" target="_blank" href="/prof/metodiki/97957-26.pdf">Скачать</a></td></tr>
<tr><td class="resulttable2">Межповерочный интервал (МПИ)</td><td class="resulttable">60 мес.</td></tr>
<tr><td class="resulttable2">Допускается поверка партии</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Наличие периодической поверки</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Сведения о типе</td><td class="resulttable">Заводской номер</td></tr>
<tr><td class="resulttable2">Срок свидетельства или заводской номер</td><td class="resulttable">10.03.2031</td></tr>
<tr><td class="resulttable2">Назначение</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Описание</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Программное обеспечение</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Метрологические и технические характеристики</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Комплектность</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Поверка</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Нормативные и технические документы</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Заявитель</td><td class="resulttable"></td></tr>
<tr><td class="resulttable2">Испытательный центр</td><td class="resulttable"></td></tr>
</table>

View File

@@ -0,0 +1,141 @@
using Microsoft.Data.SqlClient;
namespace CRAWLER.Services;
internal sealed class DatabaseInitializer
{
private readonly IDatabaseConnectionFactory _connectionFactory;
public DatabaseInitializer(IDatabaseConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task EnsureCreatedAsync(CancellationToken cancellationToken)
{
await EnsureDatabaseExistsAsync(cancellationToken);
await EnsureSchemaAsync(cancellationToken);
}
private async Task EnsureDatabaseExistsAsync(CancellationToken cancellationToken)
{
await using var connection = _connectionFactory.CreateMasterConnection();
await connection.OpenAsync(cancellationToken);
var safeDatabaseName = _connectionFactory.Options.Database.Replace("]", "]]");
var sql = $@"
IF DB_ID(N'{safeDatabaseName}') IS NULL
BEGIN
CREATE DATABASE [{safeDatabaseName}];
END";
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
var scripts = new[]
{
@"
IF OBJECT_ID(N'dbo.Instruments', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Instruments
(
Id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Instruments PRIMARY KEY,
RegistryNumber NVARCHAR(64) NULL,
Name NVARCHAR(512) NOT NULL,
TypeDesignation NVARCHAR(512) NULL,
Manufacturer NVARCHAR(2000) NULL,
VerificationInterval NVARCHAR(512) NULL,
CertificateOrSerialNumber NVARCHAR(512) NULL,
AllowsBatchVerification NVARCHAR(256) NULL,
HasPeriodicVerification NVARCHAR(256) NULL,
TypeInfo NVARCHAR(256) NULL,
Purpose NVARCHAR(MAX) NULL,
Description NVARCHAR(MAX) NULL,
Software NVARCHAR(MAX) NULL,
MetrologicalCharacteristics NVARCHAR(MAX) NULL,
Completeness NVARCHAR(MAX) NULL,
Verification NVARCHAR(MAX) NULL,
RegulatoryDocuments NVARCHAR(MAX) NULL,
Applicant NVARCHAR(MAX) NULL,
TestCenter NVARCHAR(MAX) NULL,
DetailUrl NVARCHAR(1024) NULL,
SourceSystem NVARCHAR(64) NOT NULL CONSTRAINT DF_Instruments_SourceSystem DEFAULT N'Manual',
LastImportedAt DATETIME2 NULL,
CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_Instruments_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2 NOT NULL CONSTRAINT DF_Instruments_UpdatedAt DEFAULT SYSUTCDATETIME()
);
END",
@"
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_Instruments_RegistryNumber'
AND object_id = OBJECT_ID(N'dbo.Instruments')
)
BEGIN
CREATE UNIQUE INDEX UX_Instruments_RegistryNumber
ON dbo.Instruments (RegistryNumber)
WHERE RegistryNumber IS NOT NULL AND RegistryNumber <> N'';
END",
@"
IF OBJECT_ID(N'dbo.PdfAttachments', N'U') IS NULL
BEGIN
CREATE TABLE dbo.PdfAttachments
(
Id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PdfAttachments PRIMARY KEY,
InstrumentId BIGINT NOT NULL,
Kind NVARCHAR(128) NOT NULL,
Title NVARCHAR(256) NULL,
SourceUrl NVARCHAR(1024) NULL,
LocalPath NVARCHAR(1024) NULL,
IsManual BIT NOT NULL CONSTRAINT DF_PdfAttachments_IsManual DEFAULT (0),
CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_PdfAttachments_CreatedAt DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_PdfAttachments_Instruments
FOREIGN KEY (InstrumentId) REFERENCES dbo.Instruments(Id)
ON DELETE CASCADE
);
END",
@"
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_PdfAttachments_InstrumentId'
AND object_id = OBJECT_ID(N'dbo.PdfAttachments')
)
BEGIN
CREATE INDEX IX_PdfAttachments_InstrumentId
ON dbo.PdfAttachments (InstrumentId, CreatedAt DESC);
END",
@"
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'UX_PdfAttachments_InstrumentId_SourceUrl'
AND object_id = OBJECT_ID(N'dbo.PdfAttachments')
)
BEGIN
CREATE UNIQUE INDEX UX_PdfAttachments_InstrumentId_SourceUrl
ON dbo.PdfAttachments (InstrumentId, SourceUrl)
WHERE SourceUrl IS NOT NULL AND SourceUrl <> N'';
END"
};
foreach (var script in scripts)
{
await using var command = new SqlCommand(script, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.Win32;
namespace CRAWLER.Services;
internal interface IFilePickerService
{
IReadOnlyList<string> PickPdfFiles(bool multiselect);
}
internal sealed class FilePickerService : IFilePickerService
{
public IReadOnlyList<string> PickPdfFiles(bool multiselect)
{
var dialog = new OpenFileDialog
{
Filter = "PDF (*.pdf)|*.pdf",
Multiselect = multiselect,
CheckFileExists = true,
CheckPathExists = true
};
return dialog.ShowDialog() == true
? dialog.FileNames
: Array.Empty<string>();
}
}

View File

@@ -0,0 +1,306 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CRAWLER.Models;
using CRAWLER.Parsing;
namespace CRAWLER.Services;
internal sealed class InstrumentCatalogService
{
private readonly CatalogPageParser _catalogPageParser;
private readonly DatabaseInitializer _databaseInitializer;
private readonly DetailPageParser _detailPageParser;
private readonly InstrumentRepository _repository;
private readonly KtoPoveritClient _client;
private readonly PdfStorageService _pdfStorageService;
public InstrumentCatalogService(
DatabaseInitializer databaseInitializer,
InstrumentRepository repository,
CatalogPageParser catalogPageParser,
DetailPageParser detailPageParser,
KtoPoveritClient client,
PdfStorageService pdfStorageService)
{
_databaseInitializer = databaseInitializer;
_repository = repository;
_catalogPageParser = catalogPageParser;
_detailPageParser = detailPageParser;
_client = client;
_pdfStorageService = pdfStorageService;
}
public int DefaultPagesToScan
{
get { return Math.Max(1, _client.Options.DefaultPagesToScan); }
}
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await _databaseInitializer.EnsureCreatedAsync(cancellationToken);
}
public Task<IReadOnlyList<InstrumentSummary>> SearchAsync(string searchText, CancellationToken cancellationToken)
{
return _repository.SearchAsync(searchText, cancellationToken);
}
public Task<InstrumentRecord> GetByIdAsync(long id, CancellationToken cancellationToken)
{
return _repository.GetByIdAsync(id, cancellationToken);
}
public async Task<long> SaveInstrumentAsync(InstrumentRecord record, IEnumerable<string> pendingPdfPaths, CancellationToken cancellationToken)
{
var id = await _repository.SaveAsync(record, cancellationToken);
if (pendingPdfPaths != null)
{
foreach (var sourcePath in pendingPdfPaths.Where(path => !string.IsNullOrWhiteSpace(path)))
{
var localPath = await _pdfStorageService.CopyFromLocalAsync(sourcePath, record.RegistryNumber, Path.GetFileNameWithoutExtension(sourcePath), cancellationToken);
await _repository.SaveAttachmentAsync(new PdfAttachment
{
InstrumentId = id,
Kind = "Ручной PDF",
Title = Path.GetFileNameWithoutExtension(sourcePath),
LocalPath = localPath,
SourceUrl = null,
IsManual = true
}, cancellationToken);
}
}
return id;
}
public async Task DeleteInstrumentAsync(InstrumentRecord record, CancellationToken cancellationToken)
{
if (record == null)
{
return;
}
foreach (var attachment in record.Attachments)
{
_pdfStorageService.TryDelete(attachment.LocalPath);
}
await _repository.DeleteInstrumentAsync(record.Id, cancellationToken);
}
public async Task RemoveAttachmentAsync(PdfAttachment attachment, CancellationToken cancellationToken)
{
if (attachment == null)
{
return;
}
_pdfStorageService.TryDelete(attachment.LocalPath);
await _repository.DeleteAttachmentAsync(attachment.Id, cancellationToken);
}
public async Task<IReadOnlyList<PdfAttachment>> AddManualAttachmentsAsync(long instrumentId, string registryNumber, IEnumerable<string> sourcePaths, CancellationToken cancellationToken)
{
if (sourcePaths == null)
{
return Array.Empty<PdfAttachment>();
}
var added = new List<PdfAttachment>();
foreach (var sourcePath in sourcePaths.Where(path => !string.IsNullOrWhiteSpace(path)))
{
var localPath = await _pdfStorageService.CopyFromLocalAsync(sourcePath, registryNumber, Path.GetFileNameWithoutExtension(sourcePath), cancellationToken);
var attachment = new PdfAttachment
{
InstrumentId = instrumentId,
Kind = "Ручной PDF",
Title = Path.GetFileNameWithoutExtension(sourcePath),
SourceUrl = null,
LocalPath = localPath,
IsManual = true
};
await _repository.SaveAttachmentAsync(attachment, cancellationToken);
added.Add(attachment);
}
return added;
}
public async Task<SyncResult> SyncFromSiteAsync(int pagesToScan, IProgress<string> progress, CancellationToken cancellationToken)
{
var result = new SyncResult();
var totalPages = Math.Max(1, pagesToScan);
for (var page = 1; page <= totalPages; page++)
{
cancellationToken.ThrowIfCancellationRequested();
progress?.Report($"Чтение страницы {page}...");
IReadOnlyList<CatalogListItem> items;
try
{
var catalogHtml = await _client.GetStringAsync(_client.BuildCatalogPageUrl(page), cancellationToken);
items = _catalogPageParser.Parse(catalogHtml, _client.Options.BaseUrl);
result.PagesScanned++;
}
catch (Exception ex)
{
result.FailedPages++;
progress?.Report($"Страница {page} пропущена: {ex.Message}");
continue;
}
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
progress?.Report($"Обработка {item.RegistryNumber ?? item.Name}...");
try
{
var existingId = await _repository.FindInstrumentIdByRegistryNumberAsync(item.RegistryNumber, cancellationToken);
var existing = existingId.HasValue
? await _repository.GetByIdAsync(existingId.Value, cancellationToken)
: null;
ParsedInstrumentDetails details = null;
if (!string.IsNullOrWhiteSpace(item.DetailUrl))
{
try
{
var detailHtml = await _client.GetStringAsync(item.DetailUrl, cancellationToken);
details = _detailPageParser.Parse(detailHtml, _client.Options.BaseUrl);
}
catch
{
result.SkippedDetailRequests++;
}
}
var merged = Merge(existing, item, details);
merged.Id = existing?.Id ?? 0;
merged.SourceSystem = "KtoPoverit";
merged.DetailUrl = item.DetailUrl ?? existing?.DetailUrl;
merged.LastImportedAt = DateTime.UtcNow;
var savedId = await _repository.SaveAsync(merged, cancellationToken);
result.ProcessedItems++;
if (existing == null)
{
result.AddedRecords++;
}
else
{
result.UpdatedRecords++;
}
await SyncAttachmentAsync(savedId, merged.RegistryNumber, "Описание типа", details?.DescriptionTypePdfUrl ?? item.DescriptionTypePdfUrl, result, cancellationToken);
await SyncAttachmentAsync(savedId, merged.RegistryNumber, "Методика поверки", details?.MethodologyPdfUrl ?? item.MethodologyPdfUrl, result, cancellationToken);
if (_client.Options.RequestDelayMilliseconds > 0)
{
await Task.Delay(_client.Options.RequestDelayMilliseconds, cancellationToken);
}
}
catch (Exception ex)
{
result.FailedItems++;
progress?.Report($"Запись {item.RegistryNumber ?? item.Name} пропущена: {ex.Message}");
}
}
}
progress?.Report($"Готово: страниц {result.PagesScanned}, записей {result.ProcessedItems}, проблемных записей {result.FailedItems}.");
return result;
}
private async Task SyncAttachmentAsync(long instrumentId, string registryNumber, string title, string sourceUrl, SyncResult result, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(sourceUrl))
{
return;
}
var existing = await _repository.FindAttachmentBySourceUrlAsync(instrumentId, sourceUrl, cancellationToken);
if (existing != null && !string.IsNullOrWhiteSpace(existing.LocalPath) && File.Exists(existing.LocalPath))
{
return;
}
try
{
var localPath = await _pdfStorageService.DownloadAsync(sourceUrl, registryNumber, title, cancellationToken);
var attachment = existing ?? new PdfAttachment
{
InstrumentId = instrumentId,
IsManual = false
};
attachment.Kind = title;
attachment.Title = title;
attachment.SourceUrl = sourceUrl;
attachment.LocalPath = localPath;
await _repository.SaveAttachmentAsync(attachment, cancellationToken);
result.DownloadedPdfFiles++;
}
catch
{
result.FailedPdfFiles++;
if (existing == null)
{
await _repository.SaveAttachmentAsync(new PdfAttachment
{
InstrumentId = instrumentId,
Kind = title,
Title = title,
SourceUrl = sourceUrl,
LocalPath = null,
IsManual = false
}, cancellationToken);
}
}
}
private static InstrumentRecord Merge(InstrumentRecord existing, CatalogListItem item, ParsedInstrumentDetails details)
{
var result = existing?.Clone() ?? new InstrumentRecord();
result.RegistryNumber = Prefer(details?.RegistryNumber, item?.RegistryNumber, existing?.RegistryNumber);
result.Name = Prefer(details?.Name, item?.Name, existing?.Name) ?? "Без названия";
result.TypeDesignation = Prefer(details?.TypeDesignation, item?.TypeDesignation, existing?.TypeDesignation);
result.Manufacturer = Prefer(details?.Manufacturer, item?.Manufacturer, existing?.Manufacturer);
result.VerificationInterval = Prefer(details?.VerificationInterval, item?.VerificationInterval, existing?.VerificationInterval);
result.CertificateOrSerialNumber = Prefer(details?.CertificateOrSerialNumber, item?.CertificateOrSerialNumber, existing?.CertificateOrSerialNumber);
result.AllowsBatchVerification = Prefer(details?.AllowsBatchVerification, existing?.AllowsBatchVerification);
result.HasPeriodicVerification = Prefer(details?.HasPeriodicVerification, existing?.HasPeriodicVerification);
result.TypeInfo = Prefer(details?.TypeInfo, existing?.TypeInfo);
result.Purpose = Prefer(details?.Purpose, existing?.Purpose);
result.Description = Prefer(details?.Description, existing?.Description);
result.Software = Prefer(details?.Software, existing?.Software);
result.MetrologicalCharacteristics = Prefer(details?.MetrologicalCharacteristics, existing?.MetrologicalCharacteristics);
result.Completeness = Prefer(details?.Completeness, existing?.Completeness);
result.Verification = Prefer(details?.Verification, existing?.Verification);
result.RegulatoryDocuments = Prefer(details?.RegulatoryDocuments, existing?.RegulatoryDocuments);
result.Applicant = Prefer(details?.Applicant, existing?.Applicant);
result.TestCenter = Prefer(details?.TestCenter, existing?.TestCenter);
return result;
}
private static string Prefer(params string[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value.Trim();
}
}
return null;
}
}

View File

@@ -0,0 +1,526 @@
using CRAWLER.Models;
using Microsoft.Data.SqlClient;
namespace CRAWLER.Services;
internal sealed class InstrumentRepository
{
private readonly IDatabaseConnectionFactory _connectionFactory;
public InstrumentRepository(IDatabaseConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public async Task<IReadOnlyList<InstrumentSummary>> SearchAsync(string searchText, CancellationToken cancellationToken)
{
var items = new List<InstrumentSummary>();
var hasFilter = !string.IsNullOrWhiteSpace(searchText);
const string sql = @"
SELECT TOP (500)
Id,
RegistryNumber,
Name,
TypeDesignation,
Manufacturer,
VerificationInterval,
SourceSystem,
UpdatedAt
FROM dbo.Instruments
WHERE @Search IS NULL
OR RegistryNumber LIKE @Like
OR Name LIKE @Like
OR TypeDesignation LIKE @Like
OR Manufacturer LIKE @Like
ORDER BY
CASE WHEN RegistryNumber IS NULL OR RegistryNumber = N'' THEN 1 ELSE 0 END,
RegistryNumber DESC,
UpdatedAt DESC;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@Search", hasFilter ? searchText.Trim() : DBNull.Value);
command.Parameters.AddWithValue("@Like", hasFilter ? $"%{searchText.Trim()}%" : DBNull.Value);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
items.Add(new InstrumentSummary
{
Id = reader.GetInt64(0),
RegistryNumber = GetString(reader, 1),
Name = GetString(reader, 2),
TypeDesignation = GetString(reader, 3),
Manufacturer = GetString(reader, 4),
VerificationInterval = GetString(reader, 5),
SourceSystem = GetString(reader, 6),
UpdatedAt = reader.GetDateTime(7)
});
}
return items;
}
public async Task<InstrumentRecord> GetByIdAsync(long id, CancellationToken cancellationToken)
{
const string sql = @"
SELECT
Id,
RegistryNumber,
Name,
TypeDesignation,
Manufacturer,
VerificationInterval,
CertificateOrSerialNumber,
AllowsBatchVerification,
HasPeriodicVerification,
TypeInfo,
Purpose,
Description,
Software,
MetrologicalCharacteristics,
Completeness,
Verification,
RegulatoryDocuments,
Applicant,
TestCenter,
DetailUrl,
SourceSystem,
LastImportedAt,
CreatedAt,
UpdatedAt
FROM dbo.Instruments
WHERE Id = @Id;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@Id", id);
InstrumentRecord item = null;
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
{
if (await reader.ReadAsync(cancellationToken))
{
item = new InstrumentRecord
{
Id = reader.GetInt64(0),
RegistryNumber = GetString(reader, 1),
Name = GetString(reader, 2),
TypeDesignation = GetString(reader, 3),
Manufacturer = GetString(reader, 4),
VerificationInterval = GetString(reader, 5),
CertificateOrSerialNumber = GetString(reader, 6),
AllowsBatchVerification = GetString(reader, 7),
HasPeriodicVerification = GetString(reader, 8),
TypeInfo = GetString(reader, 9),
Purpose = GetString(reader, 10),
Description = GetString(reader, 11),
Software = GetString(reader, 12),
MetrologicalCharacteristics = GetString(reader, 13),
Completeness = GetString(reader, 14),
Verification = GetString(reader, 15),
RegulatoryDocuments = GetString(reader, 16),
Applicant = GetString(reader, 17),
TestCenter = GetString(reader, 18),
DetailUrl = GetString(reader, 19),
SourceSystem = GetString(reader, 20),
LastImportedAt = reader.IsDBNull(21) ? (DateTime?)null : reader.GetDateTime(21),
CreatedAt = reader.GetDateTime(22),
UpdatedAt = reader.GetDateTime(23)
};
}
}
if (item == null)
{
return null;
}
item.Attachments = (await GetAttachmentsAsync(connection, id, cancellationToken)).ToList();
return item;
}
public async Task<long?> FindInstrumentIdByRegistryNumberAsync(string registryNumber, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(registryNumber))
{
return null;
}
const string sql = "SELECT Id FROM dbo.Instruments WHERE RegistryNumber = @RegistryNumber;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@RegistryNumber", registryNumber.Trim());
var result = await command.ExecuteScalarAsync(cancellationToken);
if (result == null || result == DBNull.Value)
{
return null;
}
return Convert.ToInt64(result);
}
public async Task<long> SaveAsync(InstrumentRecord record, CancellationToken cancellationToken)
{
if (record == null)
{
throw new ArgumentNullException(nameof(record));
}
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
if (record.Id <= 0)
{
const string insertSql = @"
INSERT INTO dbo.Instruments
(
RegistryNumber,
Name,
TypeDesignation,
Manufacturer,
VerificationInterval,
CertificateOrSerialNumber,
AllowsBatchVerification,
HasPeriodicVerification,
TypeInfo,
Purpose,
Description,
Software,
MetrologicalCharacteristics,
Completeness,
Verification,
RegulatoryDocuments,
Applicant,
TestCenter,
DetailUrl,
SourceSystem,
LastImportedAt,
CreatedAt,
UpdatedAt
)
OUTPUT INSERTED.Id
VALUES
(
@RegistryNumber,
@Name,
@TypeDesignation,
@Manufacturer,
@VerificationInterval,
@CertificateOrSerialNumber,
@AllowsBatchVerification,
@HasPeriodicVerification,
@TypeInfo,
@Purpose,
@Description,
@Software,
@MetrologicalCharacteristics,
@Completeness,
@Verification,
@RegulatoryDocuments,
@Applicant,
@TestCenter,
@DetailUrl,
@SourceSystem,
@LastImportedAt,
SYSUTCDATETIME(),
SYSUTCDATETIME()
);";
await using var command = CreateRecordCommand(insertSql, connection, record);
var id = await command.ExecuteScalarAsync(cancellationToken);
return Convert.ToInt64(id);
}
const string updateSql = @"
UPDATE dbo.Instruments
SET
RegistryNumber = @RegistryNumber,
Name = @Name,
TypeDesignation = @TypeDesignation,
Manufacturer = @Manufacturer,
VerificationInterval = @VerificationInterval,
CertificateOrSerialNumber = @CertificateOrSerialNumber,
AllowsBatchVerification = @AllowsBatchVerification,
HasPeriodicVerification = @HasPeriodicVerification,
TypeInfo = @TypeInfo,
Purpose = @Purpose,
Description = @Description,
Software = @Software,
MetrologicalCharacteristics = @MetrologicalCharacteristics,
Completeness = @Completeness,
Verification = @Verification,
RegulatoryDocuments = @RegulatoryDocuments,
Applicant = @Applicant,
TestCenter = @TestCenter,
DetailUrl = @DetailUrl,
SourceSystem = @SourceSystem,
LastImportedAt = @LastImportedAt,
UpdatedAt = SYSUTCDATETIME()
WHERE Id = @Id;";
await using (var command = CreateRecordCommand(updateSql, connection, record))
{
command.Parameters.AddWithValue("@Id", record.Id);
await command.ExecuteNonQueryAsync(cancellationToken);
}
return record.Id;
}
public async Task<PdfAttachment> FindAttachmentBySourceUrlAsync(long instrumentId, string sourceUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(sourceUrl))
{
return null;
}
const string sql = @"
SELECT
Id,
InstrumentId,
Kind,
Title,
SourceUrl,
LocalPath,
IsManual,
CreatedAt
FROM dbo.PdfAttachments
WHERE InstrumentId = @InstrumentId
AND SourceUrl = @SourceUrl;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@InstrumentId", instrumentId);
command.Parameters.AddWithValue("@SourceUrl", sourceUrl);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new PdfAttachment
{
Id = reader.GetInt64(0),
InstrumentId = reader.GetInt64(1),
Kind = GetString(reader, 2),
Title = GetString(reader, 3),
SourceUrl = GetString(reader, 4),
LocalPath = GetString(reader, 5),
IsManual = reader.GetBoolean(6),
CreatedAt = reader.GetDateTime(7)
};
}
public async Task SaveAttachmentAsync(PdfAttachment attachment, CancellationToken cancellationToken)
{
if (attachment == null)
{
throw new ArgumentNullException(nameof(attachment));
}
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
if (attachment.Id <= 0)
{
const string insertSql = @"
INSERT INTO dbo.PdfAttachments
(
InstrumentId,
Kind,
Title,
SourceUrl,
LocalPath,
IsManual,
CreatedAt
)
VALUES
(
@InstrumentId,
@Kind,
@Title,
@SourceUrl,
@LocalPath,
@IsManual,
SYSUTCDATETIME()
);";
await using var command = CreateAttachmentCommand(insertSql, connection, attachment);
await command.ExecuteNonQueryAsync(cancellationToken);
return;
}
const string updateSql = @"
UPDATE dbo.PdfAttachments
SET
Kind = @Kind,
Title = @Title,
SourceUrl = @SourceUrl,
LocalPath = @LocalPath,
IsManual = @IsManual
WHERE Id = @Id;";
await using (var command = CreateAttachmentCommand(updateSql, connection, attachment))
{
command.Parameters.AddWithValue("@Id", attachment.Id);
await command.ExecuteNonQueryAsync(cancellationToken);
}
}
public async Task DeleteAttachmentAsync(long attachmentId, CancellationToken cancellationToken)
{
const string sql = "DELETE FROM dbo.PdfAttachments WHERE Id = @Id;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@Id", attachmentId);
await command.ExecuteNonQueryAsync(cancellationToken);
}
public async Task DeleteInstrumentAsync(long id, CancellationToken cancellationToken)
{
const string sql = "DELETE FROM dbo.Instruments WHERE Id = @Id;";
await using var connection = _connectionFactory.CreateConnection();
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@Id", id);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private async Task<IReadOnlyList<PdfAttachment>> GetAttachmentsAsync(SqlConnection connection, long instrumentId, CancellationToken cancellationToken)
{
const string sql = @"
SELECT
Id,
InstrumentId,
Kind,
Title,
SourceUrl,
LocalPath,
IsManual,
CreatedAt
FROM dbo.PdfAttachments
WHERE InstrumentId = @InstrumentId
ORDER BY CreatedAt DESC, Id DESC;";
var items = new List<PdfAttachment>();
await using var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@InstrumentId", instrumentId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
items.Add(new PdfAttachment
{
Id = reader.GetInt64(0),
InstrumentId = reader.GetInt64(1),
Kind = GetString(reader, 2),
Title = GetString(reader, 3),
SourceUrl = GetString(reader, 4),
LocalPath = GetString(reader, 5),
IsManual = reader.GetBoolean(6),
CreatedAt = reader.GetDateTime(7)
});
}
return items;
}
private SqlCommand CreateRecordCommand(string sql, SqlConnection connection, InstrumentRecord record)
{
var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@RegistryNumber", ToDbValue(record.RegistryNumber));
command.Parameters.AddWithValue("@Name", string.IsNullOrWhiteSpace(record.Name) ? "Без названия" : record.Name.Trim());
command.Parameters.AddWithValue("@TypeDesignation", ToDbValue(record.TypeDesignation));
command.Parameters.AddWithValue("@Manufacturer", ToDbValue(record.Manufacturer));
command.Parameters.AddWithValue("@VerificationInterval", ToDbValue(record.VerificationInterval));
command.Parameters.AddWithValue("@CertificateOrSerialNumber", ToDbValue(record.CertificateOrSerialNumber));
command.Parameters.AddWithValue("@AllowsBatchVerification", ToDbValue(record.AllowsBatchVerification));
command.Parameters.AddWithValue("@HasPeriodicVerification", ToDbValue(record.HasPeriodicVerification));
command.Parameters.AddWithValue("@TypeInfo", ToDbValue(record.TypeInfo));
command.Parameters.AddWithValue("@Purpose", ToDbValue(record.Purpose));
command.Parameters.AddWithValue("@Description", ToDbValue(record.Description));
command.Parameters.AddWithValue("@Software", ToDbValue(record.Software));
command.Parameters.AddWithValue("@MetrologicalCharacteristics", ToDbValue(record.MetrologicalCharacteristics));
command.Parameters.AddWithValue("@Completeness", ToDbValue(record.Completeness));
command.Parameters.AddWithValue("@Verification", ToDbValue(record.Verification));
command.Parameters.AddWithValue("@RegulatoryDocuments", ToDbValue(record.RegulatoryDocuments));
command.Parameters.AddWithValue("@Applicant", ToDbValue(record.Applicant));
command.Parameters.AddWithValue("@TestCenter", ToDbValue(record.TestCenter));
command.Parameters.AddWithValue("@DetailUrl", ToDbValue(record.DetailUrl));
command.Parameters.AddWithValue("@SourceSystem", string.IsNullOrWhiteSpace(record.SourceSystem) ? "Manual" : record.SourceSystem.Trim());
command.Parameters.AddWithValue("@LastImportedAt", record.LastImportedAt.HasValue ? record.LastImportedAt.Value : DBNull.Value);
return command;
}
private SqlCommand CreateAttachmentCommand(string sql, SqlConnection connection, PdfAttachment attachment)
{
var command = new SqlCommand(sql, connection)
{
CommandTimeout = _connectionFactory.Options.CommandTimeoutSeconds
};
command.Parameters.AddWithValue("@InstrumentId", attachment.InstrumentId);
command.Parameters.AddWithValue("@Kind", string.IsNullOrWhiteSpace(attachment.Kind) ? "PDF" : attachment.Kind.Trim());
command.Parameters.AddWithValue("@Title", ToDbValue(attachment.Title));
command.Parameters.AddWithValue("@SourceUrl", ToDbValue(attachment.SourceUrl));
command.Parameters.AddWithValue("@LocalPath", ToDbValue(attachment.LocalPath));
command.Parameters.AddWithValue("@IsManual", attachment.IsManual);
return command;
}
private static object ToDbValue(string value)
{
return string.IsNullOrWhiteSpace(value) ? DBNull.Value : value.Trim();
}
private static string GetString(SqlDataReader reader, int index)
{
return reader.IsDBNull(index) ? null : reader.GetString(index);
}
}

View File

@@ -0,0 +1,187 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using CRAWLER.Configuration;
using Microsoft.Extensions.Configuration;
namespace CRAWLER.Services;
internal sealed class KtoPoveritClient : IDisposable
{
private readonly CrawlerOptions _options;
private readonly HttpClient _httpClient;
public KtoPoveritClient(IConfiguration configuration)
{
_options = configuration.GetSection("Crawler").Get<CrawlerOptions>()
?? throw new InvalidOperationException("Раздел Crawler не найден в appsettings.json.");
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = false
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(Math.Max(5, _options.TimeoutSeconds))
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(_options.UserAgent);
_httpClient.DefaultRequestHeaders.AcceptLanguage.ParseAdd("ru-RU,ru;q=0.9,en-US;q=0.8");
}
public CrawlerOptions Options
{
get { return _options; }
}
public async Task<string> GetStringAsync(string url, CancellationToken cancellationToken)
{
using var request = CreateRequest(url);
using var response = await SendAsync(request, cancellationToken);
return await response.Content.ReadAsStringAsync(cancellationToken);
}
public async Task<byte[]> GetBytesAsync(string url, CancellationToken cancellationToken)
{
using var request = CreateRequest(url);
using var response = await SendAsync(request, cancellationToken);
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
public string BuildCatalogPageUrl(int page)
{
var relative = string.Format(_options.CatalogPathFormat, page);
return BuildAbsoluteUrl(relative);
}
public string BuildAbsoluteUrl(string urlOrPath)
{
if (string.IsNullOrWhiteSpace(urlOrPath))
{
return null;
}
if (Uri.TryCreate(urlOrPath, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.ToString();
}
var baseUri = new Uri(_options.BaseUrl.TrimEnd('/') + "/");
return new Uri(baseUri, urlOrPath.TrimStart('/')).ToString();
}
private HttpRequestMessage CreateRequest(string url)
{
return new HttpRequestMessage(HttpMethod.Get, url)
{
Version = HttpVersion.Version11,
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var currentUri = request.RequestUri ?? throw new InvalidOperationException("Не задан URL запроса.");
const int maxRedirects = 10;
try
{
for (var redirectIndex = 0; redirectIndex <= maxRedirects; redirectIndex++)
{
using var currentRequest = CreateRequest(currentUri.ToString());
var response = await _httpClient.SendAsync(currentRequest, HttpCompletionOption.ResponseContentRead, cancellationToken);
if (IsRedirectStatusCode(response.StatusCode))
{
var redirectUri = ResolveRedirectUri(currentUri, response.Headers);
response.Dispose();
if (redirectUri == null)
{
throw new InvalidOperationException(
$"Сайт вернул {(int)response.StatusCode} для {currentUri}, но не прислал корректный адрес перенаправления.");
}
currentUri = redirectUri;
continue;
}
if ((int)response.StatusCode >= 200 && (int)response.StatusCode <= 299)
{
return response;
}
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase;
response.Dispose();
throw new HttpRequestException(
$"Response status code does not indicate success: {statusCode} ({reasonPhrase}).");
}
throw new InvalidOperationException(
$"Превышено число перенаправлений ({maxRedirects}) для {currentUri}.");
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Не удалось получить данные с сайта Кто поверит: {request.RequestUri}. {ex.Message}",
ex);
}
}
private static bool IsRedirectStatusCode(HttpStatusCode statusCode)
{
return statusCode == HttpStatusCode.Moved
|| statusCode == HttpStatusCode.Redirect
|| statusCode == HttpStatusCode.RedirectMethod
|| statusCode == HttpStatusCode.TemporaryRedirect
|| (int)statusCode == 308;
}
private static Uri ResolveRedirectUri(Uri currentUri, HttpResponseHeaders headers)
{
if (headers.Location != null)
{
return headers.Location.IsAbsoluteUri
? headers.Location
: new Uri(currentUri, headers.Location);
}
if (!headers.TryGetValues("Location", out var values))
{
return null;
}
var rawLocation = values.FirstOrDefault();
if (string.IsNullOrWhiteSpace(rawLocation))
{
return null;
}
if (Uri.TryCreate(rawLocation, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri;
}
if (Uri.TryCreate(currentUri, rawLocation, out var relativeUri))
{
return relativeUri;
}
var escaped = Uri.EscapeUriString(rawLocation);
if (Uri.TryCreate(escaped, UriKind.Absolute, out absoluteUri))
{
return absoluteUri;
}
return Uri.TryCreate(currentUri, escaped, out relativeUri)
? relativeUri
: null;
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -0,0 +1,46 @@
using System.Diagnostics;
using System.IO;
using CRAWLER.Models;
namespace CRAWLER.Services;
internal interface IPdfOpener
{
void OpenAttachment(PdfAttachment attachment);
void OpenUri(string uri);
}
internal sealed class PdfShellService : IPdfOpener
{
public void OpenAttachment(PdfAttachment attachment)
{
if (attachment == null)
{
return;
}
if (!string.IsNullOrWhiteSpace(attachment.LocalPath) && File.Exists(attachment.LocalPath))
{
OpenUri(attachment.LocalPath);
return;
}
if (!string.IsNullOrWhiteSpace(attachment.SourceUrl))
{
OpenUri(attachment.SourceUrl);
}
}
public void OpenUri(string uri)
{
if (string.IsNullOrWhiteSpace(uri))
{
return;
}
Process.Start(new ProcessStartInfo(uri)
{
UseShellExecute = true
});
}
}

View File

@@ -0,0 +1,91 @@
using System.IO;
using System.Linq;
using CRAWLER.Configuration;
using Microsoft.Extensions.Configuration;
namespace CRAWLER.Services;
internal sealed class PdfStorageService
{
private readonly KtoPoveritClient _client;
private readonly string _rootPath;
public PdfStorageService(IConfiguration configuration, KtoPoveritClient client)
{
_client = client;
var options = configuration.GetSection("Crawler").Get<CrawlerOptions>()
?? throw new InvalidOperationException("Раздел Crawler не найден в appsettings.json.");
_rootPath = Environment.ExpandEnvironmentVariables(options.PdfStoragePath);
Directory.CreateDirectory(_rootPath);
}
public async Task<string> DownloadAsync(string sourceUrl, string registryNumber, string title, CancellationToken cancellationToken)
{
var bytes = await _client.GetBytesAsync(sourceUrl, cancellationToken);
var fullPath = BuildTargetPath(registryNumber, title, sourceUrl);
await File.WriteAllBytesAsync(fullPath, bytes, cancellationToken);
return fullPath;
}
public async Task<string> CopyFromLocalAsync(string sourcePath, string registryNumber, string title, CancellationToken cancellationToken)
{
var fullPath = BuildTargetPath(registryNumber, title, sourcePath);
await using var sourceStream = File.Open(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = File.Create(fullPath);
await sourceStream.CopyToAsync(targetStream, cancellationToken);
return fullPath;
}
public void TryDelete(string path)
{
try
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
File.Delete(path);
}
}
catch
{
}
}
private string BuildTargetPath(string registryNumber, string title, string sourceIdentity)
{
var safeFolder = MakeSafePathSegment(string.IsNullOrWhiteSpace(registryNumber) ? "manual" : registryNumber);
var folder = Path.Combine(_rootPath, safeFolder);
Directory.CreateDirectory(folder);
var baseName = MakeSafePathSegment(string.IsNullOrWhiteSpace(title) ? Path.GetFileNameWithoutExtension(sourceIdentity) : title);
if (string.IsNullOrWhiteSpace(baseName))
{
baseName = "document";
}
var fullPath = Path.Combine(folder, baseName + ".pdf");
if (!File.Exists(fullPath))
{
return fullPath;
}
var counter = 2;
while (true)
{
var candidate = Path.Combine(folder, $"{baseName}-{counter}.pdf");
if (!File.Exists(candidate))
{
return candidate;
}
counter++;
}
}
private static string MakeSafePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var cleaned = new string((value ?? string.Empty).Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()).Trim();
return string.IsNullOrWhiteSpace(cleaned) ? "file" : cleaned;
}
}

View File

@@ -0,0 +1,55 @@
using CRAWLER.Configuration;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace CRAWLER.Services;
internal interface IDatabaseConnectionFactory
{
SqlConnection CreateConnection();
SqlConnection CreateMasterConnection();
DatabaseOptions Options { get; }
}
internal sealed class SqlServerConnectionFactory : IDatabaseConnectionFactory
{
public SqlServerConnectionFactory(IConfiguration configuration)
{
Options = configuration.GetSection("Database").Get<DatabaseOptions>()
?? throw new InvalidOperationException("Раздел Database не найден в appsettings.json.");
}
public DatabaseOptions Options { get; }
public SqlConnection CreateConnection()
{
return new SqlConnection(BuildConnectionString(Options.Database));
}
public SqlConnection CreateMasterConnection()
{
return new SqlConnection(BuildConnectionString("master"));
}
private string BuildConnectionString(string databaseName)
{
var builder = new SqlConnectionStringBuilder
{
ApplicationName = Options.ApplicationName,
DataSource = Options.Server,
InitialCatalog = databaseName,
ConnectTimeout = Options.ConnectTimeoutSeconds,
Encrypt = Options.Encrypt,
IntegratedSecurity = Options.IntegratedSecurity,
MultipleActiveResultSets = Options.MultipleActiveResultSets,
Pooling = Options.Pooling,
MaxPoolSize = Options.MaxPoolSize,
MinPoolSize = Options.MinPoolSize,
TrustServerCertificate = Options.TrustServerCertificate,
ConnectRetryCount = Options.ConnectRetryCount,
ConnectRetryInterval = Options.ConnectRetryIntervalSeconds
};
return builder.ConnectionString;
}
}

View File

@@ -0,0 +1,79 @@
using System.Collections.ObjectModel;
using System.Linq;
using CRAWLER.Infrastructure;
using CRAWLER.Models;
namespace CRAWLER.ViewModels;
internal sealed class EditInstrumentWindowViewModel : ObservableObject
{
private readonly InstrumentRecord _draft;
private PendingPdfFile _selectedPendingPdf;
public EditInstrumentWindowViewModel(InstrumentRecord draft, bool isNewRecord)
{
_draft = draft ?? new InstrumentRecord();
PendingPdfFiles = new ObservableCollection<PendingPdfFile>();
ExistingAttachments = new ObservableCollection<PdfAttachment>(_draft.Attachments ?? Enumerable.Empty<PdfAttachment>());
WindowTitle = isNewRecord ? "Новая запись" : $"Редактирование: {(_draft.RegistryNumber ?? _draft.Name)}";
}
public string WindowTitle { get; }
public ObservableCollection<PendingPdfFile> PendingPdfFiles { get; }
public ObservableCollection<PdfAttachment> ExistingAttachments { get; }
public PendingPdfFile SelectedPendingPdf
{
get { return _selectedPendingPdf; }
set { SetProperty(ref _selectedPendingPdf, value); }
}
public InstrumentRecord Draft
{
get { return _draft; }
}
public void AddPendingFiles(IReadOnlyList<string> paths)
{
foreach (var path in paths.Where(path => !string.IsNullOrWhiteSpace(path)))
{
if (PendingPdfFiles.Any(item => string.Equals(item.SourcePath, path, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
PendingPdfFiles.Add(new PendingPdfFile
{
SourcePath = path,
DisplayName = System.IO.Path.GetFileName(path)
});
}
}
public void RemovePendingSelected()
{
if (SelectedPendingPdf != null)
{
PendingPdfFiles.Remove(SelectedPendingPdf);
}
}
public string[] GetPendingPaths()
{
return PendingPdfFiles.Select(item => item.SourcePath).ToArray();
}
public bool Validate(out string errorMessage)
{
if (string.IsNullOrWhiteSpace(Draft.Name))
{
errorMessage = "Укажите наименование средства измерения.";
return false;
}
errorMessage = null;
return true;
}
}

View File

@@ -0,0 +1,266 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CRAWLER.Infrastructure;
using CRAWLER.Models;
using CRAWLER.Services;
namespace CRAWLER.ViewModels;
internal sealed class MainWindowViewModel : ObservableObject
{
private readonly InstrumentCatalogService _catalogService;
private readonly IPdfOpener _pdfOpener;
private InstrumentSummary _selectedSummary;
private InstrumentRecord _selectedInstrument;
private string _searchText;
private int _pagesToScan;
private string _statusText;
private bool _isBusy;
private CancellationTokenSource _selectionCancellationTokenSource;
public MainWindowViewModel(InstrumentCatalogService catalogService, IPdfOpener pdfOpener)
{
_catalogService = catalogService;
_pdfOpener = pdfOpener;
_pagesToScan = _catalogService.DefaultPagesToScan;
_statusText = "Готово.";
Instruments = new ObservableCollection<InstrumentSummary>();
}
public ObservableCollection<InstrumentSummary> Instruments { get; }
public InstrumentSummary SelectedSummary
{
get { return _selectedSummary; }
set
{
if (SetProperty(ref _selectedSummary, value))
{
_ = LoadSelectedInstrumentAsync(value?.Id);
}
}
}
public InstrumentRecord SelectedInstrument
{
get { return _selectedInstrument; }
private set { SetProperty(ref _selectedInstrument, value); }
}
public string SearchText
{
get { return _searchText; }
set { SetProperty(ref _searchText, value); }
}
public int PagesToScan
{
get { return _pagesToScan; }
set { SetProperty(ref _pagesToScan, value < 1 ? 1 : value); }
}
public string StatusText
{
get { return _statusText; }
private set { SetProperty(ref _statusText, value); }
}
public bool IsBusy
{
get { return _isBusy; }
private set { SetProperty(ref _isBusy, value); }
}
public async Task InitializeAsync()
{
await RunBusyAsync(async () =>
{
StatusText = "Подготовка базы данных...";
await _catalogService.InitializeAsync(CancellationToken.None);
await RefreshAsync();
});
}
public async Task RefreshAsync(long? selectId = null)
{
await RunBusyAsync(async () =>
{
StatusText = "Загрузка списка записей...";
var items = await _catalogService.SearchAsync(SearchText, CancellationToken.None);
Instruments.Clear();
foreach (var item in items)
{
Instruments.Add(item);
}
if (Instruments.Count == 0)
{
SelectedInstrument = null;
SelectedSummary = null;
StatusText = "Записи не найдены.";
return;
}
var summary = selectId.HasValue
? Instruments.FirstOrDefault(item => item.Id == selectId.Value)
: SelectedSummary == null
? Instruments.FirstOrDefault()
: Instruments.FirstOrDefault(item => item.Id == SelectedSummary.Id) ?? Instruments.FirstOrDefault();
SelectedSummary = summary;
StatusText = $"Загружено записей: {Instruments.Count}.";
});
}
public async Task<SyncResult> SyncAsync()
{
SyncResult result = null;
await RunBusyAsync(async () =>
{
var progress = new Progress<string>(message => StatusText = message);
result = await _catalogService.SyncFromSiteAsync(PagesToScan, progress, CancellationToken.None);
await RefreshAsync(SelectedSummary?.Id);
});
return result;
}
public InstrumentRecord CreateNewDraft()
{
return new InstrumentRecord
{
SourceSystem = "Manual"
};
}
public InstrumentRecord CreateDraftFromSelected()
{
return SelectedInstrument?.Clone();
}
public async Task<long> SaveAsync(InstrumentRecord draft, System.Collections.Generic.IEnumerable<string> pendingPdfPaths)
{
long id = 0;
await RunBusyAsync(async () =>
{
StatusText = "Сохранение записи...";
id = await _catalogService.SaveInstrumentAsync(draft, pendingPdfPaths, CancellationToken.None);
await RefreshAsync(id);
StatusText = "Изменения сохранены.";
});
return id;
}
public async Task DeleteSelectedAsync()
{
if (SelectedInstrument == null)
{
return;
}
var deletedId = SelectedInstrument.Id;
await RunBusyAsync(async () =>
{
StatusText = "Удаление записи...";
await _catalogService.DeleteInstrumentAsync(SelectedInstrument, CancellationToken.None);
await RefreshAsync();
StatusText = $"Запись {deletedId} удалена.";
});
}
public async Task AddAttachmentsToSelectedAsync(System.Collections.Generic.IEnumerable<string> paths)
{
if (SelectedInstrument == null)
{
return;
}
await RunBusyAsync(async () =>
{
StatusText = "Копирование PDF-файлов...";
await _catalogService.AddManualAttachmentsAsync(SelectedInstrument.Id, SelectedInstrument.RegistryNumber, paths, CancellationToken.None);
await LoadSelectedInstrumentAsync(SelectedInstrument.Id);
StatusText = "PDF-файлы добавлены.";
});
}
public async Task RemoveAttachmentAsync(PdfAttachment attachment)
{
if (attachment == null || SelectedInstrument == null)
{
return;
}
await RunBusyAsync(async () =>
{
StatusText = "Удаление PDF-файла...";
await _catalogService.RemoveAttachmentAsync(attachment, CancellationToken.None);
await LoadSelectedInstrumentAsync(SelectedInstrument.Id);
StatusText = "PDF-файл удалён.";
});
}
public void OpenAttachment(PdfAttachment attachment)
{
_pdfOpener.OpenAttachment(attachment);
}
public void OpenSourceUrl()
{
if (SelectedInstrument != null && !string.IsNullOrWhiteSpace(SelectedInstrument.DetailUrl))
{
_pdfOpener.OpenUri(SelectedInstrument.DetailUrl);
}
}
private async Task LoadSelectedInstrumentAsync(long? id)
{
_selectionCancellationTokenSource?.Cancel();
_selectionCancellationTokenSource = new CancellationTokenSource();
var token = _selectionCancellationTokenSource.Token;
if (!id.HasValue)
{
SelectedInstrument = null;
return;
}
try
{
var instrument = await _catalogService.GetByIdAsync(id.Value, token);
if (!token.IsCancellationRequested)
{
SelectedInstrument = instrument;
}
}
catch (OperationCanceledException)
{
}
}
private async Task RunBusyAsync(Func<Task> action)
{
if (IsBusy)
{
return;
}
try
{
IsBusy = true;
await action();
}
finally
{
IsBusy = false;
}
}
}

27
appsettings.json Normal file
View File

@@ -0,0 +1,27 @@
{
"Database": {
"ApplicationName": "CRAWLER",
"CommandTimeoutSeconds": 60,
"ConnectRetryCount": 3,
"ConnectRetryIntervalSeconds": 5,
"ConnectTimeoutSeconds": 15,
"Database": "CRAWLER",
"Encrypt": false,
"IntegratedSecurity": true,
"MultipleActiveResultSets": true,
"Pooling": true,
"MaxPoolSize": 100,
"MinPoolSize": 0,
"Server": "SEVENHILL\\SQLEXPRESS",
"TrustServerCertificate": true
},
"Crawler": {
"BaseUrl": "https://www.ktopoverit.ru",
"CatalogPathFormat": "/poverka/gosreestr_sredstv_izmereniy?page={0}",
"RequestDelayMilliseconds": 350,
"DefaultPagesToScan": 1,
"PdfStoragePath": "%LOCALAPPDATA%\\CRAWLER\\PdfStore",
"TimeoutSeconds": 30,
"UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) CRAWLER/1.0"
}
}