diff --git a/BinDays.Api.Collectors/Collectors/Councils/HarboroughDistrictCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/HarboroughDistrictCouncil.cs
new file mode 100644
index 0000000..95c503b
--- /dev/null
+++ b/BinDays.Api.Collectors/Collectors/Councils/HarboroughDistrictCouncil.cs
@@ -0,0 +1,234 @@
+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.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+///
+/// Collector implementation for Harborough District Council.
+///
+internal sealed partial class HarboroughDistrictCouncil : GovUkCollectorBase, ICollector
+{
+ ///
+ public string Name => "Harborough District Council";
+
+ ///
+ public Uri WebsiteUrl => new("https://www.harborough.gov.uk/");
+
+ ///
+ public override string GovUkId => "harborough";
+
+ ///
+ /// The list of bin types for this collector.
+ ///
+ private readonly IReadOnlyCollection _binTypes =
+ [
+ new()
+ {
+ Name = "General Waste",
+ Colour = BinColour.Black,
+ Keys = [ "Non-recyclable waste" ],
+ },
+ new()
+ {
+ Name = "Mixed Recycling",
+ Colour = BinColour.Blue,
+ Keys = [ "Recycling collection" ],
+ },
+ new()
+ {
+ Name = "Garden Waste",
+ Colour = BinColour.Green,
+ Keys = [ "Garden waste" ],
+ },
+ ];
+
+ ///
+ /// The base URL for the FCC Environment API.
+ ///
+ private const string _baseUrl = "https://harborough.fccenvironment.co.uk/";
+
+ ///
+ /// The value for the x-forwarded-proto header.
+ ///
+ private const string _forwardedProtoHeaderValue = "https";
+
+ ///
+ /// Regex for the bin days list items.
+ ///
+ [GeneratedRegex(@"\s*(?[^<]+?)\s*]*>\s*(?[^<]+)\s*\s*", RegexOptions.IgnoreCase)]
+ private static partial Regex BinDaysRegex();
+
+ ///
+ /// Regex for the next scheduled bin collection block.
+ ///
+ [GeneratedRegex(@"block-your-next-scheduled-bin-collection-days"".*?(?)", RegexOptions.Singleline)]
+ private static partial Regex BinDaysSectionRegex();
+
+ ///
+ public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse)
+ {
+ // Prepare client-side request for getting addresses
+ if (clientSideResponse == null)
+ {
+ var requestBody = JsonSerializer.Serialize(new { Postcode = postcode });
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 1,
+ Url = $"{_baseUrl}getAddress",
+ Method = "POST",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ { "content-type", "application/json" },
+ { "x-forwarded-proto", _forwardedProtoHeaderValue },
+ },
+ Body = requestBody,
+ Options = new ClientSideOptions
+ {
+ FollowRedirects = false,
+ },
+ };
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getAddressesResponse;
+ }
+ // Process addresses from response
+ else if (clientSideResponse.RequestId == 1)
+ {
+ using var jsonDocument = JsonDocument.Parse(clientSideResponse.Content);
+ var addressElements = jsonDocument.RootElement.GetProperty("datas").EnumerateArray();
+
+ // Iterate through each address, and create a new address object
+ var addresses = new List();
+ foreach (var addressElement in addressElements)
+ {
+ var uprn = addressElement.GetProperty("AccountSiteUprn").GetString()!;
+
+ if (string.IsNullOrWhiteSpace(uprn))
+ {
+ continue;
+ }
+
+ var property = addressElement.GetProperty("SiteShortAddress").GetString()!.Trim();
+ var addressLabel = addressElement.GetProperty("SiteShortAddressLabel").GetString()!.Trim();
+
+ // Uid format: "{uprn};{addressLabel}"
+ var address = new Address
+ {
+ Property = property,
+ Postcode = postcode,
+ Uid = $"{uprn};{addressLabel}",
+ };
+
+ 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)
+ {
+ // Prepare client-side request for getting bin days
+ if (clientSideResponse == null)
+ {
+ // Uid format: "{uprn};{addressLabel}"
+ var addressParts = address.Uid!.Split(';', 2);
+ var uprn = addressParts[0];
+ var addressLabel = addressParts[1];
+
+ var requestBody = ProcessingUtilities.ConvertDictionaryToFormData(new()
+ {
+ { "Uprn", uprn },
+ { "hiddenAddressLabel", addressLabel },
+ });
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 1,
+ Url = $"{_baseUrl}detail-address",
+ Method = "POST",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ { "content-type", "application/x-www-form-urlencoded; charset=UTF-8" },
+ { "x-forwarded-proto", _forwardedProtoHeaderValue },
+ },
+ Body = requestBody,
+ Options = new ClientSideOptions
+ {
+ FollowRedirects = false,
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Process bin days from response
+ else if (clientSideResponse.RequestId == 1)
+ {
+ var binDaysContent = BinDaysSectionRegex().Match(clientSideResponse.Content).Groups["content"].Value;
+ var rawBinDays = BinDaysRegex().Matches(binDaysContent)!;
+
+ // 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 collectionDate = rawBinDay.Groups["date"].Value.Trim();
+
+ var date = DateOnly.ParseExact(
+ collectionDate,
+ "d MMMM yyyy",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.None
+ );
+
+ 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/HarboroughDistrictCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/HarboroughDistrictCouncilTests.cs
new file mode 100644
index 0000000..3bea48f
--- /dev/null
+++ b/BinDays.Api.IntegrationTests/Collectors/Councils/HarboroughDistrictCouncilTests.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 HarboroughDistrictCouncilTests
+{
+ private readonly IntegrationTestClient _client;
+ private readonly ITestOutputHelper _outputHelper;
+ private static readonly string _govUkId = new HarboroughDistrictCouncil().GovUkId;
+
+ public HarboroughDistrictCouncilTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ _client = new IntegrationTestClient(outputHelper);
+ }
+
+ [Theory]
+ [InlineData("LE17 5EG")]
+ public async Task GetBinDaysTest(string postcode)
+ {
+ await TestSteps.EndToEnd(
+ _client,
+ postcode,
+ _govUkId,
+ _outputHelper
+ );
+ }
+}