diff --git a/BinDays.Api.Collectors/Collectors/Councils/NorwichCountyCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/NorwichCountyCouncil.cs new file mode 100644 index 00000000..bb6c89d4 --- /dev/null +++ b/BinDays.Api.Collectors/Collectors/Councils/NorwichCountyCouncil.cs @@ -0,0 +1,222 @@ +namespace BinDays.Api.Collectors.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors.Vendors; +using BinDays.Api.Collectors.Models; +using BinDays.Api.Collectors.Utilities; +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +/// +/// Collector implementation for Norwich County Council. +/// +internal sealed partial class NorwichCountyCouncil : GovUkCollectorBase, ICollector +{ + /// + public string Name => "Norwich County Council"; + + /// + public Uri WebsiteUrl => new("https://norwich.gov.uk/"); + + /// + public override string GovUkId => "norwich"; + + /// + /// The list of bin types for this collector. + /// + private readonly IReadOnlyCollection _binTypes = + [ + new() + { + Name = "General Waste", + Colour = BinColour.Black, + Keys = [ "Domestic Waste Collection Service" ], + }, + new() + { + Name = "Recycling", + Colour = BinColour.Blue, + Keys = [ "Recycling Collection Service" ], + }, + new() + { + Name = "Food Waste", + Colour = BinColour.Green, + Keys = [ "Food Waste Collection Service" ], + Type = BinType.Caddy, + }, + new() + { + Name = "Garden Waste", + Colour = BinColour.Brown, + Keys = [ "Garden Waste Collection Service" ], + }, + ]; + + /// + /// The base URL for the Norwich collection service. + /// + private const string _baseUrl = "https://bnr-wrp.whitespacews.com"; + + /// + /// Regex for extracting the address lookup URL. + /// + [GeneratedRegex(@"
https://bnr-wrp\.whitespacews\.com/mop\.php\?serviceID=A&Track=[^""]+&seq=2)""\s+method=""post""\s+oldTarget=""MoP""\s+align=""left""\s+data-form-title=""Property Lookup Form"">")] + private static partial Regex AddressLookupUrlRegex(); + + /// + /// Regex for extracting addresses from the address list. + /// + [GeneratedRegex(@"href=""mop\.php\?Track=(?[^&""]+)&serviceID=A&seq=3&pIndex=(?\d+)""[^>]*>\s*(?
[^<]+)\s*", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex AddressRegex(); + + /// + /// Regex for extracting bin collection dates and services. + /// + [GeneratedRegex(@"

(?\d{2}/\d{2}/\d{4})

\s*\s*]*>\s*

(?[^<]+)

", RegexOptions.Singleline)] + private static partial Regex BinDaysRegex(); + + /// + public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse) + { + // Prepare client-side request for getting the postcode form + if (clientSideResponse == null) + { + var clientSideRequest = new ClientSideRequest + { + RequestId = 1, + Url = $"{_baseUrl}/?serviceID=A&seq=1", + Method = "GET", + }; + + var getAddressesResponse = new GetAddressesResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getAddressesResponse; + } + // Prepare client-side request for address lookup + else if (clientSideResponse.RequestId == 1) + { + var addressLookupUrl = AddressLookupUrlRegex().Match(clientSideResponse.Content).Groups["addressLookupUrl"].Value; + + var requestBody = $"address_postcode={postcode}"; + + var clientSideRequest = new ClientSideRequest + { + RequestId = 2, + Url = addressLookupUrl, + Method = "POST", + Headers = new() + { + { "user-agent", Constants.UserAgent }, + { "content-type", Constants.FormUrlEncoded }, + }, + Body = requestBody, + }; + + var getAddressesResponse = new GetAddressesResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getAddressesResponse; + } + // Process addresses from response + else if (clientSideResponse.RequestId == 2) + { + var rawAddresses = AddressRegex().Matches(clientSideResponse.Content)!; + + // Iterate through each address, and create a new address object + var addresses = new List
(); + foreach (Match rawAddress in rawAddresses) + { + var track = rawAddress.Groups["track"].Value; + var pIndex = rawAddress.Groups["pIndex"].Value; + + // Uid format: "track;pIndex" + var address = new Address + { + Property = rawAddress.Groups["address"].Value.Trim(), + Postcode = postcode, + Uid = $"{track};{pIndex}", + }; + + addresses.Add(address); + } + + var getAddressesResponse = new GetAddressesResponse + { + Addresses = [.. addresses], + }; + + return getAddressesResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } + + /// + public GetBinDaysResponse GetBinDays(Address address, ClientSideResponse? clientSideResponse) + { + // Uid format: "track;pIndex" + var uidParts = address.Uid!.Split(';', 2); + var track = uidParts[0]; + var pIndex = uidParts[1]; + + // Prepare client-side request for getting bin days + if (clientSideResponse == null) + { + var clientSideRequest = new ClientSideRequest + { + RequestId = 1, + Url = $"{_baseUrl}/mop.php?Track={track}&serviceID=A&seq=3&pIndex={pIndex}", + Method = "GET", + }; + + var getBinDaysResponse = new GetBinDaysResponse + { + NextClientSideRequest = clientSideRequest, + }; + + return getBinDaysResponse; + } + // Process bin days from response + else if (clientSideResponse.RequestId == 1) + { + var rawBinDays = BinDaysRegex().Matches(clientSideResponse.Content)!; + + // Iterate through each bin day, and create a new bin day object + var binDays = new List(); + foreach (Match rawBinDay in rawBinDays) + { + var service = rawBinDay.Groups["service"].Value.Trim(); + var dateText = rawBinDay.Groups["date"].Value; + + var date = DateUtilities.ParseDateExact(dateText, "dd/MM/yyyy"); + var matchedBins = ProcessingUtilities.GetMatchingBins(_binTypes, service); + + var binDay = new BinDay + { + Date = date, + Address = address, + Bins = matchedBins, + }; + + binDays.Add(binDay); + } + + var getBinDaysResponse = new GetBinDaysResponse + { + BinDays = ProcessingUtilities.ProcessBinDays(binDays), + }; + + return getBinDaysResponse; + } + + // Throw exception for invalid request + throw new InvalidOperationException("Invalid client-side request."); + } +} diff --git a/BinDays.Api.IntegrationTests/Collectors/Councils/NorwichCountyCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/NorwichCountyCouncilTests.cs new file mode 100644 index 00000000..32817677 --- /dev/null +++ b/BinDays.Api.IntegrationTests/Collectors/Councils/NorwichCountyCouncilTests.cs @@ -0,0 +1,32 @@ +namespace BinDays.Api.IntegrationTests.Collectors.Councils; + +using BinDays.Api.Collectors.Collectors.Councils; +using BinDays.Api.IntegrationTests.Helpers; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +public class NorwichCountyCouncilTests +{ + private readonly IntegrationTestClient _client; + private readonly ITestOutputHelper _outputHelper; + private static readonly string _govUkId = new NorwichCountyCouncil().GovUkId; + + public NorwichCountyCouncilTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + _client = new IntegrationTestClient(outputHelper); + } + + [Theory] + [InlineData("NR3 3NE")] + public async Task GetBinDaysTest(string postcode) + { + await TestSteps.EndToEnd( + _client, + postcode, + _govUkId, + _outputHelper + ); + } +}