Добавьте файлы проекта.
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