Добавьте файлы проекта.
This commit is contained in:
172
App.xaml
Normal file
172
App.xaml
Normal 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
153
App.xaml.cs
Normal 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
47
AppHost.cs
Normal 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
10
AssemblyInfo.cs
Normal 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
37
CRAWLER.csproj
Normal 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
25
CRAWLER.sln
Normal 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
|
||||
12
Configuration/CrawlerOptions.cs
Normal file
12
Configuration/CrawlerOptions.cs
Normal 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";
|
||||
}
|
||||
19
Configuration/DatabaseOptions.cs
Normal file
19
Configuration/DatabaseOptions.cs
Normal 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;
|
||||
}
|
||||
175
Dialogs/EditInstrumentWindow.xaml
Normal file
175
Dialogs/EditInstrumentWindow.xaml
Normal 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>
|
||||
46
Dialogs/EditInstrumentWindow.xaml.cs
Normal file
46
Dialogs/EditInstrumentWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
126
Infrastructure/MvvmInfrastructure.cs
Normal file
126
Infrastructure/MvvmInfrastructure.cs
Normal 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
268
MainWindow.xaml
Normal 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
188
MainWindow.xaml.cs
Normal 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
150
Models/InstrumentModels.cs
Normal 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; }
|
||||
}
|
||||
86
Parsing/CatalogPageParser.cs
Normal file
86
Parsing/CatalogPageParser.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
65
Parsing/DetailPageParser.cs
Normal file
65
Parsing/DetailPageParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
64
Parsing/HtmlParsingHelpers.cs
Normal file
64
Parsing/HtmlParsingHelpers.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
SampleData/catalog-page-sample.html
Normal file
31
SampleData/catalog-page-sample.html
Normal 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&izgotov=test">Общество с ограниченной ответственностью «Производственная фирма «Гидродинамика»</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
22
SampleData/detail-page-sample.html
Normal file
22
SampleData/detail-page-sample.html
Normal 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>
|
||||
141
Services/DatabaseInitializer.cs
Normal file
141
Services/DatabaseInitializer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Services/FilePickerService.cs
Normal file
26
Services/FilePickerService.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
306
Services/InstrumentCatalogService.cs
Normal file
306
Services/InstrumentCatalogService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
526
Services/InstrumentRepository.cs
Normal file
526
Services/InstrumentRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
187
Services/KtoPoveritClient.cs
Normal file
187
Services/KtoPoveritClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
46
Services/PdfShellService.cs
Normal file
46
Services/PdfShellService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
91
Services/PdfStorageService.cs
Normal file
91
Services/PdfStorageService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
55
Services/SqlServerConnectionFactory.cs
Normal file
55
Services/SqlServerConnectionFactory.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
79
ViewModels/EditInstrumentWindowViewModel.cs
Normal file
79
ViewModels/EditInstrumentWindowViewModel.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
266
ViewModels/MainWindowViewModel.cs
Normal file
266
ViewModels/MainWindowViewModel.cs
Normal 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
27
appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user