diff --git a/BinDays.Api.Collectors/Collectors/Councils/BroxtoweBoroughCouncil.cs b/BinDays.Api.Collectors/Collectors/Councils/BroxtoweBoroughCouncil.cs
new file mode 100644
index 0000000..1347d0e
--- /dev/null
+++ b/BinDays.Api.Collectors/Collectors/Councils/BroxtoweBoroughCouncil.cs
@@ -0,0 +1,412 @@
+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.Linq;
+using System.Net;
+using System.Text.RegularExpressions;
+
+///
+/// Collector implementation for Broxtowe Borough Council.
+///
+internal sealed partial class BroxtoweBoroughCouncil : GovUkCollectorBase, ICollector
+{
+ ///
+ public string Name => "Broxtowe Borough Council";
+
+ ///
+ public Uri WebsiteUrl => new("https://www.broxtowe.gov.uk/");
+
+ ///
+ public override string GovUkId => "broxtowe";
+
+ ///
+ /// The list of bin types for this collector.
+ ///
+ private readonly IReadOnlyCollection _binTypes =
+ [
+ new()
+ {
+ Name = "Mixed Dry Recycling",
+ Colour = BinColour.Green,
+ Keys = [ "GREEN 240L", ],
+ },
+ new()
+ {
+ Name = "Glass Recycling",
+ Colour = BinColour.Green,
+ Keys = [ "GLASS BAG", ],
+ Type = BinType.Bag,
+ },
+ new()
+ {
+ Name = "Garden Waste",
+ Colour = BinColour.Brown,
+ Keys = [ "BROWN 240L", ],
+ },
+ new()
+ {
+ Name = "General Waste",
+ Colour = BinColour.Black,
+ Keys = [ "BLACK 240L", ],
+ },
+ ];
+
+ ///
+ /// The URL of the web form for bin collection lookups.
+ ///
+ private const string _formUrl = "https://selfservice.broxtowe.gov.uk/renderform.aspx?t=217&k=9D2EF214E144EE796430597FB475C3892C43C528";
+
+ ///
+ /// The ASP.NET script manager target for AJAX requests.
+ ///
+ private const string _scriptManagerTarget = "ctl00$ContentPlaceHolder1$APUP_5683";
+
+ ///
+ /// The event target for postcode search button.
+ ///
+ private const string _searchEventTarget = "ctl00$ContentPlaceHolder1$FF5683BTN";
+
+ ///
+ /// The event target for address dropdown selection.
+ ///
+ private const string _addressEventTarget = "ctl00$ContentPlaceHolder1$FF5683DDL";
+
+ ///
+ /// Regex for parsing hidden fields from AJAX responses.
+ ///
+ [GeneratedRegex(@"hiddenField\|(?__VIEWSTATEGENERATOR|__EVENTVALIDATION|__VIEWSTATE)\|(?[^|]+)")]
+ private static partial Regex AjaxHiddenFieldRegex();
+
+ ///
+ /// Regex for parsing hidden fields from HTML responses.
+ ///
+ [GeneratedRegex(@"name=""(?__VIEWSTATEGENERATOR|__EVENTVALIDATION|__VIEWSTATE)""[^>]+value=""(?[^""]+)""")]
+ private static partial Regex HtmlHiddenFieldRegex();
+
+ ///
+ /// Regex for parsing address options.
+ ///
+ [GeneratedRegex(@"")]
+ private static partial Regex AddressRegex();
+
+ ///
+ /// Regex for parsing bin rows.
+ ///
+ [GeneratedRegex(@"\s*| (?[^<]+) | \s*(?[^<]+) | \s*(?[^<]*) | \s*(?[^<]+) | \s*
", RegexOptions.Singleline)]
+ private static partial Regex BinRowRegex();
+
+ ///
+ public GetAddressesResponse GetAddresses(string postcode, ClientSideResponse? clientSideResponse)
+ {
+ // Prepare client-side request for initial form load
+ if (clientSideResponse == null)
+ {
+ var clientSideRequest = CreateInitialFormRequest();
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getAddressesResponse;
+ }
+ // Prepare client-side request for postcode search
+ else if (clientSideResponse.RequestId == 1)
+ {
+ var clientSideRequest = CreatePostcodeSearchRequest(clientSideResponse, postcode);
+
+ 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 uid = rawAddress.Groups["uid"].Value;
+
+ if (string.IsNullOrWhiteSpace(uid) || uid == "0")
+ {
+ continue;
+ }
+
+ var property = WebUtility.HtmlDecode(rawAddress.Groups["address"].Value).Trim();
+
+ var address = new Address
+ {
+ Property = property,
+ Postcode = postcode,
+ Uid = uid,
+ };
+
+ addresses.Add(address);
+ }
+
+ var getAddressesResponse = new GetAddressesResponse
+ {
+ Addresses = [.. addresses],
+ };
+
+ return getAddressesResponse;
+ }
+
+ throw new InvalidOperationException("Invalid client-side request.");
+ }
+
+ ///
+ public GetBinDaysResponse GetBinDays(Address address, ClientSideResponse? clientSideResponse)
+ {
+ // Prepare client-side request for initial form load
+ if (clientSideResponse == null)
+ {
+ var clientSideRequest = CreateInitialFormRequest();
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request for postcode search
+ else if (clientSideResponse.RequestId == 1)
+ {
+ var clientSideRequest = CreatePostcodeSearchRequest(clientSideResponse, address.Postcode!);
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request for selecting the address
+ else if (clientSideResponse.RequestId == 2)
+ {
+ var cookie = clientSideResponse.Options.Metadata["cookie"];
+
+ var viewState = GetHiddenField(clientSideResponse.Content, "__VIEWSTATE");
+ var viewStateGenerator = GetHiddenField(clientSideResponse.Content, "__VIEWSTATEGENERATOR");
+ var eventValidation = GetHiddenField(clientSideResponse.Content, "__EVENTVALIDATION");
+
+ var formData = new Dictionary
+ {
+ { "ctl00$ScriptManager1", $"{_scriptManagerTarget}|{_addressEventTarget}" },
+ { "ctl00$ContentPlaceHolder1$FF5683DDL", address.Uid! },
+ { "__EVENTTARGET", _addressEventTarget },
+ { "__VIEWSTATE", viewState },
+ { "__VIEWSTATEGENERATOR", viewStateGenerator },
+ { "__EVENTVALIDATION", eventValidation },
+ { "__ASYNCPOST", "true" },
+ };
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 3,
+ Url = _formUrl,
+ Method = "POST",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ { "x-requested-with", "XMLHttpRequest" },
+ { "x-microsoftajax", "Delta=true" },
+ { "content-type", "application/x-www-form-urlencoded; charset=utf-8" },
+ { "cookie", cookie },
+ },
+ Body = ProcessingUtilities.ConvertDictionaryToFormData(formData),
+ Options = new ClientSideOptions
+ {
+ Metadata =
+ {
+ { "cookie", cookie },
+ },
+ },
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Prepare client-side request to fetch bin collections
+ else if (clientSideResponse.RequestId == 3)
+ {
+ var cookie = clientSideResponse.Options.Metadata["cookie"];
+
+ var viewState = GetHiddenField(clientSideResponse.Content, "__VIEWSTATE");
+ var viewStateGenerator = GetHiddenField(clientSideResponse.Content, "__VIEWSTATEGENERATOR");
+ var eventValidation = GetHiddenField(clientSideResponse.Content, "__EVENTVALIDATION");
+
+ var formData = new Dictionary
+ {
+ { "__EVENTTARGET", "ctl00$ContentPlaceHolder1$btnSubmit" },
+ { "__VIEWSTATE", viewState },
+ { "__VIEWSTATEGENERATOR", viewStateGenerator },
+ { "__EVENTVALIDATION", eventValidation },
+ };
+
+ var clientSideRequest = new ClientSideRequest
+ {
+ RequestId = 4,
+ Url = _formUrl,
+ Method = "POST",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ { "content-type", "application/x-www-form-urlencoded" },
+ { "cookie", cookie },
+ },
+ Body = ProcessingUtilities.ConvertDictionaryToFormData(formData),
+ };
+
+ var getBinDaysResponse = new GetBinDaysResponse
+ {
+ NextClientSideRequest = clientSideRequest,
+ };
+
+ return getBinDaysResponse;
+ }
+ // Process bin days from response
+ else if (clientSideResponse.RequestId == 4)
+ {
+ var rawBinRows = BinRowRegex().Matches(clientSideResponse.Content)!;
+
+ // Iterate through each bin row, and create a new bin day object
+ var binDays = new List();
+ foreach (Match rawBinRow in rawBinRows)
+ {
+ var service = WebUtility.HtmlDecode(rawBinRow.Groups["service"].Value).Trim();
+ var nextCollection = WebUtility.HtmlDecode(rawBinRow.Groups["next"].Value).Trim();
+
+ var date = DateOnly.ParseExact(
+ nextCollection,
+ "dddd, dd 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 new InvalidOperationException("Invalid client-side request.");
+ }
+
+ ///
+ /// Creates the initial client-side request to load the form.
+ ///
+ private static ClientSideRequest CreateInitialFormRequest()
+ {
+ return new ClientSideRequest
+ {
+ RequestId = 1,
+ Url = _formUrl,
+ Method = "GET",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ },
+ };
+ }
+
+ ///
+ /// Creates a client-side request for postcode search.
+ ///
+ private static ClientSideRequest CreatePostcodeSearchRequest(ClientSideResponse clientSideResponse, string postcode)
+ {
+ clientSideResponse.Headers.TryGetValue("set-cookie", out var setCookieHeader);
+ var cookie = ProcessingUtilities.ParseSetCookieHeaderForRequestCookie(setCookieHeader!);
+
+ var viewState = GetHiddenField(clientSideResponse.Content, "__VIEWSTATE");
+ var viewStateGenerator = GetHiddenField(clientSideResponse.Content, "__VIEWSTATEGENERATOR");
+ var eventValidation = GetHiddenField(clientSideResponse.Content, "__EVENTVALIDATION");
+
+ var formData = new Dictionary
+ {
+ { "ctl00$ScriptManager1", $"{_scriptManagerTarget}|{_searchEventTarget}" },
+ { "__EVENTTARGET", _searchEventTarget },
+ { "__VIEWSTATE", viewState },
+ { "__VIEWSTATEGENERATOR", viewStateGenerator },
+ { "__EVENTVALIDATION", eventValidation },
+ { "ctl00$ContentPlaceHolder1$FF5683TB", postcode },
+ { "__ASYNCPOST", "true" },
+ };
+
+ return new ClientSideRequest
+ {
+ RequestId = 2,
+ Url = _formUrl,
+ Method = "POST",
+ Headers = new()
+ {
+ { "user-agent", Constants.UserAgent },
+ { "x-requested-with", "XMLHttpRequest" },
+ { "x-microsoftajax", "Delta=true" },
+ { "content-type", "application/x-www-form-urlencoded; charset=utf-8" },
+ { "cookie", cookie },
+ },
+ Body = ProcessingUtilities.ConvertDictionaryToFormData(formData),
+ Options = new ClientSideOptions
+ {
+ Metadata =
+ {
+ { "cookie", cookie },
+ },
+ },
+ };
+ }
+
+ ///
+ /// Extracts hidden field values from HTML or AJAX responses.
+ ///
+ private static string GetHiddenField(string content, string fieldName)
+ {
+ // First, try to find the field in the AJAX response format.
+ var ajaxMatch = AjaxHiddenFieldRegex().Matches(content)!.FirstOrDefault(m => m.Groups["name"].Value == fieldName);
+ if (ajaxMatch is not null)
+ {
+ return ajaxMatch.Groups["value"].Value;
+ }
+
+ // If not found, try the standard HTML input format.
+ var htmlMatch = HtmlHiddenFieldRegex().Matches(content)!.FirstOrDefault(m => m.Groups["name"].Value == fieldName);
+ if (htmlMatch is not null)
+ {
+ return htmlMatch.Groups["value"].Value;
+ }
+
+ // If the field is not found in either format, throw an exception.
+ throw new InvalidOperationException($"Hidden field '{fieldName}' not found in response content.");
+ }
+}
diff --git a/BinDays.Api.IntegrationTests/Collectors/Councils/BroxtoweBoroughCouncilTests.cs b/BinDays.Api.IntegrationTests/Collectors/Councils/BroxtoweBoroughCouncilTests.cs
new file mode 100644
index 0000000..206d09d
--- /dev/null
+++ b/BinDays.Api.IntegrationTests/Collectors/Councils/BroxtoweBoroughCouncilTests.cs
@@ -0,0 +1,36 @@
+namespace BinDays.Api.IntegrationTests.Collectors.Councils;
+
+using BinDays.Api.Collectors.Collectors;
+using BinDays.Api.Collectors.Collectors.Councils;
+using BinDays.Api.Collectors.Services;
+using BinDays.Api.IntegrationTests.Helpers;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+public class BroxtoweBoroughCouncilTests
+{
+ private readonly IntegrationTestClient _client;
+ private static readonly ICollector _collector = new BroxtoweBoroughCouncil();
+ private readonly CollectorService _collectorService = new([_collector]);
+ private readonly ITestOutputHelper _outputHelper;
+
+ public BroxtoweBoroughCouncilTests(ITestOutputHelper outputHelper)
+ {
+ _outputHelper = outputHelper;
+ _client = new IntegrationTestClient(outputHelper);
+ }
+
+ [Theory]
+ [InlineData("NG16 2NB")]
+ public async Task GetBinDaysTest(string postcode)
+ {
+ await TestSteps.EndToEnd(
+ _client,
+ _collectorService,
+ _collector,
+ postcode,
+ _outputHelper
+ );
+ }
+}