with aria-label="Last updated"
+ var metadataSection = page.Locator("section[data-testid='weatherMetadata'], section[aria-label='Last updated']").First;
+
+ // Extract text from each div separately to preserve structure
+ // Structure: Observations: X minutes ago, 9:40 pm AEST
at Gympie...
Forecast: ...
+ var lastUpdatedText = await page.EvaluateAsync(@"() => {
+ const section = document.querySelector('section[data-testid=""weatherMetadata""]') ||
+ document.querySelector('section[aria-label=""Last updated""]');
+ if (!section) return null;
+
+ // Get all divs and join with newlines to preserve structure
+ const divs = section.querySelectorAll('div');
+ return Array.from(divs).map(div => div.textContent.trim()).filter(text => text).join(' ');
+ }");
+
+ if (string.IsNullOrEmpty(lastUpdatedText))
+ {
+ // Fallback: get all text content
+ lastUpdatedText = await metadataSection.TextContentAsync();
+ }
+
+ return ParseLastUpdatedText(lastUpdatedText ?? string.Empty);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to extract last updated information");
+ return new LastUpdatedInfo
+ {
+ ObservationTime = DateTime.UtcNow,
+ ForecastTime = DateTime.UtcNow
+ };
+ }
+ }
+
+ ///
+ /// Parses last updated text to extract observation time, forecast time, weather station, and distance
+ ///
+ public LastUpdatedInfo ParseLastUpdatedText(string text)
+ {
+ var info = new LastUpdatedInfo();
+ _logger.LogInformation("Parsing last updated text: {Text}", text);
+
+ // Try to parse "Observations: 11 minutes ago, 8:20 pm AEST at Gympie weather station, 30 km from Pomona, QLD"
+ // And "Forecast: 41 minutes ago, 7:50 pm AEST"
+ // Prefer parsing the actual time string over "minutes ago" for accuracy
+
+ // Parse observation time - improved regex to better capture time and timezone
+ // Stop timezone capture before "at" (e.g., "AEST at Gympie" or "AESTat Gympie" should capture just "AEST")
+ // Pattern: "Observations: X minutes ago, 9:40 pm AESTat Gympie..." (note: sometimes no space between AEST and "at")
+ // Capture timezone as 3-4 uppercase letters (AEST, AEDT, etc.) optionally followed by "at"
+ var observationMatch = Regex.Match(text, @"Observations:\s*(?:\d+\s*minutes?\s*ago)?[,\s]+([\d:]+(?:\s*[ap]m)?)\s+([A-Z]{3,4})(?:at|\s+at|,|$)", RegexOptions.IgnoreCase);
+ _logger.LogDebug("Observation regex match result: Success={Success}, Groups.Count={Count}", observationMatch.Success, observationMatch.Groups.Count);
+ if (observationMatch.Success && observationMatch.Groups.Count >= 3 && observationMatch.Groups[1].Success)
+ {
+ var timeStr = observationMatch.Groups[1].Value.Trim();
+ var timezoneStr = observationMatch.Groups[2].Success ? observationMatch.Groups[2].Value.Trim() : null;
+
+ // Clean up timezone string - remove "at" if it got concatenated (e.g., "AESTat" -> "AEST")
+ if (!string.IsNullOrEmpty(timezoneStr) && timezoneStr.EndsWith("at", StringComparison.OrdinalIgnoreCase) && timezoneStr.Length > 2)
+ {
+ timezoneStr = timezoneStr.Substring(0, timezoneStr.Length - 2);
+ }
+
+ _logger.LogDebug("Extracted observation time string: '{TimeStr}', timezone: '{TzStr}'", timeStr, timezoneStr ?? "null");
+
+ if (TryParseTimeString(timeStr, timezoneStr, out var observationTime))
+ {
+ info.ObservationTime = observationTime;
+ _logger.LogInformation("Successfully parsed observation time: {Time} UTC (from '{TimeStr}' {TzStr})",
+ observationTime, timeStr, timezoneStr ?? "default timezone");
+ }
+ else
+ {
+ // Fallback to "minutes ago" if time parsing fails
+ _logger.LogWarning("Failed to parse observation time from '{TimeStr}', falling back to 'minutes ago'", timeStr);
+ var minutesAgoMatch = Regex.Match(text, @"Observations:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
+ if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
+ {
+ info.ObservationTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
+ _logger.LogInformation("Used 'minutes ago' fallback: {MinutesAgo} minutes ago = {Time} UTC",
+ minutesAgo, info.ObservationTime);
+ }
+ }
+ }
+
+ // Parse forecast time
+ var forecastMatch = Regex.Match(text, @"Forecast:\s*(?:\d+\s*minutes?\s*ago)?[,\s]*([\d:]+(?:\s*[ap]m)?)\s+([A-Z]+)(?:\s+at|$)", RegexOptions.IgnoreCase);
+ if (forecastMatch.Success && forecastMatch.Groups[1].Success)
+ {
+ var timeStr = forecastMatch.Groups[1].Value.Trim();
+ var timezoneStr = forecastMatch.Groups[2].Success ? forecastMatch.Groups[2].Value.Trim() : null;
+
+ if (TryParseTimeString(timeStr, timezoneStr, out var forecastTime))
+ {
+ info.ForecastTime = forecastTime;
+ }
+ else
+ {
+ // Fallback to "minutes ago" if time parsing fails
+ var minutesAgoMatch = Regex.Match(text, @"Forecast:\s*(\d+)\s*minutes?\s*ago", RegexOptions.IgnoreCase);
+ if (minutesAgoMatch.Success && int.TryParse(minutesAgoMatch.Groups[1].Value, out var minutesAgo))
+ {
+ info.ForecastTime = DateTime.UtcNow.AddMinutes(-minutesAgo);
+ }
+ }
+ }
+
+ // Extract weather station name
+ var stationMatch = Regex.Match(text, @"at\s+([^,]+)\s+weather\s+station", RegexOptions.IgnoreCase);
+ if (stationMatch.Success)
+ {
+ info.WeatherStation = stationMatch.Groups[1].Value.Trim();
+ }
+
+ // Extract distance
+ var distanceMatch = Regex.Match(text, @"(\d+)\s*km\s+from", RegexOptions.IgnoreCase);
+ if (distanceMatch.Success)
+ {
+ info.Distance = $"{distanceMatch.Groups[1].Value} km";
+ }
+
+ return info;
+ }
+
+ ///
+ /// Tries to parse a time string with optional timezone to UTC
+ ///
+ private bool TryParseTimeString(string timeStr, string? timezoneStr, out DateTime utcTime)
+ {
+ utcTime = DateTime.UtcNow;
+
+ try
+ {
+ // Parse time formats like "8:20 pm", "8:20pm", "20:20"
+ var timeFormats = new[] { "h:mm tt", "h:mmtt", "HH:mm", "H:mm tt", "H:mmtt", "H:mm" };
+ DateTime localTime = default;
+
+ // Try parsing with various formats
+ bool parsed = false;
+ foreach (var format in timeFormats)
+ {
+ if (DateTime.TryParseExact(timeStr, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedTime))
+ {
+ localTime = parsedTime;
+ parsed = true;
+ break;
+ }
+ }
+
+ if (!parsed)
+ {
+ // Fallback to general parsing
+ if (!DateTime.TryParse(timeStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out localTime))
+ {
+ return false;
+ }
+ }
+
+ // Determine the timezone - use configured timezone if not specified or if it's AEST/AEDT
+ TimeZoneInfo tz = _timeZoneInfo;
+ if (!string.IsNullOrEmpty(timezoneStr))
+ {
+ // AEST/AEDT are typically the same as Australia/Brisbane or Australia/Sydney
+ // Both Brisbane and Sydney use AEST/AEDT
+ if (timezoneStr.Contains("AEST", StringComparison.OrdinalIgnoreCase) ||
+ timezoneStr.Contains("AEDT", StringComparison.OrdinalIgnoreCase))
+ {
+ tz = _timeZoneInfo; // Use configured timezone
+ }
+ else
+ {
+ // Try to find the timezone, fallback to configured
+ try
+ {
+ tz = TimeZoneInfo.FindSystemTimeZoneById(timezoneStr);
+ }
+ catch
+ {
+ tz = _timeZoneInfo;
+ }
+ }
+ }
+
+ // Get current time in the target timezone to determine the date
+ var nowInTz = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
+ var dateTimeInTz = nowInTz.Date.Add(localTime.TimeOfDay);
+
+ // If the parsed time is in the future (e.g., it's 9pm but we parsed 8pm and it's actually yesterday),
+ // or if it's more than 12 hours in the past, assume it's from yesterday
+ if (dateTimeInTz > nowInTz)
+ {
+ // Time is in the future, so it must be from yesterday
+ dateTimeInTz = dateTimeInTz.AddDays(-1);
+ }
+ else if ((nowInTz - dateTimeInTz).TotalHours > 12)
+ {
+ // More than 12 hours ago, likely from yesterday
+ dateTimeInTz = dateTimeInTz.AddDays(-1);
+ }
+
+ // Convert to UTC
+ // Note: Brisbane (Australia/Brisbane) is always UTC+10 (AEST), no daylight saving
+ utcTime = TimeZoneInfo.ConvertTimeToUtc(dateTimeInTz, tz);
+
+ _logger.LogInformation("Parsed time string '{TimeStr}' with timezone '{TzStr}' as {DateTimeInTz} local ({UtcTime} UTC)",
+ timeStr, timezoneStr ?? "default", dateTimeInTz, utcTime);
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
+
diff --git a/Utilities/FilePathHelper.cs b/Utilities/FilePathHelper.cs
new file mode 100644
index 0000000..ffa3726
--- /dev/null
+++ b/Utilities/FilePathHelper.cs
@@ -0,0 +1,40 @@
+namespace BomLocalService.Utilities;
+
+public static class FilePathHelper
+{
+ ///
+ /// Gets the cache directory path from configuration or returns default
+ ///
+ public static string GetCacheDirectory(IConfiguration configuration)
+ {
+ return configuration.GetValue("CacheDirectory")
+ ?? Path.Combine(AppContext.BaseDirectory, "cache");
+ }
+
+ ///
+ /// Gets the metadata file path for a given screenshot path
+ ///
+ public static string GetMetadataFilePath(string screenshotPath)
+ {
+ return Path.ChangeExtension(screenshotPath, ".json");
+ }
+
+ ///
+ /// Gets the cache file pattern for a location key (e.g., "Pomona_QLD_*.png")
+ ///
+ public static string GetCacheFilePattern(string locationKey)
+ {
+ var safeLocationKey = LocationHelper.SanitizeFileName(locationKey);
+ return $"{safeLocationKey}_*.png";
+ }
+
+ ///
+ /// Gets the cache file pattern for suburb and state
+ ///
+ public static string GetCacheFilePattern(string suburb, string state)
+ {
+ var locationKey = LocationHelper.GetLocationKey(suburb, state);
+ return GetCacheFilePattern(locationKey);
+ }
+}
+
diff --git a/Utilities/LocationHelper.cs b/Utilities/LocationHelper.cs
new file mode 100644
index 0000000..5526bc4
--- /dev/null
+++ b/Utilities/LocationHelper.cs
@@ -0,0 +1,63 @@
+namespace BomLocalService.Utilities;
+
+public static class LocationHelper
+{
+ ///
+ /// Creates a location key from suburb and state (e.g., "Pomona_QLD")
+ ///
+ public static string GetLocationKey(string suburb, string state)
+ {
+ return $"{suburb}_{state}";
+ }
+
+ ///
+ /// Sanitizes a filename by removing invalid characters and replacing spaces/commas
+ ///
+ public static string SanitizeFileName(string fileName)
+ {
+ var invalidChars = Path.GetInvalidFileNameChars();
+ return string.Join("_", fileName.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries))
+ .Replace(" ", "_")
+ .Replace(",", "");
+ }
+
+ ///
+ /// Parses suburb and state from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS.png)
+ /// Returns null if parsing fails
+ ///
+ public static (string suburb, string state)? ParseLocationFromFilename(string fileName)
+ {
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
+ var parts = fileNameWithoutExtension.Split('_');
+
+ if (parts.Length >= 2)
+ {
+ return (parts[0], parts[1]);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses timestamp from a cache filename (format: Suburb_State_YYYYMMDD_HHMMSS.png)
+ /// Returns null if parsing fails
+ ///
+ public static DateTime? ParseTimestampFromFilename(string fileName)
+ {
+ var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
+ var parts = fileNameWithoutExtension.Split('_');
+
+ if (parts.Length >= 3)
+ {
+ // Last two parts should be date and time
+ var dateTimeStr = $"{parts[^2]}_{parts[^1]}";
+ if (DateTime.TryParseExact(dateTimeStr, "yyyyMMdd_HHmmss", null, System.Globalization.DateTimeStyles.None, out var fileTime))
+ {
+ return fileTime;
+ }
+ }
+
+ return null;
+ }
+}
+
diff --git a/Utilities/ResponseBuilder.cs b/Utilities/ResponseBuilder.cs
new file mode 100644
index 0000000..c228c8c
--- /dev/null
+++ b/Utilities/ResponseBuilder.cs
@@ -0,0 +1,40 @@
+using BomLocalService.Models;
+
+namespace BomLocalService.Utilities;
+
+public static class ResponseBuilder
+{
+ ///
+ /// Creates a RadarScreenshotResponse from a file path and metadata
+ ///
+ public static RadarScreenshotResponse CreateRadarScreenshotResponse(
+ string imagePath,
+ LastUpdatedInfo? metadata = null)
+ {
+ var lastWriteTime = File.Exists(imagePath)
+ ? File.GetLastWriteTime(imagePath)
+ : DateTime.UtcNow;
+
+ if (metadata == null)
+ {
+ return new RadarScreenshotResponse
+ {
+ ImagePath = imagePath,
+ LastUpdated = lastWriteTime,
+ ObservationTime = DateTime.UtcNow,
+ ForecastTime = DateTime.UtcNow
+ };
+ }
+
+ return new RadarScreenshotResponse
+ {
+ ImagePath = imagePath,
+ LastUpdated = lastWriteTime,
+ ObservationTime = metadata.ObservationTime,
+ ForecastTime = metadata.ForecastTime,
+ WeatherStation = metadata.WeatherStation,
+ Distance = metadata.Distance
+ };
+ }
+}
+
diff --git a/Utilities/SelectorHelper.cs b/Utilities/SelectorHelper.cs
new file mode 100644
index 0000000..b38f9ef
--- /dev/null
+++ b/Utilities/SelectorHelper.cs
@@ -0,0 +1,37 @@
+using Microsoft.Playwright;
+
+namespace BomLocalService.Utilities;
+
+public static class SelectorHelper
+{
+ ///
+ /// Tries to find a locator by trying multiple selectors in order
+ /// Returns the first visible locator found, or null if none found
+ ///
+ public static async Task FindLocatorBySelectorsAsync(
+ IPage page,
+ string[] selectors,
+ ILogger? logger = null)
+ {
+ foreach (var selector in selectors)
+ {
+ try
+ {
+ var locator = page.Locator(selector).First;
+ if (await locator.IsVisibleAsync())
+ {
+ logger?.LogInformation("Found element with selector: {Selector}", selector);
+ return locator;
+ }
+ }
+ catch
+ {
+ // Continue to next selector
+ continue;
+ }
+ }
+
+ return null;
+ }
+}
+
diff --git a/Utilities/StateAbbreviationHelper.cs b/Utilities/StateAbbreviationHelper.cs
new file mode 100644
index 0000000..c4c6e4b
--- /dev/null
+++ b/Utilities/StateAbbreviationHelper.cs
@@ -0,0 +1,45 @@
+namespace BomLocalService.Utilities;
+
+public static class StateAbbreviationHelper
+{
+ private static readonly Dictionary StateMappings = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "qld", new[] { "queensland" } },
+ { "nsw", new[] { "new south wales" } },
+ { "vic", new[] { "victoria" } },
+ { "sa", new[] { "south australia" } },
+ { "wa", new[] { "western australia" } },
+ { "tas", new[] { "tasmania" } },
+ { "nt", new[] { "northern territory" } },
+ { "act", new[] { "australian capital territory" } }
+ };
+
+ ///
+ /// Checks if the text matches the state abbreviation or full state name
+ ///
+ public static bool MatchesState(string text, string stateAbbreviation)
+ {
+ if (string.IsNullOrWhiteSpace(stateAbbreviation))
+ {
+ return true; // No state specified, match any
+ }
+
+ var textLower = text.ToLower();
+ var stateLower = stateAbbreviation.ToLower().Trim();
+
+ // Direct match
+ if (textLower.Contains(stateLower))
+ {
+ return true;
+ }
+
+ // Check abbreviation mappings
+ if (StateMappings.TryGetValue(stateLower, out var fullNames))
+ {
+ return fullNames.Any(fullName => textLower.Contains(fullName));
+ }
+
+ return false;
+ }
+}
+
diff --git a/Utilities/ValidationHelper.cs b/Utilities/ValidationHelper.cs
new file mode 100644
index 0000000..769587f
--- /dev/null
+++ b/Utilities/ValidationHelper.cs
@@ -0,0 +1,24 @@
+namespace BomLocalService.Utilities;
+
+public static class ValidationHelper
+{
+ ///
+ /// Validates suburb and state parameters
+ /// Returns error message if validation fails, null if valid
+ ///
+ public static string? ValidateLocation(string suburb, string state)
+ {
+ if (string.IsNullOrWhiteSpace(suburb))
+ {
+ return "Suburb is required";
+ }
+
+ if (string.IsNullOrWhiteSpace(state))
+ {
+ return "State is required";
+ }
+
+ return null;
+ }
+}
+
diff --git a/appsettings.json b/appsettings.json
new file mode 100644
index 0000000..4260920
--- /dev/null
+++ b/appsettings.json
@@ -0,0 +1,30 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "CacheDirectory": "./cache",
+ "CacheRetentionHours": 24,
+ "CacheExpirationMinutes": 12.5,
+ "CacheManagement": {
+ "CheckIntervalMinutes": 5,
+ "InitialDelaySeconds": 10,
+ "UpdateStaggerSeconds": 2,
+ "LocationStaggerSeconds": 1
+ },
+ "CacheCleanup": {
+ "IntervalHours": 1
+ },
+ "Screenshot": {
+ "DynamicContentWaitMs": 2000,
+ "TileRenderWaitMs": 5000
+ },
+ "Debug": {
+ "Enabled": false,
+ "WaitMs": 2000
+ },
+ "Timezone": "Australia/Brisbane"
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3446d29
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,45 @@
+services:
+ bom-local-service:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: bom-local-service
+ ports:
+ - "${HOST_PORT:-8082}:${CONTAINER_PORT:-8080}"
+ volumes:
+ - ./cache:/app/cache
+ environment:
+ # ASP.NET Core configuration
+ # Default to Production for security (use Development only for local debugging)
+ - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
+ - ASPNETCORE_URLS=http://+:${CONTAINER_PORT:-8080}
+
+ # Virtual display for non-headless browser
+ - DISPLAY=:99
+
+ # Application configuration (override appsettings.json)
+ # Use double underscore (__) for nested config keys
+ - CACHEDIRECTORY=${CACHEDIRECTORY:-/app/cache}
+ - CACHERETENTIONHOURS=${CACHERETENTIONHOURS:-24}
+ - CACHEEXPIRATIONMINUTES=${CACHEEXPIRATIONMINUTES:-12.5}
+ - CACHEMANAGEMENT__CHECKINTERVALMINUTES=${CACHEMANAGEMENT__CHECKINTERVALMINUTES:-5}
+ - CACHEMANAGEMENT__INITIALDELAYSECONDS=${CACHEMANAGEMENT__INITIALDELAYSECONDS:-10}
+ - CACHEMANAGEMENT__UPDATESTAGGERSECONDS=${CACHEMANAGEMENT__UPDATESTAGGERSECONDS:-2}
+ - CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS=${CACHEMANAGEMENT__LOCATIONSTAGGERSECONDS:-1}
+ - CACHECLEANUP__INTERVALHOURS=${CACHECLEANUP__INTERVALHOURS:-1}
+ - SCREENSHOT__DYNAMICCONTENTWAITMS=${SCREENSHOT__DYNAMICCONTENTWAITMS:-2000}
+ - SCREENSHOT__TILERENDERWAITMS=${SCREENSHOT__TILERENDERWAITMS:-5000}
+ - DEBUG__ENABLED=${DEBUG__ENABLED:-false}
+ - DEBUG__WAITMS=${DEBUG__WAITMS:-2000}
+ - TIMEZONE=${TIMEZONE:-Australia/Brisbane}
+ shm_size: '1gb'
+ ipc: host
+ restart: unless-stopped
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: 1
+ capabilities: [gpu]
+