initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /App
|
||||
|
||||
# Copy everything
|
||||
COPY . ./
|
||||
# Restore as distinct layers
|
||||
RUN dotnet restore
|
||||
# Build and publish a release
|
||||
RUN dotnet publish -o out
|
||||
|
||||
# Build runtime image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
VOLUME /data
|
||||
WORKDIR /App
|
||||
COPY --from=build /App/out .
|
||||
ENTRYPOINT ["dotnet", "SolicitorsApi.dll"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<Solution>
|
||||
<Folder Name="/libs/">
|
||||
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
|
||||
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
|
||||
<Project Path="libs/Solicitors.Data/Solicitors.Data.csproj" />
|
||||
<Project Path="libs/Solicitors.HtmlParsing/Solicitors.HtmlParsing.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Solicitors.HtmlParsing.Tests/Solicitors.HtmlParsing.Tests.csproj" />
|
||||
</Folder>
|
||||
<Project Path="SolicitorsApi/SolicitorsApi.csproj" />
|
||||
<Project Path="Tester/Tester.csproj" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.ComponentModel;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Solicitors.CacheBuild;
|
||||
using Solicitors.Core;
|
||||
using Solicitors.Core.Misc;
|
||||
using Solicitors.Core.Models;
|
||||
using Solicitors.Data;
|
||||
using Solicitors.Data.RepositorySetup.Sqlite;
|
||||
using Solicitors.HtmlParsing;
|
||||
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddCore();
|
||||
builder.Services.AddData(new SqliteDbSetupOptions("demo"));
|
||||
builder.Services.AddCacheBuild();
|
||||
builder.Services.AddHtmlParsing();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseCors();
|
||||
|
||||
app.MapGet(
|
||||
"/conveyancors",
|
||||
(
|
||||
[FromServices] IReadOnlySolicitorService solicitorService,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery] uint pageNumber = 1,
|
||||
[FromQuery] uint pageSize = 20,
|
||||
[FromQuery] string? nameFilter = null,
|
||||
[FromQuery] decimal minRating = 0,
|
||||
[FromQuery] string[]? cities = null,
|
||||
[FromQuery] string ratingsProvider = "Solicitors.com",
|
||||
[FromQuery] string? orderingType = null) =>
|
||||
{
|
||||
IFilter<Solicitor>? filter = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(nameFilter))
|
||||
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter));
|
||||
|
||||
if (cities is not null && cities.Length > 0)
|
||||
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
|
||||
|
||||
if (minRating > 0)
|
||||
filter = new WrapperFilter<Solicitor>(
|
||||
filter,
|
||||
solicitor => solicitor.Ratings.Any(rating => (rating.Provider == ratingsProvider) && (rating.Value / rating.Maximum >= minRating / 5.0m)));
|
||||
|
||||
IComparer<Solicitor>? ordering = orderingType switch
|
||||
{
|
||||
"rating-asc" => new RatingComparer(ratingsProvider, true),
|
||||
"rating-desc" => new RatingComparer(ratingsProvider, false),
|
||||
"alphabet-asc" => new NameComparer(true),
|
||||
"alphabet-desc" => new NameComparer(false),
|
||||
_ => null
|
||||
};
|
||||
|
||||
return solicitorService.GetSolicitorSummariesAsync(
|
||||
new Pagination(pageNumber, pageSize),
|
||||
ratingsProvider,
|
||||
filter,
|
||||
ordering,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
);
|
||||
|
||||
app.MapGet(
|
||||
"/cities",
|
||||
(
|
||||
[FromServices] IReadOnlyCitiesService citiesService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
return citiesService.GetAllCitiesAsync(cancellationToken);
|
||||
}
|
||||
);
|
||||
|
||||
app.MapGet(
|
||||
"/conveyancors/{id}",
|
||||
(
|
||||
[FromServices] IReadOnlySolicitorService solicitorService,
|
||||
[FromRoute] Guid id,
|
||||
CancellationToken cancellationToken
|
||||
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
|
||||
);
|
||||
|
||||
app.MapGet(
|
||||
"/ratingsProviders",
|
||||
(
|
||||
[FromServices] IRatingsProviderService service,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
{
|
||||
return service.GetRatingsProvidersAsync(cancellationToken);
|
||||
}
|
||||
);
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var solicitors = scope.ServiceProvider.GetRequiredService<ISolicitorService>();
|
||||
var first = await solicitors
|
||||
.GetSolicitorSummariesAsync(
|
||||
new Pagination(1, 1),
|
||||
"Solicitors.com",
|
||||
cancellationToken: CancellationToken.None);
|
||||
if (first.Data.Length == 0)
|
||||
await scope.ServiceProvider
|
||||
.GetRequiredService<ISolicitorImporter>()
|
||||
.RunFullImport(CancellationToken.None);
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
public class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
|
||||
{
|
||||
public int Compare(Solicitor? x, Solicitor? y)
|
||||
{
|
||||
var xRating = x?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
|
||||
var yRating = y?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
|
||||
|
||||
decimal compare;
|
||||
|
||||
if (xRating is null)
|
||||
compare = yRating is null ? 0 : 1;
|
||||
else if (yRating is null)
|
||||
compare = -1;
|
||||
else
|
||||
{
|
||||
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
|
||||
|
||||
if (!ascending)
|
||||
compare *= -1;
|
||||
}
|
||||
|
||||
if (compare >= 0)
|
||||
return (int)Math.Ceiling(compare);
|
||||
else
|
||||
return (int)Math.Floor(compare);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public class NameComparer(bool ascending) : IComparer<Solicitor>
|
||||
{
|
||||
public int Compare(Solicitor? x, Solicitor? y)
|
||||
{
|
||||
int compare;
|
||||
if (ReferenceEquals(x, y))
|
||||
compare = 0;
|
||||
else if (y is null)
|
||||
compare = 1;
|
||||
else if (x is null)
|
||||
compare = -1;
|
||||
else
|
||||
{
|
||||
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!ascending)
|
||||
compare *= -1;
|
||||
}
|
||||
|
||||
return compare;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5084",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7128;http://localhost:5084",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
|
||||
<ProjectReference Include="..\libs\Solicitors.Core\Solicitors.Core.csproj" />
|
||||
<ProjectReference Include="..\libs\Solicitors.Data\Solicitors.Data.csproj" />
|
||||
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
@SolicitorsApi_HostAddress = http://localhost:5084
|
||||
|
||||
GET {{SolicitorsApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Solicitors.HtmlParsing;
|
||||
|
||||
var _html = File.ReadAllText("/var/home/fox/main.html");
|
||||
var services = new ServiceCollection();
|
||||
services.AddHtmlParsing();
|
||||
var parser = services.BuildServiceProvider().GetRequiredService<IHtmlParser>();
|
||||
|
||||
var htmlNodes = parser.ParseHtml(_html).ToArray();
|
||||
Console.ReadKey();
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
|
||||
<HintPath>..\..\..\..\..\..\..\..\var\home\fox\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Solicitors.CacheBuild;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static IServiceCollection AddCacheBuild(this IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
|
||||
.AddScoped<ISolicitorImporter, SolicitorImporter>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.CacheBuild;
|
||||
|
||||
public interface ISolicitorImporter
|
||||
{
|
||||
Task RunFullImport(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Solicitors.Core.Models.Imports;
|
||||
|
||||
namespace Solicitors.CacheBuild;
|
||||
|
||||
internal interface ISolicitorParser
|
||||
{
|
||||
Task<SolicitorData[]> GetSolicitorsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Solicitors.Core.Data;
|
||||
using Solicitors.Core.Models.Imports;
|
||||
|
||||
namespace Solicitors.CacheBuild;
|
||||
|
||||
internal class SolicitorImporter : ISolicitorImporter
|
||||
{
|
||||
private readonly ISolicitorParser _parser;
|
||||
private readonly ISolicitorRepository _repository;
|
||||
|
||||
public SolicitorImporter(
|
||||
ISolicitorParser parser,
|
||||
ISolicitorRepository repository)
|
||||
{
|
||||
_parser = parser;
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task RunFullImport(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var solicitor in await _parser.GetSolicitorsAsync(cancellationToken))
|
||||
{
|
||||
await RunImport(solicitor, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
|
||||
=> _repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||
<ProjectReference Include="..\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||
|
||||
internal static class Consts
|
||||
{
|
||||
public const string BaseUrl = "https://www.solicitors.com";
|
||||
public static readonly string[] Locations =
|
||||
[
|
||||
"london", "birmingham", "leeds", "manchester", "sheffield", "bradford", "liverpool", "bristol"
|
||||
];
|
||||
|
||||
public const decimal MaxRating = 5.0m;
|
||||
public const string Name = "Solicitors.com";
|
||||
public const string IconPath = "/images/logo.svg";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||
|
||||
internal class SolicitorBuilder
|
||||
{
|
||||
private readonly HashSet<string> _baseLocations = [];
|
||||
|
||||
public SolicitorBuilder(
|
||||
string name,
|
||||
string path,
|
||||
string? shortDesc,
|
||||
string baseLocation)
|
||||
{
|
||||
Name = name;
|
||||
ShortDescription = shortDesc;
|
||||
_baseLocations.Add(baseLocation);
|
||||
|
||||
Path = path;
|
||||
}
|
||||
|
||||
public void AddBaseLocation(string baseLocation)
|
||||
{
|
||||
_baseLocations.Add(baseLocation);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
public string Name { get; }
|
||||
|
||||
public string? ShortDescription { get; }
|
||||
|
||||
public string[] BaseLocations => _baseLocations.ToArray();
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http.Headers;
|
||||
using Solicitors.Core.Misc;
|
||||
using Solicitors.Core.Models.Imports;
|
||||
using Solicitors.HtmlParsing;
|
||||
using Solicitors.HtmlParsing.Models;
|
||||
|
||||
namespace Solicitors.CacheBuild.SolicitorsDotCom;
|
||||
|
||||
internal class SolicitorParser : ISolicitorParser
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly IHtmlParser _htmlParser;
|
||||
|
||||
public SolicitorParser(
|
||||
HttpClient client,
|
||||
IHtmlParser htmlParser)
|
||||
{
|
||||
_client = client;
|
||||
_client.BaseAddress = new Uri(Consts.BaseUrl);
|
||||
|
||||
_htmlParser = htmlParser;
|
||||
}
|
||||
|
||||
public async Task<SolicitorData[]> GetSolicitorsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var builders = new Dictionary<string, SolicitorBuilder>();
|
||||
|
||||
foreach (var location in Consts.Locations)
|
||||
{
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, $"/conveyancing+{location}.html");
|
||||
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var nodes = _htmlParser.ParseHtml(html);
|
||||
IHtmlNode? mainNode = null;
|
||||
foreach (var node in nodes)
|
||||
if (node.TryGetByTagName("main", out mainNode))
|
||||
break;
|
||||
|
||||
if (TryGetResultSection(mainNode, out var results))
|
||||
ParseResultsSection(results, location, builders);
|
||||
}
|
||||
|
||||
var batches = builders.Values
|
||||
.Select(builder => GetFullSolicitorDataAsync(builder, cancellationToken))
|
||||
.Batch(5);
|
||||
|
||||
var solicitors = new List<SolicitorData?>();
|
||||
foreach (var batch in batches)
|
||||
solicitors.AddRange(await Task.WhenAll(batch));
|
||||
|
||||
return solicitors
|
||||
.Where(x => x is not null)
|
||||
.Cast<SolicitorData>()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private bool TryGetResultSection(
|
||||
IHtmlNode? mainNode,
|
||||
[NotNullWhen(true)] out IEnumerable<IHtmlNode>? results)
|
||||
{
|
||||
results = null;
|
||||
|
||||
// going one level at a time helps a little with performance here, at the cost of messier code
|
||||
return mainNode is not null
|
||||
&& mainNode.TryGetByTagNameAndClass("div", "content-holder", out var contentHolderNode)
|
||||
&& contentHolderNode.TryGetByTagNameAndClass("div", "container", out var containerNode)
|
||||
&& containerNode.TryGetByTagNameAndClass("div", "content", out var contentNode)
|
||||
&& contentNode.TryGetByTagNameAndClass("div", "result-section", out var resultSectionNode)
|
||||
&& resultSectionNode.TryGetChildren(out results);
|
||||
}
|
||||
|
||||
private void ParseResultsSection(
|
||||
IEnumerable<IHtmlNode> results,
|
||||
string location,
|
||||
Dictionary<string, SolicitorBuilder> builders)
|
||||
{
|
||||
foreach (var result in results)
|
||||
{
|
||||
if (result.TryGetByClass("result-item", out var resultItem, true)
|
||||
&& TryParseToNewBuilder(resultItem, location, out var builder))
|
||||
{
|
||||
if (!builders.TryAdd(builder.Path, builder))
|
||||
builders[builder.Path].AddBaseLocation(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryParseToNewBuilder(
|
||||
IHtmlNode resultItemNode,
|
||||
string baseLocation,
|
||||
[NotNullWhen(true)] out SolicitorBuilder? builder)
|
||||
{
|
||||
builder = null;
|
||||
if (!resultItemNode.TryGetByTagNameAndClass("a", "link-map", out var linkMapNode)
|
||||
|| !linkMapNode.TryGetAttributeValue("href", out var uniqueUrl)
|
||||
|| !resultItemNode.TryGetByTagNameAndClass("span", "h2", out var titleNode)
|
||||
|| !titleNode.TryGetChildren(out var titleChildren))
|
||||
return false;
|
||||
|
||||
string? name = null;
|
||||
foreach (var child in titleChildren)
|
||||
if (child.TryGetText(out name))
|
||||
break;
|
||||
|
||||
if (name is null)
|
||||
return false;
|
||||
|
||||
string? shortDesc = null;
|
||||
if (resultItemNode.TryGetByTagName("p", out var pNode)
|
||||
&& pNode.TryGetChildren(out var pChildren))
|
||||
foreach (var child in pChildren)
|
||||
if (child.TryGetText(out shortDesc))
|
||||
break;
|
||||
|
||||
builder = new SolicitorBuilder(name, uniqueUrl, shortDesc, baseLocation);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<SolicitorData?> GetFullSolicitorDataAsync(
|
||||
SolicitorBuilder builder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
var httpRequest = new HttpRequestMessage(HttpMethod.Get, builder.Path);
|
||||
httpRequest.Headers.Add("User-Agent", "Solicitors API Cache");
|
||||
var httpResponse = await _client.SendAsync(httpRequest, cancellationToken);
|
||||
var html = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||
var nodes = _htmlParser.ParseHtml(html);
|
||||
IHtmlNode? mainNode = null;
|
||||
foreach (var node in nodes)
|
||||
if (node.TryGetByTagName("main", out mainNode))
|
||||
break;
|
||||
|
||||
if (mainNode is null || !mainNode.TryGetByTagNameAndClass("div", "content-block", out var contentBlockNode))
|
||||
return null;
|
||||
|
||||
Location[] offices = [];
|
||||
if (contentBlockNode.TryGetByTagNameAndClass("div", "office-item", out var officeItemNode)
|
||||
&& officeItemNode.TryGetChildren(out var officeItems))
|
||||
{
|
||||
offices = ParseOffices(officeItems);
|
||||
}
|
||||
|
||||
Rating[] ratings = [];
|
||||
string? phone = null;
|
||||
string? email = null;
|
||||
string? website = null;
|
||||
if (mainNode.TryGetByTagNameAndClass("div", "side-holder", out var sidebarNode)
|
||||
&& sidebarNode.TryGetChildren(out var sidebarChildren))
|
||||
{
|
||||
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
|
||||
}
|
||||
|
||||
return new SolicitorData()
|
||||
{
|
||||
Name = builder.Name,
|
||||
UrlPath = builder.Path,
|
||||
ShortDescription = builder.ShortDescription,
|
||||
Phone = phone,
|
||||
Email = email,
|
||||
Website = website,
|
||||
Ratings = ratings,
|
||||
Offices = offices,
|
||||
Cities = builder.BaseLocations
|
||||
};
|
||||
}
|
||||
|
||||
Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
|
||||
=> officeNodes
|
||||
.Select(ParseOffice)
|
||||
.Where(x => x is not null)
|
||||
.Cast<Location>()
|
||||
.ToArray();
|
||||
|
||||
Location? ParseOffice(IHtmlNode officeNode)
|
||||
{
|
||||
if (!officeNode.TryGetChildren(out var officeChildren)
|
||||
|| !officeNode.TryGetByTagName("address", out var addressNode)
|
||||
|| !addressNode.TryGetChildren(out var addressLines))
|
||||
return null;
|
||||
|
||||
Location? location = null;
|
||||
string? address = "";
|
||||
foreach (var line in addressLines)
|
||||
{
|
||||
if (line.TryGetText(out var lineText))
|
||||
address += lineText;
|
||||
else if (line.TryGetByTagName("br", out _, true))
|
||||
address += "\n";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
address = null;
|
||||
|
||||
string? phone = null;
|
||||
foreach (var child in officeChildren)
|
||||
{
|
||||
if (child.TryGetByTagName("a", out var anchor)
|
||||
&& anchor.TryGetAttributeValue("href", out var telString)
|
||||
&& telString.StartsWith("\"tel:")
|
||||
&& telString.Length > 5)
|
||||
{
|
||||
phone = telString.Substring(5, telString.Length - 6);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (address is not null && phone is not null)
|
||||
location = new Location(address, phone, []);
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
void ParseSidebar(
|
||||
IEnumerable<IHtmlNode> sidebarChildren,
|
||||
out string? phone,
|
||||
out string? email,
|
||||
out string? website,
|
||||
out Rating[] ratings)
|
||||
{
|
||||
List<Rating> ratingsList = [];
|
||||
phone = null;
|
||||
website = null;
|
||||
email = null;
|
||||
|
||||
foreach (var node in sidebarChildren)
|
||||
{
|
||||
var isRevCount = node.TryGetByTagNameAndClass("div", "rev-count", out _, true);
|
||||
var isLinksHolder = node.TryGetByTagNameAndClass("div", "links-holder", out _, true);
|
||||
if ((!isRevCount && !isLinksHolder)
|
||||
|| !node.TryGetChildren(out var children))
|
||||
continue;
|
||||
|
||||
if (isLinksHolder)
|
||||
{
|
||||
if (node.TryGetByTagNameAndClass("a", "website", out var websiteNode)
|
||||
&& websiteNode.TryGetAttributeValue("href", out website))
|
||||
website = website.Trim('"');
|
||||
|
||||
if (node.TryGetByTagNameAndClass("a", "phone", out var phoneNode)
|
||||
&& phoneNode.TryGetAttributeValue("href", out var phoneStr)
|
||||
&& phoneStr.StartsWith("\"tel:")
|
||||
&& phoneStr.Length > 5)
|
||||
{
|
||||
phone = phoneStr.Substring(5, phoneStr.Length - 6);
|
||||
}
|
||||
}
|
||||
|
||||
var childrenArray = children as IHtmlNode[] ?? children.ToArray();
|
||||
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
|
||||
{
|
||||
if (child.TryGetAttributeValue("title", out var ratingString)
|
||||
&& TryParseRatingString(ratingString, out decimal rating, out decimal maxRating)
|
||||
&& child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode)
|
||||
&& imgNode.TryGetAttributeValue("src", out var imgSrc)
|
||||
&& TryParseRatingSrc(imgSrc, out var ratingProvider))
|
||||
{
|
||||
ratingsList.Add(new Rating(rating, maxRating, ratingProvider, imgSrc));
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingsList.Count == 0)
|
||||
{
|
||||
foreach (var child in childrenArray.Where(x => !x.HasAttribute("class")))
|
||||
{
|
||||
if (child.TryGetChildren(out var grandChildren))
|
||||
foreach (var grandChild in grandChildren)
|
||||
if (grandChild.TryGetText(out var textContent)
|
||||
&& TryParseOwnRatingString(textContent, out var rating))
|
||||
ratingsList.Add(new Rating(rating, Consts.MaxRating, Consts.Name, Consts.IconPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ratings = ratingsList.ToArray();
|
||||
}
|
||||
|
||||
|
||||
bool TryParseRatingString(string ratingString, out decimal rating, out decimal maxRating)
|
||||
{
|
||||
rating = 0;
|
||||
maxRating = 0;
|
||||
var parts = ratingString.Trim('"').Split(" / ");
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
|
||||
}
|
||||
|
||||
bool TryParseRatingSrc(string imgSrc, out string provider)
|
||||
{
|
||||
provider = "";
|
||||
imgSrc = imgSrc.Trim('"');
|
||||
var prefix = "/images/logo-";
|
||||
if (!imgSrc.StartsWith(prefix))
|
||||
return false;
|
||||
|
||||
var providerAndExtension = imgSrc.Substring(prefix.Length);
|
||||
provider = providerAndExtension.Split('.').First();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TryParseOwnRatingString(string ratingString, out decimal rating)
|
||||
{
|
||||
rating = 0;
|
||||
var prefix = "Average review score : ";
|
||||
if (!ratingString.StartsWith(prefix))
|
||||
return false;
|
||||
|
||||
var ratingPart = ratingString.Substring(prefix.Length);
|
||||
return decimal.TryParse(ratingPart, out rating);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Solicitors.Core;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static IServiceCollection AddCore(this IServiceCollection services)
|
||||
{
|
||||
return services
|
||||
.AddScoped<ISolicitorService, SolicitorService>()
|
||||
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
|
||||
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
|
||||
.AddScoped<IRatingsProviderService, RatingsProviderService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Core.Data;
|
||||
|
||||
public interface IRatingsProviderRepository
|
||||
{
|
||||
IAsyncEnumerable<string> GetAllRatingsProvidersAsync();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Solicitors.Core.Models;
|
||||
|
||||
namespace Solicitors.Core.Data;
|
||||
|
||||
public interface IReadOnlySolicitorRepository
|
||||
{
|
||||
IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync();
|
||||
Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IReadOnlyCitiesRepository
|
||||
{
|
||||
IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Solicitors.Core.Models.Imports;
|
||||
|
||||
namespace Solicitors.Core.Data;
|
||||
|
||||
public interface ISolicitorRepository : IReadOnlySolicitorRepository
|
||||
{
|
||||
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Core;
|
||||
|
||||
public interface IRatingsProviderService
|
||||
{
|
||||
Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Core;
|
||||
|
||||
public interface IReadOnlyCitiesService
|
||||
{
|
||||
Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Solicitors.Core.Misc;
|
||||
using Solicitors.Core.Models;
|
||||
|
||||
namespace Solicitors.Core;
|
||||
|
||||
public interface IReadOnlySolicitorService
|
||||
{
|
||||
Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||
Pagination pagination,
|
||||
string ratingsProvider,
|
||||
IFilter<Solicitor>? filter = null,
|
||||
IComparer<Solicitor>? ordering = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Core;
|
||||
|
||||
public interface ISolicitorService : IReadOnlySolicitorService
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Solicitors.Core.Models;
|
||||
|
||||
namespace Solicitors.Core.Misc;
|
||||
|
||||
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
|
||||
{
|
||||
public static IComparer<Solicitor> Instance { get; } = new DefaultSolicitorDataOrdering();
|
||||
|
||||
private DefaultSolicitorDataOrdering()
|
||||
{
|
||||
}
|
||||
|
||||
public int Compare(Solicitor? x, Solicitor? y)
|
||||
{
|
||||
if (x is null)
|
||||
return y is null ? 0 : int.MaxValue;
|
||||
|
||||
else if (y is null)
|
||||
return int.MinValue;
|
||||
|
||||
return Comparer<Guid>.Default.Compare(x.SolicitorId, y.SolicitorId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Solicitors.Core.Misc;
|
||||
|
||||
public static class EnumerableExtensions
|
||||
{
|
||||
public static IEnumerable<T[]> Batch<T>(this IEnumerable<T> source, int batchSize)
|
||||
{
|
||||
var batch = new List<T>(batchSize);
|
||||
foreach (var item in source)
|
||||
{
|
||||
batch.Add(item);
|
||||
if (batch.Count == batch.Capacity)
|
||||
{
|
||||
yield return batch.ToArray();
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
yield return batch.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Solicitors.Core.Misc;
|
||||
|
||||
public interface IFilter<T>
|
||||
{
|
||||
bool Filter(T item);
|
||||
}
|
||||
|
||||
public class WrapperFilter<T> : IFilter<T>
|
||||
{
|
||||
private readonly Func<T, bool> _filter;
|
||||
private readonly IFilter<T>? _inner;
|
||||
|
||||
public WrapperFilter(
|
||||
IFilter<T>? inner,
|
||||
Func<T, bool> filter)
|
||||
{
|
||||
_inner = inner;
|
||||
_filter = filter;
|
||||
}
|
||||
|
||||
|
||||
public bool Filter(T item)
|
||||
{
|
||||
if (_inner is not null)
|
||||
return _inner.Filter(item) && _filter(item);
|
||||
|
||||
return _filter(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Solicitors.Core.Misc;
|
||||
|
||||
public readonly struct Pagination(uint currentPage, uint pageSize)
|
||||
{
|
||||
public uint CurrentPage { get; } = currentPage;
|
||||
public uint PageSize { get; } = pageSize;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record City
|
||||
{
|
||||
public Guid CityId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
public List<Solicitor> Solicitors { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Solicitors.Core.Models.Imports;
|
||||
|
||||
public record Location(string address, string phone, Rating[] locationRatings)
|
||||
{
|
||||
public string Address { get; } = address;
|
||||
public string Phone { get; } = phone;
|
||||
public Rating[] LocationRatings { get; } = locationRatings;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Solicitors.Core.Models.Imports;
|
||||
|
||||
public record Rating(decimal value, decimal maxValue, string provider, string imgSrc)
|
||||
{
|
||||
public decimal Value { get; } = value;
|
||||
public decimal MaxValue { get; } = maxValue;
|
||||
public string RatingProvider { get; } = provider;
|
||||
public string RatingProviderImgSrc { get; } = imgSrc;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Solicitors.Core.Models.Imports;
|
||||
|
||||
public class SolicitorData
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string UrlPath { get; init; }
|
||||
|
||||
public string? ShortDescription { get; init; }
|
||||
public string? Phone { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? Website { get; init; }
|
||||
|
||||
public string[] Cities { get; init; } = [];
|
||||
public Rating[] Ratings { get; init; } = [];
|
||||
public Location[] Offices { get; init; } = [];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record LocationInfo
|
||||
{
|
||||
public LocationInfo(SolicitorLocation location)
|
||||
{
|
||||
Address = location.Address;
|
||||
Phone = location.Phone;
|
||||
Ratings = location.LocationRatings
|
||||
.Select(rating => new RatingInfo(rating))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string Address { get; }
|
||||
public string Phone { get; }
|
||||
|
||||
public RatingInfo[] Ratings { get; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record RatingInfo
|
||||
{
|
||||
public RatingInfo(IRating rating)
|
||||
{
|
||||
Value = rating.Value;
|
||||
Maximum = rating.Maximum;
|
||||
Provider = rating.Provider;
|
||||
}
|
||||
|
||||
public decimal Value { get; }
|
||||
public decimal Maximum { get; }
|
||||
public string Provider { get; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record Solicitor
|
||||
{
|
||||
public Guid SolicitorId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
public required string RelativeUrl { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? ShortDescription { get; set; }
|
||||
|
||||
public List<City> Cities { get; } = new();
|
||||
public List<SolicitorRating> Ratings { get; } = new();
|
||||
public List<SolicitorLocation> Locations { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record SolicitorInfo
|
||||
{
|
||||
internal SolicitorInfo(Solicitor solicitor)
|
||||
{
|
||||
Id = solicitor.SolicitorId;
|
||||
Name = solicitor.Name;
|
||||
ShortDescription = solicitor.ShortDescription;
|
||||
Phone = solicitor.Phone;
|
||||
Email = solicitor.Email;
|
||||
Website = solicitor.Website;
|
||||
|
||||
Cities = solicitor.Cities
|
||||
.Select(city => city.Name)
|
||||
.ToArray();
|
||||
|
||||
Ratings = solicitor.Ratings
|
||||
.Select(rating => new RatingInfo(rating))
|
||||
.ToArray();
|
||||
|
||||
Locations = solicitor.Locations
|
||||
.Select(location => new LocationInfo(location))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string Name { get; set; }
|
||||
public string ShortDescription { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string[] Cities { get; set; }
|
||||
public RatingInfo[] Ratings { get; set; }
|
||||
public LocationInfo[] Locations { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record SolicitorLocation
|
||||
{
|
||||
public Guid SolicitorLocationId { get; set; }
|
||||
|
||||
public required string Address { get; set; }
|
||||
public required string Phone { get; set; }
|
||||
|
||||
public List<SolicitorLocationRating> LocationRatings { get; } = new();
|
||||
|
||||
public Guid SolicitorId { get; set; }
|
||||
public required Solicitor Solicitor { get; set; }
|
||||
}
|
||||
|
||||
public interface IRating
|
||||
{
|
||||
decimal Value { get; }
|
||||
decimal Maximum { get; }
|
||||
string Provider { get; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record SolicitorLocationRating : IRating
|
||||
{
|
||||
public Guid SolicitorLocationRatingId { get; set; }
|
||||
|
||||
public required decimal Value { get; set; }
|
||||
public required decimal Maximum { get; set; }
|
||||
public required string Provider { get; set; }
|
||||
|
||||
public Guid SolicitorLocationId { get; set; }
|
||||
public required SolicitorLocation SolicitorLocation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record SolicitorRating : IRating
|
||||
{
|
||||
public Guid SolicitorRatingId { get; set; }
|
||||
|
||||
public required decimal Value { get; set; }
|
||||
public required decimal Maximum { get; set; }
|
||||
public required string Provider { get; set; }
|
||||
|
||||
public Guid SolicitorId { get; set; }
|
||||
public required Solicitor Solicitor { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Solicitors.Core.Models;
|
||||
|
||||
public record SolicitorSummary
|
||||
{
|
||||
internal SolicitorSummary(Solicitor solicitor, string ratingsProvider)
|
||||
{
|
||||
Id = solicitor.SolicitorId;
|
||||
Name = solicitor.Name;
|
||||
ShortDescription = solicitor.ShortDescription;
|
||||
var rating = solicitor.Ratings.FirstOrDefault(x => x.Provider == ratingsProvider);
|
||||
if (rating is not null)
|
||||
Rating = new RatingInfo(rating);
|
||||
}
|
||||
|
||||
public Guid Id { get; }
|
||||
public string Name { get; }
|
||||
public string? ShortDescription { get; }
|
||||
public RatingInfo? Rating { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Solicitors.Core;
|
||||
|
||||
public record PaginationResponse<T>(int Total, T[] Data)
|
||||
{
|
||||
public T[] Data { get; set; } = Data;
|
||||
public int Total { get; set; } = Total;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Solicitors.Core.Data;
|
||||
|
||||
namespace Solicitors.Core;
|
||||
|
||||
internal class RatingsProviderService : IRatingsProviderService
|
||||
{
|
||||
private readonly IRatingsProviderRepository _repo;
|
||||
|
||||
public RatingsProviderService(IRatingsProviderRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Solicitors.Core.Data;
|
||||
|
||||
namespace Solicitors.Core;
|
||||
|
||||
internal class ReadOnlyCitiesService : IReadOnlyCitiesService
|
||||
{
|
||||
private readonly IReadOnlyCitiesRepository _repo;
|
||||
|
||||
public ReadOnlyCitiesService(IReadOnlyCitiesRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var cities = _repo.GetCitiesAsync(cancellationToken);
|
||||
return await cities.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Solicitors.Core.Data;
|
||||
using Solicitors.Core.Misc;
|
||||
using Solicitors.Core.Models;
|
||||
|
||||
namespace Solicitors.Core;
|
||||
|
||||
internal class SolicitorService : ISolicitorService
|
||||
{
|
||||
private readonly IReadOnlySolicitorRepository _repo;
|
||||
|
||||
public SolicitorService(IReadOnlySolicitorRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
|
||||
Pagination pagination,
|
||||
string ratingsProvider,
|
||||
IFilter<Solicitor>? filter = null,
|
||||
IComparer<Solicitor>? ordering = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var allSolicitors = _repo.GetAllSolicitorsAsync();
|
||||
if (filter is not null)
|
||||
allSolicitors = allSolicitors.Where(filter.Filter);
|
||||
|
||||
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
|
||||
|
||||
if (ordering is null)
|
||||
ordering = DefaultSolicitorDataOrdering.Instance;
|
||||
|
||||
var results = matchingSolicitors
|
||||
.OrderBy(x => x, ordering)
|
||||
.Skip((int)((pagination.CurrentPage - 1) * pagination.PageSize))
|
||||
.Take((int)pagination.PageSize)
|
||||
.Select(x => new SolicitorSummary(x, ratingsProvider))
|
||||
.ToArray();
|
||||
|
||||
return new PaginationResponse<SolicitorSummary>(matchingSolicitors.Length, results);
|
||||
}
|
||||
|
||||
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await _repo.GetSolicitorByIdAsync(id, cancellationToken);
|
||||
if (data is null)
|
||||
return null;
|
||||
|
||||
return new SolicitorInfo(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Solicitors.Core;
|
||||
using Solicitors.Core.Data;
|
||||
using Solicitors.Data.RepositorySetup;
|
||||
using Solicitors.Data.RepositorySetup.InMemory;
|
||||
using Solicitors.Data.RepositorySetup.Sqlite;
|
||||
|
||||
namespace Solicitors.Data;
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static IServiceCollection AddData(this IServiceCollection services, IRepoSetupOptions options)
|
||||
{
|
||||
return services
|
||||
.AddScoped<SolicitorsRepository>()
|
||||
.AddScoped<ISolicitorRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
|
||||
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
|
||||
.AddSingleton(GetSetupService(options));
|
||||
}
|
||||
|
||||
private static IRepoSetupService GetSetupService(IRepoSetupOptions options)
|
||||
{
|
||||
if (options is InMemoryDbSetupOptions inMemOptions)
|
||||
return new InMemorySetupService(inMemOptions);
|
||||
|
||||
if (options is SqliteDbSetupOptions sqliteOptions)
|
||||
return new SqliteSetupService(sqliteOptions);
|
||||
|
||||
throw new ArgumentException("Options type not supported", nameof(options));
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Solicitors.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Solicitors.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(SolicitorsRepository))]
|
||||
[Migration("20260622195820_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("CitySolicitor", b =>
|
||||
{
|
||||
b.Property<Guid>("CitiesCityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorsSolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
|
||||
|
||||
b.HasIndex("SolicitorsSolicitorId");
|
||||
|
||||
b.ToTable("CitySolicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
|
||||
{
|
||||
b.Property<Guid>("CityId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CityId");
|
||||
|
||||
b.ToTable("Cities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelativeUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ShortDescription")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Website")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorId");
|
||||
|
||||
b.ToTable("Solicitors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorLocationId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorLocationId");
|
||||
|
||||
b.HasIndex("SolicitorId");
|
||||
|
||||
b.ToTable("SolicitorLocations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorLocationRatingId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Maximum")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorLocationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorLocationRatingId");
|
||||
|
||||
b.HasIndex("SolicitorLocationId");
|
||||
|
||||
b.ToTable("SolicitorLocationRatings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorRatingId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Maximum")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorRatingId");
|
||||
|
||||
b.HasIndex("SolicitorId");
|
||||
|
||||
b.ToTable("SolicitorRatings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CitySolicitor", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.City", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CitiesCityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SolicitorsSolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||
.WithMany("Locations")
|
||||
.HasForeignKey("SolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Solicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
|
||||
.WithMany("LocationRatings")
|
||||
.HasForeignKey("SolicitorLocationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SolicitorLocation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||
.WithMany("Ratings")
|
||||
.HasForeignKey("SolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Solicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||
{
|
||||
b.Navigation("Locations");
|
||||
|
||||
b.Navigation("Ratings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.Navigation("LocationRatings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Solicitors.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Cities",
|
||||
columns: table => new
|
||||
{
|
||||
CityId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Cities", x => x.CityId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Solicitors",
|
||||
columns: table => new
|
||||
{
|
||||
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RelativeUrl = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Phone = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Website = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ShortDescription = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Solicitors", x => x.SolicitorId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CitySolicitor",
|
||||
columns: table => new
|
||||
{
|
||||
CitiesCityId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SolicitorsSolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CitySolicitor", x => new { x.CitiesCityId, x.SolicitorsSolicitorId });
|
||||
table.ForeignKey(
|
||||
name: "FK_CitySolicitor_Cities_CitiesCityId",
|
||||
column: x => x.CitiesCityId,
|
||||
principalTable: "Cities",
|
||||
principalColumn: "CityId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CitySolicitor_Solicitors_SolicitorsSolicitorId",
|
||||
column: x => x.SolicitorsSolicitorId,
|
||||
principalTable: "Solicitors",
|
||||
principalColumn: "SolicitorId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SolicitorLocations",
|
||||
columns: table => new
|
||||
{
|
||||
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Address = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Phone = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SolicitorLocations", x => x.SolicitorLocationId);
|
||||
table.ForeignKey(
|
||||
name: "FK_SolicitorLocations_Solicitors_SolicitorId",
|
||||
column: x => x.SolicitorId,
|
||||
principalTable: "Solicitors",
|
||||
principalColumn: "SolicitorId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SolicitorRatings",
|
||||
columns: table => new
|
||||
{
|
||||
SolicitorRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Value = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SolicitorRatings", x => x.SolicitorRatingId);
|
||||
table.ForeignKey(
|
||||
name: "FK_SolicitorRatings_Solicitors_SolicitorId",
|
||||
column: x => x.SolicitorId,
|
||||
principalTable: "Solicitors",
|
||||
principalColumn: "SolicitorId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SolicitorLocationRatings",
|
||||
columns: table => new
|
||||
{
|
||||
SolicitorLocationRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Value = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SolicitorLocationRatings", x => x.SolicitorLocationRatingId);
|
||||
table.ForeignKey(
|
||||
name: "FK_SolicitorLocationRatings_SolicitorLocations_SolicitorLocationId",
|
||||
column: x => x.SolicitorLocationId,
|
||||
principalTable: "SolicitorLocations",
|
||||
principalColumn: "SolicitorLocationId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CitySolicitor_SolicitorsSolicitorId",
|
||||
table: "CitySolicitor",
|
||||
column: "SolicitorsSolicitorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SolicitorLocationRatings_SolicitorLocationId",
|
||||
table: "SolicitorLocationRatings",
|
||||
column: "SolicitorLocationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SolicitorLocations_SolicitorId",
|
||||
table: "SolicitorLocations",
|
||||
column: "SolicitorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SolicitorRatings_SolicitorId",
|
||||
table: "SolicitorRatings",
|
||||
column: "SolicitorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CitySolicitor");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SolicitorLocationRatings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SolicitorRatings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Cities");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SolicitorLocations");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Solicitors");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Solicitors.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Solicitors.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(SolicitorsRepository))]
|
||||
partial class SolicitorsRepositoryModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("CitySolicitor", b =>
|
||||
{
|
||||
b.Property<Guid>("CitiesCityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorsSolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
|
||||
|
||||
b.HasIndex("SolicitorsSolicitorId");
|
||||
|
||||
b.ToTable("CitySolicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
|
||||
{
|
||||
b.Property<Guid>("CityId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CityId");
|
||||
|
||||
b.ToTable("Cities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelativeUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ShortDescription")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Website")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorId");
|
||||
|
||||
b.ToTable("Solicitors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorLocationId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorLocationId");
|
||||
|
||||
b.HasIndex("SolicitorId");
|
||||
|
||||
b.ToTable("SolicitorLocations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorLocationRatingId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Maximum")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorLocationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorLocationRatingId");
|
||||
|
||||
b.HasIndex("SolicitorLocationId");
|
||||
|
||||
b.ToTable("SolicitorLocationRatings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||
{
|
||||
b.Property<Guid>("SolicitorRatingId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Maximum")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("SolicitorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SolicitorRatingId");
|
||||
|
||||
b.HasIndex("SolicitorId");
|
||||
|
||||
b.ToTable("SolicitorRatings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CitySolicitor", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.City", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CitiesCityId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SolicitorsSolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||
.WithMany("Locations")
|
||||
.HasForeignKey("SolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Solicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
|
||||
.WithMany("LocationRatings")
|
||||
.HasForeignKey("SolicitorLocationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SolicitorLocation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
|
||||
{
|
||||
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
|
||||
.WithMany("Ratings")
|
||||
.HasForeignKey("SolicitorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Solicitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
|
||||
{
|
||||
b.Navigation("Locations");
|
||||
|
||||
b.Navigation("Ratings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
|
||||
{
|
||||
b.Navigation("LocationRatings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Data.RepositorySetup;
|
||||
|
||||
public interface IRepoSetupOptions
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Solicitors.Data.RepositorySetup;
|
||||
|
||||
internal interface IRepoSetupService
|
||||
{
|
||||
void OnConfiguring(DbContextOptionsBuilder optionsBuilder);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||
|
||||
public class InMemoryDbSetupOptions(string dbName) : IRepoSetupOptions
|
||||
{
|
||||
public string DbName { get; } = dbName;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Solicitors.Data.RepositorySetup.InMemory;
|
||||
|
||||
internal class InMemorySetupService(InMemoryDbSetupOptions config) : IRepoSetupService
|
||||
{
|
||||
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||
{
|
||||
options.UseInMemoryDatabase(config.DbName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||
|
||||
public class SqliteDbSetupOptions(string dbName) : IRepoSetupOptions
|
||||
{
|
||||
public string DbName { get; } = dbName;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Solicitors.Data.RepositorySetup.Sqlite;
|
||||
|
||||
internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupService
|
||||
{
|
||||
public void OnConfiguring(DbContextOptionsBuilder options)
|
||||
{
|
||||
var folder = Environment.SpecialFolder.MyDocuments;
|
||||
var path = Environment.GetFolderPath(folder);
|
||||
path = Path.Combine(path, "/data");
|
||||
var dbPath = System.IO.Path.Join(path, $"{config.DbName}.db");
|
||||
var dbString = $"Data Source={dbPath}";
|
||||
options.UseSqlite(dbString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Solicitors.Core\Solicitors.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,157 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Solicitors.Core;
|
||||
using Solicitors.Core.Data;
|
||||
using Solicitors.Core.Models;
|
||||
using Solicitors.Core.Models.Imports;
|
||||
using Solicitors.Data.RepositorySetup;
|
||||
|
||||
namespace Solicitors.Data;
|
||||
|
||||
internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
|
||||
{
|
||||
private readonly IRepoSetupService _setup;
|
||||
|
||||
public SolicitorsRepository(IRepoSetupService setup)
|
||||
{
|
||||
_setup = setup;
|
||||
}
|
||||
|
||||
public DbSet<Solicitor> Solicitors { get; set; }
|
||||
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
|
||||
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
|
||||
public DbSet<SolicitorLocationRating> SolicitorLocationRatings { get; set; }
|
||||
public DbSet<City> Cities { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
=> _setup.OnConfiguring(optionsBuilder);
|
||||
|
||||
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
|
||||
=> Solicitors
|
||||
.Include(solicitor => solicitor.Cities)
|
||||
.Include(solicitor => solicitor.Locations)
|
||||
.ThenInclude(location => location.LocationRatings)
|
||||
.Include(solicitor => solicitor.Ratings)
|
||||
.AsAsyncEnumerable();
|
||||
|
||||
public Task<Solicitor?> GetSolicitorByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> Solicitors
|
||||
.Include(solicitor => solicitor.Cities)
|
||||
.Include(solicitor => solicitor.Locations)
|
||||
.ThenInclude(location => location.LocationRatings)
|
||||
.Include(solicitor => solicitor.Ratings)
|
||||
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
|
||||
|
||||
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
|
||||
{
|
||||
var matchingSolicitor = await Solicitors
|
||||
.FirstOrDefaultAsync(
|
||||
item => item.Name == solicitor.Name,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (matchingSolicitor is null)
|
||||
await AddNewSolicitor(solicitor, cancellationToken);
|
||||
else
|
||||
await UpdateExistingSolicitor(solicitor, matchingSolicitor, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task AddNewSolicitor(
|
||||
SolicitorData solicitor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var newSol = new Solicitor()
|
||||
{
|
||||
Name = solicitor.Name,
|
||||
RelativeUrl = solicitor.UrlPath,
|
||||
Phone = solicitor.Phone,
|
||||
Email = solicitor.Email,
|
||||
Website = solicitor.Website,
|
||||
ShortDescription = solicitor.ShortDescription
|
||||
};
|
||||
Solicitors.Add(newSol);
|
||||
|
||||
var cityTasks = solicitor.Cities
|
||||
.Distinct()
|
||||
.Select(async (city) =>
|
||||
{
|
||||
var matchingCity = await Cities.FirstOrDefaultAsync(
|
||||
item => item.Name == city,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (matchingCity is null)
|
||||
{
|
||||
matchingCity = new City() { Name = city };
|
||||
Cities.Add(matchingCity);
|
||||
newSol.Cities.Add(matchingCity);
|
||||
}
|
||||
|
||||
matchingCity.Solicitors.Add(newSol);
|
||||
});
|
||||
await Task.WhenAll(cityTasks);
|
||||
|
||||
foreach (var rating in solicitor.Ratings)
|
||||
{
|
||||
var newRating = new SolicitorRating()
|
||||
{
|
||||
Value = rating.Value,
|
||||
Maximum = rating.MaxValue,
|
||||
Provider = rating.RatingProvider,
|
||||
Solicitor = newSol
|
||||
};
|
||||
SolicitorRatings.Add(newRating);
|
||||
newSol.Ratings.Add(newRating);
|
||||
}
|
||||
|
||||
foreach (var location in solicitor.Offices)
|
||||
{
|
||||
var newLocation = new SolicitorLocation()
|
||||
{
|
||||
Address = location.Address,
|
||||
Phone = location.Phone,
|
||||
Solicitor = newSol
|
||||
};
|
||||
SolicitorLocations.Add(newLocation);
|
||||
newSol.Locations.Add(newLocation);
|
||||
|
||||
foreach (var rating in location.LocationRatings)
|
||||
{
|
||||
var newLocRating = new SolicitorLocationRating()
|
||||
{
|
||||
Value = rating.value,
|
||||
Maximum = rating.MaxValue,
|
||||
Provider = rating.RatingProvider,
|
||||
SolicitorLocation = newLocation
|
||||
};
|
||||
SolicitorLocationRatings.Add(newLocRating);
|
||||
newLocation.LocationRatings.Add(newLocRating);
|
||||
}
|
||||
}
|
||||
|
||||
await SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private Task UpdateExistingSolicitor(
|
||||
SolicitorData solicitor,
|
||||
Solicitor matchingSolicitor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Cities
|
||||
.Select(city => city.Name)
|
||||
.AsAsyncEnumerable();
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<string> GetAllRatingsProvidersAsync()
|
||||
{
|
||||
return SolicitorRatings
|
||||
.Select(rating => rating.Provider)
|
||||
.Distinct()
|
||||
.OrderBy(x => x == "Solicitors.com" ? 0 : 1)
|
||||
.ThenBy(x => x)
|
||||
.AsAsyncEnumerable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Solicitors.HtmlParsing;
|
||||
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)
|
||||
{
|
||||
return services.AddSingleton<IHtmlParser, HtmlParser>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace Solicitors.HtmlParsing;
|
||||
|
||||
using Models;
|
||||
|
||||
internal class HtmlParser : IHtmlParser
|
||||
{
|
||||
private readonly string[] _voidElements =
|
||||
[
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"source",
|
||||
"track",
|
||||
"wbr"
|
||||
];
|
||||
|
||||
public IEnumerable<IHtmlNode> ParseHtml(string html)
|
||||
{
|
||||
// wanted to do this in one pass but was quite time-consuming
|
||||
// so taking a little lesson from compiler development
|
||||
// and lex the text into tokens before parsing :)
|
||||
// deferred execution might help it still be "one pass"?
|
||||
var stringEnumerator = html.GetEnumerator();
|
||||
var tokens = LexHtml(stringEnumerator);
|
||||
var tokenEnumerator = tokens.GetEnumerator();
|
||||
return ParseHtml(tokenEnumerator);
|
||||
}
|
||||
|
||||
private IEnumerable<string> LexHtml(IEnumerator<char> content)
|
||||
{
|
||||
var work = "";
|
||||
while (content.MoveNext())
|
||||
{
|
||||
if (content.Current == '<' && !string.IsNullOrWhiteSpace(work))
|
||||
{
|
||||
yield return work.Trim();
|
||||
work = "";
|
||||
}
|
||||
|
||||
work += content.Current;
|
||||
|
||||
if (content.Current == '>' && !string.IsNullOrWhiteSpace(work))
|
||||
{
|
||||
yield return work.Trim();
|
||||
work = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<IHtmlNode> ParseHtml(IEnumerator<string> tokens)
|
||||
{
|
||||
while (tokens.MoveNext())
|
||||
{
|
||||
var token = tokens.Current;
|
||||
if (!token.StartsWith('<'))
|
||||
{
|
||||
yield return new StringNode(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var noBraces = token.Substring(1, token.Length - 2);
|
||||
var split = noBraces.Split(' ');
|
||||
IHtmlAttribute[] attributes = [];
|
||||
if (split.Length > 1)
|
||||
{
|
||||
attributes = ParseAttributes(noBraces).ToArray();
|
||||
}
|
||||
|
||||
if (IsVoidOrSelfClosingElement(token))
|
||||
yield return new HtmlNode(split.First(), attributes);
|
||||
else if (token.StartsWith("</"))
|
||||
yield break;
|
||||
else
|
||||
{
|
||||
var children = ParseHtml(tokens).ToArray();
|
||||
yield return new ParentHtmlNode(split.First(), attributes, children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool IsVoidOrSelfClosingElement(string token)
|
||||
{
|
||||
if (token.StartsWith("<!") || token.EndsWith("/>"))
|
||||
return true;
|
||||
|
||||
var tagName = token.Split(' ').First().Substring(1);
|
||||
if (tagName.EndsWith('>'))
|
||||
tagName = tagName.Substring(0, tagName.Length - 1);
|
||||
return _voidElements.Contains(tagName);
|
||||
}
|
||||
|
||||
private IEnumerable<IHtmlAttribute> ParseAttributes(string tokenNoBraces)
|
||||
{
|
||||
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
|
||||
var attString = "";
|
||||
bool inQuote = false;
|
||||
foreach (char c in attributesString)
|
||||
{
|
||||
if (!inQuote && c == ' ')
|
||||
{
|
||||
yield return ParseAttribute(attString);
|
||||
attString = "";
|
||||
}
|
||||
else
|
||||
{
|
||||
attString += c;
|
||||
if (c == '\"')
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
}
|
||||
|
||||
yield return ParseAttribute(attString);
|
||||
}
|
||||
|
||||
private IHtmlAttribute ParseAttribute(string attString)
|
||||
{
|
||||
var split = attString.Split('=');
|
||||
if (split.Length > 1)
|
||||
return new HtmlValueAttribute(split[0], string.Join('=', split.Skip(1)));
|
||||
else
|
||||
return new HtmlAttribute(attString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Solicitors.HtmlParsing;
|
||||
|
||||
using Models;
|
||||
|
||||
public interface IHtmlParser
|
||||
{
|
||||
IEnumerable<IHtmlNode> ParseHtml(string html);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal class HtmlAttribute(string name) : IHtmlAttribute
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
|
||||
: IHtmlNode
|
||||
{
|
||||
private readonly IHtmlAttribute[] _attributes = attributes;
|
||||
private readonly string _tagName = tagName;
|
||||
|
||||
public virtual bool TryGetByTagName(
|
||||
string tagName,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
if (tagName == _tagName)
|
||||
node = this;
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
private string[] Classes
|
||||
{
|
||||
get
|
||||
{
|
||||
var classAttribute = _attributes
|
||||
.Where(a => a is HtmlValueAttribute)
|
||||
.Select(a => (a as HtmlValueAttribute)!)
|
||||
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
return classAttribute is not null
|
||||
? classAttribute.Value.Substring(1, classAttribute.Value.Length - 2).Split(' ')
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
public virtual bool TryGetByClass(
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
|
||||
if (Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||
node = this;
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
public virtual bool TryGetByTagNameAndClass(
|
||||
string tagName,
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
if (tagName == _tagName && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
|
||||
node = this;
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
public virtual bool TryGetText([NotNullWhen(true)] out string? text)
|
||||
{
|
||||
text = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public virtual bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||
{
|
||||
children = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool HasAttribute(string attributeName)
|
||||
{
|
||||
return _attributes
|
||||
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
public bool TryGetAttributeValue(string attributeName, [NotNullWhen(true)] out string? attributeValue)
|
||||
{
|
||||
attributeValue = null;
|
||||
|
||||
var matchingAttribute = _attributes.FirstOrDefault(
|
||||
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
|
||||
);
|
||||
|
||||
if (matchingAttribute is HtmlValueAttribute valueAttribute)
|
||||
attributeValue = valueAttribute.Value;
|
||||
|
||||
return attributeValue is not null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal class HtmlValueAttribute(string name, string value) : HtmlAttribute(name)
|
||||
{
|
||||
public string Value { get; } = value;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal interface IHtmlAttribute
|
||||
{
|
||||
string Name { get; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
public interface IHtmlNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Searches this node and its direct children for a matching tag name.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to match.</param>
|
||||
/// <param name="node">If found, the node that matches the tag name.</param>
|
||||
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||
/// <returns>True if this node, or any of its direct children, have a matching tag name; false otherwise.</returns>
|
||||
bool TryGetByTagName(
|
||||
string tagName,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false);
|
||||
|
||||
/// <summary>
|
||||
/// Searches this node and its direct children for a matching class.
|
||||
/// </summary>
|
||||
/// <param name="className">The class name to match.</param>
|
||||
/// <param name="node">If found, the node that matches the class name.</param>
|
||||
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||
/// <returns>True if this node, or any of its direct children, have a matching class name; false otherwise.</returns>
|
||||
bool TryGetByClass(
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false);
|
||||
|
||||
/// <summary>
|
||||
/// Searches this node and its direct children for a matching tag name and matching class on the same node.
|
||||
/// </summary>
|
||||
/// <param name="tagName">The tag name to match.</param>
|
||||
/// <param name="className">The class name to match.</param>
|
||||
/// <param name="node">If found, the node that matches the tag name and class name.</param>
|
||||
/// <param name="noChildren">If true, only check this node, not its children.</param>
|
||||
/// <returns>True if this node, or any of its direct children, have a matching tag name and class name; false otherwise.</returns>
|
||||
bool TryGetByTagNameAndClass(
|
||||
string tagName,
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false);
|
||||
|
||||
/// <summary>
|
||||
/// Checks this node (and *not* its children) to see if it is text content.
|
||||
/// </summary>
|
||||
/// <param name="text">If found, the text content.</param>
|
||||
/// <returns>True if the node is a text node, false otherwise.</returns>
|
||||
bool TryGetText([NotNullWhen(true)] out string? text);
|
||||
|
||||
/// <summary>
|
||||
/// Checks this node to see if it has child elements.
|
||||
/// </summary>
|
||||
/// <param name="children">If found, the child elements.</param>
|
||||
/// <returns>True if the node has child elements, false otherwise.</returns>
|
||||
bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children);
|
||||
|
||||
bool HasAttribute(string attributeName);
|
||||
|
||||
bool TryGetAttributeValue(
|
||||
string attributeName,
|
||||
[NotNullWhen(true)] out string? attributeValue);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
|
||||
: HtmlNode(tagName, attributes)
|
||||
{
|
||||
private readonly IHtmlNode[] _children = children;
|
||||
|
||||
public override bool TryGetByTagName(
|
||||
string tagName,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
|
||||
{
|
||||
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||
while (nodesToCheck.TryDequeue(out var child))
|
||||
{
|
||||
if (child.TryGetByTagName(tagName, out var childNode, true))
|
||||
{
|
||||
node = childNode;
|
||||
break;
|
||||
}
|
||||
|
||||
if (child.TryGetChildren(out var grandChildren))
|
||||
foreach (var grandChild in grandChildren)
|
||||
nodesToCheck.Enqueue(grandChild);
|
||||
}
|
||||
}
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
public override bool TryGetByClass(
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
|
||||
{
|
||||
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||
while (nodesToCheck.TryDequeue(out var child))
|
||||
{
|
||||
if (child.TryGetByClass(className, out var childNode, true))
|
||||
{
|
||||
node = childNode;
|
||||
break;
|
||||
}
|
||||
|
||||
if (child.TryGetChildren(out var grandChildren))
|
||||
foreach (var grandChild in grandChildren)
|
||||
nodesToCheck.Enqueue(grandChild);
|
||||
}
|
||||
}
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
public override bool TryGetByTagNameAndClass(
|
||||
string tagName,
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
|
||||
{
|
||||
var nodesToCheck = new Queue<IHtmlNode>(_children);
|
||||
while (nodesToCheck.TryDequeue(out var child))
|
||||
{
|
||||
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
|
||||
{
|
||||
node = childNode;
|
||||
break;
|
||||
}
|
||||
|
||||
if (child.TryGetChildren(out var grandChildren))
|
||||
foreach (var grandChild in grandChildren)
|
||||
nodesToCheck.Enqueue(grandChild);
|
||||
}
|
||||
}
|
||||
|
||||
return node is not null;
|
||||
}
|
||||
|
||||
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||
{
|
||||
children = _children;
|
||||
return _children.Length != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Solicitors.HtmlParsing.Models;
|
||||
|
||||
internal class StringNode(string content)
|
||||
: IHtmlNode
|
||||
{
|
||||
private readonly string _content = content;
|
||||
|
||||
public bool TryGetByTagName(
|
||||
string tagName,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetByClass(
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetByTagNameAndClass(
|
||||
string tagName,
|
||||
string className,
|
||||
[NotNullWhen(true)] out IHtmlNode? node,
|
||||
bool noChildren = false)
|
||||
{
|
||||
node = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetText([NotNullWhen(true)] out string? text)
|
||||
{
|
||||
text = _content;
|
||||
return !string.IsNullOrEmpty(text);
|
||||
}
|
||||
|
||||
public bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
|
||||
{
|
||||
children = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool HasAttribute(string attributeName) => false;
|
||||
|
||||
public bool TryGetAttributeValue(
|
||||
string attributeName,
|
||||
[NotNullWhen(true)] out string? attributeValue)
|
||||
{
|
||||
attributeValue = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user